Coverage for src/renaissance/integrations/python/ast/rst_node.py: 96%

332 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-15 10:34 +0000

1# Copyright (C) 2024-2026 TNO 

2# 

3# Licensed under the terms specified in the LICENSE file in the root directory 

4# of this repository, also available at: 

5# https://github.com/TNO/Renaissance.Py/blob/main/LICENSE 

6 

7"""AI: ASTNode implementation backed by Python's built-in `ast` module (the "RST" representation).""" 

8 

9import ast 

10import sys 

11import textwrap 

12from collections.abc import Callable, Sequence 

13from pathlib import Path 

14from typing import Any, Self 

15 

16from renaissance.integrations.python.ast.kinds import PYTHON_KIND_MAP, PYTHON_OPERATOR_MAP 

17from renaissance.integrations.python.ast.util import convert 

18from renaissance.syntax_tree.match_finder import find_in_list 

19from renaissance.syntax_tree.semantic_kind import SemanticKind 

20from renaissance.utils.ast_utils import ( 

21 format_node, 

22 match_children, 

23 match_props, 

24 next_sibling, 

25 preceding_sibling, 

26 traverse, 

27) 

28 

29types = ["int", "float", "str", "list", "set", "tuple", "Mapping", "dict", "Optional"] 

30IRRELEVANT_PROPS = {"comment"} 

31IRRELEVANT_NODES = {"comment"} 

32IMPLICIT = {"ImplicitNode"} 

33 

34 

35class ImplicitNode(ast.Name): 

36 """AI: Represent a synthetic AST node inserted where the real source has none.""" 

37 

38 _fields = ( 

39 "id", 

40 "body", 

41 ) 

42 

43 _field_types = { 

44 "id": str, 

45 "body": list, 

46 } 

47 

48 def __init__(self, name, children=None) -> None: 

49 """AI: Represent a synthetic AST node inserted where the real source has none.""" 

50 super().__init__(name, children or []) 

51 self.lineno = 0 

52 self.col_offset = 0 

53 self.end_lineno = 0 

54 self.end_col_offset = 0 

55 

56 

57class PythonRSTReference: 

58 """AI: Represent a reference from one Python RST AST node to another by id.""" 

59 

60 def __repr__(self) -> str: 

61 """AI: Return a string identifying the referenced node id and reference kind.""" 

62 return f"{self.node_id}:{self.ref_kind}" 

63 

64 def __init__(self, node_id: str, ref_kind: str, properties: dict[str, Any]) -> None: 

65 """AI: Represent a reference from one Python RST AST node to another by id.""" 

66 self.node_id = node_id 

67 self.ref_kind = ref_kind 

68 self.properties = properties 

69 

70 

71class PythonRstTranslationUnit: 

72 """AI: Parse Python source into a stdlib ast tree with lazily-built reference caches.""" 

73 

74 cache = {} 

75 

76 def __init__(self, content, file_name: str) -> None: 

77 """AI: Parse Python source into a stdlib ast tree with lazily-built reference caches.""" 

78 # TODO: see docs/developer/architecture/position-consistency.md - PEP 263 declares the encoding, not the host. 

79 self.content = content.encode(sys.getfilesystemencoding()) 

80 self.atu = ast.parse(content, file_name) 

81 self.file_name = file_name 

82 self.references_initialized = False 

83 PythonRstTranslationUnit.cache[file_name] = content 

84 self.lines = self.content.splitlines() 

85 

86 self._references: dict[str, list[PythonRSTReference]] = {} 

87 self._referenced_by: dict[str, list[PythonRSTReference]] = {} 

88 self._nodes: dict[str, PythonRstNode] = {} 

89 

90 def check_diagnostics(self, continue_with_warning=True) -> None: 

91 """AI: Report ast type-ignore comments, raising an Exception unless continue_with_warning is True.""" 

92 msg = None 

93 errors = "" 

94 for d in self.atu.type_ignores: 

95 msg = f"type ignored: {d.tag} at {d.lineno}\n" 

96 errors += msg 

97 print(msg) 

98 if msg and not continue_with_warning: 

99 raise Exception(f"Error parsing: {self.file_name} \n+ errors: {errors}") 

100 

101 def lazy_create_refers(self, node: PythonRstNode) -> None: 

102 """AI: Build the translation unit's reference cache on first use, then no-op on subsequent calls.""" 

103 if self.references_initialized: 

104 return 

105 for n in traverse(node.root): 

106 self.create_references(n) 

107 self.references_initialized = True 

108 

109 def add(self, node) -> None: 

110 """AI: Register node in the node-name lookup table, keyed by its parser-kind-specific name.""" 

111 match node.parser_kind: 

112 case "Name": 

113 if node.node.id not in self._nodes and node.node.id not in types: 

114 self._nodes[node.node.id] = node 

115 case "FunctionDef": 

116 if node.node.name not in self._nodes: 

117 self._nodes[node.node.name] = node 

118 case "Call": 

119 if node.name not in self._nodes: 

120 self._nodes[node.name] = node 

121 case "ClassDef": 

122 if node.name not in self._nodes: 

123 self._nodes[node.name] = node 

124 case "arg": 

125 if node.name != "self" and node.name not in self._nodes: 

126 self._nodes[node.name] = node 

127 

128 def create_references(self, ast_node) -> None: 

129 """AI: Derive and record type, call, and inheritance references for ast_node based on its ast type.""" 

130 assert isinstance(ast_node, PythonRstNode), f"Expected PythonASTNode but got {type(ast_node)}" 

131 match type(ast_node.node): 

132 case ast.arg: 

133 # TODO: is excluding self here intentional..? 

134 if ast_node.name != "self" and isinstance(ast_node.node, ast.arg) and isinstance(ast_node.node.annotation, ast.Name): 

135 node_id = ast_node.name 

136 ref_id = ast_node.node.annotation.id 

137 ref_kind = "TypeRef" 

138 self.add_reference(node_id, ref_id, ref_kind) 

139 case ast.FunctionDef | ast.AsyncFunctionDef: 

140 if isinstance(ast_node.node, (ast.FunctionDef, ast.AsyncFunctionDef)) and isinstance(ast_node.node.returns, ast.Name): 

141 node_id = ast_node.name 

142 ref_id = ast_node.node.returns.id 

143 ref_kind = "TypeRef" 

144 self.add_reference(node_id, ref_id, ref_kind) 

145 case ast.Assign: 

146 if isinstance(ast_node.node, ast.Assign): 

147 for n in ast_node.node.targets: 

148 if isinstance(n, ast.Name) and isinstance(ast_node.node.value, ast.Call): 

149 node_id = n.id 

150 func = ast_node.node.value.func 

151 ref_id = func.id if isinstance(func, ast.Name) else None 

152 if ref_id: 

153 ref_kind = "CallRef" 

154 self.add_reference(node_id, ref_id, ref_kind) 

155 case ast.AnnAssign: 

156 if isinstance(ast_node.node, ast.AnnAssign) and ( 

157 ast_node.node.annotation 

158 and isinstance(ast_node.node.target, ast.Name) 

159 and isinstance(ast_node.node.annotation, ast.Name) 

160 ): 

161 node_id = ast_node.node.target.id 

162 ref_id = ast_node.node.annotation.id 

163 ref_kind = "TypeRef" 

164 self.add_reference(node_id, ref_id, ref_kind) 

165 case ast.ClassDef: 

166 if isinstance(ast_node.node, ast.ClassDef): 

167 node = ast_node.node 

168 node_id = node.name 

169 if node.bases: 

170 ref_node = node.bases[0] 

171 if isinstance(ref_node, ast.Name): 

172 ref_id = ref_node.id 

173 ref_kind = "Inherit" 

174 self.add_reference(node_id, ref_id, ref_kind) 

175 # add functions and attributes to class 

176 

177 case ast.Call: 

178 if isinstance(ast_node.node, ast.Call): 

179 # obj.function. then obj refers to function 

180 if isinstance(ast_node.node.func, ast.Attribute): 

181 node_id = ast_node.name 

182 ref_id = ast_node.node.func.attr 

183 ref_kind = "FuncCall" 

184 self.add_reference(node_id, ref_id, ref_kind) 

185 # call function 'a' in function 'b', then 'b' refers to 'a' 

186 container = ast_node.get_container_parent() 

187 if container.parser_kind in {"FunctionDef", "AsyncFunctionDef"} and isinstance(ast_node.node.func, ast.Name): 

188 node_id = container.name 

189 ref_id = ast_node.node.func.id 

190 ref_kind = "FuncCall" 

191 self.add_reference(node_id, ref_id, ref_kind) 

192 

193 def add_reference(self, node_id: str, ref_id: str, ref_kind: str) -> None: 

194 """AI: Record a reference from node_id to ref_id (and the corresponding referenced-by entry).""" 

195 properties = {} 

196 if node_id == ref_id: 

197 return 

198 reference = PythonRSTReference(ref_id, ref_kind, properties) 

199 referenced_by = PythonRSTReference(node_id, ref_kind, properties) 

200 if node_id in self._references: 

201 self._references[node_id].append(reference) 

202 else: 

203 self._references[node_id] = [reference] 

204 if ref_id in self._referenced_by: 

205 self._referenced_by[ref_id].append(referenced_by) 

206 else: 

207 self._referenced_by[ref_id] = [referenced_by] 

208 

209 def get_referenced_by(self, node_id): 

210 """AI: Return the references pointing to node_id, resolved to their referring nodes' names.""" 

211 refs = self._referenced_by.get(node_id, []) 

212 return [PythonRSTReference(self._nodes[ref.node_id].name, ref.ref_kind, ref.properties) for ref in refs] 

213 

214 def get_references(self, node_id): 

215 """AI: Return the references that node_id points to, resolved to their target nodes' names.""" 

216 refs = self._references.get(node_id, []) 

217 return [PythonRSTReference(self._nodes[ref.node_id].name, ref.ref_kind, ref.properties) for ref in refs] 

218 

219 

220class PythonRstNode: 

221 """AI: ASTNode implementation backed by the stdlib ast module.""" 

222 

223 def __init__(self, node: ast.AST, translation_unit: PythonRstTranslationUnit = None, parent=None) -> None: 

224 """AI: Wrap a stdlib ast node as an AST node within the given translation unit.""" 

225 self.root = parent.root if parent and parent.root else self 

226 self.node = node 

227 self.parent = parent 

228 self.translation_unit: PythonRstTranslationUnit = translation_unit 

229 self.parser_kind = type(node).__name__ 

230 self.semantic_kind = PYTHON_KIND_MAP.get(self.parser_kind, SemanticKind.NODE) 

231 

232 self.indent = "" 

233 self.name = self._derive_name() 

234 self.show_props = False 

235 self.children = [] 

236 self.properties = {} 

237 self.is_implicit = self.parser_kind not in IMPLICIT 

238 self.offset = 0 

239 self.length = 0 

240 if self.translation_unit: 

241 self.filename = translation_unit.file_name 

242 self.derive_position(node, translation_unit, parent) 

243 self.add_node() 

244 for name in node._fields: 

245 try: 

246 child = getattr(node, name) 

247 match child: 

248 case list(): # Matches any list 

249 if isinstance(node, ast.Global) and name == "names" and len(child) == 1: 

250 self.name = child[0] 

251 

252 if isinstance(node, (ast.Global, ast.Nonlocal)): 

253 pass # names is list[str], not AST nodes - nothing to build children from 

254 elif isinstance(node, (ImplicitNode, ast.Module)) or len(node._fields) == 1: 

255 # A list field can hold a bare None at a position with no value 

256 # None isn't a real AST node, so it has nothing to build a child from. 

257 self.children.extend(PythonRstNode(n, translation_unit, self) for n in child if n is not None) 

258 if name == "body": 

259 self.body = self.children 

260 else: 

261 self.children.append(PythonRstNode(ImplicitNode(name, child), translation_unit, self)) 

262 if name in ["body", "cases"]: 

263 self.body = self.children[-1].children 

264 

265 case ast.AST(): 

266 if name != "ctx": 

267 self.children.append(PythonRstNode(child, translation_unit, self)) 

268 if isinstance(child, ast.expr): 

269 self.expression = self.children[-1] 

270 case _: 

271 if name != "None": 

272 self.properties[name] = child 

273 except AttributeError as e: 

274 print(e) 

275 continue 

276 

277 self.end_offset = self.offset + self.length 

278 self.extended_end_offset = self.end_offset 

279 self.is_statement = isinstance(self.node, ast.stmt) 

280 

281 @property 

282 def kind_key(self) -> SemanticKind | str: 

283 """AI: Return the semantic kind, or the raw parser kind when no semantic kind applies.""" 

284 return self.semantic_kind if self.semantic_kind is not SemanticKind.NODE else self.parser_kind 

285 

286 def __eq__(self, other) -> bool: 

287 """AI: Return whether this node is structurally equal to `other`, ignoring irrelevant properties/children.""" 

288 return ( 

289 isinstance(other, type(self)) 

290 and self.kind_key == other.kind_key 

291 and match_props(self.properties, other.properties, IRRELEVANT_PROPS) 

292 and match_children(self.children, other.children, IRRELEVANT_NODES) 

293 ) 

294 

295 def __contains__(self, item) -> bool: 

296 """AI: Return whether item(s) are found among this node's children.""" 

297 if not isinstance(item, list): 

298 item = [item] 

299 return find_in_list(self.children, item) >= 0 

300 

301 def __getitem__(self, key) -> Any: # noqa: ANN401 (an int key yields a node, a slice yields a list of nodes) 

302 """Allow indexing/slicing into node to access children. 

303 

304 Usage: node[0] == node.children[0] 

305 """ 

306 return self.children[key] 

307 

308 def __repr__(self) -> str: 

309 """AI: Return the formatted node representation.""" 

310 return format_node(self) 

311 

312 @property 

313 def next_sibling(self) -> Self | None: 

314 """AI: Return the sibling node immediately following this one, or None.""" 

315 return next_sibling(self) 

316 

317 @property 

318 def preceding_sibling(self) -> Self | None: 

319 """AI: Return the sibling node immediately preceding this one, or None.""" 

320 return preceding_sibling(self) 

321 

322 def process(self, function: Callable[[Self], None]) -> None: 

323 """AI: Apply function to this node and recursively to all of its descendants.""" 

324 function(self) 

325 for child in self.children: 

326 child.process(function) 

327 

328 def derive_position(self, node: ast.AST, translation_unit: PythonRstTranslationUnit, parent) -> None: 

329 """AI: Compute and set this node's offset and length in the source text from its ast position attributes.""" 

330 if node._attributes: 

331 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and node.decorator_list: 

332 self.offset = convert(self.translation_unit.lines, node.decorator_list[0].lineno, node.decorator_list[0].col_offset) - 1 

333 elif parent.name == "decorator_list": 

334 # also include the @ in the decorator 

335 self.offset = convert(self.translation_unit.lines, node.lineno, node.col_offset) - 1 # type: ignore[attr-defined] 

336 else: 

337 self.offset = convert(self.translation_unit.lines, node.lineno, node.col_offset) # type: ignore[attr-defined] 

338 all_space = all(c == " " for c in self.translation_unit.content[self.offset - node.col_offset : self.offset]) 

339 if all_space: 

340 self.offset = max(self.offset - node.col_offset, 0) 

341 self.length = convert(self.translation_unit.lines, node.end_lineno, node.end_col_offset) - self.offset # type: ignore[attr-defined] 

342 elif isinstance(node, ast.Module) and translation_unit: 

343 self.offset = 0 

344 self.length = len(translation_unit.content) 

345 else: 

346 self.offset = 0 

347 self.length = 0 

348 

349 @staticmethod 

350 def load( 

351 file_path: Path, # TODO: why Path - why not FileDescriptorOrPath (the type of the file parameter of the open function)? 

352 extra_args: Sequence[str] | None = None, 

353 working_dir: Path | None = None, 

354 ) -> PythonRstNode: 

355 """AI: Parse the Python source file at file_path into a PythonRstNode tree.""" 

356 # Keep a uniform loader signature across AST node implementations. 

357 # Python's AST parser does not need extra arguments or a working dir. 

358 _ = extra_args, working_dir 

359 with Path(file_path).open() as file: 

360 content = file.read() 

361 return PythonRstNode.load_from_text(content, str(file_path)) 

362 

363 @staticmethod 

364 def load_from_text( 

365 text: str, 

366 file_name: str = "test.py", 

367 extra_args: Sequence[str] | None = None, 

368 working_dir: Path | None = None, 

369 ) -> PythonRstNode: 

370 """AI: Parse Python source text into a PythonRstNode tree, attributed to file_name.""" 

371 _ = extra_args, working_dir 

372 translation_unit = PythonRstTranslationUnit(text, file_name=str(file_name)) 

373 translation_unit.check_diagnostics() 

374 root_node = PythonRstNode(translation_unit.atu, translation_unit) 

375 return root_node 

376 

377 def _derive_name(self): 

378 

379 if ( 

380 isinstance( 

381 self.node, 

382 ( 

383 ast.FunctionDef, 

384 ast.AsyncFunctionDef, 

385 ast.ClassDef, 

386 ast.ExceptHandler, 

387 ), 

388 ) 

389 and self.node.name 

390 ): 

391 name = self.node.name 

392 elif isinstance(self.node, ast.Global) and len(self.node.names) == 1: 

393 name = self.node.names[0] 

394 elif isinstance(self.node, (ast.AnnAssign, ast.AugAssign)) and isinstance(self.node.target, ast.Name): 

395 name = self.node.target.id 

396 elif isinstance(self.node, ast.Assign) and len(self.node.targets) == 1: 

397 target = self.node.targets[0] 

398 name = target.id if isinstance(target, ast.Name) else self.parser_kind 

399 elif isinstance(self.node, ast.Name): 

400 name = self.node.id 

401 elif isinstance(self.node, ast.arg): 

402 name = self.node.arg 

403 elif isinstance(self.node, ast.Match) and isinstance(self.node.subject, ast.Name): 

404 name = self.node.subject.id 

405 elif (isinstance(self.node, ast.Import) and len(self.node.names) == 1) or ( 

406 isinstance(self.node, ast.ImportFrom) and len(self.node.names) == 1 

407 ): 

408 name = self.node.names[0].name 

409 elif isinstance(self.node, (ast.Assert, ast.Break, ast.Pass, ast.Raise, ast.Continue)): 

410 name = "" 

411 elif isinstance(self.node, (ast.For, ast.AsyncFor)): 

412 if ( 

413 isinstance(self.node.target, ast.Tuple) 

414 and len(self.node.target.elts) > 1 

415 and isinstance(self.node.target.elts[1], ast.Name) 

416 ): 

417 name = self.node.target.elts[1].id 

418 elif isinstance(self.node.target, ast.Name): 

419 name = self.node.target.id 

420 else: 

421 name = str(self.node.target) 

422 elif "body" not in self.node._fields: 

423 name = ast.unparse(self.node) 

424 elif isinstance(self.node, (ast.Module)) and self.translation_unit: 

425 name = self.translation_unit.file_name 

426 else: 

427 name = self.parser_kind 

428 return name or "" 

429 

430 @property 

431 def type(self): 

432 """AI: Return the annotation name for an annotated assignment node, or None otherwise.""" 

433 return self.node.annotation.id if isinstance(self.node, ast.AnnAssign) and isinstance(self.node.annotation, ast.Name) else None 

434 

435 @property 

436 def value(self): 

437 """AI: Return the literal value of this node, or None if it has none.""" 

438 if self.parser_kind == "Assert": 

439 return 0 

440 return self.node.value.value if hasattr(self.node, "value") else None 

441 

442 @property 

443 def expr(self): 

444 """AI: Return the wrapped inner expression node relevant to this statement's kind, or None.""" 

445 if ( 

446 isinstance( 

447 self.node, 

448 ( 

449 ast.Assign, 

450 ast.AnnAssign, 

451 ast.AugAssign, 

452 ast.Return, 

453 ast.Expr, 

454 ast.Delete, 

455 ast.NamedExpr, 

456 ), 

457 ) 

458 and hasattr(self.node, "value") 

459 and self.node.value is not None 

460 ) or (isinstance(self.node, ast.Expr) and hasattr(self.node, "value")): 

461 return PythonRstNode(self.node.value, self.translation_unit, self) 

462 if isinstance(self.node, (ast.For, ast.AsyncFor, ast.comprehension)): 

463 return PythonRstNode(self.node.iter, self.translation_unit, self) 

464 if isinstance(self.node, (ast.If, ast.While, ast.Assert)): 

465 return PythonRstNode(self.node.test, self.translation_unit, self) 

466 if isinstance(self.node, (ast.Raise, ast.ExceptHandler)) and hasattr(self.node, "exc") and self.node.exc is not None: 

467 return PythonRstNode(self.node.exc, self.translation_unit, self) 

468 return None 

469 

470 @property 

471 def operator(self): 

472 """AI: Return this node's operator symbol, or an empty string if it has none.""" 

473 node_type = type(self.node).__name__ 

474 op = type(self.node.op).__name__ if isinstance(self.node, (ast.BinOp, ast.UnaryOp, ast.BoolOp, ast.AugAssign)) else "" 

475 return PYTHON_OPERATOR_MAP.get(node_type + op, "") 

476 

477 @property 

478 def signature(self) -> str: 

479 """AI: Return the source code text of this node, prefixed with '@' for decorators.""" 

480 # TODO: see docs/developer/architecture/position-consistency.md - decodes with the host encoding, not the adapter's. 

481 sig = self.binary_file_content().decode(sys.getfilesystemencoding()) 

482 if self.parent and self.parent.name == "decorator_list" and not sig.startswith("@"): 

483 sig = "@" + sig 

484 return sig 

485 

486 def binary_file_content(self) -> bytes: 

487 """AI: Return this node's source text as encoded bytes.""" 

488 # TODO: see docs/developer/architecture/position-consistency.md - the unparse fallback builds a buffer no offset indexes. 

489 return ( 

490 self.translation_unit.content[self.offset : self.offset + self.length] 

491 if self.translation_unit 

492 else ast.unparse(self.node).encode(sys.getfilesystemencoding()) 

493 ) 

494 

495 @property 

496 def referenced_by(self) -> Sequence[PythonRSTReference]: 

497 """AI: Return the references that point to this node, building the reference cache if needed.""" 

498 self.translation_unit.lazy_create_refers(self) 

499 return self.translation_unit.get_referenced_by(self.name) 

500 

501 @property 

502 def references(self) -> list[PythonRSTReference]: 

503 """AI: Return the references that this node points to, building the reference cache if needed.""" 

504 self.translation_unit.lazy_create_refers(self) 

505 return self.translation_unit.get_references(self.name) 

506 

507 def add_node(self) -> None: 

508 """AI: Register this node in its translation unit's node-name lookup table.""" 

509 self.translation_unit.add(self) 

510 

511 def get_container_parent(self): 

512 """AI: Return the nearest ancestor node that is a function, class, or module.""" 

513 if self.parent: 

514 if self.parent.parser_kind in {"FunctionDef", "AsyncFunctionDef", "ClassDef", "Module"}: 

515 return self.parent 

516 return self.parent.get_container_parent() 

517 return self 

518 

519 @property 

520 def text(self) -> str: 

521 """AI: Return this node's signature text with common leading whitespace removed.""" 

522 return textwrap.dedent(self.signature)