Navegador

navegador / navegador / ingestion / python.py
Blame History Raw 283 lines
1
"""
2
Python AST parser — extracts classes, functions, imports, calls, and
3
their relationships from .py files using tree-sitter.
4
"""
5
6
import logging
7
from pathlib import Path
8
9
from navegador.graph.schema import EdgeType, NodeLabel
10
from navegador.graph.store import GraphStore
11
from navegador.ingestion.parser import LanguageParser
12
13
logger = logging.getLogger(__name__)
14
15
16
def _get_python_language():
17
try:
18
import tree_sitter_python as tspython # type: ignore[import]
19
from tree_sitter import Language
20
21
return Language(tspython.language())
22
except ImportError as e:
23
raise ImportError("Install tree-sitter-python: pip install tree-sitter-python") from e
24
25
26
def _get_parser():
27
from tree_sitter import Parser # type: ignore[import]
28
29
parser = Parser(_get_python_language())
30
return parser
31
32
33
def _node_text(node, source: bytes) -> str:
34
return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
35
36
37
def _get_docstring(node, source: bytes) -> str | None:
38
"""Extract the first string literal from a function/class body as docstring."""
39
body = next((c for c in node.children if c.type == "block"), None)
40
if not body:
41
return None
42
first_stmt = next((c for c in body.children if c.type == "expression_statement"), None)
43
if not first_stmt:
44
return None
45
string_node = next(
46
(c for c in first_stmt.children if c.type in ("string", "string_content")), None
47
)
48
if string_node:
49
raw = _node_text(string_node, source)
50
return raw.strip('"""').strip("'''").strip('"').strip("'").strip()
51
return None
52
53
54
class PythonParser(LanguageParser):
55
def __init__(self) -> None:
56
self._parser = _get_parser()
57
58
def parse_file(self, path: Path, repo_root: Path, store: GraphStore) -> dict[str, int]:
59
source = path.read_bytes()
60
tree = self._parser.parse(source)
61
rel_path = str(path.relative_to(repo_root))
62
63
stats = {"functions": 0, "classes": 0, "edges": 0}
64
65
# File node
66
store.create_node(
67
NodeLabel.File,
68
{
69
"name": path.name,
70
"path": rel_path,
71
"language": "python",
72
"line_count": source.count(b"\n"),
73
},
74
)
75
76
self._walk(tree.root_node, source, rel_path, store, stats, class_name=None)
77
return stats
78
79
def _walk(
80
self,
81
node,
82
source: bytes,
83
file_path: str,
84
store: GraphStore,
85
stats: dict,
86
class_name: str | None,
87
) -> None:
88
if node.type == "import_statement":
89
self._handle_import(node, source, file_path, store, stats)
90
91
elif node.type == "import_from_statement":
92
self._handle_import_from(node, source, file_path, store, stats)
93
94
elif node.type == "class_definition":
95
self._handle_class(node, source, file_path, store, stats)
96
return # class walker handles children
97
98
elif node.type == "function_definition":
99
self._handle_function(node, source, file_path, store, stats, class_name)
100
return # function walker handles children
101
102
for child in node.children:
103
self._walk(child, source, file_path, store, stats, class_name)
104
105
def _handle_import(
106
self, node, source: bytes, file_path: str, store: GraphStore, stats: dict
107
) -> None:
108
for child in node.children:
109
if child.type == "dotted_name":
110
name = _node_text(child, source)
111
store.create_node(
112
NodeLabel.Import,
113
{
114
"name": name,
115
"file_path": file_path,
116
"line_start": node.start_point[0] + 1,
117
"module": name,
118
},
119
)
120
store.create_edge(
121
NodeLabel.File,
122
{"path": file_path},
123
EdgeType.IMPORTS,
124
NodeLabel.Import,
125
{"name": name, "file_path": file_path},
126
)
127
stats["edges"] += 1
128
129
def _handle_import_from(
130
self, node, source: bytes, file_path: str, store: GraphStore, stats: dict
131
) -> None:
132
module = ""
133
for child in node.children:
134
if child.type in ("dotted_name", "relative_import"):
135
module = _node_text(child, source)
136
break
137
for child in node.children:
138
if child.type == "import_from_member":
139
name = _node_text(child, source)
140
store.create_node(
141
NodeLabel.Import,
142
{
143
"name": name,
144
"file_path": file_path,
145
"line_start": node.start_point[0] + 1,
146
"module": module,
147
},
148
)
149
store.create_edge(
150
NodeLabel.File,
151
{"path": file_path},
152
EdgeType.IMPORTS,
153
NodeLabel.Import,
154
{"name": name, "file_path": file_path},
155
)
156
stats["edges"] += 1
157
158
def _handle_class(
159
self, node, source: bytes, file_path: str, store: GraphStore, stats: dict
160
) -> None:
161
name_node = next((c for c in node.children if c.type == "identifier"), None)
162
if not name_node:
163
return
164
name = _node_text(name_node, source)
165
docstring = _get_docstring(node, source)
166
167
store.create_node(
168
NodeLabel.Class,
169
{
170
"name": name,
171
"file_path": file_path,
172
"line_start": node.start_point[0] + 1,
173
"line_end": node.end_point[0] + 1,
174
"docstring": docstring or "",
175
},
176
)
177
store.create_edge(
178
NodeLabel.File,
179
{"path": file_path},
180
EdgeType.CONTAINS,
181
NodeLabel.Class,
182
{"name": name, "file_path": file_path},
183
)
184
stats["classes"] += 1
185
stats["edges"] += 1
186
187
# Inheritance
188
for child in node.children:
189
if child.type == "argument_list":
190
for arg in child.children:
191
if arg.type == "identifier":
192
parent_name = _node_text(arg, source)
193
store.create_edge(
194
NodeLabel.Class,
195
{"name": name, "file_path": file_path},
196
EdgeType.INHERITS,
197
NodeLabel.Class,
198
{"name": parent_name, "file_path": file_path},
199
)
200
stats["edges"] += 1
201
202
# Walk class body for methods
203
body = next((c for c in node.children if c.type == "block"), None)
204
if body:
205
for child in body.children:
206
if child.type == "function_definition":
207
self._handle_function(child, source, file_path, store, stats, class_name=name)
208
209
def _handle_function(
210
self,
211
node,
212
source: bytes,
213
file_path: str,
214
store: GraphStore,
215
stats: dict,
216
class_name: str | None,
217
) -> None:
218
name_node = next((c for c in node.children if c.type == "identifier"), None)
219
if not name_node:
220
return
221
name = _node_text(name_node, source)
222
docstring = _get_docstring(node, source)
223
224
label = NodeLabel.Method if class_name else NodeLabel.Function
225
props = {
226
"name": name,
227
"file_path": file_path,
228
"line_start": node.start_point[0] + 1,
229
"line_end": node.end_point[0] + 1,
230
"docstring": docstring or "",
231
"class_name": class_name or "",
232
}
233
store.create_node(label, props)
234
235
container_label = NodeLabel.Class if class_name else NodeLabel.File
236
container_key = (
237
{"name": class_name, "file_path": file_path} if class_name else {"path": file_path}
238
)
239
store.create_edge(
240
container_label,
241
container_key,
242
EdgeType.CONTAINS,
243
label,
244
{"name": name, "file_path": file_path},
245
)
246
stats["functions"] += 1
247
stats["edges"] += 1
248
249
# Call edges — find all call expressions in the body
250
self._extract_calls(node, source, file_path, name, label, store, stats)
251
252
def _extract_calls(
253
self,
254
fn_node,
255
source: bytes,
256
file_path: str,
257
fn_name: str,
258
fn_label: str,
259
store: GraphStore,
260
stats: dict,
261
) -> None:
262
def walk_calls(node):
263
if node.type == "call":
264
func = next(
265
(c for c in node.children if c.type in ("identifier", "attribute")), None
266
)
267
if func:
268
callee_name = _node_text(func, source).split(".")[-1]
269
store.create_edge(
270
fn_label,
271
{"name": fn_name, "file_path": file_path},
272
EdgeType.CALLS,
273
NodeLabel.Function,
274
{"name": callee_name, "file_path": file_path},
275
)
276
stats["edges"] += 1
277
for child in node.children:
278
walk_calls(child)
279
280
body = next((c for c in fn_node.children if c.type == "block"), None)
281
if body:
282
walk_calls(body)
283

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button