Skip to content

twister: Add configuration reader to pytest-twister-harness #94301

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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__)

Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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<key>.+)=(?P<value>.+)$")
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)
Original file line number Diff line number Diff line change
@@ -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
Loading