karni-cli 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- karni_cli/__init__.py +3 -0
- karni_cli/_agent/__init__.py +82 -0
- karni_cli/_agent/__main__.py +44 -0
- karni_cli/_agent/check.py +658 -0
- karni_cli/_agent/history.py +328 -0
- karni_cli/_agent/history_chronicle.py +246 -0
- karni_cli/_agent/overrides.py +194 -0
- karni_cli/_agent/rules.py +673 -0
- karni_cli/_changelog/0.2.0.toml +304 -0
- karni_cli/_changelog/0.3.0.toml +25 -0
- karni_cli/_changelog/__init__.py +248 -0
- karni_cli/_errorcodes/__init__.py +1171 -0
- karni_cli/_errorcodes/__main__.py +34 -0
- karni_cli/_errorcodes/links.py +217 -0
- karni_cli/_templates/project/cookiecutter.json +8 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.dockerignore +7 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.env.example +85 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.gitignore +10 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Caddyfile +22 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Dockerfile +53 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Makefile +52 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/README.md +169 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/__init__.py +16 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/asgi.py +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/celery.py +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/settings.py +28 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/urls.py +31 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/wsgi.py +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/docker-compose.yml +126 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.dockerignore +2 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.jscpd.json +9 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.npmrc +7 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/Dockerfile.dev +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/eslint.config.js +32 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/index.html +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/package.json +43 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/postcss.config.js +6 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/public/favicon.svg +6 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/App.tsx +18 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/api/health.ts +21 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/index.css +11 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/layout.tsx +51 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/main.tsx +11 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/pages/home.tsx +28 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/routes.tsx +24 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/vite-env.d.ts +1 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tailwind.config.ts +13 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tsconfig.json +19 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/vite.config.ts +18 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/manage.py +23 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/pyproject.toml +31 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/__init__.py +0 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_api_schema.py +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_smoke.py +11 -0
- karni_cli/adapters.py +373 -0
- karni_cli/agent_adapters.toml +160 -0
- karni_cli/agent_data.py +260 -0
- karni_cli/brand.py +55 -0
- karni_cli/clidoc.py +254 -0
- karni_cli/commands/__init__.py +0 -0
- karni_cli/commands/add.py +144 -0
- karni_cli/commands/beat.py +36 -0
- karni_cli/commands/check.py +101 -0
- karni_cli/commands/config.py +81 -0
- karni_cli/commands/context.py +171 -0
- karni_cli/commands/createsuperuser.py +28 -0
- karni_cli/commands/doctor.py +136 -0
- karni_cli/commands/explain.py +60 -0
- karni_cli/commands/make.py +55 -0
- karni_cli/commands/manage_passthrough.py +28 -0
- karni_cli/commands/mcp.py +20 -0
- karni_cli/commands/migrate.py +23 -0
- karni_cli/commands/new.py +106 -0
- karni_cli/commands/routes.py +20 -0
- karni_cli/commands/rules.py +139 -0
- karni_cli/commands/run_tests.py +38 -0
- karni_cli/commands/schedule.py +40 -0
- karni_cli/commands/seed.py +34 -0
- karni_cli/commands/serve.py +50 -0
- karni_cli/commands/shell.py +28 -0
- karni_cli/commands/sync_roles.py +32 -0
- karni_cli/commands/types.py +72 -0
- karni_cli/commands/upgrade.py +231 -0
- karni_cli/commands/worker.py +63 -0
- karni_cli/config.py +142 -0
- karni_cli/environment.py +29 -0
- karni_cli/errorcodes.py +377 -0
- karni_cli/errordoc.py +227 -0
- karni_cli/generators/__init__.py +67 -0
- karni_cli/generators/apitest.py +206 -0
- karni_cli/generators/app.py +103 -0
- karni_cli/generators/command.py +98 -0
- karni_cli/generators/component.py +115 -0
- karni_cli/generators/core.py +319 -0
- karni_cli/generators/event.py +57 -0
- karni_cli/generators/factory.py +88 -0
- karni_cli/generators/fields.py +343 -0
- karni_cli/generators/introspect.py +263 -0
- karni_cli/generators/listener.py +168 -0
- karni_cli/generators/middleware.py +95 -0
- karni_cli/generators/migration.py +82 -0
- karni_cli/generators/model.py +235 -0
- karni_cli/generators/module.py +255 -0
- karni_cli/generators/naming.py +100 -0
- karni_cli/generators/page.py +373 -0
- karni_cli/generators/pysource.py +304 -0
- karni_cli/generators/schedule.py +174 -0
- karni_cli/generators/seeder.py +86 -0
- karni_cli/generators/serializer.py +165 -0
- karni_cli/generators/service.py +68 -0
- karni_cli/generators/task.py +84 -0
- karni_cli/generators/viewset.py +179 -0
- karni_cli/history.py +59 -0
- karni_cli/local_state.py +73 -0
- karni_cli/main.py +104 -0
- karni_cli/mcp_server.py +103 -0
- karni_cli/project.py +248 -0
- karni_cli/rules_render.py +86 -0
- karni_cli/scaffold.py +197 -0
- karni_cli/settings_edit.py +128 -0
- karni_cli/tips.py +53 -0
- karni_cli/typegen.py +101 -0
- karni_cli/upgrades/__init__.py +28 -0
- karni_cli/upgrades/changelog.py +123 -0
- karni_cli/upgrades/codemod.py +125 -0
- karni_cli/upgrades/engine.py +224 -0
- karni_cli/upgrades/legacy_agent_files.py +138 -0
- karni_cli/upgrades/python.py +670 -0
- karni_cli/upgrades/transforms/rename-export.cjs +96 -0
- karni_cli/upgrades/typescript.py +160 -0
- karni_cli/upgrades/v0_2/__init__.py +1 -0
- karni_cli/upgrades/v0_2/agent_guidance.py +269 -0
- karni_cli/upgrades/v1_0/__init__.py +5 -0
- karni_cli/upgrades/v1_0/all_objects_to_without.py +18 -0
- karni_cli/upgrades/v1_0/all_with_deleted_to_without.py +13 -0
- karni_cli/upgrades/v1_0/deleted_only_to_without_dead.py +13 -0
- karni_cli/upgrades/v1_0/object_permission_classes_to_access.py +16 -0
- karni_cli/upgrades/v1_0/related_lookups_to_optimize.py +15 -0
- karni_cli/upgrades/v1_0/scoped_related_lookups_to_optimize.py +15 -0
- karni_cli/upgrades/v1_0/soft_delete_manager_mixin_to_scoped.py +14 -0
- karni_cli-0.3.1.dist-info/METADATA +191 -0
- karni_cli-0.3.1.dist-info/RECORD +145 -0
- karni_cli-0.3.1.dist-info/WHEEL +4 -0
- karni_cli-0.3.1.dist-info/entry_points.txt +2 -0
- karni_cli-0.3.1.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,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]
|