diff --git a/src/roboticstoolbox/bin/_bintools.py b/src/roboticstoolbox/bin/_bintools.py new file mode 100644 index 000000000..d4ac3174a --- /dev/null +++ b/src/roboticstoolbox/bin/_bintools.py @@ -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") diff --git a/src/roboticstoolbox/bin/rtbtool.py b/src/roboticstoolbox/bin/rtbtool.py index aa599d507..ff4a86c01 100755 --- a/src/roboticstoolbox/bin/rtbtool.py +++ b/src/roboticstoolbox/bin/rtbtool.py @@ -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 @@ -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 @@ -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", ) @@ -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, @@ -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(): @@ -194,7 +222,7 @@ def startup(): def main(): - args = parse_arguments() + args, ipython_args = parse_arguments() # setup defaults np.set_printoptions( @@ -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 @@ -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__": diff --git a/tests/test_bin.py b/tests/test_bin.py new file mode 100644 index 000000000..90147643e --- /dev/null +++ b/tests/test_bin.py @@ -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()