Skip to content

Commit aef5f04

Browse files
authored
Merge pull request #58 from LUMC/release_1.1.0
Release 1.1.0
2 parents ff1d262 + 9c1d106 commit aef5f04

File tree

11 files changed

+361
-35
lines changed

11 files changed

+361
-35
lines changed

.travis.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,5 +52,5 @@ matrix:
5252
- conda config --add channels defaults
5353
- conda config --add channels bioconda
5454
- conda config --add channels conda-forge
55-
- conda create -n my_env cromwell
55+
- conda create -n my_env cromwell tox # Install tox for good integration within the conda env.
5656
- source activate my_env

HISTORY.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ Changelog
77
.. NOTE: This document is user facing. Please word the changes in such a way
88
.. that users understand how the changes affect the new version.
99
10+
version 1.1.0
11+
---------------------------
12+
+ Enabled custom tests on workflow files.
13+
1014
Version 1.0.0
1115
---------------------------
1216
Lots of small fixes that improve the usability of pytest-workflow are included

README.rst

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ pytest-workflow
44

55
.. Badges have empty alts. So nothing shows up if they do not work.
66
.. This fixes readthedocs issues with badges.
7+
.. image:: https://img.shields.io/pypi/v/pytest-workflow.svg
8+
:target: https://pypi.org/project/pytest-workflow/
9+
:alt:
10+
11+
.. image:: https://img.shields.io/pypi/pyversions/pytest-workflow.svg
12+
:target: https://pypi.org/project/pytest-workflow/
13+
:alt:
14+
715
.. image:: https://api.codacy.com/project/badge/Grade/f8bc14b0a507429eac7c06194fafcd59
816
:target: https://www.codacy.com/app/LUMC/pytest-workflow?utm_source=github.com&utm_medium=referral&utm_content=LUMC/pytest-workflow&utm_campaign=Badge_Grade
917
:alt:
@@ -16,10 +24,6 @@ pytest-workflow
1624
:target: https://codecov.io/gh/LUMC/pytest-workflow
1725
:alt:
1826

19-
.. image:: https://img.shields.io/pypi/pyversions/pytest-workflow.svg
20-
:target: https://pypi.org/project/pytest-workflow/
21-
:alt:
22-
2327
pytest-workflow is a pytest plugin that aims to make pipeline/workflow testing easy
2428
by using yaml files for the test configuration.
2529

@@ -65,7 +69,8 @@ Below is an example of a YAML file that defines a test:
6569
This will run ``touch test.file`` and check afterwards if a file with path:
6670
``test.file`` is present. It will also check if the ``command`` has exited
6771
with exit code ``0``, which is the only default test that is run. Testing
68-
workflows that exit with another exit code is also possible.
72+
workflows that exit with another exit code is also possible. Several other
73+
predefined tests as well as custom tests are possible.
6974

7075
Documentation for more advanced use cases can be found on our
7176
`readthedocs page <https://pytest-workflow.readthedocs.io/>`_.

docs/writing_tests.rst

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
Writing tests with pytest-workflow
33
==================================
44

5+
Getting started
6+
---------------
7+
58
In order to write tests that are discoverable by the plugin you need to
69
complete the following steps.
710

@@ -23,7 +26,8 @@ This will run ``touch test.file`` and check afterwards if a file with path:
2326
with exit code ``0``, which is the only default test that is run. Testing
2427
workflows that exit with another exit code is also possible.
2528

26-
A more advanced example:
29+
Test options
30+
------------
2731

2832
.. code-block:: yaml
2933
@@ -65,3 +69,40 @@ A more advanced example:
6569
6670
6771
The above YAML file contains all the possible options for a workflow test.
72+
73+
Writing custom tests
74+
--------------------
75+
76+
Pytest-workflow provides a way to run custom tests on files produced by a
77+
workflow.
78+
79+
.. code-block:: python
80+
81+
import pathlib
82+
import pytest
83+
84+
@pytest.mark.workflow(name='files containing numbers')
85+
def test_div_by_three(workflow_dir):
86+
number_file = workflow_dir / pathlib.Path("123.txt")
87+
88+
with number_file.open('rt') as file_h:
89+
number_file_content = file_h.read()
90+
91+
assert int(number_file_content) % 3 == 0
92+
93+
The ``@pytest.mark.workflow(name='files containing numbers')`` marks the test
94+
as belonging to a workflow named 'files containing numbers'. The mark can also
95+
be written without the explicit ``name`` key as ``@pytest.mark.workflow('files
96+
containing nummbers')``. This test will only run if the workflow 'files
97+
containing numbers' has run.
98+
99+
``workflow_dir`` is a fixture. It does not work without a
100+
``pytest.mark.workflow('workflow_name')`` mark. This is a
101+
`pathlib.Path <https://docs.python.org/3/library/pathlib.html>`_ object that
102+
points to the folder where the named workflow was executed. This allows writing of
103+
advanced python tests for each file produced by the workflow.
104+
105+
.. container:: note
106+
107+
NOTE: stdout and stderr are available as files in the root of the
108+
``workflow_dir`` as ``log.out`` and ``log.err`` respectively.

mypy.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[mypy]
2+
# This ignores a lot of errors that are a huge headache to fix.
3+
ignore_missing_imports = True

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
setup(
2323
name="pytest-workflow",
24-
version="1.0.0",
24+
version="1.1.0",
2525
description="A pytest plugin for configuring workflow/pipeline tests "
2626
"using YAML files",
2727
author="Leiden University Medical Center",

src/pytest_workflow/content_tests.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def check_content(strings: List[str],
4646

4747
# Create two sets. By default all strings are not found.
4848
strings_to_check = set(strings)
49-
found_strings = set()
49+
found_strings = set() # type: Set[str]
5050

5151
for line in text_lines:
5252
# Break the loop if all strings are found
@@ -88,7 +88,7 @@ def file_to_string_generator(filepath: Path) -> Iterable[str]:
8888
if filepath.suffix == ".gz" else
8989
filepath.open)
9090
# Use 'rt' here explicitly as opposed to 'rb'
91-
with file_open(mode='rt') as file_handler:
91+
with file_open(mode='rt') as file_handler: # type: ignore # mypy goes crazy here otherwise # noqa: E501
9292
for line in file_handler:
9393
yield line
9494

src/pytest_workflow/plugin.py

Lines changed: 104 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@
1919
import tempfile
2020
import warnings
2121
from pathlib import Path
22-
from typing import Optional # noqa: F401 needed for typing.
22+
from typing import List, Optional # noqa: F401 needed for typing.
23+
24+
from _pytest.config import Config as PytestConfig
25+
from _pytest.config.argparsing import Parser as PytestParser
26+
from _pytest.fixtures import SubRequest
27+
from _pytest.mark import MarkDecorator # noqa: F401 used for typing
2328

2429
import pytest
2530

@@ -32,7 +37,7 @@
3237
from .workflow import Workflow, WorkflowQueue
3338

3439

35-
def pytest_addoption(parser):
40+
def pytest_addoption(parser: PytestParser):
3641
parser.addoption(
3742
"--keep-workflow-wd",
3843
action="store_true",
@@ -82,7 +87,7 @@ def pytest_collect_file(path, parent):
8287
return None
8388

8489

85-
def pytest_configure(config):
90+
def pytest_configure(config: PytestConfig):
8691
"""This runs before tests start and adds values to the config."""
8792
# We need to add a workflow queue to some central variable. Instead of
8893
# using a global variable we add a value to the config.
@@ -92,6 +97,14 @@ def pytest_configure(config):
9297
workflow_queue = WorkflowQueue()
9398
setattr(config, "workflow_queue", workflow_queue)
9499

100+
# Save which workflows are run and which are not.
101+
executed_workflows = [] # type: List[str]
102+
setattr(config, "executed_workflows", executed_workflows)
103+
104+
# Save workflow for cleanup in this var.
105+
workflow_cleanup_dirs = [] # type: List[str]
106+
setattr(config, "workflow_cleanup_dirs", workflow_cleanup_dirs)
107+
95108
# When multiple workflows are started they should all be set in the same
96109
# temporary directory
97110
# Running in a temporary directory will prevent the project repository
@@ -110,13 +123,13 @@ def pytest_configure(config):
110123
# So this is why the native pytest `tmpdir` fixture is not used.
111124

112125
basetemp = config.getoption("basetemp")
113-
workflow_dir = (
126+
workflow_temp_dir = (
114127
Path(basetemp) if basetemp is not None
115128
else Path(tempfile.mkdtemp(prefix="pytest_workflow_")))
116-
setattr(config, "workflow_dir", workflow_dir)
129+
setattr(config, "workflow_temp_dir", workflow_temp_dir)
117130

118131

119-
def pytest_collection(session):
132+
def pytest_collection(session: pytest.Session):
120133
"""This function is started at the beginning of collection"""
121134
# pylint: disable=unused-argument
122135
# needed for pytest
@@ -129,13 +142,81 @@ def pytest_collection(session):
129142
print()
130143

131144

132-
def pytest_runtestloop(session):
145+
def pytest_collection_modifyitems(config: PytestConfig,
146+
items: List[pytest.Item]):
147+
"""Here we skip all tests related to workflows that are not executed"""
148+
149+
for item in items:
150+
marker = item.get_closest_marker(
151+
name="workflow") # type: Optional[MarkDecorator] # noqa: E501
152+
153+
if marker is None:
154+
continue
155+
156+
if 'name' in marker.kwargs:
157+
workflow_name = marker.kwargs['name']
158+
# If name key is not defined use the first arg.
159+
elif 'name' not in marker.kwargs and len(marker.args) >= 1:
160+
workflow_name = marker.args[0]
161+
# Make sure a name attribute is added anyway for the
162+
# fixture lookup.
163+
marker.kwargs['name'] = workflow_name
164+
else:
165+
# If we raise an error here a number of things will happen:
166+
# + Pytest will crash. Giving a lot of INTERNAL ERROR lines
167+
# + No tests will run
168+
# + No workflows will be started
169+
# + All the threads that are waiting for a workflow to
170+
# finish will wait indefinitely. Causing an infinite
171+
# hang. Pytest will never finish.
172+
# Therefore we do not crash here, but raise a warning.
173+
item.warn(pytest.PytestWarning(
174+
"A workflow name should be defined in the "
175+
"workflow marker of {0}".format(item.nodeid)))
176+
# Go on with the next item.
177+
continue
178+
179+
if workflow_name not in config.executed_workflows:
180+
skip_marker = pytest.mark.skip(
181+
reason="'{0}' has not run.".format(workflow_name))
182+
item.add_marker(skip_marker)
183+
184+
185+
def pytest_runtestloop(session: pytest.Session):
133186
"""This runs after collection, but before the tests."""
134187
session.config.workflow_queue.process(
135188
session.config.getoption("workflow_threads")
136189
)
137190

138191

192+
def pytest_sessionfinish(session: pytest.Session):
193+
if not session.config.getoption("keep_workflow_wd"):
194+
for tempdir in session.config.workflow_cleanup_dirs:
195+
shutil.rmtree(str(tempdir))
196+
197+
198+
@pytest.fixture()
199+
def workflow_dir(request: SubRequest):
200+
"""Returns the workflow_dir of the workflow named in the mark. This fixture
201+
is only provided for tests that are marked with the workflow mark."""
202+
203+
# request.node refers to the node that has the mark. This is a pytest.Node
204+
marker = request.node.get_closest_marker(name="workflow")
205+
206+
if marker is not None:
207+
workflow_temp_dir = request.config.workflow_temp_dir
208+
try:
209+
workflow_name = marker.kwargs['name']
210+
except KeyError:
211+
raise TypeError(
212+
"A workflow name should be defined in the "
213+
"workflow marker of {0}".format(request.node.nodeid))
214+
return workflow_temp_dir / Path(replace_whitespace(workflow_name))
215+
else:
216+
raise ValueError("workflow_dir can only be requested in tests marked"
217+
" with the workflow mark.")
218+
219+
139220
class YamlFile(pytest.File):
140221
"""
141222
This class collects YAML files and turns them into test items.
@@ -162,8 +243,6 @@ class WorkflowTestsCollector(pytest.Collector):
162243
def __init__(self, workflow_test: WorkflowTest, parent: pytest.Collector):
163244
self.workflow_test = workflow_test
164245
super().__init__(workflow_test.name, parent=parent)
165-
# Tempdir is stored for cleanup with teardown().
166-
self.tempdir = None # type: Optional[Path]
167246

168247
# Attach tags to this node for easier workflow selection
169248
self.tags = [self.workflow_test.name] + self.workflow_test.tags
@@ -185,28 +264,34 @@ def queue_workflow(self):
185264
This is shorter than using pytest's terminal reporter.
186265
"""
187266

188-
self.tempdir = (self.config.workflow_dir /
189-
Path(replace_whitespace(self.name, '_')))
267+
tempdir = (self.config.workflow_temp_dir /
268+
Path(replace_whitespace(self.name, '_')))
190269

191270
# Remove the tempdir if it exists. This is needed for shutil.copytree
192271
# to work properly.
193-
if self.tempdir.exists():
272+
if tempdir.exists():
194273
warnings.warn(
195-
"'{0}' already exists. Deleting ...".format(self.tempdir))
196-
shutil.rmtree(str(self.tempdir))
274+
"'{0}' already exists. Deleting ...".format(tempdir))
275+
shutil.rmtree(str(tempdir))
197276

198277
# Copy the project directory to the temporary directory using pytest's
199278
# rootdir.
200-
shutil.copytree(str(self.config.rootdir), str(self.tempdir))
279+
shutil.copytree(str(self.config.rootdir), str(tempdir))
201280

202281
# Create a workflow and make sure it runs in the tempdir
203282
workflow = Workflow(command=self.workflow_test.command,
204-
cwd=self.tempdir,
283+
cwd=tempdir,
205284
name=self.workflow_test.name)
206285

207286
# Add the workflow to the workflow queue.
208287
self.config.workflow_queue.put(workflow)
209288

289+
# Add the tempdir to the removal queue. We do not use a teardown method
290+
# because this will remove the tempdir right after all the tests from
291+
# this node have finished. If custom tests are defined this should not
292+
# happen. The removal queue is processed just before pytest finishes
293+
# and all tests have run.
294+
self.config.workflow_cleanup_dirs.append(tempdir)
210295
return workflow
211296

212297
def collect(self):
@@ -222,6 +307,9 @@ def collect(self):
222307
if not (set(self.config.getoption("workflow_tags")
223308
).issubset(set(self.tags))):
224309
return []
310+
else:
311+
# If we run the workflow, save this for reference later.
312+
self.config.executed_workflows.append(self.workflow_test.name)
225313

226314
# This creates a workflow that is queued for processing after the
227315
# collection phase.
@@ -253,13 +341,6 @@ def collect(self):
253341

254342
return tests
255343

256-
def teardown(self):
257-
"""This function is executed after all tests from this collector have
258-
finished. It is used to cleanup the tempdir."""
259-
if (not self.config.getoption("keep_workflow_wd")
260-
and self.tempdir is not None):
261-
shutil.rmtree(str(self.tempdir))
262-
263344

264345
class ExitCodeTest(pytest.Item):
265346
def __init__(self, parent: pytest.Collector,

src/pytest_workflow/workflow.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,11 @@ def stderr(self) -> bytes:
139139
@property
140140
def exit_code(self) -> int:
141141
self.wait()
142-
return self._popen.returncode
142+
if self._popen is not None:
143+
return self._popen.returncode
144+
else:
145+
raise ValueError("No exit code after waiting. Please contact the "
146+
"developers and report this issue.")
143147

144148

145149
class WorkflowQueue(queue.Queue):

0 commit comments

Comments
 (0)