Skip to content

Commit 5d29d5a

Browse files
committed
Add Alembic for database migrations and update dependencies
1 parent eaa319a commit 5d29d5a

11 files changed

Lines changed: 559 additions & 13 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ dependencies = [
4141
"flet>=0.81.0,<0.82.0",
4242
"pycountry",
4343
"icloudpy",
44+
"alembic>=1.18.4",
4445
]
4546

4647
[project.urls]

tuttle/app/core/database_storage_impl.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from loguru import logger
77

88
from ... import demo
9+
from ...migrations.run import run_migrations
910

1011
from .abstractions import DatabaseStorage
1112

@@ -21,18 +22,21 @@ def __init__(self, store_demo_timetracking_dataframe: Callable, debug_mode: bool
2122
self.store_demo_dataframe_callback = store_demo_timetracking_dataframe
2223
self.debug_mode = debug_mode
2324

25+
@property
26+
def db_url(self) -> str:
27+
return f"sqlite:///{self.db_path}"
28+
2429
def create_model(self):
25-
logger.info("Creating database model")
26-
sqlmodel.SQLModel.metadata.create_all(self.db_engine, checkfirst=True)
30+
logger.info("Creating/migrating database model")
31+
run_migrations(self.db_url)
2732

2833
def ensure_database(self):
2934
if not self.db_path.exists():
30-
self.db_engine = sqlmodel.create_engine(
31-
f"sqlite:///{self.db_path}", echo=True
32-
)
35+
self.db_engine = sqlmodel.create_engine(self.db_url, echo=True)
3336
self.create_model()
3437
else:
35-
logger.info("Database exists, skipping creation")
38+
logger.info("Database exists, running pending migrations")
39+
run_migrations(self.db_url)
3640

3741
def reset_database(self):
3842
logger.info("Clearing database")
@@ -41,7 +45,7 @@ def reset_database(self):
4145
except FileNotFoundError:
4246
logger.info("Database file not found, skipping delete")
4347
self.db_engine = sqlmodel.create_engine(
44-
f"sqlite:///{self.db_path}",
48+
self.db_url,
4549
echo=self.debug_mode,
4650
)
4751
self.create_model()

tuttle/demo.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from tuttle import rendering
1717
from tuttle.calendar import Calendar, ICSCalendar
18+
from tuttle.migrations.run import run_migrations
1819
from tuttle.model import (
1920
Address,
2021
BankAccount,
@@ -402,12 +403,12 @@ def install_demo_data(
402403
db_path (str): The path to the database.
403404
on_cache_timetracking_dataframe (Optional[Callable], optional): A callback function to be called when the timetracking dataframe is cached. Defaults to None.
404405
"""
405-
db_path = f"""sqlite:///{db_path}"""
406-
logger.info(f"Installing demo data in {db_path}...")
407-
logger.info(f"Creating database engine at: {db_path}...")
408-
db_engine = create_engine(db_path)
409-
logger.info("Creating database tables...")
410-
SQLModel.metadata.create_all(db_engine)
406+
db_url = f"""sqlite:///{db_path}"""
407+
logger.info(f"Installing demo data in {db_url}...")
408+
logger.info(f"Creating database engine at: {db_url}...")
409+
db_engine = create_engine(db_url)
410+
logger.info("Creating database tables via migrations...")
411+
run_migrations(db_url)
411412

412413
logger.info("Creating demo user...")
413414
with Session(db_engine) as session:

tuttle/migrations/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Database migrations for Tuttle using Alembic."""

tuttle/migrations/alembic.ini

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Alembic configuration for Tuttle
2+
#
3+
# The database URL is set programmatically at runtime,
4+
# not from this file. See env.py for details.
5+
6+
[alembic]
7+
# path to migration scripts — relative to this file
8+
script_location = %(here)s
9+
10+
# template used to generate migration file names
11+
file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(rev)s_%%(slug)s
12+
13+
# output encoding
14+
output_encoding = utf-8
15+
16+
# Logging configuration
17+
[loggers]
18+
keys = root,sqlalchemy,alembic
19+
20+
[handlers]
21+
keys = console
22+
23+
[formatters]
24+
keys = generic
25+
26+
[logger_root]
27+
level = WARN
28+
handlers = console
29+
30+
[logger_sqlalchemy]
31+
level = WARN
32+
handlers =
33+
qualname = sqlalchemy.engine
34+
35+
[logger_alembic]
36+
level = INFO
37+
handlers =
38+
qualname = alembic
39+
40+
[handler_console]
41+
class = StreamHandler
42+
args = (sys.stderr,)
43+
level = NOTSET
44+
formatter = generic
45+
46+
[formatter_generic]
47+
format = %(levelname)-5.5s [%(name)s] %(message)s
48+
datefmt = %H:%M:%S

tuttle/migrations/env.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Alembic environment configuration for Tuttle.
2+
3+
This env.py is designed to work with SQLModel and the Tuttle data model.
4+
The database URL is injected programmatically (not from alembic.ini)
5+
so that the app can resolve ~/.tuttle/tuttle.db at runtime.
6+
"""
7+
8+
from logging.config import fileConfig
9+
10+
from alembic import context
11+
from sqlalchemy import engine_from_config, pool
12+
from sqlmodel import SQLModel
13+
14+
# Import all models so that SQLModel.metadata is fully populated
15+
from tuttle.model import ( # noqa: F401
16+
Address,
17+
User,
18+
ICloudAccount,
19+
GoogleAccount,
20+
Bank,
21+
BankAccount,
22+
Contact,
23+
Client,
24+
Contract,
25+
Project,
26+
TimeTrackingItem,
27+
Timesheet,
28+
Invoice,
29+
InvoiceItem,
30+
TimelineItem,
31+
)
32+
33+
34+
# This is the Alembic Config object
35+
config = context.config
36+
37+
# Interpret the config file for Python logging, if present
38+
if config.config_file_name is not None:
39+
fileConfig(config.config_file_name)
40+
41+
# The target metadata for autogenerate support
42+
target_metadata = SQLModel.metadata
43+
44+
45+
def run_migrations_offline() -> None:
46+
"""Run migrations in 'offline' mode.
47+
48+
Configures the context with just a URL and not an Engine.
49+
Calls to context.execute() will emit the given string to the script output.
50+
"""
51+
url = config.get_main_option("sqlalchemy.url")
52+
context.configure(
53+
url=url,
54+
target_metadata=target_metadata,
55+
literal_binds=True,
56+
dialect_opts={"paramstyle": "named"},
57+
render_as_batch=True, # Required for SQLite ALTER TABLE support
58+
)
59+
60+
with context.begin_transaction():
61+
context.run_migrations()
62+
63+
64+
def run_migrations_online() -> None:
65+
"""Run migrations in 'online' mode.
66+
67+
Creates an Engine and associates a connection with the context.
68+
"""
69+
# Support URL via -x sqlalchemy.url=... CLI option (overrides config)
70+
url = context.get_x_argument(as_dictionary=True).get("sqlalchemy.url")
71+
if url:
72+
config.set_main_option("sqlalchemy.url", url)
73+
74+
connectable = engine_from_config(
75+
config.get_section(config.config_ini_section, {}),
76+
prefix="sqlalchemy.",
77+
poolclass=pool.NullPool,
78+
)
79+
80+
with connectable.connect() as connection:
81+
context.configure(
82+
connection=connection,
83+
target_metadata=target_metadata,
84+
render_as_batch=True, # Required for SQLite ALTER TABLE support
85+
)
86+
with context.begin_transaction():
87+
context.run_migrations()
88+
89+
connectable.dispose()
90+
91+
92+
if context.is_offline_mode():
93+
run_migrations_offline()
94+
else:
95+
run_migrations_online()

tuttle/migrations/run.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""Programmatic migration runner for Tuttle.
2+
3+
This module provides functions to run Alembic migrations from within
4+
the application (e.g. at startup), without requiring the alembic CLI.
5+
6+
Usage:
7+
from tuttle.migrations.run import run_migrations
8+
run_migrations("sqlite:///path/to/tuttle.db")
9+
"""
10+
11+
from pathlib import Path
12+
13+
from alembic import command
14+
from alembic.config import Config
15+
from alembic.runtime.migration import MigrationContext
16+
from alembic.script import ScriptDirectory
17+
from loguru import logger
18+
from sqlalchemy import create_engine, inspect
19+
20+
21+
# Directory containing this module (= the migrations package)
22+
_MIGRATIONS_DIR = Path(__file__).parent
23+
24+
25+
def _get_alembic_config(db_url: str) -> Config:
26+
"""Create an Alembic Config pointing at the bundled migrations."""
27+
ini_path = _MIGRATIONS_DIR / "alembic.ini"
28+
cfg = Config(str(ini_path))
29+
cfg.set_main_option("script_location", str(_MIGRATIONS_DIR))
30+
cfg.set_main_option("sqlalchemy.url", db_url)
31+
return cfg
32+
33+
34+
def get_head_revision() -> str | None:
35+
"""Return the head revision from the migration scripts."""
36+
script = ScriptDirectory(str(_MIGRATIONS_DIR))
37+
return script.get_current_head()
38+
39+
40+
def run_migrations(db_url: str) -> None:
41+
"""Run all pending Alembic migrations on the database.
42+
43+
Handles three scenarios:
44+
1. **New database** (no tables): Alembic creates everything via migrations.
45+
2. **Pre-Alembic database** (tables exist, no alembic_version):
46+
Stamps the DB at the baseline revision, then applies new migrations.
47+
3. **Migrated database** (alembic_version exists): Applies pending migrations.
48+
"""
49+
head = get_head_revision()
50+
if head is None:
51+
logger.info("No migration scripts found, skipping migrations")
52+
return
53+
54+
cfg = _get_alembic_config(db_url)
55+
engine = create_engine(db_url)
56+
57+
try:
58+
insp = inspect(engine)
59+
table_names = insp.get_table_names()
60+
has_version_table = "alembic_version" in table_names
61+
has_app_tables = any(t != "alembic_version" for t in table_names)
62+
63+
# Get current revision
64+
with engine.connect() as conn:
65+
ctx = MigrationContext.configure(conn)
66+
current = ctx.get_current_revision()
67+
68+
if has_app_tables and not has_version_table:
69+
# Pre-Alembic database: stamp at baseline so future migrations apply
70+
script = ScriptDirectory(str(_MIGRATIONS_DIR))
71+
base = script.get_base()
72+
if base is None:
73+
logger.warning("No migration scripts found, nothing to stamp")
74+
return
75+
logger.info(
76+
"Pre-existing database detected without Alembic version table. "
77+
f"Stamping at baseline revision: {base}"
78+
)
79+
command.stamp(cfg, base)
80+
current = base
81+
82+
if current == head:
83+
logger.debug(f"Database is already at head revision ({head})")
84+
return
85+
86+
logger.info(f"Running migrations: {current}{head}")
87+
command.upgrade(cfg, "head")
88+
logger.info("Migrations completed successfully")
89+
90+
finally:
91+
engine.dispose()

tuttle/migrations/script.py.mako

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""${message}
2+
3+
Revision ID: ${up_revision}
4+
Revises: ${down_revision | comma,n}
5+
Create Date: ${create_date}
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
import sqlmodel
13+
${imports if imports else ""}
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = ${repr(up_revision)}
17+
down_revision: Union[str, None] = ${repr(down_revision)}
18+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
19+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
20+
21+
22+
def upgrade() -> None:
23+
${upgrades if upgrades else "pass"}
24+
25+
26+
def downgrade() -> None:
27+
${downgrades if downgrades else "pass"}

0 commit comments

Comments
 (0)