Skip to content

Commit 4d49829

Browse files
avoid using subprocess in tests
Use main.main() instead, which makes the tests fully debuggable.
1 parent 131b1c3 commit 4d49829

File tree

8 files changed

+183
-127
lines changed

8 files changed

+183
-127
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: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,4 @@ env = {PYTHONIOENCODING = "utf-8"}
114114
[tool.poe.tasks]
115115
lint = "ruff check ."
116116
types = "mypy --package west"
117-
all = ["test", "lint", "types"]
118-
119-
[tool.pytest.ini_options]
120-
pythonpath = ["src"]
117+
all = ["test", "lint", "types"]

tests/conftest.py

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

5+
import contextlib
6+
import io
57
import os
68
import platform
79
import shutil
@@ -13,25 +15,9 @@
1315

1416
import pytest
1517

16-
import west.version
1718
from west import configuration as config
1819
from west.app import main
1920

20-
# Expected PYTHONPATH pointing to the current source directory
21-
PYTHONPATH = os.fspath(Path(west.version.__file__).resolve().parents[1])
22-
23-
# West's main Python script, runnable as a command-line executable
24-
WEST_MAIN = main.__file__
25-
26-
# installed west executable (only set if it is correct version)
27-
WEST_EXE = shutil.which('west')
28-
if WEST_EXE:
29-
# ensure that installed west exe has expected version
30-
expected = west.version.__version__
31-
actual = subprocess.check_output([WEST_EXE,'--version'], text=True)
32-
if expected not in actual:
33-
WEST_EXE = None
34-
3521
GIT = shutil.which('git')
3622

3723
# Git capabilities are discovered at runtime in
@@ -78,6 +64,47 @@
7864

7965
WINDOWS = (platform.system() == 'Windows')
8066

67+
#
68+
# Contextmanager
69+
#
70+
71+
@contextlib.contextmanager
72+
def update_env(env: dict):
73+
"""
74+
Temporarily update the process environment variables.
75+
This context manager updates `os.environ` with the key-value pairs
76+
provided in the `env` dictionary for the duration of the `with` block.
77+
Any existing environment variables are preserved and restored when
78+
the block exits.
79+
"""
80+
env_bak = dict(os.environ)
81+
env_vars = {}
82+
for k,v in env.items():
83+
env_vars[k] = str(v) if v else ""
84+
os.environ.update(env_vars)
85+
try:
86+
yield
87+
finally:
88+
os.environ.clear()
89+
os.environ.update(env_bak)
90+
91+
92+
@contextlib.contextmanager
93+
def chdir(path):
94+
"""
95+
Temporarily change the current working directory.
96+
This context manager changes the current working directory to `path`
97+
for the duration of the `with` block. After the block exits, the
98+
working directory is restored to its original value.
99+
"""
100+
oldpwd = os.getcwd()
101+
os.chdir(path)
102+
try:
103+
yield
104+
finally:
105+
os.chdir(oldpwd)
106+
107+
81108
#
82109
# Test fixtures
83110
#
@@ -334,52 +361,53 @@ def check_output(*args, **kwargs):
334361
raise
335362
return out_bytes.decode(sys.getdefaultencoding())
336363

364+
337365
def cmd(cmd, cwd=None, stderr=None, env=None):
338366
# Run a west command in a directory (cwd defaults to os.getcwd()).
339367
#
340-
# This helper takes the command as a string.
368+
# This helper takes the command as a string or list.
341369
#
342-
# This helper relies on the test environment to ensure that the
343-
# 'west' executable is a bootstrapper installed from the current
344-
# west source code. If installed 'west' executable has correct version,
345-
# it is preferred. Otherwise, fall back to running the local Python module.
346-
#
347-
# stdout from cmd is captured and returned. The command is run in
348-
# a python subprocess so that program-level setup and teardown
349-
# happen fresh.
350-
351-
# If you have quoting issues: do NOT quote. It's not portable.
352-
# Instead, pass `cmd` as a list.
353-
354-
west_exe = WEST_EXE or WEST_MAIN
355-
356-
cmd = [west_exe] + (cmd.split() if isinstance(cmd, str) else cmd)
357-
print('running:', cmd)
358-
359-
if env:
360-
print('with non-default environment:')
361-
for k in env:
362-
if k not in os.environ or env[k] != os.environ[k]:
363-
print(f'\t{k}={env[k]}')
364-
for k in os.environ:
365-
if k not in env:
366-
print(f'\t{k}: deleted, was: {os.environ[k]}')
367-
if cwd is not None:
368-
cwd = os.fspath(cwd)
369-
print(f'in {cwd}')
370-
try:
371-
return check_output(cmd, cwd=cwd, stderr=stderr, env=env)
372-
except subprocess.CalledProcessError:
373-
print('cmd: west:', west_exe, file=sys.stderr)
374-
raise
370+
# This function executes the `main()` function with the given command as
371+
# arguments, capturing its stdout output while optionally modifying the working
372+
# directory and environment.
373+
cmd = (cmd.split() if isinstance(cmd, str) else cmd)
374+
# every member of cmd must be string
375+
cmd = [str(c) for c in cmd]
376+
377+
stdout_buf = io.StringIO()
378+
with chdir(cwd or Path.cwd()):
379+
with update_env(env or {}):
380+
with contextlib.redirect_stdout(stdout_buf), \
381+
contextlib.redirect_stderr(stderr or sys.stderr):
382+
try:
383+
main.main(cmd)
384+
except SystemExit as e:
385+
if e.code:
386+
raise e
387+
except Exception as e:
388+
print(f'Uncaught exception type {e}', file=sys.stderr)
389+
raise e
390+
return stdout_buf.getvalue()
375391

376392

377393
def cmd_raises(cmd_str_or_list, expected_exception_type, cwd=None, env=None):
378394
# Similar to 'cmd' but an expected exception is caught.
379-
# Returns the output together with stderr data
395+
# Returns the stderr data.
396+
stderr_buf = io.StringIO()
380397
with pytest.raises(expected_exception_type) as exc_info:
381-
cmd(cmd_str_or_list, stderr=subprocess.STDOUT, cwd=cwd, env=env)
382-
return exc_info.value.output.decode("utf-8")
398+
cmd(cmd_str_or_list, stderr=stderr_buf, cwd=cwd, env=env)
399+
return exc_info, stderr_buf.getvalue()
400+
401+
402+
def cmd_all_stdout(cmd, cwd=None, stderr=None, env=None):
403+
# This function is similar to `cmd()`, but runs the command in a separate
404+
# Python subprocess. This ensures that both Python-level stdout and any
405+
# subprocess output invoked by `main` are captured together in a single
406+
# string. It also ensures that program-level setup and teardown in `main`
407+
# happen fresh for each call.
408+
WEST_MAIN = main.__file__
409+
cmd = (cmd.split() if isinstance(cmd, str) else cmd)
410+
return check_output([sys.executable, WEST_MAIN] + cmd, cwd=cwd)
383411

384412

385413
def create_workspace(workspace_dir, and_git=True):

tests/test_alias.py

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

5-
import subprocess
6-
75
import pytest
86
from conftest import cmd
97

@@ -50,10 +48,9 @@ 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+
with pytest.raises(SystemExit) as excinfo:
52+
cmd('test1')
53+
assert 'unknown command "test1";' in str(excinfo.value.code)
5754

5855

5956
def test_alias_empty():
@@ -62,10 +59,9 @@ def test_alias_empty():
6259
# help command shouldn't fail
6360
cmd('help')
6461

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)
62+
with pytest.raises(SystemExit) as excinfo:
63+
cmd('empty')
64+
assert 'empty alias "empty"' in str(excinfo.value.code)
6965

7066

7167
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
@@ -251,12 +250,12 @@ def test_append():
251250
'-DCONF_FILE=foo.conf -DEXTRA_CFLAGS=\'-Wextra -g0\' -DFOO=BAR'
252251

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

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

262261

@@ -387,7 +386,7 @@ def test_delete_cmd_all():
387386
assert cfg(f=ALL)['pytest']['key'] == 'local'
388387
cmd('config -D pytest.key')
389388
assert 'pytest' not in cfg(f=ALL)
390-
with pytest.raises(subprocess.CalledProcessError):
389+
with pytest.raises(SystemExit):
391390
cmd('config -D pytest.key')
392391

393392
def test_delete_cmd_none():
@@ -400,7 +399,7 @@ def test_delete_cmd_none():
400399
assert cmd('config pytest.key').rstrip() == 'global'
401400
cmd('config -d pytest.key')
402401
assert cmd('config pytest.key').rstrip() == 'system'
403-
with pytest.raises(subprocess.CalledProcessError):
402+
with pytest.raises(SystemExit):
404403
cmd('config -d pytest.key')
405404

406405
def test_delete_cmd_system():
@@ -409,7 +408,7 @@ def test_delete_cmd_system():
409408
cmd('config --global pytest.key global')
410409
cmd('config --local pytest.key local')
411410
cmd('config -d --system pytest.key')
412-
with pytest.raises(subprocess.CalledProcessError):
411+
with pytest.raises(SystemExit):
413412
cmd('config --system pytest.key')
414413
assert cmd('config --global pytest.key').rstrip() == 'global'
415414
assert cmd('config --local pytest.key').rstrip() == 'local'
@@ -421,7 +420,7 @@ def test_delete_cmd_global():
421420
cmd('config --local pytest.key local')
422421
cmd('config -d --global pytest.key')
423422
assert cmd('config --system pytest.key').rstrip() == 'system'
424-
with pytest.raises(subprocess.CalledProcessError):
423+
with pytest.raises(SystemExit):
425424
cmd('config --global pytest.key')
426425
assert cmd('config --local pytest.key').rstrip() == 'local'
427426

@@ -433,16 +432,16 @@ def test_delete_cmd_local():
433432
cmd('config -d --local pytest.key')
434433
assert cmd('config --system pytest.key').rstrip() == 'system'
435434
assert cmd('config --global pytest.key').rstrip() == 'global'
436-
with pytest.raises(subprocess.CalledProcessError):
435+
with pytest.raises(SystemExit):
437436
cmd('config --local pytest.key')
438437

439438
def test_delete_cmd_error():
440439
# Verify illegal combinations of flags error out.
441-
err_msg = cmd_raises('config -l -d pytest.key', subprocess.CalledProcessError)
440+
_, err_msg = cmd_raises('config -l -d pytest.key', SystemExit)
442441
assert 'argument -d/--delete: not allowed with argument -l/--list' in err_msg
443-
err_msg = cmd_raises('config -l -D pytest.key', subprocess.CalledProcessError)
442+
_, err_msg = cmd_raises('config -l -D pytest.key', SystemExit)
444443
assert 'argument -D/--delete-all: not allowed with argument -l/--list' in err_msg
445-
err_msg = cmd_raises('config -d -D pytest.key', subprocess.CalledProcessError)
444+
_, err_msg = cmd_raises('config -d -D pytest.key', SystemExit)
446445
assert 'argument -D/--delete-all: not allowed with argument -d/--delete' in err_msg
447446

448447
def test_default_config():
@@ -473,25 +472,25 @@ def test_config_precedence():
473472
assert cfg(f=LOCAL)['pytest']['precedence'] == 'local'
474473

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

479478

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

486485
def test_no_args():
487-
err_msg = cmd_raises('config', subprocess.CalledProcessError)
486+
_, err_msg = cmd_raises('config', SystemExit)
488487
assert 'missing argument name' in err_msg
489488

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

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

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

tests/test_help.py

Lines changed: 0 additions & 10 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

@@ -32,14 +30,6 @@ def test_extension_help_and_dash_h(west_init_tmpdir):
3230
ext2out = cmd('test-extension -h')
3331

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

tests/test_main.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import os
22
import subprocess
33
import sys
4+
from pathlib import Path
45

5-
from conftest import PYTHONPATH, WEST_EXE, cmd
6+
from conftest import cmd
67

78
import west.version
89

@@ -23,9 +24,9 @@ def test_main():
2324
# Note: if a wrong west version is installed, local PYTHONPATH needs to be
2425
# prepended so that the called process uses correct west python modules
2526
env = os.environ.copy()
26-
if not WEST_EXE:
27-
val = os.getenv('PYTHONPATH')
28-
env['PYTHONPATH'] = f'{PYTHONPATH}{os.pathsep}{val}' if val else PYTHONPATH
27+
val = os.getenv('PYTHONPATH')
28+
PYTHONPATH = os.fspath(Path(west.version.__file__).resolve().parents[1])
29+
env['PYTHONPATH'] = f'{PYTHONPATH}{os.pathsep}{val}' if val else PYTHONPATH
2930

3031
# execute west as a python module
3132
output_as_module = subprocess.check_output(

0 commit comments

Comments
 (0)