s-ormkit 0.0.1__tar.gz

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.
@@ -0,0 +1,6 @@
1
+ dist/
2
+ .venv
3
+ __pycache__/
4
+ .pytest_cache/
5
+ *.egg-info/
6
+ .coverage
@@ -0,0 +1,36 @@
1
+ # Changelog
2
+
3
+ Все значительные изменения этого проекта будут документироваться в этом файле.
4
+
5
+ Формат основан на [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ и этот проект соответствует [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.0.1] - 2026-07-04
9
+
10
+ ### Added
11
+
12
+ - **Declarative-база и миксины** (`Base`, `IntPkMixin`, `TimestampMixin`): общий
13
+ SQLAlchemy 2.0 declarative base и переиспользуемые миксины (int-PK, диалект-нейтральные
14
+ временные метки через `func.now()`)
15
+ - **Движок из DB-URL** (`make_engine`, `make_session_factory`, `init_schema`, `drop_schema`,
16
+ `dispose_engine`): резолв URL (аргумент → env `ORMKIT_DB_URL` → default), ленивое
17
+ создание движка (postgres-URL без коннекта), автоматический `PRAGMA foreign_keys=ON` для
18
+ sqlite
19
+ - **Repository-паттерн** (`BaseRepository[TModel, TDomain]`): generic CRUD
20
+ (`add / get / get_or_none / list / update / remove / count`) с маппингом ORM <-> domain
21
+ через переопределяемые хуки `to_domain` / `to_orm`
22
+ - **Транзакционная граница** (`UnitOfWork`): контекст-менеджер с rollback при исключении,
23
+ гарантированным `close` и явным `commit`
24
+ - **DIP-контракты** (`RepositoryProtocol`, `UnitOfWorkProtocol`): Protocol'ы для зависимости
25
+ прикладного слоя от абстракций, а не от `BaseRepository` / SQLAlchemy
26
+ - **Исключения** (`OrmKitError`, `NotFoundError`)
27
+ - **Тесты** (unit + integration, coverage ≥ 80%): резолв DB-URL, FK enforcement на sqlite,
28
+ round-trip CRUD, транзакции UoW, диалект-нейтральность временных меток; демо-модели
29
+ объявлены в самих тестах
30
+
31
+ ### Notes
32
+
33
+ - Первый релиз кита `s-ormkit` как generic БД/ORM-инфраструктуры для экосистемы S-kits
34
+ - 0 завязок на конкретное приложение: только чистая инфраструктура
35
+ - Зависит ТОЛЬКО от SQLAlchemy>=2.0
36
+ - Диалект-нейтрально: движок заменяется через DB-URL (sqlite → postgres → mysql) без правок кода
s_ormkit-0.0.1/LICENSE ADDED
@@ -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.
@@ -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,158 @@
1
+ # s-ormkit
2
+
3
+ Generic БД/ORM-инфраструктура для экосистемы S-kits на SQLAlchemy 2.0: диалект-нейтральный
4
+ движок из DB-URL, Repository + UnitOfWork + DIP-протоколы.
5
+
6
+ **Версия:** 0.0.1 (первый релиз)
7
+ **Лицензия:** MIT
8
+ **Зависимости:** `SQLAlchemy>=2.0` (и ничего больше)
9
+
10
+ Кит НЕ знает ни о каком приложении. Прикладная логика получает БД-слой через ORM так,
11
+ что не зависит от sqlite/диалекта: движок конфигурируется через DB-URL и заменяется на
12
+ `postgresql://.../mysql://...` **без изменения кода**.
13
+
14
+ ## Что входит
15
+
16
+ ### Declarative-база и миксины (`base.py`)
17
+ - `Base` — общий declarative base для моделей-наследников
18
+ - `IntPkMixin` — целочисленный автоинкрементный первичный ключ
19
+ - `TimestampMixin` — `created_at` / `updated_at` через `func.now()` (диалект-нейтрально)
20
+
21
+ ### Движок из DB-URL (`engine.py`)
22
+ - `make_engine` — резолв URL (аргумент → env → default), для sqlite включает
23
+ `PRAGMA foreign_keys=ON`; `create_engine` ленив (postgres-URL создаётся без коннекта)
24
+ - `make_session_factory` — `sessionmaker(expire_on_commit=False)`
25
+ - `init_schema` / `drop_schema` — создать/удалить таблицы
26
+ - `dispose_engine` — освободить пул соединений
27
+
28
+ ### Repository-паттерн (`repository.py`)
29
+ - `BaseRepository[TModel, TDomain]` — generic CRUD: `add / get / get_or_none / list /
30
+ update / remove / count`
31
+ - Маппинг ORM <-> domain через переопределяемые хуки `to_domain` / `to_orm`
32
+ - Не коммитит сам (это делает `UnitOfWork`) — только `flush` для получения id
33
+
34
+ ### Транзакционная граница (`unit_of_work.py`)
35
+ - `UnitOfWork` — контекст-менеджер: rollback при исключении, `close` всегда, `commit` явный
36
+
37
+ ### DIP-контракты (`protocols.py`)
38
+ - `RepositoryProtocol` / `UnitOfWorkProtocol` — чтобы сервисы зависели от абстракций,
39
+ а не от `BaseRepository` / SQLAlchemy
40
+
41
+ ## Быстрый старт
42
+
43
+ Кит generic — вы объявляете **свои** модели поверх `Base` и миксинов:
44
+
45
+ ```python
46
+ from dataclasses import dataclass
47
+
48
+ from sqlalchemy import String
49
+ from sqlalchemy.orm import Mapped, mapped_column
50
+
51
+ from ormkit import (
52
+ Base, IntPkMixin, TimestampMixin,
53
+ BaseRepository, UnitOfWork,
54
+ make_engine, make_session_factory, init_schema,
55
+ )
56
+
57
+
58
+ # 1. Свои ORM-модели
59
+ class User(Base, IntPkMixin, TimestampMixin):
60
+ __tablename__ = "users"
61
+ name: Mapped[str] = mapped_column(String(100))
62
+
63
+
64
+ # 2. Свой доменный объект (dataclass / pydantic / dict — кит не знает тип)
65
+ @dataclass
66
+ class UserDTO:
67
+ name: str
68
+ id: int | None = None
69
+
70
+
71
+ # 3. Свой репозиторий — переопределяем ТОЛЬКО хуки маппинга
72
+ class UserRepository(BaseRepository[User, UserDTO]):
73
+ def to_domain(self, obj: User) -> UserDTO:
74
+ return UserDTO(id=obj.id, name=obj.name)
75
+
76
+ def to_orm(self, domain: UserDTO) -> User:
77
+ return User(name=domain.name)
78
+ ```
79
+
80
+ ### Конфигурация движка через DB-URL
81
+
82
+ ```python
83
+ # sqlite по умолчанию (для локали / тестов), FK enforcement включён автоматически
84
+ engine = make_engine("sqlite:///app.db")
85
+
86
+ # та же строка кода на проде — просто другой URL, коду всё равно:
87
+ engine = make_engine(default="postgresql://user:pass@host/db")
88
+ # или через переменную окружения ORMKIT_DB_URL:
89
+ engine = make_engine()
90
+
91
+ init_schema(engine)
92
+ session_factory = make_session_factory(engine)
93
+ ```
94
+
95
+ ### Работа через UnitOfWork
96
+
97
+ ```python
98
+ with UnitOfWork(session_factory) as uow:
99
+ repo = UserRepository(uow.session, User)
100
+
101
+ saved = repo.add(UserDTO(name="Алиса")) # flush -> id проставлен
102
+ found = repo.get(saved.id) # NotFoundError, если нет
103
+ everyone = repo.list() # фильтр: repo.list(name="Алиса")
104
+ repo.update(saved.id, name="Алиса Смит")
105
+ total = repo.count()
106
+
107
+ uow.commit() # без commit транзакция не персистится
108
+ # исключение в блоке -> автоматический rollback; сессия всегда закрывается
109
+ ```
110
+
111
+ ### DIP: сервис зависит от абстракции
112
+
113
+ ```python
114
+ from ormkit import RepositoryProtocol
115
+
116
+ def register_user(repo: RepositoryProtocol[UserDTO], name: str) -> UserDTO:
117
+ return repo.add(UserDTO(name=name))
118
+ # в проде — UserRepository, в тестах — in-memory фейк, реализующий тот же протокол
119
+ ```
120
+
121
+ ## Архитектура
122
+
123
+ ```
124
+ ormkit/
125
+ ├── __init__.py # Public API + __version__
126
+ ├── exceptions.py # OrmKitError, NotFoundError
127
+ ├── base.py # Base, IntPkMixin, TimestampMixin
128
+ ├── engine.py # make_engine, make_session_factory, init/drop_schema, dispose
129
+ ├── repository.py # BaseRepository[TModel, TDomain]
130
+ ├── unit_of_work.py # UnitOfWork
131
+ └── protocols.py # RepositoryProtocol, UnitOfWorkProtocol
132
+ ```
133
+
134
+ ## Тестирование
135
+
136
+ ```bash
137
+ uv sync --extra dev
138
+ uv run pytest -q # зелёный, coverage >= 80%
139
+ uv run ruff check . # чисто
140
+ ```
141
+
142
+ Тесты объявляют **собственные** демо-модели (`_User`, `_Post`) прямо в `conftest.py` —
143
+ это доказывает, что кит generic и не тянет никакого приложения.
144
+
145
+ ## Диалект-нейтральность
146
+
147
+ - **Один код — любая СУБД.** Меняется только DB-URL: `sqlite://` ↔ `postgresql://` ↔
148
+ `mysql://`. Прикладной код не трогается.
149
+ - **sqlite FK enforcement.** Для sqlite `make_engine` навешивает `PRAGMA foreign_keys=ON`
150
+ (иначе sqlite молча игнорирует внешние ключи). Отключается флагом `foreign_keys=False`.
151
+ - **Временные метки.** `TimestampMixin` использует `func.now()`, работающий и в sqlite,
152
+ и в postgres/mysql.
153
+ - **Ленивое создание.** `make_engine("postgresql://...")` не коннектится — движок создаётся
154
+ без живого сервера.
155
+
156
+ ## Лицензия
157
+
158
+ MIT
@@ -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
+ ]
@@ -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
+ ]
@@ -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
+ ]
@@ -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
+ ]
@@ -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
+ ]