errand-run 0.1.1__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.
- errand/__init__.py +101 -0
- errand/__main__.py +5 -0
- errand/_pytest_plugin.py +25 -0
- errand/batch.py +335 -0
- errand/cli.py +1752 -0
- errand/config.py +358 -0
- errand/detect.py +216 -0
- errand/discovery.py +448 -0
- errand/entries.py +266 -0
- errand/expr.py +65 -0
- errand/fuzzy.py +154 -0
- errand/history.py +42 -0
- errand/layers.py +839 -0
- errand/local.py +104 -0
- errand/queue.py +400 -0
- errand/results.py +397 -0
- errand/runner.py +804 -0
- errand/session.py +494 -0
- errand/setup.py +151 -0
- errand/targets.py +285 -0
- errand/tui.py +1386 -0
- errand/vm.py +1292 -0
- errand/yamlish.py +203 -0
- errand_run-0.1.1.dist-info/METADATA +166 -0
- errand_run-0.1.1.dist-info/RECORD +28 -0
- errand_run-0.1.1.dist-info/WHEEL +4 -0
- errand_run-0.1.1.dist-info/entry_points.txt +2 -0
- errand_run-0.1.1.dist-info/licenses/LICENSE +21 -0
errand/__init__.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""errand -- run work somewhere, and bring back what it produced.
|
|
2
|
+
|
|
3
|
+
Entry files import from here and nothing else, and finding entries means
|
|
4
|
+
importing EVERY candidate file, so this module must stay cheap: the names
|
|
5
|
+
below come from `errand.entries`, which imports only the standard library.
|
|
6
|
+
Everything heavier -- the command line, environments, layers -- is reached
|
|
7
|
+
through the lazy `__getattr__` at the bottom.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.1"
|
|
12
|
+
|
|
13
|
+
from .entries import ( # noqa: F401 -- the public surface of a file of work
|
|
14
|
+
Args,
|
|
15
|
+
Param,
|
|
16
|
+
bench,
|
|
17
|
+
entry,
|
|
18
|
+
experiment,
|
|
19
|
+
has_tag,
|
|
20
|
+
out_dir,
|
|
21
|
+
tag,
|
|
22
|
+
test,
|
|
23
|
+
track,
|
|
24
|
+
)
|
|
25
|
+
from .local import Skipped, have, need, skip, value # noqa: F401
|
|
26
|
+
|
|
27
|
+
# Read by errand when it needs a default: `errand.default_env = "gpu"` in an `errand-*.py`.
|
|
28
|
+
default_env: str | None = None
|
|
29
|
+
# What a bare `errand run` runs: `errand.default_run = [ "tests/**::*" ]`, targets
|
|
30
|
+
# from the project root. With none, a bare `errand run` says what there is.
|
|
31
|
+
default_run: list | None = None
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"Args", "Param", "Skipped", "bench", "entry", "experiment", "has_tag", "have",
|
|
35
|
+
"need", "out_dir", "skip", "tag", "test", "track",
|
|
36
|
+
"configure", "default_env", "default_run", "Env", "envs", "runners", "main", "value",
|
|
37
|
+
"Vars", "Venv", "Micromamba", "Conda", "Uv", "Nix", "Guix", "Module",
|
|
38
|
+
"Apptainer", "Docker", "Podman", "Ssh", "Slurm", "Windows",
|
|
39
|
+
"Runner", "Level", "Outcome", "RunContext", "Errand", "Pytest", "Catch2", "Cargo",
|
|
40
|
+
"Python", "Executable",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
# Everything past the declaration surface is reached lazily: an entry file
|
|
44
|
+
# imports from here, and finding entries means importing EVERY candidate file,
|
|
45
|
+
# so that path must stay as cheap as a couple of stdlib modules.
|
|
46
|
+
_LAZY = {
|
|
47
|
+
"main" : "errand.cli",
|
|
48
|
+
"configure" : "errand.config",
|
|
49
|
+
"Env" : "errand.config",
|
|
50
|
+
"envs" : "errand.config",
|
|
51
|
+
"runners" : "errand.config",
|
|
52
|
+
"Vars" : "errand.layers",
|
|
53
|
+
"Venv" : "errand.layers",
|
|
54
|
+
"Micromamba": "errand.layers",
|
|
55
|
+
"Conda" : "errand.layers",
|
|
56
|
+
"Uv" : "errand.layers",
|
|
57
|
+
"Nix" : "errand.layers",
|
|
58
|
+
"Guix" : "errand.layers",
|
|
59
|
+
"Module" : "errand.layers",
|
|
60
|
+
"Apptainer" : "errand.layers",
|
|
61
|
+
"Docker" : "errand.layers",
|
|
62
|
+
"Podman" : "errand.layers",
|
|
63
|
+
"Ssh" : "errand.layers",
|
|
64
|
+
"Slurm" : "errand.layers",
|
|
65
|
+
"Windows" : "errand.vm",
|
|
66
|
+
"Runner" : "errand.runner",
|
|
67
|
+
"Level" : "errand.runner",
|
|
68
|
+
"Outcome" : "errand.runner",
|
|
69
|
+
"RunContext": "errand.runner",
|
|
70
|
+
"Errand" : "errand.runner",
|
|
71
|
+
"Pytest" : "errand.runner",
|
|
72
|
+
"Catch2" : "errand.runner",
|
|
73
|
+
"Cargo" : "errand.runner",
|
|
74
|
+
"Python" : "errand.runner",
|
|
75
|
+
"Executable": "errand.runner",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# The same names, spelled out for type checkers and editors, which cannot follow
|
|
79
|
+
# `__getattr__`. Never executed, so the import above stays cheap.
|
|
80
|
+
from typing import TYPE_CHECKING
|
|
81
|
+
if TYPE_CHECKING:
|
|
82
|
+
from .cli import main
|
|
83
|
+
from .config import Env, configure, envs, runners
|
|
84
|
+
from .layers import (
|
|
85
|
+
Apptainer, Conda, Docker, Guix, Micromamba, Module, Nix, Podman, Slurm, Ssh, Uv, Vars, Venv,
|
|
86
|
+
)
|
|
87
|
+
from .runner import (
|
|
88
|
+
Cargo, Catch2, Errand, Executable, Level, Outcome, Pytest, Python, RunContext, Runner,
|
|
89
|
+
)
|
|
90
|
+
from .vm import Windows
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def __getattr__( name ):
|
|
94
|
+
if name in _LAZY:
|
|
95
|
+
import importlib
|
|
96
|
+
return getattr( importlib.import_module( _LAZY[ name ] ), name )
|
|
97
|
+
raise AttributeError( f"module {__name__!r} has no attribute {name!r}" )
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def __dir__( ):
|
|
101
|
+
return sorted( set( __all__ ) | set( globals() ) )
|
errand/__main__.py
ADDED
errand/_pytest_plugin.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""A pytest plugin that writes down what pytest collected, and nothing else.
|
|
2
|
+
|
|
3
|
+
Loaded with `-p errand._pytest_plugin` for a `--collect-only` run. Asking
|
|
4
|
+
pytest what its tests are, rather than reimplementing its rules, is what keeps
|
|
5
|
+
errand from quietly disagreeing with the tool it wraps -- and its marks are
|
|
6
|
+
what become entry tags.
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def pytest_collection_finish( session ):
|
|
13
|
+
where = os.environ.get( "ERRAND_COLLECT_TO" )
|
|
14
|
+
if not where:
|
|
15
|
+
return
|
|
16
|
+
items = [ ]
|
|
17
|
+
for item in session.items:
|
|
18
|
+
items.append( {
|
|
19
|
+
"id" : item.nodeid,
|
|
20
|
+
"file" : str( item.path ) if hasattr( item, "path" ) else str( item.fspath ),
|
|
21
|
+
"name" : item.name,
|
|
22
|
+
"marks": sorted( { m.name for m in item.iter_markers() } ),
|
|
23
|
+
} )
|
|
24
|
+
with open( where, "w" ) as handle:
|
|
25
|
+
json.dump( items, handle )
|
errand/batch.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Launching work and getting the shell back, then finding out how it went.
|
|
2
|
+
|
|
3
|
+
`--batch` is a MODE, not a layer: it means the same thing in every context,
|
|
4
|
+
and only the way of letting go differs.
|
|
5
|
+
|
|
6
|
+
local a session of its own, which survives the terminal
|
|
7
|
+
Ssh started over there and released
|
|
8
|
+
Slurm, … `sbatch` instead of `srun`
|
|
9
|
+
|
|
10
|
+
Nothing else about the run changes -- same paths, same queue, same output. And
|
|
11
|
+
because each place writes its plan BEFORE anything runs there, **the arrival of
|
|
12
|
+
a result file where its plan said one would be is itself the completion
|
|
13
|
+
signal.** There is
|
|
14
|
+
no protocol between the two sides, no daemon, and no agreement that can get out
|
|
15
|
+
of sync: the output tree is the state.
|
|
16
|
+
|
|
17
|
+
What is kept here is only what the tree cannot say: which places belong to one
|
|
18
|
+
submission, what handle was given for each, and where each writes its plan.
|
|
19
|
+
That record is LOCAL: it is the side that asked which knows those places went
|
|
20
|
+
together.
|
|
21
|
+
|
|
22
|
+
Results are PULLED, not pushed: a finished remote run sits there until
|
|
23
|
+
something collects it, which is what `errand status` and `errand watch` do.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import datetime
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import subprocess
|
|
31
|
+
import time
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from . import layers as L, yamlish
|
|
35
|
+
|
|
36
|
+
def batch_rel( root ) -> str:
|
|
37
|
+
from . import config
|
|
38
|
+
return os.path.join( config.state_rel( root ), "batch" )
|
|
39
|
+
|
|
40
|
+
PENDING, PREPARING, RUNNING, DONE, LOST = "pending", "preparing", "running", "done", "lost"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def batch_dir( root: Path ) -> Path:
|
|
44
|
+
d = Path( root ) / batch_rel( root )
|
|
45
|
+
d.mkdir( parents = True, exist_ok = True )
|
|
46
|
+
return d
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def new_id( root: Path ) -> str:
|
|
50
|
+
"""Short, and not already taken."""
|
|
51
|
+
import hashlib
|
|
52
|
+
seed = f"{time.time_ns()}-{os.getpid()}"
|
|
53
|
+
return hashlib.sha256( seed.encode() ).hexdigest()[ : 4 ]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def record_path( root: Path, ident: str ) -> Path:
|
|
57
|
+
return batch_dir( root ) / f"{ident}.yaml"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def save( root: Path, record: dict ):
|
|
61
|
+
yamlish.write( record_path( root, record[ "id" ] ), record )
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_all( root: Path ) -> list:
|
|
65
|
+
out = [ ]
|
|
66
|
+
for path in sorted( batch_dir( root ).glob( "*.yaml" ) ):
|
|
67
|
+
got = yamlish.read( path )
|
|
68
|
+
if got:
|
|
69
|
+
out.append( got )
|
|
70
|
+
return sorted( out, key = lambda r: r.get( "when", "" ) )
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def forget( root: Path, ident: str ):
|
|
74
|
+
record_path( root, ident ).unlink( missing_ok = True )
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ── letting go ───────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
def detach( env, tags, argv, *, root, log: Path, child_env: dict, phase = "run",
|
|
80
|
+
then = None ) -> dict:
|
|
81
|
+
"""Start `argv` through `env`'s layers of `phase` and come straight back.
|
|
82
|
+
|
|
83
|
+
`then` is what to run once that is done, inside the same allocation but
|
|
84
|
+
outside the layers after its scheduler: how a preparation submits the run
|
|
85
|
+
it has just sized, without anybody waiting for it here.
|
|
86
|
+
|
|
87
|
+
-> { kind, handle, host?, remote_root?, options? }, or { kind: "failed" }.
|
|
88
|
+
"""
|
|
89
|
+
stack = env.stack_for( phase, tags )
|
|
90
|
+
ctx = L.Context( root = root, tags = tags, batch = True, phase = phase )
|
|
91
|
+
ssh = L.ssh_of( stack )
|
|
92
|
+
batch = L.batch_of( stack )
|
|
93
|
+
|
|
94
|
+
if L.vm_of( stack ) is not None:
|
|
95
|
+
return { "kind": "failed", "handle": "",
|
|
96
|
+
"error": f"{env.name} is a virtual machine: `--batch` does not reach one yet" }
|
|
97
|
+
|
|
98
|
+
tail, python, work = stack, "python", ctx
|
|
99
|
+
if ssh is not None:
|
|
100
|
+
tail, python = stack[ 1 : ], ssh.python
|
|
101
|
+
work = L.Context( root = ssh.remote_root( ctx ), tags = tags, remote = True,
|
|
102
|
+
batch = True, phase = phase )
|
|
103
|
+
cmd = L.Command( [ python, "-m", "errand", *argv ], dict( child_env ) )
|
|
104
|
+
follow = L.Command( [ python, "-m", "errand", *then ], dict( child_env ) ) if then else None
|
|
105
|
+
if follow is not None and batch is not None:
|
|
106
|
+
# Inside the allocation, outside the layers after the scheduler -- but with the interpreter they choose: the
|
|
107
|
+
# submission is errand itself, and the machine's own `python3` has no errand ( and, run from the project root,
|
|
108
|
+
# takes a directory named `errand` for a package without `__main__` ).
|
|
109
|
+
k = tail.index( batch )
|
|
110
|
+
follow = L.compose( L.interpreters_of( tail[ k + 1 : ] ), follow, work )
|
|
111
|
+
inner = L.compose( tail[ k + 1 : ], cmd, work )
|
|
112
|
+
cmd = L.Command( [ "sh", "-c", f"{inner.shell()} && {follow.shell()}" ], { } )
|
|
113
|
+
tail = tail[ : k + 1 ]
|
|
114
|
+
follow = None
|
|
115
|
+
wrapped = L.compose( tail, cmd, work )
|
|
116
|
+
if follow is not None:
|
|
117
|
+
# No scheduler: the same detached session goes on to the next step.
|
|
118
|
+
wrapped = L.Command( [ "sh", "-c", f"{wrapped.shell()} && {follow.shell()}" ], { } )
|
|
119
|
+
|
|
120
|
+
if ssh is None and batch is None:
|
|
121
|
+
log.parent.mkdir( parents = True, exist_ok = True )
|
|
122
|
+
with open( log, "wb" ) as handle:
|
|
123
|
+
# A session of its own: closing the terminal, or the shell sending
|
|
124
|
+
# a hangup to its group, must not take the work with it.
|
|
125
|
+
child = subprocess.Popen( wrapped.argv, cwd = root,
|
|
126
|
+
env = { **os.environ, **wrapped.env },
|
|
127
|
+
stdout = handle, stderr = subprocess.STDOUT,
|
|
128
|
+
start_new_session = True )
|
|
129
|
+
return { "kind": "local", "handle": str( child.pid ), "log": str( log ) }
|
|
130
|
+
|
|
131
|
+
if ssh is None:
|
|
132
|
+
# A batch system here on this machine: it is its own detachment.
|
|
133
|
+
got = subprocess.run( wrapped.argv, cwd = root, capture_output = True, text = True,
|
|
134
|
+
env = { **os.environ, **wrapped.env } )
|
|
135
|
+
return _from_submission( got, kind = "batch" )
|
|
136
|
+
|
|
137
|
+
L.push( root, ssh.host, work.root, ssh.options )
|
|
138
|
+
|
|
139
|
+
remote_log = f"{work.root}/{batch_rel( root )}/{log.name}"
|
|
140
|
+
line = ( f"mkdir -p {work.root}/{batch_rel( root )} && cd {work.root} && "
|
|
141
|
+
f"{wrapped.shell()}" )
|
|
142
|
+
|
|
143
|
+
if batch is not None:
|
|
144
|
+
# sbatch returns as soon as the job is queued: nothing to let go of.
|
|
145
|
+
got = _ssh_capture( ssh, line )
|
|
146
|
+
record = _from_submission( got, kind = "batch" )
|
|
147
|
+
else:
|
|
148
|
+
# Let go over there: a new session, output to a file we can fetch, and
|
|
149
|
+
# the pid printed back so there is something to ask about later.
|
|
150
|
+
detached = ( f"{line} > {remote_log} 2>&1 & echo ERRAND_PID=$!" )
|
|
151
|
+
got = _ssh_capture( ssh, detached )
|
|
152
|
+
found = re.search( r"ERRAND_PID=(\d+)", got.stdout or "" )
|
|
153
|
+
record = ( { "kind": "ssh", "handle": found.group( 1 ) } if found
|
|
154
|
+
else { "kind": "failed", "handle": "",
|
|
155
|
+
"error": ( got.stderr or got.stdout or "" ).strip()[ : 400 ] } )
|
|
156
|
+
|
|
157
|
+
record.update( host = ssh.host, remote_root = str( work.root ),
|
|
158
|
+
options = list( ssh.options ), log = remote_log )
|
|
159
|
+
return record
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def submitted_path( plan: Path ) -> Path:
|
|
163
|
+
"""Where a preparation says which job it submitted for the run it prepared."""
|
|
164
|
+
return plan.with_name( plan.stem + ".submitted.json" )
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _ssh_capture( ssh, line ):
|
|
168
|
+
import shlex
|
|
169
|
+
return subprocess.run( [ "ssh", *ssh.options, ssh.host, f"$SHELL -ic {shlex.quote( line )}" ],
|
|
170
|
+
capture_output = True, text = True )
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _from_submission( got, kind ):
|
|
174
|
+
# Every batch system prints its job id and little else; slurm's wording is
|
|
175
|
+
# the one worth matching exactly, the rest fall back to the first number.
|
|
176
|
+
text = ( got.stdout or "" ) + ( got.stderr or "" )
|
|
177
|
+
found = re.search( r"Submitted batch job (\d+)", text ) or re.search( r"\b(\d{3,})\b", text )
|
|
178
|
+
if got.returncode or not found:
|
|
179
|
+
return { "kind": "failed", "handle": "", "error": text.strip()[ : 400 ] }
|
|
180
|
+
return { "kind": kind, "handle": found.group( 1 ) }
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ── asking how it is going ───────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
def alive( place: dict ) -> bool | None:
|
|
186
|
+
"""Is the thing we were given a handle for still going? None: cannot say."""
|
|
187
|
+
kind, handle = place.get( "kind" ), str( place.get( "handle" ) or "" )
|
|
188
|
+
if not handle:
|
|
189
|
+
return False
|
|
190
|
+
try:
|
|
191
|
+
if kind == "local":
|
|
192
|
+
os.kill( int( handle ), 0 )
|
|
193
|
+
return True
|
|
194
|
+
if kind == "ssh":
|
|
195
|
+
return _remote( place, f"kill -0 {handle}" ).returncode == 0
|
|
196
|
+
if kind == "batch":
|
|
197
|
+
if place.get( "host" ):
|
|
198
|
+
got = _remote( place, f"squeue -h -j {handle} -o %T" )
|
|
199
|
+
else:
|
|
200
|
+
got = subprocess.run( [ "squeue", "-h", "-j", handle, "-o", "%T" ],
|
|
201
|
+
capture_output = True, text = True, timeout = 30 )
|
|
202
|
+
return bool( ( got.stdout or "" ).strip() ) if not got.returncode else False
|
|
203
|
+
except ( ProcessLookupError, ValueError ):
|
|
204
|
+
return False
|
|
205
|
+
except ( OSError, subprocess.SubprocessError ):
|
|
206
|
+
return None
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _remote( place: dict, line: str ):
|
|
211
|
+
return subprocess.run( [ "ssh", *( place.get( "options" ) or [ ] ), place[ "host" ], line ],
|
|
212
|
+
capture_output = True, text = True, timeout = 60 )
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _json( path: Path ):
|
|
216
|
+
import json
|
|
217
|
+
try:
|
|
218
|
+
return json.loads( path.read_text() )
|
|
219
|
+
except ( OSError, ValueError ):
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def plan_of_place( root: Path, place: dict ):
|
|
224
|
+
"""The plan that place wrote, once it has: what it is going to run."""
|
|
225
|
+
return _json( root / place[ "plan" ] ) if place.get( "plan" ) else None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def runs_of( root: Path, record: dict ) -> list:
|
|
229
|
+
"""Every run of a submission whose place has said what it will run."""
|
|
230
|
+
out = [ ]
|
|
231
|
+
for place in record.get( "places", [ ] ):
|
|
232
|
+
plan = plan_of_place( root, place )
|
|
233
|
+
if plan is not None:
|
|
234
|
+
out += [ { **r, "env": place.get( "env" ) or r.get( "env" ) } for r in plan[ "runs" ] ]
|
|
235
|
+
return out
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def run_job( root: Path, place: dict ):
|
|
239
|
+
"""The job a preparation submitted for its run phase, as a place of its own."""
|
|
240
|
+
if not place.get( "plan" ):
|
|
241
|
+
return None
|
|
242
|
+
got = _json( submitted_path( root / place[ "plan" ] ) )
|
|
243
|
+
return { **place, **got } if got else None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def alive_anywhere( root: Path, place: dict ):
|
|
247
|
+
"""Still going: the preparation, or the run it submitted."""
|
|
248
|
+
then = run_job( root, place )
|
|
249
|
+
return alive( then ) if then is not None else alive( place )
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def collect( root: Path, record: dict ):
|
|
253
|
+
"""Fetch what a remote submission has produced so far: its plan first, then
|
|
254
|
+
what the plan says will be written. Cheap, and repeatable."""
|
|
255
|
+
for place in record.get( "places", [ ] ):
|
|
256
|
+
if not place.get( "host" ):
|
|
257
|
+
continue
|
|
258
|
+
there = Path( place[ "remote_root" ] )
|
|
259
|
+
options = place.get( "options" ) or [ ]
|
|
260
|
+
L.fetch( [ str( Path( place[ "plan" ] ).parent ) ], place[ "host" ], there, root, options )
|
|
261
|
+
plan = plan_of_place( root, place )
|
|
262
|
+
if plan is not None:
|
|
263
|
+
L.fetch( sorted( { r[ "under" ] for r in plan[ "runs" ] } ), place[ "host" ], there,
|
|
264
|
+
root, options )
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def states( root: Path, record: dict ) -> list:
|
|
268
|
+
"""One row per expected run: what the output tree says about it.
|
|
269
|
+
|
|
270
|
+
The tree is the state. A run whose result file is there is done, whatever
|
|
271
|
+
any handle claims; one whose file is absent is still owed, and whether it
|
|
272
|
+
is still going or lost is the only thing a handle is asked about. A place
|
|
273
|
+
that has not written its plan yet is still preparing: one row says so.
|
|
274
|
+
|
|
275
|
+
WHERE it ran is looked for, not predicted. The place is part of the path,
|
|
276
|
+
and a batch job carries the name of whichever compute node the scheduler
|
|
277
|
+
happened to pick. So the case directory is searched, and the result that
|
|
278
|
+
names this run's environment and parameters is the one.
|
|
279
|
+
"""
|
|
280
|
+
rows = [ ]
|
|
281
|
+
for place in record.get( "places", [ ] ):
|
|
282
|
+
living = alive_anywhere( root, place )
|
|
283
|
+
plan = plan_of_place( root, place )
|
|
284
|
+
if plan is None:
|
|
285
|
+
rows.append( { "label": "(preparing)", "env": place.get( "env" ), "place": "?",
|
|
286
|
+
"params": { }, "under": "",
|
|
287
|
+
"state": PREPARING if living or living is None else LOST } )
|
|
288
|
+
continue
|
|
289
|
+
for run in plan[ "runs" ]:
|
|
290
|
+
run = { **run, "env": place.get( "env" ) or run.get( "env" ) }
|
|
291
|
+
result = _result_for( root, run )
|
|
292
|
+
if result:
|
|
293
|
+
rows.append( { **run, "state": DONE, "status": result.get( "status" ),
|
|
294
|
+
"place": result.get( "place" ) or run.get( "place" ),
|
|
295
|
+
"seconds": result.get( "duration_s" ),
|
|
296
|
+
"results": result.get( "results" ) or { } } )
|
|
297
|
+
else:
|
|
298
|
+
rows.append( { **run, "state": RUNNING if living or living is None else LOST } )
|
|
299
|
+
return rows
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _result_for( root: Path, run: dict ):
|
|
303
|
+
path = result_path_for( root, run )
|
|
304
|
+
return yamlish.read( path ) if path else None
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def result_path_for( root: Path, run: dict ):
|
|
308
|
+
"""WHERE this run's result file landed, or None while it is still owed.
|
|
309
|
+
|
|
310
|
+
The path and not just its contents, because whoever wants to watch the run
|
|
311
|
+
wants `output.txt` beside it.
|
|
312
|
+
"""
|
|
313
|
+
if not run.get( "under" ):
|
|
314
|
+
return None
|
|
315
|
+
under = root / run[ "under" ]
|
|
316
|
+
if not under.is_dir():
|
|
317
|
+
return None
|
|
318
|
+
# Newest first: the run directories are named after the moment they
|
|
319
|
+
# started, so sorting their names backwards IS sorting them by time.
|
|
320
|
+
for path in sorted( under.glob( "*/result.yaml" ), reverse = True ):
|
|
321
|
+
got = yamlish.read( path )
|
|
322
|
+
if not got:
|
|
323
|
+
continue
|
|
324
|
+
if got.get( "env" ) == run.get( "env" ) and ( got.get( "params" ) or { } ) == \
|
|
325
|
+
( run.get( "params" ) or { } ):
|
|
326
|
+
return path
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def finished( rows ) -> bool:
|
|
331
|
+
return all( r[ "state" ] in ( DONE, LOST ) for r in rows )
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def now( ) -> str:
|
|
335
|
+
return datetime.datetime.now().replace( microsecond = 0 ).isoformat( sep = " " )
|