forked from cylc/cylc-flow
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathconftest.py
More file actions
290 lines (233 loc) · 8.79 KB
/
conftest.py
File metadata and controls
290 lines (233 loc) · 8.79 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE.
# Copyright (C) NIWA & British Crown (Met Office) & Contributors.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging
import re
from pathlib import Path
from shutil import rmtree
from typing import Callable, List, Optional, Tuple
import time
import pytest
from cylc.flow import LOG, flags
from cylc.flow.cfgspec.glbl_cfg import glbl_cfg
from cylc.flow.cfgspec.globalcfg import SPEC
from cylc.flow.graphnode import GraphNodeParser
from cylc.flow.parsec.config import ParsecConfig
from cylc.flow.parsec.validate import cylc_config_validate
@pytest.fixture(autouse=True)
def before_each():
"""Reset global state before every test."""
flags.verbosity = 0
flags.cylc7_back_compat = False
LOG.setLevel(logging.NOTSET)
# Reset graph node parser singleton:
GraphNodeParser.get_inst().clear()
@pytest.fixture(scope='module')
def mod_monkeypatch():
"""A module-scoped version of the monkeypatch fixture."""
from _pytest.monkeypatch import MonkeyPatch
mpatch = MonkeyPatch()
yield mpatch
mpatch.undo()
@pytest.fixture
def mock_glbl_cfg(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""A Pytest fixture for fiddling global config values.
* Hacks the specified `glbl_cfg` object.
* Can be called multiple times within a test function.
Args:
pypath (str):
The python-like path to the global configuation object you want
to fiddle.
E.G. if you want to hack the `glbl_cfg` in
`cylc.flow.scheduler` you would provide
`cylc.flow.scheduler.glbl_cfg`
global_config (str):
The globlal configuration as a multi-line string.
Example:
Change the value of `UTC mode` in the global config as seen from
`the scheduler` module.
def test_something(mock_glbl_cfg):
mock_glbl_cfg(
'cylc.flow.scheduler.glbl_cfg',
'''
[scheduler]
UTC mode = True
'''
)
"""
# TODO: modify Parsec so we can use StringIO rather than a temp file.
def _mock_glbl_cfg(pypath: str, global_config: str) -> None:
global_config_path = tmp_path / 'global.cylc'
global_config_path.write_text(global_config)
glbl_cfg = ParsecConfig(SPEC, validator=cylc_config_validate)
glbl_cfg.loadcfg(global_config_path)
def _inner(cached=False):
return glbl_cfg
monkeypatch.setattr(pypath, _inner)
yield _mock_glbl_cfg
rmtree(tmp_path)
@pytest.fixture
def log_filter(caplog: pytest.LogCaptureFixture):
"""Filter caplog record_tuples (also discarding the log name entry).
Args:
level: Filter out records if they aren't at this logging level.
contains: Filter out records if this string is not in the message.
regex: Filter out records if the message doesn't match this regex.
exact_match: Filter out records if the message does not exactly match
this string.
log: A caplog instance.
"""
def _log_filter(
level: Optional[int] = None,
contains: Optional[str] = None,
regex: Optional[str] = None,
exact_match: Optional[str] = None,
log: Optional[pytest.LogCaptureFixture] = None
) -> List[Tuple[int, str]]:
if log is None:
log = caplog
return [
(log_level, log_message)
for _, log_level, log_message in log.record_tuples
if (level is None or level == log_level)
and (contains is None or contains in log_message)
and (regex is None or re.search(regex, log_message))
and (exact_match is None or exact_match == log_message)
]
return _log_filter
@pytest.fixture
def log_scan():
"""Ensure log messages appear in the correct order.
TRY TO AVOID DOING THIS!
If you are trying to test a sequence of events you are likely better off
doing this a different way (e.g. mock the functions you are interested in
and test the call arguments/returns later).
However, there are some occasions where this might be necessary, e.g.
testing a monolithic synchronous function.
Args:
log: The caplog fixture.
items: Iterable of string messages to compare. All are tested
by "contains" i.e. "item in string".
"""
def _log_scan(log, items):
records = iter(log.records)
record = next(records)
for item in items:
while item not in record.message:
try:
record = next(records)
except StopIteration:
raise Exception(f'Reached end of log looking for: {item}')
return _log_scan
@pytest.fixture(scope='session')
def port_range():
return glbl_cfg().get(['scheduler', 'run hosts', 'ports'])
@pytest.fixture
def capcall(monkeypatch):
"""Capture function calls without running the function.
Returns a list which is populated with function calls.
Args:
function_string:
The function to replace as it would be specified to
monkeypatch.setattr.
mock:
* If True, the function will be replaced by a "return None".
* If False, the original function will be run.
* If a Callable is provided, this will be run in place of the
original function.
Returns:
[(args: Tuple, kwargs: Dict), ...]
Example:
def test_something(capcall):
capsys = capcall('sys.exit')
sys.exit(1)
assert capsys == [(1,), {}]
"""
def _capcall(function_string: str, mock: bool | Callable = True):
calls = []
if mock is True:
fcn = lambda *args, **kwargs: None
elif mock is False:
fcn = import_object_from_string(function_string)
else:
fcn = mock
def _call(*args, **kwargs):
calls.append((args, kwargs))
return fcn(*args, **kwargs)
monkeypatch.setattr(function_string, _call)
return calls
return _capcall
def import_object_from_string(string):
"""Import a Python object from a string path.
The path may reference a module, function, class, method, whatever.
Examples:
# import a module
>>> import_object_from_string('os')
<module 'os' ...>
# import a function
>>> import_object_from_string('os.path.walk')
<function walk at ...>
# import a constant from a namespace package
>>> import_object_from_string('cylc.flow.LOG')
<Logger cylc (WARNING)>
# import a class
>>> import_object_from_string('pathlib.Path')
<class 'pathlib.Path'>
# import a method
>>> import_object_from_string('pathlib.Path.exists')
<function Path.exists ...>
"""
head = string
tail = []
while True:
try:
# try and import the thing
module = __import__(head)
except ModuleNotFoundError:
# if it's not something we can import, lop the last item off the
# end of the string and repeat
if '.' in head:
head, _tail = head.rsplit('.', 1)
tail.append(_tail)
else:
# we definitely can't import this
raise
else:
# we managed to import something
if '(namespace)' in str(module):
# with namespace packages you have to pull the module out of
# the package yourself
for part in head.split('.')[1:]:
module = getattr(module, part)
break
# extract the requested object from the module (if requested)
obj = module
for part in reversed(tail):
obj = getattr(obj, part)
return obj
@pytest.fixture
def set_timezone(monkeypatch):
"""Fixture to temporarily set a timezone.
Will use a very implausible timezone if none is provided.
"""
def patch(time_zone: str):
monkeypatch.setenv('TZ', time_zone)
time.tzset()
try:
yield patch
finally:
# Reset to the original time zone after the test
monkeypatch.undo()
time.tzset()