Allow recent pytest versions to be used with Spack (#25371)

Currently Spack vendors `pytest` at a version which is three major 
versions behind the latest (3.2.5 vs. 6.2.4). We do that since v3.2.5 
is the latest version supporting Python 2.6. Remaining so much 
behind the currently supported versions though might introduce 
some incompatibilities and is surely a technical debt.

This PR modifies Spack to:
- Use the vendored `pytest@3.2.5` only as a fallback solution, 
  if the Python interpreter used for Spack doesn't provide a newer one
- Be able to parse `pytest --collect-only` in all the different output 
  formats from v3.2.5 to v6.2.4 and use it consistently for `spack unit-test --list-*`
- Updating the unit tests in Github Actions to use a more recent `pytest` version
This commit is contained in:
Massimiliano Culpo 2021-11-18 15:08:59 +01:00 committed by GitHub
parent 8f7640dbef
commit f981682bdc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
95 changed files with 92 additions and 44 deletions

View File

@ -114,7 +114,7 @@ jobs:
patchelf cmake bison libbison-dev kcov patchelf cmake bison libbison-dev kcov
- name: Install Python packages - name: Install Python packages
run: | run: |
pip install --upgrade pip six setuptools codecov coverage[toml] pip install --upgrade pip six setuptools pytest codecov coverage[toml]
# ensure style checks are not skipped in unit tests for python >= 3.6 # ensure style checks are not skipped in unit tests for python >= 3.6
# note that true/false (i.e., 1/0) are opposite in conditions in python and bash # note that true/false (i.e., 1/0) are opposite in conditions in python and bash
if python -c 'import sys; sys.exit(not sys.version_info >= (3, 6))'; then if python -c 'import sys; sys.exit(not sys.version_info >= (3, 6))'; then
@ -173,7 +173,7 @@ jobs:
sudo apt-get install -y coreutils kcov csh zsh tcsh fish dash bash sudo apt-get install -y coreutils kcov csh zsh tcsh fish dash bash
- name: Install Python packages - name: Install Python packages
run: | run: |
pip install --upgrade pip six setuptools codecov coverage[toml] pip install --upgrade pip six setuptools pytest codecov coverage[toml]
- name: Setup git configuration - name: Setup git configuration
run: | run: |
# Need this for the git tests to succeed. # Need this for the git tests to succeed.
@ -274,7 +274,7 @@ jobs:
patchelf kcov patchelf kcov
- name: Install Python packages - name: Install Python packages
run: | run: |
pip install --upgrade pip six setuptools codecov coverage[toml] clingo pip install --upgrade pip six setuptools pytest codecov coverage[toml] clingo
- name: Setup git configuration - name: Setup git configuration
run: | run: |
# Need this for the git tests to succeed. # Need this for the git tests to succeed.
@ -317,7 +317,7 @@ jobs:
- name: Install Python packages - name: Install Python packages
run: | run: |
pip install --upgrade pip six setuptools pip install --upgrade pip six setuptools
pip install --upgrade codecov coverage[toml] pip install --upgrade pytest codecov coverage[toml]
- name: Setup Homebrew packages - name: Setup Homebrew packages
run: | run: |
brew install dash fish gcc gnupg2 kcov brew install dash fish gcc gnupg2 kcov

View File

@ -30,6 +30,7 @@
# add these directories to sys.path here. If the directory is relative to the # add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here. # documentation root, use os.path.abspath to make it absolute, like shown here.
sys.path.insert(0, os.path.abspath('_spack_root/lib/spack/external')) sys.path.insert(0, os.path.abspath('_spack_root/lib/spack/external'))
sys.path.insert(0, os.path.abspath('_spack_root/lib/spack/external/pytest-fallback'))
if sys.version_info[0] < 3: if sys.version_info[0] < 3:
sys.path.insert( sys.path.insert(

View File

@ -7,14 +7,19 @@
import argparse import argparse
import collections import collections
import os.path
import re import re
import sys import sys
import pytest try:
import pytest
except ImportError:
pytest = None # type: ignore
from six import StringIO from six import StringIO
import llnl.util.filesystem
import llnl.util.tty.color as color import llnl.util.tty.color as color
from llnl.util.filesystem import working_dir
from llnl.util.tty.colify import colify from llnl.util.tty.colify import colify
import spack.paths import spack.paths
@ -67,38 +72,6 @@ def setup_parser(subparser):
def do_list(args, extra_args): def do_list(args, extra_args):
"""Print a lists of tests than what pytest offers.""" """Print a lists of tests than what pytest offers."""
# Run test collection and get the tree out.
old_output = sys.stdout
try:
sys.stdout = output = StringIO()
pytest.main(['--collect-only'] + extra_args)
finally:
sys.stdout = old_output
lines = output.getvalue().split('\n')
tests = collections.defaultdict(lambda: set())
prefix = []
# collect tests into sections
for line in lines:
match = re.match(r"(\s*)<([^ ]*) '([^']*)'", line)
if not match:
continue
indent, nodetype, name = match.groups()
# strip parametrized tests
if "[" in name:
name = name[:name.index("[")]
depth = len(indent) // 2
if nodetype.endswith("Function"):
key = tuple(prefix)
tests[key].add(name)
else:
prefix = prefix[:depth]
prefix.append(name)
def colorize(c, prefix): def colorize(c, prefix):
if isinstance(prefix, tuple): if isinstance(prefix, tuple):
return "::".join( return "::".join(
@ -107,8 +80,65 @@ def colorize(c, prefix):
) )
return color.colorize("@%s{%s}" % (c, prefix)) return color.colorize("@%s{%s}" % (c, prefix))
# To list the files we just need to inspect the filesystem,
# which doesn't need to wait for pytest collection and doesn't
# require parsing pytest output
files = llnl.util.filesystem.find(
root=spack.paths.test_path, files='*.py', recursive=True
)
files = [
os.path.relpath(f, start=spack.paths.spack_root)
for f in files if not f.endswith(('conftest.py', '__init__.py'))
]
old_output = sys.stdout
try:
sys.stdout = output = StringIO()
pytest.main(['--collect-only'] + extra_args)
finally:
sys.stdout = old_output
lines = output.getvalue().split('\n')
tests = collections.defaultdict(set)
# collect tests into sections
node_regexp = re.compile(r"(\s*)<([^ ]*) ['\"]?([^']*)['\"]?>")
key_parts, name_parts = [], []
for line in lines:
match = node_regexp.match(line)
if not match:
continue
indent, nodetype, name = match.groups()
# strip parametrized tests
if "[" in name:
name = name[:name.index("[")]
len_indent = len(indent)
if os.path.isabs(name):
name = os.path.relpath(name, start=spack.paths.spack_root)
item = (len_indent, name, nodetype)
# Reduce the parts to the scopes that are of interest
name_parts = [x for x in name_parts if x[0] < len_indent]
key_parts = [x for x in key_parts if x[0] < len_indent]
# From version 3.X to version 6.X the output format
# changed a lot in pytest, and probably will change
# in the future - so this manipulation might be fragile
if nodetype.lower() == 'function':
name_parts.append(item)
key_end = os.path.join(*[x[1] for x in key_parts])
key = next(f for f in files if f.endswith(key_end))
tests[key].add(tuple(x[1] for x in name_parts))
elif nodetype.lower() == 'class':
name_parts.append(item)
elif nodetype.lower() in ('package', 'module'):
key_parts.append(item)
if args.list == "list": if args.list == "list":
files = set(prefix[0] for prefix in tests) files = set(tests.keys())
color_files = [colorize("B", file) for file in sorted(files)] color_files = [colorize("B", file) for file in sorted(files)]
colify(color_files) colify(color_files)
@ -144,6 +174,14 @@ def add_back_pytest_args(args, unknown_args):
def unit_test(parser, args, unknown_args): def unit_test(parser, args, unknown_args):
global pytest
if pytest is None:
vendored_pytest_dir = os.path.join(
spack.paths.external_path, 'pytest-fallback'
)
sys.path.append(vendored_pytest_dir)
import pytest
if args.pytest_help: if args.pytest_help:
# make the pytest.main help output more accurate # make the pytest.main help output more accurate
sys.argv[0] = 'spack unit-test' sys.argv[0] = 'spack unit-test'
@ -161,7 +199,7 @@ def unit_test(parser, args, unknown_args):
pytest_root = spack.extensions.path_for_extension(target, *extensions) pytest_root = spack.extensions.path_for_extension(target, *extensions)
# pytest.ini lives in the root of the spack repository. # pytest.ini lives in the root of the spack repository.
with working_dir(pytest_root): with llnl.util.filesystem.working_dir(pytest_root):
if args.list: if args.list:
do_list(args, pytest_args) do_list(args, pytest_args)
return return

View File

@ -22,7 +22,10 @@ def test_list_with_pytest_arg():
def test_list_with_keywords(): def test_list_with_keywords():
output = spack_test('--list', '-k', 'cmd/unit_test.py') # Here we removed querying with a "/" to separate directories
# since the behavior is inconsistent across different pytest
# versions, see https://stackoverflow.com/a/48814787/771663
output = spack_test('--list', '-k', 'unit_test.py')
assert output.strip() == cmd_test_py assert output.strip() == cmd_test_py

View File

@ -430,8 +430,14 @@ def _skip_if_missing_executables(request):
"""Permits to mark tests with 'require_executables' and skip the """Permits to mark tests with 'require_executables' and skip the
tests if the executables passed as arguments are not found. tests if the executables passed as arguments are not found.
""" """
if request.node.get_marker('requires_executables'): if hasattr(request.node, 'get_marker'):
required_execs = request.node.get_marker('requires_executables').args # TODO: Remove the deprecated API as soon as we drop support for Python 2.6
marker = request.node.get_marker('requires_executables')
else:
marker = request.node.get_closest_marker('requires_executables')
if marker:
required_execs = marker.args
missing_execs = [ missing_execs = [
x for x in required_execs if spack.util.executable.which(x) is None x for x in required_execs if spack.util.executable.which(x) is None
] ]
@ -1453,7 +1459,7 @@ def invalid_spec(request):
return request.param return request.param
@pytest.fixture("module") @pytest.fixture(scope='module')
def mock_test_repo(tmpdir_factory): def mock_test_repo(tmpdir_factory):
"""Create an empty repository.""" """Create an empty repository."""
repo_namespace = 'mock_test_repo' repo_namespace = 'mock_test_repo'