Skip to content

Commit 0c2cf03

Browse files
authored
Merge pull request #1 from Subhransu-De/feature/tests
Strengthen CI and streamline test coverage
2 parents ca75c54 + afa62a4 commit 0c2cf03

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+1608
-284
lines changed

.github/workflows/workflow.yml

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,73 @@
1-
name: workflow
1+
name: CI/CD
22

33
on:
44
push:
55
branches:
6-
- main
6+
- "**"
77
paths:
8-
- '**.py'
9-
- '**.ini'
10-
- '**.toml'
11-
- '**.lock'
8+
- "**.py"
9+
- "**.ini"
10+
- "**.toml"
11+
- "**.lock"
1212
pull_request:
1313
branches:
1414
- main
1515
paths:
16-
- '**.py'
17-
- '**.ini'
18-
- '**.toml'
19-
- '**.lock'
16+
- "**.py"
17+
- "**.ini"
18+
- "**.toml"
19+
- "**.lock"
2020

2121
env:
2222
REGISTRY: ghcr.io
2323
IMAGE_NAME: ${{ github.repository }}
2424

2525
jobs:
26+
ruff:
27+
runs-on: ubuntu-latest
28+
29+
steps:
30+
- uses: actions/checkout@v4
31+
32+
- uses: astral-sh/setup-uv@v5
33+
34+
- name: Install lint dependencies
35+
run: uv sync --group lint
36+
37+
- name: Run Ruff
38+
run: uv run ruff check .
39+
40+
ty:
41+
runs-on: ubuntu-latest
42+
43+
steps:
44+
- uses: actions/checkout@v4
45+
46+
- uses: astral-sh/setup-uv@v5
47+
48+
- name: Install lint and test dependencies
49+
run: uv sync --group lint --group test
50+
51+
- name: Run Ty
52+
run: uv run ty check
53+
54+
test:
55+
runs-on: ubuntu-latest
56+
57+
steps:
58+
- uses: actions/checkout@v4
59+
60+
- uses: astral-sh/setup-uv@v5
61+
62+
- name: Install dependencies
63+
run: uv sync --group test
64+
65+
- name: Run tests with coverage
66+
run: uv run pytest --cov=app --cov-report=term-missing --cov-fail-under=80
67+
2668
build:
2769
runs-on: ubuntu-latest
70+
needs: [ruff, ty, test]
2871
permissions:
2972
contents: read
3073

@@ -43,7 +86,7 @@ jobs:
4386
tags: |
4487
type=ref,event=branch
4588
type=ref,event=pr
46-
type=sha,prefix={{branch}}-
89+
type=sha,prefix=sha-
4790
type=raw,value=latest,enable={{is_default_branch}}
4891
4992
- name: Build Docker image

.zed/settings.json

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,19 @@
22
"file_scan_exclusions": [
33
"**/.git",
44
"**/.venv",
5+
"**/.coverage",
56
"**/__pycache__",
67
"**/.pytest_cache",
7-
"**/.ruff_cache",
8+
"**/.ruff_cache"
89
],
910
"languages": {
1011
"Python": {
1112
"language_servers": ["ruff", "ty", "!basedpyright"],
1213
"format_on_save": "on",
1314
"code_actions_on_format": {
1415
"source.organizeImports.ruff": true,
15-
"source.fixAll.ruff": true,
16-
},
17-
},
18-
},
16+
"source.fixAll.ruff": true
17+
}
18+
}
19+
}
1920
}

Makefile

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,28 @@
1-
.PHONY: help lint run format install docker-up docker-down
1+
.PHONY: help lint run format install install-prod upgrade docker-up docker-down test test-unit test-cov
22

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

1218
install:
13-
uv sync --group lint
19+
uv sync --group lint --group test
20+
21+
install-prod:
22+
uv sync
23+
24+
upgrade:
25+
uv sync --group lint --group test -U
1426

1527
lint:
1628
uv run ruff check .
@@ -32,3 +44,12 @@ docker-up:
3244

3345
docker-down:
3446
docker compose down -v
47+
48+
test:
49+
uv run pytest
50+
51+
test-unit:
52+
uv run pytest tests/unit -m unit
53+
54+
test-cov:
55+
uv run pytest --cov=app --cov-report=term-missing
Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,42 @@
11
"""Create Entities table
22
33
Revision ID: 97f7686bdccd
4-
Revises:
4+
Revises:
55
Create Date: 2026-01-24 20:12:27.427802+00:00
66
77
"""
8-
from typing import Sequence, Union
98

10-
from alembic import op
9+
from collections.abc import Sequence
10+
1111
import sqlalchemy as sa
1212

13+
from alembic import op
1314

14-
revision: str = '97f7686bdccd'
15-
down_revision: Union[str, Sequence[str], None] = None
16-
branch_labels: Union[str, Sequence[str], None] = None
17-
depends_on: Union[str, Sequence[str], None] = None
15+
revision: str = "97f7686bdccd"
16+
down_revision: str | Sequence[str] | None = None
17+
branch_labels: str | Sequence[str] | None = None
18+
depends_on: str | Sequence[str] | None = None
1819

1920

2021
def upgrade() -> None:
2122
# ### commands auto generated by Alembic - please adjust! ###
22-
op.create_table('entities',
23-
sa.Column('name', sa.String(length=255), nullable=False),
24-
sa.Column('description', sa.Text(), nullable=True),
25-
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
26-
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
27-
sa.Column('id', sa.UUID(), nullable=False),
28-
sa.PrimaryKeyConstraint('id', name=op.f('pk_entities'))
23+
op.create_table(
24+
"entities",
25+
sa.Column("name", sa.String(length=255), nullable=False),
26+
sa.Column("description", sa.Text(), nullable=True),
27+
sa.Column(
28+
"created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False
29+
),
30+
sa.Column(
31+
"updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False
32+
),
33+
sa.Column("id", sa.UUID(), nullable=False),
34+
sa.PrimaryKeyConstraint("id", name=op.f("pk_entities")),
2935
)
3036
# ### end Alembic commands ###
3137

3238

3339
def downgrade() -> None:
3440
# ### commands auto generated by Alembic - please adjust! ###
35-
op.drop_table('entities')
41+
op.drop_table("entities")
3642
# ### end Alembic commands ###

app/__init__.py

Whitespace-only changes.

app/database/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from app.database.engine import engine
1+
from app.database.engine import get_engine
22
from app.database.session import AsyncSessionLocal, get_session
33

4-
__all__ = ["engine", "AsyncSessionLocal", "get_session"]
4+
__all__ = ["AsyncSessionLocal", "get_engine", "get_session"]

app/database/engine.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
1-
from sqlalchemy.ext.asyncio import create_async_engine
1+
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
22

33
from app.settings import settings
44

5-
engine = create_async_engine(
6-
settings.database_url,
7-
pool_size=settings.database_pool_size,
8-
max_overflow=settings.database_max_overflow,
9-
echo=settings.database_echo,
10-
pool_pre_ping=settings.database_pool_pre_ping,
11-
)
5+
_engine: AsyncEngine | None = None
6+
7+
8+
def get_engine() -> AsyncEngine:
9+
global _engine
10+
if _engine is None:
11+
_engine = create_async_engine(
12+
settings.database_url,
13+
pool_size=settings.database_pool_size,
14+
max_overflow=settings.database_max_overflow,
15+
echo=settings.database_echo,
16+
pool_pre_ping=settings.database_pool_pre_ping,
17+
)
18+
return _engine

app/database/session.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,29 @@
1-
from typing import AsyncGenerator
1+
from collections.abc import AsyncGenerator
22

33
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
44

5-
from app.database.engine import engine
5+
from app.database.engine import get_engine
66

7-
AsyncSessionLocal = async_sessionmaker(
8-
bind=engine,
9-
expire_on_commit=False,
10-
autocommit=False,
11-
autoflush=False,
12-
)
137

8+
class _SessionMakerProxy:
9+
def __init__(self) -> None:
10+
self._sessionmaker = None
1411

15-
async def get_session() -> AsyncGenerator[AsyncSession, None]:
12+
def __call__(self, *args: object, **kwargs: object) -> AsyncSession:
13+
if self._sessionmaker is None:
14+
self._sessionmaker = async_sessionmaker(
15+
bind=get_engine(),
16+
expire_on_commit=False,
17+
autocommit=False,
18+
autoflush=False,
19+
)
20+
return self._sessionmaker(*args, **kwargs)
21+
22+
23+
AsyncSessionLocal = _SessionMakerProxy()
24+
25+
26+
async def get_session() -> AsyncGenerator[AsyncSession]:
1627
async with AsyncSessionLocal() as session:
1728
try:
1829
yield session

app/exceptions/__init__.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
from .base import BaseException, base_exception_handler
2-
from .exceptions import NoEntityFoundException
1+
from app.exceptions.base import BaseError, base_exception_handler
2+
from app.exceptions.exceptions import NoEntityFoundError
33

44
__all__: list[str] = [
5-
"BaseException",
5+
"BaseError",
6+
"NoEntityFoundError",
67
"base_exception_handler",
7-
"NoEntityFoundException",
88
]

app/exceptions/base.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55
from app.logger import logger
66

77

8-
class BaseException(Exception):
8+
class BaseError(Exception):
99
def __init__(
1010
self, message: str, status_code: int = 500, title: str = "Internal Server Error"
11-
):
11+
) -> None:
1212
self.message = message
1313
self.status_code = status_code
1414
self.title = title
@@ -24,7 +24,7 @@ def get_error(self, request: Request) -> dict:
2424
}
2525

2626

27-
async def base_exception_handler(request: Request, exc: Exception):
27+
async def base_exception_handler(request: Request, exc: Exception) -> JSONResponse:
2828
match exc:
2929
case RequestValidationError():
3030
return JSONResponse(
@@ -37,13 +37,13 @@ async def base_exception_handler(request: Request, exc: Exception):
3737
"instance": str(request.url),
3838
},
3939
)
40-
case BaseException():
40+
case BaseError():
4141
return JSONResponse(
4242
status_code=exc.status_code,
4343
content=exc.get_error(request),
4444
)
4545
case _:
46-
logger.error(str(exec))
46+
logger.error(str(exc))
4747
return JSONResponse(
4848
status_code=500,
4949
content={

0 commit comments

Comments
 (0)