Repository: cuthbertLab/music21
Pull request #1467 (2022-10-18 02:19:29)

All Music21Objects must be hashable and default instantiate
All Music21Objects must not crash on `m = MyM21Object()` and `{m: 1}` must be valid.



Patch:
diff --git a/music21/_version.py b/music21/_version.py
index 460e8eae5..ce4116f4e 100644
--- a/music21/_version.py
+++ b/music21/_version.py
@@ -47,7 +47,7 @@ Changing this number invalidates old pickles -- do it if the old pickles create
 '''
 from __future__ import annotations
 
-__version__ = '9.0.0a5'
+__version__ = '9.0.0a6'
 
 
 def get_version_tuple(vv):
diff --git a/music21/base.py b/music21/base.py
index 6c27b7d3a..d89c33afd 100644
--- a/music21/base.py
+++ b/music21/base.py
@@ -27,7 +27,7 @@ available after importing `music21`.
 <class 'music21.base.Music21Object'>
 
 >>> music21.VERSION_STR
-'9.0.0a5'
+'9.0.0a6'
 
 Alternatively, after doing a complete import, these classes are available
 under the module "base":
@@ -589,8 +589,8 @@ class Music21Object(prebase.ProtoM21Object):
         the default deepcopy style. More can be passed to it.  But calling
         functions are responsible
 
-        Changed in v9: removeFromIgnore removed; never used and this is performance
-        critical.
+        * Changed in v9: removeFromIgnore removed; never used
+          and this is performance critical.
         '''
         defaultIgnoreSet = {'_derivation', '_activeSite', '_sites', '_cache'}
         if not self.groups:
@@ -671,7 +671,7 @@ class Music21Object(prebase.ProtoM21Object):
 
     def __setstate__(self, state: dict[str, t.Any]):
         # defining self.__dict__ upon initialization currently breaks everything
-        self.__dict__ = state  # pylint: disable=attribute-defined-outside-init
+        object.__setattr__(self, '__dict__', state)  # pylint: disable=attribute-defined-outside-init
 
     def _reprInternal(self) -> str:
         '''
@@ -979,7 +979,8 @@ class Music21Object(prebase.ProtoM21Object):
         >>> n3.getOffsetBySite(s3, returnSpecial=True)
         0.0
 
-        Changed in v7. -- stringReturns renamed to returnSpecial.  Returns an OffsetSpecial Enum.
+        * Changed in v7. -- stringReturns renamed to returnSpecial.
+          Returns an OffsetSpecial Enum.
         '''
         if site is None:
             return self._naiveOffset
@@ -2543,7 +2544,7 @@ class Music21Object(prebase.ProtoM21Object):
         When in doubt, use `.getOffsetBySite(streamObj)`
         which is safer or streamObj.elementOffset(self) which is 3x faster.
 
-        Changed in v.8 -- using a Duration object as an offset is not allowed.
+        * Changed in v.8 -- using a Duration object as an offset is not allowed.
         '''
         # There is a branch that does slow searches.
         # See test/testSerialization to have it active.
@@ -3167,7 +3168,7 @@ class Music21Object(prebase.ProtoM21Object):
         music21.duration.DurationException: cannot split a duration (0.5)
             at this quarterLength (7/10)
 
-        Changed in v7. -- all but quarterLength are keyword only
+        * Changed in v7. -- all but quarterLength are keyword only
         '''
         from music21 import chord
         from music21 import note
@@ -3714,8 +3715,8 @@ class Music21Object(prebase.ProtoM21Object):
         True
 
 
-        Changed in v.6.3 -- returns `nan` if
-        there is no TimeSignature in sites.  Previously raised an exception.
+        * Changed in v.6.3 -- returns `nan` if
+          there is no TimeSignature in sites.  Previously raised an exception.
         '''
         try:
             ts = self._getTimeSignatureForBeat()
@@ -3756,8 +3757,8 @@ class Music21Object(prebase.ProtoM21Object):
         >>> isolatedNote.beatStr
         'nan'
 
-        Changed in v.6.3 -- returns 'nan' if
-        there is no TimeSignature in sites.  Previously raised an exception.
+        * Changed in v6.3 -- returns 'nan' if
+          there is no TimeSignature in sites.  Previously raised an exception.
         '''
         try:
             ts = self._getTimeSignatureForBeat()
@@ -3813,8 +3814,8 @@ class Music21Object(prebase.ProtoM21Object):
         >>> isolatedNote.beatDuration
         <music21.duration.Duration 0.0>
 
-        Changed in v.6.3 -- returns a duration.Duration object of length 0 if
-        there is no TimeSignature in sites.  Previously raised an exception.
+        * Changed in v6.3 -- returns a duration.Duration object of length 0 if
+          there is no TimeSignature in sites.  Previously raised an exception.
         '''
         try:
             ts = self._getTimeSignatureForBeat()
@@ -3909,7 +3910,7 @@ class Music21Object(prebase.ProtoM21Object):
         >>> isnan(n2.beatStrength)
         True
 
-        Changed in v6.3 -- return 'nan' instead of raising an exception.
+        * Changed in v6.3 -- return 'nan' instead of raising an exception.
         '''
         try:
             ts = self._getTimeSignatureForBeat()
@@ -4023,12 +4024,12 @@ class Music21Object(prebase.ProtoM21Object):
         >>> r.duration.type
         'half'
 
-        Changed in v6.3 -- return nan instead of raising an exception.
+        * Changed in v6.3 -- return nan instead of raising an exception.
         ''')
 
 
 # ------------------------------------------------------------------------------
-
+_m21ObjDefaultDefinedKeys: tuple[str, ...] = tuple(dir(Music21Object()))
 
 class ElementWrapper(Music21Object):
     '''
@@ -4093,8 +4094,40 @@ class ElementWrapper(Music21Object):
     <music21.base.ElementWrapper id=0_wrapper offset=0.0 obj='<...Wave_read object...'>
     <music21.base.ElementWrapper id=1_wrapper offset=1.0 obj='<...Wave_read object...'>
     <music21.base.ElementWrapper offset=2.0 obj='<...Wave_read object...>'>
+
+    Equality
+    --------
+    Two ElementWrappers are equal if they would be equal as Music21Objects and they
+    wrap objects that are equal.
+
+    >>> list1 = ['a', 'b', 'c']
+    >>> a = base.ElementWrapper(list1)
+    >>> a.offset = 3.0
+
+    >>> list2 = ['a', 'b', 'c']
+    >>> b = base.ElementWrapper(list2)
+    >>> b.offset = 3.0
+    >>> a == b
+    True
+    >>> a is not b
+    True
+
+    Offset does not need to be equal for equality:
+
+    >>> b.offset = 4.0
+    >>> a == b
+    True
+
+    But elements must compare equal
+
+    >>> list2.append('d')
+    >>> a == b
+    False
+
+    * Changed in v9: completely different approach to equality, unified w/
+      the rest of music21.
     '''
-    obj: t.Any = None
+    equalityAttributes = ('obj',)
 
     _DOC_ORDER = ['obj']
     _DOC_ATTR: dict[str, str] = {
@@ -4104,8 +4137,10 @@ class ElementWrapper(Music21Object):
     }
 
     def __init__(self, obj: t.Any = None, **keywords):
+        # note that because of how __setattr__ is overridden and needs
+        # to have a self.obj, we set this here.
+        self.obj: t.Any = obj  # object stored here
         super().__init__(**keywords)
-        self.obj = obj  # object stored here
 
     # -------------------------------------------------------------------------
 
@@ -4121,42 +4156,10 @@ class ElementWrapper(Music21Object):
         else:
             return f'offset={self.offset} obj={shortObj!r}'
 
-    def __eq__(self, other):
-        '''
-        Test ElementWrapper equality
-
-        >>> import music21
-        >>> n = note.Note('C#')
-        >>> a = music21.ElementWrapper(n)
-        >>> a.offset = 3.0
-        >>> b = music21.ElementWrapper(n)
-        >>> b.offset = 3.0
-        >>> a == b
-        True
-        >>> a is not b
-        True
-        >>> c = music21.ElementWrapper(n)
-        >>> c.offset = 2.0
-        >>> c.offset
-        2.0
-        >>> a == c
-        False
-        '''
-        # TODO: call super on eq.
-        for other_prop in ('obj', 'offset', 'priority', 'groups', 'activeSite', 'duration'):
-            if not hasattr(other, other_prop):
-                return False
-
-        if (self.obj == other.obj
-                and self.offset == other.offset
-                and self.priority == other.priority
-                and self.groups == other.groups
-                and self.duration == self.duration):
-            return True
-        else:
-            return False
-
     def __setattr__(self, name: str, value: t.Any) -> None:
+        if name == 'obj':
+            object.__setattr__(self, 'obj', value)
+            return
         # environLocal.printDebug(['calling __setattr__ of ElementWrapper', name, value])
 
         # if in the ElementWrapper already, set that first
@@ -4165,12 +4168,12 @@ class ElementWrapper(Music21Object):
 
         # if not, change the attribute in the stored object
         storedObj = object.__getattribute__(self, 'obj')
-        if (name not in ('offset', '_offset', '_activeSite')
+        if (name not in _m21ObjDefaultDefinedKeys
                 and storedObj is not None
                 and hasattr(storedObj, name)):
             setattr(storedObj, name, value)
-        # unless neither has the attribute, in which case add it to the ElementWrapper
         else:
+            # unless neither has the attribute, in which case add it to the ElementWrapper
             object.__setattr__(self, name, value)
 
     def __getattr__(self, name: str) -> t.Any:
diff --git a/music21/interval.py b/music21/interval.py
index 346ec4ba8..ca26c55fd 100644
--- a/music21/interval.py
+++ b/music21/interval.py
@@ -926,6 +926,9 @@ class GenericInterval(IntervalBase):
         else:
             return False
 
+    def __hash__(self):
+        return id(self) >> 4
+
     @property
     def value(self) -> int:
         '''
@@ -1736,6 +1739,9 @@ class DiatonicInterval(IntervalBase):
         else:
             return False
 
+    def __hash__(self):
+        return id(self) >> 4
+
     @property
     def name(self) -> str:
         '''
@@ -2262,9 +2268,11 @@ class ChromaticInterval(IntervalBase):
         else:
             return False
 
+    def __hash__(self):
+        return id(self) >> 4
+
     # -------------------------------------------------------
     # properties
-
     @property
     def cents(self) -> float:
         '''
@@ -3131,10 +3139,11 @@ class Interval(IntervalBase):
         else:
             return False
 
+    def __hash__(self):
+        return id(self) >> 4
 
     # -------------------------------------
     # properties
-
     @property
     def generic(self) -> GenericInterval:
         '''
diff --git a/music21/key.py b/music21/key.py
index d1a288fb0..04af465fa 100644
--- a/music21/key.py
+++ b/music21/key.py
@@ -1021,6 +1021,9 @@ class Key(KeySignature, scale.DiatonicScale):
         # don't check on Scale equality
         return self.tonic.name == other.tonic.name and self.mode == other.mode
 
+    def __hash__(self):
+        return id(self) >> 4
+
     @property
     def relative(self) -> Key:
         '''
diff --git a/music21/percussion.py b/music21/percussion.py
index 9169f7aa8..6fd4fbf38 100644
--- a/music21/percussion.py
+++ b/music21/percussion.py
@@ -89,6 +89,9 @@ class PercussionChord(chord.ChordBase):
                 return False
         return True
 
+    def __hash__(self):
+        return id(self) >> 4
+
     @property
     def notes(self) -> tuple[note.NotRest, ...]:
         return tuple(self._notes)
diff --git a/music21/roman.py b/music21/roman.py
index 953e7589a..6ef77796e 100644
--- a/music21/roman.py
+++ b/music21/roman.py
@@ -1873,7 +1873,6 @@ class RomanNumeral(harmony.Harmony):
     '''
     equalityAttributes = ('figure', 'key')
 
-
     # TODO: document better! what is inherited and what is new?
     _alterationRegex = re.compile(r'^(b+|-+|#+)')
     _omittedStepsRegex = re.compile(r'(\[(no[1-9]+)+]\s*)+')
@@ -2330,6 +2329,8 @@ class RomanNumeral(harmony.Harmony):
         # will be picked up as well.
         return note.NotRest.__eq__(self, other)
 
+    def __hash__(self):
+        return id(self) >> 4
 
     def _reprInternal(self):
         if hasattr(self.key, 'tonic'):
diff --git a/music21/scale/__init__.py b/music21/scale/__init__.py
index da66161f9..d1e8ecb10 100644
--- a/music21/scale/__init__.py
+++ b/music21/scale/__init__.py
@@ -232,8 +232,26 @@ class AbstractScale(Scale):
     Subclasses can define altered nodes in AbstractScale that are passed
     to the :class:`~music21.intervalNetwork.IntervalNetwork`.
 
-    # TODO: make a subclass of IntervalNetwork and remove the ._net aspect.
+    Equality
+    --------
+    Two abstract scales are the same if they have the same class and the
+    same tonicDegree and octaveDuplicating attributes and the same intervalNetwork,
+    and it satisfies all other music21 superclass attributes.
+
+    >>> as1 = scale.AbstractOctatonicScale()
+    >>> as2 = scale.AbstractOctatonicScale()
+    >>> as1 == as2
+    True
+    >>> as2.tonicDegree = 5
+    >>> as1 == as2
+    False
+
+    >>> as1 == scale.AbstractDiatonicScale()
+    False
     '''
+    # TODO: make a subclass of IntervalNetwork and remove the ._net aspect.
+    equalityAttributes = ('tonicDegree', '_net', 'octaveDuplicating')
+
     def __init__(self, **keywords):
         super().__init__(**keywords)
         # store interval network within abstract scale
@@ -252,27 +270,6 @@ class AbstractScale(Scale):
         # step: {'direction':Direction.BI, 'interval':Interval}
         self._alteredDegrees = {}
 
-    def __eq__(self, other):
-        '''
-        Two abstract scales are the same if they have the same class and the
-        same tonicDegree and the same intervalNetwork.
-
-        >>> as1 = scale.AbstractScale()
-        >>> as2 = scale.AbstractScale()
-        >>> as1 == as2
-        True
-        >>> as1.isConcrete
-        False
-        '''
-        # have to test each so as not to confuse with a subclass
-        if (isinstance(other, self.__class__)
-                and isinstance(self, other.__class__)
-                and self.tonicDegree == other.tonicDegree
-                and self._net == other._net):
-            return True
-        else:
-            return False
-
     def buildNetwork(self):
         '''
         Calling the buildNetwork, with or without parameters,
@@ -669,6 +666,25 @@ class AbstractDiatonicScale(AbstractScale):
     >>> as1.octaveDuplicating
     True
 
+    Equality
+    --------
+    AbstractDiatonicScales must satisfy all the characteristics of a
+    general AbstractScale but also need to have equal dominantDegrees.
+
+    >>> as1 = scale.AbstractDiatonicScale('major')
+    >>> as2 = scale.AbstractDiatonicScale('lydian')
+    >>> as1 == as2
+    False
+
+    Note that their modes do not need to be the same.
+    For instance for the case of major and Ionian which have
+    the same networks:
+
+    >>> as3 = scale.AbstractDiatonicScale('ionian')
+    >>> (as1.mode, as3.mode)
+    ('major', 'ionian')
+    >>> as1 == as3
+    True
     '''
     def __init__(self, mode: str | None = None, **keywords):
         super().__init__(**keywords)
@@ -682,41 +698,6 @@ class AbstractDiatonicScale(AbstractScale):
         self.relativeMajorDegree: int = -1
         self.buildNetwork(mode=mode)
 
-    def __eq__(self, other):
-        '''
-        Two AbstractDiatonicScale objects are equal if
-        their tonicDegrees, their dominantDegrees, and
-        their networks are the same.
-
-        >>> as1 = scale.AbstractDiatonicScale('major')
-        >>> as2 = scale.AbstractDiatonicScale('lydian')
-        >>> as1 == as2
-        False
-
-        Note that their modes do not need to be the same.
-        For instance for the case of major and Ionian which have
-        the same networks:
-
-        >>> as3 = scale.AbstractDiatonicScale('ionian')
-        >>> (as1.mode, as3.mode)
-        ('major', 'ionian')
-        >>> as1 == as3
-        True
-        '''
-        if not isinstance(other, self.__class__):
-            return False
-        if not isinstance(self, other.__class__):
-            return False
-
-        # have to test each so as not to confuse with a subclass
-        if (self.type == other.type
-            and self.tonicDegree == other.tonicDegree
-            and self.dominantDegree == other.dominantDegree
-                and self._net == other._net):
-            return True
-        else:
-            return False
-
     def buildNetwork(self, mode=None):
         '''
         Given subclass dependent parameters, build and assign the IntervalNetwork.
@@ -1260,11 +1241,21 @@ class ConcreteScale(Scale):
     >>> [str(p) for p in complexScale.getPitches('C7', 'C5')]
     ['A6', 'F#6', 'D~6', 'B5', 'G5', 'F5', 'E-5', 'C#5']
 
+    Equality
+    --------
+    Two ConcreteScales are the same if they satisfy all general Music21Object
+    equality methods and their Abstract scales, their tonics, and their
+    boundRanges are the same:
+
+
+
     OMIT_FROM_DOCS
 
     >>> scale.ConcreteScale(tonic=4)
     Traceback (most recent call last):
     ValueError: Tonic must be a Pitch, Note, or str, not <class 'int'>
+
+    This is in OMIT
     '''
     usePitchDegreeCache = False
 
@@ -1358,19 +1349,13 @@ class ConcreteScale(Scale):
         True
         >>> sc5 == sc3  # implicit abstract comparison
         False
-
         '''
-        # have to test each so as not to confuse with a subclass
-        # TODO: add pitch range comparison if defined
-        if other is None:
-            return False
-        if (not hasattr(self, 'isConcrete')) or (not hasattr(other, 'isConcrete')):
+        if not super().__eq__(other):
             return False
 
         if not self.isConcrete or not other.isConcrete:
             # if tonic is none, then we automatically do an abstract comparison
             return self._abstract == other._abstract
-
         else:
             if (isinstance(other, self.__class__)
                     and isinstance(self, other.__class__)
@@ -1381,6 +1366,9 @@ class ConcreteScale(Scale):
             else:
                 return False
 
+    def __hash__(self):
+        return id(self) >> 4
+
     @property
     def name(self):
         '''
diff --git a/music21/stream/base.py b/music21/stream/base.py
index 9b574f988..5fa7de699 100644
--- a/music21/stream/base.py
+++ b/music21/stream/base.py
@@ -14333,11 +14333,17 @@ class SpannerStorage(Stream):
 
     A `client` keyword argument must be provided by the Spanner in creation.
 
-    Changed in v8: spannerParent is renamed client.
+    >>> stream.SpannerStorage(client=spanner.Slur())
+    <music21.stream.SpannerStorage for music21.spanner.Slur>
+
+    * Changed in v8: spannerParent is renamed client.
     '''
-    def __init__(self, givenElements=None, *, client: spanner.Spanner, **keywords):
+    def __init__(self, givenElements=None, *, client: spanner.Spanner | None = None, **keywords):
         # No longer need store as weakref since Py2.3 and better references
-        self.client = client
+        if client is None:  # should never be none.  Just for testing
+            from music21 import spanner
+            client = spanner.Spanner()
+        self.client: spanner.Spanner = client
         super().__init__(givenElements, **keywords)
 
         # must provide a keyword argument with a reference to the spanner
@@ -14345,6 +14351,10 @@ class SpannerStorage(Stream):
 
         # environLocal.printDebug('keywords', keywords)
 
+    def _reprInternal(self):
+        tc = type(self.client)
+        return f'for {tc.__module__}.{tc.__qualname__}'
+
     # NOTE: for serialization, this will need to properly tag
     # the spanner parent by updating the scaffolding code.
     def coreSelfActiveSite(self, el):
diff --git a/music21/voiceLeading.py b/music21/voiceLeading.py
index 82ff759a1..a77bf2732 100644
--- a/music21/voiceLeading.py
+++ b/music21/voiceLeading.py
@@ -1426,8 +1426,10 @@ class Verticality(base.Music21Object):
             in a single part)''',
     }
 
-    def __init__(self, contentDict: dict, **keywords):
+    def __init__(self, contentDict: dict | None = None, **keywords):
         super().__init__(**keywords)
+        if contentDict is None:
+            contentDict = {}
         for partNum, element in contentDict.items():
             if not isinstance(element, list):
                 contentDict[partNum] = [element]
@@ -1793,7 +1795,7 @@ class VerticalityNTuplet(base.Music21Object):
     motion and music theory elements such as passing tones
     '''
 
-    def __init__(self, listOfVerticalities, **keywords):
+    def __init__(self, listOfVerticalities=(), **keywords):
         super().__init__(**keywords)
 
         self.verticalities = listOfVerticalities
@@ -1816,11 +1818,12 @@ class VerticalityTriplet(VerticalityNTuplet):
     '''
     a collection of three Verticalities
     '''
-    def __init__(self, listOfVerticalities, **keywords):
+    def __init__(self, listOfVerticalities=(), **keywords):
         super().__init__(listOfVerticalities, **keywords)
 
-        self.tnlsDict = {}  # defaultdict(int)  # Three Note Linear Segments
-        self._calcTNLS()
+        self.tnlsDict = {}  # Three Note Linear Segments
+        if listOfVerticalities:
+            self._calcTNLS()
 
     def _calcTNLS(self):
         '''
@@ -1927,8 +1930,7 @@ class NNoteLinearSegment(base.Music21Object):
     >>> n.noteList
     [<music21.note.Note A>, <music21.note.Note C>, <music21.note.Note D>]
     '''
-
-    def __init__(self, noteList, **keywords):
+    def __init__(self, noteList=(), **keywords):
         super().__init__(**keywords)
         self._noteList = []
         for value in noteList:
@@ -2316,7 +2318,7 @@ class NChordLinearSegmentException(exceptions21.Music21Exception):
 
 
 class NObjectLinearSegment(base.Music21Object):
-    def __init__(self, objectList, **keywords):
+    def __init__(self, objectList=(), **keywords):
         super().__init__(**keywords)
         self.objectList = objectList
 
@@ -2325,7 +2327,7 @@ class NObjectLinearSegment(base.Music21Object):
 
 
 class NChordLinearSegment(NObjectLinearSegment):
-    def __init__(self, chordList, **keywords):
+    def __init__(self, chordList=(), **keywords):
         super().__init__(chordList, **keywords)
         self._chordList = []
         for value in chordList:
@@ -2364,9 +2366,9 @@ class NChordLinearSegment(NObjectLinearSegment):
         return f'chordList={self.chordList}'
 
 class TwoChordLinearSegment(NChordLinearSegment):
-    def __init__(self, chordList, chord2=None, **keywords):
+    def __init__(self, chordList=(), chord2=None, **keywords):
         if isinstance(chordList, (list, tuple)):
-            if len(chordList) != 2:  # pragma: no cover
+            if chordList and len(chordList) != 2:  # pragma: no cover
                 raise ValueError(
                     f'First argument must be a list of length 2, not {chordList!r}'
                 )


Test patch:
diff --git a/music21/test/test_base.py b/music21/test/test_base.py
index 68427f26f..eee46150b 100644
--- a/music21/test/test_base.py
+++ b/music21/test/test_base.py
@@ -57,11 +57,10 @@ class Test(unittest.TestCase):
         a = ElementWrapper(n)
         a.offset = 3.0
         c = ElementWrapper(n)
-        c.offset = 3.0
+        c.offset = 2.0
         self.assertEqual(a, c)
         self.assertIsNot(a, c)
-        b = ElementWrapper(n)
-        b.offset = 2.0
+        b = ElementWrapper(note.Note('G'))
         self.assertNotEqual(a, b)
 
     def testNoteCreation(self):
diff --git a/music21/test/treeYield.py b/music21/test/treeYield.py
index 57c6123d6..cb09d5ade 100644
--- a/music21/test/treeYield.py
+++ b/music21/test/treeYield.py
@@ -15,6 +15,8 @@ from __future__ import annotations
 
 import weakref
 
+import music21
+
 class TreeYielder:  # pragma: no cover
     def __init__(self, yieldValue=None):
         '''
@@ -169,13 +171,43 @@ def testMIDIParse():  # pragma: no cover
 
 
 def find_all_exception_classes_in_m21():  # pragma: no cover
+    return find_all_classes_by_criteria(
+        lambda mm: issubclass(mm, music21.exceptions21.Music21Exception)
+    )
+
+def find_all_non_hashable_m21objects():  # pragma: no cover
+    # is a bug if not empty
+    def is_unhashable(mm):
+        if not issubclass(mm, music21.base.Music21Object):
+            return False
+        try:
+            {mm()}
+        except TypeError as te:
+            return 'unhashable' in str(te)
+        return False
+    return find_all_classes_by_criteria(is_unhashable)
+
+def find_all_non_default_instantiation_m21objects():  # pragma: no cover
+    # Lack of default instantiation is not necessarily a bug, but
+    # let's try not to have them
+    def needs_attributes(mm):
+        if not issubclass(mm, music21.base.Music21Object):
+            return False
+        try:
+            mm()
+        except TypeError:
+            return True
+        return False
+    return find_all_classes_by_criteria(needs_attributes)
+
+
+def find_all_classes_by_criteria(criteria):  # pragma: no cover
     from collections import deque
-    import music21
     import types
 
     d = deque([music21])
     seen = set()
-    tous = set()
+    matches = set()
     while d:
         m = d.popleft()
         if m in seen:
@@ -186,11 +218,12 @@ def find_all_exception_classes_in_m21():  # pragma: no cover
             if (isinstance(mm, types.ModuleType)
                     and mm not in seen
                     and 'music21' in getattr(mm, '__file__', '')):
+                # noinspection PyTypeChecker
                 d.append(mm)
-            elif isinstance(mm, type) and issubclass(mm, music21.exceptions21.Music21Exception):
-                tous.add(mm)
+            elif isinstance(mm, type) and mm not in seen and criteria(mm):
+                matches.add(mm)
         seen.add(m)
-    return tous
+    return matches
 
 
 if __name__ == '__main__':