unitport 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- unitport/__init__.py +26 -0
- unitport/__main__.py +9 -0
- unitport/bundle.py +152 -0
- unitport/capabilities.py +196 -0
- unitport/cli.py +196 -0
- unitport/config.py +275 -0
- unitport/doctor.py +146 -0
- unitport/errors.py +55 -0
- unitport/pairing.py +156 -0
- unitport/protocol.py +151 -0
- unitport/results.py +111 -0
- unitport/runner.py +283 -0
- unitport/security.py +65 -0
- unitport/transport.py +151 -0
- unitport/verbs.py +127 -0
- unitport-0.0.1.dist-info/METADATA +79 -0
- unitport-0.0.1.dist-info/RECORD +20 -0
- unitport-0.0.1.dist-info/WHEEL +5 -0
- unitport-0.0.1.dist-info/entry_points.txt +2 -0
- unitport-0.0.1.dist-info/top_level.txt +1 -0
unitport/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""unitport — UnitPort local helper (headless daemon).
|
|
3
|
+
|
|
4
|
+
Package-level constants shared across modules. Keep this import cheap: NO heavy
|
|
5
|
+
third-party imports here (websockets etc. are imported lazily where used) so that
|
|
6
|
+
``config`` / ``doctor`` / ``capabilities`` stay usable even in a broken env.
|
|
7
|
+
|
|
8
|
+
Authoritative design: ``knowledges/local_helper_design.md`` (LH-D1…D6, C10/C11).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.0.1"
|
|
12
|
+
|
|
13
|
+
# Wire / schema identifiers — must match the design doc and the cloud edge.
|
|
14
|
+
CAPABILITIES_SCHEMA = "unitport.helper.capabilities@1" # design §5
|
|
15
|
+
PROTOCOL_VERSION = 1 # envelope { v, kind, id, verb, run_id?, payload, ts } §2
|
|
16
|
+
|
|
17
|
+
# Cloud edge (Cloudflare Workers, design §11). Overridable via config.toml
|
|
18
|
+
# [cloud].base_url or the UNITPORT_BASE_URL env var — never hard-required here.
|
|
19
|
+
DEFAULT_BASE_URL = "https://svc.unitport.ai"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"__version__",
|
|
23
|
+
"CAPABILITIES_SCHEMA",
|
|
24
|
+
"PROTOCOL_VERSION",
|
|
25
|
+
"DEFAULT_BASE_URL",
|
|
26
|
+
]
|
unitport/__main__.py
ADDED
unitport/bundle.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""DispatchBundle model + materialization (contract C4 / design §8, LH-P4).
|
|
3
|
+
|
|
4
|
+
A DispatchBundle is what the cloud sends via ``receive-bundle``. It is the
|
|
5
|
+
complete, executable payload the compile chain produced
|
|
6
|
+
(``web_export_chain_plan.md`` §6): env-cfg source + TrainingSpec + asset URI
|
|
7
|
+
list + provenance + run_id. Large asset bodies never ride inside it — they are
|
|
8
|
+
referenced by D2 URIs (``github://`` / ``helper://`` / ``storage://``) and
|
|
9
|
+
resolved locally at materialize time.
|
|
10
|
+
|
|
11
|
+
Wire shape (payload of the ``receive-bundle`` frame)::
|
|
12
|
+
|
|
13
|
+
{
|
|
14
|
+
"run_id": "r-abc123",
|
|
15
|
+
"backend": "isaac_lab" | "sb3_mujoco",
|
|
16
|
+
"provenance": { "owner_uid": "<uuid>", "canvas_project": "...", "signed_at": "..." },
|
|
17
|
+
"env_cfg": { "file_name": "unitport_env_cfg.py", "source": "<python source>" },
|
|
18
|
+
"spec": { "algorithm": "PPO"|"AMP_PPO", "max_iterations": 100, "seed": 1,
|
|
19
|
+
"robot_sku": "...", "robot_family": "...",
|
|
20
|
+
"resume_checkpoint": null | "helper://.../model_X.pt",
|
|
21
|
+
"warm_start_actor": false },
|
|
22
|
+
"assets": [ { "uri": "helper://<registered-path>", "dest": "assets/go2.xml" }, ... ]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
``materialize`` verifies provenance (own-canvas), lays out the run dir, writes
|
|
26
|
+
the env-cfg + spec + metadata, and resolves ``helper://`` assets against the
|
|
27
|
+
registered roots. It does NOT launch training (that is ``runner.start``).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import shutil
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any, Dict, List
|
|
36
|
+
|
|
37
|
+
from . import config as _config
|
|
38
|
+
from . import security
|
|
39
|
+
from .errors import VerbError
|
|
40
|
+
|
|
41
|
+
VALID_BACKENDS = frozenset({"isaac_lab", "sb3_mujoco"})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class MaterializedRun:
|
|
45
|
+
"""Result of materializing a bundle — everything ``runner`` needs."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, run_id: str, backend: str, run_dir: Path, config_file: Path, spec: Dict[str, Any]):
|
|
48
|
+
self.run_id = run_id
|
|
49
|
+
self.backend = backend
|
|
50
|
+
self.run_dir = run_dir
|
|
51
|
+
self.config_file = config_file # compiled env-cfg .py
|
|
52
|
+
self.spec = spec
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
55
|
+
return {
|
|
56
|
+
"run_id": self.run_id,
|
|
57
|
+
"backend": self.backend,
|
|
58
|
+
"run_dir": str(self.run_dir),
|
|
59
|
+
"config_file": str(self.config_file),
|
|
60
|
+
"spec": self.spec,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _require(bundle: Dict[str, Any], key: str) -> Any:
|
|
65
|
+
if key not in bundle or bundle[key] in (None, ""):
|
|
66
|
+
raise VerbError(f"bundle missing required field: {key!r}")
|
|
67
|
+
return bundle[key]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def run_root(cfg: Dict[str, Any], backend: str, run_id: str) -> Path:
|
|
71
|
+
ws = Path((cfg.get("workspace", {}) or {}).get("dir") or (_config.config_dir() / "workspace")).expanduser()
|
|
72
|
+
return ws / "training" / "runs" / backend / run_id
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def materialize(bundle: Dict[str, Any], paired_uid: str, cfg: Dict[str, Any] | None = None) -> MaterializedRun:
|
|
76
|
+
"""Verify + lay down a bundle on disk. Fail loud on anything malformed or
|
|
77
|
+
provenance/asset-boundary violations (C11 §7)."""
|
|
78
|
+
cfg = cfg if cfg is not None else _config.load_config()
|
|
79
|
+
|
|
80
|
+
run_id = str(_require(bundle, "run_id"))
|
|
81
|
+
backend = str(_require(bundle, "backend"))
|
|
82
|
+
if backend not in VALID_BACKENDS:
|
|
83
|
+
raise VerbError(f"unknown backend {backend!r} (allowed: {sorted(VALID_BACKENDS)})")
|
|
84
|
+
|
|
85
|
+
provenance = bundle.get("provenance") or {}
|
|
86
|
+
security.assert_own_canvas(str(provenance.get("owner_uid") or ""), paired_uid)
|
|
87
|
+
|
|
88
|
+
env_cfg = _require(bundle, "env_cfg")
|
|
89
|
+
source = env_cfg.get("source")
|
|
90
|
+
if not source:
|
|
91
|
+
raise VerbError("bundle env_cfg.source is empty (helper consumes precompiled env-cfg, §8)")
|
|
92
|
+
file_name = env_cfg.get("file_name") or "unitport_env_cfg.py"
|
|
93
|
+
|
|
94
|
+
spec = dict(bundle.get("spec") or {})
|
|
95
|
+
|
|
96
|
+
run_dir = run_root(cfg, backend, run_id)
|
|
97
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
|
|
99
|
+
config_file = run_dir / file_name
|
|
100
|
+
config_file.write_text(source, encoding="utf-8")
|
|
101
|
+
(run_dir / "spec.json").write_text(json.dumps(spec, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
102
|
+
|
|
103
|
+
assets_resolved = _resolve_assets(bundle.get("assets") or [], run_dir, cfg)
|
|
104
|
+
|
|
105
|
+
(run_dir / "run.json").write_text(
|
|
106
|
+
json.dumps(
|
|
107
|
+
{
|
|
108
|
+
"run_id": run_id,
|
|
109
|
+
"backend": backend,
|
|
110
|
+
"owner_uid": paired_uid,
|
|
111
|
+
"config_file": str(config_file),
|
|
112
|
+
"assets": assets_resolved,
|
|
113
|
+
"materialized_at": _config.now_iso(),
|
|
114
|
+
"status": "materialized",
|
|
115
|
+
},
|
|
116
|
+
indent=2,
|
|
117
|
+
ensure_ascii=False,
|
|
118
|
+
),
|
|
119
|
+
encoding="utf-8",
|
|
120
|
+
)
|
|
121
|
+
return MaterializedRun(run_id, backend, run_dir, config_file, spec)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _resolve_assets(assets: List[Dict[str, Any]], run_dir: Path, cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
125
|
+
"""Resolve each asset URI. ``helper://`` stays referenced by default (copied
|
|
126
|
+
into the run dir only if a ``dest`` is given). ``github://`` / ``storage://``
|
|
127
|
+
are declared-but-not-yet-fetched here (LH-P4 tail / needs network+storage);
|
|
128
|
+
they fail loud rather than silently skip."""
|
|
129
|
+
roots = (cfg.get("resource", {}) or {}).get("roots", []) or []
|
|
130
|
+
out: List[Dict[str, Any]] = []
|
|
131
|
+
for a in assets:
|
|
132
|
+
uri = a.get("uri") or ""
|
|
133
|
+
if uri.startswith("helper://"):
|
|
134
|
+
src = security.resolve_helper_uri(uri, roots)
|
|
135
|
+
entry = {"uri": uri, "resolved": str(src)}
|
|
136
|
+
dest = a.get("dest")
|
|
137
|
+
if dest:
|
|
138
|
+
dst = (run_dir / dest).resolve()
|
|
139
|
+
# dest must stay inside the run dir (no escape).
|
|
140
|
+
if run_dir.resolve() not in dst.parents and dst != run_dir.resolve():
|
|
141
|
+
from .errors import SecurityError
|
|
142
|
+
|
|
143
|
+
raise SecurityError(f"asset dest escapes run dir: {dest!r}")
|
|
144
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
shutil.copy2(src, dst)
|
|
146
|
+
entry["copied_to"] = str(dst)
|
|
147
|
+
out.append(entry)
|
|
148
|
+
elif uri.startswith(("github://", "storage://")):
|
|
149
|
+
raise VerbError(f"asset scheme not yet materialized in this build: {uri!r} (LH-P4 tail)")
|
|
150
|
+
else:
|
|
151
|
+
raise VerbError(f"unknown asset URI scheme: {uri!r}")
|
|
152
|
+
return out
|
unitport/capabilities.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Capability manifest (``report-capabilities`` verb, design §5).
|
|
3
|
+
|
|
4
|
+
Probes the host for training engines + GPU and emits the
|
|
5
|
+
``unitport.helper.capabilities@1`` payload the cloud uses to light up
|
|
6
|
+
`BTN:helper` and gate which backends are dispatchable (no Isaac Lab → Isaac
|
|
7
|
+
dispatch disabled, fail-loud rather than silent downgrade).
|
|
8
|
+
|
|
9
|
+
Probing is intentionally **fail-soft**: a missing engine/command reports
|
|
10
|
+
``installed: false`` (or an empty list), never raises. This is capability
|
|
11
|
+
*discovery*, distinct from the §7 security red line (which is fail-loud).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import platform
|
|
17
|
+
import re
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
from typing import Any, Dict, List, Optional
|
|
22
|
+
|
|
23
|
+
from . import CAPABILITIES_SCHEMA
|
|
24
|
+
from . import config as _config
|
|
25
|
+
|
|
26
|
+
# A probe importing a heavy engine could hang; keep every subprocess bounded.
|
|
27
|
+
_PROBE_TIMEOUT = 20 # seconds
|
|
28
|
+
_TOOL_TIMEOUT = 10
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _run(cmd: List[str], timeout: int) -> Optional[str]:
|
|
32
|
+
"""Run ``cmd``, return stripped stdout or None on any failure/timeout."""
|
|
33
|
+
try:
|
|
34
|
+
out = subprocess.run(
|
|
35
|
+
cmd,
|
|
36
|
+
stdout=subprocess.PIPE,
|
|
37
|
+
stderr=subprocess.DEVNULL,
|
|
38
|
+
timeout=timeout,
|
|
39
|
+
text=True,
|
|
40
|
+
encoding="utf-8",
|
|
41
|
+
errors="replace",
|
|
42
|
+
)
|
|
43
|
+
except (OSError, subprocess.SubprocessError):
|
|
44
|
+
return None
|
|
45
|
+
if out.returncode != 0:
|
|
46
|
+
return None
|
|
47
|
+
return (out.stdout or "").strip()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _probe_import(python_exe: str, module: str, version_expr: str) -> Dict[str, Any]:
|
|
51
|
+
"""Probe ``module`` inside ``python_exe``; report installed + version.
|
|
52
|
+
|
|
53
|
+
Runs an isolated one-liner (``-I``) so we don't inherit our own site-packages
|
|
54
|
+
when probing a *different* interpreter. Fail-soft.
|
|
55
|
+
"""
|
|
56
|
+
code = (
|
|
57
|
+
f"import importlib,sys\n"
|
|
58
|
+
f"try:\n"
|
|
59
|
+
f" m=importlib.import_module({module!r})\n"
|
|
60
|
+
f" print({version_expr})\n"
|
|
61
|
+
f"except Exception:\n"
|
|
62
|
+
f" sys.exit(3)\n"
|
|
63
|
+
)
|
|
64
|
+
ver = _run([python_exe, "-I", "-c", code], _PROBE_TIMEOUT)
|
|
65
|
+
if ver is None:
|
|
66
|
+
return {"installed": False, "version": None}
|
|
67
|
+
return {"installed": True, "version": ver or "unknown"}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Engines
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
def detect_isaac_lab(cfg: Dict[str, Any]) -> Dict[str, Any]:
|
|
74
|
+
"""Isaac Lab lives behind its OWN interpreter (Isaac Sim's python), declared
|
|
75
|
+
via ``[installs.isaac_lab].python``. We report installed by path existence;
|
|
76
|
+
we do NOT import ``isaaclab`` here (it needs the Kit runtime and can hang) —
|
|
77
|
+
a deep version probe is deferred to `doctor`/training launch."""
|
|
78
|
+
entry = (cfg.get("installs", {}) or {}).get("isaac_lab", {}) or {}
|
|
79
|
+
py = entry.get("python")
|
|
80
|
+
if not py:
|
|
81
|
+
return {"installed": False, "version": None, "python": None}
|
|
82
|
+
from pathlib import Path
|
|
83
|
+
|
|
84
|
+
exists = Path(py).exists()
|
|
85
|
+
return {
|
|
86
|
+
"installed": bool(exists),
|
|
87
|
+
"version": None, # deep probe deferred (Kit runtime); §5 note
|
|
88
|
+
"python": _config._norm_path(py) if py else None,
|
|
89
|
+
"python_exists": bool(exists),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def detect_mujoco(cfg: Dict[str, Any]) -> Dict[str, Any]:
|
|
94
|
+
"""MuJoCo (SB3 path) can run in the helper's own interpreter or a declared
|
|
95
|
+
one. Probe the declared interpreter if present, else ``sys.executable``."""
|
|
96
|
+
py = ((cfg.get("installs", {}) or {}).get("mujoco", {}) or {}).get("python") or sys.executable
|
|
97
|
+
return _probe_import(py, "mujoco", "getattr(m,'__version__','unknown')")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def detect_sb3(cfg: Dict[str, Any]) -> Dict[str, Any]:
|
|
101
|
+
py = ((cfg.get("installs", {}) or {}).get("sb3", {}) or {}).get("python") or sys.executable
|
|
102
|
+
return _probe_import(py, "stable_baselines3", "getattr(m,'__version__','unknown')")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def detect_conda_envs() -> List[str]:
|
|
106
|
+
if not shutil.which("conda"):
|
|
107
|
+
return []
|
|
108
|
+
out = _run(["conda", "env", "list", "--json"], _TOOL_TIMEOUT)
|
|
109
|
+
if not out:
|
|
110
|
+
return []
|
|
111
|
+
try:
|
|
112
|
+
import json
|
|
113
|
+
|
|
114
|
+
envs = json.loads(out).get("envs", [])
|
|
115
|
+
except Exception:
|
|
116
|
+
return []
|
|
117
|
+
names = []
|
|
118
|
+
for path in envs:
|
|
119
|
+
name = str(path).replace("\\", "/").rstrip("/").rsplit("/", 1)[-1]
|
|
120
|
+
if name:
|
|
121
|
+
names.append(name)
|
|
122
|
+
return names
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# GPU (nvidia-smi)
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
def detect_gpu() -> Dict[str, Any]:
|
|
129
|
+
smi = shutil.which("nvidia-smi")
|
|
130
|
+
if not smi:
|
|
131
|
+
return {"nvidia_smi": []}
|
|
132
|
+
cuda = _detect_cuda_version(smi)
|
|
133
|
+
out = _run(
|
|
134
|
+
[
|
|
135
|
+
smi,
|
|
136
|
+
"--query-gpu=name,memory.total,driver_version",
|
|
137
|
+
"--format=csv,noheader,nounits",
|
|
138
|
+
],
|
|
139
|
+
_TOOL_TIMEOUT,
|
|
140
|
+
)
|
|
141
|
+
gpus: List[Dict[str, Any]] = []
|
|
142
|
+
if out:
|
|
143
|
+
for line in out.splitlines():
|
|
144
|
+
parts = [p.strip() for p in line.split(",")]
|
|
145
|
+
if len(parts) < 3:
|
|
146
|
+
continue
|
|
147
|
+
name, mem, driver = parts[0], parts[1], parts[2]
|
|
148
|
+
try:
|
|
149
|
+
mem_mb = int(float(mem))
|
|
150
|
+
except ValueError:
|
|
151
|
+
mem_mb = None
|
|
152
|
+
gpus.append({"name": name, "mem_mb": mem_mb, "driver": driver, "cuda": cuda})
|
|
153
|
+
return {"nvidia_smi": gpus}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _detect_cuda_version(smi: str) -> Optional[str]:
|
|
157
|
+
banner = _run([smi], _TOOL_TIMEOUT)
|
|
158
|
+
if not banner:
|
|
159
|
+
return None
|
|
160
|
+
m = re.search(r"CUDA Version:\s*([0-9.]+)", banner)
|
|
161
|
+
return m.group(1) if m else None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
# Manifest
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
def _os_name() -> str:
|
|
168
|
+
sysname = platform.system().lower()
|
|
169
|
+
if sysname.startswith("win"):
|
|
170
|
+
return "windows"
|
|
171
|
+
if sysname == "linux":
|
|
172
|
+
return "linux"
|
|
173
|
+
return sysname or "unknown"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def build_manifest(cfg: Optional[Dict[str, Any]] = None, device_id: Optional[str] = None) -> Dict[str, Any]:
|
|
177
|
+
"""Assemble the full capability manifest payload (design §5)."""
|
|
178
|
+
cfg = cfg if cfg is not None else _config.load_config()
|
|
179
|
+
device_id = device_id or _config.get_or_create_device_id()
|
|
180
|
+
return {
|
|
181
|
+
"schema": CAPABILITIES_SCHEMA,
|
|
182
|
+
"device_id": device_id,
|
|
183
|
+
"host": {
|
|
184
|
+
"os": _os_name(),
|
|
185
|
+
"arch": platform.machine() or "unknown",
|
|
186
|
+
"hostname": platform.node() or "unknown",
|
|
187
|
+
},
|
|
188
|
+
"engines": {
|
|
189
|
+
"isaac_lab": detect_isaac_lab(cfg),
|
|
190
|
+
"mujoco": detect_mujoco(cfg),
|
|
191
|
+
"sb3": detect_sb3(cfg),
|
|
192
|
+
},
|
|
193
|
+
"conda_envs": detect_conda_envs(),
|
|
194
|
+
"gpu": detect_gpu(),
|
|
195
|
+
"reported_at": _config.now_iso(),
|
|
196
|
+
}
|
unitport/cli.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""`unitport` command-line entrypoint (headless — design §10).
|
|
3
|
+
|
|
4
|
+
Subcommands: login · up/serve · status · config · doctor · logout, plus a hidden
|
|
5
|
+
`capabilities` for probing/debugging. There are deliberately NO pull/train/push
|
|
6
|
+
subcommands — those are *remote* verbs driven from the canvas (C11 §4).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
# Windows console defaults to a legacy code page; force UTF-8 (img_generate.py style).
|
|
16
|
+
try: # pragma: no cover
|
|
17
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
18
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
19
|
+
except Exception: # pragma: no cover
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
from . import __version__
|
|
23
|
+
from . import capabilities as _caps
|
|
24
|
+
from . import config as _config
|
|
25
|
+
from . import doctor as _doctor
|
|
26
|
+
from .errors import UnitportError
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Command handlers
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
def _cmd_login(args: argparse.Namespace) -> int:
|
|
33
|
+
from . import pairing # lazy: pulls no heavy deps, but keep startup lean
|
|
34
|
+
|
|
35
|
+
pairing.login(base_url=args.base_url)
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _cmd_up(args: argparse.Namespace) -> int:
|
|
40
|
+
from . import transport # lazy: imports websockets only on connect
|
|
41
|
+
|
|
42
|
+
return transport.run(base_url=args.base_url)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _cmd_status(_args: argparse.Namespace) -> int:
|
|
46
|
+
cfg = _config.load_config()
|
|
47
|
+
tok = None
|
|
48
|
+
try:
|
|
49
|
+
tok = _config.load_token()
|
|
50
|
+
except UnitportError as exc:
|
|
51
|
+
print(f" token: ERROR ({exc})")
|
|
52
|
+
|
|
53
|
+
isaac = (cfg.get("installs", {}) or {}).get("isaac_lab", {}) or {}
|
|
54
|
+
roots = (cfg.get("resource", {}) or {}).get("roots", []) or []
|
|
55
|
+
|
|
56
|
+
print(f"unitport v{__version__}")
|
|
57
|
+
print(f" config dir : {_config.config_dir()}")
|
|
58
|
+
print(f" cloud : {_config.base_url(cfg)}")
|
|
59
|
+
print(f" device_id : {_config.get_or_create_device_id()}")
|
|
60
|
+
if tok:
|
|
61
|
+
print(f" paired : yes (uid={tok.get('uid')})")
|
|
62
|
+
print(f" token exp : {tok.get('expires_at', '?')}")
|
|
63
|
+
else:
|
|
64
|
+
print(" paired : no (run `unitport login`)")
|
|
65
|
+
print(f" isaac py : {isaac.get('python') or '(not set)'}")
|
|
66
|
+
print(f" resource : {len(roots)} root(s)")
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_ENGINE_FIELD_ACTIONS = {
|
|
71
|
+
"isaac-python": ("isaac_lab", "python"),
|
|
72
|
+
"isaac-launcher": ("isaac_lab", "launcher"),
|
|
73
|
+
"isaac-path": ("isaac_lab", "isaac_lab_path"),
|
|
74
|
+
"sb3-python": ("sb3_mujoco", "python"),
|
|
75
|
+
"sb3-launcher": ("sb3_mujoco", "launcher"),
|
|
76
|
+
"mujoco-python": ("mujoco", "python"),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _cmd_config(args: argparse.Namespace) -> int:
|
|
81
|
+
action = args.config_action
|
|
82
|
+
if action == "show" or action is None:
|
|
83
|
+
print(json.dumps(_config.load_config(), indent=2, ensure_ascii=False))
|
|
84
|
+
return 0
|
|
85
|
+
if action in _ENGINE_FIELD_ACTIONS:
|
|
86
|
+
engine, field = _ENGINE_FIELD_ACTIONS[action]
|
|
87
|
+
_config.set_engine_field(engine, field, args.path)
|
|
88
|
+
print(f" {engine}.{field} set: {args.path}")
|
|
89
|
+
return 0
|
|
90
|
+
if action == "workspace":
|
|
91
|
+
_config.set_workspace(args.path)
|
|
92
|
+
print(f" workspace set: {args.path}")
|
|
93
|
+
return 0
|
|
94
|
+
if action == "add-root":
|
|
95
|
+
cfg = _config.add_resource_root(args.path)
|
|
96
|
+
roots = cfg["resource"]["roots"]
|
|
97
|
+
print(f" resource roots ({len(roots)}): {', '.join(roots)}")
|
|
98
|
+
return 0
|
|
99
|
+
if action == "base-url":
|
|
100
|
+
_config.set_base_url(args.url)
|
|
101
|
+
print(f" cloud base_url set: {args.url}")
|
|
102
|
+
return 0
|
|
103
|
+
print(f"unknown config action: {action}", file=sys.stderr)
|
|
104
|
+
return 2
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _cmd_doctor(_args: argparse.Namespace) -> int:
|
|
108
|
+
return _doctor.run()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _cmd_capabilities(args: argparse.Namespace) -> int:
|
|
112
|
+
manifest = _caps.build_manifest()
|
|
113
|
+
print(json.dumps(manifest, indent=2, ensure_ascii=False))
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _cmd_logout(_args: argparse.Namespace) -> int:
|
|
118
|
+
from . import pairing
|
|
119
|
+
|
|
120
|
+
pairing.logout()
|
|
121
|
+
print(" logged out (helper token cleared).")
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# Parser
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
129
|
+
p = argparse.ArgumentParser(
|
|
130
|
+
prog="unitport",
|
|
131
|
+
description="UnitPort local helper — headless daemon (canvas is the remote control).",
|
|
132
|
+
)
|
|
133
|
+
p.add_argument("-V", "--version", action="version", version=f"unitport {__version__}")
|
|
134
|
+
sub = p.add_subparsers(dest="command")
|
|
135
|
+
|
|
136
|
+
sp = sub.add_parser("login", help="browser-approve device pairing (C10)")
|
|
137
|
+
sp.add_argument("--base-url", default=None, help="override cloud base URL")
|
|
138
|
+
sp.set_defaults(func=_cmd_login)
|
|
139
|
+
|
|
140
|
+
for name in ("up", "serve"):
|
|
141
|
+
sp = sub.add_parser(name, help="dial out to the relay and serve the fixed verb set")
|
|
142
|
+
sp.add_argument("--base-url", default=None, help="override cloud base URL")
|
|
143
|
+
sp.set_defaults(func=_cmd_up)
|
|
144
|
+
|
|
145
|
+
sp = sub.add_parser("status", help="connection / config / paired-account summary")
|
|
146
|
+
sp.set_defaults(func=_cmd_status)
|
|
147
|
+
|
|
148
|
+
sp = sub.add_parser("config", help="declare engine interpreters + resource roots")
|
|
149
|
+
csub = sp.add_subparsers(dest="config_action")
|
|
150
|
+
csub.add_parser("show", help="print effective config")
|
|
151
|
+
for act, helptext in (
|
|
152
|
+
("isaac-python", "Isaac Sim venv python.exe"),
|
|
153
|
+
("isaac-launcher", "path to il_train_launcher.py"),
|
|
154
|
+
("isaac-path", "Isaac Lab root (subprocess cwd)"),
|
|
155
|
+
("sb3-python", "SB3/MuJoCo python"),
|
|
156
|
+
("sb3-launcher", "SB3 launcher script"),
|
|
157
|
+
("mujoco-python", "MuJoCo probe python"),
|
|
158
|
+
("workspace", "workspace root (Isaac: under PROJECTS_DIR)"),
|
|
159
|
+
("add-root", "add a resource scan root"),
|
|
160
|
+
):
|
|
161
|
+
cp = csub.add_parser(act, help=helptext)
|
|
162
|
+
cp.add_argument("path", help="filesystem path")
|
|
163
|
+
cp = csub.add_parser("base-url", help="set cloud base URL")
|
|
164
|
+
cp.add_argument("url", help="e.g. https://svc.unitport.ai")
|
|
165
|
+
sp.set_defaults(func=_cmd_config)
|
|
166
|
+
|
|
167
|
+
sp = sub.add_parser("doctor", help="fail-loud self-check")
|
|
168
|
+
sp.set_defaults(func=_cmd_doctor)
|
|
169
|
+
|
|
170
|
+
sp = sub.add_parser("capabilities", help="print the capability manifest (debug)")
|
|
171
|
+
sp.set_defaults(func=_cmd_capabilities)
|
|
172
|
+
|
|
173
|
+
sp = sub.add_parser("logout", help="clear the stored helper token")
|
|
174
|
+
sp.set_defaults(func=_cmd_logout)
|
|
175
|
+
|
|
176
|
+
return p
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def main(argv=None) -> int:
|
|
180
|
+
parser = build_parser()
|
|
181
|
+
args = parser.parse_args(argv)
|
|
182
|
+
if not getattr(args, "command", None):
|
|
183
|
+
parser.print_help()
|
|
184
|
+
return 0
|
|
185
|
+
try:
|
|
186
|
+
return int(args.func(args) or 0)
|
|
187
|
+
except UnitportError as exc:
|
|
188
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
189
|
+
return 1
|
|
190
|
+
except KeyboardInterrupt:
|
|
191
|
+
print("\naborted.", file=sys.stderr)
|
|
192
|
+
return 130
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
if __name__ == "__main__": # pragma: no cover
|
|
196
|
+
sys.exit(main())
|