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.
@@ -0,0 +1,23 @@
1
+ """The public API. Imports perform no network or project-local file access."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from ._models import Diagnostic, MacrostatesError, Report
6
+ from ._project import Project
7
+ from ._sources import GitHubSource, SourceProvider
8
+
9
+ __all__ = ["Diagnostic", "GitHubSource", "MacrostatesError", "Project", "Report", "SourceProvider"]
10
+
11
+
12
+ def __getattr__(name: str) -> str:
13
+ if name != "__version__":
14
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
15
+ # Read distribution metadata only when a caller explicitly requests a version.
16
+ try:
17
+ return version("macrostates-cli")
18
+ except PackageNotFoundError:
19
+ return "0+uninstalled"
20
+
21
+
22
+ def __dir__() -> list[str]:
23
+ return sorted([*globals(), "__version__"])
@@ -0,0 +1,4 @@
1
+ from ._cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,56 @@
1
+ """Setuptools version source, evaluated only during a distribution build."""
2
+
3
+ import re
4
+ from collections.abc import Hashable
5
+ from datetime import date
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+
11
+
12
+ class DeclarationLoader(yaml.BaseLoader):
13
+ """Build isolation cannot import the application; keep this reader standalone."""
14
+
15
+ def construct_mapping(self, node: Any, deep: bool = False) -> dict[Hashable, Any]:
16
+ result: dict[Hashable, Any] = {}
17
+ for key_node, value_node in node.value:
18
+ key = self.construct_object(key_node, deep=deep)
19
+ if not isinstance(key, str) or key in result:
20
+ raise ValueError("Invalid or duplicate build declaration key")
21
+ result[key] = self.construct_object(value_node, deep=deep)
22
+ return result
23
+
24
+
25
+ def numeric_version(value: Any) -> tuple[int, int, int]:
26
+ if not isinstance(value, str) or not re.fullmatch(
27
+ r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)", value
28
+ ):
29
+ raise ValueError("Build version must be MAJOR.MINOR.REVISION")
30
+ major, minor, revision = value.split(".")
31
+ return int(major), int(minor), int(revision)
32
+
33
+
34
+ def release_version() -> str:
35
+ declaration = Path(__file__).parents[2] / ".macrostates/implementation/release.yaml"
36
+ if declaration.is_symlink():
37
+ raise ValueError("Build declaration must not be a symbolic link")
38
+ data = yaml.load(declaration.read_text(encoding="utf-8"), Loader=DeclarationLoader)
39
+ if not isinstance(data, dict):
40
+ raise ValueError("Build declaration must be a mapping")
41
+ selected = data.get("version")
42
+ current = numeric_version(selected)
43
+ baseline = data.get("specification")
44
+ if not isinstance(baseline, str) or not baseline.startswith("spec-"):
45
+ raise ValueError("Build declaration needs specification: spec-MAJOR.MINOR.REVISION")
46
+ if current[:2] != numeric_version(baseline.removeprefix("spec-"))[:2]:
47
+ raise ValueError("Build declaration Major.Minor is not aligned")
48
+ stamp = data.get("release_date")
49
+ if not isinstance(stamp, str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", stamp):
50
+ raise ValueError("Build declaration needs release_date: YYYY-MM-DD")
51
+ date.fromisoformat(stamp)
52
+ assert isinstance(selected, str)
53
+ return selected
54
+
55
+
56
+ VERSION = release_version()
macrostates/_cli.py ADDED
@@ -0,0 +1,158 @@
1
+ """Thin argparse interface over the public project API."""
2
+
3
+ import argparse
4
+ import json
5
+ import logging
6
+ import sys
7
+ from collections.abc import Sequence
8
+ from dataclasses import asdict
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from . import Project, Report, __version__
13
+ from ._models import MacrostatesError
14
+
15
+
16
+ def parser() -> argparse.ArgumentParser:
17
+ result = argparse.ArgumentParser(
18
+ prog="macrostates", description="Compose, install and check Macrostates specifications."
19
+ )
20
+ result.add_argument("--version", action="version", version=f"macrostates {__version__}")
21
+ result.add_argument(
22
+ "--project", default=".", help="Project directory (default: current directory)"
23
+ )
24
+ result.add_argument("--json", action="store_true", help="Output structured JSON")
25
+ commands = result.add_subparsers(dest="command", required=True)
26
+ for command, description in (
27
+ ("init", "Prepare project specification files"),
28
+ ("install", "Install selected archive packages"),
29
+ ("lock", "Verify existing copies against source and record integrity"),
30
+ ("verify", "Check package integrity offline"),
31
+ ("lint", "Check specification structure offline"),
32
+ ("info", "Show composition and package dependencies"),
33
+ ("check", "Run lint and integrity verification"),
34
+ ):
35
+ sub = commands.add_parser(command, help=description)
36
+ sub.add_argument("--project", default=argparse.SUPPRESS)
37
+ sub.add_argument("--json", action="store_true", default=argparse.SUPPRESS)
38
+ if command == "init":
39
+ sub.add_argument("--name", help="Project name when selecting packages")
40
+ sub.add_argument(
41
+ "--package",
42
+ action="append",
43
+ default=[],
44
+ metavar="NAME@VERSION",
45
+ help="Explicit package selection, repeatable",
46
+ )
47
+ sub.add_argument(
48
+ "--from",
49
+ dest="manifest",
50
+ type=Path,
51
+ help="Copy a supplied composition, preserving extension fields",
52
+ )
53
+ sub.add_argument("--layout", choices=["modern", "legacy"], default="modern")
54
+ elif command == "install":
55
+ sub.add_argument(
56
+ "--locked", action="store_true", help="Require existing, unchanged lock selections"
57
+ )
58
+ elif command == "check":
59
+ sub.add_argument(
60
+ "--staged", action="store_true", help="Check the Git index instead of working files"
61
+ )
62
+ return result
63
+
64
+
65
+ def display(payload: dict[str, Any], *, json_output: bool) -> None:
66
+ if json_output:
67
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
68
+ return
69
+ if "diagnostics" in payload:
70
+ for diagnostic in payload["diagnostics"]:
71
+ path = f" ({diagnostic['path']})" if diagnostic["path"] else ""
72
+ print(f"{diagnostic['severity']}: [{diagnostic['code']}] {diagnostic['message']}{path}")
73
+ print("Checks passed." if payload["ok"] else "Checks failed.")
74
+ elif "packages" in payload:
75
+ print(
76
+ f"Project: {payload['project']}\nSpecifications: {payload['specs']}\nFormat: {payload['schema_version']}"
77
+ )
78
+ for package in payload["packages"]:
79
+ source = package["source"]
80
+ print(
81
+ f" {package['name']} {package['version']} — {package['path']} ({source.get('type', 'unspecified')})"
82
+ )
83
+ if source.get("repository"):
84
+ print(f" {source['repository']} @ {source['tag']}")
85
+ for dependency in package.get("dependencies", []):
86
+ print(
87
+ f" requires {dependency['name']} {dependency['constraint']} {dependency['version']}"
88
+ )
89
+ print("Reading order: " + " → ".join(payload["reading_order"]))
90
+ print("Authority (highest first): " + " → ".join(payload["authority_order"]))
91
+ for scope in payload.get("directory_specifications", []):
92
+ print(f"Directory scope: {scope['scope']} — {scope['entrypoint']}")
93
+ else:
94
+ print(payload.get("message", "Done."))
95
+
96
+
97
+ def main(argv: Sequence[str] | None = None) -> int:
98
+ arguments = parser().parse_args(argv)
99
+ logging.basicConfig(level=logging.WARNING)
100
+ try:
101
+ if arguments.command == "init":
102
+ packages = []
103
+ for selection in arguments.package:
104
+ parts = selection.rsplit("@", 1)
105
+ if len(parts) != 2:
106
+ raise MacrostatesError("Package selections must be NAME@VERSION")
107
+ packages.append((parts[0], parts[1]))
108
+ project = Project.initialize(
109
+ arguments.project,
110
+ name=arguments.name,
111
+ packages=packages,
112
+ manifest=arguments.manifest,
113
+ layout=arguments.layout,
114
+ )
115
+ display(
116
+ {
117
+ "ok": True,
118
+ "message": f"Initialized {project.specs.relative_to(project.root).as_posix()}; review authority and reading order, then run macrostates install.",
119
+ },
120
+ json_output=arguments.json,
121
+ )
122
+ return 0
123
+ project = Project.open(arguments.project)
124
+ if arguments.command == "info":
125
+ display(project.info(), json_output=arguments.json)
126
+ return 0
127
+ report: Report
128
+ match arguments.command:
129
+ case "install":
130
+ report = project.install(locked=arguments.locked)
131
+ case "lock":
132
+ report = project.lock()
133
+ case "verify":
134
+ report = project.verify()
135
+ case "lint":
136
+ report = project.lint()
137
+ case "check":
138
+ report = project.check(staged=arguments.staged)
139
+ case _:
140
+ raise MacrostatesError("Unknown command")
141
+ display(
142
+ {"ok": report.ok, "diagnostics": [asdict(item) for item in report.diagnostics]},
143
+ json_output=arguments.json,
144
+ )
145
+ return 0 if report.ok else 1
146
+ except (MacrostatesError, OSError, UnicodeError) as exc:
147
+ if arguments.json:
148
+ print(
149
+ json.dumps(
150
+ {"ok": False, "error": {"code": "operation.invalid", "message": str(exc)}}
151
+ )
152
+ )
153
+ else:
154
+ print(f"error: {exc}", file=sys.stderr)
155
+ return 2
156
+ except KeyboardInterrupt:
157
+ print("Operation interrupted.", file=sys.stderr)
158
+ return 130
@@ -0,0 +1,42 @@
1
+ """Explicit format registry. New formats add readers without changing old readers."""
2
+
3
+ from typing import Any, Protocol
4
+
5
+ from .._models import Composition, MacrostatesError, Metadata
6
+ from .v0 import LegacyComposition
7
+ from .v1 import CompositionV1, MetadataV1
8
+
9
+
10
+ class CompositionReader(Protocol):
11
+ def read(self, data: dict[str, Any]) -> Composition: ...
12
+
13
+
14
+ class MetadataReader(Protocol):
15
+ def read(self, data: dict[str, Any]) -> Metadata: ...
16
+
17
+
18
+ COMPOSITION_READERS: dict[int, CompositionReader] = {0: LegacyComposition(), 1: CompositionV1()}
19
+ METADATA_READERS: dict[int, MetadataReader] = {1: MetadataV1()}
20
+
21
+
22
+ def select_version(data: dict[str, Any], default: int) -> int:
23
+ selected = data.get("schema_version", default)
24
+ if type(selected) is not int:
25
+ raise MacrostatesError("schema_version must be an integer")
26
+ return selected
27
+
28
+
29
+ def read_composition(data: dict[str, Any]) -> Composition:
30
+ selected = select_version(data, 0)
31
+ reader = COMPOSITION_READERS.get(selected)
32
+ if reader is None:
33
+ raise MacrostatesError(f"Unsupported composition schema_version: {selected}")
34
+ return reader.read(data)
35
+
36
+
37
+ def read_metadata(data: dict[str, Any]) -> Metadata:
38
+ selected = select_version(data, 1)
39
+ reader = METADATA_READERS.get(selected)
40
+ if reader is None:
41
+ raise MacrostatesError(f"Unsupported package metadata schema_version: {selected}")
42
+ return reader.read(data)
@@ -0,0 +1,102 @@
1
+ """Format-1 field primitives; compatibility changes require new adapter modules."""
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ from .._io import mapping, relative_path, string, version
7
+ from .._models import Composition, Dependency, MacrostatesError, Package
8
+
9
+
10
+ def dependencies(raw: Any, label: str) -> tuple[Dependency, ...]:
11
+ if not isinstance(raw, list):
12
+ raise MacrostatesError(f"{label} must be a list")
13
+ result = []
14
+ for item in raw:
15
+ item = mapping(item, label)
16
+ name = string(item.get("name"), "dependency name")
17
+ selected = string(item.get("version"), "dependency version")
18
+ version(selected)
19
+ constraint = string(item.get("constraint"), "dependency constraint")
20
+ if constraint not in ("exact", "compatible", "at_least"):
21
+ raise MacrostatesError(f"Unsupported dependency constraint: {constraint}")
22
+ if name in {dependency.name for dependency in result}:
23
+ raise MacrostatesError(f"Duplicate dependency: {name}")
24
+ result.append(Dependency(name, selected, constraint))
25
+ return tuple(result)
26
+
27
+
28
+ def composition_fields(data: dict[str, Any], schema: int) -> Composition:
29
+ project = mapping(data.get("project"), "project")
30
+ name = string(project.get("name"), "project.name")
31
+ entrypoint = relative_path(project.get("entrypoint", "main.md"), "project.entrypoint")
32
+ selected = data.get("packages")
33
+ if not isinstance(selected, list) or not selected:
34
+ raise MacrostatesError("packages must be a nonempty list")
35
+ packages: list[Package] = []
36
+ for item in selected:
37
+ item = mapping(item, "package selection")
38
+ package_name = string(item.get("name"), "package name")
39
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", package_name):
40
+ raise MacrostatesError("Package names must be portable identifiers")
41
+ selected_version = string(item.get("version"), "package version")
42
+ version(selected_version)
43
+ path = relative_path(item.get("path"), "package path")
44
+ entry = relative_path(item.get("entrypoint", "README.md"), "package entrypoint")
45
+ if package_name in {package.name for package in packages}:
46
+ raise MacrostatesError(f"Duplicate package name: {package_name}")
47
+ for package in packages:
48
+ left, right = package.path.casefold(), path.casefold()
49
+ if left == right or left.startswith(right + "/") or right.startswith(left + "/"):
50
+ raise MacrostatesError("Package destinations overlap or collide by case")
51
+ source = mapping(item.get("source", {}), "source")
52
+ if any(
53
+ key.casefold()
54
+ in {"token", "password", "credentials", "authorization", "access_token", "github_token"}
55
+ for key in source
56
+ ):
57
+ raise MacrostatesError(
58
+ "Source credentials belong in explicit authentication, not composition metadata"
59
+ )
60
+ if source.get("type") not in (None, "local", "github-archive", "git-subtree"):
61
+ raise MacrostatesError("Unsupported package source type")
62
+ if source.get("type") in ("github-archive", "git-subtree"):
63
+ string(source.get("repository"), "source.repository")
64
+ if source.get("tag") != "v" + selected_version:
65
+ raise MacrostatesError("source.tag must equal v<selected package version>")
66
+ if "commit" in source:
67
+ raise MacrostatesError("Legacy source.commit needs an explicit tag migration")
68
+ if source["type"] == "git-subtree":
69
+ string(source.get("branch"), "source.branch")
70
+ relative_path(source.get("prefix"), "source.prefix")
71
+ legacy = {
72
+ key: item[key] for key in ("dependencies", "optional_dependencies") if key in item
73
+ }
74
+ packages.append(Package(package_name, selected_version, path, entry, source, legacy))
75
+
76
+ def order(key: str) -> tuple[str, ...]:
77
+ values = data.get(key, [])
78
+ if not isinstance(values, list):
79
+ raise MacrostatesError(f"{key} must be a list")
80
+ references = {
81
+ reference: package.name
82
+ for package in packages
83
+ for reference in (package.name, package.path + "/" + package.entrypoint)
84
+ }
85
+ result = []
86
+ for value in values:
87
+ if not isinstance(value, str) or value not in references:
88
+ raise MacrostatesError(f"{key} references an unselected package or entrypoint")
89
+ if references[value] in result:
90
+ raise MacrostatesError(f"{key} contains a duplicate package")
91
+ result.append(references[value])
92
+ return tuple(result)
93
+
94
+ return Composition(
95
+ schema,
96
+ name,
97
+ entrypoint,
98
+ tuple(packages),
99
+ order("authority_order"),
100
+ order("reading_order"),
101
+ data,
102
+ )
@@ -0,0 +1,11 @@
1
+ """Reader for existing unversioned Meta compositions. Never writes a migration."""
2
+
3
+ from typing import Any
4
+
5
+ from .._models import Composition
6
+ from .common import composition_fields
7
+
8
+
9
+ class LegacyComposition:
10
+ def read(self, data: dict[str, Any]) -> Composition:
11
+ return composition_fields(data, 0)
@@ -0,0 +1,44 @@
1
+ """Version 1 archive composition and existing package metadata readers."""
2
+
3
+ import re
4
+ from datetime import datetime
5
+ from typing import Any
6
+
7
+ from .._io import relative_path, string, version
8
+ from .._models import Composition, MacrostatesError, Metadata
9
+ from .common import composition_fields, dependencies
10
+
11
+
12
+ class CompositionV1:
13
+ def read(self, data: dict[str, Any]) -> Composition:
14
+ result = composition_fields(data, 1)
15
+ names = {package.name for package in result.packages}
16
+ for field in (result.authority_order, result.reading_order):
17
+ if set(field) != names:
18
+ raise MacrostatesError(
19
+ "Format 1 requires complete authority_order and reading_order"
20
+ )
21
+ return result
22
+
23
+
24
+ class MetadataV1:
25
+ def read(self, data: dict[str, Any]) -> Metadata:
26
+ name = string(data.get("name"), "package.name")
27
+ selected = string(data.get("version"), "package.version")
28
+ version(selected)
29
+ string(data.get("description"), "package.description")
30
+ stamp = string(data.get("versioned_at"), "package.versioned_at")
31
+ if not re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}", stamp):
32
+ raise MacrostatesError("versioned_at must be YYYY-MM-DD HH:mm")
33
+ try:
34
+ datetime.strptime(stamp, "%Y-%m-%d %H:%M")
35
+ except ValueError as exc:
36
+ raise MacrostatesError("versioned_at must be YYYY-MM-DD HH:mm") from exc
37
+ entry = relative_path(data.get("entrypoint"), "package.entrypoint")
38
+ return Metadata(
39
+ name,
40
+ selected,
41
+ entry,
42
+ dependencies(data.get("dependencies", []), "dependencies"),
43
+ dependencies(data.get("optional_dependencies", []), "optional_dependencies"),
44
+ )
@@ -0,0 +1,144 @@
1
+ """Content inventory and bounded extraction of GitHub source archives."""
2
+
3
+ import hashlib
4
+ import io
5
+ import re
6
+ import tarfile
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from ._io import canonical, mapping, relative_path
11
+ from ._models import MacrostatesError, Package
12
+
13
+ MAX_ARCHIVE = 32 * 1024 * 1024
14
+ MAX_EXTRACTED = 128 * 1024 * 1024
15
+ MAX_FILES = 10000
16
+
17
+
18
+ def extract_archive(data: bytes, target: Path) -> None:
19
+ if len(data) > MAX_ARCHIVE:
20
+ raise MacrostatesError("Compressed archive exceeds 32 MiB")
21
+ try:
22
+ archive = tarfile.open(fileobj=io.BytesIO(data), mode="r:*")
23
+ except tarfile.TarError as exc:
24
+ raise MacrostatesError("Source is not a valid tar archive") from exc
25
+ with archive:
26
+ seen: set[str] = set()
27
+ root: str | None = None
28
+ total = 0
29
+ plan: list[tuple[tarfile.TarInfo, str]] = []
30
+ for member in archive:
31
+ if len(seen) >= MAX_FILES:
32
+ raise MacrostatesError("Archive exceeds 10000 entries")
33
+ name = relative_path(member.name.rstrip("/"), "archive member")
34
+ parts = name.split("/")
35
+ if root is None:
36
+ root = parts[0]
37
+ if parts[0] != root:
38
+ raise MacrostatesError("Archive must have exactly one root directory")
39
+ folded = name.casefold()
40
+ if folded in seen:
41
+ raise MacrostatesError("Archive has duplicate or case-colliding members")
42
+ seen.add(folded)
43
+ if not (member.isfile() or member.isdir()):
44
+ raise MacrostatesError("Archive links and special files are unsupported")
45
+ if len(parts) == 1:
46
+ if not member.isdir():
47
+ raise MacrostatesError("Archive files must live below its root directory")
48
+ continue
49
+ if member.size < 0:
50
+ raise MacrostatesError("Archive has an invalid member size")
51
+ total += member.size
52
+ if total > MAX_EXTRACTED:
53
+ raise MacrostatesError("Extracted archive exceeds 128 MiB")
54
+ plan.append((member, "/".join(parts[1:])))
55
+ if not plan:
56
+ raise MacrostatesError("Archive contains no package files")
57
+ try:
58
+ for member, relative in plan:
59
+ destination = target / relative
60
+ if member.isdir():
61
+ destination.mkdir(parents=True, exist_ok=True)
62
+ else:
63
+ destination.parent.mkdir(parents=True, exist_ok=True)
64
+ stream = archive.extractfile(member)
65
+ if stream is None:
66
+ raise MacrostatesError("Archive member could not be read")
67
+ with stream, destination.open("xb") as output:
68
+ output.write(stream.read(member.size + 1))
69
+ if destination.stat().st_size != member.size:
70
+ raise MacrostatesError("Truncated archive member")
71
+ destination.chmod(0o755 if member.mode & 0o111 else 0o644)
72
+ except (OSError, tarfile.TarError) as exc:
73
+ raise MacrostatesError("Archive has conflicting or invalid file entries") from exc
74
+
75
+
76
+ def inventory(root: Path) -> dict[str, dict[str, Any]]:
77
+ if root.is_symlink() or not root.is_dir():
78
+ raise MacrostatesError("Package directory is missing or is a symbolic link")
79
+ result: dict[str, dict[str, Any]] = {}
80
+ seen: set[str] = set()
81
+ for path in sorted(root.rglob("*")):
82
+ if path.is_symlink():
83
+ raise MacrostatesError("Package contains a symbolic link")
84
+ relative = relative_path(path.relative_to(root).as_posix(), "package file")
85
+ if relative.casefold() in seen:
86
+ raise MacrostatesError("Package contains case-colliding paths")
87
+ seen.add(relative.casefold())
88
+ if path.is_dir():
89
+ continue
90
+ if not path.is_file():
91
+ raise MacrostatesError("Package contains a special file")
92
+ digest = hashlib.sha256()
93
+ with path.open("rb") as stream:
94
+ for chunk in iter(lambda: stream.read(65536), b""):
95
+ digest.update(chunk)
96
+ result[relative] = {
97
+ "sha256": digest.hexdigest(),
98
+ "executable": bool(path.stat().st_mode & 0o111),
99
+ }
100
+ return result
101
+
102
+
103
+ def content_hash(files: dict[str, Any]) -> str:
104
+ return hashlib.sha256(canonical(files)).hexdigest()
105
+
106
+
107
+ def binding(package: Package) -> dict[str, Any]:
108
+ return {
109
+ "name": package.name,
110
+ "version": package.version,
111
+ "path": package.path,
112
+ "entrypoint": package.entrypoint,
113
+ "source": package.source,
114
+ }
115
+
116
+
117
+ def parse_lock(data: dict[str, Any]) -> dict[str, Any]:
118
+ if type(data.get("schema_version")) is not int or data["schema_version"] != 1:
119
+ raise MacrostatesError("Unsupported lock schema_version; supported: 1")
120
+ packages = mapping(data.get("packages"), "lock.packages")
121
+ for name, raw in packages.items():
122
+ record = mapping(raw, "locked package")
123
+ mapping(record.get("selection"), "lock selection")
124
+ if not isinstance(record.get("commit"), str) or not re.fullmatch(
125
+ r"[0-9a-f]{40}|[0-9a-f]{64}", record["commit"]
126
+ ):
127
+ raise MacrostatesError("Lock commit must be a full Git object ID")
128
+ files = mapping(record.get("files"), "lock files")
129
+ seen: set[str] = set()
130
+ for path, value in files.items():
131
+ relative_path(path, "locked file")
132
+ if path.casefold() in seen:
133
+ raise MacrostatesError("Lock has case-colliding file paths")
134
+ seen.add(path.casefold())
135
+ item = mapping(value, "locked file")
136
+ if not isinstance(item.get("sha256"), str) or not re.fullmatch(
137
+ r"[0-9a-f]{64}", item["sha256"]
138
+ ):
139
+ raise MacrostatesError("Invalid locked file checksum")
140
+ if type(item.get("executable")) is not bool:
141
+ raise MacrostatesError("Locked executable flag must be boolean")
142
+ if record.get("content_sha256") != content_hash(files):
143
+ raise MacrostatesError(f"Inconsistent lock checksum for {name}")
144
+ return packages