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,373 @@
1
+ """``karni make page <name>`` — a ≤ 50-line page composing @karni/ui, routed.
2
+
3
+ Kinds (``--resource R --kind ...``), with routes under a base path
4
+ (``--path``, default ``/<resource>``):
5
+
6
+ * ``list`` → ``<base>``: ``<Page><ResourceTable resource=R /></Page>``
7
+ * ``detail`` → ``<base>/$id``: ``<ResourceDetail resource=R id={id} />``
8
+ * ``form`` → ``<base>/new`` and ``<base>/$id/edit``: ``<ResourceForm />``
9
+
10
+ Pages link to their siblings only when those routes exist (or are generated
11
+ in the same run) — TanStack Router's typed ``navigate`` rejects unknown
12
+ paths, so a link to a missing page would not compile. Without
13
+ ``--resource`` the page is a bare ``<Page title=... />`` at ``/<name>``.
14
+
15
+ Routes are registered in ``frontend/src/routes.tsx`` between the
16
+ ``// karni:page-imports`` and ``// karni:routes`` markers.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import ast
22
+ import re
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+
26
+ import typer
27
+
28
+ from karni_cli.generators.core import (
29
+ DRY_RUN_OPTION,
30
+ FORCE_OPTION,
31
+ FRONTEND,
32
+ Workspace,
33
+ local_apps,
34
+ project_workspace,
35
+ require_frontend,
36
+ )
37
+ from karni_cli.generators.introspect import viewsets_file
38
+ from karni_cli.generators.naming import default_route, humanize, pascal, require_kebab
39
+ from karni_cli.generators.pysource import parse
40
+ from karni_cli.project import CliError
41
+
42
+ NAME = "page"
43
+ SUMMARY = "Add a frontend page (<= 50 lines) composing @karni/ui, with its route."
44
+
45
+ KINDS = ("list", "detail", "form", "login")
46
+ MAX_LINES = 50
47
+ PAGES = FRONTEND / "src" / "pages"
48
+ ROUTES = FRONTEND / "src" / "routes.tsx"
49
+ IMPORT_MARKER = "// karni:page-imports"
50
+ ROUTE_MARKER = "// karni:routes"
51
+
52
+ _PATH = re.compile(r"^/([A-Za-z0-9_.$-]+(/[A-Za-z0-9_.$-]+)*)?$")
53
+ _ROUTE_PATH = re.compile(r"""path:\s*["']([^"']+)["']""")
54
+ _SUFFIXES = ("-detail", "-form", "-list", "-page")
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class PageSpec:
59
+ name: str
60
+ kind: str | None
61
+ resource: str | None
62
+ base: str
63
+ title: str
64
+ #: Relation fields a detail page asks the API to expand (labels, not raw ids).
65
+ expand: tuple[str, ...] = ()
66
+
67
+ @property
68
+ def component(self) -> str:
69
+ stem = pascal(self.name)
70
+ return stem if stem.endswith("Page") else f"{stem}Page"
71
+
72
+ @property
73
+ def paths(self) -> list[str]:
74
+ if self.kind == "detail":
75
+ return [f"{self.base}/$id"]
76
+ if self.kind == "form":
77
+ return [f"{self.base}/new", f"{self.base}/$id/edit"]
78
+ return [self.base]
79
+
80
+ @property
81
+ def file(self) -> Path:
82
+ return PAGES / f"{self.name}.tsx"
83
+
84
+
85
+ def spec_for(
86
+ name: str,
87
+ *,
88
+ resource: str | None,
89
+ kind: str | None,
90
+ path: str | None,
91
+ title: str | None = None,
92
+ expand: tuple[str, ...] | list[str] | None = None,
93
+ ) -> PageSpec:
94
+ require_kebab(name, "Page name")
95
+ if kind is not None and kind not in KINDS:
96
+ raise CliError(f"--kind {kind!r}: choose one of {', '.join(KINDS)}.", code="C106")
97
+ if kind is not None and kind != "login" and resource is None:
98
+ raise CliError(
99
+ f"--kind {kind} needs --resource <route>, e.g. --resource events.", code="C004"
100
+ )
101
+ if resource is not None:
102
+ require_kebab(resource, "--resource")
103
+ kind = kind or "list"
104
+ base = (path or f"/{resource or name}").rstrip("/") or "/"
105
+ if not _PATH.match(base):
106
+ raise CliError(f"--path {path!r} must look like /events or /admin/events.", code="C106")
107
+ if title is None:
108
+ stem = name
109
+ for suffix in _SUFFIXES:
110
+ stem = stem.removesuffix(suffix)
111
+ title = (
112
+ "Sign in"
113
+ if kind == "login"
114
+ else humanize(resource if kind == "list" and resource else stem)
115
+ )
116
+ if any(ch in title for ch in '"{}<>\\`'):
117
+ raise CliError(
118
+ f"--title {title!r}: plain text only (no quotes, braces or angle brackets).",
119
+ code="C106",
120
+ )
121
+ return PageSpec(name, kind, resource, base, title, tuple(expand or ()))
122
+
123
+
124
+ def known_routes(ws: Workspace) -> set[str]:
125
+ text = ws.read(ROUTES) or ""
126
+ return set(_ROUTE_PATH.findall(text))
127
+
128
+
129
+ def _routed_resources(ws: Workspace) -> set[str]:
130
+ """Routes of the project's viewsets (default kebab-plural, or an explicit ``route``)."""
131
+ found: set[str] = set()
132
+ for app in local_apps(ws):
133
+ path = viewsets_file(ws, app)
134
+ text = ws.read(path)
135
+ if not text:
136
+ continue
137
+ for node in parse(ws, ws.rel(path), text).body:
138
+ if not isinstance(node, ast.ClassDef):
139
+ continue
140
+ explicit = None
141
+ model = None
142
+ for stmt in node.body:
143
+ if not isinstance(stmt, ast.Assign) or len(stmt.targets) != 1:
144
+ continue
145
+ target = stmt.targets[0]
146
+ if not isinstance(target, ast.Name):
147
+ continue
148
+ if target.id == "route" and isinstance(stmt.value, ast.Constant):
149
+ explicit = str(stmt.value.value)
150
+ elif target.id == "model" and isinstance(stmt.value, ast.Name):
151
+ model = stmt.value.id
152
+ if explicit:
153
+ found.add(explicit)
154
+ elif model:
155
+ found.add(default_route(model))
156
+ return found
157
+
158
+
159
+ # rendering
160
+
161
+
162
+ def _ui_import(names: set[str]) -> str:
163
+ return "import { " + ", ".join(sorted(names)) + ' } from "@karni/ui";'
164
+
165
+
166
+ def _router_import(names: set[str]) -> str:
167
+ return "import { " + ", ".join(sorted(names)) + ' } from "@tanstack/react-router";'
168
+
169
+
170
+ def _go(to: str, params: str = "") -> str:
171
+ extra = f", params: {{ {params} }}" if params else ""
172
+ return f'void navigate({{ to: "{to}"{extra} }})'
173
+
174
+
175
+ def render(spec: PageSpec, routes: set[str]) -> str:
176
+ """The page source; links only to routes in ``routes``."""
177
+ base = spec.base.rstrip("/")
178
+ if spec.kind is None:
179
+ return (
180
+ f"{_ui_import({'Page'})}\n\n"
181
+ f"export default function {spec.component}() {{\n"
182
+ f' return <Page title="{spec.title}" />;\n'
183
+ "}\n"
184
+ )
185
+ if spec.kind == "login":
186
+ home = "/" if "/" in routes or not routes else min(routes)
187
+ props = [f"onSuccess={{() => {_go(home)}}}"]
188
+ return _shell(spec, "LoginForm", props, None, hooks=[], ui={"LoginForm"})
189
+ detail, new, edit = f"{base}/$id", f"{base}/new", f"{base}/$id/edit"
190
+ resource = spec.resource
191
+ if spec.kind == "list":
192
+ props = [f'resource="{resource}"']
193
+ if detail in routes:
194
+ props.append(f"onRowClick={{(row) =>\n {_go(detail, 'id: String(row.id)')}\n}}")
195
+ if edit in routes:
196
+ props.append(f"onEdit={{(row) =>\n {_go(edit, 'id: String(row.id)')}\n}}")
197
+ actions = f"<Button onClick={{() => {_go(new)}}}>New</Button>" if new in routes else None
198
+ return _shell(spec, "ResourceTable", props, actions, hooks=[], ui={"ResourceTable"})
199
+ if spec.kind == "detail":
200
+ hooks = [f'const {{ id }} = useParams({{ from: "{detail}" }});']
201
+ actions = None
202
+ if edit in routes:
203
+ actions = f"<Button onClick={{() => {_go(edit, 'id')}}}>\n Edit\n</Button>"
204
+ props = [f'resource="{resource}"', "id={id}"]
205
+ if spec.expand:
206
+ names = ", ".join(f'"{name}"' for name in spec.expand)
207
+ props.append(f"expand={{[{names}]}}")
208
+ return _shell(spec, "ResourceDetail", props, actions, hooks=hooks, ui={"ResourceDetail"})
209
+ hooks = ["const { id } = useParams({ strict: false });"]
210
+ props = [f'resource="{resource}"', "id={id}"]
211
+ if detail in routes:
212
+ props.append(f"onSuccess={{(result) =>\n {_go(detail, 'id: String(result.id)')}\n}}")
213
+ elif spec.base in routes:
214
+ props.append(f"onSuccess={{() => {_go(spec.base)}}}")
215
+ noun = spec.title.lower()
216
+ title = f'{{id === undefined ? "New {noun}" : "Edit {noun}"}}'
217
+ return _shell(spec, "ResourceForm", props, None, hooks=hooks, ui={"ResourceForm"}, title=title)
218
+
219
+
220
+ def _indent(text: str, spaces: int) -> str:
221
+ pad = " " * spaces
222
+ return "\n".join(pad + line if line else line for line in text.split("\n"))
223
+
224
+
225
+ def _shell(
226
+ spec: PageSpec,
227
+ element: str,
228
+ props: list[str],
229
+ actions: str | None,
230
+ *,
231
+ hooks: list[str],
232
+ ui: set[str],
233
+ title: str | None = None,
234
+ ) -> str:
235
+ body = "\n".join(props)
236
+ navigates = "navigate(" in body or (actions is not None and "navigate(" in actions)
237
+ if navigates:
238
+ hooks = [*hooks, "const navigate = useNavigate();"]
239
+ router: set[str] = set()
240
+ if any("useParams(" in hook for hook in hooks):
241
+ router.add("useParams")
242
+ if navigates:
243
+ router.add("useNavigate")
244
+ title_attr = f"title={title}" if title else f'title="{spec.title}"'
245
+ ui = {*ui, "Page", *({"Button"} if actions else set())}
246
+ lines = [_ui_import(ui)]
247
+ if router:
248
+ lines.append(_router_import(router))
249
+ lines += ["", f"export default function {spec.component}() {{"]
250
+ lines += [f" {hook}" for hook in hooks]
251
+ lines.append(" return (")
252
+ if actions:
253
+ lines += [" <Page", f" {title_attr}", " actions={"]
254
+ lines.append(_indent(actions, 16))
255
+ lines += [" }", " >"]
256
+ else:
257
+ lines.append(f" <Page {title_attr}>")
258
+ if len(props) == 1:
259
+ lines.append(f" <{element} {props[0]} />")
260
+ elif all("\n" not in p for p in props) and len(" ".join(props)) <= 50:
261
+ lines.append(f" <{element} {' '.join(props)} />")
262
+ else:
263
+ lines.append(f" <{element}")
264
+ lines += [_indent(prop, 16) for prop in props]
265
+ lines.append(" />")
266
+ lines += [" </Page>", " );", "}", ""]
267
+ return "\n".join(lines)
268
+
269
+
270
+ # planning
271
+
272
+
273
+ def plan(
274
+ ws: Workspace,
275
+ spec: PageSpec,
276
+ *,
277
+ register: bool = True,
278
+ planned_routes: set[str] | None = None,
279
+ ) -> None:
280
+ """Plan the page file and its route entries."""
281
+ require_frontend(ws)
282
+ existing = known_routes(ws)
283
+ routes = existing | set(planned_routes or ()) | set(spec.paths)
284
+ source = render(spec, routes)
285
+ count = source.count("\n")
286
+ if count > MAX_LINES: # pragma: no cover - the templates stay far below the cap
287
+ raise CliError(f"internal: generated page is {count} lines (cap {MAX_LINES}).", code="C199")
288
+ ws.create(spec.file, source)
289
+ if spec.resource and spec.resource not in _routed_resources(ws):
290
+ ws.note(
291
+ f"note: no viewset in this project routes /api/{spec.resource}/ yet — "
292
+ "karni make viewset <Model> (or point --resource at a module's resource)."
293
+ )
294
+ if register:
295
+ _register(ws, spec, existing)
296
+ else:
297
+ ws.note(f"route not registered (--no-route): add {', '.join(spec.paths)} yourself.")
298
+ ws.note(f"page: {', '.join(spec.paths)} -> {spec.component}")
299
+
300
+
301
+ def _register(ws: Workspace, spec: PageSpec, existing: set[str]) -> None:
302
+ text = ws.read(ROUTES)
303
+ manual = f'import {spec.component} from "./pages/{spec.name}";\n' + "\n".join(
304
+ _route_line(spec, p) for p in spec.paths
305
+ )
306
+ if text is None or IMPORT_MARKER not in text or ROUTE_MARKER not in text:
307
+ raise CliError(
308
+ f"{ROUTES.as_posix()} with the `{IMPORT_MARKER}` and `{ROUTE_MARKER}` markers "
309
+ "was not found (projects scaffolded before `karni make`). Register the route "
310
+ f"yourself and re-run with --no-route:\n{manual}",
311
+ code="C108",
312
+ )
313
+ lines = text.splitlines()
314
+ import_line = f'import {spec.component} from "./pages/{spec.name}";'
315
+ for path in spec.paths:
316
+ if path in existing:
317
+ owner = next((ln for ln in lines if f'"{path}"' in ln), "")
318
+ if f"component: {spec.component}" not in owner:
319
+ raise CliError(
320
+ f"Route {path} is already registered in {ROUTES.as_posix()} — choose "
321
+ "another --path (or remove the existing route).",
322
+ code="C107",
323
+ )
324
+ if import_line not in lines:
325
+ index = next(i for i, ln in enumerate(lines) if IMPORT_MARKER in ln)
326
+ lines.insert(index, import_line)
327
+ for path in spec.paths:
328
+ if path in existing:
329
+ continue
330
+ index = next(i for i, ln in enumerate(lines) if ROUTE_MARKER in ln)
331
+ indent = lines[index][: len(lines[index]) - len(lines[index].lstrip())]
332
+ lines.insert(index, indent + _route_line(spec, path).strip())
333
+ ws.write(ROUTES, "\n".join(lines) + "\n")
334
+
335
+
336
+ def _route_line(spec: PageSpec, path: str) -> str:
337
+ return (
338
+ f' createRoute({{ getParentRoute: () => root, path: "{path}", '
339
+ f"component: {spec.component} }}),"
340
+ )
341
+
342
+
343
+ def command(
344
+ name: str = typer.Argument(..., help="Page file name, kebab-case (e.g. events)."),
345
+ resource: str | None = typer.Option(
346
+ None, "--resource", help="API resource (router prefix, e.g. events)."
347
+ ),
348
+ kind: str | None = typer.Option(
349
+ None,
350
+ "--kind",
351
+ help="With --resource: list (default), detail or form; login needs no resource.",
352
+ ),
353
+ path: str | None = typer.Option(
354
+ None, "--path", help="Route path (resource pages: the base, default /<resource>)."
355
+ ),
356
+ title: str | None = typer.Option(None, "--title", help="Page title (default: from the name)."),
357
+ no_route: bool = typer.Option(
358
+ False, "--no-route", help="Do not register the route in frontend/src/routes.tsx."
359
+ ),
360
+ force: bool = FORCE_OPTION,
361
+ dry_run: bool = DRY_RUN_OPTION,
362
+ ) -> None:
363
+ """Add frontend/src/pages/NAME.tsx (<= 50 lines) and register its route.
364
+
365
+ --resource R --kind list|detail|form composes ResourceTable /
366
+ ResourceDetail / ResourceForm for the API resource R; without --resource
367
+ the page is a bare <Page>. Routes: list <base>, detail <base>/$id, form
368
+ <base>/new and <base>/$id/edit, base = --path or /<resource>.
369
+ """
370
+ ws = project_workspace(force)
371
+ spec = spec_for(name, resource=resource, kind=kind, path=path, title=title)
372
+ plan(ws, spec, register=not no_route)
373
+ ws.commit(dry_run=dry_run)
@@ -0,0 +1,304 @@
1
+ """AST-located edits and ruff-clean rendering of generated Python.
2
+
3
+ Generators never regex-rewrite Python. They parse the file, locate top-level
4
+ statements by AST position, and either append a block or replace one
5
+ statement's lines. Imports are merged into the leading import block and
6
+ re-rendered the way ruff's isort and formatter lay them out (sections,
7
+ straight-before-from, order-by-type, wrapping at the project's line length);
8
+ a block that carries comments is never re-rendered — missing imports are
9
+ appended after it instead.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import ast
15
+ import re
16
+ import textwrap
17
+ from dataclasses import dataclass
18
+
19
+ from karni_cli.generators.core import Workspace
20
+ from karni_cli.project import CliError
21
+
22
+ _DEF_LIKE = re.compile(r"^(class |def |async def |@)")
23
+
24
+
25
+ @dataclass(frozen=True, order=True)
26
+ class Import:
27
+ """``from module import name [as alias]``; ``name=None`` is ``import module [as alias]``."""
28
+
29
+ module: str
30
+ name: str | None = None
31
+ alias: str | None = None
32
+ level: int = 0
33
+
34
+
35
+ def parse(ws: Workspace, rel: object, text: str) -> ast.Module:
36
+ try:
37
+ return ast.parse(text)
38
+ except SyntaxError as exc:
39
+ raise CliError(
40
+ f"{rel}:{exc.lineno}: cannot parse ({exc.msg}) — fix the file, then re-run.",
41
+ code="C110",
42
+ ) from None
43
+
44
+
45
+ # locating statements
46
+
47
+
48
+ def top_level(tree: ast.Module, name: str) -> ast.stmt | None:
49
+ """The top-level class, function or simple assignment binding ``name``."""
50
+ for node in tree.body:
51
+ if isinstance(node, ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef):
52
+ if node.name == name:
53
+ return node
54
+ elif isinstance(node, ast.Assign):
55
+ if any(isinstance(t, ast.Name) and t.id == name for t in node.targets):
56
+ return node
57
+ elif (
58
+ isinstance(node, ast.AnnAssign)
59
+ and isinstance(node.target, ast.Name)
60
+ and node.target.id == name
61
+ ):
62
+ return node
63
+ return None
64
+
65
+
66
+ def span(lines: list[str], node: ast.stmt) -> tuple[int, int]:
67
+ """1-based ``(first, last)`` lines of ``node``: decorators and the comments right above."""
68
+ decorators = getattr(node, "decorator_list", [])
69
+ first = min([node.lineno, *(d.lineno for d in decorators)])
70
+ while first > 1 and lines[first - 2].lstrip().startswith("#"):
71
+ first -= 1
72
+ last = node.end_lineno or node.lineno
73
+ # Trailing comments indented into the body belong to it (the AST ends at code).
74
+ while last < len(lines):
75
+ following = lines[last]
76
+ indent = len(following) - len(following.lstrip())
77
+ if not following.lstrip().startswith("#") or indent <= node.col_offset:
78
+ break
79
+ last += 1
80
+ return first, last
81
+
82
+
83
+ def replace_span(text: str, first: int, last: int, block: str) -> str:
84
+ lines = text.splitlines()
85
+ lines[first - 1 : last] = block.rstrip("\n").splitlines()
86
+ return "\n".join(lines) + "\n"
87
+
88
+
89
+ def _def_like(block: str) -> bool:
90
+ for line in block.splitlines():
91
+ stripped = line.strip()
92
+ if stripped and not stripped.startswith("#"):
93
+ return bool(_DEF_LIKE.match(stripped))
94
+ return False
95
+
96
+
97
+ def append(text: str, block: str) -> str:
98
+ """``block`` appended as new top-level code, separated the way ruff formats it."""
99
+ body = text.rstrip()
100
+ block = block.strip("\n")
101
+ if not body:
102
+ return block + "\n"
103
+ tree = ast.parse(text)
104
+ last = tree.body[-1] if tree.body else None
105
+ previous_def = isinstance(last, ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef)
106
+ gap = "\n\n\n" if _def_like(block) or previous_def else "\n\n"
107
+ return f"{body}{gap}{block}\n"
108
+
109
+
110
+ def replace_or_append(ws: Workspace, rel: object, text: str, name: str, block: str) -> str:
111
+ """Append ``block``; when ``name`` is already bound at top level, replace it (``--force``)."""
112
+ tree = parse(ws, rel, text)
113
+ existing = top_level(tree, name)
114
+ if existing is None:
115
+ return append(text, block)
116
+ ws.refuse_existing(f"{name} in {rel}")
117
+ first, last = span(text.splitlines(), existing)
118
+ return replace_span(text, first, last, block)
119
+
120
+
121
+ # imports
122
+
123
+
124
+ def _bound(tree: ast.Module) -> set[Import]:
125
+ found: set[Import] = set()
126
+ for node in tree.body:
127
+ if isinstance(node, ast.Import):
128
+ found |= {Import(a.name, None, a.asname) for a in node.names}
129
+ elif isinstance(node, ast.ImportFrom):
130
+ module = node.module or ""
131
+ found |= {Import(module, a.name, a.asname, node.level) for a in node.names}
132
+ return found
133
+
134
+
135
+ def _leading_run(tree: ast.Module) -> tuple[list[ast.stmt], bool]:
136
+ """The contiguous top-level import statements at the top, and whether a docstring precedes."""
137
+ body = list(tree.body)
138
+ has_doc = bool(
139
+ body
140
+ and isinstance(body[0], ast.Expr)
141
+ and isinstance(body[0].value, ast.Constant)
142
+ and isinstance(body[0].value.value, str)
143
+ )
144
+ run: list[ast.stmt] = []
145
+ for node in body[1:] if has_doc else body:
146
+ if not isinstance(node, ast.Import | ast.ImportFrom):
147
+ break
148
+ run.append(node)
149
+ return run, has_doc
150
+
151
+
152
+ def _member_key(name: str) -> tuple[int, str, str]:
153
+ """ruff's ``order-by-type``: CONSTANTS, then Classes, then functions/variables."""
154
+ if name.isupper() and len(name) > 1:
155
+ kind = 0
156
+ elif name[:1].isupper():
157
+ kind = 1
158
+ else:
159
+ kind = 2
160
+ return kind, name.lower(), name
161
+
162
+
163
+ def _module_key(module: str) -> tuple[str, str]:
164
+ return module.lower(), module
165
+
166
+
167
+ def render_imports(ws: Workspace, imports: set[Import]) -> str:
168
+ """The import block for ``imports`` laid out as ruff's isort does."""
169
+ sections: dict[int, list[Import]] = {}
170
+ for item in imports:
171
+ sections.setdefault(ws.import_section(item.module, item.level), []).append(item)
172
+ blocks: list[str] = []
173
+ for section in sorted(sections):
174
+ items = sections[section]
175
+ lines: list[str] = []
176
+ straight = sorted((i for i in items if i.name is None), key=lambda i: _module_key(i.module))
177
+ for item in straight:
178
+ alias = f" as {item.alias}" if item.alias else ""
179
+ lines.append(f"import {item.module}{alias}")
180
+ grouped: dict[tuple[int, str], list[Import]] = {}
181
+ for item in items:
182
+ if item.name is not None:
183
+ grouped.setdefault((item.level, item.module), []).append(item)
184
+ for level, module in sorted(grouped, key=lambda k: (_module_key(k[1]), k[0])):
185
+ members = grouped[(level, module)]
186
+ source = "." * level + module
187
+ plain = sorted({m.name for m in members if not m.alias and m.name}, key=_member_key)
188
+ if plain:
189
+ lines.append(_from_line(source, plain, ws.line_length))
190
+ aliased = sorted(
191
+ (m for m in members if m.alias), key=lambda m: _member_key(m.name or "")
192
+ )
193
+ for member in aliased:
194
+ lines.append(f"from {source} import {member.name} as {member.alias}")
195
+ blocks.append("\n".join(lines))
196
+ return "\n\n".join(blocks)
197
+
198
+
199
+ def _from_line(source: str, names: list[str], line_length: int) -> str:
200
+ single = f"from {source} import {', '.join(names)}"
201
+ if len(single) <= line_length:
202
+ return single
203
+ body = "".join(f" {name},\n" for name in names)
204
+ return f"from {source} import (\n{body})"
205
+
206
+
207
+ def ensure_imports(ws: Workspace, rel: object, text: str, wanted: list[Import]) -> str:
208
+ """``text`` with every import in ``wanted`` present (merged into the leading block)."""
209
+ tree = parse(ws, rel, text)
210
+ present = _bound(tree)
211
+ missing = {item for item in wanted if item not in present}
212
+ if not missing:
213
+ return text
214
+ lines = text.splitlines()
215
+ run, has_doc = _leading_run(tree)
216
+ if run:
217
+ first = run[0].lineno
218
+ last = run[-1].end_lineno or run[-1].lineno
219
+ region = lines[first - 1 : last]
220
+ if any("#" in line for line in region):
221
+ # A commented block is never re-rendered: append after it instead.
222
+ addition = render_imports(ws, missing).replace("\n\n", "\n")
223
+ lines[last:last] = addition.splitlines()
224
+ return "\n".join(lines) + "\n"
225
+ merged = _bound(_module_of(run)) | missing
226
+ lines[first - 1 : last] = render_imports(ws, merged).splitlines()
227
+ return "\n".join(lines) + "\n"
228
+ block = render_imports(ws, missing)
229
+ if has_doc:
230
+ doc_end = tree.body[0].end_lineno or tree.body[0].lineno
231
+ head, rest = lines[:doc_end], lines[doc_end:]
232
+ else:
233
+ head, rest = [], lines
234
+ while rest and not rest[0].strip():
235
+ rest.pop(0)
236
+ parts = [*head, *([""] if head else []), *block.splitlines()]
237
+ if rest:
238
+ gap = ["", ""] if _def_like("\n".join(rest)) else [""]
239
+ parts += [*gap, *rest]
240
+ return "\n".join(parts) + "\n"
241
+
242
+
243
+ def _module_of(nodes: list[ast.stmt]) -> ast.Module:
244
+ return ast.Module(body=nodes, type_ignores=[])
245
+
246
+
247
+ # rendering
248
+
249
+
250
+ def pystr(value: str) -> str:
251
+ """A string literal in ruff format's preferred quotes."""
252
+ if '"' in value and "'" not in value:
253
+ return "'" + value.replace("\\", "\\\\") + "'"
254
+ return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
255
+
256
+
257
+ def call(
258
+ head: str,
259
+ args: list[str],
260
+ indent: str,
261
+ line_length: int,
262
+ tail: str = "",
263
+ *,
264
+ collection: bool = False,
265
+ ) -> str:
266
+ """``{indent}{head}(args){tail}`` split the way ruff format splits it.
267
+
268
+ ruff format hugs call arguments on one indented line when they fit; a
269
+ collection literal (``collection=True``) that does not fit is always
270
+ split one element per line, with a trailing comma.
271
+ """
272
+ flat = f"{indent}{head}({', '.join(args)}){tail}"
273
+ if len(flat) <= line_length or not args:
274
+ return flat
275
+ inner = f"{indent} {', '.join(args)}"
276
+ if len(inner) <= line_length and not collection:
277
+ return f"{indent}{head}(\n{inner}\n{indent}){tail}"
278
+ body = "".join(f"{indent} {arg},\n" for arg in args)
279
+ return f"{indent}{head}(\n{body}{indent}){tail}"
280
+
281
+
282
+ def tuple_literal(head: str, items: list[str], indent: str, line_length: int) -> str:
283
+ """``{indent}{head}(a, b)``: ``(a,)`` for one item, one item per line when too long."""
284
+ if len(items) == 1:
285
+ flat = f"{indent}{head}({items[0]},)"
286
+ if len(flat) <= line_length:
287
+ return flat
288
+ return call(head, items, indent, line_length, collection=True)
289
+
290
+
291
+ def docstring(text: str, indent: str, line_length: int) -> str:
292
+ """A docstring statement wrapped to the line length."""
293
+ flat = f'{indent}"""{text}"""'
294
+ if len(flat) <= line_length:
295
+ return flat
296
+ wrapped = textwrap.wrap(text, width=max(line_length - len(indent) - 3, 20))
297
+ lines = [f'{indent}"""{wrapped[0]}', *(f"{indent}{line}" for line in wrapped[1:])]
298
+ return "\n".join([*lines, f'{indent}"""'])
299
+
300
+
301
+ def comment(text: str, indent: str, line_length: int, prefix: str = "# ") -> str:
302
+ """A comment wrapped to the line length."""
303
+ width = max(line_length - len(indent) - len(prefix), 20)
304
+ return "\n".join(f"{indent}{prefix}{line}" for line in textwrap.wrap(text, width=width))