sentinel-codegraph 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,458 @@
1
+ """Python structural extraction from a raw tree-sitter tree.
2
+
3
+ Collect phase (:func:`collect_python_file`): walks once with a single
4
+ ``active_definition_stack`` (byte-offset expiry), emits nodes +
5
+ ``contains`` / ``imports`` edges inline with ``base:name:start:end``
6
+ ids, and buffers bare-name call sites unresolved. Call resolution
7
+ happens later in the link phase (:mod:`codegraph.parser.links`)
8
+ against the global definition registry + per-file import map, so this
9
+ module never resolves modules and never emits ``calls`` edges.
10
+ Decorator call sites are buffered per decorated definition and drained
11
+ into the next ``function_definition`` / ``class_definition`` node as
12
+ ``ParsedCall`` rows carrying the @-line; the link phase resolves them
13
+ like any other call (attribute decorators are skipped, builtins drop).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Literal
22
+
23
+ from tree_sitter import Node
24
+
25
+ from codegraph.models import Edge, EdgeKind, Node as GraphNode, NodeKind
26
+ from codegraph.parser.base import ParsedCall, ParsedImport
27
+ from codegraph.parser.raw_core import field_text, node_text, span, walk
28
+
29
+ LANGUAGE: str = "python"
30
+
31
+ DefinitionKind = Literal["class", "function"]
32
+
33
+
34
+ def _definition_name(syntax_node: Node) -> str:
35
+ return field_text(syntax_node, "name")
36
+
37
+
38
+ def _bare_callee_name(syntax_node: Node) -> str | None:
39
+ """Return the callee name for a bare-name call, else None."""
40
+ if syntax_node.type != "call":
41
+ return None
42
+ function_node: Node | None = syntax_node.child_by_field_name("function")
43
+ if function_node is None or function_node.type != "identifier":
44
+ return None
45
+ return node_text(function_node) or None
46
+
47
+
48
+ def _decorator_callee_name(syntax_node: Node) -> str | None:
49
+ """Return the decorator callee for a ``decorator`` node, else None.
50
+
51
+ Bare ``@retry`` unwraps the single ``identifier`` child; arg-form
52
+ ``@with_logging("debug")`` unwraps one ``call`` layer via its
53
+ ``function`` field. Attribute decorators (``@app.get``) return None
54
+ (skipped, per the no-attribute rule).
55
+ """
56
+ if syntax_node.type != "decorator":
57
+ return None
58
+ callee_nodes: list[Node] = syntax_node.named_children
59
+ if len(callee_nodes) != 1:
60
+ return None
61
+ callee_node: Node = callee_nodes[0]
62
+ if callee_node.type == "identifier":
63
+ return node_text(callee_node) or None
64
+ if callee_node.type == "call":
65
+ return _bare_callee_name(callee_node)
66
+ return None
67
+
68
+
69
+ @dataclass(slots=True)
70
+ class _ActiveDefinition:
71
+ """One live entry on the definition nesting stack: enclosing class or function."""
72
+
73
+ def_kind: DefinitionKind # "class" | "function"
74
+ def_name: str
75
+ node_id: str
76
+ end_byte: int
77
+
78
+
79
+ @dataclass(slots=True)
80
+ class ExtractedFileGraph:
81
+ """Per-file collect output: rows plus unresolved call sites.
82
+
83
+ ``definitions`` maps def name -> node id (first registration wins);
84
+ ``imports`` / ``calls`` are the raw IR the link phase resolves.
85
+ No ``calls`` edges are emitted here.
86
+ """
87
+
88
+ rel_path: str
89
+ language: str
90
+ total_lines: int
91
+ nodes: dict[str, GraphNode] = field(
92
+ default_factory=lambda: dict[str, GraphNode]()
93
+ )
94
+ edges: list[Edge] = field(default_factory=lambda: list[Edge]())
95
+ definitions: dict[str, str] = field(
96
+ default_factory=lambda: dict[str, str]()
97
+ )
98
+ imports: list[ParsedImport] = field(
99
+ default_factory=lambda: list[ParsedImport]()
100
+ )
101
+ calls: list[ParsedCall] = field(default_factory=lambda: list[ParsedCall]())
102
+
103
+
104
+ def build_defined_node_id(
105
+ rel_path: str, def_name: str, start_line: int, end_line: int
106
+ ) -> str:
107
+ """Return the symbolic def id ``base:name:start:end``."""
108
+ return f"{rel_path}:{def_name}:{start_line}:{end_line}"
109
+
110
+
111
+ def _pop_finished_definitions(
112
+ active_definition_stack: list[_ActiveDefinition], byte_offset: int
113
+ ) -> None:
114
+ """Pop stack tops whose def ended before byte offset ``byte_offset``."""
115
+ while active_definition_stack and byte_offset > active_definition_stack[-1].end_byte:
116
+ active_definition_stack.pop()
117
+
118
+
119
+ def _find_innermost_definition_of_kind(
120
+ active_definition_stack: list[_ActiveDefinition], def_kind: DefinitionKind
121
+ ) -> _ActiveDefinition | None:
122
+ """Return the innermost live stack entry of ``def_kind``."""
123
+ for active_definition in reversed(active_definition_stack):
124
+ if active_definition.def_kind == def_kind:
125
+ return active_definition
126
+ return None
127
+
128
+
129
+ def _imports_from_statement(
130
+ source: str, start_line: int, end_line: int
131
+ ) -> list[ParsedImport]:
132
+ """Parse one Python import statement into per-name import rows.
133
+
134
+ ``from`` imports keep both the bound name and the defining-module
135
+ name (``from utils import helper as h`` -> bound ``h``,
136
+ original ``helper``); the link phase joins on the original.
137
+ """
138
+ parsed_imports: list[ParsedImport] = []
139
+ text: str = " ".join(source.replace("(", " ").replace(")", " ").split())
140
+ if text.startswith("from "):
141
+ rest: str = text[5:]
142
+ module, sep, names_part = rest.partition(" import ")
143
+ module = module.strip()
144
+ if not sep or not module:
145
+ return parsed_imports
146
+ for raw in names_part.split(","):
147
+ symbol: str = raw.strip().rstrip(";")
148
+ if not symbol:
149
+ continue
150
+ if symbol == "*":
151
+ parsed_imports.append(
152
+ ParsedImport(
153
+ module=module,
154
+ name="*",
155
+ start_line=start_line,
156
+ end_line=end_line,
157
+ original="*",
158
+ )
159
+ )
160
+ continue
161
+ original, alias_sep, alias = symbol.partition(" as ")
162
+ original = original.strip()
163
+ alias = alias.strip()
164
+ if not original:
165
+ continue
166
+ bound_name: str = alias if alias_sep else original
167
+ if not bound_name:
168
+ continue
169
+ parsed_imports.append(
170
+ ParsedImport(
171
+ module=module,
172
+ name=bound_name,
173
+ start_line=start_line,
174
+ end_line=end_line,
175
+ original="" if bound_name == original else original,
176
+ )
177
+ )
178
+ elif text.startswith("import "):
179
+ rest = text[7:]
180
+ for raw in rest.split(","):
181
+ symbol = raw.strip().rstrip(";")
182
+ if not symbol:
183
+ continue
184
+ dotted, sep, alias = symbol.partition(" as ")
185
+ dotted = dotted.strip()
186
+ if not dotted:
187
+ continue
188
+ bound_name = alias.strip() if sep else dotted.split(".")[0]
189
+ if bound_name:
190
+ parsed_imports.append(
191
+ ParsedImport(
192
+ module=dotted,
193
+ name=bound_name,
194
+ start_line=start_line,
195
+ end_line=end_line,
196
+ )
197
+ )
198
+ return parsed_imports
199
+
200
+
201
+ _IMPORT_RE: re.Pattern[str] = re.compile(r"^\s*(import\s+|from\s+)")
202
+
203
+
204
+ def _register_node_once(
205
+ nodes_by_id: dict[str, GraphNode],
206
+ *,
207
+ node_id: str,
208
+ graph_root: str,
209
+ rel_path: str,
210
+ language: str,
211
+ kind: NodeKind,
212
+ name: str,
213
+ start_line: int,
214
+ end_line: int,
215
+ parent_id: str | None,
216
+ ) -> None:
217
+ """Insert a node row (first registration wins)."""
218
+ if node_id in nodes_by_id:
219
+ return
220
+ nodes_by_id[node_id] = GraphNode(
221
+ id=node_id,
222
+ root=graph_root,
223
+ file_path=rel_path,
224
+ kind=kind,
225
+ name=name,
226
+ language=language,
227
+ start_line=start_line,
228
+ end_line=end_line,
229
+ parent_id=parent_id,
230
+ )
231
+
232
+
233
+ def _register_edge_once(
234
+ emitted_edges: list[Edge],
235
+ emitted_edge_keys: set[tuple[str, str, str]],
236
+ *,
237
+ graph_root: str,
238
+ src_id: str,
239
+ dst_id: str,
240
+ kind: EdgeKind,
241
+ target_module: str | None = None,
242
+ site_line: int | None = None,
243
+ ) -> None:
244
+ """Append an edge row, deduped by ``(src, dst, kind)``."""
245
+ key: tuple[str, str, str] = (src_id, dst_id, kind.value)
246
+ if key in emitted_edge_keys:
247
+ return
248
+ emitted_edge_keys.add(key)
249
+ emitted_edges.append(
250
+ Edge(
251
+ id=f"{src_id}::{kind.value}::{dst_id}",
252
+ root=graph_root,
253
+ src_id=src_id,
254
+ dst_id=dst_id,
255
+ kind=kind,
256
+ target_module=target_module,
257
+ site_line=site_line,
258
+ )
259
+ )
260
+
261
+
262
+ def collect_python_file(
263
+ rel_path: str,
264
+ syntax_root: Node,
265
+ graph_root: str,
266
+ total_lines: int,
267
+ ) -> ExtractedFileGraph:
268
+ """Walk one Python file once, collecting defs + imports + call sites.
269
+
270
+ - defs → ``Node`` + ``Edge(CONTAINS)`` with ``base:name:start:end`` ids.
271
+ - imports → ``Node(IMPORT)`` + ``Edge(IMPORTS)`` (existing statement rules).
272
+ - bare calls with a live enclosing def → buffered as
273
+ ``ParsedCall(caller=name, callee, site_line)`` for the link phase.
274
+ Module-level call sites are dropped here.
275
+
276
+ Pure given the parsed tree: no module resolution, no ``calls`` edges.
277
+ """
278
+ language: str = LANGUAGE
279
+ extracted_file = ExtractedFileGraph(
280
+ rel_path=rel_path, language=language, total_lines=total_lines
281
+ )
282
+ _register_node_once(
283
+ extracted_file.nodes,
284
+ node_id=rel_path,
285
+ graph_root=graph_root,
286
+ rel_path=rel_path,
287
+ language=language,
288
+ kind=NodeKind.FILE,
289
+ name=Path(rel_path).name,
290
+ start_line=1,
291
+ end_line=total_lines,
292
+ parent_id=None,
293
+ )
294
+ # Single unified LIFO stack preserving nesting order (class + function
295
+ # entries interleaved). Separate class/function stacks would lose the
296
+ # parent chain for closures (def-in-method-in-class) and double the
297
+ # byte-offset prune logic, so one stack is kept and named as such.
298
+ active_definition_stack: list[_ActiveDefinition] = []
299
+ # Decorator call sites buffered here and drained into the next
300
+ # def/class node: (callee_name, at_line). A skipped attribute
301
+ # decorator buffers nothing, so the drain always pairs exactly
302
+ # with the wrapped definition.
303
+ pending_decorator_sites: list[tuple[str, int]] = []
304
+ emitted_edge_keys: set[tuple[str, str, str]] = set()
305
+ first_def_node_id_by_name: dict[str, str] = {}
306
+
307
+ for syntax_node in walk(syntax_root):
308
+ if syntax_node.type == "decorator":
309
+ decorator_callee: str | None = _decorator_callee_name(syntax_node)
310
+ if decorator_callee is not None:
311
+ decorator_line: int = span(syntax_node)[0] # the @-line
312
+ pending_decorator_sites.append((decorator_callee, decorator_line))
313
+ continue
314
+ if syntax_node.type in ("class_definition", "function_definition"):
315
+ _pop_finished_definitions(
316
+ active_definition_stack, syntax_node.start_byte
317
+ )
318
+ def_name: str = _definition_name(syntax_node)
319
+ if not def_name:
320
+ continue
321
+ for decorator_callee, decorator_line in pending_decorator_sites:
322
+ extracted_file.calls.append(
323
+ ParsedCall(
324
+ caller=def_name,
325
+ callee=decorator_callee,
326
+ site_line=decorator_line,
327
+ )
328
+ )
329
+ pending_decorator_sites.clear()
330
+ if syntax_node.type == "class_definition":
331
+ definition_kind_label: str = "class"
332
+ graph_node_kind: NodeKind = NodeKind.CLASS
333
+ enclosing_definition: _ActiveDefinition | None = (
334
+ active_definition_stack[-1] if active_definition_stack else None
335
+ )
336
+ else:
337
+ enclosing_class_definition: _ActiveDefinition | None = (
338
+ _find_innermost_definition_of_kind(active_definition_stack, "class")
339
+ )
340
+ is_direct_method_of_class: bool = (
341
+ enclosing_class_definition is not None
342
+ and bool(active_definition_stack)
343
+ and active_definition_stack[-1] is enclosing_class_definition
344
+ )
345
+ definition_kind_label = (
346
+ "method" if is_direct_method_of_class else "function"
347
+ )
348
+ graph_node_kind = (
349
+ NodeKind.METHOD if is_direct_method_of_class else NodeKind.FUNCTION
350
+ )
351
+ enclosing_definition = (
352
+ active_definition_stack[-1] if active_definition_stack else None
353
+ )
354
+ start_line, end_line = span(syntax_node)
355
+ definition_node_id: str = build_defined_node_id(
356
+ rel_path, def_name, start_line, end_line
357
+ )
358
+ parent_node_id: str = (
359
+ enclosing_definition.node_id
360
+ if enclosing_definition is not None
361
+ else rel_path
362
+ )
363
+ _register_node_once(
364
+ extracted_file.nodes,
365
+ node_id=definition_node_id,
366
+ graph_root=graph_root,
367
+ rel_path=rel_path,
368
+ language=language,
369
+ kind=graph_node_kind,
370
+ name=def_name,
371
+ start_line=start_line,
372
+ end_line=end_line,
373
+ parent_id=parent_node_id,
374
+ )
375
+ _register_edge_once(
376
+ extracted_file.edges,
377
+ emitted_edge_keys,
378
+ graph_root=graph_root,
379
+ src_id=parent_node_id,
380
+ dst_id=definition_node_id,
381
+ kind=EdgeKind.CONTAINS,
382
+ )
383
+ first_def_node_id_by_name.setdefault(def_name, definition_node_id)
384
+ active_definition_stack.append(
385
+ _ActiveDefinition(
386
+ def_kind="class" if definition_kind_label == "class" else "function",
387
+ def_name=def_name,
388
+ node_id=definition_node_id,
389
+ end_byte=syntax_node.end_byte,
390
+ )
391
+ )
392
+ elif syntax_node.type in ("import_statement", "import_from_statement"):
393
+ import_statement_text: str = node_text(syntax_node)
394
+ if not _IMPORT_RE.match(import_statement_text):
395
+ continue
396
+ start_line, end_line = span(syntax_node)
397
+ for parsed_import in _imports_from_statement(
398
+ import_statement_text, start_line, end_line
399
+ ):
400
+ import_node_id: str = (
401
+ f"{rel_path}:import:{parsed_import.name}:{start_line}"
402
+ )
403
+ _register_node_once(
404
+ extracted_file.nodes,
405
+ node_id=import_node_id,
406
+ graph_root=graph_root,
407
+ rel_path=rel_path,
408
+ language=language,
409
+ kind=NodeKind.IMPORT,
410
+ name=parsed_import.name,
411
+ start_line=start_line,
412
+ end_line=end_line,
413
+ parent_id=rel_path,
414
+ )
415
+ _register_edge_once(
416
+ extracted_file.edges,
417
+ emitted_edge_keys,
418
+ graph_root=graph_root,
419
+ src_id=rel_path,
420
+ dst_id=import_node_id,
421
+ kind=EdgeKind.IMPORTS,
422
+ target_module=parsed_import.module,
423
+ )
424
+ extracted_file.imports.append(parsed_import)
425
+ else:
426
+ if syntax_node.type == "call" and (
427
+ syntax_node.parent is not None
428
+ and syntax_node.parent.type == "decorator"
429
+ ):
430
+ continue # arg-form wrapper (e.g. with_logging("debug")):
431
+ # already buffered by the decorator branch above
432
+ callee_name: str | None = _bare_callee_name(syntax_node)
433
+ if callee_name is None:
434
+ continue
435
+ _pop_finished_definitions(
436
+ active_definition_stack, syntax_node.start_byte
437
+ )
438
+ if not active_definition_stack:
439
+ continue # module-level call: drop
440
+ call_site_line: int = span(syntax_node)[0]
441
+ extracted_file.calls.append(
442
+ ParsedCall(
443
+ caller=active_definition_stack[-1].def_name,
444
+ callee=callee_name,
445
+ site_line=call_site_line,
446
+ )
447
+ )
448
+
449
+ extracted_file.definitions.update(first_def_node_id_by_name)
450
+ return extracted_file
451
+
452
+
453
+ __all__ = [
454
+ "ExtractedFileGraph",
455
+ "LANGUAGE",
456
+ "build_defined_node_id",
457
+ "collect_python_file",
458
+ ]