Repository: cuthbertLab/music21
Pull request #709 (2020-12-15 01:26:12)

Prevent multiple periods in the filepath from opus.write()
When writing a test for the line I hadn't covered yet, sure enough there was a bug. Multiple periods were being created in a couple places.

Also short-circuited on lack of `.scores` so that we don't raise an ugly `MathDomainError`.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/35713694/badge)](https://coveralls.io/builds/35713694)

Coverage increased (+0.005%) to 91.142% when pulling **ca1f7b3bd371146267afec5a78bca8a19304220a on jacobtylerwalls:opus-write-cleanup** into **b599ebd39ea9900e51861f03a4deceadbd7b7b1e on cuthbertLab:master**.


Patch:
diff --git a/music21/chord/__init__.py b/music21/chord/__init__.py
index b81bfb585..a9f5e20bd 100644
--- a/music21/chord/__init__.py
+++ b/music21/chord/__init__.py
@@ -1212,12 +1212,9 @@ class Chord(note.NotRest):
         False
         '''
         # no need to cache, since third, fifth, and seventh are cached
-        try:
-            third = self.third
-            fifth = self.fifth
-            seventh = self.seventh
-        except ChordException:
-            return False
+        third = self.third
+        fifth = self.fifth
+        seventh = self.seventh
 
         if third is None or fifth is None or seventh is None:
             return False
@@ -1249,13 +1246,8 @@ class Chord(note.NotRest):
         False
         '''
         # no need to cache, since third and fifth are cached
-        try:
-            third = self.third
-            fifth = self.fifth
-            # the only reason it cannot find a third or a fifth is
-            # that there is a complete 7-note diatonic scale present. or no notes
-        except ChordException:
-            return False
+        third = self.third
+        fifth = self.fifth
 
         if third is None or fifth is None:
             return False
@@ -2116,11 +2108,8 @@ class Chord(note.NotRest):
         >>> chord.Chord().isAugmentedTriad()
         False
         '''
-        try:
-            third = self.third
-            fifth = self.fifth
-        except ChordException:
-            return False
+        third = self.third
+        fifth = self.fifth
 
         if third is None or fifth is None:
             return False
@@ -2261,17 +2250,15 @@ class Chord(note.NotRest):
 
         intervalArray can be any iterable.
         '''
-        try:
-            third = self.third
-            fifth = self.fifth
-            seventh = self.seventh
-        except ChordException:
+        third = self.third
+        fifth = self.fifth
+        seventh = self.seventh
+
+        if third is None or fifth is None or seventh is None:
             return False
 
         root = self.root()
 
-        if third is None or fifth is None or seventh is None:
-            return False
         for thisPitch in self.pitches:
             thisInterval = interval.notesToInterval(root, thisPitch)
             if thisInterval.chromatic.mod12 not in intervalArray:
@@ -2300,11 +2287,8 @@ class Chord(note.NotRest):
         >>> chord.Chord().isDiminishedTriad()
         False
         '''
-        try:
-            third = self.third
-            fifth = self.fifth
-        except ChordException:
-            return False
+        third = self.third
+        fifth = self.fifth
 
         if third is None or fifth is None:
             return False
@@ -2666,10 +2650,7 @@ class Chord(note.NotRest):
         if self.chordTablesAddress[:2] != (2, 3):
             return False
 
-        try:
-            third = self.third
-        except ChordException:
-            return False
+        third = self.third
         if third is None:
             return False
 
@@ -2825,11 +2806,8 @@ class Chord(note.NotRest):
         if self.chordTablesAddress[:3] != (3, 11, -1):
             return False
 
-        try:
-            third = self.third
-            fifth = self.fifth
-        except ChordException:
-            return False
+        third = self.third
+        fifth = self.fifth
         if third is None or fifth is None:
             return False
 
@@ -2870,11 +2848,8 @@ class Chord(note.NotRest):
         if self.chordTablesAddress[:3] != (3, 11, 1):
             return False
 
-        try:
-            third = self.third
-            fifth = self.fifth
-        except ChordException:
-            return False
+        third = self.third
+        fifth = self.fifth
         if third is None or fifth is None:
             return False
 
@@ -2914,12 +2889,9 @@ class Chord(note.NotRest):
         if self.pitchClassCardinality != 4:
             return False
 
-        try:
-            third = self.third
-            fifth = self.fifth
-            seventh = self.seventh
-        except ChordException:
-            return False
+        third = self.third
+        fifth = self.fifth
+        seventh = self.seventh
 
         if third is None or fifth is None or seventh is None:
             return False
@@ -3052,11 +3024,9 @@ class Chord(note.NotRest):
         if self.pitchClassCardinality != 3:
             return False
 
-        try:
-            third = self.third
-            fifth = self.fifth
-        except ChordException:
-            return False
+        third = self.third
+        fifth = self.fifth
+
         if third is None or fifth is None:
             return False
         for thisPitch in self.pitches:
@@ -4265,8 +4235,9 @@ class Chord(note.NotRest):
 
     @property
     @cacheMethod
-    def fifth(self):
-        '''Shortcut for getChordStep(5):
+    def fifth(self) -> Optional[pitch.Pitch]:
+        '''
+        Shortcut for getChordStep(5), but caches it and does not raise exceptions
 
         >>> cMaj1stInv = chord.Chord(['E3', 'C4', 'G5'])
         >>> cMaj1stInv.fifth
@@ -4274,8 +4245,18 @@ class Chord(note.NotRest):
 
         >>> cMaj1stInv.fifth.midi
         79
+
+        >>> chord.Chord('C4 D4').fifth is None
+        True
+
+        OMIT_FROM_DOCS
+
+        >>> chord.Chord().fifth
         '''
-        return self.getChordStep(5)
+        try:
+            return self.getChordStep(5)
+        except ChordException:
+            return None
 
     @property
     def forteClass(self):
@@ -4292,8 +4273,21 @@ class Chord(note.NotRest):
         >>> c2.forteClass
         '3-11B'
 
+        Empty chords return 'N/A'
+
+        >>> chord.Chord().forteClass
+        'N/A'
+
+        Non-twelve-tone chords return as if all microtones and non-twelve-tone
+        accidentals are removed:
+
+        >>> chord.Chord('c~4 d`4').forteClass
+        '2-2'
         '''
-        return chordTables.addressToForteName(self.chordTablesAddress, 'tn')
+        try:
+            return chordTables.addressToForteName(self.chordTablesAddress, 'tn')
+        except chordTables.ChordTablesException:
+            return 'N/A'
 
     @property
     def forteClassNumber(self):
@@ -4342,8 +4336,22 @@ class Chord(note.NotRest):
         >>> c2 = chord.Chord(['c', 'e', 'g'])
         >>> c2.forteClassTnI
         '3-11'
+
+        Empty chords return 'N/A'
+
+        >>> chord.Chord().forteClassTnI
+        'N/A'
+
+        Non-twelve-tone chords return as if all microtones and non-twelve-tone
+        accidentals are removed:
+
+        >>> chord.Chord('c~4 d`4').forteClassTnI
+        '2-2'
         '''
-        return chordTables.addressToForteName(self.chordTablesAddress, 'tni')
+        try:
+            return chordTables.addressToForteName(self.chordTablesAddress, 'tni')
+        except chordTables.ChordTablesException:
+            return 'N/A'
 
     @property
     def fullName(self):
@@ -4380,8 +4388,17 @@ class Chord(note.NotRest):
         >>> c2 = chord.Chord(['c', 'c#', 'e', 'f#'])
         >>> c2.hasZRelation  # it is c, c#, e-, g
         True
+
+        OMIT_FROM_DOCS
+
+        >>> chord.Chord().hasZRelation
+        False
         '''
-        post = chordTables.addressToZAddress(self.chordTablesAddress)
+        try:
+            post = chordTables.addressToZAddress(self.chordTablesAddress)
+        except chordTables.ChordTablesException:
+            return False  # empty chords have no z-relations
+
         # environLocal.printDebug(['got post', post])
         if post is not None:
             return True
@@ -4403,8 +4420,16 @@ class Chord(note.NotRest):
         >>> c3 = chord.Chord(['c', 'c#', 'e-', 'g'])
         >>> c3.intervalVector
         [1, 1, 1, 1, 1, 1]
+
+        OMIT_FROM_DOCS
+
+        >>> chord.Chord().intervalVector
+        [0, 0, 0, 0, 0, 0]
         '''
-        return list(chordTables.addressToIntervalVector(self.chordTablesAddress))
+        try:
+            return list(chordTables.addressToIntervalVector(self.chordTablesAddress))
+        except chordTables.ChordTablesException:
+            return [0, 0, 0, 0, 0, 0]
 
     @property
     def intervalVectorString(self):
@@ -4522,6 +4547,7 @@ class Chord(note.NotRest):
         self.add(newNotes, runSort=False)
 
     @property
+    @cacheMethod
     def normalOrder(self):
         '''
         Return the normal order/normal form of the Chord represented as a list of integers:
@@ -4575,9 +4601,16 @@ class Chord(note.NotRest):
 
         >>> chord.Chord('E#3 A3 C#4').normalOrder
         [1, 5, 9]
+
+        >>> chord.Chord().normalOrder
+        []
         '''
         cta = self.chordTablesAddress
-        transposedNormalForm = chordTables.addressToTransposedNormalForm(cta)
+        try:
+            transposedNormalForm = chordTables.addressToTransposedNormalForm(cta)
+        except chordTables.ChordTablesException:
+            return []
+
         orderedPCs = self.orderedPitchClasses
         mustBePresentPCs = set(orderedPCs)
         for transposeAmount in orderedPCs:
@@ -4864,7 +4897,7 @@ class Chord(note.NotRest):
             self._notes.append(note.Note(p))
 
     @property
-    def primeForm(self):
+    def primeForm(self) -> List[int]:
         '''
         Return a representation of the Chord as a prime-form list of pitch
         class integers:
@@ -4876,11 +4909,19 @@ class Chord(note.NotRest):
         >>> c2 = chord.Chord(['c', 'e', 'g'])
         >>> c2.primeForm
         [0, 3, 7]
+
+        OMIT_FROM_DOCS
+
+        >>> chord.Chord().primeForm
+        []
         '''
-        return list(chordTables.addressToPrimeForm(self.chordTablesAddress))
+        try:
+            return list(chordTables.addressToPrimeForm(self.chordTablesAddress))
+        except chordTables.ChordTablesException:
+            return []
 
     @property
-    def primeFormString(self):
+    def primeFormString(self) -> str:
         '''
         Return a representation of the Chord as a prime-form set class string.
 
@@ -4941,9 +4982,18 @@ class Chord(note.NotRest):
         'major'
         >>> chord.Chord('C E--').quality
         'other'
+
+        Empty chords are definitely 'other':
+
+        >>> chord.Chord().quality
+        'other'
         '''
-        third = self.semitonesFromChordStep(3)
-        fifth = self.semitonesFromChordStep(5)
+        try:
+            third = self.semitonesFromChordStep(3)
+            fifth = self.semitonesFromChordStep(5)
+        except ChordException:
+            return 'other'
+
         # environLocal.printDebug(['third, fifth', third, fifth])
         if third is None:
             return 'other'
@@ -5025,6 +5075,11 @@ class Chord(note.NotRest):
         >>> chord2.scaleDegrees
         [(1, None), (1, <accidental sharp>), (2, None),
          (3, <accidental flat>), (3, None), (4, None)]
+
+        Changed in v6.5 -- will return None if no context can be found:
+
+        >>> chord.Chord('C4 E4 G4').scaleDegrees is None
+        True
         '''
         from music21 import scale
         # roman numerals have this built in as the key attribute
@@ -5034,10 +5089,7 @@ class Chord(note.NotRest):
         else:
             sc = self.getContextByClass(scale.Scale, sortByCreationTime=True)
             if sc is None:
-                raise ChordException(
-                    'Cannot find a Key or Scale context for this chord, '
-                    + 'so cannot find what scale degrees the pitches correspond to!'
-                )
+                return None
         degrees = []
         for thisPitch in self.pitches:
             degree = sc.getScaleDegreeFromPitch(
@@ -5062,6 +5114,7 @@ class Chord(note.NotRest):
         return degrees
 
     @property
+    @cacheMethod
     def seventh(self):
         '''
         Shortcut for getChordStep(7), but caches the value
@@ -5075,13 +5128,24 @@ class Chord(note.NotRest):
         >>> c = chord.Chord(['C4', 'E4', 'G4', 'B#4'])
         >>> c.seventh
         <music21.pitch.Pitch B#4>
+
+        Changed in v6.5 -- return None on empty chords/errors
+
+        OMIT_FROM_DOCS
+
+        >>> chord.Chord().seventh
         '''
-        return self.getChordStep(7)
+        try:
+            return self.getChordStep(7)
+        except ChordException:
+            return None
 
     @property
-    def third(self):
+    @cacheMethod
+    def third(self) -> Optional[pitch.Pitch]:
         '''
-        Shortcut for getChordStep(3), but caches the value.
+        Shortcut for getChordStep(3), but caches the value, and returns
+        None on errors.
 
         >>> cMaj1stInv = chord.Chord(['E3', 'C4', 'G5'])
         >>> cMaj1stInv.third
@@ -5089,8 +5153,17 @@ class Chord(note.NotRest):
 
         >>> cMaj1stInv.third.octave
         3
+
+        Changed in v6.5 -- return None on empty chords/errors
+
+        OMIT_FROM_DOCS
+
+        >>> chord.Chord().third
         '''
-        return self.getChordStep(3)
+        try:
+            return self.getChordStep(3)
+        except ChordException:
+            return None
 
     @property
     def tie(self):
@@ -5161,25 +5234,34 @@ class Chord(note.NotRest):
         >>> c.volume.velocityIsRelative = False
         >>> c.volume  # return a new volume that is an average
         <music21.volume.Volume realized=0.76>
+
+        OMIT_FROM_DOCS
+
+        Make sure that empty chords have a volume:
+
+        >>> chord.Chord().volume
+        <music21.volume.Volume realized=0.71>
         '''
-        if not self.hasComponentVolumes() and self._volume is None:
-            # create a single new Volume object for the chord
-            return note.NotRest._getVolume(self, forceClient=self)
-        elif self._volume is not None:
+        if self._volume is not None:
             # if we already have a Volume, use that
             return self._volume
+
+        if not self.hasComponentVolumes():
+            # create a single new Volume object for the chord
+            self._volume = note.NotRest._getVolume(self, forceClient=self)
+            return self._volume
+
         # if we have components and _volume is None, create a volume from
         # components
-        elif self.hasComponentVolumes():
-            velocities = []
-            for d in self._notes:
-                velocities.append(d.volume.velocity)
-            # create new local object
-            self._volume = volume.Volume(client=self)
+        velocities = []
+        for d in self._notes:
+            velocities.append(d.volume.velocity)
+        # create new local object
+        self._volume = volume.Volume(client=self)
+        if velocities:  # avoid division by zero error
             self._volume.velocity = int(round(sum(velocities) / len(velocities)))
-            return self._volume
-        else:
-            raise ChordException('unmatched condition')
+        return self._volume
+
 
     @volume.setter
     def volume(self, expr):
diff --git a/music21/clef.py b/music21/clef.py
index f30f9a1f5..2d7cafb3e 100644
--- a/music21/clef.py
+++ b/music21/clef.py
@@ -176,9 +176,17 @@ class Clef(base.Music21Object):
         >>> tc = clef.TrebleClef()
         >>> tc.name
         'treble'
+
+        OMIT_FROM_DOCS
+
+        >>> clef.Clef().name
+        ''
         '''
         className = self.__class__.__name__.replace('Clef', '')
-        return className[0].lower() + className[1:]
+        if className:
+            return className[0].lower() + className[1:]
+        else:
+            return ''
 
 # ------------------------------------------------------------------------------
 
diff --git a/music21/common/pathTools.py b/music21/common/pathTools.py
index 18250ba8e..c4dd5a090 100644
--- a/music21/common/pathTools.py
+++ b/music21/common/pathTools.py
@@ -22,6 +22,7 @@ __all__ = [
 import inspect
 import os
 import pathlib
+import unittest
 
 # ------------------------------------------------------------------------------
 
@@ -127,13 +128,14 @@ def getRootFilePath():
     which has directories such as "dist", "documentation", "music21"
 
     >>> fp = common.getRootFilePath()
-    >>> str(fp).endswith('/music21')
-    True
+    >>> #_DOCS_SHOW fp
+    PosixPath('/Users/florencePrice/git/music21')
 
     :rtype: pathlib.Path
     '''
     fpMusic21 = getSourceFilePath()
     fpParent = fpMusic21.parent
+    # Do not assume will end in music21 -- people can put this anywhere they want
     return fpParent
 
 
@@ -183,6 +185,12 @@ def cleanpath(path, *, returnPathlib=None):
         return pathlib.Path(path)
 
 
+class Test(unittest.TestCase):
+    def testGetSourcePath(self):
+        fp = getSourceFilePath()
+        self.assertIsInstance(fp, pathlib.Path)
+
+
 if __name__ == '__main__':
     import music21
-    music21.mainTest()
+    music21.mainTest(Test)
diff --git a/music21/harmony.py b/music21/harmony.py
index 7313f615d..107f570c5 100644
--- a/music21/harmony.py
+++ b/music21/harmony.py
@@ -390,9 +390,19 @@ class Harmony(chord.Chord):
         >>> h = harmony.ChordSymbol('B-/D')
         >>> h.romanNumeral
         <music21.roman.RomanNumeral I6 in B- major>
+
+        OMIT_FROM_DOCS
+
+        Empty ChordSymbol = empty RomanNumeral:
+
+        >>> harmony.ChordSymbol().romanNumeral
+        <music21.roman.RomanNumeral>
         '''
         if self._roman is None:
             from music21 import roman
+            if not self.pitches:
+                return roman.RomanNumeral()
+
 
             storedWriteAsChord = self._writeAsChord
             self.writeAsChord = True
diff --git a/music21/interval.py b/music21/interval.py
index e5eac1b2b..b8d59ce09 100644
--- a/music21/interval.py
+++ b/music21/interval.py
@@ -2164,7 +2164,10 @@ class DiatonicInterval(IntervalBase):
         100.0
         '''
         c = self.getChromatic()
-        return c.cents
+        if c:
+            return c.cents
+        else:
+            return 0.0
 
 
 class ChromaticInterval(IntervalBase):
@@ -3000,7 +3003,11 @@ class Interval(IntervalBase):
 
     def _reprInternal(self):
         from music21 import pitch
-        shift = self._diatonicIntervalCentShift()
+        try:
+            shift = self._diatonicIntervalCentShift()
+        except AttributeError:
+            return ''
+
         if shift != 0:
             micro = pitch.Microtone(shift)
             return self.directedName + ' ' + str(micro)
@@ -3227,8 +3234,16 @@ class Interval(IntervalBase):
         >>> dInterval = cInterval.complement
         >>> dInterval
         <music21.interval.Interval d7>
+
+        OMIT_FROM_DOCS
+
+        >>> interval.Interval().complement
+        <music21.interval.Interval>
         '''
-        return Interval(self.diatonic.mod7inversion)
+        try:
+            return Interval(self.diatonic.mod7inversion)
+        except AttributeError:
+            return Interval()
 
     @property
     def intervalClass(self):
@@ -3244,8 +3259,18 @@ class Interval(IntervalBase):
         >>> bInterval = interval.Interval('m6')
         >>> bInterval.intervalClass
         4
+
+        Empty intervals return 0:
+
+        >>> interval.Interval().intervalClass
+        0
+
+        Changed in v6.5 -- empty intervals return 0
         '''
-        return self.chromatic.intervalClass
+        try:
+            return self.chromatic.intervalClass
+        except AttributeError:
+            return 0
 
     @property
     def cents(self):
@@ -3262,8 +3287,16 @@ class Interval(IntervalBase):
         >>> microtoneInterval = interval.Interval(noteStart=n1, noteEnd=n2)
         >>> microtoneInterval.cents
         230.0
+
+        OMIT_FROM_DOCS
+
+        >>> interval.Interval().cents
+        0.0
         '''
-        return self.chromatic.cents
+        try:
+            return self.chromatic.cents
+        except AttributeError:  # chromatic might be None
+            return 0.0
 
     def _diatonicIntervalCentShift(self):
         '''
@@ -4319,6 +4352,17 @@ class Test(unittest.TestCase):
         self.assertEqual(i3.semitones, 5)
 
 
+    def testEmptyIntervalProperties(self):
+        empty = DiatonicInterval()
+        self.assertEqual(empty.cents, 0.0)
+
+        empty = Interval()
+        self.assertEqual(empty.complement, empty)
+        self.assertIsNot(empty.complement, empty)
+        self.assertEqual(empty.cents, 0.0)
+        self.assertEqual(empty.intervalClass, 0)
+
+
 # ------------------------------------------------------------------------------
 # define presented order in documentation
 _DOC_ORDER = [notesToChromatic, intervalsToDiatonic,
diff --git a/music21/roman.py b/music21/roman.py
index ba476b3a0..b1642ec8e 100644
--- a/music21/roman.py
+++ b/music21/roman.py
@@ -2314,19 +2314,27 @@ class RomanNumeral(harmony.Harmony):
         >>> rn = roman.RomanNumeral('V65/V', 'e')
         >>> rn.figureAndKey
         'V65/V in e minor'
+
+        Without a key, it is the same as figure:
+
+        >>> roman.RomanNumeral('V7').figureAndKey
+        'V7'
         '''
+        if self.key is None:
+            return self.figure
+
+        mode = ''
         tonic = self.key.tonic
+
         if hasattr(tonic, 'name'):
             tonic = tonic.name
-        mode = ''
         if hasattr(self.key, 'mode'):
             mode = ' ' + self.key.mode
         elif self.key.__class__.__name__ == 'MajorScale':
             mode = ' major'
         elif self.key.__class__.__name__ == 'MinorScale':
             mode = ' minor'
-        else:
-            pass
+
         if mode == ' minor':
             tonic = tonic.lower()
         elif mode == ' major':
diff --git a/music21/stream/__init__.py b/music21/stream/__init__.py
index e2f6ee6ac..ac08b16ee 100644
--- a/music21/stream/__init__.py
+++ b/music21/stream/__init__.py
@@ -13400,6 +13400,9 @@ class Opus(Stream):
         #_DOCS_SHOW >>> o.write()
         #_DOCS_SHOW PosixPath('/some/temp/path-2.xml')
         '''
+        if not self.scores:
+            return None
+
         if fmt is not None and 'lily' in fmt:
             return Stream.write(self, fmt, fp, **keywords)
         elif common.runningUnderIPython():
@@ -13407,10 +13410,10 @@ class Opus(Stream):
 
         if fp is None:
             if fmt is None:
-                suffix = environLocal['writeFormat']
+                suffix = '.' + environLocal['writeFormat']
             else:
                 unused_format, suffix = common.findFormat(fmt)
-            fp = environLocal.getTempFile(returnPathlib=False) + '.' + suffix
+            fp = environLocal.getTempFile(returnPathlib=False) + suffix
         if isinstance(fp, str):
             fp = pathlib.Path(fp)
 
@@ -13424,7 +13427,7 @@ class Opus(Stream):
             placesConsumed = math.ceil(math.log10(i + 1))
             zeroesNeeded = placesRequired - placesConsumed
             zeroes = '0' * zeroesNeeded
-            scoreName = fpStem + '-' + zeroes + str(i + 1) + '.' + fpSuffix
+            scoreName = fpStem + '-' + zeroes + str(i + 1) + fpSuffix
             fpToUse = fpParent / scoreName
             fpReturned = s.write(fmt=fmt, fp=fpToUse, **keywords)
             environLocal.printDebug(f'Component {s} written to {fpReturned}')


Test patch:
diff --git a/music21/test/testStream.py b/music21/test/testStream.py
index 2d7aed797..d640ca4f8 100644
--- a/music21/test/testStream.py
+++ b/music21/test/testStream.py
@@ -8154,6 +8154,9 @@ class Test(unittest.TestCase):
         out = o.write(fp=environLocal.getTempFile())
         self.assertTrue(str(out).endswith('-2.xml'))
 
+        out = o.write(fmt='midi')
+        self.assertTrue(str(out).endswith('-2.mid'))
+
 
 # -----------------------------------------------------------------------------