mpt-tool 5.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. mpt_tool/__init__.py +0 -0
  2. mpt_tool/cli.py +76 -0
  3. mpt_tool/commands/__init__.py +4 -0
  4. mpt_tool/commands/base.py +27 -0
  5. mpt_tool/commands/data.py +23 -0
  6. mpt_tool/commands/errors.py +9 -0
  7. mpt_tool/commands/factory.py +45 -0
  8. mpt_tool/commands/fake.py +26 -0
  9. mpt_tool/commands/list.py +33 -0
  10. mpt_tool/commands/new_data.py +30 -0
  11. mpt_tool/commands/new_schema.py +30 -0
  12. mpt_tool/commands/schema.py +23 -0
  13. mpt_tool/commands/validators.py +24 -0
  14. mpt_tool/config.py +22 -0
  15. mpt_tool/constants.py +4 -0
  16. mpt_tool/enums.py +41 -0
  17. mpt_tool/errors.py +9 -0
  18. mpt_tool/managers/__init__.py +5 -0
  19. mpt_tool/managers/encoders.py +15 -0
  20. mpt_tool/managers/errors.py +25 -0
  21. mpt_tool/managers/file_migration.py +132 -0
  22. mpt_tool/managers/state/__init__.py +0 -0
  23. mpt_tool/managers/state/airtable.py +108 -0
  24. mpt_tool/managers/state/base.py +49 -0
  25. mpt_tool/managers/state/factory.py +32 -0
  26. mpt_tool/managers/state/file.py +64 -0
  27. mpt_tool/migration/__init__.py +4 -0
  28. mpt_tool/migration/base.py +25 -0
  29. mpt_tool/migration/data_base.py +10 -0
  30. mpt_tool/migration/mixins/__init__.py +4 -0
  31. mpt_tool/migration/mixins/airtable_client.py +29 -0
  32. mpt_tool/migration/mixins/mpt_client.py +31 -0
  33. mpt_tool/migration/schema_base.py +10 -0
  34. mpt_tool/models.py +140 -0
  35. mpt_tool/py.typed +0 -0
  36. mpt_tool/renders.py +30 -0
  37. mpt_tool/templates.py +12 -0
  38. mpt_tool/use_cases/__init__.py +11 -0
  39. mpt_tool/use_cases/apply_migration.py +42 -0
  40. mpt_tool/use_cases/errors.py +17 -0
  41. mpt_tool/use_cases/list_migrations.py +35 -0
  42. mpt_tool/use_cases/new_migration.py +22 -0
  43. mpt_tool/use_cases/run_migrations.py +98 -0
  44. mpt_tool-5.0.0.dist-info/METADATA +299 -0
  45. mpt_tool-5.0.0.dist-info/RECORD +48 -0
  46. mpt_tool-5.0.0.dist-info/WHEEL +4 -0
  47. mpt_tool-5.0.0.dist-info/entry_points.txt +2 -0
  48. mpt_tool-5.0.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,108 @@
1
+ from typing import override
2
+
3
+ from pyairtable.formulas import match
4
+ from pyairtable.orm import Model, fields
5
+
6
+ from mpt_tool.config import get_airtable_config
7
+ from mpt_tool.enums import MigrationTypeEnum
8
+ from mpt_tool.managers import StateManager
9
+ from mpt_tool.managers.errors import StateNotFoundError
10
+ from mpt_tool.models import Migration
11
+
12
+
13
+ class MigrationStateModel(Model):
14
+ """Airtable model for migration states."""
15
+
16
+ migration_id = fields.RequiredTextField("migration_id")
17
+ order_id = fields.RequiredIntegerField("order_id")
18
+ type = fields.RequiredSelectField("type")
19
+ started_at = fields.DatetimeField("started_at")
20
+ applied_at = fields.DatetimeField("applied_at")
21
+
22
+ class Meta:
23
+ @staticmethod
24
+ def api_key() -> str | None: # noqa: WPS602
25
+ """Airtable API key."""
26
+ return get_airtable_config("api_key")
27
+
28
+ @staticmethod
29
+ def base_id() -> str | None: # noqa: WPS602
30
+ """Airtable base ID."""
31
+ return get_airtable_config("base_id")
32
+
33
+ @staticmethod
34
+ def table_name() -> str | None: # noqa: WPS602
35
+ """Airtable table name."""
36
+ return get_airtable_config("table_name")
37
+
38
+
39
+ class AirtableStateManager(StateManager):
40
+ """Manages migration states in Airtable."""
41
+
42
+ @override
43
+ @classmethod
44
+ def load(cls) -> dict[str, Migration]:
45
+ migrations = {}
46
+ for state in MigrationStateModel.all():
47
+ migration = Migration(
48
+ migration_id=state.migration_id,
49
+ order_id=state.order_id,
50
+ type=MigrationTypeEnum(state.type),
51
+ started_at=state.started_at,
52
+ applied_at=state.applied_at,
53
+ )
54
+ migrations[migration.migration_id] = migration
55
+
56
+ return migrations
57
+
58
+ @override
59
+ @classmethod
60
+ def get_by_id(cls, migration_id: str) -> Migration:
61
+ state = MigrationStateModel.first(formula=match({"migration_id": migration_id}))
62
+ if not state:
63
+ raise StateNotFoundError(f"State {migration_id} not found")
64
+
65
+ return Migration(
66
+ migration_id=state.migration_id,
67
+ order_id=state.order_id,
68
+ type=MigrationTypeEnum(state.type),
69
+ started_at=state.started_at,
70
+ applied_at=state.applied_at,
71
+ )
72
+
73
+ @override
74
+ @classmethod
75
+ def new(cls, migration_id: str, migration_type: MigrationTypeEnum, order_id: int) -> Migration:
76
+ state = MigrationStateModel(
77
+ migration_id=migration_id, order_id=order_id, type=migration_type.value
78
+ )
79
+ state.save()
80
+ return Migration(
81
+ migration_id=state.migration_id,
82
+ order_id=state.order_id,
83
+ type=MigrationTypeEnum(state.type),
84
+ started_at=state.started_at,
85
+ applied_at=state.applied_at,
86
+ )
87
+
88
+ @override
89
+ @classmethod
90
+ def save_state(cls, state: Migration) -> None:
91
+ migration_state_model = MigrationStateModel.first(
92
+ formula=f"migration_id = '{state.migration_id}'"
93
+ )
94
+ if migration_state_model:
95
+ migration_state_model.order_id = state.order_id
96
+ migration_state_model.type = state.type.value
97
+ migration_state_model.started_at = state.started_at
98
+ migration_state_model.applied_at = state.applied_at
99
+ else:
100
+ migration_state_model = MigrationStateModel(
101
+ migration_id=state.migration_id,
102
+ order_id=state.order_id,
103
+ type=state.type.value,
104
+ started_at=state.started_at,
105
+ applied_at=state.applied_at,
106
+ )
107
+
108
+ migration_state_model.save()
@@ -0,0 +1,49 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ from mpt_tool.enums import MigrationTypeEnum
4
+ from mpt_tool.models import Migration
5
+
6
+
7
+ class StateManager(ABC):
8
+ """Base class for state managers."""
9
+
10
+ @classmethod
11
+ @abstractmethod
12
+ def load(cls) -> dict[str, Migration]:
13
+ """Load migration states."""
14
+ raise NotImplementedError
15
+
16
+ @classmethod
17
+ @abstractmethod
18
+ def get_by_id(cls, migration_id: str) -> Migration:
19
+ """Get a migration state by its ID.
20
+
21
+ Args:
22
+ migration_id: The migration ID.
23
+
24
+ Raises:
25
+ StateNotFoundError: If the state is not found.
26
+ """
27
+ raise NotImplementedError
28
+
29
+ @classmethod
30
+ @abstractmethod
31
+ def new(cls, migration_id: str, migration_type: MigrationTypeEnum, order_id: int) -> Migration:
32
+ """Create a new migration state.
33
+
34
+ Args:
35
+ migration_id: The migration ID.
36
+ migration_type: The migration type.
37
+ order_id: The order ID.
38
+ """
39
+ raise NotImplementedError
40
+
41
+ @classmethod
42
+ @abstractmethod
43
+ def save_state(cls, state: Migration) -> None:
44
+ """Save a migration state to the state file.
45
+
46
+ Args:
47
+ state: The migration state.
48
+ """
49
+ raise NotImplementedError
@@ -0,0 +1,32 @@
1
+ from enum import StrEnum
2
+
3
+ from mpt_tool.config import get_storage_type
4
+ from mpt_tool.managers import StateManager
5
+ from mpt_tool.managers.state.airtable import AirtableStateManager
6
+ from mpt_tool.managers.state.file import FileStateManager
7
+
8
+
9
+ class StorageTypeEnum(StrEnum):
10
+ """Enum for storage types."""
11
+
12
+ LOCAL = "local"
13
+ AIRTABLE = "airtable"
14
+
15
+
16
+ class StateManagerFactory:
17
+ """Factory to create StateManager instances."""
18
+
19
+ @classmethod
20
+ def get_instance(cls) -> StateManager:
21
+ """Return a StateManager instance based on environment variables.
22
+
23
+ Returns:
24
+ StateManager instance (FileStateManager or AirtableStateManager)
25
+
26
+ """
27
+ storage_type = get_storage_type()
28
+ return (
29
+ AirtableStateManager()
30
+ if storage_type == StorageTypeEnum.AIRTABLE
31
+ else FileStateManager()
32
+ )
@@ -0,0 +1,64 @@
1
+ import json
2
+ from pathlib import Path
3
+ from typing import override
4
+
5
+ from mpt_tool.constants import MIGRATION_STATE_FILE
6
+ from mpt_tool.enums import MigrationTypeEnum
7
+ from mpt_tool.managers import StateManager
8
+ from mpt_tool.managers.encoders import StateJSONEncoder
9
+ from mpt_tool.managers.errors import InvalidStateError, StateNotFoundError
10
+ from mpt_tool.models import Migration
11
+
12
+
13
+ class FileStateManager(StateManager):
14
+ """Manages file migration states."""
15
+
16
+ _state_path: Path = Path(MIGRATION_STATE_FILE)
17
+
18
+ @override
19
+ @classmethod
20
+ def load(cls) -> dict[str, Migration]:
21
+ if not cls._state_path.exists():
22
+ return {}
23
+
24
+ try:
25
+ state_data = json.loads(cls._state_path.read_text(encoding="utf-8"))
26
+ except json.JSONDecodeError as error:
27
+ raise InvalidStateError(f"Invalid state file: {error!s}") from error
28
+
29
+ return {key: Migration.from_dict(mig_data) for key, mig_data in state_data.items()}
30
+
31
+ @override
32
+ @classmethod
33
+ def get_by_id(cls, migration_id: str) -> Migration:
34
+ state_data = cls.load()
35
+ try:
36
+ state = state_data[migration_id]
37
+ except KeyError:
38
+ raise StateNotFoundError("State not found") from None
39
+
40
+ return state
41
+
42
+ @override
43
+ @classmethod
44
+ def new(cls, migration_id: str, migration_type: MigrationTypeEnum, order_id: int) -> Migration:
45
+ new_state = Migration(
46
+ migration_id=migration_id,
47
+ order_id=order_id,
48
+ type=migration_type,
49
+ )
50
+ cls.save_state(new_state)
51
+ return new_state
52
+
53
+ @override
54
+ @classmethod
55
+ def save_state(cls, state: Migration) -> None:
56
+ state_data = cls.load()
57
+ state_data[state.migration_id] = state
58
+ cls._save(state_data)
59
+
60
+ @classmethod
61
+ def _save(cls, state_data: dict[str, Migration]) -> None:
62
+ cls._state_path.write_text(
63
+ json.dumps(state_data, indent=2, cls=StateJSONEncoder), encoding="utf-8"
64
+ )
@@ -0,0 +1,4 @@
1
+ from mpt_tool.migration.data_base import DataBaseMigration
2
+ from mpt_tool.migration.schema_base import SchemaBaseMigration
3
+
4
+ __all__ = ["DataBaseMigration", "SchemaBaseMigration"]
@@ -0,0 +1,25 @@
1
+ import logging
2
+ from abc import ABC, abstractmethod
3
+
4
+ from mpt_tool.enums import MigrationTypeEnum
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ class BaseMigration(ABC):
10
+ """Abstract base class for all migration commands."""
11
+
12
+ _type: MigrationTypeEnum
13
+
14
+ def __init__(self) -> None:
15
+ self.log = logger
16
+
17
+ @property
18
+ def type(self) -> MigrationTypeEnum:
19
+ """The type of migration this command represents."""
20
+ return self._type
21
+
22
+ @abstractmethod
23
+ def run(self) -> None:
24
+ """Executes the command."""
25
+ raise NotImplementedError
@@ -0,0 +1,10 @@
1
+ from abc import ABC
2
+
3
+ from mpt_tool.enums import MigrationTypeEnum
4
+ from mpt_tool.migration.base import BaseMigration
5
+
6
+
7
+ class DataBaseMigration(BaseMigration, ABC):
8
+ """Base command for data migrations."""
9
+
10
+ _type = MigrationTypeEnum.DATA
@@ -0,0 +1,4 @@
1
+ from mpt_tool.migration.mixins.airtable_client import AirtableAPIClientMixin
2
+ from mpt_tool.migration.mixins.mpt_client import MPTAPIClientMixin
3
+
4
+ __all__ = ["AirtableAPIClientMixin", "MPTAPIClientMixin"]
@@ -0,0 +1,29 @@
1
+ from functools import cached_property
2
+
3
+ from pyairtable import Api as AirtableClient
4
+
5
+ from mpt_tool.config import get_airtable_config
6
+
7
+
8
+ class AirtableAPIClientMixin:
9
+ """Mixin to add Airtable API client to commands.
10
+
11
+ The API key is read from the environment variable AIRTABLE_API_KEY.
12
+ """
13
+
14
+ @cached_property
15
+ def airtable_client(self) -> AirtableClient:
16
+ """Get or create Airtable Client.
17
+
18
+ Returns:
19
+ The Airtable Client instance
20
+
21
+ Raises:
22
+ ValueError: If required environment variables are not set
23
+
24
+ """
25
+ airtable_api_key = get_airtable_config("api_key")
26
+ if not airtable_api_key:
27
+ raise ValueError("Airtable API key must be set in env variable AIRTABLE_API_KEY")
28
+
29
+ return AirtableClient(airtable_api_key)
@@ -0,0 +1,31 @@
1
+ from functools import cached_property
2
+
3
+ from mpt_api_client import MPTClient
4
+
5
+ from mpt_tool.config import get_mpt_config
6
+
7
+
8
+ class MPTAPIClientMixin:
9
+ """Mixin to add MPT API client to commands.
10
+
11
+ MPT API token and base URL are read from the environment variables MPT_API_TOKEN
12
+ and MPT_API_BASE_URL.
13
+ """
14
+
15
+ @cached_property
16
+ def mpt_client(self) -> MPTClient:
17
+ """Get or create MPT API client from environment configurations.
18
+
19
+ Returns:
20
+ The MPT API client instance
21
+
22
+ Raises:
23
+ ValueError: If required environment variables are not set
24
+
25
+ """
26
+ api_token = get_mpt_config("api_token")
27
+ base_url = get_mpt_config("base_url")
28
+ if not api_token or not base_url:
29
+ raise ValueError("MPT API token and base URL must be set in env variables")
30
+
31
+ return MPTClient.from_config(api_token=api_token, base_url=base_url)
@@ -0,0 +1,10 @@
1
+ from abc import ABC
2
+
3
+ from mpt_tool.enums import MigrationTypeEnum
4
+ from mpt_tool.migration.base import BaseMigration
5
+
6
+
7
+ class SchemaBaseMigration(BaseMigration, ABC):
8
+ """Base command for schema migrations."""
9
+
10
+ _type = MigrationTypeEnum.SCHEMA
mpt_tool/models.py ADDED
@@ -0,0 +1,140 @@
1
+ import datetime as dt
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+ from typing import Any, Self
5
+
6
+ from mpt_tool.constants import MAX_LEN_MIGRATION_ID
7
+ from mpt_tool.enums import MigrationStatusEnum, MigrationTypeEnum
8
+
9
+
10
+ @dataclass
11
+ class Migration:
12
+ """Represents a migration."""
13
+
14
+ migration_id: str
15
+ order_id: int
16
+ type: MigrationTypeEnum
17
+ started_at: dt.datetime | None = None
18
+ applied_at: dt.datetime | None = None
19
+
20
+ @classmethod
21
+ def from_dict(cls, migration_data: dict[str, Any]) -> Self:
22
+ """Create a migration from a dictionary."""
23
+ return cls(
24
+ migration_id=migration_data["migration_id"],
25
+ order_id=migration_data["order_id"],
26
+ type=MigrationTypeEnum(migration_data["type"]),
27
+ started_at=dt.datetime.fromisoformat(migration_data["started_at"])
28
+ if migration_data["started_at"]
29
+ else None,
30
+ applied_at=dt.datetime.fromisoformat(migration_data["applied_at"])
31
+ if migration_data["applied_at"]
32
+ else None,
33
+ )
34
+
35
+ def to_dict(self) -> dict[str, Any]:
36
+ """Convert a migration to a dictionary."""
37
+ return {
38
+ "migration_id": self.migration_id,
39
+ "order_id": self.order_id,
40
+ "type": self.type.value,
41
+ "started_at": self.started_at.isoformat() if self.started_at else None,
42
+ "applied_at": self.applied_at.isoformat() if self.applied_at else None,
43
+ }
44
+
45
+ def applied(self) -> None:
46
+ """Mark the migration as applied."""
47
+ self.applied_at = dt.datetime.now(tz=dt.UTC)
48
+
49
+ def failed(self) -> None:
50
+ """Mark the migration as failed."""
51
+ self.started_at = None
52
+ self.applied_at = None
53
+
54
+ def fake(self) -> None:
55
+ """Mark the migration as fake."""
56
+ self.started_at = None
57
+ self.applied_at = dt.datetime.now(tz=dt.UTC)
58
+
59
+ def start(self) -> None:
60
+ """Mark the migration as started."""
61
+ self.started_at = dt.datetime.now(tz=dt.UTC)
62
+
63
+
64
+ @dataclass
65
+ class MigrationFile:
66
+ """Represents a migration file with its identifier and order."""
67
+
68
+ full_path: Path
69
+ migration_id: str
70
+ order_id: int
71
+
72
+ @property
73
+ def file_name(self) -> str:
74
+ """Migration file name."""
75
+ return f"{self.order_id}_{self.migration_id}.py"
76
+
77
+ def __post_init__(self) -> None:
78
+ if len(self.migration_id) >= MAX_LEN_MIGRATION_ID:
79
+ raise ValueError(
80
+ f"Migration ID must be less than {MAX_LEN_MIGRATION_ID} characters long."
81
+ )
82
+ if not self.migration_id.isidentifier():
83
+ raise ValueError(
84
+ "Migration ID must contain only alphanumeric letters and numbers, or underscores."
85
+ )
86
+
87
+ @property
88
+ def name(self) -> str:
89
+ """Migration file name."""
90
+ return f"{self.order_id}_{self.migration_id}"
91
+
92
+ @classmethod
93
+ def build_from_path(cls, path: Path) -> Self:
94
+ """Build a migration file from a path.
95
+
96
+ Args:
97
+ path: The path to the migration file.
98
+ """
99
+ order_id, migration_id = path.stem.split("_", maxsplit=1)
100
+ return cls(full_path=path, order_id=int(order_id), migration_id=migration_id)
101
+
102
+ @classmethod
103
+ def new(cls, migration_id: str, path: Path) -> Self:
104
+ """Create a new migration file.
105
+
106
+ Args:
107
+ migration_id: The migration ID.
108
+ path: The path to the migration folder.
109
+ """
110
+ timestamp = dt.datetime.now(tz=dt.UTC).strftime("%Y%m%d%H%M%S")
111
+ full_path = path / f"{timestamp}_{migration_id}.py"
112
+ return cls(full_path=full_path, migration_id=migration_id, order_id=int(timestamp))
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class MigrationListItem:
117
+ """Represents the data required to render a migration entry."""
118
+
119
+ migration_id: str
120
+ order_id: int
121
+ migration_type: MigrationTypeEnum | None
122
+ started_at: dt.datetime | None
123
+ applied_at: dt.datetime | None
124
+ status: MigrationStatusEnum
125
+
126
+ @classmethod
127
+ def from_sources(
128
+ cls,
129
+ migration_file: MigrationFile,
130
+ migration_state: Migration | None,
131
+ ) -> Self:
132
+ """Create a migration list item from file metadata and stored state."""
133
+ return cls(
134
+ migration_id=migration_file.migration_id,
135
+ order_id=migration_file.order_id,
136
+ migration_type=migration_state.type if migration_state else None,
137
+ started_at=migration_state.started_at if migration_state else None,
138
+ applied_at=migration_state.applied_at if migration_state else None,
139
+ status=MigrationStatusEnum.from_state(migration_state),
140
+ )
mpt_tool/py.typed ADDED
File without changes
mpt_tool/renders.py ADDED
@@ -0,0 +1,30 @@
1
+ from rich.console import Console, ConsoleOptions, RenderResult
2
+ from rich.table import Table
3
+
4
+ from mpt_tool.models import MigrationListItem
5
+
6
+
7
+ class MigrationRender:
8
+ """Render migration state information as a formatted table."""
9
+
10
+ _fields = ("order_id", "migration_id", "started_at", "applied_at", "type", "status")
11
+
12
+ def __init__(self, migration_items: list[MigrationListItem]):
13
+ self.migration_items = migration_items
14
+
15
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: # noqa: PLW3201
16
+ table = Table(show_lines=True, title_justify="center")
17
+ for field in self._fields:
18
+ table.add_column(field, no_wrap=True, min_width=len(field))
19
+
20
+ for migration in self.migration_items:
21
+ table.add_row(
22
+ str(migration.order_id),
23
+ migration.migration_id,
24
+ migration.started_at.isoformat(timespec="seconds") if migration.started_at else "-",
25
+ migration.applied_at.isoformat(timespec="seconds") if migration.applied_at else "-",
26
+ migration.migration_type.value if migration.migration_type else "-",
27
+ migration.status.title(),
28
+ )
29
+
30
+ yield table
mpt_tool/templates.py ADDED
@@ -0,0 +1,12 @@
1
+ from string import Template
2
+
3
+ MIGRATION_SCAFFOLDING_FILE_TEXT = """\
4
+ from mpt_tool.migration import $migration_name
5
+
6
+
7
+ class Migration($migration_name):
8
+ def run(self):
9
+ # implement your logic here
10
+ pass
11
+ """
12
+ MIGRATION_SCAFFOLDING_TEMPLATE: Template = Template(MIGRATION_SCAFFOLDING_FILE_TEXT)
@@ -0,0 +1,11 @@
1
+ from mpt_tool.use_cases.apply_migration import ApplyMigrationUseCase
2
+ from mpt_tool.use_cases.list_migrations import ListMigrationsUseCase
3
+ from mpt_tool.use_cases.new_migration import NewMigrationUseCase
4
+ from mpt_tool.use_cases.run_migrations import RunMigrationsUseCase
5
+
6
+ __all__ = [
7
+ "ApplyMigrationUseCase",
8
+ "ListMigrationsUseCase",
9
+ "NewMigrationUseCase",
10
+ "RunMigrationsUseCase",
11
+ ]
@@ -0,0 +1,42 @@
1
+ from mpt_tool.managers import FileMigrationManager, StateManager, StateManagerFactory
2
+ from mpt_tool.managers.errors import MigrationFolderError, StateNotFoundError
3
+ from mpt_tool.use_cases.errors import ApplyMigrationError
4
+
5
+
6
+ class ApplyMigrationUseCase:
7
+ """Use case for applying a migration without running it."""
8
+
9
+ def __init__(
10
+ self,
11
+ file_migration_manager: FileMigrationManager | None = None,
12
+ state_manager: StateManager | None = None,
13
+ ):
14
+ self.file_migration_manager = file_migration_manager or FileMigrationManager()
15
+ self.state_manager = state_manager or StateManagerFactory.get_instance()
16
+
17
+ def execute(self, migration_id: str) -> None:
18
+ """Apply a migration without running it."""
19
+ try:
20
+ migration_files = self.file_migration_manager.validate()
21
+ except MigrationFolderError as error:
22
+ raise ApplyMigrationError(str(error)) from error
23
+
24
+ migration_file = next(
25
+ (migration for migration in migration_files if migration.migration_id == migration_id),
26
+ None,
27
+ )
28
+ if not migration_file:
29
+ raise ApplyMigrationError(f"Migration {migration_id} not found")
30
+
31
+ try:
32
+ state = self.state_manager.get_by_id(migration_id)
33
+ except StateNotFoundError:
34
+ # TODO: handle LoadMigrationError exception
35
+ migration = self.file_migration_manager.load_migration(migration_file)
36
+ state = self.state_manager.new(migration_id, migration.type, migration_file.order_id)
37
+
38
+ if state.applied_at is not None:
39
+ raise ApplyMigrationError(f"Migration {migration_id} already applied")
40
+
41
+ state.fake()
42
+ self.state_manager.save_state(state)
@@ -0,0 +1,17 @@
1
+ from mpt_tool.errors import BaseError
2
+
3
+
4
+ class UseCaseError(BaseError):
5
+ """Base error for use cases."""
6
+
7
+
8
+ class NewMigrationError(UseCaseError):
9
+ """Error creating new migration."""
10
+
11
+
12
+ class RunMigrationError(UseCaseError):
13
+ """Error running migration."""
14
+
15
+
16
+ class ApplyMigrationError(UseCaseError):
17
+ """Error applying migration."""
@@ -0,0 +1,35 @@
1
+ import logging
2
+
3
+ from mpt_tool.managers import FileMigrationManager, StateManager, StateManagerFactory
4
+ from mpt_tool.models import MigrationListItem
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ class ListMigrationsUseCase:
10
+ """Use case for listing all migrations."""
11
+
12
+ def __init__(
13
+ self,
14
+ file_migration_manager: FileMigrationManager | None = None,
15
+ state_manager: StateManager | None = None,
16
+ ):
17
+ self.file_migration_manager = file_migration_manager or FileMigrationManager()
18
+ self.state_manager = state_manager or StateManagerFactory.get_instance()
19
+
20
+ def execute(self) -> list[MigrationListItem]:
21
+ """List all migrations sorted by order identifier."""
22
+ migration_files = self.file_migration_manager.retrieve_migration_files()
23
+ migration_states = self.state_manager.load()
24
+ migration_list: list[MigrationListItem] = []
25
+ for migration_file in migration_files:
26
+ migration_state = migration_states.get(migration_file.migration_id)
27
+ if not migration_state:
28
+ logger.debug("No state found for migration: %s", migration_file.migration_id)
29
+ migration_list.append(
30
+ MigrationListItem.from_sources(
31
+ migration_file=migration_file, migration_state=migration_state
32
+ )
33
+ )
34
+
35
+ return sorted(migration_list, key=lambda migration: migration.order_id)