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,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)
|