retrofy 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- retrofy/__init__.py +8 -0
- retrofy/_converters.py +174 -0
- retrofy/_meta_hook_converter.py +62 -0
- retrofy/_pep517_hooks.py +71 -0
- retrofy/_transformations/__init__.py +0 -0
- retrofy/_transformations/dataclass.py +240 -0
- retrofy/_transformations/import_utils.py +103 -0
- retrofy/_transformations/match_statement.py +1218 -0
- retrofy/_transformations/type_alias.py +367 -0
- retrofy/_transformations/walrus.py +838 -0
- retrofy/_version.py +21 -0
- retrofy/tests/__init__.py +0 -0
- retrofy/tests/_transformations/__init__.py +0 -0
- retrofy/tests/_transformations/test__type_alias.py +302 -0
- retrofy/tests/_transformations/test__walrus.py +272 -0
- retrofy/tests/_transformations/test_dataclasses.py +233 -0
- retrofy/tests/_transformations/test_future_imports.py +131 -0
- retrofy/tests/_transformations/test_match_statement.py +1900 -0
- retrofy/tests/test__converters.py +124 -0
- retrofy/tests/test_integration_match.py +91 -0
- retrofy/tests/test_retrofy.py +11 -0
- retrofy-0.2.1.dist-info/METADATA +94 -0
- retrofy-0.2.1.dist-info/RECORD +26 -0
- retrofy-0.2.1.dist-info/WHEEL +5 -0
- retrofy-0.2.1.dist-info/entry_points.txt +3 -0
- retrofy-0.2.1.dist-info/top_level.txt +1 -0
retrofy/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Documentation for the retrofy package
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
# Import the version from the generated _version.py file. __version__ is part
|
|
7
|
+
# of the public API, and we therefore ignore the "unused" (F401) lint warning.
|
|
8
|
+
from ._version import __version__ # noqa: F401 # pylint: disable=import-error
|
retrofy/_converters.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import typing
|
|
3
|
+
|
|
4
|
+
import libcst as cst
|
|
5
|
+
|
|
6
|
+
from ._transformations import dataclass, match_statement, type_alias, walrus
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TypingTransformer(cst.CSTTransformer):
|
|
10
|
+
def __init__(self, scope):
|
|
11
|
+
self._scope = scope
|
|
12
|
+
self._require_typing = False
|
|
13
|
+
self._has_typing = False # TODO: We can figure this out.
|
|
14
|
+
|
|
15
|
+
def leave_Annotation(
|
|
16
|
+
self,
|
|
17
|
+
node: cst.Annotation,
|
|
18
|
+
updated_node: cst.Annotation,
|
|
19
|
+
) -> cst.Annotation:
|
|
20
|
+
if isinstance(updated_node.annotation, cst.BinaryOperation):
|
|
21
|
+
# TODO: Use a matcher here.
|
|
22
|
+
if isinstance(updated_node.annotation.operator, cst.BitOr):
|
|
23
|
+
# print('ANNO scope', self._scope[node.annotation])
|
|
24
|
+
self._require_typing = True
|
|
25
|
+
new_node = cst.Subscript(
|
|
26
|
+
cst.Attribute(
|
|
27
|
+
cst.Name("typing"),
|
|
28
|
+
cst.Name("Union"),
|
|
29
|
+
),
|
|
30
|
+
slice=(
|
|
31
|
+
cst.SubscriptElement(
|
|
32
|
+
updated_node.annotation.left, # type: ignore
|
|
33
|
+
),
|
|
34
|
+
cst.SubscriptElement(
|
|
35
|
+
updated_node.annotation.right, # type: ignore
|
|
36
|
+
),
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
return cst.Annotation(new_node)
|
|
40
|
+
|
|
41
|
+
return updated_node
|
|
42
|
+
|
|
43
|
+
def leave_Module(self, node: cst.Module, updated_node: cst.Module) -> cst.Module:
|
|
44
|
+
if self._require_typing:
|
|
45
|
+
# Find the correct position to insert the typing import
|
|
46
|
+
# It should be after any docstrings and __future__ imports
|
|
47
|
+
insert_position = self._find_typing_import_position(updated_node.body)
|
|
48
|
+
|
|
49
|
+
typing_import = cst.SimpleStatementLine(
|
|
50
|
+
[
|
|
51
|
+
cst.Import(
|
|
52
|
+
[
|
|
53
|
+
cst.ImportAlias(
|
|
54
|
+
cst.Name("typing"),
|
|
55
|
+
),
|
|
56
|
+
],
|
|
57
|
+
),
|
|
58
|
+
],
|
|
59
|
+
trailing_whitespace=cst.TrailingWhitespace(
|
|
60
|
+
newline=cst.Newline(),
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
new_stmts = list(updated_node.body)
|
|
65
|
+
new_stmts.insert(insert_position, typing_import)
|
|
66
|
+
|
|
67
|
+
return dataclasses.replace(updated_node, body=tuple(new_stmts))
|
|
68
|
+
return updated_node
|
|
69
|
+
|
|
70
|
+
def _find_typing_import_position(
|
|
71
|
+
self,
|
|
72
|
+
body: typing.Tuple[cst.BaseStatement, ...],
|
|
73
|
+
) -> int:
|
|
74
|
+
"""Find the correct position to insert the typing import."""
|
|
75
|
+
position = 0
|
|
76
|
+
|
|
77
|
+
# Skip module docstrings
|
|
78
|
+
if body and isinstance(body[0], cst.SimpleStatementLine):
|
|
79
|
+
if (
|
|
80
|
+
len(body[0].body) == 1
|
|
81
|
+
and isinstance(body[0].body[0], cst.Expr)
|
|
82
|
+
and isinstance(body[0].body[0].value, cst.SimpleString)
|
|
83
|
+
):
|
|
84
|
+
position = 1
|
|
85
|
+
|
|
86
|
+
# Skip __future__ imports
|
|
87
|
+
for i in range(position, len(body)):
|
|
88
|
+
stmt = body[i]
|
|
89
|
+
if isinstance(stmt, cst.SimpleStatementLine):
|
|
90
|
+
for substmt in stmt.body:
|
|
91
|
+
if (
|
|
92
|
+
isinstance(substmt, cst.ImportFrom)
|
|
93
|
+
and substmt.module
|
|
94
|
+
and isinstance(substmt.module, cst.Attribute)
|
|
95
|
+
and substmt.module.attr.value == "__future__"
|
|
96
|
+
):
|
|
97
|
+
position = i + 1
|
|
98
|
+
break
|
|
99
|
+
elif (
|
|
100
|
+
isinstance(substmt, cst.ImportFrom)
|
|
101
|
+
and substmt.module
|
|
102
|
+
and isinstance(substmt.module, cst.Name)
|
|
103
|
+
and substmt.module.value == "__future__"
|
|
104
|
+
):
|
|
105
|
+
position = i + 1
|
|
106
|
+
break
|
|
107
|
+
else:
|
|
108
|
+
# If we didn't find a __future__ import in this statement, stop looking
|
|
109
|
+
break
|
|
110
|
+
else:
|
|
111
|
+
# If we hit a non-simple statement, stop looking
|
|
112
|
+
break
|
|
113
|
+
|
|
114
|
+
return position
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def convert_union(module: cst.Module) -> cst.Module:
|
|
118
|
+
"""
|
|
119
|
+
Given typing such as `SomeClass | AnotherClass`, convert this to
|
|
120
|
+
`typing.Union[SomeClass, AnotherClass]`, with the appropriate `typing`
|
|
121
|
+
import included.
|
|
122
|
+
|
|
123
|
+
Note that this typically doesn't need to be done at runtime, and mostly is
|
|
124
|
+
a type checking feature.
|
|
125
|
+
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
wrapper = cst.metadata.MetadataWrapper(module)
|
|
129
|
+
metadata = wrapper.resolve(cst.metadata.ScopeProvider)
|
|
130
|
+
|
|
131
|
+
transformer = TypingTransformer(metadata)
|
|
132
|
+
return module.visit(transformer)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def convert_sequence_subscript(module: cst.Module) -> cst.Module:
|
|
136
|
+
"""
|
|
137
|
+
Convert the built-in sequence types such as `list[int]` to the
|
|
138
|
+
`typing.List[int]` form.
|
|
139
|
+
|
|
140
|
+
Currently only `list[int]` is supported.
|
|
141
|
+
"""
|
|
142
|
+
# TODO: Do this correctly. Including adding the necessry typing import.
|
|
143
|
+
orig = module.code
|
|
144
|
+
new = orig.replace("list[str]", "typing.List[str]")
|
|
145
|
+
if new != orig:
|
|
146
|
+
return cst.parse_module(new)
|
|
147
|
+
return module
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def convert_walrus_operator(module: cst.Module) -> cst.Module:
|
|
151
|
+
return module.visit(walrus.WalrusOperatorTransformer())
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def convert_type_alias(module: cst.Module) -> cst.Module:
|
|
155
|
+
return module.visit(type_alias.PEP695Transformer())
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def convert_match_statement(module: cst.Module) -> cst.Module:
|
|
159
|
+
return module.visit(match_statement.MatchStatementTransformer())
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def convert_dataclass(module: cst.Module) -> cst.Module:
|
|
163
|
+
return module.visit(dataclass.DataclassTransformer())
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def convert(code: str) -> str:
|
|
167
|
+
mod = cst.parse_module(code)
|
|
168
|
+
mod = convert_sequence_subscript(mod)
|
|
169
|
+
mod = convert_walrus_operator(mod)
|
|
170
|
+
mod = convert_type_alias(mod)
|
|
171
|
+
mod = convert_dataclass(mod)
|
|
172
|
+
mod = convert_match_statement(mod)
|
|
173
|
+
mod = convert_union(mod)
|
|
174
|
+
return mod.code
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import typing
|
|
3
|
+
from importlib.abc import MetaPathFinder, SourceLoader
|
|
4
|
+
|
|
5
|
+
from ._converters import convert
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OnTheFlyConverter(SourceLoader):
|
|
9
|
+
def __init__(self, path):
|
|
10
|
+
self.path = path
|
|
11
|
+
|
|
12
|
+
def get_filename(self, fullname):
|
|
13
|
+
return self.path
|
|
14
|
+
|
|
15
|
+
def get_data(self, filename):
|
|
16
|
+
"""exec_module is already defined for us, we just have to provide a way
|
|
17
|
+
of getting the source code of the module"""
|
|
18
|
+
with open(filename) as f:
|
|
19
|
+
data = f.read()
|
|
20
|
+
|
|
21
|
+
new_code = convert(data)
|
|
22
|
+
return new_code
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MyMetaPathFinder(MetaPathFinder):
|
|
26
|
+
def __init__(self, package_names: typing.Sequence[str] = ()):
|
|
27
|
+
self.package_names: set[str] = set()
|
|
28
|
+
self.add_package_handling(package_names)
|
|
29
|
+
|
|
30
|
+
def add_package_handling(self, package_names: typing.Sequence[str]):
|
|
31
|
+
self.package_names.update(package_names)
|
|
32
|
+
|
|
33
|
+
def find_spec(self, fullname, path, target=None):
|
|
34
|
+
for prefix in self.package_names:
|
|
35
|
+
if fullname != prefix and not fullname.startswith(f"{prefix}."):
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
# If your custom logic doesn't handle it, defer to the next finder
|
|
39
|
+
for finder in sys.meta_path:
|
|
40
|
+
if isinstance(finder, MyMetaPathFinder):
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
spec = finder.find_spec(fullname, path, target)
|
|
44
|
+
if spec:
|
|
45
|
+
break
|
|
46
|
+
else:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
spec.loader = OnTheFlyConverter(spec.origin)
|
|
50
|
+
return spec
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def register_hook(package_names):
|
|
54
|
+
for finder in sys.meta_path:
|
|
55
|
+
if isinstance(finder, MyMetaPathFinder):
|
|
56
|
+
existing_hook = finder
|
|
57
|
+
break
|
|
58
|
+
else:
|
|
59
|
+
existing_hook = MyMetaPathFinder()
|
|
60
|
+
sys.meta_path.insert(0, MyMetaPathFinder(package_names))
|
|
61
|
+
|
|
62
|
+
existing_hook.add_package_handling(package_names)
|
retrofy/_pep517_hooks.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import pathlib
|
|
2
|
+
import shutil
|
|
3
|
+
import zipfile
|
|
4
|
+
|
|
5
|
+
from setuptools_ext import WheelModifier
|
|
6
|
+
|
|
7
|
+
from ._converters import convert
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def compatibility_via_import_hook(wheel: pathlib.Path):
|
|
11
|
+
"""
|
|
12
|
+
Add a pth hook to ensure make imported code compatible at import time
|
|
13
|
+
(i.e. suitable for editable mode)
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
editable_copy = wheel.parent / (wheel.name + ".copy.whl")
|
|
17
|
+
shutil.copy(wheel, editable_copy)
|
|
18
|
+
|
|
19
|
+
with zipfile.ZipFile(str(editable_copy), "r") as whl_zip:
|
|
20
|
+
whl = WheelModifier(whl_zip)
|
|
21
|
+
|
|
22
|
+
top_level = (
|
|
23
|
+
whl.read(
|
|
24
|
+
whl.dist_info_dirname() + "/top_level.txt",
|
|
25
|
+
)
|
|
26
|
+
.decode("utf-8")
|
|
27
|
+
.splitlines()
|
|
28
|
+
)
|
|
29
|
+
top_level_pkgs = [pkg for pkg in top_level if pkg]
|
|
30
|
+
|
|
31
|
+
for pkg in top_level_pkgs:
|
|
32
|
+
fn = f"_retrofy.__editable_compat__.{pkg}.pth"
|
|
33
|
+
script = (
|
|
34
|
+
f"import retrofy._meta_hook_converter as c; c.register_hook(['{pkg}']);"
|
|
35
|
+
)
|
|
36
|
+
whl.write(zipfile.ZipInfo(fn), script)
|
|
37
|
+
|
|
38
|
+
with wheel.open("wb") as whl_fh:
|
|
39
|
+
whl.write_wheel(whl_fh)
|
|
40
|
+
|
|
41
|
+
print("Enabling automatic retrofiting of Python code at import-time")
|
|
42
|
+
|
|
43
|
+
# TODO: I think we need to add retrofy as a runtime dependency for editable mode
|
|
44
|
+
# (we can do this).
|
|
45
|
+
|
|
46
|
+
editable_copy.unlink()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def compatibility_via_rewrite(wheel: pathlib.Path):
|
|
50
|
+
"""Change code within the given wheel to be compatible"""
|
|
51
|
+
editable_copy = wheel.parent / (wheel.name + ".copy.whl")
|
|
52
|
+
shutil.copy(wheel, editable_copy)
|
|
53
|
+
|
|
54
|
+
has_modifications = False
|
|
55
|
+
|
|
56
|
+
with zipfile.ZipFile(str(editable_copy), "r") as whl_zip:
|
|
57
|
+
whl = WheelModifier(whl_zip)
|
|
58
|
+
|
|
59
|
+
for filename in whl_zip.namelist():
|
|
60
|
+
if filename.endswith(".py"):
|
|
61
|
+
code = whl.read(filename).decode("utf-8")
|
|
62
|
+
new_code = convert(code)
|
|
63
|
+
if new_code != code:
|
|
64
|
+
print(f"Converted {filename} to compatibility syntax")
|
|
65
|
+
whl.write(filename, new_code)
|
|
66
|
+
has_modifications = True
|
|
67
|
+
if has_modifications:
|
|
68
|
+
with wheel.open("wb") as whl_fh:
|
|
69
|
+
whl.write_wheel(whl_fh)
|
|
70
|
+
|
|
71
|
+
editable_copy.unlink()
|
|
File without changes
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
import libcst as cst
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DataclassTransformer(cst.CSTTransformer):
|
|
9
|
+
"""
|
|
10
|
+
Transform @dataclass decorated classes to include __match_args__ attribute.
|
|
11
|
+
|
|
12
|
+
This transformation adds __match_args__ to dataclasses for Python < 3.10
|
|
13
|
+
compatibility, unless match_args=False is explicitly specified.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def leave_Module(
|
|
17
|
+
self,
|
|
18
|
+
original_node: cst.Module,
|
|
19
|
+
updated_node: cst.Module,
|
|
20
|
+
) -> cst.Module:
|
|
21
|
+
"""Transform the entire module to add __match_args__ handling after class definitions."""
|
|
22
|
+
new_body = []
|
|
23
|
+
|
|
24
|
+
for stmt in updated_node.body:
|
|
25
|
+
if isinstance(stmt, cst.ClassDef):
|
|
26
|
+
# Process the class
|
|
27
|
+
processed_class, needs_deletion = self._process_dataclass(stmt)
|
|
28
|
+
new_body.append(processed_class)
|
|
29
|
+
|
|
30
|
+
# Add deletion statement if needed
|
|
31
|
+
if needs_deletion:
|
|
32
|
+
delete_stmt = self._create_delete_statement(
|
|
33
|
+
processed_class.name.value,
|
|
34
|
+
)
|
|
35
|
+
# Add the delete statement with proper spacing
|
|
36
|
+
delete_stmt = delete_stmt.with_changes(
|
|
37
|
+
leading_lines=[
|
|
38
|
+
cst.EmptyLine(
|
|
39
|
+
whitespace=cst.SimpleWhitespace(""),
|
|
40
|
+
comment=None,
|
|
41
|
+
),
|
|
42
|
+
],
|
|
43
|
+
)
|
|
44
|
+
new_body.append(delete_stmt)
|
|
45
|
+
else:
|
|
46
|
+
new_body.append(stmt)
|
|
47
|
+
|
|
48
|
+
return updated_node.with_changes(body=new_body)
|
|
49
|
+
|
|
50
|
+
def _process_dataclass(self, class_def: cst.ClassDef) -> tuple[cst.ClassDef, bool]:
|
|
51
|
+
"""Process a class definition and return (modified_class, needs_deletion)."""
|
|
52
|
+
|
|
53
|
+
# Check if this class has a @dataclass decorator
|
|
54
|
+
if not self._has_dataclass_decorator(class_def):
|
|
55
|
+
return class_def, False
|
|
56
|
+
|
|
57
|
+
# Check if match_args=False is specified
|
|
58
|
+
if self._has_match_args_false(class_def):
|
|
59
|
+
# Remove match_args=False parameter and mark for deletion
|
|
60
|
+
modified_class = self._remove_match_args_parameter(class_def)
|
|
61
|
+
# Only need deletion if __match_args__ wasn't explicitly defined
|
|
62
|
+
needs_deletion = not self._has_match_args_attribute(class_def)
|
|
63
|
+
return modified_class, needs_deletion
|
|
64
|
+
|
|
65
|
+
# Check if __match_args__ is already defined
|
|
66
|
+
if self._has_match_args_attribute(class_def):
|
|
67
|
+
return class_def, False
|
|
68
|
+
|
|
69
|
+
# Add __match_args__ for regular dataclasses
|
|
70
|
+
field_names = self._extract_field_names(class_def)
|
|
71
|
+
if field_names:
|
|
72
|
+
match_args_stmt = self._create_match_args_statement(field_names)
|
|
73
|
+
new_body = list(class_def.body.body) + [match_args_stmt]
|
|
74
|
+
new_body_node = class_def.body.with_changes(body=new_body)
|
|
75
|
+
modified_class = class_def.with_changes(body=new_body_node)
|
|
76
|
+
return modified_class, False
|
|
77
|
+
|
|
78
|
+
return class_def, False
|
|
79
|
+
|
|
80
|
+
def _create_delete_statement(self, class_name: str) -> cst.Try:
|
|
81
|
+
"""Create try/except block to delete __match_args__ from the specified class."""
|
|
82
|
+
# try: del ClassName.__match_args__; except AttributeError: pass
|
|
83
|
+
|
|
84
|
+
del_stmt = cst.Del(
|
|
85
|
+
target=cst.Attribute(
|
|
86
|
+
value=cst.Name(class_name),
|
|
87
|
+
attr=cst.Name("__match_args__"),
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
try_body = cst.IndentedBlock([cst.SimpleStatementLine([del_stmt])])
|
|
92
|
+
|
|
93
|
+
except_handler = cst.ExceptHandler(
|
|
94
|
+
type=cst.Name("AttributeError"),
|
|
95
|
+
body=cst.IndentedBlock([cst.SimpleStatementLine([cst.Pass()])]),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return cst.Try(
|
|
99
|
+
body=try_body,
|
|
100
|
+
handlers=[except_handler],
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def _has_dataclass_decorator(self, class_def: cst.ClassDef) -> bool:
|
|
104
|
+
"""Check if class has @dataclass decorator."""
|
|
105
|
+
for decorator in class_def.decorators:
|
|
106
|
+
if isinstance(decorator.decorator, cst.Name):
|
|
107
|
+
if decorator.decorator.value == "dataclass":
|
|
108
|
+
return True
|
|
109
|
+
elif isinstance(decorator.decorator, cst.Call):
|
|
110
|
+
if (
|
|
111
|
+
isinstance(decorator.decorator.func, cst.Name)
|
|
112
|
+
and decorator.decorator.func.value == "dataclass"
|
|
113
|
+
):
|
|
114
|
+
return True
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
def _has_match_args_false(self, class_def: cst.ClassDef) -> bool:
|
|
118
|
+
"""Check if @dataclass has match_args=False."""
|
|
119
|
+
for decorator in class_def.decorators:
|
|
120
|
+
if isinstance(decorator.decorator, cst.Call):
|
|
121
|
+
if (
|
|
122
|
+
isinstance(decorator.decorator.func, cst.Name)
|
|
123
|
+
and decorator.decorator.func.value == "dataclass"
|
|
124
|
+
):
|
|
125
|
+
# Check for match_args=False in arguments
|
|
126
|
+
for arg in decorator.decorator.args:
|
|
127
|
+
if (
|
|
128
|
+
isinstance(arg, cst.Arg)
|
|
129
|
+
and isinstance(arg.keyword, cst.Name)
|
|
130
|
+
and arg.keyword.value == "match_args"
|
|
131
|
+
and isinstance(arg.value, cst.Name)
|
|
132
|
+
and arg.value.value == "False"
|
|
133
|
+
):
|
|
134
|
+
return True
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
def _has_match_args_attribute(self, class_def: cst.ClassDef) -> bool:
|
|
138
|
+
"""Check if class already has __match_args__ attribute."""
|
|
139
|
+
for stmt in class_def.body.body:
|
|
140
|
+
if isinstance(stmt, cst.SimpleStatementLine):
|
|
141
|
+
for inner_stmt in stmt.body:
|
|
142
|
+
if isinstance(inner_stmt, cst.Assign):
|
|
143
|
+
for target in inner_stmt.targets:
|
|
144
|
+
if (
|
|
145
|
+
isinstance(target.target, cst.Name)
|
|
146
|
+
and target.target.value == "__match_args__"
|
|
147
|
+
):
|
|
148
|
+
return True
|
|
149
|
+
elif isinstance(stmt, cst.AnnAssign):
|
|
150
|
+
if (
|
|
151
|
+
isinstance(stmt.target, cst.Name)
|
|
152
|
+
and stmt.target.value == "__match_args__"
|
|
153
|
+
):
|
|
154
|
+
return True
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
def _extract_field_names(self, class_def: cst.ClassDef) -> List[str]:
|
|
158
|
+
"""Extract field names from dataclass."""
|
|
159
|
+
field_names = []
|
|
160
|
+
|
|
161
|
+
for stmt in class_def.body.body:
|
|
162
|
+
if isinstance(stmt, cst.AnnAssign):
|
|
163
|
+
# Annotated assignment: x: int or x: int = 5
|
|
164
|
+
if isinstance(stmt.target, cst.Name):
|
|
165
|
+
field_names.append(stmt.target.value)
|
|
166
|
+
elif isinstance(stmt, cst.SimpleStatementLine):
|
|
167
|
+
for inner_stmt in stmt.body:
|
|
168
|
+
if isinstance(inner_stmt, cst.Assign):
|
|
169
|
+
# Regular assignment: x = 5 (less common in dataclasses)
|
|
170
|
+
for target in inner_stmt.targets:
|
|
171
|
+
if isinstance(target.target, cst.Name):
|
|
172
|
+
field_names.append(target.target.value)
|
|
173
|
+
elif isinstance(inner_stmt, cst.AnnAssign):
|
|
174
|
+
# Annotated assignment in simple statement line
|
|
175
|
+
if isinstance(inner_stmt.target, cst.Name):
|
|
176
|
+
field_names.append(inner_stmt.target.value)
|
|
177
|
+
|
|
178
|
+
return field_names
|
|
179
|
+
|
|
180
|
+
def _create_match_args_statement(
|
|
181
|
+
self,
|
|
182
|
+
field_names: List[str],
|
|
183
|
+
) -> cst.SimpleStatementLine:
|
|
184
|
+
"""Create __match_args__ = ('field1', 'field2', ...) statement."""
|
|
185
|
+
|
|
186
|
+
# Create tuple elements from field names
|
|
187
|
+
elements = []
|
|
188
|
+
for name in field_names:
|
|
189
|
+
elements.append(cst.Element(cst.SimpleString(f"'{name}'")))
|
|
190
|
+
|
|
191
|
+
# Create tuple
|
|
192
|
+
if len(elements) == 1:
|
|
193
|
+
# Single element tuple needs trailing comma
|
|
194
|
+
tuple_value = cst.Tuple([elements[0].with_changes(comma=cst.Comma())])
|
|
195
|
+
else:
|
|
196
|
+
tuple_value = cst.Tuple(elements)
|
|
197
|
+
|
|
198
|
+
# Create assignment: __match_args__ = (...)
|
|
199
|
+
assignment = cst.Assign(
|
|
200
|
+
targets=[cst.AssignTarget(cst.Name("__match_args__"))],
|
|
201
|
+
value=tuple_value,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
return cst.SimpleStatementLine([assignment])
|
|
205
|
+
|
|
206
|
+
def _remove_match_args_parameter(self, class_def: cst.ClassDef) -> cst.ClassDef:
|
|
207
|
+
"""Remove match_args=False parameter from @dataclass decorator."""
|
|
208
|
+
new_decorators = []
|
|
209
|
+
|
|
210
|
+
for decorator in class_def.decorators:
|
|
211
|
+
if isinstance(decorator.decorator, cst.Call):
|
|
212
|
+
if (
|
|
213
|
+
isinstance(decorator.decorator.func, cst.Name)
|
|
214
|
+
and decorator.decorator.func.value == "dataclass"
|
|
215
|
+
):
|
|
216
|
+
# Filter out the match_args parameter
|
|
217
|
+
new_args = []
|
|
218
|
+
for arg in decorator.decorator.args:
|
|
219
|
+
if not (
|
|
220
|
+
isinstance(arg, cst.Arg)
|
|
221
|
+
and isinstance(arg.keyword, cst.Name)
|
|
222
|
+
and arg.keyword.value == "match_args"
|
|
223
|
+
):
|
|
224
|
+
new_args.append(arg)
|
|
225
|
+
|
|
226
|
+
# If no args remain, convert back to simple @dataclass
|
|
227
|
+
if not new_args:
|
|
228
|
+
new_decorator = cst.Decorator(cst.Name("dataclass"))
|
|
229
|
+
else:
|
|
230
|
+
new_decorator = decorator.with_changes(
|
|
231
|
+
decorator=decorator.decorator.with_changes(args=new_args),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
new_decorators.append(new_decorator)
|
|
235
|
+
else:
|
|
236
|
+
new_decorators.append(decorator)
|
|
237
|
+
else:
|
|
238
|
+
new_decorators.append(decorator)
|
|
239
|
+
|
|
240
|
+
return class_def.with_changes(decorators=new_decorators)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Utilities for managing imports in transformations."""
|
|
2
|
+
|
|
3
|
+
from typing import Tuple
|
|
4
|
+
|
|
5
|
+
import libcst as cst
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ImportManager:
|
|
9
|
+
"""Helper class for managing automatic imports in transformations."""
|
|
10
|
+
|
|
11
|
+
def __init__(self):
|
|
12
|
+
self._required_imports = set()
|
|
13
|
+
|
|
14
|
+
def require_import(self, import_name: str) -> None:
|
|
15
|
+
"""Mark an import as required."""
|
|
16
|
+
self._required_imports.add(import_name)
|
|
17
|
+
|
|
18
|
+
def apply_imports(self, module: cst.Module) -> cst.Module:
|
|
19
|
+
"""Add all required imports to the module."""
|
|
20
|
+
if not self._required_imports:
|
|
21
|
+
return module
|
|
22
|
+
|
|
23
|
+
# Find the correct position to insert imports
|
|
24
|
+
insert_position = self._find_import_position(module.body)
|
|
25
|
+
|
|
26
|
+
new_stmts = list(module.body)
|
|
27
|
+
for import_name in sorted(self._required_imports):
|
|
28
|
+
import_stmt = self._create_import_statement(import_name)
|
|
29
|
+
new_stmts.insert(insert_position, import_stmt)
|
|
30
|
+
insert_position += 1
|
|
31
|
+
|
|
32
|
+
return module.with_changes(body=tuple(new_stmts))
|
|
33
|
+
|
|
34
|
+
def _create_import_statement(self, import_name: str) -> cst.SimpleStatementLine:
|
|
35
|
+
"""Create an import statement for the given import name."""
|
|
36
|
+
if "." in import_name:
|
|
37
|
+
# Handle dotted imports like "collections.abc"
|
|
38
|
+
parts = import_name.split(".")
|
|
39
|
+
import_node = cst.Name(parts[0])
|
|
40
|
+
for part in parts[1:]:
|
|
41
|
+
import_node = cst.Attribute(import_node, cst.Name(part))
|
|
42
|
+
else:
|
|
43
|
+
# Simple import like "typing"
|
|
44
|
+
import_node = cst.Name(import_name)
|
|
45
|
+
|
|
46
|
+
return cst.SimpleStatementLine(
|
|
47
|
+
[
|
|
48
|
+
cst.Import(
|
|
49
|
+
[
|
|
50
|
+
cst.ImportAlias(import_node),
|
|
51
|
+
],
|
|
52
|
+
),
|
|
53
|
+
],
|
|
54
|
+
trailing_whitespace=cst.TrailingWhitespace(
|
|
55
|
+
newline=cst.Newline(),
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def _find_import_position(
|
|
60
|
+
self,
|
|
61
|
+
body: Tuple[cst.BaseStatement, ...],
|
|
62
|
+
) -> int:
|
|
63
|
+
"""Find the correct position to insert imports."""
|
|
64
|
+
position = 0
|
|
65
|
+
|
|
66
|
+
# Skip module docstrings
|
|
67
|
+
if body and isinstance(body[0], cst.SimpleStatementLine):
|
|
68
|
+
if (
|
|
69
|
+
len(body[0].body) == 1
|
|
70
|
+
and isinstance(body[0].body[0], cst.Expr)
|
|
71
|
+
and isinstance(body[0].body[0].value, cst.SimpleString)
|
|
72
|
+
):
|
|
73
|
+
position = 1
|
|
74
|
+
|
|
75
|
+
# Skip __future__ imports
|
|
76
|
+
for i in range(position, len(body)):
|
|
77
|
+
stmt = body[i]
|
|
78
|
+
if isinstance(stmt, cst.SimpleStatementLine):
|
|
79
|
+
for substmt in stmt.body:
|
|
80
|
+
if (
|
|
81
|
+
isinstance(substmt, cst.ImportFrom)
|
|
82
|
+
and substmt.module
|
|
83
|
+
and isinstance(substmt.module, cst.Attribute)
|
|
84
|
+
and substmt.module.attr.value == "__future__"
|
|
85
|
+
):
|
|
86
|
+
position = i + 1
|
|
87
|
+
break
|
|
88
|
+
elif (
|
|
89
|
+
isinstance(substmt, cst.ImportFrom)
|
|
90
|
+
and substmt.module
|
|
91
|
+
and isinstance(substmt.module, cst.Name)
|
|
92
|
+
and substmt.module.value == "__future__"
|
|
93
|
+
):
|
|
94
|
+
position = i + 1
|
|
95
|
+
break
|
|
96
|
+
else:
|
|
97
|
+
# If we didn't find a __future__ import in this statement, stop looking
|
|
98
|
+
break
|
|
99
|
+
else:
|
|
100
|
+
# If we hit a non-simple statement, stop looking
|
|
101
|
+
break
|
|
102
|
+
|
|
103
|
+
return position
|