diff --git a/README.md b/README.md index 764d7d0..3a2d62e 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,14 @@ conda install rvc3python There are a lot of dependencies and this might take a minute or so. You now have a very powerful computing environment for robotics and computer vision. +To check everything installed and works correctly, run +```shell +rvctool --test +``` +This is a quick, non-interactive check that prints package versions and exercises one +real code path per toolbox (RTB, MVTB, spatialgeometry, spatialmath, bdsim, and Open3D +if installed), reporting PASS/FAIL for each rather than just "it imported". + ### Python version `rvc3python` requires **Python 3.10 or later**. @@ -129,13 +137,10 @@ for Python (RTB==1.3.1, MVTB==2.3.0, SG==1.3.0, SMTB==1.1.16, NumPy==2.5.2, SciP func/object?? - show source code Results of assignments will be displayed, use trailing ; to suppress - -Python 3.12.8 | packaged by conda-forge -Type 'copyright', 'credits' or 'license' for more information -IPython 9.16.1 -- An enhanced Interactive Python. Type '?' for help. +Default numeric formatting: %.3g ->>> +>>> ``` This provides an interactive Python diff --git a/RVC3/bin/_bintools.py b/RVC3/bin/_bintools.py new file mode 100644 index 0000000..51d69df --- /dev/null +++ b/RVC3/bin/_bintools.py @@ -0,0 +1,61 @@ +""" +Shared helpers for RVC3 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 reflows 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.""" + + +RVC3_URL = "https://github.com/petercorke/RVC3-python" +RVC3_LINK = link(RVC3_URL, "Robotics, Vision & Control 3e: for Python") diff --git a/RVC3/bin/rvctool.py b/RVC3/bin/rvctool.py index c65dcb9..57b0197 100755 --- a/RVC3/bin/rvctool.py +++ b/RVC3/bin/rvctool.py @@ -1,30 +1,36 @@ #!/usr/bin/env python3 +""" +Interactive Robotics, Vision & Control shell -- starts an IPython session +with NumPy, RTB, MVTB, SG and SpatialMath pre-imported. -# a simple Robotics & Vision Toolbox "shell", runs Python3 and loads -# NumPy, SciPy, RTB-P, SMTB-P, MVTB-P. -# -# Run it from the shell -# $ rvctool -# -# Default switches can be set using the environent variables RVCTOOL, eg. -# -# setenv RVCTOOL "-n" -# export RVCTOOL="-n" +Usage:: + + $ rvctool + $ rvctool myscript.py +""" # import stuff +import argparse import os -from pathlib import Path +import shlex import sys -from importlib.metadata import version -import argparse +import textwrap +from importlib.metadata import PackageNotFoundError, version +from math import pi # lgtm [py/unused-import] +import pathlib -from pygments.token import Token -from IPython.terminal.prompts import Prompts -from IPython.terminal.prompts import ClassicPrompts -from traitlets.config import Config -import IPython import matplotlib as mpl +# imports for use by IPython and user +import numpy as np +from scipy import linalg, optimize +import matplotlib.pyplot as plt # lgtm [py/unused-import] +from spatialmath import * # lgtm [py/polluting-import] +from spatialmath.base import * +from spatialmath.base import sym + +from RVC3.bin import _bintools + try: from colored import fg, bg, attr @@ -33,23 +39,54 @@ except ImportError: # print('colored not found') _colored = False + fg = lambda *args, **kwargs: "" + bg = lambda *args, **kwargs: "" + attr = lambda *args, **kwargs: "" -# imports for use by IPython and user -from math import pi # lgtm [py/unused-import] -import numpy as np -from scipy import linalg, optimize -import matplotlib.pyplot as plt # lgtm [py/unused-import] -from spatialmath import * # lgtm [py/polluting-import] -from spatialmath.base import * -from spatialmath.base import sym +_OPTIONS_ENVVAR = "RVCTOOL_OPTIONS" +_LEGACY_OPTIONS_ENVVAR = "RVCTOOL" + + +def env_arguments(parser): + """Return command-line style options from the environment. + + Prefers :data:`_OPTIONS_ENVVAR`; falls back to the deprecated + :data:`_LEGACY_OPTIONS_ENVVAR` (printing a one-line warning) if the + new variable isn't set. + + :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 options is None: + options = os.environ.get(_LEGACY_OPTIONS_ENVVAR) + if options is not None: + print( + f"Warning: the {_LEGACY_OPTIONS_ENVVAR} environment variable is " + f"deprecated, use {_OPTIONS_ENVVAR} instead", + file=sys.stderr, + ) + + 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( prog="rvctool", + formatter_class=_bintools.LineWrapRawTextDefaultsHelpFormatter, epilog=( - "To set defaults put the relevant command line switches as a string in the" - " environment varible RVCTOOL." + "options can be set via the environment variable RVCTOOL_OPTIONS " + "(or the deprecated RVCTOOL), for example:\n\n" + " $ export RVCTOOL_OPTIONS=\"--backend TkAgg --prompt 'rvc> ' " + '--reload"\n' ), description=( "Interactive python enviroment for exploring the Robotics & Machine Vision" @@ -70,7 +107,9 @@ def parse_arguments(): " for dark mode" ), ) - parser.add_argument("--confirmexit", "-x", default=False, help="confirm exit") + parser.add_argument( + "--confirmexit", "-x", default=False, action="store_true", help="confirm exit" + ) parser.add_argument("--prompt", "-p", default=None, help="input prompt") parser.add_argument( "-r", @@ -145,58 +184,131 @@ def parse_arguments(): action="store_true", help="use Swift as default backend", ) + parser.add_argument( + "--torch", + default=False, + action="store_true", + help="import torch and torchvision if installed", + ) + parser.add_argument( + "--reload", + default=False, + action="store_true", + help="enable autoreload of any imported modules, same as IPython's builtin %%autoreload 2", + ) + parser.add_argument( + "--test", + default=False, + action="store_true", + help="non-interactive environment smoke test: print package versions, " + "exercise one real code path per toolbox (RTB, MVTB, SG, SMTB, bdsim, " + "Open3D), exit 0/1 instead of starting an interactive shell", + ) - # add options for light/dark mode + argv = env_arguments(parser) + sys.argv[1:] + args, rest = parser.parse_known_args(argv) - env = os.getenv("RVCTOOL") - if env is not None: - # if envariable is set, parse it just like command line options - args = parser.parse_args(env.split()) - # then use it to set the defaults for the actual command line parsing - parser.set_defaults(**args.__dict__) + if args.script is not None: + args.banner = False - args, rest = parser.parse_known_args() + return args, rest - # 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 - if args.script is not None: - args.banner = False +def optional_torch_imports(enable): + """Optionally import torch and torchvision. - return args + :param enable: if ``True``, attempt optional imports + :type enable: bool + :return: tuple of imported modules dictionary and warning messages + :rtype: tuple(dict, list) + """ + modules = {} + warnings = [] + if not enable: + return modules, warnings -def make_banner(args): - # http://patorjk.com/software/taag/#p=display&f=Standard&t=RVC%203 - # print the banner: standard - # https://patorjk.com/software/taag/#p=display&f=Standard&t=Robotics%2C%20Vision%20%26%20Control%203 + try: + import torch as _torch - banner = fg("yellow") - banner += r""" ____ _ _ _ __ ___ _ ___ ____ _ _ _____ -| _ \ ___ | |__ ___ | |_(_) ___ ___ \ \ / (_)___(_) ___ _ __ ( _ ) / ___|___ _ __ | |_ _ __ ___ | | |___ / -| |_) / _ \| '_ \ / _ \| __| |/ __/ __| \ \ / /| / __| |/ _ \| '_ \ / _ \/\ | | / _ \| '_ \| __| '__/ _ \| | |_ \ -| _ < (_) | |_) | (_) | |_| | (__\__ \_ \ V / | \__ \ | (_) | | | | | (_> < | |__| (_) | | | | |_| | | (_) | | ___) | -|_| \_\___/|_.__/ \___/ \__|_|\___|___( ) \_/ |_|___/_|\___/|_| |_| \___/\/ \____\___/|_| |_|\__|_| \___/|_| |____/ - |/ -for Python""" + modules["torch"] = _torch + except ImportError: + warnings.append("PyTorch (torch) not found") + + try: + import torchvision as _torchvision + + modules["torchvision"] = _torchvision + except ImportError: + warnings.append("TorchVision (torchvision) not found") + + return modules, warnings + + +def get_versions(args, torch_modules=None): + """Package version strings shown in the banner and by --test. + + :param args: parsed command-line arguments + :param torch_modules: optional imported torch/torchvision modules + :type torch_modules: dict, optional + :return: version strings, one per package + :rtype: list[str] + """ + torch_modules = torch_modules or {} - versions = [] + versions = [f"Python=={sys.version.split()[0]}"] if args.robot: versions.append(f"RTB=={version('roboticstoolbox-python')}") if args.vision: versions.append(f"MVTB=={version('machinevision-toolbox-python')}") - try: - versions.append(f"SG=={version('spatialmath-python')}") - except: - pass + versions.append(f"SG=={version('spatialgeometry')}") versions.append(f"SMTB=={version('spatialmath-python')}") + versions.append(f"bdsim=={version('bdsim')}") versions.append(f"NumPy=={version('numpy')}") versions.append(f"SciPy=={version('scipy')}") versions.append(f"Matplotlib=={version('matplotlib')}") + try: + versions.append(f"Open3D=={version('open3d')}") + except PackageNotFoundError: + versions.append("Open3D==not installed") + if "torch" in torch_modules: + versions.append( + f"PyTorch=={getattr(torch_modules['torch'], '__version__', 'unknown')}" + ) + if "torchvision" in torch_modules: + versions.append( + "TorchVision==" + f"{getattr(torch_modules['torchvision'], '__version__', 'unknown')}" + ) + return versions + + +def make_banner(args, torch_modules=None): + # http://patorjk.com/software/taag/#p=display&f=Standard&t=RVC%203 + # print the banner: standard + # https://patorjk.com/software/taag/#p=display&f=Standard&t=Robotics%2C%20Vision%20%26%20Control%203 + + banner = fg("yellow") + banner += r""" ____ _ _ _ __ ___ _ ___ ____ _ _ _____ +| _ \ ___ | |__ ___ | |_(_) ___ ___ \ \ / (_)___(_) ___ _ __ ( _ ) / ___|___ _ __ | |_ _ __ ___ | | |___ / +| |_) / _ \| '_ \ / _ \| __| |/ __/ __| \ \ / /| / __| |/ _ \| '_ \ / _ \/\ | | / _ \| '_ \| __| '__/ _ \| | |_ \ +| _ < (_) | |_) | (_) | |_| | (__\__ \_ \ V / | \__ \ | (_) | | | | | (_> < | |__| (_) | | | | |_| | | (_) | | ___) | +|_| \_\___/|_.__/ \___/ \__|_|\___|___( ) \_/ |_|___/_|\___/|_| |_| \___/\/ \____\___/|_| |_|\__|_| \___/|_| |____/ + |/ +for Python + +""" + + versions = "You're running: " + ", ".join(get_versions(args, torch_modules)) + banner += "\n".join( + textwrap.wrap( + versions, + break_long_words=False, + subsequent_indent=" " * len("You're running: "), + width=80, + ) + ) - # create banner - banner += " (" + ", ".join(versions) + ")" banner += r""" import math @@ -206,12 +318,13 @@ def make_banner(args): from spatialmath import * from spatialmath.base import * from spatialmath.base import sym - from spatialgeometry import * """ if args.robot: - banner += "from roboticstoolbox import *\n" + banner += """from spatialgeometry import * + from roboticstoolbox import * + """ if args.vision: - banner += """ from machinevisiontoolbox import * + banner += """from machinevisiontoolbox import * import machinevisiontoolbox.base as mvb """ @@ -230,13 +343,135 @@ def make_banner(args): return banner +def run_smoke_test(args) -> bool: + """Non-interactive environment sanity check, used by --test. + + Not a substitute for the pytest suite -- a fast, human- or script-run + "did this environment actually come together correctly" check: real + versions, and one real result per toolbox, checked against a sanity + condition rather than just "it didn't raise". Missing optional + dependencies (e.g. Open3D) are reported as a FAIL with the reason, not + silently skipped. + + :param args: parsed command-line arguments + :return: ``True`` if every check passed + :rtype: bool + """ + # force a non-interactive backend so this never pops a GUI window + mpl.use("Agg", force=True) + + print(", ".join(get_versions(args))) + + checks: list[tuple[str, bool]] = [] + + try: + from roboticstoolbox import models + + panda = models.DH.Panda() + T = panda.fkine(panda.qz) + ok = T.shape == (4, 4) and np.isclose(np.linalg.det(T.R), 1.0) + checks.append(("RTB: Panda().fkine() (kinematics)", ok)) + except Exception as e: + checks.append((f"RTB: Panda().fkine() (kinematics): {e}", False)) + + try: + from machinevisiontoolbox import Image + + img = Image.Read("monalisa.png", mono=True) + smoothed = img.smooth(sigma=2) + ok = smoothed.shape == img.shape and not np.array_equal( + smoothed.array, img.array + ) + checks.append(("MVTB: Image.Read + smooth() (OpenCV-backed)", ok)) + except Exception as e: + checks.append((f"MVTB: Image.Read + smooth() (OpenCV-backed): {e}", False)) + + try: + from spatialgeometry import Cuboid + + cube = Cuboid(scale=[1, 1, 1]) + ok = list(cube.scale) == [1, 1, 1] + checks.append(("SG: Cuboid() (shape creation)", ok)) + except Exception as e: + checks.append((f"SG: Cuboid() (shape creation): {e}", False)) + + try: + T = SE3.Rx(pi / 2) * SE3.Rx(-pi / 2) + ok = np.allclose(T.A, np.eye(4), atol=1e-9) + checks.append(("SMTB: SE3 composition (identity check)", ok)) + except Exception as e: + checks.append((f"SMTB: SE3 composition (identity check): {e}", False)) + + try: + import open3d # noqa: F401 -- presence check + except ImportError as e: + checks.append((f"Open3D: point cloud support: not installed ({e})", False)) + else: + try: + import open3d as o3d + + pcd = o3d.geometry.PointCloud() + pcd.points = o3d.utility.Vector3dVector(np.random.rand(10, 3)) + checks.append(("Open3D: PointCloud creation", len(pcd.points) == 10)) + except Exception as e: + checks.append((f"Open3D: PointCloud creation: {e}", False)) + + try: + import importlib + + import RVC3 + + models_dir = pathlib.Path(RVC3.__path__[0]) / "models" + if str(models_dir) not in sys.path: + sys.path.insert(0, str(models_dir)) + # bdsim's own BDSim()/run() read sys.argv for their own flags (-g, -H, + # ...), both at BDSim() construction (module import time here) and at + # run() -- clear it for both so rvctool's own arguments aren't + # misread as bdsim's. + saved_argv, sys.argv = sys.argv, sys.argv[:1] + try: + vloop_test = importlib.import_module("vloop_test") + # both are read dynamically at run() time, not baked in at + # construction, so overriding them post-import is safe + vloop_test.sim.options.quiet = True + vloop_test.sim.options.graphics = False + out = vloop_test.sim.run(vloop_test.bd, 0.1, dt=1e-3) + finally: + sys.argv = saved_argv + ok = np.isclose(out.t[-1], 0.1) and out.x.shape[0] > 0 + checks.append(("bdsim: vloop_test block diagram run", ok)) + except Exception as e: + checks.append((f"bdsim: vloop_test block diagram run: {e}", False)) + + for name, passed in checks: + print(f"[{'PASS' if passed else 'FAIL'}] {name}") + + n_passed = sum(1 for _, passed in checks if passed) + print(f"rvctool --test: {n_passed}/{len(checks)} checks passed") + return n_passed == len(checks) + + def startup(): plt.ion() def main(): - args = parse_arguments() - # print(args) + args, ipython_args = parse_arguments() + + if args.test: + sys.exit(0 if run_smoke_test(args) else 1) + + try: + import IPython + from IPython.terminal.prompts import Prompts + from pygments.token import Token + from traitlets.config import Config + except ImportError as e: + sys.exit( + f"rvctool requires IPython and pygments, which are not " + f"installed ({e}).\nInstall them with:\n\n" + " pip install rvc3python[tool]\n" + ) if args.book: # set book options @@ -252,13 +487,15 @@ def main(): formatter={"float": lambda x: f"{x:8.4g}" if abs(x) > 1e-10 else f"{0:8.4g}"}, ) + torch_modules, torch_warnings = optional_torch_imports(args.torch) + globs = globals() if args.robot: exec("from spatialgeometry import *", globs) exec("from roboticstoolbox import *", globs) from roboticstoolbox import __path__ - sys.path.append(str(Path(__path__[0]) / "examples")) + sys.path.append(str(pathlib.Path(__path__[0]) / "examples")) # load some robot models globs["puma"] = models.DH.Puma560() @@ -270,7 +507,9 @@ def main(): if args.vision: exec("from machinevisiontoolbox import *", globs) - exec("import machinevisiontoolbox.base as mvbase", globs) + exec("import machinevisiontoolbox.base as mvb", globs) + + globs.update(torch_modules) # set matrix printing mode for spatialmath SE3._ansimatrix = args.ansi @@ -281,9 +520,12 @@ def main(): mpl.use(args.backend) if args.banner: - banner = make_banner(args) + banner = make_banner(args, torch_modules) print(banner) + for warning in torch_warnings: + print(f"Warning: {warning}") + if args.showassign and args.banner: print( fg("red") @@ -295,7 +537,7 @@ def main(): # append to the module path # - RVC3 models and examples # - RTB examples - root = Path(__file__).absolute().parent.parent + root = pathlib.Path(__file__).absolute().parent.parent sys.path.append(str(root / "models")) sys.path.append(str(root / "examples")) @@ -323,11 +565,12 @@ def out_prompt_tokens(self, cli=None): c = Config() c.InteractiveShellEmbed.colors = args.color c.InteractiveShell.confirm_exit = args.confirmexit - # c.InteractiveShell.prompts_class = ClassicPrompts c.InteractiveShell.prompts_class = MyPrompt if args.showassign: c.InteractiveShell.ast_node_interactivity = "last_expr_or_assign" - c.TerminalIPythonApp.display_banner = args.banner + # rvctool prints its own banner above (when args.banner is set); IPython's + # own generic banner would just be redundant noise stacked underneath it + c.TerminalIPythonApp.display_banner = False # set precision, same as %precision c.PlainTextFormatter.float_precision = "%.3f" @@ -335,7 +578,7 @@ def out_prompt_tokens(self, cli=None): # set up a script to be executed by IPython when we get there code = None if args.script is not None: - path = Path(args.script) + path = pathlib.Path(args.script) if not path.exists(): raise ValueError(f"script does not exist: {args.script}") code = path.open("r").readlines() @@ -343,11 +586,18 @@ def out_prompt_tokens(self, cli=None): if code is None: code = [ "startup()", - "%precision %.3g;", + "_prec = get_ipython().run_line_magic('precision', '%.3g'); " + "print(f'Default numeric formatting: {_prec}')", ] + if args.reload: + code = ["%load_ext autoreload", "%autoreload 2"] + code + c.InteractiveShellApp.exec_lines = code - IPython.start_ipython(config=c, user_ns=globals()) + + # 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=globs, argv=ipython_args) if __name__ == "__main__": diff --git a/tests/test_bin.py b/tests/test_bin.py new file mode 100644 index 0000000..d91868d --- /dev/null +++ b/tests/test_bin.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python +""" +Smoke tests for command-line entry points in RVC3.bin. + +``--help`` verifies that imports and argument parsing work and the tool +exits cleanly. ``--test`` runs rvctool's own non-interactive environment +check and verifies its PASS/FAIL reporting and exit code. +""" + +import subprocess +import sys +import unittest +from importlib.metadata import PackageNotFoundError, version + +try: + version("open3d") + _open3d_available = True +except PackageNotFoundError: + _open3d_available = False + + +def _run(args: list[str], timeout: float | None = None) -> subprocess.CompletedProcess: + """Run a command via the current Python interpreter's entry-point module.""" + return subprocess.run( + [sys.executable, "-m"] + args, + capture_output=True, + timeout=timeout, + ) + + +class TestRvctool(unittest.TestCase): + + def test_help(self): + result = _run(["RVC3.bin.rvctool", "--help"]) + self.assertEqual(result.returncode, 0, msg=result.stderr.decode()) + + def test_smoke_test(self): + """--test always exercises RTB, MVTB, SG, SMTB and bdsim; Open3D's + result depends on whether it's installed in this environment, but + either way it must be reported explicitly, not silently skipped.""" + result = _run(["RVC3.bin.rvctool", "--test"], timeout=60) + stdout = result.stdout.decode() + self.assertIn("[PASS] RTB: Panda().fkine()", stdout, msg=stdout) + self.assertIn("[PASS] MVTB: Image.Read + smooth()", stdout, msg=stdout) + self.assertIn("[PASS] SG: Cuboid()", stdout, msg=stdout) + self.assertIn("[PASS] SMTB: SE3 composition", stdout, msg=stdout) + self.assertIn("[PASS] bdsim: vloop_test block diagram run", stdout, msg=stdout) + if _open3d_available: + self.assertIn("[PASS] Open3D: PointCloud creation", stdout, msg=stdout) + self.assertEqual(result.returncode, 0, msg=stdout) + else: + self.assertIn("Open3D: point cloud support: not installed", stdout, msg=stdout) + self.assertEqual(result.returncode, 1, msg=stdout) + + +if __name__ == "__main__": + unittest.main()