3@brief Storage constants, profiles, and the local artifact state marker.
8import concurrent.futures
23from pathlib
import Path
27STORAGE_CONFIG_FILENAME =
".picurv-storage.yml"
30STORAGE_STATE_FILENAME =
".picurv-storage.json"
33STORAGE_LOCK_FILENAME =
".picurv-storage.lock.json"
38STORAGE_SCHEMA_VERSION = 2
41REMOTE_OBJECTS_DIRECTORY =
"objects"
45REMOTE_BLOBS_DIRECTORY =
"blobs"
48REMOTE_MANIFEST_FILENAME =
"manifest.json"
53STORAGE_ARTIFACT_TYPES = (
"run",
"study",
"study-case")
60RUN_MANIFEST_FILENAME =
"manifest.json"
64STUDY_MANIFEST_FILENAME =
"study_manifest.json"
67REMOTE_COMPLETE_FILENAME =
"COMPLETE"
70DEFAULT_PROFILE_NAME =
"archive"
73DEFAULT_CHUNK_SIZE_GIB = 8.0
76DEFAULT_STORAGE_WORKERS = max(1, min(8, os.cpu_count()
or 1))
79AUTO_NO_COMPRESSION_BYTES = 256 * 1024 * 1024
82AUTO_MAXIMUM_COMPRESSION_BYTES = 20 * 1024 * 1024 * 1024
85CHECKPOINT_DIRECTORY_PATTERN = re.compile(
r"^step_(\d{12})$")
88INCOMPLETE_CHECKPOINT_PATTERN = re.compile(
r"^\.step_\d{12}\.incomplete\.")
91ARCHIVE_ID_PATTERN = re.compile(
r"^[0-9a-f]{32}$")
94KNOWN_CHECKPOINT_VERSION = 1
99UNCLASSIFIED_COMPONENT =
"unclassified"
104WORKSPACE_CONFIG_FILENAME =
".picurv-workspace.yml"
110WORKSPACE_EXCLUDED_ROOTS = (
"runs",
"studies")
118ALWAYS_RETAINED_COMPONENTS = frozenset(
119 {UNCLASSIFIED_COMPONENT,
"workspace-config",
"workspace-inputs",
"assets"}
123STORAGE_RESTORE_COMPONENTS = (
124 "inputs",
"raw-output",
"analysis",
"visualization",
"logs",
"assets",
125 UNCLASSIFIED_COMPONENT,
"workspace-config",
"workspace-inputs",
133ALWAYS_RESTORED_COMPONENTS = frozenset({
"metadata",
"workspace-config"})
136_PARALLEL_GZIP_VALUES = {
"fast",
"balanced"}
140 """! @brief User-facing storage workflow failure. """
145 @brief Return a stable UTC timestamp for storage metadata.
146 @return Result produced by this operation.
148 return datetime.datetime.now(datetime.timezone.utc).isoformat()
151def _human_bytes(value: int) -> str:
153 @brief Format a byte count for concise command output.
154 @param[in] value Value supplied through the `value` argument.
155 @return Result produced by this operation.
158 for suffix
in (
"B",
"KiB",
"MiB",
"GiB",
"TiB",
"PiB"):
159 if size < 1024.0
or suffix ==
"PiB":
160 return f
"{size:.1f} {suffix}" if suffix !=
"B" else f
"{int(size)} B"
162 return f
"{int(value)} B"
165def _sha256_file(path: str) -> str:
167 @brief Calculate SHA-256 without loading a potentially large file into memory.
168 @param[in] path Value supplied through the `path` argument.
169 @return Result produced by this operation.
171 digest = hashlib.sha256()
172 with open(path,
"rb")
as stream:
174 block = stream.read(8 * 1024 * 1024)
178 return digest.hexdigest()
181def _atomic_write_json(path: str, payload: dict) ->
None:
183 @brief Atomically replace a JSON state or manifest file.
184 @param[in] path Value supplied through the `path` argument.
185 @param[in] payload Value supplied through the `payload` argument.
187 path_abs = os.path.abspath(path)
188 os.makedirs(os.path.dirname(path_abs), exist_ok=
True)
189 temporary = f
"{path_abs}.tmp.{os.getpid()}"
190 with open(temporary,
"w", encoding=
"utf-8")
as stream:
191 json.dump(payload, stream, indent=2, sort_keys=
True)
194 os.fsync(stream.fileno())
195 os.replace(temporary, path_abs)
198def _read_json(path: str):
200 @brief Read a JSON mapping when present, otherwise return None.
201 @param[in] path Value supplied through the `path` argument.
202 @return Result produced by this operation.
205 with open(path,
"r", encoding=
"utf-8")
as stream:
206 payload = json.load(stream)
207 except (OSError, ValueError):
209 return payload
if isinstance(payload, dict)
else None
214 @brief Read a local artifact's own identity manifest, run or study.
215 @param[in] root_path Run, study, or study-member directory.
216 @return The parsed manifest mapping, or None when the directory has none.
218 root = os.path.abspath(root_path)
219 for filename
in (RUN_MANIFEST_FILENAME, STUDY_MANIFEST_FILENAME):
220 payload = _read_json(os.path.join(root, filename))
226def read_artifact_identity(root_path: str) -> dict:
228 @brief Resolve what a local artifact directory is, and what it calls itself.
230 @details The manifest is authoritative. The directory basename is a last resort,
231 reported as such through `identity_source`, so a caller can tell a real
232 identity from a guess instead of both looking the same.
233 @param[in] root_path Run, study, or study-member directory.
234 @return Mapping with `artifact_type`, `run_id`, `study_id`, `case_id`, and
235 `identity_source` ("manifest" or "directory-name").
237 root = os.path.abspath(root_path)
240 artifact_type = manifest.get(
"artifact_type")
241 if artifact_type
in STORAGE_ARTIFACT_TYPES:
243 "artifact_type": artifact_type,
244 "run_id": manifest.get(
"run_id"),
245 "study_id": manifest.get(
"study_id"),
246 "case_id": manifest.get(
"case_id"),
247 "paths": manifest.get(
"paths")
or {},
248 "identity_source":
"manifest",
251 "artifact_type":
None,
252 "run_id": os.path.basename(root),
253 "study_id": os.path.basename(root),
256 "identity_source":
"directory-name",
261def _find_upwards(start: str, filename: str):
263 @brief Find the nearest named file at or above a filesystem anchor.
264 @param[in] start Value supplied through the `start` argument.
265 @param[in] filename Value supplied through the `filename` argument.
266 @return Result produced by this operation.
268 current = os.path.abspath(start)
269 if os.path.isfile(current):
270 current = os.path.dirname(current)
272 candidate = os.path.join(current, filename)
273 if os.path.isfile(candidate):
275 parent = os.path.dirname(current)
276 if parent == current:
281def storage_workspace_root(start: str =
None) -> str:
283 @brief Locate the initialized workspace a storage command is standing in.
284 @param[in] start Directory to search from; defaults to the current directory.
285 @return Absolute workspace root, or None when the command is not inside one.
287 marker = _find_upwards(start
or os.getcwd(), WORKSPACE_CONFIG_FILENAME)
288 return os.path.dirname(marker)
if marker
else None
291def storage_config_origin(config_path: str, workspace_root: str =
None) -> str:
293 @brief Classify where an active storage configuration came from.
295 @details Discovery walks upward without stopping at the workspace boundary, which
296 is deliberate: one configuration is meant to be shareable across a
297 directory of campaigns. What makes that dangerous is silence, not sharing.
298 An offload uploads to the remote this file names and then prunes local
299 payload, so which file answered has to be visible at the point of use.
300 @param[in] config_path Resolved storage configuration path.
301 @param[in] workspace_root Owning workspace, or None to discover it from the cwd.
302 @return "workspace" when the file belongs to the workspace in scope, "shared" when
303 it was inherited from above it, and "unowned" when there is no workspace.
307 root = workspace_root
if workspace_root
is not None else storage_workspace_root()
310 resolved = os.path.abspath(config_path)
311 root = os.path.abspath(root)
312 return "workspace" if os.path.dirname(resolved) == root
else "shared"
315def resolve_storage_config_path(explicit_path: str =
None, require: bool =
True) -> str:
317 @brief Resolve an explicit or nearest workspace storage configuration.
318 @param[in] explicit_path Optional user-selected YAML path.
319 @param[in] require Whether a missing configuration is an error.
320 @return Result produced by this operation.
323 result = os.path.abspath(explicit_path)
325 result = _find_upwards(os.getcwd(), STORAGE_CONFIG_FILENAME)
326 if result
and os.path.isfile(result):
330 "No PICurv storage configuration was found. Run "
331 "'picurv storage setup --remote <rclone-remote:path>' first or pass --storage-config."
333 return os.path.abspath(explicit_path
or os.path.join(os.getcwd(), STORAGE_CONFIG_FILENAME))
336def load_storage_profile(profile_name: str =
None, config_path: str =
None) -> dict:
338 @brief Load and validate one non-secret rclone storage profile.
339 @param[in] profile_name Value supplied through the `profile_name` argument.
340 @param[in] config_path Value supplied through the `config_path` argument.
341 @return Result produced by this operation.
343 resolved_config = resolve_storage_config_path(config_path)
344 with open(resolved_config,
"r", encoding=
"utf-8")
as stream:
345 payload = yaml.safe_load(stream)
or {}
346 profiles = payload.get(
"profiles")
347 if not isinstance(profiles, dict):
348 raise StorageError(f
"Storage config has no 'profiles' mapping: {resolved_config}")
349 selected = profile_name
or payload.get(
"default_profile")
or DEFAULT_PROFILE_NAME
350 profile = profiles.get(selected)
351 if not isinstance(profile, dict):
352 raise StorageError(f
"Storage profile '{selected}' does not exist in {resolved_config}.")
353 remote = profile.get(
"remote")
354 if not isinstance(remote, str)
or not remote.strip():
355 raise StorageError(f
"Storage profile '{selected}' requires a non-empty remote.")
356 result = dict(profile)
357 result[
"name"] = selected
358 result[
"remote"] = remote.rstrip(
"/")
359 result[
"config_path"] = resolved_config
361 chunk_size_gib = float(result.get(
"chunk_size_gib", DEFAULT_CHUNK_SIZE_GIB))
362 except (TypeError, ValueError)
as exc:
363 raise StorageError(f
"Storage profile '{selected}' chunk_size_gib must be numeric.")
from exc
364 if chunk_size_gib <= 0.0:
365 raise StorageError(f
"Storage profile '{selected}' chunk_size_gib must be positive.")
366 result[
"chunk_size_bytes"] = int(chunk_size_gib * 1024 ** 3)
368 workers = int(result.get(
"workers", DEFAULT_STORAGE_WORKERS))
369 except (TypeError, ValueError)
as exc:
370 raise StorageError(f
"Storage profile '{selected}' workers must be an integer.")
from exc
372 raise StorageError(f
"Storage profile '{selected}' workers must be positive.")
373 result[
"workers"] = workers
374 offload_policy = str(result.get(
"offload_policy",
"metadata-only"))
375 if offload_policy
not in STORAGE_OFFLOAD_POLICIES:
377 f
"Storage profile '{selected}' offload_policy must be one of: "
378 +
", ".join(STORAGE_OFFLOAD_POLICIES)
380 result[
"offload_policy"] = offload_policy
381 result[
"keep_latest_checkpoint"] = bool(result.get(
"keep_latest_checkpoint",
False))
385def _state_path(root_path: str) -> str:
387 @brief Return the local storage state marker path for an artifact root.
388 @param[in] root_path Value supplied through the `root_path` argument.
389 @return Result produced by this operation.
391 return os.path.join(os.path.abspath(root_path), STORAGE_STATE_FILENAME)
394def read_storage_state(root_path: str):
396 @brief Read the nearest applicable storage marker for a run, study, or study member.
397 @param[in] root_path Value supplied through the `root_path` argument.
398 @return Result produced by this operation.
400 root = Path(os.path.abspath(root_path))
401 state = _read_json(_state_path(str(root)))
407 if root.parent.name ==
"cases":
408 return _read_json(_state_path(str(root.parent.parent)))
412def is_artifact_cold(root_path: str) -> bool:
414 @brief Return whether a local artifact marker says payload data was pruned.
415 @param[in] root_path Value supplied through the `root_path` argument.
416 @return Result produced by this operation.
418 state = read_storage_state(root_path)
419 return bool(state
and state.get(
"local_pruned"))
422def storage_state_summary(root_path: str) -> dict:
424 @brief Return a compact storage status for summarize and status commands.
425 @param[in] root_path Value supplied through the `root_path` argument.
426 @return Result produced by this operation.
428 state = read_storage_state(root_path)
432 if os.path.exists(_state_path(root_path)):
433 return {
"state":
"BROKEN",
"archive_id":
None,
"label":
None,
434 "detail":
"storage marker is present but unreadable"}
435 return {
"state":
"LOCAL",
"archive_id":
None,
"label":
None}
436 if not state.get(
"archive_id"):
437 return {
"state":
"BROKEN",
"archive_id":
None,
"label": state.get(
"label"),
438 "detail":
"storage marker names no archive"}
439 if state.get(
"local_pruned"):
440 status =
"PARTIAL" if state.get(
"restored_components")
else "COLD"
445 "archive_id": state.get(
"archive_id"),
446 "label": state.get(
"label"),
447 "profile": state.get(
"profile"),
448 "remote": state.get(
"remote"),
453CLI_OUTPUT_FORMATS = (
"text",
"json")
458STORAGE_COMPRESSION_POLICIES = (
"auto",
"none",
"fast",
"balanced",
"maximum")
461STORAGE_OFFLOAD_POLICIES = (
"metadata-only",
"restart-ready",
"analysis-ready")
470STORAGE_RETENTION_COMPONENTS = (
471 "checkpoints",
"logs",
"analysis",
"visualization",
"inputs",
"raw-output",
477STORAGE_COMPRESSION_EXTENSIONS = {
478 "none":
".tar",
"fast":
".tar.gz",
"balanced":
".tar.gz",
"maximum":
".tar.xz",
482def _parse_tags(raw_tags) -> dict:
484 @brief Parse repeatable KEY=VALUE tags into deterministic metadata.
485 @param[in] raw_tags Value supplied through the `raw_tags` argument.
486 @return Result produced by this operation.
489 for item
in raw_tags
or []:
490 key, separator, value = str(item).partition(
"=")
491 if not separator
or not key.strip()
or not value.strip():
492 raise StorageError(f
"Invalid tag {item!r}; expected KEY=VALUE.")
493 tags[key.strip()] = value.strip()
User-facing storage workflow failure.
read_artifact_manifest(str root_path)
Read a local artifact's own identity manifest, run or study.
str _utc_now()
Return a stable UTC timestamp for storage metadata.