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,255 @@
|
|
|
1
|
+
"""``karni make module <name>`` — a reusable Karni module skeleton (docs/modules.md).
|
|
2
|
+
|
|
3
|
+
Writes ``modules/karni-<name>/``: the ``karni-<name>`` distribution with the
|
|
4
|
+
``karni_<name>`` app (label ``karni_<name>``), a range-pinned karni
|
|
5
|
+
dependency, the ``karni.settings`` fragment entry point, ``AGENTS.fragment.md``
|
|
6
|
+
pinned as package data, and a standalone test suite. Projects install it
|
|
7
|
+
with ``karni add <name>``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from importlib import metadata
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
from karni_cli import __version__
|
|
19
|
+
from karni_cli.generators.core import DRY_RUN_OPTION, Workspace
|
|
20
|
+
from karni_cli.generators.naming import humanize, pascal
|
|
21
|
+
from karni_cli.project import CliError, find_project
|
|
22
|
+
|
|
23
|
+
NAME = "module"
|
|
24
|
+
SUMMARY = "Create a reusable module skeleton: modules/karni-<name>/ (docs/modules.md)."
|
|
25
|
+
|
|
26
|
+
_NAME = re.compile(r"^[a-z][a-z0-9]*([_-][a-z0-9]+)*$")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def karni_pin() -> str:
|
|
30
|
+
"""``karni>=X.Y,<…`` for the karni this CLI sees (0.x: pin the minor)."""
|
|
31
|
+
try:
|
|
32
|
+
version = metadata.version("karni")
|
|
33
|
+
except metadata.PackageNotFoundError:
|
|
34
|
+
version = __version__
|
|
35
|
+
parts = [int(p) for p in re.findall(r"\d+", version)[:2]] + [0, 0]
|
|
36
|
+
major, minor = parts[0], parts[1]
|
|
37
|
+
upper = f"0.{minor + 1}" if major == 0 else str(major + 1)
|
|
38
|
+
return f"karni>={major}.{minor},<{upper}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def files(name: str) -> dict[str, str]:
|
|
42
|
+
"""Relative path → content for the module ``name`` (``payments`` / ``live-chat``)."""
|
|
43
|
+
dist = f"karni-{name.replace('_', '-')}"
|
|
44
|
+
app = f"karni_{name.replace('-', '_')}"
|
|
45
|
+
entry = name.replace("-", "_")
|
|
46
|
+
title = humanize(name)
|
|
47
|
+
config = f"Karni{pascal(name)}Config"
|
|
48
|
+
upper = entry.upper()
|
|
49
|
+
return {
|
|
50
|
+
"pyproject.toml": f"""[build-system]
|
|
51
|
+
requires = ["hatchling"]
|
|
52
|
+
build-backend = "hatchling.build"
|
|
53
|
+
|
|
54
|
+
[project]
|
|
55
|
+
name = "{dist}"
|
|
56
|
+
version = "0.1.0"
|
|
57
|
+
description = "{title} module for Karni."
|
|
58
|
+
readme = "README.md"
|
|
59
|
+
requires-python = ">=3.12"
|
|
60
|
+
# Range-pinned per the module contract (docs/modules.md): karni 0.x minors may break.
|
|
61
|
+
dependencies = ["{karni_pin()}"]
|
|
62
|
+
|
|
63
|
+
# The settings fragment: karni_settings() merges DEFAULTS into every project
|
|
64
|
+
# that installs this module (new top-level names only).
|
|
65
|
+
[project.entry-points."karni.settings"]
|
|
66
|
+
{entry} = "{app}.conf:DEFAULTS"
|
|
67
|
+
|
|
68
|
+
[dependency-groups]
|
|
69
|
+
dev = ["pytest>=8.2", "pytest-django>=4.8", "ruff>=0.5"]
|
|
70
|
+
|
|
71
|
+
[tool.hatch.build.targets.wheel]
|
|
72
|
+
packages = ["{app}"]
|
|
73
|
+
# `karni add` reads the agent guidance through importlib.resources after install.
|
|
74
|
+
artifacts = ["{app}/AGENTS.fragment.md"]
|
|
75
|
+
|
|
76
|
+
[tool.hatch.build.targets.sdist]
|
|
77
|
+
artifacts = ["{app}/AGENTS.fragment.md"]
|
|
78
|
+
|
|
79
|
+
[tool.ruff]
|
|
80
|
+
target-version = "py312"
|
|
81
|
+
line-length = 100
|
|
82
|
+
extend-exclude = ["{app}/migrations"]
|
|
83
|
+
|
|
84
|
+
[tool.ruff.lint]
|
|
85
|
+
select = ["E", "W", "F", "I", "UP", "B"]
|
|
86
|
+
|
|
87
|
+
[tool.pytest.ini_options]
|
|
88
|
+
DJANGO_SETTINGS_MODULE = "tests.settings"
|
|
89
|
+
# Inside a project checkout, never pick up the project's manage.py / tests package.
|
|
90
|
+
django_find_project = false
|
|
91
|
+
testpaths = ["tests"]
|
|
92
|
+
pythonpath = ["."]
|
|
93
|
+
addopts = "-ra"
|
|
94
|
+
""",
|
|
95
|
+
"README.md": f"""# {dist}
|
|
96
|
+
|
|
97
|
+
{title} for Karni projects.
|
|
98
|
+
|
|
99
|
+
## Install
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
pip install {dist}
|
|
103
|
+
karni add {name}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`karni add {name}` adds the dependency, wires `{app}` into the `# karni:apps`
|
|
107
|
+
block and records this module's agent guidance. Then `karni migrate`.
|
|
108
|
+
|
|
109
|
+
## Settings
|
|
110
|
+
|
|
111
|
+
Every setting arrives through the module's settings fragment
|
|
112
|
+
(`{app}.conf.DEFAULTS`) and is overridable in the project's settings.py or
|
|
113
|
+
by its environment variable.
|
|
114
|
+
|
|
115
|
+
| Setting | Env var | Default | Meaning |
|
|
116
|
+
| --- | --- | --- | --- |
|
|
117
|
+
| | | | |
|
|
118
|
+
|
|
119
|
+
## Escape hatches
|
|
120
|
+
|
|
121
|
+
Document every override point here (settings-driven dotted paths,
|
|
122
|
+
overridable methods, swappable classes).
|
|
123
|
+
""",
|
|
124
|
+
f"{app}/__init__.py": "",
|
|
125
|
+
f"{app}/AGENTS.fragment.md": f"""## {title} ({dist})
|
|
126
|
+
- What the module provides, and the one `config/urls.py` include line if it ships URLs.
|
|
127
|
+
- Env vars it reads (prefix {upper}_).
|
|
128
|
+
- Configure it only through its documented settings; never patch its internals.
|
|
129
|
+
""",
|
|
130
|
+
f"{app}/apps.py": f'''"""Django app configuration for ``{app}``."""
|
|
131
|
+
|
|
132
|
+
from django.apps import AppConfig
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class {config}(AppConfig):
|
|
136
|
+
"""App config for the {title} module (label ``{app}``).
|
|
137
|
+
|
|
138
|
+
Override / escape hatch: subclass this config in your project and point
|
|
139
|
+
``INSTALLED_APPS`` at the subclass; keep the label ``{app}`` so the
|
|
140
|
+
shipped migrations keep applying.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
name = "{app}"
|
|
144
|
+
label = "{app}"
|
|
145
|
+
verbose_name = "Karni {title.lower()}"
|
|
146
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
147
|
+
''',
|
|
148
|
+
f"{app}/conf.py": f'''"""Settings vocabulary for {dist}: the ``karni.settings`` entry point.
|
|
149
|
+
|
|
150
|
+
Import-light by contract: plain data plus django-environ reads, never
|
|
151
|
+
``django.conf.settings`` at import time. Module code reads settings through
|
|
152
|
+
:func:`get`, so it also works without ``karni_settings()``.
|
|
153
|
+
|
|
154
|
+
Override / escape hatch: define any of these names in the project's
|
|
155
|
+
settings.py, or set the environment variable.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
from typing import Any
|
|
159
|
+
|
|
160
|
+
import environ
|
|
161
|
+
from django.conf import settings
|
|
162
|
+
|
|
163
|
+
env = environ.Env()
|
|
164
|
+
|
|
165
|
+
DEFAULTS: dict[str, Any] = {{
|
|
166
|
+
# "{upper}_ENABLED": env.bool("{upper}_ENABLED", default=True),
|
|
167
|
+
}}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def get(name: str) -> Any:
|
|
171
|
+
"""``settings.<name>`` if the project defines it, else the module default."""
|
|
172
|
+
return getattr(settings, name, DEFAULTS[name])
|
|
173
|
+
''',
|
|
174
|
+
f"{app}/models.py": f'"""{title} models, each on karni.models.BaseModel."""\n',
|
|
175
|
+
f"{app}/viewsets.py": (
|
|
176
|
+
f'"""{title} API: BaseViewSet subclasses (auto-routed), each with `access`."""\n'
|
|
177
|
+
),
|
|
178
|
+
f"{app}/migrations/__init__.py": "",
|
|
179
|
+
"tests/__init__.py": "",
|
|
180
|
+
"tests/settings.py": f'''"""Settings for {dist}'s standalone test suite (sqlite, eager Celery)."""
|
|
181
|
+
|
|
182
|
+
from karni.conf import karni_settings
|
|
183
|
+
|
|
184
|
+
SECRET_KEY = "{dist}-tests-secret-key-not-for-production"
|
|
185
|
+
DATABASES = {{"default": {{"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}}}}
|
|
186
|
+
CACHES = {{"default": {{"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}}}
|
|
187
|
+
CELERY_TASK_ALWAYS_EAGER = True
|
|
188
|
+
CELERY_TASK_EAGER_PROPAGATES = True
|
|
189
|
+
KARNI_SEARCH_PROVIDER = "karni.api.search.simple.SimpleSearchProvider"
|
|
190
|
+
ROOT_URLCONF = "tests.urls"
|
|
191
|
+
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
|
|
192
|
+
|
|
193
|
+
karni_settings(globals(), env_name="dev")
|
|
194
|
+
|
|
195
|
+
INSTALLED_APPS += ["{app}"] # type: ignore[name-defined] # noqa: F821
|
|
196
|
+
''',
|
|
197
|
+
"tests/urls.py": """from django.urls import include, path
|
|
198
|
+
|
|
199
|
+
urlpatterns = [path("api/", include("karni.api.urls"))]
|
|
200
|
+
""",
|
|
201
|
+
"tests/test_app.py": f'''from django.apps import apps
|
|
202
|
+
|
|
203
|
+
from {app} import conf
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_app_is_installed() -> None:
|
|
207
|
+
assert apps.get_app_config("{app}").name == "{app}"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_settings_fragment_is_a_dict() -> None:
|
|
211
|
+
assert isinstance(conf.DEFAULTS, dict)
|
|
212
|
+
''',
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def plan(ws: Workspace, name: str, parent: Path) -> Path:
|
|
217
|
+
if not _NAME.match(name):
|
|
218
|
+
raise CliError(
|
|
219
|
+
f"Module name {name!r}: lowercase letters and digits, words joined by - or _ "
|
|
220
|
+
"(e.g. payments, live-chat).",
|
|
221
|
+
code="C103",
|
|
222
|
+
)
|
|
223
|
+
if name == "karni" or name.startswith(("karni-", "karni_")):
|
|
224
|
+
raise CliError(
|
|
225
|
+
f"Module name {name!r}: drop the karni prefix — it is added for you.", code="C103"
|
|
226
|
+
)
|
|
227
|
+
target = parent / f"karni-{name.replace('_', '-')}"
|
|
228
|
+
ws.create_skeleton({target / rel: content for rel, content in files(name).items()})
|
|
229
|
+
ws.note(f"next: cd {ws.rel(target)} && python -m pytest · in a project: karni add {name}")
|
|
230
|
+
return target
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def command(
|
|
234
|
+
name: str = typer.Argument(..., help="Module short name (e.g. payments, live-chat)."),
|
|
235
|
+
directory: Path | None = typer.Option(
|
|
236
|
+
None, "--dir", help="Parent directory (default: modules/ in the project, else the cwd)."
|
|
237
|
+
),
|
|
238
|
+
force: bool = typer.Option(
|
|
239
|
+
False,
|
|
240
|
+
"--force",
|
|
241
|
+
help="Create only the missing files of an existing module (keeps the rest).",
|
|
242
|
+
),
|
|
243
|
+
dry_run: bool = DRY_RUN_OPTION,
|
|
244
|
+
) -> None:
|
|
245
|
+
"""Create modules/karni-NAME/ — a reusable module per docs/modules.md.
|
|
246
|
+
|
|
247
|
+
The karni-NAME distribution with the karni_NAME app, a range-pinned
|
|
248
|
+
karni dependency, the karni.settings fragment, AGENTS.fragment.md and a
|
|
249
|
+
standalone test suite. Works outside a project too.
|
|
250
|
+
"""
|
|
251
|
+
root = find_project() or Path.cwd()
|
|
252
|
+
ws = Workspace(root, force=force)
|
|
253
|
+
parent = (Path.cwd() / directory) if directory is not None else ws.root / "modules"
|
|
254
|
+
plan(ws, name, parent.resolve())
|
|
255
|
+
ws.commit(dry_run=dry_run)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Name conversions shared by the generators (no Django import).
|
|
2
|
+
|
|
3
|
+
``kebab`` and ``pluralize`` mirror :func:`karni.api.routers.kebab` /
|
|
4
|
+
:func:`karni.api.routers.pluralize` exactly, so a generated page targets the
|
|
5
|
+
route the auto-router actually registers for a model.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import keyword
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
from karni_cli.project import CliError
|
|
14
|
+
|
|
15
|
+
_VOWELS = frozenset("aeiou")
|
|
16
|
+
_ES_ENDINGS = ("s", "x", "z", "ch", "sh")
|
|
17
|
+
_WORDS = re.compile(r"[A-Z]+(?=[A-Z][a-z]|\d|\b|_|-)|[A-Z]?[a-z]+|[A-Z]+|\d+")
|
|
18
|
+
_SNAKE = re.compile(r"^[a-z_][a-z0-9_]*$")
|
|
19
|
+
_KEBAB = re.compile(r"^[a-z][a-z0-9-]*$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def words(name: str) -> list[str]:
|
|
23
|
+
"""``"EventType"``, ``"event_type"``, ``"event-type"`` → ``["event", "type"]``."""
|
|
24
|
+
return [w.lower() for w in _WORDS.findall(name)]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def pascal(name: str) -> str:
|
|
28
|
+
"""``event_type`` / ``event-type`` / ``eventType`` → ``EventType`` (PascalCase kept)."""
|
|
29
|
+
if re.fullmatch(r"[A-Z][A-Za-z0-9]*", name):
|
|
30
|
+
return name
|
|
31
|
+
return "".join(w[:1].upper() + w[1:] for w in words(name))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def snake(name: str) -> str:
|
|
35
|
+
"""``EventType`` / ``event-type`` → ``event_type``."""
|
|
36
|
+
if _SNAKE.match(name):
|
|
37
|
+
return name
|
|
38
|
+
return "_".join(words(name))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def kebab(name: str) -> str:
|
|
42
|
+
"""``CamelCase`` → ``kebab-case`` (``APIKey`` → ``api-key``) — as karni.api.routers."""
|
|
43
|
+
return re.sub(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", "-", name).lower()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def pluralize(word: str) -> str:
|
|
47
|
+
"""Naive English plural used for route prefixes — as karni.api.routers."""
|
|
48
|
+
if word.endswith("y") and len(word) > 1 and word[-2].lower() not in _VOWELS:
|
|
49
|
+
return word[:-1] + "ies"
|
|
50
|
+
if word.endswith(_ES_ENDINGS):
|
|
51
|
+
return word + "es"
|
|
52
|
+
return word + "s"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def default_route(model: str) -> str:
|
|
56
|
+
"""The auto-router prefix for a model class name: ``EventType`` → ``event-types``."""
|
|
57
|
+
return pluralize(kebab(model))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def humanize(name: str) -> str:
|
|
61
|
+
"""``event_type`` / ``EventType`` → ``Event type``."""
|
|
62
|
+
text = " ".join(words(name))
|
|
63
|
+
return text[:1].upper() + text[1:]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def require_identifier(name: str, what: str) -> str:
|
|
67
|
+
"""``name`` when it is a usable Python identifier, else a CliError naming the fix."""
|
|
68
|
+
if not name.isidentifier() or keyword.iskeyword(name):
|
|
69
|
+
raise CliError(f"{what} {name!r} is not a valid Python identifier.", code="C103")
|
|
70
|
+
return name
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def require_snake(name: str, what: str) -> str:
|
|
74
|
+
if not _SNAKE.match(name) or keyword.iskeyword(name):
|
|
75
|
+
raise CliError(
|
|
76
|
+
f"{what} {name!r} must be snake_case (lowercase letters, digits, underscores), "
|
|
77
|
+
f"e.g. {snake(name) or 'my_name'!r}.",
|
|
78
|
+
code="C103",
|
|
79
|
+
)
|
|
80
|
+
return name
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def require_pascal(name: str, what: str) -> str:
|
|
84
|
+
converted = pascal(name)
|
|
85
|
+
if not converted or not re.fullmatch(r"[A-Z][A-Za-z0-9]*", converted):
|
|
86
|
+
raise CliError(
|
|
87
|
+
f"{what} {name!r} must be a class name such as 'Event' or 'EventType'.", code="C103"
|
|
88
|
+
)
|
|
89
|
+
return converted
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def require_kebab(name: str, what: str) -> str:
|
|
93
|
+
if not _KEBAB.match(name):
|
|
94
|
+
suggestion = "-".join(words(name)) or "my-page"
|
|
95
|
+
raise CliError(
|
|
96
|
+
f"{what} {name!r} must be kebab-case (lowercase letters, digits, dashes), "
|
|
97
|
+
f"e.g. {suggestion!r}.",
|
|
98
|
+
code="C103",
|
|
99
|
+
)
|
|
100
|
+
return name
|