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.
Files changed (145) hide show
  1. karni_cli/__init__.py +3 -0
  2. karni_cli/_agent/__init__.py +82 -0
  3. karni_cli/_agent/__main__.py +44 -0
  4. karni_cli/_agent/check.py +658 -0
  5. karni_cli/_agent/history.py +328 -0
  6. karni_cli/_agent/history_chronicle.py +246 -0
  7. karni_cli/_agent/overrides.py +194 -0
  8. karni_cli/_agent/rules.py +673 -0
  9. karni_cli/_changelog/0.2.0.toml +304 -0
  10. karni_cli/_changelog/0.3.0.toml +25 -0
  11. karni_cli/_changelog/__init__.py +248 -0
  12. karni_cli/_errorcodes/__init__.py +1171 -0
  13. karni_cli/_errorcodes/__main__.py +34 -0
  14. karni_cli/_errorcodes/links.py +217 -0
  15. karni_cli/_templates/project/cookiecutter.json +8 -0
  16. karni_cli/_templates/project/{{cookiecutter.project_slug}}/.dockerignore +7 -0
  17. karni_cli/_templates/project/{{cookiecutter.project_slug}}/.env.example +85 -0
  18. karni_cli/_templates/project/{{cookiecutter.project_slug}}/.gitignore +10 -0
  19. karni_cli/_templates/project/{{cookiecutter.project_slug}}/Caddyfile +22 -0
  20. karni_cli/_templates/project/{{cookiecutter.project_slug}}/Dockerfile +53 -0
  21. karni_cli/_templates/project/{{cookiecutter.project_slug}}/Makefile +52 -0
  22. karni_cli/_templates/project/{{cookiecutter.project_slug}}/README.md +169 -0
  23. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/__init__.py +16 -0
  24. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/asgi.py +14 -0
  25. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/celery.py +17 -0
  26. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/settings.py +28 -0
  27. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/urls.py +31 -0
  28. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/wsgi.py +14 -0
  29. karni_cli/_templates/project/{{cookiecutter.project_slug}}/docker-compose.yml +126 -0
  30. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.dockerignore +2 -0
  31. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.jscpd.json +9 -0
  32. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.npmrc +7 -0
  33. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/Dockerfile.dev +17 -0
  34. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/eslint.config.js +32 -0
  35. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/index.html +14 -0
  36. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/package.json +43 -0
  37. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/postcss.config.js +6 -0
  38. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/public/favicon.svg +6 -0
  39. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/App.tsx +18 -0
  40. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/api/health.ts +21 -0
  41. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/index.css +11 -0
  42. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/layout.tsx +51 -0
  43. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/main.tsx +11 -0
  44. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/pages/home.tsx +28 -0
  45. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/routes.tsx +24 -0
  46. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/vite-env.d.ts +1 -0
  47. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tailwind.config.ts +13 -0
  48. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tsconfig.json +19 -0
  49. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/vite.config.ts +18 -0
  50. karni_cli/_templates/project/{{cookiecutter.project_slug}}/manage.py +23 -0
  51. karni_cli/_templates/project/{{cookiecutter.project_slug}}/pyproject.toml +31 -0
  52. karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/__init__.py +0 -0
  53. karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_api_schema.py +17 -0
  54. karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_smoke.py +11 -0
  55. karni_cli/adapters.py +373 -0
  56. karni_cli/agent_adapters.toml +160 -0
  57. karni_cli/agent_data.py +260 -0
  58. karni_cli/brand.py +55 -0
  59. karni_cli/clidoc.py +254 -0
  60. karni_cli/commands/__init__.py +0 -0
  61. karni_cli/commands/add.py +144 -0
  62. karni_cli/commands/beat.py +36 -0
  63. karni_cli/commands/check.py +101 -0
  64. karni_cli/commands/config.py +81 -0
  65. karni_cli/commands/context.py +171 -0
  66. karni_cli/commands/createsuperuser.py +28 -0
  67. karni_cli/commands/doctor.py +136 -0
  68. karni_cli/commands/explain.py +60 -0
  69. karni_cli/commands/make.py +55 -0
  70. karni_cli/commands/manage_passthrough.py +28 -0
  71. karni_cli/commands/mcp.py +20 -0
  72. karni_cli/commands/migrate.py +23 -0
  73. karni_cli/commands/new.py +106 -0
  74. karni_cli/commands/routes.py +20 -0
  75. karni_cli/commands/rules.py +139 -0
  76. karni_cli/commands/run_tests.py +38 -0
  77. karni_cli/commands/schedule.py +40 -0
  78. karni_cli/commands/seed.py +34 -0
  79. karni_cli/commands/serve.py +50 -0
  80. karni_cli/commands/shell.py +28 -0
  81. karni_cli/commands/sync_roles.py +32 -0
  82. karni_cli/commands/types.py +72 -0
  83. karni_cli/commands/upgrade.py +231 -0
  84. karni_cli/commands/worker.py +63 -0
  85. karni_cli/config.py +142 -0
  86. karni_cli/environment.py +29 -0
  87. karni_cli/errorcodes.py +377 -0
  88. karni_cli/errordoc.py +227 -0
  89. karni_cli/generators/__init__.py +67 -0
  90. karni_cli/generators/apitest.py +206 -0
  91. karni_cli/generators/app.py +103 -0
  92. karni_cli/generators/command.py +98 -0
  93. karni_cli/generators/component.py +115 -0
  94. karni_cli/generators/core.py +319 -0
  95. karni_cli/generators/event.py +57 -0
  96. karni_cli/generators/factory.py +88 -0
  97. karni_cli/generators/fields.py +343 -0
  98. karni_cli/generators/introspect.py +263 -0
  99. karni_cli/generators/listener.py +168 -0
  100. karni_cli/generators/middleware.py +95 -0
  101. karni_cli/generators/migration.py +82 -0
  102. karni_cli/generators/model.py +235 -0
  103. karni_cli/generators/module.py +255 -0
  104. karni_cli/generators/naming.py +100 -0
  105. karni_cli/generators/page.py +373 -0
  106. karni_cli/generators/pysource.py +304 -0
  107. karni_cli/generators/schedule.py +174 -0
  108. karni_cli/generators/seeder.py +86 -0
  109. karni_cli/generators/serializer.py +165 -0
  110. karni_cli/generators/service.py +68 -0
  111. karni_cli/generators/task.py +84 -0
  112. karni_cli/generators/viewset.py +179 -0
  113. karni_cli/history.py +59 -0
  114. karni_cli/local_state.py +73 -0
  115. karni_cli/main.py +104 -0
  116. karni_cli/mcp_server.py +103 -0
  117. karni_cli/project.py +248 -0
  118. karni_cli/rules_render.py +86 -0
  119. karni_cli/scaffold.py +197 -0
  120. karni_cli/settings_edit.py +128 -0
  121. karni_cli/tips.py +53 -0
  122. karni_cli/typegen.py +101 -0
  123. karni_cli/upgrades/__init__.py +28 -0
  124. karni_cli/upgrades/changelog.py +123 -0
  125. karni_cli/upgrades/codemod.py +125 -0
  126. karni_cli/upgrades/engine.py +224 -0
  127. karni_cli/upgrades/legacy_agent_files.py +138 -0
  128. karni_cli/upgrades/python.py +670 -0
  129. karni_cli/upgrades/transforms/rename-export.cjs +96 -0
  130. karni_cli/upgrades/typescript.py +160 -0
  131. karni_cli/upgrades/v0_2/__init__.py +1 -0
  132. karni_cli/upgrades/v0_2/agent_guidance.py +269 -0
  133. karni_cli/upgrades/v1_0/__init__.py +5 -0
  134. karni_cli/upgrades/v1_0/all_objects_to_without.py +18 -0
  135. karni_cli/upgrades/v1_0/all_with_deleted_to_without.py +13 -0
  136. karni_cli/upgrades/v1_0/deleted_only_to_without_dead.py +13 -0
  137. karni_cli/upgrades/v1_0/object_permission_classes_to_access.py +16 -0
  138. karni_cli/upgrades/v1_0/related_lookups_to_optimize.py +15 -0
  139. karni_cli/upgrades/v1_0/scoped_related_lookups_to_optimize.py +15 -0
  140. karni_cli/upgrades/v1_0/soft_delete_manager_mixin_to_scoped.py +14 -0
  141. karni_cli-0.3.1.dist-info/METADATA +191 -0
  142. karni_cli-0.3.1.dist-info/RECORD +145 -0
  143. karni_cli-0.3.1.dist-info/WHEEL +4 -0
  144. karni_cli-0.3.1.dist-info/entry_points.txt +2 -0
  145. 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)