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
65 changes: 54 additions & 11 deletions .github/workflows/workflow.yml
Original file line number Diff line number Diff line change
@@ -1,30 +1,73 @@
name: workflow
name: CI/CD

on:
push:
branches:
- main
- "**"
paths:
- '**.py'
- '**.ini'
- '**.toml'
- '**.lock'
- "**.py"
- "**.ini"
- "**.toml"
- "**.lock"
pull_request:
branches:
- main
paths:
- '**.py'
- '**.ini'
- '**.toml'
- '**.lock'
- "**.py"
- "**.ini"
- "**.toml"
- "**.lock"

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

jobs:
ruff:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- uses: astral-sh/setup-uv@v5

- name: Install lint dependencies
run: uv sync --group lint

- name: Run Ruff
run: uv run ruff check .

ty:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- uses: astral-sh/setup-uv@v5

- name: Install lint and test dependencies
run: uv sync --group lint --group test

- name: Run Ty
run: uv run ty check

test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- uses: astral-sh/setup-uv@v5

- name: Install dependencies
run: uv sync --group test

- name: Run tests with coverage
run: uv run pytest --cov=app --cov-report=term-missing --cov-fail-under=80

build:
runs-on: ubuntu-latest
needs: [ruff, ty, test]
permissions:
contents: read

Expand All @@ -43,7 +86,7 @@ jobs:
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix={{branch}}-
type=sha,prefix=sha-
type=raw,value=latest,enable={{is_default_branch}}

- name: Build Docker image
Expand Down
11 changes: 6 additions & 5 deletions .zed/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,19 @@
"file_scan_exclusions": [
"**/.git",
"**/.venv",
"**/.coverage",
"**/__pycache__",
"**/.pytest_cache",
"**/.ruff_cache",
"**/.ruff_cache"
],
"languages": {
"Python": {
"language_servers": ["ruff", "ty", "!basedpyright"],
"format_on_save": "on",
"code_actions_on_format": {
"source.organizeImports.ruff": true,
"source.fixAll.ruff": true,
},
},
},
"source.fixAll.ruff": true
}
}
}
}
37 changes: 29 additions & 8 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
.PHONY: help lint run format install docker-up docker-down
.PHONY: help lint run format install install-prod upgrade docker-up docker-down test test-unit test-cov

help:
@echo "Available targets:"
@echo " make install - Install dependencies (uv sync)"
@echo " make lint - Run ruff and ty checks"
@echo " make format - Auto-fix linting issues with ruff"
@echo " make run - Start FastAPI dev server with hot reload"
@echo " make docker-up - Start PostgreSQL + FastAPI containers"
@echo " make docker-down - Stop containers"
@echo " make install - Install all dependencies for development"
@echo " make install-prod - Install all dependencies for production"
@echo " make upgrade - Upgrade all dependencies"
@echo " make lint - Run ruff and ty checks"
@echo " make format - Auto-fix linting issues with ruff"
@echo " make run - Start FastAPI dev server with hot reload"
@echo " make docker-up - Start full project locally"
@echo " make docker-down - Stop full project locally"
@echo " make docker-down-destroy - Stop full project locally and destroy volumes"
@echo " make test - Run all tests"
@echo " make test-unit - Run unit tests only"
@echo " make test-cov - Run tests with coverage report"

install:
uv sync --group lint
uv sync --group lint --group test

install-prod:
uv sync

upgrade:
uv sync --group lint --group test -U

lint:
uv run ruff check .
Expand All @@ -32,3 +44,12 @@ docker-up:

docker-down:
docker compose down -v

test:
uv run pytest

test-unit:
uv run pytest tests/unit -m unit

test-cov:
uv run pytest --cov=app --cov-report=term-missing
Original file line number Diff line number Diff line change
@@ -1,36 +1,42 @@
"""Create Entities table

Revision ID: 97f7686bdccd
Revises:
Revises:
Create Date: 2026-01-24 20:12:27.427802+00:00

"""
from typing import Sequence, Union

from alembic import op
from collections.abc import Sequence

import sqlalchemy as sa

from alembic import op

revision: str = '97f7686bdccd'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
revision: str = "97f7686bdccd"
down_revision: str | Sequence[str] | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('entities',
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_entities'))
op.create_table(
"entities",
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column(
"created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False
),
sa.Column("id", sa.UUID(), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_entities")),
)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('entities')
op.drop_table("entities")
# ### end Alembic commands ###
Empty file added app/__init__.py
Empty file.
4 changes: 2 additions & 2 deletions app/database/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from app.database.engine import engine
from app.database.engine import get_engine
from app.database.session import AsyncSessionLocal, get_session

__all__ = ["engine", "AsyncSessionLocal", "get_session"]
__all__ = ["AsyncSessionLocal", "get_engine", "get_session"]
23 changes: 15 additions & 8 deletions app/database/engine.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine

from app.settings import settings

engine = create_async_engine(
settings.database_url,
pool_size=settings.database_pool_size,
max_overflow=settings.database_max_overflow,
echo=settings.database_echo,
pool_pre_ping=settings.database_pool_pre_ping,
)
_engine: AsyncEngine | None = None


def get_engine() -> AsyncEngine:
global _engine
if _engine is None:
_engine = create_async_engine(
settings.database_url,
pool_size=settings.database_pool_size,
max_overflow=settings.database_max_overflow,
echo=settings.database_echo,
pool_pre_ping=settings.database_pool_pre_ping,
)
return _engine
29 changes: 20 additions & 9 deletions app/database/session.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
from typing import AsyncGenerator
from collections.abc import AsyncGenerator

from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker

from app.database.engine import engine
from app.database.engine import get_engine

AsyncSessionLocal = async_sessionmaker(
bind=engine,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)

class _SessionMakerProxy:
def __init__(self) -> None:
self._sessionmaker = None

async def get_session() -> AsyncGenerator[AsyncSession, None]:
def __call__(self, *args: object, **kwargs: object) -> AsyncSession:
if self._sessionmaker is None:
self._sessionmaker = async_sessionmaker(
bind=get_engine(),
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
return self._sessionmaker(*args, **kwargs)


AsyncSessionLocal = _SessionMakerProxy()


async def get_session() -> AsyncGenerator[AsyncSession]:
async with AsyncSessionLocal() as session:
try:
yield session
Expand Down
8 changes: 4 additions & 4 deletions app/exceptions/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from .base import BaseException, base_exception_handler
from .exceptions import NoEntityFoundException
from app.exceptions.base import BaseError, base_exception_handler
from app.exceptions.exceptions import NoEntityFoundError

__all__: list[str] = [
"BaseException",
"BaseError",
"NoEntityFoundError",
"base_exception_handler",
"NoEntityFoundException",
]
10 changes: 5 additions & 5 deletions app/exceptions/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
from app.logger import logger


class BaseException(Exception):
class BaseError(Exception):
def __init__(
self, message: str, status_code: int = 500, title: str = "Internal Server Error"
):
) -> None:
self.message = message
self.status_code = status_code
self.title = title
Expand All @@ -24,7 +24,7 @@ def get_error(self, request: Request) -> dict:
}


async def base_exception_handler(request: Request, exc: Exception):
async def base_exception_handler(request: Request, exc: Exception) -> JSONResponse:
match exc:
case RequestValidationError():
return JSONResponse(
Expand All @@ -37,13 +37,13 @@ async def base_exception_handler(request: Request, exc: Exception):
"instance": str(request.url),
},
)
case BaseException():
case BaseError():
return JSONResponse(
status_code=exc.status_code,
content=exc.get_error(request),
)
case _:
logger.error(str(exec))
logger.error(str(exc))
return JSONResponse(
status_code=500,
content={
Expand Down
8 changes: 4 additions & 4 deletions app/exceptions/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
from uuid import UUID

from app.exceptions.base import BaseException
from app.exceptions.base import BaseError


class NoEntityFoundException(BaseException):
def __init__(self, id: UUID):
class NoEntityFoundError(BaseError):
def __init__(self, entity_id: UUID) -> None:
super().__init__(
message=f"Entity '{id}' not found",
message=f"Entity '{entity_id}' not found",
status_code=404,
title="Not Found",
)
2 changes: 1 addition & 1 deletion app/io/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from app.io.entity import EntityCreate, EntityResponse, EntityUpdate

__all__ = ["EntityCreate", "EntityUpdate", "EntityResponse"]
__all__ = ["EntityCreate", "EntityResponse", "EntityUpdate"]
5 changes: 2 additions & 3 deletions app/io/entity.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from datetime import datetime
from typing import Optional
from uuid import UUID

from pydantic import BaseModel, Field
Expand All @@ -11,8 +10,8 @@ class EntityCreate(BaseModel):


class EntityUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=5000)
name: str | None = Field(None, min_length=1, max_length=255)
description: str | None = Field(None, max_length=5000)


class EntityResponse(BaseModel):
Expand Down
4 changes: 2 additions & 2 deletions app/logger/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from .logger import debug, error, info, setup_logging
from app.logger.logger import debug, error, info, setup_logging

__all__ = ["info", "error", "debug", "setup_logging"]
__all__ = ["debug", "error", "info", "setup_logging"]
Loading
Loading