Skip to content

Commit 98ce326

Browse files
committed
feat(python)!: speak schema 2.0.0 — canonical model fields, CanNode call-graph keys, fail-fast version checks
Adapt the Python facade to codeanalyzer-python 1.0.x (analysis schema 2.0.0): - unwrap the v2 Analysis envelope in _run_analyzer and fail fast unless schema_version is the SUPPORTED_ANALYSIS_SCHEMA (2.0.0) - follow the model renames everywhere the backends walk the symbol table: module.classes→types, class methods→callables, inner_classes→types, callable inner_callables→callables, inner_classes→types - recover callable source by slicing module.source with span.bytes (_code_of): schema v2 stores source once per module instead of a code field per callable - translate CanNode can:// call-edge endpoints (PyCallEdge.src/dst) back to dotted signatures for the public call graph; externals keep their raw ids - read the renamed PY_CALLS edge property (prov) and construct PyCallEdge with src/dst/prov in the Neo4j backend - add a fail-fast schema_version gate to PyNeo4jBackend, reading the stamp on the scoped :PyApplication node (mirrors TSNeo4jBackend) - map reconstruct.py's graph-vocabulary kwargs (methods/inner_*) onto the new model fields; the callable's code node property is no longer reconstructed - pin codeanalyzer-python==1.0.2 (1.0.0/1.0.1 emitter fix in #104 verified) and codeanalyzer-typescript==1.0.0 in dependencies and [tool.backend-versions] - rewrite the hand-built test fixtures in the new vocabulary and add test_python_schema_contract.py: both fail-fast gates + CanNode translation
1 parent 8ba29ab commit 98ce326

8 files changed

Lines changed: 363 additions & 109 deletions

File tree

cldk/analysis/python/codeanalyzer/codeanalyzer.py

Lines changed: 87 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262

6363
from cldk.analysis import AnalysisLevel
6464
from cldk.analysis.python.backend import PythonAnalysisBackend
65+
from cldk.utils.exceptions import CldkSchemaMismatchException
6566
from cldk.models.python import (
6667
PyApplication,
6768
PyCallEdge,
@@ -77,6 +78,18 @@
7778
logger = logging.getLogger(__name__)
7879

7980

81+
def _code_of(module: PyModule, c: PyCallable) -> str:
82+
"""Slice a callable's source text out of its module's ``source`` using ``span.bytes``.
83+
84+
Schema 2.0.0 stores source once per module; callables carry a byte-offset ``Span``
85+
instead of a duplicated ``code`` string.
86+
"""
87+
if not module.source or c.span is None:
88+
return ""
89+
start, end = c.span.bytes
90+
return module.source.encode("utf-8")[start:end].decode("utf-8")
91+
92+
8093
def _overview(c: PyCallable, class_signature: str | None, kind: str) -> PyCallableOverview:
8194
"""Project a :class:`PyCallable` into a lightweight :class:`PyCallableOverview`."""
8295
return PyCallableOverview(
@@ -120,6 +133,10 @@ class PyCodeanalyzer(PythonAnalysisBackend):
120133
- :class:`~cldk.analysis.python.PythonAnalysis`: High-level facade.
121134
"""
122135

136+
#: The ``codeanalyzer-python`` analysis schema this backend speaks. ``_run_analyzer``
137+
#: fails fast when the analyzer's ``Analysis.schema_version`` differs.
138+
SUPPORTED_ANALYSIS_SCHEMA = "2.0.0"
139+
123140
def __init__(
124141
self,
125142
project_dir: Union[str, Path],
@@ -191,11 +208,11 @@ def __init__(
191208
# Class-signature → file path lookup, built once.
192209
self._class_to_file: Dict[str, str] = {}
193210
for file_path, module in self.application.symbol_table.items():
194-
for class_sig in module.classes:
211+
for class_sig in module.types:
195212
self._class_to_file[class_sig] = file_path
196213

197214
if analysis_level == AnalysisLevel.call_graph:
198-
self.call_graph: nx.DiGraph | None = self._build_call_graph(self.application.call_graph)
215+
self.call_graph: nx.DiGraph | None = self._build_call_graph(self.application.call_graph, self._id_to_signature())
199216
else:
200217
self.call_graph = None
201218

@@ -210,7 +227,13 @@ def _run_analyzer(self) -> PyApplication:
210227
Returns:
211228
A :class:`~cldk.models.python.PyApplication` object containing
212229
the complete analysis results, including the symbol table and
213-
call graph edges.
230+
call graph edges. The analyzer returns a schema-v2 ``Analysis``
231+
envelope; this method verifies its ``schema_version`` and unwraps
232+
the ``application`` payload.
233+
234+
Raises:
235+
CldkSchemaMismatchException: If the analyzer's ``schema_version``
236+
is not :attr:`SUPPORTED_ANALYSIS_SCHEMA`.
214237
215238
Note:
216239
If ``target_files`` contains multiple files, only the first
@@ -237,10 +260,25 @@ def _run_analyzer(self) -> PyApplication:
237260
)
238261

239262
with Codeanalyzer(options) as analyzer:
240-
return analyzer.analyze()
263+
analysis = analyzer.analyze()
264+
if analysis.schema_version != self.SUPPORTED_ANALYSIS_SCHEMA:
265+
raise CldkSchemaMismatchException(
266+
f"analysis schema {analysis.schema_version!r} from codeanalyzer-python, this SDK speaks "
267+
f"{self.SUPPORTED_ANALYSIS_SCHEMA!r} — align the pinned codeanalyzer-python with the SDK"
268+
)
269+
return analysis.application
270+
271+
def _id_to_signature(self) -> Dict[str, str]:
272+
"""Map every symbol-table callable's ``can://`` id to its dotted signature.
273+
274+
Schema 2.0.0 call edges reference callables by CanNode id (``PyCallEdge.src``/``dst``);
275+
the SDK's public call-graph vocabulary stays dotted signatures (matching the Neo4j
276+
backend, whose ``:PySymbol`` nodes carry the dotted ``signature`` property).
277+
"""
278+
return {c.id: c.signature for c, _, _, _ in self._iter_callables() if c.id}
241279

242280
@staticmethod
243-
def _build_call_graph(edges: List[PyCallEdge]) -> nx.DiGraph:
281+
def _build_call_graph(edges: List[PyCallEdge], id_to_signature: Dict[str, str]) -> nx.DiGraph:
244282
"""Convert a list of call edges into a NetworkX directed graph.
245283
246284
Transforms the flat list of :class:`PyCallEdge` objects from the
@@ -250,6 +288,9 @@ def _build_call_graph(edges: List[PyCallEdge]) -> nx.DiGraph:
250288
Args:
251289
edges: List of :class:`~cldk.models.python.PyCallEdge` objects
252290
representing call relationships between methods/functions.
291+
id_to_signature: ``can://`` id → dotted signature for symbol-table callables
292+
(see :meth:`_id_to_signature`). Ids with no entry — external targets —
293+
keep their raw ``can://`` id as the node key.
253294
254295
Returns:
255296
A ``networkx.DiGraph`` where:
@@ -259,7 +300,15 @@ def _build_call_graph(edges: List[PyCallEdge]) -> nx.DiGraph:
259300
"""
260301
graph = nx.DiGraph()
261302
for edge in edges:
262-
graph.add_edge(edge.source, edge.target, type=edge.type, weight=edge.weight, provenance=tuple(edge.provenance))
303+
# Schema 2.0.0 dropped the edge's `type` field; "CALL_DEP" is the fixed edge kind
304+
# (matching the Neo4j backend). nx attribute names stay the SDK's public shape.
305+
graph.add_edge(
306+
id_to_signature.get(edge.src, edge.src),
307+
id_to_signature.get(edge.dst, edge.dst),
308+
type="CALL_DEP",
309+
weight=edge.weight,
310+
provenance=tuple(edge.prov),
311+
)
263312
return graph
264313

265314
# --------------------------------------------------------- application
@@ -300,7 +349,7 @@ def get_call_graph(self) -> nx.DiGraph:
300349
relationships across the project.
301350
"""
302351
if self.call_graph is None:
303-
self.call_graph = self._build_call_graph(self.application.call_graph)
352+
self.call_graph = self._build_call_graph(self.application.call_graph, self._id_to_signature())
304353
return self.call_graph
305354

306355
def get_call_graph_json(self) -> str:
@@ -348,7 +397,7 @@ def get_all_classes(self) -> Dict[str, PyClass]:
348397
"""
349398
result: Dict[str, PyClass] = {}
350399
for module in self.application.symbol_table.values():
351-
result.update(module.classes)
400+
result.update(module.types)
352401
return result
353402

354403
def get_class(self, qualified_class_name: str) -> PyClass | None:
@@ -376,7 +425,7 @@ def get_all_nested_classes(self, qualified_class_name: str) -> List[PyClass]:
376425
not found.
377426
"""
378427
cls = self.get_class(qualified_class_name)
379-
return list(cls.inner_classes.values()) if cls else []
428+
return list(cls.types.values()) if cls else []
380429

381430
def get_all_sub_classes(self, qualified_class_name: str) -> Dict[str, PyClass]:
382431
"""Return all classes that inherit from a specific class.
@@ -447,8 +496,8 @@ def get_all_methods_in_application(self) -> Dict[str, Dict[str, PyCallable]]:
447496
"""
448497
result: Dict[str, Dict[str, PyCallable]] = {}
449498
for module in self.application.symbol_table.values():
450-
for class_sig, cls in module.classes.items():
451-
result[class_sig] = dict(cls.methods)
499+
for class_sig, cls in module.types.items():
500+
result[class_sig] = dict(cls.callables)
452501
if module.functions:
453502
result.setdefault(module.module_name, {}).update(module.functions)
454503
return result
@@ -465,7 +514,7 @@ def get_all_methods_in_class(self, qualified_class_name: str) -> Dict[str, PyCal
465514
Returns empty dict if class not found.
466515
"""
467516
cls = self.get_class(qualified_class_name)
468-
return dict(cls.methods) if cls else {}
517+
return dict(cls.callables) if cls else {}
469518

470519
def get_method(self, qualified_class_name: str, qualified_method_name: str) -> PyCallable | None:
471520
"""Return a specific method or module-level function by scope and name.
@@ -478,7 +527,7 @@ def get_method(self, qualified_class_name: str, qualified_method_name: str) -> P
478527
attribute.
479528
480529
Note:
481-
Callables nested inside another callable (``inner_callables``) are not reachable via
530+
Callables nested inside another callable (``PyCallable.callables``) are not reachable via
482531
this lookup — only top-level class methods and top-level module functions are.
483532
484533
Note:
@@ -546,60 +595,61 @@ def get_all_fields(self, qualified_class_name: str) -> List[PyClassAttribute]:
546595
return list(cls.attributes.values()) if cls else []
547596

548597
# ----------------------------------------------------------- bulk / projected accessors
549-
def _iter_callables(self) -> Iterator[Tuple[PyCallable, "str | None", str]]:
550-
"""Yield ``(callable, class_signature, kind)`` for every callable in the application.
598+
def _iter_callables(self) -> Iterator[Tuple[PyCallable, "str | None", str, PyModule]]:
599+
"""Yield ``(callable, class_signature, kind, module)`` for every callable in the application.
551600
552601
Walks the in-memory symbol table the same way the Neo4j backend's ``MATCH (c:PyCallable)``
553602
sees nodes: a callable is a ``"method"`` only when a class declares it directly (mirroring
554603
``PY_HAS_METHOD``); module-level functions and functions nested inside a callable are
555604
``"function"`` with a ``None`` class signature. The two backends therefore enumerate the
556-
same set.
605+
same set. The declaring module rides along so consumers can slice source text from
606+
``module.source`` (see :func:`_code_of`).
557607
"""
558608

559-
def from_callable(c: PyCallable):
560-
for inner in c.inner_callables.values():
561-
yield inner, None, "function"
562-
yield from from_callable(inner)
563-
for inner_cls in c.inner_classes.values():
564-
yield from from_class(inner_cls)
609+
def from_callable(c: PyCallable, module: PyModule):
610+
for inner in c.callables.values():
611+
yield inner, None, "function", module
612+
yield from from_callable(inner, module)
613+
for inner_cls in c.types.values():
614+
yield from from_class(inner_cls, module)
565615

566-
def from_class(cls: PyClass):
567-
for m in cls.methods.values():
568-
yield m, cls.signature, "method"
569-
yield from from_callable(m)
570-
for inner_cls in cls.inner_classes.values():
571-
yield from from_class(inner_cls)
616+
def from_class(cls: PyClass, module: PyModule):
617+
for m in cls.callables.values():
618+
yield m, cls.signature, "method", module
619+
yield from from_callable(m, module)
620+
for inner_cls in cls.types.values():
621+
yield from from_class(inner_cls, module)
572622

573623
for module in self.application.symbol_table.values():
574-
for cls in module.classes.values():
575-
yield from from_class(cls)
624+
for cls in module.types.values():
625+
yield from from_class(cls, module)
576626
for fn in module.functions.values():
577-
yield fn, None, "function"
578-
yield from from_callable(fn)
627+
yield fn, None, "function", module
628+
yield from from_callable(fn, module)
579629

580630
def get_callables_overview(self) -> List[PyCallableOverview]:
581631
"""Return a lightweight overview of every callable in the application (see
582632
:meth:`PythonAnalysisBackend.get_callables_overview`)."""
583-
return [_overview(c, class_sig, kind) for c, class_sig, kind in self._iter_callables()]
633+
return [_overview(c, class_sig, kind) for c, class_sig, kind, _ in self._iter_callables()]
584634

585635
def get_method_bodies(self, signatures: List[str]) -> Dict[str, str]:
586636
"""Return ``{signature: code}`` for the requested signatures that exist."""
587637
wanted = set(signatures)
588-
return {c.signature: c.code for c, _, _ in self._iter_callables() if c.signature in wanted}
638+
return {c.signature: _code_of(module, c) for c, _, _, module in self._iter_callables() if c.signature in wanted}
589639

590640
def get_decorated_callables(self, markers: List[str]) -> List[PyCallableOverview]:
591641
"""Return overviews of callables decorated with any of ``markers``."""
592642
marker_set = set(markers)
593643
return [
594644
_overview(c, class_sig, kind)
595-
for c, class_sig, kind in self._iter_callables()
645+
for c, class_sig, kind, _ in self._iter_callables()
596646
if marker_set.intersection(c.decorators or [])
597647
]
598648

599649
def get_callsites_for(self, signatures: List[str]) -> Dict[str, List[PyCallsite]]:
600650
"""Return ``{signature: call_sites}`` for the requested signatures that exist."""
601651
wanted = set(signatures)
602-
return {c.signature: list(c.call_sites) for c, _, _ in self._iter_callables() if c.signature in wanted}
652+
return {c.signature: list(c.call_sites) for c, _, _, _ in self._iter_callables() if c.signature in wanted}
603653

604654
# ----------------------------------------------------------- callers/callees
605655
def get_all_callers(self, target_class_name: str, target_method_declaration: str) -> Dict:
@@ -708,7 +758,7 @@ def get_class_call_graph(
708758
return []
709759
return list(nx.edge_dfs(graph, source=method.signature))
710760
edges: List[Tuple[str, str]] = []
711-
for method in cls.methods.values():
761+
for method in cls.callables.values():
712762
if method.signature in graph:
713763
edges.extend(nx.edge_dfs(graph, source=method.signature))
714764
return edges

0 commit comments

Comments
 (0)