chore: merge main into PR branch, resolving conflicts

- Remove poetry.lock (project migrated to uv)
- Update pyproject.toml to use uv/hatchling format with bumped dependency versions
- Regenerate uv.lock with updated package versions

Co-authored-by: furyhawk <831682+furyhawk@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-04-22 05:09:45 +00:00
committed by GitHub
co-authored by furyhawk
14 changed files with 1552 additions and 2129 deletions
+55
View File
@@ -0,0 +1,55 @@
---
description: "Use when: designing or modifying SQLAlchemy models, creating database migrations, altering schema, managing relationships, or working with Alembic"
name: "Database & Models Specialist"
tools: [read, edit, search, execute]
user-invocable: true
---
You are a database and models specialist for the Listen project. Your role is to design, create, and modify SQLAlchemy models, manage Alembic migrations, and maintain database schema integrity following project conventions.
## Core Conventions
- **SQLAlchemy 2.0 async patterns**: Use `Mapped`, `mapped_column()`, and declarative syntax
- **Base class inheritance**: All models inherit from `Base` which provides `create_time` and `update_time` timestamps automatically
- **Naming**: Table names are lowercase singular (e.g. `user_account`, `refresh_token`, `pet`)
- **Primary keys**: Typically `BigInteger` for auto-incrementing, `Uuid` for user-facing IDs (stored as strings)
- **Relationships**: Use `relationship()` with `back_populates` for bidirectional relationships, include `ondelete="CASCADE"` on foreign keys
- **Alembic automation**: After model changes, run `alembic revision --autogenerate -m "description"` then `alembic upgrade head`
- **Testing**: Changes to models require corresponding migration tests; minimum 92% coverage
## Responsibilities
1. **Model Design**: Create well-structured SQLAlchemy models with proper types, constraints, and relationships
2. **Migrations**: Generate, review, and apply Alembic migrations; ensure bidirectional relationships in models
3. **Schema Changes**: Modify existing models while maintaining backward compatibility where appropriate
4. **Relationship Management**: Set up proper foreign key constraints and ORM relationships
5. **Type Safety**: Use SQLAlchemy 2.0 type hints (`Mapped[T]`, `type[T]`) consistently
## Constraints
- DO NOT use deprecated SQLAlchemy 1.x patterns (`Column()`, `ForeignKey()` without relationship setup)
- DO NOT create models without proper `__tablename__` definition
- DO NOT forget bidirectional relationships when creating foreign key relationships
- DO NOT modify migration files manually—regenerate them via `alembic revision --autogenerate`
- DO NOT skip test coverage when adding new models or relationships
## Common Tasks
- **Add a new model**: Create model in `app/models.py`, run Alembic autogenerate, apply migration
- **Add a relationship**: Use `relationship()` with `back_populates` on both sides
- **Modify field type or constraint**: Update model, autogenerate migration, apply it
- **Handle cascading deletes**: Set `ondelete="CASCADE"` on ForeignKey for dependent data
## Tools to Use
- **read**: Review `app/models.py`, `alembic/versions/`, database schema
- **edit**: Modify models, apply migrations
- **search**: Find model references, relationship usage patterns
- **execute**: Run Alembic commands (`alembic revision`, `alembic upgrade`)
## Output Format
For model changes, always provide:
1. Model definition updated (with full class shown)
2. Alembic migration command to run
3. Brief explanation of schema change
+12 -13
View File
@@ -29,29 +29,28 @@ jobs:
with:
python-version: "3.13.1"
- name: Install Poetry
uses: snok/install-poetry@v1
with:
virtualenvs-create: true
virtualenvs-in-project: false
virtualenvs-path: /opt/venv
- name: Install uv
uses: astral-sh/setup-uv@v3
- name: Load cached venv
id: cached-poetry-dependencies
id: cached-uv-dependencies
uses: actions/cache@v4
with:
path: /opt/venv
key: venv-${{ runner.os }}-python-3.13.1-${{ hashFiles('poetry.lock') }}
path: .venv
key: venv-${{ runner.os }}-python-3.13.1-${{ hashFiles('pyproject.toml', 'uv.lock') }}
- name: Install dependencies and actiavte virtualenv
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
- name: Install dependencies
run: |
poetry install --no-interaction --no-root
uv sync --all-extras
- name: Run tests
env:
SECURITY__JWT_SECRET_KEY: very-not-secret
SECURITY__BACKEND_CORS_ORIGINS: "[]"
SECURITY__ALLOWED_HOSTS: '["localhost", "127.0.0.1"]'
DATABASE__HOSTNAME: localhost
DATABASE__USERNAME: postgres
DATABASE__PASSWORD: postgres
DATABASE__DB: postgres
run: |
poetry run pytest
uv run pytest
+8 -19
View File
@@ -21,31 +21,20 @@ jobs:
with:
python-version: "3.13.1"
- name: Install Poetry
uses: snok/install-poetry@v1
with:
virtualenvs-create: true
virtualenvs-in-project: true
- name: Install Poetry
uses: snok/install-poetry@v1
with:
virtualenvs-create: true
virtualenvs-in-project: false
virtualenvs-path: /opt/venv
- name: Install uv
uses: astral-sh/setup-uv@v3
- name: Load cached venv
id: cached-poetry-dependencies
id: cached-uv-dependencies
uses: actions/cache@v4
with:
path: /opt/venv
key: venv-${{ runner.os }}-python-3.13.1-${{ hashFiles('poetry.lock') }}
path: .venv
key: venv-${{ runner.os }}-python-3.13.1-${{ hashFiles('pyproject.toml', 'uv.lock') }}
- name: Install dependencies and actiavte virtualenv
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
- name: Install dependencies
run: |
poetry install --no-interaction --no-root
uv sync --all-extras
- name: Run ${{ matrix.check }}
run: |
poetry run ${{ matrix.check }} .
uv run ${{ matrix.check }} .
+6 -6
View File
@@ -1,16 +1,16 @@
FROM python:3.13.3-slim-bookworm as base
FROM python:3.14.3-slim-bookworm as base
ENV PYTHONUNBUFFERED 1
WORKDIR /build
# Create requirements.txt file
FROM base as poetry
RUN pip install poetry==1.8.2
COPY poetry.lock pyproject.toml ./
RUN poetry export -o /requirements.txt --without-hashes
FROM base as deps
RUN pip install uv==0.4.26
COPY pyproject.toml ./
RUN uv pip compile pyproject.toml -o /requirements.txt
FROM base as common
COPY --from=poetry /requirements.txt .
COPY --from=deps /requirements.txt .
# Create venv, add it to path and install requirements
RUN python -m venv /venv
ENV PATH="/venv/bin:$PATH"
+6 -6
View File
@@ -12,7 +12,7 @@ _Check out online example: https://minimal-fastapi-postgres-template.rafsaf.pl,
- [Features](#features)
- [Quickstart](#quickstart)
- [1. Create repository from a template](#1-create-repository-from-a-template)
- [2. Install dependecies with Poetry](#2-install-dependecies-with-poetry)
- [2. Install dependecies with uv](#2-install-dependecies-with-uv)
- [3. Setup database and migrations](#3-setup-database-and-migrations)
- [4. Now you can run app](#4-now-you-can-run-app)
- [5. Activate pre-commit](#5-activate-pre-commit)
@@ -38,7 +38,7 @@ _Check out online example: https://minimal-fastapi-postgres-template.rafsaf.pl,
- [x] Full [Alembic](https://alembic.sqlalchemy.org/en/latest/) migrations setup
- [x] Refresh token endpoint (not only access like in official template)
- [x] Ready to go Dockerfile with [uvicorn](https://www.uvicorn.org/) webserver as an example
- [x] [Poetry](https://python-poetry.org/docs/), `mypy`, `pre-commit` hooks with [ruff](https://github.com/astral-sh/ruff)
- [x] [uv](https://docs.astral.sh/uv/), `mypy`, `pre-commit` hooks with [ruff](https://github.com/astral-sh/ruff)
- [x] Perfect pytest asynchronous test setup with +40 tests and full coverage
<br>
@@ -55,16 +55,16 @@ _Check out online example: https://minimal-fastapi-postgres-template.rafsaf.pl,
See [docs](https://docs.github.com/en/repositories/creating-and-managing-repositories/creating-a-repository-from-a-template).
### 2. Install dependecies with [Poetry](https://python-poetry.org/docs/)
### 2. Install dependecies with [uv](https://docs.astral.sh/uv/)
```bash
cd your_project_name
### Poetry install (python3.13)
poetry install
### uv install (python3.13)
uv sync
```
Note, be sure to use `python3.13` with this template with either poetry or standard venv & pip, if you need to stick to some earlier python version, you should adapt it yourself (remove new versions specific syntax for example `str | int` for python < 3.10)
Note, be sure to use `python3.13` with this template with either uv or a standard venv and pip setup. If you need to stick to an earlier Python version, adapt the project accordingly.
### 3. Setup database and migrations
+22 -7
View File
@@ -1,4 +1,6 @@
from fastapi import APIRouter, Depends, status
from datetime import datetime
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -27,15 +29,28 @@ async def create_new_humidity(
@router.get(
"/all",
"/search",
response_model=list[HumidityResponse],
status_code=status.HTTP_200_OK,
description="Get humidity of the city.",
description="Search humidity readings with optional date range and limit.",
)
async def get_humidity(
async def search_humidity(
session: AsyncSession = Depends(deps.get_session),
limit: int = Query(100, ge=1, le=1000),
start_date: datetime | None = Query(
None, description="Filter readings from this date (ISO 8601)"
),
end_date: datetime | None = Query(
None, description="Filter readings until this date (ISO 8601)"
),
) -> list[Humidity]:
humidity_list = await session.scalars(
select(Humidity).order_by(Humidity.create_time.desc())
)
query = select(Humidity)
if start_date:
query = query.where(Humidity.create_time >= start_date)
if end_date:
query = query.where(Humidity.create_time <= end_date)
query = query.order_by(Humidity.create_time.desc()).limit(limit)
humidity_list = await session.scalars(query)
return list(humidity_list.all())
+22 -7
View File
@@ -1,4 +1,6 @@
from fastapi import APIRouter, Depends, status
from datetime import datetime
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy import ScalarResult, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -27,15 +29,28 @@ async def create_new_pressure(
@router.get(
"/all",
"/search",
response_model=list[PressureResponse],
status_code=status.HTTP_200_OK,
description="Get pressure of the city.",
description="Search pressure readings with optional date range and limit.",
)
async def get_pressure(
async def search_pressure(
session: AsyncSession = Depends(deps.get_session),
limit: int = Query(100, ge=1, le=1000),
start_date: datetime | None = Query(
None, description="Filter readings from this date (ISO 8601)"
),
end_date: datetime | None = Query(
None, description="Filter readings until this date (ISO 8601)"
),
) -> list[Pressure]:
pressure_list: ScalarResult[Pressure] = await session.scalars(
select(Pressure).order_by(Pressure.create_time.desc())
)
query = select(Pressure)
if start_date:
query = query.where(Pressure.create_time >= start_date)
if end_date:
query = query.where(Pressure.create_time <= end_date)
query = query.order_by(Pressure.create_time.desc()).limit(limit)
pressure_list: ScalarResult[Pressure] = await session.scalars(query)
return list(pressure_list.all())
+22 -7
View File
@@ -1,4 +1,6 @@
from fastapi import APIRouter, Depends, status
from datetime import datetime
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -27,15 +29,28 @@ async def create_new_temperature(
@router.get(
"/all",
"/search",
response_model=list[TemperatureResponse],
status_code=status.HTTP_200_OK,
description="Get temperature of the city.",
description="Search temperature readings with optional date range and limit.",
)
async def get_temperature(
async def search_temperature(
session: AsyncSession = Depends(deps.get_session),
limit: int = Query(100, ge=1, le=1000),
start_date: datetime | None = Query(
None, description="Filter readings from this date (ISO 8601)"
),
end_date: datetime | None = Query(
None, description="Filter readings until this date (ISO 8601)"
),
) -> list[Temperature]:
temperature_list = await session.scalars(
select(Temperature).order_by(Temperature.create_time.desc())
)
query = select(Temperature)
if start_date:
query = query.where(Temperature.create_time >= start_date)
if end_date:
query = query.where(Temperature.create_time <= end_date)
query = query.order_by(Temperature.create_time.desc()).limit(limit)
temperature_list = await session.scalars(query)
return list(temperature_list.all())
+1 -1
View File
@@ -138,6 +138,6 @@ async def fixture_default_user(
yield default_user
@pytest_asyncio.fixture(name="default_user_headers", scope="function")
@pytest.fixture(name="default_user_headers", scope="function")
def fixture_default_user_headers(default_user: User) -> dict[str, str]:
return {"Authorization": f"Bearer {default_user_access_token}"}
+66 -42
View File
@@ -1,3 +1,5 @@
from datetime import datetime, timedelta
import pytest
from fastapi import status
from httpx import AsyncClient
@@ -6,6 +8,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.main import app
from app.models import Humidity, Pressure, Temperature
MIN_EXPECTED_RESULTS = 2
@pytest.mark.asyncio(loop_scope="session")
async def test_create_new_temperature(client: AsyncClient) -> None:
@@ -35,7 +39,7 @@ async def test_create_new_temperature(client: AsyncClient) -> None:
@pytest.mark.asyncio(loop_scope="session")
async def test_get_temperature(
async def test_search_temperature(
client: AsyncClient,
session: AsyncSession,
) -> None:
@@ -47,22 +51,40 @@ async def test_get_temperature(
await session.commit()
response = await client.get(
app.url_path_for("get_temperature"),
app.url_path_for("search_temperature"),
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()) >= MIN_EXPECTED_RESULTS
assert response.json() == [
{
"update_time": temperature1.update_time.isoformat().replace("+00:00", "Z"),
"temperature": temperature1.temperature,
"id": temperature1.id,
},
{
"update_time": temperature2.update_time.isoformat().replace("+00:00", "Z"),
"temperature": temperature2.temperature,
"id": temperature2.id,
},
]
@pytest.mark.asyncio(loop_scope="session")
async def test_search_temperature_with_limit(
client: AsyncClient,
session: AsyncSession,
) -> None:
response = await client.get(
app.url_path_for("search_temperature"),
params={"limit": 1},
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()) <= 1
@pytest.mark.asyncio(loop_scope="session")
async def test_search_temperature_with_date_range(
client: AsyncClient,
session: AsyncSession,
) -> None:
now = datetime.utcnow()
start_date = (now - timedelta(days=1)).isoformat() + "Z"
end_date = (now + timedelta(days=1)).isoformat() + "Z"
response = await client.get(
app.url_path_for("search_temperature"),
params={"start_date": start_date, "end_date": end_date},
)
assert response.status_code == status.HTTP_200_OK
assert isinstance(response.json(), list)
@pytest.mark.asyncio(loop_scope="session")
@@ -93,7 +115,7 @@ async def test_create_new_pressure(client: AsyncClient) -> None:
@pytest.mark.asyncio(loop_scope="session")
async def test_get_pressure(
async def test_search_pressure(
client: AsyncClient,
session: AsyncSession,
) -> None:
@@ -105,22 +127,23 @@ async def test_get_pressure(
await session.commit()
response = await client.get(
app.url_path_for("get_pressure"),
app.url_path_for("search_pressure"),
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()) >= MIN_EXPECTED_RESULTS
assert response.json() == [
{
"update_time": pressure1.update_time.isoformat().replace("+00:00", "Z"),
"pressure": pressure1.pressure,
"id": pressure1.id,
},
{
"update_time": pressure2.update_time.isoformat().replace("+00:00", "Z"),
"pressure": pressure2.pressure,
"id": pressure2.id,
},
]
@pytest.mark.asyncio(loop_scope="session")
async def test_search_pressure_with_limit(
client: AsyncClient,
session: AsyncSession,
) -> None:
response = await client.get(
app.url_path_for("search_pressure"),
params={"limit": 1},
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()) <= 1
@pytest.mark.asyncio(loop_scope="session")
@@ -151,7 +174,7 @@ async def test_create_new_humidity(client: AsyncClient) -> None:
@pytest.mark.asyncio(loop_scope="session")
async def test_get_humidity(
async def test_search_humidity(
client: AsyncClient,
session: AsyncSession,
) -> None:
@@ -163,19 +186,20 @@ async def test_get_humidity(
await session.commit()
response = await client.get(
app.url_path_for("get_humidity"),
app.url_path_for("search_humidity"),
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()) >= MIN_EXPECTED_RESULTS
assert response.json() == [
{
"update_time": humidity1.update_time.isoformat().replace("+00:00", "Z"),
"humidity": humidity1.humidity,
"id": humidity1.id,
},
{
"update_time": humidity2.update_time.isoformat().replace("+00:00", "Z"),
"humidity": humidity2.humidity,
"id": humidity2.id,
},
]
@pytest.mark.asyncio(loop_scope="session")
async def test_search_humidity_with_limit(
client: AsyncClient,
session: AsyncSession,
) -> None:
response = await client.get(
app.url_path_for("search_humidity"),
params={"limit": 1},
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()) <= 1
Generated
-1990
View File
File diff suppressed because it is too large Load Diff
+32 -31
View File
@@ -1,39 +1,40 @@
[tool.poetry]
authors = ["admin <admin@example.com>"]
[project]
authors = [{email = "admin@example.com", name = "admin"}]
description = "FastAPI project generated using minimal-fastapi-postgres-template."
name = "app"
version = "0.1.0-alpha"
version = "0.1.19"
requires-python = ">=3.13"
dependencies = [
"alembic>=1.18.3,<2",
"asyncpg>=0.31.0,<1",
"bcrypt>=5.0.0,<6",
"fastapi>=0.128.0,<1",
"pydantic[email]>=2.12.5,<3",
"pydantic-settings>=2.12.0,<3",
"pyjwt>=2.11.0,<3",
"python-multipart>=0.0.22,<1",
"sqlalchemy[asyncio]>=2.0.46,<3",
]
[tool.poetry.dependencies]
python = "^3.13"
alembic = "^1.18.3"
asyncpg = "^0.31.0"
bcrypt = "^5.0.0"
fastapi = "^0.128.0"
pydantic = { extras = ["dotenv", "email"], version = "^2.12.5" }
pydantic-settings = "^2.12.0"
pyjwt = "^2.11.0"
python-multipart = "^0.0.22"
sqlalchemy = {extras = ["asyncio"], version = "^2.0.46"}
[tool.poetry.group.dev.dependencies]
coverage = "^7.13.2"
freezegun = "^1.5.5"
greenlet = "^3.3.1"
httpx = "^0.28.1"
mypy = "^1.19.1"
pre-commit = "^4.5.1"
pytest = "^8.4.2"
pytest-asyncio = "^0.26.0"
pytest-cov = "^7.0.0"
pytest-xdist = "^3.8.0"
ruff = "^0.14.14"
uvicorn = { extras = ["standard"], version = "^0.40.0" }
[project.optional-dependencies]
dev = [
"coverage>=7.13.2,<8",
"freezegun>=1.5.5,<2",
"greenlet>=3.3.1,<4",
"httpx>=0.28.1,<1",
"mypy>=1.19.1,<2",
"pre-commit>=4.5.1,<5",
"pytest>=8.4.2,<9",
"pytest-asyncio>=0.26.0,<1",
"pytest-cov>=7.0.0,<8",
"pytest-xdist>=3.8.0,<4",
"ruff>=0.14.14,<1",
"uvicorn[standard]>=0.40.0,<1",
]
[build-system]
build-backend = "poetry.core.masonry.api"
requires = ["poetry-core>=1.0.0"]
build-backend = "hatchling.build"
requires = ["hatchling"]
[tool.pytest.ini_options]
addopts = "-vv -n auto --cov --cov-report xml --cov-report term-missing --cov-fail-under=92"
+64
View File
@@ -0,0 +1,64 @@
# This file was autogenerated by uv via the following command:
# uv pip compile pyproject.toml -o requirements.txt
alembic==1.18.4
# via app (pyproject.toml)
annotated-doc==0.0.4
# via fastapi
annotated-types==0.7.0
# via pydantic
anyio==4.13.0
# via starlette
asyncpg==0.31.0
# via app (pyproject.toml)
bcrypt==4.3.0
# via app (pyproject.toml)
dnspython==2.8.0
# via email-validator
email-validator==2.3.0
# via pydantic
fastapi==0.135.3
# via app (pyproject.toml)
greenlet==3.4.0
# via sqlalchemy
idna==3.11
# via
# anyio
# email-validator
mako==1.3.10
# via alembic
markupsafe==3.0.3
# via mako
pydantic==2.12.5
# via
# app (pyproject.toml)
# fastapi
# pydantic-settings
pydantic-core==2.41.5
# via pydantic
pydantic-settings==2.13.1
# via app (pyproject.toml)
pyjwt==2.12.1
# via app (pyproject.toml)
python-dotenv==1.2.2
# via pydantic-settings
python-multipart==0.0.26
# via app (pyproject.toml)
sqlalchemy==2.0.49
# via
# app (pyproject.toml)
# alembic
starlette==1.0.0
# via fastapi
typing-extensions==4.15.0
# via
# alembic
# fastapi
# pydantic
# pydantic-core
# sqlalchemy
# typing-inspection
typing-inspection==0.4.2
# via
# fastapi
# pydantic
# pydantic-settings
Generated
+1236
View File
File diff suppressed because it is too large Load Diff