Skip to content

Commit b782013

Browse files
authored
Merge pull request #1037 from romainx/test_packages
Test packages
2 parents 4609df0 + 7dae682 commit b782013

File tree

3 files changed

+191
-3
lines changed

3 files changed

+191
-3
lines changed

Makefile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,5 @@ tx-en: ## rebuild en locale strings and push to master (req: GH_TOKEN)
9494
test/%: ## run tests against a stack (only common tests or common tests + specific tests)
9595
@if [ ! -d "$(notdir $@)/test" ]; then TEST_IMAGE="$(OWNER)/$(notdir $@)" pytest -m "not info" test; \
9696
else TEST_IMAGE="$(OWNER)/$(notdir $@)" pytest -m "not info" test $(notdir $@)/test; fi
97+
98+
test-all: $(foreach I,$(ALL_IMAGES),test/$(I)) ## test all stacks

test/helpers.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,21 @@ def _execute_command(self, command):
8888
def _packages_from_json(env_export):
8989
"""Extract packages and versions from the lines returned by the list of specifications"""
9090
dependencies = json.loads(env_export).get("dependencies")
91-
packages_list = map(lambda x: x.split("=", 1), dependencies)
92-
# TODO: could be improved
93-
return {package[0]: set(package[1:]) for package in packages_list}
91+
packages_dict = dict()
92+
for split in map(lambda x: x.split("=", 1), dependencies):
93+
# default values
94+
package = split[0]
95+
version = set()
96+
# cheking if it's a proper version by testing if the first char is a digit
97+
if len(split) > 1:
98+
if split[1][0].isdigit():
99+
# package + version case
100+
version = set(split[1:])
101+
else:
102+
# The split was incorrect and the package shall not be splitted
103+
package = f"{split[0]}={split[1]}"
104+
packages_dict[package] = version
105+
return packages_dict
94106

95107
def available_packages(self):
96108
"""Return the available packages"""

test/test_packages.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
# Copyright (c) Jupyter Development Team.
2+
# Distributed under the terms of the Modified BSD License.
3+
4+
"""
5+
test_packages
6+
~~~~~~~~~~~~~~~
7+
This test module tests if R and Python packages installed can be imported.
8+
It's a basic test aiming to prove that the package is working properly.
9+
10+
The goal is to detect import errors that can be caused by incompatibilities between packages for example:
11+
12+
- #1012: issue importing `sympy`
13+
- #966: isssue importing `pyarrow`
14+
15+
This module checks dynmamically, through the `CondaPackageHelper`, only the specified packages i.e. packages requested by `conda install` in the `Dockerfiles`.
16+
This means that it does not check dependencies. This choice is a tradeoff to cover the main requirements while achieving reasonable test duration.
17+
However it could be easily changed (or completed) to cover also dependencies `package_helper.installed_packages()` instead of `package_helper.specified_packages()`.
18+
19+
Example:
20+
21+
$ make test/datascience-notebook
22+
23+
# [...]
24+
# test/test_packages.py::test_python_packages
25+
# --------------------------------------------------------------------------------------------- live log setup ----------------------------------------------------------------------------------------------
26+
# 2020-03-08 09:56:04 [ INFO] Starting container jupyter/datascience-notebook ... (helpers.py:51)
27+
# 2020-03-08 09:56:04 [ INFO] Running jupyter/datascience-notebook with args {'detach': True, 'ports': {'8888/tcp': 8888}, 'tty': True, 'command': ['start.sh', 'bash', '-c', 'sleep infinity']} ... (conftest.py:78)
28+
# 2020-03-08 09:56:04 [ INFO] Grabing the list of specifications ... (helpers.py:76)
29+
# ---------------------------------------------------------------------------------------------- live log call ----------------------------------------------------------------------------------------------
30+
# 2020-03-08 09:56:07 [ INFO] Testing the import of packages ... (test_packages.py:125)
31+
# 2020-03-08 09:56:07 [ INFO] Trying to import conda (test_packages.py:127)
32+
# 2020-03-08 09:56:07 [ INFO] Trying to import notebook (test_packages.py:127)
33+
# 2020-03-08 09:56:08 [ INFO] Trying to import jupyterhub (test_packages.py:127)
34+
# [...]
35+
36+
"""
37+
38+
import logging
39+
40+
import pytest
41+
42+
from helpers import CondaPackageHelper
43+
44+
LOGGER = logging.getLogger(__name__)
45+
46+
# Mapping between package and module name
47+
PACKAGE_MAPPING = {
48+
# Python
49+
"matplotlib-base": "matplotlib",
50+
"beautifulsoup4": "bs4",
51+
"scikit-learn": "sklearn",
52+
"scikit-image": "skimage",
53+
"spylon-kernel": "spylon_kernel",
54+
# R
55+
"randomforest": "randomForest",
56+
"rsqlite": "DBI",
57+
"rcurl": "RCurl",
58+
"rodbc": "RODBC",
59+
}
60+
61+
# List of packages that cannot be tested in a standard way
62+
EXCLUDED_PACKAGES = [
63+
"tini",
64+
"python",
65+
"hdf5",
66+
"conda-forge::blas[build=openblas]",
67+
"protobuf",
68+
"r-irkernel",
69+
"unixodbc",
70+
]
71+
72+
73+
@pytest.fixture(scope="function")
74+
def package_helper(container):
75+
"""Return a package helper object that can be used to perform tests on installed packages"""
76+
return CondaPackageHelper(container)
77+
78+
79+
@pytest.fixture(scope="function")
80+
def packages(package_helper):
81+
"""Return the list of specified packages (i.e. packages explicitely installed excluding dependencies)"""
82+
return package_helper.specified_packages()
83+
84+
85+
def package_map(package):
86+
"""Perform a mapping between the python package name and the name used for the import"""
87+
_package = package
88+
if _package in PACKAGE_MAPPING:
89+
_package = PACKAGE_MAPPING.get(_package)
90+
return _package
91+
92+
93+
def excluded_package_predicate(package):
94+
"""Return whether a package is excluded from the list (i.e. a package that cannot be tested with standard imports)"""
95+
return package in EXCLUDED_PACKAGES
96+
97+
98+
def python_package_predicate(package):
99+
"""Predicate matching python packages"""
100+
return not excluded_package_predicate(package) and not r_package_predicate(package)
101+
102+
103+
def r_package_predicate(package):
104+
"""Predicate matching R packages"""
105+
return not excluded_package_predicate(package) and package.startswith("r-")
106+
107+
108+
def _check_import_package(package_helper, command):
109+
"""Generic function executing a command"""
110+
LOGGER.debug(f"Trying to import a package with [{command}] ...")
111+
rc = package_helper.running_container.exec_run(command)
112+
return rc.exit_code
113+
114+
115+
def check_import_python_package(package_helper, package):
116+
"""Try to import a Python package from the command line"""
117+
return _check_import_package(package_helper, ["python", "-c", f"import {package}"])
118+
119+
120+
def check_import_r_package(package_helper, package):
121+
"""Try to import a R package from the command line"""
122+
return _check_import_package(
123+
package_helper, ["R", "--slave", "-e", f"library({package})"]
124+
)
125+
126+
127+
def _import_packages(package_helper, filtered_packages, check_function, max_failures):
128+
"""Test if packages can be imported
129+
130+
Note: using a list of packages instead of a fixture for the list of packages since pytest prevents use of multiple yields
131+
"""
132+
failures = {}
133+
LOGGER.info(f"Testing the import of packages ...")
134+
for package in filtered_packages:
135+
LOGGER.info(f"Trying to import {package}")
136+
try:
137+
assert (
138+
check_function(package_helper, package) == 0
139+
), f"Package [{package}] import failed"
140+
except AssertionError as err:
141+
failures[package] = err
142+
if len(failures) > max_failures:
143+
raise AssertionError(failures)
144+
elif len(failures) > 0:
145+
LOGGER.warning(f"Some import(s) has(have) failed: {failures}")
146+
147+
148+
@pytest.fixture(scope="function")
149+
def r_packages(packages):
150+
"""Return an iterable of R packages"""
151+
# package[2:] is to remove the leading "r-" appended by conda on R packages
152+
return map(
153+
lambda package: package_map(package[2:]), filter(r_package_predicate, packages)
154+
)
155+
156+
157+
def test_python_packages(package_helper, python_packages, max_failures=0):
158+
"""Test the import of specified python packages"""
159+
return _import_packages(
160+
package_helper, python_packages, check_import_python_package, max_failures
161+
)
162+
163+
164+
@pytest.fixture(scope="function")
165+
def python_packages(packages):
166+
"""Return an iterable of Python packages"""
167+
return map(package_map, filter(python_package_predicate, packages))
168+
169+
170+
def test_r_packages(package_helper, r_packages, max_failures=0):
171+
"""Test the import of specified R packages"""
172+
return _import_packages(
173+
package_helper, r_packages, check_import_r_package, max_failures
174+
)

0 commit comments

Comments
 (0)