Skip to content

repositron

A typed, generic repository base for SQLAlchemy 2.0.
Full CRUD, with zero per-table boilerplate.

Get started Guides


Every SQLAlchemy project ends up with the same folder: one repository class per table, each wrapping select(...) / session.scalars(...) in the same get, the same list, the same pagination math. repositron writes that layer once, generically, and types it against your model and your return shape.

from sqlalchemy import select


class TaskRepository:
    def __init__(self, session: Session) -> None:
        self.session = session

    def get(self, id: int) -> TaskDTO | None:
        row = self.session.scalars(select(Task).where(Task.id == id)).first()
        if row is None:
            return None
        return TaskDTO(id=row.id, title=row.title, status=row.status, assignee_id=row.assignee_id)

    def list(self, *, status: str | None = None) -> list[TaskDTO]:
        stmt = select(Task)
        if status is not None:
            stmt = stmt.where(Task.status == status)
        rows = self.session.scalars(stmt).all()
        return [TaskDTO(id=r.id, title=r.title, status=r.status, assignee_id=r.assignee_id) for r in rows]

    def update(self, id: int, *, assignee_id: int | None = None) -> bool:
        task = self.session.scalars(select(Task).where(Task.id == id)).first()
        if task is None:
            return False
        if assignee_id is not None:   # and how do you unassign on purpose?
            task.assignee_id = assignee_id
        self.session.flush()
        return True

    # ...count, delete, first, pagination, then again for the next ten tables.
from dataclasses import dataclass
from repositron import Repository, UNSET, UnsetType


@dataclass(frozen=True, slots=True)
class TaskDTO:               # light, detached, serializes straight to JSON
    id: int
    title: str
    status: str
    assignee_id: int | None


@dataclass
class TaskCreate:
    workspace_id: int
    title: str


@dataclass
class TaskUpdate:
    title: str | UnsetType = UNSET            # absent leaves it; None sets NULL
    status: str | UnsetType = UNSET
    assignee_id: int | None | UnsetType = UNSET


class TaskRepository(Repository[Task, TaskDTO, TaskCreate, TaskUpdate]):
    ...

Every method from the other tab now exists, typed against TaskDTO, with no further code.

What you get

  • Typed end to end


    repo.list() is list[TaskDTO], and your editor knows it. No casts, no Any. The return value is the same object your API serializes.

  • Two ways to filter, one call


    Equality by keyword and arbitrary SQLAlchemy expressions, combined. You never pick between readable and powerful.

    Filtering

  • Updates that write NULL on purpose


    UNSET leaves a column alone; None sets it to NULL. The is not None pattern cannot tell those apart. repositron can.

    Updating rows

  • Load only what you need


    repo[Card].list() selects just that shape's columns, for one call, without touching the injected repository.

    Projection

  • Pagination that refuses to lie


    list_paginated requires order_by and raises if you forget, turning a production heisenbug into an error at the call site.

    Pagination

  • One dependency


    Just sqlalchemy>=2.0. Dataclass DTOs add nothing else; Pydantic is detected only if your DTO is one.

Install

uv add repositron
pip install repositron

Python 3.13+ and sqlalchemy>=2.0.

Get started