stapel-tools
CLI scaffold and linting tools for Stapel/Django projects.
Install
pip install git+https://github.com/usestapel/stapel-tools.git
Or as a dev dependency in your project:
pip install -e path/to/stapel-tools
Commands
stapel-create-project — interactive project wizard
stapel-create-project # full wizard
stapel-create-project my-app --type monolith # skip some wizard steps
stapel-create-project my-app \
--type monolith \
--title "My App" \
--url https://myapp.com \
--company-name "ACME" \
--company-email hello@myapp.com \
--modules auth billing # fully non-interactive
Project types: monolith (recommended), microservices, minimal (no Docker, SQLite).
stapel-new-service — add a service to an existing project
stapel-new-service auth
stapel-new-service auth --title "Auth Service" --prefix iron-
stapel-new-service blog --celery
stapel-new-service blog --dry-run
stapel-new-library — scaffold a standalone stapel-* package repo
For contributing a new reusable package to the framework (or building your
own to the same standard). Materializes the Stapel library standard: flat
layout, STAPEL_<NAME> settings namespace, comm surface with JSON schemas,
serializer seams, MODULE.md, community files, CI with the codecov
ratchet/floor policy, ruff git hooks. The generated repo's own test suite
is green out of the box.
stapel-new-library search # L2 service module
stapel-new-library attributes --kind library # L1 importable lib
stapel-new-library support-chat --title "Support chat" --dir ~/Projects
Kinds: module (default — Django app with models/views/comm surface;
modules never import each other) and library (importable package without
service identity, like stapel-attributes).
stapel-new-react-lib — scaffold a headless @stapel/<module>-react pair
The frontend counterpart to stapel-new-library: materializes a headless
React/TS pair from the auth-react etalon (frontend-standard §9,
frontend-core-architecture §4 checklist) into a stapel-react monorepo
(packages/<module>-react). Emits the layer stack api → model → flows → headless → i18n, namespaced query keys, the create<Module>Runtime /
<Module>Provider wiring, a module-scoped i18n bundle + errors map, a vitest
smoke suite, and full package hygiene (ESM, sideEffects:false,
isolatedDeclarations, src-in-tarball, size-limit, manifest/llms.txt
exports). The createFlowMachine primitive is imported from @stapel/core,
never copied.
stapel-new-react-lib notifications # → @stapel/notifications-react
stapel-new-react-lib billing --title "Billing" # backend defaults to stapel-billing
stapel-new-react-lib profiles --react-dir ~/Projects/stapel/stapel-react
Fork-free: the generated package.json wires the monorepo's env-parametrized
codegen drivers (scripts/gen-{flows,errors,manifest}.mjs) via env knobs
rather than duplicating them. Each pair owns three per-package drift gates
(gen:{flows,errors,manifest}:check); gen:api is core-owned. After
scaffolding: pnpm install && pnpm --filter @stapel/<module>-react gen build lint test.
stapel-new-module — add a Django app to a service
cd svc-auth/
stapel-new-module users
stapel-new-module billing --title "Billing Plans"
stapel-remove-service — remove a service
stapel-remove-service auth
stapel-remove-service auth --prefix iron- --yes
stapel-remove-service auth --dry-run
stapel-codegen — emit the frontend codegen source artifacts
Runs inside a configured all-modules Django instance (e.g. stapel-example-monolith on sqlite) and emits the three language-agnostic backend artifacts the frontend TS client is generated from (docs/done/flow-system-v1.md §0.1):
schema.json— the unified drf-spectacular OpenAPI for every installed module (same document the instance serves at/schema/, produced offline via thespectacularmanagement command — no server, byte-stable).flows.json— thegenerate_flow_docsmachine artifact.errors.json— thegenerate_error_keysmachine artifact: everyerror.<status>.<name>key the instance can raise, with its HTTP status,{param}slots, remediation hint and canonical English text (stapel-core'sstapel_core/django/api/errors.py).
All three use a byte-stable JSON encoding, so regenerating without a code change yields zero diff — the invariant a drift gate rests on.
DJANGO_ENV=local DJANGO_SETTINGS_MODULE=config.settings.codegen \
python -m stapel_tools.codegen --out codegen/generated
In stapel-example-monolith this is wrapped as make codegen (regenerate) and
make codegen-check (drift gate). The generated schema.json then feeds
stapel-react's pnpm gen:api (openapi-typescript → typed @stapel/core API).
stapel-analytics-report — typed-analytics summary report
Generates the analytics/flow report (frontend-guardrails §3.3) across a pnpm
workspace of @stapel/*-react pairs and/or a customer app, from static
generated artifacts (events.json/manifest.events, backend flows.json,
manifest.machines) plus a syntactic scan of TS/TSX call sites
(tracked()/trackedSubmit()/track(), data-analytics="flow"/"none"
markers, eslint-disable … -- description). Two slices are always separated:
app (customer code) and library (@stapel/* pairs).
# machine-readable report.json to stdout
stapel-analytics-report path/to/stapel-react
# report.json + report.md + report.html into a dir, with canonical backend prose
stapel-analytics-report path/to/stapel-react \
--backend-flows path/to/monolith/codegen/generated/flows.json \
--out ./analytics-report
stapel-analytics-report ./my-app --package packages/web --format md
Outputs report.json (for the Studio project passport), report.md, and a
self-contained report.html. Per event: description, typed props, emit sites
(file:line + component), linked flow. The flow report joins backend flows with
frontend coverage and renders a [gated: <ENV>] badge (from gated_by, task
G6 — absent means always-on). --capabilities is reserved (§3.4 env-aware).
stapel-catalog — module-catalog aggregator
Aggregates every module's docs/capabilities.json (the fourth contract
artifact) into a catalog for stack advisors and CTO prompts. Emits
catalog.json (the full machine aggregate — every source document verbatim
plus roll-up totals and curated recipes), catalog.md (a compact,
prompt-ready projection: header roll-up, then per module a provides
one-liner, an axis table key | default | ops gated, extension-point names and
requires) and llms.txt (the fleet's own root index, badge-canon §3 p.5:
one line per module — its provides one-liner and a link to that module's
own docs/llms.txt, the fifth contract artifact from stapel-llms-txt
below). llms.txt is what an agent that does not yet know which module it
needs should read FIRST — one small file instead of catalog.md in full or
every modular docs/llms.txt. A module counts as "described" only once its
own docs/llms.txt actually exists on disk (or in the wheel, under
--from-installed) — a module without one yet is listed by name under "Not
yet described", loudly, never silently dropped. All three outputs are
deterministic (modules sorted by name, axes by key, no timestamps), so they
are stable enough to commit into other repos' system prompts.
# scan a workspace (repos without capabilities.json are skipped with a warning)
stapel-catalog --workspace ~/Projects/stapel --out-dir ./catalog
# explicit module repos (or direct capabilities.json paths)
stapel-catalog ../stapel-auth ../stapel-billing --out-dir ./catalog
# add curated composite recipes (marketplace = N modules) as their own section
stapel-catalog --workspace ~/Projects/stapel --recipes recipes.yaml --out-dir ./catalog
# the environment IS the source: every installed stapel-* wheel, no checkout
stapel-catalog --from-installed --index -o ./stack_index.json
Freshness is a property, not a chore
Two rules keep a catalog from becoming a lie:
--from-installedsources the aggregate from the current environment — every installedstapel-*distribution that shipsdocs/capabilities.jsonin its wheel (modules shipcapabilities.json,flows.json,errors.json,CONFIG.MDand — once a module adoptsstapel-llms-txt—llms.txtas package-data, so an installed-sourced index is not a degraded one; the rootllms.txt's "described" count comes from the same wheel-shipped files). The result is a pure function of the lockfile: it cannot drift away from the code the product actually runs, no matter whose discipline lapses.--checkis the drift gate for a committed catalog: it rebuilds in memory and compares byte-for-byte against the artifact on disk, exiting non-zero on any mismatch. A committed aggregate without this gate in CI is a snapshot that will go stale silently — that is not a hypothesis, it is how both of this project's aggregates rotted. Wire it next tomake contract-check.
stapel-tools deliberately commits no catalog of its own: the artifact
belongs in the repo that consumes it, behind that repo's --check gate.
Recipes are curated, not derived — a minimal, dependency-free YAML subset:
recipes:
- name: marketplace
summary: Two-sided marketplace — accounts, profiles and listings.
modules: [stapel-auth, stapel-profiles, stapel-listings]
notes: reviews live in a separate target-generic module
stapel-docs — bilingual API/flow documentation
Renders docs/api.en.md + docs/api.ru.md at a generated project's root
from artifacts it already ships: schema.json (endpoints, DTO fields —
descriptions sourced straight from backend docstrings, R004 canon),
flows.json (flow/step user stories) and errors.json (every
error.<status>.<name> code). Where a module has already shipped a
Russian translation (translations/flows.ru.json /
translations/errors.ru.json — the stapel-translate precedent), the
Russian doc uses it verbatim; where it hasn't (yet), the English text is
shown with an honest (en) marker instead of a fabricated translation.
Supports three schema layouts: a monolith's codegen/generated/schema.json
aggregate (grouped back into per-module sections by path prefix), a
per-service/vendored-lib <service>/docs/schema.json, or a literal
<mod>/api/v1/schema.json.
stapel-docs . # write docs/api.en.md + docs/api.ru.md
stapel-docs . --check # drift gate (pre-commit's api-docs-check)
A project with no schema.json generated yet is a graceful no-op, exit 0.
stapel-gen-client — project-owned typed API-client override
Tier 2 of the answer to "if we override a module's backend, its frontend
pair must handle that" (docs/pending/profile-fields.md "Owner
addendum"): regenerates a typed TS client from the PROJECT'S OWN
schema.json (not the library's upstream one) into
frontend/src/api/generated-override/<module>/schema.ts — reusing
openapi-typescript (the same generation core stapel-react's own
scripts/gen-api.mjs uses) via npx, not a reimplementation. A pair's api
layer can point at this file instead of its own bundled types once a host
has overridden that module.
Only does anything once the project actually HAS an override — a
non-empty STAPEL_SWAP = {...} anywhere in the project, or an explicit
stapel.override.json with "clientOverride": true:
stapel-gen-client . # no-op without an override signal
stapel-gen-client . --check # drift gate (pre-commit's gen-client-check)
stapel-gen-client . --force # generate even without a detected override
stapel-verify + stapel-lint.toml — the arsenal, and the switch a legacy project needs
stapel-verify . # every composed linter, human table
stapel-verify . --json # machine output (profile + per-linter)
stapel-verify . --run-native # also execute the profile's native gates
Every linter stapel-verify composes encodes a stapel contract, which is
a fair gate only against a project stapel generated. An imported legacy tree
trips hundreds of them on its first commit, none describing a defect — and a
permanently red gate is a gate that is off, minus the record.
stapel-lint.toml at the project root is that record. One mode per
surface (python, frontend, docs, i18n, deploy):
| mode | meaning |
|---|---|
stapel |
run the stapel arsenal for the surface (the default — an absent file means this everywhere, so a generated project is unaffected) |
native |
the project's own linter is the gate; command is mandatory |
off |
the surface is not gated; reason is mandatory |
# stapel-lint.toml
[surface.python]
mode = "native"
command = "ruff check ."
[surface.frontend]
mode = "native"
command = "npm run lint"
[surface.docs]
mode = "off"
reason = "reference docs live in Confluence, not the repo"
[waivers]
SWAP002 = "presenters are the app's own; see ADR-7"
Three rules keep it from becoming a silent kill switch, all borrowed from
stapel_core.django.check_guard's waiver canon:
offwithout areason,nativewithout acommand, or a[waivers]entry with an empty reason is an error in the profile — the gate stops and names the line, it never degrades into "run nothing";- every non-
stapelsurface still emits a report line carrying its mode and its reason, in the table and in--json, so what was not checked is visible next to what was — including a waiver that matched nothing; - a
nativecommand is a shell command out of the tree under inspection, so it does not run without--run-native. Studio's sandbox passes it (it already runs the project's ownmake controlsthere); a bare local run of an untrusted checkout does not.
A native gate's exit code is the verdict and its output tail is the
evidence — stapel deliberately does not parse another tool's format, and the
coder loop never needed it to: it already reads make controls tails the
same way.
stapel-lint — project-specific static linter
stapel-lint # scan current directory
stapel-lint svc-auth/ # scan specific service
stapel-lint --stats # show per-rule counts
stapel-lint --ignore R002 # skip a rule
Rules: R001 bare Response(), R002 serializers.ValidationError, R003 undocumented @action,
R004 @dataclass without docstring, R005 hardcoded error string, R006 StapelResponse(dict).
Suppress per-line: # noqa: R001
stapel-migration-lint — expand/contract gate for Django migrations
stapel-migration-lint # lint every app under .
stapel-migration-lint svc-app/ --json # machine output (+watermarks/floors)
stapel-migration-lint . --base-sha <sha> # verify against the previous release
stapel-migration-lint . --strict # warnings become errors
Static (AST) analysis — no Django settings needed, so it runs on customer
project checkouts at release cut AND on stapel-* module repos in CI.
Rules: MIG001 destructive op (RemoveField/DeleteModel/Rename*/narrowing
AlterField) requires a phase marker on the file (see below); MIG002 with
--base-sha the destroyed target must not be referenced by the app's code at
the previous release's sha; MIG003 RunPython/RunSQL without a reverse
requires # stapel: irreversible (lowers the app's reversible_floor in
release.json); MIG004 NOT NULL AddField without default/db_default on an
existing model (breaks N-1 rollback); MIG005 both phase markers on one file
(contradictory claims — neither licenses the destructive op).
Two phase markers, two different claims — pick one, never both:
| marker | claim | machine-checked |
|---|---|---|
# stapel: contract-phase |
the code stopped using the target one release AGO (expand rN → contract rN+1); the target is already dead code when this migration runs | nothing beyond the marker — pure assertion |
# stapel: cutover-phase |
deletion-driven cutover: THIS migration carries the data out and then removes the target, in one release | a data-carrying RunPython (forward code is not RunPython.noop) must appear before the destructive op in the same operations list |
contract-phase is the default shape and the only one safe under a rolling
deploy. cutover-phase is safe only where a deployment never runs old and
new code against the same schema at once — a stop-the-world deploy
(docker compose up -d, what this fleet does); under rolling/blue-green the
old process would keep writing to a table already drained and dropped. It is
not a synonym for contract-phase and not a general licence to destroy: a
cutover-phase file whose destructive op has no data path before it is still
MIG001. What stays the author's assertion: that the RunPython carries THIS
target's rows (the callable's body is out of AST reach) and that the deploy
is stop-the-world. RunSQL does not count as the data path — from the AST a
copying INSERT…SELECT and a destructive DDL string look the same.
stapel-api-lint — HTTP surface versioning gate (§60)
stapel-api-lint . # diff against the newest reachable v<semver> tag
stapel-api-lint . --base-ref v0.6.2 # diff against a specific release
stapel-api-lint . --json # machine output
stapel-api-lint . --strict # warnings (SCHEMA001) become errors
Diffs the committed docs/schema.json at a baseline git ref against the one
in the working tree and classifies the diff as additive or breaking, per
docs/pending/api-versioning.md §3. The contract pipeline's drift gate
already tells you the schema changed; nothing until now decided whether the
change breaks a caller — the author did, from memory, at release time.
Canon: /<mod>/api/v1/…, the version segment right after api/, one counter
per module. Breaking = an endpoint removed/renamed; a field removed,
renamed or retyped; a required-status flip in the direction that hurts
(optional→required in a REQUEST, required→optional in a RESPONSE — the
opposite flip on each side is a strengthening and stays additive); a response
status code disappearing; an operation's auth contract changing; an enum value
removed (always), or added to an enum marked x-stapel-closed-enum. Enums are
OPEN by default: adding a value is additive and clients must ignore what they
do not know.
| rule | level | what it holds |
|---|---|---|
| API001 | error | a breaking diff must be carried by BOTH a sufficient version bump (pre-1.0 minor, post-1.0 major — library-standard §1.4) AND a docs/UPGRADE.json record of kind: "api_change" for the new version |
| API002 | error | a breaking change may not land in place: vN+1 must appear beside the frozen vN, and urls.py must still mount urls_vN.py — a version in the schema but not in the URLconf is documented, not served |
| API003 | error | a vN present at the baseline may not disappear before its x-stapel-sunset date (or with no sunset ever declared — a window that never opened cannot have closed) |
| SCHEMA001 | warning | docs/schema.json's info block must follow the fleet convention (below): the drf defaults info.version: "0.0.0" + empty info.title are clean; a lib that writes its package version there, or any other info shape, is flagged |
The info.version convention. info.version is not the contract's
version. Every lib leaves SPECTACULAR_SETTINGS unset in its
_codegen_settings.py so its emitted triad stays byte-identical to the
monolith aggregate's slice — and the aggregate runs on drf defaults, which
emit info.version: "0.0.0" with an empty info.title. That pair is the
correct state and SCHEMA001 is silent on it. The version a consumer pins
lives in pyproject.toml and in the pair's manifest.json
(backend.contract). SCHEMA001 therefore fires on divergence from the
convention — most usefully when a lib passes package=/version= to
get_spectacular_settings and quietly stops matching the aggregate.
No baseline (no git repo, no v<semver> tag, or no schema at that ref) means
API001-003 do not run, and the linter says so in a note rather than inventing
a "before". A repo with no docs/schema.json has no HTTP contract to check
and is silent. Composed into stapel-verify, which forwards its --base-sha
as the baseline ref.
stapel-authz-lint — the "credentials verified, authorization never asked" gate
stapel-authz-lint . # lint the project in .
stapel-authz-lint . --json # machine output
stapel-authz-lint . --strict # AUTHZ003 (warning) becomes an error
stapel-authz-lint . --probe # AUTHZ007's runtime half (needs DJANGO_SETTINGS_MODULE)
stapel-authz-lint . --probe --cookie-name meettoday_jwt
On 2026-08-24 stapel-core shipped five security releases (0.38.0-0.43.0) for
one defect class wearing five costumes: every place where the code proved
who you are and then never asked what you may have. JWTCookieLoginView
is the admin login view, but named no authentication_form, so Django used
the plain AuthenticationForm — is_active and nothing else — and
form_valid() then called login(), create_tokens() and
set_jwt_cookies() with no staff check on that path. Any active account's own
password minted a fleet-wide JWT pair. The file did read is_staff three
times; all three sat in dispatch()'s already-authenticated branch, so every
file-level grep called it safe.
Every one of the five was found by a human reading code. None was found by a test, because each component's suite was green: the login view did log people in, the refresh endpoint did return a token, the blacklist did blacklist. The defect lived in what was not written, and nothing in the fleet read for absence.
| rule | level | what it holds |
|---|---|---|
| AUTHZ001 | error | a LoginView subclass defining form_valid must either name an Admin* authentication form (class attribute or get_form_class) or read authorization (is_staff/is_superuser/a permission call) inside form_valid's own body — a gate in a sibling method is not on the minting path |
| AUTHZ002 | error | create_tokens/set_jwt_cookies in a function that also calls login() or form.get_user() needs an authorization read earlier in the same function. A token outlives the request that minted it, so a check after the mint is a log line |
| AUTHZ003 | warning | an explicit refresh_access_token(x, None) re-mints from the presented token's own claims (up to JWT_REFRESH_TOKEN_LIFETIME, 7 days), resurrecting revoked flags. Since core 0.39.0 the django-layer default is the database loader, so the bare call is the safe form and only the typed-out None is flagged — it must be a decision, not an omission |
| AUTHZ004 | error | a get_user() method override returning objects.get() needs an is_active/user_can_authenticate check. get_user resolves request.user on every request after the one that authenticated, so dropping it lets a deactivated account keep a live session for the life of the session cookie |
| AUTHZ005 | error | a revocation/blacklist entry read or written through django.core.cache.cache. Django builds the real key from this deployment's KEY_PREFIX, so auth wrote auth:1:jwt_blacklist:<jti> while profiles read stapel_profiles:1:jwt_blacklist:<jti> — use stapel_core.core.revocation_store.revocation_cache() |
| AUTHZ006 | error | a view or @action reachable without authentication (AllowAny, an empty permission_classes, IsAuthenticatedOrReadOnly on a read) that reads a filter-bypassing manager (all_objects, _base_manager, with_deleted(), all_with_deleted(), only_deleted()) and answers with identity or moderation state (owner/owner_id/user_id/created_by, moderation_*, or the status of a row the default filter hides). Each half is ordinary; together they are an enumeration oracle |
| AUTHZ007 | error | a host-defined authentication class listed in REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"] whose authenticate() raises an authentication failure — directly, or through a same-module helper it does not catch. A default authenticator runs before EVERY view, including an AllowAny one, so a raise is a 401 no permission class gets to override. Return None for a credential you cannot use (stapel-core's own JWTCookieAuthentication does) and log the reason |
Suppress with # noqa: AUTHZ00N on the reported line; a written reason after
the code on the same line is read fine, and is the point. AUTHZ007 has a second,
class-scoped escape — # stapel: strict-authenticator in the class body — for
an authenticator that is meant to be a verdict; write the reason next to it.
AUTHZ006 — the second defect this family was paid for. On 2026-08-28
stapel-listings shipped 0.8.0 for a hole none of AUTHZ001-005 could see,
because nothing on the path minted a credential or overrode get_user:
@action(detail=True, methods=["get"], permission_classes=[AllowAny])
def status(self, request, pk=None):
listing = Listing.all_objects.get(pk=pk) # the UNFILTERED manager
return StapelResponse(ListingStatusSerializer(listing)) # owner_id, moderation_status, …
Live on a production stand, unauthenticated, over sequential ids:
GET /listings/api/v1/listings/10/status/
→ 200 {"status":"draft","moderation_status":"pending","is_deleted":true,
"owner_id":"720a67e2-…"}
An anonymous walk of the ids harvested the owner's user UUID and the moderation verdict for every listing in the deployment — other people's drafts, rejected and soft-deleted rows included.
The rule keys on the missing decision, not on the permission and not on the
manager, because the shipped fix changed neither: 0.8.0 is still AllowAny,
still all_objects, still the same serializer in the schema. Deleting the
endpoint or locking it to services was the wrong fix and was tried first — a
real browser client (@stapel/listings-react's ListingDetail) needs the
probe to tell "removed" from "never existed", since GET /{pk}/ 404s
identically for a deleted row and a made-up id. Keep the capability, remove
the disclosure: full view for a fleet service and for the row's own owner,
one boolean for everyone else — a signed-in stranger included, or the oracle
costs an attacker one free account. A branch on ownership or permission in the
handler (or a named predicate in the same module) is what silences the rule,
because that branch is the fix.
AUTHZ007 — the inverted member: authentication answering for
authorization. Every other rule in this family is about a check that is
missing. This one is about a check that happens too early and too widely. On
2026-09-07 meettoday (MR !16) had, in accounts/auth.py, a class wired as the
deployment's only DEFAULT_AUTHENTICATION_CLASSES entry:
def authenticate(self, request):
token = get_request_token(request) # the httponly stapel_jwt cookie FIRST
if token is None:
return None
payload = decode_token(token, expected_type="access") # raises on expired/invalid
...
DRF runs a default authenticator on every request, before permissions.
GET /auth/api/v1/token/refresh/ is AllowAny — its whole job is to be
reachable with a dead access token — but a raising authenticator answers 401
before any view runs, so the one call that repairs an expired session was the
one call an expired session could not make. And the window is guaranteed, not
rare: the browser keeps sending the access cookie for seconds after the token
inside it dies, because the cookie's Max-Age is counted from when the
browser received it and the token's exp from when the server minted it.
Every continuously signed-in user was thrown to the login screen once an hour
and let back in three seconds later, when the credential rotted enough for the
browser to drop the cookie.
DRF's own convention leaves room for both — return None when the request
carries no credential of this kind, raise when it carries one that is invalid —
and that room is safe for an Authorization header, which a caller attaches
deliberately. It is not safe for a cookie, which the browser attaches to
every request whether the caller meant to assert anything or not, and it is
never safe for a class in the default list, which runs on views its author
has never seen. So stapel-core's JWTCookieAuthentication returns None — "no
opinion, ask the next authenticator, then ask the permissions" — for a
blacklisted, expired or invalid token, and that is the contract this rule
holds. Nothing is loosened by returning None: an unusable token granted
nothing either way, and a protected view still answers 401, through
IsAuthenticated plus authenticate_header() instead of through an
exception.
So the rule reads the settings for which classes are the fleet-wide default,
keeps only the ones the host defines (a stapel_* or vendor dotted path
never enters the index — the class that defines the contract is never scanned
against it), and reads authenticate() for a raise. Keeping the raising helper
is fine and is what the fix did: try/except AuthenticationFailed → log →
return None silences the rule, because that shape is the repair.
stapel-authz-lint backend --probe # DJANGO_SETTINGS_MODULE=config.settings.local
--probe is the runtime half, and it exists because the static half cannot
resolve a base class: it hands every configured authenticator a request
carrying a garbage stapel_jwt cookie and asserts authenticate() returns
None. Four outcomes, and only two of them fail: ok, raised (AUTHZ007
observed rather than inferred), returned (authenticated somebody off a
garbage token — worse than the rule it was written for) and inconclusive
(the class could not be built, or blew up on something that is not an
authentication verdict — never counted as a pass). It is opt-in and is not
composed into stapel-verify, which stays static and boots nothing.
What these rules cannot catch, stated plainly — a rule that pretends to cover more than it does is worse than one whose edge is written down:
- Recognition is syntactic. There is no import resolution, no cross-module
call graph, no dataflow. An authorization helper imported from another
module reads as absence; only same-module predicates are followed (one
fixed-point hop, so
form_valid→has_admin_access→is_staffpasses). - AUTHZ001 keys on a base class whose name ends in
LoginView. A credential view built onAPIView,Viewor a bespoke base is invisible to it — AUTHZ002 is the net underneath, and it only fires wherelogin()orform.get_user()appears. - AUTHZ002 does not fire on a mint with no credential call in the same function: a DRF flow that mints for an account some service already verified (password reset, OTP promotion — ~15 such call sites in stapel-auth) is the intended grant, not this class. The cost of that precision is that a genuine bypass split across two functions is not seen.
- "Before the mint" is source order, not execution order. A read inside a branch that never runs, or a helper whose refusal is a logged warning rather than a return, satisfies the rule.
- AUTHZ003 sees a literal
None, never a value.loader or Noneand a variable that happens to beNoneat runtime both pass. - AUTHZ004 covers
objects.get().objects.filter(...).first(), a manager method, a cache lookup or a raw query in aget_useroverride are not matched. - AUTHZ005 decides "is this about revocation?" from the enclosing function
name, the enclosing class (name and class-level constants), and identifiers
and string literals inside the call itself. Deliberately not from prose:
a docstring mentioning the blacklist next to an unrelated
cache.clear()is how a rule earns its first blanket suppression. A revocation key built from a constant imported from elsewhere and named nothing revocation-ish is missed. - AUTHZ006 reads statically declared response fields: serializer class
attributes,
Meta.fields/read_only_fieldsstring lists, the literal dict a hand-writtento_representationreturns, and a literal dict built in the handler itself. It does not see a serializer chosen at runtime, a response assembled from**kwargsor a comprehension,fields = "__all__"(no model resolution, and guessing would be its first false positive), or a disclosing column aliased to an innocuous name. Silence from AUTHZ006 is not proof a response is clean, and the message says so on every hit. - AUTHZ006's serializer index is keyed by bare class name across the project — no import resolution, so two same-named serializers in different apps merge. Linting a single file indexes that file's own directory; linting a project indexes the whole tree.
- AUTHZ006 reads permissions only from a list literal —
permission_classes = PUBLIC_PERMSor a computed list is "not declared", and with nothing declared anywhere the project'sDEFAULT_PERMISSION_CLASSESdecides, which is a setting this linter does not resolve. Undeclared means silent, not assumed-open. - AUTHZ006 treats any ownership/permission decision in the handler as the fix,
without checking that the disclosing branch is the guarded one. A handler
that branches on
owner_idand then returns the full serializer from both arms passes. Source-order and branch-reachability are outside every rule in this family. - AUTHZ007 reads the class the settings name. An
authenticate()inherited from a base class is not resolved (no base-class resolution anywhere in this family), a raise out of a helper in another module is invisible, and a per-viewauthentication_classesis deliberately not read — it is scoped to views whose permissions the same author wrote.--probeis what covers the first two, against a booted deployment. - AUTHZ007 matches a declaration to a class by dotted module path, so two
same-named authenticators in different apps stay distinct. A settings string
rooted at a
sys.pathentry the linter cannot see resolves to no file, and then the declaration widens to the bare class name — a wider match rather than a rule that silently does not run.
Composed into stapel-verify (surface python), so a project picks the gate
up on its next stapel-tools upgrade with nothing to regenerate.
stapel-sibling-lint — the suite imports what nothing declares
stapel-sibling-lint . # lint the repo in .
stapel-sibling-lint . --json # machine output
stapel-sibling-lint . --strict # SIB004/SIB005 (warnings) become errors
On 2026-08-24 three releases died of one defect within hours of each other —
stapel-chat 0.5.0, stapel-core 0.44.0, stapel-notifications 0.17.0. All green
on a laptop, all red on the runner, all at test setup with
ModuleNotFoundError, all re-released the next morning with no product change.
The mechanism: this fleet develops in ONE virtualenv that has every sibling
installed, so a test may import stapel_moderation while pyproject.toml
never says the suite needs it. Nothing in the repo disagrees — pytest is happy,
ruff is happy, review is happy, because a reader cannot see an absence. The
runner, which installs only what is declared, is the first machine to find out.
| rule | level | what it holds |
|---|---|---|
| SIB001 | error | a suite file imports a top-level stapel_* package that pyproject declares in neither dependencies nor the test extra. Imports are collected at any depth — function bodies, fixtures, try blocks — because that is where the ones that shipped were hiding |
| SIB002 | error | a suite file names an undeclared stapel_* package in an INSTALLED_APPS list. There is no import token on that line and it imports all the same: override_settings fires setting_changed, Django reloads the app registry, and every label is loaded for real (stapel-core 0.44.0, verbatim) |
| SIB003 | error | pytest.importorskip("stapel_*") — or the requires/installed seam from tests/siblings.py — naming an undeclared sibling. A skip is not a declaration; it is a decision to run nothing, taken silently, on every machine that lacks the package — which on CI is every machine |
| SIB004 | warning | a declared sibling behind a skip guard (importorskip, or try: import … except ImportError: skip) in a repo whose workflows never set STAPEL_TEST_STRICT_SIBLINGS=1. On CI the extra IS installed, so a skip means the install step did not do what the workflow says, and the run stays green having asserted nothing |
| SIB005 | warning | the test extra declares a stapel-* sibling no suite file imports or names (a requires("stapel_x") counts — the seam speaks in strings) — the other direction, so the extra cannot rot into a wish list |
| SIB006 | error | a committed docs/errors.json entry whose owner is neither this module nor stapel_core. The codegen reads the error registry of the interpreter it runs in, so a workspace virtualenv holding every sibling emits keys this package does not own while CI emits fewer: the artifact then describes the machine that generated it, and its drift gate flips depending on who ran it last |
The STAPEL_TEST_STRICT_SIBLINGS contract (what SIB004 asks for). Every
stapel library's suite honours one environment variable: a DECLARED sibling
that is not installed is a FAILURE, never a skip. Unset — a laptop, a fork,
someone who ran pip install -e . — a missing sibling skips with a named
reason. Set (CI, always), it fails, because CI installed .[test] two steps
earlier and a skip there is the install step lying. The seam is three names in
tests/siblings.py (STRICT, installed(module), requires(module, …)), and
stapel-new-library now scaffolds it along with the test extra, the CI step
that installs it and the flag on the pytest step. The linter never runs your
suite, so it checks the two things it can see: that the declaration exists, and
that a skip-guarded suite is not shipped with CI silent about strictness.
Suppress with # noqa: SIB00N on the reported line, and write the reason.
What this rule cannot catch, stated plainly: it is not a dependency
resolver. A module maps to a distribution by the fleet's convention
(stapel_foo → stapel-foo); a distribution shipping a top-level module under
another name reads as undeclared. It does not follow transitive dependencies
either, on purpose — transitivity is not a contract, and "it worked because
something else pulled it in" is what this class is made of. The one expansion
it performs is a self-referential extra (stapel-notifications[realtime]
inside notifications' own test extra), because that is this file talking
about this file.
Composed into stapel-verify (surface python).
stapel-adoption-lint — honesty gate for stapel-module adoption
stapel-adoption-lint . # lint the project in .
stapel-adoption-lint proj/ --json # machine output
stapel-adoption-lint proj/ --strict # warnings become errors
stapel-adoption-lint proj/ --workspace ~/ws # extra root for sibling module repos
Catches modules "adopted" on paper but not in fact (a dependency without a
mount, a route re-implemented over one the module ships, a done migration off
main). ADO001 (error) a module installed (requirements/INSTALLED_APPS)
and shipping a urlconf but not mounted in ROOT_URLCONF — declare intentional
headless use with # stapel: headless <mod>; library-only modules are exempt.
ADO002 (error) a project-owned urlpattern whose route (params normalized, so
<int:pk> ≡ {id}) duplicates a path in an installed module's
docs/schema.json. ADO003 (warning) STAPEL-MIGRATION.md records done work
but the branch is not main/master nor merged into it. ADO004 (warning) a
requirements pin never imported anywhere (dead pin, e.g. PyJWT→jwt);
stapel modules, settings-configured packages, and an entry-point-only
runtime/tooling allowlist are exempt. ADO005 (error) an installed gdpr data
owner (a library shipping schemas/consumes/gdpr.erasure.requested.json)
that cannot answer an erasure: no consume_actions process in the service's
deploy (docker-compose*.yml, or the <svc>.yml fragment next to
services.conf), or the owner name the library declares in its
erasure.py/gdpr.py (OWNER/GDPR_OWNER — stapel-cdn answers to
media, stapel-profiles to profile) missing from
STAPEL_GDPR["DATA_OWNERS"] on the gdpr host, or listed there without the
subject types the library claims. Both halves skip with a stderr note when
their input is not discoverable.
stapel-surface-lint — pre-merge gate against reinventing what the fleet ships
stapel-surface-lint . # lint the project in .
stapel-surface-lint proj/ --json # machine output
stapel-surface-lint proj/ --workspace ~/ws # extra root for sibling module repos
stapel-surface-lint proj/ --no-installed # workspace checkouts only
Reads the surface section of every capabilities.json the environment and the
workspace expose (installed distributions first — the index is a function of the
lockfile) and fails the branch that rebuilds one of its entries. SUR001 (error) a
BasePermission subclass declared under a name an installed module already
publishes as a permission_class — matched on the published name, so a
product's own domain permissions stay quiet. SUR002 (error) a symbol listed in
instead_of sits in permission_classes while its published replacement is used
nowhere in the project; one finding per displaced symbol, never per call site.
SUR003 (error) a gate_function imported and never mentioned again — re-export
hubs, __all__, TYPE_CHECKING and any value reference are cleared first.
SUR004 (error) a capability_field with consumer: frontend that the -react
package reads nowhere outside its generated OpenAPI types; reported only to the
publishing module and to the consuming package. Composed into stapel-verify.
Silent, with a note, in an environment whose installed modules ship no
docs/capabilities.json yet.
Suppress SUR001-SUR003 with # noqa: SUR00N on the reported line, the shared
grammar (below) — a bare # noqa suppresses all of them. SUR004 reports
against the consuming package's package.json, which has no comment token to
put a noqa in; record that decision as a test pinning the choice instead.
Until 0.64.0 this linter read no noqa comment at all: a client fleet's
host carried # noqa: SUR002 on a permission_classes line for two years,
and the marker suppressed nothing.
The shared # noqa grammar, and stapel-escape-lint — is this escape doing anything?
Every linter above that reads a # noqa comment (R/AUTHZ/SIB/CFG/MIG/SWAP/DOC/
URL/SUR/NGX/EADDR/FED/SH — see each section's own "Suppress with" line) parses
the SAME grammar now, from one module (stapel_tools.escape): # noqa alone
is a blanket suppression; # noqa: RULE[, RULE ...] names one or more rule
ids, comma- or semicolon-separated, case-insensitive, and a written reason
after the first rule id of a chunk is read fine
(# noqa: AUTHZ001 - storefront login, not an admin view). A handful of
linters carry a SECOND, family-specific escape on top of this one —
# stapel: strict-authenticator (AUTHZ007), # stapel: env-address-ok
(env-address-lint), CFG007's rule that only a NAMED noqa counts, never a
blanket one — and those are untouched: this is only the common grammar
underneath.
stapel-escape-lint . # ESC001 only (no arsenal to cross-check against)
stapel-escape-lint . --json # machine output
Before 0.64.0 that shared grammar did not exist: every linter that read noqa
carried its own copy of the same dozen lines, and stapel-surface-lint (SUR)
read no noqa comment AT ALL — so a marker naming a SUR rule was silently
inert, and looked exactly like one that worked. A client fleet's host
(svc-agent) carried # noqa: SUR002 for two years on that shape.
| rule | level | what it holds |
|---|---|---|
| ESC001 | error | a # noqa: <RULE> names a rule id no linter in this stapel-tools version knows at all (typo, retired rule, or a foreign code like ruff's own F401 — never touched, since this only looks at markers already shaped like a stapel rule id), OR names a rule id a linter DOES know but never reads a noqa marker on that construct at all (most of ADO/API/IDX/PO/EXP, plus specific non-noqa rules inside otherwise-noqa-aware families — CFG002-CFG005, MIG001-MIG003/MIG005, SIB006, SUR004). Either way: this marker suppresses nothing |
| ESC002 | warning | a # noqa: <RULE> names a rule that IS noqa-aware, but the rule did not fire on that exact line this run — checked by re-running the composed arsenal with the shared grammar disabled and diffing against the markers found. Either the code no longer trips the rule (drop the marker) or the marker sits on the wrong line (it never suppressed what its author thinks it does) |
stapel_tools.escape.RULE_REGISTRY is the table both rules read: every rule
id this version ships, which linter owns it, and whether that linter's own
code actually reads a noqa marker there. ESC002 needs the REST of the
composed arsenal's raw (pre-suppression) findings, so it only runs the full
check inside stapel-verify — composed there LAST, since it closes over
every other composed linter's callable; the standalone CLI above runs ESC001
only and says so in a note.
stapel-swap-lint — the anti-lock-in indirection gate
stapel-swap-lint . # lint the project in .
stapel-swap-lint proj/ --json # machine output
SWAP001 (error) a direct import — or instantiation — of a class registered as
the default= of a get_model() / get_presenter() call somewhere in the
tree: one stray import silently defeats a host's config-swap for that call
site, with no error, just an override that never takes effect. SWAP002 (error)
a views.py filling in a DTO imported from a sibling dto.py by hand instead
of going through a presenter.
SWAP003 (error) a hardcoded dotted path into another top-level package,
resolved at runtime — import_string("other_pkg.validators.validate"),
importlib.import_module("other_pkg.thing"), apps.get_model("other_app", "Model"), apps.is_installed("other_app"), find_spec("other_pkg"),
getattr(other_pkg_module, "symbol"). Prototype: stapel-workspaces 0.19.0,
which asked Django's app registry whether stapel_profiles ran in this
process and then resolved stapel_profiles.validators.validate_display_name
by string. It worked in a monolith and answered a permanent 503 in a split
deployment, because a symbol resolution has no remote form.
The line the rule draws — and the reason it does not outlaw the fleet's own extension mechanism — is where the value comes from, decided at the call site with no index and no configuration:
| shape | verdict |
|---|---|
import_string(settings.STAPEL_RECORDINGS["STORAGE"]) |
silent — the host chose it |
import_string(getattr(settings, "NORMALIZER", DEFAULT)) |
silent — a declared extension point |
get_model(KEY, default="ourpkg.models.Thing") |
silent — a swap seam waiting to be overridden |
import_string("ourpkg.validators.validate") |
silent — your own overridable entity |
apps.is_installed("django.contrib.admin") |
silent — a question about the host's config |
find_spec("pyvips") with pyvips in an extra |
silent — declared, pinned, installed with you |
import_string("other_pkg.validators.validate") |
error — nobody chose it but the author |
"Ours" is derived, never configured: every top-level package in the tree,
every AppConfig label, the pyproject.toml distribution name, everything
the manifest pins ([project.dependencies], any extra, any
requirements*.txt), the standard library, and django. There is no
allowlist to add yourself to. tests/, test_*.py, migrations/ and
.vendor/ are out of scope; a dotted path inside a code template or a
settings assignment is data, not resolution, and is never seen. Suppress a
deliberate exception with # noqa: SWAP003. Composed into stapel-verify.
SWAP004 (error) a vendor SDK imported outside the fleet library that owns
the integration — today livekit outside stapel_video. Prototype: a
product carrying its own copy of the LiveKit provider next to the library's.
It was not a bad copy; it was ahead of the library on two capabilities, and
that is the mechanism — a fork of a provider layer never starts as a fork, it
starts as one call the library did not have yet, added where the engineer was
standing. Every capability added there is one no other consumer gets, and the
day the library fixes something the product with the fork cannot receive the
fix at all.
Not a dependency ban: depend on the SDK, run it in a worker, whatever. What
you may not do is import it, because that is the one act that puts a
provider call in product code. The fix is always the same: add the capability
to the library's provider contract and call it through the seam. The
owner table (_VENDOR_SDK_OWNERS) is explicit and tiny — a row is added the
day a library ships the capability for the whole fleet, not before. Suppress
with # noqa: SWAP004.
Measured across the fleet on release: 34 raw hits, 3 after triage.
stapel-makemessages / stapel-po-lint / stapel-po-prune — the gettext seam
make messages # extract + gate (the command to reach for)
stapel-po-lint . # the gate alone — CI, pre-commit
stapel-po-prune . --sample 20 # product-side dry run; --apply to rewrite
makemessages is what everybody reaches for the moment they add a translatable
string, and run bare it demotes every entry whose source it could not find.
gettext skips both demotions: obsolete (#~, parked at the end of the file)
and fuzzy (left among the live entries, still carrying a translation, still
looking translated). Fuzzy is the dangerous one — a python-format →
python-brace-format flag flip produces it with no edit to the msgid at all —
and a suite that asserts almost no strings stays green while the product
reverts to its source language.
stapel-makemessages runs the extraction with the ignores Django does not
apply itself, runs the gate on the result, and restores the catalogues
byte-for-byte if anything was demoted. --accept-losses keeps the result
when the demotion is the point.
stapel-po-lint is the gate on its own, and is composed into stapel-verify:
| rule | level | what |
|---|---|---|
| PO001 | error | fuzzy entry — gettext skips it, the file still shows a translation |
| PO002 | error | obsolete (#~) entry — its translation is switched off |
| PO003 | warning | empty msgstr — ships the source language |
| PO004 | warning | unowned entry — no #: reference resolves to a file in this tree |
--max-fuzzy N / --max-obsolete N let a known count stand during a sweep, so
the gate still fails when the count rises.
PO004 states the rule as a rule: a catalogue is a projection of its own
sources; it is never a place to park somebody else's strings. It applies only
to catalogues that are projections — at least one #: reference resolving to a
real file — so a hand-authored library catalogue whose #: slot holds
translation keys (#: notification.otp_code.subject) is never judged on
ownership. PO001/PO002 apply to every catalogue.
stapel-po-prune is the product-side fixer. It asks makemessages — run in a
scratch copy — what the tree actually contains, then sorts every entry:
sourced— the extraction found it. Kept.shadow— the extraction found it, but an installed package owns the same msgid; it survives only because something in the tree quotes the literal.foreign— the extraction did not find it and a library owns it.dead— nobody owns it. Removed on--apply.
foreign and shadow are never deleted blindly — deleting them hands the
string back to the library default. They are reported with the override
rewritten into the owning library's own seam (for stapel-notifications,
STAPEL_NOTIFICATIONS["TEXT"], keyed by translation key, read out of the
library's catalogue and key registry), and removed only with
--relocate-applied.
Dry run is the default and it proves idempotence rather than claiming it: it applies to a scratch copy, classifies again, and reports what a second apply would remove.
stapel-llms-txt — generate the module's own docs/llms.txt
stapel-llms-txt . # emit <repo>/docs/llms.txt
stapel-llms-txt . --check # drift gate (nonzero exit, no write)
stapel-llms-txt . --out /tmp/x # render a checkout you must not write to
stapel-llms-txt . --budget 8000 # raise the ceiling DELIBERATELY
stapel-llms-txt . --skip-missing # a repo with no contract is a loud no-op
The fifth per-module contract artifact, next to
docs/{schema,flows,errors,capabilities}.json and under the same discipline:
emitted by make contract, gated by make contract-check, committed, shipped
in the wheel. It renders the module's surface slice for an agent's context —
header + provides, then Configuration axes (key, kind, default, business
label, gated operations and the OR-composed co-gates), Usage surface (the
main section: name — path, instead of, consumer, the curated intent),
Extension points, Fits with, then the compact METHOD /path — operationId catalog grouped by tag with the mount prefix factored out, the
error codes one line each (code [status] remediation {slots} — localized
prose stays in errors.<lang>.md), and the flow index. Sections whose source
document is absent do not appear at all, so stapel-core — no OpenAPI, no
axes — renders as surface + seams.
Three properties, copied from the frontend's scripts/gen-manifest.mjs rather
than reinvented:
- deterministic — every list has an explicit sort key, nothing carries a timestamp or an absolute path, so the drift gate compares bytes without false reds;
- hard token budget — 4000 by default, the frontend's
LLMS_TOKEN_BUDGET. Over budget fails with a per-section cost breakdown and writes nothing; it never truncates, because a cut context file is indistinguishable from a complete one at the point of use. The reported trim order issurface → axes → extension_points;--budget Nin the module's Makefile is the deliberate alternative; - loud when there is nothing to say — a module with no
docs/capabilities.jsonis an error naming the module, never an emptyllms.txt. An empty context file answers "does the fleet have a mechanism for X?" with a confident no.
Per-module wiring is two lines:
contract: python3 -m stapel_tools.llms_txt .
contract-check: python3 -m stapel_tools.llms_txt . --check
stapel-release-manifest — build the open release.json manifest
stapel-release-manifest . --release r4 --git-sha $(git rev-parse HEAD) \
--image app=registry/tenant/proj/app:r4 --out release.json
Describes one gated build (release-management.md §1): per-app migration
watermarks (max migration file at the sha — the artifact, not a DB),
reversible_floor per app, contracts (stapel-* version pins),
config_digest over the STAPEL_<MOD> settings blocks, and gate results
(migration_lint computed via the shared analyzer; prodguard/
handover_scan recorded from --gate name=pass|fail). Output is
byte-deterministic (sorted keys; --created-at/SOURCE_DATE_EPOCH).
The platform bake step calls this during image build and bakes the file
into the image at /app/release.json.
stapel-disk — build/disk lifecycle: preflight guard, tiered reclaim, ephemeral reaper
stapel-disk guard --min-free-gb 15 --for "make e2e" # refuse BEFORE the build
stapel-disk doctor # where the space went
stapel-disk reclaim [--images] # tier 1 [+ tier 2]
stapel-disk reap [--dry-run] [--owner studio-e2e] # this run's own garbage
A build that runs out of disk does not fail cleanly. It dies mid-layer on an
opaque ENOSPC, and it can take the docker daemon's socket with it — after
which every shell tool on the machine fails with an EOF too. Measured on the
fleet's own workstation the night this shipped: 27.9 GB across 187 unreferenced
volumes (131 of them anonymous PGDATA directories orphaned by a test script
that removed its postgres container without -v), plus 12.4 GB of
unreferenced images.
guard is the preflight: put it in front of every target that builds an
image or starts a sandbox. Below the threshold it refuses with the free space,
the threshold, the shortfall and the exact reclaim command; above it, it is
silent and cheap. Threshold: --min-free-gb, else STAPEL_DISK_MIN_FREE_GB,
else 15 GiB.
reclaim is tiered:
| tier | what | how |
|---|---|---|
| 1 | build cache, dangling images, stopped containers | always, safe to automate |
| 2 | unreferenced images | --images, opt-in |
| — | volumes | refused, at every tier |
The volume refusal is a hard rule in the tool, not a convention: a project's
repositories, its databases and its snapshots live in named volumes, and
docker system prune --volumes cannot tell them from build garbage. Passing
--volumes exits 2 and prints why. No argv this command emits can delete a
volume.
reap is the other half — the thing that creates throwaway resources must
own their death. It removes only resources that identify themselves as
ephemeral: the stapel.ephemeral=true label (the contract, stamped at
creation) or an explicit name pattern (studio-vol-e2e-* and friends — the
fallback for resources created before the label existed). A pattern must carry
at least four literal characters before its first wildcard, so --pattern '*'
is refused rather than obeyed. --dry-run lists matches and the count of
resources inspected and left untouched.
Wire it into a Makefile so the whole lifecycle is one target's business:
e2e: disk-guard
@set -e; trap 'stapel-disk reap --quiet' EXIT INT TERM; \
docker compose up -d && ./run-the-suite.sh
The trap is the point: a crashed run is exactly the run that leaks, so the
reaper has to fire on failure as well as success. Generated projects inherit
disk-guard / disk-doctor targets from the scaffold.
stapel-fixture-lint — the catalogue/vocabulary fixture contract
A catalogue importer turns some external classified's schema into two kinds of
file, which manage.py load_catalog and manage.py load_vocabulary apply:
<out>/catalog/features.json root FeatureDefs — stapel-categories catalog_fixtures format
<out>/catalog/categories.json the tree, parent_slug edges, per-category feature lists
<out>/vocabularies/<slug>.json levels + terms + edges, for load_vocabulary
The importers themselves are specific to their source and belong to whoever
owns that source. What is here is the part every one of them shares:
stapel_tools.catalog_fixtures — the deterministic slug/term-code/dedup rules,
the byte-stable writer, the shipped vocabulary-fixture.schema.json, and the
gate over what was emitted.
stapel-fixture-lint out/ # out/{vocabularies,catalog}/*.json
stapel-fixture-lint out/vocabularies # or one half
stapel-fixture-lint one-fixture.json # or one file
stapel-fixture-lint out/ --json
Django-free: no settings module, no database, no network. A fixture is checked as a file, hours or weeks before anything tries to load it — which is the point, because the alternative is the loader rejecting a 25 MB file on the stand at term 26 420.
| Rule | What it catches |
|---|---|
VOC001 |
not JSON, or not the vocabulary-fixture schema (through jsonschema, when installed; the checks below run either way) |
VOC002 |
two terms of one level share a code |
VOC003 |
a level's parent is not a level declared before it |
VOC004 |
terms are not in canonical (level index, code) order |
VOC005 |
an edge names a term the fixture does not declare |
VOC006 |
an edge joins two levels that are not a declared parent/child pair |
VOC007 |
edges unsorted, or one repeated |
VOC008 |
the file's bytes are not the canonical rendering of its own content |
CAT001 |
a catalogue half is not valid JSON or not canonical bytes |
The decisions worth knowing:
- A term code is an identity, so
VOC002is an error and not a warning. It is what a facet, a rule and a listing's stored value all address. The collision that motivated the rule was three trim levels —Exclusive,Exclusive 2andExclusive+— where the first and third both slugify toexclusiveand a blind-2on the third handsexclusive-2to two different terms.slug.dedupreserves every code the data itself claims before it hands out a suffix, in either direction;assert_unique_codesis the same check as a raising call, for an importer that wants to stop during the build rather than after writing the file. - Byte stability is a contract, not a nicety. Fixtures are reviewed as code:
two runs over the same input must produce files a
diffcalls identical, otherwise every regeneration is an unreadable churn commit.VOC004,VOC007andVOC008are the three ways an emitter breaks it — an order that depends on a dict's iteration, an unsorted edge list, and a hand-edited or differently-serialised file. - A prefix namespaces one source's vocabularies.
vocabulary_slugtakes one, because the slug is the vocabulary's identity in the database and two sources both shipping abrands.xmlmust not fight over it. The prefix is charged to the caller's stem budget, so it can never push the slug past the schema's 64-character cap. - The catalogue half's INNER shape is not this gate's business.
stapel-categories' loader owns that contract and checks it properly;CAT001is only about the file being parseable and canonical, which is what makes it reviewable.
Project layout
Generated projects follow the mainstream Django community canon so the shape is familiar to anyone who has used the popular templates.
-
config/— the settings/URLs/WSGI package. Notcore/. This is the convention of cookiecutter-django, the HackSoft styleguide, and Two Scoops of Django (ROOT_URLCONF = "config.urls"). It also avoids colliding with thestapel-corepackage name. The monolith and microservices presets split it intoconfig/settings/{base,dev,local,prod}.py; minimal keeps a singleconfig/settings.py— a deliberate choice for a small, no-Docker project (the falco / Adam Johnson "one settings file until you need more" camp). -
apps/— a regular package holding your Django apps. Every module lives atapps/<module>with anapps/__init__.py, is listed asINSTALLED_APPS = ["apps.<module>"], and setsAppConfig.name = "apps.<module>"(the full dotted path — see Django ticket #24801). This is the wemake-django-template pattern, and it is uniform: the starter module and everything added later bystapel-new-moduleshare the same import path.
myapp/ # monolith / microservices service (svc-myapp/)
├── config/
│ ├── settings/{base,dev,local,prod}.py
│ ├── urls.py wsgi.py asgi.py
├── apps/
│ ├── __init__.py # regular package (required)
│ └── myapp/ # apps.myapp — INSTALLED_APPS + AppConfig.name
├── tests/ # outbox/mailtrap integration harness
└── manage.py
myapp/ # minimal preset (no Docker, SQLite)
├── config/
│ ├── settings.py # single file (deliberate)
│ ├── urls.py wsgi.py
├── apps/__init__.py apps/myapp/
└── manage.py
Available modules
| Module | Description |
|---|---|
core |
Core framework (always included) |
auth |
Authentication — JWT, OAuth, OTP |
billing |
Billing & subscriptions |
cdn |
File uploads & CDN |
notifications |
Email / push notifications |
profiles |
User profiles |
translate |
Translations & i18n |
workspaces |
Workspaces & multi-tenancy |
gdpr |
GDPR — data export & deletion |
Metadata
Release files for stapel-tools 0.64.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| stapel_tools-0.64.1.tar.gz | 1.0 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| stapel_tools-0.64.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 1.7 MB
Release files / stapel_tools-0.64.1.tar.gz
| Download URL | stapel_tools-0.64.1.tar.gz |
|---|---|
| Size | 1.0 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c7d598f5f07a6c45ef893504be3352dad2c8ec341a0e40701e94fd8b09d79231
|
|
BLAKE2b-256 checksum How to use checksums |
55a677e4d8371b3a357bd1fc90ffb613cebd189ac43125a992c498b9a7361b34
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 7, 2026.
Transparency logRelease files / stapel_tools-0.64.1-py3-none-any.whl
| Download URL | stapel_tools-0.64.1-py3-none-any.whl |
|---|---|
| Size | 706.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d3c7188a427dd480f7e1a706ba60ca54a46b0b4143837fa81f4b2f40ba6c2c3c
|
|
BLAKE2b-256 checksum How to use checksums |
22b29bf66c619a4ae1071a921b27501765e43a9d9c0b0f25197a4f257fa51895
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 7, 2026.
Transparency log