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]]