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
mongomig/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""MongoMig — Alembic-style schema evolution and migrations for MongoDB."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from mongomig._version import __version__
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from mongomig.errors import MongoMigError
|
|
11
|
+
|
|
12
|
+
__all__ = ["MongoMigError", "__version__"]
|
|
13
|
+
|
|
14
|
+
# Public names are resolved lazily so `import mongomig` (and the CLI) stays fast.
|
|
15
|
+
_LAZY: dict[str, str] = {
|
|
16
|
+
"MongoMigError": "mongomig.errors",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def __getattr__(name: str) -> Any:
|
|
21
|
+
module_path = _LAZY.get(name)
|
|
22
|
+
if module_path is None:
|
|
23
|
+
raise AttributeError(f"module 'mongomig' has no attribute {name!r}")
|
|
24
|
+
import importlib
|
|
25
|
+
|
|
26
|
+
return getattr(importlib.import_module(module_path), name)
|
mongomig/__main__.py
ADDED
mongomig/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0.dev0"
|
mongomig/cli/__init__.py
ADDED
|
File without changes
|
mongomig/cli/app.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""CLI entry point.
|
|
2
|
+
|
|
3
|
+
Only the command *signatures* live here. Each implementation is in ``cli/commands/<name>.py``
|
|
4
|
+
and is imported when the command runs, keeping ``mongomig --help`` fast.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import importlib
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Annotated, Any
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from mongomig._version import __version__
|
|
16
|
+
from mongomig.cli.context import GlobalOptions
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(
|
|
19
|
+
name="mongomig",
|
|
20
|
+
help="Alembic-style schema evolution and migrations for MongoDB.",
|
|
21
|
+
no_args_is_help=True,
|
|
22
|
+
add_completion=False,
|
|
23
|
+
pretty_exceptions_enable=False,
|
|
24
|
+
rich_markup_mode=None,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
JsonOpt = Annotated[bool, typer.Option("--json", help="Machine-readable JSON output.")]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _version_callback(value: bool) -> None:
|
|
31
|
+
if value:
|
|
32
|
+
typer.echo(f"mongomig {__version__}")
|
|
33
|
+
raise typer.Exit()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.callback()
|
|
37
|
+
def _global(
|
|
38
|
+
ctx: typer.Context,
|
|
39
|
+
config: Annotated[
|
|
40
|
+
Path | None,
|
|
41
|
+
typer.Option(
|
|
42
|
+
"--config",
|
|
43
|
+
"-c",
|
|
44
|
+
envvar="MONGOMIG_CONFIG",
|
|
45
|
+
help="Path to mongomig.yaml.",
|
|
46
|
+
dir_okay=False,
|
|
47
|
+
),
|
|
48
|
+
] = None,
|
|
49
|
+
env: Annotated[
|
|
50
|
+
str | None,
|
|
51
|
+
typer.Option(
|
|
52
|
+
"--env",
|
|
53
|
+
"-e",
|
|
54
|
+
envvar="MONGOMIG_ENV",
|
|
55
|
+
help="Environment overlay to apply (mongomig.<env>.yaml).",
|
|
56
|
+
),
|
|
57
|
+
] = None,
|
|
58
|
+
json_: JsonOpt = False,
|
|
59
|
+
verbose: Annotated[bool, typer.Option("--verbose", "-v", help="More detail.")] = False,
|
|
60
|
+
_version: Annotated[
|
|
61
|
+
bool,
|
|
62
|
+
typer.Option(
|
|
63
|
+
"--version", callback=_version_callback, is_eager=True, help="Show version and exit."
|
|
64
|
+
),
|
|
65
|
+
] = False,
|
|
66
|
+
) -> None:
|
|
67
|
+
ctx.obj = GlobalOptions(config=config, env=env, json=json_, verbose=verbose)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _run(ctx: typer.Context, command: str, *, json_: bool = False, **kwargs: Any) -> None:
|
|
71
|
+
from mongomig.errors import MongoMigError
|
|
72
|
+
from mongomig.output.console import Output
|
|
73
|
+
|
|
74
|
+
opts: GlobalOptions = ctx.obj
|
|
75
|
+
out = Output(json_mode=opts.json or json_, verbose=opts.verbose)
|
|
76
|
+
module = importlib.import_module(f"mongomig.cli.commands.{command}")
|
|
77
|
+
try:
|
|
78
|
+
module.run(opts, out, **kwargs)
|
|
79
|
+
except MongoMigError as err:
|
|
80
|
+
out.error(err)
|
|
81
|
+
raise typer.Exit(int(err.exit_code)) from None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@app.command()
|
|
85
|
+
def init(
|
|
86
|
+
ctx: typer.Context,
|
|
87
|
+
directory: Annotated[
|
|
88
|
+
Path, typer.Argument(help="Project directory to initialise.", file_okay=False)
|
|
89
|
+
] = Path("."),
|
|
90
|
+
migrations_dir: Annotated[
|
|
91
|
+
str, typer.Option("--migrations-dir", help="Name of the migrations directory.")
|
|
92
|
+
] = "migrations",
|
|
93
|
+
json_: JsonOpt = False,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Create mongomig.yaml and the migrations/ directory."""
|
|
96
|
+
_run(ctx, "init", json_=json_, directory=directory, migrations_dir=migrations_dir)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.command()
|
|
100
|
+
def revision(
|
|
101
|
+
ctx: typer.Context,
|
|
102
|
+
message: Annotated[str, typer.Option("--message", "-m", help="Short description.")],
|
|
103
|
+
head: Annotated[
|
|
104
|
+
str | None,
|
|
105
|
+
typer.Option("--head", help="Parent revision (required when there are several heads)."),
|
|
106
|
+
] = None,
|
|
107
|
+
rev_id: Annotated[
|
|
108
|
+
str | None, typer.Option("--rev-id", help="Use this revision id instead of a random one.")
|
|
109
|
+
] = None,
|
|
110
|
+
json_: JsonOpt = False,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Create a new, empty revision file."""
|
|
113
|
+
_run(ctx, "revision", json_=json_, message=message, head=head, rev_id=rev_id)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.command()
|
|
117
|
+
def heads(ctx: typer.Context, json_: JsonOpt = False) -> None:
|
|
118
|
+
"""Show the head revision(s)."""
|
|
119
|
+
_run(ctx, "heads", json_=json_)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.command()
|
|
123
|
+
def history(ctx: typer.Context, json_: JsonOpt = False) -> None:
|
|
124
|
+
"""List all revisions, newest first."""
|
|
125
|
+
_run(ctx, "history", json_=json_)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@app.command()
|
|
129
|
+
def current(ctx: typer.Context, json_: JsonOpt = False) -> None:
|
|
130
|
+
"""Show which revisions are applied to the database, and what is pending."""
|
|
131
|
+
_run(ctx, "current", json_=json_)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def main() -> None:
|
|
135
|
+
app()
|
|
File without changes
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from mongomig.cli.context import GlobalOptions, load_config, load_graph
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from mongomig.output.console import Output
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run(opts: GlobalOptions, out: Output) -> None:
|
|
14
|
+
from mongomig.database.client import create_client, get_database, ping
|
|
15
|
+
from mongomig.migrations.tracker import MigrationTracker, compute_state
|
|
16
|
+
|
|
17
|
+
config = load_config(opts, out)
|
|
18
|
+
graph = load_graph(config)
|
|
19
|
+
|
|
20
|
+
client = create_client(config)
|
|
21
|
+
try:
|
|
22
|
+
ping(client, config)
|
|
23
|
+
db = get_database(client, config)
|
|
24
|
+
# Read-only: never creates the tracking collection.
|
|
25
|
+
tracker = MigrationTracker(db, config.settings.migrations.tracking_collection)
|
|
26
|
+
records = tracker.records()
|
|
27
|
+
finally:
|
|
28
|
+
client.close()
|
|
29
|
+
|
|
30
|
+
state = compute_state(graph, records)
|
|
31
|
+
|
|
32
|
+
def describe(rev: str) -> dict[str, str]:
|
|
33
|
+
script = graph.scripts.get(rev)
|
|
34
|
+
return {"revision": rev, "message": script.message if script else ""}
|
|
35
|
+
|
|
36
|
+
current = [describe(r) for r in state.applied_heads]
|
|
37
|
+
pending = [describe(r) for r in state.pending]
|
|
38
|
+
data = {
|
|
39
|
+
"database": db.name,
|
|
40
|
+
"environment": config.environment,
|
|
41
|
+
"current": current,
|
|
42
|
+
"pending": pending,
|
|
43
|
+
"failed": state.failed,
|
|
44
|
+
"unknown": state.unknown,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
def render(con: Console) -> None:
|
|
48
|
+
env = f" [dim]({config.environment})[/dim]" if config.environment else ""
|
|
49
|
+
con.print(f"Database: [bold]{db.name}[/bold]{env}")
|
|
50
|
+
if state.applied_heads:
|
|
51
|
+
for item in current:
|
|
52
|
+
con.print(f"Current: [bold]{item['revision']}[/bold] {item['message']}")
|
|
53
|
+
else:
|
|
54
|
+
con.print("Current: [dim]<base> (no revisions applied)[/dim]")
|
|
55
|
+
|
|
56
|
+
if state.pending:
|
|
57
|
+
con.print(f"Pending: [yellow]{len(state.pending)}[/yellow]")
|
|
58
|
+
for item in pending:
|
|
59
|
+
con.print(f" - {item['revision']} {item['message']}")
|
|
60
|
+
else:
|
|
61
|
+
con.print("Pending: [green]none — up to date[/green]")
|
|
62
|
+
|
|
63
|
+
for rev in state.failed:
|
|
64
|
+
con.print(f"[red]Failed:[/red] {rev} (last run did not complete)")
|
|
65
|
+
if state.unknown:
|
|
66
|
+
con.print(
|
|
67
|
+
f"[yellow]Unknown:[/yellow] {', '.join(state.unknown)} "
|
|
68
|
+
"— applied in the database but no revision file here. "
|
|
69
|
+
"Is this code older than the database?"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
out.result(data, render)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from mongomig.cli.context import GlobalOptions, load_config, load_graph, relpath
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from mongomig.output.console import Output
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run(opts: GlobalOptions, out: Output) -> None:
|
|
14
|
+
config = load_config(opts, out)
|
|
15
|
+
graph = load_graph(config)
|
|
16
|
+
heads = graph.heads()
|
|
17
|
+
data = [
|
|
18
|
+
{
|
|
19
|
+
"revision": rev,
|
|
20
|
+
"message": graph.scripts[rev].message,
|
|
21
|
+
"path": relpath(graph.scripts[rev].path, config),
|
|
22
|
+
}
|
|
23
|
+
for rev in heads
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
def render(con: Console) -> None:
|
|
27
|
+
if not heads:
|
|
28
|
+
con.print('No revisions yet. Create one with: mongomig revision -m "initial"')
|
|
29
|
+
return
|
|
30
|
+
for item in data:
|
|
31
|
+
con.print(f"[bold]{item['revision']}[/bold] (head) {item['message']}")
|
|
32
|
+
if out.verbose:
|
|
33
|
+
con.print(f" [dim]{item['path']}[/dim]")
|
|
34
|
+
if len(heads) > 1:
|
|
35
|
+
con.print(
|
|
36
|
+
f"\n[yellow]{len(heads)} heads:[/yellow] the history has diverged "
|
|
37
|
+
"(e.g. two branches each added a revision). Merge them before upgrading."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
out.result({"heads": data}, render)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
from mongomig.cli.context import GlobalOptions, load_config, load_graph, relpath
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from mongomig.output.console import Output
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run(opts: GlobalOptions, out: Output) -> None:
|
|
14
|
+
config = load_config(opts, out)
|
|
15
|
+
graph = load_graph(config)
|
|
16
|
+
heads = set(graph.heads())
|
|
17
|
+
|
|
18
|
+
data: list[dict[str, Any]] = []
|
|
19
|
+
for rev in reversed(graph.topological_order()):
|
|
20
|
+
script = graph.scripts[rev]
|
|
21
|
+
data.append(
|
|
22
|
+
{
|
|
23
|
+
"revision": rev,
|
|
24
|
+
"down_revisions": list(script.down_revisions),
|
|
25
|
+
"message": script.message,
|
|
26
|
+
"is_head": rev in heads,
|
|
27
|
+
"is_base": script.is_base,
|
|
28
|
+
"is_merge": script.is_merge,
|
|
29
|
+
"reversible": script.reversible,
|
|
30
|
+
"path": relpath(script.path, config),
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def render(con: Console) -> None:
|
|
35
|
+
if not data:
|
|
36
|
+
con.print("No revisions yet.")
|
|
37
|
+
return
|
|
38
|
+
for item in data:
|
|
39
|
+
parents = ", ".join(item["down_revisions"]) or "<base>"
|
|
40
|
+
tags = [t for t, on in (("head", item["is_head"]), ("merge", item["is_merge"])) if on]
|
|
41
|
+
if not item["reversible"]:
|
|
42
|
+
tags.append("irreversible")
|
|
43
|
+
tag_text = f" [cyan]({', '.join(tags)})[/cyan]" if tags else ""
|
|
44
|
+
con.print(f"{parents} -> [bold]{item['revision']}[/bold]{tag_text}, {item['message']}")
|
|
45
|
+
if out.verbose:
|
|
46
|
+
con.print(f" [dim]{item['path']}[/dim]")
|
|
47
|
+
|
|
48
|
+
out.result({"revisions": data}, render)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from importlib.resources import files
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from mongomig.cli.context import GlobalOptions
|
|
9
|
+
from mongomig.config.models import (
|
|
10
|
+
DEFAULT_CONFIG_FILENAME,
|
|
11
|
+
ENV_FILENAME,
|
|
12
|
+
SNAPSHOT_FILENAME,
|
|
13
|
+
VERSIONS_DIRNAME,
|
|
14
|
+
)
|
|
15
|
+
from mongomig.errors import ConfigError
|
|
16
|
+
from mongomig.schema.snapshot import canonical_json, empty_snapshot
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
|
|
21
|
+
from mongomig.output.console import Output
|
|
22
|
+
|
|
23
|
+
_DIR_NAME_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.-]*$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _template(name: str) -> str:
|
|
27
|
+
return files("mongomig").joinpath(f"templates/{name}").read_text(encoding="utf-8")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run(opts: GlobalOptions, out: Output, *, directory: Path, migrations_dir: str) -> None:
|
|
31
|
+
if not _DIR_NAME_RE.match(migrations_dir):
|
|
32
|
+
raise ConfigError(
|
|
33
|
+
f"Invalid migrations directory name {migrations_dir!r}.",
|
|
34
|
+
suggestion="Use a simple directory name such as 'migrations'.",
|
|
35
|
+
)
|
|
36
|
+
root = directory.expanduser().resolve()
|
|
37
|
+
mig = root / migrations_dir
|
|
38
|
+
planned: dict[Path, str | None] = {
|
|
39
|
+
root / DEFAULT_CONFIG_FILENAME: _template("mongomig.yaml.tmpl").replace(
|
|
40
|
+
"{{migrations_dir}}", migrations_dir
|
|
41
|
+
),
|
|
42
|
+
mig / ENV_FILENAME: _template("env.py.tmpl"),
|
|
43
|
+
mig / SNAPSHOT_FILENAME: canonical_json(empty_snapshot()),
|
|
44
|
+
mig / VERSIONS_DIRNAME / ".gitkeep": "",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
existing = [p for p in planned if p.exists()]
|
|
48
|
+
if existing:
|
|
49
|
+
raise ConfigError(
|
|
50
|
+
"MongoMig is already initialised here: "
|
|
51
|
+
+ ", ".join(str(p.relative_to(root)) for p in existing),
|
|
52
|
+
suggestion="Remove those files first if you really want to start over.",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
for path, content in planned.items():
|
|
56
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
path.write_text(content or "", encoding="utf-8")
|
|
58
|
+
|
|
59
|
+
created = [str(p.relative_to(root)) for p in planned if p.name != ".gitkeep"]
|
|
60
|
+
created.insert(3, f"{migrations_dir}/{VERSIONS_DIRNAME}/")
|
|
61
|
+
|
|
62
|
+
def render(con: Console) -> None:
|
|
63
|
+
con.print(f"[green]Initialised MongoMig in[/green] {root}")
|
|
64
|
+
for item in created:
|
|
65
|
+
con.print(f" [green]+[/green] {item}")
|
|
66
|
+
con.print("\nNext steps:")
|
|
67
|
+
con.print(" 1. export MONGODB_URI=mongodb://localhost:27017")
|
|
68
|
+
con.print(' 2. mongomig revision -m "initial"')
|
|
69
|
+
con.print(" 3. mongomig current")
|
|
70
|
+
|
|
71
|
+
out.result({"root": str(root), "created": created}, render)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from mongomig.cli.context import GlobalOptions, load_config, load_graph, relpath
|
|
6
|
+
from mongomig.errors import RevisionConflictError
|
|
7
|
+
from mongomig.migrations.revision import write_revision
|
|
8
|
+
from mongomig.schema.snapshot import snapshot_hash
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
from mongomig.output.console import Output
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run(
|
|
17
|
+
opts: GlobalOptions, out: Output, *, message: str, head: str | None, rev_id: str | None
|
|
18
|
+
) -> None:
|
|
19
|
+
config = load_config(opts, out)
|
|
20
|
+
graph = load_graph(config)
|
|
21
|
+
|
|
22
|
+
parent = graph.resolve(head) if head else graph.single_head()
|
|
23
|
+
if head and parent is not None and graph.children[parent]:
|
|
24
|
+
out.warn(f"{parent} already has children; this creates a new branch (a second head).")
|
|
25
|
+
if rev_id and rev_id in graph:
|
|
26
|
+
raise RevisionConflictError(f"Revision id {rev_id!r} already exists.")
|
|
27
|
+
|
|
28
|
+
new_id, path = write_revision(
|
|
29
|
+
config.versions_dir,
|
|
30
|
+
message=message,
|
|
31
|
+
down_revision=parent,
|
|
32
|
+
snapshot_hash=snapshot_hash(config.snapshot_path),
|
|
33
|
+
rev_id=rev_id,
|
|
34
|
+
)
|
|
35
|
+
shown = relpath(path, config)
|
|
36
|
+
|
|
37
|
+
def render(con: Console) -> None:
|
|
38
|
+
con.print(f"[green]Created revision[/green] [bold]{new_id}[/bold] → {shown}")
|
|
39
|
+
con.print(f" revises: {parent or '<base>'}")
|
|
40
|
+
|
|
41
|
+
out.result({"revision": new_id, "down_revision": parent, "path": shown}, render)
|
mongomig/cli/context.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""State shared by all commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from mongomig.config.models import LoadedConfig
|
|
11
|
+
from mongomig.migrations.graph import RevisionGraph
|
|
12
|
+
from mongomig.output.console import Output
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class GlobalOptions:
|
|
17
|
+
config: Path | None = None
|
|
18
|
+
env: str | None = None
|
|
19
|
+
json: bool = False
|
|
20
|
+
verbose: bool = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_config(opts: GlobalOptions, out: Output) -> LoadedConfig:
|
|
24
|
+
from mongomig.config.loader import load_config as _load
|
|
25
|
+
|
|
26
|
+
config = _load(opts.config, environment=opts.env)
|
|
27
|
+
for warning in config.warnings:
|
|
28
|
+
out.warn(warning)
|
|
29
|
+
out.info(
|
|
30
|
+
f"config: {config.config_path}"
|
|
31
|
+
+ (f" (environment: {config.environment})" if config.environment else "")
|
|
32
|
+
)
|
|
33
|
+
return config
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_graph(config: LoadedConfig) -> RevisionGraph:
|
|
37
|
+
from mongomig.migrations.graph import RevisionGraph
|
|
38
|
+
from mongomig.migrations.script import load_scripts
|
|
39
|
+
|
|
40
|
+
return RevisionGraph(load_scripts(config.versions_dir))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def relpath(path: Path, config: LoadedConfig) -> str:
|
|
44
|
+
try:
|
|
45
|
+
return str(path.relative_to(config.root_dir))
|
|
46
|
+
except ValueError:
|
|
47
|
+
return str(path)
|
|
File without changes
|
mongomig/config/envpy.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Load the project's ``migrations/env.py`` and read ``target_metadata`` from it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import sys
|
|
7
|
+
import traceback
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from types import ModuleType
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from mongomig.config.models import LoadedConfig
|
|
13
|
+
from mongomig.errors import ConfigError
|
|
14
|
+
|
|
15
|
+
ENV_MODULE_NAME = "_mongomig_env"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_env_module(path: Path, project_root: Path) -> ModuleType:
|
|
19
|
+
"""Execute ``env.py`` as a fresh module with ``project_root`` importable.
|
|
20
|
+
|
|
21
|
+
The project root goes on ``sys.path`` so ``import app.models`` works from env.py, the same
|
|
22
|
+
way Alembic's env.py imports application code.
|
|
23
|
+
"""
|
|
24
|
+
if not path.is_file():
|
|
25
|
+
raise ConfigError(
|
|
26
|
+
f"{path.name} not found at {path}",
|
|
27
|
+
suggestion="Run `mongomig init` or restore migrations/env.py.",
|
|
28
|
+
)
|
|
29
|
+
root = str(project_root)
|
|
30
|
+
if root not in sys.path:
|
|
31
|
+
sys.path.insert(0, root)
|
|
32
|
+
|
|
33
|
+
spec = importlib.util.spec_from_file_location(ENV_MODULE_NAME, path)
|
|
34
|
+
if spec is None or spec.loader is None:
|
|
35
|
+
raise ConfigError(f"Cannot load {path}")
|
|
36
|
+
module = importlib.util.module_from_spec(spec)
|
|
37
|
+
sys.modules[ENV_MODULE_NAME] = module
|
|
38
|
+
try:
|
|
39
|
+
spec.loader.exec_module(module)
|
|
40
|
+
except Exception as exc:
|
|
41
|
+
sys.modules.pop(ENV_MODULE_NAME, None)
|
|
42
|
+
last = traceback.extract_tb(exc.__traceback__)[-1]
|
|
43
|
+
raise ConfigError(
|
|
44
|
+
f"Error while executing {path.name}: {type(exc).__name__}: {exc}",
|
|
45
|
+
suggestion="Fix the error in env.py (often an import of your application models).",
|
|
46
|
+
details={"path": str(path), "line": f"{last.filename}:{last.lineno}"},
|
|
47
|
+
) from exc
|
|
48
|
+
return module
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_target_metadata(config: LoadedConfig) -> Any:
|
|
52
|
+
module = load_env_module(config.env_py_path, config.root_dir)
|
|
53
|
+
if not hasattr(module, "target_metadata"):
|
|
54
|
+
raise ConfigError(
|
|
55
|
+
f"{config.env_py_path.name} does not define `target_metadata`.",
|
|
56
|
+
suggestion="Add `target_metadata = ...` to migrations/env.py (None is allowed).",
|
|
57
|
+
)
|
|
58
|
+
return module.target_metadata
|