diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index de35ee2e5..07d8c39eb 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -301,6 +301,9 @@ def setStyleAttributes(self, mxObject, m21Object, musicXMLNames, m21Names=None): >>> m21Obj.style.hideObjectOnPrint True ''' + if not mxObject.attrib: + return + if isinstance(m21Object, style.Style): stObj = m21Object else: diff --git a/music21/pitch.py b/music21/pitch.py index 740b032a8..145c3d81d 100644 --- a/music21/pitch.py +++ b/music21/pitch.py @@ -1639,6 +1639,12 @@ def fullName(self) -> str: # ------------------------------------------------------------------------------ +# strings that Pitch.name parsed: (step, accidental string or None, octave or None). +# Holds no Accidental objects, since those are mutable. Cleared when full. +_pitchNameCache: dict[str, tuple[StepName, str|None, int|None]] = {} +_pitchNameCacheSize = 1000 + + # tried as SlottedObjectMixin -- made creation time slower! Not worth the restrictions class Pitch(prebase.ProtoM21Object): ''' @@ -2811,6 +2817,8 @@ def name(self) -> str: >>> a = pitch.Pitch('B---') >>> a.name 'B---' + + * Changed in v11: a name longer than 100 characters raises a ValueError. ''' if self.accidental is not None: return self.step + self.accidental.modifier @@ -2823,10 +2831,38 @@ def name(self, usrStr: str) -> None: Set name, which may be provided with or without octave values. C4 or D-3 are both accepted. ''' + try: + step, accidentalStr, octave = _pitchNameCache[usrStr] + except (KeyError, TypeError): # TypeError: not hashable, so not a str + self._cacheParsedName(usrStr) + step, accidentalStr, octave = _pitchNameCache[usrStr] + self._step = step + self.spellingIsInferred = False + self._accidental = (None if accidentalStr is None + else Accidental(accidentalStr)) + if octave is not None: + self._octave = octave + self.informClient() + + def _cacheParsedName(self, usrStr: str) -> None: + ''' + Parse a name not yet seen and remember its step, accidental, and + octave for the `.name` setter. Raises if it is not a name. + + >>> p = pitch.Pitch() + >>> p._cacheParsedName(' f#5') + >>> pitch._pitchNameCache[' f#5'] + ('F', '#', 5) + ''' + cacheKey = usrStr try: usrStr = usrStr.strip() except AttributeError: raise ValueError(f'Argument to name, {usrStr!r}, must be a string, not {type(usrStr)}.') + # remembered names are kept, so do not keep huge ones + if len(cacheKey) > 100: + raise ValueError( + f'Argument to name must be at most 100 characters, not {len(cacheKey)}.') # extract any numbers that may be octave designations octFound: list[str] = [] @@ -2843,20 +2879,24 @@ def name(self, usrStr: str) -> None: octNot.append(char) usrStr = ''.join(octNot) octFoundStr = ''.join(octFound) - # we have nothing but pitch specification - if len(usrStr) == 1: - self.step = usrStr # type: ignore - self.accidental = None - # assume everything following pitch is accidental specification - elif len(usrStr) > 1: - self.step = usrStr[0] # type: ignore - self.accidental = Accidental(usrStr[1:]) - else: + if not usrStr: raise PitchException(f'Cannot make a name out of {usrStr!r}') + step = t.cast(StepName, usrStr[0].upper()) + if step not in STEPNAMES: + raise PitchException(f'Cannot make a step out of {step!r}') + # assume everything following pitch is accidental specification + accidentalStr: str|None = None + if len(usrStr) > 1: + accidentalStr = usrStr[1:] + Accidental(accidentalStr) # raises if it is not an accidental + octave: int|None = None if octFoundStr: # bool('0') == True, so okay octave = int(octFoundStr) - self.octave = octave + + if len(_pitchNameCache) >= _pitchNameCacheSize: + _pitchNameCache.clear() + _pitchNameCache[cacheKey] = (step, accidentalStr, octave) @property def unicodeName(self) -> str: diff --git a/music21/test/test_pitch.py b/music21/test/test_pitch.py index 006e6dc8e..6c57994ab 100644 --- a/music21/test/test_pitch.py +++ b/music21/test/test_pitch.py @@ -122,6 +122,48 @@ def testNameSetting(self): ): p.name = 32 + def testNameCache(self): + ''' + A name is parsed once and remembered; names that cannot be parsed are not. + ''' + with mock.patch.dict(pitch._pitchNameCache, clear=True): + first = Pitch('E-6') + self.assertEqual(pitch._pitchNameCache, {'E-6': ('E', '-', 6)}) + second = Pitch('E-6') + self.assertEqual(second.nameWithOctave, 'E-6') + # Accidentals can be changed, so each Pitch gets its own + self.assertIsNot(second.accidental, first.accidental) + + # a name that cannot be parsed changes nothing + with self.assertRaises(AccidentalException): + second.name = 'D$' + self.assertEqual(second.nameWithOctave, 'E-6') + with self.assertRaisesRegex(ValueError, 'must be a string'): + second.name = ['C4'] # not hashable + with self.assertRaisesRegex(ValueError, 'at most 100 characters'): + second.name = 'C4'.center(101) # padding counts + self.assertEqual(list(pitch._pitchNameCache), ['E-6']) + self.assertEqual(Pitch('C4'.center(100)).nameWithOctave, 'C4') + + # cleared when full + with mock.patch.object(pitch, '_pitchNameCacheSize', 1): + Pitch('F4') + self.assertEqual(list(pitch._pitchNameCache), ['F4']) + + def testNameSetterInformsNoteOnce(self): + ''' + Setting a name without an octave, even one not seen before, keeps the + octave and the microtone, makes the spelling explicit, and tells the Note once. + ''' + n = note.Note(73) # C#5, spelling inferred + n.pitch.microtone = 20 + with (mock.patch.dict(pitch._pitchNameCache, clear=True), + mock.patch.object(n, 'pitchChanged') as pitchChanged): + n.pitch.name = 'B-' + pitchChanged.assert_called_once() + self.assertEqual(str(n.pitch), 'B-5(+20c)') + self.assertFalse(n.pitch.spellingIsInferred) + def testInitShortcutsMatchParsing(self): # 'C', 'C4', and step= skip the name and step setters self.assertEqual(Pitch('C'), Pitch('c'))