SARepo 0.1.0__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.
SARepo/__init__.py ADDED
@@ -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
SARepo/base.py ADDED
@@ -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
SARepo/models.py ADDED
@@ -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)
SARepo/repo.py ADDED
@@ -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]: ...
SARepo/sa_repo.py ADDED
@@ -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)
SARepo/specs.py ADDED
@@ -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
SARepo/uow.py ADDED
@@ -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,12 @@
1
+ SARepo/__init__.py,sha256=tNYbuUDloC1qVnXq7uomS3jRcaPUvy0VEZiMdYR6x1M,183
2
+ SARepo/base.py,sha256=UbAdZ9WYh_o93mrCVL3D8Q0tY_8mvm0HspO_L5m0GTQ,874
3
+ SARepo/models.py,sha256=ypSmbKAijvNH4WjSeJBgyNT8mKa_e_7-I5kiNisjGAI,570
4
+ SARepo/repo.py,sha256=vCVQmjYifzlO_ggOFwj3Xdyqt4_7ILDoUtHUAFnFyZU,482
5
+ SARepo/sa_repo.py,sha256=mdTe3RyrGklG3z50jr4pOv4aidWxOZ_6dtdLgr83Osk,3617
6
+ SARepo/specs.py,sha256=e-X1cCkva3e71M37hcfbjNDMezZAaOkkRaPToUzhEeU,687
7
+ SARepo/uow.py,sha256=yPlvi7PoH9x2pLNt6hEin9zT5qG9axUKn_TkZcIuz1s,859
8
+ sarepo-0.1.0.dist-info/licenses/LICENSE,sha256=px90NOQCrnZ77qoFT8IvAiNS1CXQ2OU27uVBKbki9DM,131
9
+ sarepo-0.1.0.dist-info/METADATA,sha256=lHz7KBK2U2CAvoxwUB1ytK9d1LeNoiV0ODlvM7BFjgE,2691
10
+ sarepo-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
11
+ sarepo-0.1.0.dist-info/top_level.txt,sha256=0k952UYVZLIIv3kZzFlxI0yzBMusZo3-XCQtxms_kS0,7
12
+ sarepo-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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) ...
@@ -0,0 +1 @@
1
+ SARepo