Skip to content

Properties use decorators throughout - #2012

Merged
mscuthbert merged 2 commits into
masterfrom
property_to_decorator
Aug 28, 2026
Merged

Properties use decorators throughout#2012
mscuthbert merged 2 commits into
masterfrom
property_to_decorator

Conversation

@mscuthbert

Copy link
Copy Markdown
Member

short story - nearly all remaining x = property(_getX, _setX, doc=...) become @property def x / @x.setter.

Music21's been around for a while -- first supporting Python 2.2 which defined properties as:

def _getDuration(self):
    ...

def _setDuration(self, value):
    ...

duration = property(_getDuration, _setDuration, doc='''get or set Duration''')

Since all the way since Python 2.4 it's been possible to define properties as:

@property
def duration(self):
    '''get or set Duration'''
    ...

@duration.setter
def duration(self, value):
    ....

so when 2.4 became m21 minimum we started slowly using the second form. But music21 also uses doctests extensively. The problem was that while the old-style x = property(_get, _set) reported line numbers of failing doctests properly, the new-style @property did not. So debugging music21 was much harder with the new version. I pushed hard for a fix which finally as a Christmas 2023 present Serhiy Storchaka pushed through got a simpler solution into Python 3.13 (backported to 3.12.3 and 3.11.9) so it's finally available on basically all Python systems music21 runs on.

But like sitting asbestos, there was no need harm from old code so stirring up the pot and changing properties that already worked in music21 wasn't a priority. (Plus the new code is inelegant...note below.)

But in the past year or so, two things have changed:

  1. agentic coding made this type of "almost-but-not-quite-simple-find-and-replace" so much easier.
  2. type checking became so much more important, and mypy does not do as well with inferring types from property()

So it's worth a change. 60ish properties across 25 modules are refactored. Left along some places where the _get() or _set() form does double duty (such as having a second argument), and braille test's cool write-only properties.

Agent found 19 possible errors once mypy got their types. Mostly harmless (like setting stream.metadata = Metadata(); stream.metadata.title = ... getting a stream.metadata might be None) or type-only problems. (We did well there!)

AI-assisted (Claude)

Property ugliness

Not that anyone cares what I think, and probably too late/ship was sailed, unless we use a new term like @prop but the duplication of the function name in the setter is so ugly. Why not instead of

@property
def duration(self):
    '''get or set Duration'''
    ...

@duration.setter   # duration named here with funny syntax
def duration(self, value):  # and then named again here!
    ....

just do this:

@property
def duration(self):
    '''get or set Duration'''
    ...

@property
def duration(self, value):
    ....

And have in the basic case that one-argument = getter, and two-arguments = setter, and lookup is by matching name in the parsing. (The same type of magic as allowing super().__init__() instead of super(self, Duration).__init() in Py3)

Yes, there are some cases where you want to define just the setter alone or the thing you are setting has a different name etc. but there's a wonderful property() function for those strange cases!

Roughly sixty properties across 25 modules move from the old
`x = property(_getX, _setX, doc=...)` form to `@property` / `@x.setter`.
The doc= text becomes the getter's docstring; internal callers of the
private helpers (Stream.finalBarline recursing into parts, Measure's
barline setters, Music21Object.purgeOrphans) now go through the property.

Left as-is where property() is doing something a decorator cannot:

- key.sharps: being handled on the sharps-int branch.
- tempo.MetronomeMark .text/.number: the setters take an extra optional
  keyword, and _updateTextFromNumber() calls _setText with it.
- scale .pitches/.chord: built from the public getPitches()/getChord(),
  which take arguments and are also called directly.
- Stream.secondsMap: _getSecondsMap takes arguments.
- braille/test.py: write-only property(fset=...).

Annotating the getters made their types visible to mypy for the first
time, which surfaced nineteen latent errors, all fixed here:

- Stream.metadata is Metadata|None, so tsvConverter, clercqTemperley and
  humdrum.spineParser were dereferencing a possible None. Each now holds
  the Metadata it just inserted in a local.
- mixedNumeral took numbers.Real, which mypy accepts for neither float
  nor Fraction; it now takes int|float|Fraction.
- Metadata.fileNumber's setter claimed str while converter passes int|None.
- meter.bestTimeSignature rebound the int `numerator` to a float mid-loop.
- Duration.consolidate's currentTupletDuration needed OffsetQL, not float.
- xmlToM21 assigned RepeatBracket.number from a MusicXML attribute that
  may be absent; None reached the setter and was caught by the except
  clause, which is now unnecessary for that case.

AI-assisted (Claude)
@coveralls

Copy link
Copy Markdown

Coverage Status

coverage: 93.331%. remained the same — property_to_decorator into master

@mscuthbert
mscuthbert merged commit d051f42 into master Aug 28, 2026
7 checks passed
@mscuthbert
mscuthbert deleted the property_to_decorator branch August 28, 2026 20:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants