From 4a6fe18698bcd3ad177ab2115dd881f1a37d66eb Mon Sep 17 00:00:00 2001 From: marius-mather Date: Mon, 30 Jun 2025 11:36:28 +1000 Subject: [PATCH 01/19] Add sqlmodel dependency --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e346e930..0729de58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,8 @@ dependencies = [ "fastapi[standard]>=0.115.12", "httpx>=0.28.1", "pydantic-settings>=2.8.1", - "python-jose>=3.4.0" + "python-jose>=3.4.0", + "sqlmodel>=0.0.24", ] [project.optional-dependencies] From 61b4e14a01ea7030e55316a004a85e2dd2d7d73c Mon Sep 17 00:00:00 2001 From: marius-mather Date: Mon, 30 Jun 2025 11:37:11 +1000 Subject: [PATCH 02/19] Initial DB setup code --- db/__init__.py | 0 db/setup.py | 21 +++++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 db/__init__.py create mode 100644 db/setup.py diff --git a/db/__init__.py b/db/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/db/setup.py b/db/setup.py new file mode 100644 index 00000000..888fed7d --- /dev/null +++ b/db/setup.py @@ -0,0 +1,21 @@ +from dotenv import dotenv_values +from sqlmodel import Session, SQLModel, create_engine + +# Read DB_URL from .env +# Doing this separately from pydantic-settings as we +# need this before loading the FastAPI app +env_values = dotenv_values(".env") +# Fall back to in-memory SQLite if no DB in env file +DB_URL = env_values.get("DB_URL", "sqlite://") + +connect_args = {"check_same_thread": False} +engine = create_engine(DB_URL, connect_args=connect_args) + + +def create_db_and_tables(): + SQLModel.metadata.create_all(engine) + + +def get_db_session(): + with Session(engine) as session: + yield session From f36527d595bae9522fe5520c0b7d5c2d13c51baa Mon Sep 17 00:00:00 2001 From: marius-mather Date: Mon, 30 Jun 2025 11:37:56 +1000 Subject: [PATCH 03/19] GroupMembership model --- db/models.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 db/models.py diff --git a/db/models.py b/db/models.py new file mode 100644 index 00000000..8ea30542 --- /dev/null +++ b/db/models.py @@ -0,0 +1,22 @@ +import uuid +from datetime import datetime, timezone +from typing import Literal + +from pydantic import AwareDatetime +from sqlmodel import AutoString, DateTime, Field, SQLModel + +ApprovalStatus = Literal["approved", "pending", "revoked"] + + +class GroupMembership(SQLModel, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + # TODO: May want to constrain the types of strings + # given they have a specific format + # TODO: May want to make group and/or user_id indexes? + group: str + user_id: str + user_email: str + approval_status: ApprovalStatus = Field(sa_type=AutoString) + updated_at: AwareDatetime = Field(default_factory=lambda: datetime.now(timezone.utc), sa_type=DateTime) + updated_by_id: str + updated_by_email: str From 96d2354ad97a23f317694646552bc5b6b648fa8f Mon Sep 17 00:00:00 2001 From: marius-mather Date: Mon, 30 Jun 2025 11:42:13 +1000 Subject: [PATCH 04/19] Create DB in app startup --- main.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index c503aa59..120ddcee 100644 --- a/main.py +++ b/main.py @@ -1,8 +1,12 @@ +from contextlib import asynccontextmanager from dotenv import dotenv_values from fastapi import FastAPI from starlette.middleware.cors import CORSMiddleware +# This has to be imported even if unused +from db import models # noqa: F401 +from db.setup import create_db_and_tables from routers import admin, bpa_register, galaxy_register, user, utils # Load .env to get CORS_ALLOWED_ORIGINS. @@ -14,7 +18,13 @@ origin.strip() for origin in env_values.get("CORS_ALLOWED_ORIGINS", "").split(",") ] -app = FastAPI() + +@asynccontextmanager +async def lifespan(app: FastAPI): + create_db_and_tables() + yield + +app = FastAPI(lifespan=lifespan) app.add_middleware( CORSMiddleware, allow_origins=ALLOWED_ORIGINS, From a05252314e3879a4afadb4df9ca7aa7905918a60 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Mon, 30 Jun 2025 11:45:32 +1000 Subject: [PATCH 05/19] Add test of GroupMembership model --- tests/db/__init__.py | 0 tests/db/test_models.py | 27 +++++++++++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 tests/db/__init__.py create mode 100644 tests/db/test_models.py diff --git a/tests/db/__init__.py b/tests/db/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/db/test_models.py b/tests/db/test_models.py new file mode 100644 index 00000000..fd06cf55 --- /dev/null +++ b/tests/db/test_models.py @@ -0,0 +1,27 @@ +from datetime import datetime, timezone + +from mimesis import Person +from mimesis.locales import Locale + +from db.models import GroupMembership +from tests.datagen import random_auth0_id + + +def test_create_group_membership(session): + user = Person(locale=Locale("en")) + user_id = random_auth0_id() + updater = Person(locale=Locale("en")) + updater_id = random_auth0_id() + group = GroupMembership( + group="tsi", + user_id=user_id, + user_email=user.email(), + approval_status="pending", + updated_at=datetime.now(tz=timezone.utc), + updated_by_id=updater_id, + updated_by_email=updater.email(), + ) + session.add(group) + session.commit() + session.refresh(group) + assert group.group == "tsi" From 5a06b94b896e0ce9e07ae7daebfc9d1e17aff132 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Mon, 30 Jun 2025 11:46:46 +1000 Subject: [PATCH 06/19] Add mimesis for fake data generation --- pyproject.toml | 3 ++- uv.lock | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0729de58..16fac77c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,8 @@ dev = [ "polyfactory>=2.21.0", "pre-commit>=3.7.0", "freezegun>=1.5.2", - "respx>=0.22.0" + "respx>=0.22.0", + "mimesis~=18.0" ] [tool.pytest.ini_options] diff --git a/uv.lock b/uv.lock index 18fda6bd..1d945d17 100644 --- a/uv.lock +++ b/uv.lock @@ -11,11 +11,13 @@ dependencies = [ { name = "httpx" }, { name = "pydantic-settings" }, { name = "python-jose" }, + { name = "sqlmodel" }, ] [package.optional-dependencies] dev = [ { name = "freezegun" }, + { name = "mimesis" }, { name = "polyfactory" }, { name = "pre-commit" }, { name = "pytest" }, @@ -30,6 +32,7 @@ requires-dist = [ { name = "fastapi", extras = ["standard"], specifier = ">=0.115.12" }, { name = "freezegun", marker = "extra == 'dev'", specifier = ">=1.5.2" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "mimesis", marker = "extra == 'dev'", specifier = "~=18.0" }, { name = "polyfactory", marker = "extra == 'dev'", specifier = ">=2.21.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.7.0" }, { name = "pydantic-settings", specifier = ">=2.8.1" }, @@ -39,6 +42,7 @@ requires-dist = [ { name = "python-jose", specifier = ">=3.4.0" }, { name = "respx", marker = "extra == 'dev'", specifier = ">=0.22.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.4" }, + { name = "sqlmodel", specifier = ">=0.0.24" }, ] provides-extras = ["dev"] @@ -253,6 +257,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/b2/68d4c9b6431121b6b6aa5e04a153cac41dcacc79600ed6e2e7c3382156f5/freezegun-1.5.2-py3-none-any.whl", hash = "sha256:5aaf3ba229cda57afab5bd311f0108d86b6fb119ae89d2cd9c43ec8c1733c85b", size = 18715, upload-time = "2025-05-24T12:38:45.274Z" }, ] +[[package]] +name = "greenlet" +version = "3.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/92/bb85bd6e80148a4d2e0c59f7c0c2891029f8fd510183afc7d8d2feeed9b6/greenlet-3.2.3.tar.gz", hash = "sha256:8b0dd8ae4c0d6f5e54ee55ba935eeb3d735a9b58a8a1e5b5cbab64e01a39f365", size = 185752, upload-time = "2025-06-05T16:16:09.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/cf/f5c0b23309070ae93de75c90d29300751a5aacefc0a3ed1b1d8edb28f08b/greenlet-3.2.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:500b8689aa9dd1ab26872a34084503aeddefcb438e2e7317b89b11eaea1901ad", size = 270732, upload-time = "2025-06-05T16:10:08.26Z" }, + { url = "https://files.pythonhosted.org/packages/48/ae/91a957ba60482d3fecf9be49bc3948f341d706b52ddb9d83a70d42abd498/greenlet-3.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a07d3472c2a93117af3b0136f246b2833fdc0b542d4a9799ae5f41c28323faef", size = 639033, upload-time = "2025-06-05T16:38:53.983Z" }, + { url = "https://files.pythonhosted.org/packages/6f/df/20ffa66dd5a7a7beffa6451bdb7400d66251374ab40b99981478c69a67a8/greenlet-3.2.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:8704b3768d2f51150626962f4b9a9e4a17d2e37c8a8d9867bbd9fa4eb938d3b3", size = 652999, upload-time = "2025-06-05T16:41:37.89Z" }, + { url = "https://files.pythonhosted.org/packages/51/b4/ebb2c8cb41e521f1d72bf0465f2f9a2fd803f674a88db228887e6847077e/greenlet-3.2.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5035d77a27b7c62db6cf41cf786cfe2242644a7a337a0e155c80960598baab95", size = 647368, upload-time = "2025-06-05T16:48:21.467Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6a/1e1b5aa10dced4ae876a322155705257748108b7fd2e4fae3f2a091fe81a/greenlet-3.2.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2d8aa5423cd4a396792f6d4580f88bdc6efcb9205891c9d40d20f6e670992efb", size = 650037, upload-time = "2025-06-05T16:13:06.402Z" }, + { url = "https://files.pythonhosted.org/packages/26/f2/ad51331a157c7015c675702e2d5230c243695c788f8f75feba1af32b3617/greenlet-3.2.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c724620a101f8170065d7dded3f962a2aea7a7dae133a009cada42847e04a7b", size = 608402, upload-time = "2025-06-05T16:12:51.91Z" }, + { url = "https://files.pythonhosted.org/packages/26/bc/862bd2083e6b3aff23300900a956f4ea9a4059de337f5c8734346b9b34fc/greenlet-3.2.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:873abe55f134c48e1f2a6f53f7d1419192a3d1a4e873bace00499a4e45ea6af0", size = 1119577, upload-time = "2025-06-05T16:36:49.787Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/1fc0cc068cfde885170e01de40a619b00eaa8f2916bf3541744730ffb4c3/greenlet-3.2.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:024571bbce5f2c1cfff08bf3fbaa43bbc7444f580ae13b0099e95d0e6e67ed36", size = 1147121, upload-time = "2025-06-05T16:12:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/27/1a/199f9587e8cb08a0658f9c30f3799244307614148ffe8b1e3aa22f324dea/greenlet-3.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:5195fb1e75e592dd04ce79881c8a22becdfa3e6f500e7feb059b1e6fdd54d3e3", size = 297603, upload-time = "2025-06-05T16:20:12.651Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ca/accd7aa5280eb92b70ed9e8f7fd79dc50a2c21d8c73b9a0856f5b564e222/greenlet-3.2.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:3d04332dddb10b4a211b68111dabaee2e1a073663d117dc10247b5b1642bac86", size = 271479, upload-time = "2025-06-05T16:10:47.525Z" }, + { url = "https://files.pythonhosted.org/packages/55/71/01ed9895d9eb49223280ecc98a557585edfa56b3d0e965b9fa9f7f06b6d9/greenlet-3.2.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8186162dffde068a465deab08fc72c767196895c39db26ab1c17c0b77a6d8b97", size = 683952, upload-time = "2025-06-05T16:38:55.125Z" }, + { url = "https://files.pythonhosted.org/packages/ea/61/638c4bdf460c3c678a0a1ef4c200f347dff80719597e53b5edb2fb27ab54/greenlet-3.2.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f4bfbaa6096b1b7a200024784217defedf46a07c2eee1a498e94a1b5f8ec5728", size = 696917, upload-time = "2025-06-05T16:41:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/22/cc/0bd1a7eb759d1f3e3cc2d1bc0f0b487ad3cc9f34d74da4b80f226fde4ec3/greenlet-3.2.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:ed6cfa9200484d234d8394c70f5492f144b20d4533f69262d530a1a082f6ee9a", size = 692443, upload-time = "2025-06-05T16:48:23.113Z" }, + { url = "https://files.pythonhosted.org/packages/67/10/b2a4b63d3f08362662e89c103f7fe28894a51ae0bc890fabf37d1d780e52/greenlet-3.2.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:02b0df6f63cd15012bed5401b47829cfd2e97052dc89da3cfaf2c779124eb892", size = 692995, upload-time = "2025-06-05T16:13:07.972Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c6/ad82f148a4e3ce9564056453a71529732baf5448ad53fc323e37efe34f66/greenlet-3.2.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86c2d68e87107c1792e2e8d5399acec2487a4e993ab76c792408e59394d52141", size = 655320, upload-time = "2025-06-05T16:12:53.453Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4f/aab73ecaa6b3086a4c89863d94cf26fa84cbff63f52ce9bc4342b3087a06/greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a", size = 301236, upload-time = "2025-06-05T16:15:20.111Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -393,6 +421,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mimesis" +version = "18.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/f1/33fb02aa19271a5a2334d24a597047399744a3f1e8c97be31053887260b4/mimesis-18.0.0.tar.gz", hash = "sha256:7d7c76ecd680ae48afe8dc4413ef1ef1ee7ef20e16f9f9cb42892add642fc1b2", size = 4681287, upload-time = "2024-09-13T22:26:05.273Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/c8/0b91996ce5337bb877300d0a0e86df17ab72da573625348dcb6cd92c3c41/mimesis-18.0.0-py3-none-any.whl", hash = "sha256:a51854a5ce63ebf2bd6a98e8841412e04cede38593be7e16d1d712848e6273df", size = 4734674, upload-time = "2024-09-13T22:26:03.283Z" }, +] + [[package]] name = "nodeenv" version = "1.9.1" @@ -737,6 +774,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.41" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/66/45b165c595ec89aa7dcc2c1cd222ab269bc753f1fc7a1e68f8481bd957bf/sqlalchemy-2.0.41.tar.gz", hash = "sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9", size = 9689424, upload-time = "2025-05-14T17:10:32.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/ad/2e1c6d4f235a97eeef52d0200d8ddda16f6c4dd70ae5ad88c46963440480/sqlalchemy-2.0.41-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4eeb195cdedaf17aab6b247894ff2734dcead6c08f748e617bfe05bd5a218443", size = 2115491, upload-time = "2025-05-14T17:55:31.177Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8d/be490e5db8400dacc89056f78a52d44b04fbf75e8439569d5b879623a53b/sqlalchemy-2.0.41-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d4ae769b9c1c7757e4ccce94b0641bc203bbdf43ba7a2413ab2523d8d047d8dc", size = 2102827, upload-time = "2025-05-14T17:55:34.921Z" }, + { url = "https://files.pythonhosted.org/packages/a0/72/c97ad430f0b0e78efaf2791342e13ffeafcbb3c06242f01a3bb8fe44f65d/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a62448526dd9ed3e3beedc93df9bb6b55a436ed1474db31a2af13b313a70a7e1", size = 3225224, upload-time = "2025-05-14T17:50:41.418Z" }, + { url = "https://files.pythonhosted.org/packages/5e/51/5ba9ea3246ea068630acf35a6ba0d181e99f1af1afd17e159eac7e8bc2b8/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc56c9788617b8964ad02e8fcfeed4001c1f8ba91a9e1f31483c0dffb207002a", size = 3230045, upload-time = "2025-05-14T17:51:54.722Z" }, + { url = "https://files.pythonhosted.org/packages/78/2f/8c14443b2acea700c62f9b4a8bad9e49fc1b65cfb260edead71fd38e9f19/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c153265408d18de4cc5ded1941dcd8315894572cddd3c58df5d5b5705b3fa28d", size = 3159357, upload-time = "2025-05-14T17:50:43.483Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b2/43eacbf6ccc5276d76cea18cb7c3d73e294d6fb21f9ff8b4eef9b42bbfd5/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f67766965996e63bb46cfbf2ce5355fc32d9dd3b8ad7e536a920ff9ee422e23", size = 3197511, upload-time = "2025-05-14T17:51:57.308Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/677c17c5d6a004c3c45334ab1dbe7b7deb834430b282b8a0f75ae220c8eb/sqlalchemy-2.0.41-cp313-cp313-win32.whl", hash = "sha256:bfc9064f6658a3d1cadeaa0ba07570b83ce6801a1314985bf98ec9b95d74e15f", size = 2082420, upload-time = "2025-05-14T17:55:52.69Z" }, + { url = "https://files.pythonhosted.org/packages/e9/61/e8c1b9b6307c57157d328dd8b8348ddc4c47ffdf1279365a13b2b98b8049/sqlalchemy-2.0.41-cp313-cp313-win_amd64.whl", hash = "sha256:82ca366a844eb551daff9d2e6e7a9e5e76d2612c8564f58db6c19a726869c1df", size = 2108329, upload-time = "2025-05-14T17:55:54.495Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size = 1911224, upload-time = "2025-05-14T17:39:42.154Z" }, +] + +[[package]] +name = "sqlmodel" +version = "0.0.24" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/4b/c2ad0496f5bdc6073d9b4cef52be9c04f2b37a5773441cc6600b1857648b/sqlmodel-0.0.24.tar.gz", hash = "sha256:cc5c7613c1a5533c9c7867e1aab2fd489a76c9e8a061984da11b4e613c182423", size = 116780, upload-time = "2025-03-07T05:43:32.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/91/484cd2d05569892b7fef7f5ceab3bc89fb0f8a8c0cde1030d383dbc5449c/sqlmodel-0.0.24-py3-none-any.whl", hash = "sha256:6778852f09370908985b667d6a3ab92910d0d5ec88adcaf23dbc242715ff7193", size = 28622, upload-time = "2025-03-07T05:43:30.37Z" }, +] + [[package]] name = "starlette" version = "0.46.2" From d8319b3adf8024de899f5349640d89820727ccd2 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Mon, 30 Jun 2025 11:48:08 +1000 Subject: [PATCH 07/19] Add data generation func for Auth0 user IDs --- tests/datagen.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/datagen.py b/tests/datagen.py index 3aa158a2..ef1b0a5a 100644 --- a/tests/datagen.py +++ b/tests/datagen.py @@ -10,6 +10,10 @@ from schemas.user import SessionUser +def random_auth0_id() -> str: + return "auth0|" + ''.join(random.choices('0123456789abcdef', k=24)) + + class AccessTokenPayloadFactory(ModelFactory[AccessTokenPayload]): ... @@ -20,7 +24,7 @@ class BiocommonsAuth0UserFactory(ModelFactory[BiocommonsAuth0User]): @classmethod def user_id(cls) -> str: - return "auth0|" + ''.join(random.choices('0123456789abcdef', k=24)) + return random_auth0_id() class GalaxyRegistrationDataFactory(ModelFactory[GalaxyRegistrationData]): From 31bb3e041c19d9a05b2782efe2b69dcca0733831 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Mon, 30 Jun 2025 11:48:45 +1000 Subject: [PATCH 08/19] Override database in testing --- tests/conftest.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 5fc716ca..44bdd435 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,16 +2,52 @@ import pytest from fastapi.testclient import TestClient +from sqlmodel import Session, SQLModel, StaticPool, create_engine from auth.config import Settings, get_settings from auth.management import get_management_token from auth.validator import get_current_user +from db.setup import get_db_session from galaxy.client import GalaxyClient, get_galaxy_client from galaxy.config import GalaxySettings, get_galaxy_settings from main import app from tests.datagen import AccessTokenPayloadFactory, SessionUserFactory +@pytest.fixture(scope="session") +def test_db_engine(): + from db import models # noqa: F401 + engine = create_engine( + # Use in-memory DB by default + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + return engine + + +@pytest.fixture(name="session") +def session_fixture(test_db_engine): + session = Session(test_db_engine) + try: + yield session + finally: + session.close() + + +@pytest.fixture(autouse=True) +def use_test_db(session): + """ + Ensure we always use the test database + """ + def get_db_session_override(): + yield session + app.dependency_overrides[get_db_session] = get_db_session_override + yield + app.dependency_overrides.clear() + + @pytest.fixture(autouse=True) def ignore_env_file(): """ From 752a61141ac9bc949b5eb3cf6a1d77b97e0262cc Mon Sep 17 00:00:00 2001 From: marius-mather Date: Mon, 30 Jun 2025 13:24:33 +1000 Subject: [PATCH 09/19] Get DB_URL from environment variable if needed --- db/setup.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/db/setup.py b/db/setup.py index 888fed7d..54e1d0ed 100644 --- a/db/setup.py +++ b/db/setup.py @@ -1,3 +1,5 @@ +import os + from dotenv import dotenv_values from sqlmodel import Session, SQLModel, create_engine @@ -5,8 +7,9 @@ # Doing this separately from pydantic-settings as we # need this before loading the FastAPI app env_values = dotenv_values(".env") -# Fall back to in-memory SQLite if no DB in env file -DB_URL = env_values.get("DB_URL", "sqlite://") +# Prefer the explicitly set value in .env, then environment variable, +# fallback to in-memory DB +DB_URL = env_values.get("DB_URL") or os.getenv("DB_URL") or "sqlite://" connect_args = {"check_same_thread": False} engine = create_engine(DB_URL, connect_args=connect_args) From c5b2ba5b64200411024e1a5faf267b978491e5a2 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Mon, 30 Jun 2025 13:26:03 +1000 Subject: [PATCH 10/19] Add psycopg for postgres DBs --- pyproject.toml | 1 + uv.lock | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 16fac77c..a09d79c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.13" dependencies = [ "fastapi[standard]>=0.115.12", "httpx>=0.28.1", + "psycopg[binary]>=3.2.9", "pydantic-settings>=2.8.1", "python-jose>=3.4.0", "sqlmodel>=0.0.24", diff --git a/uv.lock b/uv.lock index 1d945d17..4782ff5f 100644 --- a/uv.lock +++ b/uv.lock @@ -9,6 +9,7 @@ source = { editable = "." } dependencies = [ { name = "fastapi", extra = ["standard"] }, { name = "httpx" }, + { name = "psycopg", extra = ["binary"] }, { name = "pydantic-settings" }, { name = "python-jose" }, { name = "sqlmodel" }, @@ -35,6 +36,7 @@ requires-dist = [ { name = "mimesis", marker = "extra == 'dev'", specifier = "~=18.0" }, { name = "polyfactory", marker = "extra == 'dev'", specifier = ">=2.21.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.7.0" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.2.9" }, { name = "pydantic-settings", specifier = ">=2.8.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.5" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, @@ -495,6 +497,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" }, ] +[[package]] +name = "psycopg" +version = "3.2.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/4a/93a6ab570a8d1a4ad171a1f4256e205ce48d828781312c0bbaff36380ecb/psycopg-3.2.9.tar.gz", hash = "sha256:2fbb46fcd17bc81f993f28c47f1ebea38d66ae97cc2dbc3cad73b37cefbff700", size = 158122, upload-time = "2025-05-13T16:11:15.533Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b0/a73c195a56eb6b92e937a5ca58521a5c3346fb233345adc80fd3e2f542e2/psycopg-3.2.9-py3-none-any.whl", hash = "sha256:01a8dadccdaac2123c916208c96e06631641c0566b22005493f09663c7a8d3b6", size = 202705, upload-time = "2025-05-13T16:06:26.584Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.2.9" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/0b/f61ff4e9f23396aca674ed4d5c9a5b7323738021d5d72d36d8b865b3deaf/psycopg_binary-3.2.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:98bbe35b5ad24a782c7bf267596638d78aa0e87abc7837bdac5b2a2ab954179e", size = 4017127, upload-time = "2025-05-13T16:08:21.391Z" }, + { url = "https://files.pythonhosted.org/packages/bc/00/7e181fb1179fbfc24493738b61efd0453d4b70a0c4b12728e2b82db355fd/psycopg_binary-3.2.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:72691a1615ebb42da8b636c5ca9f2b71f266be9e172f66209a361c175b7842c5", size = 4080322, upload-time = "2025-05-13T16:08:24.049Z" }, + { url = "https://files.pythonhosted.org/packages/58/fd/94fc267c1d1392c4211e54ccb943be96ea4032e761573cf1047951887494/psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25ab464bfba8c401f5536d5aa95f0ca1dd8257b5202eede04019b4415f491351", size = 4655097, upload-time = "2025-05-13T16:08:27.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/17/31b3acf43de0b2ba83eac5878ff0dea5a608ca2a5c5dd48067999503a9de/psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e8aeefebe752f46e3c4b769e53f1d4ad71208fe1150975ef7662c22cca80fab", size = 4482114, upload-time = "2025-05-13T16:08:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/78/b4d75e5fd5a85e17f2beb977abbba3389d11a4536b116205846b0e1cf744/psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7e4e4dd177a8665c9ce86bc9caae2ab3aa9360b7ce7ec01827ea1baea9ff748", size = 4737693, upload-time = "2025-05-13T16:08:34.625Z" }, + { url = "https://files.pythonhosted.org/packages/3b/95/7325a8550e3388b00b5e54f4ced5e7346b531eb4573bf054c3dbbfdc14fe/psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fc2915949e5c1ea27a851f7a472a7da7d0a40d679f0a31e42f1022f3c562e87", size = 4437423, upload-time = "2025-05-13T16:08:37.444Z" }, + { url = "https://files.pythonhosted.org/packages/1a/db/cef77d08e59910d483df4ee6da8af51c03bb597f500f1fe818f0f3b925d3/psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a1fa38a4687b14f517f049477178093c39c2a10fdcced21116f47c017516498f", size = 3758667, upload-time = "2025-05-13T16:08:40.116Z" }, + { url = "https://files.pythonhosted.org/packages/95/3e/252fcbffb47189aa84d723b54682e1bb6d05c8875fa50ce1ada914ae6e28/psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5be8292d07a3ab828dc95b5ee6b69ca0a5b2e579a577b39671f4f5b47116dfd2", size = 3320576, upload-time = "2025-05-13T16:08:43.243Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cd/9b5583936515d085a1bec32b45289ceb53b80d9ce1cea0fef4c782dc41a7/psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:778588ca9897b6c6bab39b0d3034efff4c5438f5e3bd52fda3914175498202f9", size = 3411439, upload-time = "2025-05-13T16:08:47.321Z" }, + { url = "https://files.pythonhosted.org/packages/45/6b/6f1164ea1634c87956cdb6db759e0b8c5827f989ee3cdff0f5c70e8331f2/psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f0d5b3af045a187aedbd7ed5fc513bd933a97aaff78e61c3745b330792c4345b", size = 3477477, upload-time = "2025-05-13T16:08:51.166Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1d/bf54cfec79377929da600c16114f0da77a5f1670f45e0c3af9fcd36879bc/psycopg_binary-3.2.9-cp313-cp313-win_amd64.whl", hash = "sha256:2290bc146a1b6a9730350f695e8b670e1d1feb8446597bed0bbe7c3c30e0abcb", size = 2928009, upload-time = "2025-05-13T16:08:53.67Z" }, +] + [[package]] name = "pyasn1" version = "0.4.8" From 6e3bf1902622a0f2e965c940ee61e6c23b3df8f6 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Mon, 30 Jun 2025 14:24:56 +1000 Subject: [PATCH 11/19] Set up DB differently when running on AWS --- db/setup.py | 42 +++++++++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/db/setup.py b/db/setup.py index 54e1d0ed..985f1698 100644 --- a/db/setup.py +++ b/db/setup.py @@ -1,18 +1,42 @@ import os +from typing import Tuple from dotenv import dotenv_values from sqlmodel import Session, SQLModel, create_engine -# Read DB_URL from .env -# Doing this separately from pydantic-settings as we -# need this before loading the FastAPI app -env_values = dotenv_values(".env") -# Prefer the explicitly set value in .env, then environment variable, -# fallback to in-memory DB -DB_URL = env_values.get("DB_URL") or os.getenv("DB_URL") or "sqlite://" -connect_args = {"check_same_thread": False} -engine = create_engine(DB_URL, connect_args=connect_args) +def get_db_config() -> Tuple[str, dict]: + """ + Get database configuration from environment variables + or the .env file + """ + # Get database URL + # Case 1: AWS: we need to assemble the DB url from different + # environment variables (as these need to be populated from + # secrets) + host = os.getenv("DB_HOST", None) + if host is not None: + user = os.getenv("DB_USER") + password = os.getenv("DB_PASSWORD") + db_url = f"postgresql+psycopg2://{user}:{password}@{host}" + return db_url, {} + # Case 2: we have DB_URL set in the .env file, or we just want + # an in-memory DB for dev/testing + # Doing this separately from pydantic-settings as we + # need this before loading the FastAPI app + env_values = dotenv_values(".env") + # Prefer the explicitly set value in .env, then environment variable, + # fallback to in-memory DB + db_url = env_values.get("DB_URL") or os.getenv("DB_URL") or "sqlite://" + if db_url.startswith("sqlite://"): + connect_args = {"check_same_thread": False} + else: + connect_args = {} + return db_url, connect_args + + +DB_URL, db_connect_args = get_db_config() +engine = create_engine(DB_URL, connect_args=db_connect_args) def create_db_and_tables(): From 2b6c9de6cefc697403a2ab38773bc061bb909e75 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Mon, 30 Jun 2025 14:25:28 +1000 Subject: [PATCH 12/19] Test DB config --- tests/db/test_setup.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 tests/db/test_setup.py diff --git a/tests/db/test_setup.py b/tests/db/test_setup.py new file mode 100644 index 00000000..93b5d1e9 --- /dev/null +++ b/tests/db/test_setup.py @@ -0,0 +1,28 @@ +from db.setup import get_db_config + + +def test_db_config_db_host(monkeypatch): + """ + Test we return a postgres connection URL + when the DB_HOST environment variable is set + (we use this on AWS, where we need to combine + it with the DB_USER and DB_PASSWORD environment variables) + """ + monkeypatch.setenv("DB_HOST", "db:5432") + monkeypatch.setenv("DB_USER", "user") + monkeypatch.setenv("DB_PASSWORD", "password") + db_url, connect_args = get_db_config() + assert "user:password@db:5432" in db_url + assert connect_args == {} + + +def test_db_config_db_url(monkeypatch): + """ + Test we return the provided connection string + when the DB_URL environment variable is set + """ + env_url = "sqlite:///database.db" + monkeypatch.setenv("DB_URL", env_url) + db_url, connect_args = get_db_config() + assert db_url == env_url + assert connect_args["check_same_thread"] is False From 3dc286544616111c2c03de8dc4d5686960e710bd Mon Sep 17 00:00:00 2001 From: marius-mather Date: Mon, 30 Jun 2025 14:39:17 +1000 Subject: [PATCH 13/19] Update CDK deploy script to add DB secrets --- .../aai_backend_deploy/aai_backend_deploy_stack.py | 13 +++++++++++++ deploy/app.py | 1 + 2 files changed, 14 insertions(+) diff --git a/deploy/aai_backend_deploy/aai_backend_deploy_stack.py b/deploy/aai_backend_deploy/aai_backend_deploy_stack.py index a2260488..0236fdfd 100644 --- a/deploy/aai_backend_deploy/aai_backend_deploy_stack.py +++ b/deploy/aai_backend_deploy/aai_backend_deploy_stack.py @@ -25,6 +25,9 @@ from aws_cdk import ( aws_route53 as route53, ) +from aws_cdk import ( + aws_secretsmanager as secretsmanager, +) from constructs import Construct @@ -36,9 +39,14 @@ def __init__(self, scope: Construct, construct_id: str, config: dict, **kwargs) self.certificate_arn = config["AWS_CERTIFICATE_ARN"] self.zone_id = config["AWS_ZONE_ID"] self.zone_domain = config["AWS_ZONE_DOMAIN"] + self.db_host = config["AWS_DB_HOST"] except KeyError as e: raise ValueError(f"Missing required configuration: {e}. These should be set in .env locally, or GitHub Secrets.") + db_secret = secretsmanager.Secret.from_secret_name_v2( + self, "DbCredentials", "aai-backend-db-credentials" + ) + # VPC vpc = ec2.Vpc(self, "AaiBackendVPC", max_azs=2) @@ -64,6 +72,11 @@ def __init__(self, scope: Construct, construct_id: str, config: dict, **kwargs) environment={ "FORCE_REDEPLOY": str(datetime.datetime.now()) }, + secrets={ + "DB_USER": ecs.Secret.from_secrets_manager(db_secret, field="username"), + "DB_PASSWORD": ecs.Secret.from_secrets_manager(db_secret, field="password"), + "DB_HOST": self.db_host, + }, logging=ecs.LogDrivers.aws_logs(stream_prefix="FastAPI"), ) diff --git a/deploy/app.py b/deploy/app.py index c1866c42..1035cc4d 100644 --- a/deploy/app.py +++ b/deploy/app.py @@ -12,6 +12,7 @@ def get_dotenv_config(): "AWS_CERTIFICATE_ARN": os.getenv("AWS_CERTIFICATE_ARN"), "AWS_ZONE_ID": os.getenv("AWS_ZONE_ID"), "AWS_ZONE_DOMAIN": os.getenv("AWS_ZONE_DOMAIN"), + "AWS_DB_HOST": os.getenv("AWS_DB_HOST"), } config = get_dotenv_config() From 847fb006fac0312fcce4699b20d28bdf33f56bc4 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Mon, 30 Jun 2025 15:11:04 +1000 Subject: [PATCH 14/19] Add DB config variables to .env.example --- .env.example | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.env.example b/.env.example index ebc70610..95efc5c5 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,10 @@ GALAXY_API_KEY=api-key # don't process this with pydantic-settings as it needs # to be used before the FastAPI app loads CORS_ALLOWED_ORIGINS=http://localhost:8000 +# Database config: we do this differently for local dev vs. on AWS +# NOTE: DB_HOST is used first if present, so don't include it +# if you want a local DB +# Local dev: supply the full DB connection string as DB_URL +DB_URL=sqlite:///mydatabase.db +# AWS: supply DB_HOST, with the host name and port +# DB_HOST=mydb.amazonaws.com:5432 From b3109c10f6dc520aff744c3e71483c763138c4ea Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 1 Jul 2025 09:05:14 +1000 Subject: [PATCH 15/19] Add AWS_DB_HOST to GitHub Actions secrets --- .github/workflows/deploy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b3e72c7b..29b0b04c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -55,6 +55,7 @@ jobs: AWS_CERTIFICATE_ARN=${{ secrets.AWS_CERTIFICATE_ARN }} AWS_ZONE_ID=${{ secrets.AWS_ZONE_ID }} AWS_ZONE_DOMAIN=${{ secrets.AWS_ZONE_DOMAIN }} + AWS_DB_HOST=${{ secrets.AWS_DB_HOST }} EOF - name: CDK Deploy From 6015a282fe4a6eb9129b5dc2cd259d2157bd986b Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 1 Jul 2025 09:05:37 +1000 Subject: [PATCH 16/19] Update DB_URL to use psycopg 3 --- db/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/setup.py b/db/setup.py index 985f1698..82076ece 100644 --- a/db/setup.py +++ b/db/setup.py @@ -18,7 +18,7 @@ def get_db_config() -> Tuple[str, dict]: if host is not None: user = os.getenv("DB_USER") password = os.getenv("DB_PASSWORD") - db_url = f"postgresql+psycopg2://{user}:{password}@{host}" + db_url = f"postgresql+psycopg://{user}:{password}@{host}" return db_url, {} # Case 2: we have DB_URL set in the .env file, or we just want # an in-memory DB for dev/testing From 7b031eb901cc03402af9859c889fbbe13e7cfd05 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 1 Jul 2025 09:07:43 +1000 Subject: [PATCH 17/19] Add alembic to dependencies --- pyproject.toml | 1 + uv.lock | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index a09d79c0..2d8955b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.13" dependencies = [ + "alembic>=1.16.2", "fastapi[standard]>=0.115.12", "httpx>=0.28.1", "psycopg[binary]>=3.2.9", diff --git a/uv.lock b/uv.lock index 4782ff5f..eb2875ac 100644 --- a/uv.lock +++ b/uv.lock @@ -7,6 +7,7 @@ name = "aai-backend" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "alembic" }, { name = "fastapi", extra = ["standard"] }, { name = "httpx" }, { name = "psycopg", extra = ["binary"] }, @@ -30,6 +31,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "alembic", specifier = ">=1.16.2" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.115.12" }, { name = "freezegun", marker = "extra == 'dev'", specifier = ">=1.5.2" }, { name = "httpx", specifier = ">=0.28.1" }, @@ -48,6 +50,20 @@ requires-dist = [ ] provides-extras = ["dev"] +[[package]] +name = "alembic" +version = "1.16.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/35/116797ff14635e496bbda0c168987f5326a6555b09312e9b817e360d1f56/alembic-1.16.2.tar.gz", hash = "sha256:e53c38ff88dadb92eb22f8b150708367db731d58ad7e9d417c9168ab516cbed8", size = 1963563, upload-time = "2025-06-16T18:05:08.566Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/e2/88e425adac5ad887a087c38d04fe2030010572a3e0e627f8a6e8c33eeda8/alembic-1.16.2-py3-none-any.whl", hash = "sha256:5f42e9bd0afdbd1d5e3ad856c01754530367debdebf21ed6894e34af52b3bb03", size = 242717, upload-time = "2025-06-16T18:05:10.27Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -374,6 +390,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, +] + [[package]] name = "markdown-it-py" version = "3.0.0" From f69e46aa26cdd530a00ce1aa17addebe59fd2096 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 1 Jul 2025 09:14:21 +1000 Subject: [PATCH 18/19] Initial alembic configuration --- alembic.ini | 118 ++++++++++++++++++++++++++++++++++++++ migrations/README | 1 + migrations/env.py | 83 +++++++++++++++++++++++++++ migrations/script.py.mako | 27 +++++++++ 4 files changed, 229 insertions(+) create mode 100644 alembic.ini create mode 100644 migrations/README create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 00000000..5887001c --- /dev/null +++ b/alembic.ini @@ -0,0 +1,118 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# NOTE: we set the sqlalchemy URL in migrations/env.py +# as we need to set it dynamically based on environment +#sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[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/migrations/README b/migrations/README new file mode 100644 index 00000000..2500aa1b --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 00000000..bd2eafa6 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,83 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool +from sqlmodel import SQLModel + +from alembic import context + +from db import models +from db.setup import get_db_config + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config +db_url, _ = get_db_config() +print(f"Database URL: {db_url}") +config.set_main_option("sqlalchemy.url", db_url) + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +target_metadata = SQLModel.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +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 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 = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 00000000..6ce33510 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,27 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} From 7e63617cbe5eb563cce9ede7659745c005c35abe Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 1 Jul 2025 10:50:13 +1000 Subject: [PATCH 19/19] Remove print statement from alembic setup --- migrations/env.py | 1 - 1 file changed, 1 deletion(-) diff --git a/migrations/env.py b/migrations/env.py index bd2eafa6..f4acc5c1 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -13,7 +13,6 @@ # access to the values within the .ini file in use. config = context.config db_url, _ = get_db_config() -print(f"Database URL: {db_url}") config.set_main_option("sqlalchemy.url", db_url) # Interpret the config file for Python logging.