Migrating to Karni 0.2¶
Every change on this page is recorded in Karni's machine-readable changelog
(packages/karni/karni/changelog/0.2.0.toml). karni upgrade reads it and
rewrites what can be rewritten safely. It reports everything else with a link
to the section below.
Upgrading¶
Run the upgrade before you raise the karni pin, while 0.1 is still
installed: karni upgrade reads the installed version to know where the
project starts.
pipx upgrade karni-cli # the CLI carries the changelog and the codemods
karni upgrade --dry-run # a unified diff of every change; writes nothing
karni upgrade # needs a clean git tree (or --allow-dirty)
# then: pin karni to 0.2 in pyproject.toml, reinstall, run `karni check` and the tests
If you already installed 0.2, pass the version you came from:
karni upgrade --from 0.1.0. Codemods only rewrite code that still uses the
old form, so running one twice changes nothing.
The final report has three parts:
- Applied: every codemod that changed code, with each file and its number of rewrites.
- Could not fix automatically: changes that need a person, with the code locations a detector found and the link to the note here.
- Skipped: codemods with nothing to change, or that could not run (for example a TypeScript codemod on a machine without Node.js).
Deprecated names still work in 0.2. Each one raises a
karni.deprecation.KarniDeprecationWarning naming its replacement, the release
that removes it and its section on this page. To find any your tests still
reach, run them with -W error::karni.deprecation.KarniDeprecationWarning.
Deprecated in 0.2, removed in 1.0¶
all_objects¶
karni upgrade rewrites it (v1_0.all_objects_to_without).
# before
Book.all_objects.filter(shelf=shelf)
# after
Book.objects.without("soft_delete", reason="<auto: migrated from all_objects>").filter(shelf=shelf)
Replace the generated reason with your own: it is logged every time the scope
is lifted. One behaviour changed even before removal. all_objects used to drop
every default-manager filter, and now it lifts only soft_delete, so it can
never bypass a scope a module adds later, such as a tenant scope.
The codemod rewrites X.all_objects only where X names a class: a
capitalised name, cls, self.model, type(obj). Django does not let you
reach a manager through an instance, so obj.all_objects cannot be Karni's
alias and is left alone. The codemod leaves two cases for you and lists them
in the report:
- the project defines its own
all_objectson some class, so a reference could be either manager; - the next call needs a manager rather than a queryset, as in
Book.all_objects.get_queryset()orBook.all_objects.without(...).
all_with_deleted()¶
karni upgrade rewrites it (v1_0.all_with_deleted_to_without).
# before
Book.objects.all_with_deleted()
shelf.books.all_with_deleted()
# after
Book.objects.without("soft_delete", reason="<auto: migrated from all_with_deleted()>")
shelf.books.without("soft_delete", reason="<auto: migrated from all_with_deleted()>")
On a related manager the relation filter stays. If the project defines its own
all_with_deleted method, the codemod cannot tell the two apart. It then
reports every call instead of rewriting it.
deleted_only()¶
karni upgrade rewrites it (v1_0.deleted_only_to_without_dead).
# before
Book.objects.deleted_only()
# after
Book.objects.without("soft_delete", reason="<auto: migrated from deleted_only()>").dead()
SoftDeleteManagerMixin¶
karni upgrade renames it everywhere (v1_0.soft_delete_manager_mixin_to_scoped):
in imports, in base-class lists, in attribute access through the module, and in
dotted-path strings. An import alias (import ... as M) is kept.
# before
from karni.managers import SoftDeleteManagerMixin
class EventManager(SoftDeleteManagerMixin, Manager.from_queryset(EventQuerySet)):
pass
# after
from karni.managers import ScopedManagerMixin
class EventManager(ScopedManagerMixin, Manager.from_queryset(EventQuerySet)):
pass
It is the same class: the mixin now runs every named scope (see models), so the old name was too narrow.
object_permission_classes¶
Manual. karni upgrade lists every viewset that sets it. It cannot
translate an arbitrary permission class into access predicates.
# before
class EventViewSet(BaseViewSet):
model = Event
owner_field = "organizer"
object_permission_classes = [IsOwner]
# after
from karni.api import Access, Authenticated, Owned
class EventViewSet(BaseViewSet):
model = Event
access = Access(read=Authenticated(), write=Owned("organizer"))
Until 1.0 the classes still run after the access object check, so they can
only narrow. The Access control guide maps the
common rules onto Owned, HasRole, Where and |/&. IsOwner,
RolePermission and ObjectPermissionsMixin stay available for plain DRF
views.
related_lookups() and scoped_related_lookups()¶
Manual. karni upgrade lists every reference. The replacement takes a
queryset and the serializer that will render it, not expand paths, so the
calling code changes shape:
# before
from karni.api.expansion import related_lookups
select, prefetch = related_lookups(Event, expand)
queryset = Event.objects.select_related(*select).prefetch_related(*prefetch)
# after
from karni.api.optimizer import optimize
queryset = optimize(Event.objects.all(), EventSerializer)
Pass scope= when each level must obey a viewset's read scope
(scoped_related_lookups). Inside a BaseViewSet, self.optimize_queryset(qs)
does that for you. See Query optimisation.
Breaking in 0.2¶
Agent guidance files¶
karni upgrade migrates them (v0_2.agent_guidance). Projects scaffolded by
0.1 have hand-written AGENTS.md, CLAUDE.md, .cursorrules and
.claude/commands/*.md. In 0.2 these files are adapters that karni rules sync
generates. The codemod:
- moves module blocks to
.karni/modules/<x>.md; - copies every line you added to Karni's text into
karni.rules.py, verbatim; - lists every line you removed as a
# REVIEW:comment and in the report, because only you know whether it meant "disable this rule"; - backs the originals up under
.karni/backup/and generates the adapters.
Files that do not look like Karni's are left untouched and reported. See agents.
Every BaseViewSet declares access¶
Manual. karni check lists every viewset that is missing it (the
viewset-access rule). Until you add one, the viewset denies every request.
# before (0.1: anonymous reads were allowed by the project default)
class EventViewSet(BaseViewSet):
model = Event
# after
from karni.api import Access, Owned, Public
class EventViewSet(BaseViewSet):
model = Event
access = Access(read=Public(), write=Owned("organizer"))
access = Unscoped(reason="...") keeps a viewset outside the access system on
purpose. With access in place, an object outside the read scope answers
404, and one that is readable but not writable answers 403. See
Access control.
DEFAULT_PERMISSION_CLASSES¶
Manual. Karni's default changed from IsAuthenticatedOrReadOnly to
IsAuthenticated. It no longer gates a BaseViewSet that declares access:
the declaration is the gate, so a permissive project default cannot open it.
It still covers plain DRF views and Unscoped viewsets, which therefore no
longer allow anonymous reads. To keep a view public, say so on that view:
Relations and expansion follow the target's read scope¶
Manual: review API clients. A writable relation field accepts only primary
keys that the requester can read through the target model's viewset. A PK
outside that scope fails exactly like a missing one. ?expand= embeds only
readable rows, and an unreadable to-one target renders as null. A client
that relied on linking or embedding rows it cannot read sees validation errors
or nulls. See Access control.
KARNI_MAX_PAGE_SIZE defaults to 100¶
Manual: review API clients. ?page_size= is capped at
KARNI_MAX_PAGE_SIZE, which defaults to 100. In 0.1 the cap was a hard-coded
500. A client asking for more silently gets 100 rows per page. Raise the
setting if you must, or page through:
Unpaginated lists need Unpaginated(...)¶
Manual. pagination_class = None on a BaseViewSet is a class-definition
error. Boot also refuses REST_FRAMEWORK["PAGE_SIZE"] = None and a non-Karni
default pagination class. Declare an unpaginated list explicitly:
# before
class CountryViewSet(BaseViewSet):
pagination_class = None
# after
from karni.api import Unpaginated
class CountryViewSet(BaseViewSet):
pagination = Unpaginated(max_rows=300, reason="fixed ISO list for a picker")
See Pagination.
Query budgets fail tests¶
Manual. Every list, retrieve, create, update and destroy action has a
query budget. Karni's pytest plugin runs tests with
KARNI_QUERY_BUDGET_MODE = "raise", so an action over budget now fails its
test with a report that names the repeated statement. Fix the N+1 first (see
Query optimisation). Raise a budget only with a
reason:
from karni.api import Budget
class ReportViewSet(BaseViewSet):
query_budget = Budget(list=25, reason="per-row signed URLs, measured")
See Query budgets.
Roles as data¶
migrate now reconciles the role tables with KARNI_ROLES (ADR 0002): the
shipped default declares staff (every permission, every row), user and
customer (their own rows), so three groups and their Role rows appear on
the next migrate, and every group that already existed gets a Role row
(slug from its name). Nothing is deleted — karni sync-roles reports roles
the declaration no longer names and leaves them. Declare your own set in
KARNI_ROLES to replace the default; HasRole(...) checks keep working, and
Can("app_label.model:action") is now available in access declarations
(it used to raise E108, now retired). Set KARNI_ROLES_AUTOSYNC = False to
keep migrate from touching the tables. See
roles as data.
Row-level scoping¶
The admin now applies the roles' grants to its rows (ADR 0002, P2): a staff
user sees, in the changelist, search, filters, autocomplete, relation
choices and the change form, only the rows their grants reach for view,
and may change or delete a row only when it is inside the matching grant's
scope. Django's model permissions still decide which screens exist. The
visible change: a staff user who holds Django permissions for a model but
no Karni grant now sees no rows of it. Give their role a grant in
KARNI_ROLES ("shop.order:*": "all", or "own" / "related:<key>") and
run karni sync-roles; the shipped staff role holds every grant, and
superusers always do. An admin that must show everything its Django
permissions admit opts out with scope_exempt = True and a
scope_exempt_reason (E210 without one); karni check lists every
exemption.
On the API, access = Granted() is new and is what karni make viewset
writes by default (it used to write Access(read=Authenticated(),
write=Authenticated())): the roles' grants for the viewset's model and the
current action, row scope included. Existing declarations are untouched. A
viewset may set scope_exempt = True with a reason too. See
Access control and
the admin's row scope.
Field-level visibility¶
class Karni: field_roles on a model hides fields per role on the API, in
?expand=, in the schema and in the admin (ADR 0002, P3). Two behaviour
changes come with it, whether or not a model declares rules:
?ordering=accepts only the fields the request's serializer reads back.BaseViewSetno longer setsordering_fields = "__all__", which let any model field — includingAPIMeta.write_onlyones — order the list (an ordering oracle over values the API never returns). A?ordering=on a field the serializer does not return is ignored, as an invalid ordering always was. A viewset that needs more setsordering_fieldsitself.search_fieldsmay not name a write-only field (karni.E131at boot): a search over a value the API never returns is a membership oracle. Remove the field fromsearch_fieldsor fromAPIMeta.write_only.
assert_crud gained a visibility check; it is skipped for resources whose
model declares no field_roles. See field visibility.
Change history¶
karni.audit is a new installed app (karni_settings() adds it): run
karni migrate once for its ChangeLog table. Nothing is recorded until a
model opts in with class Karni: audit = True. Every BaseViewSet gains a
GET /<resource>/<id>/history/ route (404, and absent from the schema,
unless the model is audited). karni check gains the audited-bulk-write
rule (K113). See change history.
Permission matrix¶
RolePermission gains an origin column (declared for every existing
row): run karni migrate once. Grants now change in the admin — Roles →
Permission matrix — or through POST api/auth/permissions/matrix/, with
guard rails and a confirmation diff; the Permission and RolePermission
admins are read-only. Two things about karni sync-roles changed with it:
a grant set in the matrix (origin = "matrix") is left alone and listed as
overridden in the report (and --json gains overridden), and a declared
grant the declaration no longer names is removed outright — it used to be
soft-deleted, which kept the unique constraint and blocked re-granting it.
A declared grant revoked in the matrix is a row with scope none; nothing
in KARNI_ROLES can declare that scope. See
permission matrix.
Import and export¶
karni.transfer is a new installed app (karni_settings() adds it) and
karni.audit gains a ChangeBatch table and a batch column: run
karni migrate once. Every BaseViewSet gains GET /<resource>/export/
and the /<resource>/import/… routes, gated by the declaration's create
or write; every Karni admin gains the export actions and the import page
(karni_transfer = False opts an admin out). openpyxl is a new core
dependency. The import and export actions are not counted against query
budgets unless query_budget names them. See
import and export.
Write interception¶
Every BaseModel save() and delete() now sends karni.signals.pre_write
before any SQL; a receiver may raise KarniWriteDeferred to stop the write
(the API answers 202, the admin shows the message). Nothing changes for a
project with no receiver. A model that overrides save() must still call
super().save() for the hook — and karni-approvals — to see the write.
See write interception.
Vetoes at the gates¶
karni.vetoes is a registry the API gate (AccessPermission) and the admin
(has_add/change/delete_permission, the actions menu, the Import page)
consult after the access declaration admitted an action. Nothing changes
for a project that registers no veto. A project that overrides
has_delete_permission or get_actions on a Karni admin and does not call
super() loses the veto check there — call super() or
self.vetoed(request, action) yourself. karni-impersonation is the first
user; see vetoes.
Architecture rules scan the project without BASE_DIR¶
karni check, pytest --pyargs karni.testing.architecture and the scaling
check used to scan only apps under settings.BASE_DIR, which a Karni
scaffold never defines — so in every generated project they scanned nothing
and passed. Discovery now falls back to the parent of the settings package
(the project root). A project that has accumulated violations will see them
on the next karni check; each names its rule and fix, and every rule has
the inline opt-out with a reason.