Skip to content

Commit 60bee86

Browse files
committed
feat: add init option
1 parent 10f2c0e commit 60bee86

File tree

17 files changed

+331
-38
lines changed

17 files changed

+331
-38
lines changed

.pre-commit-config.yaml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ repos:
3535
args: ["--config-file=pyproject.toml", "."]
3636
pass_filenames: false
3737
additional_dependencies:
38-
- typer
39-
- mpt_api_client
40-
- pyairtable
38+
- mpt_api_client==5.0.*
39+
- typer>=0.9.0
40+
- types-requests==2.32.*
41+
- pyairtable==3.3.*

docs/PROJECT_DESCRIPTION.md

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@ mpt-tool is a command-line utility to scaffold, run, and audit migrations for MP
77
```bash
88
pip install mpt-tool
99
```
10-
2. **Create your first migration:**
10+
2. **Initialize the migration tool:**
11+
```bash
12+
mpt-tool migrate --init
13+
```
14+
3. **Create your first migration:**
1115
```bash
1216
mpt-tool migrate --new-data sync_users
1317
```
14-
3. **Edit the generated file in the migrations/ folder**
15-
4. **Run all pending migrations**
18+
4. **Edit the generated file in the migrations/ folder**
19+
5. **Run all pending data migrations**
1620
```bash
1721
mpt-tool migrate --data
1822
```
@@ -32,7 +36,7 @@ Install with pip or your favorite PyPI package manager:
3236
## Prerequisites
3337

3438
- Python 3.12+ in your environment
35-
- A `migrations/` folder in your project (it will be created automatically the first time you create a migration)
39+
- A `migrations/` folder in your project (created automatically with `--init` or when you create your first migration)
3640
- Environment variables. See [Environment Variables](#environment-variables) for details.
3741

3842
## Environment Variables
@@ -80,6 +84,27 @@ Your Airtable table must have the following columns:
8084
2. Add the columns listed above with the specified field types
8185
3. Set the environment variables with your base ID and table name
8286

87+
## Initialization
88+
89+
Before using the migration tool for the first time, you should initialize it. This creates the necessary resources:
90+
91+
```bash
92+
mpt-tool migrate --init
93+
```
94+
95+
This command creates:
96+
- The `migrations/` folder in your project root (if it doesn't exist)
97+
- The state storage:
98+
- For **local storage**: creates `.migrations-state.json` file
99+
- For **Airtable storage**: creates the table in Airtable with the required schema
100+
101+
**When to use `--init`:**
102+
- First time setting up the tool in a project
103+
- When switching from local to Airtable storage (or vice versa)
104+
- When you need to recreate the state storage
105+
106+
**Note:** If the state storage already exists, the command will fail with an error message. This prevents accidental data loss. If you need to reinitialize, manually delete the existing state file or table first.
107+
83108
## Usage
84109
85110
### Creating a New Migration
@@ -281,6 +306,14 @@ Run `mpt-tool --help` to see all available commands and params:
281306
282307
### Common Issues
283308
309+
**Initialization fails - state already exists:**
310+
- Error: "Cannot initialize - State file already exists" (local storage) or similar for Airtable
311+
- Cause: The state storage has already been initialized
312+
- Solution: This is intentional to prevent data loss. If you need to reinitialize:
313+
- For local storage: delete `.migrations-state.json` manually
314+
- For Airtable: delete the table manually or use a different table name
315+
- Only reinitialize if you're certain you want to start fresh
316+
284317
**Migrations not detected:**
285318
- Ensure files are in the `migrations/` folder
286319
- Verify filename follows the pattern: `<timestamp>_<migration_id>.py` (e.g., `20260121120000_migration_name.py`)

mpt_tool/cli.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ def migrate( # noqa: WPS211
3131
metavar="MIGRATION_ID",
3232
),
3333
] = None,
34+
init: Annotated[ # noqa: FBT002
35+
bool, typer.Option("--init", help="Initialize migration tool resources.")
36+
] = False,
3437
new_data: Annotated[
3538
str | None,
3639
typer.Option(

mpt_tool/commands/factory.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from mpt_tool.commands.data import DataCommand
88
from mpt_tool.commands.errors import CommandNotFoundError
99
from mpt_tool.commands.fake import FakeCommand
10+
from mpt_tool.commands.init import InitCommand
1011
from mpt_tool.commands.list import ListCommand
1112
from mpt_tool.commands.new_data import NewDataCommand
1213
from mpt_tool.commands.new_schema import NewSchemaCommand
@@ -30,6 +31,8 @@ def get_instance(cls, param_data: dict[str, bool | str | None]) -> BaseCommand:
3031
CommandNotFoundError: If no command is found.
3132
"""
3233
match param_data: # noqa: WPS242
34+
case {"init": True}:
35+
return InitCommand()
3336
case {"check": True}:
3437
return CheckCommand()
3538
case {"data": True}:

mpt_tool/commands/init.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from typing import override
2+
3+
from mpt_tool.commands.base import BaseCommand
4+
from mpt_tool.use_cases import InitializeUseCase
5+
6+
7+
class InitCommand(BaseCommand):
8+
"""Initialize migration tool resources."""
9+
10+
@override
11+
@property
12+
def start_message(self) -> str:
13+
return "Initializing migration tool..."
14+
15+
@override
16+
@property
17+
def success_message(self) -> str:
18+
return "Migration tool initialized successfully."
19+
20+
@override
21+
def run(self) -> None:
22+
InitializeUseCase().execute()

mpt_tool/managers/errors.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ class CreateMigrationError(ManagerError):
99
"""Error creating the migration file."""
1010

1111

12+
class InitializationError(ManagerError):
13+
"""Error during initialization."""
14+
15+
1216
class InvalidStateError(ManagerError):
1317
"""Error loading invalid state."""
1418

mpt_tool/managers/file_migration.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ class FileMigrationManager:
1717

1818
_migration_folder: Path = Path(MIGRATION_FOLDER)
1919

20+
@classmethod
21+
def exists(cls) -> bool:
22+
"""Check if the file migration folder exists."""
23+
return cls._migration_folder.exists()
24+
2025
@classmethod
2126
def load_migration(cls, migration_file: MigrationFile) -> BaseMigration:
2227
"""Loads a migration instance from a migration file.
@@ -61,7 +66,7 @@ def new_migration(cls, file_suffix: str, migration_type: MigrationTypeEnum) -> M
6166
Raises:
6267
CreateMigrationError: If an error occurs during migration creation.
6368
"""
64-
cls._migration_folder.mkdir(parents=True, exist_ok=True)
69+
cls.new_migration_folder()
6570
try:
6671
migration_file = MigrationFile.new(migration_id=file_suffix, path=cls._migration_folder)
6772
except ValueError as error:
@@ -84,6 +89,11 @@ def new_migration(cls, file_suffix: str, migration_type: MigrationTypeEnum) -> M
8489
)
8590
return migration_file
8691

92+
@classmethod
93+
def new_migration_folder(cls) -> None:
94+
"""Creates a new migration folder."""
95+
cls._migration_folder.mkdir(parents=True, exist_ok=True)
96+
8797
@classmethod
8898
def retrieve_migration_files(cls) -> tuple[MigrationFile, ...]:
8999
"""Retrieves all migration files."""
@@ -99,7 +109,7 @@ def validate(cls) -> tuple[MigrationFile, ...]:
99109
Raises:
100110
MigrationFolderError: If an error occurs during migration validation.
101111
"""
102-
if not cls._migration_folder.exists():
112+
if not cls.exists():
103113
raise MigrationFolderError(f"Migration folder not found: {cls._migration_folder}")
104114

105115
migrations = cls._get_migration_files()

mpt_tool/managers/state/airtable.py

Lines changed: 71 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
from typing import override
1+
from typing import Any, override
22

3+
from pyairtable import Api
34
from pyairtable.formulas import match
45
from pyairtable.orm import Model, fields
6+
from requests import HTTPError
57

68
from mpt_tool.config import get_airtable_config
79
from mpt_tool.enums import MigrationTypeEnum
810
from mpt_tool.managers import StateManager
9-
from mpt_tool.managers.errors import StateNotFoundError
11+
from mpt_tool.managers.errors import InitializationError, StateNotFoundError
1012
from mpt_tool.models import Migration
1113

1214

@@ -41,19 +43,13 @@ class AirtableStateManager(StateManager):
4143

4244
@override
4345
@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
46+
def exists(cls) -> bool:
47+
try:
48+
MigrationStateModel.meta.table.schema()
49+
except HTTPError:
50+
return False
5551

56-
return migrations
52+
return True
5753

5854
@override
5955
@classmethod
@@ -70,6 +66,67 @@ def get_by_id(cls, migration_id: str) -> Migration:
7066
applied_at=state.applied_at,
7167
)
7268

69+
@override
70+
@classmethod
71+
def initialize(cls) -> None:
72+
api_key = get_airtable_config("api_key")
73+
base_id = get_airtable_config("base_id")
74+
table_name = get_airtable_config("table_name")
75+
if not api_key or not base_id or not table_name:
76+
raise InitializationError(
77+
"Airtable configuration missing. Please set MPT_TOOL_STORAGE_AIRTABLE_API_KEY, "
78+
"MPT_TOOL_STORAGE_AIRTABLE_BASE_ID, and MPT_TOOL_STORAGE_AIRTABLE_TABLE_NAME."
79+
)
80+
81+
base = Api(api_key).base(base_id)
82+
table_fields: list[dict[str, Any]] = [
83+
{"name": "migration_id", "type": "singleLineText"},
84+
{"name": "order_id", "type": "number", "options": {"precision": 0}},
85+
{
86+
"name": "type",
87+
"type": "singleSelect",
88+
"options": {"choices": [{"name": "data"}, {"name": "schema"}]},
89+
},
90+
{
91+
"name": "started_at",
92+
"type": "dateTime",
93+
"options": {
94+
"dateFormat": {"name": "iso"},
95+
"timeFormat": {"name": "24hour"},
96+
"timeZone": "utc",
97+
},
98+
},
99+
{
100+
"name": "applied_at",
101+
"type": "dateTime",
102+
"options": {
103+
"dateFormat": {"name": "iso"},
104+
"timeFormat": {"name": "24hour"},
105+
"timeZone": "utc",
106+
},
107+
},
108+
]
109+
try:
110+
base.create_table(table_name, fields=table_fields)
111+
except HTTPError as error:
112+
raise InitializationError(str(error)) from error
113+
114+
@override
115+
@classmethod
116+
def load(cls) -> dict[str, Migration]:
117+
migrations = {}
118+
for state in MigrationStateModel.all():
119+
migration = Migration(
120+
migration_id=state.migration_id,
121+
order_id=state.order_id,
122+
type=MigrationTypeEnum(state.type),
123+
started_at=state.started_at,
124+
applied_at=state.applied_at,
125+
)
126+
migrations[migration.migration_id] = migration
127+
128+
return migrations
129+
73130
@override
74131
@classmethod
75132
def new(cls, migration_id: str, migration_type: MigrationTypeEnum, order_id: int) -> Migration:

mpt_tool/managers/state/base.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ class StateManager(ABC):
99

1010
@classmethod
1111
@abstractmethod
12-
def load(cls) -> dict[str, Migration]:
13-
"""Load migration states."""
12+
def exists(cls) -> bool:
13+
"""Return True if storage exists."""
1414
raise NotImplementedError
1515

1616
@classmethod
@@ -26,6 +26,22 @@ def get_by_id(cls, migration_id: str) -> Migration:
2626
"""
2727
raise NotImplementedError
2828

29+
@classmethod
30+
@abstractmethod
31+
def initialize(cls) -> None:
32+
"""Initialize the state storage.
33+
34+
Raises:
35+
InitializationError: If the storage already exists or cannot be created.
36+
"""
37+
raise NotImplementedError
38+
39+
@classmethod
40+
@abstractmethod
41+
def load(cls) -> dict[str, Migration]:
42+
"""Load migration states."""
43+
raise NotImplementedError
44+
2945
@classmethod
3046
@abstractmethod
3147
def new(cls, migration_id: str, migration_type: MigrationTypeEnum, order_id: int) -> Migration:

0 commit comments

Comments
 (0)