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)
276 reusable = _find_reusable_archive(
277 profile, target, fingerprint,
278 compression=plan[
"compression"]
if compression
else None,
280 if reusable
is not None:
286 f
"[INFO] Reusing verified archive {reusable['archive_id']}: "
287 f
"{target['root_path']} is unchanged since it was archived."
289 return _register_existing_archive(
290 target, profile, reusable, inventory, plan, prune_local=prune_local,
291 label=label, tags=tags, notes=notes,
293 archive_id = uuid.uuid4().hex
294 specs = _build_chunk_specs(inventory, profile[
"chunk_size_bytes"])
295 staging_parent = profile.get(
"staging_directory")
297 staging_parent = os.path.abspath(os.path.expanduser(str(staging_parent)))
298 os.makedirs(staging_parent, exist_ok=
True)
303 concurrent_chunks = sorted(
304 (spec[
"uncompressed_bytes"]
for spec
in specs), reverse=
True
307 staging_parent
or tempfile.gettempdir(), sum(concurrent_chunks),
308 f
"stage {target['root_path']}",
310 print(f
"[INFO] Creating archive {archive_id} for {target['root_path']}")
311 with tempfile.TemporaryDirectory(prefix=
"picurv-storage-", dir=staging_parent)
as staging:
312 def package_and_upload(index_spec):
314 @brief Package and verify one component chunk.
315 @param[in] index_spec Tuple of chunk index and inventory specification.
316 @return Uploaded chunk manifest entry.
318 index, spec = index_spec
319 component = _safe_component_name(spec[
"component"])
320 filename = f
"{index:05d}_{component}{_chunk_extension(plan['compression'])}"
321 local_chunk = os.path.join(staging, filename)
323 f
"[INFO] Packaging chunk {index + 1}/{len(specs)}: "
324 f
"{spec['component']} ({_human_bytes(spec['uncompressed_bytes'])})"
326 compressor = _write_tar_chunk(
327 target[
"root_path"], spec, local_chunk, plan[
"compression"],
328 workers=plan[
"workers"],
331 digest = _sha256_file(local_chunk)
335 if _remote_blob_present(profile, digest):
336 print(f
"[INFO] Chunk {index + 1}/{len(specs)} already stored; skipping upload.")
338 "sha256": digest,
"stored_bytes": os.path.getsize(local_chunk),
341 verified = _transport._upload_verified(local_chunk, _blob_remote(profile, digest))
344 "component": spec[
"component"],
345 "file_count": len(spec[
"entries"]),
346 "uncompressed_bytes": spec[
"uncompressed_bytes"],
347 "stored_bytes": verified[
"stored_bytes"],
348 "sha256": verified[
"sha256"],
349 "compressor": compressor,
354 os.remove(local_chunk)
355 except FileNotFoundError:
361 plan[
"compression"]
in _PARALLEL_GZIP_VALUES
and shutil.which(
"pigz")
362 )
or (plan[
"compression"] ==
"maximum" and shutil.which(
"xz"))
363 task_workers = 1
if native_parallel
else min(plan[
"workers"], max(1, len(specs)))
364 if task_workers == 1:
365 chunks = [package_and_upload(item)
for item
in enumerate(specs)]
367 with concurrent.futures.ThreadPoolExecutor(max_workers=task_workers)
as executor:
368 chunks =
list(executor.map(package_and_upload, enumerate(specs)))
369 chunks.sort(key=
lambda item: item[
"name"])
372 "storage_schema_version": STORAGE_SCHEMA_VERSION,
373 "archive_id": archive_id,
374 "created_at": _utc_now(),
375 "artifact_type": target[
"artifact_type"],
376 "run_id": target.get(
"run_id"),
377 "study_id": target.get(
"study_id"),
378 "case_id": target.get(
"case_id"),
379 "workspace_id": target.get(
"workspace_id"),
380 "label": label
or os.path.basename(target[
"root_path"]),
382 "tags": _parse_tags(tags),
385 "parameters": _capture_parameter_summary(target[
"root_path"]),
386 "original_path": target[
"original_path"],
387 "original_study_path": target.get(
"study_path"),
388 "profile": profile[
"name"],
389 "remote": profile[
"remote"],
390 "source_bytes": inventory[
"total_bytes"],
391 "source_file_count": inventory[
"file_count"],
392 "inventory_sha256": fingerprint,
393 "compression": plan[
"compression"],
394 "workers": plan[
"workers"],
395 "offload_policy": plan[
"offload_policy"],
396 "checkpoint_format_version": KNOWN_CHECKPOINT_VERSION,
397 "checkpoint_steps": inventory[
"checkpoint_steps"],
399 "config_sha256": _config_fingerprints(target[
"root_path"]),
400 "git": _git_provenance(target[
"root_path"]),
401 "external_paths": inventory[
"external_paths"],
402 "dependencies": inventory[
"dependencies"],
403 "study_context": _capture_study_context(target),
404 "run_assets": _capture_run_assets(target[
"root_path"]),
405 "workspace_assets": _capture_workspace_assets(target),
408 "continuable": bool(inventory[
"checkpoint_steps"]),
409 "reprocessable": bool(inventory[
"checkpoint_steps"]),
410 "exact_binary_reproduction":
False,
413 manifest_path = os.path.join(staging, REMOTE_MANIFEST_FILENAME)
414 _atomic_write_json(manifest_path, manifest)
415 _transport._upload_verified(manifest_path, _object_remote(profile, archive_id, REMOTE_MANIFEST_FILENAME))
416 manifest_digest = _sha256_file(manifest_path)
417 complete_path = os.path.join(staging, REMOTE_COMPLETE_FILENAME)
418 with open(complete_path,
"w", encoding=
"ascii")
as stream:
419 stream.write(manifest_digest +
"\n")
420 _transport._upload_verified(complete_path, _object_remote(profile, archive_id, REMOTE_COMPLETE_FILENAME))
423 "storage_schema_version": STORAGE_SCHEMA_VERSION,
424 "archive_id": archive_id,
425 "profile": profile[
"name"],
426 "remote": profile[
"remote"],
427 "label": manifest[
"label"],
428 "archived_at": manifest[
"created_at"],
429 "local_pruned":
False,
430 "restored_components": [],
431 "retained_components": [],
433 _atomic_write_json(_state_path(target[
"root_path"]), state)
435 _prune_archived_payload(target[
"root_path"], inventory, plan[
"offload_policy"])
436 state[
"local_pruned"] =
True
437 state[
"pruned_at"] = _utc_now()
438 state[
"offload_policy"] = plan[
"offload_policy"]
439 latest_step = max(inventory[
"checkpoint_steps"], default=
None)
440 retained =
list(plan[
"offload_policy"][
"retained_components"])
441 if plan[
"offload_policy"][
"keep_latest_checkpoint"]
and latest_step
is not None:
442 retained.append(f
"checkpoint:{latest_step}")
443 state[
"retained_components"] = retained
444 _atomic_write_json(_state_path(target[
"root_path"]), state)
446 f
"[SUCCESS] {'Offloaded' if prune_local else 'Protected'} {target['root_path']} "
447 f
"as archive {archive_id}."
537def _download_archive_components(profile: dict, manifest: dict, components: set,
538 destination: str, workers: int =
None) ->
None:
540 @brief Download and safely merge selected semantic archive components.
541 @param[in] profile Active storage profile.
542 @param[in] manifest Verified remote archive manifest.
543 @param[in] components Semantic components to retrieve.
544 @param[in] destination Directory receiving merged content.
545 @param[in] workers Optional parallel worker count.
549 chunk
for chunk
in manifest.get(
"chunks", [])
550 if str(chunk.get(
"component",
""))
in components
554 f
"Archive {manifest.get('archive_id')} has none of the requested components: "
555 +
", ".join(sorted(components))
557 worker_count = int(workers
or profile.get(
"workers", DEFAULT_STORAGE_WORKERS))
558 if worker_count <= 0:
560 os.makedirs(destination, exist_ok=
True)
563 extracted_bytes = sum(chunk.get(
"uncompressed_bytes", 0)
for chunk
in chunks)
565 destination, 2 * extracted_bytes, f
"restore archive {manifest.get('archive_id')} components"
567 with tempfile.TemporaryDirectory(prefix=
"picurv-component-restore-")
as staging:
568 def download_and_extract(index_chunk):
570 @brief Download, verify, and extract one selected chunk.
571 @param[in] index_chunk Tuple of chunk index and manifest entry.
572 @return Chunk index and extraction directory.
574 index, chunk = index_chunk
575 chunk_root = os.path.join(staging, f
"extract-{index:05d}")
576 os.makedirs(chunk_root)
577 local_chunk = os.path.join(staging, chunk[
"name"])
578 _transport._run_rclone([
580 _chunk_remote_path(profile, manifest[
"archive_id"], chunk),
583 actual = _sha256_file(local_chunk)
584 if actual != chunk.get(
"sha256"):
586 f
"Downloaded chunk checksum mismatch: {chunk['name']} "
587 f
"(expected {chunk.get('sha256')}, got {actual})."
589 _extract_chunk(local_chunk, chunk_root)
590 return index, chunk_root
592 task_workers = min(worker_count, len(chunks))
593 if task_workers == 1:
594 extracted = [download_and_extract(item)
for item
in enumerate(chunks)]
596 with concurrent.futures.ThreadPoolExecutor(max_workers=task_workers)
as executor:
597 extracted =
list(executor.map(download_and_extract, enumerate(chunks)))
598 for _, chunk_root
in sorted(extracted):
599 _merge_tree(chunk_root, destination)
604 @brief Recover immutable workspace assets from archived run-local input copies.
605 @param[in] workspace_root Initialized workspace receiving recovered objects.
606 @param[in] provider_spec_hashes Required provider-specification hashes.
607 @return Provider-specification hash to recovered asset-reference mapping.
609 requested = {str(value)
for value
in provider_spec_hashes
if value}
612 workspace = Path(os.path.abspath(workspace_root))
613 (workspace /
"assets").mkdir(parents=
True, exist_ok=
True)
614 profile = load_storage_profile(config_path=str(workspace / STORAGE_CONFIG_FILENAME))
616 for manifest
in sorted(
617 list_remote_manifests(profile), key=
lambda item: item.get(
"created_at",
""), reverse=
True
619 for reference
in manifest.get(
"run_assets", []):
620 spec_hash = reference.get(
"provider_spec_sha256")
621 if spec_hash
in requested
and spec_hash
not in matches:
622 matches[spec_hash] = (manifest, reference)
623 if requested <= set(matches):
630 for spec_hash, (manifest, reference)
in matches.items():
631 group = by_archive.setdefault(manifest[
"archive_id"], {
"manifest": manifest,
"items": []})
632 group[
"items"].append((spec_hash, reference))
635 "initial-condition":
"initial_conditions",
636 "inlet-profiles":
"inlet_profiles",
638 for group
in by_archive.values():
639 with tempfile.TemporaryDirectory(prefix=
".asset-restore-", dir=workspace /
"assets")
as temporary:
640 _download_archive_components(profile, group[
"manifest"], {
"inputs"}, temporary)
641 for spec_hash, reference
in group[
"items"]:
642 kind = reference.get(
"kind")
643 asset_id = reference.get(
"asset_id")
644 if kind
not in kind_directories
or not asset_id:
646 object_relative = f
"assets/objects/{kind_directories[kind]}/{asset_id}"
647 object_root = workspace / object_relative
648 if not object_root.is_dir():
649 object_root.parent.mkdir(parents=
True, exist_ok=
True)
650 object_staging = Path(tempfile.mkdtemp(
651 prefix=f
".{asset_id[:12]}-", dir=object_root.parent
655 for item
in reference.get(
"files", []):
656 source = Path(temporary, *item[
"archived_path"].split(
"/"))
657 if not source.is_file()
or (
658 item.get(
"sha256")
and _sha256_file(str(source)) != item[
"sha256"]
661 f
"Archived asset payload is missing or corrupt: {item['archived_path']}"
663 destination = object_staging /
"payload" / item[
"path"]
664 destination.parent.mkdir(parents=
True, exist_ok=
True)
665 shutil.copy2(source, destination)
667 "path": item[
"path"],
668 "bytes": destination.stat().st_size,
669 "sha256": _sha256_file(str(destination)),
671 _atomic_write_json(str(object_staging /
"asset.json"), {
673 "asset_id": asset_id,
675 "provider": reference.get(
"provider"),
676 "provider_spec_sha256": spec_hash,
677 "recovered_from_archive": group[
"manifest"][
"archive_id"],
681 os.replace(object_staging, object_root)
682 except OSError
as exc:
683 if exc.errno != errno.ENOTEMPTY
and not object_root.is_dir():
686 if object_staging.is_dir():
687 shutil.rmtree(object_staging, ignore_errors=
True)
688 restored[spec_hash] = {
689 "asset_id": asset_id,
691 "provider": reference.get(
"provider"),
692 "provider_spec_sha256": spec_hash,
693 "object": object_relative,
694 "files": reference.get(
"files", []),
696 print(f
"[INFO] Restored shared asset {kind}: {asset_id}")
701 checkpoints=
None, force: bool =
False,
702 workers: int =
None, components=
None) -> dict:
704 @brief Download, verify, extract, and materialize an archive or selected checkpoints.
705 @param[in] profile Value supplied through the `profile` argument.
706 @param[in] archive_id Value supplied through the `archive_id` argument.
707 @param[in] destination Value supplied through the `destination` argument.
708 @param[in] checkpoints Value supplied through the `checkpoints` argument.
709 @param[in] force Value supplied through the `force` argument.
710 @param[in] workers Optional parallel download and extraction worker count.
711 @param[in] components Optional semantic components to restore.
712 @return Result produced by this operation.
714 manifest = _load_remote_manifest(profile, archive_id)
715 original = os.path.abspath(manifest[
"original_path"])
716 destination_abs = os.path.abspath(destination
or original)
717 selected_steps = {int(step)
for step
in (checkpoints
or [])}
718 selected_components = {str(component)
for component
in (components
or [])}
720 for chunk
in manifest.get(
"chunks", []):
721 component = str(chunk.get(
"component",
""))
722 if selected_steps
or selected_components:
723 if component
in ALWAYS_RESTORED_COMPONENTS:
725 elif component.startswith(
"checkpoint:")
and int(component.split(
":", 1)[1])
in selected_steps:
727 elif component
in selected_components:
732 available = set(manifest.get(
"checkpoint_steps", []))
733 missing = selected_steps - available
735 raise StorageError(f
"Archive {archive_id} does not contain checkpoint step(s): {sorted(missing)}")
736 existing_state = read_storage_state(destination_abs)
if os.path.isdir(destination_abs)
else None
737 if os.path.exists(destination_abs)
and not force:
738 if not existing_state
or existing_state.get(
"archive_id") != archive_id:
740 f
"Restore destination already exists and is not the matching cold artifact: {destination_abs}. "
741 "Choose --to or use --force after verifying the destination."
745 already_local = (set(existing_state.get(
"restored_components")
or [])
746 | set(existing_state.get(
"retained_components")
or []))
747 def held_locally(component: str) -> bool:
749 @brief Whether a chunk's component is already present in the destination.
750 @param[in] component Chunk component name.
751 @return True for a restored or retained component; a retained `checkpoints`
752 entry covers every committed step.
754 return component
in already_local
or (
755 component.startswith(
"checkpoint:")
and "checkpoints" in already_local)
757 skipped = [chunk[
"component"]
for chunk
in chunks
758 if chunk.get(
"component")
not in ALWAYS_RESTORED_COMPONENTS
759 and held_locally(str(chunk.get(
"component",
"")))]
761 chunks = [chunk
for chunk
in chunks
if chunk.get(
"component")
not in skipped]
762 print(f
"[INFO] Already local, not downloaded again: {', '.join(skipped)}")
763 parent = os.path.dirname(destination_abs)
764 os.makedirs(parent, exist_ok=
True)
767 extracted_bytes = sum(chunk.get(
"uncompressed_bytes", 0)
for chunk
in chunks)
768 _require_free_space(parent, 2 * extracted_bytes, f
"restore archive {archive_id}")
769 temporary = tempfile.mkdtemp(prefix=f
".picurv-restore-{archive_id[:8]}-", dir=parent)
770 materialized = os.path.join(temporary,
"materialized")
771 os.makedirs(materialized)
774 [f
"checkpoint:{step}" for step
in sorted(selected_steps)]
775 + sorted(selected_components)
777 prior_restored = (existing_state
or {}).get(
"restored_components")
or []
778 worker_count = int(workers
or profile.get(
"workers", DEFAULT_STORAGE_WORKERS))
779 if worker_count <= 0:
782 def download_and_extract(index_chunk):
784 @brief Download, verify, and extract one archive chunk.
785 @param[in] index_chunk Tuple of chunk index and manifest entry.
786 @return Chunk index and extraction directory.
788 index, chunk = index_chunk
789 chunk_root = os.path.join(temporary, f
"extract-{index:05d}")
790 os.makedirs(chunk_root)
791 local_chunk = os.path.join(temporary, chunk[
"name"])
792 print(f
"[INFO] Restoring chunk {index + 1}/{len(chunks)}: {chunk['component']}")
793 _transport._run_rclone([
794 "copyto", _chunk_remote_path(profile, archive_id, chunk), local_chunk,
796 actual = _sha256_file(local_chunk)
797 if actual != chunk.get(
"sha256"):
799 f
"Downloaded chunk checksum mismatch: {chunk['name']} "
800 f
"(expected {chunk.get('sha256')}, got {actual})."
802 _extract_chunk(local_chunk, chunk_root)
803 os.remove(local_chunk)
804 return index, chunk_root
806 task_workers = min(worker_count, max(1, len(chunks)))
807 if task_workers == 1:
808 extracted = [download_and_extract(item)
for item
in enumerate(chunks)]
810 with concurrent.futures.ThreadPoolExecutor(max_workers=task_workers)
as executor:
811 extracted =
list(executor.map(download_and_extract, enumerate(chunks)))
812 for _, chunk_root
in sorted(extracted):
813 _merge_tree(chunk_root, materialized)
815 if os.path.isdir(destination_abs):
816 _merge_tree(materialized, destination_abs)
818 os.replace(materialized, destination_abs)
819 _restore_study_context(manifest, destination_abs)
821 replacements = [(original, destination_abs)]
822 original_study = manifest.get(
"original_study_path")
823 if original_study
and manifest.get(
"artifact_type") ==
"study-case":
824 replacements.append((os.path.abspath(original_study), str(Path(destination_abs).parent.parent)))
825 rebased = _rebase_restored_text_paths(destination_abs, replacements)
827 "storage_schema_version": STORAGE_SCHEMA_VERSION,
828 "archive_id": archive_id,
829 "profile": profile[
"name"],
830 "remote": profile[
"remote"],
831 "label": manifest.get(
"label"),
832 "archived_at": manifest.get(
"created_at"),
833 "restored_at": _utc_now(),
834 "local_pruned": bool(selected_steps
or selected_components),
835 "restored_components": sorted(set(prior_restored) | set(newly_restored)),
836 "retained_components": (existing_state
or {}).get(
"retained_components")
or [],
837 "relocated_from": original
if original != destination_abs
else None,
838 "rebased_files": len(rebased),
840 _atomic_write_json(_state_path(destination_abs), state)
842 if os.path.isdir(temporary):
843 shutil.rmtree(temporary, ignore_errors=
True)
845 if os.path.isdir(temporary):
846 shutil.rmtree(temporary, ignore_errors=
True)
847 print(f
"[SUCCESS] Restored archive {archive_id} to {destination_abs}.")
849 print(f
"[INFO] Rebased {len(rebased)} generated text artifact(s) to the restored path.")
971 @brief Create or update a non-secret workspace storage profile.
972 @param[in] args Value supplied through the `args` argument.
974 explicit_config = getattr(args,
"storage_config",
None)
975 config_path = resolve_storage_config_path(explicit_config, require=
False)
976 workspace_root = storage_workspace_root()
982 if not explicit_config
and workspace_root
and storage_config_origin(config_path, workspace_root) ==
"shared":
983 inherited = config_path
984 config_path = os.path.join(workspace_root, STORAGE_CONFIG_FILENAME)
986 f
"[INFO] A shared configuration exists at {inherited}, but it belongs to a "
987 "directory above this workspace."
990 f
" Writing this workspace's own configuration instead. To edit the "
991 f
"shared one, pass --storage-config {inherited}."
994 if os.path.isfile(config_path):
995 with open(config_path,
"r", encoding=
"utf-8")
as stream:
996 payload = yaml.safe_load(stream)
or {}
997 profiles = payload.setdefault(
"profiles", {})
998 profile_name = args.profile
or DEFAULT_PROFILE_NAME
1000 "remote": args.remote.rstrip(
"/"),
1001 "compression": args.compression,
1002 "chunk_size_gib": args.chunk_size_gib,
1003 "workers": getattr(args,
"workers", DEFAULT_STORAGE_WORKERS),
1004 "offload_policy": getattr(args,
"offload_policy",
"metadata-only"),
1005 "keep_latest_checkpoint": bool(getattr(args,
"keep_latest_checkpoint",
False)),
1007 if args.staging_directory:
1008 profile[
"staging_directory"] = os.path.abspath(os.path.expanduser(args.staging_directory))
1009 payload[
"default_profile"] = profile_name
1010 profiles[profile_name] = profile
1011 print(f
"[INFO] Storage config : {config_path}")
1012 print(f
"[INFO] Profile : {profile_name}")
1013 print(f
"[INFO] Remote : {profile['remote']}")
1015 print(
"[INFO] Dry-run only. No configuration or remote directories were changed.")
1017 _transport._run_rclone([
"mkdir", _remote_join(profile[
"remote"], REMOTE_OBJECTS_DIRECTORY)])
1018 os.makedirs(os.path.dirname(config_path), exist_ok=
True)
1019 temporary = f
"{config_path}.tmp.{os.getpid()}"
1020 with open(temporary,
"w", encoding=
"utf-8")
as stream:
1021 yaml.safe_dump(payload, stream, sort_keys=
False)
1022 os.replace(temporary, config_path)
1023 print(
"[SUCCESS] Storage profile configured and remote access verified.")
1028 @brief Print local storage and lifecycle status for selected artifacts.
1029 @param[in] args Value supplied through the `args` argument.
1031 if args.study_dir
and not args.case_ids:
1032 study_root = os.path.abspath(args.study_dir)
1033 if getattr(args,
"completed",
False):
1034 case_ids = _select_completed_case_ids(study_root)
1037 path.name
for path
in sorted((Path(study_root) /
"cases").glob(
"case_*"))
if path.is_dir()
1039 targets = resolve_local_storage_targets(
None, study_root, case_ids)
if case_ids
else resolve_local_storage_targets(
None, study_root)
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),
1047 inventories = [inspect_artifact(target)
for target
in targets]
1048 if args.output_format ==
"json":
1050 for item
in inventories:
1051 copy_item = dict(item)
1052 copy_item.pop(
"entries",
None)
1053 serializable.append(copy_item)
1054 print(json.dumps(serializable, indent=2, sort_keys=
True))
1056 print(f
"{'ARTIFACT':<36} {'TYPE':<12} {'STATE':<10} {'LOCAL SIZE':>12} LABEL")
1057 for inventory
in inventories:
1058 _render_status(inventory)
1280def add_storage_parser(subparsers) -> argparse.ArgumentParser:
1282 @brief Attach the nested storage command parser to PICurv's top-level parser.
1283 @param[in] subparsers Value supplied through the `subparsers` argument.
1284 @return Result produced by this operation.
1286 parser = subparsers.add_parser(
1288 help=
"Protect, offload, inspect, verify, and restore run/study artifacts.",
1289 formatter_class=argparse.RawTextHelpFormatter,
1291 "Manage PICurv run and study data through a configured rclone remote.\n"
1292 "Remote archives are checksum-verified before local payload can be pruned.\n\n"
1294 " picurv storage setup --remote labstore:picurv-data\n"
1295 " picurv storage status --run-dir runs/my_run\n"
1296 " picurv storage protect --run-dir runs/my_run --label 'baseline'\n"
1297 " picurv storage offload --study-dir studies/my_study --case-id case_0003\n"
1298 " picurv storage list --search '64-grid'\n"
1299 " picurv storage restore --archive-id <id>"
1301 epilog=
"Use `picurv storage <action> --help` for action-specific controls.",
1303 actions = parser.add_subparsers(dest=
"storage_action", required=
True, help=
"Storage action")
1305 setup = actions.add_parser(
"setup", help=
"Configure a non-secret rclone storage profile.")
1306 setup.add_argument(
"--remote", required=
True, help=
"Rclone remote and base path, such as labstore:picurv-data.")
1307 setup.add_argument(
"--profile", default=DEFAULT_PROFILE_NAME, help=
"Profile name (default: archive).")
1308 setup.add_argument(
"--storage-config", help=f
"Storage YAML path (default: ./{STORAGE_CONFIG_FILENAME}).")
1309 setup.add_argument(
"--compression", choices=
list(STORAGE_COMPRESSION_POLICIES), default=
"auto")
1310 setup.add_argument(
"--chunk-size-gib", type=float, default=DEFAULT_CHUNK_SIZE_GIB)
1311 setup.add_argument(
"--workers", type=int, default=DEFAULT_STORAGE_WORKERS,
1312 help=
"CPU workers for compression and restoration.")
1313 setup.add_argument(
"--offload-policy", choices=
list(STORAGE_OFFLOAD_POLICIES), default=
"metadata-only")
1314 setup.add_argument(
"--keep-latest-checkpoint", action=
"store_true",
1315 help=
"Retain the newest committed checkpoint after offload.")
1316 setup.add_argument(
"--staging-directory", help=
"Optional local directory for one archive chunk at a time.")
1317 setup.add_argument(
"--dry-run", action=
"store_true")
1319 def add_profile_options(action_parser):
1321 @brief Attach shared storage-profile selectors to one action parser.
1322 @param[in] action_parser Value supplied through the `action_parser` argument.
1324 action_parser.add_argument(
"--profile", help=
"Configured storage profile name.")
1325 action_parser.add_argument(
"--storage-config", help=
"Explicit storage YAML path.")
1327 def add_local_target(action_parser, require=True, allow_workspace=True):
1329 @brief Attach the standard run/study/workspace target selectors to one parser.
1330 @param[in] action_parser Value supplied through the `action_parser` argument.
1331 @param[in] require Value supplied through the `require` argument.
1332 @param[in] allow_workspace Whether a whole workspace is a valid target here.
1334 group = action_parser.add_mutually_exclusive_group(required=require)
1335 group.add_argument(
"--run-dir", help=
"Standalone run directory.")
1336 group.add_argument(
"--study-dir", help=
"Sweep study directory.")
1340 help=
"Workspace root: its configuration, catalog, and assets, not its "
1341 "runs and studies, which are their own artifacts.",
1343 action_parser.add_argument(
1344 "--case-id", dest=
"case_ids", action=
"append",
1345 help=
"One numbered study member, such as case_0003; repeat to select several.",
1347 action_parser.add_argument(
1348 "--completed", action=
"store_true",
1349 help=
"With --study-dir, select every finished member and skip the rest.",
1352 def add_retention_options(parser):
1354 @brief Add the per-component local-retention overrides to one action parser.
1355 @param[in] parser Action parser being configured.
1358 parser.add_argument(
1359 "--retain", action=
"append", metavar=
"COMPONENT",
1360 help=
"Keep this component local regardless of --policy; repeatable and\n"
1361 "comma-separated. One of: " +
", ".join(STORAGE_RETENTION_COMPONENTS) +
".",
1363 parser.add_argument(
1364 "--drop", action=
"append", metavar=
"COMPONENT",
1365 help=
"Prune this component locally regardless of --policy; same names as --retain.",
1368 status = actions.add_parser(
"status", help=
"Show local, protected, cold, and busy artifact state.")
1369 add_local_target(status)
1370 status.add_argument(
"--format", dest=
"output_format", choices=
list(CLI_OUTPUT_FORMATS), default=
"text")
1372 plan = actions.add_parser(
"plan", help=
"Show packaging, dependencies, and safety checks without writing.")
1373 add_local_target(plan)
1374 add_profile_options(plan)
1375 plan.add_argument(
"--compression", choices=
list(STORAGE_COMPRESSION_POLICIES))
1376 plan.add_argument(
"--policy", choices=
list(STORAGE_OFFLOAD_POLICIES))
1377 add_retention_options(plan)
1378 plan.add_argument(
"--workers", type=int)
1379 plan_checkpoint = plan.add_mutually_exclusive_group()
1380 plan_checkpoint.add_argument(
"--keep-latest-checkpoint", dest=
"keep_latest_checkpoint", action=
"store_true")
1381 plan_checkpoint.add_argument(
"--drop-all-checkpoints", dest=
"keep_latest_checkpoint", action=
"store_false")
1382 plan.set_defaults(keep_latest_checkpoint=
None)
1384 for name, help_text
in (
1385 (
"protect",
"Upload and verify an archive while retaining all local files."),
1386 (
"offload",
"Upload and verify an archive, then prune heavy local payload."),
1388 action_parser = actions.add_parser(name, help=help_text)
1389 add_local_target(action_parser)
1390 add_profile_options(action_parser)
1391 action_parser.add_argument(
1392 "--include-inputs", action=
"store_true",
1393 help=
"With --workspace, also archive user-supplied files under inputs/.",
1395 action_parser.add_argument(
"--label", help=
"Human-readable searchable label.")
1396 action_parser.add_argument(
1397 "--notes", help=
"Free-text note recorded with the archive and shown by `show`."
1399 action_parser.add_argument(
"--tag", dest=
"tags", action=
"append", help=
"Repeatable KEY=VALUE catalog tag.")
1400 action_parser.add_argument(
"--compression", choices=
list(STORAGE_COMPRESSION_POLICIES))
1401 action_parser.add_argument(
"--policy", choices=
list(STORAGE_OFFLOAD_POLICIES))
1402 add_retention_options(action_parser)
1403 action_parser.add_argument(
"--workers", type=int)
1404 checkpoint_group = action_parser.add_mutually_exclusive_group()
1405 checkpoint_group.add_argument(
"--keep-latest-checkpoint", dest=
"keep_latest_checkpoint", action=
"store_true")
1406 checkpoint_group.add_argument(
"--drop-all-checkpoints", dest=
"keep_latest_checkpoint", action=
"store_false")
1407 action_parser.set_defaults(keep_latest_checkpoint=
None)
1408 action_parser.add_argument(
"--dry-run", action=
"store_true")
1410 restore = actions.add_parser(
"restore", help=
"Restore a complete archive or selected checkpoints.")
1411 restore_source = restore.add_mutually_exclusive_group(required=
True)
1412 restore_source.add_argument(
"--archive-id", help=
"Globally unique remote archive ID.")
1413 restore_source.add_argument(
1414 "--workspace-id", help=
"Restore a workspace archive by its recorded workspace identity."
1416 restore_source.add_argument(
"--run-dir", help=
"Cold run containing a local storage marker.")
1417 restore_source.add_argument(
"--study-dir", help=
"Cold study containing a local storage marker.")
1418 restore.add_argument(
"--case-id", dest=
"case_ids", action=
"append")
1419 add_profile_options(restore)
1420 restore.add_argument(
"--to", dest=
"destination", help=
"Optional alternate restore destination.")
1421 restore.add_argument(
1422 "--checkpoint", dest=
"checkpoints", action=
"append", type=int,
1423 help=
"One committed step; repeat to select several.",
1425 restore.add_argument(
1426 "--checkpoints", dest=
"checkpoint_ranges", action=
"append",
1427 help=
"An inclusive step range as START:END or START:END:STRIDE; repeatable.",
1429 restore.add_argument(
1430 "--component", dest=
"components", action=
"append",
1431 choices=STORAGE_RESTORE_COMPONENTS,
1432 help=
"Restore one semantic component; repeat as needed.",
1434 restore.add_argument(
"--force", action=
"store_true", help=
"Allow merge into a non-matching existing destination.")
1435 restore.add_argument(
"--workers", type=int, help=
"Parallel download/extraction workers.")
1437 prune = actions.add_parser(
1438 "prune", help=
"Remove verified local asset objects that nothing local still needs."
1440 prune.add_argument(
"--workspace", help=
"Workspace root; defaults to discovery from the cwd.")
1442 "--assets", action=
"store_true", required=
True,
1443 help=
"Select the workspace asset store. Required: prune removes nothing else.",
1446 "--unused-locally", action=
"store_true", required=
True,
1447 help=
"Confirm that only objects with no active local run are removed.",
1449 prune.add_argument(
"--dry-run", action=
"store_true", help=
"Report the decision only.")
1450 add_profile_options(prune)
1452 verify = actions.add_parser(
"verify", help=
"Verify a remote archive completion marker and chunk checksums.")
1453 verify_source = verify.add_mutually_exclusive_group(required=
True)
1454 verify_source.add_argument(
"--archive-id")
1455 verify_source.add_argument(
"--run-dir")
1456 verify_source.add_argument(
"--study-dir")
1457 verify_source.add_argument(
"--workspace", help=
"Workspace whose own archive to verify.")
1458 verify.add_argument(
"--case-id", dest=
"case_ids", action=
"append")
1459 add_profile_options(verify)
1461 list_parser = actions.add_parser(
"list", help=
"List/search remote archives without local directories.")
1462 add_profile_options(list_parser)
1463 list_parser.add_argument(
"--search", help=
"Case-insensitive search across IDs, labels, identities, and tags.")
1464 list_parser.add_argument(
1465 "--workspace-label", help=
"Show only archives belonging to this workspace identity."
1467 list_parser.add_argument(
"--format", dest=
"output_format", choices=
list(CLI_OUTPUT_FORMATS), default=
"text")
1469 show = actions.add_parser(
"show", help=
"Print the complete manifest for one archive.")
1470 show.add_argument(
"--archive-id", required=
True)
1471 add_profile_options(show)
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.