mirror of
https://github.com/wpilibsuite/allwpilib
synced 2026-06-19 00:41:43 +00:00
[copybara] mostrobotpy to allwpilib (#8545)
Project import generated by Copybara. GitOrigin-RevId: f10284b37498bb6a088891ca41f160793ec7fd90
This commit is contained in:
@@ -13,6 +13,7 @@ enums:
|
||||
value_prefix: HAL_kMatchType
|
||||
HAL_RobotMode:
|
||||
rename: _RobotMode
|
||||
value_prefix: HAL_ROBOTMODE
|
||||
RobotMode:
|
||||
classes:
|
||||
HAL_ControlWord:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
1
romiVendordep/robotpy_pybind_build_info.bzl
generated
1
romiVendordep/robotpy_pybind_build_info.bzl
generated
@@ -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"],
|
||||
|
||||
@@ -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"
|
||||
|
||||
121
romiVendordep/src/main/python/romi/cli.py
Normal file
121
romiVendordep/src/main/python/romi/cli.py
Normal file
@@ -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)
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 + [
|
||||
|
||||
@@ -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"),
|
||||
],
|
||||
)
|
||||
|
||||
18
simulation/halsim_ds_socket/src/main/python/README.md
Normal file
18
simulation/halsim_ds_socket/src/main/python/README.md
Normal file
@@ -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/
|
||||
@@ -0,0 +1 @@
|
||||
from .main import loadExtension
|
||||
@@ -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)
|
||||
40
simulation/halsim_ds_socket/src/main/python/pyproject.toml
Normal file
40
simulation/halsim_ds_socket/src/main/python/pyproject.toml
Normal file
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
],
|
||||
)
|
||||
|
||||
152
simulation/halsim_gui/robotpy_pybind_build_info.bzl
Normal file
152
simulation/halsim_gui/robotpy_pybind_build_info.bzl
Normal file
@@ -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"],
|
||||
)
|
||||
18
simulation/halsim_gui/src/main/python/README.md
Normal file
18
simulation/halsim_gui/src/main/python/README.md
Normal file
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
from .main import loadExtension
|
||||
@@ -0,0 +1 @@
|
||||
from . import _init__halsim_gui_ext
|
||||
@@ -0,0 +1,44 @@
|
||||
|
||||
#include <semiwrap_init.halsim_gui._ext._halsim_gui_ext.hpp>
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include <wpi/halsim/gui/HALSimGuiExt.hpp>
|
||||
#include <wpi/hal/Extensions.h>
|
||||
|
||||
std::function<void()> 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
27
simulation/halsim_gui/src/main/python/halsim_gui/main.py
Normal file
27
simulation/halsim_gui/src/main/python/halsim_gui/main.py
Normal file
@@ -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()
|
||||
57
simulation/halsim_gui/src/main/python/pyproject.toml
Normal file
57
simulation/halsim_gui/src/main/python/pyproject.toml
Normal file
@@ -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"]
|
||||
20
simulation/halsim_gui/src/test/python/test_halsim_gui.py
Normal file
20
simulation/halsim_gui/src/test/python/test_halsim_gui.py
Normal file
@@ -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
|
||||
@@ -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"),
|
||||
],
|
||||
)
|
||||
|
||||
19
simulation/halsim_ws_core/src/main/python/README.md
Normal file
19
simulation/halsim_ws_core/src/main/python/README.md
Normal file
@@ -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/
|
||||
@@ -0,0 +1 @@
|
||||
from .main import loadExtension
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
from .main import loadExtension
|
||||
@@ -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)
|
||||
55
simulation/halsim_ws_core/src/main/python/pyproject.toml
Normal file
55
simulation/halsim_ws_core/src/main/python/pyproject.toml
Normal file
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
4
wpilibc/robotpy_pybind_build_info.bzl
generated
4
wpilibc/robotpy_pybind_build_info.bzl
generated
@@ -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"],
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -228,6 +228,4 @@ try:
|
||||
except ImportError:
|
||||
__version__ = "master"
|
||||
|
||||
from ._impl.main import run
|
||||
|
||||
__all__ += ["CameraServer", "run"]
|
||||
__all__ += ["CameraServer"]
|
||||
|
||||
51
wpilibc/src/main/python/wpilib/_impl/cli_add_tests.py
Normal file
51
wpilibc/src/main/python/wpilib/_impl/cli_add_tests.py
Normal file
@@ -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'")
|
||||
10
wpilibc/src/main/python/wpilib/_impl/cli_run.py
Normal file
10
wpilibc/src/main/python/wpilib/_impl/cli_run.py
Normal file
@@ -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)
|
||||
78
wpilibc/src/main/python/wpilib/_impl/cli_sim.py
Normal file
78
wpilibc/src/main/python/wpilib/_impl/cli_sim.py
Normal file
@@ -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)
|
||||
244
wpilibc/src/main/python/wpilib/_impl/cli_test.py
Normal file
244
wpilibc/src/main/python/wpilib/_impl/cli_test.py
Normal file
@@ -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 --<space>, 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"
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
0
wpilibc/src/main/python/wpilib/testing/__init__.py
Normal file
0
wpilibc/src/main/python/wpilib/testing/__init__.py
Normal file
163
wpilibc/src/main/python/wpilib/testing/controller.py
Normal file
163
wpilibc/src/main/python/wpilib/testing/controller.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
145
wpilibc/src/main/python/wpilib/testing/pytest_plugin.py
Normal file
145
wpilibc/src/main/python/wpilib/testing/pytest_plugin.py
Normal file
@@ -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
|
||||
74
wpilibc/src/main/python/wpilib/testing/robot_tests.py
Normal file
74
wpilibc/src/main/python/wpilib/testing/robot_tests.py
Normal file
@@ -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)
|
||||
@@ -5,6 +5,8 @@ import ntcore
|
||||
import wpilib
|
||||
from wpilib.simulation._simulation import _resetWpilibSimulationData
|
||||
|
||||
pytest_plugins = "pytester"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cfg_logging(caplog):
|
||||
|
||||
@@ -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():
|
||||
|
||||
389
wpilibc/src/test/python/test_pytest_plugins.py
Normal file
389
wpilibc/src/test/python/test_pytest_plugins.py
Normal file
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ public:
|
||||
return false;
|
||||
}
|
||||
|
||||
value = WPI_String(str, static_cast<size_t>(size));
|
||||
value = WPI_String{str, static_cast<size_t>(size)};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
121
xrpVendordep/src/main/python/xrp/cli.py
Normal file
121
xrpVendordep/src/main/python/xrp/cli.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user