Skip to content

Commit 1673f25

Browse files
zhewenlzhewenli
authored andcommitted
add class
1 parent 2baa712 commit 1673f25

7 files changed

Lines changed: 404 additions & 48 deletions

File tree

tools/stronghold/src/api/__init__.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
import dataclasses
6-
from collections.abc import Sequence
6+
from collections.abc import Sequence, Mapping
77
from typing import Optional
88

99
import api.types
@@ -41,3 +41,30 @@ class Parameter:
4141
line: int
4242
# Type annotation (relies on ast.annotation types)
4343
type_annotation: Optional[api.types.TypeHint] = None
44+
45+
46+
@dataclasses.dataclass
47+
class Field:
48+
"""Represents a dataclass or class attribute."""
49+
50+
name: str
51+
required: bool
52+
line: int
53+
type_annotation: Optional[api.types.TypeHint] = None
54+
55+
56+
@dataclasses.dataclass
57+
class Class:
58+
"""Represents a class or dataclass."""
59+
60+
fields: Sequence[Field]
61+
line: int
62+
dataclass: bool = False
63+
64+
65+
@dataclasses.dataclass
66+
class API:
67+
"""Represents extracted API information."""
68+
69+
functions: Mapping[str, Parameters]
70+
classes: Mapping[str, Class]

tools/stronghold/src/api/ast.py

Lines changed: 73 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,28 +11,27 @@
1111
import api.types
1212

1313

14-
def extract(path: pathlib.Path) -> Mapping[str, api.Parameters]:
15-
"""Extracts the API from a given source file.
16-
17-
The keys will be the fully-qualified path from the root of the module, e.g.
18-
* global_func
19-
* ClassName.method_name
20-
* ClassName.SubClassName.method_name
21-
"""
22-
raw_api = extract_raw(path)
23-
return {
24-
name: _function_def_to_parameters(function_def)
25-
for name, function_def in raw_api.items()
14+
def extract(path: pathlib.Path, *, include_classes: bool = False) -> api.API:
15+
"""Extracts API definitions from a given source file."""
16+
17+
funcs, classes = extract_raw(path, include_classes=include_classes)
18+
parameters = {
19+
name: _function_def_to_parameters(func) for name, func in funcs.items()
2620
}
21+
return api.API(functions=parameters, classes=classes)
22+
2723

24+
def extract_raw(
25+
path: pathlib.Path, *, include_classes: bool = False
26+
) -> tuple[Mapping[str, ast.FunctionDef], Mapping[str, api.Class]]:
27+
"""Extracts API as AST nodes."""
2828

29-
def extract_raw(path: pathlib.Path) -> Mapping[str, ast.FunctionDef]:
30-
"""Extracts the API as ast.FunctionDef instances."""
31-
out: dict[str, ast.FunctionDef] = {}
32-
_ContextualNodeVisitor(out, context=[]).visit(
29+
funcs: dict[str, ast.FunctionDef] = {}
30+
classes: dict[str, api.Class] = {}
31+
_ContextualNodeVisitor(funcs, classes if include_classes else None, []).visit(
3332
ast.parse(path.read_text(), os.fspath(path))
3433
)
35-
return out
34+
return funcs, classes
3635

3736

3837
def _function_def_to_parameters(node: ast.FunctionDef) -> api.Parameters:
@@ -90,20 +89,69 @@ def _function_def_to_parameters(node: ast.FunctionDef) -> api.Parameters:
9089

9190

9291
class _ContextualNodeVisitor(ast.NodeVisitor):
93-
"""NodeVisitor implementation that tracks which class, if any, it is a member of."""
94-
95-
def __init__(self, out: dict[str, ast.FunctionDef], context: Sequence[str]) -> None:
96-
self._out = out
97-
self._context = context
92+
"""NodeVisitor that collects functions and optionally classes."""
93+
94+
def __init__(
95+
self,
96+
functions: dict[str, ast.FunctionDef],
97+
classes: dict[str, api.Class] | None,
98+
context: Sequence[str],
99+
) -> None:
100+
self._functions = functions
101+
self._classes = classes
102+
self._context = list(context)
98103

99104
def visit_ClassDef(self, node: ast.ClassDef) -> None:
100105
# Recursively visit all nodes under this class, with the given
101106
# class name pushed onto a new context.
107+
if self._classes is not None:
108+
name = ".".join(self._context + [node.name])
109+
is_dataclass = any(
110+
(isinstance(dec, ast.Name) and dec.id == "dataclass")
111+
or (isinstance(dec, ast.Attribute) and dec.attr == "dataclass")
112+
for dec in node.decorator_list
113+
)
114+
fields: list[api.Field] = []
115+
for stmt in node.body:
116+
if isinstance(stmt, ast.AnnAssign) and isinstance(
117+
stmt.target, ast.Name
118+
):
119+
field_name = stmt.target.id
120+
if field_name.startswith("_"):
121+
continue
122+
fields.append(
123+
api.Field(
124+
name=field_name,
125+
required=stmt.value is None,
126+
line=stmt.lineno,
127+
type_annotation=api.types.annotation_to_dataclass(
128+
stmt.annotation
129+
),
130+
)
131+
)
132+
elif isinstance(stmt, ast.Assign):
133+
for target in stmt.targets:
134+
if isinstance(target, ast.Name):
135+
field_name = target.id
136+
if field_name.startswith("_"):
137+
continue
138+
fields.append(
139+
api.Field(
140+
name=field_name,
141+
required=False,
142+
line=stmt.lineno,
143+
type_annotation=None,
144+
)
145+
)
146+
self._classes[name] = api.Class(
147+
fields=fields, line=node.lineno, dataclass=is_dataclass
148+
)
149+
102150
_ContextualNodeVisitor(
103-
self._out, list(self._context) + [node.name]
151+
self._functions, self._classes, self._context + [node.name]
104152
).generic_visit(node)
105153

106154
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
107155
# Records this function.
108-
name = ".".join(list(self._context) + [node.name])
109-
self._out[name] = node
156+
name = ".".join(self._context + [node.name])
157+
self._functions[name] = node

tools/stronghold/src/api/compatibility.py

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,15 +68,19 @@ def check(
6868
before: pathlib.Path, after: pathlib.Path
6969
) -> Sequence[api.violations.Violation]:
7070
"""Identifies API compatibility issues between two files."""
71-
before_api = api.ast.extract(before)
72-
after_api = api.ast.extract(after)
71+
before_api = api.ast.extract(before, include_classes=True)
72+
after_api = api.ast.extract(after, include_classes=True)
73+
before_funcs = before_api.functions
74+
after_funcs = after_api.functions
75+
before_classes = before_api.classes
76+
after_classes = after_api.classes
7377

7478
violations: list[api.violations.Violation] = []
75-
for name, before_def in before_api.items():
79+
for name, before_def in before_funcs.items():
7680
if any(token.startswith("_") for token in name.split(".")):
7781
continue
7882

79-
after_def = after_api.get(name)
83+
after_def = after_funcs.get(name)
8084
if after_def is None:
8185
violations.append(api.violations.FunctionDeleted(func=name, line=1))
8286
continue
@@ -103,6 +107,14 @@ def check(
103107
violations += _check_by_requiredness(name, before_def, after_def)
104108
violations += _check_variadic_parameters(name, before_def, after_def)
105109

110+
for name, before_class in before_classes.items():
111+
if any(token.startswith("_") for token in name.split(".")):
112+
continue
113+
after_class = after_classes.get(name)
114+
if after_class is None:
115+
continue
116+
violations += list(_check_class_fields(name, before_class, after_class))
117+
106118
return violations
107119

108120

@@ -250,6 +262,37 @@ def _check_variadic_parameters(
250262
yield api.violations.KwArgsDeleted(func, line=after.line)
251263

252264

265+
def _check_class_fields(
266+
cls: str, before: api.Class, after: api.Class
267+
) -> Iterable[api.violations.Violation]:
268+
"""Checks class and dataclass field compatibility."""
269+
270+
before_fields = {f.name: f for f in before.fields}
271+
after_fields = {f.name: f for f in after.fields}
272+
273+
for name, before_field in before_fields.items():
274+
after_field = after_fields.get(name)
275+
if after_field is None:
276+
yield api.violations.FieldRemoved(func=cls, parameter=name, line=after.line)
277+
continue
278+
279+
if not _check_type_compatibility(
280+
before_field.type_annotation, after_field.type_annotation
281+
):
282+
yield api.violations.FieldTypeChanged(
283+
func=cls,
284+
parameter=name,
285+
line=after_field.line,
286+
type_before=str(before_field.type_annotation),
287+
type_after=str(after_field.type_annotation),
288+
)
289+
290+
for name in set(after_fields) - set(before_fields):
291+
yield api.violations.FieldAdded(
292+
func=cls, parameter=name, line=after_fields[name].line
293+
)
294+
295+
253296
def _check_type_compatibility(
254297
type_before: api.types.TypeHint, type_after: api.types.TypeHint
255298
) -> bool:

tools/stronghold/src/api/violations.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,38 @@ def __post_init__(self) -> None:
123123
self.message = (
124124
f"{self.parameter} changed from {self.type_before} to {self.type_after}"
125125
)
126+
127+
128+
# ====================================
129+
# Class field violations
130+
@dataclass
131+
class FieldViolation(Violation):
132+
parameter: str = ""
133+
134+
135+
@dataclass
136+
class FieldRemoved(FieldViolation):
137+
message: str = ""
138+
139+
def __post_init__(self) -> None:
140+
self.message = f"{self.parameter} was removed"
141+
142+
143+
@dataclass
144+
class FieldAdded(FieldViolation):
145+
message: str = ""
146+
147+
def __post_init__(self) -> None:
148+
self.message = f"{self.parameter} was added"
149+
150+
151+
@dataclass
152+
class FieldTypeChanged(FieldViolation):
153+
type_before: str = ""
154+
type_after: str = ""
155+
message: str = ""
156+
157+
def __post_init__(self) -> None:
158+
self.message = (
159+
f"{self.parameter} changed from {self.type_before} to {self.type_after}"
160+
)

0 commit comments

Comments
 (0)