s-ormkit 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ormkit/__init__.py ADDED
@@ -0,0 +1,56 @@
1
+ """s-ormkit — generic БД/ORM-инфраструктура для экосистемы S-kits.
2
+
3
+ Чистый инфраструктурный кит на SQLAlchemy 2.0 БЕЗ завязок на конкретное приложение.
4
+ Задача — дать прикладной логике БД-слой через ORM так, чтобы она НЕ знала про
5
+ sqlite/диалект: движок конфигурируется через DB-URL и заменяется на
6
+ `postgresql://.../mysql://...` без изменения кода.
7
+
8
+ **Ядро (v0.0.1):**
9
+ - Declarative-база и миксины: `Base`, `IntPkMixin`, `TimestampMixin`
10
+ - Движок из DB-URL, диалект-нейтрально: `make_engine`, `make_session_factory`,
11
+ `init_schema`, `drop_schema`, `dispose_engine`
12
+ - Repository-паттерн: `BaseRepository` (маппинг ORM <-> domain через хуки)
13
+ - Транзакционная граница: `UnitOfWork`
14
+ - DIP-контракты: `RepositoryProtocol`, `UnitOfWorkProtocol`
15
+
16
+ Зависимости: SQLAlchemy>=2.0 (и ничего больше).
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from ormkit.base import Base, IntPkMixin, TimestampMixin
21
+ from ormkit.engine import (
22
+ dispose_engine,
23
+ drop_schema,
24
+ init_schema,
25
+ make_engine,
26
+ make_session_factory,
27
+ )
28
+ from ormkit.exceptions import NotFoundError, OrmKitError
29
+ from ormkit.protocols import RepositoryProtocol, UnitOfWorkProtocol
30
+ from ormkit.repository import BaseRepository
31
+ from ormkit.unit_of_work import UnitOfWork
32
+
33
+ __version__ = "0.0.1"
34
+
35
+ __all__ = [
36
+ # base
37
+ "Base",
38
+ "IntPkMixin",
39
+ "TimestampMixin",
40
+ # engine
41
+ "make_engine",
42
+ "make_session_factory",
43
+ "init_schema",
44
+ "drop_schema",
45
+ "dispose_engine",
46
+ # repository
47
+ "BaseRepository",
48
+ # unit of work
49
+ "UnitOfWork",
50
+ # protocols
51
+ "RepositoryProtocol",
52
+ "UnitOfWorkProtocol",
53
+ # exceptions
54
+ "OrmKitError",
55
+ "NotFoundError",
56
+ ]
ormkit/base.py ADDED
@@ -0,0 +1,50 @@
1
+ """Declarative-база SQLAlchemy 2.0 и переиспользуемые миксины для моделей.
2
+
3
+ Диалект-нейтрально: `func.now()` работает и в sqlite, и в postgres/mysql, поэтому
4
+ модели наследников не завязаны на конкретную СУБД.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from datetime import datetime
9
+
10
+ from sqlalchemy import DateTime, func
11
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
12
+
13
+
14
+ class Base(DeclarativeBase):
15
+ """Общий declarative base для всех моделей-наследников кита."""
16
+
17
+ pass
18
+
19
+
20
+ class IntPkMixin:
21
+ """Миксин целочисленного автоинкрементного первичного ключа."""
22
+
23
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
24
+
25
+
26
+ class TimestampMixin:
27
+ """Миксин временных меток создания/обновления.
28
+
29
+ `created_at` проставляется БД на INSERT, `updated_at` — на INSERT и на UPDATE.
30
+ Использует `func.now()`, что диалект-нейтрально.
31
+ """
32
+
33
+ created_at: Mapped[datetime] = mapped_column(
34
+ DateTime(timezone=True),
35
+ server_default=func.now(),
36
+ )
37
+ updated_at: Mapped[datetime] = mapped_column(
38
+ DateTime(timezone=True),
39
+ server_default=func.now(),
40
+ onupdate=func.now(),
41
+ )
42
+
43
+
44
+ __all__ = [
45
+ "Base",
46
+ "IntPkMixin",
47
+ "TimestampMixin",
48
+ "Mapped",
49
+ "mapped_column",
50
+ ]
ormkit/engine.py ADDED
@@ -0,0 +1,86 @@
1
+ """Создание движка и фабрики сессий из DB-URL, диалект-нейтрально.
2
+
3
+ Движок конфигурируется через DB-URL: по умолчанию можно подать sqlite, заменяемо на
4
+ `postgresql://.../mysql://...` БЕЗ изменения прикладного кода. `create_engine` ленив —
5
+ движок к postgres/mysql создаётся без живого сервера и без коннекта.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+
11
+ from sqlalchemy import Engine, create_engine, event
12
+ from sqlalchemy.orm import Session, sessionmaker
13
+
14
+ from ormkit.base import Base
15
+ from ormkit.exceptions import OrmKitError
16
+
17
+
18
+ def make_engine(
19
+ url: str | None = None,
20
+ *,
21
+ env_var: str = "ORMKIT_DB_URL",
22
+ default: str | None = None,
23
+ echo: bool = False,
24
+ foreign_keys: bool = True,
25
+ ) -> Engine:
26
+ """Создать движок из DB-URL.
27
+
28
+ Резолв URL по приоритету: явный аргумент → `os.environ[env_var]` → `default`.
29
+ Если ничего не задано — `OrmKitError`.
30
+
31
+ Для sqlite при `foreign_keys=True` навешивается обработчик `connect`, выполняющий
32
+ `PRAGMA foreign_keys=ON` (иначе sqlite молча игнорирует внешние ключи). Для прочих
33
+ диалектов PRAGMA не применяется.
34
+
35
+ `create_engine` ленив: движок к недоступному postgres/mysql создаётся без коннекта.
36
+ """
37
+ resolved = url if url is not None else os.environ.get(env_var, default)
38
+ if not resolved:
39
+ raise OrmKitError(
40
+ "Не задан DB-URL: передайте url аргументом, "
41
+ f"установите переменную окружения {env_var} или задайте default."
42
+ )
43
+
44
+ engine = create_engine(resolved, echo=echo)
45
+
46
+ if foreign_keys and engine.dialect.name == "sqlite":
47
+
48
+ @event.listens_for(engine, "connect")
49
+ def _enable_sqlite_fk(dbapi_connection, connection_record):
50
+ cursor = dbapi_connection.cursor()
51
+ cursor.execute("PRAGMA foreign_keys=ON")
52
+ cursor.close()
53
+
54
+ return engine
55
+
56
+
57
+ def make_session_factory(engine: Engine) -> sessionmaker[Session]:
58
+ """Создать фабрику сессий, привязанную к движку.
59
+
60
+ `expire_on_commit=False`, чтобы объекты оставались доступны после commit.
61
+ """
62
+ return sessionmaker(bind=engine, expire_on_commit=False)
63
+
64
+
65
+ def init_schema(engine: Engine, base: type = Base) -> None:
66
+ """Создать все таблицы, зарегистрированные в metadata указанного base."""
67
+ base.metadata.create_all(engine)
68
+
69
+
70
+ def drop_schema(engine: Engine, base: type = Base) -> None:
71
+ """Удалить все таблицы, зарегистрированные в metadata указанного base."""
72
+ base.metadata.drop_all(engine)
73
+
74
+
75
+ def dispose_engine(engine: Engine) -> None:
76
+ """Освободить пул соединений движка."""
77
+ engine.dispose()
78
+
79
+
80
+ __all__ = [
81
+ "make_engine",
82
+ "make_session_factory",
83
+ "init_schema",
84
+ "drop_schema",
85
+ "dispose_engine",
86
+ ]
ormkit/exceptions.py ADDED
@@ -0,0 +1,20 @@
1
+ """Исключения кита ormkit."""
2
+ from __future__ import annotations
3
+
4
+
5
+ class OrmKitError(Exception):
6
+ """Базовое исключение кита ormkit."""
7
+
8
+ pass
9
+
10
+
11
+ class NotFoundError(OrmKitError):
12
+ """Сущность не найдена по первичному ключу."""
13
+
14
+ pass
15
+
16
+
17
+ __all__ = [
18
+ "OrmKitError",
19
+ "NotFoundError",
20
+ ]
ormkit/protocols.py ADDED
@@ -0,0 +1,54 @@
1
+ """DIP-контракты: прикладные сервисы зависят от абстракций, а не от BaseRepository/SQLAlchemy.
2
+
3
+ Protocol'ы позволяют внедрять любую реализацию репозитория/UoW (в т.ч. in-memory фейки
4
+ для тестов), не завязываясь на конкретный класс кита.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from types import TracebackType
9
+ from typing import Any, Protocol, TypeVar, runtime_checkable
10
+
11
+ TDomain = TypeVar("TDomain")
12
+
13
+
14
+ @runtime_checkable
15
+ class RepositoryProtocol(Protocol[TDomain]):
16
+ """Контракт репозитория для прикладного слоя."""
17
+
18
+ def add(self, domain: TDomain) -> TDomain: ...
19
+
20
+ def get(self, id: Any) -> TDomain: ...
21
+
22
+ def get_or_none(self, id: Any) -> TDomain | None: ...
23
+
24
+ def list(self, **filters: Any) -> list[TDomain]: ...
25
+
26
+ def update(self, id: Any, **changes: Any) -> TDomain: ...
27
+
28
+ def remove(self, id: Any) -> None: ...
29
+
30
+ def count(self, **filters: Any) -> int: ...
31
+
32
+
33
+ @runtime_checkable
34
+ class UnitOfWorkProtocol(Protocol):
35
+ """Контракт транзакционной границы для прикладного слоя."""
36
+
37
+ def __enter__(self) -> UnitOfWorkProtocol: ...
38
+
39
+ def __exit__(
40
+ self,
41
+ exc_type: type[BaseException] | None,
42
+ exc: BaseException | None,
43
+ tb: TracebackType | None,
44
+ ) -> None: ...
45
+
46
+ def commit(self) -> None: ...
47
+
48
+ def rollback(self) -> None: ...
49
+
50
+
51
+ __all__ = [
52
+ "RepositoryProtocol",
53
+ "UnitOfWorkProtocol",
54
+ ]
ormkit/repository.py ADDED
@@ -0,0 +1,117 @@
1
+ """Generic CRUD-репозиторий поверх ORM Session с маппингом ORM <-> domain.
2
+
3
+ Кит НЕ знает доменный тип: маппинг между ORM-моделью и доменным объектом задаётся
4
+ наследником через хуки `to_domain` / `to_orm`. Репозиторий не коммитит транзакцию сам —
5
+ это ответственность `UnitOfWork`; он делает только `flush`, чтобы получить сгенерированный id.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Generic, TypeVar
10
+
11
+ from sqlalchemy import func, select
12
+ from sqlalchemy.orm import Session
13
+
14
+ from ormkit.exceptions import NotFoundError
15
+
16
+ TModel = TypeVar("TModel")
17
+ TDomain = TypeVar("TDomain")
18
+
19
+
20
+ class BaseRepository(Generic[TModel, TDomain]):
21
+ """Базовый репозиторий CRUD поверх ORM Session.
22
+
23
+ Наследник переопределяет ТОЛЬКО `to_domain` / `to_orm` (и, при необходимости,
24
+ `pk_column`). Прочие методы работают через эти хуки и не завязаны на доменный тип.
25
+ """
26
+
27
+ def __init__(self, session: Session, model: type[TModel]) -> None:
28
+ self.session = session
29
+ self.model = model
30
+
31
+ # --- переопределяемые хуки маппинга --------------------------------------
32
+
33
+ def to_domain(self, obj: TModel) -> TDomain:
34
+ """ORM-объект -> доменный объект. Переопределяется наследником."""
35
+ raise NotImplementedError
36
+
37
+ def to_orm(self, domain: TDomain) -> TModel:
38
+ """Доменный объект -> ORM-объект. Переопределяется наследником."""
39
+ raise NotImplementedError
40
+
41
+ def pk_column(self) -> str:
42
+ """Имя колонки первичного ключа (по умолчанию ``id``)."""
43
+ return "id"
44
+
45
+ # --- внутреннее ----------------------------------------------------------
46
+
47
+ def _pk_attr(self):
48
+ return getattr(self.model, self.pk_column())
49
+
50
+ def _get_orm(self, id: Any) -> TModel | None:
51
+ stmt = select(self.model).where(self._pk_attr() == id)
52
+ return self.session.execute(stmt).scalar_one_or_none()
53
+
54
+ # --- CRUD ----------------------------------------------------------------
55
+
56
+ def add(self, domain: TDomain) -> TDomain:
57
+ """Добавить доменный объект. Возвращает домен с проставленным id."""
58
+ obj = self.to_orm(domain)
59
+ self.session.add(obj)
60
+ self.session.flush()
61
+ return self.to_domain(obj)
62
+
63
+ def get(self, id: Any) -> TDomain:
64
+ """Получить по id или `NotFoundError`."""
65
+ obj = self._get_orm(id)
66
+ if obj is None:
67
+ raise NotFoundError(
68
+ f"{self.model.__name__} с {self.pk_column()}={id!r} не найден"
69
+ )
70
+ return self.to_domain(obj)
71
+
72
+ def get_or_none(self, id: Any) -> TDomain | None:
73
+ """Получить по id или `None`."""
74
+ obj = self._get_orm(id)
75
+ return self.to_domain(obj) if obj is not None else None
76
+
77
+ def list(self, **filters: Any) -> list[TDomain]:
78
+ """Список с фильтром по равенству колонок (`колонка=значение`)."""
79
+ stmt = select(self.model)
80
+ for column, value in filters.items():
81
+ stmt = stmt.where(getattr(self.model, column) == value)
82
+ objs = self.session.execute(stmt).scalars().all()
83
+ return [self.to_domain(obj) for obj in objs]
84
+
85
+ def update(self, id: Any, **changes: Any) -> TDomain:
86
+ """Обновить поля сущности по id. `NotFoundError`, если её нет."""
87
+ obj = self._get_orm(id)
88
+ if obj is None:
89
+ raise NotFoundError(
90
+ f"{self.model.__name__} с {self.pk_column()}={id!r} не найден"
91
+ )
92
+ for column, value in changes.items():
93
+ setattr(obj, column, value)
94
+ self.session.flush()
95
+ return self.to_domain(obj)
96
+
97
+ def remove(self, id: Any) -> None:
98
+ """Удалить сущность по id. `NotFoundError`, если её нет."""
99
+ obj = self._get_orm(id)
100
+ if obj is None:
101
+ raise NotFoundError(
102
+ f"{self.model.__name__} с {self.pk_column()}={id!r} не найден"
103
+ )
104
+ self.session.delete(obj)
105
+ self.session.flush()
106
+
107
+ def count(self, **filters: Any) -> int:
108
+ """Количество строк с фильтром по равенству колонок."""
109
+ stmt = select(func.count()).select_from(self.model)
110
+ for column, value in filters.items():
111
+ stmt = stmt.where(getattr(self.model, column) == value)
112
+ return int(self.session.execute(stmt).scalar_one())
113
+
114
+
115
+ __all__ = [
116
+ "BaseRepository",
117
+ ]
ormkit/unit_of_work.py ADDED
@@ -0,0 +1,57 @@
1
+ """Транзакционная граница поверх фабрики сессий.
2
+
3
+ `UnitOfWork` открывает Session на входе в контекст и гарантированно закрывает её на выходе.
4
+ При исключении в блоке — автоматический rollback; при штатном выходе транзакция остаётся
5
+ на явный `commit()` прикладного кода.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from types import TracebackType
10
+
11
+ from sqlalchemy.orm import Session, sessionmaker
12
+
13
+
14
+ class UnitOfWork:
15
+ """Контекст-менеджер транзакционной границы.
16
+
17
+ Пример::
18
+
19
+ with UnitOfWork(session_factory) as uow:
20
+ repo = MyRepo(uow.session, MyOrm)
21
+ repo.add(x)
22
+ uow.commit()
23
+ """
24
+
25
+ session: Session
26
+
27
+ def __init__(self, session_factory: sessionmaker[Session]) -> None:
28
+ self._session_factory = session_factory
29
+
30
+ def __enter__(self) -> UnitOfWork:
31
+ self.session = self._session_factory()
32
+ return self
33
+
34
+ def __exit__(
35
+ self,
36
+ exc_type: type[BaseException] | None,
37
+ exc: BaseException | None,
38
+ tb: TracebackType | None,
39
+ ) -> None:
40
+ try:
41
+ if exc_type is not None:
42
+ self.rollback()
43
+ finally:
44
+ self.session.close()
45
+
46
+ def commit(self) -> None:
47
+ """Зафиксировать транзакцию."""
48
+ self.session.commit()
49
+
50
+ def rollback(self) -> None:
51
+ """Откатить транзакцию."""
52
+ self.session.rollback()
53
+
54
+
55
+ __all__ = [
56
+ "UnitOfWork",
57
+ ]
@@ -0,0 +1,174 @@
1
+ Metadata-Version: 2.4
2
+ Name: s-ormkit
3
+ Version: 0.0.1
4
+ Summary: Generic БД/ORM-инфраструктура на SQLAlchemy 2.0: диалект-нейтральный движок из DB-URL, Repository + UnitOfWork + DIP-протоколы. 0 завязок на конкретное приложение.
5
+ Author: Dmitry
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: sqlalchemy>=2.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: psycopg[binary]>=3.1; extra == 'dev'
12
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
13
+ Requires-Dist: pytest-cov>=6.0; extra == 'dev'
14
+ Requires-Dist: pytest>=8; extra == 'dev'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # s-ormkit
18
+
19
+ Generic БД/ORM-инфраструктура для экосистемы S-kits на SQLAlchemy 2.0: диалект-нейтральный
20
+ движок из DB-URL, Repository + UnitOfWork + DIP-протоколы.
21
+
22
+ **Версия:** 0.0.1 (первый релиз)
23
+ **Лицензия:** MIT
24
+ **Зависимости:** `SQLAlchemy>=2.0` (и ничего больше)
25
+
26
+ Кит НЕ знает ни о каком приложении. Прикладная логика получает БД-слой через ORM так,
27
+ что не зависит от sqlite/диалекта: движок конфигурируется через DB-URL и заменяется на
28
+ `postgresql://.../mysql://...` **без изменения кода**.
29
+
30
+ ## Что входит
31
+
32
+ ### Declarative-база и миксины (`base.py`)
33
+ - `Base` — общий declarative base для моделей-наследников
34
+ - `IntPkMixin` — целочисленный автоинкрементный первичный ключ
35
+ - `TimestampMixin` — `created_at` / `updated_at` через `func.now()` (диалект-нейтрально)
36
+
37
+ ### Движок из DB-URL (`engine.py`)
38
+ - `make_engine` — резолв URL (аргумент → env → default), для sqlite включает
39
+ `PRAGMA foreign_keys=ON`; `create_engine` ленив (postgres-URL создаётся без коннекта)
40
+ - `make_session_factory` — `sessionmaker(expire_on_commit=False)`
41
+ - `init_schema` / `drop_schema` — создать/удалить таблицы
42
+ - `dispose_engine` — освободить пул соединений
43
+
44
+ ### Repository-паттерн (`repository.py`)
45
+ - `BaseRepository[TModel, TDomain]` — generic CRUD: `add / get / get_or_none / list /
46
+ update / remove / count`
47
+ - Маппинг ORM <-> domain через переопределяемые хуки `to_domain` / `to_orm`
48
+ - Не коммитит сам (это делает `UnitOfWork`) — только `flush` для получения id
49
+
50
+ ### Транзакционная граница (`unit_of_work.py`)
51
+ - `UnitOfWork` — контекст-менеджер: rollback при исключении, `close` всегда, `commit` явный
52
+
53
+ ### DIP-контракты (`protocols.py`)
54
+ - `RepositoryProtocol` / `UnitOfWorkProtocol` — чтобы сервисы зависели от абстракций,
55
+ а не от `BaseRepository` / SQLAlchemy
56
+
57
+ ## Быстрый старт
58
+
59
+ Кит generic — вы объявляете **свои** модели поверх `Base` и миксинов:
60
+
61
+ ```python
62
+ from dataclasses import dataclass
63
+
64
+ from sqlalchemy import String
65
+ from sqlalchemy.orm import Mapped, mapped_column
66
+
67
+ from ormkit import (
68
+ Base, IntPkMixin, TimestampMixin,
69
+ BaseRepository, UnitOfWork,
70
+ make_engine, make_session_factory, init_schema,
71
+ )
72
+
73
+
74
+ # 1. Свои ORM-модели
75
+ class User(Base, IntPkMixin, TimestampMixin):
76
+ __tablename__ = "users"
77
+ name: Mapped[str] = mapped_column(String(100))
78
+
79
+
80
+ # 2. Свой доменный объект (dataclass / pydantic / dict — кит не знает тип)
81
+ @dataclass
82
+ class UserDTO:
83
+ name: str
84
+ id: int | None = None
85
+
86
+
87
+ # 3. Свой репозиторий — переопределяем ТОЛЬКО хуки маппинга
88
+ class UserRepository(BaseRepository[User, UserDTO]):
89
+ def to_domain(self, obj: User) -> UserDTO:
90
+ return UserDTO(id=obj.id, name=obj.name)
91
+
92
+ def to_orm(self, domain: UserDTO) -> User:
93
+ return User(name=domain.name)
94
+ ```
95
+
96
+ ### Конфигурация движка через DB-URL
97
+
98
+ ```python
99
+ # sqlite по умолчанию (для локали / тестов), FK enforcement включён автоматически
100
+ engine = make_engine("sqlite:///app.db")
101
+
102
+ # та же строка кода на проде — просто другой URL, коду всё равно:
103
+ engine = make_engine(default="postgresql://user:pass@host/db")
104
+ # или через переменную окружения ORMKIT_DB_URL:
105
+ engine = make_engine()
106
+
107
+ init_schema(engine)
108
+ session_factory = make_session_factory(engine)
109
+ ```
110
+
111
+ ### Работа через UnitOfWork
112
+
113
+ ```python
114
+ with UnitOfWork(session_factory) as uow:
115
+ repo = UserRepository(uow.session, User)
116
+
117
+ saved = repo.add(UserDTO(name="Алиса")) # flush -> id проставлен
118
+ found = repo.get(saved.id) # NotFoundError, если нет
119
+ everyone = repo.list() # фильтр: repo.list(name="Алиса")
120
+ repo.update(saved.id, name="Алиса Смит")
121
+ total = repo.count()
122
+
123
+ uow.commit() # без commit транзакция не персистится
124
+ # исключение в блоке -> автоматический rollback; сессия всегда закрывается
125
+ ```
126
+
127
+ ### DIP: сервис зависит от абстракции
128
+
129
+ ```python
130
+ from ormkit import RepositoryProtocol
131
+
132
+ def register_user(repo: RepositoryProtocol[UserDTO], name: str) -> UserDTO:
133
+ return repo.add(UserDTO(name=name))
134
+ # в проде — UserRepository, в тестах — in-memory фейк, реализующий тот же протокол
135
+ ```
136
+
137
+ ## Архитектура
138
+
139
+ ```
140
+ ormkit/
141
+ ├── __init__.py # Public API + __version__
142
+ ├── exceptions.py # OrmKitError, NotFoundError
143
+ ├── base.py # Base, IntPkMixin, TimestampMixin
144
+ ├── engine.py # make_engine, make_session_factory, init/drop_schema, dispose
145
+ ├── repository.py # BaseRepository[TModel, TDomain]
146
+ ├── unit_of_work.py # UnitOfWork
147
+ └── protocols.py # RepositoryProtocol, UnitOfWorkProtocol
148
+ ```
149
+
150
+ ## Тестирование
151
+
152
+ ```bash
153
+ uv sync --extra dev
154
+ uv run pytest -q # зелёный, coverage >= 80%
155
+ uv run ruff check . # чисто
156
+ ```
157
+
158
+ Тесты объявляют **собственные** демо-модели (`_User`, `_Post`) прямо в `conftest.py` —
159
+ это доказывает, что кит generic и не тянет никакого приложения.
160
+
161
+ ## Диалект-нейтральность
162
+
163
+ - **Один код — любая СУБД.** Меняется только DB-URL: `sqlite://` ↔ `postgresql://` ↔
164
+ `mysql://`. Прикладной код не трогается.
165
+ - **sqlite FK enforcement.** Для sqlite `make_engine` навешивает `PRAGMA foreign_keys=ON`
166
+ (иначе sqlite молча игнорирует внешние ключи). Отключается флагом `foreign_keys=False`.
167
+ - **Временные метки.** `TimestampMixin` использует `func.now()`, работающий и в sqlite,
168
+ и в postgres/mysql.
169
+ - **Ленивое создание.** `make_engine("postgresql://...")` не коннектится — движок создаётся
170
+ без живого сервера.
171
+
172
+ ## Лицензия
173
+
174
+ MIT
@@ -0,0 +1,11 @@
1
+ ormkit/__init__.py,sha256=MAixki_setcUtu_1mh3VryJzRECBXHvyAcsSyZ58gWg,1978
2
+ ormkit/base.py,sha256=-mj2xypBLmxL91vxT3j-tLAFJRsRZD3F1oj767qE5y4,1552
3
+ ormkit/engine.py,sha256=6BFazYC3Yw2AxecgUxJNQGXFqS93RWRcIryKJikl-Fk,3342
4
+ ormkit/exceptions.py,sha256=gvjxWrj7IVZHNlIylUclfGKr3JrU_aia8Fzl4DD8HBU,371
5
+ ormkit/protocols.py,sha256=BRto3bfLuJjNqr72F4igagkiccYypp-domugmgEZdC0,1597
6
+ ormkit/repository.py,sha256=2sdyg5_Z1ukmasK2XMtTwC5usdyw7MAdm3Nt94PQn_s,5032
7
+ ormkit/unit_of_work.py,sha256=oHxDRU1osX1vcwTRVF525Bkhknmr0FGUw-2uOqwiEwc,1681
8
+ s_ormkit-0.0.1.dist-info/METADATA,sha256=oz6RQM4WQNBP4cTmULnnEGPaDnYp9pNqDnn5TUmXk2I,7585
9
+ s_ormkit-0.0.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
10
+ s_ormkit-0.0.1.dist-info/licenses/LICENSE,sha256=j9GKJmUNdQuKRUbKhbpv0uyMaL99xsxE6L2TDtXuaZ4,1063
11
+ s_ormkit-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dmitry
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.