Metadata-Version: 2.5
Name: taxomesh
Version: 1.0.0
Summary: 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.
Project-URL: Homepage, https://github.com/ediazpacheco/taxomesh
Project-URL: Repository, https://github.com/ediazpacheco/taxomesh
Project-URL: Issues, https://github.com/ediazpacheco/taxomesh/issues
Project-URL: Python API, https://github.com/ediazpacheco/taxomesh/blob/main/docs/python-api.md
Project-URL: HTTP API integration, https://github.com/ediazpacheco/taxomesh/blob/main/docs/http-api-integration.md
Project-URL: Django integration, https://github.com/ediazpacheco/taxomesh/blob/main/docs/django-integration.md
Project-URL: Repositories, https://github.com/ediazpacheco/taxomesh/blob/main/docs/repositories.md
Project-URL: Configuration, https://github.com/ediazpacheco/taxomesh/blob/main/docs/configuration.md
Project-URL: CLI reference, https://github.com/ediazpacheco/taxomesh/blob/main/docs/cli.md
Author-email: Ezequiel Diaz Pacheco <ediazpacheco@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: categorization,dag,hierarchy,repository,tags,taxonomy
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Database
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.13
Requires-Dist: pydantic>=2.0
Requires-Dist: pyyaml>=6.0
Requires-Dist: rapidfuzz>=3.0
Provides-Extra: cli
Requires-Dist: rich>=13.0; extra == 'cli'
Requires-Dist: typer>=0.12; extra == 'cli'
Provides-Extra: django
Requires-Dist: django<7,>=6.0; extra == 'django'
Description-Content-Type: text/markdown

# 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.

[![CI](https://github.com/ediazpacheco/taxomesh/actions/workflows/ci.yml/badge.svg)](https://github.com/ediazpacheco/taxomesh/actions/workflows/ci.yml)
[![PyPI version](https://img.shields.io/pypi/v/taxomesh.svg)](https://pypi.org/project/taxomesh/)
[![Python versions](https://img.shields.io/pypi/pyversions/taxomesh.svg)](https://pypi.org/project/taxomesh/)
[![Typed](https://img.shields.io/badge/types-mypy--strict-blue.svg)](https://github.com/ediazpacheco/taxomesh)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/ediazpacheco/taxomesh/blob/main/LICENSE)

## What it is

taxomesh stores a taxonomy for records that your own system keeps, such as products, articles or
tracks. It is not a database for the records: each record stays in your system, and an item or a
category can stand for it through its `external_id`. taxomesh stores categories with any number of
parents, items placed in any number of categories, an order under each parent, tags, and typed
relations between items. It also searches items and categories. The `taxomesh` command, the Django
admin with its graph view, and the HTTP handlers, which need no web framework, call `TaxomeshService`.

Use it when the taxonomy needs its own component, for example for navigation that is not a strict
tree, one item in several places, an order that depends on the place, or one set of rules for
scripts, admin tools and APIs. It is not needed for a flat list of choices, a tree in which each
category has one parent, a small fixed part of one screen, or a taxonomy with no need for reusable
validation, traversal or integrations.

## Installation

Requires **Python 3.13+**.

```bash
pip install taxomesh             # the library
pip install "taxomesh[cli]"      # with the taxomesh command (typer, rich)
pip install "taxomesh[django]"   # with the Django ORM backend and admin
```

## Quick start

```python
from pathlib import Path

from taxomesh import TaxomeshService
from taxomesh.repositories import YamlRepository

Path("quickstart.yaml").unlink(missing_ok=True)  # each run starts with an empty store
svc = TaxomeshService(YamlRepository("quickstart.yaml"))

# Categories form a graph: Jazz has two parents, Music and Genres.
music = svc.categories.create(name="Music")
genres = svc.categories.create(name="Genres")
jazz = svc.categories.create(name="Jazz")
svc.categories.add_parent(jazz, music)
svc.categories.add_parent(jazz, genres)

# An item stands for a record of yours, and is found again by its external id.
album = svc.items.create(name="Kind of Blue", external_id="catalog:42")
svc.items.place_in(album, jazz)
featured = svc.tags.create(name="featured")
svc.items.tag(album, featured)

# Read it with the forms a dict has: a row, or its UUID, is the key.
assert svc.items.get_by_external_id("catalog:42").item_id == album.item_id
assert svc.items[album.item_id].name == "Kind of Blue"  # subscript raises when absent
assert svc.items.get_by_slug("no-such-slug") is None  # get* never raises
assert jazz in svc.categories and len(svc.categories) == 3
assert [c.name for c in svc.categories.list(item=album)] == ["Jazz"]
assert [i.name for i in svc.items.list(tag=featured)] == ["Kind of Blue"]
assert [c.name for c in svc.categories.roots()] == ["Music", "Genres"]  # in the order they were made

node = svc.graph()[jazz]  # a read-only snapshot of the whole taxonomy
assert sorted(parent.category.name for parent in node.parents) == ["Genres", "Music"]
```

With no argument, `TaxomeshService()` reads `taxomesh.toml` in the working directory, and without one
keeps its store in `data/taxomesh.yaml`, between runs.

## The forms

`coll` is `svc.categories`, `svc.items` or `svc.tags`, and a key is a row or its `UUID`. Tags have
no slug, external id or search:

| Form | Returns | On a miss |
|---|---|---|
| `coll[key]` | the row | raises `Taxomesh<X>NotFoundError`, a `KeyError` |
| `coll.get(key, default=None)` | the row | `default` |
| `key in coll` | `bool` | `False` |
| `len(coll)` | `int` | `0` |
| `del coll[key]` | `None` | raises |
| `coll.get_by_slug(s)`, `coll.get_by_external_id(x)` | the row | `None` |
| `coll.get_many(keys)` | a new `dict` | the key is left out |
| `coll.list(...)`, `coll.search(...)` | a `tuple` | `()`; a filter naming an entity that is not stored raises |

The collections do not iterate ([why](https://github.com/ediazpacheco/taxomesh/blob/main/docs/design.md#the-law-subscript-raises-get-never-does)):
`coll.list(...)` enumerates, and `len()` is the one form that reads every row.

## What it does

- **Multi-parent categories**: a category has any number of parents, and a cycle raises
  `TaxomeshCyclicDependencyError`.
- **An order under each parent**: every parent link and placement carries its own `sort_index`,
  which `reorder` and `move` change.
- **External ids**: `str`, `int` or `UUID`, stored as text and unique within its kind, and looked
  up one at a time or in bulk.
- **Tags and typed relations**: free-form tags, and directed item-to-item relations (`covers`,
  `version_of`) read in either direction, for many items at once in two storage reads.
- **Fuzzy search**: ranked, typo-tolerant and accent-insensitive, on names and slugs; an external id
  matches as a substring only. It runs in the process, with rapidfuzz, and needs no search server.
- **Graph snapshots**: `svc.graph()` holds one node per category, however many paths reach it.
- **Frozen rows**: `update` returns a new row, and `expected_version` makes it conditional.
- **A cache per service**: reads are cached for `cache_ttl` seconds, 5 by default, and a write
  clears its own service's cache only.
- **Storage**: YAML (the default), JSON or the Django ORM, chosen in code or in a `taxomesh.toml`,
  or your own backend implementing `TaxomeshRepositoryBase`. One contract suite holds every shipped
  backend to that port.
- **Errors**: a member raises a `TaxomeshError`, a not-found one also a `KeyError` and a validation
  one also a `ValueError`, or a `TypeError` for an argument of the wrong type, except that the flags
  `recursive`, `fuzzy` and `include_items` are read as `if flag:`. Log records go to the `taxomesh`
  logger, which has only a `NullHandler` of its own.

## Documentation

The whole Python API is on one page ([`llms.txt`](https://github.com/ediazpacheco/taxomesh/blob/main/llms.txt)), so an AI coding agent can use taxomesh without reading its source.

| Page | Covers |
|---|---|
| [Python API](https://github.com/ediazpacheco/taxomesh/blob/main/docs/python-api.md) | Every member, with runnable examples |
| [Repositories](https://github.com/ediazpacheco/taxomesh/blob/main/docs/repositories.md) | The three backends, and writing your own |
| [Configuration](https://github.com/ediazpacheco/taxomesh/blob/main/docs/configuration.md) | `taxomesh.toml` |
| [CLI reference](https://github.com/ediazpacheco/taxomesh/blob/main/docs/cli.md) | Every command and global option |
| [Django integration](https://github.com/ediazpacheco/taxomesh/blob/main/docs/django-integration.md) | The app, the admin, and bridging your models |
| [HTTP API integration](https://github.com/ediazpacheco/taxomesh/blob/main/docs/http-api-integration.md) | Request schemas, handlers, serializers and error mapping |
| [Design](https://github.com/ediazpacheco/taxomesh/blob/main/docs/design.md) | The architecture and the decisions behind the surface, for contributors |
| [Changelog](https://github.com/ediazpacheco/taxomesh/blob/main/CHANGELOG.md) | What each release changed |

## Stability and versioning

taxomesh uses [Semantic Versioning](https://semver.org/): what `taxomesh`, `taxomesh.repositories`,
`taxomesh.contrib.api` and `taxomesh.contrib.django` export, and the repository port, change
incompatibly only in a major release. The [changelog](https://github.com/ediazpacheco/taxomesh/blob/main/CHANGELOG.md)
names every breaking change.

taxomesh supports Python 3.13 and 3.14, and Django 6.x. CI checks every change to `main` with
`ruff`, `mypy --strict` over the package and its tests, the Django integration included, and the full
test suite with at least 80% line coverage, a measure that leaves out the Django integration, which
its own tests exercise.

## Used in production

taxomesh is used in production at [LetrasTango](https://letrastango.com/) to organise the categories
and relations of its tango content.

## Development

```bash
uv sync --all-extras
uv run ruff check .
uv run ruff format --check .
uv run mypy --strict .
uv run pytest
```

Contributions are welcome: see [CONTRIBUTING.md](https://github.com/ediazpacheco/taxomesh/blob/main/CONTRIBUTING.md) for the setup and the quality gates.
Licensed under [MIT](https://github.com/ediazpacheco/taxomesh/blob/main/LICENSE).
