From c7e3434b3e7d814c00e9609db4bb2a54d8eb8bab Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Fri, 16 Jul 2021 15:06:08 +0200 Subject: [PATCH 1/8] Added `stdout`, `stderr` argument to BmiClientSingularity constructor --- grpc4bmi/bmi_client_singularity.py | 45 ++++++++++++++++++++++++++---- test/test_singularity.py | 34 +++++++++++++++++++++- 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/grpc4bmi/bmi_client_singularity.py b/grpc4bmi/bmi_client_singularity.py index 8d852a7..48bb3a1 100644 --- a/grpc4bmi/bmi_client_singularity.py +++ b/grpc4bmi/bmi_client_singularity.py @@ -1,9 +1,9 @@ +import logging import os +import subprocess import time from os.path import abspath -import subprocess -import logging -from typing import Iterable +from typing import Iterable, BinaryIO, TextIO, Union, Literal import semver from typeguard import check_argument_types, qualified_name @@ -23,6 +23,21 @@ def check_singularity_version(): return True +class DeadSingularityContainerException(ChildProcessError): + """ + Exception for when a Docker container has died. + + Args: + message (str): Human readable error message + exitcode (int): The non-zero exit code of the container + + """ + def __init__(self, message, exitcode, *args): + super().__init__(message, *args) + #: Exit code of container + self.exitcode = exitcode + + class BmiClientSingularity(BmiClient): """BMI GRPC client for singularity server processes During initialization launches a singularity container with run-bmi-server as its command. @@ -76,6 +91,19 @@ class BmiClientSingularity(BmiClient): By default will try forever to connect to gRPC server inside container. Set to low number to escape endless wait. + stderr (Union[None, BinaryIO, TextIO, int]): Redirect stderr of singularity container. + + By default will inherit stderr file handle from current Python process. + Can be set to a file object to log stdout to a file. + Or can be set to `subprocess.DEVNULL` to redirect to null device never to be seen again. + Or can be set to `subprocess.STDOUT` to redirect the stderr to stdout. + + stdout (Union[None, BinaryIO, TextIO, int]): Redirect stdout of singularity container. + + By default will inherit stdout file handle from current Python process. + Can be set to a file object to log stdout to a file. + Or can be set to `subprocess.DEVNULL` to redirect to null device never to be seen again. + **Example 1: Config file already inside image** MARRMoT has an `example config file `_ inside its Docker image. @@ -176,7 +204,11 @@ class BmiClientSingularity(BmiClient): del client_rhine """ - def __init__(self, image: str, work_dir: str, input_dirs: Iterable[str] = tuple(), delay=0, timeout=None): + + def __init__(self, image: str, work_dir: str, input_dirs: Iterable[str] = tuple(), delay=0, timeout=None, + stderr: Union[None, BinaryIO, TextIO, int] = None, + stdout: Union[None, BinaryIO, TextIO, int] = None + ): assert check_argument_types() if type(input_dirs) == str: msg = f'type of argument "input_dirs" must be collections.abc.Iterable; ' \ @@ -207,8 +239,11 @@ def __init__(self, image: str, work_dir: str, input_dirs: Iterable[str] = tuple( args += ["--pwd", self.work_dir] args.append(image) logging.info(f'Running {image} singularity container on port {port}') - self.container = subprocess.Popen(args, preexec_fn=os.setsid) + self.container = subprocess.Popen(args, preexec_fn=os.setsid, stderr=stderr, stdout=stdout) time.sleep(delay) + returncode = self.container.poll() + if returncode is not None: + raise DeadSingularityContainerException(f'singularity container {image} prematurely exited with code {returncode}', returncode) super(BmiClientSingularity, self).__init__(BmiClient.create_grpc_channel(port=port, host=host), timeout=timeout) def __del__(self): diff --git a/test/test_singularity.py b/test/test_singularity.py index 4a42d04..ce05a38 100644 --- a/test/test_singularity.py +++ b/test/test_singularity.py @@ -1,3 +1,4 @@ +import os import subprocess from tempfile import TemporaryDirectory from textwrap import dedent @@ -7,7 +8,7 @@ from nbconvert.preprocessors import ExecutePreprocessor from nbformat.v4 import new_notebook, new_code_cell -from grpc4bmi.bmi_client_singularity import BmiClientSingularity +from grpc4bmi.bmi_client_singularity import BmiClientSingularity, DeadSingularityContainerException from test.conftest import write_config, write_datafile IMAGE_NAME = "docker://ewatercycle/walrus-grpc4bmi:v0.2.0" @@ -175,6 +176,37 @@ def test_inputdirs_as_number(self, tmp_path): BmiClientSingularity(image=IMAGE_NAME, input_dirs=42, work_dir=some_dir) +class TestRedirectOutput: + EXPECTED = 'Hello from Docker!' + + @pytest.fixture + def image(self): + hello_image = 'docker://hello-world' + # Cache image, first test does not use delay time to build image + os.system(f'singularity run {hello_image}') + return hello_image + + def test_default(self, image, tmp_path, capfd): + with pytest.raises(DeadSingularityContainerException): + BmiClientSingularity(image=image, work_dir=str(tmp_path), delay=2) + + assert self.EXPECTED in capfd.readouterr().out + + def test_textfile(self, image, tmp_path): + mylog = tmp_path / 'mylog.txt' + + with mylog.open('w') as f, pytest.raises(DeadSingularityContainerException): + BmiClientSingularity(image=image, work_dir=str(tmp_path), stdout=f, delay=2) + + assert self.EXPECTED in mylog.read_text() + + def test_devnull(self, image, tmp_path, capfd): + with pytest.raises(DeadSingularityContainerException): + BmiClientSingularity(image=image, work_dir=str(tmp_path), stdout=subprocess.DEVNULL, delay=2) + + assert self.EXPECTED not in capfd.readouterr().out + + @pytest.fixture def notebook(tmp_path): tmp_path.mkdir(exist_ok=True) From 20f23fc0c4811d0d5abc7241d223417b2ba984b9 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Fri, 16 Jul 2021 15:07:42 +0200 Subject: [PATCH 2/8] Use correct name --- grpc4bmi/bmi_client_singularity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grpc4bmi/bmi_client_singularity.py b/grpc4bmi/bmi_client_singularity.py index 48bb3a1..aeb8239 100644 --- a/grpc4bmi/bmi_client_singularity.py +++ b/grpc4bmi/bmi_client_singularity.py @@ -25,7 +25,7 @@ def check_singularity_version(): class DeadSingularityContainerException(ChildProcessError): """ - Exception for when a Docker container has died. + Exception for when a Singularity container has died. Args: message (str): Human readable error message From d6e632662f379c31e149f9ebdf76b6268a1a975c Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Fri, 16 Jul 2021 15:15:01 +0200 Subject: [PATCH 3/8] Unused import --- grpc4bmi/bmi_client_singularity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grpc4bmi/bmi_client_singularity.py b/grpc4bmi/bmi_client_singularity.py index aeb8239..0ee0824 100644 --- a/grpc4bmi/bmi_client_singularity.py +++ b/grpc4bmi/bmi_client_singularity.py @@ -3,7 +3,7 @@ import subprocess import time from os.path import abspath -from typing import Iterable, BinaryIO, TextIO, Union, Literal +from typing import Iterable, BinaryIO, TextIO, Union import semver from typeguard import check_argument_types, qualified_name From bca8189dee626e90616defbbb50711ee341e1d93 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Mon, 19 Jul 2021 15:43:14 +0200 Subject: [PATCH 4/8] Silence semver deprecation warning --- grpc4bmi/bmi_client_singularity.py | 8 +++++--- setup.py | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/grpc4bmi/bmi_client_singularity.py b/grpc4bmi/bmi_client_singularity.py index 0ee0824..3957b75 100644 --- a/grpc4bmi/bmi_client_singularity.py +++ b/grpc4bmi/bmi_client_singularity.py @@ -10,7 +10,7 @@ from grpc4bmi.bmi_grpc_client import BmiClient -REQUIRED_SINGULARITY_VERSION = '>=3.6.0' +REQUIRED_SINGULARITY_VERSION = '3.6.0' def check_singularity_version(): @@ -18,8 +18,10 @@ def check_singularity_version(): (stdout, _stderr) = p.communicate() if p.returncode != 0: raise Exception('Unable to determine singularity version') - if not semver.match(stdout.decode('utf-8').replace('_', '-'), REQUIRED_SINGULARITY_VERSION): - raise Exception(f'Wrong version of singularity found, require version {REQUIRED_SINGULARITY_VERSION}') + local_version = semver.VersionInfo.parse(stdout.decode('utf-8').replace('_', '-')) + if local_version < REQUIRED_SINGULARITY_VERSION: + raise Exception(f'Wrong version ({local_version}) of singularity found, ' + f'require version {REQUIRED_SINGULARITY_VERSION}') return True diff --git a/setup.py b/setup.py index 3342c21..266e5f3 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ def read(fname): "numpy", "docker", "basic-modeling-interface", - "semver", + "semver>=2.10.0", "typeguard", ], extras_require={ From e55e7dcfb07da6700ee5d9233ce390a3a992a8cf Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Mon, 19 Jul 2021 15:43:42 +0200 Subject: [PATCH 5/8] Add logs() to BmiClientSingularity and BmiClientDocker In BmiClientSingularity: Removed stderr+stdout passthrough in favor of grpc4bmi capturing everything by default with option to disable log capture --- grpc4bmi/bmi_client_docker.py | 9 +++++ grpc4bmi/bmi_client_singularity.py | 63 +++++++++++++++++++++--------- test/test_docker.py | 6 +++ test/test_singularity.py | 18 +++------ 4 files changed, 66 insertions(+), 30 deletions(-) diff --git a/grpc4bmi/bmi_client_docker.py b/grpc4bmi/bmi_client_docker.py index 065f78f..a37c1b5 100644 --- a/grpc4bmi/bmi_client_docker.py +++ b/grpc4bmi/bmi_client_docker.py @@ -19,6 +19,7 @@ class DeadDockerContainerException(ChildProcessError): logs (str): Logs the container produced """ + def __init__(self, message, exitcode, logs, *args): super().__init__(message, *args) #: Exit code of container @@ -62,6 +63,7 @@ class BmiClientDocker(BmiClient): See :py:class:`grpc4bmi.bmi_client_singularity.BmiClientSingularity` for examples using `input_dirs` and `work_dir`. """ + def __init__(self, image: str, work_dir: str, image_port=50051, host=None, input_dirs: Iterable[str] = tuple(), user=os.getuid(), remove=False, delay=5, @@ -111,3 +113,10 @@ def __del__(self): def get_value_ref(self, var_name): raise NotImplementedError("Cannot exchange memory references across process boundary") + + def logs(self) -> str: + """Returns complete combined stdout and stderr written by the Docker container. + """ + if hasattr(self, "container"): + return self.container.logs().decode('utf8') + return '' diff --git a/grpc4bmi/bmi_client_singularity.py b/grpc4bmi/bmi_client_singularity.py index 3957b75..673b17d 100644 --- a/grpc4bmi/bmi_client_singularity.py +++ b/grpc4bmi/bmi_client_singularity.py @@ -3,7 +3,8 @@ import subprocess import time from os.path import abspath -from typing import Iterable, BinaryIO, TextIO, Union +from tempfile import SpooledTemporaryFile +from typing import Iterable import semver from typeguard import check_argument_types, qualified_name @@ -32,12 +33,15 @@ class DeadSingularityContainerException(ChildProcessError): Args: message (str): Human readable error message exitcode (int): The non-zero exit code of the container + logs (str): Logs the container produced """ - def __init__(self, message, exitcode, *args): + def __init__(self, message, exitcode, logs, *args): super().__init__(message, *args) #: Exit code of container self.exitcode = exitcode + #: Stdout and stderr of container + self.logs = logs class BmiClientSingularity(BmiClient): @@ -93,18 +97,11 @@ class BmiClientSingularity(BmiClient): By default will try forever to connect to gRPC server inside container. Set to low number to escape endless wait. - stderr (Union[None, BinaryIO, TextIO, int]): Redirect stderr of singularity container. + capture_logs (bool): Whether to capture stdout and stderr of container . - By default will inherit stderr file handle from current Python process. - Can be set to a file object to log stdout to a file. - Or can be set to `subprocess.DEVNULL` to redirect to null device never to be seen again. - Or can be set to `subprocess.STDOUT` to redirect the stderr to stdout. - - stdout (Union[None, BinaryIO, TextIO, int]): Redirect stdout of singularity container. - - By default will inherit stdout file handle from current Python process. - Can be set to a file object to log stdout to a file. - Or can be set to `subprocess.DEVNULL` to redirect to null device never to be seen again. + If false then redirects output to null device never to be seen again. + If true then redirects output to temporary file which can be read with :py:func:`BmiClientSingularity.logs()`. + The temporary file gets removed when this object is deleted. **Example 1: Config file already inside image** @@ -208,8 +205,7 @@ class BmiClientSingularity(BmiClient): """ def __init__(self, image: str, work_dir: str, input_dirs: Iterable[str] = tuple(), delay=0, timeout=None, - stderr: Union[None, BinaryIO, TextIO, int] = None, - stdout: Union[None, BinaryIO, TextIO, int] = None + capture_logs=True, ): assert check_argument_types() if type(input_dirs) == str: @@ -232,7 +228,7 @@ def __init__(self, image: str, work_dir: str, input_dirs: Iterable[str] = tuple( raise NotADirectoryError(input_dir) args += ["--bind", f'{input_dir}:{input_dir}:ro'] self.work_dir = abspath(work_dir) - if self.work_dir in set([abspath(d) for d in input_dirs]): + if self.work_dir in {abspath(d) for d in input_dirs}: raise ValueError('Found work_dir equal to one of the input directories. Please drop that input dir.') if not os.path.isdir(self.work_dir): raise NotADirectoryError(self.work_dir) @@ -241,17 +237,48 @@ def __init__(self, image: str, work_dir: str, input_dirs: Iterable[str] = tuple( args += ["--pwd", self.work_dir] args.append(image) logging.info(f'Running {image} singularity container on port {port}') - self.container = subprocess.Popen(args, preexec_fn=os.setsid, stderr=stderr, stdout=stdout) + if capture_logs: + self.logfile = SpooledTemporaryFile(max_size=2**16, # keep until 65Kb in memory if bigger write to disk + prefix='grpc4bmi-singularity-log', + mode='w+t', + encoding='utf8') + stdout = self.logfile + else: + stdout = subprocess.DEVNULL + self.container = subprocess.Popen(args, preexec_fn=os.setsid, stderr=subprocess.STDOUT, stdout=stdout) time.sleep(delay) returncode = self.container.poll() if returncode is not None: - raise DeadSingularityContainerException(f'singularity container {image} prematurely exited with code {returncode}', returncode) + raise DeadSingularityContainerException( + f'singularity container {image} prematurely exited with code {returncode}', + returncode, + self.logs() + ) super(BmiClientSingularity, self).__init__(BmiClient.create_grpc_channel(port=port, host=host), timeout=timeout) def __del__(self): if hasattr(self, "container"): self.container.terminate() self.container.wait() + if hasattr(self, "logfile"): + # Force deletion of log file + self.logfile.close() def get_value_ref(self, var_name): raise NotImplementedError("Cannot exchange memory references across process boundary") + + def logs(self) -> str: + """Returns complete combined stdout and stderr written by the Singularity container. + + When object was created with `log_enable=False` argument then always returns empty string. + """ + if not hasattr(self, "logfile"): + return '' + + current_position = self.logfile.tell() + # Read from start + self.logfile.seek(0) + content = self.logfile.read() + # Write from last position + self.logfile.seek(current_position) + return content diff --git a/test/test_docker.py b/test/test_docker.py index 522d66a..9ba7bff 100644 --- a/test/test_docker.py +++ b/test/test_docker.py @@ -105,3 +105,9 @@ def test_inputdirs_as_number(self, tmp_path): some_dir = str(tmp_path) with pytest.raises(TypeError, match='must be collections.abc.Iterable; got int instead'): BmiClientDocker(image=walrus_docker_image, input_dirs=42, work_dir=some_dir) + + def test_logs(self, walrus_model, capfd): + logs = walrus_model.logs() + + assert 'R[write to console]' in logs + assert 'R[write to console]' not in capfd.readouterr().out diff --git a/test/test_singularity.py b/test/test_singularity.py index ce05a38..378c962 100644 --- a/test/test_singularity.py +++ b/test/test_singularity.py @@ -187,24 +187,18 @@ def image(self): return hello_image def test_default(self, image, tmp_path, capfd): - with pytest.raises(DeadSingularityContainerException): + with pytest.raises(DeadSingularityContainerException) as excinf: BmiClientSingularity(image=image, work_dir=str(tmp_path), delay=2) - assert self.EXPECTED in capfd.readouterr().out - - def test_textfile(self, image, tmp_path): - mylog = tmp_path / 'mylog.txt' - - with mylog.open('w') as f, pytest.raises(DeadSingularityContainerException): - BmiClientSingularity(image=image, work_dir=str(tmp_path), stdout=f, delay=2) - - assert self.EXPECTED in mylog.read_text() + assert self.EXPECTED not in capfd.readouterr().out + assert self.EXPECTED in excinf.value.logs def test_devnull(self, image, tmp_path, capfd): - with pytest.raises(DeadSingularityContainerException): - BmiClientSingularity(image=image, work_dir=str(tmp_path), stdout=subprocess.DEVNULL, delay=2) + with pytest.raises(DeadSingularityContainerException) as excinf: + BmiClientSingularity(image=image, work_dir=str(tmp_path), capture_logs=False, delay=2) assert self.EXPECTED not in capfd.readouterr().out + assert self.EXPECTED not in excinf.value.logs @pytest.fixture From 659721a46dc41e0c279b0ba9bfeb45f83bd93477 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Tue, 20 Jul 2021 09:23:22 +0200 Subject: [PATCH 6/8] Prep for 0.2.13 release --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 266e5f3..2cc1766 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ def read(fname): setup(name="grpc4bmi", - version="0.2.12", + version="0.2.13", author="Gijs van den Oord", author_email="g.vandenoord@esciencecenter.nl", description="Run your BMI implementation in a separate process and expose it as BMI-python with GRPC", From e9a38b5390d7d5d5a8bce951635cc00f6b5a9a13 Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Tue, 20 Jul 2021 09:24:18 +0200 Subject: [PATCH 7/8] Use own method --- grpc4bmi/bmi_client_docker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grpc4bmi/bmi_client_docker.py b/grpc4bmi/bmi_client_docker.py index a37c1b5..f49b777 100644 --- a/grpc4bmi/bmi_client_docker.py +++ b/grpc4bmi/bmi_client_docker.py @@ -101,7 +101,7 @@ def __init__(self, image: str, work_dir: str, image_port=50051, host=None, self.container.reload() if self.container.status == 'exited': exitcode = self.container.attrs["State"]["ExitCode"] - logs = self.container.logs() + logs = self.logs() msg = f'Failed to start Docker container with image {image}, Container log: {logs}' raise DeadDockerContainerException(msg, exitcode, logs) From 6001693b17a303dba46d92219464eb6917b0aeee Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Tue, 20 Jul 2021 09:36:17 +0200 Subject: [PATCH 8/8] Dedup container exception --- grpc4bmi/bmi_client_docker.py | 22 ++-------------------- grpc4bmi/bmi_client_singularity.py | 29 ++++++----------------------- grpc4bmi/exceptions.py | 21 +++++++++++++++++++++ test/test_docker.py | 9 +++++---- test/test_singularity.py | 7 ++++--- 5 files changed, 38 insertions(+), 50 deletions(-) create mode 100644 grpc4bmi/exceptions.py diff --git a/grpc4bmi/bmi_client_docker.py b/grpc4bmi/bmi_client_docker.py index f49b777..8ccc23b 100644 --- a/grpc4bmi/bmi_client_docker.py +++ b/grpc4bmi/bmi_client_docker.py @@ -7,25 +7,7 @@ from typeguard import check_argument_types, qualified_name from grpc4bmi.bmi_grpc_client import BmiClient - - -class DeadDockerContainerException(ChildProcessError): - """ - Exception for when a Docker container has died. - - Args: - message (str): Human readable error message - exitcode (int): The non-zero exit code of the container - logs (str): Logs the container produced - - """ - - def __init__(self, message, exitcode, logs, *args): - super().__init__(message, *args) - #: Exit code of container - self.exitcode = exitcode - #: Stdout and stderr of container - self.logs = logs +from grpc4bmi.exceptions import DeadContainerException class BmiClientDocker(BmiClient): @@ -103,7 +85,7 @@ def __init__(self, image: str, work_dir: str, image_port=50051, host=None, exitcode = self.container.attrs["State"]["ExitCode"] logs = self.logs() msg = f'Failed to start Docker container with image {image}, Container log: {logs}' - raise DeadDockerContainerException(msg, exitcode, logs) + raise DeadContainerException(msg, exitcode, logs) super(BmiClientDocker, self).__init__(BmiClient.create_grpc_channel(port=port, host=host), timeout=timeout) diff --git a/grpc4bmi/bmi_client_singularity.py b/grpc4bmi/bmi_client_singularity.py index 673b17d..e0928c6 100644 --- a/grpc4bmi/bmi_client_singularity.py +++ b/grpc4bmi/bmi_client_singularity.py @@ -10,6 +10,7 @@ from typeguard import check_argument_types, qualified_name from grpc4bmi.bmi_grpc_client import BmiClient +from grpc4bmi.exceptions import DeadContainerException, SingularityVersionException REQUIRED_SINGULARITY_VERSION = '3.6.0' @@ -18,32 +19,14 @@ def check_singularity_version(): p = subprocess.Popen(['singularity', 'version'], stdout=subprocess.PIPE) (stdout, _stderr) = p.communicate() if p.returncode != 0: - raise Exception('Unable to determine singularity version') + raise SingularityVersionException('Unable to determine singularity version') local_version = semver.VersionInfo.parse(stdout.decode('utf-8').replace('_', '-')) if local_version < REQUIRED_SINGULARITY_VERSION: - raise Exception(f'Wrong version ({local_version}) of singularity found, ' - f'require version {REQUIRED_SINGULARITY_VERSION}') + raise SingularityVersionException(f'Wrong version ({local_version}) of singularity found, ' + f'require version {REQUIRED_SINGULARITY_VERSION}') return True -class DeadSingularityContainerException(ChildProcessError): - """ - Exception for when a Singularity container has died. - - Args: - message (str): Human readable error message - exitcode (int): The non-zero exit code of the container - logs (str): Logs the container produced - - """ - def __init__(self, message, exitcode, logs, *args): - super().__init__(message, *args) - #: Exit code of container - self.exitcode = exitcode - #: Stdout and stderr of container - self.logs = logs - - class BmiClientSingularity(BmiClient): """BMI GRPC client for singularity server processes During initialization launches a singularity container with run-bmi-server as its command. @@ -238,7 +221,7 @@ def __init__(self, image: str, work_dir: str, input_dirs: Iterable[str] = tuple( args.append(image) logging.info(f'Running {image} singularity container on port {port}') if capture_logs: - self.logfile = SpooledTemporaryFile(max_size=2**16, # keep until 65Kb in memory if bigger write to disk + self.logfile = SpooledTemporaryFile(max_size=2 ** 16, # keep until 65Kb in memory if bigger write to disk prefix='grpc4bmi-singularity-log', mode='w+t', encoding='utf8') @@ -249,7 +232,7 @@ def __init__(self, image: str, work_dir: str, input_dirs: Iterable[str] = tuple( time.sleep(delay) returncode = self.container.poll() if returncode is not None: - raise DeadSingularityContainerException( + raise DeadContainerException( f'singularity container {image} prematurely exited with code {returncode}', returncode, self.logs() diff --git a/grpc4bmi/exceptions.py b/grpc4bmi/exceptions.py new file mode 100644 index 0000000..9d63cd9 --- /dev/null +++ b/grpc4bmi/exceptions.py @@ -0,0 +1,21 @@ + +class DeadContainerException(ChildProcessError): + """ + Exception for when a container has died. + + Args: + message (str): Human readable error message + exitcode (int): The non-zero exit code of the container + logs (str): Logs the container produced + + """ + def __init__(self, message, exitcode, logs, *args): + super().__init__(message, *args) + #: Exit code of container + self.exitcode = exitcode + #: Stdout and stderr of container + self.logs = logs + + +class SingularityVersionException(ValueError): + pass diff --git a/test/test_docker.py b/test/test_docker.py index 9ba7bff..ce79d6f 100644 --- a/test/test_docker.py +++ b/test/test_docker.py @@ -3,7 +3,8 @@ import docker import pytest -from grpc4bmi.bmi_client_docker import BmiClientDocker, DeadDockerContainerException +from grpc4bmi.bmi_client_docker import BmiClientDocker +from grpc4bmi.exceptions import DeadContainerException walrus_docker_image = 'ewatercycle/walrus-grpc4bmi:v0.2.0' @@ -79,12 +80,12 @@ def test_workdir_absent(self, tmp_path): def test_container_start_failure(self, exit_container, tmp_path): expected = r"Failed to start Docker container with image" - with pytest.raises(DeadDockerContainerException, match=expected) as excinfo: + with pytest.raises(DeadContainerException, match=expected) as excinfo: BmiClientDocker(image=exit_container, work_dir=str(tmp_path)) assert excinfo.value.exitcode == 25 - assert b'my stderr' in excinfo.value.logs - assert b'my stdout' in excinfo.value.logs + assert 'my stderr' in excinfo.value.logs + assert 'my stdout' in excinfo.value.logs def test_same_inputdir_and_workdir(self, tmp_path): some_dir = str(tmp_path) diff --git a/test/test_singularity.py b/test/test_singularity.py index 378c962..b593a9c 100644 --- a/test/test_singularity.py +++ b/test/test_singularity.py @@ -8,7 +8,8 @@ from nbconvert.preprocessors import ExecutePreprocessor from nbformat.v4 import new_notebook, new_code_cell -from grpc4bmi.bmi_client_singularity import BmiClientSingularity, DeadSingularityContainerException +from grpc4bmi.bmi_client_singularity import BmiClientSingularity +from grpc4bmi.exceptions import DeadContainerException from test.conftest import write_config, write_datafile IMAGE_NAME = "docker://ewatercycle/walrus-grpc4bmi:v0.2.0" @@ -187,14 +188,14 @@ def image(self): return hello_image def test_default(self, image, tmp_path, capfd): - with pytest.raises(DeadSingularityContainerException) as excinf: + with pytest.raises(DeadContainerException) as excinf: BmiClientSingularity(image=image, work_dir=str(tmp_path), delay=2) assert self.EXPECTED not in capfd.readouterr().out assert self.EXPECTED in excinf.value.logs def test_devnull(self, image, tmp_path, capfd): - with pytest.raises(DeadSingularityContainerException) as excinf: + with pytest.raises(DeadContainerException) as excinf: BmiClientSingularity(image=image, work_dir=str(tmp_path), capture_logs=False, delay=2) assert self.EXPECTED not in capfd.readouterr().out