Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .agents/skills/writing-docs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ So: a fix for a crash on an edge case, a check that some input no longer
produces invalid output, an assertion tied to an issue number — unittest. An
example a user would want to read — doctest.

The trap is the one-line `Traceback` example proving that bad input now raises.
It looks like documentation and costs almost nothing to add, but the input comes
from the bug rather than from anything a user would write, so it teaches nothing
while making an obscure corner one of the first things a reader meets. It is a
unittest.

Naming the guarded bug **is** appropriate in a unittest; that is what the test
is for. The rule against narrating old bugs applies to docstrings and to
comments in shipping code, not to tests.
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@
if on a single core machine.)
- Run `uv run ruff check music21` before making PRs or pushes to open PRs.
- Run `uv run mypy music21` before making PRs or pushes to open PRs.
- **Regression cases go in the module's `Test(unittest.TestCase)` class — never in a
docstring.** This is absolute, and it covers the tempting one-liner showing that some
bad input now raises. A doctest sits in the most-read documentation the project has,
so an example built from input no one would ever write teaches nothing and puts an
obscure bug on a billboard. The test: would a first-time reader of this object want
this example? If no, it is a unittest. See the `writing-docs` skill.
- Never commit `forceSource=True` to a test or doctest (it re-parses from source every
run and slows the suite for everyone). The ONLY exception is the one test that exercises
`forceSource` itself. If you hit a stale-parse problem while developing:
Expand Down
21 changes: 14 additions & 7 deletions music21/roman.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
# -----------------------------------------------------------------------------


SHORTHAND_RE = re.compile(r'#*-*b*o*[1-9xyz]')
SHORTHAND_RE = re.compile(r'#*-*b*o*[1-9⑪⑬⑮]')
ENDWITHFLAT_RE = re.compile(r'[b\-]$')

# cache all Key/Scale objects created or passed in; re-use
Expand Down Expand Up @@ -232,18 +232,16 @@ def expandShortHand(shorthand):
shorthand = shorthand.replace('/', '') # this line actually seems unnecessary.
if ENDWITHFLAT_RE.match(shorthand):
shorthand += '3'
shorthand = re.sub('11', 'x', shorthand)
shorthand = re.sub('13', 'y', shorthand)
shorthand = re.sub('15', 'z', shorthand)
# single characters for the two-digit figures, so that each group
# SHORTHAND_RE finds is one figure.
shorthand = shorthand.replace('11', '⑪').replace('13', '⑬').replace('15', '⑮')
shorthandGroups = SHORTHAND_RE.findall(shorthand)
if len(shorthandGroups) == 1 and shorthandGroups[0].endswith('3'):
shorthandGroups = ['5', shorthandGroups[0]]

shGroupOut = []
for sh in shorthandGroups:
sh = re.sub('x', '11', sh)
sh = re.sub('y', '13', sh)
sh = re.sub('z', '15', sh)
sh = sh.replace('⑪', '11').replace('⑬', '13').replace('⑮', '15')
shGroupOut.append(sh)
return shGroupOut

Expand Down Expand Up @@ -2422,6 +2420,9 @@ def __init__(
if not all(char.isalnum() or char in '#°+-/[]' for char in figure):
# V, b, ø, no, etc. already covered by isalnum()
raise RomanNumeralException(f'Invalid figure: {figure}')
if any(char in 'xyz' for char in figure):
# no roman numeral figure contains these letters
raise RomanNumeralException(f'Invalid figure: {figure}')

# Store raw figure before calling setKeyOrScale:
self._figure = figure
Expand Down Expand Up @@ -4630,6 +4631,12 @@ def test_get_key_from_cache_mode(self):
self.assertEqual(_getKeyFromCache('C').mode, 'major')
self.assertEqual(_getKeyFromCache('c').mode, 'minor')

def testXYZAreNotFigures(self):
for fig in ('Ix', 'Iy', 'Iz', 'V7/ix'):
with self.subTest(figure=fig):
with self.assertRaises(RomanNumeralException):
RomanNumeral(fig, 'C')


class TestExternal(unittest.TestCase):
show = True
Expand Down
Loading