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,174 @@
|
|
|
1
|
+
"""``karni make schedule <name> --every 15m | --cron "0 3 * * *"`` — a beat schedule.
|
|
2
|
+
|
|
3
|
+
Appends ``every(...)`` / ``cron(...)`` to ``<app>/schedules.py``
|
|
4
|
+
(:mod:`karni.schedule`; autodiscovered into Celery beat). Without ``--task``
|
|
5
|
+
the schedule runs ``<app>.tasks.<name>``, created when it does not exist yet.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from karni_cli.generators import task as task_gen
|
|
16
|
+
from karni_cli.generators.core import (
|
|
17
|
+
APP_OPTION,
|
|
18
|
+
DRY_RUN_OPTION,
|
|
19
|
+
FORCE_OPTION,
|
|
20
|
+
App,
|
|
21
|
+
Workspace,
|
|
22
|
+
local_apps,
|
|
23
|
+
project_workspace,
|
|
24
|
+
resolve_app,
|
|
25
|
+
)
|
|
26
|
+
from karni_cli.generators.introspect import top_level_functions
|
|
27
|
+
from karni_cli.generators.pysource import (
|
|
28
|
+
Import,
|
|
29
|
+
append,
|
|
30
|
+
call,
|
|
31
|
+
ensure_imports,
|
|
32
|
+
parse,
|
|
33
|
+
pystr,
|
|
34
|
+
replace_span,
|
|
35
|
+
span,
|
|
36
|
+
)
|
|
37
|
+
from karni_cli.project import CliError
|
|
38
|
+
|
|
39
|
+
NAME = "schedule"
|
|
40
|
+
SUMMARY = "Add a beat schedule (every/cron) to <app>/schedules.py, creating the task if needed."
|
|
41
|
+
|
|
42
|
+
# Mirrors karni.schedule's own validation, so mistakes fail before any write.
|
|
43
|
+
_INTERVAL = re.compile(r"^\s*(?:\d+\s*[dhms]\s*)+$")
|
|
44
|
+
_DOTTED = re.compile(r"^[A-Za-z_]\w*(\.[A-Za-z_]\w*)+$")
|
|
45
|
+
_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]*$")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _declared(ws: Workspace, rel: str, text: str) -> dict[str, ast.stmt]:
|
|
49
|
+
"""``name=`` of each top-level every()/cron() call in a schedules module."""
|
|
50
|
+
found: dict[str, ast.stmt] = {}
|
|
51
|
+
if not text:
|
|
52
|
+
return found
|
|
53
|
+
for node in parse(ws, rel, text).body:
|
|
54
|
+
value = node.value if isinstance(node, ast.Expr | ast.Assign) else None
|
|
55
|
+
if not isinstance(value, ast.Call):
|
|
56
|
+
continue
|
|
57
|
+
func = value.func
|
|
58
|
+
called = func.id if isinstance(func, ast.Name) else getattr(func, "attr", "")
|
|
59
|
+
if called not in {"every", "cron"}:
|
|
60
|
+
continue
|
|
61
|
+
for keyword in value.keywords:
|
|
62
|
+
if keyword.arg == "name" and isinstance(keyword.value, ast.Constant):
|
|
63
|
+
found[str(keyword.value.value)] = node
|
|
64
|
+
return found
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _app_schedules(ws: Workspace, app: App) -> dict[str, ast.stmt]:
|
|
68
|
+
path = app.file("schedules.py")
|
|
69
|
+
return _declared(ws, ws.rel(path), ws.read(path) or "")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def plan(
|
|
73
|
+
ws: Workspace,
|
|
74
|
+
app: App,
|
|
75
|
+
name: str,
|
|
76
|
+
*,
|
|
77
|
+
every: str | None,
|
|
78
|
+
cron: str | None,
|
|
79
|
+
task: str | None,
|
|
80
|
+
) -> None:
|
|
81
|
+
if not _NAME.match(name):
|
|
82
|
+
raise CliError(
|
|
83
|
+
f"Schedule name {name!r}: use letters, digits, _ - . : (e.g. nightly-report).",
|
|
84
|
+
code="C103",
|
|
85
|
+
)
|
|
86
|
+
if (every is None) == (cron is None):
|
|
87
|
+
raise CliError(
|
|
88
|
+
'Give exactly one of --every (e.g. 15m, 1h, 1d) or --cron "0 3 * * *".', code="C004"
|
|
89
|
+
)
|
|
90
|
+
if every is not None and not _INTERVAL.match(every):
|
|
91
|
+
raise CliError(
|
|
92
|
+
f"--every {every!r}: use a number with s, m, h or d — 30s, 15m, 1h30m, 1d.", code="C106"
|
|
93
|
+
)
|
|
94
|
+
if cron is not None and len(cron.split()) != 5:
|
|
95
|
+
raise CliError(
|
|
96
|
+
f"--cron {cron!r}: needs 5 fields 'minute hour day-of-month month day-of-week', "
|
|
97
|
+
"e.g. '0 3 * * *' (03:00 daily).",
|
|
98
|
+
code="C106",
|
|
99
|
+
)
|
|
100
|
+
for other in local_apps(ws):
|
|
101
|
+
if other != app and name in _app_schedules(ws, other):
|
|
102
|
+
raise CliError(
|
|
103
|
+
f"Schedule {name!r} is already declared in {ws.rel(other.file('schedules.py'))} "
|
|
104
|
+
"— schedule names are project-wide (they become PeriodicTask names).",
|
|
105
|
+
code="C107",
|
|
106
|
+
)
|
|
107
|
+
imports = [Import("karni.schedule", "every" if every is not None else "cron")]
|
|
108
|
+
if task is not None:
|
|
109
|
+
if not _DOTTED.match(task):
|
|
110
|
+
raise CliError(
|
|
111
|
+
f"--task {task!r}: give the dotted task name, e.g. shop.tasks.sync.", code="C106"
|
|
112
|
+
)
|
|
113
|
+
module, _, function = task.rpartition(".")
|
|
114
|
+
if module == f"{app.module}.tasks" and function not in top_level_functions(
|
|
115
|
+
ws, app.file("tasks.py")
|
|
116
|
+
):
|
|
117
|
+
raise CliError(
|
|
118
|
+
f"No task {function} in {ws.rel(app.file('tasks.py'))} — "
|
|
119
|
+
f"karni make task {function} --app {app.label} (or drop --task).",
|
|
120
|
+
code="C105",
|
|
121
|
+
)
|
|
122
|
+
ref = pystr(task)
|
|
123
|
+
else:
|
|
124
|
+
function = re.sub(r"\W", "_", name).lower()
|
|
125
|
+
if function not in top_level_functions(ws, app.file("tasks.py")):
|
|
126
|
+
task_gen.plan(ws, app, function, announce=False)
|
|
127
|
+
ref = f"tasks.{function}"
|
|
128
|
+
imports.append(Import(app.module, "tasks"))
|
|
129
|
+
timing = pystr(every.strip() if every is not None else " ".join((cron or "").split()))
|
|
130
|
+
statement = call(
|
|
131
|
+
"every" if every is not None else "cron",
|
|
132
|
+
[timing, ref, f"name={pystr(name)}"],
|
|
133
|
+
"",
|
|
134
|
+
ws.line_length,
|
|
135
|
+
)
|
|
136
|
+
path = app.file("schedules.py")
|
|
137
|
+
rel = ws.rel(path)
|
|
138
|
+
text = ensure_imports(ws, rel, ws.read(path) or "", imports)
|
|
139
|
+
existing = _declared(ws, rel, text).get(name)
|
|
140
|
+
if existing is not None:
|
|
141
|
+
ws.refuse_existing(f"schedule {name!r} in {rel}")
|
|
142
|
+
first, last = span(text.splitlines(), existing)
|
|
143
|
+
text = replace_span(text, first, last, statement)
|
|
144
|
+
else:
|
|
145
|
+
text = append(text, statement)
|
|
146
|
+
ws.write(path, text)
|
|
147
|
+
ws.note(
|
|
148
|
+
f"schedule: {name} — check with: karni schedule list · run once: karni schedule run {name}"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def command(
|
|
153
|
+
name: str = typer.Argument(
|
|
154
|
+
..., help="Schedule name, unique in the project (e.g. nightly-report)."
|
|
155
|
+
),
|
|
156
|
+
every: str | None = typer.Option(None, "--every", help="Interval: 30s, 15m, 1h, 1h30m, 1d."),
|
|
157
|
+
cron: str | None = typer.Option(
|
|
158
|
+
None, "--cron", help='Five-field crontab, e.g. "0 3 * * *" (03:00 daily).'
|
|
159
|
+
),
|
|
160
|
+
task: str | None = typer.Option(
|
|
161
|
+
None, "--task", help="Dotted task to run (default: APP.tasks.<name>, created if missing)."
|
|
162
|
+
),
|
|
163
|
+
app: str | None = APP_OPTION,
|
|
164
|
+
force: bool = FORCE_OPTION,
|
|
165
|
+
dry_run: bool = DRY_RUN_OPTION,
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Add every(...)/cron(...) to APP/schedules.py — merged into Celery beat at startup.
|
|
168
|
+
|
|
169
|
+
Without --task the schedule runs APP.tasks.<name> (dashes become
|
|
170
|
+
underscores), which is created when missing.
|
|
171
|
+
"""
|
|
172
|
+
ws = project_workspace(force)
|
|
173
|
+
plan(ws, resolve_app(ws, app), name, every=every, cron=cron, task=task)
|
|
174
|
+
ws.commit(dry_run=dry_run)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""``karni make seeder <Model>`` — a ``Seeder`` in ``<app>/seeds.py`` for ``karni seed``.
|
|
2
|
+
|
|
3
|
+
``karni seed`` already fills every model with values inferred from field
|
|
4
|
+
names and types. A seeder steers one model: ``Meta.count`` pins its rows per
|
|
5
|
+
run (``0`` skips it) and any class attribute overrides one field with a
|
|
6
|
+
value or a factory-boy declaration; override ``seed()`` for full control.
|
|
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
|
+
App,
|
|
18
|
+
Workspace,
|
|
19
|
+
project_workspace,
|
|
20
|
+
resolve_app,
|
|
21
|
+
)
|
|
22
|
+
from karni_cli.generators.introspect import require_model
|
|
23
|
+
from karni_cli.generators.naming import require_pascal
|
|
24
|
+
from karni_cli.generators.pysource import Import, docstring, ensure_imports, replace_or_append
|
|
25
|
+
from karni_cli.project import CliError
|
|
26
|
+
|
|
27
|
+
NAME = "seeder"
|
|
28
|
+
SUMMARY = "Add a Seeder to <app>/seeds.py: per-model rows and values for karni seed."
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def plan(ws: Workspace, app: App, model_name: str, *, count: int | None = None) -> str:
|
|
32
|
+
"""Plan ``<Model>Seeder``; returns the class name."""
|
|
33
|
+
if count is not None and count < 0:
|
|
34
|
+
raise CliError("--count must be 0 or more (0 skips the model).", code="C106")
|
|
35
|
+
model = require_model(ws, app, require_pascal(model_name, "Model"))
|
|
36
|
+
path = model.app.file("seeds.py")
|
|
37
|
+
rel = ws.rel(path)
|
|
38
|
+
line = ws.line_length
|
|
39
|
+
text = ws.read(path)
|
|
40
|
+
if text is None:
|
|
41
|
+
text = docstring(f"{model.app.label} demo data for `karni seed`.", "", line) + "\n"
|
|
42
|
+
class_name = f"{model.name}Seeder"
|
|
43
|
+
meta = [" class Meta:", f" model = {model.name}"]
|
|
44
|
+
if count is not None:
|
|
45
|
+
meta.append(f" count = {count}")
|
|
46
|
+
block = "\n".join(
|
|
47
|
+
[
|
|
48
|
+
f"class {class_name}(Seeder):",
|
|
49
|
+
docstring(
|
|
50
|
+
f"Demo rows for {model.name}; undeclared fields are inferred from their names.",
|
|
51
|
+
" ",
|
|
52
|
+
line,
|
|
53
|
+
),
|
|
54
|
+
"",
|
|
55
|
+
*meta,
|
|
56
|
+
"",
|
|
57
|
+
" # Steer a field with a value or a factory-boy declaration, e.g.:",
|
|
58
|
+
' # status = factory.Iterator(["published", "draft"])',
|
|
59
|
+
]
|
|
60
|
+
)
|
|
61
|
+
text = ensure_imports(
|
|
62
|
+
ws, rel, text, [Import("karni.seeding", "Seeder"), Import(model.import_module, model.name)]
|
|
63
|
+
)
|
|
64
|
+
ws.write(path, replace_or_append(ws, rel, text, class_name, block))
|
|
65
|
+
ws.note(f"seeder: {model.app.module}.seeds.{class_name} — run: karni seed")
|
|
66
|
+
return class_name
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def command(
|
|
70
|
+
model: str = typer.Argument(..., help="Model class name (e.g. Event)."),
|
|
71
|
+
count: int | None = typer.Option(
|
|
72
|
+
None, "--count", help="Rows per `karni seed` run for this model (0 skips it)."
|
|
73
|
+
),
|
|
74
|
+
app: str | None = APP_OPTION,
|
|
75
|
+
force: bool = FORCE_OPTION,
|
|
76
|
+
dry_run: bool = DRY_RUN_OPTION,
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Add `<Model>Seeder(Seeder)` to APP/seeds.py, read by `karni seed`.
|
|
79
|
+
|
|
80
|
+
Only needed to steer a model's demo data: Meta.count pins its rows per
|
|
81
|
+
run, and a class attribute overrides a field (a value or a factory-boy
|
|
82
|
+
declaration); every other field stays inferred from its name and type.
|
|
83
|
+
"""
|
|
84
|
+
ws = project_workspace(force)
|
|
85
|
+
plan(ws, resolve_app(ws, app), model, count=count)
|
|
86
|
+
ws.commit(dry_run=dry_run)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""``karni make serializer <Model> --reason "..."`` — an explicit serializer, wired in.
|
|
2
|
+
|
|
3
|
+
``BaseViewSet(model=X)`` already generates the serializer (K106), and
|
|
4
|
+
``APIMeta`` steers its fields; an explicit one is the escape hatch for what
|
|
5
|
+
neither covers. So ``--reason`` is mandatory: it becomes the
|
|
6
|
+
``# karni: allow(redundant-serializer): <reason>`` marker above the class.
|
|
7
|
+
The viewset's ``serializer_class`` is set in the same run.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ast
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
from karni_cli.generators.core import (
|
|
18
|
+
APP_OPTION,
|
|
19
|
+
DRY_RUN_OPTION,
|
|
20
|
+
FORCE_OPTION,
|
|
21
|
+
App,
|
|
22
|
+
Workspace,
|
|
23
|
+
project_workspace,
|
|
24
|
+
resolve_app,
|
|
25
|
+
)
|
|
26
|
+
from karni_cli.generators.introspect import (
|
|
27
|
+
ModelInfo,
|
|
28
|
+
owned_paths,
|
|
29
|
+
require_model,
|
|
30
|
+
viewset_for,
|
|
31
|
+
viewsets_file,
|
|
32
|
+
)
|
|
33
|
+
from karni_cli.generators.naming import require_pascal
|
|
34
|
+
from karni_cli.generators.pysource import (
|
|
35
|
+
Import,
|
|
36
|
+
ensure_imports,
|
|
37
|
+
pystr,
|
|
38
|
+
replace_or_append,
|
|
39
|
+
replace_span,
|
|
40
|
+
tuple_literal,
|
|
41
|
+
)
|
|
42
|
+
from karni_cli.project import CliError
|
|
43
|
+
|
|
44
|
+
NAME = "serializer"
|
|
45
|
+
SUMMARY = "Add an explicit serializer (--reason required) and set serializer_class."
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _field_lists(model: ModelInfo, owners: list[str]) -> tuple[list[str], list[str]]:
|
|
49
|
+
bases = model.bases
|
|
50
|
+
head = ["id"]
|
|
51
|
+
tail: list[str] = []
|
|
52
|
+
if bases & {"BaseModel", "TimestampedModel"}:
|
|
53
|
+
tail += ["created_at", "updated_at"]
|
|
54
|
+
if bases & {"BaseModel", "AuditedModel"}:
|
|
55
|
+
tail += ["created_by", "updated_by"]
|
|
56
|
+
fields = [*head, *model.fields, *tail]
|
|
57
|
+
owned = dict.fromkeys(o for o in owners if o in model.fields) # read + write may both name it
|
|
58
|
+
read_only = [*head, *tail, *owned]
|
|
59
|
+
return fields, read_only
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def plan(ws: Workspace, app: App, model_name: str, reason: str) -> str:
|
|
63
|
+
reason = " ".join(reason.split())
|
|
64
|
+
if not reason:
|
|
65
|
+
raise CliError(
|
|
66
|
+
"--reason is required: BaseViewSet already generates this serializer (K106) — say "
|
|
67
|
+
'why an explicit one is needed, e.g. --reason "computed display_name field".',
|
|
68
|
+
code="C109",
|
|
69
|
+
)
|
|
70
|
+
model = require_model(ws, app, model_name)
|
|
71
|
+
found = viewset_for(ws, app, model.name)
|
|
72
|
+
if found is None:
|
|
73
|
+
raise CliError(
|
|
74
|
+
f"No viewset for {model.name} in {ws.rel(viewsets_file(ws, app))} to wire the "
|
|
75
|
+
f"serializer into — karni make viewset {model.name} first.",
|
|
76
|
+
code="C105",
|
|
77
|
+
)
|
|
78
|
+
vs_path, viewset = found
|
|
79
|
+
class_name = f"{model.name}Serializer"
|
|
80
|
+
line = ws.line_length
|
|
81
|
+
fields, read_only = _field_lists(model, owned_paths(viewset))
|
|
82
|
+
block = "\n".join(
|
|
83
|
+
[
|
|
84
|
+
f"# karni: allow(redundant-serializer): {reason}",
|
|
85
|
+
f"class {class_name}(KarniModelSerializer):",
|
|
86
|
+
" class Meta:",
|
|
87
|
+
f" model = {model.name}",
|
|
88
|
+
tuple_literal("fields = ", [pystr(f) for f in fields], " ", line),
|
|
89
|
+
tuple_literal("read_only_fields = ", [pystr(f) for f in read_only], " ", line),
|
|
90
|
+
]
|
|
91
|
+
)
|
|
92
|
+
path = app.file("serializers.py")
|
|
93
|
+
rel = ws.rel(path)
|
|
94
|
+
text = ensure_imports(
|
|
95
|
+
ws,
|
|
96
|
+
rel,
|
|
97
|
+
ws.read(path) or "",
|
|
98
|
+
[
|
|
99
|
+
Import("karni.api.serializers", "KarniModelSerializer"),
|
|
100
|
+
Import(model.import_module, model.name),
|
|
101
|
+
],
|
|
102
|
+
)
|
|
103
|
+
ws.write(path, replace_or_append(ws, rel, text, class_name, block))
|
|
104
|
+
_wire(ws, vs_path, viewset, class_name, f"{app.module}.serializers")
|
|
105
|
+
ws.note(
|
|
106
|
+
f"note: BaseViewSet(model={model.name}) generates a serializer already; "
|
|
107
|
+
f"{class_name} replaces it. Prefer APIMeta for fields/read_only/write_only."
|
|
108
|
+
)
|
|
109
|
+
return class_name
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _wire(ws: Workspace, path: Path, viewset: ast.ClassDef, class_name: str, module: str) -> None:
|
|
113
|
+
"""Set ``serializer_class`` on the viewset: after its ``model = X`` line (AST-located)."""
|
|
114
|
+
rel = ws.rel(path)
|
|
115
|
+
text = ws.read(path) or ""
|
|
116
|
+
lines = text.splitlines()
|
|
117
|
+
indent = " " * viewset.body[0].col_offset
|
|
118
|
+
assignment = f"{indent}serializer_class = {class_name}"
|
|
119
|
+
existing = next(
|
|
120
|
+
(
|
|
121
|
+
stmt
|
|
122
|
+
for stmt in viewset.body
|
|
123
|
+
if isinstance(stmt, ast.Assign)
|
|
124
|
+
and any(isinstance(t, ast.Name) and t.id == "serializer_class" for t in stmt.targets)
|
|
125
|
+
),
|
|
126
|
+
None,
|
|
127
|
+
)
|
|
128
|
+
if existing is not None:
|
|
129
|
+
ws.refuse_existing(f"{viewset.name}.serializer_class in {rel}")
|
|
130
|
+
text = replace_span(
|
|
131
|
+
text, existing.lineno, existing.end_lineno or existing.lineno, assignment
|
|
132
|
+
)
|
|
133
|
+
else:
|
|
134
|
+
anchor = next(
|
|
135
|
+
stmt
|
|
136
|
+
for stmt in viewset.body
|
|
137
|
+
if isinstance(stmt, ast.Assign)
|
|
138
|
+
and any(isinstance(t, ast.Name) and t.id == "model" for t in stmt.targets)
|
|
139
|
+
)
|
|
140
|
+
end = anchor.end_lineno or anchor.lineno
|
|
141
|
+
lines.insert(end, assignment)
|
|
142
|
+
text = "\n".join(lines) + "\n"
|
|
143
|
+
text = ensure_imports(ws, rel, text, [Import(module, class_name)])
|
|
144
|
+
ws.write(path, text)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def command(
|
|
148
|
+
model: str = typer.Argument(..., help="Model class name (e.g. Event)."),
|
|
149
|
+
reason: str = typer.Option(
|
|
150
|
+
"", "--reason", help="Why the generated serializer is not enough (required)."
|
|
151
|
+
),
|
|
152
|
+
app: str | None = APP_OPTION,
|
|
153
|
+
force: bool = FORCE_OPTION,
|
|
154
|
+
dry_run: bool = DRY_RUN_OPTION,
|
|
155
|
+
) -> None:
|
|
156
|
+
"""Add `<Model>Serializer` to APP/serializers.py and set the viewset's serializer_class.
|
|
157
|
+
|
|
158
|
+
BaseViewSet generates the serializer and APIMeta steers it, so an
|
|
159
|
+
explicit one needs --reason; it is recorded as the
|
|
160
|
+
`# karni: allow(redundant-serializer): <reason>` marker.
|
|
161
|
+
"""
|
|
162
|
+
ws = project_workspace(force)
|
|
163
|
+
target = resolve_app(ws, app)
|
|
164
|
+
plan(ws, target, require_pascal(model.removesuffix("Serializer"), "Model"), reason)
|
|
165
|
+
ws.commit(dry_run=dry_run)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""``karni make service <name>`` — a module under ``<app>/services/``.
|
|
2
|
+
|
|
3
|
+
Services are where external calls live: rule K105 allows HTTP clients
|
|
4
|
+
(``requests``, ``httpx``, ``urllib.request``) only under ``services/`` and
|
|
5
|
+
``providers/``, so views, tasks and listeners call a service, never a client.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from karni_cli.generators.core import (
|
|
13
|
+
APP_OPTION,
|
|
14
|
+
DRY_RUN_OPTION,
|
|
15
|
+
FORCE_OPTION,
|
|
16
|
+
App,
|
|
17
|
+
Workspace,
|
|
18
|
+
project_workspace,
|
|
19
|
+
resolve_app,
|
|
20
|
+
)
|
|
21
|
+
from karni_cli.generators.naming import humanize, pascal, require_snake
|
|
22
|
+
from karni_cli.generators.pysource import comment, docstring
|
|
23
|
+
from karni_cli.project import CliError
|
|
24
|
+
|
|
25
|
+
NAME = "service"
|
|
26
|
+
SUMMARY = "Add a service module (<app>/services/<name>.py) — the home of HTTP clients."
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def plan(ws: Workspace, app: App, name: str) -> None:
|
|
30
|
+
require_snake(name, "Service name")
|
|
31
|
+
if ws.exists(app.file("services.py")):
|
|
32
|
+
raise CliError(
|
|
33
|
+
f"{ws.rel(app.file('services.py'))} exists — a services/ package would shadow it. "
|
|
34
|
+
"Move it to services/__init__.py first, then re-run.",
|
|
35
|
+
code="C107",
|
|
36
|
+
)
|
|
37
|
+
init = app.file("services", "__init__.py")
|
|
38
|
+
if not ws.exists(init):
|
|
39
|
+
ws.create(init, "")
|
|
40
|
+
line = ws.line_length
|
|
41
|
+
class_name = pascal(name) + ("" if name.endswith("service") else "Service")
|
|
42
|
+
body = comment(
|
|
43
|
+
"External calls (requests, httpx) belong here: rule K105 allows HTTP clients "
|
|
44
|
+
"only under services/ and providers/. Views, tasks and listeners call this "
|
|
45
|
+
"module, never a client directly.",
|
|
46
|
+
"",
|
|
47
|
+
line,
|
|
48
|
+
prefix="",
|
|
49
|
+
)
|
|
50
|
+
module_doc = f'"""{humanize(name)} service.\n\n{body}\n"""'
|
|
51
|
+
class_doc = docstring(f"{humanize(name)}: the calls this app makes to it.", " ", line)
|
|
52
|
+
ws.create(
|
|
53
|
+
app.file("services", f"{name}.py"),
|
|
54
|
+
f"{module_doc}\n\n\nclass {class_name}:\n{class_doc}\n",
|
|
55
|
+
)
|
|
56
|
+
ws.note(f"import: from {app.module}.services.{name} import {class_name}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def command(
|
|
60
|
+
name: str = typer.Argument(..., help="Service module name, snake_case (e.g. stripe_client)."),
|
|
61
|
+
app: str | None = APP_OPTION,
|
|
62
|
+
force: bool = FORCE_OPTION,
|
|
63
|
+
dry_run: bool = DRY_RUN_OPTION,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Add APP/services/NAME.py — the one place HTTP clients are allowed (K105)."""
|
|
66
|
+
ws = project_workspace(force)
|
|
67
|
+
plan(ws, resolve_app(ws, app), name)
|
|
68
|
+
ws.commit(dry_run=dry_run)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""``karni make task <name>`` — a Celery ``@shared_task`` in ``<app>/tasks.py``.
|
|
2
|
+
|
|
3
|
+
Celery autodiscovers ``tasks`` modules (``karni.celery.create_celery_app``),
|
|
4
|
+
so the task is registered as ``<app>.tasks.<name>`` with no wiring.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from karni_cli.generators.core import (
|
|
12
|
+
APP_OPTION,
|
|
13
|
+
DRY_RUN_OPTION,
|
|
14
|
+
FORCE_OPTION,
|
|
15
|
+
App,
|
|
16
|
+
Workspace,
|
|
17
|
+
project_workspace,
|
|
18
|
+
resolve_app,
|
|
19
|
+
)
|
|
20
|
+
from karni_cli.generators.naming import require_snake
|
|
21
|
+
from karni_cli.generators.pysource import (
|
|
22
|
+
Import,
|
|
23
|
+
call,
|
|
24
|
+
docstring,
|
|
25
|
+
ensure_imports,
|
|
26
|
+
replace_or_append,
|
|
27
|
+
)
|
|
28
|
+
from karni_cli.project import CliError
|
|
29
|
+
|
|
30
|
+
NAME = "task"
|
|
31
|
+
SUMMARY = "Add a Celery task (@shared_task) to <app>/tasks.py; --retry N."
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def plan(
|
|
35
|
+
ws: Workspace, app: App, name: str, *, retry: int | None = None, announce: bool = True
|
|
36
|
+
) -> str:
|
|
37
|
+
"""Plan the task; returns its registered name (``<app>.tasks.<name>``)."""
|
|
38
|
+
require_snake(name, "Task name")
|
|
39
|
+
if retry is not None and retry < 1:
|
|
40
|
+
raise CliError("--retry must be 1 or more.", code="C106")
|
|
41
|
+
path = app.file("tasks.py")
|
|
42
|
+
rel = ws.rel(path)
|
|
43
|
+
line = ws.line_length
|
|
44
|
+
if retry is None:
|
|
45
|
+
decorator = "@shared_task"
|
|
46
|
+
else:
|
|
47
|
+
decorator = call(
|
|
48
|
+
"@shared_task",
|
|
49
|
+
["autoretry_for=(Exception,)", "retry_backoff=True", f"max_retries={retry}"],
|
|
50
|
+
"",
|
|
51
|
+
line,
|
|
52
|
+
)
|
|
53
|
+
block = "\n".join(
|
|
54
|
+
[
|
|
55
|
+
decorator,
|
|
56
|
+
f"def {name}() -> None:",
|
|
57
|
+
docstring(f"Runs in a Celery worker: {name}.delay() queues it.", " ", line),
|
|
58
|
+
" # Off-request writes are audit-stamped only inside",
|
|
59
|
+
" # karni.middleware.current_user(user).",
|
|
60
|
+
]
|
|
61
|
+
)
|
|
62
|
+
text = ensure_imports(ws, rel, ws.read(path) or "", [Import("celery", "shared_task")])
|
|
63
|
+
ws.write(path, replace_or_append(ws, rel, text, name, block))
|
|
64
|
+
dotted = f"{app.module}.tasks.{name}"
|
|
65
|
+
if announce:
|
|
66
|
+
ws.note(f"task: {dotted} — schedule it with: karni make schedule {name} --every 1h")
|
|
67
|
+
else:
|
|
68
|
+
ws.note(f"task: {dotted}")
|
|
69
|
+
return dotted
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def command(
|
|
73
|
+
name: str = typer.Argument(..., help="Task function name, snake_case (e.g. send_digest)."),
|
|
74
|
+
retry: int | None = typer.Option(
|
|
75
|
+
None, "--retry", help="Retry up to N times on any exception (exponential backoff)."
|
|
76
|
+
),
|
|
77
|
+
app: str | None = APP_OPTION,
|
|
78
|
+
force: bool = FORCE_OPTION,
|
|
79
|
+
dry_run: bool = DRY_RUN_OPTION,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Add a Celery task `NAME` to APP/tasks.py (registered as APP.tasks.NAME)."""
|
|
82
|
+
ws = project_workspace(force)
|
|
83
|
+
plan(ws, resolve_app(ws, app), name, retry=retry)
|
|
84
|
+
ws.commit(dry_run=dry_run)
|