karni-cli 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- karni_cli/__init__.py +3 -0
- karni_cli/_agent/__init__.py +82 -0
- karni_cli/_agent/__main__.py +44 -0
- karni_cli/_agent/check.py +658 -0
- karni_cli/_agent/history.py +328 -0
- karni_cli/_agent/history_chronicle.py +246 -0
- karni_cli/_agent/overrides.py +194 -0
- karni_cli/_agent/rules.py +673 -0
- karni_cli/_changelog/0.2.0.toml +304 -0
- karni_cli/_changelog/0.3.0.toml +25 -0
- karni_cli/_changelog/__init__.py +248 -0
- karni_cli/_errorcodes/__init__.py +1171 -0
- karni_cli/_errorcodes/__main__.py +34 -0
- karni_cli/_errorcodes/links.py +217 -0
- karni_cli/_templates/project/cookiecutter.json +8 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.dockerignore +7 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.env.example +85 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.gitignore +10 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Caddyfile +22 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Dockerfile +53 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Makefile +52 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/README.md +169 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/__init__.py +16 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/asgi.py +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/celery.py +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/settings.py +28 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/urls.py +31 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/wsgi.py +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/docker-compose.yml +126 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.dockerignore +2 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.jscpd.json +9 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.npmrc +7 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/Dockerfile.dev +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/eslint.config.js +32 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/index.html +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/package.json +43 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/postcss.config.js +6 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/public/favicon.svg +6 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/App.tsx +18 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/api/health.ts +21 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/index.css +11 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/layout.tsx +51 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/main.tsx +11 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/pages/home.tsx +28 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/routes.tsx +24 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/vite-env.d.ts +1 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tailwind.config.ts +13 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tsconfig.json +19 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/vite.config.ts +18 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/manage.py +23 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/pyproject.toml +31 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/__init__.py +0 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_api_schema.py +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_smoke.py +11 -0
- karni_cli/adapters.py +373 -0
- karni_cli/agent_adapters.toml +160 -0
- karni_cli/agent_data.py +260 -0
- karni_cli/brand.py +55 -0
- karni_cli/clidoc.py +254 -0
- karni_cli/commands/__init__.py +0 -0
- karni_cli/commands/add.py +144 -0
- karni_cli/commands/beat.py +36 -0
- karni_cli/commands/check.py +101 -0
- karni_cli/commands/config.py +81 -0
- karni_cli/commands/context.py +171 -0
- karni_cli/commands/createsuperuser.py +28 -0
- karni_cli/commands/doctor.py +136 -0
- karni_cli/commands/explain.py +60 -0
- karni_cli/commands/make.py +55 -0
- karni_cli/commands/manage_passthrough.py +28 -0
- karni_cli/commands/mcp.py +20 -0
- karni_cli/commands/migrate.py +23 -0
- karni_cli/commands/new.py +106 -0
- karni_cli/commands/routes.py +20 -0
- karni_cli/commands/rules.py +139 -0
- karni_cli/commands/run_tests.py +38 -0
- karni_cli/commands/schedule.py +40 -0
- karni_cli/commands/seed.py +34 -0
- karni_cli/commands/serve.py +50 -0
- karni_cli/commands/shell.py +28 -0
- karni_cli/commands/sync_roles.py +32 -0
- karni_cli/commands/types.py +72 -0
- karni_cli/commands/upgrade.py +231 -0
- karni_cli/commands/worker.py +63 -0
- karni_cli/config.py +142 -0
- karni_cli/environment.py +29 -0
- karni_cli/errorcodes.py +377 -0
- karni_cli/errordoc.py +227 -0
- karni_cli/generators/__init__.py +67 -0
- karni_cli/generators/apitest.py +206 -0
- karni_cli/generators/app.py +103 -0
- karni_cli/generators/command.py +98 -0
- karni_cli/generators/component.py +115 -0
- karni_cli/generators/core.py +319 -0
- karni_cli/generators/event.py +57 -0
- karni_cli/generators/factory.py +88 -0
- karni_cli/generators/fields.py +343 -0
- karni_cli/generators/introspect.py +263 -0
- karni_cli/generators/listener.py +168 -0
- karni_cli/generators/middleware.py +95 -0
- karni_cli/generators/migration.py +82 -0
- karni_cli/generators/model.py +235 -0
- karni_cli/generators/module.py +255 -0
- karni_cli/generators/naming.py +100 -0
- karni_cli/generators/page.py +373 -0
- karni_cli/generators/pysource.py +304 -0
- karni_cli/generators/schedule.py +174 -0
- karni_cli/generators/seeder.py +86 -0
- karni_cli/generators/serializer.py +165 -0
- karni_cli/generators/service.py +68 -0
- karni_cli/generators/task.py +84 -0
- karni_cli/generators/viewset.py +179 -0
- karni_cli/history.py +59 -0
- karni_cli/local_state.py +73 -0
- karni_cli/main.py +104 -0
- karni_cli/mcp_server.py +103 -0
- karni_cli/project.py +248 -0
- karni_cli/rules_render.py +86 -0
- karni_cli/scaffold.py +197 -0
- karni_cli/settings_edit.py +128 -0
- karni_cli/tips.py +53 -0
- karni_cli/typegen.py +101 -0
- karni_cli/upgrades/__init__.py +28 -0
- karni_cli/upgrades/changelog.py +123 -0
- karni_cli/upgrades/codemod.py +125 -0
- karni_cli/upgrades/engine.py +224 -0
- karni_cli/upgrades/legacy_agent_files.py +138 -0
- karni_cli/upgrades/python.py +670 -0
- karni_cli/upgrades/transforms/rename-export.cjs +96 -0
- karni_cli/upgrades/typescript.py +160 -0
- karni_cli/upgrades/v0_2/__init__.py +1 -0
- karni_cli/upgrades/v0_2/agent_guidance.py +269 -0
- karni_cli/upgrades/v1_0/__init__.py +5 -0
- karni_cli/upgrades/v1_0/all_objects_to_without.py +18 -0
- karni_cli/upgrades/v1_0/all_with_deleted_to_without.py +13 -0
- karni_cli/upgrades/v1_0/deleted_only_to_without_dead.py +13 -0
- karni_cli/upgrades/v1_0/object_permission_classes_to_access.py +16 -0
- karni_cli/upgrades/v1_0/related_lookups_to_optimize.py +15 -0
- karni_cli/upgrades/v1_0/scoped_related_lookups_to_optimize.py +15 -0
- karni_cli/upgrades/v1_0/soft_delete_manager_mixin_to_scoped.py +14 -0
- karni_cli-0.3.1.dist-info/METADATA +191 -0
- karni_cli-0.3.1.dist-info/RECORD +145 -0
- karni_cli-0.3.1.dist-info/WHEEL +4 -0
- karni_cli-0.3.1.dist-info/entry_points.txt +2 -0
- karni_cli-0.3.1.dist-info/licenses/LICENSE +201 -0
karni_cli/agent_data.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Where the CLI gets Karni's agent rules from (``karni.agent``).
|
|
2
|
+
|
|
3
|
+
The CLI never imports project code, but ``karni.agent`` is Karni's own
|
|
4
|
+
stdlib-only data module, so it may be imported when available. Resolution
|
|
5
|
+
(first that works; force one with ``KARNI_AGENT_SOURCE=local|project|bundled``):
|
|
6
|
+
|
|
7
|
+
1. ``local`` — ``import karni.agent`` in the CLI's own environment.
|
|
8
|
+
2. ``project`` — ``python -m karni.agent`` through the project's interpreter
|
|
9
|
+
(``.venv``, else a running compose service — the same resolution as
|
|
10
|
+
``karni context``); answers with the project's own karni version.
|
|
11
|
+
3. ``bundled`` — the copy of ``karni/agent`` shipped inside karni-cli (the
|
|
12
|
+
monorepo source when run from a checkout), loaded by path. This is what
|
|
13
|
+
``karni new`` uses on a machine where only karni-cli is installed.
|
|
14
|
+
|
|
15
|
+
Every path yields the same JSON-able shape (``karni.agent.dump``), so the
|
|
16
|
+
renderers below never care which one answered.
|
|
17
|
+
|
|
18
|
+
History providers (``karni.agent.history``, :func:`history_module`) are the
|
|
19
|
+
exception: they always run in the CLI's own environment, and in ``project``
|
|
20
|
+
mode the CLI hands its detection to the project interpreter
|
|
21
|
+
(``KARNI_AGENT_CONDITIONS``) so conditional rules follow the same providers.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import importlib.util
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from importlib import resources
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from types import ModuleType
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from karni_cli import project as project_mod
|
|
38
|
+
from karni_cli.project import CliError
|
|
39
|
+
|
|
40
|
+
SOURCES = ("local", "project", "bundled")
|
|
41
|
+
_BUNDLED_NAME = "karni_cli._bundled_karni_agent"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class AgentData:
|
|
46
|
+
raw: dict[str, Any]
|
|
47
|
+
source: str
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def rules(self) -> list[dict[str, Any]]:
|
|
51
|
+
return list(self.raw["rules"])
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def procedures(self) -> list[dict[str, Any]]:
|
|
55
|
+
return list(self.raw["procedures"])
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def categories(self) -> list[str]:
|
|
59
|
+
return list(self.raw["categories"])
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def adapter_overrides(self) -> dict[str, Any]:
|
|
63
|
+
return dict(self.raw.get("adapters") or {})
|
|
64
|
+
|
|
65
|
+
def rule(self, code: str) -> dict[str, Any] | None:
|
|
66
|
+
return next((r for r in self.rules if r["code"] == code.upper()), None)
|
|
67
|
+
|
|
68
|
+
def procedure(self, name: str) -> dict[str, Any] | None:
|
|
69
|
+
return next((p for p in self.procedures if p["name"] == name), None)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _local() -> ModuleType | None:
|
|
73
|
+
try:
|
|
74
|
+
from karni import agent
|
|
75
|
+
except ImportError:
|
|
76
|
+
return None
|
|
77
|
+
return agent
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _bundled_dir() -> Path | None:
|
|
81
|
+
for parent in Path(__file__).resolve().parents:
|
|
82
|
+
candidate = parent / "packages" / "karni" / "karni" / "agent"
|
|
83
|
+
if (candidate / "rules.py").is_file():
|
|
84
|
+
return candidate
|
|
85
|
+
packaged = resources.files("karni_cli") / "_agent"
|
|
86
|
+
with resources.as_file(packaged) as path:
|
|
87
|
+
if (path / "rules.py").is_file():
|
|
88
|
+
return path
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _bundled() -> ModuleType | None:
|
|
93
|
+
if _BUNDLED_NAME in sys.modules:
|
|
94
|
+
return sys.modules[_BUNDLED_NAME]
|
|
95
|
+
directory = _bundled_dir()
|
|
96
|
+
if directory is None:
|
|
97
|
+
return None
|
|
98
|
+
spec = importlib.util.spec_from_file_location(
|
|
99
|
+
_BUNDLED_NAME, directory / "__init__.py", submodule_search_locations=[str(directory)]
|
|
100
|
+
)
|
|
101
|
+
if spec is None or spec.loader is None:
|
|
102
|
+
return None
|
|
103
|
+
module = importlib.util.module_from_spec(spec)
|
|
104
|
+
sys.modules[_BUNDLED_NAME] = module
|
|
105
|
+
try:
|
|
106
|
+
spec.loader.exec_module(module)
|
|
107
|
+
except Exception:
|
|
108
|
+
del sys.modules[_BUNDLED_NAME]
|
|
109
|
+
raise
|
|
110
|
+
return module
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def history_module() -> ModuleType | None:
|
|
114
|
+
"""``karni.agent.history`` for the CLI's own environment.
|
|
115
|
+
|
|
116
|
+
History providers always run in the CLI's environment (third-party
|
|
117
|
+
providers are discovered from its entry points): karni's module when
|
|
118
|
+
importable here (unless ``KARNI_AGENT_SOURCE=bundled``), else the bundled
|
|
119
|
+
copy, which carries the built-in Gigai Chronicle provider by itself.
|
|
120
|
+
"""
|
|
121
|
+
if os.environ.get("KARNI_AGENT_SOURCE", "").strip() != "bundled":
|
|
122
|
+
try:
|
|
123
|
+
from karni.agent import history
|
|
124
|
+
|
|
125
|
+
return history
|
|
126
|
+
except ImportError:
|
|
127
|
+
pass # karni absent here, or a karni older than history providers
|
|
128
|
+
bundled = _bundled()
|
|
129
|
+
if bundled is None:
|
|
130
|
+
return None
|
|
131
|
+
try:
|
|
132
|
+
return importlib.import_module(f"{bundled.__name__}.history")
|
|
133
|
+
except ImportError:
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _conditions_env(project: Path) -> dict[str, str]:
|
|
138
|
+
"""The CLI's own condition detection, handed to the project interpreter so a
|
|
139
|
+
conditional rule follows the same providers as ``karni context --with-history``
|
|
140
|
+
(and a compose container that cannot see a store above its mount)."""
|
|
141
|
+
module = history_module()
|
|
142
|
+
if module is None:
|
|
143
|
+
return {}
|
|
144
|
+
return {module.CONDITIONS_ENV: ",".join(sorted(module.active_conditions(project)))}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _project_command(project: Path, env: dict[str, str] | None = None) -> list[str] | None:
|
|
148
|
+
python = project_mod.venv_python(project)
|
|
149
|
+
if python is not None:
|
|
150
|
+
return [str(python), "-m", "karni.agent"]
|
|
151
|
+
service = os.environ.get("KARNI_COMPOSE_SERVICE", "django")
|
|
152
|
+
if project_mod.compose_service_running(project, service):
|
|
153
|
+
flags = [flag for key, value in (env or {}).items() for flag in ("-e", f"{key}={value}")]
|
|
154
|
+
return ["docker", "compose", "exec", "-T", *flags, service, "python", "-m", "karni.agent"]
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _run_project(project: Path, args: list[str], env: dict[str, str] | None = None) -> Any | None:
|
|
159
|
+
command = _project_command(project, env)
|
|
160
|
+
if command is None:
|
|
161
|
+
return None
|
|
162
|
+
proc = subprocess.run(
|
|
163
|
+
[*command, *args],
|
|
164
|
+
cwd=project,
|
|
165
|
+
text=True,
|
|
166
|
+
capture_output=True,
|
|
167
|
+
check=False,
|
|
168
|
+
env={**os.environ, **env} if env else None,
|
|
169
|
+
)
|
|
170
|
+
if proc.returncode == 2:
|
|
171
|
+
raise CliError(proc.stderr.strip(), code="C009")
|
|
172
|
+
if proc.returncode != 0:
|
|
173
|
+
return None # e.g. an older karni without karni.agent: try the next source
|
|
174
|
+
return json.loads(proc.stdout)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _order() -> tuple[str, ...]:
|
|
178
|
+
forced = os.environ.get("KARNI_AGENT_SOURCE", "").strip()
|
|
179
|
+
if not forced:
|
|
180
|
+
return SOURCES
|
|
181
|
+
if forced not in SOURCES:
|
|
182
|
+
raise CliError(
|
|
183
|
+
f"KARNI_AGENT_SOURCE must be one of {', '.join(SOURCES)}, got {forced!r}.", code="C007"
|
|
184
|
+
)
|
|
185
|
+
return (forced,)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _module_for(source: str) -> ModuleType | None:
|
|
189
|
+
return _local() if source == "local" else _bundled()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _call(module: ModuleType, name: str, *args: Any) -> Any:
|
|
193
|
+
try:
|
|
194
|
+
return getattr(module, name)(*args)
|
|
195
|
+
except module.OverrideError as exc:
|
|
196
|
+
raise CliError(_bare(exc), code=getattr(exc, "code", "E701")) from None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def load(project: Path | None) -> AgentData:
|
|
200
|
+
"""Rules + procedures with ``<project>/karni.rules.py`` applied."""
|
|
201
|
+
for source in _order():
|
|
202
|
+
if source == "project":
|
|
203
|
+
if project is None:
|
|
204
|
+
continue
|
|
205
|
+
raw = _run_project(project, ["dump", "--root", "."], _conditions_env(project))
|
|
206
|
+
if raw is not None:
|
|
207
|
+
return AgentData(raw, source)
|
|
208
|
+
continue
|
|
209
|
+
module = _module_for(source)
|
|
210
|
+
if module is not None:
|
|
211
|
+
return AgentData(_call(module, "dump", project), source)
|
|
212
|
+
raise CliError(
|
|
213
|
+
"Karni's agent rules are unavailable: karni is not importable here, the project "
|
|
214
|
+
"interpreter is unreachable, and no bundled copy ships with this karni-cli.\n"
|
|
215
|
+
"Install karni next to karni-cli, or run inside a project with a .venv.",
|
|
216
|
+
code="C005",
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def check(paths: list[Path], project: Path | None) -> list[dict[str, Any]]:
|
|
221
|
+
"""Static findings for ``paths`` (each a dict with path/code/rule_id/line/message/fix)."""
|
|
222
|
+
for source in _order():
|
|
223
|
+
if source == "project":
|
|
224
|
+
if project is None:
|
|
225
|
+
continue
|
|
226
|
+
rel = [_relative(p, project) for p in paths]
|
|
227
|
+
found = _run_project(project, ["check", "--root", ".", *rel])
|
|
228
|
+
if found is not None:
|
|
229
|
+
return [{**f, "path": str(p)} for f, p in _rejoin(found, rel, paths)]
|
|
230
|
+
continue
|
|
231
|
+
module = _module_for(source)
|
|
232
|
+
if module is not None:
|
|
233
|
+
try:
|
|
234
|
+
return list(_call(module, "check_paths", paths, project))
|
|
235
|
+
except (OSError, ValueError) as exc:
|
|
236
|
+
raise CliError(_bare(exc), code=getattr(exc, "code", "C010")) from None
|
|
237
|
+
raise CliError("Karni's agent checks are unavailable (see `karni rules`).", code="C005")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _bare(exc: BaseException) -> str:
|
|
241
|
+
"""The message without the code prefix the bundled exception adds to ``str()``."""
|
|
242
|
+
return str(exc.args[0]) if exc.args else str(exc)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _relative(path: Path, project: Path) -> str:
|
|
246
|
+
try:
|
|
247
|
+
return str(path.resolve().relative_to(project.resolve()))
|
|
248
|
+
except ValueError:
|
|
249
|
+
raise CliError(
|
|
250
|
+
f"{path} is outside the project; the project interpreter can only check "
|
|
251
|
+
"files inside it.",
|
|
252
|
+
code="C010",
|
|
253
|
+
) from None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _rejoin(
|
|
257
|
+
found: list[dict[str, Any]], rel: list[str], paths: list[Path]
|
|
258
|
+
) -> list[tuple[dict[str, Any], Path]]:
|
|
259
|
+
by_rel = dict(zip(rel, paths, strict=True))
|
|
260
|
+
return [(f, by_rel.get(f["path"], Path(f["path"]))) for f in found]
|
karni_cli/brand.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""The Karni mark for the terminal: a few lines, only where a person will see them.
|
|
2
|
+
|
|
3
|
+
``print_mark()`` writes a small truecolor rendering of the crown when stdout
|
|
4
|
+
is an interactive terminal; it writes nothing when the output is piped, when
|
|
5
|
+
``NO_COLOR`` is set (https://no-color.org) or when ``TERM`` is ``dumb``, so
|
|
6
|
+
no machine-readable output ever carries it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from typing import IO
|
|
15
|
+
|
|
16
|
+
ACCENT = "\x1b[38;2;251;93;8m"
|
|
17
|
+
LINE = "\x1b[38;2;255;255;255m"
|
|
18
|
+
RESET = "\x1b[0m"
|
|
19
|
+
|
|
20
|
+
#: Rows of the mark, as (ink, text) runs — rings above the crown's legs, peak and crossings.
|
|
21
|
+
ROWS: tuple[tuple[tuple[str, str], ...], ...] = (
|
|
22
|
+
((ACCENT, " ● ● ●"),),
|
|
23
|
+
((LINE, " │╲ ╱ ╲ ╱│"),),
|
|
24
|
+
((LINE, " │ ╳ ╳ │"),),
|
|
25
|
+
((LINE, " │╱ ╲ ╱ ╲│"),),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def wants_mark(stream: IO[str] | None = None, environ: Mapping[str, str] | None = None) -> bool:
|
|
30
|
+
"""Is this an interactive, colour-capable terminal that has not opted out?"""
|
|
31
|
+
stream = stream or sys.stdout
|
|
32
|
+
env: Mapping[str, str] = os.environ if environ is None else environ
|
|
33
|
+
if env.get("NO_COLOR") or env.get("TERM", "") == "dumb":
|
|
34
|
+
return False
|
|
35
|
+
isatty = getattr(stream, "isatty", None)
|
|
36
|
+
return bool(isatty and isatty())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def mark_lines(*, colour: bool = True) -> list[str]:
|
|
40
|
+
"""The mark's rows, with or without ANSI colour."""
|
|
41
|
+
lines = []
|
|
42
|
+
for row in ROWS:
|
|
43
|
+
text = "".join(f"{ink}{run}{RESET}" if colour else run for ink, run in row)
|
|
44
|
+
lines.append(text)
|
|
45
|
+
return lines
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def print_mark(stream: IO[str] | None = None) -> bool:
|
|
49
|
+
"""Print the mark when :func:`wants_mark`; returns whether it was printed."""
|
|
50
|
+
stream = stream or sys.stdout
|
|
51
|
+
if not wants_mark(stream):
|
|
52
|
+
return False
|
|
53
|
+
stream.write("\n".join(mark_lines()) + "\n\n")
|
|
54
|
+
stream.flush()
|
|
55
|
+
return True
|
karni_cli/clidoc.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Render docs/cli.md from the typer app — the CLI reference is generated, never hand-written.
|
|
2
|
+
|
|
3
|
+
python -m karni_cli.clidoc > docs/cli.md
|
|
4
|
+
|
|
5
|
+
``tests/test_clidoc.py`` fails when docs/cli.md differs from :func:`render`,
|
|
6
|
+
so a changed option or help text must be regenerated in the same commit.
|
|
7
|
+
Everything comes from the click command tree typer builds (help texts,
|
|
8
|
+
arguments, options, defaults) plus :data:`karni_cli.generators.GENERATORS`
|
|
9
|
+
and the field DSL reference (:data:`karni_cli.generators.fields.FIELD_DOCS`).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import textwrap
|
|
15
|
+
from pathlib import PurePath
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
|
|
20
|
+
from karni_cli.commands.make import list_text
|
|
21
|
+
from karni_cli.generators.fields import FIELD_DOCS
|
|
22
|
+
|
|
23
|
+
#: Section → top-level commands, in reading order. Anything else lands in "Other".
|
|
24
|
+
SECTIONS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
25
|
+
("Project", ("new", "add", "upgrade", "doctor", "config")),
|
|
26
|
+
("Agents and checks", ("context", "rules", "check", "explain", "types", "mcp")),
|
|
27
|
+
(
|
|
28
|
+
"Runners",
|
|
29
|
+
(
|
|
30
|
+
"serve",
|
|
31
|
+
"migrate",
|
|
32
|
+
"test",
|
|
33
|
+
"shell",
|
|
34
|
+
"routes",
|
|
35
|
+
"worker",
|
|
36
|
+
"beat",
|
|
37
|
+
"schedule",
|
|
38
|
+
"seed",
|
|
39
|
+
"sync-roles",
|
|
40
|
+
"createsuperuser",
|
|
41
|
+
"manage",
|
|
42
|
+
),
|
|
43
|
+
),
|
|
44
|
+
("Generators", ("make",)),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
HEADER = """---
|
|
48
|
+
description: Every karni command with its arguments and options, generated from the CLI's own help text.
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
# The `karni` command
|
|
52
|
+
|
|
53
|
+
> Generated from the CLI itself — `python -m karni_cli.clidoc > docs/cli.md`;
|
|
54
|
+
> `packages/karni-cli/tests/test_clidoc.py` fails when this file is stale.
|
|
55
|
+
> Change a command's help, then regenerate; never edit this file by hand.
|
|
56
|
+
|
|
57
|
+
`karni <command> --help` prints the same information. Runners execute in the
|
|
58
|
+
project environment: the project's `.venv`, else the running docker compose
|
|
59
|
+
service (`--docker/--no-docker` forces one; `--service` / `KARNI_COMPOSE_SERVICE`
|
|
60
|
+
names it). Put `--` before passthrough arguments that clash with karni's own
|
|
61
|
+
options.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
GENERATOR_RULES = """Every generator follows the same rules:
|
|
65
|
+
|
|
66
|
+
- It writes project code only — never framework logic.
|
|
67
|
+
- It never overwrites: an existing target is an error naming the file and
|
|
68
|
+
`--force` (which replaces it).
|
|
69
|
+
- `--dry-run` prints the files and the diff it would write, and writes nothing.
|
|
70
|
+
- It prints every created and modified file, then any notes (the chosen
|
|
71
|
+
`access`, the route, the next command).
|
|
72
|
+
- The target app is `--app <label>`, else the project's only local app (local
|
|
73
|
+
apps are the `# karni:apps` entries of config/settings.py).
|
|
74
|
+
- Existing files change only through marker comments (`# karni:apps`,
|
|
75
|
+
`# karni:middleware`, `// karni:page-imports`, `// karni:routes`) or
|
|
76
|
+
AST-located appends; an unfamiliar layout is an error that names the manual
|
|
77
|
+
step. Generated Python is ruff-clean (isort sections, ruff format layout).
|
|
78
|
+
- It never imports Django: `karni make migration` (and `make model --migrate`)
|
|
79
|
+
run `manage.py makemigrations` through the project interpreter.
|
|
80
|
+
- Every generator is also available as a hidden Laravel-style alias:
|
|
81
|
+
`karni make:<type>` (e.g. `karni make:model Event title:str`).
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _md(text: str) -> str:
|
|
86
|
+
"""Escape ``<`` / ``>`` outside backtick code spans (Markdown would eat ``<field>``)."""
|
|
87
|
+
parts = text.split("`")
|
|
88
|
+
for i in range(0, len(parts), 2):
|
|
89
|
+
parts[i] = parts[i].replace("<", "<").replace(">", ">")
|
|
90
|
+
return "`".join(parts)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _escape(text: str) -> str:
|
|
94
|
+
"""A table cell: Markdown-escaped, pipes escaped, one line."""
|
|
95
|
+
return _md(text).replace("|", "\\|").replace("\n", " ")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _paragraphs(help_text: str | None) -> str:
|
|
99
|
+
"""Help text as Markdown: paragraphs re-flowed, ``Example:`` paragraphs as code."""
|
|
100
|
+
if not help_text:
|
|
101
|
+
return ""
|
|
102
|
+
blocks: list[str] = []
|
|
103
|
+
for raw in textwrap.dedent(help_text).strip().split("\n\n"):
|
|
104
|
+
text = " ".join(line.strip() for line in raw.strip().splitlines())
|
|
105
|
+
if not text or text == "\b":
|
|
106
|
+
continue
|
|
107
|
+
for label in ("Example:", "Examples:"):
|
|
108
|
+
if text.startswith(label):
|
|
109
|
+
code = text[len(label) :].strip()
|
|
110
|
+
blocks.append(f"{label}\n\n```sh\n{code}\n```")
|
|
111
|
+
break
|
|
112
|
+
else:
|
|
113
|
+
blocks.append(_md(text))
|
|
114
|
+
return "\n\n".join(blocks)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# Typer >= 0.20 vendors its own click (typer._click), older ones use click: the
|
|
118
|
+
# command tree is read by duck typing, never isinstance against a click import.
|
|
119
|
+
Command = Any
|
|
120
|
+
Parameter = Any
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _is_argument(param: Parameter) -> bool:
|
|
124
|
+
return getattr(param, "param_type_name", "") == "argument"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _subcommands(command: Command) -> dict[str, Command]:
|
|
128
|
+
commands = getattr(command, "commands", None)
|
|
129
|
+
return dict(commands) if isinstance(commands, dict) else {}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
_TYPE_LABELS = {"str": "TEXT", "string": "TEXT", "text": "TEXT", "int": "INTEGER"}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _metavar(param: Parameter) -> str:
|
|
136
|
+
"""A stable value label (click and typer's vendored click name types differently)."""
|
|
137
|
+
name = str(getattr(param.type, "name", "text")).lower()
|
|
138
|
+
return _TYPE_LABELS.get(name, name.upper())
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _default(param: Parameter) -> str:
|
|
142
|
+
value: Any = param.default
|
|
143
|
+
if callable(value) or value in (None, False, "", (), []):
|
|
144
|
+
return ""
|
|
145
|
+
if value is True:
|
|
146
|
+
return "on"
|
|
147
|
+
if isinstance(value, PurePath):
|
|
148
|
+
value = value.as_posix()
|
|
149
|
+
return f"`{value}`"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _param_row(param: Parameter) -> str | None:
|
|
153
|
+
if getattr(param, "hidden", False):
|
|
154
|
+
return None
|
|
155
|
+
help_text = _escape(getattr(param, "help", None) or "")
|
|
156
|
+
if _is_argument(param):
|
|
157
|
+
name = (param.name or "").rstrip("_").upper()
|
|
158
|
+
name += "..." if param.nargs == -1 else ""
|
|
159
|
+
label = f"`{name}`" + (" (required)" if param.required else "")
|
|
160
|
+
else:
|
|
161
|
+
flags = list(param.opts)
|
|
162
|
+
if param.secondary_opts:
|
|
163
|
+
flags = [f"{flags[0]}/{param.secondary_opts[0]}", *flags[1:]]
|
|
164
|
+
label = ", ".join(f"`{flag}`" for flag in flags)
|
|
165
|
+
if not param.is_flag:
|
|
166
|
+
label += f" {_metavar(param)}"
|
|
167
|
+
if param.multiple:
|
|
168
|
+
label += " (repeatable)"
|
|
169
|
+
if param.required:
|
|
170
|
+
label += " (required)"
|
|
171
|
+
if param.envvar:
|
|
172
|
+
envvars = [param.envvar] if isinstance(param.envvar, str) else list(param.envvar)
|
|
173
|
+
help_text = f"{help_text} Env: {', '.join(f'`{e}`' for e in envvars)}.".strip()
|
|
174
|
+
return f"| {label} | {_default(param)} | {help_text} |"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _usage(path: str, command: Command) -> str:
|
|
178
|
+
pieces = [f"karni {path}"]
|
|
179
|
+
if any(not _is_argument(p) and not getattr(p, "hidden", False) for p in command.params):
|
|
180
|
+
pieces.append("[OPTIONS]")
|
|
181
|
+
for param in command.params:
|
|
182
|
+
if _is_argument(param):
|
|
183
|
+
name = (param.name or "").rstrip("_").upper()
|
|
184
|
+
name += "..." if param.nargs == -1 else ""
|
|
185
|
+
pieces.append(name if param.required else f"[{name}]")
|
|
186
|
+
if _subcommands(command):
|
|
187
|
+
pieces.append("COMMAND [ARGS]...")
|
|
188
|
+
return " ".join(pieces)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _command(path: str, command: Command, level: int) -> list[str]:
|
|
192
|
+
out = [f"{'#' * level} `karni {path}`", ""]
|
|
193
|
+
body = _paragraphs(command.help)
|
|
194
|
+
if body:
|
|
195
|
+
out += [body, ""]
|
|
196
|
+
out += ["```", _usage(path, command), "```", ""]
|
|
197
|
+
rows = [row for row in (_param_row(p) for p in command.params) if row]
|
|
198
|
+
if rows:
|
|
199
|
+
out += ["| Argument / option | Default | Description |", "| --- | --- | --- |", *rows, ""]
|
|
200
|
+
if path == "make":
|
|
201
|
+
out += [
|
|
202
|
+
GENERATOR_RULES,
|
|
203
|
+
"`karni make --list`:",
|
|
204
|
+
"",
|
|
205
|
+
"```",
|
|
206
|
+
list_text(),
|
|
207
|
+
"```",
|
|
208
|
+
"",
|
|
209
|
+
"Field DSL for `karni make model NAME [FIELD...]`:",
|
|
210
|
+
"",
|
|
211
|
+
"| Field | Generates |",
|
|
212
|
+
"| --- | --- |",
|
|
213
|
+
*(f"| `{_escape(syntax)}` | {_escape(result)} |" for syntax, result in FIELD_DOCS),
|
|
214
|
+
"",
|
|
215
|
+
]
|
|
216
|
+
for name, sub in _subcommands(command).items():
|
|
217
|
+
if not sub.hidden:
|
|
218
|
+
out += _command(f"{path} {name}", sub, level + 1)
|
|
219
|
+
return out
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def render(app: typer.Typer | None = None) -> str:
|
|
223
|
+
"""docs/cli.md for ``app`` (default: the ``karni`` app)."""
|
|
224
|
+
if app is None:
|
|
225
|
+
from karni_cli.main import app as karni_app
|
|
226
|
+
|
|
227
|
+
app = karni_app
|
|
228
|
+
root = typer.main.get_command(app)
|
|
229
|
+
visible = {name: cmd for name, cmd in _subcommands(root).items() if not cmd.hidden}
|
|
230
|
+
placed = {name for _, names in SECTIONS for name in names}
|
|
231
|
+
sections = [*SECTIONS, ("Other", tuple(n for n in visible if n not in placed))]
|
|
232
|
+
out = [HEADER, "## Contents", ""]
|
|
233
|
+
for title, names in sections:
|
|
234
|
+
present = [n for n in names if n in visible]
|
|
235
|
+
if present:
|
|
236
|
+
links = ", ".join(f"[{n}](#karni-{n})" for n in present)
|
|
237
|
+
out.append(f"- **{title}**: {links}")
|
|
238
|
+
out.append("")
|
|
239
|
+
for title, names in sections:
|
|
240
|
+
present = [n for n in names if n in visible]
|
|
241
|
+
if not present:
|
|
242
|
+
continue
|
|
243
|
+
out += [f"## {title}", ""]
|
|
244
|
+
for name in present:
|
|
245
|
+
out += _command(name, visible[name], 3)
|
|
246
|
+
return "\n".join(out).rstrip("\n") + "\n"
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def main() -> None:
|
|
250
|
+
typer.echo(render(), nl=False)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
if __name__ == "__main__":
|
|
254
|
+
main()
|
|
File without changes
|