karni-cli 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- karni_cli/__init__.py +3 -0
- karni_cli/_agent/__init__.py +82 -0
- karni_cli/_agent/__main__.py +44 -0
- karni_cli/_agent/check.py +658 -0
- karni_cli/_agent/history.py +328 -0
- karni_cli/_agent/history_chronicle.py +246 -0
- karni_cli/_agent/overrides.py +194 -0
- karni_cli/_agent/rules.py +673 -0
- karni_cli/_changelog/0.2.0.toml +304 -0
- karni_cli/_changelog/0.3.0.toml +25 -0
- karni_cli/_changelog/__init__.py +248 -0
- karni_cli/_errorcodes/__init__.py +1171 -0
- karni_cli/_errorcodes/__main__.py +34 -0
- karni_cli/_errorcodes/links.py +217 -0
- karni_cli/_templates/project/cookiecutter.json +8 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.dockerignore +7 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.env.example +85 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.gitignore +10 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Caddyfile +22 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Dockerfile +53 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Makefile +52 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/README.md +169 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/__init__.py +16 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/asgi.py +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/celery.py +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/settings.py +28 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/urls.py +31 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/wsgi.py +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/docker-compose.yml +126 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.dockerignore +2 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.jscpd.json +9 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.npmrc +7 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/Dockerfile.dev +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/eslint.config.js +32 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/index.html +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/package.json +43 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/postcss.config.js +6 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/public/favicon.svg +6 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/App.tsx +18 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/api/health.ts +21 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/index.css +11 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/layout.tsx +51 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/main.tsx +11 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/pages/home.tsx +28 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/routes.tsx +24 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/vite-env.d.ts +1 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tailwind.config.ts +13 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tsconfig.json +19 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/vite.config.ts +18 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/manage.py +23 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/pyproject.toml +31 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/__init__.py +0 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_api_schema.py +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_smoke.py +11 -0
- karni_cli/adapters.py +373 -0
- karni_cli/agent_adapters.toml +160 -0
- karni_cli/agent_data.py +260 -0
- karni_cli/brand.py +55 -0
- karni_cli/clidoc.py +254 -0
- karni_cli/commands/__init__.py +0 -0
- karni_cli/commands/add.py +144 -0
- karni_cli/commands/beat.py +36 -0
- karni_cli/commands/check.py +101 -0
- karni_cli/commands/config.py +81 -0
- karni_cli/commands/context.py +171 -0
- karni_cli/commands/createsuperuser.py +28 -0
- karni_cli/commands/doctor.py +136 -0
- karni_cli/commands/explain.py +60 -0
- karni_cli/commands/make.py +55 -0
- karni_cli/commands/manage_passthrough.py +28 -0
- karni_cli/commands/mcp.py +20 -0
- karni_cli/commands/migrate.py +23 -0
- karni_cli/commands/new.py +106 -0
- karni_cli/commands/routes.py +20 -0
- karni_cli/commands/rules.py +139 -0
- karni_cli/commands/run_tests.py +38 -0
- karni_cli/commands/schedule.py +40 -0
- karni_cli/commands/seed.py +34 -0
- karni_cli/commands/serve.py +50 -0
- karni_cli/commands/shell.py +28 -0
- karni_cli/commands/sync_roles.py +32 -0
- karni_cli/commands/types.py +72 -0
- karni_cli/commands/upgrade.py +231 -0
- karni_cli/commands/worker.py +63 -0
- karni_cli/config.py +142 -0
- karni_cli/environment.py +29 -0
- karni_cli/errorcodes.py +377 -0
- karni_cli/errordoc.py +227 -0
- karni_cli/generators/__init__.py +67 -0
- karni_cli/generators/apitest.py +206 -0
- karni_cli/generators/app.py +103 -0
- karni_cli/generators/command.py +98 -0
- karni_cli/generators/component.py +115 -0
- karni_cli/generators/core.py +319 -0
- karni_cli/generators/event.py +57 -0
- karni_cli/generators/factory.py +88 -0
- karni_cli/generators/fields.py +343 -0
- karni_cli/generators/introspect.py +263 -0
- karni_cli/generators/listener.py +168 -0
- karni_cli/generators/middleware.py +95 -0
- karni_cli/generators/migration.py +82 -0
- karni_cli/generators/model.py +235 -0
- karni_cli/generators/module.py +255 -0
- karni_cli/generators/naming.py +100 -0
- karni_cli/generators/page.py +373 -0
- karni_cli/generators/pysource.py +304 -0
- karni_cli/generators/schedule.py +174 -0
- karni_cli/generators/seeder.py +86 -0
- karni_cli/generators/serializer.py +165 -0
- karni_cli/generators/service.py +68 -0
- karni_cli/generators/task.py +84 -0
- karni_cli/generators/viewset.py +179 -0
- karni_cli/history.py +59 -0
- karni_cli/local_state.py +73 -0
- karni_cli/main.py +104 -0
- karni_cli/mcp_server.py +103 -0
- karni_cli/project.py +248 -0
- karni_cli/rules_render.py +86 -0
- karni_cli/scaffold.py +197 -0
- karni_cli/settings_edit.py +128 -0
- karni_cli/tips.py +53 -0
- karni_cli/typegen.py +101 -0
- karni_cli/upgrades/__init__.py +28 -0
- karni_cli/upgrades/changelog.py +123 -0
- karni_cli/upgrades/codemod.py +125 -0
- karni_cli/upgrades/engine.py +224 -0
- karni_cli/upgrades/legacy_agent_files.py +138 -0
- karni_cli/upgrades/python.py +670 -0
- karni_cli/upgrades/transforms/rename-export.cjs +96 -0
- karni_cli/upgrades/typescript.py +160 -0
- karni_cli/upgrades/v0_2/__init__.py +1 -0
- karni_cli/upgrades/v0_2/agent_guidance.py +269 -0
- karni_cli/upgrades/v1_0/__init__.py +5 -0
- karni_cli/upgrades/v1_0/all_objects_to_without.py +18 -0
- karni_cli/upgrades/v1_0/all_with_deleted_to_without.py +13 -0
- karni_cli/upgrades/v1_0/deleted_only_to_without_dead.py +13 -0
- karni_cli/upgrades/v1_0/object_permission_classes_to_access.py +16 -0
- karni_cli/upgrades/v1_0/related_lookups_to_optimize.py +15 -0
- karni_cli/upgrades/v1_0/scoped_related_lookups_to_optimize.py +15 -0
- karni_cli/upgrades/v1_0/soft_delete_manager_mixin_to_scoped.py +14 -0
- karni_cli-0.3.1.dist-info/METADATA +191 -0
- karni_cli-0.3.1.dist-info/RECORD +145 -0
- karni_cli-0.3.1.dist-info/WHEEL +4 -0
- karni_cli-0.3.1.dist-info/entry_points.txt +2 -0
- karni_cli-0.3.1.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""``karni make test <Model>`` — the resource's API test: full CRUD surface plus isolation.
|
|
2
|
+
|
|
3
|
+
Writes ``<app>/tests/test_<model>.py`` with a :class:`karni.testing.APITestCase`
|
|
4
|
+
that calls ``assert_crud`` (list, retrieve, create, update, delete, the
|
|
5
|
+
promised denials, pagination, filtering and the OpenAPI contract, in one
|
|
6
|
+
call) and — when the viewset's ``access`` reads through ``Owned(...)``, or
|
|
7
|
+
through ``Granted()`` with a role whose grant is ``own`` / ``related`` —
|
|
8
|
+
``assert_isolated`` (another scope's rows are out of reach). The role is
|
|
9
|
+
``--role``, else the first ``HasRole("...")`` the viewset declares, else for
|
|
10
|
+
``Granted()`` the first role in ``KARNI_ROLES`` (the shipped ``user`` when
|
|
11
|
+
the project declares none) whose grants reach the model, else any signed-in
|
|
12
|
+
user. The model needs a viewset first (``karni make viewset``).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import textwrap
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
|
|
21
|
+
from karni_cli.generators.core import (
|
|
22
|
+
APP_OPTION,
|
|
23
|
+
DRY_RUN_OPTION,
|
|
24
|
+
FORCE_OPTION,
|
|
25
|
+
App,
|
|
26
|
+
Workspace,
|
|
27
|
+
project_workspace,
|
|
28
|
+
resolve_app,
|
|
29
|
+
)
|
|
30
|
+
from karni_cli.generators.introspect import (
|
|
31
|
+
ModelInfo,
|
|
32
|
+
declared_roles,
|
|
33
|
+
grant_scopes,
|
|
34
|
+
granted_reads,
|
|
35
|
+
owned_paths,
|
|
36
|
+
require_model,
|
|
37
|
+
role_names,
|
|
38
|
+
viewset_for,
|
|
39
|
+
viewset_module,
|
|
40
|
+
)
|
|
41
|
+
from karni_cli.generators.naming import require_pascal, snake
|
|
42
|
+
from karni_cli.generators.pysource import Import, docstring, ensure_imports, pystr
|
|
43
|
+
from karni_cli.project import CliError
|
|
44
|
+
|
|
45
|
+
NAME = "test"
|
|
46
|
+
SUMMARY = "Add <app>/tests/test_<model>.py: assert_crud + the isolation check for a resource."
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def render(
|
|
50
|
+
ws: Workspace,
|
|
51
|
+
model: str,
|
|
52
|
+
viewset: str,
|
|
53
|
+
module: str,
|
|
54
|
+
role: str | None,
|
|
55
|
+
isolation: bool,
|
|
56
|
+
note: str | None = None,
|
|
57
|
+
) -> str:
|
|
58
|
+
"""The test module's text (ruff-clean in the project's layout)."""
|
|
59
|
+
rel = "tests/test.py"
|
|
60
|
+
line = ws.line_length
|
|
61
|
+
role_arg = f", {pystr(role)}" if role else ""
|
|
62
|
+
lines = [
|
|
63
|
+
docstring(
|
|
64
|
+
f"The {model} API: CRUD, access" + (" and isolation" if isolation else "") + ".",
|
|
65
|
+
"",
|
|
66
|
+
line,
|
|
67
|
+
),
|
|
68
|
+
"",
|
|
69
|
+
"",
|
|
70
|
+
f"class {model}APITests(APITestCase):",
|
|
71
|
+
" def test_crud(self) -> None:",
|
|
72
|
+
f" self.assert_crud({viewset}{role_arg})",
|
|
73
|
+
]
|
|
74
|
+
if isolation:
|
|
75
|
+
lines += [
|
|
76
|
+
"",
|
|
77
|
+
" def test_owners_cannot_reach_each_others_rows(self) -> None:",
|
|
78
|
+
f" self.assert_isolated({viewset}{role_arg})",
|
|
79
|
+
]
|
|
80
|
+
elif note:
|
|
81
|
+
wrapped = textwrap.wrap(f"No assert_isolated: {note}", width=max(line - 6, 40))
|
|
82
|
+
lines += ["", *(f" # {part}" for part in wrapped)]
|
|
83
|
+
text = "\n".join(lines) + "\n"
|
|
84
|
+
return ensure_imports(
|
|
85
|
+
ws, rel, text, [Import("karni.testing", "APITestCase"), Import(module, viewset)]
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _scoped(scope: str | None) -> bool:
|
|
90
|
+
return scope is not None and (scope == "own" or scope.startswith("related:"))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def no_isolation_reason(ws: Workspace, model: ModelInfo, *, explicit: bool) -> str | None:
|
|
94
|
+
"""Why no ``assert_isolated`` is written, when a reader would expect one; else ``None``.
|
|
95
|
+
|
|
96
|
+
Never silent: a role reaching the rows through a ``custom:`` scope is
|
|
97
|
+
always named (``assert_isolated`` cannot drive one); with the role picked
|
|
98
|
+
automatically, a resource no role partitions is named too. An explicit
|
|
99
|
+
``--role`` whose grant is ``all`` is the caller's choice and says nothing.
|
|
100
|
+
"""
|
|
101
|
+
key = f"{model.app.label}.{model.name.lower()}:view"
|
|
102
|
+
scopes = grant_scopes(declared_roles(ws), key)
|
|
103
|
+
custom = sorted(
|
|
104
|
+
f"{slug!r} ({scope})" for slug, scope in scopes.items() if scope.startswith("custom:")
|
|
105
|
+
)
|
|
106
|
+
if custom:
|
|
107
|
+
return (
|
|
108
|
+
f"{', '.join(custom)} reach {key} through a custom scope, which assert_isolated "
|
|
109
|
+
"cannot drive. For membership, grant 'own' with `class Karni: owner_field = "
|
|
110
|
+
"'<path to the user>'` (e.g. 'workspace__members') and regenerate this test."
|
|
111
|
+
)
|
|
112
|
+
if not explicit and scopes:
|
|
113
|
+
return (
|
|
114
|
+
f"no role's grant on {key} is 'own' or 'related:<key>', so every role that reads "
|
|
115
|
+
f"{model.name} sees every row. If rows belong to someone, declare "
|
|
116
|
+
"`class Karni: owner_field` and grant 'own'."
|
|
117
|
+
)
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def granted_role(ws: Workspace, model: ModelInfo, role: str | None) -> tuple[str | None, bool]:
|
|
122
|
+
"""``(role, isolates)`` for a ``Granted()`` viewset of ``model``.
|
|
123
|
+
|
|
124
|
+
``role`` when given; else the first role in ``KARNI_ROLES`` whose grant
|
|
125
|
+
for the model's ``view`` permission is ``own`` / ``related`` (so the
|
|
126
|
+
isolation check applies), else the first whose grants reach it at all.
|
|
127
|
+
``isolates`` says whether that role's grant partitions the rows.
|
|
128
|
+
"""
|
|
129
|
+
key = f"{model.app.label}.{model.name.lower()}:view"
|
|
130
|
+
scopes = grant_scopes(declared_roles(ws), key)
|
|
131
|
+
if role is not None:
|
|
132
|
+
return role, _scoped(scopes.get(role))
|
|
133
|
+
for slug, scope in scopes.items():
|
|
134
|
+
if _scoped(scope):
|
|
135
|
+
return slug, True
|
|
136
|
+
if scopes:
|
|
137
|
+
return next(iter(scopes)), False
|
|
138
|
+
return None, False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def plan(ws: Workspace, app: App, model_name: str, *, role: str | None = None) -> str:
|
|
142
|
+
"""Plan the test module for ``model_name``; returns its path relative to the project."""
|
|
143
|
+
model = require_model(ws, app, require_pascal(model_name, "Model"))
|
|
144
|
+
found = viewset_for(ws, model.app, model.name)
|
|
145
|
+
if found is None:
|
|
146
|
+
raise CliError(
|
|
147
|
+
f"No viewset routes {model.name} — create it first: karni make viewset "
|
|
148
|
+
f"{model.name} --app {model.app.label} (the test drives the resource's URLs).",
|
|
149
|
+
code="C105",
|
|
150
|
+
)
|
|
151
|
+
path, cls = found
|
|
152
|
+
roles = role_names(cls)
|
|
153
|
+
chosen = role if role is not None else (roles[0] if roles else None)
|
|
154
|
+
owned = bool(owned_paths(cls))
|
|
155
|
+
granted = not owned and granted_reads(cls)
|
|
156
|
+
how = " + owner isolation" if owned else ""
|
|
157
|
+
note: str | None = None
|
|
158
|
+
if granted and chosen is None or granted and role is not None:
|
|
159
|
+
chosen, scoped = granted_role(ws, model, role)
|
|
160
|
+
how = " + grant isolation" if scoped else ""
|
|
161
|
+
if not scoped:
|
|
162
|
+
note = no_isolation_reason(ws, model, explicit=role is not None)
|
|
163
|
+
if chosen is None:
|
|
164
|
+
ws.note(
|
|
165
|
+
f"no role in KARNI_ROLES grants {model.app.label}.{model.name.lower()}:view; "
|
|
166
|
+
"as any signed-in user the test is refused everything — pass --role"
|
|
167
|
+
)
|
|
168
|
+
module = viewset_module(ws, model.app, path)
|
|
169
|
+
tests = model.app.file("tests")
|
|
170
|
+
init = tests / "__init__.py"
|
|
171
|
+
if not ws.exists(init):
|
|
172
|
+
ws.create(init, "")
|
|
173
|
+
target = tests / f"test_{snake(model.name)}.py"
|
|
174
|
+
ws.create(target, render(ws, model.name, cls.name, module, chosen, bool(how), note))
|
|
175
|
+
who = f"role {chosen!r}" if chosen else "any signed-in user"
|
|
176
|
+
ws.note(f"test: {cls.name} as {who}{how}")
|
|
177
|
+
if note:
|
|
178
|
+
ws.note(f"isolation: NOT generated — {note}")
|
|
179
|
+
ws.note(f"run: karni test {ws.rel(target)}")
|
|
180
|
+
return ws.rel(target)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def command(
|
|
184
|
+
model: str = typer.Argument(..., help="Model class name (e.g. Event); it needs a viewset."),
|
|
185
|
+
role: str | None = typer.Option(
|
|
186
|
+
None,
|
|
187
|
+
"--role",
|
|
188
|
+
help="The role the test acts as. Default: the first HasRole the viewset declares; "
|
|
189
|
+
"for Granted() the first KARNI_ROLES role whose grant reaches the model (own/related "
|
|
190
|
+
"first); else any signed-in user.",
|
|
191
|
+
),
|
|
192
|
+
app: str | None = APP_OPTION,
|
|
193
|
+
force: bool = FORCE_OPTION,
|
|
194
|
+
dry_run: bool = DRY_RUN_OPTION,
|
|
195
|
+
) -> None:
|
|
196
|
+
"""Add APP/tests/test_<model>.py — the resource's whole API surface in a few lines.
|
|
197
|
+
|
|
198
|
+
One APITestCase: `assert_crud` checks list, retrieve, create, update,
|
|
199
|
+
delete, the denials the access declaration promises, pagination,
|
|
200
|
+
filtering and the OpenAPI contract; `assert_isolated` (when reads are
|
|
201
|
+
Owned, or Granted() with an own/related grant) checks another scope's
|
|
202
|
+
rows are out of reach on every surface. Run it with `karni test`.
|
|
203
|
+
"""
|
|
204
|
+
ws = project_workspace(force)
|
|
205
|
+
plan(ws, resolve_app(ws, app), model, role=role)
|
|
206
|
+
ws.commit(dry_run=dry_run)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""``karni make app <name>`` — a local Django app, wired into ``# karni:apps``.
|
|
2
|
+
|
|
3
|
+
Replaces ``manage.py startapp``: only ``models.py`` and ``viewsets.py`` (plus
|
|
4
|
+
``apps.py``, ``migrations/`` and ``tests/``) — no admin.py, serializers.py or
|
|
5
|
+
urls.py, which Karni derives.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from karni_cli.generators.core import (
|
|
15
|
+
DRY_RUN_OPTION,
|
|
16
|
+
SETTINGS,
|
|
17
|
+
Workspace,
|
|
18
|
+
project_workspace,
|
|
19
|
+
require_settings,
|
|
20
|
+
)
|
|
21
|
+
from karni_cli.generators.naming import pascal, require_snake
|
|
22
|
+
from karni_cli.generators.pysource import docstring
|
|
23
|
+
from karni_cli.project import CliError
|
|
24
|
+
from karni_cli.settings_edit import MARKER, add_entry
|
|
25
|
+
|
|
26
|
+
NAME = "app"
|
|
27
|
+
SUMMARY = "Create a local Django app (models.py, viewsets.py) wired into # karni:apps."
|
|
28
|
+
|
|
29
|
+
#: Top-level names a project already uses, or that would shadow a dependency.
|
|
30
|
+
_TAKEN = frozenset(
|
|
31
|
+
{"config", "frontend", "tests", "modules", "manage", "karni", "django", "rest_framework"}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def plan(ws: Workspace, name: str) -> str:
|
|
36
|
+
"""Plan the app ``name``; returns its label."""
|
|
37
|
+
require_snake(name, "App name")
|
|
38
|
+
if name in _TAKEN or name in sys.stdlib_module_names:
|
|
39
|
+
raise CliError(
|
|
40
|
+
f"App name {name!r} would shadow an existing module — choose another.", code="C103"
|
|
41
|
+
)
|
|
42
|
+
if name.startswith("karni_"):
|
|
43
|
+
raise CliError(
|
|
44
|
+
f"App name {name!r}: karni_* names belong to Karni modules — use "
|
|
45
|
+
"`karni make module` for a reusable module, or drop the prefix.",
|
|
46
|
+
code="C103",
|
|
47
|
+
)
|
|
48
|
+
settings = require_settings(ws)
|
|
49
|
+
root = ws.path(name)
|
|
50
|
+
if ws.exists(ws.path(f"{name}.py")):
|
|
51
|
+
raise CliError(
|
|
52
|
+
f"{name}.py exists in the project root — an app {name}/ would clash.", code="C103"
|
|
53
|
+
)
|
|
54
|
+
line = ws.line_length
|
|
55
|
+
files = {
|
|
56
|
+
"__init__.py": "",
|
|
57
|
+
"apps.py": (
|
|
58
|
+
"from django.apps import AppConfig\n\n\n"
|
|
59
|
+
f"class {pascal(name)}Config(AppConfig):\n"
|
|
60
|
+
' default_auto_field = "django.db.models.BigAutoField"\n'
|
|
61
|
+
f' name = "{name}"\n'
|
|
62
|
+
),
|
|
63
|
+
"models.py": docstring(
|
|
64
|
+
f"{name} models, each on karni.models.BaseModel: `karni make model <Name>`.", "", line
|
|
65
|
+
)
|
|
66
|
+
+ "\n",
|
|
67
|
+
"viewsets.py": docstring(
|
|
68
|
+
f"{name} API: BaseViewSet subclasses, auto-routed: `karni make viewset <Model>`.",
|
|
69
|
+
"",
|
|
70
|
+
line,
|
|
71
|
+
)
|
|
72
|
+
+ "\n",
|
|
73
|
+
"migrations/__init__.py": "",
|
|
74
|
+
"tests/__init__.py": "",
|
|
75
|
+
}
|
|
76
|
+
ws.create_skeleton({root / rel: content for rel, content in files.items()})
|
|
77
|
+
updated = add_entry(
|
|
78
|
+
settings, name, setting="INSTALLED_APPS", marker=MARKER, filename=SETTINGS.as_posix()
|
|
79
|
+
)
|
|
80
|
+
if updated is not None:
|
|
81
|
+
ws.write(SETTINGS, updated)
|
|
82
|
+
ws.note(f"next: karni make model <Name> [field:type ...] --app {name}")
|
|
83
|
+
return name
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def command(
|
|
87
|
+
name: str = typer.Argument(..., help="App label, snake_case (e.g. events)."),
|
|
88
|
+
force: bool = typer.Option(
|
|
89
|
+
False, "--force", help="Create only the missing files of an existing app (keeps the rest)."
|
|
90
|
+
),
|
|
91
|
+
dry_run: bool = DRY_RUN_OPTION,
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Create a local app — replaces `manage.py startapp`.
|
|
94
|
+
|
|
95
|
+
Writes NAME/{__init__,apps,models,viewsets}.py, NAME/migrations/ and
|
|
96
|
+
NAME/tests/, and adds NAME to the `# karni:apps` block of
|
|
97
|
+
config/settings.py. No admin.py, serializers.py or urls.py: Karni derives
|
|
98
|
+
them. An existing app is never truncated: --force only fills in missing
|
|
99
|
+
files.
|
|
100
|
+
"""
|
|
101
|
+
ws = project_workspace(force)
|
|
102
|
+
plan(ws, name)
|
|
103
|
+
ws.commit(dry_run=dry_run)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""``karni make command <name>`` — a management command in ``<app>/management/commands/``.
|
|
2
|
+
|
|
3
|
+
Run it with ``karni manage <name>`` (through the project interpreter).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from karni_cli.generators.core import (
|
|
11
|
+
APP_OPTION,
|
|
12
|
+
DRY_RUN_OPTION,
|
|
13
|
+
FORCE_OPTION,
|
|
14
|
+
App,
|
|
15
|
+
Workspace,
|
|
16
|
+
project_workspace,
|
|
17
|
+
resolve_app,
|
|
18
|
+
)
|
|
19
|
+
from karni_cli.generators.naming import humanize, require_snake
|
|
20
|
+
from karni_cli.generators.pysource import pystr
|
|
21
|
+
from karni_cli.project import CliError
|
|
22
|
+
|
|
23
|
+
NAME = "command"
|
|
24
|
+
SUMMARY = "Add a management command (<app>/management/commands/<name>.py)."
|
|
25
|
+
|
|
26
|
+
#: Django's and Karni's own commands: an app command of the same name is shadowed.
|
|
27
|
+
BUILTIN = frozenset(
|
|
28
|
+
{
|
|
29
|
+
"changepassword",
|
|
30
|
+
"check",
|
|
31
|
+
"clearsessions",
|
|
32
|
+
"collectstatic",
|
|
33
|
+
"createsuperuser",
|
|
34
|
+
"dbshell",
|
|
35
|
+
"diffsettings",
|
|
36
|
+
"dumpdata",
|
|
37
|
+
"flush",
|
|
38
|
+
"inspectdb",
|
|
39
|
+
"karni_check",
|
|
40
|
+
"karni_context",
|
|
41
|
+
"karni_routes",
|
|
42
|
+
"karni_schedules",
|
|
43
|
+
"loaddata",
|
|
44
|
+
"makemigrations",
|
|
45
|
+
"migrate",
|
|
46
|
+
"runserver",
|
|
47
|
+
"sendtestemail",
|
|
48
|
+
"shell",
|
|
49
|
+
"showmigrations",
|
|
50
|
+
"spectacular",
|
|
51
|
+
"sqlflush",
|
|
52
|
+
"sqlmigrate",
|
|
53
|
+
"sqlsequencereset",
|
|
54
|
+
"squashmigrations",
|
|
55
|
+
"startapp",
|
|
56
|
+
"startproject",
|
|
57
|
+
"test",
|
|
58
|
+
"testserver",
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def plan(ws: Workspace, app: App, name: str) -> None:
|
|
64
|
+
require_snake(name, "Command name")
|
|
65
|
+
if name in BUILTIN:
|
|
66
|
+
raise CliError(
|
|
67
|
+
f"`{name}` is a Django/Karni command — an app command of that name would be "
|
|
68
|
+
"shadowed. Choose another name.",
|
|
69
|
+
code="C103",
|
|
70
|
+
)
|
|
71
|
+
for package in ("management", "management/commands"):
|
|
72
|
+
init = app.file(*package.split("/"), "__init__.py")
|
|
73
|
+
if not ws.exists(init):
|
|
74
|
+
ws.create(init, "")
|
|
75
|
+
help_text = pystr(f"{humanize(name)}.")
|
|
76
|
+
done = pystr(f"{name}: done")
|
|
77
|
+
ws.create(
|
|
78
|
+
app.file("management", "commands", f"{name}.py"),
|
|
79
|
+
"from typing import Any\n\n"
|
|
80
|
+
"from django.core.management.base import BaseCommand\n\n\n"
|
|
81
|
+
"class Command(BaseCommand):\n"
|
|
82
|
+
f" help = {help_text}\n\n"
|
|
83
|
+
" def handle(self, *args: Any, **options: Any) -> None:\n"
|
|
84
|
+
f" self.stdout.write(self.style.SUCCESS({done}))\n",
|
|
85
|
+
)
|
|
86
|
+
ws.note(f"run: karni manage {name}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def command(
|
|
90
|
+
name: str = typer.Argument(..., help="Command name, snake_case (e.g. send_reminders)."),
|
|
91
|
+
app: str | None = APP_OPTION,
|
|
92
|
+
force: bool = FORCE_OPTION,
|
|
93
|
+
dry_run: bool = DRY_RUN_OPTION,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Add a management command NAME to APP (run it with `karni manage NAME`)."""
|
|
96
|
+
ws = project_workspace(force)
|
|
97
|
+
plan(ws, resolve_app(ws, app), name)
|
|
98
|
+
ws.commit(dry_run=dry_run)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""``karni make component <Name>`` — a React component that claims a role.
|
|
2
|
+
|
|
3
|
+
Every component registers its semantic role (``registerComponentRole`` from
|
|
4
|
+
@karni/ui), so two components doing the same job fail loudly instead of
|
|
5
|
+
drifting apart (K303: extend, never fork). A role @karni/ui or the project
|
|
6
|
+
already claims is refused; replacing one on purpose takes ``--replace-reason``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
from karni_cli.commands.context import find_manifest
|
|
17
|
+
from karni_cli.generators.core import (
|
|
18
|
+
DRY_RUN_OPTION,
|
|
19
|
+
FORCE_OPTION,
|
|
20
|
+
FRONTEND,
|
|
21
|
+
Workspace,
|
|
22
|
+
project_workspace,
|
|
23
|
+
require_frontend,
|
|
24
|
+
)
|
|
25
|
+
from karni_cli.generators.naming import kebab, require_kebab, require_pascal
|
|
26
|
+
from karni_cli.project import CliError
|
|
27
|
+
|
|
28
|
+
NAME = "component"
|
|
29
|
+
SUMMARY = "Add a React component that claims a role (registerComponentRole)."
|
|
30
|
+
|
|
31
|
+
COMPONENTS = FRONTEND / "src" / "components"
|
|
32
|
+
_REGISTERED = re.compile(r"""registerComponentRole\(\s*["']([^"']+)["']\s*,\s*["']([^"']+)["']""")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _claimed(ws: Workspace, skip: str) -> dict[str, str]:
|
|
36
|
+
"""``role -> owner`` from the @karni/ui manifest and the project's own sources."""
|
|
37
|
+
claimed: dict[str, str] = {}
|
|
38
|
+
manifest = find_manifest(ws.root, None)
|
|
39
|
+
if manifest is not None:
|
|
40
|
+
try:
|
|
41
|
+
roles = json.loads(manifest.read_text()).get("roles") or {}
|
|
42
|
+
except (OSError, json.JSONDecodeError):
|
|
43
|
+
roles = {}
|
|
44
|
+
claimed.update({role: f"@karni/ui's {owner}" for role, owner in roles.items()})
|
|
45
|
+
else:
|
|
46
|
+
ws.note("note: @karni/ui is not installed — its roles could not be checked.")
|
|
47
|
+
src = ws.path(FRONTEND / "src")
|
|
48
|
+
if src.is_dir():
|
|
49
|
+
for path in sorted(src.rglob("*.ts*")):
|
|
50
|
+
if "node_modules" in path.parts or ws.rel(path) == skip:
|
|
51
|
+
continue
|
|
52
|
+
text = ws.read(path) or ""
|
|
53
|
+
for role, owner in _REGISTERED.findall(text):
|
|
54
|
+
claimed[role] = f"{owner} ({ws.rel(path)})"
|
|
55
|
+
return claimed
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def plan(ws: Workspace, name: str, *, role: str | None, replace_reason: str | None) -> str:
|
|
59
|
+
require_frontend(ws)
|
|
60
|
+
component = require_pascal(name, "Component name")
|
|
61
|
+
role = require_kebab(role or kebab(component), "--role")
|
|
62
|
+
target = COMPONENTS / f"{component}.tsx"
|
|
63
|
+
owner = _claimed(ws, ws.rel(ws.path(target))).get(role)
|
|
64
|
+
options = ""
|
|
65
|
+
if owner is not None:
|
|
66
|
+
if not replace_reason:
|
|
67
|
+
raise CliError(
|
|
68
|
+
f"Role {role!r} is already claimed by {owner}. Compose or extend it instead "
|
|
69
|
+
"(K303), pick another --role, or replace it on purpose with "
|
|
70
|
+
'--replace-reason "<why>".',
|
|
71
|
+
code="C107",
|
|
72
|
+
)
|
|
73
|
+
reason = json.dumps(replace_reason)
|
|
74
|
+
options = f", {{ replace: true, reason: {reason} }}"
|
|
75
|
+
elif replace_reason:
|
|
76
|
+
raise CliError(f"--replace-reason: no component claims role {role!r} yet.", code="C106")
|
|
77
|
+
source = (
|
|
78
|
+
'import type { ReactNode } from "react";\n'
|
|
79
|
+
'import { registerComponentRole } from "@karni/ui";\n'
|
|
80
|
+
"\n"
|
|
81
|
+
f'registerComponentRole("{role}", "{component}"{options});\n'
|
|
82
|
+
"\n"
|
|
83
|
+
f"export interface {component}Props {{\n"
|
|
84
|
+
" children?: ReactNode;\n"
|
|
85
|
+
"}\n"
|
|
86
|
+
"\n"
|
|
87
|
+
f"export function {component}({{ children }}: {component}Props) {{\n"
|
|
88
|
+
" return <div>{children}</div>;\n"
|
|
89
|
+
"}\n"
|
|
90
|
+
)
|
|
91
|
+
ws.create(target, source)
|
|
92
|
+
ws.note(f"role: {role} -> {component}")
|
|
93
|
+
return role
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def command(
|
|
97
|
+
name: str = typer.Argument(..., help="Component name, PascalCase (e.g. StatusBadge)."),
|
|
98
|
+
role: str | None = typer.Option(
|
|
99
|
+
None, "--role", help="Semantic role it claims (default: kebab-case of NAME)."
|
|
100
|
+
),
|
|
101
|
+
replace_reason: str | None = typer.Option(
|
|
102
|
+
None, "--replace-reason", help="Take over a role another component claims, saying why."
|
|
103
|
+
),
|
|
104
|
+
force: bool = FORCE_OPTION,
|
|
105
|
+
dry_run: bool = DRY_RUN_OPTION,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Add frontend/src/components/NAME.tsx, registered with registerComponentRole.
|
|
108
|
+
|
|
109
|
+
A role already claimed by @karni/ui (its manifest) or by another project
|
|
110
|
+
component is refused — compose that component instead, or pass
|
|
111
|
+
--replace-reason to replace it on purpose.
|
|
112
|
+
"""
|
|
113
|
+
ws = project_workspace(force)
|
|
114
|
+
plan(ws, name, role=role, replace_reason=replace_reason)
|
|
115
|
+
ws.commit(dry_run=dry_run)
|