Skip to content

Commit 3e48705

Browse files
fundakoleivindj-nordic
authored andcommitted
tests: refactor pytest_plugins for twister tests
Splitted `cli_commands.py` module to multiple modules. Signed-off-by: Lukasz Fundakowski <lukasz.fundakowski@nordicsemi.no>
1 parent 14c6d29 commit 3e48705

8 files changed

Lines changed: 284 additions & 207 deletions

File tree

File renamed without changes.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Copyright (c) 2025 Nordic Semiconductor ASA
2+
#
3+
# SPDX-License-Identifier: LicenseRef-Nordic-5-Clause
4+
5+
"""Builder pattern implementation for building Zephyr/NCS projects.
6+
7+
This module provides a builder interface and implementation for building
8+
Nordic Semiconductor projects using the west build system. It includes
9+
support for parallel builds through the BuildDirector orchestrator.
10+
"""
11+
12+
import abc
13+
import concurrent.futures
14+
import logging
15+
import multiprocessing
16+
from collections.abc import Sequence
17+
from dataclasses import dataclass
18+
from pathlib import Path
19+
20+
from .common import duration
21+
from .west import west_build
22+
23+
MAX_WORKERS = max(multiprocessing.cpu_count() - 1, 1)
24+
25+
logger = logging.getLogger(__name__)
26+
27+
28+
class Builder(abc.ABC):
29+
"""Interface class for builders."""
30+
31+
@abc.abstractmethod
32+
def build(self) -> tuple[Path, Path]:
33+
"""Run build and return source and build paths as tuple."""
34+
35+
36+
@dataclass
37+
class WestBuilder(Builder):
38+
"""Build a sample using west."""
39+
40+
source_dir: Path
41+
build_dir: Path
42+
board: str
43+
testsuite: str | None = None
44+
extra_args: str | None = None
45+
timeout: int = 60
46+
47+
@duration
48+
def build(self) -> tuple[Path, Path]:
49+
"""Run west build for the required build configuration."""
50+
return west_build(
51+
source_dir=self.source_dir,
52+
board=self.board,
53+
build_dir=self.build_dir,
54+
timeout=self.timeout,
55+
testsuite=self.testsuite,
56+
extra_args=self.extra_args,
57+
)
58+
59+
60+
class BuildDirector:
61+
"""Orchestrator for builders to build in parallel."""
62+
63+
def __init__(self, builders: Sequence[Builder], max_workers: int = MAX_WORKERS) -> None:
64+
self.builders = builders
65+
self.max_workers = max_workers
66+
self.exceptions: list[Exception] = []
67+
68+
def run(self) -> None:
69+
with concurrent.futures.ThreadPoolExecutor(max_workers=self.max_workers) as executor:
70+
tasks = {executor.submit(builder.build) for builder in self.builders}
71+
for future in concurrent.futures.as_completed(tasks):
72+
try:
73+
_, build_dir = future.result()
74+
except Exception as exc:
75+
logger.error('Failed to build: %s', exc)
76+
self.exceptions.append(exc)
77+
else:
78+
logger.info("Build success: %s", build_dir)
79+
logger.info("Finished building samples")
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Copyright (c) 2025 Nordic Semiconductor ASA
2+
#
3+
# SPDX-License-Identifier: LicenseRef-Nordic-5-Clause
4+
5+
"""Common utility functions for adapter modules.
6+
7+
This module provides shared utility functions used across adapter modules.
8+
"""
9+
10+
import logging
11+
import os
12+
import shlex
13+
import subprocess
14+
import time
15+
from collections.abc import Callable
16+
from pathlib import Path
17+
from typing import Any
18+
19+
logger = logging.getLogger(__name__)
20+
21+
22+
def normalize_path(path: str) -> str:
23+
"""Normalize a path by expanding user and environment variables."""
24+
expanded = os.path.expanduser(os.path.expandvars(path))
25+
return str(Path(expanded).resolve())
26+
27+
28+
def duration(func: Callable) -> Any:
29+
"""Decorator to print duration of calling a function."""
30+
31+
def _inner(*args, **kwargs):
32+
start_t = time.monotonic()
33+
try:
34+
return func(*args, **kwargs)
35+
finally:
36+
logger.debug(
37+
"The `%s` function took %s seconds to execute",
38+
func.__name__,
39+
round(time.monotonic() - start_t, 3),
40+
)
41+
42+
return _inner
43+
44+
45+
def run_command(command: list[str], timeout: int = 30) -> None:
46+
"""Run command in subprocess."""
47+
logger.info(f"CMD: {shlex.join(command)}")
48+
ret: subprocess.CompletedProcess = subprocess.run(
49+
command,
50+
text=True,
51+
stdout=subprocess.PIPE,
52+
stderr=subprocess.STDOUT,
53+
timeout=timeout,
54+
)
55+
if ret.returncode:
56+
logger.error(f"Failed command: {shlex.join(command)}")
57+
logger.info(ret.stdout)
58+
raise subprocess.CalledProcessError(ret.returncode, command)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Copyright (c) 2025 Nordic Semiconductor ASA
2+
#
3+
# SPDX-License-Identifier: LicenseRef-Nordic-5-Clause
4+
5+
"""Adapter for nrfutil device management commands.
6+
7+
This module provides functions for interacting with Nordic Semiconductor
8+
devices using the nrfutil command-line tool.
9+
"""
10+
11+
import logging
12+
13+
from .common import run_command
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
def reset_board(dev_id: str | None = None) -> None:
19+
"""Reset device."""
20+
command = ["nrfutil", "device", "reset"]
21+
if dev_id:
22+
command.extend(["--serial-number", dev_id])
23+
run_command(command)
24+
25+
26+
def erase_board(dev_id: str | None) -> None:
27+
"""Run nrfutil device erase command."""
28+
command = ["nrfutil", "device", "erase"]
29+
if dev_id:
30+
command.extend(["--serial-number", dev_id])
31+
run_command(command)
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Copyright (c) 2025 Nordic Semiconductor ASA
2+
#
3+
# SPDX-License-Identifier: LicenseRef-Nordic-5-Clause
4+
5+
"""Adapter for west commands."""
6+
7+
import logging
8+
import shlex
9+
import shutil
10+
import subprocess
11+
from pathlib import Path
12+
from typing import Literal
13+
14+
from .common import normalize_path, run_command
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
class WestFlashException(Exception):
20+
"""Raised when west flash failed."""
21+
22+
23+
class WestBuildException(Exception):
24+
"""Raised when west build failed."""
25+
26+
27+
def provision_keys_for_kmu(
28+
keys: list[str] | list[Path] | str | Path,
29+
*,
30+
keyname: Literal["UROT_PUBKEY", "BL_PUBKEY", "APP_PUBKEY"] = "BL_PUBKEY",
31+
policy: Literal["revokable", "lock", "lock-last"] | None = None,
32+
dev_id: str | None = None,
33+
):
34+
"""Upload keys with west provision command."""
35+
logger.info("Provision keys using west command.")
36+
command = ["west", "ncs-provision", "upload", "--keyname", keyname]
37+
if policy:
38+
command += ["--policy", policy]
39+
if dev_id:
40+
command += ["--dev-id", dev_id]
41+
if isinstance(keys, list):
42+
list_of_keys = keys
43+
else:
44+
list_of_keys = [keys]
45+
for key in list_of_keys:
46+
key_path = Path(key) if not isinstance(key, Path) else key
47+
assert key_path.exists(), f"Key file does not exist: {key}"
48+
command += ["--key", normalize_path(str(key_path))]
49+
50+
run_command(command)
51+
logger.info("Keys provisioned successfully")
52+
53+
54+
def west_flash(
55+
build_dir: Path, snr: str | None, *, extra_args: str | None = None, timeout: int = 30
56+
) -> None:
57+
"""Run west flash."""
58+
command = ["west", "flash", "--skip-rebuild", "--build-dir", str(build_dir)]
59+
if snr:
60+
command += ["--dev-id", snr]
61+
if extra_args:
62+
command.extend(shlex.split(extra_args, posix=False))
63+
64+
try:
65+
run_command(command, timeout=timeout)
66+
except subprocess.CalledProcessError:
67+
raise WestFlashException("Failed to flash device") from None
68+
except subprocess.TimeoutExpired:
69+
logger.error("Timeout flashing device")
70+
raise WestFlashException("Timeout flashing device") from None
71+
72+
73+
def west_build(
74+
source_dir: Path,
75+
board: str,
76+
build_dir: Path,
77+
*,
78+
testsuite: str | None = None,
79+
extra_args: str | None = None,
80+
timeout: int = 60,
81+
) -> tuple[Path, Path]:
82+
"""Run west build."""
83+
command = [
84+
"west",
85+
"build",
86+
"-p",
87+
"-b",
88+
board,
89+
str(source_dir),
90+
"-d",
91+
str(build_dir),
92+
]
93+
if testsuite:
94+
command.extend(["-T", testsuite])
95+
if extra_args:
96+
command.extend(shlex.split(extra_args, posix=False))
97+
98+
try:
99+
run_command(command, timeout=timeout)
100+
except subprocess.CalledProcessError:
101+
# create empty file to indicate a build error, will be used
102+
# to avoid unnecessary builds in other tests
103+
Path(build_dir / "build.error").touch()
104+
raise WestBuildException("Failed to build required app") from None
105+
except subprocess.TimeoutExpired:
106+
logger.error("Timeout building required app")
107+
shutil.rmtree(build_dir)
108+
raise WestBuildException("Timeout building required app") from None
109+
110+
return source_dir, build_dir

0 commit comments

Comments
 (0)