]> git.ipfire.org Git - thirdparty/fastapi/sqlmodel.git/commitdiff
WIP
authorPatrick Arminio <patrick.arminio@gmail.com>
Thu, 16 Oct 2025 19:47:09 +0000 (20:47 +0100)
committerPatrick Arminio <patrick.arminio@gmail.com>
Thu, 15 Jan 2026 15:05:24 +0000 (15:05 +0000)
pyproject.toml
sqlmodel/cli/__init__.py [new file with mode: 0644]
sqlmodel/cli/migrations.py [new file with mode: 0644]
tests/test_cli/__init__.py [new file with mode: 0644]
tests/test_cli/__inline_snapshot__/test_migrations/test_create_first_migration/f1182584-912e-4f31-9d79-2233e5a8a986.py [new file with mode: 0644]
tests/test_cli/fixtures/__init__.py [new file with mode: 0644]
tests/test_cli/fixtures/models_initial.py [new file with mode: 0644]
tests/test_cli/test_migrations.py [new file with mode: 0644]
uv.lock

index b441ee0de0e8edf12b77a6ef650a1c45f196f620..da127ebc4902b42d3729277508782bce5e2050cc 100644 (file)
@@ -38,8 +38,13 @@ classifiers = [
 dependencies = [
     "SQLAlchemy >=2.0.14,<2.1.0",
     "pydantic>=2.7.0",
+    "typer >=0.9.0",
+    "alembic >=1.13.0",
 ]
 
+[project.scripts]
+sqlmodel = "sqlmodel.cli:main"
+
 [project.urls]
 Homepage = "https://github.com/fastapi/sqlmodel"
 Documentation = "https://sqlmodel.tiangolo.com"
@@ -52,6 +57,7 @@ dev = [
     { include-group = "docs" },
     { include-group = "tests" },
     "prek>=0.2.24,<1.0.0",
+    "inline-snapshot >=0.13.0",
 ]
 docs = [
     "black>=22.10",
diff --git a/sqlmodel/cli/__init__.py b/sqlmodel/cli/__init__.py
new file mode 100644 (file)
index 0000000..cf2cf7c
--- /dev/null
@@ -0,0 +1,10 @@
+import typer
+
+from .migrations import migrations_app
+
+app = typer.Typer()
+app.add_typer(migrations_app, name="migrations")
+
+
+def main() -> None:
+    app()
diff --git a/sqlmodel/cli/migrations.py b/sqlmodel/cli/migrations.py
new file mode 100644 (file)
index 0000000..3c5cc9f
--- /dev/null
@@ -0,0 +1,453 @@
+import os
+import re
+from pathlib import Path
+from typing import Optional
+
+import typer
+from alembic.autogenerate import produce_migrations, render_python_code
+from alembic.config import Config
+from alembic.runtime.migration import MigrationContext
+from sqlalchemy import create_engine, pool
+
+migrations_app = typer.Typer()
+
+
+def get_migrations_dir(migrations_path: Optional[str] = None) -> Path:
+    """Get the migrations directory path."""
+    if migrations_path:
+        return Path(migrations_path)
+    return Path.cwd() / "migrations"
+
+
+def get_alembic_config(migrations_dir: Path) -> Config:
+    """Create an Alembic config object programmatically without ini file."""
+    config = Config()
+    config.set_main_option("script_location", str(migrations_dir))
+    config.set_main_option("sqlalchemy.url", "")  # Will be set by env.py
+    return config
+
+
+def get_next_migration_number(migrations_dir: Path) -> str:
+    """Get the next sequential migration number."""
+    if not migrations_dir.exists():
+        return "0001"
+
+    migration_files = list(migrations_dir.glob("*.py"))
+    if not migration_files:
+        return "0001"
+
+    numbers = []
+    for f in migration_files:
+        match = re.match(r"^(\d{4})_", f.name)
+        if match:
+            numbers.append(int(match.group(1)))
+
+    if not numbers:
+        return "0001"
+
+    return f"{max(numbers) + 1:04d}"
+
+
+def get_metadata(models_path: str):
+    """Import and return SQLModel metadata."""
+    import sys
+    from importlib import import_module
+
+    # Add current directory to Python path
+    sys.path.insert(0, str(Path.cwd()))
+
+    try:
+        # Import the module containing the models
+        models_module = import_module(models_path)
+
+        # Get SQLModel from the module or import it
+        if hasattr(models_module, "SQLModel"):
+            return models_module.SQLModel.metadata
+        else:
+            # Try importing SQLModel from sqlmodel
+            from sqlmodel import SQLModel
+
+            return SQLModel.metadata
+    except ImportError as e:
+        raise ValueError(
+            f"Failed to import models from '{models_path}': {e}\n"
+            f"Make sure the module exists and is importable from the current directory."
+        )
+
+
+def get_current_revision(db_url: str) -> Optional[str]:
+    """Get the current revision from the database."""
+    import sqlalchemy as sa
+
+    engine = create_engine(db_url, poolclass=pool.NullPool)
+
+    # Create alembic_version table if it doesn't exist
+    with engine.begin() as connection:
+        connection.execute(
+            sa.text("""
+            CREATE TABLE IF NOT EXISTS alembic_version (
+                version_num VARCHAR(32) NOT NULL,
+                CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
+            )
+        """)
+        )
+
+    # Get current revision
+    with engine.connect() as connection:
+        result = connection.execute(sa.text("SELECT version_num FROM alembic_version"))
+        row = result.first()
+        return row[0] if row else None
+
+
+def generate_migration_ops(db_url: str, metadata):
+    """Generate migration operations by comparing metadata to database."""
+
+    engine = create_engine(db_url, poolclass=pool.NullPool)
+
+    with engine.connect() as connection:
+        migration_context = MigrationContext.configure(connection)
+        # Use produce_migrations which returns actual operation objects
+        migration_script = produce_migrations(migration_context, metadata)
+
+    return migration_script.upgrade_ops, migration_script.downgrade_ops
+
+
+@migrations_app.command()
+def create(
+    message: str = typer.Option(..., "--message", "-m", help="Migration message"),
+    models: str = typer.Option(
+        ...,
+        "--models",
+        help="Python import path to models module (e.g., 'models' or 'app.models')",
+    ),
+    migrations_path: Optional[str] = typer.Option(
+        None, "--path", "-p", help="Path to migrations directory"
+    ),
+) -> None:
+    """Create a new migration with autogenerate."""
+    migrations_dir = get_migrations_dir(migrations_path)
+
+    # Create migrations directory if it doesn't exist
+    migrations_dir.mkdir(parents=True, exist_ok=True)
+
+    # Get next migration number
+    migration_number = get_next_migration_number(migrations_dir)
+
+    # Create slug from message
+    # TODO: truncate, handle special characters
+    slug = message.lower().replace(" ", "_")
+    slug = re.sub(r"[^a-z0-9_]", "", slug)
+
+    filename = f"{migration_number}_{slug}.py"
+    filepath = migrations_dir / filename
+
+    typer.echo(f"Creating migration: {filename}")
+
+    try:
+        # Get database URL
+        db_url = os.getenv("DATABASE_URL")
+        if not db_url:
+            raise ValueError(
+                "DATABASE_URL environment variable is not set. "
+                "Please set it to your database connection string."
+            )
+
+        # Get metadata
+        metadata = get_metadata(models)
+
+        # Check if there are pending migrations that need to be applied
+        current_revision = get_current_revision(db_url)
+        existing_migrations = sorted(
+            [f for f in migrations_dir.glob("*.py") if f.name != "__init__.py"]
+        )
+
+        if existing_migrations:
+            # Get the latest migration file
+            latest_migration_file = existing_migrations[-1]
+            content = latest_migration_file.read_text()
+            # Extract revision = "..." from the file
+            match = re.search(
+                r'^revision = ["\']([^"\']+)["\']', content, re.MULTILINE
+            )
+            if match:
+                latest_file_revision = match.group(1)
+                # Check if database is up to date
+                if current_revision != latest_file_revision:
+                    typer.echo(
+                        f"Error: Database is not up to date. Current revision: {current_revision or 'None'}, "
+                        f"Latest migration: {latest_file_revision}",
+                        err=True,
+                    )
+                    typer.echo(
+                        "Please run 'sqlmodel migrations migrate' to apply pending migrations before creating a new one.",
+                        err=True,
+                    )
+                    raise typer.Exit(1)
+
+        # Generate migration operations
+        upgrade_ops_obj, downgrade_ops_obj = generate_migration_ops(db_url, metadata)
+
+        # Render upgrade
+        if upgrade_ops_obj:
+            upgrade_code = render_python_code(upgrade_ops_obj).strip()
+        else:
+            upgrade_code = "pass"
+
+        # Render downgrade
+        if downgrade_ops_obj:
+            downgrade_code = render_python_code(downgrade_ops_obj).strip()
+        else:
+            # TODO: space :)
+            downgrade_code = "pass"
+
+        # Remove Alembic comments to check if migrations are actually empty
+        def extract_code_without_comments(code: str) -> str:
+            """Extract actual code, removing Alembic auto-generated comments."""
+            lines = code.split("\n")
+            actual_lines = []
+            for line in lines:
+                # Skip Alembic comment lines
+                if not line.strip().startswith("# ###"):
+                    actual_lines.append(line)
+            return "\n".join(actual_lines).strip()
+
+        upgrade_code_clean = extract_code_without_comments(upgrade_code)
+        downgrade_code_clean = extract_code_without_comments(downgrade_code)
+
+        # Only reject empty migrations if there are already existing migrations
+        # (i.e., this is not the first migration)
+        if (
+            upgrade_code_clean == "pass"
+            and downgrade_code_clean == "pass"
+            and len(existing_migrations) > 0
+        ):
+            # TODO: better message
+            typer.echo(
+                "Empty migrations are not allowed"
+            )  # TODO: unless you pass `--empty`
+            raise typer.Exit(1)
+
+        # Generate revision ID from filename (without .py extension)
+        revision_id = f"{migration_number}_{slug}"
+
+        # Get previous revision by reading the last migration file's revision ID
+        down_revision = None
+        if existing_migrations:
+            # Read the last migration file to get its revision ID
+            last_migration = existing_migrations[-1]
+            content = last_migration.read_text()
+            # Extract revision = "..." from the file
+            import re as regex_module
+
+            match = regex_module.search(
+                r'^revision = ["\']([^"\']+)["\']', content, regex_module.MULTILINE
+            )
+            if match:
+                down_revision = match.group(1)
+
+        # Check if we need to import sqlmodel
+        needs_sqlmodel = "sqlmodel" in upgrade_code or "sqlmodel" in downgrade_code
+
+        # Generate migration file - build without f-strings to avoid % issues
+        lines: list[str] = []
+        lines.append(f'"""{message}"""')
+        lines.append("")
+        lines.append("import sqlalchemy as sa")
+        if needs_sqlmodel:
+            lines.append("import sqlmodel")
+        lines.append("from alembic import op")
+        lines.append("")
+        lines.append('revision = "' + revision_id + '"')
+        lines.append("down_revision = " + repr(down_revision))
+        lines.append("depends_on = None")
+        lines.append("")
+        lines.append("")
+        lines.append("def upgrade() -> None:")
+
+        # Add upgrade code with proper indentation
+        for line in upgrade_code.split("\n"):
+            lines.append(line)
+
+        lines.append("")
+        lines.append("")
+        lines.append("def downgrade() -> None:")
+
+        # Add downgrade code with proper indentation
+        for line in downgrade_code.split("\n"):
+            lines.append(line)
+
+        migration_content = "\n".join(lines)
+
+        filepath.write_text(migration_content)
+
+        typer.echo(f"✓ Created migration: {filename}")
+
+    except ValueError as e:
+        typer.echo(f"Error creating migration: {e}", err=True)
+        raise typer.Exit(1)
+    except Exception as e:
+        import traceback
+
+        typer.echo(f"Error creating migration: {e}", err=True)
+        typer.echo("\nFull traceback:", err=True)
+        traceback.print_exc()
+        raise typer.Exit(1)
+
+
+def get_pending_migrations(
+    migrations_dir: Path, current_revision: Optional[str]
+) -> list[Path]:
+    """Get list of pending migration files."""
+    all_migrations = sorted(
+        [
+            f
+            for f in migrations_dir.glob("*.py")
+            if f.name != "__init__.py" and f.name != "env.py"
+        ]
+    )
+
+    if not current_revision:
+        return all_migrations
+
+    # Find migrations after the current revision
+    pending = []
+    found_current = False
+    for migration_file in all_migrations:
+        if found_current:
+            pending.append(migration_file)
+        elif migration_file.stem == current_revision:
+            found_current = True
+
+    return pending
+
+
+def apply_migrations_programmatically(
+    migrations_dir: Path, db_url: str, models: str
+) -> None:
+    """Apply migrations programmatically without env.py."""
+    import importlib.util
+
+    import sqlalchemy as sa
+
+    # Get metadata
+    metadata = get_metadata(models)
+
+    # Create engine
+    engine = create_engine(db_url, poolclass=pool.NullPool)
+
+    # Create alembic_version table if it doesn't exist (outside transaction)
+    with engine.begin() as connection:
+        connection.execute(
+            sa.text("""
+            CREATE TABLE IF NOT EXISTS alembic_version (
+                version_num VARCHAR(32) NOT NULL,
+                CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
+            )
+        """)
+        )
+
+    # Get current revision
+    with engine.connect() as connection:
+        result = connection.execute(sa.text("SELECT version_num FROM alembic_version"))
+        row = result.first()
+        current_revision = row[0] if row else None
+
+    # Get pending migrations
+    pending_migrations = get_pending_migrations(migrations_dir, current_revision)
+
+    if not pending_migrations:
+        typer.echo("  No pending migrations")
+        return
+
+    # Run each migration
+    for migration_file in pending_migrations:
+        revision_id = migration_file.stem
+        typer.echo(f"  Applying: {revision_id}")
+
+        # Execute each migration in its own transaction
+        with engine.begin() as connection:
+            # Create migration context
+            migration_context = MigrationContext.configure(
+                connection, opts={"target_metadata": metadata}
+            )
+
+            # Load the migration module
+            spec = importlib.util.spec_from_file_location(revision_id, migration_file)
+            if not spec or not spec.loader:
+                raise ValueError(f"Could not load migration: {migration_file}")
+
+            module = importlib.util.module_from_spec(spec)
+
+            # Also make sqlalchemy and sqlmodel available
+            import sqlmodel
+
+            module.sa = sa  # type: ignore
+            module.sqlmodel = sqlmodel  # type: ignore
+
+            # Execute the module to define the functions
+            spec.loader.exec_module(module)
+
+            # Create operations context and run upgrade within ops.invoke_for_target
+            from alembic.operations import ops
+
+            with ops.Operations.context(migration_context):
+                # Now op proxy is available via alembic.op
+                from alembic import op as alembic_op
+
+                module.op = alembic_op  # type: ignore
+
+                # Execute upgrade
+                module.upgrade()
+
+            # Update alembic_version table
+            if current_revision:
+                connection.execute(
+                    sa.text("UPDATE alembic_version SET version_num = :version"),
+                    {"version": revision_id},
+                )
+            else:
+                connection.execute(
+                    sa.text(
+                        "INSERT INTO alembic_version (version_num) VALUES (:version)"
+                    ),
+                    {"version": revision_id},
+                )
+
+            current_revision = revision_id
+
+
+@migrations_app.command()
+def migrate(
+    models: str = typer.Option(
+        ...,
+        "--models",
+        help="Python import path to models module (e.g., 'models' or 'app.models')",
+    ),
+    migrations_path: Optional[str] = typer.Option(
+        None, "--path", "-p", help="Path to migrations directory"
+    ),
+) -> None:
+    """Apply all pending migrations to the database."""
+    migrations_dir = get_migrations_dir(migrations_path)
+
+    if not migrations_dir.exists():
+        typer.echo(
+            f"Error: {migrations_dir} not found. Run 'sqlmodel migrations init' first.",
+            err=True,
+        )
+        raise typer.Exit(1)
+
+    # Get database URL
+    db_url = os.getenv("DATABASE_URL")
+    if not db_url:
+        typer.echo("Error: DATABASE_URL environment variable is not set.", err=True)
+        raise typer.Exit(1)
+
+    typer.echo("Applying migrations...")
+
+    try:
+        apply_migrations_programmatically(migrations_dir, db_url, models)
+        typer.echo("✓ Migrations applied successfully")
+    except Exception as e:
+        typer.echo(f"Error applying migrations: {e}", err=True)
+        raise typer.Exit(1)
diff --git a/tests/test_cli/__init__.py b/tests/test_cli/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/tests/test_cli/__inline_snapshot__/test_migrations/test_create_first_migration/f1182584-912e-4f31-9d79-2233e5a8a986.py b/tests/test_cli/__inline_snapshot__/test_migrations/test_create_first_migration/f1182584-912e-4f31-9d79-2233e5a8a986.py
new file mode 100644 (file)
index 0000000..55dee2e
--- /dev/null
@@ -0,0 +1,27 @@
+"""Initial migration"""
+
+import sqlalchemy as sa
+import sqlmodel
+from alembic import op
+
+revision = "0001_initial_migration"
+down_revision = None
+depends_on = None
+
+
+def upgrade() -> None:
+# ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('hero',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
+    sa.Column('secret_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
+    sa.Column('age', sa.Integer(), nullable=True),
+    sa.PrimaryKeyConstraint('id')
+    )
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+# ### commands auto generated by Alembic - please adjust! ###
+    op.drop_table('hero')
+    # ### end Alembic commands ###
\ No newline at end of file
diff --git a/tests/test_cli/fixtures/__init__.py b/tests/test_cli/fixtures/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/tests/test_cli/fixtures/models_initial.py b/tests/test_cli/fixtures/models_initial.py
new file mode 100644 (file)
index 0000000..12132bb
--- /dev/null
@@ -0,0 +1,10 @@
+from typing import Optional
+
+from sqlmodel import Field, SQLModel
+
+
+class Hero(SQLModel, table=True):
+    id: Optional[int] = Field(default=None, primary_key=True)
+    name: str
+    secret_name: str
+    age: Optional[int] = None
diff --git a/tests/test_cli/test_migrations.py b/tests/test_cli/test_migrations.py
new file mode 100644 (file)
index 0000000..001a754
--- /dev/null
@@ -0,0 +1,225 @@
+import shutil
+from pathlib import Path
+
+import pytest
+from inline_snapshot import external, register_format_alias
+from sqlmodel.cli import app
+from typer.testing import CliRunner
+
+register_format_alias(".py", ".txt")
+
+runner = CliRunner()
+
+
+HERE = Path(__file__).parent
+
+register_format_alias(".html", ".txt")
+
+
+def test_create_first_migration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+    """Test creating the first migration with an empty database."""
+    db_path = tmp_path / "test.db"
+    db_url = f"sqlite:///{db_path}"
+    migrations_dir = tmp_path / "migrations"
+
+    model_source = HERE / "./fixtures/models_initial.py"
+
+    models_dir = tmp_path / "test_models"
+    models_dir.mkdir()
+
+    (models_dir / "__init__.py").write_text("")
+    models_file = models_dir / "models.py"
+
+    shutil.copy(model_source, models_file)
+
+    monkeypatch.setenv("DATABASE_URL", db_url)
+    monkeypatch.chdir(tmp_path)
+
+    # Run the create command
+    result = runner.invoke(
+        app,
+        [
+            "migrations",
+            "create",
+            "-m",
+            "Initial migration",
+            "--models",
+            "test_models.models",
+            "--path",
+            str(migrations_dir),
+        ],
+    )
+
+    assert result.exit_code == 0, f"Command failed: {result.stdout}"
+    assert "✓ Created migration:" in result.stdout
+
+    migration_files = sorted(
+        [str(f.relative_to(tmp_path)) for f in migrations_dir.glob("*.py")]
+    )
+
+    assert migration_files == [
+        "migrations/0001_initial_migration.py",
+    ]
+
+    migration_file = migrations_dir / "0001_initial_migration.py"
+
+    assert migration_file.read_text() == external(
+        "uuid:f1182584-912e-4f31-9d79-2233e5a8a986.py"
+    )
+
+
+# TODO: to force migration you need to pass `--empty`s
+def test_running_migration_twice_only_generates_migration_once(
+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+):
+    db_path = tmp_path / "test.db"
+    db_url = f"sqlite:///{db_path}"
+    migrations_dir = tmp_path / "migrations"
+
+    model_source = HERE / "./fixtures/models_initial.py"
+
+    models_dir = tmp_path / "test_models"
+    models_dir.mkdir()
+
+    (models_dir / "__init__.py").write_text("")
+    models_file = models_dir / "models.py"
+
+    shutil.copy(model_source, models_file)
+
+    monkeypatch.setenv("DATABASE_URL", db_url)
+    monkeypatch.chdir(tmp_path)
+
+    # Run the create command
+    result = runner.invoke(
+        app,
+        [
+            "migrations",
+            "create",
+            "-m",
+            "Initial migration",
+            "--models",
+            "test_models.models",
+            "--path",
+            str(migrations_dir),
+        ],
+    )
+
+    assert result.exit_code == 0, f"Command failed: {result.stdout}"
+    assert "✓ Created migration:" in result.stdout
+
+    # Apply the first migration to the database
+    result = runner.invoke(
+        app,
+        [
+            "migrations",
+            "migrate",
+            "--models",
+            "test_models.models",
+            "--path",
+            str(migrations_dir),
+        ],
+    )
+
+    assert result.exit_code == 0, f"Migration failed: {result.stdout}"
+
+    # Run the create command again (should fail with empty migration)
+    result = runner.invoke(
+        app,
+        [
+            "migrations",
+            "create",
+            "-m",
+            "Initial migration",
+            "--models",
+            "test_models.models",
+            "--path",
+            str(migrations_dir),
+        ],
+    )
+
+    assert result.exit_code == 1
+    assert "Empty migrations are not allowed" in result.stdout
+
+    migration_files = sorted(
+        [str(f.relative_to(tmp_path)) for f in migrations_dir.glob("*.py")]
+    )
+
+    assert migration_files == [
+        "migrations/0001_initial_migration.py",
+    ]
+
+
+def test_cannot_create_migration_with_pending_migrations(
+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+):
+    """Test that creating a migration fails if there are unapplied migrations."""
+    db_path = tmp_path / "test.db"
+    db_url = f"sqlite:///{db_path}"
+    migrations_dir = tmp_path / "migrations"
+
+    model_source = HERE / "./fixtures/models_initial.py"
+
+    models_dir = tmp_path / "test_models"
+    models_dir.mkdir()
+
+    (models_dir / "__init__.py").write_text("")
+    models_file = models_dir / "models.py"
+
+    shutil.copy(model_source, models_file)
+
+    monkeypatch.setenv("DATABASE_URL", db_url)
+    monkeypatch.chdir(tmp_path)
+
+    # Run the create command to create first migration
+    result = runner.invoke(
+        app,
+        [
+            "migrations",
+            "create",
+            "-m",
+            "Initial migration",
+            "--models",
+            "test_models.models",
+            "--path",
+            str(migrations_dir),
+        ],
+    )
+
+    assert result.exit_code == 0, f"Command failed: {result.stdout}"
+    assert "✓ Created migration:" in result.stdout
+
+    # Try to create another migration WITHOUT applying the first one
+    result = runner.invoke(
+        app,
+        [
+            "migrations",
+            "create",
+            "-m",
+            "Second migration",
+            "--models",
+            "test_models.models",
+            "--path",
+            str(migrations_dir),
+        ],
+    )
+
+    # Should fail because database is not up to date
+    assert result.exit_code == 1
+    # Error messages are printed to stderr, which Typer's CliRunner combines into output
+    assert (
+        "Database is not up to date" in result.stdout
+        or "Database is not up to date" in str(result.output)
+    )
+    assert (
+        "Please run 'sqlmodel migrations migrate'" in result.stdout
+        or "Please run 'sqlmodel migrations migrate'" in str(result.output)
+    )
+
+    # Verify only one migration file exists
+    migration_files = sorted(
+        [str(f.relative_to(tmp_path)) for f in migrations_dir.glob("*.py")]
+    )
+
+    assert migration_files == [
+        "migrations/0001_initial_migration.py",
+    ]
diff --git a/uv.lock b/uv.lock
index cbe02ad8c9d90191789d8720e204a1661166bae9..97e0c186d1261b475f59b48547423809706ce7d3 100644 (file)
--- a/uv.lock
+++ b/uv.lock
@@ -6,6 +6,42 @@ resolution-markers = [
     "python_full_version < '3.10'",
 ]
 
+[[package]]
+name = "alembic"
+version = "1.16.5"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+    "python_full_version < '3.10'",
+]
+dependencies = [
+    { name = "mako", marker = "python_full_version < '3.10'" },
+    { name = "sqlalchemy", marker = "python_full_version < '3.10'" },
+    { name = "tomli", marker = "python_full_version < '3.10'" },
+    { name = "typing-extensions", marker = "python_full_version < '3.10'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9a/ca/4dc52902cf3491892d464f5265a81e9dff094692c8a049a3ed6a05fe7ee8/alembic-1.16.5.tar.gz", hash = "sha256:a88bb7f6e513bd4301ecf4c7f2206fe93f9913f9b48dac3b78babde2d6fe765e", size = 1969868, upload-time = "2025-08-27T18:02:05.668Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/39/4a/4c61d4c84cfd9befb6fa08a702535b27b21fff08c946bc2f6139decbf7f7/alembic-1.16.5-py3-none-any.whl", hash = "sha256:e845dfe090c5ffa7b92593ae6687c5cb1a101e91fa53868497dbd79847f9dbe3", size = 247355, upload-time = "2025-08-27T18:02:07.37Z" },
+]
+
+[[package]]
+name = "alembic"
+version = "1.18.1"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+    "python_full_version >= '3.10'",
+]
+dependencies = [
+    { name = "mako", marker = "python_full_version >= '3.10'" },
+    { name = "sqlalchemy", marker = "python_full_version >= '3.10'" },
+    { name = "tomli", marker = "python_full_version == '3.10.*'" },
+    { name = "typing-extensions", marker = "python_full_version >= '3.10'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/49/cc/aca263693b2ece99fa99a09b6d092acb89973eb2bb575faef1777e04f8b4/alembic-1.18.1.tar.gz", hash = "sha256:83ac6b81359596816fb3b893099841a0862f2117b2963258e965d70dc62fb866", size = 2044319, upload-time = "2026-01-14T18:53:14.907Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/83/36/cd9cb6101e81e39076b2fbe303bfa3c85ca34e55142b0324fcbf22c5c6e2/alembic-1.18.1-py3-none-any.whl", hash = "sha256:f1c3b0920b87134e851c25f1f7f236d8a332c34b75416802d06971df5d1b7810", size = 260973, upload-time = "2026-01-14T18:53:17.533Z" },
+]
+
 [[package]]
 name = "annotated-doc"
 version = "0.0.4"
@@ -38,6 +74,15 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" },
 ]
 
+[[package]]
+name = "asttokens"
+version = "3.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" },
+]
+
 [[package]]
 name = "babel"
 version = "2.17.0"
@@ -807,6 +852,15 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
 ]
 
+[[package]]
+name = "executing"
+version = "2.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" },
+]
+
 [[package]]
 name = "fastapi"
 version = "0.128.0"
@@ -871,6 +925,7 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/7d/ed/6bfa4109fcb23a58819600392564fea69cdc6551ffd5e69ccf1d52a40cbc/greenlet-3.2.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c", size = 271061, upload-time = "2025-08-07T13:17:15.373Z" },
     { url = "https://files.pythonhosted.org/packages/2a/fc/102ec1a2fc015b3a7652abab7acf3541d58c04d3d17a8d3d6a44adae1eb1/greenlet-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590", size = 629475, upload-time = "2025-08-07T13:42:54.009Z" },
     { url = "https://files.pythonhosted.org/packages/c5/26/80383131d55a4ac0fb08d71660fd77e7660b9db6bdb4e8884f46d9f2cc04/greenlet-3.2.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f10fd42b5ee276335863712fa3da6608e93f70629c631bf77145021600abc23c", size = 640802, upload-time = "2025-08-07T13:45:25.52Z" },
+    { url = "https://files.pythonhosted.org/packages/9f/7c/e7833dbcd8f376f3326bd728c845d31dcde4c84268d3921afcae77d90d08/greenlet-3.2.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c8c9e331e58180d0d83c5b7999255721b725913ff6bc6cf39fa2a45841a4fd4b", size = 636703, upload-time = "2025-08-07T13:53:12.622Z" },
     { url = "https://files.pythonhosted.org/packages/e9/49/547b93b7c0428ede7b3f309bc965986874759f7d89e4e04aeddbc9699acb/greenlet-3.2.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58b97143c9cc7b86fc458f215bd0932f1757ce649e05b640fea2e79b54cedb31", size = 635417, upload-time = "2025-08-07T13:18:25.189Z" },
     { url = "https://files.pythonhosted.org/packages/7f/91/ae2eb6b7979e2f9b035a9f612cf70f1bf54aad4e1d125129bef1eae96f19/greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d", size = 584358, upload-time = "2025-08-07T13:18:23.708Z" },
     { url = "https://files.pythonhosted.org/packages/f7/85/433de0c9c0252b22b16d413c9407e6cb3b41df7389afc366ca204dbc1393/greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5", size = 1113550, upload-time = "2025-08-07T13:42:37.467Z" },
@@ -881,6 +936,7 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/a4/de/f28ced0a67749cac23fecb02b694f6473f47686dff6afaa211d186e2ef9c/greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", size = 272305, upload-time = "2025-08-07T13:15:41.288Z" },
     { url = "https://files.pythonhosted.org/packages/09/16/2c3792cba130000bf2a31c5272999113f4764fd9d874fb257ff588ac779a/greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", size = 632472, upload-time = "2025-08-07T13:42:55.044Z" },
     { url = "https://files.pythonhosted.org/packages/ae/8f/95d48d7e3d433e6dae5b1682e4292242a53f22df82e6d3dda81b1701a960/greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3", size = 644646, upload-time = "2025-08-07T13:45:26.523Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/5e/405965351aef8c76b8ef7ad370e5da58d57ef6068df197548b015464001a/greenlet-3.2.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633", size = 640519, upload-time = "2025-08-07T13:53:13.928Z" },
     { url = "https://files.pythonhosted.org/packages/25/5d/382753b52006ce0218297ec1b628e048c4e64b155379331f25a7316eb749/greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079", size = 639707, upload-time = "2025-08-07T13:18:27.146Z" },
     { url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684, upload-time = "2025-08-07T13:18:25.164Z" },
     { url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647, upload-time = "2025-08-07T13:42:38.655Z" },
@@ -891,6 +947,7 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" },
     { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" },
     { url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" },
+    { url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" },
     { url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" },
     { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" },
     { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" },
@@ -901,6 +958,7 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" },
     { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" },
     { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" },
+    { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" },
     { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" },
     { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
     { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
@@ -911,6 +969,7 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" },
     { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" },
     { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" },
     { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" },
     { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" },
     { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" },
@@ -919,6 +978,7 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/f7/c0/93885c4106d2626bf51fdec377d6aef740dfa5c4877461889a7cf8e565cc/greenlet-3.2.4-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c", size = 269859, upload-time = "2025-08-07T13:16:16.003Z" },
     { url = "https://files.pythonhosted.org/packages/4d/f5/33f05dc3ba10a02dedb1485870cf81c109227d3d3aa280f0e48486cac248/greenlet-3.2.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d", size = 627610, upload-time = "2025-08-07T13:43:01.345Z" },
     { url = "https://files.pythonhosted.org/packages/b2/a7/9476decef51a0844195f99ed5dc611d212e9b3515512ecdf7321543a7225/greenlet-3.2.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:18d9260df2b5fbf41ae5139e1be4e796d99655f023a636cd0e11e6406cca7d58", size = 639417, upload-time = "2025-08-07T13:45:32.094Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/e0/849b9159cbb176f8c0af5caaff1faffdece7a8417fcc6fe1869770e33e21/greenlet-3.2.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:671df96c1f23c4a0d4077a325483c1503c96a1b7d9db26592ae770daa41233d4", size = 634751, upload-time = "2025-08-07T13:53:18.848Z" },
     { url = "https://files.pythonhosted.org/packages/5f/d3/844e714a9bbd39034144dca8b658dcd01839b72bb0ec7d8014e33e3705f0/greenlet-3.2.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:16458c245a38991aa19676900d48bd1a6f2ce3e16595051a4db9d012154e8433", size = 634020, upload-time = "2025-08-07T13:18:36.841Z" },
     { url = "https://files.pythonhosted.org/packages/6b/4c/f3de2a8de0e840ecb0253ad0dc7e2bb3747348e798ec7e397d783a3cb380/greenlet-3.2.4-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df", size = 582817, upload-time = "2025-08-07T13:18:35.48Z" },
     { url = "https://files.pythonhosted.org/packages/89/80/7332915adc766035c8980b161c2e5d50b2f941f453af232c164cff5e0aeb/greenlet-3.2.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594", size = 1111985, upload-time = "2025-08-07T13:42:42.425Z" },
@@ -941,6 +1001,7 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/32/6a/33d1702184d94106d3cdd7bfb788e19723206fce152e303473ca3b946c7b/greenlet-3.3.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:6f8496d434d5cb2dce025773ba5597f71f5410ae499d5dd9533e0653258cdb3d", size = 273658, upload-time = "2025-12-04T14:23:37.494Z" },
     { url = "https://files.pythonhosted.org/packages/d6/b7/2b5805bbf1907c26e434f4e448cd8b696a0b71725204fa21a211ff0c04a7/greenlet-3.3.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b96dc7eef78fd404e022e165ec55327f935b9b52ff355b067eb4a0267fc1cffb", size = 574810, upload-time = "2025-12-04T14:50:04.154Z" },
     { url = "https://files.pythonhosted.org/packages/94/38/343242ec12eddf3d8458c73f555c084359883d4ddc674240d9e61ec51fd6/greenlet-3.3.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73631cd5cccbcfe63e3f9492aaa664d278fda0ce5c3d43aeda8e77317e38efbd", size = 586248, upload-time = "2025-12-04T14:57:39.35Z" },
+    { url = "https://files.pythonhosted.org/packages/f0/d0/0ae86792fb212e4384041e0ef8e7bc66f59a54912ce407d26a966ed2914d/greenlet-3.3.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b299a0cb979f5d7197442dccc3aee67fce53500cd88951b7e6c35575701c980b", size = 597403, upload-time = "2025-12-04T15:07:10.831Z" },
     { url = "https://files.pythonhosted.org/packages/b6/a8/15d0aa26c0036a15d2659175af00954aaaa5d0d66ba538345bd88013b4d7/greenlet-3.3.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7dee147740789a4632cace364816046e43310b59ff8fb79833ab043aefa72fd5", size = 586910, upload-time = "2025-12-04T14:25:59.705Z" },
     { url = "https://files.pythonhosted.org/packages/e1/9b/68d5e3b7ccaba3907e5532cf8b9bf16f9ef5056a008f195a367db0ff32db/greenlet-3.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:39b28e339fc3c348427560494e28d8a6f3561c8d2bcf7d706e1c624ed8d822b9", size = 1547206, upload-time = "2025-12-04T15:04:21.027Z" },
     { url = "https://files.pythonhosted.org/packages/66/bd/e3086ccedc61e49f91e2cfb5ffad9d8d62e5dc85e512a6200f096875b60c/greenlet-3.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3c374782c2935cc63b2a27ba8708471de4ad1abaa862ffdb1ef45a643ddbb7d", size = 1613359, upload-time = "2025-12-04T14:27:26.548Z" },
@@ -948,6 +1009,7 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/1f/cb/48e964c452ca2b92175a9b2dca037a553036cb053ba69e284650ce755f13/greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e", size = 274908, upload-time = "2025-12-04T14:23:26.435Z" },
     { url = "https://files.pythonhosted.org/packages/28/da/38d7bff4d0277b594ec557f479d65272a893f1f2a716cad91efeb8680953/greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62", size = 577113, upload-time = "2025-12-04T14:50:05.493Z" },
     { url = "https://files.pythonhosted.org/packages/3c/f2/89c5eb0faddc3ff014f1c04467d67dee0d1d334ab81fadbf3744847f8a8a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32", size = 590338, upload-time = "2025-12-04T14:57:41.136Z" },
+    { url = "https://files.pythonhosted.org/packages/80/d7/db0a5085035d05134f8c089643da2b44cc9b80647c39e93129c5ef170d8f/greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45", size = 601098, upload-time = "2025-12-04T15:07:11.898Z" },
     { url = "https://files.pythonhosted.org/packages/dc/a6/e959a127b630a58e23529972dbc868c107f9d583b5a9f878fb858c46bc1a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948", size = 590206, upload-time = "2025-12-04T14:26:01.254Z" },
     { url = "https://files.pythonhosted.org/packages/48/60/29035719feb91798693023608447283b266b12efc576ed013dd9442364bb/greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794", size = 1550668, upload-time = "2025-12-04T15:04:22.439Z" },
     { url = "https://files.pythonhosted.org/packages/0a/5f/783a23754b691bfa86bd72c3033aa107490deac9b2ef190837b860996c9f/greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5", size = 1615483, upload-time = "2025-12-04T14:27:28.083Z" },
@@ -955,6 +1017,7 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" },
     { url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" },
     { url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" },
+    { url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" },
     { url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" },
     { url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" },
     { url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" },
@@ -962,6 +1025,7 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" },
     { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" },
     { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" },
+    { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" },
     { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" },
     { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" },
     { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" },
@@ -969,6 +1033,7 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" },
     { url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" },
     { url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" },
+    { url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" },
     { url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" },
     { url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" },
     { url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" },
@@ -976,6 +1041,7 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" },
     { url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" },
     { url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" },
+    { url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" },
     { url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" },
     { url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" },
     { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" },
@@ -1138,6 +1204,22 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
 ]
 
+[[package]]
+name = "inline-snapshot"
+version = "0.31.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "asttokens" },
+    { name = "executing" },
+    { name = "pytest" },
+    { name = "rich" },
+    { name = "tomli", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1c/b1/52b5ee59f73ed31d5fe21b10881bf2d121d07d54b23c0b6b74186792e620/inline_snapshot-0.31.1.tar.gz", hash = "sha256:4ea5ed70aa1d652713bbfd750606b94bd8a42483f7d3680433b3e92994495f64", size = 2606338, upload-time = "2025-11-07T07:36:18.932Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/ba/52/945db420380efbda8c69a7a4a16c53df9d7ac50d8217286b9d41e5d825ff/inline_snapshot-0.31.1-py3-none-any.whl", hash = "sha256:7875a73c986a03388c7e758fb5cb8a43d2c3a20328aa1d851bfb4ed536c4496f", size = 71965, upload-time = "2025-11-07T07:36:16.836Z" },
+]
+
 [[package]]
 name = "jinja2"
 version = "3.1.6"
@@ -1233,6 +1315,18 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/34/b3/85aef151a052a40521f5b54005908a22c437dd4c952800d5e5efce99a47d/librt-0.7.7-cp39-cp39-win_amd64.whl", hash = "sha256:264720fc288c86039c091a4ad63419a5d7cabbf1c1c9933336a957ed2483e570", size = 48957, upload-time = "2026-01-01T23:52:21.43Z" },
 ]
 
+[[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"
 version = "3.9"
@@ -2493,8 +2587,11 @@ wheels = [
 name = "sqlmodel"
 source = { editable = "." }
 dependencies = [
+    { name = "alembic", version = "1.16.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+    { name = "alembic", version = "1.18.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
     { name = "pydantic" },
     { name = "sqlalchemy" },
+    { name = "typer" },
 ]
 
 [package.dev-dependencies]
@@ -2509,6 +2606,7 @@ dev = [
     { name = "griffe-typingdoc" },
     { name = "griffe-warnings-deprecated" },
     { name = "httpx" },
+    { name = "inline-snapshot" },
     { name = "jinja2" },
     { name = "markdown-include-variants" },
     { name = "mdx-include" },
@@ -2570,8 +2668,10 @@ tests = [
 
 [package.metadata]
 requires-dist = [
+    { name = "alembic", specifier = ">=1.13.0" },
     { name = "pydantic", specifier = ">=2.7.0" },
     { name = "sqlalchemy", specifier = ">=2.0.14,<2.1.0" },
+    { name = "typer", specifier = ">=0.9.0" },
 ]
 
 [package.metadata.requires-dev]
@@ -2584,6 +2684,7 @@ dev = [
     { name = "griffe-typingdoc", specifier = "==0.3.0" },
     { name = "griffe-warnings-deprecated", specifier = "==1.1.0" },
     { name = "httpx", specifier = "==0.28.1" },
+    { name = "inline-snapshot", specifier = ">=0.13.0" },
     { name = "jinja2", specifier = "==3.1.6" },
     { name = "markdown-include-variants", specifier = "==0.0.8" },
     { name = "mdx-include", specifier = ">=1.4.1,<2.0.0" },