simulo 0.33.0__tar.gz → 0.34.0__tar.gz
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-0.33.0/src/simulo.egg-info → simulo-0.34.0}/PKG-INFO +4 -2
- {simulo-0.33.0 → simulo-0.34.0}/pyproject.toml +3 -2
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/__init__.py +6 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/app.py +52 -6
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/bundle.py +9 -7
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/cli_context.py +25 -4
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/cli_job_reporting.py +62 -22
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/cli_waiting.py +37 -25
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/commands/assets.py +6 -3
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/commands/jobs.py +79 -24
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/commands/recordings.py +49 -41
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/commands/run.py +125 -48
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/credentials.py +11 -1
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/evaluation_report.py +45 -6
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/facades.py +2 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/learning.py +12 -1
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/packaging.py +27 -17
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/preview_report.py +129 -101
- simulo-0.34.0/src/simulo/_client/recording_export.py +54 -0
- simulo-0.34.0/src/simulo/_client/repeatable_episodes.py +22 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/submit_api.py +36 -21
- simulo-0.34.0/src/simulo/_client/training_algorithm.py +11 -0
- simulo-0.34.0/src/simulo/_client/viewstream_policy.py +86 -0
- {simulo-0.33.0 → simulo-0.34.0/src/simulo.egg-info}/PKG-INFO +4 -2
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo.egg-info/SOURCES.txt +4 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo.egg-info/requires.txt +6 -1
- {simulo-0.33.0 → simulo-0.34.0}/MANIFEST.in +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/PYPI.md +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/setup.cfg +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/__init__.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/_arguments.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/_mounts.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/_runner.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/_secure_downloads.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/asset.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/asset_api.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/asset_package.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/asset_pins.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/builtin_aliases.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/cancel_api.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/cli.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/cli_arguments.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/cli_downloads.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/cli_errors.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/cli_listing.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/cli_rendering.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/cli_text.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/commands/__init__.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/commands/auth.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/commands/create.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/commands/policies.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/commands/systems.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/config.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/discovery.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/export_api.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/http.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/identity_api.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/install_samples.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/jobs_api.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/login.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/mode.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/onnx_bundle.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/outputs.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/policies_api.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/preflight_api.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/preflight_render.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/registry.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/runtime.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/runtime_display.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/stub.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/templates/__init__.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/templates/training/eval.py.tmpl +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/templates/training/play.py.tmpl +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/templates/training/preview.py.tmpl +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/templates/training/simuloignore.tmpl +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/templates/training/task.py.tmpl +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/templates/training/train.py.tmpl +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/view_fragment.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/view_session_api.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/_client/volume.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/callbacks.py +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo/py.typed +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo.egg-info/dependency_links.txt +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo.egg-info/entry_points.txt +0 -0
- {simulo-0.33.0 → simulo-0.34.0}/src/simulo.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: simulo
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.34.0
|
|
4
4
|
Summary: Python SDK and CLI for cloud-native robotics simulation, reinforcement learning, and robot policy training on managed GPUs.
|
|
5
5
|
Author-email: Simulo Team <team@simulo.ai>
|
|
6
6
|
License: BSD-3-Clause
|
|
@@ -31,10 +31,12 @@ Classifier: Topic :: System :: Distributed Computing
|
|
|
31
31
|
Classifier: Typing :: Typed
|
|
32
32
|
Requires-Python: >=3.11
|
|
33
33
|
Description-Content-Type: text/markdown
|
|
34
|
-
Requires-Dist: simulo-interfaces<0.
|
|
34
|
+
Requires-Dist: simulo-interfaces<0.26,>=0.25.0
|
|
35
35
|
Requires-Dist: mcap<2,>=1.3
|
|
36
36
|
Requires-Dist: defusedxml>=0.7.1
|
|
37
37
|
Requires-Dist: usd-core<27,>=26.5
|
|
38
|
+
Provides-Extra: viz
|
|
39
|
+
Requires-Dist: simulo-viz<0.2,>=0.1.0; python_version >= "3.12" and extra == "viz"
|
|
38
40
|
Provides-Extra: assets
|
|
39
41
|
Requires-Dist: usd-core<27,>=26.5; extra == "assets"
|
|
40
42
|
Provides-Extra: dev
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "simulo"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.34.0"
|
|
8
8
|
description = "Python SDK and CLI for cloud-native robotics simulation, reinforcement learning, and robot policy training on managed GPUs."
|
|
9
9
|
readme = "PYPI.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -59,7 +59,7 @@ classifiers = [
|
|
|
59
59
|
# mcap: used by `simulo recordings` to read back a downloaded recording and
|
|
60
60
|
# verify it (message count) — MIT-licensed, deps only lz4+zstandard.
|
|
61
61
|
dependencies = [
|
|
62
|
-
"simulo-interfaces>=0.
|
|
62
|
+
"simulo-interfaces>=0.25.0,<0.26",
|
|
63
63
|
"mcap>=1.3,<2",
|
|
64
64
|
# Hardened XML parsing for URDF ingestion (USD Asset Catalogs, PR-12 fix
|
|
65
65
|
# loop 2, security NIT): stdlib xml.etree.ElementTree relies on
|
|
@@ -81,6 +81,7 @@ dependencies = [
|
|
|
81
81
|
]
|
|
82
82
|
|
|
83
83
|
[project.optional-dependencies]
|
|
84
|
+
viz = ["simulo-viz>=0.1.0,<0.2; python_version >= '3.12'"]
|
|
84
85
|
# `assets` — kept for backward compatibility with `pip install
|
|
85
86
|
# "simulo[assets]"` (the 0.13.0-era spelling): usd-core moved into the base
|
|
86
87
|
# install above in 0.13.1, so this extra is now redundant (its dep is
|
|
@@ -129,6 +129,7 @@ from simulo.interfaces import (
|
|
|
129
129
|
# re-exported here like every other concrete authoring type — no exclusion
|
|
130
130
|
# needed any more.
|
|
131
131
|
from simulo.interfaces.authoring import (
|
|
132
|
+
ActionChannel,
|
|
132
133
|
ArrowMarker,
|
|
133
134
|
AssetEntity,
|
|
134
135
|
AssetSource,
|
|
@@ -143,6 +144,8 @@ from simulo.interfaces.authoring import (
|
|
|
143
144
|
DomeLight,
|
|
144
145
|
GroundPlane,
|
|
145
146
|
LightConfig,
|
|
147
|
+
LineMarker,
|
|
148
|
+
ObjectVariants,
|
|
146
149
|
PointMarker,
|
|
147
150
|
PolicyCheckpoint,
|
|
148
151
|
PolicyInput,
|
|
@@ -210,6 +213,7 @@ __all__ = [
|
|
|
210
213
|
"Digest",
|
|
211
214
|
# authoring value types (eager, real) — the rest of the vocabulary the
|
|
212
215
|
# lazy resolver below does not carry
|
|
216
|
+
"ActionChannel",
|
|
213
217
|
"Collision",
|
|
214
218
|
"RigidPhysics",
|
|
215
219
|
"DeformableMaterial",
|
|
@@ -219,6 +223,7 @@ __all__ = [
|
|
|
219
223
|
"USDAsset",
|
|
220
224
|
"URDFAsset",
|
|
221
225
|
"LightConfig",
|
|
226
|
+
"ObjectVariants",
|
|
222
227
|
"DistantLight",
|
|
223
228
|
"DomeLight",
|
|
224
229
|
"TerrainConfig",
|
|
@@ -238,6 +243,7 @@ __all__ = [
|
|
|
238
243
|
"SphereMarker",
|
|
239
244
|
"ArrowMarker",
|
|
240
245
|
"PointMarker",
|
|
246
|
+
"LineMarker",
|
|
241
247
|
# lazy, mode-aware (the learning names + save_output)
|
|
242
248
|
"ActuatorGainsConfig",
|
|
243
249
|
"ArticulationConfig",
|
|
@@ -48,7 +48,9 @@ from typing import (
|
|
|
48
48
|
|
|
49
49
|
from simulo._client import config, credentials, registry
|
|
50
50
|
from simulo._client._runner import JobFailedError, JobHandle, JobResultUnavailable
|
|
51
|
+
from simulo._client.repeatable_episodes import requested_repeatable_episodes
|
|
51
52
|
from simulo._client.runtime import DEFAULT_RUNTIME
|
|
53
|
+
from simulo._client.training_algorithm import validate_training_algorithm
|
|
52
54
|
from simulo.interfaces.ids import JobId, parse_job_public_id
|
|
53
55
|
from simulo.interfaces.platform import JobCallbackProtocol, JobType, normalize_job_tags
|
|
54
56
|
from simulo.interfaces.platform.app import Mount
|
|
@@ -131,19 +133,43 @@ def spawn_recorder() -> Iterator[list["JobHandle"]]:
|
|
|
131
133
|
# through arbitrary user code" reason.
|
|
132
134
|
# ---------------------------------------------------------------------------
|
|
133
135
|
|
|
134
|
-
_VIEWSTREAM_REQUESTED =
|
|
136
|
+
_VIEWSTREAM_REQUESTED: Optional[bool] = None
|
|
137
|
+
_VIEWSTREAM_NOTICE: Optional[str] = None
|
|
135
138
|
|
|
136
139
|
|
|
137
140
|
@contextlib.contextmanager
|
|
138
|
-
def viewstream_requested(enabled: bool) -> Iterator[None]:
|
|
139
|
-
"""Scope
|
|
140
|
-
global _VIEWSTREAM_REQUESTED
|
|
141
|
+
def viewstream_requested(enabled: Optional[bool], *, notice: Optional[str] = None) -> Iterator[None]:
|
|
142
|
+
"""Scope one resolved live-view request for one ``simulo run`` submit call."""
|
|
143
|
+
global _VIEWSTREAM_REQUESTED, _VIEWSTREAM_NOTICE
|
|
141
144
|
previous = _VIEWSTREAM_REQUESTED
|
|
145
|
+
previous_notice = _VIEWSTREAM_NOTICE
|
|
142
146
|
_VIEWSTREAM_REQUESTED = enabled
|
|
147
|
+
_VIEWSTREAM_NOTICE = notice
|
|
143
148
|
try:
|
|
144
149
|
yield
|
|
145
150
|
finally:
|
|
146
151
|
_VIEWSTREAM_REQUESTED = previous
|
|
152
|
+
_VIEWSTREAM_NOTICE = previous_notice
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
# 3D-recording ambient signal — `simulo run --record-3d COPIES` scopes an
|
|
157
|
+
# execution option without placing it in package arguments or the manifest.
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
_RECORDING_COPIES_REQUESTED: Optional[int] = None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@contextlib.contextmanager
|
|
164
|
+
def recording_copies_requested(copies: Optional[int]) -> Iterator[None]:
|
|
165
|
+
"""Scope an optional 3D recording copy count for one submit."""
|
|
166
|
+
global _RECORDING_COPIES_REQUESTED
|
|
167
|
+
previous = _RECORDING_COPIES_REQUESTED
|
|
168
|
+
_RECORDING_COPIES_REQUESTED = copies
|
|
169
|
+
try:
|
|
170
|
+
yield
|
|
171
|
+
finally:
|
|
172
|
+
_RECORDING_COPIES_REQUESTED = previous
|
|
147
173
|
|
|
148
174
|
|
|
149
175
|
# ---------------------------------------------------------------------------
|
|
@@ -526,7 +552,9 @@ def _spawn_cloud(
|
|
|
526
552
|
package_path: Path,
|
|
527
553
|
creds: Optional[dict[str, Any]],
|
|
528
554
|
*,
|
|
529
|
-
viewstream: bool =
|
|
555
|
+
viewstream: Optional[bool] = None,
|
|
556
|
+
recording_copies: Optional[int] = None,
|
|
557
|
+
repeatable_episodes: Optional[bool] = None,
|
|
530
558
|
input_checkpoint: Optional[str] = None,
|
|
531
559
|
compare_checkpoint: Optional[str] = None,
|
|
532
560
|
require_success: Optional[float] = None,
|
|
@@ -689,7 +717,7 @@ def _spawn_cloud(
|
|
|
689
717
|
archive_size=len(archive_bytes),
|
|
690
718
|
job_type=job_type,
|
|
691
719
|
input_checkpoint=input_checkpoint,
|
|
692
|
-
viewstream=viewstream,
|
|
720
|
+
viewstream=viewstream is True,
|
|
693
721
|
assets=[] if not asset_refs else None,
|
|
694
722
|
)
|
|
695
723
|
deferred_stdout, deferred_stderr = _handle_preflight_outcome(outcome, job_name=job_name)
|
|
@@ -721,6 +749,8 @@ def _spawn_cloud(
|
|
|
721
749
|
job_name=job_name,
|
|
722
750
|
args=args,
|
|
723
751
|
viewstream=viewstream,
|
|
752
|
+
recording_copies=recording_copies,
|
|
753
|
+
repeatable_episodes=repeatable_episodes,
|
|
724
754
|
input_checkpoint=input_checkpoint,
|
|
725
755
|
compare_checkpoint=compare_checkpoint,
|
|
726
756
|
require_success=require_success,
|
|
@@ -732,6 +762,14 @@ def _spawn_cloud(
|
|
|
732
762
|
tags=tags,
|
|
733
763
|
manifest_version=manifest_version,
|
|
734
764
|
)
|
|
765
|
+
# A submitted entrypoint can call ``spawn()`` more than once. The
|
|
766
|
+
# resolved notice describes the run-level opt-in, so print it once only
|
|
767
|
+
# after the first successful job-create response. Do not consume it when
|
|
768
|
+
# create_job raises: then the run did not successfully submit anything.
|
|
769
|
+
global _VIEWSTREAM_NOTICE
|
|
770
|
+
if _VIEWSTREAM_NOTICE is not None:
|
|
771
|
+
print(_VIEWSTREAM_NOTICE, file=sys.stderr)
|
|
772
|
+
_VIEWSTREAM_NOTICE = None
|
|
735
773
|
|
|
736
774
|
raw_public_id = record.get("public_id")
|
|
737
775
|
try:
|
|
@@ -813,6 +851,12 @@ class JobFunction:
|
|
|
813
851
|
# job/success contract as ``simulo run``. This happens before the
|
|
814
852
|
# intent marker, bundle construction, asset resolution, or network I/O.
|
|
815
853
|
self._app.validate_job_submission(self._spec.name)
|
|
854
|
+
if self._spec.job_type is JobType.TRAIN:
|
|
855
|
+
parameter = inspect.signature(self._spec.fn).parameters.get("algorithm")
|
|
856
|
+
if "algorithm" in kwargs:
|
|
857
|
+
validate_training_algorithm(kwargs["algorithm"])
|
|
858
|
+
elif parameter is not None and parameter.default is not inspect.Parameter.empty:
|
|
859
|
+
validate_training_algorithm(parameter.default)
|
|
816
860
|
|
|
817
861
|
# Intent-to-submit is recorded FIRST — before any packaging or network
|
|
818
862
|
# I/O — so a submit that fails partway (or times out AFTER the server
|
|
@@ -861,6 +905,8 @@ class JobFunction:
|
|
|
861
905
|
package_path,
|
|
862
906
|
creds,
|
|
863
907
|
viewstream=_VIEWSTREAM_REQUESTED,
|
|
908
|
+
recording_copies=_RECORDING_COPIES_REQUESTED,
|
|
909
|
+
repeatable_episodes=requested_repeatable_episodes(),
|
|
864
910
|
input_checkpoint=_INPUT_CHECKPOINT_REF,
|
|
865
911
|
compare_checkpoint=_COMPARE_CHECKPOINT_REF,
|
|
866
912
|
require_success=_REQUIRE_SUCCESS,
|
|
@@ -4,9 +4,10 @@ Shared by ``simulo run`` (the CLI) and ``JobFunction.spawn`` — the *submit* pa
|
|
|
4
4
|
discover the app's metadata and write the package (manifest + source bundle) to
|
|
5
5
|
disk. The manifest builder reads only the public ``App`` surface, so it works in
|
|
6
6
|
discovery mode (where submit runs). ``ensure_bundle`` memoises the written package
|
|
7
|
-
per *submission* — ``(app, entry_file, job_name, args)`` — so
|
|
8
|
-
same file and job with the same args
|
|
9
|
-
|
|
7
|
+
per *submission* — ``(app, entry_file, job_name, args, captured assets)`` — so
|
|
8
|
+
re-submitting the same file and job with the same args and captured assets reuses
|
|
9
|
+
the package. Package identity hashes source, entry file, job, canonical args,
|
|
10
|
+
and the canonical asset list; changing any of those writes a distinct package.
|
|
10
11
|
|
|
11
12
|
Submit NEVER executes a job and NEVER invokes the backend — it only writes the
|
|
12
13
|
package. This module imports no heavy library; it stays on the torch-free side of
|
|
@@ -59,9 +60,9 @@ DEFAULT_OUT_SUBPATH = (".simulo", "packages")
|
|
|
59
60
|
#: capture — see ``asset.py``) — included so a LATER spawn() in the same process,
|
|
60
61
|
#: after user code has constructed MORE assets since an earlier spawn() with
|
|
61
62
|
#: the identical ``(app, entry_file, job_name, args)``, is never served a stale cached bundle
|
|
62
|
-
#: whose ``resources`` predates those constructions
|
|
63
|
-
#:
|
|
64
|
-
#:
|
|
63
|
+
#: whose ``resources`` predates those constructions. The package id also hashes
|
|
64
|
+
#: resources, but a cache hit does not rebuild the manifest or recompute the id,
|
|
65
|
+
#: so this key must still distinguish the captured asset set.
|
|
65
66
|
_BUNDLE_CACHE: dict[tuple[int, str, str, str, tuple[str, ...]], tuple[Path, str]] = {}
|
|
66
67
|
|
|
67
68
|
|
|
@@ -197,7 +198,8 @@ def ensure_bundle(
|
|
|
197
198
|
This is the *submit* primitive: the manifest is built from the live app object
|
|
198
199
|
(no second discovery import) with the submitted job's ``args`` recorded, and
|
|
199
200
|
the source tree is written to ``<out>/<bundle_dir_name(package_id)>/`` — the
|
|
200
|
-
per-submission directory keyed on source + entry file + ``job_name`` +
|
|
201
|
+
per-submission directory keyed on source + entry file + ``job_name`` + canonical
|
|
202
|
+
``args`` + canonical asset list (named by
|
|
201
203
|
the id's short, filesystem-safe form; see ``packaging.bundle_dir_name``).
|
|
202
204
|
Memoised per submission (``(id(app), entry_file, job_name, canonical_json(args), sorted
|
|
203
205
|
extra_assets uris)``) so re-submitting the same job with the same args AND the
|
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import argparse
|
|
6
6
|
import os
|
|
7
7
|
import sys
|
|
8
|
-
from typing import Callable, Optional, Sequence
|
|
8
|
+
from typing import Any, Callable, Optional, Sequence
|
|
9
9
|
|
|
10
10
|
from simulo._client import config, credentials, preview_report
|
|
11
11
|
from simulo._client.app import (
|
|
@@ -43,11 +43,32 @@ def _resolve_client_context() -> tuple[str, Optional[str]]:
|
|
|
43
43
|
return base_url, token
|
|
44
44
|
|
|
45
45
|
|
|
46
|
-
def _client_with_fresh_token(client: JobsApiClient) -> JobsApiClient:
|
|
46
|
+
def _client_with_fresh_token(client: JobsApiClient, initial_credentials: dict[str, Any]) -> JobsApiClient:
|
|
47
47
|
"""Return a client with current stored credentials, preserving its target."""
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
if not credentials.credentials_file_exists():
|
|
49
|
+
# Preserve the selected-file error and the manual-token fallback.
|
|
50
|
+
if os.environ.get(credentials.CREDENTIALS_FILE_ENV) is not None:
|
|
51
|
+
credentials.load_credentials()
|
|
50
52
|
return client
|
|
53
|
+
creds = credentials.load_credentials()
|
|
54
|
+
if (
|
|
55
|
+
not initial_credentials
|
|
56
|
+
or config.resolve_base_url(creds).rstrip("/") != client.base_url
|
|
57
|
+
or any(
|
|
58
|
+
creds.get(field) != initial_credentials.get(field)
|
|
59
|
+
for field in ("api_base_url", "user_email", "active_organization_id")
|
|
60
|
+
)
|
|
61
|
+
):
|
|
62
|
+
raise credentials.CredentialsError(
|
|
63
|
+
"Your saved sign-in changed while this command was running "
|
|
64
|
+
"(different user, organization or server). Re-run the command."
|
|
65
|
+
)
|
|
66
|
+
# Refresh the checked snapshot directly. A second file read could adopt a
|
|
67
|
+
# different login between this guard and the refresh request.
|
|
68
|
+
if credentials.is_expired(creds):
|
|
69
|
+
creds = credentials.refresh_access_token(
|
|
70
|
+
creds, base_url=client.base_url, min_validity_s=credentials._EXPIRY_SKEW_S
|
|
71
|
+
)
|
|
51
72
|
return JobsApiClient(client.base_url, token=str(creds["access_token"]))
|
|
52
73
|
|
|
53
74
|
|
|
@@ -7,9 +7,8 @@ import sys
|
|
|
7
7
|
import time
|
|
8
8
|
from typing import Any, Callable, Optional, TypeVar
|
|
9
9
|
|
|
10
|
-
from simulo._client import credentials,
|
|
10
|
+
from simulo._client import credentials, preview_report
|
|
11
11
|
from simulo._client.cli_context import _client_with_fresh_token
|
|
12
|
-
from simulo._client.cli_errors import caused_by
|
|
13
12
|
from simulo._client.cli_rendering import _safe_table_cell
|
|
14
13
|
from simulo._client.cli_text import (
|
|
15
14
|
_MAX_OUTPUTS_LINE_CHARS,
|
|
@@ -18,11 +17,10 @@ from simulo._client.cli_text import (
|
|
|
18
17
|
_safe_cli_error_text,
|
|
19
18
|
_safe_single_line_text,
|
|
20
19
|
)
|
|
21
|
-
from simulo._client.cli_waiting import
|
|
20
|
+
from simulo._client.cli_waiting import _retry_transient, _sleep_for_rate_limit
|
|
22
21
|
from simulo._client.jobs_api import JobsApiClient, JobsApiError, JobsApiHTTPError
|
|
23
22
|
from simulo.interfaces.platform.enums import JobStatus
|
|
24
23
|
from simulo.interfaces.platform.outputs import OUTPUT_FLAG_ANOMALY, OUTPUT_FLAG_OUTPUT_DROPPED
|
|
25
|
-
from simulo.interfaces.platform.preview import PREVIEW_REPORT_SCHEMA_VERSION
|
|
26
24
|
from simulo.interfaces.platform.runs import (
|
|
27
25
|
JOB_SCOPE_MINE,
|
|
28
26
|
LOG_ARCHIVE_EXPIRED_CODE,
|
|
@@ -31,9 +29,12 @@ from simulo.interfaces.platform.runs import (
|
|
|
31
29
|
LOG_TAIL_MAX_BYTES,
|
|
32
30
|
TERMINAL_JOB_STATUSES,
|
|
33
31
|
)
|
|
32
|
+
from simulo.interfaces.platform.submit import LIVE_VIEW_UNAVAILABLE_WARNING_CODE
|
|
34
33
|
|
|
35
34
|
#: ``--follow`` poll cadence. Module-level so tests can shrink it.
|
|
36
35
|
_FOLLOW_POLL_INTERVAL_S = 0.7
|
|
36
|
+
#: Keep warning refreshes aligned with the worker's default heartbeat cadence.
|
|
37
|
+
_LIVE_VIEW_WARNING_POLL_INTERVAL_S = 15.0
|
|
37
38
|
_T = TypeVar("_T")
|
|
38
39
|
#: ``simulo logs --follow`` starts from the last this-many LINES of existing
|
|
39
40
|
#: output by default (``tail -f`` style) — override with ``--tail N`` /
|
|
@@ -56,6 +57,8 @@ _COMPLETED_STATUS = str(JobStatus.COMPLETED)
|
|
|
56
57
|
_QUEUED_STATUS = str(JobStatus.QUEUED)
|
|
57
58
|
_RUNNING_STATUS = str(JobStatus.RUNNING)
|
|
58
59
|
_CANCELLED_STATUS = str(JobStatus.CANCELLED)
|
|
60
|
+
_ORG_RUNNING_LIMIT_REASON = "org_running_limit_reached"
|
|
61
|
+
_ORG_RUNNING_LIMIT_MESSAGE = "waiting for a running job in your organization to finish"
|
|
59
62
|
#: The terminal statuses that carry a self-diagnosis reason (JobRecord's
|
|
60
63
|
#: ``status_reason``/``status_detail``): a failed, cancelled, or timed-out
|
|
61
64
|
#: job. A cleanly ``completed`` job has none.
|
|
@@ -144,7 +147,7 @@ def _echo_report_stages(echo: Callable[[str], None], report: dict[str, Any]) ->
|
|
|
144
147
|
check and its status, prints every row's detail, the owner of a warning or
|
|
145
148
|
failure, and where to look in the recording — see
|
|
146
149
|
:func:`preview_report.echo_preview_stages`."""
|
|
147
|
-
if
|
|
150
|
+
if preview_report.is_preview_report(report):
|
|
148
151
|
preview_report.echo_preview_stages(echo, report)
|
|
149
152
|
return
|
|
150
153
|
for stage in report.get("stages") or []:
|
|
@@ -216,22 +219,17 @@ def _outcome_unknown_text(reason: str, job_id: str) -> str:
|
|
|
216
219
|
)
|
|
217
220
|
|
|
218
221
|
|
|
219
|
-
def _fresh_client(
|
|
222
|
+
def _fresh_client(
|
|
223
|
+
client: JobsApiClient, job_id: str, follow: bool, initial_credentials: dict[str, Any]
|
|
224
|
+
) -> JobsApiClient:
|
|
220
225
|
"""Refresh the access token; while *follow*ing, a refresh that cannot reach
|
|
221
226
|
the server is the same unknown-outcome give-up as a failed log read, not a
|
|
222
227
|
credentials error that callers report as the job failing."""
|
|
223
|
-
|
|
224
|
-
return _client_with_fresh_token(client)
|
|
225
|
-
except credentials.TokenRefreshError as exc:
|
|
226
|
-
if follow and caused_by(exc, http.HttpUnavailable):
|
|
227
|
-
raise GaveUpUnavailable(
|
|
228
|
-
f"{_outcome_unknown_text('the session could not be refreshed', job_id)}; {exc}"
|
|
229
|
-
) from exc
|
|
230
|
-
raise
|
|
228
|
+
return _followed(lambda: _client_with_fresh_token(client, initial_credentials), job_id, follow)
|
|
231
229
|
|
|
232
230
|
|
|
233
231
|
def _followed(call: Callable[[], _T], job_id: str, follow: bool) -> _T:
|
|
234
|
-
"""Run a log read
|
|
232
|
+
"""Run a log read or token refresh, retrying only while *follow*ing a job.
|
|
235
233
|
|
|
236
234
|
A one-shot read reports the error as before; a follow that keeps failing
|
|
237
235
|
says the job's outcome is unknown and names how to check it.
|
|
@@ -333,6 +331,7 @@ def _drain_logs(
|
|
|
333
331
|
follow: bool,
|
|
334
332
|
tail_lines: Optional[int] = None,
|
|
335
333
|
scope: str = JOB_SCOPE_MINE,
|
|
334
|
+
initial_credentials: Optional[dict[str, Any]] = None,
|
|
336
335
|
) -> int:
|
|
337
336
|
"""Print log chunks; ``follow`` polls to a terminal status.
|
|
338
337
|
|
|
@@ -345,8 +344,12 @@ def _drain_logs(
|
|
|
345
344
|
post-``simulo run`` default follow — one follow loop, one
|
|
346
345
|
exit-code-mirrors-outcome rule.
|
|
347
346
|
"""
|
|
347
|
+
if initial_credentials is None:
|
|
348
|
+
initial_credentials = credentials.load_credentials() if credentials.credentials_file_exists() else {}
|
|
348
349
|
if follow:
|
|
349
|
-
client = _fresh_client(client, job_id, follow)
|
|
350
|
+
client = _fresh_client(client, job_id, follow, initial_credentials)
|
|
351
|
+
warning_reported = _emit_live_view_warning(client, job_id, scope=scope)
|
|
352
|
+
next_warning_check_at = time.monotonic() + _LIVE_VIEW_WARNING_POLL_INTERVAL_S
|
|
350
353
|
offset = 0
|
|
351
354
|
if tail_lines is not None:
|
|
352
355
|
try:
|
|
@@ -373,7 +376,7 @@ def _drain_logs(
|
|
|
373
376
|
# history from where the server's offset-0 answer left off.
|
|
374
377
|
while True:
|
|
375
378
|
if follow:
|
|
376
|
-
client = _fresh_client(client, job_id, follow)
|
|
379
|
+
client = _fresh_client(client, job_id, follow, initial_credentials)
|
|
377
380
|
try:
|
|
378
381
|
chunk, offset, status, _ = _followed(
|
|
379
382
|
lambda: client.get_log_chunk(job_id, offset, scope=scope), job_id, follow
|
|
@@ -410,11 +413,16 @@ def _drain_logs(
|
|
|
410
413
|
# and a completed job's typed outputs summary (e.g. `completed ⚠ 1
|
|
411
414
|
# anomaly (non_finite_reward)`) gets the identical best-effort
|
|
412
415
|
# treatment — on stderr, never touching stdout or the exit code.
|
|
416
|
+
if not warning_reported:
|
|
417
|
+
warning_reported = _emit_live_view_warning(client, job_id, scope=scope)
|
|
413
418
|
if status in _FAILURE_STATUS_STRINGS:
|
|
414
419
|
_emit_terminal_reason(client, job_id, scope=scope)
|
|
415
420
|
elif status == _COMPLETED_STATUS:
|
|
416
421
|
_emit_outputs_summary(client, job_id, scope=scope)
|
|
417
422
|
return 0 if status == _COMPLETED_STATUS else 1
|
|
423
|
+
if not warning_reported and time.monotonic() >= next_warning_check_at:
|
|
424
|
+
warning_reported = _emit_live_view_warning(client, job_id, scope=scope)
|
|
425
|
+
next_warning_check_at = time.monotonic() + _LIVE_VIEW_WARNING_POLL_INTERVAL_S
|
|
418
426
|
time.sleep(_FOLLOW_POLL_INTERVAL_S)
|
|
419
427
|
|
|
420
428
|
|
|
@@ -500,12 +508,15 @@ def _report_log_archive_unavailable(exc: JobsApiHTTPError, job_id: str) -> int:
|
|
|
500
508
|
|
|
501
509
|
|
|
502
510
|
def _failure_reason_code(record: dict[str, Any]) -> Optional[str]:
|
|
503
|
-
"""The
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
return None
|
|
511
|
+
"""The displayed reason for a failed/cancelled job, or the known queued
|
|
512
|
+
organization-running-limit wait. Other queued reasons remain hidden until
|
|
513
|
+
they have a deliberate CLI rendering."""
|
|
514
|
+
status = str(record.get("status", ""))
|
|
508
515
|
reason = record.get("status_reason")
|
|
516
|
+
if status == _QUEUED_STATUS and reason == _ORG_RUNNING_LIMIT_REASON:
|
|
517
|
+
return _ORG_RUNNING_LIMIT_MESSAGE
|
|
518
|
+
if status not in _FAILURE_STATUS_STRINGS:
|
|
519
|
+
return None
|
|
509
520
|
if isinstance(reason, str) and reason.strip():
|
|
510
521
|
return _safe_cli_error_text(reason.strip())
|
|
511
522
|
return None
|
|
@@ -540,6 +551,35 @@ def _terminal_reason_line(record: dict[str, Any]) -> Optional[str]:
|
|
|
540
551
|
return line
|
|
541
552
|
|
|
542
553
|
|
|
554
|
+
def _live_view_warning_message(record: dict[str, Any]) -> Optional[str]:
|
|
555
|
+
"""The known non-terminal live-view warning, or ``None``.
|
|
556
|
+
|
|
557
|
+
``warning_code`` and ``warning_message`` are separate from terminal
|
|
558
|
+
failure fields. Training can continue while live view is unavailable.
|
|
559
|
+
"""
|
|
560
|
+
if str(record.get("status", "")) not in {_RUNNING_STATUS, _COMPLETED_STATUS}:
|
|
561
|
+
return None
|
|
562
|
+
if record.get("warning_code") != LIVE_VIEW_UNAVAILABLE_WARNING_CODE:
|
|
563
|
+
return None
|
|
564
|
+
message = record.get("warning_message")
|
|
565
|
+
if not isinstance(message, str) or not message.strip():
|
|
566
|
+
return None
|
|
567
|
+
return _safe_cli_error_text(message.strip())
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _emit_live_view_warning(client: JobsApiClient, job_id: str, *, scope: str = JOB_SCOPE_MINE) -> bool:
|
|
571
|
+
"""Print the live-view warning to stderr and return whether it was present."""
|
|
572
|
+
try:
|
|
573
|
+
record = client.get_job(job_id, scope=scope)
|
|
574
|
+
except JobsApiError:
|
|
575
|
+
return False
|
|
576
|
+
message = _live_view_warning_message(record)
|
|
577
|
+
if message is not None:
|
|
578
|
+
print(f"warning: {message}", file=sys.stderr, flush=True)
|
|
579
|
+
return True
|
|
580
|
+
return False
|
|
581
|
+
|
|
582
|
+
|
|
543
583
|
def _emit_terminal_reason(client: JobsApiClient, job_id: str, *, scope: str = JOB_SCOPE_MINE) -> None:
|
|
544
584
|
"""Print a failed/cancelled job's terminal reason (:func:`_terminal_reason_line`)
|
|
545
585
|
to stderr, or do nothing. Best-effort: a re-fetch that 404s (or any HTTP
|
|
@@ -6,6 +6,7 @@ import sys
|
|
|
6
6
|
import time
|
|
7
7
|
from typing import Callable, Optional, TypeVar
|
|
8
8
|
|
|
9
|
+
from simulo._client import credentials
|
|
9
10
|
from simulo._client.jobs_api import JobsApiHTTPError, JobsApiUnavailable
|
|
10
11
|
from simulo.interfaces.platform.runs import LOG_ARCHIVE_READ_FAILED_CODE
|
|
11
12
|
|
|
@@ -13,7 +14,8 @@ _T = TypeVar("_T")
|
|
|
13
14
|
|
|
14
15
|
#: How long a printed "waiting..." line stays valid before ``_WaitAnnouncer``
|
|
15
16
|
#: re-prints it — so a multi-minute wait doesn't read as frozen on a real
|
|
16
|
-
#: tty
|
|
17
|
+
#: tty. Non-TTY output announces each wait only once.
|
|
18
|
+
#: (critic MINOR; no prior periodic-reannounce precedent in this file,
|
|
17
19
|
#: so this is a new, small, self-contained helper rather than a bigger
|
|
18
20
|
#: refactor of the existing single-print wait loops).
|
|
19
21
|
_SPINNER_REANNOUNCE_INTERVAL_S = 15.0
|
|
@@ -23,8 +25,8 @@ class _WaitAnnouncer:
|
|
|
23
25
|
"""Prints *message* immediately, then re-prints it every
|
|
24
26
|
``_SPINNER_REANNOUNCE_INTERVAL_S`` while ``poll()`` keeps being called —
|
|
25
27
|
so a multi-minute queued-wait or gap-retry doesn't look frozen on a real
|
|
26
|
-
terminal. One small stateful helper shared
|
|
27
|
-
rather than duplicating the timer bookkeeping twice.
|
|
28
|
+
terminal. Non-TTY output prints it once. One small stateful helper shared
|
|
29
|
+
by both wait loops below rather than duplicating the timer bookkeeping twice.
|
|
28
30
|
"""
|
|
29
31
|
|
|
30
32
|
def __init__(self, message: str) -> None:
|
|
@@ -33,7 +35,9 @@ class _WaitAnnouncer:
|
|
|
33
35
|
|
|
34
36
|
def poll(self) -> None:
|
|
35
37
|
now = time.monotonic()
|
|
36
|
-
if self._last_announced_at is None or
|
|
38
|
+
if self._last_announced_at is None or (
|
|
39
|
+
sys.stderr.isatty() and now - self._last_announced_at >= _SPINNER_REANNOUNCE_INTERVAL_S
|
|
40
|
+
):
|
|
37
41
|
print(self._message, file=sys.stderr, flush=True)
|
|
38
42
|
self._last_announced_at = now
|
|
39
43
|
|
|
@@ -123,8 +127,10 @@ def _retry_transient(call: Callable[[], _T], *, give_up: Callable[[str], str]) -
|
|
|
123
127
|
"""Run ``call()``, retrying a transient failure a bounded number of times.
|
|
124
128
|
|
|
125
129
|
Retried: a load-balancer 502/503/504 and ``JobsApiUnavailable`` (connection
|
|
126
|
-
reset or refused, or the client's request timeout)
|
|
127
|
-
|
|
130
|
+
reset or refused, or the client's request timeout), including those wrapped
|
|
131
|
+
in ``TokenRefreshError``. A refresh rejected with 401/403 is never retried.
|
|
132
|
+
A 429 and every other error go straight to the caller, which owns their
|
|
133
|
+
policy, and a ``503``
|
|
128
134
|
carrying the log-read-failure code is left alone because the caller already
|
|
129
135
|
explains it in its own words. After the last retry the failure becomes a
|
|
130
136
|
``GaveUpHTTPError`` (code ``server_unavailable``) or ``GaveUpUnavailable``
|
|
@@ -138,26 +144,32 @@ def _retry_transient(call: Callable[[], _T], *, give_up: Callable[[str], str]) -
|
|
|
138
144
|
while True:
|
|
139
145
|
try:
|
|
140
146
|
return call()
|
|
141
|
-
except JobsApiHTTPError as exc:
|
|
142
|
-
|
|
147
|
+
except (JobsApiHTTPError, JobsApiUnavailable, credentials.TokenRefreshError) as exc:
|
|
148
|
+
# Refresh wraps HTTP failures in a credential error. Classify its
|
|
149
|
+
# cause so a server outage is retried without retrying a rejection.
|
|
150
|
+
failure = exc.__cause__ if isinstance(exc, credentials.TokenRefreshError) else exc
|
|
151
|
+
if isinstance(failure, JobsApiHTTPError):
|
|
152
|
+
if failure.status not in _TRANSIENT_5XX_STATUSES or failure.code == LOG_ARCHIVE_READ_FAILED_CODE:
|
|
153
|
+
raise
|
|
154
|
+
delay = next(delays, None)
|
|
155
|
+
if delay is None:
|
|
156
|
+
raise GaveUpHTTPError(
|
|
157
|
+
failure.status,
|
|
158
|
+
"server_unavailable",
|
|
159
|
+
give_up(f"the server kept returning HTTP {failure.status}"),
|
|
160
|
+
) from exc
|
|
161
|
+
what = f"the server returned HTTP {failure.status}"
|
|
162
|
+
elif isinstance(failure, JobsApiUnavailable):
|
|
163
|
+
delay = next(delays, None)
|
|
164
|
+
if delay is None:
|
|
165
|
+
raise GaveUpUnavailable(
|
|
166
|
+
f"{give_up('the connection to the server kept failing')}; {failure}"
|
|
167
|
+
) from exc
|
|
168
|
+
what = "the connection to the server failed"
|
|
169
|
+
if first_note:
|
|
170
|
+
what += f": {' '.join(str(failure).split()).rstrip('.')}"
|
|
171
|
+
else:
|
|
143
172
|
raise
|
|
144
|
-
delay = next(delays, None)
|
|
145
|
-
if delay is None:
|
|
146
|
-
raise GaveUpHTTPError(
|
|
147
|
-
exc.status,
|
|
148
|
-
"server_unavailable",
|
|
149
|
-
give_up(f"the server kept returning HTTP {exc.status}"),
|
|
150
|
-
) from exc
|
|
151
|
-
what = f"the server returned HTTP {exc.status}"
|
|
152
|
-
except JobsApiUnavailable as exc:
|
|
153
|
-
delay = next(delays, None)
|
|
154
|
-
if delay is None:
|
|
155
|
-
raise GaveUpUnavailable(f"{give_up('the connection to the server kept failing')}; {exc}") from exc
|
|
156
|
-
what = "the connection to the server failed"
|
|
157
|
-
if first_note:
|
|
158
|
-
# Name the cause once, so a mistyped SIMULO_API_URL can be
|
|
159
|
-
# spotted on the first line instead of after the last retry.
|
|
160
|
-
what += f": {' '.join(str(exc).split()).rstrip('.')}"
|
|
161
173
|
print(f"note: {what}; retrying in {delay:g}s...", file=sys.stderr, flush=True)
|
|
162
174
|
first_note = False
|
|
163
175
|
time.sleep(delay)
|
|
@@ -60,6 +60,7 @@ from simulo.interfaces.platform.asset_catalog import (
|
|
|
60
60
|
ASSET_ARCHIVE_TOO_LARGE_CODE,
|
|
61
61
|
ASSET_KINDS,
|
|
62
62
|
ASSET_NOT_EXPORTABLE_CODE,
|
|
63
|
+
ASSET_NOT_FOUND_CODE,
|
|
63
64
|
ASSET_SLUG_PATTERN,
|
|
64
65
|
ASSET_STORAGE_QUOTA_EXCEEDED_CODE,
|
|
65
66
|
ASSET_TOO_MANY_CONCURRENT_UPLOADS_CODE,
|
|
@@ -165,6 +166,7 @@ def _friendly_asset_error(exc: AssetApiHTTPError) -> str:
|
|
|
165
166
|
)
|
|
166
167
|
if exc.code in (
|
|
167
168
|
ASSET_ARCHIVE_TOO_LARGE_CODE,
|
|
169
|
+
ASSET_NOT_FOUND_CODE,
|
|
168
170
|
ASSET_TOO_MANY_FILES_CODE,
|
|
169
171
|
ASSET_TOO_MANY_CONCURRENT_UPLOADS_CODE,
|
|
170
172
|
ASSET_VERSION_NOT_UPLOADING_CODE,
|
|
@@ -513,7 +515,7 @@ def _echo_facts_block(echo: Callable[[str], None], facts: dict[str, Any]) -> Non
|
|
|
513
515
|
# step_cost_indicator" — PR-11's facts.py emits EXACTLY these names).
|
|
514
516
|
collision = facts.get("collision_shape_count")
|
|
515
517
|
if collision is not None:
|
|
516
|
-
extras.append(f"{collision} collision meshes")
|
|
518
|
+
extras.append(f"{collision} collision {'mesh' if collision == 1 else 'meshes'}")
|
|
517
519
|
units = facts.get("units")
|
|
518
520
|
if units:
|
|
519
521
|
extras.append(f"units {units}")
|
|
@@ -1730,9 +1732,10 @@ def _resolve_target_version(
|
|
|
1730
1732
|
base_url=base_url,
|
|
1731
1733
|
token=token,
|
|
1732
1734
|
)
|
|
1735
|
+
ref = _asset_display_ref(publisher=parsed.publisher, kind=kind, name=name, version=None)
|
|
1733
1736
|
return None, (
|
|
1734
|
-
f"{
|
|
1735
|
-
f"See its versions with `simulo asset inspect {
|
|
1737
|
+
f"{ref} has no version v{parsed.version} in the catalog. "
|
|
1738
|
+
f"See its versions with `simulo asset inspect {ref}`."
|
|
1736
1739
|
)
|
|
1737
1740
|
if not _has_known_asset_version_status(matches[0]):
|
|
1738
1741
|
return None, _ASSET_UNRECOGNIZED_VERSION_STATUS_MESSAGE
|