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
74 changes: 39 additions & 35 deletions robotpy/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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))
185 changes: 185 additions & 0 deletions tests/test_argparse.py
Original file line number Diff line number Diff line change
@@ -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
16 changes: 4 additions & 12 deletions tests/test_robot_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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()

Expand Down
Loading