← Files NGS Analysis WorkbenchARCHIVED FILE
mcp/ngs_workbench_execution_monitoring/snakemake_log.py
9.54 KB · Sep 30, 2026 · 23:20 UTC
"""Read task attempts from Snakemake's native execution log."""
from __future__ import annotations
import re
from collections.abc import Iterable
from pathlib import Path
from typing import Literal
from .models import (
ExecutionEvidence,
ExecutionObservation,
ExecutionProgress,
TaskAttempt,
TaskState,
count_attempts,
discover_processes,
empty_observation,
limit_attempts,
)
_TERMINAL_STATES = {"completed", "failed", "cached", "aborted"}
_TERMINAL_WORKFLOW_STATUSES = {"completed", "failed", "canceled", "orphaned"}
_TIMESTAMP = re.compile(r"^\[(?P<value>.+)]$")
_RULE_START = re.compile(r"^(?:localrule|rule) (?P<name>[^:]+):$")
_FINISHED = re.compile(r"^Finished jobid: (?P<job_id>\S+) \(Rule: (?P<name>[^)]+)\)$")
_ERROR = re.compile(r"^Error in rule (?P<name>[^:]+):$")
_JOB_ID = re.compile(r"^\s+jobid:\s*(?P<job_id>\S+)\s*$")
_WILDCARDS = re.compile(r"^\s+wildcards:\s*(?P<value>.+?)\s*$")
_PROGRESS = re.compile(r"^(?P<completed>\d+) of (?P<total>\d+) steps(?: \(\d+%\))? done$")
_JOB_STATS_TOTAL = re.compile(r"^total\s+(?P<total>\d+)\s*$")
PendingKind = Literal["start", "error"]
class SnakemakeLogObserver:
"""Normalize append-only records from the newest native Snakemake log."""
binding = "snakemake"
engine = "snakemake"
evidence_kind = "snakemake_native_log"
file_patterns = ("results/.snakemake/log/*.snakemake.log",)
def observe(self, run_dir: Path, workflow_status: str) -> ExecutionObservation:
try:
log_path = _newest_log(run_dir)
except OSError as exc:
return empty_observation(
engine=self.engine,
evidence_kind=self.evidence_kind,
evidence_path=str(run_dir / "results" / ".snakemake" / "log"),
reason=f"Snakemake log directory could not be read: {exc}",
)
if log_path is None:
return empty_observation(
engine=self.engine,
evidence_kind=self.evidence_kind,
evidence_path=str(run_dir / "results" / ".snakemake" / "log"),
reason="Snakemake has not produced a native execution log yet",
)
try:
with log_path.open(encoding="utf-8-sig", newline="") as lines:
return self.observe_lines(
lines,
evidence_path=str(log_path),
workflow_status=workflow_status,
)
except (OSError, UnicodeError) as exc:
return empty_observation(
engine=self.engine,
evidence_kind=self.evidence_kind,
evidence_path=str(log_path),
reason=f"Snakemake log could not be read: {exc}",
)
def observe_lines(
self,
lines: Iterable[str],
*,
evidence_path: str,
workflow_status: str,
) -> ExecutionObservation:
"""Normalize one Snakemake log supplied by a local or remote transport."""
parsed = _parse_lines(lines)
if workflow_status in _TERMINAL_WORKFLOW_STATUSES:
parsed.abort_unfinished()
counts = count_attempts(parsed.attempts)
finished_attempts = sum(getattr(counts, state) for state in _TERMINAL_STATES)
total = parsed.progress_total or parsed.planned_total
reason = None
if total is None:
reason = "the native log has not reported a planned step count yet"
if not parsed.attempts:
reason = "the native log does not contain complete task records yet"
returned_attempts, attempts_truncated = limit_attempts(parsed.attempts)
return ExecutionObservation(
engine=self.engine,
evidence=ExecutionEvidence(
kind=self.evidence_kind,
path=evidence_path,
available=True,
append_only=True,
coverage="native Snakemake execution log",
reason=reason if not parsed.attempts else None,
record_count=parsed.line_count,
ignored_record_count=parsed.ignored_record_count,
),
counts=counts,
progress=ExecutionProgress(
completed=(
parsed.progress_completed
if parsed.progress_completed is not None
else counts.completed + counts.cached
),
finished_attempts=finished_attempts,
total=total,
determinate=total is not None,
reason=reason,
),
structure=discover_processes(
parsed.attempts,
ordering="first_runtime_observation",
),
attempts_truncated=attempts_truncated,
attempts=returned_attempts,
)
class _ParsedLog:
def __init__(self) -> None:
self.attempts: list[TaskAttempt] = []
self.attempts_by_job: dict[str, list[TaskAttempt]] = {}
self.line_count = 0
self.ignored_record_count = 0
self.planned_total: int | None = None
self.progress_completed: int | None = None
self.progress_total: int | None = None
self.last_observed_at: str | None = None
def start(self, job_id: str, process: str, timestamp: str | None) -> TaskAttempt:
job_attempts = self.attempts_by_job.setdefault(job_id, [])
if job_attempts and job_attempts[-1].state in {"queued", "running"}:
return job_attempts[-1]
attempt_number = len(job_attempts) + 1
attempt = TaskAttempt(
attempt_id=f"{job_id}:{attempt_number}",
attempt_number=attempt_number,
task_id=job_id,
process=process,
process_label=process.rsplit(":", 1)[-1],
state="running",
started_at=timestamp,
)
job_attempts.append(attempt)
self.attempts.append(attempt)
return attempt
def finish(
self,
job_id: str,
process: str,
timestamp: str | None,
state: TaskState,
) -> None:
job_attempts = self.attempts_by_job.get(job_id, [])
attempt = next(
(
candidate
for candidate in reversed(job_attempts)
if candidate.state in {"queued", "running"}
),
None,
)
if attempt is None:
attempt = self.start(job_id, process, timestamp)
attempt.state = state
attempt.completed_at = timestamp
def abort_unfinished(self) -> None:
for attempt in self.attempts:
if attempt.state in {"queued", "running"}:
attempt.state = "aborted"
attempt.completed_at = self.last_observed_at
def _newest_log(run_dir: Path) -> Path | None:
for pattern in SnakemakeLogObserver.file_patterns:
logs = [path for path in run_dir.glob(pattern) if path.is_file()]
if logs:
return max(logs, key=lambda path: (path.stat().st_mtime_ns, path.name))
return None
def _parse_lines(lines: Iterable[str]) -> _ParsedLog:
parsed = _ParsedLog()
timestamp: str | None = None
pending_kind: PendingKind | None = None
pending_process: str | None = None
pending_attempt: TaskAttempt | None = None
in_job_stats = False
for raw_line in lines:
parsed.line_count += 1
line = raw_line.rstrip("\r\n")
if match := _TIMESTAMP.match(line):
if pending_kind == "start" and pending_attempt is None:
parsed.ignored_record_count += 1
timestamp = match.group("value")
parsed.last_observed_at = timestamp
pending_kind = None
pending_process = None
pending_attempt = None
continue
if line == "Job stats:":
in_job_stats = True
continue
if in_job_stats:
if match := _JOB_STATS_TOTAL.match(line):
parsed.planned_total = int(match.group("total"))
in_job_stats = False
continue
if line.startswith(("Select jobs", "Execute ")):
in_job_stats = False
if match := _PROGRESS.match(line):
parsed.progress_completed = int(match.group("completed"))
parsed.progress_total = int(match.group("total"))
continue
if match := _FINISHED.match(line):
parsed.finish(
match.group("job_id"),
match.group("name"),
timestamp,
"completed",
)
continue
if match := _RULE_START.match(line):
pending_kind = "start"
pending_process = match.group("name")
pending_attempt = None
continue
if match := _ERROR.match(line):
pending_kind = "error"
pending_process = match.group("name")
pending_attempt = None
continue
if match := _JOB_ID.match(line):
job_id = match.group("job_id")
if pending_kind == "start" and pending_process is not None:
pending_attempt = parsed.start(job_id, pending_process, timestamp)
elif pending_kind == "error" and pending_process is not None:
parsed.finish(job_id, pending_process, timestamp, "failed")
continue
if match := _WILDCARDS.match(line):
if pending_attempt is not None:
pending_attempt.sample_or_shard = match.group("value")
if pending_kind == "start" and pending_attempt is None:
parsed.ignored_record_count += 1
return parsed
SHA-256: a0b5e4d58fde67e3583784e38375b23b134325d3b84deb3e4dc7cd6d63c11d84