simulo 0.24.0__tar.gz → 0.25.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 (61) hide show
  1. {simulo-0.24.0/src/simulo.egg-info → simulo-0.25.0}/PKG-INFO +5 -1
  2. {simulo-0.24.0 → simulo-0.25.0}/PYPI.md +4 -0
  3. {simulo-0.24.0 → simulo-0.25.0}/pyproject.toml +1 -1
  4. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/cli.py +71 -3
  5. simulo-0.25.0/src/simulo/_client/install_samples.py +267 -0
  6. {simulo-0.24.0 → simulo-0.25.0/src/simulo.egg-info}/PKG-INFO +5 -1
  7. {simulo-0.24.0 → simulo-0.25.0}/src/simulo.egg-info/SOURCES.txt +1 -0
  8. {simulo-0.24.0 → simulo-0.25.0}/MANIFEST.in +0 -0
  9. {simulo-0.24.0 → simulo-0.25.0}/setup.cfg +0 -0
  10. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/__init__.py +0 -0
  11. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/__init__.py +0 -0
  12. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/_entrypoint.py +0 -0
  13. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/_mounts.py +0 -0
  14. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/_runner.py +0 -0
  15. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/_secure_downloads.py +0 -0
  16. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/app.py +0 -0
  17. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/asset.py +0 -0
  18. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/asset_api.py +0 -0
  19. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/asset_package.py +0 -0
  20. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/asset_pins.py +0 -0
  21. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/builtin_aliases.py +0 -0
  22. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/bundle.py +0 -0
  23. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/cancel_api.py +0 -0
  24. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/config.py +0 -0
  25. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/credentials.py +0 -0
  26. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/discovery.py +0 -0
  27. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/export_api.py +0 -0
  28. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/export_bundle.py +0 -0
  29. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/facades.py +0 -0
  30. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/http.py +0 -0
  31. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/identity_api.py +0 -0
  32. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/jobs_api.py +0 -0
  33. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/learning.py +0 -0
  34. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/login.py +0 -0
  35. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/mode.py +0 -0
  36. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/outputs.py +0 -0
  37. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/packaging.py +0 -0
  38. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/preflight_api.py +0 -0
  39. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/preflight_render.py +0 -0
  40. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/registry.py +0 -0
  41. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/runtime.py +0 -0
  42. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/runtime_display.py +0 -0
  43. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/seed_ref.py +0 -0
  44. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/stub.py +0 -0
  45. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/submit_api.py +0 -0
  46. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/templates/__init__.py +0 -0
  47. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/templates/inference/app.py.tmpl +0 -0
  48. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/templates/inference/simuloignore.tmpl +0 -0
  49. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/templates/scenario/app.py.tmpl +0 -0
  50. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/templates/scenario/simuloignore.tmpl +0 -0
  51. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/templates/training/app.py.tmpl +0 -0
  52. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/templates/training/simuloignore.tmpl +0 -0
  53. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/view_fragment.py +0 -0
  54. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/view_session_api.py +0 -0
  55. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/volume.py +0 -0
  56. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/callbacks.py +0 -0
  57. {simulo-0.24.0 → simulo-0.25.0}/src/simulo/py.typed +0 -0
  58. {simulo-0.24.0 → simulo-0.25.0}/src/simulo.egg-info/dependency_links.txt +0 -0
  59. {simulo-0.24.0 → simulo-0.25.0}/src/simulo.egg-info/entry_points.txt +0 -0
  60. {simulo-0.24.0 → simulo-0.25.0}/src/simulo.egg-info/requires.txt +0 -0
  61. {simulo-0.24.0 → simulo-0.25.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.24.0
3
+ Version: 0.25.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
@@ -71,6 +71,10 @@ required — `simulo create` only writes files to your local folder. Edit
71
71
  `app.py` for your own robot and task, then see "Log in and submit" below to
72
72
  run it.
73
73
 
74
+ To download a curated collection of runnable projects, use `simulo install samples`.
75
+ It creates `./simulo-samples` as a normal Git clone, so `cd simulo-samples` and
76
+ `cat README.md` open the sample index.
77
+
74
78
  ### Or write an app by hand
75
79
 
76
80
  ```python
@@ -31,6 +31,10 @@ required — `simulo create` only writes files to your local folder. Edit
31
31
  `app.py` for your own robot and task, then see "Log in and submit" below to
32
32
  run it.
33
33
 
34
+ To download a curated collection of runnable projects, use `simulo install samples`.
35
+ It creates `./simulo-samples` as a normal Git clone, so `cd simulo-samples` and
36
+ `cat README.md` open the sample index.
37
+
34
38
  ### Or write an app by hand
35
39
 
36
40
  ```python
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "simulo"
7
- version = "0.24.0"
7
+ version = "0.25.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"
@@ -53,6 +53,10 @@ the packaged ``src/`` layout and never reaches a real ``pip install simulo``
53
53
  user. No network call, no auth — this only touches the local filesystem and the
54
54
  wheel's own bundled resources.
55
55
 
56
+ ``simulo install samples`` clones the public sample collection into
57
+ ``./simulo-samples`` with normal Git history. It uses neither client settings nor
58
+ saved sign-in state; a failed clone is removed before it can appear at that path.
59
+
56
60
  **Auth** (``simulo login`` / ``logout`` / ``whoami``) drives the OAuth 2.0 +
57
61
  PKCE loopback flow and manages
58
62
  ``~/.simulo/credentials``.
@@ -182,6 +186,7 @@ from simulo._client.discovery import discover, import_app_module
182
186
  from simulo._client.export_api import DEFAULT_EXPORT_FORMAT, ExportApiClient, ExportApiError
183
187
  from simulo._client.export_bundle import PolicyBundleError, extract_policy_bundle
184
188
  from simulo._client.identity_api import IdentityApiClient, IdentityApiError
189
+ from simulo._client.install_samples import InstallSamplesError, InstallSamplesUsageError, install_samples
185
190
  from simulo._client.jobs_api import JobsApiClient, JobsApiError, JobsApiHTTPError, JobsApiUnavailable
186
191
  from simulo._client.login import LoginError, perform_login
187
192
  from simulo._client.mode import DISCOVERY
@@ -635,6 +640,19 @@ _MACHINE_UUID_RE = re.compile(
635
640
  )
636
641
  _MAX_CLI_ERROR_CHARS = 2048
637
642
 
643
+ #: Clean, human-worded renderings for account/organization state error codes
644
+ #: that reach the CLI verbatim from the wire (``code: message``) otherwise.
645
+ #: Shared between ``_safe_cli_error_text`` (the general path: ``jobs``,
646
+ #: ``cancel``, ``view``, ``run``, ``whoami``, ...) and ``_friendly_asset_error``
647
+ #: (the asset-catalog surface, which wraps errors in its own ``AssetApiHTTPError``
648
+ #: rather than ``http.HttpHTTPError``, so it cannot share the first function's
649
+ #: isinstance check) so a person sees the identical message regardless of which
650
+ #: command surfaced the error.
651
+ _ACCOUNT_SUSPENDED_MESSAGE = "Your Simulo account has been suspended. Contact Simulo support to restore access."
652
+ _ORGANIZATION_DEACTIVATED_MESSAGE = (
653
+ "This organization has been deactivated. Its owner can reactivate it from organization settings."
654
+ )
655
+
638
656
 
639
657
  def _canonical_uuid_text(value: Any) -> Optional[str]:
640
658
  """Return one exact legacy UUID in canonical form, or ``None``."""
@@ -682,7 +700,13 @@ def _safe_cli_error_text(value: Any) -> str:
682
700
  if (isinstance(value, http.HttpHTTPError) and value.code == "account_suspended") or (
683
701
  isinstance(value, credentials.TokenRefreshError) and value.code == "account_suspended"
684
702
  ):
685
- text = "Your Simulo account has been suspended. Contact Simulo support to restore access."
703
+ text = _ACCOUNT_SUSPENDED_MESSAGE
704
+ elif isinstance(value, http.HttpHTTPError) and value.code == "organization_deactivated":
705
+ # Unlike suspension, deactivation never disables Cognito: a stale
706
+ # credential still refreshes fine, and it's the org-scoped API call
707
+ # that returns this 403. There is deliberately no
708
+ # ``credentials.TokenRefreshError`` counterpart here.
709
+ text = _ORGANIZATION_DEACTIVATED_MESSAGE
686
710
  else:
687
711
  text = str(value)
688
712
  # A legacy UUID-prefix ambiguity is the one named compatibility exception
@@ -5729,9 +5753,21 @@ def _friendly_asset_error(exc: AssetApiHTTPError) -> str:
5729
5753
  The server's message leads (it already speaks user language); the quota
5730
5754
  case appends the catalog's required "two ways out" (delete or
5731
5755
  upgrade). Unrecognized codes render as the standard ``code: message``.
5732
- Every matched code is a shared interfaces constant, so a control-plane rename cannot
5733
- silently degrade this copy by drifting from a hand-typed literal here.
5756
+ Every matched ``ASSET_*`` code is a shared interfaces constant, so a
5757
+ control-plane rename cannot silently degrade this copy by drifting from a
5758
+ hand-typed literal here. ``account_suspended`` and
5759
+ ``organization_deactivated`` are the two exceptions: they are account/org
5760
+ states, not asset-catalog concepts, so they have no shared constant here
5761
+ either (``_safe_cli_error_text`` matches them the same way) — every asset
5762
+ command reaches ``AssetApiHTTPError`` instead of ``http.HttpHTTPError``
5763
+ (see ``asset_api._translate``), so it cannot share that function's
5764
+ isinstance-gated branches and needs its own. Render the identical
5765
+ hand-worded message either function would produce, not the raw code.
5734
5766
  """
5767
+ if exc.code == "account_suspended":
5768
+ return _ACCOUNT_SUSPENDED_MESSAGE
5769
+ if exc.code == "organization_deactivated":
5770
+ return _ORGANIZATION_DEACTIVATED_MESSAGE
5735
5771
  if exc.code == ASSET_STORAGE_QUOTA_EXCEEDED_CODE:
5736
5772
  return (
5737
5773
  f"{exc.message}\n"
@@ -8229,6 +8265,38 @@ def _build_parser() -> argparse.ArgumentParser:
8229
8265
 
8230
8266
  create.set_defaults(func=_handle_create)
8231
8267
 
8268
+ install = sub.add_parser("install", help="Install a curated local collection of Simulo samples.")
8269
+ install_sub = install.add_subparsers(dest="install_command", required=True)
8270
+ samples = install_sub.add_parser(
8271
+ "samples",
8272
+ help="Clone the sample collection into ./simulo-samples with full Git history.",
8273
+ )
8274
+
8275
+ def _handle_install_samples(args: argparse.Namespace, extras: Sequence[str]) -> int:
8276
+ if extras:
8277
+ samples.error(f"unrecognized arguments: {' '.join(extras)}")
8278
+ try:
8279
+ installed = install_samples(Path.cwd())
8280
+ except InstallSamplesUsageError as exc:
8281
+ print(f"error: {exc}", file=sys.stderr)
8282
+ print("Nothing was installed.", file=sys.stderr)
8283
+ return 2
8284
+ except InstallSamplesError as exc:
8285
+ print(f"error: {exc}", file=sys.stderr)
8286
+ print("Nothing was installed.", file=sys.stderr)
8287
+ return 1
8288
+ print(
8289
+ f"Installed the Simulo samples in {installed.path} "
8290
+ "(a normal Git clone: history, branches, and git pull all work)."
8291
+ )
8292
+ print("Next steps:")
8293
+ print(" cd simulo-samples")
8294
+ print(" cat README.md")
8295
+ print("README.md is the sample index; each sample folder has its own app.py to run with simulo run.")
8296
+ return 0
8297
+
8298
+ samples.set_defaults(func=_handle_install_samples)
8299
+
8232
8300
  # -- simulo asset (the FIRST nested subcommand group in this CLI) --------
8233
8301
  asset = sub.add_parser(
8234
8302
  "asset",
@@ -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())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simulo
3
- Version: 0.24.0
3
+ Version: 0.25.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
@@ -71,6 +71,10 @@ required — `simulo create` only writes files to your local folder. Edit
71
71
  `app.py` for your own robot and task, then see "Log in and submit" below to
72
72
  run it.
73
73
 
74
+ To download a curated collection of runnable projects, use `simulo install samples`.
75
+ It creates `./simulo-samples` as a normal Git clone, so `cd simulo-samples` and
76
+ `cat README.md` open the sample index.
77
+
74
78
  ### Or write an app by hand
75
79
 
76
80
  ```python
@@ -32,6 +32,7 @@ src/simulo/_client/export_bundle.py
32
32
  src/simulo/_client/facades.py
33
33
  src/simulo/_client/http.py
34
34
  src/simulo/_client/identity_api.py
35
+ src/simulo/_client/install_samples.py
35
36
  src/simulo/_client/jobs_api.py
36
37
  src/simulo/_client/learning.py
37
38
  src/simulo/_client/login.py
File without changes
File without changes
File without changes
File without changes
File without changes