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