fastabc 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.
fastabc-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,37 @@
1
+ Metadata-Version: 2.1
2
+ Name: fastabc
3
+ Version: 0.1.0
4
+ Summary: Ready-made API Abstractions: basic models, repositories, unit of work and other stuff.
5
+ Author: Ivan Stasevich
6
+ Author-email: pravitel2015ify@gmail.com
7
+ Requires-Python: >=3.11,<4.0
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Environment :: Web Environment
10
+ Classifier: Framework :: AsyncIO
11
+ Classifier: Framework :: FastAPI
12
+ Classifier: Framework :: Pydantic
13
+ Classifier: Framework :: Pydantic :: 1
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Information Technology
16
+ Classifier: Intended Audience :: System Administrators
17
+ Classifier: License :: OSI Approved :: MIT License
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3 :: Only
24
+ Classifier: Topic :: Internet
25
+ Classifier: Topic :: Internet :: WWW/HTTP
26
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
27
+ Classifier: Topic :: Software Development
28
+ Classifier: Topic :: Software Development :: Libraries
29
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
30
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
31
+ Classifier: Typing :: Typed
32
+ Requires-Dist: pydantic (>=2.7.3,<3.0.0)
33
+ Requires-Dist: pydantic-settings (>=2.3.1,<3.0.0)
34
+ Requires-Dist: sqlalchemy (>=2.0.30,<3.0.0)
35
+ Description-Content-Type: text/markdown
36
+
37
+
File without changes
@@ -0,0 +1,17 @@
1
+ """Ready-made API Abstractions: basic models, repositories, unit of work and other stuff."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .alchemy import SkeletonBase, MixinBase, AlchemyEntity
6
+ from .schemas import SkeletonModel, MixinModel, Page, PageParams, EntityModel
7
+
8
+ __all__ = [
9
+ "SkeletonModel",
10
+ "SkeletonBase",
11
+ "MixinModel",
12
+ "MixinBase",
13
+ "Page",
14
+ "PageParams",
15
+ "AlchemyEntity",
16
+ "EntityModel",
17
+ ]
@@ -0,0 +1,12 @@
1
+ from .gateway import AbstractGateway
2
+ from .repository import AbstractRepository
3
+ from .service import AbstractService
4
+ from .uow import AbstractUOW, FakeUOW
5
+
6
+ __all__ = [
7
+ "AbstractGateway",
8
+ "AbstractService",
9
+ "AbstractUOW",
10
+ "AbstractRepository",
11
+ "FakeUOW",
12
+ ]
@@ -0,0 +1,20 @@
1
+ from abc import ABC
2
+
3
+
4
+ class AbstractGateway(ABC):
5
+ """
6
+ Provides access to application services.
7
+
8
+ e.g.::
9
+
10
+ async def get_user(
11
+ email: str, gateway: Annotated[Gateway, Depends(get_gateway)]
12
+ ) -> None:
13
+ user = await gateway.users.get_by_email(email)
14
+ return user
15
+
16
+
17
+ async def handler(message: types.Message, gateway: Gateway) -> None:
18
+ user = await gateway.users.get_by_email(message.text)
19
+ await message.answer(user.model_dump_json())
20
+ """ # noqa: E501
@@ -0,0 +1,5 @@
1
+ from abc import ABC
2
+
3
+
4
+ class AbstractRepository(ABC):
5
+ """Provides access to the data storage."""
@@ -0,0 +1,5 @@
1
+ from abc import ABC
2
+
3
+
4
+ class AbstractService(ABC):
5
+ """Implements the business logic"""
@@ -0,0 +1,60 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any, Self
3
+
4
+
5
+ class AbstractUOW(ABC):
6
+ """Provides transaction management"""
7
+
8
+ @property
9
+ @abstractmethod
10
+ def is_opened(self) -> bool:
11
+ pass
12
+
13
+ async def on_open(self) -> None:
14
+ pass
15
+
16
+ @abstractmethod
17
+ async def open(self) -> None:
18
+ await self.on_open()
19
+
20
+ @abstractmethod
21
+ async def close(self, type_: Any, value: Any, traceback: Any) -> None:
22
+ pass
23
+
24
+ @abstractmethod
25
+ async def commit(self) -> None:
26
+ pass
27
+
28
+ @abstractmethod
29
+ async def rollback(self) -> None:
30
+ pass
31
+
32
+ async def __aenter__(self) -> Self:
33
+ await self.open()
34
+ return self
35
+
36
+ async def __aexit__(self, type_: Any, value: Any, traceback: Any) -> None:
37
+ await self.rollback()
38
+ await self.close(type_, value, traceback)
39
+
40
+
41
+ class FakeUOW(AbstractUOW, ABC):
42
+ _is_opened: bool = False
43
+
44
+ @property
45
+ def is_opened(self) -> bool:
46
+ return self._is_opened
47
+
48
+ async def open(self) -> None:
49
+ self._is_opened = True
50
+ await super().open()
51
+
52
+ async def close(self, type_: Any, value: Any, traceback: Any) -> None:
53
+ self._is_opened = False
54
+ await super().close(type_, value, traceback)
55
+
56
+ async def commit(self) -> None:
57
+ pass
58
+
59
+ async def rollback(self) -> None:
60
+ pass
@@ -0,0 +1,17 @@
1
+ from .base import SkeletonBase
2
+ from .mixins import AlchemyEntity, HasTimestamp, MixinBase
3
+ from .repository import AlchemyRepository
4
+ from .testing import TestUser
5
+ from .uow import AlchemyUOW
6
+ from .service import AlchemyService
7
+
8
+ __all__ = [
9
+ "SkeletonBase",
10
+ "AlchemyEntity",
11
+ "AlchemyRepository",
12
+ "HasTimestamp",
13
+ "TestUser",
14
+ "MixinBase",
15
+ "AlchemyUOW",
16
+ "AlchemyService",
17
+ ]
@@ -0,0 +1,41 @@
1
+ from enum import Enum
2
+ from typing import Any, Self
3
+
4
+ from pydantic import BaseModel
5
+ from sqlalchemy import BigInteger, inspect, Enum as SAEnum, MetaData
6
+ from sqlalchemy.orm import (
7
+ DeclarativeBase,
8
+ )
9
+
10
+ type_map = {int: BigInteger, Enum: SAEnum(Enum, native_enum=False)}
11
+
12
+ POSTGRES_INDEXES_NAMING_CONVENTION = {
13
+ "ix": "%(column_0_label)s_idx",
14
+ "uq": "%(table_name)s_%(column_0_name)s_key",
15
+ "ck": "%(table_name)s_%(constraint_name)s_check",
16
+ "fk": "%(table_name)s_%(column_0_name)s_fkey",
17
+ "pk": "%(table_name)s_pkey",
18
+ }
19
+ metadata = MetaData(naming_convention=POSTGRES_INDEXES_NAMING_CONVENTION)
20
+
21
+
22
+ class SkeletonBase(DeclarativeBase):
23
+ type_annotation_map = type_map
24
+ metadata = metadata
25
+
26
+ def update(self, attributes: BaseModel | dict[str, Any]) -> Self:
27
+ """Update an object with given attribute values."""
28
+ if isinstance(attributes, BaseModel):
29
+ attributes = attributes.model_dump(exclude_unset=True)
30
+
31
+ for name, value in attributes.items():
32
+ setattr(self, name, value)
33
+
34
+ return self
35
+
36
+ def dump(self) -> dict[str, Any]:
37
+ """Dump the object to a dictionary."""
38
+ return {
39
+ c.key: getattr(self, c.key)
40
+ for c in inspect(self).mapper.column_attrs # noqa
41
+ }
@@ -0,0 +1,52 @@
1
+ import datetime
2
+
3
+ from sqlalchemy import func, Identity
4
+ from sqlalchemy.orm import mapped_column, Mapped
5
+
6
+ from .base import SkeletonBase
7
+
8
+
9
+ # https://docs.sqlalchemy.org/en/20/core/defaults.html
10
+ # https://docs.sqlalchemy.org/en/20/orm/declarative_config.html#abstract
11
+ # https://docs.sqlalchemy.org/en/20/orm/declarative_tables.html#orm-declarative-mapped-column-type-map-pep593
12
+
13
+
14
+ class MixinBase(SkeletonBase):
15
+ __abstract__ = True
16
+
17
+
18
+ class HasID(MixinBase):
19
+ __abstract__ = True
20
+
21
+ id: Mapped[int] = mapped_column(
22
+ Identity(), primary_key=True, sort_order=-100
23
+ )
24
+
25
+
26
+ class HasTimestamp(MixinBase):
27
+ __abstract__ = True
28
+
29
+ created_at: Mapped[datetime.datetime] = mapped_column(
30
+ server_default=func.now(), sort_order=100
31
+ )
32
+ updated_at: Mapped[datetime.datetime] = mapped_column(
33
+ server_default=func.now(),
34
+ onupdate=datetime.datetime.utcnow, # noqa
35
+ sort_order=101,
36
+ )
37
+
38
+
39
+ class AlchemyEntity(HasID, HasTimestamp):
40
+ __abstract__ = True
41
+
42
+
43
+ class SoftDeletable(MixinBase):
44
+ __abstract__ = True
45
+
46
+ deleted_at: Mapped[datetime.datetime | None] = mapped_column(
47
+ nullable=True, sort_order=102
48
+ )
49
+
50
+ def delete(self) -> None:
51
+ # self.deleted_at = func.now()
52
+ self.deleted_at = datetime.datetime.utcnow() # noqa
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
6
+
7
+ from fastabc.abc import AbstractUOW
8
+
9
+
10
+ class MultipleAlchemyUOW(AbstractUOW):
11
+ """
12
+ This UOW allows to work with multiple SQLAlchemy sessions.
13
+
14
+ Usage::
15
+
16
+ def get_session_factories() -> dict[str, async_sessionmaker[AsyncSession]]:
17
+ engines = {
18
+ "first-db": create_async_engine(
19
+ "postgresql+asyncpg://user:password@localhost/first_db"
20
+ ),
21
+ "second-db": create_async_engine(
22
+ "postgresql+asyncpg://user:password@localhost/second_db"
23
+ ),
24
+ }
25
+
26
+ return {
27
+ name: async_sessionmaker(engine)
28
+ for name, engine in engines.items()
29
+ }
30
+
31
+
32
+ class UOW(BaseMultipleUOW):
33
+ users: UserRepository
34
+ music: MusicRepository
35
+
36
+ async def open(self) -> None:
37
+ self.users = UserRepository(self.sessions["first-db"])
38
+ self.music = MusicRepository(self.sessions["second-db"])
39
+
40
+
41
+ async with UOW(get_session_factories()) as uow:
42
+ # TRANSACTION IS BEGUN...
43
+
44
+ uow.users.add(User(name="Bob", age="18"))
45
+ now.music.add(Music(name="Song", author="Bob"))
46
+
47
+ uow.commit()
48
+
49
+ """ # noqa: E501
50
+
51
+ session_factories: dict[str, async_sessionmaker[AsyncSession]]
52
+ sessions: dict[str, AsyncSession]
53
+
54
+ def __init__(
55
+ self, session_factories: dict[str, async_sessionmaker[AsyncSession]]
56
+ ) -> None:
57
+ self.session_factories = session_factories
58
+
59
+ @property
60
+ def is_opened(self) -> bool:
61
+ if not self.sessions:
62
+ return False
63
+
64
+ return any(session.is_active for session in self.sessions.values())
65
+
66
+ async def open(self) -> None:
67
+ self.sessions = {
68
+ name: factory() for name, factory in self.session_factories.items()
69
+ }
70
+
71
+ for session in self.sessions.values():
72
+ await session.__aenter__()
73
+
74
+ async def close(self, type_: Any, value: Any, traceback: Any) -> None:
75
+ for session in self.sessions.values():
76
+ await session.__aexit__(type_, value, traceback)
77
+
78
+ async def flush(self) -> None:
79
+ for session in self.sessions.values():
80
+ await session.flush()
81
+
82
+ async def commit(self) -> None:
83
+ for session in self.sessions.values():
84
+ await session.commit()
85
+
86
+ async def rollback(self) -> None:
87
+ for session in self.sessions.values():
88
+ await session.rollback()
@@ -0,0 +1,203 @@
1
+ from abc import ABC
2
+ from typing import TypeVar, Generic, Sequence, Any, cast
3
+
4
+ from sqlalchemy import (
5
+ select,
6
+ func,
7
+ ColumnElement,
8
+ BinaryExpression,
9
+ UnaryExpression,
10
+ insert,
11
+ update,
12
+ )
13
+ from sqlalchemy.exc import NoResultFound, MultipleResultsFound
14
+ from sqlalchemy.ext.asyncio import AsyncSession
15
+ from sqlalchemy.orm import InstrumentedAttribute
16
+ from sqlalchemy.orm.interfaces import ORMOption
17
+
18
+ from .base import SkeletonBase
19
+ from ..abc.repository import AbstractRepository
20
+ from ..schemas import PageParams
21
+
22
+ Model = TypeVar("Model", bound=SkeletonBase)
23
+ IdLike = Any | tuple[Any, ...]
24
+ WhereClause = Sequence[BinaryExpression[bool] | ColumnElement[bool]]
25
+ OrderBy = Sequence[UnaryExpression[Any] | ColumnElement[Any]]
26
+ T = TypeVar("T")
27
+
28
+
29
+ class AlchemyRepository(AbstractRepository, Generic[Model], ABC):
30
+ """Repository for SQLAlchemy models."""
31
+
32
+ model_type: type[Model]
33
+ session: AsyncSession
34
+
35
+ def __init__(self, session: AsyncSession):
36
+ self.session = session
37
+
38
+ def add(self, *instances: Model) -> None:
39
+ """Add an instance in the session."""
40
+ self.session.add_all(instances)
41
+
42
+ async def insert(
43
+ self,
44
+ *instances: Model,
45
+ ) -> None:
46
+ """Insert instances into the database."""
47
+ if not instances:
48
+ return
49
+ stmt = insert(self.model_type)
50
+ instance_dicts = [instance.dump() for instance in instances]
51
+ await self.session.execute(stmt, instance_dicts)
52
+
53
+ async def insert_and_return(
54
+ self,
55
+ *instances: Model,
56
+ sort: bool = True,
57
+ ) -> list[Model]:
58
+ """Insert instances into the database and return them."""
59
+ if not instances:
60
+ return []
61
+ stmt = insert(self.model_type).returning(
62
+ self.model_type,
63
+ sort_by_parameter_order=sort,
64
+ )
65
+ instance_dicts = [instance.dump() for instance in instances]
66
+ scalars = await self.session.scalars(stmt, instance_dicts)
67
+ return list(scalars)
68
+
69
+ async def get(
70
+ self, ident: IdLike, options: Sequence[ORMOption] | None = None
71
+ ) -> Model | None:
72
+ """Get an instance by ID. Return `None` if not found."""
73
+ return await self.session.get(self.model_type, ident, options=options)
74
+
75
+ async def get_one(
76
+ self, ident: IdLike, options: Sequence[ORMOption] | None = None
77
+ ) -> Model:
78
+ """Get an instance by ID. Raise `sqlalchemy.exc.NoResultFound` if not found."""
79
+ return await self.session.get_one(
80
+ self.model_type, ident, options=options
81
+ )
82
+
83
+ async def get_many(
84
+ self,
85
+ *,
86
+ where: WhereClause | None = None,
87
+ group_by: Sequence[ColumnElement[Any]] | None = None,
88
+ having: WhereClause | None = None,
89
+ distinct: Sequence[ColumnElement[Any]] | None = None,
90
+ order_by: OrderBy | None = None,
91
+ limit: int | None = 10,
92
+ offset: int | None = None,
93
+ ) -> list[Model]:
94
+ """Get instances from the database that meet the condition."""
95
+ stmt = select(self.model_type)
96
+ if where is not None:
97
+ stmt = stmt.where(*where)
98
+ if group_by is not None:
99
+ stmt = stmt.group_by(*group_by)
100
+ if having is not None:
101
+ stmt = stmt.having(*having)
102
+ if distinct is not None:
103
+ stmt = stmt.distinct(*distinct)
104
+ if order_by is not None:
105
+ stmt = stmt.order_by(*order_by)
106
+ if limit is not None:
107
+ stmt = stmt.limit(limit)
108
+ if offset is not None:
109
+ stmt = stmt.offset(offset)
110
+ result = await self.session.execute(stmt)
111
+ instances = list(result.scalars().all())
112
+ return instances
113
+
114
+ async def paginate(
115
+ self,
116
+ params: PageParams,
117
+ where: WhereClause | None = None,
118
+ group_by: Sequence[ColumnElement[Any]] | None = None,
119
+ having: WhereClause | None = None,
120
+ distinct: Sequence[ColumnElement[Any]] | None = None,
121
+ ) -> list[Model]:
122
+ """Paginate instances from the database."""
123
+ attr = getattr(self.model_type, params.sort_by)
124
+ order_by = [attr.desc() if params.order == "desc" else attr.asc()]
125
+ return await self.get_many(
126
+ where=where,
127
+ group_by=group_by,
128
+ having=having,
129
+ distinct=distinct,
130
+ limit=params.limit,
131
+ offset=params.offset,
132
+ order_by=order_by,
133
+ )
134
+
135
+ async def get_by_where(
136
+ self, where: WhereClause | None = None
137
+ ) -> Model | None:
138
+ """
139
+ Get instance from the database that meets the condition.
140
+
141
+ Return None if not found.
142
+ Raise `sqlalchemy.exc.MultipleResultsFound` if more than one found.
143
+ """
144
+ model_list = await self.get_many(where=where, limit=2)
145
+
146
+ if not model_list:
147
+ return None
148
+
149
+ if len(model_list) > 1:
150
+ raise MultipleResultsFound()
151
+
152
+ return model_list[0]
153
+
154
+ async def get_one_by_where(
155
+ self, where: WhereClause | None = None
156
+ ) -> Model:
157
+ """
158
+ Get instance from the database that meets the condition.
159
+
160
+ Raise `sqlalchemy.exc.NoResultFound` if not found.
161
+ Raise `sqlalchemy.exc.MultipleResultsFound` if more than one found.
162
+ """
163
+ instance = await self.get_by_where(where)
164
+
165
+ if not instance:
166
+ raise NoResultFound()
167
+
168
+ return instance
169
+
170
+ async def update(self, *instances: Model) -> None:
171
+ """Update instances in the database."""
172
+ stmt = update(self.model_type)
173
+ dicts = [instance.dump() for instance in instances]
174
+ await self.session.execute(stmt, dicts)
175
+
176
+ async def merge(self, instance: Model) -> Model:
177
+ """Merge an instance with the corresponding session state."""
178
+ return await self.session.merge(instance)
179
+
180
+ async def delete(self, model: Model) -> None:
181
+ """Delete an instance from the session."""
182
+ await self.session.delete(model)
183
+
184
+ async def count(self, where: WhereClause) -> int:
185
+ """Count instances in the database that meets the condition."""
186
+ stmt = select(func.total(self.model_type)).where(*where)
187
+ result = await self.session.execute(stmt)
188
+ scalar = cast(int, result.scalar_one())
189
+ return scalar
190
+
191
+ async def max(self, attribute: InstrumentedAttribute[T]) -> T:
192
+ """Get the maximum value of an attribute from the database."""
193
+ stmt = select(func.max(attribute))
194
+ result = await self.session.execute(stmt)
195
+ scalar = result.scalar_one()
196
+ return scalar
197
+
198
+ async def min(self, attribute: InstrumentedAttribute[T]) -> T:
199
+ """Get the minimum value of an attribute from the database."""
200
+ stmt = select(func.min(attribute))
201
+ result = await self.session.execute(stmt)
202
+ scalar = result.scalar_one()
203
+ return scalar
@@ -0,0 +1,9 @@
1
+ from fastabc.abc import AbstractService
2
+ from fastabc.alchemy import AlchemyUOW
3
+
4
+
5
+ class AlchemyService(AbstractService):
6
+ uow: AlchemyUOW
7
+
8
+ def __init__(self, uow: AlchemyUOW):
9
+ self.uow = uow
@@ -0,0 +1,10 @@
1
+ from sqlalchemy.orm import Mapped
2
+
3
+ from .mixins import AlchemyEntity
4
+
5
+
6
+ class TestUser(AlchemyEntity):
7
+ __tablename__ = "__test_users__"
8
+
9
+ name: Mapped[str]
10
+ age: Mapped[int]
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, cast
4
+
5
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
6
+
7
+ from fastabc.abc import AbstractUOW
8
+
9
+
10
+ class AlchemyUOW(AbstractUOW):
11
+ """
12
+ SQLAlchemy UOW.
13
+
14
+ Usage::
15
+
16
+ class UOW(AlchemyUOW):
17
+ users: UserRepository
18
+
19
+ async def on_open(self) -> None:
20
+ self.users = UserRepository(self.session)
21
+
22
+
23
+ async with UOW(session_factory) as uow:
24
+ # TRANSACTION IS BEGUN...
25
+
26
+ uow.users.add(User(name="Bob", age="18"))
27
+ uow.commit()
28
+
29
+ """ # noqa: E501
30
+
31
+ session_factory: async_sessionmaker[AsyncSession]
32
+ session: AsyncSession
33
+
34
+ def __init__(self, session_factory: async_sessionmaker[AsyncSession]):
35
+ self.session_factory = session_factory
36
+
37
+ @property
38
+ def is_opened(self) -> bool:
39
+ if not self.session:
40
+ return False
41
+
42
+ return cast(bool, self.session.is_active)
43
+
44
+ async def on_open(self) -> None:
45
+ ...
46
+
47
+ async def open(self) -> None:
48
+ self.session = self.session_factory()
49
+ await self.session.__aenter__()
50
+ await self.on_open()
51
+
52
+ async def close(self, type_: Any, value: Any, traceback: Any) -> None:
53
+ await self.session.__aexit__(type_, value, traceback)
54
+
55
+ async def flush(self) -> None:
56
+ await self.session.flush()
57
+
58
+ async def rollback(self) -> None:
59
+ await self.session.rollback()
60
+
61
+ async def commit(self) -> None:
62
+ await self.session.commit()
@@ -0,0 +1,11 @@
1
+ from .openapi import (
2
+ OpenAPIResponseType,
3
+ StringErrorResponse,
4
+ ResponseGroup,
5
+ )
6
+
7
+ __all__ = [
8
+ "OpenAPIResponseType",
9
+ "StringErrorResponse",
10
+ "ResponseGroup",
11
+ ]
@@ -0,0 +1,103 @@
1
+ from abc import ABC
2
+ from typing import Any, Annotated
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+ # https://fastapi.tiangolo.com/how-to/extending-openapi/
7
+ OpenAPIResponseType = dict[int | str, dict[str, Any]]
8
+
9
+
10
+ class StringErrorResponse(BaseModel):
11
+ detail: Annotated[str, Field(min_length=1, max_length=1024)]
12
+
13
+
14
+ class ResponseGroup(ABC):
15
+ @classmethod
16
+ def create(cls) -> OpenAPIResponseType:
17
+ """400 Bad Request, 403 Forbidden."""
18
+ return {
19
+ 400: {
20
+ "description": "Already exists",
21
+ "model": StringErrorResponse,
22
+ },
23
+ 403: {
24
+ "description": "Access denied",
25
+ "model": StringErrorResponse,
26
+ },
27
+ }
28
+
29
+ @classmethod
30
+ def create_many(cls) -> OpenAPIResponseType:
31
+ """403 Forbidden."""
32
+ return {
33
+ 403: {
34
+ "description": "Access denied",
35
+ "model": StringErrorResponse,
36
+ }
37
+ }
38
+
39
+ @classmethod
40
+ def get(cls) -> OpenAPIResponseType:
41
+ """403 Forbidden, 404 Not Found"""
42
+ return {
43
+ 404: {
44
+ "description": "Not found",
45
+ "model": StringErrorResponse,
46
+ },
47
+ 403: {
48
+ "description": "Access denied",
49
+ "model": StringErrorResponse,
50
+ },
51
+ }
52
+
53
+ @classmethod
54
+ def get_many(cls) -> OpenAPIResponseType:
55
+ """403 Forbidden."""
56
+ return {
57
+ 403: {
58
+ "description": "Access denied",
59
+ "model": StringErrorResponse,
60
+ }
61
+ }
62
+
63
+ @classmethod
64
+ def update(cls) -> OpenAPIResponseType:
65
+ """403 Forbidden, 404 Not Found."""
66
+ return {
67
+ 404: {
68
+ "description": "Not found",
69
+ "model": StringErrorResponse,
70
+ },
71
+ 403: {
72
+ "description": "Access denied",
73
+ "model": StringErrorResponse,
74
+ },
75
+ }
76
+
77
+ @classmethod
78
+ def delete(cls) -> OpenAPIResponseType:
79
+ """403 Forbidden, 404 Not Found"""
80
+ return {
81
+ 404: {
82
+ "description": "Not found",
83
+ "model": StringErrorResponse,
84
+ },
85
+ 403: {
86
+ "description": "Access denied",
87
+ "model": StringErrorResponse,
88
+ },
89
+ }
90
+
91
+ @classmethod
92
+ def multi_response(cls, examples: dict[str | int, str]) -> dict[str, Any]:
93
+ return {
94
+ "content": {
95
+ "application/json": {
96
+ "examples": {
97
+ code: {"summary": summary, "value": {"detail": code}}
98
+ for code, summary in examples.items()
99
+ }
100
+ }
101
+ },
102
+ "model": StringErrorResponse,
103
+ }
@@ -0,0 +1,6 @@
1
+ class FastABCException(Exception):
2
+ pass
3
+
4
+
5
+ class SettingsNotFound(FastABCException):
6
+ pass
@@ -0,0 +1,23 @@
1
+ from .base import SkeletonModel
2
+ from .mixins import (
3
+ HasTimestamp,
4
+ SoftDeletable,
5
+ HasID,
6
+ MixinModel,
7
+ EntityModel,
8
+ )
9
+ from .pagination import (
10
+ PageParams,
11
+ Page,
12
+ )
13
+
14
+ __all__ = [
15
+ "PageParams",
16
+ "Page",
17
+ "HasTimestamp",
18
+ "SoftDeletable",
19
+ "HasID",
20
+ "MixinModel",
21
+ "EntityModel",
22
+ "SkeletonModel",
23
+ ]
@@ -0,0 +1,19 @@
1
+ from abc import ABC
2
+ from typing import TypeVar, Type, Any
3
+
4
+ from pydantic import BaseModel, ConfigDict
5
+
6
+ Model = TypeVar("Model", bound=BaseModel)
7
+
8
+
9
+ class SkeletonModel(BaseModel, ABC):
10
+ @classmethod
11
+ def validate_or_none(
12
+ cls: Type[Model], model: Any, **kwargs: Any
13
+ ) -> Model | None:
14
+ if model is None:
15
+ return None
16
+
17
+ return cls.model_validate(model, **kwargs)
18
+
19
+ model_config = ConfigDict(from_attributes=True)
@@ -0,0 +1,38 @@
1
+ import datetime
2
+ from abc import ABC
3
+
4
+ from pydantic import Field
5
+
6
+ from .base import SkeletonModel
7
+
8
+
9
+ # https://docs.pydantic.dev/latest/concepts/models/#abstract-base-classes
10
+
11
+
12
+ class MixinModel(SkeletonModel, ABC):
13
+ """Base class for all mixins."""
14
+
15
+ pass
16
+
17
+
18
+ class HasID(MixinModel):
19
+ id: int = Field(ge=1)
20
+
21
+
22
+ class HasTimestamp(MixinModel):
23
+ created_at: datetime.datetime = Field(
24
+ default_factory=datetime.datetime.now,
25
+ )
26
+ updated_at: datetime.datetime = Field(
27
+ default_factory=datetime.datetime.now,
28
+ )
29
+
30
+
31
+ class SoftDeletable(MixinModel):
32
+ deleted_at: datetime.datetime | None = Field(
33
+ None,
34
+ )
35
+
36
+
37
+ class EntityModel(HasID, HasTimestamp):
38
+ pass
@@ -0,0 +1,27 @@
1
+ from typing import Generic, TypeVar, Literal
2
+
3
+ from pydantic import Field, computed_field, BaseModel
4
+
5
+ from .base import SkeletonModel
6
+
7
+
8
+ class PageParams(SkeletonModel):
9
+ limit: int = Field(10, ge=0, le=100)
10
+ offset: int = Field(0, ge=0)
11
+ sort_by: Literal["updated_at", "created_at"] = "updated_at"
12
+ order: Literal["asc", "desc"] = "desc"
13
+
14
+
15
+ # https://docs.pydantic.dev/latest/concepts/models/#generic-models
16
+
17
+
18
+ Model = TypeVar("Model", bound=BaseModel)
19
+
20
+
21
+ class Page(SkeletonModel, Generic[Model]):
22
+ items: list[Model]
23
+
24
+ @computed_field
25
+ @property
26
+ def total(self) -> int:
27
+ return len(self.items)
@@ -0,0 +1,22 @@
1
+ from .app import AppModel, APIModel, BotModel
2
+ from .db import DatabaseServerDsn, DatabaseDsn
3
+ from .env import load_env
4
+ from .pg import PgServerDsn, PostgresDsn
5
+ from .mysql import MysqlServerDsn, MysqlDsn
6
+ from .url import ConnectionUrl
7
+ from .redis import RedisDsn
8
+
9
+ __all__ = [
10
+ "DatabaseServerDsn",
11
+ "PgServerDsn",
12
+ "DatabaseDsn",
13
+ "PostgresDsn",
14
+ "ConnectionUrl",
15
+ "AppModel",
16
+ "APIModel",
17
+ "BotModel",
18
+ "load_env",
19
+ "RedisDsn",
20
+ "MysqlServerDsn",
21
+ "MysqlDsn",
22
+ ]
@@ -0,0 +1,22 @@
1
+ from typing import Literal
2
+
3
+ from pydantic import Field
4
+
5
+ from fastabc import SkeletonModel
6
+
7
+
8
+ class AppModel(SkeletonModel):
9
+ title: str = "Test"
10
+ version: str = "1.0.0"
11
+ env: Literal["dev", "prod", "test"] = "dev"
12
+ debug: bool = False
13
+
14
+
15
+ class APIModel(AppModel):
16
+ cors_origins: list[str] = Field(["*"])
17
+ cors_headers: list[str] = Field(["*"])
18
+ cors_methods: list[str] = Field(["*"])
19
+
20
+
21
+ class BotModel(AppModel):
22
+ bot_token: str
@@ -0,0 +1,34 @@
1
+ from abc import ABC
2
+
3
+ from sqlalchemy import URL
4
+
5
+ from .url import ConnectionUrl
6
+
7
+
8
+ class DatabaseServerDsn(ConnectionUrl, ABC):
9
+ dialect: str
10
+ sync_driver: str
11
+ async_driver: str
12
+
13
+ def get_dsn(self, db: str, sync: bool = True) -> str:
14
+ driver = self.sync_driver if sync else self.async_driver
15
+ return URL.create(
16
+ drivername=f"{self.dialect}+{driver}",
17
+ username=self.username,
18
+ password=self.password.get_secret_value(),
19
+ host=self.host,
20
+ port=self.port,
21
+ database=db,
22
+ ).render_as_string(hide_password=False)
23
+
24
+
25
+ class DatabaseDsn(DatabaseServerDsn):
26
+ db: str
27
+
28
+ @property
29
+ def sync_dsn(self) -> str:
30
+ return super().get_dsn(self.db)
31
+
32
+ @property
33
+ def async_dsn(self) -> str:
34
+ return super().get_dsn(self.db, sync=False)
@@ -0,0 +1,18 @@
1
+ import os
2
+ from typing import Sequence
3
+
4
+ from dotenv import load_dotenv
5
+
6
+ from fastabc.exceptions import SettingsNotFound
7
+
8
+
9
+ def load_env(
10
+ env_files: Sequence[str] = (".dev.env", ".prod.env", ".env"),
11
+ no_env_file_var: str = "NO_ENV_FILE",
12
+ ) -> None:
13
+ if os.getenv(no_env_file_var):
14
+ return
15
+ for file in env_files:
16
+ if load_dotenv(file):
17
+ return
18
+ raise SettingsNotFound(f"Settings file not found: {env_files}")
@@ -0,0 +1,11 @@
1
+ from .db import DatabaseServerDsn, DatabaseDsn
2
+
3
+
4
+ class MysqlServerDsn(DatabaseServerDsn):
5
+ dialect: str = "mysql"
6
+ sync_driver: str = "mysql"
7
+ async_driver: str = "aiomysql"
8
+
9
+
10
+ class MysqlDsn(MysqlServerDsn, DatabaseDsn):
11
+ pass
@@ -0,0 +1,11 @@
1
+ from .db import DatabaseServerDsn, DatabaseDsn
2
+
3
+
4
+ class PgServerDsn(DatabaseServerDsn):
5
+ dialect: str = "postgresql"
6
+ sync_driver: str = "psycopg2"
7
+ async_driver: str = "asyncpg"
8
+
9
+
10
+ class PostgresDsn(PgServerDsn, DatabaseDsn):
11
+ pass
@@ -0,0 +1,16 @@
1
+ from abc import ABC
2
+
3
+ from pydantic import SecretStr
4
+
5
+ from fastabc.settings import ConnectionUrl
6
+
7
+
8
+ class RedisDsn(ConnectionUrl, ABC):
9
+ host: str
10
+ port: int
11
+ username: str = "default"
12
+ password: SecretStr = SecretStr("")
13
+ db: int = 0
14
+
15
+ state_ttl: int | None = None
16
+ data_ttl: int | None = None
@@ -0,0 +1,29 @@
1
+ from abc import ABC
2
+ from typing import Any
3
+
4
+ from pydantic import model_validator, SecretStr
5
+
6
+ from fastabc import SkeletonModel
7
+
8
+
9
+ class ConnectionUrl(SkeletonModel, ABC):
10
+ host: str
11
+ port: int
12
+ username: str
13
+ password: SecretStr
14
+
15
+ # aliases
16
+ user: str
17
+
18
+ @model_validator(mode="before")
19
+ def check_model(cls, data: Any) -> Any:
20
+ if isinstance(data, dict):
21
+ if "user" in data and "username" in data:
22
+ raise ValueError(
23
+ "Cannot have both 'user' and 'username' in the same URL"
24
+ )
25
+ if "user" in data:
26
+ data["username"] = data["user"]
27
+ if "username" in data:
28
+ data["user"] = data["username"]
29
+ return data
@@ -0,0 +1,71 @@
1
+ [tool.poetry]
2
+ name = "fastabc"
3
+ version = "0.1.0"
4
+ description = "Ready-made API Abstractions: basic models, repositories, unit of work and other stuff."
5
+ authors = ["Ivan Stasevich <pravitel2015ify@gmail.com>"]
6
+ readme = "README.md"
7
+ classifiers = [
8
+ "Intended Audience :: Information Technology",
9
+ "Intended Audience :: System Administrators",
10
+ "Operating System :: OS Independent",
11
+ "Programming Language :: Python :: 3",
12
+ "Programming Language :: Python",
13
+ "Topic :: Internet",
14
+ "Topic :: Software Development :: Libraries :: Application Frameworks",
15
+ "Topic :: Software Development :: Libraries :: Python Modules",
16
+ "Topic :: Software Development :: Libraries",
17
+ "Topic :: Software Development",
18
+ "Typing :: Typed",
19
+ "Development Status :: 4 - Beta",
20
+ "Environment :: Web Environment",
21
+ "Framework :: AsyncIO",
22
+ "Framework :: FastAPI",
23
+ "Framework :: Pydantic",
24
+ "Framework :: Pydantic :: 1",
25
+ "Intended Audience :: Developers",
26
+ "License :: OSI Approved :: MIT License",
27
+ "Programming Language :: Python :: 3 :: Only",
28
+ "Programming Language :: Python :: 3.11",
29
+ "Programming Language :: Python :: 3.12",
30
+ "Topic :: Internet :: WWW/HTTP :: HTTP Servers",
31
+ "Topic :: Internet :: WWW/HTTP",
32
+ ]
33
+
34
+ [tool.poetry.group.dev.dependencies]
35
+ mypy = "^1.10.0"
36
+ ruff = "^0.4.8"
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/everysoftware/fastabc"
40
+ Documentation = ""
41
+ Repository = "https://github.com/everysoftware/fastabc"
42
+
43
+ [tool.poetry.dependencies]
44
+ python = "^3.11"
45
+ pydantic = "^2.7.3"
46
+ sqlalchemy = "^2.0.30"
47
+ pydantic-settings = "^2.3.1"
48
+
49
+
50
+ [build-system]
51
+ requires = ["poetry-core"]
52
+ build-backend = "poetry.core.masonry.api"
53
+
54
+
55
+ [tool.ruff]
56
+ fix = true
57
+ indent-width = 4
58
+ line-length = 79
59
+ src = ["src"]
60
+ target-version = "py311"
61
+
62
+ [tool.ruff.format]
63
+ docstring-code-format = true
64
+
65
+
66
+ [tool.mypy]
67
+ strict = true
68
+
69
+ [[tool.mypy.overrides]]
70
+ module = "*.*"
71
+ ignore_missing_imports = true