diff options
author | Anthony DiGirolamo <tonymd@google.com> | 2020-09-29 14:34:27 -0700 |
---|---|---|
committer | CQ Bot Account <pigweed-scoped@luci-project-accounts.iam.gserviceaccount.com> | 2020-10-12 19:02:19 +0000 |
commit | c36af656df3632422c4cc892f1e8f9728def5a86 (patch) | |
tree | c51aae510bb3371ada5af878fbb77b8bb01d9a4e /pw_arduino_build | |
parent | 13c7c4fdaf42c09d70ef36e8c28c8356d4a16fb4 (diff) | |
download | pigweed-c36af656df3632422c4cc892f1e8f9728def5a86.tar.gz |
Arduino: unit_test_runner
- New unit test scripts
- arduino_unit_test_runner
- arduino_test_server
- arduino_test_client
- teensy_detector
- arduino_builder changes
- '--config-file' loading and '--save-config' options
- '--set-variable' option to set arduino recipe vars. This is used
for passing vars to the teensyloader flash recipe.
- GN changes
- exec_script(arduino_builder) calls will save config files to:
./out/arduino_debug/gen/arduino_builder_config.json
- 'pw_arduino_use_test_server' gn build arg
The test_runner and test_server both require a options to
run properly, see targets/arduino/target_docs.rst for usage.
Change-Id: I118498c291d2fbb034faa372b9250f6a0783a478
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/19220
Commit-Queue: Anthony DiGirolamo <tonymd@google.com>
Reviewed-by: Armando Montanez <amontanez@google.com>
Diffstat (limited to 'pw_arduino_build')
-rw-r--r-- | pw_arduino_build/arduino.gni | 13 | ||||
-rw-r--r-- | pw_arduino_build/py/pw_arduino_build/__main__.py | 190 | ||||
-rwxr-xr-x | pw_arduino_build/py/pw_arduino_build/builder.py | 59 | ||||
-rw-r--r-- | pw_arduino_build/py/pw_arduino_build/core_installer.py | 11 | ||||
-rw-r--r-- | pw_arduino_build/py/pw_arduino_build/file_operations.py | 40 | ||||
-rw-r--r-- | pw_arduino_build/py/pw_arduino_build/log.py | 24 | ||||
-rw-r--r-- | pw_arduino_build/py/pw_arduino_build/teensy_detector.py | 114 | ||||
-rwxr-xr-x | pw_arduino_build/py/pw_arduino_build/unit_test_client.py | 58 | ||||
-rwxr-xr-x | pw_arduino_build/py/pw_arduino_build/unit_test_runner.py | 340 | ||||
-rw-r--r-- | pw_arduino_build/py/pw_arduino_build/unit_test_server.py | 164 | ||||
-rw-r--r-- | pw_arduino_build/py/setup.py | 12 |
11 files changed, 929 insertions, 96 deletions
diff --git a/pw_arduino_build/arduino.gni b/pw_arduino_build/arduino.gni index c21463251..8c89202cd 100644 --- a/pw_arduino_build/arduino.gni +++ b/pw_arduino_build/arduino.gni @@ -15,6 +15,10 @@ # gn-format disable import("//build_overrides/pigweed.gni") declare_args() { + # Enable/disable Arduino builds via group("arduino"). + # Set to the full path of ./third_party/arduino + dir_pw_third_party_arduino = "" + # Expected args for an Arduino build: arduino_core_name = "teensy" arduino_package_name = "teensy/avr" @@ -25,10 +29,6 @@ declare_args() { "menu.usb.serial", "menu.keys.en-us", ] - - # Enable/disable Arduino builds via group("arduino"). - # Set to the full path of ./third_party/arduino - dir_pw_third_party_arduino = "" } arduino_builder_script = @@ -46,6 +46,11 @@ arduino_global_args = [ arduino_package_name, "--compiler-path-override", _compiler_path_override, + + # Save config files to "out/arduino_debug/gen/arduino_builder_config.json" + "--config-file", + rebase_path(root_gen_dir) + "/arduino_builder_config.json", + "--save-config", ] arduino_board_args = [ diff --git a/pw_arduino_build/py/pw_arduino_build/__main__.py b/pw_arduino_build/py/pw_arduino_build/__main__.py index 0bdbcf124..16dfde796 100644 --- a/pw_arduino_build/py/pw_arduino_build/__main__.py +++ b/pw_arduino_build/py/pw_arduino_build/__main__.py @@ -15,7 +15,7 @@ """Command line interface for arduino_builder.""" import argparse -import glob +import json import logging import os import pprint @@ -26,6 +26,7 @@ from typing import List from pw_arduino_build import core_installer, log from pw_arduino_build.builder import ArduinoBuilder +from pw_arduino_build.file_operations import decode_file_json _LOG = logging.getLogger(__name__) @@ -33,6 +34,10 @@ _pretty_print = pprint.PrettyPrinter(indent=1, width=120).pprint _pretty_format = pprint.PrettyPrinter(indent=1, width=120).pformat +class MissingArduinoCore(Exception): + """Exception raised when an Arduino core can not be found.""" + + def list_boards_command(unused_args, builder): # list-boards subcommand # (does not need a selected board or default menu options) @@ -65,8 +70,8 @@ def list_menu_options_command(args, builder): print("\nDefault Options") print(separator) - default_options, unused_column_widths = builder.get_default_menu_options() - for name, description in default_options: + menu_options, unused_col_widths = builder.get_default_menu_options() + for name, description in menu_options: print(name.ljust(all_column_widths[0] + 1), description) @@ -198,8 +203,11 @@ def show_command(args, builder): elif args.core_path: print(builder.get_core_path()) - elif args.postbuild: - print(builder.get_postbuild_line(args.postbuild)) + elif args.prebuilds: + show_command_print_string_list(args, builder.get_prebuild_steps()) + + elif args.postbuilds: + show_command_print_string_list(args, builder.get_postbuild_steps()) elif args.upload_command: print(builder.get_upload_line(args.upload_command, args.serial_port)) @@ -243,14 +251,13 @@ def show_command(args, builder): show_command_print_string_list(args, vfiles) -def add_common_options(parser, serial_port, build_path, build_project_name, - project_path, project_source_path): +def add_common_parser_args(parser, serial_port, build_path, build_project_name, + project_path, project_source_path): """Add command line options common to the run and show commands.""" parser.add_argument( "--serial-port", default=serial_port, - help="Serial port for flashing flash. Default: '{}'".format( - serial_port)) + help="Serial port for flashing. Default: '{}'".format(serial_port)) parser.add_argument( "--build-path", default=build_path, @@ -272,7 +279,98 @@ def add_common_options(parser, serial_port, build_path, build_project_name, help="Name of the board to use.") # nargs="+" is one or more args, e.g: # --menu-options menu.usb.serialhid menu.speed.150 - parser.add_argument("--menu-options", nargs="+", type=str) + parser.add_argument( + "--menu-options", + nargs="+", + type=str, + metavar="menu.usb.serial", + help="Desired Arduino menu options. See the " + "'list-menu-options' subcommand for available options.") + parser.add_argument("--set-variable", + action="append", + metavar='some.variable=NEW_VALUE', + help="Override an Arduino recipe variable. May be " + "specified multiple times. For example: " + "--set-variable 'serial.port.label=/dev/ttyACM0' " + "--set-variable 'serial.port.protocol=Teensy'") + + +def check_for_missing_args(args): + if args.arduino_package_path is None: + raise MissingArduinoCore( + "Please specify the location of an Arduino core using " + "'--arduino-package-path' and '--arduino-package-name'.") + + +# TODO(tonymd): These defaults don't make sense anymore and should be removed. +def get_default_options(): + defaults = {} + defaults["build_path"] = os.path.realpath( + os.path.expanduser( + os.path.expandvars(os.path.join(os.getcwd(), "build")))) + defaults["project_path"] = os.path.realpath( + os.path.expanduser(os.path.expandvars(os.getcwd()))) + defaults["project_source_path"] = os.path.join(defaults["project_path"], + "src") + defaults["build_project_name"] = os.path.basename(defaults["project_path"]) + defaults["serial_port"] = "UNKNOWN" + return defaults + + +def load_config_file(args, default_options): + """Load a config file and merge with command line options. + + Command line takes precedence over values loaded from a config file.""" + + if args.save_config and not args.config_file: + raise FileNotFoundError( + "'--save-config' requires the '--config-file' option") + + if not args.config_file: + return + + commandline_options = { + # Global option + "arduino_package_path": args.arduino_package_path, + "arduino_package_name": args.arduino_package_name, + "compiler_path_override": args.compiler_path_override, + # These options may not exist unless show or run command + "build_path": getattr(args, "build_path", None), + "project_path": getattr(args, "project_path", None), + "project_source_path": getattr(args, "project_source_path", None), + "build_project_name": getattr(args, "build_project_name", None), + "board": getattr(args, "board", None), + "menu_options": getattr(args, "menu_options", None), + } + + # Decode JSON config file. + json_file_options, config_file_path = decode_file_json(args.config_file) + + # Merge config file with command line options. + merged_options = {} + for key, value in commandline_options.items(): + # Use the command line specified option by default + merged_options[key] = value + + # Is this option in the config file? + if json_file_options.get(key, None) is not None: + # Use the json defined option if it's not set on the command + # line (or is a default value). + if value is None or value == default_options.get(key, None): + merged_options[key] = json_file_options[key] + + # Update args namespace to matched merged_options. + for key, value in merged_options.items(): + setattr(args, key, value) + + # Write merged_options if --save-config. + if args.save_config: + encoded_json = json.dumps(merged_options, indent=4) + # Create parent directories + os.makedirs(os.path.dirname(config_file_path), exist_ok=True) + # Save json file. + with open(config_file_path, "w") as jfile: + jfile.write(encoded_json) def main(): @@ -299,20 +397,7 @@ def main(): help='Set the log level ' '(debug, info, warning, error, critical)') - build_path = os.path.realpath( - os.path.expanduser(os.path.expandvars("./build"))) - project_path = os.path.realpath( - os.path.expanduser(os.path.expandvars("./"))) - project_source_path = os.path.join(project_path, "src") - build_project_name = os.path.basename(project_path) - - serial_port = "UNKNOWN" - # TODO(tonymd): Temp solution to passing in serial port. It should use - # arduino core discovery tools. - possible_serial_ports = glob.glob("/dev/ttyACM*") + glob.glob( - "/dev/ttyUSB*") - if possible_serial_ports: - serial_port = possible_serial_ports[0] + default_options = get_default_options() # Global command line options parser.add_argument("--arduino-package-path", @@ -322,6 +407,10 @@ def main(): parser.add_argument("--compiler-path-override", help="Path to arm-none-eabi-gcc bin folder. " "Default: Arduino core specified gcc") + parser.add_argument("-c", "--config-file", help="Path to a config file.") + parser.add_argument("--save-config", + action="store_true", + help="Save command line arguments to the config file.") # Subcommands subparsers = parser.add_subparsers(title="subcommand", @@ -360,8 +449,11 @@ def main(): # show command show_parser = subparsers.add_parser("show", help="Return compiler information.") - add_common_options(show_parser, serial_port, build_path, - build_project_name, project_path, project_source_path) + add_common_parser_args(show_parser, default_options["serial_port"], + default_options["build_path"], + default_options["build_project_name"], + default_options["project_path"], + default_options["project_source_path"]) show_parser.add_argument("--delimit-with-newlines", help="Separate flag output with newlines.", action="store_true") @@ -385,8 +477,12 @@ def main(): output_group.add_argument("--ar-binary", action="store_true") output_group.add_argument("--objcopy-binary", action="store_true") output_group.add_argument("--size-binary", action="store_true") - output_group.add_argument("--postbuild", - help="Show recipe.hooks.postbuild.*.pattern") + output_group.add_argument("--prebuilds", + action="store_true", + help="Show prebuild step commands.") + output_group.add_argument("--postbuilds", + action="store_true", + help="Show postbuild step commands.") output_group.add_argument("--upload-tools", action="store_true") output_group.add_argument("--upload-command") output_group.add_argument("--library-includes", action="store_true") @@ -403,8 +499,11 @@ def main(): # run command run_parser = subparsers.add_parser("run", help="Run Arduino recipes.") - add_common_options(run_parser, serial_port, build_path, build_project_name, - project_path, project_source_path) + add_common_parser_args(run_parser, default_options["serial_port"], + default_options["build_path"], + default_options["build_project_name"], + default_options["project_path"], + default_options["project_source_path"]) run_parser.add_argument("--run-link", nargs="+", type=str, @@ -417,37 +516,44 @@ def main(): run_parser.set_defaults(func=run_command) + # Parse command line arguments. args = parser.parse_args() - - log.install() - log.set_level(args.loglevel) - _LOG.debug(_pretty_format(args)) + log.install(args.loglevel) + # Check for and set alternate compiler path. - compiler_path_override = False if args.compiler_path_override: + # Get absolute path compiler_path_override = os.path.realpath( os.path.expanduser(os.path.expandvars( args.compiler_path_override))) + args.compiler_path_override = compiler_path_override + + load_config_file(args, default_options) if args.subcommand == "install-core": args.func(args) elif args.subcommand in ["list-boards", "list-menu-options"]: + check_for_missing_args(args) builder = ArduinoBuilder(args.arduino_package_path, args.arduino_package_name) builder.load_board_definitions() args.func(args, builder) else: - builder = ArduinoBuilder(args.arduino_package_path, - args.arduino_package_name, - build_path=args.build_path, - build_project_name=args.build_project_name, - project_path=args.project_path, - project_source_path=args.project_source_path, - compiler_path_override=compiler_path_override) + check_for_missing_args(args) + builder = ArduinoBuilder( + args.arduino_package_path, + args.arduino_package_name, + build_path=args.build_path, + build_project_name=args.build_project_name, + project_path=args.project_path, + project_source_path=args.project_source_path, + compiler_path_override=args.compiler_path_override) builder.load_board_definitions() builder.select_board(args.board, args.menu_options) + if args.set_variable: + builder.set_variables(args.set_variable) args.func(args, builder) sys.exit(0) diff --git a/pw_arduino_build/py/pw_arduino_build/builder.py b/pw_arduino_build/py/pw_arduino_build/builder.py index 06c4117cb..34d56e42f 100755 --- a/pw_arduino_build/py/pw_arduino_build/builder.py +++ b/pw_arduino_build/py/pw_arduino_build/builder.py @@ -46,27 +46,27 @@ class ArduinoBuilder: """Used to interpret arduino boards.txt and platform.txt files.""" # pylint: disable=too-many-instance-attributes,too-many-public-methods - board_menu_regex = re.compile( + BOARD_MENU_REGEX = re.compile( r"^(?P<name>menu\.[^#=]+)=(?P<description>.*)$", re.MULTILINE) - board_name_regex = re.compile( + BOARD_NAME_REGEX = re.compile( r"^(?P<name>[^\s#\.]+)\.name=(?P<description>.*)$", re.MULTILINE) - variable_regex = re.compile(r"^(?P<name>[^\s#=]+)=(?P<value>.*)$", + VARIABLE_REGEX = re.compile(r"^(?P<name>[^\s#=]+)=(?P<value>.*)$", re.MULTILINE) - menu_option_regex = re.compile( + MENU_OPTION_REGEX = re.compile( r"^menu\." # starts with "menu" r"(?P<menu_option_name>[^.]+)\." # first token after . r"(?P<menu_option_value>[^.]+)$") # second (final) token after . - tool_name_regex = re.compile( + TOOL_NAME_REGEX = re.compile( r"^tools\." # starts with "tools" r"(?P<tool_name>[^.]+)\.") # first token after . - interpolated_variable_regex = re.compile(r"{[^}]+}", re.MULTILINE) + INTERPOLATED_VARIABLE_REGEX = re.compile(r"{[^}]+}", re.MULTILINE) - objcopy_step_name_regex = re.compile(r"^recipe.objcopy.([^.]+).pattern$") + OBJCOPY_STEP_NAME_REGEX = re.compile(r"^recipe.objcopy.([^.]+).pattern$") def __init__(self, arduino_path, @@ -106,9 +106,9 @@ class ArduinoBuilder: self.hardware_path = os.path.join(self.arduino_path, "hardware") if not os.path.exists(self.hardware_path): - _LOG.error("Error: Arduino package path '%s' does not exist.", - self.arduino_path) - raise FileNotFoundError + raise FileNotFoundError( + "Arduino package path '{}' does not exist.".format( + self.arduino_path)) # Set and check for valid package name self.package_path = os.path.join(self.arduino_path, "hardware", @@ -158,6 +158,18 @@ class ArduinoBuilder: self._apply_recipe_overrides() self._substitute_variables() + def set_variables(self, variable_list: List[str]): + # Convert the string list containing 'name=value' items into a dict + variable_source = {} + for var in variable_list: + var_name, value = var.split("=") + variable_source[var_name] = value + + # Replace variables in platform + for var, value in self.platform.items(): + self.platform[var] = self._replace_variables( + value, variable_source) + def _apply_recipe_overrides(self): # Override link recipes with per-core exceptions # Teensyduino cores @@ -215,7 +227,7 @@ class ArduinoBuilder: return False # Override default menu option with new value. - menu_match_result = self.menu_option_regex.match(moption) + menu_match_result = self.MENU_OPTION_REGEX.match(moption) if menu_match_result: menu_match = menu_match_result.groupdict() menu_value = menu_match["menu_option_value"] @@ -234,6 +246,9 @@ class ArduinoBuilder: https://arduino.github.io/arduino-cli/platform-specification/#global-predefined-properties """ + # TODO(tonymd): Make sure these variables are replaced in recipe lines + # even if they are None: build_path, project_path, project_source_path, + # build_project_name for current_board_name in self.board.keys(): if self.build_path: self.board[current_board_name]["build.path"] = self.build_path @@ -301,7 +316,7 @@ class ArduinoBuilder: # Load platform.txt with open(self.platform_txt, "r") as pfile: platform_file = pfile.read() - platform_var_matches = self.variable_regex.finditer(platform_file) + platform_var_matches = self.VARIABLE_REGEX.finditer(platform_file) for p_match in [m.groupdict() for m in platform_var_matches]: self.platform[p_match["name"]] = p_match["value"] @@ -309,14 +324,14 @@ class ArduinoBuilder: with open(self.boards_txt, "r") as bfile: board_file = bfile.read() # Get all top-level menu options, e.g. menu.usb=USB Type - board_menu_matches = self.board_menu_regex.finditer(board_file) + board_menu_matches = self.BOARD_MENU_REGEX.finditer(board_file) for menuitem in [m.groupdict() for m in board_menu_matches]: self.menu_options["global_options"][menuitem["name"]] = { "description": menuitem["description"] } # Get all board names, e.g. teensy40.name=Teensy 4.0 - board_name_matches = self.board_name_regex.finditer(board_file) + board_name_matches = self.BOARD_NAME_REGEX.finditer(board_file) for b_match in [m.groupdict() for m in board_name_matches]: self.board[b_match["name"]] = OrderedDict() self.menu_options["default_board_values"][ @@ -377,7 +392,7 @@ class ArduinoBuilder: definitions from variable_lookup_source. """ new_line = line - for current_var_match in self.interpolated_variable_regex.findall( + for current_var_match in self.INTERPOLATED_VARIABLE_REGEX.findall( line): # {build.flags.c} --> build.flags.c current_var = current_var_match.strip("{}") @@ -520,7 +535,7 @@ class ArduinoBuilder: max_string_length = [0, 0] for key_name, description in self.board[self.selected_board].items(): - menu_match_result = self.menu_option_regex.match(key_name) + menu_match_result = self.MENU_OPTION_REGEX.match(key_name) if menu_match_result: menu_match = menu_match_result.groupdict() name = "menu.{}.{}".format(menu_match["menu_option_name"], @@ -572,7 +587,7 @@ class ArduinoBuilder: return line def _get_tool_name(self, line): - tool_match_result = self.tool_name_regex.match(line) + tool_match_result = self.TOOL_NAME_REGEX.match(line) if tool_match_result: return tool_match_result[1] return False @@ -580,7 +595,7 @@ class ArduinoBuilder: def get_upload_tool_names(self): return [ self._get_tool_name(t) for t in self.platform.keys() - if self.tool_name_regex.match(t) and 'upload.pattern' in t + if self.TOOL_NAME_REGEX.match(t) and 'upload.pattern' in t ] # TODO(tonymd): Use these getters in _replace_variables() or @@ -595,7 +610,7 @@ class ArduinoBuilder: line = self.platform.get(variable, False) # Get all unique variables used in this line in line. unique_vars = sorted( - set(self.interpolated_variable_regex.findall(line))) + set(self.INTERPOLATED_VARIABLE_REGEX.findall(line))) # Search for each unique_vars in namespace and global. for var in unique_vars: v_raw_name = var.strip("{}") @@ -775,14 +790,14 @@ class ArduinoBuilder: def get_objcopy_step_names(self): names = [ name for name, line in self.platform.items() - if self.objcopy_step_name_regex.match(name) + if self.OBJCOPY_STEP_NAME_REGEX.match(name) ] return names def get_objcopy_steps(self) -> List[str]: lines = [ line for name, line in self.platform.items() - if self.objcopy_step_name_regex.match(name) + if self.OBJCOPY_STEP_NAME_REGEX.match(name) ] lines = [ self.replace_compile_binary_with_override_path(line) @@ -802,7 +817,7 @@ class ArduinoBuilder: objcopy_suffixes = [ m[1] for m in [ - self.objcopy_step_name_regex.match(line) + self.OBJCOPY_STEP_NAME_REGEX.match(line) for line in objcopy_step_names ] if m ] diff --git a/pw_arduino_build/py/pw_arduino_build/core_installer.py b/pw_arduino_build/py/pw_arduino_build/core_installer.py index a1e3d3c74..35d630876 100644 --- a/pw_arduino_build/py/pw_arduino_build/core_installer.py +++ b/pw_arduino_build/py/pw_arduino_build/core_installer.py @@ -28,6 +28,11 @@ import pw_arduino_build.file_operations as file_operations _LOG = logging.getLogger(__name__) + +class ArduinoCoreNotSupported(Exception): + """Exception raised when a given core can not be installed.""" + + # yapf: disable _ARDUINO_CORE_ARTIFACTS: Dict[str, Dict] = { # pylint: disable=line-too-long @@ -169,9 +174,9 @@ def install_core_command(args: argparse.Namespace): elif args.core_name == "arduino-samd": install_arduino_samd_core(install_prefix, install_dir, cache_dir) else: - _LOG.error("Invalid core '%s'. Supported cores: %s", args.core_name, - ", ".join(supported_cores())) - sys.exit(1) + raise ArduinoCoreNotSupported( + "Invalid core '{}'. Supported cores: {}".format( + args.core_name, ", ".join(supported_cores()))) def supported_cores(): diff --git a/pw_arduino_build/py/pw_arduino_build/file_operations.py b/pw_arduino_build/py/pw_arduino_build/file_operations.py index 2bcdb69ca..1e5796313 100644 --- a/pw_arduino_build/py/pw_arduino_build/file_operations.py +++ b/pw_arduino_build/py/pw_arduino_build/file_operations.py @@ -16,6 +16,7 @@ import glob import hashlib +import json import logging import os import shutil @@ -38,8 +39,8 @@ def find_files(starting_dir: str, directories_only=False) -> List[str]: original_working_dir = os.getcwd() if not (os.path.exists(starting_dir) and os.path.isdir(starting_dir)): - _LOG.error("Directory '%s' does not exist.", starting_dir) - raise FileNotFoundError + raise FileNotFoundError( + "Directory '{}' does not exist.".format(starting_dir)) os.chdir(starting_dir) files = [] @@ -72,17 +73,11 @@ def verify_file_checksum(file_path, expected_checksum, sum_function=sha256_sum): downloaded_checksum = sum_function(file_path) - try: - if downloaded_checksum != expected_checksum: - raise InvalidChecksumError - except InvalidChecksumError: - _LOG.exception("Invalid %s\n" - "%s %s\n" - "%s (expected)", - sum_function.__name__, downloaded_checksum, - os.path.basename(file_path), expected_checksum) - # Exit to stop installation - return sys.exit(1) + if downloaded_checksum != expected_checksum: + raise InvalidChecksumError( + f"Invalid {sum_function.__name__}\n" + f"{downloaded_checksum} {os.path.basename(file_path)}\n" + f"{expected_checksum} (expected)") _LOG.info(" %s:", sum_function.__name__) _LOG.info(" %s %s", downloaded_checksum, os.path.basename(file_path)) @@ -188,3 +183,22 @@ def remove_empty_directories(directory): # if empty directory elif path.is_dir() and len(os.listdir(path)) == 0: path.rmdir() + + +def decode_file_json(file_name): + """Decode JSON values from a file. + + Does not raise an error if the file cannot be decoded.""" + + # Get absolute path to the file. + file_path = os.path.realpath( + os.path.expanduser(os.path.expandvars(file_name))) + + json_file_options = {} + try: + with open(file_path, "r") as jfile: + json_file_options = json.loads(jfile.read()) + except (FileNotFoundError, json.JSONDecodeError): + _LOG.warning("Unable to read file '%s'", file_path) + + return json_file_options, file_path diff --git a/pw_arduino_build/py/pw_arduino_build/log.py b/pw_arduino_build/py/pw_arduino_build/log.py index d5ed9f795..d5afb3035 100644 --- a/pw_arduino_build/py/pw_arduino_build/log.py +++ b/pw_arduino_build/py/pw_arduino_build/log.py @@ -22,16 +22,20 @@ _STDERR_HANDLER = logging.StreamHandler() def install(level: int = logging.INFO) -> None: """Configure the system logger for the arduino_builder log format.""" - # Set log level on root logger to debug, otherwise any higher levels - # elsewhere are ignored. - root = logging.getLogger() - root.setLevel(logging.DEBUG) - - _STDERR_HANDLER.setLevel(level) - _STDERR_HANDLER.setFormatter( - logging.Formatter("[%(asctime)s] " - "%(levelname)s %(message)s", "%Y%m%d %H:%M:%S")) - root.addHandler(_STDERR_HANDLER) + try: + import pw_cli.log # pylint: disable=import-outside-toplevel + pw_cli.log.install(level=level) + except ImportError: + # Set log level on root logger to debug, otherwise any higher levels + # elsewhere are ignored. + root = logging.getLogger() + root.setLevel(logging.DEBUG) + + _STDERR_HANDLER.setLevel(level) + _STDERR_HANDLER.setFormatter( + logging.Formatter("[%(asctime)s] " + "%(levelname)s %(message)s", "%Y%m%d %H:%M:%S")) + root.addHandler(_STDERR_HANDLER) def set_level(log_level: int): diff --git a/pw_arduino_build/py/pw_arduino_build/teensy_detector.py b/pw_arduino_build/py/pw_arduino_build/teensy_detector.py new file mode 100644 index 000000000..7c6bdf4d9 --- /dev/null +++ b/pw_arduino_build/py/pw_arduino_build/teensy_detector.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +# Copyright 2020 The Pigweed Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. +"""Detects attached Teensy boards connected via usb.""" + +import logging +import os +import re +import subprocess +import typing + +from typing import List + +import pw_arduino_build.log + +_LOG = logging.getLogger('teensy_detector') + + +def log_subprocess_output(level, output): + """Logs subprocess output line-by-line.""" + + lines = output.decode('utf-8', errors='replace').splitlines() + for line in lines: + _LOG.log(level, line) + + +class BoardInfo(typing.NamedTuple): + """Information about a connected dev board.""" + dev_name: str + usb_device_path: str + protocol: str + label: str + arduino_upload_tool_name: str + + def test_runner_args(self) -> List[str]: + return [ + "--set-variable", f"serial.port.protocol={self.protocol}", + "--set-variable", f"serial.port={self.usb_device_path}", + "--set-variable", f"serial.port.label={self.dev_name}" + ] + + +def detect_boards(arduino_package_path=False) -> list: + """Detect attached boards, returning a list of Board objects.""" + + if not arduino_package_path: + arduino_package_path = os.path.join("third_party", "arduino", "cores", + "teensy") + + teensy_device_line_regex = re.compile( + r"^(?P<address>[^ ]+) (?P<dev_name>[^ ]+) " + r"\((?P<label>[^)]+)\) ?(?P<rest>.*)$") + + boards = [] + detect_command = [ + os.path.join(os.getcwd(), arduino_package_path, "hardware", "tools", + "teensy_ports"), "-L" + ] + + process = subprocess.run(detect_command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + if process.returncode != 0: + _LOG.error("Command failed with exit code %d.", process.returncode) + _LOG.error("Full command:") + _LOG.error("") + _LOG.error(" %s", " ".join(detect_command)) + _LOG.error("") + _LOG.error("Process output:") + log_subprocess_output(logging.ERROR, process.stdout) + _LOG.error('') + for line in process.stdout.decode("utf-8", errors="replace").splitlines(): + device_match_result = teensy_device_line_regex.match(line) + if device_match_result: + teensy_device = device_match_result.groupdict() + boards.append( + BoardInfo(dev_name=teensy_device["dev_name"], + usb_device_path=teensy_device["address"], + protocol="Teensy", + label=teensy_device["label"], + arduino_upload_tool_name="teensyloader")) + return boards + + +def main(): + """This detects and then displays all attached discovery boards.""" + + pw_arduino_build.log.install(logging.INFO) + + boards = detect_boards() + if not boards: + _LOG.info("No attached boards detected") + for idx, board in enumerate(boards): + _LOG.info("Board %d:", idx) + _LOG.info(" - Name: %s", board.label) + _LOG.info(" - Port: %s", board.dev_name) + _LOG.info(" - Address: %s", board.usb_device_path) + _LOG.info(" - Test runner args: %s", + " ".join(board.test_runner_args())) + + +if __name__ == "__main__": + main() diff --git a/pw_arduino_build/py/pw_arduino_build/unit_test_client.py b/pw_arduino_build/py/pw_arduino_build/unit_test_client.py new file mode 100755 index 000000000..38227f56a --- /dev/null +++ b/pw_arduino_build/py/pw_arduino_build/unit_test_client.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +# Copyright 2020 The Pigweed Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. +"""Launch a pw_target_runner client that sends a test request.""" + +import argparse +import subprocess +import sys +from typing import Optional + +_TARGET_CLIENT_COMMAND = 'pw_target_runner_client' + + +def parse_args(): + """Parses command-line arguments.""" + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('binary', help='The target test binary to run') + parser.add_argument('--server-port', + type=int, + default=8081, + help='Port the test server is located on') + parser.add_argument('runner_args', + nargs=argparse.REMAINDER, + help='Arguments to forward to the test runner') + + return parser.parse_args() + + +def launch_client(binary: str, server_port: Optional[int]) -> int: + """Sends a test request to the specified server port.""" + cmd = [_TARGET_CLIENT_COMMAND, '-binary', binary] + + if server_port is not None: + cmd.extend(['-port', str(server_port)]) + + return subprocess.call(cmd) + + +def main() -> int: + """Launch a test by sending a request to a pw_target_runner_server.""" + args = parse_args() + return launch_client(args.binary, args.server_port) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/pw_arduino_build/py/pw_arduino_build/unit_test_runner.py b/pw_arduino_build/py/pw_arduino_build/unit_test_runner.py new file mode 100755 index 000000000..24ae65512 --- /dev/null +++ b/pw_arduino_build/py/pw_arduino_build/unit_test_runner.py @@ -0,0 +1,340 @@ +#!/usr/bin/env python3 +# Copyright 2020 The Pigweed Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. +"""This script flashes and runs unit tests onto Arduino boards.""" + +import argparse +import logging +import os +import re +import subprocess +import sys +import time +from typing import List + +import serial +import pw_arduino_build.log +from pw_arduino_build import teensy_detector +from pw_arduino_build.file_operations import decode_file_json + +_LOG = logging.getLogger('unit_test_runner') + +# Verification of test pass/failure depends on these strings. If the formatting +# or output of the simple_printing_event_handler changes, this may need to be +# updated. +_TESTS_STARTING_STRING = b'[==========] Running all tests.' +_TESTS_DONE_STRING = b'[==========] Done running all tests.' +_TEST_FAILURE_STRING = b'[ FAILED ]' + +# How long to wait for the first byte of a test to be emitted. This is longer +# than the user-configurable timeout as there's a delay while the device is +# flashed. +_FLASH_TIMEOUT = 5.0 + + +class TestingFailure(Exception): + """A simple exception to be raised when a testing step fails.""" + + +class DeviceNotFound(Exception): + """A simple exception to be raised when unable to connect to a device.""" + + +class ArduinoCoreNotSupported(Exception): + """Exception raised when a given core does not support unit testing.""" + + +def parse_args(): + """Parses command-line arguments.""" + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('binary', help='The target test binary to run') + parser.add_argument('--port', + help='The name of the serial port to connect to when ' + 'running tests') + parser.add_argument('--baud', + type=int, + default=115200, + help='Target baud rate to use for serial communication' + ' with target device') + parser.add_argument('--test-timeout', + type=float, + default=5.0, + help='Maximum communication delay in seconds before a ' + 'test is considered unresponsive and aborted') + parser.add_argument('--verbose', + '-v', + dest='verbose', + action="store_true", + help='Output additional logs as the script runs') + + # arduino_builder arguments + # TODO(tonymd): Get these args from __main__.py or elsewhere. + parser.add_argument("-c", + "--config-file", + required=True, + help="Path to a config file.") + parser.add_argument("--arduino-package-path", + help="Path to the arduino IDE install location.") + parser.add_argument("--arduino-package-name", + help="Name of the Arduino board package to use.") + parser.add_argument("--compiler-path-override", + help="Path to arm-none-eabi-gcc bin folder. " + "Default: Arduino core specified gcc") + parser.add_argument("--board", help="Name of the Arduino board to use.") + parser.add_argument("--upload-tool", + required=True, + help="Name of the Arduino upload tool to use.") + parser.add_argument("--set-variable", + action="append", + metavar='some.variable=NEW_VALUE', + help="Override an Arduino recipe variable. May be " + "specified multiple times. For example: " + "--set-variable 'serial.port.label=/dev/ttyACM0' " + "--set-variable 'serial.port.protocol=Teensy'") + return parser.parse_args() + + +def log_subprocess_output(level, output): + """Logs subprocess output line-by-line.""" + + lines = output.decode('utf-8', errors='replace').splitlines() + for line in lines: + _LOG.log(level, line) + + +def read_serial(port, baud_rate, test_timeout) -> bytes: + """Reads lines from a serial port until a line read times out. + + Returns bytes object containing the read serial data. + """ + + serial_data = bytearray() + device = serial.Serial(baudrate=baud_rate, + port=port, + timeout=_FLASH_TIMEOUT) + if not device.is_open: + raise TestingFailure('Failed to open device') + + # Flush input buffer and reset the device to begin the test. + device.reset_input_buffer() + + # Block and wait for the first byte. + serial_data += device.read() + if not serial_data: + raise TestingFailure('Device not producing output') + + device.timeout = test_timeout + + # Read with a reasonable timeout until we stop getting characters. + while True: + bytes_read = device.readline() + if not bytes_read: + break + serial_data += bytes_read + if serial_data.rfind(_TESTS_DONE_STRING) != -1: + # Set to much more aggressive timeout since the last one or two + # lines should print out immediately. (one line if all fails or all + # passes, two lines if mixed.) + device.timeout = 0.01 + + # Remove carriage returns. + serial_data = serial_data.replace(b'\r', b'') + + # Try to trim captured results to only contain most recent test run. + test_start_index = serial_data.rfind(_TESTS_STARTING_STRING) + return serial_data if test_start_index == -1 else serial_data[ + test_start_index:] + + +def wait_for_port(port): + """Wait for the path to the port to be available.""" + # Wait for the port to exist + while not os.path.exists(port): + time.sleep(1) + # Wait for the port to be accessible + while not os.access(port, os.R_OK): + time.sleep(1) + + +def flash_device(test_runner_args, upload_tool): + """Flash binary to a connected device using the provided configuration.""" + + # TODO(tonymd): Create a library function to call rather than launching + # the arduino_builder script. + flash_tool = 'arduino_builder' + cmd = [flash_tool, "--quiet"] + test_runner_args + [ + "--run-objcopy", "--run-postbuilds", "--run-upload", upload_tool + ] + _LOG.info('Flashing firmware to device') + _LOG.debug('Running: %s', " ".join(cmd)) + + env = os.environ.copy() + process = subprocess.run(cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=env) + if process.returncode: + log_subprocess_output(logging.ERROR, process.stdout) + raise TestingFailure('Failed to flash target device') + + log_subprocess_output(logging.DEBUG, process.stdout) + + _LOG.debug('Successfully flashed firmware to device') + + +def handle_test_results(test_output): + """Parses test output to determine whether tests passed or failed.""" + + if test_output.find(_TESTS_STARTING_STRING) == -1: + raise TestingFailure('Failed to find test start') + + if test_output.rfind(_TESTS_DONE_STRING) == -1: + log_subprocess_output(logging.INFO, test_output) + raise TestingFailure('Tests did not complete') + + if test_output.rfind(_TEST_FAILURE_STRING) != -1: + log_subprocess_output(logging.INFO, test_output) + raise TestingFailure('Test suite had one or more failures') + + log_subprocess_output(logging.DEBUG, test_output) + + _LOG.info('Test passed!') + + +def run_device_test(binary, port, baud, test_timeout, upload_tool, + arduino_package_path, test_runner_args) -> bool: + """Flashes, runs, and checks an on-device test binary. + + Returns true on test pass. + """ + if test_runner_args is None: + test_runner_args = [] + + if "teensy" not in arduino_package_path: + raise ArduinoCoreNotSupported(arduino_package_path) + + if port is None or "--set-variable" not in test_runner_args: + _LOG.debug('Attempting to automatically detect dev board') + boards = teensy_detector.detect_boards(arduino_package_path) + if not boards: + error = 'Could not find an attached device' + _LOG.error(error) + raise DeviceNotFound(error) + test_runner_args += boards[0].test_runner_args() + upload_tool = boards[0].arduino_upload_tool_name + if port is None: + port = boards[0].dev_name + + _LOG.debug('Launching test binary %s', binary) + try: + result: List[bytes] = [] + _LOG.info('Running test') + # Warning: A race condition is possible here. This assumes the host is + # able to connect to the port and that there isn't a test running on + # this serial port. + flash_device(test_runner_args, upload_tool) + wait_for_port(port) + result.append(read_serial(port, baud, test_timeout)) + if result: + handle_test_results(result[0]) + except TestingFailure as err: + _LOG.error(err) + return False + + return True + + +def get_option(key, config_file_values, args, required=False): + command_line_option = getattr(args, key, None) + final_option = config_file_values.get(key, command_line_option) + if required and command_line_option is None and final_option is None: + # Print a similar error message to argparse + executable = os.path.basename(sys.argv[0]) + option = "--" + key.replace("_", "-") + print(f"{executable}: error: the following arguments are required: " + f"{option}") + sys.exit(1) + return final_option + + +def main(): + """Set up runner, and then flash/run device test.""" + args = parse_args() + + json_file_options, unused_config_path = decode_file_json(args.config_file) + + log_level = logging.DEBUG if args.verbose else logging.INFO + pw_arduino_build.log.install(log_level) + + # Construct arduino_builder flash arguments for a given .elf binary. + arduino_package_path = get_option("arduino_package_path", + json_file_options, + args, + required=True) + # Arduino core args. + arduino_builder_args = [ + "--arduino-package-path", + arduino_package_path, + "--arduino-package-name", + get_option("arduino_package_name", + json_file_options, + args, + required=True), + ] + + # Use CIPD installed compilers. + compiler_path_override = get_option("compiler_path_override", + json_file_options, args) + if compiler_path_override: + arduino_builder_args += [ + "--compiler-path-override", compiler_path_override + ] + + # Run subcommand with board selection arg. + arduino_builder_args += [ + "run", "--board", + get_option("board", json_file_options, args, required=True) + ] + + # .elf file location args. + build_path = os.path.dirname(args.binary) + arduino_builder_args += ["--build-path", build_path] + build_project_name = os.path.basename(args.binary) + # Remove '.elf' extension. + match_result = re.match(r'(.*?)\.elf$', os.path.basename(args.binary), + re.IGNORECASE) + if match_result: + build_project_name = match_result[1] + arduino_builder_args += ["--build-project-name", build_project_name] + + # USB port is passed to arduino_builder_args via --set-variable args. + if args.set_variable: + for var in args.set_variable: + arduino_builder_args += ["--set-variable", var] + + if run_device_test(args.binary, + args.port, + args.baud, + args.test_timeout, + args.upload_tool, + arduino_package_path, + test_runner_args=arduino_builder_args): + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/pw_arduino_build/py/pw_arduino_build/unit_test_server.py b/pw_arduino_build/py/pw_arduino_build/unit_test_server.py new file mode 100644 index 000000000..0fafd71fb --- /dev/null +++ b/pw_arduino_build/py/pw_arduino_build/unit_test_server.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +# Copyright 2020 The Pigweed Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. +"""Launch a pw_test_server server to use for multi-device testing.""" + +import argparse +import logging +import sys +import tempfile +from typing import IO, List, Optional + +import pw_cli.process +import pw_arduino_build.log +from pw_arduino_build import teensy_detector +from pw_arduino_build.file_operations import decode_file_json +from pw_arduino_build.unit_test_runner import ArduinoCoreNotSupported + +_LOG = logging.getLogger('unit_test_server') + +_TEST_RUNNER_COMMAND = 'arduino_unit_test_runner' + +_TEST_SERVER_COMMAND = 'pw_target_runner_server' + + +class UnknownArduinoCore(Exception): + """Exception raised when no Arduino core can be found.""" + + +def parse_args(): + """Parses command-line arguments.""" + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--server-port', + type=int, + default=8081, + help='Port to launch the pw_target_runner_server on') + parser.add_argument('--server-config', + type=argparse.FileType('r'), + help='Path to server config file') + parser.add_argument('--verbose', + '-v', + dest='verbose', + action="store_true", + help='Output additional logs as the script runs') + parser.add_argument("-c", + "--config-file", + required=True, + help="Path to an arduino_builder config file.") + # TODO(tonymd): Explicitly split args using "--". See example in: + # //pw_unit_test/py/pw_unit_test/test_runner.py:326 + parser.add_argument('runner_args', + nargs=argparse.REMAINDER, + help='Arguments to forward to the test runner') + + return parser.parse_args() + + +def generate_runner(command: str, arguments: List[str]) -> str: + """Generates a text-proto style pw_target_runner_server configuration.""" + # TODO(amontanez): Use a real proto library to generate this when we have + # one set up. + for i, arg in enumerate(arguments): + arguments[i] = f' args: "{arg}"' + runner = ['runner {', f' command:"{command}"'] + runner.extend(arguments) + runner.append('}\n') + return '\n'.join(runner) + + +def generate_server_config(runner_args: Optional[List[str]], + arduino_package_path: str) -> IO[bytes]: + """Returns a temporary generated file for use as the server config.""" + + if "teensy" not in arduino_package_path: + raise ArduinoCoreNotSupported(arduino_package_path) + + boards = teensy_detector.detect_boards(arduino_package_path) + if not boards: + _LOG.critical('No attached boards detected') + sys.exit(1) + config_file = tempfile.NamedTemporaryFile() + _LOG.debug('Generating test server config at %s', config_file.name) + _LOG.debug('Found %d attached devices', len(boards)) + for board in boards: + test_runner_args = [] + if runner_args: + test_runner_args += runner_args + test_runner_args += ["-v"] + board.test_runner_args() + test_runner_args += ["--port", board.dev_name] + test_runner_args += ["--upload-tool", board.arduino_upload_tool_name] + config_file.write( + generate_runner(_TEST_RUNNER_COMMAND, + test_runner_args).encode('utf-8')) + config_file.flush() + return config_file + + +def launch_server(server_config: Optional[IO[bytes]], + server_port: Optional[int], runner_args: Optional[List[str]], + arduino_package_path: str) -> int: + """Launch a device test server with the provided arguments.""" + if server_config is None: + # Auto-detect attached boards if no config is provided. + server_config = generate_server_config(runner_args, + arduino_package_path) + + cmd = [_TEST_SERVER_COMMAND, '-config', server_config.name] + + if server_port is not None: + cmd.extend(['-port', str(server_port)]) + + return pw_cli.process.run(*cmd, log_output=True).returncode + + +def main(): + """Launch a device test server with the provided arguments.""" + args = parse_args() + + if "--" in args.runner_args: + args.runner_args.remove("--") + + log_level = logging.DEBUG if args.verbose else logging.INFO + pw_arduino_build.log.install(log_level) + + # Get arduino_package_path from either the config file or command line args. + arduino_package_path = None + if args.config_file: + json_file_options, unused_config_path = decode_file_json( + args.config_file) + arduino_package_path = json_file_options.get("arduino_package_path", + None) + # Must pass --config-file option in the runner_args. + if "--config-file" not in args.runner_args: + args.runner_args.append("--config-file") + args.runner_args.append(args.config_file) + + # Check for arduino_package_path in the runner_args + try: + arduino_package_path = args.runner_args[ + args.runner_args.index("--arduino-package-path") + 1] + except (ValueError, IndexError): + # Only raise an error if arduino_package_path not set from the json. + if arduino_package_path is None: + raise UnknownArduinoCore("Test runner arguments: '{}'".format( + " ".join(args.runner_args))) + + exit_code = launch_server(args.server_config, args.server_port, + args.runner_args, arduino_package_path) + sys.exit(exit_code) + + +if __name__ == '__main__': + main() diff --git a/pw_arduino_build/py/setup.py b/pw_arduino_build/py/setup.py index 1cb074f75..0c7afc60b 100644 --- a/pw_arduino_build/py/setup.py +++ b/pw_arduino_build/py/setup.py @@ -23,8 +23,16 @@ setuptools.setup( description='Target-specific python scripts for the arduino target', packages=setuptools.find_packages(), entry_points={ - 'console_scripts': - ['arduino_builder = pw_arduino_build.__main__:main'] + 'console_scripts': [ + 'arduino_builder = pw_arduino_build.__main__:main', + 'teensy_detector = pw_arduino_build.teensy_detector:main', + 'arduino_unit_test_runner = ' + ' pw_arduino_build.unit_test_runner:main', + 'arduino_test_server = ' + ' pw_arduino_build.unit_test_server:main', + 'arduino_test_client = ' + ' pw_arduino_build.unit_test_client:main', + ] }, install_requires=[ 'pyserial', |