pytest-regtest 2.2.0__py2.py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytest_regtest/__init__.py +96 -0
- pytest_regtest/numpy_handler.py +209 -0
- pytest_regtest/pandas_handler.py +135 -0
- pytest_regtest/pytest_regtest.py +610 -0
- pytest_regtest/register_third_party_handlers.py +28 -0
- pytest_regtest/snapshot_handler.py +76 -0
- pytest_regtest/utils.py +28 -0
- pytest_regtest-2.2.0.dist-info/METADATA +81 -0
- pytest_regtest-2.2.0.dist-info/RECORD +12 -0
- pytest_regtest-2.2.0.dist-info/WHEEL +5 -0
- pytest_regtest-2.2.0.dist-info/entry_points.txt +2 -0
- pytest_regtest-2.2.0.dist-info/licenses/LICENSE.txt +21 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from importlib.metadata import version as _version
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from .pytest_regtest import clear_converters # noqa: F401
|
|
6
|
+
from .pytest_regtest import patch_terminal_size # noqa: F401
|
|
7
|
+
from .pytest_regtest import register_converter_post # noqa: F401
|
|
8
|
+
from .pytest_regtest import register_converter_pre # noqa: F401
|
|
9
|
+
from .pytest_regtest import (
|
|
10
|
+
PytestRegtestCommonHooks,
|
|
11
|
+
PytestRegtestPlugin,
|
|
12
|
+
RegtestStream,
|
|
13
|
+
Snapshot,
|
|
14
|
+
SnapshotPlugin,
|
|
15
|
+
)
|
|
16
|
+
from .register_third_party_handlers import (
|
|
17
|
+
register_numpy_handler,
|
|
18
|
+
register_pandas_handler,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__version__ = _version(__package__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def pytest_addoption(parser):
|
|
25
|
+
"""Add options to control the timeout plugin"""
|
|
26
|
+
group = parser.getgroup("regtest", "regression test plugin")
|
|
27
|
+
group.addoption(
|
|
28
|
+
"--regtest-reset",
|
|
29
|
+
action="store_true",
|
|
30
|
+
help="do not run regtest but record current output",
|
|
31
|
+
)
|
|
32
|
+
group.addoption(
|
|
33
|
+
"--regtest-tee",
|
|
34
|
+
action="store_true",
|
|
35
|
+
default=False,
|
|
36
|
+
help="print recorded results to console too",
|
|
37
|
+
)
|
|
38
|
+
group.addoption(
|
|
39
|
+
"--regtest-consider-line-endings",
|
|
40
|
+
action="store_true",
|
|
41
|
+
default=False,
|
|
42
|
+
help="do not strip whitespaces at end of recorded lines",
|
|
43
|
+
)
|
|
44
|
+
group.addoption(
|
|
45
|
+
"--regtest-nodiff",
|
|
46
|
+
action="store_true",
|
|
47
|
+
default=False,
|
|
48
|
+
help="do not show diff output for failed regresson tests",
|
|
49
|
+
)
|
|
50
|
+
group.addoption(
|
|
51
|
+
"--regtest-disable-stdconv",
|
|
52
|
+
action="store_true",
|
|
53
|
+
default=False,
|
|
54
|
+
help=(
|
|
55
|
+
"do not apply standard output converters to clean up indeterministic output"
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def pytest_configure(config):
|
|
61
|
+
common = PytestRegtestCommonHooks()
|
|
62
|
+
config.pluginmanager.register(common)
|
|
63
|
+
config.pluginmanager.register(PytestRegtestPlugin(common))
|
|
64
|
+
config.pluginmanager.register(SnapshotPlugin(common))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.fixture
|
|
68
|
+
def regtest(request):
|
|
69
|
+
yield RegtestStream(request)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.fixture
|
|
73
|
+
def snapshot(request):
|
|
74
|
+
yield Snapshot(request)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@pytest.fixture
|
|
78
|
+
def regtest_all(regtest):
|
|
79
|
+
yield regtest
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
snapshot_all_output = regtest_all
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
import pandas # noqa: F401
|
|
86
|
+
|
|
87
|
+
register_pandas_handler()
|
|
88
|
+
except ImportError:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
import numpy # noqa: F401
|
|
93
|
+
|
|
94
|
+
register_numpy_handler()
|
|
95
|
+
except ImportError:
|
|
96
|
+
pass
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import difflib
|
|
2
|
+
import io
|
|
3
|
+
import os.path
|
|
4
|
+
import warnings
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from .snapshot_handler import BaseSnapshotHandler
|
|
9
|
+
from .utils import highlight_mismatches
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NumpyHandler(BaseSnapshotHandler):
|
|
13
|
+
def __init__(self, handler_options, pytest_config, tw):
|
|
14
|
+
self.print_options = np.get_printoptions() | handler_options.get(
|
|
15
|
+
"print_options", {}
|
|
16
|
+
)
|
|
17
|
+
self.atol = handler_options.get("atol", 0.0)
|
|
18
|
+
self.rtol = handler_options.get("rtol", 0.0)
|
|
19
|
+
self.equal_nan = handler_options.get("equal_nan", True)
|
|
20
|
+
|
|
21
|
+
def _filename(self, folder):
|
|
22
|
+
return os.path.join(folder, "arrays.npy")
|
|
23
|
+
|
|
24
|
+
def save(self, folder, obj):
|
|
25
|
+
np.save(self._filename(folder), obj)
|
|
26
|
+
|
|
27
|
+
def load(self, folder):
|
|
28
|
+
return np.load(self._filename(folder))
|
|
29
|
+
|
|
30
|
+
def show(self, obj):
|
|
31
|
+
stream = io.StringIO()
|
|
32
|
+
with np.printoptions(**self.print_options):
|
|
33
|
+
print(obj, file=stream)
|
|
34
|
+
return stream.getvalue().splitlines()
|
|
35
|
+
|
|
36
|
+
def compare(self, current_obj, recorded_obj):
|
|
37
|
+
return (
|
|
38
|
+
isinstance(current_obj, np.ndarray)
|
|
39
|
+
and current_obj.shape == recorded_obj.shape
|
|
40
|
+
and current_obj.dtype == recorded_obj.dtype
|
|
41
|
+
and np.allclose(
|
|
42
|
+
recorded_obj,
|
|
43
|
+
current_obj,
|
|
44
|
+
atol=self.atol,
|
|
45
|
+
rtol=self.rtol,
|
|
46
|
+
equal_nan=self.equal_nan,
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def show_differences(self, current_obj, recorded_obj, has_markup):
|
|
51
|
+
lines = []
|
|
52
|
+
|
|
53
|
+
if recorded_obj.dtype != current_obj.dtype:
|
|
54
|
+
lines.extend(
|
|
55
|
+
[
|
|
56
|
+
f"dtype mismatch: current dtype: {current_obj.dtype}",
|
|
57
|
+
f" recorded dtype: {recorded_obj.dtype}",
|
|
58
|
+
]
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
recorded_as_text = self.show(recorded_obj)
|
|
62
|
+
current_as_text = self.show(current_obj)
|
|
63
|
+
|
|
64
|
+
if recorded_obj.shape == current_obj.shape:
|
|
65
|
+
if np.allclose(current_obj, recorded_obj, rtol=self.rtol, atol=self.atol):
|
|
66
|
+
return lines or None
|
|
67
|
+
|
|
68
|
+
lines.extend(self.error_diagnostics(recorded_obj, current_obj))
|
|
69
|
+
|
|
70
|
+
else:
|
|
71
|
+
lines.extend(
|
|
72
|
+
[
|
|
73
|
+
f"shape mismatch: current shape: {current_obj.shape}",
|
|
74
|
+
f" recorded shape: {recorded_obj.shape}",
|
|
75
|
+
]
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if recorded_obj.ndim > 2:
|
|
79
|
+
return lines
|
|
80
|
+
|
|
81
|
+
if recorded_obj.ndim == 1:
|
|
82
|
+
diff_lines = list(
|
|
83
|
+
difflib.unified_diff(
|
|
84
|
+
current_as_text,
|
|
85
|
+
recorded_as_text,
|
|
86
|
+
"current",
|
|
87
|
+
"expected",
|
|
88
|
+
lineterm="",
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
lines.append("")
|
|
92
|
+
lines.extend(diff_lines)
|
|
93
|
+
|
|
94
|
+
else:
|
|
95
|
+
diff_lines = self.error_diagnostics_2d_linewise(
|
|
96
|
+
current_obj,
|
|
97
|
+
current_as_text,
|
|
98
|
+
recorded_obj,
|
|
99
|
+
recorded_as_text,
|
|
100
|
+
has_markup,
|
|
101
|
+
)
|
|
102
|
+
lines.extend(diff_lines)
|
|
103
|
+
|
|
104
|
+
if not diff_lines:
|
|
105
|
+
lines.append("diff is empty, you may want to change the print options")
|
|
106
|
+
|
|
107
|
+
return lines
|
|
108
|
+
|
|
109
|
+
def error_diagnostics(self, recorded_obj, current_obj):
|
|
110
|
+
with warnings.catch_warnings():
|
|
111
|
+
warnings.simplefilter("ignore", RuntimeWarning)
|
|
112
|
+
rel_err = np.abs(current_obj - recorded_obj) / recorded_obj
|
|
113
|
+
rel_err[(recorded_obj == 0) * (current_obj == recorded_obj)] = 0.0
|
|
114
|
+
rel_err_max_1 = np.max(rel_err)
|
|
115
|
+
rel_err_max_2 = np.max(rel_err[recorded_obj != 0])
|
|
116
|
+
|
|
117
|
+
abs_err = np.abs(current_obj - recorded_obj)
|
|
118
|
+
abs_err_max = np.max(abs_err)
|
|
119
|
+
|
|
120
|
+
lines = []
|
|
121
|
+
|
|
122
|
+
if rel_err_max_1 == rel_err_max_2:
|
|
123
|
+
lines.append(f"max relative deviation: {rel_err_max_1:e}")
|
|
124
|
+
else:
|
|
125
|
+
lines.append(f"max relative deviation: {rel_err_max_1:e}")
|
|
126
|
+
lines.append(f"max relative deviation except inf: {rel_err_max_2:e}")
|
|
127
|
+
|
|
128
|
+
lines.append(f"max absolute deviation: {abs_err_max:e}")
|
|
129
|
+
|
|
130
|
+
n_diff = np.sum(
|
|
131
|
+
np.logical_not(
|
|
132
|
+
np.isclose(current_obj, recorded_obj, rtol=self.rtol, atol=self.atol)
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
lines.append(
|
|
137
|
+
f"both arrays differ in {n_diff} out of {np.prod(recorded_obj.shape)}"
|
|
138
|
+
" entries"
|
|
139
|
+
)
|
|
140
|
+
lines.append(
|
|
141
|
+
f"up to given precision settings rtol={self.rtol:e} and"
|
|
142
|
+
f" atol={self.atol:e}"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
return lines
|
|
146
|
+
|
|
147
|
+
def error_diagnostics_2d_linewise(
|
|
148
|
+
self, current_obj, current_as_text, recorded_obj, recorded_as_text, has_markup
|
|
149
|
+
):
|
|
150
|
+
sub_diff = []
|
|
151
|
+
|
|
152
|
+
for i, (l1, l2, r1, r2) in enumerate(
|
|
153
|
+
zip(current_as_text, recorded_as_text, current_obj, recorded_obj)
|
|
154
|
+
):
|
|
155
|
+
if r1.shape == r2.shape and np.allclose(
|
|
156
|
+
r1, r2, rtol=self.rtol, atol=self.atol
|
|
157
|
+
):
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
if r1.shape == r2.shape:
|
|
161
|
+
# enforces more uniform formatting of both lines:
|
|
162
|
+
rows_together = np.vstack((r1, r2))
|
|
163
|
+
lines_together = self.show(rows_together)
|
|
164
|
+
line_diff = list(
|
|
165
|
+
difflib.unified_diff(
|
|
166
|
+
[lines_together[0][1:].strip()],
|
|
167
|
+
[lines_together[1][:-1].strip()],
|
|
168
|
+
"current",
|
|
169
|
+
"expected",
|
|
170
|
+
lineterm="",
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
else:
|
|
174
|
+
row_1 = self.show(r1)
|
|
175
|
+
row_2 = self.show(r2)
|
|
176
|
+
line_diff = list(
|
|
177
|
+
difflib.unified_diff(
|
|
178
|
+
row_1,
|
|
179
|
+
row_2,
|
|
180
|
+
"current",
|
|
181
|
+
"expected",
|
|
182
|
+
lineterm="",
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
if line_diff:
|
|
187
|
+
if not sub_diff:
|
|
188
|
+
sub_diff = line_diff[:2]
|
|
189
|
+
|
|
190
|
+
l1, l2 = line_diff[-2], line_diff[-1]
|
|
191
|
+
if has_markup:
|
|
192
|
+
l1, l2 = highlight_mismatches(l1, l2)
|
|
193
|
+
|
|
194
|
+
sub_diff.append(f"row {i:3d}: {l1}")
|
|
195
|
+
sub_diff.append(f" {l2}")
|
|
196
|
+
|
|
197
|
+
missing = len(current_as_text) - len(recorded_as_text)
|
|
198
|
+
if missing > 0:
|
|
199
|
+
for i, row in enumerate(current_as_text[-missing:], len(recorded_as_text)):
|
|
200
|
+
# remove duplicate brackets
|
|
201
|
+
row = row.rstrip("]") + "]"
|
|
202
|
+
sub_diff.append(f"row {i:3d}: -{row.lstrip()}")
|
|
203
|
+
if missing < 0:
|
|
204
|
+
for i, row in enumerate(recorded_as_text[missing:], len(current_as_text)):
|
|
205
|
+
# remove duplicate brackets
|
|
206
|
+
row = row.rstrip("]") + "]"
|
|
207
|
+
sub_diff.append(f"row {i:3d}: +{row.lstrip()}")
|
|
208
|
+
|
|
209
|
+
return sub_diff
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import difflib
|
|
2
|
+
import io
|
|
3
|
+
import os.path
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
from .snapshot_handler import BaseSnapshotHandler
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DataFrameHandler(BaseSnapshotHandler):
|
|
12
|
+
def __init__(self, handler_options, pytest_config, tw):
|
|
13
|
+
# default contains a few nested dicts and we flatten those, e.g.
|
|
14
|
+
# { "html": {"border": 1} } -> { "html.border": 1 }
|
|
15
|
+
default = list(pd.options.display.d.items())
|
|
16
|
+
default_flattened = {}
|
|
17
|
+
for k, v in default:
|
|
18
|
+
if isinstance(v, dict):
|
|
19
|
+
for k0, v0 in v.items():
|
|
20
|
+
default_flattened[f"{k}.{k0}"] = v0
|
|
21
|
+
else:
|
|
22
|
+
default_flattened[k] = v
|
|
23
|
+
|
|
24
|
+
# overwrite with user settings:
|
|
25
|
+
items = (default_flattened | handler_options.get("display_options", {})).items()
|
|
26
|
+
|
|
27
|
+
# flatten items as required by pandas.option_context:
|
|
28
|
+
self.display_options_flat = [
|
|
29
|
+
entry for item in items for entry in (f"display.{item[0]}", item[1])
|
|
30
|
+
]
|
|
31
|
+
self.atol = handler_options.get("atol", 0.0)
|
|
32
|
+
self.rtol = handler_options.get("rtol", 0.0)
|
|
33
|
+
|
|
34
|
+
def _filename(self, folder):
|
|
35
|
+
return os.path.join(folder, "dataframe.pkl")
|
|
36
|
+
|
|
37
|
+
def save(self, folder, obj):
|
|
38
|
+
obj.to_pickle(self._filename(folder), compression="gzip")
|
|
39
|
+
|
|
40
|
+
def load(self, folder):
|
|
41
|
+
return pd.read_pickle(self._filename(folder), compression="gzip")
|
|
42
|
+
|
|
43
|
+
def show(self, obj):
|
|
44
|
+
stream = io.StringIO()
|
|
45
|
+
with pd.option_context(*self.display_options_flat):
|
|
46
|
+
print(obj, file=stream)
|
|
47
|
+
return stream.getvalue().splitlines()
|
|
48
|
+
|
|
49
|
+
def compare(self, current, recorded):
|
|
50
|
+
missing = set(
|
|
51
|
+
n
|
|
52
|
+
for (n, t) in set(zip(recorded.columns, recorded.dtypes))
|
|
53
|
+
^ set(zip(current.columns, current.dtypes))
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if missing:
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
common = set(
|
|
60
|
+
n
|
|
61
|
+
for (n, t) in set(zip(recorded.columns, recorded.dtypes))
|
|
62
|
+
& set(zip(current.columns, current.dtypes))
|
|
63
|
+
)
|
|
64
|
+
current_reduced = current[[n for n in current.columns if n in common]]
|
|
65
|
+
recorded_reduced = recorded[[n for n in recorded.columns if n in common]]
|
|
66
|
+
|
|
67
|
+
def extract(df, selector):
|
|
68
|
+
return df[[n for (n, t) in zip(df.columns, df.dtypes) if selector(t)]]
|
|
69
|
+
|
|
70
|
+
current_reduced_floats = extract(
|
|
71
|
+
current_reduced, lambda t: t.type is np.float64
|
|
72
|
+
).to_numpy()
|
|
73
|
+
|
|
74
|
+
current_reduced_other = extract(
|
|
75
|
+
current_reduced, lambda t: t.type is not np.float64
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
recorded_reduced_floats = extract(
|
|
79
|
+
recorded_reduced, lambda t: t.type is np.float64
|
|
80
|
+
).to_numpy()
|
|
81
|
+
|
|
82
|
+
recorded_reduced_other = extract(
|
|
83
|
+
recorded_reduced, lambda t: t.type is not np.float64
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return np.allclose(
|
|
87
|
+
current_reduced_floats,
|
|
88
|
+
recorded_reduced_floats,
|
|
89
|
+
atol=self.atol,
|
|
90
|
+
rtol=self.rtol,
|
|
91
|
+
equal_nan=True,
|
|
92
|
+
) and (current_reduced_other == recorded_reduced_other).all(axis=None)
|
|
93
|
+
|
|
94
|
+
def show_differences(self, current, recorded, has_markup):
|
|
95
|
+
lines = []
|
|
96
|
+
|
|
97
|
+
stream = io.StringIO()
|
|
98
|
+
current.info(buf=stream, verbose=True, memory_usage=False)
|
|
99
|
+
current_info = stream.getvalue().splitlines()[2:][:-1]
|
|
100
|
+
|
|
101
|
+
stream = io.StringIO()
|
|
102
|
+
recorded.info(buf=stream, verbose=True, memory_usage=False)
|
|
103
|
+
recorded_info = stream.getvalue().splitlines()[2:][:-1]
|
|
104
|
+
|
|
105
|
+
info_diff = list(
|
|
106
|
+
difflib.unified_diff(
|
|
107
|
+
current_info,
|
|
108
|
+
recorded_info,
|
|
109
|
+
"current",
|
|
110
|
+
"expected",
|
|
111
|
+
lineterm="",
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
lines.extend(info_diff)
|
|
115
|
+
|
|
116
|
+
recorded_as_text = self.show(recorded)
|
|
117
|
+
current_as_text = self.show(current)
|
|
118
|
+
|
|
119
|
+
diffs = list(
|
|
120
|
+
difflib.unified_diff(
|
|
121
|
+
current_as_text,
|
|
122
|
+
recorded_as_text,
|
|
123
|
+
"current",
|
|
124
|
+
"expected",
|
|
125
|
+
lineterm="",
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
lines.append("")
|
|
130
|
+
if diffs:
|
|
131
|
+
lines.extend(diffs)
|
|
132
|
+
else:
|
|
133
|
+
lines.append("diff is empty, you may want to change the print options")
|
|
134
|
+
|
|
135
|
+
return lines
|
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
import difflib
|
|
2
|
+
import functools
|
|
3
|
+
import inspect
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from hashlib import sha512
|
|
10
|
+
from io import StringIO
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
from _pytest._code.code import TerminalRepr
|
|
14
|
+
from _pytest._io import TerminalWriter
|
|
15
|
+
|
|
16
|
+
from .snapshot_handler import PythonObjectHandler # noqa: F401
|
|
17
|
+
from .snapshot_handler import snapshot_handlers
|
|
18
|
+
|
|
19
|
+
IS_WIN = sys.platform == "win32"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def patch_terminal_size(w, h):
|
|
23
|
+
def get_terminal_size(fallback=None):
|
|
24
|
+
return w, h
|
|
25
|
+
|
|
26
|
+
shutil.get_terminal_size = get_terminal_size
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# we determine actual terminal size before pytest changes this:
|
|
30
|
+
tw, _ = shutil.get_terminal_size()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RegtestException(Exception):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RecordedOutputException(RegtestException):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SnapshotException(RegtestException):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PytestRegtestCommonHooks:
|
|
46
|
+
def __init__(self):
|
|
47
|
+
self._reset_snapshots = []
|
|
48
|
+
self._reset_regtest_outputs = []
|
|
49
|
+
self._failed_snapshots = []
|
|
50
|
+
self._failed_regtests = []
|
|
51
|
+
|
|
52
|
+
@pytest.hookimpl(hookwrapper=False)
|
|
53
|
+
def pytest_terminal_summary(self, terminalreporter, exitstatus, config):
|
|
54
|
+
tr = terminalreporter
|
|
55
|
+
tr.ensure_newline()
|
|
56
|
+
tr.section("pytest-regtest report", sep="-", blue=True, bold=True)
|
|
57
|
+
tr.write("total number of failed regression tests: ", bold=True)
|
|
58
|
+
tr.line(str(len(self._failed_regtests)))
|
|
59
|
+
tr.write("total number of failed snapshot tests : ", bold=True)
|
|
60
|
+
tr.line(str(len(self._failed_snapshots)))
|
|
61
|
+
|
|
62
|
+
if config.getvalue("--regtest-reset"):
|
|
63
|
+
if config.option.verbose:
|
|
64
|
+
tr.line("the following output files have been reset:", bold=True)
|
|
65
|
+
for path in self._reset_regtest_outputs:
|
|
66
|
+
rel_path = os.path.relpath(path)
|
|
67
|
+
tr.line(" " + rel_path)
|
|
68
|
+
for path in self._reset_snapshots:
|
|
69
|
+
rel_path = os.path.relpath(path)
|
|
70
|
+
tr.line(" " + rel_path)
|
|
71
|
+
else:
|
|
72
|
+
tr.write("total number of reset output files: ", bold=True)
|
|
73
|
+
tr.line(
|
|
74
|
+
str(len(self._reset_regtest_outputs) + len(self._reset_snapshots))
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
@pytest.hookimpl(hookwrapper=True)
|
|
78
|
+
def pytest_pyfunc_call(self, pyfuncitem):
|
|
79
|
+
stdout = sys.stdout
|
|
80
|
+
if "regtest_all" in pyfuncitem.fixturenames and hasattr(
|
|
81
|
+
pyfuncitem, "regtest_stream"
|
|
82
|
+
):
|
|
83
|
+
sys.stdout = pyfuncitem.regtest_stream
|
|
84
|
+
yield
|
|
85
|
+
sys.stdout = stdout
|
|
86
|
+
|
|
87
|
+
@pytest.hookimpl(hookwrapper=True)
|
|
88
|
+
def pytest_report_teststatus(self, report, config):
|
|
89
|
+
outcome = yield
|
|
90
|
+
if report.when == "call" and "uses-regtest" in report.keywords:
|
|
91
|
+
if config.getvalue("--regtest-reset"):
|
|
92
|
+
result = outcome.get_result()
|
|
93
|
+
if result[0] != "failed":
|
|
94
|
+
outcome.force_result((result[0], "R", "RESET"))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class PytestRegtestPlugin:
|
|
98
|
+
def __init__(self, recorder):
|
|
99
|
+
self.recorder = recorder
|
|
100
|
+
|
|
101
|
+
@pytest.hookimpl(trylast=True)
|
|
102
|
+
def pytest_runtest_call(self, item):
|
|
103
|
+
if hasattr(item, "regtest_stream"):
|
|
104
|
+
output_exception = self.check_recorded_output(item)
|
|
105
|
+
if output_exception is not None:
|
|
106
|
+
raise output_exception
|
|
107
|
+
|
|
108
|
+
if item.get_closest_marker("xfail") and item.config.getvalue("--regtest-reset"):
|
|
109
|
+
# enforce consistency with xfail:
|
|
110
|
+
assert False
|
|
111
|
+
|
|
112
|
+
def check_recorded_output(self, item):
|
|
113
|
+
test_folder = item.fspath.dirname
|
|
114
|
+
regtest_stream = item.regtest_stream
|
|
115
|
+
version = regtest_stream.version
|
|
116
|
+
if not isinstance(regtest_stream, RegtestStream):
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
orig_identifer, recorded_output_path = result_file_paths(
|
|
120
|
+
test_folder, item.nodeid, version
|
|
121
|
+
)
|
|
122
|
+
config = item.config
|
|
123
|
+
|
|
124
|
+
consider_line_endings = config.getvalue("--regtest-consider-line-endings")
|
|
125
|
+
reset = config.getvalue("--regtest-reset")
|
|
126
|
+
|
|
127
|
+
if reset:
|
|
128
|
+
os.makedirs(os.path.dirname(recorded_output_path), exist_ok=True)
|
|
129
|
+
with open(recorded_output_path + ".out", "w", encoding="utf-8") as fh:
|
|
130
|
+
fh.write("".join(regtest_stream.get_lines()))
|
|
131
|
+
if orig_identifer is not None:
|
|
132
|
+
self.recorder._reset_regtest_outputs.append(
|
|
133
|
+
recorded_output_path + ".item"
|
|
134
|
+
)
|
|
135
|
+
with open(recorded_output_path + ".item", "w") as fh:
|
|
136
|
+
print(orig_identifer, file=fh)
|
|
137
|
+
self.recorder._reset_regtest_outputs.append(recorded_output_path + ".out")
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
if os.path.exists(recorded_output_path + ".out"):
|
|
141
|
+
with open(recorded_output_path + ".out", "r", encoding="utf-8") as fh:
|
|
142
|
+
tobe = fh.readlines()
|
|
143
|
+
recorded_output_file_exists = True
|
|
144
|
+
else:
|
|
145
|
+
tobe = []
|
|
146
|
+
recorded_output_file_exists = False
|
|
147
|
+
|
|
148
|
+
current = regtest_stream.get_lines()
|
|
149
|
+
if consider_line_endings:
|
|
150
|
+
current = [repr(line.rstrip("\n")) for line in current]
|
|
151
|
+
tobe = [repr(line.rstrip("\n")) for line in tobe]
|
|
152
|
+
else:
|
|
153
|
+
current = [line.rstrip() for line in current]
|
|
154
|
+
tobe = [line.rstrip() for line in tobe]
|
|
155
|
+
|
|
156
|
+
if current != tobe:
|
|
157
|
+
self.recorder._failed_regtests.append(item)
|
|
158
|
+
return RecordedOutputException(
|
|
159
|
+
current,
|
|
160
|
+
tobe,
|
|
161
|
+
recorded_output_path,
|
|
162
|
+
regtest_stream,
|
|
163
|
+
recorded_output_file_exists,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
@pytest.hookimpl(hookwrapper=True)
|
|
167
|
+
def pytest_runtest_makereport(self, item, call):
|
|
168
|
+
outcome = yield
|
|
169
|
+
result = outcome.get_result()
|
|
170
|
+
if call.when == "teardown" and hasattr(item, "regtest_stream"):
|
|
171
|
+
if item.config.getvalue("--regtest-tee"):
|
|
172
|
+
tw = TerminalWriter()
|
|
173
|
+
output = item.regtest_stream.get_output()
|
|
174
|
+
|
|
175
|
+
if output:
|
|
176
|
+
tw.line()
|
|
177
|
+
line = "recorded raw output to regtest fixture: "
|
|
178
|
+
line = line.ljust(tw.fullwidth, "-")
|
|
179
|
+
tw.line(line, green=True)
|
|
180
|
+
tw.write(item.regtest_stream.get_output() + "\n", cyan=True)
|
|
181
|
+
line = "-" * tw.fullwidth
|
|
182
|
+
tw.line(line, green=True)
|
|
183
|
+
|
|
184
|
+
if call.when != "call" or not getattr(item, "regtest", False):
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
result.keywords["uses-regtest"] = True
|
|
188
|
+
|
|
189
|
+
if call.excinfo is not None:
|
|
190
|
+
all_lines, all_colors = [], []
|
|
191
|
+
if call.excinfo.type is RecordedOutputException:
|
|
192
|
+
output_exception = call.excinfo
|
|
193
|
+
if output_exception is not None:
|
|
194
|
+
lines, colors = self._handle_regtest_exception(
|
|
195
|
+
item, output_exception.value.args, result
|
|
196
|
+
)
|
|
197
|
+
all_lines.extend(lines)
|
|
198
|
+
all_colors.extend(colors)
|
|
199
|
+
|
|
200
|
+
else:
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
result.longrepr = CollectErrorRepr(all_lines, all_colors)
|
|
204
|
+
|
|
205
|
+
def _handle_regtest_exception(self, item, exc_args, result):
|
|
206
|
+
(
|
|
207
|
+
current,
|
|
208
|
+
recorded,
|
|
209
|
+
recorded_output_path,
|
|
210
|
+
regtest_stream,
|
|
211
|
+
recorded_output_file_exists,
|
|
212
|
+
) = exc_args
|
|
213
|
+
|
|
214
|
+
nodeid = item.nodeid + (
|
|
215
|
+
"" if regtest_stream.version is None else "__" + regtest_stream.version
|
|
216
|
+
)
|
|
217
|
+
if not recorded_output_file_exists:
|
|
218
|
+
msg = "\nregression test output not recorded yet for {}:\n".format(nodeid)
|
|
219
|
+
return (
|
|
220
|
+
[msg] + current,
|
|
221
|
+
[dict()] + len(current) * [dict(red=True, bold=True)],
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
nodiff = item.config.getvalue("--regtest-nodiff")
|
|
225
|
+
diffs = list(
|
|
226
|
+
difflib.unified_diff(current, recorded, "current", "expected", lineterm="")
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
msg = "\nregression test output differences for {}:\n".format(nodeid)
|
|
230
|
+
|
|
231
|
+
if nodiff:
|
|
232
|
+
msg_diff = f" {len(diffs)} lines in diff"
|
|
233
|
+
else:
|
|
234
|
+
recorded_output_path = os.path.relpath(recorded_output_path)
|
|
235
|
+
msg += f" (recorded output from {recorded_output_path})\n"
|
|
236
|
+
msg_diff = " > " + "\n > ".join(diffs)
|
|
237
|
+
|
|
238
|
+
return [msg, msg_diff + "\n"], [dict(), dict(red=True, bold=True)]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class SnapshotPlugin:
|
|
242
|
+
def __init__(self, recorder):
|
|
243
|
+
self.recorder = recorder
|
|
244
|
+
|
|
245
|
+
@pytest.hookimpl(trylast=True)
|
|
246
|
+
def pytest_runtest_call(self, item):
|
|
247
|
+
if hasattr(item, "snapshot"):
|
|
248
|
+
snapshot_exception = self.check_snapshots(item)
|
|
249
|
+
if snapshot_exception is not None:
|
|
250
|
+
raise snapshot_exception
|
|
251
|
+
|
|
252
|
+
if item.get_closest_marker("xfail") and item.config.getvalue("--regtest-reset"):
|
|
253
|
+
# enforce fail
|
|
254
|
+
assert False
|
|
255
|
+
|
|
256
|
+
def check_snapshots(self, item):
|
|
257
|
+
results = []
|
|
258
|
+
|
|
259
|
+
any_failed = False
|
|
260
|
+
for idx, snapshot in enumerate(item.snapshot.snapshots):
|
|
261
|
+
is_recorded, ok, msg = self.check_snapshot(idx, item, snapshot)
|
|
262
|
+
if not ok:
|
|
263
|
+
any_failed = True
|
|
264
|
+
results.append((ok, snapshot, is_recorded, msg))
|
|
265
|
+
|
|
266
|
+
if any_failed:
|
|
267
|
+
self.recorder._failed_snapshots.append(item)
|
|
268
|
+
return SnapshotException(results)
|
|
269
|
+
|
|
270
|
+
def check_snapshot(self, idx, item, snapshot):
|
|
271
|
+
obj, version, handler_options, _ = snapshot
|
|
272
|
+
handler = get_snapshot_handler(obj, handler_options, item.config)
|
|
273
|
+
|
|
274
|
+
test_folder = item.fspath.dirname
|
|
275
|
+
if version is not None:
|
|
276
|
+
identifier = str(version) + "__" + str(idx)
|
|
277
|
+
else:
|
|
278
|
+
identifier = str(idx)
|
|
279
|
+
|
|
280
|
+
config = item.config
|
|
281
|
+
|
|
282
|
+
orig_identifer, recorded_output_path = result_file_paths(
|
|
283
|
+
test_folder, item.nodeid, identifier
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
reset = config.getvalue("--regtest-reset")
|
|
287
|
+
|
|
288
|
+
if reset:
|
|
289
|
+
os.makedirs(recorded_output_path, exist_ok=True)
|
|
290
|
+
handler.save(recorded_output_path, obj)
|
|
291
|
+
if orig_identifer is not None:
|
|
292
|
+
self.recorder._reset_snapshots.append(recorded_output_path + ".item")
|
|
293
|
+
with open(recorded_output_path + ".item", "w") as fh:
|
|
294
|
+
print(orig_identifer, file=fh)
|
|
295
|
+
self.recorder._reset_snapshots.append(recorded_output_path)
|
|
296
|
+
return True, True, None
|
|
297
|
+
|
|
298
|
+
has_markup = item.config.get_terminal_writer().hasmarkup
|
|
299
|
+
if os.path.exists(recorded_output_path):
|
|
300
|
+
recorded_obj = handler.load(recorded_output_path)
|
|
301
|
+
ok = handler.compare(obj, recorded_obj)
|
|
302
|
+
if ok:
|
|
303
|
+
return True, True, None
|
|
304
|
+
msg = handler.show_differences(obj, recorded_obj, has_markup)
|
|
305
|
+
return True, False, msg
|
|
306
|
+
|
|
307
|
+
msg = handler.show(obj)
|
|
308
|
+
return False, False, msg
|
|
309
|
+
|
|
310
|
+
@pytest.hookimpl(hookwrapper=True)
|
|
311
|
+
def pytest_runtest_makereport(self, item, call):
|
|
312
|
+
outcome = yield
|
|
313
|
+
result = outcome.get_result()
|
|
314
|
+
if call.when == "teardown" and hasattr(item, "snapshot"):
|
|
315
|
+
if item.config.getvalue("--regtest-tee"):
|
|
316
|
+
tw = TerminalWriter()
|
|
317
|
+
snapshots = item.snapshot.snapshots
|
|
318
|
+
if not snapshots:
|
|
319
|
+
return
|
|
320
|
+
|
|
321
|
+
tw.line()
|
|
322
|
+
line = "recorded snapshots: "
|
|
323
|
+
line = line.ljust(tw.fullwidth, "-")
|
|
324
|
+
tw.line(line, green=True)
|
|
325
|
+
path = item.fspath.relto(item.session.fspath)
|
|
326
|
+
code_lines = item.fspath.readlines()
|
|
327
|
+
|
|
328
|
+
for obj, version, handler_options, line_no in snapshots:
|
|
329
|
+
info = code_lines[line_no - 1].strip()
|
|
330
|
+
tw.line(f"> {path} +{line_no}")
|
|
331
|
+
tw.line(f"> {info}")
|
|
332
|
+
handler = get_snapshot_handler(obj, handler_options, item.config)
|
|
333
|
+
lines = handler.show(obj)
|
|
334
|
+
for line in lines:
|
|
335
|
+
tw.line(line, cyan=True)
|
|
336
|
+
tw.line("-" * tw.fullwidth, green=True)
|
|
337
|
+
tw.line()
|
|
338
|
+
tw.flush()
|
|
339
|
+
|
|
340
|
+
if call.when != "call" or not hasattr(item, "snapshot"):
|
|
341
|
+
return
|
|
342
|
+
|
|
343
|
+
result.keywords["uses-regtest"] = True
|
|
344
|
+
|
|
345
|
+
if call.excinfo is not None:
|
|
346
|
+
all_lines, all_colors = [], []
|
|
347
|
+
if call.excinfo.type is SnapshotException:
|
|
348
|
+
snapshot_exception = call.excinfo
|
|
349
|
+
if snapshot_exception is not None:
|
|
350
|
+
lines, colors = self._handle_snapshot_exception(
|
|
351
|
+
item, snapshot_exception.value.args, result
|
|
352
|
+
)
|
|
353
|
+
all_lines.extend(lines)
|
|
354
|
+
all_colors.extend(colors)
|
|
355
|
+
else:
|
|
356
|
+
return
|
|
357
|
+
|
|
358
|
+
result.longrepr = CollectErrorRepr(all_lines, all_colors)
|
|
359
|
+
|
|
360
|
+
def _handle_snapshot_exception(self, item, exc_args, result):
|
|
361
|
+
snapshot = item.snapshot
|
|
362
|
+
lines = []
|
|
363
|
+
colors = []
|
|
364
|
+
|
|
365
|
+
code_lines = item.fspath.readlines()
|
|
366
|
+
|
|
367
|
+
NO_COLOR = dict()
|
|
368
|
+
RED = dict(red=True, bold=True)
|
|
369
|
+
GREEN = dict(green=True, bold=False)
|
|
370
|
+
|
|
371
|
+
headline = "\nsnapshot error(s) for {}:".format(item.nodeid)
|
|
372
|
+
lines.append(headline)
|
|
373
|
+
colors.append(NO_COLOR)
|
|
374
|
+
|
|
375
|
+
for ok, snapshot, is_recorded, msg in exc_args[0]:
|
|
376
|
+
obj, version, kw, line_no = snapshot
|
|
377
|
+
info = code_lines[line_no - 1].strip()
|
|
378
|
+
|
|
379
|
+
path = item.fspath.relto(item.session.fspath)
|
|
380
|
+
if ok:
|
|
381
|
+
lines.append("\nsnapshot ok:")
|
|
382
|
+
lines.append(f" > {path} +{line_no}")
|
|
383
|
+
lines.append(f" > {info}")
|
|
384
|
+
colors.append(GREEN)
|
|
385
|
+
colors.append(NO_COLOR)
|
|
386
|
+
colors.append(NO_COLOR)
|
|
387
|
+
elif is_recorded:
|
|
388
|
+
lines.append("\nsnapshot mismatch:")
|
|
389
|
+
lines.append(f" > {path} +{line_no}:")
|
|
390
|
+
lines.append(f" > {info}")
|
|
391
|
+
colors.append(RED)
|
|
392
|
+
colors.append(NO_COLOR)
|
|
393
|
+
colors.append(NO_COLOR)
|
|
394
|
+
nodiff = item.config.getvalue("--regtest-nodiff")
|
|
395
|
+
if nodiff:
|
|
396
|
+
lines.append(f" {len(msg)} lines in report")
|
|
397
|
+
colors.append(RED)
|
|
398
|
+
else:
|
|
399
|
+
lines.extend(" " + ll for ll in msg)
|
|
400
|
+
colors.extend(len(msg) * [RED])
|
|
401
|
+
else:
|
|
402
|
+
headline = "\nsnapshot not recorded yet:"
|
|
403
|
+
lines.append(headline)
|
|
404
|
+
colors.append(NO_COLOR)
|
|
405
|
+
lines.append(" > " + info.strip())
|
|
406
|
+
colors.append(RED)
|
|
407
|
+
lines.extend(" " + ll for ll in msg)
|
|
408
|
+
colors.extend(len(msg) * [RED])
|
|
409
|
+
|
|
410
|
+
return lines, colors
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def result_file_paths(test_folder, nodeid, version):
|
|
414
|
+
file_path, __, test_function_name = nodeid.partition("::")
|
|
415
|
+
file_name = os.path.basename(file_path)
|
|
416
|
+
|
|
417
|
+
orig_test_function_identifier = f"{file_name}::{test_function_name}"
|
|
418
|
+
|
|
419
|
+
for c in "/\\:*\"'?<>|":
|
|
420
|
+
test_function_name = test_function_name.replace(c, "-")
|
|
421
|
+
|
|
422
|
+
# If file name is too long, hash parameters.
|
|
423
|
+
if len(test_function_name) > 100:
|
|
424
|
+
test_function_name = (
|
|
425
|
+
test_function_name[:88]
|
|
426
|
+
+ "__"
|
|
427
|
+
+ sha512(test_function_name.encode("utf-8")).hexdigest()[:10]
|
|
428
|
+
)
|
|
429
|
+
else:
|
|
430
|
+
orig_test_function_identifier = None
|
|
431
|
+
|
|
432
|
+
test_function_name = test_function_name.replace(" ", "_")
|
|
433
|
+
stem, __ = os.path.splitext(file_name)
|
|
434
|
+
if version is not None:
|
|
435
|
+
output_file_name = stem + "." + test_function_name + "__" + str(version)
|
|
436
|
+
else:
|
|
437
|
+
output_file_name = stem + "." + test_function_name
|
|
438
|
+
|
|
439
|
+
return orig_test_function_identifier, os.path.join(
|
|
440
|
+
test_folder, "_regtest_outputs", output_file_name
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class RegtestStream:
|
|
445
|
+
def __init__(self, request):
|
|
446
|
+
request.node.regtest_stream = self
|
|
447
|
+
request.node.regtest = True
|
|
448
|
+
self.request = request
|
|
449
|
+
self.buffer = StringIO()
|
|
450
|
+
self.version = None
|
|
451
|
+
|
|
452
|
+
self.snapshots = []
|
|
453
|
+
|
|
454
|
+
def write(self, what):
|
|
455
|
+
self.buffer.write(what)
|
|
456
|
+
|
|
457
|
+
def flush(self):
|
|
458
|
+
pass
|
|
459
|
+
|
|
460
|
+
def get_lines(self):
|
|
461
|
+
output = self.buffer.getvalue()
|
|
462
|
+
if not output:
|
|
463
|
+
return []
|
|
464
|
+
output = cleanup(output, self.request)
|
|
465
|
+
lines = output.splitlines(keepends=True)
|
|
466
|
+
return lines
|
|
467
|
+
|
|
468
|
+
def get_output(self):
|
|
469
|
+
return self.buffer.getvalue()
|
|
470
|
+
|
|
471
|
+
def __enter__(self):
|
|
472
|
+
sys.stdout = self
|
|
473
|
+
return self
|
|
474
|
+
|
|
475
|
+
def __exit__(self, *a):
|
|
476
|
+
sys.stdout = sys.__stdout__
|
|
477
|
+
return False # dont suppress exception
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
class Snapshot:
|
|
481
|
+
def __init__(self, request):
|
|
482
|
+
request.node.snapshot = self
|
|
483
|
+
request.node.regtest = True
|
|
484
|
+
self.request = request
|
|
485
|
+
self.buffer = StringIO()
|
|
486
|
+
|
|
487
|
+
self.snapshots = []
|
|
488
|
+
|
|
489
|
+
def check(self, obj, *, version=None, **kw):
|
|
490
|
+
line_no = inspect.currentframe().f_back.f_lineno
|
|
491
|
+
self.snapshots.append((obj, version, kw, line_no))
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def cleanup(output, request):
|
|
495
|
+
for converter in _converters_pre:
|
|
496
|
+
output = converter(output, request)
|
|
497
|
+
|
|
498
|
+
if not request.config.getvalue("--regtest-disable-stdconv"):
|
|
499
|
+
output = _std_conversion(output, request)
|
|
500
|
+
|
|
501
|
+
for converter in _converters_post:
|
|
502
|
+
output = converter(output, request)
|
|
503
|
+
|
|
504
|
+
# in python 3 a string should not contain binary symbols...:
|
|
505
|
+
if contains_binary(output):
|
|
506
|
+
request.raiseerror(
|
|
507
|
+
"recorded output for regression test contains unprintable characters."
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
return output
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
# the function below is modified version of http://stackoverflow.com/questions/898669/
|
|
514
|
+
textchars = bytearray({7, 8, 9, 10, 12, 13, 27} | set(range(0x20, 0x100)) - {0x7F})
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def contains_binary(txt):
|
|
518
|
+
return bool(txt.translate(dict(zip(textchars, " " * 9999))).replace(" ", ""))
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
_converters_pre = []
|
|
522
|
+
_converters_post = []
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def clear_converters():
|
|
526
|
+
_converters_pre.clear()
|
|
527
|
+
_converters_post.clear()
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _fix_pre_v2_converter_function(function):
|
|
531
|
+
@functools.wraps(function)
|
|
532
|
+
def fixed_converter_function(output, request):
|
|
533
|
+
return function(output)
|
|
534
|
+
|
|
535
|
+
return fixed_converter_function
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def register_converter_pre(function):
|
|
539
|
+
if function not in _converters_pre:
|
|
540
|
+
signature = inspect.signature(function)
|
|
541
|
+
# keep downward compatibility:
|
|
542
|
+
if len(signature.parameters) == 1:
|
|
543
|
+
function = _fix_pre_v2_converter_function(function)
|
|
544
|
+
_converters_pre.append(function)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def register_converter_post(function):
|
|
548
|
+
if function not in _converters_post:
|
|
549
|
+
signature = inspect.signature(function)
|
|
550
|
+
# keep downward compatibility:
|
|
551
|
+
if len(signature.parameters) == 1:
|
|
552
|
+
function = _fix_pre_v2_converter_function(function)
|
|
553
|
+
_converters_post.append(function)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _std_replacements(request):
|
|
557
|
+
if "tmpdir" in request.fixturenames:
|
|
558
|
+
tmpdir = request.getfixturevalue("tmpdir").strpath + os.path.sep
|
|
559
|
+
yield tmpdir, "<tmpdir_from_fixture>/"
|
|
560
|
+
tmpdir = request.getfixturevalue("tmpdir").strpath
|
|
561
|
+
yield tmpdir, "<tmpdir_from_fixture>"
|
|
562
|
+
|
|
563
|
+
regexp = os.path.join(
|
|
564
|
+
os.path.realpath(tempfile.gettempdir()), "pytest-of-.*", r"pytest-\d+/"
|
|
565
|
+
)
|
|
566
|
+
yield regexp, "<pytest_tempdir>/"
|
|
567
|
+
|
|
568
|
+
regexp = os.path.join(tempfile.gettempdir(), "tmp[_a-zA-Z0-9]+")
|
|
569
|
+
|
|
570
|
+
yield regexp, "<tmpdir_from_tempfile_module>"
|
|
571
|
+
yield (
|
|
572
|
+
os.path.realpath(tempfile.gettempdir()) + os.path.sep,
|
|
573
|
+
"<tmpdir_from_tempfile_module>/",
|
|
574
|
+
)
|
|
575
|
+
yield os.path.realpath(tempfile.gettempdir()), "<tmpdir_from_tempfile_module>"
|
|
576
|
+
yield tempfile.tempdir + os.path.sep, "<tmpdir_from_tempfile_module>/"
|
|
577
|
+
yield tempfile.tempdir, "<tmpdir_from_tempfile_module>"
|
|
578
|
+
yield r"var/folders/.*/pytest-of.*/", "<pytest_tempdir>/"
|
|
579
|
+
|
|
580
|
+
# replace hex object ids in output by 0x?????????
|
|
581
|
+
yield r" 0x[0-9a-fA-F]+", " 0x?????????"
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _std_conversion(output, request):
|
|
585
|
+
fixed = []
|
|
586
|
+
for line in output.splitlines(keepends=True):
|
|
587
|
+
for regex, replacement in _std_replacements(request):
|
|
588
|
+
if IS_WIN:
|
|
589
|
+
# fix windows backwards slashes in regex
|
|
590
|
+
regex = regex.replace("\\", "\\\\")
|
|
591
|
+
line, __ = re.subn(regex, replacement, line)
|
|
592
|
+
fixed.append(line)
|
|
593
|
+
return "".join(fixed)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
class CollectErrorRepr(TerminalRepr):
|
|
597
|
+
def __init__(self, messages, colors):
|
|
598
|
+
self.messages = messages
|
|
599
|
+
self.colors = colors
|
|
600
|
+
|
|
601
|
+
def toterminal(self, out):
|
|
602
|
+
for message, color in zip(self.messages, self.colors):
|
|
603
|
+
out.line(message, **color)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def get_snapshot_handler(obj, handler_options, pytest_config):
|
|
607
|
+
for check, handler in snapshot_handlers:
|
|
608
|
+
if check(obj):
|
|
609
|
+
return handler(handler_options, pytest_config, tw)
|
|
610
|
+
raise ValueError(f"cannot take snapshot for type {obj.__class__}")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
def register_pandas_handler():
|
|
2
|
+
def is_dataframe(obj):
|
|
3
|
+
try:
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
return isinstance(obj, pd.DataFrame)
|
|
7
|
+
except ImportError:
|
|
8
|
+
return False
|
|
9
|
+
|
|
10
|
+
from .pandas_handler import DataFrameHandler
|
|
11
|
+
from .snapshot_handler import snapshot_handlers
|
|
12
|
+
|
|
13
|
+
snapshot_handlers.append((is_dataframe, DataFrameHandler))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def register_numpy_handler():
|
|
17
|
+
def is_numpy(obj):
|
|
18
|
+
try:
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
return isinstance(obj, np.ndarray)
|
|
22
|
+
except ImportError:
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
from .numpy_handler import NumpyHandler
|
|
26
|
+
from .snapshot_handler import snapshot_handlers
|
|
27
|
+
|
|
28
|
+
snapshot_handlers.append((is_numpy, NumpyHandler))
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import difflib
|
|
3
|
+
import os
|
|
4
|
+
import pickle
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from pprint import pformat
|
|
7
|
+
from typing import Any, Type, Union
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BaseSnapshotHandler(abc.ABC):
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
handler_options: dict[str, Any],
|
|
16
|
+
pytest_config: Type[pytest.Config],
|
|
17
|
+
tw: int,
|
|
18
|
+
) -> None: ...
|
|
19
|
+
|
|
20
|
+
@abc.abstractmethod
|
|
21
|
+
def save(self, folder: Union[str, os.PathLike], obj: Any) -> None: ...
|
|
22
|
+
|
|
23
|
+
@abc.abstractmethod
|
|
24
|
+
def load(self, folder: Union[str, os.PathLike]) -> Any: ...
|
|
25
|
+
|
|
26
|
+
@abc.abstractmethod
|
|
27
|
+
def show(self, obj: Any) -> list[str]: ...
|
|
28
|
+
|
|
29
|
+
@abc.abstractmethod
|
|
30
|
+
def compare(self, current_obj: Any, recorded_obj: Any) -> bool: ...
|
|
31
|
+
|
|
32
|
+
@abc.abstractmethod
|
|
33
|
+
def show_differences(
|
|
34
|
+
self, current_obj: Any, recorded_obj: Any, has_markup: bool
|
|
35
|
+
) -> list[str]: ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
snapshot_handlers: list[tuple[Callable[[Any], bool]]] = []
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PythonObjectHandler(BaseSnapshotHandler):
|
|
42
|
+
def __init__(self, handler_options, pytest_config, tw):
|
|
43
|
+
self.compact = handler_options.get("compact", False)
|
|
44
|
+
|
|
45
|
+
def save(self, folder, obj):
|
|
46
|
+
with open(os.path.join(folder, "object.pkl"), "wb") as fh:
|
|
47
|
+
pickle.dump(obj, fh)
|
|
48
|
+
|
|
49
|
+
def load(self, folder):
|
|
50
|
+
with open(os.path.join(folder, "object.pkl"), "rb") as fh:
|
|
51
|
+
return pickle.load(fh)
|
|
52
|
+
|
|
53
|
+
def show(self, obj):
|
|
54
|
+
return pformat(obj, compact=self.compact).splitlines()
|
|
55
|
+
|
|
56
|
+
def compare(self, current_obj, recorded_obj):
|
|
57
|
+
return recorded_obj == current_obj
|
|
58
|
+
|
|
59
|
+
def show_differences(self, current_obj, recorded_obj, has_markup):
|
|
60
|
+
return list(
|
|
61
|
+
difflib.unified_diff(
|
|
62
|
+
self.show(current_obj),
|
|
63
|
+
self.show(recorded_obj),
|
|
64
|
+
"current",
|
|
65
|
+
"expected",
|
|
66
|
+
lineterm="",
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
snapshot_handlers.append(
|
|
72
|
+
(
|
|
73
|
+
lambda obj: isinstance(obj, (int, float, str, list, tuple, dict, set)),
|
|
74
|
+
PythonObjectHandler,
|
|
75
|
+
),
|
|
76
|
+
)
|
pytest_regtest/utils.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
def highlight_mismatches(l1, l2):
|
|
2
|
+
if not l1 and not l2:
|
|
3
|
+
return l1, l2
|
|
4
|
+
|
|
5
|
+
l1 = l1.ljust(max(len(l1), len(l2)), " ")
|
|
6
|
+
l2 = l2.ljust(max(len(l1), len(l2)), " ")
|
|
7
|
+
|
|
8
|
+
chars_1 = [l1[0]]
|
|
9
|
+
chars_2 = [l2[0]]
|
|
10
|
+
UNDERLINE_ON = "\x1b[21m"
|
|
11
|
+
UNDERLINE_OFF = "\x1b[24m"
|
|
12
|
+
|
|
13
|
+
is_invert = False
|
|
14
|
+
|
|
15
|
+
for c1, c2 in zip(l1[1:], l2[1:]):
|
|
16
|
+
if not is_invert and c1 != c2:
|
|
17
|
+
chars_1.append(UNDERLINE_ON)
|
|
18
|
+
chars_2.append(UNDERLINE_ON)
|
|
19
|
+
is_invert = True
|
|
20
|
+
if is_invert and c1 == c2:
|
|
21
|
+
chars_1.append(UNDERLINE_OFF)
|
|
22
|
+
chars_2.append(UNDERLINE_OFF)
|
|
23
|
+
is_invert = False
|
|
24
|
+
chars_1.append(c1)
|
|
25
|
+
chars_2.append(c2)
|
|
26
|
+
chars_1.append(UNDERLINE_OFF)
|
|
27
|
+
chars_2.append(UNDERLINE_OFF)
|
|
28
|
+
return "".join(chars_1), "".join(chars_2)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pytest-regtest
|
|
3
|
+
Version: 2.2.0
|
|
4
|
+
Summary: pytest plugin for snapshot regression testing
|
|
5
|
+
Project-URL: Source, https://gitlab.com/uweschmitt/pytest-regtest
|
|
6
|
+
Project-URL: Documentation, https://pytest-regtest.readthedocs.org
|
|
7
|
+
Author-email: Uwe Schmitt <uwe.schmitt@id.ethz.ch>
|
|
8
|
+
License: MIT License
|
|
9
|
+
License-File: LICENSE.txt
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Requires-Dist: pytest>7.2
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: black; extra == 'dev'
|
|
19
|
+
Requires-Dist: build; extra == 'dev'
|
|
20
|
+
Requires-Dist: hatchling; extra == 'dev'
|
|
21
|
+
Requires-Dist: mistletoe; extra == 'dev'
|
|
22
|
+
Requires-Dist: mkdocs; extra == 'dev'
|
|
23
|
+
Requires-Dist: mkdocs-awesome-pages-plugin; extra == 'dev'
|
|
24
|
+
Requires-Dist: mkdocs-material; extra == 'dev'
|
|
25
|
+
Requires-Dist: numpy; extra == 'dev'
|
|
26
|
+
Requires-Dist: pandas; extra == 'dev'
|
|
27
|
+
Requires-Dist: pre-commit; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
30
|
+
Requires-Dist: twine; extra == 'dev'
|
|
31
|
+
Requires-Dist: wheel; extra == 'dev'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# About
|
|
35
|
+
|
|
36
|
+
## Introduction
|
|
37
|
+
|
|
38
|
+
`pytest-regtest` is a plugin for [pytest](https://pytest.org) to implement **regression testing**.
|
|
39
|
+
|
|
40
|
+
Unlike [functional testing](https://en.wikipedia.org/wiki/Functional_testing), [regression
|
|
41
|
+
testing](https://en.wikipedia.org/wiki/Regression_testing) testing does not test whether the
|
|
42
|
+
software produces the correct results, but whether it behaves as it did before changes were
|
|
43
|
+
introduced.
|
|
44
|
+
|
|
45
|
+
More specifically, `pytest-regtest` provides **snapshot testing**, which implements regression
|
|
46
|
+
testing by recording data within a test function and comparing this recorded output to a previously
|
|
47
|
+
recorded reference output.
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
To install and activate this plugin execute:
|
|
53
|
+
|
|
54
|
+
$ pip install pytest-regtest
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
## Use case 1: Changing code with no or little testing setup yet
|
|
58
|
+
If you're working with code that has little or no unit testing, you can use regression testing to
|
|
59
|
+
ensure that your changes don't break or alter previous results.
|
|
60
|
+
|
|
61
|
+
**Example**: This can be useful when working with scientific data analysis scripts, which often
|
|
62
|
+
start a long script and then are restructured into different functions.
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
## Use case 2: Testing complex data
|
|
66
|
+
|
|
67
|
+
If your unit tests contain a lot of `assert` statements to check a complex data structure you can
|
|
68
|
+
use regression tests instead.
|
|
69
|
+
|
|
70
|
+
**Example**: To test code which ingests data into a database one can use regression tests on textual
|
|
71
|
+
database dumps.
|
|
72
|
+
|
|
73
|
+
## Use case 3: Testing numpy arrays or pandas data frames
|
|
74
|
+
|
|
75
|
+
If code produces numerical results, such as `numpy` arrays or pandas` data frames, you can
|
|
76
|
+
use `pytest-regtest` to simply record such results and test later with considering relative and
|
|
77
|
+
absolute tolerances.
|
|
78
|
+
|
|
79
|
+
**Example**: A function creates a 10 x 10 matrix. Either you have to write 100 assert statements or
|
|
80
|
+
you use summary statistics to test your result. In both cases, you may get little debugging
|
|
81
|
+
information if a test fails.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
pytest_regtest/__init__.py,sha256=OIo8mNzqvvQ-AiArwcuJvjNvfPOHxwgALfTHqIM1te0,2313
|
|
2
|
+
pytest_regtest/numpy_handler.py,sha256=UZyp9GqO07Kq07qgNSGJq5JXw2vUlgfljGeoxco95Mk,6919
|
|
3
|
+
pytest_regtest/pandas_handler.py,sha256=wlCVYLQU_CJ0x6r9Nok3ToF4CXm8eydBkoil-Nu2rEk,4249
|
|
4
|
+
pytest_regtest/pytest_regtest.py,sha256=b87mqp4ih0puoiKGazHPB79KdTa3rD-D7rRyN0OfDCc,20893
|
|
5
|
+
pytest_regtest/register_third_party_handlers.py,sha256=29bdboPfus_KfnJb0P26-mwF4dEf_Yd4EfdPp75vVpA,725
|
|
6
|
+
pytest_regtest/snapshot_handler.py,sha256=uSngr-Mjnomj6VzDxTjbUnnHsocj00Fiez4EMU0ZMQw,2005
|
|
7
|
+
pytest_regtest/utils.py,sha256=2jYTlV_qL5hH6FCeg7T1HJJvKual-Kux2scJ9_aB1lY,811
|
|
8
|
+
pytest_regtest-2.2.0.dist-info/METADATA,sha256=peSQ9xOiJtyZ0JW9GvyUprtnpWsGVWVHJAsCQLsymRk,3207
|
|
9
|
+
pytest_regtest-2.2.0.dist-info/WHEEL,sha256=fl6v0VwpzfGBVsGtkAkhILUlJxROXbA3HvRL6Fe3140,105
|
|
10
|
+
pytest_regtest-2.2.0.dist-info/entry_points.txt,sha256=4VuIhXeMGhDo0ATbaUfyjND0atofmZjV_P-o6_uEk2s,36
|
|
11
|
+
pytest_regtest-2.2.0.dist-info/licenses/LICENSE.txt,sha256=Tue36uAzpW79-9WAqzkwPhsDDVd1X-VWUmdZ0MfGYvk,1068
|
|
12
|
+
pytest_regtest-2.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2016 Uwe Schmitt
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|