SARepo 0.1.0__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.
sarepo-0.1.0/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+
2
+ MIT License
3
+
4
+ Copyright (c) 2025
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ ... (shortened) ...
sarepo-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: SARepo
3
+ Version: 0.1.0
4
+ Summary: Minimal, explicit Repository & Unit-of-Work layer over SQLAlchemy 2.x
5
+ Author: nurbergenovv
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: SQLAlchemy>=2.0
11
+ Dynamic: license-file
12
+
13
+
14
+ # SARepo
15
+
16
+ Minimal, explicit **Repository** + **Unit of Work** layer on top of **SQLAlchemy 2.x** (sync & async).
17
+ No magic method-names, just clean typed APIs, composable specs, pagination, and optional soft-delete.
18
+
19
+ ## Install (editable)
20
+
21
+ ```bash
22
+ pip install -e .
23
+ ```
24
+
25
+ ## Quick Start (sync)
26
+
27
+ ```python
28
+ from sqlalchemy import create_engine
29
+ from sqlalchemy.orm import sessionmaker, Mapped, mapped_column
30
+ from sqlalchemy.orm import DeclarativeBase
31
+ from sqlalchemy import String
32
+
33
+ from SARepo.sa_repo import SARepository
34
+ from SARepo.base import PageRequest
35
+ from SARepo.specs import and_specs, ilike
36
+
37
+ class Base(DeclarativeBase): pass
38
+
39
+ class Request(Base):
40
+ __tablename__ = "requests"
41
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
42
+ title: Mapped[str] = mapped_column(String(255))
43
+ status: Mapped[str] = mapped_column(String(50))
44
+
45
+ engine = create_engine("sqlite+pysqlite:///:memory:", echo=False)
46
+ Base.metadata.create_all(engine)
47
+ SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
48
+
49
+ with SessionLocal() as session:
50
+ repo = SARepository(Request, session)
51
+ # create
52
+ r = repo.add(Request(title="Hello", status="NEW"))
53
+ session.commit()
54
+
55
+ # search + paginate
56
+ page = repo.page(PageRequest(0, 10), spec=ilike(Request.title, "%He%"))
57
+ print(page.total, [i.title for i in page.items])
58
+ ```
59
+
60
+ ## Async Quick Start
61
+
62
+ ```python
63
+ import asyncio
64
+ from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
65
+ from SARepo.sa_repo import SAAsyncRepository
66
+ from SARepo.base import PageRequest
67
+
68
+ async def main():
69
+ engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
70
+ async with engine.begin() as conn:
71
+ await conn.run_sync(Base.metadata.create_all)
72
+ SessionLocal = async_sessionmaker(bind=engine, expire_on_commit=False)
73
+ async with SessionLocal() as session:
74
+ repo = SAAsyncRepository(Request, session)
75
+ await repo.add(Request(title="Hello", status="NEW"))
76
+ await session.commit()
77
+ page = await repo.page(PageRequest(0, 10))
78
+ print(page.total)
79
+
80
+ asyncio.run(main())
81
+ ```
82
+
83
+ ## Features
84
+ - `SARepository` and `SAAsyncRepository` (CRUD, `page()` with total count)
85
+ - Composable **specs** (`eq`, `ilike`, `and_specs`, `not_deleted`)
86
+ - Optional **soft-delete** if the entity has `is_deleted: bool`
87
+ - Minimal **Unit of Work** helpers (sync & async)
88
+
89
+ ## License
90
+ MIT
sarepo-0.1.0/README.md ADDED
@@ -0,0 +1,78 @@
1
+
2
+ # SARepo
3
+
4
+ Minimal, explicit **Repository** + **Unit of Work** layer on top of **SQLAlchemy 2.x** (sync & async).
5
+ No magic method-names, just clean typed APIs, composable specs, pagination, and optional soft-delete.
6
+
7
+ ## Install (editable)
8
+
9
+ ```bash
10
+ pip install -e .
11
+ ```
12
+
13
+ ## Quick Start (sync)
14
+
15
+ ```python
16
+ from sqlalchemy import create_engine
17
+ from sqlalchemy.orm import sessionmaker, Mapped, mapped_column
18
+ from sqlalchemy.orm import DeclarativeBase
19
+ from sqlalchemy import String
20
+
21
+ from SARepo.sa_repo import SARepository
22
+ from SARepo.base import PageRequest
23
+ from SARepo.specs import and_specs, ilike
24
+
25
+ class Base(DeclarativeBase): pass
26
+
27
+ class Request(Base):
28
+ __tablename__ = "requests"
29
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
30
+ title: Mapped[str] = mapped_column(String(255))
31
+ status: Mapped[str] = mapped_column(String(50))
32
+
33
+ engine = create_engine("sqlite+pysqlite:///:memory:", echo=False)
34
+ Base.metadata.create_all(engine)
35
+ SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
36
+
37
+ with SessionLocal() as session:
38
+ repo = SARepository(Request, session)
39
+ # create
40
+ r = repo.add(Request(title="Hello", status="NEW"))
41
+ session.commit()
42
+
43
+ # search + paginate
44
+ page = repo.page(PageRequest(0, 10), spec=ilike(Request.title, "%He%"))
45
+ print(page.total, [i.title for i in page.items])
46
+ ```
47
+
48
+ ## Async Quick Start
49
+
50
+ ```python
51
+ import asyncio
52
+ from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
53
+ from SARepo.sa_repo import SAAsyncRepository
54
+ from SARepo.base import PageRequest
55
+
56
+ async def main():
57
+ engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
58
+ async with engine.begin() as conn:
59
+ await conn.run_sync(Base.metadata.create_all)
60
+ SessionLocal = async_sessionmaker(bind=engine, expire_on_commit=False)
61
+ async with SessionLocal() as session:
62
+ repo = SAAsyncRepository(Request, session)
63
+ await repo.add(Request(title="Hello", status="NEW"))
64
+ await session.commit()
65
+ page = await repo.page(PageRequest(0, 10))
66
+ print(page.total)
67
+
68
+ asyncio.run(main())
69
+ ```
70
+
71
+ ## Features
72
+ - `SARepository` and `SAAsyncRepository` (CRUD, `page()` with total count)
73
+ - Composable **specs** (`eq`, `ilike`, `and_specs`, `not_deleted`)
74
+ - Optional **soft-delete** if the entity has `is_deleted: bool`
75
+ - Minimal **Unit of Work** helpers (sync & async)
76
+
77
+ ## License
78
+ MIT
@@ -0,0 +1,5 @@
1
+
2
+ from .base import Page, PageRequest, NotFoundError, ConcurrencyError
3
+ from .sa_repo import SARepository, SAAsyncRepository
4
+ from .uow import UoW, AsyncUoW
5
+ from . import specs as specs
@@ -0,0 +1,31 @@
1
+
2
+ from typing import Generic, TypeVar, Sequence
3
+ T = TypeVar("T")
4
+
5
+ class NotFoundError(Exception):
6
+ """Raised when entity not found."""
7
+ pass
8
+
9
+ class ConcurrencyError(Exception):
10
+ """Raised when optimistic lock/version conflicts occur."""
11
+ pass
12
+
13
+ class PageRequest:
14
+ def __init__(self, page: int = 0, size: int = 10):
15
+ if page < 0:
16
+ raise ValueError("page must be >= 0")
17
+ if not (1 <= size <= 10000):
18
+ raise ValueError("size must be in [1, 10000]")
19
+ self.page = page
20
+ self.size = size
21
+
22
+ class Page(Generic[T]):
23
+ def __init__(self, items: Sequence[T], total: int, page: int, size: int):
24
+ self.items = list(items)
25
+ self.total = int(total)
26
+ self.page = int(page)
27
+ self.size = int(size)
28
+
29
+ @property
30
+ def pages(self) -> int:
31
+ return (self.total + self.size - 1) // self.size
@@ -0,0 +1,14 @@
1
+
2
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
3
+ from sqlalchemy import func, DateTime, Boolean
4
+
5
+ class Base(DeclarativeBase):
6
+ """Base for user models if you don't want to declare your own."""
7
+ pass
8
+
9
+ class TimeStamped:
10
+ created_at: Mapped["datetime"] = mapped_column(DateTime(timezone=True), server_default=func.now())
11
+ updated_at: Mapped["datetime"] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
12
+
13
+ class SoftDelete:
14
+ is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
@@ -0,0 +1,14 @@
1
+
2
+ from typing import Generic, TypeVar, Type, Optional, Any, Protocol
3
+ from .base import Page, PageRequest
4
+
5
+ T = TypeVar("T")
6
+
7
+ class CrudRepository(Protocol, Generic[T]):
8
+ model: Type[T]
9
+ def get(self, id_: Any) -> T: ...
10
+ def try_get(self, id_: Any) -> Optional[T]: ...
11
+ def add(self, entity: T) -> T: ...
12
+ def update(self, entity: T) -> T: ...
13
+ def remove(self, entity: T) -> None: ...
14
+ def page(self, page: PageRequest, spec=None, order_by=None) -> Page[T]: ...
@@ -0,0 +1,108 @@
1
+
2
+ from typing import Type, Generic, TypeVar, Optional, Sequence, Any, Callable
3
+ from sqlalchemy.orm import Session
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+ from sqlalchemy import select, func
6
+ from .base import PageRequest, Page, NotFoundError
7
+
8
+ T = TypeVar("T")
9
+ Spec = Callable # aliased to match specs.Spec
10
+
11
+ class SARepository(Generic[T]):
12
+ """Synchronous repository implementation for SQLAlchemy 2.x."""
13
+ def __init__(self, model: Type[T], session: Session):
14
+ self.model = model
15
+ self.session = session
16
+
17
+ def _select(self):
18
+ return select(self.model)
19
+
20
+ def get(self, id_: Any) -> T:
21
+ obj = self.session.get(self.model, id_)
22
+ if not obj:
23
+ raise NotFoundError(f"{self.model.__name__}({id_}) not found")
24
+ return obj
25
+
26
+ def try_get(self, id_: Any) -> Optional[T]:
27
+ return self.session.get(self.model, id_)
28
+
29
+ def add(self, entity: T) -> T:
30
+ self.session.add(entity)
31
+ self.session.flush()
32
+ self.session.refresh(entity)
33
+ return entity
34
+
35
+ def update(self, entity: T) -> T:
36
+ self.session.flush()
37
+ self.session.refresh(entity)
38
+ return entity
39
+
40
+ def remove(self, entity: T) -> None:
41
+ if hasattr(entity, "is_deleted"):
42
+ setattr(entity, "is_deleted", True)
43
+ else:
44
+ self.session.delete(entity)
45
+
46
+ def page(self, page: PageRequest, spec: Optional[Spec] = None, order_by=None) -> Page[T]:
47
+ stmt = self._select()
48
+ if spec:
49
+ stmt = spec(stmt)
50
+ if order_by is not None:
51
+ stmt = stmt.order_by(order_by)
52
+ total = self.session.execute(
53
+ select(func.count()).select_from(stmt.subquery())
54
+ ).scalar_one()
55
+ items = self.session.execute(
56
+ stmt.offset(page.page * page.size).limit(page.size)
57
+ ).scalars().all()
58
+ return Page(items, total, page.page, page.size)
59
+
60
+ class SAAsyncRepository(Generic[T]):
61
+ """Async repository implementation for SQLAlchemy 2.x."""
62
+ def __init__(self, model: Type[T], session: AsyncSession):
63
+ self.model = model
64
+ self.session = session
65
+
66
+ def _select(self):
67
+ return select(self.model)
68
+
69
+ async def get(self, id_: Any) -> T:
70
+ obj = await self.session.get(self.model, id_)
71
+ if not obj:
72
+ raise NotFoundError(f"{self.model.__name__}({id_}) not found")
73
+ return obj
74
+
75
+ async def try_get(self, id_: Any) -> Optional[T]:
76
+ return await self.session.get(self.model, id_)
77
+
78
+ async def add(self, entity: T) -> T:
79
+ self.session.add(entity)
80
+ await self.session.flush()
81
+ await self.session.refresh(entity)
82
+ return entity
83
+
84
+ async def update(self, entity: T) -> T:
85
+ await self.session.flush()
86
+ await self.session.refresh(entity)
87
+ return entity
88
+
89
+ async def remove(self, entity: T) -> None:
90
+ if hasattr(entity, "is_deleted"):
91
+ setattr(entity, "is_deleted", True)
92
+ else:
93
+ await self.session.delete(entity)
94
+
95
+ async def page(self, page: PageRequest, spec: Optional[Spec] = None, order_by=None) -> Page[T]:
96
+ stmt = self._select()
97
+ if spec:
98
+ stmt = spec(stmt)
99
+ if order_by is not None:
100
+ stmt = stmt.order_by(order_by)
101
+ total = (await self.session.execute(
102
+ select(func.count()).select_from(stmt.subquery())
103
+ )).scalar_one()
104
+ res = await self.session.execute(
105
+ stmt.offset(page.page * page.size).limit(page.size)
106
+ )
107
+ items = res.scalars().all()
108
+ return Page(items, total, page.page, page.size)
@@ -0,0 +1,27 @@
1
+
2
+ from typing import Callable
3
+ from sqlalchemy.sql import Select
4
+
5
+ Spec = Callable[[Select], Select]
6
+
7
+ def and_specs(*specs: Spec) -> Spec:
8
+ def _apply(q: Select) -> Select:
9
+ for s in specs:
10
+ q = s(q)
11
+ return q
12
+ return _apply
13
+
14
+ def eq(model_attr, value) -> Spec:
15
+ def _s(q: Select) -> Select:
16
+ return q.where(model_attr == value)
17
+ return _s
18
+
19
+ def ilike(model_attr, pattern: str) -> Spec:
20
+ def _s(q: Select) -> Select:
21
+ return q.where(model_attr.ilike(pattern))
22
+ return _s
23
+
24
+ def not_deleted(model_cls) -> Spec:
25
+ def _s(q: Select) -> Select:
26
+ return q.where(getattr(model_cls, "is_deleted") == False) # noqa: E712
27
+ return _s
@@ -0,0 +1,28 @@
1
+
2
+ from contextlib import AbstractContextManager
3
+ from sqlalchemy.orm import Session
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+
6
+ class UoW(AbstractContextManager):
7
+ """Minimal Unit of Work for sync SQLAlchemy sessions."""
8
+ def __init__(self, session: Session):
9
+ self.session = session
10
+ def __enter__(self):
11
+ return self
12
+ def __exit__(self, exc_type, *_):
13
+ if exc_type:
14
+ self.session.rollback()
15
+ else:
16
+ self.session.commit()
17
+
18
+ class AsyncUoW:
19
+ """Minimal Unit of Work for async SQLAlchemy sessions."""
20
+ def __init__(self, session: AsyncSession):
21
+ self.session = session
22
+ async def __aenter__(self):
23
+ return self
24
+ async def __aexit__(self, exc_type, *_):
25
+ if exc_type:
26
+ await self.session.rollback()
27
+ else:
28
+ await self.session.commit()
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: SARepo
3
+ Version: 0.1.0
4
+ Summary: Minimal, explicit Repository & Unit-of-Work layer over SQLAlchemy 2.x
5
+ Author: nurbergenovv
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: SQLAlchemy>=2.0
11
+ Dynamic: license-file
12
+
13
+
14
+ # SARepo
15
+
16
+ Minimal, explicit **Repository** + **Unit of Work** layer on top of **SQLAlchemy 2.x** (sync & async).
17
+ No magic method-names, just clean typed APIs, composable specs, pagination, and optional soft-delete.
18
+
19
+ ## Install (editable)
20
+
21
+ ```bash
22
+ pip install -e .
23
+ ```
24
+
25
+ ## Quick Start (sync)
26
+
27
+ ```python
28
+ from sqlalchemy import create_engine
29
+ from sqlalchemy.orm import sessionmaker, Mapped, mapped_column
30
+ from sqlalchemy.orm import DeclarativeBase
31
+ from sqlalchemy import String
32
+
33
+ from SARepo.sa_repo import SARepository
34
+ from SARepo.base import PageRequest
35
+ from SARepo.specs import and_specs, ilike
36
+
37
+ class Base(DeclarativeBase): pass
38
+
39
+ class Request(Base):
40
+ __tablename__ = "requests"
41
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
42
+ title: Mapped[str] = mapped_column(String(255))
43
+ status: Mapped[str] = mapped_column(String(50))
44
+
45
+ engine = create_engine("sqlite+pysqlite:///:memory:", echo=False)
46
+ Base.metadata.create_all(engine)
47
+ SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
48
+
49
+ with SessionLocal() as session:
50
+ repo = SARepository(Request, session)
51
+ # create
52
+ r = repo.add(Request(title="Hello", status="NEW"))
53
+ session.commit()
54
+
55
+ # search + paginate
56
+ page = repo.page(PageRequest(0, 10), spec=ilike(Request.title, "%He%"))
57
+ print(page.total, [i.title for i in page.items])
58
+ ```
59
+
60
+ ## Async Quick Start
61
+
62
+ ```python
63
+ import asyncio
64
+ from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
65
+ from SARepo.sa_repo import SAAsyncRepository
66
+ from SARepo.base import PageRequest
67
+
68
+ async def main():
69
+ engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
70
+ async with engine.begin() as conn:
71
+ await conn.run_sync(Base.metadata.create_all)
72
+ SessionLocal = async_sessionmaker(bind=engine, expire_on_commit=False)
73
+ async with SessionLocal() as session:
74
+ repo = SAAsyncRepository(Request, session)
75
+ await repo.add(Request(title="Hello", status="NEW"))
76
+ await session.commit()
77
+ page = await repo.page(PageRequest(0, 10))
78
+ print(page.total)
79
+
80
+ asyncio.run(main())
81
+ ```
82
+
83
+ ## Features
84
+ - `SARepository` and `SAAsyncRepository` (CRUD, `page()` with total count)
85
+ - Composable **specs** (`eq`, `ilike`, `and_specs`, `not_deleted`)
86
+ - Optional **soft-delete** if the entity has `is_deleted: bool`
87
+ - Minimal **Unit of Work** helpers (sync & async)
88
+
89
+ ## License
90
+ MIT
@@ -0,0 +1,16 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ SARepo/__init__.py
5
+ SARepo/base.py
6
+ SARepo/models.py
7
+ SARepo/repo.py
8
+ SARepo/sa_repo.py
9
+ SARepo/specs.py
10
+ SARepo/uow.py
11
+ SARepo.egg-info/PKG-INFO
12
+ SARepo.egg-info/SOURCES.txt
13
+ SARepo.egg-info/dependency_links.txt
14
+ SARepo.egg-info/requires.txt
15
+ SARepo.egg-info/top_level.txt
16
+ tests/test_sync_basic.py
@@ -0,0 +1 @@
1
+ SQLAlchemy>=2.0
@@ -0,0 +1 @@
1
+ SARepo
@@ -0,0 +1,20 @@
1
+
2
+ [project]
3
+ name = "SARepo"
4
+ version = "0.1.0"
5
+ description = "Minimal, explicit Repository & Unit-of-Work layer over SQLAlchemy 2.x"
6
+ readme = "README.md"
7
+ requires-python = ">=3.11"
8
+ authors = [{name = "nurbergenovv"}]
9
+ license = {text = "MIT"}
10
+ dependencies = [
11
+ "SQLAlchemy>=2.0"
12
+ ]
13
+
14
+ [build-system]
15
+ requires = ["setuptools>=68", "wheel"]
16
+ build-backend = "setuptools.build_meta"
17
+
18
+ [tool.setuptools]
19
+ packages = ["SARepo"]
20
+ include-package-data = true
sarepo-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,30 @@
1
+
2
+ import pytest
3
+ from sqlalchemy import create_engine, String
4
+ from sqlalchemy.orm import sessionmaker, DeclarativeBase, Mapped, mapped_column
5
+ from SARepoo.sa_repo import SARepository
6
+ from SARepoo.base import PageRequest
7
+
8
+ class Base(DeclarativeBase): pass
9
+
10
+ class Item(Base):
11
+ __tablename__ = "items"
12
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
13
+ title: Mapped[str] = mapped_column(String(100))
14
+
15
+ def test_crud_and_pagination():
16
+ engine = create_engine("sqlite+pysqlite:///:memory:", echo=False)
17
+ Base.metadata.create_all(engine)
18
+ SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
19
+
20
+ with SessionLocal() as session:
21
+ repo = SARepository(Item, session)
22
+ for i in range(25):
23
+ repo.add(Item(title=f"t{i}"))
24
+ session.commit()
25
+
26
+ page = repo.page(PageRequest(1, 10))
27
+ assert page.total == 25
28
+ assert len(page.items) == 10
29
+ assert page.page == 1
30
+ assert page.pages == 3