Repository: cuthbertLab/music21 (python)

Issue:
Stem styles aren't exported unless stemDirection is explicit
**music21 version**
dev

**Problem summary**
Stem styles (e.g. colors) aren't exported if the stem direction is unspecified.

**Steps to reproduce**

```py
s = converter.parse('tinyNotation: 2/4 c2')
s.recurse().notes.first().style.stemStyle = style.Style()
s.recurse().notes.first().style.stemStyle.color = 'red'
s.write()
```

**Expected vs. actual behavior**
.write() doesn't produce a `<stem>` element. Expected:

```xml
<stem color="#FF0000">up</stem>
```

Pull request:
Export stem styles on notes with implicit stem directions
**Before**
Stem styles were only exported if a stemDirection was explicit.

**Now**
If a stem has style information, the stem direction is calculated and the stem element is written with style info.

Fixes #1724

Patch:
diff --git a/music21/base.py b/music21/base.py
index b85f2a9ac..a474d78f4 100644
--- a/music21/base.py
+++ b/music21/base.py
@@ -800,7 +800,7 @@ class Music21Object(prebase.ProtoM21Object):
         return self._style
 
     @style.setter
-    def style(self, newStyle: Style):
+    def style(self, newStyle: Style|None):
         self._style = newStyle
 
     # convenience.
diff --git a/music21/braille/basic.py b/music21/braille/basic.py
index fbc8e377b..5904c04e4 100644
--- a/music21/braille/basic.py
+++ b/music21/braille/basic.py
@@ -189,7 +189,7 @@ def chordToBraille(music21Chord, descending=True, showOctave=True):
         music21Chord.editorial.brailleEnglish.append(f'{music21Chord} None')
         return symbols['basic_exception']
     chordTrans.append(brailleNote)
-    englishJoined = '\n'.join(initNote.editorial.get('brailleEnglish', []))
+    englishJoined = '\n'.join(initNote.editorial.brailleEnglish)
     music21Chord.editorial.brailleEnglish.append(
         f'{direction} Chord:\n{englishJoined}'
     )
@@ -531,7 +531,7 @@ def metronomeMarkToBraille(music21MetronomeMark):
         metroNote = note.Note('C4', quarterLength=music21MetronomeMark.referent.quarterLength)
         brailleNote = noteToBraille(metroNote, showOctave=False)
         metroTrans.append(brailleNote)
-        englishJoined = ' '.join(metroNote.editorial.get('brailleEnglish', []))
+        englishJoined = ' '.join(metroNote.editorial.brailleEnglish)
         music21MetronomeMark.editorial.brailleEnglish.append(
             f'Metronome Note {englishJoined}'
         )
diff --git a/music21/chord/__init__.py b/music21/chord/__init__.py
index 37f7ad410..6a9145f50 100644
--- a/music21/chord/__init__.py
+++ b/music21/chord/__init__.py
@@ -42,7 +42,7 @@ from music21.chord import tools
 
 if t.TYPE_CHECKING:
     from music21 import stream
-    from music21.style import Style
+
 
 environLocal = environment.Environment('chord')
 
@@ -3684,7 +3684,7 @@ class Chord(ChordBase):
         note name in two different octaves is retained.
 
         If `inPlace` is True, a copy is not made and a list of deleted pitches is returned;
-        otherwise make and return a copy.
+        otherwise a copy is made and that copy is returned.
 
         >>> c1 = chord.Chord(['c2', 'e3', 'g4', 'e3'])
         >>> c1
@@ -4194,10 +4194,10 @@ class Chord(ChordBase):
         '''
         # assign to base
         if pitchTarget is None and self._notes:
-            # Pylint going crazy here
-            self.style.color = value  # pylint: disable=attribute-defined-outside-init
+            self.style.color = value
             for n in self._notes:
-                n.style.color = value  # pylint: disable=attribute-defined-outside-init
+                n.style.color = value
+
             return
         elif isinstance(pitchTarget, str):
             pitchTarget = pitch.Pitch(pitchTarget)
@@ -4347,7 +4347,7 @@ class Chord(ChordBase):
         None
         False
 
-        By default, assigns to first pitch:
+        By default assigns to first pitch:
 
         >>> c3 = chord.Chord('C3 F4')
         >>> c3.setNoteheadFill(False, None)
diff --git a/music21/derivation.py b/music21/derivation.py
index 0ca9ba4d3..c9a4aa7b4 100644
--- a/music21/derivation.py
+++ b/music21/derivation.py
@@ -240,10 +240,10 @@ class Derivation(SlottedObjectMixin):
         >>> list(s3.derivation.chain()) == [s2, s1]
         True
         '''
-        orig: base.Music21Object | None = self.origin
-        while orig is not None:
-            yield orig
-            orig = orig.derivation.origin  # pylint: disable=no-member
+        origin = self.origin
+        while origin is not None:
+            yield origin
+            origin = origin.derivation.origin
 
     @property
     def method(self) -> str|None:
diff --git a/music21/meter/core.py b/music21/meter/core.py
index 5f62d534f..1b665dd58 100644
--- a/music21/meter/core.py
+++ b/music21/meter/core.py
@@ -1840,3 +1840,4 @@ class MeterSequence(MeterTerminal):
 if __name__ == '__main__':
     import music21
     music21.mainTest()
+
diff --git a/music21/musicxml/m21ToXml.py b/music21/musicxml/m21ToXml.py
index aad81005e..ea2c7dc04 100644
--- a/music21/musicxml/m21ToXml.py
+++ b/music21/musicxml/m21ToXml.py
@@ -4103,17 +4103,27 @@ class MeasureExporter(XMLExporterBase):
             n = t.cast(note.NotRest, n)
             stemDirection = n.stemDirection
 
-        if stemDirection is not None:
+        stem_style = None
+        if (chordOrN.hasStyleInformation
+                and isinstance(chordOrN.style, style.NoteStyle)
+                and chordOrN.style.stemStyle is not None):
+            stem_style = chordOrN.style.stemStyle
+
+        if stemDirection is not None or stem_style:
             mxStem = SubElement(mxNote, 'stem')
-            sdText = stemDirection
+            if stemDirection is None:
+                if closest_clef := chordOrN.getContextByClass(clef.Clef):
+                    sdText = closest_clef.getStemDirectionForPitches(chordOrN.pitches)
+                else:
+                    sdText = 'up'
+            else:
+                sdText = stemDirection
             if sdText == 'noStem':
                 sdText = 'none'
             mxStem.text = sdText
-            if (chordOrN.hasStyleInformation
-                    and isinstance(chordOrN.style, style.NoteStyle)
-                    and chordOrN.style.stemStyle is not None):
-                self.setColor(mxStem, chordOrN.style.stemStyle)
-                self.setPosition(mxStem, chordOrN.style.stemStyle)
+            if stem_style:
+                self.setColor(mxStem, stem_style)
+                self.setPosition(mxStem, stem_style)
 
         # end Stem
 
diff --git a/music21/note.py b/music21/note.py
index 52b2c22b9..37a2e9e55 100644
--- a/music21/note.py
+++ b/music21/note.py
@@ -43,8 +43,6 @@ if t.TYPE_CHECKING:
     from music21 import chord
     from music21 import instrument
     from music21 import percussion
-    from music21.style import Style
-
     _NotRestType = t.TypeVar('_NotRestType', bound='NotRest')
 
 environLocal = environment.Environment('note')
@@ -2097,8 +2095,7 @@ class TestExternal(unittest.TestCase):
             b = note.Note()
             b.quarterLength = qLen
             b.name = pitchName
-            # Pylint going crazy here
-            b.style.color = '#FF00FF'  # pylint: disable=attribute-defined-outside-init
+            b.style.color = '#FF00FF'
             a.append(b)
 
         if self.show:
diff --git a/music21/stream/base.py b/music21/stream/base.py
index 09808e02e..0d0db5fc9 100644
--- a/music21/stream/base.py
+++ b/music21/stream/base.py
@@ -7982,12 +7982,9 @@ class Stream(core.StreamCore, t.Generic[M21ObjType]):
             newId = str(sOldId) + '_' + method
             sNew.id = newId
 
-        sNew_derivation = derivation.Derivation(sNew)
-        sNew_derivation.origin = self
-        sNew_derivation.method = method
-
-        sNew.derivation = sNew_derivation
-
+        sNew._derivation = derivation.Derivation(sNew)
+        sNew._derivation.origin = self
+        sNew.derivation.method = method
         # storing .elements in here necessitates
         # create a new, independent cache instance in the flat representation
         sNew._cache = {}
diff --git a/music21/style.py b/music21/style.py
index c402c7cf2..c241ebc35 100644
--- a/music21/style.py
+++ b/music21/style.py
@@ -102,25 +102,45 @@ class Style(ProtoM21Object):
         self.dashLength: float|int|None = None
         self.spaceLength: float|int|None = None
 
+    def _getEnclosure(self) -> Enclosure|None:
+        return self._enclosure
 
-    @property
-    def enclosure(self) -> Enclosure|None:
-        '''
-        Get or set the enclosure as a style.Enclosure enum or None.
+    def _setEnclosure(self, value: Enclosure|None):
+        if value is None:
+            self._enclosure = value
+        elif value == Enclosure.NONE:
+            self._enclosure = None
+        elif isinstance(value, Enclosure):
+            self._enclosure = value
+        elif isinstance(value, str):
+            try:
+                enc_value = Enclosure(value.lower())
+            except ValueError as ve:
+                raise TextFormatException(f'Not a supported enclosure: {value!r}') from ve
+
+            self._enclosure = enc_value
 
-        Valid names are:
+        else:
+            raise TextFormatException(f'Not a supported enclosure: {value!r}')
+
+    enclosure = property(_getEnclosure,
+                         _setEnclosure,
+                         doc='''
+        Get or set the enclosure as a style.Enclosure enum or None.
 
-        * "rectangle"/style.Enclosure.RECTANGLE,
-        * "square"/style.Enclosure.SQUARE,
-        * "oval"/style.Enclosure.OVAL,
-        * "circle"/style.Enclosure.CIRCLE,
-        * "bracket"/style.Enclosure.BRACKET,
-        * "inverted-bracket"/style.Enclosure.INVERTED_BRACKET (output in musicxml 4 only)
-        * None/"none"/style.Enclosure.NONE (returns Python None object)
+        Valid names are
+        "rectangle"/style.Enclosure.RECTANGLE,
+        "square"/style.Enclosure.SQUARE,
+        "oval"/style.Enclosure.OVAL,
+        "circle"/style.Enclosure.CIRCLE,
+        "bracket"/style.Enclosure.BRACKET,
+        "inverted-bracket"/style.Enclosure.INVERTED_BRACKET (output in musicxml 4 only)
+        None/"none"/style.Enclosure.NONE (returns Python None object)
 
         or the following other shapes with their ALLCAPS Enclosure equivalents:
 
-        triangle, diamond, pentagon, hexagon, heptagon, octagon,
+        triangle, diamond,
+        pentagon, hexagon, heptagon, octagon,
         nonagon, or decagon.
 
         >>> tst = style.TextStyle()
@@ -152,27 +172,7 @@ class Style(ProtoM21Object):
         Traceback (most recent call last):
         music21.style.TextFormatException:
             Not a supported enclosure: 4
-        '''
-        return self._enclosure
-
-    @enclosure.setter
-    def enclosure(self, value: Enclosure|None):
-        if value is None:
-            self._enclosure = value
-        elif value == Enclosure.NONE:
-            self._enclosure = None
-        elif isinstance(value, Enclosure):
-            self._enclosure = value
-        elif isinstance(value, str):
-            try:
-                enc_value = Enclosure(value.lower())
-            except ValueError as ve:
-                raise TextFormatException(f'Not a supported enclosure: {value!r}') from ve
-
-            self._enclosure = enc_value
-
-        else:
-            raise TextFormatException(f'Not a supported enclosure: {value!r}')
+        ''')
 
     def _getAbsoluteY(self):
         return self._absoluteY
diff --git a/requirements.txt b/requirements.txt
index 8ec736d1d..d0e75c050 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,6 +3,6 @@ joblib
 jsonpickle
 matplotlib
 more_itertools
-numpy<2.0.0
+numpy
 webcolors>=1.5
 requests


Test patch:
diff --git a/music21/musicxml/test_m21ToXml.py b/music21/musicxml/test_m21ToXml.py
index 8d368b061..0dabca2d7 100644
--- a/music21/musicxml/test_m21ToXml.py
+++ b/music21/musicxml/test_m21ToXml.py
@@ -27,6 +27,7 @@ from music21 import note
 from music21 import repeat
 from music21 import spanner
 from music21 import stream
+from music21 import style
 from music21 import tempo
 
 from music21.musicxml import helpers
@@ -921,6 +922,13 @@ class Test(unittest.TestCase):
         self.assertIn('<rest', xmlOut)
         self.assertNotIn('<forward>', xmlOut)
 
+    def test_stem_style_without_direction(self):
+        one_note_tune = converter.parse('tinyNotation: 2/4 c2')
+        half_note = one_note_tune.recurse().notes.first()
+        half_note.style.stemStyle = style.Style()
+        half_note.style.stemStyle.color = 'red'
+        xmlOut = GeneralObjectExporter().parse(one_note_tune).decode('utf-8')
+        self.assertIn('<stem color="#FF0000">up</stem>', xmlOut)
 
 
 class TestExternal(unittest.TestCase):