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.
@@ -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
+ )
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.25.0
3
+ Root-Is-Purelib: true
4
+ Tag: py2-none-any
5
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ regtest = pytest_regtest
@@ -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.