Skip to content

Commit 40c027a

Browse files
authored
Merge pull request #110 from LUMC/release_1.4.0
Release 1.4.0
2 parents 45887cd + 66117a6 commit 40c027a

17 files changed

+355
-53
lines changed

HISTORY.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ Changelog
77
.. 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.4.0
11+
---------------------------
12+
+ Usage of the ``name`` keyword argument in workflow marks is now deprecated.
13+
Using this will crash the plugin with a DeprecationWarning.
14+
+ Update minimum python requirement in the documentation.
15+
+ Removed redundant check in string checking code.
16+
+ Add new options ``contains_regex`` and ``must_not_contain_regex`` to check
17+
for regexes in files and stdout/stderr.
18+
1019
version 1.3.0
1120
---------------------------
1221
Python 3.6 and pytest 5.4.0.0 are now minimum requirements for pytest-workflow.

README.rst

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ pytest-workflow
2828
:target: https://codecov.io/gh/LUMC/pytest-workflow
2929
:alt:
3030

31+
.. image:: https://zenodo.org/badge/DOI/10.5281/zenodo.3757727.svg
32+
:target: https://doi.org/10.5281/zenodo.3757727
33+
:alt: More information on how to cite pytest-workflow here.
34+
3135
pytest-workflow is a pytest plugin that aims to make pipeline/workflow testing easy
3236
by using yaml files for the test configuration.
3337

@@ -37,8 +41,8 @@ For our complete documentation checkout our
3741

3842
Installation
3943
============
40-
Pytest-workflow requires Python 3.5 or higher. It is tested on Python 3.5, 3.6,
41-
3.7 and 3.8. Python 2 is not supported.
44+
Pytest-workflow requires Python 3.6 or higher. It is tested on Python 3.6, 3.7
45+
and 3.8. Python 2 is not supported.
4246

4347
- Make sure your virtual environment is activated.
4448
- Install using pip ``pip install pytest-workflow``
@@ -124,5 +128,19 @@ predefined tests as well as custom tests are possible.
124128
must_not_contain: # A list of strings which should NOT be in stderr (optional)
125129
- "Mission accomplished!"
126130
131+
- name: regex tests
132+
command: echo Hello, world
133+
stdout:
134+
contains_regex: # A list of regex patterns that should be in stdout (optional)
135+
- 'Hello.*' # Note the single quotes, these are required for complex regexes
136+
- 'Hello .*' # This will fail, since there is a comma after Hello, not a space
137+
138+
must_not_contain_regex: # A list of regex patterns that should not be in stdout (optional)
139+
- '^He.*' # This will fail, since the regex matches Hello, world
140+
- '^Hello .*' # Complex regexes will break yaml if double quotes are used
141+
142+
For more information on how Python parses regular expressions, see the `Python
143+
documentation <https://docs.python.org/3.6/library/re.html>`_.
144+
127145
Documentation for more advanced use cases including the custom tests can be
128146
found on our `readthedocs page <https://pytest-workflow.readthedocs.io/>`_.

docs/installation.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
Installation
33
============
44

5-
Pytest-workflow is tested on python 3.5, 3.6, 3.7 and 3.8. Python 2 is not
5+
Pytest-workflow is tested on python 3.6, 3.7 and 3.8. Python 2 is not
66
supported.
77

88
In a virtual environment

docs/known_issues.rst

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,15 @@ Known issues
1010
1111
coverage run --source=<your_source_here> -m py.test <your_test_dir>
1212
13-
This will work as expected.
13+
This will work as expected.
14+
15+
+ ``contains_regex`` and ``must_not_contain_regex`` only work well with single
16+
quotes in the yaml file. This is due to the way the yaml file is parsed: with
17+
double quotes, special characters (like ``\t``) will be expanded, which can
18+
lead to crashes.
19+
20+
+ Special care should be taken when using the backslash character (``\``) in
21+
``contains_regex`` and ``must_not_contain_regex``, since this collides with
22+
Python's usage of the same character to escape special characters in strings.
23+
Please see the `Python documentation on regular expressions
24+
<https://docs.python.org/3.6/library/re.html>`_ for details.

docs/writing_tests.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,24 @@ Test options
6868
must_not_contain: # A list of strings which should NOT be in stderr (optional)
6969
- "Mission accomplished!"
7070
71+
- name: regex tests
72+
command: echo Hello, world
73+
stdout:
74+
contains_regex: # A list of regex patterns that should be in stdout (optional)
75+
- 'Hello.*' # Note the single quotes, these are required for complex regexes
76+
- 'Hello .*' # This will fail, since there is a comma after Hello, not a space
77+
78+
must_not_contain_regex: # A list of regex patterns that should not be in stdout (optional)
79+
- '^He.*' # This will fail, since the regex matches Hello, world
80+
- '^Hello .*' # Complex regexes will break yaml if double quotes are used
81+
7182
7283
The above YAML file contains all the possible options for a workflow test.
7384

85+
Please see the `Python documentation on regular expressions
86+
<https://docs.python.org/3.6/library/re.html>`_ to see how Python handles escape
87+
sequences.
88+
7489
.. note::
7590
Workflow names must be unique. Pytest workflow will crash when multiple
7691
workflows have the same name, even if they are in different files.

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.3.0",
24+
version="1.4.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: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
once."""
2222
import functools
2323
import gzip
24+
import re
2425
import threading
2526
from pathlib import Path
2627
from typing import Iterable, Optional, Set
@@ -55,14 +56,51 @@ def check_content(strings: Iterable[str],
5556
break
5657

5758
for string in strings_to_check:
58-
if string not in found_strings and string in line:
59+
if string in line:
5960
found_strings.add(string)
6061
# Remove found strings for faster searching. This should be done
6162
# outside of the loop above.
6263
strings_to_check -= found_strings
6364
return found_strings
6465

6566

67+
def check_regex_content(patterns: Iterable[str],
68+
text_lines: Iterable[str]) -> Set[str]:
69+
"""
70+
Checks whether any of the patterns is present in the text lines
71+
It only reads the lines once and it stops reading when
72+
everything is found. This makes searching for patterns in large bodies of
73+
text more efficient.
74+
:param patterns: A list of regexes which is matched
75+
:param text_lines: The lines of text that need to be searched.
76+
:return: A tuple with a set of found regexes, and a set of not found
77+
regexes
78+
"""
79+
80+
# Create two sets. By default all strings are not found.
81+
regex_to_match = {re.compile(pattern) for pattern in patterns}
82+
found_patterns: Set[str] = set()
83+
84+
for line in text_lines:
85+
# Break the loop if all regexes have been matched
86+
if not regex_to_match:
87+
break
88+
89+
# Regexes we don't have to check anymore
90+
to_remove = list()
91+
for regex in regex_to_match:
92+
if re.search(regex, line):
93+
found_patterns.add(regex.pattern)
94+
to_remove.append(regex)
95+
96+
# Remove found patterns for faster searching. This should be done
97+
# outside of the loop above.
98+
for regex in to_remove:
99+
regex_to_match.remove(regex)
100+
101+
return found_patterns
102+
103+
66104
class ContentTestCollector(pytest.Collector):
67105
def __init__(self, name: str, parent: pytest.Collector,
68106
filepath: Path,
@@ -84,6 +122,7 @@ def __init__(self, name: str, parent: pytest.Collector,
84122
self.content_test = content_test
85123
self.workflow = workflow
86124
self.found_strings = None
125+
self.found_patterns = None
87126
self.thread = None
88127
# We check the contents of files. Sometimes files are not there. Then
89128
# content can not be checked. We save FileNotFoundErrors in this
@@ -99,6 +138,8 @@ def find_strings(self):
99138
self.workflow.wait()
100139
strings_to_check = (self.content_test.contains +
101140
self.content_test.must_not_contain)
141+
patterns_to_check = (self.content_test.contains_regex +
142+
self.content_test.must_not_contain_regex)
102143
file_open = (functools.partial(gzip.open, str(self.filepath))
103144
if self.filepath.suffix == ".gz" else
104145
self.filepath.open)
@@ -108,6 +149,11 @@ def find_strings(self):
108149
self.found_strings = check_content(
109150
strings=strings_to_check,
110151
text_lines=file_handler)
152+
# Read the file again for the regex
153+
with file_open(mode='rt') as file_handler: # type: ignore # mypy goes crazy here otherwise # noqa: E501
154+
self.found_patterns = check_regex_content(
155+
patterns=patterns_to_check,
156+
text_lines=file_handler)
111157
except FileNotFoundError:
112158
self.file_not_found = True
113159

@@ -124,6 +170,7 @@ def collect(self):
124170
parent=self,
125171
string=string,
126172
should_contain=True,
173+
regex=False,
127174
content_name=self.content_name
128175
)
129176
for string in self.content_test.contains]
@@ -133,18 +180,39 @@ def collect(self):
133180
parent=self,
134181
string=string,
135182
should_contain=False,
183+
regex=False,
136184
content_name=self.content_name
137185
)
138186
for string in self.content_test.must_not_contain]
139187

188+
test_items += [
189+
ContentTestItem.from_parent(
190+
parent=self,
191+
string=pattern,
192+
should_contain=True,
193+
regex=True,
194+
content_name=self.content_name
195+
)
196+
for pattern in self.content_test.contains_regex]
197+
198+
test_items += [
199+
ContentTestItem.from_parent(
200+
parent=self,
201+
string=pattern,
202+
should_contain=False,
203+
regex=True,
204+
content_name=self.content_name
205+
)
206+
for pattern in self.content_test.must_not_contain_regex]
207+
140208
return test_items
141209

142210

143211
class ContentTestItem(pytest.Item):
144212
"""Item that reports if a string has been found in content."""
145213

146214
def __init__(self, parent: ContentTestCollector, string: str,
147-
should_contain: bool, content_name: str):
215+
should_contain: bool, regex: bool, content_name: str):
148216
"""
149217
Create a ContentTestItem
150218
:param parent: A ContentTestCollector. We use a ContentTestCollector
@@ -153,6 +221,7 @@ def __init__(self, parent: ContentTestCollector, string: str,
153221
finished.
154222
:param string: The string that was searched for.
155223
:param should_contain: Whether the string should have been there
224+
:param regex: Wether we are looking for a regex
156225
:param content_name: the name of the content which allows for easier
157226
debugging if the test fails
158227
"""
@@ -163,6 +232,7 @@ def __init__(self, parent: ContentTestCollector, string: str,
163232
self.should_contain = should_contain
164233
self.string = string
165234
self.content_name = content_name
235+
self.regex = regex
166236

167237
def runtest(self):
168238
"""Only after a workflow is finished the contents of files and logs are
@@ -175,8 +245,12 @@ def runtest(self):
175245
# Wait for thread to complete.
176246
self.parent.thread.join()
177247
assert not self.parent.file_not_found
178-
assert ((self.string in self.parent.found_strings) ==
179-
self.should_contain)
248+
if self.regex:
249+
assert ((self.string in self.parent.found_patterns) ==
250+
self.should_contain)
251+
else:
252+
assert ((self.string in self.parent.found_strings) ==
253+
self.should_contain)
180254

181255
def repr_failure(self, excinfo, style=None):
182256
if self.parent.file_not_found:

src/pytest_workflow/file_tests.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ def collect(self):
5959
should_exist=self.filetest.should_exist,
6060
workflow=self.workflow)]
6161

62-
if self.filetest.contains or self.filetest.must_not_contain:
62+
if any((self.filetest.contains, self.filetest.must_not_contain,
63+
self.filetest.contains_regex,
64+
self.filetest.must_not_contain_regex)):
6365
tests += [ContentTestCollector.from_parent(
6466
name="content",
6567
parent=self,

src/pytest_workflow/plugin.py

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -191,23 +191,12 @@ def pytest_collection():
191191

192192
def get_workflow_names_from_workflow_marker(marker: MarkDecorator
193193
) -> List[str]:
194-
if not marker.name == "workflow":
195-
raise ValueError(
196-
f"Can only get names from markers named 'workflow' "
197-
f"not '{marker.name}'.")
198-
if marker.args:
199-
return marker.args
200-
elif 'name' in marker.kwargs:
201-
# TODO: Remove this as soon as version reaches 1.4.0-dev
202-
# This means also the entire get_workflow_names_from_workflow_marker
203-
# function can be removed. As simply marker.args can be used.
204-
warnings.warn(PendingDeprecationWarning(
194+
if 'name' in marker.kwargs:
195+
raise DeprecationWarning(
205196
"Using pytest.mark.workflow(name='workflow name') is "
206-
"deprecated. Use pytest.mark.workflow('workflow_name') instead. "
207-
"This behavior will be removed in a later version."))
208-
return [marker.kwargs['name']]
209-
else:
210-
return []
197+
"deprecated. Use pytest.mark.workflow('workflow_name') "
198+
"instead.")
199+
return marker.args
211200

212201

213202
def pytest_generate_tests(metafunc: Metafunc):

src/pytest_workflow/schema.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -106,22 +106,29 @@ def test_contains_concordance(dictionary: dict, name: str):
106106

107107
class ContentTest(object):
108108
"""
109-
A class that holds two lists of strings. Everything in `contains` should be
110-
present in the file/text
111-
Everything in `must_not_contain` should not be present.
109+
A class that holds four lists of strings. Everything in `contains` and
110+
`contains_regex` should be present in the file/text
111+
Everything in `must_not_contain` and `must_not_contain_regex` should
112+
not be present.
112113
"""
113114
def __init__(self, contains: Optional[List[str]] = None,
114-
must_not_contain: Optional[List[str]] = None):
115+
must_not_contain: Optional[List[str]] = None,
116+
contains_regex: Optional[List[str]] = None,
117+
must_not_contain_regex: Optional[List[str]] = None):
115118
self.contains: List[str] = contains or []
116119
self.must_not_contain: List[str] = must_not_contain or []
120+
self.contains_regex: List[str] = contains_regex or []
121+
self.must_not_contain_regex: List[str] = must_not_contain_regex or []
117122

118123

119124
class FileTest(ContentTest):
120125
"""A class that contains all the properties of a to be tested file."""
121126
def __init__(self, path: str, md5sum: Optional[str] = None,
122127
should_exist: bool = DEFAULT_FILE_SHOULD_EXIST,
123128
contains: Optional[List[str]] = None,
124-
must_not_contain: Optional[List[str]] = None):
129+
must_not_contain: Optional[List[str]] = None,
130+
contains_regex: Optional[List[str]] = None,
131+
must_not_contain_regex: Optional[List[str]] = None):
125132
"""
126133
A container object
127134
:param path: the path to the file
@@ -130,8 +137,14 @@ def __init__(self, path: str, md5sum: Optional[str] = None,
130137
:param contains: a list of strings that should be present in the file
131138
:param must_not_contain: a list of strings that should not be present
132139
in the file
140+
:param contains_regex: a list of regular expression patterns that
141+
should be present in the file
142+
:param must_not_contain_regex: a list of regular expression pattersn
143+
that should not be present in the file
133144
"""
134-
super().__init__(contains=contains, must_not_contain=must_not_contain)
145+
super().__init__(contains=contains, must_not_contain=must_not_contain,
146+
contains_regex=contains_regex,
147+
must_not_contain_regex=must_not_contain_regex)
135148
self.path = Path(path)
136149
self.md5sum = md5sum
137150
self.should_exist = should_exist

0 commit comments

Comments
 (0)