Metadata-Version: 2.4
Name: arcanus
Version: 0.0.11
Summary: Sticker to bind pydantic schemas with various datasources
Project-URL: Homepage, https://github.com/kalynnka/arcanus
Project-URL: Repository, https://github.com/kalynnka/arcanus
Project-URL: Issues, https://github.com/kalynnka/arcanus/issues
Author-email: arcanus <arcanus@example.com>
License: MIT License
        
        Copyright (c) 2025 Lu Hui
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
License-File: LICENSE
Keywords: database,datasource,orm,pydantic,schema,sqlalchemy,sqlmodel
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Database
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.11
Requires-Dist: greenlet>=3.2.4
Requires-Dist: pydantic>=2.11.7
Requires-Dist: sqlalchemy>=2.0.0
Provides-Extra: dev
Requires-Dist: asyncpg>=0.28.0; extra == 'dev'
Requires-Dist: mypy>=1.0.0; extra == 'dev'
Requires-Dist: pre-commit>=3.0.0; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
Requires-Dist: pytest>=7.0.0; extra == 'dev'
Description-Content-Type: text/markdown

# Arcanus

[![Tests](https://github.com/kalynnka/arcanus/actions/workflows/tests.yml/badge.svg)](https://github.com/kalynnka/arcanus/actions/workflows/tests.yml)
[![Codecov](https://codecov.io/gh/kalynnka/arcanus/branch/main/graph/badge.svg)](https://codecov.io/gh/kalynnka/arcanus)
[![CodSpeed](https://img.shields.io/endpoint?url=https://codspeed.io/badge.json)](https://codspeed.io/kalynnka/arcanus?utm_source=badge)
[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

**Arcanus** is a Python library designed to seamlessly bind Pydantic schemas with various datasources, eliminating the need to manually create templates, factories, and utilities repeatedly. It provides a unified interface for working with different data backends while maintaining type safety and validation through Pydantic.

> **⚠️ Warning:** This repository is still a work in progress and is currently at a minimum viable state. Expect bugs, breaking changes, and incomplete features.

> **⚠️ Note:** At the moment, SQLAlchemy is the only supported provider and is hardcoded as the default backend.

## Features

- 🔄 **Unified Interface**: Work with different data backends through a consistent API with Pydantic
- 🛡️ **Type Safety**: Full Pydantic validation and type checking
- 🔗 **Relationship Management**: Intuitive handling of one-to-one, one-to-many, and many-to-many relationships
- ⚡ **Async Support**: Native async/await support for SQLAlchemy
- 🎯 **Multiple Materia**: NoOpMateria for testing, SQLAlchemy Materia for database operations
- 📦 **Partial Models**: Built-in support for Create/Update operations

## Materia Types

Arcanus supports different "Materia" backends to handle data:

### NoOpMateria

A no-operation materia that's perfect for testing and development. It allows working with Pydantic models without any backend, making it ideal for unit tests and prototyping.

> **Note:** NoOpMateria is automatically active by default - no manual blessing required! Simply define transmuter classes and they'll work without any backend setup.

```python
from arcanus.base import BaseTransmuter, Identity
from arcanus.association import Relation, RelationCollection, Relationships
from pydantic import Field
from typing import Annotated, Optional

class Author(BaseTransmuter):
    id: Annotated[Optional[int], Identity] = Field(default=None, frozen=True)
    name: str
    field: str

    books: RelationCollection[Book] = Relationships()

class Book(BaseTransmuter):
    id: Annotated[Optional[int], Identity] = Field(default=None, frozen=True)
    title: str
    year: int
    author_id: int | None = None

    author: Relation[Author] = Relationships()

# Use them like regular Pydantic models
author = Author(id=1, name="Isaac Asimov", field="Science Fiction")
book = Book(id=1, title="Foundation", year=1951, author=Relation(author))

# Access relationships
print(book.author.value.name)  # Isaac Asimov
print(list(author.books))  # [Book(...)]
```

### Dataclass Transmuters

In addition to `BaseTransmuter` (which extends `BaseModel`), arcanus provides a
`@dataclass` decorator for lightweight transmuters built on **pydantic dataclasses**.

```python
from arcanus import dataclass

@dataclass
class SimpleTag:
    """A simple tag with no associations."""
    label: str

tag = SimpleTag(label="example")
tag.revalidate()          # transmuter method, injected at runtime
print(SimpleTag.Create)   # partial model, also available
```

The `@dataclass` decorator handles pydantic dataclass creation **and** transmuter
protocol injection automatically. It accepts plain classes, stdlib dataclasses,
and pydantic dataclasses as input.

#### Inheriting `Transmuter` (optional)

By default, `@dataclass` injects `Transmuter` as a base class **at runtime**.
This means transmuter methods (`revalidate()`, `Create`, `Update`, `shell()`,
`absorb()`, etc.) are always available on instances regardless of how the class
is declared.

However, since Python's type system cannot express intersection types
(`type[T & Transmuter]`), type checkers like pyright won't see the
transmuter-specific attributes unless you explicitly inherit from `Transmuter`:

```python
from arcanus import Transmuter, dataclass

# Option 1: Minimal — fields work, transmuter attrs not visible to type checkers
@dataclass
class Foo:
    name: str

foo = Foo(name="bar")
foo.revalidate()          # ✅ works at runtime
foo.revalidate()          # ❌ pyright: "revalidate" is not a known member

# Option 2: Full type safety — inherit Transmuter
@dataclass
class Bar(Transmuter):
    name: str

bar = Bar(name="baz")
bar.revalidate()          # ✅ works at runtime
bar.revalidate()          # ✅ pyright sees it
Bar.Create                # ✅ pyright sees it
```

Both forms are fully functional at runtime — the only difference is static type
visibility. Choose whichever style suits your project.

#### Rebuilding Forward References

When a `@dataclass` transmuter uses forward references to types defined later in
the module (just like `BaseModel.model_rebuild()` in pydantic), you must call
`rebuild_dataclass` after all referenced types are available:

```python
from arcanus import Transmuter, dataclass, rebuild_dataclass
from arcanus.base import BaseTransmuter
from arcanus.association import Relation, Relationship

# Category references Book, which isn't defined yet
@dataclass
class Category(Transmuter):
    name: str
    books: RelationCollection[Book] = Relationships()  # forward ref

# Book is defined after Category
class Book(BaseTransmuter):
    title: str
    categories: RelationCollection[Category] = Relationships()

# Resolve Category → Book now that Book exists
rebuild_dataclass(Category)
```

Forward references between dataclass transmuters are retried automatically when
new `@dataclass` transmuters are created. The explicit `rebuild_dataclass` call
is only needed when a dataclass transmuter references a `BaseTransmuter`
(BaseModel) type that is defined later.

### SQLAlchemy Materia

Connect schemas to SQLAlchemy ORM models for full database functionality, **enabling operations on Pydantic transmuter objects just like ORM objects**, seamlessly gluing together the best of both worlds.

> **⚠️ Important:** Use `arcanus.database.Session` instead of SQLAlchemy's native `sqlalchemy.orm.Session`. The arcanus Session handles the automatic "blessing" of ORM objects into transmuter schemas.

#### Bridging Pydantic and SQLAlchemy

Traditional Pydantic + SQLAlchemy patterns often involve some friction:

- **Manual conversion**: Validating Pydantic models and then converting them to ORM objects
- **Object duality**: Juggling both ORM objects and Pydantic objects throughout the codebase
- **Relationship complexity**: Managing relationships across two separate object systems
- **Boilerplate code**: Writing conversion utilities and factory functions

**SQLAlchemy Materia aims to reduce this friction** by:

**Work with unified objects** - Transmuter schemas are backed by ORM objects, reducing the need for manual conversion.

**Bi-directional sync** - Changes to transmuter objects reflect in the underlying ORM object and vice versa.

**Relationship handling** - Relationships can be accessed through Pydantic models with lazy loading support handled behind the scenes.

**Combined benefits** - Pydantic's validation and type checking work alongside SQLAlchemy's query capabilities.

**Single interface** - One consistent object interface instead of switching between ORM and Pydantic models.

#### Setup

Define SQLAlchemy ORM models and link them to transmuter schemas:

```python
from sqlalchemy import ForeignKey, Integer, String, create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from arcanus.materia.sqlalchemy.base import SqlalchemyMateria
from arcanus.base import BaseTransmuter, Identity
from arcanus.association import Relation, RelationCollection, Relationships
from arcanus.database import Session
from pydantic import Field
from typing import Annotated, Optional

# Define ORM models
class Base(DeclarativeBase): ...

class AuthorModel(Base):
    __tablename__ = "authors"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    name: Mapped[str] = mapped_column(String(100), nullable=False)
    field: Mapped[str] = mapped_column(String(50), nullable=False)

    books: Mapped[list["BookModel"]] = relationship(back_populates="author")

class BookModel(Base):
    __tablename__ = "books"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    title: Mapped[str] = mapped_column(String(200), nullable=False)
    year: Mapped[int] = mapped_column(Integer, nullable=False)
    author_id: Mapped[int] = mapped_column(ForeignKey(AuthorModel.id), nullable=False)

    author: Mapped[AuthorModel] = relationship(back_populates="books")

# Initialize SQLAlchemy Materia and bless schemas
sqlalchemy_materia = SqlalchemyMateria()

@sqlalchemy_materia.bless(AuthorModel)
class Author(BaseTransmuter):
    id: Annotated[Optional[int], Identity] = Field(default=None, frozen=True)
    name: str
    field: str

    books: RelationCollection[Book] = Relationships()

@sqlalchemy_materia.bless(BookModel)
class Book(BaseTransmuter):
    id: Annotated[Optional[int], Identity] = Field(default=None, frozen=True)
    title: str
    year: int
    author_id: int | None = None

    author: Relation[Author] = Relationships()

# Create engine
engine = create_engine("postgresql://user:password@localhost/dbname")
Base.metadata.create_all(engine)
```

#### Transmuter-ORM Proxying

All objects retrieved from arcanus Session are transmuter instances, wrapping the origianl ORM objects.

```python
with Session(engine) as session:
    author = session.get_one(Author, 1)

    # This is a transmuter object with Pydantic validation
    assert isinstance(author, Author)
    assert isinstance(author, BaseTransmuter)

    # Access the underlying ORM object via __transmuter_provided__
    orm_author = author.__transmuter_provided__
    assert isinstance(orm_author, AuthorModel)

    # Changes sync bi-directionally
    author.name = "Arthur C. Clarke"
    assert orm_author.name == "Arthur C. Clarke"  # Synced to ORM

    # ORM changes reflect in transmuter after revalidation
    orm_author.field = "Hard Science Fiction"
    author.revalidate()  # Sync ORM changes back to transmuter
    assert author.field == "Hard Science Fiction"

    # Related objects are also transmuters
    for book in author.books:
        assert isinstance(book, Book)
        assert hasattr(book, '__transmuter_provided__')

    session.commit()
```

#### Use Cases

##### Creating and Persisting Objects

```python
# Create objects with relationships
with Session(engine) as session:
    author = Author(name="Isaac Asimov", field="Science Fiction")
    book = Book(title="Foundation", year=1951, author=Relation(author))

    session.add(book)  # Adding book automatically adds author
    session.flush()

    # Sync server-generated values (autoincrement IDs)
    # PostgreSQL/SQLite with RETURNING support:
    author.revalidate()  # No extra query
    book.revalidate()

    # MySQL without RETURNING:
    # session.refresh(author)  # Issues SELECT
    # session.refresh(book)

    session.commit()
    print(f"Created book #{book.id}: {book.title}")
```

##### Querying Objects

```python
with Session(engine) as session:
    # By primary key
    author = session.get_one(Author, 1)

    # Using filters
    author = session.one(Author, name="Isaac Asimov")

    # With expressions
    from sqlalchemy import select
    stmt = select(Author).where(Author["field"] == "Science Fiction")
    result = session.execute(stmt)
    authors = result.scalars().all()

    # List with pagination
    books = session.list(Book, limit=10, offset=0,
                        order_bys=[Book["year"].desc()])
```

##### Accessing Relationships

```python
with Session(engine) as session:
    author = session.get_one(Author, 1)

    # Navigate one-to-many
    for book in author.books:
        print(f"{book.title} ({book.year})")

        # Navigate many-to-one (same object reference)
        assert book.author.value is author
```

##### Updating Objects

```python
with Session(engine) as session:
    # Direct update
    book = session.get_one(Book, 1)
    book.title = "Foundation (Revised)"
    session.commit()

    # Bulk update with RETURNING
    from sqlalchemy import update
    stmt = (
        update(Book)
        .where(Book["author_id"] == 1)
        .values(field="Updated")
        .returning(Book)
    )
    result = session.execute(stmt)
    updated_books = result.scalars().all()
    session.commit()
```

##### Using Partial Models (APIs)

```python
# Create partial (excludes identity fields)
create_data = Author.Create(name="New Author", field="Physics")
author = Author.shell(create_data)

with Session(engine) as session:
    session.add(author)
    session.commit()

# Update partial (respects frozen fields)
update_data = Author.Update(field="Quantum Physics")
author = session.get_one(Author, 1)
author.absorb(update_data)
session.commit()
```

##### Deleting Objects

```python
with Session(engine) as session:
    # Delete with cascade
    author = session.get_one(Author, 1)
    session.delete(author)  # Related books deleted by cascade
    session.commit()

    # Bulk delete with RETURNING
    from sqlalchemy import delete
    stmt = delete(Book).where(Book["year"] < 2000).returning(Book)
    result = session.execute(stmt)
    deleted_books = result.scalars().all()
    session.commit()
```

#### Session Helper Methods

**`get` / `get_one`** - Retrieve by primary key:

```python
author = session.get(Author, 1)  # Returns None if not found
author = session.get_one(Author, 1)  # Raises if not found
```

**`one` / `one_or_none`** - Single result with filters:

```python
author = session.one(Author, name="Isaac Asimov")
author = session.one_or_none(Author, name="Maybe Exists")
```

**`first`** - First result with ordering:

```python
author = session.first(Author, order_bys=[Author["name"]])
```

**`list`** - Multiple results with pagination:

```python
authors = session.list(Author, limit=10, offset=20,
                      expressions=[Author["field"].like("Science%")])
```

**`bulk`** - Multiple by IDs:

```python
authors = session.bulk(Author, [1, 2, 3, 4, 5])
```

**`count`** - Count matching rows:

```python
total = session.count(Author)
filtered = session.count(Author, expressions=[Author["field"] == "Physics"])
```

**`partitions`** - Stream large result sets:

```python
for partition in session.partitions(Author, size=100):
    for author in partition:
        process(author)
```

### Async Support

Arcanus supports asynchronous operations using SQLAlchemy's async engine. Use `arcanus.database.AsyncSession` instead of `sqlalchemy.ext.asyncio.AsyncSession`.

All operations work identically to the sync version - just use `AsyncSession` and await async operations:

```python
from sqlalchemy.ext.asyncio import create_async_engine
from arcanus.database import AsyncSession

# Create async engine
async_engine = create_async_engine(
    "postgresql+asyncpg://user:password@localhost/dbname",
    echo=True
)

# All operations are awaitable
async with AsyncSession(async_engine, expire_on_commit=True) as session:
    # Query
    author = await session.get_one(Author, 1)

    # Create
    book = Book(title="Async Book", year=2024, author=Relation(author))
    session.add(book)
    await session.flush()
    await session.commit()

    # List with filters
    books = await session.list(Book, limit=10,
                               expressions=[Book["year"] > 2020])
```

#### Relationship Loading in Async

SQLAlchemy's relationship loading strategies work with arcanus transmuters. The await syntax depends on the loading strategy:

**Lazy loading (select)** - Requires await to trigger the query, otherwise a greenlet issue will be raised:

```python
class BookModel(Base):
    # Default lazy="select" - loads on access
    author: Mapped[AuthorModel] = relationship(lazy="select", back_populates="books")

async with AsyncSession(async_engine) as session:
    book = await session.get_one(Book, 1)

    # Must await for lazy loading - triggers SELECT query
    parent_author = await book.author  # Returns Author object directly
    parent_author is book.author.value # standerd usage, no need for await for the second time visit
    assert isinstance(parent_author, Author)
```

**Eager loading (selectin/joined)** - Loaded upfront, but keep await syntax for consistency:

```python
class BookModel(Base):
    # Eager loading strategies - data already loaded
    author: Mapped[AuthorModel] = relationship(lazy="selectin", back_populates="books")
    # or lazy="joined"

async with AsyncSession(async_engine) as session:
    book = await session.get_one(Book, 1)

    # No I/O needed (data already loaded), but await still works

    parent_author = await book.author  # Returns cached data

    book2 = await session.get_one(Book, 2)
    # also works without await for selectin/joined strategies
    # but recommended to keep await syntax consistent across strategies
    parent_author = book.author.value

```

**Syntactic sugar for await:**

- `await relation` (Relation) → Returns the related object directly (equivalent to `relation.value`)
- `await relation_collection` (RelationCollection) → Returns a shallow list copy of all related objects

```python
async with AsyncSession(async_engine) as session:
    author = await session.get_one(Author, 1)

    # RelationCollection: await returns list of related objects
    books_list = await author.books  # Returns list[Book]
    for book in books_list:
        print(book.title)

    # Can also iterate the collection directly after await
    await author.books
    for book in author.books:  # Iterates the collection
        print(book.title)

    # Relation: await returns the related object
    book = await session.get_one(Book, 1)
    parent_author = await book.author  # Returns Author, not Relation[Author]
    assert parent_author.id == book.author_id
```

## Association Types

Arcanus provides several association types to model different relationship patterns. All association types work across all materia backends (NoOp, SQLAlchemy, etc.).

| Association             | Relationship                     | Container            | Example                               |
| ----------------------- | -------------------------------- | -------------------- | ------------------------------------- |
| `Relation[T]`           | One-to-one / Many-to-one         | Single value         | `author: Relation[Author]`            |
| `RelationCollection[T]` | One-to-many / Many-to-many       | `list[T]`            | `books: RelationCollection[Book]`     |
| `RelationSet[T]`        | Many-to-many (unique)            | `set[T]`             | `tags: RelationSet[Tag]`              |
| `RelationMap[K, T]`     | Keyed collection (homogeneous)   | `dict[K, T]`         | `settings: RelationMap[str, Setting]` |
| `TypedRelationMap[TD]`  | Keyed collection (heterogeneous) | `dict` via TypedDict | `media: TypedRelationMap[MediaFiles]` |

Default factories are provided for convenience:

| Field Helper          | Creates                                                  |
| --------------------- | -------------------------------------------------------- |
| `Relationship()`      | `Field(default_factory=Relation, frozen=True)`           |
| `Relationships()`     | `Field(default_factory=RelationCollection, frozen=True)` |
| `RelationMaps()`      | `Field(default_factory=RelationMap, frozen=True)`        |
| `TypedRelationMaps()` | `Field(default_factory=TypedRelationMap, frozen=True)`   |

### Relation

`Relation[T]` wraps a single related transmuter. Use it for one-to-one or many-to-one relationships.

```python
from arcanus.base import BaseTransmuter, Identity
from arcanus.association import Relation, Relationship
from pydantic import Field
from typing import Annotated, Optional

class Author(BaseTransmuter):
    id: Annotated[Optional[int], Identity] = Field(default=None, frozen=True)
    name: str

class Book(BaseTransmuter):
    id: Annotated[Optional[int], Identity] = Field(default=None, frozen=True)
    title: str
    author: Relation[Author] = Relationship()

# Assign at construction
author = Author(id=1, name="Isaac Asimov")
book = Book(id=1, title="Foundation", author=Relation(author))

# Access the value
print(book.author.value.name)  # Isaac Asimov

# Update the value
new_author = Author(id=2, name="Arthur C. Clarke")
book.author.value = new_author
print(book.author.value.name)  # Arthur C. Clarke

# Optional relation (defaults to None)
class Review(BaseTransmuter):
    id: Annotated[Optional[int], Identity] = Field(default=None, frozen=True)
    text: str
    book: Relation[Optional[Book]] = Relationship()

review = Review(id=1, text="Great book!")
print(review.book.value)  # None
```

### RelationCollection

`RelationCollection[T]` is a `list`-based association for one-to-many or many-to-many relationships.

```python
from arcanus.association import RelationCollection, Relationships

class Author(BaseTransmuter):
    id: Annotated[Optional[int], Identity] = Field(default=None, frozen=True)
    name: str
    books: RelationCollection[Book] = Relationships()

class Book(BaseTransmuter):
    id: Annotated[Optional[int], Identity] = Field(default=None, frozen=True)
    title: str

# Start empty, add items
author = Author(id=1, name="Asimov")
author.books.append(Book(id=1, title="Foundation"))
author.books.append(Book(id=2, title="I, Robot"))

# List operations
print(len(author.books))       # 2
print(author.books[0].title)   # Foundation

for book in author.books:
    print(book.title)

# Extend with multiple
author.books.extend([
    Book(id=3, title="Caves of Steel"),
    Book(id=4, title="The End of Eternity"),
])

# Remove, pop, clear
author.books.remove(author.books[0])
popped = author.books.pop()
author.books.clear()

# Initialize with values at construction
author = Author(
    id=1,
    name="Asimov",
    books=RelationCollection([
        Book(id=1, title="Foundation"),
        Book(id=2, title="I, Robot"),
    ]),
)
```

### RelationSet

`RelationSet[T]` is a `set`-based association for unique collections. Ideal for many-to-many relationships where duplicates are not allowed.

```python
from arcanus.association import RelationSet, Relationships

class Tag(BaseTransmuter):
    id: Annotated[Optional[int], Identity] = Field(default=None)
    name: str

class Article(BaseTransmuter):
    id: Annotated[Optional[int], Identity] = Field(default=None)
    title: str
    tags: RelationSet[Tag] = Relationships()

article = Article(id=1, title="Intro to Python")

# Add tags
python_tag = Tag(id=1, name="python")
article.tags.add(python_tag)
article.tags.add(Tag(id=2, name="tutorial"))

# Set operations
print(len(article.tags))          # 2
print(python_tag in article.tags)  # True

# Discard (no error if absent)
article.tags.discard(python_tag)

# Update with multiple
article.tags.update([
    Tag(id=3, name="beginner"),
    Tag(id=4, name="guide"),
])

# Set algebra
other_tags = {Tag(id=3, name="beginner"), Tag(id=5, name="advanced")}
common = article.tags & other_tags       # intersection
combined = article.tags | other_tags     # union
diff = article.tags - other_tags         # difference

# Initialize with values
article = Article(
    id=1,
    title="Intro",
    tags=RelationSet({Tag(id=1, name="python"), Tag(id=2, name="tutorial")}),
)
```

### RelationMap

`RelationMap[K, T]` is a `dict`-based association with **homogeneous** value types — all values share the same transmuter type. Use it for keyed one-to-many relationships.

```python
from arcanus.association import RelationMap, RelationMaps

class ShelfItem(BaseTransmuter):
    id: Annotated[Optional[int], Identity] = Field(default=None, frozen=True)
    label: str
    description: str

class Shelf(BaseTransmuter):
    id: Annotated[Optional[int], Identity] = Field(default=None, frozen=True)
    name: str
    items: RelationMap[str, ShelfItem] = RelationMaps()

shelf = Shelf(id=1, name="Reference")

# Set items by key
shelf.items["python"] = ShelfItem(id=1, label="python", description="Python ref")
shelf.items["rust"] = ShelfItem(id=2, label="rust", description="Rust ref")

# Dict operations
print(shelf.items["python"].description)  # Python ref
print(len(shelf.items))                    # 2
print("python" in shelf.items)             # True

for key, item in shelf.items.items():
    print(f"{key}: {item.label}")

# Update, pop, clear
shelf.items.update({"go": ShelfItem(id=3, label="go", description="Go ref")})
removed = shelf.items.pop("rust")
shelf.items.clear()

# Literal-typed keys for stricter validation
from typing import Literal

class StrictCatalog(BaseTransmuter):
    id: Annotated[Optional[int], Identity] = Field(default=None)
    title: str
    tags: RelationMap[Literal["python", "rust", "go"], Tag] = RelationMaps()

catalog = StrictCatalog(id=1, title="Languages")
catalog.tags["python"] = Tag(id=1, name="Python")
# catalog.tags["java"] = ...  # raises ValidationError — key not in Literal
```

### TypedRelationMap

`TypedRelationMap[TD]` is a dict-based association where each key can have a **different Transmuter type**, defined through a `TypedDict`. This is ideal for polymorphic relationships where different keys correspond to different subclasses.

#### Defining Typed Media Slots

```python
from typing import Annotated, Optional
from typing_extensions import TypedDict
from pydantic import Field
from arcanus.base import BaseTransmuter, Identity
from arcanus.association import TypedRelationMap, TypedRelationMaps

# Define polymorphic transmuter types
class MediaItem(BaseTransmuter):
    id: Annotated[Optional[int], Identity] = Field(default=None, frozen=True)
    slot: str = ""
    name: str

class ImageMedia(MediaItem):
    width: int = 0
    height: int = 0

class VideoMedia(MediaItem):
    duration: float = 0.0

# Define per-key types with TypedDict
class DocumentMedia(TypedDict):
    image: ImageMedia
    video: VideoMedia

# Use in a transmuter
class Gallery(BaseTransmuter):
    id: Annotated[Optional[int], Identity] = Field(default=None, frozen=True)
    name: str
    media: TypedRelationMap[DocumentMedia] = TypedRelationMaps()
```

#### Basic Usage

```python
# Create with values
img = ImageMedia(id=1, name="photo", width=1920, height=1080)
vid = VideoMedia(id=2, name="clip", duration=120.0)

gallery = Gallery(id=1, name="My Gallery")
gallery.media["image"] = img
gallery.media["video"] = vid

# Type-safe per-key access
photo = gallery.media["image"]   # ImageMedia
clip = gallery.media["video"]    # VideoMedia

print(photo.width)    # 1920
print(clip.duration)  # 120.0
```

#### Construction with TypedRelationMap

```python
# Pass values at construction time
gallery = Gallery(
    id=1,
    name="My Gallery",
    media=TypedRelationMap({"image": img, "video": vid}),
)

# Or initialize from plain dicts (Pydantic validates and coerces)
gallery = Gallery(
    id=1,
    name="My Gallery",
    media=TypedRelationMap({
        "image": {"name": "photo", "width": 1920, "height": 1080},
        "video": {"name": "clip", "duration": 120.0},
    }),
)
```

#### Dict Operations

`TypedRelationMap` supports standard dict operations, with per-key type validation:

```python
gallery = Gallery(id=1, name="Test")

# Set items (validated against TypedDict key types)
gallery.media["image"] = ImageMedia(id=1, name="photo", width=800, height=600)

# Check membership
"image" in gallery.media  # True
len(gallery.media)         # 1

# Iterate
for key in gallery.media:
    print(key, gallery.media[key])

# Delete
del gallery.media["image"]

# Update with multiple items
gallery.media.update({
    "image": ImageMedia(id=1, name="new_photo", width=1024, height=768),
    "video": VideoMedia(id=2, name="intro", duration=30.0),
})

# Pop
removed = gallery.media.pop("video")
```

#### Optional Keys with `total=False`

Use `total=False` in the TypedDict to make all keys optional, allowing partial population:

```python
class OptionalMedia(TypedDict, total=False):
    image: ImageMedia
    video: VideoMedia

class FlexibleGallery(BaseTransmuter):
    id: Annotated[Optional[int], Identity] = Field(default=None, frozen=True)
    name: str
    media: TypedRelationMap[OptionalMedia] = TypedRelationMaps()

# Only some keys need to be present
gallery = FlexibleGallery(id=1, name="Partial")
gallery.media["image"] = ImageMedia(id=1, name="photo", width=800, height=600)
# gallery.media["video"] is not required

data = gallery.model_dump()
# {"id": 1, "name": "Partial", "media": {"image": {...}}}
```

#### Serialization

```python
gallery = Gallery(id=1, name="Test")
gallery.media["image"] = ImageMedia(id=1, name="photo", width=800, height=600)
gallery.media["video"] = VideoMedia(id=2, name="clip", duration=30.0)

# Serialize to dict
data = gallery.model_dump()
# {
#     "id": 1, "name": "Test",
#     "media": {
#         "image": {"id": 1, "slot": "", "name": "photo", "media_type": "generic", "width": 800, "height": 600},
#         "video": {"id": 2, "slot": "", "name": "clip", "media_type": "generic", "duration": 30.0},
#     }
# }

# Round-trip: reconstruct from serialized data
restored = Gallery.model_validate(data)
assert isinstance(restored.media["image"], ImageMedia)
```

#### Key Validation

Invalid keys or wrong value types raise errors:

```python
gallery = Gallery(id=1, name="Test")

# Invalid key (not in TypedDict)
gallery.media["audio"] = some_value  # raises KeyError

# Wrong value type for key
gallery.media["image"] = VideoMedia(...)  # raises ValidationError
```
