From 571e42cd23a000a24a531333c927059901870ee2 Mon Sep 17 00:00:00 2001 From: Dustin Spicuzza Date: Wed, 6 May 2026 05:35:43 +0000 Subject: [PATCH 1/8] feat: add Python opmode decorators and discovery --- .../robotpy-wpilib/tests/test_opmode_robot.py | 104 ++++++++- subprojects/robotpy-wpilib/wpilib/__init__.py | 4 +- .../robotpy-wpilib/wpilib/opmoderobot.py | 198 +++++++++++++++++- 3 files changed, 300 insertions(+), 6 deletions(-) diff --git a/subprojects/robotpy-wpilib/tests/test_opmode_robot.py b/subprojects/robotpy-wpilib/tests/test_opmode_robot.py index b21cfe7d4..0bce3fd8b 100644 --- a/subprojects/robotpy-wpilib/tests/test_opmode_robot.py +++ b/subprojects/robotpy-wpilib/tests/test_opmode_robot.py @@ -1,9 +1,10 @@ +import importlib import pytest import threading from wpilib import simulation as wsim from wpimath.units import seconds -from wpilib.opmoderobot import OpModeRobot -from wpilib import OpMode, RobotState +from wpilib.opmoderobot import OpModeRobot, autonomous, teleop, utility +from wpilib import OpMode, RobotState, autonomous as top_level_autonomous from hal._wpiHal import RobotMode from wpiutil import Color @@ -62,6 +63,45 @@ def sim_timing_setup(): RobotState.clearOpModes() +def test_opmode_decorators_attach_metadata(): + @autonomous(group="Drive", description="Auto desc") + class AutoMode(OpMode): + pass + + metadata = AutoMode.__wpilib_opmode_metadata__ + assert metadata.mode == RobotMode.AUTONOMOUS + assert metadata.name == "AutoMode" + assert metadata.group == "Drive" + assert metadata.description == "Auto desc" + + +def test_opmode_decorator_rejects_multiple_modes(): + with pytest.raises(ValueError, match="multiple opmode decorators"): + + @teleop + @autonomous + class BadMode(OpMode): + pass + + +def test_opmode_decorator_preserves_explicit_metadata(): + @utility( + name="Arm Test", + group="Mechanisms", + description="tests arm", + textColor=Color.WHITE, + backgroundColor=Color.BLACK, + ) + class UtilityMode(OpMode): + pass + + metadata = UtilityMode.__wpilib_opmode_metadata__ + assert metadata.name == "Arm Test" + assert metadata.textColor == Color.WHITE + assert metadata.backgroundColor == Color.BLACK + assert top_level_autonomous is autonomous + + def test_add_op_mode(): class MyMockRobot(MockRobot): def __init__(self): @@ -187,3 +227,63 @@ def test_robot_periodic(periodic_robot_test_fixture): # Additional time steps should continue calling RobotPeriodic wsim.stepTiming(kPeriod) assert robot.periodic_count == 2 + + +def test_opmode_robot_auto_discovers_decorated_modules(tmp_path, monkeypatch): + pkg = tmp_path / "samplebot" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "robot.py").write_text("""\ +from wpilib.opmoderobot import OpModeRobot +class Robot(OpModeRobot): + def __init__(self): + super().__init__() +""") + (pkg / "default_auto_mode.py").write_text("""\ +from wpilib import PeriodicOpMode +from wpilib.opmoderobot import autonomous +@autonomous +class DefaultAutoMode(PeriodicOpMode): + pass +""") + (pkg / "default_tele_mode.py").write_text("""\ +from wpilib import PeriodicOpMode +from wpilib.opmoderobot import teleop +@teleop +class DefaultTeleMode(PeriodicOpMode): + pass +""") + + monkeypatch.syspath_prepend(str(tmp_path)) + module = importlib.import_module("samplebot.robot") + robot = module.Robot() + + options = wsim.DriverStationSim.getOpModeOptions() + assert {opt.name for opt in options} == {"DefaultAutoMode", "DefaultTeleMode"} + + +def test_opmode_robot_skips_non_candidate_files(tmp_path, monkeypatch): + pkg = tmp_path / "safeimportbot" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "robot.py").write_text("""\ +from wpilib.opmoderobot import OpModeRobot +class Robot(OpModeRobot): + def __init__(self): + super().__init__() +""") + (pkg / "default_auto_mode.py").write_text("""\ +from wpilib import PeriodicOpMode +from wpilib.opmoderobot import autonomous +@autonomous +class DefaultAutoMode(PeriodicOpMode): + pass +""") + (pkg / "helper.py").write_text("raise RuntimeError('should not import')\n") + + monkeypatch.syspath_prepend(str(tmp_path)) + module = importlib.import_module("safeimportbot.robot") + module.Robot() + + options = wsim.DriverStationSim.getOpModeOptions() + assert {opt.name for opt in options} == {"DefaultAutoMode"} diff --git a/subprojects/robotpy-wpilib/wpilib/__init__.py b/subprojects/robotpy-wpilib/wpilib/__init__.py index da8a19cef..274e08cc1 100644 --- a/subprojects/robotpy-wpilib/wpilib/__init__.py +++ b/subprojects/robotpy-wpilib/wpilib/__init__.py @@ -234,9 +234,9 @@ del _init__wpilib -from .opmoderobot import OpModeRobot +from .opmoderobot import OpModeRobot, autonomous, teleop, utility -__all__ += ["OpModeRobot"] +__all__ += ["OpModeRobot", "autonomous", "teleop", "utility"] from .cameraserver import CameraServer from .deployinfo import getDeployData diff --git a/subprojects/robotpy-wpilib/wpilib/opmoderobot.py b/subprojects/robotpy-wpilib/wpilib/opmoderobot.py index f81a13a4f..906c544d3 100644 --- a/subprojects/robotpy-wpilib/wpilib/opmoderobot.py +++ b/subprojects/robotpy-wpilib/wpilib/opmoderobot.py @@ -1,12 +1,205 @@ +import ast +import importlib +import inspect +from dataclasses import dataclass from hal import RobotMode -from typing import Optional +from pathlib import Path +from typing import Callable, Optional, TypeVar, overload from wpiutil import Color -__all__ = ["OpModeRobot"] +__all__ = ["OpModeRobot", "autonomous", "teleop", "utility"] from ._wpilib import OpModeRobotBase, OpMode +@dataclass(frozen=True) +class _OpModeMetadata: + mode: RobotMode + name: str | None + group: str | None + description: str | None + textColor: Color | None + backgroundColor: Color | None + + +_OpModeType = TypeVar("_OpModeType", bound=type[OpMode]) +_DECORATOR_NAMES = {"autonomous", "teleop", "utility"} + + +def _attach_opmode_metadata( + cls: _OpModeType, + *, + mode: RobotMode, + name: str | None = None, + group: str | None = None, + description: str | None = None, + textColor: Color | None = None, + backgroundColor: Color | None = None, +) -> _OpModeType: + if hasattr(cls, "__wpilib_opmode_metadata__"): + raise ValueError("multiple opmode decorators are not allowed") + + cls.__wpilib_opmode_metadata__ = _OpModeMetadata( + mode=mode, + name=name or cls.__name__, + group=group, + description=description, + textColor=textColor, + backgroundColor=backgroundColor, + ) + return cls + + +def _make_opmode_decorator(mode: RobotMode, _cls=None, **kwargs): + def decorator(cls: _OpModeType) -> _OpModeType: + return _attach_opmode_metadata(cls, mode=mode, **kwargs) + + if _cls is None: + return decorator + return decorator(_cls) + + +@overload +def autonomous(cls: _OpModeType, /) -> _OpModeType: ... + + +@overload +def autonomous( + cls: None = None, + /, + *, + name: str | None = None, + group: str | None = None, + description: str | None = None, + textColor: Color | None = None, + backgroundColor: Color | None = None, +) -> Callable[[_OpModeType], _OpModeType]: ... + + +def autonomous(_cls=None, **kwargs): + return _make_opmode_decorator(RobotMode.AUTONOMOUS, _cls, **kwargs) + + +@overload +def teleop(cls: _OpModeType, /) -> _OpModeType: ... + + +@overload +def teleop( + cls: None = None, + /, + *, + name: str | None = None, + group: str | None = None, + description: str | None = None, + textColor: Color | None = None, + backgroundColor: Color | None = None, +) -> Callable[[_OpModeType], _OpModeType]: ... + + +def teleop(_cls=None, **kwargs): + return _make_opmode_decorator(RobotMode.TELEOPERATED, _cls, **kwargs) + + +@overload +def utility(cls: _OpModeType, /) -> _OpModeType: ... + + +@overload +def utility( + cls: None = None, + /, + *, + name: str | None = None, + group: str | None = None, + description: str | None = None, + textColor: Color | None = None, + backgroundColor: Color | None = None, +) -> Callable[[_OpModeType], _OpModeType]: ... + + +def utility(_cls=None, **kwargs): + return _make_opmode_decorator(RobotMode.UTILITY, _cls, **kwargs) + + +def _decorator_name(node: ast.expr) -> str | None: + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Call): + return _decorator_name(node.func) + if isinstance(node, ast.Attribute): + return node.attr + return None + + +def _contains_opmode_decorator(path: Path) -> bool: + source = path.read_text() + tree = ast.parse(source, filename=str(path)) + + for stmt in tree.body: + if not isinstance(stmt, ast.ClassDef): + continue + for decorator in stmt.decorator_list: + if _decorator_name(decorator) in _DECORATOR_NAMES: + return True + return False + + +def _iter_scan_files(root: Path): + for path in root.rglob("*.py"): + if any(part.startswith(".") for part in path.parts): + continue + if "__pycache__" in path.parts: + continue + yield path + + +def _module_name_from_path(robot_module, scan_root: Path, path: Path) -> str: + relative_path = path.relative_to(scan_root) + parts = list(relative_path.with_suffix("").parts) + if parts and parts[-1] == "__init__": + parts.pop() + + package = robot_module.__spec__.parent if robot_module.__spec__ is not None else "" + if package: + return ".".join([package, *parts]) if parts else package + return ".".join(parts) + + +def _discover_decorated_opmodes(robot: "OpModeRobot") -> None: + robot_module = inspect.getmodule(type(robot)) + if robot_module is None or getattr(robot_module, "__file__", None) is None: + return + + scan_root = Path(robot_module.__file__).resolve().parent + discovered: set[type] = set() + + for path in _iter_scan_files(scan_root): + if not _contains_opmode_decorator(path): + continue + + module_name = _module_name_from_path(robot_module, scan_root, path) + module = importlib.import_module(module_name) + for value in vars(module).values(): + metadata = getattr(value, "__wpilib_opmode_metadata__", None) + if metadata is None or not inspect.isclass(value): + continue + if value in discovered: + continue + discovered.add(value) + robot.addOpMode( + value, + metadata.mode, + metadata.name or value.__name__, + metadata.group, + metadata.description, + metadata.textColor, + metadata.backgroundColor, + ) + + robot.publishOpModes() + + class OpModeRobot(OpModeRobotBase): """ OpModeRobot implements the opmode-based robot program framework. @@ -23,6 +216,7 @@ class OpModeRobot(OpModeRobotBase): def __init__(self): super().__init__() + _discover_decorated_opmodes(self) def addOpMode( self, From cf18107b32c13e6332674b91b4d931e518cbfbc2 Mon Sep 17 00:00:00 2001 From: Dustin Spicuzza Date: Wed, 6 May 2026 05:45:37 +0000 Subject: [PATCH 2/8] feat: validate discovered Python opmodes --- .../ExpansionHubSample/defaultautomode.py | 31 ++++ .../ExpansionHubSample/defaulttelemode.py | 24 ++++ examples/robot/ExpansionHubSample/robot.py | 20 +++ examples/robot/examples.toml | 1 + .../robotpy-wpilib/tests/test_opmode_robot.py | 133 ++++++++++++++++++ .../robotpy-wpilib/wpilib/opmoderobot.py | 42 +++++- 6 files changed, 244 insertions(+), 7 deletions(-) create mode 100644 examples/robot/ExpansionHubSample/defaultautomode.py create mode 100644 examples/robot/ExpansionHubSample/defaulttelemode.py create mode 100644 examples/robot/ExpansionHubSample/robot.py diff --git a/examples/robot/ExpansionHubSample/defaultautomode.py b/examples/robot/ExpansionHubSample/defaultautomode.py new file mode 100644 index 000000000..5bcc1a843 --- /dev/null +++ b/examples/robot/ExpansionHubSample/defaultautomode.py @@ -0,0 +1,31 @@ +# +# Copyright (c) FIRST and other WPILib contributors. +# Open Source Software; you can modify and/or share it under the terms of +# the WPILib BSD license file in the root directory of this project. +# + +from wpilib import PeriodicOpMode, Timer +from wpilib.opmoderobot import autonomous + + +@autonomous +class DefaultAutoMode(PeriodicOpMode): + def __init__(self, robot): + super().__init__() + self.robot = robot + self.timer = Timer() + + def start(self): + self.timer.reset() + self.timer.start() + + def periodic(self): + if self.timer.get() < 2.0: + self.robot.motor0.setThrottle(0.5) + self.robot.motor1.setThrottle(0.5) + elif self.timer.get() < 4.0: + self.robot.motor0.setThrottle(0.9) + self.robot.motor1.setThrottle(0.9) + else: + self.robot.motor0.setThrottle(0.0) + self.robot.motor1.setThrottle(0.0) diff --git a/examples/robot/ExpansionHubSample/defaulttelemode.py b/examples/robot/ExpansionHubSample/defaulttelemode.py new file mode 100644 index 000000000..a61c5d281 --- /dev/null +++ b/examples/robot/ExpansionHubSample/defaulttelemode.py @@ -0,0 +1,24 @@ +# +# Copyright (c) FIRST and other WPILib contributors. +# Open Source Software; you can modify and/or share it under the terms of +# the WPILib BSD license file in the root directory of this project. +# + +from wpilib import Gamepad, PeriodicOpMode +from wpilib.opmoderobot import teleop + + +@teleop +class DefaultTeleMode(PeriodicOpMode): + def __init__(self, robot): + super().__init__() + self.robot = robot + self.gamepad = Gamepad(0) + + def periodic(self): + self.robot.motor0.setThrottle(-self.gamepad.getLeftY()) + self.robot.motor1.setThrottle(-self.gamepad.getRightY()) + self.robot.motor2.setThrottle(-self.gamepad.getLeftX()) + self.robot.motor3.setThrottle(-self.gamepad.getRightX()) + self.robot.servo0.setPosition(self.gamepad.getLeftTriggerAxis()) + self.robot.servo1.setPosition(self.gamepad.getRightTriggerAxis()) diff --git a/examples/robot/ExpansionHubSample/robot.py b/examples/robot/ExpansionHubSample/robot.py new file mode 100644 index 000000000..34ecaa29d --- /dev/null +++ b/examples/robot/ExpansionHubSample/robot.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +# +# Copyright (c) FIRST and other WPILib contributors. +# Open Source Software; you can modify and/or share it under the terms of +# the WPILib BSD license file in the root directory of this project. +# + +from wpilib import ExpansionHubMotor, ExpansionHubServo +from wpilib.opmoderobot import OpModeRobot + + +class Robot(OpModeRobot): + def __init__(self): + super().__init__() + self.motor0 = ExpansionHubMotor(0, 0) + self.motor1 = ExpansionHubMotor(0, 1) + self.motor2 = ExpansionHubMotor(0, 2) + self.motor3 = ExpansionHubMotor(0, 3) + self.servo0 = ExpansionHubServo(0, 0) + self.servo1 = ExpansionHubServo(0, 1) diff --git a/examples/robot/examples.toml b/examples/robot/examples.toml index 29c5587c4..75408c541 100644 --- a/examples/robot/examples.toml +++ b/examples/robot/examples.toml @@ -19,6 +19,7 @@ base = [ "ElevatorTrapezoidProfile", "Encoder", "EventLoop", + "ExpansionHubSample", "FlywheelBangBangController", "GettingStarted", "Gyro", diff --git a/subprojects/robotpy-wpilib/tests/test_opmode_robot.py b/subprojects/robotpy-wpilib/tests/test_opmode_robot.py index 0bce3fd8b..8ad0a511e 100644 --- a/subprojects/robotpy-wpilib/tests/test_opmode_robot.py +++ b/subprojects/robotpy-wpilib/tests/test_opmode_robot.py @@ -287,3 +287,136 @@ class DefaultAutoMode(PeriodicOpMode): options = wsim.DriverStationSim.getOpModeOptions() assert {opt.name for opt in options} == {"DefaultAutoMode"} + + +def test_opmode_robot_fails_on_syntax_error_in_scan_tree(tmp_path, monkeypatch): + pkg = tmp_path / "brokenbot" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "robot.py").write_text("""\ +from wpilib.opmoderobot import OpModeRobot +class Robot(OpModeRobot): + def __init__(self): + super().__init__() +""") + (pkg / "broken.py").write_text("def nope(:\n") + + monkeypatch.syspath_prepend(str(tmp_path)) + module = importlib.import_module("brokenbot.robot") + + with pytest.raises(RuntimeError, match="broken.py"): + module.Robot() + + +def test_opmode_robot_fails_on_candidate_import_error(tmp_path, monkeypatch): + pkg = tmp_path / "importbrokenbot" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "robot.py").write_text("""\ +from wpilib.opmoderobot import OpModeRobot +class Robot(OpModeRobot): + def __init__(self): + super().__init__() +""") + (pkg / "bad_auto.py").write_text("""\ +from wpilib import PeriodicOpMode +from wpilib.opmoderobot import autonomous +raise RuntimeError('boom') +@autonomous +class BadAuto(PeriodicOpMode): + pass +""") + + monkeypatch.syspath_prepend(str(tmp_path)) + module = importlib.import_module("importbrokenbot.robot") + + with pytest.raises(RuntimeError, match="bad_auto.py"): + module.Robot() + + +def test_opmode_robot_rejects_duplicate_names_within_mode(tmp_path, monkeypatch): + pkg = tmp_path / "duplicatebot" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "robot.py").write_text("""\ +from wpilib.opmoderobot import OpModeRobot +class Robot(OpModeRobot): + def __init__(self): + super().__init__() +""") + (pkg / "drive_modes.py").write_text("""\ +from wpilib import PeriodicOpMode +from wpilib.opmoderobot import teleop +@teleop(name='Drive') +class DriveModeA(PeriodicOpMode): + pass +@teleop(name='Drive') +class DriveModeB(PeriodicOpMode): + pass +""") + + monkeypatch.syspath_prepend(str(tmp_path)) + module = importlib.import_module("duplicatebot.robot") + + with pytest.raises(ValueError, match="duplicate"): + module.Robot() + + +def test_opmode_robot_rejects_decorated_non_opmode_class(tmp_path, monkeypatch): + pkg = tmp_path / "typecheckbot" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "robot.py").write_text("""\ +from wpilib.opmoderobot import OpModeRobot +class Robot(OpModeRobot): + def __init__(self): + super().__init__() +""") + (pkg / "not_an_opmode.py").write_text("""\ +from wpilib.opmoderobot import autonomous +@autonomous +class NotAnOpMode: + pass +""") + + monkeypatch.syspath_prepend(str(tmp_path)) + module = importlib.import_module("typecheckbot.robot") + + with pytest.raises(TypeError, match="OpMode"): + module.Robot() + + +def test_expansion_hub_style_project_discovers_split_opmodes(tmp_path, monkeypatch): + pkg = tmp_path / "expansionhubsample" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "robot.py").write_text("""\ +from wpilib.opmoderobot import OpModeRobot +class Robot(OpModeRobot): + motor0 = None + def __init__(self): + super().__init__() +""") + (pkg / "defaultautomode.py").write_text("""\ +from wpilib import PeriodicOpMode +from wpilib.opmoderobot import autonomous +@autonomous +class DefaultAutoMode(PeriodicOpMode): + def __init__(self, robot): + self.robot = robot +""") + (pkg / "defaulttelemode.py").write_text("""\ +from wpilib import PeriodicOpMode +from wpilib.opmoderobot import teleop +@teleop +class DefaultTeleMode(PeriodicOpMode): + def __init__(self, robot): + self.robot = robot +""") + + monkeypatch.syspath_prepend(str(tmp_path)) + module = importlib.import_module("expansionhubsample.robot") + module.Robot() + + options = wsim.DriverStationSim.getOpModeOptions() + assert {opt.name for opt in options} == {"DefaultAutoMode", "DefaultTeleMode"} diff --git a/subprojects/robotpy-wpilib/wpilib/opmoderobot.py b/subprojects/robotpy-wpilib/wpilib/opmoderobot.py index 906c544d3..7e2812835 100644 --- a/subprojects/robotpy-wpilib/wpilib/opmoderobot.py +++ b/subprojects/robotpy-wpilib/wpilib/opmoderobot.py @@ -133,8 +133,13 @@ def _decorator_name(node: ast.expr) -> str | None: def _contains_opmode_decorator(path: Path) -> bool: - source = path.read_text() - tree = ast.parse(source, filename=str(path)) + try: + source = path.read_text() + tree = ast.parse(source, filename=str(path)) + except Exception as exc: + raise RuntimeError( + f"Failed to parse opmode scan file {path.resolve()}: {exc}" + ) from exc for stmt in tree.body: if not isinstance(stmt, ast.ClassDef): @@ -169,30 +174,53 @@ def _module_name_from_path(robot_module, scan_root: Path, path: Path) -> str: def _discover_decorated_opmodes(robot: "OpModeRobot") -> None: robot_module = inspect.getmodule(type(robot)) if robot_module is None or getattr(robot_module, "__file__", None) is None: - return + raise RuntimeError( + f"Unable to resolve robot module source file for {type(robot).__module__}.{type(robot).__qualname__}" + ) scan_root = Path(robot_module.__file__).resolve().parent discovered: set[type] = set() + registered_names: set[tuple[RobotMode, str]] = set() for path in _iter_scan_files(scan_root): if not _contains_opmode_decorator(path): continue module_name = _module_name_from_path(robot_module, scan_root, path) - module = importlib.import_module(module_name) + try: + module = importlib.import_module(module_name) + except Exception as exc: + raise RuntimeError( + f"Failed to import opmode discovery candidate {path.resolve()}: {exc}" + ) from exc for value in vars(module).values(): metadata = getattr(value, "__wpilib_opmode_metadata__", None) if metadata is None or not inspect.isclass(value): continue if value in discovered: continue + if not issubclass(value, OpMode): + raise TypeError( + f"Decorated class {value.__module__}.{value.__qualname__} must inherit from OpMode" + ) + + name = metadata.name or value.__name__ + group = metadata.group or "" + description = metadata.description or "" + registration_key = (metadata.mode, name) + if registration_key in registered_names: + raise ValueError( + f"duplicate opmode registration for mode {metadata.mode} and name {name!r}" + ) + + registered_names.add(registration_key) discovered.add(value) robot.addOpMode( value, metadata.mode, - metadata.name or value.__name__, - metadata.group, - metadata.description, + name, + group, + description, metadata.textColor, metadata.backgroundColor, ) From 639f5933703c76dff5e86564fcfb2731c3709964 Mon Sep 17 00:00:00 2001 From: Dustin Spicuzza Date: Mon, 11 May 2026 05:38:01 +0000 Subject: [PATCH 3/8] Add OpMode user controls injection --- .../ExpansionHubSample/defaulttelemode.py | 19 ++-- examples/robot/ExpansionHubSample/robot.py | 4 +- .../robotpy-wpilib/tests/test_opmode_robot.py | 85 +++++++++++++++ subprojects/robotpy-wpilib/wpilib/__init__.py | 10 +- .../robotpy-wpilib/wpilib/opmoderobot.py | 102 ++++++++++++++++-- .../robotpy-wpilib/wpilib/usercontrols.py | 17 +++ 6 files changed, 216 insertions(+), 21 deletions(-) create mode 100644 subprojects/robotpy-wpilib/wpilib/usercontrols.py diff --git a/examples/robot/ExpansionHubSample/defaulttelemode.py b/examples/robot/ExpansionHubSample/defaulttelemode.py index a61c5d281..94456c35a 100644 --- a/examples/robot/ExpansionHubSample/defaulttelemode.py +++ b/examples/robot/ExpansionHubSample/defaulttelemode.py @@ -4,21 +4,22 @@ # the WPILib BSD license file in the root directory of this project. # -from wpilib import Gamepad, PeriodicOpMode +from wpilib import DefaultUserControls, PeriodicOpMode from wpilib.opmoderobot import teleop @teleop class DefaultTeleMode(PeriodicOpMode): - def __init__(self, robot): + def __init__(self, robot, user_controls: DefaultUserControls): super().__init__() self.robot = robot - self.gamepad = Gamepad(0) + self.user_controls = user_controls def periodic(self): - self.robot.motor0.setThrottle(-self.gamepad.getLeftY()) - self.robot.motor1.setThrottle(-self.gamepad.getRightY()) - self.robot.motor2.setThrottle(-self.gamepad.getLeftX()) - self.robot.motor3.setThrottle(-self.gamepad.getRightX()) - self.robot.servo0.setPosition(self.gamepad.getLeftTriggerAxis()) - self.robot.servo1.setPosition(self.gamepad.getRightTriggerAxis()) + gamepad = self.user_controls.getGamepad(0) + self.robot.motor0.setThrottle(-gamepad.getLeftY()) + self.robot.motor1.setThrottle(-gamepad.getRightY()) + self.robot.motor2.setThrottle(-gamepad.getLeftX()) + self.robot.motor3.setThrottle(-gamepad.getRightX()) + self.robot.servo0.setPosition(gamepad.getLeftTriggerAxis()) + self.robot.servo1.setPosition(gamepad.getRightTriggerAxis()) diff --git a/examples/robot/ExpansionHubSample/robot.py b/examples/robot/ExpansionHubSample/robot.py index 34ecaa29d..24bc60742 100644 --- a/examples/robot/ExpansionHubSample/robot.py +++ b/examples/robot/ExpansionHubSample/robot.py @@ -5,11 +5,11 @@ # the WPILib BSD license file in the root directory of this project. # -from wpilib import ExpansionHubMotor, ExpansionHubServo +from wpilib import DefaultUserControls, ExpansionHubMotor, ExpansionHubServo from wpilib.opmoderobot import OpModeRobot -class Robot(OpModeRobot): +class Robot(OpModeRobot, user_controls=DefaultUserControls): def __init__(self): super().__init__() self.motor0 = ExpansionHubMotor(0, 0) diff --git a/subprojects/robotpy-wpilib/tests/test_opmode_robot.py b/subprojects/robotpy-wpilib/tests/test_opmode_robot.py index 8ad0a511e..df02d09b0 100644 --- a/subprojects/robotpy-wpilib/tests/test_opmode_robot.py +++ b/subprojects/robotpy-wpilib/tests/test_opmode_robot.py @@ -420,3 +420,88 @@ def __init__(self, robot): options = wsim.DriverStationSim.getOpModeOptions() assert {opt.name for opt in options} == {"DefaultAutoMode", "DefaultTeleMode"} + + +def test_add_op_mode_injects_robot_and_user_controls_by_parameter_name(tmp_path, monkeypatch): + pkg = tmp_path / "controlsbot" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "robot.py").write_text("""\ +from wpilib import OpMode +from wpilib.opmoderobot import OpModeRobot + +class ExampleControls: + def __init__(self): + self.token = object() + +class ExampleMode(OpMode): + def __init__(self, user_controls, robot): + super().__init__() + self.user_controls = user_controls + self.robot = robot + +class Robot(OpModeRobot, user_controls=ExampleControls): + def __init__(self): + self.factories = [] + super().__init__() + self.addOpMode(ExampleMode, 0, 'ExampleMode') + + def addOpModeFactory(self, *args): + self.factories.append(args) + + def publishOpModes(self): + pass +""") + + monkeypatch.syspath_prepend(str(tmp_path)) + module = importlib.import_module("controlsbot.robot") + robot = module.Robot() + + opmode = robot.factories[0][0]() + assert opmode.robot is robot + assert opmode.user_controls is robot._user_controls + + +def test_add_op_mode_rejects_unsupported_constructor_parameter_name(tmp_path, monkeypatch): + pkg = tmp_path / "badcontrolsbot" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "robot.py").write_text("""\ +from wpilib import OpMode +from wpilib.opmoderobot import OpModeRobot + +class ExampleControls: + pass + +class BadMode(OpMode): + def __init__(self, controls): + super().__init__() + +class Robot(OpModeRobot, user_controls=ExampleControls): + def __init__(self): + super().__init__() +""") + + monkeypatch.syspath_prepend(str(tmp_path)) + module = importlib.import_module("badcontrolsbot.robot") + robot = module.Robot() + + with pytest.raises(TypeError, match="unsupported constructor parameter name 'controls'"): + robot.addOpMode(module.BadMode, RobotMode.TELEOPERATED, "BadMode") + + +def test_robot_subclass_keyword_rejects_non_type_user_controls(tmp_path, monkeypatch): + pkg = tmp_path / "invalidcontrolsbot" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "robot.py").write_text("""\ +from wpilib.opmoderobot import OpModeRobot + +class Robot(OpModeRobot, user_controls=42): + pass +""") + + monkeypatch.syspath_prepend(str(tmp_path)) + + with pytest.raises(TypeError, match="user_controls must be a type"): + importlib.import_module("invalidcontrolsbot.robot") diff --git a/subprojects/robotpy-wpilib/wpilib/__init__.py b/subprojects/robotpy-wpilib/wpilib/__init__.py index 274e08cc1..a0706fa21 100644 --- a/subprojects/robotpy-wpilib/wpilib/__init__.py +++ b/subprojects/robotpy-wpilib/wpilib/__init__.py @@ -235,8 +235,16 @@ del _init__wpilib from .opmoderobot import OpModeRobot, autonomous, teleop, utility +from .usercontrols import DefaultUserControls, UserControls -__all__ += ["OpModeRobot", "autonomous", "teleop", "utility"] +__all__ += [ + "OpModeRobot", + "autonomous", + "teleop", + "utility", + "DefaultUserControls", + "UserControls", +] from .cameraserver import CameraServer from .deployinfo import getDeployData diff --git a/subprojects/robotpy-wpilib/wpilib/opmoderobot.py b/subprojects/robotpy-wpilib/wpilib/opmoderobot.py index 7e2812835..62b6d6cdd 100644 --- a/subprojects/robotpy-wpilib/wpilib/opmoderobot.py +++ b/subprojects/robotpy-wpilib/wpilib/opmoderobot.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from hal import RobotMode from pathlib import Path -from typing import Callable, Optional, TypeVar, overload +from typing import Any, Callable, Optional, TypeVar, overload from wpiutil import Color __all__ = ["OpModeRobot", "autonomous", "teleop", "utility"] @@ -24,6 +24,7 @@ class _OpModeMetadata: _OpModeType = TypeVar("_OpModeType", bound=type[OpMode]) _DECORATOR_NAMES = {"autonomous", "teleop", "utility"} +_SUPPORTED_CTOR_PARAM_NAMES = frozenset({"robot", "user_controls"}) def _attach_opmode_metadata( @@ -171,6 +172,44 @@ def _module_name_from_path(robot_module, scan_root: Path, path: Path) -> str: return ".".join(parts) +def _resolve_opmode_constructor_kwargs( + opmodeCls: type, + robot: "OpModeRobot", + user_controls: Any | None, +) -> dict[str, Any]: + kwargs: dict[str, Any] = {} + initializer = opmodeCls.__dict__.get("__init__") + if initializer is None: + return kwargs + + for parameter in list(inspect.signature(initializer).parameters.values())[1:]: + if parameter.kind in ( + inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD, + ): + raise TypeError( + f"{opmodeCls.__module__}.{opmodeCls.__qualname__} constructor cannot use *args or **kwargs" + ) + + if parameter.name not in _SUPPORTED_CTOR_PARAM_NAMES: + raise TypeError( + f"{opmodeCls.__module__}.{opmodeCls.__qualname__} uses unsupported constructor parameter name {parameter.name!r}; " + "supported names are 'robot' and 'user_controls'" + ) + + if parameter.name == "robot": + kwargs[parameter.name] = robot + elif user_controls is not None: + kwargs[parameter.name] = user_controls + elif parameter.default is inspect.Parameter.empty: + raise TypeError( + f"{opmodeCls.__module__}.{opmodeCls.__qualname__} requires 'user_controls', " + f"but {type(robot).__module__}.{type(robot).__qualname__} did not declare user_controls" + ) + + return kwargs + + def _discover_decorated_opmodes(robot: "OpModeRobot") -> None: robot_module = inspect.getmodule(type(robot)) if robot_module is None or getattr(robot_module, "__file__", None) is None: @@ -240,12 +279,56 @@ class OpModeRobot(OpModeRobotBase): selected. When no opmode is selected, nonePeriodic() is called. The driverStationConnected() function is called the first time the driver station connects to the robot. + + Robot subclasses may declare a framework-managed user controls object using + the ``user_controls=...`` class keyword:: + + from wpilib import DefaultUserControls + + class Robot(OpModeRobot, user_controls=DefaultUserControls): + ... + + When declared, the controls class is instantiated once per robot and may be + injected into opmode constructors by naming a parameter ``user_controls``. + The robot instance may likewise be injected by naming a parameter ``robot``. + Supported opmode constructor shapes therefore include:: + + __init__(self) + __init__(self, robot) + __init__(self, user_controls) + __init__(self, robot, user_controls) + + Injection is strict by parameter name: any other constructor parameter name + is rejected with ``TypeError``. """ + __wpilib_user_controls_type__: type | None = None + + def __init_subclass__(cls, *, user_controls: type | None = None, **kwargs): + super().__init_subclass__(**kwargs) + if user_controls is not None and not isinstance(user_controls, type): + raise TypeError("user_controls must be a type") + if user_controls is not None: + cls.__wpilib_user_controls_type__ = user_controls + def __init__(self): super().__init__() + self._user_controls = self._make_user_controls_instance() _discover_decorated_opmodes(self) + def _make_user_controls_instance(self): + user_controls_type = type(self).__wpilib_user_controls_type__ + if user_controls_type is None: + return None + + try: + return user_controls_type() + except TypeError as exc: + raise TypeError( + f"{type(self).__module__}.{type(self).__qualname__} declared user_controls={user_controls_type.__module__}.{user_controls_type.__qualname__}, " + "but it could not be constructed without arguments" + ) from exc + def addOpMode( self, opmodeCls: type, @@ -264,8 +347,10 @@ def addOpMode( only one has no effect (if only one is provided, it will be ignored). :param opmodeCls: opmode class; must be a public, non-abstract subclass of OpMode - with a constructor that either takes no arguments or accepts a - single argument of this class's type (the latter is preferred). + with constructor parameters named only 'robot' and/or + 'user_controls'. These dependencies are injected by name. + Supported constructor forms are (), (robot), + (user_controls), and (robot, user_controls). :param mode: robot mode :param name: name of the operating mode :param group: group of the operating mode @@ -274,13 +359,12 @@ def addOpMode( :param backgroundColor: background color """ + constructor_kwargs = _resolve_opmode_constructor_kwargs( + opmodeCls, self, self._user_controls + ) + def makeOpModeInstance() -> OpMode: - # Try to instantiate with robot argument first - try: - return opmodeCls(self) # type: ignore - except TypeError: - # Fallback to no-argument constructor - return opmodeCls() # type: ignore + return opmodeCls(**constructor_kwargs) # type: ignore[arg-type] if textColor is None or backgroundColor is None: self.addOpModeFactory( diff --git a/subprojects/robotpy-wpilib/wpilib/usercontrols.py b/subprojects/robotpy-wpilib/wpilib/usercontrols.py new file mode 100644 index 000000000..dc35e488f --- /dev/null +++ b/subprojects/robotpy-wpilib/wpilib/usercontrols.py @@ -0,0 +1,17 @@ +from ._wpilib import Gamepad + +__all__ = ["UserControls", "DefaultUserControls"] + + +class UserControls: + """Marker base class for framework-managed user controls objects.""" + + +class DefaultUserControls(UserControls): + """Provides one Gamepad per standard driver station port.""" + + def __init__(self): + self._gamepads = tuple(Gamepad(port) for port in range(6)) + + def getGamepad(self, port: int) -> Gamepad: + return self._gamepads[port] From 9d25f61556fe0f31045d46ebb57932660db5079e Mon Sep 17 00:00:00 2001 From: Dustin Spicuzza Date: Mon, 11 May 2026 05:43:30 +0000 Subject: [PATCH 4/8] Document OpMode decorators and discovery --- .../robotpy-wpilib/wpilib/opmoderobot.py | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/subprojects/robotpy-wpilib/wpilib/opmoderobot.py b/subprojects/robotpy-wpilib/wpilib/opmoderobot.py index 62b6d6cdd..23e752fdc 100644 --- a/subprojects/robotpy-wpilib/wpilib/opmoderobot.py +++ b/subprojects/robotpy-wpilib/wpilib/opmoderobot.py @@ -78,6 +78,26 @@ def autonomous( def autonomous(_cls=None, **kwargs): + """ + Decorator for automatic registration of autonomous opmode classes. + + May be used with or without arguments:: + + @autonomous + class DefaultAutoMode(PeriodicOpMode): + ... + + @autonomous(name="Two Ball", group="Autos", description="Example") + class TwoBallAuto(PeriodicOpMode): + ... + + ``name`` is shown as the selection name in the Driver Station and must be + unique across autonomous opmodes in the project. If omitted, the class name + is used. ``group`` controls Driver Station grouping and defaults to + ungrouped. ``description`` is optional. ``textColor`` and + ``backgroundColor`` are optional display colors; both must be provided to + have an effect. + """ return _make_opmode_decorator(RobotMode.AUTONOMOUS, _cls, **kwargs) @@ -99,6 +119,26 @@ def teleop( def teleop(_cls=None, **kwargs): + """ + Decorator for automatic registration of teleoperated opmode classes. + + May be used with or without arguments:: + + @teleop + class DefaultTeleMode(PeriodicOpMode): + ... + + @teleop(name="Drive", group="Main") + class DriveMode(PeriodicOpMode): + ... + + ``name`` is shown as the selection name in the Driver Station and must be + unique across teleoperated opmodes in the project. If omitted, the class + name is used. ``group`` controls Driver Station grouping and defaults to + ungrouped. ``description`` is optional. ``textColor`` and + ``backgroundColor`` are optional display colors; both must be provided to + have an effect. + """ return _make_opmode_decorator(RobotMode.TELEOPERATED, _cls, **kwargs) @@ -120,6 +160,25 @@ def utility( def utility(_cls=None, **kwargs): + """ + Decorator for automatic registration of utility opmode classes. + + May be used with or without arguments:: + + @utility + class ServoTest(PeriodicOpMode): + ... + + @utility(name="Servo Test", group="Mechanisms") + class ServoTest(PeriodicOpMode): + ... + + ``name`` is shown as the selection name in the Driver Station and must be + unique across utility opmodes in the project. If omitted, the class name is + used. ``group`` controls Driver Station grouping and defaults to ungrouped. + ``description`` is optional. ``textColor`` and ``backgroundColor`` are + optional display colors; both must be provided to have an effect. + """ return _make_opmode_decorator(RobotMode.UTILITY, _cls, **kwargs) @@ -280,6 +339,13 @@ class OpModeRobot(OpModeRobotBase): driverStationConnected() function is called the first time the driver station connects to the robot. + Decorated opmodes are auto-discovered from Python modules in the same + package directory as the robot class. Any class decorated with + ``@autonomous``, ``@teleop``, or ``@utility`` is imported, validated as an + ``OpMode`` subclass, registered automatically, and then published to the + Driver Station during robot initialization. Selection names must be unique + within each robot mode. + Robot subclasses may declare a framework-managed user controls object using the ``user_controls=...`` class keyword:: From 12e6836789b81ca9a6f410ac412bb98cda7ebf63 Mon Sep 17 00:00:00 2001 From: Dustin Spicuzza Date: Mon, 11 May 2026 05:46:47 +0000 Subject: [PATCH 5/8] Limit OpMode auto-discovery scope --- .../robotpy-wpilib/tests/test_opmode_robot.py | 49 +++++++++++++++++++ .../robotpy-wpilib/wpilib/opmoderobot.py | 40 ++++++++++----- 2 files changed, 77 insertions(+), 12 deletions(-) diff --git a/subprojects/robotpy-wpilib/tests/test_opmode_robot.py b/subprojects/robotpy-wpilib/tests/test_opmode_robot.py index df02d09b0..82533a352 100644 --- a/subprojects/robotpy-wpilib/tests/test_opmode_robot.py +++ b/subprojects/robotpy-wpilib/tests/test_opmode_robot.py @@ -280,6 +280,16 @@ class DefaultAutoMode(PeriodicOpMode): pass """) (pkg / "helper.py").write_text("raise RuntimeError('should not import')\n") + nested = pkg / "support" + nested.mkdir() + (nested / "bad_auto.py").write_text("""\ +from wpilib import PeriodicOpMode +from wpilib.opmoderobot import autonomous +raise RuntimeError('should not import nested module outside opmodes') +@autonomous +class BadAuto(PeriodicOpMode): + pass +""") monkeypatch.syspath_prepend(str(tmp_path)) module = importlib.import_module("safeimportbot.robot") @@ -289,6 +299,45 @@ class DefaultAutoMode(PeriodicOpMode): assert {opt.name for opt in options} == {"DefaultAutoMode"} +def test_opmode_robot_discovers_opmodes_package_recursively(tmp_path, monkeypatch): + pkg = tmp_path / "nestedopmodesbot" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "robot.py").write_text("""\ +from wpilib.opmoderobot import OpModeRobot +class Robot(OpModeRobot): + def __init__(self): + super().__init__() +""") + opmodes = pkg / "opmodes" + opmodes.mkdir() + (opmodes / "__init__.py").write_text("") + (opmodes / "drive.py").write_text("""\ +from wpilib import PeriodicOpMode +from wpilib.opmoderobot import teleop +@teleop +class DriveMode(PeriodicOpMode): + pass +""") + nested = opmodes / "test" + nested.mkdir() + (nested / "__init__.py").write_text("") + (nested / "servo.py").write_text("""\ +from wpilib import PeriodicOpMode +from wpilib.opmoderobot import utility +@utility +class ServoMode(PeriodicOpMode): + pass +""") + + monkeypatch.syspath_prepend(str(tmp_path)) + module = importlib.import_module("nestedopmodesbot.robot") + module.Robot() + + options = wsim.DriverStationSim.getOpModeOptions() + assert {opt.name for opt in options} == {"DriveMode", "ServoMode"} + + def test_opmode_robot_fails_on_syntax_error_in_scan_tree(tmp_path, monkeypatch): pkg = tmp_path / "brokenbot" pkg.mkdir() diff --git a/subprojects/robotpy-wpilib/wpilib/opmoderobot.py b/subprojects/robotpy-wpilib/wpilib/opmoderobot.py index 23e752fdc..dd9c8bb87 100644 --- a/subprojects/robotpy-wpilib/wpilib/opmoderobot.py +++ b/subprojects/robotpy-wpilib/wpilib/opmoderobot.py @@ -210,13 +210,28 @@ def _contains_opmode_decorator(path: Path) -> bool: return False +def _is_scannable_python_file(path: Path) -> bool: + if path.suffix != ".py": + return False + if any(part.startswith(".") for part in path.parts): + return False + if "__pycache__" in path.parts: + return False + return True + + def _iter_scan_files(root: Path): - for path in root.rglob("*.py"): - if any(part.startswith(".") for part in path.parts): - continue - if "__pycache__" in path.parts: - continue - yield path + for path in root.iterdir(): + if path.is_file() and _is_scannable_python_file(path): + yield path + + opmodes_dir = root / "opmodes" + if not opmodes_dir.is_dir(): + return + + for path in opmodes_dir.rglob("*.py"): + if _is_scannable_python_file(path): + yield path def _module_name_from_path(robot_module, scan_root: Path, path: Path) -> str: @@ -339,12 +354,13 @@ class OpModeRobot(OpModeRobotBase): driverStationConnected() function is called the first time the driver station connects to the robot. - Decorated opmodes are auto-discovered from Python modules in the same - package directory as the robot class. Any class decorated with - ``@autonomous``, ``@teleop``, or ``@utility`` is imported, validated as an - ``OpMode`` subclass, registered automatically, and then published to the - Driver Station during robot initialization. Selection names must be unique - within each robot mode. + Decorated opmodes are auto-discovered from a limited set of Python modules + near the robot class: ``*.py`` files directly beside ``robot.py`` and + ``opmodes/**/*.py`` recursively under an ``opmodes`` subpackage. Any class + decorated with ``@autonomous``, ``@teleop``, or ``@utility`` in those files + is imported, validated as an ``OpMode`` subclass, registered automatically, + and then published to the Driver Station during robot initialization. + Selection names must be unique within each robot mode. Robot subclasses may declare a framework-managed user controls object using the ``user_controls=...`` class keyword:: From 5dc5709832ced67d1aee6e962377bb85fd7d852b Mon Sep 17 00:00:00 2001 From: Dustin Spicuzza Date: Mon, 11 May 2026 05:50:03 +0000 Subject: [PATCH 6/8] Document DefaultUserControls.getGamepad --- subprojects/robotpy-wpilib/wpilib/usercontrols.py | 1 + 1 file changed, 1 insertion(+) diff --git a/subprojects/robotpy-wpilib/wpilib/usercontrols.py b/subprojects/robotpy-wpilib/wpilib/usercontrols.py index dc35e488f..fa9c66ba5 100644 --- a/subprojects/robotpy-wpilib/wpilib/usercontrols.py +++ b/subprojects/robotpy-wpilib/wpilib/usercontrols.py @@ -14,4 +14,5 @@ def __init__(self): self._gamepads = tuple(Gamepad(port) for port in range(6)) def getGamepad(self, port: int) -> Gamepad: + """Return the Gamepad instance for the specified driver station port.""" return self._gamepads[port] From c2445ec94fa4acb19209afea3da49fccbcfddb8a Mon Sep 17 00:00:00 2001 From: Dustin Spicuzza Date: Mon, 11 May 2026 05:56:48 +0000 Subject: [PATCH 7/8] Reject non-OpMode classes in decorators --- subprojects/robotpy-wpilib/tests/test_opmode_robot.py | 10 +++++++++- subprojects/robotpy-wpilib/wpilib/opmoderobot.py | 5 +++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/subprojects/robotpy-wpilib/tests/test_opmode_robot.py b/subprojects/robotpy-wpilib/tests/test_opmode_robot.py index 82533a352..c55a7380d 100644 --- a/subprojects/robotpy-wpilib/tests/test_opmode_robot.py +++ b/subprojects/robotpy-wpilib/tests/test_opmode_robot.py @@ -84,6 +84,14 @@ class BadMode(OpMode): pass +def test_opmode_decorator_rejects_non_opmode_class_eagerly(): + with pytest.raises(TypeError, match="must inherit from OpMode"): + + @autonomous + class NotAnOpMode: + pass + + def test_opmode_decorator_preserves_explicit_metadata(): @utility( name="Arm Test", @@ -431,7 +439,7 @@ class NotAnOpMode: monkeypatch.syspath_prepend(str(tmp_path)) module = importlib.import_module("typecheckbot.robot") - with pytest.raises(TypeError, match="OpMode"): + with pytest.raises(RuntimeError, match="OpMode"): module.Robot() diff --git a/subprojects/robotpy-wpilib/wpilib/opmoderobot.py b/subprojects/robotpy-wpilib/wpilib/opmoderobot.py index dd9c8bb87..7973d080a 100644 --- a/subprojects/robotpy-wpilib/wpilib/opmoderobot.py +++ b/subprojects/robotpy-wpilib/wpilib/opmoderobot.py @@ -37,6 +37,11 @@ def _attach_opmode_metadata( textColor: Color | None = None, backgroundColor: Color | None = None, ) -> _OpModeType: + if not issubclass(cls, OpMode): + raise TypeError( + f"Decorated class {cls.__module__}.{cls.__qualname__} must inherit from OpMode" + ) + if hasattr(cls, "__wpilib_opmode_metadata__"): raise ValueError("multiple opmode decorators are not allowed") From 7226c87d398e904b58b9f2a0186a7283d224d615 Mon Sep 17 00:00:00 2001 From: Dustin Spicuzza Date: Mon, 11 May 2026 14:57:02 +0000 Subject: [PATCH 8/8] Formatting --- .../robotpy-wpilib/tests/test_opmode_robot.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/subprojects/robotpy-wpilib/tests/test_opmode_robot.py b/subprojects/robotpy-wpilib/tests/test_opmode_robot.py index c55a7380d..a6b84d374 100644 --- a/subprojects/robotpy-wpilib/tests/test_opmode_robot.py +++ b/subprojects/robotpy-wpilib/tests/test_opmode_robot.py @@ -479,7 +479,9 @@ def __init__(self, robot): assert {opt.name for opt in options} == {"DefaultAutoMode", "DefaultTeleMode"} -def test_add_op_mode_injects_robot_and_user_controls_by_parameter_name(tmp_path, monkeypatch): +def test_add_op_mode_injects_robot_and_user_controls_by_parameter_name( + tmp_path, monkeypatch +): pkg = tmp_path / "controlsbot" pkg.mkdir() (pkg / "__init__.py").write_text("") @@ -519,7 +521,9 @@ def publishOpModes(self): assert opmode.user_controls is robot._user_controls -def test_add_op_mode_rejects_unsupported_constructor_parameter_name(tmp_path, monkeypatch): +def test_add_op_mode_rejects_unsupported_constructor_parameter_name( + tmp_path, monkeypatch +): pkg = tmp_path / "badcontrolsbot" pkg.mkdir() (pkg / "__init__.py").write_text("") @@ -543,7 +547,9 @@ def __init__(self): module = importlib.import_module("badcontrolsbot.robot") robot = module.Robot() - with pytest.raises(TypeError, match="unsupported constructor parameter name 'controls'"): + with pytest.raises( + TypeError, match="unsupported constructor parameter name 'controls'" + ): robot.addOpMode(module.BadMode, RobotMode.TELEOPERATED, "BadMode")