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,178 @@
1
+ """Locate, read, interpolate and validate ``mongomig.yaml`` (+ environment overlays)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from collections.abc import Mapping
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from mongomig.config.models import DEFAULT_CONFIG_FILENAME, LoadedConfig, MongoMigConfig
12
+ from mongomig.errors import ConfigError
13
+
14
+ CONFIG_ENV_VAR = "MONGOMIG_CONFIG"
15
+ ENVIRONMENT_ENV_VAR = "MONGOMIG_ENV"
16
+
17
+ # ${VAR} or ${VAR:-default}
18
+ _VAR_RE = re.compile(r"\$\{(?P<name>[A-Za-z_][A-Za-z0-9_]*)(?::-(?P<default>[^}]*))?\}")
19
+ _ENV_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
20
+
21
+
22
+ def find_config(start: Path | None = None) -> Path | None:
23
+ """Walk up from ``start`` (default: cwd) looking for ``mongomig.yaml``."""
24
+ current = (start or Path.cwd()).resolve()
25
+ for directory in (current, *current.parents):
26
+ candidate = directory / DEFAULT_CONFIG_FILENAME
27
+ if candidate.is_file():
28
+ return candidate
29
+ return None
30
+
31
+
32
+ def load_config(
33
+ config_path: Path | None = None,
34
+ *,
35
+ environment: str | None = None,
36
+ env: Mapping[str, str] | None = None,
37
+ start_dir: Path | None = None,
38
+ ) -> LoadedConfig:
39
+ """Load configuration.
40
+
41
+ Resolution order for the file: explicit ``config_path`` → ``$MONGOMIG_CONFIG`` → search
42
+ upward from ``start_dir``. The environment overlay (``mongomig.<environment>.yaml``) comes
43
+ from ``environment`` or ``$MONGOMIG_ENV`` and is deep-merged over the base file.
44
+ """
45
+ env = os.environ if env is None else env
46
+
47
+ path = _resolve_config_path(config_path, env, start_dir)
48
+ environment = environment or env.get(ENVIRONMENT_ENV_VAR) or None
49
+
50
+ raw = _read_yaml(path)
51
+ warnings = _credential_warnings(raw, path)
52
+
53
+ if environment:
54
+ if not _ENV_NAME_RE.match(environment):
55
+ raise ConfigError(
56
+ f"Invalid environment name {environment!r}.",
57
+ suggestion="Use letters, digits, '-' or '_' (e.g. --env production).",
58
+ )
59
+ overlay_path = path.with_name(f"{path.stem}.{environment}{path.suffix}")
60
+ if not overlay_path.is_file():
61
+ raise ConfigError(
62
+ f"No configuration found for environment {environment!r}.",
63
+ suggestion=f"Create {overlay_path.name} next to {path.name}.",
64
+ details={"expected_path": str(overlay_path)},
65
+ )
66
+ overlay = _read_yaml(overlay_path)
67
+ warnings += _credential_warnings(overlay, overlay_path)
68
+ raw = deep_merge(raw, overlay)
69
+
70
+ missing: set[str] = set()
71
+ interpolated = interpolate(raw, env, missing)
72
+
73
+ from pydantic import ValidationError
74
+
75
+ try:
76
+ settings = MongoMigConfig.model_validate(interpolated)
77
+ except ValidationError as exc:
78
+ problems = "; ".join(
79
+ f"{'.'.join(str(p) for p in err['loc']) or '<root>'}: {err['msg']}"
80
+ for err in exc.errors()
81
+ )
82
+ raise ConfigError(
83
+ f"Invalid configuration in {path.name}: {problems}",
84
+ suggestion="Check the file against the documented mongomig.yaml format.",
85
+ details={"path": str(path)},
86
+ ) from None
87
+
88
+ return LoadedConfig(
89
+ settings=settings,
90
+ config_path=path,
91
+ environment=environment,
92
+ missing_env_vars=frozenset(missing),
93
+ warnings=tuple(warnings),
94
+ )
95
+
96
+
97
+ def interpolate(value: Any, env: Mapping[str, str], missing: set[str]) -> Any:
98
+ """Recursively expand ``${VAR}`` / ``${VAR:-default}`` in string values.
99
+
100
+ Unset variables without a default are left as-is and recorded in ``missing`` so commands
101
+ that don't need them (e.g. ``history``) keep working offline.
102
+ """
103
+ if isinstance(value, str):
104
+
105
+ def _sub(match: re.Match[str]) -> str:
106
+ name, default = match.group("name"), match.group("default")
107
+ if name in env:
108
+ return env[name]
109
+ if default is not None:
110
+ return default
111
+ missing.add(name)
112
+ return match.group(0)
113
+
114
+ return _VAR_RE.sub(_sub, value)
115
+ if isinstance(value, dict):
116
+ return {k: interpolate(v, env, missing) for k, v in value.items()}
117
+ if isinstance(value, list):
118
+ return [interpolate(v, env, missing) for v in value]
119
+ return value
120
+
121
+
122
+ def deep_merge(base: Mapping[str, Any], overlay: Mapping[str, Any]) -> dict[str, Any]:
123
+ """Merge mappings recursively; overlay wins. Lists and scalars are replaced, not merged."""
124
+ merged = dict(base)
125
+ for key, value in overlay.items():
126
+ if isinstance(value, Mapping) and isinstance(merged.get(key), Mapping):
127
+ merged[key] = deep_merge(merged[key], value)
128
+ else:
129
+ merged[key] = value
130
+ return merged
131
+
132
+
133
+ def _resolve_config_path(
134
+ config_path: Path | None, env: Mapping[str, str], start_dir: Path | None
135
+ ) -> Path:
136
+ if config_path is None and env.get(CONFIG_ENV_VAR):
137
+ config_path = Path(env[CONFIG_ENV_VAR])
138
+ if config_path is not None:
139
+ path = config_path.expanduser().resolve()
140
+ if not path.is_file():
141
+ raise ConfigError(f"Config file not found: {path}")
142
+ return path
143
+ found = find_config(start_dir)
144
+ if found is None:
145
+ raise ConfigError(
146
+ f"No {DEFAULT_CONFIG_FILENAME} found in this directory or any parent.",
147
+ suggestion="Run `mongomig init` to create one, or pass --config PATH.",
148
+ )
149
+ return found
150
+
151
+
152
+ def _read_yaml(path: Path) -> dict[str, Any]:
153
+ import yaml
154
+
155
+ try:
156
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
157
+ except yaml.YAMLError as exc:
158
+ raise ConfigError(f"{path.name} is not valid YAML: {exc}") from None
159
+ except OSError as exc:
160
+ raise ConfigError(f"Cannot read {path}: {exc.strerror}") from None
161
+ if data is None:
162
+ return {}
163
+ if not isinstance(data, dict):
164
+ raise ConfigError(f"{path.name} must contain a YAML mapping at the top level.")
165
+ return data
166
+
167
+
168
+ def _credential_warnings(raw: Mapping[str, Any], path: Path) -> list[str]:
169
+ from mongomig.database.redact import uri_has_inline_password
170
+
171
+ database = raw.get("database")
172
+ uri = database.get("uri") if isinstance(database, Mapping) else None
173
+ if isinstance(uri, str) and uri_has_inline_password(uri):
174
+ return [
175
+ f"{path.name} contains a password in database.uri. "
176
+ "Use an environment variable instead, e.g. uri: ${MONGODB_URI}"
177
+ ]
178
+ return []
@@ -0,0 +1,87 @@
1
+ """Typed model of ``mongomig.yaml``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field, PositiveInt, field_validator
8
+
9
+ DEFAULT_CONFIG_FILENAME = "mongomig.yaml"
10
+ DEFAULT_MIGRATIONS_DIR = "migrations"
11
+ VERSIONS_DIRNAME = "versions"
12
+ ENV_FILENAME = "env.py"
13
+ SNAPSHOT_FILENAME = "schema_snapshot.json"
14
+
15
+
16
+ class _Section(BaseModel):
17
+ model_config = ConfigDict(extra="forbid", frozen=True)
18
+
19
+
20
+ class DatabaseConfig(_Section):
21
+ uri: str
22
+ # Optional: falls back to the database named in the URI path.
23
+ name: str | None = None
24
+ server_selection_timeout_ms: PositiveInt = 5000
25
+
26
+ @field_validator("name")
27
+ @classmethod
28
+ def _empty_name_is_none(cls, value: str | None) -> str | None:
29
+ return value or None
30
+
31
+
32
+ class MigrationsConfig(_Section):
33
+ directory: str = DEFAULT_MIGRATIONS_DIR
34
+ tracking_collection: str = "__mongomig_migrations"
35
+ lock_collection: str = "__mongomig_lock"
36
+
37
+
38
+ class ExecutionConfig(_Section):
39
+ batch_size: PositiveInt = 1000
40
+ sleep_ms_between_batches: int = Field(default=0, ge=0)
41
+ max_retries: int = Field(default=3, ge=0)
42
+ lock_ttl_seconds: PositiveInt = 300
43
+
44
+
45
+ class SamplingConfig(_Section):
46
+ size: PositiveInt = 10000
47
+
48
+
49
+ class MongoMigConfig(_Section):
50
+ database: DatabaseConfig
51
+ migrations: MigrationsConfig = MigrationsConfig()
52
+ execution: ExecutionConfig = ExecutionConfig()
53
+ sampling: SamplingConfig = SamplingConfig()
54
+
55
+
56
+ class LoadedConfig(BaseModel):
57
+ """A parsed config plus where it came from. Paths are absolute."""
58
+
59
+ model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
60
+
61
+ settings: MongoMigConfig
62
+ config_path: Path
63
+ environment: str | None = None
64
+ # Environment variables referenced by the config but not set. Commands that need them
65
+ # (anything touching the database) fail with a clear error; offline commands still work.
66
+ missing_env_vars: frozenset[str] = frozenset()
67
+ warnings: tuple[str, ...] = ()
68
+
69
+ @property
70
+ def root_dir(self) -> Path:
71
+ return self.config_path.parent
72
+
73
+ @property
74
+ def migrations_dir(self) -> Path:
75
+ return (self.root_dir / self.settings.migrations.directory).resolve()
76
+
77
+ @property
78
+ def versions_dir(self) -> Path:
79
+ return self.migrations_dir / VERSIONS_DIRNAME
80
+
81
+ @property
82
+ def env_py_path(self) -> Path:
83
+ return self.migrations_dir / ENV_FILENAME
84
+
85
+ @property
86
+ def snapshot_path(self) -> Path:
87
+ return self.migrations_dir / SNAPSHOT_FILENAME
File without changes
@@ -0,0 +1,82 @@
1
+ """The only module that constructs PyMongo clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from mongomig.config.models import LoadedConfig
8
+ from mongomig.database.redact import describe_hosts, redact_text
9
+ from mongomig.errors import ConfigError, DatabaseError
10
+
11
+ if TYPE_CHECKING:
12
+ from pymongo import MongoClient
13
+ from pymongo.database import Database
14
+
15
+
16
+ def create_client(config: LoadedConfig) -> MongoClient[dict[str, Any]]:
17
+ db_cfg = config.settings.database
18
+ unresolved = sorted(v for v in config.missing_env_vars if f"${{{v}" in db_cfg.uri)
19
+ if unresolved:
20
+ raise ConfigError(
21
+ f"Environment variable {unresolved[0]} is not set (needed by database.uri).",
22
+ suggestion=f"export {unresolved[0]}=mongodb://... or add it to your deployment env.",
23
+ )
24
+
25
+ from pymongo import MongoClient
26
+ from pymongo.errors import ConfigurationError, InvalidURI
27
+
28
+ try:
29
+ return MongoClient(
30
+ db_cfg.uri,
31
+ appname="mongomig",
32
+ serverSelectionTimeoutMS=db_cfg.server_selection_timeout_ms,
33
+ tz_aware=True,
34
+ connect=False,
35
+ )
36
+ except (InvalidURI, ConfigurationError) as exc:
37
+ raise ConfigError(
38
+ f"Invalid MongoDB URI: {redact_text(str(exc))}",
39
+ suggestion="Check database.uri (format: mongodb://host:27017/dbname).",
40
+ ) from None
41
+
42
+
43
+ def get_database(
44
+ client: MongoClient[dict[str, Any]], config: LoadedConfig
45
+ ) -> Database[dict[str, Any]]:
46
+ name = config.settings.database.name
47
+ if name and "${" in name:
48
+ raise ConfigError(
49
+ f"database.name references an unset environment variable: {name}",
50
+ )
51
+ if name:
52
+ return client[name]
53
+
54
+ from pymongo.errors import ConfigurationError
55
+
56
+ try:
57
+ return client.get_default_database()
58
+ except ConfigurationError:
59
+ raise ConfigError(
60
+ "No database name configured.",
61
+ suggestion="Set database.name in mongomig.yaml or include it in the URI path.",
62
+ ) from None
63
+
64
+
65
+ def ping(client: MongoClient[dict[str, Any]], config: LoadedConfig) -> None:
66
+ """Fail fast with a readable, credential-free error when MongoDB is unreachable."""
67
+ from pymongo.errors import OperationFailure, PyMongoError
68
+
69
+ hosts = describe_hosts(config.settings.database.uri)
70
+ try:
71
+ client.admin.command("ping")
72
+ except OperationFailure as exc:
73
+ raise DatabaseError(
74
+ f"MongoDB rejected the connection to {hosts}: {redact_text(str(exc.details or exc))}",
75
+ suggestion="Check credentials and that the user has access to this database.",
76
+ ) from None
77
+ except PyMongoError as exc:
78
+ raise DatabaseError(
79
+ f"Cannot connect to MongoDB at {hosts}: {type(exc).__name__}",
80
+ suggestion="Is MongoDB running and reachable? For local dev: `docker compose up -d`.",
81
+ details={"driver_error": redact_text(str(exc))[:500]},
82
+ ) from None
@@ -0,0 +1,70 @@
1
+ """Keep credentials out of anything MongoMig prints or logs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from urllib.parse import parse_qsl, urlencode
7
+
8
+ _SCHEME_RE = re.compile(r"^(?P<scheme>mongodb(?:\+srv)?://)(?P<rest>.*)$", re.IGNORECASE)
9
+ _SENSITIVE_PARAM_RE = re.compile(r"(pass|secret|token|key|credential)", re.IGNORECASE)
10
+ # user:password@ in a URI; used to spot URIs embedded in free text (e.g. driver error messages).
11
+ _INLINE_URI_RE = re.compile(r"mongodb(?:\+srv)?://[^\s'\"]+", re.IGNORECASE)
12
+
13
+ REDACTED = "***"
14
+
15
+
16
+ def redact_uri(uri: str) -> str:
17
+ """Return ``uri`` with password and sensitive query parameters masked.
18
+
19
+ ``mongodb://user:pw@host/db?authSource=admin`` -> ``mongodb://user:***@host/db?authSource=admin``
20
+ """
21
+ match = _SCHEME_RE.match(uri.strip())
22
+ if not match:
23
+ return REDACTED
24
+ scheme, rest = match.group("scheme"), match.group("rest")
25
+
26
+ # Userinfo ends at the last '@' before the first '/' (passwords may contain '@' if unescaped).
27
+ path_start = rest.find("/")
28
+ authority = rest if path_start == -1 else rest[:path_start]
29
+ tail = "" if path_start == -1 else rest[path_start:]
30
+
31
+ if "@" in authority:
32
+ userinfo, hosts = authority.rsplit("@", 1)
33
+ user = userinfo.split(":", 1)[0]
34
+ authority = f"{user}:{REDACTED}@{hosts}" if ":" in userinfo else f"{user}@{hosts}"
35
+
36
+ if "?" in tail:
37
+ path, query = tail.split("?", 1)
38
+ params = [
39
+ (k, REDACTED if _SENSITIVE_PARAM_RE.search(k) else v)
40
+ for k, v in parse_qsl(query, keep_blank_values=True)
41
+ ]
42
+ tail = f"{path}?{urlencode(params, safe='*')}"
43
+
44
+ return f"{scheme}{authority}{tail}"
45
+
46
+
47
+ def redact_text(text: str) -> str:
48
+ """Mask any MongoDB URI that appears inside arbitrary text."""
49
+ return _INLINE_URI_RE.sub(lambda m: redact_uri(m.group(0)), text)
50
+
51
+
52
+ def uri_has_inline_password(uri: str) -> bool:
53
+ match = _SCHEME_RE.match(uri.strip())
54
+ if not match:
55
+ return False
56
+ rest = match.group("rest")
57
+ authority = rest.split("/", 1)[0]
58
+ if "@" not in authority:
59
+ return False
60
+ userinfo = authority.rsplit("@", 1)[0]
61
+ return ":" in userinfo and userinfo.split(":", 1)[1] != ""
62
+
63
+
64
+ def describe_hosts(uri: str) -> str:
65
+ """Hosts portion of a URI, safe for messages like 'cannot connect to host:27017'."""
66
+ match = _SCHEME_RE.match(uri.strip())
67
+ if not match:
68
+ return "<unknown host>"
69
+ authority = match.group("rest").split("/", 1)[0].split("?", 1)[0]
70
+ return authority.rsplit("@", 1)[-1] or "<unknown host>"
mongomig/errors.py ADDED
@@ -0,0 +1,108 @@
1
+ """Exception hierarchy. Every error maps to a documented CLI exit code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import IntEnum
6
+ from typing import Any
7
+
8
+
9
+ class ExitCode(IntEnum):
10
+ SUCCESS = 0
11
+ VALIDATION_FAILURE = 1
12
+ EXECUTION_FAILURE = 2
13
+ CONFIG_ERROR = 3
14
+ CONFLICT = 4
15
+ LOCK_FAILURE = 5
16
+ CHECKSUM_MISMATCH = 6
17
+
18
+
19
+ class MongoMigError(Exception):
20
+ """Base class for all MongoMig errors.
21
+
22
+ ``suggestion`` is a human-readable recovery hint shown by the CLI.
23
+ ``details`` holds structured context (revision, collection, path, ...) for ``--json`` output.
24
+ """
25
+
26
+ exit_code: ExitCode = ExitCode.EXECUTION_FAILURE
27
+
28
+ def __init__(
29
+ self,
30
+ message: str,
31
+ *,
32
+ suggestion: str | None = None,
33
+ details: dict[str, Any] | None = None,
34
+ ) -> None:
35
+ super().__init__(message)
36
+ self.message = message
37
+ self.suggestion = suggestion
38
+ self.details = details or {}
39
+
40
+ def to_dict(self) -> dict[str, Any]:
41
+ return {
42
+ "type": type(self).__name__,
43
+ "message": self.message,
44
+ "suggestion": self.suggestion,
45
+ "exit_code": int(self.exit_code),
46
+ "details": self.details,
47
+ }
48
+
49
+
50
+ # --- exit code 1: validation ---------------------------------------------------------------
51
+
52
+
53
+ class ValidationError(MongoMigError):
54
+ exit_code = ExitCode.VALIDATION_FAILURE
55
+
56
+
57
+ class ScriptError(ValidationError):
58
+ """A revision file is malformed."""
59
+
60
+
61
+ class RevisionNotFoundError(ValidationError):
62
+ pass
63
+
64
+
65
+ class AmbiguousRevisionError(ValidationError):
66
+ pass
67
+
68
+
69
+ # --- exit code 2: execution ----------------------------------------------------------------
70
+
71
+
72
+ class ExecutionError(MongoMigError):
73
+ exit_code = ExitCode.EXECUTION_FAILURE
74
+
75
+
76
+ class DatabaseError(ExecutionError):
77
+ """Connecting to or talking to MongoDB failed."""
78
+
79
+
80
+ # --- exit code 3: configuration ------------------------------------------------------------
81
+
82
+
83
+ class ConfigError(MongoMigError):
84
+ exit_code = ExitCode.CONFIG_ERROR
85
+
86
+
87
+ # --- exit code 4: conflicts in the revision graph ------------------------------------------
88
+
89
+
90
+ class RevisionConflictError(MongoMigError):
91
+ """Duplicate revision ids, cycles, missing parents."""
92
+
93
+ exit_code = ExitCode.CONFLICT
94
+
95
+
96
+ class MultipleHeadsError(RevisionConflictError):
97
+ pass
98
+
99
+
100
+ # --- exit code 5 / 6 -----------------------------------------------------------------------
101
+
102
+
103
+ class LockError(MongoMigError):
104
+ exit_code = ExitCode.LOCK_FAILURE
105
+
106
+
107
+ class ChecksumMismatchError(MongoMigError):
108
+ exit_code = ExitCode.CHECKSUM_MISMATCH
File without changes
@@ -0,0 +1,161 @@
1
+ """The revision DAG built from ``down_revision`` (and ``depends_on``) links."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import heapq
6
+ from collections.abc import Iterable
7
+
8
+ from mongomig.errors import (
9
+ AmbiguousRevisionError,
10
+ MultipleHeadsError,
11
+ RevisionConflictError,
12
+ RevisionNotFoundError,
13
+ )
14
+ from mongomig.migrations.script import Script
15
+
16
+ MIN_PREFIX_LENGTH = 4
17
+
18
+
19
+ class RevisionGraph:
20
+ def __init__(self, scripts: Iterable[Script]) -> None:
21
+ self.scripts: dict[str, Script] = {}
22
+ for script in scripts:
23
+ if script.revision in self.scripts:
24
+ raise RevisionConflictError(f"Duplicate revision id {script.revision!r}.")
25
+ self.scripts[script.revision] = script
26
+
27
+ self.children: dict[str, set[str]] = {rev: set() for rev in self.scripts}
28
+ for script in self.scripts.values():
29
+ for parent in script.down_revisions:
30
+ if parent not in self.scripts:
31
+ raise RevisionConflictError(
32
+ f"Revision {script.revision} ({script.path.name}) points to "
33
+ f"down_revision {parent!r}, which does not exist.",
34
+ suggestion="Restore the missing revision file or fix down_revision.",
35
+ details={"path": str(script.path)},
36
+ )
37
+ self.children[parent].add(script.revision)
38
+ for dep in script.depends_on:
39
+ if self._lookup_label(dep) is None and dep not in self.scripts:
40
+ raise RevisionConflictError(
41
+ f"Revision {script.revision} depends_on {dep!r}, which does not exist.",
42
+ details={"path": str(script.path)},
43
+ )
44
+
45
+ self._order = self._topological_order()
46
+
47
+ def __len__(self) -> int:
48
+ return len(self.scripts)
49
+
50
+ def __contains__(self, rev: object) -> bool:
51
+ return rev in self.scripts
52
+
53
+ # --- structure ------------------------------------------------------------------------
54
+
55
+ def heads(self) -> list[str]:
56
+ """Revisions nothing builds on, in chronological order."""
57
+ return [rev for rev in self._order if not self.children[rev]]
58
+
59
+ def bases(self) -> list[str]:
60
+ return [rev for rev in self._order if self.scripts[rev].is_base]
61
+
62
+ def topological_order(self) -> list[str]:
63
+ """Parents before children; ties broken by file name (i.e. creation time)."""
64
+ return list(self._order)
65
+
66
+ def single_head(self) -> str | None:
67
+ heads = self.heads()
68
+ if len(heads) > 1:
69
+ raise MultipleHeadsError(
70
+ f"Multiple heads: {', '.join(heads)}.",
71
+ suggestion="Pass --head <revision> to choose a parent, or merge the branches.",
72
+ details={"heads": heads},
73
+ )
74
+ return heads[0] if heads else None
75
+
76
+ def ancestors(self, rev: str) -> set[str]:
77
+ """All revisions ``rev`` builds on (excluding itself)."""
78
+ seen: set[str] = set()
79
+ stack = list(self._dependencies(rev))
80
+ while stack:
81
+ current = stack.pop()
82
+ if current not in seen:
83
+ seen.add(current)
84
+ stack.extend(self._dependencies(current))
85
+ return seen
86
+
87
+ # --- lookup ---------------------------------------------------------------------------
88
+
89
+ def resolve(self, ref: str) -> str:
90
+ """Resolve a full id, a unique prefix (>= 4 chars), a branch label, or ``head``."""
91
+ ref = ref.strip()
92
+ if ref == "head":
93
+ head = self.single_head()
94
+ if head is None:
95
+ raise RevisionNotFoundError("There are no revisions yet.")
96
+ return head
97
+ if ref in self.scripts:
98
+ return ref
99
+ labelled = self._lookup_label(ref)
100
+ if labelled is not None:
101
+ return labelled
102
+ if len(ref) >= MIN_PREFIX_LENGTH:
103
+ matches = sorted(rev for rev in self.scripts if rev.startswith(ref))
104
+ if len(matches) == 1:
105
+ return matches[0]
106
+ if len(matches) > 1:
107
+ raise AmbiguousRevisionError(
108
+ f"Revision prefix {ref!r} is ambiguous: {', '.join(matches)}.",
109
+ suggestion="Use more characters of the revision id.",
110
+ )
111
+ hint = (
112
+ f"Prefixes need at least {MIN_PREFIX_LENGTH} characters."
113
+ if len(ref) < MIN_PREFIX_LENGTH
114
+ else "Run `mongomig history` to list revisions."
115
+ )
116
+ raise RevisionNotFoundError(f"Unknown revision {ref!r}.", suggestion=hint)
117
+
118
+ # --- internals ------------------------------------------------------------------------
119
+
120
+ def _dependencies(self, rev: str) -> tuple[str, ...]:
121
+ script = self.scripts[rev]
122
+ deps = [self._lookup_label(d) or d for d in script.depends_on]
123
+ return (*script.down_revisions, *deps)
124
+
125
+ def _lookup_label(self, label: str) -> str | None:
126
+ found = [rev for rev, s in self.scripts.items() if label in s.branch_labels]
127
+ if len(found) > 1:
128
+ raise RevisionConflictError(
129
+ f"Branch label {label!r} is used by several revisions: {', '.join(found)}."
130
+ )
131
+ return found[0] if found else None
132
+
133
+ def _topological_order(self) -> list[str]:
134
+ indegree = {rev: 0 for rev in self.scripts}
135
+ dependents: dict[str, list[str]] = {rev: [] for rev in self.scripts}
136
+ for rev in self.scripts:
137
+ for dep in set(self._dependencies(rev)):
138
+ indegree[rev] += 1
139
+ dependents[dep].append(rev)
140
+
141
+ def key(rev: str) -> tuple[str, str]:
142
+ return (self.scripts[rev].path.name, rev)
143
+
144
+ ready = [key(rev) for rev, n in indegree.items() if n == 0]
145
+ heapq.heapify(ready)
146
+ order: list[str] = []
147
+ while ready:
148
+ _, rev = heapq.heappop(ready)
149
+ order.append(rev)
150
+ for child in dependents[rev]:
151
+ indegree[child] -= 1
152
+ if indegree[child] == 0:
153
+ heapq.heappush(ready, key(child))
154
+
155
+ if len(order) != len(self.scripts):
156
+ cyclic = sorted(rev for rev, n in indegree.items() if n > 0)
157
+ raise RevisionConflictError(
158
+ f"Revision graph contains a cycle involving: {', '.join(cyclic)}.",
159
+ suggestion="Fix down_revision/depends_on so revisions don't reference each other.",
160
+ )
161
+ return order