karni-cli 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. karni_cli/__init__.py +3 -0
  2. karni_cli/_agent/__init__.py +82 -0
  3. karni_cli/_agent/__main__.py +44 -0
  4. karni_cli/_agent/check.py +658 -0
  5. karni_cli/_agent/history.py +328 -0
  6. karni_cli/_agent/history_chronicle.py +246 -0
  7. karni_cli/_agent/overrides.py +194 -0
  8. karni_cli/_agent/rules.py +673 -0
  9. karni_cli/_changelog/0.2.0.toml +304 -0
  10. karni_cli/_changelog/0.3.0.toml +25 -0
  11. karni_cli/_changelog/__init__.py +248 -0
  12. karni_cli/_errorcodes/__init__.py +1171 -0
  13. karni_cli/_errorcodes/__main__.py +34 -0
  14. karni_cli/_errorcodes/links.py +217 -0
  15. karni_cli/_templates/project/cookiecutter.json +8 -0
  16. karni_cli/_templates/project/{{cookiecutter.project_slug}}/.dockerignore +7 -0
  17. karni_cli/_templates/project/{{cookiecutter.project_slug}}/.env.example +85 -0
  18. karni_cli/_templates/project/{{cookiecutter.project_slug}}/.gitignore +10 -0
  19. karni_cli/_templates/project/{{cookiecutter.project_slug}}/Caddyfile +22 -0
  20. karni_cli/_templates/project/{{cookiecutter.project_slug}}/Dockerfile +53 -0
  21. karni_cli/_templates/project/{{cookiecutter.project_slug}}/Makefile +52 -0
  22. karni_cli/_templates/project/{{cookiecutter.project_slug}}/README.md +169 -0
  23. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/__init__.py +16 -0
  24. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/asgi.py +14 -0
  25. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/celery.py +17 -0
  26. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/settings.py +28 -0
  27. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/urls.py +31 -0
  28. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/wsgi.py +14 -0
  29. karni_cli/_templates/project/{{cookiecutter.project_slug}}/docker-compose.yml +126 -0
  30. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.dockerignore +2 -0
  31. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.jscpd.json +9 -0
  32. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.npmrc +7 -0
  33. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/Dockerfile.dev +17 -0
  34. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/eslint.config.js +32 -0
  35. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/index.html +14 -0
  36. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/package.json +43 -0
  37. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/postcss.config.js +6 -0
  38. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/public/favicon.svg +6 -0
  39. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/App.tsx +18 -0
  40. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/api/health.ts +21 -0
  41. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/index.css +11 -0
  42. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/layout.tsx +51 -0
  43. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/main.tsx +11 -0
  44. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/pages/home.tsx +28 -0
  45. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/routes.tsx +24 -0
  46. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/vite-env.d.ts +1 -0
  47. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tailwind.config.ts +13 -0
  48. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tsconfig.json +19 -0
  49. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/vite.config.ts +18 -0
  50. karni_cli/_templates/project/{{cookiecutter.project_slug}}/manage.py +23 -0
  51. karni_cli/_templates/project/{{cookiecutter.project_slug}}/pyproject.toml +31 -0
  52. karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/__init__.py +0 -0
  53. karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_api_schema.py +17 -0
  54. karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_smoke.py +11 -0
  55. karni_cli/adapters.py +373 -0
  56. karni_cli/agent_adapters.toml +160 -0
  57. karni_cli/agent_data.py +260 -0
  58. karni_cli/brand.py +55 -0
  59. karni_cli/clidoc.py +254 -0
  60. karni_cli/commands/__init__.py +0 -0
  61. karni_cli/commands/add.py +144 -0
  62. karni_cli/commands/beat.py +36 -0
  63. karni_cli/commands/check.py +101 -0
  64. karni_cli/commands/config.py +81 -0
  65. karni_cli/commands/context.py +171 -0
  66. karni_cli/commands/createsuperuser.py +28 -0
  67. karni_cli/commands/doctor.py +136 -0
  68. karni_cli/commands/explain.py +60 -0
  69. karni_cli/commands/make.py +55 -0
  70. karni_cli/commands/manage_passthrough.py +28 -0
  71. karni_cli/commands/mcp.py +20 -0
  72. karni_cli/commands/migrate.py +23 -0
  73. karni_cli/commands/new.py +106 -0
  74. karni_cli/commands/routes.py +20 -0
  75. karni_cli/commands/rules.py +139 -0
  76. karni_cli/commands/run_tests.py +38 -0
  77. karni_cli/commands/schedule.py +40 -0
  78. karni_cli/commands/seed.py +34 -0
  79. karni_cli/commands/serve.py +50 -0
  80. karni_cli/commands/shell.py +28 -0
  81. karni_cli/commands/sync_roles.py +32 -0
  82. karni_cli/commands/types.py +72 -0
  83. karni_cli/commands/upgrade.py +231 -0
  84. karni_cli/commands/worker.py +63 -0
  85. karni_cli/config.py +142 -0
  86. karni_cli/environment.py +29 -0
  87. karni_cli/errorcodes.py +377 -0
  88. karni_cli/errordoc.py +227 -0
  89. karni_cli/generators/__init__.py +67 -0
  90. karni_cli/generators/apitest.py +206 -0
  91. karni_cli/generators/app.py +103 -0
  92. karni_cli/generators/command.py +98 -0
  93. karni_cli/generators/component.py +115 -0
  94. karni_cli/generators/core.py +319 -0
  95. karni_cli/generators/event.py +57 -0
  96. karni_cli/generators/factory.py +88 -0
  97. karni_cli/generators/fields.py +343 -0
  98. karni_cli/generators/introspect.py +263 -0
  99. karni_cli/generators/listener.py +168 -0
  100. karni_cli/generators/middleware.py +95 -0
  101. karni_cli/generators/migration.py +82 -0
  102. karni_cli/generators/model.py +235 -0
  103. karni_cli/generators/module.py +255 -0
  104. karni_cli/generators/naming.py +100 -0
  105. karni_cli/generators/page.py +373 -0
  106. karni_cli/generators/pysource.py +304 -0
  107. karni_cli/generators/schedule.py +174 -0
  108. karni_cli/generators/seeder.py +86 -0
  109. karni_cli/generators/serializer.py +165 -0
  110. karni_cli/generators/service.py +68 -0
  111. karni_cli/generators/task.py +84 -0
  112. karni_cli/generators/viewset.py +179 -0
  113. karni_cli/history.py +59 -0
  114. karni_cli/local_state.py +73 -0
  115. karni_cli/main.py +104 -0
  116. karni_cli/mcp_server.py +103 -0
  117. karni_cli/project.py +248 -0
  118. karni_cli/rules_render.py +86 -0
  119. karni_cli/scaffold.py +197 -0
  120. karni_cli/settings_edit.py +128 -0
  121. karni_cli/tips.py +53 -0
  122. karni_cli/typegen.py +101 -0
  123. karni_cli/upgrades/__init__.py +28 -0
  124. karni_cli/upgrades/changelog.py +123 -0
  125. karni_cli/upgrades/codemod.py +125 -0
  126. karni_cli/upgrades/engine.py +224 -0
  127. karni_cli/upgrades/legacy_agent_files.py +138 -0
  128. karni_cli/upgrades/python.py +670 -0
  129. karni_cli/upgrades/transforms/rename-export.cjs +96 -0
  130. karni_cli/upgrades/typescript.py +160 -0
  131. karni_cli/upgrades/v0_2/__init__.py +1 -0
  132. karni_cli/upgrades/v0_2/agent_guidance.py +269 -0
  133. karni_cli/upgrades/v1_0/__init__.py +5 -0
  134. karni_cli/upgrades/v1_0/all_objects_to_without.py +18 -0
  135. karni_cli/upgrades/v1_0/all_with_deleted_to_without.py +13 -0
  136. karni_cli/upgrades/v1_0/deleted_only_to_without_dead.py +13 -0
  137. karni_cli/upgrades/v1_0/object_permission_classes_to_access.py +16 -0
  138. karni_cli/upgrades/v1_0/related_lookups_to_optimize.py +15 -0
  139. karni_cli/upgrades/v1_0/scoped_related_lookups_to_optimize.py +15 -0
  140. karni_cli/upgrades/v1_0/soft_delete_manager_mixin_to_scoped.py +14 -0
  141. karni_cli-0.3.1.dist-info/METADATA +191 -0
  142. karni_cli-0.3.1.dist-info/RECORD +145 -0
  143. karni_cli-0.3.1.dist-info/WHEEL +4 -0
  144. karni_cli-0.3.1.dist-info/entry_points.txt +2 -0
  145. karni_cli-0.3.1.dist-info/licenses/LICENSE +201 -0
@@ -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)