Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,22 @@ All notable changes to this project are documented here. The format is based on
[Semantic Versioning](https://semver.org/spec/v2.0.0.html). While the version is
`0.x`, breaking changes may occur on any minor release.

## [Unreleased]
### Added

- **CLI:** added `inspect-robots completion bash|zsh` command to generate ready-to-source shell autocompletion scripts.

### Fixed

- **Core & Plugins:** modernized `isinstance` checks to Python 3.10+ union syntax (`X | Y`), addressing Ruff UP038 lints across the core framework and plugins.
- **Voice plugin (0.5.1):** operator-ended trials now cut `--speak` narration
instead of draining it at eval end
([plan 0061](plans/0061-speak-operator-end-cut.md),
[#343](https://github.com/robocurve/inspect-robots/issues/343)).

### Changed

- **CLI:** extended `inspect-robots doctor` to print environment diagnostics (Python interpreter version, OS platform, framework version, and optional package installation statuses).

- **Core:** the `--epochs` below-1 guard in `run` and `eval-set` now lives in
one shared helper, and its error reads `--epochs must be >= 1, got 0`
(naming the offending task in `eval-set`) instead of doubling the wording
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ def begin_trial(self, log_dir: str, run_id: str, trial_id: str) -> None:
self._relative_path = (Path("wire") / run_id / safe_trial_id / "calls.jsonl").as_posix()
except BaseException as exc:
self._disable(exc)
if isinstance(exc, (KeyboardInterrupt, SystemExit)):
if isinstance(exc, KeyboardInterrupt | SystemExit):
raise

def record(
Expand Down Expand Up @@ -144,7 +144,7 @@ def record(
self._rows_written = True
except BaseException as exc:
self._disable(exc)
if isinstance(exc, (KeyboardInterrupt, SystemExit)):
if isinstance(exc, KeyboardInterrupt | SystemExit):
raise

def end_trial(self) -> str | None:
Expand All @@ -159,7 +159,7 @@ def end_trial(self) -> str | None:
return pointer
except BaseException as exc:
self._disable(exc)
if isinstance(exc, (KeyboardInterrupt, SystemExit)):
if isinstance(exc, KeyboardInterrupt | SystemExit):
raise
return pointer

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ def _move(self, arguments: dict[str, Any], observation: Observation) -> ToolResu
return ToolResult(
error=f"unknown dimension {label!r}; valid names: {', '.join(self._labels)}"
)
if isinstance(raw, bool) or not isinstance(raw, (int, float)):
if isinstance(raw, bool) or not isinstance(raw, int | float):
return ToolResult(error=f"value for {label!r} must be a finite number, got {raw!r}")
try:
# Arbitrary-precision JSON integers overflow float() (and crash
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ def _disable_debug_vis(cfg: Any) -> None:
if isinstance(obj, dict):
stack.extend(obj.values())
continue
if isinstance(obj, (list, tuple, set)):
if isinstance(obj, list | tuple | set):
stack.extend(obj)
continue
obj_vars = getattr(obj, "__dict__", None)
Expand All @@ -102,7 +102,7 @@ def _disable_debug_vis(cfg: Any) -> None:
for key, value in obj_vars.items():
if key == "debug_vis" and value is True:
obj.debug_vis = False
elif isinstance(value, (dict, list, tuple, set)) or hasattr(value, "__dict__"):
elif isinstance(value, dict | list | tuple | set) or hasattr(value, "__dict__"):
stack.append(value)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ def test_no_ram_leak_over_many_steps() -> None:
# The adapter itself must hold no per-step accumulation.
assert fake.step_calls == 3200
for value in vars(emb).values():
assert not isinstance(value, (list, dict)) or len(value) <= 1
assert not isinstance(value, list | dict) or len(value) <= 1


def test_disable_debug_vis_walks_nested_configs() -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ def _receive_loop(self) -> None:
try:
while True:
raw = ws.recv()
if not isinstance(raw, (str, bytes)):
if not isinstance(raw, str | bytes):
raise RosbridgeError(
"invalid_frame",
f"rosbridge sent unsupported frame type {type(raw).__name__}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ def _parse_numeric_list(value: NumericList | None, arg: str) -> tuple[float, ...
raw: Sequence[Any]
if isinstance(value, str):
raw = tuple(item.strip() for item in value.split(",") if item.strip())
elif isinstance(value, (int, float)):
elif isinstance(value, int | float):
raw = (value,)
else:
raw = value
Expand Down
2 changes: 1 addition & 1 deletion plugins/inspect-robots-ros/tests/_stub_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ def _handler(self, ws: ServerConnection) -> None:
self._subscriptions[ws] = {}
try:
for raw in ws:
if not isinstance(raw, (str, bytes)):
if not isinstance(raw, str | bytes):
continue
operation = json.loads(raw)
if not isinstance(operation, dict):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,11 @@ def speaker_sink(**kwargs: ScalarValue) -> SpeakerSink:
mode = kwargs.get("mode", _DEFAULT_MODE)
if not isinstance(voice, str):
raise TypeError("voice must be a string")
if not isinstance(speed, (int, float)) or isinstance(speed, bool):
if not isinstance(speed, int | float) or isinstance(speed, bool):
raise TypeError("speed must be a number")
if speed <= 0:
raise TypeError("speed must be positive")
if not isinstance(volume, (int, float)) or isinstance(volume, bool):
if not isinstance(volume, int | float) or isinstance(volume, bool):
raise TypeError("volume must be a number")
if not 0 <= volume <= 1:
raise TypeError("volume must be between 0 and 1")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ def request(
if remaining <= 0:
raise TimeoutError
raw = ws.recv(timeout=remaining)
if not isinstance(raw, (bytes, bytearray)):
if not isinstance(raw, bytes | bytearray):
continue # protocol is binary-only; ignore stray text frames
try:
reply = decode_frame(bytes(raw))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ def from_wire(cls, data: dict[str, Any]) -> Frame:

def _encode_hook(obj: Any) -> Any:
# Same restriction as upstream: object-dtype arrays cannot round-trip.
if isinstance(obj, (np.ndarray, np.generic)) and obj.dtype.kind == "O":
if isinstance(obj, np.ndarray | np.generic) and obj.dtype.kind == "O":
raise WsError("invalid_frame", "object dtype numpy arrays are not supported")
return msgpack_numpy.encode(obj)

Expand Down
2 changes: 1 addition & 1 deletion plugins/inspect-robots-xpolicylab/tests/_stub_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ def stop(self) -> None:

def _handler(self, ws: ServerConnection) -> None:
for raw in ws:
if not isinstance(raw, (bytes, bytearray)):
if not isinstance(raw, bytes | bytearray):
continue
frame = decode_frame(bytes(raw))
self.frames.append(frame)
Expand Down
6 changes: 3 additions & 3 deletions src/inspect_robots/_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -638,7 +638,7 @@ def _render_tool_call(raw_call: object) -> str:
)
elif isinstance(value, list):
chips.append(_call_chip(key, ", ".join(str(item) for item in value)))
elif value is None or isinstance(value, (str, int, float, bool)):
elif value is None or isinstance(value, str | int | float | bool):
chips.append(_call_chip(key, value))
else:
return f"{notes}{_raw_tool_call(name, arguments)}"
Expand All @@ -650,7 +650,7 @@ def _render_tool_call(raw_call: object) -> str:

def _is_number(value: object) -> bool:
"""Accept real JSON numbers while excluding booleans from numeric chips."""
return isinstance(value, (int, float)) and not isinstance(value, bool)
return isinstance(value, int | float) and not isinstance(value, bool)


def _call_chip(key: object, value: object) -> str:
Expand Down Expand Up @@ -1245,7 +1245,7 @@ def _render_wire_call(
shown_status = "null" if status is None else status
shown_duration = (
f"{_number(duration)} s"
if isinstance(duration, (int, float)) and not isinstance(duration, bool)
if isinstance(duration, int | float) and not isinstance(duration, bool)
else "n/a"
)
summary = (
Expand Down
2 changes: 1 addition & 1 deletion src/inspect_robots/_summarize.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ def _parallel_value(values: tuple[Any, ...], index: int) -> Any:

def _step_count(scores: dict[str, float]) -> str:
value = scores.get("episode_length")
if isinstance(value, (int, float)) and not isinstance(value, bool):
if isinstance(value, int | float) and not isinstance(value, bool):
return f"{value:g}"
return "not recorded"

Expand Down
2 changes: 1 addition & 1 deletion src/inspect_robots/_video.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ def default_fps(embodiment_info: Mapping[str, Any]) -> tuple[float, str]:
"""
rate = embodiment_info.get("control_hz")
if (
isinstance(rate, (int, float))
isinstance(rate, int | float)
and not isinstance(rate, bool)
and rate > 0
and math.isfinite(rate)
Expand Down
77 changes: 72 additions & 5 deletions src/inspect_robots/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ def _styled(text: str, code: str) -> str:
"config",
"setup",
"doctor",
"completion",
)

_ENV_BY_KIND = {"policy": _ENV_POLICY, "embodiment": _ENV_EMBODIMENT}
Expand Down Expand Up @@ -559,6 +560,16 @@ def build_parser() -> argparse.ArgumentParser:
p_doctor.add_argument("-E", dest="embodiment_args", action="append", metavar="k=v")
_add_config_arg(p_doctor)

p_completion = sub.add_parser(
"completion",
help="generate shell completion script (bash or zsh)",
)
p_completion.add_argument(
"shell",
choices=["bash", "zsh"],
help="shell target ('bash' or 'zsh')",
)

p_setup = sub.add_parser(
"setup",
help="interactive first-run wizard: pick defaults and discover camera devices, "
Expand Down Expand Up @@ -1018,7 +1029,7 @@ def _seconds_horizon(log: EvalLog) -> tuple[float, int, float | None] | None:
max_seconds = log.eval.max_seconds
max_steps = log.eval.max_steps
if not (
isinstance(max_seconds, (int, float))
isinstance(max_seconds, int | float)
and not isinstance(max_seconds, bool)
and math.isfinite(max_seconds)
and max_seconds > 0
Expand All @@ -1031,7 +1042,7 @@ def _seconds_horizon(log: EvalLog) -> tuple[float, int, float | None] | None:
rate = log.eval.embodiment_info.get("control_hz")
valid_rate = (
float(rate)
if isinstance(rate, (int, float))
if isinstance(rate, int | float)
and not isinstance(rate, bool)
and math.isfinite(rate)
and rate > 0
Expand Down Expand Up @@ -1097,7 +1108,7 @@ def _print_step_limit_notice(log: EvalLog, is_adhoc: bool) -> None:
parenthetical = f"max_steps={max_steps}"
rate = log.eval.embodiment_info.get("control_hz")
if (
isinstance(rate, (int, float))
isinstance(rate, int | float)
and not isinstance(rate, bool)
and math.isfinite(rate)
and rate > 0
Expand Down Expand Up @@ -1253,7 +1264,7 @@ def _print_wire_table(trials: list[_WireTrial]) -> None:
duration = row.get("duration_s")
shown_duration = (
f"{duration:.3f}s"
if isinstance(duration, (int, float)) and not isinstance(duration, bool)
if isinstance(duration, int | float) and not isinstance(duration, bool)
else "-"
)
status = "-" if row.get("status") is None else str(row["status"])
Expand Down Expand Up @@ -2640,9 +2651,19 @@ def _cmd_doctor(args: argparse.Namespace) -> int:
Purely declarative — the embodiment is constructed (adapters keep
constructors hardware-free by convention) but never reset or stepped.
"""
from inspect_robots.conformance import check_embodiment, missing_runtime_requirements
from inspect_robots.conformance import (
check_embodiment,
check_environment_diagnostics,
missing_runtime_requirements,
)
from inspect_robots.registry import registered

print("Environment diagnostics:")
diag = check_environment_diagnostics()
for key, val in diag.items():
print(f" {key}: {val}")
print()

defaults = load_defaults(os.environ)
name, source = _pick_component(
"embodiment", args.embodiment, defaults.embodiment, defaults.embodiment_source
Expand All @@ -2666,6 +2687,50 @@ def _cmd_doctor(args: argparse.Namespace) -> int:
return 1 if not report.ok or missing else 0


def _generate_completion_script(shell: str) -> str:
"""Generate ready-to-source completion script for bash or zsh."""
if shell == "bash":
subcmds = " ".join(_SUBCOMMANDS)
return f"""# inspect-robots bash completion script
_inspect_robots_completions() {{
local cur prev subcommands
cur="${{COMP_WORDS[COMP_CWORD]}}"
prev="${{COMP_WORDS[COMP_CWORD-1]}}"
subcommands="{subcmds}"

if [ "$COMP_CWORD" -eq 1 ]; then
COMPREPLY=( $(compgen -W "$subcommands" -- "$cur") )
return 0
fi
}}
complete -F _inspect_robots_completions inspect-robots
"""
elif shell == "zsh":
sub_list = "\n ".join(
f"'{cmd}:inspect-robots {cmd} subcommand'" for cmd in _SUBCOMMANDS
)
return f"""#compdef inspect-robots

_inspect_robots() {{
local -a subcommands
subcommands=(
{sub_list}
)
_describe -t commands 'inspect-robots subcommand' subcommands
}}

_inspect_robots "$@"
"""
raise ValueError(f"unsupported shell: {shell}")


def _cmd_completion(args: argparse.Namespace) -> int:
"""Output shell completion script to stdout."""
script = _generate_completion_script(args.shell)
print(script, end="")
return 0


def _cmd_setup() -> int:
from inspect_robots._setup import run_setup

Expand Down Expand Up @@ -2756,6 +2821,8 @@ def main(argv: Sequence[str] | None = None) -> int:
return _cmd_setup()
if args.command == "doctor":
return _cmd_doctor(args)
if args.command == "completion":
return _cmd_completion(args)
parser.print_help()
return 0

Expand Down
34 changes: 34 additions & 0 deletions src/inspect_robots/conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,3 +288,37 @@ def assert_guardrail_contribution_conformant(embodiment: Embodiment, action_spac
report = check_guardrail_contribution(embodiment, action_space)
if not report.ok:
raise AssertionError(report.summary())


def check_environment_diagnostics() -> dict[str, str]:
"""Collect runtime environment, platform, dependency, and package info.

Returns a dictionary mapping diagnostic category keys to human-readable strings.
Never fails or throws exceptions when optional libraries are missing.
"""
import importlib.metadata
import importlib.util
import platform
import sys
from contextlib import suppress

from inspect_robots import __version__

diag: dict[str, str] = {
"python": f"{sys.version.split()[0]} ({sys.executable})",
"platform": platform.platform(),
"inspect_robots": __version__,
}

for mod in ("rerun", "PIL", "griffe", "torch"):
pkg_name = "pillow" if mod == "PIL" else mod
spec = importlib.util.find_spec(mod)
if spec is None:
diag[f"dep:{pkg_name}"] = "not installed"
continue
ver_str = "installed"
with suppress(Exception):
ver_str = f"installed ({importlib.metadata.version(pkg_name)})"
diag[f"dep:{pkg_name}"] = ver_str

return diag
2 changes: 1 addition & 1 deletion src/inspect_robots/logging/json_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def _sanitize(obj: object) -> object:
return obj if math.isfinite(obj) else None
if isinstance(obj, dict):
return {key: _sanitize(value) for key, value in obj.items()}
if isinstance(obj, (list, tuple)):
if isinstance(obj, list | tuple):
return [_sanitize(value) for value in obj]
return obj

Expand Down
Loading