node-walk 0.1.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,559 @@
1
+ """
2
+ AST visitor — walks a Tree-sitter parse tree and collects
3
+ symbols and relationships for a single Python source file.
4
+
5
+ This module contains:
6
+ - Module-level helper functions (node text, line numbers, docstrings)
7
+ - Constants (ABC/Protocol base names, tree-sitter parser setup)
8
+ - SymbolCollector — the stateful visitor class
9
+
10
+ Resolution strategy
11
+ -------------------
12
+ CONTAINS Always resolved (structural, not semantic).
13
+ IMPORTS Emitted as UNRESOLVED; resolved in the cross-file pass (Indexer).
14
+ CALLS Best-effort: exact FQN match → RESOLVED; simple name match →
15
+ PROBABLE; no match → UNRESOLVED (target_id = "").
16
+ EXTENDS /
17
+ IMPLEMENTS Emitted as UNRESOLVED; resolved in the cross-file pass.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import tree_sitter_python as tspython
26
+ from tree_sitter import Language as TSLanguage, Node, Parser
27
+
28
+ from node_walk.analysis.python.scope import Scope
29
+ from node_walk.ir.enums import Language, RelationshipType, ResolutionStatus, SymbolKind
30
+ from node_walk.ir.models import (
31
+ AnalysisResult,
32
+ FileInfo,
33
+ Relationship,
34
+ SourceLocation,
35
+ Symbol,
36
+ )
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Tree-sitter parser (module-level singleton — safe to share across threads
40
+ # since parse() is called per-file and returns a new tree each time)
41
+ # ---------------------------------------------------------------------------
42
+
43
+ _PY_LANGUAGE = TSLanguage(tspython.language())
44
+ _PARSER = Parser(_PY_LANGUAGE)
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Constants
48
+ # ---------------------------------------------------------------------------
49
+
50
+ # Base class names that mark a class as an interface/protocol
51
+ _ABC_BASES: frozenset[str] = frozenset({"ABC", "ABCMeta"})
52
+ _PROTOCOL_BASES: frozenset[str] = frozenset({"Protocol"})
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Helper functions
57
+ # ---------------------------------------------------------------------------
58
+
59
+
60
+ def node_text(node: Node, source: bytes) -> str:
61
+ """Return the UTF-8 text of a tree-sitter node."""
62
+ return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
63
+
64
+
65
+ def start_line(node: Node) -> int:
66
+ """1-indexed start line of a node."""
67
+ return node.start_point[0] + 1
68
+
69
+
70
+ def end_line(node: Node) -> int:
71
+ """1-indexed end line of a node."""
72
+ return node.end_point[0] + 1
73
+
74
+
75
+ def first_docstring(node: Node, source: bytes) -> str:
76
+ """
77
+ Extract the first string literal from a block body node.
78
+ Returns an empty string if none is found.
79
+ """
80
+ for child in node.children:
81
+ if child.type == "expression_statement":
82
+ for grandchild in child.children:
83
+ if grandchild.type in ("string", "concatenated_string"):
84
+ raw = node_text(grandchild, source)
85
+ for q in ('"""', "'''", '"', "'"):
86
+ if raw.startswith(q) and raw.endswith(q) and len(raw) >= 2 * len(q):
87
+ return raw[len(q) : -len(q)].strip()
88
+ return ""
89
+
90
+
91
+ def derive_module_qname(file_path: str) -> str:
92
+ """
93
+ Derive a dotted module qualified name from an absolute file path.
94
+
95
+ Walks up the directory tree while __init__.py exists (i.e., while
96
+ we are inside a package). Falls back to the file stem otherwise.
97
+
98
+ Example: ``/repo/myapp/services/users.py`` → ``myapp.services.users``
99
+ """
100
+ p = Path(file_path)
101
+ parts: list[str] = [p.stem if p.suffix == ".py" else p.name]
102
+ current = p.parent
103
+
104
+ while (current / "__init__.py").exists():
105
+ parts.append(current.name)
106
+ current = current.parent
107
+
108
+ parts.reverse()
109
+ return ".".join(parts)
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # AST visitor
114
+ # ---------------------------------------------------------------------------
115
+
116
+
117
+ class SymbolCollector:
118
+ """
119
+ Stateful visitor that walks a Tree-sitter Python AST and produces
120
+ lists of Symbol and Relationship objects.
121
+
122
+ Usage::
123
+
124
+ collector = SymbolCollector(file_info, source_bytes)
125
+ collector.visit(tree.root_node)
126
+ result = AnalysisResult(
127
+ file=file_info,
128
+ symbols=collector.symbols,
129
+ relationships=collector.relationships,
130
+ )
131
+ """
132
+
133
+ def __init__(self, file_info: FileInfo, source_bytes: bytes) -> None:
134
+ self.file_info = file_info
135
+ self.source = source_bytes
136
+ self.symbols: list[Symbol] = []
137
+ self.relationships: list[Relationship] = []
138
+
139
+ # Fast lookup: simple name → Symbol (for in-file resolution)
140
+ self._local_by_name: dict[str, Symbol] = {}
141
+ # Fast lookup: qualified name → Symbol (for in-file resolution)
142
+ self._local_by_qname: dict[str, Symbol] = {}
143
+
144
+ self._scope = Scope()
145
+ self._module_qname = derive_module_qname(file_info.path)
146
+ self._file_symbol = self._make_file_symbol()
147
+
148
+ # ------------------------------------------------------------------
149
+ # Entry point
150
+ # ------------------------------------------------------------------
151
+
152
+ def visit(self, root: Node) -> None:
153
+ """Walk the module root node and collect everything."""
154
+ self._visit_module(root)
155
+
156
+ # ------------------------------------------------------------------
157
+ # Module
158
+ # ------------------------------------------------------------------
159
+
160
+ def _visit_module(self, node: Node) -> None:
161
+ doc = first_docstring(node, self.source)
162
+ name = self._module_qname.split(".")[-1] if self._module_qname else "__main__"
163
+
164
+ module_sym = Symbol(
165
+ id=self._file_symbol.id, # module and file share the same id
166
+ name=name,
167
+ qualified_name=self._module_qname or "__main__",
168
+ kind=SymbolKind.MODULE,
169
+ language=Language.PYTHON,
170
+ file_id=self.file_info.id,
171
+ start_line=1,
172
+ end_line=end_line(node),
173
+ docstring=doc,
174
+ )
175
+ self.symbols.append(self._file_symbol)
176
+ self._register(module_sym, skip_append=True) # shares file_symbol's slot
177
+
178
+ self._scope.push(module_sym)
179
+ for child in node.children:
180
+ self._visit_top_level(child)
181
+ self._scope.pop()
182
+
183
+ # ------------------------------------------------------------------
184
+ # Top-level statement dispatch
185
+ # ------------------------------------------------------------------
186
+
187
+ def _visit_top_level(self, node: Node) -> None:
188
+ t = node.type
189
+ if t in ("import_statement", "import_from_statement"):
190
+ self._handle_import(node)
191
+ elif t == "class_definition":
192
+ self._handle_class(node)
193
+ elif t in ("function_definition", "decorated_definition"):
194
+ self._handle_function_or_decorated(node, parent_sym=self._scope.current)
195
+ elif t == "expression_statement":
196
+ for child in node.children:
197
+ if child.type == "assignment":
198
+ self._handle_assignment(child)
199
+ break
200
+ elif t == "assignment":
201
+ self._handle_assignment(node)
202
+
203
+ # ------------------------------------------------------------------
204
+ # Imports
205
+ # ------------------------------------------------------------------
206
+
207
+ def _handle_import(self, node: Node) -> None:
208
+ """Handle ``import x`` and ``from x import y`` statements."""
209
+ loc = SourceLocation(file_id=self.file_info.id, line=start_line(node))
210
+ file_sym = self._file_symbol
211
+
212
+ if node.type == "import_statement":
213
+ for child in node.children:
214
+ if child.type == "dotted_name":
215
+ self._emit_import(file_sym, node_text(child, self.source), loc)
216
+ elif child.type == "aliased_import":
217
+ for sub in child.children:
218
+ if sub.type == "dotted_name":
219
+ self._emit_import(file_sym, node_text(sub, self.source), loc)
220
+ break
221
+
222
+ elif node.type == "import_from_statement":
223
+ module_parts: list[str] = []
224
+ imported_names: list[str] = []
225
+ reading_module = True
226
+
227
+ for child in node.children:
228
+ if child.type in ("from", "import"):
229
+ if child.type == "import":
230
+ reading_module = False
231
+ continue
232
+ if reading_module:
233
+ if child.type in ("dotted_name", "relative_import"):
234
+ module_parts.append(node_text(child, self.source))
235
+ else:
236
+ if child.type == "dotted_name":
237
+ imported_names.append(node_text(child, self.source))
238
+ elif child.type == "aliased_import":
239
+ for sub in child.children:
240
+ if sub.type == "dotted_name":
241
+ imported_names.append(node_text(sub, self.source))
242
+ break
243
+ elif child.type == "wildcard_import":
244
+ imported_names.append("*")
245
+
246
+ module_str = ".".join(module_parts)
247
+ if imported_names:
248
+ for name in imported_names:
249
+ full = (
250
+ f"{module_str}.{name}"
251
+ if module_str and name != "*"
252
+ else module_str or name
253
+ )
254
+ self._emit_import(file_sym, full, loc)
255
+ elif module_str:
256
+ self._emit_import(file_sym, module_str, loc)
257
+
258
+ def _emit_import(self, source_sym: Symbol, target_name: str, loc: SourceLocation) -> None:
259
+ self.relationships.append(
260
+ Relationship(
261
+ source_id=source_sym.id,
262
+ target_id="",
263
+ type=RelationshipType.IMPORTS,
264
+ source_location=loc,
265
+ resolution=ResolutionStatus.UNRESOLVED,
266
+ metadata={"target_name": target_name},
267
+ )
268
+ )
269
+
270
+ # ------------------------------------------------------------------
271
+ # Classes
272
+ # ------------------------------------------------------------------
273
+
274
+ def _handle_class(self, node: Node) -> None:
275
+ name_node = node.child_by_field_name("name")
276
+ if not name_node:
277
+ return
278
+ name = node_text(name_node, self.source)
279
+
280
+ bases = self._extract_base_names(node)
281
+ is_interface = bool(_ABC_BASES & set(bases) or _PROTOCOL_BASES & set(bases))
282
+ kind = SymbolKind.INTERFACE if is_interface else SymbolKind.CLASS
283
+
284
+ parent_sym = self._scope.current
285
+ qname = f"{self._scope.qualified_prefix(self._module_qname)}.{name}"
286
+
287
+ body_node = node.child_by_field_name("body")
288
+ doc = first_docstring(body_node, self.source) if body_node else ""
289
+
290
+ class_sym = Symbol(
291
+ name=name,
292
+ qualified_name=qname,
293
+ kind=kind,
294
+ language=Language.PYTHON,
295
+ file_id=self.file_info.id,
296
+ start_line=start_line(node),
297
+ end_line=end_line(node),
298
+ parent_id=parent_sym.id if parent_sym else None,
299
+ docstring=doc,
300
+ )
301
+ self._register(class_sym)
302
+ if parent_sym:
303
+ self._emit_contains(parent_sym.id, class_sym.id, start_line(node))
304
+
305
+ # Inheritance edges (unresolved at this stage)
306
+ for base_name in bases:
307
+ if base_name in _ABC_BASES | _PROTOCOL_BASES:
308
+ continue
309
+ rel_type = RelationshipType.IMPLEMENTS if is_interface else RelationshipType.EXTENDS
310
+ self.relationships.append(
311
+ Relationship(
312
+ source_id=class_sym.id,
313
+ target_id="",
314
+ type=rel_type,
315
+ resolution=ResolutionStatus.UNRESOLVED,
316
+ metadata={"target_name": base_name},
317
+ )
318
+ )
319
+
320
+ if body_node:
321
+ self._scope.push(class_sym)
322
+ for child in body_node.children:
323
+ ct = child.type
324
+ if ct in ("function_definition", "decorated_definition"):
325
+ self._handle_function_or_decorated(child, parent_sym=class_sym)
326
+ elif ct == "class_definition":
327
+ self._handle_class(child)
328
+ elif ct in ("expression_statement", "assignment"):
329
+ self._handle_class_field(child, class_sym)
330
+ self._scope.pop()
331
+
332
+ def _extract_base_names(self, class_node: Node) -> list[str]:
333
+ """Return simple base class names for a class_definition node."""
334
+ bases: list[str] = []
335
+ args_node = class_node.child_by_field_name("superclasses")
336
+ if not args_node:
337
+ return bases
338
+ for child in args_node.children:
339
+ if child.type in ("identifier", "attribute"):
340
+ bases.append(node_text(child, self.source).split(".")[-1])
341
+ return bases
342
+
343
+ def _handle_class_field(self, node: Node, class_sym: Symbol) -> None:
344
+ """Handle class-level assignments (FIELD / CONSTANT symbols)."""
345
+ # Unwrap expression_statement if needed
346
+ target_node = node
347
+ if node.type == "expression_statement":
348
+ for child in node.children:
349
+ if child.type == "assignment":
350
+ target_node = child
351
+ break
352
+ else:
353
+ return
354
+
355
+ for target_name in self._extract_assignment_targets(target_node):
356
+ if not target_name.isidentifier():
357
+ continue
358
+ kind = SymbolKind.CONSTANT if target_name.isupper() else SymbolKind.FIELD
359
+ sym = Symbol(
360
+ name=target_name,
361
+ qualified_name=f"{class_sym.qualified_name}.{target_name}",
362
+ kind=kind,
363
+ language=Language.PYTHON,
364
+ file_id=self.file_info.id,
365
+ start_line=start_line(node),
366
+ end_line=end_line(node),
367
+ parent_id=class_sym.id,
368
+ )
369
+ self._register(sym)
370
+ self._emit_contains(class_sym.id, sym.id, start_line(node))
371
+
372
+ # ------------------------------------------------------------------
373
+ # Functions and methods
374
+ # ------------------------------------------------------------------
375
+
376
+ def _handle_function_or_decorated(
377
+ self, node: Node, parent_sym: Symbol | None
378
+ ) -> None:
379
+ if node.type == "decorated_definition":
380
+ for child in node.children:
381
+ if child.type == "function_definition":
382
+ self._handle_function(child, parent_sym)
383
+ return
384
+ if child.type == "class_definition":
385
+ self._handle_class(child)
386
+ return
387
+ else:
388
+ self._handle_function(node, parent_sym)
389
+
390
+ def _handle_function(self, node: Node, parent_sym: Symbol | None) -> None:
391
+ name_node = node.child_by_field_name("name")
392
+ if not name_node:
393
+ return
394
+ name = node_text(name_node, self.source)
395
+
396
+ is_method = parent_sym is not None and parent_sym.kind in (
397
+ SymbolKind.CLASS,
398
+ SymbolKind.INTERFACE,
399
+ )
400
+ kind = SymbolKind.METHOD if is_method else SymbolKind.FUNCTION
401
+ is_async = any(c.type == "async" for c in node.children)
402
+
403
+ params_node = node.child_by_field_name("parameters")
404
+ return_node = node.child_by_field_name("return_type")
405
+ sig_parts = []
406
+ if params_node:
407
+ sig_parts.append(node_text(params_node, self.source))
408
+ if return_node:
409
+ sig_parts.append(f"-> {node_text(return_node, self.source)}")
410
+ signature = " ".join(sig_parts)
411
+
412
+ qname = f"{self._scope.qualified_prefix(self._module_qname)}.{name}"
413
+ body_node = node.child_by_field_name("body")
414
+ doc = first_docstring(body_node, self.source) if body_node else ""
415
+
416
+ func_sym = Symbol(
417
+ name=name,
418
+ qualified_name=qname,
419
+ kind=kind,
420
+ language=Language.PYTHON,
421
+ file_id=self.file_info.id,
422
+ start_line=start_line(node),
423
+ end_line=end_line(node),
424
+ signature=signature,
425
+ parent_id=parent_sym.id if parent_sym else self._file_symbol.id,
426
+ docstring=doc,
427
+ is_async=is_async,
428
+ )
429
+ self._register(func_sym)
430
+
431
+ container = parent_sym or self._file_symbol
432
+ self._emit_contains(container.id, func_sym.id, start_line(node))
433
+
434
+ if body_node:
435
+ self._scope.push(func_sym)
436
+ self._collect_calls(body_node, func_sym)
437
+ for child in body_node.children:
438
+ if child.type in ("function_definition", "decorated_definition"):
439
+ self._handle_function_or_decorated(child, parent_sym=func_sym)
440
+ elif child.type == "class_definition":
441
+ self._handle_class(child)
442
+ self._scope.pop()
443
+
444
+ # ------------------------------------------------------------------
445
+ # Call sites
446
+ # ------------------------------------------------------------------
447
+
448
+ def _collect_calls(self, node: Node, caller_sym: Symbol) -> None:
449
+ """Recursively find all call expressions within a function/method body."""
450
+ if node.type == "call":
451
+ self._handle_call(node, caller_sym)
452
+ for child in node.children:
453
+ if child.type not in ("function_definition", "class_definition", "decorated_definition"):
454
+ self._collect_calls(child, caller_sym)
455
+
456
+ def _handle_call(self, node: Node, caller_sym: Symbol) -> None:
457
+ func_node = node.child_by_field_name("function")
458
+ if not func_node:
459
+ return
460
+
461
+ call_text = node_text(func_node, self.source)
462
+ callee_name = call_text.split(".")[-1]
463
+ loc = SourceLocation(
464
+ file_id=self.file_info.id,
465
+ line=start_line(node),
466
+ col=node.start_point[1],
467
+ )
468
+
469
+ target_id = ""
470
+ resolution = ResolutionStatus.UNRESOLVED
471
+
472
+ fqn_match = self._local_by_qname.get(call_text)
473
+ name_match = self._local_by_name.get(callee_name)
474
+ if fqn_match:
475
+ target_id = fqn_match.id
476
+ resolution = ResolutionStatus.RESOLVED
477
+ elif name_match:
478
+ target_id = name_match.id
479
+ resolution = ResolutionStatus.PROBABLE
480
+
481
+ self.relationships.append(
482
+ Relationship(
483
+ source_id=caller_sym.id,
484
+ target_id=target_id,
485
+ type=RelationshipType.CALLS,
486
+ source_location=loc,
487
+ resolution=resolution,
488
+ metadata={"call_text": call_text, "callee_name": callee_name},
489
+ )
490
+ )
491
+
492
+ # ------------------------------------------------------------------
493
+ # Module-level assignments
494
+ # ------------------------------------------------------------------
495
+
496
+ def _handle_assignment(self, node: Node) -> None:
497
+ """Handle module-level VARIABLE / CONSTANT symbols."""
498
+ for target_name in self._extract_assignment_targets(node):
499
+ if not target_name.isidentifier():
500
+ continue
501
+ kind = SymbolKind.CONSTANT if target_name.isupper() else SymbolKind.VARIABLE
502
+ qname = f"{self._module_qname}.{target_name}" if self._module_qname else target_name
503
+ sym = Symbol(
504
+ name=target_name,
505
+ qualified_name=qname,
506
+ kind=kind,
507
+ language=Language.PYTHON,
508
+ file_id=self.file_info.id,
509
+ start_line=start_line(node),
510
+ end_line=end_line(node),
511
+ parent_id=self._file_symbol.id,
512
+ )
513
+ self._register(sym)
514
+ self._emit_contains(self._file_symbol.id, sym.id, start_line(node))
515
+
516
+ # ------------------------------------------------------------------
517
+ # Private helpers
518
+ # ------------------------------------------------------------------
519
+
520
+ def _register(self, sym: Symbol, skip_append: bool = False) -> None:
521
+ if not skip_append:
522
+ self.symbols.append(sym)
523
+ self._local_by_name[sym.name] = sym
524
+ self._local_by_qname[sym.qualified_name] = sym
525
+
526
+ def _emit_contains(self, source_id: str, target_id: str, line: int) -> None:
527
+ self.relationships.append(
528
+ Relationship(
529
+ source_id=source_id,
530
+ target_id=target_id,
531
+ type=RelationshipType.CONTAINS,
532
+ source_location=SourceLocation(file_id=self.file_info.id, line=line),
533
+ )
534
+ )
535
+
536
+ def _make_file_symbol(self) -> Symbol:
537
+ return Symbol(
538
+ name=Path(self.file_info.path).name,
539
+ qualified_name=self.file_info.path,
540
+ kind=SymbolKind.FILE,
541
+ language=Language.PYTHON,
542
+ file_id=self.file_info.id,
543
+ start_line=1,
544
+ end_line=1,
545
+ )
546
+
547
+ @staticmethod
548
+ def _extract_assignment_targets(node: Node) -> list[str]:
549
+ targets: list[str] = []
550
+ for child in node.children:
551
+ if child.type == "identifier":
552
+ text = child.text
553
+ if text:
554
+ targets.append(text.decode("utf-8", errors="replace"))
555
+ elif child.type == "pattern_list":
556
+ for sub in child.children:
557
+ if sub.type == "identifier" and sub.text:
558
+ targets.append(sub.text.decode("utf-8", errors="replace"))
559
+ return [t for t in targets if t]
@@ -0,0 +1,14 @@
1
+ """
2
+ node_walk.analysis.python_analyzer — backward-compatibility shim.
3
+
4
+ The Python analyzer now lives in the node_walk.analysis.python sub-package:
5
+ - node_walk.analysis.python.analyzer (PythonAnalyzer)
6
+ - node_walk.analysis.python.visitor (SymbolCollector + helpers)
7
+ - node_walk.analysis.python.scope (Scope)
8
+
9
+ This module re-exports PythonAnalyzer so existing imports keep working.
10
+ """
11
+
12
+ from node_walk.analysis.python.analyzer import PythonAnalyzer
13
+
14
+ __all__ = ["PythonAnalyzer"]
@@ -0,0 +1 @@
1
+ """CLI — Typer-based command-line interface."""