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,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