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,1171 @@
1
+ """Karni's error code registry: every code an error, check or test failure carries.
2
+
3
+ An AI agent (or a person) that reads a Karni error should be able to fix it
4
+ without a documentation lookup, so every message states what is wrong, where,
5
+ why Karni objects, and the exact fix. The code (``E104``) is the stable
6
+ handle: it never changes meaning and is never reused. ``karni explain E104``
7
+ prints the entry offline, and ``docs/errors.md`` is generated from this
8
+ registry, so the codes and their documentation cannot drift apart.
9
+
10
+ This module is stdlib only. karni-cli bundles a copy (``karni_cli/_errorcodes``)
11
+ and loads it by path, so ``karni explain`` works without Django or a project.
12
+
13
+ Code ranges
14
+ -----------
15
+ ``E0xx``/``W0xx`` models, managers, scopes · ``E1xx``/``W1xx`` API (access,
16
+ viewsets, serializers, pagination, budgets, caching, expand) · ``E2xx``
17
+ accounts · ``E3xx`` admin · ``E4xx`` events, listeners, schedules, Celery ·
18
+ ``E5xx`` settings and optional dependencies · ``E6xx`` testing and seeding ·
19
+ ``E7xx`` agent tooling (``karni check``, ``karni.rules.py``, changelog).
20
+ ``E`` codes are errors, ``W`` codes warnings. A system check's id is
21
+ ``karni.<code>`` (silence a warning with ``SILENCED_SYSTEM_CHECKS``).
22
+
23
+ Retired codes are never reused: ``E108`` (Can() was reserved before the
24
+ roles-as-data tier shipped in 0.2; a bad ``Can()`` argument is ``E209``).
25
+
26
+ Modules register their own codes under their own prefix (karni-payments uses
27
+ ``PAY``) through the ``karni.errorcodes`` entry-point group, whose value is a
28
+ tuple of :class:`ErrorCode`. The CLI's codes (``C``) live in karni-cli.
29
+
30
+ Override / escape hatch: :func:`registry` takes extra entries, and
31
+ :data:`DOCS_BASE_URL` / :data:`ERRORS_PAGE` are the only places a docs link
32
+ is built from.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import re
38
+ from collections.abc import Iterable
39
+ from dataclasses import dataclass
40
+ from typing import Any, Final
41
+
42
+ __all__ = [
43
+ "CODES",
44
+ "DOCS_BASE_URL",
45
+ "ERRORS_PAGE",
46
+ "KINDS",
47
+ "ErrorCode",
48
+ "KarniError",
49
+ "docs_url",
50
+ "get",
51
+ "problems",
52
+ "registry",
53
+ ]
54
+
55
+
56
+ def _docs_base_url() -> str:
57
+ """``karni.links.DOCS_BASE_URL``, also when this module is loaded by path.
58
+
59
+ karni-cli bundles this package without ``karni`` installed and loads it
60
+ under a private name, so the import may fail; the build hook puts a copy
61
+ of ``links.py`` next to this file, and the monorepo has it one level up.
62
+ """
63
+ try:
64
+ from karni.links import DOCS_BASE_URL as base
65
+ except ImportError:
66
+ import importlib.util
67
+ from pathlib import Path
68
+
69
+ here = Path(__file__).resolve()
70
+ for candidate in (here.parent / "links.py", here.parents[1] / "links.py"):
71
+ if candidate.is_file():
72
+ spec = importlib.util.spec_from_file_location("_karni_links", candidate)
73
+ if spec is not None and spec.loader is not None:
74
+ module = importlib.util.module_from_spec(spec)
75
+ spec.loader.exec_module(module)
76
+ return str(module.DOCS_BASE_URL)
77
+ raise
78
+ return base
79
+
80
+
81
+ #: Base URL of Karni's documentation — ``karni.links.DOCS_BASE_URL``, the one place it is set.
82
+ DOCS_BASE_URL: Final[str] = _docs_base_url()
83
+ #: The generated page every code links to, relative to :data:`DOCS_BASE_URL`
84
+ #: (``docs/errors.md`` in the repository, served as a directory on the site).
85
+ ERRORS_PAGE: Final[str] = "errors/"
86
+
87
+ #: ``exception``: raised for code to fix · ``check``: a Django system check
88
+ #: error (boot fails) · ``warning``: a system check warning · ``api``: a 400
89
+ #: from the API naming the bad parameter · ``test``: a karni.testing failure.
90
+ KINDS: Final[tuple[str, ...]] = ("exception", "check", "warning", "api", "test")
91
+
92
+ _CODE = re.compile(r"^(?:[EW]\d{3}|[A-Z]{2,5}\d{3})$")
93
+
94
+
95
+ def docs_url(code: str) -> str:
96
+ """The documentation link for ``code`` (``…/errors/#e104``)."""
97
+ return f"{DOCS_BASE_URL}{ERRORS_PAGE}#{code.lower()}"
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class ErrorCode:
102
+ """One registry entry: what the code means and how to fix it, in general terms.
103
+
104
+ The raised message is the specific form (it names your class, field or
105
+ setting); this entry is what ``karni explain`` and ``docs/errors.md`` show.
106
+ """
107
+
108
+ code: str
109
+ title: str
110
+ kind: str
111
+ why: str
112
+ fix: str
113
+ #: Where it comes from: the class, function, check or command.
114
+ source: str
115
+
116
+ @property
117
+ def url(self) -> str:
118
+ return docs_url(self.code)
119
+
120
+ def as_dict(self) -> dict[str, str]:
121
+ return {
122
+ "code": self.code,
123
+ "title": self.title,
124
+ "kind": self.kind,
125
+ "why": self.why,
126
+ "fix": self.fix,
127
+ "source": self.source,
128
+ "url": self.url,
129
+ }
130
+
131
+
132
+ class KarniError(Exception):
133
+ """Base of every error Karni raises for a developer to fix.
134
+
135
+ ``str(error)`` is ``"<code>: <message> See <docs url>"``; ``.message`` is
136
+ the bare message and ``.code`` the registry code. Subclasses that also
137
+ inherit a builtin or Django exception (``karni.exceptions``) keep existing
138
+ ``except ValueError`` / ``except ImproperlyConfigured`` clauses working.
139
+
140
+ Override / escape hatch: a subclass may set a class-level ``code`` used
141
+ when the raise site passes none.
142
+ """
143
+
144
+ code: str = ""
145
+
146
+ def __init__(self, message: str, *, code: str | None = None) -> None:
147
+ if code is not None:
148
+ self.code = code
149
+ self.message = message
150
+ super().__init__(message)
151
+
152
+ @property
153
+ def docs_url(self) -> str:
154
+ return docs_url(self.code) if self.code else ""
155
+
156
+ def __str__(self) -> str:
157
+ if not self.code:
158
+ return self.message
159
+ # A multi-line report (assert_crud, settings validation) keeps the link apart.
160
+ sep = "\n" if "\n" in self.message else " "
161
+ return f"{self.code}: {self.message}{sep}See {self.docs_url}"
162
+
163
+ def __reduce__(self) -> tuple[Any, ...]:
164
+ # Celery and multiprocessing pickle exceptions; subclasses' __init__
165
+ # signatures differ, so rebuild without calling it.
166
+ return (_rebuild, (type(self), self.message, self.__dict__.copy()))
167
+
168
+
169
+ def _rebuild(cls: type[KarniError], message: str, state: dict[str, Any]) -> KarniError:
170
+ error = cls.__new__(cls)
171
+ Exception.__init__(error, message)
172
+ error.__dict__.update(state)
173
+ return error
174
+
175
+
176
+ def _e(code: str, title: str, kind: str, source: str, why: str, fix: str) -> ErrorCode:
177
+ return ErrorCode(code=code, title=title, kind=kind, why=why, fix=fix, source=source)
178
+
179
+
180
+ CODES: Final[tuple[ErrorCode, ...]] = (
181
+ # E0xx models, managers, scopes
182
+ _e(
183
+ "E001",
184
+ "A soft-delete model's default manager shows deleted rows",
185
+ "check",
186
+ "karni.checks.check_soft_delete_managers",
187
+ "The model inherits SoftDeleteModel, but its default manager does not run Karni's "
188
+ "scope pipeline, usually because another mixin listed first supplied a plain "
189
+ "`objects`. Deleted rows then appear in every query, relation and API response, "
190
+ "and the model misses every future scope such as `tenant`.",
191
+ "Inherit karni.models.BaseModel, or list SoftDeleteModel first among the Karni "
192
+ "mixins, or declare `objects = SoftDeleteManager()` (any manager built on "
193
+ "karni.managers.ScopedManagerMixin) with `class Meta(SoftDeleteModel.Meta)`.",
194
+ ),
195
+ _e(
196
+ "E002",
197
+ "A scope or the KARNI_SCOPES setting is invalid",
198
+ "check",
199
+ "karni.scopes registry; karni.checks.check_scopes",
200
+ "Every query of a scoped model runs the registered scopes in order. A scope that "
201
+ "fails to load, has a bad name or order, or a KARNI_SCOPES list that names unknown "
202
+ "or duplicate scopes would silently change which rows every query returns.",
203
+ "Fix the scope class or the KARNI_SCOPES entry the message names: a Scope subclass "
204
+ "with an identifier `name` and an int `order`; KARNI_SCOPES a list of registered "
205
+ "names in pipeline order (or unset to run all of them).",
206
+ ),
207
+ _e(
208
+ "E003",
209
+ "objects.without() names no scope, or an unknown one",
210
+ "exception",
211
+ "karni.scopes; Model.objects.without()",
212
+ "without() lifts a scope by name for one query. An unknown name would lift nothing "
213
+ "while the code reads as if it did.",
214
+ 'Pass a registered scope name, e.g. Model.objects.without("soft_delete", '
215
+ 'reason="..."). The message lists the registered scopes.',
216
+ ),
217
+ _e(
218
+ "E004",
219
+ "objects.without() needs a reason",
220
+ "exception",
221
+ "karni.scopes; Model.objects.without()",
222
+ "Lifting a scope shows rows the scope exists to hide (deleted rows, another "
223
+ "tenant's rows). The reason is logged so every such query can be audited.",
224
+ 'Pass reason="<why this query must see rows the scope hides>", e.g. '
225
+ 'Model.objects.without("soft_delete", reason="admin: deleted filter").',
226
+ ),
227
+ _e(
228
+ "E005",
229
+ "without() was called on a queryset",
230
+ "exception",
231
+ "karni.managers.ScopedQuerySet.without",
232
+ "Scopes are applied when the manager builds the queryset, so a queryset has "
233
+ "already filtered by every scope; lifting one there cannot bring rows back.",
234
+ 'Start from the manager: Model.objects.without("<scope>", reason="...").filter(...).',
235
+ ),
236
+ _e(
237
+ "E006",
238
+ "A scope's context is not bound",
239
+ "exception",
240
+ "karni.scopes.ScopeContextMissing (raised by a scope, e.g. tenant)",
241
+ "A scope that needs context (the current tenant) has none, typically in a Celery "
242
+ "task, management command or test. Karni fails closed instead of returning every "
243
+ "tenant's rows.",
244
+ "Bind the context before querying, as the message says (e.g. wrap the task body in "
245
+ "the module's context manager), or lift the scope deliberately with "
246
+ 'Model.objects.without("<scope>", reason="...").',
247
+ ),
248
+ _e(
249
+ "W001",
250
+ "A registered scope is switched off by KARNI_SCOPES",
251
+ "warning",
252
+ "karni.checks.check_scopes",
253
+ "KARNI_SCOPES lists the scopes that run; a registered scope it leaves out filters "
254
+ "nothing, which is usually an accident after installing a module.",
255
+ "Add the scope's name to KARNI_SCOPES (or unset KARNI_SCOPES to run every scope). "
256
+ "If switching it off is deliberate, add 'karni.W001' to SILENCED_SYSTEM_CHECKS.",
257
+ ),
258
+ _e(
259
+ "E007",
260
+ "Karni.audit or Karni.sensitive is malformed",
261
+ "exception",
262
+ "karni.audit.capture",
263
+ "Change history is opted into per model as True or a list of that model's field "
264
+ "names, and sensitive fields are named the same way; a name the model does not have "
265
+ "would audit or redact nothing silently.",
266
+ "Write `class Karni: audit = True` (or a list of field names) and "
267
+ "`sensitive = [...]` with field names of the model.",
268
+ ),
269
+ # E1xx API: access
270
+ _e(
271
+ "E101",
272
+ "A viewset declares no access",
273
+ "check",
274
+ "karni.checks.check_viewsets; karni.api.access.KarniAccessPermission",
275
+ "Access is mandatory (ADR 0001 Q2.1): without a declaration Karni cannot know which "
276
+ "rows a user may see or change, so the viewset denies every request.",
277
+ "Declare `access = Granted()` on the viewset (the roles' grants for its model, "
278
+ "row-scoped), or a custom `Access(read=..., write=...)` (e.g. "
279
+ "Access(read=Authenticated(), write=Owned('owner'))), or opt out explicitly with "
280
+ "`access = Unscoped(reason='...')`.",
281
+ ),
282
+ _e(
283
+ "E102",
284
+ "access is not an Access(...) or Unscoped(...) declaration",
285
+ "exception",
286
+ "karni.api.access.validate_access",
287
+ "The access declaration compiles into both the request gate and the row filter; "
288
+ "anything else would leave one of them undefined.",
289
+ "Write `access = Access(read=..., write=..., create=...)` with predicates, or "
290
+ "`access = Unscoped(reason='...')`. A bare predicate goes inside Access(read=...).",
291
+ ),
292
+ _e(
293
+ "E103",
294
+ "A predicate was expected but something else was given",
295
+ "exception",
296
+ "karni.api.access: Access(...), Owned(bypass=...), @action(access=...)",
297
+ "Access slots, Owned(bypass=...) and @action(access=...) take predicate instances so "
298
+ "they can be evaluated per request and per row.",
299
+ "Pass a predicate instance: Owned('field'), HasRole('staff'), Authenticated(), "
300
+ "Public(), Where(fn), combined with | and &. Write HasRole('staff'), not HasRole.",
301
+ ),
302
+ _e(
303
+ "E104",
304
+ "Owned() does not name a path to the user",
305
+ "exception",
306
+ "karni.api.access.Owned; karni.api.access.validate_access",
307
+ "Owned('path') filters rows to those whose path leads to request.user, and stamps "
308
+ "the owner on create; a path that does not end at the user model would match "
309
+ "nothing or the wrong rows.",
310
+ "Name the owner relation as a string, following relations with __ and ending at "
311
+ "the user model: Owned('organizer') or Owned('event__organizer').",
312
+ ),
313
+ _e(
314
+ "E105",
315
+ "HasRole() names no roles",
316
+ "exception",
317
+ "karni.api.access.HasRole",
318
+ "Roles are Django group names; a HasRole without names would grant nobody.",
319
+ "Pass one or more group names: HasRole('staff') or HasRole('organizer', 'staff').",
320
+ ),
321
+ _e(
322
+ "E106",
323
+ "A Where() function is invalid",
324
+ "exception",
325
+ "karni.api.access.Where",
326
+ "Where(fn) calls fn(request) for the request gate and as the row filter; it must be "
327
+ "callable and return a Q (rows) or a bool (everything or nothing).",
328
+ "Pass a function of the request returning a Q or a bool, e.g. "
329
+ "Where(lambda request: Q(published=True)).",
330
+ ),
331
+ _e(
332
+ "E107",
333
+ "Unscoped() needs a reason",
334
+ "exception",
335
+ "karni.api.access.Unscoped",
336
+ "Opting a viewset out of access control is sometimes right (a public catalogue), "
337
+ "but it must be deliberate and reviewable; the reason is published in karni "
338
+ "context and the schema.",
339
+ "Write `access = Unscoped(reason='public catalogue')` with a non-empty reason.",
340
+ ),
341
+ _e(
342
+ "E109",
343
+ "Access predicates were combined with and / or / not",
344
+ "exception",
345
+ "karni.api.access.Predicate.__bool__",
346
+ "Python's `and`/`or`/`not` evaluate truthiness, so one side of the expression would "
347
+ "be silently dropped.",
348
+ "Combine predicates with | and &: Owned('user') | HasRole('staff').",
349
+ ),
350
+ # E1xx API: viewsets, pagination, budgets, caching
351
+ _e(
352
+ "E110",
353
+ "A viewset has neither model nor queryset",
354
+ "exception",
355
+ "karni.api.viewsets.BaseViewSet",
356
+ "Karni generates the queryset, serializer, filters and expansion from the model.",
357
+ "Set `model = YourModel` on the viewset (or `queryset = ...`), or override "
358
+ "get_queryset() and get_serializer_class().",
359
+ ),
360
+ _e(
361
+ "E111",
362
+ "Pagination was switched off without Unpaginated(...)",
363
+ "exception",
364
+ "karni.pagination.check_pagination_class; BaseViewSet",
365
+ "An unpaginated list grows with the table until it times out (ADR 0001 Q3.5), so "
366
+ "every list must be bounded.",
367
+ "Keep the default paginator, or declare `pagination = Unpaginated(max_rows=N, "
368
+ "reason='...')` to return a bounded plain list.",
369
+ ),
370
+ _e(
371
+ "E112",
372
+ "pagination_class is not a Karni paginator",
373
+ "exception",
374
+ "karni.pagination.check_pagination_class",
375
+ "Karni's paginators enforce KARNI_MAX_PAGE_SIZE; another paginator would let a "
376
+ "client request unbounded pages.",
377
+ "Subclass karni.pagination.KarniPageNumberPagination or KarniCursorPagination.",
378
+ ),
379
+ _e(
380
+ "E113",
381
+ "Unpaginated(...) is invalid",
382
+ "exception",
383
+ "karni.pagination.Unpaginated; BaseViewSet.pagination",
384
+ "Unpaginated lists still need a hard row limit and a reviewable reason.",
385
+ "Write `pagination = Unpaginated(max_rows=500, reason='CSV export')` with a "
386
+ "positive max_rows and a non-empty reason. To change the paginator, set "
387
+ "pagination_class instead.",
388
+ ),
389
+ _e(
390
+ "E114",
391
+ "Both pagination and pagination_class are declared",
392
+ "exception",
393
+ "karni.api.viewsets.BaseViewSet",
394
+ "The two declarations contradict each other.",
395
+ "Keep one: pagination = Unpaginated(...) for a plain bounded list, or "
396
+ "pagination_class for another Karni paginator.",
397
+ ),
398
+ _e(
399
+ "E115",
400
+ "A query Budget(...) is invalid",
401
+ "exception",
402
+ "karni.performance.Budget; BaseViewSet.query_budget",
403
+ "A query budget caps SQL statements per request to catch N+1 regressions; raising "
404
+ "it must be a measured, explained decision.",
405
+ "Write `query_budget = Budget(list=25, reason='...')`: name at least one action, use "
406
+ "positive integers (None stops checking an action) and give a non-empty reason.",
407
+ ),
408
+ _e(
409
+ "E116",
410
+ "query_budget names unknown actions",
411
+ "exception",
412
+ "karni.api.viewsets.BaseViewSet",
413
+ "A budget for an action the viewset does not have would never apply.",
414
+ "Use the viewset's action names (list, retrieve, create, update, partial_update, "
415
+ "destroy, or an @action's name), or default= for every other action.",
416
+ ),
417
+ _e(
418
+ "E117",
419
+ "A request ran more SQL queries than its budget",
420
+ "exception",
421
+ "karni.api.viewsets.BaseViewSet; karni.testing.query_budget",
422
+ "Repeated statements are almost always an N+1: a relation loaded once per row. "
423
+ "Budgets turn that into a failing test instead of a slow production page.",
424
+ "Read the repeated statements in the message and load that relation up front "
425
+ "(select_related / prefetch_related, or let the optimizer see it through the "
426
+ "serializer). If the work is real, raise the limit: query_budget = Budget(list=N, "
427
+ "reason='...').",
428
+ ),
429
+ _e(
430
+ "E118",
431
+ "KARNI_QUERY_BUDGET_MODE is invalid",
432
+ "exception",
433
+ "karni.performance.budget_mode; karni.testing.pytest_plugin",
434
+ "The mode decides whether an exceeded budget is ignored, logged or raised.",
435
+ "Set KARNI_QUERY_BUDGET_MODE (or the pytest ini option karni_query_budget_mode) to "
436
+ "'off', 'warn' or 'raise'.",
437
+ ),
438
+ _e(
439
+ "E119",
440
+ "Cache(...) or Uncached(...) is invalid",
441
+ "exception",
442
+ "karni.api.caching; BaseViewSet.cache",
443
+ "Response caching must have a positive lifetime, and opting a viewset out must be "
444
+ "explained.",
445
+ "Write `cache = Cache(ttl=300)` with a positive integer, or `cache = "
446
+ "Uncached(reason='balances change on every request')`.",
447
+ ),
448
+ _e(
449
+ "E120",
450
+ "Query hints are invalid",
451
+ "exception",
452
+ "karni.api.optimizer.validate_hints",
453
+ "select_related / prefetch_related / select hints are merged into the optimizer's "
454
+ "plan; they must be lookups it can apply.",
455
+ 'Give a list of lookup strings (e.g. select_related = ["venue"]) or Prefetch '
456
+ "objects for prefetch_related.",
457
+ ),
458
+ _e(
459
+ "E121",
460
+ "optimize must be True or False",
461
+ "exception",
462
+ "karni.api.viewsets.BaseViewSet",
463
+ "optimize switches the automatic query optimizer for the viewset.",
464
+ "Set optimize = True (default) or optimize = False.",
465
+ ),
466
+ # E1xx API: serializers and expand
467
+ _e(
468
+ "E122",
469
+ "APIMeta declares both fields and exclude",
470
+ "exception",
471
+ "karni.api.serializers (generated serializers)",
472
+ "The generated serializer can take an allow-list or a deny-list, not both.",
473
+ "Keep APIMeta.fields or APIMeta.exclude on the model.",
474
+ ),
475
+ _e(
476
+ "E123",
477
+ "APIMeta names fields the serializer does not have",
478
+ "exception",
479
+ "karni.api.serializers (generated serializers)",
480
+ "read_only_fields, write_only_fields and expand entries must name fields that the "
481
+ "generated serializer exposes, or they would silently do nothing.",
482
+ "Fix the spelling, or add the field to APIMeta.fields (or remove it from exclude).",
483
+ ),
484
+ _e(
485
+ "E124",
486
+ "A field is both read-only and write-only",
487
+ "exception",
488
+ "karni.api.serializers (generated serializers)",
489
+ "A field cannot be both returned-only and accepted-only.",
490
+ "Remove the field from APIMeta.read_only_fields or APIMeta.write_only_fields.",
491
+ ),
492
+ _e(
493
+ "E125",
494
+ "An expandable field is not a relation",
495
+ "exception",
496
+ "karni.api.serializers.expanded_field",
497
+ "?expand= replaces a foreign key's id with the related object; only relations can "
498
+ "be expanded.",
499
+ "Name a ForeignKey, OneToOneField or ManyToManyField (or a reverse relation) of the model.",
500
+ ),
501
+ _e(
502
+ "E126",
503
+ "The ?expand= parameter names an unknown or invalid path",
504
+ "api",
505
+ "karni.api.expansion",
506
+ "Expansion is validated against the model so a typo returns a 400 instead of a "
507
+ "silently unexpanded response.",
508
+ "Use the keys the error lists (comma-separated, nested with dots: "
509
+ "?expand=venue,tiers.event). Each segment must be a relation.",
510
+ ),
511
+ _e(
512
+ "E127",
513
+ "The ?expand= path is too deep",
514
+ "api",
515
+ "karni.api.expansion",
516
+ "Each level of expansion multiplies the work per row; the depth is capped by "
517
+ "KARNI_API_MAX_EXPAND_DEPTH.",
518
+ "Request fewer levels, or raise KARNI_API_MAX_EXPAND_DEPTH in settings if the "
519
+ "client really needs them.",
520
+ ),
521
+ _e(
522
+ "E128",
523
+ "KARNI_API_ROUTER cannot be imported",
524
+ "check",
525
+ "karni.checks.check_viewsets",
526
+ "The router factory builds every API route; without it the API serves nothing.",
527
+ "Point KARNI_API_ROUTER at an importable zero-argument factory returning a router "
528
+ "(default 'karni.api.routers.get_default_router').",
529
+ ),
530
+ _e(
531
+ "E129",
532
+ "List or retrieve queries grow with the number of rows (N+1)",
533
+ "test",
534
+ "karni.testing.performance (pytest --pyargs karni.testing.performance)",
535
+ "The scaling check runs list and retrieve with few and many rows; more rows must "
536
+ "not mean more queries, or the page slows down as the table grows.",
537
+ "Let the optimiser see the relation through the serializer, or add "
538
+ "select_related / prefetch_related hints (or APIMeta.select/prefetch) for a "
539
+ "SerializerMethodField or property. Opt out only with "
540
+ "`# karni: allow(query-scaling): <reason>` above the viewset class.",
541
+ ),
542
+ _e(
543
+ "E130",
544
+ "An opt-out marker has no reason",
545
+ "test",
546
+ "karni.testing (# karni: allow(<rule>) markers)",
547
+ "Opting out of a check must be deliberate and reviewable; the reason is the review.",
548
+ "Write the reason after the colon: `# karni: allow(<rule>): <why>`.",
549
+ ),
550
+ _e(
551
+ "E131",
552
+ "search_fields names a write-only field",
553
+ "check",
554
+ "karni.checks.check_viewsets",
555
+ "A field in APIMeta.write_only never appears in a response, but ?search= over it "
556
+ "answers whether a row contains a value — a membership oracle on a hidden column.",
557
+ "Remove the field from search_fields, or take it out of APIMeta.write_only if it "
558
+ "may be read.",
559
+ ),
560
+ _e(
561
+ "W101",
562
+ "A hand-written serializer duplicates the one Karni generates",
563
+ "warning",
564
+ "karni.checks.check_viewsets",
565
+ "Karni generates the serializer from the model (APIMeta tunes it). A copy that "
566
+ "declares nothing of its own drifts from the model and from the schema the "
567
+ "frontend is generated from.",
568
+ "Delete the serializer and its serializer_class line, and tune the generated one "
569
+ "with APIMeta on the model. Keep serializer_class only for a serializer with custom "
570
+ "fields or validation.",
571
+ ),
572
+ # E2xx accounts
573
+ _e(
574
+ "E201",
575
+ "KARNI_ACCOUNTS_EMAIL_BACKEND is not an accounts email backend",
576
+ "exception",
577
+ "karni.accounts.emails.get_backend",
578
+ "Account emails (verification, password reset) are sent through this backend.",
579
+ "Point the setting at a subclass of karni.accounts.emails.AccountsEmailBackend, "
580
+ "e.g. 'karni.accounts.emails.DjangoEmailBackend'.",
581
+ ),
582
+ _e(
583
+ "E202",
584
+ "No KARNI_ACCOUNTS_LINKS entry for a token purpose",
585
+ "exception",
586
+ "karni.accounts.emails",
587
+ "Emails link to your frontend; Karni needs the URL template for each purpose "
588
+ "(verify_email, reset_password, ...).",
589
+ "Add the purpose to KARNI_ACCOUNTS_LINKS, e.g. "
590
+ "{'reset_password': '{frontend}/reset-password?token={token}'}.",
591
+ ),
592
+ _e(
593
+ "E203",
594
+ "create_user() or create_superuser() was called with invalid arguments",
595
+ "exception",
596
+ "karni.accounts.managers.UserManager",
597
+ "Karni users log in by email, and a superuser must be staff and superuser.",
598
+ "Pass email= (and password=). For create_superuser(), leave is_staff and "
599
+ "is_superuser unset or True.",
600
+ ),
601
+ _e(
602
+ "E204",
603
+ "Unknown one-time token purpose",
604
+ "exception",
605
+ "karni.accounts.tokens",
606
+ "One-time tokens are issued per purpose so a reset token cannot verify an email.",
607
+ "Use one of the purposes the message lists.",
608
+ ),
609
+ _e(
610
+ "E205",
611
+ "KARNI_ROLES is malformed",
612
+ "exception",
613
+ "karni.accounts.roles.declared_roles",
614
+ "The role declaration is the single source of what each role may do; a malformed "
615
+ "entry cannot be reconciled into the role tables.",
616
+ "Write KARNI_ROLES = {'<slug>': {'name': ..., 'description': ..., 'system': bool, "
617
+ "'grants': {'app_label.model:action': '<scope>'}}}. A pattern may use '*' for the "
618
+ "model or the action (or '*' alone); a scope is 'all', 'own', 'related:<key>' or "
619
+ "'custom:<name>'.",
620
+ ),
621
+ _e(
622
+ "E206",
623
+ "A grant in KARNI_ROLES cannot be resolved",
624
+ "exception",
625
+ "karni.accounts.roles.sync_roles",
626
+ "A pattern that matches no permission, an 'own' scope on a model with no owner "
627
+ "field, a 'related' scope on a model without that scope path, or a 'custom' scope "
628
+ "nobody registered would grant nothing silently; Karni refuses instead.",
629
+ "Fix the key (permissions look like 'events.event:view'; extra actions come from "
630
+ "`class Karni: actions`), declare `owner_field` / `scope_paths` in the model's "
631
+ "`class Karni`, or register the custom scope with karni.accounts.roles.register_scope.",
632
+ ),
633
+ _e(
634
+ "E207",
635
+ "user.can() was given an action without a model",
636
+ "exception",
637
+ "karni.accounts.roles.user_can",
638
+ "A permission is a (model, action) pair; a bare action name cannot be looked up.",
639
+ "Pass the full key, user.can('shop.order:approve'), or name the model: "
640
+ "user.can('approve', obj=order) or user.can('approve', model=Order).",
641
+ ),
642
+ _e(
643
+ "E208",
644
+ "The request lacks the permission @requires names",
645
+ "api",
646
+ "karni.accounts.roles.requires",
647
+ "A view or service guarded with @requires('app.model:action') was called by a user "
648
+ "whose roles hold no grant for it (a 403).",
649
+ "Grant the permission to one of the user's roles in KARNI_ROLES and run "
650
+ "`karni sync-roles`, or assign the user a role that has it.",
651
+ ),
652
+ _e(
653
+ "E209",
654
+ "Can() was given a bad permission, or one for another model",
655
+ "exception",
656
+ "karni.api.access.Can",
657
+ "Can() names permissions as 'app_label.model:action', and its row filter is built "
658
+ "against that model, so a viewset's declaration can only use its own model's "
659
+ "permissions.",
660
+ "Write Can('events.event:approve'); on EventViewSet it must be an events.event "
661
+ "permission. To gate on another model's permission use HasRole(...).",
662
+ ),
663
+ _e(
664
+ "E212",
665
+ "Karni.field_roles is malformed, names a missing field or an undeclared role",
666
+ "check",
667
+ "karni.meta.field_roles; karni.checks.check_field_roles",
668
+ "Field-level visibility is declared once on the model as field -> roles; a typo in "
669
+ "a field name hides nothing, and a role nobody holds hides the field from everyone, "
670
+ "so both are refused at boot.",
671
+ "Write field_roles = {'phone': {'read': ['staff', 'support'], 'write': ['staff']}} "
672
+ "(or 'phone': ['staff'] for read and write) with field names of the model and role "
673
+ "slugs declared in KARNI_ROLES.",
674
+ ),
675
+ _e(
676
+ "E210",
677
+ "scope_exempt = True without a reason",
678
+ "exception",
679
+ "karni.api.access.validate_access",
680
+ "A viewset or admin that shows every row its gate admits is the one place row "
681
+ "scoping is off; without a reason it looks like an oversight, and karni check "
682
+ "lists every exemption by its reason.",
683
+ "Add scope_exempt_reason = '...' next to scope_exempt = True (e.g. 'public "
684
+ "catalogue'), or remove the exemption and let Granted() / the roles' grants scope "
685
+ "the rows.",
686
+ ),
687
+ _e(
688
+ "E211",
689
+ "Granted() has no permission for this action or viewset",
690
+ "exception",
691
+ "karni.api.access.Granted",
692
+ "Granted() resolves to the viewset's model and the current action; an unsafe "
693
+ "custom @action the model never declared has no permission to check, and a "
694
+ "viewset without a model has nothing to resolve against. Evaluating it outside a "
695
+ "viewset is the same error.",
696
+ "Add the action to `class Karni: actions = [...]` on the model and grant "
697
+ "'app.model:<action>' in KARNI_ROLES, give the action its own @action(access=...), "
698
+ "set `model = ...` on the viewset, or evaluate through action_predicate(view, "
699
+ "access).",
700
+ ),
701
+ _e(
702
+ "E213",
703
+ "The permission matrix refused a change",
704
+ "api",
705
+ "karni.accounts.matrix.apply_changes",
706
+ "A guard rail stopped the request and nothing was written: a system role's declared "
707
+ "grant is locked, the operator does not hold the permission (or holds it at a "
708
+ "narrower scope than the one granted), the change would remove the operator's own "
709
+ "last permission to change permissions, or the role, permission or scope is unknown.",
710
+ "Change KARNI_ROLES for a system role's grants; ask someone who holds the permission "
711
+ "at that scope; keep one of your roles able to change permissions; check the role "
712
+ "slug, the 'app.model:action' key and the scope value against GET "
713
+ "permissions/matrix/.",
714
+ ),
715
+ _e(
716
+ "E214",
717
+ "A role's scope names a path the model does not have",
718
+ "check",
719
+ "karni.checks.check_role_scope_paths",
720
+ "A grant's scope becomes a database filter on every request: `own` follows "
721
+ "Karni.owner_field, `related:<key>` follows Karni.scope_paths[key], and a "
722
+ "`custom:<name>` scope builds its own. A path the model does not have would answer "
723
+ "500 to every user holding the grant — typically after a new model joins a wildcard "
724
+ "grant written for its siblings.",
725
+ "Give the model the path the scope expects (`class Karni: owner_field = "
726
+ "'workspace__members'`, or the scope path), teach the custom scope the new model, "
727
+ "or narrow the grant's pattern so it does not cover the model.",
728
+ ),
729
+ # E3xx admin
730
+ _e(
731
+ "E301",
732
+ "KARNI_ADMIN_BASE is not a ModelAdmin class",
733
+ "exception",
734
+ "karni.admin.base",
735
+ "Every auto-registered admin is built on this base class.",
736
+ "Point KARNI_ADMIN_BASE at an importable django.contrib.admin.ModelAdmin subclass "
737
+ "(default 'unfold.admin.ModelAdmin').",
738
+ ),
739
+ _e(
740
+ "E302",
741
+ "AdminMeta.inline_on is invalid",
742
+ "exception",
743
+ "karni.admin.inlines",
744
+ "inline_on names the foreign key under whose parent the model is shown inline.",
745
+ "Name a ForeignKey field of the model: class AdminMeta: inline_on = 'event'.",
746
+ ),
747
+ _e(
748
+ "E303",
749
+ "KARNI_ADMIN_LOGO is not a logo declaration",
750
+ "exception",
751
+ "karni.conf.admin_logo.unfold_logo_entries",
752
+ "The admin logo is wired into UNFOLD from this one setting, so it must name static "
753
+ "files unfold can serve.",
754
+ "Set KARNI_ADMIN_LOGO = 'karni' for Karni's mark, or {'light': '<static path>', "
755
+ "'dark': ..., 'icon': ...} with at least 'light'; leave it unset for a text header.",
756
+ ),
757
+ # E4xx events, listeners, schedules, Celery
758
+ _e(
759
+ "E401",
760
+ "A queued listener is not a module-level function",
761
+ "exception",
762
+ "karni.events.queued",
763
+ "A queued listener runs on a Celery worker, which finds it by its dotted name; "
764
+ "nested functions and lambdas have none.",
765
+ "Define the listener at the top level of <app>/listeners.py, or queue an existing "
766
+ "task with queued('app.tasks.task_name').",
767
+ ),
768
+ _e(
769
+ "E402",
770
+ "A listener registration is invalid",
771
+ "exception",
772
+ "karni.events",
773
+ "Listeners connect to model events (on_created(Model), ...) and must be callables.",
774
+ "Decorate a function, and pass a model class or an 'app_label.ModelName' string: "
775
+ "@on_created(Event) or @on_created('events.Event').",
776
+ ),
777
+ _e(
778
+ "E403",
779
+ "A Celery task cannot be found",
780
+ "exception",
781
+ "karni.celery.resolve_task",
782
+ "Tasks are found by dotted name; Celery autodiscovers only @shared_task functions "
783
+ "in installed apps' tasks.py.",
784
+ "Check the dotted name, and that the function is decorated with @shared_task in "
785
+ "an installed app's tasks.py.",
786
+ ),
787
+ _e(
788
+ "E404",
789
+ "A schedule declaration is invalid",
790
+ "exception",
791
+ "karni.schedule (every, cron)",
792
+ "Schedules are stored by django-celery-beat: the task must be a task or its dotted "
793
+ "name, and name/args/kwargs/options must be plain JSON-serializable values.",
794
+ "Pass a @shared_task or 'app.tasks.task_name', a non-empty name, args as a list, "
795
+ "kwargs and options as dicts of plain values (ids, not objects).",
796
+ ),
797
+ _e(
798
+ "E405",
799
+ "A schedule's interval or cron expression is invalid",
800
+ "exception",
801
+ "karni.schedule (every, cron)",
802
+ "Intervals are whole positive seconds; cron expressions have five fields.",
803
+ "Use every('15m') / every(minutes=15) (s, m, h, d units), or cron('0 3 * * *') "
804
+ "('minute hour day-of-month month day-of-week').",
805
+ ),
806
+ _e(
807
+ "E406",
808
+ "Two schedules share a name",
809
+ "check",
810
+ "karni.schedule; karni.checks.check_schedules",
811
+ "Schedule names become django-celery-beat PeriodicTask names, so they must be "
812
+ "unique across the project.",
813
+ "Pass a distinct name=... to one of the two declarations the message names.",
814
+ ),
815
+ _e(
816
+ "E407",
817
+ "No schedule has that name",
818
+ "exception",
819
+ "karni.schedule.run_now; karni schedule run",
820
+ "Schedules are looked up by name.",
821
+ "List them with `karni schedule list` and use one of the names shown.",
822
+ ),
823
+ _e(
824
+ "E408",
825
+ "A scheduled task failed when run by hand",
826
+ "exception",
827
+ "karni_schedules management command; karni schedule run",
828
+ "`karni schedule run` executes the task in-process so failures show immediately.",
829
+ "Read the task's own error in the message and fix the task; re-run with "
830
+ "`karni schedule run <name>`.",
831
+ ),
832
+ # E5xx settings and optional dependencies
833
+ _e(
834
+ "E501",
835
+ "An optional dependency is not installed",
836
+ "exception",
837
+ "karni.conf.sentry, karni.storage, karni.accounts.twofa, karni.api.search.postgres",
838
+ "Optional features (Sentry, S3 storage, thumbnails, 2FA, PostgreSQL search) need "
839
+ "extra packages that a minimal install leaves out.",
840
+ "Install the extra the message names, e.g. pip install 'karni[sentry]', "
841
+ "'karni[s3]', 'karni[images]', 'karni[2fa]' or 'psycopg[binary]'.",
842
+ ),
843
+ _e(
844
+ "E502",
845
+ "Unknown settings profile",
846
+ "exception",
847
+ "karni.conf.karni_settings",
848
+ "karni_settings() picks dev or prod defaults from DJANGO_ENV.",
849
+ "Set DJANGO_ENV to one of the profiles the message lists, or pass env_name= to "
850
+ "karni_settings().",
851
+ ),
852
+ _e(
853
+ "E503",
854
+ "A module's settings fragment is invalid",
855
+ "exception",
856
+ "karni.conf (karni.settings entry points)",
857
+ "Installed modules contribute default settings; a fragment may only add new "
858
+ "top-level names, so installing a module never changes a setting you rely on.",
859
+ "Fix or upgrade the module the message names. Module authors: export a dict of new "
860
+ "names only.",
861
+ ),
862
+ _e(
863
+ "E504",
864
+ "Karni settings failed validation",
865
+ "exception",
866
+ "karni.conf.validation.validate_settings (at startup)",
867
+ "Settings are validated once at boot so a bad value fails the deploy, not the "
868
+ "first request that reads it.",
869
+ "Fix every setting the message lists. KARNI_VALIDATE = False skips validation "
870
+ "(not recommended).",
871
+ ),
872
+ _e(
873
+ "E505",
874
+ "The welcome page is reachable with DEBUG off",
875
+ "check",
876
+ "karni.checks.check_welcome_route",
877
+ "The welcome page (karni.welcome) introspects the project at request time — models, "
878
+ "modules, status checks, the agent context — for first boot in development; served "
879
+ "in production it would describe the deployment to anyone who opens the site.",
880
+ "Mount it only through `*karni.welcome.welcome_urls()` at the end of urlpatterns "
881
+ "(it returns no route when DEBUG is False) and remove any hand-written "
882
+ "path('', WelcomeView.as_view()).",
883
+ ),
884
+ _e(
885
+ "W501",
886
+ "The throttle cache is private to each process",
887
+ "warning",
888
+ "karni.checks.check_throttle_cache",
889
+ "DRF keeps rate-limit counters in CACHES['default']. With DEBUG off, a locmem or "
890
+ "dummy backend gives every worker its own counters, so the login, registration and "
891
+ "password-reset limits are multiplied by the worker count.",
892
+ "Point CACHES['default'] at a backend every worker shares (Redis — Karni's default "
893
+ "— or the database cache), or silence karni.W501 for a single-process deployment.",
894
+ ),
895
+ _e(
896
+ "E506",
897
+ "A setting names a dotted path that cannot be imported",
898
+ "exception",
899
+ "karni.imports.import_setting (KARNI_SEARCH_PROVIDER, KARNI_API_ROUTER, "
900
+ "KARNI_ADMIN_SITE, KARNI_THUMBNAILER, ...)",
901
+ "Settings that name a class or function by dotted path are resolved when first used; "
902
+ "a typo or a package missing from the environment would otherwise surface as a bare "
903
+ "ModuleNotFoundError far from the setting.",
904
+ "Fix the path the message names ('package.module.Object') or install the package "
905
+ "that provides it.",
906
+ ),
907
+ _e(
908
+ "E507",
909
+ "KARNI_CACHE names an unknown cache backend",
910
+ "exception",
911
+ "karni.conf (KARNI_CACHE)",
912
+ "The default cache (throttle counters, response cache, locks) is Redis at REDIS_URL; "
913
+ "`locmem` is the in-process alternative for a host run without Redis.",
914
+ "Set KARNI_CACHE to `redis` (the default) or `locmem`; for anything else define "
915
+ "CACHES yourself in settings.py.",
916
+ ),
917
+ # E6xx testing and seeding
918
+ _e(
919
+ "E601",
920
+ "The resource under test is not routed",
921
+ "test",
922
+ "karni.testing.assert_crud / assert_isolated",
923
+ "assert_crud tests a resource through its URLs, exactly as clients reach it.",
924
+ "Route the viewset (karni make viewset <Model>, or auto_register), or pass the "
925
+ "viewset class or its route name.",
926
+ ),
927
+ _e(
928
+ "E602",
929
+ "The API does not behave as its access declaration promises",
930
+ "test",
931
+ "karni.testing.assert_crud",
932
+ "assert_crud derives every expected outcome (allowed actions, 403/404 denials, "
933
+ "pagination, filtering, owner stamping, response schema) from the viewset's "
934
+ "access and the OpenAPI schema; each listed failure is a real difference.",
935
+ "Fix each failure in the report: usually the access declaration, a custom "
936
+ "get_queryset, or a serializer that does not match the schema. skip=[...] "
937
+ "excludes a check that genuinely does not apply.",
938
+ ),
939
+ _e(
940
+ "E603",
941
+ "assert_crud(skip=...) names unknown checks",
942
+ "exception",
943
+ "karni.testing.assert_crud",
944
+ "Skipping a check that does not exist would hide nothing and mislead the reader.",
945
+ "Use the check names the message lists.",
946
+ ),
947
+ _e(
948
+ "E604",
949
+ "A request raised an exception during assert_crud",
950
+ "test",
951
+ "karni.testing.assert_crud",
952
+ "The API raised instead of answering, so the check could not run.",
953
+ "Read the exception in the message; fix the view, serializer or data it names.",
954
+ ),
955
+ _e(
956
+ "E605",
957
+ "assert_isolated cannot isolate this resource",
958
+ "exception",
959
+ "karni.testing.assert_isolated; karni.testing.isolation",
960
+ "Isolation is proved along a dimension (owner, tenant, ...) that the resource's "
961
+ "access actually filters by.",
962
+ "Give the resource an Owned(...) access (or install the module providing the "
963
+ "dimension), and name only registered dimensions.",
964
+ ),
965
+ _e(
966
+ "E606",
967
+ "Rows leak to a user who must not see them",
968
+ "test",
969
+ "karni.testing.assert_isolated",
970
+ "One owner (or tenant) could read or change another's rows: a data leak.",
971
+ "Restrict the viewset's access with Owned('<owner path>') (or the tenant scope), "
972
+ "and check custom get_queryset / @action code that bypasses it.",
973
+ ),
974
+ _e(
975
+ "E607",
976
+ "There is no OpenAPI schema snapshot yet",
977
+ "test",
978
+ "karni.testing.assert_schema_snapshot",
979
+ "CI compares the API schema with a committed snapshot so an accidental breaking "
980
+ "change fails the build; CI never writes snapshots.",
981
+ "Generate it locally with the command in the message and commit the file.",
982
+ ),
983
+ _e(
984
+ "E608",
985
+ "The OpenAPI schema changed",
986
+ "test",
987
+ "karni.testing.assert_schema_snapshot",
988
+ "Clients (and the generated frontend types) depend on the schema; this change "
989
+ "would reach them.",
990
+ "If the change is intended, accept it with the command in the message and commit "
991
+ "the snapshot; otherwise revert the change that caused it.",
992
+ ),
993
+ _e(
994
+ "E609",
995
+ "No factory can be built for this model",
996
+ "exception",
997
+ "karni.testing.factories",
998
+ "Factories create rows of concrete models.",
999
+ "Pass a concrete model class or an 'app_label.Model' string.",
1000
+ ),
1001
+ _e(
1002
+ "E610",
1003
+ "A factory cannot generate a value for a field",
1004
+ "exception",
1005
+ "karni.testing.factories",
1006
+ "Values are inferred from field types and names; some custom fields have no safe default.",
1007
+ "Pass the value (create(Model, field=...)) or define an explicit factory in "
1008
+ "<app>/factories.py.",
1009
+ ),
1010
+ _e(
1011
+ "E611",
1012
+ "Required relations form a loop",
1013
+ "exception",
1014
+ "karni.testing.factories; karni.seeding",
1015
+ "Each row needs its required relation to exist first; in a loop, no order works.",
1016
+ "Make one of the foreign keys nullable, pass an existing row for that field, or "
1017
+ "seed one model yourself in a Seeder.seed() override.",
1018
+ ),
1019
+ _e(
1020
+ "E612",
1021
+ "Seeding keeps hitting a database constraint",
1022
+ "exception",
1023
+ "karni.seeding",
1024
+ "Generated values collided with a unique or check constraint on every attempt.",
1025
+ "Give the clashing fields distinct values in a seeder (karni make seeder <Model>), "
1026
+ "e.g. with factory.Sequence.",
1027
+ ),
1028
+ _e(
1029
+ "E613",
1030
+ "Seeding a row failed",
1031
+ "exception",
1032
+ "karni.seeding",
1033
+ "The model rejected a generated row (model validation or save() logic).",
1034
+ "Override Seeder.seed() for the model (karni make seeder <Model>) and create the "
1035
+ "rows yourself with context.create(..., field=value).",
1036
+ ),
1037
+ _e(
1038
+ "E614",
1039
+ "karni seed was given invalid arguments",
1040
+ "exception",
1041
+ "karni seed; manage.py karni_seed",
1042
+ "--model takes app_label.Model names and --count a non-negative number.",
1043
+ "Fix the argument the message names, e.g. karni seed --model events.Event --count 20.",
1044
+ ),
1045
+ _e(
1046
+ "E615",
1047
+ "karni seed refuses to run with DEBUG = False",
1048
+ "exception",
1049
+ "karni seed; manage.py karni_seed",
1050
+ "DEBUG = False usually means a production database, and seeding creates a demo "
1051
+ "superuser with a known password.",
1052
+ "Seed a development database. If you really mean it, pass --force.",
1053
+ ),
1054
+ # E7xx agent tooling
1055
+ _e(
1056
+ "E701",
1057
+ "karni.rules.py is invalid",
1058
+ "exception",
1059
+ "karni.agent.overrides",
1060
+ "karni.rules.py adjusts the agent rules; it is read statically (never executed), so "
1061
+ "only literal values are allowed.",
1062
+ "Fix the line the message names: literal ADD/OVERRIDE/DISABLE/ADAPTERS values; ADD "
1063
+ "codes like P001 with a non-empty 'rule'; OVERRIDE/DISABLE only Karni K codes.",
1064
+ ),
1065
+ _e(
1066
+ "E702",
1067
+ "karni check found violations",
1068
+ "exception",
1069
+ "karni check; manage.py karni_check",
1070
+ "The architecture checks enforce the K rules (generated code is not hand-written, "
1071
+ "access is declared, and so on).",
1072
+ "Fix each listed violation (each names its K code; `karni explain K106` explains "
1073
+ "one), or suppress a deliberate exception the way the violation describes.",
1074
+ ),
1075
+ _e(
1076
+ "E703",
1077
+ "A source file cannot be parsed",
1078
+ "exception",
1079
+ "karni.agent.check",
1080
+ "karni check reads your code statically; a syntax error stops it.",
1081
+ "Fix the syntax error at the file and line in the message.",
1082
+ ),
1083
+ _e(
1084
+ "E704",
1085
+ "Karni's changelog is invalid",
1086
+ "exception",
1087
+ "karni.changelog",
1088
+ "karni upgrade reads the changelog to find and apply every breaking change.",
1089
+ "Maintainers: fix the entries the message lists (schema in karni/changelog/"
1090
+ "__init__.py). Users: reinstall karni-cli.",
1091
+ ),
1092
+ # E8xx import and export
1093
+ _e(
1094
+ "E801",
1095
+ "An import cannot proceed as asked",
1096
+ "api",
1097
+ "karni.transfer",
1098
+ "The mapping names a column the file lacks or a field the role may not write, the "
1099
+ "file has no header row, a commit was requested before the mandatory preview, or "
1100
+ "the model's import metadata (Karni.natural_key / import_key / import_lookup / "
1101
+ "import_transform / after_import) is malformed.",
1102
+ "Map columns to importable fields only (GET /<resource>/import/ lists them), "
1103
+ "preview before committing, and check the names in class Karni against the model.",
1104
+ ),
1105
+ _e(
1106
+ "E802",
1107
+ "The file format is not supported",
1108
+ "api",
1109
+ "karni.transfer.formats",
1110
+ "Import and export speak CSV and XLSX; anything else (or an upload whose format "
1111
+ "cannot be told from its name or content type) is refused.",
1112
+ "Upload a .csv or .xlsx file, or pass ?format=csv|xlsx; XLSX needs openpyxl "
1113
+ "(a core dependency).",
1114
+ ),
1115
+ _e(
1116
+ "E803",
1117
+ "Too many rows for one import or export",
1118
+ "api",
1119
+ "karni.transfer",
1120
+ "KARNI_IMPORT_MAX_ROWS caps an upload and KARNI_EXPORT_MAX_ROWS an export so one "
1121
+ "request cannot pin a worker or build an unbounded file.",
1122
+ "Split the upload, narrow the export's filters, or raise the setting for a project "
1123
+ "that needs more.",
1124
+ ),
1125
+ )
1126
+
1127
+
1128
+ def registry(extra: Iterable[ErrorCode] = (), *, modules: bool = True) -> dict[str, ErrorCode]:
1129
+ """Every code by name: Karni's, each installed module's, and ``extra``.
1130
+
1131
+ Modules register through the ``karni.errorcodes`` entry-point group.
1132
+ """
1133
+ entries: list[ErrorCode] = list(CODES)
1134
+ if modules:
1135
+ from importlib.metadata import entry_points
1136
+
1137
+ for entry_point in entry_points(group="karni.errorcodes"):
1138
+ entries.extend(entry_point.load())
1139
+ entries.extend(extra)
1140
+ found: dict[str, ErrorCode] = {}
1141
+ for entry in entries:
1142
+ if entry.code in found:
1143
+ raise KarniError(
1144
+ f"Error code {entry.code} is registered twice ({found[entry.code].source} and "
1145
+ f"{entry.source}). Codes are permanent and unique: give the newer entry the "
1146
+ "next free code."
1147
+ )
1148
+ found[entry.code] = entry
1149
+ return found
1150
+
1151
+
1152
+ def get(code: str) -> ErrorCode | None:
1153
+ """The entry for ``code`` (case-insensitive), or ``None``."""
1154
+ return registry().get(code.strip().upper())
1155
+
1156
+
1157
+ def problems(entries: Iterable[ErrorCode]) -> list[str]:
1158
+ """Registry integrity problems (format, kind, empty fields) — used by tests."""
1159
+ found: list[str] = []
1160
+ for entry in entries:
1161
+ if not _CODE.match(entry.code):
1162
+ found.append(f"{entry.code}: not a code like E104, W101 or PAY001")
1163
+ if entry.kind not in KINDS:
1164
+ found.append(f"{entry.code}: kind {entry.kind!r} is not one of {KINDS}")
1165
+ core = re.match(r"^[EW]\d{3}$", entry.code) is not None
1166
+ if core and entry.code.startswith("W") != (entry.kind == "warning"):
1167
+ found.append(f"{entry.code}: W codes are exactly the warnings")
1168
+ for name in ("title", "why", "fix", "source"):
1169
+ if not getattr(entry, name).strip():
1170
+ found.append(f"{entry.code}: empty {name}")
1171
+ return found