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/_project.py
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
"""Public project API; interface translation lives in the command-line boundary."""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import tempfile
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from dataclasses import asdict
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
|
|
12
|
+
from ._formats import read_composition, read_metadata
|
|
13
|
+
from ._integrity import binding, content_hash, extract_archive, inventory, parse_lock
|
|
14
|
+
from ._io import dump_yaml, read_yaml, safe_path, string, version
|
|
15
|
+
from ._models import Composition, MacrostatesError, Report
|
|
16
|
+
from ._mutations import replace_paths
|
|
17
|
+
from ._scopes import directory_scopes
|
|
18
|
+
from ._sources import GitHubSource, SourceProvider, github_repository
|
|
19
|
+
from ._staged import export_index
|
|
20
|
+
from ._validation import (
|
|
21
|
+
check_links,
|
|
22
|
+
check_meta,
|
|
23
|
+
check_process,
|
|
24
|
+
has_contract_version,
|
|
25
|
+
process_requires_modern_layout,
|
|
26
|
+
validate_packages,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
MANIFESTS = ("composition.yaml", "composition.yml")
|
|
30
|
+
LAYOUTS = {"modern": ".macrostates/specs", "legacy": "specs"}
|
|
31
|
+
LOCK_NAME = "composition.lock.yaml"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Project:
|
|
35
|
+
"""A discovered composition. Read-only methods are offline and do not mutate files.
|
|
36
|
+
|
|
37
|
+
Use open() to discover a project or initialize() to create one. Pass source
|
|
38
|
+
explicitly to inject a transport or configure credentials without globals.
|
|
39
|
+
The object reloads configuration at each operation to detect intervening edits.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self, root: Path, specs: Path, manifest: Path, *, source: SourceProvider | None = None
|
|
44
|
+
) -> None:
|
|
45
|
+
self.root = root
|
|
46
|
+
self.specs = specs
|
|
47
|
+
self.manifest = manifest
|
|
48
|
+
self.implementation = (
|
|
49
|
+
specs.parent / "implementation"
|
|
50
|
+
if specs.parent.name == ".macrostates"
|
|
51
|
+
else root / "implementation"
|
|
52
|
+
)
|
|
53
|
+
self.source: SourceProvider = source if source is not None else GitHubSource()
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def open(cls, path: str | Path = ".", *, source: SourceProvider | None = None) -> "Project":
|
|
57
|
+
start = Path(path).absolute()
|
|
58
|
+
if start.is_file():
|
|
59
|
+
start = start.parent
|
|
60
|
+
for root in (start, *start.parents):
|
|
61
|
+
found: list[Path] = []
|
|
62
|
+
for directory in LAYOUTS.values():
|
|
63
|
+
specs = safe_path(root, directory)
|
|
64
|
+
found.extend(
|
|
65
|
+
safe_path(specs, name) for name in MANIFESTS if (specs / name).exists()
|
|
66
|
+
)
|
|
67
|
+
if len(found) > 1:
|
|
68
|
+
raise MacrostatesError("Ambiguous composition: multiple manifests or layouts exist")
|
|
69
|
+
if found:
|
|
70
|
+
project = cls(root, found[0].parent, found[0], source=source)
|
|
71
|
+
project._composition()
|
|
72
|
+
return project
|
|
73
|
+
if (root / ".git").exists():
|
|
74
|
+
break
|
|
75
|
+
raise MacrostatesError(
|
|
76
|
+
"No composition found; run macrostates init or select the project directory"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def initialize(
|
|
81
|
+
cls,
|
|
82
|
+
root: str | Path,
|
|
83
|
+
*,
|
|
84
|
+
name: str | None = None,
|
|
85
|
+
packages: Sequence[tuple[str, str]] = (),
|
|
86
|
+
manifest: dict[str, Any] | str | Path | None = None,
|
|
87
|
+
layout: Literal["modern", "legacy"] = "modern",
|
|
88
|
+
source: SourceProvider | None = None,
|
|
89
|
+
) -> "Project":
|
|
90
|
+
directory = Path(root).absolute()
|
|
91
|
+
if not directory.is_dir() or directory.is_symlink():
|
|
92
|
+
raise MacrostatesError(
|
|
93
|
+
"Initialization requires an existing directory without symbolic links"
|
|
94
|
+
)
|
|
95
|
+
if layout not in LAYOUTS:
|
|
96
|
+
raise MacrostatesError("Unknown layout; choose modern or legacy")
|
|
97
|
+
for location in LAYOUTS.values():
|
|
98
|
+
candidate = safe_path(directory, location)
|
|
99
|
+
if any((candidate / filename).exists() for filename in MANIFESTS):
|
|
100
|
+
raise MacrostatesError(
|
|
101
|
+
"Project already has a composition; initialization will not overwrite it"
|
|
102
|
+
)
|
|
103
|
+
if manifest is not None:
|
|
104
|
+
if packages or name:
|
|
105
|
+
raise MacrostatesError("Use either a supplied manifest or name/package selections")
|
|
106
|
+
data = (
|
|
107
|
+
copy.deepcopy(manifest) if isinstance(manifest, dict) else read_yaml(Path(manifest))
|
|
108
|
+
)
|
|
109
|
+
else:
|
|
110
|
+
if not packages:
|
|
111
|
+
raise MacrostatesError(
|
|
112
|
+
"Select packages explicitly; no stack or latest versions are assumed"
|
|
113
|
+
)
|
|
114
|
+
selection = []
|
|
115
|
+
conventional = {
|
|
116
|
+
"meta": "000_meta",
|
|
117
|
+
"process": "001_process",
|
|
118
|
+
"repository-1": "010_repository-1",
|
|
119
|
+
"docker-1": "011_docker-1",
|
|
120
|
+
"python-1": "020_python-1",
|
|
121
|
+
"python-project-1": "030_python-project-1",
|
|
122
|
+
"python-library-1": "031_python-library-1",
|
|
123
|
+
"android-app-1": "040_android-app-1",
|
|
124
|
+
}
|
|
125
|
+
for index, (package_name, selected) in enumerate(packages):
|
|
126
|
+
version(selected)
|
|
127
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", package_name):
|
|
128
|
+
raise MacrostatesError("Package names must be portable identifiers")
|
|
129
|
+
if package_name not in conventional:
|
|
130
|
+
raise MacrostatesError(
|
|
131
|
+
"Custom packages require a supplied manifest with a real source URL"
|
|
132
|
+
)
|
|
133
|
+
selection.append(
|
|
134
|
+
{
|
|
135
|
+
"name": package_name,
|
|
136
|
+
"version": selected,
|
|
137
|
+
"path": conventional.get(package_name, f"{100 + index:03d}_{package_name}"),
|
|
138
|
+
"entrypoint": "README.md",
|
|
139
|
+
"source": {
|
|
140
|
+
"type": "github-archive",
|
|
141
|
+
"repository": f"https://github.com/Macrostates/macrostates-{package_name}.git",
|
|
142
|
+
"tag": "v" + selected,
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
order = [item["name"] for item in selection]
|
|
147
|
+
data = {
|
|
148
|
+
"schema_version": 1,
|
|
149
|
+
"project": {"name": string(name, "name"), "entrypoint": "main.md"},
|
|
150
|
+
"packages": selection,
|
|
151
|
+
"authority_order": order,
|
|
152
|
+
"reading_order": order,
|
|
153
|
+
}
|
|
154
|
+
process = next((item for item in selection if item["name"] == "process"), None)
|
|
155
|
+
if process and has_contract_version(process["version"]):
|
|
156
|
+
data["project"]["version"] = "spec-0.1.0"
|
|
157
|
+
composition = read_composition(data)
|
|
158
|
+
specs = safe_path(directory, LAYOUTS[layout])
|
|
159
|
+
project = cls(directory, specs, specs / "composition.yaml", source=source)
|
|
160
|
+
project._validate_paths(composition)
|
|
161
|
+
layout_report = Report()
|
|
162
|
+
check_meta(composition, specs, directory, layout_report)
|
|
163
|
+
if not layout_report.ok:
|
|
164
|
+
raise MacrostatesError("; ".join(item.message for item in layout_report.diagnostics))
|
|
165
|
+
process = next((item for item in composition.packages if item.name == "process"), None)
|
|
166
|
+
if process and process_requires_modern_layout(process.version) and layout != "modern":
|
|
167
|
+
raise MacrostatesError(
|
|
168
|
+
f"Process {process.version} requires the modern .macrostates/ layout"
|
|
169
|
+
)
|
|
170
|
+
package_lines = "\n".join(
|
|
171
|
+
f"- [{package.name} {package.version}]({package.path}/{package.entrypoint})"
|
|
172
|
+
for package in composition.packages
|
|
173
|
+
)
|
|
174
|
+
order_lines = "\n".join(
|
|
175
|
+
f"{index}. {package}" for index, package in enumerate(composition.reading_order, 1)
|
|
176
|
+
)
|
|
177
|
+
authority = (
|
|
178
|
+
", ".join(composition.authority_order) or "Define explicitly before installation."
|
|
179
|
+
)
|
|
180
|
+
overview = f"# {composition.name} specifications\n\nDescribe the project's purpose and requirements here.\n\n## Selected packages\n\n{package_lines}\n\n## Reading order\n\n{order_lines}\n\n## Authority\n\nHighest to lowest: {authority}. Directory-scoped specifications apply only to their enclosing directory; define their authority here before use.\n\nSelection and sources: [composition.yaml](composition.yaml).\n"
|
|
181
|
+
if composition.schema_version == 1:
|
|
182
|
+
meta = next((item for item in composition.packages if item.name == "meta"), None)
|
|
183
|
+
guidance = (
|
|
184
|
+
"These locations and snapshot sources follow the selected Meta 2 layout."
|
|
185
|
+
if meta and version(meta.version)[0] == 2
|
|
186
|
+
else "These explicit project-level location and transport choices take precedence over selected packages' older layout and subtree conventions."
|
|
187
|
+
)
|
|
188
|
+
overview += f"\n## Layout and verification\n\nSpecification files live under `{specs.relative_to(directory).as_posix()}/`; implementation documentation lives under `{project.implementation.relative_to(directory).as_posix()}/`. Packages marked `github-archive` are tracked release snapshots checked against `composition.lock.yaml`. {guidance} Local packages remain editable. Process, when selected, owns composition/implementation version policy. CLI checks supplement specification reading and do not establish implementation conformance.\n"
|
|
189
|
+
agent_target = (specs.relative_to(directory) / composition.entrypoint).as_posix()
|
|
190
|
+
planned = {
|
|
191
|
+
project.manifest: dump_yaml(data),
|
|
192
|
+
safe_path(specs, composition.entrypoint): overview,
|
|
193
|
+
safe_path(
|
|
194
|
+
project.implementation, "main.md"
|
|
195
|
+
): "# Implementation\n\nProject phase: specification\n\nNo accepted implementation baseline exists.\n",
|
|
196
|
+
}
|
|
197
|
+
agents = directory / "AGENTS.md"
|
|
198
|
+
if not agents.exists():
|
|
199
|
+
planned[agents] = (
|
|
200
|
+
f"Read [{agent_target}]({agent_target}) and follow its composition, reading order and authority rules.\n"
|
|
201
|
+
)
|
|
202
|
+
if any(path.exists() or path.is_symlink() for path in planned):
|
|
203
|
+
raise MacrostatesError("Initialization would overwrite existing project files")
|
|
204
|
+
workspace = Path(tempfile.mkdtemp(prefix=".macrostates-init-", dir=directory))
|
|
205
|
+
try:
|
|
206
|
+
replacements = {}
|
|
207
|
+
for index, (destination, contents) in enumerate(planned.items()):
|
|
208
|
+
prepared = workspace / str(index)
|
|
209
|
+
prepared.write_text(contents, encoding="utf-8")
|
|
210
|
+
replacements[destination] = prepared
|
|
211
|
+
replace_paths(replacements, workspace, allow_existing=False)
|
|
212
|
+
except MacrostatesError as exc:
|
|
213
|
+
# Keep explicit recovery files if rollback itself failed.
|
|
214
|
+
if not str(exc).startswith("Rollback failed;"):
|
|
215
|
+
shutil.rmtree(workspace)
|
|
216
|
+
raise
|
|
217
|
+
except OSError as exc:
|
|
218
|
+
shutil.rmtree(workspace)
|
|
219
|
+
raise MacrostatesError("Initialization failed; created files were rolled back") from exc
|
|
220
|
+
else:
|
|
221
|
+
shutil.rmtree(workspace)
|
|
222
|
+
return project
|
|
223
|
+
|
|
224
|
+
def _composition(self) -> Composition:
|
|
225
|
+
result = read_composition(read_yaml(self.manifest))
|
|
226
|
+
self._validate_paths(result)
|
|
227
|
+
return result
|
|
228
|
+
|
|
229
|
+
def _validate_paths(self, composition: Composition) -> None:
|
|
230
|
+
reserved = {composition.entrypoint, *MANIFESTS, LOCK_NAME, ".macrostates-operation"}
|
|
231
|
+
for control in (*MANIFESTS, LOCK_NAME, ".macrostates-operation"):
|
|
232
|
+
if (
|
|
233
|
+
composition.entrypoint == control
|
|
234
|
+
or composition.entrypoint.startswith(control + "/")
|
|
235
|
+
or control.startswith(composition.entrypoint + "/")
|
|
236
|
+
):
|
|
237
|
+
raise MacrostatesError("Project entrypoint overlaps a composition control file")
|
|
238
|
+
for package in composition.packages:
|
|
239
|
+
safe_path(self.specs, package.path)
|
|
240
|
+
safe_path(self.specs, package.path + "/" + package.entrypoint)
|
|
241
|
+
if any(
|
|
242
|
+
package.path == path
|
|
243
|
+
or package.path.startswith(path + "/")
|
|
244
|
+
or path.startswith(package.path + "/")
|
|
245
|
+
for path in reserved
|
|
246
|
+
):
|
|
247
|
+
raise MacrostatesError("Package destination overlaps a composition control file")
|
|
248
|
+
if package.source.get("type") in ("github-archive", "git-subtree"):
|
|
249
|
+
github_repository(package.source["repository"])
|
|
250
|
+
if package.source.get("type") == "git-subtree":
|
|
251
|
+
actual = (self.specs.relative_to(self.root) / package.path).as_posix()
|
|
252
|
+
if package.source["prefix"] != actual:
|
|
253
|
+
raise MacrostatesError(
|
|
254
|
+
"Git subtree prefix does not match its package destination"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def _roots(self, composition: Composition) -> dict[str, Path]:
|
|
258
|
+
return {
|
|
259
|
+
package.name: safe_path(self.specs, package.path) for package in composition.packages
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
def lint(self) -> Report:
|
|
263
|
+
"""Check selected metadata, dependencies and local specification links offline."""
|
|
264
|
+
composition = self._composition()
|
|
265
|
+
report, _ = validate_packages(composition, self._roots(composition))
|
|
266
|
+
entry = safe_path(self.specs, composition.entrypoint)
|
|
267
|
+
if not entry.is_file():
|
|
268
|
+
report.add("entrypoint.project_missing", "Project specification entrypoint is missing")
|
|
269
|
+
for key, selected in (
|
|
270
|
+
("authority_order", composition.authority_order),
|
|
271
|
+
("reading_order", composition.reading_order),
|
|
272
|
+
):
|
|
273
|
+
if not selected:
|
|
274
|
+
report.add(
|
|
275
|
+
"order.manual",
|
|
276
|
+
f"Legacy {key} is not machine-readable; inspect the project entrypoint",
|
|
277
|
+
warning=True,
|
|
278
|
+
)
|
|
279
|
+
check_links(self.specs, self.root, report)
|
|
280
|
+
for _, scoped_specs in directory_scopes(self.root, self.specs):
|
|
281
|
+
check_links(scoped_specs, self.root, report)
|
|
282
|
+
check_meta(composition, self.specs, self.root, report)
|
|
283
|
+
check_process(composition, self.implementation, report)
|
|
284
|
+
return report
|
|
285
|
+
|
|
286
|
+
def info(self) -> dict[str, Any]:
|
|
287
|
+
"""Return composition and available package requirements without network access."""
|
|
288
|
+
composition = self._composition()
|
|
289
|
+
packages = []
|
|
290
|
+
for package in composition.packages:
|
|
291
|
+
item = binding(package)
|
|
292
|
+
metadata = safe_path(self.specs, package.path + "/package.yaml")
|
|
293
|
+
if metadata.is_file():
|
|
294
|
+
parsed = read_metadata(read_yaml(metadata))
|
|
295
|
+
item["dependencies"] = [asdict(dependency) for dependency in parsed.dependencies]
|
|
296
|
+
item["optional_dependencies"] = [
|
|
297
|
+
asdict(dependency) for dependency in parsed.optional_dependencies
|
|
298
|
+
]
|
|
299
|
+
packages.append(item)
|
|
300
|
+
return {
|
|
301
|
+
"project": composition.name,
|
|
302
|
+
"schema_version": composition.schema_version,
|
|
303
|
+
"specs": self.specs.relative_to(self.root).as_posix(),
|
|
304
|
+
"packages": packages,
|
|
305
|
+
"authority_order": list(composition.authority_order),
|
|
306
|
+
"reading_order": list(composition.reading_order),
|
|
307
|
+
"directory_specifications": [
|
|
308
|
+
{
|
|
309
|
+
"scope": scope.relative_to(self.root).as_posix(),
|
|
310
|
+
"entrypoint": (local / "main.md").relative_to(self.root).as_posix(),
|
|
311
|
+
}
|
|
312
|
+
for scope, local in directory_scopes(self.root, self.specs)
|
|
313
|
+
],
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
def _lock_records(self, *, required: bool = True) -> dict[str, Any]:
|
|
317
|
+
path = safe_path(self.specs, LOCK_NAME)
|
|
318
|
+
if not path.exists():
|
|
319
|
+
if required:
|
|
320
|
+
raise MacrostatesError(
|
|
321
|
+
"No integrity lockfile; run install or lock against verified source releases"
|
|
322
|
+
)
|
|
323
|
+
return {}
|
|
324
|
+
return parse_lock(read_yaml(path))
|
|
325
|
+
|
|
326
|
+
def verify(self) -> Report:
|
|
327
|
+
"""Compare installed external packages with their recorded inventories offline."""
|
|
328
|
+
composition = self._composition()
|
|
329
|
+
external = {
|
|
330
|
+
package.name: package
|
|
331
|
+
for package in composition.packages
|
|
332
|
+
if package.source.get("type") != "local"
|
|
333
|
+
}
|
|
334
|
+
report = Report()
|
|
335
|
+
if not external:
|
|
336
|
+
return report
|
|
337
|
+
records = self._lock_records()
|
|
338
|
+
for name in sorted(set(records) - set(external)):
|
|
339
|
+
report.add("lock.unselected", f"Lock contains an unselected package: {name}")
|
|
340
|
+
for name, package in external.items():
|
|
341
|
+
record = records.get(name)
|
|
342
|
+
if not record:
|
|
343
|
+
report.add("lock.missing", f"No locked source for {name}")
|
|
344
|
+
continue
|
|
345
|
+
if record["selection"] != binding(package):
|
|
346
|
+
report.add(
|
|
347
|
+
"lock.stale",
|
|
348
|
+
f"Selection changed for {name}; deliberately install the selected release",
|
|
349
|
+
)
|
|
350
|
+
continue
|
|
351
|
+
try:
|
|
352
|
+
actual = inventory(safe_path(self.specs, package.path))
|
|
353
|
+
except (MacrostatesError, OSError) as exc:
|
|
354
|
+
report.add("integrity.invalid", f"{name}: {exc}", package.path)
|
|
355
|
+
continue
|
|
356
|
+
expected = record["files"]
|
|
357
|
+
for path in sorted(set(actual) | set(expected)):
|
|
358
|
+
status = (
|
|
359
|
+
"added"
|
|
360
|
+
if path not in expected
|
|
361
|
+
else "missing"
|
|
362
|
+
if path not in actual
|
|
363
|
+
else "modified"
|
|
364
|
+
if actual[path] != expected[path]
|
|
365
|
+
else None
|
|
366
|
+
)
|
|
367
|
+
if status:
|
|
368
|
+
report.add(
|
|
369
|
+
"integrity." + status, f"{name}: {status} file", package.path + "/" + path
|
|
370
|
+
)
|
|
371
|
+
return report
|
|
372
|
+
|
|
373
|
+
def check(self, *, staged: bool = False) -> Report:
|
|
374
|
+
"""Combine lint and integrity checks, optionally against the Git index."""
|
|
375
|
+
if staged:
|
|
376
|
+
with tempfile.TemporaryDirectory(prefix="macrostates-index-") as directory:
|
|
377
|
+
root = export_index(self.root, Path(directory))
|
|
378
|
+
return Project.open(root, source=self.source).check()
|
|
379
|
+
report = self.lint()
|
|
380
|
+
try:
|
|
381
|
+
report.diagnostics.extend(self.verify().diagnostics)
|
|
382
|
+
except MacrostatesError as exc:
|
|
383
|
+
report.add("lock.invalid", str(exc))
|
|
384
|
+
return report
|
|
385
|
+
|
|
386
|
+
def install(self, *, locked: bool = False) -> Report:
|
|
387
|
+
"""Install archive releases; refuse modified packages or subtree conversion.
|
|
388
|
+
|
|
389
|
+
Existing selections reuse their locked commit and reject moved tags.
|
|
390
|
+
locked=True also prohibits changed selections and a missing lockfile.
|
|
391
|
+
"""
|
|
392
|
+
return self._synchronize(lock_only=False, locked=locked)
|
|
393
|
+
|
|
394
|
+
def lock(self) -> Report:
|
|
395
|
+
"""Record integrity only after existing copies match canonical release contents."""
|
|
396
|
+
return self._synchronize(lock_only=True, locked=False)
|
|
397
|
+
|
|
398
|
+
def _synchronize(self, *, lock_only: bool, locked: bool) -> Report:
|
|
399
|
+
composition = self._composition()
|
|
400
|
+
records = self._lock_records(required=locked)
|
|
401
|
+
lock_path = safe_path(self.specs, LOCK_NAME)
|
|
402
|
+
original_lock = lock_path.read_bytes() if lock_path.exists() else None
|
|
403
|
+
external = [
|
|
404
|
+
package for package in composition.packages if package.source.get("type") != "local"
|
|
405
|
+
]
|
|
406
|
+
for package in external:
|
|
407
|
+
kind = package.source.get("type")
|
|
408
|
+
if kind not in ("github-archive", "git-subtree"):
|
|
409
|
+
raise MacrostatesError(f"{package.name} has no installable source")
|
|
410
|
+
if kind == "git-subtree" and not lock_only:
|
|
411
|
+
raise MacrostatesError(
|
|
412
|
+
"Archive install cannot convert Git subtrees; use Git subtree maintenance or an explicit migration"
|
|
413
|
+
)
|
|
414
|
+
if locked and records.get(package.name, {}).get("selection") != binding(package):
|
|
415
|
+
raise MacrostatesError("Locked installation requires unchanged package selections")
|
|
416
|
+
if locked and set(records) != {package.name for package in external}:
|
|
417
|
+
raise MacrostatesError("Locked installation requires an exact selected package set")
|
|
418
|
+
operation = safe_path(self.specs, ".macrostates-operation")
|
|
419
|
+
try:
|
|
420
|
+
operation.touch(exist_ok=False)
|
|
421
|
+
except FileExistsError as exc:
|
|
422
|
+
raise MacrostatesError(
|
|
423
|
+
"Another package operation is running; inspect .macrostates-operation before recovery"
|
|
424
|
+
) from exc
|
|
425
|
+
try:
|
|
426
|
+
workspace = Path(
|
|
427
|
+
tempfile.mkdtemp(prefix=".macrostates-install-", dir=self.specs.parent)
|
|
428
|
+
)
|
|
429
|
+
except OSError:
|
|
430
|
+
operation.unlink(missing_ok=True)
|
|
431
|
+
raise
|
|
432
|
+
retain_workspace = False
|
|
433
|
+
try:
|
|
434
|
+
roots = self._roots(composition)
|
|
435
|
+
new_records = {}
|
|
436
|
+
replacements: dict[Path, Path] = {}
|
|
437
|
+
originals: dict[Path, dict[str, Any]] = {}
|
|
438
|
+
for index, package in enumerate(external):
|
|
439
|
+
previous = records.get(package.name)
|
|
440
|
+
selected = self.source.resolve(package.source["repository"], package.source["tag"])
|
|
441
|
+
if not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", selected):
|
|
442
|
+
raise MacrostatesError("Source returned an invalid commit ID")
|
|
443
|
+
if (
|
|
444
|
+
previous
|
|
445
|
+
and previous["selection"] == binding(package)
|
|
446
|
+
and selected != previous["commit"]
|
|
447
|
+
):
|
|
448
|
+
raise MacrostatesError(
|
|
449
|
+
f"Release tag moved for {package.name}; its locked identity is preserved"
|
|
450
|
+
)
|
|
451
|
+
destination = roots[package.name]
|
|
452
|
+
prepared = workspace / f"package-{index}"
|
|
453
|
+
prepared.mkdir()
|
|
454
|
+
extract_archive(
|
|
455
|
+
self.source.download(package.source["repository"], selected), prepared
|
|
456
|
+
)
|
|
457
|
+
files = inventory(prepared)
|
|
458
|
+
if (
|
|
459
|
+
previous
|
|
460
|
+
and previous["selection"] == binding(package)
|
|
461
|
+
and files != previous["files"]
|
|
462
|
+
):
|
|
463
|
+
raise MacrostatesError(
|
|
464
|
+
f"Source contents differ from the lock for {package.name}"
|
|
465
|
+
)
|
|
466
|
+
if destination.exists():
|
|
467
|
+
current = inventory(destination)
|
|
468
|
+
originals[destination] = current
|
|
469
|
+
if current != files:
|
|
470
|
+
if lock_only or previous is None or current != previous["files"]:
|
|
471
|
+
raise MacrostatesError(
|
|
472
|
+
f"Local files differ for {package.name}; preserve or restore them before installing"
|
|
473
|
+
)
|
|
474
|
+
elif lock_only:
|
|
475
|
+
raise MacrostatesError(f"Cannot lock missing package: {package.name}")
|
|
476
|
+
if not lock_only:
|
|
477
|
+
replacements[destination] = prepared
|
|
478
|
+
roots[package.name] = prepared
|
|
479
|
+
new_records[package.name] = {
|
|
480
|
+
"selection": binding(package),
|
|
481
|
+
"commit": selected,
|
|
482
|
+
"content_sha256": content_hash(files),
|
|
483
|
+
"files": files,
|
|
484
|
+
}
|
|
485
|
+
report, _ = validate_packages(composition, roots)
|
|
486
|
+
if not report.ok:
|
|
487
|
+
return report
|
|
488
|
+
lock_data = {"schema_version": 1, "packages": new_records}
|
|
489
|
+
parse_lock(lock_data)
|
|
490
|
+
prepared_lock = workspace / LOCK_NAME
|
|
491
|
+
prepared_lock.write_text(dump_yaml(lock_data), encoding="utf-8")
|
|
492
|
+
replacements[safe_path(self.specs, LOCK_NAME)] = prepared_lock
|
|
493
|
+
# Recheck after network access, before the first destination changes.
|
|
494
|
+
if self._composition().raw != composition.raw:
|
|
495
|
+
raise MacrostatesError(
|
|
496
|
+
"Composition changed during installation; nothing was installed"
|
|
497
|
+
)
|
|
498
|
+
for destination, expected in originals.items():
|
|
499
|
+
if inventory(destination) != expected:
|
|
500
|
+
raise MacrostatesError(
|
|
501
|
+
"Package files changed during installation; nothing was installed"
|
|
502
|
+
)
|
|
503
|
+
for destination in replacements:
|
|
504
|
+
if (
|
|
505
|
+
destination != lock_path
|
|
506
|
+
and destination not in originals
|
|
507
|
+
and destination.exists()
|
|
508
|
+
):
|
|
509
|
+
raise MacrostatesError(
|
|
510
|
+
"A package destination appeared during installation; it was preserved"
|
|
511
|
+
)
|
|
512
|
+
current_lock = lock_path.read_bytes() if lock_path.exists() else None
|
|
513
|
+
if current_lock != original_lock:
|
|
514
|
+
raise MacrostatesError(
|
|
515
|
+
"Lockfile changed during installation; nothing was installed"
|
|
516
|
+
)
|
|
517
|
+
replace_paths(replacements, workspace)
|
|
518
|
+
return report
|
|
519
|
+
except MacrostatesError as exc:
|
|
520
|
+
retain_workspace = str(exc).startswith("Rollback failed;")
|
|
521
|
+
raise
|
|
522
|
+
except OSError as exc:
|
|
523
|
+
raise MacrostatesError("Package operation failed; changes were rolled back") from exc
|
|
524
|
+
finally:
|
|
525
|
+
operation.unlink(missing_ok=True)
|
|
526
|
+
if not retain_workspace:
|
|
527
|
+
shutil.rmtree(workspace)
|
macrostates/_scopes.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Find directory specifications without crossing independent compositions."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from ._io import safe_path
|
|
6
|
+
|
|
7
|
+
IGNORED = {".git", ".venv", "node_modules", "dist", "build", "tmp", "__pycache__"}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def directory_scopes(root: Path, specs: Path) -> list[tuple[Path, Path]]:
|
|
11
|
+
relative = ".macrostates/specs" if specs.parent.name == ".macrostates" else "specs"
|
|
12
|
+
result = []
|
|
13
|
+
for directory, children, _ in root.walk(follow_symlinks=False):
|
|
14
|
+
children[:] = sorted(
|
|
15
|
+
name for name in children if name not in IGNORED and not name.startswith(".")
|
|
16
|
+
)
|
|
17
|
+
if "specs" in children:
|
|
18
|
+
# Specification resources cannot establish implementation scopes.
|
|
19
|
+
children.remove("specs")
|
|
20
|
+
if directory == root:
|
|
21
|
+
continue
|
|
22
|
+
local = safe_path(directory, relative)
|
|
23
|
+
if any(
|
|
24
|
+
(safe_path(directory, location) / name).exists()
|
|
25
|
+
for location in (".macrostates/specs", "specs")
|
|
26
|
+
for name in ("composition.yaml", "composition.yml")
|
|
27
|
+
):
|
|
28
|
+
# This is a separately composed context; its own checks cover internals.
|
|
29
|
+
children.clear()
|
|
30
|
+
continue
|
|
31
|
+
entrypoint = safe_path(local, "main.md")
|
|
32
|
+
if entrypoint.is_file():
|
|
33
|
+
result.append((directory, local))
|
|
34
|
+
return result
|