mongrator 0.1.0__tar.gz
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.
- mongrator-0.1.0/PKG-INFO +158 -0
- mongrator-0.1.0/README.md +148 -0
- mongrator-0.1.0/pyproject.toml +37 -0
- mongrator-0.1.0/src/mongrator/__init__.py +3 -0
- mongrator-0.1.0/src/mongrator/_templates/migration.py.tmpl +40 -0
- mongrator-0.1.0/src/mongrator/cli.py +256 -0
- mongrator-0.1.0/src/mongrator/config.py +61 -0
- mongrator-0.1.0/src/mongrator/exceptions.py +65 -0
- mongrator-0.1.0/src/mongrator/loader.py +74 -0
- mongrator-0.1.0/src/mongrator/migration.py +57 -0
- mongrator-0.1.0/src/mongrator/ops.py +128 -0
- mongrator-0.1.0/src/mongrator/planner.py +76 -0
- mongrator-0.1.0/src/mongrator/py.typed +0 -0
- mongrator-0.1.0/src/mongrator/runner.py +218 -0
- mongrator-0.1.0/src/mongrator/state.py +101 -0
mongrator-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: mongrator
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight MongoDB schema migration tool
|
|
5
|
+
Author: Sasha Gerrand
|
|
6
|
+
Author-email: Sasha Gerrand <mongrator@sgerrand.dev>
|
|
7
|
+
Requires-Dist: pymongo>=4.10
|
|
8
|
+
Requires-Python: >=3.13
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# mongrator
|
|
12
|
+
|
|
13
|
+
[](https://github.com/sgerrand/pymongrator/actions/workflows/ci.yml)
|
|
14
|
+
|
|
15
|
+
Lightweight MongoDB schema migration tool with synchronous and asynchronous PyMongo support.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
pip install mongrator
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
# Create config and migrations directory
|
|
27
|
+
mongrator init
|
|
28
|
+
|
|
29
|
+
# Generate a new migration file
|
|
30
|
+
mongrator create add_users_email_index
|
|
31
|
+
|
|
32
|
+
# Check migration status
|
|
33
|
+
mongrator status
|
|
34
|
+
|
|
35
|
+
# Apply pending migrations
|
|
36
|
+
mongrator up
|
|
37
|
+
|
|
38
|
+
# Roll back the last migration
|
|
39
|
+
mongrator down
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Configuration
|
|
43
|
+
|
|
44
|
+
`mongrator init` creates a `mongrator.toml` stub:
|
|
45
|
+
|
|
46
|
+
```toml
|
|
47
|
+
uri = "mongodb://localhost:27017"
|
|
48
|
+
database = "mydb"
|
|
49
|
+
migrations_dir = "migrations"
|
|
50
|
+
collection = "mongrator_migrations" # optional
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Alternatively, configure via environment variables:
|
|
54
|
+
|
|
55
|
+
| Variable | Description | Required |
|
|
56
|
+
|----------|-------------|----------|
|
|
57
|
+
| `MONGRATOR_URI` | MongoDB connection URI | yes |
|
|
58
|
+
| `MONGRATOR_DB` | Database name | yes |
|
|
59
|
+
| `MONGRATOR_MIGRATIONS_DIR` | Path to migrations directory | no (default: `migrations`) |
|
|
60
|
+
| `MONGRATOR_COLLECTION` | Tracking collection name | no (default: `mongrator_migrations`) |
|
|
61
|
+
|
|
62
|
+
## Writing migrations
|
|
63
|
+
|
|
64
|
+
Migration files are plain Python named `{timestamp}_{slug}.py` (e.g. `20260408_143022_add_users_email_index.py`). Each file must define an `up(db)` function. A `down(db)` function is optional but enables rollback.
|
|
65
|
+
|
|
66
|
+
### Using the ops helpers (recommended)
|
|
67
|
+
|
|
68
|
+
The `ops` helpers record their own inverses, so `down()` is generated automatically:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from mongrator import ops
|
|
72
|
+
|
|
73
|
+
def up(db):
|
|
74
|
+
return [
|
|
75
|
+
ops.create_index("users", {"email": 1}, unique=True),
|
|
76
|
+
ops.rename_field("users", "username", "handle"),
|
|
77
|
+
ops.add_field("users", "verified", default_value=False),
|
|
78
|
+
]
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Using plain PyMongo
|
|
82
|
+
|
|
83
|
+
For complex logic, write directly against the `db` argument and define `down()` manually:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
def up(db):
|
|
87
|
+
db["orders"].update_many(
|
|
88
|
+
{"status": {"$exists": False}},
|
|
89
|
+
{"$set": {"status": "pending"}},
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def down(db):
|
|
93
|
+
db["orders"].update_many({}, {"$unset": {"status": ""}})
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Available ops helpers
|
|
97
|
+
|
|
98
|
+
| Helper | Reversible | Description |
|
|
99
|
+
|--------|-----------|-------------|
|
|
100
|
+
| `ops.create_index(collection, keys, **kwargs)` | yes | Create an index |
|
|
101
|
+
| `ops.drop_index(collection, index_name)` | no | Drop an index by name |
|
|
102
|
+
| `ops.rename_field(collection, old, new, filter=None)` | yes | Rename a field across documents |
|
|
103
|
+
| `ops.add_field(collection, field, default_value, filter=None)` | yes | Add a field with a default value |
|
|
104
|
+
|
|
105
|
+
## CLI reference
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
mongrator init create migrations dir and mongrator.toml
|
|
109
|
+
mongrator create <name> generate a new migration file
|
|
110
|
+
mongrator status show applied/pending migrations
|
|
111
|
+
mongrator up [--target ID] apply pending migrations
|
|
112
|
+
mongrator up --async [--target ID] apply using async runner
|
|
113
|
+
mongrator down [--steps N] roll back N migrations (default: 1)
|
|
114
|
+
mongrator down --async [--steps N] roll back using async runner
|
|
115
|
+
mongrator validate verify checksums of applied migrations
|
|
116
|
+
mongrator --config PATH <command> use an alternate config file
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Async usage
|
|
120
|
+
|
|
121
|
+
Pass `--async` to `up` or `down` to use the async runner (backed by `pymongo.AsyncMongoClient`):
|
|
122
|
+
|
|
123
|
+
```sh
|
|
124
|
+
mongrator up --async
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
To use the runners programmatically:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
# Synchronous
|
|
131
|
+
from pathlib import Path
|
|
132
|
+
import pymongo
|
|
133
|
+
from mongrator.config import MigratorConfig
|
|
134
|
+
from mongrator.runner import SyncRunner
|
|
135
|
+
|
|
136
|
+
config = MigratorConfig(uri="mongodb://localhost:27017", database="mydb", migrations_dir=Path("migrations"))
|
|
137
|
+
runner = SyncRunner(pymongo.MongoClient(config.uri), config)
|
|
138
|
+
runner.up()
|
|
139
|
+
|
|
140
|
+
# Asynchronous
|
|
141
|
+
from pymongo import AsyncMongoClient
|
|
142
|
+
from mongrator.runner import AsyncRunner
|
|
143
|
+
|
|
144
|
+
runner = AsyncRunner(AsyncMongoClient(config.uri), config)
|
|
145
|
+
await runner.up()
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Migration tracking
|
|
149
|
+
|
|
150
|
+
Applied migrations are recorded in the `mongrator_migrations` collection (configurable) within the target database. Each document stores:
|
|
151
|
+
|
|
152
|
+
- `_id` — migration file stem
|
|
153
|
+
- `applied_at` — UTC timestamp
|
|
154
|
+
- `checksum` — SHA-256 of the migration file at time of application
|
|
155
|
+
- `direction` — `"up"` or `"down"`
|
|
156
|
+
- `duration_ms` — execution time in milliseconds
|
|
157
|
+
|
|
158
|
+
Running `mongrator validate` compares current file checksums against recorded values and reports any modifications.
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# mongrator
|
|
2
|
+
|
|
3
|
+
[](https://github.com/sgerrand/pymongrator/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
Lightweight MongoDB schema migration tool with synchronous and asynchronous PyMongo support.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pip install mongrator
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
# Create config and migrations directory
|
|
17
|
+
mongrator init
|
|
18
|
+
|
|
19
|
+
# Generate a new migration file
|
|
20
|
+
mongrator create add_users_email_index
|
|
21
|
+
|
|
22
|
+
# Check migration status
|
|
23
|
+
mongrator status
|
|
24
|
+
|
|
25
|
+
# Apply pending migrations
|
|
26
|
+
mongrator up
|
|
27
|
+
|
|
28
|
+
# Roll back the last migration
|
|
29
|
+
mongrator down
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Configuration
|
|
33
|
+
|
|
34
|
+
`mongrator init` creates a `mongrator.toml` stub:
|
|
35
|
+
|
|
36
|
+
```toml
|
|
37
|
+
uri = "mongodb://localhost:27017"
|
|
38
|
+
database = "mydb"
|
|
39
|
+
migrations_dir = "migrations"
|
|
40
|
+
collection = "mongrator_migrations" # optional
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Alternatively, configure via environment variables:
|
|
44
|
+
|
|
45
|
+
| Variable | Description | Required |
|
|
46
|
+
|----------|-------------|----------|
|
|
47
|
+
| `MONGRATOR_URI` | MongoDB connection URI | yes |
|
|
48
|
+
| `MONGRATOR_DB` | Database name | yes |
|
|
49
|
+
| `MONGRATOR_MIGRATIONS_DIR` | Path to migrations directory | no (default: `migrations`) |
|
|
50
|
+
| `MONGRATOR_COLLECTION` | Tracking collection name | no (default: `mongrator_migrations`) |
|
|
51
|
+
|
|
52
|
+
## Writing migrations
|
|
53
|
+
|
|
54
|
+
Migration files are plain Python named `{timestamp}_{slug}.py` (e.g. `20260408_143022_add_users_email_index.py`). Each file must define an `up(db)` function. A `down(db)` function is optional but enables rollback.
|
|
55
|
+
|
|
56
|
+
### Using the ops helpers (recommended)
|
|
57
|
+
|
|
58
|
+
The `ops` helpers record their own inverses, so `down()` is generated automatically:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from mongrator import ops
|
|
62
|
+
|
|
63
|
+
def up(db):
|
|
64
|
+
return [
|
|
65
|
+
ops.create_index("users", {"email": 1}, unique=True),
|
|
66
|
+
ops.rename_field("users", "username", "handle"),
|
|
67
|
+
ops.add_field("users", "verified", default_value=False),
|
|
68
|
+
]
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Using plain PyMongo
|
|
72
|
+
|
|
73
|
+
For complex logic, write directly against the `db` argument and define `down()` manually:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
def up(db):
|
|
77
|
+
db["orders"].update_many(
|
|
78
|
+
{"status": {"$exists": False}},
|
|
79
|
+
{"$set": {"status": "pending"}},
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def down(db):
|
|
83
|
+
db["orders"].update_many({}, {"$unset": {"status": ""}})
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Available ops helpers
|
|
87
|
+
|
|
88
|
+
| Helper | Reversible | Description |
|
|
89
|
+
|--------|-----------|-------------|
|
|
90
|
+
| `ops.create_index(collection, keys, **kwargs)` | yes | Create an index |
|
|
91
|
+
| `ops.drop_index(collection, index_name)` | no | Drop an index by name |
|
|
92
|
+
| `ops.rename_field(collection, old, new, filter=None)` | yes | Rename a field across documents |
|
|
93
|
+
| `ops.add_field(collection, field, default_value, filter=None)` | yes | Add a field with a default value |
|
|
94
|
+
|
|
95
|
+
## CLI reference
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
mongrator init create migrations dir and mongrator.toml
|
|
99
|
+
mongrator create <name> generate a new migration file
|
|
100
|
+
mongrator status show applied/pending migrations
|
|
101
|
+
mongrator up [--target ID] apply pending migrations
|
|
102
|
+
mongrator up --async [--target ID] apply using async runner
|
|
103
|
+
mongrator down [--steps N] roll back N migrations (default: 1)
|
|
104
|
+
mongrator down --async [--steps N] roll back using async runner
|
|
105
|
+
mongrator validate verify checksums of applied migrations
|
|
106
|
+
mongrator --config PATH <command> use an alternate config file
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Async usage
|
|
110
|
+
|
|
111
|
+
Pass `--async` to `up` or `down` to use the async runner (backed by `pymongo.AsyncMongoClient`):
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
mongrator up --async
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
To use the runners programmatically:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
# Synchronous
|
|
121
|
+
from pathlib import Path
|
|
122
|
+
import pymongo
|
|
123
|
+
from mongrator.config import MigratorConfig
|
|
124
|
+
from mongrator.runner import SyncRunner
|
|
125
|
+
|
|
126
|
+
config = MigratorConfig(uri="mongodb://localhost:27017", database="mydb", migrations_dir=Path("migrations"))
|
|
127
|
+
runner = SyncRunner(pymongo.MongoClient(config.uri), config)
|
|
128
|
+
runner.up()
|
|
129
|
+
|
|
130
|
+
# Asynchronous
|
|
131
|
+
from pymongo import AsyncMongoClient
|
|
132
|
+
from mongrator.runner import AsyncRunner
|
|
133
|
+
|
|
134
|
+
runner = AsyncRunner(AsyncMongoClient(config.uri), config)
|
|
135
|
+
await runner.up()
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Migration tracking
|
|
139
|
+
|
|
140
|
+
Applied migrations are recorded in the `mongrator_migrations` collection (configurable) within the target database. Each document stores:
|
|
141
|
+
|
|
142
|
+
- `_id` — migration file stem
|
|
143
|
+
- `applied_at` — UTC timestamp
|
|
144
|
+
- `checksum` — SHA-256 of the migration file at time of application
|
|
145
|
+
- `direction` — `"up"` or `"down"`
|
|
146
|
+
- `duration_ms` — execution time in milliseconds
|
|
147
|
+
|
|
148
|
+
Running `mongrator validate` compares current file checksums against recorded values and reports any modifications.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mongrator"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Lightweight MongoDB schema migration tool"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Sasha Gerrand", email = "mongrator@sgerrand.dev" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"pymongo>=4.10",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[dependency-groups]
|
|
15
|
+
dev = [
|
|
16
|
+
"pytest>=8.3",
|
|
17
|
+
"pytest-asyncio>=0.24",
|
|
18
|
+
"testcontainers[mongodb]>=4.0",
|
|
19
|
+
"ty",
|
|
20
|
+
"ruff>=0.9",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
mongrator = "mongrator:main"
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["uv_build>=0.8.20,<0.9.0"]
|
|
28
|
+
build-backend = "uv_build"
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
asyncio_mode = "auto"
|
|
32
|
+
|
|
33
|
+
[tool.ruff]
|
|
34
|
+
line-length = 120
|
|
35
|
+
|
|
36
|
+
[tool.ruff.lint]
|
|
37
|
+
select = ["E", "F", "I", "UP"]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Migration: {slug}
|
|
2
|
+
|
|
3
|
+
Generated by mongrator on {timestamp}.
|
|
4
|
+
|
|
5
|
+
Use the ops helpers for common reversible operations, or write plain PyMongo
|
|
6
|
+
calls directly against the `db` argument for anything more complex.
|
|
7
|
+
|
|
8
|
+
Example using ops helpers (auto-rollback supported):
|
|
9
|
+
|
|
10
|
+
from mongrator import ops
|
|
11
|
+
|
|
12
|
+
def up(db):
|
|
13
|
+
return [
|
|
14
|
+
ops.create_index("my_collection", {{"field": 1}}, unique=True),
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
Example using plain PyMongo (define down() for rollback):
|
|
18
|
+
|
|
19
|
+
def up(db):
|
|
20
|
+
db["my_collection"].update_many({{}}, {{"$set": {{"new_field": None}}}})
|
|
21
|
+
|
|
22
|
+
def down(db):
|
|
23
|
+
db["my_collection"].update_many({{}}, {{"$unset": {{"new_field": ""}}}})
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Uncomment to use the ops helpers:
|
|
28
|
+
# from mongrator import ops
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def up(db):
|
|
32
|
+
pass
|
|
33
|
+
# return [
|
|
34
|
+
# ops.create_index("my_collection", {{"field": 1}}),
|
|
35
|
+
# ]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# def down(db):
|
|
39
|
+
# """Optional: define rollback logic here if not using ops helpers."""
|
|
40
|
+
# pass
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Command-line interface for mongrator.
|
|
2
|
+
|
|
3
|
+
Subcommands:
|
|
4
|
+
init — create the migrations directory and a mongrator.toml stub
|
|
5
|
+
create — generate a new timestamped migration file
|
|
6
|
+
status — show applied/pending migration table
|
|
7
|
+
up — apply pending migrations
|
|
8
|
+
down — roll back applied migrations
|
|
9
|
+
validate — verify checksums of applied migrations
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import asyncio
|
|
14
|
+
import sys
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from importlib.resources import files
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .config import MigratorConfig
|
|
20
|
+
from .exceptions import MigratorError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
24
|
+
parser = argparse.ArgumentParser(
|
|
25
|
+
prog="mongrator",
|
|
26
|
+
description="Lightweight MongoDB schema migration tool",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--config",
|
|
30
|
+
metavar="PATH",
|
|
31
|
+
default="mongrator.toml",
|
|
32
|
+
help="path to config file (default: mongrator.toml)",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
36
|
+
sub.required = True
|
|
37
|
+
|
|
38
|
+
# init
|
|
39
|
+
sub.add_parser("init", help="create migrations directory and config stub")
|
|
40
|
+
|
|
41
|
+
# create
|
|
42
|
+
p_create = sub.add_parser("create", help="generate a new migration file")
|
|
43
|
+
p_create.add_argument("name", help="short description, e.g. add_users_email_index")
|
|
44
|
+
|
|
45
|
+
# status
|
|
46
|
+
sub.add_parser("status", help="show applied/pending migration table")
|
|
47
|
+
|
|
48
|
+
# up
|
|
49
|
+
p_up = sub.add_parser("up", help="apply pending migrations")
|
|
50
|
+
p_up.add_argument("--target", metavar="ID", help="apply only up to this migration ID")
|
|
51
|
+
p_up.add_argument("--async", dest="use_async", action="store_true", help="use async runner")
|
|
52
|
+
|
|
53
|
+
# down
|
|
54
|
+
p_down = sub.add_parser("down", help="roll back applied migrations")
|
|
55
|
+
p_down.add_argument(
|
|
56
|
+
"--steps", type=int, default=1, metavar="N", help="number of migrations to roll back (default: 1)"
|
|
57
|
+
)
|
|
58
|
+
p_down.add_argument("--async", dest="use_async", action="store_true", help="use async runner")
|
|
59
|
+
|
|
60
|
+
# validate
|
|
61
|
+
sub.add_parser("validate", help="verify checksums of applied migration files")
|
|
62
|
+
|
|
63
|
+
return parser
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _load_config(args: argparse.Namespace) -> MigratorConfig:
|
|
67
|
+
config_path = Path(args.config)
|
|
68
|
+
if config_path.exists():
|
|
69
|
+
return MigratorConfig.from_toml(config_path)
|
|
70
|
+
return MigratorConfig.from_env()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Subcommand handlers
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _cmd_init(args: argparse.Namespace) -> int:
|
|
79
|
+
config_path = Path(args.config)
|
|
80
|
+
if not config_path.exists():
|
|
81
|
+
config_path.write_text(
|
|
82
|
+
'[mongrator]\nuri = "mongodb://localhost:27017"\ndatabase = "mydb"\n'
|
|
83
|
+
'migrations_dir = "migrations"\ncollection = "mongrator_migrations"\n'
|
|
84
|
+
)
|
|
85
|
+
print(f"Created {config_path}")
|
|
86
|
+
|
|
87
|
+
migrations_dir = Path("migrations")
|
|
88
|
+
migrations_dir.mkdir(exist_ok=True)
|
|
89
|
+
print(f"Created {migrations_dir}/")
|
|
90
|
+
return 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _cmd_create(args: argparse.Namespace) -> int:
|
|
94
|
+
config = _load_config(args)
|
|
95
|
+
config.migrations_dir.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
|
|
97
|
+
slug = args.name.strip().replace(" ", "_").lower()
|
|
98
|
+
timestamp = datetime.now(tz=UTC).strftime("%Y%m%d_%H%M%S")
|
|
99
|
+
filename = f"{timestamp}_{slug}.py"
|
|
100
|
+
dest = config.migrations_dir / filename
|
|
101
|
+
|
|
102
|
+
template_text = files("mongrator._templates").joinpath("migration.py.tmpl").read_text(encoding="utf-8")
|
|
103
|
+
content = template_text.format(slug=slug, timestamp=timestamp)
|
|
104
|
+
dest.write_text(content, encoding="utf-8")
|
|
105
|
+
print(f"Created {dest}")
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _cmd_status(args: argparse.Namespace) -> int:
|
|
110
|
+
from .runner import SyncRunner
|
|
111
|
+
|
|
112
|
+
config = _load_config(args)
|
|
113
|
+
try:
|
|
114
|
+
import pymongo
|
|
115
|
+
except ImportError:
|
|
116
|
+
print("error: pymongo is required. Install with: pip install pymongo", file=sys.stderr)
|
|
117
|
+
return 1
|
|
118
|
+
|
|
119
|
+
client = pymongo.MongoClient(config.uri)
|
|
120
|
+
runner = SyncRunner(client, config)
|
|
121
|
+
statuses = runner.status()
|
|
122
|
+
|
|
123
|
+
if not statuses:
|
|
124
|
+
print("No migrations found.")
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
col_width = max(len(s.id) for s in statuses) + 2
|
|
128
|
+
print(f"{'Migration':<{col_width}} {'Status':<10} {'Applied At'}")
|
|
129
|
+
print("-" * (col_width + 30))
|
|
130
|
+
for s in statuses:
|
|
131
|
+
state = "applied" if s.applied else "pending"
|
|
132
|
+
if s.applied and not s.checksum_ok:
|
|
133
|
+
state = "MODIFIED"
|
|
134
|
+
applied_at = s.applied_at.isoformat() if s.applied_at else "-"
|
|
135
|
+
print(f"{s.id:<{col_width}} {state:<10} {applied_at}")
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _cmd_up(args: argparse.Namespace) -> int:
|
|
140
|
+
config = _load_config(args)
|
|
141
|
+
if args.use_async:
|
|
142
|
+
return asyncio.run(_async_up(config, args.target))
|
|
143
|
+
import pymongo
|
|
144
|
+
|
|
145
|
+
from .runner import SyncRunner
|
|
146
|
+
|
|
147
|
+
client = pymongo.MongoClient(config.uri)
|
|
148
|
+
runner = SyncRunner(client, config)
|
|
149
|
+
applied = runner.up(target=args.target)
|
|
150
|
+
if applied:
|
|
151
|
+
for mid in applied:
|
|
152
|
+
print(f" applied {mid}")
|
|
153
|
+
else:
|
|
154
|
+
print("Nothing to apply.")
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def _async_up(config: MigratorConfig, target: str | None) -> int:
|
|
159
|
+
from pymongo import AsyncMongoClient
|
|
160
|
+
|
|
161
|
+
from .runner import AsyncRunner
|
|
162
|
+
|
|
163
|
+
client = AsyncMongoClient(config.uri)
|
|
164
|
+
runner = AsyncRunner(client, config)
|
|
165
|
+
applied = await runner.up(target=target)
|
|
166
|
+
if applied:
|
|
167
|
+
for mid in applied:
|
|
168
|
+
print(f" applied {mid}")
|
|
169
|
+
else:
|
|
170
|
+
print("Nothing to apply.")
|
|
171
|
+
return 0
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _cmd_down(args: argparse.Namespace) -> int:
|
|
175
|
+
config = _load_config(args)
|
|
176
|
+
if args.use_async:
|
|
177
|
+
return asyncio.run(_async_down(config, args.steps))
|
|
178
|
+
import pymongo
|
|
179
|
+
|
|
180
|
+
from .runner import SyncRunner
|
|
181
|
+
|
|
182
|
+
client = pymongo.MongoClient(config.uri)
|
|
183
|
+
runner = SyncRunner(client, config)
|
|
184
|
+
rolled_back = runner.down(steps=args.steps)
|
|
185
|
+
if rolled_back:
|
|
186
|
+
for mid in rolled_back:
|
|
187
|
+
print(f" rolled back {mid}")
|
|
188
|
+
else:
|
|
189
|
+
print("Nothing to roll back.")
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
async def _async_down(config: MigratorConfig, steps: int) -> int:
|
|
194
|
+
from pymongo import AsyncMongoClient
|
|
195
|
+
|
|
196
|
+
from .runner import AsyncRunner
|
|
197
|
+
|
|
198
|
+
client = AsyncMongoClient(config.uri)
|
|
199
|
+
runner = AsyncRunner(client, config)
|
|
200
|
+
rolled_back = await runner.down(steps=steps)
|
|
201
|
+
if rolled_back:
|
|
202
|
+
for mid in rolled_back:
|
|
203
|
+
print(f" rolled back {mid}")
|
|
204
|
+
else:
|
|
205
|
+
print("Nothing to roll back.")
|
|
206
|
+
return 0
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _cmd_validate(args: argparse.Namespace) -> int:
|
|
210
|
+
import pymongo
|
|
211
|
+
|
|
212
|
+
from .runner import SyncRunner
|
|
213
|
+
|
|
214
|
+
config = _load_config(args)
|
|
215
|
+
client = pymongo.MongoClient(config.uri)
|
|
216
|
+
runner = SyncRunner(client, config)
|
|
217
|
+
errors = runner.validate()
|
|
218
|
+
|
|
219
|
+
if not errors:
|
|
220
|
+
print("All applied migrations have valid checksums.")
|
|
221
|
+
return 0
|
|
222
|
+
|
|
223
|
+
eg = ExceptionGroup("Checksum mismatches detected", errors)
|
|
224
|
+
print(f"error: {eg}", file=sys.stderr)
|
|
225
|
+
for e in errors:
|
|
226
|
+
print(f" {e}", file=sys.stderr)
|
|
227
|
+
return 1
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
# Entry point
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def main() -> None:
|
|
236
|
+
parser = _build_parser()
|
|
237
|
+
args = parser.parse_args()
|
|
238
|
+
|
|
239
|
+
dispatch = {
|
|
240
|
+
"init": _cmd_init,
|
|
241
|
+
"create": _cmd_create,
|
|
242
|
+
"status": _cmd_status,
|
|
243
|
+
"up": _cmd_up,
|
|
244
|
+
"down": _cmd_down,
|
|
245
|
+
"validate": _cmd_validate,
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
rc = dispatch[args.command](args)
|
|
250
|
+
except MigratorError as e:
|
|
251
|
+
print(f"error: {e}", file=sys.stderr)
|
|
252
|
+
sys.exit(1)
|
|
253
|
+
except KeyboardInterrupt:
|
|
254
|
+
sys.exit(130)
|
|
255
|
+
|
|
256
|
+
sys.exit(rc)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tomllib
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Self
|
|
6
|
+
|
|
7
|
+
from .exceptions import ConfigurationError
|
|
8
|
+
|
|
9
|
+
_DEFAULT_COLLECTION = "mongrator_migrations"
|
|
10
|
+
_DEFAULT_MIGRATIONS_DIR = Path("migrations")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class MigratorConfig:
|
|
15
|
+
"""Immutable configuration for a migrator instance."""
|
|
16
|
+
|
|
17
|
+
uri: str
|
|
18
|
+
database: str
|
|
19
|
+
migrations_dir: Path
|
|
20
|
+
collection: str = _DEFAULT_COLLECTION
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_toml(cls, path: Path) -> Self:
|
|
24
|
+
"""Load configuration from a TOML file (e.g. mongrator.toml)."""
|
|
25
|
+
try:
|
|
26
|
+
with open(path, "rb") as f:
|
|
27
|
+
data = tomllib.load(f)
|
|
28
|
+
except FileNotFoundError:
|
|
29
|
+
raise ConfigurationError(f"Config file not found: {path}")
|
|
30
|
+
except tomllib.TOMLDecodeError as e:
|
|
31
|
+
raise ConfigurationError(f"Invalid TOML in {path}: {e}")
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
uri: str = data["uri"]
|
|
35
|
+
database: str = data["database"]
|
|
36
|
+
except KeyError as e:
|
|
37
|
+
raise ConfigurationError(f"Missing required config key: {e}")
|
|
38
|
+
|
|
39
|
+
migrations_dir = Path(data.get("migrations_dir", str(_DEFAULT_MIGRATIONS_DIR)))
|
|
40
|
+
collection: str = data.get("collection", _DEFAULT_COLLECTION)
|
|
41
|
+
return cls(uri=uri, database=database, migrations_dir=migrations_dir, collection=collection)
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_env(cls) -> Self:
|
|
45
|
+
"""Load configuration from environment variables.
|
|
46
|
+
|
|
47
|
+
Variables:
|
|
48
|
+
MONGRATOR_URI — MongoDB connection URI (required)
|
|
49
|
+
MONGRATOR_DB — database name (required)
|
|
50
|
+
MONGRATOR_MIGRATIONS_DIR — path to migrations directory (default: migrations)
|
|
51
|
+
MONGRATOR_COLLECTION — tracking collection name (default: mongrator_migrations)
|
|
52
|
+
"""
|
|
53
|
+
uri = os.environ.get("MONGRATOR_URI")
|
|
54
|
+
database = os.environ.get("MONGRATOR_DB")
|
|
55
|
+
if not uri:
|
|
56
|
+
raise ConfigurationError("MONGRATOR_URI environment variable is not set")
|
|
57
|
+
if not database:
|
|
58
|
+
raise ConfigurationError("MONGRATOR_DB environment variable is not set")
|
|
59
|
+
migrations_dir = Path(os.environ.get("MONGRATOR_MIGRATIONS_DIR", str(_DEFAULT_MIGRATIONS_DIR)))
|
|
60
|
+
collection = os.environ.get("MONGRATOR_COLLECTION", _DEFAULT_COLLECTION)
|
|
61
|
+
return cls(uri=uri, database=database, migrations_dir=migrations_dir, collection=collection)
|