mongrade 0.1.0a1__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.
mongrade/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ # coding=utf-8
2
+ """Mongrade 的稳定公共 API;导入时不建立连接或加载业务模块。"""
3
+
4
+ from ._version import __version__
5
+ from .catalog import load_catalog
6
+ from .executor import adopt, migrate, preflight, recover
7
+ from .models import Catalog, MigrationError, Plan, Revision
8
+ from .planner import diagnose, plan, verify
9
+ from .state import connect
10
+
11
+ __all__ = [
12
+ "Catalog",
13
+ "MigrationError",
14
+ "Plan",
15
+ "Revision",
16
+ "__version__",
17
+ "adopt",
18
+ "connect",
19
+ "diagnose",
20
+ "load_catalog",
21
+ "migrate",
22
+ "plan",
23
+ "preflight",
24
+ "recover",
25
+ "verify",
26
+ ]
mongrade/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ # coding=utf-8
2
+ """支持 python -m mongrade,行为与安装后的控制台入口一致。"""
3
+
4
+ from .cli import main
5
+
6
+ if __name__ == "__main__":
7
+ raise SystemExit(main())
mongrade/_version.py ADDED
@@ -0,0 +1,4 @@
1
+ # coding=utf-8
2
+ """包版本的唯一声明位置,供运行时和标准构建后端静态读取。"""
3
+
4
+ __version__ = "0.1.0a1"
mongrade/catalog.py ADDED
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/env python3
2
+ # coding=utf-8
3
+ """静态发现单链;任何只读路径均不导入 revision。"""
4
+
5
+ import ast
6
+ import hashlib
7
+ import json
8
+ import re
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Union
11
+
12
+ from .models import Catalog, Revision, digest, require
13
+
14
+
15
+ def file_sha(path: Path) -> str:
16
+ """计算发布制品原始字节的 SHA256。"""
17
+ return hashlib.sha256(path.read_bytes()).hexdigest()
18
+
19
+
20
+ def require_catalog_unchanged(catalog: Catalog) -> None:
21
+ """复核磁盘发布制品;缓存图不能作为启动放行依据。"""
22
+ require(
23
+ load_catalog(catalog.path).sha256 == catalog.sha256, "release_artifact_drift"
24
+ )
25
+
26
+
27
+ def read_json(path: Path) -> Dict[str, Any]:
28
+ """读取 UTF-8 JSON,拒绝重复字段以避免有歧义的发布配置。"""
29
+
30
+ def pairs(items: Any) -> Dict[str, Any]:
31
+ result = {}
32
+ for key, value in items:
33
+ require(key not in result, "duplicate_json_key")
34
+ result[key] = value
35
+ return result
36
+
37
+ return json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=pairs)
38
+
39
+
40
+ def validate_checks(checks: Any, resources: Dict[str, Any]) -> None:
41
+ """验证声明式后置检查,不执行脚本。"""
42
+ require(isinstance(checks, list) and bool(checks), "postchecks_required")
43
+ for check in checks:
44
+ require(
45
+ isinstance(check, dict) and check.get("resource") in resources,
46
+ "invalid_check_resource",
47
+ )
48
+ require(
49
+ set(check)
50
+ <= {"resource", "exists", "filter", "count", "min_count", "index"},
51
+ "unknown_check",
52
+ )
53
+ require(
54
+ any(key in check for key in ("exists", "count", "min_count", "index")),
55
+ "empty_check",
56
+ )
57
+ if "index" in check:
58
+ expected = check["index"]
59
+ require(
60
+ isinstance(expected, dict)
61
+ and {"name", "key"} <= set(expected)
62
+ and set(expected) <= {"name", "key", "unique"},
63
+ "unsupported_index_postcheck",
64
+ )
65
+ require(
66
+ isinstance(expected["name"], str)
67
+ and isinstance(expected["key"], list)
68
+ and bool(expected["key"]),
69
+ "invalid_index_postcheck",
70
+ )
71
+
72
+
73
+ def load_catalog(path: Union[str, Path]) -> Catalog:
74
+ """静态加载业务项目及单链版本图,异常时拒绝后续执行。"""
75
+ path = Path(path).resolve()
76
+ project = read_json(path)
77
+ require(
78
+ project.get("format") == 1 and project.get("engine") == "v1.0.0",
79
+ "incompatible_project_format",
80
+ )
81
+ require(
82
+ re.fullmatch(r"[A-Za-z0-9_-]{1,80}", project.get("domain", "")),
83
+ "invalid_domain",
84
+ )
85
+ for field in ("database", "auth_source", "control_database", "ledger"):
86
+ require(
87
+ isinstance(project.get(field), str)
88
+ and re.fullmatch(r"[A-Za-z0-9_-]{1,100}", project[field]),
89
+ "invalid_" + field,
90
+ )
91
+ require(project["ledger"] != "_commonlib_migration_coordination", "ledger_conflict")
92
+ coordination = project.get(
93
+ "coordination_collection", "_commonlib_migration_coordination"
94
+ )
95
+ require(
96
+ isinstance(coordination, str)
97
+ and re.fullmatch(r"[A-Za-z0-9_-]{1,100}", coordination)
98
+ and coordination != project["ledger"],
99
+ "invalid_coordination_collection",
100
+ )
101
+ legacy = project.get("legacy")
102
+ if legacy:
103
+ require(
104
+ legacy.get("database") == project["database"], "legacy_database_conflict"
105
+ )
106
+ require(
107
+ legacy.get("collection")
108
+ not in (
109
+ project["ledger"],
110
+ coordination,
111
+ "_commonlib_migration_coordination",
112
+ ),
113
+ "legacy_ledger_conflict",
114
+ )
115
+ resources = project.get("resources", {})
116
+ require(isinstance(resources, dict) and bool(resources), "resources_required")
117
+ physical = set()
118
+ for alias, resource in resources.items():
119
+ require(re.fullmatch(r"[A-Za-z0-9_-]{1,80}", alias), "invalid_alias")
120
+ require(
121
+ set(resource) <= {"database", "collection", "index", "owner"}
122
+ and resource.get("owner") == project["domain"],
123
+ "resource_owner_conflict",
124
+ )
125
+ require(
126
+ resource.get("database") == project["database"],
127
+ "multi_database_resources_not_supported_v1",
128
+ )
129
+ name = resource.get("collection", "")
130
+ require(
131
+ re.fullmatch(r"[A-Za-z0-9_-]{1,100}", name)
132
+ and name not in (project["ledger"], coordination),
133
+ "invalid_resource_collection",
134
+ )
135
+ require(
136
+ not legacy or name != legacy["collection"], "resource_metadata_conflict"
137
+ )
138
+ key = (resource["database"], name, resource.get("index"))
139
+ require(key not in physical, "duplicate_resource")
140
+ physical.add(key)
141
+ revisions = {}
142
+ versions = path.parent / "versions"
143
+ require(versions.is_dir(), "versions_missing")
144
+ artifacts = sorted(list(versions.glob("*.py")) + list(versions.glob("*.pyc")))
145
+ for script in artifacts:
146
+ if script.name in ("__init__.py", "__init__.pyc"):
147
+ continue
148
+ require(not script.is_symlink(), "revision_symlink_rejected")
149
+ artifact_sha256 = file_sha(script)
150
+ source_sha256 = artifact_sha256
151
+ if script.suffix == ".py":
152
+ tree = ast.parse(script.read_text(encoding="utf-8-sig"))
153
+ assignments = [
154
+ node
155
+ for node in tree.body
156
+ if isinstance(node, ast.Assign)
157
+ and any(
158
+ isinstance(target, ast.Name) and target.id == "MIGRATION"
159
+ for target in node.targets
160
+ )
161
+ ]
162
+ require(len(assignments) == 1, "literal_metadata_required")
163
+ metadata = ast.literal_eval(assignments[0].value)
164
+ else:
165
+ metadata = read_json(script.with_suffix(".json"))
166
+ require(
167
+ metadata.pop("artifact_sha256", None) == file_sha(script),
168
+ "pyc_artifact_digest_mismatch",
169
+ )
170
+ source_sha256 = metadata.pop("source_sha256", None)
171
+ require(
172
+ isinstance(source_sha256, str)
173
+ and re.fullmatch(r"[0-9a-f]{64}", source_sha256),
174
+ "pyc_source_digest_required",
175
+ )
176
+ require(
177
+ isinstance(metadata, dict)
178
+ and set(metadata)
179
+ == {"revision", "parent", "resources", "risk", "checks", "resume"},
180
+ "invalid_revision_metadata",
181
+ )
182
+ revision = metadata["revision"]
183
+ require(
184
+ isinstance(revision, str)
185
+ and re.fullmatch(r"[A-Za-z0-9_-]{1,80}", revision),
186
+ "invalid_revision",
187
+ )
188
+ require(revision not in revisions, "duplicate_revision_or_source_and_pyc")
189
+ require(metadata["risk"] in ("additive", "destructive"), "unsupported_risk")
190
+ require(isinstance(metadata["resume"], bool), "invalid_resume_contract")
191
+ require(
192
+ isinstance(metadata["resources"], list)
193
+ and bool(metadata["resources"])
194
+ and set(metadata["resources"]) <= set(resources),
195
+ "invalid_revision_resources",
196
+ )
197
+ validate_checks(metadata["checks"], resources)
198
+ revisions[revision] = Revision(
199
+ revision,
200
+ metadata["parent"],
201
+ script,
202
+ source_sha256,
203
+ artifact_sha256,
204
+ metadata,
205
+ )
206
+ require(bool(revisions), "empty_graph")
207
+ ordered: List[Revision] = []
208
+ parent = None
209
+ while len(ordered) < len(revisions):
210
+ children = [r for r in revisions.values() if r.parent == parent]
211
+ require(len(children) == 1, "broken_cycle_or_branch")
212
+ child = children[0]
213
+ require(child not in ordered, "cycle")
214
+ ordered.append(child)
215
+ parent = child.revision
216
+ return Catalog(
217
+ path,
218
+ project,
219
+ tuple(ordered),
220
+ digest(
221
+ {
222
+ "project": project,
223
+ "revisions": [
224
+ (r.metadata, r.sha256, r.artifact_sha256) for r in ordered
225
+ ],
226
+ }
227
+ ),
228
+ )
mongrade/cli.py ADDED
@@ -0,0 +1,189 @@
1
+ # coding=utf-8
2
+ """独立命令行入口;CommonLib 可以显式注入项目目录和已经判定的角色。"""
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import re
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any, Optional, Sequence, Union
11
+
12
+ from ._version import __version__
13
+ from .catalog import load_catalog, read_json
14
+ from .executor import adopt, migrate, recover
15
+ from .models import Catalog, MigrationError, digest, require
16
+ from .planner import diagnose, plan, verify
17
+ from .state import classify_error, connect
18
+
19
+
20
+ def public_result(value: Any) -> Any:
21
+ """将 checkpoint、旧记录和证据正文转换为摘要,供控制台安全输出。"""
22
+ if isinstance(value, dict):
23
+ return {
24
+ key: digest(item)
25
+ if key in {"checkpoint", "rows", "evidence", "recovery_confirmation"}
26
+ else public_result(item)
27
+ for key, item in value.items()
28
+ }
29
+ if isinstance(value, (tuple, list)):
30
+ return [public_result(item) for item in value]
31
+ return value
32
+
33
+
34
+ def _create_revision(catalog: Catalog, revision: Optional[str]) -> None:
35
+ require(
36
+ revision is not None and re.fullmatch(r"[A-Za-z0-9_-]{1,80}", revision),
37
+ "revision_id_required",
38
+ )
39
+ require(
40
+ revision not in [item.revision for item in catalog.revisions],
41
+ "duplicate_revision",
42
+ )
43
+ resource = next(iter(catalog.project["resources"]))
44
+ metadata = {
45
+ "revision": revision,
46
+ "parent": catalog.revisions[-1].revision,
47
+ "resources": [resource],
48
+ "risk": "additive",
49
+ "resume": False,
50
+ "checks": [{"resource": resource, "exists": True}],
51
+ }
52
+ source = (
53
+ '# coding=utf-8\n"""服务自有迁移;发布前补齐风险和数据后检。"""\n'
54
+ "MIGRATION = " + repr(metadata) + "\n\ndef upgrade(context):\n"
55
+ ' raise NotImplementedError("请实现迁移和后置检查")\n'
56
+ )
57
+ with (catalog.path.parent / "versions" / (str(revision) + ".py")).open(
58
+ "x", encoding="utf-8"
59
+ ) as output:
60
+ output.write(source)
61
+
62
+
63
+ def main(
64
+ argv: Optional[Sequence[str]] = None,
65
+ *,
66
+ project_root: Optional[Union[str, Path]] = None,
67
+ execution_role: Optional[str] = None,
68
+ ) -> int:
69
+ """执行独立 CLI,成功返回0,失败返回非0。
70
+
71
+ Args:
72
+ argv: 参数列表;默认读取进程参数。
73
+ project_root: 业务项目目录,默认为当前工作目录,而非 site-packages。
74
+ execution_role: 适配器已经判定的角色;提供后禁止命令行覆盖。
75
+
76
+ Returns:
77
+ 进程退出码。连接和脚本错误仅输出脱敏的错误类别。
78
+
79
+ """
80
+ parser = argparse.ArgumentParser(description="Mongrade:MongoDB 版本化迁移工具")
81
+ parser.add_argument("--version", action="version", version=__version__)
82
+ parser.add_argument(
83
+ "command",
84
+ choices=(
85
+ "diagnose",
86
+ "verify",
87
+ "plan",
88
+ "migrate",
89
+ "graph",
90
+ "show",
91
+ "adopt",
92
+ "recover",
93
+ "create",
94
+ "tui",
95
+ ),
96
+ )
97
+ parser.add_argument("--project", default="mongo_migrations/project.json")
98
+ parser.add_argument(
99
+ "--role",
100
+ choices=("author", "deploy"),
101
+ default=execution_role or os.environ.get("MIGRATION_EXECUTION_ROLE", "deploy"),
102
+ )
103
+ parser.add_argument("--uri-env", default="MONGRADE_URI")
104
+ parser.add_argument("--target", default="head")
105
+ parser.add_argument("--evidence")
106
+ parser.add_argument("--confirmation")
107
+ parser.add_argument("--confirm-plan-sha256")
108
+ parser.add_argument("--revision-id")
109
+ args = parser.parse_args(argv)
110
+ try:
111
+ role = args.role
112
+ require(role in ("author", "deploy"), "invalid_execution_role")
113
+ require(
114
+ execution_role is None or role == execution_role,
115
+ "execution_role_override_rejected",
116
+ )
117
+ root = (
118
+ Path(project_root).resolve()
119
+ if project_root is not None
120
+ else Path.cwd().resolve()
121
+ )
122
+ path = (root / args.project).resolve()
123
+ require(
124
+ role == "author"
125
+ or path == (root / "mongo_migrations/project.json").resolve(),
126
+ "deploy_requires_service_project",
127
+ )
128
+ catalog = load_catalog(path)
129
+ if args.command == "create":
130
+ require(role == "author", "deploy_cannot_create_revision")
131
+ _create_revision(catalog, args.revision_id)
132
+ print("[OK] revision_created_requires_release_review")
133
+ return 0
134
+ if args.command == "graph":
135
+ print(
136
+ json.dumps(
137
+ [revision.metadata for revision in catalog.revisions],
138
+ ensure_ascii=True,
139
+ )
140
+ )
141
+ return 0
142
+ uri = os.environ.get(args.uri_env)
143
+ if not uri:
144
+ raise MigrationError("mongo_uri_environment_required")
145
+ evidence = read_json(root / args.evidence) if args.evidence else {}
146
+ with connect(uri, catalog) as client:
147
+ command = args.command
148
+ if command == "tui":
149
+ print(
150
+ json.dumps(
151
+ public_result(diagnose(client, catalog)),
152
+ default=str,
153
+ ensure_ascii=True,
154
+ )
155
+ )
156
+ print("[1] diagnose [2] verify [3] plan [0] exit")
157
+ command = {"1": "diagnose", "2": "verify", "3": "plan"}.get(
158
+ input("> ").strip()
159
+ )
160
+ if command is None:
161
+ return 0
162
+ if command in ("diagnose", "show"):
163
+ result = diagnose(client, catalog)
164
+ elif command == "verify":
165
+ result = verify(client, catalog)
166
+ elif command == "recover":
167
+ require(args.confirmation is not None, "recovery_confirmation_required")
168
+ result = recover(
169
+ client, catalog, evidence, read_json(root / args.confirmation), role
170
+ )
171
+ else:
172
+ approved = plan(client, catalog, args.target, evidence)
173
+ if command == "plan":
174
+ result = {"plan_sha256": approved.sha256, "facts": approved.facts}
175
+ else:
176
+ require(
177
+ args.confirm_plan_sha256 == approved.sha256,
178
+ "plan_confirmation_required",
179
+ )
180
+ operation = adopt if command == "adopt" else migrate
181
+ result = operation(client, catalog, approved, evidence, role)
182
+ print(json.dumps(public_result(result), default=str, ensure_ascii=True))
183
+ return 0
184
+ except (Exception, KeyboardInterrupt) as error:
185
+ reason = (
186
+ str(error) if isinstance(error, MigrationError) else classify_error(error)
187
+ )
188
+ print("[ERROR] " + reason, file=sys.stderr)
189
+ return 1
mongrade/context.py ADDED
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env python3
2
+ # coding=utf-8
3
+ """受信 revision 的显式资源及合作式停写上下文;不是沙箱。"""
4
+
5
+ from typing import Any
6
+
7
+ from .models import Revision, require
8
+
9
+ READS = {"find_one", "count_documents", "index_information"}
10
+ ADDITIONS = {"insert_one", "insert_many", "create_index"}
11
+ DESTRUCTIVE = {
12
+ "update_one",
13
+ "update_many",
14
+ "replace_one",
15
+ "delete_one",
16
+ "delete_many",
17
+ "drop_index",
18
+ "drop",
19
+ }
20
+
21
+
22
+ class Resource:
23
+ """按声明资源执行操作并复核准入;不是安全沙箱。"""
24
+
25
+ def __init__(self, context: Any, resource: Any) -> None:
26
+ """绑定资源声明和当前执行上下文。"""
27
+ self._context, self._resource = context, resource
28
+
29
+ def __getattr__(self, method: str) -> Any:
30
+ """只暴露受审操作,并在每次调用前后验证执行权。"""
31
+ require(
32
+ method in READS | ADDITIONS | DESTRUCTIVE, "unsupported_resource_operation"
33
+ )
34
+
35
+ def invoke(*args: Any, **kwargs: Any) -> Any:
36
+ context, resource = self._context, self._resource
37
+ context.guard()
38
+ require(
39
+ method not in DESTRUCTIVE or context.destructive,
40
+ "destructive_operation_requires_review",
41
+ )
42
+ if method == "create_index":
43
+ require(
44
+ set(kwargs) <= {"name", "unique"}, "unsupported_index_options_v1"
45
+ )
46
+ if resource.get("index"):
47
+ require(
48
+ method in READS or method in ("create_index", "drop_index"),
49
+ "index_owner_cannot_write_collection_data",
50
+ )
51
+ if method == "create_index":
52
+ require(
53
+ kwargs.get("name") == resource["index"], "index_scope_conflict"
54
+ )
55
+ if method == "drop_index":
56
+ require(
57
+ args and args[0] == resource["index"], "index_scope_conflict"
58
+ )
59
+ result = getattr(
60
+ context._state.client[resource["database"]][resource["collection"]],
61
+ method,
62
+ )(*args, **kwargs)
63
+ context.guard()
64
+ return result
65
+
66
+ return invoke
67
+
68
+
69
+ class Context:
70
+ """迁移脚本的显式资源和 checkpoint 上下文。"""
71
+
72
+ def __init__(self, state: Any, revision: Revision, guard: Any) -> None:
73
+ """绑定运行状态、当前版本及本次准入复核函数。"""
74
+ self._state, self._revision, self._guard = state, revision, guard
75
+ self.destructive = revision.metadata["risk"] == "destructive"
76
+
77
+ def guard(self) -> None:
78
+ """复核锁及切换前提,失锁时停止后续写入。"""
79
+ self._state.guard()
80
+ self._guard()
81
+
82
+ def resource(self, alias: str) -> Resource:
83
+ """取得当前 revision 已声明的资源别名。"""
84
+ require(alias in self._revision.metadata["resources"], "undeclared_resource")
85
+ self.guard()
86
+ return Resource(self, self._state.project["resources"][alias])
87
+
88
+ @property
89
+ def checkpoint(self) -> Any:
90
+ """读取或保存当前运行版本的受保护进度。"""
91
+ self.guard()
92
+ return self._state.read()["running"]["checkpoint"]
93
+
94
+ def save_checkpoint(self, value: Any) -> None:
95
+ """锁和维护证据仍有效时保存批次进度。"""
96
+ self.guard()
97
+ self._state.checkpoint(value)