mongomig 0.1.0.dev0__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.
- mongomig/__init__.py +26 -0
- mongomig/__main__.py +4 -0
- mongomig/_version.py +1 -0
- mongomig/cli/__init__.py +0 -0
- mongomig/cli/app.py +135 -0
- mongomig/cli/commands/__init__.py +0 -0
- mongomig/cli/commands/current.py +72 -0
- mongomig/cli/commands/heads.py +40 -0
- mongomig/cli/commands/history.py +48 -0
- mongomig/cli/commands/init.py +71 -0
- mongomig/cli/commands/revision.py +41 -0
- mongomig/cli/context.py +47 -0
- mongomig/config/__init__.py +0 -0
- mongomig/config/envpy.py +58 -0
- mongomig/config/loader.py +178 -0
- mongomig/config/models.py +87 -0
- mongomig/database/__init__.py +0 -0
- mongomig/database/client.py +82 -0
- mongomig/database/redact.py +70 -0
- mongomig/errors.py +108 -0
- mongomig/migrations/__init__.py +0 -0
- mongomig/migrations/graph.py +161 -0
- mongomig/migrations/revision.py +107 -0
- mongomig/migrations/script.py +198 -0
- mongomig/migrations/tracker.py +149 -0
- mongomig/output/__init__.py +0 -0
- mongomig/output/console.py +88 -0
- mongomig/py.typed +0 -0
- mongomig/schema/__init__.py +0 -0
- mongomig/schema/snapshot.py +35 -0
- mongomig/templates/env.py.tmpl +18 -0
- mongomig/templates/mongomig.yaml.tmpl +25 -0
- mongomig/templates/revision.py.tmpl +22 -0
- mongomig-0.1.0.dev0.dist-info/METADATA +177 -0
- mongomig-0.1.0.dev0.dist-info/RECORD +38 -0
- mongomig-0.1.0.dev0.dist-info/WHEEL +4 -0
- mongomig-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- mongomig-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Revision ids, file names and rendering of new revision files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import secrets
|
|
8
|
+
import unicodedata
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from importlib.resources import files
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from string import Template
|
|
13
|
+
|
|
14
|
+
from mongomig.errors import ScriptError
|
|
15
|
+
|
|
16
|
+
REVISION_ID_RE = re.compile(r"^[A-Za-z0-9_]{1,64}$")
|
|
17
|
+
MAX_SLUG_LENGTH = 40
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def new_revision_id() -> str:
|
|
21
|
+
"""12 random hex chars: collisions between developers are practically impossible."""
|
|
22
|
+
return secrets.token_hex(6)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def validate_revision_id(rev_id: str) -> str:
|
|
26
|
+
if not REVISION_ID_RE.match(rev_id):
|
|
27
|
+
raise ScriptError(
|
|
28
|
+
f"Invalid revision id {rev_id!r}.",
|
|
29
|
+
suggestion="Use 1-64 letters, digits or underscores.",
|
|
30
|
+
)
|
|
31
|
+
return rev_id
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def slugify(message: str) -> str:
|
|
35
|
+
ascii_text = (
|
|
36
|
+
unicodedata.normalize("NFKD", message).encode("ascii", "ignore").decode("ascii").lower()
|
|
37
|
+
)
|
|
38
|
+
slug = re.sub(r"[^a-z0-9]+", "_", ascii_text).strip("_")
|
|
39
|
+
slug = slug[:MAX_SLUG_LENGTH].rstrip("_")
|
|
40
|
+
return slug or "revision"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def revision_filename(rev_id: str, message: str, created: datetime) -> str:
|
|
44
|
+
"""``YYYYMMDD_HHMM_<id>_<slug>.py`` — sorts chronologically in a file browser."""
|
|
45
|
+
return f"{created:%Y%m%d_%H%M}_{rev_id}_{slugify(message)}.py"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def render_revision(
|
|
49
|
+
*,
|
|
50
|
+
rev_id: str,
|
|
51
|
+
message: str,
|
|
52
|
+
down_revision: str | tuple[str, ...] | None,
|
|
53
|
+
snapshot_hash: str | None,
|
|
54
|
+
created: datetime,
|
|
55
|
+
) -> str:
|
|
56
|
+
template = Template(
|
|
57
|
+
files("mongomig").joinpath("templates/revision.py.tmpl").read_text(encoding="utf-8")
|
|
58
|
+
)
|
|
59
|
+
if isinstance(down_revision, tuple):
|
|
60
|
+
down_text = ", ".join(down_revision)
|
|
61
|
+
else:
|
|
62
|
+
down_text = down_revision or "<base>"
|
|
63
|
+
return template.substitute(
|
|
64
|
+
# The docstring must not be terminated early by the message itself.
|
|
65
|
+
message=message.replace("\\", "\\\\").replace('"""', "'''").strip() or "empty message",
|
|
66
|
+
revision=rev_id,
|
|
67
|
+
down_revision_text=down_text,
|
|
68
|
+
created=f"{created:%Y-%m-%d %H:%M:%S}",
|
|
69
|
+
revision_repr=_py_literal(rev_id),
|
|
70
|
+
down_revision_repr=_py_literal(down_revision),
|
|
71
|
+
snapshot_hash_repr=_py_literal(snapshot_hash),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _py_literal(value: str | tuple[str, ...] | None) -> str:
|
|
76
|
+
"""Python literal using double quotes (black/ruff style) for generated files."""
|
|
77
|
+
if value is None:
|
|
78
|
+
return "None"
|
|
79
|
+
if isinstance(value, tuple):
|
|
80
|
+
return "(" + ", ".join(json.dumps(v) for v in value) + ")"
|
|
81
|
+
return json.dumps(value)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def write_revision(
|
|
85
|
+
versions_dir: Path,
|
|
86
|
+
*,
|
|
87
|
+
message: str,
|
|
88
|
+
down_revision: str | tuple[str, ...] | None,
|
|
89
|
+
snapshot_hash: str | None,
|
|
90
|
+
rev_id: str | None = None,
|
|
91
|
+
now: datetime | None = None,
|
|
92
|
+
) -> tuple[str, Path]:
|
|
93
|
+
rev_id = validate_revision_id(rev_id) if rev_id else new_revision_id()
|
|
94
|
+
created = now or datetime.now(UTC)
|
|
95
|
+
path = versions_dir / revision_filename(rev_id, message, created)
|
|
96
|
+
if path.exists():
|
|
97
|
+
raise ScriptError(f"Refusing to overwrite existing file {path}")
|
|
98
|
+
content = render_revision(
|
|
99
|
+
rev_id=rev_id,
|
|
100
|
+
message=message,
|
|
101
|
+
down_revision=down_revision,
|
|
102
|
+
snapshot_hash=snapshot_hash,
|
|
103
|
+
created=created,
|
|
104
|
+
)
|
|
105
|
+
versions_dir.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
path.write_text(content, encoding="utf-8")
|
|
107
|
+
return rev_id, path
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Read revision files.
|
|
2
|
+
|
|
3
|
+
Metadata is read by parsing the file's AST instead of importing it, so offline commands
|
|
4
|
+
(``history``, ``heads``, ``revision``) are fast and never execute migration code or require
|
|
5
|
+
the application's dependencies. The module is only imported when a migration actually runs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
import hashlib
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from mongomig.errors import RevisionConflictError, ScriptError
|
|
17
|
+
from mongomig.migrations.revision import REVISION_ID_RE
|
|
18
|
+
|
|
19
|
+
SUPPORTED_FORMAT = 1
|
|
20
|
+
_METADATA_NAMES = frozenset(
|
|
21
|
+
{
|
|
22
|
+
"revision",
|
|
23
|
+
"down_revision",
|
|
24
|
+
"branch_labels",
|
|
25
|
+
"depends_on",
|
|
26
|
+
"reversible",
|
|
27
|
+
"snapshot_hash",
|
|
28
|
+
"mongomig_format",
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Script:
|
|
35
|
+
revision: str
|
|
36
|
+
down_revisions: tuple[str, ...]
|
|
37
|
+
path: Path
|
|
38
|
+
message: str
|
|
39
|
+
branch_labels: tuple[str, ...] = ()
|
|
40
|
+
depends_on: tuple[str, ...] = ()
|
|
41
|
+
reversible: bool = True
|
|
42
|
+
snapshot_hash: str | None = None
|
|
43
|
+
format_version: int = SUPPORTED_FORMAT
|
|
44
|
+
checksum: str = ""
|
|
45
|
+
has_downgrade: bool = True
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def is_base(self) -> bool:
|
|
49
|
+
return not self.down_revisions
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def is_merge(self) -> bool:
|
|
53
|
+
return len(self.down_revisions) > 1
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def file_checksum(source: bytes) -> str:
|
|
57
|
+
"""sha256 over the file with normalised newlines (CRLF checkouts must not look modified)."""
|
|
58
|
+
normalised = source.replace(b"\r\n", b"\n")
|
|
59
|
+
return "sha256:" + hashlib.sha256(normalised).hexdigest()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_script(path: Path) -> Script:
|
|
63
|
+
try:
|
|
64
|
+
source = path.read_bytes()
|
|
65
|
+
except OSError as exc:
|
|
66
|
+
raise ScriptError(f"Cannot read {path}: {exc.strerror}") from None
|
|
67
|
+
try:
|
|
68
|
+
tree = ast.parse(source, filename=str(path))
|
|
69
|
+
except SyntaxError as exc:
|
|
70
|
+
raise ScriptError(
|
|
71
|
+
f"Syntax error in {path.name} line {exc.lineno}: {exc.msg}",
|
|
72
|
+
details={"path": str(path)},
|
|
73
|
+
) from None
|
|
74
|
+
|
|
75
|
+
values, functions = _top_level(tree, path)
|
|
76
|
+
|
|
77
|
+
def fail(message: str, suggestion: str | None = None) -> ScriptError:
|
|
78
|
+
return ScriptError(
|
|
79
|
+
f"{path.name}: {message}", suggestion=suggestion, details={"path": str(path)}
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
revision = values.get("revision")
|
|
83
|
+
if not isinstance(revision, str) or not REVISION_ID_RE.match(revision):
|
|
84
|
+
raise fail("missing or invalid `revision` (expected a string id).")
|
|
85
|
+
|
|
86
|
+
fmt = values.get("mongomig_format", SUPPORTED_FORMAT)
|
|
87
|
+
if not isinstance(fmt, int) or fmt < 1:
|
|
88
|
+
raise fail("`mongomig_format` must be a positive integer.")
|
|
89
|
+
if fmt > SUPPORTED_FORMAT:
|
|
90
|
+
raise fail(
|
|
91
|
+
f"written for migration format {fmt}; this mongomig supports up to {SUPPORTED_FORMAT}.",
|
|
92
|
+
"Upgrade mongomig: pip install -U mongomig",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
reversible = values.get("reversible", True)
|
|
96
|
+
if not isinstance(reversible, bool):
|
|
97
|
+
raise fail("`reversible` must be True or False.")
|
|
98
|
+
|
|
99
|
+
if "upgrade" not in functions:
|
|
100
|
+
raise fail("missing `def upgrade(ctx)`.")
|
|
101
|
+
has_downgrade = "downgrade" in functions
|
|
102
|
+
if not has_downgrade and reversible:
|
|
103
|
+
raise fail(
|
|
104
|
+
"missing `def downgrade(ctx)`.",
|
|
105
|
+
"Add a downgrade function, or set `reversible = False` if it cannot be undone.",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
snapshot = values.get("snapshot_hash")
|
|
109
|
+
if snapshot is not None and not isinstance(snapshot, str):
|
|
110
|
+
raise fail("`snapshot_hash` must be a string or None.")
|
|
111
|
+
|
|
112
|
+
return Script(
|
|
113
|
+
revision=revision,
|
|
114
|
+
down_revisions=_id_tuple(values.get("down_revision"), "down_revision", fail),
|
|
115
|
+
path=path,
|
|
116
|
+
message=_message(tree),
|
|
117
|
+
branch_labels=_id_tuple(values.get("branch_labels"), "branch_labels", fail),
|
|
118
|
+
depends_on=_id_tuple(values.get("depends_on"), "depends_on", fail),
|
|
119
|
+
reversible=reversible,
|
|
120
|
+
snapshot_hash=snapshot,
|
|
121
|
+
format_version=fmt,
|
|
122
|
+
checksum=file_checksum(source),
|
|
123
|
+
has_downgrade=has_downgrade,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def load_scripts(versions_dir: Path) -> list[Script]:
|
|
128
|
+
"""Load every revision file in ``versions_dir`` (non-recursive; ``_*`` and ``.*`` skipped)."""
|
|
129
|
+
if not versions_dir.is_dir():
|
|
130
|
+
return []
|
|
131
|
+
scripts: list[Script] = []
|
|
132
|
+
seen: dict[str, Path] = {}
|
|
133
|
+
for path in sorted(versions_dir.glob("*.py")):
|
|
134
|
+
if path.name.startswith(("_", ".")):
|
|
135
|
+
continue
|
|
136
|
+
script = load_script(path)
|
|
137
|
+
if script.revision in seen:
|
|
138
|
+
raise RevisionConflictError(
|
|
139
|
+
f"Duplicate revision id {script.revision!r}.",
|
|
140
|
+
suggestion="Give one of the files a new id; revision ids must be unique.",
|
|
141
|
+
details={"files": [str(seen[script.revision]), str(path)]},
|
|
142
|
+
)
|
|
143
|
+
seen[script.revision] = path
|
|
144
|
+
scripts.append(script)
|
|
145
|
+
return scripts
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _top_level(tree: ast.Module, path: Path) -> tuple[dict[str, Any], set[str]]:
|
|
149
|
+
"""Literal metadata assignments and function names defined at module level."""
|
|
150
|
+
values: dict[str, Any] = {}
|
|
151
|
+
functions: set[str] = set()
|
|
152
|
+
for node in tree.body:
|
|
153
|
+
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
154
|
+
functions.add(node.name)
|
|
155
|
+
continue
|
|
156
|
+
if isinstance(node, ast.Assign):
|
|
157
|
+
targets, value = node.targets, node.value
|
|
158
|
+
elif isinstance(node, ast.AnnAssign) and node.value is not None:
|
|
159
|
+
targets, value = [node.target], node.value
|
|
160
|
+
else:
|
|
161
|
+
continue
|
|
162
|
+
for target in targets:
|
|
163
|
+
if isinstance(target, ast.Name) and target.id in _METADATA_NAMES:
|
|
164
|
+
values[target.id] = _literal(value, target.id, path)
|
|
165
|
+
return values, functions
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _literal(node: ast.expr, name: str, path: Path) -> Any:
|
|
169
|
+
try:
|
|
170
|
+
return ast.literal_eval(node)
|
|
171
|
+
except ValueError:
|
|
172
|
+
raise ScriptError(
|
|
173
|
+
f"{path.name}: `{name}` must be a literal value (string, tuple, bool, None).",
|
|
174
|
+
details={"path": str(path)},
|
|
175
|
+
) from None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _id_tuple(value: Any, name: str, fail: Any) -> tuple[str, ...]:
|
|
179
|
+
if value is None:
|
|
180
|
+
return ()
|
|
181
|
+
if isinstance(value, str):
|
|
182
|
+
items: tuple[Any, ...] = (value,)
|
|
183
|
+
elif isinstance(value, list | tuple):
|
|
184
|
+
items = tuple(value)
|
|
185
|
+
else:
|
|
186
|
+
raise fail(f"`{name}` must be None, a string, or a tuple of strings.")
|
|
187
|
+
if not all(isinstance(item, str) and item for item in items):
|
|
188
|
+
raise fail(f"`{name}` must contain only non-empty strings.")
|
|
189
|
+
if len(set(items)) != len(items):
|
|
190
|
+
raise fail(f"`{name}` contains duplicates.")
|
|
191
|
+
return items
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _message(tree: ast.Module) -> str:
|
|
195
|
+
doc = ast.get_docstring(tree)
|
|
196
|
+
if not doc:
|
|
197
|
+
return ""
|
|
198
|
+
return doc.strip().splitlines()[0].strip()
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""The ``__mongomig_migrations`` collection: which revisions have been applied."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import os
|
|
7
|
+
import socket
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
11
|
+
|
|
12
|
+
from mongomig._version import __version__
|
|
13
|
+
from mongomig.migrations.graph import RevisionGraph
|
|
14
|
+
from mongomig.migrations.script import Script
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from pymongo.collection import Collection
|
|
18
|
+
from pymongo.database import Database
|
|
19
|
+
|
|
20
|
+
Status = Literal["applied", "failed", "running"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class AppliedRecord:
|
|
25
|
+
revision: str
|
|
26
|
+
status: Status
|
|
27
|
+
down_revisions: tuple[str, ...]
|
|
28
|
+
description: str
|
|
29
|
+
checksum: str | None
|
|
30
|
+
applied_at: datetime | None
|
|
31
|
+
execution_time_ms: int | None
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_doc(cls, doc: dict[str, Any]) -> AppliedRecord:
|
|
35
|
+
down = doc.get("down_revision")
|
|
36
|
+
if down is None:
|
|
37
|
+
downs: tuple[str, ...] = ()
|
|
38
|
+
elif isinstance(down, str):
|
|
39
|
+
downs = (down,)
|
|
40
|
+
else:
|
|
41
|
+
downs = tuple(down)
|
|
42
|
+
return cls(
|
|
43
|
+
revision=str(doc["_id"]),
|
|
44
|
+
status=doc.get("status", "applied"),
|
|
45
|
+
down_revisions=downs,
|
|
46
|
+
description=doc.get("description", ""),
|
|
47
|
+
checksum=doc.get("checksum"),
|
|
48
|
+
applied_at=doc.get("applied_at"),
|
|
49
|
+
execution_time_ms=doc.get("execution_time_ms"),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class CurrentState:
|
|
55
|
+
"""Where the database is relative to the revision files."""
|
|
56
|
+
|
|
57
|
+
applied_heads: list[str]
|
|
58
|
+
pending: list[str]
|
|
59
|
+
unknown: list[str] # applied in the DB but no revision file (DB ahead of this code)
|
|
60
|
+
failed: list[str]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class MigrationTracker:
|
|
64
|
+
def __init__(self, db: Database[dict[str, Any]], collection_name: str) -> None:
|
|
65
|
+
self.db = db
|
|
66
|
+
self.collection_name = collection_name
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def collection(self) -> Collection[dict[str, Any]]:
|
|
70
|
+
return self.db[self.collection_name]
|
|
71
|
+
|
|
72
|
+
def exists(self) -> bool:
|
|
73
|
+
return self.collection_name in self.db.list_collection_names(
|
|
74
|
+
filter={"name": self.collection_name}
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def ensure(self) -> None:
|
|
78
|
+
"""Create the tracking collection and its indexes (idempotent, race-safe)."""
|
|
79
|
+
from pymongo.errors import CollectionInvalid
|
|
80
|
+
|
|
81
|
+
if not self.exists():
|
|
82
|
+
with contextlib.suppress(CollectionInvalid): # another runner created it first
|
|
83
|
+
self.db.create_collection(self.collection_name)
|
|
84
|
+
self.collection.create_index("status", name="status_1")
|
|
85
|
+
self.collection.create_index("applied_at", name="applied_at_1")
|
|
86
|
+
|
|
87
|
+
def records(self) -> list[AppliedRecord]:
|
|
88
|
+
"""All records (any status); read-only, safe when the collection doesn't exist."""
|
|
89
|
+
docs = self.collection.find({}, sort=[("applied_at", 1), ("_id", 1)])
|
|
90
|
+
return [AppliedRecord.from_doc(doc) for doc in docs]
|
|
91
|
+
|
|
92
|
+
def applied_ids(self) -> set[str]:
|
|
93
|
+
return {r.revision for r in self.records() if r.status == "applied"}
|
|
94
|
+
|
|
95
|
+
def record_applied(
|
|
96
|
+
self, script: Script, *, execution_time_ms: int, environment: str | None = None
|
|
97
|
+
) -> None:
|
|
98
|
+
self.collection.replace_one(
|
|
99
|
+
{"_id": script.revision},
|
|
100
|
+
self._doc(script, "applied", execution_time_ms, environment),
|
|
101
|
+
upsert=True,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def record_failed(self, script: Script, *, error: str, environment: str | None = None) -> None:
|
|
105
|
+
doc = self._doc(script, "failed", None, environment)
|
|
106
|
+
doc["error"] = error
|
|
107
|
+
self.collection.replace_one({"_id": script.revision}, doc, upsert=True)
|
|
108
|
+
|
|
109
|
+
def remove(self, revision: str) -> None:
|
|
110
|
+
self.collection.delete_one({"_id": revision})
|
|
111
|
+
|
|
112
|
+
def _doc(
|
|
113
|
+
self,
|
|
114
|
+
script: Script,
|
|
115
|
+
status: Status,
|
|
116
|
+
execution_time_ms: int | None,
|
|
117
|
+
environment: str | None,
|
|
118
|
+
) -> dict[str, Any]:
|
|
119
|
+
downs = script.down_revisions
|
|
120
|
+
return {
|
|
121
|
+
"_id": script.revision,
|
|
122
|
+
"revision": script.revision,
|
|
123
|
+
"down_revision": None if not downs else downs[0] if len(downs) == 1 else list(downs),
|
|
124
|
+
"description": script.message,
|
|
125
|
+
"status": status,
|
|
126
|
+
"checksum": script.checksum,
|
|
127
|
+
"applied_at": datetime.now(UTC),
|
|
128
|
+
"execution_time_ms": execution_time_ms,
|
|
129
|
+
"mongomig_version": __version__,
|
|
130
|
+
"meta": {
|
|
131
|
+
"environment": environment,
|
|
132
|
+
"hostname": socket.gethostname(),
|
|
133
|
+
"user": os.environ.get("USER") or os.environ.get("USERNAME"),
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def compute_state(graph: RevisionGraph, records: list[AppliedRecord]) -> CurrentState:
|
|
139
|
+
applied = {r.revision for r in records if r.status == "applied"}
|
|
140
|
+
failed = [r.revision for r in records if r.status == "failed"]
|
|
141
|
+
known_applied = {rev for rev in applied if rev in graph}
|
|
142
|
+
heads = [
|
|
143
|
+
rev
|
|
144
|
+
for rev in graph.topological_order()
|
|
145
|
+
if rev in known_applied and not (graph.children[rev] & known_applied)
|
|
146
|
+
]
|
|
147
|
+
pending = [rev for rev in graph.topological_order() if rev not in applied]
|
|
148
|
+
unknown = sorted(applied - set(graph.scripts))
|
|
149
|
+
return CurrentState(applied_heads=heads, pending=pending, unknown=unknown, failed=failed)
|
|
File without changes
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Single place that decides how results, warnings and errors reach the terminal.
|
|
2
|
+
|
|
3
|
+
Human mode renders with rich (colour only on a TTY). ``--json`` mode writes exactly one JSON
|
|
4
|
+
document to stdout per command; warnings go to stderr so stdout stays machine-parseable.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import TYPE_CHECKING, Any, TextIO
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
|
|
17
|
+
from mongomig.errors import MongoMigError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Output:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
*,
|
|
24
|
+
json_mode: bool = False,
|
|
25
|
+
verbose: bool = False,
|
|
26
|
+
stdout: TextIO | None = None,
|
|
27
|
+
stderr: TextIO | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
self.json_mode = json_mode
|
|
30
|
+
self.verbose = verbose
|
|
31
|
+
self._stdout = stdout
|
|
32
|
+
self._stderr = stderr
|
|
33
|
+
self._console: Console | None = None
|
|
34
|
+
self._err_console: Console | None = None
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def console(self) -> Console:
|
|
38
|
+
if self._console is None:
|
|
39
|
+
from rich.console import Console
|
|
40
|
+
|
|
41
|
+
self._console = Console(
|
|
42
|
+
file=self._stdout or sys.stdout, highlight=False, soft_wrap=True
|
|
43
|
+
)
|
|
44
|
+
return self._console
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def err_console(self) -> Console:
|
|
48
|
+
if self._err_console is None:
|
|
49
|
+
from rich.console import Console
|
|
50
|
+
|
|
51
|
+
self._err_console = Console(
|
|
52
|
+
file=self._stderr or sys.stderr, highlight=False, soft_wrap=True
|
|
53
|
+
)
|
|
54
|
+
return self._err_console
|
|
55
|
+
|
|
56
|
+
def result(self, data: Any, render: Callable[[Console], None]) -> None:
|
|
57
|
+
"""Emit a command result: ``data`` as JSON, or ``render`` for humans."""
|
|
58
|
+
if self.json_mode:
|
|
59
|
+
stream = self._stdout or sys.stdout
|
|
60
|
+
stream.write(json.dumps(data, indent=2, default=str) + "\n")
|
|
61
|
+
else:
|
|
62
|
+
render(self.console)
|
|
63
|
+
|
|
64
|
+
def warn(self, message: str) -> None:
|
|
65
|
+
if self.json_mode:
|
|
66
|
+
(self._stderr or sys.stderr).write(f"warning: {message}\n")
|
|
67
|
+
else:
|
|
68
|
+
self.err_console.print(f"[yellow]warning:[/yellow] {message}")
|
|
69
|
+
|
|
70
|
+
def info(self, message: str) -> None:
|
|
71
|
+
"""Verbose-only progress messages."""
|
|
72
|
+
if self.verbose and not self.json_mode:
|
|
73
|
+
self.err_console.print(f"[dim]{message}[/dim]")
|
|
74
|
+
|
|
75
|
+
def error(self, err: MongoMigError) -> None:
|
|
76
|
+
if self.json_mode:
|
|
77
|
+
stream = self._stdout or sys.stdout
|
|
78
|
+
stream.write(json.dumps({"error": err.to_dict()}, indent=2, default=str) + "\n")
|
|
79
|
+
return
|
|
80
|
+
from rich.markup import escape
|
|
81
|
+
|
|
82
|
+
con = self.err_console
|
|
83
|
+
con.print(f"[bold red]error:[/bold red] {escape(err.message)}")
|
|
84
|
+
if self.verbose and err.details:
|
|
85
|
+
for key, value in err.details.items():
|
|
86
|
+
con.print(f" [dim]{key}:[/dim] {escape(str(value))}")
|
|
87
|
+
if err.suggestion:
|
|
88
|
+
con.print(f"[cyan]hint:[/cyan] {escape(err.suggestion)}")
|
mongomig/py.typed
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""``schema_snapshot.json``: the committed "last known expected schema".
|
|
2
|
+
|
|
3
|
+
M1 only needs the empty document and a stable hash; the schema contents arrive in M3.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
SNAPSHOT_FORMAT = 1
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def empty_snapshot() -> dict[str, Any]:
|
|
17
|
+
return {"mongomig_format": SNAPSHOT_FORMAT, "storage": None, "collections": {}}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def canonical_json(data: Any) -> str:
|
|
21
|
+
"""Deterministic serialisation so git diffs and hashes are stable."""
|
|
22
|
+
return json.dumps(data, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def snapshot_hash(path: Path) -> str | None:
|
|
26
|
+
"""Hash of the snapshot's *content* (not formatting); ``None`` if there is no snapshot."""
|
|
27
|
+
if not path.is_file():
|
|
28
|
+
return None
|
|
29
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
30
|
+
compact = json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
31
|
+
return "sha256:" + hashlib.sha256(compact.encode("utf-8")).hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def write_snapshot(path: Path, data: dict[str, Any]) -> None:
|
|
35
|
+
path.write_text(canonical_json(data), encoding="utf-8")
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""MongoMig environment.
|
|
2
|
+
|
|
3
|
+
This file is plain Python, executed by MongoMig with your project root on sys.path, so you
|
|
4
|
+
can import your application's models here (just like Alembic's env.py).
|
|
5
|
+
|
|
6
|
+
`target_metadata` tells MongoMig which collections/models it should manage. Model-based
|
|
7
|
+
autogeneration is not wired up yet; until then leave it as None and write migrations by hand
|
|
8
|
+
with `mongomig revision -m "..."`.
|
|
9
|
+
|
|
10
|
+
Coming soon:
|
|
11
|
+
|
|
12
|
+
from mongomig import MongoMetadata
|
|
13
|
+
import app.models # noqa: F401 (importing registers @collection models)
|
|
14
|
+
|
|
15
|
+
target_metadata = MongoMetadata.default(storage="python", by_alias=True)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
target_metadata = None
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# MongoMig configuration.
|
|
2
|
+
# Values support ${VAR} and ${VAR:-default} environment-variable interpolation.
|
|
3
|
+
# Environment overlays: mongomig.<env>.yaml is merged on top when running
|
|
4
|
+
# `mongomig --env <env> ...` (or MONGOMIG_ENV=<env>).
|
|
5
|
+
|
|
6
|
+
database:
|
|
7
|
+
# Never put credentials here — keep them in the environment.
|
|
8
|
+
uri: ${MONGODB_URI}
|
|
9
|
+
# Optional if the URI already contains the database name.
|
|
10
|
+
name: ${MONGODB_DATABASE:-app}
|
|
11
|
+
server_selection_timeout_ms: 5000
|
|
12
|
+
|
|
13
|
+
migrations:
|
|
14
|
+
directory: {{migrations_dir}}
|
|
15
|
+
tracking_collection: __mongomig_migrations
|
|
16
|
+
lock_collection: __mongomig_lock
|
|
17
|
+
|
|
18
|
+
execution:
|
|
19
|
+
batch_size: 1000
|
|
20
|
+
sleep_ms_between_batches: 0
|
|
21
|
+
max_retries: 3
|
|
22
|
+
lock_ttl_seconds: 300
|
|
23
|
+
|
|
24
|
+
sampling:
|
|
25
|
+
size: 10000
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""${message}
|
|
2
|
+
|
|
3
|
+
Revision: ${revision}
|
|
4
|
+
Revises: ${down_revision_text}
|
|
5
|
+
Created: ${created} UTC
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
revision = ${revision_repr}
|
|
9
|
+
down_revision = ${down_revision_repr}
|
|
10
|
+
branch_labels = None
|
|
11
|
+
depends_on = None
|
|
12
|
+
reversible = True
|
|
13
|
+
snapshot_hash = ${snapshot_hash_repr}
|
|
14
|
+
mongomig_format = 1
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def upgrade(ctx):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def downgrade(ctx):
|
|
22
|
+
pass
|