Skip to content

Commit 36cc8e5

Browse files
authored
Merge pull request #1 from OO-LD/ast-annotation
Extract class constructor keywords args as compact json
2 parents 72bffb0 + ac5f958 commit 36cc8e5

3 files changed

Lines changed: 249 additions & 3 deletions

File tree

src/awl/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
from awl.core import AstSerialization # noqa
1+
from awl.core import ASTNotAModule, AstSerialization # noqa

src/awl/core.py

Lines changed: 173 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,26 @@
88
from awl import jsonld_context
99

1010

11+
class ASTNotAModule(Exception):
12+
"""Raised when the root node of the parsed object is **not** an ast.Module."""
13+
14+
1115
class AstSerialization:
12-
def __init__(self):
13-
pass
16+
def __init__(self, annotate: bool = False, backparsable: bool = False) -> None:
17+
"""
18+
Initializes AstSerialization with parser options.
19+
20+
Parameters
21+
----------
22+
annotate : bool, default False
23+
If ``True``, annotates the AST tree
24+
backparsable : bool, default False
25+
If ``True``, AST tree is unparsable via self.unparse
26+
If ``False``, Annotations deletes that are required for unparsing,
27+
resulting in a neat tree
28+
"""
29+
self.annotate = annotate
30+
self.backparsable = backparsable
1431

1532
@staticmethod
1633
def del_keys(d: dict, keys: list) -> dict:
@@ -55,6 +72,10 @@ def parse(self, source: str) -> dict:
5572

5673
ast_dict = self.del_keys(ast_dict, rm_keywords) # remove annotations
5774
self.ast_dict = ast_dict
75+
76+
if self.annotate:
77+
self.annotate_ast()
78+
5879
return ast_dict
5980

6081
def unparse(self, ast_dict: dict = None) -> str:
@@ -71,6 +92,156 @@ def dumps(self, format="yaml") -> str:
7192
res = yaml.dump(self.ast_dict, indent=4)
7293
return res
7394

95+
def annotate_ast(self) -> None:
96+
"""Validate the root node and start annotation walk.
97+
Raises
98+
------
99+
ASTNotAModule
100+
If the parsed tree does **not** start with an ``ast.Module`` node.
101+
"""
102+
# todo add further veryfication
103+
if self.ast_dict.get("_type") != "Module":
104+
raise ASTNotAModule("root node is not a Module")
105+
self._walk_json_ast(self.ast_dict, path=None)
106+
107+
def _walk_json_ast(self, node: list | dict | object, path: list) -> None:
108+
"""Depth‑first traversal of *node* while keeping track of *path*.
109+
110+
Parameters
111+
----------
112+
node
113+
Current AST sub‑node (``dict``, ``list`` or scalar).
114+
path
115+
Accumulated list of keys / indices leading from the root to *node*.
116+
"""
117+
118+
if path is None:
119+
path = []
120+
# ------------------------------------------------------------------ #
121+
# 1.Recursive walk
122+
# ------------------------------------------------------------------ #
123+
elif isinstance(node, list):
124+
# print(f"Path: {path}")
125+
for index, item in enumerate(node):
126+
self._walk_json_ast(item, path + [index])
127+
128+
if isinstance(node, dict):
129+
# print(f"Path: {path}")
130+
for key, value in node.items():
131+
self._walk_json_ast(value, path + [key])
132+
133+
# Primitive leaf – nothing to do
134+
else:
135+
# print(f"Path: {path} -> Value: {node}")
136+
pass
137+
138+
# ------------------------------------------------------------------ #
139+
# 2.Collapse handles the replacement logic to from leaf to "stem"
140+
# ------------------------------------------------------------------ #
141+
142+
# This checks for the class constructor syntax in AST
143+
# e.g "value":
144+
# {"_type": "Call","args": [],"func": {"_type": "Name","id": "ClassA"}
145+
if isinstance(node, dict):
146+
if (
147+
node.get("_type") == "Call" # A Constructor is a call
148+
and node.get("func", {}).get("_type")
149+
== "Name" # A Constructor is a call of type Name
150+
and (
151+
fid := node.get("func", {}).get("id")
152+
) # fid is None if the path is missing and hence False
153+
and fid[0].isupper() # only runs if fid is truthy,
154+
# wont give TypeError/IndexError
155+
):
156+
ctor_node = AstSerialization._get_from_path(self.ast_dict, path)
157+
158+
ctor_node["__class_name__"] = fid
159+
# self.ast_dict["__class_name__"] = fid
160+
# print (fid)
161+
162+
for kw_node in node["keywords"]:
163+
if isinstance(kw_node, dict):
164+
if kw_node.get("_type") == "keyword":
165+
ctor_node[kw_node["arg"]] = self._val(kw_node["value"])
166+
167+
if self.backparsable is False:
168+
# slim notation
169+
ctor_node = AstSerialization.slim_notation(ctor_node)
170+
171+
@staticmethod
172+
def _val(node: list | dict | object) -> object | None:
173+
"""Convert AST *value* nodes into primitives or nested constructor annotations.
174+
175+
Returns
176+
-------
177+
object | None
178+
* ``int``, ``str`` … for ``Constant`` nodes;
179+
* dotted ``str`` for ``Attribute`` chains;
180+
* nested constructor annotations (dict) for embedded calls;
181+
* ``None`` for values that are irrelevant / not serialisable.
182+
"""
183+
184+
if isinstance(node, dict):
185+
# todo currently f(a=t) and f(a="t") have same annotation,
186+
# think about if this can lead to problems
187+
t = node.get("_type")
188+
ctor = node.get("__class_name__")
189+
# f(a=1) :"value": {"_type": "Constant","value": 1}
190+
if t == "Constant":
191+
return node["value"]
192+
# f(a=t) : "value": {"_type": "Name","id": "t"}
193+
if t == "Name":
194+
return node["id"]
195+
# f(a = U.V) : "value":
196+
# {"_type": "Attribute","attr": "V","value": {"_type": "Name","id": "U"}}
197+
if t == "Attribute":
198+
return AstSerialization._attr_to_str(node)
199+
if ctor:
200+
return AstSerialization.slim_notation(node.copy())
201+
return None
202+
203+
# ------------------------------------------------------------------ #
204+
# Attribute -> dotted string
205+
# ------------------------------------------------------------------ #
206+
@staticmethod
207+
def _attr_to_str(node: dict) -> str:
208+
"""Flatten a chain of ``Attribute``/``Name`` nodes into ``"U.V"``."""
209+
# f(a = U.V) : "value":
210+
# {"_type": "Attribute","attr": "V","value": {"_type": "Name","id": "U"}}
211+
parts: list[str] = []
212+
213+
def walk(n):
214+
if n["_type"] == "Attribute":
215+
walk(n["value"])
216+
parts.append(n["attr"])
217+
elif n["_type"] == "Name":
218+
parts.append(n["id"])
219+
220+
walk(node)
221+
return ".".join(parts)
222+
223+
@staticmethod
224+
def _get_from_path(node: list | dict | object, path: list) -> list | dict | object:
225+
"""Return the sub‑node referenced by *path*."""
226+
for key in path:
227+
node = node[key]
228+
return node
229+
230+
@staticmethod
231+
def _dump_from_path(node: list | dict | object, path: list) -> str:
232+
"""Pretty JSON dump of the sub‑node at *path* (debug helper)."""
233+
node = AstSerialization._get_from_path(node, path)
234+
res = json.dumps(node, indent=4)
235+
return res
236+
237+
@staticmethod
238+
def slim_notation(node: list | dict | object) -> list | dict | object:
239+
"""pops the unnecessary parameters of a constructor
240+
and returns slim notation node"""
241+
for k in ("_type", "args", "func", "keywords"):
242+
node.pop(k, None)
243+
return node
244+
74245
def to_jsonld(self) -> dict:
75246
res = {"@context": jsonld_context.awl_context["@context"], **self.ast_dict}
76247
return res

tests/test_ast_annotation.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# from awl import AstSerialization
2+
# import json
3+
import pytest
4+
5+
from awl import ASTNotAModule, AstSerialization
6+
7+
8+
def test_ast_annotation():
9+
# ------------------------------------------------------------------ #
10+
# Check that trying to annotate something that is not
11+
# ------------------------------------------------------------------ #
12+
ast_annotation = AstSerialization(annotate=True, backparsable=True)
13+
ast_annotation.ast_dict = {}
14+
with pytest.raises(ASTNotAModule):
15+
ast_annotation.annotate_ast()
16+
17+
# ------------------------------------------------------------------ #
18+
# Check that annotation doesnt effect unparsing
19+
# ------------------------------------------------------------------ #
20+
ast_annotation = AstSerialization(annotate=True, backparsable=True)
21+
22+
source = """while i < 3:
23+
a = ClassA(a=1, b='b', c=ClassB(d=False))
24+
if a.a < 5:
25+
a.a += 1
26+
ClassB(a=1, b='b', c=ClassB(d=False))
27+
i += 1"""
28+
# print(source)
29+
30+
ast_dict = ast_annotation.parse(source)
31+
# print(json.dumps(ast_dict, indent=4))
32+
src_code = ast_annotation.unparse(ast_dict)
33+
assert src_code == source
34+
# ------------------------------------------------------------------ #
35+
# Check the annotation
36+
# ------------------------------------------------------------------ #
37+
ast_annotation = AstSerialization(annotate=True, backparsable=False)
38+
source = """ClassA(a=1, b='b', c=ClassB(d=False), d=t, e=A.B)"""
39+
ast_dict = ast_annotation.parse(source)
40+
ast_dumps = ast_annotation.dumps("json")
41+
print(ast_dumps)
42+
assert (
43+
ast_dumps
44+
== """{
45+
"_type": "Module",
46+
"body": [
47+
{
48+
"_type": "Expr",
49+
"value": {
50+
"__class_name__": "ClassA",
51+
"a": 1,
52+
"b": "b",
53+
"c": {
54+
"__class_name__": "ClassB",
55+
"d": false
56+
},
57+
"d": "t",
58+
"e": "A.B"
59+
}
60+
}
61+
],
62+
"type_ignores": []
63+
}"""
64+
)
65+
66+
example_path = ["body", 0, "value", "__class_name__"]
67+
print(ast_annotation._dump_from_path(ast_annotation.ast_dict, example_path))
68+
assert (
69+
ast_annotation._dump_from_path(ast_annotation.ast_dict, example_path)
70+
== '"ClassA"'
71+
)
72+
73+
74+
if __name__ == "__main__":
75+
test_ast_annotation()

0 commit comments

Comments
 (0)