123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553 |
- import os
- from pathlib import Path
- import platform
- import re
- import shutil
- import subprocess
- import sys
- import weakref
- import numpy as np
- import pytest
- import matplotlib as mpl
- from matplotlib import pyplot as plt
- from matplotlib import animation
- from matplotlib.testing.decorators import check_figures_equal
- @pytest.fixture()
- def anim(request):
- """Create a simple animation (with options)."""
- fig, ax = plt.subplots()
- line, = ax.plot([], [])
- ax.set_xlim(0, 10)
- ax.set_ylim(-1, 1)
- def init():
- line.set_data([], [])
- return line,
- def animate(i):
- x = np.linspace(0, 10, 100)
- y = np.sin(x + i)
- line.set_data(x, y)
- return line,
- # "klass" can be passed to determine the class returned by the fixture
- kwargs = dict(getattr(request, 'param', {})) # make a copy
- klass = kwargs.pop('klass', animation.FuncAnimation)
- if 'frames' not in kwargs:
- kwargs['frames'] = 5
- return klass(fig=fig, func=animate, init_func=init, **kwargs)
- class NullMovieWriter(animation.AbstractMovieWriter):
- """
- A minimal MovieWriter. It doesn't actually write anything.
- It just saves the arguments that were given to the setup() and
- grab_frame() methods as attributes, and counts how many times
- grab_frame() is called.
- This class doesn't have an __init__ method with the appropriate
- signature, and it doesn't define an isAvailable() method, so
- it cannot be added to the 'writers' registry.
- """
- def setup(self, fig, outfile, dpi, *args):
- self.fig = fig
- self.outfile = outfile
- self.dpi = dpi
- self.args = args
- self._count = 0
- def grab_frame(self, **savefig_kwargs):
- from matplotlib.animation import _validate_grabframe_kwargs
- _validate_grabframe_kwargs(savefig_kwargs)
- self.savefig_kwargs = savefig_kwargs
- self._count += 1
- def finish(self):
- pass
- def test_null_movie_writer(anim):
- # Test running an animation with NullMovieWriter.
- plt.rcParams["savefig.facecolor"] = "auto"
- filename = "unused.null"
- dpi = 50
- savefig_kwargs = dict(foo=0)
- writer = NullMovieWriter()
- anim.save(filename, dpi=dpi, writer=writer,
- savefig_kwargs=savefig_kwargs)
- assert writer.fig == plt.figure(1) # The figure used by anim fixture
- assert writer.outfile == filename
- assert writer.dpi == dpi
- assert writer.args == ()
- # we enrich the savefig kwargs to ensure we composite transparent
- # output to an opaque background
- for k, v in savefig_kwargs.items():
- assert writer.savefig_kwargs[k] == v
- assert writer._count == anim._save_count
- @pytest.mark.parametrize('anim', [dict(klass=dict)], indirect=['anim'])
- def test_animation_delete(anim):
- if platform.python_implementation() == 'PyPy':
- # Something in the test setup fixture lingers around into the test and
- # breaks pytest.warns on PyPy. This garbage collection fixes it.
- # https://foss.heptapod.net/pypy/pypy/-/issues/3536
- np.testing.break_cycles()
- anim = animation.FuncAnimation(**anim)
- with pytest.warns(Warning, match='Animation was deleted'):
- del anim
- np.testing.break_cycles()
- def test_movie_writer_dpi_default():
- class DummyMovieWriter(animation.MovieWriter):
- def _run(self):
- pass
- # Test setting up movie writer with figure.dpi default.
- fig = plt.figure()
- filename = "unused.null"
- fps = 5
- codec = "unused"
- bitrate = 1
- extra_args = ["unused"]
- writer = DummyMovieWriter(fps, codec, bitrate, extra_args)
- writer.setup(fig, filename)
- assert writer.dpi == fig.dpi
- @animation.writers.register('null')
- class RegisteredNullMovieWriter(NullMovieWriter):
- # To be able to add NullMovieWriter to the 'writers' registry,
- # we must define an __init__ method with a specific signature,
- # and we must define the class method isAvailable().
- # (These methods are not actually required to use an instance
- # of this class as the 'writer' argument of Animation.save().)
- def __init__(self, fps=None, codec=None, bitrate=None,
- extra_args=None, metadata=None):
- pass
- @classmethod
- def isAvailable(cls):
- return True
- WRITER_OUTPUT = [
- ('ffmpeg', 'movie.mp4'),
- ('ffmpeg_file', 'movie.mp4'),
- ('imagemagick', 'movie.gif'),
- ('imagemagick_file', 'movie.gif'),
- ('pillow', 'movie.gif'),
- ('html', 'movie.html'),
- ('null', 'movie.null')
- ]
- def gen_writers():
- for writer, output in WRITER_OUTPUT:
- if not animation.writers.is_available(writer):
- mark = pytest.mark.skip(
- f"writer '{writer}' not available on this system")
- yield pytest.param(writer, None, output, marks=[mark])
- yield pytest.param(writer, None, Path(output), marks=[mark])
- continue
- writer_class = animation.writers[writer]
- for frame_format in getattr(writer_class, 'supported_formats', [None]):
- yield writer, frame_format, output
- yield writer, frame_format, Path(output)
- # Smoke test for saving animations. In the future, we should probably
- # design more sophisticated tests which compare resulting frames a-la
- # matplotlib.testing.image_comparison
- @pytest.mark.parametrize('writer, frame_format, output', gen_writers())
- @pytest.mark.parametrize('anim', [dict(klass=dict)], indirect=['anim'])
- def test_save_animation_smoketest(tmpdir, writer, frame_format, output, anim):
- if frame_format is not None:
- plt.rcParams["animation.frame_format"] = frame_format
- anim = animation.FuncAnimation(**anim)
- dpi = None
- codec = None
- if writer == 'ffmpeg':
- # Issue #8253
- anim._fig.set_size_inches((10.85, 9.21))
- dpi = 100.
- codec = 'h264'
- # Use temporary directory for the file-based writers, which produce a file
- # per frame with known names.
- with tmpdir.as_cwd():
- anim.save(output, fps=30, writer=writer, bitrate=500, dpi=dpi,
- codec=codec)
- del anim
- @pytest.mark.parametrize('writer, frame_format, output', gen_writers())
- def test_grabframe(tmpdir, writer, frame_format, output):
- WriterClass = animation.writers[writer]
- if frame_format is not None:
- plt.rcParams["animation.frame_format"] = frame_format
- fig, ax = plt.subplots()
- dpi = None
- codec = None
- if writer == 'ffmpeg':
- # Issue #8253
- fig.set_size_inches((10.85, 9.21))
- dpi = 100.
- codec = 'h264'
- test_writer = WriterClass()
- # Use temporary directory for the file-based writers, which produce a file
- # per frame with known names.
- with tmpdir.as_cwd():
- with test_writer.saving(fig, output, dpi):
- # smoke test it works
- test_writer.grab_frame()
- for k in {'dpi', 'bbox_inches', 'format'}:
- with pytest.raises(
- TypeError,
- match=f"grab_frame got an unexpected keyword argument {k!r}"
- ):
- test_writer.grab_frame(**{k: object()})
- @pytest.mark.parametrize('writer', [
- pytest.param(
- 'ffmpeg', marks=pytest.mark.skipif(
- not animation.FFMpegWriter.isAvailable(),
- reason='Requires FFMpeg')),
- pytest.param(
- 'imagemagick', marks=pytest.mark.skipif(
- not animation.ImageMagickWriter.isAvailable(),
- reason='Requires ImageMagick')),
- ])
- @pytest.mark.parametrize('html, want', [
- ('none', None),
- ('html5', '<video width'),
- ('jshtml', '<script ')
- ])
- @pytest.mark.parametrize('anim', [dict(klass=dict)], indirect=['anim'])
- def test_animation_repr_html(writer, html, want, anim):
- if platform.python_implementation() == 'PyPy':
- # Something in the test setup fixture lingers around into the test and
- # breaks pytest.warns on PyPy. This garbage collection fixes it.
- # https://foss.heptapod.net/pypy/pypy/-/issues/3536
- np.testing.break_cycles()
- if (writer == 'imagemagick' and html == 'html5'
- # ImageMagick delegates to ffmpeg for this format.
- and not animation.FFMpegWriter.isAvailable()):
- pytest.skip('Requires FFMpeg')
- # create here rather than in the fixture otherwise we get __del__ warnings
- # about producing no output
- anim = animation.FuncAnimation(**anim)
- with plt.rc_context({'animation.writer': writer,
- 'animation.html': html}):
- html = anim._repr_html_()
- if want is None:
- assert html is None
- with pytest.warns(UserWarning):
- del anim # Animation was never run, so will warn on cleanup.
- np.testing.break_cycles()
- else:
- assert want in html
- @pytest.mark.parametrize(
- 'anim',
- [{'save_count': 10, 'frames': iter(range(5))}],
- indirect=['anim']
- )
- def test_no_length_frames(anim):
- anim.save('unused.null', writer=NullMovieWriter())
- def test_movie_writer_registry():
- assert len(animation.writers._registered) > 0
- mpl.rcParams['animation.ffmpeg_path'] = "not_available_ever_xxxx"
- assert not animation.writers.is_available("ffmpeg")
- # something guaranteed to be available in path and exits immediately
- bin = "true" if sys.platform != 'win32' else "where"
- mpl.rcParams['animation.ffmpeg_path'] = bin
- assert animation.writers.is_available("ffmpeg")
- @pytest.mark.parametrize(
- "method_name",
- [pytest.param("to_html5_video", marks=pytest.mark.skipif(
- not animation.writers.is_available(mpl.rcParams["animation.writer"]),
- reason="animation writer not installed")),
- "to_jshtml"])
- @pytest.mark.parametrize('anim', [dict(frames=1)], indirect=['anim'])
- def test_embed_limit(method_name, caplog, tmpdir, anim):
- caplog.set_level("WARNING")
- with tmpdir.as_cwd():
- with mpl.rc_context({"animation.embed_limit": 1e-6}): # ~1 byte.
- getattr(anim, method_name)()
- assert len(caplog.records) == 1
- record, = caplog.records
- assert (record.name == "matplotlib.animation"
- and record.levelname == "WARNING")
- @pytest.mark.parametrize(
- "method_name",
- [pytest.param("to_html5_video", marks=pytest.mark.skipif(
- not animation.writers.is_available(mpl.rcParams["animation.writer"]),
- reason="animation writer not installed")),
- "to_jshtml"])
- @pytest.mark.parametrize('anim', [dict(frames=1)], indirect=['anim'])
- def test_cleanup_temporaries(method_name, tmpdir, anim):
- with tmpdir.as_cwd():
- getattr(anim, method_name)()
- assert list(Path(str(tmpdir)).iterdir()) == []
- @pytest.mark.skipif(shutil.which("/bin/sh") is None, reason="requires a POSIX OS")
- def test_failing_ffmpeg(tmpdir, monkeypatch, anim):
- """
- Test that we correctly raise a CalledProcessError when ffmpeg fails.
- To do so, mock ffmpeg using a simple executable shell script that
- succeeds when called with no arguments (so that it gets registered by
- `isAvailable`), but fails otherwise, and add it to the $PATH.
- """
- with tmpdir.as_cwd():
- monkeypatch.setenv("PATH", ".:" + os.environ["PATH"])
- exe_path = Path(str(tmpdir), "ffmpeg")
- exe_path.write_bytes(b"#!/bin/sh\n[[ $@ -eq 0 ]]\n")
- os.chmod(exe_path, 0o755)
- with pytest.raises(subprocess.CalledProcessError):
- anim.save("test.mpeg")
- @pytest.mark.parametrize("cache_frame_data", [False, True])
- def test_funcanimation_cache_frame_data(cache_frame_data):
- fig, ax = plt.subplots()
- line, = ax.plot([], [])
- class Frame(dict):
- # this subclassing enables to use weakref.ref()
- pass
- def init():
- line.set_data([], [])
- return line,
- def animate(frame):
- line.set_data(frame['x'], frame['y'])
- return line,
- frames_generated = []
- def frames_generator():
- for _ in range(5):
- x = np.linspace(0, 10, 100)
- y = np.random.rand(100)
- frame = Frame(x=x, y=y)
- # collect weak references to frames
- # to validate their references later
- frames_generated.append(weakref.ref(frame))
- yield frame
- MAX_FRAMES = 100
- anim = animation.FuncAnimation(fig, animate, init_func=init,
- frames=frames_generator,
- cache_frame_data=cache_frame_data,
- save_count=MAX_FRAMES)
- writer = NullMovieWriter()
- anim.save('unused.null', writer=writer)
- assert len(frames_generated) == 5
- np.testing.break_cycles()
- for f in frames_generated:
- # If cache_frame_data is True, then the weakref should be alive;
- # if cache_frame_data is False, then the weakref should be dead (None).
- assert (f() is None) != cache_frame_data
- @pytest.mark.parametrize('return_value', [
- # User forgot to return (returns None).
- None,
- # User returned a string.
- 'string',
- # User returned an int.
- 1,
- # User returns a sequence of other objects, e.g., string instead of Artist.
- ('string', ),
- # User forgot to return a sequence (handled in `animate` below.)
- 'artist',
- ])
- def test_draw_frame(return_value):
- # test _draw_frame method
- fig, ax = plt.subplots()
- line, = ax.plot([])
- def animate(i):
- # general update func
- line.set_data([0, 1], [0, i])
- if return_value == 'artist':
- # *not* a sequence
- return line
- else:
- return return_value
- with pytest.raises(RuntimeError):
- animation.FuncAnimation(
- fig, animate, blit=True, cache_frame_data=False
- )
- def test_exhausted_animation(tmpdir):
- fig, ax = plt.subplots()
- def update(frame):
- return []
- anim = animation.FuncAnimation(
- fig, update, frames=iter(range(10)), repeat=False,
- cache_frame_data=False
- )
- with tmpdir.as_cwd():
- anim.save("test.gif", writer='pillow')
- with pytest.warns(UserWarning, match="exhausted"):
- anim._start()
- def test_no_frame_warning(tmpdir):
- fig, ax = plt.subplots()
- def update(frame):
- return []
- anim = animation.FuncAnimation(
- fig, update, frames=[], repeat=False,
- cache_frame_data=False
- )
- with pytest.warns(UserWarning, match="exhausted"):
- anim._start()
- @check_figures_equal(extensions=["png"])
- def test_animation_frame(tmpdir, fig_test, fig_ref):
- # Test the expected image after iterating through a few frames
- # we save the animation to get the iteration because we are not
- # in an interactive framework.
- ax = fig_test.add_subplot()
- ax.set_xlim(0, 2 * np.pi)
- ax.set_ylim(-1, 1)
- x = np.linspace(0, 2 * np.pi, 100)
- line, = ax.plot([], [])
- def init():
- line.set_data([], [])
- return line,
- def animate(i):
- line.set_data(x, np.sin(x + i / 100))
- return line,
- anim = animation.FuncAnimation(
- fig_test, animate, init_func=init, frames=5,
- blit=True, repeat=False)
- with tmpdir.as_cwd():
- anim.save("test.gif")
- # Reference figure without animation
- ax = fig_ref.add_subplot()
- ax.set_xlim(0, 2 * np.pi)
- ax.set_ylim(-1, 1)
- # 5th frame's data
- ax.plot(x, np.sin(x + 4 / 100))
- @pytest.mark.parametrize('anim', [dict(klass=dict)], indirect=['anim'])
- def test_save_count_override_warnings_has_length(anim):
- save_count = 5
- frames = list(range(2))
- match_target = (
- f'You passed in an explicit {save_count=} '
- "which is being ignored in favor of "
- f"{len(frames)=}."
- )
- with pytest.warns(UserWarning, match=re.escape(match_target)):
- anim = animation.FuncAnimation(
- **{**anim, 'frames': frames, 'save_count': save_count}
- )
- assert anim._save_count == len(frames)
- anim._init_draw()
- @pytest.mark.parametrize('anim', [dict(klass=dict)], indirect=['anim'])
- def test_save_count_override_warnings_scaler(anim):
- save_count = 5
- frames = 7
- match_target = (
- f'You passed in an explicit {save_count=} ' +
- "which is being ignored in favor of " +
- f"{frames=}."
- )
- with pytest.warns(UserWarning, match=re.escape(match_target)):
- anim = animation.FuncAnimation(
- **{**anim, 'frames': frames, 'save_count': save_count}
- )
- assert anim._save_count == frames
- anim._init_draw()
- @pytest.mark.parametrize('anim', [dict(klass=dict)], indirect=['anim'])
- def test_disable_cache_warning(anim):
- cache_frame_data = True
- frames = iter(range(5))
- match_target = (
- f"{frames=!r} which we can infer the length of, "
- "did not pass an explicit *save_count* "
- f"and passed {cache_frame_data=}. To avoid a possibly "
- "unbounded cache, frame data caching has been disabled. "
- "To suppress this warning either pass "
- "`cache_frame_data=False` or `save_count=MAX_FRAMES`."
- )
- with pytest.warns(UserWarning, match=re.escape(match_target)):
- anim = animation.FuncAnimation(
- **{**anim, 'cache_frame_data': cache_frame_data, 'frames': frames}
- )
- assert anim._cache_frame_data is False
- anim._init_draw()
- def test_movie_writer_invalid_path(anim):
- if sys.platform == "win32":
- match_str = re.escape("[WinError 3] The system cannot find the path specified:")
- else:
- match_str = re.escape("[Errno 2] No such file or directory: '/foo")
- with pytest.raises(FileNotFoundError, match=match_str):
- anim.save("/foo/bar/aardvark/thiscannotreallyexist.mp4",
- writer=animation.FFMpegFileWriter())
|