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,304 @@
1
+ # Karni 0.2.0: breaking changes and deprecations since 0.1.0.
2
+ # Schema and rules: karni/changelog/__init__.py. Migration notes: docs/migrations/0.2.md.
3
+ # `karni upgrade` runs each codemod (karni_cli/upgrades/<codemod>.py) in order and
4
+ # reports every manual change with its migration note.
5
+ version = "0.2.0"
6
+
7
+ # --- deprecated in 0.2, removed in 1.0 ------------------------------------------
8
+
9
+ [[change]]
10
+ id = "all-objects"
11
+ kind = "api-change"
12
+ symbol = "Model.all_objects"
13
+ replacement = 'Model.objects.without("soft_delete", reason=...)'
14
+ summary = """\
15
+ The all_objects manager is replaced by lifting the soft_delete scope by name. \
16
+ Until 1.0 it still works, but it now lifts only soft_delete (it used to drop \
17
+ every default-manager filter)."""
18
+ removed_in = "1.0"
19
+ mechanical = true
20
+ codemod = "v1_0.all_objects_to_without"
21
+ migration = "migrations/0.2.md#all_objects"
22
+
23
+ [[change]]
24
+ id = "all-with-deleted"
25
+ kind = "api-change"
26
+ symbol = "objects.all_with_deleted()"
27
+ replacement = 'objects.without("soft_delete", reason=...)'
28
+ summary = "The manager method is replaced by lifting the soft_delete scope by name."
29
+ removed_in = "1.0"
30
+ mechanical = true
31
+ codemod = "v1_0.all_with_deleted_to_without"
32
+ migration = "migrations/0.2.md#all_with_deleted"
33
+
34
+ [[change]]
35
+ id = "deleted-only"
36
+ kind = "api-change"
37
+ symbol = "objects.deleted_only()"
38
+ replacement = 'objects.without("soft_delete", reason=...).dead()'
39
+ summary = "The manager method is replaced by lifting soft_delete and keeping only dead rows."
40
+ removed_in = "1.0"
41
+ mechanical = true
42
+ codemod = "v1_0.deleted_only_to_without_dead"
43
+ migration = "migrations/0.2.md#deleted_only"
44
+
45
+ [[change]]
46
+ id = "soft-delete-manager-mixin"
47
+ kind = "base-rename"
48
+ symbol = "karni.managers.SoftDeleteManagerMixin"
49
+ replacement = "karni.managers.ScopedManagerMixin"
50
+ summary = """\
51
+ The manager mixin runs every named scope now, not just soft delete, so it was \
52
+ renamed. The old name is the same class."""
53
+ removed_in = "1.0"
54
+ mechanical = true
55
+ codemod = "v1_0.soft_delete_manager_mixin_to_scoped"
56
+ migration = "migrations/0.2.md#softdeletemanagermixin"
57
+
58
+ [[change]]
59
+ id = "object-permission-classes"
60
+ kind = "api-change"
61
+ symbol = "BaseViewSet.object_permission_classes"
62
+ replacement = "the access declaration (access = Access(read=..., write=...))"
63
+ summary = """\
64
+ Object-level permission classes on a BaseViewSet are replaced by the access \
65
+ declaration. They still run, after the access object check, until 1.0. The \
66
+ rule they encode has to be re-expressed as access predicates by hand."""
67
+ removed_in = "1.0"
68
+ mechanical = false
69
+ codemod = "v1_0.object_permission_classes_to_access"
70
+ migration = "migrations/0.2.md#object_permission_classes"
71
+
72
+ [[change]]
73
+ id = "related-lookups"
74
+ kind = "api-change"
75
+ symbol = "karni.api.expansion.related_lookups"
76
+ replacement = "karni.api.optimizer.optimize(queryset, serializer, scope=...)"
77
+ summary = """\
78
+ The expansion-only lookup helper is subsumed by the serializer-walking query \
79
+ optimiser, which takes a queryset and a serializer instead of expand paths."""
80
+ removed_in = "1.0"
81
+ mechanical = false
82
+ codemod = "v1_0.related_lookups_to_optimize"
83
+ migration = "migrations/0.2.md#related_lookups-and-scoped_related_lookups"
84
+
85
+ [[change]]
86
+ id = "scoped-related-lookups"
87
+ kind = "api-change"
88
+ symbol = "karni.api.expansion.scoped_related_lookups"
89
+ replacement = "karni.api.optimizer.optimize(queryset, serializer, scope=...)"
90
+ summary = """\
91
+ The scoped expansion lookup helper is subsumed by the query optimiser, which \
92
+ applies each target viewset's read scope itself."""
93
+ removed_in = "1.0"
94
+ mechanical = false
95
+ codemod = "v1_0.scoped_related_lookups_to_optimize"
96
+ migration = "migrations/0.2.md#related_lookups-and-scoped_related_lookups"
97
+
98
+ # --- breaking in 0.2 -----------------------------------------------------------
99
+
100
+ [[change]]
101
+ id = "agent-guidance-files"
102
+ kind = "project-files"
103
+ symbol = "AGENTS.md, CLAUDE.md, .cursorrules, .claude/commands/*.md"
104
+ replacement = "karni.rules.py plus adapters generated by `karni rules sync`"
105
+ summary = """\
106
+ Hand-written agent guidance files became generated adapters. The project's own \
107
+ additions move to karni.rules.py verbatim, module blocks to .karni/modules/, and \
108
+ the originals are backed up under .karni/backup/."""
109
+ mechanical = true
110
+ codemod = "v0_2.agent_guidance"
111
+ migration = "migrations/0.2.md#agent-guidance-files"
112
+
113
+ [[change]]
114
+ id = "mandatory-access"
115
+ kind = "behaviour"
116
+ symbol = "karni.api.BaseViewSet"
117
+ replacement = 'access = Access(read=..., write=...) or access = Unscoped(reason="...")'
118
+ summary = """\
119
+ Every BaseViewSet must declare access. A viewset without one fails `karni check` \
120
+ (viewset-access) and denies every request at runtime. An object outside the read \
121
+ scope answers 404; one readable but not writable answers 403."""
122
+ mechanical = false
123
+ migration = "migrations/0.2.md#every-baseviewset-declares-access"
124
+
125
+ [[change]]
126
+ id = "default-permission-classes"
127
+ kind = "behaviour"
128
+ symbol = 'REST_FRAMEWORK["DEFAULT_PERMISSION_CLASSES"]'
129
+ replacement = "rest_framework.permissions.IsAuthenticated"
130
+ summary = """\
131
+ Karni's default changed from IsAuthenticatedOrReadOnly to IsAuthenticated, and \
132
+ it no longer gates a BaseViewSet with an access declaration. It still covers \
133
+ plain DRF views and Unscoped viewsets, which no longer allow anonymous reads."""
134
+ mechanical = false
135
+ migration = "migrations/0.2.md#default_permission_classes"
136
+
137
+ [[change]]
138
+ id = "relation-read-scope"
139
+ kind = "behaviour"
140
+ symbol = "relation fields and ?expand="
141
+ summary = """\
142
+ A writable relation field accepts only primary keys the requester can read \
143
+ through the target model's viewset, and ?expand= embeds only readable rows (an \
144
+ unreadable to-one target renders as null)."""
145
+ mechanical = false
146
+ migration = "migrations/0.2.md#relations-and-expansion-follow-the-targets-read-scope"
147
+
148
+ [[change]]
149
+ id = "max-page-size"
150
+ kind = "behaviour"
151
+ symbol = "KarniPageNumberPagination.max_page_size"
152
+ replacement = "KARNI_MAX_PAGE_SIZE"
153
+ summary = """\
154
+ ?page_size= is capped at KARNI_MAX_PAGE_SIZE, default 100 (was a hard-coded \
155
+ 500). A paginator subclass may lower the cap, never raise it."""
156
+ mechanical = false
157
+ migration = "migrations/0.2.md#karni_max_page_size-defaults-to-100"
158
+
159
+ [[change]]
160
+ id = "pagination-required"
161
+ kind = "behaviour"
162
+ symbol = "pagination_class = None"
163
+ replacement = 'pagination = Unpaginated(max_rows=..., reason="...")'
164
+ summary = """\
165
+ pagination_class = None on a BaseViewSet is a class-definition error, and boot \
166
+ refuses REST_FRAMEWORK["PAGE_SIZE"] = None or a non-Karni default pagination \
167
+ class. An unpaginated list is declared with Unpaginated(...)."""
168
+ mechanical = false
169
+ migration = "migrations/0.2.md#unpaginated-lists-need-unpaginated"
170
+
171
+ [[change]]
172
+ id = "query-budgets"
173
+ kind = "behaviour"
174
+ symbol = "KARNI_QUERY_BUDGET_MODE"
175
+ replacement = 'query_budget = Budget(list=..., reason="...") on the viewset'
176
+ summary = """\
177
+ Every list, retrieve, create, update and destroy has a query budget. Karni's \
178
+ pytest plugin runs tests with KARNI_QUERY_BUDGET_MODE = "raise", so an action \
179
+ over budget now fails its test."""
180
+ mechanical = false
181
+ migration = "migrations/0.2.md#query-budgets-fail-tests"
182
+
183
+ [[change]]
184
+ id = "roles-as-data"
185
+ kind = "behaviour"
186
+ symbol = "KARNI_ROLES and migrate"
187
+ replacement = "KARNI_ROLES = {...}; karni sync-roles"
188
+ summary = """\
189
+ Roles are data (ADR 0002): migrate now creates the roles KARNI_ROLES declares \
190
+ (staff, user and customer by default, as groups plus Role rows with grants) and \
191
+ a Role row for every existing group. Nothing is deleted; Can('app.model:action') \
192
+ is available in access declarations."""
193
+ mechanical = false
194
+ migration = "migrations/0.2.md#roles-as-data"
195
+
196
+ [[change]]
197
+ id = "row-level-scoping"
198
+ kind = "behaviour"
199
+ symbol = "admin row scope"
200
+ replacement = 'access = Granted(); scope_exempt = True, scope_exempt_reason = "..."'
201
+ summary = """\
202
+ Row-level scoping (ADR 0002, P2): access = Granted() scopes a viewset by the \
203
+ roles' grants and is what karni make viewset writes, and the admin now shows \
204
+ only the rows the user's grants reach — a staff user with Django permissions \
205
+ but no grant sees nothing. scope_exempt opts a viewset or admin out, with a \
206
+ reason that karni check lists."""
207
+ mechanical = false
208
+ migration = "migrations/0.2.md#row-level-scoping"
209
+
210
+ [[change]]
211
+ id = "field-visibility"
212
+ kind = "behaviour"
213
+ symbol = 'ordering_fields = "__all__"'
214
+ replacement = "class Karni: field_roles = {...} on the model; ordering_fields on a viewset that needs more"
215
+ summary = """\
216
+ Field-level visibility (ADR 0002, P3): Karni.field_roles hides fields per role on \
217
+ the API, in ?expand=, in the schema and in the admin. ?ordering= now accepts only \
218
+ the fields the request's serializer reads back (write-only and hidden fields no \
219
+ longer order the list), and search_fields may not name a write-only field \
220
+ (karni.E131)."""
221
+ mechanical = false
222
+ migration = "migrations/0.2.md#field-level-visibility"
223
+
224
+ [[change]]
225
+ id = "change-history"
226
+ kind = "behaviour"
227
+ symbol = "karni.audit"
228
+ replacement = "class Karni: audit = True on the models to record"
229
+ summary = """\
230
+ Change history (ADR 0002, P4): the new karni.audit app (run migrate once) records \
231
+ field-level diffs for models that opt in, surfaced on the admin History tab, \
232
+ GET /<resource>/<id>/history/ and <ChangeHistory/>; QuerySet.update()/bulk_update() \
233
+ on an audited model is flagged by karni check (audited-bulk-write)."""
234
+ mechanical = false
235
+ migration = "migrations/0.2.md#change-history"
236
+
237
+ [[change]]
238
+ id = "permission-matrix"
239
+ kind = "behaviour"
240
+ symbol = "karni.accounts.matrix"
241
+ replacement = "the Permission matrix admin page / POST api/auth/permissions/matrix/"
242
+ summary = """\
243
+ Permission matrix (ADR 0002, P5): grants change in the admin (Roles → Permission \
244
+ matrix) or through api/auth/permissions/matrix/ with guard rails and a confirmation \
245
+ diff, and every change is history. RolePermission gains `origin` (run migrate once); \
246
+ a grant set in the matrix is kept by karni sync-roles and reported as overridden; a \
247
+ revoked declared grant stays as a `none` row. Dropped declared grants are now removed \
248
+ outright instead of soft-deleted."""
249
+ mechanical = false
250
+ migration = "migrations/0.2.md#permission-matrix"
251
+
252
+ [[change]]
253
+ id = "import-export"
254
+ kind = "behaviour"
255
+ symbol = "karni.transfer"
256
+ replacement = "GET /<resource>/export/, /<resource>/import/…, the admin's Export/Import, <ResourceImport/>"
257
+ summary = """\
258
+ Import and export (ADR 0002, P6): the new karni.transfer app (run migrate once; \
259
+ karni.audit gains ChangeBatch) gives every BaseViewSet a CSV/XLSX export of the \
260
+ list as the request sees it and a four-step import with a mandatory dry run, \
261
+ audited as one batch; every Karni admin gains the export actions and an import \
262
+ page; @karni/ui gains <ResourceImport/> and export buttons on <ResourceTable>; \
263
+ openpyxl is a new core dependency."""
264
+ mechanical = false
265
+ migration = "migrations/0.2.md#import-and-export"
266
+
267
+ [[change]]
268
+ id = "write-interception"
269
+ kind = "behaviour"
270
+ symbol = "karni.signals.pre_write"
271
+ replacement = "nothing: an additive hook (karni-approvals is its first user)"
272
+ summary = """\
273
+ Every BaseModel save() and delete() now sends karni.signals.pre_write first; a \
274
+ receiver raising KarniWriteDeferred stops the write before any SQL (the API answers \
275
+ 202 with its payload, the admin shows its message). Code that wraps model saves \
276
+ in its own transactions sees no difference; a custom BaseModel.save() override \
277
+ must call super().save() for the hook to fire."""
278
+ mechanical = false
279
+ migration = "migrations/0.2.md#write-interception"
280
+
281
+ [[change]]
282
+ id = "architecture-scans-without-base-dir"
283
+ kind = "behaviour"
284
+ symbol = "BASE_DIR"
285
+ replacement = "nothing: discovery infers the project root"
286
+ summary = """\
287
+ karni check and the architecture suite now scan the project's apps without \
288
+ settings.BASE_DIR (they infer the root from the settings package); projects \
289
+ that scanned nothing before may see violations on the next karni check."""
290
+ mechanical = false
291
+ migration = "migrations/0.2.md#architecture-rules-scan-the-project-without-base_dir"
292
+
293
+ [[change]]
294
+ id = "vetoes"
295
+ kind = "behaviour"
296
+ symbol = "karni.vetoes"
297
+ replacement = "call super() (or self.vetoed(request, action)) in admin permission overrides"
298
+ summary = """\
299
+ The API gate and the admin consult karni.vetoes after the access declaration \
300
+ admitted an action. Nothing changes without a registered veto; a Karni admin \
301
+ that overrides has_delete_permission or get_actions without super() skips the \
302
+ check there."""
303
+ mechanical = false
304
+ migration = "migrations/0.2.md#vetoes-at-the-gates"
@@ -0,0 +1,25 @@
1
+ # Karni 0.3.0: breaking changes since 0.2.0.
2
+ # Schema and rules: karni/changelog/__init__.py. Migration notes: docs/migrations/0.3.md.
3
+ # Neither change has a codemod: both are reported by `karni upgrade` with their note.
4
+ version = "0.3.0"
5
+
6
+ [[change]]
7
+ id = "django-5-2"
8
+ kind = "behaviour"
9
+ symbol = "Django"
10
+ summary = """\
11
+ Karni requires Django 5.2 LTS (>=5.2.8). Django 5.1 is out of support with \
12
+ unpatched security advisories, and its admin fails on Python 3.14."""
13
+ mechanical = false
14
+ migration = "migrations/0.3.md#django-52-lts"
15
+
16
+ [[change]]
17
+ id = "role-scope-paths-checked"
18
+ kind = "behaviour"
19
+ symbol = "KARNI_ROLES"
20
+ summary = """\
21
+ Every path a role's grants filter on is resolved at startup. A path that does \
22
+ not resolve on its model is now system check error karni.E214 instead of a 500 \
23
+ on the first request that uses it."""
24
+ mechanical = false
25
+ migration = "migrations/0.3.md#role-scope-paths-are-checked-at-startup"
@@ -0,0 +1,248 @@
1
+ """Karni's machine-readable changelog: every breaking change and deprecation, per release.
2
+
3
+ ``karni upgrade`` (karni-cli) reads it to find every change between a
4
+ project's Karni version and the target, runs their codemods in order and
5
+ reports what it could not fix. ``docs/migrations/<version>.md`` explains each
6
+ entry for humans. This package is stdlib only: karni-cli loads it by path
7
+ (bundled as ``karni_cli/_changelog``) without importing Django or Karni.
8
+
9
+ One TOML file per release, named by its version (``0.2.0.toml``). A release
10
+ only ever adds its own file; entries are never edited after they ship. Schema::
11
+
12
+ version = "0.2.0" # must match the file name
13
+
14
+ [[change]]
15
+ id = "all-objects" # unique across every release, kebab-case
16
+ kind = "api-change" # one of KINDS
17
+ symbol = "Model.all_objects" # what changed: dotted path, setting, method, file
18
+ replacement = 'Model.objects.without("soft_delete", reason=...)' # required for renames
19
+ summary = "One or two sentences: what changed and what breaks."
20
+ removed_in = "1.0" # deprecations only: the release that removes `symbol`
21
+ mechanical = true # a codemod rewrites every use safely
22
+ codemod = "v1_0.all_objects_to_without" # module under karni_cli.upgrades
23
+ migration = "migrations/0.2.md#all_objects" # migration note, relative to docs/
24
+
25
+ A change *breaks* at ``removed_in`` when set (a deprecation: the old name
26
+ keeps working, with a :class:`karni.deprecation.KarniDeprecationWarning`,
27
+ from ``version`` until then), else at ``version`` itself. ``mechanical = true``
28
+ requires ``codemod``. A manual change (``mechanical = false``) may still name
29
+ one: a detector that reports the code to change without editing it.
30
+
31
+ Override / escape hatch: :func:`load` takes any directory, so tools and tests
32
+ can read another changelog with the same schema and validation.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import re
38
+ import tomllib
39
+ from dataclasses import dataclass
40
+ from pathlib import Path
41
+ from typing import Any, Final
42
+
43
+ from karni import links as _links
44
+
45
+ __all__ = [
46
+ "DOCS_BASE_URL",
47
+ "KINDS",
48
+ "Change",
49
+ "ChangelogError",
50
+ "Release",
51
+ "changes",
52
+ "docs_url",
53
+ "load",
54
+ "version_key",
55
+ ]
56
+
57
+ #: Where ``docs/`` is published: ``migration`` values here and ``doc_anchor``
58
+ #: values in :func:`karni.deprecation.warn_deprecated` are relative to it.
59
+ #: Set in one place, ``karni.links``; re-exported here for the upgrade tooling.
60
+ DOCS_BASE_URL: Final[str] = _links.DOCS_BASE_URL
61
+
62
+ #: What a change can be. ``project-files``: files the scaffold generated changed shape.
63
+ KINDS: Final = (
64
+ "setting-rename",
65
+ "import-move",
66
+ "base-rename",
67
+ "api-change",
68
+ "behaviour",
69
+ "project-files",
70
+ )
71
+ #: Kinds whose ``replacement`` is a name a codemod renames ``symbol`` to.
72
+ RENAME_KINDS: Final = frozenset({"setting-rename", "import-move", "base-rename"})
73
+
74
+ DIRECTORY: Final = Path(__file__).resolve().parent
75
+
76
+ _ID = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
77
+ _VERSION = re.compile(r"^\d+(?:\.\d+){0,2}$")
78
+ _CODEMOD = re.compile(r"^v\d+_\d+\.[a-z_][a-z0-9_]*$")
79
+ _MIGRATION = re.compile(r"^migrations/[\w.-]+\.md#[\w-]+$")
80
+ _REQUIRED: Final = ("id", "kind", "symbol", "summary", "mechanical", "migration")
81
+ _OPTIONAL: Final = ("replacement", "removed_in", "codemod")
82
+
83
+
84
+ def docs_url(doc_anchor: str) -> str:
85
+ """Absolute docs-site link for ``doc_anchor`` (``"migrations/0.2.md#section"``).
86
+
87
+ Pages are addressed as in the repository (``models.md``); the site serves
88
+ each page as a directory (``models/``), so the link is translated.
89
+ """
90
+ path, _, anchor = doc_anchor.lstrip("/").partition("#")
91
+ if path.endswith(".md"):
92
+ path = path[:-3]
93
+ path = "" if path == "index" else path + "/"
94
+ return DOCS_BASE_URL + path + (f"#{anchor}" if anchor else "")
95
+
96
+
97
+ class ChangelogError(ValueError):
98
+ """The changelog does not match the schema (the message lists every problem).
99
+
100
+ Code ``E704``; a ``ValueError`` so callers that predate the code keep working.
101
+ """
102
+
103
+ code = "E704"
104
+
105
+ def __str__(self) -> str:
106
+ return f"{self.code}: {super().__str__()} See {DOCS_BASE_URL}errors/#e704"
107
+
108
+
109
+ def version_key(text: str) -> tuple[int, int, int]:
110
+ """``"1.0"`` -> ``(1, 0, 0)``: release versions only (``MAJOR[.MINOR[.PATCH]]``)."""
111
+ if not _VERSION.match(text):
112
+ raise ChangelogError(f"{text!r} is not a release version (MAJOR[.MINOR[.PATCH]])")
113
+ major, minor, patch = ([int(part) for part in text.split(".")] + [0, 0])[:3]
114
+ return major, minor, patch
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class Change:
119
+ """One breaking change or deprecation."""
120
+
121
+ id: str
122
+ release: str
123
+ kind: str
124
+ symbol: str
125
+ summary: str
126
+ mechanical: bool
127
+ migration: str
128
+ replacement: str = ""
129
+ removed_in: str = ""
130
+ codemod: str = ""
131
+
132
+ @property
133
+ def breaks_in(self) -> str:
134
+ """The release whose code no longer accepts the old form."""
135
+ return self.removed_in or self.release
136
+
137
+ @property
138
+ def is_deprecation(self) -> bool:
139
+ return bool(self.removed_in)
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class Release:
144
+ version: str
145
+ changes: tuple[Change, ...]
146
+
147
+
148
+ def _change(raw: Any, release: str, where: str, problems: list[str]) -> Change | None:
149
+ if not isinstance(raw, dict):
150
+ problems.append(f"{where}: a [[change]] must be a table")
151
+ return None
152
+ label = f"{where} ({raw.get('id', '?')})"
153
+ missing = [key for key in _REQUIRED if key not in raw]
154
+ unknown = sorted(set(raw) - set(_REQUIRED) - set(_OPTIONAL))
155
+ if missing:
156
+ problems.append(f"{label}: missing {', '.join(missing)}")
157
+ if unknown:
158
+ problems.append(f"{label}: unknown key(s) {', '.join(unknown)}")
159
+ if missing or unknown:
160
+ return None
161
+ for key in (*_REQUIRED, *_OPTIONAL):
162
+ if key in raw and key != "mechanical" and not isinstance(raw[key], str):
163
+ problems.append(f"{label}: {key} must be a string")
164
+ return None
165
+ if not isinstance(raw["mechanical"], bool):
166
+ problems.append(f"{label}: mechanical must be true or false")
167
+ return None
168
+ change = Change(release=release, **raw)
169
+ if not _ID.match(change.id):
170
+ problems.append(f"{label}: id must be kebab-case")
171
+ if change.kind not in KINDS:
172
+ problems.append(f"{label}: kind {change.kind!r} is not one of {', '.join(KINDS)}")
173
+ if not change.summary.strip() or not change.symbol.strip():
174
+ problems.append(f"{label}: symbol and summary must not be empty")
175
+ if change.kind in RENAME_KINDS and not change.replacement:
176
+ problems.append(f"{label}: a {change.kind} needs a replacement")
177
+ if change.removed_in:
178
+ try:
179
+ if version_key(change.removed_in) <= version_key(release):
180
+ problems.append(f"{label}: removed_in must be later than {release}")
181
+ except ChangelogError as exc:
182
+ problems.append(f"{label}: removed_in: {exc}")
183
+ if change.mechanical and not change.codemod:
184
+ problems.append(f"{label}: a mechanical change needs a codemod")
185
+ if change.codemod and not _CODEMOD.match(change.codemod):
186
+ problems.append(f"{label}: codemod must look like 'v1_0.module_name'")
187
+ if not _MIGRATION.match(change.migration):
188
+ problems.append(f"{label}: migration must look like 'migrations/<file>.md#<anchor>'")
189
+ return change
190
+
191
+
192
+ def _release(path: Path, problems: list[str]) -> Release | None:
193
+ try:
194
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
195
+ except tomllib.TOMLDecodeError as exc:
196
+ problems.append(f"{path.name}: invalid TOML: {exc}")
197
+ return None
198
+ version = data.get("version")
199
+ if version != path.stem:
200
+ problems.append(f"{path.name}: version = {version!r} must match the file name")
201
+ return None
202
+ try:
203
+ version_key(version)
204
+ except ChangelogError as exc:
205
+ problems.append(f"{path.name}: {exc}")
206
+ return None
207
+ unknown = sorted(set(data) - {"version", "change"})
208
+ if unknown:
209
+ problems.append(f"{path.name}: unknown key(s) {', '.join(unknown)}")
210
+ raw_changes = data.get("change", [])
211
+ if not isinstance(raw_changes, list):
212
+ problems.append(f"{path.name}: change must be an array of tables ([[change]])")
213
+ return None
214
+ parsed = [
215
+ _change(raw, version, f"{path.name}[{index}]", problems)
216
+ for index, raw in enumerate(raw_changes)
217
+ ]
218
+ return Release(version, tuple(change for change in parsed if change is not None))
219
+
220
+
221
+ def load(directory: Path | None = None) -> tuple[Release, ...]:
222
+ """Every release in ``directory`` (default: Karni's own), oldest first.
223
+
224
+ :raises ChangelogError: listing every schema problem found.
225
+ """
226
+ root = DIRECTORY if directory is None else directory
227
+ problems: list[str] = []
228
+ releases = [
229
+ release
230
+ for path in sorted(root.glob("*.toml"))
231
+ if (release := _release(path, problems)) is not None
232
+ ]
233
+ seen: dict[str, str] = {}
234
+ for release in releases:
235
+ for change in release.changes:
236
+ if change.id in seen:
237
+ problems.append(
238
+ f"duplicate id {change.id!r} in {seen[change.id]} and {release.version}"
239
+ )
240
+ seen[change.id] = release.version
241
+ if problems:
242
+ raise ChangelogError("invalid changelog:\n " + "\n ".join(problems))
243
+ return tuple(sorted(releases, key=lambda release: version_key(release.version)))
244
+
245
+
246
+ def changes(directory: Path | None = None) -> list[Change]:
247
+ """Every change, oldest release first, in file order within a release."""
248
+ return [change for release in load(directory) for change in release.changes]