Skip to content

Commit 5aac5d7

Browse files
thorsten-kleinpdgendt
authored andcommitted
Remove subprocess usage from tests
The previous test setup used subprocess calls, which did not allow to debug the tests. It also caused a mix of Python modules from the installed West package and the local source tree when running tests directly with `pytest`. The tests have been updated so that `pytest` can now be run directly and fully debugged. `pytest` can be run as follows: - `pytest` — runs tests against the installed package - `pytest -o pythonpath=src` — runs tests against the local source tree Within the tests following methods can be used to run west commands: - cmd(...): call main() with given west command and capture std (and optionally stderr) - cmd_raises(...): call main() with given west command and catch expected exception. The exception and stderr are returned by default. Optionally stdout can be captured. - cmd_subprocess(...): Run west command in a subprocess and capture stdout.
1 parent 50cbc34 commit 5aac5d7

File tree

8 files changed

+182
-134
lines changed

8 files changed

+182
-134
lines changed

README.rst

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,9 +156,12 @@ Then, run the test suite locally from the top level directory::
156156
# Recommended in an active virtual environment
157157
poe all
158158

159-
# Manually
159+
# Manually (test the installed west version)
160160
pytest
161161

162+
# Manually (test the local copy)
163+
pytest -o pythonpath=src
164+
162165
The ``all`` target from ``poe`` runs multiple tasks sequentially. Run ``poe -h``
163166
to get the list of configured tasks.
164167
You can pass arguments to the task running ``poe``. This is especially useful

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,8 @@ omit = [
7676
[tool.coverage.report]
7777
omit = [
7878
"*/tmp/*",
79-
"net-tools/scripts/test.py",
80-
"subdir/Kconfiglib/scripts/test.py",
79+
"*/net-tools/scripts/test.py",
80+
"*/subdir/Kconfiglib/scripts/test.py",
8181
]
8282

8383
[tool.coverage.paths]

tests/conftest.py

Lines changed: 71 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# SPDX-License-Identifier: Apache-2.0
44

55
import contextlib
6+
import io
67
import os
78
import platform
89
import shutil
@@ -14,6 +15,8 @@
1415

1516
import pytest
1617

18+
from west.app import main
19+
1720
GIT = shutil.which('git')
1821

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

368371

369-
def cmd(cmd, cwd=None, stderr=None, env=None):
370-
# Run a west command in a directory (cwd defaults to os.getcwd()).
371-
#
372-
# This helper takes the command as a string.
373-
#
374-
# This helper relies on the test environment to ensure that the
375-
# 'west' executable is a bootstrapper installed from the current
376-
# west source code.
377-
#
378-
# stdout from cmd is captured and returned. The command is run in
379-
# a python subprocess so that program-level setup and teardown
380-
# happen fresh.
381-
382-
# If you have quoting issues: do NOT quote. It's not portable.
383-
# Instead, pass `cmd` as a list.
384-
cmd = ['west'] + (cmd.split() if isinstance(cmd, str) else cmd)
385-
386-
print('running:', cmd)
387-
if env:
388-
print('with non-default environment:')
389-
for k in env:
390-
if k not in os.environ or env[k] != os.environ[k]:
391-
print(f'\t{k}={env[k]}')
392-
for k in os.environ:
393-
if k not in env:
394-
print(f'\t{k}: deleted, was: {os.environ[k]}')
395-
if cwd is not None:
396-
cwd = os.fspath(cwd)
397-
print(f'in {cwd}')
398-
try:
399-
return check_output(cmd, cwd=cwd, stderr=stderr, env=env)
400-
except subprocess.CalledProcessError:
401-
print('cmd: west:', shutil.which('west'), file=sys.stderr)
402-
raise
403-
404-
405-
def cmd_raises(cmd_str_or_list, expected_exception_type, cwd=None, env=None):
406-
# Similar to 'cmd' but an expected exception is caught.
407-
# Returns the output together with stderr data
408-
with pytest.raises(expected_exception_type) as exc_info:
409-
cmd(cmd_str_or_list, stderr=subprocess.STDOUT, cwd=cwd, env=env)
410-
return exc_info.value.output.decode("utf-8")
372+
def _cmd(cmd, cwd=None, env=None):
373+
# Executes a west command by invoking the `main()` function with the
374+
# provided command arguments.
375+
# Parameters:
376+
# cwd: The working directory in which to execute the command.
377+
# env: A dictionary of extra environment variables to apply temporarily
378+
# during execution.
379+
380+
# ensure that cmd is a list of strings
381+
cmd = cmd.split() if isinstance(cmd, str) else cmd
382+
cmd = [str(c) for c in cmd]
383+
384+
# run main()
385+
with (
386+
chdir(cwd or Path.cwd()),
387+
update_env(env or {}),
388+
):
389+
try:
390+
main.main(cmd)
391+
except SystemExit as e:
392+
if e.code:
393+
raise e
394+
except Exception as e:
395+
print(f'Uncaught exception type {e}', file=sys.stderr)
396+
raise e
397+
398+
399+
def cmd(cmd: list | str, cwd=None, stderr: io.StringIO | None = None, env=None):
400+
# Same as _cmd(), but it captures and returns combined stdout and stderr.
401+
# Optionally stderr can be captured separately into given stderr.
402+
# Note that this function does not capture any stdout or stderr from an
403+
# internally invoked subprocess.
404+
stdout_buf = io.StringIO()
405+
stderr_buf = stderr or stdout_buf
406+
with contextlib.redirect_stdout(stdout_buf), contextlib.redirect_stderr(stderr_buf):
407+
_cmd(cmd, cwd, env)
408+
return stdout_buf.getvalue()
409+
410+
411+
def cmd_raises(cmd: list | str, expected_exception_type, stdout=None, cwd=None, env=None):
412+
# Similar to '_cmd' but an expected exception is caught.
413+
# The exception is returned together with stderr.
414+
# Optionally stdout is captured into given stdout (io.StringIO)
415+
stdout_buf = stdout or sys.stdout
416+
stderr_buf = io.StringIO()
417+
with (
418+
contextlib.redirect_stdout(stdout_buf),
419+
contextlib.redirect_stderr(stderr_buf),
420+
pytest.raises(expected_exception_type) as exc_info,
421+
):
422+
_cmd(cmd, cwd=cwd, env=env)
423+
return exc_info, stderr_buf.getvalue()
424+
425+
426+
def cmd_subprocess(cmd: list | str, *args, **kwargs):
427+
# This function behaves similarly to `cmd()`, but executes the command in a
428+
# separate Python subprocess, capturing all stdout output.
429+
# The captured stdout includes both Python-level output and the output of
430+
# any subprocesses spawned internally. This makes the function particularly
431+
# useful in test cases where the code under test launches subprocesses and
432+
# the combined stdout needs to be verified.
433+
# The main drawback is that it cannot be debugged within Python, so it
434+
# should only be used sparingly in tests.
435+
cmd = cmd if isinstance(cmd, list) else cmd.split()
436+
cmd = [sys.executable, main.__file__] + cmd
437+
print('running (subprocess):', cmd)
438+
ret = check_output(cmd, *args, **kwargs)
439+
return ret
411440

412441

413442
def create_workspace(workspace_dir, and_git=True):

tests/test_alias.py

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,8 @@
22
#
33
# SPDX-License-Identifier: Apache-2.0
44

5-
import subprocess
6-
75
import pytest
8-
from conftest import cmd
6+
from conftest import cmd, cmd_raises
97

108

119
@pytest.fixture(autouse=True)
@@ -50,10 +48,8 @@ def test_alias_infinite_recursion():
5048
cmd('config alias.test2 test3')
5149
cmd('config alias.test3 test1')
5250

53-
with pytest.raises(subprocess.CalledProcessError) as excinfo:
54-
cmd('test1', stderr=subprocess.STDOUT)
55-
56-
assert 'unknown command "test1";' in str(excinfo.value.stdout)
51+
exc, _ = cmd_raises('test1', SystemExit)
52+
assert 'unknown command "test1";' in str(exc.value)
5753

5854

5955
def test_alias_empty():
@@ -62,10 +58,8 @@ def test_alias_empty():
6258
# help command shouldn't fail
6359
cmd('help')
6460

65-
with pytest.raises(subprocess.CalledProcessError) as excinfo:
66-
cmd('empty', stderr=subprocess.STDOUT)
67-
68-
assert 'empty alias "empty"' in str(excinfo.value.stdout)
61+
exc, _ = cmd_raises('empty', SystemExit)
62+
assert 'empty alias "empty"' in str(exc.value)
6963

7064

7165
def test_alias_early_args():

tests/test_config.py

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import configparser
66
import os
77
import pathlib
8-
import subprocess
98
import textwrap
109
from typing import Any
1110

@@ -351,13 +350,13 @@ def test_append():
351350

352351

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

357356

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

363362

@@ -497,7 +496,7 @@ def test_delete_cmd_all():
497496
assert cfg(f=ALL)['pytest']['key'] == 'local'
498497
cmd('config -D pytest.key')
499498
assert 'pytest' not in cfg(f=ALL)
500-
with pytest.raises(subprocess.CalledProcessError):
499+
with pytest.raises(SystemExit):
501500
cmd('config -D pytest.key')
502501

503502

@@ -511,7 +510,7 @@ def test_delete_cmd_none():
511510
assert cmd('config pytest.key').rstrip() == 'global'
512511
cmd('config -d pytest.key')
513512
assert cmd('config pytest.key').rstrip() == 'system'
514-
with pytest.raises(subprocess.CalledProcessError):
513+
with pytest.raises(SystemExit):
515514
cmd('config -d pytest.key')
516515

517516

@@ -521,7 +520,7 @@ def test_delete_cmd_system():
521520
cmd('config --global pytest.key global')
522521
cmd('config --local pytest.key local')
523522
cmd('config -d --system pytest.key')
524-
with pytest.raises(subprocess.CalledProcessError):
523+
with pytest.raises(SystemExit):
525524
cmd('config --system pytest.key')
526525
assert cmd('config --global pytest.key').rstrip() == 'global'
527526
assert cmd('config --local pytest.key').rstrip() == 'local'
@@ -534,7 +533,7 @@ def test_delete_cmd_global():
534533
cmd('config --local pytest.key local')
535534
cmd('config -d --global pytest.key')
536535
assert cmd('config --system pytest.key').rstrip() == 'system'
537-
with pytest.raises(subprocess.CalledProcessError):
536+
with pytest.raises(SystemExit):
538537
cmd('config --global pytest.key')
539538
assert cmd('config --local pytest.key').rstrip() == 'local'
540539

@@ -547,17 +546,17 @@ def test_delete_cmd_local():
547546
cmd('config -d --local pytest.key')
548547
assert cmd('config --system pytest.key').rstrip() == 'system'
549548
assert cmd('config --global pytest.key').rstrip() == 'global'
550-
with pytest.raises(subprocess.CalledProcessError):
549+
with pytest.raises(SystemExit):
551550
cmd('config --local pytest.key')
552551

553552

554553
def test_delete_cmd_error():
555554
# Verify illegal combinations of flags error out.
556-
err_msg = cmd_raises('config -l -d pytest.key', subprocess.CalledProcessError)
555+
_, err_msg = cmd_raises('config -l -d pytest.key', SystemExit)
557556
assert 'argument -d/--delete: not allowed with argument -l/--list' in err_msg
558-
err_msg = cmd_raises('config -l -D pytest.key', subprocess.CalledProcessError)
557+
_, err_msg = cmd_raises('config -l -D pytest.key', SystemExit)
559558
assert 'argument -D/--delete-all: not allowed with argument -l/--list' in err_msg
560-
err_msg = cmd_raises('config -d -D pytest.key', subprocess.CalledProcessError)
559+
_, err_msg = cmd_raises('config -d -D pytest.key', SystemExit)
561560
assert 'argument -D/--delete-all: not allowed with argument -d/--delete' in err_msg
562561

563562

@@ -591,27 +590,27 @@ def test_config_precedence():
591590

592591

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

597596

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

604603

605604
def test_no_args():
606-
err_msg = cmd_raises('config', subprocess.CalledProcessError)
605+
_, err_msg = cmd_raises('config', SystemExit)
607606
assert 'missing argument name' in err_msg
608607

609608

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

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

617616
assert cmd('config -l').strip() == ''

tests/test_help.py

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
# Copyright (c) 2020, Nordic Semiconductor ASA
22

33
import itertools
4-
import os
5-
import sys
64

75
from conftest import cmd
86

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

3533
expected = EXTENSION_EXPECTED
36-
if sys.platform == 'win32':
37-
# Manage gratuitous incompatibilities:
38-
#
39-
# - multiline python strings are \n separated even on windows
40-
# - the windows command help output gets an extra newline
41-
expected = [os.linesep.join(case.splitlines()) + os.linesep for case in EXTENSION_EXPECTED]
42-
4334
assert ext1out == ext2out
4435
assert ext1out in expected
4536

tests/test_main.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import subprocess
2-
import sys
1+
from conftest import cmd, cmd_subprocess
32

43
import west.version
54

@@ -11,7 +10,14 @@ def test_main():
1110
# sane (i.e. the actual version number is printed instead of
1211
# simply an error message to stderr).
1312

14-
output_as_module = subprocess.check_output([sys.executable, '-m', 'west', '--version']).decode()
15-
output_directly = subprocess.check_output(['west', '--version']).decode()
16-
assert west.version.__version__ in output_as_module
17-
assert output_as_module == output_directly
13+
expected_version = west.version.__version__
14+
15+
# call west executable directly
16+
output_directly = cmd(['--version'])
17+
assert expected_version in output_directly
18+
19+
output_subprocess = cmd_subprocess('--version')
20+
assert expected_version in output_subprocess
21+
22+
# output must be same in both cases
23+
assert output_subprocess.rstrip() == output_directly.rstrip()

0 commit comments

Comments
 (0)