Repository: kivy/kivy

Issue:
'on_start' is dispatched multiple times while 'on_complete' is dispatched only once (kivy.animation.Sequence)
### Versions

* Python: 3.6.3
* OS: LinuxMint18.2(derived from Ubuntu16.04)
* Kivy: master
* Kivy installation method: pip

### Description

The code below should dispatch `on_start` and `on_complete` only once. But actually `on_start` is dispatched multiple times. And the animation doen't stop.(This is related to #5204)

I already understand why this happend and am fixing it now. The reason I submit this issue is to clarify the bugs and to make commit-message simple. (`git commit -m "fix #5929"` )

### Code and Logs

```python
from kivy.animation import Animation
from kivy.lang import Builder
from kivy.clock import Clock
from kivy.app import runTouchApp


root = Builder.load_string('''
Widget:
    Button:
        id: button
''')

button = root.ids.button
anim = Animation(width=200, d=.5) + Animation(width=50, d=.5)
anim.repeat = True
anim.bind(
    on_complete=lambda *args:  print('on_complete()'),
    on_start=lambda *args: print('on_start()'),
)


Clock.schedule_once(lambda __: anim.start(button))
Clock.schedule_once(lambda __: anim.stop(button), 3)
runTouchApp(root)
```
```text
on_start()
on_start()
on_start()
on_start()
on_complete()
on_start()
on_start()
on_start()
on_start()
.
.
.
```


Discussion:

Resolving patch:
diff --git a/kivy/animation.py b/kivy/animation.py
--- a/kivy/animation.py
+++ b/kivy/animation.py
@@ -84,6 +84,7 @@
 __all__ = ('Animation', 'AnimationTransition')
 
 from math import sqrt, cos, sin, pi
+from collections import ChainMap
 from kivy.event import EventDispatcher
 from kivy.clock import Clock
 from kivy.compat import string_types, iterkeys
@@ -128,7 +129,7 @@ class Animation(EventDispatcher):
     __events__ = ('on_start', 'on_progress', 'on_complete')
 
     def __init__(self, **kw):
-        super(Animation, self).__init__()
+        super().__init__()
         # Initialize
         self._clock_installed = False
         self._duration = kw.pop('d', kw.pop('duration', 1.))
@@ -391,41 +392,7 @@ def __and__(self, animation):
         return Parallel(self, animation)
 
 
-class Sequence(Animation):
-
-    def __init__(self, anim1, anim2):
-        super(Sequence, self).__init__()
-
-        #: Repeat the sequence. See 'Repeating animation' in the header
-        #: documentation.
-        self.repeat = False
-
-        self.anim1 = anim1
-        self.anim2 = anim2
-
-        self.anim1.bind(on_start=self.on_anim1_start,
-                        on_progress=self.on_anim1_progress)
-        self.anim2.bind(on_complete=self.on_anim2_complete,
-                        on_progress=self.on_anim2_progress)
-
-    @property
-    def duration(self):
-        return self.anim1.duration + self.anim2.duration
-
-    def start(self, widget):
-        self.stop(widget)
-        self._widgets[widget.uid] = True
-        self._register()
-        self.anim1.start(widget)
-        self.anim1.bind(on_complete=self.on_anim1_complete)
-
-    def stop(self, widget):
-        self.anim1.stop(widget)
-        self.anim2.stop(widget)
-        props = self._widgets.pop(widget.uid, None)
-        if props:
-            self.dispatch('on_complete', widget)
-        super(Sequence, self).cancel(widget)
+class CompoundAnimation(Animation):
 
     def stop_property(self, widget, prop):
         self.anim1.stop_property(widget, prop)
@@ -437,7 +404,7 @@ def stop_property(self, widget, prop):
     def cancel(self, widget):
         self.anim1.cancel(widget)
         self.anim2.cancel(widget)
-        super(Sequence, self).cancel(widget)
+        super().cancel(widget)
 
     def cancel_property(self, widget, prop):
         '''Even if an animation is running, remove a property. It will not be
@@ -455,11 +422,62 @@ def cancel_property(self, widget, prop):
                 not self.anim2.have_properties_to_animate(widget)):
             self.cancel(widget)
 
-    def on_anim1_start(self, instance, widget):
+    def have_properties_to_animate(self, widget):
+        return (self.anim1.have_properties_to_animate(widget) or
+                self.anim2.have_properties_to_animate(widget))
+
+    @property
+    def animated_properties(self):
+        return ChainMap({},
+                        self.anim2.animated_properties,
+                        self.anim1.animated_properties)
+
+    @property
+    def transition(self):
+        # This property is impossible to implement
+        raise AttributeError(
+            "Can't lookup transition attribute of a CompoundAnimation")
+
+
+class Sequence(CompoundAnimation):
+
+    def __init__(self, anim1, anim2):
+        super().__init__()
+
+        #: Repeat the sequence. See 'Repeating animation' in the header
+        #: documentation.
+        self.repeat = False
+
+        self.anim1 = anim1
+        self.anim2 = anim2
+
+        self.anim1.bind(on_complete=self.on_anim1_complete,
+                        on_progress=self.on_anim1_progress)
+        self.anim2.bind(on_complete=self.on_anim2_complete,
+                        on_progress=self.on_anim2_progress)
+
+    @property
+    def duration(self):
+        return self.anim1.duration + self.anim2.duration
+
+    def stop(self, widget):
+        props = self._widgets.pop(widget.uid, None)
+        self.anim1.stop(widget)
+        self.anim2.stop(widget)
+        if props:
+            self.dispatch('on_complete', widget)
+        super().cancel(widget)
+
+    def start(self, widget):
+        self.stop(widget)
+        self._widgets[widget.uid] = True
+        self._register()
         self.dispatch('on_start', widget)
+        self.anim1.start(widget)
 
     def on_anim1_complete(self, instance, widget):
-        self.anim1.unbind(on_complete=self.on_anim1_complete)
+        if widget.uid not in self._widgets:
+            return
         self.anim2.start(widget)
 
     def on_anim1_progress(self, instance, widget, progress):
@@ -470,9 +488,10 @@ def on_anim2_complete(self, instance, widget):
 
         .. versionadded:: 1.7.1
         '''
+        if widget.uid not in self._widgets:
+            return
         if self.repeat:
             self.anim1.start(widget)
-            self.anim1.bind(on_complete=self.on_anim1_complete)
         else:
             self.dispatch('on_complete', widget)
             self.cancel(widget)
@@ -481,10 +500,10 @@ def on_anim2_progress(self, instance, widget, progress):
         self.dispatch('on_progress', widget, .5 + progress / 2.)
 
 
-class Parallel(Animation):
+class Parallel(CompoundAnimation):
 
     def __init__(self, anim1, anim2):
-        super(Parallel, self).__init__()
+        super().__init__()
         self.anim1 = anim1
         self.anim2 = anim2
 
@@ -495,6 +514,13 @@ def __init__(self, anim1, anim2):
     def duration(self):
         return max(self.anim1.duration, self.anim2.duration)
 
+    def stop(self, widget):
+        self.anim1.stop(widget)
+        self.anim2.stop(widget)
+        if self._widgets.pop(widget.uid, None):
+            self.dispatch('on_complete', widget)
+        super().cancel(widget)
+
     def start(self, widget):
         self.stop(widget)
         self.anim1.start(widget)
@@ -503,33 +529,13 @@ def start(self, widget):
         self._register()
         self.dispatch('on_start', widget)
 
-    def stop(self, widget):
-        self.anim1.stop(widget)
-        self.anim2.stop(widget)
-        props = self._widgets.pop(widget.uid, None)
-        if props:
-            self.dispatch('on_complete', widget)
-        super(Parallel, self).cancel(widget)
-
-    def stop_property(self, widget, prop):
-        self.anim1.stop_property(widget, prop)
-        self.anim2.stop_property(widget, prop)
-        if (not self.anim1.have_properties_to_animate(widget) and
-                not self.anim2.have_properties_to_animate(widget)):
-            self.stop(widget)
-
-    def cancel(self, widget):
-        self.anim1.cancel(widget)
-        self.anim2.cancel(widget)
-        super(Parallel, self).cancel(widget)
-
     def on_anim_complete(self, instance, widget):
         self._widgets[widget.uid]['complete'] += 1
         if self._widgets[widget.uid]['complete'] == 2:
             self.stop(widget)
 
 
-class AnimationTransition(object):
+class AnimationTransition:
     '''Collection of animation functions to be used with the Animation object.
     Easing Functions ported to Kivy from the Clutter Project
     https://developer.gnome.org/clutter/stable/ClutterAlpha.html


Test patch:
diff --git a/kivy/tests/test_animations.py b/kivy/tests/test_animations.py
--- a/kivy/tests/test_animations.py
+++ b/kivy/tests/test_animations.py
@@ -2,139 +2,384 @@
 Animations tests
 ================
 '''
+import pytest
 
-import unittest
-import gc
-from time import time, sleep
-from kivy.animation import Animation, AnimationTransition
-from kivy.uix.widget import Widget
-from kivy.clock import Clock
-from kivy.graphics import Scale
-from kivy.weakproxy import WeakProxy
 
+@pytest.fixture(scope='module')
+def ec_cls():
+    class EventCounter:
+        def __init__(self, anim):
+            self.n_start = 0
+            self.n_progress = 0
+            self.n_complete = 0
+            anim.bind(on_start=self.on_start,
+                      on_progress=self.on_progress,
+                      on_complete=self.on_complete)
 
-class AnimationTestCase(unittest.TestCase):
-    def sleep(self, t):
-        start = time()
-        while time() < start + t:
-            sleep(.01)
-            Clock.tick()
+        def on_start(self, anim, widget):
+            self.n_start += 1
 
-    def setUp(self):
-        self.assertEqual(len(Animation._instances), 0)
-        self.a = Animation(x=100, d=1, t='out_bounce')
-        self.w = Widget()
+        def on_progress(self, anim, widget, progress):
+            self.n_progress += 1
 
-    def tearDown(self):
-        self.assertEqual(len(Animation._instances), 0)
+        def on_complete(self, anim, widget):
+            self.n_complete += 1
+
+        def assert_(self, n_start, n_progress_greater_than_zero, n_complete):
+            assert self.n_start == n_start
+            if n_progress_greater_than_zero:
+                assert self.n_progress > 0
+            else:
+                assert self.n_progress == 0
+            assert self.n_complete == n_complete
+    return EventCounter
+
+
+@pytest.fixture(autouse=True)
+def cleanup():
+    from kivy.animation import Animation
+    for anim in Animation._instances.copy():
+        anim.cancel()
+    Animation._instances.clear()
+
+
+def no_animations_being_played():
+    from kivy.animation import Animation
+    return len(Animation._instances) == 0
+
+
+def sleep(t):
+    from time import time, sleep
+    from kivy.clock import Clock
+    tick = Clock.tick
+    start = time()
+    while time() < start + t:
+        sleep(.01)
+        tick()
+
+
+class TestAnimation:
 
     def test_start_animation(self):
-        self.a.start(self.w)
-        self.sleep(1.5)
-        self.assertAlmostEqual(self.w.x, 100)
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        a = Animation(x=100, d=1)
+        w = Widget()
+        a.start(w)
+        sleep(1.5)
+        assert w.x == pytest.approx(100)
+        assert no_animations_being_played()
 
     def test_animation_duration_0(self):
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
         a = Animation(x=100, d=0)
-        a.start(self.w)
-        self.sleep(.5)
+        w = Widget()
+        a.start(w)
+        sleep(.5)
+        assert no_animations_being_played()
 
     def test_stop_animation(self):
-        self.a.start(self.w)
-        self.sleep(.5)
-        self.a.stop(self.w)
-        self.assertNotAlmostEqual(self.w.x, 100)
-        self.assertNotAlmostEqual(self.w.x, 0)
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        a = Animation(x=100, d=1)
+        w = Widget()
+        a.start(w)
+        sleep(.5)
+        a.stop(w)
+        assert w.x != pytest.approx(100)
+        assert w.x != pytest.approx(0)
+        assert no_animations_being_played()
 
     def test_stop_all(self):
-        self.a.start(self.w)
-        self.sleep(.5)
-        Animation.stop_all(self.w)
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        a = Animation(x=100, d=1)
+        w = Widget()
+        a.start(w)
+        sleep(.5)
+        Animation.stop_all(w)
+        assert no_animations_being_played()
 
     def test_stop_all_2(self):
-        self.a.start(self.w)
-        self.sleep(.5)
-        Animation.stop_all(self.w, 'x')
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        a = Animation(x=100, d=1)
+        w = Widget()
+        a.start(w)
+        sleep(.5)
+        Animation.stop_all(w, 'x')
+        assert no_animations_being_played()
 
     def test_duration(self):
-        self.assertEqual(self.a.duration, 1)
+        from kivy.animation import Animation
+        a = Animation(x=100, d=1)
+        assert a.duration == 1
 
     def test_transition(self):
-        self.assertEqual(self.a.transition, AnimationTransition.out_bounce)
+        from kivy.animation import Animation, AnimationTransition
+        a = Animation(x=100, t='out_bounce')
+        assert a.transition is AnimationTransition.out_bounce
 
     def test_animated_properties(self):
-        self.assertEqual(self.a.animated_properties['x'], 100)
+        from kivy.animation import Animation
+        a = Animation(x=100)
+        assert a.animated_properties == {'x': 100, }
 
     def test_animated_instruction(self):
+        from kivy.graphics import Scale
+        from kivy.animation import Animation
+        a = Animation(x=100, d=1)
         instruction = Scale(3)
-        self.a.start(instruction)
-        self.assertEqual(self.a.animated_properties['x'], 100)
-        self.assertAlmostEqual(instruction.x, 3)
-        self.sleep(1.5)
-        self.assertAlmostEqual(instruction.x, 100)
+        a.start(instruction)
+        assert a.animated_properties == {'x': 100, }
+        assert instruction.x == pytest.approx(3)
+        sleep(1.5)
+        assert instruction.x == pytest.approx(100)
+        assert no_animations_being_played()
 
     def test_weakref(self):
-        widget = Widget()
-        anim = Animation(x=100)
-        anim.start(widget.proxy_ref)
-        del widget
+        import gc
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        w = Widget()
+        a = Animation(x=100)
+        a.start(w.proxy_ref)
+        del w
         gc.collect()
         try:
-            self.sleep(1.)
+            sleep(1.)
         except ReferenceError:
             pass
+        assert no_animations_being_played()
 
 
-class SequentialAnimationTestCase(unittest.TestCase):
-
-    def sleep(self, t):
-        start = time()
-        while time() < start + t:
-            sleep(.01)
-            Clock.tick()
-
-    def setUp(self):
-        self.assertEqual(len(Animation._instances), 0)
-        self.a = Animation(x=100, d=1, t='out_bounce')
-        self.a += Animation(x=0, d=1, t='out_bounce')
-        self.w = Widget()
-
-    def tearDown(self):
-        self.assertEqual(len(Animation._instances), 0)
+class TestSequence:
 
     def test_cancel_all(self):
-        self.a.start(self.w)
-        self.sleep(.5)
-        Animation.cancel_all(self.w)
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        a = Animation(x=100) + Animation(x=0)
+        w = Widget()
+        a.start(w)
+        sleep(.5)
+        Animation.cancel_all(w)
+        assert no_animations_being_played()
 
     def test_cancel_all_2(self):
-        self.a.start(self.w)
-        self.sleep(.5)
-        Animation.cancel_all(self.w, 'x')
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        a = Animation(x=100) + Animation(x=0)
+        w = Widget()
+        a.start(w)
+        sleep(.5)
+        Animation.cancel_all(w, 'x')
+        assert no_animations_being_played()
 
     def test_stop_all(self):
-        self.a.start(self.w)
-        self.sleep(.5)
-        Animation.stop_all(self.w)
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        a = Animation(x=100) + Animation(x=0)
+        w = Widget()
+        a.start(w)
+        sleep(.5)
+        Animation.stop_all(w)
+        assert no_animations_being_played()
 
     def test_stop_all_2(self):
-        self.a.start(self.w)
-        self.sleep(.5)
-        Animation.stop_all(self.w, 'x')
-
-    def _test_on_progress(self, anim, widget, progress):
-        self._on_progress_called = True
-
-    def _test_on_complete(self, anim, widget):
-        self._on_complete_called = True
-
-    def test_events(self):
-        self._on_progress_called = False
-        self._on_complete_called = False
-        self.a.bind(on_progress=self._test_on_progress,
-                    on_complete=self._test_on_complete)
-        self.a.start(self.w)
-        self.sleep(.5)
-        self.assertTrue(self._on_progress_called)
-        self.sleep(2)
-        self.assertTrue(self._on_progress_called)
-        self.assertTrue(self._on_complete_called)
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        a = Animation(x=100) + Animation(x=0)
+        w = Widget()
+        a.start(w)
+        sleep(.5)
+        Animation.stop_all(w, 'x')
+        assert no_animations_being_played()
+
+    def test_count_events(self, ec_cls):
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        a = Animation(x=100, d=.5) + Animation(x=0, d=.5)
+        w = Widget()
+        ec = ec_cls(a)
+        ec1 = ec_cls(a.anim1)
+        ec2 = ec_cls(a.anim2)
+        a.start(w)
+
+        # right after the animation starts
+        ec.assert_(1, False, 0)
+        ec1.assert_(1, False, 0)
+        ec2.assert_(0, False, 0)
+        sleep(.2)
+
+        # during the first half of the animation
+        ec.assert_(1, True, 0)
+        ec1.assert_(1, True, 0)
+        ec2.assert_(0, False, 0)
+        sleep(.5)
+
+        # during the second half of the animation
+        ec.assert_(1, True, 0)
+        ec1.assert_(1, True, 1)
+        ec2.assert_(1, True, 0)
+        sleep(.5)
+
+        # after the animation completed
+        ec.assert_(1, True, 1)
+        ec1.assert_(1, True, 1)
+        ec2.assert_(1, True, 1)
+        assert no_animations_being_played()
+
+    def test_have_properties_to_animate(self):
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        a = Animation(x=100) + Animation(x=0)
+        w = Widget()
+        assert not a.have_properties_to_animate(w)
+        a.start(w)
+        assert a.have_properties_to_animate(w)
+        a.stop(w)
+        assert not a.have_properties_to_animate(w)
+        assert no_animations_being_played()
+
+    def test_animated_properties(self):
+        from kivy.animation import Animation
+        a = Animation(x=100, y=200) + Animation(x=0)
+        assert a.animated_properties == {'x': 0, 'y': 200, }
+
+    def test_transition(self):
+        from kivy.animation import Animation
+        a = Animation(x=100) + Animation(x=0)
+        with pytest.raises(AttributeError):
+            a.transition
+
+
+class TestRepetitiveSequence:
+
+    def test_stop(self):
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        a = Animation(x=100) + Animation(x=0)
+        a.repeat = True
+        w = Widget()
+        a.start(w)
+        a.stop(w)
+        assert no_animations_being_played()
+
+    def test_count_events(self, ec_cls):
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        a = Animation(x=100, d=.5) + Animation(x=0, d=.5)
+        a.repeat = True
+        w = Widget()
+        ec = ec_cls(a)
+        ec1 = ec_cls(a.anim1)
+        ec2 = ec_cls(a.anim2)
+        a.start(w)
+
+        # right after the animation starts
+        ec.assert_(1, False, 0)
+        ec1.assert_(1, False, 0)
+        ec2.assert_(0, False, 0)
+        sleep(.2)
+
+        # during the first half of the first round of the animation
+        ec.assert_(1, True, 0)
+        ec1.assert_(1, True, 0)
+        ec2.assert_(0, False, 0)
+        sleep(.5)
+
+        # during the second half of the first round of the animation
+        ec.assert_(1, True, 0)
+        ec1.assert_(1, True, 1)
+        ec2.assert_(1, True, 0)
+        sleep(.5)
+
+        # during the first half of the second round of the animation
+        ec.assert_(1, True, 0)
+        ec1.assert_(2, True, 1)
+        ec2.assert_(1, True, 1)
+        sleep(.5)
+
+        # during the second half of the second round of the animation
+        ec.assert_(1, True, 0)
+        ec1.assert_(2, True, 2)
+        ec2.assert_(2, True, 1)
+        a.stop(w)
+
+        # after the animation stopped
+        ec.assert_(1, True, 1)
+        ec1.assert_(2, True, 2)
+        ec2.assert_(2, True, 2)
+        assert no_animations_being_played()
+
+
+class TestParallel:
+
+    def test_have_properties_to_animate(self):
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        a = Animation(x=100) & Animation(y=100)
+        w = Widget()
+        assert not a.have_properties_to_animate(w)
+        a.start(w)
+        assert a.have_properties_to_animate(w)
+        a.stop(w)
+        assert not a.have_properties_to_animate(w)
+        assert no_animations_being_played()
+
+    def test_cancel_property(self):
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        a = Animation(x=100) & Animation(y=100)
+        w = Widget()
+        a.start(w)
+        a.cancel_property(w, 'x')
+        assert not no_animations_being_played()
+        a.stop(w)
+        assert no_animations_being_played()
+
+    def test_animated_properties(self):
+        from kivy.animation import Animation
+        a = Animation(x=100) & Animation(y=100)
+        assert a.animated_properties == {'x': 100, 'y': 100, }
+
+    def test_transition(self):
+        from kivy.animation import Animation
+        a = Animation(x=100) & Animation(y=100)
+        with pytest.raises(AttributeError):
+            a.transition
+
+    def test_count_events(self, ec_cls):
+        from kivy.animation import Animation
+        from kivy.uix.widget import Widget
+        a = Animation(x=100) & Animation(y=100, d=.5)
+        w = Widget()
+        ec = ec_cls(a)
+        ec1 = ec_cls(a.anim1)
+        ec2 = ec_cls(a.anim2)
+        a.start(w)
+
+        # right after the animation started
+        ec.assert_(1, False, 0)
+        ec1.assert_(1, False, 0)
+        ec2.assert_(1, False, 0)
+        sleep(.2)
+
+        # during the first half of the animation
+        ec.assert_(1, False, 0)  # n_progress is still 0 !!
+        ec1.assert_(1, True, 0)
+        ec2.assert_(1, True, 0)
+        sleep(.5)
+
+        # during the second half of the animation
+        ec.assert_(1, False, 0)  # n_progress is still 0 !!
+        ec1.assert_(1, True, 0)
+        ec2.assert_(1, True, 1)
+        sleep(.5)
+
+        # after the animation compeleted
+        ec.assert_(1, False, 1)  # n_progress is still 0 !
+        ec1.assert_(1, True, 1)
+        ec2.assert_(1, True, 1)
+        assert no_animations_being_played()