Skip to content

Commit 84bf46c

Browse files
committed
refactor: add RunMigrationsUseCase
1 parent 2ce7a95 commit 84bf46c

File tree

9 files changed

+179
-52
lines changed

9 files changed

+179
-52
lines changed

mpt_tool/cli.py

Lines changed: 8 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import datetime as dt
2+
import logging
23
from pathlib import Path
34
from typing import Annotated
45

56
import typer
67

78
from mpt_tool.constants import MIGRATION_FOLDER
89
from mpt_tool.enums import MigrationTypeEnum
9-
from mpt_tool.errors import StateNotFoundError
10-
from mpt_tool.managers import FileMigrationManager, FileStateManager, MigrationFolderError
10+
from mpt_tool.errors import RunMigrationError
1111
from mpt_tool.templates import MIGRATION_SCAFFOLDING_TEMPLATE
12+
from mpt_tool.use_cases import RunMigrationsUseCase
1213

1314
app = typer.Typer(help="MPT CLI - Migration tool for extensions.", no_args_is_help=True)
1415

@@ -50,37 +51,13 @@ def migrate( # noqa: C901, WPS238, WPS210, WPS213, WPS231
5051
migration_type = MigrationTypeEnum.DATA if data else MigrationTypeEnum.SCHEMA
5152
typer.echo(f"Running {migration_type} migrations...")
5253

54+
run_migration = RunMigrationsUseCase()
5355
try:
54-
validated_migration_files = FileMigrationManager.validate()
55-
except MigrationFolderError as error:
56-
typer.secho(str(error), fg=typer.colors.RED)
56+
run_migration.execute(migration_type)
57+
except RunMigrationError as error:
58+
typer.secho(f"Error running migrations: {error!s}", fg=typer.colors.RED)
5759
raise typer.Abort
5860

59-
for migration_file in validated_migration_files:
60-
migration_id = migration_file.migration_id
61-
try:
62-
state = FileStateManager.get_by_id(migration_id)
63-
except StateNotFoundError:
64-
state = FileStateManager.new(migration_id, migration_type, migration_file.order_id)
65-
66-
if state.started_at is not None:
67-
continue
68-
69-
typer.secho(f"Running migration: {migration_id}...", fg=typer.colors.GREEN)
70-
state.start()
71-
migration_module = FileMigrationManager.load_migration(migration_file)
72-
migration_instance = migration_module.Command()
73-
try:
74-
migration_instance.run()
75-
except Exception as error:
76-
state.failed()
77-
FileStateManager.save_state(state)
78-
typer.secho(f"Migration {migration_id} failed: {error}", fg=typer.colors.RED)
79-
raise typer.Abort
80-
81-
state.applied()
82-
FileStateManager.save_state(state)
83-
8461
typer.secho("Migrations completed successfully.", fg=typer.colors.GREEN)
8562
return
8663

@@ -89,7 +66,6 @@ def migrate( # noqa: C901, WPS238, WPS210, WPS213, WPS231
8966
typer.echo(f"Scaffolding migration: {filename_suffix}.")
9067
migration_folder = Path(MIGRATION_FOLDER)
9168
migration_folder.mkdir(parents=True, exist_ok=True)
92-
# TODO: add init file
9369
timestamp = dt.datetime.now(tz=dt.UTC).strftime("%Y%m%d%H%M%S")
9470
# TODO: add filename validation
9571
filename = f"{timestamp}_{filename_suffix}.py"
@@ -111,4 +87,5 @@ def migrate( # noqa: C901, WPS238, WPS210, WPS213, WPS231
11187

11288
def main() -> None:
11389
"""Entry point for the CLI."""
90+
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
11491
app()

mpt_tool/commands/base.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
11
import logging
22
from abc import ABC, abstractmethod
33

4+
from mpt_tool.enums import MigrationTypeEnum
5+
46
logger = logging.getLogger(__name__)
57

68

79
class BaseCommand(ABC):
810
"""Abstract base class for all migration commands."""
911

12+
_type: MigrationTypeEnum
13+
1014
def __init__(self):
11-
self.logger = logger
15+
self.log = logger
16+
17+
@property
18+
def type(self) -> MigrationTypeEnum:
19+
"""The type of migration this command represents."""
20+
return self._type
1221

1322
@abstractmethod
1423
def run(self) -> None:

mpt_tool/errors.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,9 @@ class MigrationFolderError(BaseError):
1717
"""Error accessing migrations folder."""
1818

1919

20+
class RunMigrationError(BaseError):
21+
"""Error running migrations."""
22+
23+
2024
class StateNotFoundError(BaseError):
2125
"""Error getting state from state file."""

mpt_tool/managers.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ class FileMigrationManager:
1616
"""Manages migration files."""
1717

1818
_migration_folder: Path = Path(MIGRATION_FOLDER)
19-
_validated_migrations: tuple[MigrationFile, ...] = ()
2019

2120
@classmethod
2221
def validate(cls) -> tuple[MigrationFile, ...]:
@@ -25,9 +24,14 @@ def validate(cls) -> tuple[MigrationFile, ...]:
2524
raise MigrationFolderError(f"Migration folder not found: {cls._migration_folder}")
2625

2726
migrations = tuple(
28-
MigrationFile.build_from_path(path)
29-
for path in cls._migration_folder.glob("*.py")
30-
if re.match(r"\d+_.*\.py", path.name)
27+
sorted(
28+
(
29+
MigrationFile.build_from_path(path)
30+
for path in cls._migration_folder.glob("*.py")
31+
if re.match(r"\d+_.*\.py", path.name)
32+
),
33+
key=lambda migration_file: migration_file.order_id,
34+
)
3135
)
3236
if not migrations:
3337
raise MigrationFolderError(f"No migration files found in {cls._migration_folder}")
@@ -39,8 +43,6 @@ def validate(cls) -> tuple[MigrationFile, ...]:
3943
f"Duplicate migration filename found: {duplicated_migrations[0]}"
4044
)
4145

42-
cls._validated_migrations = migrations
43-
4446
return migrations
4547

4648
@classmethod

mpt_tool/use_cases/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from mpt_tool.use_cases.run_migrations import RunMigrationsUseCase
2+
3+
__all__ = ["RunMigrationsUseCase"]
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import logging
2+
3+
from mpt_tool.enums import MigrationTypeEnum
4+
from mpt_tool.errors import (
5+
LoadMigrationError,
6+
MigrationFolderError,
7+
RunMigrationError,
8+
StateNotFoundError,
9+
)
10+
from mpt_tool.managers import FileMigrationManager, FileStateManager
11+
from mpt_tool.models import Migration
12+
13+
logger = logging.getLogger(__name__)
14+
15+
16+
class RunMigrationsUseCase:
17+
"""Service to run migrations."""
18+
19+
def __init__(
20+
self,
21+
file_migration_manager: FileMigrationManager | None = None,
22+
state_manager: FileStateManager | None = None,
23+
):
24+
self.file_migration_manager = file_migration_manager or FileMigrationManager
25+
self.state_manager = state_manager or FileStateManager
26+
27+
def execute(self, migration_type: MigrationTypeEnum) -> None: # noqa: C901, WPS213, WPS231
28+
"""Run all migrations of a given type.
29+
30+
Args:
31+
migration_type: The type of migrations to run.
32+
33+
Raises:
34+
RunMigrationError: If an error occurs during migration execution.
35+
"""
36+
try:
37+
migration_files = self.file_migration_manager.validate()
38+
except MigrationFolderError as error:
39+
raise RunMigrationError(str(error)) from error
40+
41+
for migration_file in migration_files:
42+
try:
43+
migration = self.file_migration_manager.load_migration(migration_file)
44+
except LoadMigrationError as error:
45+
raise RunMigrationError(str(error)) from error
46+
47+
migration_instance = migration.Command()
48+
if migration_instance.type != migration_type:
49+
continue
50+
51+
state = self._get_or_create_state(
52+
migration_file.migration_id, migration_type, migration_file.order_id
53+
)
54+
if state.applied_at is not None:
55+
logger.debug("Skipping applied migration: %s", migration_file.migration_id)
56+
continue
57+
58+
logger.info("Running migration: %s", migration_file.migration_id)
59+
state.start()
60+
try:
61+
migration_instance.run()
62+
except Exception as error:
63+
state.failed()
64+
self.state_manager.save_state(state)
65+
raise RunMigrationError(
66+
f"Migration {migration_file.migration_id} failed: {error!s}"
67+
) from error
68+
69+
state.applied()
70+
self.state_manager.save_state(state)
71+
72+
def _get_or_create_state(
73+
self, migration_id: str, migration_type: MigrationTypeEnum, order_id: int
74+
) -> Migration:
75+
try:
76+
state = self.state_manager.get_by_id(migration_id)
77+
except StateNotFoundError:
78+
state = self.state_manager.new(migration_id, migration_type, order_id)
79+
80+
return state

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,6 @@ show-source = true
8787
statistics = false
8888
per-file-ignores = [
8989
"tests/*: WPS202",
90-
"mpt_tool/cli.py: WPS232",
9190
]
9291

9392
[tool.ruff]

tests/conftest.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import logging
2+
3+
import pytest
4+
5+
6+
@pytest.fixture
7+
def log(caplog):
8+
caplog.set_level(logging.DEBUG)
9+
return caplog

tests/test_cli.py

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,12 @@ def mock_chdir(monkeypatch, tmp_path):
1818
def migration_folder(tmp_path):
1919
migration_folder_path = tmp_path / MIGRATION_FOLDER
2020
migration_folder_path.mkdir(exist_ok=True)
21-
(migration_folder_path / "__init__.py").touch()
2221
return migration_folder_path
2322

2423

2524
@pytest.fixture
2625
def data_migration_file(migration_folder):
27-
migration_id = "fake_file_name"
26+
migration_id = "fake_data_file_name"
2827
migration_file = migration_folder / f"20250406020202_{migration_id}.py"
2928
migration_file.write_text(
3029
encoding="utf-8",
@@ -40,9 +39,17 @@ def data_migration_file_error(migration_folder):
4039
file_data = MIGRATION_SCAFFOLDING_TEMPLATE.substitute(command_name="DataBaseCommand").replace(
4140
"pass", "raise Exception('Fake Error')"
4241
)
42+
migration_file.write_text(encoding="utf-8", data=file_data)
43+
return {"migration_id": migration_id, "full_filename": migration_file}
44+
45+
46+
@pytest.fixture
47+
def schema_migration_file(migration_folder):
48+
migration_id = "fake_schema_file_name"
49+
migration_file = migration_folder / f"20260101010101_{migration_id}.py"
4350
migration_file.write_text(
4451
encoding="utf-8",
45-
data=file_data,
52+
data=MIGRATION_SCAFFOLDING_TEMPLATE.substitute(command_name="SchemaBaseCommand"),
4653
)
4754
return {"migration_id": migration_id, "full_filename": migration_file}
4855

@@ -84,20 +91,57 @@ def test_migrate_command_multiple_options_error(runner):
8491

8592

8693
@freeze_time("2025-04-06 13:00:00")
87-
def test_migrate_data_migration(data_migration_file, migration_state_file, runner):
94+
def test_migrate_data_migration(
95+
data_migration_file, schema_migration_file, migration_state_file, runner, log
96+
):
8897
result = runner.invoke(app, ["migrate", "--data"])
8998

9099
assert result.exit_code == 0, result.output
91100
migration_state_data = json.loads(migration_state_file.read_text(encoding="utf-8"))
92-
assert migration_state_data["fake_file_name"] == {
93-
"migration_id": "fake_file_name",
94-
"order_id": 20250406020202,
95-
"type": "data",
96-
"started_at": "2025-04-06T13:00:00+00:00",
97-
"applied_at": "2025-04-06T13:00:00+00:00",
101+
assert migration_state_data == {
102+
"fake_data_file_name": {
103+
"migration_id": "fake_data_file_name",
104+
"order_id": 20250406020202,
105+
"type": "data",
106+
"started_at": "2025-04-06T13:00:00+00:00",
107+
"applied_at": "2025-04-06T13:00:00+00:00",
108+
}
109+
}
110+
assert "Running data migrations..." in result.output
111+
assert "Running migration: fake_data_file_name" in log.text
112+
assert "Migrations completed successfully." in result.output
113+
114+
115+
@freeze_time("2025-04-06 13:00:00")
116+
def test_migrate_skip_migration_already_applied(
117+
data_migration_file, migration_state_file, runner, log
118+
):
119+
applied_state_data = {
120+
"fake_data_file_name": {
121+
"migration_id": "fake_data_file_name",
122+
"order_id": 20250406020202,
123+
"type": "data",
124+
"started_at": "2025-04-06T13:00:00+00:00",
125+
"applied_at": "2025-04-06T13:00:00+00:00",
126+
}
127+
}
128+
migration_state_file.write_text(data=json.dumps(applied_state_data))
129+
130+
result = runner.invoke(app, ["migrate", "--data"])
131+
132+
assert result.exit_code == 0, result.output
133+
migration_state_data = json.loads(migration_state_file.read_text(encoding="utf-8"))
134+
assert migration_state_data == {
135+
"fake_data_file_name": {
136+
"migration_id": "fake_data_file_name",
137+
"order_id": 20250406020202,
138+
"type": "data",
139+
"started_at": "2025-04-06T13:00:00+00:00",
140+
"applied_at": "2025-04-06T13:00:00+00:00",
141+
}
98142
}
99143
assert "Running data migrations..." in result.output
100-
assert "Running migration: fake_file_name" in result.output
144+
assert "Skipping applied migration: fake_data_file_name" in log.text
101145
assert "Migrations completed successfully." in result.output
102146

103147

@@ -119,7 +163,7 @@ def test_migrate_data_duplicate_migration(runner, migration_folder):
119163

120164

121165
@freeze_time("2025-04-06 13:00:00")
122-
def test_migrate_data_run_script_fail(data_migration_file_error, migration_state_file, runner):
166+
def test_migrate_data_run_script_fail(data_migration_file_error, migration_state_file, runner, log):
123167
result = runner.invoke(app, ["migrate", "--data"])
124168

125169
assert result.exit_code == 1, result.output
@@ -132,7 +176,7 @@ def test_migrate_data_run_script_fail(data_migration_file_error, migration_state
132176
"applied_at": None,
133177
}
134178
assert "Running data migrations..." in result.output
135-
assert "Running migration: fake_error_file_name" in result.output
179+
assert "Running migration: fake_error_file_name" in log.text
136180
assert "Migration fake_error_file_name failed: Fake Error" in result.output
137181

138182

0 commit comments

Comments
 (0)