From f69e6a91864ae216449e6495e21522b2d6ba681c Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Mon, 22 Jun 2026 21:51:57 -1000 Subject: [PATCH 01/10] romanText: add type annotations; fix CTSong year keyword bug Type-annotate the romanText package (clercqTemperley, rtObjects, translate, tsvConverter, writeRoman). Also: - Fix a CTSong keyword bug where `year=` was assigning the keyword *name* rather than its value; add a CTSong title/year test. - Annotate features.Feature.__init__ / _getVectors (missed by the small-folders typing pass). - Note in AGENTS.md that PR CI tests the branch merged with the latest master, so merge master before opening/updating a PR. AI-assisted (Claude) --- AGENTS.md | 6 + music21/features/base.py | 4 +- music21/romanText/clercqTemperley.py | 75 ++++--- music21/romanText/rtObjects.py | 279 +++++++++++++++------------ music21/romanText/translate.py | 153 +++++++++------ music21/romanText/tsvConverter.py | 66 ++++--- music21/romanText/writeRoman.py | 29 +-- 7 files changed, 354 insertions(+), 258 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c6f5e6ac21..f53c9de6d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,12 @@ # PRs and Issues +- GitHub runs PR checks against your branch **merged with the latest `master`**, not the + branch alone. So before opening a PR (or pushing updates to one), merge the latest commits + from `master` into the branch — unless told otherwise, or when updating an older version + branch such as `m21_9`. When a CI check fails but passes locally, "is my branch behind + `master`?" should be one of the first things to check: fetch and merge `master`, then the + newer types/code on `master` will reproduce the failure locally. - All PRs and Issues need to be declared AI-assisted. - 10 or more lines of code written by an agent needs to be declared as AI-assisted in the docstring. Humans can remove and should remove this note when they do a review. diff --git a/music21/features/base.py b/music21/features/base.py index 2f96c106eb..a22b81161a 100644 --- a/music21/features/base.py +++ b/music21/features/base.py @@ -85,7 +85,7 @@ class Feature: And that's it! FeatureExtractors are much more interesting. ''' - def __init__(self): + def __init__(self) -> None: # these values will be filled by the extractor self.dimensions: int = 1 # number of dimensions # data storage; possibly use numpy array @@ -97,7 +97,7 @@ def __init__(self): self.isSequential = None # True or False self.discrete = None # is discrete or continuous - def _getVectors(self): + def _getVectors(self) -> list[int|float]: ''' Prepare a vector of appropriate size and return ''' diff --git a/music21/romanText/clercqTemperley.py b/music21/romanText/clercqTemperley.py index d03f2bbc04..b4917e1d4f 100644 --- a/music21/romanText/clercqTemperley.py +++ b/music21/romanText/clercqTemperley.py @@ -333,8 +333,8 @@ class CTSong(prebase.ProtoM21Object): ''', } - def __init__(self, textFile: str|pathlib.Path = '', **keywords): - self._title = None + def __init__(self, textFile: str|pathlib.Path = '', **keywords: t.Any) -> None: + self._title: str|None = None self.text = '' self.lines: list[str] = [] # Dictionary of all component rules of the type CTRule @@ -345,27 +345,27 @@ def __init__(self, textFile: str|pathlib.Path = '', **keywords): self.tsList: list[meter.TimeSignature] = [] self._partObj = stream.Part() - self.year = None + self.year: int|None = None - self._homeTimeSig = None - self._homeKey = None + self._homeTimeSig: meter.TimeSignature|None = None + self._homeKey: key.Key|None = None self.labelRomanNumerals = True self.labelSubsectionsOnScore = True - for kw in keywords: + for kw, value in keywords.items(): if kw == 'title': - self._title = kw - if kw == 'year': - self.year = kw + self._title = value + elif kw == 'year': + self.year = value self.parse(textFile) - def _reprInternal(self): + def _reprInternal(self) -> str: return f'title={self.title!r} year={self.year}' # -------------------------------------------------------------------------- - def parse(self, textFile: str|pathlib.Path): + def parse(self, textFile: str|pathlib.Path) -> None: ''' Called when a CTSong is created by passing a string or filename; in the second case, it opens the file @@ -412,7 +412,7 @@ def parse(self, textFile: str|pathlib.Path): self.text = pieceString @property - def title(self): + def title(self) -> str|None: ''' Get or set the title of the CTSong. If not specified explicitly but the clercq-Temperley text exists, @@ -432,7 +432,7 @@ def title(self): return title @property - def comments(self): + def comments(self) -> list[list[str]]: r""" Get the comments list of all CTRule objects. @@ -459,7 +459,7 @@ def comments(self): >>> s.comments [['A wonderful shaker melody'], ['Vr:', 'incomplete verse'], ['S:', 'Not quite finished!']] """ - comments = [] + comments: list[list[str]] = [] for line in self.lines[1:]: if '%' in line: if line.split()[0].endswith(':'): @@ -470,7 +470,7 @@ def comments(self): return comments @property - def rules(self): + def rules(self) -> dict[str, CTRule]: # noinspection PyShadowingNames ''' Get the rules of a CTSong. the Rules is an OrderedDict of @@ -515,7 +515,7 @@ def rules(self): return self._rules @property - def homeTimeSig(self): + def homeTimeSig(self) -> meter.TimeSignature|None: r''' gets the initial, or 'home', time signature in a song by looking at the 'S' substring and returning the provided time signature. If not present, returns a default music21 @@ -551,7 +551,7 @@ def homeTimeSig(self): return self._homeTimeSig @property - def homeKey(self): + def homeKey(self) -> key.Key|None: ''' gets the initial, or 'home', Key by looking at the music text and locating the key signature at the start of the S: rule. @@ -580,7 +580,11 @@ def homeKey(self): pass return self._homeKey - def toPart(self, labelRomanNumerals=True, labelSubsectionsOnScore=True) -> stream.Part: + def toPart( + self, + labelRomanNumerals: bool = True, + labelSubsectionsOnScore: bool = True, + ) -> stream.Part: # noinspection PyShadowingNames ''' creates a Part object out of a from CTSong and also creates CTRule objects in the process, @@ -611,7 +615,11 @@ def toPart(self, labelRomanNumerals=True, labelSubsectionsOnScore=True) -> strea self._partObj = partObj return partObj - def toScore(self, labelRomanNumerals=True, labelSubsectionsOnScore=True) -> stream.Part: + def toScore( + self, + labelRomanNumerals: bool = True, + labelSubsectionsOnScore: bool = True, + ) -> stream.Part: ''' DEPRECATED: use .toPart() instead. This method will be removed in v.10 ''' @@ -642,8 +650,8 @@ class CTRule(prebase.ProtoM21Object): SPLITMEASURES = re.compile(r'(\|\*?\d*)') REPETITION = re.compile(r'\*(\d+)') - def __init__(self, text='', parent: CTSong|None = None): - self._parent = None + def __init__(self, text: str = '', parent: CTSong|None = None) -> None: + self._parent: t.Any = None if parent is not None: self.parent = parent @@ -660,14 +668,14 @@ def __init__(self, text='', parent: CTSong|None = None): self._lastChordIsInSameMeasure: bool = False - def _reprInternal(self): + def _reprInternal(self) -> str: return f'text={self.text!r}' # -------------------------------------------------------------------------- - def _getParent(self): + def _getParent(self) -> CTSong|None: return common.unwrapWeakref(self._parent) - def _setParent(self, parent): + def _setParent(self, parent: CTSong) -> None: self._parent = common.wrapWeakref(parent) parent = property(_getParent, _setParent, doc=r''' @@ -1002,7 +1010,7 @@ def fixupChordAtom(self, atom: str) -> str: def _setMusicText(self, value: str) -> None: self._musicText = str(value) - def _getMusicText(self): + def _getMusicText(self) -> str: if self._musicText: return self._musicText @@ -1073,7 +1081,7 @@ def _setLHS(self, value: str) -> None: ''') @property - def sectionName(self): + def sectionName(self) -> str: ''' Returns the expanded version of the Left-hand side (LHS) such as Introduction, Verse, etc. if @@ -1114,20 +1122,29 @@ def sectionName(self): # ------------------------------------------------------------------------------ class Test(unittest.TestCase): - pass + + def testTitleAndYear(self) -> None: + ''' + The title and year keywords passed to CTSong should be stored on the + object itself (rather than the keyword names being stored by mistake). + ''' + s = CTSong(BlitzkriegBopCT, title="Someday We'll Be Together", year=1969) + # the title keyword overrides the "% Blitzkrieg Bop" comment in the text + self.assertEqual(s.title, "Someday We'll Be Together") + self.assertEqual(s.year, 1969) class TestExternal(unittest.TestCase): show = True - def testB(self): + def testB(self) -> None: from music21.romanText import clercqTemperley s = clercqTemperley.CTSong(BlitzkriegBopCT) partObj = s.toPart() if self.show: partObj.show() - def x_testA(self): + def x_testA(self) -> None: pass # from music21.romanText import clercqTemperley # diff --git a/music21/romanText/rtObjects.py b/music21/romanText/rtObjects.py index dae59bd679..248812d9c6 100644 --- a/music21/romanText/rtObjects.py +++ b/music21/romanText/rtObjects.py @@ -17,14 +17,21 @@ import fractions import io +import pathlib import re +import typing as t import unittest from music21 import common +from music21.common.types import OffsetQL from music21 import exceptions21 from music21 import environment from music21 import key from music21 import prebase + +if t.TYPE_CHECKING: + from music21 import meter + environLocal = environment.Environment('romanText.rtObjects') # alternate endings might end with a, b, c for @@ -89,59 +96,59 @@ class RTToken(prebase.ProtoM21Object): False ''' - def __init__(self, src=''): - self.src = src # store source character sequence - self.lineNumber = 0 + def __init__(self, src: str = ''): + self.src: str = src # store source character sequence + self.lineNumber: int = 0 - def _reprInternal(self): + def _reprInternal(self) -> str: return repr(self.src) - def isComposer(self): + def isComposer(self) -> bool: return False - def isTitle(self): + def isTitle(self) -> bool: return False - def isPiece(self): + def isPiece(self) -> bool: return False - def isAnalyst(self): + def isAnalyst(self) -> bool: return False - def isProofreader(self): + def isProofreader(self) -> bool: return False - def isTimeSignature(self): + def isTimeSignature(self) -> bool: return False - def isKeySignature(self): + def isKeySignature(self) -> bool: return False - def isNote(self): + def isNote(self) -> bool: return False - def isForm(self): + def isForm(self) -> bool: ''' Occasionally found in header. ''' return False - def isMeasure(self): + def isMeasure(self) -> bool: return False - def isPedal(self): + def isPedal(self) -> bool: return False - def isWork(self): + def isWork(self) -> bool: return False - def isMovement(self): + def isMovement(self) -> bool: return False - def isVersion(self): + def isVersion(self) -> bool: return False - def isAtom(self): + def isAtom(self) -> bool: ''' Atoms are any untagged data; generally only found inside a measure definition. @@ -171,11 +178,11 @@ class RTTagged(RTToken): False ''' - def __init__(self, src=''): + def __init__(self, src: str = ''): super().__init__(src) # try to split off tag from data - self.tag = '' - self.data = '' + self.tag: str = '' + self.data: str = '' if ':' in src: iFirst = src.find(':') # first index found at self.tag = src[:iFirst].strip() @@ -184,7 +191,7 @@ def __init__(self, src=''): else: # we do not have a clear tag; perhaps store all as data self.data = src - def isComposer(self): + def isComposer(self) -> bool: ''' True is the tag represents a composer. @@ -202,7 +209,7 @@ def isComposer(self): return True return False - def isTitle(self): + def isTitle(self) -> bool: ''' True if tag represents a title, otherwise False. @@ -218,7 +225,7 @@ def isTitle(self): return True return False - def isPiece(self): + def isPiece(self) -> bool: ''' True if tag represents a piece, otherwise False. @@ -234,7 +241,7 @@ def isPiece(self): return True return False - def isAnalyst(self): + def isAnalyst(self) -> bool: ''' True if tag represents an analyst, otherwise False. @@ -250,7 +257,7 @@ def isAnalyst(self): return True return False - def isProofreader(self): + def isProofreader(self) -> bool: ''' True if tag represents a proofreader, otherwise False. @@ -266,7 +273,7 @@ def isProofreader(self): return True return False - def isTimeSignature(self): + def isTimeSignature(self) -> bool: ''' True if tag represents a time signature, otherwise False. @@ -284,7 +291,7 @@ def isTimeSignature(self): return True return False - def isKeySignature(self): + def isKeySignature(self) -> bool: ''' True if tag represents a key signature, otherwise False. @@ -327,7 +334,7 @@ def isKeySignature(self): else: return False - def isNote(self): + def isNote(self) -> bool: ''' True if tag represents a note, otherwise False. @@ -343,7 +350,7 @@ def isNote(self): return True return False - def isForm(self): + def isForm(self) -> bool: ''' True if tag represents a form, otherwise False. @@ -359,7 +366,7 @@ def isForm(self): return True return False - def isPedal(self): + def isPedal(self) -> bool: ''' True if tag represents a pedal, otherwise False. @@ -375,7 +382,7 @@ def isPedal(self): return True return False - def isVersion(self): + def isVersion(self) -> bool: ''' True if tag defines the version of RomanText standard used, otherwise False. @@ -398,7 +405,7 @@ def isVersion(self): else: return False - def isWork(self): + def isWork(self) -> bool: ''' True if tag represents a work, otherwise False. @@ -430,7 +437,7 @@ def isWork(self): else: return False - def isMovement(self): + def isMovement(self) -> bool: ''' True if tag represents a movement, otherwise False. @@ -446,7 +453,7 @@ def isMovement(self): return True return False - def isSixthMinor(self): + def isSixthMinor(self) -> bool: ''' True if tag represents a configuration setting for setting vi/vio/VI in minor @@ -458,7 +465,7 @@ def isSixthMinor(self): ''' return self.tag.lower() in ('sixthminor', 'sixth minor') # e.g. 'Sixth Minor: FLAT' - def isSeventhMinor(self): + def isSeventhMinor(self) -> bool: ''' True if tag represents a configuration setting for setting vii/viio/VII in minor @@ -508,26 +515,26 @@ class RTMeasure(RTToken): ''' - def __init__(self, src=''): + def __init__(self, src: str = ''): super().__init__(src) # try to split off tag from data - self.tag = '' # the measure number or range - self.data = '' # only chord, phrase, and similar definitions - self.number = [] # one or more measure numbers - self.repeatLetter = [] # one or more repeat letters - self.variantNumber = None # a one-measure or short variant + self.tag: str = '' # the measure number or range + self.data: str = '' # only chord, phrase, and similar definitions + self.number: list[int] = [] # one or more measure numbers + self.repeatLetter: list[str] = [] # one or more repeat letters + self.variantNumber: int | None = None # a one-measure or short variant # a longer-variant that # defines a different way of reading a large section - self.variantLetter = None + self.variantLetter: str | None = None # store boolean if this measure defines copying another range - self.isCopyDefinition = False + self.isCopyDefinition: bool = False # store processed tokens associated with this measure - self.atoms = [] + self.atoms: list[RTAtom] = [] if src: self._parseAttributes(src) - def _getMeasureNumberData(self, src): + def _getMeasureNumberData(self, src: str) -> tuple[list[int], list[str]]: ''' Return the number of numbers as a list, as well as any repeat indications. @@ -556,7 +563,7 @@ def _getMeasureNumberData(self, src): repeatLetter.append(alphaStr) return number, repeatLetter - def _parseAttributes(self, src): + def _parseAttributes(self, src: str) -> None: # assume that we have already checked that this is a measure g = reMeasureTag.match(src) if g is None: # not measure tag found @@ -586,17 +593,17 @@ def _parseAttributes(self, src): if self.data.startswith('='): self.isCopyDefinition = True - def _reprInternal(self): + def _reprInternal(self) -> str: if len(self.number) == 1: numberStr = str(self.number[0]) else: numberStr = f'{self.number[0]}-{self.number[1]}' return numberStr - def isMeasure(self): + def isMeasure(self) -> bool: return True - def getCopyTarget(self): + def getCopyTarget(self) -> tuple[list[int], list[str]]: ''' If this measure defines a copy operation, return two lists defining the measures to copy; the second list has the repeat data. @@ -638,12 +645,12 @@ class RTAtom(RTToken): specifically for storing chords, beats, etc. ''' - def __init__(self, src='', container=None): + def __init__(self, src: str = '', container: RTMeasure | None = None): # this stores the source super().__init__(src) - self.container = container + self.container: RTMeasure | None = container - def isAtom(self): + def isAtom(self) -> bool: return True # for lower level distinctions, use isinstance(), as each type has its own subclass. @@ -659,13 +666,13 @@ class RTChord(RTAtom): ''' - def __init__(self, src='', container=None): + def __init__(self, src: str = '', container: RTMeasure | None = None): super().__init__(src, container) # store offset within measure - self.offset = None + self.offset: OffsetQL | None = None # store a quarterLength duration - self.quarterLength = None + self.quarterLength: OffsetQL | None = None class RTNoChord(RTAtom): @@ -684,13 +691,13 @@ class RTNoChord(RTAtom): ] ''' - def __init__(self, src='', container=None): + def __init__(self, src: str = '', container: RTMeasure | None = None): super().__init__(src, container) # store offset within measure - self.offset = None + self.offset: OffsetQL | None = None # store a quarterLength duration - self.quarterLength = None + self.quarterLength: OffsetQL | None = None class RTBeat(RTAtom): @@ -703,7 +710,7 @@ class RTBeat(RTAtom): ''' - def getBeatFloatOrFrac(self): + def getBeatFloatOrFrac(self) -> OffsetQL: ''' Gets the beat number as a float or fraction. Time signature independent @@ -786,7 +793,7 @@ def getBeatFloatOrFrac(self): beat = common.opFrac(mainBeat + fracPart + fracBeatFrac) return beat - def getOffset(self, timeSignature): + def getOffset(self, timeSignature: meter.TimeSignature) -> OffsetQL: ''' Given a time signature, return the offset position specified by this beat. @@ -839,9 +846,9 @@ class RTKeyTypeAtom(RTAtom): >>> gMinor.getKey() ''' - footerStrip = ';:' + footerStrip: str = ';:' - def getKey(self): + def getKey(self) -> key.Key: ''' This returns a Key, not a KeySignature object ''' @@ -849,7 +856,7 @@ def getKey(self): myKey = key.convertKeyStringToMusic21KeyString(myKey) return key.Key(myKey) - def getKeySignature(self): + def getKeySignature(self) -> key.KeySignature: ''' Get a KeySignature object. ''' @@ -928,7 +935,7 @@ class RTKeySignature(RTAtom): ''' - def getKeySignature(self): + def getKeySignature(self) -> key.KeySignature: numSharps = int(self.src[2:]) return key.KeySignature(numSharps) @@ -941,7 +948,7 @@ class RTOpenParens(RTAtom): ''' - def __init__(self, src='(', container=None): + def __init__(self, src: str = '(', container: RTMeasure | None = None): super().__init__(src, container) @@ -953,7 +960,7 @@ class RTCloseParens(RTAtom): ''' - def __init__(self, src=')', container=None): + def __init__(self, src: str = ')', container: RTMeasure | None = None): super().__init__(src, container) @@ -970,7 +977,7 @@ class RTOptionalKeyOpen(RTAtom): ''' - def getKey(self): + def getKey(self) -> key.Key: # alter flat symbol if self.src == '?(b:': return key.Key('b') @@ -998,7 +1005,7 @@ class RTOptionalKeyClose(RTAtom): ''' - def getKey(self): + def getKey(self) -> key.Key: # alter flat symbol if self.src in ('?)b:', '?)b'): return key.Key('b') @@ -1028,7 +1035,7 @@ class RTPhraseBoundary(RTPhraseMarker): ''' - def __init__(self, src='||', container=None): + def __init__(self, src: str = '||', container: RTMeasure | None = None): super().__init__(src, container) @@ -1039,7 +1046,7 @@ class RTEllisonStart(RTPhraseMarker): ''' - def __init__(self, src='|*', container=None): + def __init__(self, src: str = '|*', container: RTMeasure | None = None): super().__init__(src, container) @@ -1050,7 +1057,7 @@ class RTEllisonStop(RTPhraseMarker): ''' - def __init__(self, src='*|', container=None): + def __init__(self, src: str = '*|', container: RTMeasure | None = None): super().__init__(src, container) @@ -1069,7 +1076,7 @@ class RTRepeatStart(RTRepeat): ''' - def __init__(self, src='||:', container=None): + def __init__(self, src: str = '||:', container: RTMeasure | None = None): super().__init__(src, container) @@ -1080,7 +1087,7 @@ class RTRepeatStop(RTRepeat): ''' - def __init__(self, src=':||', container=None): + def __init__(self, src: str = ':||', container: RTMeasure | None = None): super().__init__(src, container) @@ -1089,14 +1096,14 @@ def __init__(self, src=':||', container=None): class RTHandler: # divide elements of a character stream into rtObjects and handle # store in a list, and pass global information to components - def __init__(self): + def __init__(self) -> None: # tokens are ABC rtObjects in a linear stream # tokens are strongly divided between header and body, so can # divide here - self._tokens = [] - self.currentLineNumber = 0 + self._tokens: list[RTToken] = [] + self.currentLineNumber: int = 0 - def splitAtHeader(self, lines): + def splitAtHeader(self, lines: list[str]) -> tuple[list[str], list[str]]: ''' Divide string into header and non-header; this is done before tokenization. @@ -1117,12 +1124,12 @@ def splitAtHeader(self, lines): + 'Dumping contexts: %s', lines) return lines[:iStartBody], lines[iStartBody:] - def tokenizeHeader(self, lines): + def tokenizeHeader(self, lines: list[str]) -> list[RTTagged]: ''' In the header, we only have :class:`~music21.romanText.base.RTTagged` tokens. We can this process these all as the same class. ''' - post = [] + post: list[RTTagged] = [] for i, line in enumerate(lines): line = line.strip() if line == '': @@ -1134,12 +1141,12 @@ def tokenizeHeader(self, lines): self.currentLineNumber = len(lines) + 1 return post - def tokenizeBody(self, lines): + def tokenizeBody(self, lines: list[str]) -> list[RTToken]: ''' In the body, we may have measure, time signature, or note declarations, as well as possible other tagged definitions. ''' - post = [] + post: list[RTToken] = [] startLineNumber = self.currentLineNumber for i, line in enumerate(lines): currentLineNumber = startLineNumber + i @@ -1170,7 +1177,11 @@ def tokenizeBody(self, lines): ) from exc return post - def tokenizeAtoms(self, line, container=None): + def tokenizeAtoms( + self, + line: str, + container: RTMeasure | None = None + ) -> list[RTAtom]: ''' Given a line of data stored in measure consisting only of Atoms, tokenize and return a list. @@ -1226,7 +1237,7 @@ def tokenizeAtoms(self, line, container=None): , ] ''' - post = [] + post: list[RTAtom] = [] # break by spaces for word in line.split(' '): word = word.strip() @@ -1264,7 +1275,7 @@ def tokenizeAtoms(self, line, container=None): post.append(RTChord(word, container)) return post - def tokenize(self, src): + def tokenize(self, src: str) -> None: ''' Walk the RT string, creating RT rtObjects along the way. ''' @@ -1275,7 +1286,7 @@ def tokenize(self, src): self._tokens += self.tokenizeHeader(linesHeader) self._tokens += self.tokenizeBody(linesBody) - def process(self, src): + def process(self, src: str) -> None: ''' Given an entire specification as a single source string, strSrc, tokenize it. This is usually provided in a file. @@ -1283,7 +1294,7 @@ def process(self, src): self._tokens = [] self.tokenize(src) - def definesMovements(self, countRequired=2): + def definesMovements(self, countRequired: int = 2) -> bool: ''' Return True if more than one movement is defined in a RT file. @@ -1298,14 +1309,14 @@ def definesMovements(self, countRequired=2): if not self._tokens: raise RTHandlerException('must create tokens first') count = 0 - for t in self._tokens: - if t.isMovement(): + for token in self._tokens: + if token.isMovement(): count += 1 if count >= countRequired: return True return False - def definesMovement(self): + def definesMovement(self) -> bool: ''' Return True if this handler has 1 or more movement. @@ -1318,7 +1329,7 @@ def definesMovement(self): ''' return self.definesMovements(countRequired=1) - def splitByMovement(self, duplicateHeader=True): + def splitByMovement(self, duplicateHeader: bool = True) -> list[RTHandler]: # noinspection PyShadowingNames ''' If we have movements defined, return a list of RTHandler rtObjects, @@ -1345,17 +1356,17 @@ def splitByMovement(self, duplicateHeader=True): >>> len(post[0]), len(post[1]) (3, 3) ''' - post = [] - sub = [] - for t in self._tokens: - if t.isMovement(): + post: list[RTHandler] = [] + sub: list[RTToken] = [] + for token in self._tokens: + if token.isMovement(): # when finding a movement, we are ending a previous # and starting a new; this may just have metadata rth = RTHandler() rth.tokens = sub post.append(rth) sub = [] - sub.append(t) + sub.append(token) if sub: rth = RTHandler() @@ -1363,7 +1374,7 @@ def splitByMovement(self, duplicateHeader=True): post.append(rth) if duplicateHeader: - alt = [] + alt: list[RTHandler] = [] # if no movement in this first handler, assume it is header info if not post[0].definesMovement(): handlerHead = post[0] @@ -1384,7 +1395,7 @@ def splitByMovement(self, duplicateHeader=True): # access tokens @property - def tokens(self): + def tokens(self) -> list[RTToken]: ''' Get or set tokens for this Handler. ''' @@ -1393,16 +1404,16 @@ def tokens(self): return self._tokens @tokens.setter - def tokens(self, tokens): + def tokens(self, tokens: list[RTToken]) -> None: ''' Assign tokens to this Handler. ''' self._tokens = tokens - def __len__(self): + def __len__(self) -> int: return len(self._tokens) - def __add__(self, other): + def __add__(self, other: RTHandler) -> RTHandler: ''' Return a new handler adding the tokens in both ''' @@ -1418,15 +1429,16 @@ class RTFile(prebase.ProtoM21Object): Roman Text File access. ''' - def __init__(self): - self.file = None - self.filename = None + def __init__(self) -> None: + self.file: t.IO[str] | None = None + self.filename: str | pathlib.Path | None = None - def open(self, filename): + def open(self, filename: str | pathlib.Path) -> None: ''' Open a file for reading, trying a variety of encodings and then trying them again with an "ignore" flag if it is not possible. ''' + encoding: str | None for encoding in ('utf-8', 'macintosh', 'latin-1', 'utf-16'): try: # pylint: disable=consider-using-with @@ -1450,28 +1462,30 @@ def open(self, filename): self.filename = filename - def openFileLike(self, fileLike): + def openFileLike(self, fileLike: t.IO[str]) -> None: ''' Assign a file-like object, such as those provided by StringIO, as an open file object. ''' self.file = fileLike # already 'open' - def _reprInternal(self): + def _reprInternal(self) -> str: return '' - def close(self): - self.file.close() + def close(self) -> None: + openFile = t.cast('t.IO[str]', self.file) + openFile.close() - def read(self): + def read(self) -> RTHandler: ''' Read a file. Note that this calls readstr, which processes all tokens. If `number` is given, a work number will be extracted if possible. ''' - return self.readstr(self.file.read()) + openFile = t.cast('t.IO[str]', self.file) + return self.readstr(openFile.read()) - def readstr(self, strSrc): + def readstr(self, strSrc: str) -> RTHandler: ''' Read a string and process all Tokens. Returns a ABCHandler instance. ''' @@ -1485,52 +1499,65 @@ def readstr(self, strSrc): class Test(unittest.TestCase): - def testBasicA(self): + def testBasicA(self) -> None: from music21.romanText import testFiles for fileStr in testFiles.ALL: f = RTFile() unused_rth = f.readstr(fileStr) # get a handler from a string - def testReA(self): + def testReA(self) -> None: # gets the index of the end of the measure indication g = reMeasureTag.match('m1 g: V b2 i') + assert g is not None self.assertEqual(g.end(), 2) self.assertEqual(g.group(0), 'm1') self.assertEqual(reMeasureTag.match('Time Signature: 2/2'), None) g = reMeasureTag.match('m3-4=m1-2') + assert g is not None self.assertEqual(g.end(), 4) self.assertEqual(g.start(), 0) self.assertEqual(g.group(0), 'm3-4') g = reMeasureTag.match('m123-432=m1120-24234') + assert g is not None self.assertEqual(g.group(0), 'm123-432') g = reMeasureTag.match('m231a IV6 b4 C: V') + assert g is not None self.assertEqual(g.group(0), 'm231a') g = reMeasureTag.match('m123b-432b=m1120a-24234a') + assert g is not None self.assertEqual(g.group(0), 'm123b-432b') g = reMeasureTag.match('m231var1 IV6 b4 C: V') + assert g is not None self.assertEqual(g.group(0), 'm231') # this only works if it starts the string g = reVariant.match('var1 IV6 b4 C: V') + assert g is not None self.assertEqual(g.group(0), 'var1') g = reAnalyticKeyAtom.match('Bb:') + assert g is not None self.assertEqual(g.group(0), 'Bb:') g = reAnalyticKeyAtom.match('F#:') + assert g is not None self.assertEqual(g.group(0), 'F#:') g = reAnalyticKeyAtom.match('f#:') + assert g is not None self.assertEqual(g.group(0), 'f#:') g = reAnalyticKeyAtom.match('b:') + assert g is not None self.assertEqual(g.group(0), 'b:') g = reAnalyticKeyAtom.match('bb:') + assert g is not None self.assertEqual(g.group(0), 'bb:') g = reAnalyticKeyAtom.match('g:') + assert g is not None self.assertEqual(g.group(0), 'g:') # beats do not have a colon @@ -1538,15 +1565,17 @@ def testReA(self): self.assertEqual(reKeyAtom.match('b2.5'), None) g = reBeatAtom.match('b2.5') + assert g is not None self.assertEqual(g.group(0), 'b2.5') g = reBeatAtom.match('bVII') self.assertEqual(g, None) g = reBeatAtom.match('b1.66.5') + assert g is not None self.assertEqual(g.group(0), 'b1.66.5') - def testMeasureAttributeProcessing(self): + def testMeasureAttributeProcessing(self) -> None: rtm = RTMeasure('m17var1 vi b2 IV b2.5 viio6/4 b3.5 I') self.assertEqual(rtm.data, 'vi b2 IV b2.5 viio6/4 b3.5 I') self.assertEqual(rtm.number, [17]) @@ -1585,7 +1614,7 @@ def testMeasureAttributeProcessing(self): self.assertEqual(rtm.variantNumber, None) self.assertTrue(rtm.isCopyDefinition) - def testTokenDefinition(self): + def testTokenDefinition(self) -> None: # test that we are always getting the right number of tokens from music21.romanText import testFiles @@ -1593,24 +1622,24 @@ def testTokenDefinition(self): rth.process(testFiles.mozartK279) count = 0 - for t in rth._tokens: - if t.isMovement(): + for token in rth._tokens: + if token.isMovement(): count += 1 self.assertEqual(count, 3) rth.process(testFiles.riemenschneider001) count = 0 - for t in rth._tokens: - if t.isMeasure(): - # print(t.src) + for token in rth._tokens: + if token.isMeasure(): + # print(token.src) count += 1 # 21, 2 variants, and one pickup self.assertEqual(count, 21 + 2 + 1) count = 0 - for t in rth._tokens: - if t.isMeasure(): - for a in t.atoms: + for token in rth._tokens: + if isinstance(token, RTMeasure): + for a in token.atoms: if isinstance(a, RTAnalyticKey): count += 1 self.assertEqual(count, 1) diff --git a/music21/romanText/translate.py b/music21/romanText/translate.py index 70f8eff322..af3f4951b1 100644 --- a/music21/romanText/translate.py +++ b/music21/romanText/translate.py @@ -164,7 +164,7 @@ class RomanTextUnprocessedToken(base.ElementWrapper): class RomanTextUnprocessedMetadata(base.Music21Object): - def __init__(self, tag='', data='', **keywords): + def __init__(self, tag: str = '', data: str = '', **keywords) -> None: super().__init__(**keywords) self.tag = tag self.data = data @@ -173,7 +173,11 @@ def _reprInternal(self) -> str: return f'{self.tag}: {self.data}' -def _copySingleMeasure(rtTagged, p, kCurrent): +def _copySingleMeasure( + rtTagged: rtObjects.RTMeasure, + p: stream.Part, + kCurrent: key.Key|None, +) -> tuple[stream.Measure|None, key.Key|None]: ''' Given a RomanText token, a Part used as the current container, and the current Key, return a Measure copied from the past of the Part. @@ -181,7 +185,7 @@ def _copySingleMeasure(rtTagged, p, kCurrent): This is used in cases of definitions such as: m23=m21 ''' - m = None + m: stream.Measure|None = None # copy from a past location; need to change key # environLocal.printDebug(['calling _copySingleMeasure()']) targetNumber, unused_targetRepeat = rtTagged.getCopyTarget() @@ -227,9 +231,11 @@ def _copySingleMeasure(rtTagged, p, kCurrent): return m, kCurrent -def _copyMultipleMeasures(rtMeasure: rtObjects.RTMeasure, - p: stream.Part, - kCurrent: key.Key|None): +def _copyMultipleMeasures( + rtMeasure: rtObjects.RTMeasure, + p: stream.Part, + kCurrent: key.Key|None, +) -> tuple[list[stream.Measure], key.Key|None]: ''' Given a RomanText token for a RTMeasure, a Part used as the current container, and the current Key, @@ -256,7 +262,7 @@ def _copyMultipleMeasures(rtMeasure: rtObjects.RTMeasure, raise RomanTextTranslateException( 'the source section cannot overlap with the destination section') - measures = [] + measures: list[stream.Measure] = [] for mPast in p.getElementsByClass(stream.Measure): if mPast.number in range(targetStart, targetEnd + 1): try: @@ -297,7 +303,9 @@ def _copyMultipleMeasures(rtMeasure: rtObjects.RTMeasure, return measures, kCurrent -def _getKeyAndPrefix(rtKeyOrString): +def _getKeyAndPrefix( + rtKeyOrString: str|rtObjects.RTKeyTypeAtom, +) -> tuple[key.Key, str]: ''' Given an RTKey specification, return the Key and a string prefix based on the tonic: @@ -318,8 +326,8 @@ def _getKeyAndPrefix(rtKeyOrString): (, 'B--: ') ''' if isinstance(rtKeyOrString, str): - rtKeyOrString = key.convertKeyStringToMusic21KeyString(rtKeyOrString) - k = key.Key(rtKeyOrString) + keyString = key.convertKeyStringToMusic21KeyString(rtKeyOrString) + k = key.Key(keyString) else: k = rtKeyOrString.getKey() tonicName = k.tonic.name @@ -341,22 +349,22 @@ class PartTranslator: was written under severe time constraints). ''' - def __init__(self, md=None): + def __init__(self, md: metadata.Metadata|None = None) -> None: if md is None: md = metadata.Metadata() self.md = md # global metadata object self.p = stream.Part() - self.romanTextVersion = ROMANTEXT_VERSION + self.romanTextVersion: float = ROMANTEXT_VERSION # ts indication are found in header, and also found elsewhere - self.tsCurrent = meter.TimeSignature('4/4') # create default 4/4 - self.tsAtTimeOfLastChord = self.tsCurrent + self.tsCurrent: meter.TimeSignature = meter.TimeSignature('4/4') # create default 4/4 + self.tsAtTimeOfLastChord: meter.TimeSignature = self.tsCurrent self.tsSet = False # store if set to a measure - self.lastMeasureToken = None + self.lastMeasureToken: rtObjects.RTMeasure|None = None self.lastMeasureNumber = 0 - self.previousRn = None - self.keySigCurrent = None + self.previousRn: note.GeneralNote|None = None + self.keySigCurrent: key.KeySignature|None = None self.setKeySigFromFirstKeyToken = True # set a keySignature self.foundAKeySignatureSoFar = False self.kCurrent, unused_prefixLyric = _getKeyAndPrefix('C') # default if none defined @@ -365,17 +373,17 @@ def __init__(self, md=None): self.sixthMinor = roman.Minor67Default.CAUTIONARY self.seventhMinor = roman.Minor67Default.CAUTIONARY - self.repeatEndings = {} + self.repeatEndings: dict[int, list[tuple[int, stream.Measure]]] = {} # reset for each measure - self.currentMeasureToken = None - self.previousChordInMeasure = None + self.currentMeasureToken: rtObjects.RTMeasure|None = None + self.previousChordInMeasure: note.GeneralNote|None = None self.pivotChordPossible = False self.numberOfAtomsInCurrentMeasure = 0 self.setKeyChangeToken = False - self.currentOffsetInMeasure = 0.0 + self.currentOffsetInMeasure: OffsetQL = 0.0 - def translateTokens(self, tokens): + def translateTokens(self, tokens: list[rtObjects.RTToken]) -> stream.Part: for token in tokens: try: self.translateOneLineToken(token) @@ -393,7 +401,7 @@ def translateTokens(self, tokens): _addRepeatsFromRepeatEndings(p, self.repeatEndings) # 1st and second endings return p - def translateOneLineToken(self, lineToken: rtObjects.RTTagged): + def translateOneLineToken(self, lineToken: rtObjects.RTToken) -> None: # noinspection SpellCheckingInspection ''' Translates one line token and set the current settings. @@ -409,8 +417,11 @@ def translateOneLineToken(self, lineToken: rtObjects.RTTagged): if lineToken.isMeasure(): measureToken = t.cast(rtObjects.RTMeasure, lineToken) self.translateMeasureLineToken(measureToken) + return - elif lineToken.isTitle(): + # everything below is a tagged (non-measure) line token + lineToken = t.cast(rtObjects.RTTagged, lineToken) + if lineToken.isTitle(): md.add('title', lineToken.data) elif lineToken.isWork(): @@ -460,7 +471,7 @@ def translateOneLineToken(self, lineToken: rtObjects.RTTagged): unprocessed = RomanTextUnprocessedToken(lineToken) self.p.append(unprocessed) - def setMinorRootParse(self, rtTagged: rtObjects.RTTagged): + def setMinorRootParse(self, rtTagged: rtObjects.RTTagged) -> None: ''' Set Roman Numeral parsing standards from a token. @@ -523,7 +534,7 @@ def setMinorRootParse(self, rtTagged: rtObjects.RTTagged): else: self.seventhMinor = tEnum - def translateMeasureLineToken(self, measureLineToken: rtObjects.RTMeasure): + def translateMeasureLineToken(self, measureLineToken: rtObjects.RTMeasure) -> None: ''' Translate a measure token consisting of a single line such as:: @@ -559,7 +570,9 @@ def translateMeasureLineToken(self, measureLineToken: rtObjects.RTMeasure): # create a new measure or copy a past measure if isSingleMeasureCopy: # if not a range p.coreElementsChanged() - m, self.kCurrent = _copySingleMeasure(measureLineToken, p, self.kCurrent) + mCopy, kCopied = _copySingleMeasure(measureLineToken, p, self.kCurrent) + m = t.cast(stream.Measure, mCopy) + self.kCurrent = t.cast(key.Key, kCopied) p.coreAppend(m) self.lastMeasureNumber = m.number self.lastMeasureToken = measureLineToken @@ -569,7 +582,8 @@ def translateMeasureLineToken(self, measureLineToken: rtObjects.RTMeasure): elif isMultipleMeasureCopy: p.coreElementsChanged() - measures, self.kCurrent = _copyMultipleMeasures(measureLineToken, p, self.kCurrent) + measures, kCopiedMulti = _copyMultipleMeasures(measureLineToken, p, self.kCurrent) + self.kCurrent = t.cast(key.Key, kCopiedMulti) p.append(measures) # appendCore does not work with list self.lastMeasureNumber = measures[-1].number self.lastMeasureToken = measureLineToken @@ -583,7 +597,7 @@ def translateMeasureLineToken(self, measureLineToken: rtObjects.RTMeasure): self.fillMeasureFromPreviousRn(m) p.coreAppend(m) - def fillToMeasureToken(self, measureToken: rtObjects.RTMeasure): + def fillToMeasureToken(self, measureToken: rtObjects.RTMeasure) -> None: ''' Create a series of measures which extend the previous RN until the measure number implied by `measureToken`. @@ -593,9 +607,10 @@ def fillToMeasureToken(self, measureToken: rtObjects.RTMeasure): mFill = stream.Measure() mFill.number = i self.fillMeasureFromPreviousRn(mFill) - appendMeasureToRepeatEndingsDict(self.lastMeasureToken, - mFill, - self.repeatEndings, i) + appendMeasureToRepeatEndingsDict( + t.cast(rtObjects.RTMeasure, self.lastMeasureToken), + mFill, + self.repeatEndings, i) p.coreAppend(mFill) self.lastMeasureNumber = measureToken.number[0] - 1 self.lastMeasureToken = measureToken @@ -615,7 +630,7 @@ def fillMeasureFromPreviousRn(self, mFill: stream.Measure) -> None: self.previousRn = newRn mFill.append(newRn) - def parseKeySignatureTag(self, rtTagged: rtObjects.RTTagged): + def parseKeySignatureTag(self, rtTagged: rtObjects.RTTagged) -> None: ''' Parse a key signature tag which has already been determined to be a key signature. @@ -663,7 +678,7 @@ def parseKeySignatureTag(self, rtTagged: rtObjects.RTTagged): # environLocal.printDebug(['keySigCurrent:', keySigCurrent]) self.foundAKeySignatureSoFar = True - def translateSingleMeasure(self, measureToken): + def translateSingleMeasure(self, measureToken: rtObjects.RTMeasure) -> stream.Measure: ''' Given a measureToken, return a `stream.Measure` object with the appropriate atoms set. @@ -694,7 +709,8 @@ def translateSingleMeasure(self, measureToken): # may need to adjust duration of last chord added if self.tsCurrent is not None: - self.previousRn.quarterLength = (self.tsCurrent.barDuration.quarterLength + previousRn = t.cast(note.GeneralNote, self.previousRn) + previousRn.quarterLength = (self.tsCurrent.barDuration.quarterLength - self.currentOffsetInMeasure) m.coreElementsChanged() return m @@ -715,6 +731,7 @@ def translateSingleMeasureAtom( Uses coreInsert and coreAppend methods, so must have `m.coreElementsChanged()` called afterward. ''' + currentMeasureToken = t.cast(rtObjects.RTMeasure, self.currentMeasureToken) if (isinstance(a, rtObjects.RTKey) or (self.foundAKeySignatureSoFar is False and isinstance(a, rtObjects.RTAnalyticKey))): @@ -732,7 +749,7 @@ def translateSingleMeasureAtom( thisSig = a.getKeySignature() except (exceptions21.Music21Exception, ValueError) as ve: # pragma: no cover raise RomanTextTranslateException( - f'cannot get key from {a.src} in line {self.currentMeasureToken.src}' + f'cannot get key from {a.src} in line {currentMeasureToken.src}' ) from ve # insert at beginning of measure if at beginning # -- for things like pickups. @@ -753,7 +770,7 @@ def translateSingleMeasureAtom( raise RomanTextTranslateException( f'cannot properly get an offset from beat data {a.src} ' f'under timeSignature {self.tsCurrent} ' - f'in line {self.currentMeasureToken.src}' + f'in line {currentMeasureToken.src}' ) from ve if (self.previousChordInMeasure is None and self.previousRn is not None @@ -789,7 +806,7 @@ def translateSingleMeasureAtom( newQL = self.currentOffsetInMeasure - oPrevious if newQL <= 0: # pragma: no cover raise RomanTextTranslateException( - f'too many notes in this measure: {self.currentMeasureToken.src}') + f'too many notes in this measure: {currentMeasureToken.src}') self.previousChordInMeasure.quarterLength = newQL self.prefixLyric = '' m.coreInsert(self.currentOffsetInMeasure, rn) @@ -842,6 +859,7 @@ def processRTChord( ''' Process a single RTChord atom. ''' + currentMeasureToken = t.cast(rtObjects.RTMeasure, self.currentMeasureToken) # use source to evaluation roman self.tsAtTimeOfLastChord = self.tsCurrent try: @@ -905,7 +923,7 @@ def processRTChord( newQL = currentOffset - oPrevious if newQL <= 0: # pragma: no cover raise RomanTextTranslateException( - f'too many notes in this measure: {self.currentMeasureToken.src}') + f'too many notes in this measure: {currentMeasureToken.src}') self.previousChordInMeasure.quarterLength = newQL rn.addLyric(self.prefixLyric + a.src) @@ -915,27 +933,32 @@ def processRTChord( self.previousRn = rn self.pivotChordPossible = True else: - self.previousChordInMeasure.lyric += '//' + self.prefixLyric + a.src - self.previousChordInMeasure.pivotChord = rn + previousChord = t.cast(roman.RomanNumeral, self.previousChordInMeasure) + previousChord.lyric = (previousChord.lyric or '') + '//' + self.prefixLyric + a.src + previousChord.pivotChord = rn self.prefixLyric = '' self.pivotChordPossible = False - def setAnalyticKey(self, a): + def setAnalyticKey(self, a: rtObjects.RTKeyTypeAtom) -> None: ''' Indicates a change in the analyzed key, not a change in anything else, such as the keySignature. ''' + currentMeasureToken = t.cast(rtObjects.RTMeasure, self.currentMeasureToken) try: # this sets the key and the keysignature self.kCurrent, pl = _getKeyAndPrefix(a) self.prefixLyric += pl except (ValueError, exceptions21.Music21Exception) as ve: # pragma: no cover raise RomanTextTranslateException( - f'cannot get analytic key from {a.src} in line {self.currentMeasureToken.src}' + f'cannot get analytic key from {a.src} in line {currentMeasureToken.src}' ) from ve self.setKeyChangeToken = True -def romanTextToStreamScore(rtHandler, inputM21=None): +def romanTextToStreamScore( + rtHandler: rtObjects.RTHandler|str, + inputM21: stream.Score|None = None, +) -> stream.Score: ''' The main processing module for single-movement RomanText works. @@ -971,8 +994,8 @@ def romanTextToStreamScore(rtHandler, inputM21=None): def appendMeasureToRepeatEndingsDict(rtMeasureObj: rtObjects.RTMeasure, m: stream.Measure, - repeatEndings: dict, - measureNumber=None): + repeatEndings: dict[int, list[tuple[int, stream.Measure]]], + measureNumber: int|None = None) -> None: # noinspection PyShadowingNames ''' Takes an RTMeasure object (which might represent one or more @@ -1024,7 +1047,9 @@ def appendMeasureToRepeatEndingsDict(rtMeasureObj: rtObjects.RTMeasure, repeatEndings[repeatNumber].append(measureTuple) -def _consolidateRepeatEndings(repeatEndings): +def _consolidateRepeatEndings[M]( + repeatEndings: dict[int, list[tuple[int, M]]], +) -> list[tuple[list[M], int]]: # noinspection PyShadowingNames ''' take repeatEndings, which is a dict of integers (repeat ending numbers) each @@ -1045,18 +1070,18 @@ def _consolidateRepeatEndings(repeatEndings): [(['m5a', 'm6a'], 1), (['m17', 'm18', 'm19'], 1), (['m23a'], 1), (['m5b', 'm6b'], 2), (['m20', 'm21'], 2), (['m23b'], 2), (['m23c'], 3)] ''' - returnList = [] + returnList: list[tuple[list[M], int]] = [] for endingNumber in repeatEndings: - startMeasureNumber = None - lastMeasureNumber = None - measureList = [] + startMeasureNumber: int|None = None + lastMeasureNumber: int|None = None + measureList: list[M] = [] for measureNumberUnderEnding, measureObject in repeatEndings[endingNumber]: if startMeasureNumber is None: startMeasureNumber = measureNumberUnderEnding lastMeasureNumber = measureNumberUnderEnding measureList.append(measureObject) - elif measureNumberUnderEnding > lastMeasureNumber + 1: + elif lastMeasureNumber is not None and measureNumberUnderEnding > lastMeasureNumber + 1: myTuple = (measureList, endingNumber) returnList.append(myTuple) startMeasureNumber = measureNumberUnderEnding @@ -1072,7 +1097,10 @@ def _consolidateRepeatEndings(repeatEndings): return returnList -def _addRepeatsFromRepeatEndings(s, repeatEndings): +def _addRepeatsFromRepeatEndings( + s: stream.Part, + repeatEndings: dict[int, list[tuple[int, stream.Measure]]], +) -> None: ''' Given a Stream and the repeatEndings dict, add repeats to the stream. ''' @@ -1090,7 +1118,7 @@ def _addRepeatsFromRepeatEndings(s, repeatEndings): measureList[-1].rightBarline = bar.Repeat(direction='end') -def fixPickupMeasure(partObject): +def fixPickupMeasure(partObject: stream.Part) -> None: # noinspection PyShadowingNames ''' Fix a pickup measure if any. @@ -1157,7 +1185,7 @@ def fixPickupMeasure(partObject): if el.offset > 0: el.offset -= leftPadding mLast = partObject.getElementsByClass(stream.Measure).last() - if mLast is m0: + if mLast is None or mLast is m0: return lastRN = mLast.getElementsByClass([roman.RomanNumeral, harmony.NoChord]).last() @@ -1177,7 +1205,10 @@ def fixPickupMeasure(partObject): i -= 1 -def romanTextToStreamOpus(rtHandler, inputM21=None): +def romanTextToStreamOpus( + rtHandler: rtObjects.RTHandler|str, + inputM21: stream.Score|stream.Opus|None = None, +) -> stream.Score|stream.Opus: ''' The main processing routine for RomanText objects that may or may not be multi movement. @@ -1196,6 +1227,7 @@ def romanTextToStreamOpus(rtHandler, inputM21=None): rtHandler = rtf.readstr(rtHandler) # return handler, processes tokens if rtHandler.definesMovements(): # create an opus + s: stream.Score|stream.Opus if inputM21 is None: s = stream.Opus() else: @@ -1209,7 +1241,8 @@ def romanTextToStreamOpus(rtHandler, inputM21=None): s.append(romanTextToStreamScore(h)) return s # an opus else: # create a Score - return romanTextToStreamScore(rtHandler, inputM21=inputM21) + scoreInput = t.cast('stream.Score|None', inputM21) + return romanTextToStreamScore(rtHandler, inputM21=scoreInput) # ------------------------------------------------------------------------------ @@ -1368,7 +1401,7 @@ def testMinor67set(self): s = romanTextToStreamScore(testFiles.testSetMinorRootParse) chords = list(s[roman.RomanNumeral]) - def pitchEqual(index, pitchStr): + def pitchEqual(index: int, pitchStr: str) -> None: ch = chords[index] chPitches = ch.pitches self.assertEqual(' '.join(p.name for p in chPitches), pitchStr) @@ -1685,7 +1718,7 @@ def testTuplets(self): self.assertEqual(n2.offset, common.opFrac(11 / 6)) self.assertEqual(n2.duration.quarterLength, common.opFrac(13 / 6)) - def testCopyEmptyMeasures(self) -> None: + def testCopyEmptyMeasures(self): from music21 import converter empty_measures_with_copy = textwrap.dedent('''' Time Signature: 2/4 @@ -1697,7 +1730,7 @@ def testCopyEmptyMeasures(self) -> None: s = converter.parse(empty_measures_with_copy, format='romanText') assert s.duration.quarterLength == 10 - def testRepeats(self) -> None: + def testRepeats(self): from music21 import converter def _repeat_tester( diff --git a/music21/romanText/tsvConverter.py b/music21/romanText/tsvConverter.py index 0fc61463d5..6ec96eed58 100644 --- a/music21/romanText/tsvConverter.py +++ b/music21/romanText/tsvConverter.py @@ -35,12 +35,16 @@ environLocal = environment.Environment() +# A coercion callable maps a raw TSV string cell to a typed value (str, int, +# float, or a fraction-aware float via _float_or_frac). +type Coercer = t.Callable[[str], t.Any] + # ------------------------------------------------------------------------------ # V1_HEADERS and V2_HEADERS specify the columns that we process from the DCML # files, together with the type that the columns should be coerced to (usually # str) -V1_HEADERS = types.MappingProxyType({ +V1_HEADERS: types.MappingProxyType[str, Coercer] = types.MappingProxyType({ 'chord': str, 'altchord': str, 'measure': int, @@ -63,17 +67,17 @@ r'(?P\d+(?:\.\d+)?)/(?P\d+(?:\.\d+)?)' ) -def _float_or_frac(value): +def _float_or_frac(value: str) -> float: # mn_onset in V2 is sometimes notated as a fraction like '1/2'; we need # to handle such cases try: return float(value) except ValueError: - m = re.match(MN_ONSET_REGEX, value) + m = t.cast(re.Match, re.match(MN_ONSET_REGEX, value)) return float(m.group('numer')) / float(m.group('denom')) -V2_HEADERS = types.MappingProxyType({ +V2_HEADERS: types.MappingProxyType[str, Coercer] = types.MappingProxyType({ 'chord': str, 'mn': int, 'mn_onset': _float_or_frac, @@ -202,7 +206,7 @@ def combinedChord(self) -> str: return self.chord @combinedChord.setter - def combinedChord(self, value: str): + def combinedChord(self, value: str) -> None: self.chord = value def _changeRepresentation(self) -> None: @@ -376,7 +380,7 @@ def tabToM21(self) -> harmony.Harmony: def populateFromRow( self, row: list[str], - headIndices: dict[str, tuple[int, type]], + headIndices: dict[str, tuple[int, Coercer]], extraIndices: dict[int, str] ) -> None: # To implement without calling setattr we would need to repeat lines @@ -446,7 +450,7 @@ def beat(self) -> float: return self.mn_onset * 4.0 + 1.0 @beat.setter - def beat(self, beat: float): + def beat(self, beat: float) -> None: self.mn_onset = (beat - 1.0) / 4.0 if beat is not None else None @property @@ -458,7 +462,7 @@ def measure(self) -> int: return int(self.mn) @measure.setter - def measure(self, measure: int): + def measure(self, measure: int) -> None: self.mn = int(measure) if measure is not None else None @property @@ -471,7 +475,7 @@ def local_key(self) -> str: return self.localkey @local_key.setter - def local_key(self, k: str): + def local_key(self, k: str) -> None: self.localkey = k @property @@ -484,7 +488,7 @@ def global_key(self) -> str: return self.globalkey @global_key.setter - def global_key(self, k: str): + def global_key(self, k: str) -> None: self.globalkey = k # ------------------------------------------------------------------------------ @@ -520,9 +524,9 @@ class TsvHandler: 'I' ''' - def __init__(self, tsvFile: str|pathlib.Path, dcml_version: int = 1): + def __init__(self, tsvFile: str|pathlib.Path, dcml_version: int = 1) -> None: if dcml_version == 1: - self.heading_names = HEADERS[1] + self.heading_names: t.Mapping[str, Coercer] = HEADERS[1] self._tab_chord_cls: type[TabChordBase] = TabChord elif dcml_version == 2: self.heading_names = HEADERS[2] @@ -532,7 +536,7 @@ def __init__(self, tsvFile: str|pathlib.Path, dcml_version: int = 1): self.tsvFileName = tsvFile self.chordList: list[TabChordBase] = [] self.m21stream: stream.Score|None = None - self._head_indices: dict[str, tuple[int, type|t.Any]] = {} + self._head_indices: dict[str, tuple[int, Coercer]] = {} self._extra_indices: dict[int, str] = {} self.dcml_version = dcml_version self.tsvData = self._importTsv() # converted to private @@ -684,7 +688,7 @@ def prepStream(self) -> stream.Score: previousMeasure: int = self.chordList[0].measure - 1 # Covers pickups previousVolta: str = '' - repeatBracket: t.Optional[spanner.RepeatBracket] = None + repeatBracket: spanner.RepeatBracket | None = None for entry in self.chordList: if isinstance(entry, TabChordV2) and entry.volta != previousVolta: if entry.volta: @@ -762,7 +766,7 @@ class M21toTSV: >>> tsvData[1][DCML_V2_HEADERS.index('chord')] 'I' ''' - def __init__(self, m21Stream: stream.Score, dcml_version: int = 2): + def __init__(self, m21Stream: stream.Score, dcml_version: int = 2) -> None: self.version = dcml_version self.m21Stream = m21Stream if dcml_version == 1: @@ -783,7 +787,7 @@ def m21ToTsv(self) -> list[list[str]]: return self._m21ToTsv_v2() def _m21ToTsv_v1(self) -> list[list[str]]: - tsvData = [] + tsvData: list[list[str]] = [] # take the global_key from the first item global_key = next( self.m21Stream.recurse().getElementsByClass('RomanNumeral') @@ -911,7 +915,7 @@ def _m21ToTsv_v2(self) -> list[list[str]]: tsvData.append(thisInfo) return tsvData - def write(self, filePathAndName: str|pathlib.Path): + def write(self, filePathAndName: str|pathlib.Path) -> None: ''' Writes a list of lists (e.g. from m21ToTsv()) to a tsv file. ''' @@ -1189,7 +1193,7 @@ def getSecondaryKey(rn: str, local_key: str) -> str: class Test(unittest.TestCase): - def testTsvHandler(self): + def testTsvHandler(self) -> None: import os test_files = { 1: ('tsvEg_v1.tsv',), @@ -1239,8 +1243,11 @@ def testTsvHandler(self): # M21 stream out_stream = handler.toM21Stream() + firstMeasure = t.cast( + stream.Measure, out_stream.parts[0].measure(1) + ) self.assertEqual( - out_stream.parts[0].measure(1)[roman.RomanNumeral][0].figure, + firstMeasure[roman.RomanNumeral][0].figure, 'I6' if version == 2 else 'I' ) @@ -1268,8 +1275,11 @@ def testTsvHandler(self): self.assertEqual( item1, item2, msg=f'item {i}, version {version}: {item1} != {item2}' ) - first_harmony = stream1[harmony.Harmony].first() - first_offset = first_harmony.activeSite.offset + first_harmony.offset + first_harmony = t.cast( + harmony.Harmony, stream1[harmony.Harmony].first() + ) + first_active_site = t.cast(stream.Stream, first_harmony.activeSite) + first_offset = first_active_site.offset + first_harmony.offset self.assertEqual( sum( h.quarterLength @@ -1278,7 +1288,7 @@ def testTsvHandler(self): stream1.quarterLength - first_offset ) - def testM21ToTsv(self): + def testM21ToTsv(self) -> None: import os from music21 import corpus @@ -1298,11 +1308,11 @@ def testM21ToTsv(self): self.assertEqual(handler.tsvData[0][numeral_i], 'I') os.remove(tempF) - def testIsMinor(self): + def testIsMinor(self) -> None: self.assertTrue(isMinor('f')) self.assertFalse(isMinor('F')) - def testOfCharacter(self): + def testOfCharacter(self) -> None: startText = 'before%after' newText = ''.join([characterSwaps(x, direction='DCML-m21') for x in startText]) @@ -1320,7 +1330,7 @@ def testOfCharacter(self): self.assertEqual(testStr1out, 'iiø') - def testGetLocalKey(self): + def testGetLocalKey(self) -> None: test1 = getLocalKey('V', 'G') self.assertEqual(test1, 'D') @@ -1333,7 +1343,7 @@ def testGetLocalKey(self): test4 = getLocalKey('vii', 'a', convertDCMLToM21=True) self.assertEqual(test4, 'g') - def testGetSecondaryKey(self): + def testGetSecondaryKey(self) -> None: testRN = 'V/vi' testLocalKey = 'D' @@ -1342,9 +1352,9 @@ def testGetSecondaryKey(self): self.assertIsInstance(veryLocalKey, str) self.assertEqual(veryLocalKey, 'b') - def testRepeats(self): + def testRepeats(self) -> None: def _test_ending_contents( - rb: spanner.RepeatBracket, expectedMeasures: t.List[str] + rb: spanner.RepeatBracket, expectedMeasures: list[str] ) -> None: measure_nos = [m.measureNumberWithSuffix() for m in rb[stream.Measure]] self.assertEqual(measure_nos, expectedMeasures) diff --git a/music21/romanText/writeRoman.py b/music21/romanText/writeRoman.py index 431f36486b..98d0f5cfa5 100644 --- a/music21/romanText/writeRoman.py +++ b/music21/romanText/writeRoman.py @@ -119,7 +119,7 @@ class RnWriter(prebase.ProtoM21Object): def __init__(self, obj: base.Music21Object, - ): + ) -> None: self.composer = 'Composer unknown' self.title = 'Title unknown' @@ -179,7 +179,7 @@ def __init__(self, self.prepSequentialListOfLines() def _makeContainer(self, - obj: stream.Stream|list): + obj: stream.Stream|list[base.Music21Object]) -> stream.Part: ''' Makes a placeholder container for the unusual cases where this class is called on generic- or non-stream object as opposed to @@ -194,7 +194,7 @@ def _makeContainer(self, return container def prepTitle(self, - md: metadata.Metadata): + md: metadata.Metadata) -> None: ''' Attempt to prepare a single work title from the score metadata looking at each of the title, movementNumber and movementName attributes. @@ -214,7 +214,7 @@ def prepTitle(self, 'Fake title - No.123456789: Fake movementName' ''' - workingTitle = [] + workingTitle: list[str] = [] if md.bestTitle: workingTitle.append(md.bestTitle) @@ -229,7 +229,7 @@ def prepTitle(self, # ------------------------------------------------------------------------------ - def prepSequentialListOfLines(self): + def prepSequentialListOfLines(self) -> None: ''' Prepares a sequential list of text lines, with time signatures and Roman numerals adding this to the (already prepared) metadata preamble ready for printing. @@ -318,6 +318,7 @@ def prepSequentialListOfLines(self): # numeral to avoid printing an unnecessary indication like # 'b3' prior to the repeat last_rn = thisMeasure[roman.RomanNumeral].last() + beat: float|fractions.Fraction if last_rn is None: beat = 1.0 else: @@ -332,7 +333,7 @@ def prepSequentialListOfLines(self): self.combinedList.append(measureString) def getChordString(self, - rn: roman.RomanNumeral): + rn: roman.RomanNumeral) -> str: ''' Produce a string from a Roman numeral with the chord and the key if that key constitutes a change from the foregoing context. @@ -365,7 +366,7 @@ def getChordString(self, def rnString(measureNumber: int|str, beat: str|int|float|fractions.Fraction, chordString: str, - inString: str|None = ''): + inString: str|None = '') -> str: ''' Creates or extends a string of RomanText such that the output corresponds to a single measure line. @@ -413,7 +414,7 @@ def rnString(measureNumber: int|str, def intBeat(beat: str|int|float|fractions.Fraction, - roundValue: int = 2): + roundValue: int = 2) -> int|float: ''' Converts beats to integers if possible, and otherwise to rounded decimals. Accepts input as string, int, float, or fractions.Fraction. @@ -478,7 +479,7 @@ class Test(unittest.TestCase): for handling the special case of opus objects. ''' - def testOpus(self): + def testOpus(self) -> None: ''' As the rntxt input parser handles Opus objects (i.e. more than one score within the same rntxt files), @@ -526,7 +527,7 @@ def testOpus(self): ]: self.assertIn(x, testOpusRnWriter.combinedList) - def testTwoCorpusPiecesAndTwoCorruptions(self): + def testTwoCorpusPiecesAndTwoCorruptions(self) -> None: ''' Tests for two analysis cases (the smallest rntxt files in the music21 corpus) along with two test by modifying those scores. @@ -575,7 +576,7 @@ def testTwoCorpusPiecesAndTwoCorruptions(self): adjustedMonte = RnWriter(scoreMonte) self.assertEqual(adjustedMonte.title, 'Fake title - No.123456789: Fake movementName') - def testTypeParses(self): + def testTypeParses(self) -> None: ''' Tests successful init on a range of supported objects (score, part, even RomanNumeral). ''' @@ -606,7 +607,7 @@ def testTypeParses(self): rn = roman.RomanNumeral('viio6', 'G') RnWriter(rn) # and even (perhaps dubiously) directly on other music21 objects - def testRepeats(self): + def testRepeats(self) -> None: from music21 import converter rntxt = textwrap.dedent(''' Time Signature: 2/4 @@ -621,7 +622,7 @@ def testRepeats(self): writer = RnWriter(s) assert '\n'.join(writer.combinedList).strip().endswith(rntxt.strip()) - def testRnString(self): + def testRnString(self) -> None: test = rnString(1, 1, 'G: I') self.assertEqual(test, 'm1 G: I') # no beat number given for b1 @@ -631,7 +632,7 @@ def testRnString(self): with self.assertRaises(ValueError): # error when the measure numbers don't match rnString(15, 1, 'viio6', 'm14 G: I') - def testIntBeat(self): + def testIntBeat(self) -> None: testInt = intBeat(1, roundValue=2) self.assertEqual(testInt, 1) From 0611a7f3376820e4712549ce9cfa956c994902ad Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Mon, 22 Jun 2026 23:15:54 -1000 Subject: [PATCH 02/10] romanText: use unittest assertions instead of bare asserts in tests Bare `assert` in a test gives a poor traceback on failure. Replace them with real unittest assertions: - testReA: precede each Optional-narrowing `assert g is not None` with `self.assertIsNotNone(g)` (the meaningful check); the bare assert stays after it -- guaranteed to succeed -- purely so mypy narrows the re.Match | None. - testRepeats: `assert s.duration.quarterLength == 10` -> assertEqual. - writeRoman test: `assert ....endswith(...)` -> assertTrue. AI-assisted (Claude) --- music21/romanText/rtObjects.py | 15 +++++++++++++++ music21/romanText/translate.py | 2 +- music21/romanText/writeRoman.py | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/music21/romanText/rtObjects.py b/music21/romanText/rtObjects.py index 248812d9c6..9cf517bc79 100644 --- a/music21/romanText/rtObjects.py +++ b/music21/romanText/rtObjects.py @@ -1508,6 +1508,7 @@ def testBasicA(self) -> None: def testReA(self) -> None: # gets the index of the end of the measure indication g = reMeasureTag.match('m1 g: V b2 i') + self.assertIsNotNone(g) assert g is not None self.assertEqual(g.end(), 2) self.assertEqual(g.group(0), 'm1') @@ -1515,48 +1516,60 @@ def testReA(self) -> None: self.assertEqual(reMeasureTag.match('Time Signature: 2/2'), None) g = reMeasureTag.match('m3-4=m1-2') + self.assertIsNotNone(g) assert g is not None self.assertEqual(g.end(), 4) self.assertEqual(g.start(), 0) self.assertEqual(g.group(0), 'm3-4') g = reMeasureTag.match('m123-432=m1120-24234') + self.assertIsNotNone(g) assert g is not None self.assertEqual(g.group(0), 'm123-432') g = reMeasureTag.match('m231a IV6 b4 C: V') + self.assertIsNotNone(g) assert g is not None self.assertEqual(g.group(0), 'm231a') g = reMeasureTag.match('m123b-432b=m1120a-24234a') + self.assertIsNotNone(g) assert g is not None self.assertEqual(g.group(0), 'm123b-432b') g = reMeasureTag.match('m231var1 IV6 b4 C: V') + self.assertIsNotNone(g) assert g is not None self.assertEqual(g.group(0), 'm231') # this only works if it starts the string g = reVariant.match('var1 IV6 b4 C: V') + self.assertIsNotNone(g) assert g is not None self.assertEqual(g.group(0), 'var1') g = reAnalyticKeyAtom.match('Bb:') + self.assertIsNotNone(g) assert g is not None self.assertEqual(g.group(0), 'Bb:') g = reAnalyticKeyAtom.match('F#:') + self.assertIsNotNone(g) assert g is not None self.assertEqual(g.group(0), 'F#:') g = reAnalyticKeyAtom.match('f#:') + self.assertIsNotNone(g) assert g is not None self.assertEqual(g.group(0), 'f#:') g = reAnalyticKeyAtom.match('b:') + self.assertIsNotNone(g) assert g is not None self.assertEqual(g.group(0), 'b:') g = reAnalyticKeyAtom.match('bb:') + self.assertIsNotNone(g) assert g is not None self.assertEqual(g.group(0), 'bb:') g = reAnalyticKeyAtom.match('g:') + self.assertIsNotNone(g) assert g is not None self.assertEqual(g.group(0), 'g:') @@ -1565,6 +1578,7 @@ def testReA(self) -> None: self.assertEqual(reKeyAtom.match('b2.5'), None) g = reBeatAtom.match('b2.5') + self.assertIsNotNone(g) assert g is not None self.assertEqual(g.group(0), 'b2.5') @@ -1572,6 +1586,7 @@ def testReA(self) -> None: self.assertEqual(g, None) g = reBeatAtom.match('b1.66.5') + self.assertIsNotNone(g) assert g is not None self.assertEqual(g.group(0), 'b1.66.5') diff --git a/music21/romanText/translate.py b/music21/romanText/translate.py index af3f4951b1..286fb63c31 100644 --- a/music21/romanText/translate.py +++ b/music21/romanText/translate.py @@ -1728,7 +1728,7 @@ def testCopyEmptyMeasures(self): m4-5 = m1-2 ''') s = converter.parse(empty_measures_with_copy, format='romanText') - assert s.duration.quarterLength == 10 + self.assertEqual(s.duration.quarterLength, 10) def testRepeats(self): from music21 import converter diff --git a/music21/romanText/writeRoman.py b/music21/romanText/writeRoman.py index 98d0f5cfa5..132564fce9 100644 --- a/music21/romanText/writeRoman.py +++ b/music21/romanText/writeRoman.py @@ -620,7 +620,7 @@ def testRepeats(self) -> None: ''') s = converter.parse(rntxt, format='romanText') writer = RnWriter(s) - assert '\n'.join(writer.combinedList).strip().endswith(rntxt.strip()) + self.assertTrue('\n'.join(writer.combinedList).strip().endswith(rntxt.strip())) def testRnString(self) -> None: test = rnString(1, 1, 'G: I') From d2da0de8837d4272d3912230b61bc6e311600b5c Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 23 Jun 2026 15:48:58 -1000 Subject: [PATCH 03/10] romanText: simplify testReA to bare asserts with messages Collapse the redundant `self.assertIsNotNone(g)` + `assert g is not None` pairs into a single `assert g is not None, ' did not match'`, which narrows re.Match | None for mypy and gives a useful failure message. AI-assisted (Claude) --- music21/romanText/rtObjects.py | 45 ++++++++++++---------------------- 1 file changed, 15 insertions(+), 30 deletions(-) diff --git a/music21/romanText/rtObjects.py b/music21/romanText/rtObjects.py index 9cf517bc79..c373ed4679 100644 --- a/music21/romanText/rtObjects.py +++ b/music21/romanText/rtObjects.py @@ -1508,69 +1508,56 @@ def testBasicA(self) -> None: def testReA(self) -> None: # gets the index of the end of the measure indication g = reMeasureTag.match('m1 g: V b2 i') - self.assertIsNotNone(g) - assert g is not None + assert g is not None, 'reMeasureTag did not match' self.assertEqual(g.end(), 2) self.assertEqual(g.group(0), 'm1') self.assertEqual(reMeasureTag.match('Time Signature: 2/2'), None) g = reMeasureTag.match('m3-4=m1-2') - self.assertIsNotNone(g) - assert g is not None + assert g is not None, 'reMeasureTag did not match' self.assertEqual(g.end(), 4) self.assertEqual(g.start(), 0) self.assertEqual(g.group(0), 'm3-4') g = reMeasureTag.match('m123-432=m1120-24234') - self.assertIsNotNone(g) - assert g is not None + assert g is not None, 'reMeasureTag did not match' self.assertEqual(g.group(0), 'm123-432') g = reMeasureTag.match('m231a IV6 b4 C: V') - self.assertIsNotNone(g) - assert g is not None + assert g is not None, 'reMeasureTag did not match' self.assertEqual(g.group(0), 'm231a') g = reMeasureTag.match('m123b-432b=m1120a-24234a') - self.assertIsNotNone(g) - assert g is not None + assert g is not None, 'reMeasureTag did not match' self.assertEqual(g.group(0), 'm123b-432b') g = reMeasureTag.match('m231var1 IV6 b4 C: V') - self.assertIsNotNone(g) - assert g is not None + assert g is not None, 'reMeasureTag did not match' self.assertEqual(g.group(0), 'm231') # this only works if it starts the string g = reVariant.match('var1 IV6 b4 C: V') - self.assertIsNotNone(g) - assert g is not None + assert g is not None, 'reVariant did not match' self.assertEqual(g.group(0), 'var1') g = reAnalyticKeyAtom.match('Bb:') - self.assertIsNotNone(g) - assert g is not None + assert g is not None, 'reAnalyticKeyAtom did not match' self.assertEqual(g.group(0), 'Bb:') g = reAnalyticKeyAtom.match('F#:') - self.assertIsNotNone(g) - assert g is not None + assert g is not None, 'reAnalyticKeyAtom did not match' self.assertEqual(g.group(0), 'F#:') g = reAnalyticKeyAtom.match('f#:') - self.assertIsNotNone(g) - assert g is not None + assert g is not None, 'reAnalyticKeyAtom did not match' self.assertEqual(g.group(0), 'f#:') g = reAnalyticKeyAtom.match('b:') - self.assertIsNotNone(g) - assert g is not None + assert g is not None, 'reAnalyticKeyAtom did not match' self.assertEqual(g.group(0), 'b:') g = reAnalyticKeyAtom.match('bb:') - self.assertIsNotNone(g) - assert g is not None + assert g is not None, 'reAnalyticKeyAtom did not match' self.assertEqual(g.group(0), 'bb:') g = reAnalyticKeyAtom.match('g:') - self.assertIsNotNone(g) - assert g is not None + assert g is not None, 'reAnalyticKeyAtom did not match' self.assertEqual(g.group(0), 'g:') # beats do not have a colon @@ -1578,16 +1565,14 @@ def testReA(self) -> None: self.assertEqual(reKeyAtom.match('b2.5'), None) g = reBeatAtom.match('b2.5') - self.assertIsNotNone(g) - assert g is not None + assert g is not None, 'reBeatAtom did not match' self.assertEqual(g.group(0), 'b2.5') g = reBeatAtom.match('bVII') self.assertEqual(g, None) g = reBeatAtom.match('b1.66.5') - self.assertIsNotNone(g) - assert g is not None + assert g is not None, 'reBeatAtom did not match' self.assertEqual(g.group(0), 'b1.66.5') def testMeasureAttributeProcessing(self) -> None: From 313bea1f33abb1432c37b07bbc6df65d7f5b0860 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 23 Jun 2026 16:28:23 -1000 Subject: [PATCH 04/10] romanText.translate: require Key, raise on missing copy target, drop sentinels' None These |None types were never really None -- the call sites already cast them away. Tighten them and remove the casts: - _copySingleMeasure / _copyMultipleMeasures now require kCurrent: key.Key (a Key is always supplied, defaulting to C at instantiation) and return a non-Optional Key. The dead "no past key definitions" guards are removed. - Both now raise RomanTextTranslateException if the copy *source* measure(s) are not found, instead of returning None / an empty list and failing obscurely downstream. Added tests for both (forward-reference copy targets). - currentMeasureToken and lastMeasureToken are initialised to a measure-0 RTMeasure sentinel (always overwritten before use), so they are typed as a plain RTMeasure and the four t.casts are gone. previousChordInMeasure stays Optional on purpose: it is reset to None at the start of every measure and the `is None` checks are the first-chord-in-measure detection. AI-assisted (Claude) --- music21/romanText/translate.py | 65 +++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 24 deletions(-) diff --git a/music21/romanText/translate.py b/music21/romanText/translate.py index 286fb63c31..6e1b01d20c 100644 --- a/music21/romanText/translate.py +++ b/music21/romanText/translate.py @@ -176,8 +176,8 @@ def _reprInternal(self) -> str: def _copySingleMeasure( rtTagged: rtObjects.RTMeasure, p: stream.Part, - kCurrent: key.Key|None, -) -> tuple[stream.Measure|None, key.Key|None]: + kCurrent: key.Key, +) -> tuple[stream.Measure, key.Key]: ''' Given a RomanText token, a Part used as the current container, and the current Key, return a Measure copied from the past of the Part. @@ -208,10 +208,6 @@ def _copySingleMeasure( m.number = rtTagged.number[0] # update all keys for rnPast in m.getElementsByClass(roman.RomanNumeral): - if kCurrent is None: # pragma: no cover - # should not happen - raise RomanTextTranslateException( - 'attempting to copy a measure but no past key definitions are found') if rnPast.followsKeyChange: kCurrent = rnPast.key elif rnPast.pivotChord is not None: @@ -228,14 +224,17 @@ def _copySingleMeasure( m.replace(rnPast, newRN) break + if m is None: + raise RomanTextTranslateException( + f'Could not find measure {target} to copy from') return m, kCurrent def _copyMultipleMeasures( rtMeasure: rtObjects.RTMeasure, p: stream.Part, - kCurrent: key.Key|None, -) -> tuple[list[stream.Measure], key.Key|None]: + kCurrent: key.Key, +) -> tuple[list[stream.Measure], key.Key]: ''' Given a RomanText token for a RTMeasure, a Part used as the current container, and the current Key, @@ -279,10 +278,6 @@ def _copyMultipleMeasures( # update all keys allRNs = list(m.getElementsByClass(roman.RomanNumeral)) for rnPast in allRNs: - if kCurrent is None: # pragma: no cover - # should not happen - raise RomanTextTranslateException( - 'attempting to copy a measure but no past key definitions are found') if rnPast.followsKeyChange: kCurrent = rnPast.key elif rnPast.pivotChord is not None: @@ -300,6 +295,9 @@ def _copyMultipleMeasures( if mPast.number == targetEnd: break + if not measures: + raise RomanTextTranslateException( + f'Could not find measures {targetStart}-{targetEnd} to copy from') return measures, kCurrent @@ -361,7 +359,8 @@ def __init__(self, md: metadata.Metadata|None = None) -> None: self.tsCurrent: meter.TimeSignature = meter.TimeSignature('4/4') # create default 4/4 self.tsAtTimeOfLastChord: meter.TimeSignature = self.tsCurrent self.tsSet = False # store if set to a measure - self.lastMeasureToken: rtObjects.RTMeasure|None = None + # measure-0 sentinel; always overwritten with the real token before use + self.lastMeasureToken: rtObjects.RTMeasure = rtObjects.RTMeasure('m0') self.lastMeasureNumber = 0 self.previousRn: note.GeneralNote|None = None self.keySigCurrent: key.KeySignature|None = None @@ -375,8 +374,9 @@ def __init__(self, md: metadata.Metadata|None = None) -> None: self.repeatEndings: dict[int, list[tuple[int, stream.Measure]]] = {} - # reset for each measure - self.currentMeasureToken: rtObjects.RTMeasure|None = None + # reset for each measure. currentMeasureToken starts as a measure-0 + # sentinel and is always overwritten with the real token before use. + self.currentMeasureToken: rtObjects.RTMeasure = rtObjects.RTMeasure('m0') self.previousChordInMeasure: note.GeneralNote|None = None self.pivotChordPossible = False self.numberOfAtomsInCurrentMeasure = 0 @@ -570,9 +570,7 @@ def translateMeasureLineToken(self, measureLineToken: rtObjects.RTMeasure) -> No # create a new measure or copy a past measure if isSingleMeasureCopy: # if not a range p.coreElementsChanged() - mCopy, kCopied = _copySingleMeasure(measureLineToken, p, self.kCurrent) - m = t.cast(stream.Measure, mCopy) - self.kCurrent = t.cast(key.Key, kCopied) + m, self.kCurrent = _copySingleMeasure(measureLineToken, p, self.kCurrent) p.coreAppend(m) self.lastMeasureNumber = m.number self.lastMeasureToken = measureLineToken @@ -582,8 +580,7 @@ def translateMeasureLineToken(self, measureLineToken: rtObjects.RTMeasure) -> No elif isMultipleMeasureCopy: p.coreElementsChanged() - measures, kCopiedMulti = _copyMultipleMeasures(measureLineToken, p, self.kCurrent) - self.kCurrent = t.cast(key.Key, kCopiedMulti) + measures, self.kCurrent = _copyMultipleMeasures(measureLineToken, p, self.kCurrent) p.append(measures) # appendCore does not work with list self.lastMeasureNumber = measures[-1].number self.lastMeasureToken = measureLineToken @@ -608,7 +605,7 @@ def fillToMeasureToken(self, measureToken: rtObjects.RTMeasure) -> None: mFill.number = i self.fillMeasureFromPreviousRn(mFill) appendMeasureToRepeatEndingsDict( - t.cast(rtObjects.RTMeasure, self.lastMeasureToken), + self.lastMeasureToken, mFill, self.repeatEndings, i) p.coreAppend(mFill) @@ -731,7 +728,7 @@ def translateSingleMeasureAtom( Uses coreInsert and coreAppend methods, so must have `m.coreElementsChanged()` called afterward. ''' - currentMeasureToken = t.cast(rtObjects.RTMeasure, self.currentMeasureToken) + currentMeasureToken = self.currentMeasureToken if (isinstance(a, rtObjects.RTKey) or (self.foundAKeySignatureSoFar is False and isinstance(a, rtObjects.RTAnalyticKey))): @@ -859,7 +856,7 @@ def processRTChord( ''' Process a single RTChord atom. ''' - currentMeasureToken = t.cast(rtObjects.RTMeasure, self.currentMeasureToken) + currentMeasureToken = self.currentMeasureToken # use source to evaluation roman self.tsAtTimeOfLastChord = self.tsCurrent try: @@ -944,7 +941,7 @@ def setAnalyticKey(self, a: rtObjects.RTKeyTypeAtom) -> None: Indicates a change in the analyzed key, not a change in anything else, such as the keySignature. ''' - currentMeasureToken = t.cast(rtObjects.RTMeasure, self.currentMeasureToken) + currentMeasureToken = self.currentMeasureToken try: # this sets the key and the keysignature self.kCurrent, pl = _getKeyAndPrefix(a) self.prefixLyric += pl @@ -1730,6 +1727,26 @@ def testCopyEmptyMeasures(self): s = converter.parse(empty_measures_with_copy, format='romanText') self.assertEqual(s.duration.quarterLength, 10) + def testCopySingleMeasureTargetNotFound(self) -> None: + from music21 import key, stream + from music21.romanText import rtObjects + # m2 copies from m9, which does not exist in the part + rtm = rtObjects.RTMeasure('m2=m9') + p = stream.Part() + with self.assertRaisesRegex( + RomanTextTranslateException, 'Could not find measure 9'): + _copySingleMeasure(rtm, p, key.Key('C')) + + def testCopyMultipleMeasuresTargetNotFound(self) -> None: + from music21 import key, stream + from music21.romanText import rtObjects + # m20-21 copies from m2-3, which do not exist in the part + rtm = rtObjects.RTMeasure('m20-21=m2-3') + p = stream.Part() + with self.assertRaisesRegex( + RomanTextTranslateException, 'Could not find measures 2-3'): + _copyMultipleMeasures(rtm, p, key.Key('C')) + def testRepeats(self): from music21 import converter From babd556117d3d7e8403321f46b3c1d8bd95f0e9f Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 23 Jun 2026 16:45:31 -1000 Subject: [PATCH 05/10] romanText.translate: fix crash on a piece beginning with chordless measures translateSingleMeasure cast self.previousRn to GeneralNote when adjusting the last chord's duration, but previousRn is None until the first chord atom is seen. A piece that begins with an empty or key-only measure therefore crashed with "AttributeError: 'NoneType' object has no attribute 'quarterLength'" (the cast was masking it). Guard on `self.previousRn is not None` instead -- if no chord was added there is no last chord to resize -- which also removes the cast. Added a regression test. AI-assisted (Claude) --- music21/romanText/translate.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/music21/romanText/translate.py b/music21/romanText/translate.py index 6e1b01d20c..d7c3443855 100644 --- a/music21/romanText/translate.py +++ b/music21/romanText/translate.py @@ -704,11 +704,11 @@ def translateSingleMeasure(self, measureToken: rtObjects.RTMeasure) -> stream.Me isLastAtomInMeasure = (i == self.numberOfAtomsInCurrentMeasure - 1) self.translateSingleMeasureAtom(a, m, isLastAtomInMeasure=isLastAtomInMeasure) - # may need to adjust duration of last chord added - if self.tsCurrent is not None: - previousRn = t.cast(note.GeneralNote, self.previousRn) - previousRn.quarterLength = (self.tsCurrent.barDuration.quarterLength - - self.currentOffsetInMeasure) + # may need to adjust duration of last chord added (if any chord was added) + if self.tsCurrent is not None and self.previousRn is not None: + self.previousRn.quarterLength = ( + self.tsCurrent.barDuration.quarterLength - self.currentOffsetInMeasure + ) m.coreElementsChanged() return m @@ -1747,6 +1747,17 @@ def testCopyMultipleMeasuresTargetNotFound(self) -> None: RomanTextTranslateException, 'Could not find measures 2-3'): _copyMultipleMeasures(rtm, p, key.Key('C')) + def testLeadingMeasureWithoutChord(self) -> None: + # a piece may begin with measures that have no chord atoms (empty or + # key-only); the last-chord duration adjustment must be skipped rather + # than crash because no previous RomanNumeral exists yet. + from music21 import converter, stream + for src in ('Time Signature: 4/4\nm1\nm2 I\n', + 'Time Signature: 4/4\nm1 G:\nm2 I\n'): + s = converter.parse(src, format='romanText') + measures = list(s.recurse().getElementsByClass(stream.Measure)) + self.assertEqual(len(measures), 2) + def testRepeats(self): from music21 import converter From 2a973dd0620f8973cdb97a1a02c5509bcbd82d9d Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 23 Jun 2026 16:50:24 -1000 Subject: [PATCH 06/10] romanText.translate: strengthen leading-chordless-measure regression test Assert the empty first measure has no RomanNumeral and the second measure parses as a I chord, so the test guards correct parsing of a piece that begins without chord atoms, not merely that it does not crash. AI-assisted (Claude) --- music21/romanText/translate.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/music21/romanText/translate.py b/music21/romanText/translate.py index d7c3443855..291962fe14 100644 --- a/music21/romanText/translate.py +++ b/music21/romanText/translate.py @@ -1748,15 +1748,21 @@ def testCopyMultipleMeasuresTargetNotFound(self) -> None: _copyMultipleMeasures(rtm, p, key.Key('C')) def testLeadingMeasureWithoutChord(self) -> None: - # a piece may begin with measures that have no chord atoms (empty or - # key-only); the last-chord duration adjustment must be skipped rather - # than crash because no previous RomanNumeral exists yet. - from music21 import converter, stream + # Regression: a piece may begin with measures that have no chord atoms + # (empty or key-only). The last-chord duration adjustment in + # translateSingleMeasure must be skipped rather than crash with + # AttributeError because no previous RomanNumeral exists yet. + from music21 import converter, roman, stream for src in ('Time Signature: 4/4\nm1\nm2 I\n', 'Time Signature: 4/4\nm1 G:\nm2 I\n'): s = converter.parse(src, format='romanText') measures = list(s.recurse().getElementsByClass(stream.Measure)) self.assertEqual(len(measures), 2) + # the empty first measure has no chord; the second parses as I + self.assertEqual(len(measures[0].getElementsByClass(roman.RomanNumeral)), 0) + secondMeasureRNs = list(measures[1].getElementsByClass(roman.RomanNumeral)) + self.assertEqual(len(secondMeasureRNs), 1) + self.assertEqual(secondMeasureRNs[0].figure, 'I') def testRepeats(self): from music21 import converter From fb10a8c9308cce07840d7d287a604bd9dbfcb03a Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 23 Jun 2026 17:04:25 -1000 Subject: [PATCH 07/10] romanText.translate: narrow previousRn type to RomanNumeral | Rest | None previousRn was typed note.GeneralNote|None but only ever holds a RomanNumeral (a chord) or a Rest (from an N.C. atom), or None before the first chord. Narrow the annotation accordingly. No behavior change -- it still starts as None, so the existing is-None guards are unaffected. AI-assisted (Claude) --- music21/romanText/translate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/music21/romanText/translate.py b/music21/romanText/translate.py index 291962fe14..93698cbcda 100644 --- a/music21/romanText/translate.py +++ b/music21/romanText/translate.py @@ -362,7 +362,7 @@ def __init__(self, md: metadata.Metadata|None = None) -> None: # measure-0 sentinel; always overwritten with the real token before use self.lastMeasureToken: rtObjects.RTMeasure = rtObjects.RTMeasure('m0') self.lastMeasureNumber = 0 - self.previousRn: note.GeneralNote|None = None + self.previousRn: roman.RomanNumeral|note.Rest|None = None self.keySigCurrent: key.KeySignature|None = None self.setKeySigFromFirstKeyToken = True # set a keySignature self.foundAKeySignatureSoFar = False From e6a6c0d65eb7791779c7065e7c5037794e80f2fe Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 23 Jun 2026 17:05:10 -1000 Subject: [PATCH 08/10] romanText.translate: TODO to start previousRn as a Rest (fix leading-gap bug) Note next to the initial previousRn assignment that it should start as a note.Rest (dropping None) so a piece beginning before its first chord -- e.g. one starting with N.C. -- gets leading rests rather than a gap. AI-assisted (Claude) --- music21/romanText/translate.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/music21/romanText/translate.py b/music21/romanText/translate.py index 93698cbcda..4766826ad7 100644 --- a/music21/romanText/translate.py +++ b/music21/romanText/translate.py @@ -362,6 +362,10 @@ def __init__(self, md: metadata.Metadata|None = None) -> None: # measure-0 sentinel; always overwritten with the real token before use self.lastMeasureToken: rtObjects.RTMeasure = rtObjects.RTMeasure('m0') self.lastMeasureNumber = 0 + # TODO: make previousRn start as a note.Rest (dropping the None option) + # so that a piece beginning before its first chord -- e.g. one that + # starts with N.C. -- gets leading rests instead of a gap at the + # start of the stream. self.previousRn: roman.RomanNumeral|note.Rest|None = None self.keySigCurrent: key.KeySignature|None = None self.setKeySigFromFirstKeyToken = True # set a keySignature From 2b5c43558677b712d6d6d3d2b453d9429393aa1d Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 23 Jun 2026 17:05:56 -1000 Subject: [PATCH 09/10] romanText.translate: note leading-gap limitation at the beat back-fill Cross-reference the previousRn TODO from the measure-start back-fill: it does not fire at the start of a piece because previousRn is None there. AI-assisted (Claude) --- music21/romanText/translate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/music21/romanText/translate.py b/music21/romanText/translate.py index 4766826ad7..a21963b033 100644 --- a/music21/romanText/translate.py +++ b/music21/romanText/translate.py @@ -773,6 +773,9 @@ def translateSingleMeasureAtom( f'under timeSignature {self.tsCurrent} ' f'in line {currentMeasureToken.src}' ) from ve + # Back-fills the measure start from a tied-forward chord -- but not + # at the start of a piece (previousRn is None there). See the + # previousRn TODO: starting it as a Rest would fix the leading gap. if (self.previousChordInMeasure is None and self.previousRn is not None and newOffset > 0): From 1ed5b486244b22a95edc26c893b68f5fbe7235d8 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 23 Jun 2026 17:07:24 -1000 Subject: [PATCH 10/10] romanText.translate: drop redundant local imports in new tests key, stream, rtObjects, and roman are already imported at module level; the new tests re-imported them, tripping pylint's redefined-outer-name / reimported warnings. Use the module-level names (keep only the local converter import, which is not module-level). AI-assisted (Claude) --- music21/romanText/translate.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/music21/romanText/translate.py b/music21/romanText/translate.py index a21963b033..e029534819 100644 --- a/music21/romanText/translate.py +++ b/music21/romanText/translate.py @@ -1735,8 +1735,6 @@ def testCopyEmptyMeasures(self): self.assertEqual(s.duration.quarterLength, 10) def testCopySingleMeasureTargetNotFound(self) -> None: - from music21 import key, stream - from music21.romanText import rtObjects # m2 copies from m9, which does not exist in the part rtm = rtObjects.RTMeasure('m2=m9') p = stream.Part() @@ -1745,8 +1743,6 @@ def testCopySingleMeasureTargetNotFound(self) -> None: _copySingleMeasure(rtm, p, key.Key('C')) def testCopyMultipleMeasuresTargetNotFound(self) -> None: - from music21 import key, stream - from music21.romanText import rtObjects # m20-21 copies from m2-3, which do not exist in the part rtm = rtObjects.RTMeasure('m20-21=m2-3') p = stream.Part() @@ -1759,7 +1755,7 @@ def testLeadingMeasureWithoutChord(self) -> None: # (empty or key-only). The last-chord duration adjustment in # translateSingleMeasure must be skipped rather than crash with # AttributeError because no previous RomanNumeral exists yet. - from music21 import converter, roman, stream + from music21 import converter for src in ('Time Signature: 4/4\nm1\nm2 I\n', 'Time Signature: 4/4\nm1 G:\nm2 I\n'): s = converter.parse(src, format='romanText')