# app/modules/migrations.py """ Module migration discovery utility. Provides utilities for discovering and managing module-specific migrations. Each self-contained module can have its own migrations directory that will be included in Alembic's version locations. Module Migration Structure: app/modules// └── migrations/ └── versions/ ├── _001_initial.py ├── _002_add_field.py └── ... Migration Naming Convention: {module_code}_{sequence}_{description}.py Example: cms_001_create_content_pages.py This ensures no collision between modules and makes it clear which module owns each migration. Usage: # Get all migration paths for Alembic from app.modules.migrations import get_all_migration_paths paths = get_all_migration_paths() # Returns: [Path("alembic/versions"), Path("app/modules/cms/migrations/versions"), ...] # In alembic/env.py context.configure( version_locations=[str(p) for p in get_all_migration_paths()], ... ) """ import logging from pathlib import Path from typing import TYPE_CHECKING if TYPE_CHECKING: from app.modules.base import ModuleDefinition logger = logging.getLogger(__name__) def get_core_migrations_path() -> Path: """ Get the path to the core (non-module) migrations directory. Returns: Path to alembic/versions/ """ # Navigate from app/modules/migrations.py to project root project_root = Path(__file__).parent.parent.parent return project_root / "alembic" / "versions" def get_module_migrations_path(module: "ModuleDefinition") -> Path | None: """ Get the migrations path for a specific module. Args: module: Module definition to get migrations path for Returns: Path to module's migrations/versions/ directory, or None if not configured """ migrations_dir = module.get_migrations_dir() if migrations_dir is None: return None versions_dir = migrations_dir / "versions" return versions_dir def discover_module_migrations() -> list[Path]: """ Discover all module migration directories. Scans all registered modules for those with migrations_path configured and returns paths to their versions directories. Returns: List of paths to module migration version directories """ # Import here to avoid circular imports from app.modules.registry import MODULES paths: list[Path] = [] for module in MODULES.values(): if not module.migrations_path: continue versions_path = get_module_migrations_path(module) if versions_path is None: continue if versions_path.exists(): paths.append(versions_path) logger.debug(f"Found migrations for module {module.code}: {versions_path}") else: logger.debug( f"Module {module.code} has migrations_path but no versions directory" ) return sorted(paths) # Sort for deterministic ordering def get_all_migration_paths() -> list[Path]: """ Get all migration paths including core and module migrations. Returns: List of paths starting with core migrations, followed by module migrations in alphabetical order by module code. Example: [ Path("alembic/versions"), Path("app/modules/billing/migrations/versions"), Path("app/modules/cms/migrations/versions"), ] """ paths = [get_core_migrations_path()] paths.extend(discover_module_migrations()) return paths def get_migration_order() -> list[str]: """ Get the order in which module migrations should be applied. Returns migrations in dependency order - modules with no dependencies first, then modules that depend on them, etc. Returns: List of module codes in migration order """ # Import here to avoid circular imports from app.modules.registry import MODULES # Build dependency graph modules_with_migrations = [ m for m in MODULES.values() if m.migrations_path ] if not modules_with_migrations: return [] # Topological sort based on dependencies ordered: list[str] = [] visited: set[str] = set() temp_visited: set[str] = set() def visit(code: str) -> None: if code in visited: return if code in temp_visited: raise ValueError(f"Circular dependency detected involving {code}") module = MODULES.get(code) if module is None: return temp_visited.add(code) # Visit dependencies first for dep in module.requires: if dep in {m.code for m in modules_with_migrations}: visit(dep) temp_visited.remove(code) visited.add(code) if module.migrations_path: ordered.append(code) for module in modules_with_migrations: visit(module.code) return ordered def validate_migration_names() -> list[str]: """ Validate that all module migrations follow the naming convention. Returns: List of validation errors (empty if all valid) """ errors: list[str] = [] for path in discover_module_migrations(): # Extract module code from path (e.g., app/modules/cms/migrations/versions -> cms) module_dir = path.parent.parent # migrations/versions -> migrations -> module module_code = module_dir.name.replace("_", "-") # cms or dev_tools -> dev-tools for migration_file in path.glob("*.py"): name = migration_file.stem if name == "__pycache__": continue # Check prefix matches module code expected_prefix = module_code.replace("-", "_") if not name.startswith(f"{expected_prefix}_"): errors.append( f"Migration {migration_file} should start with '{expected_prefix}_'" ) return errors def create_module_migrations_dir(module: "ModuleDefinition") -> Path: """ Create the migrations directory structure for a module. Args: module: Module to create migrations for Returns: Path to the created versions directory """ module_dir = module.get_module_dir() migrations_dir = module_dir / "migrations" versions_dir = migrations_dir / "versions" versions_dir.mkdir(parents=True, exist_ok=True) # Create __init__.py files init_files = [ migrations_dir / "__init__.py", versions_dir / "__init__.py", ] for init_file in init_files: if not init_file.exists(): init_file.write_text('"""Module migrations."""\n') logger.info(f"Created migrations directory for module {module.code}") return versions_dir __all__ = [ "get_core_migrations_path", "get_module_migrations_path", "discover_module_migrations", "get_all_migration_paths", "get_migration_order", "validate_migration_names", "create_module_migrations_dir", ]