PICurv 0.1.0
A Parallel Particle-In-Cell Solver for Curvilinear LES
 
Loading...
Searching...
No Matches
Functions
picurv_cli.storage.operations Namespace Reference

Functions

list restore_cold_study_members (str study_path, case_ids, str profile_name=None, str storage_config=None)
 Restore named cold study members in place from their local markers.
 
dict build_storage_plan (dict target, dict profile, str compression=None, str policy=None, keep_latest_checkpoint=None, int workers=None, retain=None, drop=None)
 Build the read-only plan consumed by protect and offload.
 
None _render_plan (dict plan)
 Print a concise archive/offload plan.
 
dict archive_artifact (dict target, dict profile, str label=None, tags=None, str compression=None, bool prune_local=False, str policy=None, keep_latest_checkpoint=None, int workers=None, str notes=None, retain=None, drop=None)
 Package, upload, verify, register, and optionally prune one artifact.
 
dict _register_existing_archive (dict target, dict profile, dict manifest, dict inventory, dict plan, *bool prune_local, str label=None, tags=None, str notes=None)
 Point an artifact at an already-uploaded archive of its current content.
 
None _prune_archived_payload (str root, dict inventory, dict policy)
 Remove only verified heavy payload while retaining control-plane files.
 
None _download_archive_components (dict profile, dict manifest, set components, str destination, int workers=None)
 Download and safely merge selected semantic archive components.
 
dict restore_missing_workspace_assets (str workspace_root, provider_spec_hashes)
 Recover immutable workspace assets from archived run-local input copies.
 
dict restore_archive (dict profile, str archive_id, str destination=None, checkpoints=None, bool force=False, int workers=None, components=None)
 Download, verify, extract, and materialize an archive or selected checkpoints.
 
list _expand_checkpoint_selection (explicit, ranges)
 Combine repeated --checkpoint values with START:END[:STRIDE] ranges.
 
str _resolve_archive_id_from_args (args)
 Resolve an explicit archive ID or a local artifact marker.
 
None _render_status (dict inventory)
 Render one artifact status row and safety details.
 
dict report_storage_configuration (dict profile)
 State which configuration answered, and warn when it came from outside.
 
dict load_reported_storage_profile (args)
 Load the storage profile a command will act on, and report its origin.
 
None storage_setup_workflow (args)
 Create or update a non-secret workspace storage profile.
 
None storage_status_workflow (args)
 Print local storage and lifecycle status for selected artifacts.
 
None storage_plan_workflow (args)
 Render a read-only packaging and safety plan.
 
None storage_archive_workflow (args, bool prune_local)
 Execute protect or offload for one or more explicit local targets.
 
None storage_restore_workflow (args)
 Restore a remote archive by globally unique ID or local marker.
 
None storage_prune_workflow (args)
 Remove local asset objects nothing local needs and storage has verified.
 
None storage_verify_workflow (args)
 Verify all remote chunks for one archive.
 
None storage_list_workflow (args)
 Search the remote manifest catalog without local artifact state.
 
None storage_show_workflow (args)
 Show one complete remote archive manifest.
 
None storage_workflow (args)
 Dispatch nested storage actions using existing PICurv workflow conventions.
 
argparse.ArgumentParser add_storage_parser (subparsers)
 Attach the nested storage command parser to PICurv's top-level parser.
 

Function Documentation

◆ restore_cold_study_members()

list picurv_cli.storage.operations.restore_cold_study_members ( str  study_path,
  case_ids,
str   profile_name = None,
str   storage_config = None 
)

Restore named cold study members in place from their local markers.

Parameters
[in]study_pathStudy directory owning the members.
[in]case_idsMember ids to restore.
[in]profile_nameOptional storage profile name.
[in]storage_configOptional explicit storage configuration path.
Returns
Restored member ids.
Exceptions
StorageErrorwhen a member carries no usable archive reference.

Definition at line 109 of file operations.py.

110 storage_config: str = None) -> list:
111 """!
112 @brief Restore named cold study members in place from their local markers.
113 @param[in] study_path Study directory owning the members.
114 @param[in] case_ids Member ids to restore.
115 @param[in] profile_name Optional storage profile name.
116 @param[in] storage_config Optional explicit storage configuration path.
117 @return Restored member ids.
118 @throws StorageError when a member carries no usable archive reference.
119 """
120 # This runs inside a staging workflow rather than a storage command, so the
121 # configuration answering it has had no chance to be named yet.
122 profile = report_storage_configuration(load_storage_profile(profile_name, storage_config))
123 restored = []
124 for case_id in case_ids:
125 member = os.path.join(study_path, "cases", case_id)
126 state = read_storage_state(member) or {}
127 archive_id = state.get("archive_id")
128 if not archive_id:
129 raise StorageError(
130 f"{case_id} is cold but its marker names no archive; restore it by id."
131 )
132 restore_archive(profile, archive_id, destination=member, force=True)
133 restored.append(case_id)
134 return restored
135
136
Here is the call graph for this function:

◆ build_storage_plan()

dict picurv_cli.storage.operations.build_storage_plan ( dict  target,
dict  profile,
str   compression = None,
str   policy = None,
  keep_latest_checkpoint = None,
int   workers = None,
  retain = None,
  drop = None 
)

Build the read-only plan consumed by protect and offload.

Parameters
[in]targetValue supplied through the target argument.
[in]profileValue supplied through the profile argument.
[in]compressionValue supplied through the compression argument.
[in]policyOptional semantic retention policy override.
[in]keep_latest_checkpointOptional newest-checkpoint retention override.
[in]workersOptional compression worker count override.
[in]retainComponents to retain locally regardless of the policy preset.
[in]dropComponents to prune locally regardless of the policy preset.
Returns
Result produced by this operation.

Definition at line 137 of file operations.py.

139 workers: int = None, retain=None, drop=None) -> dict:
140 """!
141 @brief Build the read-only plan consumed by protect and offload.
142 @param[in] target Value supplied through the `target` argument.
143 @param[in] profile Value supplied through the `profile` argument.
144 @param[in] compression Value supplied through the `compression` argument.
145 @param[in] policy Optional semantic retention policy override.
146 @param[in] keep_latest_checkpoint Optional newest-checkpoint retention override.
147 @param[in] workers Optional compression worker count override.
148 @param[in] retain Components to retain locally regardless of the policy preset.
149 @param[in] drop Components to prune locally regardless of the policy preset.
150 @return Result produced by this operation.
151 """
152 inventory = inspect_artifact(target)
153 selected_compression = _select_compression(compression, inventory["total_bytes"], profile)
154 specs = _build_chunk_specs(inventory, profile["chunk_size_bytes"])
155 retention = _resolve_offload_policy(profile, policy, keep_latest_checkpoint, retain, drop)
156 latest_step = max(inventory["checkpoint_steps"], default=None)
157 retained_bytes = sum(
158 entry["size"] for entry in inventory["entries"]
159 if entry["type"] != "directory" and _entry_retained_by_policy(entry, retention, latest_step)
160 )
161 worker_count = int(workers or profile.get("workers", DEFAULT_STORAGE_WORKERS))
162 if worker_count <= 0:
163 raise StorageError("Storage workers must be positive.")
164 compressed_range = _compression_size_range(inventory["total_bytes"], selected_compression)
165 return {
166 "inventory": inventory,
167 "compression": selected_compression,
168 "chunk_count": len(specs),
169 "workers": worker_count,
170 "offload_policy": retention,
171 "retained_local_bytes": retained_bytes,
172 "pruned_local_bytes": max(0, inventory["total_bytes"] - retained_bytes),
173 "estimated_stored_bytes": {"low": compressed_range[0], "high": compressed_range[1]},
174 "chunks": [
175 {
176 "component": spec["component"],
177 "file_count": len(spec["entries"]),
178 "uncompressed_bytes": spec["uncompressed_bytes"],
179 }
180 for spec in specs
181 ],
182 }
183
184
Here is the caller graph for this function:

◆ _render_plan()

None picurv_cli.storage.operations._render_plan ( dict  plan)
protected

Print a concise archive/offload plan.

Parameters
[in]planValue supplied through the plan argument.

Definition at line 185 of file operations.py.

185def _render_plan(plan: dict) -> None:
186 """!
187 @brief Print a concise archive/offload plan.
188 @param[in] plan Value supplied through the `plan` argument.
189 """
190 inventory = plan["inventory"]
191 target = inventory["target"]
192 print(f"[INFO] Artifact type : {target['artifact_type']}")
193 print(f"[INFO] Artifact path : {target['root_path']}")
194 print(f"[INFO] Local size : {_human_bytes(inventory['total_bytes'])}")
195 print(f"[INFO] Files : {inventory['file_count']}")
196 print(f"[INFO] Checkpoints : {len(inventory['checkpoint_steps'])}")
197 print(f"[INFO] Compression : {plan['compression']}")
198 print(f"[INFO] CPU workers : {plan['workers']}")
199 print(f"[INFO] Archive chunks: {plan['chunk_count']}")
200 estimate = plan["estimated_stored_bytes"]
201 print(
202 f"[INFO] Remote estimate: {_human_bytes(estimate['low'])}–"
203 f"{_human_bytes(estimate['high'])} (content-dependent)"
204 )
205 policy = plan["offload_policy"]
206 print(f"[INFO] Offload policy: {policy['name']}")
207 # The preset name alone no longer says what is kept once --retain/--drop adjust it,
208 # so the resolved component set is reported rather than left to be inferred.
209 print(f"[INFO] Kept components: {', '.join(policy['retained_components'])}")
210 if policy.get("explicit_retain"):
211 print(f"[INFO] Retained by flag: {', '.join(policy['explicit_retain'])}")
212 if policy.get("explicit_drop"):
213 print(f"[INFO] Dropped by flag: {', '.join(policy['explicit_drop'])}")
214 print(f"[INFO] Retained local: {_human_bytes(plan['retained_local_bytes'])}")
215 print(f"[INFO] Pruned local : {_human_bytes(plan['pruned_local_bytes'])}")
216 if "checkpoints" in policy["retained_components"]:
217 print(f"[INFO] Kept checkpoint: every committed step ({len(inventory['checkpoint_steps'])})")
218 elif policy["keep_latest_checkpoint"]:
219 latest = max(inventory["checkpoint_steps"], default=None)
220 print(f"[INFO] Kept checkpoint: {latest if latest is not None else 'none available'}")
221 if inventory["external_paths"]:
222 print("[WARNING] External configured paths are recorded but are not followed automatically:")
223 for item in inventory["external_paths"]:
224 print(f" - {item['source']}: {item['path']}")
225 if inventory["dependencies"]:
226 print("[WARNING] External run dependencies:")
227 for item in inventory["dependencies"]:
228 print(f" - {item['kind']}: {item['path']}")
229 unclassified = [
230 entry for entry in inventory["entries"]
231 if entry["type"] != "directory" and entry["component"] == UNCLASSIFIED_COMPONENT
232 ]
233 if unclassified:
234 print(
235 f"[WARNING] {len(unclassified)} file(s) are not part of any known component. "
236 "They are archived, and retained locally by every policy, because storage "
237 "does not delete files whose purpose it cannot state:"
238 )
239 for entry in unclassified[:10]:
240 print(f" - {entry['path']}")
241 if len(unclassified) > 10:
242 print(f" - ... and {len(unclassified) - 10} more")
243
244

◆ archive_artifact()

dict picurv_cli.storage.operations.archive_artifact ( dict  target,
dict  profile,
str   label = None,
  tags = None,
str   compression = None,
bool   prune_local = False,
str   policy = None,
  keep_latest_checkpoint = None,
int   workers = None,
str   notes = None,
  retain = None,
  drop = None 
)

Package, upload, verify, register, and optionally prune one artifact.

Parameters
[in]targetValue supplied through the target argument.
[in]profileValue supplied through the profile argument.
[in]labelValue supplied through the label argument.
[in]tagsValue supplied through the tags argument.
[in]compressionValue supplied through the compression argument.
[in]prune_localValue supplied through the prune_local argument.
[in]policyOptional semantic retention policy override.
[in]keep_latest_checkpointOptional newest-checkpoint retention override.
[in]workersOptional compression worker count override.
[in]notesOptional free-text note recorded with the archive.
[in]retainComponents to retain locally regardless of the policy preset.
[in]dropComponents to prune locally regardless of the policy preset.
Returns
Result produced by this operation.

Definition at line 245 of file operations.py.

248 workers: int = None, notes: str = None, retain=None, drop=None) -> dict:
249 """!
250 @brief Package, upload, verify, register, and optionally prune one artifact.
251 @param[in] target Value supplied through the `target` argument.
252 @param[in] profile Value supplied through the `profile` argument.
253 @param[in] label Value supplied through the `label` argument.
254 @param[in] tags Value supplied through the `tags` argument.
255 @param[in] compression Value supplied through the `compression` argument.
256 @param[in] prune_local Value supplied through the `prune_local` argument.
257 @param[in] policy Optional semantic retention policy override.
258 @param[in] keep_latest_checkpoint Optional newest-checkpoint retention override.
259 @param[in] workers Optional compression worker count override.
260 @param[in] notes Optional free-text note recorded with the archive.
261 @param[in] retain Components to retain locally regardless of the policy preset.
262 @param[in] drop Components to prune locally regardless of the policy preset.
263 @return Result produced by this operation.
264 """
265 with storage_operation_lock(target["root_path"], "offload" if prune_local else "protect"):
266 plan = build_storage_plan(
267 target, profile, compression, policy=policy,
268 keep_latest_checkpoint=keep_latest_checkpoint, workers=workers,
269 retain=retain, drop=drop,
270 )
271 inventory = plan["inventory"]
272 _assert_archive_safe(inventory)
273 fingerprint = _inventory_fingerprint(inventory)
274 # An explicitly requested compression must be honoured; without one, any
275 # current archive of the artifact will do (protect, then a plain offload).
276 reusable = _find_reusable_archive(
277 profile, target, fingerprint,
278 compression=plan["compression"] if compression else None,
279 )
280 if reusable is not None:
281 # `protect` then `offload` is the documented workflow for "back it up now,
282 # free the space later". Re-packaging and re-uploading an unchanged artifact
283 # would store a second full copy of it, which for a campaign-sized run is
284 # the most expensive thing this command can do for no benefit.
285 print(
286 f"[INFO] Reusing verified archive {reusable['archive_id']}: "
287 f"{target['root_path']} is unchanged since it was archived."
288 )
289 return _register_existing_archive(
290 target, profile, reusable, inventory, plan, prune_local=prune_local,
291 label=label, tags=tags, notes=notes,
292 )
293 archive_id = uuid.uuid4().hex
294 specs = _build_chunk_specs(inventory, profile["chunk_size_bytes"])
295 staging_parent = profile.get("staging_directory")
296 if staging_parent:
297 staging_parent = os.path.abspath(os.path.expanduser(str(staging_parent)))
298 os.makedirs(staging_parent, exist_ok=True)
299 # As many chunks as `plan["workers"]` can be staged (packaged, not yet
300 # uploaded) at once; each is deleted right after its verified upload, so
301 # staging never needs archive-sized space, only room for the largest
302 # concurrent handful of uncompressed chunks.
303 concurrent_chunks = sorted(
304 (spec["uncompressed_bytes"] for spec in specs), reverse=True
305 )[:plan["workers"]]
306 _require_free_space(
307 staging_parent or tempfile.gettempdir(), sum(concurrent_chunks),
308 f"stage {target['root_path']}",
309 )
310 print(f"[INFO] Creating archive {archive_id} for {target['root_path']}")
311 with tempfile.TemporaryDirectory(prefix="picurv-storage-", dir=staging_parent) as staging:
312 def package_and_upload(index_spec):
313 """!
314 @brief Package and verify one component chunk.
315 @param[in] index_spec Tuple of chunk index and inventory specification.
316 @return Uploaded chunk manifest entry.
317 """
318 index, spec = index_spec
319 component = _safe_component_name(spec["component"])
320 filename = f"{index:05d}_{component}{_chunk_extension(plan['compression'])}"
321 local_chunk = os.path.join(staging, filename)
322 print(
323 f"[INFO] Packaging chunk {index + 1}/{len(specs)}: "
324 f"{spec['component']} ({_human_bytes(spec['uncompressed_bytes'])})"
325 )
326 compressor = _write_tar_chunk(
327 target["root_path"], spec, local_chunk, plan["compression"],
328 workers=plan["workers"],
329 )
330 try:
331 digest = _sha256_file(local_chunk)
332 # Content-addressed: an identical chunk anywhere on this remote is
333 # already stored, so a rerun after a failed upload skips what
334 # succeeded, and two archives of the same checkpoint share one copy.
335 if _remote_blob_present(profile, digest):
336 print(f"[INFO] Chunk {index + 1}/{len(specs)} already stored; skipping upload.")
337 verified = {
338 "sha256": digest, "stored_bytes": os.path.getsize(local_chunk),
339 }
340 else:
341 verified = _transport._upload_verified(local_chunk, _blob_remote(profile, digest))
342 return {
343 "name": filename,
344 "component": spec["component"],
345 "file_count": len(spec["entries"]),
346 "uncompressed_bytes": spec["uncompressed_bytes"],
347 "stored_bytes": verified["stored_bytes"],
348 "sha256": verified["sha256"],
349 "compressor": compressor,
350 "blob": True,
351 }
352 finally:
353 try:
354 os.remove(local_chunk)
355 except FileNotFoundError:
356 pass
357
358 # pigz/xz already use every requested CPU on one large chunk. The
359 # Python fallback instead gains parallelism across independent chunks.
360 native_parallel = (
361 plan["compression"] in _PARALLEL_GZIP_VALUES and shutil.which("pigz")
362 ) or (plan["compression"] == "maximum" and shutil.which("xz"))
363 task_workers = 1 if native_parallel else min(plan["workers"], max(1, len(specs)))
364 if task_workers == 1:
365 chunks = [package_and_upload(item) for item in enumerate(specs)]
366 else:
367 with concurrent.futures.ThreadPoolExecutor(max_workers=task_workers) as executor:
368 chunks = list(executor.map(package_and_upload, enumerate(specs)))
369 chunks.sort(key=lambda item: item["name"])
370
371 manifest = {
372 "storage_schema_version": STORAGE_SCHEMA_VERSION,
373 "archive_id": archive_id,
374 "created_at": _utc_now(),
375 "artifact_type": target["artifact_type"],
376 "run_id": target.get("run_id"),
377 "study_id": target.get("study_id"),
378 "case_id": target.get("case_id"),
379 "workspace_id": target.get("workspace_id"),
380 "label": label or os.path.basename(target["root_path"]),
381 "notes": notes,
382 "tags": _parse_tags(tags),
383 # What was actually solved, so `storage show` answers "which run was
384 # this?" without restoring anything.
385 "parameters": _capture_parameter_summary(target["root_path"]),
386 "original_path": target["original_path"],
387 "original_study_path": target.get("study_path"),
388 "profile": profile["name"],
389 "remote": profile["remote"],
390 "source_bytes": inventory["total_bytes"],
391 "source_file_count": inventory["file_count"],
392 "inventory_sha256": fingerprint,
393 "compression": plan["compression"],
394 "workers": plan["workers"],
395 "offload_policy": plan["offload_policy"],
396 "checkpoint_format_version": KNOWN_CHECKPOINT_VERSION,
397 "checkpoint_steps": inventory["checkpoint_steps"],
398 "chunks": chunks,
399 "config_sha256": _config_fingerprints(target["root_path"]),
400 "git": _git_provenance(target["root_path"]),
401 "external_paths": inventory["external_paths"],
402 "dependencies": inventory["dependencies"],
403 "study_context": _capture_study_context(target),
404 "run_assets": _capture_run_assets(target["root_path"]),
405 "workspace_assets": _capture_workspace_assets(target),
406 "capabilities": {
407 "restorable": True,
408 "continuable": bool(inventory["checkpoint_steps"]),
409 "reprocessable": bool(inventory["checkpoint_steps"]),
410 "exact_binary_reproduction": False,
411 },
412 }
413 manifest_path = os.path.join(staging, REMOTE_MANIFEST_FILENAME)
414 _atomic_write_json(manifest_path, manifest)
415 _transport._upload_verified(manifest_path, _object_remote(profile, archive_id, REMOTE_MANIFEST_FILENAME))
416 manifest_digest = _sha256_file(manifest_path)
417 complete_path = os.path.join(staging, REMOTE_COMPLETE_FILENAME)
418 with open(complete_path, "w", encoding="ascii") as stream:
419 stream.write(manifest_digest + "\n")
420 _transport._upload_verified(complete_path, _object_remote(profile, archive_id, REMOTE_COMPLETE_FILENAME))
421
422 state = {
423 "storage_schema_version": STORAGE_SCHEMA_VERSION,
424 "archive_id": archive_id,
425 "profile": profile["name"],
426 "remote": profile["remote"],
427 "label": manifest["label"],
428 "archived_at": manifest["created_at"],
429 "local_pruned": False,
430 "restored_components": [],
431 "retained_components": [],
432 }
433 _atomic_write_json(_state_path(target["root_path"]), state)
434 if prune_local:
435 _prune_archived_payload(target["root_path"], inventory, plan["offload_policy"])
436 state["local_pruned"] = True
437 state["pruned_at"] = _utc_now()
438 state["offload_policy"] = plan["offload_policy"]
439 latest_step = max(inventory["checkpoint_steps"], default=None)
440 retained = list(plan["offload_policy"]["retained_components"])
441 if plan["offload_policy"]["keep_latest_checkpoint"] and latest_step is not None:
442 retained.append(f"checkpoint:{latest_step}")
443 state["retained_components"] = retained
444 _atomic_write_json(_state_path(target["root_path"]), state)
445 print(
446 f"[SUCCESS] {'Offloaded' if prune_local else 'Protected'} {target['root_path']} "
447 f"as archive {archive_id}."
448 )
449 return manifest
450
451
Head of a generic C-style linked list.
Definition variables.h:469
Here is the call graph for this function:
Here is the caller graph for this function:

◆ _register_existing_archive()

dict picurv_cli.storage.operations._register_existing_archive ( dict  target,
dict  profile,
dict  manifest,
dict  inventory,
dict  plan,
*bool  prune_local,
str   label = None,
  tags = None,
str   notes = None 
)
protected

Point an artifact at an already-uploaded archive of its current content.

Used when nothing has changed since a previous protect or offload. The remote object is immutable and is not rewritten; only this artifact's local marker is updated, and a label or tags supplied now are recorded there so the second invocation is not silently less descriptive than the first.

Parameters
[in]targetLocal artifact target.
[in]profileResolved storage profile.
[in]manifestRemote manifest being reused.
[in]inventoryCurrent local inventory.
[in]planStorage plan carrying the requested offload policy.
[in]prune_localWhether to prune the verified payload afterwards.
[in]labelOptional label supplied to this invocation.
[in]tagsOptional tags supplied to this invocation.
[in]notesOptional free-text note supplied to this invocation.
Returns
The reused remote manifest.

Definition at line 452 of file operations.py.

454 label: str = None, tags=None, notes: str = None) -> dict:
455 """!
456 @brief Point an artifact at an already-uploaded archive of its current content.
457
458 @details Used when nothing has changed since a previous `protect` or `offload`. The
459 remote object is immutable and is not rewritten; only this artifact's local
460 marker is updated, and a label or tags supplied now are recorded there so
461 the second invocation is not silently less descriptive than the first.
462 @param[in] target Local artifact target.
463 @param[in] profile Resolved storage profile.
464 @param[in] manifest Remote manifest being reused.
465 @param[in] inventory Current local inventory.
466 @param[in] plan Storage plan carrying the requested offload policy.
467 @param[in] prune_local Whether to prune the verified payload afterwards.
468 @param[in] label Optional label supplied to this invocation.
469 @param[in] tags Optional tags supplied to this invocation.
470 @param[in] notes Optional free-text note supplied to this invocation.
471 @return The reused remote manifest.
472 """
473 state = {
474 "storage_schema_version": STORAGE_SCHEMA_VERSION,
475 "archive_id": manifest["archive_id"],
476 "profile": profile["name"],
477 "remote": profile["remote"],
478 "label": label or manifest.get("label") or os.path.basename(target["root_path"]),
479 "archived_at": manifest.get("created_at"),
480 "reused_existing_archive": True,
481 "local_pruned": False,
482 "restored_components": [],
483 "retained_components": [],
484 }
485 parsed_tags = _parse_tags(tags)
486 if parsed_tags:
487 state["tags"] = parsed_tags
488 if notes:
489 state["notes"] = notes
490 _atomic_write_json(_state_path(target["root_path"]), state)
491 if prune_local:
492 _prune_archived_payload(target["root_path"], inventory, plan["offload_policy"])
493 state["local_pruned"] = True
494 state["pruned_at"] = _utc_now()
495 state["offload_policy"] = plan["offload_policy"]
496 latest_step = max(inventory["checkpoint_steps"], default=None)
497 retained = list(plan["offload_policy"]["retained_components"])
498 if plan["offload_policy"]["keep_latest_checkpoint"] and latest_step is not None:
499 retained.append(f"checkpoint:{latest_step}")
500 state["retained_components"] = retained
501 _atomic_write_json(_state_path(target["root_path"]), state)
502 print(
503 f"[SUCCESS] {'Offloaded' if prune_local else 'Protected'} {target['root_path']} "
504 f"using existing archive {manifest['archive_id']}."
505 )
506 return manifest
507
508

◆ _prune_archived_payload()

None picurv_cli.storage.operations._prune_archived_payload ( str  root,
dict  inventory,
dict  policy 
)
protected

Remove only verified heavy payload while retaining control-plane files.

Parameters
[in]rootValue supplied through the root argument.
[in]inventoryValue supplied through the inventory argument.
[in]policyNormalized semantic retention policy.

Definition at line 509 of file operations.py.

509def _prune_archived_payload(root: str, inventory: dict, policy: dict) -> None:
510 """!
511 @brief Remove only verified heavy payload while retaining control-plane files.
512 @param[in] root Value supplied through the `root` argument.
513 @param[in] inventory Value supplied through the `inventory` argument.
514 @param[in] policy Normalized semantic retention policy.
515 """
516 latest_step = max(inventory["checkpoint_steps"], default=None)
517 for entry in sorted(inventory["entries"], key=lambda item: item["path"], reverse=True):
518 if entry["type"] == "directory" or _entry_retained_by_policy(entry, policy, latest_step):
519 continue
520 path = os.path.join(root, *entry["path"].split("/"))
521 try:
522 if os.path.islink(path) or os.path.isfile(path):
523 os.remove(path)
524 except FileNotFoundError:
525 pass
526 for entry in sorted(
527 (item for item in inventory["entries"] if item["type"] == "directory"),
528 key=lambda item: item["path"].count("/"), reverse=True,
529 ):
530 path = os.path.join(root, *entry["path"].split("/"))
531 try:
532 os.rmdir(path)
533 except OSError:
534 pass
535
536

◆ _download_archive_components()

None picurv_cli.storage.operations._download_archive_components ( dict  profile,
dict  manifest,
set  components,
str  destination,
int   workers = None 
)
protected

Download and safely merge selected semantic archive components.

Parameters
[in]profileActive storage profile.
[in]manifestVerified remote archive manifest.
[in]componentsSemantic components to retrieve.
[in]destinationDirectory receiving merged content.
[in]workersOptional parallel worker count.
Returns
None.

Definition at line 537 of file operations.py.

538 destination: str, workers: int = None) -> None:
539 """!
540 @brief Download and safely merge selected semantic archive components.
541 @param[in] profile Active storage profile.
542 @param[in] manifest Verified remote archive manifest.
543 @param[in] components Semantic components to retrieve.
544 @param[in] destination Directory receiving merged content.
545 @param[in] workers Optional parallel worker count.
546 @return None.
547 """
548 chunks = [
549 chunk for chunk in manifest.get("chunks", [])
550 if str(chunk.get("component", "")) in components
551 ]
552 if not chunks:
553 raise StorageError(
554 f"Archive {manifest.get('archive_id')} has none of the requested components: "
555 + ", ".join(sorted(components))
556 )
557 worker_count = int(workers or profile.get("workers", DEFAULT_STORAGE_WORKERS))
558 if worker_count <= 0:
559 raise StorageError("Restore workers must be positive.")
560 os.makedirs(destination, exist_ok=True)
561 # Each chunk is extracted in full before `_merge_tree` copies it into `destination`,
562 # so both copies exist on disk at once.
563 extracted_bytes = sum(chunk.get("uncompressed_bytes", 0) for chunk in chunks)
564 _require_free_space(
565 destination, 2 * extracted_bytes, f"restore archive {manifest.get('archive_id')} components"
566 )
567 with tempfile.TemporaryDirectory(prefix="picurv-component-restore-") as staging:
568 def download_and_extract(index_chunk):
569 """!
570 @brief Download, verify, and extract one selected chunk.
571 @param[in] index_chunk Tuple of chunk index and manifest entry.
572 @return Chunk index and extraction directory.
573 """
574 index, chunk = index_chunk
575 chunk_root = os.path.join(staging, f"extract-{index:05d}")
576 os.makedirs(chunk_root)
577 local_chunk = os.path.join(staging, chunk["name"])
578 _transport._run_rclone([
579 "copyto",
580 _chunk_remote_path(profile, manifest["archive_id"], chunk),
581 local_chunk,
582 ])
583 actual = _sha256_file(local_chunk)
584 if actual != chunk.get("sha256"):
585 raise StorageError(
586 f"Downloaded chunk checksum mismatch: {chunk['name']} "
587 f"(expected {chunk.get('sha256')}, got {actual})."
588 )
589 _extract_chunk(local_chunk, chunk_root)
590 return index, chunk_root
591
592 task_workers = min(worker_count, len(chunks))
593 if task_workers == 1:
594 extracted = [download_and_extract(item) for item in enumerate(chunks)]
595 else:
596 with concurrent.futures.ThreadPoolExecutor(max_workers=task_workers) as executor:
597 extracted = list(executor.map(download_and_extract, enumerate(chunks)))
598 for _, chunk_root in sorted(extracted):
599 _merge_tree(chunk_root, destination)
600
601

◆ restore_missing_workspace_assets()

dict picurv_cli.storage.operations.restore_missing_workspace_assets ( str  workspace_root,
  provider_spec_hashes 
)

Recover immutable workspace assets from archived run-local input copies.

Parameters
[in]workspace_rootInitialized workspace receiving recovered objects.
[in]provider_spec_hashesRequired provider-specification hashes.
Returns
Provider-specification hash to recovered asset-reference mapping.

Definition at line 602 of file operations.py.

602def restore_missing_workspace_assets(workspace_root: str, provider_spec_hashes) -> dict:
603 """!
604 @brief Recover immutable workspace assets from archived run-local input copies.
605 @param[in] workspace_root Initialized workspace receiving recovered objects.
606 @param[in] provider_spec_hashes Required provider-specification hashes.
607 @return Provider-specification hash to recovered asset-reference mapping.
608 """
609 requested = {str(value) for value in provider_spec_hashes if value}
610 if not requested:
611 return {}
612 workspace = Path(os.path.abspath(workspace_root))
613 (workspace / "assets").mkdir(parents=True, exist_ok=True)
614 profile = load_storage_profile(config_path=str(workspace / STORAGE_CONFIG_FILENAME))
615 matches = {}
616 for manifest in sorted(
617 list_remote_manifests(profile), key=lambda item: item.get("created_at", ""), reverse=True
618 ):
619 for reference in manifest.get("run_assets", []):
620 spec_hash = reference.get("provider_spec_sha256")
621 if spec_hash in requested and spec_hash not in matches:
622 matches[spec_hash] = (manifest, reference)
623 if requested <= set(matches):
624 break
625 if not matches:
626 return {}
627
628 restored = {}
629 by_archive = {}
630 for spec_hash, (manifest, reference) in matches.items():
631 group = by_archive.setdefault(manifest["archive_id"], {"manifest": manifest, "items": []})
632 group["items"].append((spec_hash, reference))
633 kind_directories = {
634 "grid": "grids",
635 "initial-condition": "initial_conditions",
636 "inlet-profiles": "inlet_profiles",
637 }
638 for group in by_archive.values():
639 with tempfile.TemporaryDirectory(prefix=".asset-restore-", dir=workspace / "assets") as temporary:
640 _download_archive_components(profile, group["manifest"], {"inputs"}, temporary)
641 for spec_hash, reference in group["items"]:
642 kind = reference.get("kind")
643 asset_id = reference.get("asset_id")
644 if kind not in kind_directories or not asset_id:
645 continue
646 object_relative = f"assets/objects/{kind_directories[kind]}/{asset_id}"
647 object_root = workspace / object_relative
648 if not object_root.is_dir():
649 object_root.parent.mkdir(parents=True, exist_ok=True)
650 object_staging = Path(tempfile.mkdtemp(
651 prefix=f".{asset_id[:12]}-", dir=object_root.parent
652 ))
653 try:
654 files = []
655 for item in reference.get("files", []):
656 source = Path(temporary, *item["archived_path"].split("/"))
657 if not source.is_file() or (
658 item.get("sha256") and _sha256_file(str(source)) != item["sha256"]
659 ):
660 raise StorageError(
661 f"Archived asset payload is missing or corrupt: {item['archived_path']}"
662 )
663 destination = object_staging / "payload" / item["path"]
664 destination.parent.mkdir(parents=True, exist_ok=True)
665 shutil.copy2(source, destination)
666 files.append({
667 "path": item["path"],
668 "bytes": destination.stat().st_size,
669 "sha256": _sha256_file(str(destination)),
670 })
671 _atomic_write_json(str(object_staging / "asset.json"), {
672 "schema_version": 1,
673 "asset_id": asset_id,
674 "kind": kind,
675 "provider": reference.get("provider"),
676 "provider_spec_sha256": spec_hash,
677 "recovered_from_archive": group["manifest"]["archive_id"],
678 "files": files,
679 })
680 try:
681 os.replace(object_staging, object_root)
682 except OSError as exc:
683 if exc.errno != errno.ENOTEMPTY and not object_root.is_dir():
684 raise
685 finally:
686 if object_staging.is_dir():
687 shutil.rmtree(object_staging, ignore_errors=True)
688 restored[spec_hash] = {
689 "asset_id": asset_id,
690 "kind": kind,
691 "provider": reference.get("provider"),
692 "provider_spec_sha256": spec_hash,
693 "object": object_relative,
694 "files": reference.get("files", []),
695 }
696 print(f"[INFO] Restored shared asset {kind}: {asset_id}")
697 return restored
698
699

◆ restore_archive()

dict picurv_cli.storage.operations.restore_archive ( dict  profile,
str  archive_id,
str   destination = None,
  checkpoints = None,
bool   force = False,
int   workers = None,
  components = None 
)

Download, verify, extract, and materialize an archive or selected checkpoints.

Parameters
[in]profileValue supplied through the profile argument.
[in]archive_idValue supplied through the archive_id argument.
[in]destinationValue supplied through the destination argument.
[in]checkpointsValue supplied through the checkpoints argument.
[in]forceValue supplied through the force argument.
[in]workersOptional parallel download and extraction worker count.
[in]componentsOptional semantic components to restore.
Returns
Result produced by this operation.

Definition at line 700 of file operations.py.

702 workers: int = None, components=None) -> dict:
703 """!
704 @brief Download, verify, extract, and materialize an archive or selected checkpoints.
705 @param[in] profile Value supplied through the `profile` argument.
706 @param[in] archive_id Value supplied through the `archive_id` argument.
707 @param[in] destination Value supplied through the `destination` argument.
708 @param[in] checkpoints Value supplied through the `checkpoints` argument.
709 @param[in] force Value supplied through the `force` argument.
710 @param[in] workers Optional parallel download and extraction worker count.
711 @param[in] components Optional semantic components to restore.
712 @return Result produced by this operation.
713 """
714 manifest = _load_remote_manifest(profile, archive_id)
715 original = os.path.abspath(manifest["original_path"])
716 destination_abs = os.path.abspath(destination or original)
717 selected_steps = {int(step) for step in (checkpoints or [])}
718 selected_components = {str(component) for component in (components or [])}
719 chunks = []
720 for chunk in manifest.get("chunks", []):
721 component = str(chunk.get("component", ""))
722 if selected_steps or selected_components:
723 if component in ALWAYS_RESTORED_COMPONENTS:
724 chunks.append(chunk)
725 elif component.startswith("checkpoint:") and int(component.split(":", 1)[1]) in selected_steps:
726 chunks.append(chunk)
727 elif component in selected_components:
728 chunks.append(chunk)
729 else:
730 chunks.append(chunk)
731 if selected_steps:
732 available = set(manifest.get("checkpoint_steps", []))
733 missing = selected_steps - available
734 if missing:
735 raise StorageError(f"Archive {archive_id} does not contain checkpoint step(s): {sorted(missing)}")
736 existing_state = read_storage_state(destination_abs) if os.path.isdir(destination_abs) else None
737 if os.path.exists(destination_abs) and not force:
738 if not existing_state or existing_state.get("archive_id") != archive_id:
739 raise StorageError(
740 f"Restore destination already exists and is not the matching cold artifact: {destination_abs}. "
741 "Choose --to or use --force after verifying the destination."
742 )
743 # Components this artifact already holds locally - restored earlier or never
744 # pruned - are not downloaded again; --force fetches everything.
745 already_local = (set(existing_state.get("restored_components") or [])
746 | set(existing_state.get("retained_components") or []))
747 def held_locally(component: str) -> bool:
748 """!
749 @brief Whether a chunk's component is already present in the destination.
750 @param[in] component Chunk component name.
751 @return True for a restored or retained component; a retained `checkpoints`
752 entry covers every committed step.
753 """
754 return component in already_local or (
755 component.startswith("checkpoint:") and "checkpoints" in already_local)
756
757 skipped = [chunk["component"] for chunk in chunks
758 if chunk.get("component") not in ALWAYS_RESTORED_COMPONENTS
759 and held_locally(str(chunk.get("component", "")))]
760 if skipped:
761 chunks = [chunk for chunk in chunks if chunk.get("component") not in skipped]
762 print(f"[INFO] Already local, not downloaded again: {', '.join(skipped)}")
763 parent = os.path.dirname(destination_abs)
764 os.makedirs(parent, exist_ok=True)
765 # Every selected chunk is extracted in full before `_merge_tree` copies it into
766 # `materialized`, so both copies exist on disk at once ahead of the final move.
767 extracted_bytes = sum(chunk.get("uncompressed_bytes", 0) for chunk in chunks)
768 _require_free_space(parent, 2 * extracted_bytes, f"restore archive {archive_id}")
769 temporary = tempfile.mkdtemp(prefix=f".picurv-restore-{archive_id[:8]}-", dir=parent)
770 materialized = os.path.join(temporary, "materialized")
771 os.makedirs(materialized)
772 try:
773 newly_restored = (
774 [f"checkpoint:{step}" for step in sorted(selected_steps)]
775 + sorted(selected_components)
776 )
777 prior_restored = (existing_state or {}).get("restored_components") or []
778 worker_count = int(workers or profile.get("workers", DEFAULT_STORAGE_WORKERS))
779 if worker_count <= 0:
780 raise StorageError("Restore workers must be positive.")
781
782 def download_and_extract(index_chunk):
783 """!
784 @brief Download, verify, and extract one archive chunk.
785 @param[in] index_chunk Tuple of chunk index and manifest entry.
786 @return Chunk index and extraction directory.
787 """
788 index, chunk = index_chunk
789 chunk_root = os.path.join(temporary, f"extract-{index:05d}")
790 os.makedirs(chunk_root)
791 local_chunk = os.path.join(temporary, chunk["name"])
792 print(f"[INFO] Restoring chunk {index + 1}/{len(chunks)}: {chunk['component']}")
793 _transport._run_rclone([
794 "copyto", _chunk_remote_path(profile, archive_id, chunk), local_chunk,
795 ])
796 actual = _sha256_file(local_chunk)
797 if actual != chunk.get("sha256"):
798 raise StorageError(
799 f"Downloaded chunk checksum mismatch: {chunk['name']} "
800 f"(expected {chunk.get('sha256')}, got {actual})."
801 )
802 _extract_chunk(local_chunk, chunk_root)
803 os.remove(local_chunk)
804 return index, chunk_root
805
806 task_workers = min(worker_count, max(1, len(chunks)))
807 if task_workers == 1:
808 extracted = [download_and_extract(item) for item in enumerate(chunks)]
809 else:
810 with concurrent.futures.ThreadPoolExecutor(max_workers=task_workers) as executor:
811 extracted = list(executor.map(download_and_extract, enumerate(chunks)))
812 for _, chunk_root in sorted(extracted):
813 _merge_tree(chunk_root, materialized)
814
815 if os.path.isdir(destination_abs):
816 _merge_tree(materialized, destination_abs)
817 else:
818 os.replace(materialized, destination_abs)
819 _restore_study_context(manifest, destination_abs)
820
821 replacements = [(original, destination_abs)]
822 original_study = manifest.get("original_study_path")
823 if original_study and manifest.get("artifact_type") == "study-case":
824 replacements.append((os.path.abspath(original_study), str(Path(destination_abs).parent.parent)))
825 rebased = _rebase_restored_text_paths(destination_abs, replacements)
826 state = {
827 "storage_schema_version": STORAGE_SCHEMA_VERSION,
828 "archive_id": archive_id,
829 "profile": profile["name"],
830 "remote": profile["remote"],
831 "label": manifest.get("label"),
832 "archived_at": manifest.get("created_at"),
833 "restored_at": _utc_now(),
834 "local_pruned": bool(selected_steps or selected_components),
835 "restored_components": sorted(set(prior_restored) | set(newly_restored)),
836 "retained_components": (existing_state or {}).get("retained_components") or [],
837 "relocated_from": original if original != destination_abs else None,
838 "rebased_files": len(rebased),
839 }
840 _atomic_write_json(_state_path(destination_abs), state)
841 except Exception:
842 if os.path.isdir(temporary):
843 shutil.rmtree(temporary, ignore_errors=True)
844 raise
845 if os.path.isdir(temporary):
846 shutil.rmtree(temporary, ignore_errors=True)
847 print(f"[SUCCESS] Restored archive {archive_id} to {destination_abs}.")
848 if rebased:
849 print(f"[INFO] Rebased {len(rebased)} generated text artifact(s) to the restored path.")
850 return manifest
851
852
Here is the caller graph for this function:

◆ _expand_checkpoint_selection()

list picurv_cli.storage.operations._expand_checkpoint_selection (   explicit,
  ranges 
)
protected

Combine repeated --checkpoint values with START:END[:STRIDE] ranges.

Parameters
[in]explicitIndividually named steps, or None.
[in]rangesRange expressions, or None.
Returns
Sorted unique step list, or None when nothing was selected.

Definition at line 853 of file operations.py.

853def _expand_checkpoint_selection(explicit, ranges) -> list:
854 """!
855 @brief Combine repeated `--checkpoint` values with `START:END[:STRIDE]` ranges.
856 @param[in] explicit Individually named steps, or None.
857 @param[in] ranges Range expressions, or None.
858 @return Sorted unique step list, or None when nothing was selected.
859 """
860 selected = set(explicit or ())
861 for expression in ranges or ():
862 parts = str(expression).split(":")
863 if len(parts) not in (2, 3):
864 raise StorageError(
865 f"--checkpoints expects START:END or START:END:STRIDE, got {expression!r}."
866 )
867 try:
868 numbers = [int(part) for part in parts]
869 except ValueError:
870 raise StorageError(
871 f"--checkpoints expects integer steps, got {expression!r}."
872 ) from None
873 start, end = numbers[0], numbers[1]
874 stride = numbers[2] if len(numbers) == 3 else 1
875 if stride <= 0:
876 raise StorageError(f"--checkpoints stride must be positive, got {expression!r}.")
877 if end < start:
878 raise StorageError(f"--checkpoints end precedes start in {expression!r}.")
879 selected.update(range(start, end + 1, stride))
880 return sorted(selected) or None
881
882

◆ _resolve_archive_id_from_args()

str picurv_cli.storage.operations._resolve_archive_id_from_args (   args)
protected

Resolve an explicit archive ID or a local artifact marker.

Parameters
[in]argsValue supplied through the args argument.
Returns
Result produced by this operation.

Definition at line 883 of file operations.py.

883def _resolve_archive_id_from_args(args) -> str:
884 """!
885 @brief Resolve an explicit archive ID or a local artifact marker.
886 @param[in] args Value supplied through the `args` argument.
887 @return Result produced by this operation.
888 """
889 archive_id = getattr(args, "archive_id", None)
890 if archive_id:
891 return archive_id
892 workspace_id = getattr(args, "workspace_id", None)
893 if workspace_id:
894 return resolve_workspace_archive_id(
895 load_storage_profile(
896 getattr(args, "profile", None), getattr(args, "storage_config", None)
897 ),
898 workspace_id,
899 )
900 targets = resolve_local_storage_targets(
901 getattr(args, "run_dir", None), getattr(args, "study_dir", None), getattr(args, "case_ids", None),
902 workspace=getattr(args, "workspace", None),
903 )
904 if len(targets) != 1:
905 raise StorageError("Restore/verify by local marker requires exactly one target.")
906 state = read_storage_state(targets[0]["root_path"])
907 if not state or not state.get("archive_id"):
908 raise StorageError(f"No storage marker with an archive ID exists under {targets[0]['root_path']}.")
909 return state["archive_id"]
910
911

◆ _render_status()

None picurv_cli.storage.operations._render_status ( dict  inventory)
protected

Render one artifact status row and safety details.

Parameters
[in]inventoryValue supplied through the inventory argument.

Definition at line 912 of file operations.py.

912def _render_status(inventory: dict) -> None:
913 """!
914 @brief Render one artifact status row and safety details.
915 @param[in] inventory Value supplied through the `inventory` argument.
916 """
917 target = inventory["target"]
918 storage = inventory["storage"]
919 activity = "BUSY" if inventory["active_locks"] or inventory["slurm"]["active"] else storage["state"]
920 identity = (
921 target.get("case_id") or target.get("run_id") or target.get("study_id")
922 or target.get("workspace_id") or os.path.basename(target["root_path"])
923 )
924 print(
925 f"{identity:<36} {target['artifact_type']:<12} {activity:<10} "
926 f"{_human_bytes(inventory['total_bytes']):>12} {storage.get('label') or ''}"
927 )
928
929

◆ report_storage_configuration()

dict picurv_cli.storage.operations.report_storage_configuration ( dict  profile)

State which configuration answered, and warn when it came from outside.

Every command below acts on the remote this file names, and offload prunes local payload once that remote has verified the upload. Discovery searches upward past the workspace boundary, so the file that answered is not always the one the user thinks they are standing in; naming it here is what keeps an inherited configuration a choice rather than an accident.

Parameters
[in]profileResolved storage profile, carrying its config_path.
Returns
The profile, unchanged, so callers can wrap the load.

Definition at line 930 of file operations.py.

930def report_storage_configuration(profile: dict) -> dict:
931 """!
932 @brief State which configuration answered, and warn when it came from outside.
933
934 @details Every command below acts on the remote this file names, and `offload`
935 prunes local payload once that remote has verified the upload. Discovery
936 searches upward past the workspace boundary, so the file that answered is
937 not always the one the user thinks they are standing in; naming it here is
938 what keeps an inherited configuration a choice rather than an accident.
939 @param[in] profile Resolved storage profile, carrying its `config_path`.
940 @return The profile, unchanged, so callers can wrap the load.
941 """
942 config_path = profile.get("config_path")
943 origin = storage_config_origin(config_path)
944 print(f"[INFO] Storage config : {config_path}")
945 print(f"[INFO] Storage profile: {profile['name']} -> {profile['remote']}")
946 if origin == "shared":
947 print(
948 f"[WARNING] That configuration is outside this workspace; it is shared with "
949 f"everything below {os.path.dirname(os.path.abspath(config_path))}. Pass "
950 "--storage-config to choose another, or run 'picurv storage setup' here to "
951 "give this workspace its own.",
952 file=sys.stderr,
953 )
954 return profile
955
956
Here is the caller graph for this function:

◆ load_reported_storage_profile()

dict picurv_cli.storage.operations.load_reported_storage_profile (   args)

Load the storage profile a command will act on, and report its origin.

Parameters
[in]argsParsed storage command arguments.
Returns
Resolved storage profile.

Definition at line 957 of file operations.py.

957def load_reported_storage_profile(args) -> dict:
958 """!
959 @brief Load the storage profile a command will act on, and report its origin.
960 @param[in] args Parsed storage command arguments.
961 @return Resolved storage profile.
962 """
963 return report_storage_configuration(
964 load_storage_profile(getattr(args, "profile", None),
965 getattr(args, "storage_config", None))
966 )
967
968
Here is the call graph for this function:
Here is the caller graph for this function:

◆ storage_setup_workflow()

None picurv_cli.storage.operations.storage_setup_workflow (   args)

Create or update a non-secret workspace storage profile.

Parameters
[in]argsValue supplied through the args argument.

Definition at line 969 of file operations.py.

969def storage_setup_workflow(args) -> None:
970 """!
971 @brief Create or update a non-secret workspace storage profile.
972 @param[in] args Value supplied through the `args` argument.
973 """
974 explicit_config = getattr(args, "storage_config", None)
975 config_path = resolve_storage_config_path(explicit_config, require=False)
976 workspace_root = storage_workspace_root()
977 # Discovery walks past the workspace boundary, which is right for reading a shared
978 # configuration and wrong for writing one: `setup` inside a fresh workspace would
979 # otherwise rewrite the remote of the campaign directory above it, silently
980 # re-pointing every other workspace under it. Configure the workspace being stood
981 # in; editing a shared file stays available, but has to be asked for.
982 if not explicit_config and workspace_root and storage_config_origin(config_path, workspace_root) == "shared":
983 inherited = config_path
984 config_path = os.path.join(workspace_root, STORAGE_CONFIG_FILENAME)
985 print(
986 f"[INFO] A shared configuration exists at {inherited}, but it belongs to a "
987 "directory above this workspace."
988 )
989 print(
990 f" Writing this workspace's own configuration instead. To edit the "
991 f"shared one, pass --storage-config {inherited}."
992 )
993 payload = {}
994 if os.path.isfile(config_path):
995 with open(config_path, "r", encoding="utf-8") as stream:
996 payload = yaml.safe_load(stream) or {}
997 profiles = payload.setdefault("profiles", {})
998 profile_name = args.profile or DEFAULT_PROFILE_NAME
999 profile = {
1000 "remote": args.remote.rstrip("/"),
1001 "compression": args.compression,
1002 "chunk_size_gib": args.chunk_size_gib,
1003 "workers": getattr(args, "workers", DEFAULT_STORAGE_WORKERS),
1004 "offload_policy": getattr(args, "offload_policy", "metadata-only"),
1005 "keep_latest_checkpoint": bool(getattr(args, "keep_latest_checkpoint", False)),
1006 }
1007 if args.staging_directory:
1008 profile["staging_directory"] = os.path.abspath(os.path.expanduser(args.staging_directory))
1009 payload["default_profile"] = profile_name
1010 profiles[profile_name] = profile
1011 print(f"[INFO] Storage config : {config_path}")
1012 print(f"[INFO] Profile : {profile_name}")
1013 print(f"[INFO] Remote : {profile['remote']}")
1014 if args.dry_run:
1015 print("[INFO] Dry-run only. No configuration or remote directories were changed.")
1016 return
1017 _transport._run_rclone(["mkdir", _remote_join(profile["remote"], REMOTE_OBJECTS_DIRECTORY)])
1018 os.makedirs(os.path.dirname(config_path), exist_ok=True)
1019 temporary = f"{config_path}.tmp.{os.getpid()}"
1020 with open(temporary, "w", encoding="utf-8") as stream:
1021 yaml.safe_dump(payload, stream, sort_keys=False)
1022 os.replace(temporary, config_path)
1023 print("[SUCCESS] Storage profile configured and remote access verified.")
1024
1025
Here is the caller graph for this function:

◆ storage_status_workflow()

None picurv_cli.storage.operations.storage_status_workflow (   args)

Print local storage and lifecycle status for selected artifacts.

Parameters
[in]argsValue supplied through the args argument.

Definition at line 1026 of file operations.py.

1026def storage_status_workflow(args) -> None:
1027 """!
1028 @brief Print local storage and lifecycle status for selected artifacts.
1029 @param[in] args Value supplied through the `args` argument.
1030 """
1031 if args.study_dir and not args.case_ids:
1032 study_root = os.path.abspath(args.study_dir)
1033 if getattr(args, "completed", False):
1034 case_ids = _select_completed_case_ids(study_root)
1035 else:
1036 case_ids = [
1037 path.name for path in sorted((Path(study_root) / "cases").glob("case_*")) if path.is_dir()
1038 ]
1039 targets = resolve_local_storage_targets(None, study_root, case_ids) if case_ids else resolve_local_storage_targets(None, study_root)
1040 else:
1041 targets = resolve_local_storage_targets(
1042 args.run_dir, args.study_dir, args.case_ids,
1043 workspace=getattr(args, "workspace", None),
1044 include_inputs=getattr(args, "include_inputs", False),
1045 completed=getattr(args, "completed", False),
1046 )
1047 inventories = [inspect_artifact(target) for target in targets]
1048 if args.output_format == "json":
1049 serializable = []
1050 for item in inventories:
1051 copy_item = dict(item)
1052 copy_item.pop("entries", None)
1053 serializable.append(copy_item)
1054 print(json.dumps(serializable, indent=2, sort_keys=True))
1055 return
1056 print(f"{'ARTIFACT':<36} {'TYPE':<12} {'STATE':<10} {'LOCAL SIZE':>12} LABEL")
1057 for inventory in inventories:
1058 _render_status(inventory)
1059
1060
Here is the caller graph for this function:

◆ storage_plan_workflow()

None picurv_cli.storage.operations.storage_plan_workflow (   args)

Render a read-only packaging and safety plan.

Parameters
[in]argsValue supplied through the args argument.

Definition at line 1061 of file operations.py.

1061def storage_plan_workflow(args) -> None:
1062 """!
1063 @brief Render a read-only packaging and safety plan.
1064 @param[in] args Value supplied through the `args` argument.
1065 """
1066 profile = load_reported_storage_profile(args)
1067 targets = resolve_local_storage_targets(
1068 args.run_dir, args.study_dir, args.case_ids,
1069 workspace=getattr(args, "workspace", None),
1070 include_inputs=getattr(args, "include_inputs", False),
1071 completed=getattr(args, "completed", False),
1072 )
1073 for index, target in enumerate(targets):
1074 if index:
1075 print()
1076 plan = build_storage_plan(
1077 target, profile, args.compression,
1078 policy=getattr(args, "policy", None),
1079 keep_latest_checkpoint=getattr(args, "keep_latest_checkpoint", None),
1080 workers=getattr(args, "workers", None),
1081 retain=getattr(args, "retain", None), drop=getattr(args, "drop", None),
1082 )
1083 _render_plan(plan)
1084 _assert_archive_safe(plan["inventory"])
1085
1086
Here is the call graph for this function:
Here is the caller graph for this function:

◆ storage_archive_workflow()

None picurv_cli.storage.operations.storage_archive_workflow (   args,
bool  prune_local 
)

Execute protect or offload for one or more explicit local targets.

Parameters
[in]argsValue supplied through the args argument.
[in]prune_localValue supplied through the prune_local argument.

Definition at line 1087 of file operations.py.

1087def storage_archive_workflow(args, prune_local: bool) -> None:
1088 """!
1089 @brief Execute protect or offload for one or more explicit local targets.
1090 @param[in] args Value supplied through the `args` argument.
1091 @param[in] prune_local Value supplied through the `prune_local` argument.
1092 """
1093 profile = load_reported_storage_profile(args)
1094 targets = resolve_local_storage_targets(
1095 args.run_dir, args.study_dir, args.case_ids,
1096 workspace=getattr(args, "workspace", None),
1097 include_inputs=getattr(args, "include_inputs", False),
1098 completed=getattr(args, "completed", False),
1099 )
1100 for target in targets:
1101 if args.dry_run:
1102 _render_plan(build_storage_plan(
1103 target, profile, args.compression,
1104 policy=getattr(args, "policy", None),
1105 keep_latest_checkpoint=getattr(args, "keep_latest_checkpoint", None),
1106 workers=getattr(args, "workers", None),
1107 retain=getattr(args, "retain", None), drop=getattr(args, "drop", None),
1108 ))
1109 print("[INFO] Dry-run only. No files were packaged, uploaded, or pruned.")
1110 continue
1111 archive_artifact(
1112 target,
1113 profile,
1114 label=args.label,
1115 tags=args.tags,
1116 compression=args.compression,
1117 prune_local=prune_local,
1118 policy=getattr(args, "policy", None),
1119 keep_latest_checkpoint=getattr(args, "keep_latest_checkpoint", None),
1120 workers=getattr(args, "workers", None),
1121 notes=getattr(args, "notes", None),
1122 retain=getattr(args, "retain", None),
1123 drop=getattr(args, "drop", None),
1124 )
1125
1126
Here is the call graph for this function:
Here is the caller graph for this function:

◆ storage_restore_workflow()

None picurv_cli.storage.operations.storage_restore_workflow (   args)

Restore a remote archive by globally unique ID or local marker.

Parameters
[in]argsValue supplied through the args argument.

Definition at line 1127 of file operations.py.

1127def storage_restore_workflow(args) -> None:
1128 """!
1129 @brief Restore a remote archive by globally unique ID or local marker.
1130 @param[in] args Value supplied through the `args` argument.
1131 """
1132 profile = load_reported_storage_profile(args)
1133 archive_id = _resolve_archive_id_from_args(args)
1134 restore_archive(
1135 profile,
1136 archive_id,
1137 destination=args.destination,
1138 checkpoints=_expand_checkpoint_selection(
1139 args.checkpoints, getattr(args, "checkpoint_ranges", None)
1140 ),
1141 force=args.force,
1142 workers=getattr(args, "workers", None),
1143 components=getattr(args, "components", None),
1144 )
1145
1146
Here is the call graph for this function:
Here is the caller graph for this function:

◆ storage_prune_workflow()

None picurv_cli.storage.operations.storage_prune_workflow (   args)

Remove local asset objects nothing local needs and storage has verified.

Parameters
[in]argsValue supplied through the args argument.

Definition at line 1147 of file operations.py.

1147def storage_prune_workflow(args) -> None:
1148 """!
1149 @brief Remove local asset objects nothing local needs and storage has verified.
1150 @param[in] args Value supplied through the `args` argument.
1151 """
1152 profile = load_reported_storage_profile(args)
1153 workspace_root = os.path.abspath(args.workspace) if args.workspace else _find_upwards(
1154 os.getcwd(), WORKSPACE_CONFIG_FILENAME
1155 )
1156 if workspace_root and os.path.isfile(workspace_root):
1157 workspace_root = os.path.dirname(workspace_root)
1158 if not workspace_root:
1159 raise StorageError(
1160 "No initialized workspace found. Pass --workspace, or run inside one."
1161 )
1162 decisions = prune_unused_workspace_assets(workspace_root, profile, dry_run=args.dry_run)
1163 if not decisions:
1164 print("[INFO] The workspace asset store holds no published objects.")
1165 return
1166 for decision in decisions:
1167 print(
1168 f"{decision['asset_id'][:16]} {decision['kind']}\n"
1169 f" referenced by remote runs {decision['cold_runs']}\n"
1170 f" referenced by active local runs {decision['active_local_runs']}\n"
1171 f" remote protection {decision['remote_protection']}\n"
1172 f" local removal {decision['local_removal']}"
1173 )
1174 removed = [item for item in decisions if item.get("removed")]
1175 failed = [item for item in decisions if item.get("error")]
1176 if args.dry_run:
1177 safe = [item for item in decisions if item["local_removal"] == "safe"]
1178 print(f"[INFO] Dry-run only. {len(safe)} object(s) would be removed.")
1179 else:
1180 for item in failed:
1181 print(f"[ERROR] Could not remove {item['object']}: {item['error']}", file=sys.stderr)
1182 print(f"[SUCCESS] Removed {len(removed)} local asset object(s).")
1183 if failed:
1184 raise StorageError(f"{len(failed)} asset object(s) could not be removed; see above.")
1185
1186
Here is the call graph for this function:
Here is the caller graph for this function:

◆ storage_verify_workflow()

None picurv_cli.storage.operations.storage_verify_workflow (   args)

Verify all remote chunks for one archive.

Parameters
[in]argsValue supplied through the args argument.

Definition at line 1187 of file operations.py.

1187def storage_verify_workflow(args) -> None:
1188 """!
1189 @brief Verify all remote chunks for one archive.
1190 @param[in] args Value supplied through the `args` argument.
1191 """
1192 profile = load_reported_storage_profile(args)
1193 archive_id = _resolve_archive_id_from_args(args)
1194 manifest = verify_remote_archive(profile, archive_id)
1195 print(
1196 f"[SUCCESS] Archive {archive_id} is complete; verified {len(manifest.get('chunks', []))} chunk(s)."
1197 )
1198
1199
Here is the call graph for this function:
Here is the caller graph for this function:

◆ storage_list_workflow()

None picurv_cli.storage.operations.storage_list_workflow (   args)

Search the remote manifest catalog without local artifact state.

Parameters
[in]argsValue supplied through the args argument.

Definition at line 1200 of file operations.py.

1200def storage_list_workflow(args) -> None:
1201 """!
1202 @brief Search the remote manifest catalog without local artifact state.
1203 @param[in] args Value supplied through the `args` argument.
1204 """
1205 profile = load_reported_storage_profile(args)
1206 manifests = list_remote_manifests(profile)
1207 workspace_label = getattr(args, "workspace_label", None)
1208 if workspace_label:
1209 manifests = [
1210 item for item in manifests
1211 if str(item.get("workspace_id") or "") == workspace_label
1212 ]
1213 query = str(args.search or "").lower()
1214 if query:
1215 manifests = [
1216 item for item in manifests
1217 if query in " ".join(
1218 str(item.get(key, "")) for key in (
1219 "archive_id", "label", "notes", "run_id", "study_id", "case_id",
1220 "workspace_id", "tags",
1221 )
1222 ).lower()
1223 ]
1224 if args.output_format == "json":
1225 print(json.dumps(manifests, indent=2, sort_keys=True))
1226 return
1227 print(f"{'ARCHIVE ID':<34} {'TYPE':<12} {'IDENTITY':<32} LABEL")
1228 for item in manifests:
1229 identity = (
1230 item.get("case_id") or item.get("run_id") or item.get("study_id")
1231 or item.get("workspace_id") or "-"
1232 )
1233 print(f"{item['archive_id']:<34} {item.get('artifact_type', '-'):<12} {identity:<32} {item.get('label', '')}")
1234
1235
Here is the call graph for this function:
Here is the caller graph for this function:

◆ storage_show_workflow()

None picurv_cli.storage.operations.storage_show_workflow (   args)

Show one complete remote archive manifest.

Parameters
[in]argsValue supplied through the args argument.

Definition at line 1236 of file operations.py.

1236def storage_show_workflow(args) -> None:
1237 """!
1238 @brief Show one complete remote archive manifest.
1239 @param[in] args Value supplied through the `args` argument.
1240 """
1241 profile = load_reported_storage_profile(args)
1242 manifest = _load_remote_manifest(profile, args.archive_id)
1243 print(json.dumps(manifest, indent=2, sort_keys=True))
1244
1245
Here is the call graph for this function:
Here is the caller graph for this function:

◆ storage_workflow()

None picurv_cli.storage.operations.storage_workflow (   args)

Dispatch nested storage actions using existing PICurv workflow conventions.

Parameters
[in]argsValue supplied through the args argument.

Definition at line 1246 of file operations.py.

1246def storage_workflow(args) -> None:
1247 """!
1248 @brief Dispatch nested storage actions using existing PICurv workflow conventions.
1249 @param[in] args Value supplied through the `args` argument.
1250 """
1251 try:
1252 action = args.storage_action
1253 if action == "setup":
1254 storage_setup_workflow(args)
1255 elif action == "status":
1256 storage_status_workflow(args)
1257 elif action == "plan":
1258 storage_plan_workflow(args)
1259 elif action == "protect":
1260 storage_archive_workflow(args, prune_local=False)
1261 elif action == "offload":
1262 storage_archive_workflow(args, prune_local=True)
1263 elif action == "restore":
1264 storage_restore_workflow(args)
1265 elif action == "prune":
1266 storage_prune_workflow(args)
1267 elif action == "verify":
1268 storage_verify_workflow(args)
1269 elif action == "list":
1270 storage_list_workflow(args)
1271 elif action == "show":
1272 storage_show_workflow(args)
1273 else:
1274 raise StorageError(f"Unsupported storage action: {action}")
1275 except StorageError as exc:
1276 print(f"[FATAL] {exc}", file=sys.stderr)
1277 raise SystemExit(1)
1278
1279
Here is the call graph for this function:

◆ add_storage_parser()

argparse.ArgumentParser picurv_cli.storage.operations.add_storage_parser (   subparsers)

Attach the nested storage command parser to PICurv's top-level parser.

Parameters
[in]subparsersValue supplied through the subparsers argument.
Returns
Result produced by this operation.

Definition at line 1280 of file operations.py.

1280def add_storage_parser(subparsers) -> argparse.ArgumentParser:
1281 """!
1282 @brief Attach the nested storage command parser to PICurv's top-level parser.
1283 @param[in] subparsers Value supplied through the `subparsers` argument.
1284 @return Result produced by this operation.
1285 """
1286 parser = subparsers.add_parser(
1287 "storage",
1288 help="Protect, offload, inspect, verify, and restore run/study artifacts.",
1289 formatter_class=argparse.RawTextHelpFormatter,
1290 description=(
1291 "Manage PICurv run and study data through a configured rclone remote.\n"
1292 "Remote archives are checksum-verified before local payload can be pruned.\n\n"
1293 "Examples:\n"
1294 " picurv storage setup --remote labstore:picurv-data\n"
1295 " picurv storage status --run-dir runs/my_run\n"
1296 " picurv storage protect --run-dir runs/my_run --label 'baseline'\n"
1297 " picurv storage offload --study-dir studies/my_study --case-id case_0003\n"
1298 " picurv storage list --search '64-grid'\n"
1299 " picurv storage restore --archive-id <id>"
1300 ),
1301 epilog="Use `picurv storage <action> --help` for action-specific controls.",
1302 )
1303 actions = parser.add_subparsers(dest="storage_action", required=True, help="Storage action")
1304
1305 setup = actions.add_parser("setup", help="Configure a non-secret rclone storage profile.")
1306 setup.add_argument("--remote", required=True, help="Rclone remote and base path, such as labstore:picurv-data.")
1307 setup.add_argument("--profile", default=DEFAULT_PROFILE_NAME, help="Profile name (default: archive).")
1308 setup.add_argument("--storage-config", help=f"Storage YAML path (default: ./{STORAGE_CONFIG_FILENAME}).")
1309 setup.add_argument("--compression", choices=list(STORAGE_COMPRESSION_POLICIES), default="auto")
1310 setup.add_argument("--chunk-size-gib", type=float, default=DEFAULT_CHUNK_SIZE_GIB)
1311 setup.add_argument("--workers", type=int, default=DEFAULT_STORAGE_WORKERS,
1312 help="CPU workers for compression and restoration.")
1313 setup.add_argument("--offload-policy", choices=list(STORAGE_OFFLOAD_POLICIES), default="metadata-only")
1314 setup.add_argument("--keep-latest-checkpoint", action="store_true",
1315 help="Retain the newest committed checkpoint after offload.")
1316 setup.add_argument("--staging-directory", help="Optional local directory for one archive chunk at a time.")
1317 setup.add_argument("--dry-run", action="store_true")
1318
1319 def add_profile_options(action_parser):
1320 """!
1321 @brief Attach shared storage-profile selectors to one action parser.
1322 @param[in] action_parser Value supplied through the `action_parser` argument.
1323 """
1324 action_parser.add_argument("--profile", help="Configured storage profile name.")
1325 action_parser.add_argument("--storage-config", help="Explicit storage YAML path.")
1326
1327 def add_local_target(action_parser, require=True, allow_workspace=True):
1328 """!
1329 @brief Attach the standard run/study/workspace target selectors to one parser.
1330 @param[in] action_parser Value supplied through the `action_parser` argument.
1331 @param[in] require Value supplied through the `require` argument.
1332 @param[in] allow_workspace Whether a whole workspace is a valid target here.
1333 """
1334 group = action_parser.add_mutually_exclusive_group(required=require)
1335 group.add_argument("--run-dir", help="Standalone run directory.")
1336 group.add_argument("--study-dir", help="Sweep study directory.")
1337 if allow_workspace:
1338 group.add_argument(
1339 "--workspace",
1340 help="Workspace root: its configuration, catalog, and assets, not its "
1341 "runs and studies, which are their own artifacts.",
1342 )
1343 action_parser.add_argument(
1344 "--case-id", dest="case_ids", action="append",
1345 help="One numbered study member, such as case_0003; repeat to select several.",
1346 )
1347 action_parser.add_argument(
1348 "--completed", action="store_true",
1349 help="With --study-dir, select every finished member and skip the rest.",
1350 )
1351
1352 def add_retention_options(parser):
1353 """!
1354 @brief Add the per-component local-retention overrides to one action parser.
1355 @param[in] parser Action parser being configured.
1356 @return None.
1357 """
1358 parser.add_argument(
1359 "--retain", action="append", metavar="COMPONENT",
1360 help="Keep this component local regardless of --policy; repeatable and\n"
1361 "comma-separated. One of: " + ", ".join(STORAGE_RETENTION_COMPONENTS) + ".",
1362 )
1363 parser.add_argument(
1364 "--drop", action="append", metavar="COMPONENT",
1365 help="Prune this component locally regardless of --policy; same names as --retain.",
1366 )
1367
1368 status = actions.add_parser("status", help="Show local, protected, cold, and busy artifact state.")
1369 add_local_target(status)
1370 status.add_argument("--format", dest="output_format", choices=list(CLI_OUTPUT_FORMATS), default="text")
1371
1372 plan = actions.add_parser("plan", help="Show packaging, dependencies, and safety checks without writing.")
1373 add_local_target(plan)
1374 add_profile_options(plan)
1375 plan.add_argument("--compression", choices=list(STORAGE_COMPRESSION_POLICIES))
1376 plan.add_argument("--policy", choices=list(STORAGE_OFFLOAD_POLICIES))
1377 add_retention_options(plan)
1378 plan.add_argument("--workers", type=int)
1379 plan_checkpoint = plan.add_mutually_exclusive_group()
1380 plan_checkpoint.add_argument("--keep-latest-checkpoint", dest="keep_latest_checkpoint", action="store_true")
1381 plan_checkpoint.add_argument("--drop-all-checkpoints", dest="keep_latest_checkpoint", action="store_false")
1382 plan.set_defaults(keep_latest_checkpoint=None)
1383
1384 for name, help_text in (
1385 ("protect", "Upload and verify an archive while retaining all local files."),
1386 ("offload", "Upload and verify an archive, then prune heavy local payload."),
1387 ):
1388 action_parser = actions.add_parser(name, help=help_text)
1389 add_local_target(action_parser)
1390 add_profile_options(action_parser)
1391 action_parser.add_argument(
1392 "--include-inputs", action="store_true",
1393 help="With --workspace, also archive user-supplied files under inputs/.",
1394 )
1395 action_parser.add_argument("--label", help="Human-readable searchable label.")
1396 action_parser.add_argument(
1397 "--notes", help="Free-text note recorded with the archive and shown by `show`."
1398 )
1399 action_parser.add_argument("--tag", dest="tags", action="append", help="Repeatable KEY=VALUE catalog tag.")
1400 action_parser.add_argument("--compression", choices=list(STORAGE_COMPRESSION_POLICIES))
1401 action_parser.add_argument("--policy", choices=list(STORAGE_OFFLOAD_POLICIES))
1402 add_retention_options(action_parser)
1403 action_parser.add_argument("--workers", type=int)
1404 checkpoint_group = action_parser.add_mutually_exclusive_group()
1405 checkpoint_group.add_argument("--keep-latest-checkpoint", dest="keep_latest_checkpoint", action="store_true")
1406 checkpoint_group.add_argument("--drop-all-checkpoints", dest="keep_latest_checkpoint", action="store_false")
1407 action_parser.set_defaults(keep_latest_checkpoint=None)
1408 action_parser.add_argument("--dry-run", action="store_true")
1409
1410 restore = actions.add_parser("restore", help="Restore a complete archive or selected checkpoints.")
1411 restore_source = restore.add_mutually_exclusive_group(required=True)
1412 restore_source.add_argument("--archive-id", help="Globally unique remote archive ID.")
1413 restore_source.add_argument(
1414 "--workspace-id", help="Restore a workspace archive by its recorded workspace identity."
1415 )
1416 restore_source.add_argument("--run-dir", help="Cold run containing a local storage marker.")
1417 restore_source.add_argument("--study-dir", help="Cold study containing a local storage marker.")
1418 restore.add_argument("--case-id", dest="case_ids", action="append")
1419 add_profile_options(restore)
1420 restore.add_argument("--to", dest="destination", help="Optional alternate restore destination.")
1421 restore.add_argument(
1422 "--checkpoint", dest="checkpoints", action="append", type=int,
1423 help="One committed step; repeat to select several.",
1424 )
1425 restore.add_argument(
1426 "--checkpoints", dest="checkpoint_ranges", action="append",
1427 help="An inclusive step range as START:END or START:END:STRIDE; repeatable.",
1428 )
1429 restore.add_argument(
1430 "--component", dest="components", action="append",
1431 choices=STORAGE_RESTORE_COMPONENTS,
1432 help="Restore one semantic component; repeat as needed.",
1433 )
1434 restore.add_argument("--force", action="store_true", help="Allow merge into a non-matching existing destination.")
1435 restore.add_argument("--workers", type=int, help="Parallel download/extraction workers.")
1436
1437 prune = actions.add_parser(
1438 "prune", help="Remove verified local asset objects that nothing local still needs."
1439 )
1440 prune.add_argument("--workspace", help="Workspace root; defaults to discovery from the cwd.")
1441 prune.add_argument(
1442 "--assets", action="store_true", required=True,
1443 help="Select the workspace asset store. Required: prune removes nothing else.",
1444 )
1445 prune.add_argument(
1446 "--unused-locally", action="store_true", required=True,
1447 help="Confirm that only objects with no active local run are removed.",
1448 )
1449 prune.add_argument("--dry-run", action="store_true", help="Report the decision only.")
1450 add_profile_options(prune)
1451
1452 verify = actions.add_parser("verify", help="Verify a remote archive completion marker and chunk checksums.")
1453 verify_source = verify.add_mutually_exclusive_group(required=True)
1454 verify_source.add_argument("--archive-id")
1455 verify_source.add_argument("--run-dir")
1456 verify_source.add_argument("--study-dir")
1457 verify_source.add_argument("--workspace", help="Workspace whose own archive to verify.")
1458 verify.add_argument("--case-id", dest="case_ids", action="append")
1459 add_profile_options(verify)
1460
1461 list_parser = actions.add_parser("list", help="List/search remote archives without local directories.")
1462 add_profile_options(list_parser)
1463 list_parser.add_argument("--search", help="Case-insensitive search across IDs, labels, identities, and tags.")
1464 list_parser.add_argument(
1465 "--workspace-label", help="Show only archives belonging to this workspace identity."
1466 )
1467 list_parser.add_argument("--format", dest="output_format", choices=list(CLI_OUTPUT_FORMATS), default="text")
1468
1469 show = actions.add_parser("show", help="Print the complete manifest for one archive.")
1470 show.add_argument("--archive-id", required=True)
1471 add_profile_options(show)
1472 return parser