simulo 0.14.0__tar.gz → 0.16.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.
Files changed (58) hide show
  1. {simulo-0.14.0/src/simulo.egg-info → simulo-0.16.0}/PKG-INFO +3 -3
  2. {simulo-0.14.0 → simulo-0.16.0}/PYPI.md +1 -1
  3. {simulo-0.14.0 → simulo-0.16.0}/pyproject.toml +27 -2
  4. simulo-0.16.0/src/simulo/__init__.py +188 -0
  5. simulo-0.16.0/src/simulo/_client/_entrypoint.py +313 -0
  6. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/_runner.py +3 -3
  7. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/app.py +494 -68
  8. simulo-0.16.0/src/simulo/_client/artifacts.py +132 -0
  9. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/asset.py +11 -9
  10. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/asset_api.py +2 -2
  11. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/asset_package.py +26 -25
  12. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/asset_pins.py +7 -7
  13. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/builtin_aliases.py +3 -3
  14. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/bundle.py +19 -7
  15. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/cancel_api.py +4 -5
  16. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/cli.py +1391 -107
  17. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/config.py +2 -2
  18. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/credentials.py +1 -1
  19. simulo-0.16.0/src/simulo/_client/facades.py +408 -0
  20. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/learning.py +94 -21
  21. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/login.py +1 -2
  22. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/packaging.py +6 -5
  23. simulo-0.16.0/src/simulo/_client/preflight_api.py +186 -0
  24. simulo-0.16.0/src/simulo/_client/preflight_render.py +191 -0
  25. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/registry.py +14 -1
  26. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/runtime_display.py +1 -1
  27. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/seed_ref.py +1 -1
  28. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/submit_api.py +256 -21
  29. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/templates/__init__.py +1 -1
  30. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/templates/inference/app.py.tmpl +30 -82
  31. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/templates/scenario/app.py.tmpl +11 -36
  32. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/templates/training/app.py.tmpl +32 -46
  33. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/view_session_api.py +4 -5
  34. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/callbacks.py +37 -24
  35. {simulo-0.14.0 → simulo-0.16.0/src/simulo.egg-info}/PKG-INFO +3 -3
  36. {simulo-0.14.0 → simulo-0.16.0}/src/simulo.egg-info/SOURCES.txt +4 -1
  37. {simulo-0.14.0 → simulo-0.16.0}/src/simulo.egg-info/requires.txt +1 -1
  38. simulo-0.14.0/src/simulo/__init__.py +0 -154
  39. simulo-0.14.0/src/simulo/_client/_entrypoint.py +0 -104
  40. simulo-0.14.0/src/simulo/_client/manifest.py +0 -57
  41. {simulo-0.14.0 → simulo-0.16.0}/MANIFEST.in +0 -0
  42. {simulo-0.14.0 → simulo-0.16.0}/setup.cfg +0 -0
  43. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/__init__.py +0 -0
  44. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/_mounts.py +0 -0
  45. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/discovery.py +0 -0
  46. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/http.py +0 -0
  47. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/jobs_api.py +0 -0
  48. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/mode.py +0 -0
  49. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/runtime.py +0 -0
  50. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/stub.py +0 -0
  51. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/templates/inference/simuloignore.tmpl +0 -0
  52. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/templates/scenario/simuloignore.tmpl +0 -0
  53. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/templates/training/simuloignore.tmpl +0 -0
  54. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/_client/volume.py +0 -0
  55. {simulo-0.14.0 → simulo-0.16.0}/src/simulo/py.typed +0 -0
  56. {simulo-0.14.0 → simulo-0.16.0}/src/simulo.egg-info/dependency_links.txt +0 -0
  57. {simulo-0.14.0 → simulo-0.16.0}/src/simulo.egg-info/entry_points.txt +0 -0
  58. {simulo-0.14.0 → simulo-0.16.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.14.0
3
+ Version: 0.16.0
4
4
  Summary: Simulo SDK and CLI — define robotics simulation and training apps in Python and run them on the Simulo cloud.
5
5
  Author-email: Simulo Team <team@simulo.ai>
6
6
  License: BSD-3-Clause
@@ -15,7 +15,7 @@ Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Typing :: Typed
16
16
  Requires-Python: >=3.11
17
17
  Description-Content-Type: text/markdown
18
- Requires-Dist: simulo-interfaces<0.7,>=0.6
18
+ Requires-Dist: simulo-interfaces<0.11,>=0.10
19
19
  Requires-Dist: mcap<2,>=1.3
20
20
  Requires-Dist: defusedxml>=0.7.1
21
21
  Requires-Dist: usd-core<27,>=25.5
@@ -83,7 +83,7 @@ def hello(name: str = "world", repeat: int = 3) -> dict:
83
83
  return {"greeting": f"hello, {name}", "repeat": repeat}
84
84
 
85
85
 
86
- @app.local_entrypoint
86
+ @app.entrypoint
87
87
  def main(name: str = "world", repeat: int = 3) -> None:
88
88
  handle = hello.spawn(name=name, repeat=repeat)
89
89
  print(f"Submitted job: {handle.job_name}")
@@ -47,7 +47,7 @@ def hello(name: str = "world", repeat: int = 3) -> dict:
47
47
  return {"greeting": f"hello, {name}", "repeat": repeat}
48
48
 
49
49
 
50
- @app.local_entrypoint
50
+ @app.entrypoint
51
51
  def main(name: str = "world", repeat: int = 3) -> None:
52
52
  handle = hello.spawn(name=name, repeat=repeat)
53
53
  print(f"Submitted job: {handle.job_name}")
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "simulo"
7
- version = "0.14.0"
7
+ version = "0.16.0"
8
8
  description = "Simulo SDK and CLI — define robotics simulation and training apps in Python and run them on the Simulo cloud."
9
9
  readme = "PYPI.md"
10
10
  requires-python = ">=3.11"
@@ -26,7 +26,7 @@ classifiers = [
26
26
  # mcap: used by `simulo recordings` to read back a downloaded recording and
27
27
  # verify it (message count) — MIT-licensed, deps only lz4+zstandard.
28
28
  dependencies = [
29
- "simulo-interfaces>=0.6,<0.7",
29
+ "simulo-interfaces>=0.10,<0.11",
30
30
  "mcap>=1.3,<2",
31
31
  # Hardened XML parsing for URDF ingestion (USD Asset Catalogs, PR-12 fix
32
32
  # loop 2, security NIT): stdlib xml.etree.ElementTree relies on
@@ -100,18 +100,43 @@ markers = [
100
100
  "requires_execution_backend: end-to-end test that needs a live execution backend in the loop; skipped in CPU/unit runs",
101
101
  ]
102
102
 
103
+ # --- Formatters / linters --------------------------------------------------
104
+ # `tests/fixtures/wire_stability/` holds byte-frozen snapshots of real shipped
105
+ # apps. `test_manifest_wire_stability.py` pins their archive digests, so those
106
+ # bytes are INPUT to a gate, not source to be maintained: a formatter version
107
+ # bump that reformats one silently moves every golden and reds the gate with a
108
+ # message blaming the client. Every tool that rewrites files in `tests/` must
109
+ # therefore skip that directory.
110
+ #
111
+ # Each tool needs a SECOND setting to honour the skip when a path is passed
112
+ # EXPLICITLY (`black tests/fixtures/wire_stability/cartpole/app.py`) rather than
113
+ # discovered from a directory — the explicit form is what a changed-files lint
114
+ # invocation or an editor-on-save uses, so plain excludes alone are a false
115
+ # sense of safety (measured: all three tools reformatted the file anyway):
116
+ # black -> `force-exclude` (not `extend-exclude`)
117
+ # isort -> `filter_files = true` (and a `*/`-anchored glob)
118
+ # ruff -> `force-exclude = true`
119
+ # The source digests are additionally pinned in `_FIXTURE_SOURCE_SHA256`
120
+ # (`tests/test_manifest_wire_stability.py`) as the backstop for any mutation
121
+ # these excludes do not cover.
122
+
103
123
  [tool.isort]
104
124
  profile = "black"
105
125
  line_length = 120
106
126
  known_first_party = ["simulo"]
127
+ extend_skip_glob = ["*/tests/fixtures/wire_stability/*"]
128
+ filter_files = true
107
129
 
108
130
  [tool.ruff]
109
131
  line-length = 120
110
132
  src = ["src"]
133
+ extend-exclude = ["tests/fixtures/wire_stability"]
134
+ force-exclude = true
111
135
 
112
136
  [tool.black]
113
137
  line-length = 120
114
138
  target-version = ["py311"]
139
+ force-exclude = "tests/fixtures/wire_stability/"
115
140
 
116
141
  # --- Commitizen ------------------------------------------------------------
117
142
  # `cz bump` creates the version-bump commit + tag locally; CI publishes to
@@ -0,0 +1,188 @@
1
+ """Simulo thin client — the user-facing, torch-free SDK surface.
2
+
3
+ This is the ONE Simulo distribution that ships ``simulo/__init__.py``. The
4
+ ``extend_path`` call below unions every ``simulo/`` directory on ``sys.path`` into
5
+ this package's ``__path__`` so the PEP 420 namespace portions shipped by sibling
6
+ distributions (``simulo.core`` / ``simulo.backend`` / ``simulo.recording`` from
7
+ ``simulo-backend``; ``simulo.interfaces`` from ``simulo-interfaces``) resolve
8
+ alongside the clean top-level ``simulo.App`` re-exported here.
9
+
10
+ It MUST run before the re-exports below, because importing the thin client's
11
+ internal ``App`` module pulls in ``simulo.interfaces`` (a sibling distribution).
12
+
13
+ Two kinds of name live on ``simulo``:
14
+
15
+ * **Platform** names — ``App / Runtime / Volume / Asset / Artifact /
16
+ ArtifactKind / callbacks`` — are **real, eager** attributes bound below.
17
+ They are always torch-free (``Artifact`` is the ``simulo-interfaces``
18
+ contract dataclass, ``ArtifactKind`` its wire-stable ``StrEnum``, both
19
+ re-exported).
20
+ * **Lazy, mode-aware** names — the learning names ``Task / Scene / Robot /
21
+ LearningEnv / RLTrainer / …`` plus the execution-time ``save_artifact`` — are
22
+ resolved through the PEP 562 ``__getattr__`` below, which delegates to the
23
+ thin client's internal learning-name resolver. At submit (discovery mode) they resolve to
24
+ lean stand-ins so the user app imports without the heavy runtime; only the
25
+ backend runner (execution mode) resolves them to the real implementations.
26
+ Because the platform names are real attributes, ``__getattr__`` fires *only*
27
+ for the lazy names. The RELOCATED authoring value types (``Pose`` /
28
+ ``Material`` / ``Physics`` / ``Light`` / ``Terrain`` / ``World`` /
29
+ ``Entity`` / ``Visual`` / ``RecordConfig``) ride the same resolver but
30
+ resolve to their real, dependency-free ``simulo.interfaces.authoring``
31
+ class in BOTH modes — they need no heavy runtime, and exactly one
32
+ definition exists. (``Terrain`` is ground you generate; ``World`` is a
33
+ prebuilt place you bring, ``World.usd(...)``.)
34
+ """
35
+
36
+ import pkgutil as _pkgutil
37
+ from typing import TYPE_CHECKING
38
+
39
+ __path__ = _pkgutil.extend_path(__path__, __name__)
40
+
41
+ from simulo import callbacks
42
+ from simulo._client.app import App
43
+ from simulo._client.asset import Asset
44
+ from simulo._client.learning import LEARNING_NAMES as _LEARNING_NAMES
45
+ from simulo._client.learning import resolve as _resolve_learning
46
+ from simulo._client.runtime import Runtime
47
+ from simulo._client.volume import Volume
48
+ from simulo.interfaces.platform import Artifact, ArtifactKind
49
+
50
+ # Public surface = eager platform exports (real attributes; ``__getattr__`` never
51
+ # fires for these) + the lazily-resolved learning names. Kept a static literal so
52
+ # tooling recognises the re-exports; a test guards it against ``LEARNING_NAMES`` drift.
53
+ __all__ = [
54
+ # platform (eager, real)
55
+ "App",
56
+ "Artifact",
57
+ "ArtifactKind",
58
+ "Runtime",
59
+ "Volume",
60
+ "Asset",
61
+ "callbacks",
62
+ # lazy, mode-aware (the learning names + save_artifact)
63
+ "Entity",
64
+ "LearningEnv",
65
+ "Light",
66
+ "Material",
67
+ "ObservationComponent",
68
+ "Physics",
69
+ "Player",
70
+ "Policy",
71
+ "Pose",
72
+ "RLPlayer",
73
+ "RLTrainer",
74
+ "RecordConfig",
75
+ "RewardComponent",
76
+ "Robot",
77
+ "Scenario",
78
+ "Scene",
79
+ "Task",
80
+ "Terrain",
81
+ "TerminationCondition",
82
+ "Trainer",
83
+ "Visual",
84
+ "World",
85
+ "save_artifact",
86
+ ]
87
+
88
+
89
+ def __getattr__(name: str) -> object:
90
+ """PEP 562 hook: lazily resolve the mode-aware learning names.
91
+
92
+ Fires only for attributes missing from the module dict — i.e. never for the
93
+ real platform exports above. Delegates the learning names to the thin
94
+ client's internal resolver (discovery stand-in vs. real
95
+ ``simulo.core`` class, by mode); everything else is a genuine ``AttributeError``.
96
+ """
97
+ if name in _LEARNING_NAMES:
98
+ return _resolve_learning(name)
99
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
100
+
101
+
102
+ def __dir__() -> list[str]:
103
+ """Expose only the public surface to ``dir(simulo)``.
104
+
105
+ That is ``__all__`` (eager platform exports + learning names) unioned with
106
+ :data:`LEARNING_NAMES`. Private module globals (``_pkgutil``,
107
+ ``_resolve_learning``, ``_LEARNING_NAMES``, the ``TYPE_CHECKING`` import, …)
108
+ are deliberately filtered out so tab-completion shows the user-facing API only.
109
+ """
110
+ return sorted(set(__all__) | set(_LEARNING_NAMES))
111
+
112
+
113
+ if TYPE_CHECKING:
114
+ # Static aliases so a type checker sees the learning names with precise types
115
+ # (PEP 562 ``__getattr__`` would otherwise type them as ``Any``). The rule that
116
+ # this block MUST follow — and that the mypy positive test in
117
+ # ``tests/test_learning_typing.py`` locks — is:
118
+ #
119
+ # * **Subclassable** names (used as ``class X(simulo.Task)``) alias their
120
+ # ``interfaces.runtime`` Protocol, so the subclass is checked against the
121
+ # contract (e.g. ``TaskProtocol``).
122
+ # * **Instantiated mirror** names (used as ``simulo.LearningEnv(task=...)``,
123
+ # ``simulo.RLTrainer(env=...)``, ``simulo.Robot(...)``,
124
+ # ``simulo.RLPlayer(env=...)``) alias their typing FACADE in the thin
125
+ # client's internal facades module — a Protocol alias would make mypy
126
+ # reject instantiation ("Cannot instantiate protocol class"), and
127
+ # ``Any`` (the pre-Wave-C4 rule) type-checked nothing. The facade
128
+ # declares the contract surface plus the real constructor, so both the
129
+ # call and every member access are checked against the real member set
130
+ # and signature shape (name, kind, arity, presence of a default —
131
+ # NOT the declared type; see the facades module docstring for the
132
+ # known gap); facade fidelity to the contract AND to the concrete
133
+ # worker class is pinned by the backend suite's conformance engine.
134
+ # * **Annotation-position mirror** names alias their contract Protocol:
135
+ # ``Scene`` is ``SceneProtocol`` — measured across every shipped user
136
+ # surface it appears only as ``def build(self, scene: simulo.Scene)``,
137
+ # never instantiated, and the Protocol alias is what keeps that override
138
+ # Liskov-compatible with the contract's ``scene: SceneProtocol``.
139
+ # * **Relocated value types** alias their real, dependency-free
140
+ # ``simulo.interfaces.authoring`` class — precise field checking in both
141
+ # modes, because only one definition exists.
142
+ from simulo._client.facades import LearningEnv as LearningEnv
143
+ from simulo._client.facades import RLPlayer as RLPlayer
144
+ from simulo._client.facades import RLTrainer as RLTrainer
145
+ from simulo._client.facades import Robot as Robot
146
+ from simulo.interfaces.authoring import SceneProtocol
147
+ from simulo.interfaces.runtime import (
148
+ ObservationComponentProtocol,
149
+ PlayerProtocol,
150
+ PolicyProtocol,
151
+ RewardComponentProtocol,
152
+ ScenarioProtocol,
153
+ TaskProtocol,
154
+ TerminationConditionProtocol,
155
+ TrainerProtocol,
156
+ )
157
+
158
+ # Subclassable names alias their Protocol so ``class X(simulo.Task)`` is checked
159
+ # against ``TaskProtocol``.
160
+ Task = TaskProtocol
161
+ Scenario = ScenarioProtocol
162
+ Policy = PolicyProtocol
163
+ Player = PlayerProtocol
164
+ Trainer = TrainerProtocol
165
+ ObservationComponent = ObservationComponentProtocol
166
+ RewardComponent = RewardComponentProtocol
167
+ TerminationCondition = TerminationConditionProtocol
168
+ # Annotation-position mirror name (see the rule above).
169
+ Scene = SceneProtocol
170
+ # ``save_artifact`` aliases its real execution-mode implementation (a
171
+ # torch-free thin-client function), so ``simulo.save_artifact("summary",
172
+ # path)`` type-checks with the precise signature instead of ``Any``.
173
+ from simulo._client.artifacts import save_artifact as save_artifact
174
+
175
+ # RELOCATED authoring value types (Plan C — Waves C1, C2, then C4's
176
+ # ``RecordConfig``): the runtime object IS the real, dependency-free
177
+ # ``simulo.interfaces.authoring`` class in both
178
+ # modes, so these alias the real class — precise field/signature checking
179
+ # (e.g. mypy rejects ``simulo.Pose(xyz="nope")``) instead of ``Any``.
180
+ from simulo.interfaces.authoring import Entity as Entity
181
+ from simulo.interfaces.authoring import Light as Light
182
+ from simulo.interfaces.authoring import Material as Material
183
+ from simulo.interfaces.authoring import Physics as Physics
184
+ from simulo.interfaces.authoring import Pose as Pose
185
+ from simulo.interfaces.authoring import RecordConfig as RecordConfig
186
+ from simulo.interfaces.authoring import Terrain as Terrain
187
+ from simulo.interfaces.authoring import Visual as Visual
188
+ from simulo.interfaces.authoring import World as World
@@ -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))
@@ -10,9 +10,9 @@ Two submit modes now share this one handle type:
10
10
  * **Cloud submit** (credentials present, or ``SIMULO_API_URL``/``SIMULO_ENV``
11
11
  set): ``spawn`` has already uploaded the package and created a job on the
12
12
  control plane, so the handle carries the SERVER-assigned ``job_id`` plus the
13
- base URL/token to observe it. ``.get()`` polls
14
- :class:`~simulo._client.jobs_api.JobsApiClient` to a terminal status and
15
- returns the result on success, or raises :class:`JobFailedError` (with a
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
16
  ``simulo logs <id>`` hint) on failure/cancellation.
17
17
 
18
18
  The thin client keeps **no** import of and no ``find_spec`` dependency on