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.
Files changed (55) hide show
  1. simulo/__init__.py +433 -0
  2. simulo/_client/__init__.py +6 -0
  3. simulo/_client/_entrypoint.py +313 -0
  4. simulo/_client/_mounts.py +25 -0
  5. simulo/_client/_runner.py +186 -0
  6. simulo/_client/_secure_downloads.py +1181 -0
  7. simulo/_client/app.py +1308 -0
  8. simulo/_client/asset.py +331 -0
  9. simulo/_client/asset_api.py +517 -0
  10. simulo/_client/asset_package.py +1103 -0
  11. simulo/_client/asset_pins.py +187 -0
  12. simulo/_client/builtin_aliases.py +107 -0
  13. simulo/_client/bundle.py +254 -0
  14. simulo/_client/cancel_api.py +104 -0
  15. simulo/_client/cli.py +9063 -0
  16. simulo/_client/config.py +186 -0
  17. simulo/_client/credentials.py +210 -0
  18. simulo/_client/discovery.py +214 -0
  19. simulo/_client/export_api.py +212 -0
  20. simulo/_client/export_bundle.py +296 -0
  21. simulo/_client/facades.py +581 -0
  22. simulo/_client/http.py +414 -0
  23. simulo/_client/identity_api.py +117 -0
  24. simulo/_client/install_samples.py +267 -0
  25. simulo/_client/jobs_api.py +224 -0
  26. simulo/_client/learning.py +393 -0
  27. simulo/_client/login.py +319 -0
  28. simulo/_client/mode.py +29 -0
  29. simulo/_client/outputs.py +116 -0
  30. simulo/_client/packaging.py +445 -0
  31. simulo/_client/preflight_api.py +186 -0
  32. simulo/_client/preflight_render.py +200 -0
  33. simulo/_client/registry.py +98 -0
  34. simulo/_client/runtime.py +185 -0
  35. simulo/_client/runtime_display.py +90 -0
  36. simulo/_client/seed_ref.py +76 -0
  37. simulo/_client/stub.py +41 -0
  38. simulo/_client/submit_api.py +1057 -0
  39. simulo/_client/templates/__init__.py +21 -0
  40. simulo/_client/templates/inference/app.py.tmpl +316 -0
  41. simulo/_client/templates/inference/simuloignore.tmpl +30 -0
  42. simulo/_client/templates/scenario/app.py.tmpl +93 -0
  43. simulo/_client/templates/scenario/simuloignore.tmpl +27 -0
  44. simulo/_client/templates/training/app.py.tmpl +235 -0
  45. simulo/_client/templates/training/simuloignore.tmpl +29 -0
  46. simulo/_client/view_fragment.py +21 -0
  47. simulo/_client/view_session_api.py +122 -0
  48. simulo/_client/volume.py +71 -0
  49. simulo/callbacks.py +274 -0
  50. simulo/py.typed +0 -0
  51. simulo-0.26.0.dist-info/METADATA +130 -0
  52. simulo-0.26.0.dist-info/RECORD +55 -0
  53. simulo-0.26.0.dist-info/WHEEL +5 -0
  54. simulo-0.26.0.dist-info/entry_points.txt +2 -0
  55. 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)