Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ 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.
- `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

### Added
Expand Down
41 changes: 36 additions & 5 deletions codegraph/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<module>.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()

Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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] = []

Expand Down Expand Up @@ -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)
Expand Down
44 changes: 34 additions & 10 deletions codegraph/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -197,13 +213,16 @@ 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]
# next will be def
_, _, _ = next(g)[0:3]
_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]
if next_token != "def":
continue
# we need method name
tokentype, func_name, start = next(g)[0:3]
if tokentype != NAME:
Expand All @@ -214,14 +233,16 @@ 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":
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.
Expand All @@ -235,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:
Expand Down Expand Up @@ -290,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)
Expand Down
162 changes: 162 additions & 0 deletions tests/test_graph_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,168 @@ 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 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 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."""

Expand Down