Skip to content

Commit c9fc6a3

Browse files
authored
Merge pull request #24131 from oscargus/animationdeprecations
Deprecate attributes and expire deprecation in animation
2 parents 5f836e5 + a253f22 commit c9fc6a3

File tree

9 files changed

+383
-746
lines changed

9 files changed

+383
-746
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
``FuncAnimation(save_count=None)``
2+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
3+
4+
Passing ``save_count=None`` to `.FuncAnimation` no longer limits the number
5+
of frames to 100. Make sure that it either can be inferred from *frames*
6+
or provide an integer *save_count*.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
``Animation`` attributes
2+
~~~~~~~~~~~~~~~~~~~~~~~~
3+
4+
The attributes ``repeat`` of `.TimedAnimation` and subclasses and
5+
``save_count`` of `.FuncAnimation` are considered private and deprecated.

doc/missing-references.json

Lines changed: 250 additions & 696 deletions
Large diffs are not rendered by default.

examples/animation/animate_decay.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,5 +50,7 @@ def run(data):
5050

5151
return line,
5252

53-
ani = animation.FuncAnimation(fig, run, data_gen, interval=100, init_func=init)
53+
# Only save last 100 frames, but run forever
54+
ani = animation.FuncAnimation(fig, run, data_gen, interval=100, init_func=init,
55+
save_count=100)
5456
plt.show()

examples/animation/rain.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,5 +67,5 @@ def update(frame_number):
6767

6868

6969
# Construct the animation, using the update function as the animation director.
70-
animation = FuncAnimation(fig, update, interval=10)
70+
animation = FuncAnimation(fig, update, interval=10, save_count=100)
7171
plt.show()

examples/animation/strip_chart.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,6 @@ def emitter(p=0.1):
6161

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

6666
plt.show()

examples/animation/unchained.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,5 +69,5 @@ def update(*args):
6969
return lines
7070

7171
# Construct the animation, using the update function as the animation director.
72-
anim = animation.FuncAnimation(fig, update, interval=10)
72+
anim = animation.FuncAnimation(fig, update, interval=10, save_count=100)
7373
plt.show()

lib/matplotlib/animation.py

Lines changed: 45 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ class MovieWriter(AbstractMovieWriter):
247247
The format used in writing frame data, defaults to 'rgba'.
248248
fig : `~matplotlib.figure.Figure`
249249
The figure to capture data from.
250-
This must be provided by the sub-classes.
250+
This must be provided by the subclasses.
251251
"""
252252

253253
# Builtin writer subclasses additionally define the _exec_key and _args_key
@@ -1083,7 +1083,7 @@ def _pre_composite_to_white(color):
10831083
frame_number = 0
10841084
# TODO: Currently only FuncAnimation has a save_count
10851085
# attribute. Can we generalize this to all Animations?
1086-
save_count_list = [getattr(a, 'save_count', None)
1086+
save_count_list = [getattr(a, '_save_count', None)
10871087
for a in all_anim]
10881088
if None in save_count_list:
10891089
total_frames = None
@@ -1236,7 +1236,7 @@ def to_html5_video(self, embed_limit=None):
12361236
This saves the animation as an h264 video, encoded in base64
12371237
directly into the HTML5 video tag. This respects :rc:`animation.writer`
12381238
and :rc:`animation.bitrate`. This also makes use of the
1239-
``interval`` to control the speed, and uses the ``repeat``
1239+
*interval* to control the speed, and uses the *repeat*
12401240
parameter to decide whether to loop.
12411241
12421242
Parameters
@@ -1299,7 +1299,7 @@ def to_html5_video(self, embed_limit=None):
12991299
options = ['controls', 'autoplay']
13001300

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

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

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

13351336
if not hasattr(self, "_html_representation"):
13361337
# Can't open a NamedTemporaryFile twice on Windows, so use a
@@ -1394,13 +1395,12 @@ class TimedAnimation(Animation):
13941395
blit : bool, default: False
13951396
Whether blitting is used to optimize drawing.
13961397
"""
1397-
13981398
def __init__(self, fig, interval=200, repeat_delay=0, repeat=True,
13991399
event_source=None, *args, **kwargs):
14001400
self._interval = interval
14011401
# Undocumented support for repeat_delay = None as backcompat.
14021402
self._repeat_delay = repeat_delay if repeat_delay is not None else 0
1403-
self.repeat = repeat
1403+
self._repeat = repeat
14041404
# If we're not given an event source, create a new timer. This permits
14051405
# sharing timers between animation objects for syncing animations.
14061406
if event_source is None:
@@ -1417,7 +1417,7 @@ def _step(self, *args):
14171417
# back.
14181418
still_going = super()._step(*args)
14191419
if not still_going:
1420-
if self.repeat:
1420+
if self._repeat:
14211421
# Restart the draw loop
14221422
self._init_draw()
14231423
self.frame_seq = self.new_frame_seq()
@@ -1437,6 +1437,8 @@ def _step(self, *args):
14371437
self.event_source.interval = self._interval
14381438
return True
14391439

1440+
repeat = _api.deprecate_privatize_attribute("3.7")
1441+
14401442

14411443
class ArtistAnimation(TimedAnimation):
14421444
"""
@@ -1593,7 +1595,7 @@ def init_func() -> iterable_of_artists
15931595
Additional arguments to pass to each call to *func*. Note: the use of
15941596
`functools.partial` is preferred over *fargs*. See *func* for details.
15951597
1596-
save_count : int, default: 100
1598+
save_count : int, optional
15971599
Fallback for the number of values from *frames* to cache. This is
15981600
only used if the number of frames cannot be inferred from *frames*,
15991601
i.e. when it's an iterator without length or a generator.
@@ -1618,7 +1620,6 @@ def init_func() -> iterable_of_artists
16181620
Whether frame data is cached. Disabling cache might be helpful when
16191621
frames contain large objects.
16201622
"""
1621-
16221623
def __init__(self, fig, func, frames=None, init_func=None, fargs=None,
16231624
save_count=None, *, cache_frame_data=True, **kwargs):
16241625
if fargs:
@@ -1631,7 +1632,7 @@ def __init__(self, fig, func, frames=None, init_func=None, fargs=None,
16311632
# Amount of framedata to keep around for saving movies. This is only
16321633
# used if we don't know how many frames there will be: in the case
16331634
# of no generator or in the case of a callable.
1634-
self.save_count = save_count
1635+
self._save_count = save_count
16351636
# Set up a function that creates a new iterable when needed. If nothing
16361637
# is passed in for frames, just use itertools.count, which will just
16371638
# keep counting from 0. A callable passed in for frames is assumed to
@@ -1651,19 +1652,31 @@ def iter_frames(frames=frames):
16511652
else:
16521653
self._iter_gen = lambda: iter(frames)
16531654
if hasattr(frames, '__len__'):
1654-
self.save_count = len(frames)
1655+
self._save_count = len(frames)
1656+
if save_count is not None:
1657+
_api.warn_external(
1658+
f"You passed in an explicit {save_count=} "
1659+
"which is being ignored in favor of "
1660+
f"{len(frames)=}."
1661+
)
16551662
else:
16561663
self._iter_gen = lambda: iter(range(frames))
1657-
self.save_count = frames
1658-
1659-
if self.save_count is None:
1660-
# If we're passed in and using the default, set save_count to 100.
1661-
self.save_count = 100
1662-
else:
1663-
# itertools.islice returns an error when passed a numpy int instead
1664-
# of a native python int (https://bugs.python.org/issue30537).
1665-
# As a workaround, convert save_count to a native python int.
1666-
self.save_count = int(self.save_count)
1664+
self._save_count = frames
1665+
if save_count is not None:
1666+
_api.warn_external(
1667+
f"You passed in an explicit {save_count=} which is being "
1668+
f"ignored in favor of {frames=}."
1669+
)
1670+
if self._save_count is None and cache_frame_data:
1671+
_api.warn_external(
1672+
f"{frames=!r} which we can infer the length of, "
1673+
"did not pass an explicit *save_count* "
1674+
f"and passed {cache_frame_data=}. To avoid a possibly "
1675+
"unbounded cache, frame data caching has been disabled. "
1676+
"To suppress this warning either pass "
1677+
"`cache_frame_data=False` or `save_count=MAX_FRAMES`."
1678+
)
1679+
cache_frame_data = False
16671680

16681681
self._cache_frame_data = cache_frame_data
16691682

@@ -1690,26 +1703,18 @@ def new_saved_frame_seq(self):
16901703
self._old_saved_seq = list(self._save_seq)
16911704
return iter(self._old_saved_seq)
16921705
else:
1693-
if self.save_count is not None:
1694-
return itertools.islice(self.new_frame_seq(), self.save_count)
1695-
1696-
else:
1706+
if self._save_count is None:
16971707
frame_seq = self.new_frame_seq()
16981708

16991709
def gen():
17001710
try:
1701-
for _ in range(100):
1711+
while True:
17021712
yield next(frame_seq)
17031713
except StopIteration:
17041714
pass
1705-
else:
1706-
_api.warn_deprecated(
1707-
"2.2", message="FuncAnimation.save has truncated "
1708-
"your animation to 100 frames. In the future, no "
1709-
"such truncation will occur; please pass "
1710-
"'save_count' accordingly.")
1711-
17121715
return gen()
1716+
else:
1717+
return itertools.islice(self.new_frame_seq(), self._save_count)
17131718

17141719
def _init_draw(self):
17151720
super()._init_draw()
@@ -1747,10 +1752,7 @@ def _draw_frame(self, framedata):
17471752
if self._cache_frame_data:
17481753
# Save the data for potential saving of movies.
17491754
self._save_seq.append(framedata)
1750-
1751-
# Make sure to respect save_count (keep only the last save_count
1752-
# around)
1753-
self._save_seq = self._save_seq[-self.save_count:]
1755+
self._save_seq = self._save_seq[-self._save_count:]
17541756

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

17771779
for a in self._drawn_artists:
17781780
a.set_animated(self._blit)
1781+
1782+
save_count = _api.deprecate_privatize_attribute("3.7")

lib/matplotlib/tests/test_animation.py

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
22
from pathlib import Path
33
import platform
4+
import re
45
import subprocess
56
import sys
67
import weakref
@@ -87,7 +88,7 @@ def test_null_movie_writer(anim):
8788
# output to an opaque background
8889
for k, v in savefig_kwargs.items():
8990
assert writer.savefig_kwargs[k] == v
90-
assert writer._count == anim.save_count
91+
assert writer._count == anim._save_count
9192

9293

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

235236

236-
@pytest.mark.parametrize('anim', [dict(frames=iter(range(5)))],
237-
indirect=['anim'])
237+
@pytest.mark.parametrize(
238+
'anim',
239+
[{'save_count': 10, 'frames': iter(range(5))}],
240+
indirect=['anim']
241+
)
238242
def test_no_length_frames(anim):
239243
anim.save('unused.null', writer=NullMovieWriter())
240244

@@ -330,9 +334,11 @@ def frames_generator():
330334

331335
yield frame
332336

337+
MAX_FRAMES = 100
333338
anim = animation.FuncAnimation(fig, animate, init_func=init,
334339
frames=frames_generator,
335-
cache_frame_data=cache_frame_data)
340+
cache_frame_data=cache_frame_data,
341+
save_count=MAX_FRAMES)
336342

337343
writer = NullMovieWriter()
338344
anim.save('unused.null', writer=writer)
@@ -372,7 +378,9 @@ def animate(i):
372378
return return_value
373379

374380
with pytest.raises(RuntimeError):
375-
animation.FuncAnimation(fig, animate, blit=True)
381+
animation.FuncAnimation(
382+
fig, animate, blit=True, cache_frame_data=False
383+
)
376384

377385

378386
def test_exhausted_animation(tmpdir):
@@ -440,3 +448,61 @@ def animate(i):
440448

441449
# 5th frame's data
442450
ax.plot(x, np.sin(x + 4 / 100))
451+
452+
453+
@pytest.mark.parametrize('anim', [dict(klass=dict)], indirect=['anim'])
454+
def test_save_count_override_warnings_has_length(anim):
455+
456+
save_count = 5
457+
frames = list(range(2))
458+
match_target = (
459+
f'You passed in an explicit {save_count=} '
460+
"which is being ignored in favor of "
461+
f"{len(frames)=}."
462+
)
463+
464+
with pytest.warns(UserWarning, match=re.escape(match_target)):
465+
anim = animation.FuncAnimation(
466+
**{**anim, 'frames': frames, 'save_count': save_count}
467+
)
468+
assert anim._save_count == len(frames)
469+
anim._init_draw()
470+
471+
472+
@pytest.mark.parametrize('anim', [dict(klass=dict)], indirect=['anim'])
473+
def test_save_count_override_warnings_scaler(anim):
474+
save_count = 5
475+
frames = 7
476+
match_target = (
477+
f'You passed in an explicit {save_count=} ' +
478+
"which is being ignored in favor of " +
479+
f"{frames=}."
480+
)
481+
482+
with pytest.warns(UserWarning, match=re.escape(match_target)):
483+
anim = animation.FuncAnimation(
484+
**{**anim, 'frames': frames, 'save_count': save_count}
485+
)
486+
487+
assert anim._save_count == frames
488+
anim._init_draw()
489+
490+
491+
@pytest.mark.parametrize('anim', [dict(klass=dict)], indirect=['anim'])
492+
def test_disable_cache_warning(anim):
493+
cache_frame_data = True
494+
frames = iter(range(5))
495+
match_target = (
496+
f"{frames=!r} which we can infer the length of, "
497+
"did not pass an explicit *save_count* "
498+
f"and passed {cache_frame_data=}. To avoid a possibly "
499+
"unbounded cache, frame data caching has been disabled. "
500+
"To suppress this warning either pass "
501+
"`cache_frame_data=False` or `save_count=MAX_FRAMES`."
502+
)
503+
with pytest.warns(UserWarning, match=re.escape(match_target)):
504+
anim = animation.FuncAnimation(
505+
**{**anim, 'cache_frame_data': cache_frame_data, 'frames': frames}
506+
)
507+
assert anim._cache_frame_data is False
508+
anim._init_draw()

0 commit comments

Comments
 (0)