46def _completed_study_members(study_root: str) -> list:
48 @brief Select the study members that are finished and idle.
50 @details "Finished" is deliberately conservative: a member qualifies only when it
51 holds at least one committed checkpoint, is writing none, and has no
52 active lock or scheduler job. Anything ambiguous is skipped rather than
54 @param[in] study_root Study directory to scan.
55 @return Sorted member ids.
57 cases_dir = os.path.join(study_root,
"cases")
58 study_identity = read_artifact_identity(study_root)
60 for name
in sorted(os.listdir(cases_dir)):
61 member = os.path.join(cases_dir, name)
62 if not os.path.isdir(member):
67 member_identity = read_artifact_identity(member)
68 if member_identity[
"identity_source"] !=
"manifest" and not re.fullmatch(
r"case_\d+", name):
71 "artifact_type":
"study-case",
"root_path": member,
"original_path": member,
72 "run_id": member_identity[
"run_id"]
or name,
73 "study_id": study_identity[
"study_id"],
74 "case_id": member_identity[
"case_id"]
or name,
75 "study_path": study_root,
76 "identity_source": member_identity[
"identity_source"],
79 inventory = inspect_artifact(target)
80 _assert_archive_safe(inventory)
83 if inventory[
"checkpoint_steps"]:
120def resolve_local_storage_targets(run_dir: str =
None, study_dir: str =
None, case_ids=
None,
121 workspace: str =
None, include_inputs: bool =
False,
122 completed: bool =
False) -> list:
124 @brief Resolve explicit run/study/workspace selectors into artifact descriptions.
125 @param[in] run_dir Value supplied through the `run_dir` argument.
126 @param[in] study_dir Value supplied through the `study_dir` argument.
127 @param[in] case_ids Value supplied through the `case_ids` argument.
128 @param[in] workspace Workspace root to protect as its own artifact.
129 @param[in] include_inputs Whether workspace protection covers user-supplied inputs.
130 @param[in] completed Select every finished study member instead of naming them.
131 @return Result produced by this operation.
133 selectors = [bool(run_dir), bool(study_dir), bool(workspace)]
134 if sum(selectors) != 1:
135 raise StorageError(
"Select exactly one of --run-dir, --study-dir, or --workspace.")
136 if include_inputs
and not workspace:
137 raise StorageError(
"--include-inputs is valid only with --workspace.")
140 raise StorageError(
"--case-id is valid only with --study-dir.")
141 root = os.path.abspath(workspace)
142 if not os.path.isfile(os.path.join(root, WORKSPACE_CONFIG_FILENAME)):
144 f
"Directory is not an initialized PICurv workspace (missing "
145 f
"{WORKSPACE_CONFIG_FILENAME}): {root}"
149 with open(os.path.join(root, WORKSPACE_CONFIG_FILENAME),
"r", encoding=
"utf-8")
as stream:
150 identity = (yaml.safe_load(stream)
or {}).get(
"workspace")
or {}
151 except (OSError, ValueError):
154 "artifact_type":
"workspace",
156 "original_path": root,
160 "workspace_id": identity.get(
"id")
or os.path.basename(root),
161 "include_inputs": bool(include_inputs),
165 raise StorageError(
"--case-id is valid only with --study-dir.")
166 root = os.path.abspath(run_dir)
167 if not os.path.isdir(root):
173 identity = read_artifact_identity(root)
174 if identity[
"identity_source"] !=
"manifest" and not os.path.isdir(
175 os.path.join(root,
"config")):
177 f
"Directory does not look like a PICurv run (no {RUN_MANIFEST_FILENAME} "
178 f
"and no config/): {root}"
180 if identity[
"artifact_type"] ==
"study":
181 raise StorageError(f
"That is a study, not a run; use --study-dir: {root}")
183 "artifact_type":
"run",
185 "original_path": root,
186 "run_id": identity[
"run_id"],
187 "study_id": identity[
"study_id"]
if identity[
"case_id"]
else None,
188 "case_id": identity[
"case_id"],
189 "identity_source": identity[
"identity_source"],
192 study_root = os.path.abspath(study_dir)
193 if not os.path.isdir(study_root):
194 raise StorageError(f
"Study directory not found: {study_root}")
195 study_identity = read_artifact_identity(study_root)
196 if study_identity[
"identity_source"] !=
"manifest" and not os.path.isdir(
197 os.path.join(study_root,
"cases")):
199 f
"Directory does not look like a PICurv study (no {STUDY_MANIFEST_FILENAME} "
200 f
"and no cases/): {study_root}"
202 if not os.path.isdir(os.path.join(study_root,
"cases")):
203 raise StorageError(f
"Study has no cases/ directory: {study_root}")
206 raise StorageError(
"--completed selects members itself; do not also pass --case-id.")
207 case_ids = _select_completed_case_ids(study_root)
210 for raw_case_id
in case_ids:
211 case_id = _validate_case_id(raw_case_id)
212 case_root = os.path.join(study_root,
"cases", case_id)
213 if not os.path.isdir(case_root):
214 raise StorageError(f
"Study member not found: {case_root}")
215 member_identity = read_artifact_identity(case_root)
217 "artifact_type":
"study-case",
218 "root_path": case_root,
219 "original_path": case_root,
220 "run_id": member_identity[
"run_id"]
or case_id,
221 "study_id": study_identity[
"study_id"],
222 "case_id": member_identity[
"case_id"]
or case_id,
223 "study_path": study_root,
224 "identity_source": member_identity[
"identity_source"],
228 "artifact_type":
"study",
229 "root_path": study_root,
230 "original_path": study_root,
232 "study_id": study_identity[
"study_id"],
234 "identity_source": study_identity[
"identity_source"],
277def _discover_external_paths(root: str) -> list:
279 @brief Report explicit external-reference descriptors inside an artifact.
280 @param[in] root Value supplied through the `root` argument.
281 @return Result produced by this operation.
284 archive_root = os.path.abspath(root)
285 for descriptor
in Path(archive_root).glob(
"**/*.reference.yml"):
287 with descriptor.open(
"r", encoding=
"utf-8")
as stream:
288 payload = yaml.safe_load(stream)
or {}
289 target = payload.get(
"picurv_external_reference")
290 if isinstance(target, str)
and os.path.isabs(target):
292 "source": os.path.relpath(descriptor, archive_root).replace(os.sep,
"/"),
293 "path": os.path.abspath(target),
295 except (OSError, ValueError, TypeError):
297 return sorted(external, key=
lambda item: (item[
"source"], item[
"path"]))
300def _discover_dependencies(root: str) -> list:
302 @brief Discover absolute restart/source paths embedded in generated controls.
303 @param[in] root Value supplied through the `root` argument.
304 @return Result produced by this operation.
307 archive_root = os.path.abspath(root)
309 for runtime_root
in _artifact_runtime_roots(root):
310 controls.extend(sorted(Path(runtime_root).glob(
"config/*.control")))
311 for control
in controls:
313 lines = control.read_text(encoding=
"utf-8", errors=
"replace").splitlines()
317 stripped = line.strip()
318 if not stripped.startswith(
"-restart_dir "):
321 tokens = __import__(
"shlex").split(stripped)
324 if len(tokens) >= 2
and os.path.isabs(tokens[1])
and not _path_is_within(archive_root, tokens[1]):
325 dependencies.append({
"kind":
"restart",
"path": os.path.abspath(tokens[1])})
329def _walk_archive_entries(root: str, excluded_roots=()) -> list:
331 @brief Enumerate archive entries without following symlinks.
332 @param[in] root Value supplied through the `root` argument.
333 @param[in] excluded_roots Top-level directory names to skip entirely.
334 @return Result produced by this operation.
336 root_abs = os.path.abspath(root)
337 excluded = set(excluded_roots
or ())
340 def visit(directory: str):
342 @brief Recursively inventory directory entries without following symlinks.
343 @param[in] directory Value supplied through the `directory` argument.
346 children = sorted(os.scandir(directory), key=
lambda item: item.name)
347 except OSError
as exc:
348 raise StorageError(f
"Unable to inventory {directory}: {exc}")
from exc
349 for child
in children:
350 if child.name
in {STORAGE_STATE_FILENAME, STORAGE_LOCK_FILENAME}:
352 rel = os.path.relpath(child.path, root_abs).replace(os.sep,
"/")
356 stat_result = child.stat(follow_symlinks=
False)
357 except OSError
as exc:
358 raise StorageError(f
"Unable to stat {child.path}: {exc}")
from exc
359 if child.is_symlink():
360 entry_type =
"symlink"
362 elif child.is_dir(follow_symlinks=
False):
363 entry_type =
"directory"
365 elif child.is_file(follow_symlinks=
False):
367 size = int(stat_result.st_size)
369 raise StorageError(f
"Unsupported filesystem entry in artifact: {child.path}")
374 "mode": int(stat_result.st_mode & 0o7777),
375 "mtime_ns": int(stat_result.st_mtime_ns),
377 if entry_type ==
"directory":
400def _artifact_component_layout(root: str, artifact_type: str) -> dict:
402 @brief Read from the artifact's own manifests where each component lives.
404 @details Storage classifies what it packages by asking the run what its directories
405 are, not by assuming the topology from path prefixes. A run records its
406 component map in `manifest.json` under `paths`, and a study's members each
407 record their own, so a renamed, restored, or re-rooted artifact classifies
408 the same way it did where it was written.
410 Members are found by the manifest each one carries, not by matching a
411 `case_NNNN` name. When a manifest is missing the fixed workspace topology
412 is used and reported through `identity_source`, so a caller can tell an
413 answer from a fallback.
414 @param[in] root Absolute artifact root.
415 @param[in] artifact_type "run", "study", "study-case", or "workspace".
416 @return Mapping with `members` (member-prefix to component-path map) and
419 root_abs = os.path.abspath(root)
421 def component_paths(member_root: str) -> tuple:
423 @brief Return one artifact's declared component map and where it came from.
424 @param[in] member_root Absolute run or study-member root.
425 @return Tuple of (component path mapping, identity source).
427 identity = read_artifact_identity(member_root)
428 declared = identity.get(
"paths")
or {}
432 key: str(value).strip(
"/")
433 for key, value
in declared.items()
434 if isinstance(value, str)
and value
and not os.path.isabs(value)
436 if identity[
"identity_source"] ==
"manifest" and usable:
437 return usable,
"manifest"
438 return dict(FALLBACK_COMPONENT_PATHS),
"fixed-topology"
442 if artifact_type ==
"study":
443 cases_root = os.path.join(root_abs,
"cases")
444 if os.path.isdir(cases_root):
445 for name
in sorted(os.listdir(cases_root)):
446 member_root = os.path.join(cases_root, name)
447 if not os.path.isdir(member_root):
449 paths, source = component_paths(member_root)
450 members[f
"cases/{name}"] = paths
452 paths, source = component_paths(root_abs)
458 "identity_source":
"manifest" if sources == {
"manifest"}
else "mixed"
459 if "manifest" in sources
else "fixed-topology",
506def _classify_component(relative_path: str, layout: dict =
None) -> str:
508 @brief Classify one artifact path for packaging and local retention.
509 @param[in] relative_path Artifact-relative path of the entry.
510 @param[in] layout Component layout from `_artifact_component_layout()`. When absent
511 the fixed workspace topology is assumed.
512 @return Component name, or a `checkpoint:<step>` token.
514 layout = layout
or {
"root":
"",
"members": {
"": dict(FALLBACK_COMPONENT_PATHS)}}
515 members = layout[
"members"]
518 for candidate
in members:
521 if relative_path == candidate
or relative_path.startswith(candidate +
"/"):
522 if len(candidate) > len(prefix):
524 paths = members.get(prefix)
or dict(FALLBACK_COMPONENT_PATHS)
525 local = relative_path[len(prefix):].lstrip(
"/")
if prefix
else relative_path
526 base = os.path.basename(relative_path)
528 def under(component_key: str) -> bool:
530 @brief Whether the entry lies at or below one declared component path.
531 @param[in] component_key Component name in the artifact's own path map.
532 @return True when the entry belongs to that component.
534 declared = paths.get(component_key)
537 return local == declared
or local.startswith(declared +
"/")
543 "manifest.json",
"study_manifest.json",
"study.yml",
"cluster.yml",
544 "assets.lock.yml",
"software.lock.json",
547 if under(
"config")
or under(
"scheduler"):
549 if under(
"checkpoints"):
550 declared = paths[
"checkpoints"]
551 remainder = local[len(declared):].lstrip(
"/")
552 bundle = remainder.split(
"/", 1)[0]
554 step = _checkpoint_step_from_bundle(
555 os.path.join(layout[
"root"], *(prefix.split(
"/")
if prefix
else []),
556 *declared.split(
"/"), bundle)
563 name_match = CHECKPOINT_DIRECTORY_PATTERN.fullmatch(bundle)
565 step = int(name_match.group(1))
567 return f
"checkpoint:{step}"
570 return UNCLASSIFIED_COMPONENT
572 if under(
"analysis"):
574 if under(
"visualization"):
575 return "visualization"
582 if local.split(
"/")[0] ==
"assets":
584 if local.split(
"/")[0] ==
"results":
588 return UNCLASSIFIED_COMPONENT
618def inspect_artifact(target: dict, query_scheduler: bool =
True) -> dict:
620 @brief Build a read-only inventory and lifecycle assessment for one artifact.
621 @param[in] target Value supplied through the `target` argument.
622 @param[in] query_scheduler Value supplied through the `query_scheduler` argument.
623 @return Result produced by this operation.
625 root = target[
"root_path"]
626 workspace = target[
"artifact_type"] ==
"workspace"
627 entries = _walk_archive_entries(
628 root, WORKSPACE_EXCLUDED_ROOTS
if workspace
else ()
630 layout =
None if workspace
else _artifact_component_layout(root, target[
"artifact_type"])
631 for entry
in entries:
632 entry[
"component"] = (
633 _classify_workspace_component(entry[
"path"])
if workspace
634 else _classify_component(entry[
"path"], layout)
636 if workspace
and not target.get(
"include_inputs"):
641 entry
for entry
in entries
642 if entry[
"component"] !=
"workspace-inputs" or entry[
"type"] ==
"directory"
645 for name
in (
"post.lock.json",
"solver.lock.json"):
646 for candidate
in Path(root).glob(f
"**/scheduler/{name}"):
647 if _lock_owner_active(str(candidate)):
648 lock_paths.append(os.path.relpath(candidate, root))
649 slurm = _slurm_activity(root)
if query_scheduler
else {
"job_ids": [],
"active": [],
"unknown":
False}
650 state = storage_state_summary(root)
652 "target": dict(target),
653 "component_layout_source": layout[
"identity_source"]
if layout
else "workspace-contract",
655 "file_count": sum(entry[
"type"]
in {
"file",
"symlink"}
for entry
in entries),
656 "total_bytes": sum(entry[
"size"]
for entry
in entries),
657 "checkpoint_steps": _checkpoint_steps(entries),
658 "incomplete_checkpoints": _find_incomplete_checkpoints(entries),
659 "active_locks": sorted(lock_paths),
661 "external_paths": _discover_external_paths(root),
662 "dependencies": _discover_dependencies(root),