Skip to content

Commit b1304d0

Browse files
authored
Merge pull request #6 from PyNumLab/codex/tighten-pyi-ast-parser
codex: tighten pyi AST parser helpers
2 parents 8d9b1f0 + 0797dc4 commit b1304d0

2 files changed

Lines changed: 98 additions & 44 deletions

File tree

semantics/pyi_parser.py

Lines changed: 78 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -67,32 +67,14 @@ def import_name(self, node: ast.Import) -> str:
6767
)
6868

6969
def class_def(self, node: ast.ClassDef, *, visibility: str) -> SemanticClass:
70-
fields: list[SemanticArgument] = []
71-
methods: list[SemanticMethod] = []
72-
73-
for item in node.body:
74-
if isinstance(item, ast.Pass):
75-
continue
76-
if isinstance(item, ast.AnnAssign):
77-
fields.append(self.ann_assign(item, default_intent="in"))
78-
continue
79-
if isinstance(item, ast.FunctionDef):
80-
decorators = self.decorators(item.decorator_list, context="class body")
81-
methods.append(
82-
self.method_def(
83-
item,
84-
visibility=decorators.visibility,
85-
projection=decorators.projection,
86-
)
87-
)
88-
continue
89-
raise ValueError(f"Unsupported class body node: {_node_text(item)!r}")
70+
body = _ClassBodyVisitor(self)
71+
body.visit_body(node.body)
9072

9173
return SemanticClass(
9274
name=node.name,
9375
native_name=node.name,
94-
fields=fields,
95-
methods=methods,
76+
fields=body.fields,
77+
methods=body.methods,
9678
base_classes=[ast.unparse(base) for base in node.bases],
9779
visibility=visibility,
9880
)
@@ -152,10 +134,10 @@ def ann_assign(self, node: ast.AnnAssign, *, default_intent: str) -> SemanticArg
152134
def decorators(self, nodes: list[ast.expr], *, context: str) -> _Decorators:
153135
parsed = _Decorators()
154136
for node in nodes:
155-
if isinstance(node, ast.Name) and node.id == "private":
137+
if self.matches_name(node, "private"):
156138
parsed.visibility = "private"
157139
continue
158-
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "native_call":
140+
if isinstance(node, ast.Call) and self.matches_name(node.func, "native_call"):
159141
parsed.has_native_call = True
160142
parsed.projection = self.native_call(node)
161143
continue
@@ -174,12 +156,12 @@ def native_call(self, node: ast.Call) -> list[ProjectionMapping]:
174156
]
175157

176158
def native_projection_entry(self, node: ast.AST, native_position: int) -> ProjectionMapping:
177-
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name):
159+
if not isinstance(node, ast.Call):
178160
raise ValueError("native_call expects projection entry calls")
179161
if node.keywords:
180-
raise ValueError(f"{node.func.id} expects positional arguments only")
162+
raise ValueError(f"{self.required_name(node.func)} expects positional arguments only")
181163

182-
helper = node.func.id
164+
helper = self.required_name(node.func)
183165
if helper == "Arg":
184166
if len(node.args) != 1:
185167
raise ValueError("Arg expects one positional index")
@@ -239,27 +221,28 @@ def native_projection_entry(self, node: ast.AST, native_position: int) -> Projec
239221
raise ValueError(f"Unsupported native_call projection entry: {helper}")
240222

241223
def native_value_ref(self, node: ast.AST) -> dict[str, int | str]:
242-
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name):
224+
if not isinstance(node, ast.Call):
243225
raise ValueError("Expected Arg(...), Return(...), or Work(...) value reference")
244226
if node.keywords or len(node.args) != 1:
245-
raise ValueError(f"{node.func.id} value reference expects one positional argument")
246-
if node.func.id == "Arg":
227+
raise ValueError(f"{self.required_name(node.func)} value reference expects one positional argument")
228+
helper = self.required_name(node.func)
229+
if helper == "Arg":
247230
return {"kind": "arg", "position": int(ast.literal_eval(node.args[0]))}
248-
if node.func.id == "Return":
231+
if helper == "Return":
249232
return {"kind": "return", "position": int(ast.literal_eval(node.args[0]))}
250-
if node.func.id == "Work":
233+
if helper == "Work":
251234
return {"kind": "work", "name": str(ast.literal_eval(node.args[0]))}
252235
raise ValueError("Expected Arg(...), Return(...), or Work(...) value reference")
253236

254237
def visible_type(self, node: ast.expr) -> tuple[str, SemanticType, str | None]:
255-
if self.subscript_name(node) == "private":
238+
if self.is_subscript_of(node, "private"):
256239
semantic_type, original_name = self.semantic_type_annotation(self.subscript_slice(node))
257240
return "private", semantic_type, original_name
258241
semantic_type, original_name = self.semantic_type_annotation(node)
259242
return "public", semantic_type, original_name
260243

261244
def semantic_type_annotation(self, node: ast.expr) -> tuple[SemanticType, str | None]:
262-
if self.subscript_name(node) != "Annotated":
245+
if not self.is_subscript_of(node, "Annotated"):
263246
return self.semantic_type(node), None
264247

265248
items = self.subscript_items(node)
@@ -274,7 +257,7 @@ def semantic_type_annotation(self, node: ast.expr) -> tuple[SemanticType, str |
274257
return self.semantic_type(items[0]), original_name
275258

276259
def semantic_type(self, node: ast.expr) -> SemanticType:
277-
if self.subscript_name(node) == "Annotated":
260+
if self.is_subscript_of(node, "Annotated"):
278261
semantic_type, _ = self.semantic_type_annotation(node)
279262
return semantic_type
280263

@@ -299,9 +282,9 @@ def semantic_type(self, node: ast.expr) -> SemanticType:
299282
def constraint(self, node: ast.expr) -> SemanticConstraint:
300283
if isinstance(node, ast.Name):
301284
return SemanticConstraint(node.id)
302-
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
285+
if isinstance(node, ast.Call):
303286
return SemanticConstraint(
304-
name=node.func.id,
287+
name=self.required_name(node.func),
305288
arguments=[ast.literal_eval(arg) for arg in node.args],
306289
)
307290
raise ValueError(f"Unsupported semantic type constraint: {ast.unparse(node)!r}")
@@ -338,7 +321,7 @@ def return_projection(self, node: ast.expr) -> tuple[SemanticType | None, list[S
338321
return return_type, returned_args
339322

340323
def returned_argument(self, node: ast.expr) -> SemanticArgument | None:
341-
if self.subscript_name(node) != "Returns":
324+
if not self.is_subscript_of(node, "Returns"):
342325
return None
343326
items = self.subscript_items(node)
344327
if len(items) not in {2, 3}:
@@ -355,7 +338,7 @@ def returned_argument(self, node: ast.expr) -> SemanticArgument | None:
355338

356339
@staticmethod
357340
def name_metadata(node: ast.expr) -> str | None:
358-
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "Name":
341+
if isinstance(node, ast.Call) and _PyiAstParser.matches_name(node.func, "Name"):
359342
if len(node.args) != 1:
360343
raise ValueError(f"Name metadata expects one argument: {ast.unparse(node)!r}")
361344
return str(ast.literal_eval(node.args[0]))
@@ -374,10 +357,31 @@ def default_marks_optional(node: ast.expr | None) -> bool:
374357
return isinstance(node, ast.Constant) and node.value in {Ellipsis, None}
375358

376359
@staticmethod
377-
def subscript_name(node: ast.AST) -> str:
378-
if isinstance(node, ast.Subscript):
379-
return ast.unparse(node.value)
380-
return ""
360+
def qualified_name(node: ast.AST) -> tuple[str, ...] | None:
361+
if isinstance(node, ast.Name):
362+
return (node.id,)
363+
if isinstance(node, ast.Attribute):
364+
parent = _PyiAstParser.qualified_name(node.value)
365+
if parent is None:
366+
return None
367+
return (*parent, node.attr)
368+
return None
369+
370+
@staticmethod
371+
def matches_name(node: ast.AST, name: str) -> bool:
372+
qualified = _PyiAstParser.qualified_name(node)
373+
return qualified is not None and qualified[-1] == name
374+
375+
@staticmethod
376+
def required_name(node: ast.AST) -> str:
377+
qualified = _PyiAstParser.qualified_name(node)
378+
if qualified is None:
379+
raise ValueError(f"Expected named helper: {ast.unparse(node)!r}")
380+
return qualified[-1]
381+
382+
@staticmethod
383+
def is_subscript_of(node: ast.AST, name: str) -> bool:
384+
return isinstance(node, ast.Subscript) and _PyiAstParser.matches_name(node.value, name)
381385

382386
@staticmethod
383387
def subscript_slice(node: ast.AST) -> ast.expr:
@@ -527,11 +531,41 @@ def _apply_native_call_argument_names(
527531
mapping.result_position = return_positions.get(arg.name)
528532

529533
def return_items(self, node: ast.expr) -> list[ast.expr]:
530-
if self.subscript_name(node) == "tuple":
534+
if self.is_subscript_of(node, "tuple") or self.is_subscript_of(node, "Tuple"):
531535
return self.subscript_items(node)
532536
return [node]
533537

534538

539+
class _ClassBodyVisitor(ast.NodeVisitor):
540+
def __init__(self, parser: _PyiAstParser):
541+
self.parser = parser
542+
self.fields: list[SemanticArgument] = []
543+
self.methods: list[SemanticMethod] = []
544+
545+
def visit_body(self, nodes: list[ast.stmt]) -> None:
546+
for node in nodes:
547+
self.visit(node)
548+
549+
def visit_Pass(self, node: ast.Pass) -> None:
550+
return None
551+
552+
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
553+
self.fields.append(self.parser.ann_assign(node, default_intent="in"))
554+
555+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
556+
decorators = self.parser.decorators(node.decorator_list, context="class body")
557+
self.methods.append(
558+
self.parser.method_def(
559+
node,
560+
visibility=decorators.visibility,
561+
projection=decorators.projection,
562+
)
563+
)
564+
565+
def generic_visit(self, node: ast.AST) -> None:
566+
raise ValueError(f"Unsupported class body node: {_node_text(node)!r}")
567+
568+
535569
class _ModuleVisitor(ast.NodeVisitor):
536570
def __init__(self, parser: _PyiAstParser):
537571
self.parser = parser

tests/pyi/test_pyi_to_ir.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,26 @@ def test_pyi_parser_ignores_unknown_annotation_metadata():
141141
assert module.variables[1].name == "native_alias"
142142

143143

144+
def test_parse_pyi_text_accepts_qualified_ast_wrapper_names():
145+
module = parse_pyi_text(
146+
"""
147+
import typing
148+
149+
alias: typing.Annotated[Float64[typing.Shape("1:n")], typing.Name("native_alias")]
150+
151+
def f() -> typing.Tuple[Float64, typing.Returns["y", Float64]]: ...
152+
""",
153+
module_name="edited",
154+
)
155+
156+
assert module.variables[0].name == "native_alias"
157+
assert module.variables[0].semantic_type.shape == ["1:n"]
158+
assert module.functions[0].return_type is not None
159+
assert module.functions[0].return_type.name == "Float64"
160+
assert module.functions[0].arguments[0].name == "y"
161+
assert module.functions[0].arguments[0].intent == "out"
162+
163+
144164
def test_parse_pyi_text_accepts_ast_only_projection_value_refs():
145165
module = parse_pyi_text(
146166
"""

0 commit comments

Comments
 (0)