simulo 0.26.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.
- simulo/__init__.py +433 -0
- simulo/_client/__init__.py +6 -0
- simulo/_client/_entrypoint.py +313 -0
- simulo/_client/_mounts.py +25 -0
- simulo/_client/_runner.py +186 -0
- simulo/_client/_secure_downloads.py +1181 -0
- simulo/_client/app.py +1308 -0
- simulo/_client/asset.py +331 -0
- simulo/_client/asset_api.py +517 -0
- simulo/_client/asset_package.py +1103 -0
- simulo/_client/asset_pins.py +187 -0
- simulo/_client/builtin_aliases.py +107 -0
- simulo/_client/bundle.py +254 -0
- simulo/_client/cancel_api.py +104 -0
- simulo/_client/cli.py +9063 -0
- simulo/_client/config.py +186 -0
- simulo/_client/credentials.py +210 -0
- simulo/_client/discovery.py +214 -0
- simulo/_client/export_api.py +212 -0
- simulo/_client/export_bundle.py +296 -0
- simulo/_client/facades.py +581 -0
- simulo/_client/http.py +414 -0
- simulo/_client/identity_api.py +117 -0
- simulo/_client/install_samples.py +267 -0
- simulo/_client/jobs_api.py +224 -0
- simulo/_client/learning.py +393 -0
- simulo/_client/login.py +319 -0
- simulo/_client/mode.py +29 -0
- simulo/_client/outputs.py +116 -0
- simulo/_client/packaging.py +445 -0
- simulo/_client/preflight_api.py +186 -0
- simulo/_client/preflight_render.py +200 -0
- simulo/_client/registry.py +98 -0
- simulo/_client/runtime.py +185 -0
- simulo/_client/runtime_display.py +90 -0
- simulo/_client/seed_ref.py +76 -0
- simulo/_client/stub.py +41 -0
- simulo/_client/submit_api.py +1057 -0
- simulo/_client/templates/__init__.py +21 -0
- simulo/_client/templates/inference/app.py.tmpl +316 -0
- simulo/_client/templates/inference/simuloignore.tmpl +30 -0
- simulo/_client/templates/scenario/app.py.tmpl +93 -0
- simulo/_client/templates/scenario/simuloignore.tmpl +27 -0
- simulo/_client/templates/training/app.py.tmpl +235 -0
- simulo/_client/templates/training/simuloignore.tmpl +29 -0
- simulo/_client/view_fragment.py +21 -0
- simulo/_client/view_session_api.py +122 -0
- simulo/_client/volume.py +71 -0
- simulo/callbacks.py +274 -0
- simulo/py.typed +0 -0
- simulo-0.26.0.dist-info/METADATA +130 -0
- simulo-0.26.0.dist-info/RECORD +55 -0
- simulo-0.26.0.dist-info/WHEEL +5 -0
- simulo-0.26.0.dist-info/entry_points.txt +2 -0
- simulo-0.26.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Map ``simulo run`` command-line arguments onto a function's signature.
|
|
2
|
+
|
|
3
|
+
Two submit paths share this module's introspection, pointed at different
|
|
4
|
+
targets:
|
|
5
|
+
|
|
6
|
+
* **The entrypoint path** — ``simulo run app.py`` maps ``--flag value``
|
|
7
|
+
arguments onto the registered ``@app.entrypoint``'s parameters and
|
|
8
|
+
invokes it; the entrypoint's own ``spawn()`` calls end at exactly one
|
|
9
|
+
submit (:func:`run_entrypoint` / :func:`parse_args`). (A direct ``python
|
|
10
|
+
app.py`` never reaches this module: :meth:`App.entrypoint` prints the
|
|
11
|
+
exact equivalent ``simulo run`` command to stderr and exits 2.)
|
|
12
|
+
* **The no-entrypoint path** — with no ``@app.entrypoint`` declared, the
|
|
13
|
+
CLI maps the same ``--flag value`` arguments directly onto the chosen
|
|
14
|
+
``@app.job``'s own signature (:func:`parse_explicit_args`) and spawns it.
|
|
15
|
+
Only EXPLICITLY-passed flags are returned there: a defaulted-but-unpassed
|
|
16
|
+
parameter must stay out of the submitted ``args`` so an upgraded client
|
|
17
|
+
submits the byte-identical package (same ``canonical_args`` → same
|
|
18
|
+
``package_id``) for the no-flags case it always produced ``args={}`` for.
|
|
19
|
+
|
|
20
|
+
An argument parser is built from the target's signature: each parameter
|
|
21
|
+
``foo_bar`` becomes ``--foo-bar`` (dash for underscore), coerced from the
|
|
22
|
+
parameter's default type, then its annotation. A parameter without a default is
|
|
23
|
+
required. This keeps authoring frictionless — ``def main(num_envs: int = 4096)``
|
|
24
|
+
just works as ``--num-envs 4096`` — with no argparse boilerplate in the app file.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import inspect
|
|
31
|
+
import types
|
|
32
|
+
import typing
|
|
33
|
+
from typing import Any, Callable, Collection, Mapping, Optional, Sequence
|
|
34
|
+
|
|
35
|
+
_STR_TO_TYPE: Mapping[str, type] = {"int": int, "float": float, "bool": bool, "str": str}
|
|
36
|
+
|
|
37
|
+
_SKIP_KINDS = (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
|
|
38
|
+
|
|
39
|
+
#: The only types a ``--flag value`` string can be conclusively coerced to.
|
|
40
|
+
_SCALAR_TYPES: tuple[type, ...] = (int, float, bool, str)
|
|
41
|
+
|
|
42
|
+
#: Sentinel default for :func:`parse_explicit_args`'s parser: a parameter whose
|
|
43
|
+
#: parsed value is still this object was never passed on the command line, so it
|
|
44
|
+
#: is omitted from the returned kwargs (the job body applies its own default at
|
|
45
|
+
#: execution). ``None`` cannot play this role — it is a legitimate flag value.
|
|
46
|
+
_UNSET: Any = object()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _param_type(param: inspect.Parameter) -> type:
|
|
50
|
+
"""Best-effort scalar type for a parameter: default's type, then annotation.
|
|
51
|
+
|
|
52
|
+
A concrete (non-``None``) default is the most reliable signal — ``= 4096``
|
|
53
|
+
means ``int``. Otherwise fall back to the annotation (a real type, or a PEP
|
|
54
|
+
563 string like ``"int"``). Defaults to ``str`` when nothing is conclusive.
|
|
55
|
+
"""
|
|
56
|
+
default = param.default
|
|
57
|
+
if default is not inspect.Parameter.empty and default is not None:
|
|
58
|
+
return type(default)
|
|
59
|
+
ann = param.annotation
|
|
60
|
+
if isinstance(ann, type):
|
|
61
|
+
return ann
|
|
62
|
+
if isinstance(ann, str):
|
|
63
|
+
return _STR_TO_TYPE.get(ann, str)
|
|
64
|
+
return str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _str_to_bool(raw: str) -> bool:
|
|
68
|
+
"""Parse a CLI ``--flag value`` boolean (``true``/``1``/``yes`` → ``True``)."""
|
|
69
|
+
lowered = raw.strip().lower()
|
|
70
|
+
if lowered in ("true", "1", "yes", "y", "on"):
|
|
71
|
+
return True
|
|
72
|
+
if lowered in ("false", "0", "no", "n", "off"):
|
|
73
|
+
return False
|
|
74
|
+
raise argparse.ArgumentTypeError(f"expected a boolean (true/false), got {raw!r}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def flag_for_param(name: str) -> str:
|
|
78
|
+
"""The ``--flag`` a signature parameter ``name`` maps to (dash for underscore)."""
|
|
79
|
+
return "--" + name.replace("_", "-")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def mappable_params(fn: Callable[..., object]) -> list[tuple[str, inspect.Parameter]]:
|
|
83
|
+
"""The parameters :func:`build_parser` maps to ``--flags``.
|
|
84
|
+
|
|
85
|
+
``VAR_POSITIONAL`` (``*args``) and ``VAR_KEYWORD`` (``**kwargs``) are
|
|
86
|
+
skipped, exactly as ``build_parser`` skips them — signature guards must use
|
|
87
|
+
the same filter, or a harmless ``def train(**job)`` would be rejected for a
|
|
88
|
+
flag it never gets.
|
|
89
|
+
"""
|
|
90
|
+
return [(name, param) for name, param in inspect.signature(fn).parameters.items() if param.kind not in _SKIP_KINDS]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _strip_optional_annotation(ann: Any) -> Any:
|
|
94
|
+
"""Unwrap ``Optional[X]`` / ``X | None`` / ``Union[X, None]`` to ``X``.
|
|
95
|
+
|
|
96
|
+
Handles both representations of each spelling: under ``from __future__
|
|
97
|
+
import annotations`` (which every shipped scaffold enables) an annotation
|
|
98
|
+
is a STRING like ``"int | None"``, ``"Optional[int]"``, or
|
|
99
|
+
``"Union[int, None]"``; without it, a typing object (whose ``Optional``
|
|
100
|
+
and ``Union`` spellings are the same object, handled by ``get_origin``).
|
|
101
|
+
All are common, deliberate ways to declare "this scalar, or unset" — the
|
|
102
|
+
wrapper must not hide the scalar, and no SPELLING may work in one
|
|
103
|
+
representation and fail in the other. Anything that is not exactly "one
|
|
104
|
+
type or None" is returned unchanged.
|
|
105
|
+
"""
|
|
106
|
+
if isinstance(ann, str):
|
|
107
|
+
text = ann.strip()
|
|
108
|
+
if text.startswith("typing."):
|
|
109
|
+
text = text[len("typing.") :]
|
|
110
|
+
if text.startswith("Optional[") and text.endswith("]"):
|
|
111
|
+
return text[len("Optional[") : -1].strip()
|
|
112
|
+
if text.startswith("Union[") and text.endswith("]"):
|
|
113
|
+
# Naive comma split is safe here: a nested generic like
|
|
114
|
+
# "Union[dict[str, int], None]" yields 3 parts and falls through
|
|
115
|
+
# unchanged — its inner type is not a scalar anyway.
|
|
116
|
+
parts = [part.strip() for part in text[len("Union[") : -1].split(",")]
|
|
117
|
+
non_none = [part for part in parts if part != "None"]
|
|
118
|
+
if len(parts) == 2 and len(non_none) == 1:
|
|
119
|
+
return non_none[0]
|
|
120
|
+
return ann
|
|
121
|
+
if "|" in text:
|
|
122
|
+
parts = [part.strip() for part in text.split("|")]
|
|
123
|
+
non_none = [part for part in parts if part != "None"]
|
|
124
|
+
if len(parts) == 2 and len(non_none) == 1:
|
|
125
|
+
return non_none[0]
|
|
126
|
+
return ann
|
|
127
|
+
origin = typing.get_origin(ann)
|
|
128
|
+
if origin is typing.Union or origin is types.UnionType:
|
|
129
|
+
args = [arg for arg in typing.get_args(ann) if arg is not type(None)]
|
|
130
|
+
if len(args) == 1:
|
|
131
|
+
return args[0]
|
|
132
|
+
return ann
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def conclusive_param_type(param: inspect.Parameter) -> Optional[type]:
|
|
136
|
+
"""The scalar type a ``--flag`` value is conclusively coerced to, or ``None``.
|
|
137
|
+
|
|
138
|
+
Unlike :func:`_param_type` — whose ``str`` FALLBACK is fine for an
|
|
139
|
+
entrypoint (the entrypoint body runs client-side and can fix things up) —
|
|
140
|
+
the no-entrypoint job path submits the coerced value straight into the
|
|
141
|
+
job's ``args``, so a guess is a silent wrong submission: ``tags: list = []``
|
|
142
|
+
would coerce ``--tags release`` through ``list("release")`` into
|
|
143
|
+
``["r", "e", "l", ...]``, and a ``Path``/``set``/enum value would crash
|
|
144
|
+
``json.dumps`` at manifest time. ``None`` here means "reject at submit".
|
|
145
|
+
|
|
146
|
+
Conclusive: a non-``None`` scalar default (its type), or an ``int`` /
|
|
147
|
+
``float`` / ``bool`` / ``str`` annotation — real type, PEP 563 string, or
|
|
148
|
+
either wrapped in ``Optional[...]`` / ``| None``. An unannotated parameter
|
|
149
|
+
stays ``str`` (the documented text fallback, unchanged from the entrypoint
|
|
150
|
+
convention).
|
|
151
|
+
"""
|
|
152
|
+
default = param.default
|
|
153
|
+
if default is not inspect.Parameter.empty and default is not None:
|
|
154
|
+
return type(default) if type(default) in _SCALAR_TYPES else None
|
|
155
|
+
ann = param.annotation
|
|
156
|
+
if ann is inspect.Parameter.empty:
|
|
157
|
+
return str
|
|
158
|
+
ann = _strip_optional_annotation(ann)
|
|
159
|
+
if isinstance(ann, type) and ann in _SCALAR_TYPES:
|
|
160
|
+
return ann
|
|
161
|
+
if isinstance(ann, str) and ann in _STR_TO_TYPE:
|
|
162
|
+
return _STR_TO_TYPE[ann]
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def unmappable_params(fn: Callable[..., object]) -> list[tuple[str, str, str, bool]]:
|
|
167
|
+
"""``(name, kind, why, has_default)`` for parameters the CLI cannot map.
|
|
168
|
+
|
|
169
|
+
``kind`` is ``"positional-only"`` (the worker executes ``fn(**args)``, so a
|
|
170
|
+
keyword can never reach it) or ``"type"`` (no conclusive scalar coercion —
|
|
171
|
+
see :func:`conclusive_param_type`).
|
|
172
|
+
|
|
173
|
+
``has_default`` is the caller's rejection policy input: without a default
|
|
174
|
+
the parameter is required-but-unmappable — the submit can never succeed, so
|
|
175
|
+
it is rejected always. WITH a default the defaults-only submit works today
|
|
176
|
+
(``fn(**{})`` never touches the parameter), so callers reject only when a
|
|
177
|
+
flag actually targets it — a blanket rejection would break submits that
|
|
178
|
+
work on shipped 0.14.x clients.
|
|
179
|
+
"""
|
|
180
|
+
result: list[tuple[str, str, str, bool]] = []
|
|
181
|
+
for name, param in mappable_params(fn):
|
|
182
|
+
has_default = param.default is not inspect.Parameter.empty
|
|
183
|
+
if param.kind is inspect.Parameter.POSITIONAL_ONLY:
|
|
184
|
+
result.append((name, "positional-only", "declared positional-only (before a '/')", has_default))
|
|
185
|
+
continue
|
|
186
|
+
if conclusive_param_type(param) is not None:
|
|
187
|
+
continue
|
|
188
|
+
if param.default is not inspect.Parameter.empty and param.default is not None:
|
|
189
|
+
why = f"default {param.default!r} of non-scalar type {type(param.default).__name__}"
|
|
190
|
+
elif param.annotation is not inspect.Parameter.empty:
|
|
191
|
+
why = f"annotation {param.annotation!r} is not a scalar type"
|
|
192
|
+
else: # pragma: no cover - unreachable: no annotation + no default resolves to str
|
|
193
|
+
why = "no usable type"
|
|
194
|
+
result.append((name, "type", why, has_default))
|
|
195
|
+
return result
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def build_parser(
|
|
199
|
+
fn: Callable[..., object],
|
|
200
|
+
*,
|
|
201
|
+
sentinel_defaults: bool = False,
|
|
202
|
+
prog: Optional[str] = None,
|
|
203
|
+
exclude: Collection[str] = (),
|
|
204
|
+
) -> argparse.ArgumentParser:
|
|
205
|
+
"""Build an ``argparse`` parser from a function's signature.
|
|
206
|
+
|
|
207
|
+
With ``sentinel_defaults=False`` (the entrypoint path — behavior unchanged),
|
|
208
|
+
a defaulted parameter's parser default is the signature's own default, so
|
|
209
|
+
``parse_args`` yields EVERY parameter, and values coerce via
|
|
210
|
+
:func:`_param_type` (``str`` fallback included). With
|
|
211
|
+
``sentinel_defaults=True`` (the no-entrypoint job path), defaulted
|
|
212
|
+
parameters default to the private ``_UNSET`` sentinel instead — so
|
|
213
|
+
:func:`parse_explicit_args` can tell an explicitly-passed flag from an
|
|
214
|
+
untouched default — and values coerce via :func:`conclusive_param_type`
|
|
215
|
+
(callers must have rejected inconclusive signatures first). ``prog`` names
|
|
216
|
+
the parser in ``-h`` output (e.g. ``"simulo run app.py"``); default is the
|
|
217
|
+
function's own name, as before.
|
|
218
|
+
|
|
219
|
+
``exclude`` drops the named parameters from the parser entirely — the
|
|
220
|
+
no-entrypoint path passes its DEFERRED unmappable-but-defaulted parameters
|
|
221
|
+
here (see :func:`unmappable_params`), so a flag targeting one — even via
|
|
222
|
+
an argparse abbreviation the caller's exact-token scan cannot see — fails
|
|
223
|
+
at submit as an unrecognized argument rather than being mis-parsed.
|
|
224
|
+
"""
|
|
225
|
+
sig = inspect.signature(fn)
|
|
226
|
+
doc = (inspect.getdoc(fn) or "").strip().splitlines()
|
|
227
|
+
parser = argparse.ArgumentParser(
|
|
228
|
+
prog=prog if prog is not None else getattr(fn, "__name__", "entrypoint"),
|
|
229
|
+
description=doc[0] if doc else None,
|
|
230
|
+
)
|
|
231
|
+
for name, param in sig.parameters.items():
|
|
232
|
+
if param.kind in _SKIP_KINDS or name in exclude:
|
|
233
|
+
continue
|
|
234
|
+
flag = flag_for_param(name)
|
|
235
|
+
required = param.default is inspect.Parameter.empty
|
|
236
|
+
if required:
|
|
237
|
+
default = None
|
|
238
|
+
elif sentinel_defaults:
|
|
239
|
+
default = _UNSET
|
|
240
|
+
else:
|
|
241
|
+
default = param.default
|
|
242
|
+
if sentinel_defaults:
|
|
243
|
+
resolved = conclusive_param_type(param)
|
|
244
|
+
typ = resolved if resolved is not None else _param_type(param)
|
|
245
|
+
else:
|
|
246
|
+
typ = _param_type(param)
|
|
247
|
+
if typ is bool:
|
|
248
|
+
parser.add_argument(
|
|
249
|
+
flag,
|
|
250
|
+
dest=name,
|
|
251
|
+
type=_str_to_bool,
|
|
252
|
+
required=required,
|
|
253
|
+
default=default,
|
|
254
|
+
metavar="BOOL",
|
|
255
|
+
)
|
|
256
|
+
else:
|
|
257
|
+
parser.add_argument(
|
|
258
|
+
flag,
|
|
259
|
+
dest=name,
|
|
260
|
+
type=typ,
|
|
261
|
+
required=required,
|
|
262
|
+
default=default,
|
|
263
|
+
)
|
|
264
|
+
return parser
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def parse_args(fn: Callable[..., object], argv: Sequence[str]) -> dict[str, Any]:
|
|
268
|
+
"""Parse ``argv`` against ``fn``'s signature into a keyword-argument dict."""
|
|
269
|
+
parser = build_parser(fn)
|
|
270
|
+
namespace = parser.parse_args(list(argv))
|
|
271
|
+
sig = inspect.signature(fn)
|
|
272
|
+
kwargs: dict[str, Any] = {}
|
|
273
|
+
for name, param in sig.parameters.items():
|
|
274
|
+
if param.kind in _SKIP_KINDS:
|
|
275
|
+
continue
|
|
276
|
+
kwargs[name] = getattr(namespace, name)
|
|
277
|
+
return kwargs
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def parse_explicit_args(
|
|
281
|
+
fn: Callable[..., object],
|
|
282
|
+
argv: Sequence[str],
|
|
283
|
+
*,
|
|
284
|
+
prog: Optional[str] = None,
|
|
285
|
+
exclude: Collection[str] = (),
|
|
286
|
+
) -> dict[str, Any]:
|
|
287
|
+
"""Parse ``argv`` against ``fn``'s signature; return ONLY explicitly-passed kwargs.
|
|
288
|
+
|
|
289
|
+
The no-entrypoint submit path maps CLI flags onto a ``@app.job``'s own
|
|
290
|
+
signature with this: a parameter the user did not pass stays OUT of the
|
|
291
|
+
returned dict (the job body applies its own default on the worker), so the
|
|
292
|
+
no-flags case still submits ``args={}`` — exactly what pre-mapping clients
|
|
293
|
+
always submitted — and ``package_id`` is unchanged across the upgrade.
|
|
294
|
+
A parameter without a default is required, exactly as on the entrypoint
|
|
295
|
+
path. ``exclude`` names parameters left out of the parser and the result
|
|
296
|
+
(the deferred unmappable-but-defaulted ones — see :func:`build_parser`).
|
|
297
|
+
"""
|
|
298
|
+
parser = build_parser(fn, sentinel_defaults=True, prog=prog, exclude=exclude)
|
|
299
|
+
namespace = parser.parse_args(list(argv))
|
|
300
|
+
sig = inspect.signature(fn)
|
|
301
|
+
kwargs: dict[str, Any] = {}
|
|
302
|
+
for name, param in sig.parameters.items():
|
|
303
|
+
if param.kind in _SKIP_KINDS or name in exclude:
|
|
304
|
+
continue
|
|
305
|
+
value = getattr(namespace, name)
|
|
306
|
+
if value is not _UNSET:
|
|
307
|
+
kwargs[name] = value
|
|
308
|
+
return kwargs
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def run_entrypoint(fn: Callable[..., object], argv: Sequence[str]) -> object:
|
|
312
|
+
"""Parse ``argv`` for ``fn`` and invoke it (the single submit happens inside)."""
|
|
313
|
+
return fn(**parse_args(fn, argv))
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Shared naming convention for mount environment variables.
|
|
2
|
+
|
|
3
|
+
The backend runner (follow-up PR) exports one env var per mount before invoking
|
|
4
|
+
a job body; ``Volume.path`` / ``Asset.path`` read them back. Centralising the
|
|
5
|
+
key derivation here keeps the producer and consumer in lock-step.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
_NON_ALNUM = re.compile(r"[^A-Za-z0-9]+")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _sanitize(segment: str) -> str:
|
|
14
|
+
"""Upper-case, collapse runs of non-alphanumerics to ``_``, strip edges."""
|
|
15
|
+
return _NON_ALNUM.sub("_", segment).strip("_").upper()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def volume_env_key(name: str) -> str:
|
|
19
|
+
"""Env var the runner sets to a volume's local mount path."""
|
|
20
|
+
return f"SIMULO_VOLUME_{_sanitize(name)}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def asset_env_key(uri: str) -> str:
|
|
24
|
+
"""Env var the runner sets to an asset's local (read-only) mount path."""
|
|
25
|
+
return f"SIMULO_ASSET_{_sanitize(uri)}"
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Submit handle for a packaged job — the result of ``JobFunction.spawn``.
|
|
2
|
+
|
|
3
|
+
Two submit modes now share this one handle type:
|
|
4
|
+
|
|
5
|
+
* **Local-disk submit** (no credentials, no ``SIMULO_API_URL``/``SIMULO_ENV``):
|
|
6
|
+
the thin client's only job is to *write the package* to disk; it never trains
|
|
7
|
+
and never invokes the backend. ``.get()`` raises :class:`JobResultUnavailable`
|
|
8
|
+
pointing at the package on disk and the execute command
|
|
9
|
+
(``simulo-backend run-package``), exactly as before this wave.
|
|
10
|
+
* **Cloud submit** (credentials present, or ``SIMULO_API_URL``/``SIMULO_ENV``
|
|
11
|
+
set): ``spawn`` has already uploaded the package and created a job on the
|
|
12
|
+
control plane, so the handle carries the SERVER-assigned ``job_id`` plus the
|
|
13
|
+
base URL/token to observe it. ``.get()`` polls the jobs API client to a
|
|
14
|
+
terminal status and returns the result on success, or raises
|
|
15
|
+
:class:`JobFailedError` (with a
|
|
16
|
+
``simulo logs <id>`` hint) on failure/cancellation.
|
|
17
|
+
|
|
18
|
+
The thin client keeps **no** import of and no ``find_spec`` dependency on
|
|
19
|
+
``simulo.backend`` — submit is fully decoupled from execution either way.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import time
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, NoReturn, Optional
|
|
27
|
+
|
|
28
|
+
from simulo.interfaces.ids import JobId, JobPublicId
|
|
29
|
+
from simulo.interfaces.platform.enums import JobStatus
|
|
30
|
+
from simulo.interfaces.platform.runs import JOB_SCOPE_MINE, TERMINAL_JOB_STATUSES
|
|
31
|
+
|
|
32
|
+
#: Poll cadence for cloud ``.get()``. Module-level so tests can shrink it.
|
|
33
|
+
_POLL_INTERVAL_S = 1.0
|
|
34
|
+
|
|
35
|
+
_TERMINAL_STATUS_STRINGS = frozenset(str(status) for status in TERMINAL_JOB_STATUSES)
|
|
36
|
+
_COMPLETED_STATUS = str(JobStatus.COMPLETED)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class JobFailedError(RuntimeError):
|
|
40
|
+
"""A cloud job reached a terminal ``failed``/``cancelled`` status.
|
|
41
|
+
|
|
42
|
+
Also reserved for the executor (the ``simulo-backend`` runner) to signal a
|
|
43
|
+
failed job body in local-disk mode, where the thin client never raises it
|
|
44
|
+
itself (submitting cannot fail a job because it never runs one there).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class JobResultUnavailable(NotImplementedError):
|
|
49
|
+
"""``JobHandle.get()`` was called in local-disk submit mode.
|
|
50
|
+
|
|
51
|
+
Submitting writes the package to disk; it does not execute the job, so
|
|
52
|
+
there is no result to return. Raised with the package path and the command
|
|
53
|
+
that *does* execute it (the ``simulo-backend`` runner).
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class JobTimeoutError(TimeoutError):
|
|
58
|
+
"""``JobHandle.get(timeout=...)`` elapsed before the job reached a terminal status."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class JobHandle:
|
|
62
|
+
"""Handle to a submitted job — local-disk package, or a cloud job id.
|
|
63
|
+
|
|
64
|
+
Returned by :meth:`JobFunction.spawn`. In cloud mode it carries the
|
|
65
|
+
server-assigned machine ``job_id``, human-facing ``public_id``, and enough
|
|
66
|
+
context (``base_url`` / ``token``) to poll it; ``execute_hint()`` still names the local package path
|
|
67
|
+
(preserved for debugging) but ``get()`` talks to the platform instead of
|
|
68
|
+
raising.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
job_id: JobId,
|
|
74
|
+
package_path: Path,
|
|
75
|
+
job_name: str,
|
|
76
|
+
*,
|
|
77
|
+
public_id: Optional[JobPublicId] = None,
|
|
78
|
+
cloud: bool = False,
|
|
79
|
+
base_url: Optional[str] = None,
|
|
80
|
+
token: Optional[str] = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
self._job_id = job_id
|
|
83
|
+
self._public_id = public_id
|
|
84
|
+
self._package_path = package_path
|
|
85
|
+
self._job_name = job_name
|
|
86
|
+
self._cloud = cloud
|
|
87
|
+
self._base_url = base_url
|
|
88
|
+
self._token = token
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def job_id(self) -> JobId:
|
|
92
|
+
"""Opaque id for this submission — server-assigned in cloud mode."""
|
|
93
|
+
return self._job_id
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def public_id(self) -> Optional[JobPublicId]:
|
|
97
|
+
"""Human-facing Job ID in cloud mode; ``None`` for local package-only submits."""
|
|
98
|
+
return self._public_id
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def package_path(self) -> Path:
|
|
102
|
+
"""Directory the package was written to (``<out>/<bundle_dir_name(package_id)>/``)."""
|
|
103
|
+
return self._package_path
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def job_name(self) -> str:
|
|
107
|
+
"""The ``@app.job`` recorded as submitted in the package manifest."""
|
|
108
|
+
return self._job_name
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def is_cloud(self) -> bool:
|
|
112
|
+
"""``True`` when this job was submitted to the cloud (not local-disk)."""
|
|
113
|
+
return self._cloud
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def base_url(self) -> Optional[str]:
|
|
117
|
+
"""The API base URL this job was submitted against (cloud mode only)."""
|
|
118
|
+
return self._base_url
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def token(self) -> Optional[str]:
|
|
122
|
+
"""The bearer token to observe this job with (cloud mode only)."""
|
|
123
|
+
return self._token
|
|
124
|
+
|
|
125
|
+
def execute_hint(self) -> str:
|
|
126
|
+
"""The command that executes this submitted package locally (local-disk mode)."""
|
|
127
|
+
return f"simulo-backend run-package {self._package_path} --job {self._job_name}"
|
|
128
|
+
|
|
129
|
+
def logs_hint(self) -> str:
|
|
130
|
+
"""The command that streams this job's logs (cloud mode)."""
|
|
131
|
+
if self._public_id is None:
|
|
132
|
+
return "simulo jobs"
|
|
133
|
+
return f"simulo logs {self._public_id} --follow"
|
|
134
|
+
|
|
135
|
+
def get(self, timeout: Optional[float] = None) -> Any:
|
|
136
|
+
"""Return the job's result.
|
|
137
|
+
|
|
138
|
+
Cloud mode: poll to a terminal status (optionally bounded by
|
|
139
|
+
*timeout* seconds; ``None`` waits indefinitely), returning the result
|
|
140
|
+
JSON on ``completed`` or raising :class:`JobFailedError` on
|
|
141
|
+
``failed``/``cancelled``. Local-disk mode: raises
|
|
142
|
+
:class:`JobResultUnavailable` — submitting never executes a job there.
|
|
143
|
+
"""
|
|
144
|
+
if not self._cloud:
|
|
145
|
+
self._raise_local_disk_unavailable()
|
|
146
|
+
return self._get_cloud(timeout)
|
|
147
|
+
|
|
148
|
+
def _get_cloud(self, timeout: Optional[float]) -> Any:
|
|
149
|
+
from simulo._client.jobs_api import JobsApiClient
|
|
150
|
+
|
|
151
|
+
assert self._base_url is not None, "cloud JobHandle must carry a base_url"
|
|
152
|
+
client = JobsApiClient(self._base_url, token=self._token)
|
|
153
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
154
|
+
while True:
|
|
155
|
+
record = client.get_job(str(self._job_id), scope=JOB_SCOPE_MINE)
|
|
156
|
+
status = str(record.get("status"))
|
|
157
|
+
if status in _TERMINAL_STATUS_STRINGS:
|
|
158
|
+
if status == _COMPLETED_STATUS:
|
|
159
|
+
return client.get_result(str(self._job_id), scope=JOB_SCOPE_MINE)
|
|
160
|
+
exit_code = record.get("exit_code")
|
|
161
|
+
raise JobFailedError(
|
|
162
|
+
f"{self._display_name()} {status} (exit_code={exit_code}).\n"
|
|
163
|
+
f" View its logs with:\n {self.logs_hint()}"
|
|
164
|
+
)
|
|
165
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
166
|
+
raise JobTimeoutError(
|
|
167
|
+
f"{self._display_name()} did not reach a terminal status within "
|
|
168
|
+
f"{timeout}s (still {status!r}). Check it with:\n {self.logs_hint()}"
|
|
169
|
+
)
|
|
170
|
+
time.sleep(_POLL_INTERVAL_S)
|
|
171
|
+
|
|
172
|
+
def _display_name(self) -> str:
|
|
173
|
+
"""A user-facing label that never falls back to the machine UUID."""
|
|
174
|
+
if self._public_id is None:
|
|
175
|
+
return f"Job {self._job_name!r}"
|
|
176
|
+
return f"Job {self._job_name!r} ({self._public_id})"
|
|
177
|
+
|
|
178
|
+
def _raise_local_disk_unavailable(self) -> NoReturn:
|
|
179
|
+
raise JobResultUnavailable(
|
|
180
|
+
f"No result is available locally for job {self._job_name!r}: submitting writes the "
|
|
181
|
+
f"package to disk, it does not execute the job.\n"
|
|
182
|
+
f" package: {self._package_path}\n"
|
|
183
|
+
f" execute it with the backend (the sole executor):\n"
|
|
184
|
+
f" {self.execute_hint()}\n"
|
|
185
|
+
f"(Run `simulo login` to submit to the cloud instead, where get() streams the real result.)"
|
|
186
|
+
)
|