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 +37 -0
- fastabc-0.1.0/README.md +0 -0
- fastabc-0.1.0/fastabc/__init__.py +17 -0
- fastabc-0.1.0/fastabc/abc/__init__.py +12 -0
- fastabc-0.1.0/fastabc/abc/gateway.py +20 -0
- fastabc-0.1.0/fastabc/abc/repository.py +5 -0
- fastabc-0.1.0/fastabc/abc/service.py +5 -0
- fastabc-0.1.0/fastabc/abc/uow.py +60 -0
- fastabc-0.1.0/fastabc/alchemy/__init__.py +17 -0
- fastabc-0.1.0/fastabc/alchemy/base.py +41 -0
- fastabc-0.1.0/fastabc/alchemy/mixins.py +52 -0
- fastabc-0.1.0/fastabc/alchemy/multiple.py +88 -0
- fastabc-0.1.0/fastabc/alchemy/repository.py +203 -0
- fastabc-0.1.0/fastabc/alchemy/service.py +9 -0
- fastabc-0.1.0/fastabc/alchemy/testing.py +10 -0
- fastabc-0.1.0/fastabc/alchemy/uow.py +62 -0
- fastabc-0.1.0/fastabc/api/__init__.py +11 -0
- fastabc-0.1.0/fastabc/api/openapi.py +103 -0
- fastabc-0.1.0/fastabc/exceptions.py +6 -0
- fastabc-0.1.0/fastabc/schemas/__init__.py +23 -0
- fastabc-0.1.0/fastabc/schemas/base.py +19 -0
- fastabc-0.1.0/fastabc/schemas/mixins.py +38 -0
- fastabc-0.1.0/fastabc/schemas/pagination.py +27 -0
- fastabc-0.1.0/fastabc/settings/__init__.py +22 -0
- fastabc-0.1.0/fastabc/settings/app.py +22 -0
- fastabc-0.1.0/fastabc/settings/db.py +34 -0
- fastabc-0.1.0/fastabc/settings/env.py +18 -0
- fastabc-0.1.0/fastabc/settings/mysql.py +11 -0
- fastabc-0.1.0/fastabc/settings/pg.py +11 -0
- fastabc-0.1.0/fastabc/settings/redis.py +16 -0
- fastabc-0.1.0/fastabc/settings/url.py +29 -0
- fastabc-0.1.0/pyproject.toml +71 -0
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
|
+
|
fastabc-0.1.0/README.md
ADDED
|
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,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,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,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,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,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
|