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/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) diff --git a/app/core/configs.py b/app/core/configs.py index 8b7234d..30234ac 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,12 +6,21 @@ 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 VERSION: str PREFIX: str + TZ: str = "Asia/Seoul" # --------- DATABASE SETTINGS --------- # DB_TYPE: str 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..746091d 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -1,30 +1,87 @@ -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 NullPool +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_scoped_session, + async_sessionmaker, + create_async_engine, +) -from app.core.configs import configs +from app.core.configs import ENVIRONMENT, 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.engine = create_async_engine(configs.DATABASE_URI, echo=configs.DB_ECHO) + self.context = Context() + 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, 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: + if configs.ENV == ENVIRONMENT.TEST: + await conn.run_sync(BaseModel.metadata.drop_all) 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..b1091cf 100644 --- a/app/core/lifespan.py +++ b/app/core/lifespan.py @@ -6,8 +6,9 @@ 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 @@ -16,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}", @@ -27,10 +31,9 @@ async def lifespan(app: FastAPI): # pylint: disable=unused-argument # logging.getLogger("uvicorn.access").addHandler(LoguruHandler()) # logging.getLogger("uvicorn.error").addHandler(LoguruHandler()) - container = Container() + logger.info(f"{configs.ENV=}") if configs.DB_TABLE_CREATE: - logger.warning("Create database") - database = container.database() await database.create_all() + app.container = Container() # type: ignore[attr-defined] 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/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/database.py b/app/exceptions/database.py index e70f94f..fa7de9e 100644 --- a/app/exceptions/database.py +++ b/app/exceptions/database.py @@ -3,6 +3,16 @@ from app.exceptions.base import CoreException -class EntityNotFound(CoreException): +class DatabaseException(CoreException): + status: int + 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/exceptions/handlers.py b/app/exceptions/handlers.py index 301c2d9..c0eeacb 100644 --- a/app/exceptions/handlers.py +++ b/app/exceptions/handlers.py @@ -19,14 +19,13 @@ async def global_exception_handler(request: Request, exc: Exception) -> JSONResp ) -async def business_exception_handler( - request: Request, exc: CoreException +async def core_exception_handler( + request: Request, exc: CoreException # pylint: disable=unused-argument ) -> 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, ) diff --git a/app/main.py b/app/main.py index 44af309..89d850c 100644 --- a/app/main.py +++ b/app/main.py @@ -3,9 +3,9 @@ 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 +from app.exceptions.handlers import core_exception_handler, global_exception_handler app = FastAPI( title=configs.PROJECT_NAME, @@ -17,12 +17,12 @@ redoc_url=f"{configs.PREFIX}/redoc", exception_handlers={ Exception: global_exception_handler, - CoreException: business_exception_handler, + CoreException: core_exception_handler, }, lifespan=lifespan, ) 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..0f94268 100644 --- a/app/repositories/base.py +++ b/app/repositories/base.py @@ -1,10 +1,11 @@ -from typing import Any, AsyncContextManager, Callable, Generic, Type, TypeVar +from typing import Any, Generic, Type, TypeVar -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.exc import IntegrityError from sqlalchemy.future import select from sqlalchemy.orm import joinedload -from app.exceptions.database import EntityNotFound +from app.core.database import database +from app.exceptions.database import EntityAlreadyExists, EntityNotFound from app.models.base import BaseModel T = TypeVar("T", bound=BaseModel) @@ -13,63 +14,70 @@ 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) + try: + await session.flush() + except IntegrityError as error: + raise EntityAlreadyExists from error + await session.refresh(model) 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) + try: + await session.flush() + except IntegrityError as error: + raise EntityAlreadyExists from error + await session.refresh(result) 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) + try: + await session.flush() + except IntegrityError as error: + raise EntityAlreadyExists from error + await session.refresh(result) 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/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/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_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 dbeae25..75be143 100644 --- a/app/tests/api/v1/test_users.py +++ b/app/tests/api/v1/test_users.py @@ -1,6 +1,6 @@ import time +from typing import Any -import pytest from fastapi.testclient import TestClient from loguru import logger from starlette import status @@ -8,19 +8,30 @@ from app.core.configs import configs -def test_create_user(client: TestClient) -> None: - for id in range(1, 3): - name = f"create-{id}" - response = client.post(f"{configs.PREFIX}/v1/user", json={"name": name}) +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 -@pytest.mark.run(after="test_create_user") -def test_get_user(client: TestClient) -> None: - for id in range(1, 3): - name = f"create-{id}" - response = client.get(f"{configs.PREFIX}/v1/user/{id}") +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"] @@ -28,31 +39,42 @@ 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: - name = "patch" - time.sleep(1) - response = 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") - 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(after="test_get_user") -def test_put_user(client: TestClient) -> None: - name = "put" - time.sleep(1) - response = 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") - 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 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/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..b7535d5 --- /dev/null +++ b/app/tests/services/test_users.py @@ -0,0 +1,90 @@ +from contextvars import Token + +import pytest +from loguru import logger + +from app.core.container import Container +from app.exceptions.database import EntityAlreadyExists, EntityNotFound +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() + 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) diff --git a/envs/test.env b/envs/test.env index cbb59d2..52af4f6 100644 --- a/envs/test.env +++ b/envs/test.env @@ -1,8 +1,11 @@ +ENV="TEST" + PORT="8000" PROJECT_NAME="Zerohertz's FastAPI Cookbook (test)" -VERSION="v0.1.0" +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 b17576c..bda30cb 100644 --- a/k8s/postgresql/configmap.yaml +++ b/k8s/postgresql/configmap.yaml @@ -4,17 +4,21 @@ metadata: name: fastapi-env data: dev.env: | + ENV="DEV" PORT="8000" PROJECT_NAME="Zerohertz's FastAPI Cookbook (dev)" - VERSION="v0.0.2" + VERSION="v0.1.1" PREFIX="/api" + TZ="Asia/Seoul" DB_ECHO=true DB_TABLE_CREATE=true prod.env: | + ENV="PROD" PORT="8000" PROJECT_NAME="Zerohertz's FastAPI Cookbook (prod)" - VERSION="v0.0.2" + VERSION="v0.1.1" PREFIX="" + TZ="Asia/Seoul" DB_ECHO=false DB_TABLE_CREATE=false --- diff --git a/pyproject.toml b/pyproject.toml index e9809c5..f919c6b 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", @@ -25,17 +26,16 @@ 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", "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", - # NOTE: Sync Drivers - # "mysqlclient>=2.2.7", + "pytest-asyncio>=0.25.2", ] [tool.mypy] diff --git a/uv.lock b/uv.lock index ca9e56a..2eeb02b 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" }, @@ -325,15 +326,16 @@ lint = [ { name = "pylint" }, { name = "ruff" }, { name = "sqlalchemy", extra = ["mypy"] }, + { name = "types-pytz" }, ] test = [ { name = "aiomysql" }, { name = "aiosqlite" }, { name = "cryptography" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-dotenv" }, - { name = "pytest-ordering" }, ] [package.metadata] @@ -345,6 +347,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" }, @@ -358,15 +361,16 @@ 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" }, { 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]] @@ -725,6 +729,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" @@ -752,24 +768,21 @@ wheels = [ ] [[package]] -name = "pytest-ordering" -version = "0.6" +name = "python-dotenv" +version = "1.0.1" 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 } +sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/98/adc368fe369465f291ab24e18b9900473786ed1afdf861ba90467eb0767e/pytest_ordering-0.6-py3-none-any.whl", hash = "sha256:3f314a178dbeb6777509548727dc69edf22d6d9a2867bf2d310ab85c403380b6", size = 4643 }, + { 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 = "python-dotenv" -version = "1.0.1" +name = "pytz" +version = "2024.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } +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/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, + { url = "https://files.pythonhosted.org/packages/11/c3/005fcca25ce078d2cc29fd559379817424e94885510568bc1bc53d7d5846/pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725", size = 508002 }, ] [[package]] @@ -861,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" @@ -872,26 +894,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]]