6262
6363from cldk .analysis import AnalysisLevel
6464from cldk .analysis .python .backend import PythonAnalysisBackend
65+ from cldk .utils .exceptions import CldkSchemaMismatchException
6566from cldk .models .python import (
6667 PyApplication ,
6768 PyCallEdge ,
7778logger = 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+
8093def _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