Skip to content

Commit 06abc8d

Browse files
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 a53dbf4 commit 06abc8d

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: 64 additions & 40 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,69 @@ 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
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
403397

404398

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")
399+
def cmd(cmd, cwd=None, stderr=None, env=None):
400+
# Same as _cmd(), but it captures and returns stdout.
401+
# Optionally stderr is captured into given stderr (io.StringIO)
402+
stdout_buf = io.StringIO()
403+
stderr_buf = stderr or sys.stderr
404+
with contextlib.redirect_stdout(stdout_buf), contextlib.redirect_stderr(stderr_buf):
405+
_cmd(cmd, cwd, env)
406+
return stdout_buf.getvalue()
407+
408+
409+
def cmd_raises(cmd_str_or_list, expected_exception_type, stdout=None, cwd=None, env=None):
410+
# Similar to '_cmd' but an expected exception is caught.
411+
# The exception is returned together with stderr.
412+
# Optionally stdout is captured into given stdout (io.StringIO)
413+
stdout_buf = stdout or sys.stdout
414+
stderr_buf = io.StringIO()
415+
with (
416+
contextlib.redirect_stdout(stdout_buf),
417+
contextlib.redirect_stderr(stderr_buf),
418+
pytest.raises(expected_exception_type) as exc_info,
419+
):
420+
_cmd(cmd_str_or_list, cwd=cwd, env=env)
421+
return exc_info, stderr_buf.getvalue()
422+
423+
424+
def cmd_subprocess(cmd, *args, **kwargs):
425+
# This function is similar to `cmd()`, but runs the command in a separate
426+
# Python subprocess. This ensures that both Python-level stdout and any
427+
# subprocess output invoked by `main` are captured together in a single
428+
# string and also, that program-level setup and teardown in `main` happen
429+
# fresh for each call.
430+
cmd = cmd.split() if isinstance(cmd, str) else cmd
431+
cmd = [sys.executable, main.__file__] + cmd
432+
print('running (subprocess):', cmd)
433+
ret = check_output(cmd, *args, **kwargs)
434+
return ret
411435

412436

413437
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
from typing import Any
109

1110
import pytest
@@ -279,13 +278,13 @@ def test_append():
279278

280279

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

285284

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

291290

@@ -425,7 +424,7 @@ def test_delete_cmd_all():
425424
assert cfg(f=ALL)['pytest']['key'] == 'local'
426425
cmd('config -D pytest.key')
427426
assert 'pytest' not in cfg(f=ALL)
428-
with pytest.raises(subprocess.CalledProcessError):
427+
with pytest.raises(SystemExit):
429428
cmd('config -D pytest.key')
430429

431430

@@ -439,7 +438,7 @@ def test_delete_cmd_none():
439438
assert cmd('config pytest.key').rstrip() == 'global'
440439
cmd('config -d pytest.key')
441440
assert cmd('config pytest.key').rstrip() == 'system'
442-
with pytest.raises(subprocess.CalledProcessError):
441+
with pytest.raises(SystemExit):
443442
cmd('config -d pytest.key')
444443

445444

@@ -449,7 +448,7 @@ def test_delete_cmd_system():
449448
cmd('config --global pytest.key global')
450449
cmd('config --local pytest.key local')
451450
cmd('config -d --system pytest.key')
452-
with pytest.raises(subprocess.CalledProcessError):
451+
with pytest.raises(SystemExit):
453452
cmd('config --system pytest.key')
454453
assert cmd('config --global pytest.key').rstrip() == 'global'
455454
assert cmd('config --local pytest.key').rstrip() == 'local'
@@ -462,7 +461,7 @@ def test_delete_cmd_global():
462461
cmd('config --local pytest.key local')
463462
cmd('config -d --global pytest.key')
464463
assert cmd('config --system pytest.key').rstrip() == 'system'
465-
with pytest.raises(subprocess.CalledProcessError):
464+
with pytest.raises(SystemExit):
466465
cmd('config --global pytest.key')
467466
assert cmd('config --local pytest.key').rstrip() == 'local'
468467

@@ -475,17 +474,17 @@ def test_delete_cmd_local():
475474
cmd('config -d --local pytest.key')
476475
assert cmd('config --system pytest.key').rstrip() == 'system'
477476
assert cmd('config --global pytest.key').rstrip() == 'global'
478-
with pytest.raises(subprocess.CalledProcessError):
477+
with pytest.raises(SystemExit):
479478
cmd('config --local pytest.key')
480479

481480

482481
def test_delete_cmd_error():
483482
# Verify illegal combinations of flags error out.
484-
err_msg = cmd_raises('config -l -d pytest.key', subprocess.CalledProcessError)
483+
_, err_msg = cmd_raises('config -l -d pytest.key', SystemExit)
485484
assert 'argument -d/--delete: not allowed with argument -l/--list' in err_msg
486-
err_msg = cmd_raises('config -l -D pytest.key', subprocess.CalledProcessError)
485+
_, err_msg = cmd_raises('config -l -D pytest.key', SystemExit)
487486
assert 'argument -D/--delete-all: not allowed with argument -l/--list' in err_msg
488-
err_msg = cmd_raises('config -d -D pytest.key', subprocess.CalledProcessError)
487+
_, err_msg = cmd_raises('config -d -D pytest.key', SystemExit)
489488
assert 'argument -D/--delete-all: not allowed with argument -d/--delete' in err_msg
490489

491490

@@ -519,27 +518,27 @@ def test_config_precedence():
519518

520519

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

525524

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

532531

533532
def test_no_args():
534-
err_msg = cmd_raises('config', subprocess.CalledProcessError)
533+
_, err_msg = cmd_raises('config', SystemExit)
535534
assert 'missing argument name' in err_msg
536535

537536

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

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

545544
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)