diff --git a/scripts/pylib/pytest-twister-harness/src/twister_harness/fixtures.py b/scripts/pylib/pytest-twister-harness/src/twister_harness/fixtures.py index d53d74b1be1d9..621b0defd71a1 100644 --- a/scripts/pylib/pytest-twister-harness/src/twister_harness/fixtures.py +++ b/scripts/pylib/pytest-twister-harness/src/twister_harness/fixtures.py @@ -4,7 +4,7 @@ import logging from pathlib import Path -from typing import Generator, Type +from typing import Generator, Type, Callable import pytest import time @@ -15,6 +15,7 @@ from twister_harness.helpers.shell import Shell from twister_harness.helpers.mcumgr import MCUmgr, MCUmgrBle from twister_harness.helpers.utils import find_in_config +from twister_harness.helpers.config_reader import ConfigReader logger = logging.getLogger(__name__) @@ -117,3 +118,27 @@ def mcumgr_ble(device_object: DeviceAdapter) -> Generator[MCUmgrBle, None, None] ) or 'Zephyr' yield MCUmgrBle.create_for_ble(hci_index, peer_name) + + +@pytest.fixture +def config_reader() -> Callable[[str | Path], ConfigReader]: + """ + Pytest fixture that provides a ConfigReader instance for reading configuration files. + + This fixture allows tests to easily create a ConfigReader object by passing + the path to a configuration file. The ConfigReader reads the file and + provides a method to access the configuration data. + + Returns: + Callable[[str, Path], ConfigReader]: A function that takes a file path + (as a string or Path object) and returns an instance of ConfigReader. + + Example: + def test_config_value(config_reader): + config = config_reader("build_dir/zephyr/.config") + assert config.read("some_key") == "expected_value" + """ + def inner(file): + return ConfigReader(file) + + return inner diff --git a/scripts/pylib/pytest-twister-harness/src/twister_harness/helpers/config_reader.py b/scripts/pylib/pytest-twister-harness/src/twister_harness/helpers/config_reader.py new file mode 100644 index 0000000000000..701a83982f0d1 --- /dev/null +++ b/scripts/pylib/pytest-twister-harness/src/twister_harness/helpers/config_reader.py @@ -0,0 +1,103 @@ +# Copyright (c) 2025 Nordic Semiconductor ASA +# +# SPDX-License-Identifier: Apache-2.0 + +"""Implementation for a configuration file reader.""" + +import logging +import os +import re +from pathlib import Path +from typing import Any + +__tracebackhide__ = True + +logger = logging.getLogger(__name__) + + +class ConfigReader: + """Reads configuration from a config file.""" + + def __init__(self, config_file: Path | str) -> None: + """Initialize. + + :param config_file: path to a configuration file + """ + assert os.path.exists(config_file), f"Path does not exist: {config_file}" + assert os.path.isfile(config_file), f"It is not a file: {config_file}" + self.config_file = config_file + self.config: dict[str, str] = {} + self.parse() + + def parse(self) -> dict[str, str]: + """Parse a config file.""" + pattern = re.compile(r"^(?P.+)=(?P.+)$") + with open(self.config_file) as file: + for line in file: + if match := pattern.match(line): + key, value = match.group("key"), match.group("value") + self.config[key] = value.strip("\"'") + return self.config + + def read(self, config_key: str, default: Any = None, *, silent=False) -> str | None: + """Find key in config file. + + :param config_key: key to read + :param default: default value to return if key not found + :param silent: do not raise an exception when key not found + :raises ValueError: if key not found + """ + try: + value = self.config[config_key] + except KeyError: + if default is not None: + return default + logger.debug("Not found key: %s", config_key) + if silent: + return None + raise ValueError(f"Could not find key: {config_key}") from None + logger.debug("Found matching key: %s=%s", config_key, value) + return value + + def read_int(self, config_key: str, default: int | None = None) -> int: + """Find key in config file and return int. + + :param config_key: key to read + :param default: default value to return if key not found + """ + if default is not None and not isinstance(default, int): + raise TypeError(f"default value must be type of int, but was {type(default)}") + if default is not None: + default = hex(default) # type: ignore + if value := self.read(config_key, default): + try: + return int(value) + except ValueError: + return int(value, 16) + raise Exception("Non reachable code") # pragma: no cover + + def read_bool(self, config_key: str, default: bool | None = None) -> bool: + """Find key in config file and return bool. + + :param config_key: key to read + :param default: default value to return if key not found + """ + value = self.read(config_key, default) + if isinstance(value, str): + if value.lower() == "y": + return True + if value.lower() == "n": + return False + return bool(value) + + def read_hex(self, config_key: str, default: int | None = None) -> str: + """Find key in config file and return hex. + + :param config_key: key to read + :param default: default value to return if key not found + :return: hex value as string + """ + if default is not None and not isinstance(default, int): + raise TypeError(f"default value must be type of int, but was {type(default)}") + value = self.read_int(config_key, default) + return hex(value) diff --git a/scripts/pylib/pytest-twister-harness/tests/helpers/test_config_reader.py b/scripts/pylib/pytest-twister-harness/tests/helpers/test_config_reader.py new file mode 100644 index 0000000000000..81046b77b5702 --- /dev/null +++ b/scripts/pylib/pytest-twister-harness/tests/helpers/test_config_reader.py @@ -0,0 +1,81 @@ +# Copyright (c) 2025 Nordic Semiconductor ASA +# +# SPDX-License-Identifier: Apache-2.0 + +import textwrap + +import pytest +from twister_harness.helpers.config_reader import ConfigReader + +CONFIG: str = textwrap.dedent(""" + # comment + X_PM_MCUBOOT_OFFSET=0x0 + X_PM_MCUBOOT_END_ADDRESS=0xd800 + + X_PM_MCUBOOT_NAME=mcuboot + X_PM_MCUBOOT_ID=0 + X_CONFIG_BOOL_TRUE=y + X_CONFIG_BOOL_FALSE=n +""") + + +@pytest.fixture +def config_reader(tmp_path) -> ConfigReader: + config_file = tmp_path / "config" + config_file.write_text(CONFIG) + reader = ConfigReader(config_file) + return reader + + +def test_if_raises_exception_path_is_directory(tmp_path): + build_dir = tmp_path / "build" + build_dir.mkdir() + with pytest.raises(AssertionError, match=f"It is not a file: {build_dir}"): + ConfigReader(build_dir) + + +def test_if_raises_exception_when_path_does_not_exist(tmp_path): + build_dir = tmp_path / "build" + build_dir.mkdir() + config_file = build_dir / "file_does_not_exist" + with pytest.raises(AssertionError, match=f"Path does not exist: {config_file}"): + ConfigReader(config_file) + + +def test_if_can_read_values_from_config_file(config_reader): + assert config_reader.config, "Config is empty" + assert config_reader.read("X_PM_MCUBOOT_NAME") == "mcuboot" + assert config_reader.read("X_CONFIG_BOOL_TRUE") == "y" + assert config_reader.read_bool("X_CONFIG_BOOL_TRUE") is True + assert config_reader.read_bool("X_CONFIG_BOOL_FALSE") is False + assert config_reader.read_hex("X_PM_MCUBOOT_END_ADDRESS") == "0xd800" + assert config_reader.read_int("X_PM_MCUBOOT_END_ADDRESS") == 0xD800 + + +def test_if_raises_value_error_when_key_does_not_exist(config_reader): + with pytest.raises(ValueError, match="Could not find key: DO_NOT_EXIST"): + config_reader.read("DO_NOT_EXIST") + + with pytest.raises(ValueError, match="Could not find key: DO_NOT_EXIST"): + config_reader.read_int("DO_NOT_EXIST") + + +def test_if_raises_value_error_when_default_value_is_not_proper(config_reader): + with pytest.raises(TypeError, match="default value must be type of int, but was .*"): + config_reader.read_hex("X_PM_MCUBOOT_OFFSET", "0x10") + with pytest.raises(TypeError, match="default value must be type of int, but was .*"): + config_reader.read_int("X_PM_MCUBOOT_OFFSET", "0x10") + + +def test_if_returns_default_value_when_key_does_not_exist(config_reader): + assert config_reader.read("DO_NOT_EXIST", "default") == "default" + assert config_reader.read_int("DO_NOT_EXIST", 10) == 10 + assert config_reader.read_hex("DO_NOT_EXIST", 0x20) == "0x20" + assert config_reader.read_bool("DO_NOT_EXIST", True) is True + assert config_reader.read_bool("DO_NOT_EXIST", False) is False + assert config_reader.read_bool("DO_NOT_EXIST", 1) is True + assert config_reader.read_bool("DO_NOT_EXIST", "1") is True + + +def test_if_does_not_raise_exception_in_silent_mode_for_key_not_found(config_reader): + assert config_reader.read("DO_NOT_EXIST", silent=True) is None