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,168 @@
|
|
|
1
|
+
"""``karni make listener <name> --on Model.created|updated|deleted | <event>`` — a receiver.
|
|
2
|
+
|
|
3
|
+
Writes to ``<app>/listeners.py``, which Karni autodiscovers at startup
|
|
4
|
+
(:mod:`karni.events`) — no ``AppConfig.ready()`` wiring.
|
|
5
|
+
|
|
6
|
+
* ``--on Event.created`` (``updated``/``deleted``; ``shop.Event.created``
|
|
7
|
+
for another app's model) → ``@on_created(Event)``; soft deletes count as
|
|
8
|
+
deleted. ``--queued`` → ``queued=True``: the work runs in a Celery worker
|
|
9
|
+
after the transaction commits.
|
|
10
|
+
* ``--on order_shipped`` (or ``shop.events.order_shipped``) → a
|
|
11
|
+
``@receiver`` for a custom event from ``events.py``. ``--queued`` creates
|
|
12
|
+
the task ``<app>.tasks.<name>`` and queues it on commit.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
|
|
21
|
+
from karni_cli.generators import task as task_gen
|
|
22
|
+
from karni_cli.generators.core import (
|
|
23
|
+
APP_OPTION,
|
|
24
|
+
DRY_RUN_OPTION,
|
|
25
|
+
FORCE_OPTION,
|
|
26
|
+
App,
|
|
27
|
+
Workspace,
|
|
28
|
+
local_apps,
|
|
29
|
+
project_workspace,
|
|
30
|
+
resolve_app,
|
|
31
|
+
)
|
|
32
|
+
from karni_cli.generators.introspect import (
|
|
33
|
+
find_model,
|
|
34
|
+
models_in,
|
|
35
|
+
top_level_assignments,
|
|
36
|
+
top_level_functions,
|
|
37
|
+
)
|
|
38
|
+
from karni_cli.generators.naming import require_snake, snake
|
|
39
|
+
from karni_cli.generators.pysource import Import, docstring, ensure_imports, replace_or_append
|
|
40
|
+
from karni_cli.project import CliError
|
|
41
|
+
|
|
42
|
+
NAME = "listener"
|
|
43
|
+
SUMMARY = "Add a listener to <app>/listeners.py: --on Model.created|updated|deleted or an event."
|
|
44
|
+
|
|
45
|
+
_LIFECYCLE = re.compile(
|
|
46
|
+
r"^(?:(?P<app>[a-z_]\w*)\.)?(?P<model>[A-Z]\w*)\.(?P<event>created|updated|deleted)$"
|
|
47
|
+
)
|
|
48
|
+
_EVENT = re.compile(r"^(?:(?P<module>[a-z_][\w.]*)\.events\.)?(?P<event>[a-z_]\w*)$")
|
|
49
|
+
_ON_HELP = (
|
|
50
|
+
"Model.created|Model.updated|Model.deleted (app_label.Model.event for an installed "
|
|
51
|
+
"app's model, e.g. karni_payments.Payment.updated), or a custom event (order_shipped)"
|
|
52
|
+
)
|
|
53
|
+
#: The user model's label when no local app defines ``User`` (Karni's default).
|
|
54
|
+
_USER_MODEL = "karni_accounts.User"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _local_app(ws: Workspace, label: str) -> App | None:
|
|
58
|
+
for other in local_apps(ws):
|
|
59
|
+
if label in (other.label, other.module):
|
|
60
|
+
return other
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _app_named(ws: Workspace, label: str) -> App:
|
|
65
|
+
found = _local_app(ws, label)
|
|
66
|
+
if found is None:
|
|
67
|
+
raise CliError(f"--on: no local app {label!r}.", code="C102")
|
|
68
|
+
return found
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def plan(ws: Workspace, app: App, name: str, *, on: str, queued: bool = False) -> None:
|
|
72
|
+
require_snake(name, "Listener name")
|
|
73
|
+
line = ws.line_length
|
|
74
|
+
lifecycle = _LIFECYCLE.match(on)
|
|
75
|
+
custom = None if lifecycle else _EVENT.match(on)
|
|
76
|
+
imports: list[Import] = []
|
|
77
|
+
if lifecycle:
|
|
78
|
+
label, model_name, event = lifecycle["app"], lifecycle["model"], lifecycle["event"]
|
|
79
|
+
owner = _local_app(ws, label) if label else app
|
|
80
|
+
model = None
|
|
81
|
+
if owner is not None:
|
|
82
|
+
model = (
|
|
83
|
+
models_in(ws, owner).get(model_name) if label else find_model(ws, owner, model_name)
|
|
84
|
+
)
|
|
85
|
+
# Not a local model: an installed app's (karni_payments.Payment) or the user
|
|
86
|
+
# model. karni.events takes the lazy "app_label.Model" string, so no import.
|
|
87
|
+
lazy = f"{label}.{model_name}" if label else (_USER_MODEL if model_name == "User" else "")
|
|
88
|
+
if model is None and not lazy:
|
|
89
|
+
raise CliError(
|
|
90
|
+
f"--on {on}: no model {model_name} — create it first: karni make model "
|
|
91
|
+
f"{model_name}, or name an installed app's model as app_label.{model_name}.{event}.",
|
|
92
|
+
code="C105",
|
|
93
|
+
)
|
|
94
|
+
target = model.name if model is not None else f'"{lazy}"'
|
|
95
|
+
annotation = model.name if model is not None else "Any"
|
|
96
|
+
decorator = f"@on_{event}({target}{', queued=True' if queued else ''})"
|
|
97
|
+
param = snake(model_name)
|
|
98
|
+
if param == name:
|
|
99
|
+
param = "instance"
|
|
100
|
+
where = (
|
|
101
|
+
"in a Celery worker, after commit" if queued else "inline, in the saving transaction"
|
|
102
|
+
)
|
|
103
|
+
lines = [
|
|
104
|
+
decorator,
|
|
105
|
+
f"def {name}({param}: {annotation}) -> None:",
|
|
106
|
+
docstring(f"Runs after {model_name} rows are {event} — {where}.", " ", line),
|
|
107
|
+
]
|
|
108
|
+
imports.append(Import("karni.events", f"on_{event}"))
|
|
109
|
+
imports.append(
|
|
110
|
+
Import(model.import_module, model.name)
|
|
111
|
+
if model is not None
|
|
112
|
+
else Import("typing", "Any")
|
|
113
|
+
)
|
|
114
|
+
elif custom:
|
|
115
|
+
source = _app_named(ws, custom["module"]) if custom["module"] else app
|
|
116
|
+
event = custom["event"]
|
|
117
|
+
events_path = source.file("events.py")
|
|
118
|
+
if event not in top_level_assignments(ws, events_path):
|
|
119
|
+
raise CliError(
|
|
120
|
+
f"--on {on}: no event {event} in {ws.rel(events_path)} — "
|
|
121
|
+
f"karni make event {event} --app {source.label}",
|
|
122
|
+
code="C105",
|
|
123
|
+
)
|
|
124
|
+
lines = [f"@receiver({event})", f"def {name}(sender: Any, **kwargs: Any) -> None:"]
|
|
125
|
+
imports += [
|
|
126
|
+
Import("typing", "Any"),
|
|
127
|
+
Import("django.dispatch", "receiver"),
|
|
128
|
+
Import(f"{source.module}.events", event),
|
|
129
|
+
]
|
|
130
|
+
if queued:
|
|
131
|
+
if name not in top_level_functions(ws, app.file("tasks.py")):
|
|
132
|
+
task_gen.plan(ws, app, name, announce=False)
|
|
133
|
+
lines.append(
|
|
134
|
+
docstring(f"Queues tasks.{name} once the transaction commits.", " ", line)
|
|
135
|
+
)
|
|
136
|
+
lines.append(f" transaction.on_commit(tasks.{name}.delay)")
|
|
137
|
+
imports += [Import("django.db", "transaction"), Import(app.module, "tasks")]
|
|
138
|
+
else:
|
|
139
|
+
lines.append(
|
|
140
|
+
docstring(f"Runs when {event} is sent — inline, in the sender.", " ", line)
|
|
141
|
+
)
|
|
142
|
+
else:
|
|
143
|
+
raise CliError(f"--on {on!r}: expected {_ON_HELP}.", code="C106")
|
|
144
|
+
path = app.file("listeners.py")
|
|
145
|
+
rel = ws.rel(path)
|
|
146
|
+
text = ensure_imports(ws, rel, ws.read(path) or "", imports)
|
|
147
|
+
ws.write(path, replace_or_append(ws, rel, text, name, "\n".join(lines)))
|
|
148
|
+
ws.note(f"listener: {app.module}.listeners.{name} on {on} (autodiscovered at startup)")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def command(
|
|
152
|
+
name: str = typer.Argument(..., help="Listener function name, snake_case."),
|
|
153
|
+
on: str = typer.Option(..., "--on", help=_ON_HELP + "."),
|
|
154
|
+
queued: bool = typer.Option(
|
|
155
|
+
False, "--queued", help="Run the work in a Celery worker after the transaction commits."
|
|
156
|
+
),
|
|
157
|
+
app: str | None = APP_OPTION,
|
|
158
|
+
force: bool = FORCE_OPTION,
|
|
159
|
+
dry_run: bool = DRY_RUN_OPTION,
|
|
160
|
+
) -> None:
|
|
161
|
+
"""Add a listener to APP/listeners.py (autodiscovered — no AppConfig.ready wiring).
|
|
162
|
+
|
|
163
|
+
--on Event.created|updated|deleted uses karni.events (soft deletes count
|
|
164
|
+
as deleted); --on order_shipped receives a custom event from events.py.
|
|
165
|
+
"""
|
|
166
|
+
ws = project_workspace(force)
|
|
167
|
+
plan(ws, resolve_app(ws, app), name, on=on, queued=queued)
|
|
168
|
+
ws.commit(dry_run=dry_run)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""``karni make middleware <Name>`` — a middleware class, wired into settings.
|
|
2
|
+
|
|
3
|
+
The class goes to ``<app>/middleware.py``; its dotted path is added to the
|
|
4
|
+
``# karni:middleware`` block of config/settings.py (``MIDDLEWARE += [...]``,
|
|
5
|
+
so it runs after Karni's stack). A settings file without that block gets the
|
|
6
|
+
exact line to add instead.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from karni_cli.generators.core import (
|
|
14
|
+
APP_OPTION,
|
|
15
|
+
DRY_RUN_OPTION,
|
|
16
|
+
FORCE_OPTION,
|
|
17
|
+
SETTINGS,
|
|
18
|
+
App,
|
|
19
|
+
Workspace,
|
|
20
|
+
project_workspace,
|
|
21
|
+
resolve_app,
|
|
22
|
+
)
|
|
23
|
+
from karni_cli.generators.naming import require_pascal
|
|
24
|
+
from karni_cli.generators.pysource import Import, ensure_imports, replace_or_append
|
|
25
|
+
from karni_cli.project import CliError
|
|
26
|
+
from karni_cli.settings_edit import MIDDLEWARE_MARKER, add_entry
|
|
27
|
+
|
|
28
|
+
NAME = "middleware"
|
|
29
|
+
SUMMARY = "Add a middleware class to <app>/middleware.py, wired via # karni:middleware."
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def plan(ws: Workspace, app: App, name: str) -> str:
|
|
33
|
+
base = require_pascal(name, "Middleware name")
|
|
34
|
+
class_name = base if base.endswith("Middleware") else f"{base}Middleware"
|
|
35
|
+
path = app.file("middleware.py")
|
|
36
|
+
rel = ws.rel(path)
|
|
37
|
+
block = "\n".join(
|
|
38
|
+
[
|
|
39
|
+
f"class {class_name}:",
|
|
40
|
+
" def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:",
|
|
41
|
+
" self.get_response = get_response",
|
|
42
|
+
"",
|
|
43
|
+
" def __call__(self, request: HttpRequest) -> HttpResponse:",
|
|
44
|
+
" # Runs before the view (and the middleware after this one).",
|
|
45
|
+
" response = self.get_response(request)",
|
|
46
|
+
" # Runs after the view.",
|
|
47
|
+
" return response",
|
|
48
|
+
]
|
|
49
|
+
)
|
|
50
|
+
imports = [
|
|
51
|
+
Import("collections.abc", "Callable"),
|
|
52
|
+
Import("django.http", "HttpRequest"),
|
|
53
|
+
Import("django.http", "HttpResponse"),
|
|
54
|
+
]
|
|
55
|
+
text = ensure_imports(ws, rel, ws.read(path) or "", imports)
|
|
56
|
+
ws.write(path, replace_or_append(ws, rel, text, class_name, block))
|
|
57
|
+
dotted = f"{app.module}.middleware.{class_name}"
|
|
58
|
+
settings = ws.read(SETTINGS)
|
|
59
|
+
try:
|
|
60
|
+
if settings is None:
|
|
61
|
+
raise CliError(f"{SETTINGS.as_posix()} not found", code="C001")
|
|
62
|
+
updated = add_entry(
|
|
63
|
+
settings,
|
|
64
|
+
dotted,
|
|
65
|
+
setting="MIDDLEWARE",
|
|
66
|
+
marker=MIDDLEWARE_MARKER,
|
|
67
|
+
filename=SETTINGS.as_posix(),
|
|
68
|
+
)
|
|
69
|
+
except CliError:
|
|
70
|
+
ws.note(
|
|
71
|
+
f"wire it yourself: add {dotted!r} to MIDDLEWARE in {SETTINGS.as_posix()} "
|
|
72
|
+
f"(projects scaffolded before `{MIDDLEWARE_MARKER}` have no marker block)."
|
|
73
|
+
)
|
|
74
|
+
else:
|
|
75
|
+
if updated is not None:
|
|
76
|
+
ws.write(SETTINGS, updated)
|
|
77
|
+
ws.note(f"middleware: {dotted} (last in MIDDLEWARE — move it if order matters)")
|
|
78
|
+
return dotted
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def command(
|
|
82
|
+
name: str = typer.Argument(..., help="Class name, PascalCase (Middleware suffix added)."),
|
|
83
|
+
app: str | None = APP_OPTION,
|
|
84
|
+
force: bool = FORCE_OPTION,
|
|
85
|
+
dry_run: bool = DRY_RUN_OPTION,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Add a middleware class to APP/middleware.py and wire it into MIDDLEWARE.
|
|
88
|
+
|
|
89
|
+
Wiring goes through the `# karni:middleware` block of config/settings.py
|
|
90
|
+
(appended after Karni's middleware); without that block the command
|
|
91
|
+
prints the line to add.
|
|
92
|
+
"""
|
|
93
|
+
ws = project_workspace(force)
|
|
94
|
+
plan(ws, resolve_app(ws, app), name)
|
|
95
|
+
ws.commit(dry_run=dry_run)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""``karni make migration [name]`` — ``manage.py makemigrations`` through the project interpreter.
|
|
2
|
+
|
|
3
|
+
The only generator that needs Django: it runs the project's own
|
|
4
|
+
``makemigrations`` (``.venv``, else the running compose service — see
|
|
5
|
+
:mod:`karni_cli.project`), streaming its output and exit code.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from karni_cli import project as project_mod
|
|
15
|
+
from karni_cli.generators.core import APP_OPTION, DRY_RUN_OPTION, Workspace, resolve_app
|
|
16
|
+
from karni_cli.project import require_project
|
|
17
|
+
|
|
18
|
+
NAME = "migration"
|
|
19
|
+
SUMMARY = "Create migrations: wraps manage.py makemigrations [app] [--name] [--empty]."
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def makemigrations_args(
|
|
23
|
+
app_label: str | None, name: str | None, *, empty: bool, dry_run: bool
|
|
24
|
+
) -> list[str]:
|
|
25
|
+
args = ["makemigrations"]
|
|
26
|
+
if app_label:
|
|
27
|
+
args.append(app_label)
|
|
28
|
+
if name:
|
|
29
|
+
args += ["--name", name]
|
|
30
|
+
if empty:
|
|
31
|
+
args.append("--empty")
|
|
32
|
+
if dry_run:
|
|
33
|
+
args.append("--dry-run")
|
|
34
|
+
return args
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def run(
|
|
38
|
+
project: Path,
|
|
39
|
+
app_label: str | None,
|
|
40
|
+
name: str | None = None,
|
|
41
|
+
*,
|
|
42
|
+
empty: bool = False,
|
|
43
|
+
dry_run: bool = False,
|
|
44
|
+
docker: bool | None = None,
|
|
45
|
+
service: str = "django",
|
|
46
|
+
) -> int:
|
|
47
|
+
"""Run makemigrations in the project environment; returns its exit code."""
|
|
48
|
+
proc = project_mod.run_manage(
|
|
49
|
+
project,
|
|
50
|
+
makemigrations_args(app_label, name, empty=empty, dry_run=dry_run),
|
|
51
|
+
docker=docker,
|
|
52
|
+
service=service,
|
|
53
|
+
capture=False,
|
|
54
|
+
)
|
|
55
|
+
return proc.returncode
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def command(
|
|
59
|
+
name: str | None = typer.Argument(
|
|
60
|
+
None, help="Migration name (makemigrations --name), e.g. add_event_capacity."
|
|
61
|
+
),
|
|
62
|
+
empty: bool = typer.Option(
|
|
63
|
+
False, "--empty", help="An empty migration to fill in (data migrations); needs one app."
|
|
64
|
+
),
|
|
65
|
+
app: str | None = APP_OPTION,
|
|
66
|
+
dry_run: bool = DRY_RUN_OPTION,
|
|
67
|
+
docker: bool | None = typer.Option(None, "--docker/--no-docker"),
|
|
68
|
+
service: str = typer.Option("django", "--service", envvar="KARNI_COMPOSE_SERVICE"),
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Create migrations — wraps `manage.py makemigrations [APP] [--name NAME] [--empty]`.
|
|
71
|
+
|
|
72
|
+
Without --app every app with model changes gets a migration; --empty
|
|
73
|
+
(a data migration) targets one app: --app, else the only local app.
|
|
74
|
+
--dry-run is makemigrations' own (prints, writes nothing).
|
|
75
|
+
"""
|
|
76
|
+
project = require_project()
|
|
77
|
+
label = None
|
|
78
|
+
if app is not None or empty:
|
|
79
|
+
label = resolve_app(Workspace(project), app).label
|
|
80
|
+
raise typer.Exit(
|
|
81
|
+
run(project, label, name, empty=empty, dry_run=dry_run, docker=docker, service=service)
|
|
82
|
+
)
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""``karni make model <Name> [field:type ...]`` — a model on BaseModel.
|
|
2
|
+
|
|
3
|
+
The field DSL is documented in :mod:`karni_cli.generators.fields`. ``--with
|
|
4
|
+
viewset,page,test,factory,seeder`` chains the other generators in the same
|
|
5
|
+
run: a viewset (``--access``/``--search`` apply), the list, detail and form
|
|
6
|
+
pages for the resource (linked to each other), its API test, an explicit
|
|
7
|
+
factory and a seeder. ``-a/--all`` chains viewset, page and test — factories
|
|
8
|
+
and seeders are overrides of what Karni already generates, so they are only
|
|
9
|
+
written when asked for.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
from karni_cli.generators import apitest as test_gen
|
|
17
|
+
from karni_cli.generators import factory as factory_gen
|
|
18
|
+
from karni_cli.generators import migration as migration_gen
|
|
19
|
+
from karni_cli.generators import page as page_gen
|
|
20
|
+
from karni_cli.generators import seeder as seeder_gen
|
|
21
|
+
from karni_cli.generators import viewset as viewset_gen
|
|
22
|
+
from karni_cli.generators.core import (
|
|
23
|
+
APP_OPTION,
|
|
24
|
+
DRY_RUN_OPTION,
|
|
25
|
+
FORCE_OPTION,
|
|
26
|
+
App,
|
|
27
|
+
Workspace,
|
|
28
|
+
local_apps,
|
|
29
|
+
project_workspace,
|
|
30
|
+
resolve_app,
|
|
31
|
+
split_csv,
|
|
32
|
+
)
|
|
33
|
+
from karni_cli.generators.fields import (
|
|
34
|
+
FieldSpec,
|
|
35
|
+
RelationTarget,
|
|
36
|
+
is_user_target,
|
|
37
|
+
parse_fields,
|
|
38
|
+
relation_name,
|
|
39
|
+
render_model_body,
|
|
40
|
+
)
|
|
41
|
+
from karni_cli.generators.introspect import models_file, models_in
|
|
42
|
+
from karni_cli.generators.naming import default_route, kebab, require_pascal
|
|
43
|
+
from karni_cli.generators.pysource import Import, ensure_imports, pystr, replace_or_append
|
|
44
|
+
from karni_cli.project import CliError
|
|
45
|
+
|
|
46
|
+
NAME = "model"
|
|
47
|
+
SUMMARY = "Add a model on BaseModel from a field DSL; --with viewset,page,test or -a chains them."
|
|
48
|
+
|
|
49
|
+
#: What ``--with`` can chain, in run order.
|
|
50
|
+
CHAINABLE = ("viewset", "page", "test", "factory", "seeder")
|
|
51
|
+
#: What ``-a/--all`` chains: everything except the explicit overrides (factory, seeder).
|
|
52
|
+
ALL = ("viewset", "page", "test")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _targets(
|
|
56
|
+
ws: Workspace, app: App, model: str, specs: list[FieldSpec]
|
|
57
|
+
) -> dict[str, RelationTarget]:
|
|
58
|
+
resolved: dict[str, RelationTarget] = {}
|
|
59
|
+
own = models_in(ws, app)
|
|
60
|
+
for spec in specs:
|
|
61
|
+
if not spec.is_relation:
|
|
62
|
+
continue
|
|
63
|
+
raw = relation_name(spec)
|
|
64
|
+
if is_user_target(raw):
|
|
65
|
+
resolved[spec.name] = RelationTarget("settings.AUTH_USER_MODEL", True, "user")
|
|
66
|
+
elif raw in ("self", model):
|
|
67
|
+
resolved[spec.name] = RelationTarget(pystr("self"), False, "self")
|
|
68
|
+
elif "." in raw:
|
|
69
|
+
label, _, name = raw.partition(".")
|
|
70
|
+
for other in local_apps(ws):
|
|
71
|
+
if other.label == label and name not in models_in(ws, other):
|
|
72
|
+
raise CliError(
|
|
73
|
+
f"Field {spec.name}: app {label} has no model {name} — "
|
|
74
|
+
f"karni make model {name} --app {label}",
|
|
75
|
+
code="C104",
|
|
76
|
+
)
|
|
77
|
+
resolved[spec.name] = RelationTarget(pystr(raw), False, raw.lower())
|
|
78
|
+
elif raw in own:
|
|
79
|
+
resolved[spec.name] = RelationTarget(pystr(raw), False, f"{app.label}.{raw}".lower())
|
|
80
|
+
else:
|
|
81
|
+
owners = [other for other in local_apps(ws) if raw in models_in(ws, other)]
|
|
82
|
+
if len(owners) != 1:
|
|
83
|
+
where = "several apps" if owners else "no local app"
|
|
84
|
+
raise CliError(
|
|
85
|
+
f"Field {spec.name}: {where} defines a model {raw!r} — create it first "
|
|
86
|
+
f"(karni make model {raw}) or write app.Model, e.g. "
|
|
87
|
+
f"{spec.name}:{spec.kind}:shop.{raw}.",
|
|
88
|
+
code="C104",
|
|
89
|
+
)
|
|
90
|
+
target = f"{owners[0].label}.{raw}"
|
|
91
|
+
resolved[spec.name] = RelationTarget(pystr(target), False, target.lower())
|
|
92
|
+
return resolved
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def plan(ws: Workspace, app: App, name: str, field_tokens: list[str]) -> str:
|
|
96
|
+
"""Plan the model class in ``<app>/models.py``; returns the class name."""
|
|
97
|
+
model = require_pascal(name, "Model name")
|
|
98
|
+
specs = parse_fields(field_tokens)
|
|
99
|
+
path = models_file(ws, app)
|
|
100
|
+
rel = ws.rel(path)
|
|
101
|
+
body = render_model_body(
|
|
102
|
+
specs,
|
|
103
|
+
model=model,
|
|
104
|
+
app_label=app.label,
|
|
105
|
+
resolve=_targets(ws, app, model, specs),
|
|
106
|
+
line_length=ws.line_length,
|
|
107
|
+
)
|
|
108
|
+
lines = [f"class {model}(BaseModel):"]
|
|
109
|
+
if body.inner:
|
|
110
|
+
lines += [*body.inner, ""]
|
|
111
|
+
lines += body.lines
|
|
112
|
+
text_field = next((s.name for s in specs if s.is_text), None)
|
|
113
|
+
if text_field is not None:
|
|
114
|
+
lines += ["", " def __str__(self) -> str:", f" return self.{text_field}"]
|
|
115
|
+
if len(lines) == 1:
|
|
116
|
+
lines.append(" pass")
|
|
117
|
+
text = ws.read(path) or ""
|
|
118
|
+
text = ensure_imports(ws, rel, text, [Import("karni.models", "BaseModel"), *body.imports])
|
|
119
|
+
ws.write(path, replace_or_append(ws, rel, text, model, "\n".join(lines)))
|
|
120
|
+
for note in body.notes:
|
|
121
|
+
ws.note(note)
|
|
122
|
+
return model
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def plan_chain(
|
|
126
|
+
ws: Workspace,
|
|
127
|
+
app: App,
|
|
128
|
+
model: str,
|
|
129
|
+
chain: list[str],
|
|
130
|
+
*,
|
|
131
|
+
access: str | None,
|
|
132
|
+
search: list[str],
|
|
133
|
+
expand: list[str] | None = None,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""The ``--with`` steps after the model, in :data:`CHAINABLE` order.
|
|
136
|
+
|
|
137
|
+
``expand``: relation fields the detail page asks the API to expand, so it
|
|
138
|
+
shows the related rows' labels instead of raw ids.
|
|
139
|
+
"""
|
|
140
|
+
if "viewset" in chain:
|
|
141
|
+
viewset_gen.plan(ws, app, model, access=access, search=search)
|
|
142
|
+
if "page" in chain:
|
|
143
|
+
resource = default_route(model)
|
|
144
|
+
singular = kebab(model)
|
|
145
|
+
specs = [
|
|
146
|
+
page_gen.spec_for(f"{singular}-form", resource=resource, kind="form", path=None),
|
|
147
|
+
page_gen.spec_for(
|
|
148
|
+
f"{singular}-detail", resource=resource, kind="detail", path=None, expand=expand
|
|
149
|
+
),
|
|
150
|
+
page_gen.spec_for(resource, resource=resource, kind="list", path=None),
|
|
151
|
+
]
|
|
152
|
+
planned = {path for spec in specs for path in spec.paths}
|
|
153
|
+
for spec in specs:
|
|
154
|
+
page_gen.plan(ws, spec, planned_routes=planned)
|
|
155
|
+
if "test" in chain:
|
|
156
|
+
test_gen.plan(ws, app, model)
|
|
157
|
+
if "factory" in chain:
|
|
158
|
+
factory_gen.plan(ws, app, model)
|
|
159
|
+
if "seeder" in chain:
|
|
160
|
+
seeder_gen.plan(ws, app, model)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def resolve_chain(with_: list[str], all_: bool) -> list[str]:
|
|
164
|
+
chain = [*ALL, *split_csv(with_)] if all_ else split_csv(with_)
|
|
165
|
+
unknown = [item for item in chain if item not in CHAINABLE]
|
|
166
|
+
if unknown:
|
|
167
|
+
raise CliError(
|
|
168
|
+
f"--with {', '.join(unknown)}: unknown (available: {', '.join(CHAINABLE)}).",
|
|
169
|
+
code="C106",
|
|
170
|
+
)
|
|
171
|
+
return [item for item in CHAINABLE if item in chain]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def command(
|
|
175
|
+
name: str = typer.Argument(..., help="Model class name, PascalCase (e.g. Event)."),
|
|
176
|
+
fields: list[str] | None = typer.Argument(
|
|
177
|
+
None,
|
|
178
|
+
help=(
|
|
179
|
+
"Fields as name:type[:arg][?] — str[:len] text int float decimal[:max:places] "
|
|
180
|
+
"bool date datetime email url slug json file image uuid choice:a|b fk:Model "
|
|
181
|
+
"m2m:Model o2o:Model; trailing ? = optional."
|
|
182
|
+
),
|
|
183
|
+
show_default=False,
|
|
184
|
+
),
|
|
185
|
+
with_: list[str] | None = typer.Option(
|
|
186
|
+
None,
|
|
187
|
+
"--with",
|
|
188
|
+
help="Also generate: viewset, page, test, factory, seeder (comma-separated or repeated).",
|
|
189
|
+
),
|
|
190
|
+
all_: bool = typer.Option(
|
|
191
|
+
False, "--all", "-a", help="Also generate the viewset, the pages and the API test."
|
|
192
|
+
),
|
|
193
|
+
access: str | None = typer.Option(
|
|
194
|
+
None, "--access", help=f"With viewset: {viewset_gen.ACCESS_CHOICES}."
|
|
195
|
+
),
|
|
196
|
+
search: list[str] | None = typer.Option(
|
|
197
|
+
None, "--search", help="With viewset: search_fields (comma-separated)."
|
|
198
|
+
),
|
|
199
|
+
migrate: bool = typer.Option(
|
|
200
|
+
False, "--migrate", help="Run `karni make migration` for the app afterwards."
|
|
201
|
+
),
|
|
202
|
+
app: str | None = APP_OPTION,
|
|
203
|
+
force: bool = FORCE_OPTION,
|
|
204
|
+
dry_run: bool = DRY_RUN_OPTION,
|
|
205
|
+
) -> None:
|
|
206
|
+
"""Add `class NAME(BaseModel)` with FIELDS to APP/models.py.
|
|
207
|
+
|
|
208
|
+
Example: karni make model Event title:str:200 starts_at:datetime
|
|
209
|
+
"status:choice:draft|live" organizer:fk:User 'venue:fk:Venue?' -a
|
|
210
|
+
--access owned:organizer
|
|
211
|
+
"""
|
|
212
|
+
ws = project_workspace(force)
|
|
213
|
+
chain = resolve_chain(with_ or [], all_)
|
|
214
|
+
if (access or search) and "viewset" not in chain:
|
|
215
|
+
raise CliError(
|
|
216
|
+
"--access/--search configure the viewset: add --with viewset (or -a).", code="C004"
|
|
217
|
+
)
|
|
218
|
+
target = resolve_app(ws, app)
|
|
219
|
+
if all_ and not ws.is_dir("frontend"):
|
|
220
|
+
chain = [item for item in chain if item != "page"]
|
|
221
|
+
ws.note("note: no frontend/ directory — pages skipped.")
|
|
222
|
+
model = plan(ws, target, name, fields or [])
|
|
223
|
+
expand = [
|
|
224
|
+
spec.name
|
|
225
|
+
for spec in parse_fields(fields or [])
|
|
226
|
+
if spec.is_relation and not is_user_target(relation_name(spec))
|
|
227
|
+
]
|
|
228
|
+
plan_chain(ws, target, model, chain, access=access, search=split_csv(search), expand=expand)
|
|
229
|
+
if not migrate:
|
|
230
|
+
ws.note(f"next: karni make migration --app {target.label}")
|
|
231
|
+
ws.commit(dry_run=dry_run)
|
|
232
|
+
if migrate and not dry_run:
|
|
233
|
+
code = migration_gen.run(ws.root, target.label)
|
|
234
|
+
if code != 0:
|
|
235
|
+
raise typer.Exit(code)
|