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
karni_cli/tips.py ADDED
@@ -0,0 +1,53 @@
1
+ """One-time suggestions, done honestly.
2
+
3
+ A tip names what it would add and who made it, appears at most once per
4
+ project (recorded in the untracked ``.karni/local/state.json``), and never:
5
+
6
+ * in CI (:func:`karni_cli.environment.is_ci`);
7
+ * with ``suggest_tools = false`` (``karni config set suggest_tools false``,
8
+ project or ``--global``) or a quiet flag (``-q``);
9
+ * when it no longer applies (here: a history provider is already present);
10
+ * in machine-readable output — only ``karni doctor`` and ``karni check`` ask
11
+ for tips; ``karni context``, ``karni types``, ``karni rules`` and
12
+ ``karni new`` never do.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+
19
+ from karni_cli import config, history, local_state
20
+ from karni_cli.environment import is_ci
21
+ from karni_cli.project import CliError
22
+
23
+ HISTORY_TIP_ID = "history-provider"
24
+
25
+ #: The authorship disclosure is part of the tip, not optional.
26
+ HISTORY_TIP = (
27
+ "tip: no prompt-history store found. `karni context --with-history` can brief\n"
28
+ " an agent with what was already asked about a file. Works with Gigai\n"
29
+ " Chronicle (MIT, local-only, by Karni's author).\n"
30
+ " Silence this: karni config set suggest_tools false"
31
+ )
32
+
33
+
34
+ def history_tip(project: Path | None, *, quiet: bool = False) -> str | None:
35
+ """The history-provider tip if it may be shown now, else ``None``.
36
+
37
+ Returning the text records it as shown; if that record cannot be written
38
+ the tip is not shown at all, so it can never repeat.
39
+ """
40
+ if quiet or project is None or is_ci():
41
+ return None
42
+ try:
43
+ if not config.resolve("suggest_tools", project).value:
44
+ return None
45
+ except CliError:
46
+ return None # an unreadable config file: stay silent rather than guess
47
+ if local_state.tip_shown(project, HISTORY_TIP_ID):
48
+ return None
49
+ if history.provider_present(project):
50
+ return None
51
+ if not local_state.mark_tip_shown(project, HISTORY_TIP_ID):
52
+ return None
53
+ return HISTORY_TIP
karni_cli/typegen.py ADDED
@@ -0,0 +1,101 @@
1
+ """Typed-hook generation for ``karni types`` (E2).
2
+
3
+ The item component for each resource is resolved by following the list
4
+ operation's 200 response ``$ref`` through the paginated wrapper to
5
+ ``properties.results.items.$ref`` — never derived from the resource name
6
+ (component names are app-qualified, e.g. ``TestappEvent``).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import yaml
16
+
17
+ from karni_cli.project import CliError
18
+
19
+ HEADER = "// generated by karni types — do not edit\n"
20
+
21
+
22
+ def load_schema(path: Path) -> dict[str, Any]:
23
+ text = path.read_text()
24
+ if path.suffix == ".json":
25
+ return json.loads(text)
26
+ return yaml.safe_load(text)
27
+
28
+
29
+ def _resolve(doc: dict[str, Any], ref: str) -> dict[str, Any]:
30
+ if not ref.startswith("#/"):
31
+ raise CliError(f"Unsupported external $ref: {ref}", code="C206")
32
+ node: Any = doc
33
+ for part in ref[2:].split("/"):
34
+ node = node[part]
35
+ return node
36
+
37
+
38
+ def _component_name(ref: str) -> str:
39
+ return ref.rsplit("/", 1)[-1]
40
+
41
+
42
+ def resource_components(doc: dict[str, Any]) -> dict[str, str]:
43
+ """resource → item component name, from list ops carrying x-karni-resource."""
44
+ out: dict[str, str] = {}
45
+ for _path, item in sorted(doc.get("paths", {}).items()):
46
+ op = item.get("get")
47
+ if not op:
48
+ continue
49
+ resource = op.get("x-karni-resource")
50
+ if not resource or resource in out:
51
+ continue
52
+ try:
53
+ ref = op["responses"]["200"]["content"]["application/json"]["schema"]["$ref"]
54
+ paginated = _resolve(doc, ref)
55
+ items_ref = paginated["properties"]["results"]["items"]["$ref"]
56
+ except (KeyError, TypeError):
57
+ continue # not a paginated list response; skip quietly
58
+ out[resource] = _component_name(items_ref)
59
+ return out
60
+
61
+
62
+ def _pascal(resource: str) -> str:
63
+ return "".join(part.capitalize() for part in resource.replace("-", "_").split("_"))
64
+
65
+
66
+ def generate_hooks(doc: dict[str, Any]) -> str:
67
+ resources = resource_components(doc)
68
+ if not resources:
69
+ raise CliError("No x-karni-resource list operations found in the schema.", code="C206")
70
+ lines = [
71
+ HEADER.rstrip("\n"),
72
+ 'import { useResourceItem, useResourceList, useResourceMutations } from "@karni/ui";',
73
+ "import type {",
74
+ " ResourceItemParams,",
75
+ " ResourceListParams,",
76
+ " ResourceMutationsOptions,",
77
+ '} from "@karni/ui";',
78
+ 'import type { components } from "./types";',
79
+ "",
80
+ ]
81
+ for resource, component in sorted(resources.items()):
82
+ name = _pascal(resource)
83
+ item = f'components["schemas"]["{component}"]'
84
+ lines += [
85
+ f"export type {name}Item = {item};",
86
+ f"export const use{name}List = (params?: ResourceListParams) =>",
87
+ f' useResourceList<{item}>("{resource}", params);',
88
+ f"export const use{name}Item = (id: string | number, params?: ResourceItemParams) =>",
89
+ f' useResourceItem<{item}>("{resource}", id, params);',
90
+ f"export const use{name}Mutations = (options?: ResourceMutationsOptions) =>",
91
+ f' useResourceMutations<{item}>("{resource}", options);',
92
+ "",
93
+ ]
94
+ return "\n".join(lines)
95
+
96
+
97
+ def write_hooks(schema_path: Path, out_dir: Path) -> Path:
98
+ out_dir.mkdir(parents=True, exist_ok=True)
99
+ hooks = out_dir / "hooks.ts"
100
+ hooks.write_text(generate_hooks(load_schema(schema_path)))
101
+ return hooks
@@ -0,0 +1,28 @@
1
+ """``karni upgrade``: move a project across Karni versions with the changelog's codemods.
2
+
3
+ * :mod:`.changelog` — Karni's machine-readable changelog (one TOML file per
4
+ release in ``packages/karni/karni/changelog``) and the selection of the
5
+ changes between two versions;
6
+ * :mod:`.codemod` — the codemod contract, and which project files it may touch;
7
+ * :mod:`.python` — data-driven libcst codemods (rename a symbol or a setting,
8
+ replace an attribute or a method call) and detectors for manual changes;
9
+ * :mod:`.typescript` — the pinned jscodeshift runner and its data-driven
10
+ transforms (``transforms/``);
11
+ * :mod:`.engine` — runs codemods over a scratch copy, diffs it, writes it
12
+ back all or nothing, and builds the report;
13
+ * ``v<major>_<minor>/<name>.py`` — one module per breaking change, named by
14
+ the release in which the old form breaks, exposing ``CODEMOD``.
15
+
16
+ Recording a breaking change:
17
+
18
+ 1. append a ``[[change]]`` to ``packages/karni/karni/changelog/<release>.toml``;
19
+ 2. if it is mechanical, add ``upgrades/v<X>_<Y>/<name>.py``, usually one line
20
+ of data (``CODEMOD = RenameSymbol(old=..., new=...)``,
21
+ ``RenameSetting(...)``, ``RenameExport(...)`` for ``@karni/ui``), plus a
22
+ before/after test; if it is manual, optionally a detector the same way;
23
+ 3. write its section in ``docs/migrations/<release>.md``; if it deprecates
24
+ something, ``warn_deprecated(..., removed_in=..., doc_anchor=<that section>)``.
25
+
26
+ The changelog tests (packages/karni/tests/test_changelog.py,
27
+ tests/test_upgrade.py here) fail until every piece is in place.
28
+ """
@@ -0,0 +1,123 @@
1
+ """Karni's machine-readable changelog, as karni-cli reads it, and version selection.
2
+
3
+ The data and its schema live in Karni (``packages/karni/karni/changelog``).
4
+ That package is stdlib only, so the CLI loads it by path and never imports
5
+ Karni or Django: from the monorepo source when run from a checkout, else from
6
+ the copy bundled into the wheel (``karni_cli/_changelog``, see hatch_build.py).
7
+ The CLI therefore knows every change up to its own release, whichever karni
8
+ the project has installed. That is what lets ``karni upgrade`` run *before*
9
+ the project raises its karni pin.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import importlib.util
15
+ import sys
16
+ from collections.abc import Iterable, Sequence
17
+ from importlib import resources
18
+ from pathlib import Path
19
+ from types import ModuleType
20
+ from typing import TYPE_CHECKING
21
+
22
+ from packaging.version import InvalidVersion, Version
23
+
24
+ from karni_cli.project import CliError
25
+
26
+ if TYPE_CHECKING:
27
+ from karni.changelog import Change
28
+
29
+ _MODULE_NAME = "karni_cli._bundled_karni_changelog"
30
+
31
+
32
+ def source_dir() -> Path | None:
33
+ """The changelog package: the monorepo source, else the wheel's bundled copy."""
34
+ for parent in Path(__file__).resolve().parents:
35
+ candidate = parent / "packages" / "karni" / "karni" / "changelog"
36
+ if (candidate / "__init__.py").is_file():
37
+ return candidate
38
+ packaged = resources.files("karni_cli") / "_changelog"
39
+ with resources.as_file(packaged) as path:
40
+ if (path / "__init__.py").is_file():
41
+ return path
42
+ return None
43
+
44
+
45
+ def module() -> ModuleType:
46
+ """``karni.changelog``, loaded by path under a private name."""
47
+ if _MODULE_NAME in sys.modules:
48
+ return sys.modules[_MODULE_NAME]
49
+ directory = source_dir()
50
+ if directory is None:
51
+ raise CliError(
52
+ "Karni's changelog is missing from this karni-cli install "
53
+ "(karni_cli/_changelog). Reinstall karni-cli.",
54
+ code="C005",
55
+ )
56
+ spec = importlib.util.spec_from_file_location(_MODULE_NAME, directory / "__init__.py")
57
+ if spec is None or spec.loader is None: # pragma: no cover - importlib contract
58
+ raise CliError(f"Cannot load Karni's changelog from {directory}", code="C005")
59
+ loaded = importlib.util.module_from_spec(spec)
60
+ sys.modules[_MODULE_NAME] = loaded
61
+ try:
62
+ spec.loader.exec_module(loaded)
63
+ except Exception:
64
+ del sys.modules[_MODULE_NAME]
65
+ raise
66
+ return loaded
67
+
68
+
69
+ def load(directory: Path | None = None) -> list[Change]:
70
+ """Every recorded change, oldest release first (see ``karni.changelog.load``)."""
71
+ changelog = module()
72
+ try:
73
+ return list(changelog.changes(directory or source_dir()))
74
+ except changelog.ChangelogError as exc:
75
+ raise CliError(str(exc.args[0]) if exc.args else str(exc), code="E704") from None
76
+
77
+
78
+ def docs_url(anchor: str) -> str:
79
+ """The published link to a migration note (Karni's single ``DOCS_BASE_URL``)."""
80
+ return str(module().docs_url(anchor))
81
+
82
+
83
+ def parse(version: str, what: str) -> Version:
84
+ try:
85
+ return Version(version)
86
+ except InvalidVersion:
87
+ raise CliError(
88
+ f"{what} {version!r} is not a version number (e.g. 0.2.0).", code="C203"
89
+ ) from None
90
+
91
+
92
+ def newest_release(changes: Iterable[Change]) -> str:
93
+ """The default ``--to``: the newest release that recorded a change."""
94
+ releases = {change.release for change in changes}
95
+ return max(releases, key=Version) if releases else "0"
96
+
97
+
98
+ def horizon(changes: Iterable[Change]) -> Version:
99
+ """The newest version this changelog knows anything about (a release or a removal)."""
100
+ versions = [Version(c.release) for c in changes] + [
101
+ Version(c.removed_in) for c in changes if c.removed_in
102
+ ]
103
+ return max(versions, default=Version("0"))
104
+
105
+
106
+ def select(changes: Sequence[Change], current: str, target: str) -> list[Change]:
107
+ """The changes to apply moving ``current`` -> ``target``, in the order to apply them.
108
+
109
+ A change is selected when its replacement exists in the target
110
+ (``release <= target``) and the project has not yet passed the release
111
+ where the old form breaks (``current < breaks_in``). So a deprecation is
112
+ picked up by every upgrade from before its removal, including one from
113
+ the very release that deprecated it, and moving to that release fixes
114
+ the warnings before they break anything. Order: by the release that
115
+ breaks, then the release that introduced, then file order.
116
+ """
117
+ now, then = Version(current), Version(target)
118
+ picked = [
119
+ change
120
+ for change in changes
121
+ if Version(change.release) <= then and now < Version(change.breaks_in)
122
+ ]
123
+ return sorted(picked, key=lambda c: (Version(c.breaks_in), Version(c.release)))
@@ -0,0 +1,125 @@
1
+ """The codemod contract, and which project files a codemod may touch.
2
+
3
+ A codemod is one breaking change made mechanical. It runs over a directory
4
+ tree (the engine hands it a scratch copy of the project), edits files in
5
+ place and returns an :class:`Outcome`: the files it changed with a count of
6
+ rewrites each, the :class:`Site` list it could not fix safely, or why it
7
+ did not run at all. A *detector* (``edits = False``) never edits: it finds
8
+ the code that a manual change has to touch.
9
+
10
+ Codemods are addressed by id, ``v<major>_<minor>.<name>``: the module
11
+ ``karni_cli.upgrades.v1_0.all_objects_to_without`` exposes ``CODEMOD``.
12
+ The version is the release in which the old form breaks.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import importlib
18
+ import os
19
+ from abc import ABC, abstractmethod
20
+ from collections.abc import Iterator
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import ClassVar
24
+
25
+ #: Directories never copied or scanned: VCS data, virtualenvs, installed
26
+ #: dependencies, caches, build output, uploaded and collected files.
27
+ PRUNE: frozenset[str] = frozenset(
28
+ {
29
+ ".git",
30
+ ".hg",
31
+ ".svn",
32
+ ".venv",
33
+ "venv",
34
+ "node_modules",
35
+ "site-packages",
36
+ "dist-packages",
37
+ "__pycache__",
38
+ ".mypy_cache",
39
+ ".pytest_cache",
40
+ ".ruff_cache",
41
+ ".tox",
42
+ ".nox",
43
+ "dist",
44
+ "build",
45
+ "media",
46
+ "staticfiles",
47
+ }
48
+ )
49
+ #: Also skipped when looking for source to rewrite: migrations are history
50
+ #: (they must keep replaying as written) and .karni/ holds Karni's own files.
51
+ SOURCE_PRUNE: frozenset[str] = PRUNE | {"migrations", ".karni"}
52
+
53
+
54
+ def walk(root: Path, prune: frozenset[str] = PRUNE) -> Iterator[Path]:
55
+ """Every regular file under ``root``, skipping ``prune`` and any virtualenv.
56
+
57
+ Path-based, like Karni's project-code discovery: what lives under the
58
+ project root is the project's, except dependencies and generated trees.
59
+ Symbolic links are never followed or yielded.
60
+ """
61
+ for directory, dirnames, filenames in os.walk(root):
62
+ here = Path(directory)
63
+ dirnames[:] = sorted(
64
+ name
65
+ for name in dirnames
66
+ if name not in prune
67
+ and not (here / name).is_symlink()
68
+ and not (here / name / "pyvenv.cfg").is_file()
69
+ )
70
+ for name in sorted(filenames):
71
+ path = here / name
72
+ if not path.is_symlink():
73
+ yield path
74
+
75
+
76
+ def source_files(root: Path, suffixes: tuple[str, ...]) -> list[str]:
77
+ """Project-relative POSIX paths of the project's own source files."""
78
+ return [
79
+ path.relative_to(root).as_posix()
80
+ for path in walk(root, SOURCE_PRUNE)
81
+ if path.name.endswith(suffixes)
82
+ ]
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class Site:
87
+ """A place in the project that needs a person."""
88
+
89
+ path: str
90
+ line: int
91
+ note: str
92
+
93
+ def render(self) -> str:
94
+ where = f"{self.path}:{self.line}" if self.line else self.path
95
+ return f"{where}: {self.note}"
96
+
97
+
98
+ @dataclass
99
+ class Outcome:
100
+ #: project-relative path -> number of rewrites in it
101
+ files: dict[str, int] = field(default_factory=dict)
102
+ #: code the codemod found but could not (or must not) rewrite
103
+ sites: list[Site] = field(default_factory=list)
104
+ #: why the codemod did not run (empty when it did)
105
+ skipped: str = ""
106
+
107
+
108
+ class Codemod(ABC):
109
+ """One breaking change, made mechanical (or, with ``edits = False``, findable)."""
110
+
111
+ #: False for a detector: it reports sites and never edits.
112
+ edits: ClassVar[bool] = True
113
+
114
+ @abstractmethod
115
+ def apply(self, root: Path) -> Outcome:
116
+ """Rewrite the tree under ``root`` in place and say what happened."""
117
+
118
+
119
+ def load_codemod(codemod_id: str) -> Codemod:
120
+ """``"v1_0.all_objects_to_without"`` -> that module's ``CODEMOD``."""
121
+ module = importlib.import_module(f"karni_cli.upgrades.{codemod_id}")
122
+ codemod = getattr(module, "CODEMOD", None)
123
+ if not isinstance(codemod, Codemod):
124
+ raise TypeError(f"karni_cli.upgrades.{codemod_id} must define CODEMOD (a Codemod)")
125
+ return codemod
@@ -0,0 +1,224 @@
1
+ """Run the selected changes' codemods: all or nothing, with a diff and a report.
2
+
3
+ Every codemod runs, in order, over a scratch copy of the project (the
4
+ project's own files, without .git, virtualenvs, node_modules, caches or
5
+ build output; ``.venv`` is linked in so project-interpreter lookups still
6
+ work). Then the copy is compared with the project:
7
+
8
+ * ``dry_run``: the unified diff is returned and the project is untouched;
9
+ * otherwise every changed, added or removed file is written back, and only
10
+ after every codemod succeeded. A failing codemod leaves the project as it
11
+ was.
12
+
13
+ Each change lands in the report as *applied* (files and rewrite counts),
14
+ *manual* (sites a person must change, plus every manual change, with its
15
+ migration note) and/or *skipped* (nothing to change, or could not run).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import difflib
21
+ import os
22
+ import shutil
23
+ import tempfile
24
+ from collections.abc import Callable, Sequence
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from typing import TYPE_CHECKING
28
+
29
+ from karni_cli.project import CliError
30
+ from karni_cli.upgrades import changelog as changelog_mod
31
+ from karni_cli.upgrades.codemod import Codemod, Outcome, Site, load_codemod, walk
32
+
33
+ if TYPE_CHECKING:
34
+ from karni.changelog import Change
35
+
36
+ #: Files larger than this are not copied: no codemod rewrites them.
37
+ MAX_BYTES = 5 * 1024 * 1024
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class FileChange:
42
+ path: str
43
+ before: bytes | None # None: the codemods created it
44
+ after: bytes | None # None: the codemods removed it
45
+
46
+
47
+ @dataclass
48
+ class Report:
49
+ current: str
50
+ target: str
51
+ dry_run: bool
52
+ applied: list[tuple[Change, Outcome]] = field(default_factory=list)
53
+ manual: list[tuple[Change, list[Site]]] = field(default_factory=list)
54
+ skipped: list[tuple[Change, str]] = field(default_factory=list)
55
+ changes: list[FileChange] = field(default_factory=list)
56
+
57
+ @property
58
+ def diff(self) -> str:
59
+ return unified_diff(self.changes)
60
+
61
+
62
+ # the scratch copy
63
+
64
+
65
+ def _copy(project: Path, scratch: Path) -> set[str]:
66
+ """Copy the project's files into ``scratch``; returns their relative paths."""
67
+ copied: set[str] = set()
68
+ for path in walk(project):
69
+ if path.stat().st_size > MAX_BYTES:
70
+ continue
71
+ rel = path.relative_to(project).as_posix()
72
+ target = scratch / rel
73
+ target.parent.mkdir(parents=True, exist_ok=True)
74
+ shutil.copy2(path, target)
75
+ copied.add(rel)
76
+ venv = project / ".venv"
77
+ if venv.is_dir():
78
+ os.symlink(venv, scratch / ".venv", target_is_directory=True)
79
+ return copied
80
+
81
+
82
+ def _compare(project: Path, scratch: Path, copied: set[str]) -> list[FileChange]:
83
+ found: list[FileChange] = []
84
+ for rel in sorted(copied):
85
+ before = (project / rel).read_bytes()
86
+ result = scratch / rel
87
+ after = result.read_bytes() if result.is_file() else None
88
+ if after != before:
89
+ found.append(FileChange(rel, before, after))
90
+ for path in walk(scratch):
91
+ rel = path.relative_to(scratch).as_posix()
92
+ if rel not in copied:
93
+ found.append(FileChange(rel, None, path.read_bytes()))
94
+ return sorted(found, key=lambda change: change.path)
95
+
96
+
97
+ def _write_back(project: Path, scratch: Path, changes: Sequence[FileChange]) -> None:
98
+ for change in changes:
99
+ target = project / change.path
100
+ if change.after is None:
101
+ target.unlink(missing_ok=True)
102
+ continue
103
+ target.parent.mkdir(parents=True, exist_ok=True)
104
+ shutil.copy2(scratch / change.path, target)
105
+
106
+
107
+ def unified_diff(changes: Sequence[FileChange]) -> str:
108
+ """``git diff``-style text for ``changes`` (new files from /dev/null, removals to it)."""
109
+ chunks: list[str] = []
110
+ for change in changes:
111
+ old_name = f"a/{change.path}" if change.before is not None else "/dev/null"
112
+ new_name = f"b/{change.path}" if change.after is not None else "/dev/null"
113
+ try:
114
+ old = (change.before or b"").decode("utf-8")
115
+ new = (change.after or b"").decode("utf-8")
116
+ except UnicodeDecodeError:
117
+ chunks.append(f"Binary files {old_name} and {new_name} differ\n")
118
+ continue
119
+ lines = list(
120
+ difflib.unified_diff(
121
+ old.splitlines(keepends=True),
122
+ new.splitlines(keepends=True),
123
+ fromfile=old_name,
124
+ tofile=new_name,
125
+ )
126
+ )
127
+ chunks.append(
128
+ "".join(
129
+ line if line.endswith("\n") else line + "\n\\n"
130
+ for line in lines
131
+ )
132
+ )
133
+ return "".join(chunks)
134
+
135
+
136
+ # running
137
+
138
+
139
+ def _record(report: Report, change: Change, codemod: Codemod, outcome: Outcome) -> None:
140
+ if outcome.skipped:
141
+ report.skipped.append((change, outcome.skipped))
142
+ if outcome.files:
143
+ report.applied.append((change, outcome))
144
+ if outcome.sites:
145
+ report.manual.append((change, outcome.sites))
146
+ elif not outcome.files and not outcome.skipped:
147
+ report.skipped.append((change, "nothing to change" if codemod.edits else "no uses found"))
148
+
149
+
150
+ def run(
151
+ project: Path,
152
+ changes: Sequence[Change],
153
+ *,
154
+ current: str,
155
+ target: str,
156
+ dry_run: bool,
157
+ resolve: Callable[[str], Codemod] = load_codemod,
158
+ ) -> Report:
159
+ """Apply ``changes`` (already selected and ordered) to ``project``."""
160
+ report = Report(current=current, target=target, dry_run=dry_run)
161
+ with tempfile.TemporaryDirectory(prefix="karni-upgrade-") as tmp:
162
+ scratch = Path(tmp) / project.name
163
+ scratch.mkdir()
164
+ copied = _copy(project, scratch)
165
+ for change in changes:
166
+ if not change.codemod:
167
+ report.manual.append((change, []))
168
+ continue
169
+ codemod = resolve(change.codemod)
170
+ try:
171
+ outcome = codemod.apply(scratch)
172
+ except Exception as exc:
173
+ raise CliError(
174
+ f"codemod {change.codemod} ({change.id}) failed: {exc!r}. "
175
+ "Nothing was written; please report this with the file it stopped on.",
176
+ code="C199",
177
+ ) from exc
178
+ _record(report, change, codemod, outcome)
179
+ report.changes = _compare(project, scratch, copied)
180
+ if not dry_run:
181
+ _write_back(project, scratch, report.changes)
182
+ return report
183
+
184
+
185
+ # the report
186
+
187
+
188
+ def _title(change: Change) -> str:
189
+ arrow = f" -> {change.replacement}" if change.replacement else ""
190
+ return f"{change.id}: {change.symbol}{arrow}"
191
+
192
+
193
+ def render(report: Report) -> str:
194
+ """The human report: applied, could not fix automatically, skipped, next steps."""
195
+ verb = "Would apply" if report.dry_run else "Applied"
196
+ lines: list[str] = []
197
+ if report.applied:
198
+ lines.append(f"{verb} automatically:")
199
+ for change, outcome in report.applied:
200
+ lines.append(f" {_title(change)} [{change.codemod}]")
201
+ lines += [f" {path}: {count}" for path, count in sorted(outcome.files.items())]
202
+ if report.manual:
203
+ lines.append("Could not fix automatically (needs you):")
204
+ for change, sites in report.manual:
205
+ lines.append(f" {_title(change)}")
206
+ if not sites:
207
+ lines.append(f" {change.summary}")
208
+ lines += [f" {site.render()}" for site in sites]
209
+ lines.append(f" see {changelog_mod.docs_url(change.migration)}")
210
+ if report.skipped:
211
+ lines.append("Skipped:")
212
+ lines += [f" {change.id}: {reason}" for change, reason in report.skipped]
213
+ files = len(report.changes)
214
+ if report.dry_run:
215
+ lines.append(f"Dry run: {files} file(s) would change; nothing was written.")
216
+ else:
217
+ lines.append(f"{files} file(s) changed.")
218
+ lines.append(
219
+ f"Next: review `git diff`, pin karni to {report.target} and reinstall, make the "
220
+ "changes listed above, run your formatter (rewrites keep your style but do not "
221
+ "re-wrap long lines), then `karni check` and your tests with "
222
+ "-W error::karni.deprecation.KarniDeprecationWarning."
223
+ )
224
+ return "\n".join(lines)