karni-cli 0.3.1__py3-none-any.whl

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