karni-cli 0.3.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.
- karni_cli/__init__.py +3 -0
- karni_cli/_agent/__init__.py +82 -0
- karni_cli/_agent/__main__.py +44 -0
- karni_cli/_agent/check.py +658 -0
- karni_cli/_agent/history.py +328 -0
- karni_cli/_agent/history_chronicle.py +246 -0
- karni_cli/_agent/overrides.py +194 -0
- karni_cli/_agent/rules.py +673 -0
- karni_cli/_changelog/0.2.0.toml +304 -0
- karni_cli/_changelog/0.3.0.toml +25 -0
- karni_cli/_changelog/__init__.py +248 -0
- karni_cli/_errorcodes/__init__.py +1171 -0
- karni_cli/_errorcodes/__main__.py +34 -0
- karni_cli/_errorcodes/links.py +217 -0
- karni_cli/_templates/project/cookiecutter.json +8 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.dockerignore +7 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.env.example +85 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.gitignore +10 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Caddyfile +22 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Dockerfile +53 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Makefile +52 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/README.md +169 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/__init__.py +16 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/asgi.py +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/celery.py +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/settings.py +28 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/urls.py +31 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/wsgi.py +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/docker-compose.yml +126 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.dockerignore +2 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.jscpd.json +9 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.npmrc +7 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/Dockerfile.dev +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/eslint.config.js +32 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/index.html +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/package.json +43 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/postcss.config.js +6 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/public/favicon.svg +6 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/App.tsx +18 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/api/health.ts +21 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/index.css +11 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/layout.tsx +51 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/main.tsx +11 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/pages/home.tsx +28 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/routes.tsx +24 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/vite-env.d.ts +1 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tailwind.config.ts +13 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tsconfig.json +19 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/vite.config.ts +18 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/manage.py +23 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/pyproject.toml +31 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/__init__.py +0 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_api_schema.py +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_smoke.py +11 -0
- karni_cli/adapters.py +373 -0
- karni_cli/agent_adapters.toml +160 -0
- karni_cli/agent_data.py +260 -0
- karni_cli/brand.py +55 -0
- karni_cli/clidoc.py +254 -0
- karni_cli/commands/__init__.py +0 -0
- karni_cli/commands/add.py +144 -0
- karni_cli/commands/beat.py +36 -0
- karni_cli/commands/check.py +101 -0
- karni_cli/commands/config.py +81 -0
- karni_cli/commands/context.py +171 -0
- karni_cli/commands/createsuperuser.py +28 -0
- karni_cli/commands/doctor.py +136 -0
- karni_cli/commands/explain.py +60 -0
- karni_cli/commands/make.py +55 -0
- karni_cli/commands/manage_passthrough.py +28 -0
- karni_cli/commands/mcp.py +20 -0
- karni_cli/commands/migrate.py +23 -0
- karni_cli/commands/new.py +106 -0
- karni_cli/commands/routes.py +20 -0
- karni_cli/commands/rules.py +139 -0
- karni_cli/commands/run_tests.py +38 -0
- karni_cli/commands/schedule.py +40 -0
- karni_cli/commands/seed.py +34 -0
- karni_cli/commands/serve.py +50 -0
- karni_cli/commands/shell.py +28 -0
- karni_cli/commands/sync_roles.py +32 -0
- karni_cli/commands/types.py +72 -0
- karni_cli/commands/upgrade.py +231 -0
- karni_cli/commands/worker.py +63 -0
- karni_cli/config.py +142 -0
- karni_cli/environment.py +29 -0
- karni_cli/errorcodes.py +377 -0
- karni_cli/errordoc.py +227 -0
- karni_cli/generators/__init__.py +67 -0
- karni_cli/generators/apitest.py +206 -0
- karni_cli/generators/app.py +103 -0
- karni_cli/generators/command.py +98 -0
- karni_cli/generators/component.py +115 -0
- karni_cli/generators/core.py +319 -0
- karni_cli/generators/event.py +57 -0
- karni_cli/generators/factory.py +88 -0
- karni_cli/generators/fields.py +343 -0
- karni_cli/generators/introspect.py +263 -0
- karni_cli/generators/listener.py +168 -0
- karni_cli/generators/middleware.py +95 -0
- karni_cli/generators/migration.py +82 -0
- karni_cli/generators/model.py +235 -0
- karni_cli/generators/module.py +255 -0
- karni_cli/generators/naming.py +100 -0
- karni_cli/generators/page.py +373 -0
- karni_cli/generators/pysource.py +304 -0
- karni_cli/generators/schedule.py +174 -0
- karni_cli/generators/seeder.py +86 -0
- karni_cli/generators/serializer.py +165 -0
- karni_cli/generators/service.py +68 -0
- karni_cli/generators/task.py +84 -0
- karni_cli/generators/viewset.py +179 -0
- karni_cli/history.py +59 -0
- karni_cli/local_state.py +73 -0
- karni_cli/main.py +104 -0
- karni_cli/mcp_server.py +103 -0
- karni_cli/project.py +248 -0
- karni_cli/rules_render.py +86 -0
- karni_cli/scaffold.py +197 -0
- karni_cli/settings_edit.py +128 -0
- karni_cli/tips.py +53 -0
- karni_cli/typegen.py +101 -0
- karni_cli/upgrades/__init__.py +28 -0
- karni_cli/upgrades/changelog.py +123 -0
- karni_cli/upgrades/codemod.py +125 -0
- karni_cli/upgrades/engine.py +224 -0
- karni_cli/upgrades/legacy_agent_files.py +138 -0
- karni_cli/upgrades/python.py +670 -0
- karni_cli/upgrades/transforms/rename-export.cjs +96 -0
- karni_cli/upgrades/typescript.py +160 -0
- karni_cli/upgrades/v0_2/__init__.py +1 -0
- karni_cli/upgrades/v0_2/agent_guidance.py +269 -0
- karni_cli/upgrades/v1_0/__init__.py +5 -0
- karni_cli/upgrades/v1_0/all_objects_to_without.py +18 -0
- karni_cli/upgrades/v1_0/all_with_deleted_to_without.py +13 -0
- karni_cli/upgrades/v1_0/deleted_only_to_without_dead.py +13 -0
- karni_cli/upgrades/v1_0/object_permission_classes_to_access.py +16 -0
- karni_cli/upgrades/v1_0/related_lookups_to_optimize.py +15 -0
- karni_cli/upgrades/v1_0/scoped_related_lookups_to_optimize.py +15 -0
- karni_cli/upgrades/v1_0/soft_delete_manager_mixin_to_scoped.py +14 -0
- karni_cli-0.3.1.dist-info/METADATA +191 -0
- karni_cli-0.3.1.dist-info/RECORD +145 -0
- karni_cli-0.3.1.dist-info/WHEEL +4 -0
- karni_cli-0.3.1.dist-info/entry_points.txt +2 -0
- karni_cli-0.3.1.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
"""Data-driven Python codemods (libcst): a mechanical breaking change is one line of data.
|
|
2
|
+
|
|
3
|
+
CODEMOD = RenameSymbol("karni.managers.SoftDeleteManagerMixin",
|
|
4
|
+
"karni.managers.ScopedManagerMixin")
|
|
5
|
+
|
|
6
|
+
libcst keeps every comment, blank line and quote style; only the rewritten
|
|
7
|
+
nodes change. Each codemod is idempotent (its output never matches its own
|
|
8
|
+
pattern) and conservative: where the project's own code makes a rewrite
|
|
9
|
+
ambiguous, it reports the site instead of guessing.
|
|
10
|
+
|
|
11
|
+
* :class:`RenameSymbol` — a moved import, a renamed class or base class.
|
|
12
|
+
* :class:`RenameSetting` — a renamed setting, where it is defined and read.
|
|
13
|
+
* :class:`ReplaceAttribute` — ``Model.<attr>`` becomes another expression.
|
|
14
|
+
* :class:`ReplaceMethodCall` — ``<expr>.<method>()`` becomes another expression.
|
|
15
|
+
* :class:`FindClassAttribute`, :class:`FindReferences` — detectors for manual
|
|
16
|
+
changes: they report every site and edit nothing.
|
|
17
|
+
|
|
18
|
+
Override / escape hatch: subclass :class:`PythonCodemod` (``needles``,
|
|
19
|
+
``analyse``, ``recorder``) for a change no data-driven codemod covers.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from abc import abstractmethod
|
|
25
|
+
from collections.abc import Collection, Mapping, Sequence
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, ClassVar
|
|
29
|
+
|
|
30
|
+
import libcst as cst
|
|
31
|
+
from libcst.codemod import CodemodContext
|
|
32
|
+
from libcst.codemod.visitors import AddImportsVisitor
|
|
33
|
+
from libcst.helpers import get_full_name_for_node
|
|
34
|
+
from libcst.metadata import (
|
|
35
|
+
ClassScope,
|
|
36
|
+
ExpressionContext,
|
|
37
|
+
ExpressionContextProvider,
|
|
38
|
+
GlobalScope,
|
|
39
|
+
MetadataWrapper,
|
|
40
|
+
ParentNodeProvider,
|
|
41
|
+
PositionProvider,
|
|
42
|
+
ProviderT,
|
|
43
|
+
QualifiedNameProvider,
|
|
44
|
+
ScopeProvider,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
from karni_cli.upgrades.codemod import Codemod, Outcome, Site, source_files
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"FindClassAttribute",
|
|
51
|
+
"FindReferences",
|
|
52
|
+
"PythonCodemod",
|
|
53
|
+
"Recorder",
|
|
54
|
+
"RenameSetting",
|
|
55
|
+
"RenameSymbol",
|
|
56
|
+
"ReplaceAttribute",
|
|
57
|
+
"ReplaceMethodCall",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
_PLACEHOLDER = "__karni_receiver__"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Recorder(cst.CSTTransformer):
|
|
64
|
+
"""A transformer that counts its rewrites and records sites with line numbers."""
|
|
65
|
+
|
|
66
|
+
METADATA_DEPENDENCIES: ClassVar[Collection[ProviderT]] = (PositionProvider,)
|
|
67
|
+
|
|
68
|
+
def __init__(self, path: str) -> None:
|
|
69
|
+
super().__init__()
|
|
70
|
+
self.path = path
|
|
71
|
+
self.count = 0
|
|
72
|
+
self.sites: list[Site] = []
|
|
73
|
+
|
|
74
|
+
def site(self, node: cst.CSTNode, note: str) -> None:
|
|
75
|
+
line = self.get_metadata(PositionProvider, node).start.line
|
|
76
|
+
found = Site(self.path, line, note)
|
|
77
|
+
if found not in self.sites:
|
|
78
|
+
self.sites.append(found)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class PythonCodemod(Codemod):
|
|
82
|
+
"""A codemod over the project's own ``.py`` files (migrations excluded)."""
|
|
83
|
+
|
|
84
|
+
@abstractmethod
|
|
85
|
+
def needles(self) -> tuple[str, ...]:
|
|
86
|
+
"""A file is parsed only when it contains one of these strings."""
|
|
87
|
+
|
|
88
|
+
def analyse(self, modules: Mapping[str, cst.Module]) -> Any:
|
|
89
|
+
"""Project-wide pre-pass over every candidate file; the result reaches :meth:`recorder`."""
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
@abstractmethod
|
|
93
|
+
def recorder(self, path: str, analysis: Any, context: CodemodContext) -> Recorder:
|
|
94
|
+
"""The transformer for one file."""
|
|
95
|
+
|
|
96
|
+
def transform_module(
|
|
97
|
+
self, module: cst.Module, path: str, analysis: Any
|
|
98
|
+
) -> tuple[cst.Module, Recorder]:
|
|
99
|
+
context = CodemodContext()
|
|
100
|
+
recorder = self.recorder(path, analysis, context)
|
|
101
|
+
result = MetadataWrapper(module).visit(recorder)
|
|
102
|
+
if context.scratch.get(AddImportsVisitor.CONTEXT_KEY):
|
|
103
|
+
result = AddImportsVisitor(context).transform_module(result)
|
|
104
|
+
return result, recorder
|
|
105
|
+
|
|
106
|
+
def transform_source(self, source: str, path: str = "module.py") -> tuple[str, int, list[Site]]:
|
|
107
|
+
"""Rewrite one file's text on its own (its analysis sees only this file)."""
|
|
108
|
+
module = cst.parse_module(source)
|
|
109
|
+
new, recorder = self.transform_module(module, path, self.analyse({path: module}))
|
|
110
|
+
return new.code, recorder.count, recorder.sites
|
|
111
|
+
|
|
112
|
+
def apply(self, root: Path) -> Outcome:
|
|
113
|
+
outcome = Outcome()
|
|
114
|
+
modules: dict[str, cst.Module] = {}
|
|
115
|
+
needles = [needle.encode() for needle in self.needles()]
|
|
116
|
+
for rel in source_files(root, (".py",)):
|
|
117
|
+
raw = (root / rel).read_bytes()
|
|
118
|
+
if not any(needle in raw for needle in needles):
|
|
119
|
+
continue
|
|
120
|
+
try:
|
|
121
|
+
modules[rel] = cst.parse_module(raw)
|
|
122
|
+
except cst.ParserSyntaxError as exc:
|
|
123
|
+
note = f"not parsed ({exc.message}); check it by hand"
|
|
124
|
+
outcome.sites.append(Site(rel, exc.raw_line, note))
|
|
125
|
+
analysis = self.analyse(modules)
|
|
126
|
+
for rel, module in modules.items():
|
|
127
|
+
new, recorder = self.transform_module(module, rel, analysis)
|
|
128
|
+
outcome.sites += recorder.sites
|
|
129
|
+
if recorder.count and new.code != module.code:
|
|
130
|
+
(root / rel).write_bytes(new.bytes)
|
|
131
|
+
outcome.files[rel] = recorder.count
|
|
132
|
+
return outcome
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _visit(recorder: Recorder, modules: Mapping[str, cst.Module]) -> list[Site]:
|
|
136
|
+
"""Run a site-recording visitor (built per path) over every module."""
|
|
137
|
+
sites: list[Site] = []
|
|
138
|
+
for path, module in modules.items():
|
|
139
|
+
recorder.path = path
|
|
140
|
+
recorder.sites = []
|
|
141
|
+
MetadataWrapper(module).visit(recorder)
|
|
142
|
+
sites += recorder.sites
|
|
143
|
+
return sites
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class _Substitute(cst.CSTTransformer):
|
|
147
|
+
def __init__(self, receiver: cst.BaseExpression) -> None:
|
|
148
|
+
super().__init__()
|
|
149
|
+
self.receiver = receiver
|
|
150
|
+
|
|
151
|
+
def leave_Name(self, original_node: cst.Name, updated_node: cst.Name) -> cst.BaseExpression:
|
|
152
|
+
return self.receiver if updated_node.value == _PLACEHOLDER else updated_node
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _attach(
|
|
156
|
+
receiver: cst.BaseExpression, replacement: str, like: cst.BaseExpression
|
|
157
|
+
) -> cst.BaseExpression:
|
|
158
|
+
"""``<receiver>.<replacement>``, keeping the parentheses ``like`` had."""
|
|
159
|
+
template = cst.parse_expression(f"{_PLACEHOLDER}.{replacement}")
|
|
160
|
+
built = template.visit(_Substitute(receiver))
|
|
161
|
+
assert isinstance(built, cst.BaseExpression)
|
|
162
|
+
return built.with_changes(lpar=like.lpar, rpar=like.rpar)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _class_like(node: cst.BaseExpression) -> bool:
|
|
166
|
+
"""Does ``node`` name a class: ``Book``, ``models.Book``, ``cls``, ``self.model``, ``type(x)``?"""
|
|
167
|
+
if isinstance(node, cst.Call):
|
|
168
|
+
func = node.func
|
|
169
|
+
return isinstance(func, cst.Name) and func.value == "type" and len(node.args) == 1
|
|
170
|
+
if isinstance(node, cst.Attribute):
|
|
171
|
+
name = node.attr.value
|
|
172
|
+
elif isinstance(node, cst.Name):
|
|
173
|
+
name = node.value
|
|
174
|
+
else:
|
|
175
|
+
return False
|
|
176
|
+
return (
|
|
177
|
+
name.lstrip("_")[:1].isupper()
|
|
178
|
+
or name in {"cls", "klass", "__class__"}
|
|
179
|
+
or name.endswith(("model", "_cls", "_class"))
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# finders (analysis passes and detectors)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class _ClassAttributeFinder(Recorder):
|
|
187
|
+
"""Sites where a class body assigns ``name``."""
|
|
188
|
+
|
|
189
|
+
METADATA_DEPENDENCIES = (PositionProvider, ScopeProvider)
|
|
190
|
+
|
|
191
|
+
def __init__(self, name: str, note: str) -> None:
|
|
192
|
+
super().__init__("")
|
|
193
|
+
self.name = name
|
|
194
|
+
self.note = note
|
|
195
|
+
|
|
196
|
+
def _check(self, node: cst.CSTNode, targets: Sequence[cst.BaseExpression]) -> None:
|
|
197
|
+
named = any(isinstance(t, cst.Name) and t.value == self.name for t in targets)
|
|
198
|
+
if named and isinstance(self.get_metadata(ScopeProvider, node, None), ClassScope):
|
|
199
|
+
self.site(node, self.note)
|
|
200
|
+
|
|
201
|
+
def visit_Assign(self, node: cst.Assign) -> None:
|
|
202
|
+
self._check(node, [target.target for target in node.targets])
|
|
203
|
+
|
|
204
|
+
def visit_AnnAssign(self, node: cst.AnnAssign) -> None:
|
|
205
|
+
self._check(node, [node.target])
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class _FunctionFinder(Recorder):
|
|
209
|
+
"""Sites where a function or method named ``name`` is defined."""
|
|
210
|
+
|
|
211
|
+
def __init__(self, name: str, note: str) -> None:
|
|
212
|
+
super().__init__("")
|
|
213
|
+
self.name = name
|
|
214
|
+
self.note = note
|
|
215
|
+
|
|
216
|
+
def visit_FunctionDef(self, node: cst.FunctionDef) -> None:
|
|
217
|
+
if node.name.value == self.name:
|
|
218
|
+
self.site(node, self.note)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class _GlobalAssignmentFinder(Recorder):
|
|
222
|
+
"""Sites where module-level code assigns ``name``."""
|
|
223
|
+
|
|
224
|
+
METADATA_DEPENDENCIES = (PositionProvider, ScopeProvider)
|
|
225
|
+
|
|
226
|
+
def __init__(self, name: str) -> None:
|
|
227
|
+
super().__init__("")
|
|
228
|
+
self.name = name
|
|
229
|
+
|
|
230
|
+
def _check(self, node: cst.CSTNode, targets: Sequence[cst.BaseExpression]) -> None:
|
|
231
|
+
named = any(isinstance(t, cst.Name) and t.value == self.name for t in targets)
|
|
232
|
+
if named and isinstance(self.get_metadata(ScopeProvider, node, None), GlobalScope):
|
|
233
|
+
self.site(node, "defines it")
|
|
234
|
+
|
|
235
|
+
def visit_Assign(self, node: cst.Assign) -> None:
|
|
236
|
+
self._check(node, [target.target for target in node.targets])
|
|
237
|
+
|
|
238
|
+
def visit_AnnAssign(self, node: cst.AnnAssign) -> None:
|
|
239
|
+
self._check(node, [node.target])
|
|
240
|
+
|
|
241
|
+
def visit_AugAssign(self, node: cst.AugAssign) -> None:
|
|
242
|
+
self._check(node, [node.target])
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class _ReferenceFinder(Recorder):
|
|
246
|
+
"""Sites that import or reference any of ``names`` (qualified, so aliases count)."""
|
|
247
|
+
|
|
248
|
+
METADATA_DEPENDENCIES = (PositionProvider, QualifiedNameProvider)
|
|
249
|
+
|
|
250
|
+
def __init__(self, path: str, names: frozenset[str], note: str) -> None:
|
|
251
|
+
super().__init__(path)
|
|
252
|
+
self.names = names
|
|
253
|
+
self.note = note
|
|
254
|
+
|
|
255
|
+
def _matches(self, node: cst.CSTNode) -> bool:
|
|
256
|
+
qualified = self.get_metadata(QualifiedNameProvider, node, set())
|
|
257
|
+
return any(q.name in self.names for q in qualified)
|
|
258
|
+
|
|
259
|
+
def visit_ImportFrom(self, node: cst.ImportFrom) -> bool:
|
|
260
|
+
if (
|
|
261
|
+
node.module is not None
|
|
262
|
+
and not node.relative
|
|
263
|
+
and not isinstance(node.names, cst.ImportStar)
|
|
264
|
+
):
|
|
265
|
+
module = get_full_name_for_node(node.module)
|
|
266
|
+
if any(f"{module}.{get_full_name_for_node(a.name)}" in self.names for a in node.names):
|
|
267
|
+
self.site(node, self.note)
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
def visit_Name(self, node: cst.Name) -> None:
|
|
271
|
+
if self._matches(node):
|
|
272
|
+
self.site(node, self.note)
|
|
273
|
+
|
|
274
|
+
def visit_Attribute(self, node: cst.Attribute) -> bool:
|
|
275
|
+
if self._matches(node):
|
|
276
|
+
self.site(node, self.note)
|
|
277
|
+
return False
|
|
278
|
+
return True
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# RenameSymbol
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _imports_unaliased(module: cst.Module, from_module: str, name: str) -> bool:
|
|
285
|
+
for statement in module.body:
|
|
286
|
+
if not isinstance(statement, cst.SimpleStatementLine):
|
|
287
|
+
continue
|
|
288
|
+
for small in statement.body:
|
|
289
|
+
if (
|
|
290
|
+
isinstance(small, cst.ImportFrom)
|
|
291
|
+
and small.module is not None
|
|
292
|
+
and not small.relative
|
|
293
|
+
and get_full_name_for_node(small.module) == from_module
|
|
294
|
+
and not isinstance(small.names, cst.ImportStar)
|
|
295
|
+
and any(
|
|
296
|
+
a.asname is None and get_full_name_for_node(a.name) == name for a in small.names
|
|
297
|
+
)
|
|
298
|
+
):
|
|
299
|
+
return True
|
|
300
|
+
return False
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class _RenameSymbol(Recorder):
|
|
304
|
+
METADATA_DEPENDENCIES = (PositionProvider, QualifiedNameProvider)
|
|
305
|
+
|
|
306
|
+
def __init__(self, path: str, old: str, new: str, context: CodemodContext) -> None:
|
|
307
|
+
super().__init__(path)
|
|
308
|
+
self.old, self.new, self.context = old, new, context
|
|
309
|
+
self.old_module, self.old_name = old.rsplit(".", 1)
|
|
310
|
+
self.new_module, self.new_name = new.rsplit(".", 1)
|
|
311
|
+
self.moved = self.old_module != self.new_module
|
|
312
|
+
self.in_import = 0
|
|
313
|
+
self.attr_names: set[int] = set()
|
|
314
|
+
self.new_imported = False
|
|
315
|
+
|
|
316
|
+
def _is_old(self, node: cst.CSTNode) -> bool:
|
|
317
|
+
return any(
|
|
318
|
+
q.name == self.old for q in self.get_metadata(QualifiedNameProvider, node, set())
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
def visit_Module(self, node: cst.Module) -> None:
|
|
322
|
+
self.new_imported = _imports_unaliased(node, self.new_module, self.new_name)
|
|
323
|
+
|
|
324
|
+
def visit_Import(self, node: cst.Import) -> None:
|
|
325
|
+
self.in_import += 1
|
|
326
|
+
|
|
327
|
+
def leave_Import(self, original_node: cst.Import, updated_node: cst.Import) -> cst.Import:
|
|
328
|
+
self.in_import -= 1
|
|
329
|
+
return updated_node
|
|
330
|
+
|
|
331
|
+
def visit_ImportFrom(self, node: cst.ImportFrom) -> None:
|
|
332
|
+
self.in_import += 1
|
|
333
|
+
|
|
334
|
+
def leave_ImportFrom(
|
|
335
|
+
self, original_node: cst.ImportFrom, updated_node: cst.ImportFrom
|
|
336
|
+
) -> cst.ImportFrom | cst.RemovalSentinel:
|
|
337
|
+
self.in_import -= 1
|
|
338
|
+
names = updated_node.names
|
|
339
|
+
if (
|
|
340
|
+
updated_node.relative
|
|
341
|
+
or updated_node.module is None
|
|
342
|
+
or isinstance(names, cst.ImportStar)
|
|
343
|
+
or get_full_name_for_node(updated_node.module) != self.old_module
|
|
344
|
+
):
|
|
345
|
+
return updated_node
|
|
346
|
+
kept: list[cst.ImportAlias] = []
|
|
347
|
+
touched = False
|
|
348
|
+
for alias in names:
|
|
349
|
+
if get_full_name_for_node(alias.name) != self.old_name:
|
|
350
|
+
kept.append(alias)
|
|
351
|
+
continue
|
|
352
|
+
touched = True
|
|
353
|
+
self.count += 1
|
|
354
|
+
asname = alias.asname.name if alias.asname else None
|
|
355
|
+
alias_name = asname.value if isinstance(asname, cst.Name) else None
|
|
356
|
+
if self.moved:
|
|
357
|
+
AddImportsVisitor.add_needed_import(
|
|
358
|
+
self.context, self.new_module, self.new_name, alias_name
|
|
359
|
+
)
|
|
360
|
+
elif alias_name is not None or not self.new_imported:
|
|
361
|
+
kept.append(alias.with_changes(name=cst.Name(self.new_name)))
|
|
362
|
+
self.new_imported = self.new_imported or alias_name is None
|
|
363
|
+
# else: the new name is already imported here; the old alias just goes.
|
|
364
|
+
if not touched:
|
|
365
|
+
return updated_node
|
|
366
|
+
if not kept:
|
|
367
|
+
return cst.RemoveFromParent()
|
|
368
|
+
if get_full_name_for_node(names[-1].name) == self.old_name:
|
|
369
|
+
kept[-1] = kept[-1].with_changes(comma=names[-1].comma)
|
|
370
|
+
return updated_node.with_changes(names=kept)
|
|
371
|
+
|
|
372
|
+
def visit_Attribute(self, node: cst.Attribute) -> None:
|
|
373
|
+
self.attr_names.add(id(node.attr))
|
|
374
|
+
|
|
375
|
+
def leave_Name(self, original_node: cst.Name, updated_node: cst.Name) -> cst.Name:
|
|
376
|
+
if (
|
|
377
|
+
self.in_import
|
|
378
|
+
or id(original_node) in self.attr_names
|
|
379
|
+
or updated_node.value != self.old_name
|
|
380
|
+
or not self._is_old(original_node)
|
|
381
|
+
):
|
|
382
|
+
return updated_node
|
|
383
|
+
self.count += 1
|
|
384
|
+
return updated_node.with_changes(value=self.new_name)
|
|
385
|
+
|
|
386
|
+
def leave_Attribute(
|
|
387
|
+
self, original_node: cst.Attribute, updated_node: cst.Attribute
|
|
388
|
+
) -> cst.BaseExpression:
|
|
389
|
+
if self.in_import or not self._is_old(original_node):
|
|
390
|
+
return updated_node
|
|
391
|
+
self.count += 1
|
|
392
|
+
if not self.moved:
|
|
393
|
+
return updated_node.with_changes(attr=cst.Name(self.new_name))
|
|
394
|
+
AddImportsVisitor.add_needed_import(self.context, self.new_module)
|
|
395
|
+
dotted = cst.parse_expression(self.new)
|
|
396
|
+
return dotted.with_changes(lpar=updated_node.lpar, rpar=updated_node.rpar)
|
|
397
|
+
|
|
398
|
+
def leave_SimpleString(
|
|
399
|
+
self, original_node: cst.SimpleString, updated_node: cst.SimpleString
|
|
400
|
+
) -> cst.SimpleString:
|
|
401
|
+
if self.in_import or updated_node.evaluated_value != self.old:
|
|
402
|
+
return updated_node
|
|
403
|
+
self.count += 1
|
|
404
|
+
quote = updated_node.quote
|
|
405
|
+
return updated_node.with_changes(value=f"{updated_node.prefix}{quote}{self.new}{quote}")
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@dataclass(frozen=True)
|
|
409
|
+
class RenameSymbol(PythonCodemod):
|
|
410
|
+
"""``old`` -> ``new`` (dotted paths): a moved import, a renamed class or base class.
|
|
411
|
+
|
|
412
|
+
Rewrites ``from`` imports (an ``as`` alias is kept), every reference that
|
|
413
|
+
resolves to ``old`` (bare names, ``module.Name`` access, base-class
|
|
414
|
+
lists) and string literals equal to the dotted path (settings name
|
|
415
|
+
classes that way). References are matched by what they import, never by
|
|
416
|
+
spelling, so a name the project defines itself is not touched. When the
|
|
417
|
+
module changes too, ``from new.module import Name`` is added.
|
|
418
|
+
"""
|
|
419
|
+
|
|
420
|
+
old: str
|
|
421
|
+
new: str
|
|
422
|
+
|
|
423
|
+
def needles(self) -> tuple[str, ...]:
|
|
424
|
+
return (self.old.rsplit(".", 1)[1],)
|
|
425
|
+
|
|
426
|
+
def recorder(self, path: str, analysis: Any, context: CodemodContext) -> Recorder:
|
|
427
|
+
return _RenameSymbol(path, self.old, self.new, context)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# RenameSetting
|
|
431
|
+
|
|
432
|
+
_SETTINGS_OBJECTS = frozenset({"settings", "conf.settings", "django.conf.settings"})
|
|
433
|
+
_SETTINGS_CALLS = frozenset({"override_settings", "modify_settings", "settings"})
|
|
434
|
+
_ATTR_FUNCTIONS = frozenset({"getattr", "hasattr", "setattr", "delattr"})
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _is_settings(node: cst.BaseExpression) -> bool:
|
|
438
|
+
return get_full_name_for_node(node) in _SETTINGS_OBJECTS
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
class _RenameSetting(Recorder):
|
|
442
|
+
def __init__(self, path: str, old: str, new: str, defines: bool) -> None:
|
|
443
|
+
super().__init__(path)
|
|
444
|
+
self.old, self.new, self.defines = old, new, defines
|
|
445
|
+
self.skip: set[int] = set()
|
|
446
|
+
|
|
447
|
+
def visit_Attribute(self, node: cst.Attribute) -> None:
|
|
448
|
+
self.skip.add(id(node.attr))
|
|
449
|
+
|
|
450
|
+
def visit_Arg(self, node: cst.Arg) -> None:
|
|
451
|
+
if node.keyword is not None:
|
|
452
|
+
self.skip.add(id(node.keyword))
|
|
453
|
+
|
|
454
|
+
def leave_Name(self, original_node: cst.Name, updated_node: cst.Name) -> cst.Name:
|
|
455
|
+
if self.defines and updated_node.value == self.old and id(original_node) not in self.skip:
|
|
456
|
+
self.count += 1
|
|
457
|
+
return updated_node.with_changes(value=self.new)
|
|
458
|
+
return updated_node
|
|
459
|
+
|
|
460
|
+
def leave_Attribute(
|
|
461
|
+
self, original_node: cst.Attribute, updated_node: cst.Attribute
|
|
462
|
+
) -> cst.Attribute:
|
|
463
|
+
if updated_node.attr.value == self.old and _is_settings(updated_node.value):
|
|
464
|
+
self.count += 1
|
|
465
|
+
return updated_node.with_changes(attr=cst.Name(self.new))
|
|
466
|
+
return updated_node
|
|
467
|
+
|
|
468
|
+
def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Call:
|
|
469
|
+
name = (get_full_name_for_node(updated_node.func) or "").rsplit(".", 1)[-1]
|
|
470
|
+
args = list(updated_node.args)
|
|
471
|
+
changed = False
|
|
472
|
+
if name in _SETTINGS_CALLS:
|
|
473
|
+
for index, arg in enumerate(args):
|
|
474
|
+
if arg.keyword is not None and arg.keyword.value == self.old:
|
|
475
|
+
args[index] = arg.with_changes(keyword=cst.Name(self.new))
|
|
476
|
+
changed = True
|
|
477
|
+
elif name in _ATTR_FUNCTIONS and len(args) >= 2 and _is_settings(args[0].value):
|
|
478
|
+
literal = args[1].value
|
|
479
|
+
if isinstance(literal, cst.SimpleString) and literal.evaluated_value == self.old:
|
|
480
|
+
quoted = f"{literal.prefix}{literal.quote}{self.new}{literal.quote}"
|
|
481
|
+
args[1] = args[1].with_changes(value=literal.with_changes(value=quoted))
|
|
482
|
+
changed = True
|
|
483
|
+
if not changed:
|
|
484
|
+
return updated_node
|
|
485
|
+
self.count += 1
|
|
486
|
+
return updated_node.with_changes(args=args)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
@dataclass(frozen=True)
|
|
490
|
+
class RenameSetting(PythonCodemod):
|
|
491
|
+
"""Setting ``old`` -> ``new``, where it is defined and wherever it is read.
|
|
492
|
+
|
|
493
|
+
Rewrites a module-level assignment (and every use of that name in the
|
|
494
|
+
module that assigns it), ``settings.OLD`` (django.conf's ``settings``
|
|
495
|
+
or pytest-django's ``settings`` fixture), ``getattr``/``hasattr``/
|
|
496
|
+
``setattr``/``delattr(settings, "OLD", ...)``, and ``OLD=`` keywords of
|
|
497
|
+
``override_settings``/``modify_settings``/``self.settings``. Environment
|
|
498
|
+
variable names are not settings and are left alone.
|
|
499
|
+
"""
|
|
500
|
+
|
|
501
|
+
old: str
|
|
502
|
+
new: str
|
|
503
|
+
|
|
504
|
+
def needles(self) -> tuple[str, ...]:
|
|
505
|
+
return (self.old,)
|
|
506
|
+
|
|
507
|
+
def analyse(self, modules: Mapping[str, cst.Module]) -> set[str]:
|
|
508
|
+
return {site.path for site in _visit(_GlobalAssignmentFinder(self.old), modules)}
|
|
509
|
+
|
|
510
|
+
def recorder(self, path: str, analysis: Any, context: CodemodContext) -> Recorder:
|
|
511
|
+
return _RenameSetting(path, self.old, self.new, path in analysis)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
# ReplaceAttribute
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
class _ReplaceAttribute(Recorder):
|
|
518
|
+
METADATA_DEPENDENCIES = (PositionProvider, ExpressionContextProvider, ParentNodeProvider)
|
|
519
|
+
|
|
520
|
+
def __init__(self, path: str, spec: ReplaceAttribute, definitions: list[Site]) -> None:
|
|
521
|
+
super().__init__(path)
|
|
522
|
+
self.spec = spec
|
|
523
|
+
self.definitions = definitions
|
|
524
|
+
|
|
525
|
+
def leave_Attribute(
|
|
526
|
+
self, original_node: cst.Attribute, updated_node: cst.Attribute
|
|
527
|
+
) -> cst.BaseExpression:
|
|
528
|
+
spec = self.spec
|
|
529
|
+
if original_node.attr.value != spec.attr or not _class_like(original_node.value):
|
|
530
|
+
return updated_node
|
|
531
|
+
if self.get_metadata(ExpressionContextProvider, original_node, None) is not (
|
|
532
|
+
ExpressionContext.LOAD
|
|
533
|
+
):
|
|
534
|
+
return updated_node
|
|
535
|
+
if self.definitions:
|
|
536
|
+
first = self.definitions[0]
|
|
537
|
+
self.site(
|
|
538
|
+
original_node,
|
|
539
|
+
f"left as is: the project defines its own {spec.attr} "
|
|
540
|
+
f"({first.path}:{first.line}); if this is Karni's, use .{spec.replacement}",
|
|
541
|
+
)
|
|
542
|
+
return updated_node
|
|
543
|
+
parent = self.get_metadata(ParentNodeProvider, original_node, None)
|
|
544
|
+
if (
|
|
545
|
+
isinstance(parent, cst.Attribute)
|
|
546
|
+
and parent.value is original_node
|
|
547
|
+
and parent.attr.value in spec.manager_only
|
|
548
|
+
):
|
|
549
|
+
self.site(
|
|
550
|
+
original_node,
|
|
551
|
+
f"left as is: .{parent.attr.value}() needs the manager {spec.attr} returned; "
|
|
552
|
+
"rewrite it by hand",
|
|
553
|
+
)
|
|
554
|
+
return updated_node
|
|
555
|
+
self.count += 1
|
|
556
|
+
return _attach(updated_node.value, spec.replacement, updated_node)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
@dataclass(frozen=True)
|
|
560
|
+
class ReplaceAttribute(PythonCodemod):
|
|
561
|
+
"""``Model.<attr>`` -> ``Model.<replacement>`` wherever the receiver names a class.
|
|
562
|
+
|
|
563
|
+
Only class-like receivers are rewritten (``Book``, ``models.Book``,
|
|
564
|
+
``cls``, ``self.model``, ``type(obj)``): Django never exposes a manager
|
|
565
|
+
through an instance, so ``obj.<attr>`` is someone else's attribute and is
|
|
566
|
+
left alone. Reported instead of rewritten: every use, when the project
|
|
567
|
+
defines ``<attr>`` on a class itself (a reference could reach either);
|
|
568
|
+
and a use followed by a ``manager_only`` call, which needs what ``<attr>``
|
|
569
|
+
returned rather than what ``replacement`` returns.
|
|
570
|
+
"""
|
|
571
|
+
|
|
572
|
+
attr: str
|
|
573
|
+
replacement: str
|
|
574
|
+
manager_only: frozenset[str] = frozenset()
|
|
575
|
+
|
|
576
|
+
def needles(self) -> tuple[str, ...]:
|
|
577
|
+
return (self.attr,)
|
|
578
|
+
|
|
579
|
+
def analyse(self, modules: Mapping[str, cst.Module]) -> list[Site]:
|
|
580
|
+
return _visit(_ClassAttributeFinder(self.attr, "defines it"), modules)
|
|
581
|
+
|
|
582
|
+
def recorder(self, path: str, analysis: Any, context: CodemodContext) -> Recorder:
|
|
583
|
+
return _ReplaceAttribute(path, self, analysis)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
# ReplaceMethodCall
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
class _ReplaceMethodCall(Recorder):
|
|
590
|
+
def __init__(self, path: str, spec: ReplaceMethodCall, definitions: list[Site]) -> None:
|
|
591
|
+
super().__init__(path)
|
|
592
|
+
self.spec = spec
|
|
593
|
+
self.definitions = definitions
|
|
594
|
+
|
|
595
|
+
def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.BaseExpression:
|
|
596
|
+
spec = self.spec
|
|
597
|
+
func = updated_node.func
|
|
598
|
+
if not isinstance(func, cst.Attribute) or func.attr.value != spec.method:
|
|
599
|
+
return updated_node
|
|
600
|
+
if updated_node.args:
|
|
601
|
+
return updated_node # not the argument-less method this codemod replaces
|
|
602
|
+
if self.definitions:
|
|
603
|
+
first = self.definitions[0]
|
|
604
|
+
self.site(
|
|
605
|
+
original_node,
|
|
606
|
+
f"left as is: the project defines its own {spec.method}() "
|
|
607
|
+
f"({first.path}:{first.line}); if this is Karni's, use .{spec.replacement}",
|
|
608
|
+
)
|
|
609
|
+
return updated_node
|
|
610
|
+
self.count += 1
|
|
611
|
+
return _attach(func.value, spec.replacement, updated_node)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
@dataclass(frozen=True)
|
|
615
|
+
class ReplaceMethodCall(PythonCodemod):
|
|
616
|
+
"""``<expr>.<method>()`` -> ``<expr>.<replacement>``, on any receiver.
|
|
617
|
+
|
|
618
|
+
Only argument-less calls match. When the project defines a function or
|
|
619
|
+
method named ``<method>`` itself, every call is reported instead (it
|
|
620
|
+
could be either).
|
|
621
|
+
"""
|
|
622
|
+
|
|
623
|
+
method: str
|
|
624
|
+
replacement: str
|
|
625
|
+
|
|
626
|
+
def needles(self) -> tuple[str, ...]:
|
|
627
|
+
return (self.method,)
|
|
628
|
+
|
|
629
|
+
def analyse(self, modules: Mapping[str, cst.Module]) -> list[Site]:
|
|
630
|
+
return _visit(_FunctionFinder(self.method, "defines it"), modules)
|
|
631
|
+
|
|
632
|
+
def recorder(self, path: str, analysis: Any, context: CodemodContext) -> Recorder:
|
|
633
|
+
return _ReplaceMethodCall(path, self, analysis)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
# detectors
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
@dataclass(frozen=True)
|
|
640
|
+
class FindClassAttribute(PythonCodemod):
|
|
641
|
+
"""Detector: every class body that assigns ``name``."""
|
|
642
|
+
|
|
643
|
+
edits = False
|
|
644
|
+
|
|
645
|
+
name: str
|
|
646
|
+
note: str
|
|
647
|
+
|
|
648
|
+
def needles(self) -> tuple[str, ...]:
|
|
649
|
+
return (self.name,)
|
|
650
|
+
|
|
651
|
+
def recorder(self, path: str, analysis: Any, context: CodemodContext) -> Recorder:
|
|
652
|
+
finder = _ClassAttributeFinder(self.name, self.note)
|
|
653
|
+
finder.path = path
|
|
654
|
+
return finder
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
@dataclass(frozen=True)
|
|
658
|
+
class FindReferences(PythonCodemod):
|
|
659
|
+
"""Detector: every import of, or reference to, one of ``qualified_names``."""
|
|
660
|
+
|
|
661
|
+
edits = False
|
|
662
|
+
|
|
663
|
+
qualified_names: tuple[str, ...]
|
|
664
|
+
note: str
|
|
665
|
+
|
|
666
|
+
def needles(self) -> tuple[str, ...]:
|
|
667
|
+
return tuple(name.rsplit(".", 1)[-1] for name in self.qualified_names)
|
|
668
|
+
|
|
669
|
+
def recorder(self, path: str, analysis: Any, context: CodemodContext) -> Recorder:
|
|
670
|
+
return _ReferenceFinder(path, frozenset(self.qualified_names), self.note)
|