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,206 @@
1
+ """``karni make test <Model>`` — the resource's API test: full CRUD surface plus isolation.
2
+
3
+ Writes ``<app>/tests/test_<model>.py`` with a :class:`karni.testing.APITestCase`
4
+ that calls ``assert_crud`` (list, retrieve, create, update, delete, the
5
+ promised denials, pagination, filtering and the OpenAPI contract, in one
6
+ call) and — when the viewset's ``access`` reads through ``Owned(...)``, or
7
+ through ``Granted()`` with a role whose grant is ``own`` / ``related`` —
8
+ ``assert_isolated`` (another scope's rows are out of reach). The role is
9
+ ``--role``, else the first ``HasRole("...")`` the viewset declares, else for
10
+ ``Granted()`` the first role in ``KARNI_ROLES`` (the shipped ``user`` when
11
+ the project declares none) whose grants reach the model, else any signed-in
12
+ user. The model needs a viewset first (``karni make viewset``).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import textwrap
18
+
19
+ import typer
20
+
21
+ from karni_cli.generators.core import (
22
+ APP_OPTION,
23
+ DRY_RUN_OPTION,
24
+ FORCE_OPTION,
25
+ App,
26
+ Workspace,
27
+ project_workspace,
28
+ resolve_app,
29
+ )
30
+ from karni_cli.generators.introspect import (
31
+ ModelInfo,
32
+ declared_roles,
33
+ grant_scopes,
34
+ granted_reads,
35
+ owned_paths,
36
+ require_model,
37
+ role_names,
38
+ viewset_for,
39
+ viewset_module,
40
+ )
41
+ from karni_cli.generators.naming import require_pascal, snake
42
+ from karni_cli.generators.pysource import Import, docstring, ensure_imports, pystr
43
+ from karni_cli.project import CliError
44
+
45
+ NAME = "test"
46
+ SUMMARY = "Add <app>/tests/test_<model>.py: assert_crud + the isolation check for a resource."
47
+
48
+
49
+ def render(
50
+ ws: Workspace,
51
+ model: str,
52
+ viewset: str,
53
+ module: str,
54
+ role: str | None,
55
+ isolation: bool,
56
+ note: str | None = None,
57
+ ) -> str:
58
+ """The test module's text (ruff-clean in the project's layout)."""
59
+ rel = "tests/test.py"
60
+ line = ws.line_length
61
+ role_arg = f", {pystr(role)}" if role else ""
62
+ lines = [
63
+ docstring(
64
+ f"The {model} API: CRUD, access" + (" and isolation" if isolation else "") + ".",
65
+ "",
66
+ line,
67
+ ),
68
+ "",
69
+ "",
70
+ f"class {model}APITests(APITestCase):",
71
+ " def test_crud(self) -> None:",
72
+ f" self.assert_crud({viewset}{role_arg})",
73
+ ]
74
+ if isolation:
75
+ lines += [
76
+ "",
77
+ " def test_owners_cannot_reach_each_others_rows(self) -> None:",
78
+ f" self.assert_isolated({viewset}{role_arg})",
79
+ ]
80
+ elif note:
81
+ wrapped = textwrap.wrap(f"No assert_isolated: {note}", width=max(line - 6, 40))
82
+ lines += ["", *(f" # {part}" for part in wrapped)]
83
+ text = "\n".join(lines) + "\n"
84
+ return ensure_imports(
85
+ ws, rel, text, [Import("karni.testing", "APITestCase"), Import(module, viewset)]
86
+ )
87
+
88
+
89
+ def _scoped(scope: str | None) -> bool:
90
+ return scope is not None and (scope == "own" or scope.startswith("related:"))
91
+
92
+
93
+ def no_isolation_reason(ws: Workspace, model: ModelInfo, *, explicit: bool) -> str | None:
94
+ """Why no ``assert_isolated`` is written, when a reader would expect one; else ``None``.
95
+
96
+ Never silent: a role reaching the rows through a ``custom:`` scope is
97
+ always named (``assert_isolated`` cannot drive one); with the role picked
98
+ automatically, a resource no role partitions is named too. An explicit
99
+ ``--role`` whose grant is ``all`` is the caller's choice and says nothing.
100
+ """
101
+ key = f"{model.app.label}.{model.name.lower()}:view"
102
+ scopes = grant_scopes(declared_roles(ws), key)
103
+ custom = sorted(
104
+ f"{slug!r} ({scope})" for slug, scope in scopes.items() if scope.startswith("custom:")
105
+ )
106
+ if custom:
107
+ return (
108
+ f"{', '.join(custom)} reach {key} through a custom scope, which assert_isolated "
109
+ "cannot drive. For membership, grant 'own' with `class Karni: owner_field = "
110
+ "'<path to the user>'` (e.g. 'workspace__members') and regenerate this test."
111
+ )
112
+ if not explicit and scopes:
113
+ return (
114
+ f"no role's grant on {key} is 'own' or 'related:<key>', so every role that reads "
115
+ f"{model.name} sees every row. If rows belong to someone, declare "
116
+ "`class Karni: owner_field` and grant 'own'."
117
+ )
118
+ return None
119
+
120
+
121
+ def granted_role(ws: Workspace, model: ModelInfo, role: str | None) -> tuple[str | None, bool]:
122
+ """``(role, isolates)`` for a ``Granted()`` viewset of ``model``.
123
+
124
+ ``role`` when given; else the first role in ``KARNI_ROLES`` whose grant
125
+ for the model's ``view`` permission is ``own`` / ``related`` (so the
126
+ isolation check applies), else the first whose grants reach it at all.
127
+ ``isolates`` says whether that role's grant partitions the rows.
128
+ """
129
+ key = f"{model.app.label}.{model.name.lower()}:view"
130
+ scopes = grant_scopes(declared_roles(ws), key)
131
+ if role is not None:
132
+ return role, _scoped(scopes.get(role))
133
+ for slug, scope in scopes.items():
134
+ if _scoped(scope):
135
+ return slug, True
136
+ if scopes:
137
+ return next(iter(scopes)), False
138
+ return None, False
139
+
140
+
141
+ def plan(ws: Workspace, app: App, model_name: str, *, role: str | None = None) -> str:
142
+ """Plan the test module for ``model_name``; returns its path relative to the project."""
143
+ model = require_model(ws, app, require_pascal(model_name, "Model"))
144
+ found = viewset_for(ws, model.app, model.name)
145
+ if found is None:
146
+ raise CliError(
147
+ f"No viewset routes {model.name} — create it first: karni make viewset "
148
+ f"{model.name} --app {model.app.label} (the test drives the resource's URLs).",
149
+ code="C105",
150
+ )
151
+ path, cls = found
152
+ roles = role_names(cls)
153
+ chosen = role if role is not None else (roles[0] if roles else None)
154
+ owned = bool(owned_paths(cls))
155
+ granted = not owned and granted_reads(cls)
156
+ how = " + owner isolation" if owned else ""
157
+ note: str | None = None
158
+ if granted and chosen is None or granted and role is not None:
159
+ chosen, scoped = granted_role(ws, model, role)
160
+ how = " + grant isolation" if scoped else ""
161
+ if not scoped:
162
+ note = no_isolation_reason(ws, model, explicit=role is not None)
163
+ if chosen is None:
164
+ ws.note(
165
+ f"no role in KARNI_ROLES grants {model.app.label}.{model.name.lower()}:view; "
166
+ "as any signed-in user the test is refused everything — pass --role"
167
+ )
168
+ module = viewset_module(ws, model.app, path)
169
+ tests = model.app.file("tests")
170
+ init = tests / "__init__.py"
171
+ if not ws.exists(init):
172
+ ws.create(init, "")
173
+ target = tests / f"test_{snake(model.name)}.py"
174
+ ws.create(target, render(ws, model.name, cls.name, module, chosen, bool(how), note))
175
+ who = f"role {chosen!r}" if chosen else "any signed-in user"
176
+ ws.note(f"test: {cls.name} as {who}{how}")
177
+ if note:
178
+ ws.note(f"isolation: NOT generated — {note}")
179
+ ws.note(f"run: karni test {ws.rel(target)}")
180
+ return ws.rel(target)
181
+
182
+
183
+ def command(
184
+ model: str = typer.Argument(..., help="Model class name (e.g. Event); it needs a viewset."),
185
+ role: str | None = typer.Option(
186
+ None,
187
+ "--role",
188
+ help="The role the test acts as. Default: the first HasRole the viewset declares; "
189
+ "for Granted() the first KARNI_ROLES role whose grant reaches the model (own/related "
190
+ "first); else any signed-in user.",
191
+ ),
192
+ app: str | None = APP_OPTION,
193
+ force: bool = FORCE_OPTION,
194
+ dry_run: bool = DRY_RUN_OPTION,
195
+ ) -> None:
196
+ """Add APP/tests/test_<model>.py — the resource's whole API surface in a few lines.
197
+
198
+ One APITestCase: `assert_crud` checks list, retrieve, create, update,
199
+ delete, the denials the access declaration promises, pagination,
200
+ filtering and the OpenAPI contract; `assert_isolated` (when reads are
201
+ Owned, or Granted() with an own/related grant) checks another scope's
202
+ rows are out of reach on every surface. Run it with `karni test`.
203
+ """
204
+ ws = project_workspace(force)
205
+ plan(ws, resolve_app(ws, app), model, role=role)
206
+ ws.commit(dry_run=dry_run)
@@ -0,0 +1,103 @@
1
+ """``karni make app <name>`` — a local Django app, wired into ``# karni:apps``.
2
+
3
+ Replaces ``manage.py startapp``: only ``models.py`` and ``viewsets.py`` (plus
4
+ ``apps.py``, ``migrations/`` and ``tests/``) — no admin.py, serializers.py or
5
+ urls.py, which Karni derives.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+
12
+ import typer
13
+
14
+ from karni_cli.generators.core import (
15
+ DRY_RUN_OPTION,
16
+ SETTINGS,
17
+ Workspace,
18
+ project_workspace,
19
+ require_settings,
20
+ )
21
+ from karni_cli.generators.naming import pascal, require_snake
22
+ from karni_cli.generators.pysource import docstring
23
+ from karni_cli.project import CliError
24
+ from karni_cli.settings_edit import MARKER, add_entry
25
+
26
+ NAME = "app"
27
+ SUMMARY = "Create a local Django app (models.py, viewsets.py) wired into # karni:apps."
28
+
29
+ #: Top-level names a project already uses, or that would shadow a dependency.
30
+ _TAKEN = frozenset(
31
+ {"config", "frontend", "tests", "modules", "manage", "karni", "django", "rest_framework"}
32
+ )
33
+
34
+
35
+ def plan(ws: Workspace, name: str) -> str:
36
+ """Plan the app ``name``; returns its label."""
37
+ require_snake(name, "App name")
38
+ if name in _TAKEN or name in sys.stdlib_module_names:
39
+ raise CliError(
40
+ f"App name {name!r} would shadow an existing module — choose another.", code="C103"
41
+ )
42
+ if name.startswith("karni_"):
43
+ raise CliError(
44
+ f"App name {name!r}: karni_* names belong to Karni modules — use "
45
+ "`karni make module` for a reusable module, or drop the prefix.",
46
+ code="C103",
47
+ )
48
+ settings = require_settings(ws)
49
+ root = ws.path(name)
50
+ if ws.exists(ws.path(f"{name}.py")):
51
+ raise CliError(
52
+ f"{name}.py exists in the project root — an app {name}/ would clash.", code="C103"
53
+ )
54
+ line = ws.line_length
55
+ files = {
56
+ "__init__.py": "",
57
+ "apps.py": (
58
+ "from django.apps import AppConfig\n\n\n"
59
+ f"class {pascal(name)}Config(AppConfig):\n"
60
+ ' default_auto_field = "django.db.models.BigAutoField"\n'
61
+ f' name = "{name}"\n'
62
+ ),
63
+ "models.py": docstring(
64
+ f"{name} models, each on karni.models.BaseModel: `karni make model <Name>`.", "", line
65
+ )
66
+ + "\n",
67
+ "viewsets.py": docstring(
68
+ f"{name} API: BaseViewSet subclasses, auto-routed: `karni make viewset <Model>`.",
69
+ "",
70
+ line,
71
+ )
72
+ + "\n",
73
+ "migrations/__init__.py": "",
74
+ "tests/__init__.py": "",
75
+ }
76
+ ws.create_skeleton({root / rel: content for rel, content in files.items()})
77
+ updated = add_entry(
78
+ settings, name, setting="INSTALLED_APPS", marker=MARKER, filename=SETTINGS.as_posix()
79
+ )
80
+ if updated is not None:
81
+ ws.write(SETTINGS, updated)
82
+ ws.note(f"next: karni make model <Name> [field:type ...] --app {name}")
83
+ return name
84
+
85
+
86
+ def command(
87
+ name: str = typer.Argument(..., help="App label, snake_case (e.g. events)."),
88
+ force: bool = typer.Option(
89
+ False, "--force", help="Create only the missing files of an existing app (keeps the rest)."
90
+ ),
91
+ dry_run: bool = DRY_RUN_OPTION,
92
+ ) -> None:
93
+ """Create a local app — replaces `manage.py startapp`.
94
+
95
+ Writes NAME/{__init__,apps,models,viewsets}.py, NAME/migrations/ and
96
+ NAME/tests/, and adds NAME to the `# karni:apps` block of
97
+ config/settings.py. No admin.py, serializers.py or urls.py: Karni derives
98
+ them. An existing app is never truncated: --force only fills in missing
99
+ files.
100
+ """
101
+ ws = project_workspace(force)
102
+ plan(ws, name)
103
+ ws.commit(dry_run=dry_run)
@@ -0,0 +1,98 @@
1
+ """``karni make command <name>`` — a management command in ``<app>/management/commands/``.
2
+
3
+ Run it with ``karni manage <name>`` (through the project interpreter).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import typer
9
+
10
+ from karni_cli.generators.core import (
11
+ APP_OPTION,
12
+ DRY_RUN_OPTION,
13
+ FORCE_OPTION,
14
+ App,
15
+ Workspace,
16
+ project_workspace,
17
+ resolve_app,
18
+ )
19
+ from karni_cli.generators.naming import humanize, require_snake
20
+ from karni_cli.generators.pysource import pystr
21
+ from karni_cli.project import CliError
22
+
23
+ NAME = "command"
24
+ SUMMARY = "Add a management command (<app>/management/commands/<name>.py)."
25
+
26
+ #: Django's and Karni's own commands: an app command of the same name is shadowed.
27
+ BUILTIN = frozenset(
28
+ {
29
+ "changepassword",
30
+ "check",
31
+ "clearsessions",
32
+ "collectstatic",
33
+ "createsuperuser",
34
+ "dbshell",
35
+ "diffsettings",
36
+ "dumpdata",
37
+ "flush",
38
+ "inspectdb",
39
+ "karni_check",
40
+ "karni_context",
41
+ "karni_routes",
42
+ "karni_schedules",
43
+ "loaddata",
44
+ "makemigrations",
45
+ "migrate",
46
+ "runserver",
47
+ "sendtestemail",
48
+ "shell",
49
+ "showmigrations",
50
+ "spectacular",
51
+ "sqlflush",
52
+ "sqlmigrate",
53
+ "sqlsequencereset",
54
+ "squashmigrations",
55
+ "startapp",
56
+ "startproject",
57
+ "test",
58
+ "testserver",
59
+ }
60
+ )
61
+
62
+
63
+ def plan(ws: Workspace, app: App, name: str) -> None:
64
+ require_snake(name, "Command name")
65
+ if name in BUILTIN:
66
+ raise CliError(
67
+ f"`{name}` is a Django/Karni command — an app command of that name would be "
68
+ "shadowed. Choose another name.",
69
+ code="C103",
70
+ )
71
+ for package in ("management", "management/commands"):
72
+ init = app.file(*package.split("/"), "__init__.py")
73
+ if not ws.exists(init):
74
+ ws.create(init, "")
75
+ help_text = pystr(f"{humanize(name)}.")
76
+ done = pystr(f"{name}: done")
77
+ ws.create(
78
+ app.file("management", "commands", f"{name}.py"),
79
+ "from typing import Any\n\n"
80
+ "from django.core.management.base import BaseCommand\n\n\n"
81
+ "class Command(BaseCommand):\n"
82
+ f" help = {help_text}\n\n"
83
+ " def handle(self, *args: Any, **options: Any) -> None:\n"
84
+ f" self.stdout.write(self.style.SUCCESS({done}))\n",
85
+ )
86
+ ws.note(f"run: karni manage {name}")
87
+
88
+
89
+ def command(
90
+ name: str = typer.Argument(..., help="Command name, snake_case (e.g. send_reminders)."),
91
+ app: str | None = APP_OPTION,
92
+ force: bool = FORCE_OPTION,
93
+ dry_run: bool = DRY_RUN_OPTION,
94
+ ) -> None:
95
+ """Add a management command NAME to APP (run it with `karni manage NAME`)."""
96
+ ws = project_workspace(force)
97
+ plan(ws, resolve_app(ws, app), name)
98
+ ws.commit(dry_run=dry_run)
@@ -0,0 +1,115 @@
1
+ """``karni make component <Name>`` — a React component that claims a role.
2
+
3
+ Every component registers its semantic role (``registerComponentRole`` from
4
+ @karni/ui), so two components doing the same job fail loudly instead of
5
+ drifting apart (K303: extend, never fork). A role @karni/ui or the project
6
+ already claims is refused; replacing one on purpose takes ``--replace-reason``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+
14
+ import typer
15
+
16
+ from karni_cli.commands.context import find_manifest
17
+ from karni_cli.generators.core import (
18
+ DRY_RUN_OPTION,
19
+ FORCE_OPTION,
20
+ FRONTEND,
21
+ Workspace,
22
+ project_workspace,
23
+ require_frontend,
24
+ )
25
+ from karni_cli.generators.naming import kebab, require_kebab, require_pascal
26
+ from karni_cli.project import CliError
27
+
28
+ NAME = "component"
29
+ SUMMARY = "Add a React component that claims a role (registerComponentRole)."
30
+
31
+ COMPONENTS = FRONTEND / "src" / "components"
32
+ _REGISTERED = re.compile(r"""registerComponentRole\(\s*["']([^"']+)["']\s*,\s*["']([^"']+)["']""")
33
+
34
+
35
+ def _claimed(ws: Workspace, skip: str) -> dict[str, str]:
36
+ """``role -> owner`` from the @karni/ui manifest and the project's own sources."""
37
+ claimed: dict[str, str] = {}
38
+ manifest = find_manifest(ws.root, None)
39
+ if manifest is not None:
40
+ try:
41
+ roles = json.loads(manifest.read_text()).get("roles") or {}
42
+ except (OSError, json.JSONDecodeError):
43
+ roles = {}
44
+ claimed.update({role: f"@karni/ui's {owner}" for role, owner in roles.items()})
45
+ else:
46
+ ws.note("note: @karni/ui is not installed — its roles could not be checked.")
47
+ src = ws.path(FRONTEND / "src")
48
+ if src.is_dir():
49
+ for path in sorted(src.rglob("*.ts*")):
50
+ if "node_modules" in path.parts or ws.rel(path) == skip:
51
+ continue
52
+ text = ws.read(path) or ""
53
+ for role, owner in _REGISTERED.findall(text):
54
+ claimed[role] = f"{owner} ({ws.rel(path)})"
55
+ return claimed
56
+
57
+
58
+ def plan(ws: Workspace, name: str, *, role: str | None, replace_reason: str | None) -> str:
59
+ require_frontend(ws)
60
+ component = require_pascal(name, "Component name")
61
+ role = require_kebab(role or kebab(component), "--role")
62
+ target = COMPONENTS / f"{component}.tsx"
63
+ owner = _claimed(ws, ws.rel(ws.path(target))).get(role)
64
+ options = ""
65
+ if owner is not None:
66
+ if not replace_reason:
67
+ raise CliError(
68
+ f"Role {role!r} is already claimed by {owner}. Compose or extend it instead "
69
+ "(K303), pick another --role, or replace it on purpose with "
70
+ '--replace-reason "<why>".',
71
+ code="C107",
72
+ )
73
+ reason = json.dumps(replace_reason)
74
+ options = f", {{ replace: true, reason: {reason} }}"
75
+ elif replace_reason:
76
+ raise CliError(f"--replace-reason: no component claims role {role!r} yet.", code="C106")
77
+ source = (
78
+ 'import type { ReactNode } from "react";\n'
79
+ 'import { registerComponentRole } from "@karni/ui";\n'
80
+ "\n"
81
+ f'registerComponentRole("{role}", "{component}"{options});\n'
82
+ "\n"
83
+ f"export interface {component}Props {{\n"
84
+ " children?: ReactNode;\n"
85
+ "}\n"
86
+ "\n"
87
+ f"export function {component}({{ children }}: {component}Props) {{\n"
88
+ " return <div>{children}</div>;\n"
89
+ "}\n"
90
+ )
91
+ ws.create(target, source)
92
+ ws.note(f"role: {role} -> {component}")
93
+ return role
94
+
95
+
96
+ def command(
97
+ name: str = typer.Argument(..., help="Component name, PascalCase (e.g. StatusBadge)."),
98
+ role: str | None = typer.Option(
99
+ None, "--role", help="Semantic role it claims (default: kebab-case of NAME)."
100
+ ),
101
+ replace_reason: str | None = typer.Option(
102
+ None, "--replace-reason", help="Take over a role another component claims, saying why."
103
+ ),
104
+ force: bool = FORCE_OPTION,
105
+ dry_run: bool = DRY_RUN_OPTION,
106
+ ) -> None:
107
+ """Add frontend/src/components/NAME.tsx, registered with registerComponentRole.
108
+
109
+ A role already claimed by @karni/ui (its manifest) or by another project
110
+ component is refused — compose that component instead, or pass
111
+ --replace-reason to replace it on purpose.
112
+ """
113
+ ws = project_workspace(force)
114
+ plan(ws, name, role=role, replace_reason=replace_reason)
115
+ ws.commit(dry_run=dry_run)