Skip to content

Commit 3bd3703

Browse files
Merge pull request #197 from astrofrog/deterministic-default
2 parents db0ea36 + efc9f33 commit 3bd3703

File tree

6 files changed

+228
-26
lines changed

6 files changed

+228
-26
lines changed

docs/configuration.rst

+7-2
Original file line numberDiff line numberDiff line change
@@ -250,8 +250,8 @@ If the RMS difference is greater than the tolerance, the test will fail.
250250
Whether to make metadata deterministic
251251
--------------------------------------
252252
| **kwarg**: ``deterministic=<bool>``
253-
| **CLI**: ---
254-
| **INI**: ---
253+
| **CLI**: ``--mpl-deterministic`` or ``--mpl-no-deterministic``
254+
| **INI**: ``mpl-deterministic = <bool>``
255255
| Default: ``True`` (PNG: ``False``)
256256
257257
Whether to make the image file metadata deterministic.
@@ -270,6 +270,11 @@ By default, ``pytest-mpl`` will save and compare figures in PNG format.
270270
However, it is possible to set the format to use by setting, e.g., ``savefig_kwargs={"format": "pdf"}`` when configuring the :ref:`savefig_kwargs configuration option <savefig-kwargs>`.
271271
Note that Ghostscript is required to be installed for comparing PDF and EPS figures, while Inkscape is required for SVG comparison.
272272

273+
.. note::
274+
275+
A future major release of ``pytest-mpl`` will generate deterministic PNG files by default.
276+
It is recommended to explicitly set this configuration option to avoid hashes changing.
277+
273278
Whether to remove titles and axis tick labels
274279
---------------------------------------------
275280
| **kwargs**: ``remove_text=<bool>``

pytest_mpl/plugin.py

+59-3
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,13 @@ def pytest_addoption(parser):
203203
group.addoption(f"--{option}", help=msg, action="store")
204204
parser.addini(option, help=msg)
205205

206+
msg = "whether to make the image file metadata deterministic"
207+
option_true = "mpl-deterministic"
208+
option_false = "mpl-no-deterministic"
209+
group.addoption(f"--{option_true}", help=msg, action="store_true")
210+
group.addoption(f"--{option_false}", help=msg, action="store_true")
211+
parser.addini(option_true, help=msg, type="bool", default=None)
212+
206213
msg = "default backend to use for tests, unless specified in the mpl_image_compare decorator"
207214
option = "mpl-default-backend"
208215
group.addoption(f"--{option}", help=msg, action="store")
@@ -244,6 +251,21 @@ def get_cli_or_ini(name, default=None):
244251
default_tolerance = int(default_tolerance)
245252
else:
246253
default_tolerance = float(default_tolerance)
254+
255+
deterministic_ini = config.getini("mpl-deterministic")
256+
deterministic_flag_true = config.getoption("--mpl-deterministic")
257+
deterministic_flag_false = config.getoption("--mpl-no-deterministic")
258+
if deterministic_flag_true and deterministic_flag_false:
259+
raise ValueError("Only one of `--mpl-deterministic` and `--mpl-no-deterministic` can be set.")
260+
if deterministic_flag_true:
261+
deterministic = True
262+
elif deterministic_flag_false:
263+
deterministic = False
264+
elif isinstance(deterministic_ini, bool):
265+
deterministic = deterministic_ini
266+
else:
267+
deterministic = None
268+
247269
default_style = get_cli_or_ini("mpl-default-style", DEFAULT_STYLE)
248270
default_backend = get_cli_or_ini("mpl-default-backend", DEFAULT_BACKEND)
249271

@@ -279,6 +301,7 @@ def get_cli_or_ini(name, default=None):
279301
use_full_test_name=use_full_test_name,
280302
default_style=default_style,
281303
default_tolerance=default_tolerance,
304+
deterministic=deterministic,
282305
default_backend=default_backend,
283306
_hash_library_from_cli=_hash_library_from_cli,
284307
)
@@ -341,6 +364,7 @@ def __init__(
341364
use_full_test_name=False,
342365
default_style=DEFAULT_STYLE,
343366
default_tolerance=DEFAULT_TOLERANCE,
367+
deterministic=None,
344368
default_backend=DEFAULT_BACKEND,
345369
_hash_library_from_cli=False, # for backwards compatibility
346370
):
@@ -367,6 +391,7 @@ def __init__(
367391

368392
self.default_style = default_style
369393
self.default_tolerance = default_tolerance
394+
self.deterministic = deterministic
370395
self.default_backend = default_backend
371396

372397
# Generate the containing dir for all test results
@@ -639,12 +664,45 @@ def save_figure(self, item, fig, filename):
639664
filename = str(filename)
640665
compare = get_compare(item)
641666
savefig_kwargs = compare.kwargs.get('savefig_kwargs', {})
642-
deterministic = compare.kwargs.get('deterministic', False)
667+
deterministic = compare.kwargs.get('deterministic', self.deterministic)
643668

644669
original_source_date_epoch = os.environ.get('SOURCE_DATE_EPOCH', None)
645670

646671
extra_rcparams = {}
647672

673+
ext = self._file_extension(item)
674+
675+
if deterministic is None:
676+
677+
# The deterministic option should only matter for hash-based tests,
678+
# so we first check if a hash library is being used
679+
680+
if self.hash_library or compare.kwargs.get('hash_library', None):
681+
682+
if ext == 'png':
683+
if 'metadata' not in savefig_kwargs or 'Software' not in savefig_kwargs['metadata']:
684+
warnings.warn("deterministic option not set (currently defaulting to False), "
685+
"in future this will default to True to give consistent "
686+
"hashes across Matplotlib versions. To suppress this warning, "
687+
"set deterministic to True if you are happy with the future "
688+
"behavior or to False if you want to preserve the old behavior.",
689+
FutureWarning)
690+
else:
691+
# Set to False but in practice because Software is set to a constant value
692+
# by the caller, the output will be deterministic (we don't want to change
693+
# Software to None if the caller set it to e.g. 'test')
694+
deterministic = False
695+
else:
696+
deterministic = True
697+
698+
else:
699+
700+
# We can just default to True since it shouldn't matter and in
701+
# case generated images are somehow used in future to compute
702+
# hashes
703+
704+
deterministic = True
705+
648706
if deterministic:
649707

650708
# Make sure we don't modify the original dictionary in case is a common
@@ -654,8 +712,6 @@ def save_figure(self, item, fig, filename):
654712
if 'metadata' not in savefig_kwargs:
655713
savefig_kwargs['metadata'] = {}
656714

657-
ext = self._file_extension(item)
658-
659715
if ext == 'png':
660716
extra_metadata = {"Software": None}
661717
elif ext == 'pdf':

tests/helpers.py

+29
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,36 @@
1+
import sys
12
from pathlib import Path
23

4+
import matplotlib
5+
import pytest
6+
from matplotlib.testing.compare import converter
7+
from packaging.version import Version
8+
9+
MPL_VERSION = Version(matplotlib.__version__)
10+
311

412
def pytester_path(pytester):
513
if hasattr(pytester, "path"):
614
return pytester.path
715
return Path(pytester.tmpdir) # pytest v5
16+
17+
18+
def skip_if_format_unsupported(file_format, using_hashes=False):
19+
if file_format == 'svg' and MPL_VERSION < Version('3.3'):
20+
pytest.skip('SVG comparison is only supported in Matplotlib 3.3 and above')
21+
22+
if using_hashes:
23+
24+
if file_format == 'pdf' and MPL_VERSION < Version('2.1'):
25+
pytest.skip('PDF hashes are only deterministic in Matplotlib 2.1 and above')
26+
elif file_format == 'eps' and MPL_VERSION < Version('2.1'):
27+
pytest.skip('EPS hashes are only deterministic in Matplotlib 2.1 and above')
28+
29+
if using_hashes and not sys.platform.startswith('linux'):
30+
pytest.skip('Hashes for vector graphics are only provided in the hash library for Linux')
31+
32+
if file_format != 'png' and file_format not in converter:
33+
if file_format == 'svg':
34+
pytest.skip('Comparing SVG files requires inkscape to be installed')
35+
else:
36+
pytest.skip('Comparing EPS and PDF files requires ghostscript to be installed')

tests/test_deterministic.py

+128
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import matplotlib
2+
import matplotlib.pyplot as plt
3+
import pytest
4+
from helpers import pytester_path, skip_if_format_unsupported
5+
from packaging.version import Version
6+
from PIL import Image
7+
8+
MPL_VERSION = Version(matplotlib.__version__)
9+
10+
METADATA = {
11+
"png": {"Software": None},
12+
"pdf": {"Creator": None, "Producer": None, "CreationDate": None},
13+
"eps": {"Creator": "test"},
14+
"svg": {"Date": None},
15+
}
16+
17+
18+
def test_multiple_cli_flags(pytester):
19+
result = pytester.runpytest("--mpl", "--mpl-deterministic", "--mpl-no-deterministic")
20+
result.stderr.fnmatch_lines(
21+
["*ValueError: Only one of `--mpl-deterministic` and `--mpl-no-deterministic` can be set.*"]
22+
)
23+
24+
25+
def test_warning(pytester):
26+
path = pytester_path(pytester)
27+
hash_library = path / "hash_library.json"
28+
kwarg = f"hash_library=r'{hash_library}'"
29+
pytester.makepyfile(
30+
f"""
31+
import matplotlib.pyplot as plt
32+
import pytest
33+
@pytest.mark.mpl_image_compare({kwarg})
34+
def test_mpl():
35+
fig, ax = plt.subplots()
36+
ax.plot([1, 3, 2])
37+
return fig
38+
"""
39+
)
40+
result = pytester.runpytest(f"--mpl-generate-hash-library={hash_library}")
41+
result.stdout.fnmatch_lines(["*FutureWarning: deterministic option not set*"])
42+
result.assert_outcomes(failed=1)
43+
44+
45+
@pytest.mark.parametrize("file_format", ["eps", "pdf", "png", "svg"])
46+
@pytest.mark.parametrize(
47+
"ini, cli, kwarg, success_expected",
48+
[
49+
("true", "", None, True),
50+
("false", "--mpl-deterministic", None, True),
51+
("true", "--mpl-no-deterministic", None, False),
52+
("", "--mpl-no-deterministic", True, True),
53+
("true", "", False, False),
54+
],
55+
)
56+
@pytest.mark.skipif(MPL_VERSION < Version("3.3.0"), reason="Test unsupported: Default metadata is different in MPL<3.3")
57+
def test_config(pytester, file_format, ini, cli, kwarg, success_expected):
58+
skip_if_format_unsupported(file_format, using_hashes=True)
59+
60+
path = pytester_path(pytester)
61+
baseline_dir = path / "baseline"
62+
hash_library = path / "hash_library.json"
63+
64+
ini = f"mpl-deterministic = {ini}" if ini else ""
65+
pytester.makeini(
66+
f"""
67+
[pytest]
68+
mpl-hash-library = {hash_library}
69+
{ini}
70+
"""
71+
)
72+
73+
kwarg = f", deterministic={kwarg}" if isinstance(kwarg, bool) else ""
74+
pytester.makepyfile(
75+
f"""
76+
import matplotlib.pyplot as plt
77+
import pytest
78+
@pytest.mark.mpl_image_compare(savefig_kwargs={{'format': '{file_format}'}}{kwarg})
79+
def test_mpl():
80+
fig, ax = plt.subplots()
81+
ax.plot([1, 2, 3])
82+
return fig
83+
"""
84+
)
85+
86+
# Generate baseline hashes
87+
assert not hash_library.exists()
88+
pytester.runpytest(
89+
f"--mpl-generate-path={baseline_dir}",
90+
f"--mpl-generate-hash-library={hash_library}",
91+
cli,
92+
)
93+
assert hash_library.exists()
94+
baseline_image = baseline_dir / f"test_mpl.{file_format}"
95+
assert baseline_image.exists()
96+
deterministic_metadata = METADATA[file_format]
97+
98+
if file_format == "svg": # The only format that is reliably non-deterministic between runs
99+
result = pytester.runpytest("--mpl", f"--mpl-baseline-path={baseline_dir}", cli)
100+
if success_expected:
101+
result.assert_outcomes(passed=1)
102+
else:
103+
result.assert_outcomes(failed=1)
104+
105+
elif file_format == "pdf":
106+
with open(baseline_image, "rb") as fp:
107+
file = str(fp.read())
108+
for metadata_key in deterministic_metadata.keys():
109+
key_in_file = fr"/{metadata_key}" in file
110+
if success_expected: # metadata keys should not be in the file
111+
assert not key_in_file
112+
else:
113+
assert key_in_file
114+
115+
else: # "eps" or "png"
116+
actual_metadata = Image.open(str(baseline_image)).info
117+
for k, expected in deterministic_metadata.items():
118+
actual = actual_metadata.get(k, None)
119+
if success_expected: # metadata keys should not be in the file
120+
if expected is None:
121+
assert actual is None
122+
else:
123+
assert actual == expected
124+
else: # metadata keys should still be in the file
125+
if expected is None:
126+
assert actual is not None
127+
else:
128+
assert actual != expected

tests/test_hash_library.py

+1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ def test_config(pytester, ini, cli, kwarg, success_expected):
2424
pytester.makeini(
2525
f"""
2626
[pytest]
27+
mpl-deterministic: true
2728
{ini}
2829
"""
2930
)

tests/test_pytest_mpl.py

+4-21
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import matplotlib.ft2font
1010
import matplotlib.pyplot as plt
1111
import pytest
12-
from matplotlib.testing.compare import converter
12+
from helpers import skip_if_format_unsupported
1313
from packaging.version import Version
1414

1515
MPL_VERSION = Version(matplotlib.__version__)
@@ -668,31 +668,14 @@ def test_raises():
668668
@pytest.mark.parametrize('use_hash_library', (False, True))
669669
@pytest.mark.parametrize('passes', (False, True))
670670
@pytest.mark.parametrize("file_format", ['eps', 'pdf', 'png', 'svg'])
671-
@pytest.mark.skipif(not hash_library.exists(), reason="No hash library for this mpl version")
672671
def test_formats(pytester, use_hash_library, passes, file_format):
673672
"""
674673
Note that we don't test all possible formats as some do not compress well
675674
and would bloat the baseline directory.
676675
"""
677-
678-
if file_format == 'svg' and MPL_VERSION < Version('3.3'):
679-
pytest.skip('SVG comparison is only supported in Matplotlib 3.3 and above')
680-
681-
if use_hash_library:
682-
683-
if file_format == 'pdf' and MPL_VERSION < Version('2.1'):
684-
pytest.skip('PDF hashes are only deterministic in Matplotlib 2.1 and above')
685-
elif file_format == 'eps' and MPL_VERSION < Version('2.1'):
686-
pytest.skip('EPS hashes are only deterministic in Matplotlib 2.1 and above')
687-
688-
if use_hash_library and not sys.platform.startswith('linux'):
689-
pytest.skip('Hashes for vector graphics are only provided in the hash library for Linux')
690-
691-
if file_format != 'png' and file_format not in converter:
692-
if file_format == 'svg':
693-
pytest.skip('Comparing SVG files requires inkscape to be installed')
694-
else:
695-
pytest.skip('Comparing EPS and PDF files requires ghostscript to be installed')
676+
skip_if_format_unsupported(file_format, using_hashes=use_hash_library)
677+
if use_hash_library and not hash_library.exists():
678+
pytest.skip("No hash library for this mpl version")
696679

697680
pytester.makepyfile(
698681
f"""

0 commit comments

Comments
 (0)