Skip to content

Deprecate attributes and expire deprecation in animation #24131

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Dec 29, 2022
6 changes: 6 additions & 0 deletions doc/api/next_api_changes/behavior/24131-OG.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
``FuncAnimation(save_count=None)``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Passing ``save_count=None`` to `.FuncAnimation` no longer limits the number
of frames to 100. Make sure that it either can be inferred from *frames*
or provide an integer *save_count*.
5 changes: 5 additions & 0 deletions doc/api/next_api_changes/deprecations/24131-OG.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
``Animation`` attributes
~~~~~~~~~~~~~~~~~~~~~~~~

The attributes ``repeat`` of `.TimedAnimation` and subclasses and
``save_count`` of `.FuncAnimation` are considered private and deprecated.
946 changes: 250 additions & 696 deletions doc/missing-references.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion examples/animation/animate_decay.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,7 @@ def run(data):

return line,

ani = animation.FuncAnimation(fig, run, data_gen, interval=100, init_func=init)
# Only save last 100 frames, but run forever
ani = animation.FuncAnimation(fig, run, data_gen, interval=100, init_func=init,
save_count=100)
Comment on lines +54 to +55
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this needed for sphinx-gallery? There isn't otherwise any saving going on in any of these examples.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, SG saves both a gif thumbnail and a js_html output to embed in the docs.

plt.show()
2 changes: 1 addition & 1 deletion examples/animation/rain.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,5 +67,5 @@ def update(frame_number):


# Construct the animation, using the update function as the animation director.
animation = FuncAnimation(fig, update, interval=10)
animation = FuncAnimation(fig, update, interval=10, save_count=100)
plt.show()
2 changes: 1 addition & 1 deletion examples/animation/strip_chart.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,6 @@ def emitter(p=0.1):

# pass a generator in "emitter" to produce data for the update func
ani = animation.FuncAnimation(fig, scope.update, emitter, interval=50,
blit=True)
blit=True, save_count=100)

plt.show()
2 changes: 1 addition & 1 deletion examples/animation/unchained.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,5 +69,5 @@ def update(*args):
return lines

# Construct the animation, using the update function as the animation director.
anim = animation.FuncAnimation(fig, update, interval=10)
anim = animation.FuncAnimation(fig, update, interval=10, save_count=100)
plt.show()
86 changes: 45 additions & 41 deletions lib/matplotlib/animation.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ class MovieWriter(AbstractMovieWriter):
The format used in writing frame data, defaults to 'rgba'.
fig : `~matplotlib.figure.Figure`
The figure to capture data from.
This must be provided by the sub-classes.
This must be provided by the subclasses.
"""

# Builtin writer subclasses additionally define the _exec_key and _args_key
Expand Down Expand Up @@ -1084,7 +1084,7 @@ def _pre_composite_to_white(color):
frame_number = 0
# TODO: Currently only FuncAnimation has a save_count
# attribute. Can we generalize this to all Animations?
save_count_list = [getattr(a, 'save_count', None)
save_count_list = [getattr(a, '_save_count', None)
for a in all_anim]
if None in save_count_list:
total_frames = None
Expand Down Expand Up @@ -1237,7 +1237,7 @@ def to_html5_video(self, embed_limit=None):
This saves the animation as an h264 video, encoded in base64
directly into the HTML5 video tag. This respects :rc:`animation.writer`
and :rc:`animation.bitrate`. This also makes use of the
``interval`` to control the speed, and uses the ``repeat``
*interval* to control the speed, and uses the *repeat*
parameter to decide whether to loop.

Parameters
Expand Down Expand Up @@ -1300,7 +1300,7 @@ def to_html5_video(self, embed_limit=None):
options = ['controls', 'autoplay']

# If we're set to repeat, make it loop
if hasattr(self, 'repeat') and self.repeat:
if getattr(self, '_repeat', False):
options.append('loop')

return VIDEO_TAG.format(video=self._base64_video,
Expand All @@ -1321,17 +1321,18 @@ def to_jshtml(self, fps=None, embed_frames=True, default_mode=None):
embed_frames : bool, optional
default_mode : str, optional
What to do when the animation ends. Must be one of ``{'loop',
'once', 'reflect'}``. Defaults to ``'loop'`` if ``self.repeat``
is True, otherwise ``'once'``.
'once', 'reflect'}``. Defaults to ``'loop'`` if the *repeat*
parameter is True, otherwise ``'once'``.
"""
if fps is None and hasattr(self, '_interval'):
# Convert interval in ms to frames per second
fps = 1000 / self._interval

# If we're not given a default mode, choose one base on the value of
# the repeat attribute
# the _repeat attribute
if default_mode is None:
default_mode = 'loop' if self.repeat else 'once'
default_mode = 'loop' if getattr(self, '_repeat',
False) else 'once'

if not hasattr(self, "_html_representation"):
# Can't open a NamedTemporaryFile twice on Windows, so use a
Expand Down Expand Up @@ -1395,13 +1396,12 @@ class TimedAnimation(Animation):
blit : bool, default: False
Whether blitting is used to optimize drawing.
"""

def __init__(self, fig, interval=200, repeat_delay=0, repeat=True,
event_source=None, *args, **kwargs):
self._interval = interval
# Undocumented support for repeat_delay = None as backcompat.
self._repeat_delay = repeat_delay if repeat_delay is not None else 0
self.repeat = repeat
self._repeat = repeat
# If we're not given an event source, create a new timer. This permits
# sharing timers between animation objects for syncing animations.
if event_source is None:
Expand All @@ -1418,7 +1418,7 @@ def _step(self, *args):
# back.
still_going = super()._step(*args)
if not still_going:
if self.repeat:
if self._repeat:
# Restart the draw loop
self._init_draw()
self.frame_seq = self.new_frame_seq()
Expand All @@ -1438,6 +1438,8 @@ def _step(self, *args):
self.event_source.interval = self._interval
return True

repeat = _api.deprecate_privatize_attribute("3.7")


class ArtistAnimation(TimedAnimation):
"""
Expand Down Expand Up @@ -1594,7 +1596,7 @@ def init_func() -> iterable_of_artists
Additional arguments to pass to each call to *func*. Note: the use of
`functools.partial` is preferred over *fargs*. See *func* for details.

save_count : int, default: 100
save_count : int, optional
Fallback for the number of values from *frames* to cache. This is
only used if the number of frames cannot be inferred from *frames*,
i.e. when it's an iterator without length or a generator.
Expand All @@ -1619,7 +1621,6 @@ def init_func() -> iterable_of_artists
Whether frame data is cached. Disabling cache might be helpful when
frames contain large objects.
"""

def __init__(self, fig, func, frames=None, init_func=None, fargs=None,
save_count=None, *, cache_frame_data=True, **kwargs):
if fargs:
Expand All @@ -1632,7 +1633,7 @@ def __init__(self, fig, func, frames=None, init_func=None, fargs=None,
# Amount of framedata to keep around for saving movies. This is only
# used if we don't know how many frames there will be: in the case
# of no generator or in the case of a callable.
self.save_count = save_count
self._save_count = save_count
# Set up a function that creates a new iterable when needed. If nothing
# is passed in for frames, just use itertools.count, which will just
# keep counting from 0. A callable passed in for frames is assumed to
Expand All @@ -1652,19 +1653,31 @@ def iter_frames(frames=frames):
else:
self._iter_gen = lambda: iter(frames)
if hasattr(frames, '__len__'):
self.save_count = len(frames)
self._save_count = len(frames)
if save_count is not None:
_api.warn_external(
f"You passed in an explicit {save_count=} "
"which is being ignored in favor of "
f"{len(frames)=}."
)
else:
self._iter_gen = lambda: iter(range(frames))
self.save_count = frames

if self.save_count is None:
# If we're passed in and using the default, set save_count to 100.
self.save_count = 100
else:
# itertools.islice returns an error when passed a numpy int instead
# of a native python int (https://bugs.python.org/issue30537).
# As a workaround, convert save_count to a native python int.
self.save_count = int(self.save_count)
self._save_count = frames
if save_count is not None:
_api.warn_external(
f"You passed in an explicit {save_count=} which is being "
f"ignored in favor of {frames=}."
)
if self._save_count is None and cache_frame_data:
_api.warn_external(
f"{frames=!r} which we can infer the length of, "
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this supposed to say "which we cannot 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`."
)
cache_frame_data = False

self._cache_frame_data = cache_frame_data

Expand All @@ -1691,26 +1704,18 @@ def new_saved_frame_seq(self):
self._old_saved_seq = list(self._save_seq)
return iter(self._old_saved_seq)
else:
if self.save_count is not None:
return itertools.islice(self.new_frame_seq(), self.save_count)

else:
if self._save_count is None:
frame_seq = self.new_frame_seq()

def gen():
try:
for _ in range(100):
while True:
yield next(frame_seq)
except StopIteration:
pass
else:
_api.warn_deprecated(
"2.2", message="FuncAnimation.save has truncated "
"your animation to 100 frames. In the future, no "
"such truncation will occur; please pass "
"'save_count' accordingly.")

return gen()
else:
return itertools.islice(self.new_frame_seq(), self._save_count)

def _init_draw(self):
super()._init_draw()
Expand Down Expand Up @@ -1748,10 +1753,7 @@ def _draw_frame(self, framedata):
if self._cache_frame_data:
# Save the data for potential saving of movies.
self._save_seq.append(framedata)

# Make sure to respect save_count (keep only the last save_count
# around)
self._save_seq = self._save_seq[-self.save_count:]
self._save_seq = self._save_seq[-self._save_count:]

# Call the func with framedata and args. If blitting is desired,
# func needs to return a sequence of any artists that were modified.
Expand All @@ -1777,3 +1779,5 @@ def _draw_frame(self, framedata):

for a in self._drawn_artists:
a.set_animated(self._blit)

save_count = _api.deprecate_privatize_attribute("3.7")
76 changes: 71 additions & 5 deletions lib/matplotlib/tests/test_animation.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import os
from pathlib import Path
import platform
import re
import subprocess
import sys
import weakref
Expand Down Expand Up @@ -87,7 +88,7 @@ def test_null_movie_writer(anim):
# output to an opaque background
for k, v in savefig_kwargs.items():
assert writer.savefig_kwargs[k] == v
assert writer._count == anim.save_count
assert writer._count == anim._save_count


@pytest.mark.parametrize('anim', [dict(klass=dict)], indirect=['anim'])
Expand Down Expand Up @@ -233,8 +234,11 @@ def test_animation_repr_html(writer, html, want, anim):
assert want in html


@pytest.mark.parametrize('anim', [dict(frames=iter(range(5)))],
indirect=['anim'])
@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())

Expand Down Expand Up @@ -330,9 +334,11 @@ def frames_generator():

yield frame

MAX_FRAMES = 100
anim = animation.FuncAnimation(fig, animate, init_func=init,
frames=frames_generator,
cache_frame_data=cache_frame_data)
cache_frame_data=cache_frame_data,
save_count=MAX_FRAMES)

writer = NullMovieWriter()
anim.save('unused.null', writer=writer)
Expand Down Expand Up @@ -372,7 +378,9 @@ def animate(i):
return return_value

with pytest.raises(RuntimeError):
animation.FuncAnimation(fig, animate, blit=True)
animation.FuncAnimation(
fig, animate, blit=True, cache_frame_data=False
)


def test_exhausted_animation(tmpdir):
Expand Down Expand Up @@ -440,3 +448,61 @@ def animate(i):

# 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()