diff options
| -rw-r--r-- | Makefile | 3 | ||||
| -rw-r--r-- | docker-compose.yml | 5 | ||||
| -rw-r--r-- | pyproject.toml | 5 | ||||
| -rw-r--r-- | thallium-backend/.dockerignore | 2 | ||||
| -rw-r--r-- | thallium-backend/Dockerfile | 12 | ||||
| -rw-r--r-- | thallium-backend/alembic.ini | 53 | ||||
| -rw-r--r-- | thallium-backend/migrations/__init__.py | 0 | ||||
| -rw-r--r-- | thallium-backend/migrations/env.py | 86 | ||||
| -rw-r--r-- | thallium-backend/migrations/script.py.mako | 27 | ||||
| -rw-r--r-- | thallium-backend/migrations/versions/__init__.py | 0 | ||||
| -rw-r--r-- | thallium-backend/src/__init__.py | 6 | ||||
| -rw-r--r-- | thallium-backend/src/orm/__init__.py | 12 | ||||
| -rw-r--r-- | thallium-backend/src/orm/base.py | 44 | ||||
| -rw-r--r-- | thallium-backend/src/settings.py | 43 |
14 files changed, 289 insertions, 9 deletions
@@ -27,3 +27,6 @@ test: retest: pytest -n 4 --lf + +revision: + cd thallium-backend && poetry run alembic revision --autogenerate -m CHANGEME diff --git a/docker-compose.yml b/docker-compose.yml index b21ec04..a82221f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,11 +17,14 @@ services: thallium: build: thallium-backend restart: unless-stopped - command: ["src.app:fastapi_app", "--host", "0.0.0.0", "--port", "80", "--reload"] + command: ["alembic upgrade head && uvicorn src.app:fastapi_app --host 0.0.0.0 --port 8000 --reload"] volumes: - ./thallium-backend/src:/thallium/src:ro env_file: - .env + environment: + BACKEND_DATABASE_URL: postgresql+psycopg_async://thallium:thallium@postgres:5432/thallium + BACKEND_TOKEN: suitable-for-development-only ports: - "8000:80" depends_on: diff --git a/pyproject.toml b/pyproject.toml index 454bd19..7381adf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,13 +54,14 @@ ignore = [ ] [tool.ruff.lint.isort] -known-first-party = ["tests", "src"] +known-first-party = ["tests", "src", "migrations"] order-by-type = false case-sensitive = true combine-as-imports = true [tool.ruff.lint.per-file-ignores] -"*/tests/**" = ["D103", "S101"] +"thallium-backend/tests/**" = ["D103", "S101"] +"thallium-backend/migrations/*" = ["N999"] [tool.pytest.ini_options] # addopts = "--ignore=examples" diff --git a/thallium-backend/.dockerignore b/thallium-backend/.dockerignore index 19d948e..de65985 100644 --- a/thallium-backend/.dockerignore +++ b/thallium-backend/.dockerignore @@ -2,6 +2,8 @@ ** # Except what we need +!alembic.ini +!migrations !src !requirements.txt !LICENSE diff --git a/thallium-backend/Dockerfile b/thallium-backend/Dockerfile index 7d7c46e..f8a4d2b 100644 --- a/thallium-backend/Dockerfile +++ b/thallium-backend/Dockerfile @@ -1,9 +1,13 @@ -FROM --platform=linux/amd64 python:3.12-slim +FROM python:3.12-slim # Define Git SHA build argument for sentry ARG git_sha="development" ENV GIT_SHA=$git_sha +RUN apt update -y \ + && apt install -y curl \ + && rm -rf /var/lib/apt/lists/* + # Install project dependencies WORKDIR /thallium COPY requirements.txt ./ @@ -12,5 +16,7 @@ RUN pip install -r requirements.txt # Copy the source code in last to optimize rebuilding the image COPY . . -ENTRYPOINT ["uvicorn"] -CMD ["src.app:fastapi_app", "--host", "0.0.0.0", "--port", "80"] +HEALTHCHECK --start-period=5s --interval=30s --timeout=1s CMD curl http://localhost/heartbeat || exit 1 + +ENTRYPOINT ["/bin/bash", "-c"] +CMD ["alembic upgrade head && uvicorn src.app:fastapi_app --host 0.0.0.0 --port 8000"] diff --git a/thallium-backend/alembic.ini b/thallium-backend/alembic.ini new file mode 100644 index 0000000..89c7735 --- /dev/null +++ b/thallium-backend/alembic.ini @@ -0,0 +1,53 @@ +# A generic, single database configuration. + +[alembic] +script_location = migrations +file_template = %%(epoch)s-%%(rev)s_%%(slug)s +prepend_sys_path = . +timezone = utc +version_path_separator = os +output_encoding = utf-8 + +[post_write_hooks] +hooks = ruff-lint, ruff-format +ruff-lint.type = exec +ruff-lint.executable = ruff +ruff-lint.options = check --fix-only REVISION_SCRIPT_FILENAME + +ruff-format.type = exec +ruff-format.executable = ruff +ruff-format.options = format REVISION_SCRIPT_FILENAME + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/thallium-backend/migrations/__init__.py b/thallium-backend/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/thallium-backend/migrations/__init__.py diff --git a/thallium-backend/migrations/env.py b/thallium-backend/migrations/env.py new file mode 100644 index 0000000..42febb0 --- /dev/null +++ b/thallium-backend/migrations/env.py @@ -0,0 +1,86 @@ +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool +from sqlalchemy.ext.asyncio import AsyncEngine +from sqlalchemy.ext.asyncio.engine import AsyncConnection + +# This is a required step by Alembic to properly generate migrations +import src.orm +from src.settings import CONFIG + +target_metadata = src.orm.base.Base.metadata + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +config.set_main_option("sqlalchemy.url", CONFIG.database_url.get_secret_value()) + + +def run_migrations_offline() -> None: + """ + Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: AsyncConnection) -> None: + """Run all migrations on the given connection.""" + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + compare_server_default=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + """ + Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + """ + connectable = AsyncEngine( + engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + future=True, + ), + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) diff --git a/thallium-backend/migrations/script.py.mako b/thallium-backend/migrations/script.py.mako new file mode 100644 index 0000000..1d3e1aa --- /dev/null +++ b/thallium-backend/migrations/script.py.mako @@ -0,0 +1,27 @@ +""" +${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +import sqlalchemy as sa +from alembic import op + +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + """Apply this migration.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Revert this migration.""" + ${downgrades if downgrades else "pass"} diff --git a/thallium-backend/migrations/versions/__init__.py b/thallium-backend/migrations/versions/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/thallium-backend/migrations/versions/__init__.py diff --git a/thallium-backend/src/__init__.py b/thallium-backend/src/__init__.py index 87f0d37..a2436a2 100644 --- a/thallium-backend/src/__init__.py +++ b/thallium-backend/src/__init__.py @@ -1,7 +1,13 @@ +import asyncio import logging +import os from src.settings import CONFIG +# On Windows, the selector event loop is required for aiodns. +if os.name == "nt": + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + # Console handler prints to terminal console_handler = logging.StreamHandler() level = logging.DEBUG if CONFIG.debug else logging.INFO diff --git a/thallium-backend/src/orm/__init__.py b/thallium-backend/src/orm/__init__.py new file mode 100644 index 0000000..ed803e8 --- /dev/null +++ b/thallium-backend/src/orm/__init__.py @@ -0,0 +1,12 @@ +"""Database models.""" + +from .base import AuditBase, Base +from .products import Product +from .users import User + +__all__ = ( + "AuditBase", + "Base", + "Product", + "User", +) diff --git a/thallium-backend/src/orm/base.py b/thallium-backend/src/orm/base.py new file mode 100644 index 0000000..a1642c7 --- /dev/null +++ b/thallium-backend/src/orm/base.py @@ -0,0 +1,44 @@ +"""The base classes for ORM models.""" + +import re +from datetime import datetime +from uuid import UUID, uuid4 + +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncAttrs +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from sqlalchemy.schema import MetaData +from sqlalchemy.sql import func +from sqlalchemy.types import DateTime + +NAMING_CONVENTIONS = { + "ix": "%(column_0_label)s_ix", + "uq": "%(table_name)s_%(column_0_name)s_uq", + "ck": "%(table_name)s_%(constraint_name)s_ck", + "fk": "%(table_name)s_%(column_0_name)s_%(referred_table_name)s_fk", + "pk": "%(table_name)s_pk", +} +table_name_regexp = re.compile("((?<=[a-z0-9])[A-Z]|(?!^)[A-Z](?=[a-z]))") + + +class Base(AsyncAttrs, DeclarativeBase): + """Classes that inherit this class will be automatically mapped using declarative mapping.""" + + metadata = MetaData(naming_convention=NAMING_CONVENTIONS) + + def patch_from_pydantic(self, pydantic_model: BaseModel) -> None: + """Patch this model using the given pydantic model, unspecified attributes remain the same.""" + for key, value in pydantic_model.model_dump(exclude_unset=True).items(): + setattr(self, key, value) + + +class AuditBase: + """Common columns for a table with UUID PK and datetime audit columns.""" + + id: Mapped[UUID] = mapped_column(default=uuid4, primary_key=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + ) diff --git a/thallium-backend/src/settings.py b/thallium-backend/src/settings.py index 2f9f6c1..931fc0d 100644 --- a/thallium-backend/src/settings.py +++ b/thallium-backend/src/settings.py @@ -1,9 +1,46 @@ -from pydantic_settings import BaseSettings +import typing +from collections.abc import AsyncGenerator +from logging import getLogger +import pydantic +import pydantic_settings +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +log = getLogger(__name__) + + +class _Config( + pydantic_settings.BaseSettings, + env_prefix="backend_", + env_file=".env", + env_file_encoding="utf-8", + env_nested_delimiter="__", + extra="ignore", +): + """General configuration settings for the service.""" -class _CONFIG(BaseSettings, env_file=".env", env_file_encoding="utf-8"): debug: bool = False git_sha: str = "development" + database_url: pydantic.SecretStr + token: pydantic.SecretStr + + +CONFIG = _Config() + + +class Connections: + """How to connect to other, internal services.""" + + DB_ENGINE = create_async_engine(CONFIG.database_url.get_secret_value(), echo=CONFIG.debug) + DB_SESSION_MAKER = async_sessionmaker(DB_ENGINE) + + +async def _get_db_session() -> AsyncGenerator[AsyncSession, None]: + """Yield a database session, for use with a FastAPI dependency.""" + async with Connections.DB_SESSION_MAKER() as session, session.begin(): + yield session + -CONFIG = _CONFIG() +DBSession = typing.Annotated[AsyncSession, Depends(_get_db_session)] |