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