diff --git a/robotpy/main.py b/robotpy/main.py index d764a30..6cf4a1e 100644 --- a/robotpy/main.py +++ b/robotpy/main.py @@ -209,21 +209,16 @@ def _make_subcommands( cmdparser.set_defaults(cmdobj=obj) -def main() -> typing.NoReturn: - """ - This function loads available entry points, parses arguments, and - sets things up specific to RobotPy so that the robot can run. This - function is used whether the code is running on the roboRIO or - a simulation. - """ +def _run( + args: typing.List[str], cmds: typing.List[typing.Tuple[str, typing.Any]] +) -> typing.Any: + """Parse arguments and run the selected command.""" parser = argparse.ArgumentParser( formatter_class=argparse.RawDescriptionHelpFormatter, - description=inspect.cleandoc( - """ + description=inspect.cleandoc(""" RobotPy CLI. See below for subcommands to accomplish various tasks for your robot project. - """ - ), + """), ) # This allows the user to name their robot.py file something different @@ -251,28 +246,6 @@ def main() -> typing.NoReturn: help="Ignore errors caused by RobotPy plugins (probably should fix or replace instead!)", ) - has_cmd = False - - cmds: typing.List[typing.Tuple[str, typing.Any]] = [] - - for entry_point in entry_points(group="robotpy_cli.2027"): - try: - cmd_class = entry_point.load() - except Exception: - if "--ignore-plugin-errors" in sys.argv: - print(f"WARNING: Ignoring error in '{entry_point}'") - continue - else: - traceback.print_exc(file=sys.stderr) - print( - f"Plugin error detected in '{entry_point}' (use " - "--ignore-plugin-errors to ignore this)", - file=sys.stderr, - ) - sys.exit(1) - - cmds.append((entry_point.name, cmd_class)) - _make_subcommands(parser, cmds, "command") if not cmds: @@ -281,7 +254,7 @@ def main() -> typing.NoReturn: ) sys.exit(1) - options = parser.parse_args() + options = parser.parse_args(args) if options.command is None or getattr(options, "cmdobj", None) is None: getattr(options, "parser", parser).print_help() sys.exit(1) @@ -338,4 +311,35 @@ def main() -> typing.NoReturn: elif retval is False: retval = 1 - sys.exit(retval) + return retval + + +def main() -> typing.NoReturn: + """ + This function loads available entry points, parses arguments, and + sets things up specific to RobotPy so that the robot can run. This + function is used whether the code is running on the roboRIO or + a simulation. + """ + + cmds: typing.List[typing.Tuple[str, typing.Any]] = [] + + for entry_point in entry_points(group="robotpy_cli.2027"): + try: + cmd_class = entry_point.load() + except Exception: + if "--ignore-plugin-errors" in sys.argv: + print(f"WARNING: Ignoring error in '{entry_point}'") + continue + else: + traceback.print_exc(file=sys.stderr) + print( + f"Plugin error detected in '{entry_point}' (use " + "--ignore-plugin-errors to ignore this)", + file=sys.stderr, + ) + sys.exit(1) + + cmds.append((entry_point.name, cmd_class)) + + sys.exit(_run(sys.argv[1:], cmds)) diff --git a/tests/test_argparse.py b/tests/test_argparse.py new file mode 100644 index 0000000..c52d606 --- /dev/null +++ b/tests/test_argparse.py @@ -0,0 +1,185 @@ +import logging + +import pytest + +from robotpy import main + + +@pytest.fixture(autouse=True) +def restore_global_state(): + handlers = logging.root.handlers[:] + level = logging.root.level + robot_py_path = main.robot_py_path + yield + logging.root.handlers[:] = handlers + logging.root.setLevel(level) + main.robot_py_path = robot_py_path + + +def test_command_arguments_are_dispatched(tmp_path): + received = {} + + class Command: + def __init__(self, parser): + parser.add_argument("--count", required=True, type=int) + + def run(self, count): + received["count"] = count + + exit_code = main._run( + ["--main", str(tmp_path), "sample", "--count", "3"], + [("sample", Command)], + ) + + assert exit_code == 0 + assert received == {"count": 3} + + +def test_nested_subcommand_is_dispatched(): + received = [] + + class Echo: + """Echo a value.""" + + def __init__(self, parser): + parser.add_argument("value") + + def run(self, value): + received.append(value) + + class Tools: + """Tool commands.""" + + subcommands = [("echo", Echo)] + + exit_code = main._run(["tools", "echo", "hello"], [("tools", Tools)]) + + assert exit_code == 0 + assert received == ["hello"] + + +def test_missing_top_level_command_prints_root_help(capsys): + class Command: + def __init__(self, parser): + pass + + def run(self): + raise AssertionError("command should not run") + + with pytest.raises(SystemExit) as excinfo: + main._run([], [("sample", Command)]) + + assert excinfo.value.code == 1 + output = capsys.readouterr().out + assert "usage:" in output + assert "sample" in output + + +def test_missing_nested_subcommand_prints_group_help(capsys): + class Echo: + """Echo a value.""" + + def __init__(self, parser): + pass + + def run(self): + raise AssertionError("command should not run") + + class Tools: + """Tool commands.""" + + subcommands = [("echo", Echo)] + + with pytest.raises(SystemExit) as excinfo: + main._run(["tools"], [("tools", Tools)]) + + assert excinfo.value.code == 1 + output = capsys.readouterr().out + assert "usage:" in output + assert "echo" in output + + +def test_special_arguments_are_injected(tmp_path): + received = {} + + class Command: + def __init__(self, parser): + parser.add_argument("--label", required=True) + + def run( + self, + label, + options, + main_file, + project_path, + load_robot_class, + ): + received.update( + label=label, + verbose=options.verbose, + ignore_plugin_errors=options.ignore_plugin_errors, + main_file=main_file, + project_path=project_path, + load_robot_class=load_robot_class, + ) + + exit_code = main._run( + [ + "--main", + str(tmp_path), + "--verbose", + "--ignore-plugin-errors", + "inspect", + "--label", + "robot", + ], + [("inspect", Command)], + ) + + assert exit_code == 0 + assert received == { + "label": "robot", + "verbose": True, + "ignore_plugin_errors": True, + "main_file": tmp_path / "robot.py", + "project_path": tmp_path, + "load_robot_class": main._load_robot_class, + } + + +@pytest.mark.parametrize( + ("return_value", "expected_exit_code"), + [(None, 0), (True, 0), (False, 1), (7, 7)], +) +def test_command_return_value_is_normalized(return_value, expected_exit_code): + class Command: + def __init__(self, parser): + pass + + def run(self): + return return_value + + assert main._run(["sample"], [("sample", Command)]) == expected_exit_code + + +def test_positional_only_run_argument_is_rejected(): + class Command: + def __init__(self, parser): + pass + + def run(self, value, /): + raise AssertionError("command should not run") + + with pytest.raises( + ValueError, + match="subcommands may only have keyword or normal arguments", + ): + main._run(["sample"], [("sample", Command)]) + + +def test_no_registered_commands_is_an_argument_error(capsys): + with pytest.raises(SystemExit) as excinfo: + main._run([], []) + + assert excinfo.value.code == 2 + assert "No entry points defined" in capsys.readouterr().err diff --git a/tests/test_robot_case.py b/tests/test_robot_case.py index 6d2bd3a..66850e0 100644 --- a/tests/test_robot_case.py +++ b/tests/test_robot_case.py @@ -19,15 +19,11 @@ def mock_wpilib(monkeypatch): def test_load_robot_class_exact_case(tmp_path, monkeypatch, capsys): # create correct-case file robot_py = tmp_path / "robot.py" - robot_py.write_text( - textwrap.dedent( - """ + robot_py.write_text(textwrap.dedent(""" import wpilib class MyRobot(wpilib.RobotBase): pass - """ - ) - ) + """)) monkeypatch.setattr(main, "robot_py_path", robot_py) @@ -38,15 +34,11 @@ class MyRobot(wpilib.RobotBase): def test_load_robot_class_wrong_case(tmp_path, monkeypatch, capsys): robot_py = tmp_path / "robot.py" Robot_py = tmp_path / "Robot.py" - Robot_py.write_text( - textwrap.dedent( - """ + Robot_py.write_text(textwrap.dedent(""" import wpilib class MyRobot(wpilib.RobotBase): pass - """ - ) - ) + """)) case_insensitive_fs = robot_py.exists()