# taxomesh

> taxomesh gives a multi-parent category graph a Python stdlib-style interface: categories, items and tags take subscript, `get`, `in`, `len` and `del` as a `dict` does. The graph is stored in a YAML file, a JSON file, the Django ORM or a backend of your own.

Python 3.13+. `pip install taxomesh`; the extras `taxomesh[cli]` (the `taxomesh` command) and `taxomesh[django]`.

## The rules
- Construct one `TaxomeshService` and work through `svc.categories`, `svc.items`, `svc.tags` and `svc.graph()`. With no argument it reads the `taxomesh.toml` in the working directory, and otherwise stores a YAML file under `data/`.
- To choose the storage, pass `TaxomeshService(repository=...)` a `YamlRepository(path="data/taxomesh.yaml")`, a `JsonRepository(path="data/taxomesh.json")` (`path` is a `str` or a `Path`) or a `DjangoRepository(using="default")`.
- On an absent key, subscript raises and anything named `get*` does not. `coll[key]` returns the row or raises `Taxomesh<X>NotFoundError`, a `KeyError`; `coll.get(key, default=None, /)` returns the default. `key in coll`, `len(coll)` and `del coll[key]` complete the forms. A `TaxomeshGraph` has them too, without `del`.
- Collections and the graph do not iterate: `coll.list(...)` and `graph.walk()` enumerate. `len(coll)` reads every row.
- `list*` returns a tuple, `get_many*` a new dict keyed by identifier or by stored external id, and `get_by_slug` and `get_by_external_id` the row or `None`. A batch lookup, and `relation_types`, takes one value or a collection of them.
- A parameter naming a category, item or tag takes the row or its `UUID` (`CategoryRef`, `ItemRef`, `TagRef`). Anything else, a row of another kind included, raises `TypeError`.
- Filters are keyword-only. `enabled` is `True` (enabled only), `False` (disabled only) or `None` (all): `True` by default on `list`, `roots`, `search`, `list_related`, `get_many_related` and `svc.graph()`, and `None` on `get_many` and `get_many_by_external_id`. Subscript, `get`, `in` and `len` do not filter.
- Rows are frozen, `metadata` included, and `metadata` is plain JSON: dicts with text keys, lists or tuples (taken as lists), text, finite numbers, booleans and `None`; a `StrEnum` or `IntEnum` member is taken as its value, and for another `Enum` you pass `.value`. `update(...)` returns a new row; its fields default to `UNSET`, which leaves a field as it is, and `external_id=None` clears the external id. `expected_version=row.version` raises `TaxomeshVersionConflictError` when the row has changed since.
- A category is at the top level exactly when it has no parent: `roots()` lists the top level, and `None` stands for it in `move` and `reorder`. Parent links form a DAG, and a cycle raises `TaxomeshCyclicDependencyError`.
- Removing an absent link is a no-op; deleting an absent row raises. A delete removes every link that names the row.
- An external id is given as `str | int | UUID | None`, stored as its `str()`, and unique within its kind; any other type raises `TypeError`.
- Each service caches its reads for `cache_ttl` seconds (default `5.0`; `0` caches nothing), and a write clears that service's cache only; a write reads what it changes from storage, never from the cache. A YAML or JSON file is read when its repository is built, so it has one writer: a repository refuses to write once another writer has changed its file, and two repositories writing at the same moment, in one process or in two, can still lose one write. One repository, or one service, may be shared by threads; processes share a store through Django. A listing reads links, then the rows they name, so it may raise the not-found error of a row that another writer deletes in between.
- A not-found error is a `KeyError` and a refused value a `TaxomeshValidationError`, a `ValueError`; no `pydantic.ValidationError` leaves a member. An argument of the wrong type raises `TypeError`, which is no `TaxomeshError`; the flags `recursive`, `fuzzy` and `include_items` are read as `if flag:`.
- Every taxomesh name here imports from `taxomesh`, except the collection classes, which you reach through the service; the repository classes import from `taxomesh.repositories`, and `TaxomeshGraphTooLargeError` from `taxomesh.contrib.api.errors`.

<!-- BEGIN GENERATED by tests/docs/_reference.py: edit the renderer, not this block -->
## TaxomeshService
TaxomeshService(repository: TaxomeshRepositoryBase | None = None, *, config_path: Path | str | None = None, cache_ttl: float = 5.0)
svc.graph(*, root: CategoryRef | None = None, enabled: bool | None = True, include_items: bool = True) -> TaxomeshGraph
svc.info: property -> TaxomeshInfo
svc.repository: property -> TaxomeshRepositoryBase
## svc.categories: CategoryCollection
svc.categories.add_parent(category: CategoryRef, parent: CategoryRef, *, sort_index: int | None = None) -> CategoryParentLink
svc.categories.create(name: str, *, description: str = '', slug: str = '', external_id: ExternalId = None, metadata: dict[str, Any] | None = None) -> Category
svc.categories.delete(key: CategoryRef, /) -> None
svc.categories.get(key: CategoryRef, default: D | None = None, /) -> Category | D | None
svc.categories.get_by_external_id(external_id: ExternalId, /) -> Category | None
svc.categories.get_by_slug(slug: str, /) -> Category | None
svc.categories.get_many(categories: CategoryRef | Collection[CategoryRef], /, *, enabled: bool | None = None) -> Mapping[UUID, Category]
svc.categories.get_many_by_external_id(external_ids: ExternalId | Collection[ExternalId], /, *, enabled: bool | None = None) -> Mapping[str, Category]
svc.categories.list(*, parent: CategoryRef | None = None, item: ItemRef | None = None, enabled: bool | None = True) -> Sequence[Category]
svc.categories.move(category: CategoryRef, *, from_parent: CategoryRef | None, to_parent: CategoryRef | None, before: CategoryRef | None = None) -> None
svc.categories.remove_parent(category: CategoryRef, parent: CategoryRef) -> None
svc.categories.reorder(parent: CategoryRef | None, categories: Sequence[CategoryRef]) -> None
svc.categories.roots(*, enabled: bool | None = True) -> Sequence[Category]
svc.categories.search(query: str, *, limit: int = 20, parent: CategoryRef | None = None, enabled: bool | None = True, fuzzy: bool = True) -> Sequence[Category]
svc.categories.update(category: CategoryRef, *, name: str | UnsetType = UNSET, description: str | UnsetType = UNSET, slug: str | UnsetType = UNSET, external_id: ExternalId | UnsetType = UNSET, enabled: bool | UnsetType = UNSET, metadata: dict[str, Any] | UnsetType = UNSET, expected_version: int | None = None) -> Category
## svc.items: ItemCollection
svc.items.create(name: str, *, slug: str = '', external_id: ExternalId = None, metadata: dict[str, Any] | None = None) -> Item
svc.items.delete(key: ItemRef, /) -> None
svc.items.get(key: ItemRef, default: D | None = None, /) -> Item | D | None
svc.items.get_by_external_id(external_id: ExternalId, /) -> Item | None
svc.items.get_by_slug(slug: str, /) -> Item | None
svc.items.get_many(items: ItemRef | Collection[ItemRef], /, *, enabled: bool | None = None) -> Mapping[UUID, Item]
svc.items.get_many_by_external_id(external_ids: ExternalId | Collection[ExternalId], /, *, enabled: bool | None = None) -> Mapping[str, Item]
svc.items.get_many_related(items: ItemRef | Collection[ItemRef], /, *, relation_types: str | Collection[str] | None = None, direction: Direction | Literal['outgoing', 'incoming', 'both'] = 'outgoing', enabled: bool | None = True) -> Mapping[UUID, RelatedItems]
svc.items.list(*, category: CategoryRef | None = None, recursive: bool = False, tag: TagRef | None = None, enabled: bool | None = True) -> Sequence[Item]
svc.items.list_related(item: ItemRef, *, relation_types: str | Collection[str] | None = None, direction: Direction | Literal['outgoing', 'incoming', 'both'] = 'outgoing', enabled: bool | None = True) -> Sequence[Item]
svc.items.list_relations(item: ItemRef, *, relation_types: str | Collection[str] | None = None, direction: Direction | Literal['outgoing', 'incoming', 'both'] = 'outgoing') -> Sequence[ItemRelationLink]
svc.items.move(item: ItemRef, *, from_category: CategoryRef, to_category: CategoryRef, before: ItemRef | None = None) -> ItemParentLink
svc.items.place_in(item: ItemRef, category: CategoryRef, *, sort_index: int | None = None) -> ItemParentLink
svc.items.relate(source: ItemRef, target: ItemRef, relation_type: str, *, sort_index: int | None = None, metadata: dict[str, Any] | None = None) -> ItemRelationLink
svc.items.remove_from(item: ItemRef, category: CategoryRef) -> None
svc.items.reorder(category: CategoryRef, items: Sequence[ItemRef]) -> None
svc.items.search(query: str, *, limit: int = 20, category: CategoryRef | None = None, recursive: bool = False, enabled: bool | None = True, fuzzy: bool = True) -> Sequence[Item]
svc.items.tag(item: ItemRef, tag: TagRef) -> ItemTagLink
svc.items.unrelate(source: ItemRef, target: ItemRef, relation_type: str) -> None
svc.items.untag(item: ItemRef, tag: TagRef) -> None
svc.items.update(item: ItemRef, *, name: str | UnsetType = UNSET, slug: str | UnsetType = UNSET, external_id: ExternalId | UnsetType = UNSET, enabled: bool | UnsetType = UNSET, metadata: dict[str, Any] | UnsetType = UNSET, expected_version: int | None = None) -> Item
## svc.tags: TagCollection
svc.tags.create(name: str, *, metadata: dict[str, Any] | None = None) -> Tag
svc.tags.delete(key: TagRef, /) -> None
svc.tags.get(key: TagRef, default: D | None = None, /) -> Tag | D | None
svc.tags.get_many(tags: TagRef | Collection[TagRef], /) -> Mapping[UUID, Tag]
svc.tags.list(*, item: ItemRef | None = None) -> Sequence[Tag]
svc.tags.update(tag: TagRef, *, name: str | UnsetType = UNSET, metadata: dict[str, Any] | UnsetType = UNSET) -> Tag
## The graph and related items
TaxomeshGraph.get(key: CategoryRef, default: D | None = None, /) -> CategoryNode | D | None
TaxomeshGraph.roots: property -> Sequence[CategoryNode]
TaxomeshGraph.walk() -> Iterator[CategoryNode]
CategoryNode.ancestors() -> Sequence[CategoryNode]
CategoryNode.category: property -> Category
CategoryNode.children: property -> Sequence[CategoryNode]
CategoryNode.descendants() -> Sequence[CategoryNode]
CategoryNode.items: property -> Sequence[Item]
CategoryNode.parents: property -> Sequence[CategoryNode]
RelatedItems.item_id: UUID
RelatedItems.by_type: Mapping[str, Sequence[Item]]
RelatedItems.of_type(relation_type: str) -> Sequence[Item]
RelatedItems.relation_types: property -> Sequence[str]
## Rows, all frozen
Category(category_id: UUID, name: str, description: str, enabled: bool, external_id: str | None, slug: str, metadata: dict[str, Any], created_at: datetime, updated_at: datetime, version: int)
Item(item_id: UUID, name: str, external_id: str | None, slug: str, enabled: bool, metadata: dict[str, Any], created_at: datetime, updated_at: datetime, version: int)
Tag(tag_id: UUID, name: str, metadata: dict[str, Any])
CategoryParentLink(category_id: UUID, parent_category_id: UUID, sort_index: int)
ItemParentLink(item_id: UUID, category_id: UUID, sort_index: int)
ItemRelationLink(source_item_id: UUID, target_item_id: UUID, relation_type: str, sort_index: int, metadata: dict[str, Any])
ItemTagLink(tag_id: UUID, item_id: UUID)
TaxomeshInfo(version: str, config_name: str | None, item_corpus_size: int | None, category_corpus_size: int | None, repository: RepositoryInfo)
RepositoryInfo(backend: str, path: str | None, diagnostics: Mapping[str, str | int | None])
## Errors
TaxomeshError(Exception)
TaxomeshNotFoundError(TaxomeshError, KeyError): TaxomeshCategoryNotFoundError, TaxomeshItemNotFoundError, TaxomeshTagNotFoundError
TaxomeshValidationError(TaxomeshError, ValueError): TaxomeshCyclicDependencyError, TaxomeshDuplicateSlugError, TaxomeshExternalIdConflictError, TaxomeshRelationError, TaxomeshRootCategoryError
TaxomeshRepositoryError(TaxomeshError)
TaxomeshConfigError(TaxomeshError)
TaxomeshVersionConflictError(TaxomeshError)
TaxomeshGraphTooLargeError(TaxomeshError), in taxomesh.contrib.api.errors
## TaxomeshRepositoryBase: the storage port, implemented only by a custom backend
add_item_tag_link(item_id: UUID, tag_id: UUID) -> None
atomic() -> AbstractContextManager[None]
config_summary: property -> str
delete_category(category_id: UUID) -> bool
delete_category_parent_link(category_id: UUID, parent_category_id: UUID) -> bool
delete_item(item_id: UUID) -> bool
delete_item_parent_link(item_id: UUID, category_id: UUID) -> bool
delete_item_relation_link(source_item_id: UUID, target_item_id: UUID, relation_type: str) -> bool
delete_item_tag_link(item_id: UUID, tag_id: UUID) -> bool
delete_tag(tag_id: UUID) -> bool
describe() -> RepositoryInfo
find_category(category_id: UUID) -> Category | None
find_category_by_external_id(external_id: str) -> Category | None
find_category_by_slug(slug: str) -> Category | None
find_item(item_id: UUID) -> Item | None
find_item_by_external_id(external_id: str) -> Item | None
find_item_by_slug(slug: str) -> Item | None
find_tag(tag_id: UUID) -> Tag | None
in_transaction: property -> bool
list_categories(*, enabled: bool | None = True) -> Sequence[Category]
list_category_parent_links(*, category_ids: Collection[UUID] | None = None, parent_category_ids: Collection[UUID] | None = None) -> Sequence[CategoryParentLink]
list_item_parent_links(*, item_ids: Collection[UUID] | None = None, category_ids: Collection[UUID] | None = None) -> Sequence[ItemParentLink]
list_item_relation_links(item_id: UUID, *, relation_types: Collection[str] | None = None, direction: Literal['outgoing', 'incoming', 'both'] = 'outgoing') -> Sequence[ItemRelationLink]
list_item_relation_links_batch(item_ids: Collection[UUID], *, relation_types: Collection[str] | None = None, direction: Literal['outgoing', 'incoming', 'both'] = 'outgoing') -> Sequence[ItemRelationLink]
list_item_tag_links(*, item_ids: Collection[UUID] | None = None, tag_ids: Collection[UUID] | None = None) -> Sequence[ItemTagLink]
list_items(*, enabled: bool | None = True) -> Sequence[Item]
list_tags() -> Sequence[Tag]
map_categories_by_external_id(external_ids: Collection[str], *, enabled: bool | None = None) -> Mapping[str, Category]
map_categories_by_id(category_ids: Collection[UUID], *, enabled: bool | None = None) -> Mapping[UUID, Category]
map_items_by_external_id(external_ids: Collection[str], *, enabled: bool | None = None) -> Mapping[str, Item]
map_items_by_id(item_ids: Collection[UUID], *, enabled: bool | None = None) -> Mapping[UUID, Item]
map_tags_by_id(tag_ids: Collection[UUID]) -> Mapping[UUID, Tag]
save_category(category: Category, *, expected_version: int | None = None) -> Category
save_category_parent_link(link: CategoryParentLink) -> None
save_item(item: Item, *, expected_version: int | None = None) -> Item
save_item_parent_link(link: ItemParentLink) -> None
save_item_relation_link(link: ItemRelationLink) -> None
save_tag(tag: Tag) -> None
<!-- END GENERATED -->

## Docs
- [Python API](https://github.com/ediazpacheco/taxomesh/blob/main/docs/python-api.md): every member, with runnable examples
- [HTTP API integration](https://github.com/ediazpacheco/taxomesh/blob/main/docs/http-api-integration.md): handlers named `<namespace>_<member>`, and `errors.to_tuple`
- [CLI](https://github.com/ediazpacheco/taxomesh/blob/main/docs/cli.md), [Repositories](https://github.com/ediazpacheco/taxomesh/blob/main/docs/repositories.md), [Configuration](https://github.com/ediazpacheco/taxomesh/blob/main/docs/configuration.md), [Django integration](https://github.com/ediazpacheco/taxomesh/blob/main/docs/django-integration.md)
