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.
@@ -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