kkrunner 0.1.0b1__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.
Files changed (55) hide show
  1. kkrunner/__init__.py +16 -0
  2. kkrunner/__main__.py +3 -0
  3. kkrunner/_version.py +5 -0
  4. kkrunner/backends/__init__.py +35 -0
  5. kkrunner/backends/endpoint.py +179 -0
  6. kkrunner/backends/local.py +328 -0
  7. kkrunner/backends/local_managed.py +363 -0
  8. kkrunner/backends/local_process.py +144 -0
  9. kkrunner/backends/mpi.py +63 -0
  10. kkrunner/backends/pool.py +68 -0
  11. kkrunner/backends/psij.py +930 -0
  12. kkrunner/cli.py +180 -0
  13. kkrunner/files/__init__.py +27 -0
  14. kkrunner/files/availability.py +92 -0
  15. kkrunner/files/fingerprints.py +35 -0
  16. kkrunner/files/manifest.py +376 -0
  17. kkrunner/files/paths.py +66 -0
  18. kkrunner/files/queries.py +110 -0
  19. kkrunner/files/task_file.py +67 -0
  20. kkrunner/files/versions.py +187 -0
  21. kkrunner/projects/__init__.py +21 -0
  22. kkrunner/projects/manager.py +89 -0
  23. kkrunner/projects/repository.py +436 -0
  24. kkrunner/projects/resolution.py +19 -0
  25. kkrunner/remote/__init__.py +6 -0
  26. kkrunner/remote/executor.py +715 -0
  27. kkrunner/remote/mailbox.py +85 -0
  28. kkrunner/remote/protocol.py +27 -0
  29. kkrunner/remote/runner.py +771 -0
  30. kkrunner/remote/service.py +600 -0
  31. kkrunner/remote/stream.py +154 -0
  32. kkrunner/remote/supervisor.py +318 -0
  33. kkrunner/remote/transport.py +429 -0
  34. kkrunner/remote/worker.py +199 -0
  35. kkrunner/runs/__init__.py +45 -0
  36. kkrunner/runs/base.py +700 -0
  37. kkrunner/runs/conflicts.py +92 -0
  38. kkrunner/runs/manager.py +1132 -0
  39. kkrunner/runs/metadata.py +40 -0
  40. kkrunner/runs/model.py +58 -0
  41. kkrunner/runs/processing.py +214 -0
  42. kkrunner/runs/records.py +252 -0
  43. kkrunner/support/__init__.py +1 -0
  44. kkrunner/support/async_utils.py +30 -0
  45. kkrunner/support/decorators.py +24 -0
  46. kkrunner/support/durability.py +137 -0
  47. kkrunner/support/file_locks.py +91 -0
  48. kkrunner/support/growing_file_tail.py +260 -0
  49. kkrunner/support/process_identity.py +85 -0
  50. kkrunner-0.1.0b1.dist-info/METADATA +162 -0
  51. kkrunner-0.1.0b1.dist-info/RECORD +55 -0
  52. kkrunner-0.1.0b1.dist-info/WHEEL +5 -0
  53. kkrunner-0.1.0b1.dist-info/entry_points.txt +2 -0
  54. kkrunner-0.1.0b1.dist-info/licenses/LICENCE +19 -0
  55. kkrunner-0.1.0b1.dist-info/top_level.txt +1 -0
kkrunner/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """Backend-independent entry points for execution and durable run management."""
2
+
3
+ from kkrunner._version import __version__
4
+ from kkrunner.files import TaskFile
5
+ from kkrunner.runs import ExecutionOutcome, ExecutionSpec, ProcessingPending, RunManager, RunState, RunStore
6
+
7
+ __all__ = [
8
+ "ExecutionOutcome",
9
+ "ExecutionSpec",
10
+ "ProcessingPending",
11
+ "RunManager",
12
+ "RunState",
13
+ "RunStore",
14
+ "TaskFile",
15
+ "__version__",
16
+ ]
kkrunner/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from kkrunner.cli import main
2
+
3
+ main()
kkrunner/_version.py ADDED
@@ -0,0 +1,5 @@
1
+ """Version of the installed KKRunner distribution."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("kkrunner")
@@ -0,0 +1,35 @@
1
+ """Execution backends and their reusable endpoint identities."""
2
+
3
+ from importlib import import_module
4
+ from typing import TYPE_CHECKING
5
+
6
+ from kkrunner.backends.endpoint import ExecutorEndpoint
7
+
8
+ if TYPE_CHECKING:
9
+ from kkrunner.backends.local import LocalExecutor
10
+ from kkrunner.backends.pool import ExecutorPool, default_executor_pool
11
+ from kkrunner.backends.psij import PsijExecutor
12
+
13
+ # Run contracts use endpoint metadata. Load implementations only on request so
14
+ # importing those contracts does not load backends or create an import cycle.
15
+ _EXPORTS = {
16
+ "LocalExecutor": "kkrunner.backends.local",
17
+ "PsijExecutor": "kkrunner.backends.psij",
18
+ "ExecutorPool": "kkrunner.backends.pool",
19
+ "default_executor_pool": "kkrunner.backends.pool",
20
+ }
21
+
22
+ __all__ = ["ExecutorEndpoint", "ExecutorPool", "LocalExecutor", "PsijExecutor", "default_executor_pool"]
23
+
24
+
25
+ def __getattr__(name):
26
+ module = _EXPORTS.get(name)
27
+ if module is None:
28
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
29
+ value = getattr(import_module(module), name)
30
+ globals()[name] = value
31
+ return value
32
+
33
+
34
+ def __dir__():
35
+ return sorted(set(globals()) | set(__all__))
@@ -0,0 +1,179 @@
1
+ """Serializable execution endpoint identity and reconstruction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ExecutorEndpoint:
11
+ """Identity of a reusable execution backend.
12
+
13
+ The endpoint contains only connection/backend properties. Launch and
14
+ staging policy deliberately live elsewhere so one executor can be shared
15
+ by profiles that differ only in how individual calculations are launched.
16
+ The value is serializable and hashable, so it can also be persisted with a
17
+ run and used as an :class:`ExecutorPool` key after process restart.
18
+ """
19
+
20
+ executor: str = "local"
21
+ host: Any = None
22
+ ssh_options: Any = ()
23
+ remote_command: Any = ()
24
+ psij_backend: Any = None
25
+ psij_url: Any = None
26
+ ssh_zstd: bool = False
27
+ filesystem_id: str | None = None
28
+
29
+ def __post_init__(self):
30
+ object.__setattr__(self, "executor", str(self.executor or "local"))
31
+ if self.filesystem_id is not None and (not isinstance(self.filesystem_id, str) or not self.filesystem_id):
32
+ raise TypeError("filesystem_id must be a nonempty string or None")
33
+ for name in ("ssh_options", "remote_command"):
34
+ value = getattr(self, name)
35
+ if value is None:
36
+ value = ()
37
+ elif isinstance(value, str):
38
+ value = (value,)
39
+ else:
40
+ value = tuple(value)
41
+ object.__setattr__(self, name, value)
42
+
43
+ for name in ("host", "psij_backend", "psij_url"):
44
+ value = getattr(self, name)
45
+ if value is not None and not isinstance(value, str):
46
+ raise TypeError(f"ExecutorEndpoint.{name} must be a string or None")
47
+ for name in ("ssh_options", "remote_command"):
48
+ if any(not isinstance(item, str) for item in getattr(self, name)):
49
+ raise TypeError(f"ExecutorEndpoint.{name} must contain strings")
50
+ if type(self.ssh_zstd) is not bool:
51
+ raise TypeError("ExecutorEndpoint.ssh_zstd must be a bool")
52
+
53
+ # Identity describes the backend actually constructed below. Irrelevant
54
+ # profile fields must not split its pool key or collision namespace.
55
+ if self.executor in ("local", "psij"):
56
+ for name, value in (
57
+ ("host", None),
58
+ ("ssh_options", ()),
59
+ ("remote_command", ()),
60
+ ("ssh_zstd", False),
61
+ ):
62
+ object.__setattr__(self, name, value)
63
+ if self.executor == "local":
64
+ object.__setattr__(self, "psij_backend", None)
65
+ object.__setattr__(self, "psij_url", None)
66
+ elif self.executor == "psij":
67
+ object.__setattr__(self, "psij_backend", self.psij_backend or "slurm")
68
+ elif self.executor == "ssh":
69
+ if not self.remote_command:
70
+ object.__setattr__(self, "remote_command", ("kkrunner", "remote"))
71
+ if self.psij_backend is not None or self.psij_url is not None:
72
+ object.__setattr__(self, "psij_backend", self.psij_backend or "slurm")
73
+
74
+
75
+ def endpoint_for_executor(executor) -> ExecutorEndpoint:
76
+ """Describe a live executor without persisting launch/staging policy."""
77
+ from kkrunner.backends.local import LocalExecutor
78
+ from kkrunner.backends.psij import PsijExecutor
79
+ from kkrunner.remote.executor import RemoteEndpointExecutor
80
+ from kkrunner.remote.transport import SshTransport
81
+
82
+ if isinstance(executor, LocalExecutor):
83
+ return ExecutorEndpoint()
84
+ if isinstance(executor, PsijExecutor):
85
+ return ExecutorEndpoint(
86
+ executor="psij",
87
+ psij_backend=executor.backend,
88
+ psij_url=executor.url,
89
+ )
90
+ if isinstance(executor, RemoteEndpointExecutor):
91
+ transport = executor.transport
92
+ psij_backend = None
93
+ psij_url = None
94
+ if executor.backend == "psij":
95
+ options = executor.backend_options or {}
96
+ # ``RemoteEndpointExecutor(..., backend="psij")`` is a complete
97
+ # public configuration even when no options are supplied: the
98
+ # target-side PSI/J backend then defaults to Slurm. Persist that
99
+ # default explicitly so restart reconstruction cannot silently
100
+ # turn the endpoint into the ordinary remote-local backend.
101
+ psij_backend = options.get("backend") or "slurm"
102
+ psij_url = options.get("url")
103
+
104
+ if not isinstance(transport, SshTransport):
105
+ return ExecutorEndpoint(
106
+ executor=(
107
+ "custom:{}.{}:{}".format(
108
+ type(executor).__module__,
109
+ type(executor).__qualname__,
110
+ executor.name or "remote",
111
+ )
112
+ ),
113
+ host=getattr(transport, "host", None),
114
+ remote_command=executor.command,
115
+ filesystem_id=executor.filesystem_id,
116
+ psij_backend=psij_backend,
117
+ psij_url=psij_url,
118
+ )
119
+ return ExecutorEndpoint(
120
+ executor="ssh",
121
+ host=transport.host,
122
+ ssh_options=transport.options,
123
+ ssh_zstd=transport.zstd,
124
+ remote_command=executor.command,
125
+ filesystem_id=executor.filesystem_id,
126
+ psij_backend=psij_backend,
127
+ psij_url=psij_url,
128
+ )
129
+
130
+ # Custom executors can participate in duplicate-submit protection in this
131
+ # process. Reconstruction is intentionally limited to known endpoint types.
132
+ return ExecutorEndpoint(
133
+ executor="custom:{}.{}:{}".format(
134
+ type(executor).__module__,
135
+ type(executor).__qualname__,
136
+ executor.name or type(executor).__qualname__,
137
+ )
138
+ )
139
+
140
+
141
+ def create_executor_from_endpoint(endpoint: ExecutorEndpoint):
142
+ """Reconstruct a standard executor from persisted endpoint identity."""
143
+ from kkrunner.backends.local import LocalExecutor
144
+ from kkrunner.backends.psij import PsijExecutor
145
+ from kkrunner.remote.executor import RemoteEndpointExecutor
146
+ from kkrunner.remote.transport import SshTransport
147
+
148
+ if isinstance(endpoint, dict):
149
+ endpoint = ExecutorEndpoint(**endpoint)
150
+
151
+ if endpoint.executor == "local":
152
+ return LocalExecutor()
153
+ if endpoint.executor == "psij":
154
+ return PsijExecutor(
155
+ endpoint.psij_backend or "slurm",
156
+ url=endpoint.psij_url,
157
+ )
158
+ if endpoint.executor == "ssh":
159
+ if not endpoint.host:
160
+ raise ValueError("SSH execution endpoint requires 'host'")
161
+ kwargs = {}
162
+ if endpoint.remote_command:
163
+ kwargs["command"] = endpoint.remote_command
164
+ if endpoint.psij_backend is not None or endpoint.psij_url is not None:
165
+ kwargs["backend"] = "psij"
166
+ kwargs["backend_options"] = {
167
+ "backend": endpoint.psij_backend or "slurm",
168
+ "url": endpoint.psij_url,
169
+ }
170
+ return RemoteEndpointExecutor(
171
+ SshTransport(
172
+ endpoint.host,
173
+ options=endpoint.ssh_options,
174
+ zstd=endpoint.ssh_zstd,
175
+ ),
176
+ filesystem_id=endpoint.filesystem_id,
177
+ **kwargs,
178
+ )
179
+ raise ValueError(f"Cannot reconstruct unsupported execution endpoint {endpoint.executor!r}")
@@ -0,0 +1,328 @@
1
+ """Local subprocess execution backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from threading import Event, Lock, Thread
7
+
8
+ from kkrunner.backends.local_process import materialize_local_spec, pump_stream, start_local_process, stop_local_process
9
+ from kkrunner.files.availability import clear_declared_outputs, discover_local_files, validate_output_collisions
10
+ from kkrunner.files.manifest import FileManifest
11
+ from kkrunner.files.paths import file_path
12
+ from kkrunner.runs.base import (
13
+ ExecutionOutcome,
14
+ ExecutionSpec,
15
+ Executor,
16
+ FailurePhase,
17
+ OutputObservers,
18
+ RunState,
19
+ SubmissionNotStarted,
20
+ close_output_files,
21
+ finish_monitor,
22
+ materialize_output_streams,
23
+ wait_for_monitor,
24
+ )
25
+
26
+
27
+ class LocalRunner:
28
+ """One local subprocess execution.
29
+
30
+ The runner owns only process lifecycle, output-file writing and raw output
31
+ observation. It deliberately performs no task-specific parsing.
32
+ """
33
+
34
+ def __init__(self, spec: ExecutionSpec, callback=None):
35
+ self.spec = spec
36
+ self.callback = callback
37
+ self.result = ExecutionOutcome(
38
+ output_file=file_path(spec.output_file, spec.directory),
39
+ input_file=file_path(spec.input_file, spec.directory),
40
+ error_file=file_path(spec.error_file, spec.directory),
41
+ )
42
+
43
+ self._state = RunState.NEW
44
+ self._output_observers = OutputObservers()
45
+ self._lock = Lock()
46
+ self._cancel_requested = Event()
47
+ self._started = Event()
48
+ self._done = Event()
49
+
50
+ self._thread = None
51
+ self._loop = None
52
+ self._process = None
53
+ self._pid = None
54
+ self._stop_task = None
55
+ self._exception = None
56
+ self._callback_exception = None
57
+ self._failure_phase = None
58
+ self._failure_message = None
59
+
60
+ @property
61
+ def state(self):
62
+ return self._state
63
+
64
+ @property
65
+ def exception(self):
66
+ return self._exception
67
+
68
+ @property
69
+ def backend_id(self):
70
+ return None if self._pid is None else str(self._pid)
71
+
72
+ @property
73
+ def failure_phase(self):
74
+ return self._failure_phase
75
+
76
+ @property
77
+ def failure_message(self):
78
+ return self._failure_message
79
+
80
+ @property
81
+ def return_code(self):
82
+ return self.result.return_code
83
+
84
+ def add_output_listener(self, callback):
85
+ self._output_observers.add(callback)
86
+
87
+ def add_output_error_listener(self, callback):
88
+ self._output_observers.add_error_listener(callback)
89
+
90
+ def _output_target(self, kind):
91
+ # A missing stderr target means live observation only, not an
92
+ # implicit merge into the saved stdout artifact.
93
+ return self.spec.error_file if kind == "err" else self.spec.output_file
94
+
95
+ def _forward_output(self, data, kind):
96
+ target = self._output_target(kind)
97
+ if target is not None:
98
+ target.write(data)
99
+ target.flush()
100
+ self._output_observers.emit_bytes(data, kind)
101
+
102
+ def _ensure_stop_task(self):
103
+ if self._process is None or self._process.returncode is not None:
104
+ return None
105
+ if self._stop_task is None or self._stop_task.done():
106
+ self._stop_task = asyncio.create_task(stop_local_process(self._process))
107
+ return self._stop_task
108
+
109
+ def cancel(self):
110
+ self._cancel_requested.set()
111
+ loop = self._loop
112
+ if loop is None:
113
+ if self._state == RunState.NEW:
114
+ self._state = RunState.CANCELLED
115
+ return
116
+ try:
117
+ loop.call_soon_threadsafe(self._ensure_stop_task)
118
+ except RuntimeError:
119
+ pass
120
+
121
+ async def _execute(self):
122
+ self._loop = asyncio.get_running_loop()
123
+ if self._cancel_requested.is_set():
124
+ self._state = RunState.CANCELLED
125
+ self._started.set()
126
+ return self.result
127
+
128
+ try:
129
+ protected = [
130
+ file_path(value, self.spec.directory)
131
+ for value in (self.spec.input_file, self.spec.stdin, *self.spec.input_files, *self.spec.cache_files)
132
+ ]
133
+ # Preserve already-open stdout/stderr handles: an unlink would
134
+ # disconnect their inode from the named destination.
135
+ outputs = [file_path(value, self.spec.directory) for value in self.spec.output_files]
136
+ validate_output_collisions(
137
+ (*outputs, self.result.output_file, self.result.error_file),
138
+ protected=protected,
139
+ )
140
+ self.spec = materialize_output_streams(self.spec)
141
+ clear_declared_outputs(
142
+ outputs,
143
+ protected=(*protected, self.result.output_file, self.result.error_file),
144
+ )
145
+ try:
146
+ self._process = await start_local_process(self.spec)
147
+ except BaseException as exc:
148
+ self._failure_phase = FailurePhase.LAUNCH
149
+ self._failure_message = str(exc)
150
+ self._state = RunState.FAILED
151
+ raise
152
+
153
+ self._pid = self._process.pid
154
+ self._state = RunState.RUNNING
155
+ self._started.set()
156
+
157
+ if self._cancel_requested.is_set():
158
+ stop_task = self._ensure_stop_task()
159
+ if stop_task is not None:
160
+ await stop_task
161
+
162
+ wait_task = asyncio.create_task(self._process.wait())
163
+ stdout_task = asyncio.create_task(
164
+ pump_stream(
165
+ self._process.stdout,
166
+ lambda data: self._forward_output(data, "out"),
167
+ )
168
+ )
169
+ stderr_task = asyncio.create_task(
170
+ pump_stream(
171
+ self._process.stderr,
172
+ lambda data: self._forward_output(data, "err"),
173
+ )
174
+ )
175
+ tasks = (wait_task, stdout_task, stderr_task)
176
+ try:
177
+ return_code, _, _ = await asyncio.gather(*tasks)
178
+ finally:
179
+ for task in tasks:
180
+ if not task.done():
181
+ task.cancel()
182
+ await asyncio.gather(*tasks, return_exceptions=True)
183
+
184
+ self.result.return_code = return_code
185
+ if self._cancel_requested.is_set():
186
+ self._state = RunState.CANCELLED
187
+ elif return_code == 0:
188
+ self._state = RunState.FINISHED
189
+ else:
190
+ self._state = RunState.FAILED
191
+ self._failure_phase = FailurePhase.EXECUTION
192
+ self._failure_message = f"Process exited with status {return_code}"
193
+ if self._state == RunState.FINISHED:
194
+ files = discover_local_files(FileManifest.from_spec(self.spec))
195
+ if files.failed_artifacts:
196
+ self._state = RunState.FAILED
197
+ self._failure_phase = FailurePhase.ARTIFACTS
198
+ self._failure_message = files.availability_message
199
+ return self.result
200
+ except BaseException:
201
+ self._started.set()
202
+ if self._state not in (RunState.CANCELLED, RunState.FAILED):
203
+ self._state = RunState.FAILED
204
+ self._failure_phase = FailurePhase.EXECUTION
205
+ if self._process is not None and self._process.returncode is None:
206
+ await stop_local_process(self._process)
207
+ raise
208
+ finally:
209
+ if self._stop_task is not None:
210
+ await asyncio.gather(self._stop_task, return_exceptions=True)
211
+ self._output_observers.finish_bytes()
212
+ close_output_files(self.spec.output_file, self.spec.error_file)
213
+ self._process = None
214
+ self._loop = None
215
+
216
+ def _thread_main(self):
217
+ try:
218
+ asyncio.run(self._execute())
219
+ except BaseException as exc:
220
+ self._exception = exc
221
+ if self._failure_message is None:
222
+ self._failure_message = str(exc)
223
+ finally:
224
+ finish_monitor(self)
225
+
226
+ def submit(self):
227
+ with self._lock:
228
+ if self._state == RunState.CANCELLED and self._thread is None:
229
+ raise RuntimeError("Run was cancelled before submission")
230
+ if self._state != RunState.NEW:
231
+ raise RuntimeError(f"Run is already {self._state.value}")
232
+ self._state = RunState.SUBMITTING
233
+ self._thread = Thread(target=self._thread_main, daemon=True)
234
+ self._thread.start()
235
+
236
+ self._started.wait()
237
+ if self._pid is None and self._state == RunState.FAILED:
238
+ self._done.wait()
239
+ error = self._exception or RuntimeError(self._failure_message or "Launch failed")
240
+ raise SubmissionNotStarted(str(error)) from self._exception
241
+
242
+ def wait(self):
243
+ if self._state == RunState.NEW:
244
+ raise RuntimeError("Run has not been submitted")
245
+ if self._state == RunState.CANCELLED and self._thread is None:
246
+ return self.result
247
+ return wait_for_monitor(self)
248
+
249
+ def run(self):
250
+ if self._state == RunState.NEW:
251
+ self.submit()
252
+ return self.wait()
253
+
254
+ async def run_async(self):
255
+ if self._state == RunState.NEW:
256
+ self.submit()
257
+ return await asyncio.to_thread(self.wait)
258
+
259
+
260
+ class LocalExecutor(Executor):
261
+ """Create raw runners executed as local subprocesses."""
262
+
263
+ name = "local"
264
+
265
+ def create_run(self, spec: ExecutionSpec, callback=None):
266
+ return LocalRunner(materialize_local_spec(spec), callback=callback)
267
+
268
+ def create_managed_run(self, spec: ExecutionSpec, *, run_id: str, callback=None):
269
+ from kkrunner.backends.local_managed import ManagedLocalRunner
270
+
271
+ return ManagedLocalRunner(
272
+ self,
273
+ spec,
274
+ run_id=run_id,
275
+ callback=callback,
276
+ )
277
+
278
+ def recover_submission(self, run_id, submission, *, submitted_at=None):
279
+ from kkrunner.backends.local_managed import _local_status_from_facts
280
+ from kkrunner.remote.service import recover_remote
281
+
282
+ reply = recover_remote(
283
+ run_id,
284
+ {
285
+ "executor": "local",
286
+ "directory": submission.execution.directory.local,
287
+ },
288
+ )
289
+ if reply is None:
290
+ # The absence of a ticket is not proof that submission never
291
+ # happened. Never authorize an automatic duplicate launch.
292
+ raise NotImplementedError("Submission identity is not recoverable")
293
+ return _local_status_from_facts(
294
+ reply["remote_id"],
295
+ reply,
296
+ submission.files,
297
+ )
298
+
299
+ def attach(self, run, callback=None, output_listener=None, start=True):
300
+ from kkrunner.backends.local_managed import attach_local
301
+
302
+ return attach_local(
303
+ self,
304
+ run,
305
+ callback=callback,
306
+ output_listener=output_listener,
307
+ start=start,
308
+ )
309
+
310
+ def acknowledge_submission(self, run):
311
+ from kkrunner.remote.service import acknowledge_submission
312
+
313
+ acknowledge_submission(run.id)
314
+
315
+ def acknowledge(self, run):
316
+ from kkrunner.remote.service import acknowledge_remote
317
+
318
+ acknowledge_remote(run.execution.backend_id)
319
+
320
+ def status(self, *runs):
321
+ from kkrunner.backends.local_managed import local_status
322
+
323
+ statuses = []
324
+ for run in runs:
325
+ if run.execution.endpoint.executor != "local":
326
+ raise ValueError("Run does not belong to the local backend")
327
+ statuses.append(local_status(run.execution.backend_id, run.files))
328
+ return tuple(statuses)