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.
- {simulo-0.24.0/src/simulo.egg-info → simulo-0.25.0}/PKG-INFO +5 -1
- {simulo-0.24.0 → simulo-0.25.0}/PYPI.md +4 -0
- {simulo-0.24.0 → simulo-0.25.0}/pyproject.toml +1 -1
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/cli.py +71 -3
- simulo-0.25.0/src/simulo/_client/install_samples.py +267 -0
- {simulo-0.24.0 → simulo-0.25.0/src/simulo.egg-info}/PKG-INFO +5 -1
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo.egg-info/SOURCES.txt +1 -0
- {simulo-0.24.0 → simulo-0.25.0}/MANIFEST.in +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/setup.cfg +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/__init__.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/__init__.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/_entrypoint.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/_mounts.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/_runner.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/_secure_downloads.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/app.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/asset.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/asset_api.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/asset_package.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/asset_pins.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/builtin_aliases.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/bundle.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/cancel_api.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/config.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/credentials.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/discovery.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/export_api.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/export_bundle.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/facades.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/http.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/identity_api.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/jobs_api.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/learning.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/login.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/mode.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/outputs.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/packaging.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/preflight_api.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/preflight_render.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/registry.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/runtime.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/runtime_display.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/seed_ref.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/stub.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/submit_api.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/templates/__init__.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/templates/inference/app.py.tmpl +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/templates/inference/simuloignore.tmpl +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/templates/scenario/app.py.tmpl +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/templates/scenario/simuloignore.tmpl +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/templates/training/app.py.tmpl +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/templates/training/simuloignore.tmpl +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/view_fragment.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/view_session_api.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/_client/volume.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/callbacks.py +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo/py.typed +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo.egg-info/dependency_links.txt +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo.egg-info/entry_points.txt +0 -0
- {simulo-0.24.0 → simulo-0.25.0}/src/simulo.egg-info/requires.txt +0 -0
- {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.
|
|
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.
|
|
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 =
|
|
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
|
|
5733
|
-
silently degrade this copy by drifting from a
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|