Skip to content

Commit a52c621

Browse files
authored
MPT-17299: add status column to the list command (#30)
Add status column to the list command <img width="1014" height="313" alt="image" src="https://github.com/user-attachments/assets/f64ab39d-20c9-407e-bf4a-d5c89ba96e6c" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> Closes [MPT-17299](https://softwareone.atlassian.net/browse/MPT-17299) - Add status column to the migrate list output (statuses: Running, Failed, Fake Apply, Applied, Not Applied) - Add MigrationStatusEnum with from_state(...) to derive status from started_at/applied_at timestamps - Introduce MigrationListItem dataclass to model list rows (migration_id, order_id, migration_type, started_at, applied_at, status) - Refactor rendering to MigrationRender class using Rich and Console(width=CONSOLE_WIDTH) - Update list use case to return list[MigrationListItem] sorted by order_id - Add CONSOLE_WIDTH constant (200) for consistent console width - Update run_migrations to track and persist status transitions (RUNNING, APPLIED, FAILED) and skip already-applied migrations - Update CLI tests and documentation to include and explain the new status column <!-- end of auto-generated comment: release notes by coderabbit.ai --> [MPT-17299]: https://softwareone.atlassian.net/browse/MPT-17299?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
2 parents 880a06c + 110289f commit a52c621

File tree

10 files changed

+161
-71
lines changed

10 files changed

+161
-71
lines changed

docs/PROJECT_DESCRIPTION.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,16 @@ To see all migrations and their status:
220220

221221
The output shows execution order, status, and timestamps.
222222

223+
The status column is derived from the persisted timestamps:
224+
225+
| Status | Condition |
226+
|-------------|---------------------------------------------------------------|
227+
| running | `started_at` is set and `applied_at` is empty |
228+
| failed | `started_at` and `applied_at` are empty for an existing state |
229+
| faked | `started_at` is empty and `applied_at` is set |
230+
| applied | Both `started_at` and `applied_at` are set |
231+
| not applied | No state entry exists for the migration file |
232+
223233
### Getting Help
224234
Run `mpt-tool --help` to see all available commands and params:
225235
```bash

mpt_tool/commands/list.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from rich.console import Console
55

66
from mpt_tool.commands.base import BaseCommand
7+
from mpt_tool.constants import CONSOLE_WIDTH
78
from mpt_tool.renders import MigrationRender
89
from mpt_tool.use_cases import ListMigrationsUseCase
910

@@ -23,11 +24,10 @@ def success_message(self) -> str:
2324

2425
@override
2526
def run(self) -> None:
26-
state_data = ListMigrationsUseCase().execute()
27-
if not state_data:
27+
migrations = ListMigrationsUseCase().execute()
28+
if not migrations:
2829
typer.echo("No migrations found.")
2930
return
3031

31-
console = Console()
32-
# TODO: check console render -> https://rich.readthedocs.io/en/stable/protocol.html#console-render
33-
console.print(MigrationRender.table(state_data), overflow="fold")
32+
console = Console(width=CONSOLE_WIDTH)
33+
console.print(MigrationRender(migration_items=migrations), overflow="fold")

mpt_tool/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
CONSOLE_WIDTH: int = 200
12
MAX_LEN_MIGRATION_ID: int = 125
23
MIGRATION_FOLDER: str = "migrations"
34
MIGRATION_STATE_FILE: str = ".migrations-state.json"

mpt_tool/enums.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,32 @@
11
from enum import StrEnum
2+
from typing import TYPE_CHECKING, Self
3+
4+
if TYPE_CHECKING:
5+
from mpt_tool.models import Migration
6+
7+
8+
class MigrationStatusEnum(StrEnum):
9+
"""Enumeration of migration status values."""
10+
11+
RUNNING = "running"
12+
FAILED = "failed"
13+
FAKE_APPLY = "faked"
14+
APPLIED = "applied"
15+
NOT_APPLIED = "not applied"
16+
17+
@classmethod
18+
def from_state(cls, migration_state: "Migration | None") -> Self:
19+
"""Calculate migration status based on migration state."""
20+
if migration_state is None:
21+
return cls.NOT_APPLIED
22+
if migration_state.started_at and migration_state.applied_at:
23+
return cls.APPLIED
24+
if migration_state.started_at and not migration_state.applied_at:
25+
return cls.RUNNING
26+
if migration_state.started_at is None and migration_state.applied_at:
27+
return cls.FAKE_APPLY
28+
29+
return cls.FAILED
230

331

432
class MigrationTypeEnum(StrEnum):

mpt_tool/models.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from typing import Any, Self
55

66
from mpt_tool.constants import MAX_LEN_MIGRATION_ID
7-
from mpt_tool.enums import MigrationTypeEnum
7+
from mpt_tool.enums import MigrationStatusEnum, MigrationTypeEnum
88

99

1010
@dataclass
@@ -110,3 +110,31 @@ def new(cls, migration_id: str, path: Path) -> Self:
110110
timestamp = dt.datetime.now(tz=dt.UTC).strftime("%Y%m%d%H%M%S")
111111
full_path = path / f"{timestamp}_{migration_id}.py"
112112
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/renders.py

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,30 @@
1-
from typing import Any
2-
1+
from rich.console import Console, ConsoleOptions, RenderResult
32
from rich.table import Table
43

4+
from mpt_tool.models import MigrationListItem
5+
56

67
class MigrationRender:
7-
"""Render the migration state."""
8-
9-
@classmethod
10-
def table(cls, migrations_data: dict[str, dict[str, Any]]) -> Table:
11-
"""Render the migration state data in a table."""
12-
table = Table()
13-
table.add_column("order_id")
14-
table.add_column("migration_id")
15-
table.add_column("started_at")
16-
table.add_column("applied_at")
17-
table.add_column("type")
18-
for key, migration_data in migrations_data.items():
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:
1921
table.add_row(
20-
str(migration_data["order_id"]),
21-
key,
22-
migration_data.get("started_at"),
23-
migration_data.get("applied_at"),
24-
migration_data.get("type"),
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(),
2528
)
2629

27-
return table
30+
yield table
Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import logging
2-
from typing import Any
32

43
from mpt_tool.managers import FileMigrationManager, StateManager, StateManagerFactory
4+
from mpt_tool.models import MigrationListItem
55

66
logger = logging.getLogger(__name__)
77

@@ -17,21 +17,19 @@ def __init__(
1717
self.file_migration_manager = file_migration_manager or FileMigrationManager()
1818
self.state_manager = state_manager or StateManagerFactory.get_instance()
1919

20-
def execute(self) -> dict[str, dict[str, Any]]:
21-
"""List all migrations."""
22-
migrations_files = self.file_migration_manager.retrieve_migration_files()
23-
state_file = self.state_manager.load()
24-
migration_list_data = {}
25-
for migration_file in migrations_files:
26-
try:
27-
state = state_file[migration_file.migration_id].to_dict()
28-
except KeyError:
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:
2928
logger.debug("No state found for migration: %s", migration_file.migration_id)
30-
state = {}
29+
migration_list.append(
30+
MigrationListItem.from_sources(
31+
migration_file=migration_file, migration_state=migration_state
32+
)
33+
)
3134

32-
state["order_id"] = migration_file.order_id
33-
migration_list_data[migration_file.migration_id] = state
34-
35-
# TODO: Create a DTO to represent the migration list and use it
36-
# between the CLI layer and the Application layer (Use cases)
37-
return migration_list_data
35+
return sorted(migration_list, key=lambda migration: migration.order_id)

mpt_tool/use_cases/run_migrations.py

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import logging
22

3-
from mpt_tool.enums import MigrationTypeEnum
3+
from mpt_tool.enums import MigrationStatusEnum, MigrationTypeEnum
44
from mpt_tool.managers import FileMigrationManager, StateManager, StateManagerFactory
55
from mpt_tool.managers.errors import LoadMigrationError, MigrationFolderError, StateNotFoundError
6-
from mpt_tool.models import Migration
6+
from mpt_tool.migration.base import BaseMigration
7+
from mpt_tool.models import Migration, MigrationFile
78
from mpt_tool.use_cases.errors import RunMigrationError
89

910
logger = logging.getLogger(__name__)
@@ -20,7 +21,7 @@ def __init__(
2021
self.file_migration_manager = file_migration_manager or FileMigrationManager()
2122
self.state_manager = state_manager or StateManagerFactory.get_instance()
2223

23-
def execute(self, migration_type: MigrationTypeEnum) -> None: # noqa: C901, WPS231
24+
def execute(self, migration_type: MigrationTypeEnum) -> None: # noqa: WPS231
2425
"""Run all migrations of a given type.
2526
2627
Args:
@@ -35,12 +36,10 @@ def execute(self, migration_type: MigrationTypeEnum) -> None: # noqa: C901, WPS
3536
raise RunMigrationError(str(error)) from error
3637

3738
for migration_file in migration_files:
38-
try:
39-
migration_instance = self.file_migration_manager.load_migration(migration_file)
40-
except LoadMigrationError as error:
41-
raise RunMigrationError(str(error)) from error
42-
43-
if migration_instance.type != migration_type:
39+
migration_instance = self._get_migration_instance_by_type(
40+
migration_file, migration_type
41+
)
42+
if migration_instance is None:
4443
continue
4544

4645
state = self._get_or_create_state(
@@ -51,20 +50,31 @@ def execute(self, migration_type: MigrationTypeEnum) -> None: # noqa: C901, WPS
5150
continue
5251

5352
logger.info("Running migration: %s", migration_file.migration_id)
54-
state.start()
53+
self._save_state(state, status=MigrationStatusEnum.RUNNING)
5554
try:
5655
migration_instance.run()
5756
# We catch all exceptions here to ensure the state is updated
5857
# and the flow is not interrupted abruptly
5958
except Exception as error:
60-
state.failed()
61-
self.state_manager.save_state(state)
59+
self._save_state(state, status=MigrationStatusEnum.FAILED)
6260
raise RunMigrationError(
6361
f"Migration {migration_file.migration_id} failed: {error!s}"
6462
) from error
6563

66-
state.applied()
67-
self.state_manager.save_state(state)
64+
self._save_state(state, status=MigrationStatusEnum.APPLIED)
65+
66+
def _get_migration_instance_by_type(
67+
self, migration_file: MigrationFile, migration_type: MigrationTypeEnum
68+
) -> BaseMigration | None:
69+
try:
70+
migration_instance = self.file_migration_manager.load_migration(migration_file)
71+
except LoadMigrationError as error:
72+
raise RunMigrationError(str(error)) from error
73+
74+
if migration_instance.type != migration_type:
75+
return None
76+
77+
return migration_instance
6878

6979
def _get_or_create_state(
7080
self, migration_id: str, migration_type: MigrationTypeEnum, order_id: int
@@ -75,3 +85,14 @@ def _get_or_create_state(
7585
state = self.state_manager.new(migration_id, migration_type, order_id)
7686

7787
return state
88+
89+
def _save_state(self, state: Migration, status: MigrationStatusEnum) -> None:
90+
match status:
91+
case MigrationStatusEnum.APPLIED:
92+
state.applied()
93+
case MigrationStatusEnum.FAILED:
94+
state.failed()
95+
case MigrationStatusEnum.RUNNING:
96+
state.start()
97+
98+
self.state_manager.save_state(state)

tests/cli/test_cli_airtable_storage.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,9 @@ def test_migrate_list(runner, log):
125125
assert result.exit_code == 0, result.output
126126
assert "No state found for migration: fake_schema_file_name" in log.text
127127
formatted_output = "".join(result.output.split())
128-
assert "┃order_id┃migration_id┃started_at┃applied_at┃type┃" in formatted_output
128+
assert "┃order_id┃migration_id┃started_at┃applied_at┃type┃status┃" in formatted_output
129129
assert (
130-
"│20250406020202│fake_data_file…│2025-04-06T13:…│2025-04-06T13:0…│data│" in formatted_output
130+
"│20250406020202│fake_data_file_name│2025-04-06T13:00:00+00:00│2025-04-06T13:00:00+00:00│data│Applied│"
131+
in formatted_output
131132
)
132-
assert "│20260101010101│fake_schema_fi…││││" in formatted_output
133+
assert "│20260101010101│fake_schema_file_name│-│-│-│NotApplied│" in formatted_output

tests/cli/test_cli_local_storage.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,16 @@ def applied_migration(data_migration_file, migration_state_file):
2121
"migration_id": "fake_data_file_name",
2222
"order_id": 20250406020202,
2323
"type": "data",
24-
"started_at": "2025-04-06T13:00:00+00:00",
25-
"applied_at": "2025-04-06T13:00:00+00:00",
24+
"started_at": "2025-04-06T13:10:20+00:00",
25+
"applied_at": "2025-04-06T13:10:30+00:00",
2626
}
2727
}
2828
migration_state_file.write_text(encoding="utf-8", data=json.dumps(applied_state_data))
2929

3030
return data_migration_file
3131

3232

33-
@freeze_time("2025-04-06 13:00:00")
33+
@freeze_time("2025-04-06 13:10:30")
3434
@pytest.mark.usefixtures("data_migration_file", "schema_migration_file")
3535
def test_migrate_data_migration(migration_state_file, runner, log):
3636
result = runner.invoke(app, ["migrate", "--data"])
@@ -42,8 +42,8 @@ def test_migrate_data_migration(migration_state_file, runner, log):
4242
"migration_id": "fake_data_file_name",
4343
"order_id": 20250406020202,
4444
"type": "data",
45-
"started_at": "2025-04-06T13:00:00+00:00",
46-
"applied_at": "2025-04-06T13:00:00+00:00",
45+
"started_at": "2025-04-06T13:10:30+00:00",
46+
"applied_at": "2025-04-06T13:10:30+00:00",
4747
}
4848
}
4949
assert "Running data migrations..." in result.output
@@ -63,16 +63,15 @@ def test_migrate_skip_migration_already_applied(migration_state_file, runner, lo
6363
"migration_id": "fake_data_file_name",
6464
"order_id": 20250406020202,
6565
"type": "data",
66-
"started_at": "2025-04-06T13:00:00+00:00",
67-
"applied_at": "2025-04-06T13:00:00+00:00",
66+
"started_at": "2025-04-06T13:10:20+00:00",
67+
"applied_at": "2025-04-06T13:10:30+00:00",
6868
}
6969
}
7070
assert "Running data migrations..." in result.output
7171
assert "Skipping applied migration: fake_data_file_name" in log.text
7272
assert "Migrations completed successfully." in result.output
7373

7474

75-
@freeze_time("2025-04-06 13:00:00")
7675
@pytest.mark.usefixtures("data_migration_file_error")
7776
def test_migrate_data_run_script_fail(migration_state_file, runner, log):
7877
result = runner.invoke(app, ["migrate", "--data"])
@@ -128,8 +127,9 @@ def test_migrate_list(runner, log):
128127
assert result.exit_code == 0, result.output
129128
assert "No state found for migration: fake_schema_file_name" in log.text
130129
formatted_output = "".join(result.output.split())
131-
assert "┃order_id┃migration_id┃started_at┃applied_at┃type┃" in formatted_output
130+
assert "┃order_id┃migration_id┃started_at┃applied_at┃type┃status┃" in formatted_output
132131
assert (
133-
"│20250406020202│fake_data_file…│2025-04-06T13:…│2025-04-06T13:0…│data│" in formatted_output
132+
"│20250406020202│fake_data_file_name│2025-04-06T13:10:20+00:00│2025-04-06T13:10:30+00:00│data│Applied│"
133+
in formatted_output
134134
)
135-
assert "│20260101010101│fake_schema_fi…││││" in formatted_output
135+
assert "│20260101010101│fake_schema_file_name│-│-│-│NotApplied│" in formatted_output

0 commit comments

Comments
 (0)