diff --git a/AGENTS.md b/AGENTS.md index c6f5e6ac2..f53c9de6d 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 2f96c106e..a22b81161 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 d03f2bbc0..b4917e1d4 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 dae59bd67..c373ed467 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, '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') + 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') + assert g is not None, 'reMeasureTag did not match' self.assertEqual(g.group(0), 'm123-432') g = reMeasureTag.match('m231a IV6 b4 C: V') + assert g is not None, 'reMeasureTag did not match' self.assertEqual(g.group(0), 'm231a') g = reMeasureTag.match('m123b-432b=m1120a-24234a') + assert g is not None, 'reMeasureTag did not match' self.assertEqual(g.group(0), 'm123b-432b') g = reMeasureTag.match('m231var1 IV6 b4 C: V') + 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') + assert g is not None, 'reVariant did not match' self.assertEqual(g.group(0), 'var1') g = reAnalyticKeyAtom.match('Bb:') + assert g is not None, 'reAnalyticKeyAtom did not match' self.assertEqual(g.group(0), 'Bb:') g = reAnalyticKeyAtom.match('F#:') + assert g is not None, 'reAnalyticKeyAtom did not match' self.assertEqual(g.group(0), 'F#:') g = reAnalyticKeyAtom.match('f#:') + assert g is not None, 'reAnalyticKeyAtom did not match' self.assertEqual(g.group(0), 'f#:') g = reAnalyticKeyAtom.match('b:') + assert g is not None, 'reAnalyticKeyAtom did not match' self.assertEqual(g.group(0), 'b:') g = reAnalyticKeyAtom.match('bb:') + assert g is not None, 'reAnalyticKeyAtom did not match' self.assertEqual(g.group(0), 'bb:') g = reAnalyticKeyAtom.match('g:') + assert g is not None, 'reAnalyticKeyAtom did not match' 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, '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') + assert g is not None, 'reBeatAtom did not match' 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 70f8eff32..e02953481 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, +) -> 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. @@ -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() @@ -204,10 +208,6 @@ def _copySingleMeasure(rtTagged, p, kCurrent): 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: @@ -224,12 +224,17 @@ def _copySingleMeasure(rtTagged, p, kCurrent): 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): +def _copyMultipleMeasures( + rtMeasure: rtObjects.RTMeasure, + p: stream.Part, + 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, @@ -256,7 +261,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: @@ -273,10 +278,6 @@ def _copyMultipleMeasures(rtMeasure: rtObjects.RTMeasure, # 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: @@ -294,10 +295,15 @@ def _copyMultipleMeasures(rtMeasure: rtObjects.RTMeasure, if mPast.number == targetEnd: break + if not measures: + raise RomanTextTranslateException( + f'Could not find measures {targetStart}-{targetEnd} to copy from') 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 +324,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 +347,27 @@ 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 + # measure-0 sentinel; always overwritten with the real token before use + self.lastMeasureToken: rtObjects.RTMeasure = rtObjects.RTMeasure('m0') self.lastMeasureNumber = 0 - self.previousRn = None - self.keySigCurrent = None + # 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 self.foundAKeySignatureSoFar = False self.kCurrent, unused_prefixLyric = _getKeyAndPrefix('C') # default if none defined @@ -365,17 +376,18 @@ 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 + # 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 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 +405,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 +421,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 +475,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 +538,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:: @@ -583,7 +598,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 +608,10 @@ def fillToMeasureToken(self, measureToken: rtObjects.RTMeasure): mFill = stream.Measure() mFill.number = i self.fillMeasureFromPreviousRn(mFill) - appendMeasureToRepeatEndingsDict(self.lastMeasureToken, - mFill, - self.repeatEndings, i) + appendMeasureToRepeatEndingsDict( + self.lastMeasureToken, + mFill, + self.repeatEndings, i) p.coreAppend(mFill) self.lastMeasureNumber = measureToken.number[0] - 1 self.lastMeasureToken = measureToken @@ -615,7 +631,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 +679,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. @@ -692,10 +708,11 @@ def translateSingleMeasure(self, measureToken): 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: - self.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 @@ -715,6 +732,7 @@ def translateSingleMeasureAtom( Uses coreInsert and coreAppend methods, so must have `m.coreElementsChanged()` called afterward. ''' + currentMeasureToken = self.currentMeasureToken if (isinstance(a, rtObjects.RTKey) or (self.foundAKeySignatureSoFar is False and isinstance(a, rtObjects.RTAnalyticKey))): @@ -732,7 +750,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,8 +771,11 @@ 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 + # 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): @@ -789,7 +810,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 +863,7 @@ def processRTChord( ''' Process a single RTChord atom. ''' + currentMeasureToken = self.currentMeasureToken # use source to evaluation roman self.tsAtTimeOfLastChord = self.tsCurrent try: @@ -905,7 +927,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 +937,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 = 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 +998,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 +1051,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 +1074,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 +1101,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 +1122,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 +1189,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 +1209,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 +1231,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 +1245,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 +1405,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 +1722,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 @@ -1695,9 +1732,42 @@ def testCopyEmptyMeasures(self) -> None: m4-5 = m1-2 ''') s = converter.parse(empty_measures_with_copy, format='romanText') - assert s.duration.quarterLength == 10 - - def testRepeats(self) -> None: + self.assertEqual(s.duration.quarterLength, 10) + + def testCopySingleMeasureTargetNotFound(self) -> None: + # 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: + # 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 testLeadingMeasureWithoutChord(self) -> None: + # 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 + 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 def _repeat_tester( diff --git a/music21/romanText/tsvConverter.py b/music21/romanText/tsvConverter.py index 0fc61463d..6ec96eed5 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 431f36486..132564fce 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 @@ -619,9 +620,9 @@ def testRepeats(self): ''') 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): + 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)