Repository: cuthbertLab/music21
Pull request #1052 (2021-07-02 05:07:39)

Parse requirements from file
Reads minimal requirements from the adjacent requirements.txt and uses them to define the install_requires
options for setuptools.

This addresses https://github.com/cuthbertLab/music21/issues/338 and fixes https://github.com/cuthbertLab/music21/issues/1050.

I tested using the following procedure:
```
git_home=~/Downloads
repo_name=music21
repo_path=${git_home}/${repo_name}
repo_url=https://github.com/JamesOwers/music21.git
git clone ${repo_url} ${repo_path}
conda create -n ${repo_name}-dev python=3.8 --yes
conda activate ${repo_name}-dev
pip install ${repo_path}
python -c 'import jsonpickle' && echo 'This succeeded as expected!'
```

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

Coverage remained the same at 92.339% when pulling **6586f1b2a10f046b4e61f21bed0c54a862b2069b on JamesOwers:master** into **2c21a9921b55ffa906c828ab91739592780f9c0b on cuthbertLab:master**.

jacobtylerwalls: @mscuthbert would you prefer hard-coding here? I don't know how common it is to read files in a setup.py
jacobtylerwalls: Ah, the python docs example from pip's setup.py does read files.
JamesOwers: Yeah, tis standard (check out numpy/pandas/sklearn etc). If you don’t use
the requirements.txt elsewhere, it’s somewhat neater to put these options
in the setup.cfg IMHO - removes two files and the file reading code in
setup.py. Happy to implement that if you prefer.

On Fri, 2 Jul 2021 at 16:45, Jacob Walls ***@***.***> wrote:

> Ah, the python docs example from pip's setup.py does read files.
>
> —
> You are receiving this because you authored the thread.
> Reply to this email directly, view it on GitHub
> <https://github.com/cuthbertLab/music21/pull/1052#issuecomment-873092432>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AA24Q44PGTKWJ3QTEQBFO4LTVXNLFANCNFSM47V7T5LQ>
> .
>

jacobtylerwalls: > I wonder if at this point, with numpy and matplotlib so commonly available if it's just worth it to put them and jsonpickle into the requirements.txt file

+1. Users of tiny systems can download zip and `python -m pip install -r requirements-minimum.txt` if they don't want matplotlib and friends, no?
mscuthbert: Okay, let's go with that.  The extras are then just scipy, pygame, and perhaps even more obscure single-use calls.  I've removed Pillow and Gregorio at some point.

In a certain sense, MuseScore and Lilypond are more central deps but they're not python modules so can't be installed by PIP.
mscuthbert: By go-with-that, I mean, we'll just put numpy, matplotlib, and jsonpickle in requirements.txt.
JamesOwers: Have removed the extras_require option - the install now reads the requirements file, and pulled from main (which adds extra packages to requirements.txt).

Patch:
diff --git a/music21/_version.py b/music21/_version.py
index 7651c8d0c..b4949ae8b 100644
--- a/music21/_version.py
+++ b/music21/_version.py
@@ -42,7 +42,7 @@ When changing, update the single test case in base.py.
 Changing this number invalidates old pickles -- do it if the old pickles create a problem.
 '''
 
-__version_info__ = (7, 0, 7, 'b1')  # can be 4-tuple: (7, 0, 5, 'a2')
+__version_info__ = (7, 0, 8)  # can be 4-tuple: (7, 0, 5, 'a2')
 
 v = '.'.join(str(x) for x in __version_info__[0:3])
 if len(__version_info__) > 3 and __version_info__[3]:
diff --git a/music21/base.py b/music21/base.py
index 3330d787c..1c930b143 100644
--- a/music21/base.py
+++ b/music21/base.py
@@ -28,7 +28,7 @@ available after importing `music21`.
 <class 'music21.base.Music21Object'>
 
 >>> music21.VERSION_STR
-'7.0.7b1'
+'7.0.8'
 
 Alternatively, after doing a complete import, these classes are available
 under the module "base":
diff --git a/music21/dynamics.py b/music21/dynamics.py
index 1b0508bbb..98026c557 100644
--- a/music21/dynamics.py
+++ b/music21/dynamics.py
@@ -212,6 +212,7 @@ class Dynamic(base.Music21Object):
             >>> d.englishName
             'very soft'
             ''',
+        'placement': "Staff placement: 'above', 'below', or None.",
     }
 
     def __init__(self, value=None):
@@ -237,7 +238,7 @@ class Dynamic(base.Music21Object):
         self.style.absoluteX = -36
         self.style.absoluteY = -80  # below top line
         # this value provides good 16th note alignment
-        self.positionPlacement = None
+        self.placement = None
 
     def _reprInternal(self):
         return str(self.value)
diff --git a/music21/expressions.py b/music21/expressions.py
index f10a66809..af36669d0 100644
--- a/music21/expressions.py
+++ b/music21/expressions.py
@@ -322,6 +322,10 @@ class TextExpression(Expression):
     classSortOrder = -30
     _styleClass = style.TextStyle
 
+    _DOC_ATTR = {
+        'placement': "Staff placement: 'above', 'below', or None.",
+    }
+
     def __init__(self, content=None):
         super().__init__()
         # numerous properties are inherited from TextFormat
@@ -335,7 +339,7 @@ class TextExpression(Expression):
         self._enclosure = None
 
         # this does not do anything if default y is defined
-        self.positionPlacement = None
+        self.placement = None
 
     def _reprInternal(self):
         if len(self._content) >= 13:
diff --git a/music21/meter/base.py b/music21/meter/base.py
index 98d4d016f..ef4ac52c6 100644
--- a/music21/meter/base.py
+++ b/music21/meter/base.py
@@ -1293,6 +1293,14 @@ class TimeSignature(base.Music21Object):
          <music21.beam.Beams <music21.beam.Beam 1/continue>>,
          <music21.beam.Beams <music21.beam.Beam 1/continue>>,
          <music21.beam.Beams <music21.beam.Beam 1/stop>>]
+
+        Fixed in v.7 -- incomplete final measures in 6/8:
+
+        >>> sixEight = meter.TimeSignature('6/8')
+        >>> nList = [note.Note(type='quarter'), note.Note(type='eighth'), note.Note(type='eighth')]
+        >>> beamList = sixEight.getBeams(nList)
+        >>> print(beamList)
+        [None, None, None]
         '''
         from music21 import stream
         if isinstance(srcList, stream.Stream):
@@ -1352,8 +1360,11 @@ class TimeSignature(base.Music21Object):
 
             # watch for a special case where a duration completely fills
             # the archetype; this generally should not be beamed
-            if (start == archetypeSpanStart
-                    and end == archetypeSpanEnd):
+            # same if beamPrevious is None and beamNumber == 1 (quarter-eighth in 6/8)
+            if end == archetypeSpanEnd and (
+                start == archetypeSpanStart
+                or (beamPrevious is None and beamNumber == 1)
+            ):
                 # increment position and continue loop
                 beamsList[i] = None  # replace with None!
                 return
@@ -1368,7 +1379,7 @@ class TimeSignature(base.Music21Object):
 
             elif isLast:  # last is always stop
                 beamType = 'stop'
-                # get a partial beam if we cannot come form a beam
+                # get a partial beam if we cannot form a beam
                 if (beamPrevious is None
                         or beamNumber not in beamPrevious.getNumbers()):
                     # environLocal.warn(['triggering partial left where a stop normally falls'])
diff --git a/music21/musicxml/m21ToXml.py b/music21/musicxml/m21ToXml.py
index 51ecf9ad0..42d488927 100644
--- a/music21/musicxml/m21ToXml.py
+++ b/music21/musicxml/m21ToXml.py
@@ -432,6 +432,7 @@ class GeneralObjectExporter:
                 {0.0} <music21.clef.TrebleClef>
                 {0.0} <music21.meter.TimeSignature 6/8>
                 {0.0} <music21.note.Note C>
+                {3.0} <music21.bar.Barline type=final>
         >>> s.flat.notes[0].duration
         <music21.duration.Duration 3.0>
         '''
@@ -668,6 +669,9 @@ class GeneralObjectExporter:
         Translate a music21 :class:`~music21.note.Note` into an object
         ready to be parsed.
 
+        An attempt is made to find the best TimeSignature for quarterLengths
+        <= 6:
+
         >>> n = note.Note('c3')
         >>> n.quarterLength = 3
         >>> GEX = musicxml.m21ToXml.GeneralObjectExporter()
@@ -678,19 +682,29 @@ class GeneralObjectExporter:
                 {0.0} <music21.clef.BassClef>
                 {0.0} <music21.meter.TimeSignature 6/8>
                 {0.0} <music21.note.Note C>
+                {3.0} <music21.bar.Barline type=final>
+
+        But longer notes will be broken into tied components placed in
+        4/4 measures:
+
+        >>> long_note = note.Note('e5', quarterLength=40)
+        >>> GEX = musicxml.m21ToXml.GeneralObjectExporter()
+        >>> sc = GEX.fromGeneralNote(long_note)
+        >>> sc[meter.TimeSignature].first()
+        <music21.meter.TimeSignature 4/4>
+        >>> len(sc[stream.Measure])
+        10
         '''
         # make a copy, as this process will change tuple types
-        # this method is called infrequently, and only for display of a single
-        # note
+        # this method is called infrequently (only displaying a single note)
         nCopy = copy.deepcopy(n)
-
-        # modifies in place
-        stream.makeNotation.makeTupletBrackets([nCopy.duration], inPlace=True)
-        out = stream.Measure(number=1)
-        out.append(nCopy)
-
-        # call the musicxml property on Stream
-        return self.fromMeasure(out)
+        new_part = stream.Part(nCopy)
+        if 0 < n.quarterLength <= 6.0:
+            new_part.insert(0, meter.bestTimeSignature(new_part))
+        stream.makeNotation.makeMeasures(
+            new_part, inPlace=True, refStreamOrTimeRange=[0, nCopy.quarterLength])
+        stream.makeNotation.makeTupletBrackets(new_part, inPlace=True)
+        return self.fromPart(new_part)
 
     def fromPitch(self, p):
         # noinspection PyShadowingNames
@@ -5003,9 +5017,9 @@ class MeasureExporter(XMLExporterBase):
         mxDirectionType = SubElement(mxDirection, 'direction-type')
         mxDirectionType.append(mxObj)
         if (m21Obj is not None
-                and hasattr(m21Obj, 'positionPlacement')
-                and m21Obj.positionPlacement is not None):
-            mxDirection.set('placement', m21Obj.positionPlacement)
+                and hasattr(m21Obj, 'placement')
+                and m21Obj.placement is not None):
+            mxDirection.set('placement', m21Obj.placement)
 
         return mxDirection
 
@@ -6603,7 +6617,7 @@ class Test(unittest.TestCase):
         c.useSymbol = False
         f = repeat.Fine()
         mm = tempo.MetronomeMark(text='Langsam')
-        mm.positionPlacement = 'above'
+        mm.placement = 'above'
         s.measure(1).storeAtEnd([c, f, mm])
 
         tree = self.getET(s)
diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py
index 1f81743ac..050e8e405 100644
--- a/music21/musicxml/xmlToM21.py
+++ b/music21/musicxml/xmlToM21.py
@@ -1989,8 +1989,8 @@ class PartParser(XMLParserBase):
         Fills an empty measure with a measure of rest (bug in PDFtoMusic and
         other MusicXML writers).
 
-        sets self.lastMeasureWasShort to True or False if it is an incomplete measure
-        that is not a pickup.
+        Sets self.lastMeasureWasShort to True or False if it is an incomplete measure
+        that is not a pickup and sets paddingRight.
 
         >>> m = stream.Measure([meter.TimeSignature('4/4'), harmony.ChordSymbol('C7')])
         >>> m.highestTime
@@ -2002,6 +2002,17 @@ class PartParser(XMLParserBase):
         4.0
         >>> PP.lastMeasureWasShort
         False
+
+        Incomplete final measure:
+
+        >>> m = stream.Measure([meter.TimeSignature('6/8'), note.Note(), note.Note()])
+        >>> m.offset = 24.0
+        >>> PP = musicxml.xmlToM21.PartParser()
+        >>> PP.lastMeasureOffset = 21.0
+        >>> PP.setLastMeasureInfo(m)
+        >>> PP.adjustTimeAttributesFromMeasure(m)
+        >>> m.paddingRight
+        1.0
         '''
         # note: we cannot assume that the time signature properly
         # describes the offsets w/n this bar. need to look at
@@ -2049,6 +2060,9 @@ class PartParser(XMLParserBase):
                         # or something
                         self.lastMeasureWasShort = False
                 else:
+                    # Incomplete measure that is likely NOT an anacrusis, set paddingRight
+                    if m.barDurationProportion() < 1.0:
+                        m.paddingRight = m.barDuration.quarterLength - m.highestTime
                     if mHighestTime < lastTimeSignatureQuarterLength:
                         self.lastMeasureWasShort = True
                     else:
@@ -4777,8 +4791,7 @@ class MeasureParser(XMLParserBase):
                 d = dynamics.Dynamic(m21DynamicText)
 
                 _synchronizeIds(dyn, d)
-                _setAttributeFromAttribute(d, mxDirection,
-                                           'placement', 'positionPlacement')
+                _setAttributeFromAttribute(d, mxDirection, 'placement', 'placement')
 
                 self.insertCoreAndRef(totalOffset, staffKey, d)
                 self.setPosition(mxDir, d)
@@ -4809,8 +4822,7 @@ class MeasureParser(XMLParserBase):
         elif tag == 'metronome':
             mm = self.xmlToTempoIndication(mxDir)
             # SAX was offsetMeasureNote; bug? should be totalOffset???
-            _setAttributeFromAttribute(mm, mxDirection,
-                            'placement', 'positionPlacement')
+            _setAttributeFromAttribute(mm, mxDirection, 'placement', 'placement')
             self.insertCoreAndRef(totalOffset, staffKey, mm)
             self.setEditorial(mxDirection, mm)
 
@@ -4825,8 +4837,7 @@ class MeasureParser(XMLParserBase):
             # environLocal.printDebug(['got TextExpression object', repr(te)])
             # offset here is a combination of the current position
             # (offsetMeasureNote) and and the direction's offset
-            _setAttributeFromAttribute(textExpression, mxDirection,
-                                       'placement', 'positionPlacement')
+            _setAttributeFromAttribute(textExpression, mxDirection, 'placement', 'placement')
 
             repeatExpression = textExpression.getRepeatExpression()
             if repeatExpression is not None:
@@ -7012,7 +7023,7 @@ class Test(unittest.TestCase):
         s = converter.parse(testFiles.tabTest)
         metro = s.recurse().getElementsByClass('MetronomeMark').first()
         self.assertEqual(metro.style.absoluteY, 40)
-        self.assertEqual(metro.positionPlacement, 'above')
+        self.assertEqual(metro.placement, 'above')
 
 
 if __name__ == '__main__':
diff --git a/music21/stream/makeNotation.py b/music21/stream/makeNotation.py
index f1a71b5e2..ab47aeca8 100644
--- a/music21/stream/makeNotation.py
+++ b/music21/stream/makeNotation.py
@@ -188,6 +188,9 @@ def makeBeams(
             offset = 0.0
             if m.paddingLeft != 0.0:
                 offset = opFrac(m.paddingLeft)
+            elif m.paddingRight != 0.0:
+                pass
+            # Incomplete measure without any padding set: assume paddingLeft
             elif noteStream.highestTime < barQL:
                 offset = barQL - noteStream.highestTime
 
@@ -1727,9 +1730,11 @@ def setStemDirectionOneGroup(
     if not group:  # pragma: no cover
         return  # should not happen
 
-    up_down_stem_directions = set(n.stemDirection for n in group
-                                  if n.stemDirection in ('up', 'down'))
-    if len(up_down_stem_directions) < 2:
+    stem_directions = {n.stemDirection for n in group
+                       if n.stemDirection in ('up', 'down', 'unspecified')}
+    if 'unspecified' in stem_directions:
+        has_consistent_stem_directions = False
+    elif len(stem_directions) < 2:
         has_consistent_stem_directions = True
     else:
         has_consistent_stem_directions = False
@@ -1760,8 +1765,6 @@ def setStemDirectionOneGroup(
 
 
 
-
-
 # -----------------------------------------------------------------------------
 
 class Test(unittest.TestCase):
@@ -1837,6 +1840,31 @@ class Test(unittest.TestCase):
                          + ['down', 'noStem', 'double', 'down']
                          )
 
+    def testSetStemDirectionConsistency(self):
+        """
+        Stems that would all be up starting from scratch,
+        but because of overrideConsistentStemDirections=False,
+        we only change the first group with an "unspecified" direction
+        """
+        from music21 import converter
+        p = converter.parse('tinyNotation: 2/4 b8 f8 a8 b8')
+        p.makeBeams(inPlace=True)
+        self.assertEqual(
+            [n.stemDirection for n in p.flat.notes],
+            ['up', 'up', 'up', 'up']
+        )
+
+        # make manual changes
+        dStems = ['down', 'unspecified', 'down', 'down']
+        for n, stemDir in zip(p.flat.notes, dStems):
+            n.stemDirection = stemDir
+
+        setStemDirectionForBeamGroups(p, setNewStems=True, overrideConsistentStemDirections=False)
+        self.assertEqual(
+            [n.stemDirection for n in p.flat.notes],
+            ['up', 'up', 'down', 'down']
+        )
+
     def testMakeBeamsWithStemDirection(self):
         from music21 import converter
         p = converter.parse(self.allaBreveBeamTest)
diff --git a/music21/tempo.py b/music21/tempo.py
index d71f945f4..78444a8bb 100644
--- a/music21/tempo.py
+++ b/music21/tempo.py
@@ -377,6 +377,9 @@ class MetronomeMark(TempoIndication):
     >>> tm2.number
     144
     '''
+    _DOC_ATTR = {
+        'placement': "Staff placement: 'above', 'below', or None.",
+    }
 
     def __init__(self, text=None, number=None, referent=None, parentheses=False):
         super().__init__()
@@ -398,7 +401,7 @@ class MetronomeMark(TempoIndication):
         # TODO: style??
         self.parentheses = parentheses
 
-        self.positionPlacement = None
+        self.placement = None
 
         self._referent = None  # set with property
         if referent is None:
diff --git a/setup.py b/setup.py
index 3c92cb1dc..4f4b33ca8 100644
--- a/setup.py
+++ b/setup.py
@@ -31,13 +31,11 @@ DESCRIPTION_LONG = """A Toolkit for Computer-Aided Musical Analysis.
                         The development of music21 is supported by the
                         generosity of the Seaver Institute and the NEH."""
 
-INSTALL_REQUIRES = [
-    "chardet",
-    "joblib",
-    "more-itertools",
-    "webcolors",
-]
-
+requirements_path = os.path.join(
+    os.path.dirname(__file__), "requirements.txt"
+)
+with open(requirements_path, 'r') as f:
+    INSTALL_REQUIRES = f.read().splitlines()
 
 classifiers = [
     'Development Status :: 5 - Production/Stable',


Test patch:
diff --git a/music21/stream/tests.py b/music21/stream/tests.py
index 58eb22af6..5b763f437 100644
--- a/music21/stream/tests.py
+++ b/music21/stream/tests.py
@@ -8303,6 +8303,33 @@ class Test(unittest.TestCase):
         self.assertEqual(beams[3], continue_beam)  # fourth should be continue
         self.assertEqual(beams[4], stop_beam)  # last should be stop
 
+    def test_makeBeams__paddingRight(self):
+        m = Measure()
+        m.timeSignature = meter.TimeSignature('6/8')
+        m.paddingRight = 1.0
+        m.append(note.Note(type='quarter'))
+        m.append(note.Note(type='eighth'))
+        m.append(note.Note(type='eighth'))
+
+        m.makeBeams(inPlace=True)
+        beams = self.get_beams_from_stream(m)
+
+        no_beam = beam.Beams()
+        start_beam = beam.Beams()
+        start_beam.append('start')
+        stop_beam = beam.Beams()
+        stop_beam.append('stop')
+
+        self.assertEqual(beams, [no_beam, no_beam, no_beam])
+
+        m.paddingRight = 0.5
+        m.append(note.Note(type='eighth'))
+
+        m.makeBeams(inPlace=True)
+        beams = self.get_beams_from_stream(m)
+
+        self.assertEqual(beams, [no_beam, no_beam, start_beam, stop_beam])
+
     def testWrite(self):
         import os