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 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)
@@ -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