diff --git a/hal/src/main/python/semiwrap/DriverStationTypes.yml b/hal/src/main/python/semiwrap/DriverStationTypes.yml index f2ae04a06c..8dedf5c9e2 100644 --- a/hal/src/main/python/semiwrap/DriverStationTypes.yml +++ b/hal/src/main/python/semiwrap/DriverStationTypes.yml @@ -13,6 +13,7 @@ enums: value_prefix: HAL_kMatchType HAL_RobotMode: rename: _RobotMode + value_prefix: HAL_ROBOTMODE RobotMode: classes: HAL_ControlWord: diff --git a/hal/src/main/python/semiwrap/Main.yml b/hal/src/main/python/semiwrap/Main.yml index 480034fddb..b8737bf1fc 100644 --- a/hal/src/main/python/semiwrap/Main.yml +++ b/hal/src/main/python/semiwrap/Main.yml @@ -6,4 +6,16 @@ functions: ignore: true # TODO HAL_HasMain: HAL_RunMain: + cpp_code: | + []() { + { + py::gil_scoped_release gil; + HAL_RunMain(); + } + + // halsim-gui will set the python error indicator if an exception occurs + if (PyErr_Occurred()) { + throw py::error_already_set(); + } + } HAL_ExitMain: diff --git a/requirements.txt b/requirements.txt index 0890313470..b654425bfb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,8 @@ jinja2==3.1.6 protobuf==5.28.3 grpcio-tools==1.68.0 semiwrap==0.2.1 -pytest +pytest>=3.9 numpy opencv-python~=4.6 +robotpy-cli~=2027.0.0a1 +pytest-reraise diff --git a/requirements_lock.txt b/requirements_lock.txt index 495192c6d2..e878793795 100644 --- a/requirements_lock.txt +++ b/requirements_lock.txt @@ -326,6 +326,12 @@ pygments==2.19.2 \ pytest==8.4.1 \ --hash=sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7 \ --hash=sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c + # via + # -r requirements.txt + # pytest-reraise +pytest-reraise==2.1.2 \ + --hash=sha256:5ab59bd0e2028be095289e6dfc9e36cc0b56936465278f3223e81bea0f2d1c70 \ + --hash=sha256:c22430d33b2cc18905959d7af28978e371113fcc6ef67b5fec95efcd80b88c16 # via -r requirements.txt pyyaml==6.0.2 \ --hash=sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff \ @@ -382,6 +388,10 @@ pyyaml==6.0.2 \ --hash=sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12 \ --hash=sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4 # via semiwrap +robotpy-cli==2027.0.1b1 \ + --hash=sha256:5f91f6a2e90f6c55800a7a7205180e2454ca961aaf1ef98ce2b63d76aab67505 \ + --hash=sha256:f56d8d7444a4aecd4a9c965ef97d4fcf8e951e7ed7a3497f7f8a2635613a5222 + # via -r requirements.txt ruamel-yaml==0.18.14 \ --hash=sha256:710ff198bb53da66718c7db27eec4fbcc9aa6ca7204e4c1df2f282b6fe5eb6b2 \ --hash=sha256:7227b76aaec364df15936730efbf7d72b30c0b79b1d578bbb8e3dcb2d81f52b7 diff --git a/requirements_windows_lock.txt b/requirements_windows_lock.txt index 06788964e7..91750bdf49 100644 --- a/requirements_windows_lock.txt +++ b/requirements_windows_lock.txt @@ -332,6 +332,12 @@ pygments==2.19.2 \ pytest==8.4.1 \ --hash=sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7 \ --hash=sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c + # via + # -r requirements.txt + # pytest-reraise +pytest-reraise==2.1.2 \ + --hash=sha256:5ab59bd0e2028be095289e6dfc9e36cc0b56936465278f3223e81bea0f2d1c70 \ + --hash=sha256:c22430d33b2cc18905959d7af28978e371113fcc6ef67b5fec95efcd80b88c16 # via -r requirements.txt pyyaml==6.0.2 \ --hash=sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff \ @@ -388,6 +394,10 @@ pyyaml==6.0.2 \ --hash=sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12 \ --hash=sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4 # via semiwrap +robotpy-cli==2027.0.1b1 \ + --hash=sha256:5f91f6a2e90f6c55800a7a7205180e2454ca961aaf1ef98ce2b63d76aab67505 \ + --hash=sha256:f56d8d7444a4aecd4a9c965ef97d4fcf8e951e7ed7a3497f7f8a2635613a5222 + # via -r requirements.txt ruamel-yaml==0.18.14 \ --hash=sha256:710ff198bb53da66718c7db27eec4fbcc9aa6ca7204e4c1df2f282b6fe5eb6b2 \ --hash=sha256:7227b76aaec364df15936730efbf7d72b30c0b79b1d578bbb8e3dcb2d81f52b7 diff --git a/romiVendordep/robotpy_pybind_build_info.bzl b/romiVendordep/robotpy_pybind_build_info.bzl index b4fc3d776b..ee229bf665 100644 --- a/romiVendordep/robotpy_pybind_build_info.bzl +++ b/romiVendordep/robotpy_pybind_build_info.bzl @@ -173,6 +173,7 @@ def define_pybind_library(name, pkgcfgs = []): imports = ["src/main/python"], deps = [ "//romiVendordep:robotpy-native-romi", + "//simulation/halsim_ws_core:robotpy-halsim-ws", "//wpilibc:robotpy-wpilib", ], visibility = ["//visibility:public"], diff --git a/romiVendordep/src/main/python/pyproject.toml b/romiVendordep/src/main/python/pyproject.toml index 1c332c5cd4..a1cf3be978 100644 --- a/romiVendordep/src/main/python/pyproject.toml +++ b/romiVendordep/src/main/python/pyproject.toml @@ -20,12 +20,16 @@ authors = [ license = "BSD-3-Clause" dependencies = [ "robotpy-native-romi==0.0.0", - "wpilib==0.0.0" + "wpilib==0.0.0", + "robotpy-halsim-ws==0.0.0", ] [project.urls] "Source code" = "https://github.com/robotpy/mostrobotpy" +[project.entry-points."robotpy_cli.2027"] +run-romi = "romi.cli:RunRomi" + [tool.hatch.build.hooks.robotpy] version_file = "romi/version.py" diff --git a/romiVendordep/src/main/python/romi/cli.py b/romiVendordep/src/main/python/romi/cli.py new file mode 100644 index 0000000000..04753e73bb --- /dev/null +++ b/romiVendordep/src/main/python/romi/cli.py @@ -0,0 +1,121 @@ +import argparse +import importlib.metadata +import os +import sys +import typing + +import wpilib + + +if sys.version_info < (3, 10): + + def entry_points(group): + eps = importlib.metadata.entry_points() + return eps.get(group, []) + +else: + entry_points = importlib.metadata.entry_points + + +def _int_env_default(name: str, fallback: int) -> int: + value = os.environ.get(name) + if value is None: + return fallback + try: + return int(value) + except ValueError: + return fallback + + +class RunRomi: + """ + Runs the robot using the HAL simulator connected to a ROMI + """ + + def __init__(self, parser: argparse.ArgumentParser): + parser.add_argument( + "--nogui", + default=False, + action="store_true", + help="Don't use the WPILib simulation gui", + ) + + sim_group = parser.add_argument_group("Additional simulation extensions") + self.simexts = {} + + for entry_point in entry_points(group="robotpy_sim.2027"): + try: + sim_ext_module = entry_point.load() + except ImportError: + print(f"WARNING: Error detected in {entry_point}") + continue + + self.simexts[entry_point.name] = sim_ext_module + + try: + if entry_point.name == "ws-client": + cmd_help = argparse.SUPPRESS + else: + cmd_help = importlib.metadata.metadata(entry_point.dist.name)[ + "summary" + ] + except AttributeError: + cmd_help = "Load specified simulation extension" + sim_group.add_argument( + f"--{entry_point.name}", + default=False, + action="store_true", + help=cmd_help, + ) + + parser.add_argument( + "--host", + default=os.environ.get("HALSIMWS_HOST", "10.0.0.2"), + help="ROMI websocket host (default: HALSIMWS_HOST or 10.0.0.2)", + ) + parser.add_argument( + "--port", + type=int, + default=_int_env_default("HALSIMWS_PORT", 3300), + help="ROMI websocket port (default: HALSIMWS_PORT or 3300)", + ) + + def run( + self, + options: argparse.Namespace, + project_path: "os.PathLike[str]", + robot_class: typing.Type[wpilib.RobotBase], + ): + if "ws-client" not in self.simexts: + print( + "ws-client HALSim extension is missing. Reinstall robotpy-halsim-ws to use run-romi", + file=sys.stderr, + ) + return False + + os.environ["HALSIMWS_HOST"] = options.host + os.environ["HALSIMWS_PORT"] = str(options.port) + + options.ws_client = True + + if not options.nogui: + try: + import halsim_gui + except ImportError: + print("robotpy-halsim-gui is not installed!", file=sys.stderr) + return False + else: + halsim_gui.loadExtension() + + cwd = os.getcwd() + + for name, module in self.simexts.items(): + if getattr(options, name.replace("-", "_"), False): + try: + module.loadExtension() + except Exception: + print(f"Error loading {name}!", file=sys.stderr) + raise + + os.chdir(cwd) + return robot_class.main(robot_class) diff --git a/shared/bazel/rules/robotpy/generate_pybind_build_file.py b/shared/bazel/rules/robotpy/generate_pybind_build_file.py index 15c683f288..403d664da1 100644 --- a/shared/bazel/rules/robotpy/generate_pybind_build_file.py +++ b/shared/bazel/rules/robotpy/generate_pybind_build_file.py @@ -382,13 +382,24 @@ def generate_pybind_build_file( base_library = python_dep.replace("robotpy-", "") return f"//{fixup_root_package_name(base_library)}:{fixup_python_dep_name(python_dep)}" + EXTERNAL_PYPI_DEPS = [ + "robotpy-cli", + "pytest-reraise", + "pytest", + ] + python_deps = [] + has_external_python_deps = False if "dependencies" in raw_config["project"]: for d in raw_config["project"]["dependencies"]: - if "robotpy-cli" in d: - continue - pd = target_from_python_dep(d.split("==")[0]) - python_deps.append(pd) + for external_dep in EXTERNAL_PYPI_DEPS: + if external_dep in d: + has_external_python_deps = True + python_deps.append(f'requirement("{external_dep}")') + break + else: + pd = target_from_python_dep(d.split("==")[0]) + python_deps.append(pd) env = Environment(loader=BaseLoader) env.filters["jsonify"] = jsonify @@ -420,6 +431,7 @@ def generate_pybind_build_file( raw_project_config=raw_config["project"], entry_points=entry_points, version_file=version_file, + has_external_python_deps=has_external_python_deps, ) + "\n" ) diff --git a/shared/bazel/rules/robotpy/generation_utils.py b/shared/bazel/rules/robotpy/generation_utils.py index d136ea905b..9373438e08 100644 --- a/shared/bazel/rules/robotpy/generation_utils.py +++ b/shared/bazel/rules/robotpy/generation_utils.py @@ -11,6 +11,8 @@ def fixup_root_package_name(name): return "romiVendordep" if name == "pyntcore": return "ntcore" + if name == "halsim-ws": + return "simulation/halsim_ws_core" return name diff --git a/shared/bazel/rules/robotpy/pybind_build_file_template.jinja2 b/shared/bazel/rules/robotpy/pybind_build_file_template.jinja2 index 7d90d1e86d..c7f11bb65c 100644 --- a/shared/bazel/rules/robotpy/pybind_build_file_template.jinja2 +++ b/shared/bazel/rules/robotpy/pybind_build_file_template.jinja2 @@ -1,5 +1,8 @@ # THIS FILE IS AUTO GENERATED -{% if publish_casters_targets %} +{% if has_external_python_deps %} +load("@allwpilib_pip_deps//:requirements.bzl", "requirement") +{%- endif %} +{%- if publish_casters_targets %} load("@rules_cc//cc:cc_library.bzl", "cc_library") {%- endif %} {%- if version_file %} @@ -211,7 +214,7 @@ def define_pybind_library(name, pkgcfgs = []): imports = ["{{stripped_include_prefix}}"], deps = [ {%- for d in python_deps %} - "{{d}}", + {% if "requirement" in d %}{{d}}{% else %}"{{d}}"{% endif %}, {%- endfor %} ], visibility = ["//visibility:public"], diff --git a/shared/bazel/rules/robotpy/pytest_util.bzl b/shared/bazel/rules/robotpy/pytest_util.bzl index 0cb1a37013..1620f86573 100644 --- a/shared/bazel/rules/robotpy/pytest_util.bzl +++ b/shared/bazel/rules/robotpy/pytest_util.bzl @@ -1,10 +1,10 @@ load("@rules_python_pytest//python_pytest:defs.bzl", "py_pytest_test") load("//shared/bazel/rules/robotpy:compatibility_select.bzl", "robotpy_compatibility_select") -def robotpy_py_test(name, srcs, tags = [], **kwargs): +def robotpy_py_test(name, srcs, tags = [], size = "small", **kwargs): py_pytest_test( name = name, - size = "small", + size = size, srcs = srcs, target_compatible_with = robotpy_compatibility_select(), tags = tags + [ diff --git a/simulation/halsim_ds_socket/BUILD.bazel b/simulation/halsim_ds_socket/BUILD.bazel index 407e4d19ed..1ea4bd915f 100644 --- a/simulation/halsim_ds_socket/BUILD.bazel +++ b/simulation/halsim_ds_socket/BUILD.bazel @@ -1,7 +1,11 @@ +load("@allwpilib_pip_deps//:requirements.bzl", "requirement") load("@rules_cc//cc:defs.bzl", "cc_binary", "cc_library", "cc_test") +load("@rules_python//python:defs.bzl", "py_library") load("//shared/bazel/rules:cc_rules.bzl", "wpilib_cc_library", "wpilib_cc_shared_library", "wpilib_cc_static_library") load("//shared/bazel/rules:packaging.bzl", "package_default_cc_project") load("//shared/bazel/rules:publishing.bzl", "host_architectures") +load("//shared/bazel/rules/robotpy:pybind_rules.bzl", "copy_native_file") +load("//shared/bazel/rules/robotpy:pytest_util.bzl", "robotpy_py_test") cc_library( name = "headers", @@ -92,3 +96,31 @@ package_default_cc_project( maven_artifact_name = "halsim_ds_socket", maven_group_id = "org.wpilib.halsim", ) + +copy_native_file( + name = "halsim_ds_socket", + base_path = "src/main/python/halsim_ds_socket/", + library = "shared/halsim_ds_socket", +) + +py_library( + name = "robotpy-halsim-ds-socket", + srcs = glob(["src/main/python/**/*.py"]), + data = [ + ":halsim_ds_socket.copy_lib", + ], + imports = ["src/main/python"], + deps = [ + "//hal:robotpy-native-wpihal", + "//wpinet:robotpy-native-wpinet", + ], +) + +robotpy_py_test( + "python_tests", + srcs = glob(["src/test/python/**/*.py"]), + deps = [ + ":robotpy-halsim-ds-socket", + requirement("pytest"), + ], +) diff --git a/simulation/halsim_ds_socket/src/main/python/README.md b/simulation/halsim_ds_socket/src/main/python/README.md new file mode 100644 index 0000000000..87e0e16485 --- /dev/null +++ b/simulation/halsim_ds_socket/src/main/python/README.md @@ -0,0 +1,18 @@ +robotpy-halsim-ds-socket +================== + +Installing this package will allow you to utilize the 2020+ WPILib GUI +DS Socket from a RobotPy program. + +Usage +----- + +First, install pyfrc. Then run your robot with the 'sim' argument and --ds-socket flag: + + # Windows + py -3 -m robotpy sim --ds-socket + + # Linux/OSX + python3 -m robotpy sim --ds-socket + +WPILib's documentation for using the simulator can be found at http://docs.wpilib.org/en/latest/docs/software/wpilib-tools/robot-simulation/ diff --git a/simulation/halsim_ds_socket/src/main/python/halsim_ds_socket/__init__.py b/simulation/halsim_ds_socket/src/main/python/halsim_ds_socket/__init__.py new file mode 100644 index 0000000000..cae2ac0d3d --- /dev/null +++ b/simulation/halsim_ds_socket/src/main/python/halsim_ds_socket/__init__.py @@ -0,0 +1 @@ +from .main import loadExtension diff --git a/simulation/halsim_ds_socket/src/main/python/halsim_ds_socket/main.py b/simulation/halsim_ds_socket/src/main/python/halsim_ds_socket/main.py new file mode 100644 index 0000000000..cc28c09497 --- /dev/null +++ b/simulation/halsim_ds_socket/src/main/python/halsim_ds_socket/main.py @@ -0,0 +1,23 @@ +import logging +import os +from os.path import abspath, dirname, join + +logger = logging.getLogger("halsim_ds_socket") + + +def loadExtension(): + try: + import hal + except ImportError as e: + # really, should never happen... + raise ImportError("you must install robotpy-hal!") from e + + from .version import version + + logger.info("WPILib HAL Simulation DS Socket Extension %s", version) + + root = join(abspath(dirname(__file__)), "lib") + ext = join(root, os.listdir(root)[0]) + retval = hal.loadOneExtension(ext) + if retval != 0: + logger.warn("loading extension may have failed (error=%d)", retval) diff --git a/simulation/halsim_ds_socket/src/main/python/halsim_ds_socket/py.typed b/simulation/halsim_ds_socket/src/main/python/halsim_ds_socket/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/simulation/halsim_ds_socket/src/main/python/pyproject.toml b/simulation/halsim_ds_socket/src/main/python/pyproject.toml new file mode 100644 index 0000000000..2df613abdf --- /dev/null +++ b/simulation/halsim_ds_socket/src/main/python/pyproject.toml @@ -0,0 +1,40 @@ +[build-system] +build-backend = "hatchling.build" +requires = [ + "hatchling", + "hatch-robotpy~=0.2.1", +] + +[project] +name = "robotpy-halsim-ds-socket" +version = "0.0.0" +description = "WPILib simulator DS Socket Extension" +authors = [ + {name = "RobotPy Development Team", email = "robotpy@googlegroups.com"}, +] +license = "BSD-3-Clause" +dependencies = [ + "robotpy-native-wpihal==0.0.0", + "robotpy-native-wpinet==0.0.0", +] + +[project.entry-points."robotpy_sim.2027"] +ds-socket = "halsim_ds_socket" + + +[tool.hatch.build.hooks.robotpy] +version_file = "halsim_ds_socket/version.py" + +[[tool.hatch.build.hooks.robotpy.maven_lib_download]] +artifact_id = "halsim_ds_socket" +group_id = "org.wpilib.halsim" +repo_url = "" +version = "0.0.0" +use_headers = false + +extract_to = "halsim_ds_socket" +libs = ["halsim_ds_socket"] + + +[tool.hatch.build.targets.wheel] +packages = ["halsim_ds_socket"] \ No newline at end of file diff --git a/simulation/halsim_ds_socket/src/test/python/test_halsim_ds_socket.py b/simulation/halsim_ds_socket/src/test/python/test_halsim_ds_socket.py new file mode 100644 index 0000000000..33dc5254df --- /dev/null +++ b/simulation/halsim_ds_socket/src/test/python/test_halsim_ds_socket.py @@ -0,0 +1,18 @@ +import ctypes +import pathlib + + +def test_halsim_ds_socket(): + # dependencies + import native.wpihal._init_robotpy_native_wpihal + import native.wpinet._init_robotpy_native_wpinet + + import halsim_ds_socket as base + + loaded = 0 + for fname in (pathlib.Path(base.__file__).parent / "lib").iterdir(): + if fname.is_file() and fname.suffix in (".dll", ".dylib", ".so"): + ctypes.CDLL(str(fname)) + loaded += 1 + + assert loaded diff --git a/simulation/halsim_gui/BUILD.bazel b/simulation/halsim_gui/BUILD.bazel index 19668c4317..0f530fd648 100644 --- a/simulation/halsim_gui/BUILD.bazel +++ b/simulation/halsim_gui/BUILD.bazel @@ -1,7 +1,10 @@ +load("@allwpilib_pip_deps//:requirements.bzl", "requirement") load("@rules_cc//cc:defs.bzl", "cc_binary", "cc_test") load("//shared/bazel/rules:cc_rules.bzl", "wpilib_cc_library", "wpilib_cc_shared_library", "wpilib_cc_static_library") load("//shared/bazel/rules:packaging.bzl", "package_default_cc_project") load("//shared/bazel/rules:publishing.bzl", "host_architectures") +load("//shared/bazel/rules/robotpy:pytest_util.bzl", "robotpy_py_test") +load("//simulation/halsim_gui:robotpy_pybind_build_info.bzl", "define_pybind_library", "halsim_gui_ext_extension") wpilib_cc_library( name = "halsim_gui", @@ -148,3 +151,36 @@ package_default_cc_project( maven_artifact_name = "halsim_gui", maven_group_id = "org.wpilib.halsim", ) + +PKG_CONFIG_DEPS = [ + "//datalog:native/datalog/robotpy-native-datalog.pc", + "//datalog:robotpy-wpilog.generated_pkgcfg_files", + "//hal:native/wpihal/robotpy-native-wpihal.pc", + "//hal:robotpy-hal.generated_pkgcfg_files", + "//ntcore:native/ntcore/robotpy-native-ntcore.pc", + "//ntcore:pyntcore.generated_pkgcfg_files", + "//wpimath:native/wpimath/robotpy-native-wpimath.pc", + "//wpimath:robotpy-wpimath.generated_pkgcfg_files", + "//wpinet:native/wpinet/robotpy-native-wpinet.pc", + "//wpinet:robotpy-wpinet.generated_pkgcfg_files", + "//wpiutil:native/wpiutil/robotpy-native-wpiutil.pc", + "//wpiutil:robotpy-wpiutil.generated_pkgcfg_files", +] + +halsim_gui_ext_extension( + srcs = glob(["src/main/python/halsim_gui/_ext/**/*.cpp"]), +) + +define_pybind_library( + name = "robotpy-halsim-gui", + pkgcfgs = PKG_CONFIG_DEPS, +) + +robotpy_py_test( + "python_tests", + srcs = glob(["src/test/python/**/*.py"]), + deps = [ + ":robotpy-halsim-gui", + requirement("pytest"), + ], +) diff --git a/simulation/halsim_gui/robotpy_pybind_build_info.bzl b/simulation/halsim_gui/robotpy_pybind_build_info.bzl new file mode 100644 index 0000000000..f098d655dd --- /dev/null +++ b/simulation/halsim_gui/robotpy_pybind_build_info.bzl @@ -0,0 +1,152 @@ +# THIS FILE IS AUTO GENERATED + +load("//shared/bazel/rules/gen:gen-version-file.bzl", "generate_version_file") +load("//shared/bazel/rules/robotpy:pybind_rules.bzl", "copy_native_file", "create_pybind_library", "robotpy_library") +load("//shared/bazel/rules/robotpy:semiwrap_helpers.bzl", "gen_libinit", "gen_modinit_hpp", "gen_pkgconf", "resolve_casters", "run_header_gen") + +def halsim_gui_ext_extension(srcs = [], header_to_dat_deps = [], extra_hdrs = [], includes = [], extra_pyi_deps = []): + HALSIM_GUI_EXT_HEADER_GEN = [ + ] + + resolve_casters( + name = "halsim_gui_ext.resolve_casters", + caster_deps = ["//wpimath:src/main/python/wpimath/wpimath-casters.pybind11.json", "//wpiutil:src/main/python/wpiutil/wpiutil-casters.pybind11.json"], + casters_pkl_file = "halsim_gui_ext.casters.pkl", + dep_file = "halsim_gui_ext.casters.d", + ) + + gen_libinit( + name = "halsim_gui_ext.gen_lib_init", + output_file = "src/main/python/halsim_gui/_ext/_init__halsim_gui_ext.py", + modules = ["hal._init__wpiHal", "wpimath._init__wpimath", "ntcore._init__ntcore"], + ) + + gen_pkgconf( + name = "halsim_gui_ext.gen_pkgconf", + libinit_py = "halsim_gui._ext._init__halsim_gui_ext", + module_pkg_name = "halsim_gui._ext._halsim_gui_ext", + output_file = "halsim_gui_ext.pc", + pkg_name = "halsim_gui_ext", + install_path = "src/main/python/halsim_gui/_ext", + project_file = "src/main/python/pyproject.toml", + package_root = "src/main/python/halsim_gui/_ext/__init__.py", + ) + + gen_modinit_hpp( + name = "halsim_gui_ext.gen_modinit_hpp", + input_dats = [x.class_name for x in HALSIM_GUI_EXT_HEADER_GEN], + libname = "_halsim_gui_ext", + output_file = "semiwrap_init.halsim_gui._ext._halsim_gui_ext.hpp", + ) + + run_header_gen( + name = "halsim_gui_ext", + casters_pickle = "halsim_gui_ext.casters.pkl", + header_gen_config = HALSIM_GUI_EXT_HEADER_GEN, + trampoline_subpath = "src/main/python/halsim_gui/_ext", + deps = header_to_dat_deps, + local_native_libraries = [ + ], + ) + + create_pybind_library( + name = "halsim_gui_ext", + install_path = "src/main/python/halsim_gui/_ext/", + extension_name = "_halsim_gui_ext", + generated_srcs = [], + semiwrap_header = [":halsim_gui_ext.gen_modinit_hpp"], + deps = [ + ":halsim_gui_ext.tmpl_hdrs", + ":halsim_gui_ext.trampoline_hdrs", + "//hal:wpiHal", + "//hal:wpihal_pybind_library", + "//ntcore:ntcore", + "//ntcore:ntcore_pybind_library", + "//simulation/halsim_gui:halsim_gui", + "//wpimath:wpimath", + "//wpimath:wpimath_pybind_library", + ], + dynamic_deps = [ + "//hal:shared/wpiHal", + "//simulation/halsim_gui:shared/halsim_gui", + "//ntcore:shared/ntcore", + "//wpimath:shared/wpimath", + ], + extra_hdrs = extra_hdrs, + extra_srcs = srcs, + includes = includes, + ) + + native.filegroup( + name = "halsim_gui_ext.generated_files", + srcs = [ + "halsim_gui_ext.gen_modinit_hpp.gen", + "halsim_gui_ext.header_gen_files", + "halsim_gui_ext.gen_pkgconf", + "halsim_gui_ext.gen_lib_init", + ], + tags = ["manual", "robotpy"], + ) + +def define_pybind_library(name, pkgcfgs = []): + # Helper used to generate all files with one target. + native.filegroup( + name = "{}.generated_files".format(name), + srcs = [ + "halsim_gui_ext.generated_files", + ], + tags = ["manual", "robotpy"], + visibility = ["//visibility:public"], + ) + + # Files that will be included in the wheel as data deps + native.filegroup( + name = "{}.generated_pkgcfg_files".format(name), + srcs = [ + "src/main/python/halsim_gui/_ext/halsim_gui_ext.pc", + ], + tags = ["manual", "robotpy"], + visibility = ["//visibility:public"], + ) + + # Contains all of the non-python files that need to be included in the wheel + native.filegroup( + name = "{}.extra_files".format(name), + srcs = native.glob(["src/main/python/halsim_gui/**"], exclude = ["src/main/python/halsim_gui/**/*.py"], allow_empty = True), + tags = ["manual", "robotpy"], + ) + + generate_version_file( + name = "{}.generate_version".format(name), + output_file = "src/main/python/halsim_gui/version.py", + template = "//shared/bazel/rules/robotpy:version_template.in", + ) + + copy_native_file( + name = "halsim_gui", + library = ":shared/halsim_gui", + base_path = "src/main/python/halsim_gui/", + ) + + robotpy_library( + name = name, + srcs = native.glob(["src/main/python/halsim_gui/**/*.py"]) + [ + "src/main/python/halsim_gui/_ext/_init__halsim_gui_ext.py", + "{}.generate_version".format(name), + ], + data = [ + "{}.generated_pkgcfg_files".format(name), + "{}.extra_files".format(name), + ":src/main/python/halsim_gui/_ext/_halsim_gui_ext", + ":halsim_gui_ext.trampoline_hdr_files", + ":halsim_gui.copy_lib", + ], + imports = ["src/main/python"], + deps = [ + "//hal:robotpy-hal", + "//ntcore:pyntcore", + "//wpimath:robotpy-wpimath", + "//wpiutil:robotpy-wpiutil", + ], + visibility = ["//visibility:public"], + ) diff --git a/simulation/halsim_gui/src/main/python/README.md b/simulation/halsim_gui/src/main/python/README.md new file mode 100644 index 0000000000..efc66da47a --- /dev/null +++ b/simulation/halsim_gui/src/main/python/README.md @@ -0,0 +1,18 @@ +robotpy-halsim-gui +================== + +Installing this package will allow you to utilize the 2020+ WPILib GUI +simulation from a RobotPy program. + +Usage +----- + +First, install pyfrc. Then run your robot with the 'sim' argument: + + # Windows + py -3 -m robotpy sim + + # Linux/OSX + python3 -m robotpy sim + +WPILib's documentation for using the simulator can be found at http://docs.wpilib.org/en/latest/docs/software/wpilib-tools/robot-simulation/simulation-gui.html diff --git a/simulation/halsim_gui/src/main/python/halsim_gui/__init__.py b/simulation/halsim_gui/src/main/python/halsim_gui/__init__.py new file mode 100644 index 0000000000..cae2ac0d3d --- /dev/null +++ b/simulation/halsim_gui/src/main/python/halsim_gui/__init__.py @@ -0,0 +1 @@ +from .main import loadExtension diff --git a/simulation/halsim_gui/src/main/python/halsim_gui/_ext/__init__.py b/simulation/halsim_gui/src/main/python/halsim_gui/_ext/__init__.py new file mode 100644 index 0000000000..c3ad7249c9 --- /dev/null +++ b/simulation/halsim_gui/src/main/python/halsim_gui/_ext/__init__.py @@ -0,0 +1 @@ +from . import _init__halsim_gui_ext diff --git a/simulation/halsim_gui/src/main/python/halsim_gui/_ext/main.cpp b/simulation/halsim_gui/src/main/python/halsim_gui/_ext/main.cpp new file mode 100644 index 0000000000..a566a30865 --- /dev/null +++ b/simulation/halsim_gui/src/main/python/halsim_gui/_ext/main.cpp @@ -0,0 +1,44 @@ + +#include + +#include + +#include +#include + +std::function g_gui_exit; + +SEMIWRAP_PYBIND11_MODULE(m) { + + initWrapper(m); + + m.def("_kill_on_signal", []() { + HAL_RegisterExtensionListener( + nullptr, [](void *, const char *name, void *data) { + std::string_view name_view{name}; + if (name_view == HALSIMGUI_EXT_GUIEXIT) { + g_gui_exit = (halsimgui::GuiExitFn)data; + } else if (name_view == HALSIMGUI_EXT_ADDGUILATEEXECUTE) { + auto AddGuiLateExecute = (halsimgui::AddGuiLateExecuteFn)data; + AddGuiLateExecute([] { + py::gil_scoped_acquire gil; + if (PyErr_CheckSignals() == -1) { + + // If a python signal has been triggered, the GUI needs to exit. It's + // not safe to throw an exception here on all platforms so we just + // assume that the only eventual caller of this function is HAL_RunMain, + // and our wrapper around that function will check if a python error is + // set and throw from there. + // + // Reference: https://github.com/wpilibsuite/allwpilib/issues/8528 + if (g_gui_exit) { + g_gui_exit(); + } else { + throw py::error_already_set(); + } + } + }); + } + }); + }); +} \ No newline at end of file diff --git a/simulation/halsim_gui/src/main/python/halsim_gui/main.py b/simulation/halsim_gui/src/main/python/halsim_gui/main.py new file mode 100644 index 0000000000..6068aa3a64 --- /dev/null +++ b/simulation/halsim_gui/src/main/python/halsim_gui/main.py @@ -0,0 +1,27 @@ +import logging +import os +from os.path import abspath, dirname, join + +logger = logging.getLogger("halsim_gui") + + +def loadExtension(): + try: + import hal + except ImportError as e: + # really, should never happen... + raise ImportError("you must install robotpy-hal!") from e + + from .version import version + + logger.info("WPILib HAL Simulation %s", version) + + root = join(abspath(dirname(__file__)), "lib") + ext = join(root, os.listdir(root)[0]) + retval = hal.loadOneExtension(ext) + if retval != 0: + logger.warn("loading extension may have failed (error=%d)", retval) + + from ._ext._halsim_gui_ext import _kill_on_signal + + _kill_on_signal() diff --git a/simulation/halsim_gui/src/main/python/halsim_gui/py.typed b/simulation/halsim_gui/src/main/python/halsim_gui/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/simulation/halsim_gui/src/main/python/pyproject.toml b/simulation/halsim_gui/src/main/python/pyproject.toml new file mode 100644 index 0000000000..714efe734c --- /dev/null +++ b/simulation/halsim_gui/src/main/python/pyproject.toml @@ -0,0 +1,57 @@ +[build-system] +build-backend = "hatchling.build" +requires = [ + "semiwrap~=0.2.1", + "hatch-meson~=0.1.0", + "hatch-robotpy~=0.2.1", + "hatchling", + "robotpy-wpiutil==0.0.0", + "robotpy-wpimath==0.0.0", + "robotpy-hal==0.0.0", + "pyntcore==0.0.0", +] + +[project] +name = "robotpy-halsim-gui" +version = "0.0.0" +description = "WPILib simulation GUI" +authors = [ + {name = "RobotPy Development Team", email = "robotpy@googlegroups.com"}, +] +license = "BSD-3-Clause" +dependencies = [ + "robotpy-wpiutil==0.0.0", + "robotpy-wpimath==0.0.0", + "robotpy-hal==0.0.0", + "pyntcore==0.0.0", +] + +[project.urls] +"Source code" = "https://github.com/robotpy/mostrobotpy" + + +[tool.hatch.build.hooks.robotpy] +version_file = "halsim_gui/version.py" + +[[tool.hatch.build.hooks.robotpy.maven_lib_download]] +artifact_id = "halsim_gui" +group_id = "org.wpilib.halsim" +repo_url = "" +version = "0.0.0" +use_headers = true + +libs = ["halsim_gui"] +extract_to = "halsim_gui" + +[tool.hatch.build.hooks.semiwrap] + +[tool.hatch.build.hooks.meson] + +[tool.hatch.build.targets.wheel] +packages = ["halsim_gui"] + + +[tool.semiwrap] +[tool.semiwrap.extension_modules."halsim_gui._ext._halsim_gui_ext"] +name = "halsim_gui_ext" +depends = ["wpihal", "wpimath", "ntcore"] diff --git a/simulation/halsim_gui/src/test/python/test_halsim_gui.py b/simulation/halsim_gui/src/test/python/test_halsim_gui.py new file mode 100644 index 0000000000..65d40386b9 --- /dev/null +++ b/simulation/halsim_gui/src/test/python/test_halsim_gui.py @@ -0,0 +1,20 @@ +import ctypes +import pathlib + + +def test_halsim_gui(): + # dependencies + import wpinet + import hal + import wpimath + import ntcore + + import halsim_gui as base + + loaded = 0 + for fname in (pathlib.Path(base.__file__).parent / "lib").iterdir(): + if fname.is_file() and fname.suffix in (".dll", ".dylib", ".so"): + ctypes.CDLL(str(fname)) + loaded += 1 + + assert loaded diff --git a/simulation/halsim_ws_core/BUILD.bazel b/simulation/halsim_ws_core/BUILD.bazel index 0674d31703..38e290b678 100644 --- a/simulation/halsim_ws_core/BUILD.bazel +++ b/simulation/halsim_ws_core/BUILD.bazel @@ -1,6 +1,10 @@ +load("@allwpilib_pip_deps//:requirements.bzl", "requirement") +load("@rules_python//python:defs.bzl", "py_library") load("//shared/bazel/rules:cc_rules.bzl", "wpilib_cc_library", "wpilib_cc_shared_library", "wpilib_cc_static_library") load("//shared/bazel/rules:packaging.bzl", "package_default_cc_project") load("//shared/bazel/rules:publishing.bzl", "host_architectures") +load("//shared/bazel/rules/robotpy:pybind_rules.bzl", "copy_native_file") +load("//shared/bazel/rules/robotpy:pytest_util.bzl", "robotpy_py_test") wpilib_cc_library( name = "halsim_ws_core", @@ -54,3 +58,39 @@ package_default_cc_project( maven_artifact_name = "halsim_ws_core", maven_group_id = "org.wpilib.halsim", ) + +copy_native_file( + name = "halsim_ws_client", + base_path = "src/main/python/halsim_ws/client/", + library = "//simulation/halsim_ws_client:shared/halsim_ws_client", +) + +copy_native_file( + name = "halsim_ws_server", + base_path = "src/main/python/halsim_ws/server/", + library = "//simulation/halsim_ws_server:shared/halsim_ws_server", +) + +py_library( + name = "robotpy-halsim-ws", + srcs = glob(["src/main/python/**/*.py"]), + data = [ + ":halsim_ws_client.copy_lib", + ":halsim_ws_server.copy_lib", + ], + imports = ["src/main/python"], + visibility = ["//visibility:public"], + deps = [ + "//hal:robotpy-native-wpihal", + "//wpinet:robotpy-native-wpinet", + ], +) + +robotpy_py_test( + "python_tests", + srcs = glob(["src/test/python/**/*.py"]), + deps = [ + ":robotpy-halsim-ws", + requirement("pytest"), + ], +) diff --git a/simulation/halsim_ws_core/src/main/python/README.md b/simulation/halsim_ws_core/src/main/python/README.md new file mode 100644 index 0000000000..a1d4340181 --- /dev/null +++ b/simulation/halsim_ws_core/src/main/python/README.md @@ -0,0 +1,19 @@ +robotpy-halsim-ws +================== + +Installing this package will allow you to utilize the 2020+ WPILib websim from a RobotPy program. + +Usage +----- + +First, install pyfrc. Then run your robot with the 'sim' argument and --ws-server or --ws-client flag: + + # Windows + py -3 -m robotpy sim --ws-server + py -3 -m robotpy sim --ws-client + + # Linux/OSX + python3 -m robotpy sim --ws-server + python3 -m robotpy sim --ws-client + +WPILib's documentation for using the simulator can be found at http://docs.wpilib.org/en/latest/docs/software/wpilib-tools/robot-simulation/ diff --git a/simulation/halsim_ws_core/src/main/python/halsim_ws/__init__.py b/simulation/halsim_ws_core/src/main/python/halsim_ws/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/simulation/halsim_ws_core/src/main/python/halsim_ws/client/__init__.py b/simulation/halsim_ws_core/src/main/python/halsim_ws/client/__init__.py new file mode 100644 index 0000000000..cae2ac0d3d --- /dev/null +++ b/simulation/halsim_ws_core/src/main/python/halsim_ws/client/__init__.py @@ -0,0 +1 @@ +from .main import loadExtension diff --git a/simulation/halsim_ws_core/src/main/python/halsim_ws/client/main.py b/simulation/halsim_ws_core/src/main/python/halsim_ws/client/main.py new file mode 100644 index 0000000000..e907d19a38 --- /dev/null +++ b/simulation/halsim_ws_core/src/main/python/halsim_ws/client/main.py @@ -0,0 +1,23 @@ +import logging +import os +from os.path import abspath, dirname, join + +logger = logging.getLogger("halsim_ws.client") + + +def loadExtension(): + try: + import hal + except ImportError as e: + # really, should never happen... + raise ImportError("you must install robotpy-hal!") from e + + from ..version import version + + logger.info("WPILib HAL Simulation websim client %s", version) + + root = join(abspath(dirname(__file__)), "lib") + ext = join(root, os.listdir(root)[0]) + retval = hal.loadOneExtension(ext) + if retval != 0: + logger.warn("loading extension may have failed (error=%d)", retval) diff --git a/simulation/halsim_ws_core/src/main/python/halsim_ws/py.typed b/simulation/halsim_ws_core/src/main/python/halsim_ws/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/simulation/halsim_ws_core/src/main/python/halsim_ws/server/__init__.py b/simulation/halsim_ws_core/src/main/python/halsim_ws/server/__init__.py new file mode 100644 index 0000000000..cae2ac0d3d --- /dev/null +++ b/simulation/halsim_ws_core/src/main/python/halsim_ws/server/__init__.py @@ -0,0 +1 @@ +from .main import loadExtension diff --git a/simulation/halsim_ws_core/src/main/python/halsim_ws/server/main.py b/simulation/halsim_ws_core/src/main/python/halsim_ws/server/main.py new file mode 100644 index 0000000000..91090563cf --- /dev/null +++ b/simulation/halsim_ws_core/src/main/python/halsim_ws/server/main.py @@ -0,0 +1,23 @@ +import logging +import os +from os.path import abspath, dirname, join + +logger = logging.getLogger("halsim_ws.server") + + +def loadExtension(): + try: + import hal + except ImportError as e: + # really, should never happen... + raise ImportError("you must install robotpy-hal!") from e + + from ..version import version + + logger.info("WPILib HAL Simulation websim server %s", version) + + root = join(abspath(dirname(__file__)), "lib") + ext = join(root, os.listdir(root)[0]) + retval = hal.loadOneExtension(ext) + if retval != 0: + logger.warn("loading extension may have failed (error=%d)", retval) diff --git a/simulation/halsim_ws_core/src/main/python/pyproject.toml b/simulation/halsim_ws_core/src/main/python/pyproject.toml new file mode 100644 index 0000000000..b2692d47d7 --- /dev/null +++ b/simulation/halsim_ws_core/src/main/python/pyproject.toml @@ -0,0 +1,55 @@ +[build-system] +build-backend = "hatchling.build" +requires = [ + "hatchling", + "hatch-robotpy~=0.2.1", +] + +[project] +name = "robotpy-halsim-ws" +version = "0.0.0" +description = "WPILib simulator websim Extensions" +authors = [ + {name = "RobotPy Development Team", email = "robotpy@googlegroups.com"}, +] +license = "BSD-3-Clause" +dependencies = [ + "robotpy-native-wpihal==0.0.0", + "robotpy-native-wpinet==0.0.0", +] + +[project.entry-points."robotpy_sim.2027"] +ws-server = "halsim_ws.server" +ws-client = "halsim_ws.client" + +[project.urls] +"Source code" = "https://github.com/robotpy/mostrobotpy" + + +[tool.hatch.build.hooks.robotpy] +version_file = "halsim_ws/version.py" + + +[[tool.hatch.build.hooks.robotpy.maven_lib_download]] +artifact_id = "halsim_ws_server" +group_id = "org.wpilib.halsim" +repo_url = "" +version = "0.0.0" +use_headers = false + +extract_to = "halsim_ws/server" +libs = ["halsim_ws_server"] + +[[tool.hatch.build.hooks.robotpy.maven_lib_download]] +artifact_id = "halsim_ws_client" +group_id = "org.wpilib.halsim" +repo_url = "" +version = "0.0.0" +use_headers = false + +extract_to = "halsim_ws/client" +libs = ["halsim_ws_client"] + + +[tool.hatch.build.targets.wheel] +packages = ["halsim_ws"] diff --git a/simulation/halsim_ws_core/src/test/python/test_halsim_ws_client.py b/simulation/halsim_ws_core/src/test/python/test_halsim_ws_client.py new file mode 100644 index 0000000000..1d99750052 --- /dev/null +++ b/simulation/halsim_ws_core/src/test/python/test_halsim_ws_client.py @@ -0,0 +1,18 @@ +import ctypes +import pathlib + + +def test_halsim_ws_client(): + # dependencies + import native.wpihal._init_robotpy_native_wpihal + import native.wpinet._init_robotpy_native_wpinet + + import halsim_ws.client as base + + loaded = 0 + for fname in (pathlib.Path(base.__file__).parent / "lib").iterdir(): + if fname.is_file() and fname.suffix in (".dll", ".dylib", ".so"): + ctypes.CDLL(str(fname)) + loaded += 1 + + assert loaded diff --git a/simulation/halsim_ws_core/src/test/python/test_halsim_ws_server.py b/simulation/halsim_ws_core/src/test/python/test_halsim_ws_server.py new file mode 100644 index 0000000000..4be822dab7 --- /dev/null +++ b/simulation/halsim_ws_core/src/test/python/test_halsim_ws_server.py @@ -0,0 +1,18 @@ +import ctypes +import pathlib + + +def test_halsim_ws_server(): + # dependencies + import native.wpihal._init_robotpy_native_wpihal + import native.wpinet._init_robotpy_native_wpinet + + import halsim_ws.server as base + + loaded = 0 + for fname in (pathlib.Path(base.__file__).parent / "lib").iterdir(): + if fname.is_file() and fname.suffix in (".dll", ".dylib", ".so"): + ctypes.CDLL(str(fname)) + loaded += 1 + + assert loaded diff --git a/wpilibc/BUILD.bazel b/wpilibc/BUILD.bazel index f346d2a3ee..09ebc0f972 100644 --- a/wpilibc/BUILD.bazel +++ b/wpilibc/BUILD.bazel @@ -270,7 +270,11 @@ define_pybind_library( robotpy_py_test( "python_tests", - srcs = glob(["src/test/python/**/*.py"]), + srcs = glob( + ["src/test/python/**/*.py"], + # TODO(pjreiniger) This excluded test needs the ENTRY_POINT hooks (i.e. have a dependency on a wheel) + exclude = ["src/test/python/test_pytest_plugins.py"], + ), tags = ["exclusive"], deps = [ ":robotpy-wpilib", diff --git a/wpilibc/robotpy_pybind_build_info.bzl b/wpilibc/robotpy_pybind_build_info.bzl index 5b4def166d..5a534aea6d 100644 --- a/wpilibc/robotpy_pybind_build_info.bzl +++ b/wpilibc/robotpy_pybind_build_info.bzl @@ -1,5 +1,6 @@ # THIS FILE IS AUTO GENERATED +load("@allwpilib_pip_deps//:requirements.bzl", "requirement") load("//shared/bazel/rules/gen:gen-version-file.bzl", "generate_version_file") load("//shared/bazel/rules/robotpy:pybind_rules.bzl", "create_pybind_library", "robotpy_library") load("//shared/bazel/rules/robotpy:semiwrap_helpers.bzl", "gen_libinit", "gen_modinit_hpp", "gen_pkgconf", "resolve_casters", "run_header_gen") @@ -1636,6 +1637,9 @@ def define_pybind_library(name, pkgcfgs = []): "//wpilibc:robotpy-native-wpilib", "//wpimath:robotpy-wpimath", "//wpiutil:robotpy-wpiutil", + requirement("pytest"), + requirement("pytest-reraise"), + requirement("robotpy-cli"), ], visibility = ["//visibility:public"], ) diff --git a/wpilibc/src/main/python/pyproject.toml b/wpilibc/src/main/python/pyproject.toml index e22cbf5ae2..c5658204af 100644 --- a/wpilibc/src/main/python/pyproject.toml +++ b/wpilibc/src/main/python/pyproject.toml @@ -26,14 +26,21 @@ dependencies = [ "robotpy-wpimath==0.0.0", "robotpy-hal==0.0.0", "pyntcore==0.0.0", - "robotpy-cli~=2027.0.0a1" + "robotpy-cli~=2027.0.0a1", + + # For running robot tests + "pytest>=3.9", + "pytest-reraise", ] [project.urls] "Source code" = "https://github.com/robotpy/mostrobotpy" [project.entry-points."robotpy_cli.2027"] -run = "wpilib._impl.start:Main" +add-tests = "wpilib._impl.cli_add_tests:AddTests" +run = "wpilib._impl.cli_run:Main" +sim = "wpilib._impl.cli_sim:RobotSim" +test = "wpilib._impl.cli_test:RobotTest" [tool.hatch.build.hooks.robotpy] diff --git a/wpilibc/src/main/python/semiwrap/IterativeRobotBase.yml b/wpilibc/src/main/python/semiwrap/IterativeRobotBase.yml index 035506a7a8..9f27f1c21c 100644 --- a/wpilibc/src/main/python/semiwrap/IterativeRobotBase.yml +++ b/wpilibc/src/main/python/semiwrap/IterativeRobotBase.yml @@ -18,9 +18,7 @@ classes: wpi::units::second_t: LoopFunc: SimulationInit: - internal: true SimulationPeriodic: - internal: true DisabledExit: AutonomousExit: TeleopExit: @@ -38,7 +36,8 @@ classes: This class provides the following functions which are called by the main loop, StartCompetition(), at the appropriate times: - RobotInit() -- provide for initialization at robot power-on + DriverStationConnected() -- provide for initialization the first time the DS + is connected Init() functions -- each of the following functions is called once when the appropriate mode is entered: diff --git a/wpilibc/src/main/python/wpilib/__init__.py b/wpilibc/src/main/python/wpilib/__init__.py index 3e483c75fe..d9a35bab70 100644 --- a/wpilibc/src/main/python/wpilib/__init__.py +++ b/wpilibc/src/main/python/wpilib/__init__.py @@ -228,6 +228,4 @@ try: except ImportError: __version__ = "master" -from ._impl.main import run - -__all__ += ["CameraServer", "run"] +__all__ += ["CameraServer"] diff --git a/wpilibc/src/main/python/wpilib/_impl/cli_add_tests.py b/wpilibc/src/main/python/wpilib/_impl/cli_add_tests.py new file mode 100644 index 0000000000..3d45036794 --- /dev/null +++ b/wpilibc/src/main/python/wpilib/_impl/cli_add_tests.py @@ -0,0 +1,51 @@ +import pathlib +import sys + +builtin_tests = """''' + This test module imports tests that come with wpilib, and can be used + to test basic functionality of just about any robot. +''' + +from wpilib.testing.robot_tests import * +""" + + +class AddTests: + """ + Adds default robot tests to your robot project directory + """ + + def __init__(self, parser=None): + pass + + def run(self, main_file: pathlib.Path, project_path: pathlib.Path): + if not main_file.exists(): + print( + f"ERROR: is this a robot project? {main_file} does not exist", + file=sys.stderr, + ) + return 1 + + try_dirs = [project_path / "tests", project_path / ".." / "tests"] + + test_directory = try_dirs[0] + + for d in try_dirs: + if d.exists(): + test_directory = d + break + else: + test_directory.mkdir(parents=True) + + print(f"Tests directory is {test_directory}") + print() + builtin_tests_file = test_directory / "robot_test.py" + if builtin_tests_file.exists(): + print("- robot_test.py already exists") + else: + with open(builtin_tests_file, "w") as fp: + fp.write(builtin_tests) + print("- builtin tests created at", builtin_tests_file) + + print() + print("Robot tests can be ran via 'python3 -m robotpy test'") diff --git a/wpilibc/src/main/python/wpilib/_impl/cli_run.py b/wpilibc/src/main/python/wpilib/_impl/cli_run.py new file mode 100644 index 0000000000..995c18fb7f --- /dev/null +++ b/wpilibc/src/main/python/wpilib/_impl/cli_run.py @@ -0,0 +1,10 @@ +class Main: + """ + Executes the robot code using the currently installed HAL (this is probably not what you want unless this is controlling the physical robot) + """ + + def __init__(self, parser): + pass + + def run(self, options, robot_class, **static_options): + return robot_class.main(robot_class) diff --git a/wpilibc/src/main/python/wpilib/_impl/cli_sim.py b/wpilibc/src/main/python/wpilib/_impl/cli_sim.py new file mode 100644 index 0000000000..eca00373cf --- /dev/null +++ b/wpilibc/src/main/python/wpilib/_impl/cli_sim.py @@ -0,0 +1,78 @@ +import os +import argparse +import importlib.metadata +import logging +import sys +import typing + +import wpilib + + +logger = logging.getLogger("robot.sim") + + +class RobotSim: + """ + Runs the robot in simulation mode + """ + + def __init__(self, parser: argparse.ArgumentParser): + parser.add_argument( + "--nogui", + default=False, + action="store_true", + help="Don't use the WPIlib simulation gui", + ) + + self.simexts = {} + + for entry_point in importlib.metadata.entry_points(group="robotpy_sim.2027"): + try: + sim_ext_module = entry_point.load() + except ImportError: + print(f"WARNING: Error detected in {entry_point}", file=sys.stderr) + continue + + self.simexts[entry_point.name] = sim_ext_module + + try: + cmd_help = importlib.metadata.metadata(entry_point.dist.name)["summary"] + except AttributeError: + cmd_help = "Load specified simulation extension" + parser.add_argument( + f"--{entry_point.name}", + default=False, + action="store_true", + help=cmd_help, + ) + + def run( + self, + options: argparse.Namespace, + nogui: bool, + robot_class: typing.Type[wpilib.RobotBase], + ): + if not nogui: + try: + import halsim_gui + except ImportError: + print("robotpy-halsim-gui is not installed!", file=sys.stderr) + exit(1) + else: + halsim_gui.loadExtension() + + # Some extensions (gui) changes the current directory + cwd = os.getcwd() + + for name, module in self.simexts.items(): + if getattr(options, name.replace("-", "_"), False): + try: + module.loadExtension() + except: + print(f"Error loading {name}!", file=sys.stderr) + raise + + os.chdir(cwd) + + # run the robot + return robot_class.main(robot_class) diff --git a/wpilibc/src/main/python/wpilib/_impl/cli_test.py b/wpilibc/src/main/python/wpilib/_impl/cli_test.py new file mode 100644 index 0000000000..883d44d447 --- /dev/null +++ b/wpilibc/src/main/python/wpilib/_impl/cli_test.py @@ -0,0 +1,244 @@ +import logging +import os +from os.path import abspath +import inspect +import pathlib +import sys +import tomllib +import typing + +import wpilib + +import pytest + +from ..testing import pytest_plugin + +# TODO: setting the plugins so that the end user can invoke pytest directly +# could be a useful thing. Will have to consider that later. + +logger = logging.getLogger("test") + + +class _TryAgain(Exception): + pass + + +# +# main test class +# + + +class RobotTest: + """ + Executes unit tests on the robot code using a special pytest plugin + """ + + def __init__(self, parser=None): + if parser: + parser.add_argument( + "--builtin", + default=False, + action="store_true", + help="Use builtin tests if no tests are specified", + ) + isolation_group = parser.add_mutually_exclusive_group() + isolation_group.add_argument( + "--isolated", + default=None, + dest="isolated", + action="store_true", + help="Run each test in a separate robot process (default). Set `tool.robotpy.testing.isolated` in your pyproject.toml to control the default", + ) + isolation_group.add_argument( + "--no-isolation", + dest="isolated", + action="store_false", + help="Disable isolated test mode and run tests in-process", + ) + parser.add_argument( + "--coverage-mode", + default=False, + action="store_true", + help="This flag is passed when trying to determine coverage", + ) + parser.add_argument( + "pytest_args", + nargs="*", + help="To pass args to pytest, specify --, then the args", + ) + parser.add_argument( + "-j", + "--jobs", + type=int, + default=-1, + help="Maximum isolated robot processes (default: max CPUs - 1)", + ) + + def run( + self, + main_file: pathlib.Path, + project_path: pathlib.Path, + robot_class: typing.Type[wpilib.RobotBase], + builtin: bool, + isolated: typing.Optional[bool], + coverage_mode: bool, + verbose: bool, + pytest_args: typing.List[str], + jobs: int, + ): + if isolated is None: + pyproject_path = project_path / "pyproject.toml" + if pyproject_path.exists(): + with open(pyproject_path, "rb") as fp: + d = tomllib.load(fp) + + try: + v = d["tool"]["robotpy"]["testing"]["isolated"] + except KeyError: + pass + else: + if not isinstance(v, bool): + raise ValueError( + f"tool.robotpy.testing.isolated must be a boolean value (got {v})" + ) + + isolated = v + + if isolated is None: + isolated = True + + try: + return self._run_test( + main_file, + project_path, + robot_class, + builtin, + isolated, + coverage_mode, + verbose, + pytest_args, + jobs, + ) + except _TryAgain: + return self._run_test( + main_file, + project_path, + robot_class, + builtin, + isolated, + coverage_mode, + verbose, + pytest_args, + jobs, + ) + + def _run_test( + self, + main_file: pathlib.Path, + project_path: pathlib.Path, + robot_class: typing.Type[wpilib.RobotBase], + builtin: bool, + isolated: bool, + coverage_mode: bool, + verbose: bool, + pytest_args: typing.List[str], + jobs: int, + ): + # find test directory, change current directory so pytest can find the tests + # -> assume that tests reside in tests or ../tests + + curdir = pathlib.Path.cwd().absolute() + + self.try_dirs = [ + ((project_path / "tests").absolute(), False), + ((project_path / ".." / "tests").absolute(), True), + ] + + for d, chdir in self.try_dirs: + if d.exists(): + builtin = False + if chdir: + os.chdir(d) + break + else: + if not builtin: + print("ERROR: Cannot run robot tests, as test directory was not found!") + retv = self._no_tests(main_file, project_path) + return 1 + + from ..testing import robot_tests + + pytest_args.insert(0, abspath(inspect.getfile(robot_tests))) + + try: + if isolated: + from ..testing import pytest_isolated_tests_plugin + + retv = pytest.main( + pytest_args, + plugins=[ + pytest_isolated_tests_plugin.IsolatedTestsPlugin( + robot_class, main_file, builtin, verbose, jobs + ) + ], + ) + else: + retv = pytest.main( + pytest_args, + plugins=[ + pytest_plugin.RobotTestingPlugin(robot_class, main_file, False) + ], + ) + finally: + os.chdir(curdir) + + # requires pytest 2.8.x + if retv == 5: + print() + print("ERROR: a tests directory was found, but no tests were defined") + retv = self._no_tests(main_file, project_path, retv) + + return retv + + def _no_tests( + self, main_file: pathlib.Path, project_path: pathlib.Path, r: int = 1 + ): + print() + print("Looked for tests at:") + for d, _ in self.try_dirs: + print("-", d) + print() + print( + "If you don't want to write your own tests, wpilib comes with generic tests" + ) + print("that can test basic functionality of most robots. You can run them by") + print("specifying the --builtin option.") + print() + + if not sys.stdin.isatty(): + print( + "Alternatively, to create a tests directory with the builtin tests, you can run:" + ) + print() + print(" python3 -m robotpy add-tests") + print() + else: + if yesno("Create a tests directory with builtin tests now?"): + from .cli_add_tests import AddTests + + add_tests = AddTests() + add_tests.run(main_file, project_path) + + raise _TryAgain() + + return r + + +def yesno(prompt): + """Returns True if user answers 'y'""" + prompt += " [y/n]" + a = "" + while a not in ["y", "n"]: + a = input(prompt).lower() + + return a == "y" diff --git a/wpilibc/src/main/python/wpilib/_impl/main.py b/wpilibc/src/main/python/wpilib/_impl/main.py deleted file mode 100644 index 80de3ee24c..0000000000 --- a/wpilibc/src/main/python/wpilib/_impl/main.py +++ /dev/null @@ -1,18 +0,0 @@ -import inspect -import sys - - -def run(robot_class, **kwargs): - """ - ``wpilib.run`` is no longer used. You should run your robot code via one of - the following methods instead: - - * Windows: ``py -m robotpy [arguments]`` - * Linux/macOS: ``python -m robotpy [arguments]`` - - In a virtualenv the ``robotpy`` command can be used directly. - """ - - msg = inspect.cleandoc(inspect.getdoc(run) or "`wpilib.run` is no longer used") - print(msg, file=sys.stderr) - sys.exit(1) diff --git a/wpilibc/src/main/python/wpilib/_impl/start.py b/wpilibc/src/main/python/wpilib/_impl/start.py index 6c87d074e0..10c6af8086 100644 --- a/wpilibc/src/main/python/wpilib/_impl/start.py +++ b/wpilibc/src/main/python/wpilib/_impl/start.py @@ -77,18 +77,6 @@ def _log_versions(robotpy_version: typing.Optional[str]): logger.debug("%s version %s", k, v) -class Main: - """ - Executes the robot code using the currently installed HAL (this is probably not what you want unless you're on the roboRIO) - """ - - def __init__(self, parser): - pass - - def run(self, options, robot_class, **static_options): - return robot_class.main(robot_class) - - class RobotStarter: def __init__(self): self.logger = logging.getLogger("robotpy") diff --git a/wpilibc/src/main/python/wpilib/opmoderobot.py b/wpilibc/src/main/python/wpilib/opmoderobot.py index 9a26f71b24..f81a13a4f8 100644 --- a/wpilibc/src/main/python/wpilib/opmoderobot.py +++ b/wpilibc/src/main/python/wpilib/opmoderobot.py @@ -6,6 +6,7 @@ __all__ = ["OpModeRobot"] from ._wpilib import OpModeRobotBase, OpMode + class OpModeRobot(OpModeRobotBase): """ OpModeRobot implements the opmode-based robot program framework. @@ -23,14 +24,16 @@ class OpModeRobot(OpModeRobotBase): def __init__(self): super().__init__() - def addOpMode(self, - opmodeCls: type, - mode: RobotMode, - name: str, - group: Optional[str] = None, - description: Optional[str] = None, - textColor: Optional[Color] = None, - backgroundColor: Optional[Color] = None) -> None: + def addOpMode( + self, + opmodeCls: type, + mode: RobotMode, + name: str, + group: Optional[str] = None, + description: Optional[str] = None, + textColor: Optional[Color] = None, + backgroundColor: Optional[Color] = None, + ) -> None: """ Adds an operating mode option. It's necessary to call PublishOpModes() to make the added modes visible to the driver station. @@ -48,6 +51,7 @@ class OpModeRobot(OpModeRobotBase): :param textColor: text color :param backgroundColor: background color """ + def makeOpModeInstance() -> OpMode: # Try to instantiate with robot argument first try: @@ -55,7 +59,18 @@ class OpModeRobot(OpModeRobotBase): except TypeError: # Fallback to no-argument constructor return opmodeCls() # type: ignore + if textColor is None or backgroundColor is None: - self.addOpModeFactory(makeOpModeInstance, mode, name, group or "", description or "") + self.addOpModeFactory( + makeOpModeInstance, mode, name, group or "", description or "" + ) else: - self.addOpModeFactory(makeOpModeInstance, mode, name, group or "", description or "", textColor, backgroundColor) + self.addOpModeFactory( + makeOpModeInstance, + mode, + name, + group or "", + description or "", + textColor, + backgroundColor, + ) diff --git a/wpilibc/src/main/python/wpilib/testing/__init__.py b/wpilibc/src/main/python/wpilib/testing/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/wpilibc/src/main/python/wpilib/testing/controller.py b/wpilibc/src/main/python/wpilib/testing/controller.py new file mode 100644 index 0000000000..9fb5c1ccbc --- /dev/null +++ b/wpilibc/src/main/python/wpilib/testing/controller.py @@ -0,0 +1,163 @@ +import contextlib +import time +import threading +import typing as T + +import pytest + +from .. import RobotBase +from ..simulation import ( + DriverStationSim, + stepTiming, + stepTimingAsync, + getProgramStarted, +) +from hal._wpiHal import _RobotMode as RobotMode + + +class RobotTestController: + """ + Use this object to control the robot's state during tests + """ + + def __init__(self, reraise, robot: RobotBase): + self._reraise = reraise + + self._thread: T.Optional[threading.Thread] = None + self._robot = robot + + self._cond = threading.Condition() + self._robot_started = False + self._robot_finished = False + + def _robot_thread(self, robot: RobotBase): + with self._cond: + self._robot_started = True + self._cond.notify_all() + + with self._reraise(catch=True): + assert robot is not None # shouldn't happen... + + try: + robot.startCompetition() + assert self._robot_finished + finally: + # always call endCompetition or python hangs + robot.endCompetition() + del robot + + @contextlib.contextmanager + def run_robot(self): + """ + Use this in a "with" statement to start your robot code at the + beginning of the with block, and end your robot code at the end + of the with block. + """ + + # remove robot reference so it gets cleaned up when gc.collect() is called + robot = self._robot + assert robot is not None + self._robot = None + + self._thread = th = threading.Thread( + target=self._robot_thread, args=(robot,), daemon=True + ) + th.start() + + with self._cond: + # make sure the thread didn't die + assert self._cond.wait_for(lambda: self._robot_started, timeout=1) + + # This is the same thing that waitForProgramStart does + for _ in range(1000): + if getProgramStarted(): + break + time.sleep(0.001) + else: + assert False, "robot never started" + + try: + # in this block you should tell the sim to do sim things + yield + finally: + self._robot_finished = True + robot.endCompetition() + + if isinstance(self._reraise.exception, RuntimeError): + if str(self._reraise.exception).startswith( + "HAL: A handle parameter was passed incorrectly" + ): + msg = ( + "Do not reuse HAL objects in tests! This error may occur if you" + " stored a motor/sensor as a global or as a class variable" + " outside of a method." + ) + if hasattr(Exception, "add_note"): + self._reraise.exception.add_note(f"*** {msg}") + else: + e = self._reraise.exception + self._reraise.reset() + raise RuntimeError(msg) from e + + # Increment time by 1 second to ensure that any notifiers fire + stepTimingAsync(1.0) + + # the robot thread should exit quickly + th.join(timeout=1) + if th.is_alive(): + pytest.fail("robot did not exit within 2 seconds") + + self._robot = None + self._thread = None + + @property + def robot_is_alive(self) -> bool: + """ + True if the robot code is alive + """ + th = self._thread + if not th: + return False + + return th.is_alive() + + def step_timing( + self, + *, + seconds: float, + autonomous: bool, + enabled: bool, + assert_alive: bool = True, + ) -> float: + """ + This utility will increment simulated time, while pretending that + there's a driver station connected and delivering new packets + every 0.2 seconds. + + :param seconds: Number of seconds to run (will step in increments of 0.2) + :param autonomous: Tell the robot that it is in autonomous mode + :param enabled: Tell the robot that it is enabled + + :returns: Number of seconds time was incremented + """ + + assert self.robot_is_alive, "did you call control.run_robot()?" + + assert seconds > 0 + + DriverStationSim.setDsAttached(True) + DriverStationSim.setRobotMode( + RobotMode.AUTONOMOUS if autonomous else RobotMode.TELEOPERATED + ) + DriverStationSim.setEnabled(enabled) + + tm = 0.0 + + while tm < seconds + 0.01: + DriverStationSim.notifyNewData() + stepTiming(0.2) + if assert_alive: + assert self.robot_is_alive + tm += 0.2 + + return tm diff --git a/wpilibc/src/main/python/wpilib/testing/pytest_isolated_tests_plugin.py b/wpilibc/src/main/python/wpilib/testing/pytest_isolated_tests_plugin.py new file mode 100644 index 0000000000..fa16ab9792 --- /dev/null +++ b/wpilibc/src/main/python/wpilib/testing/pytest_isolated_tests_plugin.py @@ -0,0 +1,461 @@ +import dataclasses +import logging +import multiprocessing +import multiprocessing.connection +import os +import pathlib +import signal +import sys +import time +import typing as T + +import pytest + +import robotpy.main +import wpilib + + +from .pytest_plugin import RobotTestingPlugin + + +def _enable_faulthandler(): + # + # In the event of a segfault, faulthandler will dump the currently + # active stack so you can figure out what went wrong. + # + # Additionally, on non-Windows platforms we register a SIGUSR2 + # handler -- if you send the robot process a SIGUSR2, then + # faulthandler will dump all of your current stacks. This can + # be really useful for figuring out things like deadlocks. + # + + import logging + + logger = logging.getLogger("faulthandler") + + try: + # These should work on all platforms + import faulthandler + + faulthandler.enable() + except Exception as e: + logger.warning("Could not enable faulthandler: %s", e) + return + + try: + faulthandler.register(signal.SIGUSR2) + logger.info("registered SIGUSR2 for PID %s", os.getpid()) + except Exception: + return + + +class WorkerPlugin: + """ + This pytest plugin runs in the isolated process that runs a test that uses the + robot fixture. + + Heavily borrowed from pytest-xdist WorkerInteractor + """ + + def __init__(self, channel: multiprocessing.connection.Connection): + self.channel = channel + + def sendevent(self, name: str, **kwargs: object): + self.channel.send((name, kwargs)) + + @pytest.hookimpl(wrapper=True) + def pytest_sessionstart(self, session: pytest.Session): + self.config = session.config + return (yield) + + @pytest.hookimpl + def pytest_internalerror(self, excrepr: object): + formatted_error = str(excrepr) + for line in formatted_error.split("\n"): + print("IERROR>", line, file=sys.stderr) + self.sendevent("internal_error", formatted_error=formatted_error) + + @pytest.hookimpl + def pytest_runtest_logstart( + self, + nodeid: str, + location: tuple[str, int | None, str], + ): + self.sendevent("logstart", nodeid=nodeid, location=location) + + @pytest.hookimpl + def pytest_runtest_logfinish( + self, + nodeid: str, + location: tuple[str, int | None, str], + ): + self.sendevent("logfinish", nodeid=nodeid, location=location) + + @pytest.hookimpl + def pytest_runtest_logreport(self, report: pytest.TestReport): + data = self.config.hook.pytest_report_to_serializable( + config=self.config, report=report + ) + self.sendevent("testreport", data=data) + + +def _run_test( + item_nodeid, config_args, robot_class, robot_file, verbose, pipe, root_path +): + """This function runs in a subprocess""" + logging.root.addHandler(logging.NullHandler()) + logging.root.setLevel(logging.DEBUG if verbose else logging.INFO) + + _enable_faulthandler() + + # This is used by getDeployDirectory, so make sure it gets fixed + robotpy.main.robot_py_path = robot_file + + os.chdir(root_path) + + # keep the plugins around because it has a reference to the robot + # and we don't want it to die and deadlock + plugin = RobotTestingPlugin(robot_class, robot_file, True) + worker_plugin = WorkerPlugin(pipe) + + ec = pytest.main( + [item_nodeid, "--no-header", "-p", "no:terminalreporter", *config_args], + plugins=[plugin, worker_plugin], + ) + + # ensure output is printed out + sys.stdout.flush() + + # Don't let the process die, let the parent kill us to avoid + # python interpreter badness + worker_plugin.sendevent("finished", exit_code=ec) + pipe.close() + + # ensure that the gc doesn't collect the plugin.. + while plugin: + time.sleep(100) + + +@dataclasses.dataclass +class IsolatedTestJob: + item: pytest.Function + conn: multiprocessing.connection.Connection + process: multiprocessing.Process + start_time: float + exit_code: int | None = None + + finished: bool = False + + # set when the worker indicates it has finished + worker_completed: bool = False + + def set_exit_code(self, ec: int): + if self.exit_code is None: + self.exit_code = ec + + +class IsolatedTestsPlugin: + """ + This pytest plugin runs any test that uses the 'robot' fixture in an + isolated subprocess + """ + + def __init__( + self, + robot_class: T.Type[wpilib.RobotBase], + robot_file: pathlib.Path, + builtin_tests: bool, + verbose: bool, + parallelism: int, + ): + self._robot_class = robot_class + self._robot_file = robot_file + self._builtin_tests = builtin_tests + self._verbose = verbose + + if parallelism < 1: + try: + parallelism = multiprocessing.cpu_count() - 1 + except NotImplementedError: + parallelism = 1 + + self._parallelism = max(1, parallelism) + self._shouldstop = False + + @pytest.hookimpl(wrapper=True) + def pytest_sessionstart(self, session: pytest.Session): + self._config = session.config + self._maxfail: int = self._config.getvalue("maxfail") + self._countfailures = 0 + self._shouldstop = False + + multiprocessing.set_start_method("spawn") + + return (yield) + + @pytest.hookimpl + def pytest_runtestloop(self, session: pytest.Session) -> bool: + if ( + session.testsfailed + and not session.config.option.continue_on_collection_errors + ): + raise session.Interrupted( + f"{session.testsfailed} error{'s' if session.testsfailed != 1 else ''} during collection" + ) + + if session.config.option.collectonly: + return True + + running: list[IsolatedTestJob] = [] + deferred: list[pytest.Function] = [] + try: + # Start any tests that use the robot fixture first. Tests that don't + # use the robot fixture will be ran later + for item in session.items: + assert isinstance(item, pytest.Function) + if "robot" not in item.fixturenames: + deferred.append(item) + continue + + while len(running) >= self._parallelism: + self._wait_for_jobs(running, session) + + running.append(self._start_isolated_test(item)) + self._maybe_raise(session) + + # Run the in-process tests now while the robot tests are finishing + for idx, item in enumerate(deferred): + nextitem = deferred[idx + 1] if idx + 1 < len(deferred) else None + session.config.hook.pytest_runtest_protocol( + item=item, nextitem=nextitem + ) + self._maybe_raise(session) + + while running: + self._wait_for_jobs(running, session) + finally: + for job in running: + self._cleanup_job(job) + + return True + + def _start_isolated_test(self, item: pytest.Function) -> IsolatedTestJob: + + config_args = self._config.invocation_params.args + if self._builtin_tests: + nodeid = f"{config_args[0]}::{item.name}" + config_args = config_args[1:] + else: + nodeid = item.nodeid + + pconn, cconn = multiprocessing.Pipe() + process = multiprocessing.Process( + target=_run_test, + args=( + nodeid, + config_args, + self._robot_class, + self._robot_file, + self._verbose, + cconn, + self._config.rootpath, + ), + ) + process.start() + cconn.close() + + return IsolatedTestJob( + item=item, + conn=pconn, + process=process, + start_time=time.time(), + ) + + def _wait_for_jobs(self, running: list[IsolatedTestJob], session: pytest.Session): + if not running: + return + + ready = multiprocessing.connection.wait([job.conn for job in running]) + + for conn in ready: + job = next(job for job in running if job.conn == conn) + self._process_job_messages(job, session) + if job.finished: + running.remove(job) + self._finalize_job(job, session) + + def _process_job_messages(self, job: IsolatedTestJob, session: pytest.Session): + while not job.finished: + try: + if not job.conn.poll(): + break + callname, kwargs = job.conn.recv() + except (IOError, EOFError) as e: + job.finished = True + break + + method = "worker_" + callname + call = getattr(self, method) + call(job, **kwargs) + self._maybe_raise(session) + + if not job.process.is_alive(): + job.finished = True + + def _finalize_job(self, job: IsolatedTestJob, session: pytest.Session): + self._cleanup_job(job) + + if job.worker_completed: + return + + stop = time.time() + duration = stop - job.start_time + + ec = job.exit_code + longrepr = None + if ec is None: + longrepr = "subprocess failed for unknown reason" + else: + if ec < 0: + try: + signal_name = signal.strsignal(-ec) + longrepr = f"subprocess exited due to signal {-ec}: {signal_name}" + except ValueError: + pass + + if longrepr is None: + longrepr = f"subprocess exited with exit code {ec}" + + report = pytest.TestReport( + nodeid=job.item.nodeid, + location=job.item.location, + keywords=job.item.keywords, + outcome="failed", + longrepr=longrepr, + when="call", + duration=duration, + start=job.start_time, + stop=stop, + ) + + self._config.hook.pytest_runtest_logstart( + nodeid=job.item.nodeid, location=job.item.location + ) + self._config.hook.pytest_runtest_logreport(report=report) + self._config.hook.pytest_runtest_logfinish( + nodeid=job.item.nodeid, location=job.item.location + ) + + self._maybe_raise(session) + + def _cleanup_job(self, job: IsolatedTestJob): + try: + job.conn.close() + except Exception: + pass + + if job.process.is_alive(): + job.process.kill() + + try: + job.process.join(timeout=1) + except TimeoutError: + pass + + ec = job.process.exitcode + if ec is not None: + job.set_exit_code(ec) + + job.process.close() + + def _maybe_raise(self, session: pytest.Session): + if self._shouldstop: + raise session.Interrupted(self._shouldstop) + if session.shouldfail: + raise session.Failed(session.shouldfail) + if session.shouldstop: + raise session.Interrupted(session.shouldstop) + + # + # Worker dispatch functions (copied from pytest-xdist) + # + + def worker_logstart( + self, + job: IsolatedTestJob, + nodeid: str, + location: tuple[str, int | None, str], + ): + """Emitted when a node calls the pytest_runtest_logstart hook.""" + if self._config.option.verbose > 0: + return + self._config.hook.pytest_runtest_logstart(nodeid=nodeid, location=location) + + def worker_logfinish( + self, + job: IsolatedTestJob, + nodeid: str, + location: tuple[str, int | None, str], + ): + """Emitted when a node calls the pytest_runtest_logfinish hook.""" + if self._config.option.verbose > 0: + return + self._config.hook.pytest_runtest_logfinish(nodeid=nodeid, location=location) + + def worker_testreport(self, job: IsolatedTestJob, data: object): + """Emitted when a node calls the pytest_runtest_logreport hook.""" + + report = self._config.hook.pytest_report_from_serializable( + config=self._config, data=data + ) + self._config.hook.pytest_runtest_logreport(report=report) + self._handlefailures(report) + + def worker_internal_error(self, job: IsolatedTestJob, formatted_error: str): + """Emitted when a node calls the pytest_internalerror hook.""" + for line in formatted_error.split("\n"): + print("IERROR>", line, file=sys.stderr) + + job.finished = True + if not self._shouldstop: + self._shouldstop = "internal error in worker" + + def worker_finished(self, job: IsolatedTestJob, exit_code: object | None = None): + """Emitted when a node finishes running.""" + if exit_code is not None: + job.exit_code = int(exit_code) + + job.worker_completed = True + job.finished = True + + def _handlefailures(self, rep: pytest.TestReport): + if rep.failed: + self._countfailures += 1 + if ( + self._maxfail + and self._countfailures >= self._maxfail + and not self._shouldstop + ): + self._shouldstop = f"stopping after {self._countfailures} failures" + + # + # These fixtures match the ones in RobotTestingPlugin but these have no effect + # + + @pytest.fixture(scope="function") + def robot(self): + pass + + @pytest.fixture(scope="function") + def control(self, reraise, robot): + pass + + @pytest.fixture() + def robot_file(self) -> pathlib.Path: + """The absolute filename your robot code is started from""" + return self._robot_file + + @pytest.fixture() + def robot_path(self) -> pathlib.Path: + """The absolute directory that your robot code is located at""" + return self._robot_file.parent diff --git a/wpilibc/src/main/python/wpilib/testing/pytest_plugin.py b/wpilibc/src/main/python/wpilib/testing/pytest_plugin.py new file mode 100644 index 0000000000..26863934ad --- /dev/null +++ b/wpilibc/src/main/python/wpilib/testing/pytest_plugin.py @@ -0,0 +1,145 @@ +import gc +import pathlib +import typing as T +import weakref + +import pytest + +import hal +import hal.simulation +from hal._wpiHal import _RobotMode as RobotMode +import ntcore +import wpilib +from wpilib.simulation import DriverStationSim, pauseTiming, restartTiming +import wpilib.simulation + +# TODO: get rid of special-casing.. maybe should register a HAL shutdown hook or something +try: + import commands2 +except ImportError: + commands2 = None + +from .controller import RobotTestController + + +class RobotTestingPlugin: + """ + Pytest plugin. Each documented member function name can be an argument + to your test functions, and the data that these functions return will + be passed to your test function. + """ + + def __init__( + self, + robot_class: T.Type[wpilib.RobotBase], + robot_file: pathlib.Path, + isolated: bool, + ): + self.isolated = isolated + self._robot_file = robot_file + self._robot_class = robot_class + + # + # Fixtures + # + # Each one of these can be arguments to your test, and the result of the + # corresponding function will be passed to your test as that argument. + # + + @pytest.fixture(scope="function") + def robot(self): + """ + Your robot instance + + .. note:: RobotPy/WPILib testing infrastructure is really sensitive + to ensuring that things get cleaned up properly. Make sure + that you don't store references to your robot or other + WPILib objects in a global or static context. + """ + + # + # This function needs to do the same things that RobotBase.main does + # plus some extra things needed for testing + # + # Previously this was separate from robot fixture, but we need to + # ensure that the robot cleanup happens deterministically relative to + # when handle cleanup/etc happens, otherwise unnecessary HAL errors will + # bubble up to the user + # + + nt_inst = ntcore.NetworkTableInstance.getDefault() + nt_inst.startLocal() + + pauseTiming() + restartTiming() + + wpilib.DriverStation.silenceJoystickConnectionWarning(True) + DriverStationSim.setRobotMode(RobotMode.AUTONOMOUS) + DriverStationSim.setEnabled(False) + DriverStationSim.notifyNewData() + + # Create the user's robot instance + robot = self._robot_class() + + # Tests only get a proxy to ensure cleanup is more reliable + yield weakref.proxy(robot) + + # If running in separate processes, no need to do cleanup + if self.isolated: + # .. and funny enough, in isolated mode we *don't* want the + # robot to be cleaned up, as that can deadlock + self._saved_robot = robot + return + + # HACK: avoid motor safety deadlock + wpilib.simulation._simulation._resetMotorSafety() + + del robot + + if commands2 is not None: + commands2.CommandScheduler.resetInstance() + + # Double-check all objects are destroyed so that HAL handles are released + gc.collect() + + # shutdown networktables before other kinds of global cleanup + # -> some reset functions will re-register listeners, so it's important + # to do this before so that the listeners are active on the current + # NetworkTables instance + nt_inst.stopLocal() + nt_inst._reset() + + # Cleanup WPILib globals + # -> preferences, SmartDashboard, MotorSafety + wpilib.simulation._simulation._resetWpilibSimulationData() + wpilib._wpilib._clearSmartDashboardData() + + # Cancel all periodic callbacks + hal.simulation.cancelAllSimPeriodicCallbacks() + + # Reset the HAL handles + hal.simulation.resetGlobalHandles() + + # Reset the HAL data + hal.simulation.resetAllSimData() + + # Don't call HAL shutdown! This is only used to cleanup HAL extensions, + # and functions will only be called the first time (unless re-registered) + # hal.shutdown() + + @pytest.fixture(scope="function") + def control(self, reraise, robot: wpilib.RobotBase) -> RobotTestController: + """ + A pytest fixture that provides control over your robot + """ + return RobotTestController(reraise, robot) + + @pytest.fixture() + def robot_file(self) -> pathlib.Path: + """The absolute filename your robot code is started from""" + return self._robot_file + + @pytest.fixture() + def robot_path(self) -> pathlib.Path: + """The absolute directory that your robot code is located at""" + return self._robot_file.parent diff --git a/wpilibc/src/main/python/wpilib/testing/robot_tests.py b/wpilibc/src/main/python/wpilib/testing/robot_tests.py new file mode 100644 index 0000000000..09f5299673 --- /dev/null +++ b/wpilibc/src/main/python/wpilib/testing/robot_tests.py @@ -0,0 +1,74 @@ +""" +The primary purpose of these tests is to run through your code +and make sure that it doesn't crash. If you actually want to test +your code, you need to write your own custom tests to tease out +the edge cases. + +To use these, add the following to a python file in your tests directory:: + + from wpilib.testing.robot_tests import * + +""" + +import pytest + +from .controller import RobotTestController + + +def test_autonomous(control: RobotTestController): + """Runs autonomous mode by itself""" + + with control.run_robot(): + # Run disabled for a short period + control.step_timing(seconds=0.5, autonomous=True, enabled=False) + + # Run enabled for 15 seconds + control.step_timing(seconds=15, autonomous=True, enabled=True) + + # Disabled for another short period + control.step_timing(seconds=0.5, autonomous=True, enabled=False) + + +@pytest.mark.filterwarnings("ignore") +def test_disabled(control: RobotTestController, robot): + """Runs disabled mode by itself""" + + with control.run_robot(): + # Run disabled + autonomous for a short period + control.step_timing(seconds=5, autonomous=True, enabled=False) + + # Run disabled + !autonomous for a short period + control.step_timing(seconds=5, autonomous=False, enabled=False) + + +@pytest.mark.filterwarnings("ignore") +def test_operator_control(control: RobotTestController): + """Runs operator control mode by itself""" + + with control.run_robot(): + # Run disabled for a short period + control.step_timing(seconds=0.5, autonomous=False, enabled=False) + + # Run enabled for 15 seconds + control.step_timing(seconds=15, autonomous=False, enabled=True) + + # Disabled for another short period + control.step_timing(seconds=0.5, autonomous=False, enabled=False) + + +@pytest.mark.filterwarnings("ignore") +def test_practice(control: RobotTestController): + """Runs through the entire span of a practice match""" + + with control.run_robot(): + # Run disabled for a short period + control.step_timing(seconds=0.5, autonomous=True, enabled=False) + + # Run autonomous + enabled for 15 seconds + control.step_timing(seconds=15, autonomous=True, enabled=True) + + # Disabled for another short period + control.step_timing(seconds=0.5, autonomous=False, enabled=False) + + # Run teleop + enabled for 2 minutes + control.step_timing(seconds=120, autonomous=False, enabled=True) diff --git a/wpilibc/src/test/python/conftest.py b/wpilibc/src/test/python/conftest.py index 88728aa91f..6ec8724d6e 100644 --- a/wpilibc/src/test/python/conftest.py +++ b/wpilibc/src/test/python/conftest.py @@ -5,6 +5,8 @@ import ntcore import wpilib from wpilib.simulation._simulation import _resetWpilibSimulationData +pytest_plugins = "pytester" + @pytest.fixture def cfg_logging(caplog): diff --git a/wpilibc/src/test/python/test_opmode_robot.py b/wpilibc/src/test/python/test_opmode_robot.py index 7aa9e030d1..abc04214d2 100644 --- a/wpilibc/src/test/python/test_opmode_robot.py +++ b/wpilibc/src/test/python/test_opmode_robot.py @@ -3,7 +3,7 @@ import threading from wpilib import simulation as wsim from wpimath.units import seconds from wpilib.opmoderobot import OpModeRobot -from wpilib import OpMode +from wpilib import OpMode, DriverStation from hal._wpiHal import RobotMode from wpiutil import Color @@ -55,6 +55,7 @@ def sim_timing_setup(): wsim.setProgramStarted(False) yield wsim.resumeTiming() + DriverStation.clearOpModes() def test_add_op_mode(): diff --git a/wpilibc/src/test/python/test_pytest_plugins.py b/wpilibc/src/test/python/test_pytest_plugins.py new file mode 100644 index 0000000000..d485c29cd8 --- /dev/null +++ b/wpilibc/src/test/python/test_pytest_plugins.py @@ -0,0 +1,389 @@ +import pathlib +import sys + +import pytest + + +def _make_robot_module(pytester): + pytester.makepyfile( + robot_module=""" +import wpilib + + +class DummyRobot(wpilib.TimedRobot): + def __init__(self): + super().__init__() + self.did_init = True + + +class AutonomousPeriodicFailed(wpilib.TimedRobot): + def autonomousPeriodic(self): + assert False + + +class TeleopPeriodicFailed(wpilib.TimedRobot): + def teleopPeriodic(self): + assert False + + +class TeleopInitFailed(wpilib.TimedRobot): + def teleopInit(self): + assert False + + +class IterativeStateRobot(wpilib.TimedRobot): + + def disabledInit(self): + self.did_disabled_init = True + + def disabledPeriodic(self): + self.did_disabled_periodic = True + + def autonomousInit(self): + self.did_auto_init = True + + def autonomousPeriodic(self): + self.did_auto_periodic = True + + def teleopInit(self): + self.did_teleop_init = True + + def teleopPeriodic(self): + self.did_teleop_periodic = True + +""" + ) + + +def _configure_robot_testing_plugin(pytester, robot_class="DummyRobot"): + pytester.makeconftest( + f""" +import pathlib + +from wpilib.testing.pytest_plugin import RobotTestingPlugin + +from robot_module import {robot_class} + + +def pytest_configure(config): + robot_file = pathlib.Path(__file__).resolve() + config.pluginmanager.register(RobotTestingPlugin({robot_class}, robot_file, False)) +""" + ) + + +def _configure_isolated_plugin(pytester, parallelism=1, robot_class="DummyRobot"): + pytester.makeconftest( + f""" +import pathlib + +from wpilib.testing.pytest_isolated_tests_plugin import IsolatedTestsPlugin + +from robot_module import {robot_class} + +def pytest_configure(config): + if "--no-header" in config.invocation_params.args: + return + robot_file = pathlib.Path(__file__).resolve() + config.pluginmanager.register( + IsolatedTestsPlugin({robot_class}, robot_file, False, False, {parallelism}) + ) +""" + ) + + +def test_robot_testing_plugin_success(pytester): + _make_robot_module(pytester) + _configure_robot_testing_plugin(pytester) + pytester.makepyfile( + test_success=""" +def test_robot_fixture(robot): + assert robot.did_init +""" + ) + + result = pytester.runpytest("-vv") + + result.assert_outcomes(passed=1) + + +def test_robot_testing_plugin_failure_shows_output(pytester): + _make_robot_module(pytester) + _configure_robot_testing_plugin(pytester) + pytester.makepyfile( + test_failure=""" +def test_robot_failure(robot): + print("checked failure output") + assert False +""" + ) + + result = pytester.runpytest("-vv") + + result.assert_outcomes(failed=1) + result.stdout.fnmatch_lines( + [ + "*test_failure.py::test_robot_failure FAILED*", + "*checked failure output*", + ] + ) + + +def test_isolated_plugin_process_and_output(pytester): + _make_robot_module(pytester) + _configure_isolated_plugin(pytester) + pytester.makepyfile( + test_isolated=""" +import os + + +def test_non_robot_pid(): + with open("non_robot_pid.txt", "w") as fp: + fp.write(str(os.getpid())) + + +def test_robot_pid_one(robot): + with open("robot_pid_one.txt", "w") as fp: + fp.write(str(os.getpid())) + + +def test_robot_pid_two(robot): + with open("robot_pid_two.txt", "w") as fp: + fp.write(str(os.getpid())) + + +def test_robot_failure_output(robot): + print("isolated failure output") + assert False +""" + ) + + result = pytester.runpytest_subprocess("-vv") + + result.assert_outcomes(passed=3, failed=1) + result.stdout.fnmatch_lines( + [ + "*test_isolated.py::test_robot_failure_output FAILED*", + "*isolated failure output*", + ] + ) + + root = pathlib.Path(pytester.path) + main_pid = int(root.joinpath("non_robot_pid.txt").read_text()) + robot_pid_one = int(root.joinpath("robot_pid_one.txt").read_text()) + robot_pid_two = int(root.joinpath("robot_pid_two.txt").read_text()) + + assert robot_pid_one != main_pid + assert robot_pid_two != main_pid + assert robot_pid_one != robot_pid_two + + +def test_isolated_plugin_no_duplicate_verbose_output(pytester): + _make_robot_module(pytester) + _configure_isolated_plugin(pytester) + pytester.makepyfile( + test_isolated=""" +def test_non_robot(): + assert True + + +def test_robot_one(robot): + assert robot is not None + + +def test_robot_two(robot): + assert robot is not None +""" + ) + + result = pytester.runpytest_subprocess("-v") + + result.assert_outcomes(passed=3) + assert ( + sum(1 for line in result.outlines if "test_isolated.py::test_robot_one" in line) + == 1 + ) + assert ( + sum(1 for line in result.outlines if "test_isolated.py::test_robot_two" in line) + == 1 + ) + + +@pytest.mark.skipif( + sys.platform.startswith("win"), + reason="Process signal exits do not work on Windows", +) +def test_isolated_plugin_reports_signal_exit(pytester): + _make_robot_module(pytester) + _configure_isolated_plugin(pytester) + pytester.makepyfile( + test_isolated=""" +import os +import signal + + +def test_robot_signal_exit(robot): + os.kill(os.getpid(), signal.SIGTERM) +""" + ) + + result = pytester.runpytest_subprocess("-vv") + + result.assert_outcomes(failed=1) + result.stdout.fnmatch_lines( + [ + "*test_isolated.py::test_robot_signal_exit FAILED*", + "*Terminated*", + ] + ) + + +def test_isolated_plugin_shows_file_in_non_verbose_output(pytester): + _make_robot_module(pytester) + _configure_isolated_plugin(pytester) + pytester.makepyfile( + test_isolated=""" +def test_non_robot(): + assert True + + +def test_robot_one(robot): + assert robot is not None + + +def test_robot_two(robot): + assert robot is not None +""" + ) + + result = pytester.runpytest_subprocess() + + result.assert_outcomes(passed=3) + assert ( + sum(1 for line in result.outlines if line.startswith("test_isolated.py")) == 1 + ) + + +def test_isolated_plugin_maxfail_stops_early(pytester): + _make_robot_module(pytester) + _configure_isolated_plugin(pytester) + pytester.makepyfile( + test_isolated=""" +def test_robot_first(robot): + assert False + + +def test_robot_second(robot): + assert False +""" + ) + + result = pytester.runpytest_subprocess("-v", "-x") + + result.assert_outcomes(failed=1) + assert not any("test_robot_second" in line for line in result.outlines) + + +@pytest.mark.parametrize("isolated", [False, True]) +def test_builtin_tests_module(pytester, isolated): + _make_robot_module(pytester) + if isolated: + _configure_isolated_plugin(pytester, robot_class="DummyRobot") + else: + _configure_robot_testing_plugin(pytester, robot_class="DummyRobot") + pytester.makepyfile(pyfrc_test="from wpilib.testing.robot_tests import *\n") + + result = pytester.runpytest_subprocess("-q") + + result.assert_outcomes(passed=4) + + +def _run_robot_suite(pytester, isolated, robot_class, test_source, *args): + _make_robot_module(pytester) + if isolated: + _configure_isolated_plugin(pytester, robot_class=robot_class) + else: + _configure_robot_testing_plugin(pytester, robot_class=robot_class) + pytester.makepyfile(test_robot=test_source) + return pytester.runpytest_subprocess(*args) + + +_AUTO_FAILURES = [ + "AutonomousPeriodicFailed", +] + +_TELEOP_FAILURES = [ + "TeleopPeriodicFailed", + "TeleopInitFailed", +] + + +@pytest.mark.parametrize("isolated", [False, True]) +@pytest.mark.parametrize("robot_class", _AUTO_FAILURES) +def test_autonomous_failure_detection(pytester, isolated, robot_class): + result = _run_robot_suite( + pytester, + isolated, + robot_class, + """ +def test_autonomous_failure(robot, control): + with control.run_robot(): + control.step_timing(seconds=0.4, autonomous=True, enabled=True) +""", + "-vv", + ) + + result.assert_outcomes(failed=1) + + +@pytest.mark.parametrize("isolated", [False, True]) +@pytest.mark.parametrize("robot_class", _TELEOP_FAILURES) +def test_teleop_failure_detection(pytester, isolated, robot_class): + result = _run_robot_suite( + pytester, + isolated, + robot_class, + """ +def test_teleop_failure(robot, control): + with control.run_robot(): + control.step_timing(seconds=0.4, autonomous=False, enabled=True) +""", + "-vv", + ) + + result.assert_outcomes(failed=1) + + +@pytest.mark.parametrize("isolated", [False, True]) +@pytest.mark.parametrize("robot_class", ["IterativeStateRobot"]) +def test_robot_state_transitions(pytester, isolated, robot_class): + expected = { + "IterativeStateRobot": [ + "did_disabled_init", + "did_disabled_periodic", + "did_auto_init", + "did_auto_periodic", + "did_teleop_init", + "did_teleop_periodic", + ], + }[robot_class] + + result = _run_robot_suite( + pytester, + isolated, + robot_class, + f""" +def test_state_transitions(robot, control): + with control.run_robot(): + control.step_timing(seconds=0.4, autonomous=False, enabled=False) + control.step_timing(seconds=0.4, autonomous=True, enabled=True) + control.step_timing(seconds=0.4, autonomous=False, enabled=True) + + expected = {{name: True for name in {expected!r}}} + attrs = {{name: getattr(robot, name, False) for name in {expected!r}}} + assert expected == attrs +""", + "-vv", + ) + + result.assert_outcomes(passed=1) diff --git a/wpimath/src/test/python/geometry/test_rotation3d.py b/wpimath/src/test/python/geometry/test_rotation3d.py index 736daf7810..53641f5097 100644 --- a/wpimath/src/test/python/geometry/test_rotation3d.py +++ b/wpimath/src/test/python/geometry/test_rotation3d.py @@ -73,11 +73,7 @@ def test_init_rotation_matrix(): assert expected2 == rot2 # Matrix that isn't orthogonal - R3 = np.array([ - [1.0, 0.0, 0.0], - [1.0, 0.0, 0.0], - [1.0, 0.0, 0.0] - ]) + R3 = np.array([[1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 0.0, 0.0]]) with pytest.raises(ValueError): Rotation3d(R3) diff --git a/wpiutil/src/main/python/wpiutil/src/type_casters/wpi_string_type_caster.h b/wpiutil/src/main/python/wpiutil/src/type_casters/wpi_string_type_caster.h index abb17adaf7..ba0c26dea8 100644 --- a/wpiutil/src/main/python/wpiutil/src/type_casters/wpi_string_type_caster.h +++ b/wpiutil/src/main/python/wpiutil/src/type_casters/wpi_string_type_caster.h @@ -21,7 +21,7 @@ public: return false; } - value = WPI_String(str, static_cast(size)); + value = WPI_String{str, static_cast(size)}; return true; } diff --git a/xrpVendordep/src/main/python/pyproject.toml b/xrpVendordep/src/main/python/pyproject.toml index 2e7e7fb15d..3f200b321a 100644 --- a/xrpVendordep/src/main/python/pyproject.toml +++ b/xrpVendordep/src/main/python/pyproject.toml @@ -23,6 +23,9 @@ dependencies = [ "wpilib==0.0.0" ] +[project.entry-points."robotpy_cli.2027"] +run-xrp = "xrp.cli:RunXrp" + [project.entry-points."robotpy_sim.2027"] xrp = "xrp.extension" diff --git a/xrpVendordep/src/main/python/xrp/cli.py b/xrpVendordep/src/main/python/xrp/cli.py new file mode 100644 index 0000000000..0549380260 --- /dev/null +++ b/xrpVendordep/src/main/python/xrp/cli.py @@ -0,0 +1,121 @@ +import argparse +import importlib.metadata +import os +import sys +import typing + +import wpilib + + +if sys.version_info < (3, 10): + + def entry_points(group): + eps = importlib.metadata.entry_points() + return eps.get(group, []) + +else: + entry_points = importlib.metadata.entry_points + + +def _int_env_default(name: str, fallback: int) -> int: + value = os.environ.get(name) + if value is None: + return fallback + try: + return int(value) + except ValueError: + return fallback + + +class RunXrp: + """ + Runs the robot using the HAL simulator connected to an XRP platform + """ + + def __init__(self, parser: argparse.ArgumentParser): + parser.add_argument( + "--nogui", + default=False, + action="store_true", + help="Don't use the WPILib simulation gui", + ) + + sim_group = parser.add_argument_group("Additional simulation extensions") + self.simexts = {} + + for entry_point in entry_points(group="robotpy_sim.2027"): + try: + sim_ext_module = entry_point.load() + except ImportError: + print(f"WARNING: Error detected in {entry_point}") + continue + + self.simexts[entry_point.name] = sim_ext_module + + try: + if entry_point.name == "xrp": + cmd_help = argparse.SUPPRESS + else: + cmd_help = importlib.metadata.metadata(entry_point.dist.name)[ + "summary" + ] + except AttributeError: + cmd_help = "Load specified simulation extension" + sim_group.add_argument( + f"--{entry_point.name}", + default=False, + action="store_true", + help=cmd_help, + ) + + parser.add_argument( + "--host", + default=os.environ.get("HALSIMXRP_HOST", "192.168.42.1"), + help="XRP host (default: HALSIMXRP_HOST or 192.168.42.1)", + ) + parser.add_argument( + "--port", + type=int, + default=_int_env_default("HALSIMXRP_PORT", 3540), + help="XRP port (default: HALSIMXRP_PORT or 3540)", + ) + + def run( + self, + options: argparse.Namespace, + project_path: "os.PathLike[str]", + robot_class: typing.Type[wpilib.RobotBase], + ): + if "xrp" not in self.simexts: + print( + "robotpy-xrp HALSim extension is missing. Reinstall robotpy-xrp to use run-xrp.", + file=sys.stderr, + ) + return False + + os.environ["HALSIMXRP_HOST"] = options.host + os.environ["HALSIMXRP_PORT"] = str(options.port) + + options.xrp = True + + if not options.nogui: + try: + import halsim_gui + except ImportError: + print("robotpy-halsim-gui is not installed!", file=sys.stderr) + return False + else: + halsim_gui.loadExtension() + + cwd = os.getcwd() + + for name, module in self.simexts.items(): + if getattr(options, name.replace("-", "_"), False): + try: + module.loadExtension() + except Exception: + print(f"Error loading {name}!", file=sys.stderr) + raise + + os.chdir(cwd) + return robot_class.main(robot_class)