Source code for phenotypic.sdk_.slurm._script_rendering

"""Shared rendering helpers for SLURM array scripts."""

from __future__ import annotations

import shlex
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

from ._environment import SLURM_PYTHONPATH_BOOTSTRAP_BASH
from ._sbatch import format_sbatch_directives


[docs] @dataclass(frozen=True) class SlurmArrayScriptSpec: """Specification for a bash SLURM array script. Args: job_name: SLURM job name for ``#SBATCH --job-name``. slurm_args: CLI-style SLURM arguments passed to the shared directive formatter. log_path: Path used for both stdout and stderr logs. error_log_path: Optional stderr path. Defaults to ``log_path``. task_indices: Values mapped from ``SLURM_ARRAY_TASK_ID`` into the current task variable. Strings are shell-quoted. body: Bash body executed once the current task variable is available. prelude: Optional bash block inserted after strict mode and before the task array. comments: Optional comment lines inserted after SBATCH directives. array_name: Bash array variable name. current_var: Bash variable that receives the current array entry. missing_task_id_message: Error printed when not running as an array job. bounds_error_message: Error printed when the array task id is out of bounds. When omitted, a message is derived from ``array_name``. signal_grace: Optional seconds for ``#SBATCH --signal=B:TERM@N``. requeue: Whether to include ``#SBATCH --requeue``. """ job_name: str slurm_args: Mapping[str, Any] log_path: Path task_indices: Sequence[int | str] body: str error_log_path: Path | None = None prelude: str = "" comments: Sequence[str] = field(default_factory=tuple) array_name: str = "TASK_INDICES" current_var: str = "CURRENT_TASK_INDEX" missing_task_id_message: str = "ERROR: SLURM_ARRAY_TASK_ID not set" bounds_error_message: str | None = None signal_grace: int | None = None requeue: bool = False
[docs] def render(self) -> str: """Render the script content.""" if not self.task_indices: raise ValueError("task_indices must contain at least one entry") directives = format_sbatch_directives( job_name=self.job_name, slurm_args=dict(self.slurm_args), output_log=Path(self.log_path), error_log=Path(self.error_log_path or self.log_path), ) array_directive = f"#SBATCH --array=0-{len(self.task_indices) - 1}" extra_directives = [] if self.signal_grace: extra_directives.append( f"#SBATCH --signal=B:TERM@{self.signal_grace}" ) if self.requeue: extra_directives.append("#SBATCH --requeue") directive_block = "\n".join( [directives, array_directive, *extra_directives] ) comment_block = _line_block(self.comments) prelude_block = self.prelude.rstrip() entries = "\n".join( f" {_render_task_value(entry)}" for entry in self.task_indices ) bounds_message = self.bounds_error_message or ( f"ERROR: Array task ID $SLURM_ARRAY_TASK_ID exceeds " f"{self.array_name} size ${{#{self.array_name}[@]}}" ) body = self.body.rstrip() return f"""#!/bin/bash {directive_block} {comment_block} set -e set -u {SLURM_PYTHONPATH_BOOTSTRAP_BASH} {prelude_block} {self.array_name}=( {entries} ) if [ "${{SLURM_ARRAY_TASK_ID:-}}" = "" ]; then echo "{self.missing_task_id_message}" exit 1 fi if [ "$SLURM_ARRAY_TASK_ID" -ge "${{#{self.array_name}[@]}}" ]; then echo "{bounds_message}" exit 1 fi {self.current_var}="${{{self.array_name}[$SLURM_ARRAY_TASK_ID]}}" echo "Job ID: ${{SLURM_JOB_ID:-unknown}}" echo "Array Task ID: ${{SLURM_ARRAY_TASK_ID:-unknown}}" echo "Node: ${{SLURMD_NODENAME:-$(hostname)}}" echo "Start Time: $(date)" set +e {body} EXIT_CODE=$? set -e echo "" echo "Exit Code: $EXIT_CODE" echo "End Time: $(date)" exit $EXIT_CODE """
[docs] def write_slurm_array_script(path: Path, spec: SlurmArrayScriptSpec) -> Path: """Write ``spec`` to ``path`` and mark it executable. Args: path: Destination script path. spec: Script specification to render. Returns: The destination path. """ path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(spec.render(), encoding="utf-8") path.chmod(0o755) return path
def _render_task_value(value: int | str) -> str: """Render a bash array literal entry.""" if isinstance(value, int): return str(value) return shlex.quote(value) def _line_block(lines: Sequence[str]) -> str: """Return a newline-terminated block for optional lines.""" if not lines: return "" return "\n".join(lines).rstrip() + "\n\n"