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

324 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-09-09 14:04 +0000

1import ast 

2import sys 

3import textwrap 

4from collections.abc import Callable, Sequence 

5from pathlib import Path 

6from typing import Any, Self 

7 

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

9from renaissance.integrations.types import KIND_MAP, OPERATOR_MAP, Assert, FunctionDef, Global, ImplicitNode, Tuple, UnknownType 

10from renaissance.syntax_tree.match_finder import find_in_list 

11from renaissance.utils.ast_utils import ( 

12 format_node, 

13 match_children, 

14 match_props, 

15 next_sibling, 

16 preceding_sibling, 

17 traverse, 

18) 

19 

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

21IRRELEVANT_PROPS = {"comment"} 

22IRRELEVANT_NODES = {"comment"} 

23IMPLICIT = [ImplicitNode] 

24 

25 

26class ImplicitNode(ast.Name): 

27 _fields = ( 

28 "id", 

29 "body", 

30 ) 

31 

32 _field_types = { 

33 "id": str, 

34 "body": list, 

35 } 

36 

37 def __init__(self, name, children=None): 

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

39 self.lineno = 0 

40 self.col_offset = 0 

41 self.end_lineno = 0 

42 self.end_col_offset = 0 

43 

44 

45class PythonRSTReference: 

46 def __repr__(self): 

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

48 

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

50 self.node_id = node_id 

51 self.ref_kind = ref_kind 

52 self.properties = properties 

53 

54 

55class PythonRstTranslationUnit: 

56 cache = {} 

57 

58 def __init__(self, content, file_name: str): 

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

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

61 self.file_name = file_name 

62 self.references_initialized = False 

63 PythonRstTranslationUnit.cache[file_name] = content 

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

65 

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

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

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

69 

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

71 msg = None 

72 errors = "" 

73 for d in self.atu.type_ignores: 

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

75 errors += msg 

76 print(msg) 

77 if msg and not continue_with_warning: 

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

79 

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

81 if self.references_initialized: 

82 return 

83 for n in traverse(node.root): 

84 self.create_references(n) 

85 self.references_initialized = True 

86 

87 def add(self, node): 

88 match node.ast_type.__name__: 

89 case "Name": 

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

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

92 case "FunctionDef": 

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

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

95 case "Call": 

96 if node.name not in self._nodes: 

97 self._nodes[node.name] = node 

98 case "ClassDef": 

99 if node.name not in self._nodes: 

100 self._nodes[node.name] = node 

101 case "arg": 

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

103 self._nodes[node.name] = node 

104 

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

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

107 match type(ast_node.node): 

108 case ast.arg: 

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

110 node_id = ast_node.name 

111 ref_id = ast_node.node.annotation.id 

112 ref_kind = "TypeRef" 

113 self.add_reference(node_id, ref_id, ref_kind) 

114 case ast.Assign: 

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

116 for n in ast_node.node.targets: 

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

118 node_id = n.id 

119 func = ast_node.node.value.func 

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

121 if ref_id: 

122 ref_kind = "CallRef" 

123 self.add_reference(node_id, ref_id, ref_kind) 

124 case ast.AnnAssign: 

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

126 ast_node.node.annotation 

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

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

129 ): 

130 node_id = ast_node.node.target.id 

131 ref_id = ast_node.node.annotation.id 

132 ref_kind = "TypeRef" 

133 self.add_reference(node_id, ref_id, ref_kind) 

134 case ast.ClassDef: 

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

136 node = ast_node.node 

137 node_id = node.name 

138 if node.bases: 

139 ref_node = node.bases[0] 

140 if isinstance(ref_node, ast.Name): 

141 ref_id = ref_node.id 

142 ref_kind = "Inherit" 

143 self.add_reference(node_id, ref_id, ref_kind) 

144 # add functions and attributes to class 

145 

146 case ast.Call: 

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

148 # obj.function. then obj refers to function 

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

150 node_id = ast_node.name 

151 ref_id = ast_node.node.func.attr 

152 ref_kind = "FuncCall" 

153 self.add_reference(node_id, ref_id, ref_kind) 

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

155 container = ast_node.get_container_parent() 

156 if container.ast_type == FunctionDef and isinstance(ast_node.node.func, ast.Name): 

157 node_id = container.name 

158 ref_id = ast_node.node.func.id 

159 ref_kind = "FuncCall" 

160 self.add_reference(node_id, ref_id, ref_kind) 

161 

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

163 properties = {} 

164 if node_id == ref_id: 

165 return 

166 reference = PythonRSTReference(ref_id, ref_kind, properties) 

167 referenced_by = PythonRSTReference(node_id, ref_kind, properties) 

168 if node_id in self._references: 

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

170 else: 

171 self._references[node_id] = [reference] 

172 if ref_id in self._referenced_by: 

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

174 else: 

175 self._referenced_by[ref_id] = [referenced_by] 

176 

177 def get_referenced_by(self, node_id): 

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

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

180 

181 def get_references(self, node_id): 

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

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

184 

185 

186class PythonRstNode: 

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

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

189 self.node = node 

190 self.parent = parent 

191 self.translation_unit: PythonRstTranslationUnit = translation_unit 

192 self.ast_type = KIND_MAP.get(type(node).__name__, UnknownType) 

193 if self.ast_type == UnknownType: 

194 print(f'"{type(node).__name__}": {type(node).__name__},') 

195 

196 self.indent = "" 

197 self.name = self._derive_name() 

198 self.show_props = False 

199 self.children = [] 

200 self.properties = {} 

201 self.is_implicit = self.ast_type not in IMPLICIT 

202 self.offset = 0 

203 self.length = 0 

204 if self.translation_unit: 

205 self.filename = translation_unit.file_name 

206 self.derive_position(node, translation_unit, parent) 

207 self.add_node() 

208 for name in node._fields: 

209 try: 

210 child = getattr(node, name) 

211 match child: 

212 case list(): # Matches any list 

213 if isinstance(node, Global) and name == "names": 

214 if len(child) == 1: 

215 self.name = child[0] 

216 if name == "body": 

217 self.body = self.children 

218 

219 if isinstance(node, (ImplicitNode, ast.Module)) or len(node._fields) == 1: 

220 self.children.extend(PythonRstNode(n, translation_unit, self) for n in child) 

221 if name == "body": 

222 self.body = self.children 

223 else: 

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

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

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

227 

228 case ast.AST(): 

229 if name not in ["ctx"]: 

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

231 if isinstance(child, ast.expr): 

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

233 case _: 

234 if name not in ["None"]: 

235 self.properties[name] = child 

236 except AttributeError as e: 

237 print(e) 

238 continue 

239 

240 self.end_offset = self.offset + self.length 

241 self.extended_end_offset = self.end_offset 

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

243 

244 def __eq__(self, other): 

245 return ( 

246 isinstance(other, type(self)) 

247 and self.ast_type == other.ast_type 

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

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

250 ) 

251 

252 def __contains__(self, item): 

253 if not isinstance(item, list): 

254 item = [item] 

255 return find_in_list(self.children, item) 

256 

257 def __getitem__(self, key): 

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

259 

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

261 """ 

262 return self.children[key] 

263 

264 def __repr__(self): 

265 return format_node(self) 

266 

267 @property 

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

269 return next_sibling(self) 

270 

271 @property 

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

273 return preceding_sibling(self) 

274 

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

276 function(self) 

277 for child in self.children: 

278 child.process(function) 

279 

280 def derive_position(self, node: ast.AST, translation_unit: PythonRstTranslationUnit, parent): 

281 if node._attributes: 

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

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

284 elif parent.name == "decorator_list": 

285 # also include the @ in the decorator 

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

287 else: 

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

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

290 if all_space: 

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

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

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

294 self.offset = 0 

295 self.length = len(translation_unit.content) 

296 else: 

297 self.offset = 0 

298 self.length = 0 

299 

300 @staticmethod 

301 def load( 

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

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

304 working_dir: Path | None = None, 

305 ) -> PythonRstNode: 

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

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

308 _ = extra_args, working_dir 

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

310 content = file.read() 

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

312 

313 @staticmethod 

314 def load_from_text( 

315 text: str, 

316 file_name: str = "test.py", 

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

318 working_dir: Path | None = None, 

319 ) -> PythonRstNode: 

320 _ = extra_args, working_dir 

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

322 translation_unit.check_diagnostics() 

323 root_node = PythonRstNode(translation_unit.atu, translation_unit) 

324 return root_node 

325 

326 def _derive_name(self): 

327 

328 if ( 

329 isinstance( 

330 self.node, 

331 ( 

332 ast.FunctionDef, 

333 ast.AsyncFunctionDef, 

334 ast.ClassDef, 

335 ast.ExceptHandler, 

336 ), 

337 ) 

338 and self.node.name 

339 ): 

340 name = self.node.name 

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

342 name = self.node.names[0] 

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

344 name = self.node.target.id 

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

346 target = self.node.targets[0] 

347 name = target.id if isinstance(target, ast.Name) else self.ast_type.__name__ 

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

349 name = self.node.id 

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

351 name = self.node.arg 

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

353 name = self.node.subject.id 

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

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

356 ): 

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

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

359 name = "" 

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

361 if isinstance(self.node.target, Tuple): 

362 name = self.node.target.dims[1].id 

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

364 name = self.node.target.id 

365 else: 

366 name = str(self.node.target) 

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

368 name = ast.unparse(self.node) 

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

370 name = self.translation_unit.file_name 

371 else: 

372 name = self.ast_type.__name__ 

373 return name or "" 

374 

375 @property 

376 def type(self): 

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

378 

379 @property 

380 def value(self): 

381 if self.ast_type == Assert: 

382 return 0 

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

384 

385 @property 

386 def expr(self): 

387 if ( 

388 isinstance( 

389 self.node, 

390 ( 

391 ast.Assign, 

392 ast.AnnAssign, 

393 ast.AugAssign, 

394 ast.Return, 

395 ast.Expr, 

396 ast.Delete, 

397 ast.NamedExpr, 

398 ), 

399 ) 

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

401 and self.node.value is not None 

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

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

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

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

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

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

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

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

410 return None 

411 

412 @property 

413 def operator(self): 

414 node_type = type(self.node).__name__ 

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

416 return OPERATOR_MAP.get(node_type + op, "") 

417 

418 @property 

419 def signature(self) -> str: 

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

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

422 sig = "@" + sig 

423 return sig 

424 

425 def binary_file_content(self) -> bytes: 

426 return ( 

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

428 if self.translation_unit 

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

430 ) 

431 

432 @property 

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

434 self.translation_unit.lazy_create_refers(self) 

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

436 

437 @property 

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

439 self.translation_unit.lazy_create_refers(self) 

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

441 

442 def add_node(self): 

443 self.translation_unit.add(self) 

444 

445 def get_container_parent(self): 

446 if self.parent: 

447 if self.parent.ast_type.__name__ in ["FunctionDef", "ClassDef", "Module"]: 

448 return self.parent 

449 return self.parent.get_container_parent() 

450 return self 

451 

452 @property 

453 def text(self) -> str: 

454 return textwrap.dedent(self.signature)