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