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/__init__.py +23 -0
- macrostates/__main__.py +4 -0
- macrostates/_build_version.py +56 -0
- macrostates/_cli.py +158 -0
- macrostates/_formats/__init__.py +42 -0
- macrostates/_formats/common.py +102 -0
- macrostates/_formats/v0.py +11 -0
- macrostates/_formats/v1.py +44 -0
- macrostates/_integrity.py +144 -0
- macrostates/_io.py +118 -0
- macrostates/_models.py +71 -0
- macrostates/_mutations.py +53 -0
- macrostates/_project.py +527 -0
- macrostates/_scopes.py +34 -0
- macrostates/_sources.py +153 -0
- macrostates/_staged.py +56 -0
- macrostates/_validation.py +274 -0
- macrostates/py.typed +0 -0
- macrostates_cli-0.3.1.dist-info/METADATA +259 -0
- macrostates_cli-0.3.1.dist-info/RECORD +24 -0
- macrostates_cli-0.3.1.dist-info/WHEEL +5 -0
- macrostates_cli-0.3.1.dist-info/entry_points.txt +2 -0
- macrostates_cli-0.3.1.dist-info/licenses/LICENSE +21 -0
- macrostates_cli-0.3.1.dist-info/top_level.txt +1 -0
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
|