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 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
@@ -0,0 +1,4 @@
1
+ from mongomig.cli.app import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
mongomig/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0.dev0"
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)
@@ -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
@@ -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