ballpython 2.0.0__py3-none-any.whl → 2.0.2__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.
- ballpython/py.typed +0 -0
- {ballpython-2.0.0.dist-info → ballpython-2.0.2.dist-info}/METADATA +13 -7
- ballpython-2.0.2.dist-info/RECORD +34 -0
- pycleaner/__init__.py +39 -2
- pycleaner/baseline.py +127 -0
- pycleaner/cache.py +282 -0
- pycleaner/cli.py +644 -43
- pycleaner/complexity_analyzer.py +16 -28
- pycleaner/config.py +8 -1
- pycleaner/dead_code_detector.py +275 -38
- pycleaner/dependency_auditor.py +175 -75
- pycleaner/discovery.py +206 -0
- pycleaner/explanations.py +323 -0
- pycleaner/frameworks/__init__.py +115 -0
- pycleaner/frameworks/plugins.py +290 -0
- pycleaner/modernizer.py +349 -0
- pycleaner/pipeline.py +208 -20
- pycleaner/py.typed +0 -0
- pycleaner/security_scanner.py +59 -38
- pycleaner/syntax_healer.py +188 -10
- pycleaner/taint_engine.py +8 -8
- pycleaner/test_generator.py +124 -35
- pycleaner/type_checker.py +29 -33
- pycleaner/verifier.py +495 -0
- ballpython-2.0.0.dist-info/RECORD +0 -24
- {ballpython-2.0.0.dist-info → ballpython-2.0.2.dist-info}/WHEEL +0 -0
- {ballpython-2.0.0.dist-info → ballpython-2.0.2.dist-info}/entry_points.txt +0 -0
- {ballpython-2.0.0.dist-info → ballpython-2.0.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pycleaner.frameworks.plugins
|
|
3
|
+
============================
|
|
4
|
+
|
|
5
|
+
Concrete Framework Plugins for Pydantic, Pytest, FastAPI, SQLAlchemy, and Dataclasses.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get_decorator_name(node: ast.expr) -> str:
|
|
15
|
+
"""Extract string name from decorator node (e.g. 'pytest.fixture' or 'app.get')."""
|
|
16
|
+
if isinstance(node, ast.Name):
|
|
17
|
+
return node.id
|
|
18
|
+
if isinstance(node, ast.Attribute):
|
|
19
|
+
val = _get_decorator_name(node.value)
|
|
20
|
+
return f"{val}.{node.attr}" if val else node.attr
|
|
21
|
+
if isinstance(node, ast.Call):
|
|
22
|
+
return _get_decorator_name(node.func)
|
|
23
|
+
return ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _has_base_named(class_node: ast.ClassDef, base_names: set[str]) -> bool:
|
|
27
|
+
"""Check if class node inherits from any name in base_names."""
|
|
28
|
+
for base in class_node.bases:
|
|
29
|
+
if isinstance(base, ast.Name) and base.id in base_names:
|
|
30
|
+
return True
|
|
31
|
+
if isinstance(base, ast.Attribute) and base.attr in base_names:
|
|
32
|
+
return True
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PydanticPlugin:
|
|
37
|
+
"""Understands Pydantic BaseModel, Field, model_config, and validators."""
|
|
38
|
+
|
|
39
|
+
name = "pydantic"
|
|
40
|
+
_MODEL_BASES = {"BaseModel", "BaseSettings", "RootModel", "GenericModel"}
|
|
41
|
+
_VALIDATOR_DECORATORS = {
|
|
42
|
+
"field_validator",
|
|
43
|
+
"model_validator",
|
|
44
|
+
"validator",
|
|
45
|
+
"root_validator",
|
|
46
|
+
"computed_field",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
def is_applicable(self, tree: ast.AST, filepath: Path | str) -> bool:
|
|
50
|
+
for node in ast.walk(tree):
|
|
51
|
+
if isinstance(node, ast.Import):
|
|
52
|
+
for alias in node.names:
|
|
53
|
+
if alias.name.split(".")[0] == "pydantic":
|
|
54
|
+
return True
|
|
55
|
+
elif isinstance(node, ast.ImportFrom):
|
|
56
|
+
if node.module and node.module.split(".")[0] == "pydantic":
|
|
57
|
+
return True
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
def get_protected_names(self, tree: ast.AST) -> set[str]:
|
|
61
|
+
return {"model_config", "Config", "ConfigDict"}
|
|
62
|
+
|
|
63
|
+
def get_protected_decorators(self) -> set[str]:
|
|
64
|
+
return self._VALIDATOR_DECORATORS
|
|
65
|
+
|
|
66
|
+
def is_protected_field(self, node: ast.AST, class_node: ast.ClassDef) -> bool:
|
|
67
|
+
return _has_base_named(class_node, self._MODEL_BASES)
|
|
68
|
+
|
|
69
|
+
def should_ignore_definition(
|
|
70
|
+
self,
|
|
71
|
+
name: str,
|
|
72
|
+
kind: str,
|
|
73
|
+
node: ast.AST,
|
|
74
|
+
context: str,
|
|
75
|
+
tree: ast.AST,
|
|
76
|
+
) -> bool:
|
|
77
|
+
if name in self.get_protected_names(tree):
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
81
|
+
for deco in node.decorator_list:
|
|
82
|
+
deco_name = _get_decorator_name(deco)
|
|
83
|
+
for val_dec in self._VALIDATOR_DECORATORS:
|
|
84
|
+
if val_dec in deco_name:
|
|
85
|
+
return True
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class PytestPlugin:
|
|
90
|
+
"""Understands Pytest fixtures, hooks, parameterizations, and marks."""
|
|
91
|
+
|
|
92
|
+
name = "pytest"
|
|
93
|
+
|
|
94
|
+
def is_applicable(self, tree: ast.AST, filepath: Path | str) -> bool:
|
|
95
|
+
f_name = Path(filepath).name
|
|
96
|
+
if (
|
|
97
|
+
f_name.startswith("test_")
|
|
98
|
+
or f_name.endswith("_test.py")
|
|
99
|
+
or f_name == "conftest.py"
|
|
100
|
+
):
|
|
101
|
+
return True
|
|
102
|
+
for node in ast.walk(tree):
|
|
103
|
+
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
104
|
+
if isinstance(node, ast.Import):
|
|
105
|
+
for a in node.names:
|
|
106
|
+
if a.name == "pytest":
|
|
107
|
+
return True
|
|
108
|
+
elif node.module and node.module.split(".")[0] == "pytest":
|
|
109
|
+
return True
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
def get_protected_names(self, tree: ast.AST) -> set[str]:
|
|
113
|
+
return {
|
|
114
|
+
"pytestmark",
|
|
115
|
+
"pytest_plugins",
|
|
116
|
+
"pytest_configure",
|
|
117
|
+
"pytest_unconfigure",
|
|
118
|
+
"pytest_addoption",
|
|
119
|
+
"pytest_collection_modifyitems",
|
|
120
|
+
"pytest_sessionstart",
|
|
121
|
+
"pytest_sessionfinish",
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
def get_protected_decorators(self) -> set[str]:
|
|
125
|
+
return {"pytest.fixture", "fixture", "pytest.mark"}
|
|
126
|
+
|
|
127
|
+
def is_protected_field(self, node: ast.AST, class_node: ast.ClassDef) -> bool:
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
def should_ignore_definition(
|
|
131
|
+
self,
|
|
132
|
+
name: str,
|
|
133
|
+
kind: str,
|
|
134
|
+
node: ast.AST,
|
|
135
|
+
context: str,
|
|
136
|
+
tree: ast.AST,
|
|
137
|
+
) -> bool:
|
|
138
|
+
if name in self.get_protected_names(tree):
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
# Test functions themselves are discovered by pytest runner
|
|
142
|
+
if kind == "function" and (name.startswith("test_") or name.endswith("_test")):
|
|
143
|
+
return True
|
|
144
|
+
if kind == "class" and name.startswith("Test"):
|
|
145
|
+
return True
|
|
146
|
+
|
|
147
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
148
|
+
for deco in node.decorator_list:
|
|
149
|
+
d_name = _get_decorator_name(deco)
|
|
150
|
+
if "fixture" in d_name or "mark." in d_name:
|
|
151
|
+
return True
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class FastAPIPlugin:
|
|
156
|
+
"""Understands FastAPI route handlers, dependencies, and lifecycle hooks."""
|
|
157
|
+
|
|
158
|
+
name = "fastapi"
|
|
159
|
+
_ROUTE_METHODS = {
|
|
160
|
+
"get",
|
|
161
|
+
"post",
|
|
162
|
+
"put",
|
|
163
|
+
"delete",
|
|
164
|
+
"patch",
|
|
165
|
+
"options",
|
|
166
|
+
"head",
|
|
167
|
+
"api_route",
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
def is_applicable(self, tree: ast.AST, filepath: Path | str) -> bool:
|
|
171
|
+
for node in ast.walk(tree):
|
|
172
|
+
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
173
|
+
if isinstance(node, ast.Import):
|
|
174
|
+
for a in node.names:
|
|
175
|
+
if a.name in ("fastapi", "starlette"):
|
|
176
|
+
return True
|
|
177
|
+
elif node.module and node.module.split(".")[0] in (
|
|
178
|
+
"fastapi",
|
|
179
|
+
"starlette",
|
|
180
|
+
):
|
|
181
|
+
return True
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
def get_protected_names(self, tree: ast.AST) -> set[str]:
|
|
185
|
+
return set()
|
|
186
|
+
|
|
187
|
+
def get_protected_decorators(self) -> set[str]:
|
|
188
|
+
return {"app.get", "app.post", "router.get", "router.post"}
|
|
189
|
+
|
|
190
|
+
def is_protected_field(self, node: ast.AST, class_node: ast.ClassDef) -> bool:
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
def should_ignore_definition(
|
|
194
|
+
self,
|
|
195
|
+
name: str,
|
|
196
|
+
kind: str,
|
|
197
|
+
node: ast.AST,
|
|
198
|
+
context: str,
|
|
199
|
+
tree: ast.AST,
|
|
200
|
+
) -> bool:
|
|
201
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
202
|
+
for deco in node.decorator_list:
|
|
203
|
+
d_name = _get_decorator_name(deco)
|
|
204
|
+
parts = d_name.split(".")
|
|
205
|
+
if len(parts) >= 2 and parts[-1] in self._ROUTE_METHODS:
|
|
206
|
+
return True
|
|
207
|
+
if (
|
|
208
|
+
"middleware" in d_name
|
|
209
|
+
or "on_event" in d_name
|
|
210
|
+
or "exception_handler" in d_name
|
|
211
|
+
):
|
|
212
|
+
return True
|
|
213
|
+
return False
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class SQLAlchemyPlugin:
|
|
217
|
+
"""Understands SQLAlchemy DeclarativeBase, Mapped, Column, and relationships."""
|
|
218
|
+
|
|
219
|
+
name = "sqlalchemy"
|
|
220
|
+
_BASE_CLASSES = {"Base", "DeclarativeBase", "DeclarativeBaseNoMeta"}
|
|
221
|
+
|
|
222
|
+
def is_applicable(self, tree: ast.AST, filepath: Path | str) -> bool:
|
|
223
|
+
for node in ast.walk(tree):
|
|
224
|
+
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
225
|
+
if isinstance(node, ast.Import):
|
|
226
|
+
for a in node.names:
|
|
227
|
+
if a.name.split(".")[0] == "sqlalchemy":
|
|
228
|
+
return True
|
|
229
|
+
elif node.module and node.module.split(".")[0] == "sqlalchemy":
|
|
230
|
+
return True
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
def get_protected_names(self, tree: ast.AST) -> set[str]:
|
|
234
|
+
return {"__tablename__", "__table_args__", "__mapper_args__"}
|
|
235
|
+
|
|
236
|
+
def get_protected_decorators(self) -> set[str]:
|
|
237
|
+
return {"validates"}
|
|
238
|
+
|
|
239
|
+
def is_protected_field(self, node: ast.AST, class_node: ast.ClassDef) -> bool:
|
|
240
|
+
if _has_base_named(class_node, self._BASE_CLASSES):
|
|
241
|
+
return True
|
|
242
|
+
# Check if attribute annotation uses Mapped[...]
|
|
243
|
+
if isinstance(node, ast.AnnAssign):
|
|
244
|
+
ann = node.annotation
|
|
245
|
+
if isinstance(ann, ast.Subscript):
|
|
246
|
+
if isinstance(ann.value, ast.Name) and ann.value.id == "Mapped":
|
|
247
|
+
return True
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
def should_ignore_definition(
|
|
251
|
+
self,
|
|
252
|
+
name: str,
|
|
253
|
+
kind: str,
|
|
254
|
+
node: ast.AST,
|
|
255
|
+
context: str,
|
|
256
|
+
tree: ast.AST,
|
|
257
|
+
) -> bool:
|
|
258
|
+
return name in self.get_protected_names(tree)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class DataclassPlugin:
|
|
262
|
+
"""Understands standard library dataclasses and attrs."""
|
|
263
|
+
|
|
264
|
+
name = "dataclass"
|
|
265
|
+
|
|
266
|
+
def is_applicable(self, tree: ast.AST, filepath: Path | str) -> bool:
|
|
267
|
+
return True
|
|
268
|
+
|
|
269
|
+
def get_protected_names(self, tree: ast.AST) -> set[str]:
|
|
270
|
+
return {"__post_init__"}
|
|
271
|
+
|
|
272
|
+
def get_protected_decorators(self) -> set[str]:
|
|
273
|
+
return {"dataclass"}
|
|
274
|
+
|
|
275
|
+
def is_protected_field(self, node: ast.AST, class_node: ast.ClassDef) -> bool:
|
|
276
|
+
for deco in class_node.decorator_list:
|
|
277
|
+
d_name = _get_decorator_name(deco)
|
|
278
|
+
if "dataclass" in d_name:
|
|
279
|
+
return True
|
|
280
|
+
return False
|
|
281
|
+
|
|
282
|
+
def should_ignore_definition(
|
|
283
|
+
self,
|
|
284
|
+
name: str,
|
|
285
|
+
kind: str,
|
|
286
|
+
node: ast.AST,
|
|
287
|
+
context: str,
|
|
288
|
+
tree: ast.AST,
|
|
289
|
+
) -> bool:
|
|
290
|
+
return name in self.get_protected_names(tree)
|
pycleaner/modernizer.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Code modernizer engine for upgrading Python source to modern Python idioms (PEP 585, PEP 604).
|
|
3
|
+
|
|
4
|
+
Performs AST-driven modernization:
|
|
5
|
+
- PEP 585 built-in collection generics (List[T] -> list[T], Dict[K, V] -> dict[K, V], etc.)
|
|
6
|
+
- PEP 604 union syntax (Union[A, B] -> A | B, Optional[A] -> A | None)
|
|
7
|
+
- Automatic injection of 'from __future__ import annotations' when type syntax is modernized
|
|
8
|
+
- Singleton equality comparison healing (== None -> is None, == True/False -> is True/False)
|
|
9
|
+
- Redundant (object) class inheritance pruning (class Foo(object): -> class Foo:)
|
|
10
|
+
- Redundant unicode literal prefix pruning ("..." -> "...")
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
import io
|
|
17
|
+
import re
|
|
18
|
+
import tokenize
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class ModernizeResult:
|
|
24
|
+
"""Result of modernizing Python source code."""
|
|
25
|
+
|
|
26
|
+
code: str
|
|
27
|
+
changed: bool
|
|
28
|
+
transformations: list[str] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _AnnotationTransformer(ast.NodeTransformer):
|
|
32
|
+
"""Transforms legacy typing annotations to PEP 585 and PEP 604 syntax."""
|
|
33
|
+
|
|
34
|
+
_PEP_585_MAP = {
|
|
35
|
+
"List": "list",
|
|
36
|
+
"Dict": "dict",
|
|
37
|
+
"Set": "set",
|
|
38
|
+
"Tuple": "tuple",
|
|
39
|
+
"FrozenSet": "frozenset",
|
|
40
|
+
"Type": "type",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
self.changed = False
|
|
45
|
+
self.transformations: list[str] = []
|
|
46
|
+
|
|
47
|
+
def visit_Subscript(self, node: ast.Subscript) -> ast.AST:
|
|
48
|
+
self.generic_visit(node)
|
|
49
|
+
name: str | None = None
|
|
50
|
+
if isinstance(node.value, ast.Name):
|
|
51
|
+
name = node.value.id
|
|
52
|
+
elif (
|
|
53
|
+
isinstance(node.value, ast.Attribute)
|
|
54
|
+
and isinstance(node.value.value, ast.Name)
|
|
55
|
+
and node.value.value.id == "typing"
|
|
56
|
+
):
|
|
57
|
+
name = node.value.attr
|
|
58
|
+
|
|
59
|
+
if not name:
|
|
60
|
+
return node
|
|
61
|
+
|
|
62
|
+
if name in self._PEP_585_MAP:
|
|
63
|
+
self.changed = True
|
|
64
|
+
new_id = self._PEP_585_MAP[name]
|
|
65
|
+
self.transformations.append(f"PEP 585: typing.{name} -> {new_id}")
|
|
66
|
+
node.value = ast.Name(id=new_id, ctx=ast.Load())
|
|
67
|
+
return node
|
|
68
|
+
|
|
69
|
+
if name == "Optional":
|
|
70
|
+
self.changed = True
|
|
71
|
+
self.transformations.append("PEP 604: typing.Optional[T] -> T | None")
|
|
72
|
+
return ast.BinOp(
|
|
73
|
+
left=node.slice, op=ast.BitOr(), right=ast.Constant(value=None)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if name == "Union":
|
|
77
|
+
self.changed = True
|
|
78
|
+
self.transformations.append("PEP 604: typing.Union[...] -> ... | ...")
|
|
79
|
+
if isinstance(node.slice, ast.Tuple) and node.slice.elts:
|
|
80
|
+
cur: ast.expr = node.slice.elts[0]
|
|
81
|
+
for nxt in node.slice.elts[1:]:
|
|
82
|
+
cur = ast.BinOp(left=cur, op=ast.BitOr(), right=nxt)
|
|
83
|
+
return cur
|
|
84
|
+
return node.slice
|
|
85
|
+
|
|
86
|
+
return node
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Modernizer:
|
|
90
|
+
"""Modernizes Python codebases to canonical modern syntax (PEP 585 / 604 / 695)."""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
enable_pep585: bool = True,
|
|
95
|
+
enable_pep604: bool = True,
|
|
96
|
+
enable_singleton_is: bool = True,
|
|
97
|
+
enable_object_inheritance_pruning: bool = True,
|
|
98
|
+
enable_unicode_prefix_pruning: bool = True,
|
|
99
|
+
) -> None:
|
|
100
|
+
self.enable_pep585 = enable_pep585
|
|
101
|
+
self.enable_pep604 = enable_pep604
|
|
102
|
+
self.enable_singleton_is = enable_singleton_is
|
|
103
|
+
self.enable_object_inheritance_pruning = enable_object_inheritance_pruning
|
|
104
|
+
self.enable_unicode_prefix_pruning = enable_unicode_prefix_pruning
|
|
105
|
+
|
|
106
|
+
def modernize(self, source: str, filename: str = "<stdin>") -> ModernizeResult:
|
|
107
|
+
"""Modernize Python source string with surgical preservation."""
|
|
108
|
+
try:
|
|
109
|
+
ast.parse(source, filename=filename)
|
|
110
|
+
except SyntaxError:
|
|
111
|
+
return ModernizeResult(code=source, changed=False)
|
|
112
|
+
|
|
113
|
+
current_code = source
|
|
114
|
+
all_transforms: list[str] = []
|
|
115
|
+
|
|
116
|
+
# 1. Class (object) pruning
|
|
117
|
+
if self.enable_object_inheritance_pruning:
|
|
118
|
+
current_code, t_obj = self._prune_object_inheritance(current_code)
|
|
119
|
+
all_transforms.extend(t_obj)
|
|
120
|
+
|
|
121
|
+
# 2. Singleton comparisons (== None -> is None, == True/False)
|
|
122
|
+
if self.enable_singleton_is:
|
|
123
|
+
current_code, t_cmp = self._modernize_singleton_comparisons(current_code)
|
|
124
|
+
all_transforms.extend(t_cmp)
|
|
125
|
+
|
|
126
|
+
# 3. Unicode literal prefixes ("..." -> "...")
|
|
127
|
+
if self.enable_unicode_prefix_pruning:
|
|
128
|
+
current_code, t_u = self._prune_unicode_prefixes(current_code)
|
|
129
|
+
all_transforms.extend(t_u)
|
|
130
|
+
|
|
131
|
+
# 4. PEP 585 / PEP 604 Annotations
|
|
132
|
+
if self.enable_pep585 or self.enable_pep604:
|
|
133
|
+
current_code, t_ann = self._modernize_annotations(current_code)
|
|
134
|
+
all_transforms.extend(t_ann)
|
|
135
|
+
|
|
136
|
+
changed = current_code != source
|
|
137
|
+
return ModernizeResult(
|
|
138
|
+
code=current_code,
|
|
139
|
+
changed=changed,
|
|
140
|
+
transformations=all_transforms,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def _prune_object_inheritance(self, source: str) -> tuple[str, list[str]]:
|
|
144
|
+
try:
|
|
145
|
+
tree = ast.parse(source)
|
|
146
|
+
except SyntaxError:
|
|
147
|
+
return source, []
|
|
148
|
+
|
|
149
|
+
transforms: list[str] = []
|
|
150
|
+
lines = source.splitlines(keepends=True)
|
|
151
|
+
edits: list[tuple[int, int, str]] = []
|
|
152
|
+
for node in ast.walk(tree):
|
|
153
|
+
if isinstance(node, ast.ClassDef) and len(node.bases) == 1:
|
|
154
|
+
base = node.bases[0]
|
|
155
|
+
if isinstance(base, ast.Name) and base.id == "object":
|
|
156
|
+
line_idx = node.lineno - 1
|
|
157
|
+
line_str = lines[line_idx]
|
|
158
|
+
pattern = re.compile(
|
|
159
|
+
rf"(class\s+{re.escape(node.name)})\s*\(\s*object\s*\)\s*:"
|
|
160
|
+
)
|
|
161
|
+
if pattern.search(line_str):
|
|
162
|
+
new_line = pattern.sub(rf"class {node.name}:", line_str)
|
|
163
|
+
edits.append((line_idx, line_idx + 1, new_line))
|
|
164
|
+
transforms.append(
|
|
165
|
+
f"Pruned legacy (object) inheritance from class {node.name}"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
if not edits:
|
|
169
|
+
return source, []
|
|
170
|
+
|
|
171
|
+
for start, end, new_text in sorted(edits, key=lambda x: x[0], reverse=True):
|
|
172
|
+
lines[start:end] = [new_text]
|
|
173
|
+
|
|
174
|
+
return "".join(lines), transforms
|
|
175
|
+
|
|
176
|
+
def _modernize_singleton_comparisons(self, source: str) -> tuple[str, list[str]]:
|
|
177
|
+
try:
|
|
178
|
+
tree = ast.parse(source)
|
|
179
|
+
except SyntaxError:
|
|
180
|
+
return source, []
|
|
181
|
+
|
|
182
|
+
lines = source.splitlines(keepends=True)
|
|
183
|
+
transforms: list[str] = []
|
|
184
|
+
edits: list[tuple[int, int, int, int, str]] = []
|
|
185
|
+
|
|
186
|
+
for node in ast.walk(tree):
|
|
187
|
+
if (
|
|
188
|
+
isinstance(node, ast.Compare)
|
|
189
|
+
and len(node.ops) == 1
|
|
190
|
+
and len(node.comparators) == 1
|
|
191
|
+
):
|
|
192
|
+
op = node.ops[0]
|
|
193
|
+
comparator = node.comparators[0]
|
|
194
|
+
if isinstance(comparator, ast.Constant) and (
|
|
195
|
+
comparator.value is None or isinstance(comparator.value, bool)
|
|
196
|
+
):
|
|
197
|
+
val = comparator.value
|
|
198
|
+
if isinstance(op, ast.Eq):
|
|
199
|
+
replacement_op = "is"
|
|
200
|
+
transforms.append(f"Modernized '== {val}' to 'is {val}'")
|
|
201
|
+
elif isinstance(op, ast.NotEq):
|
|
202
|
+
replacement_op = "is not"
|
|
203
|
+
transforms.append(f"Modernized '!= {val}' to 'is not {val}'")
|
|
204
|
+
else:
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
left_unparsed = ast.unparse(node.left)
|
|
208
|
+
new_expr = f"{left_unparsed} {replacement_op} {val}"
|
|
209
|
+
edits.append(
|
|
210
|
+
(
|
|
211
|
+
node.lineno,
|
|
212
|
+
node.col_offset,
|
|
213
|
+
node.end_lineno,
|
|
214
|
+
node.end_col_offset,
|
|
215
|
+
new_expr,
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
if not edits:
|
|
220
|
+
return source, []
|
|
221
|
+
|
|
222
|
+
edits.sort(key=lambda e: (e[0], e[1]), reverse=True)
|
|
223
|
+
for lineno, col_offset, end_lineno, end_col_offset, new_text in edits:
|
|
224
|
+
if lineno == end_lineno:
|
|
225
|
+
idx = lineno - 1
|
|
226
|
+
line = lines[idx]
|
|
227
|
+
lines[idx] = line[:col_offset] + new_text + line[end_col_offset:]
|
|
228
|
+
|
|
229
|
+
return "".join(lines), transforms
|
|
230
|
+
|
|
231
|
+
def _prune_unicode_prefixes(self, source: str) -> tuple[str, list[str]]:
|
|
232
|
+
try:
|
|
233
|
+
tokens = list(
|
|
234
|
+
tokenize.tokenize(io.BytesIO(source.encode("utf-8")).readline)
|
|
235
|
+
)
|
|
236
|
+
except tokenize.TokenError:
|
|
237
|
+
return source, []
|
|
238
|
+
|
|
239
|
+
edits: list[tuple[int, int, int, int]] = []
|
|
240
|
+
for tok in tokens:
|
|
241
|
+
if tok.type == tokenize.STRING:
|
|
242
|
+
if tok.string.startswith(
|
|
243
|
+
('u"', "u'", 'u"""', "u'''", 'U"', "U'", 'U"""', "U'''")
|
|
244
|
+
):
|
|
245
|
+
s_line, s_col = tok.start
|
|
246
|
+
edits.append((s_line, s_col, s_line, s_col + 1))
|
|
247
|
+
|
|
248
|
+
if not edits:
|
|
249
|
+
return source, []
|
|
250
|
+
|
|
251
|
+
lines = source.splitlines(keepends=True)
|
|
252
|
+
edits.sort(key=lambda e: (e[0], e[1]), reverse=True)
|
|
253
|
+
for s_line, s_col, _e_line, e_col in edits:
|
|
254
|
+
idx = s_line - 1
|
|
255
|
+
if 0 <= idx < len(lines):
|
|
256
|
+
line = lines[idx]
|
|
257
|
+
lines[idx] = line[:s_col] + line[e_col:]
|
|
258
|
+
|
|
259
|
+
new_source = "".join(lines)
|
|
260
|
+
return new_source, ["Pruned obsolete unicode literal prefixes"]
|
|
261
|
+
|
|
262
|
+
def _modernize_annotations(self, source: str) -> tuple[str, list[str]]:
|
|
263
|
+
try:
|
|
264
|
+
tree = ast.parse(source)
|
|
265
|
+
except SyntaxError:
|
|
266
|
+
return source, []
|
|
267
|
+
|
|
268
|
+
lines = source.splitlines(keepends=True)
|
|
269
|
+
edits: list[tuple[int, int, int, int, str]] = []
|
|
270
|
+
transformer = _AnnotationTransformer()
|
|
271
|
+
|
|
272
|
+
def process_annotation(node: ast.AST | None) -> None:
|
|
273
|
+
if node is None:
|
|
274
|
+
return
|
|
275
|
+
if not hasattr(node, "lineno") or not hasattr(node, "end_lineno"):
|
|
276
|
+
return
|
|
277
|
+
sub = _AnnotationTransformer()
|
|
278
|
+
new_node = sub.visit(node)
|
|
279
|
+
if sub.changed:
|
|
280
|
+
transformer.changed = True
|
|
281
|
+
transformer.transformations.extend(sub.transformations)
|
|
282
|
+
new_repr = ast.unparse(new_node)
|
|
283
|
+
edits.append(
|
|
284
|
+
(
|
|
285
|
+
node.lineno,
|
|
286
|
+
node.col_offset,
|
|
287
|
+
node.end_lineno,
|
|
288
|
+
node.end_col_offset,
|
|
289
|
+
new_repr,
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
for node in ast.walk(tree):
|
|
294
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
295
|
+
process_annotation(node.returns)
|
|
296
|
+
for arg in (
|
|
297
|
+
node.args.posonlyargs + node.args.args + node.args.kwonlyargs
|
|
298
|
+
):
|
|
299
|
+
process_annotation(arg.annotation)
|
|
300
|
+
if node.args.vararg:
|
|
301
|
+
process_annotation(node.args.vararg.annotation)
|
|
302
|
+
if node.args.kwarg:
|
|
303
|
+
process_annotation(node.args.kwarg.annotation)
|
|
304
|
+
elif isinstance(node, ast.AnnAssign):
|
|
305
|
+
process_annotation(node.annotation)
|
|
306
|
+
|
|
307
|
+
if not edits:
|
|
308
|
+
return source, []
|
|
309
|
+
|
|
310
|
+
edits.sort(key=lambda e: (e[0], e[1]), reverse=True)
|
|
311
|
+
for lineno, col_offset, end_lineno, end_col_offset, new_text in edits:
|
|
312
|
+
if lineno == end_lineno:
|
|
313
|
+
idx = lineno - 1
|
|
314
|
+
line = lines[idx]
|
|
315
|
+
lines[idx] = line[:col_offset] + new_text + line[end_col_offset:]
|
|
316
|
+
|
|
317
|
+
res = "".join(lines)
|
|
318
|
+
|
|
319
|
+
if transformer.changed and "from __future__ import annotations" not in res:
|
|
320
|
+
res = self._inject_future_annotations(res)
|
|
321
|
+
transformer.transformations.append(
|
|
322
|
+
"Injected 'from __future__ import annotations'"
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
return res, transformer.transformations
|
|
326
|
+
|
|
327
|
+
@staticmethod
|
|
328
|
+
def _inject_future_annotations(source: str) -> str:
|
|
329
|
+
lines = source.splitlines(keepends=True)
|
|
330
|
+
insert_idx = 0
|
|
331
|
+
try:
|
|
332
|
+
tree = ast.parse(source)
|
|
333
|
+
if (
|
|
334
|
+
tree.body
|
|
335
|
+
and isinstance(tree.body[0], ast.Expr)
|
|
336
|
+
and isinstance(tree.body[0].value, ast.Constant)
|
|
337
|
+
and isinstance(tree.body[0].value.value, str)
|
|
338
|
+
):
|
|
339
|
+
insert_idx = getattr(tree.body[0], "end_lineno", 1)
|
|
340
|
+
except SyntaxError:
|
|
341
|
+
pass # Fall back to default index if docstring extraction fails
|
|
342
|
+
|
|
343
|
+
future_stmt = "from __future__ import annotations\n\n"
|
|
344
|
+
if insert_idx < len(lines):
|
|
345
|
+
lines.insert(insert_idx, future_stmt)
|
|
346
|
+
else:
|
|
347
|
+
lines.append(future_stmt)
|
|
348
|
+
|
|
349
|
+
return "".join(lines)
|