Skip to content

Commit 314fa76

Browse files
authored
MPT-16844: implement NewMigrationUseCase (#16)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> Closes [MPT-16844](https://softwareone.atlassian.net/browse/MPT-16844) - Implemented NewMigrationUseCase to encapsulate creation of new migration files and replace ad-hoc CLI scaffolding - Added CreateMigrationError and NewMigrationError exception classes for clearer error mapping - Added FileMigrationManager.new_migration(...) to create migration files, ensure folders, validate IDs, prevent collisions, and write scaffolding templates - Enhanced MigrationFile with: - file_name property formatted as "<order_id>_<migration_id>.py" - __post_init__ validation to ensure migration IDs are valid identifiers - new() classmethod to generate timestamped migration filenames and order IDs - Refactored CLI migrate command to delegate scaffolding to NewMigrationUseCase (handles NewMigrationError) and removed direct filesystem/template logic - Updated migrate CLI options to --new-data and --new-schema (string filename values, mutually exclusive with --data/--schema) and kept run-migration behavior for --data/--schema <!-- end of auto-generated comment: release notes by coderabbit.ai --> [MPT-16844]: https://softwareone.atlassian.net/browse/MPT-16844?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
2 parents 6a824d4 + da214a3 commit 314fa76

File tree

5 files changed

+100
-28
lines changed

5 files changed

+100
-28
lines changed

mpt_tool/cli.py

Lines changed: 8 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
1-
import datetime as dt
21
import logging
3-
from pathlib import Path
4-
from typing import Annotated
2+
from typing import Annotated, cast
53

64
import typer
75

8-
from mpt_tool.constants import MIGRATION_FOLDER
96
from mpt_tool.enums import MigrationTypeEnum
10-
from mpt_tool.errors import RunMigrationError
11-
from mpt_tool.templates import MIGRATION_SCAFFOLDING_TEMPLATE
7+
from mpt_tool.errors import NewMigrationError, RunMigrationError
128
from mpt_tool.use_cases import RunMigrationsUseCase
9+
from mpt_tool.use_cases.new_migration import NewMigrationUseCase
1310

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

@@ -20,7 +17,7 @@ def callback() -> None:
2017

2118

2219
@app.command("migrate")
23-
def migrate( # noqa: C901, WPS238, WPS210, WPS213, WPS231
20+
def migrate( # noqa: C901, WPS238, WPS231
2421
data: Annotated[bool, typer.Option("--data", help="Run data migrations.")] = False, # noqa: FBT002
2522
schema: Annotated[bool, typer.Option("--schema", help="Run schema migrations.")] = False, # noqa: FBT002
2623
new_data: Annotated[
@@ -63,25 +60,14 @@ def migrate( # noqa: C901, WPS238, WPS210, WPS213, WPS231
6360

6461
if new_schema or new_data:
6562
filename_suffix = new_data or new_schema
63+
migration_type = MigrationTypeEnum.DATA if new_data else MigrationTypeEnum.SCHEMA
6664
typer.echo(f"Scaffolding migration: {filename_suffix}.")
67-
migration_folder = Path(MIGRATION_FOLDER)
68-
migration_folder.mkdir(parents=True, exist_ok=True)
69-
timestamp = dt.datetime.now(tz=dt.UTC).strftime("%Y%m%d%H%M%S")
70-
# TODO: add filename validation
71-
filename = f"{timestamp}_{filename_suffix}.py"
72-
full_filename_path = migration_folder / filename
7365
try:
74-
full_filename_path.touch(exist_ok=False)
75-
except FileExistsError:
76-
typer.secho(f"File already exists: {filename}", fg=typer.colors.RED)
66+
filename = NewMigrationUseCase().execute(migration_type, cast(str, filename_suffix))
67+
except NewMigrationError as error:
68+
typer.secho(f"Error creating migration: {error!s}", fg=typer.colors.RED)
7769
raise typer.Abort
7870

79-
full_filename_path.write_text(
80-
encoding="utf-8",
81-
data=MIGRATION_SCAFFOLDING_TEMPLATE.substitute(
82-
command_name="DataBaseCommand" if new_data else "SchemaBaseCommand"
83-
),
84-
)
8571
typer.secho(f"Migration file: {filename} has been created.", fg=typer.colors.GREEN)
8672

8773

mpt_tool/errors.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,18 @@ def __init__(self, message: str):
99
super().__init__(message)
1010

1111

12+
class CreateMigrationError(BaseError):
13+
"""Error creating the migration file."""
14+
15+
1216
class LoadMigrationError(BaseError):
1317
"""Error loading migrations."""
1418

1519

20+
class NewMigrationError(BaseError):
21+
"""Error creating new migration."""
22+
23+
1624
class MigrationFolderError(BaseError):
1725
"""Error accessing migrations folder."""
1826

mpt_tool/managers.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,14 @@
88

99
from mpt_tool.constants import MIGRATION_FOLDER, MIGRATION_STATE_FILE
1010
from mpt_tool.enums import MigrationTypeEnum
11-
from mpt_tool.errors import LoadMigrationError, MigrationFolderError, StateNotFoundError
11+
from mpt_tool.errors import (
12+
CreateMigrationError,
13+
LoadMigrationError,
14+
MigrationFolderError,
15+
StateNotFoundError,
16+
)
1217
from mpt_tool.models import Migration, MigrationFile
18+
from mpt_tool.templates import MIGRATION_SCAFFOLDING_TEMPLATE
1319

1420

1521
class FileMigrationManager:
@@ -18,21 +24,23 @@ class FileMigrationManager:
1824
_migration_folder: Path = Path(MIGRATION_FOLDER)
1925

2026
@classmethod
21-
def validate(cls) -> tuple[MigrationFile, ...]:
27+
def validate(cls) -> tuple[MigrationFile, ...]: # noqa: WPS238
2228
"""Validates the migration folder and returns a tuple of migration files."""
2329
if not cls._migration_folder.exists():
2430
raise MigrationFolderError(f"Migration folder not found: {cls._migration_folder}")
2531

26-
migrations = tuple(
27-
sorted(
32+
try:
33+
migrations = sorted(
2834
(
2935
MigrationFile.build_from_path(path)
3036
for path in cls._migration_folder.glob("*.py")
3137
if re.match(r"\d+_.*\.py", path.name)
3238
),
3339
key=lambda migration_file: migration_file.order_id,
3440
)
35-
)
41+
except ValueError as error:
42+
raise MigrationFolderError(str(error)) from None
43+
3644
if not migrations:
3745
raise MigrationFolderError(f"No migration files found in {cls._migration_folder}")
3846

@@ -43,7 +51,7 @@ def validate(cls) -> tuple[MigrationFile, ...]:
4351
f"Duplicate migration filename found: {duplicated_migrations[0]}"
4452
)
4553

46-
return migrations
54+
return tuple(migrations)
4755

4856
@classmethod
4957
def load_migration(cls, migration_file: MigrationFile) -> ModuleType:
@@ -63,6 +71,32 @@ def load_migration(cls, migration_file: MigrationFile) -> ModuleType:
6371
spec.loader.exec_module(migration_module)
6472
return migration_module
6573

74+
@classmethod
75+
def new_migration(cls, file_suffix: str, migration_type: MigrationTypeEnum) -> MigrationFile:
76+
"""Creates a new migration file."""
77+
cls._migration_folder.mkdir(parents=True, exist_ok=True)
78+
try:
79+
migration_file = MigrationFile.new(migration_id=file_suffix, path=cls._migration_folder)
80+
except ValueError as error:
81+
raise CreateMigrationError(f"Invalid migration ID: {error}") from error
82+
83+
try:
84+
migration_file.full_path.touch(exist_ok=False)
85+
except FileExistsError as error:
86+
raise CreateMigrationError(
87+
f"File already exists: {migration_file.file_name}"
88+
) from error
89+
90+
migration_file.full_path.write_text(
91+
encoding="utf-8",
92+
data=MIGRATION_SCAFFOLDING_TEMPLATE.substitute(
93+
command_name="DataBaseCommand"
94+
if migration_type == MigrationTypeEnum.DATA
95+
else "SchemaBaseCommand"
96+
),
97+
)
98+
return migration_file
99+
66100

67101
class StateJSONEncoder(json.JSONEncoder):
68102
"""JSON encoder for migration states."""

mpt_tool/models.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,17 @@ class MigrationFile:
6363
migration_id: str
6464
order_id: int
6565

66+
@property
67+
def file_name(self) -> str:
68+
"""Migration file name."""
69+
return f"{self.order_id}_{self.migration_id}.py"
70+
71+
def __post_init__(self):
72+
if not self.migration_id.isidentifier():
73+
raise ValueError(
74+
"Migration ID must contain only alphanumeric letters and numbers, or underscores."
75+
)
76+
6677
@property
6778
def name(self) -> str:
6879
"""Migration file name."""
@@ -77,3 +88,15 @@ def build_from_path(cls, path: Path) -> Self:
7788
"""
7889
order_id, migration_id = path.stem.split("_", maxsplit=1)
7990
return cls(full_path=path, order_id=int(order_id), migration_id=migration_id)
91+
92+
@classmethod
93+
def new(cls, migration_id: str, path: Path) -> Self:
94+
"""Create a new migration file.
95+
96+
Args:
97+
migration_id: The migration ID.
98+
path: The path to the migration folder.
99+
"""
100+
timestamp = dt.datetime.now(tz=dt.UTC).strftime("%Y%m%d%H%M%S")
101+
full_path = path / f"{timestamp}_{migration_id}.py"
102+
return cls(full_path=full_path, migration_id=migration_id, order_id=int(timestamp))
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from mpt_tool.enums import MigrationTypeEnum
2+
from mpt_tool.errors import CreateMigrationError, NewMigrationError
3+
from mpt_tool.managers import FileMigrationManager
4+
5+
6+
class NewMigrationUseCase:
7+
"""Service to create a new migration file."""
8+
9+
def __init__(self, file_manager: FileMigrationManager | None = None):
10+
self.file_manager = file_manager or FileMigrationManager()
11+
12+
def execute(self, migration_type: MigrationTypeEnum, file_name: str) -> str:
13+
"""Create a new migration file."""
14+
try:
15+
migration_file = self.file_manager.new_migration(
16+
file_suffix=file_name, migration_type=migration_type
17+
)
18+
except CreateMigrationError as error:
19+
raise NewMigrationError(str(error)) from error
20+
21+
return migration_file.file_name

0 commit comments

Comments
 (0)