From 8361fc536b514f5ee3285f4c610f63c7b225f694 Mon Sep 17 00:00:00 2001 From: Javanaut Date: Sun, 12 Apr 2026 17:53:56 +0200 Subject: [PATCH] ff --- requirements/project.md | 2 + src/ffx/database.py | 76 ++++++++++++++++++++++++++++- src/ffx/model/migration/__init__.py | 54 ++++++++++++++++---- tests/unit/test_database.py | 47 +++++++++++++++++- tests/unit/test_migration.py | 17 ++++++- 5 files changed, 182 insertions(+), 14 deletions(-) diff --git a/requirements/project.md b/requirements/project.md index 024e37d..dd436dc 100644 --- a/requirements/project.md +++ b/requirements/project.md @@ -50,6 +50,8 @@ - show-level and pattern-level shifted-season mappings, - internal database version properties. - The system shall apply supported ordered database migrations automatically when opening an older local database file and shall fail fast when no supported path exists. +- Before applying a required database migration, the system shall show the current version, target version, required sequential steps, and whether each corresponding migration module is present, then require user confirmation. +- Before applying a confirmed file-backed database migration, the system shall create an in-place backup copy whose filename includes the covered version range. - Detailed show, pattern, and duplicate-match management rules live in `requirements/pattern_management.md`. - The system shall inspect source media using `ffprobe` and derive a structured description of container metadata and streams. - The system shall optionally open a Textual UI to browse shows, inspect files, and create, edit, or delete shows, patterns, stream definitions, tags, and shifted-season rules. diff --git a/src/ffx/database.py b/src/ffx/database.py index 70fc1a6..8665ee3 100644 --- a/src/ffx/database.py +++ b/src/ffx/database.py @@ -1,4 +1,4 @@ -import os, click +import os, shutil, click from sqlalchemy import create_engine, inspect from sqlalchemy.orm import sessionmaker @@ -9,7 +9,11 @@ import ffx.model from ffx.model.show import Base from ffx.model.property import Property -from ffx.model.migration import DatabaseVersionException, migrateDatabase +from ffx.model.migration import ( + DatabaseVersionException, + getMigrationPlan, + migrateDatabase, +) from ffx.constants import DATABASE_VERSION @@ -30,7 +34,13 @@ def databaseContext(databasePath: str = ''): if not os.path.exists(ffxVarDir): os.makedirs(ffxVarDir) databasePath = os.path.join(ffxVarDir, 'ffx.db') + else: + databasePath = os.path.expanduser(databasePath) + if databasePath != ':memory:': + databasePath = os.path.abspath(databasePath) + + databaseContext['path'] = databasePath databaseContext['url'] = f"sqlite:///{databasePath}" databaseContext['engine'] = create_engine(databaseContext['url']) databaseContext['session'] = sessionmaker(bind=databaseContext['engine']) @@ -79,6 +89,7 @@ def ensureDatabaseVersion(databaseContext): ) if currentDatabaseVersion < DATABASE_VERSION: + promptForDatabaseMigration(databaseContext, currentDatabaseVersion, DATABASE_VERSION) migrateDatabase(databaseContext, currentDatabaseVersion, DATABASE_VERSION, setDatabaseVersion) currentDatabaseVersion = getDatabaseVersion(databaseContext) @@ -88,6 +99,67 @@ def ensureDatabaseVersion(databaseContext): ) +def promptForDatabaseMigration(databaseContext, currentDatabaseVersion: int, targetDatabaseVersion: int): + migrationPlan = getMigrationPlan(currentDatabaseVersion, targetDatabaseVersion) + + click.echo("Database migration required.") + click.echo(f"Current version: {currentDatabaseVersion}") + click.echo(f"Target version: {targetDatabaseVersion}") + click.echo("Steps required:") + + missingSteps = [] + for migrationStep in migrationPlan: + moduleStatus = "present" if migrationStep.modulePresent else "missing" + click.echo( + f" {migrationStep.versionFrom} -> {migrationStep.versionTo}: " + + f"{migrationStep.moduleName} [{moduleStatus}]" + ) + if not migrationStep.modulePresent: + missingSteps.append(migrationStep) + + if missingSteps: + firstMissingStep = missingSteps[0] + raise DatabaseVersionException( + f"No migration path from database version " + + f"{firstMissingStep.versionFrom} to {firstMissingStep.versionTo}" + ) + + if not click.confirm( + "Create a backup and continue with database migration?", + default=True, + ): + raise click.ClickException("Database migration aborted by user.") + + backupPath = backupDatabaseBeforeMigration( + databaseContext, + currentDatabaseVersion, + targetDatabaseVersion, + ) + click.echo(f"Database backup created: {backupPath}") + + +def backupDatabaseBeforeMigration(databaseContext, currentDatabaseVersion: int, targetDatabaseVersion: int) -> str: + databasePath = databaseContext.get('path', '') + if not databasePath or databasePath == ':memory:': + raise click.ClickException("Database migration backup requires a file-backed SQLite database.") + + if not os.path.isfile(databasePath): + raise click.ClickException(f"Database file not found for backup: {databasePath}") + + backupPath = f"{databasePath}.v{currentDatabaseVersion}-to-v{targetDatabaseVersion}.bak" + backupIndex = 1 + while os.path.exists(backupPath): + backupPath = ( + f"{databasePath}.v{currentDatabaseVersion}-to-v{targetDatabaseVersion}.{backupIndex}.bak" + ) + backupIndex += 1 + + databaseContext['engine'].dispose() + shutil.copy2(databasePath, backupPath) + + return backupPath + + def getDatabaseVersion(databaseContext): try: diff --git a/src/ffx/model/migration/__init__.py b/src/ffx/model/migration/__init__.py index 4fd8681..0b51712 100644 --- a/src/ffx/model/migration/__init__.py +++ b/src/ffx/model/migration/__init__.py @@ -1,6 +1,8 @@ from __future__ import annotations +from dataclasses import dataclass import importlib +import importlib.util class DatabaseVersionException(Exception): @@ -8,10 +10,47 @@ class DatabaseVersionException(Exception): super().__init__(errorMessage) +@dataclass(frozen=True) +class MigrationStep: + versionFrom: int + versionTo: int + moduleName: str + modulePresent: bool + + def getMigrationStepModuleName(versionFrom: int, versionTo: int) -> str: return f"ffx.model.migration.step_{int(versionFrom)}_{int(versionTo)}" +def migrationStepModuleExists(versionFrom: int, versionTo: int) -> bool: + moduleName = getMigrationStepModuleName(versionFrom, versionTo) + + try: + return importlib.util.find_spec(moduleName) is not None + except ModuleNotFoundError: + return False + + +def getMigrationPlan(currentVersion: int, targetVersion: int) -> list[MigrationStep]: + version = int(currentVersion) + target = int(targetVersion) + migrationPlan = [] + + while version < target: + nextVersion = version + 1 + migrationPlan.append( + MigrationStep( + versionFrom=version, + versionTo=nextVersion, + moduleName=getMigrationStepModuleName(version, nextVersion), + modulePresent=migrationStepModuleExists(version, nextVersion), + ) + ) + version = nextVersion + + return migrationPlan + + def loadMigrationStep(versionFrom: int, versionTo: int): moduleName = getMigrationStepModuleName(versionFrom, versionTo) @@ -34,13 +73,10 @@ def loadMigrationStep(versionFrom: int, versionTo: int): def migrateDatabase(databaseContext, currentVersion: int, targetVersion: int, setDatabaseVersion): - version = int(currentVersion) - target = int(targetVersion) - - while version < target: - nextVersion = version + 1 - migrationStep = loadMigrationStep(version, nextVersion) + for migrationStepInfo in getMigrationPlan(currentVersion, targetVersion): + migrationStep = loadMigrationStep( + migrationStepInfo.versionFrom, + migrationStepInfo.versionTo, + ) migrationStep(databaseContext) - version = nextVersion - setDatabaseVersion(databaseContext, version) - + setDatabaseVersion(databaseContext, migrationStepInfo.versionTo) diff --git a/tests/unit/test_database.py b/tests/unit/test_database.py index af99d11..f800038 100644 --- a/tests/unit/test_database.py +++ b/tests/unit/test_database.py @@ -7,6 +7,8 @@ import tempfile import unittest from unittest.mock import patch +import click + SRC_ROOT = Path(__file__).resolve().parents[2] / "src" @@ -165,9 +167,16 @@ class DatabaseContextTests(unittest.TestCase): finally: connection.close() - reopened_context = databaseContext(str(self.database_path)) + with patch("ffx.database.click.confirm", return_value=True) as mocked_confirm, patch( + "ffx.database.click.echo" + ) as mocked_echo: + reopened_context = databaseContext(str(self.database_path)) try: self.assertEqual(DATABASE_VERSION, getDatabaseVersion(reopened_context)) + mocked_confirm.assert_called_once() + + backup_path = Path(f"{self.database_path}.v2-to-v3.bak") + self.assertTrue(backup_path.exists()) Session = reopened_context["session"] session = Session() @@ -188,6 +197,42 @@ class DatabaseContextTests(unittest.TestCase): finally: reopened_context["engine"].dispose() + echoedLines = [call.args[0] for call in mocked_echo.call_args_list] + self.assertIn("Database migration required.", echoedLines) + self.assertIn("Current version: 2", echoedLines) + self.assertIn(f"Target version: {DATABASE_VERSION}", echoedLines) + self.assertIn( + " 2 -> 3: ffx.model.migration.step_2_3 [present]", + echoedLines, + ) + + def test_database_context_aborts_migration_when_confirmation_is_declined(self): + context = databaseContext(str(self.database_path)) + try: + Session = context["session"] + session = Session() + try: + version_row = ( + session.query(Property) + .filter(Property.key == DATABASE_VERSION_KEY) + .first() + ) + version_row.value = "2" + session.commit() + finally: + session.close() + finally: + context["engine"].dispose() + + with patch("ffx.database.click.confirm", return_value=False), patch( + "ffx.database.click.echo" + ): + with self.assertRaises(click.ClickException) as raisedContext: + databaseContext(str(self.database_path)) + + self.assertEqual("Database migration aborted by user.", str(raisedContext.exception)) + self.assertFalse(Path(f"{self.database_path}.v2-to-v3.bak").exists()) + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_migration.py b/tests/unit/test_migration.py index 7f3ae07..7af41c7 100644 --- a/tests/unit/test_migration.py +++ b/tests/unit/test_migration.py @@ -11,10 +11,24 @@ if str(SRC_ROOT) not in sys.path: sys.path.insert(0, str(SRC_ROOT)) -from ffx.model.migration import DatabaseVersionException, loadMigrationStep, migrateDatabase # noqa: E402 +from ffx.model.migration import ( # noqa: E402 + DatabaseVersionException, + getMigrationPlan, + loadMigrationStep, + migrateDatabase, +) class MigrationTests(unittest.TestCase): + def test_get_migration_plan_lists_known_step_with_module_presence(self): + migrationPlan = getMigrationPlan(2, 3) + + self.assertEqual(1, len(migrationPlan)) + self.assertEqual(2, migrationPlan[0].versionFrom) + self.assertEqual(3, migrationPlan[0].versionTo) + self.assertEqual("ffx.model.migration.step_2_3", migrationPlan[0].moduleName) + self.assertTrue(migrationPlan[0].modulePresent) + def test_load_migration_step_returns_known_step(self): migrationStep = loadMigrationStep(2, 3) @@ -31,4 +45,3 @@ class MigrationTests(unittest.TestCase): if __name__ == "__main__": unittest.main() -