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.
- mpt_tool/__init__.py +0 -0
- mpt_tool/cli.py +76 -0
- mpt_tool/commands/__init__.py +4 -0
- mpt_tool/commands/base.py +27 -0
- mpt_tool/commands/data.py +23 -0
- mpt_tool/commands/errors.py +9 -0
- mpt_tool/commands/factory.py +45 -0
- mpt_tool/commands/fake.py +26 -0
- mpt_tool/commands/list.py +33 -0
- mpt_tool/commands/new_data.py +30 -0
- mpt_tool/commands/new_schema.py +30 -0
- mpt_tool/commands/schema.py +23 -0
- mpt_tool/commands/validators.py +24 -0
- mpt_tool/config.py +22 -0
- mpt_tool/constants.py +4 -0
- mpt_tool/enums.py +41 -0
- mpt_tool/errors.py +9 -0
- mpt_tool/managers/__init__.py +5 -0
- mpt_tool/managers/encoders.py +15 -0
- mpt_tool/managers/errors.py +25 -0
- mpt_tool/managers/file_migration.py +132 -0
- mpt_tool/managers/state/__init__.py +0 -0
- mpt_tool/managers/state/airtable.py +108 -0
- mpt_tool/managers/state/base.py +49 -0
- mpt_tool/managers/state/factory.py +32 -0
- mpt_tool/managers/state/file.py +64 -0
- mpt_tool/migration/__init__.py +4 -0
- mpt_tool/migration/base.py +25 -0
- mpt_tool/migration/data_base.py +10 -0
- mpt_tool/migration/mixins/__init__.py +4 -0
- mpt_tool/migration/mixins/airtable_client.py +29 -0
- mpt_tool/migration/mixins/mpt_client.py +31 -0
- mpt_tool/migration/schema_base.py +10 -0
- mpt_tool/models.py +140 -0
- mpt_tool/py.typed +0 -0
- mpt_tool/renders.py +30 -0
- mpt_tool/templates.py +12 -0
- mpt_tool/use_cases/__init__.py +11 -0
- mpt_tool/use_cases/apply_migration.py +42 -0
- mpt_tool/use_cases/errors.py +17 -0
- mpt_tool/use_cases/list_migrations.py +35 -0
- mpt_tool/use_cases/new_migration.py +22 -0
- mpt_tool/use_cases/run_migrations.py +98 -0
- mpt_tool-5.0.0.dist-info/METADATA +299 -0
- mpt_tool-5.0.0.dist-info/RECORD +48 -0
- mpt_tool-5.0.0.dist-info/WHEEL +4 -0
- mpt_tool-5.0.0.dist-info/entry_points.txt +2 -0
- 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,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,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)
|
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)
|