Skip to content

Commit 7228af3

Browse files
authored
fix: address PR #84 follow-up items (#105) (#106)
1 parent 6d80fcc commit 7228af3

3 files changed

Lines changed: 166 additions & 7 deletions

File tree

strands_robots/assets/manager.py

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,49 @@ def _resolve_candidates(asset_dir_name: str, xml_file: str, name: str) -> list[P
111111
return candidates
112112

113113

114+
def is_robot_asset_present(name: str) -> bool:
115+
"""Check whether a robot's model XML exists on disk without triggering downloads.
116+
117+
Pure filesystem check — no auto-download, no mesh walk, no network.
118+
Use this for status queries (e.g. ``download_assets(action="status")``)
119+
where you need to quickly check presence without side effects.
120+
121+
Args:
122+
name: Robot name (canonical or alias).
123+
124+
Returns:
125+
True if the model XML file exists on at least one search path.
126+
"""
127+
info = get_robot(name)
128+
if not info or "asset" not in info:
129+
return False
130+
131+
asset = info["asset"]
132+
xml_file: str = str(asset["model_xml"])
133+
asset_dir_name: str = str(asset["dir"])
134+
135+
# Check user-registered path first
136+
user_path = info.get("_user_asset_path")
137+
if user_path:
138+
try:
139+
user_model = safe_join(Path(user_path), xml_file)
140+
if user_model.exists():
141+
return True
142+
except ValueError:
143+
pass
144+
145+
# Check standard search paths
146+
for search_dir in get_search_paths():
147+
try:
148+
model_path = safe_join(search_dir, f"{asset_dir_name}/{xml_file}")
149+
if model_path.exists():
150+
return True
151+
except ValueError:
152+
continue
153+
154+
return False
155+
156+
114157
def resolve_model_path(
115158
name: str,
116159
prefer_scene: bool = False,
@@ -250,21 +293,28 @@ def get_robot_info(name: str) -> dict | None:
250293
def list_available_robots() -> list[dict]:
251294
"""List all available robot models with their info.
252295
296+
Uses :func:`is_robot_asset_present` for a fast filesystem-only check
297+
per robot instead of the heavier :func:`resolve_model_path` which can
298+
trigger auto-downloads and mesh cache walks.
299+
253300
Returns:
254301
List of dicts with name, description, joints, category, available, path.
255302
"""
256303
robots = []
257304
for r in list_robots(mode="sim"):
258-
path = resolve_model_path(r["name"])
259-
info = get_robot(r["name"]) or {}
305+
name = r["name"]
306+
present = is_robot_asset_present(name)
307+
info = get_robot(name) or {}
308+
# Only resolve full path when asset is present — avoids download attempts
309+
path = resolve_model_path(name) if present else None
260310
robots.append(
261311
{
262-
"name": r["name"],
312+
"name": name,
263313
"description": r.get("description", ""),
264314
"joints": r.get("joints"),
265315
"category": r.get("category", ""),
266316
"dir": info.get("asset", {}).get("dir", ""),
267-
"available": path is not None,
317+
"available": present,
268318
"path": str(path) if path else None,
269319
}
270320
)

strands_robots/utils.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,12 +171,11 @@ def get_search_paths() -> list[Path]:
171171
172172
Order (local assets take priority over defaults):
173173
1. User asset dir (``STRANDS_ASSETS_DIR`` or ``~/.strands_robots/assets/``)
174-
2. ``CWD/assets`` (project-local)
174+
2. ``CWD/assets`` (project-local, deduplicated if it resolves to the same dir)
175175
"""
176176
paths: list[Path] = []
177177
user_cache = get_assets_dir()
178-
if user_cache not in paths:
179-
paths.append(user_cache)
178+
paths.append(user_cache)
180179
cwd_assets = Path.cwd() / "assets"
181180
if cwd_assets not in paths:
182181
paths.append(cwd_assets)

tests/test_registry_resolves.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Integration test: every robot in robots.json resolves to an existing file.
2+
3+
Walks the full registry and asserts that every ``asset.dir / asset.model_xml``
4+
path is a valid relative path that would resolve under the search directories
5+
**if** the assets were downloaded. This catches:
6+
- Typos in ``robots.json`` (e.g. ``asimov_v0.xml`` → ``xmls/asimov.xml``)
7+
- Upstream layout regressions in robot_descriptions / MuJoCo Menagerie
8+
- Missing ``dir`` or ``model_xml`` keys in sim-capable robots
9+
- Path traversal sequences in registry entries
10+
11+
The test does NOT require downloaded assets or GPU — it only validates the
12+
registry metadata itself (directory/file names, path safety). Run it in the
13+
unit or integ hatch env.
14+
15+
Added as follow-up to PR #84 review (issue #105, task 2).
16+
"""
17+
18+
import json
19+
from pathlib import Path
20+
21+
import pytest
22+
23+
# ─────────────────────────────────────────────────────────────────────
24+
# Load registry directly to avoid import side effects
25+
# ─────────────────────────────────────────────────────────────────────
26+
27+
_REGISTRY_PATH = Path(__file__).resolve().parent.parent / "strands_robots" / "registry" / "robots.json"
28+
29+
30+
def _load_registry() -> dict:
31+
"""Load robots.json and return the robots dict."""
32+
with open(_REGISTRY_PATH) as f:
33+
data = json.load(f)
34+
return data.get("robots", data)
35+
36+
37+
_ROBOTS = _load_registry()
38+
39+
# Robots that have simulation assets (asset.dir + asset.model_xml).
40+
# Hardware-only robots (e.g. lekiwi, reachy2) have no 'asset' key.
41+
_SIM_ROBOTS = {name: info for name, info in _ROBOTS.items() if "asset" in info}
42+
_SIM_ROBOT_NAMES = list(_SIM_ROBOTS.keys())
43+
44+
45+
# ─────────────────────────────────────────────────────────────────────
46+
# Tests for ALL robots (sim + hardware-only)
47+
# ─────────────────────────────────────────────────────────────────────
48+
49+
50+
@pytest.mark.parametrize("name", list(_ROBOTS.keys()), ids=list(_ROBOTS.keys()))
51+
def test_registry_entry_is_well_formed(name: str) -> None:
52+
"""Every robot must have a description and category."""
53+
info = _ROBOTS[name]
54+
assert "description" in info, f"Robot '{name}' missing 'description'"
55+
assert "category" in info, f"Robot '{name}' missing 'category'"
56+
57+
58+
@pytest.mark.parametrize("name", list(_ROBOTS.keys()), ids=list(_ROBOTS.keys()))
59+
def test_registry_resolve_via_api(name: str) -> None:
60+
"""Verify the registry API can look up each robot without errors."""
61+
from strands_robots.registry import get_robot, resolve_name
62+
63+
canonical = resolve_name(name)
64+
assert canonical is not None, f"resolve_name({name!r}) returned None"
65+
66+
info = get_robot(name)
67+
assert info is not None, f"get_robot({name!r}) returned None"
68+
69+
70+
# ─────────────────────────────────────────────────────────────────────
71+
# Tests for sim-capable robots only (have 'asset' key)
72+
# ─────────────────────────────────────────────────────────────────────
73+
74+
75+
@pytest.mark.parametrize("name", _SIM_ROBOT_NAMES, ids=_SIM_ROBOT_NAMES)
76+
def test_sim_robot_has_required_asset_fields(name: str) -> None:
77+
"""Sim robots must have asset.dir and asset.model_xml."""
78+
asset = _SIM_ROBOTS[name]["asset"]
79+
assert "dir" in asset, f"Robot '{name}' missing 'asset.dir'"
80+
assert "model_xml" in asset, f"Robot '{name}' missing 'asset.model_xml'"
81+
assert isinstance(asset["dir"], str) and asset["dir"], f"Robot '{name}' has empty 'asset.dir'"
82+
assert isinstance(asset["model_xml"], str) and asset["model_xml"], f"Robot '{name}' has empty 'asset.model_xml'"
83+
84+
85+
@pytest.mark.parametrize("name", _SIM_ROBOT_NAMES, ids=_SIM_ROBOT_NAMES)
86+
def test_sim_robot_paths_are_safe(name: str) -> None:
87+
"""No registry path should contain traversal sequences."""
88+
asset = _SIM_ROBOTS[name]["asset"]
89+
dir_name = asset.get("dir", "")
90+
model_xml = asset.get("model_xml", "")
91+
scene_xml = asset.get("scene_xml", "")
92+
93+
for field, value in [("dir", dir_name), ("model_xml", model_xml), ("scene_xml", scene_xml)]:
94+
if not value:
95+
continue
96+
assert ".." not in value, f"Robot '{name}' asset.{field} contains '..': {value!r}"
97+
assert not value.startswith("/"), f"Robot '{name}' asset.{field} is absolute: {value!r}"
98+
99+
100+
@pytest.mark.parametrize("name", _SIM_ROBOT_NAMES, ids=_SIM_ROBOT_NAMES)
101+
def test_sim_robot_xml_has_xml_extension(name: str) -> None:
102+
"""model_xml and scene_xml should end with .xml."""
103+
asset = _SIM_ROBOTS[name]["asset"]
104+
model_xml = asset.get("model_xml", "")
105+
scene_xml = asset.get("scene_xml", "")
106+
107+
if model_xml:
108+
assert model_xml.endswith(".xml"), f"Robot '{name}' model_xml doesn't end with .xml: {model_xml!r}"
109+
if scene_xml:
110+
assert scene_xml.endswith(".xml"), f"Robot '{name}' scene_xml doesn't end with .xml: {scene_xml!r}"

0 commit comments

Comments
 (0)