This commit is contained in:
Javanaut
2026-04-12 17:53:56 +02:00
parent 4d4272e5e8
commit 8361fc536b
5 changed files with 182 additions and 14 deletions

View File

@@ -50,6 +50,8 @@
- show-level and pattern-level shifted-season mappings, - show-level and pattern-level shifted-season mappings,
- internal database version properties. - 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. - 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`. - 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 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. - 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.

View File

@@ -1,4 +1,4 @@
import os, click import os, shutil, click
from sqlalchemy import create_engine, inspect from sqlalchemy import create_engine, inspect
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
@@ -9,7 +9,11 @@ import ffx.model
from ffx.model.show import Base from ffx.model.show import Base
from ffx.model.property import Property 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 from ffx.constants import DATABASE_VERSION
@@ -30,7 +34,13 @@ def databaseContext(databasePath: str = ''):
if not os.path.exists(ffxVarDir): if not os.path.exists(ffxVarDir):
os.makedirs(ffxVarDir) os.makedirs(ffxVarDir)
databasePath = os.path.join(ffxVarDir, 'ffx.db') 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['url'] = f"sqlite:///{databasePath}"
databaseContext['engine'] = create_engine(databaseContext['url']) databaseContext['engine'] = create_engine(databaseContext['url'])
databaseContext['session'] = sessionmaker(bind=databaseContext['engine']) databaseContext['session'] = sessionmaker(bind=databaseContext['engine'])
@@ -79,6 +89,7 @@ def ensureDatabaseVersion(databaseContext):
) )
if currentDatabaseVersion < DATABASE_VERSION: if currentDatabaseVersion < DATABASE_VERSION:
promptForDatabaseMigration(databaseContext, currentDatabaseVersion, DATABASE_VERSION)
migrateDatabase(databaseContext, currentDatabaseVersion, DATABASE_VERSION, setDatabaseVersion) migrateDatabase(databaseContext, currentDatabaseVersion, DATABASE_VERSION, setDatabaseVersion)
currentDatabaseVersion = getDatabaseVersion(databaseContext) 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): def getDatabaseVersion(databaseContext):
try: try:

View File

@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
import importlib import importlib
import importlib.util
class DatabaseVersionException(Exception): class DatabaseVersionException(Exception):
@@ -8,10 +10,47 @@ class DatabaseVersionException(Exception):
super().__init__(errorMessage) super().__init__(errorMessage)
@dataclass(frozen=True)
class MigrationStep:
versionFrom: int
versionTo: int
moduleName: str
modulePresent: bool
def getMigrationStepModuleName(versionFrom: int, versionTo: int) -> str: def getMigrationStepModuleName(versionFrom: int, versionTo: int) -> str:
return f"ffx.model.migration.step_{int(versionFrom)}_{int(versionTo)}" 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): def loadMigrationStep(versionFrom: int, versionTo: int):
moduleName = getMigrationStepModuleName(versionFrom, versionTo) moduleName = getMigrationStepModuleName(versionFrom, versionTo)
@@ -34,13 +73,10 @@ def loadMigrationStep(versionFrom: int, versionTo: int):
def migrateDatabase(databaseContext, currentVersion: int, targetVersion: int, setDatabaseVersion): def migrateDatabase(databaseContext, currentVersion: int, targetVersion: int, setDatabaseVersion):
version = int(currentVersion) for migrationStepInfo in getMigrationPlan(currentVersion, targetVersion):
target = int(targetVersion) migrationStep = loadMigrationStep(
migrationStepInfo.versionFrom,
while version < target: migrationStepInfo.versionTo,
nextVersion = version + 1 )
migrationStep = loadMigrationStep(version, nextVersion)
migrationStep(databaseContext) migrationStep(databaseContext)
version = nextVersion setDatabaseVersion(databaseContext, migrationStepInfo.versionTo)
setDatabaseVersion(databaseContext, version)

View File

@@ -7,6 +7,8 @@ import tempfile
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
import click
SRC_ROOT = Path(__file__).resolve().parents[2] / "src" SRC_ROOT = Path(__file__).resolve().parents[2] / "src"
@@ -165,9 +167,16 @@ class DatabaseContextTests(unittest.TestCase):
finally: finally:
connection.close() connection.close()
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)) reopened_context = databaseContext(str(self.database_path))
try: try:
self.assertEqual(DATABASE_VERSION, getDatabaseVersion(reopened_context)) 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 = reopened_context["session"]
session = Session() session = Session()
@@ -188,6 +197,42 @@ class DatabaseContextTests(unittest.TestCase):
finally: finally:
reopened_context["engine"].dispose() 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -11,10 +11,24 @@ if str(SRC_ROOT) not in sys.path:
sys.path.insert(0, str(SRC_ROOT)) 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): 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): def test_load_migration_step_returns_known_step(self):
migrationStep = loadMigrationStep(2, 3) migrationStep = loadMigrationStep(2, 3)
@@ -31,4 +45,3 @@ class MigrationTests(unittest.TestCase):
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()