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