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

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

◆ _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 504 of file operations.py.

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

◆ _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 532 of file operations.py.

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

◆ 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 597 of file operations.py.

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

◆ 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 695 of file operations.py.

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

828def _expand_checkpoint_selection(explicit, ranges) -> list:
829 """!
830 @brief Combine repeated `--checkpoint` values with `START:END[:STRIDE]` ranges.
831 @param[in] explicit Individually named steps, or None.
832 @param[in] ranges Range expressions, or None.
833 @return Sorted unique step list, or None when nothing was selected.
834 """
835 selected = set(explicit or ())
836 for expression in ranges or ():
837 parts = str(expression).split(":")
838 if len(parts) not in (2, 3):
839 raise StorageError(
840 f"--checkpoints expects START:END or START:END:STRIDE, got {expression!r}."
841 )
842 try:
843 numbers = [int(part) for part in parts]
844 except ValueError:
845 raise StorageError(
846 f"--checkpoints expects integer steps, got {expression!r}."
847 ) from None
848 start, end = numbers[0], numbers[1]
849 stride = numbers[2] if len(numbers) == 3 else 1
850 if stride <= 0:
851 raise StorageError(f"--checkpoints stride must be positive, got {expression!r}.")
852 if end < start:
853 raise StorageError(f"--checkpoints end precedes start in {expression!r}.")
854 selected.update(range(start, end + 1, stride))
855 return sorted(selected) or None
856
857

◆ _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 858 of file operations.py.

858def _resolve_archive_id_from_args(args) -> str:
859 """!
860 @brief Resolve an explicit archive ID or a local artifact marker.
861 @param[in] args Value supplied through the `args` argument.
862 @return Result produced by this operation.
863 """
864 archive_id = getattr(args, "archive_id", None)
865 if archive_id:
866 return archive_id
867 workspace_id = getattr(args, "workspace_id", None)
868 if workspace_id:
869 return resolve_workspace_archive_id(
870 load_storage_profile(
871 getattr(args, "profile", None), getattr(args, "storage_config", None)
872 ),
873 workspace_id,
874 )
875 targets = resolve_local_storage_targets(
876 getattr(args, "run_dir", None), getattr(args, "study_dir", None), getattr(args, "case_ids", None)
877 )
878 if len(targets) != 1:
879 raise StorageError("Restore/verify by local marker requires exactly one target.")
880 state = read_storage_state(targets[0]["root_path"])
881 if not state or not state.get("archive_id"):
882 raise StorageError(f"No storage marker with an archive ID exists under {targets[0]['root_path']}.")
883 return state["archive_id"]
884
885

◆ _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 886 of file operations.py.

886def _render_status(inventory: dict) -> None:
887 """!
888 @brief Render one artifact status row and safety details.
889 @param[in] inventory Value supplied through the `inventory` argument.
890 """
891 target = inventory["target"]
892 storage = inventory["storage"]
893 activity = "BUSY" if inventory["active_locks"] or inventory["slurm"]["active"] else storage["state"]
894 identity = (
895 target.get("case_id") or target.get("run_id") or target.get("study_id")
896 or target.get("workspace_id") or os.path.basename(target["root_path"])
897 )
898 print(
899 f"{identity:<36} {target['artifact_type']:<12} {activity:<10} "
900 f"{_human_bytes(inventory['total_bytes']):>12} {storage.get('label') or ''}"
901 )
902
903

◆ 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 904 of file operations.py.

904def report_storage_configuration(profile: dict) -> dict:
905 """!
906 @brief State which configuration answered, and warn when it came from outside.
907
908 @details Every command below acts on the remote this file names, and `offload`
909 prunes local payload once that remote has verified the upload. Discovery
910 searches upward past the workspace boundary, so the file that answered is
911 not always the one the user thinks they are standing in; naming it here is
912 what keeps an inherited configuration a choice rather than an accident.
913 @param[in] profile Resolved storage profile, carrying its `config_path`.
914 @return The profile, unchanged, so callers can wrap the load.
915 """
916 config_path = profile.get("config_path")
917 origin = storage_config_origin(config_path)
918 print(f"[INFO] Storage config : {config_path}")
919 print(f"[INFO] Storage profile: {profile['name']} -> {profile['remote']}")
920 if origin == "shared":
921 print(
922 f"[WARNING] That configuration is outside this workspace; it is shared with "
923 f"everything below {os.path.dirname(os.path.abspath(config_path))}. Pass "
924 "--storage-config to choose another, or run 'picurv storage setup' here to "
925 "give this workspace its own.",
926 file=sys.stderr,
927 )
928 return profile
929
930
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 931 of file operations.py.

931def load_reported_storage_profile(args) -> dict:
932 """!
933 @brief Load the storage profile a command will act on, and report its origin.
934 @param[in] args Parsed storage command arguments.
935 @return Resolved storage profile.
936 """
937 return report_storage_configuration(
938 load_storage_profile(getattr(args, "profile", None),
939 getattr(args, "storage_config", None))
940 )
941
942
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 943 of file operations.py.

943def storage_setup_workflow(args) -> None:
944 """!
945 @brief Create or update a non-secret workspace storage profile.
946 @param[in] args Value supplied through the `args` argument.
947 """
948 explicit_config = getattr(args, "storage_config", None)
949 config_path = resolve_storage_config_path(explicit_config, require=False)
950 workspace_root = storage_workspace_root()
951 # Discovery walks past the workspace boundary, which is right for reading a shared
952 # configuration and wrong for writing one: `setup` inside a fresh workspace would
953 # otherwise rewrite the remote of the campaign directory above it, silently
954 # re-pointing every other workspace under it. Configure the workspace being stood
955 # in; editing a shared file stays available, but has to be asked for.
956 if not explicit_config and workspace_root and storage_config_origin(config_path, workspace_root) == "shared":
957 inherited = config_path
958 config_path = os.path.join(workspace_root, STORAGE_CONFIG_FILENAME)
959 print(
960 f"[INFO] A shared configuration exists at {inherited}, but it belongs to a "
961 "directory above this workspace."
962 )
963 print(
964 f" Writing this workspace's own configuration instead. To edit the "
965 f"shared one, pass --storage-config {inherited}."
966 )
967 payload = {}
968 if os.path.isfile(config_path):
969 with open(config_path, "r", encoding="utf-8") as stream:
970 payload = yaml.safe_load(stream) or {}
971 profiles = payload.setdefault("profiles", {})
972 profile_name = args.profile or DEFAULT_PROFILE_NAME
973 profile = {
974 "remote": args.remote.rstrip("/"),
975 "compression": args.compression,
976 "chunk_size_gib": args.chunk_size_gib,
977 "workers": getattr(args, "workers", DEFAULT_STORAGE_WORKERS),
978 "offload_policy": getattr(args, "offload_policy", "metadata-only"),
979 "keep_latest_checkpoint": bool(getattr(args, "keep_latest_checkpoint", False)),
980 }
981 if args.staging_directory:
982 profile["staging_directory"] = os.path.abspath(os.path.expanduser(args.staging_directory))
983 payload["default_profile"] = profile_name
984 profiles[profile_name] = profile
985 print(f"[INFO] Storage config : {config_path}")
986 print(f"[INFO] Profile : {profile_name}")
987 print(f"[INFO] Remote : {profile['remote']}")
988 if args.dry_run:
989 print("[INFO] Dry-run only. No configuration or remote directories were changed.")
990 return
991 _transport._run_rclone(["mkdir", _remote_join(profile["remote"], REMOTE_OBJECTS_DIRECTORY)])
992 os.makedirs(os.path.dirname(config_path), exist_ok=True)
993 temporary = f"{config_path}.tmp.{os.getpid()}"
994 with open(temporary, "w", encoding="utf-8") as stream:
995 yaml.safe_dump(payload, stream, sort_keys=False)
996 os.replace(temporary, config_path)
997 print("[SUCCESS] Storage profile configured and remote access verified.")
998
999
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 1000 of file operations.py.

1000def storage_status_workflow(args) -> None:
1001 """!
1002 @brief Print local storage and lifecycle status for selected artifacts.
1003 @param[in] args Value supplied through the `args` argument.
1004 """
1005 if args.study_dir and not args.case_ids:
1006 study_root = os.path.abspath(args.study_dir)
1007 if getattr(args, "completed", False):
1008 case_ids = _select_completed_case_ids(study_root)
1009 else:
1010 case_ids = [
1011 path.name for path in sorted((Path(study_root) / "cases").glob("case_*")) if path.is_dir()
1012 ]
1013 targets = resolve_local_storage_targets(None, study_root, case_ids) if case_ids else resolve_local_storage_targets(None, study_root)
1014 else:
1015 targets = resolve_local_storage_targets(
1016 args.run_dir, args.study_dir, args.case_ids,
1017 workspace=getattr(args, "workspace", None),
1018 include_inputs=getattr(args, "include_inputs", False),
1019 completed=getattr(args, "completed", False),
1020 )
1021 inventories = [inspect_artifact(target) for target in targets]
1022 if args.output_format == "json":
1023 serializable = []
1024 for item in inventories:
1025 copy_item = dict(item)
1026 copy_item.pop("entries", None)
1027 serializable.append(copy_item)
1028 print(json.dumps(serializable, indent=2, sort_keys=True))
1029 return
1030 print(f"{'ARTIFACT':<36} {'TYPE':<12} {'STATE':<10} {'LOCAL SIZE':>12} LABEL")
1031 for inventory in inventories:
1032 _render_status(inventory)
1033
1034
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 1035 of file operations.py.

1035def storage_plan_workflow(args) -> None:
1036 """!
1037 @brief Render a read-only packaging and safety plan.
1038 @param[in] args Value supplied through the `args` argument.
1039 """
1040 profile = load_reported_storage_profile(args)
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 for index, target in enumerate(targets):
1048 if index:
1049 print()
1050 plan = build_storage_plan(
1051 target, profile, args.compression,
1052 policy=getattr(args, "policy", None),
1053 keep_latest_checkpoint=getattr(args, "keep_latest_checkpoint", None),
1054 workers=getattr(args, "workers", None),
1055 retain=getattr(args, "retain", None), drop=getattr(args, "drop", None),
1056 )
1057 _render_plan(plan)
1058 _assert_archive_safe(plan["inventory"])
1059
1060
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 1061 of file operations.py.

1061def storage_archive_workflow(args, prune_local: bool) -> None:
1062 """!
1063 @brief Execute protect or offload for one or more explicit local targets.
1064 @param[in] args Value supplied through the `args` argument.
1065 @param[in] prune_local Value supplied through the `prune_local` argument.
1066 """
1067 profile = load_reported_storage_profile(args)
1068 targets = resolve_local_storage_targets(
1069 args.run_dir, args.study_dir, args.case_ids,
1070 workspace=getattr(args, "workspace", None),
1071 include_inputs=getattr(args, "include_inputs", False),
1072 completed=getattr(args, "completed", False),
1073 )
1074 for target in targets:
1075 if args.dry_run:
1076 _render_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 print("[INFO] Dry-run only. No files were packaged, uploaded, or pruned.")
1084 continue
1085 archive_artifact(
1086 target,
1087 profile,
1088 label=args.label,
1089 tags=args.tags,
1090 compression=args.compression,
1091 prune_local=prune_local,
1092 policy=getattr(args, "policy", None),
1093 keep_latest_checkpoint=getattr(args, "keep_latest_checkpoint", None),
1094 workers=getattr(args, "workers", None),
1095 notes=getattr(args, "notes", None),
1096 retain=getattr(args, "retain", None),
1097 drop=getattr(args, "drop", None),
1098 )
1099
1100
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 1101 of file operations.py.

1101def storage_restore_workflow(args) -> None:
1102 """!
1103 @brief Restore a remote archive by globally unique ID or local marker.
1104 @param[in] args Value supplied through the `args` argument.
1105 """
1106 profile = load_reported_storage_profile(args)
1107 archive_id = _resolve_archive_id_from_args(args)
1108 restore_archive(
1109 profile,
1110 archive_id,
1111 destination=args.destination,
1112 checkpoints=_expand_checkpoint_selection(
1113 args.checkpoints, getattr(args, "checkpoint_ranges", None)
1114 ),
1115 force=args.force,
1116 workers=getattr(args, "workers", None),
1117 components=getattr(args, "components", None),
1118 )
1119
1120
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 1121 of file operations.py.

1121def storage_prune_workflow(args) -> None:
1122 """!
1123 @brief Remove local asset objects nothing local needs and storage has verified.
1124 @param[in] args Value supplied through the `args` argument.
1125 """
1126 profile = load_reported_storage_profile(args)
1127 workspace_root = os.path.abspath(args.workspace) if args.workspace else _find_upwards(
1128 os.getcwd(), WORKSPACE_CONFIG_FILENAME
1129 )
1130 if workspace_root and os.path.isfile(workspace_root):
1131 workspace_root = os.path.dirname(workspace_root)
1132 if not workspace_root:
1133 raise StorageError(
1134 "No initialized workspace found. Pass --workspace, or run inside one."
1135 )
1136 decisions = prune_unused_workspace_assets(workspace_root, profile, dry_run=args.dry_run)
1137 if not decisions:
1138 print("[INFO] The workspace asset store holds no published objects.")
1139 return
1140 for decision in decisions:
1141 print(
1142 f"{decision['asset_id'][:16]} {decision['kind']}\n"
1143 f" referenced by remote runs {decision['cold_runs']}\n"
1144 f" referenced by active local runs {decision['active_local_runs']}\n"
1145 f" remote protection {decision['remote_protection']}\n"
1146 f" local removal {decision['local_removal']}"
1147 )
1148 removed = [item for item in decisions if item.get("removed")]
1149 if args.dry_run:
1150 safe = [item for item in decisions if item["local_removal"] == "safe"]
1151 print(f"[INFO] Dry-run only. {len(safe)} object(s) would be removed.")
1152 else:
1153 print(f"[SUCCESS] Removed {len(removed)} local asset object(s).")
1154
1155
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 1156 of file operations.py.

1156def storage_verify_workflow(args) -> None:
1157 """!
1158 @brief Verify all remote chunks for one archive.
1159 @param[in] args Value supplied through the `args` argument.
1160 """
1161 profile = load_reported_storage_profile(args)
1162 archive_id = _resolve_archive_id_from_args(args)
1163 manifest = verify_remote_archive(profile, archive_id)
1164 print(
1165 f"[SUCCESS] Archive {archive_id} is complete; verified {len(manifest.get('chunks', []))} chunk(s)."
1166 )
1167
1168
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 1169 of file operations.py.

1169def storage_list_workflow(args) -> None:
1170 """!
1171 @brief Search the remote manifest catalog without local artifact state.
1172 @param[in] args Value supplied through the `args` argument.
1173 """
1174 profile = load_reported_storage_profile(args)
1175 manifests = list_remote_manifests(profile)
1176 workspace_label = getattr(args, "workspace_label", None)
1177 if workspace_label:
1178 manifests = [
1179 item for item in manifests
1180 if str(item.get("workspace_id") or "") == workspace_label
1181 ]
1182 query = str(args.search or "").lower()
1183 if query:
1184 manifests = [
1185 item for item in manifests
1186 if query in " ".join(
1187 str(item.get(key, "")) for key in (
1188 "archive_id", "label", "notes", "run_id", "study_id", "case_id",
1189 "workspace_id", "tags",
1190 )
1191 ).lower()
1192 ]
1193 if args.output_format == "json":
1194 print(json.dumps(manifests, indent=2, sort_keys=True))
1195 return
1196 print(f"{'ARCHIVE ID':<34} {'TYPE':<12} {'IDENTITY':<32} LABEL")
1197 for item in manifests:
1198 identity = (
1199 item.get("case_id") or item.get("run_id") or item.get("study_id")
1200 or item.get("workspace_id") or "-"
1201 )
1202 print(f"{item['archive_id']:<34} {item.get('artifact_type', '-'):<12} {identity:<32} {item.get('label', '')}")
1203
1204
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 1205 of file operations.py.

1205def storage_show_workflow(args) -> None:
1206 """!
1207 @brief Show one complete remote archive manifest.
1208 @param[in] args Value supplied through the `args` argument.
1209 """
1210 profile = load_reported_storage_profile(args)
1211 manifest = _load_remote_manifest(profile, args.archive_id)
1212 print(json.dumps(manifest, indent=2, sort_keys=True))
1213
1214
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 1215 of file operations.py.

1215def storage_workflow(args) -> None:
1216 """!
1217 @brief Dispatch nested storage actions using existing PICurv workflow conventions.
1218 @param[in] args Value supplied through the `args` argument.
1219 """
1220 try:
1221 action = args.storage_action
1222 if action == "setup":
1223 storage_setup_workflow(args)
1224 elif action == "status":
1225 storage_status_workflow(args)
1226 elif action == "plan":
1227 storage_plan_workflow(args)
1228 elif action == "protect":
1229 storage_archive_workflow(args, prune_local=False)
1230 elif action == "offload":
1231 storage_archive_workflow(args, prune_local=True)
1232 elif action == "restore":
1233 storage_restore_workflow(args)
1234 elif action == "prune":
1235 storage_prune_workflow(args)
1236 elif action == "verify":
1237 storage_verify_workflow(args)
1238 elif action == "list":
1239 storage_list_workflow(args)
1240 elif action == "show":
1241 storage_show_workflow(args)
1242 else:
1243 raise StorageError(f"Unsupported storage action: {action}")
1244 except StorageError as exc:
1245 print(f"[FATAL] {exc}", file=sys.stderr)
1246 raise SystemExit(1)
1247
1248
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 1249 of file operations.py.

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