outerloop-science 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- outerloop/__init__.py +18 -0
- outerloop/__main__.py +3 -0
- outerloop/appauth.py +230 -0
- outerloop/appmanifest.py +203 -0
- outerloop/attempt.py +3784 -0
- outerloop/brief.py +528 -0
- outerloop/cli.py +621 -0
- outerloop/climbboard.py +1395 -0
- outerloop/compute.py +654 -0
- outerloop/contract.py +492 -0
- outerloop/contract_cli.py +63 -0
- outerloop/disk.py +164 -0
- outerloop/dispatch.py +631 -0
- outerloop/evalcache.py +147 -0
- outerloop/followup.py +2172 -0
- outerloop/github.py +1531 -0
- outerloop/harness.py +1435 -0
- outerloop/housekeeping.py +151 -0
- outerloop/image.py +368 -0
- outerloop/init.py +744 -0
- outerloop/intake.py +126 -0
- outerloop/launchlog.py +239 -0
- outerloop/limits.py +80 -0
- outerloop/maintain.py +353 -0
- outerloop/maintain_agent_cli.py +81 -0
- outerloop/maintain_post_cli.py +140 -0
- outerloop/markers.py +48 -0
- outerloop/measure.py +529 -0
- outerloop/orchestrator.py +2011 -0
- outerloop/panel.py +188 -0
- outerloop/paths.py +40 -0
- outerloop/posting.py +160 -0
- outerloop/progress.py +170 -0
- outerloop/py.typed +0 -0
- outerloop/review.py +615 -0
- outerloop/review_agent.py +263 -0
- outerloop/review_agent_cli.py +209 -0
- outerloop/review_post_cli.py +162 -0
- outerloop/review_summarize_cli.py +165 -0
- outerloop/role_runner.py +229 -0
- outerloop/roles.py +274 -0
- outerloop/rolespec.py +91 -0
- outerloop/runstate.py +385 -0
- outerloop/steward.py +845 -0
- outerloop/style.py +12 -0
- outerloop/syscall.py +1192 -0
- outerloop/syscall_cli.py +762 -0
- outerloop/tick.py +3422 -0
- outerloop/verifier.py +403 -0
- outerloop/verify_agent.py +151 -0
- outerloop/verify_agent_cli.py +95 -0
- outerloop/verify_post_cli.py +116 -0
- outerloop/watcher.py +203 -0
- outerloop_science-0.1.0.dist-info/METADATA +152 -0
- outerloop_science-0.1.0.dist-info/RECORD +59 -0
- outerloop_science-0.1.0.dist-info/WHEEL +4 -0
- outerloop_science-0.1.0.dist-info/entry_points.txt +2 -0
- outerloop_science-0.1.0.dist-info/licenses/LICENSE +202 -0
- outerloop_science-0.1.0.dist-info/licenses/NOTICE +5 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Disk housekeeping: ended runs shed their workspaces.
|
|
2
|
+
|
|
3
|
+
A run's workspace (`ws/`, the clone the session worked in, and `ws-home/`,
|
|
4
|
+
the session's home with its tool caches) is the bulk of what a run leaves
|
|
5
|
+
on the state filesystem, in files far more than in bytes: 2026-09-03 the
|
|
6
|
+
scratch quota on Torch hit its 5M-file ceiling with 1.67M of them under
|
|
7
|
+
state/runs, and no tick could start for two hours. Everything the record
|
|
8
|
+
keeps for the research record lives outside those two directories: the
|
|
9
|
+
run's state.json, its report, its transcripts, and the ledger entries; the
|
|
10
|
+
tree itself is on GitHub (the PR branch, or the research line's snapshot).
|
|
11
|
+
|
|
12
|
+
Rules (docs/design/disk-maintenance.md):
|
|
13
|
+
|
|
14
|
+
- only ENDED runs shed, and only the two directories `ws` and `ws-home`;
|
|
15
|
+
- after a grace period (default 24 h, for post-mortems), oldest first;
|
|
16
|
+
- when the state filesystem's write probe FAILS, the grace is waived: the
|
|
17
|
+
tick sheds until the probe passes again, so a full quota heals itself;
|
|
18
|
+
- a workspace whose top-level entry is a symlink is not removed (a session
|
|
19
|
+
could aim it anywhere); it is logged and left for a human;
|
|
20
|
+
- the record notes when it shed (`workspace_shed`), so nothing runs twice.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import shutil
|
|
29
|
+
import time
|
|
30
|
+
from dataclasses import replace
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
from outerloop.runstate import ENDED, RunRecord, load_record, run_dir, save_record
|
|
34
|
+
|
|
35
|
+
log = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
WORKSPACE_DIRS = ("ws", "ws-home")
|
|
38
|
+
DEFAULT_SHED_GRACE_S = 24 * 3600.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def shed_workspace(root: Path, record: RunRecord, now: float) -> bool:
|
|
42
|
+
"""Remove the run's `ws` and `ws-home` directories and stamp the record.
|
|
43
|
+
Returns False (and removes nothing) when either is a symlink."""
|
|
44
|
+
base = run_dir(root, record.run_id)
|
|
45
|
+
targets = [base / d for d in WORKSPACE_DIRS if (base / d).exists() or (base / d).is_symlink()]
|
|
46
|
+
for path in targets:
|
|
47
|
+
if path.is_symlink():
|
|
48
|
+
log.warning("not shedding %s: %s is a symlink", record.run_id, path.name)
|
|
49
|
+
return False
|
|
50
|
+
for path in targets:
|
|
51
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
52
|
+
remaining = [p.name for p in targets if p.exists()]
|
|
53
|
+
if remaining:
|
|
54
|
+
log.warning("shed %s incompletely: %s remain", record.run_id, ", ".join(remaining))
|
|
55
|
+
return False
|
|
56
|
+
save_record(root, replace(record, workspace_shed=now), now)
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_TS_RE = re.compile(r"(\d{8}-\d{6})")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _run_id_timestamp(run_id: str) -> str | None:
|
|
64
|
+
m = _TS_RE.search(run_id)
|
|
65
|
+
return m.group(1) if m else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _is_shed_candidate(
|
|
69
|
+
root: Path, record: RunRecord, now: float, grace_s: float, force: bool
|
|
70
|
+
) -> bool:
|
|
71
|
+
if record.state != ENDED or record.workspace_shed:
|
|
72
|
+
return False
|
|
73
|
+
if not force and now - record.updated < grace_s:
|
|
74
|
+
return False
|
|
75
|
+
return any((run_dir(root, record.run_id) / d).exists() for d in WORKSPACE_DIRS)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def shed_ended_workspaces(
|
|
79
|
+
root: Path,
|
|
80
|
+
now: float,
|
|
81
|
+
*,
|
|
82
|
+
grace_s: float = DEFAULT_SHED_GRACE_S,
|
|
83
|
+
force: bool = False,
|
|
84
|
+
limit: int = 3,
|
|
85
|
+
time_budget_s: float = 120.0,
|
|
86
|
+
until_ok: object = None,
|
|
87
|
+
clock: object = None,
|
|
88
|
+
) -> list[str]:
|
|
89
|
+
"""Shed due workspaces until `limit` is reached or `time_budget_s`
|
|
90
|
+
elapses, checking the budget between runs; a forced sweep also stops when
|
|
91
|
+
`until_ok` reports a healthy disk.
|
|
92
|
+
|
|
93
|
+
Bounded by BOTH a count (`limit`) and a wall-clock budget
|
|
94
|
+
(`time_budget_s`). Removing a workspace is `rm -rf` over the state
|
|
95
|
+
filesystem, tens of thousands of tiny files each on a networked FS, so an
|
|
96
|
+
unbounded batch inside a tick can run for many minutes and blow the tick's
|
|
97
|
+
own timeout (2026-09-03: a 50-run batch, and reading every record to find
|
|
98
|
+
candidates, killed the tick before it could publish). Discovery is ONE
|
|
99
|
+
directory read, sorted oldest-first by the timestamp embedded in each run
|
|
100
|
+
id (no per-entry stat, no record load); the loop then loads one record at
|
|
101
|
+
a time and checks the budget EACH step, so both are bounded. The backlog
|
|
102
|
+
drains over several ticks. With `until_ok` a forced sweep also stops as
|
|
103
|
+
soon as the disk reports healthy."""
|
|
104
|
+
monotonic = clock if callable(clock) else time.monotonic
|
|
105
|
+
start = monotonic()
|
|
106
|
+
runs_root = root / "runs"
|
|
107
|
+
try:
|
|
108
|
+
# ONE directory read (no per-entry stat), sorted oldest-first by the
|
|
109
|
+
# timestamp every run id carries (`<name>-YYYYMMDD-HHMMSS-...`), which
|
|
110
|
+
# is chronological across benchmark prefixes where a lexical sort is
|
|
111
|
+
# not; ids without one sort last so they never block the backlog.
|
|
112
|
+
run_ids = sorted(
|
|
113
|
+
(e.name for e in os.scandir(runs_root)),
|
|
114
|
+
key=lambda name: (_run_id_timestamp(name) or "99999999-999999", name),
|
|
115
|
+
)
|
|
116
|
+
except OSError:
|
|
117
|
+
return []
|
|
118
|
+
# One readdir + an in-memory sort is cheap even for thousands of run dirs,
|
|
119
|
+
# but never start shedding if it somehow overran the budget: the tick then
|
|
120
|
+
# spends the rest of its time publishing, not deleting.
|
|
121
|
+
if monotonic() - start >= time_budget_s:
|
|
122
|
+
return []
|
|
123
|
+
shed: list[str] = []
|
|
124
|
+
for run_id in run_ids:
|
|
125
|
+
if len(shed) >= limit:
|
|
126
|
+
break
|
|
127
|
+
if monotonic() - start >= time_budget_s:
|
|
128
|
+
log.info(
|
|
129
|
+
"housekeeping: time budget (%.0fs) reached; %d shed this tick",
|
|
130
|
+
time_budget_s,
|
|
131
|
+
len(shed),
|
|
132
|
+
)
|
|
133
|
+
break
|
|
134
|
+
if until_ok is not None and callable(until_ok) and until_ok():
|
|
135
|
+
break
|
|
136
|
+
try:
|
|
137
|
+
record = load_record(root, run_id)
|
|
138
|
+
except (OSError, ValueError, TypeError, KeyError):
|
|
139
|
+
continue
|
|
140
|
+
if _is_shed_candidate(root, record, now, grace_s, force) and shed_workspace(
|
|
141
|
+
root, record, now
|
|
142
|
+
):
|
|
143
|
+
shed.append(run_id)
|
|
144
|
+
if shed:
|
|
145
|
+
log.info(
|
|
146
|
+
"shed %d ended workspace(s)%s: %s",
|
|
147
|
+
len(shed),
|
|
148
|
+
" (forced)" if force else "",
|
|
149
|
+
", ".join(shed),
|
|
150
|
+
)
|
|
151
|
+
return shed
|
outerloop/image.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""The agent image on an adopter's machine: where it lives, and fetching the
|
|
2
|
+
published one. The tick reads `~/outerloop-images/agent-py312.sif` unless
|
|
3
|
+
`OUTERLOOP_IMAGE` names another file (tick._default_image); `init` fills that
|
|
4
|
+
default on Linux when Apptainer is installed, so local mode runs contained
|
|
5
|
+
without a hand step."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import os
|
|
11
|
+
import platform
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
import time
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
IMAGE_NAME = "agent-py312.sif"
|
|
24
|
+
IMAGE_URL = f"https://huggingface.co/outerloop-science/agent-image/resolve/main/{IMAGE_NAME}"
|
|
25
|
+
IMAGE_DIR_NAME = "outerloop-images"
|
|
26
|
+
LEGACY_IMAGE_DIR_NAME = "autoresearch-images" # pre-rename; honored until the release after 0.1
|
|
27
|
+
_CHUNK = 1 << 20
|
|
28
|
+
_PROBE_TIMEOUT_S = 60
|
|
29
|
+
APPTAINER_RELEASES = "https://github.com/apptainer/apptainer/releases"
|
|
30
|
+
APPTAINER_VERSION = "1.5.3"
|
|
31
|
+
# the GitHub release ships Debian packages for x86-64 only; the project's PPA
|
|
32
|
+
# builds amd64 and arm64 for every current Ubuntu
|
|
33
|
+
APPTAINER_DEB = f"apptainer_{APPTAINER_VERSION}_amd64.deb"
|
|
34
|
+
APPTAINER_DEB_URL = f"{APPTAINER_RELEASES}/download/v{APPTAINER_VERSION}/{APPTAINER_DEB}"
|
|
35
|
+
APPTAINER_PPA = "ppa:apptainer/ppa"
|
|
36
|
+
# the unprivileged installer, pinned to the release tag (never a moving branch)
|
|
37
|
+
APPTAINER_UNPRIV_URL = (
|
|
38
|
+
"https://raw.githubusercontent.com/apptainer/apptainer/"
|
|
39
|
+
f"v{APPTAINER_VERSION}/tools/install-unprivileged.sh"
|
|
40
|
+
)
|
|
41
|
+
PROBE_CMD = "apptainer exec docker://alpine:3.20 cat /etc/alpine-release"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def image_dir(home: Path | None = None) -> Path:
|
|
45
|
+
return (home or Path.home()) / IMAGE_DIR_NAME
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def find_image(home: Path | None = None) -> str:
|
|
49
|
+
"""An existing image on this machine, new location first, or ""."""
|
|
50
|
+
home = home or Path.home()
|
|
51
|
+
for d in (IMAGE_DIR_NAME, LEGACY_IMAGE_DIR_NAME):
|
|
52
|
+
p = home / d / IMAGE_NAME
|
|
53
|
+
if p.is_file():
|
|
54
|
+
return str(p)
|
|
55
|
+
return ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _linux_flavor() -> tuple[str, str]:
|
|
59
|
+
"""(distribution id, version) from os-release, or ("", "")."""
|
|
60
|
+
try:
|
|
61
|
+
info = platform.freedesktop_os_release()
|
|
62
|
+
except OSError:
|
|
63
|
+
return "", ""
|
|
64
|
+
return info.get("ID", ""), info.get("VERSION_ID", "")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def install_hint() -> str:
|
|
68
|
+
"""The exact steps for THIS machine, written for someone installing Apptainer
|
|
69
|
+
for the first time. Every path is a root operation or an admin request, which
|
|
70
|
+
is why init never runs it."""
|
|
71
|
+
if sys.platform == "darwin":
|
|
72
|
+
return (
|
|
73
|
+
"macOS has no Apptainer; runs stay uncontained here (a macOS containment is on the\n"
|
|
74
|
+
" roadmap). A Linux machine, or Slurm, runs contained."
|
|
75
|
+
)
|
|
76
|
+
dist, _version = _linux_flavor()
|
|
77
|
+
apparmor_note = (
|
|
78
|
+
" Ubuntu 23.10+ blocks unprivileged user namespaces unless an AppArmor profile allows\n"
|
|
79
|
+
" them; the official package carries that profile (the unprivileged installer does\n"
|
|
80
|
+
" not, and fails with 'Could not write info to setgroups').\n"
|
|
81
|
+
)
|
|
82
|
+
check = (
|
|
83
|
+
f" 3. {PROBE_CMD}\n"
|
|
84
|
+
" (prints a version number)\n"
|
|
85
|
+
" then run `outerloop init --force` again to download the image."
|
|
86
|
+
)
|
|
87
|
+
if dist in ("ubuntu", "linuxmint", "pop"):
|
|
88
|
+
return (
|
|
89
|
+
"Install Apptainer from the project's Ubuntu PPA (amd64 and arm64):\n"
|
|
90
|
+
+ apparmor_note
|
|
91
|
+
+ f" 1. sudo add-apt-repository -y {APPTAINER_PPA}\n"
|
|
92
|
+
" 2. sudo apt-get update && sudo apt-get install -y apptainer\n" + check
|
|
93
|
+
)
|
|
94
|
+
if dist == "debian":
|
|
95
|
+
if platform.machine() == "x86_64":
|
|
96
|
+
return (
|
|
97
|
+
"Install the official Apptainer package (Debian, x86-64):\n"
|
|
98
|
+
+ apparmor_note
|
|
99
|
+
+ f" 1. curl -fsSLO {APPTAINER_DEB_URL}\n"
|
|
100
|
+
f" 2. sudo apt-get install -y ./{APPTAINER_DEB}\n" + check
|
|
101
|
+
)
|
|
102
|
+
return (
|
|
103
|
+
f"No Apptainer Debian package is published for this machine ({platform.machine()}).\n"
|
|
104
|
+
" Build from source (https://apptainer.org/docs/admin/main/installation.html) or use\n"
|
|
105
|
+
" the unprivileged installer. Download it, read it, then run it:\n"
|
|
106
|
+
f" curl -fsSLO {APPTAINER_UNPRIV_URL}\n"
|
|
107
|
+
" less install-unprivileged.sh && bash install-unprivileged.sh ~/apptainer\n"
|
|
108
|
+
" export PATH=$HOME/apptainer/bin:$PATH\n"
|
|
109
|
+
f" Check with `{PROBE_CMD}`, then run `outerloop init --force` again."
|
|
110
|
+
)
|
|
111
|
+
if dist == "fedora":
|
|
112
|
+
return (
|
|
113
|
+
"Install Apptainer from the Fedora repositories:\n"
|
|
114
|
+
" sudo dnf install -y apptainer\n"
|
|
115
|
+
f" {PROBE_CMD} # prints a version number\n"
|
|
116
|
+
" then run `outerloop init --force` again to download the image."
|
|
117
|
+
)
|
|
118
|
+
if dist in ("rhel", "centos", "rocky", "almalinux"):
|
|
119
|
+
return (
|
|
120
|
+
"Install Apptainer from EPEL:\n"
|
|
121
|
+
" sudo dnf install -y epel-release\n"
|
|
122
|
+
" sudo dnf install -y apptainer\n"
|
|
123
|
+
f" {PROBE_CMD} # prints a version number\n"
|
|
124
|
+
" then run `outerloop init --force` again to download the image."
|
|
125
|
+
)
|
|
126
|
+
return (
|
|
127
|
+
"Install Apptainer for your distribution:\n"
|
|
128
|
+
" https://apptainer.org/docs/admin/main/installation.html\n"
|
|
129
|
+
" Without root, the project's unprivileged installer works on most systems.\n"
|
|
130
|
+
" Download it, read it, then run it:\n"
|
|
131
|
+
f" curl -fsSLO {APPTAINER_UNPRIV_URL}\n"
|
|
132
|
+
" less install-unprivileged.sh && bash install-unprivileged.sh ~/apptainer\n"
|
|
133
|
+
" export PATH=$HOME/apptainer/bin:$PATH\n"
|
|
134
|
+
" On a Slurm cluster, ask the administrators; most already provide it.\n"
|
|
135
|
+
f" Check with `{PROBE_CMD}`, then run\n"
|
|
136
|
+
" `outerloop init --force` again to download the image."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def containment_check(*, runner: Callable[..., Any] = subprocess.run) -> str:
|
|
141
|
+
"""Why contained runs are NOT possible here, or "" when they are. Finds
|
|
142
|
+
apptainer and actually runs a container: being on PATH is not enough
|
|
143
|
+
(Ubuntu 24.04 lets any program create a user namespace but strips its
|
|
144
|
+
capabilities unless an AppArmor profile allows it, so a hand-installed
|
|
145
|
+
apptainer fails at exec time with `Could not write info to setgroups`).
|
|
146
|
+
The probe needs no network: a throwaway sandbox with the standard mount
|
|
147
|
+
points, the host's /bin, /lib and /usr bound in, running /bin/true —
|
|
148
|
+
about 0.1 s on a working install."""
|
|
149
|
+
if sys.platform == "darwin":
|
|
150
|
+
return "apptainer does not exist on macOS"
|
|
151
|
+
if not sys.platform.startswith("linux"):
|
|
152
|
+
return f"apptainer is not available on {sys.platform}"
|
|
153
|
+
binary = shutil.which("apptainer")
|
|
154
|
+
if binary is None:
|
|
155
|
+
return "apptainer is not installed (not on PATH)"
|
|
156
|
+
binds = ",".join(d for d in ("/bin", "/lib", "/lib64", "/usr") if os.path.exists(d))
|
|
157
|
+
with tempfile.TemporaryDirectory(prefix="outerloop-probe-") as sandbox:
|
|
158
|
+
for d in (
|
|
159
|
+
"dev",
|
|
160
|
+
"proc",
|
|
161
|
+
"sys",
|
|
162
|
+
"tmp",
|
|
163
|
+
"etc",
|
|
164
|
+
"bin",
|
|
165
|
+
"lib",
|
|
166
|
+
"lib64",
|
|
167
|
+
"usr",
|
|
168
|
+
"root",
|
|
169
|
+
"home",
|
|
170
|
+
"var/tmp",
|
|
171
|
+
):
|
|
172
|
+
os.makedirs(os.path.join(sandbox, d), exist_ok=True)
|
|
173
|
+
for f in ("etc/passwd", "etc/group"):
|
|
174
|
+
open(os.path.join(sandbox, f), "a").close()
|
|
175
|
+
argv = [binary, "exec", "--containall", "--cleanenv", "--no-home", "--pwd", "/"]
|
|
176
|
+
if binds:
|
|
177
|
+
argv += ["-B", binds]
|
|
178
|
+
argv += [sandbox, "/bin/true"]
|
|
179
|
+
try:
|
|
180
|
+
proc = runner(argv, capture_output=True, text=True, timeout=_PROBE_TIMEOUT_S)
|
|
181
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
182
|
+
return f"apptainer failed to start: {exc}"
|
|
183
|
+
err = (proc.stderr or "").strip()
|
|
184
|
+
if "setgroups" in err or "user namespace" in err.lower() or "unshare" in err.lower():
|
|
185
|
+
last = err.splitlines()[-1] if err else "user namespace refused"
|
|
186
|
+
return f"apptainer cannot create containers here ({last})"
|
|
187
|
+
if proc.returncode != 0:
|
|
188
|
+
last = err.splitlines()[-1] if err else f"exit {proc.returncode}"
|
|
189
|
+
return f"apptainer exec failed ({last})"
|
|
190
|
+
return ""
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _fmt_mb(n: int) -> str:
|
|
194
|
+
return f"{n / (1 << 20):,.0f} MB"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class _Progress:
|
|
198
|
+
"""Download progress: a redrawn bar with size, speed and ETA when stdout is
|
|
199
|
+
a terminal; one line per 10% when it is not (a log, a CI step). `report`
|
|
200
|
+
receives the non-tty lines and the final summary."""
|
|
201
|
+
|
|
202
|
+
def __init__(self, url: str, report: Callable[[str], None]) -> None:
|
|
203
|
+
self.url = url
|
|
204
|
+
self.report = report
|
|
205
|
+
self.total = 0
|
|
206
|
+
self.t0 = 0.0
|
|
207
|
+
self.tty = sys.stdout.isatty()
|
|
208
|
+
self.next_mark = 10
|
|
209
|
+
self.last_draw = 0.0
|
|
210
|
+
|
|
211
|
+
def start(self, total: int) -> None:
|
|
212
|
+
self.total = total
|
|
213
|
+
self.t0 = time.monotonic()
|
|
214
|
+
size = f" ({_fmt_mb(total)})" if total else ""
|
|
215
|
+
self.report(f"downloading {self.url}{size}")
|
|
216
|
+
|
|
217
|
+
def update(self, done: int) -> None:
|
|
218
|
+
now = time.monotonic()
|
|
219
|
+
if self.tty:
|
|
220
|
+
if now - self.last_draw < 0.2:
|
|
221
|
+
return
|
|
222
|
+
self.last_draw = now
|
|
223
|
+
sys.stdout.write("\r" + self._line(done, now) + "\033[K")
|
|
224
|
+
sys.stdout.flush()
|
|
225
|
+
elif self.total:
|
|
226
|
+
pct = done * 100 // self.total
|
|
227
|
+
while pct >= self.next_mark and self.next_mark <= 100:
|
|
228
|
+
self.report(f" {self.next_mark:3d}% {_fmt_mb(done)} of {_fmt_mb(self.total)}")
|
|
229
|
+
self.next_mark += 10
|
|
230
|
+
|
|
231
|
+
def _line(self, done: int, now: float) -> str:
|
|
232
|
+
elapsed = max(now - self.t0, 1e-6)
|
|
233
|
+
speed = done / elapsed
|
|
234
|
+
if self.total:
|
|
235
|
+
frac = min(done / self.total, 1.0)
|
|
236
|
+
width = 30
|
|
237
|
+
filled = int(frac * width)
|
|
238
|
+
bar = "#" * filled + "-" * (width - filled)
|
|
239
|
+
eta = (self.total - done) / speed if speed > 0 else 0
|
|
240
|
+
return (
|
|
241
|
+
f" [{bar}] {frac * 100:3.0f}% {_fmt_mb(done)} of {_fmt_mb(self.total)}"
|
|
242
|
+
f" {speed / (1 << 20):.1f} MB/s ETA {int(eta) // 60}:{int(eta) % 60:02d}"
|
|
243
|
+
)
|
|
244
|
+
return f" {_fmt_mb(done)} {speed / (1 << 20):.1f} MB/s"
|
|
245
|
+
|
|
246
|
+
def finish(self, done: int) -> None:
|
|
247
|
+
if self.tty:
|
|
248
|
+
sys.stdout.write("\r" + self._line(done, time.monotonic()) + "\033[K\n")
|
|
249
|
+
sys.stdout.flush()
|
|
250
|
+
elapsed = time.monotonic() - self.t0
|
|
251
|
+
self.report(f" downloaded {_fmt_mb(done)} in {int(elapsed) // 60}:{int(elapsed) % 60:02d}")
|
|
252
|
+
|
|
253
|
+
def abort(self) -> None:
|
|
254
|
+
if self.tty:
|
|
255
|
+
sys.stdout.write("\n")
|
|
256
|
+
sys.stdout.flush()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def download_image(
|
|
260
|
+
url: str = IMAGE_URL,
|
|
261
|
+
dest: Path | None = None,
|
|
262
|
+
*,
|
|
263
|
+
opener: Callable[..., Any] = urllib.request.urlopen,
|
|
264
|
+
report: Callable[[str], None] = print,
|
|
265
|
+
checksum_url: str = "",
|
|
266
|
+
) -> Path:
|
|
267
|
+
"""Stream `url` to `dest` (default: the image dir), through a `.part`
|
|
268
|
+
file renamed into place only after its sha256 matches the checksum the
|
|
269
|
+
build published beside it (`<url>.sha256`); any failure, mismatch or
|
|
270
|
+
interruption removes the part file. Progress: a live bar on a terminal,
|
|
271
|
+
one line per 10% otherwise."""
|
|
272
|
+
dest = dest or image_dir() / IMAGE_NAME
|
|
273
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
274
|
+
part = dest.with_name(dest.name + ".part")
|
|
275
|
+
digest = hashlib.sha256()
|
|
276
|
+
bar = _Progress(url, report)
|
|
277
|
+
try:
|
|
278
|
+
with opener(url, timeout=60) as resp:
|
|
279
|
+
total = int(resp.headers.get("Content-Length") or 0)
|
|
280
|
+
bar.start(total)
|
|
281
|
+
done = 0
|
|
282
|
+
with part.open("wb") as out:
|
|
283
|
+
while True:
|
|
284
|
+
chunk = resp.read(_CHUNK)
|
|
285
|
+
if not chunk:
|
|
286
|
+
break
|
|
287
|
+
out.write(chunk)
|
|
288
|
+
digest.update(chunk)
|
|
289
|
+
done += len(chunk)
|
|
290
|
+
bar.update(done)
|
|
291
|
+
bar.finish(done)
|
|
292
|
+
if total and done != total:
|
|
293
|
+
raise OSError(f"short download: {done} of {total} bytes")
|
|
294
|
+
# the checksum the build published beside the image: an image that does
|
|
295
|
+
# not match it is never installed (it would run with the bound harness
|
|
296
|
+
# binary and the run's credentials)
|
|
297
|
+
expected = _published_checksum(checksum_url or url + ".sha256", opener)
|
|
298
|
+
if digest.hexdigest() != expected:
|
|
299
|
+
raise OSError(
|
|
300
|
+
f"checksum mismatch for {url}: expected {expected}, got {digest.hexdigest()}"
|
|
301
|
+
)
|
|
302
|
+
report(" checksum verified")
|
|
303
|
+
except BaseException:
|
|
304
|
+
# a failed or interrupted download never leaves a partial image where
|
|
305
|
+
# a later run would find it
|
|
306
|
+
bar.abort()
|
|
307
|
+
part.unlink(missing_ok=True)
|
|
308
|
+
raise
|
|
309
|
+
os.replace(part, dest)
|
|
310
|
+
report(f" saved {dest}")
|
|
311
|
+
return dest
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _published_checksum(url: str, opener: Callable[..., Any]) -> str:
|
|
315
|
+
"""The sha256 hex the build published at `url` (`<hex> <file>` as sha256sum
|
|
316
|
+
writes it). No readable checksum means no image."""
|
|
317
|
+
try:
|
|
318
|
+
with opener(url, timeout=60) as resp:
|
|
319
|
+
text = resp.read(4096).decode("ascii", "replace")
|
|
320
|
+
except (OSError, urllib.error.URLError) as exc:
|
|
321
|
+
raise OSError(f"no checksum published at {url}: {exc}") from exc
|
|
322
|
+
token = text.split()[0].strip().lower() if text.split() else ""
|
|
323
|
+
if len(token) != 64 or any(c not in "0123456789abcdef" for c in token):
|
|
324
|
+
raise OSError(f"unreadable checksum at {url}")
|
|
325
|
+
return token
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def ensure_image(
|
|
329
|
+
*,
|
|
330
|
+
interactive: bool,
|
|
331
|
+
want: bool = True,
|
|
332
|
+
probe: bool = True,
|
|
333
|
+
home: Path | None = None,
|
|
334
|
+
ask: Callable[[str], str] = input,
|
|
335
|
+
report: Callable[[str], None] = print,
|
|
336
|
+
fetch: Callable[..., Path] = download_image,
|
|
337
|
+
) -> str:
|
|
338
|
+
"""The image path `init` records: an existing image, else the published
|
|
339
|
+
one downloaded to the image dir, when this machine can run it and the
|
|
340
|
+
adopter did not opt out. "" means uncontained (the loop says so at start);
|
|
341
|
+
when that is because Apptainer is missing or cannot run containers, the
|
|
342
|
+
exact install steps for this machine are printed, and an image already on
|
|
343
|
+
disk is NOT recorded (a contained run would only fail later). `probe=False`
|
|
344
|
+
skips the container check: on Slurm the image runs on compute nodes, which
|
|
345
|
+
the login node cannot speak for. A failed download is a warning, never a
|
|
346
|
+
failed setup."""
|
|
347
|
+
if not want:
|
|
348
|
+
return ""
|
|
349
|
+
problem = containment_check() if probe else ""
|
|
350
|
+
found = find_image(home)
|
|
351
|
+
if problem:
|
|
352
|
+
report(f"Runs will be UNCONTAINED on this machine: {problem}.")
|
|
353
|
+
report(" " + install_hint())
|
|
354
|
+
if found:
|
|
355
|
+
report(f" (the image at {found} will be used once apptainer works)")
|
|
356
|
+
return ""
|
|
357
|
+
if found:
|
|
358
|
+
return found
|
|
359
|
+
dest = image_dir(home) / IMAGE_NAME
|
|
360
|
+
if interactive:
|
|
361
|
+
answer = ask(f"Download the agent image to {dest} so runs are contained? [Y/n] ")
|
|
362
|
+
if answer.strip().lower().startswith("n"):
|
|
363
|
+
return ""
|
|
364
|
+
try:
|
|
365
|
+
return str(fetch(IMAGE_URL, dest, report=report))
|
|
366
|
+
except (OSError, urllib.error.URLError) as exc:
|
|
367
|
+
report(f" image download failed ({exc}); continuing without one, pass --image later")
|
|
368
|
+
return ""
|