Repository: cuthbertLab/music21 (python)

Issue:
Decide whether to support class strings as keys for `Stream.__getitem__`
https://github.com/cuthbertLab/music21/blob/ebc47a1fbc9d65981bc52928593b4b564ae96cd5/music21/stream/base.py#L322-L324

Doesn't work as described; only names work, not strings:

```
>>> h = corpus.parse('handel')
>>> h[stream.Part]
<music21.stream.Score 0x110207ee0>
>>> h['stream.Part']
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/jwalls/music21/music21/stream/base.py", line 451, in __getitem__
    raise KeyError(f'provided key ({k}) does not match any id or group')
KeyError: 'provided key (stream.Part) does not match any id or group'
>>> h['Part']
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/jwalls/music21/music21/stream/base.py", line 451, in __getitem__
    raise KeyError(f'provided key ({k}) does not match any id or group')
KeyError: 'provided key (Part) does not match any id or group'
```

**More information**
Shall we correct the docs or add the feature?

Here's adding the feature:
```diff
diff --git a/music21/stream/base.py b/music21/stream/base.py
index 4d8f92316..3e792a754 100644
--- a/music21/stream/base.py
+++ b/music21/stream/base.py
@@ -398,7 +398,8 @@ class Stream(core.StreamCoreMixin, base.Music21Object):
         KeyError: "provided class (<class 'music21.layout.StaffLayout'>) does
             not match any contained Objects"
 
-        Changed in v7. -- out of range indexes now raise an IndexError, not StreamException
+        Changed in v7. -- out of range indexes now raise an IndexError, not StreamException.
+        Unsupported types now raise TypeError. Strings now supported for class names.
         '''
         # need to sort if not sorted, as this call may rely on index positions
         if not self.isSorted and self.autoSort:
@@ -447,15 +448,20 @@ class Stream(core.StreamCoreMixin, base.Music21Object):
                 groupStream = self.iter.getElementsByGroup(k)
                 if groupStream:
                     return groupStream.stream()
-                else:
-                    raise KeyError(f'provided key ({k}) does not match any id or group')
-        elif isinstance(k, type(type)):
+                # else:
+                    # raise KeyError(f'provided key ({k}) does not match any id or group')
+
+        if isinstance(k, (str, type)):
             # assume it is a class name
             classStream = self.iter.getElementsByClass(k)
             if classStream:
                 return classStream.stream()
-            else:
+            elif isinstance(k, type):
                 raise KeyError(f'provided class ({k}) does not match any contained Objects')
+            else:
+                raise KeyError(f'provided key ({k}) does not match any id, group, or class name')
+
+        raise TypeError(f'Get items by int, slice, id, or class; got {type(k)}')
```

Pull request:
Improve `Stream.__getitem__()`
Fixes #947 

Changed in v7:
  - [earlier PR:] out of range indexes now raise an IndexError, not StreamException
  - class searches now recursive
  - negative slice indices now work
  -  Unsupported types now raise TypeError
  - Class and string searches now return a `StreamIterator` rather than a `Stream`
  - slice searches return a list, not a `Stream`

Patch:
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index 686422476..b248a201f 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -34,5 +34,6 @@ myStream2.insert(note.Rest(0.5))
 <!-- 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.
+     Bug reports that are specific to other OSes w/o a PR will be closed
+     unless the reporter is willing to contribute a fix.
  -->
diff --git a/music21/analysis/reduction.py b/music21/analysis/reduction.py
index e6aacfd57..f4ac14192 100644
--- a/music21/analysis/reduction.py
+++ b/music21/analysis/reduction.py
@@ -908,15 +908,20 @@ class Test(unittest.TestCase):
         self.assertEqual(len(post.parts[0].flat.notes), 3)
         # post.parts[0].show('t')
 
-        match = [(repr(e), e.offset, e.duration.quarterLength)
-            for e in post.parts[0].getElementsByClass('Measure').stream()[0:3].flat.notesAndRests]
+        three_measures = post.parts.first()[stream.Measure][:3]
+        new_stream = stream.Stream()
+        for m in three_measures:
+            new_stream.append(m)
+        flat_stream = new_stream.flat
+        match = [(repr(e), e.offset, e.duration.quarterLength) for e in flat_stream.notesAndRests]
+        self.maxDiff = None
         self.assertEqual(match,
                          [('<music21.note.Rest rest>', 0.0, 1.0),
                           ('<music21.note.Note F#>', 1.0, 1.0),
                           ('<music21.note.Rest rest>', 2.0, 1.0),
                           ('<music21.note.Note C#>', 3.0, 1.0),
-                          ('<music21.note.Rest rest>', 5.0, 1.0),
-                          ('<music21.note.Note G#>', 6.0, 1.0)])
+                          ('<music21.note.Rest rest>', 4.0, 1.0),
+                          ('<music21.note.Note G#>', 5.0, 1.0)])
 
         # test that lyric is found
         self.assertEqual(post.parts[0].flat.notes[0].lyric, 'fromBass')
diff --git a/music21/audioSearch/__init__.py b/music21/audioSearch/__init__.py
index ef7fb70a3..6bf5ddbe2 100644
--- a/music21/audioSearch/__init__.py
+++ b/music21/audioSearch/__init__.py
@@ -920,7 +920,8 @@ def decisionProcess(
     >>> beginningData = []
     >>> lengthData = []
     >>> for i in range(4):
-    ...     scNotes = scoreStream[i * hop + 1:i * hop + tn_recording + 1]
+    ...     excerpt = scoreStream[i * hop + 1:i * hop + tn_recording + 1]
+    ...     scNotes = stream.Part(excerpt)
     ...     name = str(i)
     ...     beginningData.append(i * hop + 1)
     ...     lengthData.append(tn_recording)
diff --git a/music21/audioSearch/scoreFollower.py b/music21/audioSearch/scoreFollower.py
index 86557c2a2..4ec36f464 100644
--- a/music21/audioSearch/scoreFollower.py
+++ b/music21/audioSearch/scoreFollower.py
@@ -17,6 +17,7 @@ from time import time
 
 from music21 import scale
 from music21 import search
+from music21 import stream
 
 from music21 import environment
 _MOD = 'audioSearch.scoreFollower'
@@ -160,7 +161,8 @@ class ScoreFollower:
             detectedPitchObjects)
         self.silencePeriodDetection(notesList)
         environLocal.printDebug('made it to here...')
-        scNotes = self.scoreStream[self.lastNotePosition:self.lastNotePosition + len(notesList)]
+        excerpt = self.scoreStream[self.lastNotePosition:self.lastNotePosition + len(notesList)]
+        scNotes = stream.Part(excerpt)
         # print('1')
         transcribedScore, self.qle = audioSearch.notesAndDurationsToStream(
             notesList,
@@ -501,7 +503,8 @@ class ScoreFollower:
                              - math.ceil(tn_window / hop))
 
         for i in range(iterations):
-            scNotes = scoreStream[i * hop + 1:i * hop + tn_recording + 1]
+            excerpt = scoreStream[i * hop + 1:i * hop + tn_recording + 1]
+            scNotes = stream.Part(excerpt)
             name = str(i)
             beginningData.append(i * hop + 1)
             lengthData.append(tn_recording)
diff --git a/music21/figuredBass/realizer.py b/music21/figuredBass/realizer.py
index 532b680c1..9a7663a38 100644
--- a/music21/figuredBass/realizer.py
+++ b/music21/figuredBass/realizer.py
@@ -263,7 +263,7 @@ class FiguredBassLine:
 
         >>> from music21 import corpus
         >>> sBach = corpus.parse('bach/bwv307')
-        >>> sBach['bass'].measure(0).show('text')
+        >>> sBach.parts.last().measure(0).show('text')
         {0.0} ...
         {0.0} <music21.clef.BassClef>
         {0.0} <music21.key.Key of B- major>
@@ -271,7 +271,7 @@ class FiguredBassLine:
         {0.0} <music21.note.Note B->
         {0.5} <music21.note.Note C>
 
-        >>> fbLine = realizer.figuredBassFromStream(sBach['bass'])
+        >>> fbLine = realizer.figuredBassFromStream(sBach.parts.last())
         >>> fbLine.generateBassLine().measure(1).show('text')
         {0.0} <music21.clef.BassClef>
         {0.0} <music21.key.KeySignature of 2 flats>
diff --git a/music21/key.py b/music21/key.py
index c3086e8e6..0dd523f0c 100644
--- a/music21/key.py
+++ b/music21/key.py
@@ -1095,7 +1095,7 @@ class Key(KeySignature, scale.DiatonicScale):
         alternateInterpretations list has
         been filled from the use of a KeyWeightKeyAnalysis subclass.
 
-        >>> littlePiece = converter.parse('tinyNotation: 4/4 c4 d e f g a b cc ee gg ee cc')
+        >>> littlePiece = converter.parse("tinyNotation: 4/4 c4 d e f g a b c' e' g' e' c'")
         >>> k = littlePiece.analyze('key')
         >>> k
         <music21.key.Key of C major>
diff --git a/music21/stream/base.py b/music21/stream/base.py
index 18dafee3a..a69e460b8 100644
--- a/music21/stream/base.py
+++ b/music21/stream/base.py
@@ -373,95 +373,137 @@ class Stream(core.StreamCoreMixin, base.Music21Object):
         '''
         Get a Music21Object from the Stream using a variety of keys or indices.
 
-        If an int is given, the Music21Object at the index is returned. If the Stream is sorted
-        (if isSorted is True), the elements are returned in order.
-
-        If a class name is given (as a string or name),
-        :meth:`~music21.stream.Stream.getElementsByClass` is used to return a Stream of the
-        elements that match the requested class.
-
-        If a string is given, :meth:`~music21.stream.Stream.getElementById` first, then
-        (if no results are found) :meth:`~music21.stream.Stream.getElementsByGroup` is used to
-        collect and return elements as a Stream.
-
-        >>> a = stream.Part(id='hello')
-        >>> names = ['C', 'D', 'E', 'F', 'G', 'A']
-        >>> for i in range(6):
-        ...     name = names[i]
-        ...     a.insert(i + 1, note.Note(name))
-        >>> a[0]
+        If an int is given, the Music21Object at the index is returned, as if it were a list
+        or tuple:
+
+        >>> c = note.Note('C')
+        >>> d = note.Note('D')
+        >>> e = note.Note('E')
+        >>> r1 = note.Rest()
+        >>> f = note.Note('F')
+        >>> g = note.Note('G')
+        >>> r2 = note.Rest()
+        >>> a = note.Note('A')
+        >>> s = stream.Stream([c, d, e, r1, f, g, r2, a])
+
+        >>> s[0]
         <music21.note.Note C>
-        >>> a[-1]
+        >>> s[-1]
         <music21.note.Note A>
 
         Out of range notes raise an IndexError:
 
-        >>> a[99]
+        >>> s[99]
         Traceback (most recent call last):
-        IndexError: attempting to access index 99 while elements is of size 6
-        >>> subslice = a[2:5]
+        IndexError: attempting to access index 99 while elements is of size 8
+
+        If a slice of indices is given, a list of elements is returned, as if the Stream
+        were a list or Tuple.
+
+        >>> subslice = s[2:5]
         >>> subslice
-        <music21.stream.Part hello>
-        >>> subslice.derivation
-        <Derivation of <music21.stream.Part hello> from
-            <music21.stream.Part hello> via '__getitem__'>
+        [<music21.note.Note E>, <music21.note.Rest rest>, <music21.note.Note F>]
         >>> len(subslice)
         3
-        >>> a[1].offset
-        2.0
+        >>> s[1].offset
+        1.0
         >>> subslice[1].offset
-        4.0
+        3.0
 
-        >>> b = note.Note()
-        >>> b.id = 'green'
-        >>> b.groups.append('violin')
-        >>> a.insert(b)
 
-        Get an item by class
+        If a class is given then an iterator of elements
+        that match the requested class(es) is returned, similar
+        to `Stream().recurse().getElementsByClass()`.
 
-        >>> allNotes = a[note.Note]
-        >>> allNotes
-        <music21.stream.Part hello>
-        >>> allNotes.derivation
-        <Derivation of <music21.stream.Part hello> from
-            <music21.stream.Part hello> via 'getElementsByClass'>
-        >>> allNotes[0] is b
-        True
+        >>> len(s)
+        8
+        >>> len(s[note.Rest])
+        2
+        >>> len(s[note.Note])
+        6
 
-        Get items by groups:
+        >>> for n in s[note.Note]:
+        ...     print(n.name, end=' ')
+        C D E F G A
 
-        >>> violinGroup = a['violin']
-        >>> violinGroup
-        <music21.stream.Part hello>
-        >>> violinGroup.derivation
-        <Derivation of <music21.stream.Part hello> from
-            <music21.stream.Part hello> via 'getElementsByGroup'>
-        >>> violinGroup[0] is b
-        True
+        Note that this iterator is recursive by default.
+
+        >>> c_sharp = note.Note('C#')
+        >>> v = stream.Voice([c_sharp])
+        >>> s.insert(0.5, c_sharp)
+
+        >>> len(s[note.Note])
+        7
+
+
+        The actual object returned by `s[module.Class]` is a
+        :class:`~music21.stream.iterator.RecursiveIterator` and has all the functions
+        available on it:
 
-        Get a single element by id:
+        >>> s[note.Note]
+        <...>
+
+        If no elements of the class are found, no error is raised in version 7:
+
+        >>> list(s[layout.StaffLayout])
+        []
 
-        >>> a['green'] is b
+
+        If the key is a string, it is treated as a `querySelector` as defined in
+        :meth:`~music21.stream.iterator.getElementsByQuerySelector`, namely that bare strings
+        are treated as class names, strings beginning with `#` are id-queries, and strings
+        beginning with `.` are group queries.
+
+        We can set some ids and groups for demonstrating.
+
+        >>> a.id = 'last_a'
+        >>> c.groups.append('ghost')
+        >>> e.groups.append('ghost')
+
+        'Note' is treated as a class name and returns a `RecursiveIterator`:
+
+        >>> for n in s['Note']:
+        ...     print(n.name, end=' ')
+        C C# D E F G A
+
+        '.ghost', because it begins with is treated as a class name and
+        returns a `RecursiveIterator`:
+
+
+        >>> for n in s['.ghost']:
+        ...     print(n.name, end=' ')
+        C E
+
+        A query selector with a `#`
+
+        >>> s['#last_a']
+        <music21.note.Note A>
+
+        >>> s['#nothing'] is None
         True
 
-        If a string or class is not found, a KeyError will be raised:
 
-        >>> a['purple']
-        Traceback (most recent call last):
-        KeyError: 'provided key (purple) does not match any id or group'
+        Any other query raises a TypeError:
 
-        >>> a[layout.StaffLayout]
+        >>> s[0.5]
         Traceback (most recent call last):
-        KeyError: "provided class (<class 'music21.layout.StaffLayout'>) does
-            not match any contained Objects"
+        TypeError: Streams can get items by int, slice, class, or string query; got <class 'float'>
+
+
+        Changed in v7:
 
-        Changed in v7. -- out of range indexes now raise an IndexError, not StreamException
+          - out of range indexes now raise an IndexError, not StreamException
+          - strings ('Note', '#id', '.group') are now treated like a query selector.
+          - slices with negative indices now supported
+          - Unsupported types now raise TypeError
+          - Class and Group searches now return a recursive `StreamIterator` rather than a `Stream`
+          - Slice searches now return a list of elements rather than a `Stream`
         '''
         # need to sort if not sorted, as this call may rely on index positions
         if not self.isSorted and self.autoSort:
             self.sort()  # will set isSorted to True
 
-        if common.isNum(k):
+        if isinstance(k, int):
             match = None
             # handle easy and most common case first
             if 0 <= k < len(self._elements):
@@ -483,36 +525,27 @@ class Stream(core.StreamCoreMixin, base.Music21Object):
         elif isinstance(k, slice):  # get a slice of index values
             # manually inserting elements is critical to setting the element
             # locations
-            try:
-                found = self.cloneEmpty(derivationMethod='__getitem__')
-            except TypeError:  # pragma: no cover
-                raise StreamException(f'Error in defining class: {self.__class__}'
-                                      + 'Stream subclasses and Music21Objects cannot have required '
-                                      + 'arguments in __init__')
-            for e in self.elements[k]:
-                found.coreInsert(self.elementOffset(e), e)
+            searchElements = self._elements
+            if (k.start is not None and k.start < 0) or (k.stop is not None and k.stop < 0):
+                # Must use .elements property to incorporate end elements
+                searchElements = self.elements
+
+            return searchElements[k]
 
-            found.coreElementsChanged(clearIsSorted=False)
-            return found
+        elif isinstance(k, type):
+            return self.recurse().getElementsByClass(k)
 
         elif isinstance(k, str):
-            # first search id, then search groups
-            idMatch = self.getElementById(k)
-            if idMatch is not None:
-                return idMatch
-            else:  # search groups, return first element match
-                groupStream = self.iter.getElementsByGroup(k)
-                if groupStream:
-                    return groupStream.stream()
-                else:
-                    raise KeyError(f'provided key ({k}) does not match any id or group')
-        elif isinstance(k, type(type)):
-            # assume it is a class name
-            classStream = self.iter.getElementsByClass(k)
-            if classStream:
-                return classStream.stream()
+            querySelectorIterator = self.recurse().getElementsByQuerySelector(k)
+            if '#' in k:
+                # an id anywhere in the query selector should return only one element
+                return querySelectorIterator.first()
             else:
-                raise KeyError(f'provided class ({k}) does not match any contained Objects')
+                return querySelectorIterator
+
+        raise TypeError(
+            f'Streams can get items by int, slice, class, or string query; got {type(k)}'
+        )
 
     def first(self):
         '''
@@ -13207,27 +13240,9 @@ class Score(Stream):
         >>> s = corpus.parse('bach/bwv66.6')
         >>> s.parts
         <music21.stream.iterator.StreamIterator for Score:0x104af3a58 @:0>
-        >>> partStream = s.parts.stream()
-        >>> partStream.classes
-        ('Score', 'Stream', 'StreamCoreMixin', 'Music21Object', 'ProtoM21Object', 'object')
-        >>> len(partStream)
+        >>> len(s.parts)
         4
 
-        The partStream object is a full `stream.Score` object, thus the elements inside it
-        can be accessed by index number or by id string, or iterated over:
-
-        >>> partStream[0]
-        <music21.stream.Part Soprano>
-        >>> partStream['Alto']
-        <music21.stream.Part Alto>
-        >>> for p in partStream:
-        ...     print(p.id)
-        Soprano
-        Alto
-        Tenor
-        Bass
-
-
         OMIT_FROM_DOCS
 
         Ensure that getting from cache still will reset the iteration.
diff --git a/music21/stream/iterator.py b/music21/stream/iterator.py
index 9df634b46..8c4d7b8ae 100644
--- a/music21/stream/iterator.py
+++ b/music21/stream/iterator.py
@@ -89,6 +89,16 @@ class StreamIterator(prebase.ProtoM21Object):
     * For `activeInformation` see above.
 
     Changed in v.5.2 -- all arguments except srcStream are keyword only.
+
+    OMIT_FROM_DOCS
+
+    Informative exception for user error:
+
+    >>> s = stream.Stream()
+    >>> sIter = stream.iterator.StreamIterator(s, filterList=[note.Note])
+    Traceback (most recent call last):
+    TypeError: filterList expects Filters or callables,
+    not types themselves; got <class 'music21.note.Note'>
     '''
     def __init__(self,
                  srcStream: 'music21.stream.Stream',
@@ -122,6 +132,10 @@ class StreamIterator(prebase.ProtoM21Object):
             filterList = [filterList]
         elif isinstance(filterList, (set, tuple)):
             filterList = list(filterList)  # mutable....
+        for x in filterList:
+            if isinstance(x, type):
+                raise TypeError(
+                    f'filterList expects Filters or callables, not types themselves; got {x}')
         # self.filters is a list of expressions that
         # return True or False for an element for
         # whether it should be yielded.
@@ -921,6 +935,53 @@ class StreamIterator(prebase.ProtoM21Object):
         '''
         return self.addFilter(filters.ClassFilter(classFilterList), returnClone=returnClone)
 
+    def getElementsByQuerySelector(self, querySelector: str, *, returnClone=True):
+        '''
+        First implementation of a query selector, similar to CSS QuerySelectors used in
+        HTML DOM:
+
+        * A leading `#` indicates the id of an element, so '#hello' will find elements
+          with `el.id=='hello'` (should only be one)
+        * A leading `.` indicates the group of an element, so '.high' will find elements
+          with `'high' in el.groups`
+        * Any other string is considered to be the type/class of the element.  So `Note`
+          will find all Note elements.  Can be fully qualified like `note.Note`
+
+        Eventually, more complex query selectors will be implemented.  This is just a start.
+
+        Setting up an example:
+
+        >>> s = converter.parse('tinyNotation: 4/4 GG4 AA4 BB4 r4 C4 D4 E4 F4 r1')
+        >>> s[note.Note].last().id = 'last'
+        >>> for n in s[note.Note]:
+        ...     if n.octave == 3:
+        ...         n.groups.append('tenor')
+
+        >>> list(s.recurse().getElementsByQuerySelector('.tenor'))
+        [<music21.note.Note C>,
+         <music21.note.Note D>,
+         <music21.note.Note E>,
+         <music21.note.Note F>]
+
+        >>> list(s.recurse().getElementsByQuerySelector('Rest'))
+        [<music21.note.Rest rest>,
+         <music21.note.Rest rest>]
+
+        Note that unlike with stream slices, the querySelector does not do anything special
+        for id searches.  `.first()` will need to be called to find the element (if any)
+
+        >>> s.recurse().getElementsByQuerySelector('#last').first()
+        <music21.note.Note F>
+
+        New in v.7
+        '''
+        if querySelector.startswith('#'):
+            return self.addFilter(filters.IdFilter(querySelector[1:]), returnClone=returnClone)
+        if querySelector.startswith('.'):
+            return self.addFilter(filters.GroupFilter(querySelector[1:]), returnClone=returnClone)
+        return self.addFilter(filters.ClassFilter(querySelector), returnClone=returnClone)
+
+
     def getElementsNotOfClass(self, classFilterList, *, returnClone=True):
         '''
         Adds a filter, removing all Elements that do not
diff --git a/music21/tinyNotation.py b/music21/tinyNotation.py
index 30052c77a..9f854016a 100644
--- a/music21/tinyNotation.py
+++ b/music21/tinyNotation.py
@@ -230,6 +230,7 @@ import collections
 import copy
 import re
 import sre_parse
+import typing
 import unittest
 
 from music21 import note
@@ -751,6 +752,120 @@ class NoteToken(NoteOrRestToken):
         return t
 
 
+def _getDefaultTokenMap() -> typing.List[
+        typing.Tuple[
+            str,
+            typing.Type[Token]
+        ]
+]:
+    """
+    Returns the default tokenMap for TinyNotation.
+
+    Based on the following grammar (in Extended Backus-Naur form)
+    (https://en.wikipedia.org/wiki/Extended_Backus%E2%80%93Naur_form)
+
+    (* Items in parentheses are grouped *)
+    (* Items in curly braces appear zero or more times *)
+    (* Items in square brackets may appear exactly zero or one time *)
+    (* Items in double quotes are literal strings *)
+    (* Items between question marks should be interpreted as English *)
+    (* Each rule is ended by a semicolon *)
+
+    TINY-NOTATION = TOKEN, { WHITESPACE, TOKEN } ;
+    WHITESPACE = ( " " | ? Carriage return ? ) , { " " | ? Carriage return ? } ;
+    TOKEN = ( TIME-SIGNATURE | TUPLET | REST | NOTE );
+    TIME-SIGNATURE = INTEGER, "/", INTEGER ;
+    INTEGER = DIGIT, { DIGIT } ;
+    DIGIT = ( "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" ) ;
+    TUPLET = ( "trip" | "quad" | ALPHANUMERIC ), "{",
+        [ WHITESPACE ],
+        ( REST | NOTE ),
+        { WHITESPACE, ( REST | NOTE ) },
+        [ WHITESPACE ],
+    "}" ;
+    REST = "r", [ DURATION ], [ MODIFIER ] ;
+    DURATION = ( EVEN-NUMBER, { "." } | { "." }, EVEN-NUMBER | ".", { "." } ) ;
+    EVEN-NUMBER = { INTEGER }, ( "0" | "2" | "4" | "6" | "8" ) ;
+    NOTE = PITCH, [ DURATION ], [ TIE ], { MODIFIER } ;
+    PITCH = (
+        ( LOW-A | LOW-B | LOW-C | LOW-D | LOW-E | LOW-F | LOW-G ), [ ACCIDENTAL ] |
+        ( "a" | "b" | "c" | "d" | "e" | "f" | "g" ), [ ACCIDENTAL ], { "'" } |
+        ( "a" | "b" | "c" | "d" | "e" | "f" | "g" ), { "'" }, [ ACCIDENTAL ]
+    ) ;
+    LOW-A = "A", { "A" } ;
+    LOW-B = "B", { "B" } ;
+    LOW-C = "C", { "C" } ;
+    LOW-D = "D", { "D" } ;
+    LOW-E = "E", { "E" } ;
+    LOW-F = "F", { "F" } ;
+    LOW-G = "G", { "G" } ;
+    ACCIDENTAL = ( EDITORIAL | SHARPS | FLATS | NATURAL ) ;
+    EDITORIAL = "(", ( SHARPS | FLATS | NATURAL ), ")" ;
+    SHARPS = "#", { "#" } ;
+    FLATS = "-", { "-" } ;
+    NATURAL = "n" ;
+    TIE = "~" ;
+    MODIFIER = (
+        EQUALS-MODIFIER |
+        UNDERSCORE-MODIFIER |
+        SQUARE-MODIFIER |
+        ANGLE-MODIFIER |
+        PARENS-MODIFIER |
+        STAR-MODIFIER
+    ) ;
+    EQUALS-MODIFIER = "=", EQUALS-DATA ;
+    UNDERSCORE-MODIFIER = "_", UNDERSCORE-DATA ;
+    SQUARE-MODIFIER = "[", ALPHANUMERIC, "]" ;
+    ANGLE-MODIFIER = "<", ALPHANUMERIC, ">" ;
+    PARENS-MODIFIER = "(", ALPHANUMERIC, ")" ;
+    STAR-MODIFIER = "*", ALPHANUMERIC, "*" ;
+    (* The following is just shorthand. *)
+    ALPHANUMERIC = ? At least one alphanumeric character. So "a-z", "A-Z", or "0-9" ? ;
+    EQUALS-DATA = ? At least one non-whitespace, non-"_" character. ? ;
+    UNDERSCORE-DATA = ? At least one non-whitespace, non-"=" character. ? ;
+    """
+    sharpsFlatsOrNaturalRegex = r'#+|-+|n'
+    editorialRegex = fr'\((?:{sharpsFlatsOrNaturalRegex})\)'
+    accidentalRegex = fr'{editorialRegex}|(?:{sharpsFlatsOrNaturalRegex})'
+
+    lowNoteRegex = fr'(?:A+|B+|C+|D+|E+|F+|G+)(?:{accidentalRegex})?'
+    highNoteRegex = (
+        r'(?:a|b|c|d|e|f|g)'
+        + fr"(?:(?:{accidentalRegex})?'*|'*(?:{accidentalRegex})?)"
+    )
+    noteNameRegex = fr'{lowNoteRegex}|{highNoteRegex}'
+
+    durationRegex = r'\d+\.*|\.*\d+|\.+'
+
+    tieStateRegex = r'~'
+
+    equalsRegex = r'=[^\s_]*'
+    starRegex = r'\*.*?\*'
+    angleRegex = r'<.*?>'
+    parensRegex = r'\(.*?\)'
+    squareRegex = r'\[.*?]'
+    underscoreRegex = r'_[^\s=]'
+    modifierRegex = (
+        fr'{equalsRegex}|{starRegex}|{angleRegex}|'
+        + fr'{parensRegex}|{squareRegex}|{underscoreRegex}'
+    )
+
+    return [
+        (r'^(\d+\/\d+)$', TimeSignatureToken),
+        (
+            fr'^r((?:{durationRegex})?(?:{modifierRegex})*)$',
+            RestToken
+        ),
+        (
+            (
+                fr'^((?:{noteNameRegex})(?:{durationRegex})?'
+                + fr'(?:{tieStateRegex})?(?:{modifierRegex})*)$'
+            ),
+            NoteToken
+        ),  # last
+    ]
+
+
 class Converter:
     '''
     Main conversion object for TinyNotation.
@@ -942,11 +1057,7 @@ class Converter:
         self.generalBracketStateRe = re.compile(r'(\w+){')
         self.tieStateRe = re.compile(r'~')
 
-        self.tokenMap = [
-            (r'(\d+\/\d+)', TimeSignatureToken),
-            (r'r(\S*)', RestToken),
-            (r'([a-gA-G]\S*)', NoteToken),  # last
-        ]
+        self.tokenMap = _getDefaultTokenMap()
         self.modifierEquals = IdModifier
         self.modifierStar = None
         self.modifierAngle = None
@@ -1238,7 +1349,7 @@ class Converter:
 class Test(unittest.TestCase):
     parseTest = '1/4 trip{C8~ C~_hello C=mine} F~ F~ 2/8 F F# quad{g--16 a## FF(n) g#} g16 F0'
 
-    def testOne(self):
+    def testOne(self) -> None:
         c = Converter(self.parseTest)
         c.parse()
         s = c.stream
@@ -1256,6 +1367,97 @@ class Test(unittest.TestCase):
         self.assertEqual(sfn[12].duration.quarterLength, 1.0)
         self.assertEqual(sfn[12].expressions[0].classes, expressions.Fermata().classes)
 
+    def testRaiseExceptions(self) -> None:
+        error_states = [
+            {
+                'string': 'h',
+                'reason': 'h is not a valid note',
+            },
+            {
+                'string': 'a;',
+                'reason': 'a semicolon is not a valid character or modifier',
+            },
+            {
+                'string': 'r;',
+                'reason': 'a semicolon is not a valid character or modifier',
+            },
+            {
+                'string': '4/4;',
+                'reason': 'a semicolon is not a valid character or modifier',
+            },
+            {
+                'string': 'ABC',
+                'reason': (
+                    'only the same upper-cased letter may be repeated to '
+                    + 'indicate lower octaves'
+                ),
+            },
+            {
+                'string': 'aaa',
+                'reason': (
+                    'the same lower-cased letter may not be repeated to '
+                    + 'indicate higher octaves. Instead use apostrophes.'
+                ),
+            },
+        ]
+
+        for error_state in error_states:
+            with self.assertRaises(TinyNotationException, msg=(
+                    'Should have raised a TinyNotationException for input '
+                    + f"'{error_state['string']}' because {error_state['reason']}."
+            )):
+                converter = Converter(error_state['string'], raiseExceptions=True)
+                converter.parse()
+
+    def testGetDefaultTokenMap(self) -> None:
+        defaultTokenMap = _getDefaultTokenMap()
+
+        self.assertEqual(
+            len(defaultTokenMap),
+            3,
+            (
+                'There should be three valid token types by default: Time '
+                + 'signatures, Notes, and Rests'
+            )
+        )
+
+        validTokenTypeCounts = {
+            NoteToken: 0,
+            RestToken: 0,
+            TimeSignatureToken: 0,
+        }
+
+        for regex, tokenType in defaultTokenMap:
+            self.assertIn(
+                tokenType,
+                validTokenTypeCounts,
+                (
+                    'Found unexpected token type in default token map:'
+                    + f'{tokenType.__class__.__name__}.'
+                )
+            )
+            validTokenTypeCounts[tokenType] += 1
+            self.assertGreater(
+                len(regex),
+                0,
+                (
+                    'Should provide a non-empty string for the regular '
+                    + 'expression in the default token map for tokens of type '
+                    + f'{tokenType.__class__.__name__}.'
+                )
+            )
+
+        for tokenType in validTokenTypeCounts:
+            self.assertEqual(
+                validTokenTypeCounts[tokenType],
+                1,
+                (
+                    'Should have found each valid token type exactly once in '
+                    + 'the default token map.'
+                )
+            )
+
+
 
 class TestExternal(unittest.TestCase):  # pragma: no cover
 


Test patch:
diff --git a/music21/stream/tests.py b/music21/stream/tests.py
index f68d26540..6a061cc16 100644
--- a/music21/stream/tests.py
+++ b/music21/stream/tests.py
@@ -619,18 +619,18 @@ class Test(unittest.TestCase):
         a = converter.parse(thisWork)
 
         b = a[7][5:10]
-        environLocal.printDebug(['b', b, b.sites.getSiteIds()])
+        # environLocal.printDebug(['b', b, b.sites.getSiteIds()])
         c = a[7][10:15]
-        environLocal.printDebug(['c', c, c.sites.getSiteIds()])
+        # environLocal.printDebug(['c', c, c.sites.getSiteIds()])
         d = a[7][15:20]
-        environLocal.printDebug(['d', d, d.sites.getSiteIds()])
+        # environLocal.printDebug(['d', d, d.sites.getSiteIds()])
 
         s2 = Stream()
-        environLocal.printDebug(['s2', s2, id(s2)])
+        # environLocal.printDebug(['s2', s2, id(s2)])
 
-        s2.insert(b)
-        s2.insert(c)
-        s2.insert(d)
+        for stream_case in (b, c, d):
+            for e in stream_case:
+                s2.insert(e.offset, e)
 
     def testActiveSites(self):
         '''
@@ -2929,7 +2929,8 @@ class Test(unittest.TestCase):
         '''
         src = corpus.parse('bach/bwv324.xml')
         # get some measures of the soprano; just get the notes
-        ex = src.parts[0].flat.notesAndRests.stream()[0:30]
+        found = src.parts.first().flat.notesAndRests[0:30]
+        ex = Part(found)
 
         self.assertEqual(ex.highestOffset, 38.0)
         self.assertEqual(ex.highestTime, 42.0)
@@ -2957,7 +2958,8 @@ class Test(unittest.TestCase):
         src = corpus.parse('bach/bwv324.xml')
         # get some measures of the soprano; just get the notes
         # environLocal.printDebug(['testAugmentOrDiminishCorpus()', 'extracting notes:'])
-        ex = src.parts[0].flat.notesAndRests.stream()[0:30]
+        found = src.parts.first().flat.notesAndRests[0:30]
+        ex = Part(found)
         # attach a couple of transformations
         s = Score()
         for scalar in [0.5, 1.5, 2, 0.25]:
@@ -6546,11 +6548,11 @@ class Test(unittest.TestCase):
         self.assertEqual(match, [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 25.0])
 
         # s.elements = s1
-        s3 = s1[:]
+        s3 = Stream(s1[:])
         match = []
         for e in s3.elements:
             match.append(e.getOffsetBySite(s3))
-        self.assertEqual(match, [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0])
+        self.assertEqual(match, [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 25.0])
 
         # this resets active site, so we get the right offsets on element
         # assignment