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,319 @@
1
+ """Shared machinery for ``karni make``: the virtual workspace, app resolution, output.
2
+
3
+ Every generator works against a :class:`Workspace` — an in-memory overlay of
4
+ the project tree. Generators read through it, plan their writes into it, and
5
+ only :meth:`Workspace.commit` touches the disk, after every generator in the
6
+ chain has succeeded. So a failing step (an existing target without
7
+ ``--force``, an unsupported layout) writes nothing, and ``--dry-run`` shows
8
+ exactly what a real run would write.
9
+
10
+ Universal rules (docs/cli.md "Generators"):
11
+
12
+ * never overwrite an existing target without ``--force``;
13
+ * edit existing files only through marker comments or AST-located appends;
14
+ * never import Django — anything that needs it runs through the project
15
+ interpreter (:func:`karni_cli.project.run_manage`).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import difflib
21
+ import sys
22
+ import tomllib
23
+ from collections.abc import Iterable
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+
27
+ import typer
28
+
29
+ from karni_cli.project import CliError, require_project
30
+ from karni_cli.settings_edit import MARKER, block_entries
31
+
32
+ #: Ruff's default line length — what a project without ``[tool.ruff]`` gets.
33
+ DEFAULT_LINE_LENGTH = 88
34
+
35
+ SETTINGS = Path("config") / "settings.py"
36
+ FRONTEND = Path("frontend")
37
+
38
+ #: Import sections in ruff/isort order.
39
+ FUTURE, STDLIB, THIRD_PARTY, FIRST_PARTY, LOCAL = range(5)
40
+
41
+
42
+ @dataclass
43
+ class _Entry:
44
+ before: str | None
45
+ after: str
46
+
47
+
48
+ class Workspace:
49
+ """An in-memory overlay of ``root``: reads see planned writes; disk is untouched until commit.
50
+
51
+ :param root: the directory generators write under (the project, or the
52
+ cwd for ``make module`` outside a project).
53
+ :param force: overwrite existing targets instead of refusing.
54
+ """
55
+
56
+ def __init__(self, root: Path, *, force: bool = False) -> None:
57
+ self.root = root.resolve()
58
+ self.force = force
59
+ self.notes: list[str] = []
60
+ self._files: dict[Path, _Entry] = {}
61
+ self._line_length: int | None = None
62
+
63
+ # paths
64
+
65
+ def path(self, rel: str | Path) -> Path:
66
+ candidate = Path(rel)
67
+ return candidate if candidate.is_absolute() else self.root / candidate
68
+
69
+ def rel(self, path: Path) -> str:
70
+ try:
71
+ return path.relative_to(self.root).as_posix()
72
+ except ValueError:
73
+ return path.as_posix()
74
+
75
+ # reading
76
+
77
+ def read(self, rel: str | Path) -> str | None:
78
+ """Planned content, else disk content, else ``None``."""
79
+ path = self.path(rel)
80
+ if path in self._files:
81
+ return self._files[path].after
82
+ if path.is_file():
83
+ return path.read_text(encoding="utf-8")
84
+ return None
85
+
86
+ def exists(self, rel: str | Path) -> bool:
87
+ return self.read(rel) is not None
88
+
89
+ def is_dir(self, rel: str | Path) -> bool:
90
+ """A directory on disk, or one a planned file will create."""
91
+ path = self.path(rel)
92
+ if path.is_dir():
93
+ return True
94
+ return any(path in planned.parents for planned in self._files)
95
+
96
+ def module_exists(self, module: str) -> bool:
97
+ """``a.b`` exists under the root as ``a/b.py`` or a directory ``a/b/``."""
98
+ base = self.root.joinpath(*module.split("."))
99
+ return self.exists(base.with_suffix(".py")) or self.is_dir(base)
100
+
101
+ # writing
102
+
103
+ def create(self, rel: str | Path, content: str) -> None:
104
+ """Plan a new file; an existing one is an error unless ``--force``."""
105
+ path = self.path(rel)
106
+ if self.exists(path) and path not in self._files and not self.force:
107
+ raise CliError(
108
+ f"{self.rel(path)} already exists — pass --force to overwrite it.", code="C101"
109
+ )
110
+ self._record(path, content)
111
+
112
+ def create_skeleton(self, files: dict[Path, str]) -> None:
113
+ """Plan a multi-file skeleton (an app, a module) that never truncates user code.
114
+
115
+ Without ``--force`` any existing file is an error; with it, existing
116
+ files are kept and only the missing ones are created.
117
+ """
118
+ existing = [path for path in files if self.exists(path)]
119
+ if existing and not self.force:
120
+ shown = ", ".join(self.rel(path) for path in existing[:3])
121
+ raise CliError(
122
+ f"{shown} already exist{'s' if len(existing) == 1 else ''} — pass --force "
123
+ "to create only the missing files (existing ones are kept).",
124
+ code="C101",
125
+ )
126
+ for path, content in files.items():
127
+ if path in existing:
128
+ self.note(f"kept {self.rel(path)} (exists)")
129
+ else:
130
+ self.create(path, content)
131
+
132
+ def write(self, rel: str | Path, content: str) -> None:
133
+ """Plan the new content of a file being edited (or created by an edit)."""
134
+ self._record(self.path(rel), content)
135
+
136
+ def refuse_existing(self, what: str) -> None:
137
+ """Raise unless ``--force``: ``what`` (e.g. "class Event in events/models.py") exists."""
138
+ if not self.force:
139
+ raise CliError(f"{what} already exists — pass --force to replace it.", code="C101")
140
+
141
+ def note(self, text: str) -> None:
142
+ if text not in self.notes:
143
+ self.notes.append(text)
144
+
145
+ def _record(self, path: Path, content: str) -> None:
146
+ if path in self._files:
147
+ self._files[path].after = content
148
+ return
149
+ before = path.read_text(encoding="utf-8") if path.is_file() else None
150
+ self._files[path] = _Entry(before, content)
151
+
152
+ # project facts
153
+
154
+ @property
155
+ def line_length(self) -> int:
156
+ """The project's ruff line length (``[tool.ruff] line-length``, default 88)."""
157
+ if self._line_length is None:
158
+ self._line_length = DEFAULT_LINE_LENGTH
159
+ text = self.read("pyproject.toml")
160
+ if text:
161
+ try:
162
+ ruff = tomllib.loads(text).get("tool", {}).get("ruff", {})
163
+ except tomllib.TOMLDecodeError:
164
+ ruff = {}
165
+ value = ruff.get("line-length")
166
+ if isinstance(value, int) and value > 0:
167
+ self._line_length = value
168
+ return self._line_length
169
+
170
+ def import_section(self, module: str, level: int = 0) -> int:
171
+ """The ruff/isort section of ``module`` as seen from the project root."""
172
+ if level:
173
+ return LOCAL
174
+ top = module.split(".", 1)[0]
175
+ if top == "__future__":
176
+ return FUTURE
177
+ if top in sys.stdlib_module_names:
178
+ return STDLIB
179
+ if self.module_exists(module):
180
+ return FIRST_PARTY
181
+ return THIRD_PARTY
182
+
183
+ # output
184
+
185
+ def changes(self) -> list[tuple[Path, str | None, str]]:
186
+ return [
187
+ (path, entry.before, entry.after)
188
+ for path, entry in self._files.items()
189
+ if entry.before != entry.after
190
+ ]
191
+
192
+ def commit(self, *, dry_run: bool) -> None:
193
+ """Write (or, with ``dry_run``, show) every planned change, then the notes."""
194
+ for path, before, after in self.changes():
195
+ rel = self.rel(path)
196
+ verb = "create" if before is None else "modify"
197
+ if dry_run:
198
+ typer.echo(f"would {verb} {rel}")
199
+ diff = difflib.unified_diff(
200
+ (before or "").splitlines(keepends=True),
201
+ after.splitlines(keepends=True),
202
+ fromfile="/dev/null" if before is None else f"a/{rel}",
203
+ tofile=f"b/{rel}",
204
+ )
205
+ typer.echo("".join(diff).rstrip("\n"))
206
+ continue
207
+ path.parent.mkdir(parents=True, exist_ok=True)
208
+ path.write_text(after, encoding="utf-8")
209
+ typer.echo(f"{'created' if before is None else 'modified':<9}{rel}")
210
+ if not self.changes():
211
+ typer.echo("nothing to change")
212
+ for note in self.notes:
213
+ typer.echo(note)
214
+ if dry_run:
215
+ typer.echo("(dry run: nothing written)")
216
+
217
+
218
+ # shared command options
219
+
220
+ APP_OPTION = typer.Option(
221
+ None, "--app", help="Target app label (default: the project's only local app)."
222
+ )
223
+ FORCE_OPTION = typer.Option(False, "--force", help="Overwrite / replace existing targets.")
224
+ DRY_RUN_OPTION = typer.Option(
225
+ False, "--dry-run", help="Print the files and diff it would write; write nothing."
226
+ )
227
+
228
+
229
+ def project_workspace(force: bool) -> Workspace:
230
+ """A workspace over the project found from the cwd (manage.py)."""
231
+ return Workspace(require_project(), force=force)
232
+
233
+
234
+ # apps
235
+
236
+
237
+ @dataclass(frozen=True)
238
+ class App:
239
+ """A local app wired into the ``# karni:apps`` block."""
240
+
241
+ label: str
242
+ module: str
243
+ path: Path
244
+
245
+ def file(self, *parts: str) -> Path:
246
+ return self.path.joinpath(*parts)
247
+
248
+
249
+ def local_apps(ws: Workspace) -> list[App]:
250
+ """Entries of the ``# karni:apps`` block whose package lives in the project."""
251
+ text = ws.read(SETTINGS)
252
+ if text is None:
253
+ return []
254
+ found: list[App] = []
255
+ for entry in block_entries(text):
256
+ module = entry.split(".apps.", 1)[0]
257
+ path = ws.root.joinpath(*module.split("."))
258
+ if ws.is_dir(path):
259
+ found.append(App(module.rsplit(".", 1)[-1], module, path))
260
+ return found
261
+
262
+
263
+ def resolve_app(ws: Workspace, name: str | None) -> App:
264
+ """``--app NAME`` (label or module path), else the project's only local app."""
265
+ apps = local_apps(ws)
266
+ listing = ", ".join(app.label for app in apps)
267
+ if name is not None:
268
+ for app in apps:
269
+ if name in (app.label, app.module):
270
+ return app
271
+ if not apps:
272
+ raise CliError(
273
+ f"No local app {name!r} — create it first: karni make app {name}", code="C102"
274
+ )
275
+ raise CliError(
276
+ f"No local app {name!r} (local apps: {listing}) — or: karni make app {name}",
277
+ code="C102",
278
+ )
279
+ if len(apps) == 1:
280
+ return apps[0]
281
+ if not apps:
282
+ raise CliError(
283
+ "This project has no local app yet (none in the `# karni:apps` block of "
284
+ "config/settings.py) — create one first: karni make app <name>",
285
+ code="C102",
286
+ )
287
+ raise CliError(f"Several local apps ({listing}) — choose one with --app <label>.", code="C102")
288
+
289
+
290
+ def require_settings(ws: Workspace) -> str:
291
+ text = ws.read(SETTINGS)
292
+ if text is None:
293
+ raise CliError(
294
+ f"{SETTINGS.as_posix()} not found — run inside a Karni project.", code="C001"
295
+ )
296
+ if MARKER not in text:
297
+ raise CliError(
298
+ f"No `{MARKER}` block in {SETTINGS.as_posix()} — karni make finds local apps "
299
+ "there. Add one (see the scaffold's settings.py) and list your apps in it.",
300
+ code="C108",
301
+ )
302
+ return text
303
+
304
+
305
+ def require_frontend(ws: Workspace) -> Path:
306
+ path = ws.path(FRONTEND)
307
+ if not ws.is_dir(path):
308
+ raise CliError(
309
+ "No frontend/ directory in this project — pages and components live there.", code="C105"
310
+ )
311
+ return path
312
+
313
+
314
+ def split_csv(values: Iterable[str] | None) -> list[str]:
315
+ """``["a,b", "c"]`` → ``["a", "b", "c"]`` (repeatable and comma-separated options)."""
316
+ out: list[str] = []
317
+ for value in values or ():
318
+ out.extend(part.strip() for part in value.split(",") if part.strip())
319
+ return out
@@ -0,0 +1,57 @@
1
+ """``karni make event <name>`` — a custom event (a Django ``Signal``) in ``<app>/events.py``.
2
+
3
+ ``events.py`` is autodiscovered at startup together with ``listeners.py``
4
+ (:mod:`karni.events`); receive the event with ``karni make listener <name>
5
+ --on <event>``.
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 require_snake
22
+ from karni_cli.generators.pysource import Import, comment, ensure_imports, replace_or_append
23
+
24
+ NAME = "event"
25
+ SUMMARY = "Add a custom event (a Django Signal) to <app>/events.py."
26
+
27
+
28
+ def plan(ws: Workspace, app: App, name: str) -> str:
29
+ require_snake(name, "Event name")
30
+ path = app.file("events.py")
31
+ rel = ws.rel(path)
32
+ line = ws.line_length
33
+ doc = "\n".join(
34
+ [
35
+ comment(f"Send: {name}.send(sender=MyModel, instance=obj)", "", line, "#: "),
36
+ comment(f"Receive: karni make listener <name> --on {name}", "", line, "#: "),
37
+ ]
38
+ )
39
+ block = f"{doc}\n{name} = Signal()"
40
+ text = ensure_imports(ws, rel, ws.read(path) or "", [Import("django.dispatch", "Signal")])
41
+ ws.write(path, replace_or_append(ws, rel, text, name, block))
42
+ ws.note(
43
+ f"event: {app.module}.events.{name} — listen with: karni make listener <name> --on {name}"
44
+ )
45
+ return name
46
+
47
+
48
+ def command(
49
+ name: str = typer.Argument(..., help="Event name, snake_case (e.g. order_shipped)."),
50
+ app: str | None = APP_OPTION,
51
+ force: bool = FORCE_OPTION,
52
+ dry_run: bool = DRY_RUN_OPTION,
53
+ ) -> None:
54
+ """Add `NAME = Signal()` to APP/events.py (autodiscovered with the listeners)."""
55
+ ws = project_workspace(force)
56
+ plan(ws, resolve_app(ws, app), name)
57
+ ws.commit(dry_run=dry_run)
@@ -0,0 +1,88 @@
1
+ """``karni make factory <Model>`` — an explicit test factory in ``<app>/factories.py``.
2
+
3
+ Every model already has a generated factory (:mod:`karni.testing.factories`):
4
+ fields get realistic values from their names and types. Write an explicit one
5
+ only to change some of them — it derives from ``ModelFactory``, so it
6
+ declares just what differs, and it then replaces the generated factory
7
+ everywhere (tests, related rows, ``karni seed``, the N+1 scaling check).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import typer
13
+
14
+ from karni_cli.generators.core import (
15
+ APP_OPTION,
16
+ DRY_RUN_OPTION,
17
+ FORCE_OPTION,
18
+ App,
19
+ Workspace,
20
+ project_workspace,
21
+ resolve_app,
22
+ )
23
+ from karni_cli.generators.introspect import require_model
24
+ from karni_cli.generators.naming import require_pascal
25
+ from karni_cli.generators.pysource import Import, docstring, ensure_imports, replace_or_append
26
+
27
+ NAME = "factory"
28
+ SUMMARY = "Add an explicit test factory to <app>/factories.py (overrides the generated one)."
29
+
30
+
31
+ def plan(ws: Workspace, app: App, model_name: str) -> str:
32
+ """Plan ``<Model>Factory``; returns the class name."""
33
+ model = require_model(ws, app, require_pascal(model_name, "Model"))
34
+ path = model.app.file("factories.py")
35
+ rel = ws.rel(path)
36
+ line = ws.line_length
37
+ text = ws.read(path)
38
+ if text is None:
39
+ text = (
40
+ docstring(f"{model.app.label} test factories: declare only what differs.", "", line)
41
+ + "\n"
42
+ )
43
+ class_name = f"{model.name}Factory"
44
+ block = "\n".join(
45
+ [
46
+ f"class {class_name}(ModelFactory):",
47
+ docstring(
48
+ f"Test rows for {model.name}; undeclared fields keep their generated values.",
49
+ " ",
50
+ line,
51
+ ),
52
+ "",
53
+ " class Meta:",
54
+ f" model = {model.name}",
55
+ "",
56
+ " # Declare only the fields to change, e.g.:",
57
+ ' # title = factory.Faker("catch_phrase")',
58
+ ]
59
+ )
60
+ text = ensure_imports(
61
+ ws,
62
+ rel,
63
+ text,
64
+ [
65
+ Import("karni.testing.factories", "ModelFactory"),
66
+ Import(model.import_module, model.name),
67
+ ],
68
+ )
69
+ ws.write(path, replace_or_append(ws, rel, text, class_name, block))
70
+ ws.note(f"factory: {model.app.module}.factories.{class_name} (picked up automatically)")
71
+ return class_name
72
+
73
+
74
+ def command(
75
+ model: str = typer.Argument(..., help="Model class name (e.g. Event)."),
76
+ app: str | None = APP_OPTION,
77
+ force: bool = FORCE_OPTION,
78
+ dry_run: bool = DRY_RUN_OPTION,
79
+ ) -> None:
80
+ """Add `<Model>Factory(ModelFactory)` to APP/factories.py.
81
+
82
+ Only needed to change generated values: an explicit factory replaces the
83
+ generated one everywhere (tests, related rows, karni seed), and fields it
84
+ does not declare keep their generated, realistic values.
85
+ """
86
+ ws = project_workspace(force)
87
+ plan(ws, resolve_app(ws, app), model)
88
+ ws.commit(dry_run=dry_run)