stapel-categories
Category tree with typed features: a hierarchical category tree (django-treenode) and a parallel feature tree whose typed config is validated by stapel-attributes, an ordered category<->feature M2M, feature inheritance, and a feature-editor lifecycle (keep/add/edit/inherit/remove/create/replace) with optimistic-concurrency apply over a category subtree. Each node also declares how its CHILDREN are presented (
children_as:tilesfor real subcategories,chipsfor a partition of one attribute template), authored or derived by thederive_children_ascommand, and the whole visible tree is readable nested in one cached call (GET /categories/api/v1/tree/?depth=N).
Part of the Stapel framework — composable Django apps that deploy as a monolith or as microservices without changing module code.
Install
pip install stapel-categories
At a glance
| Fact | Value |
|---|---|
| Version | 0.24.1 |
| Python | >=3.11 (3.11, 3.12, 3.13, 3.14) |
| HTTP operations | 37 |
| Config axes | 1 |
| Usage surface | 23 |
| Extension points | 4 |
| Error codes | 64 |
| Fleet dependencies | stapel-attributes · stapel-core |
Documentation
OpenAPI · capabilities.json · llms.txt (for agents)
What this is
A hierarchical category tree (django-treenode) and a parallel feature
tree whose typed config is validated by
stapel-attributes. Categories
own the tree structure, feature inheritance, the ordered category↔feature M2M,
and the feature-editor lifecycle; the attribute engine (types, config/DTO/DAO
validation, polymorphic serializers, admin widgets) lives in stapel-attributes
and is imported, never re-implemented.
Quick start
INSTALLED_APPS = [
# ...
"treenode", # django-treenode (tree-cache signals)
"stapel_categories",
]
# urls.py — this module's own urls.py bakes in only `v1/`; the host
# contributes `api/`, giving the canonical `/categories/api/v1/...` prefix
# (exactly what stapel-example-monolith already does for this module).
path("categories/api/", include("stapel_categories.urls"))
stapel-attributes is an imported library (no app to install); its config
editor ships static assets, so run collectstatic if you use the admin.
Presenting the tree
Every node says how its children should be drawn, as children_as on
every public read:
| value | meaning |
|---|---|
tiles |
the children are real subcategories — a tile grid of destinations |
chips |
the children partition ONE attribute template (new/used, buy/sell/rent, boys/girls) — a chip row over the parent's own feed |
transparent |
browsing SKIPS this node: its children appear where it would, and its own page is its parent's |
null |
the node has no children |
Children are counted live. Every public read carries children_pks (the
ids of the children a reader can see, in order) and children_count. Both are
GET /categories/{id}/children/ — the same visibility rule, the same order —
and children_as resolves off the same count, so a node whose every child is
soft-deleted answers null rather than sending a storefront to draw a grid of
nothing. Do not build on tn_children_pks/tn_children_count: those are
django-treenode's structure columns and count soft-deleted and retired rows
(a live stand read "68,67,221" where /children/ returned one row). They
stay on the payload for the revision-sync feed.
transparent is the import wrapper — a level a catalogue keeps for placement
that nobody should have to browse through («Предложение услуг» between a root
and the 34 groups that are the real level). The TREE is unchanged: the node
keeps its id, its path and its place as the target of a listing; only the
presentation of it is. It is AUTHORED only — the collapse is an editorial call
read off a census, and no signal on a tree can make it, so derive_children_as
never emits it and never overwrites it.
A chips or transparent parent that declares no features of its own
answers the effective
schema — the UNION of its children's — wherever features are read
(GET /categories/<id>/features/, the categories.features Function). It
renders the feed and the chip row for the whole partition, so "what can be
filtered here" is a question about the children. A feature only SOME children
carry is IN it, marked partial: true with carried_by naming those children
and a config merged from them alone — a filter the partition offers on one
chip is a filter the partition offers, and intersecting made the combined feed
poorer than the leaf beside it. A feature the carrying children disagree on
carries divergent: true beside the WIDEST config of theirs, so a client may
render it (it refuses nothing a child accepts) or hide it until a chip is
picked. The HTTP read says which it did in the X-Effective-From header
(own / children), the Function in effective_from. A parent with features
of its own keeps them ALONE — own only, never own plus the union. A transparent node has no page of its own, but a composer
walking through it and every caller of categories.features still ask what it
types, and a wrapper's own links are empty by construction — so it takes the
same rule. That is the whole of the overlap: it draws no chip row and gets no
axis caption.
A chip row also needs a NAME for the axis it splits on — «Все | С пробегом |
Новые» is a set of values, and only the parent can say what they are values
of. That name is children_axis_label, an optional translation key on the
parent (empty means the row is drawn uncaptioned), authored in the admin or
over the staff serializer and carried on every public read next to
children_as.
The stored column takes a third value, auto, which a reader never sees:
auto means "nobody has decided", and it is resolved server-side. Two
columns hold the two answers — children_as is the authored intent,
children_as_derived is the derivation's cache — so a re-run can improve its
own output without ever overwriting an operator's.
django-admin derive_children_as # report only
django-admin derive_children_as --apply # write the derived column
An --apply run also NAMES the axis of the rows it makes chips, from the
vocabulary group it already matched (deal type, condition, for whom) — a
translation key per group, never a rendered word. Authored text always wins,
and the command only ever replaces a label it emitted itself, which is what
keeps the step re-runnable.
The command prints one line per parent with the decision, the signal that
carried it (schema, vocabulary, empty-schema, structure,
vocabulary>structure) and the Jaccard overlap of the children's own feature
keys, so a wrong call can be pinned by hand — set children_as to tiles or
chips in the admin and no future run touches it. A node already authored
(including transparent) is printed with its value and skipped.
A census is applied without the admin, in one command, idempotently:
django-admin set_children_as --path uslugi/predlozhenie-uslug --value transparent
django-admin set_children_as --paths-from census.txt --value transparent
django-admin set_children_as --path a/b --value tiles --dry-run
The same command owns children_axis_label — the caption a chip row needs
for its axis («Тип жилья» over Новостройка | Вторичка). derive_children_as
only ever fills a blank label or improves its own previous key; text an
engineer actually wrote had no command of its own until now:
django-admin set_children_as --path a/b --axis-label "Тип жилья"
django-admin set_children_as --path a/b --clear-axis-label
--value and --axis-label/--clear-axis-label combine in one run — one
save per node either way — and at least one of the three is required.
The path is the slug path root→self — the exact form the derivation report
prints, so a census read off that report pastes straight back. A bare slug
works too (the column is unique); a longer path is checked against the tree and
refused if it no longer matches. Every path resolves before any is written, so
one bad line leaves nothing half-applied, and a node that already carries the
value is reported unchanged and not re-saved.
A child set that spells a partition is a chip row even where some of those
children have children of their own: Квартиры → Продам/Сдам/Куплю/
Сниму is a partition whether or not Продам splits further, and each such
child is then a parent whose own children are decided by the same rules.
structure stays a veto only where the names say nothing.
children_as travels in the catalogue fixture (export_catalog /
load_catalog), so an authored decision survives an image rebuild; the
derived cache does not — a load leaves it to be re-derived.
Which feature is the make
A handful of features are not properties of an object — they are the axis
a classified is organised along, and a product has to know which feature that
is before it can do anything with it: «Найти больше вариантов этой марки»
needs the make of the leaf a listing sits in, and an AI descent must fill make
before model before generation because each narrows the next. So a feature
carries axis_role — make, model, generation, year, mileage, or
null for the overwhelming majority — and every read that carries a feature
schema carries it: GET /categories/{id}/features/, feature_defs() and the
categories.features comm Function alike. A consumer asks
by_axis_role(features)["make"] (stapel-attributes) instead of keeping a
closed table of slugs that a catalogue spelling the axis a fourth way drops
out of silently.
Two columns again, for the same reason children_as needs two. axis_role is
authored — by the fixture, the admin, or set_axis_role; axis_role_derived
is load_catalog's cache, filled after every apply from a documented slug
table (brand/make/vendor/manufacturer → make, model → model,
generation, year/god_vypuska, mileage/kilometrage, with a trailing
_ref_select stripped first, so make_ref_select is not the spelling nobody
added). The reader sees the resolved answer.
A category offering two candidates for one role (brand AND vendor)
derives neither — there and everywhere else, since the row is shared —
because a link built off the wrong one sends a buyer to a facet they did not
click. load_catalog and catalog_health both name such a pair, and the tie
is broken by hand:
django-admin set_axis_role --slug vendor --role make
django-admin set_axis_role --slug proizvoditel --slug marka --role make
django-admin set_axis_role --slug body_type --clear
The table also carries a short list of product-specific full slugs —
cool_table_brand, hearing_aid_brand, equipment_brand — because a leaf
whose product has a name of its own tends to spell the axis with it. They are
adjudicated one at a time, never matched by a _brand suffix: the same corpus
spells a ride service's driver car car_make, a part number vendor_code and
a year year_make_of_car, and NEVER_A_MAKE keeps those counter-examples as
data. Each sits in the last precedence tier, so a leaf carrying both a
compound and a canonical make keeps answering with the canonical one.
load_catalog re-derives after every apply, but a derivation that only ever
happens inside a catalogue load cannot be inspected, so the same decision is
available on its own — dry by default, counted by slug and by role:
django-admin derive_axis_roles # report only
django-admin derive_axis_roles --changed # only the rows that would move
django-admin derive_axis_roles --apply # write `axis_role_derived`
It writes exactly one column. The authored axis_role is never read, written
or overwritten by it.
An authored role travels in the catalogue fixture, so it survives an image rebuild; the derived cache does not — a load re-derives it.
The whole visible tree comes back nested in one cached call:
GET /categories/api/v1/tree/?depth=3 # 1..4, default 3
Active nodes, ordered by tn_priority descending at every level, carrying
id, slug, name, path (the /-joined id path a search query takes),
catalog_icon, children_as, children_axis_label and children. One query
whatever the depth.
Settings
All configuration lives in the STAPEL_CATEGORIES namespace (dict setting,
flat setting, or env var — resolved lazily):
| Key | Default | Meaning |
|---|---|---|
CAROUSEL_CACHE_TIMEOUT |
300 |
Seconds the carousel response is cached. |
FEATURE_DISPLAY_CACHE_TIMEOUT |
60 |
Seconds an admin feature display label is memoized. |
FEATURE_RENAME_HOOK |
"auto" |
comm Function load_catalog --rename-features hands a feature-slug rename to, so the stored listing answers move with the schema. |
DISPLAY_TRANSLATOR |
stapel_categories.translation.identity_translator |
Dotted path (key)->str for rendering translation keys (default: identity). |
comm surface
| Kind | Name | Contract |
|---|---|---|
| Function | categories.features |
{"category_id": int} -> {"category_id", "revision", "features":[{id,slug,name,mandatory,config}]} — resolved schema (own + inherited), cacheable by revision |
| Function | categories.path |
{"category_ids": [int, ...]} -> {"<id>": ["<root_id>", ..., "<id>"]} — root->leaf ancestry, one query for the batch; segments are ids, an unknown id is absent |
| Function | categories.by_slug |
{"slugs": ["transport", ...]} -> {"<slug>": ["<root_id>", ..., "<id>"]} — the same ancestry keyed by slug (Category.slug is globally unique); an unknown slug is absent, an inactive node still answers, a RETIRED slug (renamed since, CategorySlugAlias) answers its current row's ancestry keyed as asked |
| Action (emit) | category.changed |
{"category_id": int, "revision": int} on any category/feature mutation — for downstream cache invalidation |
categories.features lets stapel-listings validate attribute values against a
category's schema without importing this module. categories.path is the
provider stapel-search declares by canonical name for category rollup — without
it a search index degrades to a single path segment and a filter on a parent
category finds none of its descendants. categories.by_slug is the same
answer in the other namespace: it is what lets a page addressed
/c/avtomobili ask for its own feed, and without it every slug segment of a
search query degrades instead of filtering.
Contract
docs/{schema,flows,errors}.json are emitted from a single-module
{categories + core} Django instance mounted at the canonical
/categories/api/v1 prefix (make contract / make contract-check; see
_codegen.py) — the same mechanism stapel-search, stapel-chat and
stapel-forms already use. docs/flows.json is []: no flow is declared via
@flow yet, same state as every other contract-complete module today.
docs/capabilities.json stays hand-authored (see the Makefile comment); only
its surface section is derived.
The ten feature-value shapes (FeatureConfig, FeatureDto) are a proper
discriminated oneOf keyed by type, contributed by stapel-attributes and
now used consistently everywhere a config or a values-DTO crosses the wire —
Feature.config, FeatureBulk.config, the convert-type request body and
validate-dto's features field all resolve through it (previously the
last three fell back to an untyped JSONField/DictField).
Delta note — one pair of fields stays untyped, and it isn't this module's
to fix. FeatureValidationResult.id / .ref_value render as free-form in
the schema. Both are defined by stapel-attributes
(FeatureValidationResult dataclass + its serializers.JSONField
projection in results.py), not by this module: id is
Optional[Union[int, str]] and ref_value is
Optional[Union[str, int, float, list]] — plain scalar unions with no
type discriminator to key a oneOf on, unlike the ten-way config/DTO
shapes. Typing them is upstream's serializer to extend, not a gap
stapel-categories introduced or can close by itself.
Extension points
See MODULE.md — the agent-facing map of every fork-free seam (settings, serializer seams, comm surface, feature-editor actions, admin-UI pointer to stapel-attributes).
Development
pip install -e . && pip install pytest pytest-django ruff
./setup-hooks.sh
pytest tests/
License
MIT — see LICENSE.
This page is assembled by stapel-readme from docs/readme.md plus the contract artifacts in docs/. Edit the prose in docs/readme.md; the badges, facts and links above and below it are generated — do not hand-edit README.md.
Metadata
Release files for stapel-categories 0.24.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_categories-0.24.1.tar.gz | 372.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| stapel_categories-0.24.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 629.6 kB
Release files / stapel_categories-0.24.1.tar.gz
| Download URL | stapel_categories-0.24.1.tar.gz |
|---|---|
| Size | 372.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
04d08e9212401419a675604e2a7e739227d97753391ed041285bb81773cb63f8
|
|
BLAKE2b-256 checksum How to use checksums |
5dc9bbcd2923274cfe130adedc0833f635054ec5173d3248dba7eb90f4fdb6cf
|
| 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 Oct 15, 2026.
Transparency logRelease files / stapel_categories-0.24.1-py3-none-any.whl
| Download URL | stapel_categories-0.24.1-py3-none-any.whl |
|---|---|
| Size | 256.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
bbdd2fc425693357eb3a19d9db5ad4a8cb63d3f71556890f6f753541359aec43
|
|
BLAKE2b-256 checksum How to use checksums |
db8cfe1cda41a67e478f95eeee0ed65e12579bb49b57a8d5044b1985a33de5ef
|
| 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 Oct 15, 2026.
Transparency log