Skip to content

Commit 0dddba5

Browse files
committed
twister: Add configuration reader to pytest-twister-harness
Added a class that helps to read Kconfigs from a configuration file. Signed-off-by: Lukasz Fundakowski <[email protected]>
1 parent 6692544 commit 0dddba5

File tree

3 files changed

+209
-1
lines changed

3 files changed

+209
-1
lines changed

scripts/pylib/pytest-twister-harness/src/twister_harness/fixtures.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import logging
66
from pathlib import Path
7-
from typing import Generator, Type
7+
from typing import Generator, Type, Callable
88

99
import pytest
1010
import time
@@ -15,6 +15,7 @@
1515
from twister_harness.helpers.shell import Shell
1616
from twister_harness.helpers.mcumgr import MCUmgr, MCUmgrBle
1717
from twister_harness.helpers.utils import find_in_config
18+
from twister_harness.helpers.config_reader import ConfigReader
1819

1920
logger = logging.getLogger(__name__)
2021

@@ -117,3 +118,27 @@ def mcumgr_ble(device_object: DeviceAdapter) -> Generator[MCUmgrBle, None, None]
117118
) or 'Zephyr'
118119

119120
yield MCUmgrBle.create_for_ble(hci_index, peer_name)
121+
122+
123+
@pytest.fixture
124+
def config_reader() -> Callable[[str | Path], ConfigReader]:
125+
"""
126+
Pytest fixture that provides a ConfigReader instance for reading configuration files.
127+
128+
This fixture allows tests to easily create a ConfigReader object by passing
129+
the path to a configuration file. The ConfigReader reads the file and
130+
provides a method to access the configuration data.
131+
132+
Returns:
133+
Callable[[str, Path], ConfigReader]: A function that takes a file path
134+
(as a string or Path object) and returns an instance of ConfigReader.
135+
136+
Example:
137+
def test_config_value(config_reader):
138+
config = config_reader("pm.config")
139+
assert config.read("some_key") == "expected_value"
140+
"""
141+
def inner(file):
142+
return ConfigReader(file)
143+
144+
return inner
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# Copyright (c) 2025 Nordic Semiconductor ASA
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
"""Implementation for a configuration file reader."""
6+
7+
import logging
8+
import os
9+
import re
10+
from pathlib import Path
11+
from typing import Any
12+
13+
__tracebackhide__ = True
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
class ConfigReader:
19+
"""Reads configuration from a config file."""
20+
21+
def __init__(self, config_file: Path | str) -> None:
22+
"""Initialize.
23+
24+
:param config_file: path to a configuration file
25+
"""
26+
assert os.path.exists(config_file), f"Path does not exist: {config_file}"
27+
assert os.path.isfile(config_file), f"It is not a file: {config_file}"
28+
self.config_file = config_file
29+
self.config: dict[str, str] = {}
30+
self.parse()
31+
32+
def parse(self) -> dict[str, str]:
33+
"""Parse a config file."""
34+
pattern = re.compile(r"^(?P<key>.+)=(?P<value>.*)$")
35+
with open(self.config_file) as file:
36+
for line in file:
37+
if match := pattern.match(line):
38+
key, value = match.group("key"), match.group("value")
39+
self.config[key] = value.strip("\"'")
40+
return self.config
41+
42+
def read(self, config_key: str, default: Any = None, *, silent=False) -> str | None:
43+
"""Find key in config file.
44+
45+
:param config_key: key to read
46+
:param default: default value to return if key not found
47+
:param silent: do not raise an exception when key not found
48+
:raises ValueError: if key not found
49+
"""
50+
try:
51+
value = self.config[config_key]
52+
except KeyError:
53+
if default is not None:
54+
return default
55+
logger.debug("Not found key: %s", config_key)
56+
if silent:
57+
return None
58+
raise ValueError(f"Could not find key: {config_key}") from None
59+
logger.debug("Found matching key: %s=%s", config_key, value)
60+
return value
61+
62+
def read_int(self, config_key: str, default: int | None = None) -> int:
63+
"""Find key in config file and return int.
64+
65+
:param config_key: key to read
66+
:param default: default value to return if key not found
67+
"""
68+
if default is not None and not isinstance(default, int):
69+
raise TypeError(f"default value must be type of int, but was {type(default)}")
70+
if default is not None:
71+
default = hex(default) # type: ignore
72+
if value := self.read(config_key, default):
73+
return int(value, 16)
74+
raise Exception("Non reachable code") # pragma: no cover
75+
76+
def read_bool(self, config_key: str, default: bool | None = None) -> bool:
77+
"""Find key in config file and return bool.
78+
79+
:param config_key: key to read
80+
:param default: default value to return if key not found
81+
"""
82+
value = self.read(config_key, default)
83+
if isinstance(value, str):
84+
if value.lower() == "y":
85+
return True
86+
if value.lower() == "n":
87+
return False
88+
return bool(value)
89+
90+
def read_hex(self, config_key: str, default: int | None = None) -> str:
91+
"""Find key in config file and return hex.
92+
93+
:param config_key: key to read
94+
:param default: default value to return if key not found
95+
:return: hex value as string
96+
"""
97+
if default is not None and not isinstance(default, int):
98+
raise TypeError(f"default value must be type of int, but was {type(default)}")
99+
value = self.read_int(config_key, default)
100+
return hex(value)
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Copyright (c) 2025 Nordic Semiconductor ASA
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
import textwrap
6+
7+
import pytest
8+
from twister_harness.helpers.config_reader import ConfigReader
9+
10+
CONFIG: str = textwrap.dedent("""
11+
# comment
12+
X_PM_MCUBOOT_OFFSET=0x0
13+
X_PM_MCUBOOT_END_ADDRESS=0xd800
14+
15+
X_PM_MCUBOOT_NAME=mcuboot
16+
X_PM_MCUBOOT_ID=0
17+
X_CONFIG_BOOL_TRUE=y
18+
X_CONFIG_BOOL_EMPTY_FALSE=
19+
X_CONFIG_BOOL_FALSE=n
20+
""")
21+
22+
23+
@pytest.fixture
24+
def config_reader(tmp_path) -> ConfigReader:
25+
config_file = tmp_path / "config"
26+
config_file.write_text(CONFIG)
27+
reader = ConfigReader(config_file)
28+
return reader
29+
30+
31+
def test_if_raises_exception_path_is_directory(tmp_path):
32+
build_dir = tmp_path / "build"
33+
build_dir.mkdir()
34+
with pytest.raises(AssertionError, match=f"It is not a file: {build_dir}"):
35+
ConfigReader(build_dir)
36+
37+
38+
def test_if_raises_exception_when_path_does_not_exist(tmp_path):
39+
build_dir = tmp_path / "build"
40+
build_dir.mkdir()
41+
config_file = build_dir / "file_does_not_exist"
42+
with pytest.raises(AssertionError, match=f"Path does not exist: {config_file}"):
43+
ConfigReader(config_file)
44+
45+
46+
def test_if_can_read_values_from_config_file(config_reader):
47+
assert config_reader.config, "Config is empty"
48+
assert config_reader.read("X_PM_MCUBOOT_NAME") == "mcuboot"
49+
assert config_reader.read("X_CONFIG_BOOL_TRUE") == "y"
50+
assert config_reader.read_bool("X_CONFIG_BOOL_TRUE") is True
51+
assert config_reader.read_bool("X_CONFIG_BOOL_FALSE") is False
52+
assert config_reader.read_bool("X_CONFIG_BOOL_EMPTY_FALSE") is False
53+
assert config_reader.read_hex("X_PM_MCUBOOT_END_ADDRESS") == "0xd800"
54+
assert config_reader.read_int("X_PM_MCUBOOT_END_ADDRESS") == 0xd800
55+
56+
57+
def test_if_raises_value_error_when_key_does_not_exist(config_reader):
58+
with pytest.raises(ValueError, match="Could not find key: DO_NOT_EXIST"):
59+
config_reader.read("DO_NOT_EXIST")
60+
61+
with pytest.raises(ValueError, match="Could not find key: DO_NOT_EXIST"):
62+
config_reader.read_int("DO_NOT_EXIST")
63+
64+
65+
def test_if_raises_value_error_when_default_value_is_not_proper(config_reader):
66+
with pytest.raises(TypeError, match="default value must be type of int, but was .*"):
67+
config_reader.read_hex("X_PM_MCUBOOT_OFFSET", "0x10")
68+
with pytest.raises(TypeError, match="default value must be type of int, but was .*"):
69+
config_reader.read_int("X_PM_MCUBOOT_OFFSET", "0x10")
70+
71+
72+
def test_if_returns_default_value_when_key_does_not_exist(config_reader):
73+
assert config_reader.read("DO_NOT_EXIST", "default") == "default"
74+
assert config_reader.read_int("DO_NOT_EXIST", 10) == 10
75+
assert config_reader.read_hex("DO_NOT_EXIST", 0x20) == "0x20"
76+
assert config_reader.read_bool("DO_NOT_EXIST", True) is True
77+
assert config_reader.read_bool("DO_NOT_EXIST", False) is False
78+
assert config_reader.read_bool("DO_NOT_EXIST", 1) is True
79+
assert config_reader.read_bool("DO_NOT_EXIST", "1") is True
80+
81+
82+
def test_if_does_not_raise_exception_in_silent_mode_for_key_not_found(config_reader):
83+
assert config_reader.read("DO_NOT_EXIST", silent=True) is None

0 commit comments

Comments
 (0)