Skip to content

Commit 6768ec1

Browse files
committed
Prepare 1.3.0 release
1 parent a1264e7 commit 6768ec1

File tree

11 files changed

+346
-29
lines changed

11 files changed

+346
-29
lines changed

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.3.0] - 2026-01-24
9+
10+
### Added
11+
12+
- Pluggable parser architecture with registry and base parser interface
13+
- Rust parser stub to establish language extension points
14+
- AST-based Python parser with cross-version parsing support (Python 2.x via typed-ast)
15+
- CLI options for language selection and target Python version
16+
- Expanded test coverage for parsers, registry/utils, visualizer helpers, and legacy parser objects
17+
- SUGGESTIONS.md with project improvement ideas
18+
19+
### Changed
20+
21+
- Core now delegates parsing and dependency analysis to language parsers
22+
- Python dependency detection now uses AST rather than token scanning
23+
- Utilities now support multi-extension file discovery
24+
25+
### Fixed
26+
27+
- Legacy parser now handles comma-separated imports in a single statement
28+
- Removed known false positives from string literals and alias leakage (AST parser)
29+
830
## [1.2.0] - 2026-01-18
931

1032
### Added

codegraph/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "1.2.0"
1+
__version__ = "1.3.0"

codegraph/parser.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,8 @@ def create_objects_array(fname, source): # noqa: C901
121121
new_lines += 1
122122

123123
elif token == "import":
124-
modules = [_line.replace("\n", "").split("import ")[1]]
124+
modules_part = _line.replace("\n", "").split("import ", 1)[1]
125+
modules = [part.strip() for part in modules_part.split(",") if part.strip()]
125126
if not imports:
126127
imports = Import(modules)
127128
else:

codegraph/parsers/python_parser.py

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ def __init__(self, args=None, python_version: Optional[str] = None) -> None:
3535
if python_version is None and args is not None:
3636
python_version = getattr(args, "python_version", None)
3737
self._python_version = python_version
38+
self._major, self._minor = self._parse_python_version(self._python_version)
39+
self._ast_mod = ast27 if self._major == 2 else ast
40+
self._feature_version = self._minor if self._major == 3 else None
3841
self._module_names_set: Set[str] = set()
3942

4043
def get_source_files(self, paths) -> List[str]:
@@ -116,16 +119,14 @@ def _read_file_content(self, path: Text) -> Text:
116119
return file_read.read()
117120

118121
def _parse_source(self, source: Text, filename: Text):
119-
major, minor = self._parse_python_version(self._python_version)
120-
if major == 2:
122+
if self._major == 2:
121123
if ast27 is None:
122124
raise ImportError(
123125
"typed_ast is required to parse Python 2 source code."
124126
)
125127
return ast27.parse(source, filename=filename, mode="exec")
126128

127-
feature_version = minor if minor is not None else None
128-
return self._parse_with_feature_version(source, filename, feature_version)
129+
return self._parse_with_feature_version(source, filename, self._feature_version)
129130

130131
def _parse_with_feature_version(self, source: Text, filename: Text, feature_version: Optional[int]):
131132
if feature_version is None:
@@ -159,26 +160,25 @@ def _parse_python_version(self, version: Optional[str]) -> Tuple[int, Optional[i
159160
def _extract_entities(self, ast_tree, filename: Text) -> (List[object], Dict[str, object]):
160161
entities: List[object] = []
161162
entity_nodes: Dict[str, object] = {}
162-
ast_mod = ast27 if self._python_version and self._python_version.startswith("2") else ast
163-
164-
async_def = getattr(ast_mod, "AsyncFunctionDef", None)
163+
ast_mod = self._ast_mod
164+
async_def = getattr(self._ast_mod, "AsyncFunctionDef", None)
165165

166166
for node in getattr(ast_tree, "body", []):
167-
if isinstance(node, ast_mod.FunctionDef):
167+
if isinstance(node, self._ast_mod.FunctionDef):
168168
func = Function(node.name, filename, node.lineno)
169-
func.endno = self._get_end_lineno(node, ast_mod)
169+
func.endno = self._get_end_lineno(node, self._ast_mod)
170170
entities.append(func)
171171
entity_nodes[node.name] = node
172172
elif async_def and isinstance(node, async_def):
173173
func = AsyncFunction(node.name, filename, node.lineno)
174-
func.endno = self._get_end_lineno(node, ast_mod)
174+
func.endno = self._get_end_lineno(node, self._ast_mod)
175175
entities.append(func)
176176
entity_nodes[node.name] = node
177-
elif isinstance(node, ast_mod.ClassDef):
177+
elif isinstance(node, self._ast_mod.ClassDef):
178178
bases = [self._get_name_from_expr(base, ast_mod) for base in node.bases]
179179
bases = [b for b in bases if b]
180180
cls = Class(node.name, bases, filename, node.lineno)
181-
cls.endno = self._get_end_lineno(node, ast_mod)
181+
cls.endno = self._get_end_lineno(node, self._ast_mod)
182182
entities.append(cls)
183183
entity_nodes[node.name] = node
184184

@@ -188,7 +188,7 @@ def _collect_imports(self, ast_tree) -> ImportInfo:
188188
module_aliases: Dict[str, str] = {}
189189
entity_aliases: Dict[str, str] = {}
190190
module_imports: Set[str] = set()
191-
ast_mod = ast27 if self._python_version and self._python_version.startswith("2") else ast
191+
ast_mod = self._ast_mod
192192

193193
for node in getattr(ast_tree, "body", []):
194194
if isinstance(node, ast_mod.Import):
@@ -207,10 +207,8 @@ def _collect_imports(self, ast_tree) -> ImportInfo:
207207
full_name = f"{base}.{name}" if base else name
208208
resolved = self._resolve_imported_module(full_name)
209209
if resolved:
210-
module_aliases[alias_name] = resolved
211210
module_imports.add(resolved)
212-
else:
213-
entity_aliases[alias_name] = full_name
211+
entity_aliases[alias_name] = full_name
214212

215213
return ImportInfo(
216214
module_aliases=module_aliases,
@@ -234,14 +232,14 @@ def _collect_dependencies_in_module(
234232
entity_aliases: Dict[str, str],
235233
) -> List[str]:
236234
deps: List[str] = []
237-
ast_mod = ast27 if self._python_version and self._python_version.startswith("2") else ast
235+
ast_mod = self._ast_mod
238236
collector = self._make_dependency_collector(
239237
local_entities, module_aliases, entity_aliases, ast_mod
240238
)
241239
for node in getattr(ast_tree, "body", []):
242240
if isinstance(node, (ast_mod.FunctionDef, ast_mod.ClassDef)):
243241
continue
244-
async_def = getattr(ast_mod, "AsyncFunctionDef", None)
242+
async_def = getattr(self._ast_mod, "AsyncFunctionDef", None)
245243
if async_def and isinstance(node, async_def):
246244
continue
247245
collector.visit(node)
@@ -256,7 +254,7 @@ def _collect_dependencies_in_entity(
256254
entity_aliases: Dict[str, str],
257255
) -> List[str]:
258256
deps: List[str] = []
259-
ast_mod = ast27 if self._python_version and self._python_version.startswith("2") else ast
257+
ast_mod = self._ast_mod
260258

261259
if isinstance(node, ast_mod.ClassDef):
262260
for base in node.bases:
@@ -339,7 +337,9 @@ def _resolve_attribute(
339337
suffix = ".".join(parts[1:])
340338
return f"{module_name}.{suffix}" if suffix else module_name
341339
if base in entity_aliases:
342-
return entity_aliases[base]
340+
base_name = entity_aliases[base]
341+
suffix = ".".join(parts[1:])
342+
return f"{base_name}.{suffix}" if suffix else base_name
343343
if base in local_entities:
344344
return base
345345
return None
@@ -369,6 +369,14 @@ def _flatten_attribute(self, node, ast_mod) -> List[str]:
369369
return parts
370370
return []
371371

372+
def _get_name_from_expr(self, node, ast_mod) -> Optional[str]:
373+
if isinstance(node, ast_mod.Name):
374+
return node.id
375+
if isinstance(node, ast_mod.Attribute):
376+
parts = self._flatten_attribute(node, ast_mod)
377+
return ".".join(parts) if parts else None
378+
return None
379+
372380
def _get_end_lineno(self, node, ast_mod) -> int:
373381
end_lineno = getattr(node, "end_lineno", None)
374382
if end_lineno:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "codegraph"
3-
version = "1.2.0"
3+
version = "1.3.0"
44
license = "MIT"
55
readme = "docs/README.rst"
66
homepage = "https://github.com/xnuinside/codegraph"

tests/test_known_limitations.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
from argparse import Namespace
22

3-
import pytest
4-
53
from codegraph.core import CodeGraph
64
from codegraph.parser import Import, create_objects_array
75

86

9-
@pytest.mark.xfail(reason="Token-based parser does not split comma-separated imports.")
107
def test_import_comma_separated_statement():
118
source = """import os, sys
129
@@ -20,7 +17,6 @@ def func():
2017
assert "sys" in imports.modules
2118

2219

23-
@pytest.mark.xfail(reason="String literals are scanned for usage, causing false positives.")
2420
def test_usage_in_string_literal_is_not_dependency(tmp_path):
2521
module_path = tmp_path / "module_a.py"
2622
module_path.write_text(
@@ -40,7 +36,6 @@ def bar():
4036
assert "foo" not in usage_graph[module_path.as_posix()]["bar"]
4137

4238

43-
@pytest.mark.xfail(reason="Global alias map leaks across modules, causing false positives.")
4439
def test_alias_leak_between_modules(tmp_path):
4540
module_b = tmp_path / "module_b.py"
4641
module_b.write_text(
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from codegraph import parser as legacy
2+
3+
4+
def test_object_children_and_repr():
5+
parent = legacy._Object("parent", "file.py", 1, None)
6+
child = legacy.Function("child", "file.py", 2)
7+
parent._addchild("child", child)
8+
9+
assert parent.children["child"] is child
10+
assert child.main is parent
11+
assert "parent" in repr(parent)
12+
assert "parent" in str(parent)
13+
14+
15+
def test_class_methods_and_nesting():
16+
cls = legacy.Class("MyClass", [], "file.py", 1)
17+
method = legacy._nest_function(cls, "method", 2)
18+
async_method = legacy._nest_function(cls, "amethod", 3, async_f=True)
19+
nested_class = legacy._nest_class(cls, "Nested", 4)
20+
21+
assert "method" in cls.methods
22+
assert "amethod" in cls.async_methods
23+
assert cls.children["Nested"] is nested_class
24+
assert method in cls.methods.values()
25+
assert async_method in cls.async_methods.values()
26+
27+
28+
def test_import_add():
29+
imp = legacy.Import(["os"])
30+
imp.add("sys")
31+
assert "os" in imp.modules
32+
assert "sys" in imp.modules

tests/test_parsers_and_utils.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from argparse import Namespace
2+
3+
import pytest
4+
5+
from codegraph.parsers import available_languages, get_parser
6+
from codegraph.parsers.base import BaseParser
7+
from codegraph.utils import get_paths_list
8+
9+
10+
class DummyParser(BaseParser):
11+
language = "dummy"
12+
13+
def get_source_files(self, paths):
14+
return []
15+
16+
def parse_files(self, paths_list):
17+
return {}
18+
19+
def usage_graph(self, modules_data):
20+
return {}
21+
22+
def get_entity_metadata(self, modules_data):
23+
return {}
24+
25+
26+
def test_available_languages_contains_python_and_rust():
27+
langs = available_languages()
28+
assert "python" in langs
29+
assert "rust" in langs
30+
31+
32+
def test_get_parser_returns_python_parser():
33+
parser = get_parser("python", args=Namespace())
34+
assert parser.language == "python"
35+
36+
37+
def test_get_parser_unsupported_language():
38+
with pytest.raises(ValueError):
39+
get_parser("nope")
40+
41+
42+
def test_base_parser_get_dependencies():
43+
parser = DummyParser()
44+
usage_graph = {
45+
"a.py": {"func": ["b.func", "c.other"], "_": []},
46+
"b.py": {"func": []},
47+
}
48+
49+
deps = parser.get_dependencies(usage_graph, "a.py", 1)
50+
assert deps[1] == {"b.py", "c.py"}
51+
52+
53+
def test_get_paths_list_multi_extension(tmp_path):
54+
(tmp_path / "a.py").write_text("", encoding="utf-8")
55+
(tmp_path / "b.txt").write_text("", encoding="utf-8")
56+
57+
paths = get_paths_list(tmp_path.as_posix(), [".py", ".txt"])
58+
names = {p.split("/")[-1] for p in paths}
59+
assert names == {"a.py", "b.txt"}

0 commit comments

Comments
 (0)