Metadata-Version: 2.4
Name: taxomesh
Version: 0.1.0a14
Summary: Flexible taxonomy management for generic items — categories, tags, and multi-parent hierarchies with pluggable storage.
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
License: MIT
License-File: LICENSE
Keywords: categorization,dag,hierarchy,repository,tags,taxonomy
Classifier: Development Status :: 2 - Pre-Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Database
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: fastapi>=0.110
Requires-Dist: pyyaml>=6.0
Requires-Dist: rich>=13.0
Requires-Dist: typer>=0.12
Provides-Extra: dev
Requires-Dist: fastapi>=0.110; extra == 'dev'
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pre-commit>=3.0; extra == 'dev'
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest-django>=4.8; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: pyyaml>=6.0; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Requires-Dist: types-pyyaml>=6.0; extra == 'dev'
Provides-Extra: django
Requires-Dist: django>=4.2; extra == 'django'
Description-Content-Type: text/markdown

# taxomesh

Flexible taxonomy management for generic items with:

- multi-parent category DAGs
- per-parent sort ordering
- free-form item tags
- pluggable storage backends (YAML, JSON, Django)

`taxomesh` is **storage-agnostic by design**.

Switch from a JSON file to Django-backed storage or a custom remote backend
without touching a single line of your application code.

The goal of this library is to avoid re-implementing common taxonomy workflows
and provide a plug-and-play component for your application.

[![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/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Status: Pre-Alpha](https://img.shields.io/badge/status-pre--alpha-orange.svg)]()

## Status

`taxomesh` is currently **pre-alpha** (`0.1.x`).
API and behavior can still change between releases.

## Installation

Requires **Python 3.11+**.

```bash
pip install taxomesh
```

Optional Django integration:

```bash
pip install "taxomesh[django]"
```

## Quick start

```python
from taxomesh import TaxomeshService

svc = TaxomeshService()  # auto-discovers taxomesh.toml, else defaults to YAMLRepository(data/taxomesh.yaml)

music = svc.create_category(name="Music")
jazz = svc.create_category(name="Jazz")
svc.add_category_parent(jazz.category_id, music.category_id, sort_index=1)

kind_of_blue = svc.create_item(external_id=42)
svc.place_item_in_category(kind_of_blue.item_id, jazz.category_id, sort_index=1)

print(kind_of_blue.external_id)  # "42" (normalized to str)
print([node.category.name for node in svc.get_graph().roots])
```

## Core concepts

- **Item**: the core catalogued object, identified by an internal `item_id`. The optional `external_id` field is an escape hatch: use it to store a reference to an entity that lives outside taxomesh (e.g. a primary key from another system) when the built-in fields (`name`, `slug`, `enabled`, `metadata`) are not enough to represent your data. It is not required — items can exist without any external reference.
- **Category**: taxonomy node with optional `name`, `description`, `metadata`, `external_id`, `enabled`, and optional unique `slug`
- **Tag**: free-form item label
- **ItemRelationLink**: directed, typed relation between two items (e.g. `covers`, `version_of`, `performed_by`)
- **CategoryParentLink**: relation from category to parent category with `sort_index`
- **ItemParentLink**: relation from item to category with `sort_index`
- **TaxomeshGraph**: read snapshot returned by `get_graph()` for tree-like traversal
- **Repository protocol**: `TaxomeshRepositoryBase` (`typing.Protocol`) defines the storage contract

## Django integration

Use this when taxomesh should run inside a Django project database and admin.

### Enable admin-backed Django models

1. Install the Django extra (if not already installed):

```bash
pip install "taxomesh[django]"
```

2. Add the contrib app:

```python
# settings.py
INSTALLED_APPS = [
    # ...
    "taxomesh.contrib.django",
]
```

3. Run migrations:

```bash
python manage.py migrate
```

After migrating, Django admin exposes taxomesh models out of the box:
`CategoryModel`, `ItemModel`, and `TagModel`.

**Admin graph features (0.1.0a12)**:

- The taxonomy graph view (`/admin/taxomesh_contrib_django/graph/`) renders the top 3 levels
  of the taxonomy by default (configurable via `ADMIN_GRAPH_DEFAULT_MAX_DEPTH` in admin.py).
- Item relations are always rendered but collapsed per-item; click the `[+]` button next to
  an item to expand its outgoing relations.  The global "Show item relations" checkbox has
  been removed.
- When `TAXOMESH_LINKED_MODEL = "app.Model"` is set in Django settings, Item and Category
  list views and detail pages show a ↗ icon-link to the corresponding Django admin page for
  any row whose `external_id` matches a primary key in the linked model.
- The taxomesh app_index page shows the installed taxomesh version and active backend.

### Integrate with your app models

Example: mirror a Django model into taxomesh by `external_id`.

```python
# content_catalog/taxomesh_bridge.py
from taxomesh.contrib.django import get_taxomesh_service_with_django


def ensure_item_for_external_id(external_id: str) -> None:
    svc = get_taxomesh_service_with_django()
    if not svc.get_items_by_external_id(external_id):
        svc.create_item(external_id=external_id)


def delete_items_for_external_id(external_id: str) -> None:
    svc = get_taxomesh_service_with_django()
    for item in svc.get_items_by_external_id(external_id):
        svc.delete_item(item.item_id)
```

```python
# content_catalog/models.py
from uuid import uuid4
from django.db import models

from content_catalog.taxomesh_bridge import (
    delete_items_for_external_id,
    ensure_item_for_external_id,
)


class Content(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
    title = models.CharField(max_length=255)

    def save(self, *args, **kwargs):
        super().save(*args, **kwargs)
        ensure_item_for_external_id(str(self.id))

    def delete(self, *args, **kwargs):
        delete_items_for_external_id(str(self.id))
        return super().delete(*args, **kwargs)
```

If you need lower-level control, use `DjangoRepository` directly (see
the **Repositories** section below).

## Python API

### Categories

```python
from taxomesh import TaxomeshService

svc = TaxomeshService()

root = svc.create_category(name="Root Topic")
child = svc.create_category(name="Child Topic", slug="child-topic")
svc.add_category_parent(child.category_id, root.category_id, sort_index=10)

children = svc.list_categories(parent_id=root.category_id)
updated = svc.update_category(child.category_id, description="Updated")
svc.delete_category(updated.category_id)

# Look up by slug
cat = svc.get_category_by_slug("child-topic")  # raises TaxomeshCategoryNotFoundError if missing
```

### Items

```python
from uuid import uuid4

item_a = svc.create_item(name="Article", external_id=123, slug="article-123")
item_b = svc.create_item(name="Track", external_id=uuid4())
item_c = svc.create_item(name="Post", external_id="article-abc")

svc.update_item(item_a.item_id, enabled=False)
all_items = svc.list_items()

# Look up by slug
item = svc.get_item_by_slug("article-123")  # raises TaxomeshItemNotFoundError if missing
```

### Tags

```python
tag = svc.create_tag(name="featured")
svc.assign_tag(tag.tag_id, item_c.item_id)    # idempotent
svc.remove_tag(tag.tag_id, item_c.item_id)    # no-op if already removed
svc.delete_tag(tag.tag_id)
```

### Item relations

`ItemRelationLink` models a **directed, typed relation** between two items.
The relation type is a free-form string — taxomesh imposes no domain-specific vocabulary.

```python
# Create relations
svc.relate_items(work.item_id, artist.item_id, "music_by")
svc.relate_items(recording.item_id, work.item_id, "version_of", sort_index=1)
svc.relate_items(release.item_id, recording.item_id, "contains", metadata={"disc": 1})

# List outgoing relations from an item
links = svc.list_item_relations(recording.item_id)
# [ItemRelationLink(source=recording, target=work, relation_type="version_of"), ...]

# List incoming relations (items that point TO this item)
links = svc.list_item_relations(work.item_id, direction="incoming")

# Filter by type
links = svc.list_item_relations(recording.item_id, relation_type="version_of")

# Resolve to Item objects directly
items = svc.list_related_items(release.item_id)  # returns list[Item]

# Remove a relation
svc.remove_item_relation(recording.item_id, work.item_id, "version_of")
```

**Upsert semantics**: calling `relate_items` with the same `(source, target, relation_type)` triple
updates `sort_index` and `metadata` rather than creating a duplicate.

**Self-relations** are rejected. **Empty relation type** raises `TaxomeshValidationError`.

Relations are stored in all backends (YAML, JSON, Django). Django admin shows
editable outgoing and read-only incoming relation inlines on the Item change page.
The standalone `ItemRelationLinkModelAdmin` list has been removed in 0.1.0a12 — use the
Item inlines instead.

### Graph snapshot

```python
graph = svc.get_graph()
for node in graph.roots:
    print(node.category.name)
```

### Slug lookup

```python
from taxomesh.exceptions import TaxomeshCategoryNotFoundError, TaxomeshItemNotFoundError

cat = svc.get_category_by_slug("child-topic")   # returns Category or raises TaxomeshCategoryNotFoundError
item = svc.get_item_by_slug("article-123")       # returns Item or raises TaxomeshItemNotFoundError
```

Slugs are optional URL-friendly identifiers. They must be unique within their namespace
(categories or items). Both methods raise a typed not-found exception — they never return `None`.

### External ID lookup helpers

```python
items = svc.get_items_by_external_id("article-abc")
categories = svc.get_categories_by_external_id("legacy-category-id")
```

These methods are useful for integrations where domain entities live outside taxomesh.

## Repositories

Any class implementing `TaxomeshRepositoryBase` can be used.
`TaxomeshRepositoryBase` is defined as a `typing.Protocol`.
No inheritance is required (structural typing / protocol-based compatibility).

### YAMLRepository

Default backend when no repository is configured.
Uses atomic writes.

```python
from pathlib import Path
from taxomesh.adapters.repositories.yaml_repository import YAMLRepository

svc = TaxomeshService(repository=YAMLRepository(Path("data/taxomesh.yaml")))
```

### JsonRepository

File-backed JSON backend with atomic writes.

```python
from pathlib import Path
from taxomesh.adapters.repositories.json_repository import JsonRepository

svc = TaxomeshService(repository=JsonRepository(Path("data/taxomesh.json")))
```

### DjangoRepository

ORM-backed backend for Django projects.
If Django integration is already configured (see **Django integration** above),
use `DjangoRepository` directly when you want explicit repository wiring:

```python
from taxomesh.adapters.repositories.django_repository import DjangoRepository

svc = TaxomeshService(repository=DjangoRepository())
```

## Configuration (`taxomesh.toml`)

`taxomesh.toml` is optional.
If present, `TaxomeshService()` reads it from the current working directory.

YAML:

```toml
[repository]
type = "yaml"
path = "data/taxomesh.yaml"
```

JSON:

```toml
[repository]
type = "json"
path = "data/taxomesh.json"
```

Django:

```toml
[repository]
type = "django"
using = "default"
```

See also: [`taxomesh.toml.example`](./taxomesh.toml.example)

## CLI

After installation, the `taxomesh` command is available.

### Common commands

```bash
# Categories
taxomesh category add --name "Music"
taxomesh category list
taxomesh category update <category-uuid> --name "World Music"
taxomesh category delete <category-uuid>

# Items
taxomesh item add --external-id "kind-of-blue"
taxomesh item add-to-category <item-uuid> --category-id <category-uuid>
taxomesh item list --category-id <category-uuid>
taxomesh item update <item-uuid> --disable
taxomesh item delete <item-uuid>

# Tags
taxomesh tag add --name "classic"
taxomesh item add-to-tag <item-uuid> --tag-id <tag-uuid>
taxomesh tag list

# Relations
taxomesh item relation add <source-uuid> <target-uuid> covers
taxomesh item relation add <source-uuid> <target-uuid> version_of --sort-index 1 --metadata key=value
taxomesh item relation list <item-uuid>
taxomesh item relation list <item-uuid> --direction incoming
taxomesh item relation list <item-uuid> --type covers
taxomesh item relation related <item-uuid>
taxomesh item relation related <item-uuid> --direction incoming
taxomesh item relation delete <source-uuid> <target-uuid> covers

# Graph (shows top 3 levels by default)
taxomesh graph
taxomesh graph --max-depth 0          # show all levels (unlimited)
taxomesh graph --max-depth 1          # root categories only
taxomesh graph --show-relations       # include outgoing item relations
taxomesh graph --max-depth 5 --show-relations
```

`--max-depth N` limits the depth of the rendered tree.  Depth 0 = root categories; items
inside a category at depth D are at depth D+1.  Pass `--max-depth 0` to disable the limit
(default is 3).

Example output:

```text
Taxonomy
└── Music  11111111-1111-1111-1111-111111111111  ✓
    └── Jazz  22222222-2222-2222-2222-222222222222  ✓
        └── kind-of-blue  33333333-3333-3333-3333-333333333333  ✓
```

Verbose diagnostics:

```bash
taxomesh --verbose category list
```

## When to use categories vs tags vs item relations

| Mechanism | Use when |
|-----------|----------|
| **Category** | Hierarchical classification — items belong to a taxonomy node |
| **ItemParentLink** | Placing an item inside a category (multi-parent supported) |
| **Tag** | Flat, free-form labels applied to items (e.g. "featured", "archived") |
| **ItemRelationLink** | Directed, semantic relationships *between items* (e.g. one recording is a `version_of` a work; a release `contains` a recording) |

Choose `ItemRelationLink` when:
- The relationship is between two items (not item → category)
- The relationship has a meaningful direction (source → target)
- You need to express multiple relationship *types* between the same pair of items
- The relationship carries additional data (`sort_index`, `metadata`)

## Error model

All library exceptions inherit from `TaxomeshError`.

- `TaxomeshNotFoundError`
  - `TaxomeshCategoryNotFoundError`
  - `TaxomeshItemNotFoundError`
  - `TaxomeshTagNotFoundError`
- `TaxomeshValidationError`
  - `TaxomeshCyclicDependencyError`
  - `TaxomeshDuplicateSlugError`
- `TaxomeshRepositoryError`
- `TaxomeshConfigError`
- `TaxomeshRootCategoryError`
- `TaxomeshRelationError`

## Architecture

`taxomesh` uses a ports-and-adapters (hexagonal) shape:

- **Domain**: pure models and DAG validation
- **Application**: `TaxomeshService` orchestration
- **Ports**: repository protocol (`TaxomeshRepositoryBase`)
- **Adapters**: YAML/JSON/Django repositories + CLI

## Development

```bash
uv sync --dev
uv run pytest
uv run ruff check .
uv run mypy .
```

## Contributing

Contributions are welcome.
This project follows a spec-first workflow. Please align implementation PRs with the `specs/` directory.

## License

MIT. See [LICENSE](LICENSE).
