macrostates-cli 0.3.1__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.
macrostates/_io.py ADDED
@@ -0,0 +1,118 @@
1
+ """Strict YAML and filesystem boundaries shared by the format adapters."""
2
+
3
+ import json
4
+ import re
5
+ from collections.abc import Hashable
6
+ from pathlib import Path, PurePosixPath
7
+ from typing import Any
8
+
9
+ import yaml
10
+
11
+ from ._models import MacrostatesError
12
+
13
+ MAX_DOCUMENT = 2 * 1024 * 1024
14
+
15
+
16
+ class StrictLoader(yaml.SafeLoader):
17
+ def compose_node(self, parent: Any, index: Any) -> Any:
18
+ if self.check_event(yaml.AliasEvent):
19
+ raise MacrostatesError("YAML aliases are unsupported; use explicit values")
20
+ return super().compose_node(parent, index)
21
+
22
+ def construct_mapping(self, node: Any, deep: bool = False) -> dict[Hashable, Any]:
23
+ mapping: dict[Hashable, Any] = {}
24
+ for key_node, value_node in node.value:
25
+ key = self.construct_object(key_node, deep=deep)
26
+ if not isinstance(key, str):
27
+ raise MacrostatesError("YAML mapping keys must be strings")
28
+ if key in mapping:
29
+ raise MacrostatesError(f"Duplicate YAML key: {key}")
30
+ mapping[key] = self.construct_object(value_node, deep=deep)
31
+ return mapping
32
+
33
+
34
+ # Dates stay strings. Do not alter PyYAML's global resolver state.
35
+ StrictLoader.yaml_implicit_resolvers = {
36
+ key: [(tag, pattern) for tag, pattern in values if tag != "tag:yaml.org,2002:timestamp"]
37
+ for key, values in yaml.SafeLoader.yaml_implicit_resolvers.items()
38
+ }
39
+
40
+
41
+ def parse_yaml(data: bytes | str) -> dict[str, Any]:
42
+ if len(data) > MAX_DOCUMENT:
43
+ raise MacrostatesError("YAML document exceeds the 2 MiB limit")
44
+ try:
45
+ result = yaml.load(data, Loader=StrictLoader)
46
+ except (yaml.YAMLError, UnicodeError, RecursionError) as exc:
47
+ raise MacrostatesError("Invalid YAML document") from exc
48
+ return mapping(result, "document")
49
+
50
+
51
+ def read_yaml(path: Path) -> dict[str, Any]:
52
+ if path.is_symlink():
53
+ raise MacrostatesError("YAML documents must not be symbolic links")
54
+ with path.open("rb") as stream:
55
+ return parse_yaml(stream.read(MAX_DOCUMENT + 1))
56
+
57
+
58
+ def dump_yaml(data: dict[str, Any]) -> str:
59
+ return yaml.dump(data, Dumper=ExplicitDumper, sort_keys=False, allow_unicode=True)
60
+
61
+
62
+ class ExplicitDumper(yaml.SafeDumper):
63
+ def ignore_aliases(self, data: Any) -> bool:
64
+ return True
65
+
66
+
67
+ def mapping(value: Any, label: str) -> dict[str, Any]:
68
+ if not isinstance(value, dict):
69
+ raise MacrostatesError(f"{label} must be a mapping")
70
+ return value
71
+
72
+
73
+ def string(value: Any, label: str) -> str:
74
+ if not isinstance(value, str) or not value.strip():
75
+ raise MacrostatesError(f"{label} must be a nonempty string")
76
+ if any(ord(char) < 32 for char in value):
77
+ raise MacrostatesError(f"{label} contains control characters")
78
+ return value
79
+
80
+
81
+ def relative_path(value: Any, label: str) -> str:
82
+ text = string(value, label).rstrip("/")
83
+ parts = text.split("/")
84
+ if (
85
+ not text
86
+ or PurePosixPath(text).is_absolute()
87
+ or any(part in ("", ".", "..", ".git") for part in parts)
88
+ or "\\" in text
89
+ or ":" in text
90
+ ):
91
+ raise MacrostatesError(f"{label} must be a safe relative POSIX path")
92
+ return text
93
+
94
+
95
+ def safe_path(root: Path, relative: str) -> Path:
96
+ normalized = relative_path(relative, "path")
97
+ current = root
98
+ if root.is_symlink():
99
+ raise MacrostatesError("Project directories must not be symbolic links")
100
+ for part in normalized.split("/"):
101
+ current = current / part
102
+ if current.is_symlink():
103
+ raise MacrostatesError("Symbolic links are not allowed in managed paths")
104
+ if not current.resolve().is_relative_to(root.resolve()):
105
+ raise MacrostatesError("Path escapes the project directory")
106
+ return current
107
+
108
+
109
+ def version(value: Any) -> tuple[int, int, int]:
110
+ text = string(value, "version")
111
+ if not re.fullmatch(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)", text):
112
+ raise MacrostatesError("Versions must be MAJOR.MINOR.PATCH without suffixes")
113
+ major, minor, patch = text.split(".")
114
+ return int(major), int(minor), int(patch)
115
+
116
+
117
+ def canonical(data: Any) -> bytes:
118
+ return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
macrostates/_models.py ADDED
@@ -0,0 +1,71 @@
1
+ """Normalized domain models, independent of any persisted format."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ class MacrostatesError(Exception):
8
+ """Invalid configuration or an operation that cannot safely be performed."""
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Diagnostic:
13
+ """A stable machine-readable code and a human-readable check result."""
14
+
15
+ code: str
16
+ message: str
17
+ path: str | None = None
18
+ severity: str = "error"
19
+
20
+
21
+ @dataclass
22
+ class Report:
23
+ """Check results. Warnings do not make a report unsuccessful."""
24
+
25
+ diagnostics: list[Diagnostic] = field(default_factory=list)
26
+
27
+ @property
28
+ def ok(self) -> bool:
29
+ return not any(item.severity == "error" for item in self.diagnostics)
30
+
31
+ def add(
32
+ self, code: str, message: str, path: str | None = None, *, warning: bool = False
33
+ ) -> None:
34
+ self.diagnostics.append(Diagnostic(code, message, path, "warning" if warning else "error"))
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Dependency:
39
+ name: str
40
+ version: str
41
+ constraint: str
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Package:
46
+ name: str
47
+ version: str
48
+ path: str
49
+ entrypoint: str
50
+ source: dict[str, Any]
51
+ legacy_dependencies: dict[str, Any] = field(default_factory=dict)
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Metadata:
56
+ name: str
57
+ version: str
58
+ entrypoint: str
59
+ dependencies: tuple[Dependency, ...]
60
+ optional_dependencies: tuple[Dependency, ...]
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class Composition:
65
+ schema_version: int
66
+ name: str
67
+ entrypoint: str
68
+ packages: tuple[Package, ...]
69
+ authority_order: tuple[str, ...]
70
+ reading_order: tuple[str, ...]
71
+ raw: dict[str, Any]
@@ -0,0 +1,53 @@
1
+ """File transactions. Ordinary failures roll back; interrupted recovery is explicit."""
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+
6
+ from ._models import MacrostatesError
7
+
8
+
9
+ def replace_paths(
10
+ replacements: dict[Path, Path], workspace: Path, *, allow_existing: bool = True
11
+ ) -> None:
12
+ """Move prepared files/directories into place, retaining recoverable backups."""
13
+ backups = workspace / "backups"
14
+ backups.mkdir()
15
+ applied: list[tuple[Path, Path, bool]] = []
16
+ created_parents: list[Path] = []
17
+ try:
18
+ for number, (destination, prepared) in enumerate(replacements.items()):
19
+ if not allow_existing and (destination.exists() or destination.is_symlink()):
20
+ raise MacrostatesError(
21
+ "A destination appeared during initialization; it was preserved"
22
+ )
23
+ pending: list[Path] = []
24
+ parent = destination.parent
25
+ while not parent.exists():
26
+ pending.append(parent)
27
+ parent = parent.parent
28
+ for parent in reversed(pending):
29
+ parent.mkdir()
30
+ created_parents.append(parent)
31
+ backup = backups / str(number)
32
+ existed = destination.exists()
33
+ if existed:
34
+ destination.rename(backup)
35
+ applied.append((destination, backup, existed))
36
+ prepared.rename(destination)
37
+ except BaseException:
38
+ try:
39
+ for destination, backup, existed in reversed(applied):
40
+ if destination.exists():
41
+ if destination.is_dir():
42
+ shutil.rmtree(destination)
43
+ else:
44
+ destination.unlink()
45
+ if existed:
46
+ backup.rename(destination)
47
+ for parent in reversed(created_parents):
48
+ parent.rmdir()
49
+ except OSError as recovery_error:
50
+ raise MacrostatesError(
51
+ f"Rollback failed; recovery files retained in {workspace}"
52
+ ) from recovery_error
53
+ raise