spaex 4.0.0__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.
Files changed (68) hide show
  1. spaex/__init__.py +0 -0
  2. spaex/__main__.py +10 -0
  3. spaex/cli/__init__.py +0 -0
  4. spaex/cli/add.py +423 -0
  5. spaex/cli/constitution.py +53 -0
  6. spaex/cli/diagnostics.py +42 -0
  7. spaex/cli/install.py +234 -0
  8. spaex/cli/main.py +159 -0
  9. spaex/cli/migrate.py +256 -0
  10. spaex/cli/remove.py +156 -0
  11. spaex/constitution/__init__.py +0 -0
  12. spaex/constitution/publish.py +236 -0
  13. spaex/constitution/resolve.py +171 -0
  14. spaex/constitution/safety.py +104 -0
  15. spaex/constitution/show.py +67 -0
  16. spaex/git/__init__.py +0 -0
  17. spaex/git/publisher_fetch.py +183 -0
  18. spaex/git/remote.py +23 -0
  19. spaex/git/revparse.py +32 -0
  20. spaex/git/show.py +33 -0
  21. spaex/install/__init__.py +1 -0
  22. spaex/install/delta.py +1 -0
  23. spaex/install/errors.py +14 -0
  24. spaex/install/generation.py +39 -0
  25. spaex/install/inflight.py +125 -0
  26. spaex/install/lock.py +96 -0
  27. spaex/install/manifest_lock.py +222 -0
  28. spaex/install/overlay.py +1 -0
  29. spaex/install/write_and_reinstall.py +79 -0
  30. spaex/io/__init__.py +0 -0
  31. spaex/io/atomic.py +109 -0
  32. spaex/io/json_deterministic.py +45 -0
  33. spaex/io/state.py +110 -0
  34. spaex/io/transaction.py +221 -0
  35. spaex/io/writer_lock.py +295 -0
  36. spaex/migrate/__init__.py +0 -0
  37. spaex/migrate/detect.py +34 -0
  38. spaex/migrate/registry.py +62 -0
  39. spaex/migrate/sidecar.py +38 -0
  40. spaex/migrate/transform.py +420 -0
  41. spaex/migrate/v2_to_v3.py +329 -0
  42. spaex/migrate/v3_to_v4.py +242 -0
  43. spaex/migrate/walker.py +138 -0
  44. spaex/model/__init__.py +0 -0
  45. spaex/model/_immutable.py +23 -0
  46. spaex/model/consumer_manifest.py +164 -0
  47. spaex/model/install_lock.py +191 -0
  48. spaex/model/molecule_id.py +38 -0
  49. spaex/model/molecule_manifest.py +75 -0
  50. spaex/model/publisher_manifest.py +57 -0
  51. spaex/model/repo_relative_path.py +38 -0
  52. spaex/model/source_url.py +84 -0
  53. spaex/model/version_constraint.py +42 -0
  54. spaex/schema/__init__.py +0 -0
  55. spaex/schema/data/consumer-manifest.v4.schema.json +106 -0
  56. spaex/schema/data/install-lock.v4.schema.json +57 -0
  57. spaex/schema/data/molecule-manifest.v4.schema.json +58 -0
  58. spaex/schema/data/publisher-manifest.v4.schema.json +59 -0
  59. spaex/schema/loader.py +25 -0
  60. spaex/schema/validator.py +140 -0
  61. spaex/util/__init__.py +0 -0
  62. spaex/util/errors.py +328 -0
  63. spaex/util/exit_codes.py +52 -0
  64. spaex-4.0.0.dist-info/METADATA +111 -0
  65. spaex-4.0.0.dist-info/RECORD +68 -0
  66. spaex-4.0.0.dist-info/WHEEL +5 -0
  67. spaex-4.0.0.dist-info/entry_points.txt +2 -0
  68. spaex-4.0.0.dist-info/top_level.txt +1 -0
spaex/__init__.py ADDED
File without changes
spaex/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """`python -m spaex` entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from spaex.cli.main import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
spaex/cli/__init__.py ADDED
File without changes
spaex/cli/add.py ADDED
@@ -0,0 +1,423 @@
1
+ """`haex add` — adopt one or more molecules from a source repository.
2
+
3
+ Spec 013 T074 / T075. Reads and mutates ``.spaex.json`` under the
4
+ permanent advisory manifest lock, then delegates to ``haex install``
5
+ in-process through ``write_and_reinstall``. See
6
+ ``specs/013-add-cli-and-molecule-rename/contracts/haex-add.cli.md`` for
7
+ the full contract.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from spaex.git import publisher_fetch
17
+ from spaex.git import show as git_show
18
+ from spaex.install.manifest_lock import (
19
+ DEFAULT_LOCK_TIMEOUT_SECONDS,
20
+ MANIFEST_LOCK_NAME,
21
+ MANIFEST_NAME,
22
+ ManifestLockContext,
23
+ parse_lock_timeout,
24
+ )
25
+ from spaex.install.write_and_reinstall import write_and_reinstall
26
+ from spaex.io.state import default_state_root
27
+ from spaex.model.consumer_manifest import ConsumerManifest
28
+ from spaex.model.molecule_manifest import MoleculeManifest
29
+ from spaex.model.publisher_manifest import PublisherManifest
30
+ from spaex.model.source_url import canonicalize
31
+ from spaex.util import exit_codes
32
+ from spaex.util.errors import (
33
+ ConstitutionAlreadyAdoptedError,
34
+ HaexError,
35
+ InteractiveSelectionUnavailableError,
36
+ MoleculeIdNotInSourceError,
37
+ PublisherManifestInvalidError,
38
+ PublisherManifestMissingError,
39
+ UsageError,
40
+ WorkflowMoleculeAlreadyAdoptedError,
41
+ )
42
+
43
+ _WORKFLOW_CATEGORY = "workflow"
44
+ _CONSTITUTION_CATEGORY = "constitution"
45
+
46
+
47
+ def _load_publisher_manifest(repo_dir: Path, sha: str, source: str) -> PublisherManifest:
48
+ """Load the pinned publisher manifest and map failures to typed refusals."""
49
+ # The contract distinguishes:
50
+ # publisher-manifest-missing -> no manifest.json at the resolved SHA
51
+ # publisher-manifest-invalid -> present, but non-JSON, wrong schema, or
52
+ # spaex_version != "4"
53
+ try:
54
+ publisher_bytes = git_show.show_bytes(
55
+ repo_dir,
56
+ sha,
57
+ "manifest.json",
58
+ not_found_error=PublisherManifestMissingError,
59
+ )
60
+ except PublisherManifestMissingError as exc:
61
+ raise PublisherManifestMissingError(
62
+ message=f"publisher manifest missing at {source}@{sha[:12]}",
63
+ context={"source": source, "revision": sha},
64
+ ) from exc
65
+ try:
66
+ return PublisherManifest.from_json(publisher_bytes)
67
+ except (ValueError, KeyError) as exc:
68
+ raise PublisherManifestInvalidError(
69
+ message=f"publisher manifest invalid at {source}@{sha[:12]}: {exc}",
70
+ context={"source": source, "revision": sha},
71
+ ) from exc
72
+
73
+
74
+ def _load_molecule_manifest(
75
+ repo_dir: Path, sha: str, molecule_dir_path: str, molecule_id: str
76
+ ) -> MoleculeManifest:
77
+ molecule_bytes = git_show.show_bytes(
78
+ repo_dir,
79
+ sha,
80
+ f"{molecule_dir_path}/manifest.json",
81
+ not_found_error=PublisherManifestInvalidError,
82
+ )
83
+ try:
84
+ return MoleculeManifest.from_json(molecule_bytes)
85
+ except (ValueError, KeyError) as exc:
86
+ raise PublisherManifestInvalidError(
87
+ message=(
88
+ f"molecule manifest for {molecule_id!r} at "
89
+ f"{molecule_dir_path}/manifest.json is invalid: {exc}"
90
+ ),
91
+ context={"molecule_id": molecule_id, "path": molecule_dir_path},
92
+ ) from exc
93
+
94
+
95
+ def _select_molecule_ids(
96
+ args: argparse.Namespace,
97
+ publisher: PublisherManifest,
98
+ ) -> tuple[str, ...]:
99
+ if args.all and args.molecule_ids:
100
+ raise UsageError(
101
+ message="`--all` is mutually exclusive with positional molecule ids"
102
+ )
103
+ if args.all:
104
+ ids = tuple(sorted(publisher.molecules.keys()))
105
+ if not ids:
106
+ raise UsageError(message="publisher molecule selection was empty")
107
+ return ids
108
+ if args.molecule_ids:
109
+ ids = tuple(mid.strip() for mid in args.molecule_ids.split(",") if mid.strip())
110
+ if not ids:
111
+ raise UsageError(message="molecule id list was empty after parsing")
112
+ return ids
113
+ if not sys.stdin.isatty():
114
+ raise InteractiveSelectionUnavailableError(
115
+ message=(
116
+ "no molecule ids given, no --all, and stdin is not a TTY; "
117
+ "cannot prompt interactively"
118
+ ),
119
+ context={"available_molecules": ",".join(sorted(publisher.molecules))},
120
+ )
121
+ return _prompt_interactive(publisher)
122
+
123
+
124
+ def _prompt_interactive(publisher: PublisherManifest) -> tuple[str, ...]:
125
+ available = sorted(publisher.molecules)
126
+ sys.stdout.write(f"Available molecules at {publisher.publisher}:\n")
127
+ for index, mid in enumerate(available, start=1):
128
+ sys.stdout.write(f" [{index}] {mid}\n")
129
+ sys.stdout.write(
130
+ "Enter comma-separated ids or numeric indexes (e.g. '1,3' or 'com.a.b'): "
131
+ )
132
+ sys.stdout.flush()
133
+ raw = sys.stdin.readline().strip()
134
+ if not raw:
135
+ raise UsageError(message="empty selection")
136
+ selected: list[str] = []
137
+ for token in raw.split(","):
138
+ token = token.strip()
139
+ if not token:
140
+ continue
141
+ if token.isdigit():
142
+ idx = int(token)
143
+ if idx < 1 or idx > len(available):
144
+ raise UsageError(message=f"index {idx} out of range")
145
+ selected.append(available[idx - 1])
146
+ else:
147
+ selected.append(token)
148
+ if not selected:
149
+ raise UsageError(message="molecule selection was empty after parsing")
150
+ return tuple(selected)
151
+
152
+
153
+ def _verify_ids_in_source(molecule_ids: tuple[str, ...], publisher: PublisherManifest) -> None:
154
+ missing = [mid for mid in molecule_ids if mid not in publisher.molecules]
155
+ if missing:
156
+ raise MoleculeIdNotInSourceError(
157
+ message=(
158
+ f"publisher {publisher.publisher!r} does not declare "
159
+ f"molecule(s): {', '.join(missing)}"
160
+ ),
161
+ context={
162
+ "publisher": publisher.publisher,
163
+ "missing": ",".join(missing),
164
+ },
165
+ )
166
+
167
+
168
+ def _categories_declared_by(
169
+ molecule_ids: tuple[str, ...],
170
+ publisher: PublisherManifest,
171
+ repo_dir: Path,
172
+ sha: str,
173
+ ) -> dict[str, tuple[str, ...]]:
174
+ """Return {category: (molecule_ids that declare it)} for the added set."""
175
+ result: dict[str, list[str]] = {}
176
+ for mid in molecule_ids:
177
+ entry = publisher.molecules[mid]
178
+ molecule = _load_molecule_manifest(repo_dir, sha, entry.path, mid)
179
+ for category, paths in molecule.atoms.items():
180
+ if paths:
181
+ result.setdefault(category, []).append(mid)
182
+ return {k: tuple(v) for k, v in result.items()}
183
+
184
+
185
+ def _existing_category_owners(
186
+ manifest: ConsumerManifest,
187
+ state_root: Path,
188
+ category: str,
189
+ ) -> tuple[str, ...]:
190
+ """Return currently-adopted molecule ids that declare ``category``.
191
+
192
+ Reads publisher and molecule manifests from the existing publisher clones.
193
+ A missing clone is treated as "no owner" for this pre-check — install
194
+ itself will surface any clone-availability refusal downstream.
195
+ """
196
+ from spaex.migrate.transform import clone_dir
197
+
198
+ owners: list[str] = []
199
+ for compound in manifest.compounds:
200
+ repo_dir = clone_dir(state_root, compound.source)
201
+ if not repo_dir.is_dir():
202
+ continue
203
+ try:
204
+ publisher_bytes = git_show.show_bytes(
205
+ repo_dir,
206
+ compound.revision,
207
+ "manifest.json",
208
+ not_found_error=PublisherManifestInvalidError,
209
+ )
210
+ publisher = PublisherManifest.from_json(publisher_bytes)
211
+ except (ValueError, KeyError, HaexError):
212
+ continue
213
+ for mid in compound.molecules:
214
+ entry = publisher.molecules.get(mid)
215
+ if entry is None:
216
+ continue
217
+ try:
218
+ molecule_bytes = git_show.show_bytes(
219
+ repo_dir,
220
+ compound.revision,
221
+ f"{entry.path}/manifest.json",
222
+ not_found_error=PublisherManifestInvalidError,
223
+ )
224
+ molecule = MoleculeManifest.from_json(molecule_bytes)
225
+ except (ValueError, KeyError, HaexError):
226
+ continue
227
+ if molecule.atoms.get(category):
228
+ owners.append(mid)
229
+ return tuple(owners)
230
+
231
+
232
+ def _refuse_singleton_conflict(
233
+ added_category_declarers: dict[str, tuple[str, ...]],
234
+ existing_manifest: ConsumerManifest,
235
+ state_root: Path,
236
+ added_set: set[str],
237
+ retracted_set: set[str],
238
+ ) -> None:
239
+ """Refuse pre-write when a singleton-category rule is violated."""
240
+ for category, refuse_exc in (
241
+ (_CONSTITUTION_CATEGORY, ConstitutionAlreadyAdoptedError),
242
+ (_WORKFLOW_CATEGORY, WorkflowMoleculeAlreadyAdoptedError),
243
+ ):
244
+ adding = added_category_declarers.get(category, ())
245
+ if not adding:
246
+ continue
247
+ unique_adding = tuple(sorted(set(adding)))
248
+ if len(unique_adding) > 1:
249
+ raise refuse_exc(
250
+ message=(
251
+ f"add refuses: category {category!r} would be declared by "
252
+ f"multiple molecules: {', '.join(unique_adding)}"
253
+ ),
254
+ context={
255
+ "category": category,
256
+ "adopted_by": "",
257
+ "adding": ",".join(unique_adding),
258
+ },
259
+ )
260
+ current_owners = tuple(
261
+ owner
262
+ for owner in _existing_category_owners(existing_manifest, state_root, category)
263
+ if owner not in added_set and owner not in retracted_set
264
+ )
265
+ if current_owners:
266
+ raise refuse_exc(
267
+ message=(
268
+ f"add refuses: category {category!r} already adopted by "
269
+ f"{', '.join(current_owners)}; would-be-added: {', '.join(adding)}"
270
+ ),
271
+ context={
272
+ "category": category,
273
+ "adopted_by": ",".join(current_owners),
274
+ "adding": ",".join(adding),
275
+ },
276
+ )
277
+
278
+
279
+ def _mutate_compounds(
280
+ manifest: ConsumerManifest,
281
+ source: str,
282
+ revision: str,
283
+ added_ids: tuple[str, ...],
284
+ ) -> ConsumerManifest:
285
+ """Return a new ConsumerManifest with the compound merged/replaced/appended."""
286
+ from spaex.model.consumer_manifest import CompoundEntry
287
+
288
+ new_compounds: list[CompoundEntry] = []
289
+ consumed = False
290
+ for compound in manifest.compounds:
291
+ if compound.source == source:
292
+ if compound.revision == revision:
293
+ merged = tuple(sorted(set(compound.molecules) | set(added_ids)))
294
+ new_compounds.append(
295
+ CompoundEntry(
296
+ source=source,
297
+ revision=revision,
298
+ molecules=merged,
299
+ track=compound.track,
300
+ config=compound.config,
301
+ )
302
+ )
303
+ else:
304
+ new_compounds.append(
305
+ CompoundEntry(
306
+ source=source,
307
+ revision=revision,
308
+ molecules=tuple(sorted(set(added_ids))),
309
+ track=compound.track,
310
+ config={
311
+ molecule_id: config
312
+ for molecule_id, config in compound.config.items()
313
+ if molecule_id in added_ids
314
+ },
315
+ )
316
+ )
317
+ consumed = True
318
+ else:
319
+ new_compounds.append(compound)
320
+ if not consumed:
321
+ new_compounds.append(
322
+ CompoundEntry(
323
+ source=source,
324
+ revision=revision,
325
+ molecules=tuple(sorted(set(added_ids))),
326
+ )
327
+ )
328
+ return ConsumerManifest(
329
+ spaex_version=manifest.spaex_version,
330
+ identity=manifest.identity,
331
+ compounds=tuple(new_compounds),
332
+ spaex_min_version=manifest.spaex_min_version,
333
+ groups=manifest.groups,
334
+ active_feature=manifest.active_feature,
335
+ identity_note=manifest.identity_note,
336
+ )
337
+
338
+
339
+ def add_arguments(parser: argparse.ArgumentParser) -> None:
340
+ """Attach `haex add` arguments to ``parser``."""
341
+ parser.add_argument("source_url", help="Publisher repo URL (https:// or ssh://)")
342
+ parser.add_argument(
343
+ "molecule_ids",
344
+ nargs="?",
345
+ default="",
346
+ help="Comma-separated molecule ids to adopt",
347
+ )
348
+ parser.add_argument("--revision", default=None, help="Full 40-hex SHA to pin")
349
+ parser.add_argument("--all", action="store_true", help="Adopt every molecule at the pinned SHA")
350
+ parser.add_argument(
351
+ "--lock-timeout",
352
+ dest="lock_timeout",
353
+ type=parse_lock_timeout,
354
+ default=DEFAULT_LOCK_TIMEOUT_SECONDS,
355
+ help="Manifest-lock timeout in seconds (default 30; 0 = fail-fast)",
356
+ )
357
+
358
+
359
+ def run(args: argparse.Namespace) -> int:
360
+ """Adopt the requested molecules and reinstall the resulting manifest."""
361
+ repo_root = Path(args.repo_root).resolve()
362
+ state_root = default_state_root()
363
+
364
+ manifest_path = repo_root / MANIFEST_NAME
365
+ if not manifest_path.exists():
366
+ raise HaexError(
367
+ message=(
368
+ f"{manifest_path} is missing; create it before running `spaex add`"
369
+ ),
370
+ context={"path": str(manifest_path)},
371
+ diagnostic_key="spaex-json-missing",
372
+ exit_code=exit_codes.INCOMPLETE_TRANSACTION,
373
+ hint="Create a v4 .spaex.json first.",
374
+ )
375
+
376
+ lock = ManifestLockContext(
377
+ repo_root / MANIFEST_LOCK_NAME,
378
+ timeout_seconds=args.lock_timeout,
379
+ )
380
+ with lock:
381
+ canonical_source = canonicalize(args.source_url)
382
+ sha = publisher_fetch.resolve_sha(canonical_source, args.revision)
383
+ repo_dir = publisher_fetch.ensure_object(canonical_source, sha, state_root)
384
+ publisher = _load_publisher_manifest(repo_dir, sha, canonical_source)
385
+
386
+ molecule_ids = _select_molecule_ids(args, publisher)
387
+ _verify_ids_in_source(molecule_ids, publisher)
388
+
389
+ current_manifest = ConsumerManifest.from_json(manifest_path.read_bytes())
390
+
391
+ added_category_declarers = _categories_declared_by(
392
+ molecule_ids, publisher, repo_dir, sha
393
+ )
394
+ retracted_set = {
395
+ molecule_id
396
+ for compound in current_manifest.compounds
397
+ if compound.source == canonical_source and compound.revision != sha
398
+ for molecule_id in compound.molecules
399
+ }
400
+ _refuse_singleton_conflict(
401
+ added_category_declarers,
402
+ current_manifest,
403
+ state_root,
404
+ added_set=set(molecule_ids),
405
+ retracted_set=retracted_set,
406
+ )
407
+
408
+ new_manifest = _mutate_compounds(
409
+ current_manifest, canonical_source, sha, molecule_ids
410
+ )
411
+ new_bytes = new_manifest.to_json_bytes()
412
+
413
+ exit_code = write_and_reinstall(repo_root, new_bytes, lock)
414
+
415
+ sys.stdout.write(
416
+ f"added {len(molecule_ids)} molecule(s) at {canonical_source}@{sha[:12]}:\n"
417
+ )
418
+ for mid in molecule_ids:
419
+ sys.stdout.write(f" {mid}\n")
420
+ return exit_code
421
+
422
+
423
+ __all__ = ["add_arguments", "run"]
@@ -0,0 +1,53 @@
1
+ """`haex constitution show` handler.
2
+
3
+ `haex constitution assemble` was retired: `haex install` is the single
4
+ entry point that resolves atoms and publishes a new generation. This
5
+ module keeps only the read-only `show` subcommand, which prints the
6
+ byte-for-byte effective constitution from the currently published
7
+ generation.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ from pathlib import Path
14
+
15
+ from spaex.constitution.show import show as render_constitution
16
+ from spaex.io.state import default_state_root
17
+ from spaex.util import exit_codes
18
+ from spaex.util.errors import HaexError
19
+
20
+
21
+ def _state_root() -> Path:
22
+ """Return the spaex state directory path from env or default location."""
23
+ return default_state_root()
24
+
25
+
26
+ def run_show(args: argparse.Namespace) -> int:
27
+ """Execute the `haex constitution show` command.
28
+
29
+ Verifies install.lock before printing the byte-for-byte constitution body.
30
+
31
+ Returns:
32
+ exit_codes.SUCCESS on successful, verified output.
33
+
34
+ Raises:
35
+ HaexError: On a missing/incomplete transaction, missing constitution or
36
+ install.lock, corrupt install.lock, or an integrity mismatch.
37
+ """
38
+ repo_root = Path(args.repo_root).resolve()
39
+ try:
40
+ render_constitution(
41
+ repo_root,
42
+ no_preface=args.no_preface,
43
+ state_root=_state_root(),
44
+ )
45
+ return exit_codes.SUCCESS
46
+ except HaexError:
47
+ raise
48
+ except (OSError, ValueError) as exc:
49
+ raise HaexError(
50
+ message=f"constitution show failed: {exc}",
51
+ diagnostic_key="constitution-show-failed",
52
+ exit_code=exit_codes.INPUT_REFUSE,
53
+ ) from exc
@@ -0,0 +1,42 @@
1
+ """Single stderr formatter for every CLI-level refusal.
2
+
3
+ Every diagnostic line begins with `error: exit=<code> key=<slug>`; additional
4
+ `k=v` pairs are drawn from `HaexError.context` and any `extra` dict passed at
5
+ the call site. Secret payload values are never echoed — the caller must place
6
+ only non-sensitive metadata into `context`/`extra`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sys
13
+ from typing import TextIO
14
+
15
+ from spaex.util.errors import HaexError
16
+
17
+
18
+ def emit_refuse(
19
+ exc: HaexError,
20
+ *,
21
+ extra: dict[str, str] | None = None,
22
+ stream: TextIO | None = None,
23
+ ) -> None:
24
+ stream = stream if stream is not None else sys.stderr
25
+ parts = [f"error: exit={exc.exit_code}", f"key={exc.diagnostic_key}"]
26
+ merged: dict[str, str] = {}
27
+ merged.update(exc.context)
28
+ if extra:
29
+ merged.update(extra)
30
+ for key, value in merged.items():
31
+ text = str(value)
32
+ if text == "" or any(
33
+ ch.isspace() or ch == '"' or ord(ch) < 0x20 or ord(ch) == 0x7F
34
+ for ch in text
35
+ ):
36
+ text = json.dumps(text)
37
+ parts.append(f"{key}={text}")
38
+ stream.write(" ".join(parts) + "\n")
39
+ if exc.message:
40
+ stream.write(f" {exc.message}\n")
41
+ if exc.hint:
42
+ stream.write(f" hint: {exc.hint}\n")