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 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
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit( main() )
@@ -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 = " " )