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.
- spaex/__init__.py +0 -0
- spaex/__main__.py +10 -0
- spaex/cli/__init__.py +0 -0
- spaex/cli/add.py +423 -0
- spaex/cli/constitution.py +53 -0
- spaex/cli/diagnostics.py +42 -0
- spaex/cli/install.py +234 -0
- spaex/cli/main.py +159 -0
- spaex/cli/migrate.py +256 -0
- spaex/cli/remove.py +156 -0
- spaex/constitution/__init__.py +0 -0
- spaex/constitution/publish.py +236 -0
- spaex/constitution/resolve.py +171 -0
- spaex/constitution/safety.py +104 -0
- spaex/constitution/show.py +67 -0
- spaex/git/__init__.py +0 -0
- spaex/git/publisher_fetch.py +183 -0
- spaex/git/remote.py +23 -0
- spaex/git/revparse.py +32 -0
- spaex/git/show.py +33 -0
- spaex/install/__init__.py +1 -0
- spaex/install/delta.py +1 -0
- spaex/install/errors.py +14 -0
- spaex/install/generation.py +39 -0
- spaex/install/inflight.py +125 -0
- spaex/install/lock.py +96 -0
- spaex/install/manifest_lock.py +222 -0
- spaex/install/overlay.py +1 -0
- spaex/install/write_and_reinstall.py +79 -0
- spaex/io/__init__.py +0 -0
- spaex/io/atomic.py +109 -0
- spaex/io/json_deterministic.py +45 -0
- spaex/io/state.py +110 -0
- spaex/io/transaction.py +221 -0
- spaex/io/writer_lock.py +295 -0
- spaex/migrate/__init__.py +0 -0
- spaex/migrate/detect.py +34 -0
- spaex/migrate/registry.py +62 -0
- spaex/migrate/sidecar.py +38 -0
- spaex/migrate/transform.py +420 -0
- spaex/migrate/v2_to_v3.py +329 -0
- spaex/migrate/v3_to_v4.py +242 -0
- spaex/migrate/walker.py +138 -0
- spaex/model/__init__.py +0 -0
- spaex/model/_immutable.py +23 -0
- spaex/model/consumer_manifest.py +164 -0
- spaex/model/install_lock.py +191 -0
- spaex/model/molecule_id.py +38 -0
- spaex/model/molecule_manifest.py +75 -0
- spaex/model/publisher_manifest.py +57 -0
- spaex/model/repo_relative_path.py +38 -0
- spaex/model/source_url.py +84 -0
- spaex/model/version_constraint.py +42 -0
- spaex/schema/__init__.py +0 -0
- spaex/schema/data/consumer-manifest.v4.schema.json +106 -0
- spaex/schema/data/install-lock.v4.schema.json +57 -0
- spaex/schema/data/molecule-manifest.v4.schema.json +58 -0
- spaex/schema/data/publisher-manifest.v4.schema.json +59 -0
- spaex/schema/loader.py +25 -0
- spaex/schema/validator.py +140 -0
- spaex/util/__init__.py +0 -0
- spaex/util/errors.py +328 -0
- spaex/util/exit_codes.py +52 -0
- spaex-4.0.0.dist-info/METADATA +111 -0
- spaex-4.0.0.dist-info/RECORD +68 -0
- spaex-4.0.0.dist-info/WHEEL +5 -0
- spaex-4.0.0.dist-info/entry_points.txt +2 -0
- spaex-4.0.0.dist-info/top_level.txt +1 -0
spaex/__init__.py
ADDED
|
File without changes
|
spaex/__main__.py
ADDED
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
|
spaex/cli/diagnostics.py
ADDED
|
@@ -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")
|