From 7588aeeef4a3c3dbeff4d285450a1f500b7d54d5 Mon Sep 17 00:00:00 2001 From: ango <178757933+nanami7777777@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:13:47 +0800 Subject: [PATCH 1/3] fix: crash on module-level async def with nested def / async for / async with create_objects_array()'s `async` branch assumed the token following `async` was always `def`, so `async for` and `async with` were parsed as function definitions. It also pushed `None` on the scope stack for a module-level `async def` instead of the AsyncFunction it had just created, so the next nested `def` / `async` token dereferenced None: AttributeError: 'NoneType' object has no attribute 'file' Both are fixed: the token after `async` is now validated before being treated as a function definition, and the module-level branch assigns cur_func before pushing it on the stack - mirroring what the synchronous `def` branch already does. Adds regression tests; 5 of them fail with the AttributeError above before the fix and all pass after. --- CHANGELOG.md | 7 ++++ codegraph/parser.py | 13 ++++-- tests/test_graph_generation.py | 73 ++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6bcd74..65b6dc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- `AttributeError: 'NoneType' object has no attribute 'file'` when a module-level `async def` contains a nested function definition, an `async for` statement/comprehension, or an `async with` statement. + The `async` branch of `create_objects_array` assumed the token following `async` was always `def` (so `async for` / `async with` were parsed as function definitions) and pushed `None` on the scope stack for a module-level `async def`, which blew up on the next nested `def` / `async` token. + ## [1.2.0] - 2026-01-18 ### Added diff --git a/codegraph/parser.py b/codegraph/parser.py index d58850f..391538f 100644 --- a/codegraph/parser.py +++ b/codegraph/parser.py @@ -202,8 +202,11 @@ def create_objects_array(fname, source): # noqa: C901 # Close previous nested classes and defs. while stack and stack[-1][1] >= thisindent: del stack[-1] - # next will be def - _, _, _ = next(g)[0:3] + # next must be 'def': 'async for' / 'async with' are not + # function definitions and must not be parsed as such. + _, next_token, _ = next(g)[0:3] + if next_token != "def": + continue # we need method name tokentype, func_name, start = next(g)[0:3] if tokentype != NAME: @@ -214,8 +217,10 @@ def create_objects_array(fname, source): # noqa: C901 cur_func = _nest_function(cur_obj, func_name, lineno, async_f=True) cur_obj.endno = lineno - new_lines else: - tree.append(AsyncFunction(func_name, fname, lineno)) - stack.append((cur_func, thisindent)) + cur_func = AsyncFunction(func_name, fname, lineno) + tree.append(cur_func) + if cur_func: + stack.append((cur_func, thisindent)) elif token == "def": new_lines = 0 lineno, thisindent = start diff --git a/tests/test_graph_generation.py b/tests/test_graph_generation.py index 7c917c5..e5954ed 100644 --- a/tests/test_graph_generation.py +++ b/tests/test_graph_generation.py @@ -132,6 +132,79 @@ class MultiLineInherit( assert "Base2" in multi.super +class TestAsyncParsing: + """Tests for `async` constructs parsing. + + Regression tests: the `async` branch of ``create_objects_array`` used to + (a) assume the token after ``async`` was always ``def`` — so ``async for`` + / ``async with`` were parsed as function definitions — and (b) push + ``None`` on the stack for a module-level ``async def``, which crashed on + the next nested ``def`` / ``async`` token. + """ + + def test_nested_async_def_inside_module_level_async_def(self): + """A nested async function is attached to its module-level parent.""" + source = """async def outer(): + async def inner(): + pass + return inner +""" + result = create_objects_array("test.py", source) + outer = next(o for o in result if getattr(o, "name", None) == "outer") + assert "inner" in outer.children + + def test_nested_def_inside_module_level_async_def(self): + """A nested sync function is attached to its module-level async parent.""" + source = """async def outer(): + def inner(): + pass + return inner +""" + result = create_objects_array("test.py", source) + outer = next(o for o in result if getattr(o, "name", None) == "outer") + assert "inner" in outer.children + + def test_async_for_comprehension_is_not_a_function(self): + source = """async def collect(items): + return [x async for x in items] +""" + result = create_objects_array("test.py", source) + collect = next(o for o in result if getattr(o, "name", None) == "collect") + assert collect.children == {} + + def test_async_for_statement_is_not_a_function(self): + source = """async def drain(items): + async for item in items: + print(item) +""" + result = create_objects_array("test.py", source) + drain = next(o for o in result if getattr(o, "name", None) == "drain") + assert drain.children == {} + + def test_async_with_is_not_a_function(self): + source = """async def run(lock): + async with lock: + return 1 +""" + result = create_objects_array("test.py", source) + run = next(o for o in result if getattr(o, "name", None) == "run") + assert run.children == {} + + def test_module_level_async_def_then_top_level_def(self): + """A module-level async def does not break the following top-level def.""" + source = """async def first(): + return 1 + + +def second(): + return 2 +""" + result = create_objects_array("test.py", source) + names = [getattr(o, "name", None) for o in result] + assert "first" in names + assert "second" in names + + class TestCodeGraphConnections: """Tests for CodeGraph connection detection.""" From f597eddf820a73a348e2b1edc235dec7f3b1d993 Mon Sep 17 00:00:00 2001 From: ango <178757933+nanami7777777@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:47:10 +0800 Subject: [PATCH 2/3] fix: entities closed without DEDENT never get an end line Scopes popped by a sibling `def` / `class` (a one-line body such as `def f(self) -> None: ...` never emits INDENT/DEDENT) or still open at EOF never reach the DEDENT handler, so their `endno` stayed `None` and `CodeGraph.usage_graph()` later raised: TypeError: '<=' not supported between instances of 'int' and 'NoneType' if entity[0] <= method_usage_line <= entity[1]: Introduce `_close_scopes()` which finalizes `endno` whenever a scope is popped, use it in the `async` / `def` / `class` branches, and call it once more after the token loop to close everything left open at EOF. On a real project this dropped entities with `endno is None` from 74 to 0. --- CHANGELOG.md | 1 + codegraph/parser.py | 31 +++++++++++++++++---- tests/test_graph_generation.py | 51 ++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65b6dc3..878bbee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `AttributeError: 'NoneType' object has no attribute 'file'` when a module-level `async def` contains a nested function definition, an `async for` statement/comprehension, or an `async with` statement. The `async` branch of `create_objects_array` assumed the token following `async` was always `def` (so `async for` / `async with` were parsed as function definitions) and pushed `None` on the scope stack for a module-level `async def`, which blew up on the next nested `def` / `async` token. +- `TypeError: '<=' not supported between instances of 'int' and 'NoneType'` in `usage_graph()` when a module contains an entity that never received an end line. Scopes closed by a sibling `def` / `class` (one-line bodies such as `def f(self) -> None: ...` have no DEDENT) or by EOF never reached the DEDENT handler, so their `endno` stayed `None`. `create_objects_array` now finalizes `endno` whenever it pops a scope and closes everything still open at EOF. ## [1.2.0] - 2026-01-18 diff --git a/codegraph/parser.py b/codegraph/parser.py index 391538f..df6b966 100644 --- a/codegraph/parser.py +++ b/codegraph/parser.py @@ -81,6 +81,22 @@ def _nest_function(ob, func_name, lineno, async_f=False): return newfunc +def _close_scopes(stack: List, endno: int, indent: int) -> None: + """Pop every scope closed at ``indent``, finalising ``endno``. + + Scopes popped here never reach the DEDENT handler, so their ``endno`` + would stay ``None``: a one-line ``def f(): ...`` has no indented body, + and whatever is still open at EOF gets no DEDENT either. A ``None`` + end line later blows up ``core.usage_graph`` with + ``TypeError: '<=' not supported between instances of 'int' and + 'NoneType'``. + """ + while stack and stack[-1][1] >= indent: + ob = stack.pop()[0] + if isinstance(ob, _Object) and ob.endno is None: + ob.endno = endno + + def _nest_class(ob, class_name, lineno, super=None): "Return a Class after nesting within ob." newclass = Class(class_name, super, ob.file, lineno, ob) @@ -197,11 +213,11 @@ def create_objects_array(fname, source): # noqa: C901 for module in modules: imports.add(module) elif token == "async": + end_line = start[0] - 1 - new_lines new_lines = 0 lineno, thisindent = start # Close previous nested classes and defs. - while stack and stack[-1][1] >= thisindent: - del stack[-1] + _close_scopes(stack, end_line, thisindent) # next must be 'def': 'async for' / 'async with' are not # function definitions and must not be parsed as such. _, next_token, _ = next(g)[0:3] @@ -222,11 +238,11 @@ def create_objects_array(fname, source): # noqa: C901 if cur_func: stack.append((cur_func, thisindent)) elif token == "def": + end_line = start[0] - 1 - new_lines new_lines = 0 lineno, thisindent = start # Close previous nested classes and defs. - while stack and stack[-1][1] >= thisindent: - del stack[-1] + _close_scopes(stack, end_line, thisindent) tokentype, func_name, start = next(g)[0:3] if tokentype != NAME: continue # Skip def with syntax error. @@ -240,11 +256,11 @@ def create_objects_array(fname, source): # noqa: C901 if cur_func: stack.append((cur_func, thisindent)) elif token == "class": + end_line = start[0] - 1 - new_lines new_lines = 0 lineno, thisindent = start # Close previous nested classes and defs. - while stack and stack[-1][1] >= thisindent: - del stack[-1] + _close_scopes(stack, end_line, thisindent) tokentype, class_name, start = next(g)[0:3] if tokentype != NAME: @@ -295,6 +311,9 @@ def create_objects_array(fname, source): # noqa: C901 except StopIteration: pass + # EOF: whatever is still open never got a DEDENT - close it explicitly. + _close_scopes(stack, len(source.splitlines()), 0) + f.close() if imports: tree.append(imports) diff --git a/tests/test_graph_generation.py b/tests/test_graph_generation.py index e5954ed..06a57c1 100644 --- a/tests/test_graph_generation.py +++ b/tests/test_graph_generation.py @@ -205,6 +205,57 @@ def second(): assert "second" in names +class TestEntityEndLines: + """Tests for `endno` (entity end line) being always set. + + Regression tests: scopes closed by a sibling `def` / `class` (or by EOF) + never reach the DEDENT handler, so their `endno` stayed `None` and + `CodeGraph.usage_graph()` later raised + ``TypeError: '<=' not supported between instances of 'int' and 'NoneType'``. + """ + + def test_one_line_function_has_end_line(self): + """A `def ...: ...` with no indented body still gets an end line.""" + source = """def f(self) -> None: ... + + +def g(): + return 1 +""" + result = create_objects_array("test.py", source) + f = next(o for o in result if getattr(o, "name", None) == "f") + assert f.endno is not None + + def test_last_entity_in_module_has_end_line(self): + """The last entity of a module is closed at EOF.""" + source = """def a(): + return 1 +""" + result = create_objects_array("test.py", source) + a = next(o for o in result if getattr(o, "name", None) == "a") + assert a.endno is not None + + def test_scope_closed_by_sibling_def_has_end_line(self): + source = """class A: + def m(self): ... + + +def b(): + return 1 +""" + result = create_objects_array("test.py", source) + cls = next(o for o in result if getattr(o, "name", None) == "A") + assert cls.children["m"].endno is not None + + def test_module_level_async_function_has_end_line(self): + source = """async def a(): + return 1 +""" + result = create_objects_array("test.py", source) + a = next(o for o in result if getattr(o, "name", None) == "a") + assert a.endno is not None + + class TestCodeGraphConnections: """Tests for CodeGraph connection detection.""" From 1d50653261e4d4c5add0c85136309df92998396a Mon Sep 17 00:00:00 2001 From: ango <178757933+nanami7777777@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:41:14 +0800 Subject: [PATCH 3/3] fix: usage_graph() loses import edges on a second call `get_imports_and_entities_lines()` popped the `Import` object off the parsed module, so calling `usage_graph()` twice left the second call with no imports at all - and therefore no cross-module edges. Since the CLI builds the graph (for the visualisation / CSV) before searching it, `--file-path` / `--distance` always reported an empty dependency set. * keep the `Import` object on the list and skip it where entities are iterated, instead of popping it; * `get_dependencies()` now resolves a relative `--file-path` (graph keys are absolute), and follows an edge past distance 1 when its bare ".py" name maps to exactly one file - ambiguous names (there can be hundreds of `__init__.py`) are reported but not traversed, otherwise the BFS fans out to unrelated modules. Before: `{1: set(), 2: set()}` After: `{1: {llm.py, types.py, events.py, ...}, 2: {engine.py, ...}}` --- CHANGELOG.md | 1 + codegraph/core.py | 41 +++++++++++++++++++++++++++++----- tests/test_graph_generation.py | 38 +++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 878bbee..18883f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `AttributeError: 'NoneType' object has no attribute 'file'` when a module-level `async def` contains a nested function definition, an `async for` statement/comprehension, or an `async with` statement. The `async` branch of `create_objects_array` assumed the token following `async` was always `def` (so `async for` / `async with` were parsed as function definitions) and pushed `None` on the scope stack for a module-level `async def`, which blew up on the next nested `def` / `async` token. +- `usage_graph()` silently losing every import-derived edge on a second call: import extraction `pop()`ed the `Import` object off the parsed module. Because the CLI builds the graph before searching it, `--file-path` / `--distance` always reported no dependencies. Import extraction is now non-destructive, `--file-path` accepts a relative path, and edges that resolve to exactly one file are followed, so `--distance 2` works. - `TypeError: '<=' not supported between instances of 'int' and 'NoneType'` in `usage_graph()` when a module contains an entity that never received an end line. Scopes closed by a sibling `def` / `class` (one-line bodies such as `def f(self) -> None: ...` have no DEDENT) or by EOF never reached the DEDENT handler, so their `endno` stayed `None`. `create_objects_array` now finalizes `endno` whenever it pops a scope and closes everything still open at EOF. ## [1.2.0] - 2026-01-18 diff --git a/codegraph/core.py b/codegraph/core.py index 64ccd33..f7c8665 100644 --- a/codegraph/core.py +++ b/codegraph/core.py @@ -132,9 +132,18 @@ def get_dependencies(self, file_path: str, distance: int) -> Dict[str, Set[str]] dependencies = {i: set() for i in range(1, distance + 1)} graph = self.usage_graph() + # graph keys are absolute paths - accept a relative --file-path too + file_path = os.path.abspath(file_path) + if file_path not in graph: return dependencies + # basename -> full paths, so an edge can be followed past distance 1 + # (edges carry a bare ".py" name, which is not a graph key) + paths_by_name = defaultdict(list) + for path in graph: + paths_by_name[os.path.basename(path)].append(path) + queue = deque([(file_path, 0)]) visited = set() @@ -150,10 +159,21 @@ def get_dependencies(self, file_path: str, distance: int) -> Dict[str, Set[str]] for entity, used_entities in graph[current_file].items(): for used_entity in used_entities: if "." in used_entity: - dependent_file = used_entity.split(".")[0] + ".py" - if dependent_file != current_file: - dependencies[current_distance + 1].add(dependent_file) - queue.append((dependent_file, current_distance + 1)) + dependent_file = str(used_entity).split(".")[0] + ".py" + candidates = [ + path + for path in paths_by_name[dependent_file] + if path != current_file + ] + if not candidates: + continue + dependencies[current_distance + 1].add(dependent_file) + if len(candidates) == 1: + # An ambiguous name (there can be hundreds of + # __init__.py) would fan the BFS out to + # unrelated modules - only follow edges that + # resolve to exactly one file. + queue.append((candidates[0], current_distance + 1)) return dependencies @@ -190,7 +210,7 @@ def get_imports_and_entities_lines( # noqa: C901 # for each module in list if code_objects[path] and isinstance(code_objects[path][-1], Import): # extract imports if exist - for import_ in code_objects[path].pop(-1).modules: + for import_ in code_objects[path][-1].modules: pathed_import = import_ alias = None if " as " in pathed_import: @@ -232,6 +252,11 @@ def get_imports_and_entities_lines( # noqa: C901 break for entity in code_objects[path]: + if isinstance(entity, Import): + # the Import object is kept on the list on purpose - popping it + # made usage_graph() non-idempotent (a second call silently + # lost every import-derived edge). + continue # create a dict with lines of start and end for each entity in module entities_lines[path][(entity.lineno, entity.endno)] = entity.name return entities_lines, imports, names_map @@ -241,6 +266,8 @@ def search_entities_from_list_in_code( entities_list: List, module_name: Text, line: Text ) -> Text: for entity in entities_list: + if isinstance(entity, Import): + continue if search_entity_usage(module_name, entity.name, line): yield entity @@ -317,6 +344,8 @@ def populate_free_nodes(code_objects: Dict, dependencies: Dict, imports: Dict, m dependencies[path]["_"].append(import_dep) for entity in code_objects[path]: + if isinstance(entity, Import): + continue if entity.name not in dependencies[path]: dependencies[path][entity.name] = [] @@ -347,6 +376,8 @@ def populate_free_nodes(code_objects: Dict, dependencies: Dict, imports: Dict, m # If not found in imports, check if it's a local class if not base_found: for local_entity in code_objects[path]: + if isinstance(local_entity, Import): + continue if local_entity.name == base_class: # It's a local class, add without module prefix dependencies[path][entity.name].append(base_class) diff --git a/tests/test_graph_generation.py b/tests/test_graph_generation.py index 06a57c1..4a88e4a 100644 --- a/tests/test_graph_generation.py +++ b/tests/test_graph_generation.py @@ -256,6 +256,44 @@ def test_module_level_async_function_has_end_line(self): assert a.endno is not None +class TestUsageGraphIdempotency: + """`usage_graph()` must be safe to call more than once. + + Regression test: import extraction used to `pop()` the `Import` object + off the parsed module, so a second call silently lost every + import-derived edge. Since the CLI builds the graph (for the + visualisation / CSV) before searching it, `--file-path` / `--distance` + always reported "no dependencies". + """ + + def test_second_call_matches_the_first(self): + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + (root / "mod_a.py").write_text( + "from mod_b import helper\n\n\ndef use():\n return helper()\n" + ) + (root / "mod_b.py").write_text("def helper():\n return 1\n") + code_graph = CodeGraph(Namespace(paths=[tmp])) + first = code_graph.usage_graph() + second = code_graph.usage_graph() + + assert first == second + + def test_dependencies_found_after_usage_graph_was_called(self): + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + mod_a = root / "mod_a.py" + mod_a.write_text( + "from mod_b import helper\n\n\ndef use():\n return helper()\n" + ) + (root / "mod_b.py").write_text("def helper():\n return 1\n") + code_graph = CodeGraph(Namespace(paths=[tmp])) + code_graph.usage_graph() + dependencies = code_graph.get_dependencies(str(mod_a), 1) + + assert "mod_b.py" in dependencies[1] + + class TestCodeGraphConnections: """Tests for CodeGraph connection detection."""