karni-cli 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. karni_cli/__init__.py +3 -0
  2. karni_cli/_agent/__init__.py +82 -0
  3. karni_cli/_agent/__main__.py +44 -0
  4. karni_cli/_agent/check.py +658 -0
  5. karni_cli/_agent/history.py +328 -0
  6. karni_cli/_agent/history_chronicle.py +246 -0
  7. karni_cli/_agent/overrides.py +194 -0
  8. karni_cli/_agent/rules.py +673 -0
  9. karni_cli/_changelog/0.2.0.toml +304 -0
  10. karni_cli/_changelog/0.3.0.toml +25 -0
  11. karni_cli/_changelog/__init__.py +248 -0
  12. karni_cli/_errorcodes/__init__.py +1171 -0
  13. karni_cli/_errorcodes/__main__.py +34 -0
  14. karni_cli/_errorcodes/links.py +217 -0
  15. karni_cli/_templates/project/cookiecutter.json +8 -0
  16. karni_cli/_templates/project/{{cookiecutter.project_slug}}/.dockerignore +7 -0
  17. karni_cli/_templates/project/{{cookiecutter.project_slug}}/.env.example +85 -0
  18. karni_cli/_templates/project/{{cookiecutter.project_slug}}/.gitignore +10 -0
  19. karni_cli/_templates/project/{{cookiecutter.project_slug}}/Caddyfile +22 -0
  20. karni_cli/_templates/project/{{cookiecutter.project_slug}}/Dockerfile +53 -0
  21. karni_cli/_templates/project/{{cookiecutter.project_slug}}/Makefile +52 -0
  22. karni_cli/_templates/project/{{cookiecutter.project_slug}}/README.md +169 -0
  23. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/__init__.py +16 -0
  24. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/asgi.py +14 -0
  25. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/celery.py +17 -0
  26. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/settings.py +28 -0
  27. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/urls.py +31 -0
  28. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/wsgi.py +14 -0
  29. karni_cli/_templates/project/{{cookiecutter.project_slug}}/docker-compose.yml +126 -0
  30. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.dockerignore +2 -0
  31. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.jscpd.json +9 -0
  32. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.npmrc +7 -0
  33. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/Dockerfile.dev +17 -0
  34. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/eslint.config.js +32 -0
  35. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/index.html +14 -0
  36. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/package.json +43 -0
  37. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/postcss.config.js +6 -0
  38. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/public/favicon.svg +6 -0
  39. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/App.tsx +18 -0
  40. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/api/health.ts +21 -0
  41. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/index.css +11 -0
  42. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/layout.tsx +51 -0
  43. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/main.tsx +11 -0
  44. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/pages/home.tsx +28 -0
  45. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/routes.tsx +24 -0
  46. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/vite-env.d.ts +1 -0
  47. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tailwind.config.ts +13 -0
  48. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tsconfig.json +19 -0
  49. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/vite.config.ts +18 -0
  50. karni_cli/_templates/project/{{cookiecutter.project_slug}}/manage.py +23 -0
  51. karni_cli/_templates/project/{{cookiecutter.project_slug}}/pyproject.toml +31 -0
  52. karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/__init__.py +0 -0
  53. karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_api_schema.py +17 -0
  54. karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_smoke.py +11 -0
  55. karni_cli/adapters.py +373 -0
  56. karni_cli/agent_adapters.toml +160 -0
  57. karni_cli/agent_data.py +260 -0
  58. karni_cli/brand.py +55 -0
  59. karni_cli/clidoc.py +254 -0
  60. karni_cli/commands/__init__.py +0 -0
  61. karni_cli/commands/add.py +144 -0
  62. karni_cli/commands/beat.py +36 -0
  63. karni_cli/commands/check.py +101 -0
  64. karni_cli/commands/config.py +81 -0
  65. karni_cli/commands/context.py +171 -0
  66. karni_cli/commands/createsuperuser.py +28 -0
  67. karni_cli/commands/doctor.py +136 -0
  68. karni_cli/commands/explain.py +60 -0
  69. karni_cli/commands/make.py +55 -0
  70. karni_cli/commands/manage_passthrough.py +28 -0
  71. karni_cli/commands/mcp.py +20 -0
  72. karni_cli/commands/migrate.py +23 -0
  73. karni_cli/commands/new.py +106 -0
  74. karni_cli/commands/routes.py +20 -0
  75. karni_cli/commands/rules.py +139 -0
  76. karni_cli/commands/run_tests.py +38 -0
  77. karni_cli/commands/schedule.py +40 -0
  78. karni_cli/commands/seed.py +34 -0
  79. karni_cli/commands/serve.py +50 -0
  80. karni_cli/commands/shell.py +28 -0
  81. karni_cli/commands/sync_roles.py +32 -0
  82. karni_cli/commands/types.py +72 -0
  83. karni_cli/commands/upgrade.py +231 -0
  84. karni_cli/commands/worker.py +63 -0
  85. karni_cli/config.py +142 -0
  86. karni_cli/environment.py +29 -0
  87. karni_cli/errorcodes.py +377 -0
  88. karni_cli/errordoc.py +227 -0
  89. karni_cli/generators/__init__.py +67 -0
  90. karni_cli/generators/apitest.py +206 -0
  91. karni_cli/generators/app.py +103 -0
  92. karni_cli/generators/command.py +98 -0
  93. karni_cli/generators/component.py +115 -0
  94. karni_cli/generators/core.py +319 -0
  95. karni_cli/generators/event.py +57 -0
  96. karni_cli/generators/factory.py +88 -0
  97. karni_cli/generators/fields.py +343 -0
  98. karni_cli/generators/introspect.py +263 -0
  99. karni_cli/generators/listener.py +168 -0
  100. karni_cli/generators/middleware.py +95 -0
  101. karni_cli/generators/migration.py +82 -0
  102. karni_cli/generators/model.py +235 -0
  103. karni_cli/generators/module.py +255 -0
  104. karni_cli/generators/naming.py +100 -0
  105. karni_cli/generators/page.py +373 -0
  106. karni_cli/generators/pysource.py +304 -0
  107. karni_cli/generators/schedule.py +174 -0
  108. karni_cli/generators/seeder.py +86 -0
  109. karni_cli/generators/serializer.py +165 -0
  110. karni_cli/generators/service.py +68 -0
  111. karni_cli/generators/task.py +84 -0
  112. karni_cli/generators/viewset.py +179 -0
  113. karni_cli/history.py +59 -0
  114. karni_cli/local_state.py +73 -0
  115. karni_cli/main.py +104 -0
  116. karni_cli/mcp_server.py +103 -0
  117. karni_cli/project.py +248 -0
  118. karni_cli/rules_render.py +86 -0
  119. karni_cli/scaffold.py +197 -0
  120. karni_cli/settings_edit.py +128 -0
  121. karni_cli/tips.py +53 -0
  122. karni_cli/typegen.py +101 -0
  123. karni_cli/upgrades/__init__.py +28 -0
  124. karni_cli/upgrades/changelog.py +123 -0
  125. karni_cli/upgrades/codemod.py +125 -0
  126. karni_cli/upgrades/engine.py +224 -0
  127. karni_cli/upgrades/legacy_agent_files.py +138 -0
  128. karni_cli/upgrades/python.py +670 -0
  129. karni_cli/upgrades/transforms/rename-export.cjs +96 -0
  130. karni_cli/upgrades/typescript.py +160 -0
  131. karni_cli/upgrades/v0_2/__init__.py +1 -0
  132. karni_cli/upgrades/v0_2/agent_guidance.py +269 -0
  133. karni_cli/upgrades/v1_0/__init__.py +5 -0
  134. karni_cli/upgrades/v1_0/all_objects_to_without.py +18 -0
  135. karni_cli/upgrades/v1_0/all_with_deleted_to_without.py +13 -0
  136. karni_cli/upgrades/v1_0/deleted_only_to_without_dead.py +13 -0
  137. karni_cli/upgrades/v1_0/object_permission_classes_to_access.py +16 -0
  138. karni_cli/upgrades/v1_0/related_lookups_to_optimize.py +15 -0
  139. karni_cli/upgrades/v1_0/scoped_related_lookups_to_optimize.py +15 -0
  140. karni_cli/upgrades/v1_0/soft_delete_manager_mixin_to_scoped.py +14 -0
  141. karni_cli-0.3.1.dist-info/METADATA +191 -0
  142. karni_cli-0.3.1.dist-info/RECORD +145 -0
  143. karni_cli-0.3.1.dist-info/WHEEL +4 -0
  144. karni_cli-0.3.1.dist-info/entry_points.txt +2 -0
  145. karni_cli-0.3.1.dist-info/licenses/LICENSE +201 -0
karni_cli/project.py ADDED
@@ -0,0 +1,248 @@
1
+ """Project discovery and manage.py execution.
2
+
3
+ The CLI never imports Django. Everything project-side runs through the
4
+ project's own interpreter, resolved in this order (per command flags):
5
+ explicit ``--docker/--no-docker`` > ``<project>/.venv`` > a running docker
6
+ compose service > a two-line error naming both remedies.
7
+
8
+ Interactive runners (shell, test, worker, ...) pass ``interactive=True``:
9
+ in docker mode that allocates a TTY (``docker compose exec`` without
10
+ ``-T``) when the CLI itself runs in a terminal, so prompts, colours and
11
+ Ctrl-C work; scripted and agent runs (no TTY) keep ``-T``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import contextlib
17
+ import importlib.util
18
+ import os
19
+ import signal
20
+ import subprocess
21
+ import sys
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ #: Typer ``context_settings`` for runners that pass unknown options through
26
+ #: to the wrapped command (``karni test -x -k name``). Put ``--`` before
27
+ #: arguments that clash with the runner's own options.
28
+ PASSTHROUGH: dict[str, Any] = {"ignore_unknown_options": True, "allow_extra_args": True}
29
+
30
+
31
+ class CliError(Exception):
32
+ """User-facing CLI failure; the message is printed without a traceback.
33
+
34
+ ``code`` is a registered code (``C101``, or a Karni code such as ``E704``
35
+ passed through); ``str()`` is ``"<code>: <message> See <docs url>"`` and
36
+ ``karni explain <code>`` prints the full entry offline.
37
+ """
38
+
39
+ def __init__(self, message: str, *, code: str | None = None) -> None:
40
+ super().__init__(message)
41
+ self.message = message
42
+ self.code = code
43
+
44
+ def __str__(self) -> str:
45
+ if not self.code:
46
+ return self.message
47
+ from karni_cli.errorcodes import docs_url
48
+
49
+ sep = "\n" if "\n" in self.message else " "
50
+ return f"{self.code}: {self.message}{sep}See {docs_url(self.code)}"
51
+
52
+
53
+ def find_project(start: Path | None = None) -> Path | None:
54
+ """Walk up from ``start`` (default cwd) to the directory holding manage.py."""
55
+ current = (start or Path.cwd()).resolve()
56
+ for candidate in (current, *current.parents):
57
+ if (candidate / "manage.py").is_file():
58
+ return candidate
59
+ return None
60
+
61
+
62
+ def require_project(start: Path | None = None) -> Path:
63
+ project = find_project(start)
64
+ if project is None:
65
+ raise CliError(
66
+ "No manage.py found here or above — run inside a Karni project.", code="C001"
67
+ )
68
+ return project
69
+
70
+
71
+ def venv_python(project: Path) -> Path | None:
72
+ """The project's interpreter: its ``.venv``, else the active virtualenv, else this one.
73
+
74
+ ``VIRTUAL_ENV`` covers the host setup where one activated venv holds
75
+ karni-cli and the project's dependencies (the quickstart). The interpreter
76
+ running karni-cli counts when it has Django and karni installed — the
77
+ quickstart's venv invoked by path, an MCP host that inherited no
78
+ ``VIRTUAL_ENV``. A pipx-installed ``karni`` has neither and keeps looking
79
+ for the project's own.
80
+ """
81
+ python = project / ".venv" / "bin" / "python"
82
+ if python.exists():
83
+ return python
84
+ active = os.environ.get("VIRTUAL_ENV")
85
+ if active:
86
+ candidate = Path(active) / "bin" / "python"
87
+ if candidate.exists():
88
+ return candidate
89
+ return _running_interpreter()
90
+
91
+
92
+ def _running_interpreter() -> Path | None:
93
+ """``sys.executable`` when it can import Django and karni (never imported here)."""
94
+ if importlib.util.find_spec("django") is None or importlib.util.find_spec("karni") is None:
95
+ return None
96
+ return Path(sys.executable)
97
+
98
+
99
+ def compose_service_running(project: Path, service: str) -> bool:
100
+ """True when ``docker compose ps`` reports the service as running."""
101
+ try:
102
+ proc = subprocess.run(
103
+ ["docker", "compose", "ps", "--status", "running", "--services"],
104
+ cwd=project,
105
+ text=True,
106
+ capture_output=True,
107
+ check=False,
108
+ )
109
+ except FileNotFoundError:
110
+ return False
111
+ if proc.returncode != 0:
112
+ return False
113
+ return service in proc.stdout.split()
114
+
115
+
116
+ def resolve_execution(project: Path, docker: bool | None, service: str) -> str:
117
+ """Pick "venv" or "docker" per the documented resolution order."""
118
+ if docker is True:
119
+ if not compose_service_running(project, service):
120
+ raise CliError(
121
+ f'docker compose service "{service}" is not running.\n'
122
+ "Start the stack first: docker compose up -d",
123
+ code="C002",
124
+ )
125
+ return "docker"
126
+ if docker is False:
127
+ if venv_python(project) is None:
128
+ raise CliError(
129
+ "No .venv in the project and no active virtualenv (and --no-docker was given).\n"
130
+ "Create one (python -m venv .venv && .venv/bin/pip install -e .), activate one "
131
+ "that has the project's dependencies, or drop --no-docker.",
132
+ code="C002",
133
+ )
134
+ return "venv"
135
+ if venv_python(project) is not None:
136
+ return "venv"
137
+ if compose_service_running(project, service):
138
+ return "docker"
139
+ raise CliError(
140
+ "No way to run the project: no .venv and the compose stack is not running.\n"
141
+ "Either `docker compose up -d`, or create a venv "
142
+ "(uv venv && uv pip install -r pyproject.toml).",
143
+ code="C002",
144
+ )
145
+
146
+
147
+ def _tty() -> bool:
148
+ """True when the CLI runs in a terminal (both stdin and stdout)."""
149
+ return sys.stdin.isatty() and sys.stdout.isatty()
150
+
151
+
152
+ def python_command(
153
+ project: Path,
154
+ args: list[str],
155
+ *,
156
+ docker: bool | None = None,
157
+ service: str = "django",
158
+ interactive: bool = False,
159
+ ) -> list[str]:
160
+ """The argv running ``python <args>`` in the resolved environment."""
161
+ mode = resolve_execution(project, docker, service)
162
+ if mode == "venv":
163
+ return [str(venv_python(project)), *args]
164
+ no_tty = [] if interactive and _tty() else ["-T"]
165
+ return ["docker", "compose", "exec", *no_tty, service, "python", *args]
166
+
167
+
168
+ def run_python(
169
+ project: Path,
170
+ args: list[str],
171
+ *,
172
+ docker: bool | None = None,
173
+ service: str = "django",
174
+ capture: bool = True,
175
+ interactive: bool = False,
176
+ ) -> subprocess.CompletedProcess[str]:
177
+ """Run ``python <args>`` through the project interpreter, cwd pinned to the project.
178
+
179
+ Returns the CompletedProcess (text mode, never raises on non-zero exit).
180
+ With ``capture=False`` output streams through to the terminal (stdin is
181
+ always inherited); ``interactive=True`` also asks docker for a TTY.
182
+ """
183
+ cmd = python_command(project, args, docker=docker, service=service, interactive=interactive)
184
+ return run_process(cmd, cwd=project, capture=capture)
185
+
186
+
187
+ def run_process(
188
+ cmd: list[str], *, cwd: Path, capture: bool = False
189
+ ) -> subprocess.CompletedProcess[str]:
190
+ """``subprocess.run`` that passes SIGTERM on to the child.
191
+
192
+ ``karni serve`` and ``karni worker`` wrap a real server: a script or an
193
+ agent that stops the wrapper must stop the server too, not leak it.
194
+ Ctrl-C needs no forwarding (the terminal signals the whole group).
195
+ """
196
+ pipe = subprocess.PIPE if capture else None
197
+ with subprocess.Popen(cmd, cwd=cwd, text=True, stdout=pipe, stderr=pipe) as proc:
198
+
199
+ def forward(signum: int, _frame: Any) -> None:
200
+ with contextlib.suppress(ProcessLookupError):
201
+ proc.send_signal(signum)
202
+
203
+ previous: Any = None
204
+ try:
205
+ previous = signal.signal(signal.SIGTERM, forward)
206
+ except ValueError: # not the main thread: no forwarding, same run
207
+ previous = None
208
+ try:
209
+ out, err = proc.communicate()
210
+ except KeyboardInterrupt:
211
+ proc.wait()
212
+ raise
213
+ finally:
214
+ if previous is not None:
215
+ signal.signal(signal.SIGTERM, previous)
216
+ return subprocess.CompletedProcess(cmd, proc.returncode, out, err)
217
+
218
+
219
+ def run_manage(
220
+ project: Path,
221
+ args: list[str],
222
+ *,
223
+ docker: bool | None = None,
224
+ service: str = "django",
225
+ capture: bool = True,
226
+ interactive: bool = False,
227
+ ) -> subprocess.CompletedProcess[str]:
228
+ """Run ``manage.py <args>`` in the resolved environment (see :func:`run_python`)."""
229
+ return run_python(
230
+ project,
231
+ ["manage.py", *args],
232
+ docker=docker,
233
+ service=service,
234
+ capture=capture,
235
+ interactive=interactive,
236
+ )
237
+
238
+
239
+ def run_compose(project: Path, args: list[str]) -> subprocess.CompletedProcess[str]:
240
+ """Run ``docker compose <args>`` in the project, streaming to the terminal."""
241
+ try:
242
+ return run_process(["docker", "compose", *args], cwd=project)
243
+ except FileNotFoundError:
244
+ raise CliError(
245
+ "docker not found — install Docker with the compose plugin, "
246
+ "or run without it: karni serve --local",
247
+ code="C003",
248
+ ) from None
@@ -0,0 +1,86 @@
1
+ """Text renderings of the agent data for terminals, agents and docs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from karni_cli.agent_data import AgentData
8
+
9
+ PROMPTS_START = "<!-- karni:procedures:start (generated: `karni rules --prompts-doc`) -->"
10
+ PROMPTS_END = "<!-- karni:procedures:end -->"
11
+
12
+
13
+ def render_rules(data: AgentData, fragments: dict[str, str] | None = None) -> str:
14
+ """The ``karni rules`` listing: every rule's code + summary, grouped by category.
15
+
16
+ Kept under 400 tokens for Karni's own rules; project rules and module
17
+ guidance add to it (they are project-specific and needed to start work).
18
+ """
19
+ lines = ["Karni rules. ! = build-enforced."]
20
+ for category in data.categories:
21
+ lines.append(f"[{category}]")
22
+ for rule in data.rules:
23
+ if rule["category"] == category:
24
+ mark = "!" if rule["severity"] == "error" else ""
25
+ lines.append(f"{rule['code']}{mark} {rule['summary']}")
26
+ for name, fragment in (fragments or {}).items():
27
+ lines.append(f"[module:{name}]")
28
+ lines.append(fragment.strip())
29
+ lines.append("Why/fix: karni explain CODE")
30
+ return "\n".join(lines)
31
+
32
+
33
+ def render_explain(rule: dict[str, Any]) -> str:
34
+ lines = [f"{rule['code']} [{rule['category']}, {rule['severity']}] {rule['rule']}"]
35
+ if rule.get("rationale"):
36
+ lines.append(f"Why: {rule['rationale']}")
37
+ if rule.get("fix"):
38
+ lines.append(f"Fix: {rule['fix']}")
39
+ if rule.get("enforced_by"):
40
+ lines.append("Enforced by: " + ", ".join(rule["enforced_by"]))
41
+ return "\n".join(lines)
42
+
43
+
44
+ def render_procedure(procedure: dict[str, Any], arguments: str = "<arguments>") -> str:
45
+ lines = [procedure["intro"].replace("{arguments}", arguments), ""]
46
+ lines += [f"{i}. {step}" for i, step in enumerate(procedure["steps"], start=1)]
47
+ if procedure.get("outro"):
48
+ lines += ["", procedure["outro"]]
49
+ if procedure.get("rules"):
50
+ lines += ["", "Rules that apply: " + ", ".join(procedure["rules"])]
51
+ return "\n".join(lines)
52
+
53
+
54
+ def render_findings(findings: list[dict[str, Any]]) -> str:
55
+ lines = []
56
+ for f in findings:
57
+ lines.append(f"{f['path']}:{f['line']}: {f['code']} [{f['rule_id']}] {f['message']}")
58
+ if f.get("fix"):
59
+ lines.append(f" fix: {f['fix']}")
60
+ return "\n".join(lines)
61
+
62
+
63
+ def render_prompts_doc(data: AgentData) -> str:
64
+ """Plain-language equivalents of the slash commands (docs/agents.md block)."""
65
+ lines = [PROMPTS_START, ""]
66
+ for procedure in data.procedures:
67
+ name = procedure["name"]
68
+ lines += [
69
+ f"### `{name}` — {procedure['description']}",
70
+ "",
71
+ "Prompt for tools without slash commands (replace the placeholder):",
72
+ "",
73
+ (
74
+ f"> Run `karni rules --procedure {name}` and follow every step for: "
75
+ f"`{procedure['argument_hint']}`"
76
+ ),
77
+ "",
78
+ "What `karni rules --procedure` returns:",
79
+ "",
80
+ "```text",
81
+ render_procedure(procedure, procedure["argument_hint"]),
82
+ "```",
83
+ "",
84
+ ]
85
+ lines.append(PROMPTS_END)
86
+ return "\n".join(lines)
karni_cli/scaffold.py ADDED
@@ -0,0 +1,197 @@
1
+ """Template, @karni/ui source resolution and slug rules for ``karni new``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import posixpath
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ from importlib import resources
12
+ from pathlib import Path
13
+
14
+ from karni_cli.project import CliError
15
+
16
+ SLUG_RE = re.compile(r"^[a-z_][a-z0-9_]*$")
17
+
18
+ UI_PACKAGE = "@karni/ui"
19
+ #: The npm spec a project gets when no local @karni/ui is found.
20
+ UI_REGISTRY_SPEC = "^0.3.0"
21
+ #: What a ``file:`` consumer needs: package.json ``exports`` point into dist/.
22
+ UI_DIST_FILES = ("dist/index.js", "dist/styles.css", "dist/tailwind-preset.js")
23
+ #: Sources the dist is built from (staleness check).
24
+ UI_BUILD_INPUTS = ("src", "tailwind-preset.ts")
25
+ NPM_TIMEOUT = 900
26
+
27
+
28
+ def derive_slug(project_name: str) -> str:
29
+ """Mirror cookiecutter.json's project_slug derivation exactly."""
30
+ return project_name.lower().replace(" ", "_").replace("-", "_")
31
+
32
+
33
+ def validate_name(project_name: str) -> str:
34
+ slug = derive_slug(project_name)
35
+ if not SLUG_RE.match(slug):
36
+ raise CliError(
37
+ f'"{project_name}" derives the invalid slug "{slug}" '
38
+ "(allowed: lowercase letters, digits, underscores; must not start with a digit).",
39
+ code="C103",
40
+ )
41
+ return slug
42
+
43
+
44
+ def _is_karni_template(candidate: Path) -> bool:
45
+ """The repo-walk must verify the directory is Karni's own template (E7)."""
46
+ cc = candidate / "cookiecutter.json"
47
+ if not cc.is_file():
48
+ return False
49
+ try:
50
+ keys = json.loads(cc.read_text())
51
+ except (OSError, json.JSONDecodeError):
52
+ return False
53
+ repo_root = candidate.parent.parent
54
+ return "project_slug" in keys and (repo_root / "packages" / "karni").is_dir()
55
+
56
+
57
+ def resolve_template() -> Path:
58
+ """KARNI_TEMPLATES env > verified monorepo checkout > packaged copy."""
59
+ env = os.environ.get("KARNI_TEMPLATES")
60
+ if env:
61
+ path = Path(env).expanduser()
62
+ if not (path / "cookiecutter.json").is_file():
63
+ raise CliError(
64
+ f"KARNI_TEMPLATES={env} does not contain a cookiecutter template.", code="C007"
65
+ )
66
+ return path
67
+
68
+ for parent in Path(__file__).resolve().parents:
69
+ candidate = parent / "templates" / "project"
70
+ if candidate.is_dir() and _is_karni_template(candidate):
71
+ return candidate
72
+
73
+ packaged = resources.files("karni_cli") / "_templates" / "project"
74
+ with resources.as_file(packaged) as path:
75
+ if path.is_dir():
76
+ return path
77
+ raise CliError(
78
+ "No project template found (no KARNI_TEMPLATES, no monorepo checkout, "
79
+ "no packaged copy) — reinstall karni-cli.",
80
+ code="C005",
81
+ )
82
+
83
+
84
+ # @karni/ui source
85
+
86
+
87
+ def _is_karni_ui(candidate: Path) -> bool:
88
+ """A directory is Karni's UI package when its package.json names @karni/ui."""
89
+ try:
90
+ meta = json.loads((candidate / "package.json").read_text())
91
+ except (OSError, json.JSONDecodeError):
92
+ return False
93
+ return isinstance(meta, dict) and meta.get("name") == UI_PACKAGE
94
+
95
+
96
+ def resolve_ui_source(start: Path | None = None) -> Path | None:
97
+ """KARNI_UI_SRC env > verified monorepo checkout > ``None`` (the npm registry).
98
+
99
+ Mirrors :func:`resolve_template`: the repo-walk only accepts
100
+ ``packages/karni-ui`` when it really is @karni/ui AND sits next to
101
+ ``packages/karni`` — never some unrelated directory of the same name.
102
+ """
103
+ env = os.environ.get("KARNI_UI_SRC")
104
+ if env:
105
+ path = Path(env).expanduser().resolve()
106
+ if not _is_karni_ui(path):
107
+ raise CliError(
108
+ f"KARNI_UI_SRC={env} is not the {UI_PACKAGE} package "
109
+ "(expected a directory whose package.json is named @karni/ui, "
110
+ "e.g. /path/to/Karni/packages/karni-ui).",
111
+ code="C007",
112
+ )
113
+ return path
114
+ for parent in (start or Path(__file__).resolve()).parents:
115
+ candidate = parent / "packages" / "karni-ui"
116
+ if _is_karni_ui(candidate) and (parent / "packages" / "karni").is_dir():
117
+ return candidate
118
+ return None
119
+
120
+
121
+ def ui_spec(source: Path | None, project: Path | None = None) -> str:
122
+ """The ``@karni/ui`` dependency spec written into frontend/package.json.
123
+
124
+ A local checkout is a ``file:`` path. When the project lives inside that
125
+ checkout (``examples/``), the path is relative to ``frontend/`` so the
126
+ project installs on any machine; elsewhere it is absolute.
127
+ """
128
+ if source is None:
129
+ return UI_REGISTRY_SPEC
130
+ root = source.parent.parent
131
+ if project is not None and project.resolve().is_relative_to(root):
132
+ return f"file:{os.path.relpath(source, project.resolve() / 'frontend')}"
133
+ return f"file:{source}"
134
+
135
+
136
+ def ui_mount_target(spec: str, container_dir: str = "/app") -> str:
137
+ """Where a ``file:`` spec resolves inside the frontend container (compose bind target)."""
138
+ if not spec.startswith("file:"):
139
+ return spec
140
+ path = spec.removeprefix("file:")
141
+ if posixpath.isabs(path):
142
+ return path
143
+ return posixpath.normpath(posixpath.join(container_dir, path))
144
+
145
+
146
+ def ui_dist_missing(source: Path) -> list[str]:
147
+ return [rel for rel in UI_DIST_FILES if not (source / rel).is_file()]
148
+
149
+
150
+ def ui_dist_stale(source: Path) -> bool:
151
+ """True when a dist input (src/, the preset) changed after dist/index.js was built."""
152
+ built = source / UI_DIST_FILES[0]
153
+ if not built.is_file():
154
+ return False
155
+ stamp = built.stat().st_mtime
156
+ for name in UI_BUILD_INPUTS:
157
+ root = source / name
158
+ files = [root] if root.is_file() else (p for p in root.rglob("*") if p.is_file())
159
+ if any(path.stat().st_mtime > stamp for path in files):
160
+ return True
161
+ return False
162
+
163
+
164
+ def ui_build_commands(source: Path) -> list[list[str]]:
165
+ """``npm install`` (only without node_modules) then ``npm run build``."""
166
+ commands = [] if (source / "node_modules").is_dir() else [["npm", "install", "--no-audit"]]
167
+ return [*commands, ["npm", "run", "build"]]
168
+
169
+
170
+ def run_npm(argv: list[str], cwd: Path) -> subprocess.CompletedProcess[str]:
171
+ """Run one npm command (the seam tests replace)."""
172
+ npm = shutil.which(argv[0])
173
+ if npm is None:
174
+ return subprocess.CompletedProcess(argv, 127, stdout="", stderr="npm is not on PATH")
175
+ try:
176
+ return subprocess.run(
177
+ [npm, *argv[1:]],
178
+ cwd=cwd,
179
+ text=True,
180
+ capture_output=True,
181
+ check=False,
182
+ timeout=NPM_TIMEOUT,
183
+ env={**os.environ, "CI": "1"},
184
+ )
185
+ except subprocess.TimeoutExpired:
186
+ return subprocess.CompletedProcess(argv, 124, stdout="", stderr="timed out")
187
+
188
+
189
+ def build_ui_dist(source: Path) -> str | None:
190
+ """Build @karni/ui's dist/ in ``source``; ``None`` on success, else the failure."""
191
+ for argv in ui_build_commands(source):
192
+ proc = run_npm(argv, source)
193
+ if proc.returncode != 0:
194
+ tail = "\n".join((proc.stdout + proc.stderr).strip().splitlines()[-15:])
195
+ return f"`{' '.join(argv)}` failed ({proc.returncode}):\n{tail}"
196
+ missing = ui_dist_missing(source)
197
+ return f"the build did not produce {', '.join(missing)}" if missing else None
@@ -0,0 +1,128 @@
1
+ """Marker-based edits to a project's config/settings.py.
2
+
3
+ ``karni add`` and ``karni make app`` manage the ``# karni:apps`` block,
4
+ ``karni make middleware`` the ``# karni:middleware`` block. Two accepted
5
+ shapes of a marked block (``SETTING`` is ``INSTALLED_APPS`` / ``MIDDLEWARE``):
6
+
7
+ SETTING += [ # noqa: F821
8
+ "existing.entry",
9
+ ] # karni:<name>
10
+
11
+ SETTING += ["existing.entry"] # karni:<name>
12
+
13
+ Anything else errors with the manual-edit escape hatch — this code never
14
+ guesses at unfamiliar layouts.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from pathlib import Path
21
+
22
+ from karni_cli.project import CliError
23
+
24
+ MARKER = "# karni:apps"
25
+ MIDDLEWARE_MARKER = "# karni:middleware"
26
+
27
+ _QUOTED = re.compile(r"""["']([^"']+)["']""")
28
+
29
+
30
+ def _single_line(setting: str) -> re.Pattern[str]:
31
+ return re.compile(
32
+ rf"^(?P<head>\s*{re.escape(setting)}\s*\+=\s*)\[(?P<body>.*)\]\s*(?P<tail>#.*)$"
33
+ )
34
+
35
+
36
+ def _block_entries(lines: list[str], open_idx: int, close_idx: int) -> list[str]:
37
+ """Quoted entries on non-comment lines inside the block."""
38
+ entries: list[str] = []
39
+ for line in lines[open_idx + 1 : close_idx]:
40
+ stripped = line.strip()
41
+ if stripped.startswith("#"):
42
+ continue
43
+ entries.extend(_QUOTED.findall(stripped))
44
+ return entries
45
+
46
+
47
+ def _locate(lines: list[str], setting: str, marker: str) -> tuple[int, int] | None:
48
+ """``(open line, marker line)`` of the block (equal for the single-line shape)."""
49
+ marker_idx = next((i for i, line in enumerate(lines) if marker in line), None)
50
+ if marker_idx is None:
51
+ return None
52
+ if _single_line(setting).match(lines[marker_idx]):
53
+ return marker_idx, marker_idx
54
+ if lines[marker_idx].strip().startswith("]"):
55
+ open_idx = next(
56
+ (i for i in range(marker_idx - 1, -1, -1) if setting in lines[i] and "[" in lines[i]),
57
+ None,
58
+ )
59
+ if open_idx is not None:
60
+ return open_idx, marker_idx
61
+ return -1, marker_idx
62
+
63
+
64
+ def block_entries(text: str, setting: str = "INSTALLED_APPS", marker: str = MARKER) -> list[str]:
65
+ """The entries of the marked block in settings ``text`` (``[]`` when absent)."""
66
+ lines = text.splitlines()
67
+ found = _locate(lines, setting, marker)
68
+ if found is None or found[0] < 0:
69
+ return []
70
+ open_idx, marker_idx = found
71
+ if open_idx == marker_idx:
72
+ single = _single_line(setting).match(lines[marker_idx])
73
+ return _QUOTED.findall(single.group("body")) if single else []
74
+ return _block_entries(lines, open_idx, marker_idx)
75
+
76
+
77
+ def add_entry(text: str, entry: str, *, setting: str, marker: str, filename: str) -> str | None:
78
+ """``text`` with ``entry`` inserted into the marked block; ``None`` when already present.
79
+
80
+ :raises CliError: when the marker is missing or the block has another shape.
81
+ """
82
+ lines = text.splitlines()
83
+ found = _locate(lines, setting, marker)
84
+ if found is None:
85
+ raise CliError(
86
+ f"No `{marker}` marker in {filename} — add {entry!r} to {setting} manually.",
87
+ code="C108",
88
+ )
89
+ open_idx, marker_idx = found
90
+ if open_idx < 0:
91
+ raise CliError(
92
+ f"Unrecognized `{marker}` block shape in {filename} — add {entry!r} to {setting} "
93
+ "manually.",
94
+ code="C108",
95
+ )
96
+ if open_idx == marker_idx:
97
+ single = _single_line(setting).match(lines[marker_idx])
98
+ assert single is not None # _locate matched it
99
+ entries = _QUOTED.findall(single.group("body"))
100
+ if entry in entries:
101
+ return None
102
+ rebuilt = [f"{single.group('head').rstrip()} [ # noqa: F821"]
103
+ rebuilt += [f' "{existing}",' for existing in entries]
104
+ rebuilt.append(f' "{entry}",')
105
+ rebuilt.append(f"] {marker}")
106
+ lines[marker_idx : marker_idx + 1] = rebuilt
107
+ else:
108
+ if entry in _block_entries(lines, open_idx, marker_idx):
109
+ return None
110
+ lines.insert(marker_idx, f' "{entry}",')
111
+ return "\n".join(lines) + "\n"
112
+
113
+
114
+ def add_app(settings_path: Path, app: str) -> bool:
115
+ """Insert ``app`` into the ``# karni:apps`` block. Returns False when already present."""
116
+ if not settings_path.is_file():
117
+ raise CliError(f"{settings_path} not found.", code="C001")
118
+ updated = add_entry(
119
+ settings_path.read_text(),
120
+ app,
121
+ setting="INSTALLED_APPS",
122
+ marker=MARKER,
123
+ filename=settings_path.name,
124
+ )
125
+ if updated is None:
126
+ return False
127
+ settings_path.write_text(updated)
128
+ return True