karni-cli 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- karni_cli/__init__.py +3 -0
- karni_cli/_agent/__init__.py +82 -0
- karni_cli/_agent/__main__.py +44 -0
- karni_cli/_agent/check.py +658 -0
- karni_cli/_agent/history.py +328 -0
- karni_cli/_agent/history_chronicle.py +246 -0
- karni_cli/_agent/overrides.py +194 -0
- karni_cli/_agent/rules.py +673 -0
- karni_cli/_changelog/0.2.0.toml +304 -0
- karni_cli/_changelog/0.3.0.toml +25 -0
- karni_cli/_changelog/__init__.py +248 -0
- karni_cli/_errorcodes/__init__.py +1171 -0
- karni_cli/_errorcodes/__main__.py +34 -0
- karni_cli/_errorcodes/links.py +217 -0
- karni_cli/_templates/project/cookiecutter.json +8 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.dockerignore +7 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.env.example +85 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.gitignore +10 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Caddyfile +22 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Dockerfile +53 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Makefile +52 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/README.md +169 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/__init__.py +16 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/asgi.py +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/celery.py +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/settings.py +28 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/urls.py +31 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/wsgi.py +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/docker-compose.yml +126 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.dockerignore +2 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.jscpd.json +9 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.npmrc +7 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/Dockerfile.dev +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/eslint.config.js +32 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/index.html +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/package.json +43 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/postcss.config.js +6 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/public/favicon.svg +6 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/App.tsx +18 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/api/health.ts +21 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/index.css +11 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/layout.tsx +51 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/main.tsx +11 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/pages/home.tsx +28 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/routes.tsx +24 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/vite-env.d.ts +1 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tailwind.config.ts +13 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tsconfig.json +19 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/vite.config.ts +18 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/manage.py +23 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/pyproject.toml +31 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/__init__.py +0 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_api_schema.py +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_smoke.py +11 -0
- karni_cli/adapters.py +373 -0
- karni_cli/agent_adapters.toml +160 -0
- karni_cli/agent_data.py +260 -0
- karni_cli/brand.py +55 -0
- karni_cli/clidoc.py +254 -0
- karni_cli/commands/__init__.py +0 -0
- karni_cli/commands/add.py +144 -0
- karni_cli/commands/beat.py +36 -0
- karni_cli/commands/check.py +101 -0
- karni_cli/commands/config.py +81 -0
- karni_cli/commands/context.py +171 -0
- karni_cli/commands/createsuperuser.py +28 -0
- karni_cli/commands/doctor.py +136 -0
- karni_cli/commands/explain.py +60 -0
- karni_cli/commands/make.py +55 -0
- karni_cli/commands/manage_passthrough.py +28 -0
- karni_cli/commands/mcp.py +20 -0
- karni_cli/commands/migrate.py +23 -0
- karni_cli/commands/new.py +106 -0
- karni_cli/commands/routes.py +20 -0
- karni_cli/commands/rules.py +139 -0
- karni_cli/commands/run_tests.py +38 -0
- karni_cli/commands/schedule.py +40 -0
- karni_cli/commands/seed.py +34 -0
- karni_cli/commands/serve.py +50 -0
- karni_cli/commands/shell.py +28 -0
- karni_cli/commands/sync_roles.py +32 -0
- karni_cli/commands/types.py +72 -0
- karni_cli/commands/upgrade.py +231 -0
- karni_cli/commands/worker.py +63 -0
- karni_cli/config.py +142 -0
- karni_cli/environment.py +29 -0
- karni_cli/errorcodes.py +377 -0
- karni_cli/errordoc.py +227 -0
- karni_cli/generators/__init__.py +67 -0
- karni_cli/generators/apitest.py +206 -0
- karni_cli/generators/app.py +103 -0
- karni_cli/generators/command.py +98 -0
- karni_cli/generators/component.py +115 -0
- karni_cli/generators/core.py +319 -0
- karni_cli/generators/event.py +57 -0
- karni_cli/generators/factory.py +88 -0
- karni_cli/generators/fields.py +343 -0
- karni_cli/generators/introspect.py +263 -0
- karni_cli/generators/listener.py +168 -0
- karni_cli/generators/middleware.py +95 -0
- karni_cli/generators/migration.py +82 -0
- karni_cli/generators/model.py +235 -0
- karni_cli/generators/module.py +255 -0
- karni_cli/generators/naming.py +100 -0
- karni_cli/generators/page.py +373 -0
- karni_cli/generators/pysource.py +304 -0
- karni_cli/generators/schedule.py +174 -0
- karni_cli/generators/seeder.py +86 -0
- karni_cli/generators/serializer.py +165 -0
- karni_cli/generators/service.py +68 -0
- karni_cli/generators/task.py +84 -0
- karni_cli/generators/viewset.py +179 -0
- karni_cli/history.py +59 -0
- karni_cli/local_state.py +73 -0
- karni_cli/main.py +104 -0
- karni_cli/mcp_server.py +103 -0
- karni_cli/project.py +248 -0
- karni_cli/rules_render.py +86 -0
- karni_cli/scaffold.py +197 -0
- karni_cli/settings_edit.py +128 -0
- karni_cli/tips.py +53 -0
- karni_cli/typegen.py +101 -0
- karni_cli/upgrades/__init__.py +28 -0
- karni_cli/upgrades/changelog.py +123 -0
- karni_cli/upgrades/codemod.py +125 -0
- karni_cli/upgrades/engine.py +224 -0
- karni_cli/upgrades/legacy_agent_files.py +138 -0
- karni_cli/upgrades/python.py +670 -0
- karni_cli/upgrades/transforms/rename-export.cjs +96 -0
- karni_cli/upgrades/typescript.py +160 -0
- karni_cli/upgrades/v0_2/__init__.py +1 -0
- karni_cli/upgrades/v0_2/agent_guidance.py +269 -0
- karni_cli/upgrades/v1_0/__init__.py +5 -0
- karni_cli/upgrades/v1_0/all_objects_to_without.py +18 -0
- karni_cli/upgrades/v1_0/all_with_deleted_to_without.py +13 -0
- karni_cli/upgrades/v1_0/deleted_only_to_without_dead.py +13 -0
- karni_cli/upgrades/v1_0/object_permission_classes_to_access.py +16 -0
- karni_cli/upgrades/v1_0/related_lookups_to_optimize.py +15 -0
- karni_cli/upgrades/v1_0/scoped_related_lookups_to_optimize.py +15 -0
- karni_cli/upgrades/v1_0/soft_delete_manager_mixin_to_scoped.py +14 -0
- karni_cli-0.3.1.dist-info/METADATA +191 -0
- karni_cli-0.3.1.dist-info/RECORD +145 -0
- karni_cli-0.3.1.dist-info/WHEEL +4 -0
- karni_cli-0.3.1.dist-info/entry_points.txt +2 -0
- karni_cli-0.3.1.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""``karni seed`` — realistic, deterministic demo data (``manage.py karni_seed``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from karni_cli import project as project_mod
|
|
8
|
+
from karni_cli.project import require_project
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def seed(
|
|
12
|
+
args: list[str] | None = typer.Argument(
|
|
13
|
+
None,
|
|
14
|
+
help="Passed to manage.py karni_seed: --count N, --seed S, --reset, --model app.Model, "
|
|
15
|
+
"--json, --force.",
|
|
16
|
+
),
|
|
17
|
+
docker: bool | None = typer.Option(None, "--docker/--no-docker"),
|
|
18
|
+
service: str = typer.Option("django", "--service", envvar="KARNI_COMPOSE_SERVICE"),
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Fill every model with realistic demo data — wraps `manage.py karni_seed [ARGS]`.
|
|
21
|
+
|
|
22
|
+
Values come from field names and types (an `email` gets an email,
|
|
23
|
+
`price` money, `starts_at` a near-future date); relations point at
|
|
24
|
+
seeded rows; the same --seed gives the same rows. Prints a demo login
|
|
25
|
+
(demo@example.com / karni-password). Per-model overrides live in
|
|
26
|
+
<app>/seeds.py (`karni make seeder <Model>`).
|
|
27
|
+
|
|
28
|
+
Example: karni seed --count 20 --seed 42 --reset
|
|
29
|
+
"""
|
|
30
|
+
project = require_project()
|
|
31
|
+
proc = project_mod.run_manage(
|
|
32
|
+
project, ["karni_seed", *(args or [])], docker=docker, service=service, capture=False
|
|
33
|
+
)
|
|
34
|
+
raise typer.Exit(proc.returncode)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""``karni serve`` — start the development stack.
|
|
2
|
+
|
|
3
|
+
Default: ``docker compose up`` in the project (the scaffold's compose file
|
|
4
|
+
runs django, worker, beat, db, redis and the frontend). ``--local`` instead
|
|
5
|
+
runs ``manage.py runserver`` through the project's ``.venv`` — no Docker.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from karni_cli import project as project_mod
|
|
13
|
+
from karni_cli.project import CliError, require_project
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def serve(
|
|
17
|
+
local: bool = typer.Option(
|
|
18
|
+
False, "--local", help="Run manage.py runserver through the project .venv instead."
|
|
19
|
+
),
|
|
20
|
+
host: str | None = typer.Option(None, "--host", help="--local only. Default 127.0.0.1."),
|
|
21
|
+
port: int | None = typer.Option(None, "--port", help="--local only. Default 8000."),
|
|
22
|
+
build: bool = typer.Option(False, "--build", help="compose only: rebuild images first."),
|
|
23
|
+
detach: bool = typer.Option(False, "--detach", "-d", help="compose only: run in background."),
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Start the dev stack — wraps `docker compose up` (or `manage.py runserver` with --local)."""
|
|
26
|
+
project = require_project()
|
|
27
|
+
if local:
|
|
28
|
+
if build or detach:
|
|
29
|
+
raise CliError(
|
|
30
|
+
"--build/--detach apply to docker compose; drop them or drop --local.", code="C004"
|
|
31
|
+
)
|
|
32
|
+
if project_mod.venv_python(project) is None:
|
|
33
|
+
raise CliError(
|
|
34
|
+
"karni serve --local needs the project's .venv "
|
|
35
|
+
"(uv venv && uv pip install -r pyproject.toml).\n"
|
|
36
|
+
"Without one, drop --local to run the docker compose stack.",
|
|
37
|
+
code="C002",
|
|
38
|
+
)
|
|
39
|
+
addrport = f"{host or '127.0.0.1'}:{port or 8000}"
|
|
40
|
+
proc = project_mod.run_manage(project, ["runserver", addrport], docker=False, capture=False)
|
|
41
|
+
else:
|
|
42
|
+
if host is not None or port is not None:
|
|
43
|
+
raise CliError(
|
|
44
|
+
"--host/--port need --local; under docker compose the port is set in "
|
|
45
|
+
"docker-compose.yml (8000).",
|
|
46
|
+
code="C004",
|
|
47
|
+
)
|
|
48
|
+
args = ["up", *(["--build"] if build else []), *(["--detach"] if detach else [])]
|
|
49
|
+
proc = project_mod.run_compose(project, args)
|
|
50
|
+
raise typer.Exit(proc.returncode)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""``karni shell`` — an interactive Django shell in the project environment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from karni_cli import project as project_mod
|
|
8
|
+
from karni_cli.project import require_project
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def shell(
|
|
12
|
+
args: list[str] | None = typer.Argument(
|
|
13
|
+
None, help="Passed to manage.py shell: -c CODE, -i ipython …"
|
|
14
|
+
),
|
|
15
|
+
docker: bool | None = typer.Option(None, "--docker/--no-docker"),
|
|
16
|
+
service: str = typer.Option("django", "--service", envvar="KARNI_COMPOSE_SERVICE"),
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Open a Django shell — wraps `manage.py shell [ARGS]`."""
|
|
19
|
+
project = require_project()
|
|
20
|
+
proc = project_mod.run_manage(
|
|
21
|
+
project,
|
|
22
|
+
["shell", *(args or [])],
|
|
23
|
+
docker=docker,
|
|
24
|
+
service=service,
|
|
25
|
+
capture=False,
|
|
26
|
+
interactive=True,
|
|
27
|
+
)
|
|
28
|
+
raise typer.Exit(proc.returncode)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""``karni sync-roles`` — reconcile the role tables with ``KARNI_ROLES`` (``manage.py karni_sync_roles``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from karni_cli import project as project_mod
|
|
8
|
+
from karni_cli.project import require_project
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def sync_roles(
|
|
12
|
+
args: list[str] | None = typer.Argument(
|
|
13
|
+
None, help="Passed to manage.py karni_sync_roles: --dry-run, --json."
|
|
14
|
+
),
|
|
15
|
+
docker: bool | None = typer.Option(None, "--docker/--no-docker"),
|
|
16
|
+
service: str = typer.Option("django", "--service", envvar="KARNI_COMPOSE_SERVICE"),
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Create or update the roles KARNI_ROLES declares — wraps `manage.py karni_sync_roles [ARGS]`.
|
|
19
|
+
|
|
20
|
+
Expands each role's grant patterns against every model and action into
|
|
21
|
+
Role, Permission and RolePermission rows: creates what is missing,
|
|
22
|
+
updates what changed, reports roles the database holds that the
|
|
23
|
+
declaration no longer names, and never deletes. The same sync runs
|
|
24
|
+
after every `karni migrate` (KARNI_ROLES_AUTOSYNC).
|
|
25
|
+
|
|
26
|
+
Example: karni sync-roles --dry-run
|
|
27
|
+
"""
|
|
28
|
+
project = require_project()
|
|
29
|
+
proc = project_mod.run_manage(
|
|
30
|
+
project, ["karni_sync_roles", *(args or [])], docker=docker, service=service, capture=False
|
|
31
|
+
)
|
|
32
|
+
raise typer.Exit(proc.returncode)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""``karni types`` — OpenAPI schema → TS types + typed hooks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from karni_cli import project as project_mod
|
|
11
|
+
from karni_cli.project import CliError, require_project
|
|
12
|
+
from karni_cli.typegen import write_hooks
|
|
13
|
+
|
|
14
|
+
_FORMATS = {".yml": "openapi", ".yaml": "openapi", ".json": "openapi-json"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _openapi_typescript_cmd(project: Path, schema: Path, out: Path) -> list[str]:
|
|
18
|
+
"""Project-pinned binary first; pinned-major npx fallback (E2)."""
|
|
19
|
+
local = project / "frontend" / "node_modules" / ".bin" / "openapi-typescript"
|
|
20
|
+
if local.exists():
|
|
21
|
+
return [str(local), str(schema), "-o", str(out)]
|
|
22
|
+
return ["npx", "--yes", "openapi-typescript@^7", str(schema), "-o", str(out)]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def types(
|
|
26
|
+
schema_out: Path = typer.Option(Path("frontend/src/api/schema.yml"), "--schema-out"),
|
|
27
|
+
out: Path = typer.Option(Path("frontend/src/api"), "--out"),
|
|
28
|
+
docker: bool | None = typer.Option(None, "--docker/--no-docker"),
|
|
29
|
+
service: str = typer.Option("django", "--service", envvar="KARNI_COMPOSE_SERVICE"),
|
|
30
|
+
) -> None:
|
|
31
|
+
project = require_project()
|
|
32
|
+
fmt = _FORMATS.get(schema_out.suffix)
|
|
33
|
+
if fmt is None:
|
|
34
|
+
raise CliError(
|
|
35
|
+
f"--schema-out must end in .yml/.yaml/.json, got {schema_out.suffix}", code="C106"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
(project / schema_out).parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
proc = project_mod.run_manage(
|
|
40
|
+
project,
|
|
41
|
+
["spectacular", "--format", fmt, "--file", str(schema_out)],
|
|
42
|
+
docker=docker,
|
|
43
|
+
service=service,
|
|
44
|
+
)
|
|
45
|
+
if proc.returncode != 0:
|
|
46
|
+
typer.echo(proc.stderr or proc.stdout, err=True)
|
|
47
|
+
raise typer.Exit(proc.returncode)
|
|
48
|
+
|
|
49
|
+
types_file = project / out / "types.ts"
|
|
50
|
+
ts = subprocess.run(
|
|
51
|
+
_openapi_typescript_cmd(project, project / schema_out, types_file),
|
|
52
|
+
cwd=project,
|
|
53
|
+
text=True,
|
|
54
|
+
capture_output=True,
|
|
55
|
+
check=False,
|
|
56
|
+
)
|
|
57
|
+
if ts.returncode != 0:
|
|
58
|
+
typer.echo(ts.stderr, err=True)
|
|
59
|
+
raise CliError(
|
|
60
|
+
"openapi-typescript failed — is Node available? "
|
|
61
|
+
"Add openapi-typescript to frontend devDependencies to pin it.",
|
|
62
|
+
code="C006",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
hooks = write_hooks(project / schema_out, project / out)
|
|
66
|
+
typer.echo(f"Wrote {types_file} and {hooks}")
|
|
67
|
+
|
|
68
|
+
pkg = project / "frontend" / "package.json"
|
|
69
|
+
if pkg.is_file() and '"@karni/ui"' not in pkg.read_text():
|
|
70
|
+
typer.echo(
|
|
71
|
+
"Hint: frontend/package.json lacks @karni/ui — `npm i @karni/ui` to use hooks.ts"
|
|
72
|
+
)
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""``karni upgrade`` — move a project across Karni versions.
|
|
2
|
+
|
|
3
|
+
1. Find where the project is: the karni its interpreter has installed
|
|
4
|
+
(``--from`` overrides), and where it goes: ``--to``, default the newest
|
|
5
|
+
release in the changelog this karni-cli carries.
|
|
6
|
+
2. Select every recorded breaking change in between, in release order
|
|
7
|
+
(:func:`karni_cli.upgrades.changelog.select`).
|
|
8
|
+
3. Run their codemods over a scratch copy; ``--dry-run`` prints the diff,
|
|
9
|
+
otherwise the result is written back all or nothing. Writing needs a
|
|
10
|
+
clean git tree (``--allow-dirty`` overrides) so the change is reviewable
|
|
11
|
+
and revertible.
|
|
12
|
+
4. Report what was applied, what needs a person (with each migration note)
|
|
13
|
+
and what was skipped.
|
|
14
|
+
|
|
15
|
+
Run it before raising the karni pin. The CLI carries the changelog, so the
|
|
16
|
+
project's old karni is fine.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import subprocess
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import TYPE_CHECKING
|
|
24
|
+
|
|
25
|
+
import typer
|
|
26
|
+
|
|
27
|
+
from karni_cli import project as project_mod
|
|
28
|
+
from karni_cli.project import CliError, require_project, venv_python
|
|
29
|
+
from karni_cli.upgrades import changelog, engine
|
|
30
|
+
from karni_cli.upgrades.codemod import load_codemod
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from karni.changelog import Change
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _installed_version(project: Path, docker: bool | None, service: str) -> str:
|
|
37
|
+
code = "import karni; print(karni.__version__)"
|
|
38
|
+
mode = project_mod.resolve_execution(project, docker, service)
|
|
39
|
+
if mode == "venv":
|
|
40
|
+
cmd = [str(venv_python(project)), "-c", code]
|
|
41
|
+
else:
|
|
42
|
+
cmd = ["docker", "compose", "exec", "-T", service, "python", "-c", code]
|
|
43
|
+
proc = subprocess.run(cmd, cwd=project, text=True, capture_output=True, check=False)
|
|
44
|
+
if proc.returncode != 0:
|
|
45
|
+
raise CliError(
|
|
46
|
+
"Could not determine the installed karni version "
|
|
47
|
+
f"(exit {proc.returncode}): {proc.stderr.strip()}\n"
|
|
48
|
+
"Pass it instead: karni upgrade --from <version>",
|
|
49
|
+
code="C009",
|
|
50
|
+
)
|
|
51
|
+
return proc.stdout.strip()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _baseline(project: Path) -> str | None:
|
|
55
|
+
"""``[tool.karni] baseline`` from pyproject.toml: the release ``karni new`` generated for.
|
|
56
|
+
|
|
57
|
+
A fresh project already follows every change up to that release, whatever
|
|
58
|
+
karni version its environment happens to hold (a checkout still reports
|
|
59
|
+
the previous release's number while the next one is in development).
|
|
60
|
+
"""
|
|
61
|
+
import tomllib
|
|
62
|
+
|
|
63
|
+
pyproject = project / "pyproject.toml"
|
|
64
|
+
if not pyproject.is_file():
|
|
65
|
+
return None
|
|
66
|
+
try:
|
|
67
|
+
data = tomllib.loads(pyproject.read_text())
|
|
68
|
+
except (tomllib.TOMLDecodeError, OSError):
|
|
69
|
+
return None
|
|
70
|
+
value = data.get("tool", {}).get("karni", {}).get("baseline")
|
|
71
|
+
return str(value) if value else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _git_status(project: Path) -> list[str] | None:
|
|
75
|
+
"""Uncommitted paths under ``project`` (untracked included); None outside git."""
|
|
76
|
+
try:
|
|
77
|
+
inside = subprocess.run(
|
|
78
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
79
|
+
cwd=project,
|
|
80
|
+
text=True,
|
|
81
|
+
capture_output=True,
|
|
82
|
+
check=False,
|
|
83
|
+
)
|
|
84
|
+
except FileNotFoundError:
|
|
85
|
+
return None
|
|
86
|
+
if inside.returncode != 0 or inside.stdout.strip() != "true":
|
|
87
|
+
return None
|
|
88
|
+
status = subprocess.run(
|
|
89
|
+
["git", "status", "--porcelain", "--untracked-files=all", "--", "."],
|
|
90
|
+
cwd=project,
|
|
91
|
+
text=True,
|
|
92
|
+
capture_output=True,
|
|
93
|
+
check=False,
|
|
94
|
+
)
|
|
95
|
+
if status.returncode != 0:
|
|
96
|
+
return None
|
|
97
|
+
return [line[3:] for line in status.stdout.splitlines() if line.strip()]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _check_tree(project: Path, allow_dirty: bool) -> None:
|
|
101
|
+
dirty = _git_status(project)
|
|
102
|
+
if dirty is None:
|
|
103
|
+
why = (
|
|
104
|
+
f"{project} is not in a git work tree (or git is not installed). karni upgrade "
|
|
105
|
+
"rewrites source files in place, and without version control nothing shows "
|
|
106
|
+
"or undoes what it changed."
|
|
107
|
+
)
|
|
108
|
+
if not allow_dirty:
|
|
109
|
+
raise CliError(
|
|
110
|
+
f"Refusing to upgrade: {why}\nPreview it with --dry-run, put the project "
|
|
111
|
+
"under git, or pass --allow-dirty to write anyway.",
|
|
112
|
+
code="C201",
|
|
113
|
+
)
|
|
114
|
+
typer.echo(f"warning: {why} Continuing because of --allow-dirty.", err=True)
|
|
115
|
+
return
|
|
116
|
+
if dirty:
|
|
117
|
+
shown = ", ".join(dirty[:5]) + (f" and {len(dirty) - 5} more" if len(dirty) > 5 else "")
|
|
118
|
+
why = (
|
|
119
|
+
f"the git working tree has uncommitted changes ({shown}). karni upgrade rewrites "
|
|
120
|
+
"files in place; on a clean tree `git diff` shows exactly what it changed and "
|
|
121
|
+
"`git checkout -- .` undoes it, without mixing its edits with yours."
|
|
122
|
+
)
|
|
123
|
+
if not allow_dirty:
|
|
124
|
+
raise CliError(
|
|
125
|
+
f"Refusing to upgrade: {why}\nCommit or stash first, preview with --dry-run, "
|
|
126
|
+
"or pass --allow-dirty.",
|
|
127
|
+
code="C201",
|
|
128
|
+
)
|
|
129
|
+
typer.echo(f"warning: {why} Continuing because of --allow-dirty.", err=True)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _list(changes: list[Change]) -> str:
|
|
133
|
+
default = changelog.newest_release(changes)
|
|
134
|
+
lines = [f"Breaking changes this karni-cli knows (default --to: {default}):"]
|
|
135
|
+
release = None
|
|
136
|
+
for change in changes:
|
|
137
|
+
if change.release != release:
|
|
138
|
+
release = change.release
|
|
139
|
+
lines.append(f"\n{release}")
|
|
140
|
+
how = f"codemod {change.codemod}" if change.mechanical else "manual"
|
|
141
|
+
if not change.mechanical and change.codemod:
|
|
142
|
+
how += f", detector {change.codemod}"
|
|
143
|
+
removal = f", removed in {change.removed_in}" if change.removed_in else ""
|
|
144
|
+
arrow = f" -> {change.replacement}" if change.replacement else ""
|
|
145
|
+
lines.append(f" {change.id} [{change.kind}, {how}{removal}]")
|
|
146
|
+
lines.append(f" {change.symbol}{arrow}")
|
|
147
|
+
return "\n".join(lines)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def upgrade(
|
|
151
|
+
to: str | None = typer.Option(
|
|
152
|
+
None,
|
|
153
|
+
"--to",
|
|
154
|
+
metavar="VERSION",
|
|
155
|
+
help="Target Karni version (default: the newest release in the changelog)",
|
|
156
|
+
),
|
|
157
|
+
from_version: str | None = typer.Option(
|
|
158
|
+
None,
|
|
159
|
+
"--from",
|
|
160
|
+
metavar="VERSION",
|
|
161
|
+
help="The project's current Karni version, instead of asking its interpreter "
|
|
162
|
+
"(use it when the new karni is already installed)",
|
|
163
|
+
),
|
|
164
|
+
dry_run: bool = typer.Option(
|
|
165
|
+
False, "--dry-run", help="Print a unified diff of every change and write nothing"
|
|
166
|
+
),
|
|
167
|
+
allow_dirty: bool = typer.Option(
|
|
168
|
+
False,
|
|
169
|
+
"--allow-dirty",
|
|
170
|
+
help="Write even with uncommitted changes, or outside a git work tree",
|
|
171
|
+
),
|
|
172
|
+
list_only: bool = typer.Option(
|
|
173
|
+
False, "--list", help="List every recorded breaking change and exit"
|
|
174
|
+
),
|
|
175
|
+
docker: bool | None = typer.Option(
|
|
176
|
+
None, "--docker/--no-docker", help="Where to ask for the installed karni version"
|
|
177
|
+
),
|
|
178
|
+
service: str = typer.Option("django", "--service", envvar="KARNI_COMPOSE_SERVICE"),
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Upgrade the project's code across Karni versions: codemods plus a report."""
|
|
181
|
+
changes = changelog.load()
|
|
182
|
+
if list_only:
|
|
183
|
+
typer.echo(_list(changes))
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
project = require_project()
|
|
187
|
+
current = from_version or _installed_version(project, docker, service)
|
|
188
|
+
target = to or changelog.newest_release(changes)
|
|
189
|
+
now, then = (
|
|
190
|
+
changelog.parse(current, "The project's karni version"),
|
|
191
|
+
changelog.parse(target, "--to"),
|
|
192
|
+
)
|
|
193
|
+
baseline = _baseline(project) if from_version is None else None
|
|
194
|
+
if baseline is not None and changelog.parse(baseline, "[tool.karni] baseline") > now:
|
|
195
|
+
current, now = baseline, changelog.parse(baseline, "[tool.karni] baseline")
|
|
196
|
+
if then < now:
|
|
197
|
+
raise CliError(
|
|
198
|
+
f"--to {target} is older than the project's karni {current}; "
|
|
199
|
+
"karni upgrade does not downgrade.",
|
|
200
|
+
code="C202",
|
|
201
|
+
)
|
|
202
|
+
horizon = changelog.horizon(changes)
|
|
203
|
+
if then > horizon:
|
|
204
|
+
typer.echo(
|
|
205
|
+
f"warning: this karni-cli records changes up to {horizon}; any after that are "
|
|
206
|
+
"not applied. Upgrade karni-cli first (pipx upgrade karni-cli).",
|
|
207
|
+
err=True,
|
|
208
|
+
)
|
|
209
|
+
selected = changelog.select(changes, current, target)
|
|
210
|
+
if not selected:
|
|
211
|
+
typer.echo(f"karni {current} -> {target}: no recorded breaking changes. Nothing to do.")
|
|
212
|
+
return
|
|
213
|
+
if not dry_run:
|
|
214
|
+
_check_tree(project, allow_dirty)
|
|
215
|
+
|
|
216
|
+
mechanical = sum(1 for change in selected if change.mechanical)
|
|
217
|
+
typer.echo(
|
|
218
|
+
f"karni {current} -> {target}: {len(selected)} change(s), {mechanical} mechanical, "
|
|
219
|
+
f"{len(selected) - mechanical} manual" + (" (dry run)" if dry_run else "")
|
|
220
|
+
)
|
|
221
|
+
report = engine.run(
|
|
222
|
+
project,
|
|
223
|
+
selected,
|
|
224
|
+
current=current,
|
|
225
|
+
target=target,
|
|
226
|
+
dry_run=dry_run,
|
|
227
|
+
resolve=load_codemod,
|
|
228
|
+
)
|
|
229
|
+
if dry_run and report.changes:
|
|
230
|
+
typer.echo(report.diff, nl=False)
|
|
231
|
+
typer.echo(engine.render(report))
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""``karni worker`` — a Celery worker through the project interpreter.
|
|
2
|
+
|
|
3
|
+
With the docker compose stack up, a ``worker`` service already runs; this
|
|
4
|
+
command is for local (``.venv``) development or a one-off extra worker.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from karni_cli import project as project_mod
|
|
14
|
+
from karni_cli.project import require_project
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def celery_args(celery_app: str, role: str, loglevel: str, extra: list[str] | None) -> list[str]:
|
|
18
|
+
"""``-m celery -A <app> <role> -l <level> [extra]`` (shared with ``karni beat``)."""
|
|
19
|
+
return ["-m", "celery", "-A", celery_app, role, "-l", loglevel, *(extra or [])]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def host_pool_args(extra: list[str] | None, platform: str | None = None) -> list[str]:
|
|
23
|
+
"""``--pool=solo`` for a host worker on macOS, unless the caller chose a pool.
|
|
24
|
+
|
|
25
|
+
Celery's default prefork pool cannot run tasks on macOS (every task fails
|
|
26
|
+
in ``fast_trace_task`` with "not enough values to unpack"); the solo pool
|
|
27
|
+
does, and a development worker needs nothing more. Docker runs Linux and
|
|
28
|
+
keeps the default.
|
|
29
|
+
"""
|
|
30
|
+
args = list(extra or [])
|
|
31
|
+
platform = platform or sys.platform
|
|
32
|
+
if platform != "darwin" or any(a == "-P" or a.startswith(("--pool", "-P")) for a in args):
|
|
33
|
+
return args
|
|
34
|
+
return [*args, "--pool=solo"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def worker(
|
|
38
|
+
args: list[str] | None = typer.Argument(
|
|
39
|
+
None, help="Passed to celery worker: -Q QUEUES, --concurrency N …"
|
|
40
|
+
),
|
|
41
|
+
celery_app: str = typer.Option(
|
|
42
|
+
"config", "--app", "-A", envvar="KARNI_CELERY_APP", help="Celery app module."
|
|
43
|
+
),
|
|
44
|
+
loglevel: str = typer.Option("info", "--loglevel", "-l"),
|
|
45
|
+
docker: bool | None = typer.Option(None, "--docker/--no-docker"),
|
|
46
|
+
service: str = typer.Option("django", "--service", envvar="KARNI_COMPOSE_SERVICE"),
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Run a Celery worker — wraps `python -m celery -A config worker -l info [ARGS]`.
|
|
49
|
+
|
|
50
|
+
A host worker on macOS gets `--pool=solo` (the prefork pool cannot run tasks there).
|
|
51
|
+
"""
|
|
52
|
+
project = require_project()
|
|
53
|
+
on_host = docker is not True and project_mod.venv_python(project) is not None
|
|
54
|
+
extra = host_pool_args(args) if on_host else list(args or [])
|
|
55
|
+
proc = project_mod.run_python(
|
|
56
|
+
project,
|
|
57
|
+
celery_args(celery_app, "worker", loglevel, extra),
|
|
58
|
+
docker=docker,
|
|
59
|
+
service=service,
|
|
60
|
+
capture=False,
|
|
61
|
+
interactive=True,
|
|
62
|
+
)
|
|
63
|
+
raise typer.Exit(proc.returncode)
|
karni_cli/config.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Karni CLI settings: ``karni config get|set|list``.
|
|
2
|
+
|
|
3
|
+
Two TOML files, flat ``key = value``:
|
|
4
|
+
|
|
5
|
+
* project — ``<project>/.karni/config.toml``, committed: applies to the team;
|
|
6
|
+
* global — ``$XDG_CONFIG_HOME/karni/config.toml`` (default ``~/.config``):
|
|
7
|
+
applies to every project on this machine.
|
|
8
|
+
|
|
9
|
+
The project file wins over the global one, which wins over the default.
|
|
10
|
+
Keys are validated against :data:`KEYS`; adding a setting is one entry there.
|
|
11
|
+
Unknown keys in a file are ignored (a newer karni may have written them).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import tomllib
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import tomlkit
|
|
23
|
+
|
|
24
|
+
from karni_cli.project import CliError
|
|
25
|
+
|
|
26
|
+
PROJECT_FILE = Path(".karni") / "config.toml"
|
|
27
|
+
|
|
28
|
+
_TRUE = frozenset({"true", "yes", "on", "1"})
|
|
29
|
+
_FALSE = frozenset({"false", "no", "off", "0"})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class ConfigKey:
|
|
34
|
+
name: str
|
|
35
|
+
default: bool
|
|
36
|
+
help: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
#: Every setting karni reads. Add a setting here; nothing else needs editing.
|
|
40
|
+
KEYS: dict[str, ConfigKey] = {
|
|
41
|
+
key.name: key
|
|
42
|
+
for key in (
|
|
43
|
+
ConfigKey(
|
|
44
|
+
"suggest_tools",
|
|
45
|
+
True,
|
|
46
|
+
"One-time tips about optional tools (karni doctor, karni check outside CI).",
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Value:
|
|
54
|
+
key: str
|
|
55
|
+
value: bool
|
|
56
|
+
#: "project", "global" or "default".
|
|
57
|
+
source: str
|
|
58
|
+
path: Path | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def global_path() -> Path:
|
|
62
|
+
base = os.environ.get("XDG_CONFIG_HOME", "").strip()
|
|
63
|
+
root = Path(base) if base else Path.home() / ".config"
|
|
64
|
+
return root / "karni" / "config.toml"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def project_path(project: Path) -> Path:
|
|
68
|
+
return project / PROJECT_FILE
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def key(name: str) -> ConfigKey:
|
|
72
|
+
found = KEYS.get(name)
|
|
73
|
+
if found is None:
|
|
74
|
+
raise CliError(f"Unknown setting {name!r} (known: {', '.join(sorted(KEYS))}).", code="C007")
|
|
75
|
+
return found
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse(name: str, raw: str) -> bool:
|
|
79
|
+
key(name)
|
|
80
|
+
text = raw.strip().lower()
|
|
81
|
+
if text in _TRUE:
|
|
82
|
+
return True
|
|
83
|
+
if text in _FALSE:
|
|
84
|
+
return False
|
|
85
|
+
raise CliError(f"{name} is a boolean: use true or false, got {raw!r}.", code="C007")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def render(value: bool) -> str:
|
|
89
|
+
return "true" if value else "false"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def read_file(path: Path) -> dict[str, bool]:
|
|
93
|
+
"""The known settings in ``path`` (missing file = none); bad values name the file."""
|
|
94
|
+
if not path.is_file():
|
|
95
|
+
return {}
|
|
96
|
+
try:
|
|
97
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
98
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
99
|
+
raise CliError(f"{path}: not valid TOML ({exc}).", code="C007") from None
|
|
100
|
+
found: dict[str, bool] = {}
|
|
101
|
+
for name in KEYS:
|
|
102
|
+
if name not in data:
|
|
103
|
+
continue
|
|
104
|
+
if not isinstance(data[name], bool):
|
|
105
|
+
raise CliError(
|
|
106
|
+
f"{path}: {name} must be true or false, got {data[name]!r}.", code="C007"
|
|
107
|
+
)
|
|
108
|
+
found[name] = data[name]
|
|
109
|
+
return found
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def resolve(name: str, project: Path | None, *, global_only: bool = False) -> Value:
|
|
113
|
+
"""The effective value of ``name``: project file, then global file, then default."""
|
|
114
|
+
spec = key(name)
|
|
115
|
+
scopes: list[tuple[str, Path]] = []
|
|
116
|
+
if project is not None and not global_only:
|
|
117
|
+
scopes.append(("project", project_path(project)))
|
|
118
|
+
scopes.append(("global", global_path()))
|
|
119
|
+
for source, path in scopes:
|
|
120
|
+
values = read_file(path)
|
|
121
|
+
if name in values:
|
|
122
|
+
return Value(name, values[name], source, path)
|
|
123
|
+
return Value(name, spec.default, "default")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def write(name: str, value: bool, path: Path) -> None:
|
|
127
|
+
"""Set ``name`` in ``path``, keeping the file's other content and comments."""
|
|
128
|
+
key(name)
|
|
129
|
+
try:
|
|
130
|
+
document: Any = (
|
|
131
|
+
tomlkit.parse(path.read_text(encoding="utf-8"))
|
|
132
|
+
if path.is_file()
|
|
133
|
+
else tomlkit.document()
|
|
134
|
+
)
|
|
135
|
+
except Exception as exc: # noqa: BLE001 - tomlkit raises its own parse errors
|
|
136
|
+
raise CliError(f"{path}: not valid TOML ({exc}).", code="C007") from None
|
|
137
|
+
document[name] = value
|
|
138
|
+
try:
|
|
139
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
path.write_text(tomlkit.dumps(document), encoding="utf-8")
|
|
141
|
+
except OSError as exc:
|
|
142
|
+
raise CliError(f"Cannot write {path}: {exc}", code="C008") from None
|
karni_cli/environment.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Facts about where the CLI is running."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
|
|
8
|
+
#: ``CI`` is the de-facto convention; the rest are set by CI systems that do
|
|
9
|
+
#: not always export it (GitHub Actions, GitLab, Buildkite, CircleCI, Jenkins,
|
|
10
|
+
#: Azure Pipelines, TeamCity, Bitbucket Pipelines, AWS CodeBuild).
|
|
11
|
+
CI_ENV_VARS: tuple[str, ...] = (
|
|
12
|
+
"CI",
|
|
13
|
+
"GITHUB_ACTIONS",
|
|
14
|
+
"GITLAB_CI",
|
|
15
|
+
"BUILDKITE",
|
|
16
|
+
"CIRCLECI",
|
|
17
|
+
"JENKINS_URL",
|
|
18
|
+
"TF_BUILD",
|
|
19
|
+
"TEAMCITY_VERSION",
|
|
20
|
+
"BITBUCKET_BUILD_NUMBER",
|
|
21
|
+
"CODEBUILD_BUILD_ID",
|
|
22
|
+
)
|
|
23
|
+
_FALSY = frozenset({"", "0", "false", "no", "off"})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_ci(environ: Mapping[str, str] | None = None) -> bool:
|
|
27
|
+
"""True when any CI variable is set to something other than an explicit false."""
|
|
28
|
+
env = os.environ if environ is None else environ
|
|
29
|
+
return any(env.get(name, "").strip().lower() not in _FALSY for name in CI_ENV_VARS)
|