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