s-ormkit
Generic БД/ORM-инфраструктура для экосистемы S-kits на SQLAlchemy 2.0: диалект-нейтральный движок из DB-URL, Repository + UnitOfWork + DIP-протоколы.
Версия: 0.2.0
Лицензия: MIT
Зависимости: SQLAlchemy>=2.0 (alembic — опционально: s-ormkit[migrations])
Кит НЕ знает ни о каком приложении. Прикладная логика получает БД-слой через ORM так,
что не зависит от sqlite/диалекта: движок конфигурируется через DB-URL и заменяется на
postgresql://.../mysql://... без изменения кода.
Что входит
Declarative-база и миксины (base.py)
Base— общий declarative base для моделей-наследниковIntPkMixin— целочисленный автоинкрементный первичный ключTimestampMixin—created_at/updated_atчерезfunc.now()(диалект-нейтрально)
Движок из DB-URL (engine.py)
make_engine— резолв URL (аргумент → env → default), для sqlite включаетPRAGMA foreign_keys=ON;create_engineленив (postgres-URL создаётся без коннекта)make_session_factory—sessionmaker(expire_on_commit=False)init_schema/drop_schema— создать/удалить таблицыdispose_engine— освободить пул соединений
Repository-паттерн (repository.py)
BaseRepository[TModel, TDomain]— generic CRUD:add / get / get_or_none / list / update / remove / count- Маппинг ORM <-> domain через переопределяемые хуки
to_domain/to_orm - Не коммитит сам (это делает
UnitOfWork) — толькоflushдля получения id
Транзакционная граница (unit_of_work.py)
UnitOfWork— контекст-менеджер: rollback при исключении,closeвсегда,commitявный
DIP-контракты (protocols.py)
RepositoryProtocol/UnitOfWorkProtocol— чтобы сервисы зависели от абстракций, а не отBaseRepository/ SQLAlchemyAsyncRepositoryProtocol/AsyncUnitOfWorkProtocol— то же для async-слоя
Async-слой (async_engine.py, async_repository.py, async_unit_of_work.py)
make_async_engine/make_async_session_factory— асинхронный движок иasync_sessionmaker(expire_on_commit=False); для sqlite так же включаетсяPRAGMA foreign_keys=ON,create_async_engineтак же ленивinit_schema_async/drop_schema_async/dispose_engine_asyncAsyncUnitOfWork/AsyncBaseRepository— асинхронные близнецы с ТЕМ ЖЕ API
Резолв DB-URL (url.py)
resolve_db_url— аргумент → переменная окружения → default (общий для sync, async и миграций)async_driver_url/sync_driver_url— автоподстановка драйвера (postgresql://→postgresql+asyncpg://илиpostgresql+psycopg://)
Защита от N+1 (loading.py)
LoadSpec(selectin=, joined=)+apply_load_spec— декларация связей, которые грузятся вместе с сущностью (в т.ч. вложенные пути"posts.comments")strict_loading/enable_strict_loading/make_strict_async_session_factory— строгий режимraiseload("*"): незадекларированная ленивая подгрузка падает
Миграции (migrations.py, extra [migrations])
scaffold_alembicи CLIpython -m ormkit migrations init— генерация рабочего alembic-окружения на async-движке кита
Быстрый старт
Кит generic — вы объявляете свои модели поверх Base и миксинов:
from dataclasses import dataclass
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from ormkit import (
Base, IntPkMixin, TimestampMixin,
BaseRepository, UnitOfWork,
make_engine, make_session_factory, init_schema,
)
# 1. Свои ORM-модели
class User(Base, IntPkMixin, TimestampMixin):
__tablename__ = "users"
name: Mapped[str] = mapped_column(String(100))
# 2. Свой доменный объект (dataclass / pydantic / dict — кит не знает тип)
@dataclass
class UserDTO:
name: str
id: int | None = None
# 3. Свой репозиторий — переопределяем ТОЛЬКО хуки маппинга
class UserRepository(BaseRepository[User, UserDTO]):
def to_domain(self, obj: User) -> UserDTO:
return UserDTO(id=obj.id, name=obj.name)
def to_orm(self, domain: UserDTO) -> User:
return User(name=domain.name)
Конфигурация движка через DB-URL
# sqlite по умолчанию (для локали / тестов), FK enforcement включён автоматически
engine = make_engine("sqlite:///app.db")
# та же строка кода на проде — просто другой URL, коду всё равно:
engine = make_engine(default="postgresql://user:pass@host/db")
# или через переменную окружения ORMKIT_DB_URL:
engine = make_engine()
init_schema(engine)
session_factory = make_session_factory(engine)
Работа через UnitOfWork
with UnitOfWork(session_factory) as uow:
repo = UserRepository(uow.session, User)
saved = repo.add(UserDTO(name="Алиса")) # flush -> id проставлен
found = repo.get(saved.id) # NotFoundError, если нет
everyone = repo.list() # фильтр: repo.list(name="Алиса")
repo.update(saved.id, name="Алиса Смит")
total = repo.count()
uow.commit() # без commit транзакция не персистится
# исключение в блоке -> автоматический rollback; сессия всегда закрывается
DIP: сервис зависит от абстракции
from ormkit import RepositoryProtocol
def register_user(repo: RepositoryProtocol[UserDTO], name: str) -> UserDTO:
return repo.add(UserDTO(name=name))
# в проде — UserRepository, в тестах — in-memory фейк, реализующий тот же протокол
Асинхронное приложение
Тот же код, но на async/await — форма вызова другая, структура та же:
from ormkit import (
AsyncBaseRepository, AsyncUnitOfWork,
init_schema_async, make_async_engine, make_async_session_factory,
)
class UserRepository(AsyncBaseRepository[User, UserDTO]):
def to_domain(self, obj: User) -> UserDTO:
return UserDTO(id=obj.id, name=obj.name)
def to_orm(self, domain: UserDTO) -> User:
return User(name=domain.name)
# sqlite:// → sqlite+aiosqlite://, postgresql:// → postgresql+asyncpg:// (автоматически)
engine = make_async_engine("postgresql://user:pass@host/db")
await init_schema_async(engine) # в проде схему катает alembic, см. ниже
session_factory = make_async_session_factory(engine)
async with AsyncUnitOfWork(session_factory) as uow:
repo = UserRepository(uow.session, User)
saved = await repo.add(UserDTO(name="Алиса"))
await uow.commit()
Миграции (alembic)
create_all годится до первого изменения схемы на живых данных — дальше нужны миграции:
pip install "s-ormkit[migrations]"
python -m ormkit migrations init . --metadata myapp.models:Base
# дальше — штатный alembic, URL берётся из ORMKIT_DB_URL (как и у движка приложения):
alembic revision --autogenerate -m "init"
alembic upgrade head
Сгенерированный env.py работает на async-движке кита и с render_as_batch=True
(без него sqlite не переживает ALTER). Ссылка на metadata (pkg.module:attr) —
единственное, что нужно указать.
Защита от N+1
Репозиторий ДЕКЛАРИРУЕТ, что грузится вместе с сущностью, а строгий режим не даёт незаметно уехать в ленивую подгрузку:
from ormkit import LoadSpec, enable_strict_loading, make_strict_async_session_factory
class UserRepository(AsyncBaseRepository[User, UserDTO]):
load_spec = LoadSpec(selectin=("posts.comments",), joined=("profile",))
...
# в conftest тестов / в CI: любая НЕобъявленная ленивая подгрузка → InvalidRequestError
session_factory = make_strict_async_session_factory(engine)
# либо точечно на уже открытой сессии (возвращает выключатель):
disable = enable_strict_loading(session)
selectin — для коллекций (отдельный IN-запрос), joined — для «многие-к-одному»
(один JOIN). N+1 падает на этапе тестов, а не в проде.
Архитектура
ormkit/
├── __init__.py # Public API + __version__
├── __main__.py # CLI: python -m ormkit migrations init
├── exceptions.py # OrmKitError, NotFoundError
├── base.py # Base, IntPkMixin, TimestampMixin
├── url.py # resolve_db_url, async_driver_url, sync_driver_url
├── engine.py # make_engine, make_session_factory, init/drop_schema, dispose
├── async_engine.py # make_async_engine, make_async_session_factory, *_async
├── repository.py # BaseRepository[TModel, TDomain]
├── async_repository.py # AsyncBaseRepository[TModel, TDomain]
├── unit_of_work.py # UnitOfWork
├── async_unit_of_work.py# AsyncUnitOfWork
├── loading.py # LoadSpec, apply_load_spec, строгий режим (raiseload)
├── migrations.py # scaffold_alembic, migration_url, require_alembic
├── pagination.py # Page, page_to_offset, paginate_stmt
├── search.py # trigram_filter
├── rls.py # TenantContext, apply_tenant_guc, rls_bypass
└── protocols.py # RepositoryProtocol, UnitOfWorkProtocol (+ async)
Тестирование
uv sync --extra dev
uv run pytest -q # зелёный, coverage >= 80%
uv run ruff check . # чисто
Тесты объявляют собственные демо-модели (_User, _Post) прямо в conftest.py —
это доказывает, что кит generic и не тянет никакого приложения.
Диалект-нейтральность
- Один код — любая СУБД. Меняется только DB-URL:
sqlite://↔postgresql://↔mysql://. Прикладной код не трогается. - sqlite FK enforcement. Для sqlite
make_engineнавешиваетPRAGMA foreign_keys=ON(иначе sqlite молча игнорирует внешние ключи). Отключается флагомforeign_keys=False. - Временные метки.
TimestampMixinиспользуетfunc.now(), работающий и в sqlite, и в postgres/mysql. - Ленивое создание.
make_engine("postgresql://...")не коннектится — движок создаётся без живого сервера. То же верно дляmake_async_engine. - Драйвер подставляется сам.
postgresql://становитсяpostgresql+asyncpg://в async-контексте иpostgresql+psycopg://в синхронном; явно заданный драйвер не трогается.
Лицензия
MIT
Metadata
Release files for s-ormkit 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| s_ormkit-0.3.0.tar.gz | 80.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| s_ormkit-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 100.5 kB
Release files / s_ormkit-0.3.0.tar.gz
| Download URL | s_ormkit-0.3.0.tar.gz |
|---|---|
| Size | 80.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e64594a2374e71b2b5cbdd0540e7d81d56b67c9747f7dcec933ebf38af20fef8
|
|
BLAKE2b-256 checksum How to use checksums |
7ee8f089ce6fd8e712783f5f9feb72d0d6845519b1b04523203d4ac831c5a416
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.9.30 {"installer":{"name":"uv","version":"0.9.30","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"12","id":"bookworm","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / s_ormkit-0.3.0-py3-none-any.whl
| Download URL | s_ormkit-0.3.0-py3-none-any.whl |
|---|---|
| Size | 20.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6959d315887ae7a0c2523c12fa56ff82c10bdc37d4f632921437d0014dbbaef4
|
|
BLAKE2b-256 checksum How to use checksums |
14322bc675603df3feb823d6987ac1cf917f98bb5e69953519365ad48a11b2a6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.9.30 {"installer":{"name":"uv","version":"0.9.30","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"12","id":"bookworm","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|