138 policy: str =
None, keep_latest_checkpoint=
None,
139 workers: int =
None, retain=
None, drop=
None) -> dict:
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.
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)
161 worker_count = int(workers
or profile.get(
"workers", DEFAULT_STORAGE_WORKERS))
162 if worker_count <= 0:
164 compressed_range = _compression_size_range(inventory[
"total_bytes"], selected_compression)
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]},
176 "component": spec[
"component"],
177 "file_count": len(spec[
"entries"]),
178 "uncompressed_bytes": spec[
"uncompressed_bytes"],
246 compression: str =
None, prune_local: bool =
False,
247 policy: str =
None, keep_latest_checkpoint=
None,
248 workers: int =
None, notes: str =
None, retain=
None, drop=
None) -> dict:
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.
265 with storage_operation_lock(target[
"root_path"],
"offload" if prune_local
else "protect"):
267 target, profile, compression, policy=policy,
268 keep_latest_checkpoint=keep_latest_checkpoint, workers=workers,
269 retain=retain, drop=drop,
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:
281 f
"[INFO] Reusing verified archive {reusable['archive_id']}: "
282 f
"{target['root_path']} is unchanged since it was archived."
284 return _register_existing_archive(
285 target, profile, reusable, inventory, plan, prune_local=prune_local,
286 label=label, tags=tags, notes=notes,
288 archive_id = uuid.uuid4().hex
289 specs = _build_chunk_specs(inventory, profile[
"chunk_size_bytes"])
290 staging_parent = profile.get(
"staging_directory")
292 staging_parent = os.path.abspath(os.path.expanduser(str(staging_parent)))
293 os.makedirs(staging_parent, exist_ok=
True)
298 concurrent_chunks = sorted(
299 (spec[
"uncompressed_bytes"]
for spec
in specs), reverse=
True
302 staging_parent
or tempfile.gettempdir(), sum(concurrent_chunks),
303 f
"stage {target['root_path']}",
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):
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.
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)
318 f
"[INFO] Packaging chunk {index + 1}/{len(specs)}: "
319 f
"{spec['component']} ({_human_bytes(spec['uncompressed_bytes'])})"
321 compressor = _write_tar_chunk(
322 target[
"root_path"], spec, local_chunk, plan[
"compression"],
323 workers=plan[
"workers"],
326 digest = _sha256_file(local_chunk)
330 if _remote_blob_present(profile, digest):
331 print(f
"[INFO] Chunk {index + 1}/{len(specs)} already stored; skipping upload.")
333 "sha256": digest,
"stored_bytes": os.path.getsize(local_chunk),
336 verified = _transport._upload_verified(local_chunk, _blob_remote(profile, digest))
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,
349 os.remove(local_chunk)
350 except FileNotFoundError:
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)]
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"])
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"]),
377 "tags": _parse_tags(tags),
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"],
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),
403 "continuable": bool(inventory[
"checkpoint_steps"]),
404 "reprocessable": bool(inventory[
"checkpoint_steps"]),
405 "exact_binary_reproduction":
False,
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))
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": [],
428 _atomic_write_json(_state_path(target[
"root_path"]), state)
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)
441 f
"[SUCCESS] {'Offloaded' if prune_local else 'Protected'} {target['root_path']} "
442 f
"as archive {archive_id}."
532def _download_archive_components(profile: dict, manifest: dict, components: set,
533 destination: str, workers: int =
None) ->
None:
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.
544 chunk
for chunk
in manifest.get(
"chunks", [])
545 if str(chunk.get(
"component",
""))
in components
549 f
"Archive {manifest.get('archive_id')} has none of the requested components: "
550 +
", ".join(sorted(components))
552 worker_count = int(workers
or profile.get(
"workers", DEFAULT_STORAGE_WORKERS))
553 if worker_count <= 0:
555 os.makedirs(destination, exist_ok=
True)
558 extracted_bytes = sum(chunk.get(
"uncompressed_bytes", 0)
for chunk
in chunks)
560 destination, 2 * extracted_bytes, f
"restore archive {manifest.get('archive_id')} components"
562 with tempfile.TemporaryDirectory(prefix=
"picurv-component-restore-")
as staging:
563 def download_and_extract(index_chunk):
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.
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([
575 _chunk_remote_path(profile, manifest[
"archive_id"], chunk),
578 actual = _sha256_file(local_chunk)
579 if actual != chunk.get(
"sha256"):
581 f
"Downloaded chunk checksum mismatch: {chunk['name']} "
582 f
"(expected {chunk.get('sha256')}, got {actual})."
584 _extract_chunk(local_chunk, chunk_root)
585 return index, chunk_root
587 task_workers = min(worker_count, len(chunks))
588 if task_workers == 1:
589 extracted = [download_and_extract(item)
for item
in enumerate(chunks)]
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)
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.
604 requested = {str(value)
for value
in provider_spec_hashes
if value}
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))
611 for manifest
in sorted(
612 list_remote_manifests(profile), key=
lambda item: item.get(
"created_at",
""), reverse=
True
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):
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))
630 "initial-condition":
"initial_conditions",
631 "inlet-profiles":
"inlet_profiles",
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:
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
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"]
656 f
"Archived asset payload is missing or corrupt: {item['archived_path']}"
658 destination = object_staging /
"payload" / item[
"path"]
659 destination.parent.mkdir(parents=
True, exist_ok=
True)
660 shutil.copy2(source, destination)
662 "path": item[
"path"],
663 "bytes": destination.stat().st_size,
664 "sha256": _sha256_file(str(destination)),
666 _atomic_write_json(str(object_staging /
"asset.json"), {
668 "asset_id": asset_id,
670 "provider": reference.get(
"provider"),
671 "provider_spec_sha256": spec_hash,
672 "recovered_from_archive": group[
"manifest"][
"archive_id"],
676 os.replace(object_staging, object_root)
677 except OSError
as exc:
678 if exc.errno != errno.ENOTEMPTY
and not object_root.is_dir():
681 if object_staging.is_dir():
682 shutil.rmtree(object_staging, ignore_errors=
True)
683 restored[spec_hash] = {
684 "asset_id": asset_id,
686 "provider": reference.get(
"provider"),
687 "provider_spec_sha256": spec_hash,
688 "object": object_relative,
689 "files": reference.get(
"files", []),
691 print(f
"[INFO] Restored shared asset {kind}: {asset_id}")
696 checkpoints=
None, force: bool =
False,
697 workers: int =
None, components=
None) -> dict:
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.
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 [])}
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:
720 elif component.startswith(
"checkpoint:")
and int(component.split(
":", 1)[1])
in selected_steps:
722 elif component
in selected_components:
727 available = set(manifest.get(
"checkpoint_steps", []))
728 missing = selected_steps - available
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:
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."
738 parent = os.path.dirname(destination_abs)
739 os.makedirs(parent, exist_ok=
True)
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)
749 [f
"checkpoint:{step}" for step
in sorted(selected_steps)]
750 + sorted(selected_components)
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:
757 def download_and_extract(index_chunk):
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.
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,
771 actual = _sha256_file(local_chunk)
772 if actual != chunk.get(
"sha256"):
774 f
"Downloaded chunk checksum mismatch: {chunk['name']} "
775 f
"(expected {chunk.get('sha256')}, got {actual})."
777 _extract_chunk(local_chunk, chunk_root)
778 os.remove(local_chunk)
779 return index, chunk_root
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)]
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)
790 if os.path.isdir(destination_abs):
791 _merge_tree(materialized, destination_abs)
793 os.replace(materialized, destination_abs)
794 _restore_study_context(manifest, destination_abs)
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)
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),
815 _atomic_write_json(_state_path(destination_abs), state)
817 if os.path.isdir(temporary):
818 shutil.rmtree(temporary, ignore_errors=
True)
820 if os.path.isdir(temporary):
821 shutil.rmtree(temporary, ignore_errors=
True)
822 print(f
"[SUCCESS] Restored archive {archive_id} to {destination_abs}.")
824 print(f
"[INFO] Rebased {len(rebased)} generated text artifact(s) to the restored path.")
945 @brief Create or update a non-secret workspace storage profile.
946 @param[in] args Value supplied through the `args` argument.
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()
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)
960 f
"[INFO] A shared configuration exists at {inherited}, but it belongs to a "
961 "directory above this workspace."
964 f
" Writing this workspace's own configuration instead. To edit the "
965 f
"shared one, pass --storage-config {inherited}."
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
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)),
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']}")
989 print(
"[INFO] Dry-run only. No configuration or remote directories were changed.")
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.")
1002 @brief Print local storage and lifecycle status for selected artifacts.
1003 @param[in] args Value supplied through the `args` argument.
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)
1011 path.name
for path
in sorted((Path(study_root) /
"cases").glob(
"case_*"))
if path.is_dir()
1013 targets = resolve_local_storage_targets(
None, study_root, case_ids)
if case_ids
else resolve_local_storage_targets(
None, study_root)
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),
1021 inventories = [inspect_artifact(target)
for target
in targets]
1022 if args.output_format ==
"json":
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))
1030 print(f
"{'ARTIFACT':<36} {'TYPE':<12} {'STATE':<10} {'LOCAL SIZE':>12} LABEL")
1031 for inventory
in inventories:
1032 _render_status(inventory)
1249def add_storage_parser(subparsers) -> argparse.ArgumentParser:
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.
1255 parser = subparsers.add_parser(
1257 help=
"Protect, offload, inspect, verify, and restore run/study artifacts.",
1258 formatter_class=argparse.RawTextHelpFormatter,
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"
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>"
1270 epilog=
"Use `picurv storage <action> --help` for action-specific controls.",
1272 actions = parser.add_subparsers(dest=
"storage_action", required=
True, help=
"Storage action")
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")
1288 def add_profile_options(action_parser):
1290 @brief Attach shared storage-profile selectors to one action parser.
1291 @param[in] action_parser Value supplied through the `action_parser` argument.
1293 action_parser.add_argument(
"--profile", help=
"Configured storage profile name.")
1294 action_parser.add_argument(
"--storage-config", help=
"Explicit storage YAML path.")
1296 def add_local_target(action_parser, require=True, allow_workspace=True):
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.
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.")
1309 help=
"Workspace root: its configuration, catalog, and assets, not its "
1310 "runs and studies, which are their own artifacts.",
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.",
1316 action_parser.add_argument(
1317 "--completed", action=
"store_true",
1318 help=
"With --study-dir, select every finished member and skip the rest.",
1321 def add_retention_options(parser):
1323 @brief Add the per-component local-retention overrides to one action parser.
1324 @param[in] parser Action parser being configured.
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) +
".",
1332 parser.add_argument(
1333 "--drop", action=
"append", metavar=
"COMPONENT",
1334 help=
"Prune this component locally regardless of --policy; same names as --retain.",
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")
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)
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."),
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/.",
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`."
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")
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."
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.",
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.",
1398 restore.add_argument(
1399 "--component", dest=
"components", action=
"append",
1400 choices=STORAGE_RESTORE_COMPONENTS,
1401 help=
"Restore one semantic component; repeat as needed.",
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.")
1406 prune = actions.add_parser(
1407 "prune", help=
"Remove verified local asset objects that nothing local still needs."
1409 prune.add_argument(
"--workspace", help=
"Workspace root; defaults to discovery from the cwd.")
1411 "--assets", action=
"store_true", required=
True,
1412 help=
"Select the workspace asset store. Required: prune removes nothing else.",
1415 "--unused-locally", action=
"store_true", required=
True,
1416 help=
"Confirm that only objects with no active local run are removed.",
1418 prune.add_argument(
"--dry-run", action=
"store_true", help=
"Report the decision only.")
1419 add_profile_options(prune)
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)
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."
1435 list_parser.add_argument(
"--format", dest=
"output_format", choices=
list(CLI_OUTPUT_FORMATS), default=
"text")
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)
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.