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,267 @@
|
|
|
1
|
+
"""Install the public Simulo sample collection with a normal Git clone."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
SAMPLES_REPOSITORY_URL = "https://github.com/simulo-org/simulo-samples.git"
|
|
13
|
+
DEFAULT_SAMPLES_DIRECTORY_NAME = "simulo-samples"
|
|
14
|
+
CLONE_TIMEOUT_S = 300
|
|
15
|
+
_MAX_GIT_STDERR_INPUT_BYTES = 4096
|
|
16
|
+
_MAX_GIT_STDERR_RENDERED_BYTES = 4096
|
|
17
|
+
_GIT_OUTPUT_PREFIX = " | git: "
|
|
18
|
+
_GIT_PROGRESS_PREFIXES = (
|
|
19
|
+
"Cloning into ",
|
|
20
|
+
"Receiving objects:",
|
|
21
|
+
"Resolving deltas:",
|
|
22
|
+
"remote: Enumerating objects:",
|
|
23
|
+
"remote: Counting objects:",
|
|
24
|
+
"remote: Compressing objects:",
|
|
25
|
+
"remote: Total ",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class InstallSamplesError(RuntimeError):
|
|
30
|
+
"""Raised when the samples cannot be installed."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class InstallSamplesUsageError(ValueError):
|
|
34
|
+
"""Raised when the requested install destination is unsafe to use."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class InstalledSamples:
|
|
39
|
+
"""The local sample collection created by :func:`install_samples`."""
|
|
40
|
+
|
|
41
|
+
path: Path
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _git_environment() -> dict[str, str]:
|
|
45
|
+
"""Return a non-interactive environment for a public clone."""
|
|
46
|
+
environment = dict(os.environ)
|
|
47
|
+
for key in tuple(environment):
|
|
48
|
+
if any(fragment in key.upper() for fragment in ("TOKEN", "SECRET", "CREDENTIAL")):
|
|
49
|
+
environment.pop(key)
|
|
50
|
+
environment.pop("GIT_CONFIG_PARAMETERS", None)
|
|
51
|
+
environment.update(
|
|
52
|
+
{
|
|
53
|
+
"GIT_TERMINAL_PROMPT": "0",
|
|
54
|
+
"GIT_ASKPASS": "",
|
|
55
|
+
"GCM_INTERACTIVE": "never",
|
|
56
|
+
"GIT_CONFIG_COUNT": "1",
|
|
57
|
+
"GIT_CONFIG_KEY_0": "credential.helper",
|
|
58
|
+
"GIT_CONFIG_VALUE_0": "",
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
return environment
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _run_git(argv: list[str], *, timeout_s: int, env: dict[str, str]) -> subprocess.CompletedProcess[str]:
|
|
65
|
+
"""Run Git through the one seam tests replace."""
|
|
66
|
+
return subprocess.run(argv, capture_output=True, check=False, env=env, text=True, timeout=timeout_s)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _stderr_tail(stderr: str | bytes | None) -> str:
|
|
70
|
+
if isinstance(stderr, bytes):
|
|
71
|
+
stderr = stderr.decode(errors="replace")
|
|
72
|
+
if not stderr:
|
|
73
|
+
return ""
|
|
74
|
+
encoded = stderr.encode("utf-8", errors="replace")
|
|
75
|
+
bounded_bytes = encoded[-_MAX_GIT_STDERR_INPUT_BYTES:]
|
|
76
|
+
was_truncated = len(bounded_bytes) < len(encoded)
|
|
77
|
+
bounded = bounded_bytes.decode("utf-8", errors="replace")
|
|
78
|
+
lines = bounded.splitlines()
|
|
79
|
+
starts_mid_line = was_truncated and encoded[-_MAX_GIT_STDERR_INPUT_BYTES - 1] not in (ord("\n"), ord("\r"))
|
|
80
|
+
if starts_mid_line and len(lines) > 1:
|
|
81
|
+
lines = lines[1:]
|
|
82
|
+
non_progress_lines = [line for line in lines if not line.startswith(_GIT_PROGRESS_PREFIXES)]
|
|
83
|
+
return _quote_git_output(non_progress_lines[-10:])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _quote_git_output(lines: list[str]) -> str:
|
|
87
|
+
"""Prefix Git output without exceeding the CLI's rendered stderr bound."""
|
|
88
|
+
output: list[str] = []
|
|
89
|
+
output_bytes = 0
|
|
90
|
+
prefix_bytes = len(_GIT_OUTPUT_PREFIX.encode("utf-8"))
|
|
91
|
+
for line in reversed(lines):
|
|
92
|
+
printable_line = "".join(character for character in line if character.isprintable())
|
|
93
|
+
quoted_line = f"{_GIT_OUTPUT_PREFIX}{printable_line}"
|
|
94
|
+
quoted_line_bytes = len(quoted_line.encode("utf-8", errors="replace"))
|
|
95
|
+
separator_bytes = 1 if output else 0
|
|
96
|
+
if output_bytes + separator_bytes + quoted_line_bytes <= _MAX_GIT_STDERR_RENDERED_BYTES:
|
|
97
|
+
output.insert(0, quoted_line)
|
|
98
|
+
output_bytes += separator_bytes + quoted_line_bytes
|
|
99
|
+
elif not output:
|
|
100
|
+
available_line_bytes = _MAX_GIT_STDERR_RENDERED_BYTES - prefix_bytes
|
|
101
|
+
output.append(
|
|
102
|
+
_GIT_OUTPUT_PREFIX
|
|
103
|
+
+ printable_line.encode("utf-8", errors="replace")[-available_line_bytes:].decode(errors="ignore")
|
|
104
|
+
)
|
|
105
|
+
break
|
|
106
|
+
else:
|
|
107
|
+
break
|
|
108
|
+
return "\n".join(output)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _remove_read_only_path(function: object, path: str, _exc_info: object) -> None:
|
|
112
|
+
"""Make Windows Git object files writable, then retry rmtree's operation."""
|
|
113
|
+
# Known and owned Windows residual: this misses directory junctions, which Python 3.11 rmtree can follow; staging
|
|
114
|
+
# access follows inherited ACLs.
|
|
115
|
+
if not os.path.islink(path):
|
|
116
|
+
os.chmod(path, 0o700)
|
|
117
|
+
function(path) # type: ignore[operator]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _directory_identity(path: Path) -> tuple[int, int]:
|
|
121
|
+
status = path.stat()
|
|
122
|
+
return status.st_dev, status.st_ino
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _remove_staging_directory(staging: Path, identity: tuple[int, int]) -> None:
|
|
126
|
+
try:
|
|
127
|
+
current_identity = _directory_identity(staging)
|
|
128
|
+
except FileNotFoundError:
|
|
129
|
+
return
|
|
130
|
+
except OSError:
|
|
131
|
+
print(f"warning: could not inspect partial samples install at {staging}", file=sys.stderr)
|
|
132
|
+
return
|
|
133
|
+
if current_identity != identity:
|
|
134
|
+
print(f"warning: partial samples install path no longer belongs to this invocation: {staging}", file=sys.stderr)
|
|
135
|
+
return
|
|
136
|
+
try:
|
|
137
|
+
shutil.rmtree(staging, onerror=_remove_read_only_path)
|
|
138
|
+
except OSError:
|
|
139
|
+
print(f"warning: could not remove partial samples install at {staging}", file=sys.stderr)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _clone_failure(
|
|
143
|
+
reason: str, stderr: str | bytes | None, *, explain_public_repository: bool = False
|
|
144
|
+
) -> InstallSamplesError:
|
|
145
|
+
tail = _stderr_tail(stderr)
|
|
146
|
+
explanation = (
|
|
147
|
+
"The samples repository is public and this command does not sign in.\n"
|
|
148
|
+
"An authentication error means Git could not reach the repository or its host, not that there is a problem "
|
|
149
|
+
"with your account."
|
|
150
|
+
)
|
|
151
|
+
message = f"{reason}\n{tail}" if tail else reason
|
|
152
|
+
if explain_public_repository:
|
|
153
|
+
message = f"{message}\n{explanation}"
|
|
154
|
+
return InstallSamplesError(message)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _target_exists_error(target: Path) -> InstallSamplesUsageError:
|
|
158
|
+
if not target.is_dir():
|
|
159
|
+
return InstallSamplesUsageError(f"{target} exists and is not a directory.")
|
|
160
|
+
return InstallSamplesUsageError(f"{target} already exists.")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _git_is_reachable_through_working_directory(
|
|
164
|
+
named_git_path: Path, git_executable: Path, working_directory: Path
|
|
165
|
+
) -> bool:
|
|
166
|
+
"""Return whether resolving Git depends on the current working directory."""
|
|
167
|
+
return not named_git_path.is_absolute() or working_directory in {
|
|
168
|
+
named_git_path.parent,
|
|
169
|
+
named_git_path.parent.resolve(),
|
|
170
|
+
git_executable.parent,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _clone_has_sample_files(staging: Path) -> bool:
|
|
175
|
+
"""Return whether a successful clone produced a user-visible worktree entry.
|
|
176
|
+
|
|
177
|
+
An unreadable staging directory counts as no sample files. This is the only
|
|
178
|
+
filesystem read on the success path, and letting an OSError escape here would
|
|
179
|
+
abandon the staging directory and break the promise that a failed install
|
|
180
|
+
leaves nothing behind.
|
|
181
|
+
"""
|
|
182
|
+
try:
|
|
183
|
+
return any(entry.name != ".git" for entry in staging.iterdir())
|
|
184
|
+
except OSError:
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def install_samples(
|
|
189
|
+
parent_directory: Path,
|
|
190
|
+
*,
|
|
191
|
+
repository_url: str = SAMPLES_REPOSITORY_URL,
|
|
192
|
+
directory_name: str = DEFAULT_SAMPLES_DIRECTORY_NAME,
|
|
193
|
+
timeout_s: int = CLONE_TIMEOUT_S,
|
|
194
|
+
) -> InstalledSamples:
|
|
195
|
+
"""Clone samples into *parent_directory* and reveal them atomically."""
|
|
196
|
+
target = parent_directory / directory_name
|
|
197
|
+
if target.exists():
|
|
198
|
+
raise _target_exists_error(target)
|
|
199
|
+
|
|
200
|
+
git_path = shutil.which("git")
|
|
201
|
+
if git_path is None:
|
|
202
|
+
raise InstallSamplesError(
|
|
203
|
+
"Git is required to install samples. Get it from https://git-scm.com/downloads and try again."
|
|
204
|
+
)
|
|
205
|
+
named_git_path = Path(git_path)
|
|
206
|
+
working_directory = Path.cwd().resolve()
|
|
207
|
+
git_executable = named_git_path.resolve()
|
|
208
|
+
if _git_is_reachable_through_working_directory(named_git_path, git_executable, working_directory):
|
|
209
|
+
raise InstallSamplesError(
|
|
210
|
+
f"Refusing resolved Git executable {git_executable}: its PATH entry is relative to or resolves through "
|
|
211
|
+
"the current working directory. Run this command from a different directory, or use a Git executable "
|
|
212
|
+
"outside the working directory."
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
staging = parent_directory / f".{directory_name}.partial-{os.getpid()}"
|
|
216
|
+
try:
|
|
217
|
+
staging.mkdir()
|
|
218
|
+
except FileExistsError:
|
|
219
|
+
raise InstallSamplesError(f"Partial samples install path already exists: {staging}.") from None
|
|
220
|
+
except OSError as exc:
|
|
221
|
+
reason = exc.strerror or str(exc)
|
|
222
|
+
raise InstallSamplesError(f"Could not prepare samples install at {staging}: {reason}.") from None
|
|
223
|
+
staging_identity = _directory_identity(staging)
|
|
224
|
+
|
|
225
|
+
print("Cloning the samples with full history; this can take a minute.", file=sys.stderr)
|
|
226
|
+
argv = [str(git_executable), "clone", "--", repository_url, str(staging)]
|
|
227
|
+
try:
|
|
228
|
+
completed = _run_git(argv, timeout_s=timeout_s, env=_git_environment())
|
|
229
|
+
except subprocess.TimeoutExpired as exc:
|
|
230
|
+
_remove_staging_directory(staging, staging_identity)
|
|
231
|
+
raise _clone_failure(
|
|
232
|
+
f"Git clone timed out after {timeout_s} seconds. "
|
|
233
|
+
f"If your connection needs longer, clone it by hand with git clone {repository_url}.",
|
|
234
|
+
exc.stderr,
|
|
235
|
+
) from None
|
|
236
|
+
except KeyboardInterrupt:
|
|
237
|
+
_remove_staging_directory(staging, staging_identity)
|
|
238
|
+
raise
|
|
239
|
+
except OSError as exc:
|
|
240
|
+
_remove_staging_directory(staging, staging_identity)
|
|
241
|
+
reason = exc.strerror or str(exc)
|
|
242
|
+
raise InstallSamplesError(f"Could not start Git clone: {reason}.") from None
|
|
243
|
+
|
|
244
|
+
if completed.returncode != 0:
|
|
245
|
+
_remove_staging_directory(staging, staging_identity)
|
|
246
|
+
raise _clone_failure(
|
|
247
|
+
f"Git clone failed with exit code {completed.returncode}.",
|
|
248
|
+
completed.stderr,
|
|
249
|
+
explain_public_repository=True,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
if not _clone_has_sample_files(staging):
|
|
253
|
+
_remove_staging_directory(staging, staging_identity)
|
|
254
|
+
raise _clone_failure("Git clone produced an empty working tree.", completed.stderr)
|
|
255
|
+
|
|
256
|
+
if target.exists():
|
|
257
|
+
_remove_staging_directory(staging, staging_identity)
|
|
258
|
+
raise _target_exists_error(target)
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
os.rename(staging, target)
|
|
262
|
+
except OSError as exc:
|
|
263
|
+
_remove_staging_directory(staging, staging_identity)
|
|
264
|
+
reason = exc.strerror or str(exc)
|
|
265
|
+
raise InstallSamplesError(f"Could not finish installing samples: {reason}.") from None
|
|
266
|
+
|
|
267
|
+
return InstalledSamples(path=target.resolve())
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""HTTP client for the Simulo jobs API — stdlib ``urllib`` only, torch-free.
|
|
2
|
+
|
|
3
|
+
The thin client and the executor behave as if on TWO SEPARATE MACHINES: this
|
|
4
|
+
client NEVER reads the run store from disk — it talks HTTP to "the platform".
|
|
5
|
+
Today that platform is the local stand-in (``simulo-backend serve``, serving
|
|
6
|
+
``~/.simulo/runs`` on 127.0.0.1) or the real cloud control plane, once
|
|
7
|
+
credentials or ``SIMULO_API_URL``/``SIMULO_ENV`` point at it. The wire contract
|
|
8
|
+
lives in :mod:`simulo.interfaces.platform.runs` (routes, offset semantics,
|
|
9
|
+
error shape). Request plumbing (the structured-error parser, the
|
|
10
|
+
https-when-token guard) is shared with ``submit_api.py`` via ``http.py``.
|
|
11
|
+
|
|
12
|
+
Configuration (environment):
|
|
13
|
+
|
|
14
|
+
* ``SIMULO_API_URL`` — base URL of the jobs API. See ``config.resolve_base_url``
|
|
15
|
+
for the full precedence order (env -> ``SIMULO_ENV`` preset -> credentials ->
|
|
16
|
+
local default). Defaults to ``http://127.0.0.1:<JOBS_API_DEFAULT_PORT>`` (the
|
|
17
|
+
local stand-in) when nothing else resolves.
|
|
18
|
+
* ``SIMULO_API_TOKEN`` — when set, sent as ``Authorization: Bearer <token>``.
|
|
19
|
+
This is the offline/manual override; the cloud path passes an explicit
|
|
20
|
+
``token=`` (resolved from ``~/.simulo/credentials``) which takes precedence.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
import urllib.parse
|
|
27
|
+
from typing import Any, Optional
|
|
28
|
+
|
|
29
|
+
from simulo._client import http
|
|
30
|
+
from simulo.interfaces.platform.runs import (
|
|
31
|
+
JOB_LOGS_ROUTE_TEMPLATE,
|
|
32
|
+
JOB_RESULT_ROUTE_TEMPLATE,
|
|
33
|
+
JOB_ROUTE_TEMPLATE,
|
|
34
|
+
JOB_SCOPE_MINE,
|
|
35
|
+
JOBS_API_DEFAULT_PORT,
|
|
36
|
+
JOBS_ROUTE,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
API_URL_ENV = "SIMULO_API_URL"
|
|
40
|
+
API_TOKEN_ENV = "SIMULO_API_TOKEN"
|
|
41
|
+
|
|
42
|
+
_DEFAULT_BASE_URL = f"http://127.0.0.1:{JOBS_API_DEFAULT_PORT}"
|
|
43
|
+
_REQUEST_TIMEOUT_S = 10.0 # every outbound call has an explicit timeout (NFR)
|
|
44
|
+
|
|
45
|
+
_LIST_PAGE_LIMIT = 100 # the contract's page-size ceiling (LOG_CHUNK_MAX_BYTES-style cap)
|
|
46
|
+
#: Safety cap on pages fetched by `list_jobs` (100k jobs) — guards against an
|
|
47
|
+
#: infinite loop if a server bug always reports a full page; not a real limit.
|
|
48
|
+
_LIST_MAX_PAGES = 1000
|
|
49
|
+
|
|
50
|
+
#: Mirrors the sibling API clients' hint (submit_api.py / cancel_api.py /
|
|
51
|
+
#: view_session_api.py) — one consistent, cloud-first unavailable message
|
|
52
|
+
#: across every HTTP-talking module in ``_client``.
|
|
53
|
+
_UNAVAILABLE_HINT = "Check SIMULO_API_URL / SIMULO_ENV, and that you are logged in (`simulo login`)."
|
|
54
|
+
|
|
55
|
+
# Backward-compatible aliases: these three names used to be defined directly in
|
|
56
|
+
# this module; they now live in http.py (shared with submit_api.py) but every
|
|
57
|
+
# existing import site (`from simulo._client.jobs_api import JobsApiError, ...`)
|
|
58
|
+
# must keep working unchanged.
|
|
59
|
+
JobsApiError = http.HttpError
|
|
60
|
+
JobsApiUnavailable = http.HttpUnavailable
|
|
61
|
+
JobsApiHTTPError = http.HttpHTTPError
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class JobsApiClient:
|
|
65
|
+
"""Client for the four read-only jobs endpoints."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, base_url: Optional[str] = None, *, token: Optional[str] = None) -> None:
|
|
68
|
+
resolved = base_url or os.environ.get(API_URL_ENV) or _DEFAULT_BASE_URL
|
|
69
|
+
if not resolved.startswith(("http://", "https://")):
|
|
70
|
+
raise JobsApiError(f"{API_URL_ENV} must be an http(s) URL, got {resolved!r}.")
|
|
71
|
+
self._base_url = resolved.rstrip("/")
|
|
72
|
+
self._token = token
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def base_url(self) -> str:
|
|
76
|
+
return self._base_url
|
|
77
|
+
|
|
78
|
+
def list_jobs(self, *, scope: str = JOB_SCOPE_MINE) -> list[dict[str, Any]]:
|
|
79
|
+
"""All job records the API returns, most recent first.
|
|
80
|
+
|
|
81
|
+
Loops every page at ``_LIST_PAGE_LIMIT`` (100) rather than fetching
|
|
82
|
+
page 1 alone, so a run store with more than 100 recorded jobs is not
|
|
83
|
+
silently truncated to the first page.
|
|
84
|
+
|
|
85
|
+
``scope`` (contract ``runs.JOB_SCOPE_MINE``/``JOB_SCOPE_ORG``):
|
|
86
|
+
``"mine"`` (the default) lists only jobs the caller themselves
|
|
87
|
+
submitted; ``"org"`` (``simulo jobs --org``) lists every job in the
|
|
88
|
+
organization. Sent on every request, including the default, so a
|
|
89
|
+
server too old to know the parameter simply ignores it (unaffected —
|
|
90
|
+
the parameter is additive).
|
|
91
|
+
"""
|
|
92
|
+
items: list[dict[str, Any]] = []
|
|
93
|
+
page = 1
|
|
94
|
+
while page <= _LIST_MAX_PAGES:
|
|
95
|
+
payload = self._get(JOBS_ROUTE, {"limit": str(_LIST_PAGE_LIMIT), "page": str(page), "scope": scope})
|
|
96
|
+
page_items = payload.get("items") if isinstance(payload, dict) else None
|
|
97
|
+
if not isinstance(page_items, list):
|
|
98
|
+
raise JobsApiError(f"Malformed jobs list response from {self._base_url} (no 'items' array).")
|
|
99
|
+
items.extend(item for item in page_items if isinstance(item, dict))
|
|
100
|
+
if len(page_items) < _LIST_PAGE_LIMIT:
|
|
101
|
+
return items # a short page means there is nothing more to fetch
|
|
102
|
+
page += 1
|
|
103
|
+
return items
|
|
104
|
+
|
|
105
|
+
def list_jobs_page(
|
|
106
|
+
self, *, limit: int, page: int = 1, scope: str = JOB_SCOPE_MINE
|
|
107
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
108
|
+
"""One page of *limit* job records (most recent first) plus the total
|
|
109
|
+
job count across every page.
|
|
110
|
+
|
|
111
|
+
The single-request counterpart to :meth:`list_jobs` (which loops
|
|
112
|
+
every page for a full listing — the CLI's ``simulo jobs --json``) —
|
|
113
|
+
backs the CLI's bounded table view, so it never pays for pages it
|
|
114
|
+
will not display. *page* defaults to the first; the CLI's
|
|
115
|
+
``_fetch_list_view`` walks it forward for a view wider than one
|
|
116
|
+
server page.
|
|
117
|
+
|
|
118
|
+
``scope``: see :meth:`list_jobs`.
|
|
119
|
+
"""
|
|
120
|
+
payload = self._get(JOBS_ROUTE, {"limit": str(limit), "page": str(page), "scope": scope})
|
|
121
|
+
items = payload.get("items") if isinstance(payload, dict) else None
|
|
122
|
+
total = payload.get("total") if isinstance(payload, dict) else None
|
|
123
|
+
if not isinstance(items, list) or not isinstance(total, int):
|
|
124
|
+
raise JobsApiError(f"Malformed jobs list response from {self._base_url} (no 'items'/'total').")
|
|
125
|
+
return [item for item in items if isinstance(item, dict)], total
|
|
126
|
+
|
|
127
|
+
def get_latest_job(self, *, scope: str = JOB_SCOPE_MINE) -> Optional[dict[str, Any]]:
|
|
128
|
+
"""The most recent job record, or ``None`` when no jobs exist yet.
|
|
129
|
+
|
|
130
|
+
The contract orders ``GET /v1/jobs`` most recent first, so the latest
|
|
131
|
+
job is simply the first item of page 1 — fetched with ``limit=1``,
|
|
132
|
+
never the full listing. This is the client-side resolution ``simulo
|
|
133
|
+
logs`` / ``simulo result`` use when the job id is omitted (the cloud
|
|
134
|
+
CLI keeps this exact behavior).
|
|
135
|
+
"""
|
|
136
|
+
payload = self._get(JOBS_ROUTE, {"limit": "1", "page": "1", "scope": scope})
|
|
137
|
+
items = payload.get("items") if isinstance(payload, dict) else None
|
|
138
|
+
if not isinstance(items, list):
|
|
139
|
+
raise JobsApiError(f"Malformed jobs list response from {self._base_url} (no 'items' array).")
|
|
140
|
+
if not items:
|
|
141
|
+
return None
|
|
142
|
+
latest = items[0]
|
|
143
|
+
if not isinstance(latest, dict) or not isinstance(latest.get("job_id"), str) or not latest["job_id"]:
|
|
144
|
+
raise JobsApiError(f"Malformed job record in the jobs list from {self._base_url} (no 'job_id').")
|
|
145
|
+
return latest
|
|
146
|
+
|
|
147
|
+
def get_job(self, job_id: str, *, scope: str = JOB_SCOPE_MINE) -> dict[str, Any]:
|
|
148
|
+
"""One job record. Raises :class:`JobsApiHTTPError` (404) when unknown."""
|
|
149
|
+
payload = self._get(JOB_ROUTE_TEMPLATE.format(job_id=_quote(job_id)), {"scope": scope})
|
|
150
|
+
if not isinstance(payload, dict):
|
|
151
|
+
raise JobsApiError("Malformed job record response for the selected job.")
|
|
152
|
+
return payload
|
|
153
|
+
|
|
154
|
+
def get_log_chunk(
|
|
155
|
+
self,
|
|
156
|
+
job_id: str,
|
|
157
|
+
offset: int,
|
|
158
|
+
*,
|
|
159
|
+
tail_bytes: Optional[int] = None,
|
|
160
|
+
scope: str = JOB_SCOPE_MINE,
|
|
161
|
+
) -> tuple[str, int, str, Optional[int]]:
|
|
162
|
+
"""One log chunk from ``offset``; returns ``(chunk, next_offset, status, start_offset)``.
|
|
163
|
+
|
|
164
|
+
Byte-offset semantics per the contract: resume from the returned
|
|
165
|
+
``next_offset``; an empty chunk with an unchanged offset means "caught
|
|
166
|
+
up" — poll again while ``status`` is non-terminal.
|
|
167
|
+
|
|
168
|
+
``tail_bytes`` (contract tail semantics; requires ``offset == 0``) asks
|
|
169
|
+
the server to start the read that many bytes before the END of the
|
|
170
|
+
stream. ``start_offset`` in the returned tuple is the server's computed
|
|
171
|
+
start position when tail was honored, and ``None`` otherwise —
|
|
172
|
+
including when ``tail_bytes`` was requested but the server is too old
|
|
173
|
+
to know the parameter (it then answers from offset 0 exactly like a
|
|
174
|
+
plain request; callers use ``start_offset is None`` to detect that and
|
|
175
|
+
degrade to full history rather than mis-trim).
|
|
176
|
+
|
|
177
|
+
**Unavailable logs.** A finished job whose archived log
|
|
178
|
+
has aged out of retention, or whose bytes the platform could not keep,
|
|
179
|
+
answers ``410`` and raises :class:`JobsApiHTTPError` with
|
|
180
|
+
``status == 410`` and ``code`` in
|
|
181
|
+
:data:`~simulo.interfaces.platform.runs.LOG_ARCHIVE_UNAVAILABLE_CODES`
|
|
182
|
+
(``log_archive_expired`` / ``log_archive_lost``). This is a TERMINAL
|
|
183
|
+
condition — retrying the same request cannot succeed — and is
|
|
184
|
+
deliberately NOT an empty chunk, so a caller cannot mistake "these
|
|
185
|
+
logs no longer exist" for "this run printed nothing". A caller that
|
|
186
|
+
loops on ``next_offset`` must stop on it rather than retry; the CLI's
|
|
187
|
+
``_report_log_archive_unavailable`` is the reference handling.
|
|
188
|
+
"""
|
|
189
|
+
query = {"offset": str(offset), "scope": scope}
|
|
190
|
+
if tail_bytes is not None:
|
|
191
|
+
query["tail_bytes"] = str(tail_bytes)
|
|
192
|
+
payload = self._get(JOB_LOGS_ROUTE_TEMPLATE.format(job_id=_quote(job_id)), query)
|
|
193
|
+
if not isinstance(payload, dict):
|
|
194
|
+
raise JobsApiError("Malformed log chunk response for the selected job.")
|
|
195
|
+
chunk, next_offset, status = payload.get("chunk"), payload.get("next_offset"), payload.get("status")
|
|
196
|
+
if not isinstance(chunk, str) or not isinstance(next_offset, int) or not isinstance(status, str):
|
|
197
|
+
raise JobsApiError("Malformed log chunk response for the selected job.")
|
|
198
|
+
start_offset = payload.get("start_offset") if tail_bytes is not None else None
|
|
199
|
+
if start_offset is not None and not isinstance(start_offset, int):
|
|
200
|
+
raise JobsApiError("Malformed log chunk response for the selected job.")
|
|
201
|
+
return chunk, next_offset, status, start_offset
|
|
202
|
+
|
|
203
|
+
def get_result(self, job_id: str, *, scope: str = JOB_SCOPE_MINE) -> Any:
|
|
204
|
+
"""The result JSON. 404 :class:`JobsApiHTTPError` until the job succeeded."""
|
|
205
|
+
return self._get(JOB_RESULT_ROUTE_TEMPLATE.format(job_id=_quote(job_id)), {"scope": scope})
|
|
206
|
+
|
|
207
|
+
def _get(self, path: str, query: Optional[dict[str, str]] = None) -> Any:
|
|
208
|
+
url = self._base_url + path
|
|
209
|
+
if query:
|
|
210
|
+
url += "?" + urllib.parse.urlencode(query)
|
|
211
|
+
token = self._token if self._token is not None else os.environ.get(API_TOKEN_ENV)
|
|
212
|
+
return http.request_json(
|
|
213
|
+
"GET",
|
|
214
|
+
url,
|
|
215
|
+
token=token,
|
|
216
|
+
api_base_url=self._base_url,
|
|
217
|
+
timeout=_REQUEST_TIMEOUT_S,
|
|
218
|
+
unavailable_hint=_UNAVAILABLE_HINT,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _quote(job_id: str) -> str:
|
|
223
|
+
"""Percent-encode a job id for use as a single URL path segment."""
|
|
224
|
+
return http.quote_path_segment(job_id)
|