Skip to content

Commit

Permalink
Add a database backend and migrations
Browse files Browse the repository at this point in the history
  • Loading branch information
ChrisLovering committed Aug 16, 2024
1 parent 493ba9a commit 61d6174
Show file tree
Hide file tree
Showing 14 changed files with 289 additions and 9 deletions.
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ test:

retest:
pytest -n 4 --lf

revision:
cd thallium-backend && poetry run alembic revision --autogenerate -m CHANGEME
5 changes: 4 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions thallium-backend/.dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
**

# Except what we need
!alembic.ini
!migrations
!src
!requirements.txt
!LICENSE
12 changes: 9 additions & 3 deletions thallium-backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -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 ./
Expand All @@ -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"]
53 changes: 53 additions & 0 deletions thallium-backend/alembic.ini
Original file line number Diff line number Diff line change
@@ -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
Empty file.
86 changes: 86 additions & 0 deletions thallium-backend/migrations/env.py
Original file line number Diff line number Diff line change
@@ -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())
27 changes: 27 additions & 0 deletions thallium-backend/migrations/script.py.mako
Original file line number Diff line number Diff line change
@@ -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"}
Empty file.
6 changes: 6 additions & 0 deletions thallium-backend/src/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
12 changes: 12 additions & 0 deletions thallium-backend/src/orm/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Database models."""

from .base import AuditBase, Base
from .products import Product
from .users import User

__all__ = (
"AuditBase",
"Base",
"Product",
"User",
)
44 changes: 44 additions & 0 deletions thallium-backend/src/orm/base.py
Original file line number Diff line number Diff line change
@@ -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:
"""Base for declarative models with UUID primary keys and 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(),
)
43 changes: 40 additions & 3 deletions thallium-backend/src/settings.py
Original file line number Diff line number Diff line change
@@ -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)]

0 comments on commit 61d6174

Please sign in to comment.