ff
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user