Compare commits

..

No commits in common. "41a8688aaec940f563fd900a5a02405d4d5f8469" and "e9750107e7bac1198e3bf7b9e75444c1cd16ee72" have entirely different histories.

35 changed files with 4504 additions and 2045 deletions

View File

@ -7,19 +7,20 @@ on:
- main - main
jobs: jobs:
build: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
python-version: [ "3.13" ] python-version: [ "3.13" ]
poetry-version: [ "1.8.5" ]
env: env:
PYTHONDONTWRITEBYTECODE: 1 PYTHONDONTWRITEBYTECODE: 1
PYTHONUNBUFFERED: 1 PYTHONUNBUFFERED: 1
POSTGRES_DB: testdb POSTGRES_DB: testdb
POSTGRES_HOST: 127.0.0.1 POSTGRES_HOST: 127.0.0.1
POSTGRES_USER: panettone POSTGRES_USER: app-user
POSTGRES_PASSWORD: secret POSTGRES_PASSWORD: secret
PGPASSWORD: secret PGPASSWORD: secret
REDIS_HOST: 127.0.0.1 REDIS_HOST: 127.0.0.1
@ -36,7 +37,7 @@ jobs:
sqldb: sqldb:
image: postgres:16 image: postgres:16
env: env:
POSTGRES_USER: panettone POSTGRES_USER: app-user
POSTGRES_PASSWORD: secret POSTGRES_PASSWORD: secret
POSTGRES_DB: testdb POSTGRES_DB: testdb
ports: ports:
@ -47,17 +48,18 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Create database schema - name: Create database schema
run: PGPASSWORD=secret psql -h 127.0.0.1 -d testdb -U panettone -c "CREATE SCHEMA shakespeare; CREATE SCHEMA happy_hog;" run: PGPASSWORD=secret psql -h 127.0.0.1 -d testdb -U app-user -c "CREATE SCHEMA shakespeare; CREATE SCHEMA happy_hog;"
- uses: actions/setup-python@v5
- name: Install the latest version of uv and set the python version
uses: astral-sh/setup-uv@v5
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Install Poetry
- name: Lint with ruff uses: abatilo/actions-poetry@v3
run: uv run --frozen ruff check . with:
poetry-version: ${{ matrix.poetry-version }}
- name: Test with python ${{ matrix.python-version }} - name: Install dependencies
run: uv run --frozen pytest if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
run: poetry install --no-interaction --no-root
- name: Test Code
run: poetry run pytest tests/
- name: Lint Code
run: poetry run ruff check .

View File

@ -1,62 +1,43 @@
FROM ubuntu:oracular AS build FROM python:3.13-slim-bookworm AS base
RUN apt-get update \
&& apt-get upgrade -y \
&& apt-get install -y --no-install-recommends curl git build-essential \
&& apt-get autoremove -y
ENV POETRY_HOME="/opt/poetry"
RUN curl -sSL https://install.python-poetry.org | python3 -
RUN apt-get update -qy && apt-get install -qyy \ FROM base AS install
-o APT::Install-Recommends=false \ WORKDIR /home/code
-o APT::Install-Suggests=false \
build-essential \
ca-certificates \
python3-setuptools \
python3.13-dev \
git
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv # allow controlling the poetry installation of dependencies via external args
ARG INSTALL_ARGS="--no-root --no-interaction --no-ansi"
ENV POETRY_HOME="/opt/poetry"
ENV PATH="$POETRY_HOME/bin:$PATH"
COPY pyproject.toml poetry.lock ./
ENV UV_LINK_MODE=copy \ # install without virtualenv, since we are inside a container
UV_COMPILE_BYTECODE=1 \ RUN poetry config virtualenvs.create false \
UV_PYTHON_DOWNLOADS=never \ && poetry install $INSTALL_ARGS
UV_PYTHON=python3.13 \
UV_PROJECT_ENVIRONMENT=/panettone
COPY pyproject.toml /_lock/ # cleanup
COPY uv.lock /_lock/ RUN curl -sSL https://install.python-poetry.org | python3 - --uninstall
RUN apt-get purge -y curl git build-essential \
&& apt-get clean -y \
&& rm -rf /root/.cache \
&& rm -rf /var/apt/lists/* \
&& rm -rf /var/cache/apt/*
RUN --mount=type=cache,target=/root/.cache FROM install AS app-image
RUN cd /_lock && uv sync \
--locked \
--no-dev \
--no-install-project
##########################################################################
FROM ubuntu:oracular
ENV PATH=/panettone/bin:$PATH ENV PYTHONPATH=/home/code/ PYTHONHASHSEED=0 PYTHONASYNCIODEBUG=1
RUN groupadd -r panettone COPY tests/ tests/
RUN useradd -r -d /panettone -g panettone -N panettone COPY app/ app/
COPY alembic/ alembic/
COPY .env alembic.ini ./
STOPSIGNAL SIGINT # create a non-root user and switch to it, for security.
RUN addgroup --system --gid 1001 "app-user"
RUN adduser --system --uid 1001 "app-user"
USER "app-user"
RUN apt-get update -qy && apt-get install -qyy \
-o APT::Install-Recommends=false \
-o APT::Install-Suggests=false \
python3.13 \
libpython3.13 \
libpcre3 \
libxml2
RUN apt-get clean
RUN rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
COPY --from=build --chown=panettone:panettone /panettone /panettone
USER panettone
WORKDIR /panettone
COPY /app/ app/
COPY /tests/ tests/
COPY .env app/
COPY alembic.ini app/
COPY alembic/ app/alembic/
COPY logging-uvicorn.json /panettone/logging-uvicorn.json
RUN python -V
RUN python -Im site
RUN python -Ic 'import uvicorn'

View File

@ -39,7 +39,7 @@ safety: ## Check project and dependencies with safety https://github.com/pyupio/
.PHONY: py-upgrade .PHONY: py-upgrade
py-upgrade: ## Upgrade project py files with pyupgrade library for python version 3.10 py-upgrade: ## Upgrade project py files with pyupgrade library for python version 3.10
pyupgrade --py313-plus `find app -name "*.py"` pyupgrade --py312-plus `find app -name "*.py"`
.PHONY: lint .PHONY: lint
lint: ## Lint project code. lint: ## Lint project code.

View File

@ -26,11 +26,10 @@
<li><a href="#how-to-feed-database">How to feed database</a></li> <li><a href="#how-to-feed-database">How to feed database</a></li>
<li><a href="#rainbow-logs-with-rich">Rainbow logs with rich</a></li> <li><a href="#rainbow-logs-with-rich">Rainbow logs with rich</a></li>
<li><a href="#setup-user-auth">Setup user auth</a></li> <li><a href="#setup-user-auth">Setup user auth</a></li>
<li><a href="#setup-local-env-with-uv">Setup local development with uv</a></li> <li><a href="#local-development-with-poetry">Local development with poetry</a></li>
<li><a href="#import-xlsx-files-with-polars-and-calamine">Import xlsx files with polars and calamine</a></li> <li><a href="#import-xlsx-files-with-polars-and-calamine">Import xlsx files with polars and calamine</a></li>
<li><a href="#worker-aware-async-scheduler">Schedule jobs</a></li> <li><a href="#worker-aware-async-scheduler">Schedule jobs</a></li>
<li><a href="#smtp-setup">Email Configuration</a></li> <li><a href="#smtp-setup">Email Configuration</a></li>
<li><a href="#uv-knowledge-and-inspirations">UV knowledge and inspirations</a></li>
</ul> </ul>
</li> </li>
<li><a href="#acknowledgments">Acknowledgments</a></li> <li><a href="#acknowledgments">Acknowledgments</a></li>
@ -45,7 +44,7 @@
This example demonstrates the seamless integration of [FastAPI](https://fastapi.tiangolo.com/), a modern, high-performance web framework, This example demonstrates the seamless integration of [FastAPI](https://fastapi.tiangolo.com/), a modern, high-performance web framework,
with [Pydantic 2.0](https://github.com/pydantic/pydantic), a robust and powerful data validation library. with [Pydantic 2.0](https://github.com/pydantic/pydantic), a robust and powerful data validation library.
The integration is further enhanced by the use of [SQLAlchemy ORM](https://www.sqlalchemy.org/), a popular and feature-rich Object-Relational Mapping tool, The integration is further enhanced by the use of [SQLAlchemy ORM](https://www.sqlalchemy.org/), a popular and feature-rich Object-Relational Mapping tool,
and [PostgreSQL17](https://www.postgresql.org/docs/17/release.html) relational database. and [PostgreSQL16](https://www.postgresql.org/about/news/postgresql-16-released-2715/) relational database.
The entire stack is connected using the [asyncpg](https://github.com/MagicStack/asyncpg) Database Client Library, The entire stack is connected using the [asyncpg](https://github.com/MagicStack/asyncpg) Database Client Library,
which provides a robust and efficient way to interact with PostgreSQL databases in Python, which provides a robust and efficient way to interact with PostgreSQL databases in Python,
@ -57,7 +56,7 @@ allowing for the rapid development of APIs with Python 3.8+.
FastAPI has received significant recognition in the industry, including a review on thoughtworks Technology Radar in April 2021, FastAPI has received significant recognition in the industry, including a review on thoughtworks Technology Radar in April 2021,
where it was classified as a Trial technology, with comments praising its performance, ease of use, where it was classified as a Trial technology, with comments praising its performance, ease of use,
and features such as API documentation using OpenAPI. Additionally, FastAPI was recognized in the Python Developers Survey 2023 Results, and features such as API documentation using OpenAPI. Additionally, FastAPI was recognized in the Python Developers Survey 2022 Results,
conducted by the Python Software Foundation and JetBrains, where it was reported that 1 in 4 Python developers use FastAPI, conducted by the Python Software Foundation and JetBrains, where it was reported that 1 in 4 Python developers use FastAPI,
with a 4 percentage point increase from the previous year. with a 4 percentage point increase from the previous year.
@ -87,8 +86,7 @@ To build , run and test and more ... use magic of make help to play with this pr
4. make docker-feed-database 4. make docker-feed-database
``` ```
### Adjust make with just
[//]: # (TODO: switch form make to just)
<p align="right">(<a href="#readme-top">back to top</a>)</p> <p align="right">(<a href="#readme-top">back to top</a>)</p>
### How to feed database ### How to feed database
@ -128,11 +126,15 @@ he following steps were taken to integrate [rich](https://github.com/Textualize/
Setup user authentication with JWT and Redis as token storage. Setup user authentication with JWT and Redis as token storage.
### Setup local env with uv ### Local development with poetry
```shell ```shell
uv sync pyenv install 3.13 && pyenv local 3.13
source .venv/bin/activate
``` ```
```shell
poetry install --with dev
```
Hope you enjoy it.
### Import xlsx files with polars and calamine ### Import xlsx files with polars and calamine
Power of Polars Library in data manipulation and analysis. Power of Polars Library in data manipulation and analysis.
@ -163,16 +165,6 @@ It is implemented as a singleton to ensure that only one SMTP connection is main
throughout the application lifecycle, optimizing resource usage. throughout the application lifecycle, optimizing resource usage.
<p align="right">(<a href="#readme-top">back to top</a>)</p>
### UV knowledge and inspirations
- https://docs.astral.sh/uv/
- https://hynek.me/articles/docker-uv/
- https://thedataquarry.com/posts/towards-a-unified-python-toolchain/
- https://www.youtube.com/watch?v=ifj-izwXKRA&t=760s > UV and Ruff: Next-gen Python Tooling
- https://www.youtube.com/watch?v=8UuW8o4bHbw&t=1s > uv IS the Future of Python Packaging! 🐍📦
<p align="right">(<a href="#readme-top">back to top</a>)</p> <p align="right">(<a href="#readme-top">back to top</a>)</p>
## Acknowledgments ## Acknowledgments
@ -214,7 +206,6 @@ I've included a few of my favorites to kick things off!
- **[OCT 16 2024]** apscheduler added to project :clock1: - **[OCT 16 2024]** apscheduler added to project :clock1:
- **[DEC 16 2024]** bump project to Python 3.13 :fast_forward: - **[DEC 16 2024]** bump project to Python 3.13 :fast_forward:
- **[JAN 28 2025]** add SMTP setup :email: - **[JAN 28 2025]** add SMTP setup :email:
- **[MAR 8 2025]** switch from poetry to uv :fast_forward:
<p align="right">(<a href="#readme-top">back to top</a>)</p> <p align="right">(<a href="#readme-top">back to top</a>)</p>
@ -234,19 +225,19 @@ I've included a few of my favorites to kick things off!
[linkedin-shield]: https://img.shields.io/badge/-LinkedIn-black.svg?style=for-the-badge&logo=linkedin&colorB=555 [linkedin-shield]: https://img.shields.io/badge/-LinkedIn-black.svg?style=for-the-badge&logo=linkedin&colorB=555
[linkedin-url]: https://www.linkedin.com/in/python-has-powers/ [linkedin-url]: https://www.linkedin.com/in/python-has-powers/
[fastapi.tiangolo.com]: https://img.shields.io/badge/FastAPI-0.115.11-009485?style=for-the-badge&logo=fastapi&logoColor=white [fastapi.tiangolo.com]: https://img.shields.io/badge/FastAPI-0.115.6-009485?style=for-the-badge&logo=fastapi&logoColor=white
[fastapi-url]: https://fastapi.tiangolo.com/ [fastapi-url]: https://fastapi.tiangolo.com/
[pydantic.com]: https://img.shields.io/badge/Pydantic-2.10.6-e92063?style=for-the-badge&logo=pydantic&logoColor=white [pydantic.com]: https://img.shields.io/badge/Pydantic-2.10.3-e92063?style=for-the-badge&logo=pydantic&logoColor=white
[pydantic-url]: https://docs.pydantic.dev/latest/ [pydantic-url]: https://docs.pydantic.dev/latest/
[sqlalchemy.org]: https://img.shields.io/badge/SQLAlchemy-2.0.38-bb0000?color=bb0000&style=for-the-badge [sqlalchemy.org]: https://img.shields.io/badge/SQLAlchemy-2.0.36-bb0000?color=bb0000&style=for-the-badge
[sqlalchemy-url]: https://docs.sqlalchemy.org/en/20/ [sqlalchemy-url]: https://docs.sqlalchemy.org/en/20/
[uvicorn.org]: https://img.shields.io/badge/Uvicorn-0.34.0-2094f3?style=for-the-badge&logo=uvicorn&logoColor=white [uvicorn.org]: https://img.shields.io/badge/Uvicorn-0.34.0-2094f3?style=for-the-badge&logo=uvicorn&logoColor=white
[uvicorn-url]: https://www.uvicorn.org/ [uvicorn-url]: https://www.uvicorn.org/
[asyncpg.github.io]: https://img.shields.io/badge/asyncpg-0.30.0-2e6fce?style=for-the-badge&logo=postgresql&logoColor=white [asyncpg.github.io]: https://img.shields.io/badge/asyncpg-0.30.0-2e6fce?style=for-the-badge&logo=postgresql&logoColor=white
[asyncpg-url]: https://magicstack.github.io/asyncpg/current/ [asyncpg-url]: https://magicstack.github.io/asyncpg/current/
[pytest.org]: https://img.shields.io/badge/pytest-8.3.5-fff?style=for-the-badge&logo=pytest&logoColor=white [pytest.org]: https://img.shields.io/badge/pytest-8.3.4-fff?style=for-the-badge&logo=pytest&logoColor=white
[pytest-url]: https://docs.pytest.org/en/6.2.x/ [pytest-url]: https://docs.pytest.org/en/6.2.x/
[alembic.sqlalchemy.org]: https://img.shields.io/badge/alembic-1.15.1-6BA81E?style=for-the-badge&logo=alembic&logoColor=white [alembic.sqlalchemy.org]: https://img.shields.io/badge/alembic-1.14.0-6BA81E?style=for-the-badge&logo=alembic&logoColor=white
[alembic-url]: https://alembic.sqlalchemy.org/en/latest/ [alembic-url]: https://alembic.sqlalchemy.org/en/latest/
[rich.readthedocs.io]: https://img.shields.io/badge/rich-13.9.4-009485?style=for-the-badge&logo=rich&logoColor=white [rich.readthedocs.io]: https://img.shields.io/badge/rich-13.9.4-009485?style=for-the-badge&logo=rich&logoColor=white
[rich-url]: https://rich.readthedocs.io/en/latest/ [rich-url]: https://rich.readthedocs.io/en/latest/

View File

@ -1,11 +1,12 @@
import logging import logging
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Query, Request, status from fastapi import APIRouter, status, Request, Depends, Query
from pydantic import EmailStr from pydantic import EmailStr
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
from app.services.smtp import SMTPEmailService from app.services.smtp import SMTPEmailService
from app.utils.logging import AppLogger from app.utils.logging import AppLogger
logger = AppLogger().get_logger() logger = AppLogger().get_logger()

View File

@ -1,8 +1,7 @@
import io import io
from fastapi import APIRouter, Depends, status, UploadFile, HTTPException
import polars as pl
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
import polars as pl
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db

View File

@ -3,6 +3,8 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from fastapi_cache.decorator import cache
from app.database import get_db from app.database import get_db
from app.models.shakespeare import Paragraph from app.models.shakespeare import Paragraph
@ -12,6 +14,7 @@ router = APIRouter(prefix="/v1/shakespeare")
@router.get( @router.get(
"/", "/",
) )
@cache(namespace="test-2", expire=60)
async def find_paragraph( async def find_paragraph(
character: Annotated[str, Query(description="Character name")], character: Annotated[str, Query(description="Character name")],
db_session: AsyncSession = Depends(get_db), db_session: AsyncSession = Depends(get_db),

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession

View File

@ -1,11 +1,11 @@
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status from fastapi import APIRouter, Depends, status, Request, HTTPException, Form
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.models.user import User from app.models.user import User
from app.schemas.user import TokenResponse, UserLogin, UserResponse, UserSchema from app.schemas.user import UserSchema, UserResponse, UserLogin, TokenResponse
from app.services.auth import create_access_token from app.services.auth import create_access_token
from app.utils.logging import AppLogger from app.utils.logging import AppLogger

View File

@ -1,6 +1,6 @@
import os import os
from pydantic import BaseModel, PostgresDsn, RedisDsn, computed_field from pydantic import PostgresDsn, RedisDsn, computed_field, BaseModel
from pydantic_core import MultiHostUrl from pydantic_core import MultiHostUrl
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict

View File

@ -1,6 +1,7 @@
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.config import settings as global_settings from app.config import settings as global_settings
from app.utils.logging import AppLogger from app.utils.logging import AppLogger

View File

@ -1,22 +1,25 @@
from contextlib import asynccontextmanager
import asyncpg import asyncpg
from apscheduler import AsyncScheduler
from apscheduler.datastores.sqlalchemy import SQLAlchemyDataStore
from apscheduler.eventbrokers.redis import RedisEventBroker from apscheduler.eventbrokers.redis import RedisEventBroker
from fastapi import Depends, FastAPI from apscheduler.datastores.sqlalchemy import SQLAlchemyDataStore
from fastapi import FastAPI, Depends
from fastapi_cache import FastAPICache
from fastapi_cache.backends.redis import RedisBackend
from app.api.health import router as health_router
from app.api.nonsense import router as nonsense_router from app.api.nonsense import router as nonsense_router
from app.api.shakespeare import router as shakespeare_router from app.api.shakespeare import router as shakespeare_router
from app.api.stuff import router as stuff_router from app.api.stuff import router as stuff_router
from app.api.user import router as user_router
from app.config import settings as global_settings from app.config import settings as global_settings
from app.database import engine from app.database import engine
from app.redis import get_redis from app.utils.logging import AppLogger
from app.api.user import router as user_router
from app.api.health import router as health_router
from app.redis import get_redis, get_cache
from app.services.auth import AuthBearer from app.services.auth import AuthBearer
from app.services.scheduler import SchedulerMiddleware from app.services.scheduler import SchedulerMiddleware
from app.utils.logging import AppLogger
from contextlib import asynccontextmanager
from apscheduler import AsyncScheduler
logger = AppLogger().get_logger() logger = AppLogger().get_logger()
@ -29,7 +32,10 @@ async def lifespan(_app: FastAPI):
_postgres_dsn = global_settings.postgres_url.unicode_string() _postgres_dsn = global_settings.postgres_url.unicode_string()
try: try:
# TODO: cache with the redis connection # Initialize the cache with the redis connection
redis_cache = await get_cache()
FastAPICache.init(RedisBackend(redis_cache), prefix="fastapi-cache")
# logger.info(FastAPICache.get_cache_status_header())
# Initialize the postgres connection pool # Initialize the postgres connection pool
_app.postgres_pool = await asyncpg.create_pool( _app.postgres_pool = await asyncpg.create_pool(
dsn=_postgres_dsn, dsn=_postgres_dsn,

View File

@ -2,10 +2,9 @@ from typing import Any
from asyncpg import UniqueViolationError from asyncpg import UniqueViolationError
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy.exc import IntegrityError, SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError, IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import DeclarativeBase, declared_attr from sqlalchemy.orm import declared_attr, DeclarativeBase
from app.utils.logging import AppLogger from app.utils.logging import AppLogger
logger = AppLogger().get_logger() logger = AppLogger().get_logger()

View File

@ -4,7 +4,7 @@ from fastapi import HTTPException, status
from sqlalchemy import String, select from sqlalchemy import String, select
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import mapped_column, Mapped
from app.models.base import Base from app.models.base import Base

View File

@ -11,7 +11,6 @@ from sqlalchemy import (
) )
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base from app.models.base import Base

View File

@ -1,9 +1,9 @@
import uuid import uuid
from sqlalchemy import ForeignKey, String, select from sqlalchemy import String, select, ForeignKey
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, joinedload, mapped_column, relationship from sqlalchemy.orm import mapped_column, Mapped, relationship, joinedload
from app.models.base import Base from app.models.base import Base
from app.models.nonsense import Nonsense from app.models.nonsense import Nonsense

View File

@ -3,10 +3,10 @@ from typing import Any
import bcrypt import bcrypt
from pydantic import SecretStr from pydantic import SecretStr
from sqlalchemy import Column, LargeBinary, String, select from sqlalchemy import String, LargeBinary, select, Column
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import mapped_column, Mapped
from app.models.base import Base from app.models.base import Base

View File

@ -1,6 +1,6 @@
from uuid import UUID from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, Field, ConfigDict
config = ConfigDict(from_attributes=True) config = ConfigDict(from_attributes=True)

View File

@ -1,6 +1,6 @@
from uuid import UUID from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, Field, ConfigDict
config = ConfigDict(from_attributes=True) config = ConfigDict(from_attributes=True)

View File

@ -1,6 +1,6 @@
from uuid import UUID from uuid import UUID
from pydantic import BaseModel, ConfigDict, EmailStr, Field, SecretStr from pydantic import BaseModel, Field, EmailStr, ConfigDict, SecretStr
config = ConfigDict(from_attributes=True) config = ConfigDict(from_attributes=True)

View File

@ -1,11 +1,11 @@
import time import time
import jwt import jwt
from fastapi import HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.config import settings as global_settings from app.config import settings as global_settings
from app.models.user import User from app.models.user import User
from fastapi import Request, HTTPException
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from app.utils.logging import AppLogger from app.utils.logging import AppLogger
logger = AppLogger().get_logger() logger = AppLogger().get_logger()

View File

@ -1,10 +1,11 @@
from datetime import datetime from datetime import datetime
from apscheduler import AsyncScheduler
from apscheduler.triggers.interval import IntervalTrigger
from attrs import define from attrs import define
from sqlalchemy import text from sqlalchemy import text
from starlette.types import ASGIApp, Receive, Scope, Send from starlette.types import ASGIApp, Receive, Scope, Send
from apscheduler import AsyncScheduler
from apscheduler.triggers.interval import IntervalTrigger
from app.database import AsyncSessionFactory from app.database import AsyncSessionFactory
from app.utils.logging import AppLogger from app.utils.logging import AppLogger

View File

@ -1,15 +1,18 @@
from attrs import define, field
import smtplib import smtplib
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from attrs import define, field from app.config import settings as global_settings
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from pydantic import EmailStr from pydantic import EmailStr
from app.config import settings as global_settings
from app.utils.logging import AppLogger from app.utils.logging import AppLogger
from app.utils.singleton import SingletonMetaNoArgs from app.utils.singleton import SingletonMetaNoArgs
logger = AppLogger().get_logger() logger = AppLogger().get_logger()

View File

@ -1,7 +1,7 @@
from functools import wraps
from sqlalchemy.dialects import postgresql from sqlalchemy.dialects import postgresql
from functools import wraps
def compile_sql_or_scalar(func): def compile_sql_or_scalar(func):
""" """

View File

@ -3,6 +3,7 @@ import logging
from rich.console import Console from rich.console import Console
from rich.logging import RichHandler from rich.logging import RichHandler
from app.utils.singleton import SingletonMeta from app.utils.singleton import SingletonMeta
@ -20,5 +21,5 @@ class RichConsoleHandler(RichHandler):
def __init__(self, width=200, style=None, **kwargs): def __init__(self, width=200, style=None, **kwargs):
super().__init__( super().__init__(
console=Console(color_system="256", width=width, style=style, stderr=True), console=Console(color_system="256", width=width, style=style, stderr=True),
**kwargs, **kwargs
) )

View File

@ -1,5 +1,5 @@
# pull official base image # pull official base image
FROM postgres:17.4-alpine FROM postgres:16-alpine
# run create.sql on init # run create.sql on init
ADD create.sql /docker-entrypoint-initdb.d ADD create.sql /docker-entrypoint-initdb.d

View File

@ -1,4 +1,4 @@
from locust import HttpUser, between, task from locust import HttpUser, task, between
class Stuff(HttpUser): class Stuff(HttpUser):

4288
poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,90 +0,0 @@
[tool.poetry]
name = "fastapi-sqlalchemy-asyncpg"
version = "0.0.17"
description = ""
authors = ["Jakub Miazek <the@grillazz.com>"]
packages = []
license = "MIT"
package-mode = false
[tool.poetry.dependencies]
python = "^3.13"
fastapi = {version = "^0.115.6", extras = ["all"]}
pydantic = {version = "^2.10.3", extras = ["email"]}
pydantic-settings = "^2.7.0"
sqlalchemy = "^2.0.36"
uvicorn = { version = "^0.34.0", extras = ["standard"]}
asyncpg = "^0.30.0"
alembic = "^1.14.0"
httpx = "^0.28.1"
pytest = "^8.3.4"
pytest-cov = "^6.0.0"
uvloop = "^0.21.0"
httptools = "^0.6.4"
rich = "^13.9.4"
pyjwt = {version = "^2.10.1", extras = ["cryptography"]}
redis = "^5.2.1"
bcrypt = "^4.2.1"
polars = "^1.17.1"
python-multipart = "^0.0.20"
fastexcel = "^0.12.0"
fastapi-cache2 = "^0.2.1"
inline-snapshot = "^0.17.0"
dirty-equals = "^0.8.0"
polyfactory = "^2.18.1"
granian = "^1.7.0"
apscheduler = {version = "^4.0.0a5", extras = ["redis,sqlalchemy"]}
pendulum = {git = "https://github.com/sdispater/pendulum.git", rev="develop"}
[tool.poetry.group.dev.dependencies]
devtools = { extras = ["pygments"], version = "^0.12.2" }
safety = "*"
pyupgrade = "*"
ipython = "^8.26.0"
ruff = "^0.6.1"
sqlacodegen = "^3.0.0rc5"
tryceratops = "^2.3.3"
locust = "^2.31.3"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.ruff]
line-length = 120
indent-width = 4
lint.select = ["E", "F", "UP", "N", "C", "B"]
lint.ignore = ["E501"]
# Exclude a variety of commonly ignored directories.
exclude = ["alembic",]
# Assume Python 3.13
target-version = "py313"
[tool.ruff.lint.flake8-quotes]
docstring-quotes = "double"
[tool.ruff.lint.flake8-bugbear]
extend-immutable-calls = ["fastapi.Depends",]
[tool.pytest.ini_options]
addopts = "-v --doctest-modules --doctest-glob=*.md --ignore=alembic"
asyncio_mode = "strict"
env_files = [".env"]
[tool.tryceratops]
exclude = ["alembic",]
[tool.ruff.format]
# Like Black, use double quotes for strings.
quote-style = "double"
# Like Black, indent with spaces, rather than tabs.
indent-style = "space"
# Like Black, respect magic trailing commas.
skip-magic-trailing-comma = false
# Like Black, automatically detect the appropriate line ending.
line-ending = "auto"

View File

@ -1,75 +1,90 @@
[project] [tool.poetry]
name = "fastapi-sqlalchemy-asyncpg" name = "fastapi-sqlalchemy-asyncpg"
version = "0.1.0" version = "0.0.17"
description = "A modern FastAPI application with SQLAlchemy 2.0 and AsyncPG for high-performance async database operations. Features include JWT authentication with Redis token storage, password hashing, connection pooling, data processing with Polars, Rich logging, task scheduling with APScheduler, and Shakespeare datasets integration." description = ""
readme = "README.md" authors = ["Jakub Miazek <the@grillazz.com>"]
requires-python = ">=3.13" packages = []
dependencies = [ license = "MIT"
"fastapi[all]>=0.115.11", package-mode = false
"pydantic[email]>=2.10.6",
"pydantic-settings>=2.8.1",
"sqlalchemy>=2.0.38",
"uvicorn[standard]>=0.34.0",
"asyncpg>=0.30.0",
"alembic>=1.15.1",
"httpx>=0.28.1",
"pytest>=8.3.5",
"pytest-cov>=6.0.0",
"uvloop>=0.21.0",
"httptools>=0.6.4",
"rich>=13.9.4",
"pyjwt>=2.10.1",
"redis>=5.2.1",
"bcrypt>=4.3.0",
"polars>=1.24.0",
"python-multipart>=0.0.20",
"fastexcel>=0.13.0",
"inline-snapshot>=0.17.0",
"dirty-equals>=0.8.0",
"polyfactory>=2.18.1",
"granian>=1.7.0",
"apscheduler[redis,sqlalchemy]>=4.0.0a5",
]
[tool.uv] [tool.poetry.dependencies]
dev-dependencies = [ python = "^3.13"
"ruff>=0.9.10", fastapi = {version = "^0.115.6", extras = ["all"]}
"devtools[pygments]>=0.12.2", pydantic = {version = "^2.10.3", extras = ["email"]}
"pyupgrade>=3.19.1", pydantic-settings = "^2.7.0"
"ipython>=9.0.2", sqlalchemy = "^2.0.36"
"sqlacodegen>=3.0.0", uvicorn = { version = "^0.34.0", extras = ["standard"]}
"tryceratops>=2.4.1", asyncpg = "^0.30.0"
"locust>=2.33.0" alembic = "^1.14.0"
httpx = "^0.28.1"
pytest = "^8.3.4"
pytest-cov = "^6.0.0"
uvloop = "^0.21.0"
httptools = "^0.6.4"
rich = "^13.9.4"
pyjwt = {version = "^2.10.1", extras = ["cryptography"]}
redis = "^5.2.1"
bcrypt = "^4.2.1"
polars = "^1.17.1"
python-multipart = "^0.0.20"
fastexcel = "^0.12.0"
fastapi-cache2 = "^0.2.1"
inline-snapshot = "^0.17.0"
dirty-equals = "^0.8.0"
polyfactory = "^2.18.1"
granian = "^1.7.0"
apscheduler = {version = "^4.0.0a5", extras = ["redis,sqlalchemy"]}
pendulum = {git = "https://github.com/sdispater/pendulum.git", rev="develop"}
] [tool.poetry.group.dev.dependencies]
devtools = { extras = ["pygments"], version = "^0.12.2" }
safety = "*"
pyupgrade = "*"
ipython = "^8.26.0"
ruff = "^0.6.1"
sqlacodegen = "^3.0.0rc5"
tryceratops = "^2.3.3"
locust = "^2.31.3"
[build-system]
[tool.mypy] requires = ["poetry-core>=1.0.0"]
strict = true build-backend = "poetry.core.masonry.api"
exclude = ["venv", ".venv", "alembic"]
[tool.ruff] [tool.ruff]
line-length = 120
indent-width = 4
lint.select = ["E", "F", "UP", "N", "C", "B"]
lint.ignore = ["E501"]
# Exclude a variety of commonly ignored directories.
exclude = ["alembic",]
# Assume Python 3.13
target-version = "py313" target-version = "py313"
exclude = ["alembic"]
[tool.ruff.lint] [tool.ruff.lint.flake8-quotes]
select = [ docstring-quotes = "double"
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
"ARG001", # unused arguments in functions
]
ignore = [
"E501", # line too long, handled by black
"B008", # do not perform function calls in argument defaults
"W191", # indentation contains tabs
"B904", # Allow raising exceptions without from e, for HTTPException
]
[tool.ruff.lint.pyupgrade] [tool.ruff.lint.flake8-bugbear]
# Preserve types, even if a file imports `from __future__ import annotations`. extend-immutable-calls = ["fastapi.Depends",]
keep-runtime-typing = true
[tool.pytest.ini_options]
addopts = "-v --doctest-modules --doctest-glob=*.md --ignore=alembic"
asyncio_mode = "strict"
env_files = [".env"]
[tool.tryceratops]
exclude = ["alembic",]
[tool.ruff.format]
# Like Black, use double quotes for strings.
quote-style = "double"
# Like Black, indent with spaces, rather than tabs.
indent-style = "space"
# Like Black, respect magic trailing commas.
skip-magic-trailing-comma = false
# Like Black, automatically detect the appropriate line ending.
line-ending = "auto"

View File

@ -1,9 +1,9 @@
import jwt
import pytest import pytest
from dirty_equals import IsPositiveFloat, IsStr, IsUUID
from httpx import AsyncClient from httpx import AsyncClient
from inline_snapshot import snapshot
from starlette import status from starlette import status
import jwt
from inline_snapshot import snapshot
from dirty_equals import IsStr, IsUUID, IsPositiveFloat
pytestmark = pytest.mark.anyio pytestmark = pytest.mark.anyio

View File

@ -1,5 +1,6 @@
import pytest
from anyio import Path from anyio import Path
import pytest
from fastapi import status from fastapi import status
from httpx import AsyncClient from httpx import AsyncClient

View File

@ -1,8 +1,9 @@
import pytest import pytest
from dirty_equals import IsUUID
from fastapi import status from fastapi import status
from httpx import AsyncClient from httpx import AsyncClient
from inline_snapshot import snapshot from inline_snapshot import snapshot
from dirty_equals import IsUUID
from polyfactory.factories.pydantic_factory import ModelFactory from polyfactory.factories.pydantic_factory import ModelFactory
from app.schemas.stuff import StuffSchema from app.schemas.stuff import StuffSchema

View File

@ -1,8 +1,5 @@
from collections.abc import AsyncGenerator
from typing import Any
import pytest import pytest
from httpx import ASGITransport, AsyncClient from httpx import AsyncClient, ASGITransport
from app.database import engine from app.database import engine
from app.main import app from app.main import app
@ -31,11 +28,13 @@ async def start_db():
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
async def client(start_db) -> AsyncGenerator[AsyncClient, Any]: # noqa: ARG001 async def client(start_db) -> AsyncClient:
transport = ASGITransport( transport = ASGITransport(
app=app, app=app,
) )
async with AsyncClient( async with AsyncClient(
# app=app,
base_url="http://testserver/v1", base_url="http://testserver/v1",
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json"},
transport=transport, transport=transport,

1742
uv.lock generated

File diff suppressed because it is too large Load Diff