Skip to content
Merged
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
61 changes: 61 additions & 0 deletions src/roboticstoolbox/bin/_bintools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""
Shared helpers for RTB command-line tools.
"""

from __future__ import annotations

import argparse
import textwrap


def link(uri: str, label: str | None = None) -> str:
"""Return a terminal hyperlink escape sequence.

:param uri: target URL
:param label: display label, defaults to the URI itself
:return: OSC-8 hyperlink string
"""
# https://stackoverflow.com/questions/40419276/python-how-to-print-text-to-console-as-hyperlink
if label is None:
label = uri
escape_mask = "\033]8;{};{}\033\\{}\033]8;;\033\\"
return escape_mask.format("", uri, label)


class CustomHelpFormatter(argparse.HelpFormatter):
"""Argparse formatter that wraps at word boundaries on explicit newlines."""

def _split_lines(self, text: str, width: int) -> list[str]:
lines = text.splitlines()
wrapped: list[str] = []
for line in lines:
wrapped.extend(
textwrap.wrap(
line, width, break_long_words=False, break_on_hyphens=False
)
)
return wrapped


class CustomDefaultsHelpFormatter(
CustomHelpFormatter, argparse.ArgumentDefaultsHelpFormatter
):
"""Custom formatter that also appends default values in help output."""


class LineWrapRawTextHelpFormatter(argparse.RawDescriptionHelpFormatter):
"""Argparse formatter that reflowes whitespace and wraps at 80 columns."""

def _split_lines(self, text: str, width: int) -> list[str]:
text = self._whitespace_matcher.sub(" ", text).strip()
return textwrap.wrap(text, 80)


class LineWrapRawTextDefaultsHelpFormatter(
LineWrapRawTextHelpFormatter, argparse.ArgumentDefaultsHelpFormatter
):
"""Line-wrapped formatter that also appends default values."""


RTB_URL = "https://github.com/petercorke/robotics-toolbox-python"
RTB_LINK = link(RTB_URL, "Robotics Toolbox for Python")
93 changes: 62 additions & 31 deletions src/roboticstoolbox/bin/rtbtool.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
#!/usr/bin/env python3
"""
Interactive Robotics Toolbox shell — starts an IPython session with NumPy,
RTB, and SpatialMath pre-imported.

# a simple Robotics Toolbox "shell", runs Python3 and loads in NumPy, RTB, SMTB
#
# Run it from the shell
# % rtb.py
#
# or setup an alias
#
# alias rtb=PATH/rtb.py # sh/bash
# alias rtb PATH/rtb.py # csh/tcsh
#
# % rtb
Usage::

$ rtbtool
$ rtbtool myscript.py
"""

# import stuff
from pygments.token import Token
Expand All @@ -20,10 +17,13 @@
import IPython
import argparse
from pathlib import Path
import shlex
import sys
import os
from importlib.metadata import version

from roboticstoolbox.bin._bintools import LineWrapRawTextDefaultsHelpFormatter

try:
from colored import fg, bg, attr

Expand Down Expand Up @@ -53,24 +53,59 @@

from roboticstoolbox import * # lgtm [py/unused-import]

_OPTIONS_ENVVAR = "RTB_OPTIONS"


def env_arguments(parser):
"""Return command-line style options from the environment.

:param parser: argument parser used for error reporting
:type parser: :class:`argparse.ArgumentParser`
:return: tokenised environment arguments
:rtype: list[str]
"""
options = os.environ.get(_OPTIONS_ENVVAR)
if not options:
return []

try:
return shlex.split(options)
except ValueError as exc:
parser.error(f"invalid {_OPTIONS_ENVVAR}: {exc}")


def parse_arguments():
parser = argparse.ArgumentParser("Robotics Toolbox shell")
parser = argparse.ArgumentParser(
description="Robotics Toolbox shell",
formatter_class=LineWrapRawTextDefaultsHelpFormatter,
epilog=(
"options can be set via the environment variable RTB_OPTIONS, "
"for example:\n\n"
" $ export RTB_OPTIONS=\"--backend TkAgg --prompt 'rtb> ' "
'--reload --showassign"\n'
),
)
parser.add_argument("script", default=None, nargs="?", help="specify script to run")
parser.add_argument(
"--backend", "-B", default=None, help="specify graphics backend"
)
parser.add_argument(
"--color",
"-c",
"--theme",
"-t",
default="neutral",
help="specify terminal color scheme (neutral, lightbg, nocolor, linux), linux is for dark mode",
help="specify terminal color theme (neutral, lightbg, nocolor, linux), linux is for dark mode",
)
parser.add_argument("--confirmexit", "-x", default=False, help="confirm exit")
parser.add_argument("--prompt", "-p", default="(rtb) >>> ", help="input prompt")
parser.add_argument(
"--confirmexit",
"-x",
default=False,
action="store_true",
help="confirm exit",
)
parser.add_argument("--prompt", "-P", default="(rtb) >>> ", help="input prompt")
parser.add_argument(
"--resultprefix",
"-r",
"-R",
default=None,
help="execution result prefix, include {} for execution count number",
)
Expand All @@ -81,7 +116,6 @@ def parse_arguments():
help="enable autoreload of any imported modules, same as IPython's builtin %%autoreload 2",
)
parser.add_argument(
"-b",
"--no-banner",
dest="banner",
default=True,
Expand Down Expand Up @@ -121,20 +155,14 @@ def parse_arguments():
action="store_true",
help="use Swift as default backend",
)
args, rest = parser.parse_known_args()

# remove the arguments we've just parsed from sys.argv so that IPython can have a
# go at them later
sys.argv = [sys.argv[0]] + rest

# TODO more options
# color scheme, light/dark
# silent startup
argv = env_arguments(parser) + sys.argv[1:]
args, rest = parser.parse_known_args(argv)

if args.script is not None:
args.banner = False

return args
return args, rest


def make_banner():
Expand Down Expand Up @@ -194,7 +222,7 @@ def startup():

def main():

args = parse_arguments()
args, ipython_args = parse_arguments()

# setup defaults
np.set_printoptions(
Expand Down Expand Up @@ -269,7 +297,7 @@ def out_prompt_tokens(self, cli=None):
# set configuration options, there are lots, see
# https://ipython.readthedocs.io/en/stable/config/options/terminal.html
c = Config()
c.InteractiveShellEmbed.colors = args.color
c.InteractiveShellEmbed.colors = args.theme
c.InteractiveShell.confirm_exit = args.confirmexit
# c.InteractiveShell.prompts_class = ClassicPrompts
c.InteractiveShell.prompts_class = MyPrompt
Expand Down Expand Up @@ -300,7 +328,10 @@ def out_prompt_tokens(self, cli=None):
c.InteractiveShellApp.exec_lines = code
namespace = {k: v for k, v in globals().items() if not k.startswith("__")}
namespace.update({"puma": puma, "panda": panda})
IPython.start_ipython(config=c, user_ns=namespace)

# clear argv so IPython doesn't try to reparse arguments we've already consumed
sys.argv = sys.argv[:1]
IPython.start_ipython(config=c, user_ns=namespace, argv=ipython_args)


if __name__ == "__main__":
Expand Down
66 changes: 66 additions & 0 deletions tests/test_bin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#!/usr/bin/env python
"""
Smoke tests for command-line entry points in roboticstoolbox.bin.

``--help`` tests verify that imports and argument parsing work and the tool
exits cleanly. Startup tests verify that the tool reaches the interactive
IPython prompt: stdin is closed, so IPython's non-interactive EOF detection
ends the session cleanly instead of blocking, and we check the exit code
and captured output.
"""

import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path

_TIMEOUT = 15


def _run(args: list[str], **kwargs) -> subprocess.CompletedProcess:
"""Run a command via the current Python interpreter's entry-point module."""
return subprocess.run(
[sys.executable, "-m"] + args,
stdin=subprocess.DEVNULL,
capture_output=True,
timeout=_TIMEOUT,
**kwargs,
)


class TestRtbtool(unittest.TestCase):
def test_help(self):
result = _run(["roboticstoolbox.bin.rtbtool", "--help"])
self.assertEqual(result.returncode, 0, msg=result.stderr.decode())

def test_startup(self):
"""Tool should reach the interactive prompt without error."""
result = _run(["roboticstoolbox.bin.rtbtool", "--no-banner"])
self.assertEqual(result.returncode, 0, msg=result.stderr.decode())

def test_missing_script(self):
result = _run(["roboticstoolbox.bin.rtbtool", "/no/such/script.py"])
self.assertNotEqual(result.returncode, 0)
self.assertIn(b"script does not exist", result.stderr)

def test_run_script(self):
"""Script argument should execute with the RTB namespace available."""
with tempfile.TemporaryDirectory() as tmp:
script = Path(tmp) / "sentinel.py"
script.write_text('print("SENTINEL_OUTPUT", panda.name)\n')
result = _run(["roboticstoolbox.bin.rtbtool", str(script)])
self.assertEqual(result.returncode, 0, msg=result.stderr.decode())
self.assertIn(b"SENTINEL_OUTPUT Panda", result.stdout)

def test_options_envvar(self):
"""RTB_OPTIONS should be parsed the same as command-line arguments."""
env = dict(os.environ, RTB_OPTIONS="--prompt envtest>")
result = _run(["roboticstoolbox.bin.rtbtool", "--no-banner"], env=env)
self.assertEqual(result.returncode, 0, msg=result.stderr.decode())
self.assertIn(b"envtest>", result.stdout)


if __name__ == "__main__":
unittest.main()