Skip to content
Merged
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
5 changes: 4 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,12 @@ Then, run the test suite locally from the top level directory::
# Recommended in an active virtual environment
poe all

# Manually
# Manually (test the installed west version)
pytest

# Manually (test the local copy)
pytest -o pythonpath=src

The ``all`` target from ``poe`` runs multiple tasks sequentially. Run ``poe -h``
to get the list of configured tasks.
You can pass arguments to the task running ``poe``. This is especially useful
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ omit = [
[tool.coverage.report]
omit = [
"*/tmp/*",
"net-tools/scripts/test.py",
"subdir/Kconfiglib/scripts/test.py",
"*/net-tools/scripts/test.py",
"*/subdir/Kconfiglib/scripts/test.py",
]

[tool.coverage.paths]
Expand Down
7 changes: 7 additions & 0 deletions src/west/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@

import colorama

if __name__ == "__main__":
# Prepend the west src directory to sys.path so that running this script
# directly in a local tree always uses the according local 'west' modules
# instead of any installed modules.
src_dir = Path(__file__).resolve().parents[2]
sys.path.insert(0, os.fspath(src_dir))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment must explain what is the effect of this when people run west from a pip3 pipx/uv/uvx install or from their distro.

sys.path is security-sensitive, all use cases must be carefully considered.

You made this a separate commit. I love smaller commits and smaller PRs [*] but I wonder what happens when I try to use the previous, big refactoring without this commit?

[*] https://docs.zephyrproject.org/latest/contribute/contributor_expectations.html#defining-smaller-prs

Copy link
Copy Markdown
Contributor Author

@thorsten-klein thorsten-klein Oct 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no effect when people run west. As the comment says it has only an effect when running this script. I will adapt the comment little bit to point out that it only affects if running this script directly

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is apparently not enough, see fixup:


import west.configuration
from west import log
from west.app.config import Config
Expand Down
113 changes: 71 additions & 42 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# SPDX-License-Identifier: Apache-2.0

import contextlib
import io
import os
import platform
import shutil
Expand All @@ -14,6 +15,8 @@

import pytest

from west.app import main

GIT = shutil.which('git')

# Git capabilities are discovered at runtime in
Expand Down Expand Up @@ -366,48 +369,74 @@ def check_output(*args, **kwargs):
return out_bytes.decode(sys.getdefaultencoding())


def cmd(cmd, cwd=None, stderr=None, env=None):
# Run a west command in a directory (cwd defaults to os.getcwd()).
#
# This helper takes the command as a string.
#
# This helper relies on the test environment to ensure that the
# 'west' executable is a bootstrapper installed from the current
# west source code.
#
# stdout from cmd is captured and returned. The command is run in
# a python subprocess so that program-level setup and teardown
# happen fresh.

# If you have quoting issues: do NOT quote. It's not portable.
# Instead, pass `cmd` as a list.
cmd = ['west'] + (cmd.split() if isinstance(cmd, str) else cmd)

print('running:', cmd)
if env:
print('with non-default environment:')
for k in env:
if k not in os.environ or env[k] != os.environ[k]:
print(f'\t{k}={env[k]}')
for k in os.environ:
if k not in env:
print(f'\t{k}: deleted, was: {os.environ[k]}')
if cwd is not None:
cwd = os.fspath(cwd)
print(f'in {cwd}')
try:
return check_output(cmd, cwd=cwd, stderr=stderr, env=env)
except subprocess.CalledProcessError:
print('cmd: west:', shutil.which('west'), file=sys.stderr)
raise


def cmd_raises(cmd_str_or_list, expected_exception_type, cwd=None, env=None):
# Similar to 'cmd' but an expected exception is caught.
# Returns the output together with stderr data
with pytest.raises(expected_exception_type) as exc_info:
cmd(cmd_str_or_list, stderr=subprocess.STDOUT, cwd=cwd, env=env)
return exc_info.value.output.decode("utf-8")
def _cmd(cmd, cwd=None, env=None):
# Executes a west command by invoking the `main()` function with the
# provided command arguments.
# Parameters:
# cwd: The working directory in which to execute the command.
# env: A dictionary of extra environment variables to apply temporarily
# during execution.

# ensure that cmd is a list of strings
cmd = cmd.split() if isinstance(cmd, str) else cmd
cmd = [str(c) for c in cmd]

# run main()
with (
chdir(cwd or Path.cwd()),
update_env(env or {}),
):
try:
main.main(cmd)
except SystemExit as e:
if e.code:
raise e
except Exception as e:
print(f'Uncaught exception type {e}', file=sys.stderr)
raise e


def cmd(cmd: list | str, cwd=None, stderr: io.StringIO | None = None, env=None):
# Same as _cmd(), but it captures and returns combined stdout and stderr.
# Optionally stderr can be captured separately into given stderr.
# Note that this function does not capture any stdout or stderr from an
# internally invoked subprocess.
stdout_buf = io.StringIO()
stderr_buf = stderr or stdout_buf
with contextlib.redirect_stdout(stdout_buf), contextlib.redirect_stderr(stderr_buf):
_cmd(cmd, cwd, env)
return stdout_buf.getvalue()


def cmd_raises(cmd: list | str, expected_exception_type, stdout=None, cwd=None, env=None):
# Similar to '_cmd' but an expected exception is caught.
# The exception is returned together with stderr.
# Optionally stdout is captured into given stdout (io.StringIO)
stdout_buf = stdout or sys.stdout
stderr_buf = io.StringIO()
with (
contextlib.redirect_stdout(stdout_buf),
contextlib.redirect_stderr(stderr_buf),
pytest.raises(expected_exception_type) as exc_info,
):
_cmd(cmd, cwd=cwd, env=env)
return exc_info, stderr_buf.getvalue()


def cmd_subprocess(cmd: list | str, *args, **kwargs):
# This function behaves similarly to `cmd()`, but executes the command in a
# separate Python subprocess, capturing all stdout output.
# The captured stdout includes both Python-level output and the output of
# any subprocesses spawned internally. This makes the function particularly
# useful in test cases where the code under test launches subprocesses and
# the combined stdout needs to be verified.
# The main drawback is that it cannot be debugged within Python, so it
# should only be used sparingly in tests.
cmd = cmd if isinstance(cmd, list) else cmd.split()
cmd = [sys.executable, main.__file__] + cmd
print('running (subprocess):', cmd)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naive question sorry: isn't there some logging function around here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so. There are print statements at other places as well in conftest.py.

ret = check_output(cmd, *args, **kwargs)
return ret


def create_workspace(workspace_dir, and_git=True):
Expand Down
16 changes: 5 additions & 11 deletions tests/test_alias.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@
#
# SPDX-License-Identifier: Apache-2.0

import subprocess

import pytest
from conftest import cmd
from conftest import cmd, cmd_raises


@pytest.fixture(autouse=True)
Expand Down Expand Up @@ -50,10 +48,8 @@ def test_alias_infinite_recursion():
cmd('config alias.test2 test3')
cmd('config alias.test3 test1')

with pytest.raises(subprocess.CalledProcessError) as excinfo:
cmd('test1', stderr=subprocess.STDOUT)

assert 'unknown command "test1";' in str(excinfo.value.stdout)
exc, _ = cmd_raises('test1', SystemExit)
assert 'unknown command "test1";' in str(exc.value)


def test_alias_empty():
Expand All @@ -62,10 +58,8 @@ def test_alias_empty():
# help command shouldn't fail
cmd('help')

with pytest.raises(subprocess.CalledProcessError) as excinfo:
cmd('empty', stderr=subprocess.STDOUT)

assert 'empty alias "empty"' in str(excinfo.value.stdout)
exc, _ = cmd_raises('empty', SystemExit)
assert 'empty alias "empty"' in str(exc.value)


def test_alias_early_args():
Expand Down
29 changes: 14 additions & 15 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import configparser
import os
import pathlib
import subprocess
import textwrap
from typing import Any

Expand Down Expand Up @@ -351,13 +350,13 @@ def test_append():


def test_append_novalue():
err_msg = cmd_raises('config -a pytest.foo', subprocess.CalledProcessError)
_, err_msg = cmd_raises('config -a pytest.foo', SystemExit)
assert '-a requires both name and value' in err_msg


def test_append_notfound():
update_testcfg('pytest', 'key', 'val', configfile=LOCAL)
err_msg = cmd_raises('config -a pytest.foo bar', subprocess.CalledProcessError)
_, err_msg = cmd_raises('config -a pytest.foo bar', SystemExit)
assert 'option pytest.foo not found in the local configuration file' in err_msg


Expand Down Expand Up @@ -497,7 +496,7 @@ def test_delete_cmd_all():
assert cfg(f=ALL)['pytest']['key'] == 'local'
cmd('config -D pytest.key')
assert 'pytest' not in cfg(f=ALL)
with pytest.raises(subprocess.CalledProcessError):
with pytest.raises(SystemExit):
cmd('config -D pytest.key')


Expand All @@ -511,7 +510,7 @@ def test_delete_cmd_none():
assert cmd('config pytest.key').rstrip() == 'global'
cmd('config -d pytest.key')
assert cmd('config pytest.key').rstrip() == 'system'
with pytest.raises(subprocess.CalledProcessError):
with pytest.raises(SystemExit):
cmd('config -d pytest.key')


Expand All @@ -521,7 +520,7 @@ def test_delete_cmd_system():
cmd('config --global pytest.key global')
cmd('config --local pytest.key local')
cmd('config -d --system pytest.key')
with pytest.raises(subprocess.CalledProcessError):
with pytest.raises(SystemExit):
cmd('config --system pytest.key')
assert cmd('config --global pytest.key').rstrip() == 'global'
assert cmd('config --local pytest.key').rstrip() == 'local'
Expand All @@ -534,7 +533,7 @@ def test_delete_cmd_global():
cmd('config --local pytest.key local')
cmd('config -d --global pytest.key')
assert cmd('config --system pytest.key').rstrip() == 'system'
with pytest.raises(subprocess.CalledProcessError):
with pytest.raises(SystemExit):
cmd('config --global pytest.key')
assert cmd('config --local pytest.key').rstrip() == 'local'

Expand All @@ -547,17 +546,17 @@ def test_delete_cmd_local():
cmd('config -d --local pytest.key')
assert cmd('config --system pytest.key').rstrip() == 'system'
assert cmd('config --global pytest.key').rstrip() == 'global'
with pytest.raises(subprocess.CalledProcessError):
with pytest.raises(SystemExit):
cmd('config --local pytest.key')


def test_delete_cmd_error():
# Verify illegal combinations of flags error out.
err_msg = cmd_raises('config -l -d pytest.key', subprocess.CalledProcessError)
_, err_msg = cmd_raises('config -l -d pytest.key', SystemExit)
assert 'argument -d/--delete: not allowed with argument -l/--list' in err_msg
err_msg = cmd_raises('config -l -D pytest.key', subprocess.CalledProcessError)
_, err_msg = cmd_raises('config -l -D pytest.key', SystemExit)
assert 'argument -D/--delete-all: not allowed with argument -l/--list' in err_msg
err_msg = cmd_raises('config -d -D pytest.key', subprocess.CalledProcessError)
_, err_msg = cmd_raises('config -d -D pytest.key', SystemExit)
assert 'argument -D/--delete-all: not allowed with argument -d/--delete' in err_msg


Expand Down Expand Up @@ -591,27 +590,27 @@ def test_config_precedence():


def test_config_missing_key():
err_msg = cmd_raises('config pytest', subprocess.CalledProcessError)
_, err_msg = cmd_raises('config pytest', SystemExit)
assert 'invalid configuration option "pytest"; expected "section.key" format' in err_msg


def test_unset_config():
# Getting unset configuration options should raise an error.
# With verbose output, the exact missing option should be printed.
err_msg = cmd_raises('-v config pytest.missing', subprocess.CalledProcessError)
_, err_msg = cmd_raises('-v config pytest.missing', SystemExit)
assert 'pytest.missing is unset' in err_msg


def test_no_args():
err_msg = cmd_raises('config', subprocess.CalledProcessError)
_, err_msg = cmd_raises('config', SystemExit)
assert 'missing argument name' in err_msg


def test_list():
def sorted_list(other_args=''):
return list(sorted(cmd('config -l ' + other_args).splitlines()))

err_msg = cmd_raises('config -l pytest.foo', subprocess.CalledProcessError)
_, err_msg = cmd_raises('config -l pytest.foo', SystemExit)
assert '-l cannot be combined with name argument' in err_msg

assert cmd('config -l').strip() == ''
Expand Down
9 changes: 0 additions & 9 deletions tests/test_help.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
# Copyright (c) 2020, Nordic Semiconductor ASA

import itertools
import os
import sys

from conftest import cmd

Expand Down Expand Up @@ -33,13 +31,6 @@ def test_extension_help_and_dash_h(west_init_tmpdir):
ext2out = cmd('test-extension -h')

expected = EXTENSION_EXPECTED
if sys.platform == 'win32':
# Manage gratuitous incompatibilities:
#
# - multiline python strings are \n separated even on windows
# - the windows command help output gets an extra newline
expected = [os.linesep.join(case.splitlines()) + os.linesep for case in EXTENSION_EXPECTED]

assert ext1out == ext2out
assert ext1out in expected

Expand Down
43 changes: 38 additions & 5 deletions tests/test_main.py
Copy link
Copy Markdown
Collaborator

@marc-hb marc-hb Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please correct me: this 3rd commit and test addition is only to test the previous, sys.path commit? In other words, this is test code to test... code required for testing? Test coverage is great but this feels overkill... if the previous commit is really required for testing, then it's already going to be covered regularly, no? If not, then maybe that previous commit is not required that much.

Please correct me.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is just a test for the second commit.

Without the second commit, the cmd_subprocess does not use the correct local modules.

I am not sure if I see this as a test code. With this change also users can invoke west by running the main.py script directly. No need for them to install west or setting PYTHONPATH.

I will add this to the PR description.

Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import subprocess
import runpy
import sys
from pathlib import Path

import pytest
from conftest import cmd, cmd_subprocess

import west.version

Expand All @@ -11,7 +15,36 @@ def test_main():
# sane (i.e. the actual version number is printed instead of
# simply an error message to stderr).

output_as_module = subprocess.check_output([sys.executable, '-m', 'west', '--version']).decode()
output_directly = subprocess.check_output(['west', '--version']).decode()
assert west.version.__version__ in output_as_module
assert output_as_module == output_directly
expected_version = west.version.__version__

# call west executable directly
output_directly = cmd(['--version'])
assert expected_version in output_directly

output_subprocess = cmd_subprocess('--version')
assert expected_version in output_subprocess

# output must be same in both cases
assert output_subprocess.rstrip() == output_directly.rstrip()


def test_module_run(tmp_path, monkeypatch):
actual_path = ['initial-path']

# mock sys.argv and sys.path
monkeypatch.setattr(sys, 'path', actual_path)
monkeypatch.setattr(sys, 'argv', ['west', '--version'])

# ensure that west.app.main is freshly loaded
sys.modules.pop('west.app.main', None)

# run west.app.main as module
with pytest.raises(SystemExit) as exit_info:
runpy.run_module('west.app.main', run_name='__main__')

# check that exit code is 0
assert exit_info.value.code == 0

# check that that the sys.path was correctly inserted
expected_path = Path(__file__).parents[1] / 'src'
assert actual_path == [f'{expected_path}', 'initial-path']
Loading