Coverage for nexusLIMS/db/migrations/env.py: 100%
61 statements
« prev ^ index » next coverage.py v7.11.3, created at 2026-03-24 05:23 +0000
« prev ^ index » next coverage.py v7.11.3, created at 2026-03-24 05:23 +0000
1# ruff: noqa: ERA001
2"""Alembic migration environment configuration for NexusLIMS.
4This module configures the Alembic migration environment for the NexusLIMS database.
5It handles both online and offline migration modes and automatically configures the
6database URL from the NexusLIMS settings.
8Key features:
9 - Automatically reads database path from NX_DB_PATH environment variable
10 - Configures SQLModel metadata for autogenerate support
11 - Supports both online (live database) and offline (SQL script) migrations
12 - Imports all SQLModel classes to ensure complete schema detection
14Usage:
15 This file is automatically used by Alembic when running migration commands:
16 uv run alembic upgrade head
17 uv run alembic revision --autogenerate -m "description"
19Note:
20 All SQLModel model classes must be imported in this file (even if not directly
21 used) to ensure Alembic can detect them for autogenerate operations.
22"""
24import os
25import re
26from pathlib import Path
28import sqlalchemy as sa
29from alembic import context
30from alembic.script import ScriptDirectory
31from sqlalchemy import engine_from_config, pool
33# Import SQLModel metadata and models
34from sqlmodel import SQLModel
36from nexusLIMS.db.models import Instrument, SessionLog, UploadLog # noqa: F401
38# Derive the migrations directory from this file's own location.
39# Works regardless of whether the package is installed or run from source.
40_MIGRATIONS_DIR = Path(__file__).resolve().parent
42# this is the Alembic Config object, which provides
43# access to the values within the .ini file in use.
44config = context.config
46# Set sqlalchemy.url directly from the environment variable rather than going
47# through nexusLIMS.config.settings, because Settings validation requires fields
48# (like NX_CDCS_TOKEN, NX_DATA_PATH, etc.) that are irrelevant for database
49# migrations. This allows 'nexuslims db' commands to work with only
50# NX_DB_PATH set.
51_db_path = os.getenv("NX_DB_PATH", "")
52config.set_main_option("sqlalchemy.url", f"sqlite:///{_db_path}")
54# Set target_metadata to SQLModel metadata for autogenerate support
55target_metadata = SQLModel.metadata
57# other values from the config, defined by the needs of env.py,
58# can be acquired:
59# my_important_option = config.get_main_option("my_important_option")
60# ... etc.
63def _generate_revision_id(context_obj) -> str:
64 """Generate a user-friendly sequential revision ID.
66 This function creates revision IDs in the format: NNN_description
67 where NNN is a zero-padded sequential number.
69 Examples: 001_initial_schema, 002_add_upload_log, 003_add_constraints
71 This is much more readable than random hex values while maintaining
72 clear ordering.
73 """
74 script = ScriptDirectory.from_config(config)
76 # Find the highest existing numeric revision
77 max_num = 0
78 for rev in script.walk_revisions():
79 if rev.revision and rev.revision[0].isdigit():
80 try:
81 # Extract numeric prefix (e.g., "001" from "001_description")
82 num_part = rev.revision.split("_")[0]
83 max_num = max(max_num, int(num_part))
84 except (ValueError, IndexError): # pragma: no cover
85 # Skip if not in our format
86 pass
88 # Generate next sequential number
89 next_num = max_num + 1
91 # Get the message from the context (cleaned up for use in ID)
92 message = context_obj.opts.get("message", "migration")
93 if message:
94 # Convert message to lowercase, replace spaces/special chars with underscores
95 sanitized = re.sub(r"[^\w\s-]", "", message.lower())
96 sanitized = re.sub(r"[-\s]+", "_", sanitized).strip("_")
97 # Limit length to keep IDs reasonable
98 sanitized = sanitized[:50]
99 else:
100 sanitized = "migration"
102 return f"{next_num:03d}_{sanitized}"
105def process_revision_directives(context_obj, _revision, directives):
106 """Alembic hook to customize revision generation.
108 This is called by Alembic when creating new migrations via
109 'nexuslims db alembic revision --autogenerate'.
111 It replaces the default random hex revision ID with a sequential
112 numbered ID for better readability.
113 """
114 if config.cmd_opts and config.cmd_opts.autogenerate:
115 script = directives[0]
116 if script.upgrade_ops.is_empty():
117 # Don't generate empty migrations
118 directives[:] = []
119 return
121 # Use our custom revision ID generator
122 script.rev_id = _generate_revision_id(context_obj)
125def run_migrations_offline() -> None:
126 """Run migrations in 'offline' mode.
128 This configures the context with just a URL
129 and not an Engine, though an Engine is acceptable
130 here as well. By skipping the Engine creation
131 we don't even need a DBAPI to be available.
133 Calls to context.execute() here emit the given string to the
134 script output.
136 """
137 url = config.get_main_option("sqlalchemy.url")
138 context.configure(
139 url=url,
140 target_metadata=target_metadata,
141 literal_binds=True,
142 dialect_opts={"paramstyle": "named"},
143 process_revision_directives=process_revision_directives,
144 )
146 with context.begin_transaction():
147 context.run_migrations()
150def run_migrations_online() -> None:
151 """Run migrations in 'online' mode.
153 In this scenario we need to create an Engine
154 and associate a connection with the context.
156 """
157 from nexusLIMS.db.migrations.utils import create_backup # noqa: PLC0415
159 connectable = engine_from_config(
160 config.get_section(config.config_ini_section, {}),
161 prefix="sqlalchemy.",
162 poolclass=pool.NullPool,
163 )
165 with connectable.connect() as connection:
166 context.configure(
167 connection=connection,
168 target_metadata=target_metadata,
169 process_revision_directives=process_revision_directives,
170 )
172 with context.begin_transaction():
173 # Create automatic backup before running migrations
174 # Skip backup for new database initialization (no alembic_version table yet)
175 try:
176 destination_rev = context.get_context().opts.get("destination_rev")
177 if destination_rev:
178 # Check if alembic_version table exists
179 # (indicates existing database)
180 inspector = sa.inspect(connection)
181 has_alembic_version = (
182 "alembic_version" in inspector.get_table_names()
183 )
185 if has_alembic_version:
186 create_backup(connection)
187 # else: new database initialization, skip backup
188 except Exception: # noqa: S110
189 # If we can't determine if backup is needed, skip it
190 # (e.g., for read-only operations like current/history)
191 pass
193 context.run_migrations()
196if context.is_offline_mode():
197 run_migrations_offline()
198else:
199 run_migrations_online()