Repository: cuthbertLab/music21 (python)

Issue:
stream._cache can disappear at the most inopportune moment
<!-- Version of music21:  print(music21.__version__) -->
**music21 version**

`6.3.0`   <!-- replace with actual version -->


**Problem summary**
<!-- Briefly: what undesired thing happens when what action is taken? -->
The cache can get cleared between adding _cache_['Duration'] and setting _cache_['Duration'].quarterLength.

**Steps to reproduce**
I set duration.tuplets on a note that was already in a stream. Some time later, someone called stream.duration, and the following code in stream/__init__.py (_getDuration) crashed:

            # environLocal.printDebug(['creating new duration based on highest time'])
            self._cache['Duration'] = duration.Duration()
            self._cache['Duration'].quarterLength = self.highestTime <--- BOOM, KeyError, which seems impossible, right?
            return self._cache['Duration']

Some investigation showed that self.highestTime cleared the cache in coreElementsChanged().  To be more clear, self.highestTime called _getQuarterLength on my modified duration, so updateQuarterLength gets called, which ultimately calls informClient/informSites/coreElementsChanged, which sets stream._cache_ to {}, making:
            self._cache['Duration'].quarterLength = self.highestTime
raise KeyError while trying to evaluate self._cache['Duration'] on an empty cache.

Proposed fix:
    In place of:
            self._cache['Duration'] = duration.Duration()
            self._cache['Duration'].quarterLength = self.highestTime
    do:
            highestTime = self.highestTime # might clear self._cache!
            self._cache['Duration'] = duration.Duration()
            self._cache['Duration'].quarterLength = highestTime

Workaround:
In my new Humdrum converter's tuplet handling code, when I set duration.tuplets on notes that are already in a stream, I now immediately get the quarterLength, to avoid this situation.

```
Paste minimal code example here. Most issues can be reproduced with just a few elements.
Attach external files only after attempting to reproduce using a simple stream.

I'm afraid I don't have a minimal example.

```

**Expected vs. actual behavior**
<!-- Consider annotating the output produced by a function -->

**More information**
<!-- If relevant: OS, suggested fix, attempted workarounds -->

<!-- Note: only Mac and Windows are directly supported by music21 staff.  
     Bug reports that are specific to other OSes w/o a PR will be closed.
 -->


Pull request description:
Add safety to `Stream._getDuration()`
Fixes #957 

(few days, few minutes, what is a Duration anyway?)

Thanks again to @gregchapman-dev for tracking down the interaction with `coreElementsChanged()` so that we could get a reproducer.

Patch:
diff --git a/music21/duration.py b/music21/duration.py
index 8c6318016..6155728c1 100644
--- a/music21/duration.py
+++ b/music21/duration.py
@@ -2845,7 +2845,10 @@ class Duration(prebase.ProtoM21Object, SlottedObjectMixin):
     @property
     def tuplets(self) -> Tuple[Tuplet, ...]:
         '''
-        return a tuple of Tuplet objects
+        Return a tuple of Tuplet objects.
+        May leave a stream containing objects having this duration
+        in an unusable state, requiring :meth:`~music21.stream.core.coreElementsChanged`
+        to be called. For this reason, prefer using :meth:`appendTuplet` to add tuplets.
         '''
         if self._componentsNeedUpdating:
             self._updateComponents()
diff --git a/music21/stream/base.py b/music21/stream/base.py
index 4d8f92316..c4e39b577 100644
--- a/music21/stream/base.py
+++ b/music21/stream/base.py
@@ -7882,8 +7882,7 @@ class Stream(core.StreamCoreMixin, base.Music21Object):
             return self._cache['Duration']
         else:
             # environLocal.printDebug(['creating new duration based on highest time'])
-            self._cache['Duration'] = duration.Duration()
-            self._cache['Duration'].quarterLength = self.highestTime
+            self._cache['Duration'] = duration.Duration(quarterLength=self.highestTime)
             return self._cache['Duration']
 
     def _setDuration(self, durationObj):


Test patch:
diff --git a/music21/stream/tests.py b/music21/stream/tests.py
index 679cc8007..3d1165e16 100644
--- a/music21/stream/tests.py
+++ b/music21/stream/tests.py
@@ -545,6 +545,23 @@ class Test(unittest.TestCase):
         self.assertEqual(a.duration.quarterLength, 2.0)
         self.assertEqual(a.highestTime, 4)
 
+    def testStreamDurationRecalculated(self):
+        from fractions import Fraction
+
+        a = Stream()
+        n = note.Note(quarterLength=1.0)
+        a.append(n)
+        self.assertEqual(a.duration.quarterLength, 1.0)
+
+        # Alter the note's duration in a nonstandard way
+        # Normally, one would call Duration.appendTuplet()
+        tup = duration.Tuplet()
+        n.duration.tuplets = (tup,)
+        # Nonstandard workflow requires coreElementsChanged() to be called
+        # https://github.com/cuthbertLab/music21/issues/957
+        a.coreElementsChanged()
+        self.assertEqual(a.duration.quarterLength, Fraction(2, 3))
+
     def testMeasureStream(self):
         '''An approach to setting TimeSignature measures in offsets and durations
         '''