Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
@@ -1 +1,10 @@
.venv
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.log
.venv/
venv/
.git/
.gitignore
51 changes: 26 additions & 25 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,38 +1,39 @@
# Environment

ENV=<dev/prod>
LOG_LEVEL=<info/debug>
PYTHONPATH=<python_path>
PYTHONFAULTHANDLER=<pythonfaulthandler>
PYTHONUNBUFFERED=<pythonunbuffered>
PYTHONHASHSEED=<pythonhashseed>
PIP_NO_CACHE_DIR=<pip_no_cache_dir>
PIP_DISABLE_PIP_VERSION_CHECK=<pip_disable_pip_version_check>
PIP_DEFAULT_TIMEOUT=<pip_default_timeout>
SECRET_KEY=
ENV=
LOG_LEVEL=
PYTHONPATH=
PYTHONFAULTHANDLER=
PYTHONUNBUFFERED=
PYTHONHASHSEED=
PIP_NO_CACHE_DIR=
PIP_DISABLE_PIP_VERSION_CHECK=
PIP_DEFAULT_TIMEOUT=

# Docker Compose

COMPOSE_PROJECT_NAME=<project_name>
COMPOSE_PROJECT_NAME=

# PostgreSQL

DB_URL=<db_sync_url>
POSTGRES_DB=<db_name>
POSTGRES_USER=<db_user>
POSTGRES_PASSWORD=<db_password>
POSTGRES_HOST=<db_host>
POSTGRES_PORT=<db_port>
ECHO_SQL=<true/false>
DB_URL=
POSTGRES_DB=
POSTGRES_USER=
POSTGRES_PASSWORD=
POSTGRES_HOST=
POSTGRES_PORT=
ECHO_SQL=

# Redis
REDIS_HOST=<redis_host>
REDIS_PORT=<redis_port>
REDIS_PASSWORD=<redis_password>
REDIS_USER=<redis_user>
REDIS_USER_PASSWORD=<redis_user_password>
REDIS_HOST=
REDIS_PORT=
REDIS_PASSWORD=
REDIS_USER=
REDIS_USER_PASSWORD=

# JWT

JWT_SECRET_KEY=<jwt_secret_key>
JWT_EXPIRE_TIME_SECONDS=<jwt_expire_time_seconds>
JWT_ALGORITHM=<jwt_algorithm>
JWT_SECRET_KEY=
JWT_EXPIRE_TIME_SECONDS=
JWT_ALGORITHM=
2 changes: 1 addition & 1 deletion .mypy.ini
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[mypy]
python_version = 3.12
python_version = 3.13
namespace_packages=True
explicit_package_bases=True
ignore_missing_imports = True
Expand Down
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ repos:
- id: mixed-line-ending

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.7.4
rev: v0.11.4
hooks:
- id: ruff-format
- id: ruff
Expand Down Expand Up @@ -48,7 +48,7 @@ repos:
stages: [commit-msg]

- repo: https://github.com/crate-ci/typos
rev: v1.27.3
rev: v1.31.1
hooks:
- id: typos

Expand Down
2 changes: 1 addition & 1 deletion .ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ extend-exclude = [

line-length = 120

target-version = "py312"
target-version = "py313"

[lint]
preview = true
Expand Down
2 changes: 1 addition & 1 deletion .typos.toml
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
[files]
extend-exclude = [".gitignore", "dev.requirements.txt", "prod.requirements.txt"]
extend-exclude = [".gitignore", "uv.lock", "pyproject.toml"]
2 changes: 1 addition & 1 deletion app/alembic.ini
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[alembic]
script_location = /app/database/migrations
script_location = /opt/pysetup/app/database/migrations
file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
prepend_sys_path = .
version_path_separator = os
Expand Down
8 changes: 4 additions & 4 deletions app/database/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,17 @@ def __init__(self, db_url: str, engine_kwargs: dict[str, Any] | None = None) ->
self._engine: AsyncEngine | None = create_async_engine(
db_url, pool_size=30, pool_pre_ping=True, pool_recycle=1800, max_overflow=15, **(engine_kwargs or {})
)
self._sessionmaker: async_sessionmaker[AsyncSession] | None = async_sessionmaker(
self.session_maker: async_sessionmaker[AsyncSession] | None = async_sessionmaker(
autocommit=False,
expire_on_commit=False,
bind=self._engine,
)

async def get_session(self) -> AsyncIterator[AsyncSession]:
if self._sessionmaker is None:
if self.session_maker is None:
raise DatabaseInitializationError

session = self._sessionmaker()
session = self.session_maker()
try:
yield session
except Exception:
Expand All @@ -46,7 +46,7 @@ async def close_connection(self) -> None:
await self._engine.dispose()

self._engine = None
self._sessionmaker = None
self.session_maker = None


session_manager: DatabaseSessionManager = DatabaseSessionManager(settings.db_url, {"echo": settings.echo_sql})
4 changes: 4 additions & 0 deletions app/database/migrations/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ def include_name_filter(
) -> bool:
if type_ == "schema":
return name == target_metadata.schema

if type_ == "table":
return name in target_metadata.tables

return True


Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""add-user
"""add-user-table

Revision ID: 2351bdf7cc26
Revision ID: 6786a8aedf8f
Revises:
Create Date: 2024-12-06 10:36:23.745562
Create Date: 2025-04-06 16:14:29.743402

"""

Expand All @@ -12,7 +12,7 @@
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "2351bdf7cc26"
revision: str = "6786a8aedf8f"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
Expand All @@ -24,6 +24,7 @@ def upgrade() -> None:
"user",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.Column("email", sa.String(length=100), nullable=False),
sa.Column("password", sa.String(length=100), nullable=False),
sa.PrimaryKeyConstraint("id"),
Expand Down
1 change: 1 addition & 0 deletions app/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ class User(Base):

id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
email: Mapped[str] = mapped_column(String(100), unique=True, index=True)
password: Mapped[str] = mapped_column(String(100), nullable=False)
17 changes: 17 additions & 0 deletions app/exceptions/handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from fastapi import Request, status
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse


def validation_error_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: # noqa: ARG001
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content=jsonable_encoder({"detail": "Validation Error", "errors": exc.errors(), "body": exc.body}),
)


def internal_error_exception_handler(request: Request, exc: Exception) -> JSONResponse: # noqa: ARG001
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content=jsonable_encoder({"detail": "Internal Server Error"})
)
40 changes: 39 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@
from typing import Any

from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.utils import get_openapi
from fastapi_pagination import add_pagination
from starlette.middleware.sessions import SessionMiddleware

from app.database.engine import session_manager
from app.exceptions.handlers import internal_error_exception_handler, validation_error_exception_handler
from app.routes.misc import router as router_misc
from app.routes.user import router as router_user
from app.settings import settings
Expand All @@ -29,7 +33,40 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: # noqa: ARG001
await session_manager.close_connection()


app = FastAPI(lifespan=lifespan, docs_url=None, redoc_url=None)
app = FastAPI(
lifespan=lifespan,
docs_url=None,
redoc_url=None,
responses={
422: {
"description": "Unprocessable Entity Error",
"content": {
"application/json": {
"example": {
"detail": "Validation Error",
"errors": [
{
"type": "string",
"loc": ["string"],
"msg": "string",
}
],
"body": {},
}
}
},
},
500: {
"description": "Internal Server Error",
"content": {"application/json": {"example": {"detail": "Internal Server Error"}}},
},
},
)

add_pagination(app)

app.add_exception_handler(RequestValidationError, validation_error_exception_handler) # type: ignore[arg-type]
app.add_exception_handler(Exception, internal_error_exception_handler)

for router in ROUTERS:
app.include_router(router)
Expand Down Expand Up @@ -61,3 +98,4 @@ def _openapi_schema() -> dict[str, Any]:
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(SessionMiddleware, secret_key=settings.secret_key)
27 changes: 14 additions & 13 deletions app/manager/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@
from app.exceptions.http import HTTPBadRequestException, HTTPNotFoundException
from app.repository.base import BaseRepository, DBModelType, SchemaCreateType, SchemaUpdateType
from app.utils.constants import DEFAULT_DESC, DEFAULT_ORDER_BY
from app.utils.misc import camel_to_snake

RepositoryType = TypeVar("RepositoryType", bound=BaseRepository) # type: ignore[type-arg]


class BaseManager(Generic[DBModelType, RepositoryType, SchemaCreateType, SchemaUpdateType]):
class BaseManager(Generic[DBModelType, RepositoryType, SchemaCreateType, SchemaUpdateType]): # noqa: UP046
def __init__(self, db_model: type[DBModelType], repository: type[RepositoryType]) -> None:
self.db_model = db_model
self.repository = repository(self.db_model)
Expand All @@ -20,23 +21,24 @@ async def fetch_one(
self,
*,
filters: list[Any] | None = None,
options: list[Any] | None = None,
order_by: str = DEFAULT_ORDER_BY,
desc: bool = DEFAULT_DESC,
session: AsyncSession,
**kwargs: Any,
) -> DBModelType:
try:
order_by = getattr(self.db_model, order_by)
except AttributeError:
raise HTTPBadRequestException from None
order_by = getattr(self.db_model, camel_to_snake(order_by))
except AttributeError as exc:
raise HTTPBadRequestException(detail="Invalid value for `orderBy`") from exc

if kwargs:
for key in kwargs:
if not hasattr(self.db_model, key):
raise HTTPBadRequestException
raise HTTPBadRequestException(detail="Invalid field provided")

db_obj = await self.repository.fetch_one(
filters=filters, order_by=order_by, desc=desc, session=session, **kwargs
filters=filters, options=options, order_by=order_by, desc=desc, session=session, **kwargs
)

if not db_obj:
Expand All @@ -48,25 +50,24 @@ async def fetch(
self,
*,
filters: list[Any] | None = None,
options: list[Any] | None = None,
order_by: str = DEFAULT_ORDER_BY,
desc: bool = DEFAULT_DESC,
offset: int | None = None,
limit: int | None = None,
session: AsyncSession,
**kwargs: Any,
) -> Sequence[DBModelType] | None:
try:
order_by = getattr(self.db_model, order_by)
except AttributeError:
raise HTTPBadRequestException from None
order_by = getattr(self.db_model, camel_to_snake(order_by))
except AttributeError as exc:
raise HTTPBadRequestException(detail="Invalid value for `orderBy`") from exc

if kwargs:
for key in kwargs:
if not hasattr(self.db_model, key):
raise HTTPBadRequestException
raise HTTPBadRequestException(detail="Invalid field provided")

return await self.repository.fetch(
filters=filters, order_by=order_by, desc=desc, offset=offset, limit=limit, session=session, **kwargs
filters=filters, options=options, order_by=order_by, desc=desc, session=session, **kwargs
)

async def create(self, create_obj: SchemaCreateType, session: AsyncSession) -> DBModelType:
Expand Down
Loading