Add some typing to tests

This commit is contained in:
Ayaz Salikhov
2022-01-18 19:13:17 +03:00
parent ee77b7831e
commit 2a1316c9ac
24 changed files with 211 additions and 141 deletions

1
.gitattributes vendored
View File

@@ -1,2 +1 @@
* text=auto eol=lf * text=auto eol=lf

View File

@@ -4,10 +4,12 @@
import logging import logging
import pytest import pytest
import os from pathlib import Path
from conftest import TrackedContainer
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
THIS_DIR = os.path.dirname(os.path.realpath(__file__)) THIS_DIR = Path(__file__).absolute()
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -15,9 +17,9 @@ THIS_DIR = os.path.dirname(os.path.realpath(__file__))
# TODO: add local_sparklyr # TODO: add local_sparklyr
["local_pyspark", "local_spylon", "local_sparkR", "issue_1168"], ["local_pyspark", "local_spylon", "local_sparkR", "issue_1168"],
) )
def test_nbconvert(container, test_file): def test_nbconvert(container: TrackedContainer, test_file: str) -> None:
"""Check if Spark notebooks can be executed""" """Check if Spark notebooks can be executed"""
host_data_dir = os.path.join(THIS_DIR, "data") host_data_dir = THIS_DIR / "data"
cont_data_dir = "/home/jovyan/data" cont_data_dir = "/home/jovyan/data"
output_dir = "/tmp" output_dir = "/tmp"
timeout_ms = 600 timeout_ms = 600
@@ -29,7 +31,7 @@ def test_nbconvert(container, test_file):
+ f"--execute {cont_data_dir}/{test_file}.ipynb" + f"--execute {cont_data_dir}/{test_file}.ipynb"
) )
c = container.run( c = container.run(
volumes={host_data_dir: {"bind": cont_data_dir, "mode": "ro"}}, volumes={str(host_data_dir): {"bind": cont_data_dir, "mode": "ro"}},
tty=True, tty=True,
command=["start.sh", "bash", "-c", command], command=["start.sh", "bash", "-c", command],
) )

View File

@@ -4,11 +4,14 @@ import time
import logging import logging
import pytest import pytest
import requests
from conftest import TrackedContainer
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
def test_cli_args(container, http_client): def test_cli_args(container: TrackedContainer, http_client: requests.Session) -> None:
"""Container should respect notebook server command line args """Container should respect notebook server command line args
(e.g., disabling token security)""" (e.g., disabling token security)"""
c = container.run(command=["start-notebook.sh", "--NotebookApp.token=''"]) c = container.run(command=["start-notebook.sh", "--NotebookApp.token=''"])
@@ -26,7 +29,9 @@ def test_cli_args(container, http_client):
@pytest.mark.filterwarnings("ignore:Unverified HTTPS request") @pytest.mark.filterwarnings("ignore:Unverified HTTPS request")
def test_unsigned_ssl(container, http_client): def test_unsigned_ssl(
container: TrackedContainer, http_client: requests.Session
) -> None:
"""Container should generate a self-signed SSL certificate """Container should generate a self-signed SSL certificate
and notebook server should use it to enable HTTPS. and notebook server should use it to enable HTTPS.
""" """
@@ -48,7 +53,7 @@ def test_unsigned_ssl(container, http_client):
assert warnings[0].startswith("WARNING: Jupyter Notebook deprecation notice") assert warnings[0].startswith("WARNING: Jupyter Notebook deprecation notice")
def test_uid_change(container): def test_uid_change(container: TrackedContainer) -> None:
"""Container should change the UID of the default user.""" """Container should change the UID of the default user."""
c = container.run( c = container.run(
tty=True, tty=True,
@@ -65,7 +70,7 @@ def test_uid_change(container):
assert "uid=1010(jovyan)" in c.logs(stdout=True).decode("utf-8") assert "uid=1010(jovyan)" in c.logs(stdout=True).decode("utf-8")
def test_gid_change(container): def test_gid_change(container: TrackedContainer) -> None:
"""Container should change the GID of the default user.""" """Container should change the GID of the default user."""
c = container.run( c = container.run(
tty=True, tty=True,
@@ -82,7 +87,7 @@ def test_gid_change(container):
assert "groups=110(jovyan),100(users)" in logs assert "groups=110(jovyan),100(users)" in logs
def test_nb_user_change(container): def test_nb_user_change(container: TrackedContainer) -> None:
"""Container should change the user name (`NB_USER`) of the default user.""" """Container should change the user name (`NB_USER`) of the default user."""
nb_user = "nayvoj" nb_user = "nayvoj"
running_container = container.run( running_container = container.run(
@@ -131,7 +136,7 @@ def test_nb_user_change(container):
), f"Hidden folder .jupyter was not copied properly to {nb_user} home folder. stat: {output}, expected {expected_output}" ), f"Hidden folder .jupyter was not copied properly to {nb_user} home folder. stat: {output}, expected {expected_output}"
def test_chown_extra(container): def test_chown_extra(container: TrackedContainer) -> None:
"""Container should change the UID/GID of a comma separated """Container should change the UID/GID of a comma separated
CHOWN_EXTRA list of folders.""" CHOWN_EXTRA list of folders."""
c = container.run( c = container.run(
@@ -160,7 +165,7 @@ def test_chown_extra(container):
assert "/opt/conda/bin/jupyter:1010:101" in logs assert "/opt/conda/bin/jupyter:1010:101" in logs
def test_chown_home(container): def test_chown_home(container: TrackedContainer) -> None:
"""Container should change the NB_USER home directory owner and """Container should change the NB_USER home directory owner and
group to the current value of NB_UID and NB_GID.""" group to the current value of NB_UID and NB_GID."""
c = container.run( c = container.run(
@@ -183,7 +188,7 @@ def test_chown_home(container):
assert "/home/kitten/.bashrc:1010:101" in logs assert "/home/kitten/.bashrc:1010:101" in logs
def test_sudo(container): def test_sudo(container: TrackedContainer) -> None:
"""Container should grant passwordless sudo to the default user.""" """Container should grant passwordless sudo to the default user."""
c = container.run( c = container.run(
tty=True, tty=True,
@@ -199,7 +204,7 @@ def test_sudo(container):
assert "uid=0(root)" in logs assert "uid=0(root)" in logs
def test_sudo_path(container): def test_sudo_path(container: TrackedContainer) -> None:
"""Container should include /opt/conda/bin in the sudo secure_path.""" """Container should include /opt/conda/bin in the sudo secure_path."""
c = container.run( c = container.run(
tty=True, tty=True,
@@ -215,7 +220,7 @@ def test_sudo_path(container):
assert logs.rstrip().endswith("/opt/conda/bin/jupyter") assert logs.rstrip().endswith("/opt/conda/bin/jupyter")
def test_sudo_path_without_grant(container): def test_sudo_path_without_grant(container: TrackedContainer) -> None:
"""Container should include /opt/conda/bin in the sudo secure_path.""" """Container should include /opt/conda/bin in the sudo secure_path."""
c = container.run( c = container.run(
tty=True, tty=True,
@@ -230,7 +235,7 @@ def test_sudo_path_without_grant(container):
assert logs.rstrip().endswith("/opt/conda/bin/jupyter") assert logs.rstrip().endswith("/opt/conda/bin/jupyter")
def test_group_add(container): def test_group_add(container: TrackedContainer) -> None:
"""Container should run with the specified uid, gid, and secondary """Container should run with the specified uid, gid, and secondary
group. It won't be possible to modify /etc/passwd since gid is nonzero, so group. It won't be possible to modify /etc/passwd since gid is nonzero, so
additionally verify that setting gid=0 is suggested in a warning. additionally verify that setting gid=0 is suggested in a warning.
@@ -252,7 +257,7 @@ def test_group_add(container):
assert "uid=1010 gid=1010 groups=1010,100(users)" in logs assert "uid=1010 gid=1010 groups=1010,100(users)" in logs
def test_set_uid(container): def test_set_uid(container: TrackedContainer) -> None:
"""Container should run with the specified uid and NB_USER. """Container should run with the specified uid and NB_USER.
The /home/jovyan directory will not be writable since it's owned by 1000:users. The /home/jovyan directory will not be writable since it's owned by 1000:users.
Additionally verify that "--group-add=users" is suggested in a warning to restore Additionally verify that "--group-add=users" is suggested in a warning to restore
@@ -274,7 +279,7 @@ def test_set_uid(container):
assert "--group-add=users" in warnings[0] assert "--group-add=users" in warnings[0]
def test_set_uid_and_nb_user(container): def test_set_uid_and_nb_user(container: TrackedContainer) -> None:
"""Container should run with the specified uid and NB_USER.""" """Container should run with the specified uid and NB_USER."""
c = container.run( c = container.run(
user="1010", user="1010",
@@ -294,7 +299,7 @@ def test_set_uid_and_nb_user(container):
assert "user is kitten but home is /home/jovyan" in warnings[0] assert "user is kitten but home is /home/jovyan" in warnings[0]
def test_container_not_delete_bind_mount(container, tmp_path): def test_container_not_delete_bind_mount(container: TrackedContainer, tmp_path) -> None:
"""Container should not delete host system files when using the (docker) """Container should not delete host system files when using the (docker)
-v bind mount flag and mapping to /home/jovyan. -v bind mount flag and mapping to /home/jovyan.
""" """
@@ -324,7 +329,9 @@ def test_container_not_delete_bind_mount(container, tmp_path):
@pytest.mark.parametrize("enable_root", [False, True]) @pytest.mark.parametrize("enable_root", [False, True])
def test_jupyter_env_vars_to_unset_as_root(container, enable_root): def test_jupyter_env_vars_to_unset_as_root(
container: TrackedContainer, enable_root: bool
) -> None:
"""Environment variables names listed in JUPYTER_ENV_VARS_TO_UNSET """Environment variables names listed in JUPYTER_ENV_VARS_TO_UNSET
should be unset in the final environment.""" should be unset in the final environment."""
root_args = {"user": "root"} if enable_root else {} root_args = {"user": "root"} if enable_root else {}

View File

@@ -4,6 +4,8 @@
import logging import logging
import pytest import pytest
from conftest import TrackedContainer
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@@ -17,7 +19,9 @@ LOGGER = logging.getLogger(__name__)
("pip", "--version"), ("pip", "--version"),
], ],
) )
def test_package_manager(container, package_manager, version_arg): def test_package_manager(
container: TrackedContainer, package_manager: str, version_arg: tuple[str, ...]
) -> None:
"""Test the notebook start-notebook script""" """Test the notebook start-notebook script"""
LOGGER.info( LOGGER.info(
f"Test that the package manager {package_manager} is working properly ..." f"Test that the package manager {package_manager} is working properly ..."

View File

@@ -3,10 +3,12 @@
import logging import logging
from conftest import TrackedContainer
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
def test_pandoc(container): def test_pandoc(container: TrackedContainer) -> None:
"""Pandoc shall be able to convert MD to HTML.""" """Pandoc shall be able to convert MD to HTML."""
c = container.run( c = container.run(
tty=True, tty=True,

View File

@@ -4,10 +4,14 @@ import logging
from packaging import version from packaging import version
from conftest import TrackedContainer
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
def test_python_version(container, python_next_version="3.10"): def test_python_version(
container: TrackedContainer, python_next_version: str = "3.10"
) -> None:
"""Check that python version is lower than the next version""" """Check that python version is lower than the next version"""
LOGGER.info(f"Checking that python version is lower than {python_next_version}") LOGGER.info(f"Checking that python version is lower than {python_next_version}")
c = container.run( c = container.run(

View File

@@ -3,6 +3,9 @@
import logging import logging
import pytest import pytest
import requests
from conftest import TrackedContainer
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@@ -14,7 +17,12 @@ LOGGER = logging.getLogger(__name__)
(None, "notebook"), (None, "notebook"),
], ],
) )
def test_start_notebook(container, http_client, env, expected_server): def test_start_notebook(
container: TrackedContainer,
http_client: requests.Session,
env,
expected_server: str,
) -> None:
"""Test the notebook start-notebook script""" """Test the notebook start-notebook script"""
LOGGER.info( LOGGER.info(
f"Test that the start-notebook launches the {expected_server} server from the env {env} ..." f"Test that the start-notebook launches the {expected_server} server from the env {env} ..."
@@ -46,7 +54,9 @@ def test_start_notebook(container, http_client, env, expected_server):
assert msg in logs, f"Expected warning message {msg} not printed" assert msg in logs, f"Expected warning message {msg} not printed"
def test_tini_entrypoint(container, pid=1, command="tini"): def test_tini_entrypoint(
container: TrackedContainer, pid: int = 1, command: str = "tini"
) -> None:
"""Check that tini is launched as PID 1 """Check that tini is launched as PID 1
Credits to the following answer for the ps options used in the test: Credits to the following answer for the ps options used in the test:

View File

@@ -2,6 +2,7 @@
# Distributed under the terms of the Modified BSD License. # Distributed under the terms of the Modified BSD License.
import os import os
import logging import logging
import typing
import docker import docker
import pytest import pytest
@@ -15,7 +16,7 @@ LOGGER = logging.getLogger(__name__)
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def http_client(): def http_client() -> requests.Session:
"""Requests session with retries and backoff.""" """Requests session with retries and backoff."""
s = requests.Session() s = requests.Session()
retries = Retry(total=5, backoff_factor=1) retries = Retry(total=5, backoff_factor=1)
@@ -25,13 +26,13 @@ def http_client():
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def docker_client(): def docker_client() -> docker.DockerClient:
"""Docker client configured based on the host environment""" """Docker client configured based on the host environment"""
return docker.from_env() return docker.from_env()
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def image_name(): def image_name() -> str:
"""Image name to test""" """Image name to test"""
return os.getenv("TEST_IMAGE") return os.getenv("TEST_IMAGE")
@@ -50,13 +51,15 @@ class TrackedContainer:
Default keyword arguments to pass to docker.DockerClient.containers.run Default keyword arguments to pass to docker.DockerClient.containers.run
""" """
def __init__(self, docker_client, image_name, **kwargs): def __init__(
self, docker_client: docker.DockerClient, image_name: str, **kwargs: typing.Any
):
self.container = None self.container = None
self.docker_client = docker_client self.docker_client = docker_client
self.image_name = image_name self.image_name = image_name
self.kwargs = kwargs self.kwargs = kwargs
def run(self, **kwargs): def run(self, **kwargs: typing.Any):
"""Runs a docker container using the preconfigured image name """Runs a docker container using the preconfigured image name
and a mix of the preconfigured container options and those passed and a mix of the preconfigured container options and those passed
to this method. to this method.
@@ -74,9 +77,7 @@ class TrackedContainer:
------- -------
docker.Container docker.Container
""" """
all_kwargs = {} all_kwargs = self.kwargs | kwargs
all_kwargs.update(self.kwargs)
all_kwargs.update(kwargs)
LOGGER.info(f"Running {self.image_name} with args {all_kwargs} ...") LOGGER.info(f"Running {self.image_name} with args {all_kwargs} ...")
self.container = self.docker_client.containers.run( self.container = self.docker_client.containers.run(
self.image_name, self.image_name,
@@ -91,7 +92,7 @@ class TrackedContainer:
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
def container(docker_client, image_name): def container(docker_client: docker.DockerClient, image_name: str):
"""Notebook container with initial configuration appropriate for testing """Notebook container with initial configuration appropriate for testing
(e.g., HTTP port exposed to the host for HTTP calls). (e.g., HTTP port exposed to the host for HTTP calls).

View File

@@ -2,10 +2,12 @@
# Distributed under the terms of the Modified BSD License. # Distributed under the terms of the Modified BSD License.
import logging import logging
from conftest import TrackedContainer
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
def test_julia(container): def test_julia(container: TrackedContainer) -> None:
"""Basic julia test""" """Basic julia test"""
LOGGER.info("Test that julia is correctly installed ...") LOGGER.info("Test that julia is correctly installed ...")
running_container = container.run( running_container = container.run(

View File

@@ -3,10 +3,12 @@
import logging import logging
from conftest import TrackedContainer
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
def test_inkscape(container): def test_inkscape(container: TrackedContainer) -> None:
"""Inkscape shall be installed to be able to convert SVG files.""" """Inkscape shall be installed to be able to convert SVG files."""
LOGGER.info("Test that inkscape is working by printing its version ...") LOGGER.info("Test that inkscape is working by printing its version ...")
c = container.run( c = container.run(

View File

@@ -4,10 +4,12 @@
import logging import logging
import pytest import pytest
import os from pathlib import Path
from conftest import TrackedContainer
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
THIS_DIR = os.path.dirname(os.path.realpath(__file__)) THIS_DIR = Path(__file__).absolute()
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -19,9 +21,11 @@ THIS_DIR = os.path.dirname(os.path.realpath(__file__))
("notebook_svg", "html"), ("notebook_svg", "html"),
], ],
) )
def test_nbconvert(container, test_file, output_format): def test_nbconvert(
container: TrackedContainer, test_file: str, output_format: str
) -> None:
"""Check if nbconvert is able to convert a notebook file""" """Check if nbconvert is able to convert a notebook file"""
host_data_dir = os.path.join(THIS_DIR, "data") host_data_dir = THIS_DIR / "data"
cont_data_dir = "/home/jovyan/data" cont_data_dir = "/home/jovyan/data"
output_dir = "/tmp" output_dir = "/tmp"
LOGGER.info( LOGGER.info(
@@ -29,7 +33,7 @@ def test_nbconvert(container, test_file, output_format):
) )
command = f"jupyter nbconvert {cont_data_dir}/{test_file}.ipynb --output-dir {output_dir} --to {output_format}" command = f"jupyter nbconvert {cont_data_dir}/{test_file}.ipynb --output-dir {output_dir} --to {output_format}"
c = container.run( c = container.run(
volumes={host_data_dir: {"bind": cont_data_dir, "mode": "ro"}}, volumes={str(host_data_dir): {"bind": cont_data_dir, "mode": "ro"}},
tty=True, tty=True,
command=["start.sh", "bash", "-c", command], command=["start.sh", "bash", "-c", command],
) )

View File

@@ -2,11 +2,13 @@
# Distributed under the terms of the Modified BSD License. # Distributed under the terms of the Modified BSD License.
import logging import logging
from conftest import TrackedContainer
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
def test_spark_shell(container): def test_spark_shell(container: TrackedContainer) -> None:
"""Checking if Spark (spark-shell) is running properly""" """Checking if Spark (spark-shell) is running properly"""
c = container.run( c = container.run(
tty=True, tty=True,

View File

@@ -4,6 +4,8 @@ import logging
import pytest import pytest
from conftest import TrackedContainer
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@@ -16,7 +18,7 @@ LOGGER = logging.getLogger(__name__)
"jupyter-matplotlib", "jupyter-matplotlib",
], ],
) )
def test_check_extension(container, extension): def test_check_extension(container: TrackedContainer, extension: str) -> None:
"""Basic check of each extension """Basic check of each extension
The list of extensions can be obtained through this command The list of extensions can be obtained through this command

View File

@@ -4,10 +4,12 @@
import logging import logging
import pytest import pytest
import os from pathlib import Path
from conftest import TrackedContainer
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
THIS_DIR = os.path.dirname(os.path.realpath(__file__)) THIS_DIR = Path(__file__).absolute()
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -25,19 +27,21 @@ THIS_DIR = os.path.dirname(os.path.realpath(__file__))
), ),
], ],
) )
def test_matplotlib(container, test_file, expected_file, description): def test_matplotlib(
container: TrackedContainer, test_file: str, expected_file: str, description: str
):
"""Various tests performed on matplotlib """Various tests performed on matplotlib
- Test that matplotlib is able to plot a graph and write it as an image - Test that matplotlib is able to plot a graph and write it as an image
- Test matplotlib latex fonts, which depend on the cm-super package - Test matplotlib latex fonts, which depend on the cm-super package
""" """
host_data_dir = os.path.join(THIS_DIR, "data") host_data_dir = THIS_DIR / "data"
cont_data_dir = "/home/jovyan/data" cont_data_dir = "/home/jovyan/data"
output_dir = "/tmp" output_dir = "/tmp"
LOGGER.info(description) LOGGER.info(description)
command = "sleep infinity" command = "sleep infinity"
running_container = container.run( running_container = container.run(
volumes={host_data_dir: {"bind": cont_data_dir, "mode": "ro"}}, volumes={str(host_data_dir): {"bind": cont_data_dir, "mode": "ro"}},
tty=True, tty=True,
command=["start.sh", "bash", "-c", command], command=["start.sh", "bash", "-c", command],
) )

View File

@@ -11,7 +11,7 @@ from .git_helper import GitHelper
from .manifests import ManifestHeader, ManifestInterface from .manifests import ManifestHeader, ManifestInterface
logger = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
BUILD_TIMESTAMP = datetime.datetime.utcnow().isoformat()[:-7] + "Z" BUILD_TIMESTAMP = datetime.datetime.utcnow().isoformat()[:-7] + "Z"
@@ -24,7 +24,7 @@ def append_build_history_line(
wiki_path: str, wiki_path: str,
all_tags: list[str], all_tags: list[str],
) -> None: ) -> None:
logger.info("Appending build history line") LOGGER.info("Appending build history line")
date_column = f"`{BUILD_TIMESTAMP}`" date_column = f"`{BUILD_TIMESTAMP}`"
image_column = MARKDOWN_LINE_BREAK.join( image_column = MARKDOWN_LINE_BREAK.join(
@@ -58,7 +58,7 @@ def create_manifest_file(
container, container,
) -> None: ) -> None:
manifest_names = [manifest.__name__ for manifest in manifests] manifest_names = [manifest.__name__ for manifest in manifests]
logger.info(f"Using manifests: {manifest_names}") LOGGER.info(f"Using manifests: {manifest_names}")
commit_hash_tag = GitHelper.commit_hash_tag() commit_hash_tag = GitHelper.commit_hash_tag()
manifest_file = os.path.join( manifest_file = os.path.join(
@@ -76,7 +76,7 @@ def create_manifest_file(
def create_manifests(short_image_name: str, owner: str, wiki_path: str) -> None: def create_manifests(short_image_name: str, owner: str, wiki_path: str) -> None:
logger.info(f"Creating manifests for image: {short_image_name}") LOGGER.info(f"Creating manifests for image: {short_image_name}")
taggers, manifests = get_taggers_and_manifests(short_image_name) taggers, manifests = get_taggers_and_manifests(short_image_name)
image = f"{owner}/{short_image_name}:latest" image = f"{owner}/{short_image_name}:latest"
@@ -100,6 +100,6 @@ if __name__ == "__main__":
arg_parser.add_argument("--wiki-path", required=True, help="Path to the wiki pages") arg_parser.add_argument("--wiki-path", required=True, help="Path to the wiki pages")
args = arg_parser.parse_args() args = arg_parser.parse_args()
logger.info(f"Current build timestamp: {BUILD_TIMESTAMP}") LOGGER.info(f"Current build timestamp: {BUILD_TIMESTAMP}")
create_manifests(args.short_image_name, args.owner, args.wiki_path) create_manifests(args.short_image_name, args.owner, args.wiki_path)

View File

@@ -3,8 +3,10 @@
import docker import docker
import logging import logging
from conftest import TrackedContainer
logger = logging.getLogger(__name__)
LOGGER = logging.getLogger(__name__)
class DockerRunner: class DockerRunner:
@@ -20,27 +22,29 @@ class DockerRunner:
self.docker_client = docker_client self.docker_client = docker_client
def __enter__(self): def __enter__(self):
logger.info(f"Creating container for image {self.image_name} ...") LOGGER.info(f"Creating container for image {self.image_name} ...")
self.container = self.docker_client.containers.run( self.container = self.docker_client.containers.run(
image=self.image_name, image=self.image_name,
command=self.command, command=self.command,
detach=True, detach=True,
) )
logger.info(f"Container {self.container.name} created") LOGGER.info(f"Container {self.container.name} created")
return self.container return self.container
def __exit__(self, exc_type, exc_value, traceback): def __exit__(self, exc_type, exc_value, traceback):
logger.info(f"Removing container {self.container.name} ...") LOGGER.info(f"Removing container {self.container.name} ...")
if self.container: if self.container:
self.container.remove(force=True) self.container.remove(force=True)
logger.info(f"Container {self.container.name} removed") LOGGER.info(f"Container {self.container.name} removed")
@staticmethod @staticmethod
def run_simple_command(container, cmd: str, print_result: bool = True): def run_simple_command(
logger.info(f"Running cmd: '{cmd}' on container: {container}") container: TrackedContainer, cmd: str, print_result: bool = True
):
LOGGER.info(f"Running cmd: '{cmd}' on container: {container}")
out = container.exec_run(cmd) out = container.exec_run(cmd)
result = out.output.decode("utf-8").rstrip() result = out.output.decode("utf-8").rstrip()
if print_result: if print_result:
logger.info(f"Command result: {result}") LOGGER.info(f"Command result: {result}")
assert out.exit_code == 0, f"Command: {cmd} failed" assert out.exit_code == 0, f"Command: {cmd} failed"
return result return result

View File

@@ -1,14 +1,10 @@
# Copyright (c) Jupyter Development Team. # Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License. # Distributed under the terms of the Modified BSD License.
import logging
from plumbum.cmd import docker from plumbum.cmd import docker
from .docker_runner import DockerRunner from .docker_runner import DockerRunner
from .git_helper import GitHelper from .git_helper import GitHelper
logger = logging.getLogger(__name__)
def quoted_output(container, cmd: str) -> str: def quoted_output(container, cmd: str) -> str:
return "\n".join( return "\n".join(
[ [

View File

@@ -9,7 +9,7 @@ from .get_taggers_and_manifests import get_taggers_and_manifests
from .github_set_env import github_set_env from .github_set_env import github_set_env
logger = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
def tag_image(short_image_name: str, owner: str) -> None: def tag_image(short_image_name: str, owner: str) -> None:
@@ -20,7 +20,7 @@ def tag_image(short_image_name: str, owner: str) -> None:
Tags are in a GitHub Actions environment also saved to environment variables Tags are in a GitHub Actions environment also saved to environment variables
in a format making it easy to append them. in a format making it easy to append them.
""" """
logger.info(f"Tagging image: {short_image_name}") LOGGER.info(f"Tagging image: {short_image_name}")
taggers, _ = get_taggers_and_manifests(short_image_name) taggers, _ = get_taggers_and_manifests(short_image_name)
image = f"{owner}/{short_image_name}:latest" image = f"{owner}/{short_image_name}:latest"
@@ -31,7 +31,7 @@ def tag_image(short_image_name: str, owner: str) -> None:
tagger_name = tagger.__name__ tagger_name = tagger.__name__
tag_value = tagger.tag_value(container) tag_value = tagger.tag_value(container)
tags.append(tag_value) tags.append(tag_value)
logger.info( LOGGER.info(
f"Applying tag tagger_name: {tagger_name} tag_value: {tag_value}" f"Applying tag tagger_name: {tagger_name} tag_value: {tag_value}"
) )
docker["tag", image, f"{owner}/{short_image_name}:{tag_value}"]() docker["tag", image, f"{owner}/{short_image_name}:{tag_value}"]()

View File

@@ -1,14 +1,10 @@
# Copyright (c) Jupyter Development Team. # Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License. # Distributed under the terms of the Modified BSD License.
from datetime import datetime from datetime import datetime
import logging
from .git_helper import GitHelper from .git_helper import GitHelper
from .docker_runner import DockerRunner from .docker_runner import DockerRunner
logger = logging.getLogger(__name__)
def _get_program_version(container, program: str) -> str: def _get_program_version(container, program: str) -> str:
return DockerRunner.run_simple_command(container, cmd=f"{program} --version") return DockerRunner.run_simple_command(container, cmd=f"{program} --version")

View File

@@ -27,25 +27,27 @@ from collections import defaultdict
from itertools import chain from itertools import chain
import logging import logging
import json import json
from typing import Optional
from tabulate import tabulate from tabulate import tabulate
from conftest import TrackedContainer
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
class CondaPackageHelper: class CondaPackageHelper:
"""Conda package helper permitting to get information about packages""" """Conda package helper permitting to get information about packages"""
def __init__(self, container): def __init__(self, container: TrackedContainer):
# if isinstance(container, TrackedContainer):
self.running_container = CondaPackageHelper.start_container(container) self.running_container = CondaPackageHelper.start_container(container)
self.specs = None self.requested: Optional[dict[str, set[str]]] = None
self.installed = None self.installed: Optional[dict[str, set[str]]] = None
self.available = None self.available: Optional[dict[str, set[str]]] = None
self.comparison = None self.comparison: list[dict[str, str]] = []
@staticmethod @staticmethod
def start_container(container): def start_container(container: TrackedContainer):
"""Start the TrackedContainer and return an instance of a running container""" """Start the TrackedContainer and return an instance of a running container"""
LOGGER.info(f"Starting container {container.image_name} ...") LOGGER.info(f"Starting container {container.image_name} ...")
return container.run( return container.run(
@@ -54,32 +56,34 @@ class CondaPackageHelper:
) )
@staticmethod @staticmethod
def _conda_export_command(from_history=False): def _conda_export_command(from_history: bool) -> list[str]:
"""Return the mamba export command with or without history""" """Return the mamba export command with or without history"""
cmd = ["mamba", "env", "export", "-n", "base", "--json", "--no-builds"] cmd = ["mamba", "env", "export", "-n", "base", "--json", "--no-builds"]
if from_history: if from_history:
cmd.append("--from-history") cmd.append("--from-history")
return cmd return cmd
def installed_packages(self): def installed_packages(self) -> dict[str, set[str]]:
"""Return the installed packages""" """Return the installed packages"""
if self.installed is None: if self.installed is None:
LOGGER.info("Grabing the list of installed packages ...") LOGGER.info("Grabing the list of installed packages ...")
self.installed = CondaPackageHelper._packages_from_json( self.installed = CondaPackageHelper._packages_from_json(
self._execute_command(CondaPackageHelper._conda_export_command()) self._execute_command(
CondaPackageHelper._conda_export_command(from_history=False)
)
) )
return self.installed return self.installed
def specified_packages(self): def requested_packages(self) -> dict[str, set[str]]:
"""Return the specifications (i.e. packages installation requested)""" """Return the requested package (i.e. `mamba install <package>`)"""
if self.specs is None: if self.requested is None:
LOGGER.info("Grabing the list of specifications ...") LOGGER.info("Grabing the list of manually requested packages ...")
self.specs = CondaPackageHelper._packages_from_json( self.requested = CondaPackageHelper._packages_from_json(
self._execute_command( self._execute_command(
CondaPackageHelper._conda_export_command(from_history=True) CondaPackageHelper._conda_export_command(from_history=True)
) )
) )
return self.specs return self.requested
def _execute_command(self, command): def _execute_command(self, command):
"""Execute a command on a running container""" """Execute a command on a running container"""
@@ -87,14 +91,14 @@ class CondaPackageHelper:
return rc.output.decode("utf-8") return rc.output.decode("utf-8")
@staticmethod @staticmethod
def _packages_from_json(env_export): def _packages_from_json(env_export) -> dict[str, set[str]]:
"""Extract packages and versions from the lines returned by the list of specifications""" """Extract packages and versions from the lines returned by the list of specifications"""
# dependencies = filter(lambda x: isinstance(x, str), json.loads(env_export).get("dependencies")) # dependencies = filter(lambda x: isinstance(x, str), json.loads(env_export).get("dependencies"))
dependencies = json.loads(env_export).get("dependencies") dependencies = json.loads(env_export).get("dependencies")
# Filtering packages installed through pip in this case it's a dict {'pip': ['toree==0.3.0']} # Filtering packages installed through pip in this case it's a dict {'pip': ['toree==0.3.0']}
# Since we only manage packages installed through mamba here # Since we only manage packages installed through mamba here
dependencies = filter(lambda x: isinstance(x, str), dependencies) dependencies = filter(lambda x: isinstance(x, str), dependencies)
packages_dict = dict() packages_dict: dict[str, set[str]] = dict()
for split in map(lambda x: re.split("=?=", x), dependencies): for split in map(lambda x: re.split("=?=", x), dependencies):
# default values # default values
package = split[0] package = split[0]
@@ -129,14 +133,16 @@ class CondaPackageHelper:
ddict[pkg].add(version) ddict[pkg].add(version)
return ddict return ddict
def check_updatable_packages(self, specifications_only=True): def check_updatable_packages(
self, requested_only: bool = True
) -> list[dict[str, str]]:
"""Check the updatable packages including or not dependencies""" """Check the updatable packages including or not dependencies"""
specs = self.specified_packages() requested = self.requested_packages()
installed = self.installed_packages() installed = self.installed_packages()
available = self.available_packages() available = self.available_packages()
self.comparison = list() self.comparison = []
for pkg, inst_vs in installed.items(): for pkg, inst_vs in installed.items():
if not specifications_only or pkg in specs: if not requested_only or pkg in requested:
avail_vs = sorted( avail_vs = sorted(
list(available[pkg]), key=CondaPackageHelper.semantic_cmp list(available[pkg]), key=CondaPackageHelper.semantic_cmp
) )
@@ -156,7 +162,7 @@ class CondaPackageHelper:
return self.comparison return self.comparison
@staticmethod @staticmethod
def semantic_cmp(version_string): def semantic_cmp(version_string: str):
"""Manage semantic versioning for comparison""" """Manage semantic versioning for comparison"""
def mysplit(string): def mysplit(string):
@@ -165,14 +171,14 @@ class CondaPackageHelper:
return list(chain(map(version_substrs, string.split(".")))) return list(chain(map(version_substrs, string.split("."))))
def str_ord(string): def str_ord(string: str) -> int:
num = 0 num = 0
for char in string: for char in string:
num *= 255 num *= 255
num += ord(char) num += ord(char)
return num return num
def try_int(version_str): def try_int(version_str: str) -> int:
try: try:
return int(version_str) return int(version_str)
except ValueError: except ValueError:
@@ -181,13 +187,13 @@ class CondaPackageHelper:
mss = list(chain(*mysplit(version_string))) mss = list(chain(*mysplit(version_string)))
return tuple(map(try_int, mss)) return tuple(map(try_int, mss))
def get_outdated_summary(self, specifications_only=True): def get_outdated_summary(self, requested_only: bool = True) -> str:
"""Return a summary of outdated packages""" """Return a summary of outdated packages"""
nb_packages = len(self.specs if specifications_only else self.installed) nb_packages = len(self.requested if requested_only else self.installed)
nb_updatable = len(self.comparison) nb_updatable = len(self.comparison)
updatable_ratio = nb_updatable / nb_packages updatable_ratio = nb_updatable / nb_packages
return f"{nb_updatable}/{nb_packages} ({updatable_ratio:.0%}) packages could be updated" return f"{nb_updatable}/{nb_packages} ({updatable_ratio:.0%}) packages could be updated"
def get_outdated_table(self): def get_outdated_table(self) -> str:
"""Return a table of outdated packages""" """Return a table of outdated packages"""
return tabulate(self.comparison, headers="keys") return tabulate(self.comparison, headers="keys")

View File

@@ -2,7 +2,13 @@
# Distributed under the terms of the Modified BSD License. # Distributed under the terms of the Modified BSD License.
def test_secured_server(container, http_client): import requests
from conftest import TrackedContainer
def test_secured_server(
container: TrackedContainer, http_client: requests.Session
) -> None:
"""Notebook server should eventually request user login.""" """Notebook server should eventually request user login."""
container.run() container.run()
resp = http_client.get("http://localhost:8888") resp = http_client.get("http://localhost:8888")

View File

@@ -4,6 +4,7 @@
import logging import logging
import pytest import pytest
from conftest import TrackedContainer
from helpers import CondaPackageHelper from helpers import CondaPackageHelper
@@ -11,10 +12,10 @@ LOGGER = logging.getLogger(__name__)
@pytest.mark.info @pytest.mark.info
def test_outdated_packages(container, specifications_only=True): def test_outdated_packages(container: TrackedContainer, requested_only: bool = True):
"""Getting the list of updatable packages""" """Getting the list of updatable packages"""
LOGGER.info(f"Checking outdated packages in {container.image_name} ...") LOGGER.info(f"Checking outdated packages in {container.image_name} ...")
pkg_helper = CondaPackageHelper(container) pkg_helper = CondaPackageHelper(container)
pkg_helper.check_updatable_packages(specifications_only) pkg_helper.check_updatable_packages(requested_only)
LOGGER.info(pkg_helper.get_outdated_summary(specifications_only)) LOGGER.info(pkg_helper.get_outdated_summary(requested_only))
LOGGER.info(f"\n{pkg_helper.get_outdated_table()}\n") LOGGER.info(f"\n{pkg_helper.get_outdated_table()}\n")

View File

@@ -12,9 +12,9 @@ The goal is to detect import errors that can be caused by incompatibilities betw
- #1012: issue importing `sympy` - #1012: issue importing `sympy`
- #966: isssue importing `pyarrow` - #966: isssue importing `pyarrow`
This module checks dynamically, through the `CondaPackageHelper`, only the specified packages i.e. packages requested by `mamba install` in the `Dockerfile`s. This module checks dynamically, through the `CondaPackageHelper`, only the requested packages i.e. packages requested by `mamba install` in the `Dockerfile`s.
This means that it does not check dependencies. This choice is a tradeoff to cover the main requirements while achieving reasonable test duration. This means that it does not check dependencies. This choice is a tradeoff to cover the main requirements while achieving reasonable test duration.
However it could be easily changed (or completed) to cover also dependencies `package_helper.installed_packages()` instead of `package_helper.specified_packages()`. However it could be easily changed (or completed) to cover also dependencies `package_helper.installed_packages()` instead of `package_helper.requested_packages()`.
Example: Example:
@@ -25,7 +25,7 @@ Example:
# --------------------------------------------------------------------------------------------- live log setup ---------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------- live log setup ----------------------------------------------------------------------------------------------
# 2020-03-08 09:56:04 [ INFO] Starting container jupyter/datascience-notebook ... (helpers.py:51) # 2020-03-08 09:56:04 [ INFO] Starting container jupyter/datascience-notebook ... (helpers.py:51)
# 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) # 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)
# 2020-03-08 09:56:04 [ INFO] Grabing the list of specifications ... (helpers.py:76) # 2020-03-08 09:56:04 [ INFO] Grabing the list of manually requested packages ... (helpers.py:76)
# ---------------------------------------------------------------------------------------------- live log call ---------------------------------------------------------------------------------------------- # ---------------------------------------------------------------------------------------------- live log call ----------------------------------------------------------------------------------------------
# 2020-03-08 09:56:07 [ INFO] Testing the import of packages ... (test_packages.py:125) # 2020-03-08 09:56:07 [ INFO] Testing the import of packages ... (test_packages.py:125)
# 2020-03-08 09:56:07 [ INFO] Trying to import conda (test_packages.py:127) # 2020-03-08 09:56:07 [ INFO] Trying to import conda (test_packages.py:127)
@@ -38,6 +38,7 @@ Example:
import logging import logging
import pytest import pytest
from conftest import TrackedContainer
from helpers import CondaPackageHelper from helpers import CondaPackageHelper
@@ -75,57 +76,66 @@ EXCLUDED_PACKAGES = [
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
def package_helper(container): def package_helper(container: TrackedContainer) -> CondaPackageHelper:
"""Return a package helper object that can be used to perform tests on installed packages""" """Return a package helper object that can be used to perform tests on installed packages"""
return CondaPackageHelper(container) return CondaPackageHelper(container)
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
def packages(package_helper): def packages(package_helper: CondaPackageHelper) -> dict[str, set[str]]:
"""Return the list of specified packages (i.e. packages explicitely installed excluding dependencies)""" """Return the list of requested packages (i.e. packages explicitly installed excluding dependencies)"""
return package_helper.specified_packages() return package_helper.requested_packages()
def package_map(package): def package_map(package: str) -> str:
"""Perform a mapping between the python package name and the name used for the import""" """Perform a mapping between the python package name and the name used for the import"""
return PACKAGE_MAPPING.get(package, package) return PACKAGE_MAPPING.get(package, package)
def excluded_package_predicate(package): def excluded_package_predicate(package: str) -> bool:
"""Return whether a package is excluded from the list (i.e. a package that cannot be tested with standard imports)""" """Return whether a package is excluded from the list (i.e. a package that cannot be tested with standard imports)"""
return package in EXCLUDED_PACKAGES return package in EXCLUDED_PACKAGES
def python_package_predicate(package): def python_package_predicate(package: str) -> bool:
"""Predicate matching python packages""" """Predicate matching python packages"""
return not excluded_package_predicate(package) and not r_package_predicate(package) return not excluded_package_predicate(package) and not r_package_predicate(package)
def r_package_predicate(package): def r_package_predicate(package: str) -> bool:
"""Predicate matching R packages""" """Predicate matching R packages"""
return not excluded_package_predicate(package) and package.startswith("r-") return not excluded_package_predicate(package) and package.startswith("r-")
def _check_import_package(package_helper, command): def _check_import_package(
package_helper: CondaPackageHelper, command: list[str]
) -> int:
"""Generic function executing a command""" """Generic function executing a command"""
LOGGER.debug(f"Trying to import a package with [{command}] ...") LOGGER.debug(f"Trying to import a package with [{command}] ...")
rc = package_helper.running_container.exec_run(command) rc = package_helper.running_container.exec_run(command)
return rc.exit_code return rc.exit_code
def check_import_python_package(package_helper, package): def check_import_python_package(
package_helper: CondaPackageHelper, package: str
) -> int:
"""Try to import a Python package from the command line""" """Try to import a Python package from the command line"""
return _check_import_package(package_helper, ["python", "-c", f"import {package}"]) return _check_import_package(package_helper, ["python", "-c", f"import {package}"])
def check_import_r_package(package_helper, package): def check_import_r_package(package_helper: CondaPackageHelper, package: str) -> int:
"""Try to import a R package from the command line""" """Try to import a R package from the command line"""
return _check_import_package( return _check_import_package(
package_helper, ["R", "--slave", "-e", f"library({package})"] package_helper, ["R", "--slave", "-e", f"library({package})"]
) )
def _import_packages(package_helper, filtered_packages, check_function, max_failures): def _import_packages(
package_helper: CondaPackageHelper,
filtered_packages: dict[str, set[str]],
check_function,
max_failures: int,
) -> None:
"""Test if packages can be imported """Test if packages can be imported
Note: using a list of packages instead of a fixture for the list of packages since pytest prevents use of multiple yields Note: using a list of packages instead of a fixture for the list of packages since pytest prevents use of multiple yields
@@ -147,7 +157,7 @@ def _import_packages(package_helper, filtered_packages, check_function, max_fail
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
def r_packages(packages): def r_packages(packages: dict[str, set[str]]):
"""Return an iterable of R packages""" """Return an iterable of R packages"""
# package[2:] is to remove the leading "r-" appended on R packages # package[2:] is to remove the leading "r-" appended on R packages
return map( return map(
@@ -155,21 +165,25 @@ def r_packages(packages):
) )
def test_python_packages(package_helper, python_packages, max_failures=0): def test_r_packages(
"""Test the import of specified python packages""" package_helper: CondaPackageHelper, r_packages, max_failures: int = 0
return _import_packages( ):
package_helper, python_packages, check_import_python_package, max_failures
)
@pytest.fixture(scope="function")
def python_packages(packages):
"""Return an iterable of Python packages"""
return map(package_map, filter(python_package_predicate, packages))
def test_r_packages(package_helper, r_packages, max_failures=0):
"""Test the import of specified R packages""" """Test the import of specified R packages"""
return _import_packages( return _import_packages(
package_helper, r_packages, check_import_r_package, max_failures package_helper, r_packages, check_import_r_package, max_failures
) )
@pytest.fixture(scope="function")
def python_packages(packages: dict[str, set[str]]):
"""Return an iterable of Python packages"""
return map(package_map, filter(python_package_predicate, packages))
def test_python_packages(
package_helper: CondaPackageHelper, python_packages, max_failures: int = 0
):
"""Test the import of specified python packages"""
return _import_packages(
package_helper, python_packages, check_import_python_package, max_failures
)

View File

@@ -2,26 +2,28 @@
# Distributed under the terms of the Modified BSD License. # Distributed under the terms of the Modified BSD License.
import logging import logging
import os from pathlib import Path
from conftest import TrackedContainer
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
THIS_DIR = os.path.dirname(os.path.realpath(__file__)) THIS_DIR = Path(__file__).absolute()
def test_units(container): def test_units(container: TrackedContainer) -> None:
"""Various units tests """Various units tests
Add a py file in the {image}/test/units dir and it will be automatically tested Add a py file in the {image}/test/units dir and it will be automatically tested
""" """
short_image_name = container.image_name[container.image_name.rfind("/") + 1 :] short_image_name = container.image_name[container.image_name.rfind("/") + 1 :]
host_data_dir = os.path.join(THIS_DIR, f"../{short_image_name}/test/units") host_data_dir = THIS_DIR / f"../{short_image_name}/test/units"
LOGGER.info(f"Searching for units tests in {host_data_dir}") LOGGER.info(f"Searching for units tests in {host_data_dir}")
cont_data_dir = "/home/jovyan/data" cont_data_dir = "/home/jovyan/data"
if not os.path.exists(host_data_dir): if not host_data_dir.exists():
LOGGER.info(f"Not found unit tests for image: {container.image_name}") LOGGER.info(f"Not found unit tests for image: {container.image_name}")
return return
for test_file in os.listdir(host_data_dir): for test_file in host_data_dir.iterdir():
LOGGER.info(f"Running unit test: {test_file}") LOGGER.info(f"Running unit test: {test_file}")
c = container.run( c = container.run(