From 1f800bce391c26bfb690f21085c9da97b8fe31ac Mon Sep 17 00:00:00 2001 From: Piotr Golyzniak Date: Fri, 21 Apr 2023 12:19:40 +0200 Subject: [PATCH 01/27] scripts: add pytest plugin Add initial version of pytest plugin dedicated to run pytest tests in Zephyr project. Co-authored-by: Lukasz Fundakowski Signed-off-by: Piotr Golyzniak --- .../pylib/pytest-device-adapter/.gitignore | 68 +++++++ .../.pre-commit-config.yaml | 36 ++++ .../pylib/pytest-device-adapter/README.rst | 37 ++++ .../requirements-dev.txt | 7 + .../requirements-tests.txt | 4 + .../pytest-device-adapter/requirements.txt | 5 + scripts/pylib/pytest-device-adapter/setup.cfg | 61 ++++++ scripts/pylib/pytest-device-adapter/setup.py | 3 + .../src/device_adapter/__init__.py | 1 + .../src/device_adapter/constants.py | 4 + .../src/device_adapter/device/__init__.py | 0 .../device_adapter/device/device_abstract.py | 59 ++++++ .../src/device_adapter/device/factory.py | 35 ++++ .../device_adapter/device/hardware_adapter.py | 186 ++++++++++++++++++ .../device_adapter/device_adapter_config.py | 65 ++++++ .../src/device_adapter/exceptions.py | 2 + .../src/device_adapter/fixtures/__init__.py | 0 .../src/device_adapter/fixtures/dut.py | 34 ++++ .../src/device_adapter/helper.py | 73 +++++++ .../src/device_adapter/log.py | 66 +++++++ .../src/device_adapter/plugin.py | 132 +++++++++++++ .../tests/hardware_adapter_test.py | 24 +++ scripts/pylib/pytest-device-adapter/tox.ini | 27 +++ scripts/requirements-run-test.txt | 3 + 24 files changed, 932 insertions(+) create mode 100644 scripts/pylib/pytest-device-adapter/.gitignore create mode 100644 scripts/pylib/pytest-device-adapter/.pre-commit-config.yaml create mode 100644 scripts/pylib/pytest-device-adapter/README.rst create mode 100644 scripts/pylib/pytest-device-adapter/requirements-dev.txt create mode 100644 scripts/pylib/pytest-device-adapter/requirements-tests.txt create mode 100644 scripts/pylib/pytest-device-adapter/requirements.txt create mode 100644 scripts/pylib/pytest-device-adapter/setup.cfg create mode 100644 scripts/pylib/pytest-device-adapter/setup.py create mode 100644 scripts/pylib/pytest-device-adapter/src/device_adapter/__init__.py create mode 100644 scripts/pylib/pytest-device-adapter/src/device_adapter/constants.py create mode 100644 scripts/pylib/pytest-device-adapter/src/device_adapter/device/__init__.py create mode 100644 scripts/pylib/pytest-device-adapter/src/device_adapter/device/device_abstract.py create mode 100644 scripts/pylib/pytest-device-adapter/src/device_adapter/device/factory.py create mode 100644 scripts/pylib/pytest-device-adapter/src/device_adapter/device/hardware_adapter.py create mode 100644 scripts/pylib/pytest-device-adapter/src/device_adapter/device_adapter_config.py create mode 100644 scripts/pylib/pytest-device-adapter/src/device_adapter/exceptions.py create mode 100644 scripts/pylib/pytest-device-adapter/src/device_adapter/fixtures/__init__.py create mode 100644 scripts/pylib/pytest-device-adapter/src/device_adapter/fixtures/dut.py create mode 100644 scripts/pylib/pytest-device-adapter/src/device_adapter/helper.py create mode 100644 scripts/pylib/pytest-device-adapter/src/device_adapter/log.py create mode 100644 scripts/pylib/pytest-device-adapter/src/device_adapter/plugin.py create mode 100644 scripts/pylib/pytest-device-adapter/tests/hardware_adapter_test.py create mode 100644 scripts/pylib/pytest-device-adapter/tox.ini diff --git a/scripts/pylib/pytest-device-adapter/.gitignore b/scripts/pylib/pytest-device-adapter/.gitignore new file mode 100644 index 0000000000000..c27e590cd8675 --- /dev/null +++ b/scripts/pylib/pytest-device-adapter/.gitignore @@ -0,0 +1,68 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +*.log +*.xml + +# Pycharm +.idea/ +# VSCode +.vscode/ + +device_adapter_out diff --git a/scripts/pylib/pytest-device-adapter/.pre-commit-config.yaml b/scripts/pylib/pytest-device-adapter/.pre-commit-config.yaml new file mode 100644 index 0000000000000..62b4e15c93dc4 --- /dev/null +++ b/scripts/pylib/pytest-device-adapter/.pre-commit-config.yaml @@ -0,0 +1,36 @@ +repos: + - repo: https://github.com/pycqa/flake8 + rev: 6.0.0 + hooks: + - id: flake8 + files: ^(src/|tests/) + stages: [ commit ] + + - repo: https://github.com/PyCQA/isort + rev: 5.12.0 + hooks: + - id: isort + files: ^(src/|tests/) + stages: [ commit ] + args: [ + "--multi-line=3", + "--trailing-comma", + "--force-grid-wrap=0", + "--use-parentheses", + "--line-width=88" + ] + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: trailing-whitespace + - id: double-quote-string-fixer + - id: end-of-file-fixer + - id: requirements-txt-fixer + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v0.991 + hooks: + - id: mypy + args: ["--ignore-missing-imports"] + additional_dependencies: ["types-PyYaml", "types-tabulate"] diff --git a/scripts/pylib/pytest-device-adapter/README.rst b/scripts/pylib/pytest-device-adapter/README.rst new file mode 100644 index 0000000000000..6d547073effd9 --- /dev/null +++ b/scripts/pylib/pytest-device-adapter/README.rst @@ -0,0 +1,37 @@ +============== +Device adapter +============== + +Installation +------------ + +Installation from the source: + +.. code-block:: sh + + pip install . + + +Installation the project in editable mode: + +.. code-block:: sh + + pip install -e . + + +Usage +----- + +Built shell application by west and call pytest directly: + +.. code-block:: sh + + cd samples/subsys/shell/shell_module + west build -p -b nrf52840dk_nrf52840 + pytest --device-adapter --device-type=hardware --device-serial=/dev/ttyACM0 --build-dir=build + +Or run this test by Twister: + +.. code-block:: sh + + ./scripts/twister -vv -p nrf52840dk_nrf52840 --device-testing --device-serial /dev/ttyACM0 -T samples/subsys/shell/shell_module -s samples/subsys/shell/shell_module/sample.shell.shell_module diff --git a/scripts/pylib/pytest-device-adapter/requirements-dev.txt b/scripts/pylib/pytest-device-adapter/requirements-dev.txt new file mode 100644 index 0000000000000..832baf5b9ea13 --- /dev/null +++ b/scripts/pylib/pytest-device-adapter/requirements-dev.txt @@ -0,0 +1,7 @@ +flake8 +isort +mypy==0.991 +pytest-cov +tox +types-PyYAML +types-tabulate diff --git a/scripts/pylib/pytest-device-adapter/requirements-tests.txt b/scripts/pylib/pytest-device-adapter/requirements-tests.txt new file mode 100644 index 0000000000000..95a467226c8a3 --- /dev/null +++ b/scripts/pylib/pytest-device-adapter/requirements-tests.txt @@ -0,0 +1,4 @@ +pytest>=7.0.0 +pytest-cov +pytest-split +pytest-xdist diff --git a/scripts/pylib/pytest-device-adapter/requirements.txt b/scripts/pylib/pytest-device-adapter/requirements.txt new file mode 100644 index 0000000000000..bd26f188d1e66 --- /dev/null +++ b/scripts/pylib/pytest-device-adapter/requirements.txt @@ -0,0 +1,5 @@ +marshmallow==3.19.0 +psutil==5.9.4 +pyserial==3.5 +pytest==7.2.0 +PyYAML==6.0 diff --git a/scripts/pylib/pytest-device-adapter/setup.cfg b/scripts/pylib/pytest-device-adapter/setup.cfg new file mode 100644 index 0000000000000..e90d0d95ac0dc --- /dev/null +++ b/scripts/pylib/pytest-device-adapter/setup.cfg @@ -0,0 +1,61 @@ +[metadata] +name = pytest-device-adapter +version = attr: device_adapter.__version__ +description = Plugin for pytest to run tests which require interaction with real and simulated devices +long_description = file: README.rst +url = https://github.com/gopiotr/pytest-device-adapter +python_requires = ~=3.8 +classifiers = + Development Status :: 3 - Alpha + Intended Audience :: Developers + Topic :: Software Development :: Embedded Systems + Topic :: Software Development :: Quality Assurance + Operating System :: Posix :: Linux + Operating System :: Microsoft :: Windows + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 + +[options] +packages = find: +package_dir = + =src +install_requires = + marshmallow + psutil + pyserial + pytest>=7.0.0 + PyYAML>=5.1 + +[options.packages.find] +where = src + +[options.entry_points] +pytest11 = + device_adapter = device_adapter.plugin + +[flake8] +max-line-length = 120 +ignore = + # line break before binary operator + W503, + # See https://github.com/PyCQA/pycodestyle/issues/373 + E203, + +per-file-ignores = + # imported but unused + __init__.py: F401 + +[isort] +profile = black +src_paths = src,tests +filter_files = True +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = True +line_length = 88 + +[mypy] +ignore_missing_imports = True diff --git a/scripts/pylib/pytest-device-adapter/setup.py b/scripts/pylib/pytest-device-adapter/setup.py new file mode 100644 index 0000000000000..b908cbe55cb34 --- /dev/null +++ b/scripts/pylib/pytest-device-adapter/setup.py @@ -0,0 +1,3 @@ +import setuptools + +setuptools.setup() diff --git a/scripts/pylib/pytest-device-adapter/src/device_adapter/__init__.py b/scripts/pylib/pytest-device-adapter/src/device_adapter/__init__.py new file mode 100644 index 0000000000000..b8023d8bc0ca4 --- /dev/null +++ b/scripts/pylib/pytest-device-adapter/src/device_adapter/__init__.py @@ -0,0 +1 @@ +__version__ = '0.0.1' diff --git a/scripts/pylib/pytest-device-adapter/src/device_adapter/constants.py b/scripts/pylib/pytest-device-adapter/src/device_adapter/constants.py new file mode 100644 index 0000000000000..551f236199cb2 --- /dev/null +++ b/scripts/pylib/pytest-device-adapter/src/device_adapter/constants.py @@ -0,0 +1,4 @@ +from __future__ import annotations + +QEMU_FIFO_FILE_NAME: str = 'qemu-fifo' +END_OF_DATA = object() #: used for indicating that there will be no more data in queue diff --git a/scripts/pylib/pytest-device-adapter/src/device_adapter/device/__init__.py b/scripts/pylib/pytest-device-adapter/src/device_adapter/device/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/scripts/pylib/pytest-device-adapter/src/device_adapter/device/device_abstract.py b/scripts/pylib/pytest-device-adapter/src/device_adapter/device/device_abstract.py new file mode 100644 index 0000000000000..c4741488dc45d --- /dev/null +++ b/scripts/pylib/pytest-device-adapter/src/device_adapter/device/device_abstract.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import abc +import logging +import os +from typing import Generator + +from device_adapter.device_adapter_config import DeviceConfig + +logger = logging.getLogger(__name__) + + +class DeviceAbstract(abc.ABC): + """Class defines an interface for all devices.""" + + def __init__(self, device_config: DeviceConfig, **kwargs) -> None: + """ + :param device_config: device configuration + """ + self.device_config: DeviceConfig = device_config + + def __repr__(self) -> str: + return f'{self.__class__.__name__}()' + + @property + def env(self) -> dict[str, str]: + env = os.environ.copy() + return env + + @abc.abstractmethod + def connect(self, timeout: float = 1) -> None: + """Connect with the device (e.g. via UART)""" + + @abc.abstractmethod + def disconnect(self) -> None: + """Close a connection with the device""" + + @abc.abstractmethod + def generate_command(self) -> None: + """ + Generate command which will be used during flashing or running device. + + :param build_dir: path to directory with built application + """ + + def flash_and_run(self) -> None: + """ + Flash and run application on a device. + + :param timeout: time out in seconds + """ + + @property + @abc.abstractmethod + def iter_stdout(self) -> Generator[str, None, None]: + """Iterate stdout from a device.""" + + def stop(self) -> None: + """Stop device.""" diff --git a/scripts/pylib/pytest-device-adapter/src/device_adapter/device/factory.py b/scripts/pylib/pytest-device-adapter/src/device_adapter/device/factory.py new file mode 100644 index 0000000000000..9e1c9013c001f --- /dev/null +++ b/scripts/pylib/pytest-device-adapter/src/device_adapter/device/factory.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import logging +from typing import Type + +from device_adapter.device.device_abstract import DeviceAbstract +from device_adapter.device.hardware_adapter import HardwareAdapter +from device_adapter.exceptions import DeviceAdapterException + +logger = logging.getLogger(__name__) + + +class DeviceFactory: + _devices: dict[str, Type[DeviceAbstract]] = {} + + @classmethod + def discover(cls): + """Return available devices.""" + + @classmethod + def register_device_class(cls, name: str, klass: Type[DeviceAbstract]): + if name not in cls._devices: + cls._devices[name] = klass + + @classmethod + def get_device(cls, name: str) -> Type[DeviceAbstract]: + logger.debug('Get device type "%s"', name) + try: + return cls._devices[name] + except KeyError as e: + logger.error('There is no device with name "%s"', name) + raise DeviceAdapterException(f'There is no device with name "{name}"') from e + + +DeviceFactory.register_device_class('hardware', HardwareAdapter) diff --git a/scripts/pylib/pytest-device-adapter/src/device_adapter/device/hardware_adapter.py b/scripts/pylib/pytest-device-adapter/src/device_adapter/device/hardware_adapter.py new file mode 100644 index 0000000000000..fb823e5438f4e --- /dev/null +++ b/scripts/pylib/pytest-device-adapter/src/device_adapter/device/hardware_adapter.py @@ -0,0 +1,186 @@ +""" +This module implements adapter class for real device (DK board). +""" +from __future__ import annotations + +import logging +import os +import pty +import re +import shutil +import subprocess +from typing import Generator + +import serial + +from device_adapter.device.device_abstract import DeviceAbstract +from device_adapter.device_adapter_config import DeviceConfig +from device_adapter.exceptions import DeviceAdapterException +from device_adapter.helper import log_command + +logger = logging.getLogger(__name__) + + +class HardwareAdapter(DeviceAbstract): + """Adapter class for real device.""" + + def __init__(self, device_config: DeviceConfig, **kwargs) -> None: + super().__init__(device_config, **kwargs) + self.connection: serial.Serial | None = None + self.command: list[str] = [] + self.process_kwargs: dict = { + 'stdout': subprocess.PIPE, + 'stderr': subprocess.STDOUT, + 'env': self.env, + } + self.serial_pty_proc: subprocess.Popen | None = None + + def connect(self, timeout: float = 1) -> None: + """ + Open serial connection. + + :param timeout: Read timeout value in seconds + """ + if self.connection: + # already opened + return + + serial_name = self._open_serial_pty() or self.device_config.serial + logger.info('Opening serial connection for %s', serial_name) + try: + self.connection = serial.Serial( + serial_name, + baudrate=self.device_config.baud, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + bytesize=serial.EIGHTBITS, + timeout=timeout + ) + except serial.SerialException as e: + logger.exception('Cannot open connection: %s', e) + self._close_serial_pty() + raise + + self.connection.flush() + + def disconnect(self) -> None: + """Close serial connection.""" + if self.connection: + serial_name = self.connection.port + self.connection.close() + self.connection = None + logger.info('Closed serial connection for %s', serial_name) + self._close_serial_pty() + + def _open_serial_pty(self) -> str | None: + """Open a pty pair, run process and return tty name""" + if not self.device_config.serial_pty: + return None + master, slave = pty.openpty() + try: + self.serial_pty_proc = subprocess.Popen( + re.split(',| ', self.device_config.serial_pty), + stdout=master, + stdin=master, + stderr=master + ) + except subprocess.CalledProcessError as e: + logger.exception('Failed to run subprocess %s, error %s', + self.device_config.serial_pty, str(e)) + raise + return os.ttyname(slave) + + def _close_serial_pty(self) -> None: + """Terminate the process opened for serial pty script""" + if self.serial_pty_proc: + self.serial_pty_proc.terminate() + self.serial_pty_proc.communicate() + logger.info('Process %s terminated', self.device_config.serial_pty) + self.serial_pty_proc = None + + def generate_command(self) -> None: + """ + Return command to flash. + + :param build_dir: build directory + :return: command to flash + """ + west = shutil.which('west') + if west is None: + raise DeviceAdapterException('west not found') + + command = [ + west, + 'flash', + '--skip-rebuild', + '--build-dir', str(self.device_config.build_dir), + ] + + command_extra_args = [] + if self.device_config.west_flash_extra_args: + command_extra_args.extend(self.device_config.west_flash_extra_args) + + if runner := self.device_config.runner: + command.extend(['--runner', runner]) + + if board_id := self.device_config.id: + if runner == 'pyocd': + command_extra_args.append('--board-id') + command_extra_args.append(board_id) + elif runner == 'nrfjprog': + command_extra_args.append('--dev-id') + command_extra_args.append(board_id) + elif runner == 'openocd' and self.device_config.product in ['STM32 STLink', 'STLINK-V3']: + command_extra_args.append('--cmd-pre-init') + command_extra_args.append(f'hla_serial {board_id}') + elif runner == 'openocd' and self.device_config.product == 'EDBG CMSIS-DAP': + command_extra_args.append('--cmd-pre-init') + command_extra_args.append(f'cmsis_dap_serial {board_id}') + elif runner == 'jlink': + command.append(f'--tool-opt=-SelectEmuBySN {board_id}') + elif runner == 'stm32cubeprogrammer': + command.append(f'--tool-opt=sn={board_id}') + + if command_extra_args: + command.append('--') + command.extend(command_extra_args) + self.command = command + + def flash_and_run(self) -> None: + if not self.command: + msg = 'Flash command is empty, please verify if it was generated properly.' + logger.error(msg) + raise DeviceAdapterException(msg) + logger.info('Flashing device') + log_command(logger, 'Flashing command', self.command, level=logging.INFO) + try: + process = subprocess.Popen( + self.command, + **self.process_kwargs + ) + except subprocess.CalledProcessError: + logger.error('Error while flashing device') + raise DeviceAdapterException('Could not flash device') + else: + try: + stdout, stderr = process.communicate(timeout=self.device_config.flashing_timeout) + except subprocess.TimeoutExpired: + process.kill() + else: + for line in stdout.decode('utf-8').split('\n'): + if line: + logger.info(line) + if process.returncode == 0: + logger.info('Flashing finished') + else: + raise DeviceAdapterException('Could not flash device') + + @property + def iter_stdout(self) -> Generator[str, None, None]: + """Return output from serial.""" + if not self.connection: + return + self.connection.flush() + while self.connection and self.connection.is_open: + stream = self.connection.readline() + yield stream.decode('UTF-8').strip() diff --git a/scripts/pylib/pytest-device-adapter/src/device_adapter/device_adapter_config.py b/scripts/pylib/pytest-device-adapter/src/device_adapter/device_adapter_config.py new file mode 100644 index 0000000000000..204396a724062 --- /dev/null +++ b/scripts/pylib/pytest-device-adapter/src/device_adapter/device_adapter_config.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from pathlib import Path + +import pytest + +logger = logging.getLogger(__name__) + + +@dataclass +class DeviceConfig: + platform: str = '' + type: str = '' + serial: str = '' + baud: int = 115200 + runner: str = '' + id: str = '' + product: str = '' + serial_pty: str = '' + west_flash_extra_args: list[str] = field(default_factory=list, repr=False) + flashing_timeout: int = 60 # [s] + build_dir: Path | None = None + binary_file: Path | None = None + name: str = '' + + +@dataclass +class DeviceAdapterConfig: + """Store Device adapter configuration to have easy access in test.""" + output_dir: Path = Path('device_adapter_out') + devices: list[DeviceConfig] = field(default_factory=list, repr=False) + + @classmethod + def create(cls, config: pytest.Config) -> DeviceAdapterConfig: + """Create new instance from pytest.Config.""" + output_dir: Path = config.option.output_dir + + devices = [] + + west_flash_extra_args: list[str] = [] + if config.option.west_flash_extra_args: + west_flash_extra_args = [w.strip() for w in config.option.west_flash_extra_args.split(',')] + device_from_cli = DeviceConfig( + platform=config.option.platform, + type=config.option.device_type, + serial=config.option.device_serial, + baud=config.option.device_serial_baud, + runner=config.option.runner, + id=config.option.device_id, + product=config.option.device_product, + serial_pty=config.option.device_serial_pty, + west_flash_extra_args=west_flash_extra_args, + flashing_timeout=config.option.flashing_timeout, + build_dir=config.option.build_dir, + binary_file=config.option.binary_file, + ) + + devices.append(device_from_cli) + + return cls( + output_dir=output_dir, + devices=devices + ) diff --git a/scripts/pylib/pytest-device-adapter/src/device_adapter/exceptions.py b/scripts/pylib/pytest-device-adapter/src/device_adapter/exceptions.py new file mode 100644 index 0000000000000..41e4824d11a9d --- /dev/null +++ b/scripts/pylib/pytest-device-adapter/src/device_adapter/exceptions.py @@ -0,0 +1,2 @@ +class DeviceAdapterException(Exception): + """General device adapter exception.""" diff --git a/scripts/pylib/pytest-device-adapter/src/device_adapter/fixtures/__init__.py b/scripts/pylib/pytest-device-adapter/src/device_adapter/fixtures/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/scripts/pylib/pytest-device-adapter/src/device_adapter/fixtures/dut.py b/scripts/pylib/pytest-device-adapter/src/device_adapter/fixtures/dut.py new file mode 100644 index 0000000000000..a83df46c2b518 --- /dev/null +++ b/scripts/pylib/pytest-device-adapter/src/device_adapter/fixtures/dut.py @@ -0,0 +1,34 @@ +import logging +from typing import Generator, Type + +import pytest + +from device_adapter.device.device_abstract import DeviceAbstract +from device_adapter.device.factory import DeviceFactory +from device_adapter.device_adapter_config import DeviceAdapterConfig, DeviceConfig + +logger = logging.getLogger(__name__) + + +@pytest.fixture(scope='function') +def dut(request: pytest.FixtureRequest) -> Generator[DeviceAbstract, None, None]: + """Return device instance.""" + device_adapter_config: DeviceAdapterConfig = request.config.device_adapter_config # type: ignore + device_config: DeviceConfig = device_adapter_config.devices[0] + device_type = device_config.type + + device_class: Type[DeviceAbstract] = DeviceFactory.get_device(device_type) + + device = device_class(device_config) + + try: + device.connect() + device.generate_command() + device.flash_and_run() + device.connect() + yield device + except KeyboardInterrupt: + pass + finally: # to make sure we close all running processes after user broke execution + device.disconnect() + device.stop() diff --git a/scripts/pylib/pytest-device-adapter/src/device_adapter/helper.py b/scripts/pylib/pytest-device-adapter/src/device_adapter/helper.py new file mode 100644 index 0000000000000..9e34b1ae0f864 --- /dev/null +++ b/scripts/pylib/pytest-device-adapter/src/device_adapter/helper.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import logging +import os.path +import platform +import shlex +from pathlib import Path + +import yaml.parser + +from device_adapter.exceptions import DeviceAdapterException + +_WINDOWS = (platform.system() == 'Windows') + +logger = logging.getLogger(__name__) + + +def string_to_set(value: str | set) -> set[str]: + if isinstance(value, str): + return set(value.split()) + else: + return value + + +def string_to_list(value: str | list) -> list[str]: + if isinstance(value, str): + return list(value.split()) + else: + return value + + +def safe_load_yaml(filename: Path) -> dict: + """ + Return data from yaml file. + + :param filename: path to yaml file + :return: data read from yaml file + """ + __tracebackhide__ = True + with filename.open(encoding='UTF-8') as file: + try: + data = yaml.safe_load(file) + except yaml.parser.ParserError as exc: + logger.error('Parsing error for yaml file %s: %s', filename, exc) + raise DeviceAdapterException(f'Cannot load data from yaml file: {filename}') + else: + return data + + +def log_command(logger: logging.Logger, msg: str, args: list, level: int = logging.DEBUG): + """ + Platform-independent helper for logging subprocess invocations. + + Will log a command string that can be copy/pasted into a POSIX + shell on POSIX platforms. This is not available on Windows, so + the entire args array is logged instead. + + :param logger: logging.Logger to use + :param msg: message to associate with the command + :param args: argument list as passed to subprocess module + :param level: log level + """ + msg = f'{msg}: %s' + if _WINDOWS: + logger.log(level, msg, str(args)) + else: + logger.log(level, msg, shlex.join(args)) + + +def normalize_filename(filename: str) -> str: + filename = os.path.expanduser(os.path.expandvars(filename)) + filename = os.path.normpath(os.path.abspath(filename)) + return filename diff --git a/scripts/pylib/pytest-device-adapter/src/device_adapter/log.py b/scripts/pylib/pytest-device-adapter/src/device_adapter/log.py new file mode 100644 index 0000000000000..476a96bfdb787 --- /dev/null +++ b/scripts/pylib/pytest-device-adapter/src/device_adapter/log.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import logging.config +import os + +import pytest + + +def configure_logging(config: pytest.Config) -> None: + """Configure logging.""" + output_dir = config.option.output_dir + os.makedirs(output_dir, exist_ok=True) + log_file = os.path.join(output_dir, 'device_adapter.log') + + if hasattr(config, 'workerinput'): + worker_id = config.workerinput['workerid'] + log_file = os.path.join(output_dir, f'device_adapter_{worker_id}.log') + + log_format = '%(asctime)s:%(levelname)s:%(name)s: %(message)s' + log_level = config.getoption('--log-level') or config.getini('log_level') or logging.INFO + log_file = config.getoption('--log-file') or config.getini('log_file') or log_file + log_format = config.getini('log_cli_format') or log_format + + default_config = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'standard': { + 'format': log_format, + }, + 'simply': { + 'format': '%(message)s' + } + }, + 'handlers': { + 'file': { + 'class': 'logging.FileHandler', + 'level': 'DEBUG', + 'formatter': 'standard', + 'filters': [], + 'filename': log_file, + 'encoding': 'utf8', + 'mode': 'w' + }, + 'console': { + 'class': 'logging.StreamHandler', + 'level': 'DEBUG', + 'formatter': 'standard', + 'filters': [], + } + }, + 'loggers': { + '': { + 'handlers': ['console', 'file'], + 'level': 'WARNING', + 'propagate': False + }, + 'device_adapter': { + 'handlers': ['console', 'file'], + 'level': log_level, + 'propagate': False, + } + } + } + + logging.config.dictConfig(default_config) diff --git a/scripts/pylib/pytest-device-adapter/src/device_adapter/plugin.py b/scripts/pylib/pytest-device-adapter/src/device_adapter/plugin.py new file mode 100644 index 0000000000000..2028562f25f00 --- /dev/null +++ b/scripts/pylib/pytest-device-adapter/src/device_adapter/plugin.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import logging +import os +from pathlib import Path + +import pytest + +from device_adapter.device_adapter_config import DeviceAdapterConfig +from device_adapter.log import configure_logging + +logger = logging.getLogger(__name__) + +pytest_plugins = ( + 'device_adapter.fixtures.dut' +) + + +def pytest_addoption(parser: pytest.Parser): + device_adapter_group = parser.getgroup('Device adapter') + device_adapter_group.addoption( + '--device-adapter', + action='store_true', + default=False, + help='Activate Device adapter plugin' + ) + parser.addini( + 'device_adapter', + 'Activate Device adapter plugin', + type='bool' + ) + device_adapter_group.addoption( + '-O', + '--outdir', + metavar='PATH', + dest='output_dir', + help='Output directory for logs. If not provided then use ' + '--build-dir path as default.' + ) + device_adapter_group.addoption( + '--platform', + help='Choose specific platform' + ) + device_adapter_group.addoption( + '--device-type', + help='Choose type of device (hardware, qemu, etc.)' + ) + device_adapter_group.addoption( + '--device-serial', + help='Serial device for accessing the board ' + '(e.g., /dev/ttyACM0)' + ) + device_adapter_group.addoption( + '--device-serial-baud', + type=int, + default=115200, + help='Serial device baud rate (default 115200)' + ) + device_adapter_group.addoption( + '--runner', + help='use the specified west runner (pyocd, nrfjprog, etc)' + ) + device_adapter_group.addoption( + '--device-id', + help='ID of connected hardware device (for example 000682459367)' + ) + device_adapter_group.addoption( + '--device-product', + help='Product name of connected hardware device (for example "STM32 STLink")' + ) + device_adapter_group.addoption( + '--device-serial-pty', + metavar='PATH', + help='Script for controlling pseudoterminal. ' + 'E.g --device-testing --device-serial-pty=