From 1cc1b52dfbfe3d27597adcd60a78bf1dafeac98d Mon Sep 17 00:00:00 2001 From: Zerohertz Date: Thu, 23 Jan 2025 01:25:33 +0900 Subject: [PATCH 01/18] :sparkles: feat: transactional --- app/core/container.py | 7 +-- app/core/database.py | 75 +++++++++++++++++++++++++------ app/core/lifespan.py | 5 +-- app/core/middlewares.py | 16 +++++++ app/main.py | 4 +- app/repositories/base.py | 81 ++++++++++++++++------------------ app/repositories/users.py | 8 +--- app/services/base.py | 7 +++ app/tests/api/v1/test_users.py | 12 +++++ uv.lock | 40 ++++++++--------- 10 files changed, 162 insertions(+), 93 deletions(-) diff --git a/app/core/container.py b/app/core/container.py index fec3010..698c681 100644 --- a/app/core/container.py +++ b/app/core/container.py @@ -1,7 +1,6 @@ from dependency_injector.containers import DeclarativeContainer, WiringConfiguration -from dependency_injector.providers import Factory, Singleton +from dependency_injector.providers import Factory -from app.core.database import Database from app.repositories.users import UserRepository from app.services.users import UserService @@ -9,8 +8,6 @@ class Container(DeclarativeContainer): wiring_config = WiringConfiguration(modules=["app.api.v1.endpoints.users"]) - database = Singleton(Database) - - user_repository = Factory(UserRepository, session=database.provided.session) + user_repository = Factory(UserRepository) user_service = Factory(UserService, user_repository=user_repository) diff --git a/app/core/database.py b/app/core/database.py index 25c0bfa..c51590f 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -1,30 +1,79 @@ -from contextlib import asynccontextmanager -from typing import AsyncIterator +from contextvars import ContextVar, Token +from functools import wraps +from typing import Awaitable, Callable, Optional -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from loguru import logger +from sqlalchemy import ClauseElement, Connection, Engine +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_scoped_session, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.orm import Session as SyncSession +from sqlalchemy.orm.session import _EntityBindKey +from sqlalchemy.sql.expression import Delete, Insert, Update from app.core.configs import configs from app.models.base import BaseModel +class Context: + def __init__(self) -> None: + self.context: ContextVar[Optional[int]] = ContextVar( + "session_context", default=None + ) + + def get(self) -> int: + session_id = self.context.get() + if not session_id: + raise ValueError("Currently no session is available.") + return session_id + + def set(self, session_id: int) -> Token: + return self.context.set(session_id) + + def reset(self, context: Token) -> None: + self.context.reset(context) + + class Database: def __init__(self) -> None: + self.context = Context() self.engine = create_async_engine(configs.DATABASE_URI, echo=configs.DB_ECHO) self.sessionmaker = async_sessionmaker( - bind=self.engine, class_=AsyncSession, expire_on_commit=False + bind=self.engine, + class_=AsyncSession, + autoflush=False, + autocommit=False, + expire_on_commit=False, + ) + self.scoped_session = async_scoped_session( + session_factory=self.sessionmaker, scopefunc=self.context.get ) async def create_all(self) -> None: + logger.warning("Create database") async with self.engine.begin() as conn: await conn.run_sync(BaseModel.metadata.create_all) - @asynccontextmanager - async def session(self) -> AsyncIterator[AsyncSession]: - async with self.sessionmaker() as session: + def transactional(self, func: Callable[..., Awaitable]) -> Callable[..., Awaitable]: + @wraps(func) + async def wrapper(*args, **kwargs): try: - yield session - except Exception: - await session.rollback() - raise - finally: - await session.close() + session = self.scoped_session() + if session.in_transaction(): + logger.trace( + f"[Session in transaction]\tID: {database.context.get()}, {self.context=}" + ) + return await func(*args, **kwargs) + async with session.begin(): + response = await func(*args, **kwargs) + return response + except Exception as error: + raise error + + return wrapper + + +database = Database() diff --git a/app/core/lifespan.py b/app/core/lifespan.py index ebe3863..b75c979 100644 --- a/app/core/lifespan.py +++ b/app/core/lifespan.py @@ -8,6 +8,7 @@ from app.core.configs import configs from app.core.container import Container +from app.core.database import database from app.utils.logging import remove_handler @@ -27,10 +28,8 @@ async def lifespan(app: FastAPI): # pylint: disable=unused-argument # logging.getLogger("uvicorn.access").addHandler(LoguruHandler()) # logging.getLogger("uvicorn.error").addHandler(LoguruHandler()) - container = Container() if configs.DB_TABLE_CREATE: - logger.warning("Create database") - database = container.database() await database.create_all() + Container() yield diff --git a/app/core/middlewares.py b/app/core/middlewares.py index fa545ff..f0183b2 100644 --- a/app/core/middlewares.py +++ b/app/core/middlewares.py @@ -4,6 +4,7 @@ from loguru import logger from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from app.core.database import database from app.utils.logging import ANSI_BG_COLOR, ANSI_STYLE, ansi_format @@ -57,3 +58,18 @@ async def dispatch( f"[IP: {ip}] [URL: {url}] [Method: {method}] [Status: {status} (Elapsed Time: {elapsed_time})]" ) return response + + +class SessionMiddleware(BaseHTTPMiddleware): + async def dispatch( + self, request: Request, call_next: RequestResponseEndpoint + ) -> Response: + try: + context = database.context.set(session_id=hash(request)) + logger.trace(f"[Session Start]\tID: {database.context.get()}, {context=}") + response = await call_next(request) + finally: + await database.scoped_session.remove() + logger.trace(f"[Session End]\tID: {database.context.get()}, {context=}") + database.context.reset(context=context) + return response diff --git a/app/main.py b/app/main.py index 44af309..f80fb3d 100644 --- a/app/main.py +++ b/app/main.py @@ -3,7 +3,7 @@ from app.api.v1.routers import routers as v1_routers from app.core.configs import configs from app.core.lifespan import lifespan -from app.core.middlewares import LoggingMiddleware +from app.core.middlewares import LoggingMiddleware, SessionMiddleware from app.exceptions.base import CoreException from app.exceptions.handlers import business_exception_handler, global_exception_handler @@ -24,5 +24,5 @@ for routers in [v1_routers]: app.include_router(routers, prefix=configs.PREFIX) -for middleware in [LoggingMiddleware]: +for middleware in [SessionMiddleware, LoggingMiddleware]: app.add_middleware(middleware) diff --git a/app/repositories/base.py b/app/repositories/base.py index 014ce30..f78606d 100644 --- a/app/repositories/base.py +++ b/app/repositories/base.py @@ -1,9 +1,9 @@ -from typing import Any, AsyncContextManager, Callable, Generic, Type, TypeVar +from typing import Any, Generic, Type, TypeVar -from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from sqlalchemy.orm import joinedload +from app.core.database import database from app.exceptions.database import EntityNotFound from app.models.base import BaseModel @@ -13,63 +13,56 @@ class BaseRepository(Generic[T]): def __init__( self, - session: Callable[..., AsyncContextManager[AsyncSession]], model: Type[T], ) -> None: - self.session = session self.model = model async def create(self, model: T) -> T: - async with self.session() as session: - session.add(model) - await session.commit() - await session.refresh(model) + session = database.scoped_session() + session.add(model) + await session.flush() return model async def read_by_id(self, id: int, eager: bool = False) -> T: - async with self.session() as session: - query = select(self.model) - if eager: - for _eager in getattr(self.model, "eagers"): - query = query.options(joinedload(getattr(self.model, _eager))) - query = query.filter(self.model.id == id) - _query = await session.execute(query) - result = _query.scalar_one_or_none() - if not result: - raise EntityNotFound + query = select(self.model) + if eager: + for _eager in getattr(self.model, "eagers"): + query = query.options(joinedload(getattr(self.model, _eager))) + query = query.filter(self.model.id == id) + session = database.scoped_session() + _query = await session.execute(query) + result = _query.scalar_one_or_none() + if not result: + raise EntityNotFound return result async def update_by_id(self, id: int, model: dict) -> T: - async with self.session() as session: - query = select(self.model).filter(self.model.id == id) - _query = await session.execute(query) - result = _query.scalar_one_or_none() - if not result: - raise EntityNotFound - for key, value in model.items(): - setattr(result, key, value) - await session.commit() - await session.refresh(result) + query = select(self.model).filter(self.model.id == id) + session = database.scoped_session() + _query = await session.execute(query) + result = _query.scalar_one_or_none() + if not result: + raise EntityNotFound + for key, value in model.items(): + setattr(result, key, value) return result async def update_attr_by_id(self, id: int, column: str, value: Any) -> T: - async with self.session() as session: - query = select(self.model).filter(self.model.id == id) - _query = await session.execute(query) - result = _query.scalar_one_or_none() - if not result: - raise EntityNotFound - setattr(result, column, value) - await session.commit() + query = select(self.model).filter(self.model.id == id) + session = database.scoped_session() + _query = await session.execute(query) + result = _query.scalar_one_or_none() + if not result: + raise EntityNotFound + setattr(result, column, value) return result async def delete_by_id(self, id: int) -> T: - async with self.session() as session: - query = select(self.model).filter(self.model.id == id) - _query = await session.execute(query) - result = _query.scalar_one_or_none() - if not result: - raise EntityNotFound - await session.delete(result) - await session.commit() + query = select(self.model).filter(self.model.id == id) + session = database.scoped_session() + _query = await session.execute(query) + result = _query.scalar_one_or_none() + if not result: + raise EntityNotFound + await session.delete(result) return result diff --git a/app/repositories/users.py b/app/repositories/users.py index e7c381f..194bb04 100644 --- a/app/repositories/users.py +++ b/app/repositories/users.py @@ -1,11 +1,7 @@ -from typing import AsyncContextManager, Callable - -from sqlalchemy.ext.asyncio import AsyncSession - from app.models.users import User from app.repositories.base import BaseRepository class UserRepository(BaseRepository[User]): - def __init__(self, session: Callable[..., AsyncContextManager[AsyncSession]]): - super().__init__(session=session, model=User) + def __init__(self): + super().__init__(model=User) diff --git a/app/services/base.py b/app/services/base.py index 272e54a..b6132a0 100644 --- a/app/services/base.py +++ b/app/services/base.py @@ -1,5 +1,6 @@ from typing import Any, Generic, TypeVar, overload +from app.core.database import database from app.models.base import BaseModel from app.repositories.base import BaseRepository from app.schemas.base import BaseSchemaRequest, BaseSchemaResponse @@ -22,19 +23,23 @@ def mapper(self, data: BaseSchemaRequest | T) -> T | BaseSchemaResponse: return self.repository.model(**data.model_dump()) return BaseSchemaResponse.model_validate(data) + @database.transactional async def create(self, schema: BaseSchemaRequest) -> BaseSchemaResponse: model = self.mapper(schema) model = await self.repository.create(model=model) return self.mapper(model) + @database.transactional async def get_by_id(self, id: int) -> BaseSchemaResponse: model = await self.repository.read_by_id(id=id) return self.mapper(model) + @database.transactional async def put_by_id(self, id: int, schema: BaseSchemaRequest) -> BaseSchemaResponse: model = await self.repository.update_by_id(id=id, model=schema.model_dump()) return self.mapper(model) + @database.transactional async def patch_by_id( self, id: int, schema: BaseSchemaRequest ) -> BaseSchemaResponse: @@ -43,12 +48,14 @@ async def patch_by_id( ) return self.mapper(model) + @database.transactional async def patch_attr_by_id( self, id: int, attr: str, value: Any ) -> BaseSchemaResponse: model = await self.repository.update_attr_by_id(id=id, column=attr, value=value) return self.mapper(model) + @database.transactional async def delete_by_id(self, id: int) -> BaseSchemaResponse: model = await self.repository.delete_by_id(id=id) return self.mapper(model) diff --git a/app/tests/api/v1/test_users.py b/app/tests/api/v1/test_users.py index dbeae25..127ca15 100644 --- a/app/tests/api/v1/test_users.py +++ b/app/tests/api/v1/test_users.py @@ -9,6 +9,9 @@ def test_create_user(client: TestClient) -> None: + """ + Post user create-1, create-2 + """ for id in range(1, 3): name = f"create-{id}" response = client.post(f"{configs.PREFIX}/v1/user", json={"name": name}) @@ -18,6 +21,9 @@ def test_create_user(client: TestClient) -> None: @pytest.mark.run(after="test_create_user") def test_get_user(client: TestClient) -> None: + """ + Get user create-1, create-2 + """ for id in range(1, 3): name = f"create-{id}" response = client.get(f"{configs.PREFIX}/v1/user/{id}") @@ -30,6 +36,9 @@ def test_get_user(client: TestClient) -> None: @pytest.mark.run(after="test_get_user") def test_patch_user(client: TestClient) -> None: + """ + Patch user create-1 + """ name = "patch" time.sleep(1) response = client.patch(f"{configs.PREFIX}/v1/user/1", json={"name": name}) @@ -45,6 +54,9 @@ def test_patch_user(client: TestClient) -> None: @pytest.mark.run(after="test_get_user") def test_put_user(client: TestClient) -> None: + """ + Put user create-1 + """ name = "put" time.sleep(1) response = client.patch(f"{configs.PREFIX}/v1/user/2", json={"name": name}) diff --git a/uv.lock b/uv.lock index ca9e56a..2dfc78f 100644 --- a/uv.lock +++ b/uv.lock @@ -872,26 +872,26 @@ wheels = [ [[package]] name = "uv" -version = "0.5.21" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/c1/d8da0122d14a48a7895241b1c15b027d7df6f56350cae614561c0567ecb2/uv-0.5.21.tar.gz", hash = "sha256:eb33043b42111ae3fef76906422b5c4247188e1ae1233da63be82cc64bb527d0", size = 2631880 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/c7/c7a787cc2c526442b2999cbebe526e24517b8812f3d545e90811e38c213a/uv-0.5.21-py3-none-linux_armv6l.whl", hash = "sha256:8ea7309dc1891e88276e207aa389cc4524ec7a7038a75bfd7c5a09ed3701316f", size = 15181071 }, - { url = "https://files.pythonhosted.org/packages/5d/61/5a6796f31830898d0aa01e018d49bbbf39d61f2c19350663be16b6cfd1d9/uv-0.5.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ef4e579390a022efcbfe8720f51ad46fdff54caf982782967d5689841485ddd8", size = 15305687 }, - { url = "https://files.pythonhosted.org/packages/65/37/a5a2e0d0776063e2fe1f6dfac21dd5e707d2df9c167572c416970dd3af34/uv-0.5.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:73c9d1bdbff989114c5c37649235c569f89b65bd2e57b75d8fdb73946ade7cbd", size = 14214520 }, - { url = "https://files.pythonhosted.org/packages/15/ce/a844df3ea81c9370feed1ab0fd474776709a60f07b897c41fcdf0f260c0f/uv-0.5.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:6e97c68306c0583af1b14b5b801c3e18ab7bc349a4c9cdd8ab5f8f46348539c5", size = 14667101 }, - { url = "https://files.pythonhosted.org/packages/88/53/d4a0cefd1927f6047500c95967d69d045b11839c9f48e2a448372498186f/uv-0.5.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ecdf58adf9376f2b4f63e6538e38be0e77fcd3d5b07b3ee56a3c7cd1d9ca526", size = 14952637 }, - { url = "https://files.pythonhosted.org/packages/4d/0a/a68d9142e429b4a28cebcae21c6dba262f7905772d950d076e0b161f4e0c/uv-0.5.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dafa7b5bb3ae8949ba100645b7a8d804f683547586024f73ad1b2d97a1aa9976", size = 15665199 }, - { url = "https://files.pythonhosted.org/packages/18/9a/062eb481fe3661ee663751f0af9a6490014357592c9aea65d0261d385a40/uv-0.5.21-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:609299c04c00ece874b30abee9cb83753224a03e8d9191327397f33a92674a53", size = 16571172 }, - { url = "https://files.pythonhosted.org/packages/94/f0/8e36e40acb289a39ed00a49122f6c3ad36993ff11d8197885877ace30b73/uv-0.5.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:10232d5f24a1831f7ab3967f0b56f78681c520ff3391dcf5096eface94619e8e", size = 16292510 }, - { url = "https://files.pythonhosted.org/packages/91/40/3b48d57626dcb306c9e5736d4148fb6eaf931d94dbeb810ad32e48b58ac8/uv-0.5.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f17d35ab4a099657ad55d3cfeaf91a35b929ae2cd2b22163710cdfec45ea3941", size = 20623325 }, - { url = "https://files.pythonhosted.org/packages/5c/6f/86ee925f5e20df3aa366538a56e0d1bd5dfa9ef9d9bea57709480d47d72c/uv-0.5.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a1582f4964b1249b0e82ad0e60519a73392e099541a6db587e7333139255d50", size = 15952215 }, - { url = "https://files.pythonhosted.org/packages/62/f9/094ceaf8f0380b5381918aeb65907ff1fd06150b51f3baafa879ed9fdf4a/uv-0.5.21-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:afd98237d97b92935c8d5a9bf28218b5ecb497af9a99ad0a740d0b71b51f864a", size = 14914771 }, - { url = "https://files.pythonhosted.org/packages/0c/10/a5f73f433f29922b304eb95e7d6f18632734f92753c73017a8b05ce41795/uv-0.5.21-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:b317bfb7ba61e0396be5776f723e03e818a6393322f62828b67c16b565e1c0ec", size = 14904317 }, - { url = "https://files.pythonhosted.org/packages/76/4e/b9be4fcc45a026f1e1a2975719ee5f0444dafda1b606c0871d0c24651115/uv-0.5.21-py3-none-musllinux_1_1_i686.whl", hash = "sha256:168fca3bad68f75518a168feeebfd2c0b104e9abc06a33caa710d0b2753db3aa", size = 15315311 }, - { url = "https://files.pythonhosted.org/packages/a5/2d/74df7f292a7c15269bacd451a492520e26c4ef99b19c01fe96913506dea5/uv-0.5.21-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:f5ba5076b6b69161d318f5ddeff6dd935ab29a157ff10dd8756ed6dcb5d0a497", size = 16042115 }, - { url = "https://files.pythonhosted.org/packages/4b/69/03731b38d23e7bed653f186be2ff2dfcdcef29a611f4937ff4bacff205fe/uv-0.5.21-py3-none-win32.whl", hash = "sha256:34944204a39b840fa0efb2ba27f4decce50115460c6b8e4e6ade6aae6246d0cf", size = 15262952 }, - { url = "https://files.pythonhosted.org/packages/c2/8d/f6508e3c3fbc76b945365062ffff9fa6e60ad6516b26dae23a1c761d65c0/uv-0.5.21-py3-none-win_amd64.whl", hash = "sha256:36f21534a9e00a85cc532ef9575d3785a4e434a25daa93e51ebc08b54ade4991", size = 16625459 }, +version = "0.5.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/22/3e634cc4caf0a5acae08fc7ad80a76cf01a0be779ceb2cde3e14e164c1eb/uv-0.5.22.tar.gz", hash = "sha256:12f78366075bcff63e926f2beaf9dafe92f94368c736f4d63a64f4c6a2072af5", size = 2662375 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/40/f40aec432b7d53faa2e8a3bf155adaf2876ff8b327b7d2d43f5af7d7c213/uv-0.5.22-py3-none-linux_armv6l.whl", hash = "sha256:7b4024a82616e08f8e2361ae113bf74b8bef6f028835eb1300678eb9182e1bdc", size = 15317990 }, + { url = "https://files.pythonhosted.org/packages/be/36/a482fde2e4b762bc0838f5dcf195d2a7b5dd06ccdd5ab23004ac9451f34d/uv-0.5.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:32658bb5c8637a437b2a209b937e11beba7600dea600c672edef180b4748a729", size = 15474569 }, + { url = "https://files.pythonhosted.org/packages/bf/c7/462bc209bd6f9f4fc3db428b345a70fd59296ee914be090389c4f6f6a9f8/uv-0.5.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1ace09ab8028a2d596b84d8814ff78ce2a13f1e8a1e664523e6a6eeb9ef50aad", size = 14382343 }, + { url = "https://files.pythonhosted.org/packages/a0/e8/cc973a1fd0ebff01a7ddff903d2782a280f5588da4ed57ea70dad0a403e4/uv-0.5.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:9c9dda5ea4bcd49b0a7c36da6f1a0fd7a74fced354de3971f40c8176c5398d8a", size = 14836795 }, + { url = "https://files.pythonhosted.org/packages/8b/69/fa8ef336f799bd185e0a218ff28d39ca930295882edc972019abda23c166/uv-0.5.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f2f35104b3121d26bc1e2a19444cae589ec7307314d0b4788f1d784fab41748", size = 15052067 }, + { url = "https://files.pythonhosted.org/packages/23/18/ab10457abe0cac1cbee327c31c2a96001f4318451e0017f4727a3962045a/uv-0.5.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25e50a3216fd3370c8fad7c9c23539997e160cbc2e5121e0a8ca21d47a6bbcac", size = 15788379 }, + { url = "https://files.pythonhosted.org/packages/c2/24/c8b296df29cd92c825db8c60ae0799bd977fe1c91712599e1fa5d9bea8b5/uv-0.5.22-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ead10e1c0f443cf9921d73c18c65935b502bd4eb3cc1bbbbb43e0e7435a31dbd", size = 16705352 }, + { url = "https://files.pythonhosted.org/packages/34/7f/2cdf72eb49b99d222e6eb264515ff8433572ff8574cafcf8a910c816c459/uv-0.5.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8674f547ddf9b988d12120fa14a8eca4730b019869a2bacf11a89a6de788e6e6", size = 16428333 }, + { url = "https://files.pythonhosted.org/packages/c8/a5/74b580945594dcc444f9a1bce64f3c081a04d9c9f435340ae044da2416d9/uv-0.5.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cedf06ebfa2b233450b7f71a16c90f103ea68f415f45e61ec691554c03cccdb", size = 20752598 }, + { url = "https://files.pythonhosted.org/packages/fc/86/fddff1dd7c894bb4de44a66bc4372c9b3d64138f63bced3e0f919ca8f0ab/uv-0.5.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2f1eb24426680a22ca9a824f2af83a5e9b2179d709cd1b48ddd10e81e249d9c", size = 16118825 }, + { url = "https://files.pythonhosted.org/packages/7d/ef/860249b83023dfbcb2dc4f4ef66082a8e45504138fdaf9631d96b0221e3d/uv-0.5.22-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9b3e4bcee00b9f343c29bcd8251a739727f60c16870148336b5a97f94bb26a7b", size = 15122109 }, + { url = "https://files.pythonhosted.org/packages/e7/82/05ef1cf9de48a456969b40e08dae0d5afb9fc9b8ed6e25d42d4cdf2e4ab6/uv-0.5.22-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:82261745216bdb3faa80eab99fa7ebe7918fdde77b78a191e9f1a7f45450c405", size = 15043439 }, + { url = "https://files.pythonhosted.org/packages/9a/8a/782bdbbdd0d8623c807fedda6b11c3ab4c063dcf4940e7e88f3e652d51bb/uv-0.5.22-py3-none-musllinux_1_1_i686.whl", hash = "sha256:ee27c6bc80b59ec8680c7a9bff9716aaa082e403b03986425f6c4f7a4b2f2a7e", size = 15434092 }, + { url = "https://files.pythonhosted.org/packages/fa/fc/f267ef26fd75846f49a6e1e1ff61c18357046d6d0688409d70a16e21bae5/uv-0.5.22-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:4c03ad206af6c521d05a9b6c8f1f265b5a9390858e7095f0b7354bb2a86a33f1", size = 16247589 }, + { url = "https://files.pythonhosted.org/packages/7c/51/01277ba4e7139820921ffa09bca3143d854b38d77d1fc80dc7c6ab3a0c69/uv-0.5.22-py3-none-win32.whl", hash = "sha256:34235a5b50c8b03b7c4f4be8dc3f021802672bd3152812a7f05710d67459bf76", size = 15326817 }, + { url = "https://files.pythonhosted.org/packages/72/ee/f53d2f8f24b1f9420f85dec8d573772aa41b5a01211198112d9ce4cbb6df/uv-0.5.22-py3-none-win_amd64.whl", hash = "sha256:8c57f65b570508fb6a460001f187fffebbc7665e9c642cefbca3719bc48f078f", size = 16657665 }, ] [[package]] From 86e79428358f425de72c3d6f5675eb0e1d2870c6 Mon Sep 17 00:00:00 2001 From: Zerohertz Date: Thu, 23 Jan 2025 01:26:05 +0900 Subject: [PATCH 02/18] :hammer: update: version --- envs/test.env | 2 +- k8s/postgresql/configmap.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/envs/test.env b/envs/test.env index cbb59d2..a0c503c 100644 --- a/envs/test.env +++ b/envs/test.env @@ -1,7 +1,7 @@ PORT="8000" PROJECT_NAME="Zerohertz's FastAPI Cookbook (test)" -VERSION="v0.1.0" +VERSION="v0.1.1" PREFIX="/api" DB_TYPE="sqlite" diff --git a/k8s/postgresql/configmap.yaml b/k8s/postgresql/configmap.yaml index b17576c..4611528 100644 --- a/k8s/postgresql/configmap.yaml +++ b/k8s/postgresql/configmap.yaml @@ -6,14 +6,14 @@ data: dev.env: | PORT="8000" PROJECT_NAME="Zerohertz's FastAPI Cookbook (dev)" - VERSION="v0.0.2" + VERSION="v0.1.1" PREFIX="/api" DB_ECHO=true DB_TABLE_CREATE=true prod.env: | PORT="8000" PROJECT_NAME="Zerohertz's FastAPI Cookbook (prod)" - VERSION="v0.0.2" + VERSION="v0.1.1" PREFIX="" DB_ECHO=false DB_TABLE_CREATE=false From fa9163bf24e907ab0459e94e9071ec3ac4ad35f1 Mon Sep 17 00:00:00 2001 From: Zerohertz Date: Thu, 23 Jan 2025 01:27:43 +0900 Subject: [PATCH 03/18] :art: style: ruff --- app/core/database.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/core/database.py b/app/core/database.py index c51590f..d3f8711 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -3,16 +3,12 @@ from typing import Awaitable, Callable, Optional from loguru import logger -from sqlalchemy import ClauseElement, Connection, Engine from sqlalchemy.ext.asyncio import ( AsyncSession, async_scoped_session, async_sessionmaker, create_async_engine, ) -from sqlalchemy.orm import Session as SyncSession -from sqlalchemy.orm.session import _EntityBindKey -from sqlalchemy.sql.expression import Delete, Insert, Update from app.core.configs import configs from app.models.base import BaseModel From cdc26173488e5578503469e8ac93f8f0c346b2ca Mon Sep 17 00:00:00 2001 From: Zerohertz Date: Thu, 23 Jan 2025 03:25:33 +0900 Subject: [PATCH 04/18] :bug: fix: event loop in pytest (related: #19) --- app/core/configs.py | 9 +++++++++ app/core/database.py | 18 +++++++++++++++--- app/core/lifespan.py | 1 + envs/test.env | 2 ++ k8s/postgresql/configmap.yaml | 2 ++ pyproject.toml | 1 - uv.lock | 14 -------------- 7 files changed, 29 insertions(+), 18 deletions(-) diff --git a/app/core/configs.py b/app/core/configs.py index 8b7234d..b09147f 100644 --- a/app/core/configs.py +++ b/app/core/configs.py @@ -1,3 +1,4 @@ +from enum import Enum from typing import Optional from pydantic import computed_field @@ -5,7 +6,15 @@ from pydantic_settings import BaseSettings +class ENVIRONMENT(Enum): + TEST = "TEST" + DEV = "DEV" + PROD = "PROD" + + class Configs(BaseSettings): + ENV: ENVIRONMENT + # --------- APP SETTINGS --------- # PROJECT_NAME: str DESCRIPTION: str diff --git a/app/core/database.py b/app/core/database.py index d3f8711..746091d 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -3,6 +3,7 @@ from typing import Awaitable, Callable, Optional from loguru import logger +from sqlalchemy import NullPool from sqlalchemy.ext.asyncio import ( AsyncSession, async_scoped_session, @@ -10,7 +11,7 @@ create_async_engine, ) -from app.core.configs import configs +from app.core.configs import ENVIRONMENT, configs from app.models.base import BaseModel @@ -36,7 +37,15 @@ def reset(self, context: Token) -> None: class Database: def __init__(self) -> None: self.context = Context() - self.engine = create_async_engine(configs.DATABASE_URI, echo=configs.DB_ECHO) + async_engine_kwargs = { + "url": configs.DATABASE_URI, + "echo": configs.DB_ECHO, + } + if configs.ENV == ENVIRONMENT.TEST and configs.DB_DRIVER != "aiosqlite": + # NOTE: PyTest 시 event loop 충돌 발생 (related: #19) + logger.warning("Using NullPool for async engine") + async_engine_kwargs["poolclass"] = NullPool # type: ignore[assignment] + self.engine = create_async_engine(**async_engine_kwargs) # type: ignore[arg-type] self.sessionmaker = async_sessionmaker( bind=self.engine, class_=AsyncSession, @@ -45,12 +54,15 @@ def __init__(self) -> None: expire_on_commit=False, ) self.scoped_session = async_scoped_session( - session_factory=self.sessionmaker, scopefunc=self.context.get + session_factory=self.sessionmaker, + scopefunc=self.context.get, ) async def create_all(self) -> None: logger.warning("Create database") async with self.engine.begin() as conn: + if configs.ENV == ENVIRONMENT.TEST: + await conn.run_sync(BaseModel.metadata.drop_all) await conn.run_sync(BaseModel.metadata.create_all) def transactional(self, func: Callable[..., Awaitable]) -> Callable[..., Awaitable]: diff --git a/app/core/lifespan.py b/app/core/lifespan.py index b75c979..0503bc5 100644 --- a/app/core/lifespan.py +++ b/app/core/lifespan.py @@ -28,6 +28,7 @@ async def lifespan(app: FastAPI): # pylint: disable=unused-argument # logging.getLogger("uvicorn.access").addHandler(LoguruHandler()) # logging.getLogger("uvicorn.error").addHandler(LoguruHandler()) + logger.info(f"{configs.ENV=}") if configs.DB_TABLE_CREATE: await database.create_all() Container() diff --git a/envs/test.env b/envs/test.env index a0c503c..714d54a 100644 --- a/envs/test.env +++ b/envs/test.env @@ -1,3 +1,5 @@ +ENV="TEST" + PORT="8000" PROJECT_NAME="Zerohertz's FastAPI Cookbook (test)" diff --git a/k8s/postgresql/configmap.yaml b/k8s/postgresql/configmap.yaml index 4611528..4c8a530 100644 --- a/k8s/postgresql/configmap.yaml +++ b/k8s/postgresql/configmap.yaml @@ -4,6 +4,7 @@ metadata: name: fastapi-env data: dev.env: | + ENV="DEV" PORT="8000" PROJECT_NAME="Zerohertz's FastAPI Cookbook (dev)" VERSION="v0.1.1" @@ -11,6 +12,7 @@ data: DB_ECHO=true DB_TABLE_CREATE=true prod.env: | + ENV="PROD" PORT="8000" PROJECT_NAME="Zerohertz's FastAPI Cookbook (prod)" VERSION="v0.1.1" diff --git a/pyproject.toml b/pyproject.toml index e9809c5..f0c34d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,6 @@ test = [ "pytest>=8.3.4", "pytest-cov>=6.0.0", "pytest-dotenv>=0.5.2", - "pytest-ordering>=0.6", "aiomysql>=0.2.0", "aiosqlite>=0.20.0", "cryptography>=44.0.0", diff --git a/uv.lock b/uv.lock index 2dfc78f..2f04f3f 100644 --- a/uv.lock +++ b/uv.lock @@ -333,7 +333,6 @@ test = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-dotenv" }, - { name = "pytest-ordering" }, ] [package.metadata] @@ -366,7 +365,6 @@ test = [ { name = "pytest", specifier = ">=8.3.4" }, { name = "pytest-cov", specifier = ">=6.0.0" }, { name = "pytest-dotenv", specifier = ">=0.5.2" }, - { name = "pytest-ordering", specifier = ">=0.6" }, ] [[package]] @@ -751,18 +749,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/da/9da67c67b3d0963160e3d2cbc7c38b6fae342670cc8e6d5936644b2cf944/pytest_dotenv-0.5.2-py3-none-any.whl", hash = "sha256:40a2cece120a213898afaa5407673f6bd924b1fa7eafce6bda0e8abffe2f710f", size = 3993 }, ] -[[package]] -name = "pytest-ordering" -version = "0.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e1/ba/65091c36e6e18da479d22d860586e3ba3a4237cc92a66e3ddd945e4fe761/pytest-ordering-0.6.tar.gz", hash = "sha256:561ad653626bb171da78e682f6d39ac33bb13b3e272d406cd555adb6b006bda6", size = 2629 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/98/adc368fe369465f291ab24e18b9900473786ed1afdf861ba90467eb0767e/pytest_ordering-0.6-py3-none-any.whl", hash = "sha256:3f314a178dbeb6777509548727dc69edf22d6d9a2867bf2d310ab85c403380b6", size = 4643 }, -] - [[package]] name = "python-dotenv" version = "1.0.1" From d3b9901ce6ca197f35d62dae80b3e62f921e8736 Mon Sep 17 00:00:00 2001 From: Zerohertz Date: Thu, 23 Jan 2025 03:25:53 +0900 Subject: [PATCH 05/18] :fire: remove: pytest decorator --- app/tests/api/v1/test_users.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/tests/api/v1/test_users.py b/app/tests/api/v1/test_users.py index 127ca15..aaf8654 100644 --- a/app/tests/api/v1/test_users.py +++ b/app/tests/api/v1/test_users.py @@ -1,6 +1,5 @@ import time -import pytest from fastapi.testclient import TestClient from loguru import logger from starlette import status @@ -19,7 +18,6 @@ def test_create_user(client: TestClient) -> None: assert response.status_code == status.HTTP_201_CREATED -@pytest.mark.run(after="test_create_user") def test_get_user(client: TestClient) -> None: """ Get user create-1, create-2 @@ -34,7 +32,6 @@ def test_get_user(client: TestClient) -> None: assert data["name"] == name -@pytest.mark.run(after="test_get_user") def test_patch_user(client: TestClient) -> None: """ Patch user create-1 @@ -52,7 +49,6 @@ def test_patch_user(client: TestClient) -> None: assert data["created_at"] != data["updated_at"] -@pytest.mark.run(after="test_get_user") def test_put_user(client: TestClient) -> None: """ Put user create-1 From 0893e6dfb343620922d0f9aa458e992f11a11497 Mon Sep 17 00:00:00 2001 From: Zerohertz Date: Thu, 23 Jan 2025 03:26:01 +0900 Subject: [PATCH 06/18] :memo: docs: README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 39ee174..98f7a37 100644 --- a/README.md +++ b/README.md @@ -34,4 +34,6 @@ - [fastapi/full-stack-fastapi-template/backend](https://github.com/fastapi/full-stack-fastapi-template/tree/master/backend) +- [teamhide/fastapi-boilerplate](https://github.com/teamhide/fastapi-boilerplate) - [jujumilk3/fastapi-clean-architecture](https://github.com/jujumilk3/fastapi-clean-architecture) +- [UponTheSky/How to implement a transactional decorator in FastAPI + SQLAlchemy - with reviewing other approaches](https://dev.to/uponthesky/python-post-reviewhow-to-implement-a-transactional-decorator-in-fastapi-sqlalchemy-ein) From 4c691b885cfeb777b7902ee353b8deb05223641e Mon Sep 17 00:00:00 2001 From: Zerohertz Date: Thu, 23 Jan 2025 03:38:50 +0900 Subject: [PATCH 07/18] :ship: test: mysql database session --- app/repositories/base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/repositories/base.py b/app/repositories/base.py index f78606d..a1fba8a 100644 --- a/app/repositories/base.py +++ b/app/repositories/base.py @@ -21,6 +21,7 @@ async def create(self, model: T) -> T: session = database.scoped_session() session.add(model) await session.flush() + await session.refresh(model) return model async def read_by_id(self, id: int, eager: bool = False) -> T: From 7da30791826ab49cf0ee8ac3aa7432349e49ea49 Mon Sep 17 00:00:00 2001 From: Zerohertz Date: Fri, 24 Jan 2025 02:02:05 +0900 Subject: [PATCH 08/18] :fire: feat: async test for service layer --- Makefile | 2 +- app/core/lifespan.py | 2 +- app/tests/api/v1/test_shields.py | 4 +-- app/tests/api/v1/test_users.py | 50 +++++++++++++++++++++----------- app/tests/conftest.py | 34 ++++++++++++++++++++-- app/tests/services/__init__.py | 0 app/tests/services/test_users.py | 16 ++++++++++ pyproject.toml | 4 +-- uv.lock | 28 ++++++++++++++++++ 9 files changed, 114 insertions(+), 26 deletions(-) create mode 100644 app/tests/services/__init__.py create mode 100644 app/tests/services/test_users.py diff --git a/Makefile b/Makefile index b86b32d..4ea3caa 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ lint: test: uv sync --group test export DESCRIPTION=$$(cat README.md) && \ - uv run pytest \ + uv run pytest -vv \ --cov=app --cov-branch --cov-report=xml \ --junitxml=junit.xml -o junit_family=legacy diff --git a/app/core/lifespan.py b/app/core/lifespan.py index 0503bc5..c5635bc 100644 --- a/app/core/lifespan.py +++ b/app/core/lifespan.py @@ -31,6 +31,6 @@ async def lifespan(app: FastAPI): # pylint: disable=unused-argument logger.info(f"{configs.ENV=}") if configs.DB_TABLE_CREATE: await database.create_all() - Container() + app.container = Container() # type: ignore[attr-defined] yield diff --git a/app/tests/api/v1/test_shields.py b/app/tests/api/v1/test_shields.py index 0a641d5..5188a87 100644 --- a/app/tests/api/v1/test_shields.py +++ b/app/tests/api/v1/test_shields.py @@ -4,8 +4,8 @@ from app.core.configs import configs -def test_jmy(client: TestClient) -> None: - response = client.get( +def test_jmy(sync_client: TestClient) -> None: + response = sync_client.get( f"{configs.PREFIX}/v1/shields/jmy", ) assert response.status_code == status.HTTP_200_OK diff --git a/app/tests/api/v1/test_users.py b/app/tests/api/v1/test_users.py index aaf8654..603a3c9 100644 --- a/app/tests/api/v1/test_users.py +++ b/app/tests/api/v1/test_users.py @@ -1,5 +1,6 @@ import time +import pytest from fastapi.testclient import TestClient from loguru import logger from starlette import status @@ -7,24 +8,26 @@ from app.core.configs import configs -def test_create_user(client: TestClient) -> None: +@pytest.mark.run(order=1) +def test_create_user(sync_client: TestClient) -> None: """ - Post user create-1, create-2 + Post user route-create-1, route-create-2 """ for id in range(1, 3): - name = f"create-{id}" - response = client.post(f"{configs.PREFIX}/v1/user", json={"name": name}) + name = f"route-create-{id}" + response = sync_client.post(f"{configs.PREFIX}/v1/user", json={"name": name}) logger.warning(response) assert response.status_code == status.HTTP_201_CREATED -def test_get_user(client: TestClient) -> None: +@pytest.mark.run(order=2) +def test_get_user(sync_client: TestClient) -> None: """ Get user create-1, create-2 """ for id in range(1, 3): - name = f"create-{id}" - response = client.get(f"{configs.PREFIX}/v1/user/{id}") + name = f"route-create-{id}" + response = sync_client.get(f"{configs.PREFIX}/v1/user/{id}") logger.warning(response) assert response.status_code == status.HTTP_200_OK data = response.json()["data"] @@ -32,16 +35,17 @@ def test_get_user(client: TestClient) -> None: assert data["name"] == name -def test_patch_user(client: TestClient) -> None: +@pytest.mark.run(order=3) +def test_patch_user(sync_client: TestClient) -> None: """ - Patch user create-1 + Patch user route-create-1 to route-patch """ - name = "patch" + name = "route-patch" time.sleep(1) - response = client.patch(f"{configs.PREFIX}/v1/user/1", json={"name": name}) + response = sync_client.patch(f"{configs.PREFIX}/v1/user/1", json={"name": name}) logger.warning(response) assert response.status_code == status.HTTP_200_OK - response = client.get(f"{configs.PREFIX}/v1/user/1") + response = sync_client.get(f"{configs.PREFIX}/v1/user/1") assert response.status_code == status.HTTP_200_OK data = response.json()["data"] logger.warning(data) @@ -49,18 +53,30 @@ def test_patch_user(client: TestClient) -> None: assert data["created_at"] != data["updated_at"] -def test_put_user(client: TestClient) -> None: +@pytest.mark.run(order=3) +def test_put_user(sync_client: TestClient) -> None: """ - Put user create-1 + Put user route-create-2 to route-put """ - name = "put" + name = "route-put" time.sleep(1) - response = client.patch(f"{configs.PREFIX}/v1/user/2", json={"name": name}) + response = sync_client.patch(f"{configs.PREFIX}/v1/user/2", json={"name": name}) logger.warning(response) assert response.status_code == status.HTTP_200_OK - response = client.get(f"{configs.PREFIX}/v1/user/2") + response = sync_client.get(f"{configs.PREFIX}/v1/user/2") assert response.status_code == status.HTTP_200_OK data = response.json()["data"] logger.warning(data) assert data["name"] == name assert data["created_at"] != data["updated_at"] + + +@pytest.mark.run(order=4) +def test_delete_user(sync_client: TestClient) -> None: + """ + Delete user route-patch, route-put + """ + for id in range(1, 3): + response = sync_client.delete(f"{configs.PREFIX}/v1/user/{id}") + logger.warning(response) + assert response.status_code == status.HTTP_200_OK diff --git a/app/tests/conftest.py b/app/tests/conftest.py index e4c58e4..3ba7d6a 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -1,12 +1,40 @@ from collections.abc import Generator +from contextvars import Token +from typing import AsyncGenerator import pytest +import pytest_asyncio from fastapi.testclient import TestClient +from httpx import ASGITransport, AsyncClient +from app.core.container import Container +from app.core.database import database from app.main import app +@pytest_asyncio.fixture(scope="function") +async def context() -> AsyncGenerator[Token, None]: + _context = database.context.set(session_id=hash(123)) + yield _context + database.context.reset(context=_context) + + +@pytest.fixture(scope="session") +def container() -> Generator[Container, None, None]: + _container = Container() + _container.wire() + yield _container + + @pytest.fixture(scope="module") -def client() -> Generator[TestClient, None, None]: - with TestClient(app) as cli: - yield cli +def sync_client() -> Generator[TestClient, None, None]: + with TestClient(app) as client: + yield client + + +@pytest_asyncio.fixture(scope="session", loop_scope="session") +async def async_client() -> AsyncGenerator[AsyncClient, None]: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + yield client diff --git a/app/tests/services/__init__.py b/app/tests/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tests/services/test_users.py b/app/tests/services/test_users.py new file mode 100644 index 0000000..1079aac --- /dev/null +++ b/app/tests/services/test_users.py @@ -0,0 +1,16 @@ +from contextvars import Token + +import pytest +from loguru import logger + +from app.core.container import Container +from app.schemas.users import UserCreateRequest + + +@pytest.mark.asyncio(loop_scope="function") +async def test_create_user(container: Container, context: Token) -> None: + logger.warning(f"{context=}") + name = "service-layer-user" + user_service = container.user_service() + user = await user_service.create(schema=UserCreateRequest(name=name)) + assert user.name == name diff --git a/pyproject.toml b/pyproject.toml index f0c34d1..63cad8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,8 +33,8 @@ test = [ "aiomysql>=0.2.0", "aiosqlite>=0.20.0", "cryptography>=44.0.0", - # NOTE: Sync Drivers - # "mysqlclient>=2.2.7", + "pytest-asyncio>=0.25.2", + "pytest-ordering>=0.6", ] [tool.mypy] diff --git a/uv.lock b/uv.lock index 2f04f3f..691b053 100644 --- a/uv.lock +++ b/uv.lock @@ -331,8 +331,10 @@ test = [ { name = "aiosqlite" }, { name = "cryptography" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-dotenv" }, + { name = "pytest-ordering" }, ] [package.metadata] @@ -363,8 +365,10 @@ test = [ { name = "aiosqlite", specifier = ">=0.20.0" }, { name = "cryptography", specifier = ">=44.0.0" }, { name = "pytest", specifier = ">=8.3.4" }, + { name = "pytest-asyncio", specifier = ">=0.25.2" }, { name = "pytest-cov", specifier = ">=6.0.0" }, { name = "pytest-dotenv", specifier = ">=0.5.2" }, + { name = "pytest-ordering", specifier = ">=0.6" }, ] [[package]] @@ -723,6 +727,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 }, ] +[[package]] +name = "pytest-asyncio" +version = "0.25.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/df/adcc0d60f1053d74717d21d58c0048479e9cab51464ce0d2965b086bd0e2/pytest_asyncio-0.25.2.tar.gz", hash = "sha256:3f8ef9a98f45948ea91a0ed3dc4268b5326c0e7bce73892acc654df4262ad45f", size = 53950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/d8/defa05ae50dcd6019a95527200d3b3980043df5aa445d40cb0ef9f7f98ab/pytest_asyncio-0.25.2-py3-none-any.whl", hash = "sha256:0d0bb693f7b99da304a0634afc0a4b19e49d5e0de2d670f38dc4bfa5727c5075", size = 19400 }, +] + [[package]] name = "pytest-cov" version = "6.0.0" @@ -749,6 +765,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/da/9da67c67b3d0963160e3d2cbc7c38b6fae342670cc8e6d5936644b2cf944/pytest_dotenv-0.5.2-py3-none-any.whl", hash = "sha256:40a2cece120a213898afaa5407673f6bd924b1fa7eafce6bda0e8abffe2f710f", size = 3993 }, ] +[[package]] +name = "pytest-ordering" +version = "0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/ba/65091c36e6e18da479d22d860586e3ba3a4237cc92a66e3ddd945e4fe761/pytest-ordering-0.6.tar.gz", hash = "sha256:561ad653626bb171da78e682f6d39ac33bb13b3e272d406cd555adb6b006bda6", size = 2629 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/98/adc368fe369465f291ab24e18b9900473786ed1afdf861ba90467eb0767e/pytest_ordering-0.6-py3-none-any.whl", hash = "sha256:3f314a178dbeb6777509548727dc69edf22d6d9a2867bf2d310ab85c403380b6", size = 4643 }, +] + [[package]] name = "python-dotenv" version = "1.0.1" From da8567e87d89edeca89ee9a570439536389d045f Mon Sep 17 00:00:00 2001 From: Zerohertz Date: Fri, 24 Jan 2025 02:11:40 +0900 Subject: [PATCH 09/18] :hammer: modify: logger level depends on environment --- app/core/lifespan.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/core/lifespan.py b/app/core/lifespan.py index c5635bc..b1091cf 100644 --- a/app/core/lifespan.py +++ b/app/core/lifespan.py @@ -6,7 +6,7 @@ from fastapi import FastAPI from loguru import logger -from app.core.configs import configs +from app.core.configs import ENVIRONMENT, configs from app.core.container import Container from app.core.database import database from app.utils.logging import remove_handler @@ -17,9 +17,12 @@ async def lifespan(app: FastAPI): # pylint: disable=unused-argument remove_handler(logging.getLogger("uvicorn.access")) remove_handler(logging.getLogger("uvicorn.error")) logger.remove() + level = 0 + if configs.ENV == ENVIRONMENT.PROD: + level = 20 logger.add( sys.stderr, - level=0, + level=level, format="{time:YYYY-MM-DD HH:mm:ss.SSS} " + time.tzname[0] + " | {level: <8} | {name}:{function}:{line} - {message}", From 19bf2525f78fcea5657b70843945b4d419384e9f Mon Sep 17 00:00:00 2001 From: Zerohertz Date: Fri, 24 Jan 2025 02:16:48 +0900 Subject: [PATCH 10/18] :recycle: refactor: core exception --- app/exceptions/database.py | 7 ++++++- app/exceptions/handlers.py | 4 +--- app/main.py | 4 ++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/app/exceptions/database.py b/app/exceptions/database.py index e70f94f..33d1be6 100644 --- a/app/exceptions/database.py +++ b/app/exceptions/database.py @@ -3,6 +3,11 @@ from app.exceptions.base import CoreException -class EntityNotFound(CoreException): +class DatabaseException(CoreException): + status: int + message: str + + +class EntityNotFound(DatabaseException): status: int = status.HTTP_404_NOT_FOUND message: str = "Entity not found in the database." diff --git a/app/exceptions/handlers.py b/app/exceptions/handlers.py index 301c2d9..e4d9eed 100644 --- a/app/exceptions/handlers.py +++ b/app/exceptions/handlers.py @@ -19,9 +19,7 @@ async def global_exception_handler(request: Request, exc: Exception) -> JSONResp ) -async def business_exception_handler( - request: Request, exc: CoreException -) -> JSONResponse: +async def core_exception_handler(request: Request, exc: CoreException) -> JSONResponse: logger.error(f"{request=}, {exc=}") name = exc.__class__.__name__ return JSONResponse( diff --git a/app/main.py b/app/main.py index f80fb3d..89d850c 100644 --- a/app/main.py +++ b/app/main.py @@ -5,7 +5,7 @@ from app.core.lifespan import lifespan from app.core.middlewares import LoggingMiddleware, SessionMiddleware from app.exceptions.base import CoreException -from app.exceptions.handlers import business_exception_handler, global_exception_handler +from app.exceptions.handlers import core_exception_handler, global_exception_handler app = FastAPI( title=configs.PROJECT_NAME, @@ -17,7 +17,7 @@ redoc_url=f"{configs.PREFIX}/redoc", exception_handlers={ Exception: global_exception_handler, - CoreException: business_exception_handler, + CoreException: core_exception_handler, }, lifespan=lifespan, ) From 41e872b5270f7592005c734ac8e94347ff82c483 Mon Sep 17 00:00:00 2001 From: Zerohertz Date: Fri, 24 Jan 2025 02:31:16 +0900 Subject: [PATCH 11/18] :sparkles: feat: entity already exist --- app/core/database.py | 2 ++ app/exceptions/database.py | 5 +++++ app/repositories/base.py | 16 ++++++++++++++-- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/app/core/database.py b/app/core/database.py index 746091d..ba3b600 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -4,6 +4,7 @@ from loguru import logger from sqlalchemy import NullPool +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import ( AsyncSession, async_scoped_session, @@ -12,6 +13,7 @@ ) from app.core.configs import ENVIRONMENT, configs +from app.exceptions.database import EntityAlreadyExists from app.models.base import BaseModel diff --git a/app/exceptions/database.py b/app/exceptions/database.py index 33d1be6..fa7de9e 100644 --- a/app/exceptions/database.py +++ b/app/exceptions/database.py @@ -8,6 +8,11 @@ class DatabaseException(CoreException): message: str +class EntityAlreadyExists(DatabaseException): + status: int = status.HTTP_409_CONFLICT + message: str = "Entity already exists in the database." + + class EntityNotFound(DatabaseException): status: int = status.HTTP_404_NOT_FOUND message: str = "Entity not found in the database." diff --git a/app/repositories/base.py b/app/repositories/base.py index a1fba8a..1534a42 100644 --- a/app/repositories/base.py +++ b/app/repositories/base.py @@ -1,10 +1,11 @@ from typing import Any, Generic, Type, TypeVar +from sqlalchemy.exc import IntegrityError from sqlalchemy.future import select from sqlalchemy.orm import joinedload from app.core.database import database -from app.exceptions.database import EntityNotFound +from app.exceptions.database import EntityAlreadyExists, EntityNotFound from app.models.base import BaseModel T = TypeVar("T", bound=BaseModel) @@ -20,7 +21,10 @@ def __init__( async def create(self, model: T) -> T: session = database.scoped_session() session.add(model) - await session.flush() + try: + await session.flush() + except IntegrityError: + raise EntityAlreadyExists await session.refresh(model) return model @@ -46,6 +50,10 @@ async def update_by_id(self, id: int, model: dict) -> T: raise EntityNotFound for key, value in model.items(): setattr(result, key, value) + try: + await session.flush() + except IntegrityError: + raise EntityAlreadyExists return result async def update_attr_by_id(self, id: int, column: str, value: Any) -> T: @@ -56,6 +64,10 @@ async def update_attr_by_id(self, id: int, column: str, value: Any) -> T: if not result: raise EntityNotFound setattr(result, column, value) + try: + await session.flush() + except IntegrityError: + raise EntityAlreadyExists return result async def delete_by_id(self, id: int) -> T: From b710fc0df3c3575b5a58a5bf5e243c9196e93a87 Mon Sep 17 00:00:00 2001 From: Zerohertz Date: Fri, 24 Jan 2025 02:36:09 +0900 Subject: [PATCH 12/18] :bug: fix: session refresh --- app/repositories/base.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/repositories/base.py b/app/repositories/base.py index 1534a42..95d6897 100644 --- a/app/repositories/base.py +++ b/app/repositories/base.py @@ -54,6 +54,7 @@ async def update_by_id(self, id: int, model: dict) -> T: await session.flush() except IntegrityError: raise EntityAlreadyExists + await session.refresh(result) return result async def update_attr_by_id(self, id: int, column: str, value: Any) -> T: @@ -68,6 +69,7 @@ async def update_attr_by_id(self, id: int, column: str, value: Any) -> T: await session.flush() except IntegrityError: raise EntityAlreadyExists + await session.refresh(result) return result async def delete_by_id(self, id: int) -> T: From ff7aced832c6a977d2becfa9868559a97f1c58f3 Mon Sep 17 00:00:00 2001 From: Zerohertz Date: Fri, 24 Jan 2025 02:41:13 +0900 Subject: [PATCH 13/18] :recycle: refactor: core exception --- app/exceptions/base.py | 8 ++++++++ app/exceptions/handlers.py | 9 ++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/app/exceptions/base.py b/app/exceptions/base.py index b21c8d3..2fce833 100644 --- a/app/exceptions/base.py +++ b/app/exceptions/base.py @@ -4,3 +4,11 @@ class CoreException(abc.ABC, Exception): status: int message: str + + def __str__(self) -> str: + return ( + f"[{self.__class__.__name__}] status={self.status}, message={self.message}" + ) + + def __repr__(self) -> str: + return f"[{self.__class__.__name__}] {self.message}" diff --git a/app/exceptions/handlers.py b/app/exceptions/handlers.py index e4d9eed..e09467b 100644 --- a/app/exceptions/handlers.py +++ b/app/exceptions/handlers.py @@ -20,11 +20,10 @@ async def global_exception_handler(request: Request, exc: Exception) -> JSONResp async def core_exception_handler(request: Request, exc: CoreException) -> JSONResponse: - logger.error(f"{request=}, {exc=}") - name = exc.__class__.__name__ + logger.error(exc) return JSONResponse( - content=APIResponse.error( - status=exc.status, message=f"[{name}] {exc.message}" - ).model_dump(mode="json"), + content=APIResponse.error(status=exc.status, message=repr(exc)).model_dump( + mode="json" + ), status_code=exc.status, ) From ec6fa3e3f5ae20ee7e3fc03e9bc474e5fc1cdd96 Mon Sep 17 00:00:00 2001 From: Zerohertz Date: Fri, 24 Jan 2025 03:16:28 +0900 Subject: [PATCH 14/18] :fire: remove: pytest ordering --- pyproject.toml | 1 - uv.lock | 14 -------------- 2 files changed, 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 63cad8b..703b40f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,6 @@ test = [ "aiosqlite>=0.20.0", "cryptography>=44.0.0", "pytest-asyncio>=0.25.2", - "pytest-ordering>=0.6", ] [tool.mypy] diff --git a/uv.lock b/uv.lock index 691b053..7eefcef 100644 --- a/uv.lock +++ b/uv.lock @@ -334,7 +334,6 @@ test = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-dotenv" }, - { name = "pytest-ordering" }, ] [package.metadata] @@ -368,7 +367,6 @@ test = [ { name = "pytest-asyncio", specifier = ">=0.25.2" }, { name = "pytest-cov", specifier = ">=6.0.0" }, { name = "pytest-dotenv", specifier = ">=0.5.2" }, - { name = "pytest-ordering", specifier = ">=0.6" }, ] [[package]] @@ -765,18 +763,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/da/9da67c67b3d0963160e3d2cbc7c38b6fae342670cc8e6d5936644b2cf944/pytest_dotenv-0.5.2-py3-none-any.whl", hash = "sha256:40a2cece120a213898afaa5407673f6bd924b1fa7eafce6bda0e8abffe2f710f", size = 3993 }, ] -[[package]] -name = "pytest-ordering" -version = "0.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e1/ba/65091c36e6e18da479d22d860586e3ba3a4237cc92a66e3ddd945e4fe761/pytest-ordering-0.6.tar.gz", hash = "sha256:561ad653626bb171da78e682f6d39ac33bb13b3e272d406cd555adb6b006bda6", size = 2629 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/98/adc368fe369465f291ab24e18b9900473786ed1afdf861ba90467eb0767e/pytest_ordering-0.6-py3-none-any.whl", hash = "sha256:3f314a178dbeb6777509548727dc69edf22d6d9a2867bf2d310ab85c403380b6", size = 4643 }, -] - [[package]] name = "python-dotenv" version = "1.0.1" From 1820af4deb41009312149ffac7e69d3ac9171280 Mon Sep 17 00:00:00 2001 From: Zerohertz Date: Fri, 24 Jan 2025 03:17:08 +0900 Subject: [PATCH 15/18] :ship: test: crud routers --- app/tests/api/v1/test_users.py | 163 ++++++++++++++++----------------- 1 file changed, 81 insertions(+), 82 deletions(-) diff --git a/app/tests/api/v1/test_users.py b/app/tests/api/v1/test_users.py index 603a3c9..e594dd2 100644 --- a/app/tests/api/v1/test_users.py +++ b/app/tests/api/v1/test_users.py @@ -1,82 +1,81 @@ -import time - -import pytest -from fastapi.testclient import TestClient -from loguru import logger -from starlette import status - -from app.core.configs import configs - - -@pytest.mark.run(order=1) -def test_create_user(sync_client: TestClient) -> None: - """ - Post user route-create-1, route-create-2 - """ - for id in range(1, 3): - name = f"route-create-{id}" - response = sync_client.post(f"{configs.PREFIX}/v1/user", json={"name": name}) - logger.warning(response) - assert response.status_code == status.HTTP_201_CREATED - - -@pytest.mark.run(order=2) -def test_get_user(sync_client: TestClient) -> None: - """ - Get user create-1, create-2 - """ - for id in range(1, 3): - name = f"route-create-{id}" - response = sync_client.get(f"{configs.PREFIX}/v1/user/{id}") - logger.warning(response) - assert response.status_code == status.HTTP_200_OK - data = response.json()["data"] - logger.warning(data) - assert data["name"] == name - - -@pytest.mark.run(order=3) -def test_patch_user(sync_client: TestClient) -> None: - """ - Patch user route-create-1 to route-patch - """ - name = "route-patch" - time.sleep(1) - response = sync_client.patch(f"{configs.PREFIX}/v1/user/1", json={"name": name}) - logger.warning(response) - assert response.status_code == status.HTTP_200_OK - response = sync_client.get(f"{configs.PREFIX}/v1/user/1") - assert response.status_code == status.HTTP_200_OK - data = response.json()["data"] - logger.warning(data) - assert data["name"] == name - assert data["created_at"] != data["updated_at"] - - -@pytest.mark.run(order=3) -def test_put_user(sync_client: TestClient) -> None: - """ - Put user route-create-2 to route-put - """ - name = "route-put" - time.sleep(1) - response = sync_client.patch(f"{configs.PREFIX}/v1/user/2", json={"name": name}) - logger.warning(response) - assert response.status_code == status.HTTP_200_OK - response = sync_client.get(f"{configs.PREFIX}/v1/user/2") - assert response.status_code == status.HTTP_200_OK - data = response.json()["data"] - logger.warning(data) - assert data["name"] == name - assert data["created_at"] != data["updated_at"] - - -@pytest.mark.run(order=4) -def test_delete_user(sync_client: TestClient) -> None: - """ - Delete user route-patch, route-put - """ - for id in range(1, 3): - response = sync_client.delete(f"{configs.PREFIX}/v1/user/{id}") - logger.warning(response) - assert response.status_code == status.HTTP_200_OK +# import time +# +# import pytest +# from fastapi.testclient import TestClient +# from httpx import put +# from loguru import logger +# from starlette import status +# +# from app.core.configs import configs +# +# +# def test_crud_user(sync_client: TestClient) -> None: +# ids = create_user(sync_client) +# get_user(sync_client, ids) +# patch_user(sync_client, ids) +# put_user(sync_client, ids) +# delete_user(sync_client, ids) +# +# +# def create_user(sync_client: TestClient) -> list[tuple[int]]: +# ids = [] +# for id in range(30): +# name = f"routes-create-{id}" +# response = sync_client.post(f"{configs.PREFIX}/v1/user", json={"name": name}) +# logger.warning(response) +# assert response.status_code == status.HTTP_201_CREATED +# data = response.json()["data"] +# ids.append((data["id"], id)) +# return ids +# +# +# def get_user(sync_client: TestClient, ids: list[tuple[int]]) -> None: +# for pk, id in ids: +# name = f"routes-create-{id}" +# response = sync_client.get(f"{configs.PREFIX}/v1/user/{pk}") +# logger.warning(response) +# assert response.status_code == status.HTTP_200_OK +# data = response.json()["data"] +# logger.warning(data) +# assert data["name"] == name +# +# +# def patch_user(sync_client: TestClient, ids: list[tuple[int]]) -> None: +# for pk, id in ids[:5]: +# name = f"routes-patch-{id}" +# time.sleep(1) +# response = sync_client.patch( +# f"{configs.PREFIX}/v1/user/{pk}", json={"name": name} +# ) +# logger.warning(response) +# assert response.status_code == status.HTTP_200_OK +# response = sync_client.get(f"{configs.PREFIX}/v1/user/{pk}") +# assert response.status_code == status.HTTP_200_OK +# data = response.json()["data"] +# logger.warning(data) +# assert data["name"] == name +# assert data["created_at"] != data["updated_at"] +# +# +# def put_user(sync_client: TestClient, ids: list[tuple[int]]) -> None: +# for pk, id in ids[:5]: +# name = f"routes-put-{id}" +# time.sleep(1) +# response = sync_client.put( +# f"{configs.PREFIX}/v1/user/{pk}", json={"name": name} +# ) +# logger.warning(response) +# assert response.status_code == status.HTTP_200_OK +# response = sync_client.get(f"{configs.PREFIX}/v1/user/{pk}") +# assert response.status_code == status.HTTP_200_OK +# data = response.json()["data"] +# logger.warning(data) +# assert data["name"] == name +# assert data["created_at"] != data["updated_at"] +# +# +# def delete_user(sync_client: TestClient, ids: list[tuple[int]]) -> None: +# for pk, _ in ids: +# response = sync_client.delete(f"{configs.PREFIX}/v1/user/{pk}") +# logger.warning(response) +# assert response.status_code == status.HTTP_200_OK From 2a367bf67c6abd7b20545c5ccdc60d02d7eaaaac Mon Sep 17 00:00:00 2001 From: Zerohertz Date: Fri, 24 Jan 2025 03:18:34 +0900 Subject: [PATCH 16/18] :ship: test: crud services --- app/tests/services/test_users.py | 79 +++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/app/tests/services/test_users.py b/app/tests/services/test_users.py index 1079aac..01a066c 100644 --- a/app/tests/services/test_users.py +++ b/app/tests/services/test_users.py @@ -2,8 +2,10 @@ import pytest from loguru import logger +from sqlalchemy import schema from app.core.container import Container +from app.exceptions.database import EntityAlreadyExists, EntityNotFound from app.schemas.users import UserCreateRequest @@ -12,5 +14,78 @@ async def test_create_user(container: Container, context: Token) -> None: logger.warning(f"{context=}") name = "service-layer-user" user_service = container.user_service() - user = await user_service.create(schema=UserCreateRequest(name=name)) - assert user.name == name + for id in range(10): + _name = f"{name}-{id}" + user = await user_service.create(schema=UserCreateRequest(name=_name)) + assert user.name == _name + with pytest.raises(EntityAlreadyExists): + user = await user_service.create(schema=UserCreateRequest(name=_name)) + + +@pytest.mark.asyncio(loop_scope="function") +async def test_get_user(container: Container, context: Token) -> None: + logger.warning(f"{context=}") + name = "service-layer-user" + user_service = container.user_service() + for id in range(10, 20): + _name = f"{name}-{id}" + user = await user_service.create(schema=UserCreateRequest(name=_name)) + user = await user_service.get_by_id(id=user.id) + assert user.name == _name + with pytest.raises(EntityNotFound): + user = await user_service.get_by_id(id=99999) + + +@pytest.mark.asyncio(loop_scope="function") +async def test_put_user(container: Container, context: Token) -> None: + logger.warning(f"{context=}") + name = "service-layer-user" + user_service = container.user_service() + for id in range(20, 30): + _name = f"{name}-{id}" + user = await user_service.create(schema=UserCreateRequest(name=_name)) + _name = f"{name}-put-{id}" + user = await user_service.put_by_id( + id=user.id, schema=UserCreateRequest(name=_name) + ) + assert user.name == _name + with pytest.raises(EntityNotFound): + user = await user_service.put_by_id( + id=99999, schema=UserCreateRequest(name=name) + ) + + +@pytest.mark.asyncio(loop_scope="function") +async def test_patch_user(container: Container, context: Token) -> None: + logger.warning(f"{context=}") + name = "service-layer-user" + user_service = container.user_service() + for id in range(30, 40): + _name = f"{name}-{id}" + user = await user_service.create(schema=UserCreateRequest(name=_name)) + _name = f"{name}-patch-{id}" + user = await user_service.patch_by_id( + id=user.id, schema=UserCreateRequest(name=_name) + ) + assert user.name == _name + _name = f"{name}-patch-{id}-2" + user = await user_service.patch_attr_by_id(id=user.id, attr="name", value=_name) + assert user.name == _name + with pytest.raises(EntityNotFound): + user = await user_service.patch_by_id( + id=99999, schema=UserCreateRequest(name=name) + ) + with pytest.raises(EntityNotFound): + user = await user_service.patch_attr_by_id(id=99999, attr="name", value=name) + + +@pytest.mark.asyncio(loop_scope="function") +async def test_delete_user(container: Container, context: Token) -> None: + logger.warning(f"{context=}") + name = "service-layer-user" + user_service = container.user_service() + for _ in range(0, 10): + user = await user_service.create(schema=UserCreateRequest(name=name)) + user = await user_service.delete_by_id(id=user.id) + with pytest.raises(EntityNotFound): + user = await user_service.delete_by_id(id=99999) From c549e8225b03d3ad98ded672474202788d95b4f6 Mon Sep 17 00:00:00 2001 From: Zerohertz Date: Fri, 24 Jan 2025 03:37:21 +0900 Subject: [PATCH 17/18] :hammer: fix: timestamp in schemas --- app/core/configs.py | 1 + app/schemas/base.py | 12 +++++++++++- app/schemas/responses.py | 11 +++++++++-- envs/test.env | 1 + k8s/postgresql/configmap.yaml | 2 ++ pyproject.toml | 1 + uv.lock | 11 +++++++++++ 7 files changed, 36 insertions(+), 3 deletions(-) diff --git a/app/core/configs.py b/app/core/configs.py index b09147f..f202fc1 100644 --- a/app/core/configs.py +++ b/app/core/configs.py @@ -20,6 +20,7 @@ class Configs(BaseSettings): DESCRIPTION: str VERSION: str PREFIX: str + TZ: Optional[str] = "Asia/Seoul" # --------- DATABASE SETTINGS --------- # DB_TYPE: str diff --git a/app/schemas/base.py b/app/schemas/base.py index d5f0ddd..37b6234 100644 --- a/app/schemas/base.py +++ b/app/schemas/base.py @@ -1,7 +1,11 @@ import abc from datetime import datetime -from pydantic import BaseModel, ConfigDict +import pytz +from pydantic import BaseModel, ConfigDict, model_validator +from typing_extensions import Self + +from app.core.configs import configs class BaseSchemaRequest(BaseModel, abc.ABC): @@ -13,3 +17,9 @@ class BaseSchemaResponse(BaseModel, abc.ABC): id: int created_at: datetime updated_at: datetime + + @model_validator(mode="after") + def set_timezone(self) -> Self: + self.created_at = self.created_at.astimezone(pytz.timezone(configs.TZ)) + self.updated_at = self.updated_at.astimezone(pytz.timezone(configs.TZ)) + return self diff --git a/app/schemas/responses.py b/app/schemas/responses.py index 2b28f4d..aa90864 100644 --- a/app/schemas/responses.py +++ b/app/schemas/responses.py @@ -1,8 +1,10 @@ from datetime import datetime from typing import Generic, Optional, TypeVar +import pytz from pydantic import BaseModel +from app.core.configs import configs from app.schemas.base import BaseSchemaResponse T = TypeVar("T", bound=BaseSchemaResponse) @@ -39,12 +41,17 @@ def success(cls, *, status: int, data: T) -> "APIResponse[T]": status=status, message="The request has been successfully processed.", data=data, - timestamp=datetime.now(), + timestamp=datetime.now().astimezone(pytz.timezone(configs.TZ)), ) @classmethod def error(cls, *, status: int, message: str) -> "APIResponse[T]": - return cls(status=status, message=message, data=None, timestamp=datetime.now()) + return cls( + status=status, + message=message, + data=None, + timestamp=datetime.now().astimezone(pytz.timezone(configs.TZ)), + ) if __name__ == "__main__": diff --git a/envs/test.env b/envs/test.env index 714d54a..52af4f6 100644 --- a/envs/test.env +++ b/envs/test.env @@ -5,6 +5,7 @@ PORT="8000" PROJECT_NAME="Zerohertz's FastAPI Cookbook (test)" VERSION="v0.1.1" PREFIX="/api" +TZ="Asia/Seoul" DB_TYPE="sqlite" DB_DRIVER="aiosqlite" diff --git a/k8s/postgresql/configmap.yaml b/k8s/postgresql/configmap.yaml index 4c8a530..bda30cb 100644 --- a/k8s/postgresql/configmap.yaml +++ b/k8s/postgresql/configmap.yaml @@ -9,6 +9,7 @@ data: PROJECT_NAME="Zerohertz's FastAPI Cookbook (dev)" VERSION="v0.1.1" PREFIX="/api" + TZ="Asia/Seoul" DB_ECHO=true DB_TABLE_CREATE=true prod.env: | @@ -17,6 +18,7 @@ data: PROJECT_NAME="Zerohertz's FastAPI Cookbook (prod)" VERSION="v0.1.1" PREFIX="" + TZ="Asia/Seoul" DB_ECHO=false DB_TABLE_CREATE=false --- diff --git a/pyproject.toml b/pyproject.toml index 703b40f..f6a39e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "httpx>=0.28.1", "loguru>=0.7.3", "pydantic-settings>=2.7.1", + "pytz>=2024.2", "sqlalchemy>=2.0.37", "uv>=0.5.21", "uvicorn>=0.34.0", diff --git a/uv.lock b/uv.lock index 7eefcef..8abd688 100644 --- a/uv.lock +++ b/uv.lock @@ -312,6 +312,7 @@ dependencies = [ { name = "httpx" }, { name = "loguru" }, { name = "pydantic-settings" }, + { name = "pytz" }, { name = "sqlalchemy" }, { name = "uv" }, { name = "uvicorn" }, @@ -345,6 +346,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "pydantic-settings", specifier = ">=2.7.1" }, + { name = "pytz", specifier = ">=2024.2" }, { name = "sqlalchemy", specifier = ">=2.0.37" }, { name = "uv", specifier = ">=0.5.21" }, { name = "uvicorn", specifier = ">=0.34.0" }, @@ -772,6 +774,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, ] +[[package]] +name = "pytz" +version = "2024.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/31/3c70bf7603cc2dca0f19bdc53b4537a797747a58875b552c8c413d963a3f/pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a", size = 319692 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/c3/005fcca25ce078d2cc29fd559379817424e94885510568bc1bc53d7d5846/pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725", size = 508002 }, +] + [[package]] name = "ruff" version = "0.9.2" From 88acb563e1edfaf5fa5d661dfa70d478b870b4a0 Mon Sep 17 00:00:00 2001 From: Zerohertz Date: Fri, 24 Jan 2025 03:46:35 +0900 Subject: [PATCH 18/18] :art: style: lint --- app/core/configs.py | 2 +- app/core/database.py | 2 - app/exceptions/handlers.py | 4 +- app/repositories/base.py | 12 +-- app/tests/api/v1/test_users.py | 161 +++++++++++++++---------------- app/tests/services/test_users.py | 1 - pyproject.toml | 1 + uv.lock | 11 +++ 8 files changed, 102 insertions(+), 92 deletions(-) diff --git a/app/core/configs.py b/app/core/configs.py index f202fc1..30234ac 100644 --- a/app/core/configs.py +++ b/app/core/configs.py @@ -20,7 +20,7 @@ class Configs(BaseSettings): DESCRIPTION: str VERSION: str PREFIX: str - TZ: Optional[str] = "Asia/Seoul" + TZ: str = "Asia/Seoul" # --------- DATABASE SETTINGS --------- # DB_TYPE: str diff --git a/app/core/database.py b/app/core/database.py index ba3b600..746091d 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -4,7 +4,6 @@ from loguru import logger from sqlalchemy import NullPool -from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import ( AsyncSession, async_scoped_session, @@ -13,7 +12,6 @@ ) from app.core.configs import ENVIRONMENT, configs -from app.exceptions.database import EntityAlreadyExists from app.models.base import BaseModel diff --git a/app/exceptions/handlers.py b/app/exceptions/handlers.py index e09467b..c0eeacb 100644 --- a/app/exceptions/handlers.py +++ b/app/exceptions/handlers.py @@ -19,7 +19,9 @@ async def global_exception_handler(request: Request, exc: Exception) -> JSONResp ) -async def core_exception_handler(request: Request, exc: CoreException) -> JSONResponse: +async def core_exception_handler( + request: Request, exc: CoreException # pylint: disable=unused-argument +) -> JSONResponse: logger.error(exc) return JSONResponse( content=APIResponse.error(status=exc.status, message=repr(exc)).model_dump( diff --git a/app/repositories/base.py b/app/repositories/base.py index 95d6897..0f94268 100644 --- a/app/repositories/base.py +++ b/app/repositories/base.py @@ -23,8 +23,8 @@ async def create(self, model: T) -> T: session.add(model) try: await session.flush() - except IntegrityError: - raise EntityAlreadyExists + except IntegrityError as error: + raise EntityAlreadyExists from error await session.refresh(model) return model @@ -52,8 +52,8 @@ async def update_by_id(self, id: int, model: dict) -> T: setattr(result, key, value) try: await session.flush() - except IntegrityError: - raise EntityAlreadyExists + except IntegrityError as error: + raise EntityAlreadyExists from error await session.refresh(result) return result @@ -67,8 +67,8 @@ async def update_attr_by_id(self, id: int, column: str, value: Any) -> T: setattr(result, column, value) try: await session.flush() - except IntegrityError: - raise EntityAlreadyExists + except IntegrityError as error: + raise EntityAlreadyExists from error await session.refresh(result) return result diff --git a/app/tests/api/v1/test_users.py b/app/tests/api/v1/test_users.py index e594dd2..75be143 100644 --- a/app/tests/api/v1/test_users.py +++ b/app/tests/api/v1/test_users.py @@ -1,81 +1,80 @@ -# import time -# -# import pytest -# from fastapi.testclient import TestClient -# from httpx import put -# from loguru import logger -# from starlette import status -# -# from app.core.configs import configs -# -# -# def test_crud_user(sync_client: TestClient) -> None: -# ids = create_user(sync_client) -# get_user(sync_client, ids) -# patch_user(sync_client, ids) -# put_user(sync_client, ids) -# delete_user(sync_client, ids) -# -# -# def create_user(sync_client: TestClient) -> list[tuple[int]]: -# ids = [] -# for id in range(30): -# name = f"routes-create-{id}" -# response = sync_client.post(f"{configs.PREFIX}/v1/user", json={"name": name}) -# logger.warning(response) -# assert response.status_code == status.HTTP_201_CREATED -# data = response.json()["data"] -# ids.append((data["id"], id)) -# return ids -# -# -# def get_user(sync_client: TestClient, ids: list[tuple[int]]) -> None: -# for pk, id in ids: -# name = f"routes-create-{id}" -# response = sync_client.get(f"{configs.PREFIX}/v1/user/{pk}") -# logger.warning(response) -# assert response.status_code == status.HTTP_200_OK -# data = response.json()["data"] -# logger.warning(data) -# assert data["name"] == name -# -# -# def patch_user(sync_client: TestClient, ids: list[tuple[int]]) -> None: -# for pk, id in ids[:5]: -# name = f"routes-patch-{id}" -# time.sleep(1) -# response = sync_client.patch( -# f"{configs.PREFIX}/v1/user/{pk}", json={"name": name} -# ) -# logger.warning(response) -# assert response.status_code == status.HTTP_200_OK -# response = sync_client.get(f"{configs.PREFIX}/v1/user/{pk}") -# assert response.status_code == status.HTTP_200_OK -# data = response.json()["data"] -# logger.warning(data) -# assert data["name"] == name -# assert data["created_at"] != data["updated_at"] -# -# -# def put_user(sync_client: TestClient, ids: list[tuple[int]]) -> None: -# for pk, id in ids[:5]: -# name = f"routes-put-{id}" -# time.sleep(1) -# response = sync_client.put( -# f"{configs.PREFIX}/v1/user/{pk}", json={"name": name} -# ) -# logger.warning(response) -# assert response.status_code == status.HTTP_200_OK -# response = sync_client.get(f"{configs.PREFIX}/v1/user/{pk}") -# assert response.status_code == status.HTTP_200_OK -# data = response.json()["data"] -# logger.warning(data) -# assert data["name"] == name -# assert data["created_at"] != data["updated_at"] -# -# -# def delete_user(sync_client: TestClient, ids: list[tuple[int]]) -> None: -# for pk, _ in ids: -# response = sync_client.delete(f"{configs.PREFIX}/v1/user/{pk}") -# logger.warning(response) -# assert response.status_code == status.HTTP_200_OK +import time +from typing import Any + +from fastapi.testclient import TestClient +from loguru import logger +from starlette import status + +from app.core.configs import configs + + +def test_crud_user(sync_client: TestClient) -> None: + ids = create_user(sync_client) + get_user(sync_client, ids) + patch_user(sync_client, ids) + put_user(sync_client, ids) + delete_user(sync_client, ids) + + +def create_user(sync_client: TestClient) -> list[tuple[Any, int]]: + ids = [] + for id in range(30): + name = f"routes-create-{id}" + response = sync_client.post(f"{configs.PREFIX}/v1/user", json={"name": name}) + logger.warning(response) + assert response.status_code == status.HTTP_201_CREATED + data = response.json()["data"] + ids.append((data["id"], id)) + return ids + + +def get_user(sync_client: TestClient, ids: list[tuple[Any, int]]) -> None: + for pk, id in ids: + name = f"routes-create-{id}" + response = sync_client.get(f"{configs.PREFIX}/v1/user/{pk}") + logger.warning(response) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + logger.warning(data) + assert data["name"] == name + + +def patch_user(sync_client: TestClient, ids: list[tuple[Any, int]]) -> None: + for pk, id in ids[:5]: + name = f"routes-patch-{id}" + time.sleep(1) + response = sync_client.patch( + f"{configs.PREFIX}/v1/user/{pk}", json={"name": name} + ) + logger.warning(response) + assert response.status_code == status.HTTP_200_OK + response = sync_client.get(f"{configs.PREFIX}/v1/user/{pk}") + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + logger.warning(data) + assert data["name"] == name + assert data["created_at"] != data["updated_at"] + + +def put_user(sync_client: TestClient, ids: list[tuple[Any, int]]) -> None: + for pk, id in ids[:5]: + name = f"routes-put-{id}" + time.sleep(1) + response = sync_client.put( + f"{configs.PREFIX}/v1/user/{pk}", json={"name": name} + ) + logger.warning(response) + assert response.status_code == status.HTTP_200_OK + response = sync_client.get(f"{configs.PREFIX}/v1/user/{pk}") + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + logger.warning(data) + assert data["name"] == name + assert data["created_at"] != data["updated_at"] + + +def delete_user(sync_client: TestClient, ids: list[tuple[Any, int]]) -> None: + for pk, _ in ids: + response = sync_client.delete(f"{configs.PREFIX}/v1/user/{pk}") + logger.warning(response) + assert response.status_code == status.HTTP_200_OK diff --git a/app/tests/services/test_users.py b/app/tests/services/test_users.py index 01a066c..b7535d5 100644 --- a/app/tests/services/test_users.py +++ b/app/tests/services/test_users.py @@ -2,7 +2,6 @@ import pytest from loguru import logger -from sqlalchemy import schema from app.core.container import Container from app.exceptions.database import EntityAlreadyExists, EntityNotFound diff --git a/pyproject.toml b/pyproject.toml index f6a39e1..f919c6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ lint = [ "pylint>=3.3.3", "ruff>=0.9.2", "sqlalchemy[mypy]>=2.0.37", + "types-pytz>=2024.2.0.20241221", ] test = [ "pytest>=8.3.4", diff --git a/uv.lock b/uv.lock index 8abd688..2eeb02b 100644 --- a/uv.lock +++ b/uv.lock @@ -326,6 +326,7 @@ lint = [ { name = "pylint" }, { name = "ruff" }, { name = "sqlalchemy", extra = ["mypy"] }, + { name = "types-pytz" }, ] test = [ { name = "aiomysql" }, @@ -360,6 +361,7 @@ lint = [ { name = "pylint", specifier = ">=3.3.3" }, { name = "ruff", specifier = ">=0.9.2" }, { name = "sqlalchemy", extras = ["mypy"], specifier = ">=2.0.37" }, + { name = "types-pytz", specifier = ">=2024.2.0.20241221" }, ] test = [ { name = "aiomysql", specifier = ">=0.2.0" }, @@ -872,6 +874,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/b6/a447b5e4ec71e13871be01ba81f5dfc9d0af7e473da256ff46bc0e24026f/tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde", size = 37955 }, ] +[[package]] +name = "types-pytz" +version = "2024.2.0.20241221" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/26/516311b02b5a215e721155fb65db8a965d061372e388d6125ebce8d674b0/types_pytz-2024.2.0.20241221.tar.gz", hash = "sha256:06d7cde9613e9f7504766a0554a270c369434b50e00975b3a4a0f6eed0f2c1a9", size = 10213 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/db/c92ca6920cccd9c2998b013601542e2ac5e59bc805bcff94c94ad254b7df/types_pytz-2024.2.0.20241221-py3-none-any.whl", hash = "sha256:8fc03195329c43637ed4f593663df721fef919b60a969066e22606edf0b53ad5", size = 10008 }, +] + [[package]] name = "typing-extensions" version = "4.12.2"