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,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
|