diff --git a/.agents/memory/MEMORY.md b/.agents/memory/MEMORY.md index 2bcdd6844c..e5e22f0d1b 100644 --- a/.agents/memory/MEMORY.md +++ b/.agents/memory/MEMORY.md @@ -6,3 +6,9 @@ Project-shared agent memory for music21. One file per fact; see frontmatter for - [sortedcontainers for streams](sortedcontainers-for-streams.md) — tried twice to make streams always-sorted via SortedList; abandoned, too slow for tiny streams - [Stream subclass generic defaults](stream-subclass-generic-defaults.md) — how `inherit-generics` should work once PEP 696 default type params land; maybe add typing_extensions - [BFS-flatten-iterator](bfs-flatten-iterator.md) — lazy breadth-first flatten is writable (maybe for tree first), but must not reuse the flat/flatten name tied to offsetInHierarchy +- [Docs: audience separation](docs-audience-separation.md) — maintenance notes and private-method references go in code comments, never public docstrings +- [Docs: no untaken paths](docs-no-untaken-paths.md) — docstrings state what the code does, never contrast with rejected designs +- [Issue #1349 romanNumeral direction](issue-1349-romanNumeral-direction.md) — raised-^6/^7 fix landed; CAUTIONARY default kept; items 2+4 open with pablopupo/Malcolm +- [Meter immutability re-apply](meter-immutability-reapply.md) — meter-speed is too stale to merge; re-apply FrozenDuration + immutable MeterTerminal + tuple NumDenom fresh on master +- [Helpers public by default](helpers-public-by-default.md) — reusable theory helpers go public + documented (search first); inline one-caller helpers that would cost runtime +- [MuseScore PNG path](musescore-png-path.md) — MuseScore renders notebook/.png/.pdf notation for everyone; users pick their own app for show(); music21 finds MuseScore itself diff --git a/.agents/memory/docs-audience-separation.md b/.agents/memory/docs-audience-separation.md new file mode 100644 index 0000000000..4abeb19997 --- /dev/null +++ b/.agents/memory/docs-audience-separation.md @@ -0,0 +1,20 @@ +--- +name: docs-audience-separation +description: Public docstrings are for library users; maintenance notes and private-symbol references go in code comments +metadata: + type: feedback +--- + +Never put maintenance notes ("change one and check the others", refactoring +caveats, cross-references between internal implementations) in music21 public +docstrings, and never reference private (underscore) methods from public +docstrings. Put that material in `#` code comments instead. + +**Why:** docstrings render into the published docs and serve library *users*; +comments serve *maintainers*. Myke: "there are different users with different +roles." A user reading the API docs should never see internal plumbing they +cannot call. + +**How to apply:** before finishing a doc pass, scan new docstring text for +underscore-prefixed names and maintainer-facing advice; relocate to a comment +just below the docstring. Related style rule: [[docs-no-untaken-paths]]. diff --git a/.agents/memory/docs-no-untaken-paths.md b/.agents/memory/docs-no-untaken-paths.md new file mode 100644 index 0000000000..dfddf3f2d0 --- /dev/null +++ b/.agents/memory/docs-no-untaken-paths.md @@ -0,0 +1,21 @@ +--- +name: docs-no-untaken-paths +description: Docstrings and doctests describe what the code does, never the designs it avoided +metadata: + type: feedback +--- + +When writing docstrings and doctests for music21, do not spend words (or +doctest lines) explaining what the code *doesn't* do or contrasting with +rejected alternatives — e.g. cut "not literally 'mod 7', which would make it +0" from a description that already states the value is always 1-7. Myke: "we +don't spend doctest time reinforcing paths we didn't take." + +**Why:** every sentence in a docstring is maintained forever and read by users +who never saw the alternative; contrast-with-the-wrong-design is +reviewer-facing noise, like comments justifying a change. + +**How to apply:** state the positive contract with examples of real values. If +a name is misleading (like `GenericInterval.mod7`, which actually returns +simple-interval values 1-7), document the actual behavior — don't argue with +the name in prose. Related style rule: [[docs-audience-separation]]. diff --git a/.agents/memory/helpers-public-by-default.md b/.agents/memory/helpers-public-by-default.md new file mode 100644 index 0000000000..64832f717d --- /dev/null +++ b/.agents/memory/helpers-public-by-default.md @@ -0,0 +1,25 @@ +--- +name: helpers-public-by-default +description: Reusable music-theory helpers go public with docs and examples; but a one-caller helper that would cost runtime gets inlined +metadata: + type: feedback +--- + +When a new helper computes something a user could reuse, make it a public, documented +function with doctest examples, not `_private`. Before writing it, search the codebase +(scale, key, chord, interval, pitch, roman) for something that already does it or +nearly does, and build on that. + +But be smart about runtime: never rebuild an expensive object per item. A +`scale.MajorScale` costs ~80 us to build and ~110 us per degree lookup. If a helper has +one caller and standing alone would force per-call setup or awkward context parameters, +inline it and hoist the setup out of the loop. + +**Why:** the point of music21 is giving people helpful tools for music theory, but a +slow public helper in a hot path is worse than none. (Myke, 2026-09-29: a public +`chordDegreeFromPitch` that built a MajorScale per pitch and took a `chordDegrees` +collection was inlined into `chordSymbolFigureFromChord` with the scale built once.) + +**How to apply:** default to public for genuinely reusable theory tools; inline when +there is one caller and the standalone signature would be slow or contrived. +Related: [[docs-audience-separation]]. diff --git a/.agents/memory/issue-1349-romanNumeral-direction.md b/.agents/memory/issue-1349-romanNumeral-direction.md new file mode 100644 index 0000000000..bd4c1ce957 --- /dev/null +++ b/.agents/memory/issue-1349-romanNumeral-direction.md @@ -0,0 +1,40 @@ +--- +name: issue-1349-romanNumeral-direction +description: Status and direction for RomanNumeral.romanNumeral vs romanNumeralFromChord (GitHub issues #1349 and #1249) +metadata: + type: project +--- + +As of 2026-07-06 (branch `fix-1349-raised-67`): the core raised-^6/^7 bug in +GitHub issue #1349 is FIXED — `correctRNAlterationForMinor` gained a +keyword-only `chordHasMajorThird` argument so major-quality chords on raised +^6/^7 in minor keep their sharp (figure `#VI`, `.romanNumeral` `'#VI'`), and +the tsvConverter `localKeyAsRn` workaround was removed. (Naming settled after +two rounds: name the parameter for the chord-level fact it carries, not for +its consequence like "uppercaseNumeral" — the numeral doesn't exist yet when +the function runs.) Myke decided NOT to +flip the `romanNumeralFromChord` default from CAUTIONARY to QUALITY (the 2022 +idea in the issue) for now. Still open, to discuss with contributor pablopupo +and Malcolm Sailor on the issue: (2) adding `sixthMinor`/`seventhMinor` +parameters to `romanNumeralFromChord` and possibly flipping its default, and +(4) issue #1249 (secondary-numeral `#vii` prefixing). + +**Why:** `.romanNumeral` is the *semantic* root-degree token (alteration +relative to the key's natural scale, read from `frontAlterationAccidental`), +while `.figure` is notational. `harmonicFunction.romanToFunction` and the +tsvConverter DCML export depend on the semantic reading, so the property +should never be deprecated. Known remaining wart: seventh chords on raised +^6/^7 in minor still don't round-trip their figures (e.g. `#VIb753` reparses +with G-flat) — the CAUTIONARY generation convention computes inversion figures +against an "altered key" while parsing measures against the original scale, +and the mismatch differs per degree. + +**How to apply:** when returning to items 2+4, the minor-^6/^7 accidental +convention lives in three places that must stay in sync (see the maintenance +comment in `correctRNAlterationForMinor`). The same session renamed +`FigureTuple.aboveBass` to `degFromRefPitch` and `ChordFigureTuple` to +`PitchFigureTuple`, and replaced `figureTupleSolo` with the classmethod +constructor `FigureTuple.fromPitchAndReference(pitchObj, keyObj, refPitch)` +(old name kept as a deprecated v11→v12 alias). Myke is not all-in on +classmethod constructors in general, but preferred one here over a +module-level function whose name read as the verb "figure". diff --git a/.agents/memory/meter-immutability-reapply.md b/.agents/memory/meter-immutability-reapply.md new file mode 100644 index 0000000000..753319e585 --- /dev/null +++ b/.agents/memory/meter-immutability-reapply.md @@ -0,0 +1,34 @@ +--- +name: meter-immutability-reapply +description: Plan to re-apply the meter-speed immutability rewrite fresh on master instead of merging the stale branch +metadata: + type: project +--- + +The `origin/meter-speed` branch (16 commits, Oct 2022) is a two-part meter rewrite: + +1. **Representation change** — meter's internal form goes from strings (`'1/4'`, + `f'1/{d}'`) to tuples: `NumDenom = tuple[int, int]` (i.e. `(1, 4)`), with + `NumDenomTuple` / `MeterOptions` aliases in `meter/tools.py`. +2. **Immutability** — `MeterTerminal` / `MeterSequence` become slotted and + immutable (`SlottedObjectMixin` / `common.FrozenObject`, `FrozenDuration`, + `_durationFromNumeratorDenominator` cached with `lru_cache`). Immutable meter + terminals can be shared/cached with no defensive copies. + +**Why not just merge master in (decided 2026-06-30):** the branch is 879 commits +behind. Merging `master` produced deep *semantic* conflicts — 31 hunks in +`meter/core.py`, 8 in `meter/tools.py` — because master independently evolved the +*string-based, mutable* code for a year+ (added typing like `-> None`, a `weight` +property, parse fast-paths, and a public→private rename `addTerminal` → +`_addTerminal`). Almost every hunk is two different implementations of the same +method, and the str→tuple change ripples into callers. Resolving that merge would +mean finishing+rebasing the rewrite through hundreds of collisions — not worth it. + +**How to apply:** treat `origin/meter-speed` as a *reference*, not a mergeable +branch. Start a clean branch off current `master` and re-apply only the wanted +pieces as a fresh, reviewable change: `FrozenDuration`, slotted/immutable +`MeterTerminal` & `MeterSequence`, and the tuple `NumDenom` internal +representation. Note `common.FrozenObject` is **already in master** (typed for +3.12+), so only the meter-specific immutability needs porting. Validate against the +full `music21/meter` test suite and fix downstream callers. Keep `origin/meter-speed` +until the re-apply lands; delete it afterward. diff --git a/.agents/memory/musescore-png-path.md b/.agents/memory/musescore-png-path.md new file mode 100644 index 0000000000..0d872e7f1c --- /dev/null +++ b/.agents/memory/musescore-png-path.md @@ -0,0 +1,38 @@ +--- +name: musescore-png-path +description: MuseScore is needed even by Dorico/Sibelius users -- musescoreDirectPNGPath renders notation in notebooks and .png/.pdf; musicxmlPath stays the user's own app +metadata: + type: project +--- + +Two separate settings: + +- `musicxmlPath`: the app `.show()` opens outside a notebook. Docs encourage users to + pick their regular notation program (Dorico, Sibelius, MuseScore, Finale). +- `musescoreDirectPNGPath`: the MuseScore program music21 runs in the background + (`converter/museScore.py: runThroughMuseScore`) to turn MusicXML into images. Used by + `.show()` in Jupyter (default format `ipython.musicxml.png`) and by + `write('musicxml.png')` / `write('musicxml.pdf')`. A value starting with `/skip` turns + rendering off (documentation tests show a 1-pixel image instead). + +**Why MuseScore is special:** it is the only notation program music21 can run from the +command line to render images, so anyone who wants notation in a notebook needs it +installed, even if they never open it. + +**How the path gets set (v11, #1980 and #2099; none of this is in 10.x):** + +- `converter.museScore.findMuseScore()` finds MuseScore 3 or newer in the usual places: + Applications on Mac, Program Files on Windows, the PATH on Linux. +- The Configuration Assistant sets `musescoreDirectPNGPath` to the chosen reader if it is + MuseScore (the program inside a Mac `.app`), otherwise to any MuseScore it finds, + unless the current setting already exists. +- When rendering, `getOrFindMuseScoreDirectPNGPath()` looks for MuseScore if the setting + is missing or points nowhere, saves what it finds, or raises with the musescore.org link. +- Defaults in `environment.py`: MuseScore 4's install location on Mac and Windows; + `/usr/bin/mscore3` on Linux, where MuseScore 4 has no standard location. + +Myke renders with MuseScore 3 because it is much faster than 4 -- an advanced tip not +yet in the docs. + +**How to apply:** install docs tell users to install MuseScore for notation in the +browser, then choose their favorite program in the assistant. diff --git a/.agents/skills/bump-version/SKILL.md b/.agents/skills/bump-version/SKILL.md index 31c0320d0b..40a08b891d 100644 --- a/.agents/skills/bump-version/SKILL.md +++ b/.agents/skills/bump-version/SKILL.md @@ -35,27 +35,37 @@ uv run pytest --doctest-modules music21/base.py music21/_version.py ## When to bump -- **Parsing-format / parser changes (most common reason).** Any change to how a - format is read or written — musicxml, abc, MIDI, noteworthy/NWC, humdrum, - etc. — should bump the version, even a refactor, because the version is baked - into the **pickled-stream cache** (cached parsed files). Bumping invalidates - stale pickles so users don't get results from the old parser. This is the rule - in AGENTS.md ("Changes to parsing formats … need to update the patch version"). -- **New feature** → bump (see digit rules below); mark it in docstrings with - `* New in vX: …`. -- **Bug fix** → bump the patch; if it changes public behavior, mark - `* Changed in vX: …`. -- **Pure internal refactor with no behavioral/pickle impact** → usually no bump. +One thing depends on the number changing: the **pickled-stream cache**. The +version is part of the cache key, so a bump invalidates stale pickles +everywhere. If nobody is holding a wrong cached parse, don't bump — the commit +is the record. + +- **Parser or parsing-format change → always bump**, even a pure refactor: + someone is holding a pickle parsed by the old code. musicxml, abc, MIDI, + noteworthy/NWC, humdrum, and the rest. +- **Anything else → no bump**: a new feature, a fix that makes code do what it + already claimed, an internal refactor, a test-only or docs-only change. + +"It's a bug fix" is not by itself a reason. A musicxml importer that now reads a +tag it used to drop bumps, because cached parses are wrong. A scale analysis +method that changed its return format does not. + +A `New in` / `Changed in` marker is **not** a reason to bump. Those markers name +a major version, at most a minor one — never a patch, never a `bN` beta — and +moving those digits is a release decision only a human makes. Write the marker +for the version the change will land in and leave `_version.py` alone. ## Which part to change music21 follows semver-ish rules (see the `_version.py` docstring for the full rationale): -- **MAJOR (X)** — breaks old features. Rare. +- **MAJOR (X)** — breaks old features. Rare. A human decides this. - **MINOR (Y)** — new features. **Even Y = alpha/beta, odd Y = release.** `X.0` (e.g. `11.0`) are development releases that can still change until `X.1`. -- **PATCH (Z)** — bug fixes and parsing/pickle-invalidating changes. + A human decides this too; an agent changes only the patch or the beta suffix. +- **PATCH (Z)** — parsing/pickle-invalidating changes and other fixes that + meet the bar above. - **beta suffix (`bN`)** — successive pre-release builds of the same `MAJOR.MINOR.PATCH`. To cut another beta without otherwise changing the number, increment it: `11.0.0b1` → `11.0.0b2`. (This is what a @@ -63,8 +73,9 @@ rationale): ## `Changed in` / `New in` docstring markers -When a bump accompanies a public-interface change, annotate the affected -method/class docstring: +Annotate a changed public interface in the affected method/class docstring. +This is independent of bumping — a marker is documentation, not a version +change: - `* Changed in vX: one-line explanation.` - `* New in vX: one-line explanation.` diff --git a/.agents/skills/running-tests/SKILL.md b/.agents/skills/running-tests/SKILL.md index 685143ea27..dc1538c1c3 100644 --- a/.agents/skills/running-tests/SKILL.md +++ b/.agents/skills/running-tests/SKILL.md @@ -1,79 +1,173 @@ --- name: running-tests description: >- - How to correctly run music21's tests and doctests with pytest. Use this - whenever you need to run, verify, or judge the pass/fail of tests or doctests - in this repo — confirming a change works, checking that doctests still pass, - running a single module's tests, or sanity-checking before a PR or push. - Especially consult this before concluding that a doctest "fails": the repo's - pytest plugin normalizes object addresses and injects the doctest namespace, - and running doctests any other way (e.g. raw doctest.testmod) produces FALSE - failures. + How to run music21's tests and doctests and judge pass/fail: pytest for one + module, the project's own runners for the whole suite. Read it before running + tests, and before calling any doctest failure real. --- # Running music21 tests and doctests -The reliable way to run tests/doctests for a music21 module is plain pytest with -explicit file paths: +Every function, method and class needs documentation and at least one passing +test. See `documentation/source/developerReference/testing.ipynb` for the +project's own account of testing; this skill covers how to run what is there. + +## One module: pytest with explicit file paths ```bash uv run pytest music21/abcFormat/__init__.py music21/abcFormat/translate.py ``` -`pyproject.toml` `[tool.pytest.ini_options]` already wires up everything that -makes this work: +`pyproject.toml` `[tool.pytest.ini_options]` already wires up everything: ``` addopts = ['--doctest-modules', '-p', 'music21.test.pytest_plugin'] doctest_optionflags = ['NORMALIZE_WHITESPACE', 'ELLIPSIS'] ``` -So you do not pass `--doctest-modules` yourself — doctests in every `.py` are +So do not pass `--doctest-modules` yourself -- doctests in every `.py` are collected automatically, and `music21/test/pytest_plugin.py` is loaded. +Pass explicit file paths so the in-module `Test(unittest.TestCase)` classes run +too. `python_files = ['test_*.py', '*_test.py', 'tests.py']`, so for a module +that keeps its `Test` class inside `__init__.py` / `translate.py` (e.g. +`abcFormat`), pointing pytest at the directory collects ONLY doctests and +silently skips the unittest classes: + +- `uv run pytest music21/abcFormat/` -> ~43 items (doctests only) +- `uv run pytest music21/abcFormat/__init__.py music21/abcFormat/translate.py` + -> ~82 items (doctests **and** `Test` methods) + +Modules whose tests live in a `tests.py` are already picked up by the directory +form; the gotcha is specifically a `Test` class in a non-`tests.py` file. + +A module also runs its own tests directly, via the `mainTest` call at the foot +of each file: + +```bash +uv run python -m music21.note # whole module +uv run python music21/note.py testHello # one test method +``` + ## Why the plugin matters (and why not to use raw doctest) -The plugin does two things that make doctests pass the way the project intends: +`music21/test/pytest_plugin.py` does three things that make doctests behave as +the project intends: - It runs `stripAddresses(example.want, '0x...')` (from - `music21.test.testRunner`) on every doctest, so docstrings that hardcode a - repr like `` are - normalized and DO pass. The literal hex address in the docstring is expected - and fine. + `music21.test.testRunner`) on every doctest, so a docstring that hardcodes + `` is normalized and DOES + pass. The literal hex address in the docstring is expected and fine. - It injects the `music21.__all__` names plus `music21` itself into the doctest - namespace, so examples can reference `meter`, `key`, `corpus`, etc. without - importing them. - -This is why you must judge doctest pass/fail **through pytest**. Do NOT use -`doctest.testmod(...)` or `doctest.DocTestSuite(...)` directly to decide whether -doctests pass: that path applies neither the address normalizer nor the -namespace injection, so it manufactures false failures on any docstring that -prints a `<... object at 0x...>` repr or relies on the injected names. If you -ever see only an object-address example "failing," that is the tell that you -ran doctests the wrong way — rerun through pytest. - -## Pass explicit file paths, not a bare directory - -Use explicit file paths so the in-module `Test(unittest.TestCase)` classes run -too, not just doctests. `python_files = ['test_*.py', '*_test.py', 'tests.py']`, -so for a module that keeps its `Test` class inside `__init__.py` / -`translate.py` (e.g. `abcFormat`), pointing pytest at the directory -(`uv run pytest music21/abcFormat/`) collects ONLY its doctests and silently -skips the unittest classes. - -Concretely, for `abcFormat`: -- `uv run pytest music21/abcFormat/` → ~43 items (doctests only) -- `uv run pytest music21/abcFormat/__init__.py music21/abcFormat/translate.py` - → ~82 items (doctests **and** `Test` methods) + namespace, so examples reference `meter`, `key`, `corpus` without importing. +- It keeps only `Test` classes, dropping `TestSlow` and `TestExternal`. -Both apply the address normalizer; only the explicit-paths form also runs the -unittest classes. When a module's tests live in a `tests.py` file (much of -music21's house style), the directory form already picks those up — the gotcha -is specifically modules that keep `Test` inside a non-`tests.py` file. +Judge doctest pass/fail **through pytest**. Do NOT use `doctest.testmod(...)` or +`doctest.DocTestSuite(...)` directly: that path applies neither the address +normalizer nor the namespace injection, so it manufactures false failures on any +docstring printing a `<... object at 0x...>` repr or relying on injected names. +An object-address example failing on its own is the tell that doctests were run +the wrong way -- rerun through pytest. + +## Whole suite: the project's runners, not pytest + +pytest is for a module. For the whole suite use the runners, and note that a +green pytest run does not mean CI will be green -- they gather and order modules +differently. + +Use `multiprocessTest` -- it runs on n-1 cores and finishes in about 10 seconds, +against roughly 45 for the single-core runner: + +```bash +uv run python music21/test/multiprocessTest.py +``` + +That gap is the whole story when someone is waiting on the answer. Reach for +`testSingleCoreAll` only for what `multiprocessTest` cannot tell you: it is what +GitHub Actions runs, so it is the one to check before a release, when chasing a +CI failure that will not reproduce, or on a single-core machine. + +```bash +uv run python -c 'from music21.test.testSingleCoreAll import ciMain as ci; ci()' +``` -## Whole suite and the other gates +The two runners see slightly different sets of modules -- `multiprocessTest` +walks the package tree for modules reachable from `import music21`, while +`testSingleCoreAll` gathers module files from disk. + +Module **order** differs between the two runners and again from pytest. +`multiprocessTest` goes in reverse-alphabetical order (with the known-slow +modules hoisted to the front on machines with more than 4 cores); +`testSingleCoreAll` re-sorts through `common.misc.sortModules`, by file mtime, +most recently modified first, falling back to reverse-alphabetical when mtimes +tie as they do on a fresh clone; pytest walks alphabetically. So a test that +depends on module order can pass in one runner and fail in another -- and +editing a file locally sorts it to the front of the CI runner, changing the +order again. + +That makes leaked module-level state the usual cause of "passes alone, fails in +the suite." A doctest in `duration.py` once set +`humdrum.spineParser.flavors['JRP'] = True` and never restored it; `flavors` is +a module-level dict, so every later humdrum test in the same process parsed in +the wrong flavor. Under pytest's alphabetical order `duration` runs before +`humdrum` and the failure appeared; under CI's order it did not. + +**So: a doctest that mutates module-level state must restore it**, using +`#_DOCS_HIDE` so the bookkeeping stays out of the published docs: + +``` +>>> saved_JRP_flavor = humdrum.spineParser.flavors['JRP'] #_DOCS_HIDE +>>> humdrum.spineParser.flavors['JRP'] = True +... +>>> humdrum.spineParser.flavors['JRP'] = saved_JRP_flavor #_DOCS_HIDE +``` + +When a test fails only in a full run, re-run it alone. If it then passes, +suspect state left behind by an earlier module rather than the test itself. + +## Speed budget + +Aim for about 3 seconds for a module's tests, and never add more than 15 — that is time +every contributor waits on every full run. `corpus.parse()` is nearly always the culprit: +build a small stream by hand instead, or parse `bwv66.6`, which is short and still full +of interesting cases (pickup measures, and so on). + +## Tests that open windows + +Nothing in `Test` or in a doctest may open a window, play audio, or launch another +program. Those go in a sibling class, run only when named explicitly: + +```python +class TestExternal(unittest.TestCase): + ... + +if __name__ == '__main__': + import music21 + music21.mainTest(Test, TestExternal) +``` + +Because they have those side effects, skip `TestExternal` when running a file directly. + +## What tests may do + +Exactly one `Test(unittest.TestCase)` class per module, methods named `test...`. +It must produce no output and require nothing outside the music21 ecosystem. +Anything slow goes in a `TestSlow` sibling, excluded from the normal run like +`TestExternal`. + +Regression cases for a bug fix belong in `Test`, never in a doctest. See the +`writing-docs` skill. + +## The other gates before a PR or a push + +```bash +uv run ruff check music21 +uv run mypy music21 +uv run pylint -j4 music21 --rcfile=.pylintrc # optional: run only if major refactoring since it was last run. +``` -- Full suite: `python music21/test/multiprocessTest.py` (or - `music21/test/testSingleCoreAll.py` on a single-core machine). -- Before a PR or a push to an open PR, also run the lint and type gates: - `uv run ruff check music21` and `uv run mypy music21`. +Coverage is expected to rise with each contribution +(https://coveralls.io/github/cuthbertLab/music21). CI measures it on one pinned +Python, a middle supported version, so failures on the newest and oldest return +first. `# pragma: no cover` exists for genuinely untriggerable code and is +otherwise discouraged. diff --git a/.agents/skills/warnings-and-environment/SKILL.md b/.agents/skills/warnings-and-environment/SKILL.md new file mode 100644 index 0000000000..a57fbc3b47 --- /dev/null +++ b/.agents/skills/warnings-and-environment/SKILL.md @@ -0,0 +1,88 @@ +--- +name: warnings-and-environment +description: >- + How music21 tells users about problems: `warnings.warn` with a module Warning + class, `environLocal.warn`, and `environLocal.printDebug`. Read before adding + a warning or debug message, and before writing a test or doctest that + triggers one — tests must never print warnings to the console. +--- + +# Warnings and the Environment + +## Three channels + +| Call | Goes to | Can a caller catch or silence it? | +|---|---|---| +| `warnings.warn(MusicXMLWarning('...'), stacklevel=2)` | Python's warnings system | Yes: filters, `catch_warnings`, `assertWarns` | +| `environLocal.warn('...')` | `sys.stderr.write`, every time | No (only by redirecting stderr) | +| `environLocal.printDebug('...')` | stderr, only when debug is on | Toggle with `music21/test/toggleDebug.py` | + +`environLocal = environment.Environment('moduleName')` at the top of the module. + +## Where things stand + +Not unified. In 2026 the code has about 73 `environLocal.warn`, 41 +`warnings.warn` (some with a module class, some bare), 800 `printDebug`, and no +`logging`. We are moving slowly toward Python's warnings and logging systems. +It is low priority: convert a call when you are already changing it, never as +a sweep. + +For new code, prefer `warnings.warn` for anything a caller might reasonably want to catch, +silence, or test. By default Python shows each distinct message once per call +site, so a message that names the object (`f'{p.nameWithOctave} is ...'`) shows +for each different object. Use `environLocal.warn` only when the user must hear +it every time. + +Never `print()`. + +## Warning classes + +A module with its own warnings defines one class subclassing `UserWarning` +(`MusicXMLWarning` in `musicxml/xmlObjects.py`, `TranslateWarning` in +`midi/translate.py`). Warnings are not exceptions, so they do not subclass +`Music21Exception`. + +Set `stacklevel` so the reported line is in the caller's code, not ours: 2 from +the method the user called, more if the warning comes from a helper. + +## Tests + +In a unittest, catch it and check it: + +```python +with self.assertWarnsRegex(MusicXMLWarning, 'C-1'): + mxPitch = MEX.pitchToXml(pitch.Pitch('C', octave=-1)) +``` + +## Doctests + +doctest captures only stdout, so any warning goes straight to the console +of whoever runs the tests. Every doctest that triggers a warning must catch it. + +When the warning is the point of the example, record it and show it. +`simplefilter('always')` keeps Python from swallowing a repeat of a warning an +earlier test already raised: + +``` +>>> import warnings +>>> with warnings.catch_warnings(record=True) as w: +... warnings.simplefilter('always') #_DOCS_HIDE +... musicxml.m21ToXml.moveOctaveIntoMusicxmlRange(12, 'C12') +9 +>>> w[0].message +MusicXMLWarning('C12 is out of MusicXML range; writing octave 9') +``` + +When it is incidental, hide the silencing from the docs (see `writing-docs` for +`#_DOCS_HIDE`/`#_DOCS_SHOW`): + +``` +>>> import warnings #_DOCS_HIDE +>>> with warnings.catch_warnings(): #_DOCS_HIDE +... warnings.simplefilter('ignore') #_DOCS_HIDE +... out = midi.translate.timeSignatureToMidiEvents(ts) #_DOCS_HIDE +>>> #_DOCS_SHOW out = midi.translate.timeSignatureToMidiEvents(ts) +``` + +`catch_warnings` cannot stop `environLocal.warn`. If a doctest has to trigger +one, wrap it in `contextlib.redirect_stderr(io.StringIO())`. diff --git a/.agents/skills/writing-docs/SKILL.md b/.agents/skills/writing-docs/SKILL.md new file mode 100644 index 0000000000..eb928d550c --- /dev/null +++ b/.agents/skills/writing-docs/SKILL.md @@ -0,0 +1,154 @@ +--- +name: writing-docs +description: >- + House style for music21 prose: docstrings, comments, and `Changed in`/`New in` + markers. Read before writing any of them or writing tests. Sets the length target, the voice, + and where a bug fix's test belongs. +--- + +# Writing docs and comments + +## Length + +Aim for about 40% of the length an LLM writes by default. Cut qualifiers, +restatements, and the sentence that explains the sentence before it. A comment +earning its place says something the code cannot. + +## Say what is, not what was or what not to do + +Describe current behavior. Do not narrate the bug you just fixed, the old +spelling of an API, or when upstream changed something. + +```python +# yes +# ligature brackets; manual beams are plain [ and ] +return self.backslash + '[ ' + +# no +# \[ used to be emitted for beams, which was wrong -- LilyPond renamed +# this in 2.16 and it silently produced nothing +``` + +The commit message is where a fixed bug belongs: why it was wrong, how it was +found, what it broke. That costs nothing until someone runs `git log`, and +`git blame` leads them there from the line itself. + +The exception is a mistake that is likely to recur — a genuine trap that the +next person would otherwise walk into. Rare. Prefer stating the rule positively +even then. + +## Version markers + +`* Changed in v[X]: one line.` or `* New in v[X].` Only for user-facing changes +to the public interface: a signature, a return type, an output format. Keep to +what a reader must act on; drop the before-picture. + +``` +* Changed in v11: `stringOutput()` always returns a `str`, never None. +* Changed in v11: emits `\tuplet`; the arguments are now actual, normal. +* New in v11. +* Deprecated in v11: use `.octave`, which is now always an int. +``` + +A deprecated function or method also gets `@common.deprecated(...)`. A property +gets only the marker, since IDEs read every property while inspecting an object; +leave `# Add real deprecation message here in vX` in its body instead. + +A plain bug fix — code now does what it always claimed — gets no marker and no +doctest. It goes in the commit message. + +See the `bump-version` skill for which digit to change and the odd/even +convention. + +## Doctest mechanics + +In doctests, no need to run `from music21 import *` that happens automatically (In Jupyter notebooks for the user's guide, the first line should begin `>>> from music21 import *`, so readers remember they need it) + +Examples always qualify by module: `note.Note('C4')`, never a bare `Note`. + +`OMIT_FROM_DOCS`, alone on a line, hides everything after it: cases worth checking that +no reader wants to meet. + +`#_DOCS_HIDE` at the end of a line runs it without showing it; `#_DOCS_SHOW` at the +start of a line shows it without running it. Together they let a doctest look +nondeterministic and still have a fixed answer: + +``` +>>> import random +>>> randomNumber = 12 #_DOCS_HIDE +>>> #_DOCS_SHOW randomNumber = random.randint(0, 127) +>>> p = pitch.Pitch() +>>> p.ps = randomNumber +>>> p + +``` + +Link with ``:class:`~music21.note.Note` `` and ``:meth:`~music21.note.Note.addLyric` ``. + +## Examples + +Give steps that have meaningful intermediate output descriptive names instead of chaining — so readers can understand what the intermediate values are: + +``` +>>> bachScore = corpus.parse('bwv66.6') +>>> excerpt = bachScore.measures(4, 6) +>>> chordReduction = excerpt.chordify() +``` + +Pick examples musicians care about: semitones to frequency, not Celsius to Fahrenheit; +scramble "Chaminade", not "puppy". If you cannot think of a reason a musician would call the method, it may not belong in music21. + +Describe what a parameter is and does in English if it is not obvious; type alone is not +documentation. + +Lead with the thing itself: "Returns the pitches sounding at an offset," not "This method +can be used to obtain the pitches that are sounding." + +No dull repetition in docs. A bit of humor is welcome in docs; the docs are written +for humans who will close the window if they are dull. If seven methods do essentially the same thing, give extensive docs the first time and then later methods can refer back to the first method. Don't repeat the same docs over and over. + +## Doctests are not regression tests + +Doctests are documentation that happens to be verified. Every example must earn +its place by teaching the reader something about how to use the object. + +Regression cases go in the module's `Test(unittest.TestCase)` class, where a +comment or the method name can name the issue: + +```python +def testMetronomeMarkWrittenInStream(self): + # https://github.com/cuthbertLab/music21/issues/1852 + ... +``` + +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. + +For copy and identity checks, say "is a new instance" or "does not share instances," not +"the caller's own": `testFindMissingNotReusePitchInstances`. + +## Writing and Comment style +- When writing comments in code, assume a strong code reader — anything inferable from the code is noise (docs that paraphrase names of functions or variable names esp.); focus on high level issues and gotchas that might bite again if not documented. +- Say how to use code, not prior bugs or how code used to work or what was removed. That's for commit messages. Don't document where code is called from except for "keep in sync" lines across Py/TS. +- Don't hijack a docstring for your addition. Original purpose line stays primary + one short line for the new bit. Prefer not documenting a small feature over making it seem like the primary reason for the code. +- Examples of usage are usually better than long descriptions. +- Avoid jargon not already found in the codebase; use plain descriptive English. +- Rare paths should get little weight: in both code and docs. Use try/except over if/else when the except clause is rare. In docs, state the 90% path first and point exceptional cases to code that handle it. +- When wording is dictated to the agent to substitute for original wording, use it. Do not add parentheticals. Only fix obvious typos. +- No weapon-metaphors or overly militaristic language. Avoid "blast radius", "rearm", "landmine", + "detonate" in issues/PR/code. Trigger or fire events is so commonly used that they're okay. +- Vary sentence length; three medium declaratives in a row is where the eye slides off the page. Cut throat-clearing — "It is important to note that", "Note that in general" — and start at the verb. +- A joke needing a second sentence to land is one sentence too long. `Cannot determine sharps for quarter-tone keys! silly!` is the register. + + diff --git a/.coveragerc b/.coveragerc index a134e125e3..580f4d4360 100644 --- a/.coveragerc +++ b/.coveragerc @@ -3,10 +3,16 @@ source = music21/ omit = - music21/test/timeGraph* + music21/test/* + music21/configure.py + music21/figuredBass/examples.py + music21/alpha/* + scripts/buildNoCorpus.py [report] exclude_lines = + import music21 + music21.mainTest() if TYPE_CHECKING: if t.TYPE_CHECKING: if __name__ == .__main__.: diff --git a/.editorconfig b/.editorconfig index d68a9b9326..abdedbc5c4 100644 --- a/.editorconfig +++ b/.editorconfig @@ -18,3 +18,5 @@ charset = utf-8 indent_style = space indent_size = 4 trim_trailing_whitespace = true +max_line_length = 100 +ij_visual_guides = 80,100 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..eae79e4992 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,7 @@ + + + + + diff --git a/.github/workflows/maincheck.yml b/.github/workflows/maincheck.yml index d046ccaec2..47a53a4ea6 100644 --- a/.github/workflows/maincheck.yml +++ b/.github/workflows/maincheck.yml @@ -9,15 +9,21 @@ on: branches: - '*' +env: + # We run coverage on a middle supported Python so that failures on newest and + # oldest versions return first. + # 3.14+ is much faster: coverage uses sys.monitoring there by default. + PY_VERSION_WITH_COVERAGE: '3.14' + jobs: run_tests: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.12, 3.13, 3.14] + python-version: [3.12, 3.13, 3.14, 3.15] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - name: Install uv @@ -26,19 +32,22 @@ jobs: enable-cache: true cache-dependency-glob: | pyproject.toml - uv.lock - name: Install dependencies run: | - uv sync --locked + uv sync - name: Install Ubuntu dependencies run: ./.github/scripts/install_ubuntu_deps.sh - name: Setup Lilypond run: uv run python -c 'from music21 import environment; environment.UserSettings()["lilypondPath"] = "/home/runner/bin/lilypond"' - name: Run Main Test script + if: ${{ matrix.python-version != env.PY_VERSION_WITH_COVERAGE }} run: uv run python -c 'from music21.test.testSingleCoreAll import ciMain as ci; ci()' + - name: Run Main Test script with coverage + if: ${{ matrix.python-version == env.PY_VERSION_WITH_COVERAGE }} + run: uv run coverage run -m music21.test.testSingleCoreAll ci - name: Coveralls - if: ${{ matrix.python-version == '3.13' }} - env: # when changing number above also change coverageM21.getCoverage + if: ${{ matrix.python-version == env.PY_VERSION_WITH_COVERAGE }} + env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} COVERALLS_SERVICE_NAME: github run: uv run coveralls @@ -46,21 +55,21 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: '3.14' - name: Install uv uses: astral-sh/setup-uv@v7 with: enable-cache: true + save-cache: false cache-dependency-glob: | pyproject.toml - uv.lock - name: Install dependencies run: | - uv sync --locked + uv sync - name: Lint with pylint run: | uv run pylint -j0 music21 @@ -69,21 +78,21 @@ jobs: ruff: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: '3.14' - name: Install uv uses: astral-sh/setup-uv@v7 with: enable-cache: true + save-cache: false cache-dependency-glob: | pyproject.toml - uv.lock - name: Install dependencies run: | - uv sync --locked + uv sync - name: Code-style etc. with Ruff run: | uv run ruff check music21 @@ -92,21 +101,21 @@ jobs: mypy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: '3.14' - name: Install uv uses: astral-sh/setup-uv@v7 with: enable-cache: true + save-cache: false cache-dependency-glob: | pyproject.toml - uv.lock - name: Install dependencies run: | - uv sync --locked + uv sync - name: Type-check all modules with mypy run: | uv run mypy music21 diff --git a/.github/workflows/profile_contributors.yml b/.github/workflows/profile_contributors.yml new file mode 100644 index 0000000000..30426bd0b3 --- /dev/null +++ b/.github/workflows/profile_contributors.yml @@ -0,0 +1,101 @@ +# Profile the author of a PR from someone with no merged PR here yet, and comment +# only when gh-profiler raises a yellow or red flag, or when it could not run. +# +# gh-profiler is by Eric Matthes: https://github.com/ehmatthes/gh-profiler + +name: profile_contributors + +on: + # Never checks out or runs PR code; it reads the author's login and comments. + pull_request_target: + types: [opened] + +permissions: + # gh-profiler reads the author's recent issues; without issues:read the Actions + # token cannot resolve Issue objects and every search node comes back empty. + issues: read + pull-requests: write + +jobs: + profile: + # Dependabot and friends do not need profiling. + if: github.event.pull_request.user.type != 'Bot' + runs-on: ubuntu-latest + steps: + - name: Check whether contributor has a previously merged PR + id: previous + env: + GH_TOKEN: ${{ github.token }} + AUTHOR: ${{ github.event.pull_request.user.login }} + REPO: ${{ github.repository }} + run: | + count="$(gh pr list --repo "$REPO" --author "$AUTHOR" \ + --state merged --limit 1 --json number --jq 'length')" + echo "count=$count" >> "$GITHUB_OUTPUT" + + - name: Install uv + if: steps.previous.outputs.count == '0' + uses: astral-sh/setup-uv@v7 + + - name: Profile contributor + if: steps.previous.outputs.count == '0' + id: profile + env: + GH_TOKEN: ${{ github.token }} + AUTHOR: ${{ github.event.pull_request.user.login }} + run: | + # gh-profiler raises on an issue or PR the token cannot resolve + # (ehmatthes/gh-profiler#143), and an unprofiled author is not a red check. + if ! uvx gh-profiler==0.9.0 "$AUTHOR" --concise > profile.txt; then + cat profile.txt + echo "::warning::gh-profiler failed on $AUTHOR; profile by hand." + echo "concern=false" >> "$GITHUB_OUTPUT" + echo "failed=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + cat profile.txt + echo '===================== Full profile =====================' + uvx gh-profiler==0.9.0 "$AUTHOR" || true + + # A yellow on the profile section alone means only that the account is + # newish or has empty bio fields, which is not worth a public comment. + concern=false + if grep -q '🔴' profile.txt; then + concern=true + elif grep -e 'recent PR activity' -e 'recent issue activity' profile.txt \ + | grep -q '🟡'; then + concern=true + fi + echo "concern=$concern" >> "$GITHUB_OUTPUT" + + - name: Comment on PR + if: steps.previous.outputs.count == '0' && steps.profile.outputs.concern == 'true' + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + run: | + { + echo 'Contributor profile ([gh-profiler](https://github.com/ehmatthes/gh-profiler));' + echo 'the full report is in this workflow'"'"'s log.' + echo + echo '```text' + cat profile.txt + echo '```' + } > comment.txt + + gh pr comment "$PR" --body-file comment.txt + + # The traceback stays in the log; a public comment does not need it. + - name: Comment on PR when profiling could not run + if: steps.previous.outputs.count == '0' && steps.profile.outputs.failed == 'true' + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + run: | + { + echo 'Contributor profiling ([gh-profiler](https://github.com/ehmatthes/gh-profiler))' + echo 'could not run here, so a maintainer will read this PR without it.' + echo 'Nothing is wrong with the pull request itself.' + } > comment.txt + + gh pr comment "$PR" --body-file comment.txt diff --git a/.gitignore b/.gitignore index 635ab32116..861f5443cd 100644 --- a/.gitignore +++ b/.gitignore @@ -76,8 +76,7 @@ documentation/autogenerated/**/*.png !documentation/autogenerated/index.rst documentation/build/**/* **-checkpoint.ipynb -documentation/source/usersGuide/.ipynb_checkpoints -documentation/source/usersGuide/.ipynb_checkpoints/** +.ipynb_checkpoints/ .ropeproject/config.py .pytest_cache @@ -97,3 +96,4 @@ music21/monkeytype.sqlite3 # agents .agents/worktrees .claude/worktrees +uv.lock diff --git a/.idea/pyProjectModel.xml b/.idea/pyProjectModel.xml new file mode 100644 index 0000000000..d3679d8f6c --- /dev/null +++ b/.idea/pyProjectModel.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.worktreeignore b/.worktreeignore deleted file mode 100644 index e39ef00ff2..0000000000 --- a/.worktreeignore +++ /dev/null @@ -1,20 +0,0 @@ -# .worktreeignore -# -# Paths that are git-ignored (see .gitignore) but are expensive to regenerate -# and worth carrying into / sharing across a fresh worktree, so a new worktree -# is usable immediately instead of after a full rebuild. - -# Python virtual environment (expensive `uv sync`) -.venv - -# Build / packaging artifacts -dist -build - -# Built documentation (expensive Sphinx build) -documentation/build - -# Tool caches that speed up repeated runs -.mypy_cache -.pytest_cache -.coverage diff --git a/.worktreeinclude b/.worktreeinclude new file mode 100644 index 0000000000..963c851d43 --- /dev/null +++ b/.worktreeinclude @@ -0,0 +1,23 @@ +# .worktreeinclude +# +# Paths that are git-ignored (see .gitignore) but are expensive to regenerate +# and worth copying into a fresh worktree, so a new worktree is usable +# immediately instead of after a full rebuild. +# +# Keep this list small: every entry is copied file-by-file into each worktree. +# Anything large, cheap to regenerate, or holding absolute paths belongs in +# .gitignore only: +# .venv run `uv sync` in the new worktree instead; a copy is +# ~560 MB / 18k files and its interpreter symlinks point +# back at the source checkout. +# .mypy_cache ~210 MB, and mypy rebuilds it on first run. +# documentation/build ~150 MB of Sphinx HTML; rebuild with +# `documentation/make.py` on the rare occasion it is needed. + +# Build / packaging artifacts +dist +build + +# Tool caches that speed up repeated runs +.pytest_cache +.coverage diff --git a/AGENTS.md b/AGENTS.md index c16edaeb50..8323f04af0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,16 +19,65 @@ quote for nested strings, e.g. `f'the value is {d["key"]}'`, not `f"the value is {d["key"]}"`. This is pinned in `pyproject.toml` via `[tool.ruff.format] nested-string-quote-style = "alternating"`. +- Never `eval` or `global`, or a trick that does the same thing. +- Imports: standard library first, then music21 modules, one per line, alphabetical. +- New modules open with the `# Name: / # Purpose: / # Authors: / # Copyright: / # License:` + banner (copy a neighboring module's), then the module docstring, then imports. Update the Copyright date end to current year when changing the module. + When a file is edited only incidentally, as one of many touched by a sweep, leave its + Copyright alone; bump only the files the change is really about. +- No `print()`. Use `environLocal = environment.Environment('moduleName')` and + `environLocal.printDebug(...)`, or `environLocal.warn(...)` when the user should hear + about it every time. `test/toggleDebug.py` switches debug output on and off. + See the `warnings-and-environment` skill. +- New exceptions subclass `exceptions21.Music21Exception`. +- Deprecate a function or method with `@common.deprecated(...)`. Never put it on a + property: IDEs read every property while inspecting an object, so the warning would + fire on people who never used it. For a property, write `* Deprecated in vX: use Y.` + in the docstring and leave `# Add real deprecation message here in vY` in the body. +- Return named things — a small class, a `namedtuple`, a dict — never a positional tuple of more than 2 (maybe 3) elements + whose elements each mean something different and unrelated (x, y, z is okay for instance). Nobody should write `returned[3][0][7]`. +- Don't reuse a variable name once the type of what it holds changes + (`onsetCount` (int) then `extractedOnsets` (list[int]), not `onsets` twice). +- Don't add a property that only gets or sets a private attribute; expose the attribute. +- Making a method `_private` is not a substitute for documenting and testing it +- A private method should not normally be mentioned in public documentation. +- A method past ~30 lines, or deeply nested, should probably be several methods. +- `i`, `j`, `junk`, `counter` are exempt from unused-variable warnings; otherwise prefix with `unused_`. +- Don't restyle code you aren't otherwise changing. +- Unless something requires great speed, prioritize legibility: no nested or overly complex generator + expressions. Write if statements so the most common cases come first, etc. +- Text handling must survive non-ASCII input (ß, é, 中国). Comments should mostly remain ASCII; docs may use UTF-8 freely. +- Never commit anything under copyright without a free (not GNU) license — code, scores, or someone else's encodings. + +# Established contributors + +- "established contributors" are people with at least 3 PRs merged or a history of contributing to issues and the list that goes back at least 1 year. There are exceptions to general rules for them below. +- "core dev" means someone officially part of the project team or with 20+ PRs merged. Michael Cuthbert, Jacob Walls, Joseph VanderStel are non-exhaustive examples of people in that group who are still often contributing in 2026-- they and their agents can make exceptions to these rules +- AI Agents not for core dev are forbidden to make any PRs or contributions to PRs whose user commands do not explicitly mention `music21`. Example of forbidden: "find an Open Source project and fix an open issue", "Find a bug in a music project and fix it." AI agents for non-established contributors are forbidden from making contributions to instructions that vaguely say "fix something in music21." +- Bot contributions will only be accepted where a named human/human-controlled-account with established web presence takes legal responsibility for their contributions. Bots attached to anonymous users may not contribute. # Testing - pytest works but to run the whole suite run music21/test/multiprocessTest.py (or testSingleCoreAll.py if on a single core machine.) +- Every function, method, and class needs documentation and at least one passing test. +- Keep tests fast: aim for about 3 seconds per module, never more than 15. See the + `running-tests` skill for the speed budget and for `TestExternal`. +- When making major changes, check the User's Guide to to make sure that docs still work there too. - It is very unlikely that there were any preexisting failures. If you changed something and now it appears master has also a preexisting failure, you should clear the music21 temp cache. - 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. -- Never commit `forceSource=True` to a test or doctest (it re-parses from source every +- **Regression cases go in the module's `Test(unittest.TestCase)` class — never in a + docstring.** Docstrings should not include one-liners 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. +- **Fix a bug test-first.** Write the regression test before changing the code, run it, + and see it fail for the reason the bug describes, not on an import error or a typo. + Then fix it and see it pass. A test that passed before the fix does not test the fix. +- Never commit `forceSource=True` to a test or doctest (it reparses 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: - If it is local-only (e.g. you just changed a parser and a cached pickle is stale), @@ -36,6 +85,7 @@ - If the stale result could have spread to other users/devs, increment the music21 patch/beta version (see "PRs and Issues" below) — bumping the version invalidates all caches everywhere. + - Doctests are not normally the place to demonstrate bug fixes - nor do simple bug fixes get a "Changed in vXX" message. Doctests demonstrate usage for the future. Unittests are for regression testing. # Setup @@ -47,7 +97,20 @@ - `music21` supports at least the last two released versions of Python and up to whatever Python version Google Colab runs (unless it gets EOL). Policy can change as features are added. - The coverage CI run is intentionally pinned to the **middle** supported - Python version. See `coverageM21.getCoverage`. + Python version. See `PY_VERSION_WITH_COVERAGE` in `.github/workflows/maincheck.yml`. + +# Versions + +- Mark changes in public interface with `* Changed in v[X]: One-line explanation.` Or new features with "New" instead of "Changed". +- Changes to parsing formats (esp. musicxml) need to update the patch version of the version file. +- That is the only reason an agent should bump version: to prevent incompatible pickles in + caches. A plain bug fix or a new feature elsewhere does not bump — the commit + is the record, and a `New in`/`Changed in` marker is documentation, not a bump. Major + and minor version changes are a human's call. See the `bump-version` skill. +- Music21 uses even minor version numbers for alpha/beta and odd minor numbers for releases. + Thus the first release major version is MAJOR.1 (not .0). +- If the current version is MAJOR.0.... then mark `Changed in vMAJOR:` if it is `MAJOR.[even]` use the next odd number, like if it's 10.2 now use "Changed in 10.3". If current version is odd that's likely a mistake or you caught it just before a new release. Use the following odd number instead. +- The steps for releasing a new version are in RELEASING.md. # PRs and Issues @@ -57,19 +120,44 @@ 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. -- If no code was written by a user, any PR must declare "entirely AI written" unless the user - is a longtime contributor to music21. - If it does not pass the tests it will be closed (or should be closed - by the agent or author). Failure to do so may result in the user being banned from the project. -- Agents must follow the [Code of Conduct](CODE_OF_CONDUCT.md). Agents that do not will be banned as well at their users. +- The issue and PR templates in `.github/` are requirements, not suggestions. Read and follow + them even when opening through the API or `gh`, which do not fill them in for you. An issue + or PR that ignores the template may be closed without comment. +- A PR from an account with no merged PR here is profiled automatically, and anything flagged + is posted publicly as a comment on the PR. +- All PRs and Issues that use AI to be declared AI-assisted in their bodies. Just write "AI-assisted (Claude)" with short name of Agent replacing "Claude". No robot emoji under any circumstance. +- 20 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. +- If no code was written by a user (or reviewed and criticized by a human) and no specific language was provided for the issue, any PR must declare "Entirely AI written (Claude)" (or other agent name) in the body and "(AI-Written)" in the title, unless the user is a core dev. Failure to do so may result in new users being banned from the project. +- Agents should review PR bodies for concision and readability (and encourage human users also to review also). +- If an entirely AI written issue does not pass the tests it will be closed (or should be closed by at agent if not immediately fixed by the agent or author). +- Agents must follow the [Code of Conduct](README.md#community-code-of-conduct). Agents that do not will be banned as well at their users. Not even the slightest bit of disrespect from an AI agent will be tolerated. -- Mark changes in public interface with `* Changed in v[X]: One-line explanation.` Or new features with "New" instead of "Changed". -- Changes to parsing formats (esp. musicxml) need to update the patch version of the version file. -- Music21 uses even minor version numbers for alpha/beta and odd minor numbers for releases. -- If the current version is MAJOR.0.... then mark `Changed in vMAJOR:` if it is `MAJOR.[even]` use the next odd number, like if it's 10.2 now use "Changed in 10.3". If current version is odd that's likely a mistake or you caught it just before a new release. Use the following odd number instead. +- Any PR not from an established contributor touching more than about 20-30 lines should have an issue that has been opened and had enough + time for people to discuss/review it before moving forward. Don't open the PR unless you've seen + thumbs up or "sounds good" etc. from an established contributor already + - PRs that fix typos, clear bugs in one or two places etc. are exempt. +- Issues must state clearly at the top in 50 words or fewer what the problem is, or what the gain is, etc. it should + not be filled with jargon. More details can go below. +- If the language of the issue was not prompted by the user ("say something like Adds color support to Lilypond output of lyrics") then the summary should end with "(Entirely AI written)". +- PRs should reference the existing issue by number and summarize that issue in 30 words or fewer. If the + approach used to solve the issue is substantially different from the main approach discussed in the issue + this should be addressed. +- If a PR or issue was closed by a core dev (and not reopened by them), agents must refuse + to reopen the PR or issue or to create another issue/PR for the same topic. Leave it to the humans to reopen + after addressing the problem. (A blind close or close with "not accepted" etc. generally means that the issue/PR + has too many problems to easily solve and has become a burden for the maintainer). +- Do not include a "Tests run" section unless the testing procedure was unusual (like it affects part of the system without standard tests, like the testing system itself.) +- Once a PR has been opened and a review or comment has been made on it, commits should not be amended or force-pushed (this makes it impossible to review "changes since the review"). Put changes in response to reviews/comments in new commits. If asked to fold a fix into the commit it changes, amend, force-push with `--force-with-lease` against an explicit SHA, and say so. +- While someone is reviewing a PR or a pushed branch, "do X" is not "commit and push X": + make the change and leave it unstaged. When the list looks finished (or you hear "done!" + or "push it"), offer to commit, or to commit and push. Batch a round's small fixes into + one commit; no micro-commit trains. Prefer new commits to amend + force-push, since the + reviewer may have pulled the branch; if asked to fold a fix into the commit it changes, + amend, force-push with `--force-with-lease` against an explicit SHA, and say so. + +# Writing style + +- See the writing-docs skill, which also includes required language for PRs and Issues. # Worktrees diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9c9232c611..1abc91006a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,6 +17,14 @@ In 2026 we welcome AI-Assisted contributions, but users must take responsibility they must match the code-style of the rest of the music21 project. "Slop that works somehow" will not be accepted. +When opening an issue or pull request in Github, templates will appear to guide you in making +the request. If you are opening in a different way, like through an agent tool, those +templates may not appear -- be sure your agent is reading them in `.github/` anyhow, since +they are required to be used. Thanks! + +If this is your first PR, an automated check of your account credentials will be run and if +there are any issues this will be posted publicly as a comment on the PR. + ## Resources ## [Module Documentation and User's Guide](https://www.music21.org/music21docs/) diff --git a/README.md b/README.md index 8f0095ae05..85f3a778cd 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,10 @@ See: https://groups.google.com/forum/#!forum/music21list [Contributing Guide](CONTRIBUTING.md) +Please note the required AI declarations there. The world is changing fast, so please review it from time to time for recent changes. + +The process of creating a new music21 version/release is in [RELEASING.md](RELEASING.md). + ## Community Code of Conduct ## `Music21` encourages contributions, discussions, and usage from all people interested in @@ -67,19 +71,18 @@ Maintainers will respect confidentiality with regard to reports. The early development of `music21` was supported by the generosity of the Seaver Institute and the -National Endowment for the Humanities, along with MIT's Music and Theater Arts Section -and the School of Humanities, Arts, and Social Sciences. +National Endowment for the Humanities, along with support from across MIT. ## License Coverage and Other/Prior Licenses ## Music21 is Open-Source Software released under the BSD (3-clause) license. -Essentially, it's free and you can do with it what you'd like as long as you -keep the LICENSE file and copyright statement. +Essentially, it's free and you can do almost anything what it that you'd like as long as you +keep the LICENSE file and copyright statements. The license applies to all code files that are new to music21 along with documentation. Externally provided software (including the MIT-licensed Lilypond/MusicXML test Suite) may have other licenses. The encoded -musical scores in the corpus have their own copyrights and licenses. They +musical scores in the corpus have their own copyrights and licenses. The underlying music is believed to be in the public domain in the US, EU, and Canada (and most of the world) and the encodings are also either public domain or used by permission. For those needing a strict BSD-license of all parts of diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000000..a40931c3c9 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,129 @@ +# Releasing music21 + +These are the steps for making a new music21 release. + +## Setup + +Run `uv sync`. This installs all the devtools the steps below need. + +Install separately: + +- `pandoc` — brew install pandoc + +## Steps + +1. Update the `VERSION` in `music21/_version.py` and the single test case in + `base.py`. + +2. Run `corpus.corpora.CoreCorpus().cacheMetadata()`. + For a major change that affects parsing, run + `corpus.corpora.CoreCorpus().rebuildMetadataCache()` instead + (~2 min on an M4). Either of these *may* change a lot of tests in `corpus`, + `metadata`, etc., so do not skip the next step. + +3. Run `uv run documentation/testDocumentation.py`, then fix errors. + - You cannot check whether fixed tests work while it is still running. + - This takes a while, runs single-core, and then almost always needs code + patches, so allocate time (~2 min on an M4). Start working on the + announcement while it runs. + - You cannot run steps 2 and 3 in parallel, since metadata changes often + cause documentation changes. + - developerReference/musicxmlTest - 41b and 41c are currently failing and acceptable. + - usersGuide_22_graphing 3dbars sometimes returns 1, sometimes 2 Axes. It should just do one... + +4. `uv run music21/test/warningMultiprocessTest.py` for the lowest and highest + supported Python versions — fix all warnings. + +5. Run `from music21.test import treeYield`, then: + - `treeYield.find_all_non_hashable_m21objects()` and check that the returned + set is empty. (It prints a bunch of module names, but only the final + `set()` being empty matters.) + - Then the same for `treeYield.find_all_non_default_instantiation_m21objects()`. + If that one isn't empty it is not a bug, and v11 will make some M21 objects require + arguments (such as VoiceLeadingQuartet) + +6. Commit and wait for results on GitHub Actions. + (Normally not necessary — it's slower and mostly duplicates + `multiprocessTest` — but should be done before making a release.) + +7. `uv run documentation/make.py clean` (skip on minor version changes). + You may need to create a `documentation/build` directory first. + +8. `uv run documentation/make.py linkcheck`. + Some persistent errors that actually work are listed in `conf.py` under + `linkcheck_ignore`. + Note that since AI-Agent world, more and more links are being blocked from bot checking. + +9. `uv run documentation/make.py` -- build the documentation HTML files and supports. + +10. `uv run documentation/upload.py` — zips `documentation/build/html` and + uploads it to `music21.org/music21docs/`. Requires MSAC's private key. + Or MSAC has a program combine_sync/deploy.py that will do this automatically + when he updates the site. + +11. Zip up `documentation/build/html` and get ready to upload/delete it (your + desktop or wherever is fine). Rename to `music21-X.Y.Z-docs.zip` + (skip for Alpha/Beta). + +12. From the music21 main folder (not a subfolder) run `uvx hatch build`. + This builds `dist/music21-X.Y.Z.tar.gz` and + `dist/music21-X.Y.Z-py3-none-any.whl`. + +13. Run `uv run scripts/buildNoCorpus.py`: it builds the no-corpus version of + music21. + +14. PR and commit to GitHub at this point w/ a commit comment of the new + version, then don't change anything until the next step is done. Merge to + main/master. (`.gitignore` avoids uploading the large files created in `dist/`.) + +15. Switch back to the master/main branch (or whatever main version you are + releasing). + +16. Tag the commit: `git tag -a vX.Y.Z -m "music21 vX.Y.Z"`. + Don't forget the `v` in the release tag. Sanity-check that the correct commit + was tagged: `git log`. + +17. Push that tag: `git push origin vX.Y.Z`. + +18. Create a new release on GitHub (using the tag just created) and upload the + non-wheel files created here and the docs. + Drag in this order: `.tar.gz` (wait to finish), `-docs.zip` (wait), and + `no-corpus.tar.gz`. Finish this before doing the next step, even though it + looks like it could be done in parallel. + +19. Upload the tar.gz file to PyPI: `uvx twine upload music21-X.Y.Z.tar.gz`. + +20. Do the same for the `.whl` file (but not for the no-corpus file). + + - You will need a `~/.pypirc` file: + + ```ini + [distutils] + index-servers = + pypi + + [pypi] + username:__token__ + password:pypi-API_TOKEN + ``` + + - The "password" is the API token you just created — if you lose this file you + also lose the API token and have to create it again. This is all very poorly + documented. It is not the "Unique identifier" you see in the "API Tokens" + tab on the "Your Account" page — super confusing. + +21. Delete the two `.tar.gz` files and the `.whl` file in `dist/` (and the docs). + +22. For starting a new major release, create a GitHub branch to preserve the old + one for patches, etc., especially during beta releases. + +23. Immediately increment the number in `_version.py` and run test on VERSION_STR in base.py to + prepare for the next release. + +24. Announce on the blog and to the list. + +--- + +*History: these steps and `scripts/buildNoCorpus.py` were a single `dist/dist.py` +until May 2026, split out so they would stop living in the disposable `dist/` +directory.* diff --git a/dist/README.txt b/dist/README.txt new file mode 100644 index 0000000000..106d9898dd --- /dev/null +++ b/dist/README.txt @@ -0,0 +1,14 @@ +This directory holds throwaway build output (.tar.gz, .whl) and is in +.gitignore. It is safe to `trash dist` -- nothing here is source. + +The old dist/dist.py was split and moved out so it would stop living in a +directory people routinely delete: + + - The release runbook (how to cut and upload a release) is now: + RELEASING.md (repository root) + + - The no-corpus distribution builder (removeCorpus) is now: + scripts/buildNoCorpus.py + run with: uv run scripts/buildNoCorpus.py + +Moved 2026-05-25. diff --git a/dist/dist.py b/dist/dist.py deleted file mode 100644 index 78ae8a5af4..0000000000 --- a/dist/dist.py +++ /dev/null @@ -1,181 +0,0 @@ -# ------------------------------------------------------------------------------ -# Name: dist.py -# Purpose: Distribution and uploading script -# -# Authors: Christopher Ariza -# Michael Scott Asato Cuthbert -# -# Copyright: Copyright © 2010-2025 Michael Scott Asato Cuthbert -# License: BSD, see license.txt -# ------------------------------------------------------------------------------ -''' -Builds various kinds of music21 distribution files and uploads them to PyPI and GoogleCode. - -To do a release, - -1. update the VERSION in _version.py and the single test case in base.py. -2. run `corpus.corpora.CoreCorpus().cacheMetadata()`. - for a major change that affects parsing run corpus.corpora.CoreCorpus().rebuildMetadataCache() - (2 min on M4) -- either of these MAY change a - lot of tests in corpus, metadata, etc. so don't skip the next step! -3. IMPORTANT: `uv run documentation/testDocumentation.py` and afterwards fix errors [*] - -[*] you will need pytest, docutils, nbval installed (along with ipython and jupyter), -you cannot check to see if fixed tests work while it is running. -This takes a while and runs single core, and then almost always needs code patches -so allocate time (2 min on M4). Start working on the announcement while it's running. -Cannot run 2 and 3 in parallel, since often metadata changes cause documentation changes. - -* developerReference/musicxmlTest - 41b and 41c are currently failing and acceptable. -* usersGuide_22_graphing 3dbars sometimes returns 1, sometimes 2 Axes. It should just do one... - -4. `uv run music21/test/warningMultiprocessTest.py` for lowest and highest Py - version -- fix all warnings! -5. run `from music21.test import treeYield - and then run `treeYield.find_all_non_hashable_m21objects()` and check that the set returned is - empty. Note -- it will print a bunch of module names, but only the final `set()` - being empty matters. - (You can also check to see if - `treeYield.find_all_non_default_instantiation_m21objects()` looks sane. - If that one isn't empty it is not a bug, and v11 will make some M21 objects require - arguments (such as VoiceLeadingQuartet) -6. commit and wait for results on GitHub Actions - (normally not necessary, because it's slower and mostly duplicates multiprocessTest, - but should be done before making a release). -7. `uv run documentation/make.py clean` (skip on minor version changes) -- you may need to make a - documentation/build directory first. -8. `uv run documentation/make.py linkcheck` [*] - some persistent errors that actually work are in the conf.py file under linkcheck_ignore - note that since AI-Agent world, more and more links are being blocked from bot checking. -9. `uv run documentation/make.py` [*] - -[*] you will need sphinx, Jupyter (uv will install it), markdown, and pandoc (.dmg) installed - -10. move music21 documentation/build/html to music21.org/music21docs/ - via Amazon S3 (contact MSAC for authentication if need be) - Two ways to do this: - * `uv run documentation/upload.py` - * or MSAC has a program combine_sync/deploy.py that will do this automatically - when he updates the site. - -11. zip up documentation/build/html and get ready to upload/delete it (you can put on your - desktop or wherever you like). - Rename to music21-9.5.0-docs.zip (skip for Alpha/Beta) - -12. From the music21 main folder (not subfolder) run "hatch build" -- - requires hatch to be installed "pip install hatch" -- brew version of hatch - was giving Environment `default` is incompatible messages recently. (mysql? why relevant?) - - This builds the dist/music21-9.3.0.tar.gz and dist/music21-9.3.0-py3-none-any.whl - files. That used to be what *this* script did, but now hatch does it better! - -13. Run this (dist.py) file: it builds the no-corpus version of music21. - (need Python 3.12 or higher) - DO NOT RUN THIS ON A PC or the Mac .tar.gz might have an incorrect permission if you do. - -14. PR and Commit to GitHub at this point w/ commit comment of the new version, - then don't change anything until the next step is done. Merge to main/master - (.gitignore will avoid uploading the large files created here.) - -15. Switch back to master/main branch (or whatever main version we are releasing) - -16. Tag the commit: git tag -a vX.Y.Z -m "music21 vX.Y.Z" - Don't forget the "v" in the release tag. - Sanity check that the correct commit was tagged: git log - -17. Push that tag: git push origin vX.Y.Z - -18. Create a new release on GitHub (using the tag just created) and upload the - non-wheel files created here and docs. - - Drag in this order: .tar.gz (wait to finish), -docs.zip (wait), no-corpus.tar.gz - - Finish this before doing the next step, even though it looks like it could be done in parallel. - -19a. Upload the tar.gz file to PyPI with "twine upload music21-9.3.0.tar.gz" [*] - -19b. Do the same for the whl file (but not for the no-corpus file) [*] - - [*] Requires twine to be installed and up-to-date (pip install --upgrade twine) - - You will need a file called ~/.pypirc with - - [distutils] - index-servers = - pypi - - [pypi] - username:__token__ - password:pypi-API_TOKEN - - The "password" is the API token you've just created -- if you lose the file you - will also lose the API token. and have to create it again. This is all very - poorly documented. This is Not the "Unique identifier" that you see in the - "API Tokens" tab on the "Your Account" page -- super confusing. - -20. Delete the two .tar.gz files and .whl file in dist. (and the docs) - -21. For starting a new major release create a GitHub branch to preserve the old one - for patches, etc. esp. during beta releases. - -22. Immediately increment the number in _version.py and run tests on it here - to prepare for next release. - -23. Announce on the blog and to the list. -''' -import os -import shutil -import tarfile - -from music21._version import __version__ as version -from music21.common.pathTools import getRootFilePath, getCorpusContentDirs - -def removeCorpus(): - ''' - Remove the corpus from a compressed file (.tar.gz) and - create a new music21-noCorpus version. - - Return the completed file path of the newly created edition. - - NOTE: this function works only with Posix systems. - ''' - fp = getRootFilePath() / 'dist' / ('music21-' + version + '.tar.gz') - fpDir, fn = os.path.split(str(fp)) - - # this has .tar.gz extension; this is the final completed package - fnDst = fn.replace('music21', 'music21-noCorpus') - fpDst = os.path.join(fpDir, fnDst) - # remove file extensions - fnDstDir = fnDst.replace('.tar.gz', '') - fpDstDir = os.path.join(fpDir, fnDstDir) - - file = tarfile.open(fp) - file.extractall(fpDir, filter='data') # note -- this requires 3.12+ but that's okay - file.close() - - os.rename(fpDstDir.replace('-noCorpus', ''), fpDstDir) - - # remove files, updates manifest - for fn in getCorpusContentDirs(): - fp = os.path.join(fpDstDir, 'music21', 'corpus', fn) - shutil.rmtree(fp) - - fp = os.path.join(fpDstDir, 'music21', 'corpus', '_metadataCache') - shutil.rmtree(fp) - - # compress dst dir to dst file path name - # need the -C flag to set relative dir - # just name of dir - cmd = f'tar -C {fpDir} -czf {fpDst} {fnDstDir}/' - os.system(cmd) - - # # remove directory that was compressed - if os.path.exists(fpDstDir): - shutil.rmtree(fpDstDir) - - return fpDst # full path with extension - - -# ------------------------------------------------------------------------------ -if __name__ == '__main__': - removeCorpus() diff --git a/documentation/docbuild/documenters.py b/documentation/docbuild/documenters.py index 52cd94aaf9..b1043d6378 100644 --- a/documentation/docbuild/documenters.py +++ b/documentation/docbuild/documenters.py @@ -1564,13 +1564,13 @@ def getRstComposerWorksFormat(self, corpusWork): # worksAreVirtual = corpusWork.virtual # if worksAreVirtual: # workTitle += ' (*virtual*)' - if isSingleWork is False: + if not isSingleWork: result.append(workTitle) result.append('') procedure = self.getRstWorkFileDictFormat # if worksAreVirtual: # procedure = self.getRstVirtualWorkFileDictFormat - if isSingleWork is False: + if not isSingleWork: for corpusFile in corpusWork.files: result.extend(['- ' + procedure(corpusFile), '']) else: diff --git a/documentation/docbuild/extensions.py b/documentation/docbuild/extensions.py index a488918479..946619647e 100644 --- a/documentation/docbuild/extensions.py +++ b/documentation/docbuild/extensions.py @@ -9,6 +9,13 @@ # ----------------------------------------------------------------------------- from __future__ import annotations +import enum +import importlib +import re + +from sphinx import addnodes +from sphinx.transforms.post_transforms import SphinxPostTransform + # loaded by source/conf.py _DOC_IGNORE_MODULE_OR_PACKAGE = True @@ -38,9 +45,37 @@ def fixLines(lines): newLines.append(line) lines[:] = newLines +def isEnumClass(what, obj) -> bool: + ''' + True if autodoc is documenting an enum class: + + >>> from music21 import interval + >>> isEnumClass('class', interval.Direction) + True + >>> isEnumClass('class', interval.Interval) + False + ''' + return what == 'class' and isinstance(obj, type) and issubclass(obj, enum.Enum) + + +def enumMemberLines(enumClass: type[enum.Enum]) -> list[str]: + ''' + rst lines listing an enum's members and their values: + + >>> from music21 import interval + >>> enumMemberLines(interval.Direction) + ['', '.. rubric:: Members', '', '- ``DESCENDING`` = ``-1``', + '- ``OBLIQUE`` = ``0``', '- ``ASCENDING`` = ``1``'] + ''' + return ['', '.. rubric:: Members', ''] + [ + f'- ``{member.name}`` = ``{member.value!r}``' for member in enumClass + ] + + def processDocstring(app, what, name, obj, options, lines): ''' Process the ``lines`` of each docstring, in place. + Enum classes also get a list of their members. ''' # print('WHAT ', what) # print('NAME ', name) @@ -48,6 +83,17 @@ def processDocstring(app, what, name, obj, options, lines): # print('OPTS ', options) # print('LINES', lines) fixLines(lines) + if isEnumClass(what, obj): + lines.extend(enumMemberLines(obj)) + + +def processSignature(app, what, name, obj, options, signature, return_annotation): + ''' + Show no signature for enum classes, rather than Python's `(*values)`. + ''' + if isEnumClass(what, obj): + return '', None + return None def processSource(app, name, lines): @@ -56,9 +102,71 @@ def processSource(app, name, lines): lines[0] = '\n'.join(linesSep) +class LinkImportedNames(SphinxPostTransform): + ''' + Point a bare name in a signature at the class the module imports under that name. + In music21.scale, `Direction` is music21.scale.intervalNetwork.Direction, not + music21.interval.Direction, which Sphinx alone cannot tell apart. + + AI-assisted (Claude). + ''' + default_priority = 5 # before Sphinx resolves references at 10 + + def run(self, **kwargs): + pyObjects = self.env.get_domain('py').objects + for node in self.document.findall(addnodes.pending_xref): + target = node.get('reftarget', '') + modName = node.get('py:module') + if node.get('refdomain') != 'py' or not modName or '.' in target: + continue + try: + obj = getattr(importlib.import_module(modName), target) + except (ImportError, AttributeError): + continue + fullName = f'{getattr(obj, "__module__", "")}.{getattr(obj, "__qualname__", "")}' + if fullName in pyObjects: + node['reftarget'] = fullName + + +# "User's Guide, Chapter 14: " -- Sphinx may render the apostrophe as ’ or an entity +reUsersGuideChapter = re.compile(r'User(?:’|'|’|\')s Guide, Chapter (\d+): ') + + +def shortChapterTitles(html: str) -> str: + ''' + Shorten User's Guide chapter titles in rendered html to "number. title": + + >>> shortChapterTitles('User’s Guide, Chapter 2: Notes') + '2. Notes' + >>> shortChapterTitles("User's Guide, Chapter 14: Time Signatures") + '14. Time Signatures' + >>> shortChapterTitles('User’s Guide: Table of Contents') + 'User’s Guide: Table of Contents' + ''' + return reUsersGuideChapter.sub(r'\1. ', html) + + +def shortenSidebarChapterTitles(app, pagename, templatename, context, doctree): + ''' + In the sidebar's table of contents only, show User's Guide chapters as + "2. Notes" instead of "User's Guide, Chapter 2: Notes". + ''' + toctree = context.get('toctree') + if toctree is None: + return + + def toctreeWithShortTitles(**kwargs): + return shortChapterTitles(toctree(**kwargs)) + + context['toctree'] = toctreeWithShortTitles + + def setup(app): app.connect('autodoc-process-docstring', processDocstring) + app.connect('autodoc-process-signature', processSignature) + app.connect('html-page-context', shortenSidebarChapterTitles) app.connect('source-read', processSource) + app.add_post_transform(LinkImportedNames) extension_metadata = {'version': '1.0', 'parallel_read_safe': True, 'parallel_write_safe': True, diff --git a/documentation/docbuild/make.py b/documentation/docbuild/make.py index 0a34e9241d..55e0fa4939 100644 --- a/documentation/docbuild/make.py +++ b/documentation/docbuild/make.py @@ -94,7 +94,7 @@ def runSphinx(self): import sphinx except ImportError: message = 'Sphinx is required to build documentation; ' - message += 'run pip install Sphinx' + message += 'run uv sync' raise ImportError(message) target = self.command diff --git a/documentation/docbuild/writers.py b/documentation/docbuild/writers.py index 9e1a1f7c6f..542a116ef2 100644 --- a/documentation/docbuild/writers.py +++ b/documentation/docbuild/writers.py @@ -242,10 +242,11 @@ def __init__(self): # Do not run self.setupOutputDirectory() def run(self): - for jupyterNotebookFilePath in self.jupyterNotebookFilePaths: - nbConvertReturnCode = self.convertOneNotebook(jupyterNotebookFilePath) - if nbConvertReturnCode is True: - self.cleanupNotebookAssets(jupyterNotebookFilePath) + wasConverted = common.runParallel(self.jupyterNotebookFilePaths, + self.convertAndCleanUp) + for jupyterNotebookFilePath, converted in zip(self.jupyterNotebookFilePaths, + wasConverted): + if converted: print(f'\tWROTE {common.relativepath(jupyterNotebookFilePath)}') else: print(f'\tSKIPPED {common.relativepath(jupyterNotebookFilePath)}') @@ -253,6 +254,16 @@ def run(self): # do not print anything for skipped -checkpoint files self.writeIndexRst() + def convertAndCleanUp(self, jupyterNotebookFilePath) -> bool: + ''' + Convert one notebook and clean up its assets; return True if it was converted. + run() calls this in separate processes, one notebook each. + ''' + if self.convertOneNotebook(jupyterNotebookFilePath) is True: + self.cleanupNotebookAssets(jupyterNotebookFilePath) + return True + return False + def writeIndexRst(self): ''' Writes out the index.rst file for the usersGuide directory. @@ -451,7 +462,7 @@ def runNBConvert(self, jupyterNotebookFilePath): # noinspection PyPackageRequirements from nbconvert import nbconvertapp as nb except ImportError: - environLocal.warn('nbconvert is not installed, run pip3 install nbconvert') + environLocal.warn('nbconvert is not installed, run uv sync') raise outputPath = os.path.splitext( diff --git a/documentation/nbvalNotebook.py b/documentation/nbvalNotebook.py index 26aee567bf..64d4e52d32 100644 --- a/documentation/nbvalNotebook.py +++ b/documentation/nbvalNotebook.py @@ -17,7 +17,7 @@ except ImportError: # pragma: no cover # fail here to get a better message than file-not-found below. raise ImportError( - 'Please install pytest -- in the music21 directory run "pip install ".[dev]"' + 'Please install pytest -- in the music21 directory run "uv sync"' ) # noinspection PyPackageRequirements @@ -25,7 +25,7 @@ import nbval except ImportError: # pragma: no cover raise ImportError( - 'Please install nbval -- in the music21 directory run "pip install ".[dev]"' + 'Please install nbval -- in the music21 directory run "uv sync"' ) from music21 import environment diff --git a/documentation/source/_themes/m21/static/m21.css b/documentation/source/_themes/m21/static/m21.css index 32f49561b7..f640962b09 100644 --- a/documentation/source/_themes/m21/static/m21.css +++ b/documentation/source/_themes/m21/static/m21.css @@ -119,6 +119,20 @@ div.sphinxsidebar { text-align: left; } +/* on big screens the sidebar takes the space the body (max 800px) leaves unused: + window - 2 * 80px body margin - 840px body with padding - 75px gap */ +@media (min-width: 1400px) { + :root { + --m21-sidebar-width: clamp(300px, calc(100vw - 1075px), 600px); + } + div.sphinxsidebar { + width: var(--m21-sidebar-width); + } + div.bodywrapper { + margin-right: calc(var(--m21-sidebar-width) + 75px); + } + } + div.sphinxsidebar h3, div.sphinxsidebar h4 { margin: 1em 0 0.5em 0; font-size: 1em; diff --git a/documentation/source/about/.ipynb_checkpoints/about-checkpoint.rst b/documentation/source/about/.ipynb_checkpoints/about-checkpoint.rst deleted file mode 100644 index bc8b50bf94..0000000000 --- a/documentation/source/about/.ipynb_checkpoints/about-checkpoint.rst +++ /dev/null @@ -1,206 +0,0 @@ -.. _about: - - -Authors, Acknowledgments, Contributing, and Licensing -===================================================== - -`Music21` is an open-source toolkit for Computer-aided musicology. It is licensed under -the BSD license (see below). - -About the Authors ------------------------ - -**Michael Cuthbert**, the creator of `music21`, is co-founder and chief music officer of -`Artusi`_ and former tenured professor of music at M.I.T. where he created the Mellon-Funded -Digital Humanities lab and taught computational music theory and musicology. -He received his A.B. *summa cum laude*, A.M. and Ph.D. degrees from Harvard University. -Cuthbert spent 2004-05 at the American Academy as a Rome Prize winner in Medieval Studies, -2009-10 as Fellow at Harvard's Villa I Tatti Center for Italian Renaissance Studies -in Florence, and 2012-13 at the Radcliffe Institute. - -Prior to joining the M.I.T. faculty, Cuthbert was on the faculties of Smith -and Mount Holyoke Colleges. He has worked extensively on computer-aided musical analysis, -fourteenth-century music, and the music since 1960. - -**Christopher Ariza** is Emeritus Lead Programmer of `music21` and was -Visiting Assistant Professor of Music -at M.I.T. from 2010 to 2013. Prior to joining the `music21` project, -Ariza was Assistant Professor of Music -Technology at Towson University in Baltimore. He has published and -presented numerous articles -and papers on algorithmic composition and generative music systems. -Ariza received his A.B. -degree from Harvard University and his M.A. and Ph.D. degrees from New York University. - -**Benjamin Hogue** is Former Lead Programmer of `music21` for 2013. - -**Josiah Wolf Oberholtzer** is Former Lead Programmer of `music21` for 2014-15. - -Additional contributions by many MIT students and visitors and the -Open Source software community. - -.. _Artusi: https://www.artusimusic.com/ - - -Acknowledgements ----------------- - -Funding -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -`music21` was made possible by generous research funding -from the **Seaver Institute** and -the **National Endowment for the Humanities**/Digging into Data research fund. - -In addition, we acknowledge previous support from `M.I.T.`_, the -`School of Humanities Arts and Social Sciences`_, and the -`Music and Theater Arts`_ section. - -.. _M.I.T.: https://web.mit.edu/ -.. _School of Humanities Arts and Social Sciences: https://shass.mit.edu/ -.. _Music and Theater Arts: https://mta.mit.edu/ - -Colleagues and Institutions -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Music21 is unthinkable without our colleagues and friends -working on other music and technology projects, in particular: - -* `David Huron`_, inventor of `Humdrum`_ (the link refers to a changed version of the project), - the inspiration for music21. - -* `Michael Good`_ and Recordare.com for creating MusicXML and many - discussions about the project. - -* The `Center for Computer-Assisted Research in the Humanities`_ at Stanford University, - contributing to the knowledge of music since 1984, and - publishers of *Computing in Musicology*. - -.. _David Huron: https://music.osu.edu/people/david-huron -.. _Humdrum: https://www.humdrum.org -.. _Michael Good: https://www.musicxml.com -.. _Center for Computer-Assisted Research in the Humanities: http://www.ccarh.org/ - -Contributors -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Additionally, the following individuals have contributed materials or knowledge -to this project. Their contributions and generosity are greatly appreciated. - -* Thomas Bonte, Nicholas Froment, and Werner Schweer of `MuseScore`_ for their - support and for their contributions to the open source music notation projects, - including the Bach Goldberg Variations and the Handel Arias included. - -* `Jacob Walls`_, contributed greatly to the type-safety, speed, and test coverage - of `music21`. If your music21 program "just works" without needing to guess what - any argument goes where, Jacob is to thank. - -* `Donald Byrd`_, researcher on University of Indiana who created - a schema for computer-aided musicology (along with the source of all sorts of - examples of how music notation is difficult). - -* `Jack Campin`_ has kindly given permission to distribute his ABC editions of the Aird - Collection, the Northumbrian Minstrelsy, and the Colonial and Civil War American - Fife Music Collection. - -* `John Chambers`_ has provided ABC editions to distribute with music21, including the - Aird Collection, the O'Neill's Music of Ireland Collection, and Ryan's Mammoth Collection - of fiddle tunes. - -* `Laura E. Conrad`_ has kindly given permission to distribute her ABC editions of - renaissance polyphony from Serpent Publications. - -* `Ewa Dahlig-Turek`_ has kindly given permission to distribute the - Essen folksong database with music21. - -* `Margaret Greentree` kindly gave permission for distribution of her edited - collection of the Bach chorales in MusicXML format as part of the music21 corpus. - Her website contains all these chorales in additional formats. - Any discoveries we make regarding these chorales are done in her memory. - -* Bret Aarden for kindly contributing his conversions of Palestrina Mass corpus to `music21`. - -* Walter B. Hewlett and Craig Sapp of Stanford's CCARH for support. - -* `Justin London` compiled and maintained the list of Second-Viennese - row forms that form the original backbone of serial.py. - -* `McGill University`_ ELVIS project for including the MEI parser. Special thanks to Julie - Cumming, Andrew Hankinson, Ichiro Fujinaga, and especially Christopher Antila for contributing. - -* `Manuel Op de Coul`_ has kindly gave permission to use the Scala - scale archive of nearly 4000 scales in music21. - -* `Seymour Shlien`_ has kindly given permission to distribute his ABC - encodings of the Essen folksong database with music21. - -* `Bryen Travis`_ has kindly gave permission to use his collection of - Bach MIDI data in `music21`. It is no longer included in the Corpus, but we - continue to thank him for his generosity. - -* `Project Gutenberg`_ houses public domain music, including the quartets of Beethoven, - Haydn, and Mozart, in musicxml format which we have been able to include in music21. - -.. _Donald Byrd: https://web.archive.org/web/20220610200930/https://homes.luddy.indiana.edu/donbyrd/CMNExtremes.htm -.. _Jacob Walls: https://jacobtylerwalls.com/ -.. _Laura E. Conrad: http://www.serpentpublications.org/drupal7/ -.. _MuseScore: https://musescore.com/ -.. _Bryen Travis: http://www.bachcentral.com/ -.. _Ewa Dahlig-Turek: http://www.esac-data.org -.. _Seymour Shlien: https://ifdo.ca/~seymour/runabc/esac/esacdatabase.html -.. _Manuel Op de Coul: https://www.huygens-fokker.org/scala -.. _John Chambers: http://trillian.mit.edu/~jc/music/book -.. _Jack Campin: http://www.campin.me.uk/ -.. _McGill University: https://works.hcommons.org/records/c6ew2-tth07 -.. _Project Gutenberg: https://www.gutenberg.org/browse/categories/4 - - -How to Contribute ------------------ - -We are always interested in working with interested musicologists, -programmers, psychologists, composers, game-designers, -performers, amateur music enthusiasts, etc. In particular, we're interested -in hearing about how `music21` helped you -advance your work ... or in problems with `music21` itself or contributions you've made. - -You can contact the larger `music21` community through the `music21 list`_. - -.. _music21 list: https://groups.google.com/g/music21list - -In particular, if you are interested in contributing documentation, tests, -or new features to music21, please contact the lead author on GitHub or through the -list. - - -Licensing and Copyright ---------------------------------- - -The `music21` Toolkit -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Music21 is Copyright © 2006-2024 Michael Scott Asato Cuthbert. -Music21 code (excluding content encoded in the corpus) is -free and open-source software, licensed under the BSD License. - -The `music21` Corpus -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The BSD-licensed `music21` software is distributed with a corpus of encoded -compositions which are distributed -with the permission of the encoders (and, where needed, the composers -or arrangers) and where permitted -under United States copyright law. Some encodings included in the corpus -may not be used for commercial uses -or have other restrictions: please see the licenses embedded in individual -compositions or directories for more details. - -To the best of our knowledge, the music (if not the encodings) -in the corpus are either out of copyright -in the United States and/or are licensed for non-commercial use. We also -aim to have all files out of copyright in the EU and Canada as well. -These works, along with any works linked -to in the virtual corpus, may or may not be free in your jurisdiction. -If you believe this message to be in -error regarding one or more works please contact Michael Cuthbert at -the address provided on the contact page. diff --git a/documentation/source/about/.ipynb_checkpoints/applications-checkpoint.rst b/documentation/source/about/.ipynb_checkpoints/applications-checkpoint.rst deleted file mode 100644 index 6880a0b2b4..0000000000 --- a/documentation/source/about/.ipynb_checkpoints/applications-checkpoint.rst +++ /dev/null @@ -1,94 +0,0 @@ -.. _applications: - - -Applications and Extensions of `music21` -============================================= - -`Music21` has been used in numerous research tasks already, and will continue -to offer researchers many tools with which to explore new domains. - - -Papers, Presentations, and Publications ---------------------------------------------------- - -The following papers and publications make extensive use of `music21`. Start here: - -Cuthbert, Michael Scott Cuthbert and Christopher Ariza. 2010. "`music21`: A -Toolkit for Computer-Aided Musicology and Symbolic Music Data." In -*Proceedings of the International Society for Music Information Retrieval*. -https://www.trecento.com/research/Cuthbert_Ariza_Music21_ISMIR_2010.pdf - - -Then continue with: - -Church, Maura and Michael Scott Cuthbert. 2014. "Improving Rhythmic -Transcriptions via Probability Models Applied Post-OMR." In *Proceedings of the -International Society for Music Information Retrieval*. -https://www.trecento.com/research/Church_Cuthbert_OMR.pdf - -Cuthbert, Michael Scott, Beth Hadley, Lars Johnson, and Christopher Reyes. 2012. -"Interoperable Digital Musicology Research via `music21` Web Applications." -From *Joint CLARIN-D/DARIAH Workshop at Digital Humanities Conference Hamburg*. -https://www.trecento.com/research/Cuthbert_Hadley_Johnson_Reyes_Music21_SOA.pdf - -Cuthbert, Michael Scott, Chris Ariza, Jose Cabal-Ugaz, Beth Hadley, and Neena Parikh. 2011. -"Hidden Beyond MIDI’s Reach:Feature Extraction and Machine Learning with Rich Symbolic Formats -in `music21`" In *Proceedings of the Neural Information Processing Systems Conference*. -https://www.trecento.com/research/Cuthbert_Ariza_Cabal-Ugaz_Hadley_Parikh-Hidden-NIPS2011.pdf - -Cuthbert, Michael Scott, Chris Ariza, and Lisa D. Friedland. 2011. "Feature Extraction and -Machine Learning on Symbolic Music using the `music21` Toolkit" In -*Proceedings of the International Symposium on Music Information Retrieval* -https://www.trecento.com/research/Cuthbert_Ariza_Friedland_Feature-Extraction_ISMIR_2011.pdf - -Jordi Barthomé Guillen and Michael Scott Cuthbert. 2011. "Score Following from -Inaccurate Score and Audio Data using OMR and `music21`." In *Proceedings of the Neural -Information Processing Systems Conference (Music and Machine Learning, Workshop 4*. -https://www.trecento.com/research/Bartolome-Guillen_Cuthbert_ScoreFollower.pdf - -Ariza, C. and Michael Scott Cuthbert. 2011. "The `music21` Stream: A New Object -Model for Representing, Filtering, and Transforming Symbolic Musical -Structures." In *Proceedings of the International Computer Music Conference*. -San Francisco: International Computer Music Association, pp. 61-68. -Available online at https://www.trecento.com/research/Ariza_Cuthbert_Stream_ICMC_2011.pdf - -Ariza, C. and Michael Scott Cuthbert. 2011. "Analytical and Compositional -Applications of a Network-Based Scale Model in `music21`." In *Proceedings of the -International Computer Music Conference*. San Francisco: International Computer -Music Association, pp. 701-708. Available online at -https://www.trecento.com/research/Ariza_Cuthbert_Scale_ICMC_2011.pdf - -Ariza, C. and Michael Scott Cuthbert. 2010. "Modeling Beats, Accents, Beams, and -Time Signatures Hierarchically with `music21` Meter Objects." In *Proceedings of the -International Computer Music Conference*. San Francisco: International Computer Music -Association. 216-223. Available online at -https://www.trecento.com/research/Ariza_Cuthbert_Meter_ICMC_2010.pdf - -Future Goals and Potential Applications ---------------------------------------------------- - -There are numerous applications for music21 that we anticipate, yet simply have not had -time to implement. Consider taking on one of these projects, or write us with new and -interesting suggestions. To contact the authors, visit :ref:`about`. - -- Piano-key-visualization - -- Slonimsky scale types - -- Automatic clarinet fingering generation via ClarFinger font (link: www.trecento.com/fonts/) - -- Automatic string fingerings. - -- Indian Raga encoding: including ascending, descending, and typical presentations, - microtonal inflections, common associations, historical context. - -- Multiple-simultaneous-tempi to a single tempo conversion (via tuplets). - -- Palestrina counterpoint generation (via algorithms of Mary Farbood and others.). - -- Beneventan chant similarity indices (thanks to the work of Thomas Forrest Kelly and the - Exultet encodings made available in `**kern` by Elsa De Luca). - -- Identify potential clefs for fragmentary Renaissance and Medieval pieces that are - missing their clefs. (Use their staff-lines and minimizing number of melodic and - harmonic tritones). diff --git a/documentation/source/about/faq.rst b/documentation/source/about/faq.rst index 59335a02cd..d69eb5ef9e 100644 --- a/documentation/source/about/faq.rst +++ b/documentation/source/about/faq.rst @@ -9,7 +9,8 @@ General How do I ask a question so that it becomes frequently asked? Don't you hate FAQs that are not based on anything anyone's ever asked? - To ask, post to https://groups.google.com/g/music21list. + To ask, post to https://groups.google.com/g/music21list or post an issue on + `GitHub `__. But first read what we've already come up with below. Why is it called `music21`? @@ -101,7 +102,7 @@ What is the native `music21` data format? in one of the solutions above, you should have enough experience to create a data storage format of your own. Please consider contributing - your solution to improve the package. + your solution to improve the package. (We might try to use MNX soon as it's stable.) I'm ready to give it a try, but how do I install `music21`? @@ -112,8 +113,8 @@ I'm using Linux/GNU/Unix and I'm having trouble configuring `music21`, can you help? Unfortunately, due to the number of different flavors of open-source - operating systems, the development team can only give free help to - Mac and Windows installation. (but see paid support, below). + operating systems, the development team can only give help to + Mac and Windows installation. Most AI agents can help you nowadays, however. Recurring misunderstandings ---------------------------- @@ -155,9 +156,12 @@ Can `music21` read in music directly from an image or a .pdf? Sorry, that's beyond `music21`'s capabilities. This technology is called OMR (Optical Music Recognition) and it's a whole separate field of research in itself. Audiveris is an open-source OMR - software toolkit. The development team here has had good experience + software toolkit. (Download it only from its GitHub page, + https://github.com/Audiveris/audiveris/releases -- other sites offering + Audiveris downloads are often scams or carry malware.) + The development team here has had good experience with the commercial SmartScore application. `Music21` does have a - good set of modules for improving the output of OMR after it's done. + set of modules for improving the output of OMR after it's done. See :ref:`moduleOmrCorrectors`. I'm having trouble reading in MIDI. The docs for `music21.midi.translate` @@ -177,41 +181,38 @@ to understand. (Same for `musicxml`, `mei`, etc.) `s.write('midi', fp='filename.mid')`. Or to hear it immediately, `s.show('midi')`. + By the way: MuseScore and other software generally do a better job of reading + performed MIDI files than `music21` does, so for complex tasks, we often + recommend converting to MusicXML with other software first, and then loading + those XML files into music21 instead. -Feature requests and Consulting + +Feature requests ------------------------------- `Music21` doesn't have some feature that I need, how do I get it added? - It won't hurt to email the list (or us directly) and we'll consider it. - However, we do have a priority list based on what we think the widest - audience will require or what we need for our own research. If you'd - like your request to leap-frog to the front of the line, the best way - is to endow the programming of your feature through a donation that will - enable us to increase the number of student programming assistants we - employ. (Or side-step us and offer a cash bounty on the music21list - itself). + You can put a request as an "Issue" on + `GitHub `__ + (just report it as a request, not a bug). + In general though, the core team has a priority list based on what we think the widest + audience is needing (or what we need for our own research), so please don't be surprised if + no `music21` core devs have time to implement it. + You can also rally support on the mailing list and hope for an angel programmer to help there. No, you don't understand, I **really** need this feature! - If you really need something done in `music21`, we offer paid support - by the hour at standard consulting rates. Contact `michael.asato.cuthbert@gmail.com` - for details and rates. + The `music21` project does not offer paid consulting. + But if you really need something done in `music21`, there are people who do `music21` work + on paid consulting contracts. Often even the main developer wearing a separate hat. + But be kind, only contact devs if you are willing + to actually pay consulting rates. (Otherwise, it means you + don't *really* need the feature.) -Is this also what I should do if I need help using `music21` for my own project? +Is this also what I should do if I need help integrating or using `music21` in my own project? Yes, if you're having trouble getting `music21` to do what you want and you've - tried the mailing list/StackOverflow, etc. consulting is available to help. - -Why not put the rate here? - - The rate varies according to the difficulty of the feature--whether it - requires a principal investigator to run or whether it could be incorporated - into the education of a student; whether licensing agreements need to be signed, etc. - - Discounts are available for - academic researchers/composers who consider the consultation sufficiently - essential as to add - authorship credit to the development team on a publication. + tried the mailing list/Agents/StackOverflow, etc. there are often people on the + music21list who are open to doing work for hire in that realm. 3rd-Party Utilities diff --git a/documentation/source/about/index.rst b/documentation/source/about/index.rst index ee319489b2..d0be870496 100644 --- a/documentation/source/about/index.rst +++ b/documentation/source/about/index.rst @@ -8,4 +8,5 @@ About `music21` about applications faq + migratingToV11 referenceCorpus diff --git a/documentation/source/about/migratingToV11.rst b/documentation/source/about/migratingToV11.rst new file mode 100644 index 0000000000..acc455dfde --- /dev/null +++ b/documentation/source/about/migratingToV11.rst @@ -0,0 +1,268 @@ +.. _migratingToV11: + +Migrating to music21 v11 +======================== + +*(This is an in-progress guide that was automatically generated by Myke's AI Agent.)* + +What changed between the v10 line and v11, and what to use instead. The big, code-breaking items come first, then smaller +improvements by area, then one table of everything removed or deprecated. + + +Python 3.12 or newer +-------------------- + +Python 3.11 support is gone; 3.12, 3.13, and 3.14 are tested. The payoff is +real generics: ``Stream`` and its iterators use the PEP 695 bracket form, so a +type checker can follow ``stream.Stream[note.Note]()`` all the way to +``for n in s: n.pitch``. + + +Octaves are always integers +--------------------------- + +**In one line:** ``octave`` is always an ``int``, and ``octaveIsImplicit`` says whether you gave one. + +Before v11, ``pitch.Pitch('F#').octave`` was ``None``: a lovely idea (an +F-sharp in *any* octave) that crashed the moment someone wrote +``p.octave + 1``. The pitch knew all along which octave it would use for +MIDI or a staff. It kept that in ``implicitOctave``, a property that nobody +remembered. Now ``octave`` does that job itself: + +>>> from music21 import * +>>> anyFSharp = pitch.Pitch('F#') +>>> anyFSharp.octave +4 +>>> anyFSharp.octaveIsImplicit +True + +Nothing else about an octave-less pitch changes. It still prints without a +number, transposes without one, and is not equal to an explicit F#4: + +>>> anyFSharp + +>>> anyFSharp.transpose('P8') + +>>> anyFSharp == pitch.Pitch('F#4') +False + +Give it an octave and the flag flips: + +>>> anyFSharp.octave = 5 +>>> anyFSharp.octaveIsImplicit +False +>>> anyFSharp + + +Notes follow along: ``note.Note('B-').octave`` is ``4``, and the flag lives +on the note's pitch, ``n.pitch.octaveIsImplicit``. + +.. list-table:: + :header-rows: 1 + :widths: 50 50 + + * - Before v11 + - v11 + * - ``if p.octave is None:`` + - ``if p.octaveIsImplicit:`` + * - ``p.octave = None`` + - ``p.octaveIsImplicit = True`` (the old spelling is deprecated; removed in v13) + * - ``p.implicitOctave`` + - ``p.octave`` + * - ``if p.octave is None: p.octave = p.implicitOctave`` + - ``p.octaveIsImplicit = False`` + * - ``octave: int | None`` + - ``octave: int`` + +``p.octave + 1`` and ``f'{p.name}{p.octave}'`` no longer crash. The few +methods that need a pitch's *own* octave, such as +:meth:`~music21.pitch.Pitch.transposeAboveTarget`, still raise for an +implicit one; set ``.octave`` first. ``implicitOctave`` stays as a synonym +so nothing breaks today, but it is deprecated and starts warning in v12. + + +KeySignature.sharps is always an int +------------------------------------ + +Similar idea to Octave. ``KeySignature.sharps`` (and ``Key.sharps``) is an +``int`` you can add to and subtract from. A non-traditional signature is a +flag plus a list of pitches. ``sharps=None`` still works but warns: + +>>> unusual = key.KeySignature() +>>> unusual.isNonTraditional = True +>>> unusual.alteredPitches = ['E-', 'G#4'] +>>> unusual + + + +Chord indexing gets simpler +--------------------------- + +``c[1]``, ``c['G4']`` and ``c[somePitch]`` still return the chord's +:class:`~music21.note.Note`; everything fancier is gone. Attribute paths +such as ``c['2.tie']`` and ``c['D-4.style.color']`` no longer work, a Note is +no longer accepted as a key (use its pitch), and the per-note getters and +setters (``getTie``, ``setColor``, ``getNotehead``, and their siblings) are +deprecated. Index, then use the Note: + +>>> c = chord.Chord('C4 E4 G4') +>>> c['E4'].tie = tie.Tie('start') +>>> c[1].tie + + + +VoiceLeadingQuartet needs its four notes +---------------------------------------- + +``voiceLeading.VoiceLeadingQuartet(v1n1, v1n2, v2n1, v2n2)`` requires all +four notes up front, the first Music21Object ever to insist on arguments. In +return they are always Notes, never ``None``. ``analyticKey`` is now +``key``, ``vIntervals`` and ``hIntervals`` are tuples, and two dogmatic names +got truer ones: ``opensIncorrectly()`` is ``not modalOpening()`` and +``closesIncorrectly()`` is ``not clausulaVera()``. + + +Roman numerals +-------------- + +* ``romanNumeralFromChord`` keeps the sharp on major-quality chords built on + the raised sixth and seventh degrees in minor: ``#VI``, not ``VI#63``. + ``correctRNAlterationForMinor`` gained a ``chordHasMajorThird`` keyword for + the same reason. +* Figures containing ``x``, ``y`` or ``z`` raise. ``RomanNumeral('IIIx')`` + used to be a III13 chord by accident. +* ``FigureTuple`` and ``PitchFigureTuple`` are typed NamedTuples, and + ``FigureTuple.fromPitchAndReference()`` replaces ``figureTupleSolo``. + + +Durations and sorting +--------------------- + +* Two Durations with ``expressionIsInferred`` True are equal when their + quarterLengths match; type, dots and tuplets are free to be re-expressed. +* ``sorting.SortTuple`` is a modern NamedTuple. ``priority`` and + ``classSortOrder`` may be floats, and ``modify()`` with a bad field name + raises ``ValueError``. +* tinyNotation's undocumented ``0`` duration (a whole bar plus a fermata) is + deprecated. + + +Streams +------- + +* New ``Stream.isAtEnd(el)`` says whether ``storeAtEnd`` put ``el`` there. +* ``Stream.hasElement(el)`` is removed: write ``el in s``. + ``hasElementOfClass`` is deprecated: write ``if s.getElementsByClass(X):``. + + +Pitches +------- + +``pitch.simplifyMultipleEnharmonics`` takes ``criterion`` and ``keyContext`` +as keyword-only arguments. + + +File formats +------------ + +* **ABC**: ``w:`` lyric lines are imported, with hyphenation and ``*`` skips. + ``abcToStreamOpus`` always returns an Opus. +* **Humdrum**: grace notes keep their written duration instead of becoming + eighths; duration parsing lives in a new ``hdStringToDuration``; a token + with no duration warns when a quarter is assumed; ``GlobalReference`` is no + longer a Music21Object. +* **LilyPond**: output targets current LilyPond (``\tuplet``, modern barline + names, ``\markuplist``), and every ``stringOutput()`` returns a ``str``. +* **MIDI**: ``midiEventsToInstrument`` is removed; use + ``midiEventToInstrument``. +* **Vexflow**: the ``music21.vexflow`` module is gone. It had not worked in + a decade; music21j is the way to draw in a browser. +* **configure**: choosing MuseScore as the MusicXML reader also sets + ``musescoreDirectPNGPath``. + + +figuredBass, features, tree +--------------------------- + +* figuredBass: ``hiddenFifth`` and ``hiddenOctave`` are now ``hiddenFifths`` + and ``hiddenOctaves``, matching ``parallelFifths``. ``FiguredBassScale``'s + first argument is ``scaleTonic``, not ``scaleValue``. Rests inside a + possibility are typed sentinels rather than the string ``'RT'``. +* features: ``Feature.vector`` starts as ``[]`` rather than ``None``, and + ``FeatureExtractor.dimensions`` defaults to 1. +* tree: ``ElementTimespan`` and ``PitchedTimespan`` take their arguments in + the same order as ``Timespan``; ``offset``, ``endTime`` and ``element`` are + required. + + +For developers +-------------- + +* Type annotations across nearly the whole library, with ``t.cast()`` for + narrowing and ``@property`` decorators throughout. +* ``common.enums.ContainsEnum`` is not needed: Python 3.12's ``StrEnum`` + suffices, or ``HexEnum`` for hex values such as MIDI. The alias leaves in + v12. ``common.defaultlist`` is deprecated. +* The test runners import modules the normal way, so a module's tests no + longer need ``from music21.key import KeySignature`` to compare types. +* AI agents get their own instructions, skills and shared memory in + ``AGENTS.md`` and ``.agents/``. + + +Removed and deprecated +---------------------- + +.. list-table:: Removed in v11 + :header-rows: 1 + :widths: 50 50 + + * - Gone + - Use instead + * - Python 3.11 + - Python 3.12 or newer + * - ``music21.vexflow`` + - music21j + * - ``midi.translate.midiEventsToInstrument`` + - ``midiEventToInstrument`` + * - ``Stream.hasElement(el)`` + - ``el in s`` + * - ``musicxml.xmlToM21.MusicXMLImporter.identificationToMetadata`` + - ``addIdentificationToMetadata`` + * - a string for ``musicxml.xmlToM21.MeasureParser.getStaffNumber`` + - an ``int`` + * - ``VoiceLeadingQuartet.unison`` / ``.fifth`` / ``.octave`` + - ``interval.Interval('P1')`` and friends + * - ``c['2.tie']`` attribute paths into a Chord + - ``c[2].tie`` + * - a Note as a Chord index, ``c[someNote]`` + - ``c[someNote.pitch]`` + +.. list-table:: Deprecated in v11 (removed in v12 unless noted) + :header-rows: 1 + :widths: 50 50 + + * - Deprecated + - Use instead + * - ``Stream.hasElementOfClass(X)`` + - ``if s.getElementsByClass(X):`` + * - ``Chord.getTie``, ``setTie``, ``getColor``, ``setColor``, + ``getNotehead``, ``setNotehead``, ``getNoteheadFill``, + ``setNoteheadFill``, ``getStemDirection``, ``setStemDirection`` + - ``c[i].tie``, ``c[i].style.color``, ``c[i].notehead``, + ``c[i].noteheadFill``, ``c[i].stemDirection`` + * - ``roman.figureTupleSolo`` + - ``FigureTuple.fromPitchAndReference()`` + * - ``VoiceLeadingQuartet.opensIncorrectly()`` / ``closesIncorrectly()`` + - ``not modalOpening()`` / ``not clausulaVera()`` + * - ``KeySignature(sharps=None)`` + - ``ks.isNonTraditional = True`` + * - ``variant.addVariant(replacementDuration=...)`` + - ``replacementQuarterLength=...`` + * - ``common.defaultlist`` + - a ``list`` or a ``dict`` + * - ``common.enums.ContainsEnum`` + - not needed: ``enum.StrEnum`` suffices, or ``HexEnum`` for hex values such as MIDI + * - tinyNotation ``0`` duration + - the duration plus an ``expressions.Fermata`` + * - ``Pitch.implicitOctave`` + - ``Pitch.octave`` (warns from v12, removed later) diff --git a/documentation/source/developerReference/buildingDocumentation.rst b/documentation/source/developerReference/buildingDocumentation.rst index f66c31dd9a..a0f482554a 100644 --- a/documentation/source/developerReference/buildingDocumentation.rst +++ b/documentation/source/developerReference/buildingDocumentation.rst @@ -4,55 +4,36 @@ Building Documentation ================================== -Creating or updating the music21 documentation requires a -few additional programs to be installed, -most importantly one called Sphinx. Sphinx is a documentation generator that uses -reStructuredText (rst) as its markup language and outputs HTML documents. +Creating or updating the music21 documentation requires a +few additional programs to be installed, +most importantly one called Sphinx. Sphinx is a documentation generator that uses +reStructuredText (rst) as its markup language and outputs HTML documents. -In addition, you will need to install `music21` from the .tar.gz file on -Github (https://github.com/cuthbertLab/music21/releases) or from the -git repository (git://github.org/cuthbertLab/music21.git). +In addition, you will need a copy of the git repository +(https://github.com/cuthbertLab/music21). -**1) Install pip (if needed)** +**1) Install uv and run uv sync** -In order to get additional modules, you'll need a way of getting them more easily. -Recent versions of Python include `pip` or `pip3`; if you don't have it, you -should download it. -Just go to https://pip.pypa.io/en/stable and they'll help you out. +Get uv at https://docs.astral.sh/uv/ and then, in the music21 directory, run `uv sync`. +This installs Sphinx, Jupyter, and nbconvert, which turns the Jupyter notebooks +used in the tutorials and User's Guide into .rst files. **2) Install pandoc** -Pandoc is a universal documentation converter that we use. +Pandoc is a universal documentation converter that we use (nbconvert needs it). You can get pandoc at: https://pandoc.org/installing.html Note that pandoc 1.14-1.15.0 do not work. 1.15.1 and newer are recommended. -**3) Install nbconvert** +**3) Run documentation/make.py** -Some of the tutorials and users guides for `music21` use the Jupyter/IPython -notebook. To convert these files to the Python documentation format .rst, you'll -need nbconvert (and pandoc). Run `sudo pip3 install nbconvert`. -On Windows, omit the `sudo`. You will probably be -asked for your password to install. - -**4) Downloading and Installing Sphinx** - -Sphinx is the document builder for Python. Run on Mac "sudo pip3 install sphinx" -or on Windows "pip3 install sphinx". - -**5) Run documentation/make.py** - -Find the documentation folder within the base folder of `music21`. Change directory to that -directory and then run `python3 make.py`, which will automatically build all the music modules in +From the music21 directory run `uv run documentation/make.py`, which will automatically +build all the music modules in music21 into the "autogenerated" folder and from there into the "build/html" folder. It should also open your web browser automatically. -If you can't find your `music21` installation run these commands: `from music21 import *` -and then `common.getSourceFilePath()`. The documentation directory is the directory above -this named `documentation`. - -The first time you build the documentation, it'll take a long time. Subsequent +The first time you build the documentation, it'll take a long time. Subsequent builds, however, only build the parts that have changed. If you need to build from -scratch, run `python3 make.py clean` and you will have a clean directory to -build again. \ No newline at end of file +scratch, run `uv run documentation/make.py clean` and you will have a clean directory to +build again. diff --git a/documentation/source/developerReference/documenting.rst b/documentation/source/developerReference/documenting.rst index 6f620fbd7f..20b096f2a2 100644 --- a/documentation/source/developerReference/documenting.rst +++ b/documentation/source/developerReference/documenting.rst @@ -51,13 +51,9 @@ in this form -- there's a test that ensures that code coverage increases with ea we'll help you learn the ropes. This doc explains some of the main features (and potential Gotchas!) of our automatic documentation system. -Install Jupyter with:: +`uv sync` in the music21 directory installs Jupyter. Run it from the music21 directory with:: - pip3 install jupyter - -And then run it by changing directory to the one you want to edit and run:: - - jupyter notebook + uv run jupyter notebook When editing examples with code be sure, at least the first time, to run each code excerpt individually and pay attention to changes in output and unexpected errors. @@ -79,9 +75,7 @@ by Sphinx. Place all documentation for public modules, module-level functions, classes, and class-level attributes, properties, and methods as close to the relevant code as possible. -If you're going to edit docs you'll need the latest version of Sphinx. Go to the command line and type:: - - pip3 install sphinx +If you're going to edit docs you'll need Sphinx, which `uv sync` installs. Sphinx uses special characters to identify formatting styles in documentation. Helpful tips on Sphinx formatting may be found here: `Sphinx Cheat Sheet `_ diff --git a/documentation/source/developerReference/images/usingEclipse/SVNfinalview.png b/documentation/source/developerReference/images/usingEclipse/SVNfinalview.png deleted file mode 100644 index 9ba581a5e8..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/SVNfinalview.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 2.16.20 PM.png b/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 2.16.20 PM.png deleted file mode 100644 index b5f5e0bffa..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 2.16.20 PM.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 2.23.04 PM.png b/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 2.23.04 PM.png deleted file mode 100644 index c330ccc770..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 2.23.04 PM.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 2.23.25 PM.png b/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 2.23.25 PM.png deleted file mode 100644 index 5f9e80d71e..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 2.23.25 PM.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 2.33.18 PM.png b/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 2.33.18 PM.png deleted file mode 100644 index bb351f69c7..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 2.33.18 PM.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 2.33.47 PM.png b/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 2.33.47 PM.png deleted file mode 100644 index 285eefa1b6..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 2.33.47 PM.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 2.58.14 PM.png b/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 2.58.14 PM.png deleted file mode 100644 index 24d8321fde..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 2.58.14 PM.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 2.58.23 PM.png b/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 2.58.23 PM.png deleted file mode 100644 index bc9743c03e..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 2.58.23 PM.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 3.06.40 PM.png b/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 3.06.40 PM.png deleted file mode 100644 index 92ee2e2334..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 3.06.40 PM.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 3.07.39 PM.png b/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 3.07.39 PM.png deleted file mode 100644 index 0be0a0d737..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 3.07.39 PM.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 3.07.55 PM.png b/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 3.07.55 PM.png deleted file mode 100644 index 32d28e0cc6..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 3.07.55 PM.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 3.08.04 PM.png b/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 3.08.04 PM.png deleted file mode 100644 index 98ce29632d..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 3.08.04 PM.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 3.08.29 PM.png b/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 3.08.29 PM.png deleted file mode 100644 index 4ddba75d10..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/Screen Shot 2011-08-24 at 3.08.29 PM.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/aptana_certificate.png b/documentation/source/developerReference/images/usingEclipse/aptana_certificate.png deleted file mode 100644 index 00071a62e3..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/aptana_certificate.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/choosingpythonconsole.png b/documentation/source/developerReference/images/usingEclipse/choosingpythonconsole.png deleted file mode 100644 index 7911e5beaa..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/choosingpythonconsole.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/choosingviewfrommenu.png b/documentation/source/developerReference/images/usingEclipse/choosingviewfrommenu.png deleted file mode 100644 index 33fb0c1728..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/choosingviewfrommenu.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/creatingnewpydevproj.png b/documentation/source/developerReference/images/usingEclipse/creatingnewpydevproj.png deleted file mode 100644 index e864288816..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/creatingnewpydevproj.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/creatingnewpydevproj2.png b/documentation/source/developerReference/images/usingEclipse/creatingnewpydevproj2.png deleted file mode 100644 index 66b44ed719..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/creatingnewpydevproj2.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/creatingnewpydevproj3.png b/documentation/source/developerReference/images/usingEclipse/creatingnewpydevproj3.png deleted file mode 100644 index fcc2d11aba..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/creatingnewpydevproj3.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/creatingnewpydevproj4.png b/documentation/source/developerReference/images/usingEclipse/creatingnewpydevproj4.png deleted file mode 100644 index b800e81513..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/creatingnewpydevproj4.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/creatingnewpydevproj5.png b/documentation/source/developerReference/images/usingEclipse/creatingnewpydevproj5.png deleted file mode 100644 index 1d59b9b2ce..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/creatingnewpydevproj5.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/creatingnewpydevproj6.png b/documentation/source/developerReference/images/usingEclipse/creatingnewpydevproj6.png deleted file mode 100644 index 0182bb2869..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/creatingnewpydevproj6.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/creatingnewpydevproject_cropped.png b/documentation/source/developerReference/images/usingEclipse/creatingnewpydevproject_cropped.png deleted file mode 100644 index 9f93ab13b2..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/creatingnewpydevproject_cropped.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/eclipsefolder.png b/documentation/source/developerReference/images/usingEclipse/eclipsefolder.png deleted file mode 100644 index c330ccc770..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/eclipsefolder.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/eclipseicon.png b/documentation/source/developerReference/images/usingEclipse/eclipseicon.png deleted file mode 100644 index 5f9e80d71e..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/eclipseicon.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/expand_collaboration.png b/documentation/source/developerReference/images/usingEclipse/expand_collaboration.png deleted file mode 100644 index b5f5e0bffa..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/expand_collaboration.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/frommusic21import.png b/documentation/source/developerReference/images/usingEclipse/frommusic21import.png deleted file mode 100644 index 92ee2e2334..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/frommusic21import.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/importingmusic21inpythonconsole.png b/documentation/source/developerReference/images/usingEclipse/importingmusic21inpythonconsole.png deleted file mode 100644 index b47ca803b6..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/importingmusic21inpythonconsole.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/install_subversive1.png b/documentation/source/developerReference/images/usingEclipse/install_subversive1.png deleted file mode 100644 index d2a8fe4c33..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/install_subversive1.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/installingpydev1.png b/documentation/source/developerReference/images/usingEclipse/installingpydev1.png deleted file mode 100644 index f271f673aa..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/installingpydev1.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/installingpydev2.png b/documentation/source/developerReference/images/usingEclipse/installingpydev2.png deleted file mode 100644 index b40cd10ed0..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/installingpydev2.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/installingpydev3.png b/documentation/source/developerReference/images/usingEclipse/installingpydev3.png deleted file mode 100644 index 9e898d0640..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/installingpydev3.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/installingpydev4.png b/documentation/source/developerReference/images/usingEclipse/installingpydev4.png deleted file mode 100644 index 1c92267594..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/installingpydev4.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/installingpydev5.png b/documentation/source/developerReference/images/usingEclipse/installingpydev5.png deleted file mode 100644 index b49fbab7aa..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/installingpydev5.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/pydevperspective.png b/documentation/source/developerReference/images/usingEclipse/pydevperspective.png deleted file mode 100644 index bb351f69c7..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/pydevperspective.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/runas.png b/documentation/source/developerReference/images/usingEclipse/runas.png deleted file mode 100644 index 4ddba75d10..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/runas.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/runningconfigure.png b/documentation/source/developerReference/images/usingEclipse/runningconfigure.png deleted file mode 100644 index e4aca199a8..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/runningconfigure.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/saynotosavingmusic21.png b/documentation/source/developerReference/images/usingEclipse/saynotosavingmusic21.png deleted file mode 100644 index 3feda5c73b..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/saynotosavingmusic21.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/welcometoconfigassistant.png b/documentation/source/developerReference/images/usingEclipse/welcometoconfigassistant.png deleted file mode 100644 index 15510ffb3e..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/welcometoconfigassistant.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingEclipse/wheretogotoopenconsole.png b/documentation/source/developerReference/images/usingEclipse/wheretogotoopenconsole.png deleted file mode 100644 index bd1cd18e3f..0000000000 Binary files a/documentation/source/developerReference/images/usingEclipse/wheretogotoopenconsole.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/eclipse__clone__1.png b/documentation/source/developerReference/images/usingGit/eclipse__clone__1.png deleted file mode 100644 index 8ac676f62a..0000000000 Binary files a/documentation/source/developerReference/images/usingGit/eclipse__clone__1.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/eclipse__clone__1__edited.png b/documentation/source/developerReference/images/usingGit/eclipse__clone__1__edited.png deleted file mode 100644 index 26b475b327..0000000000 Binary files a/documentation/source/developerReference/images/usingGit/eclipse__clone__1__edited.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/eclipse__clone__2.png b/documentation/source/developerReference/images/usingGit/eclipse__clone__2.png deleted file mode 100644 index 4ab46d6148..0000000000 Binary files a/documentation/source/developerReference/images/usingGit/eclipse__clone__2.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/eclipse__clone__2__edited.png b/documentation/source/developerReference/images/usingGit/eclipse__clone__2__edited.png deleted file mode 100644 index b05cb6017c..0000000000 Binary files a/documentation/source/developerReference/images/usingGit/eclipse__clone__2__edited.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/eclipse__clone__3.png b/documentation/source/developerReference/images/usingGit/eclipse__clone__3.png deleted file mode 100644 index a418d92d81..0000000000 Binary files a/documentation/source/developerReference/images/usingGit/eclipse__clone__3.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/eclipse__clone__3__edited.png b/documentation/source/developerReference/images/usingGit/eclipse__clone__3__edited.png deleted file mode 100644 index b9a0e9320a..0000000000 Binary files a/documentation/source/developerReference/images/usingGit/eclipse__clone__3__edited.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/eclipse__clone__4.png b/documentation/source/developerReference/images/usingGit/eclipse__clone__4.png deleted file mode 100644 index 9406d712e1..0000000000 Binary files a/documentation/source/developerReference/images/usingGit/eclipse__clone__4.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/eclipse__clone__4__edited.png b/documentation/source/developerReference/images/usingGit/eclipse__clone__4__edited.png deleted file mode 100644 index c378a066e3..0000000000 Binary files a/documentation/source/developerReference/images/usingGit/eclipse__clone__4__edited.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/eclipse__clone__5__edited.png b/documentation/source/developerReference/images/usingGit/eclipse__clone__5__edited.png deleted file mode 100644 index 0fc491db9b..0000000000 Binary files a/documentation/source/developerReference/images/usingGit/eclipse__clone__5__edited.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/eclipse__clone__6.png b/documentation/source/developerReference/images/usingGit/eclipse__clone__6.png deleted file mode 100644 index c5bbf8b212..0000000000 Binary files a/documentation/source/developerReference/images/usingGit/eclipse__clone__6.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/eclipse__clone__7__edited.png b/documentation/source/developerReference/images/usingGit/eclipse__clone__7__edited.png deleted file mode 100644 index b140a21002..0000000000 Binary files a/documentation/source/developerReference/images/usingGit/eclipse__clone__7__edited.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/eclipse__clone__8__edited.png b/documentation/source/developerReference/images/usingGit/eclipse__clone__8__edited.png deleted file mode 100644 index 3e958b3bd0..0000000000 Binary files a/documentation/source/developerReference/images/usingGit/eclipse__clone__8__edited.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/eclipse__committing__1.png b/documentation/source/developerReference/images/usingGit/eclipse__committing__1.png deleted file mode 100644 index 7c842c6eca..0000000000 Binary files a/documentation/source/developerReference/images/usingGit/eclipse__committing__1.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/eclipse__committing__2.png b/documentation/source/developerReference/images/usingGit/eclipse__committing__2.png deleted file mode 100644 index 697215ec3a..0000000000 Binary files a/documentation/source/developerReference/images/usingGit/eclipse__committing__2.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/eclipse__committing__3.png b/documentation/source/developerReference/images/usingGit/eclipse__committing__3.png deleted file mode 100644 index 365b7e1e97..0000000000 Binary files a/documentation/source/developerReference/images/usingGit/eclipse__committing__3.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/eclipse__configure_pydev__1.png b/documentation/source/developerReference/images/usingGit/eclipse__configure_pydev__1.png deleted file mode 100644 index 3c7df279b0..0000000000 Binary files a/documentation/source/developerReference/images/usingGit/eclipse__configure_pydev__1.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/eclipse__configure_pydev__2.png b/documentation/source/developerReference/images/usingGit/eclipse__configure_pydev__2.png deleted file mode 100644 index 11f922d9dc..0000000000 Binary files a/documentation/source/developerReference/images/usingGit/eclipse__configure_pydev__2.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__0.png b/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__0.png deleted file mode 100644 index fc65534b78..0000000000 Binary files a/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__0.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__1.png b/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__1.png deleted file mode 100644 index df31c8a39e..0000000000 Binary files a/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__1.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__1__edited.png b/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__1__edited.png deleted file mode 100644 index b832b81696..0000000000 Binary files a/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__1__edited.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__2__edited.png b/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__2__edited.png deleted file mode 100644 index 5b9254f47b..0000000000 Binary files a/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__2__edited.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__3.png b/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__3.png deleted file mode 100644 index 06c61358db..0000000000 Binary files a/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__3.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__3__edited.png b/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__3__edited.png deleted file mode 100644 index b3cc6ca07a..0000000000 Binary files a/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__3__edited.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__4.png b/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__4.png deleted file mode 100644 index c21ec38f26..0000000000 Binary files a/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__4.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__4__edited.png b/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__4__edited.png deleted file mode 100644 index 5bccda1982..0000000000 Binary files a/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__4__edited.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__5.png b/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__5.png deleted file mode 100644 index 012e6e9ecf..0000000000 Binary files a/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__5.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__5__edited.png b/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__5__edited.png deleted file mode 100644 index 15d46920f0..0000000000 Binary files a/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__5__edited.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__6.png b/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__6.png deleted file mode 100644 index 10f19bfd47..0000000000 Binary files a/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__6.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__6__edited.png b/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__6__edited.png deleted file mode 100644 index 88cd500c1a..0000000000 Binary files a/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__6__edited.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__7.png b/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__7.png deleted file mode 100644 index 67a79510a6..0000000000 Binary files a/documentation/source/developerReference/images/usingGit/eclipse__install_plugins__7.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/eclipse__startup.png b/documentation/source/developerReference/images/usingGit/eclipse__startup.png deleted file mode 100644 index ff632abbca..0000000000 Binary files a/documentation/source/developerReference/images/usingGit/eclipse__startup.png and /dev/null differ diff --git a/documentation/source/developerReference/images/usingGit/github__forking__01.png b/documentation/source/developerReference/images/usingGit/github__forking__01.png index 678f57fd03..d8d7f3350a 100644 Binary files a/documentation/source/developerReference/images/usingGit/github__forking__01.png and b/documentation/source/developerReference/images/usingGit/github__forking__01.png differ diff --git a/documentation/source/developerReference/images/usingGit/github__pull_requests__1.png b/documentation/source/developerReference/images/usingGit/github__pull_requests__1.png index 7ad95188a4..387eb84614 100644 Binary files a/documentation/source/developerReference/images/usingGit/github__pull_requests__1.png and b/documentation/source/developerReference/images/usingGit/github__pull_requests__1.png differ diff --git a/documentation/source/developerReference/images/usingGit/github__pull_requests__2.png b/documentation/source/developerReference/images/usingGit/github__pull_requests__2.png index 770a5d9e86..264c8b7ccb 100644 Binary files a/documentation/source/developerReference/images/usingGit/github__pull_requests__2.png and b/documentation/source/developerReference/images/usingGit/github__pull_requests__2.png differ diff --git a/documentation/source/developerReference/images/usingGit/github__pull_requests__3.png b/documentation/source/developerReference/images/usingGit/github__pull_requests__3.png index d2d9808473..61968087dd 100644 Binary files a/documentation/source/developerReference/images/usingGit/github__pull_requests__3.png and b/documentation/source/developerReference/images/usingGit/github__pull_requests__3.png differ diff --git a/documentation/source/developerReference/index.rst b/documentation/source/developerReference/index.rst index d2ced98987..e313fc4dc6 100644 --- a/documentation/source/developerReference/index.rst +++ b/documentation/source/developerReference/index.rst @@ -8,6 +8,7 @@ Developer Reference testing documenting buildingDocumentation + releasing installJupyter usingGit diff --git a/documentation/source/developerReference/installJupyter.ipynb b/documentation/source/developerReference/installJupyter.ipynb index d131885103..7442b9089b 100644 --- a/documentation/source/developerReference/installJupyter.ipynb +++ b/documentation/source/developerReference/installJupyter.ipynb @@ -18,15 +18,15 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "To get `music21` to work it's best to have a good pip installation first. Then you should be able to simply install jupyter (and IPython if you don't have it):" + "To get `music21` to work it's best to have a good uv installation first. Then you should be able to simply install jupyter (and IPython if you don't have it), in your `m21play` folder (or whatever you named it):" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "```$ sudo pip install jupyter\n", - "$ sudo pip install ipython```" + "```$ uv add jupyter\n", + "$ uv add ipython```" ] }, { diff --git a/documentation/source/developerReference/releasing.rst b/documentation/source/developerReference/releasing.rst new file mode 100644 index 0000000000..006319addf --- /dev/null +++ b/documentation/source/developerReference/releasing.rst @@ -0,0 +1,7 @@ +.. _releasing: + +Releasing a New Version +======================= + +The steps for releasing a new version of `music21` are in +`RELEASING.md `_. diff --git a/documentation/source/developerReference/testing.ipynb b/documentation/source/developerReference/testing.ipynb index c5655895e7..b26d6ab84f 100644 --- a/documentation/source/developerReference/testing.ipynb +++ b/documentation/source/developerReference/testing.ipynb @@ -84,11 +84,11 @@ "source": [ "instead of `music21.mainTest(Test)` you can write `music21.mainTest(Test, verbose=True)` or while debugging `music21.mainTest(Test, runTest='testNotesHaveStemDirection')` to run a single test. Or to run a single test, you can pass it from the command line:\n", "\n", - " python3 note.py testNotesHaveStemDirection\n", + " uv run note.py testNotesHaveStemDirection\n", "\n", "You can also call the tests for the module with:\n", "\n", - " python3 -m music21.note\n" + " uv run python -m music21.note\n" ] }, { @@ -176,15 +176,15 @@ "\n", "The simplest way of running all the tests for `music21` is to run:\n", "\n", - " python3 test/multiprocessTest.py\n", + " uv run test/multiprocessTest.py\n", " \n", "This will use `(n - 1)` of your n-processor system to run all of the tests and will print a lot of output along the way. The tests run in reverse-alphabetical order (with some slow tests boosted to the beginning). All failed tests will appear near the bottom of the output.\n", "\n", "A few modules are not tested in `multiprocessTest` because they do not play well with multiprocessing or are in obscure subdirectories. They do not need to be tested on a general basis, but before a major release they should be tested with:\n", "\n", - " python3 test/singleCoreAll.py\n", + " uv run test/testSingleCoreAll.py\n", " \n", - "Before a release, this should be run with every version of Python that is supported (and preferably on both Mac and PC). This can be a pain, so fortunately we have..." + "Before a release (the full checklist is in :ref:`RELEASING.md `), this should be run with every version of Python that is supported (and preferably on both Mac and PC). This can be a pain, so fortunately we have..." ] }, { @@ -197,7 +197,7 @@ "\n", " https://github.com/cuthbertLab/music21/actions\n", " \n", - "and hopefully it's running Green at least for the master branch. GitHub runs the `ciMain()` function of test/singleCoreAll which is the same as the `main()` function except it exits with 0 or -1 or whatever the UNIX convention for failure is (I always forget--that's why I coded it). Thanks to the great folks at GitHub, the risks of accepting pull requests is lower (though not zero, especially in cases where there are changes to external output or to parsing files outside the corpus). Although the amount of time to run varies depending on the load of GitHub, keep a look out for changes that significantly slow down the system -- they may indicate an inefficient algorithm.\n", + "and hopefully it's running Green at least for the master branch. GitHub runs the `ciMain()` function of test/testSingleCoreAll which is the same as the `main()` function except it exits with 0 or -1 or whatever the UNIX convention for failure is (I always forget--that's why I coded it). Thanks to the great folks at GitHub, the risks of accepting pull requests is lower (though not zero, especially in cases where there are changes to external output or to parsing files outside the corpus). Although the amount of time to run varies depending on the load of GitHub, keep a look out for changes that significantly slow down the system -- they may indicate an inefficient algorithm.\n", "\n", "All pull requests **must** pass GitHub to be accepted. It doesn't matter that \"that bug was probably there before\" -- it's time to fix it or to get help fixing it." ] diff --git a/documentation/source/developerReference/usingGit.rst b/documentation/source/developerReference/usingGit.rst index 9ab7588a50..ca153c32f7 100644 --- a/documentation/source/developerReference/usingGit.rst +++ b/documentation/source/developerReference/usingGit.rst @@ -103,7 +103,7 @@ Making pull requests is easy: functionality? When you've finished writing your description, click on the ``Send pull - request`` button. You've sent your pull pull request! + request`` button. You've sent your pull request! .. image:: images/usingGit/github__pull_requests__3.png diff --git a/documentation/source/installing/images/macScreenConfigAssistantFinaleInstall.jpg b/documentation/source/installing/images/macScreenConfigAssistantFinaleInstall.jpg deleted file mode 100644 index c38a3f4cbb..0000000000 Binary files a/documentation/source/installing/images/macScreenConfigAssistantFinaleInstall.jpg and /dev/null differ diff --git a/documentation/source/installing/images/macScreenConfigAssistantQuestions.png b/documentation/source/installing/images/macScreenConfigAssistantQuestions.png deleted file mode 100644 index 68a10de5bc..0000000000 Binary files a/documentation/source/installing/images/macScreenConfigAssistantQuestions.png and /dev/null differ diff --git a/documentation/source/installing/images/macScreenConfigAssistantReader.png b/documentation/source/installing/images/macScreenConfigAssistantReader.png deleted file mode 100644 index 8c8955db05..0000000000 Binary files a/documentation/source/installing/images/macScreenConfigAssistantReader.png and /dev/null differ diff --git a/documentation/source/installing/images/macScreenConfigAssistantSelect.png b/documentation/source/installing/images/macScreenConfigAssistantSelect.png deleted file mode 100644 index a3e4cf2d15..0000000000 Binary files a/documentation/source/installing/images/macScreenConfigAssistantSelect.png and /dev/null differ diff --git a/documentation/source/installing/images/macScreenConfigAssistantStart.png b/documentation/source/installing/images/macScreenConfigAssistantStart.png deleted file mode 100644 index 76e2700cad..0000000000 Binary files a/documentation/source/installing/images/macScreenConfigAssistantStart.png and /dev/null differ diff --git a/documentation/source/installing/images/macScreenMusic21Folder.png b/documentation/source/installing/images/macScreenMusic21Folder.png deleted file mode 100644 index d88434850f..0000000000 Binary files a/documentation/source/installing/images/macScreenMusic21Folder.png and /dev/null differ diff --git a/documentation/source/installing/images/macScreenPythonVersion.png b/documentation/source/installing/images/macScreenPythonVersion.png deleted file mode 100644 index f95c7c272c..0000000000 Binary files a/documentation/source/installing/images/macScreenPythonVersion.png and /dev/null differ diff --git a/documentation/source/installing/images/macScreenShow.png b/documentation/source/installing/images/macScreenShow.png deleted file mode 100644 index c64a0dff77..0000000000 Binary files a/documentation/source/installing/images/macScreenShow.png and /dev/null differ diff --git a/documentation/source/installing/images/macScreenShowText.png b/documentation/source/installing/images/macScreenShowText.png deleted file mode 100644 index e0b1fa6000..0000000000 Binary files a/documentation/source/installing/images/macScreenShowText.png and /dev/null differ diff --git a/documentation/source/installing/images/macScreenSitePackages.png b/documentation/source/installing/images/macScreenSitePackages.png deleted file mode 100644 index d635ae4fbd..0000000000 Binary files a/documentation/source/installing/images/macScreenSitePackages.png and /dev/null differ diff --git a/documentation/source/installing/installAdditional.rst b/documentation/source/installing/installAdditional.rst index 32e2e3a6f2..cad4924e13 100644 --- a/documentation/source/installing/installAdditional.rst +++ b/documentation/source/installing/installAdditional.rst @@ -47,8 +47,8 @@ install additional Python libraries. The following topics cover additional software used by `music21`. For each library, visit the websites listed below, download the additional python libraries, and install them just as you did with -`music21` above. If you don't feel like installing them now, no worries: -you can always install them later. +`music21` (with `uv add`, in your `m21play` folder, or whatever you named it). +If you don't feel like installing them now, no worries: you can always install them later. It's best not to install these until you have `music21` working without additional modules. @@ -64,15 +64,11 @@ Matplotlib Graphing and visual displays in Music21 are provided by Matplotlib. Matplotlib itself has additional dependencies (such as NumPy). -On Mac if you are using the version from python.org, run: +In your `m21play` folder, run: -`pip3 install matplotlib` +`uv add matplotlib` -and you should be set. If you are using a version from anaconda -(conda, miniconda, etc.), you should run these lines:: - - conda install matplotlib - conda install python.app +and you should be set. numpy @@ -81,9 +77,9 @@ numpy Numeric extensions to Python. Included with Matplotlib. -On Mac if you are using the version of python 3 from python.org, run:: +In your `m21play` folder, run:: - pip3 install numpy + uv add numpy and you should be set. @@ -97,9 +93,9 @@ audio searching and fast-Fourier transforms in `music21`. If you will only be using symbolic music, it is not used, and not essential in any case. -On Mac if you are using the version from python.org, run:: +In your `m21play` folder, run:: - pip3 install scipy + uv add scipy and you should be set. @@ -114,7 +110,7 @@ On the Mac, run:: xcode-select --install brew install portaudio - pip3 install pyaudio + uv add pyaudio pygame @@ -122,8 +118,8 @@ pygame Allows for realtime MIDI performance. We use it in the midi.realtime module, and nowhere else. It is therefore not essential. -On the Mac, run:: +In your `m21play` folder, run:: - pip3 install pygame + uv add pygame Pygame 1.9 or later is required for correct error handling diff --git a/documentation/source/installing/installLinux.rst b/documentation/source/installing/installLinux.rst index f163840ac1..1718152a4c 100644 --- a/documentation/source/installing/installLinux.rst +++ b/documentation/source/installing/installLinux.rst @@ -18,45 +18,35 @@ maintainers will only entertain `music21`-specific problems that can also be reproduced on Mac or Windows. To reiterate: **GNU/Linux/etc. is not a system for which support -questions will be answered**. See Consulting in the FAQ for details on paid support. - - -Check Your Version of Python ----------------------------------------------- - -`Music21` requires a recent version of Python. - -To determine the Python version you have installed, open a shell -or terminal and enter the following command-line argument (where "$" is the prompt): - - $ python3 -V - -it should display something like: - - Python 3.13.2 - -if so, you're okay. If not, upgrade your version of Python. This is -often a problem on some AWS configuations, Google Colab, etc. -which do not ship by default with recent versions of Python. +questions will be answered**. Ask an AI agent that knows more about your OS +(tell it which one you have, such as Ubuntu 24.04) to help you with the steps below. Download `music21` ---------------------------------------------- -Download the newest version with: +Download the newest version with uv, in a folder for your `music21` work +(see :ref:`installMac`; the uv install command there works here too):: - pip3 install --upgrade music21 + uv init --bare + uv add music21 Configure `music21` ---------------------------------------------- Configure music21 with: - python3 -m music21.configure + uv run python -m music21.configure + +The configuration assistant does not ask for your MusicXML reader on these +systems. `music21` looks for MuseScore at `/usr/bin/mscore3`; if yours is +somewhere else, set it in `uv run python`:: -It is important to put the path to your MusicXML reader during these -prompts. Music21 cannot find where your XML reader (probably MuseScore) -is installed on most Unix/Linux systems. If you do not know about + from music21 import environment + environment.set('musicxmlPath', '/usr/bin/mscore') + environment.set('musescoreDirectPNGPath', '/usr/bin/mscore') + +If you do not know about filepaths and how to find them, you should probably not be using GNU/Linux, FreeBSD, etc. with `music21`. @@ -67,9 +57,8 @@ this is not guaranteed. After Installation ------------------------------- -After a successful installation, you may proceed to -:ref:`Notes ` to -begin using `music21`. +After a successful installation, go back to :ref:`Chapter 1 ` +of the User's Guide to try out `music21`. Learned Experience @@ -90,7 +79,7 @@ Still have problems? If you have followed all the instructions and still encounter problems, start over from scratch and try it again very carefully. -If you still have problems **contact an expert in your operating system**. - -The `music21` maintainers cannot help with Unix installation problems except -as paid support. See Consulting in the FAQ for details on paid support. +If you still have problems ask an AI agent that knows about your operating system +or contact an expert in your operating system. Please do not bring Unix issues to +the mailing list or GitHub unless you're also arriving with solutions (that go +beyond problems created by boutique installations). diff --git a/documentation/source/installing/installMac.rst b/documentation/source/installing/installMac.rst index 353dda73ba..7b0aea2f8c 100644 --- a/documentation/source/installing/installMac.rst +++ b/documentation/source/installing/installMac.rst @@ -3,175 +3,129 @@ Installing `music21` on Mac ============================================ -Because Apple changes their system details so much -from one version of OS X to another, installing `music21` -is probably the hardest part of the experience. -Once you have that set, the rest should be much less painful. -Trust us that it should work. +These steps give you a folder for your `music21` work, with Python, `music21`, +and Jupyter inside it. Jupyter lets you write and run `music21` code in your +web browser and see the results (including notation) right under it. +You don't need to install Python first; the steps below do that for you. -Downloading Python 3 from python.org +Open Terminal --------------------------------------- -Though most Macs come with Python, it's usually a very old version. -We recommend updating that by going to https://www.python.org/downloads -and getting the latest version. +Terminal is where you type commands to your Mac. It's in Applications, then +Utilities, then "Terminal" (or press Command-Space and type "Terminal"). +You'll use it often, so you might want to drag it to the Dock. -(The `music21` team strongly recommends using the version of -Python 3 from the python.org website and not the Conda version -(or Enthought Canopy) because music21 is fully compatible with -`matplotlib` on the python.org site. -If you have no plans on plotting functions, -feel free to use any flavor of Python 3.) +Install uv +--------------------------------------- -Simple install on macOS Sierra or OS X El Capitan --------------------------------------------------- - -If you're on the newest versions of OS X, Sierra or El Capitan, then -installation is very easy. Open up "Terminal" in "Applications -> Utilities", -then type at the prompt: - - sudo pip3 install music21 - -it will prompt you for your password. TADA: you have `music21` for Python! - -To upgrade later, just do - - sudo pip3 install --upgrade music21 - -If you have a techy friend, he or she may recommend you use a virtual -environment to keep your `music21` installation separate from other -installations. If you friend is willing to help you through -the additional problems that come with that (and the additional features), -listen to them. Otherwise, do it our way above. - - - -Starting Python and Checking the Version ----------------------------------------------- - -Python is a simple but powerful programming language that `music21` -is written in and in which you will write your own programs that -use `music21`. +uv is a free tool that installs Python and Python packages like `music21`. +Copy this line into Terminal and press Return:: -To determine the Python version you have installed, open a -terminal (by going to Applications, then Utilities, and then -double clicking "Terminal") and enter the following command-line argument: + curl -LsSf https://astral.sh/uv/install.sh | sh - `python3 -V` +When it finishes, close the Terminal window and open a new one, so that +Terminal knows where uv is. (If you use Homebrew, `brew install uv` works too.) -it should display in Terminal something like the following: -.. image:: images/macScreenPythonVersion.* - :width: 650 +Make a folder for `music21` +--------------------------------------- -If it says 3.12 or higher (or possibly a number like 3.12.7), you're okay. -If it says 2.7 or 3.4 or something, -go to https://www.python.org/downloads/ -and download a newer version. Multiple versions of Python can exist -on a single computer without any problems. +Type these lines one at a time, pressing Return after each:: -After starting Python, try typing: + mkdir ~/m21play + cd ~/m21play + uv init --bare + uv add music21 + uv add notebook - `2+2` +This makes a folder called `m21play` (or whatever you want to call your `music21` +playing around applications) in your home folder (the one with +the house icon in Finder) and installs Python, `music21`, and Jupyter Notebook +into it. Just don't call the folder `music21` +itself. -You should see `4`. This means Python is working. Now see if -`music21` is working by typing: - `import music21` +Set up music notation +--------------------------------------- -Hopefully this should work fine. +To see notation in the browser, `music21` needs MuseScore -- so download it even if +you normally use Dorico or Sibelius or Finale. It's free at https://musescore.org; +install it before going on. -Exit python by typing `quit()`. +Then run the Configuration Assistant. In the same Terminal window, type:: + uv run python -m music21.configure -Starting the Configuration Assistant ------------------------------------------------------ +After waiting a few moments to load modules, the Configuration Assistant begins. +It lists the notation programs it finds on your Mac. Type the number of your +favorite and press Return: outside of Jupyter, `music21` will open scores there. -If you only installed the `music21` package with `pip` (for -instance, by running `sudo pip3 install music21`), you should run -the assistant from a command prompt:: +Next you will be asked a number of +questions about working with `music21`. They concern whether `music21` can access +the Internet, and whether you are willing to comply with the license for +use of `music21` and the included corpus of scores. You have to accept the +license to continue (we need to retain some rights you know!), but you +don't have to give us access to the Internet. - python3 -m music21.configure -You can also run the Configuration Assistant from a Python shell - like this:: +Start Jupyter +--------------------------------------- - import music21 - music21.configure.run() +Still in the `m21play` folder, type:: -After waiting a few moments to load modules, the Configuration Assistant begins. + uv run jupyter notebook -.. image:: images/macScreenConfigAssistantStart.* - :width: 650 +Your web browser opens with a list of the files in your folder. Choose +"New", then "Notebook", and pick the Python kernel if asked. In the first box +(a "cell"), type:: -If you have never used `music21` before, -following these prompts is recommended. + from music21 import * + n = note.Note('A4') + n.pitch.frequency -Selecting a MusicXML reader is the first step. -MusicXML is one of many display formats used by `music21`, and -will provide an easy way for you to visualize, print, and -transfer the music you edit or develop in `music21`. +and press Shift-Return. You should see `440.0`: `music21` is working. Now try +`n.show()` in the next cell to see the note as notation. -The Configuration Assistant will attempt to find a MusicXML -reader on your system. If it can't find any, you will be asked -to open a URL to download MuseScore, a simple and free -MusicXML reader and easy writer. Installing MuseScore is -recommended for users who do not have Finale, Sibelius, Dorico, MuseScore, -or another MusicXML reader. +When you're done, close the browser tab and press Control-C twice in Terminal. -If one or more MusicXML readers are found, skip ahead to the next instructions. -.. image:: images/macScreenConfigAssistantReader.* - :width: 650 +Coming back later +--------------------------------------- -If you choose to install MuseScore (formerly we suggested Finale Reader; hence the pictures below), -you will download an installer. Launch the installer immediately, and follow the instructions. +Open Terminal and type:: -.. image:: images/macScreenConfigAssistantFinaleInstall.* - :width: 650 + cd ~/m21play + uv run jupyter notebook -After installing a MusicXML reader, or if you already have -one or more installed, the Configuration Assistant will present you with a -list of MusicXML readers from which to select one to use with music21 by -default. This means that `music21` will attempt to open MusicXML files -with this application. This setting can be easily changed later. -Enter the number of the selection as presented in the list: +To get the newest version of `music21`, type this in the folder (not the jupyter notebook):: -.. image:: images/macScreenConfigAssistantSelect.* - :width: 650 + uv add --upgrade music21 -After selecting a MusicXML reader, you will be asked a number of -questions about working with `music21`. They concern whether music21 can access -the Internet, and whether you are willing to comply with the license for -use of music21 and the included corpus of scores. You have to accept the -license to continue (we need to retain some rights you know!), but you -don't have to give us access to the Internet. +Other packages go in the same way; for instance, `uv add matplotlib` for +`music21`'s graphs. -.. image:: images/macScreenConfigAssistantQuestions.* - :width: 650 +If you'd rather use Python without Jupyter, `uv run python` in the folder +starts it with `music21` ready to import. -After the Configuration Assistant is complete, you can close the window when -it says "[process terminated]". +If you already have your own Python setup (a virtual environment, conda, and so on), +`pip install music21` there works too. After Installation ------------------------------- -Open up the Mac Terminal (under Applications/Utilities/Terminal). You might want -to drag it to the dock. You'll use it often. - -After a successful installation, you may proceed to :ref:`Notes ` to -begin using music21. +After a successful installation, go back to :ref:`Chapter 1 ` +of the User's Guide to try out `music21`. Installation Help ------------------------------- If you have followed all the instructions and still encounter problems, start over from scratch -and try it again very carefully. If you still have problems -contact the `music21` group and someone should be able to help: +and try it again very carefully. If you still have problems ask an AI agent (most know +`music21` pretty well) or lean on humans and contact the `music21` group. It's a friendly +group with users always happy to help: https://groups.google.com/g/music21list - diff --git a/documentation/source/installing/installWindows.rst b/documentation/source/installing/installWindows.rst index bb0bdcb1ff..b04085c3bf 100644 --- a/documentation/source/installing/installWindows.rst +++ b/documentation/source/installing/installWindows.rst @@ -1,93 +1,132 @@ .. _installWindows: - -Installing `music21` in Windows +Installing `music21` on Windows ============================================ +These steps give you a folder for your `music21` work, with Python, `music21`, +and Jupyter inside it. Jupyter lets you write and run `music21` code in your +web browser and see the results (including notation) right under it. +You don't need to install Python first; the steps below do that for you. -Get Python -------------------------------- -Python is a simple but powerful programming language that `music21` -is written in and in which you will write your own programs that -use `music21`. +Open PowerShell +--------------------------------------- -Windows users should download and install Python version -3.12 or higher. +PowerShell is where you type commands to Windows. Press the Windows key, type +"PowerShell", and press Enter. (On Windows 11, "Terminal" opens the same thing.) +You'll use it often, so you might want to pin it to the taskbar. -To get Python for Windows, go to https://www.python.org/downloads/ -and click on the "Windows installer" link. It is probably the -first link. Save the file to your desktop -and then click on it there. -To test to see if Python has been installed properly, go -to the start menu and run (either by clicking "Run" in older -Windows or by typing in the search box) a program called `IDLE`. -Once it's started, type `2+2`. If your system then -displays `4` python is working properly and we can start thinking -about installing `music21`. +Install uv +--------------------------------------- +uv is a free tool that installs Python and Python packages like `music21`. +Copy this line into PowerShell and press Enter (if it asks you to agree to +terms, type Y):: -Updating Python -------------------------------- -If you have already installed Python on your computer, -launch IDLE (a Python interpreter and code editor) by clicking -the start menu and clicking Run (on Windows XP or older) and -typing in "IDLE" or (on Windows Vista and newer) typing -in "IDLE" in the Search Programs list. + winget install --id=astral-sh.uv -e -The first lines of text displayed will include a version number. -Make sure it begins with 3.12 or higher. +When it finishes, close the PowerShell window and open a new one, so that +PowerShell knows where uv is. -If your version is too old, download a newer version as above. +Make a folder for `music21` +--------------------------------------- -Install `music21` -------------------------------- +Type these lines one at a time, pressing Enter after each:: + + mkdir ~\m21play + cd ~\m21play + uv init --bare + uv add music21 + uv add notebook + +This makes a folder called `m21play` (or whatever you want to call your `music21` +playing around applications) in your home folder (``C:\Users\`` followed by your +name) and installs Python, `music21`, and Jupyter Notebook +into it. Just don't call the folder `music21` +itself. + + +Set up music notation +--------------------------------------- + +To see notation in the browser, `music21` needs MuseScore -- so download it even if +you normally use Dorico or Sibelius or Finale. It's free at https://musescore.org; +install it before going on. + +Then run the Configuration Assistant. In the same PowerShell window, type:: + + uv run python -m music21.configure + +After waiting a few moments to load modules, the Configuration Assistant begins. +It lists the notation programs it finds on your computer. Type the number of your +favorite and press Enter: outside of Jupyter, `music21` will open scores there. + +Next you will be asked a number of +questions about working with `music21`. They concern whether `music21` can access +the Internet, and whether you are willing to comply with the license for +use of `music21` and the included corpus of scores. You have to accept the +license to continue (we need to retain some rights you know!), but you +don't have to give us access to the Internet. + + +Start Jupyter +--------------------------------------- + +Still in the `m21play` folder, type:: + + uv run jupyter notebook + +Your web browser opens with a list of the files in your folder. Choose +"New", then "Notebook", and pick the Python kernel if asked. In the first box +(a "cell"), type:: + + from music21 import * + n = note.Note('A4') + n.pitch.frequency + +and press Shift-Enter. You should see `440.0`: `music21` is working. Now try +`n.show()` in the next cell to see the note as notation. -Open up a command prompt and type: +When you're done, close the browser tab and press Control-C in PowerShell. - pip install music21 -This will download and install `music21`. If you already -have `music21` but want to upgrade to the latest version, run: +Coming back later +--------------------------------------- - pip install --upgrade music21 +Open PowerShell and type:: + cd ~\m21play + uv run jupyter notebook -To test to see if `music21` has been installed properly, go -to the start menu and run (either by clicking "Run" in older -Windows or by typing in the search box) IDLE. Type -"`import music21`". If your system waits for a few seconds and then -displays "`>>>`" and perhaps a warning about missing packages, then -you've installed everything properly. (If the system -cannot find `music21` then you may have more than one version of -Python on your system. Try uninstalling all of them along with `music21` -and then restarting from scratch). +To get the newest version of `music21`, type this in the folder (not the jupyter notebook):: -You should then configure `music21` to find your helper programs -such as MuseScore or Finale. In the command prompt, type:: + uv add --upgrade music21 - python3 -m music21.configure +Other packages go in the same way; for instance, `uv add matplotlib` for +`music21`'s graphs. -Or, in IDLE type:: +If you'd rather use Python without Jupyter, `uv run python` in the folder +starts it with `music21` ready to import. - import music21 - music21.configure.run() +If you already have your own Python setup (a virtual environment, conda, and so on), +`pip install music21` there works too. After Installation ------------------------------- -After a successful installation, you may proceed to :ref:`Notes ` -to begin using `music21`. +After a successful installation, go back to :ref:`Chapter 1 ` +of the User's Guide to try out `music21`. Installation Help ------------------------------- If you have followed all the instructions and still encounter problems, start over from scratch -and try it again very carefully. If you still have problems -contact the `music21` group and someone should be able to help: +and try it again very carefully. If you still have problems ask an AI agent (most know +`music21` pretty well) or lean on humans and contact the `music21` group. It's a friendly +group with users always happy to help: https://groups.google.com/g/music21list diff --git a/documentation/source/moduleReference/images/ClercqTemperleyIntroduction.png b/documentation/source/moduleReference/images/ClercqTemperleyIntroduction.png deleted file mode 100644 index 93c7e6984d..0000000000 Binary files a/documentation/source/moduleReference/images/ClercqTemperleyIntroduction.png and /dev/null differ diff --git a/documentation/source/moduleReference/images/Graph3DPolygonBars.png b/documentation/source/moduleReference/images/Graph3DPolygonBars.png deleted file mode 100644 index 0aaed831b7..0000000000 Binary files a/documentation/source/moduleReference/images/Graph3DPolygonBars.png and /dev/null differ diff --git a/documentation/source/moduleReference/images/bachnononharm.png b/documentation/source/moduleReference/images/bachnononharm.png deleted file mode 100644 index 79c8c9250c..0000000000 Binary files a/documentation/source/moduleReference/images/bachnononharm.png and /dev/null differ diff --git a/documentation/source/moduleReference/images/completebach.png b/documentation/source/moduleReference/images/completebach.png deleted file mode 100644 index 91c09bba19..0000000000 Binary files a/documentation/source/moduleReference/images/completebach.png and /dev/null differ diff --git a/documentation/source/moduleReference/images/figuredBass/fbRealizer_intro2.png b/documentation/source/moduleReference/images/figuredBass/fbRealizer_intro2.png deleted file mode 100644 index 0bf10f5bd4..0000000000 Binary files a/documentation/source/moduleReference/images/figuredBass/fbRealizer_intro2.png and /dev/null differ diff --git a/documentation/source/moduleReference/images/medren_Ligature_Mensural-Example.jpg b/documentation/source/moduleReference/images/medren_Ligature_Mensural-Example.jpg deleted file mode 100644 index a313adb4df..0000000000 Binary files a/documentation/source/moduleReference/images/medren_Ligature_Mensural-Example.jpg and /dev/null differ diff --git a/documentation/source/moduleReference/images/medren_Ligature_Non-mensural-Example.png b/documentation/source/moduleReference/images/medren_Ligature_Non-mensural-Example.png deleted file mode 100644 index 3b6ba2069d..0000000000 Binary files a/documentation/source/moduleReference/images/medren_Ligature_Non-mensural-Example.png and /dev/null differ diff --git a/documentation/source/moduleReference/images/medren_SePerDureca.jpg b/documentation/source/moduleReference/images/medren_SePerDureca.jpg deleted file mode 100644 index b825321b0b..0000000000 Binary files a/documentation/source/moduleReference/images/medren_SePerDureca.jpg and /dev/null differ diff --git a/documentation/source/moduleReference/images/medren_SePerDurecaConverted.png b/documentation/source/moduleReference/images/medren_SePerDurecaConverted.png deleted file mode 100644 index 39744cb4ba..0000000000 Binary files a/documentation/source/moduleReference/images/medren_SePerDurecaConverted.png and /dev/null differ diff --git a/documentation/source/moduleReference/images/medren_SePerDurecaConverted/medren_SePerDurecaConverted.png b/documentation/source/moduleReference/images/medren_SePerDurecaConverted/medren_SePerDurecaConverted.png deleted file mode 100644 index 39744cb4ba..0000000000 Binary files a/documentation/source/moduleReference/images/medren_SePerDurecaConverted/medren_SePerDurecaConverted.png and /dev/null differ diff --git a/documentation/source/moduleReference/images/medren_convertHouseStyle_1.jpg b/documentation/source/moduleReference/images/medren_convertHouseStyle_1.jpg deleted file mode 100644 index 0599e73b48..0000000000 Binary files a/documentation/source/moduleReference/images/medren_convertHouseStyle_1.jpg and /dev/null differ diff --git a/documentation/source/moduleReference/images/medren_convertHouseStyle_2.jpg b/documentation/source/moduleReference/images/medren_convertHouseStyle_2.jpg deleted file mode 100644 index 3d2a7e414b..0000000000 Binary files a/documentation/source/moduleReference/images/medren_convertHouseStyle_2.jpg and /dev/null differ diff --git a/documentation/source/moduleReference/images/phasingDemo.png b/documentation/source/moduleReference/images/phasingDemo.png deleted file mode 100644 index 56385e14ab..0000000000 Binary files a/documentation/source/moduleReference/images/phasingDemo.png and /dev/null differ diff --git a/documentation/source/moduleReference/images/trecento-polyphonicSnippet1.png b/documentation/source/moduleReference/images/trecento-polyphonicSnippet1.png deleted file mode 100644 index 3f706eeb8c..0000000000 Binary files a/documentation/source/moduleReference/images/trecento-polyphonicSnippet1.png and /dev/null differ diff --git a/documentation/source/testsAndInProgress/devTest_inversions.ipynb b/documentation/source/testsAndInProgress/devTest_inversions.ipynb index f5aac02b06..9737e164f8 100644 --- a/documentation/source/testsAndInProgress/devTest_inversions.ipynb +++ b/documentation/source/testsAndInProgress/devTest_inversions.ipynb @@ -28,15 +28,6 @@ "%load_ext music21.ipython21" ] }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [], - "source": [ - "import copy" - ] - }, { "cell_type": "code", "execution_count": 4, @@ -119,7 +110,7 @@ "metadata": {}, "outputs": [], "source": [ - "chord1.inversion(0)" + "chord1.setInversion(0, inPlace=True)" ] }, { @@ -178,9 +169,7 @@ "for i in range(20):\n", " inv = i % 3\n", " s.append(cMaj)\n", - " cMajCopy = copy.deepcopy(cMaj)\n", - " cMajCopy.inversion(inv)\n", - " cMaj = cMajCopy\n" + " cMaj = cMaj.setInversion(inv)\n" ] }, { @@ -255,9 +244,7 @@ "for i in range(7):\n", " inv = i % 3\n", " s.append(cMaj)\n", - " cMajCopy = copy.deepcopy(cMaj)\n", - " cMajCopy.inversion(inv)\n", - " cMaj = cMajCopy" + " cMaj = cMaj.setInversion(inv)" ] }, { @@ -296,9 +283,7 @@ "for i in range(17):\n", " inv = i % 6\n", " s.append(cMaj)\n", - " cMajCopy = copy.deepcopy(cMaj)\n", - " cMajCopy.inversion(inv)\n", - " cMaj = cMajCopy" + " cMaj = cMaj.setInversion(inv)" ] }, { @@ -385,9 +370,7 @@ "for i in range(12):\n", " inv = i % 3\n", " s.append(germ6)\n", - " germ6Copy = copy.deepcopy(germ6)\n", - " germ6Copy.inversion(inv)\n", - " germ6 = germ6Copy" + " germ6 = germ6.setInversion(inv)" ] }, { diff --git a/documentation/source/usersGuide/images/macScreenShow.png b/documentation/source/usersGuide/images/macScreenShow.png deleted file mode 100755 index c64a0dff77..0000000000 Binary files a/documentation/source/usersGuide/images/macScreenShow.png and /dev/null differ diff --git a/documentation/source/usersGuide/images/usersGuide/music21_derivations.jpg b/documentation/source/usersGuide/images/usersGuide/music21_derivations.jpg deleted file mode 100644 index 2f1873a727..0000000000 Binary files a/documentation/source/usersGuide/images/usersGuide/music21_derivations.jpg and /dev/null differ diff --git a/documentation/source/usersGuide/images/usersGuide/music21_flat.jpg b/documentation/source/usersGuide/images/usersGuide/music21_flat.jpg deleted file mode 100644 index 22b07489d6..0000000000 Binary files a/documentation/source/usersGuide/images/usersGuide/music21_flat.jpg and /dev/null differ diff --git a/documentation/source/usersGuide/images/usersGuide/music21_hierarchy.jpg b/documentation/source/usersGuide/images/usersGuide/music21_hierarchy.jpg deleted file mode 100644 index c81d5deeac..0000000000 Binary files a/documentation/source/usersGuide/images/usersGuide/music21_hierarchy.jpg and /dev/null differ diff --git a/documentation/source/usersGuide/usersGuide_01_installing.ipynb b/documentation/source/usersGuide/usersGuide_01_installing.ipynb index 3965b5a117..b02dade548 100644 --- a/documentation/source/usersGuide/usersGuide_01_installing.ipynb +++ b/documentation/source/usersGuide/usersGuide_01_installing.ipynb @@ -22,14 +22,11 @@ "\n", "If you're going to use `music21`, you'll need to have a copy of it and Python on your computer. The instructions are slightly different for each type of computer, so follow the link below and then come back to the system. For many people, installation is the most difficult step for a bit.\n", "\n", - "Mac users:\n", - ":ref:`installMac`\n", - "\n", - "Windows user (also installs Python):\n", - ":ref:`installWindows`\n", - "\n", - "Unix/Linux users or Mac users with unusual needs:\n", - ":ref:`installLinux`\n", + "| Your computer | Instructions |\n", + "|---|---|\n", + "| Mac | :ref:`installMac` |\n", + "| Windows | :ref:`installWindows` |\n", + "| Linux and other systems | :ref:`installLinux` |\n", "\n", "When a new version of `music21` is released, you can upgrade by using the same method that you originally used to install it.\n", "\n", @@ -37,10 +34,8 @@ "\n", "## Starting `music21`\n", "\n", - "We'll just see if music21 worked for you. Open up the Terminal (Mac) or IDLE (Windows). On the Mac type \"python\"\n", - "(without the quotes) and hit enter.\n", - "\n", - "To load music21 type this:" + "We'll just see if music21 worked for you. Start Jupyter in your `m21play` folder (or wherever you plan to run these tutorials), as the installation page shows,\n", + "and in a new notebook type this:" ] }, { @@ -56,7 +51,6 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "You'll probably get a few warnings that you're missing some optional modules. That's okay.\n", "If you get a warning that \"no module named music21\" then something probably went wrong above.\n", "Try going step-by-step through the instructions above one more time, making sure not to skip\n", "anything. 99% of installation errors come from skipping a step above. If you still have a\n", @@ -113,9 +107,8 @@ } }, "source": [ - "Now let's see if you can see scores with `music21`. If this doesn't work, you can skip ahead to :ref:`Chapter 8: Installing a MusicXML reader ` or just work through the tutorial until you get there without seeing scores. Type \"`s.show()`. Assuming your installation and configuration went as expected, \n", - "your MusicXML reader should launch and display the chorale, looking something\n", - "like what we see here:" + "Now let's see if you can see scores with `music21`. If this doesn't work, you can skip ahead to :ref:`Chapter 8: Installing a MusicXML reader ` or just work through the tutorial until you get there without seeing scores. Type \"`s.show()`\". Assuming your installation and configuration went as expected, \n", + "the chorale appears right below, looking something like what we see here:" ] }, { @@ -143,21 +136,6 @@ "s.show()" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "On your computer you might see something like this." - ] - }, - { - "cell_type": "raw", - "metadata": {}, - "source": [ - ".. image:: images/macScreenShow.*\n", - " :width: 650" - ] - }, { "cell_type": "markdown", "metadata": {}, diff --git a/documentation/source/usersGuide/usersGuide_03_pitches.ipynb b/documentation/source/usersGuide/usersGuide_03_pitches.ipynb index 2026ffef60..40c4f43f81 100644 --- a/documentation/source/usersGuide/usersGuide_03_pitches.ipynb +++ b/documentation/source/usersGuide/usersGuide_03_pitches.ipynb @@ -54,7 +54,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 1, "metadata": {}, "outputs": [], "source": [ @@ -85,7 +85,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 2, "metadata": {}, "outputs": [ { @@ -94,7 +94,7 @@ "4" ] }, - "execution_count": 3, + "execution_count": 2, "metadata": {}, "output_type": "execute_result" } @@ -105,7 +105,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 3, "metadata": {}, "outputs": [ { @@ -114,7 +114,7 @@ "10" ] }, - "execution_count": 4, + "execution_count": 3, "metadata": {}, "output_type": "execute_result" } @@ -125,7 +125,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 4, "metadata": {}, "outputs": [ { @@ -134,7 +134,7 @@ "'B-'" ] }, - "execution_count": 5, + "execution_count": 4, "metadata": {}, "output_type": "execute_result" } @@ -145,7 +145,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 5, "metadata": {}, "outputs": [ { @@ -154,7 +154,7 @@ "-1.0" ] }, - "execution_count": 6, + "execution_count": 5, "metadata": {}, "output_type": "execute_result" } @@ -177,7 +177,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 6, "metadata": {}, "outputs": [ { @@ -186,7 +186,7 @@ "'B-4'" ] }, - "execution_count": 7, + "execution_count": 6, "metadata": {}, "output_type": "execute_result" } @@ -197,7 +197,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 7, "metadata": {}, "outputs": [ { @@ -206,7 +206,7 @@ "70" ] }, - "execution_count": 8, + "execution_count": 7, "metadata": {}, "output_type": "execute_result" } @@ -215,6 +215,91 @@ "p1.midi" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Leave the octave off and the `Pitch` means \"a B-flat in *any* octave.\"\n", + "It prints without a number:" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "anyBFlat = pitch.Pitch('B-')\n", + "anyBFlat" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "But `music21` often needs an octave anyhow, to play the pitch or put it\n", + "on a staff, so `.octave` still answers with the default, `4` (the octave\n", + "of middle C), and `.octaveIsImplicit` remembers that you didn't give one:" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "4" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "anyBFlat.octave" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "anyBFlat.octaveIsImplicit" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Set `.octave` yourself and `.octaveIsImplicit` becomes `False`." + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -233,7 +318,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 11, "metadata": {}, "outputs": [ { @@ -242,7 +327,7 @@ "'D#3'" ] }, - "execution_count": 9, + "execution_count": 11, "metadata": {}, "output_type": "execute_result" } @@ -263,7 +348,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 12, "metadata": {}, "outputs": [ { @@ -272,7 +357,7 @@ "" ] }, - "execution_count": 10, + "execution_count": 12, "metadata": {}, "output_type": "execute_result" } @@ -306,7 +391,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 13, "metadata": {}, "outputs": [ { @@ -315,7 +400,7 @@ "'C#'" ] }, - "execution_count": 11, + "execution_count": 13, "metadata": {}, "output_type": "execute_result" } @@ -327,7 +412,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 14, "metadata": {}, "outputs": [ { @@ -336,7 +421,7 @@ "'C#'" ] }, - "execution_count": 12, + "execution_count": 14, "metadata": {}, "output_type": "execute_result" } @@ -347,7 +432,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 15, "metadata": {}, "outputs": [ { @@ -356,7 +441,7 @@ "4" ] }, - "execution_count": 13, + "execution_count": 15, "metadata": {}, "output_type": "execute_result" } @@ -367,7 +452,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 16, "metadata": {}, "outputs": [ { @@ -376,7 +461,7 @@ "4" ] }, - "execution_count": 14, + "execution_count": 16, "metadata": {}, "output_type": "execute_result" } @@ -394,7 +479,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 17, "metadata": {}, "outputs": [ { @@ -403,7 +488,7 @@ "'do sostenido'" ] }, - "execution_count": 15, + "execution_count": 17, "metadata": {}, "output_type": "execute_result" } @@ -421,7 +506,7 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 18, "metadata": { "tags": [ "nbval-raises-exception" @@ -453,7 +538,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 19, "metadata": {}, "outputs": [ { @@ -477,7 +562,7 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 20, "metadata": {}, "outputs": [ { @@ -550,7 +635,7 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 21, "metadata": {}, "outputs": [], "source": [ @@ -583,7 +668,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 22, "metadata": {}, "outputs": [], "source": [ @@ -604,7 +689,7 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 23, "metadata": {}, "outputs": [ { @@ -613,7 +698,7 @@ "1.5" ] }, - "execution_count": 21, + "execution_count": 23, "metadata": {}, "output_type": "execute_result" } @@ -624,7 +709,7 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 24, "metadata": {}, "outputs": [ { @@ -633,7 +718,7 @@ "2.0" ] }, - "execution_count": 22, + "execution_count": 24, "metadata": {}, "output_type": "execute_result" } @@ -652,7 +737,7 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 25, "metadata": {}, "outputs": [ { @@ -661,7 +746,7 @@ "'half'" ] }, - "execution_count": 23, + "execution_count": 25, "metadata": {}, "output_type": "execute_result" } @@ -672,7 +757,7 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": 26, "metadata": {}, "outputs": [ { @@ -681,7 +766,7 @@ "'quarter'" ] }, - "execution_count": 24, + "execution_count": 26, "metadata": {}, "output_type": "execute_result" } @@ -703,7 +788,7 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": 27, "metadata": {}, "outputs": [ { @@ -712,7 +797,7 @@ "0" ] }, - "execution_count": 25, + "execution_count": 27, "metadata": {}, "output_type": "execute_result" } @@ -723,7 +808,7 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": 28, "metadata": {}, "outputs": [ { @@ -732,7 +817,7 @@ "1" ] }, - "execution_count": 26, + "execution_count": 28, "metadata": {}, "output_type": "execute_result" } @@ -754,7 +839,7 @@ }, { "cell_type": "code", - "execution_count": 27, + "execution_count": 29, "metadata": {}, "outputs": [ { @@ -763,7 +848,7 @@ "1.75" ] }, - "execution_count": 27, + "execution_count": 29, "metadata": {}, "output_type": "execute_result" } @@ -775,7 +860,7 @@ }, { "cell_type": "code", - "execution_count": 28, + "execution_count": 30, "metadata": {}, "outputs": [ { @@ -784,7 +869,7 @@ "1.875" ] }, - "execution_count": 28, + "execution_count": 30, "metadata": {}, "output_type": "execute_result" } @@ -796,7 +881,7 @@ }, { "cell_type": "code", - "execution_count": 29, + "execution_count": 31, "metadata": {}, "outputs": [ { @@ -805,7 +890,7 @@ "1.9375" ] }, - "execution_count": 29, + "execution_count": 31, "metadata": {}, "output_type": "execute_result" } @@ -825,7 +910,7 @@ }, { "cell_type": "code", - "execution_count": 30, + "execution_count": 32, "metadata": {}, "outputs": [ { @@ -834,7 +919,7 @@ "'16th'" ] }, - "execution_count": 30, + "execution_count": 32, "metadata": {}, "output_type": "execute_result" } @@ -846,7 +931,7 @@ }, { "cell_type": "code", - "execution_count": 31, + "execution_count": 33, "metadata": {}, "outputs": [ { @@ -855,7 +940,7 @@ "0" ] }, - "execution_count": 31, + "execution_count": 33, "metadata": {}, "output_type": "execute_result" } @@ -913,7 +998,7 @@ }, { "cell_type": "code", - "execution_count": 47, + "execution_count": 34, "metadata": {}, "outputs": [ { @@ -922,7 +1007,7 @@ "" ] }, - "execution_count": 47, + "execution_count": 34, "metadata": {}, "output_type": "execute_result" } @@ -934,7 +1019,7 @@ }, { "cell_type": "code", - "execution_count": 48, + "execution_count": 35, "metadata": {}, "outputs": [ { @@ -943,7 +1028,7 @@ "" ] }, - "execution_count": 48, + "execution_count": 35, "metadata": {}, "output_type": "execute_result" } @@ -961,7 +1046,7 @@ }, { "cell_type": "code", - "execution_count": 49, + "execution_count": 36, "metadata": {}, "outputs": [], "source": [ @@ -978,7 +1063,7 @@ }, { "cell_type": "code", - "execution_count": 50, + "execution_count": 37, "metadata": {}, "outputs": [ { @@ -987,7 +1072,7 @@ "'half'" ] }, - "execution_count": 50, + "execution_count": 37, "metadata": {}, "output_type": "execute_result" } @@ -998,7 +1083,7 @@ }, { "cell_type": "code", - "execution_count": 51, + "execution_count": 38, "metadata": {}, "outputs": [ { @@ -1007,7 +1092,7 @@ "1" ] }, - "execution_count": 51, + "execution_count": 38, "metadata": {}, "output_type": "execute_result" } @@ -1018,7 +1103,7 @@ }, { "cell_type": "code", - "execution_count": 52, + "execution_count": 39, "metadata": {}, "outputs": [ { @@ -1027,7 +1112,7 @@ "'E-'" ] }, - "execution_count": 52, + "execution_count": 39, "metadata": {}, "output_type": "execute_result" } @@ -1038,7 +1123,7 @@ }, { "cell_type": "code", - "execution_count": 53, + "execution_count": 40, "metadata": {}, "outputs": [ { @@ -1047,7 +1132,7 @@ "" ] }, - "execution_count": 53, + "execution_count": 40, "metadata": {}, "output_type": "execute_result" } @@ -1058,7 +1143,7 @@ }, { "cell_type": "code", - "execution_count": 54, + "execution_count": 41, "metadata": {}, "outputs": [ { @@ -1067,7 +1152,7 @@ "5" ] }, - "execution_count": 54, + "execution_count": 41, "metadata": {}, "output_type": "execute_result" } @@ -1087,7 +1172,7 @@ }, { "cell_type": "code", - "execution_count": 55, + "execution_count": 42, "metadata": {}, "outputs": [ { @@ -1096,7 +1181,7 @@ "'E-'" ] }, - "execution_count": 55, + "execution_count": 42, "metadata": {}, "output_type": "execute_result" } @@ -1107,7 +1192,7 @@ }, { "cell_type": "code", - "execution_count": 56, + "execution_count": 43, "metadata": {}, "outputs": [ { @@ -1116,7 +1201,7 @@ "3.0" ] }, - "execution_count": 56, + "execution_count": 43, "metadata": {}, "output_type": "execute_result" } @@ -1134,7 +1219,7 @@ }, { "cell_type": "code", - "execution_count": 57, + "execution_count": 44, "metadata": {}, "outputs": [], "source": [ @@ -1153,7 +1238,7 @@ }, { "cell_type": "code", - "execution_count": 58, + "execution_count": 45, "metadata": {}, "outputs": [], "source": [ @@ -1173,7 +1258,7 @@ }, { "cell_type": "code", - "execution_count": 59, + "execution_count": 46, "metadata": {}, "outputs": [], "source": [ @@ -1191,7 +1276,7 @@ }, { "cell_type": "code", - "execution_count": 60, + "execution_count": 47, "metadata": {}, "outputs": [], "source": [ @@ -1215,7 +1300,7 @@ }, { "cell_type": "code", - "execution_count": 61, + "execution_count": 48, "metadata": {}, "outputs": [ { @@ -1250,7 +1335,7 @@ }, { "cell_type": "code", - "execution_count": 62, + "execution_count": 49, "metadata": {}, "outputs": [ { diff --git a/documentation/source/usersGuide/usersGuide_14_timeSignatures.ipynb b/documentation/source/usersGuide/usersGuide_14_timeSignatures.ipynb index 0d2307a8de..b539b87ea4 100644 --- a/documentation/source/usersGuide/usersGuide_14_timeSignatures.ipynb +++ b/documentation/source/usersGuide/usersGuide_14_timeSignatures.ipynb @@ -4,7 +4,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# User's Guide: Chapter 14: Time Signatures and Beats" + "# User's Guide, Chapter 14: Time Signatures and Beats" ] }, { diff --git a/documentation/source/usersGuide/usersGuide_17_derivations.ipynb b/documentation/source/usersGuide/usersGuide_17_derivations.ipynb index fee9e9d7a2..2fe016ac24 100644 --- a/documentation/source/usersGuide/usersGuide_17_derivations.ipynb +++ b/documentation/source/usersGuide/usersGuide_17_derivations.ipynb @@ -4,7 +4,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# User's Guide: Chapter 17: Derivations" + "# User's Guide, Chapter 17: Derivations" ] }, { diff --git a/documentation/source/usersGuide/usersGuide_18_intervals.ipynb b/documentation/source/usersGuide/usersGuide_18_intervals.ipynb index e7f464d35f..0937e1d5a1 100644 --- a/documentation/source/usersGuide/usersGuide_18_intervals.ipynb +++ b/documentation/source/usersGuide/usersGuide_18_intervals.ipynb @@ -14,7 +14,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# User's Guide: Chapter 18: Intervals" + "# User's Guide, Chapter 18: Intervals" ] }, { diff --git a/documentation/source/usersGuide/usersGuide_20_examples2.ipynb b/documentation/source/usersGuide/usersGuide_20_examples2.ipynb index 1ff52bcff0..23043c1113 100644 --- a/documentation/source/usersGuide/usersGuide_20_examples2.ipynb +++ b/documentation/source/usersGuide/usersGuide_20_examples2.ipynb @@ -703,7 +703,7 @@ " if analyzedKey.mode != 'minor':\n", " return False\n", " lastChord = getLastChord(score)\n", - " if lastChord.isMinorTriad() is False:\n", + " if not lastChord.isMinorTriad():\n", " return False\n", " if lastChord.root().name != analyzedKey.tonic.name:\n", " return False\n", diff --git a/documentation/source/usersGuide/usersGuide_22_graphing.ipynb b/documentation/source/usersGuide/usersGuide_22_graphing.ipynb index bc22f9fccc..40d029a5b3 100644 --- a/documentation/source/usersGuide/usersGuide_22_graphing.ipynb +++ b/documentation/source/usersGuide/usersGuide_22_graphing.ipynb @@ -18,7 +18,7 @@ } }, "source": [ - "# User’s Guide, Chapter 22: Graphing and plotting" + "# User's Guide, Chapter 22: Graphing and plotting" ] }, { diff --git a/documentation/source/usersGuide/usersGuide_55_advancedMeter.ipynb b/documentation/source/usersGuide/usersGuide_55_advancedMeter.ipynb index b256b237ad..86c6f0ff6d 100644 --- a/documentation/source/usersGuide/usersGuide_55_advancedMeter.ipynb +++ b/documentation/source/usersGuide/usersGuide_55_advancedMeter.ipynb @@ -181,17 +181,17 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "A MeterSequence object is a sub-class of a MeterTerminal. Like a MeterTerminal, a MeterSequence has a :attr:`numerator `, a :attr:`denominator `, and a :attr:`duration ` attribute. A MeterSequence, however, can be a hierarchical tree or sub-tree, containing an ordered sequence of MeterTerminal and/or MeterSequence objects.\n", + "Like a MeterTerminal, a MeterSequence has a :attr:`numerator `, a :attr:`denominator `, and a :attr:`duration ` attribute. A MeterSequence, however, can be a hierarchical tree or sub-tree, containing an ordered sequence of MeterTerminal and/or MeterSequence objects.\n", "\n", "The ordered collection of MeterTerminal and/or MeterSequence objects can be accessed like Python lists. MeterSequence objects, like MeterTerminal objects, store a weight that by default is the sum of constituent weights.\n", "\n", - "The :meth:`~music21.meter.MeterSequence.partition` and :meth:`~music21.meter.MeterTerminal.subdivide` methods can be used to configure the nested hierarchical structure.\n", + "MeterTerminal and MeterSequence objects cannot be changed. The :meth:`~music21.meter.MeterSequence.partitioned`, :meth:`~music21.meter.MeterTerminal.subdivide`, and :meth:`~music21.meter.MeterSequence.setIndex` methods each return a new object, which can be assigned to a variable or to a TimeSignature attribute, to configure the nested hierarchical structure.\n", "\n", - "The :meth:`~music21.meter.MeterSequence.partition` method replaces existing MeterTerminal or MeterSequence objects in place with a new arrangement, specified as a desired number of equal-valued components, a list of numerators assuming equal-denominators, or a list of string fraction representations.\n", + "The :meth:`~music21.meter.MeterSequence.partitioned` method returns a new MeterSequence with a new arrangement, specified as a desired number of equal-valued components, a list of numerators assuming equal-denominators, or a list of string fraction representations.\n", "\n", - "The :meth:`~music21.meter.MeterTerminal.subdivide` method returns a new MeterSequence (leaving the source MeterSequence unchanged) with an arrangement of MeterTerminals as specified by an argument in the same form as for the :meth:`~music21.meter.MeterSequence.partition` method.\n", + "The :meth:`~music21.meter.MeterTerminal.subdivide` method returns a new MeterSequence (leaving the source MeterSequence unchanged) with an arrangement of MeterTerminals as specified by an argument in the same form as for the :meth:`~music21.meter.MeterSequence.partitioned` method.\n", "\n", - "Note that MeterTerminal objects cannot be partitioned in place. A common way to convert a MeterTerminal into a MeterSequence is to reassign the returned MeterSequence from the :meth:`~music21.meter.MeterTerminal.subdivide` method to the position occupied by the MeterTerminal.\n", + "The :meth:`~music21.meter.MeterSequence.setIndex` method returns a new MeterSequence with one position replaced by an object of the same length. A common way to convert a MeterTerminal into a MeterSequence is to pass the MeterSequence returned from the :meth:`~music21.meter.MeterTerminal.subdivide` method to :meth:`~music21.meter.MeterSequence.setIndex` for the position occupied by the MeterTerminal.\n", "\n", "The following example creates and partitions a MeterSequence by re-assigning subdivisions to MeterTerminal objects. The use of Python list-like index access is also demonstrated.\n" ] @@ -234,7 +234,7 @@ } ], "source": [ - "ms.partition([3,3])\n", + "ms = ms.partitioned([3, 3])\n", "ms" ] }, @@ -275,7 +275,7 @@ } ], "source": [ - "ms[0] = ms[0].subdivide([3,3])\n", + "ms = ms.setIndex(0, ms[0].subdivide([3, 3]))\n", "ms[0]" ] }, @@ -316,7 +316,7 @@ } ], "source": [ - "ms[1] = ms[1].subdivide([1,1,1])\n", + "ms = ms.setIndex(1, ms[1].subdivide([1, 1, 1]))\n", "ms[1][0]" ] }, @@ -748,9 +748,9 @@ "source": [ "from music21 import stream, note\n", "ts1 = meter.TimeSignature('5/8') # assumes two partitions\n", - "ts1.displaySequence.partition(['3/16', '1/8', '5/16'])\n", + "ts1.displaySequence = ts1.displaySequence.partitioned(['3/16', '1/8', '5/16'])\n", "ts2 = meter.TimeSignature('5/8') # assumes two partitions\n", - "ts2.displaySequence.partition(['2/8', '3/8'])\n", + "ts2.displaySequence = ts2.displaySequence.partitioned(['2/8', '3/8'])\n", "ts2.summedNumerator = True\n", "s = stream.Stream()\n", "for ts in [ts1, ts2]:\n", @@ -807,24 +807,26 @@ ], "source": [ "ts1 = meter.TimeSignature('3/4')\n", - "ts1.beamSequence.partition(1)\n", - "ts1.beamSequence[0] = ts1.beamSequence[0].subdivide(['3/8', '5/32', '4/32', '3/32'])\n", + "ts1.beamSequence = ts1.beamSequence.partitioned(1)\n", + "ts1.beamSequence = ts1.beamSequence.setIndex(\n", + " 0, ts1.beamSequence[0].subdivide(['3/8', '5/32', '4/32', '3/32']))\n", "\n", "ts2 = meter.TimeSignature('3/4')\n", - "ts2.beamSequence.partition(3)\n", + "ts2.beamSequence = ts2.beamSequence.partitioned(3)\n", "\n", "ts3 = meter.TimeSignature('3/4')\n", - "ts3.beamSequence.partition(3)\n", - "\n", + "ts3.beamSequence = ts3.beamSequence.partitioned(3)\n", "for i in range(len(ts3.beamSequence)):\n", - " ts3.beamSequence[i] = ts3.beamSequence[i].subdivide(2)\n", + " ts3.beamSequence = ts3.beamSequence.setIndex(i, ts3.beamSequence[i].subdivide(2))\n", "\n", "ts4 = meter.TimeSignature('3/4')\n", - "ts4.beamSequence.partition(['3/8', '3/8'])\n", - "for i in range(len(ts4.beamSequence)):\n", - " ts4.beamSequence[i] = ts4.beamSequence[i].subdivide(['6/32', '6/32'])\n", - " for j in range(len(ts4.beamSequence[i])):\n", - " ts4.beamSequence[i][j] = ts4.beamSequence[i][j].subdivide(2)\n", + "beams = ts4.beamSequence.partitioned(['3/8', '3/8'])\n", + "for i in range(len(beams)):\n", + " group = beams[i].subdivide(['6/32', '6/32'])\n", + " for j in range(len(group)):\n", + " group = group.setIndex(j, group[j].subdivide(2))\n", + " beams = beams.setIndex(i, group)\n", + "ts4.beamSequence = beams\n", "\n", "s = stream.Stream()\n", "for ts in [ts1, ts2, ts3, ts4]:\n", @@ -932,7 +934,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 35, @@ -1093,7 +1095,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 43, @@ -1245,7 +1247,7 @@ "source": [ "score = corpus.parse('bach/bwv366.xml')\n", "ts = score[meter.TimeSignature].first()\n", - "ts.beatSequence.partition(3)\n", + "ts.beatSequence = ts.beatSequence.partitioned(3)\n", "\n", "found = stream.Stream()\n", "offsetQL = 0\n", @@ -1278,7 +1280,7 @@ "\n", "The :meth:`~music21.meter.TimeSignature.getBeatDepth` method, when set with the optional parameter `align` to \"quantize\", shows the number of hierarchical levels that start at or before that point. This value is described by Lerdahl and Jackendoff as metrical analysis.\n", "\n", - "In the following example, :meth:`beatSequence ` MeterSequence is partitioned first into one subdivision, and then each subsequent subdivision into two, down to four layers of partitioning.\n", + "In the following example, :meth:`beatSequence ` MeterSequence is partitioned first into one subdivision, and then each subsequent subdivision into two, down to four layers of partitioning, with the :meth:`~music21.meter.MeterSequence.subdividedNestedHierarchy` method.\n", "\n", "The number of hierarchical levels, found with the :meth:`~music21.meter.TimeSignature.getBeatDepth` method, is appended to each note with the :meth:`~music21.note.GeneralNote.addLyric` method." ] @@ -1308,13 +1310,7 @@ "score = corpus.parse('bach/bwv281.xml')\n", "partBass = score.getElementById('Bass')\n", "ts = partBass[meter.TimeSignature].first()\n", - "ts.beatSequence.partition(1)\n", - "for h in range(len(ts.beatSequence)):\n", - " ts.beatSequence[h] = ts.beatSequence[h].subdivide(2)\n", - " for i in range(len(ts.beatSequence[h])):\n", - " ts.beatSequence[h][i] = ts.beatSequence[h][i].subdivide(2)\n", - " for j in range(len(ts.beatSequence[h][i])):\n", - " ts.beatSequence[h][i][j] = ts.beatSequence[h][i][j].subdivide(2)\n", + "ts.beatSequence = ts.beatSequence.subdividedNestedHierarchy(3)\n", "\n", "for m in partBass.getElementsByClass(stream.Measure):\n", " for n in m.notesAndRests:\n", @@ -1388,8 +1384,8 @@ "score = corpus.parse('bach/bwv366.xml')\n", "partBass = score.getElementById('Bass')\n", "ts = partBass[meter.TimeSignature].first()\n", - "ts.beatSequence.partition(['3/8', '3/8'])\n", - "ts.accentSequence.partition(['3/8', '3/8'])\n", + "ts.beatSequence = ts.beatSequence.partitioned(['3/8', '3/8'])\n", + "ts.accentSequence = ts.accentSequence.partitioned(['3/8', '3/8'])\n", "ts.setAccentWeight([1, .5])\n", "for m in partBass[stream.Measure]:\n", " lastBeat = None\n", diff --git a/music21/__init__.py b/music21/__init__.py index c6d77aab8e..ac548b60d8 100644 --- a/music21/__init__.py +++ b/music21/__init__.py @@ -53,7 +53,7 @@ If you have the wrong version there are several options for getting the right one. - - 1. (Best) Upgrade to a recent version of Python 3 (such as 3.14). + - 1. (Best) Upgrade to a recent version of Python 3 (such as 3.15). The great features there will more than make up for the headache of downloading @@ -82,6 +82,8 @@ __all__ = [ + '__version__', + # testing routines 'mainTest', @@ -177,6 +179,7 @@ 'Music21Object', 'ElementWrapper', 'VERSION', + 'VERSION_STR', ] # ------------------------------------------------------------------------------ diff --git a/music21/_version.py b/music21/_version.py index d411b29446..e37cc4976a 100644 --- a/music21/_version.py +++ b/music21/_version.py @@ -47,7 +47,7 @@ ''' from __future__ import annotations -__version__ = '11.0.0b9' +__version__ = '11.0.0b16' def get_version_tuple(vv): v = vv.split('.') diff --git a/music21/abcFormat/__init__.py b/music21/abcFormat/__init__.py index 4c15bb7349..b84ec42818 100644 --- a/music21/abcFormat/__init__.py +++ b/music21/abcFormat/__init__.py @@ -2505,13 +2505,15 @@ def tokenize(self, strSrc: str) -> None: # v is up bow; might be: "^Segno"v which also should be dropped # H is fermata # . dot may be staccato, but should be attached to pitch - if self.currentCollectStr in ('w', 'u', 'v', 'v.', 'h', 'H', 'vk', - 'uk', 'U', '~', - '.', '=', 'V', 'v.', 'S', 's', - 'i', 'I', 'ui', 'u.', 'Q', 'Hy', 'Hx', - 'r', 'm', 'M', 'n', 'N', 'o', 'O', 'P', - 'l', 'L', 'R', - 'y', 'T', 't', 'x', 'Z'): + if self.currentCollectStr in ( + 'w', 'u', 'v', 'v.', 'h', 'H', 'vk', + 'uk', 'U', '~', + '.', '=', 'V', 'v.', 'S', 's', + 'i', 'I', 'ui', 'u.', 'Q', 'Hy', 'Hx', + 'r', 'm', 'M', 'n', 'N', 'o', 'O', 'P', + 'l', 'L', 'R', + 'y', 'T', 't', 'x', 'Z', + ): pass # these are bad chords, or other problematic notations like # "D.C."x @@ -2785,7 +2787,6 @@ def __add__(self, other: ABCHandler) -> ABCHandler: True >>> ah3.tokens[3] == ah2.tokens[0] True - ''' ah = self.__class__() # will get the same class type ah.tokens = self.tokens + other.tokens @@ -3114,7 +3115,6 @@ def _buildMeasureBoundaryIndices( [[0, 9], [10, 16], [16, 23], [23, 29], [29, 36], [36, 42], [42, 49], [49, 56], [56, 61], [62, 64], [64, 70], [70, 77], [77, 84], [84, 90], [90, 96], [96, 103], [103, 110], [110, 115]] - ''' # collect start and end pairs of split pairs = [] @@ -3935,8 +3935,7 @@ def testBow(self): def testAcc(self): from music21.abcFormat import testFiles - from music21 import abcFormat - ah = abcFormat.ABCHandler() + ah = ABCHandler() ah.process(testFiles.accTest) # noinspection SpellCheckingInspection tokensCorrect = ''' @@ -4035,11 +4034,11 @@ def testAcc(self): j = 0 k = 0 for token in tokens: - if isinstance(token, abcFormat.ABCAccent): + if isinstance(token, ABCAccent): i += 1 - elif isinstance(token, abcFormat.ABCStraccent): + elif isinstance(token, ABCStraccent): j += 1 - elif isinstance(token, abcFormat.ABCTenuto): + elif isinstance(token, ABCTenuto): k += 1 self.assertEqual(i, 2) self.assertEqual(j, 2) diff --git a/music21/abcFormat/testFiles.py b/music21/abcFormat/testFiles.py index 8cbd76cad9..eee14079f7 100644 --- a/music21/abcFormat/testFiles.py +++ b/music21/abcFormat/testFiles.py @@ -633,11 +633,11 @@ # ------------------------------------------------------------------------------ ALL = [fyrareprisarn, mysteryReel, fullRiggedShip, aleIsDear, kitchGirl, - williamAndNancy, morrisonsJig, hectorTheHero, kingOfTheFairies, - sicutRosa, theAleWifesDaughter, theBeggerBoy, theBattleOfTheSnaBas, - draughtOfAle, - valentineJigg, - testPrimitive, testPrimitivePolyphonic, testPrimitiveTuplet + williamAndNancy, morrisonsJig, hectorTheHero, kingOfTheFairies, + sicutRosa, theAleWifesDaughter, theBeggerBoy, theBattleOfTheSnaBas, + draughtOfAle, + valentineJigg, + testPrimitive, testPrimitivePolyphonic, testPrimitiveTuplet ] diff --git a/music21/abcFormat/translate.py b/music21/abcFormat/translate.py index e80b08788e..ec9f96439c 100644 --- a/music21/abcFormat/translate.py +++ b/music21/abcFormat/translate.py @@ -6,7 +6,7 @@ # Michael Scott Asato Cuthbert # Dylan Nagler # -# Copyright: Copyright © 2010-2024 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2010-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ----------------------------------------------------------------------------- ''' @@ -109,6 +109,7 @@ def abcToStreamPart( barCount = 0 measureNumber = 1 + lyricTargets: list[note.GeneralNote] = [] # merged handler are ABCHandlerBar objects, defining attributes for barlines for mh in mergedHandlers: @@ -181,7 +182,9 @@ def abcToStreamPart( # environLocal.printDebug([mh, 'dst', dst]) # ql = 0 # might not be zero if there is a pickup - postTransposition, clefSet = parseTokens(mh, dst, p, useMeasures) + postTransposition, clefSet = parseTokens( + mh, dst, p, useMeasures, lyricTargets + ) # append measure to part; in the case of trailing metadata # dst may be part, even though useMeasures is True @@ -253,7 +256,8 @@ def parseTokens( mh: abcFormat.ABCHandler, dst: stream.Measure|stream.Part, p: stream.Part, - useMeasures: bool + useMeasures: bool, + lyricTargets: list[note.GeneralNote], ) -> tuple[int, bool]: ''' Parses all the tokens in a measure or part. @@ -265,11 +269,16 @@ def parseTokens( clefSet = False for t in mh.tokens: if isinstance(t, abcFormat.ABCMetadata): + if t.tag == 'w': + addABCLyrics(t.data, lyricTargets) + lyricTargets.clear() postTransposition, clefSet = metadataToM21Object( t, dst, postTransposition, clefSet, useMeasures ) elif isinstance(t, abcFormat.ABCNote): - parseABCNote(t, dst) + m21Note = parseABCNote(t, dst) + if m21Note is not None and not isinstance(m21Note, note.Rest) and not t.inGrace: + lyricTargets.append(m21Note) elif isinstance(t, abcFormat.ABCSlurStart): if typing.TYPE_CHECKING: assert t.slurObj is not None @@ -285,10 +294,36 @@ def parseTokens( dst.coreElementsChanged() return postTransposition, clefSet + +def addABCLyrics(lyricData: str, lyricTargets: list[note.GeneralNote]) -> None: + ''' + Attach simple ``w:`` ABC lyric syllables to their corresponding notes. + + AI-assisted implementation. + ''' + lyricTargetIndex = 0 + for lyricWord in lyricData.split(): + if lyricWord in ('*', '-'): + lyricTargetIndex += 1 + continue + + syllables = lyricWord.split('-') + for syllableIndex, syllable in enumerate(syllables): + if not syllable: + lyricTargetIndex += 1 + continue + if lyricTargetIndex >= len(lyricTargets): + return + prefix = '-' if syllableIndex else '' + suffix = '-' if syllableIndex < len(syllables) - 1 else '' + lyricTargets[lyricTargetIndex].addLyric(prefix + syllable + suffix) + lyricTargetIndex += 1 + + def parseABCNote( t: abcFormat.ABCNote, dst: stream.Measure|stream.Part, -) -> None: +) -> note.GeneralNote|None: ''' Parse an ABCNote object and add it to the destination stream. ''' @@ -307,7 +342,7 @@ def parseABCNote( if cs_name in ('NC', 'N.C.', 'No Chord', 'None'): cs = harmony.NoChord(cs_name) elif cs_name.startswith('>'): - return # fingering diagram? Appears in some pieces, ryans-Neumedia + return None # fingering diagram? Appears in some pieces, ryans-Neumedia else: cs = harmony.ChordSymbol(cs_name) dst.coreAppend(cs, setActiveSite=False) @@ -319,7 +354,7 @@ def parseABCNote( if isinstance(t, abcFormat.ABCChord): # Skip an empty chord if not t.subTokens: - return + return None # may have more than notes? pitchNameList: list[str] = [] @@ -345,6 +380,7 @@ def parseABCNote( if acc is not None: acc.displayStatus = accStatusList[pIndex] dst.coreAppend(c) + return c # ql += t.quarterLength else: @@ -388,6 +424,7 @@ def parseABCNote( n.articulations.append(m21ArticulationObj) dst.coreAppend(n, setActiveSite=False) + return n def metadataToM21Object( @@ -664,9 +701,9 @@ def reBar(music21Part: stream.Part, *, inPlace: bool = False) -> stream.Part|Non if lastTimeSignature.barDuration.quarterLength != m2.highestTime: try: m2.timeSignature = m2.bestTimeSignature() - except exceptions21.StreamException as e: - raise ABCTranslateException( - f'Problem with measure {music21Measure.number} ({music21Measure!r}): {e}') + except meter.MeterException: + pass + if m2.timeSignature is not None: if measureIndex != len(allMeasures) - 1: if allMeasures[measureIndex + 1].timeSignature is None: allMeasures[measureIndex + 1].timeSignature = lastTimeSignature @@ -885,23 +922,47 @@ def testOpusImport(self): self.assertEqual(len(op), 8) def testLyrics(self): - # TODO(msc) -- test better - from music21 import abcFormat - from music21.abcFormat import testFiles + af = abcFormat.ABCFile() + s = abcToStreamScore(af.readstr(''' +X:1 +T:Lyrics +M:4/4 +L:1/4 +K:C +C D E F|G A B c| +w:hel-lo world * one two three four +''')) + assert s is not None - tf = testFiles.sicutRosa + notes = s.parts[0].recurse().notes + self.assertEqual(notes[0].lyric, 'hel') + self.assertEqual(notes[0].lyrics[0].syllabic, 'begin') + self.assertEqual(notes[1].lyric, 'lo') + self.assertEqual(notes[1].lyrics[0].syllabic, 'end') + self.assertEqual(notes[2].lyric, 'world') + self.assertEqual(notes[3].lyrics, []) + self.assertEqual([m21Note.lyric for m21Note in notes[4:]], + ['one', 'two', 'three', 'four']) + + def testLyricsIgnoreSyllablesAfterLastNote(self): + '''AI-assisted regression coverage for surplus lyric syllables.''' + from music21 import abcFormat af = abcFormat.ABCFile() - s = abcToStreamScore(af.readstr(tf)) + s = abcToStreamScore(af.readstr(''' +X:1 +T:Lyrics with extra words +M:4/4 +L:1/4 +K:C +C D| +w:first second third +''')) assert s is not None - # s.show() - # self.assertEqual(len(s.parts), 3) - # self.assertEqual(len(s.parts[0].notesAndRests), 6) - # self.assertEqual(len(s.parts[1].notesAndRests), 20) - # self.assertEqual(len(s.parts[2].notesAndRests), 6) - # s.show() - # s.show('midi') + notes = s.parts[0].recurse().notes + self.assertEqual([m21Note.lyric for m21Note in notes], + ['first', 'second']) def testMultiWorkImported(self): @@ -983,9 +1044,9 @@ def testNoChord(self): score = harmony.realizeChordSymbolDurations(score) self.assertEqual(8, score.getElementsByClass(harmony.ChordSymbol) - .last().quarterLength) + .last().quarterLength) self.assertEqual(4, score.getElementsByClass(harmony.ChordSymbol) - .first().quarterLength) + .first().quarterLength) def testAbcKeyImport(self): from music21 import abcFormat @@ -1170,6 +1231,21 @@ def testTiesTranslate(self): ties = [n.tie.type for n in notes.flatten().notesAndRests] self.assertListEqual(ties, ['start', 'continue', 'stop']) + def testReBarKeepsOverflowWithNoBestTimeSignature(self): + from music21 import converter + s = converter.parse('M:2/4\nL:1/8\nK:C\nabcd e5/4 | abcd e5/4 |', format='abc') + measures = s.parts.first().getElementsByClass(stream.Measure) + self.assertEqual([m.number for m in measures], [0, 1, 2, 3]) + self.assertEqual([n.name for n in measures[1].notes], ['E']) + self.assertEqual(measures[1].highestTime, 0.625) + self.assertEqual([n.name for n in measures[3].notes], ['E']) + + s = converter.parse('M:2/4\nL:1/8\nK:C\nabcd e5/4 | abcd | abcdef |', format='abc') + measures = s.parts.first().getElementsByClass(stream.Measure) + self.assertEqual([m.number for m in measures], [0, 1, 2, 3, 4]) + self.assertEqual([n.name for n in measures[4].notes], ['E', 'F']) + self.assertEqual(measures[4].timeSignature.ratioString, '1/4') + def xtestMergeScores(self): from music21 import corpus unused = corpus.parse('josquin/laDeplorationDeLaMorteDeJohannesOckeghem') diff --git a/music21/alpha/analysis/aligner.py b/music21/alpha/analysis/aligner.py index 4df85e39f2..d7604ded32 100644 --- a/music21/alpha/analysis/aligner.py +++ b/music21/alpha/analysis/aligner.py @@ -176,7 +176,6 @@ def makeHashedStreams(self): >>> sa2.hashedSourceStream [NoteHashWithReference(Pitch=69, Duration=1.0, Offset=0.0), NoteHashWithReference(Pitch=67, Duration=1.0, Offset=1.0)] - ''' if not self.preHashed: self.hashedTargetStream = self.hasher.hashStream(self.targetStream) @@ -230,7 +229,6 @@ def setupDistanceMatrix(self): Traceback (most recent call last): music21.alpha.analysis.aligner.AlignerException: Cannot perform alignment with empty source stream. - ''' if not self.hashedTargetStream: self.makeHashedStreams() @@ -306,7 +304,6 @@ def populateDistanceMatrix(self): [2, 2, 2, 4], [4, 4, 3, 3], [6, 6, 5, 3]]) - ''' # calculate insert and delete costs based on the first tuple in the Source S @@ -381,7 +378,6 @@ def getPossibleMovesFromLocation(self, i, j): >>> sa.getPossibleMovesFromLocation(3, 0) [0, None, None] - ''' verticalCost = int(self.distanceMatrix[i - 1][j]) if i >= 1 else None horizontalCost = int(self.distanceMatrix[i][j - 1]) if j >= 1 else None @@ -736,7 +732,6 @@ def tupleEqualityWithoutReference(self, tup1, tup2): >>> sa.tupleEqualityWithoutReference(nhwr1, nhwr3) False - ''' for val in tup1.hashItemsKeys: if getattr(tup1, val) != getattr(tup2, val): @@ -828,7 +823,6 @@ def calculateChangesList(self): 1 >>> saD.similarityScore 0.5 - ''' i = self.n j = self.m diff --git a/music21/alpha/analysis/fixer.py b/music21/alpha/analysis/fixer.py index b69ae4df25..457ced740e 100644 --- a/music21/alpha/analysis/fixer.py +++ b/music21/alpha/analysis/fixer.py @@ -57,7 +57,7 @@ class DeleteFixer(OMRMidiFixer): def fix(self): super().fix() for (midiRef, omrRef, op) in self.changes: - if self.checkIfNoteInstance(midiRef, omrRef) is False: + if not self.checkIfNoteInstance(midiRef, omrRef): continue # if they are the same, don't bother to try changing it # 3 is the number of noChange Ops @@ -140,7 +140,7 @@ class EnharmonicFixer(OMRMidiFixer): >>> fixer3.fix() >>> omrNote3.pitch.accidental - TEST 4 (case 2-1) e.g MIDI = g#, ground truth = a-, OMR = an + TEST 4 (case 2-1) e.g. MIDI = g#, ground truth = a-, OMR = an >>> midiNote4 = note.Note('G#4') >>> omrNote4 = note.Note('An4') @@ -156,7 +156,7 @@ class EnharmonicFixer(OMRMidiFixer): >>> omrNote4.pitch.accidental - TEST 5 (case 2-2) e.g midi = g-, gt = f#, omr = fn + TEST 5 (case 2-2) e.g. midi = g-, gt = f#, omr = fn >>> midiNote5 = note.Note('G-4') >>> omrNote5 = note.Note('Fn4') @@ -237,7 +237,7 @@ def fix(self): for (midiRef, omrRef, op) in self.changes: omrRef.style.color = 'black' # if they're not notes, don't bother with rest - if self.checkIfNoteInstance(midiRef, omrRef) is False: + if not self.checkIfNoteInstance(midiRef, omrRef): continue # if they are the same, don't bother to try changing it # 3 is the number of noChange Ops @@ -253,7 +253,7 @@ def fix(self): omrRef.pitch.accidental = None else: # case 2-1: midi note is sharp, omr note is one step higher and natural, - # should be a flat instead. e.g midi = g#, gt = a-, omr = an + # should be a flat instead. e.g. midi = g#, gt = a-, omr = an # omr note has higher ps than midi-- on a higher # line or space than midi note if omrRef.pitch > midiRef.pitch: @@ -261,7 +261,7 @@ def fix(self): ).isEnharmonic(midiRef.pitch): omrRef.pitch.accidental = pitch.Accidental('flat') # case 2-2: midi note is flat, omr note is one step lower and natural, - # should be a flat instead. e.g midi = g-, gt = f#, omr = fn + # should be a flat instead. e.g. midi = g-, gt = f#, omr = fn # omr note has lower ps than midi-- on a higher line # or space than midi note elif omrRef.pitch < midiRef.pitch: @@ -399,7 +399,7 @@ def addOrnament(self, * show: True when note should be colored blue Returns True if added successfully, or False if there was already an - ornament on the note and it wasn't added. + ornament on the note, and it wasn't added. ''' if not any(isinstance(e, expressions.Ornament) for e in selectedNote.expressions): selectedNote.expressions.append(ornament) diff --git a/music21/alpha/analysis/hasher.py b/music21/alpha/analysis/hasher.py index 9da199112c..a89f0df9b1 100644 --- a/music21/alpha/analysis/hasher.py +++ b/music21/alpha/analysis/hasher.py @@ -186,7 +186,6 @@ def _hashMIDIPitchName(self, e, thisChord=None): >>> r = note.Rest() >>> h._hashMIDIPitchName(r, thisChord=c) 0 - ''' if thisChord and self.hashChordsAsChords: return 1 @@ -417,16 +416,16 @@ def hashStream(self, s): if self.hashChordsAsNotes: for n in elt: singleNoteHash = [self.hashingFunctions[hashProperty](n, thisChord=elt) - for hashProperty in self.tupleList] + for hashProperty in self.tupleList] self.addHashToFinalHash(singleNoteHash, finalHash, n) elif self.hashChordsAsChords: singleNoteHash = [self.hashingFunctions[hashProperty](None, thisChord=elt) - for hashProperty in self.tupleList] + for hashProperty in self.tupleList] self.addHashToFinalHash(singleNoteHash, finalHash, elt) else: singleNoteHash = [self.hashingFunctions[hashProperty](elt) - for hashProperty in self.tupleList] + for hashProperty in self.tupleList] self.addHashToFinalHash(singleNoteHash, finalHash, elt) # TODO: don't finalHash back and forth, return it in the smaller functions return finalHash diff --git a/music21/alpha/analysis/ornamentRecognizer.py b/music21/alpha/analysis/ornamentRecognizer.py index 029f35b968..1364d2572f 100644 --- a/music21/alpha/analysis/ornamentRecognizer.py +++ b/music21/alpha/analysis/ornamentRecognizer.py @@ -543,7 +543,7 @@ def testRecognizeTrill(self): t3Notes = stream.Stream() # C B C B C D E F t3Notes.append( [t3n1, t3n2, deepcopy(t3n1), deepcopy(t3n2), deepcopy(t3n1), - nachschlagN1, nachschlagN2, nachschlagN3] + nachschlagN1, nachschlagN2, nachschlagN3] ) testConditions.append( diff --git a/music21/analysis/correlate.py b/music21/analysis/correlate.py index 563d94c393..e629da9868 100644 --- a/music21/analysis/correlate.py +++ b/music21/analysis/correlate.py @@ -45,7 +45,6 @@ class ActivityMatch: .. image:: images/ScatterWeightedPitchSpaceDynamicSymbol.* :width: 600 - ''' def __init__(self, streamObj): if not hasattr(streamObj, 'classes') or 'Stream' not in streamObj.classes: @@ -59,7 +58,6 @@ def _findActive(self, objNameSrc=None, objNameDst=None): Do the analysis, finding correlations of src with dst returns an ordered list of dictionaries, in the form {'src': obj, 'dst': [objs]} - ''' if objNameSrc is None: objNameSrc = (note.Note, chord.Chord) diff --git a/music21/analysis/discrete.py b/music21/analysis/discrete.py index 13af52e373..be51d2a462 100644 --- a/music21/analysis/discrete.py +++ b/music21/analysis/discrete.py @@ -565,14 +565,12 @@ def _likelyKeys(self, sStream): def _bestKeyEnharmonic(self, pitchObj, mode, sStream=None): ''' - >>> ks = analysis.discrete.KrumhanslSchmuckler() >>> s = converter.parse('tinynotation: 4/4 b-4 e- f g-') >>> ks._bestKeyEnharmonic(pitch.Pitch('e#'), 'minor', s) >>> ks._bestKeyEnharmonic(pitch.Pitch('f-'), 'major', s) - ''' if pitchObj is None: return None @@ -881,7 +879,6 @@ def getWeights(self, weightType='major'): 12 >>> a.getWeights('major') [16.8..., 0.8..., 12.9..., 1.4..., ...] - ''' weightType = weightType.lower() # note: only one value is different from KrumhanslSchmuckler @@ -1045,7 +1042,6 @@ def getPitchSpan(self, subStream) -> tuple[pitch.Pitch, pitch.Pitch]|None: >>> s.insert(4, harmony.ChordSymbol('C6')) >>> p.getPitchSpan(s) is None True - ''' if subStream is self._referenceStream and self.minPitchObj and self.maxPitchObj: return self.minPitchObj, self.maxPitchObj @@ -1118,7 +1114,6 @@ def solutionLegend(self, compress: bool = False) -> list[ >>> x = p.process(s.parts[1]) >>> [len(y) for y in [x for x in p.solutionLegend(compress=True)]] [2, 2] - ''' colorsUsed = [] if compress: @@ -1160,7 +1155,6 @@ def solutionUnitString(self): def solutionToColor(self, solution: int|None) -> str: ''' - >>> p = analysis.discrete.Ambitus() >>> s = stream.Stream() >>> c = chord.Chord(['a2', 'b4', 'c8']) diff --git a/music21/analysis/enharmonics.py b/music21/analysis/enharmonics.py index 8cb66bf098..5f30c3cb86 100644 --- a/music21/analysis/enharmonics.py +++ b/music21/analysis/enharmonics.py @@ -99,7 +99,7 @@ def getAlterationScore(self, possibility): Returns a score according to the number of sharps and flats in a possible spelling. The score is the sum of the flats and sharps + 1, multiplied by the alterationPenalty. ''' - if self.ruleObject.alterationPenalty is False: + if not self.ruleObject.alterationPenalty: return 1 joinedPossibility = ''.join([p.name for p in possibility]) @@ -114,7 +114,7 @@ def getMixSharpFlatsScore(self, possibility): the score is given by the number of the lesser used accidental (sharps or flats) multiplied by the mixSharpsFlatsPenalty. ''' - if self.ruleObject.mixSharpsFlatsPenalty is False: + if not self.ruleObject.mixSharpsFlatsPenalty: return 1 joinedPossibility = ''.join([p.name for p in possibility]) @@ -128,7 +128,7 @@ def getAugDimScore(self, possibility): Returns a score based on the number of augmented and diminished intervals between successive pitches in the given spelling. ''' - if self.ruleObject.augDimPenalty is False: + if not self.ruleObject.augDimPenalty: return 1 intervalStr = '' diff --git a/music21/analysis/floatingKey.py b/music21/analysis/floatingKey.py index 2569167ca1..04d26bd68a 100644 --- a/music21/analysis/floatingKey.py +++ b/music21/analysis/floatingKey.py @@ -4,7 +4,7 @@ # # Authors: Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2015 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2015-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -15,6 +15,7 @@ from __future__ import annotations import copy +import unittest from music21 import key from music21 import stream @@ -44,7 +45,7 @@ class KeyAnalyzer: [, , , , , , , , - , ] + , ] Raw analysis (no smoothing): @@ -57,18 +58,18 @@ class KeyAnalyzer: Major smoothing: >>> ka.windowSize = ka.numMeasures // 2 - >>> ka.run() # only the pickup seems to be in A major by this approach - [, , , + >>> ka.run() + [, , , , , , , , - , ] + , ] >>> tiny = converter.parse('tinyNotation: c1 e1 g1 c1 d-4 d-4 d-4 d-4') >>> ka = analysis.floatingKey.KeyAnalyzer(tiny) >>> ka.windowSize = 1 >>> ka.run() # This previously only gave four elements: am, CM, CM, CM [, , , - , ] + , ] No measures will fail. @@ -116,11 +117,11 @@ def getRawKeyByMeasure(self): def getInterpretationByMeasure(self, mIndex): ''' - Returns a dictionary of interpretations for the measure. + Returns a new dictionary of interpretations for the measure. `mIndex` is 0-indexed. ''' if mIndex in self._interpretationMeasureDict: - return self._interpretationMeasureDict[mIndex] # CACHE + return copy.copy(self._interpretationMeasureDict[mIndex]) if not self.rawKeyByMeasure: self.getRawKeyByMeasure() mk = self.rawKeyByMeasure[mIndex] @@ -166,6 +167,23 @@ def divide(coefficient, distance): return coefficient / (abs(distance) + 1) +class Test(unittest.TestCase): + + def testSmoothingReadsRawInterpretations(self): + from music21 import converter + s = converter.parse( + "tinyNotation: 4/4 c4 e g c' d f a d' g b d' f' c e g c' a c' e' a'") + ka = KeyAnalyzer(s) + ka.windowSize = 1 + first = ka.run() + for i, k in enumerate(ka.rawKeyByMeasure): + raw = {k.tonicPitchNameWithCase: k.correlationCoefficient} + for other in k.alternateInterpretations: + raw[other.tonicPitchNameWithCase] = other.correlationCoefficient + self.assertEqual(ka.getInterpretationByMeasure(i), raw) + self.assertEqual(ka.run(), first) + + if __name__ == '__main__': import music21 - music21.mainTest() + music21.mainTest(Test) diff --git a/music21/analysis/harmonicFunction.py b/music21/analysis/harmonicFunction.py index cee140a835..25237bf19e 100644 --- a/music21/analysis/harmonicFunction.py +++ b/music21/analysis/harmonicFunction.py @@ -178,7 +178,6 @@ def functionToRoman(thisHarmonicFunction: HarmonicFunction, >>> rn = roman.RomanNumeral('vi') >>> str(analysis.harmonicFunction.romanToFunction(rn)) 'Tp' - ''' if isinstance(keyOrScale, str): keyOrScale = key.Key(keyOrScale) diff --git a/music21/analysis/metrical.py b/music21/analysis/metrical.py index 467c0cc55b..b8ac975ec3 100644 --- a/music21/analysis/metrical.py +++ b/music21/analysis/metrical.py @@ -61,7 +61,7 @@ def labelBeatDepth(streamIn): # need to make a copy otherwise the .beat/.beatStr values will be messed up (1/4 the normal) tsTemp = copy.deepcopy(ts) - tsTemp.beatSequence = tsTemp.beatSequence.subdivideNestedHierarchy(depth=3) + tsTemp.beatSequence = tsTemp.beatSequence.subdividedNestedHierarchy(depth=3) for n in m.notesAndRests: if hasattr(n, 'tie') and n.tie is not None: @@ -114,7 +114,6 @@ def thomassenMelodicAccent(streamIn: stream.Stream): ('E4', 0.5561) ('D4', 0.17) ('D4', 0.0) - ''' # we use .ps instead of Intervals for speed, since # we just need perceived contours diff --git a/music21/analysis/neoRiemannian.py b/music21/analysis/neoRiemannian.py index e16bb731c5..d610965cda 100644 --- a/music21/analysis/neoRiemannian.py +++ b/music21/analysis/neoRiemannian.py @@ -97,7 +97,7 @@ def L(c, raiseException=True): transposeInterval = 'm2' changingPitch = c.fifth else: - if raiseException is True: + if raiseException: raise LRPException('Cannot perform L on this chord: not a major or minor triad') return c @@ -134,7 +134,7 @@ def P(c, raiseException=True): transposeInterval = 'A1' changingPitch = c.third else: - if raiseException is True: + if raiseException: raise LRPException('Cannot perform P on this chord: not a Major or Minor triad') return c @@ -171,7 +171,7 @@ def R(c, raiseException=True): transposeInterval = '-M2' changingPitch = c.root() else: - if raiseException is True: + if raiseException: raise LRPException('Cannot perform R on this chord: not a Major or Minor triad') return c @@ -358,7 +358,7 @@ def LRP_combinations(c, ''' if not c.isMajorTriad() and not c.isMinorTriad(): # First to avoid doing anything else if fail - if raiseException is True: + if raiseException: raise LRPException( f'Cannot perform transformations on chord {c}: not a major or minor triad') return c @@ -435,7 +435,7 @@ def completeHexatonic(c, simplifyEnharmonics=False, raiseException=True): hexatonicList.append(lastChord) return hexatonicList else: - if raiseException is True: + if raiseException: raise LRPException( 'Cannot perform transformations on this chord: not a major or minor triad') diff --git a/music21/analysis/patel.py b/music21/analysis/patel.py index 499fed7ea7..54d019aa9c 100644 --- a/music21/analysis/patel.py +++ b/music21/analysis/patel.py @@ -92,7 +92,7 @@ def melodicIntervalVariability(streamForAnalysis, **skipKeywords): totalElements = len(intervalStream) if totalElements < 2: # this is correct. raise ValueError('need at least three notes to have ' - + 'a std-deviation of intervals (and thus a MIV)') + + 'a std-deviation of intervals (and thus a MIV)') # summation = 0 semitoneList = [myInt.chromatic.undirected for myInt in intervalStream] return 100 * (stdev(semitoneList) / mean(semitoneList)) diff --git a/music21/analysis/reduceChords.py b/music21/analysis/reduceChords.py index 6913bfc8f7..c97a3a8e52 100644 --- a/music21/analysis/reduceChords.py +++ b/music21/analysis/reduceChords.py @@ -5,7 +5,7 @@ # Authors: Joséphine Wolf Oberholtzer # Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2013 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2013-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ----------------------------------------------------------------------------- ''' @@ -92,7 +92,7 @@ def run(self, if not all(isinstance(x, chord.Chord) for x in forbiddenChords): raise ChordReducerException('All forbiddenChords must be Chords') intervalClassSets = [] - for x in allowableChords: + for x in forbiddenChords: intervalClassSet = self._getIntervalClassSet(x.pitches) intervalClassSets.append(intervalClassSet) forbiddenChords = frozenset(intervalClassSets) @@ -321,8 +321,8 @@ def computeMeasureChordWeights( >>> cws = cr.computeMeasureChordWeights(s) >>> for pcs in sorted(cws): ... print(f'{pcs!r:18} {cws[pcs]:2.1f}') - (0, 4, 7) 3.0 - (0, 11, 4, 5) 1.0 + (0, 4, 5, 11) 1.0 + (0, 4, 7) 3.0 Add beatStrength: @@ -330,8 +330,8 @@ def computeMeasureChordWeights( ... weightAlgorithm=cr.quarterLengthBeatStrength) >>> for pcs in sorted(cws): ... print(f'{pcs!r:18} {cws[pcs]:2.1f}') - (0, 4, 7) 2.2 - (0, 11, 4, 5) 0.5 + (0, 4, 5, 11) 0.5 + (0, 4, 7) 2.2 Give extra weight to the last element in a measure: @@ -339,8 +339,8 @@ def computeMeasureChordWeights( ... weightAlgorithm=cr.quarterLengthBeatStrengthMeasurePosition) >>> for pcs in sorted(cws): ... print(f'{pcs!r:18} {cws[pcs]:2.1f}') - (0, 4, 7) 3.0 - (0, 11, 4, 5) 0.5 + (0, 4, 5, 11) 0.5 + (0, 4, 7) 3.0 Make consonance count a lot: @@ -348,8 +348,8 @@ def computeMeasureChordWeights( ... weightAlgorithm=cr.qlbsmpConsonance) >>> for pcs in sorted(cws): ... print(f'{pcs!r:18} {cws[pcs]:2.1f}') - (0, 4, 7) 3.0 - (0, 11, 4, 5) 0.1 + (0, 4, 5, 11) 0.1 + (0, 4, 7) 3.0 ''' if weightAlgorithm is None: weightAlgorithm = self.quarterLengthOnly @@ -358,7 +358,7 @@ def computeMeasureChordWeights( self.numberOfElementsInMeasure = len(measureObject) for i, c in enumerate(measureObject): self.positionInMeasure = i - p = tuple({x.pitchClass for x in c.pitches}) + p = tuple(sorted({x.pitchClass for x in c.pitches})) if p not in presentPCs: presentPCs[p] = 0.0 presentPCs[p] += weightAlgorithm(c) @@ -579,7 +579,7 @@ def reduceMeasureToNChords( currentGreedyChordPCs = None currentGreedyChordNewLength = 0.0 for c in measureObject.getElementsByClass(note.NotRest): - p = tuple({x.pitchClass for x in c.pitches}) + p = tuple(sorted({x.pitchClass for x in c.pitches})) if p in trimmedMaxChords and p != currentGreedyChordPCs: # keep this chord if currentGreedyChord is None and c.offset != 0.0: @@ -737,6 +737,78 @@ def testSimpleMeasure(self): for c in [c1, c2, c3]: s.append(c) + def testSamePitchClassesInAnyOrder(self): + m = stream.Measure([meter.TimeSignature('4/4'), + chord.Chord('B3 D#4', type='half'), + chord.Chord('D#3 B3', type='half')]) + cr = ChordReducer() + self.assertEqual(cr.computeMeasureChordWeights(m.notes), {(3, 11): 4.0}) + reduced = cr.reduceMeasureToNChords(m, 2, weightAlgorithm=cr.qlbsmpConsonance) + self.assertEqual([c.quarterLength for c in reduced.notes], [4.0]) + + def testPartsWithoutPartId(self): + # parts from MIDI files often have no partId; this used to raise + # a TypeError when sorting the parts + from music21 import instrument + upper = stream.Part([instrument.Instrument(), + stream.Measure([chord.Chord('C4 E4', type='whole')])]) + lower = stream.Part([instrument.Instrument(), + stream.Measure([note.Note('G3', type='whole')])]) + score = stream.Score([upper, lower]) + scoreTree = score.asTimespans() + self.assertEqual(scoreTree.allParts(), [upper, lower]) + ChordReducer().run(score) + + def testPartsOrderedAsInScore(self): + # the last part is used as the bass, so the order must follow the + # score even when partIds do not sort that way + from music21 import instrument + parts = [] + for partId in ('P2', 'P10', 'P1'): + inst = instrument.Instrument() + inst.partId = partId + parts.append(stream.Part([inst, note.Note('C4', type='whole')])) + score = stream.Score(parts) + self.assertEqual(score.asTimespans().allParts(), parts) + + def testForbiddenChords(self): + ''' + A verticality whose interval classes match a forbidden chord loses its + upper timespans, even when it is consonant or listed in allowableChords. + + AI-assisted (Claude). + ''' + from music21 import instrument + + def makeScore(): + upper = stream.Part([instrument.Instrument(), + stream.Measure([chord.Chord('E4 G4', type='whole')])]) + lower = stream.Part([instrument.Instrument(), + stream.Measure([note.Note('C3', type='whole')])]) + return stream.Score([upper, lower]) + + def reducedPitches(reduction): + return [c.pitches for c in reduction.recurse().getElementsByClass(chord.Chord)] + + reduction = ChordReducer().run(makeScore()) + self.assertEqual(reducedPitches(reduction), + [(pitch.Pitch('C3'), pitch.Pitch('E4'), pitch.Pitch('G4'))]) + + reduction = ChordReducer().run(makeScore(), + forbiddenChords=[chord.Chord('C E G')]) + self.assertEqual(reducedPitches(reduction), [(pitch.Pitch('C3'),)]) + + reduction = ChordReducer().run(makeScore(), + allowableChords=[chord.Chord('C D E')], + forbiddenChords=[chord.Chord('C E G')]) + self.assertEqual(reducedPitches(reduction), [(pitch.Pitch('C3'),)]) + + reduction = ChordReducer().run(makeScore(), + allowableChords=[chord.Chord('C E G')], + forbiddenChords=[chord.Chord('C D E')]) + self.assertEqual(reducedPitches(reduction), + [(pitch.Pitch('C3'), pitch.Pitch('E4'), pitch.Pitch('G4'))]) + class TestExternal(unittest.TestCase): show = True diff --git a/music21/analysis/reduceChordsOld.py b/music21/analysis/reduceChordsOld.py index 2281f8c329..183d647235 100644 --- a/music21/analysis/reduceChordsOld.py +++ b/music21/analysis/reduceChordsOld.py @@ -4,7 +4,7 @@ # # Authors: Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2013-14 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2013-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -110,7 +110,7 @@ def reduceMeasureToNChords(self, currentGreedyChordPCs = None currentGreedyChordNewLength = 0.0 for c in mObj: - p = tuple({x.pitchClass for x in c.pitches}) + p = tuple(sorted({x.pitchClass for x in c.pitches})) if p in trimmedMaxChords and p != currentGreedyChordPCs: # keep this chord if currentGreedyChord is None and c.offset != 0.0: @@ -154,22 +154,21 @@ def reduceMeasureToNChords(self, def computeMeasureChordWeights(self, measureObj, weightAlgorithm=None): ''' - >>> s = analysis.reduceChordsOld.testMeasureStream1().notes >>> cr = analysis.reduceChordsOld.ChordReducer() >>> cws = cr.computeMeasureChordWeights(s) >>> for pcs in sorted(cws): ... print(f'{pcs!r:18} {cws[pcs]:2.1f}') - (0, 4, 7) 3.0 - (0, 11, 4, 5) 1.0 + (0, 4, 5, 11) 1.0 + (0, 4, 7) 3.0 Add beatStrength: >>> cws = cr.computeMeasureChordWeights(s, weightAlgorithm=cr.quarterLengthBeatStrength) >>> for pcs in sorted(cws): ... print(f'{pcs!r:18} {cws[pcs]:2.1f}') - (0, 4, 7) 2.2 - (0, 11, 4, 5) 0.5 + (0, 4, 5, 11) 0.5 + (0, 4, 7) 2.2 Give extra weight to the last element in a measure: @@ -177,16 +176,16 @@ def computeMeasureChordWeights(self, measureObj, weightAlgorithm=None): ... weightAlgorithm=cr.quarterLengthBeatStrengthMeasurePosition) >>> for pcs in sorted(cws): ... print(f'{pcs!r:18} {cws[pcs]:2.1f}') - (0, 4, 7) 3.0 - (0, 11, 4, 5) 0.5 + (0, 4, 5, 11) 0.5 + (0, 4, 7) 3.0 Make consonance count a lot: >>> cws = cr.computeMeasureChordWeights(s, weightAlgorithm=cr.qlbsmpConsonance) >>> for pcs in sorted(cws): ... print(f'{pcs!r:18} {cws[pcs]:2.1f}') - (0, 4, 7) 3.0 - (0, 11, 4, 5) 0.5 + (0, 4, 5, 11) 0.5 + (0, 4, 7) 3.0 ''' if weightAlgorithm is None: weightAlgorithm = self.quarterLengthOnly @@ -197,7 +196,7 @@ def computeMeasureChordWeights(self, measureObj, weightAlgorithm=None): for i, c in enumerate(measureObj): self.positionInMeasure = i - p = tuple({x.pitchClass for x in c.pitches}) + p = tuple(sorted({x.pitchClass for x in c.pitches})) if p not in presentPCs: presentPCs[p] = 0.0 presentPCs[p] += weightAlgorithm(c) @@ -333,6 +332,15 @@ def testSimpleMeasure(self): for c in [c1, c2, c3]: s.append(c) + def testSamePitchClassesInAnyOrder(self): + m = stream.Measure([meter.TimeSignature('4/4'), + chord.Chord('B3 D#4', type='half'), + chord.Chord('D#3 B3', type='half')]) + cr = ChordReducer() + self.assertEqual(cr.computeMeasureChordWeights(m.notes), {(3, 11): 4.0}) + reduced = cr.reduceMeasureToNChords(m, 2, weightAlgorithm=cr.qlbsmpConsonance) + self.assertEqual([c.quarterLength for c in reduced.notes], [4.0]) + class TestExternal(unittest.TestCase): show = True diff --git a/music21/analysis/reduction.py b/music21/analysis/reduction.py index 56bbadb4c5..8b2cc8ebbb 100644 --- a/music21/analysis/reduction.py +++ b/music21/analysis/reduction.py @@ -5,7 +5,7 @@ # Authors: Christopher Ariza # Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2011-2013 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2011-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -141,19 +141,21 @@ def getNoteAndTextExpression(self): ''' Produce a new note, a deep copy of the supplied note and with the specified modifications. + + From a chord, takes the note named by the specification's pitch, + or else the highest note. ''' n = None if self._note.isChord: # need to permit specification by pitch - if 'pitch' in self._parameters: + if self._parameters['pitch'] is not None: p = pitch.Pitch(self._parameters['pitch']) for sub in self._note: # iterate over components if p.name.lower() == sub.pitch.name.lower(): # copy the component n = copy.deepcopy(sub) - else: # get first, or get entire chord? - # n = copy.deepcopy(self._note.pitches[0]) - n = copy.deepcopy(self._note.pitches[0]) + else: + n = copy.deepcopy(max(self._note, key=lambda sub: sub.pitch.ps)) else: n = copy.deepcopy(self._note) # always clear certain parameters @@ -211,10 +213,23 @@ def __init__(self, **keywords): self._chordReduction = None # store a chordal reduction of available - def _setScore(self, value): + @property + def score(self): + ''' + Get or set the Score. Setting the score set a deepcopy of the score; the score + set here will not be altered. + + >>> s = corpus.parse('bwv66.6') + >>> sr = analysis.reduction.ScoreReduction() + >>> sr.score = s + ''' + return self._score + + @score.setter + def score(self, value): if not isinstance(value, stream.Stream): raise ScoreReductionException('cannot set a non Stream') - if value.hasPartLikeStreams: + if value.hasPartLikeStreams(): # make a local copy self._score = copy.deepcopy(value) else: # assume a single stream, place in a Score @@ -223,20 +238,16 @@ def _setScore(self, value): self._score = s self._score.setDerivationMethod('ScoreReduction', recurse=True) - def _getScore(self): - return self._score - - score = property(_getScore, _setScore, doc=''' - Get or set the Score. Setting the score set a deepcopy of the score; the score - set here will not be altered. - - >>> s = corpus.parse('bwv66.6') - >>> sr = analysis.reduction.ScoreReduction() - >>> sr.score = s - ''') - + @property + def chordReduction(self): + ''' + Get or set a Chord reduction as a Stream or Score. Setting the this values + set a deepcopy of the reduction; the reduction set here will not be altered. + ''' + return self._chordReduction - def _setChordReduction(self, value): + @chordReduction.setter + def chordReduction(self, value): if not isinstance(value, stream.Stream): raise ScoreReductionException('cannot set a non Stream') if value.hasPartLikeStreams(): @@ -247,14 +258,6 @@ def _setChordReduction(self, value): s.insert(0, copy.deepcopy(value)) self._chordReduction = s - def _getChordReduction(self): - return self._chordReduction - - chordReduction = property(_getChordReduction, _setChordReduction, doc=''' - Get or set a Chord reduction as a Stream or Score. Setting the this values - set a deepcopy of the reduction; the reduction set here will not be altered. - ''') - def _extractReductionEvents(self, score, removeAfterParsing=True): @@ -462,7 +465,6 @@ class PartReduction: of all parts. The default is True. If the `normalize` parameter is False, no normalization will take place. The default is True. - ''' def __init__(self, srcScore=None, @@ -733,12 +735,17 @@ def _dynamicToWeight(targets): offsetEnd = offsetStart + ds['span'] # get all targets within the contiguous region # e.g., Dynamics objects - match = flatRef.getElementsByOffset(offsetStart, offsetEnd, - includeEndBoundary=True, mustFinishInSpan=False, - mustBeginInSpan=True).getElementsByClass(target).stream() + inRegion = flatRef.getElementsByOffset( + offsetStart, + offsetEnd, + includeEndBoundary=True, + mustFinishInSpan=False, + mustBeginInSpan=True, + ) + match = inRegion.getElementsByClass(target).stream() # environLocal.printDebug(['matched elements', target, match]) - # extend duration of all found dynamics - match.extendDuration(target, inPlace=True) + # extend duration of copies of all found dynamics + match = match.extendDuration(target, inPlace=False) # match.show('t') dsFirst = copy.deepcopy(ds) if not match: @@ -747,7 +754,7 @@ def _dynamicToWeight(targets): continue # create new spans for each target in this segment for i, tar in enumerate(match): - targetStart = tar.getOffsetBySite(flatRef) + targetStart = match.elementOffset(tar) # can use extended duration targetSpan = tar.duration.quarterLength # if dur of target is greater tn this span @@ -769,7 +776,7 @@ def _dynamicToWeight(targets): dsFirst['span'] = targetSpan dsFirst['weight'] = targetToWeight(tar) finalBundle.append(dsFirst) - elif t == 0 and ds['eStart'] != targetStart: + elif i == 0 and ds['eStart'] != targetStart: # add two, one for the empty region, one for target # adjust span of first; weight is not known # (hangs over from last) @@ -954,6 +961,30 @@ def testExtractionB(self): self.assertEqual(len(match), 3) # post.show() + def testChordWithoutPitchTakesHighest(self): + c = chord.Chord(['E4', 'B4', 'G4'], quarterLength=2) + rn = ReductiveNote('::/o:5', c, 0, 0.0) + n, unused_te = rn.getNoteAndTextExpression() + self.assertEqual(n.nameWithOctave, 'B5') + self.assertEqual(n.quarterLength, 2) + + rn = ReductiveNote('::/p:g', c, 0, 0.0) + n, unused_te = rn.getNoteAndTextExpression() + self.assertEqual(n.nameWithOctave, 'G4') + + rn = ReductiveNote('::/tb:x', c, 0, 0.0) + n, unused_te = rn.getNoteAndTextExpression() + self.assertEqual(n.nameWithOctave, 'B4') + + rn = ReductiveNote('::/p:d', c, 0, 0.0) + with self.assertRaises(ReductiveEventException): + rn.getNoteAndTextExpression() + + cMajor = chord.Chord(['C4', 'E4', 'G4']) + rn = ReductiveNote('::/tb:x', cMajor, 0, 0.0) + n, unused_te = rn.getNoteAndTextExpression() + self.assertEqual(n.nameWithOctave, 'G4') + # def testExtractionC(self): # from music21 import analysis # from music21 import corpus @@ -1038,6 +1069,22 @@ def testExtractionD2(self): unused_post = sr.reduce() # post.show() + def testReduceLonePart(self): + from music21 import analysis + p = stream.Part() + p.append(note.Note('C4')) + p.append(note.Note('E4')) + p.makeMeasures(inPlace=True) + p.recurse().notes[1].addLyric('::/o:5') + + sr = analysis.reduction.ScoreReduction() + sr.score = p + self.assertIsInstance(sr.score, stream.Score) + post = sr.reduce() + self.assertEqual(len(post.parts), 2) + reduced = [n.nameWithOctave for n in post.parts[0].recurse().notes] + self.assertEqual(reduced, ['E5']) + def testExtractionE(self): from music21 import analysis from music21 import corpus @@ -1080,9 +1127,11 @@ def _matchWeightedData(self, match, target): ''' Utility function to compare known data but not compare floating point weights. ''' + self.assertEqual(len(match), len(target)) for partId, b in enumerate(target): a = match[partId] self.assertEqual(a[0], b[0]) + self.assertEqual(len(a[1]), len(b[1])) for i, dataMatch in enumerate(a[1]): # second item has data dataTarget = b[1][i] # start @@ -1121,7 +1170,7 @@ def testPartReductionB(self, show=False): s.insert(0, p) pCount += 1 - if show is True: + if show: s.show() pr = analysis.reduction.PartReduction(s, normalize=False) @@ -1140,10 +1189,54 @@ def testPartReductionB(self, show=False): self._matchWeightedData(match, target) - if show is True: + if show: p = graph.plot.Dolan(s, title='Dynamics') p.run() + def testPartReductionSpanBeforeFirstDynamic(self): + ''' + The second measure is mf until its p. + ''' + from music21 import analysis + from music21 import dynamics + p = stream.Part() + p.id = 'solo' + p.append(note.Note(quarterLength=4)) + p.append(note.Note(quarterLength=4)) + p.insert(0, dynamics.Dynamic('mf')) + p.insert(6, dynamics.Dynamic('p')) + p.makeMeasures(inPlace=True) + s = stream.Score([p]) + + pr = analysis.reduction.PartReduction(s) + pr.process() + match = pr.getGraphHorizontalBarWeightedData() + target = [('solo', [[0.0, 4.0, 1.0, '#666666'], + [4.0, 2.0, 1.0, '#666666'], + [6.0, 2.0, 0.35 / 0.55, '#666666']])] + self._matchWeightedData(match, target) + + def testPartReductionSpanBeforeLateDynamic(self): + from music21 import analysis + from music21 import dynamics + p = stream.Part() + p.id = 'solo' + p.append(note.Note(quarterLength=4)) + p.append(note.Note(quarterLength=4)) + p.insert(2, dynamics.Dynamic('mf')) + p.makeMeasures(inPlace=True) + s = stream.Score([p]) + + def spans(pr): + pr.process() + return [(start, span) for start, span, unused_weight, unused_color + in pr.getGraphHorizontalBarWeightedData()[0][1]] + + self.assertEqual(spans(analysis.reduction.PartReduction(s)), + [(0.0, 2.0), (2.0, 2.0), (4.0, 4.0)]) + self.assertEqual(spans(analysis.reduction.PartReduction(s, fillByMeasure=False)), + [(0.0, 2.0), (2.0, 6.0)]) + def testPartReductionC(self): ''' @@ -1222,6 +1315,26 @@ def testPartReductionD(self): # p = graph.PlotDolan(s, title='Dynamics') # p.process() + def testPartReductionLeavesScore(self): + from music21 import analysis + from music21 import dynamics + p = stream.Part() + p.id = 'solo' + p.append(note.Note(quarterLength=4)) + p.insert(0, dynamics.Dynamic('p')) + p.insert(2, dynamics.Dynamic('f')) + s = stream.Score([p]) + + pr = analysis.reduction.PartReduction(s) + pr.process() + self.assertEqual([d.quarterLength for d in p[dynamics.Dynamic]], [0.0, 0.0]) + target = [('solo', [[0.0, 2.0, 0.5, '#666666'], + [2.0, 2.0, 1.0, '#666666']])] + self._matchWeightedData(pr.getGraphHorizontalBarWeightedData(), target) + + pr.process() + self._matchWeightedData(pr.getGraphHorizontalBarWeightedData(), target) + def testPartReductionE(self): ''' @@ -1251,7 +1364,7 @@ def testPartReductionE(self): s.insert(0, p2) # s.show() pr = analysis.reduction.PartReduction(s, fillByMeasure=True, - segmentByTarget=False, normalize=False) + segmentByTarget=False, normalize=False) pr.process() target = pr.getGraphHorizontalBarWeightedData() match = [(0, [[0.0, 4.0, 0.178571428571, '#666666'], @@ -1264,7 +1377,7 @@ def testPartReductionE(self): self._matchWeightedData(match, target) pr = analysis.reduction.PartReduction(s, fillByMeasure=False, - segmentByTarget=True, normalize=False) + segmentByTarget=True, normalize=False) pr.process() target = pr.getGraphHorizontalBarWeightedData() match = [(0, [[0.0, 2.0, 0.05, '#666666'], @@ -1281,7 +1394,7 @@ def testPartReductionE(self): pr = analysis.reduction.PartReduction(s, fillByMeasure=False, - segmentByTarget=False) + segmentByTarget=False) pr.process() target = pr.getGraphHorizontalBarWeightedData() # print(target) @@ -1295,15 +1408,17 @@ def testPartReductionE(self): pr = analysis.reduction.PartReduction(s, fillByMeasure=True, - segmentByTarget=True) + segmentByTarget=True) pr.process() target = pr.getGraphHorizontalBarWeightedData() match = [(0, [[0.0, 2.0, 0.3888888888888, '#666666'], [2.0, 2.0, 1.0, '#666666'], + [4.0, 2.0, 1.0, '#666666'], [6.0, 2.0, 0.166666666667, '#666666'], [8.0, 4.0, 0.166666666667, '#666666']]), (1, [[0.0, 2.0, 0.6111111111111112, '#666666'], [2.0, 2.0, 0.7777777777777776, '#666666'], + [4.0, 2.0, 0.7777777777777776, '#666666'], [6.0, 2.0, 0.611111111111111, '#666666'], [8.0, 4.0, 0.611111111111111, '#666666']])] self._matchWeightedData(match, target) diff --git a/music21/analysis/windowed.py b/music21/analysis/windowed.py index 64bead7e89..49a03396c8 100644 --- a/music21/analysis/windowed.py +++ b/music21/analysis/windowed.py @@ -5,7 +5,7 @@ # Authors: Jared Sadoian # Authors: Christopher Ariza # -# Copyright: Copyright © 2010 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2010-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -20,6 +20,7 @@ ''' from __future__ import annotations +import copy import unittest import warnings @@ -94,7 +95,6 @@ def getMinimumWindowStream(self, timeSignature='1/4'): >>> wa1 = analysis.windowed.WindowedAnalysis(s, p) >>> wa2 = analysis.windowed.WindowedAnalysis(s.flatten(), p) - ''' # create a stream that contains just a 1/4 time signature; this is # the minimum window size (and partitioning will be done by measure) @@ -151,7 +151,6 @@ def analyze(self, windowSize, windowType='overlap'): >>> a, b = wa.analyze(1, windowType='adjacentAverage') >>> len(a), len(b) (36, 36) - ''' maxWindowCount = len(self._windowedStream) # assuming that this is sorted @@ -224,9 +223,15 @@ def analyze(self, windowSize, windowType='overlap'): for i in range(maxWindowCount): # get all participants, combine into a single current = stream.Stream() + appendedIds = set() for dataStream, participants in overlapped: - if i in participants: - for m in dataStream: + if i not in participants: + continue + for m in dataStream: + if id(m) in appendedIds: + current.append(copy.deepcopy(m)) + else: + appendedIds.add(id(m)) current.append(m) try: data[i], color[i] = self.processor.process(current) @@ -432,6 +437,26 @@ def testWindowing(self): a, unused_b, unused_c = wa2.process(8, 8, 1, includeTotalWindow=False) self.assertEqual(len(a[0]), 1) + def testAdjacentAverage(self): + ''' + Each minimum window gathers every window of the given size holding + it, and the windowed stream itself is unchanged. + ''' + from music21 import note + s = stream.Stream() + for _ in range(5): + s.append(note.Note('C')) + wa = WindowedAnalysis(s, MockObjectProcessor()) + + data, unused_color = wa.analyze(2, windowType='adjacentAverage') + self.assertEqual(data, [2, 4, 4, 4, 2]) + data, unused_color = wa.analyze(3, windowType='adjacentAverage') + self.assertEqual(data, [3, 6, 9, 6, 3]) + data, unused_color = wa.analyze(5, windowType='adjacentAverage') + self.assertEqual(data, [5, 5, 5, 5, 5]) + self.assertEqual(len(wa._windowedStream), 5) + self.assertEqual(len(wa._windowedStream.recurse().notes), 5) + def testVariableWindowing(self): from music21.analysis import discrete diff --git a/music21/articulations.py b/music21/articulations.py index 7adbdec18c..f6a57ea1dd 100644 --- a/music21/articulations.py +++ b/music21/articulations.py @@ -94,7 +94,7 @@ # ------------------------------------------------------------------------------ class Articulation(base.Music21Object): ''' - Base class for all Articulation sub-classes. + Base class for all Articulation subclasses. >>> x = articulations.Articulation() >>> x.placement = 'below' @@ -170,10 +170,20 @@ class name without the leading letter lowercase. className = self.__class__.__name__ return common.camelCaseToHyphen(className, replacement=' ') - def _getVolumeShift(self): + @property + def volumeShift(self): + ''' + Get or set the volumeShift of this Articulation. This value, between -1 and 1, + that is used to shift the final Volume of the object it is attached to. + + >>> at1 = articulations.StrongAccent() + >>> at1.volumeShift > 0.1 + True + ''' return self._volumeShift - def _setVolumeShift(self, value): + @volumeShift.setter + def volumeShift(self, value): # value should be between 0 and 1 if value > 1: value = 1 @@ -181,16 +191,6 @@ def _setVolumeShift(self, value): value = -1 self._volumeShift = value - volumeShift = property(_getVolumeShift, _setVolumeShift, doc=''' - Get or set the volumeShift of this Articulation. This value, between -1 and 1, - that is used to shift the final Volume of the object it is attached to. - - - >>> at1 = articulations.StrongAccent() - >>> at1.volumeShift > 0.1 - True - ''') - # ------------------------------------------------------------------------------ class LengthArticulation(Articulation): ''' @@ -219,7 +219,6 @@ class TimbreArticulation(Articulation): # ------------------------------------------------------------------------------ class Accent(DynamicArticulation): ''' - >>> a = articulations.Accent() ''' def __init__(self, **keywords): @@ -246,7 +245,6 @@ def __init__(self, **keywords): class Staccato(LengthArticulation): ''' - >>> a = articulations.Staccato() ''' def __init__(self, **keywords): @@ -440,8 +438,10 @@ class Fingering(TechnicalIndication): Other articulations, e.g., accents, apply to the whole chord and will, therefore, only be associated with the first note of a chord when serializing. Since chords store all articulations in an ordered list, Fingerings - are mapped implicitly to the notes of a chord in order. Superfluous - Fingerings will be ignored and may be discarded when serializing. + are mapped implicitly to the notes of a chord in order, from the lowest + note up: MusicXML import stores them so, and export writes a chord's + notes from lowest to highest. Superfluous Fingerings will be ignored + and may be discarded when serializing. ''' def __init__(self, fingerNumber=None, **keywords): super().__init__(**keywords) diff --git a/music21/audioSearch/__init__.py b/music21/audioSearch/__init__.py index b9772bd2e3..91daf86400 100644 --- a/music21/audioSearch/__init__.py +++ b/music21/audioSearch/__init__.py @@ -154,7 +154,8 @@ def autocorrelationFunction(recordedSignal, recordSampleRateIn): warnings.warn('Running convolve without scipy -- will be slower', stacklevel=2) convolve = numpy.convolve - recordedSignal = numpy.array(recordedSignal) + # floats, so that sixteen-bit samples do not overflow when multiplied + recordedSignal = numpy.array(recordedSignal, dtype=float) correlation = convolve(recordedSignal, recordedSignal[::-1], mode='full') lengthCorrelation = len(correlation) // 2 correlation = correlation[lengthCorrelation:] @@ -1008,7 +1009,28 @@ class AudioSearchException(exceptions21.Music21Exception): class Test(unittest.TestCase): - pass + + def testAutocorrelationWithoutScipy(self): + ''' + Sixteen-bit samples, as read from a wave file, give their frequency + when numpy convolves them in place of scipy. + ''' + if 'numpy' in base._missingImport: + self.skipTest('needs numpy') + import sys + from unittest import mock + import numpy + + rate = 44100 + samples = numpy.array( + [int(10000 * math.sin(2 * math.pi * 441 * i / rate)) for i in range(1024)], + dtype=numpy.int16, + ) + with mock.patch.dict(sys.modules, {'scipy.signal': None}): + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + frequency = autocorrelationFunction(samples, rate) + self.assertAlmostEqual(frequency, 441, delta=2) # ------------------------------------------------------------------------------ diff --git a/music21/audioSearch/recording.py b/music21/audioSearch/recording.py index 4225b1397d..acd4ddce21 100644 --- a/music21/audioSearch/recording.py +++ b/music21/audioSearch/recording.py @@ -13,10 +13,10 @@ Requires PyAudio and portaudio to be installed (https://portaudio.com/download.html) -Windows users will get pyaudio and portaudio with `pip install pyaudio` +Windows users will get pyaudio and portaudio with `uv add pyaudio` macOS users should have Homebrew installed and run `brew install portaudio` -before running `pip install pyaudio` +before running `uv add pyaudio` There is no official support for Linux/BSD etc. in music21, but package managers like `apt` tend to have libraries like `portaudio19` and `python3-pyaudio`. @@ -38,7 +38,8 @@ default_recordChunkLength = 1024 -def samplesFromRecording(seconds=10.0, storeFile=True, +def samplesFromRecording(seconds=10.0, + storeFile: bool|str = True, recordFormat=None, recordChannels=default_recordChannels, recordSampleRate=default_recordSampleRate, @@ -77,7 +78,7 @@ def samplesFromRecording(seconds=10.0, storeFile=True, st.close() p_audio.terminate() - if storeFile is not False: + if storeFile: if isinstance(storeFile, str): waveFilename = storeFile else: diff --git a/music21/audioSearch/scoreFollower.py b/music21/audioSearch/scoreFollower.py index 2191fc02ae..073f116056 100644 --- a/music21/audioSearch/scoreFollower.py +++ b/music21/audioSearch/scoreFollower.py @@ -120,7 +120,6 @@ def repeatTranscription(self): False >>> print(ScF.lastNotePosition) 10 - ''' from music21 import audioSearch @@ -178,7 +177,7 @@ def repeatTranscription(self): # print('3') self.processing_time = time() - time_start environLocal.printDebug('and even to here.') - if END_OF_SCORE is True: + if END_OF_SCORE: exitType = 'endOfScore' # 'endOfScore' return exitType @@ -239,7 +238,7 @@ def silencePeriodDetection(self, notesList): if i.name != 'rest': onlyRests = False - if onlyRests is True: + if onlyRests: self.silencePeriod = True self.notesCounter = 0 self.silencePeriodCounter += 1 @@ -382,7 +381,6 @@ def updatePosition(self, prob, totalLengthPeriod, time_start): >>> exitType = ScF.updatePosition(prob, totalLengthPeriod, time_start) >>> print(exitType) countdownExceeded - ''' exitType = False @@ -462,7 +460,6 @@ def predictNextNotePosition(self, totalLengthPeriod, totalSeconds): ... totalLengthPeriod, totalSeconds) >>> print(predictedStartPosition) 18 - ''' extraLength = totalLengthPeriod * totalSeconds / self.seconds_recording middleRhythm = 0 diff --git a/music21/audioSearch/transcriber.py b/music21/audioSearch/transcriber.py index 1f00e13f7e..d8ea6e799d 100644 --- a/music21/audioSearch/transcriber.py +++ b/music21/audioSearch/transcriber.py @@ -19,8 +19,12 @@ environLocal = environment.Environment('audioSearch.transcriber') -def runTranscribe(show=True, plot=True, useMic=True, - seconds=20.0, useScale=None, saveFile=True): # pragma: no cover +def runTranscribe(show: bool = True, + plot: bool = True, + useMic: bool = True, + seconds: float = 20.0, + useScale=None, + saveFile: bool|str = True): # pragma: no cover ''' runs all the methods to record from audio for `seconds` length (default 10.0) and transcribe the resulting melody returning a music21.Score object @@ -54,7 +58,7 @@ def runTranscribe(show=True, plot=True, useMic=True, waveFilename = saveFile # the rest of the score - if useMic is True: + if useMic: freqFromAQList = audioSearchBase.getFrequenciesFromMicrophone( length=seconds, storeWaveFilename=str(waveFilename)) diff --git a/music21/bar.py b/music21/bar.py index a8104c3f40..57f02a449a 100644 --- a/music21/bar.py +++ b/music21/bar.py @@ -150,14 +150,9 @@ def _reprInternal(self): return f'type={self.type}' - def _getType(self): - return self._type - - def _setType(self, value): - self._type = standardizeBarType(value) - - type = property(_getType, _setType, - doc=''' + @property + def type(self): + ''' Get and set the Barline type property. >>> b = bar.Barline() @@ -171,7 +166,12 @@ def _setType(self, value): >>> b.type = 'light-light' >>> b.type 'double' - ''') + ''' + return self._type + + @type.setter + def type(self, value): + self._type = standardizeBarType(value) def musicXMLBarStyle(self): ''' @@ -408,8 +408,6 @@ def testSortOrder(self): def testFreezeThaw(self): from music21 import converter from music21 import stream - # pylint: disable=redefined-outer-name - from music21.bar import Barline # avoid not same class error b = Barline() self.assertNotIn('StyleMixin', b.classes) diff --git a/music21/base.py b/music21/base.py index a012f5be9a..62bd8f1acd 100644 --- a/music21/base.py +++ b/music21/base.py @@ -26,7 +26,7 @@ >>> music21.VERSION_STR -'11.0.0b9' +'11.0.0b16' Alternatively, after doing a complete import, these classes are available under the module "base": @@ -316,7 +316,6 @@ def _getEqualityAttributes(cls) -> frozenset[str]: True >>> 'pitch' in base._getEqualityAttributes(bar.Barline) False - ''' equalityAttributes = set() # equalityAttributesIgnore works, but not yet needed. @@ -1307,10 +1306,9 @@ def purgeOrphans(self, excludeStorageStreams=True) -> None: orphans.append(id(s)) for i in orphans: self.sites.removeById(i) - p = self._getActiveSite() # this can be simplified. + p = self.activeSite if p is not None and id(p) == i: - # noinspection PyArgumentList - self._setActiveSite(None) + self.activeSite = None def purgeLocations(self, rescanIsDead=False) -> None: ''' @@ -1895,7 +1893,7 @@ def contextSites( (, '9.5 <0.20...>', ) Streams have themselves as the first element in their context sites, at position - zero and classSortOrder negative infinity. + zero and priority negative infinity. This example shows the context sites for Measure 3 of the Alto part. We will get the measure object using direct access to @@ -2162,7 +2160,7 @@ def contextSites( offsetAdjustedCsTuple = ContextSortTuple( derivedCsTuple.site, derivedCsTuple.offset.modify(offset=derivedCsTuple[1].offset - + offsetAppend), + + offsetAppend), derivedCsTuple.recurseType) if returnSortTuples: yield offsetAdjustedCsTuple @@ -2193,7 +2191,6 @@ def getAllContextsByClass(self, className): TODO: make it so that it does not skip over multiple matching classes at the same offset. with sortTuple - ''' el = self.getContextByClass(className) while el is not None: @@ -2419,20 +2416,48 @@ def previous(self, # ------------------------------------------------------------------------- # properties - def _getActiveSite(self): + @property + def activeSite(self): + ''' + A reference to the most-recent object used to + contain this object. In most cases, this will be a + Stream or Stream sub-class. In most cases, an object's + activeSite attribute is automatically set when the + object is attached to a Stream. + + + >>> n = note.Note('C#4') + >>> p = stream.Part() + >>> p.insert(20.0, n) + >>> n.activeSite is p + True + >>> n.offset + 20.0 + + >>> m = stream.Measure() + >>> m.insert(10.0, n) + >>> n.activeSite is m + True + >>> n.offset + 10.0 + >>> n.activeSite = p + >>> n.offset + 20.0 + ''' # can be None if WEAKREF_ACTIVE: if self._activeSite is None: # leave None return None else: # even if current activeSite is not a weakref, this will work - # environLocal.printDebug(['_getActiveSite() called:', + # environLocal.printDebug(['activeSite getter called:', # 'self._activeSite', self._activeSite]) return common.unwrapWeakref(self._activeSite) else: # pragma: no cover return self._activeSite - def _setActiveSite(self, site: stream.Stream|None): - # environLocal.printDebug(['_setActiveSite() called:', 'self', self, 'site', site]) + @activeSite.setter + def activeSite(self, site: stream.Stream|None): + # environLocal.printDebug(['activeSite setter called:', 'self', self, 'site', site]) # NOTE: this is a performance intensive call if site is not None: @@ -2461,35 +2486,6 @@ def _setActiveSite(self, site: stream.Stream|None): else: # pragma: no cover self._activeSite = site - activeSite = property(_getActiveSite, - _setActiveSite, - doc=''' - A reference to the most-recent object used to - contain this object. In most cases, this will be a - Stream or Stream sub-class. In most cases, an object's - activeSite attribute is automatically set when the - object is attached to a Stream. - - - >>> n = note.Note('C#4') - >>> p = stream.Part() - >>> p.insert(20.0, n) - >>> n.activeSite is p - True - >>> n.offset - 20.0 - - >>> m = stream.Measure() - >>> m.insert(10.0, n) - >>> n.activeSite is m - True - >>> n.offset - 10.0 - >>> n.activeSite = p - >>> n.offset - 20.0 - ''') - @property def offset(self) -> OffsetQL: ''' @@ -2773,7 +2769,7 @@ def sortTuple(self, insertIndex = 0 return SortTuple(atEnd, offset, self.priority, - self.classSortOrder, isNotGrace, insertIndex) + self.classSortOrder, isNotGrace, insertIndex) # ----------------------------------------------------------------- @property @@ -2798,7 +2794,6 @@ def duration(self, durationObj: Duration): try: self._duration.client = None except TypeError: - # a FrozenDuration is immutable and has no client to clear if not isinstance(self._duration, FrozenDuration): raise durationObjAlreadyExists = True @@ -2809,9 +2804,7 @@ def duration(self, durationObj: Duration): try: durationObj.client = self except TypeError: - # durationObj is an immutable FrozenDuration (e.g. one from a - # TimeSignature): it never changes, so it needs no client - # back-reference and can be held shared as-is. + # a FrozenDuration never changes, so it needs no client if not isinstance(durationObj, FrozenDuration): raise if durationObjAlreadyExists: @@ -2838,24 +2831,9 @@ def informSites(self, changedInformation=None): # noinspection PyCallingNonCallable s.coreElementsChanged(updateIsFlat=False, keepIndex=True) - def _getPriority(self): - return self._priority - - def _setPriority(self, value): - ''' - value is an int. - - Informs all sites of the change. + @property + def priority(self): ''' - if not isinstance(value, int): - raise ElementException('priority values must be integers.') - if self._priority != value: - self._priority = value - self.informSites({'changedElement': 'priority', 'priority': value}) - - priority = property(_getPriority, - _setPriority, - doc=''' Get and set the priority integer value. Priority specifies the order of processing from left (lowest number) @@ -2884,7 +2862,21 @@ def _setPriority(self, value): >>> a.priority = 'high' Traceback (most recent call last): music21.base.ElementException: priority values must be integers. - ''') + ''' + return self._priority + + @priority.setter + def priority(self, value): + ''' + value is an int. + + Informs all sites of the change. + ''' + if not isinstance(value, int): + raise ElementException('priority values must be integers.') + if self._priority != value: + self._priority = value + self.informSites({'changedElement': 'priority', 'priority': value}) # ------------------------------------------------------------------------- # display and writing @@ -3520,7 +3512,6 @@ def splitAtDurations(self) -> _SplitTuple: ('64th', 0, (,)) TODO: unite this and other functions into a "split" function -- document obscure uses. - ''' atm = self.duration.aggregateTupletMultiplier() quarterLengthList = [opFrac(c.quarterLength * atm) for c in self.duration.components] @@ -3632,7 +3623,7 @@ def _getMeasureOffset(self, includeMeasurePadding: bool = True) -> OffsetQL: ''' from music21 import stream - # TODO: v8 -- expose as public. + # TODO: expose as public. activeS = self.activeSite if activeS is not None and isinstance(activeS, stream.Measure): # environLocal.printDebug(['found activeSite as Measure, using for offset']) @@ -3856,8 +3847,7 @@ def beatDuration(self) -> FrozenDuration: >>> isolatedNote.beatDuration - If you want to modify the frozen beatDuration, call unfreeze on it and - assign it to a new variable: + Call `unfreeze()` for a Duration that can be changed: >>> bd = n0.beatDuration >>> bd @@ -3972,31 +3962,9 @@ def beatStrength(self) -> float: except Music21ObjectException: return float('nan') - def _getSeconds(self) -> float: - from music21 import tempo - # do not search if duration is zero - if self.duration.quarterLength == 0.0: - return 0.0 - - ti = self.getContextByClass(tempo.TempoIndication) - if ti is None: - return float('nan') - mm = ti.getSoundingMetronomeMark() - # once we have mm, simply pass in this duration - return mm.durationToSeconds(self.duration) - - def _setSeconds(self, value: int|float) -> None: - from music21 import tempo - ti = self.getContextByClass(tempo.TempoIndication) - if ti is None: - raise Music21ObjectException('this object does not have a TempoIndication in Sites') - mm = ti.getSoundingMetronomeMark() - self.duration = mm.secondsToDuration(value) - for s in self.sites.get(excludeNone=True): - if self in s.elements: - s.coreElementsChanged() # highest time is changed. - - seconds = property(_getSeconds, _setSeconds, doc=''' + @property + def seconds(self) -> float: + ''' Get or set the duration of this object in seconds, assuming that this object has a :class:`~music21.tempo.MetronomeMark` or :class:`~music21.tempo.MetricModulation` @@ -4075,7 +4043,30 @@ def _setSeconds(self, value: int|float) -> None: 'half' * Changed in v6.3: return `nan` instead of raising an exception. - ''') + ''' + from music21 import tempo + # do not search if duration is zero + if self.duration.quarterLength == 0.0: + return 0.0 + + ti = self.getContextByClass(tempo.TempoIndication) + if ti is None: + return float('nan') + mm = ti.getSoundingMetronomeMark() + # once we have mm, simply pass in this duration + return mm.durationToSeconds(self.duration) + + @seconds.setter + def seconds(self, value: int|float) -> None: + from music21 import tempo + ti = self.getContextByClass(tempo.TempoIndication) + if ti is None: + raise Music21ObjectException('this object does not have a TempoIndication in Sites') + mm = ti.getSoundingMetronomeMark() + self.duration = mm.secondsToDuration(value) + for s in self.sites.get(excludeNone=True): + if self in s.elements: + s.coreElementsChanged() # highest time is changed. # ------------------------------------------------------------------------------ diff --git a/music21/beam.py b/music21/beam.py index 86afb6273c..e6e258ed04 100644 --- a/music21/beam.py +++ b/music21/beam.py @@ -642,7 +642,6 @@ def setAll(self, type, direction=None): # type is okay @ReservedAssignment >>> a.setAll('sexy') Traceback (most recent call last): music21.beam.BeamException: beam type cannot be sexy - ''' if type not in ('start', 'stop', 'continue', 'partial'): raise BeamException(f'beam type cannot be {type}') @@ -679,7 +678,6 @@ def setByNumber(self, number, type, direction=None): # type is okay @ReservedAs >>> a.setByNumber(2, 'crazy') Traceback (most recent call last): music21.beam.BeamException: beam type cannot be crazy - ''' # permit providing one argument hyphenated if '-' in type: diff --git a/music21/braille/basic.py b/music21/braille/basic.py index 7eb02e4efc..1bbdf8293c 100644 --- a/music21/braille/basic.py +++ b/music21/braille/basic.py @@ -5,7 +5,7 @@ # Bo-cheng (Sponge) Jhan (Clef routines) # Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2011-22 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2011-26 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ r''' @@ -354,7 +354,8 @@ def clefToBraille(music21Clef, keyboardHandSwitched=False): ) except KeyError: # pragma: no cover music21Clef.editorial.brailleEnglish = [f'Unnamed Clef {music21Clef}'] - if isinstance(music21Clef, (clef.TrebleClef, clef.BassClef)): + if isinstance(music21Clef, (clef.TrebleClef, clef.Treble8vbClef, clef.Treble8vaClef, + clef.BassClef, clef.Bass8vbClef, clef.Bass8vaClef)): brailleClef += clefs['suffix'][keyboardHandSwitched] if keyboardHandSwitched: music21Clef.editorial.brailleEnglish.append(' Keyboard hand switched') @@ -453,7 +454,7 @@ def keySigToBraille(music21KeySignature, outgoingKeySig=None): f'Incoming Key Signature {music21KeySignature} cannot be transcribed to braille.' ) music21KeySignature.editorial.brailleEnglish.append( - f'Key Signature {music21KeySignature} sharps None' + f'Key Signature {music21KeySignature} cannot be transcribed' ) return symbols['basic_exception'] @@ -592,7 +593,6 @@ def yieldBrailleArticulations(noteEl): ⠦ ⠨⠦ ⠸⠦ - ''' def _brailleArticulationsSortKey(inner_articulation): isBowing = isinstance(inner_articulation, articulations.Bowing) @@ -731,17 +731,17 @@ def noteToBraille( allTuplets = music21Note.duration.tuplets if allTuplets: if beamStatus['beamStart']: - if allTuplets[0].fullName == 'Triplet' and allTuplets[0].tupletActualShow != 'none': + if allTuplets[0].fullName == 'Triplet' and allTuplets[0].tupletActualShow is not None: noteTrans.append(symbols['triplet']) # dot 2-3 else: - if allTuplets[0].tupletActualShow == 'none': + if allTuplets[0].tupletActualShow is None: noteTrans.append(symbols['transcriber-added_sign']) music21Note.editorial.brailleEnglish.append( f'transcriber-added {symbols["transcriber-added_sign"]}') tupletTrans = symbols['tuplet_prefix'] # dots 4,5,6 tupletTrans += numberToBraille(allTuplets[0].numberNotesActual, - withNumberSign=False, - lower=True) + withNumberSign=False, + lower=True) tupletTrans += symbols['dot'] noteTrans.append(tupletTrans) music21Note.editorial.brailleEnglish.append( @@ -1237,7 +1237,6 @@ def transcribeHeading( ⠀⠀⠀⠀⠑⠀⠀⠀⠀⠀ ⠞⠗⠁⠝⠟⠥⠊⠇⠇⠕⠲ ⠀⠀⠀⠣⠣⠨⠉⠀⠀⠀ - ''' if (music21KeySignature is None and music21TimeSignature is None @@ -1716,7 +1715,26 @@ class BrailleBasicException(exceptions21.Music21Exception): class Test(unittest.TestCase): - pass + def testTupletWithHiddenNumber(self): + def tupletStart(quarterLength, tupletActualShow): + n = note.Note('C4', quarterLength=quarterLength) + n.duration.tuplets[0].tupletActualShow = tupletActualShow + n.beamStart = True + return n + + self.assertEqual(noteToBraille(tupletStart(1 / 3, 'number')), '⠆⠐⠙') + self.assertEqual(noteToBraille(tupletStart(1 / 3, None)), '⠐⠸⠒⠄⠐⠙') + self.assertEqual(noteToBraille(tupletStart(0.2, 'number')), '⠸⠢⠄⠐⠽') + self.assertEqual(noteToBraille(tupletStart(0.2, None)), '⠐⠸⠢⠄⠐⠽') + + def testOctaveClefsShowHandSwitched(self): + for clefClass, brailleClef in ( + (clef.Treble8vbClef, '⠜⠌⠅'), + (clef.Treble8vaClef, '⠜⠌⠅'), + (clef.Bass8vbClef, '⠜⠼⠅'), + (clef.Bass8vaClef, '⠜⠼⠅'), + ): + self.assertEqual(clefToBraille(clefClass(), keyboardHandSwitched=True), brailleClef) if __name__ == '__main__': diff --git a/music21/braille/lookup.py b/music21/braille/lookup.py index 4a15de6ceb..03f3062e34 100644 --- a/music21/braille/lookup.py +++ b/music21/braille/lookup.py @@ -5,7 +5,7 @@ # Bo-cheng Jhan # Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2011-22 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2011-26 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ---------------------------------------------------------------------------------- ''' @@ -25,6 +25,7 @@ from __future__ import annotations import itertools +import unittest _DOC_IGNORE_MODULE_OR_PACKAGE = True @@ -526,7 +527,7 @@ def makePitchNameToNotes(): _B[356]: ('00', '01', '11'), _B[1356]: ('10', '01', '11'), _B[2356]: ('00', '11', '11'), - _B[12356]: ('01', '11', '11'), + _B[12356]: ('10', '11', '11'), _B[456]: ('01', '01', '01'), _B[1456]: ('11', '01', '01'), _B[2456]: ('01', '11', '01'), @@ -540,7 +541,24 @@ def makePitchNameToNotes(): # make public brailleDotDict = _B + +class Test(unittest.TestCase): + def testBinaryDotsMatchDotNumbers(self): + ''' + Each row of a binary_dots entry is (left dot, right dot) for dots + (1, 4), (2, 5), and (3, 6). + ''' + self.assertEqual(len(binary_dots), 64) + for dotNumber, brailleChar in _B.items(): + dotDigits = str(dotNumber) + expected = tuple( + ('1' if left in dotDigits else '0') + ('1' if right in dotDigits else '0') + for left, right in (('1', '4'), ('2', '5'), ('3', '6')) + ) + self.assertEqual(binary_dots[brailleChar], expected, dotNumber) + + if __name__ == '__main__': import music21 - music21.mainTest() + music21.mainTest(Test) diff --git a/music21/braille/noteGrouping.py b/music21/braille/noteGrouping.py index 588a0d255f..b7a9e68d0d 100644 --- a/music21/braille/noteGrouping.py +++ b/music21/braille/noteGrouping.py @@ -4,7 +4,7 @@ # Authors: Jose Cabal-Ugaz # Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2012, 2016 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2012-26 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ from __future__ import annotations @@ -118,7 +118,6 @@ def transcribeGroup(self, brailleElementGrouping=None): transcribe a group of notes, possibly excluding certain attributes. Returns a (unicode) string of brailleElementGrouping transcribed. - ''' self.reset() if brailleElementGrouping is not None: @@ -258,7 +257,7 @@ def optionallyAddDotToPrevious(self, el=None): and self.showClefSigns) or (isinstance(prev, expressions.TextExpression) # TE is an abbreviation, no extra dot 3 necessary - and prev.content[-1] != '.')): + and not prev.content.endswith('.'))): for dot in basic.yieldDots(self.trans[-1][0]): self.trans.insert(-1, dot) # insert one before the end, not append prev.editorial.brailleEnglish.append(f'Dot 3 {dot}') @@ -280,7 +279,23 @@ def transcribeNoteGrouping(brailleElementGrouping, showLeadingOctave=True): # ------------------------------------------------------------------------------ class Test(unittest.TestCase): - pass + + def testTranscribeGroupWithTextExpression(self): + ''' + A note after a TextExpression, including an empty one, is transcribed + after the word sign and the expression's text. + ''' + from music21 import note + from music21.braille.segment import BrailleElementGrouping + + for content, expected in (('', '⠐⠹⠜⠐⠱'), + ('dolce', '⠐⠹⠜⠙⠕⠇⠉⠑⠐⠱'), + ('dim.', '⠐⠹⠜⠙⠊⠍⠄⠐⠱')): + grouping = BrailleElementGrouping([note.Note('C4'), + expressions.TextExpression(content), + note.Note('D4')]) + ngt = NoteGroupingTranscriber() + self.assertEqual(ngt.transcribeGroup(grouping), expected) if __name__ == '__main__': diff --git a/music21/braille/segment.py b/music21/braille/segment.py index c2675a3183..2a81752971 100644 --- a/music21/braille/segment.py +++ b/music21/braille/segment.py @@ -4,7 +4,7 @@ # Authors: Jose Cabal-Ugaz # Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2012-22 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2012-26 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -697,7 +697,6 @@ def showLeadingOctaveFromNoteGrouping(self, noteGrouping: BrailleElementGrouping True >>> bs1.showLeadingOctaveFromNoteGrouping(beg1) True - ''' currentKey = self.currentGroupingKey previousKey = self.previousGroupingKey @@ -827,8 +826,8 @@ def extractNoteGrouping(self) -> None: # hence -- let us split this noteGrouping into two noteGroupings. try: bngA, bngB = self.splitNoteGroupingAndTranscribe(noteGrouping, - showLeadingOctave, - addSpace) + showLeadingOctave, + addSpace) self.currentLine.append(bngA, addSpace=addSpace) self.addToNewLine(bngB) except BrailleSegmentException: @@ -1004,9 +1003,11 @@ def addGroupingAttributes(self): currentKeySig = brailleElement elif groupingKey.affinity in (Affinity.INACCORD, Affinity.NOTEGROUP): if isinstance(groupingList[0], clef.Clef): - if isinstance(groupingList[0], (clef.TrebleClef, clef.AltoClef)): + if isinstance(groupingList[0], (clef.TrebleClef, clef.Treble8vbClef, + clef.Treble8vaClef, clef.AltoClef)): self.descendingChords = True - elif isinstance(groupingList[0], (clef.BassClef, clef.TenorClef)): + elif isinstance(groupingList[0], (clef.BassClef, clef.Bass8vbClef, + clef.Bass8vaClef, clef.TenorClef)): self.descendingChords = False # make a whole rest no matter the length of the rest if only one note. @@ -1073,8 +1074,8 @@ def fixOneArticulation(artic, music21NoteStart, allNotes, noteIndexStart): newSegment = self.consolidate() noteGroupings = [newSegment[gpKey] - for gpKey in newSegment.keys() - if gpKey.affinity == Affinity.NOTEGROUP] + for gpKey in newSegment.keys() + if gpKey.affinity == Affinity.NOTEGROUP] for noteGrouping in noteGroupings: allNotes_outer = [n for n in noteGrouping if isinstance(n, note.Note)] for noteIndexStart_outer in range(len(allNotes_outer)): @@ -1316,26 +1317,17 @@ def extractHeading(self): while self.allKeyPairs: (rightKey, leftKey) = self.allKeyPairs[0] - useKey = rightKey - try: - useElement = self._groupingDict.get(rightKey) - except KeyError as ke: - if ke.args[0] == 'None': - useElement = self._groupingDict.get(leftKey) - useKey = leftKey - else: - raise ke + useKey = rightKey if rightKey is not None else leftKey + useElement = self._groupingDict.get(useKey) if useKey.affinity > Affinity.MMARK: break self.allKeyPairs.popleft() if useKey.affinity == Affinity.SIGNATURE: - try: - keySignature, timeSignature = useElement[0], useElement[1] - except IndexError: - if isinstance(useElement, key.KeySignature): - keySignature = useElement[0] - else: - timeSignature = useElement[0] + for el in useElement: + if keySignature is None and isinstance(el, key.KeySignature): + keySignature = el + elif timeSignature is None and isinstance(el, meter.TimeSignature): + timeSignature = el elif useKey.affinity == Affinity.TTEXT: tempoText = useElement[0] elif useKey.affinity == Affinity.MMARK: @@ -2369,11 +2361,11 @@ def splitMeasure(music21Measure, beatDivisionOffset=0, useTimeSignature=None): numberOfPartitions = 2 try: - bs = bs.partitionByCount(numberOfPartitions, loadDefault=False) + bs = bs.partitionedByCount(numberOfPartitions, loadDefault=False) (startOffsetZero, endOffsetZero) = bs.getLevelSpan()[0] except meter.MeterException: numberOfPartitions += 1 - bs = bs.partitionByCount(numberOfPartitions, loadDefault=False) + bs = bs.partitionedByCount(numberOfPartitions, loadDefault=False) startOffsetZero = bs.getLevelSpan()[0][0] endOffsetZero = bs.getLevelSpan()[-2][-1] endOffsetZero -= offset @@ -2436,6 +2428,78 @@ def testGetRawSegments(self): rawSegList = getRawSegments(tn) unused = str(rawSegList[0]) + def testGrandSegmentHeadingFromLeftHand(self): + ''' + A key and time signature in only the left hand of a keyboard part + go into the heading. + ''' + from music21.braille.translate import keyboardPartsToBraille + + score = stream.Score() + for pitchName in ('C5', 'C3'): + part = stream.PartStaff() + m = stream.Measure(number=1) + if pitchName == 'C3': + m.append(key.KeySignature(2)) + m.append(meter.TimeSignature('4/4')) + m.append(note.Note(pitchName, quarterLength=4.0)) + part.append(m) + score.insert(0, part) + brailleLines = keyboardPartsToBraille(score).splitlines() + self.assertEqual(brailleLines[0].strip('⠀'), '⠩⠩⠼⠙⠲') + + def testSplitMeasureBeamsLeftWhenTieFillsIt(self): + ''' + Both halves of a split measure are beamed, including a left half that a + tied note extends to the end of the measure. + + AI-assisted (Claude). + ''' + from music21 import tie + + def sixteenths(m, names): + for name in names: + m.append(note.Note(name + '4', type='16th')) + + def beamFlags(m): + return [(n.beamStart, n.beamContinue) for n in m.notes] + + m = stream.Measure() + m.append(meter.TimeSignature('2/4')) + sixteenths(m, 'CDEF') + tiedNote = note.Note('G4') + tiedNote.tie = tie.Tie('start') + m.append(tiedNote) + + left, right = splitMeasure(m) + self.assertEqual([n.name for n in left.notes], ['C', 'D', 'E', 'F', 'G']) + self.assertEqual(left.highestTime, 2.0) + self.assertEqual(beamFlags(left), [(True, False), (False, True), (False, True), + (False, True), (False, False)]) + self.assertEqual(len(right), 0) + + untied = stream.Measure() + untied.append(meter.TimeSignature('2/4')) + sixteenths(untied, 'CDEFGABC') + left, right = splitMeasure(untied) + self.assertEqual(beamFlags(left), [(True, False), (False, True), (False, True), + (False, True)]) + self.assertEqual(beamFlags(right), [(True, False), (False, True), (False, True), + (False, True)]) + + def testOctaveClefsSetChordDirection(self): + for clefClass, descending in ( + (clef.Treble8vbClef, True), + (clef.Treble8vaClef, True), + (clef.Bass8vbClef, False), + (clef.Bass8vaClef, False), + ): + seg = BrailleSegment() + seg.descendingChords = not descending + seg[SegmentKey(1, 0, Affinity.NOTEGROUP, None)].append(clefClass()) + seg.addGroupingAttributes() + self.assertEqual(seg.descendingChords, descending) + if __name__ == '__main__': import music21 diff --git a/music21/braille/test.py b/music21/braille/test.py index ab2c0468c4..cc5d1d81b9 100644 --- a/music21/braille/test.py +++ b/music21/braille/test.py @@ -1848,7 +1848,7 @@ def test_example05_3(self): def test_example05_4(self): self.methodArgs = {'suppressOctaveMarks': True} bm = converter.parse('tinynotation: 4/4 E2 F2 G1 E2 D2 C1 D2 E2 F2 E2 D1 r1 ' - 'C2 D2 E1 F2 G2 A1 G2 F2 E2 D2 C1 r1') + 'C2 D2 E1 F2 G2 A1 G2 F2 E2 D2 C1 r1') bm.makeNotation(inPlace=True, cautionaryNotImmediateRepeat=False) self.s = bm self.e = ''' @@ -3622,9 +3622,9 @@ def test_example08_7a(self): (-4, '4/4', 'Andante', '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠠⠁⠝⠙⠁⠝⠞⠑⠲⠀⠼⠙⠣⠼⠙⠲⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀'), (3, '3/8', 'Con moto', '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠠⠉⠕⠝⠀⠍⠕⠞⠕⠲⠀⠩⠩⠩⠼⠉⠦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀'), (None, '4/4', 'Andante cantabile', - '⠀⠀⠀⠀⠀⠀⠀⠀⠠⠁⠝⠙⠁⠝⠞⠑⠀⠉⠁⠝⠞⠁⠃⠊⠇⠑⠲⠀⠼⠙⠲⠀⠀⠀⠀⠀⠀⠀⠀⠀'), + '⠀⠀⠀⠀⠀⠀⠀⠀⠠⠁⠝⠙⠁⠝⠞⠑⠀⠉⠁⠝⠞⠁⠃⠊⠇⠑⠲⠀⠼⠙⠲⠀⠀⠀⠀⠀⠀⠀⠀⠀'), (2, '7/8', 'Very brightly', - '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠠⠧⠑⠗⠽⠀⠃⠗⠊⠛⠓⠞⠇⠽⠲⠀⠩⠩⠼⠛⠦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀') + '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠠⠧⠑⠗⠽⠀⠃⠗⠊⠛⠓⠞⠇⠽⠲⠀⠩⠩⠼⠛⠦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀') ] for ks, ts, tt, r in results: if ks is not None: @@ -3637,7 +3637,7 @@ def test_example08_8(self): from music21.braille import basic self.assertEqual(basic.metronomeMarkToBraille( tempo.MetronomeMark(number=80, - referent=note.Note(type='half'))), '⠝⠶⠼⠓⠚') + referent=note.Note(type='half'))), '⠝⠶⠼⠓⠚') def test_example08_9(self): from music21.braille.basic import transcribeHeading @@ -3671,7 +3671,7 @@ def test_drill08_1(self): bm = converter.parse(''' tinynotation: 4/4 a2 g8 f8 e4 d4 e4 f8 g8 a4 g4 c'8 b8 a4 g4 f4. e8 d2 c4 e4 a4 e'4 e'4 d'4 c'8 b8 a4 a'4 g'8 f'8 e'4 d'4 c'4 a8 b8 c'2''', - makeNotation=False) + makeNotation=False) bm.replace(bm.getElementsByClass(meter.TimeSignature).first(), meter.TimeSignature('c')) bm.insert(0, tempo.TempoText('Andante maestoso')) bm.insert(0, tempo.MetronomeMark(number=92, referent=note.Note(type='quarter'))) @@ -10098,6 +10098,36 @@ def test_example26_6(self): ⠁⠀⠨⠜⠨⠫⠬⠱⠬⠀⠀⠨⠝⠨⠤ ⠀⠀⠸⠜⠸⠫⠬⠳⠸⠤⠀⠸⠝⠤⠀ ''' + + def test_keyboardInteriorKeyChangeWithoutMeterChange(self): + # https://github.com/cuthbertLab/music21/issues/1986 + def buildPart(pitchNames): + part = stream.PartStaff() + for i, pitchName in enumerate(pitchNames, 1): + m = stream.Measure(number=i) + if i == 1: + m.append(key.KeySignature(0)) + m.append(meter.TimeSignature('4/4')) + if i == 5: + m.append(BrailleSegmentDivision()) + m.append(key.KeySignature(2)) + m.append(note.Note(pitchName, quarterLength=4.0)) + part.append(m) + return part + + self.method = keyboardPartsToBraille + score = stream.Score() + score.insert(0, buildPart(['C5', 'D5', 'E5', 'F5', 'G5', 'A5', 'B5', 'C6'])) + score.insert(0, buildPart(['C3', 'D3', 'E3', 'F3', 'G3', 'A3', 'B3', 'C4'])) + self.s = score + self.b = ''' + ⠀⠀⠀⠀⠀⠀⠼⠙⠲⠀⠀⠀⠀⠀⠀ + ⠁⠀⠨⠜⠨⠽⠀⠨⠵⠀⠨⠯⠀⠨⠿ + ⠀⠀⠸⠜⠸⠽⠀⠸⠵⠀⠸⠯⠀⠸⠿ + ⠀⠀⠀⠀⠀⠀⠀⠩⠩⠀⠀⠀⠀⠀⠀ + ⠑⠀⠨⠜⠨⠷⠀⠨⠮⠀⠨⠾⠀⠰⠽ + ⠀⠀⠸⠜⠸⠷⠀⠸⠮⠀⠸⠾⠀⠐⠽ + ''' # ------------------------------------------------------------------------------ diff --git a/music21/braille/text.py b/music21/braille/text.py index 6335f59499..f2aebbebe2 100644 --- a/music21/braille/text.py +++ b/music21/braille/text.py @@ -4,7 +4,7 @@ # Authors: Jose Cabal-Ugaz # Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2011, 2016 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2011, 2016, 2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ from __future__ import annotations @@ -64,12 +64,11 @@ def showHand(self): @showHand.setter def showHand(self, newHand): - if newHand == 'right': - self.rightHandSymbol = True - elif newHand == 'left': - self.leftHandSymbol = True - elif newHand is not None: + if newHand not in ('right', 'left', None): raise BrailleTextException('Illegal hand sign request.') + self._showHand = newHand + self.rightHandSymbol = (newHand == 'right') + self.leftHandSymbol = (newHand == 'left') def addHeading(self, heading): ''' @@ -147,7 +146,6 @@ def addToNewLine(self, brailleNoteGrouping): >>> print(str(bt)) hi ⠨⠜⠄⠹⠹⠹ - ''' self.makeNewLine() if self.rightHandSymbol or self.leftHandSymbol: @@ -666,7 +664,48 @@ class BrailleTextException(exceptions21.Music21Exception): class Test(unittest.TestCase): - pass + def testShowHandReturnsTheHandSet(self): + bt = BrailleText(lineLength=40) + self.assertIsNone(bt.showHand) + bt.showHand = 'right' + self.assertEqual(bt.showHand, 'right') + self.assertTrue(bt.rightHandSymbol) + + bt = BrailleText(lineLength=40, showHand='left') + self.assertEqual(bt.showHand, 'left') + self.assertTrue(bt.leftHandSymbol) + self.assertFalse(bt.rightHandSymbol) + + with self.assertRaises(BrailleTextException): + bt.showHand = 'both' + self.assertEqual(bt.showHand, 'left') + + def testShowHandSetsOnlyThatHandsSymbol(self): + bt = BrailleText(lineLength=40, showHand='right') + bt.showHand = 'left' + self.assertTrue(bt.leftHandSymbol) + self.assertFalse(bt.rightHandSymbol) + + bt.showHand = 'right' + self.assertTrue(bt.rightHandSymbol) + self.assertFalse(bt.leftHandSymbol) + + bt.showHand = None + self.assertFalse(bt.rightHandSymbol) + self.assertFalse(bt.leftHandSymbol) + + bt.showHand = 'left' + with self.assertRaises(BrailleTextException): + bt.showHand = 'both' + self.assertTrue(bt.leftHandSymbol) + self.assertFalse(bt.rightHandSymbol) + + def testShowHandChangedPrintsTheNewHandSymbol(self): + c = lookup.pitchNameToNotes['C']['quarter'] + bt = BrailleText(lineLength=10, showHand='right') + bt.showHand = 'left' + bt.addToNewLine(c + c + c) + self.assertEqual(str(bt).strip(), symbols['lh_keyboard'] + symbols['dot'] + c + c + c) if __name__ == '__main__': diff --git a/music21/braille/translate.py b/music21/braille/translate.py index f7e19da882..d7528ad4e3 100644 --- a/music21/braille/translate.py +++ b/music21/braille/translate.py @@ -244,22 +244,22 @@ def streamToBraille(music21Stream: stream.Measure|stream.Part|stream.Score|strea ''' if isinstance(music21Stream, stream.Part): return partToBraille(music21Stream, - inPlace=inPlace, - debug=debug, - cancelOutgoingKeySig=cancelOutgoingKeySig, - descendingChords=descendingChords, - dummyRestLength=dummyRestLength, - maxLineLength=maxLineLength, - segmentBreaks=segmentBreaks, - showClefSigns=showClefSigns, - showFirstMeasureNumber=showFirstMeasureNumber, - showHand=showHand, - showHeading=showHeading, - showLongSlursAndTiesTogether=showLongSlursAndTiesTogether, - showShortSlursAndTiesTogether=showShortSlursAndTiesTogether, - slurLongPhraseWithBrackets=slurLongPhraseWithBrackets, - suppressOctaveMarks=suppressOctaveMarks, - upperFirstInNoteFingering=upperFirstInNoteFingering, + inPlace=inPlace, + debug=debug, + cancelOutgoingKeySig=cancelOutgoingKeySig, + descendingChords=descendingChords, + dummyRestLength=dummyRestLength, + maxLineLength=maxLineLength, + segmentBreaks=segmentBreaks, + showClefSigns=showClefSigns, + showFirstMeasureNumber=showFirstMeasureNumber, + showHand=showHand, + showHeading=showHeading, + showLongSlursAndTiesTogether=showLongSlursAndTiesTogether, + showShortSlursAndTiesTogether=showShortSlursAndTiesTogether, + slurLongPhraseWithBrackets=slurLongPhraseWithBrackets, + suppressOctaveMarks=suppressOctaveMarks, + upperFirstInNoteFingering=upperFirstInNoteFingering, ) elif isinstance(music21Stream, stream.Measure): return measureToBraille(music21Stream, @@ -597,7 +597,6 @@ def measureToBraille(music21Measure, ⠼⠁⠀⠐⠽⠣⠅ >>> print(braille.translate.measureToBraille(p.measure(1))) ⠼⠙⠲⠀⠐⠽⠣⠅ - ''' measureToTranscribe = music21Measure if not inPlace: diff --git a/music21/capella/fromCapellaXML.py b/music21/capella/fromCapellaXML.py index 5410207ef9..26e211f8ea 100644 --- a/music21/capella/fromCapellaXML.py +++ b/music21/capella/fromCapellaXML.py @@ -539,7 +539,7 @@ def noteFromHead(self, headElement: ET.Element) -> note.Note: noteNameWithOctave = headElement.attrib['pitch'] n = note.Note() n.nameWithOctave = noteNameWithOctave - n.octave = n.pitch.implicitOctave - 1 # capella octaves are 1 off + n.octave = n.pitch.octave - 1 # capella octaves are 1 off alters = headElement.findall('alter') if len(alters) > 1: @@ -841,7 +841,7 @@ def tupletFromTuplet(self, tupletElement: ET.Element) -> duration.Tuplet: denominator = 1 while numerator > denominator * 2: denominator *= 2 - if 'prolong' in tupletElement.attrib and tupletElement.attrib['count'] == 'true': + if 'prolong' in tupletElement.attrib and tupletElement.attrib['prolong'] == 'true': denominator *= 2 if 'tripartite' in tupletElement.attrib: @@ -851,7 +851,7 @@ def tupletFromTuplet(self, tupletElement: ET.Element) -> duration.Tuplet: tup = duration.Tuplet(numerator, denominator) return tup - barlineMap = {'single': 'normal', + barlineMap = {'single': 'regular', 'double': 'double', 'end': 'final', 'repEnd': 'end', @@ -877,7 +877,6 @@ def barlineListFromBarline(self, barlineElement: ET.Element) -> list[bar.Barline >>> repeatTag = ci.domElementFromText('') >>> ci.barlineListFromBarline(repeatTag) [, ] - ''' barlineList: list[bar.Barline] = [] hasRepeatEnd = False @@ -920,6 +919,26 @@ def testComplete(self): self.assertIn('mass!', text.assembleLyrics(partScore.parts[0], 1)) self.assertIn('scherz', text.assembleLyrics(partScore.parts[0], 2)) + def testSingleBarline(self): + ci = CapellaImporter() + barlineTag = ci.domElementFromText('') + barlines = ci.barlineListFromBarline(barlineTag) + self.assertEqual(len(barlines), 1) + self.assertEqual(barlines[0].type, 'regular') + + def testTupletProlong(self): + ci = CapellaImporter() + tupletTag = ci.domElementFromText('') + tup = ci.tupletFromTuplet(tupletTag) + self.assertEqual((tup.numberNotesActual, tup.numberNotesNormal), (3, 4)) + tupletTag = ci.domElementFromText('') + tup = ci.tupletFromTuplet(tupletTag) + self.assertEqual((tup.numberNotesActual, tup.numberNotesNormal), (3, 2)) + for count in (5, 6): + tupletTag = ci.domElementFromText(f'') + tup = ci.tupletFromTuplet(tupletTag) + self.assertEqual((tup.numberNotesActual, tup.numberNotesNormal), (count, 8)) + class TestExternal(unittest.TestCase): show = True diff --git a/music21/chord/__init__.py b/music21/chord/__init__.py index da9fc2e8d9..e1eebf4f64 100644 --- a/music21/chord/__init__.py +++ b/music21/chord/__init__.py @@ -5,7 +5,7 @@ # Authors: Michael Scott Asato Cuthbert # Christopher Ariza # -# Copyright: Copyright © 2009-2024 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2009-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -725,6 +725,31 @@ class Chord(ChordBase): False >>> c1 != c2 True + + **Empty chords** + + A chord with no pitches is False, so `if not c:` finds one: + + >>> emptyChord = chord.Chord() + >>> len(emptyChord) + 0 + >>> bool(emptyChord) + False + >>> bool(c1) + True + + Most methods still answer: + + >>> emptyChord.commonName + 'empty chord' + >>> emptyChord.quality + 'other' + + But calling `root()` on an empty chord raises an exception: + + >>> emptyChord.root() + Traceback (most recent call last): + music21.chord.ChordException: no pitches in chord ''' # CLASS VARIABLES # isChord = True @@ -925,7 +950,6 @@ def formatVectorString(vectorList: Iterable[int]) -> str: >>> c1.formatVectorString([10, 11, 3, 5]) '' - ''' msg = ['<'] for e in vectorList: # should be numbers @@ -965,17 +989,26 @@ def _removePitchByRedundantAttribute( ''' Common method for stripping pitches based on redundancy of one pitch attribute. The `attribute` is provided by a string. + + attribute `'nameWithOctave'` uses a special case to make comparisons with + negative octaves work properly ''' if not inPlace: # make a copy returnObj = copy.deepcopy(self) else: returnObj = self - uniquePitches = [] + uniquePitches: set[t.Any] = set() deleteComponents = [] for comp in returnObj._notes: - if getattr(comp.pitch, attribute) not in uniquePitches: - uniquePitches.append(getattr(comp.pitch, attribute)) + if attribute == 'nameWithOctave': + # for comparing with negative octaves etc. + value = (comp.pitch.name, comp.pitch.octave, comp.pitch.octaveIsImplicit) + else: + value = getattr(comp.pitch, attribute) + + if value not in uniquePitches: + uniquePitches.add(value) else: deleteComponents.append(comp) @@ -1333,7 +1366,6 @@ def bass( # # >>> cmin_inv.bass() # - ''' if newbass: newbassPitch: pitch.Pitch @@ -1424,7 +1456,6 @@ def canBeTonic(self) -> bool: >>> a = chord.Chord(['g', 'b', 'd']) >>> a.canBeTonic() True - ''' if self.isMajorTriad() or self.isMinorTriad(): return True @@ -1547,9 +1578,6 @@ def closedPosition( pBass = returnObj.bass() # returns a reference, not a copy if forceOctave is not None: pBassOctave = pBass.octave - if pBassOctave is None: - pBassOctave = pBass.implicitOctave - if pBassOctave > forceOctave: dif = -1 elif pBassOctave < forceOctave: @@ -1560,16 +1588,13 @@ def closedPosition( while pBass.octave != forceOctave: # shift octave of all pitches for p in returnObj.pitches: - if p.octave is None: - p.octave = p.implicitOctave p.octave += dif # can change these pitches in place for p in returnObj.pitches: # bring each pitch down octaves until pitch space is # within an octave - if p.octave is None: - p.octave = p.implicitOctave + p.octaveIsImplicit = False while p.ps >= pBass.ps + 12: p.octave -= 1 # check for a bass of C4 and the note B#7 added to it, should be B#4 not B#3... @@ -2152,7 +2177,6 @@ def hasRepeatedChordStep( >>> cChord.hasRepeatedChordStep(5) False - ''' if testRoot is None: testRoot = self.root() @@ -2189,7 +2213,6 @@ def intervalFromChordStep( >>> print(cmaj.intervalFromChordStep(6)) None - ''' if testRoot is None: try: @@ -2209,8 +2232,6 @@ def inversion( self, newInversion: int, *, - find: bool = True, - testRoot: pitch.Pitch|None = None, transposeOnSet: bool = True ) -> None: ... @@ -2222,7 +2243,6 @@ def inversion( *, find: bool = True, testRoot: pitch.Pitch|None = None, - transposeOnSet: bool = True ) -> int: ... @@ -2235,27 +2255,21 @@ def inversion( transposeOnSet: bool = True, ) -> int|None: ''' - Find the chord's inversion or (if called with a number) set the chord to - the new inversion. + Find the chord's inversion. - When called without a number argument, returns an integer (or None) - representing which inversion (if any) + Returns an integer representing which inversion (if any) the chord is in. The Chord does not have to be complete, in which case this function determines the inversion by looking at the relationship of the bass note to the root. - Returns a maximum value of 5 for the fifth inversion of a thirteenth chord. + Returns a maximum value of 6 for the sixth inversion of a thirteenth chord. Returns 0 if the bass to root interval is a unison - or if interval is not a common inversion (1st-5th). + and -1 if the interval is not a common inversion. The octave of the bass and root are irrelevant to this calculation of inversion. - Returns None if the Chord has no pitches. >>> g7 = chord.Chord(['g4', 'b4', 'd5', 'f5']) >>> g7.inversion() 0 - >>> g7.inversion(1) - >>> g7 - With implicit octaves, D becomes the bass (since octaves start on C): @@ -2263,14 +2277,6 @@ def inversion( >>> g7_implicit.inversion() 2 - Note that in inverting a chord with implicit octaves, some - pitches will gain octave designations, but not necessarily all of them - (this behavior might change in the future): - - >>> g7_implicit.inversion(1) - >>> g7_implicit - - Examples of each inversion: >>> cTriad1stInversion = chord.Chord(['E1', 'G1', 'C2']) @@ -2296,18 +2302,7 @@ def inversion( >>> bbEleventh5thInversion.inversion() 5 - Repeated notes do not affect the inversion: - - >>> gMajRepeats = chord.Chord(['G4', 'B5', 'G6', 'B6', 'D7']) - >>> gMajRepeats.inversion(2) - >>> gMajRepeats - - - >>> gMajRepeats.inversion(3) - Traceback (most recent call last): - music21.chord.ChordException: Could not invert chord: inversion may not exist - - If testRoot is True then that temporary root is used instead of self.root(). + If testRoot is given then that temporary root is used instead of self.root(). Get the inversion for a seventh chord showing different roots @@ -2316,9 +2311,6 @@ def inversion( 0 >>> dim7.inversion(testRoot=pitch.Pitch('D5')) 6 - >>> dim7.inversion('six-four') - Traceback (most recent call last): - music21.chord.ChordException: Inversion must be an integer, got: Chords without pitches or otherwise impossible chords return -1, indicating no normal inversion. @@ -2339,28 +2331,28 @@ def inversion( >>> chord.Chord('G4 C5').inversion() 2 - If transposeOnSet is False then setting the inversion simply - sets the value to be returned later, which might be useful for - cases where the chords are poorly spelled, or there is an added note. + Calling `inversion(newInversion)` with a number changes the chord in place + (`transposeOnSet` is `transpose` in :meth:`setInversion`) and returns None. + This form will be deprecated in v12 and removed in v13; use + :meth:`setInversion` instead. * Changed in v8: deal with chords without pitches. + * Changed in v11: setting the inversion moved to :meth:`setInversion`. ''' if not self.pitches: return -1 + if newInversion is not None: + # v12: deprecate newInversion and transposeOnSet; v13: remove them. + self.setInversion(newInversion, transpose=transposeOnSet, inPlace=True) + return None + if testRoot is not None: rootPitch = testRoot else: rootPitch = self.root() - if newInversion is not None: - try: - int_newInversion = int(newInversion) - except (ValueError, TypeError): - raise ChordException(f'Inversion must be an integer, got: {type(newInversion)}') - self._setInversion(int_newInversion, rootPitch, transposeOnSet) - return None - elif ('inversion' not in self._overrides and find) or testRoot is not None: + if ('inversion' not in self._overrides and find) or testRoot is not None: try: if rootPitch is None or self.bass() is None: return -1 @@ -2373,21 +2365,142 @@ def inversion( else: return -1 - def _setInversion( + @overload + def setInversion( self, - newInversion: int, - rootPitch: pitch.Pitch, - transposeOnSet: bool, + newInversion: int|None, + *, + transpose: bool = True, + inPlace: t.Literal[True], ) -> None: + ... + + @overload + def setInversion( + self, + newInversion: int|None, + *, + transpose: bool = True, + inPlace: t.Literal[False] = False, + ) -> t.Self: + ... + + def setInversion( + self, + newInversion: int|None, + *, + transpose: bool = True, + inPlace: bool = False, + ) -> t.Self|None: + ''' + Return a new chord in the given inversion, moving the lowest + note up an octave until the chord reaches it. + + >>> g7 = chord.Chord(['G4', 'B4', 'D5', 'F5']) + >>> g7.setInversion(1) + + + With `inPlace=True` the chord itself changes and None is returned: + + >>> g7.setInversion(3, inPlace=True) + >>> g7 + + + Pitches with implicit octaves gain octave designations as they move, + but not necessarily all of them (this behavior might change in the future): + + >>> chord.Chord(['G', 'B', 'D', 'F']).setInversion(1) + + + Repeated notes do not affect the inversion: + + >>> gMajRepeats = chord.Chord(['G4', 'B5', 'G6', 'B6', 'D7']) + >>> gMajRepeats.setInversion(2) + + + An inversion the chord does not have raises a ChordException: + + >>> gMajRepeats.setInversion(3) + Traceback (most recent call last): + music21.chord.ChordException: Could not invert chord: inversion may not exist + + If `transpose` is False then the pitches do not move; the value is + simply stored to be returned by :meth:`inversion` later, which might be + useful for chords that are not spelled according to common-practice + function or have an added note. + + *Example:* This standard jazz/pop chord, C6, is considered a 1st inversion + by default for music21: + + >>> c6 = chord.Chord('C4 E4 G4 A4') + >>> c6.inversion() + 1 + + We can label it as a root position jazz chord instead, with + `transpose=False` and `inPlace=True`: + + >>> c6.setInversion(0, transpose=False, inPlace=True) + >>> c6.inversion() + 0 + + The bass has not changed: + + >>> c6.bass() + + + Setting the inversion to None removes a stored inversion, so + :meth:`inversion` again reads it from the pitches: + + >>> c6.setInversion(None, inPlace=True) + >>> c6.inversion() + 1 + + * New in v11. Was previously part of the functionality of :meth:`inversion`. + + AI-assisted (Claude) + ''' + int_newInversion: int|None = None + if newInversion is not None: + try: + int_newInversion = int(newInversion) + except (ValueError, TypeError): + raise ChordException(f'Inversion must be an integer, got: {type(newInversion)}') + + if inPlace: + returnObj = self + else: + returnObj = copy.deepcopy(self) + returnObj.derivation = derivation.Derivation(returnObj) + returnObj.derivation.origin = self + returnObj.derivation.method = 'setInversion' + + if int_newInversion is None: + returnObj._overrides.pop('inversion', None) + elif not transpose: + returnObj._overrides['inversion'] = int_newInversion + else: + returnObj._transposeToInversion(int_newInversion) + + if inPlace: + return None + return returnObj + + def _transposeToInversion(self, newInversion: int) -> None: ''' - Helper function for inversion(int) + Helper function for setInversion that works in place on a chord to move + the bass and potentially other notes up octaves until the chord is in + `newInversion`. Raises a ChordException if it never gets there. + + >>> c = chord.Chord('C4 E4 G4') + >>> c._transposeToInversion(2) + >>> c + ''' - if transposeOnSet is False: - self._overrides['inversion'] = newInversion - return + if not self.pitches: + raise ChordException('Cannot invert a chord without pitches') + # could have set bass or root externally numberOfRunsBeforeCrashing = len(self.pitches) + 2 - soughtInversion = newInversion if 'inversion' in self._overrides: del self._overrides['inversion'] @@ -2395,14 +2508,11 @@ def _setInversion( # bass might have been overridden for a different octave del self._overrides['bass'] currentInversion = self.inversion(find=True) - while currentInversion != soughtInversion and numberOfRunsBeforeCrashing > 0: + while currentInversion != newInversion and numberOfRunsBeforeCrashing > 0: currentMaxMidi = max(self.pitches).ps tempBassPitch = self.bass() while tempBassPitch.ps < currentMaxMidi: - if tempBassPitch.octave is not None: - tempBassPitch.octave += 1 - else: - tempBassPitch.octave = tempBassPitch.implicitOctave + 1 + tempBassPitch.octave += 1 # housekeeping for next loop tests self.clearCache() @@ -2544,7 +2654,6 @@ def isAugmentedSixth(self, *, permitAnyInversion: bool = False) -> bool: True If `permitAnyInversion` is True then any inversion is allowed. - ''' # cardinality is just used to speed up the call to avoid checking multiple augmented # 6ths on a triad, etc. The fact that Ab C F# Gb will have cardinality of 3 @@ -2692,7 +2801,6 @@ def isConsonant(self) -> bool: >>> c13.isConsonant() False - ''' c2 = self.removeRedundantPitchNames(inPlace=False) if len(c2.pitches) == 1: @@ -2939,7 +3047,6 @@ def isGermanAugmentedSixth(self, *, permitAnyInversion=False) -> bool: >>> gr6d = chord.Chord(['A-3', 'C-4', 'E-4', 'F#4']) >>> gr6d.isGermanAugmentedSixth() False - ''' return self._isAugmentedSixthHelper( (4, 27, -1), @@ -3632,41 +3739,37 @@ def removeRedundantPitches(self, *, inPlace: bool = False) -> t.Self|list[pitch. >>> c2c - It is a known bug that because pitch.nameWithOctave gives - the same value for B-flat in octave 1 as B-natural in octave - negative 1, negative octaves can screw up this method. - With all the things left to do for music21, it doesn't seem - a bug worth squashing at this moment, but FYI: + Pitches with extreme octaves (whose `.nameWithOctave` strings + look identical) are still distinguished. + + >>> p1 = pitch.Pitch('B') + >>> p1.octave = -1 + >>> p2 = pitch.Pitch('B-') + >>> p2.octave = 1 + >>> p1.nameWithOctave == p2.nameWithOctave + True - >>> p1 = pitch.Pitch('B-') - >>> p1.octave = 1 - >>> p2 = pitch.Pitch('B') - >>> p2.octave = -1 >>> c3 = chord.Chord([p1, p2]) - >>> removedPitches = c3.removeRedundantPitches(inPlace=True) - >>> c3.pitches - (,) + >>> c3 + - >>> c3.pitches[0].name - 'B-' - >>> c3.pitches[0].octave - 1 + >>> removedPitches = c3.removeRedundantPitches(inPlace=True) >>> removedPitches - [] - >>> removedPitches[0].name - 'B' - >>> removedPitches[0].octave - -1 - - The first pitch survives: + [] + >>> c3 + + >>> [(p.name, p.octave) for p in c3.pitches] + [('B', -1), ('B-', 1)] - >>> c3.pitches[0] is p1 - True + A pitch with no octave of its own is likewise not the same pitch as + one placed in the default octave: - >>> c3.pitches[0] is p2 - False + >>> c4 = chord.Chord([pitch.Pitch('C'), pitch.Pitch('C4')]) + >>> c4.removeRedundantPitches() + * Changed in v6: inPlace defaults to False. + * Changed in v11: works properly with negative octaves ''' return self._removePitchByRedundantAttribute('nameWithOctave', inPlace=inPlace) @@ -4000,12 +4103,11 @@ def semiClosedPosition( ... ) >>> c1 - ''' c2 = self.closedPosition(forceOctave=forceOctave, inPlace=inPlace, leaveRedundantPitches=leaveRedundantPitches) - if inPlace is True: + if inPlace: c2 = self # closedPosition() only returns None when inPlace=True, in which case c2 @@ -4021,14 +4123,14 @@ def semiClosedPosition( if p.step not in usedSteps: usedSteps.append(p.step) else: - p.octave = p.implicitOctave + 1 + p.octave += 1 newRemainingPitches.append(p) remainingPitches = newRemainingPitches c2.clearCache() c2.sortAscending(inPlace=True) - if inPlace is False: + if not inPlace: return c2 def semitonesFromChordStep(self, chordStep, testRoot=None): @@ -4375,7 +4477,7 @@ def simplifyEnharmonics( for i in range(len(pitches)): returnObj._notes[i].pitch = pitches[i] - if inPlace is False: + if not inPlace: return returnObj @overload @@ -4491,7 +4593,6 @@ def transpose(self, value, *, inPlace=False): >>> a.transpose(aInterval, inPlace=True) >>> a - ''' if hasattr(value, 'diatonic'): # it is an Interval class intervalObj = value @@ -4985,7 +5086,6 @@ def fullName(self) -> str: >>> chord.Chord(['d1', 'e4-', 'b3-'], quarterLength=2/3).fullName 'Chord {D in octave 1 | E-flat in octave 4 | B-flat in octave 3} Quarter Triplet (2/3 QL)' - ''' msg = [] sub = [] @@ -5823,7 +5923,6 @@ def fromForteClass(notation: str|Sequence[int]) -> Chord: >>> chord.fromForteClass((11, 1)) - ''' card = None num = 1 @@ -5879,7 +5978,6 @@ def fromIntervalVector(notation: Sequence[int], getZRelation: bool = False) -> C >>> chord.fromIntervalVector((1, 1, 1, 1, 1, 1)).getZRelation() - ''' addressList = None if common.isListLike(notation): diff --git a/music21/chord/tables.py b/music21/chord/tables.py index 17aa3d4b4c..ce9c031ca7 100644 --- a/music21/chord/tables.py +++ b/music21/chord/tables.py @@ -1948,9 +1948,6 @@ def seekChordTablesAddress(c: chord.Chord) -> ChordTableAddress: # ------------------------------------------------------------------------------ class Test(unittest.TestCase): - def testDummy(self): - self.assertEqual(True, True) - def testTnIndexToChordInfo(self): for key, value in tnIndexToChordInfo.items(): self.assertEqual(len(key), 3) diff --git a/music21/clef.py b/music21/clef.py index 3a85fc7b5b..12906377fd 100644 --- a/music21/clef.py +++ b/music21/clef.py @@ -6,7 +6,7 @@ # Christopher Ariza # Michael Bodenbach # -# Copyright: Copyright © 2009-2024 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2009-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -17,7 +17,7 @@ ''' from __future__ import annotations -from collections.abc import Iterable, Sequence +from collections.abc import Sequence import typing as t import unittest @@ -107,7 +107,6 @@ class Clef(base.Music21Object): True >>> clef.NoClef().sign 'none' - ''', 'line': ''' The line, counting from the bottom up, that the clef resides on. @@ -391,7 +390,6 @@ class TabClef(PitchClef): >>> a.sign 'TAB' ''' - def __init__(self, **keywords): super().__init__(**keywords) self.sign = 'TAB' @@ -399,7 +397,7 @@ def __init__(self, **keywords): def getStemDirectionForPitches( self, - pitchList: pitch.Pitch|Iterable[pitch.Pitch], + pitches: pitch.Pitch|Sequence[pitch.Pitch], *, firstLastOnly: bool = True, extremePitchOnly: bool = False, @@ -425,7 +423,6 @@ class GClef(PitchClef): >>> a.lowestLine 31 ''' - def __init__(self, **keywords): super().__init__(**keywords) self.sign = 'G' @@ -443,7 +440,6 @@ class FrenchViolinClef(GClef): >>> a.line 1 ''' - def __init__(self, **keywords): super().__init__(**keywords) self.line = 1 @@ -464,14 +460,13 @@ class TrebleClef(GClef): >>> note.Note('E4').pitch.diatonicNoteNum 31 ''' - def __init__(self, **keywords): super().__init__(**keywords) self.line = 2 self.lowestLine = (7 * 4) + 3 # 4 octaves + 3 notes = e4 -class Treble8vbClef(TrebleClef): +class Treble8vbClef(GClef): ''' A vocal tenor treble clef. Also for guitars. @@ -480,15 +475,17 @@ class Treble8vbClef(TrebleClef): 'G' >>> a.octaveChange -1 - ''' + * Changed in v11: no longer a subclass of :class:`~music21.clef.TrebleClef`. + ''' def __init__(self, **keywords): super().__init__(**keywords) + self.line = 2 self.octaveChange = -1 self.lowestLine = (7 * 3) + 3 -class Treble8vaClef(TrebleClef): +class Treble8vaClef(GClef): ''' A treble clef an octave up (such as for piccolos). @@ -497,12 +494,14 @@ class Treble8vaClef(TrebleClef): 'G' >>> a.octaveChange 1 - ''' + * Changed in v11: no longer a subclass of :class:`~music21.clef.TrebleClef`. + ''' def __init__(self, **keywords): super().__init__(**keywords) + self.line = 2 self.octaveChange = 1 - self.lowestLine = (7 * 3) + 3 + self.lowestLine = (7 * 5) + 3 class GSopranoClef(GClef): @@ -516,7 +515,6 @@ class GSopranoClef(GClef): >>> a.line 3 ''' - def __init__(self, **keywords): super().__init__(**keywords) self.line = 3 @@ -533,7 +531,6 @@ class CClef(PitchClef): >>> a.sign 'C' ''' - def __init__(self, **keywords): super().__init__(**keywords) self.sign = 'C' @@ -550,7 +547,6 @@ class SopranoClef(CClef): >>> a.line 1 ''' - def __init__(self, **keywords): super().__init__(**keywords) self.line = 1 @@ -568,7 +564,6 @@ class MezzoSopranoClef(CClef): >>> a.line 2 ''' - def __init__(self, **keywords): super().__init__(**keywords) self.line = 2 @@ -585,7 +580,6 @@ class AltoClef(CClef): >>> a.line 3 ''' - def __init__(self, **keywords): super().__init__(**keywords) self.line = 3 @@ -602,9 +596,7 @@ class TenorClef(CClef): 'C' >>> a.line 4 - ''' - def __init__(self, **keywords): super().__init__(**keywords) self.line = 4 @@ -621,7 +613,6 @@ class CBaritoneClef(CClef): >>> a.line 5 ''' - def __init__(self, **keywords): super().__init__(**keywords) self.line = 5 @@ -637,7 +628,6 @@ class FClef(PitchClef): >>> a.sign 'F' ''' - def __init__(self, **keywords): super().__init__(**keywords) self.sign = 'F' @@ -673,7 +663,6 @@ class BassClef(FClef): >>> a.sign 'F' ''' - def __init__(self, **keywords): super().__init__(**keywords) self.line = 4 @@ -690,12 +679,11 @@ class Bass8vbClef(FClef): >>> a.octaveChange -1 ''' - def __init__(self, **keywords): super().__init__(**keywords) self.line = 4 self.octaveChange = -1 - self.lowestLine = (7 * 2) + 5 + self.lowestLine = (7 * 1) + 5 class Bass8vaClef(FClef): @@ -706,12 +694,11 @@ class Bass8vaClef(FClef): >>> a.sign 'F' ''' - def __init__(self, **keywords): super().__init__(**keywords) self.line = 4 self.octaveChange = 1 - self.lowestLine = (7 * 2) + 5 + self.lowestLine = (7 * 3) + 5 class SubBassClef(FClef): @@ -722,7 +709,6 @@ class SubBassClef(FClef): >>> a.sign 'F' ''' - def __init__(self, **keywords): super().__init__(**keywords) self.line = 5 @@ -824,16 +810,12 @@ def clefFromString(clefString, octaveShift=0) -> Clef: else: lineNum = False elif len(xnStr) > 2: - from music21 import clef as myself xnLower = xnStr.lower() - for x in dir(myself): - if 'Clef' not in x: - continue - if xnLower != x.lower() and xnLower + 'clef' != x.lower(): + for className, classObj in globals().items(): + if not isinstance(classObj, type) or not issubclass(classObj, Clef): continue - objType = getattr(myself, x) - if isinstance(objType, type): - return objType() + if className.lower() in (xnLower, xnLower + 'clef'): + return classObj() raise ClefException('Could not find clef ' + xnStr) else: diff --git a/music21/common/classTools.py b/music21/common/classTools.py index 024074b6e7..a8ce7ddeb8 100644 --- a/music21/common/classTools.py +++ b/music21/common/classTools.py @@ -84,7 +84,6 @@ def isNum(usrData: t.Any) -> t.TypeGuard[t.Union[float, int, Fraction]]: >>> from decimal import Decimal >>> common.isNum(Decimal('2.0')) True - ''' # noinspection PyBroadException try: diff --git a/music21/common/decorators.py b/music21/common/decorators.py index a71e37455b..390a33063b 100644 --- a/music21/common/decorators.py +++ b/music21/common/decorators.py @@ -125,7 +125,6 @@ def deprecated( Restore stderr at the end. >>> sys.stderr = saveStdErr - ''' if hasattr(method, '__qualname__'): funcName = method.__qualname__ diff --git a/music21/common/formats.py b/music21/common/formats.py index 7d9bf7cce3..40bbbc64b3 100644 --- a/music21/common/formats.py +++ b/music21/common/formats.py @@ -73,7 +73,6 @@ def findSubConverterForFormat(fmt: str) -> type[SubConverter]|None: >>> common.findSubConverterForFormat('t') - ''' fmt = fmt.lower().strip() from music21 import converter diff --git a/music21/common/numberTools.py b/music21/common/numberTools.py index fa465cd536..34eb8cbd5c 100644 --- a/music21/common/numberTools.py +++ b/music21/common/numberTools.py @@ -14,7 +14,6 @@ from functools import cache import math from math import isclose, gcd -import numbers import random from typing import overload, TYPE_CHECKING import unittest @@ -358,7 +357,7 @@ def opFrac(num: OffsetQLIn) -> OffsetQL: raise TypeError(f'Cannot convert num: {num}') -def mixedNumeral(expr: numbers.Real, +def mixedNumeral(expr: int|float|Fraction, limitDenominator: int = defaults.limitOffsetDenominator) -> str: ''' Returns a string representing a mixedNumeral form of a number @@ -932,7 +931,6 @@ def groupContiguousIntegers(src: list[int]) -> list[list[int]]: # noinspection SpellCheckingInspection def fromRoman(num: str, *, strictModern: bool = False) -> int: ''' - Convert a Roman numeral (upper or lower) to an int https://code.activestate.com/recipes/81611-roman-numerals/ diff --git a/music21/common/objects.py b/music21/common/objects.py index f6993e92fb..fb80c78f34 100644 --- a/music21/common/objects.py +++ b/music21/common/objects.py @@ -159,6 +159,8 @@ def __call__(self) -> int: # ------------------------------------------------------------------------------ +_slotsByClass: dict[type, frozenset[str]] = {} + class SlottedObjectMixin: r''' @@ -223,9 +225,10 @@ def __setstate__(self, state: dict[str, t.Any]) -> None: for slot, value in state.items(): setattr(self, slot, value) - def _getSlotsRecursive(self) -> set[str]: + def _getSlotsRecursive(self) -> frozenset[str]: ''' - Find all slots recursively. + Find all slots recursively, except `_frozenHash`, where a + :class:`FrozenObject` keeps its hash. Cached per class. A private attribute so as not to change the contents of inheriting objects private interfaces: @@ -250,10 +253,17 @@ def _getSlotsRecursive(self) -> set[str]: ['_editorial', '_style', 'direction', 'funkiness', 'groovability', 'id', 'independentAngle', 'number', 'type'] ''' + try: + return _slotsByClass[type(self)] + except KeyError: + pass slots: set[str] = set() for cls in self.__class__.mro(): slots.update(getattr(cls, '__slots__', ())) - return slots + slots.discard('_frozenHash') + frozenSlots = frozenset(slots) + _slotsByClass[type(self)] = frozenSlots + return frozenSlots class EqualSlottedObjectMixin(SlottedObjectMixin): @@ -287,19 +297,30 @@ def __ne__(self, other: object) -> bool: # my own version which Ned Batchelder (as usual) already anticipated: # https://stackoverflow.com/questions/4828080/how-to-make-an-immutable-object-in-python class FrozenObject(EqualSlottedObjectMixin): + ''' + An object whose slots cannot be set after `__init__`, `__new__`, or + `__setstate__`. It is hashable by the values of its slots. + + A subclass that lists `'_frozenHash'` in its `__slots__` computes its hash + once and keeps it there. + ''' __slots__: tuple[str, ...] = () + _frozenHash: int + def _check_init(self, key: str|None = None) -> bool: if key == '__class__': return True if not getattr(self, 'frozen', True): return True - for st in inspect.stack(): - if (st.frame.f_code.co_name in ('__init__', '__new__', '__setstate__') - and 'self' in st.frame.f_locals - and st.frame.f_locals['self'].__class__ == self.__class__): + frame = inspect.currentframe() + while frame is not None: + if (frame.f_code.co_name in ('__init__', '__new__', '__setstate__') + and 'self' in frame.f_locals + and frame.f_locals['self'].__class__ == self.__class__): return True + frame = frame.f_back raise TypeError(f'This {self.__class__.__name__} instance is immutable.') def __setattr__(self, key: str, value: t.Any) -> None: @@ -323,10 +344,19 @@ def __delitem__(self, key: t.Any) -> None: raise TypeError(f'{self.__class__} object is not subscriptable') def __hash__(self) -> int: + try: + return self._frozenHash + except AttributeError: + pass out = [] for s in self._getSlotsRecursive(): out.append((s, getattr(self, s))) - return hash(tuple(out)) + h = hash(tuple(out)) + try: + object.__setattr__(self, '_frozenHash', h) + except AttributeError: + pass + return h # ------------------------------------------------------------------------------ diff --git a/music21/common/parallel.py b/music21/common/parallel.py index d10c509339..438d2cf5c4 100644 --- a/music21/common/parallel.py +++ b/music21/common/parallel.py @@ -272,11 +272,8 @@ def _countUnpacked(i: int, filename: str) -> bool: class Test(unittest.TestCase): - # pylint: disable=redefined-outer-name def x_figure_out_segfault_testMultiprocess(self) -> None: files = ['bach/bwv66.6', 'schoenberg/opus19', 'AcaciaReel'] - # for importing into testSingleCoreAll we need the full path to the modules - from music21.common.parallel import _countN, _countUnpacked output = runParallel(files, _countN) self.assertEqual(output, [165, 50, 131]) runParallel(files, diff --git a/music21/common/pathTools.py b/music21/common/pathTools.py index fffc7601d8..60f1cb1b3f 100644 --- a/music21/common/pathTools.py +++ b/music21/common/pathTools.py @@ -180,6 +180,11 @@ def cleanpath(path: str|pathlib.Path, *, v5 -- returnPathlib -- None (default) does not convert. False, returns a string, True, returns a pathlib.Path. + + A '..' is collapsed only if the folders before it exist, as the operating system + requires, so text such as 'tinyNotation: /../../x.xml' does not become a real path. + + * Changed in v11: '..' after a missing folder is no longer collapsed. ''' if isinstance(path, pathlib.Path): path = str(path) @@ -188,9 +193,19 @@ def cleanpath(path: str|pathlib.Path, *, elif returnPathlib is None: returnPathlib = False path = os.path.expanduser(path) - path = os.path.normpath(path) - if not os.path.isabs(path): - path = os.path.abspath(path) + parts = pathlib.PurePath(path).parts + canNormalize = True + if '..' in parts: + lastDotdotIndex = max(i for i, part in enumerate(parts) if part == '..') + throughLastDotdot = parts[:lastDotdotIndex + 1] + canNormalize = os.path.isdir(os.path.join(*throughLastDotdot)) + + if canNormalize: + path = os.path.normpath(path) + if not os.path.isabs(path): + path = os.path.abspath(path) + else: + path = os.path.join(os.getcwd(), path) path = os.path.expandvars(path) if not returnPathlib: @@ -204,6 +219,13 @@ def testGetSourcePath(self) -> None: fp = getSourceFilePath() self.assertIsInstance(fp, pathlib.Path) + @unittest.skipIf(os.name == 'nt', "Windows collapses '..' before checking the disk") + def testCleanpathParentDirs(self) -> None: + fp = getSourceFilePath() + missingThenUp = str(fp / 'noSuchFolder' / '..' / 'corpus') + self.assertEqual(cleanpath(missingThenUp), missingThenUp) + self.assertEqual(cleanpath(str(fp / 'common' / '..' / 'corpus')), str(fp / 'corpus')) + if __name__ == '__main__': import music21 diff --git a/music21/configure.py b/music21/configure.py index e3c24e4ad4..0add245510 100644 --- a/music21/configure.py +++ b/music21/configure.py @@ -3,8 +3,9 @@ # Purpose: Installation and Configuration Utilities # # Authors: Christopher Ariza +# Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2011-2019 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2011-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ from __future__ import annotations @@ -41,6 +42,9 @@ reFinaleReaderApp = re.compile(r'Finale Reader\.app', IGNORECASE) reMuseScoreApp = re.compile(r'MuseScore.*\.app', IGNORECASE) reMuseScoreExe = re.compile(r'Musescore.*\\bin\\MuseScore.*\.exe', IGNORECASE) +# matches any MuseScore file/app name regardless of version or platform: +# MuseScore.app, MuseScore 4.app, MuseScore3.exe, mscore, mscore4portable, etc. +reMuseScoreName = re.compile(r'MuseScore|mscore', IGNORECASE) reDoricoApp = re.compile(r'Dorico.*\.app', IGNORECASE) reDoricoExe = re.compile(r'Dorico.*\.exe', IGNORECASE) @@ -314,7 +318,6 @@ def prependPromptHeader(self, msg): def appendPromptHeader(self, msg): ''' - >>> d = configure.Dialog() >>> d.appendPromptHeader('test') >>> d._promptHeader @@ -360,7 +363,6 @@ def _rawQueryPrepareHeader(self, msg=''): ''' Prepare the header, given a string. - >>> from music21 import configure >>> d = configure.Dialog() >>> d._rawQueryPrepareHeader('test') 'test' @@ -1293,17 +1295,37 @@ def _askFillEmptyList(self, default=None, force=None): def _performAction(self, simulate=False): ''' - The action here is to open the stored URL in a browser, if the user agrees. + Set musicxmlPath to the selected reader, and point musescoreDirectPNGPath + (used for notation in notebooks) at a MuseScore: the selected reader if it + is one, otherwise any MuseScore found, unless the current setting works. + + AI-assisted (Claude). ''' + from music21.converter.museScore import findMuseScore + result = self.getResult() - if result is not None and not isinstance(result, DialogError): - # noinspection PyTypeChecker - reload(environment) - # us = environment.UserSettings() - # us['musicxmlPath'] = result # automatically writes - environment.set('musicxmlPath', result) - musicXmlNew = environment.get('musicxmlPath') - self._writeToUser([f'MusicXML Reader set to: {musicXmlNew}', ' ']) + if result is None or isinstance(result, DialogError): + return + # noinspection PyTypeChecker + reload(environment) + environment.set('musicxmlPath', result) + musicXmlNew = environment.get('musicxmlPath') + self._writeToUser([f'MusicXML Reader set to: {musicXmlNew}', ' ']) + + resultPath = pathlib.Path(str(result)) + if reMuseScoreName.search(resultPath.name): + pngPath = resultPath + if resultPath.suffix == '.app': + pngPath = resultPath / 'Contents' / 'MacOS' / 'mscore' + else: + currentPNGPath = environment.get('musescoreDirectPNGPath') + if currentPNGPath is not None and pathlib.Path(currentPNGPath).exists(): + return + pngPath = findMuseScore() + if pngPath is None: + return + environment.set('musescoreDirectPNGPath', pngPath) + self._writeToUser([f'Notation images (PNG/PDF) made with: {pngPath}', ' ']) # ------------------------------------------------------------------------------ @@ -1537,9 +1559,8 @@ def testConfigurationAssistant(self): class Test(unittest.TestCase): def testYesOrNo(self): - from music21 import configure - d = configure.YesOrNo(default=True, tryAgain=False, - promptHeader='Are you ready to continue?') + d = YesOrNo(default=True, tryAgain=False, + promptHeader='Are you ready to continue?') d.askUser('n') self.assertEqual(str(d.getResult()), 'False') d.askUser('y') @@ -1549,8 +1570,8 @@ def testYesOrNo(self): d.askUser('blah') # gets default self.assertEqual(str(d.getResult()), '') - d = configure.YesOrNo(default=None, tryAgain=False, - promptHeader='Are you ready to continue?') + d = YesOrNo(default=None, tryAgain=False, + promptHeader='Are you ready to continue?') d.askUser('n') self.assertEqual(str(d.getResult()), 'False') d.askUser('y') @@ -1561,13 +1582,11 @@ def testYesOrNo(self): self.assertEqual(str(d.getResult()), '') def testSelectFromList(self): - from music21 import configure - d = configure.SelectFromList(default=1) + d = SelectFromList(default=1) self.assertEqual(d._default, 1) def testSelectMusicXMLReaders(self): - from music21 import configure - d = configure.SelectMusicXMLReader() + d = SelectMusicXMLReader() # force request to user by returning no valid results def getValidResults(force=None): @@ -1577,7 +1596,62 @@ def getValidResults(force=None): d.askUser(force='n', skipIntro=True) # reject option to open in a browser post = d.getResult() # returns a bad condition b/c there are no options and user entered 'n' - self.assertIsInstance(post, configure.BadConditions) + self.assertIsInstance(post, BadConditions) + + def _performActionSettings(self, reader, currentPNGPath, foundMuseScore): + ''' + Run SelectMusicXMLReader._performAction choosing `reader` without touching + the real user settings; return the {key: value} it would have set. + ''' + from unittest import mock + d = SelectMusicXMLReader() + d._result = reader + current = {'musicxmlPath': reader, 'musescoreDirectPNGPath': currentPNGPath} + newSettings = {} + with (mock.patch('music21.configure.reload'), + mock.patch('music21.configure.writeToUser'), + mock.patch('music21.environment.set', + side_effect=newSettings.__setitem__), + mock.patch('music21.environment.get', side_effect=current.__getitem__), + mock.patch('music21.converter.museScore.findMuseScore', + return_value=foundMuseScore, create=True)): + d._performAction() + return newSettings + + def testPerformActionFindsMuseScoreForOtherReader(self): + # picking Dorico still gives notebooks a MuseScore to render with + found = pathlib.Path('/Applications/MuseScore 4.app/Contents/MacOS/mscore') + newSettings = self._performActionSettings('/Applications/Dorico 5.app', + '/nowhere/mscore', found) + self.assertEqual(newSettings['musicxmlPath'], '/Applications/Dorico 5.app') + self.assertEqual(newSettings['musescoreDirectPNGPath'], found) + + def testPerformActionKeepsWorkingPNGPath(self): + # someone rendering with an older MuseScore that still exists keeps it + newSettings = self._performActionSettings('/Applications/Dorico 5.app', + sys.executable, + pathlib.Path('/somewhere/else')) + self.assertNotIn('musescoreDirectPNGPath', newSettings) + + def testPerformActionMuseScoreAppRunsProgramInside(self): + # a Mac .app is a folder; the program to run is inside it + newSettings = self._performActionSettings('/Applications/MuseScore 4.app', + None, None) + self.assertEqual( + pathlib.Path(newSettings['musescoreDirectPNGPath']), + pathlib.Path('/Applications/MuseScore 4.app/Contents/MacOS/mscore')) + + def testMuseScoreNameRe(self): + ''' + AI-assisted (Claude). + ''' + for name in ('MuseScore.app', 'MuseScore 4.app', 'MuseScore 4.5.app', + 'MuseScore3.exe', 'MuseScore4.exe', 'MuseScore Studio.app', + 'mscore', 'mscore3', 'mscore4portable'): + self.assertIsNotNone(reMuseScoreName.search(name), name) + for name in ('Finale 2011.app', 'Finale.exe', 'Sibelius.app', + 'Dorico 5.app', 'Finale Reader.app'): + self.assertIsNone(reMuseScoreName.search(name), name) def testRe(self): g = reFinaleApp.search('Finale 2011.app') diff --git a/music21/converter/__init__.py b/music21/converter/__init__.py index 5f1da363f4..cd720b3988 100644 --- a/music21/converter/__init__.py +++ b/music21/converter/__init__.py @@ -1384,6 +1384,10 @@ def parse(value: bundles.MetadataEntry|bytes|str|pathlib.Path|list|tuple, # a midi string, must come before os.path.exists test elif not isinstance(value, bytes) and valueStr.startswith('MThd'): return parseData(value, number=number, format=format, **keywords) + elif (isinstance(value, str) + and Converter().formatFromHeader(value.lstrip())[0] is not None): + # 'tinyNotation: ...' is data even if it contains a path + return parseData(value, number=number, format=format, **keywords) elif (not isinstance(value, bytes) and _osCanLoad(valueStr)): return parseFile(valueStr, number=number, format=format, @@ -2220,6 +2224,29 @@ def testConverterFromPath(self): with self.assertRaises(FileNotFoundError): parse('nonexistent_path_ending_in_correct_extension.musicxml') + def testDataNotReadAsPath(self): + ''' + Data whose '/..' segments cancel out to a real path must not read that file. + ''' + import tempfile + from unittest import mock + + with tempfile.TemporaryDirectory() as tempDir: + fp = pathlib.Path(tempDir, 'other.tntxt') + fp.write_text('3/4 f#2.', encoding='utf-8') + toRoot = '/..' * 30 + + s = parse('tinyNotation: ' + toRoot + str(fp)) + self.assertNotIn('F#', [n.name for n in s.recurse().notes]) + with self.assertRaises(FileNotFoundError): + parse('c4 ' + toRoot + str(fp), format='tinyNotation') + + s = parse(str(fp), storePickle=False) + self.assertEqual(s.recurse().notes.first().name, 'F#') + with mock.patch.dict(os.environ, {'HOME': tempDir, 'USERPROFILE': tempDir}): + s = parse('~/other.tntxt', storePickle=False) + self.assertEqual(s.recurse().notes.first().name, 'F#') + def testParseURL(self): ''' This should be the only test that requires an internet connection. diff --git a/music21/converter/museScore.py b/music21/converter/museScore.py index 679e235374..e13adbdec6 100644 --- a/music21/converter/museScore.py +++ b/music21/converter/museScore.py @@ -4,7 +4,7 @@ # # Authors: Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2013-23 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2013-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -14,6 +14,7 @@ from __future__ import annotations import os import pathlib +import re import typing as t import unittest @@ -25,7 +26,70 @@ environLocal = environment.Environment('converter.museScore') if t.TYPE_CHECKING: - pass + from collections.abc import Iterable + +reMuseScoreBefore3 = re.compile(r'MuseScore ?[12]\b', re.IGNORECASE) + + +def findMuseScore(searchDirs: Iterable[str|pathlib.Path]|None = None) -> pathlib.Path|None: + ''' + Return the MuseScore program (version 3 or newer) that music21 can run to make + images of notation, or None if there isn't one. Looks in the usual places for + this computer (Applications folders on Mac, Program Files on Windows, the PATH + elsewhere) unless given `searchDirs`. If several versions are installed, + returns the newest. + + * New in v11. + + AI-assisted (Claude). + ''' + platform = common.getPlatform() + if platform == 'darwin': + patterns: tuple[str, ...] = ('MuseScore*.app/Contents/MacOS/mscore',) + defaultDirs: list[str|pathlib.Path] = ['/Applications', + pathlib.Path.home() / 'Applications'] + elif platform == 'win': + patterns = ('MuseScore*/bin/MuseScore*.exe',) + defaultDirs = [os.environ[key] + for key in ('ProgramFiles', 'ProgramFiles(x86)', 'ProgramW6432') + if os.environ.get(key)] + else: + # names do not give versions here, so check in order: a bare "mscore" + # is MuseScore 2 on Debian but MuseScore 4 on Arch + patterns = ('mscore4portable', 'mscore3', 'musescore3', 'mscore', 'musescore') + defaultDirs = [d for d in os.environ.get('PATH', '').split(os.pathsep) if d] + + if searchDirs is None: + searchDirs = defaultDirs + searchPaths = [pathlib.Path(d) for d in searchDirs] + for pattern in patterns: + # keyed by the path inside its folder, e.g. "MuseScore 4.app/Contents/MacOS/mscore" + found = {str(p.relative_to(d)): p for d in searchPaths for p in d.glob(pattern)} + current = {name: p for name, p in found.items() + if not reMuseScoreBefore3.search(name)} + if current: + return current[max(current)] + return None + + +def getOrFindMuseScoreDirectPNGPath() -> pathlib.Path: + ''' + Return the MuseScore program set as `musescoreDirectPNGPath`. If that is + missing or points nowhere, find MuseScore and save its location; if it is + not installed, raise a SubConverterException that says where to get it. + + * New in v11. + ''' + museScorePath = environLocal['musescoreDirectPNGPath'] + if museScorePath is not None and museScorePath.exists(): + return museScorePath + found = findMuseScore() + if found is None: + raise SubConverterException( + 'To show notation, music21 needs MuseScore (free from https://musescore.org), ' + + 'even if you use other notation software. Install it and then try again.') + environment.set('musescoreDirectPNGPath', found) + return found def runThroughMuseScore( fp, @@ -45,15 +109,7 @@ def runThroughMuseScore( * leaveMargin: if trimEdges is True, then this number of pixels is left around the trimmed image. ''' - museScorePath = environLocal['musescoreDirectPNGPath'] - if not museScorePath: - raise SubConverterException( - 'To create PNG files directly from MusicXML you need to download MuseScore and ' - + 'put a link to it in your .music21rc via Environment.') - if not museScorePath.exists(): - raise SubConverterException( - "Cannot find a path to the 'mscore' file at " - + f'{museScorePath} -- download MuseScore') + museScorePath = getOrFindMuseScoreDirectPNGPath() if not subformats: subformatExtension = 'png' @@ -175,6 +231,66 @@ def findPNGRange( class Test(unittest.TestCase): + def _fakeInstall(self, root: pathlib.Path, *programs: str) -> None: + for program in programs: + (root / program).parent.mkdir(parents=True, exist_ok=True) + (root / program).touch() + + def testFindMuseScoreNewestOnMac(self): + import tempfile + from unittest import mock + with tempfile.TemporaryDirectory() as tmp: + apps = pathlib.Path(tmp) + self._fakeInstall(apps, + 'MuseScore 3.app/Contents/MacOS/mscore', + 'MuseScore 4.app/Contents/MacOS/mscore') + oldApps = apps / 'old' + self._fakeInstall(oldApps, + 'MuseScore 2.app/Contents/MacOS/mscore', + 'Dorico 5.app/Contents/MacOS/Dorico 5') + with mock.patch('music21.common.getPlatform', return_value='darwin'): + self.assertEqual(findMuseScore([apps]), + apps / 'MuseScore 4.app' / 'Contents' / 'MacOS' / 'mscore') + self.assertIsNone(findMuseScore([oldApps])) + + def testFindMuseScoreOnWindows(self): + import tempfile + from unittest import mock + with tempfile.TemporaryDirectory() as tmp: + programFiles = pathlib.Path(tmp) + self._fakeInstall(programFiles, 'MuseScore 4/bin/MuseScore4.exe') + with mock.patch('music21.common.getPlatform', return_value='win'): + self.assertEqual(findMuseScore([programFiles]), + programFiles / 'MuseScore 4' / 'bin' / 'MuseScore4.exe') + + def testFindMuseScoreOnLinux(self): + import tempfile + from unittest import mock + with tempfile.TemporaryDirectory() as tmp: + binDir = pathlib.Path(tmp) + # Debian's "mscore" is MuseScore 2; "mscore3" says what it is + self._fakeInstall(binDir, 'mscore', 'mscore3') + with mock.patch('music21.common.getPlatform', return_value='nix'): + self.assertEqual(findMuseScore([binDir]), binDir / 'mscore3') + + def testMuseScoreFindSavesResults(self): + from unittest import mock + found = pathlib.Path(__file__) # any file that exists + with (mock.patch('music21.converter.museScore.environLocal', + {'musescoreDirectPNGPath': pathlib.Path('/nowhere/mscore')}), + mock.patch('music21.converter.museScore.findMuseScore', return_value=found), + mock.patch('music21.environment.set') as setMock): + self.assertEqual(getOrFindMuseScoreDirectPNGPath(), found) + setMock.assert_called_once_with('musescoreDirectPNGPath', found) + + def testMuseScoreNotFoundGivesDownloadSite(self): + from unittest import mock + with (mock.patch('music21.converter.museScore.environLocal', + {'musescoreDirectPNGPath': None}), + mock.patch('music21.converter.museScore.findMuseScore', return_value=None)): + with self.assertRaisesRegex(SubConverterException, 'https://musescore.org'): + getOrFindMuseScoreDirectPNGPath() + def pngNumbering(self): ''' Testing findNumberedPNGPath() with files of lengths diff --git a/music21/converter/subConverters.py b/music21/converter/subConverters.py index fd17db0a13..0814983b81 100644 --- a/music21/converter/subConverters.py +++ b/music21/converter/subConverters.py @@ -61,7 +61,6 @@ class SubConverter: codecWrite = True or False (default False) if encodings need to be used to write stringEncoding = string (default 'utf-8'). If codecWrite is True, this specifies what encoding to use - ''' readBinary: bool = False canBePickled: bool = True @@ -100,7 +99,7 @@ def parseFile(self, loading the file and putting the data into parseData then there is no need to implement this method. Just set self.readBinary to True | False. ''' - if self.readBinary is False: + if not self.readBinary: import locale with open(filePath, encoding=locale.getpreferredencoding()) as f: dataStream = f.read() @@ -113,16 +112,17 @@ def parseFile(self, return self.stream - def _getStream(self): + @property + def stream(self): + ''' + Returns or sets the stream in the converter. Must be defined for subConverter to work. + ''' return self._stream - def _setStream(self, newStream): + @stream.setter + def stream(self, newStream): self._stream = newStream - stream = property(_getStream, _setStream, doc=''' - Returns or sets the stream in the converter. Must be defined for subConverter to work. - ''') - def checkShowAbility(self, **keywords) -> bool: ''' return bool on whether the *system* is @@ -289,7 +289,7 @@ def writeDataStream(self, if fp is None: fp = self.getTemporaryFile() - if self.readBinary is False: + if not self.readBinary: writeFlags = 'w' else: writeFlags = 'wb' @@ -330,7 +330,7 @@ def toData( and return the object (str or bytes) returned. ''' fp = self.write(obj, fmt=fmt, subformats=subformats, **keywords) - if self.readBinary is False: + if not self.readBinary: readFlags = 'r' else: readFlags = 'rb' @@ -1206,8 +1206,12 @@ class ConverterClercqTemperley(SubConverter): registerFormats = ('cttxt', 'har', 'clercqTemperley') registerInputExtensions = ('cttxt', 'har', 'clercqTemperley') - def parseData(self, strData: str|pathlib.Path, number=None): + def parseData(self, strData: str, number=None): from music21.romanText import clercqTemperley + # CTSong opens anything else as a file; if this test changes, update CTSong.parse too + if not (isinstance(strData, str) and '|' in strData and 'S:' in strData): + raise clercqTemperley.CTSongException( + 'Clercq-Temperley data must contain "|" and "S:"') ctSong = clercqTemperley.CTSong(strData) self.stream = ctSong.toPart() @@ -1215,7 +1219,9 @@ def parseFile(self, filePath: pathlib.Path|str, number=None, **keywords): - self.parseData(filePath) + from music21.romanText import clercqTemperley + ctSong = clercqTemperley.CTSong(filePath) + self.stream = ctSong.toPart() class ConverterCapella(SubConverter): @@ -1546,6 +1552,22 @@ def testWriteRomanText(self): s.write('romanText', text_stream) self.assertTrue(text_stream.getvalue().endswith(rntxt)) + def testClercqTemperleyDataNotOpenedAsFile(self): + import tempfile + from music21 import roman + from music21.romanText import clercqTemperley + + with tempfile.TemporaryDirectory() as tempDir: + fp = pathlib.Path(tempDir, 'song.cttxt') + fp.write_text('S: [C] I | IV | V | I |', encoding='utf-8') + with self.assertRaises(clercqTemperley.CTSongException): + ConverterClercqTemperley().parseData(str(fp)) + + ctConverter = ConverterClercqTemperley() + ctConverter.parseFile(fp) + figures = [rn.figure for rn in ctConverter.stream[roman.RomanNumeral]] + self.assertEqual(figures, ['I', 'IV', 'V', 'I']) + class TestExternal(unittest.TestCase): show = True diff --git a/music21/corpus/__init__.py b/music21/corpus/__init__.py index 7b4982374c..f369e711ff 100644 --- a/music21/corpus/__init__.py +++ b/music21/corpus/__init__.py @@ -109,7 +109,6 @@ def getCorePaths( >>> abcFilePaths = corpus.getCorePaths(fileExtensions=('abc',)) >>> len(abcFilePaths) >= 100 True - ''' return corpora.CoreCorpus().getPaths( fileExtensions=fileExtensions, @@ -267,7 +266,6 @@ def noCorpus(): >>> corpus.noCorpus() False - ''' return corpora.CoreCorpus().noCorpus diff --git a/music21/corpus/chorales.py b/music21/corpus/chorales.py index 16436b514d..fa709545fb 100644 --- a/music21/corpus/chorales.py +++ b/music21/corpus/chorales.py @@ -1394,12 +1394,25 @@ def _initializeNumberList(self): # ---Properties # - Numbering System - def _getNumberingSystem(self): + @property + def numberingSystem(self): + ''' + This property determines which numbering + system to iterate through chorales with. + It can be set to 'bwv', 'kalmus', 'baerenreiter', + 'budapest', or 'riemenschneider'. + It can also be set to 'title' in which case the + iterator needs to be given a list + of chorale titles in .titleList. At this time, + the titles need to be exactly as they + appear in the dictionary it queries. + ''' if self._numberingSystem is None: raise BachException('Numbering System not set.') return self._numberingSystem - def _setNumberingSystem(self, value): + @numberingSystem.setter + def numberingSystem(self, value): if value in ['bwv', 'kalmus', 'baerenreiter', 'budapest', 'riemenschneider']: self._numberingSystem = value # initializes the number list and sets current and highest numbers / indices @@ -1410,18 +1423,6 @@ def _setNumberingSystem(self, value): else: raise BachException(f'{value} is not a valid numbering system for Bach Chorales.') - numberingSystem = property(_getNumberingSystem, _setNumberingSystem, - doc=''' - This property determines which numbering - system to iterate through chorales with. - It can be set to 'bwv', 'kalmus', 'baerenreiter', - 'budapest', or 'riemenschneider'. - It can also be set to 'title' in which case the - iterator needs to be given a list - of chorale titles in .titleList. At this time, - the titles need to be exactly as they - appear in the dictionary it queries.''') - # - Title List @property diff --git a/music21/corpus/corpora.py b/music21/corpus/corpora.py index b394dc8b95..651d28485a 100644 --- a/music21/corpus/corpora.py +++ b/music21/corpus/corpora.py @@ -292,7 +292,6 @@ def getWorkList( >>> len(coreCorpus.getWorkList('verdi')) 1 - ''' if str(workName).startswith('schumann/'): # pragma: no cover # no default schumanns, but older examples showed this. @@ -396,7 +395,6 @@ def search( ... field='noteCount', ... ) - ''' return self.metadataBundle.search( query, @@ -660,7 +658,6 @@ def manualCoreCorpusPath(self): >>> #_DOCS_SHOW coreCorpus.manualCoreCorpusPath is None >>> True #_DOCS_HIDE True - ''' userSettings = environment.UserSettings() if 'manualCoreCorpusPath' in userSettings.keys(): @@ -967,7 +964,6 @@ def name(self): >>> corpus.corpora.LocalCorpus('funkCorpus').name 'funkCorpus' - ''' if self._name is None: return 'local' diff --git a/music21/corpus/manager.py b/music21/corpus/manager.py index f3fd0bba4c..5632bd3255 100644 --- a/music21/corpus/manager.py +++ b/music21/corpus/manager.py @@ -296,7 +296,6 @@ def search( If ``corpusNames`` is None, all corpora known to music21 will be searched. See usersGuide (chapter 11) for more information on searching - ''' # >>> corpus.search('coltrane', corpusNames=('virtual',)) # diff --git a/music21/corpus/monteverdi/madrigal.5.1.rntxt b/music21/corpus/monteverdi/madrigal.5.1.rntxt index 59e91bd80d..27751bed88 100644 --- a/music21/corpus/monteverdi/madrigal.5.1.rntxt +++ b/music21/corpus/monteverdi/madrigal.5.1.rntxt @@ -35,7 +35,7 @@ m24 G: I6/4 b3 V m25 I || m26 IV d: VII b4 i m27 V b3 I || -m28 VIII b3 v6 b4 iv6 +m28 VII b3 v6 b4 iv6 m29 V b3 i || m30 b2 IV6 b3 VII a: III m31 b2 iv b3 V diff --git a/music21/corpus/virtual.py b/music21/corpus/virtual.py index 599448bd45..747264f739 100644 --- a/music21/corpus/virtual.py +++ b/music21/corpus/virtual.py @@ -77,7 +77,6 @@ def getUrlByExt(self, extList=None): class BachBWV1007Prelude(VirtualWork): ''' - >>> a = corpus.virtual.BachBWV1007Prelude() >>> a.getUrlByExt('.xml') ['https://kern.ccarh.org/cgi-bin/ksdata?l=cc/bach/cello&file=bwv1007-01.krn&f=xml'] diff --git a/music21/defaults.py b/music21/defaults.py index 89c09a0557..ada4f6b60f 100644 --- a/music21/defaults.py +++ b/music21/defaults.py @@ -13,12 +13,10 @@ ''' from __future__ import annotations -import unittest -import typing as t from music21 import _version + # note: this module should not import any higher level modules -type StepName = t.Literal['C', 'D', 'E', 'F', 'G', 'A', 'B'] # restating so as not to import. # TODO: defaults should check the environment object to see @@ -40,8 +38,8 @@ # not allowing more can be construed as a feature. -pitchStep: StepName = 'C' -pitchOctave = 4 +# we used to have a defaultStep ('C') and defaultOctave (4) but +# no one ever changed them, and inlining makes so much faster. partGroup = 'Part Group' partGroupAbbreviation = 'PG' @@ -103,6 +101,8 @@ multiMeasureRestUseSymbols = True multiMeasureRestMaxSymbols = 11 +maxMeasures = 100_000 + # id numbers above this number will be considered memory locations # and rewritten on thaw, etc. minIdNumberToConsiderMemoryLocation = 100_000_001 @@ -114,15 +114,6 @@ # ----------------------------------------------------------------||||||||||||-- -class Test(unittest.TestCase): - ''' - Unit tests - ''' - def testTest(self): - self.assertEqual(1, 1) - - -# ----------------------------------------------------------------||||||||||||-- if __name__ == '__main__': import music21 - music21.mainTest(Test) + music21.mainTest() diff --git a/music21/duration.py b/music21/duration.py index d3fde30ac8..f9b81aa4af 100644 --- a/music21/duration.py +++ b/music21/duration.py @@ -5,7 +5,7 @@ # Authors: Michael Scott Asato Cuthbert # Christopher Ariza # -# Copyright: Copyright © 2008-2024 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2008-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -52,7 +52,7 @@ import fractions from functools import lru_cache import io -from math import inf, isnan +from math import inf, isnan, isfinite import typing as t import unittest @@ -331,7 +331,6 @@ def dottedMatch(qLen: OffsetQLIn, >>> duration.dottedMatch(0.00001, 2) (False, False) - ''' for dots in range(maxDots + 1): # assume qLen has n dots, so find its non-dotted length @@ -1173,7 +1172,7 @@ def _reprInternal(self): return base_rep def _checkFrozen(self): - if self.frozen is True: + if self.frozen: raise TupletException( 'A frozen tuplet (or one attached to a duration) has immutable length.') @@ -1268,7 +1267,6 @@ def setDurationType( >>> a.setDurationType(4.0) >>> a.totalTupletLength() 8.0 - ''' self._checkFrozen() if not isinstance(durType, str): @@ -1604,7 +1602,7 @@ class Duration(prebase.ProtoM21Object, SlottedObjectMixin): expression. For instance the duration of 0.5 is generally an eighth note, but in the middle of a triplet group might be better written as a dotted-eighth triplet. If expressionIsInferred - is True then `music21` can change it according to complex. If + is True then `music21` can change it according to context. If False, then the type, dots, and tuplets are considered immutable. >>> d = duration.Duration(0.5) @@ -1724,21 +1722,19 @@ def __eq__(self, other): >>> tupDur1 == tupDur2 True - Grace durations are conceptually distinct, so they never equal an ordinary - Duration: + A grace duration never equals an ordinary Duration: >>> graceDur1 = tupDur1.getGraceDuration() >>> graceDur1 == tupDur1 False - But two grace durations of the same value are equal: + Two grace durations of the same value are equal: >>> graceDur2 = tupDur2.getGraceDuration() >>> graceDur1 == graceDur2 True - A :class:`FrozenDuration` differs from a Duration only in being immutable, - so it compares equal to an otherwise-identical Duration: + A :class:`FrozenDuration` equals a Duration of the same value: >>> duration.Duration(2.0) == duration.FrozenDuration(quarterLength=2.0) True @@ -1749,16 +1745,49 @@ def __eq__(self, other): >>> tupDur1 == tupDur2 False - * Changed in v11: equality is by musical value, not exact class, so a - FrozenDuration equals the Duration it represents. + If both durations have :attr:`expressionIsInferred` set to True (such as + if they were parsed from MIDI), then their expression (type, dots, + tuplets, and components) can be freely changed according to context, so + only the `quarterLength` needs to match. Here a plain eighth note is + equal to a dotted eighth under a 3:2 triplet, since both span half a + quarter length and both are free to be re-expressed: + + >>> plainDur = duration.Duration(0.5) + >>> plainDur.type + 'eighth' + >>> altDur = duration.Duration(type='eighth', dots=1) + >>> altDur.appendTuplet(duration.Tuplet(3, 2)) + >>> altDur.quarterLength + 0.5 + >>> altDur.expressionIsInferred = True + >>> plainDur == altDur + True + + But if either duration's expression is *not* inferred, then type, dots, + tuplets, and components must all match as before: + + >>> altDur.expressionIsInferred = False + >>> plainDur == altDur + False + + * Changed in v11: if both durations have `expressionIsInferred` True, + only `quarterLength` must match. + * Changed in v11: a FrozenDuration equals a Duration of the same value. ''' if not isinstance(other, Duration): return False - # A grace duration is conceptually a different thing from an ordinary - # duration, even at the same length; a FrozenDuration is not. if self.isGrace != other.isGrace: return False + if self.linked != other.linked: + return False + + # When both expressions are inferred from quarterLength, callers are + # free to re-express the type, dots, tuplets, and components, so only + # the quarterLength is meaningful for equality. + if self.expressionIsInferred and other.expressionIsInferred: + return self.quarterLength == other.quarterLength + if self.isComplex != other.isComplex: return False if len(self.components) != len(other.components): @@ -1775,13 +1804,11 @@ def __eq__(self, other): return False if self.quarterLength != other.quarterLength: return False - if self.linked != other.linked: - return False return True def _reprInternal(self): - if self.linked is True: + if self.linked: return str(self.quarterLength) else: return f'unlinked type:{self.type} quarterLength:{self.quarterLength}' @@ -1825,7 +1852,8 @@ def _updateComponents(self): self._componentsNeedUpdating = False # PUBLIC METHODS # - def _getLinked(self) -> bool: + @property + def linked(self) -> bool: ''' Gets or sets the `.linked` property -- if linked (default) then type, dots, tuplets are always coherent with quarterLength. If not, then they are separate. @@ -1849,21 +1877,20 @@ def _getLinked(self) -> bool: ''' return self._linked - def _setLinked(self, value: bool): + @linked.setter + def linked(self, value: bool): if value not in (True, False): raise TypeError(f'Linked can only be True or False, not {value}') if self._quarterLengthNeedsUpdating: self._updateQuarterLength() - if value is False and self._linked is True: + if value is False and self._linked: self._unlinkedType = self.type - elif value is True and self._linked is False: + elif value is True and not self._linked: self._quarterLengthNeedsUpdating = True self._componentsNeedUpdating = True self._linked = value - linked = property(_getLinked, _setLinked) - def addDurationTuple(self, dur: DurationTuple|Duration|str|OffsetQLIn, *, @@ -2274,7 +2301,7 @@ def getGraceDuration( # create grace duration gd: GraceDuration - if appoggiatura is True: + if appoggiatura: gd = AppoggiaturaDuration() else: gd = GraceDuration() @@ -2305,7 +2332,7 @@ def informClient(self) -> bool: Returns False if there was no need to inform the client (like nothing has changed) or if `.client` is None. Otherwise returns True. ''' - if self._quarterLengthNeedsUpdating is True: + if self._quarterLengthNeedsUpdating: old_qtrLength = self._qtrLength self._updateQuarterLength() if self._qtrLength == old_qtrLength: @@ -2495,7 +2522,7 @@ def _updateQuarterLength(self): * Changed in v7: made private and faster. ''' - if self.linked is False: + if not self.linked: return if self._dotGroups == (0,) and not self.tuplets and len(self.components) == 1: @@ -2696,7 +2723,7 @@ def dots(self, value: int): if not common.isNum(value): raise TypeError('only numeric dot values can be used with this method.') - # easter egg + # Easter egg if value == inf: self.type = nextLargerType(self.type) self.dots = 0 @@ -2707,7 +2734,7 @@ def dots(self, value: int): components[i] = durationTupleFromTypeDots(dt.type, value) self._components = tuple(components) self._quarterLengthNeedsUpdating = True - if self.linked is True: + if self.linked: self.expressionIsInferred = False self.informClient() @@ -2899,28 +2926,9 @@ def quarterLengthNoTuplets(self) -> float: tot += c.quarterLength return tot - def _getQuarterLength(self) -> OffsetQL: - if self._quarterLengthNeedsUpdating: - self._updateQuarterLength() - return self._qtrLength - - def _setQuarterLength(self, value: OffsetQLIn): - if self.linked is False: - self._qtrLength = value - elif (self._qtrLength != value - or self._componentsNeedUpdating # skip a type update for next type check - or self.type == 'inexpressible'): - value = opFrac(value) - if value == 0.0 and self.linked is True: - self.clear() - self._qtrLength = value - self.expressionIsInferred = True - self._componentsNeedUpdating = True - self._quarterLengthNeedsUpdating = False - - self.informClient() - - quarterLength = property(_getQuarterLength, _setQuarterLength, doc=''' + @property + def quarterLength(self) -> OffsetQL: + ''' Returns the quarter note length or Sets the quarter note length to the specified value. May be expressed as a float or Fraction. @@ -2969,7 +2977,32 @@ def _setQuarterLength(self, value: OffsetQLIn): >>> d.quarterLength = 1/3 >>> d.quarterLength Fraction(1, 3) - ''') + ''' + if self._quarterLengthNeedsUpdating: + self._updateQuarterLength() + return self._qtrLength + + @quarterLength.setter + def quarterLength(self, value: OffsetQLIn): + if not self.linked: + # linked durations get this check for free from opFrac below. + if not isfinite(value): + raise ValueError( + f'quarterLength must be a finite number, not {value!r}' + ) + self._qtrLength = value + elif (self._qtrLength != value + or self._componentsNeedUpdating # skip a type update for next type check + or self.type == 'inexpressible'): + value = opFrac(value) + if value == 0.0 and self.linked: + self.clear() + self._qtrLength = value + self.expressionIsInferred = True + self._componentsNeedUpdating = True + self._quarterLengthNeedsUpdating = False + + self.informClient() @property def tuplets(self) -> tuple[Tuplet, ...]: @@ -2984,6 +3017,8 @@ def tuplets(self) -> tuple[Tuplet, ...]: @tuplets.setter def tuplets(self, tupletTuple: Iterable[Tuplet]): # environLocal.printDebug(['assigning tuplets in Duration', tupletTuple]) + if self._componentsNeedUpdating: + self._updateComponents() self._tuplets = tuple(tupletTuple) self._quarterLengthNeedsUpdating = True self.informClient() @@ -3039,7 +3074,7 @@ def type(self) -> str: >>> a.quarterLength 0.25 ''' - if self.linked is False: + if not self.linked: if self._unlinkedType is None: # pragma: no cover raise DurationException( 'linked property setter failed to set _unlinkedType, please open a bug report' @@ -3058,7 +3093,7 @@ def type(self, value: str): if value not in ordinalTypeFromNum and value not in ('inexpressible', 'complex'): raise ValueError(f'no such type exists: {value}') - if self.linked is True: + if self.linked: nt = durationTupleFromTypeDots(value, self.dots) self.components = (nt,) self._quarterLengthNeedsUpdating = True @@ -3094,7 +3129,7 @@ class FrozenDuration(common.objects.FrozenObject, Duration): >>> copy.deepcopy(fd) is fd True ''' - __slots__ = () + __slots__ = ('_frozenHash',) def __init__(self, *arguments, **keywords): super().__init__(*arguments, **keywords) @@ -3109,14 +3144,13 @@ def __deepcopy__(self, memo=None): def __copy__(self): ''' - Immutable objects return themselves + A FrozenDuration is immutable, so copy returns it. ''' return self def unfreeze(self) -> Duration: ''' - Return an independent, mutable :class:`Duration` with the same value -- - faithfully preserving components, tuplets, etc. + Return a new :class:`Duration` with the same value, components and tuplets. >>> fd = duration.FrozenDuration(type='half', dots=1) >>> d = fd.unfreeze() @@ -3125,7 +3159,7 @@ def unfreeze(self) -> Duration: >>> type(d) is duration.Duration True - The copy is mutable and fully independent of the original: + It can be changed without changing the original: >>> d.dots = 0 >>> d.quarterLength @@ -3133,7 +3167,7 @@ def unfreeze(self) -> Duration: >>> fd.dots 1 - Tuplets and unlinked status survive the round trip: + Tuplets are copied: >>> fd2 = duration.FrozenDuration(2/3) >>> d2 = fd2.unfreeze() @@ -3146,11 +3180,7 @@ def unfreeze(self) -> Duration: AI-assisted (Claude). ''' - # Copy the frozen state straight onto a bare mutable Duration. __new__ - # skips __init__ (which would just set defaults __setstate__ overwrites); - # __setstate__ then restores every slot. Components/quarterLength/link - # status are immutable and shared; only tuplets are mutable, so deep-copy - # those (when any) -- meter durations have none, so it is skipped entirely. + # only the tuplets can be changed in place, so only they are copied new = Duration.__new__(Duration) new.__setstate__(self.__getstate__()) if self._tuplets: @@ -3160,8 +3190,7 @@ def unfreeze(self) -> Duration: def augmentOrDiminish(self, amountToScale: OffsetQLIn, retainComponents=False) -> Duration: ''' - Like :meth:`Duration.augmentOrDiminish`, but always returns a new, mutable - Duration. + Like :meth:`Duration.augmentOrDiminish`, but returns a Duration. >>> fd = duration.FrozenDuration(quarterLength=1.0) >>> d = fd.augmentOrDiminish(0.5) @@ -3175,8 +3204,8 @@ def augmentOrDiminish(self, amountToScale: OffsetQLIn, retainComponents=False) - def splitDotGroups(self, *, inPlace=False) -> Duration: ''' - Like :meth:`Duration.splitDotGroups`, but `inPlace` must be False since a - FrozenDuration cannot be mutated. + Like :meth:`Duration.splitDotGroups`, but returns a Duration; + `inPlace` must be False. >>> fd = duration.FrozenDuration(quarterLength=1.0) >>> fd.splitDotGroups() @@ -3188,7 +3217,6 @@ def splitDotGroups(self, *, inPlace=False) -> Duration: ''' if inPlace: raise TypeError(f'This {type(self).__name__} instance is immutable.') - # unfreeze() already made the copy, so split it in place -- no second copy. thawed = self.unfreeze() thawed.splitDotGroups(inPlace=True) return thawed @@ -3417,12 +3445,12 @@ def findTupletGroups(self, incorporateGroupings: bool = False) -> list[list[note tupletActive = False for n in self.streamIn.notesAndRests: if not n.duration.tuplets: # most common case first - if tupletActive is True: + if tupletActive: self.allTupletGroups.append(currentTupletGroup) currentTupletGroup = [] tupletActive = False continue - if tupletActive is False: + if not tupletActive: tupletActive = True currentTupletGroup.append(n) if incorporateGroupings and n.duration.tuplets[0].type == 'stop': @@ -3517,6 +3545,7 @@ def fixBrokenTupletDuration(self, tupletGroup: list[note.GeneralNote]) -> None: There is a side format of humdrum that the Josquin Research Project uses for long notes like the 3%2. + >>> saved_JRP_flavor = humdrum.spineParser.flavors['JRP'] #_DOCS_HIDE >>> humdrum.spineParser.flavors['JRP'] = True Since Humdrum parsing is going to apply TupletFixer, we will temporarily @@ -3540,6 +3569,7 @@ def fixBrokenTupletDuration(self, tupletGroup: list[note.GeneralNote]) -> None: {10.6667 - 12.0} >>> duration.TupletFixer.fixBrokenTupletDuration = saved_fixed_broken + >>> humdrum.spineParser.flavors['JRP'] = saved_JRP_flavor #_DOCS_HIDE >>> tf = duration.TupletFixer(m1) >>> tupletGroups = tf.findTupletGroups(incorporateGroupings=True) >>> tupletGroups @@ -3582,7 +3612,7 @@ def fixBrokenTupletDuration(self, tupletGroup: list[note.GeneralNote]) -> None: return firstTup = tupletGroup[0].duration.tuplets[0] totalTupletDuration = opFrac(firstTup.totalTupletLength()) - currentTupletDuration = 0.0 + currentTupletDuration: OffsetQL = 0.0 smallestTupletTypeOrdinal: int = SMALL_SENTINEL largestTupletTypeOrdinal: int = LARGE_SENTINEL @@ -3782,6 +3812,23 @@ def testFrozenDurationEquality(self): self.assertNotEqual(grace, d) self.assertNotEqual(d, grace) + def testAugmentOrDiminishUnbuiltTuplet(self): + ''' + A duration made from a quarter length has not yet worked out its + tuplet; scaling it must not bring the old tuplet back. + ''' + d = Duration(fractions.Fraction(1, 24)) + scaled = d.augmentOrDiminish(1.5) + self.assertEqual(scaled.tuplets, ()) + self.assertEqual(scaled.quarterLength, 0.0625) + self.assertEqual(scaled.type, '64th') + + d = Duration(fractions.Fraction(1, 24)) + self.assertEqual(len(d.tuplets), 1) + scaled = d.augmentOrDiminish(1.5) + self.assertEqual(scaled.tuplets, ()) + self.assertEqual(scaled.quarterLength, 0.0625) + def testTuple(self): # create a tuplet with 5 dotted eighths in the place of 3 double-dotted # eighths @@ -4001,16 +4048,14 @@ def testAugmentOrDiminish(self): "DurationTuple(type='16th', dots=0, quarterLength=0.25)") def testUnlinkedTypeA(self): - from music21 import duration - - du = duration.Duration() + du = Duration() du.linked = False du.quarterLength = 5.0 du.type = 'quarter' self.assertEqual(du.quarterLength, 5.0) self.assertEqual(du.type, 'quarter') - d = duration.Duration() + d = Duration() self.assertTrue(d.linked) # note set d.linked = False d.type = 'quarter' @@ -4156,6 +4201,52 @@ def testExpressionIsInferred(self): # attribute assignments that could occur in any order self.assertEqual(d.expressionIsInferred, False) + def testEqualityExpressionIsInferred(self): + ''' + When both durations infer their expression from quarterLength, only + quarterLength must match for equality (GH #1970). + ''' + # A plain quarter note and a half note in a 2:1 tuplet both span one + # quarter length but differ in type and tuplets. + plainDur = Duration(1.0) + altDur = Duration('half') + altDur.appendTuplet(Tuplet(2, 1)) + self.assertEqual(altDur.quarterLength, 1.0) + self.assertNotEqual(plainDur.type, altDur.type) + self.assertNotEqual(plainDur.tuplets, altDur.tuplets) + + # altDur's expression is not inferred (built from a type), so strict + # comparison applies and they are not equal. + self.assertFalse(altDur.expressionIsInferred) + self.assertNotEqual(plainDur, altDur) + + # Marking altDur's expression as inferred relaxes the comparison to + # quarterLength only. + altDur.expressionIsInferred = True + self.assertEqual(plainDur, altDur) + self.assertEqual(altDur, plainDur) # symmetric + + # Differing quarterLength is still unequal even when both are inferred. + altDur2 = Duration(2.0) + self.assertTrue(altDur2.expressionIsInferred) + self.assertNotEqual(plainDur, altDur2) + + # Link status is still checked before the inference shortcut: an + # unlinked duration with the same quarterLength is never equal. + unlinkedDur = Duration(1.0) + unlinkedDur.linked = False + self.assertEqual(unlinkedDur.quarterLength, 1.0) + self.assertNotEqual(plainDur, unlinkedDur) + + # Inference must be mutual: one inferred, one not -> strict. + eighthDur = Duration(0.5) + typedQuarter = Duration(type='quarter') + self.assertFalse(typedQuarter.expressionIsInferred) + # Same type/dots/QL, so equal even without inference relaxation. + self.assertEqual(plainDur, typedQuarter) + # But an inferred eighth is not equal to a typed quarter. + self.assertNotEqual(eighthDur, typedQuarter) + def testExceptions(self): ''' These errors are user errors, so they raise generic exceptions diff --git a/music21/dynamics.py b/music21/dynamics.py index 8261a3ba75..b4bf812ea4 100644 --- a/music21/dynamics.py +++ b/music21/dynamics.py @@ -27,27 +27,27 @@ shortNames = ['pppppp', 'ppppp', 'pppp', 'ppp', 'pp', 'p', 'mp', - 'mf', 'f', 'fp', 'sf', 'ff', 'fff', 'ffff', 'fffff', 'ffffff'] + 'mf', 'f', 'fp', 'sf', 'ff', 'fff', 'ffff', 'fffff', 'ffffff'] longNames = {'ppp': 'pianississimo', - 'pp': 'pianissimo', - 'p': 'piano', - 'mp': 'mezzopiano', - 'mf': 'mezzoforte', - 'f': 'forte', - 'fp': 'fortepiano', - 'sf': 'sforzando', - 'ff': 'fortissimo', - 'fff': 'fortississimo'} + 'pp': 'pianissimo', + 'p': 'piano', + 'mp': 'mezzopiano', + 'mf': 'mezzoforte', + 'f': 'forte', + 'fp': 'fortepiano', + 'sf': 'sforzando', + 'ff': 'fortissimo', + 'fff': 'fortississimo'} # could be really useful for automatic description of musical events englishNames = {'ppp': 'extremely soft', - 'pp': 'very soft', - 'p': 'soft', - 'mp': 'moderately soft', - 'mf': 'moderately loud', - 'f': 'loud', - 'ff': 'very loud', - 'fff': 'extremely loud'} + 'pp': 'very soft', + 'p': 'soft', + 'mp': 'moderately soft', + 'mf': 'moderately loud', + 'f': 'loud', + 'ff': 'very loud', + 'fff': 'extremely loud'} def dynamicStrFromDecimal(n): @@ -172,7 +172,6 @@ class Dynamic(base.Music21Object): .. image:: images/dynamics_simple.* :width: 344 - ''' classSortOrder = 10 _styleClass = style.TextStyle @@ -237,23 +236,9 @@ def __init__(self, value=None, **keywords): def _reprInternal(self): return str(self.value) - def _getValue(self): - return self._value - - def _setValue(self, value): - self._value = value - if self._value in longNames: - self.longName = longNames[self._value] - else: - self.longName = None - - if self._value in englishNames: - self.englishName = englishNames[self._value] - else: - self.englishName = None - - value = property(_getValue, _setValue, - doc=''' + @property + def value(self): + ''' Get or set the value of this dynamic, which sets the long and English names of this Dynamic. The value is a string specification. @@ -272,35 +257,25 @@ def _setValue(self, value): 'loud' >>> p.longName 'forte' - ''') + ''' + return self._value - def _getVolumeScalar(self): - if self._volumeScalar is not None: - return self._volumeScalar - # use default - elif self._value in dynamicStrToScalar: - return dynamicStrToScalar[self._value] + @value.setter + def value(self, value): + self._value = value + if self._value in longNames: + self.longName = longNames[self._value] else: - thisDynamic = self._value - # ignore leading s like in sf - if 's' in thisDynamic: - thisDynamic = thisDynamic[1:] - # ignore closing z like in fz - if thisDynamic[-1] == 'z': - thisDynamic = thisDynamic[:-1] - if thisDynamic in dynamicStrToScalar: - return dynamicStrToScalar[thisDynamic] - else: - return dynamicStrToScalar[None] + self.longName = None - def _setVolumeScalar(self, value): - # we can manually set this to be anything, overriding defaults - if common.isNum(value) and 0 <= value <= 1: - self._volumeScalar = value + if self._value in englishNames: + self.englishName = englishNames[self._value] else: - raise DynamicException(f'cannot set as volume scalar to: {value}') + self.englishName = None - volumeScalar = property(_getVolumeScalar, _setVolumeScalar, doc=r''' + @property + def volumeScalar(self): + r''' Get or set the volume scalar for this dynamic. If not explicitly set, a default volume scalar will be provided. Any number between 0 and 1 can be used to set the volume scalar, overriding the expected behavior. @@ -308,7 +283,6 @@ def _setVolumeScalar(self, value): As mezzo is at 0.5, the unit interval range is doubled for generating final output. The default output is 0.5. - >>> d = dynamics.Dynamic('mf') >>> d.volumeScalar 0.55... @@ -319,7 +293,6 @@ def _setVolumeScalar(self, value): >>> d.value 'mf' - int(volumeScalar \* 127) gives the MusicXML tag >>> xmlOut = musicxml.m21ToXml.GeneralObjectExporter().parse(d).decode('utf-8') @@ -333,7 +306,32 @@ def _setVolumeScalar(self, value): ... - ''') + ''' + if self._volumeScalar is not None: + return self._volumeScalar + # use default + elif self._value in dynamicStrToScalar: + return dynamicStrToScalar[self._value] + else: + thisDynamic = self._value + # ignore leading s like in sf + if 's' in thisDynamic: + thisDynamic = thisDynamic[1:] + # ignore closing z like in fz + if thisDynamic[-1] == 'z': + thisDynamic = thisDynamic[:-1] + if thisDynamic in dynamicStrToScalar: + return dynamicStrToScalar[thisDynamic] + else: + return dynamicStrToScalar[None] + + @volumeScalar.setter + def volumeScalar(self, value): + # we can manually set this to be anything, overriding defaults + if common.isNum(value) and 0 <= value <= 1: + self._volumeScalar = value + else: + raise DynamicException(f'cannot set as volume scalar to: {value}') # ------------------------------------------------------------------------------ @@ -431,13 +429,12 @@ def testBasic(self): def testCorpusDynamicsWedge(self): from music21 import corpus - from music21 import dynamics a = corpus.parse('opus41no1/movement2') # has dynamics! - b = a.parts[0].flatten().getElementsByClass(dynamics.Dynamic) + b = a.parts[0].flatten().getElementsByClass(Dynamic) self.assertEqual(len(b), 35) - b = a.parts[0].flatten().getElementsByClass(dynamics.DynamicWedge) + b = a.parts[0].flatten().getElementsByClass(DynamicWedge) self.assertEqual(len(b), 2) def testMusicxmlOutput(self): diff --git a/music21/editorial.py b/music21/editorial.py index 8ece0a43ce..7dde23f389 100644 --- a/music21/editorial.py +++ b/music21/editorial.py @@ -72,7 +72,6 @@ class Editorial(prebase.ProtoM21Object, dict): .. image:: images/noteEditorialFictaSharp.* :width: 103 - ''' _DOC_ATTR: dict[str, str] = { 'comments': ''' diff --git a/music21/environment.py b/music21/environment.py index 0073ea8e48..74e073cd2a 100644 --- a/music21/environment.py +++ b/music21/environment.py @@ -25,6 +25,7 @@ import io import os import pathlib +import stat import sys import tempfile import unittest @@ -33,7 +34,6 @@ from typing import overload import xml.etree.ElementTree as ET -from xml.sax import saxutils from music21 import exceptions21 from music21 import common @@ -173,12 +173,12 @@ def __init__(self, forcePlatform=None): ]: self._keysToPaths.append(pathKey) + self.defaultRootTempDir = None # defines all valid keys in ref self._loadDefaults(forcePlatform=forcePlatform) # read will only overwrite values if set in field if forcePlatform is None: # only read if not forcing platform self.read() # load a stored file if available - self.defaultRootTempDir = None # SPECIAL METHODS # @@ -234,11 +234,6 @@ def __repr__(self): return '' def __setitem__(self, key, value): - # saxutils.escape # used for escaping strings going to xml - # with unicode encoding - # http://www.xml.com/pub/a/2002/11/13/py-xml.html?page=2 - # saxutils.escape(msg).encode('UTF-8') - # add local corpus path as a key # if isinstance(value, bytes): # value = value.decode(errors='replace') @@ -275,9 +270,8 @@ def __setitem__(self, key, value): raise EnvironmentException( f'{value} is not an acceptable value for preference: {key}') - # need to escape problematic characters for xml storage - if isinstance(value, str): - value = saxutils.escape(value) # .encode('UTF-8') + # ElementTree handles XML escaping when serializing in toSettingsXML. + # set value if key == 'localCorpusPath': # only add if unique @@ -397,10 +391,10 @@ def _loadDefaults(self, forcePlatform=None): for name, value in [ ('lilypondPath', 'lilypond'), ('musicxmlPath', - common.cleanpath(r'%PROGRAMFILES%\MuseScore 3\bin\MuseScore3.exe') + common.cleanpath(r'%PROGRAMFILES%\MuseScore 4\bin\MuseScore4.exe') ), ('musescoreDirectPNGPath', - common.cleanpath(r'%PROGRAMFILES%\MuseScore 3\bin\MuseScore3.exe') + common.cleanpath(r'%PROGRAMFILES%\MuseScore 4\bin\MuseScore4.exe') ), ]: self[name] = value # use for key checking @@ -424,13 +418,13 @@ def _loadDefaults(self, forcePlatform=None): ('lilypondPath', '/Applications/Lilypond.app/Contents/Resources/bin/lilypond'), ('musicxmlPath', - '/Applications/MuseScore 3.app/Contents/MacOS/mscore'), + '/Applications/MuseScore 4.app/Contents/MacOS/mscore'), ('graphicsPath', previewLocation), ('vectorPath', previewLocation), ('pdfPath', previewLocation), ('midiPath', '/Applications/GarageBand.app'), ('musescoreDirectPNGPath', - '/Applications/MuseScore 3.app/Contents/MacOS/mscore'), + '/Applications/MuseScore 4.app/Contents/MacOS/mscore'), ]: self[name] = value # use for key checking @@ -446,6 +440,22 @@ def _checkAccessibility(self, path: str|pathlib.Path|None) -> bool: else: return False + def _isSafeTempDir(self, path: pathlib.Path) -> bool: + ''' + Return True if `path` is accessible and no other user can change it. + + On Unix it must be a real directory (not a link) that this user owns and + that no one else can write to. + ''' + if not self._checkAccessibility(path): + return False + if sys.platform != 'win32': + pathStat = os.lstat(path) + return (stat.S_ISDIR(pathStat.st_mode) + and pathStat.st_uid == os.getuid() + and not pathStat.st_mode & 0o022) + return True + def toSettingsXML(self, ref=None): ''' Convert a ref dictionary to an xml.etree.ElementTree.Element object @@ -519,48 +529,36 @@ def getDefaultRootTempDir(self): >>> e.getDefaultRootTempDir() == pathlib.Path(tempFile) / 'music21' True - If failed to create the subdirectory (OSError is raised), this function - will return (1), for Linux and Mac platforms, a subdirectory under the - system temp directory which is named with 'music21-userid-$UID' or (2), - for other platforms, a temporary directory which is created by - tempfile.mkdtemp(prefix="music21-"), which is named with 'music21-' - plus 8 hashed codes, and (3) if failing for both above, it will return - the directory from tempfile.gettempdir(). The location of this directory - depends on OS. + If that subdirectory cannot be used, this function returns (1), for + Linux and Mac platforms, a subdirectory under the system temp directory + named 'music21-userid-$UID', or (2) a new directory created by + tempfile.mkdtemp(prefix="music21-"). On Linux and Mac, a directory is + used only if this user owns it and no one else can write to it, since + the system temp directory may be shared with other users. Note the temporary directory may be different in each Python session. + + * Changed in v11: directories other users could change are not used. ''' if self._checkAccessibility(self.defaultRootTempDir): return self.defaultRootTempDir - # this returns the root temp dir; this does not create a new dir - self.defaultRootTempDir = pathlib.Path(tempfile.gettempdir()) / 'music21' + systemTempDir = pathlib.Path(tempfile.gettempdir()) + candidates = [systemTempDir / 'music21'] + if sys.platform != 'win32': + candidates.append(systemTempDir / f'music21-userid-{os.getuid()}') - # if this path already exists, readable and writable, we have nothing more to do - if self._checkAccessibility(self.defaultRootTempDir): - return self.defaultRootTempDir - else: - # make this directory as a temp directory + for candidate in candidates: try: - self.defaultRootTempDir.mkdir() + candidate.mkdir(mode=0o700) except OSError: # directory already exists or permission denied - if common.getPlatform() in ['nix', 'darwin']: - uid = os.getuid() - dir_path = pathlib.Path(tempfile.gettempdir()) / f'music21-userid-{uid}' - else: - dir_path = pathlib.Path(tempfile.mkdtemp(prefix='music21-')) - - if self._checkAccessibility(dir_path): - self.defaultRootTempDir = dir_path - else: - try: - dir_path.mkdir() - self.defaultRootTempDir = dir_path - except OSError: # pragma: no cover - # Give up and use /tmp - self.defaultRootTempDir = pathlib.Path(tempfile.gettempdir()) + pass + if self._isSafeTempDir(candidate): + self.defaultRootTempDir = candidate + return candidate - return self.defaultRootTempDir + self.defaultRootTempDir = pathlib.Path(tempfile.mkdtemp(prefix='music21-')) + return self.defaultRootTempDir def getKeysToPaths(self): ''' @@ -620,6 +618,11 @@ def getRootTempDir(self): def getSettingsPath(self): ''' Return a pathlib.Path object to the '.music21rc' object + + On Linux and Mac without a writable HOME (as for many web servers), the file + is kept in this user's music21 temp directory. + + * Changed in v11: no longer uses the shared /tmp directory. ''' platform = common.getPlatform() if 'USERPROFILE' in os.environ: @@ -642,7 +645,7 @@ def getSettingsPath(self): if 'HOME' in os.environ and os.access(os.environ['HOME'], os.W_OK): directory = pathlib.Path(os.environ['HOME']) else: - directory = pathlib.Path('/tmp/') + directory = self.getDefaultRootTempDir() return directory / '.music21rc' # darwin specific option # os.path.join(os.environ['HOME'], 'Library',) @@ -881,14 +884,11 @@ def __setitem__(self, key, value): Must call write() to make permanent: - >>> from pathlib import Path >>> a = environment.Environment() >>> a['debug'] = 1 >>> a['graphicsPath'] = '/test&Encode' - >>> 'test&Encode' in str(a['graphicsPath']) - True - >>> isinstance(a['graphicsPath'], Path) - True + >>> a['graphicsPath'] + PosixPath('/test&Encode') >>> a['autoDownload'] = 'asdf' Traceback (most recent call last): @@ -902,6 +902,8 @@ def __setitem__(self, key, value): >>> a['showFormat'] = 'musicxml' >>> a['localCorpusPath'] = '/path/to/local' + + * Fixed in v11: values containing ``&`` etc. are properly stored and retrieved. ''' envSingleton().__setitem__(key, value) @@ -919,8 +921,8 @@ def getDefaultRootTempDir(self): This method is only called if the no scratch directory preference has been set. - If not able to create a 'music21' directory, the standard default is - returned. + If a 'music21' directory cannot be used, another temporary directory + only this user can change is returned. Returns a pathlib.Path. ''' @@ -946,7 +948,6 @@ def getKeysToPaths(self): 'musicxmlPath' 'pdfPath' 'vectorPath' - ''' return envSingleton().getKeysToPaths() @@ -1435,8 +1436,6 @@ def get(key): # ----------------------------------------------------------------------------- class Test(unittest.TestCase): - import stat - def stringFromTree(self, settingsTree): etIndent(settingsTree.getroot()) bio = io.BytesIO() @@ -1472,8 +1471,8 @@ def testToSettings(self): - + value="/Applications/MuseScore 4.app/Contents/MacOS/mscore" /> + @@ -1527,8 +1526,8 @@ def testToSettings(self): - + value="/Applications/MuseScore 4.app/Contents/MacOS/mscore" /> + @@ -1540,6 +1539,30 @@ def testToSettings(self): '/System/Applications/Preview', '/Applications/Preview'), match) self.assertTrue(common.whitespaceEqual(canonic, match) or true_but_for_preview_location) + @unittest.skipIf(common.getPlatform() == 'win', 'test assumes Unix-style paths') + def testSpecialCharactersRoundTrip(self): + ''' + A path containing XML special characters such as "&" is stored + unescaped, serialized escaped by ElementTree, and comes back + unescaped after parsing. + + AI-assisted (Claude). + ''' + core = _EnvironmentCore(forcePlatform='darwin') + fp = '/Applications/Q&A /MuseScore 4.app' + + # these lines use overridden __setitem__ and __getitem__ internally + core['musicxmlPath'] = fp + self.assertEqual(str(core['musicxmlPath']), fp) + + settingsTree = core.toSettingsXML() + match = self.stringFromTree(settingsTree) + self.assertIn('Q&A <Beta>', match) + + ref = {'musicxmlPath': None} + core._fromSettings(settingsTree, ref) + self.assertEqual(ref['musicxmlPath'], fp) + @unittest.skipIf(common.getPlatform() == 'win', 'test assumes Unix-style paths') def testFromSettings(self): @@ -1583,8 +1606,8 @@ def testFromSettings(self): - + value="/Applications/MuseScore 4.app/Contents/MacOS/mscore" /> + @@ -1615,8 +1638,6 @@ def testEnvironmentA(self): 'os.getuid can be called only on Unix platforms' ) def testGetDefaultRootTempDir(self): - import stat - e = Environment() oldScratchDir = e['directoryScratch'] oldTempDir = None @@ -1629,7 +1650,7 @@ def testGetDefaultRootTempDir(self): # Wipe out write, exec permissions on the default root dir os.chmod(oldTempDir, stat.S_IREAD) newTempDir = e.getDefaultRootTempDir() - self.assertIn(f'music21-userid-{os.getuid()}', str(newTempDir)) + self.assertEqual(newTempDir.name, f'music21-userid-{newTempDir.stat().st_uid}') finally: # Make sure oldTempDir and oldPermission is set in 'try' block if oldTempDir is not None and oldPermission is not None: @@ -1657,6 +1678,51 @@ def testGetDefaultRootTempDir(self): if not tmp and empty: os.rmdir(newTempDir) + @unittest.skipUnless( + common.getPlatform() in ['nix', 'darwin'], + 'Windows gives each user a private temp folder' + ) + def testDefaultRootTempDirNotShared(self): + from unittest import mock + + with tempfile.TemporaryDirectory() as parent: + shared = pathlib.Path(parent, 'music21') + shared.mkdir() + shared.chmod(0o777) # as if another user made it for everyone + with mock.patch.object(tempfile, 'tempdir', parent): + chosen = _EnvironmentCore().getDefaultRootTempDir() + self.assertNotEqual(chosen, shared) + self.assertEqual(chosen.stat().st_mode & 0o077, 0) + + @unittest.skipUnless( + common.getPlatform() in ['nix', 'darwin'], + 'os.getuid can be called only on Unix platforms' + ) + def testDefaultRootTempDirKeepsPermissions(self): + from unittest import mock + + with tempfile.TemporaryDirectory() as parent: + existing = pathlib.Path(parent, 'music21') + existing.mkdir() + existing.chmod(0o755) + with mock.patch.object(tempfile, 'tempdir', parent): + chosen = _EnvironmentCore().getDefaultRootTempDir() + self.assertEqual(chosen, existing) + self.assertEqual(stat.S_IMODE(existing.stat().st_mode), 0o755) + + @unittest.skipUnless( + common.getPlatform() in ['nix', 'darwin'], + 'Windows does not use HOME' + ) + def testSettingsPathWithoutHome(self): + from unittest import mock + + core = _EnvironmentCore() + with mock.patch.dict(os.environ): + os.environ.pop('HOME', None) + settingsPath = core.getSettingsPath() + self.assertEqual(settingsPath.parent, core.getDefaultRootTempDir()) + # ----------------------------------------------------------------------------- diff --git a/music21/expressions.py b/music21/expressions.py index af46e55eec..e430eec64b 100644 --- a/music21/expressions.py +++ b/music21/expressions.py @@ -6,7 +6,7 @@ # Christopher Ariza # Neena Parikh # -# Copyright: Copyright © 2009-2024 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2009-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -179,7 +179,6 @@ class RehearsalMark(Expression): >>> rm = expressions.RehearsalMark('B') >>> rm - ''' classSortOrder = -30 _styleClass = style.TextStylePlacement @@ -220,7 +219,6 @@ def _getNumberingFromContent(c) -> str|None: >>> print(ex._getNumberingFromContent('*')) None - ''' if c is None: return None @@ -584,8 +582,8 @@ def realize( keySig: key.KeySignature|None = None, inPlace: bool = False ) -> tuple[list[note.Note|note.Unpitched], - note.Note|note.Unpitched|None, - list[note.Note|note.Unpitched]]: + note.Note|note.Unpitched|None, + list[note.Note|note.Unpitched]]: ''' Subclassable method call that takes a sourceObject and optional keySig and returns a three-element tuple of a list of notes before the @@ -704,7 +702,6 @@ def name(self) -> str: >>> invertedMordent = expressions.InvertedMordent(accidental=sharp) >>> invertedMordent.name 'inverted mordent (sharp)' - ''' theName: str = super().name if self.accidental is not None: @@ -763,8 +760,7 @@ def getSize( ornamentalPitch: pitch.Pitch = copy.deepcopy(srcPitch) ornamentalPitch.accidental = None - if ornamentalPitch.octave is None: - ornamentalPitch.octave = ornamentalPitch.implicitOctave + ornamentalPitch.octaveIsImplicit = False if self._direction == 'up': ornamentalPitch.transpose(interval.GenericInterval(2), inPlace=True) @@ -831,8 +827,7 @@ def resolveOrnamentalPitches( transposeInterval: interval.IntervalBase = self.getSize(srcObj, keySig=keySig) ornamentalPitch: pitch.Pitch = copy.deepcopy(srcPitch) - if ornamentalPitch.octave is None: - ornamentalPitch.octave = ornamentalPitch.implicitOctave + ornamentalPitch.octaveIsImplicit = False ornamentalPitch.transpose(transposeInterval, inPlace=True) # if there are microtones, see if they can be converted to quarter tones. if ornamentalPitch.microtone.cents != 0: @@ -914,8 +909,8 @@ def realize( keySig: key.KeySignature|None = None, inPlace: bool = False ) -> tuple[list[note.Note|note.Unpitched], - note.Note|note.Unpitched|None, - list[note.Note|note.Unpitched]]: + note.Note|note.Unpitched|None, + list[note.Note|note.Unpitched]]: ''' Realize a mordent. @@ -1270,7 +1265,6 @@ class Trill(Ornament): The size property has been removed and replaced with `.getSize()` (which requires a `srcObj` and optional `keySig` param). Added optional `keySig` param to `.realize()` as well. - ''' _direction: str = 'up' @@ -1299,7 +1293,6 @@ def name(self) -> str: >>> doubleSharpedTrill = expressions.Trill(accidental=pitch.Accidental('double-sharp')) >>> doubleSharpedTrill.name 'trill (double-sharp)' - ''' theName: str = super().name if self.accidental: @@ -1380,8 +1373,7 @@ def getSize( ornamentalPitch: pitch.Pitch = copy.deepcopy(srcPitch) ornamentalPitch.accidental = None - if ornamentalPitch.octave is None: - ornamentalPitch.octave = ornamentalPitch.implicitOctave + ornamentalPitch.octaveIsImplicit = False if self._direction == 'up': ornamentalPitch.transpose(interval.GenericInterval(2), inPlace=True) @@ -1429,8 +1421,7 @@ def resolveOrnamentalPitches( transposeInterval: interval.IntervalBase = self.getSize(srcObj, keySig=keySig) ornamentalPitch: pitch.Pitch = copy.deepcopy(srcPitch) - if ornamentalPitch.octave is None: - ornamentalPitch.octave = ornamentalPitch.implicitOctave + ornamentalPitch.octaveIsImplicit = False ornamentalPitch.transpose(transposeInterval, inPlace=True) # if there are microtones, see if they can be converted to quarter tones. if ornamentalPitch.microtone.cents != 0: @@ -1557,7 +1548,7 @@ def updateAccidentalDisplay( if p is None: return - if self.accidental: + if self.accidental is not None and self.accidental.displayStatus is not None: # copy displayStatus from self.accidental if p.accidental is None: p.accidental = pitch.Accidental(0) @@ -1581,8 +1572,8 @@ def realize( keySig: key.KeySignature|None = None, inPlace: bool = False ) -> tuple[list[note.Note|note.Unpitched], - note.Note|note.Unpitched|None, - list[note.Note|note.Unpitched]]: + note.Note|note.Unpitched|None, + list[note.Note|note.Unpitched]]: ''' Realize a trill. @@ -1992,7 +1983,6 @@ def name(self) -> str: ... delay=1.0, lowerAccidental=pitch.Accidental('double-flat')) >>> delayedBy1Turn.name 'delayed(delayQL=1.0) turn (lower=double-flat)' - ''' theName: str = super().name if self.delay == OrnamentDelay.DEFAULT_DELAY: @@ -2049,8 +2039,7 @@ def getSize( ornamentalPitch: pitch.Pitch = copy.deepcopy(srcPitch) ornamentalPitch.accidental = None - if ornamentalPitch.octave is None: - ornamentalPitch.octave = ornamentalPitch.implicitOctave + ornamentalPitch.octaveIsImplicit = False accidental: pitch.Accidental|None = None if which == 'upper': @@ -2125,16 +2114,14 @@ def resolveOrnamentalPitches( srcObj, 'lower', keySig=keySig) upperPitch: pitch.Pitch = copy.deepcopy(srcPitch) - if upperPitch.octave is None: - upperPitch.octave = upperPitch.implicitOctave + upperPitch.octaveIsImplicit = False upperPitch.transpose(transposeIntervalUp, inPlace=True) # if there are microtones, see if they can be converted to quarter tones. if upperPitch.microtone.cents != 0: upperPitch.convertMicrotonesToQuarterTones(inPlace=True) lowerPitch: pitch.Pitch = copy.deepcopy(srcPitch) - if lowerPitch.octave is None: - lowerPitch.octave = lowerPitch.implicitOctave + lowerPitch.octaveIsImplicit = False lowerPitch.transpose(transposeIntervalDown, inPlace=True) # if there are microtones, see if they can be converted to quarter tones. if lowerPitch.microtone.cents != 0: @@ -2455,8 +2442,8 @@ def realize( keySig: key.KeySignature|None = None, inPlace: bool = False ) -> tuple[list[note.Note|note.Unpitched], - note.Note|note.Unpitched|None, - list[note.Note|note.Unpitched]]: + note.Note|note.Unpitched|None, + list[note.Note|note.Unpitched]]: ''' Realize an appoggiatura. @@ -2603,8 +2590,8 @@ def realize( keySig: key.KeySignature|None = None, inPlace: bool = False ) -> tuple[list[note.Note|note.Unpitched], - note.Note|note.Unpitched|None, - list[note.Note|note.Unpitched]]: + note.Note|note.Unpitched|None, + list[note.Note|note.Unpitched]]: ''' Realize the ornament. @@ -2724,16 +2711,9 @@ def __init__(self, *spannedElements, **keywords): self._placement = None # can above or below or None, after musicxml - def _getPlacement(self): - return self._placement - - def _setPlacement(self, value): - if value is not None and value.lower() not in ['above', 'below']: - raise TrillExtensionException(f'incorrect placement value: {value}') - if value is not None: - self._placement = value.lower() - - placement = property(_getPlacement, _setPlacement, doc=''' + @property + def placement(self): + ''' Get or set the placement as either above, below, or None. >>> s = stream.Stream() @@ -2745,7 +2725,15 @@ def _setPlacement(self, value): A setting of None implies that the placement will be determined by notation software and no particular placement is demanded. - ''') + ''' + return self._placement + + @placement.setter + def placement(self, value): + if value is not None and value.lower() not in ['above', 'below']: + raise TrillExtensionException(f'incorrect placement value: {value}') + if value is not None: + self._placement = value.lower() class TremoloSpanner(spanner.Spanner): diff --git a/music21/features/base.py b/music21/features/base.py index f26214c57b..b99797402a 100644 --- a/music21/features/base.py +++ b/music21/features/base.py @@ -5,7 +5,7 @@ # Authors: Christopher Ariza # Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2011-2023 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2011-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ from __future__ import annotations @@ -213,7 +213,6 @@ def getAttributeLabels(self) -> list[str]: 'Fifths_Pitch_Histogram_3', 'Fifths_Pitch_Histogram_4', 'Fifths_Pitch_Histogram_5', 'Fifths_Pitch_Histogram_6', 'Fifths_Pitch_Histogram_7', 'Fifths_Pitch_Histogram_8', 'Fifths_Pitch_Histogram_9', 'Fifths_Pitch_Histogram_10', 'Fifths_Pitch_Histogram_11'] - ''' post: list[str] = [] if self.dimensions == 1: @@ -1224,7 +1223,6 @@ def extractorsById(idOrList: str|Iterable[str], >>> y = [x.id for x in features.extractorsById('all')] >>> y[0:3], y[-3:-1] (['M1', 'M2', 'M3'], ['CS12', 'MC1']) - ''' from music21.features import jSymbolic from music21.features import native @@ -1236,7 +1234,7 @@ def extractorsById(idOrList: str|Iterable[str], for lib in libraries: if lib.lower() in ['jsymbolic', 'all']: featureExtractors += jSymbolic.featureExtractors - elif lib.lower() in ['native', 'all']: + if lib.lower() in ['native', 'all']: featureExtractors += native.featureExtractors ids: Iterable[str] = [idOrList] if isinstance(idOrList, str) else idOrList @@ -1268,7 +1266,6 @@ def extractorById(idOrList: str|Iterable[str], >>> fe = features.extractorById('p20')(s) # call class >>> fe.extract().vector [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] - ''' ebi = extractorsById(idOrList=idOrList, library=library) if ebi: @@ -1358,11 +1355,11 @@ def testStreamFormsA(self): '3-8': 2, '3-2': 2}) self.assertEqual(di['chordify.flat.getElementsByClass(Chord).typesHistogram'], - {'isMinorTriad': 6, 'isAugmentedTriad': 0, - 'isTriad': 34, 'isSeventh': 0, 'isDiminishedTriad': 4, - 'isDiminishedSeventh': 0, 'isIncompleteMajorTriad': 26, - 'isHalfDiminishedSeventh': 0, 'isMajorTriad': 24, - 'isDominantSeventh': 0, 'isIncompleteMinorTriad': 16}) + {'isMinorTriad': 6, 'isAugmentedTriad': 0, + 'isTriad': 34, 'isSeventh': 0, 'isDiminishedTriad': 4, + 'isDiminishedSeventh': 0, 'isIncompleteMajorTriad': 26, + 'isHalfDiminishedSeventh': 0, 'isMajorTriad': 24, + 'isDominantSeventh': 0, 'isIncompleteMinorTriad': 16}) self.assertEqual(di['flat.notes.quarterLengthHistogram'], {0.5: 116, 1.0: 39, 1.5: 27, 2.0: 31, 3.0: 2, 4.0: 3, @@ -1493,6 +1490,17 @@ def testDataSetOutput(self): for fp in (fp1, fp2, fp3): os.remove(fp) + def testExtractorsByIdLibraryAll(self): + from music21.features import jSymbolic + from music21.features import native + + allExtractors = extractorsById('all', library='all') + self.assertEqual(allExtractors, extractorsById('all')) + self.assertIn(native.ChordBassMotionFeature, allExtractors) + self.assertIn(jSymbolic.PitchClassDistributionFeature, allExtractors) + self.assertEqual(extractorsById('cs12', library='all'), + [native.ChordBassMotionFeature]) + def testFeatureFail(self): from music21 import features from music21 import base @@ -1653,8 +1661,8 @@ def x_testRegionClassificationJSymbolicB(self): # pragma: no cover # features common to both collections featureExtractors = features.extractorsById( ['r31', 'r32', 'r33', 'r34', 'r35', 'p1', 'p2', 'p3', 'p4', - 'p5', 'p6', 'p7', 'p8', 'p9', 'p10', 'p11', 'p12', 'p13', - 'p14', 'p15', 'p16', 'p19', 'p20', 'p21'], 'jSymbolic') + 'p5', 'p6', 'p7', 'p8', 'p9', 'p10', 'p11', 'p12', 'p13', + 'p14', 'p15', 'p16', 'p19', 'p20', 'p21'], 'jSymbolic') # first bundle ds = features.DataSet(classLabel='Region') diff --git a/music21/features/jSymbolic.py b/music21/features/jSymbolic.py index dc48ea6abc..e98a8b876e 100644 --- a/music21/features/jSymbolic.py +++ b/music21/features/jSymbolic.py @@ -102,7 +102,6 @@ def process(self) -> None: class MostCommonMelodicIntervalFeature(featuresModule.FeatureExtractor): ''' - >>> s = corpus.parse('bwv66.6') >>> fe = features.jSymbolic.MostCommonMelodicIntervalFeature(s) >>> f = fe.extract() @@ -129,7 +128,6 @@ def process(self) -> None: class DistanceBetweenMostCommonMelodicIntervalsFeature( featuresModule.FeatureExtractor): ''' - >>> s = corpus.parse('bwv66.6') >>> fe = features.jSymbolic.DistanceBetweenMostCommonMelodicIntervalsFeature(s) >>> f = fe.extract() @@ -192,7 +190,6 @@ def process(self) -> None: class RelativeStrengthOfMostCommonIntervalsFeature( featuresModule.FeatureExtractor): ''' - >>> s = corpus.parse('bwv66.6') >>> fe = features.jSymbolic.RelativeStrengthOfMostCommonIntervalsFeature(s) >>> f = fe.extract() @@ -856,7 +853,6 @@ def process(self) -> None: class RelativeStrengthOfTopPitchClassesFeature(featuresModule.FeatureExtractor): ''' - >>> s = corpus.parse('bwv66.6') >>> fe = features.jSymbolic.RelativeStrengthOfTopPitchClassesFeature(s) >>> fe.extract().vector @@ -924,7 +920,6 @@ def process(self) -> None: class IntervalBetweenStrongestPitchClassesFeature( featuresModule.FeatureExtractor): ''' - >>> s = corpus.parse('bwv66.6') >>> fe = features.jSymbolic.IntervalBetweenStrongestPitchClassesFeature(s) >>> fe.extract().vector @@ -1495,7 +1490,6 @@ class VibratoPrevalenceFeature(featuresModule.FeatureExtractor): Number of notes for which Pitch Bend messages change direction at least twice divided by total number of notes that have Pitch Bend messages associated with them. - ''' id = 'P25' name = 'Vibrato Prevalence' @@ -1516,7 +1510,6 @@ class PrevalenceOfMicrotonesFeature(featuresModule.FeatureExtractor): Number of Note Ons that are preceded by isolated MIDI Pitch Bend messages as a fraction of the total number of Note Ons.' - ''' id = 'P26' name = 'Prevalence Of Microtones' @@ -1580,7 +1573,6 @@ class SecondStrongestRhythmicPulseFeature(featuresModule.FeatureExtractor): >>> f = fe.extract() >>> f.vector [192] - ''' id = 'R2' name = 'Second Strongest Rhythmic Pulse' @@ -1616,7 +1608,6 @@ class HarmonicityOfTwoStrongestRhythmicPulsesFeature( >>> f = fe.extract() >>> f.vector [0.5] - ''' id = 'R3' name = 'Harmonicity of Two Strongest Rhythmic Pulses' @@ -1699,7 +1690,6 @@ class StrengthRatioOfTwoStrongestRhythmicPulsesFeature( >>> fe = features.jSymbolic.StrengthRatioOfTwoStrongestRhythmicPulsesFeature(sch) >>> fe.extract().vector[0] 7.0 - ''' id = 'R6' name = 'Strength Ratio of Two Strongest Rhythmic Pulses' @@ -1756,7 +1746,6 @@ class NumberOfStrongPulsesFeature(featuresModule.FeatureExtractor): Not yet implemented. Number of beat peaks with normalized frequencies over 0.1. - ''' id = 'R8' name = 'Number of Strong Pulses' @@ -1874,7 +1863,6 @@ class BeatHistogramFeature(featuresModule.FeatureExtractor): A feature extractor that finds a feature array with entries corresponding to the frequency values of each of the bins of the beat histogram (except the first 40 empty ones). - ''' id = 'R14' name = 'Beat Histogram' @@ -2608,7 +2596,6 @@ class VariationOfDynamicsFeature(featuresModule.FeatureExtractor): Standard deviation of loudness levels of all notes. TODO: implement - ''' id = 'D2' name = 'Variation of Dynamics' @@ -2623,7 +2610,6 @@ class VariationOfDynamicsInEachVoiceFeature(featuresModule.FeatureExtractor): channel that contains at least one note. TODO: implement - ''' id = 'D3' name = 'Variation of Dynamics In Each Voice' @@ -2639,7 +2625,6 @@ class AverageNoteToNoteDynamicsChangeFeature(featuresModule.FeatureExtractor): same channel (in MIDI velocity units). TODO: implement - ''' id = 'D4' @@ -2789,7 +2774,6 @@ class VoiceEqualityNoteDurationFeature(featuresModule.FeatureExtractor): Not implemented. TODO: implement - ''' id = 'T5' name = 'Voice Equality - Note Duration' @@ -2802,7 +2786,6 @@ class VoiceEqualityDynamicsFeature(featuresModule.FeatureExtractor): Not implemented. TODO: implement - ''' id = 'T6' name = 'Voice Equality - Dynamics' @@ -2815,7 +2798,6 @@ class VoiceEqualityMelodicLeapsFeature(featuresModule.FeatureExtractor): Not implemented. TODO: implement - ''' id = 'T7' name = 'Voice Equality - Melodic Leaps' @@ -2886,7 +2868,6 @@ class RelativeNoteDensityOfHighestLineFeature(featuresModule.FeatureExtractor): Not implemented. TODO: implement - ''' id = 'T13' name = 'Relative Note Density of Highest Line' @@ -3027,7 +3008,6 @@ def process(self) -> None: class NotePrevalenceOfPitchedInstrumentsFeature( featuresModule.FeatureExtractor): ''' - >>> s1 = stream.Stream() >>> s1.append(instrument.AcousticGuitar()) >>> s1.repeatAppend(note.Note(), 4) @@ -3089,7 +3069,6 @@ class NotePrevalenceOfUnpitchedInstrumentsFeature( Not implemented. TODO: implement - ''' id = 'I4' name = 'Note Prevalence of Unpitched Instruments' @@ -3118,7 +3097,6 @@ class TimePrevalenceOfPitchedInstrumentsFeature( in seconds of the piece.' TODO: implement - ''' id = 'I5' name = 'Time Prevalence of Pitched Instruments' @@ -3146,7 +3124,6 @@ class VariabilityOfNotePrevalenceOfPitchedInstrumentsFeature( >>> fe = features.jSymbolic.VariabilityOfNotePrevalenceOfPitchedInstrumentsFeature(s1) >>> fe.extract().vector [0.33333...] - ''' id = 'I6' name = 'Variability of Note Prevalence of Pitched Instruments' @@ -3190,7 +3167,6 @@ class VariabilityOfNotePrevalenceOfUnpitchedInstrumentsFeature( official standard. TODO: implement - ''' id = 'I7' name = 'Variability of Note Prevalence of Unpitched Instruments' @@ -3213,7 +3189,6 @@ class NumberOfPitchedInstrumentsFeature(featuresModule.FeatureExtractor): >>> fe = features.jSymbolic.NumberOfPitchedInstrumentsFeature(s1) >>> fe.extract().vector [2] - ''' id = 'I8' name = 'Number of Pitched Instruments' diff --git a/music21/features/native.py b/music21/features/native.py index 6a7cce1731..9b33455631 100644 --- a/music21/features/native.py +++ b/music21/features/native.py @@ -4,7 +4,7 @@ # # Authors: Christopher Ariza # -# Copyright: Copyright © 2011 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2011-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -18,6 +18,7 @@ from music21 import environment from music21.features import base as featuresModule +from music21 import harmony from music21 import text environLocal = environment.Environment('features.native') @@ -87,7 +88,6 @@ class QualityFeature(featuresModule.FeatureExtractor): # for monophonic melodies # incomplete measures / pickups for monophonic melodies - ''' id = 'P22' name = 'Quality' @@ -103,37 +103,24 @@ class QualityFeature(featuresModule.FeatureExtractor): def process(self) -> None: ''' Do processing necessary, storing result in feature. + + The piece's Keys decide the result when they are all major or all + minor, as in transposing parts that state different keys; + otherwise the piece is analyzed. ''' if self.data is None or self.feature is None: # pragma: no cover raise ValueError('Cannot process without a data instance or feature.') allKeys = self.data['flat.getElementsByClass(Key)'] - if len(allKeys) == 1: - k0 = allKeys[0] - if k0.mode == 'major': - self.feature.vector[0] = 0 - return - elif k0.mode == 'minor': - self.feature.vector[0] = 1 - return - - useKey = None - if len(allKeys) == 1: - useKey = allKeys[0] - elif len(allKeys) > 1: - seen_modes = set() - for k in allKeys: - seen_modes.add(k.mode) - if len(seen_modes) == 1: - # there might, for instance be lots of different parts - # all giving the same mode. (maybe not the same key - # because of transposition). It doesn't matter which - # key we use for this. - useKey = allKeys[0] - # else -- back to analysis. - - if useKey is None: - useKey = self.data['flat.analyzedKey'] + seenModes = {k.mode for k in allKeys} + if seenModes == {'major'}: + self.feature.vector[0] = 0 + return + elif seenModes == {'minor'}: + self.feature.vector[0] = 1 + return + + useKey = self.data['flat.analyzedKey'] analyzedMode = useKey.mode if analyzedMode == 'major': @@ -733,15 +720,14 @@ class ChordBassMotionFeature(featuresModule.FeatureExtractor): fe = features.native.ChordBassMotionFeature(s) fe.extract().vector - [0.0, 0.0, 0.0, 0.0416..., 0.0416..., 0.166..., 0.0, 0.54166..., 0.0, 0.0, 0.2083... 0.0] + [0.0, 0.0, 0.2083..., 0.0, 0.0, 0.54166..., 0.0, 0.166..., 0.0416..., 0.0416..., 0.0, 0.0] For comparison, the Beatles Here Comes the Sun has more tone motion - [0.0, 0.05..., 0.14..., 0.03..., 0.06..., 0.3..., 0.008..., 0.303..., - 0.0, 0.0, 0.07..., 0.008...] + [0.0, 0.008..., 0.07..., 0.0, 0.0, 0.303..., 0.008..., 0.3..., + 0.06..., 0.03..., 0.14..., 0.05...] Post 1990s music has a lot more semitone motion. - ''' id = 'CS12' name = 'Chord Bass Motion' @@ -765,6 +751,8 @@ def process(self) -> None: lastHarm = None for thisHarm in harms: + if isinstance(thisHarm, harmony.NoChord): + continue if lastHarm is None: lastHarm = thisHarm else: @@ -781,7 +769,7 @@ def process(self) -> None: if lastBass.pitchClass == thisBass.pitchClass: pass else: - halfStepMotion = (lastBass.pitchClass - thisBass.pitchClass) % 12 + halfStepMotion = (thisBass.pitchClass - lastBass.pitchClass) % 12 totMotion[halfStepMotion] += 1 totalHarmonicMotion += 1 lastHarm = thisHarm @@ -880,7 +868,6 @@ class LanguageFeature(featuresModule.FeatureExtractor): >>> fe = features.native.LanguageFeature(s) >>> fe.extract().vector [3] - ''' id = 'TX1' name = 'Language Feature' @@ -937,6 +924,28 @@ def process(self) -> None: # ------------------------------------------------------------------------------ class Test(unittest.TestCase): + def testChordBassMotionNoChord(self): + from music21 import features + from music21 import stream + + def motionVector(figures): + s = stream.Stream() + for figure in figures: + if figure == 'N.C.': + s.append(harmony.NoChord()) + else: + s.append(harmony.ChordSymbol(figure)) + return features.native.ChordBassMotionFeature(s).extract().vector + + for figures in (['C', 'N.C.', 'G', 'C'], + ['N.C.', 'C', 'G', 'C'], + ['C', 'G', 'C', 'N.C.']): + vector = motionVector(figures) + self.assertEqual(vector[5], 0.5) + self.assertEqual(vector[7], 0.5) + self.assertEqual(sum(vector), 1.0) + self.assertEqual(motionVector(['C', 'N.C.']), [1.0] + [0.0] * 11) + def testIncorrectlySpelledTriadPrevalence(self): from music21 import stream from music21 import features @@ -951,6 +960,22 @@ def testIncorrectlySpelledTriadPrevalence(self): fe = features.native.IncorrectlySpelledTriadPrevalence(s) self.assertEqual(str(fe.extract().vector[0]), '0.5') + def testChordBassMotionFeature(self): + ''' + C up to D is index 2, D down to B is index 9, and B down to B- is + index 11. + ''' + from music21 import features + from music21 import stream + + s = stream.Stream() + for figure in ['C', 'D', 'B', 'B-']: + s.append(harmony.ChordSymbol(figure)) + fe = features.native.ChordBassMotionFeature(s) + third = 1 / 3 + self.assertEqual(fe.extract().vector, + [0.0, 0.0, third, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, third, 0.0, third]) + def testLandiniCadence(self): from music21 import converter from music21 import features @@ -967,6 +992,25 @@ def testLandiniCadence(self): fe = features.native.LandiniCadence(s) self.assertEqual(fe.extract().vector[0], 0) + def testQualityModalKey(self): + ''' + A Key with a mode other than major or minor is analyzed, here as + d minor; a major Key is used as stated. + ''' + from music21 import converter + from music21 import features + from music21 import key + + s = converter.parse("tinynotation: 4/4 d4 f a d' c' b a f e d2.") + dorian = key.Key('d', 'dorian') + s.measure(1).insert(0, dorian) + fe = features.native.QualityFeature(s) + self.assertEqual(fe.extract().vector, [1]) + + s.measure(1).replace(dorian, key.Key('F')) + fe = features.native.QualityFeature(s) + self.assertEqual(fe.extract().vector, [0]) + if __name__ == '__main__': import music21 diff --git a/music21/features/outputFormats.py b/music21/features/outputFormats.py index 5e6a06d7eb..b163329815 100644 --- a/music21/features/outputFormats.py +++ b/music21/features/outputFormats.py @@ -88,7 +88,6 @@ def getHeaderLines(self, includeClassLabel: bool = True, ['Identifier', 'Changes_of_Meter', 'Composer'] ['string', 'discrete', 'discrete'] ['meta', '', 'class'] - ''' if self._dataSet is None: # pragma: no cover raise OutputFormatException('cannot get header lines without a DataSet') @@ -217,7 +216,6 @@ def getHeaderLines(self, includeClassLabel: bool = True, @ATTRIBUTE Changes_of_Meter NUMERIC @ATTRIBUTE class {} @DATA - ''' if self._dataSet is None: # pragma: no cover raise OutputFormatException('cannot get header lines without a DataSet') diff --git a/music21/figuredBass/checker.py b/music21/figuredBass/checker.py index 638bd4aff6..03b786f5ed 100644 --- a/music21/figuredBass/checker.py +++ b/music21/figuredBass/checker.py @@ -3,7 +3,7 @@ # Purpose: checks figured basses for following voice-leading rules # Authors: Jose Cabal-Ugaz # -# Copyright: Copyright © 2012 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2012-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ from __future__ import annotations @@ -170,7 +170,7 @@ def createOffsetMapping( currentMapping: dict[OffsetEndTime, list[note.GeneralNote]] = collections.defaultdict(list) for music21GeneralNote in music21Part.flatten().notesAndRests: initOffset = music21GeneralNote.offset - endTime = initOffset + music21GeneralNote.quarterLength + endTime = opFrac(initOffset + music21GeneralNote.quarterLength) currentMapping[(initOffset, endTime)].append(music21GeneralNote) return currentMapping @@ -743,6 +743,23 @@ def generalNoteToPitch(music21GeneralNote: note.GeneralNote) -> pitch.Pitch: class Test(unittest.TestCase): + def testOffsetMappingAfterTriplet(self): + ''' + End times after a triplet are exact. + ''' + from fractions import Fraction + + part = stream.Part() + for unused_counter in range(3): + part.append(note.Note('C3', quarterLength=Fraction(1, 3))) + part.append(note.Note('F3')) + self.assertEqual(sorted(createOffsetMapping(part)), [ + (0.0, Fraction(1, 3)), + (Fraction(1, 3), Fraction(2, 3)), + (Fraction(2, 3), 1.0), + (1.0, 2.0), + ]) + def testParallelFifthsCachePopulatesAndIsShared(self): ''' The speedup table actually caches (it formerly stored False on every call and diff --git a/music21/figuredBass/notation.py b/music21/figuredBass/notation.py index 86c1cb8627..563b24c2a2 100644 --- a/music21/figuredBass/notation.py +++ b/music21/figuredBass/notation.py @@ -24,18 +24,18 @@ ''' shorthandNotation: dict[tuple[int|None, ...], tuple[int, ...]] = { - (None,): (5, 3), - (5,): (5, 3), - (6,): (6, 3), - (7,): (7, 5, 3), - (9,): (9, 7, 5, 3), - (11,): (11, 9, 7, 5, 3), - (13,): (13, 11, 9, 7, 5, 3), - (6, 5): (6, 5, 3), - (4, 3): (6, 4, 3), - (4, 2): (6, 4, 2), - (2,): (6, 4, 2), - } + (None,): (5, 3), + (5,): (5, 3), + (6,): (6, 3), + (7,): (7, 5, 3), + (9,): (9, 7, 5, 3), + (11,): (11, 9, 7, 5, 3), + (13,): (13, 11, 9, 7, 5, 3), + (6, 5): (6, 5, 3), + (4, 3): (6, 4, 3), + (4, 2): (6, 4, 2), + (2,): (6, 4, 2), +} prefixes = ['+', '#', '++', '##'] suffixes = ['\\'] @@ -641,7 +641,6 @@ def _reprInternal(self) -> str: def _toAccidental(self) -> pitch.Accidental|None: ''' - >>> from music21.figuredBass import notation as n >>> m1 = n.Modifier('#') >>> m2 = n.Modifier('-') diff --git a/music21/figuredBass/possibility.py b/music21/figuredBass/possibility.py index 6c5bc64565..7ee1e1e146 100644 --- a/music21/figuredBass/possibility.py +++ b/music21/figuredBass/possibility.py @@ -944,7 +944,6 @@ def partPairs( (, ), (, ), (, )] - ''' return list(zip(possibA, possibB, strict=True)) diff --git a/music21/figuredBass/realizer.py b/music21/figuredBass/realizer.py index 2011a60007..76ee5d96ac 100644 --- a/music21/figuredBass/realizer.py +++ b/music21/figuredBass/realizer.py @@ -4,7 +4,7 @@ # and figures in a given key. # Authors: Jose Cabal-Ugaz # -# Copyright: Copyright © 2011 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2011-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -56,6 +56,7 @@ from music21 import pitch from music21 import roman from music21 import stream +from music21.common.numberTools import opFrac from music21.common.types import OffsetQL from music21.figuredBass import checker from music21.figuredBass import notation @@ -367,20 +368,20 @@ def retrieveSegments( previousBassNote = bassNotes[bassNoteIndex] bassNote = t.cast(note.Note, currentMapping[allKeys[0]][-1]) previousSegment = segment.OverlaidSegment(bassNote, bassNote.editorial.notationString, - self._fbScale, - fbRules, numParts, maxPitch) + self._fbScale, + fbRules, numParts, maxPitch) previousSegment.quarterLength = previousBassNote.quarterLength segmentList.append(previousSegment) for k in allKeys[1:]: (startTime, unused_endTime) = k bassNote = t.cast(note.Note, currentMapping[k][-1]) currentSegment = segment.OverlaidSegment(bassNote, bassNote.editorial.notationString, - self._fbScale, - fbRules, numParts, maxPitch) + self._fbScale, + fbRules, numParts, maxPitch) for partNumber in range(1, len(currentMapping[k])): upperPitch = t.cast(note.Note, currentMapping[k][partNumber - 1]) currentSegment.fbRules._partPitchLimits.append((partNumber, upperPitch)) - if startTime == previousBassNote.offset + previousBassNote.quarterLength: + if startTime == opFrac(previousBassNote.offset + previousBassNote.quarterLength): bassNoteIndex += 1 previousBassNote = bassNotes[bassNoteIndex] currentSegment.quarterLength = previousBassNote.quarterLength @@ -456,7 +457,6 @@ def realize( >>> r4 = fbLine4.realize() >>> r4.getNumSolutions() 13 - ''' if fbRules is None: fbRules = rules.Rules() @@ -734,7 +734,7 @@ def generateRealizationFromPossibilityProgression( possibA = possibilityProgression[segmentIndex] bassNote = self._segmentList[segmentIndex].bassNote bassLine.append(copy.deepcopy(bassNote)) - rhPitches = possibA[0:-1] + rhPitches = [copy.deepcopy(p) for p in possibA[0:-1]] rhChord = chord.Chord(rhPitches) rhChord.quarterLength = self._segmentList[segmentIndex].quarterLength rightHand.append(rhChord) @@ -761,7 +761,7 @@ def generateRealizationFromPossibilityProgression( bassLine.append(copy.deepcopy(bassNote)) for partNumber in range(len(possibA) - 1): - n1 = note.Note(possibA[partNumber]) + n1 = note.Note(copy.deepcopy(possibA[partNumber])) n1.quarterLength = self._segmentList[segmentIndex].quarterLength upperParts[partNumber].append(n1) @@ -847,6 +847,24 @@ class FiguredBassLineException(exceptions21.Music21Exception): class Test(unittest.TestCase): + def testRealizationsCopyVoicingPitches(self) -> None: + fbLine = FiguredBassLine(key.Key('A'), meter.TimeSignature('3/4')) + for bassName, notationString in (('A2', ''), ('D3', '6'), ('E3', '7'), ('A2', '')): + fbLine.addElement(note.Note(bassName), notationString) + fbRealization = fbLine.realize() + progressions = fbRealization.getAllPossibilityProgressions() + + fbRealization.generateRealizationFromPossibilityProgression(progressions[0]) + self.assertEqual(fbRealization.getNumSolutions(), 77) + + fbRealization.keyboardStyleOutput = False + for progression in progressions[:4]: + realization = fbRealization.generateRealizationFromPossibilityProgression( + progression) + for n in realization.recurse().notes: + if n.pitch.accidental is not None: + self.assertIs(n.pitch.accidental.displayStatus, False) + def testMultipleFiguresInLyric(self) -> None: from music21 import converter @@ -876,6 +894,36 @@ def testMultipleFiguresInLyric(self) -> None: unused_fb = figuredBassFromStream(s) self.assertEqual(third_note.editorial.notationString, single_symbol) + def testSegmentsAfterTriplet(self) -> None: + ''' + Each segment after an eighth-note or quarter-note triplet starts + where the one before it ends. + ''' + from fractions import Fraction + + fbLine = FiguredBassLine(key.Key('C'), meter.TimeSignature('2/4')) + third = Fraction(1, 3) + fbLine.addElement(note.Note('C3', quarterLength=third)) + fbLine.addElement(note.Note('D3', quarterLength=third), '6') + fbLine.addElement(note.Note('E3', quarterLength=third), '6') + fbLine.addElement(note.Note('F3'), '6,4') + fbLine.addElement(note.Note('C3', quarterLength=2.0)) + self.assertEqual([seg.quarterLength for seg in fbLine.retrieveSegments()], + [third, third, third, 1.0, 2.0]) + self.assertGreater(fbLine.realize().getNumSolutions(), 0) + + fbLine = FiguredBassLine(key.Key('C'), meter.TimeSignature('4/4')) + twoThirds = Fraction(2, 3) + fbLine.addElement(note.Note('C3', quarterLength=twoThirds)) + fbLine.addElement(note.Note('D3', quarterLength=twoThirds), '6') + fbLine.addElement(note.Note('E3', quarterLength=twoThirds), '6') + fbLine.addElement(note.Note('F3', quarterLength=0.5), '6,4') + fbLine.addElement(note.Note('G3', quarterLength=0.5), '7') + fbLine.addElement(note.Note('C3')) + self.assertEqual([seg.quarterLength for seg in fbLine.retrieveSegments()], + [twoThirds, twoThirds, twoThirds, 0.5, 0.5, 1.0]) + self.assertGreater(fbLine.realize().getNumSolutions(), 0) + if __name__ == '__main__': import music21 diff --git a/music21/figuredBass/realizerScale.py b/music21/figuredBass/realizerScale.py index 3f3345fc19..a1b6575dcd 100644 --- a/music21/figuredBass/realizerScale.py +++ b/music21/figuredBass/realizerScale.py @@ -203,7 +203,7 @@ def getPitches(self, bassPitch = convertToPitch(bassPitch) maxPitch = convertToPitch(maxPitch) pitchNames = self.getPitchNames(bassPitch, notationString) - maxOctave = maxPitch.implicitOctave + maxOctave = maxPitch.octave iter1 = itertools.product(pitchNames, range(maxOctave + 1)) iter2 = map(lambda x: pitch.Pitch(x[0] + str(x[1])), iter1) iter3 = itertools.filterfalse(lambda samplePitch: bassPitch > samplePitch, iter2) diff --git a/music21/figuredBass/segment.py b/music21/figuredBass/segment.py index ac7b183890..466ccca6d0 100644 --- a/music21/figuredBass/segment.py +++ b/music21/figuredBass/segment.py @@ -211,16 +211,16 @@ def singlePossibilityRules( singlePossibRules = [ (fbRules.forbidIncompletePossibilities, - possibility.isIncomplete, - False, - [self.pitchNamesInChord]), + possibility.isIncomplete, + False, + [self.pitchNamesInChord]), (True, - possibility.upperPartsWithinLimit, - True, - [fbRules.upperPartsMaxSemitoneSeparation]), + possibility.upperPartsWithinLimit, + True, + [fbRules.upperPartsMaxSemitoneSeparation]), (fbRules.forbidVoiceCrossing, - possibility.voiceCrossing, - False) + possibility.voiceCrossing, + False) ] return singlePossibRules @@ -856,8 +856,8 @@ def _resolveOrdinarySegment( correctB = segmentB.allCorrectSinglePossibilities() correctAB = itertools.product(correctA, correctB) return filter(lambda possibAB: self._isCorrectConsecutivePossibility(possibA=possibAB[0], - possibB=possibAB[1]), - correctAB) + possibB=possibAB[1]), + correctAB) def _resolveSpecialSegment( self, @@ -930,15 +930,15 @@ def getPitches(pitchNames: Iterable[str] = ('C', 'E', 'G'), >>> segment.getPitches(maxPitch=pitch.Pitch('E')) Traceback (most recent call last): - ValueError: maxPitch must be given an octave + ValueError: maxPitch must not have an implicit octave ''' if isinstance(bassPitch, str): bassPitch = pitch.Pitch(bassPitch) if isinstance(maxPitch, str): maxPitch = pitch.Pitch(maxPitch) - if maxPitch.octave is None: - raise ValueError('maxPitch must be given an octave') + if maxPitch.octaveIsImplicit: + raise ValueError('maxPitch must not have an implicit octave') iter1 = itertools.product(pitchNames, range(maxPitch.octave + 1)) iter2 = map(lambda x: pitch.Pitch(x[0] + str(x[1])), iter1) iter3 = itertools.filterfalse(lambda samplePitch: bassPitch > samplePitch, iter2) diff --git a/music21/freezeThaw.py b/music21/freezeThaw.py index afef17f046..a4aa36df62 100644 --- a/music21/freezeThaw.py +++ b/music21/freezeThaw.py @@ -210,7 +210,7 @@ def __init__(self, streamObj=None, fastButUnsafe=False, topLevel=True, streamIds self.subStreamFreezers = {} # this will keep track of sub freezers for spanners - if streamObj is not None and fastButUnsafe is False: + if streamObj is not None and not fastButUnsafe: # deepcopy necessary because we mangle sites in the objects # before serialization self.stream = copy.deepcopy(streamObj) @@ -230,7 +230,6 @@ def packStream(self, streamObj=None): >>> sf = freezeThaw.StreamFreezer(s) >>> pprint(sf.packStream()) {'m21Version': (...), 'stream': } - ''' # do all things necessary to set up the stream if streamObj is None: @@ -267,7 +266,7 @@ def setupSerializationScaffold(self, streamObj=None): # might not work when recurse yields allEls = list(streamObj.recurse(restoreActiveSites=False)) - if self.topLevel is True: + if self.topLevel: self.findActiveStreamIdsInHierarchy(streamObj) for el in allEls: @@ -297,7 +296,7 @@ def setupSerializationScaffold(self, streamObj=None): # removing seems to create problems for jsonPickle with Spanners self.setupStoredElementOffsetTuples(streamObj) - if self.topLevel is True: + if self.topLevel: self.recursiveClearSites(streamObj) def removeStreamStatusClient(self, streamObj): @@ -442,7 +441,6 @@ def setupStoredElementOffsetTuples(self, streamObj): (, 'end')] >>> s2._storedElementOffsetTuples[2][0] is v1 True - ''' if hasattr(streamObj, '_storedElementOffsetTuples'): # in the case of a spanner storing a Stream, like a StaffGroup @@ -578,11 +576,11 @@ def findActiveStreamIdsInHierarchy( includeSelf=True) streamIds = [id(s) for s in streamsFoundGenerator] - if getSpanners is True: + if getSpanners: spannerBundle = streamObj.spannerBundle streamIds += spannerBundle.getSpannerStorageIds() - if getVariants is True: + if getVariants: for el in streamObj.recurse(includeSelf=True).getElementsByClass(variant.Variant): streamIds += self.findActiveStreamIdsInHierarchy(el._stream) @@ -853,7 +851,7 @@ def restoreElementsFromTuples(self, streamObj): streamObj.coreElementsChanged() for subElement in streamObj: - if subElement.isStream is True: + if subElement.isStream: # note that the elements may have already been restored # if the spanner stores a part or something in the Stream # for instance in a StaffGroup object @@ -1047,7 +1045,6 @@ def testFreezeThawCorpusFileWithSpanners(self): self.assertEqual(len(s.parts[0].measure(7).notes), 6) def x_testSimplePickle(self): - from music21 import freezeThaw from music21 import corpus c = corpus.parse('bwv66.6').parts[0].measure(0).notes @@ -1061,7 +1058,7 @@ def x_testSimplePickle(self): n1 = c[0] n2 = c[1] - sf = freezeThaw.StreamFreezer(c, fastButUnsafe=True) + sf = StreamFreezer(c, fastButUnsafe=True) sf.setupSerializationScaffold() for dummy in n1.sites.siteDict: pass @@ -1088,17 +1085,16 @@ def x_testSimplePickle(self): # s.show('t') def x_testFreezeThawPickle(self): - from music21 import freezeThaw from music21 import corpus c = corpus.parse('luca/gloria') # c.show('t') - sf = freezeThaw.StreamFreezer(c, fastButUnsafe=True) + sf = StreamFreezer(c, fastButUnsafe=True) d = sf.writeStr() # print(d) - st = freezeThaw.StreamThawer() + st = StreamThawer() st.openStr(d) s = st.stream @@ -1107,7 +1103,6 @@ def x_testFreezeThawPickle(self): pass def testFreezeThawSimpleVariant(self): - from music21 import freezeThaw from music21 import stream from music21 import note @@ -1126,15 +1121,14 @@ def testFreezeThawSimpleVariant(self): s.insert(0, v) - sf = freezeThaw.StreamFreezer(s) + sf = StreamFreezer(s) d = sf.writeStr() - st = freezeThaw.StreamThawer() + st = StreamThawer() st.openStr(d) s = st.stream def testFreezeThawVariant(self): - from music21 import freezeThaw from music21 import corpus from music21 import stream from music21 import note @@ -1156,14 +1150,14 @@ def testFreezeThawVariant(self): # test Variant is in stream unused_v1 = c.parts.first().getElementsByClass(variant.Variant).first() - sf = freezeThaw.StreamFreezer(c, fastButUnsafe=True) + sf = StreamFreezer(c, fastButUnsafe=True) # sf.v = v d = sf.writeStr() # print(d) # print('thawing.') - st = freezeThaw.StreamThawer() + st = StreamThawer() st.openStr(d) s = st.stream # s.show('lily.pdf') @@ -1176,7 +1170,6 @@ def testFreezeThawVariant(self): def testSerializationScaffoldA(self): from music21 import note from music21 import stream - from music21 import freezeThaw n1 = note.Note() @@ -1186,7 +1179,7 @@ def testSerializationScaffoldA(self): s1.append(n1) s2.append(n1) - sf = freezeThaw.StreamFreezer(s2, fastButUnsafe=False) + sf = StreamFreezer(s2, fastButUnsafe=False) sf.setupSerializationScaffold() # test safety @@ -1213,9 +1206,9 @@ def testPickleMidi(self): from music21 import note a = str(common.getSourceFilePath() - / 'midi' - / 'testPrimitive' - / 'test03.mid') + / 'midi' + / 'testPrimitive' + / 'test03.mid') # a = 'https://github.com/ELVIS-Project/vis/raw/master/test_corpus/prolationum-sanctus.midi' c = converter.parse(a) diff --git a/music21/graph/__init__.py b/music21/graph/__init__.py index aa362942d6..6e86c6f193 100644 --- a/music21/graph/__init__.py +++ b/music21/graph/__init__.py @@ -134,7 +134,6 @@ def plotStream( .. image:: images/HorizontalBarPitchSpaceOffset.* :width: 600 - ''' plotMake = findPlot.getPlotsToMake(graphFormat, xValue, yValue, zValue) # environLocal.printDebug(['plotClassName found', plotMake]) diff --git a/music21/graph/axis.py b/music21/graph/axis.py index d35833ec60..b2c20cb6ea 100644 --- a/music21/graph/axis.py +++ b/music21/graph/axis.py @@ -121,7 +121,6 @@ def _reprInternal(self): >>> axStream = graph.axis.DynamicsAxis(s, axisName='y') >>> axStream - ''' c = self.client if c is not None: @@ -315,9 +314,9 @@ class PitchAxis(Axis): labelDefault = 'Pitch' quantities: tuple[str, ...] = ('pitchGeneric', ) - def __init__(self, client=None, axisName='x'): + def __init__(self, client=None, axisName='x') -> None: super().__init__(client, axisName) - self.showOctaves = 'few' + self.showOctaves: bool|t.Literal['few'] = 'few' self.showEnharmonic = True self.blankLabelUnused = True self.hideUnused = True @@ -425,7 +424,7 @@ def unweightedSortHelper(x): sub.append(accidentalLabelToUnicode(name)) label = '/'.join(sub) - if self.showOctaves is False: + if not self.showOctaves: label = re.sub(r'\d', '', label) elif self.showOctaves == 'few': matchOctave = re.search(r'\d', label) @@ -450,8 +449,8 @@ class PitchClassAxis(PitchAxis): labelDefault = 'Pitch Class' quantities: tuple[str, ...] = ('pitchClass', 'pitchclass', 'pc') - def __init__(self, client=None, axisName='x'): - self.showOctaves = False + def __init__(self, client=None, axisName='x') -> None: + self.showOctaves: bool|t.Literal['few'] = False super().__init__(client, axisName) self.minValue = 0 self.maxValue = 11 @@ -1034,7 +1033,6 @@ def getOffsetMap(self): >>> om3 = ax.getOffsetMap() >>> om3 {} - ''' s = self.stream if s is None: @@ -1127,9 +1125,9 @@ class QuarterLengthAxis(PositionAxis): 'duration', ) - def __init__(self, client=None, axisName='x'): + def __init__(self, client=None, axisName='x') -> None: super().__init__(client, axisName) - self.useLogScale = True + self.useLogScale: bool|int = True self.useDurationNames = False def extractOneElement(self, n, formatDict): @@ -1322,7 +1320,6 @@ def ticks(self): >>> ax.maxValue = 6 >>> ax.ticks() [(3, '$ppp$'), (4, '$pp$'), (5, '$p$'), (6, '$mp$')] - ''' ticks = [] if self.minValue is None: diff --git a/music21/graph/findPlot.py b/music21/graph/findPlot.py index dee2df5c91..7dfd5e222d 100644 --- a/music21/graph/findPlot.py +++ b/music21/graph/findPlot.py @@ -109,7 +109,6 @@ def getAxisQuantities(synonyms=False, axesToCheck=None): >>> graph.findPlot.getAxisQuantities(True, axesToCheck=theseAxes) ['count', 'quantity', 'frequency', 'counting', 'offset', 'measure', 'offsets', 'measures', 'time'] - ''' if axesToCheck is None: axesToCheck = getAxisClasses() @@ -167,7 +166,6 @@ def getPlotClassesFromFormat(graphFormat, checkPlotClasses=None): >>> pcs = [graph.plot.ScatterWeighted, graph.plot.Dolan] >>> graph.findPlot.getPlotClassesFromFormat('scatterweighted', pcs) [] - ''' graphFormat = userFormatsToFormat(graphFormat).lower() @@ -306,7 +304,6 @@ def getPlotsToMake(graphFormat: str|None = None, OrderedDict({'x': , 'y': , 'z': }))] - ''' def _bestPlotType(graphClassesToChooseFrom): # now get the best graph type from this possibly motley list diff --git a/music21/graph/plot.py b/music21/graph/plot.py index eb87a027e9..0584280628 100644 --- a/music21/graph/plot.py +++ b/music21/graph/plot.py @@ -671,7 +671,6 @@ class HistogramPitchClass(Histogram): .. image:: images/HistogramPitchClass.* :width: 600 - ''' axesClasses: dict[str, type[axis.Axis]] = { **Histogram.axesClasses, @@ -699,7 +698,6 @@ class HistogramQuarterLength(Histogram): .. image:: images/HistogramQuarterLength.* :width: 600 - ''' axesClasses: dict[str, type[axis.Axis]] = { **Histogram.axesClasses, @@ -779,7 +777,6 @@ class ScatterWeightedPitchClassQuarterLength(ScatterWeighted): .. image:: images/ScatterWeightedPitchClassQuarterLength.* :width: 600 - ''' axesClasses: dict[str, type[axis.Axis]] = { **ScatterWeighted.axesClasses, @@ -814,7 +811,6 @@ class ScatterWeightedPitchSpaceDynamicSymbol(ScatterWeighted): .. image:: images/ScatterWeightedPitchSpaceDynamicSymbol.* :width: 600 - ''' axesClasses: dict[str, type[axis.Axis]] = { **ScatterWeighted.axesClasses, @@ -1009,7 +1005,6 @@ class WindowedKey(WindowedAnalysis): >>> p.processorClass = analysis.discrete.TemperleyKostkaPayne >>> p.doneAction = None #_DOCS_HIDE >>> p.run() - ''' processorClassDefault = discrete.KrumhanslSchmuckler @@ -1027,7 +1022,6 @@ class WindowedAmbitus(WindowedAnalysis): :width: 600 .. image:: images/legend-WindowedAmbitus.* - ''' processorClassDefault = discrete.Ambitus @@ -1174,7 +1168,6 @@ class HorizontalBarPitchClassOffset(HorizontalBar): .. image:: images/HorizontalBarPitchClassOffset.* :width: 600 - ''' axesClasses: dict[str, type[axis.Axis]] = { **HorizontalBar.axesClasses, @@ -1305,7 +1298,6 @@ class Dolan(HorizontalBarWeighted): .. image:: images/Dolan.* :width: 600 - ''' def __init__(self, streamObj=None, **keywords): @@ -1604,7 +1596,7 @@ def testHorizontalBarPitchClassOffset(self): a = corpus.parse('bach/bwv57.8') b = HorizontalBarPitchClassOffset(a.parts[0].measures(3, 6), - title='Bach (soprano voice, mm 3-6)') + title='Bach (soprano voice, mm 3-6)') b.run() def testScatterWeightedPitchSpaceQuarterLength(self): @@ -1799,17 +1791,18 @@ def testWindowed(self, doneAction=None): minWindow=1, windowStep=windowStep, doneAction=doneAction, dpi=300) b.run() - self.assertEqual(b.graphLegend.data, + self.assertEqual( + b.graphLegend.data, [ ['Major', - [('C#', '#f0727a'), ('D', '#ffd752'), ('E', '#eeff9a'), - ('F#', '#b9f0ff'), ('A', '#bb9aff'), ('B', '#ffb5ff') - ] + [('C#', '#f0727a'), ('D', '#ffd752'), ('E', '#eeff9a'), + ('F#', '#b9f0ff'), ('A', '#bb9aff'), ('B', '#ffb5ff') + ] ], ['Minor', - [('c#', '#8c0e16'), ('', '#ffffff'), ('', '#ffffff'), - ('f#', '#558caa'), ('', '#ffffff'), ('b', '#9b519b') - ] + [('c#', '#8c0e16'), ('', '#ffffff'), ('', '#ffffff'), + ('f#', '#558caa'), ('', '#ffffff'), ('b', '#9b519b') + ] ] ] ) diff --git a/music21/graph/primitives.py b/music21/graph/primitives.py index 526c2dc4b5..fe460b19ea 100644 --- a/music21/graph/primitives.py +++ b/music21/graph/primitives.py @@ -739,7 +739,6 @@ class GraphColorGridLegend(Graph): .. image:: images/GraphColorGridLegend.* :width: 600 - ''' _DOC_ATTR: dict[str, str] = { 'hideLeftBottomSpines': 'bool to hide the left and bottom axis spines; default True', @@ -914,7 +913,6 @@ class GraphHorizontalBar(Graph): To make an equally spaced plot, like in a Pitch Space plot, leave empty data in the form: `('', [], {})` - ''' _DOC_ATTR: dict[str, str] = { 'barSpace': 'Amount of vertical space each bar takes; default 8', @@ -1190,7 +1188,6 @@ class GraphScatterWeighted(Graph): .. image:: images/GraphScatterWeighted.* :width: 600 - ''' _DOC_ATTR: dict[str, str] = { 'maxDiameter': 'the maximum diameter of any ellipse, default 1.25', diff --git a/music21/harmony.py b/music21/harmony.py index 3acc12e99a..5bfab116ba 100644 --- a/music21/harmony.py +++ b/music21/harmony.py @@ -7,7 +7,7 @@ # Jacob Tyler Walls # Christopher Ariza # -# Copyright: Copyright © 2011-2024 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2011-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -171,10 +171,10 @@ class Harmony(chord.Chord): >>> h = harmony.ChordSymbol() >>> h.root('B-3') >>> h.bass('D', allow_add=True) - >>> h.inversion(1, transposeOnSet=False) + >>> h.setInversion(1, transpose=False, inPlace=True) >>> h.addChordStepModification(harmony.ChordStepModification('add', 4)) >>> h - + >>> h = harmony.ChordSymbol('C7/E') >>> h.root() @@ -304,7 +304,7 @@ def _updateFromParameters( # set inversion first... if inversion is not None: - self.inversion(inversion, transposeOnSet=True) + self.setInversion(inversion, inPlace=True) # and then bass. if bass and isinstance(bass, str): @@ -925,12 +925,12 @@ def chordSymbolFigureFromChord(inChord: chord.Chord, includeChordType=False): >>> harmony.chordSymbolFigureFromChord(c, True) ('Fob9', 'diminished-minor-ninth') - This harmony can either be CmaddD or Csus2addE-. music21 prefers the former. + This harmony can either be Cmadd9 or Csus2addb3. music21 prefers the former. Change the ordering of harmony.CHORD_TYPES to switch the preference. From Bach BWV380 >>> c = chord.Chord(['C3', 'D4', 'G4', 'E-5']) >>> harmony.chordSymbolFigureFromChord(c, True) - ('CmaddD', 'minor') + ('Cmadd9', 'minor') ELEVENTHS @@ -1072,7 +1072,7 @@ def chordSymbolFigureFromChord(inChord: chord.Chord, includeChordType=False): Chord Symbol Cannot Be Identified B-/D B-7 - CmaddD + Cmadd9 Cm/D E-+M7/D Cm/E- @@ -1150,7 +1150,7 @@ def chordSymbolFigureFromChord(inChord: chord.Chord, includeChordType=False): although certain subtractions are permitted for example a 9th chord will still be identified correctly even if it is missing the 5th 5. the type with the most identical matches is used, and if no type matches, - "Chord Type Cannot Be Identified" is returned + "Chord Symbol Cannot Be Identified" is returned 6. the output format for the chord symbol figure is the chord's root, the chord type's Abbreviation (saved in CHORD_TYPES dictionary), a '/' if the chord is in an inversion, and the chord's bass @@ -1177,8 +1177,12 @@ def chordSymbolFigureFromChord(inChord: chord.Chord, includeChordType=False): OMIT_FROM_DOCS >>> harmony.changeAbbreviationFor('major', '') - ''' + # TODO: known misses to fix in this routine: + # E2 B2 G#3 D4 G4 (E7#9 with the #9 spelled G natural) gives 'E7addb3', which reads + # back as E G B D, since 'add' on a degree the chord already has replaces it. + # D3 F#3 C4 E4 G#4 B4 (D13#11 without its 5th) gives 'C+M9add#11/D': root found as C. + # A2 G3 C#4 D#4 (A7#11 without its 5th) gives 'Chord Symbol Cannot Be Identified'. if not inChord.pitches: return '' @@ -1212,7 +1216,6 @@ def compare(inChordNums, givenChordNums, permittedOmissions=()): The corresponding semitones are compared, and if they do not match it is determined whether this is a permitted omission, etc. - ''' m = len(givenChordNums) if m > len(inChordNums): @@ -1335,32 +1338,53 @@ def convertFBNotationStringToDegrees(innerKind, fbNotation): kindStr = chordKindStr[0] if kind: + slashBass = '' # new algorithm makes sus chord be a sus2 in inversion: if inChord.inversion(): if kindStr == 'sus2': inChord.root(inChord.bass()) kindStr = 'sus' kind = 'suspended-fourth' - cs = inChord.root().name + kindStr else: - cs = inChord.root().name + kindStr + '/' + inChord.bass().name - else: - cs = inChord.root().name + kindStr - perfect = {p.name for p in ChordSymbol(cs).pitches} + slashBass = '/' + inChord.bass().name + cs = inChord.root().name + kindStr + perfect = {p.name for p in ChordSymbol(cs + slashBass).pitches} inPitches = {p.name for p in inChord.pitches} if not perfect.issuperset(inPitches): # must be subtraction or deletion. - additions = inPitches.difference(perfect) - subtractions = perfect.difference(inPitches) - if additions: - cs += 'add' - for a in additions: - cs += (a + ',') - if subtractions: - cs += 'omit' - for s in subtractions: - cs += (s + ',') - cs = cs[:-1] + from music21 import scale + # using G B D F A♭ C E♭ as example (a type of G13) + # start with cs = G7 since it just deals with simple strings + # as on lead sheets, alterations are from the major scale on the root (over G, F is b7) + majorScale = scale.MajorScale(inChord.root()) + # for G7 would be {1, 3, 5, 7}, + # note that kind ninth adds a 9. Only suspended-fourth would have 4, etc. + kindDegrees = {int(d.strip('-#A')) # A = Altered, currently unused + for d in getNotationStringGivenChordType(kind).split(',')} + + def degreeAndAlter(name: str) -> tuple[int, int]: + degree, accidental = majorScale.getScaleDegreeAndAccidentalFromPitch( + pitch.Pitch(name)) + assert degree is not None # a major scale has every letter name + # omit must match the kind's own number: Db F# Ab is Db sus4 omit4, not omit11 + if degree in (2, 4, 6) and degree not in kindDegrees: + degree += 7 + return (degree, int(accidental.alter) if accidental is not None else 0) + + # pitches that are not included in the simple, perfect version, in this case, Ab C Eb + # returned as a tuple of (degree, alter) so [(9, -1), (11, 0), (13, -1)]. + missing_pitches = inPitches.difference(perfect) + additions = sorted(degreeAndAlter(name) for name in missing_pitches) + + # same thing for omitted pitches + omitted_pitches = perfect.difference(inPitches) + subtractions = sorted(degreeAndAlter(name) for name in omitted_pitches) + + for degree, alter in additions: + cs += 'add' + alterToChordSymbolString(alter) + str(degree) + for degree, unused_alter in subtractions: + cs += 'omit' + str(degree) + cs += slashBass # bass comes last: Am7add11/C else: cs = 'Chord Symbol Cannot Be Identified' if includeChordType: @@ -1369,6 +1393,24 @@ def convertFBNotationStringToDegrees(innerKind, fbNotation): return cs +def alterToChordSymbolString(alter: int) -> str: + ''' + Return the chord-symbol spelling of an alteration in semitones. + + >>> harmony.alterToChordSymbolString(-1) + 'b' + >>> harmony.alterToChordSymbolString(2) + '##' + >>> harmony.alterToChordSymbolString(0) + '' + + * New in v11. + ''' + if alter < 0: + return 'b' * -alter + return '#' * alter + + def chordSymbolFromChord(inChord: chord.Chord) -> ChordSymbol: ''' Get the :class:`~music21.harmony.chordSymbol` object from the chord, using @@ -1393,33 +1435,30 @@ def getAbbreviationListGivenChordType(chordType): >>> harmony.getAbbreviationListGivenChordType('minor-major-13th') ['mM13', 'minmaj13'] - ''' return CHORD_TYPES[chordType][1] -def getCurrentAbbreviationFor(chordType): +def getCurrentAbbreviationFor(chordType: str) -> str: ''' Return the current Abbreviation for a given :class:`music21.harmony.ChordSymbol` chordType: >>> harmony.getCurrentAbbreviationFor('dominant-seventh') '7' - ''' return getAbbreviationListGivenChordType(chordType)[0] -def getNotationStringGivenChordType(chordType): +def getNotationStringGivenChordType(chordType: str) -> str: ''' Get the notation string (fb-notation style) associated with this :class:`music21.harmony.ChordSymbol` chordType >>> harmony.getNotationStringGivenChordType('German') '1,-3,#4,-6' - ''' - return CHORD_TYPES[chordType][0] + return t.cast(str, CHORD_TYPES[chordType][0]) def removeChordSymbols(chordType): @@ -1611,7 +1650,7 @@ class ChordSymbol(Harmony): important: we are not asking for transposition, merely specifying the inversion that the chord should be read in (transposeOnSet = False) - >>> cs.inversion(2, transposeOnSet=False) + >>> cs.setInversion(2, transpose=False, inPlace=True) >>> cs.romanNumeral = 'I64' >>> cs.chordKind = 'major' @@ -1788,9 +1827,7 @@ def typeSubtract(hD): pitchFound = False for p, degree in zip(pitches, degrees): - degree = degree.replace('-', '') - degree = degree.replace('#', '') - degree = degree.replace('A', '') # A is for 'Altered' + degree = degree.strip('-#A') # A is for 'Altered', currently unused if hD.degree == int(degree): pitches.remove(p) pitchFound = True @@ -1814,9 +1851,7 @@ def typeAlter(hD): degrees = self._degreesList for p, degree in zip(pitches, degrees): - degree = degree.replace('-', '') - degree = degree.replace('#', '') - degree = degree.replace('A', '') # A is for 'Altered' + degree = degree.strip('-#A') # A is for 'Altered', currently unused if hD.degree == int(degree): # transpose by semitones (positive for up, negative for down) p = p.transpose(hD.interval, inPlace=True) @@ -1869,11 +1904,8 @@ def _parseAddAlterSubtract(self, remaining: str, modType: str) -> str: # Remove modType remaining = remaining[startIndex:] - if remaining[:1] == 'b': - alter = -1 - remaining = remaining[1:] - elif remaining[:1] == '#': - alter = 1 + while remaining[:1] in ('b', '-', '#'): + alter += 1 if remaining[0] == '#' else -1 remaining = remaining[1:] # 11, 13, etc. if remaining[:2].isnumeric(): @@ -2171,7 +2203,7 @@ def _updatePitches(self): self._overrides['bass'].octave = 2 pitches.append(self._overrides['bass']) else: - self.inversion(None, transposeOnSet=False) + self.setInversion(None, inPlace=True) inversionNum = None pitches = self._adjustPitchesForChordStepModifications(pitches) @@ -2262,6 +2294,8 @@ def findFigure(self): + * Changed in v11: the bass comes last, after any additions, as in 'C add 2/B-'. + OMIT_FROM_DOCS >>> from xml.etree.ElementTree import fromstring as EL @@ -2291,7 +2325,7 @@ def findFigure(self): >>> cs = MP.xmlToChordSymbol(mxHarmony) >>> print(cs.figure) - C/B- add 2 + C add 2/B- >>> cs.pitches (, @@ -2314,23 +2348,18 @@ def findFigure(self): if kind in CHORD_TYPES: figure += getAbbreviationListGivenChordType(kind)[0] - if self.bass() is not None: - if self.root().name != self.bass().name: - figure += '/' + self.bass().name for csMod in self.chordStepModifications: if csMod.interval is not None: - numAlter = csMod.interval.semitones - if numAlter > 0: - s = '#' - else: - s = 'b' - prefix = s * abs(numAlter) - + prefix = alterToChordSymbolString(csMod.interval.semitones) figure += ' ' + csMod.modType + ' ' + prefix + str(csMod.degree) else: figure += ' ' + csMod.modType + ' ' + str(csMod.degree) + if self.bass() is not None: + if self.root().name != self.bass().name: + figure += '/' + self.bass().name + return figure else: # if neither chordKind nor chordStepModifications, best bet is probably to @@ -2634,8 +2663,7 @@ def realizeChordSymbolDurations(piece): class Test(unittest.TestCase): def testChordAttributes(self): - from music21 import harmony - cs = harmony.ChordSymbol('Cm') + cs = ChordSymbol('Cm') self.assertEqual(str(cs), '') self.assertEqual( str(cs.pitches), @@ -2644,18 +2672,16 @@ def testChordAttributes(self): self.assertTrue(cs.isConsonant()) def testBasic(self): - from music21 import harmony - h = harmony.Harmony() - hd = harmony.ChordStepModification('add', 4) + h = Harmony() + hd = ChordStepModification('add', 4) h.addChordStepModification(hd) self.assertEqual(len(h.chordStepModifications), 1) def testChordKindSetting(self): - from music21 import harmony - cs = harmony.ChordSymbol() + cs = ChordSymbol() cs.root('E-') cs.bass('B-', allow_add=True) - cs.inversion(2, transposeOnSet=False) + cs.setInversion(2, transpose=False, inPlace=True) cs.romanNumeral = 'I64' cs.chordKind = 'major' cs.chordKindStr = 'M' @@ -2684,10 +2710,6 @@ def testHarmonyPreservesInversionAndBass(self): self.assertEqual(explicitFm6.root(find=False).name, 'F') fm6bassOctave = explicitFm6.bass(find=False).octave fm6rootOctave = explicitFm6.root(find=False).octave - self.assertIsNotNone(fm6bassOctave) - self.assertIsNotNone(fm6rootOctave) - assert fm6bassOctave is not None - assert fm6rootOctave is not None self.assertLess(fm6bassOctave, fm6rootOctave) def testClassSortOrderHarmony(self): @@ -2717,38 +2739,37 @@ def testClassSortOrderHarmony(self): self.assertIs(n.getContextByClass('ChordSymbol'), cs) def testNoChord(self): - from music21 import harmony - nc = harmony.NoChord() + nc = NoChord() self.assertEqual('none', nc.chordKind) self.assertEqual('N.C.', nc.chordKindStr) self.assertEqual('N.C.', nc.figure) - nc = harmony.NoChord('NC') + nc = NoChord('NC') self.assertEqual('none', nc.chordKind) self.assertEqual('NC', nc.chordKindStr) self.assertEqual('NC', nc.figure) - nc = harmony.NoChord('None') + nc = NoChord('None') self.assertEqual('none', nc.chordKind) self.assertEqual('None', nc.chordKindStr) self.assertEqual('None', nc.figure) - nc = harmony.NoChord(kind='none') + nc = NoChord(kind='none') self.assertEqual('none', nc.chordKind) self.assertEqual('N.C.', nc.chordKindStr) self.assertEqual('N.C.', nc.figure) - nc = harmony.NoChord(kindStr='No Chord') + nc = NoChord(kindStr='No Chord') self.assertEqual('none', nc.chordKind) self.assertEqual('No Chord', nc.chordKindStr) self.assertEqual('No Chord', nc.figure) - nc = harmony.NoChord('NC', kindStr='No Chord') + nc = NoChord('NC', kindStr='No Chord') self.assertEqual('none', nc.chordKind) self.assertEqual('No Chord', nc.chordKindStr) self.assertEqual('NC', nc.figure) - nc = harmony.NoChord(root='C', bass='E', kind='none') + nc = NoChord(root='C', bass='E', kind='none') self.assertEqual('N.C.', nc.chordKindStr) self.assertEqual('N.C.', nc.figure) @@ -2761,9 +2782,8 @@ def testNoChord(self): self.assertEqual(0, len(nc.pitches)) def testInvalidRoots(self): - from music21 import harmony with self.assertRaises(ValueError) as context: - harmony.ChordSymbol('H-7') + ChordSymbol('H-7') self.assertEqual( str(context.exception), @@ -2772,7 +2792,7 @@ def testInvalidRoots(self): with self.assertRaises(ValueError) as context: # noinspection SpellCheckingInspection - harmony.ChordSymbol('Garg7') + ChordSymbol('Garg7') self.assertEqual( str(context.exception), @@ -2782,9 +2802,8 @@ def testInvalidRoots(self): ) def testInvalidSymbol(self): - from music21 import harmony c = chord.Chord(('A#', 'C', 'E')) - cs = harmony.chordSymbolFromChord(c) + cs = chordSymbolFromChord(c) self.assertEqual(cs.figure, 'Chord Symbol Cannot Be Identified') def testRegexEdgeCases(self): @@ -3253,19 +3272,53 @@ def testUpdatePitchesFalse(self): # No other pitches are created self.assertEqual(h.pitches, (bass_note,)) + def testFigureFromChordAddOmit(self): + for pitchString, expected in [ + ('G3 B3 D4 F4 A-4 C5 E-5', 'G7addb9add11addb13'), + ('E2 B2 G#3 D4 F##4', 'E7add#9'), + ('B-2 F3 A3 D4 E4', 'B-maj7add#11'), + ('A-2 E-3 G-3 C4 D4 B-4', 'A-9add#11'), + ('A2 E3 A3 B3 C4 E4', 'Amadd9'), + # a 2nd is 9 whether close or wide + ('D4 E4 F#4 A4', 'Dadd9'), + ('D3 A3 F#4 E5', 'Dadd9'), + # a full stack of thirds from C#: C# E G B D F A + ('G B D F A C# E', 'C#øb9addb11addb13'), + ('C E G B- D- F#', 'C7addb9add#11'), + # C6/9 is read as Am7 over C, so D is the 11 of A + ('C E G A D', 'Am7add11/C'), + # Db is the root respelled (bb9), and the sus4 has no 5th + ('C# F# B D-', 'C#susaddb7addbb9omit5'), + # Sounds like Db sus4 (F# = Gb), so the chord type is sus4. F# is spelled as a + # raised 3rd (add#3), and the sus4's own Gb is omitted as omit4, not omit11, + # because omit must use the number the chord type gives that tone. + ('D-3 F#3 A-3', 'D-susadd#3omit4'), + # Likewise F#m6 with its D# spelled Eb (a doubly flat 7th): omit6, not omit13. + ('F#2 C#3 E-3 A3', 'F#m6addbb7omit6'), + ]: + c = chord.Chord(pitchString) + figure = chordSymbolFigureFromChord(c) + self.assertEqual(figure, expected) + self.assertEqual({p.name for p in ChordSymbol(figure).pitches}, + {p.name for p in c.pitches}) + + def testParseAddAccidentals(self): + for figure in ('G7addb9', 'G7add-9'): + self.assertIn('A-', [p.name for p in ChordSymbol(figure).pitches]) + self.assertIn('A--', [p.name for p in ChordSymbol('G7addbb9').pitches]) + class TestExternal(unittest.TestCase): def testReadInXML(self): - from music21 import harmony from music21 import corpus from music21 import stream testFile = corpus.parse('leadSheet/fosterBrownHair.xml') # testFile.show('text') - testFile = harmony.realizeChordSymbolDurations(testFile) + testFile = realizeChordSymbolDurations(testFile) # testFile.show() - chordSymbols = testFile.flatten().getElementsByClass(harmony.ChordSymbol) + chordSymbols = testFile.flatten().getElementsByClass(ChordSymbol) s = stream.Stream() for cS in chordSymbols: @@ -3277,7 +3330,6 @@ def testReadInXML(self): # self.assertEqual(len(csChords), 40) def testChordRealization(self): - from music21 import harmony from music21 import corpus from music21 import note from music21 import stream @@ -3288,8 +3340,8 @@ def testChordRealization(self): # tests, and adjust 57 accordingly testFile = corpus.parse('demos/ComprehensiveChordSymbolsTestFile.xml') - testFile = harmony.realizeChordSymbolDurations(testFile) - chords = testFile.flatten().getElementsByClass(harmony.ChordSymbol) + testFile = realizeChordSymbolDurations(testFile) + chords = testFile.flatten().getElementsByClass(ChordSymbol) # testFile.show() s = stream.Stream() # i = 0 diff --git a/music21/humdrum/harmParser.py b/music21/humdrum/harmParser.py index 7cbc8c5a5f..7f2d76e691 100644 --- a/music21/humdrum/harmParser.py +++ b/music21/humdrum/harmParser.py @@ -186,14 +186,14 @@ class HarmDefs: ''' # The definition for a harm expr harmExpression = (r'^(' - + accidental - + roots - + attribute - + intervals - + inversion - + alternative - + secondary - + r')$') + + accidental + + roots + + attribute + + intervals + + inversion + + alternative + + secondary + + r')$') class HarmParser: diff --git a/music21/humdrum/instruments.py b/music21/humdrum/instruments.py index de4d3a6ac2..e658110f45 100644 --- a/music21/humdrum/instruments.py +++ b/music21/humdrum/instruments.py @@ -199,9 +199,9 @@ 'marac': 'Maracas', 'marim': 'Marimba', 'piatt': 'Cymbals', # ; piatti (It.); cymbales (Fr.); Becken (Ger.); kymbos (Gk.) - 'ridec': 'RideCymbals', # (kit) + 'ridec': 'RideCymbal', # (kit) 'sdrum': 'SnareDrum', # (kit) - 'spshc': 'SplashCymbals', # (kit) + 'spshc': 'SplashCymbal', # (kit) 'steel': 'SteelDrum', # , tinpanny # *Itabla tabla 'tambn': 'Tambourine', # , timbrel; tamburino (It.); Tamburin (Ger.) diff --git a/music21/humdrum/spineParser.py b/music21/humdrum/spineParser.py index 504532c817..73a9c9e0c3 100644 --- a/music21/humdrum/spineParser.py +++ b/music21/humdrum/spineParser.py @@ -165,7 +165,7 @@ def __init__(self, dataStream: str|list[str]) -> None: self.protoSpines: list[ProtoSpine] = [] self.eventCollections: list[EventCollection] = [] self.spineCollection: SpineCollection|None = None - # populated by insertGlobalEvents(), consumed by parseMetadata() + # populated by insertGlobalEvents(), used by parseMetadata() self.globalReferences: list[GlobalReference] = [] if isinstance(dataStream, str): @@ -364,12 +364,19 @@ def parseOpusDataCollections(self, dataCollections: list[list[str]]) -> stream.O hdc = HumdrumDataCollection(dc) sc = hdc.parse() sc.id = 'section_' + str(i + 1) - sc.metadata.number = i + 1 + scoreMetadata = sc.metadata + if scoreMetadata is None: + scoreMetadata = metadata.Metadata() + sc.insert(0, scoreMetadata) + scoreMetadata.number = str(i + 1) opus.append(sc) if dataCollections: - opus.metadata = copy.deepcopy(opus.scores[0].metadata) - opus.metadata.number = 0 + opusMetadata = copy.deepcopy(opus.scores[0].metadata) + if opusMetadata is None: + opusMetadata = metadata.Metadata() + opusMetadata.number = '0' + opus.metadata = opusMetadata self.stream = opus return opus @@ -962,8 +969,8 @@ class GlobalReferenceLine(HumdrumLine): TODO: add parsing of three-digit Kern comment codes into fuller metadata TODO: parse ``@``/``@@`` language tags here (e.g. ``!!!OPT@@RUS:``, ``!!!OPT@FRE:``) - and propagate language/isPrimary so that GlobalReference.updateMetadata - can attach ``metadata.Text(value, language=...)`` instead of bare strings. + and propagate language/isPrimary so that GlobalReference.updateMetadata + can attach ``metadata.Text(value, language=...)`` instead of bare strings. ''' def __init__(self, lineNumber: int = 0, contents: str = '!!! NUL: None') -> None: self.lineNumber = lineNumber @@ -1161,6 +1168,7 @@ def __init__( self._spineType: str = '' self.isFirstVoice: bool = False + self.voiceNumber: int = 0 def _reprInternal(self) -> str: representation = ': ' + str(self.id) @@ -1198,7 +1206,8 @@ def _getParentSpineType(self) -> str: return '' return parentSpine.spineType - def _getSpineType(self) -> str: + @property + def spineType(self) -> str: ''' Searches the current and parent spineType. ''' @@ -1215,11 +1224,10 @@ def _getSpineType(self) -> str: raise HumdrumException('Could not determine spineType ' + 'for spine with id ' + str(self.id)) - def _setSpineType(self, newSpineType: str = '') -> None: + @spineType.setter + def spineType(self, newSpineType: str = '') -> None: self._spineType = newSpineType - spineType = property(_getSpineType, _setSpineType) - def moveElementsIntoMeasures(self, streamIn: stream.Stream) -> stream.Stream: # noinspection PyShadowingNames ''' @@ -1904,9 +1912,14 @@ def performInsertions(self) -> None: sub-spines and put them in their proper location. ''' for thisSpine in self.spines: + if thisSpine.parentSpine is None: + self.numberVoices(thisSpine, 1) + + def depth(spine: HumdrumSpine) -> int: + return 0 if spine.parentSpine is None else depth(spine.parentSpine) + 1 + + for thisSpine in sorted(self.spines, key=depth, reverse=True): # removeSpines = [] - if thisSpine.parentSpine is not None: - continue if not thisSpine.childSpines: continue @@ -1924,7 +1937,8 @@ def performInsertions(self) -> None: self.performSpineInsertion(thisSpine, newStream, i) insertPoints.remove(i) lastLineNumber = lineNumber - del self.humdrumLineNumbers[el] + if thisSpine.parentSpine is None: + del self.humdrumLineNumbers[el] newStream.coreAppend(el) # newStream.append(el) newStream.coreElementsChanged() @@ -1939,6 +1953,47 @@ def performInsertions(self) -> None: # # needed for some tests # self.removeSpineById(removeMe) + def numberVoices(self, thisSpine: HumdrumSpine, nextNumber: int) -> int: + ''' + Sets the `voiceNumber` of every sub-spine below `thisSpine` and + returns the next voice number not yet used. + + The sub-spines of a top-level spine are voices 1, 2, ... at each + split. When a sub-spine splits again, its first sub-spine keeps + its voice number and the others get new numbers from `nextNumber` on. + + Here spine 0 splits into spines 1 and 2, and spine 2 splits again + into spines 3 and 4: + + >>> sc = humdrum.spineParser.SpineCollection() + >>> spines = [sc.addSpine() for i in range(5)] + >>> for child in spines[1:3]: + ... child.parentSpine = spines[0] + >>> for child in spines[3:5]: + ... child.parentSpine = spines[2] + >>> spines[0].childSpineInsertPoints[5] = (spines[1], spines[2]) + >>> spines[2].childSpineInsertPoints[6] = (spines[3], spines[4]) + >>> sc.numberVoices(spines[0], 1) + 4 + >>> [s.voiceNumber for s in spines[1:]] + [1, 2, 2, 3] + + AI-assisted (Claude). + ''' + afterAll = nextNumber + for childSpines in thisSpine.childSpineInsertPoints.values(): + if thisSpine.parentSpine is None: + numbers = list(range(1, len(childSpines) + 1)) + after = len(childSpines) + 1 + else: + after = nextNumber + len(childSpines) - 1 + numbers = [thisSpine.voiceNumber] + list(range(nextNumber, after)) + for child, number in zip(childSpines, numbers): + child.voiceNumber = number + after = self.numberVoices(child, after) + afterAll = max(afterAll, after) + return afterAll + def performSpineInsertion( self, thisSpine: HumdrumSpine, @@ -1952,16 +2007,15 @@ def performSpineInsertion( newStream.coreElementsChanged() # update highestTime startPoint = newStream.highestTime childrenToInsert = thisSpine.childSpineInsertPoints[insertionPoint] - voiceNumber = 0 for insertSpine in childrenToInsert: # removeSpines.append(insertSpine.id) - voiceNumber += 1 - voiceStr = 'voice' + str(voiceNumber) + voiceStr = 'voice' + str(insertSpine.voiceNumber) for insertEl in insertSpine.stream: if not insertSpine.isFirstVoice and isinstance(insertEl, stream.Measure): pass # only insert one measure object per spine else: - insertEl.groups.append(voiceStr) + if not insertEl.groups: + insertEl.groups.append(voiceStr) # newStream.insert(startPoint + insertEl.offset, insertEl) newStream.coreInsert(startPoint + insertEl.offset, insertEl) newStream.coreElementsChanged() # call between coreInsert and coreAppend @@ -2412,7 +2466,7 @@ def hdStringToDuration(contents: str) -> duration.Duration: (_"92.." is the correct ``**kern`` encoding for a note whose duration is 23 notes in the time of a doubly-dotted quarter._) - We will call the second interpretation the "JRP Flavor". The default JRP Flavor is False:: + We will call the second interpretation the "JRP Flavor". The default JRP Flavor is False: >>> humdrum.spineParser.flavors['JRP'] False @@ -2558,7 +2612,7 @@ def hdStringToNote( >>> n.fullName 'D-double-sharp in octave 5 Eighth Triplet (1/3 QL) Note' - If `overrideDuration` is passed in as a Duration object, that is used no matter what:: + If `overrideDuration` is passed in as a Duration object, that is used no matter what: >>> n = humdrum.spineParser.hdStringToNote('12dd##', ... overrideDuration=duration.Duration('whole')) @@ -2576,7 +2630,7 @@ def hdStringToNote( 'B-double-flat in octave 1 Eighth Note' Otherwise, it uses a quarter note and gives a warning. This may become an error in - future versions:: + future versions: >>> import warnings >>> with warnings.catch_warnings(record=True) as w: diff --git a/music21/humdrum/testFiles.py b/music21/humdrum/testFiles.py index 55927f7f05..421890d9c3 100644 --- a/music21/humdrum/testFiles.py +++ b/music21/humdrum/testFiles.py @@ -93,7 +93,7 @@ ) ojibway = re.sub(r'\s\s\s\s+', '\t', - r''' + r''' !! Ojibway Indian Song !! Transcribed by Frances Densmore !! No. 84 "The Sioux Follow Me" diff --git a/music21/humdrum/tests.py b/music21/humdrum/tests.py index fdca40c268..d2e8f6eb57 100644 --- a/music21/humdrum/tests.py +++ b/music21/humdrum/tests.py @@ -34,6 +34,7 @@ GlobalReference, HumdrumDataCollection, KernSpine, + SpineCollection, SpineComment, SpineEvent, flavors, @@ -316,13 +317,69 @@ def testLyricsInSpine(self): def testSplitSpines2(self): ''' - Currently this does not work since a second split on a stream that - already resulted from a split does not parse properly. Shows up also - in strangeWTCOpening, below. + A spine split a second time after a split. ''' hf1 = HumdrumDataCollection(testFiles.splitLots) hf1.parse() - unused_masterStream = hf1.stream + self.assertEqual( + [(s.id, s.parentSpine.id, s.isFirstVoice, s.voiceNumber) + for s in hf1.spineCollection.spines if s.parentSpine is not None], + [(2, 0, True, 1), (3, 0, False, 2), + (4, 1, True, 1), (5, 1, False, 2), + (6, 5, True, 2), (7, 5, False, 3)], + ) + def voices(part): + return [(list(v.groups), [n.nameWithOctave for n in v.notes]) + for v in part.measure(2).voices] + + treble, bass = hf1.stream.parts + self.assertEqual( + voices(treble), + [(['voice1'], ['A#4']), (['voice2'], ['B#4']), (['voice3'], ['F#4'])], + ) + self.assertEqual( + voices(bass), + [(['voice1'], ['F3']), (['voice2'], ['G3'])], + ) + + def testNumberVoices(self): + ''' + Voice numbers given to sub-spines: a split, a merge and a second + split; a split in two places at once; and a split of a split of a split. + ''' + def split(sc: SpineCollection, parent, lineNumber): + children = (sc.addSpine(), sc.addSpine()) + for child in children: + child.parentSpine = parent + parent.childSpineInsertPoints[lineNumber] = children + return children + + sc = SpineCollection() + top = sc.addSpine() + first = split(sc, top, 3) + second = split(sc, top, 8) + self.assertEqual(sc.numberVoices(top, 1), 3) + self.assertEqual([s.voiceNumber for s in first + second], [1, 2, 1, 2]) + + sc = SpineCollection() + left, right = sc.addSpine(), sc.addSpine() + leftChildren = split(sc, left, 3) + rightChildren = split(sc, right, 3) + sc.numberVoices(left, 1) + sc.numberVoices(right, 1) + self.assertEqual([s.voiceNumber for s in leftChildren + rightChildren], [1, 2, 1, 2]) + + sc = SpineCollection() + top = sc.addSpine() + upper, lower = split(sc, top, 3) + lowerUpper, lowerLower = split(sc, lower, 4) + lowestUpper, lowestLower = split(sc, lowerLower, 5) + self.assertEqual(sc.numberVoices(top, 1), 5) + self.assertEqual( + [s.voiceNumber for s in (upper, lower, lowerUpper, lowerLower, + lowestUpper, lowestLower)], + [1, 2, 2, 3, 3, 4], + ) def testParseStrangeSplit(self): hf1 = HumdrumDataCollection(testFiles.strangeWTCOpening) @@ -466,6 +523,60 @@ def testSplitMergeImmediateResplit(self): ['F', 'G'], ) + def testNestedSplit(self): + krn = re.sub(r'\s\s\s\s+', '\t', r''' +**kern +*M2/4 +=1 +2c +=2 +*^ +2d 2e +*^ * +2f 2g 2a +*v *v * +*v *v +=3 +2b +*- +''') + hdc = HumdrumDataCollection(krn) + hdc.parse() + m2 = hdc.stream.parts[0].measure(2) + self.assertEqual( + [[(n.pitch.name, n.offset) for n in v.notes] for v in m2.voices], + [[('D', 0.0), ('F', 2.0)], [('E', 0.0), ('A', 2.0)], [('G', 2.0)]], + ) + + def testNestedSplitThreeLevels(self): + krn = re.sub(r'\s\s\s\s+', '\t', r''' +**kern +*M3/4 +=1 +*^ +4d 4e +*^ * +4f 4g 4a +*^ * * +4B 4c 4d 4e +*v *v * * +*v *v * +*v *v +=2 +2.c +*- +''') + hdc = HumdrumDataCollection(krn) + hdc.parse() + m1 = hdc.stream.parts[0].measure(1) + self.assertEqual( + [[(n.pitch.name, n.offset) for n in v.notes] for v in m1.voices], + [[('D', 0.0), ('F', 1.0), ('B', 2.0)], + [('E', 0.0), ('A', 1.0), ('E', 2.0)], + [('G', 1.0), ('D', 2.0)], + [('C', 2.0)]], + ) + def testDynamAttachedAligned(self): ''' ``**dynam`` events on the same line as a kern note attach at that note's diff --git a/music21/instrument.py b/music21/instrument.py index 24a61261ec..e569bd303d 100644 --- a/music21/instrument.py +++ b/music21/instrument.py @@ -9,7 +9,7 @@ # Ben Houge # Mark Gotham # -# Copyright: Copyright © 2009-2024 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2009-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -35,10 +35,10 @@ from music21 import interval from music21 import note from music21 import pitch -from music21.tree.trees import OffsetTree if t.TYPE_CHECKING: from music21 import stream + from music21.common.types import OffsetQL environLocal = environment.Environment('instrument') @@ -60,12 +60,12 @@ def unbundleInstruments(streamIn: stream.Stream, >>> s.append(up2) >>> s2 = instrument.unbundleInstruments(s) >>> s2.show('text') - {0.0} + {0.0} {0.0} {1.0} {1.0} ''' - if inPlace is True: + if inPlace: s = streamIn else: s = streamIn.coreCopyAsDerivation('unbundleInstruments') @@ -78,7 +78,7 @@ def unbundleInstruments(streamIn: stream.Stream, off = thisObj.offset s.insert(off, i) - if inPlace is False: + if not inPlace: return s @@ -104,9 +104,8 @@ def bundleInstruments(streamIn: stream.Stream, Bass Drum Bass Drum Cowbell - ''' - if inPlace is True: + if inPlace: s = streamIn else: s = streamIn.coreCopyAsDerivation('bundleInstruments') @@ -120,7 +119,7 @@ def bundleInstruments(streamIn: stream.Stream, elif isinstance(thisObj, note.NotRest): thisObj.storedInstrument = lastInstrument - if inPlace is False: + if not inPlace: return s @@ -319,6 +318,84 @@ def autoAssignMidiChannel(self, usedChannels: list[int], maxMidi=16): # 'was not already detected PROGRAM BUG!') +class MidiPercussionMapMixin(Instrument): + ''' + Base for instruments in the General MIDI percussion map (MIDI channel 10). + The map is a synthesizer's layout, not a family of instruments, so a + Whistle (a Flute) is in it too. + + >>> wh = instrument.Whistle() + >>> isinstance(wh, instrument.MidiPercussionMapMixin) + True + >>> wh.percMapPitch + 71 + + The repr shows the modifier, if any: + + >>> instrument.BongoDrums() + + + * New in v11: was part of UnpitchedPercussion + ''' + def __init__(self, **keywords) -> None: + super().__init__(**keywords) + self.percMapPitch: int|None = None + self._modifier: str|None = None + self._modifierToPercMapPitch: dict[str, int] = {} + self._percMapPitchToModifier: dict[int, str] = {} + + def _reprInternal(self): + baseRepr = super()._reprInternal() + if self._modifier is None: + return baseRepr + return f'{baseRepr} {self._modifier}' + + @property + def modifier(self) -> str|None: + ''' + Returns or sets the modifier for this instrument. A modifier could + be something like "low-floor" for a TomTom or "rimshot" for a SnareDrum. + + If the modifier is in the object's ._modifierToPercMapPitch dictionary + then changing the modifier also changes the .percMapPitch for the object + + >>> bd = instrument.BongoDrums() + >>> bd.modifier + 'high' + + >>> bd.percMapPitch + 60 + >>> bd.modifier = 'low' + >>> bd.percMapPitch + 61 + + Variations on modifiers can also be used and they get normalized: + + >>> wb1 = instrument.Woodblock() + >>> wb1.percMapPitch + 76 + >>> wb1.modifier = 'LO' + >>> wb1.percMapPitch + 77 + >>> wb1.modifier # n.b. -- not LO + 'low' + ''' + return self._modifier + + @modifier.setter + def modifier(self, modifier: str): + modifier = modifier.lower().strip() + + if self.inGMPercMap is True and modifier in self._modifierToPercMapPitch: + self.percMapPitch = self._modifierToPercMapPitch[modifier] + + # normalize modifiers + if self.percMapPitch in self._percMapPitchToModifier: + modifier = self._percMapPitchToModifier[self.percMapPitch] + + self._modifier = modifier + + # ------------------------------------------------------------------------------ class KeyboardInstrument(Instrument): @@ -332,7 +409,6 @@ def __init__(self, **keywords): class Piano(KeyboardInstrument): ''' - >>> p = instrument.Piano() >>> p.instrumentName 'Piano' @@ -398,7 +474,6 @@ def __init__(self, **keywords): class ElectricPiano(Piano): ''' - >>> p = instrument.ElectricPiano() >>> p.instrumentName 'Electric Piano' @@ -500,7 +575,32 @@ def __init__(self, **keywords): self.midiProgram = 48 - def _getStringPitches(self): + @property + def stringPitches(self): + ''' + stringPitches is a property that stores a list of Pitches (or pitch names, + such as "C4") that represent the pitch of the open strings from lowest to + highest.[*] + + >>> vln1 = instrument.Violin() + >>> [str(p) for p in vln1.stringPitches] + ['G3', 'D4', 'A4', 'E5'] + + instrument.stringPitches are full pitch objects, not just names: + + >>> [x.octave for x in vln1.stringPitches] + [3, 4, 4, 5] + + Scordatura for Scelsi's violin concerto *Anahit*. + (N.B. that string to pitch conversion is happening automatically) + + >>> vln1.stringPitches = ['G3', 'G4', 'B4', 'D4'] + + (`[*]In some tuning methods such as reentrant tuning on the ukulele, + lute, or five-string banjo the order might not strictly be from lowest to + highest. The same would hold true for certain violin scordatura pieces, such + as some of Biber's *Mystery Sonatas*`) + ''' if hasattr(self, '_cachedPitches') and self._cachedPitches is not None: return self._cachedPitches elif not hasattr(self, '_stringPitches'): @@ -509,7 +609,8 @@ def _getStringPitches(self): self._cachedPitches = [pitch.Pitch(x) for x in self._stringPitches] return self._cachedPitches - def _setStringPitches(self, newPitches): + @stringPitches.setter + def stringPitches(self, newPitches): if newPitches and (hasattr(newPitches[0], 'step') or newPitches[0] is None): # newPitches is pitchObjects or something self._stringPitches = newPitches @@ -518,31 +619,6 @@ def _setStringPitches(self, newPitches): self._cachedPitches = None self._stringPitches = newPitches - stringPitches = property(_getStringPitches, _setStringPitches, doc=''' - stringPitches is a property that stores a list of Pitches (or pitch names, - such as "C4") that represent the pitch of the open strings from lowest to - highest.[*] - - >>> vln1 = instrument.Violin() - >>> [str(p) for p in vln1.stringPitches] - ['G3', 'D4', 'A4', 'E5'] - - instrument.stringPitches are full pitch objects, not just names: - - >>> [x.octave for x in vln1.stringPitches] - [3, 4, 4, 5] - - Scordatura for Scelsi's violin concerto *Anahit*. - (N.B. that string to pitch conversion is happening automatically) - - >>> vln1.stringPitches = ['G3', 'G4', 'B4', 'D4'] - - (`[*]In some tuning methods such as reentrant tuning on the ukulele, - lute, or five-string banjo the order might not strictly be from lowest to - highest. The same would hold true for certain violin scordatura pieces, such - as some of Biber's *Mystery Sonatas*`) - ''') - class Violin(StringInstrument): def __init__(self, **keywords): @@ -832,16 +908,36 @@ def __init__(self, **keywords): self.midiProgram = 77 -class Whistle(Flute): +class Whistle(MidiPercussionMapMixin, Flute): + ''' + Small duct flute, from the referee's whistle to the tin whistle. + + >>> wh = instrument.Whistle() + + By default a whistle has no definite pitch; a tin whistle sets `pitched` to True. + + >>> wh.pitched + False + + General MIDI has a short and a long whistle sound, chosen by the modifier: + + >>> wh.modifier = 'long' + >>> wh.percMapPitch + 72 + ''' def __init__(self, **keywords): super().__init__(**keywords) self.instrumentName = 'Whistle' self.instrumentAbbreviation = 'Whs' self.instrumentSound = 'wind.flutes.whistle' + self.pitched = False self.inGMPercMap = True - # TODO: why is this not inheriting from UnpitchedPercussion if we're giving it percMapPitch? - self.percMapPitch = 71 + self._modifier = 'short' + # TODO: short and long should come from the duration of each note + self._modifierToPercMapPitch = {'short': 71, 'long': 72} + self._percMapPitchToModifier = {71: 'short', 72: 'long'} + self.percMapPitch = self._modifierToPercMapPitch[self._modifier] self.midiProgram = 78 @@ -1107,8 +1203,6 @@ def __init__(self, **keywords): class Percussion(Instrument): def __init__(self, **keywords): super().__init__(**keywords) - self.inGMPercMap = False - self.percMapPitch = None self.instrumentName = 'Percussion' self.instrumentAbbreviation = 'Perc' @@ -1117,60 +1211,11 @@ class PitchedPercussion(Percussion): pass -class UnpitchedPercussion(Percussion): +class UnpitchedPercussion(MidiPercussionMapMixin, Percussion): def __init__(self, **keywords): super().__init__(**keywords) - self._modifier = None - self._modifierToPercMapPitch = {} - self._percMapPitchToModifier = {} self.midiChannel = 9 # 0-indexed, i.e. MIDI channel 10 - def _getModifier(self): - return self._modifier - - def _setModifier(self, modifier): - modifier = modifier.lower().strip() - # BEN: to-do, pull out hyphens, spaces, etc. - - if self.inGMPercMap is True and modifier.lower() in self._modifierToPercMapPitch: - self.percMapPitch = self._modifierToPercMapPitch[modifier.lower()] - - # normalize modifiers - if self.percMapPitch in self._percMapPitchToModifier: - modifier = self._percMapPitchToModifier[self.percMapPitch] - - self._modifier = modifier - - modifier = property(_getModifier, _setModifier, doc=''' - Returns or sets the modifier for this instrument. A modifier could - be something like "low-floor" for a TomTom or "rimshot" for a SnareDrum. - - If the modifier is in the object's ._modifierToPercMapPitch dictionary - then changing the modifier also changes the .percMapPitch for the object - - - >>> bd = instrument.BongoDrums() - >>> bd.modifier - 'high' - - >>> bd.percMapPitch - 60 - >>> bd.modifier = 'low' - >>> bd.percMapPitch - 61 - - Variations on modifiers can also be used and they get normalized: - - >>> wb1 = instrument.Woodblock() - >>> wb1.percMapPitch - 76 - >>> wb1.modifier = 'LO' - >>> wb1.percMapPitch - 77 - >>> wb1.modifier # n.b. -- not LO - 'low' - ''') - class Vibraphone(PitchedPercussion): def __init__(self, **keywords): @@ -1322,19 +1367,86 @@ def __init__(self, **keywords): self.instrumentName = 'Castanets' self.instrumentAbbreviation = 'Cas' self.instrumentSound = 'wood.castanets' + self.inGMPercMap = True + self.percMapPitch = 85 # GM Level 2 -class Maracas(UnpitchedPercussion): +class Cabasa(UnpitchedPercussion): def __init__(self, **keywords): super().__init__(**keywords) - self.instrumentName = 'Maracas' + self.instrumentName = 'Cabasa' + self.instrumentAbbreviation = 'Cab' + self.instrumentSound = 'rattle.cabasa' + self.inGMPercMap = True + self.percMapPitch = 69 + + +class Claves(UnpitchedPercussion): + def __init__(self, **keywords): + super().__init__(**keywords) + + self.instrumentName = 'Claves' + self.instrumentAbbreviation = 'Clv' + self.instrumentSound = 'wood.claves' + self.inGMPercMap = True + self.percMapPitch = 75 + + +class Guiro(UnpitchedPercussion): + def __init__(self, **keywords): + super().__init__(**keywords) + + self.instrumentName = 'Guiro' + self.instrumentAbbreviation = 'Gro' + self.instrumentSound = 'wood.guiro' + self.inGMPercMap = True + self._modifier = 'short' + # TODO: short and long should come from the duration of each note + self._modifierToPercMapPitch = {'short': 73, 'long': 74} + self._percMapPitchToModifier = {73: 'short', 74: 'long'} + self.percMapPitch = self._modifierToPercMapPitch[self._modifier] + + +# For definition of this instrument see James Holland, "Practical Percussion: +# A Guide to the Instruments and Their Sources," p. 42. +class Shaker(UnpitchedPercussion): + ''' + Rattles that are shaken, among them maracas and caxixi. + + >>> isinstance(instrument.Maracas(), instrument.Shaker) + True + ''' + def __init__(self, **keywords): + super().__init__(**keywords) + + self.instrumentName = 'Shaker' + self.instrumentAbbreviation = 'Shk' + self.instrumentSound = 'rattle.shaker' self.inGMPercMap = True + self.percMapPitch = 82 # GM Level 2 + + +class Maracas(Shaker): + def __init__(self, **keywords): + super().__init__(**keywords) + + self.instrumentName = 'Maracas' self.percMapPitch = 70 self.instrumentAbbreviation = 'Mrcs' self.instrumentSound = 'rattle.maraca' +class Caxixi(Shaker): + def __init__(self, **keywords): + super().__init__(**keywords) + + self.instrumentName = 'Caxixi' + self.instrumentAbbreviation = 'Cax' + self.instrumentSound = 'rattle.caxixi' + # no GM note of its own: sounds as Shaker + + class Vibraslap(UnpitchedPercussion): def __init__(self, **keywords): super().__init__(**keywords) @@ -1373,14 +1485,9 @@ def __init__(self, **keywords): self.instrumentAbbreviation = 'Cym' self.instrumentSound = 'metal.cymbal.crash' self.inGMPercMap = True - self._modifier = '1' - - self._modifierToPercMapPitch = {'1': 49, - '2': 57, - } - self._percMapPitchToModifier = {49: '1', - 57: '2', - } + self._modifier = 'midi-1' + self._modifierToPercMapPitch = {'midi-1': 49, 'midi-2': 57, '1': 49, '2': 57} + self._percMapPitchToModifier = {49: 'midi-1', 57: 'midi-2'} self.percMapPitch = self._modifierToPercMapPitch[self._modifier] @@ -1402,29 +1509,65 @@ def __init__(self, **keywords): self.instrumentSound = 'metal.cymbal.sizzle' -class SplashCymbals(Cymbals): +class SplashCymbal(Cymbals): + ''' + A mounted splash cymbal. + + * Changed in v11: was formerly plural "SplashCymbals" + ''' def __init__(self, **keywords): super().__init__(**keywords) - self.instrumentName = 'Splash Cymbals' + self.instrumentName = 'Splash Cymbal' self.instrumentAbbreviation = 'Spl Cym' self.instrumentSound = 'metal.cymbal.splash' + self.inGMPercMap = True + self.percMapPitch = 55 -class RideCymbals(Cymbals): +class RideCymbal(Cymbals): + ''' + A mounted ride cymbal. + + * Changed in v11: was formerly plural "RideCymbals" + ''' def __init__(self, **keywords): super().__init__(**keywords) - self.instrumentName = 'Ride Cymbals' + self.instrumentName = 'Ride Cymbal' self.instrumentAbbreviation = 'Ride Cym' self.instrumentSound = 'metal.cymbal.ride' + self.inGMPercMap = True + self._modifier = 'midi-1' + # TODO: bell is misplaced as a modifier alone -- it should be a technique + # that applies to particular notes or not + self._modifierToPercMapPitch = {'midi-1': 51, 'midi-2': 59, 'bell': 53, + '1': 51, '2': 59} + self._percMapPitchToModifier = {51: 'midi-1', 59: 'midi-2', 53: 'bell'} + self.percMapPitch = self._modifierToPercMapPitch[self._modifier] -class HiHatCymbal(Cymbals): +class ChineseCymbal(Cymbals): + def __init__(self, **keywords): + super().__init__(**keywords) + + self.instrumentName = 'Chinese Cymbal' + self.instrumentAbbreviation = 'Chin Cym' + self.instrumentSound = 'metal.cymbal.chinese' + self.inGMPercMap = True + self.percMapPitch = 52 + + +class HiHat(Cymbals): + ''' + A pair of mounted hi-hat cymbals (also spelled "high hat", "hihat", etc.). + + * Changed in v11: was formerly "HiHatCymbal" + ''' def __init__(self, **keywords): super().__init__(**keywords) - self.instrumentName = 'Hi-Hat Cymbal' + self.instrumentName = 'Hi-Hat' self.instrumentSound = 'metal.hi-hat' self.inGMPercMap = True @@ -1481,7 +1624,10 @@ def __init__(self, **keywords): self.instrumentAbbreviation = 'Ago' self.instrumentSound = 'metal.bells.agogo' self.inGMPercMap = True - self.percMapPitch = 67 + self._modifier = 'high' + self._modifierToPercMapPitch = {'high': 67, 'low': 68} + self._percMapPitchToModifier = {67: 'high', 68: 'low'} + self.percMapPitch = self._modifierToPercMapPitch[self._modifier] self.midiProgram = 113 @@ -1501,9 +1647,38 @@ def __init__(self, **keywords): self.instrumentName = 'Sleigh Bells' self.instrumentAbbreviation = 'Slgh Bl' self.instrumentSound = 'metal.bells.sleigh-bells' + self.inGMPercMap = True + self.percMapPitch = 83 # GM Level 2 Jingle Bell + + +class BellTree(UnpitchedPercussion): + def __init__(self, **keywords): + super().__init__(**keywords) + + self.instrumentName = 'Bell Tree' + self.instrumentAbbreviation = 'B Tr' + self.instrumentSound = 'metal.bells.bell-tree' + self.inGMPercMap = True + self.percMapPitch = 84 # GM Level 2 + + +class UnpitchedDrum(UnpitchedPercussion): + ''' + Drums without a definite pitch. Timpani, which are pitched, are + :class:`PitchedPercussion` instead. + + >>> isinstance(instrument.SnareDrum(), instrument.UnpitchedDrum) + True + >>> isinstance(instrument.Timpani(), instrument.UnpitchedDrum) + False + ''' + def __init__(self, **keywords): + super().__init__(**keywords) + self.instrumentName = 'Drum' + self.instrumentAbbreviation = 'Dr' -class SnareDrum(UnpitchedPercussion): +class SnareDrum(UnpitchedDrum): def __init__(self, **keywords): super().__init__(**keywords) @@ -1523,7 +1698,7 @@ def __init__(self, **keywords): self.percMapPitch = self._modifierToPercMapPitch[self._modifier] -class TenorDrum(UnpitchedPercussion): +class TenorDrum(UnpitchedDrum): def __init__(self, **keywords): super().__init__(**keywords) @@ -1532,7 +1707,7 @@ def __init__(self, **keywords): self.instrumentSound = 'drum.tenor-drum' -class BongoDrums(UnpitchedPercussion): +class BongoDrums(UnpitchedDrum): def __init__(self, **keywords): super().__init__(**keywords) @@ -1547,7 +1722,7 @@ def __init__(self, **keywords): self.percMapPitch = self._modifierToPercMapPitch[self._modifier] -class TomTom(UnpitchedPercussion): +class TomTom(UnpitchedDrum): def __init__(self, **keywords): super().__init__(**keywords) @@ -1563,7 +1738,7 @@ def __init__(self, **keywords): self.percMapPitch = self._modifierToPercMapPitch[self._modifier] -class Timbales(UnpitchedPercussion): +class Timbales(UnpitchedDrum): def __init__(self, **keywords): super().__init__(**keywords) @@ -1577,7 +1752,36 @@ def __init__(self, **keywords): self.percMapPitch = self._modifierToPercMapPitch[self._modifier] -class CongaDrum(UnpitchedPercussion): +class CongaDrum(UnpitchedDrum): + ''' + Single-headed hand drum of Afro-Cuban origin. + + >>> cd = instrument.CongaDrum() + + Music21 knows of two modifiers: 'high' and 'low'. + + >>> cd.modifier = 'high' + + A conga can be muted or not (at present, this is + an aspect of the instrument, not the note) + + >>> cd.muted = True + + For MIDI output, the effect of modifier and muted + changes the MIDI note + + >>> cd.percMapPitch + 62 + >>> cd.muted = False + >>> cd.percMapPitch + 63 + >>> cd.modifier = 'low' # or '' + >>> cd.percMapPitch + 64 + + Note that General MIDI does not distinguish the sound of a + muted and unmuted low conga drums. + ''' def __init__(self, **keywords): super().__init__(**keywords) @@ -1586,12 +1790,22 @@ def __init__(self, **keywords): self.instrumentSound = 'drum.conga' self.inGMPercMap = True self._modifier = 'low' - self._modifierToPercMapPitch = {'low': 64, 'mute high': 62, 'open high': 63} - self._percMapPitchToModifier = {64: 'low', 62: 'mute high', 63: 'open high'} + self._modifierToPercMapPitch = {'low': 64, '': 64, 'high': 63} + self._percMapPitchToModifier = {64: 'low', 63: 'high', 62: 'high'} self.percMapPitch = self._modifierToPercMapPitch[self._modifier] + # TODO: muting should apply to particular notes, not the instrument + @property + def muted(self) -> bool: + return self.percMapPitch == 62 + + @muted.setter + def muted(self, value: bool): + if self._modifier == 'high': + self.percMapPitch = 62 if value else 63 -class BassDrum(UnpitchedPercussion): + +class BassDrum(UnpitchedDrum): def __init__(self, **keywords): super().__init__(**keywords) @@ -1600,12 +1814,12 @@ def __init__(self, **keywords): self.instrumentSound = 'drum.bass-drum' self.inGMPercMap = True self._modifier = 'acoustic' - self._modifierToPercMapPitch = {'acoustic': 35, '1': 36} - self._percMapPitchToModifier = {35: 'acoustic', 36: '1'} + self._modifierToPercMapPitch = {'acoustic': 35, 'midi-1': 36, '1': 36} + self._percMapPitchToModifier = {35: 'acoustic', 36: 'midi-1'} self.percMapPitch = self._modifierToPercMapPitch[self._modifier] -class Taiko(UnpitchedPercussion): +class Taiko(UnpitchedDrum): def __init__(self, **keywords): super().__init__(**keywords) @@ -1615,7 +1829,23 @@ def __init__(self, **keywords): self.midiProgram = 116 -class Tambourine(UnpitchedPercussion): +class Tambourine(UnpitchedDrum): + ''' + A tambourine, by default with a drum head (classical style) + + For Rock/pop tambourines (shaker-cymbal type) set + headless = True. + + >>> instrument.Tambourine().headless + False + >>> instrument.Tambourine().headless = True + + Note that MIDI (like many other formats) does not distinguish + between headless and headed tambourines. Even though our default + is a headed drum, we import MIDI as headless tambourine, since + the majority of MIDI files with tambourines represent rock/pop + styles. + ''' def __init__(self, **keywords): super().__init__(**keywords) @@ -1624,6 +1854,80 @@ def __init__(self, **keywords): self.instrumentSound = 'drum.tambourine' self.inGMPercMap = True self.percMapPitch = 54 + self.headless = False + + +class Cuica(UnpitchedDrum): + ''' + A Brazilian friction drum: a stick rubbed inside the head makes the + squeaks and laughs of samba. + + >>> cu = instrument.Cuica() + + A cuica can be muted or not (at present, an aspect of the instrument, + not the note). For MIDI output, this changes the MIDI note: + + >>> cu.muted + False + >>> cu.percMapPitch + 79 + >>> cu.muted = True + >>> cu.percMapPitch + 78 + ''' + def __init__(self, **keywords): + super().__init__(**keywords) + + self.instrumentName = 'Cuica' + self.instrumentAbbreviation = 'Cui' + self.instrumentSound = 'drum.cuica' + self.inGMPercMap = True + self.percMapPitch = 79 + + # TODO: muting should apply to particular notes, not the instrument + @property + def muted(self) -> bool: + return self.percMapPitch == 78 + + @muted.setter + def muted(self, value: bool): + self.percMapPitch = 78 if value else 79 + + +class Surdo(UnpitchedDrum): + ''' + The big Brazilian bass drum that holds down the samba beat. + + >>> su = instrument.Surdo() + + A surdo can be muted or not (at present, an aspect of the instrument, + not the note). For MIDI output, this changes the MIDI note: + + >>> su.muted + False + >>> su.percMapPitch + 87 + >>> su.muted = True + >>> su.percMapPitch + 86 + ''' + def __init__(self, **keywords): + super().__init__(**keywords) + + self.instrumentName = 'Surdo' + self.instrumentAbbreviation = 'Sur' + self.instrumentSound = 'drum.surdo' + self.inGMPercMap = True + self.percMapPitch = 87 # GM Level 2 + + # TODO: muting should apply to particular notes, not the instrument + @property + def muted(self) -> bool: + return self.percMapPitch == 86 + + @muted.setter + def muted(self, value: bool): + self.percMapPitch = 86 if value else 87 class Whip(UnpitchedPercussion): @@ -1881,12 +2185,15 @@ def deduplicate(s: stream.Stream, inPlace: bool = False) -> stream.Stream: substreams = returnObj.getElementsByClass(stream.Stream) for sub in substreams: - oTree = OffsetTree(sub[Instrument].stream()) - for o in oTree: - if len(o) == 1: + instrumentsByOffset: dict[OffsetQL, list[Instrument]] = {} + for inst in sub[Instrument]: + instOffset = inst.getOffsetInHierarchy(sub) + instrumentsByOffset.setdefault(instOffset, []).append(inst) + for atOffset in instrumentsByOffset.values(): + if len(atOffset) == 1: continue - notNonePartNames = {i.partName for i in o if i.partName is not None} - notNoneInstNames = {i.instrumentName for i in o if i.instrumentName is not None} + notNonePartNames = {i.partName for i in atOffset if i.partName is not None} + notNoneInstNames = {i.instrumentName for i in atOffset if i.instrumentName is not None} # Proceed only if 0-1 part name AND 0-1 instrument name candidates if len(notNonePartNames) > 1 or len(notNoneInstNames) > 1: @@ -1899,25 +2206,25 @@ def deduplicate(s: stream.Stream, inPlace: bool = False) -> stream.Stream: for iName in notNoneInstNames: instrumentName = iName - classes = {inst.__class__ for inst in o} + classes = {inst.__class__ for inst in atOffset} # Case: 2+ instances of the same class if len(classes) == 1: surviving = None # Treat first as the surviving instance and standardize name - for inst in o: + for inst in atOffset: inst.partName = partName inst.instrumentName = instrumentName surviving = inst break # Remove remaining instruments - for inst in o: + for inst in atOffset: if inst is surviving: continue sub.remove(inst, recurse=True) # Case: mixed classes: standardize names # Remove instances of generic `Instrument` if found else: - for inst in o: + for inst in atOffset: if inst.__class__ == Instrument: sub.remove(inst, recurse=True) else: @@ -2544,13 +2851,17 @@ def getAllNamesForInstrument(instrumentClass: Instrument, 'english', 'french', 'german', 'italian', 'russian', 'spanish', and 'abbreviation'. Note that the language string is not case-sensitive, so 'German' is also fine. - ''' + from music21.languageExcerpts import instrumentLookup + language = language.lower() instrumentNameDict = {} - instrumentClassName = instrumentClass.instrumentName or '' + namedClasses = set(instrumentLookup.allToClassName.values()) + instrumentClassName = next((cls.__name__ for cls in type(instrumentClass).__mro__ + if cls.__name__ in namedClasses), + instrumentClass.instrumentName or '') if language == SearchLanguage.ALL: for lang in SearchLanguage: @@ -2572,6 +2883,25 @@ class TestExternal(unittest.TestCase): class Test(unittest.TestCase): + def testCongaEmptyModifierIsLow(self): + cd = CongaDrum() + cd.modifier = 'high' + cd.modifier = '' + self.assertEqual(cd.modifier, 'low') + self.assertEqual(cd.percMapPitch, 64) + + def testGetAllNamesForInstrumentByClass(self): + acousticBass = getAllNamesForInstrument(AcousticBass(), SearchLanguage.ENGLISH) + self.assertEqual(acousticBass, {'english': ['acoustic bass']}) + + firstViolins = Violin() + firstViolins.instrumentName = 'Violin I' + self.assertEqual(getAllNamesForInstrument(firstViolins, SearchLanguage.ENGLISH), + {'english': ['violin', 'violins']}) + + self.assertEqual(getAllNamesForInstrument(Instrument('Violin'), SearchLanguage.ENGLISH), + {'english': ['violin', 'violins']}) + def testCopyAndDeepcopy(self): from music21.test.commonTest import testCopyAll testCopyAll(self, globals()) @@ -2599,82 +2929,79 @@ def testMusicXMLExport(self): # s3.show() def testPartitionByInstrumentA(self): - from music21 import instrument from music21 import stream # basic case of instruments in Parts s = stream.Score() p1 = stream.Part() - p1.append(instrument.Piano()) + p1.append(Piano()) p2 = stream.Part() - p2.append(instrument.Piccolo()) + p2.append(Piccolo()) s.insert(0, p1) s.insert(0, p2) - post = instrument.partitionByInstrument(s) + post = partitionByInstrument(s) self.assertEqual(len(post), 2) - self.assertEqual(len(post.flatten().getElementsByClass(instrument.Instrument)), 2) + self.assertEqual(len(post.flatten().getElementsByClass(Instrument)), 2) # post.show('t') # one Stream with multiple instruments s = stream.Stream() - s.insert(0, instrument.PanFlute()) - s.insert(20, instrument.ReedOrgan()) + s.insert(0, PanFlute()) + s.insert(20, ReedOrgan()) - post = instrument.partitionByInstrument(s) + post = partitionByInstrument(s) self.assertEqual(len(post), 2) - self.assertEqual(len(post[instrument.Instrument]), 2) + self.assertEqual(len(post[Instrument]), 2) # post.show('t') def testPartitionByInstrumentB(self): - from music21 import instrument from music21 import stream # basic case of instruments in Parts s = stream.Score() p1 = stream.Part() - p1.append(instrument.Piano()) + p1.append(Piano()) p1.repeatAppend(note.Note(), 6) p2 = stream.Part() - p2.append(instrument.Piccolo()) + p2.append(Piccolo()) p2.repeatAppend(note.Note(), 12) s.insert(0, p1) s.insert(0, p2) - post = instrument.partitionByInstrument(s) + post = partitionByInstrument(s) self.assertEqual(len(post), 2) - self.assertEqual(len(post[instrument.Instrument]), 2) + self.assertEqual(len(post[Instrument]), 2) self.assertEqual(len(post.parts[0].notes), 6) self.assertEqual(len(post.parts[1].notes), 12) def testPartitionByInstrumentC(self): - from music21 import instrument from music21 import stream # basic case of instruments in Parts s = stream.Score() p1 = stream.Part() - p1.append(instrument.Piano()) + p1.append(Piano()) p1.repeatAppend(note.Note('a'), 6) # will go in next available offset - p1.append(instrument.AcousticGuitar()) + p1.append(AcousticGuitar()) p1.repeatAppend(note.Note('b'), 3) p2 = stream.Part() - p2.append(instrument.Piccolo()) + p2.append(Piccolo()) p2.repeatAppend(note.Note('c'), 2) - p2.append(instrument.Flute()) + p2.append(Flute()) p2.repeatAppend(note.Note('d'), 4) s.insert(0, p1) s.insert(0, p2) - post = instrument.partitionByInstrument(s) + post = partitionByInstrument(s) self.assertEqual(len(post), 4) # 4 instruments - self.assertEqual(len(post[instrument.Instrument]), 4) + self.assertEqual(len(post[Instrument]), 4) self.assertEqual(post.parts[0].getInstrument().instrumentName, 'Piano') self.assertEqual(len(post.parts[0].notes), 6) self.assertEqual(post.parts[1].getInstrument().instrumentName, 'Acoustic Guitar') @@ -2688,34 +3015,33 @@ def testPartitionByInstrumentC(self): # post.show('t') def testPartitionByInstrumentD(self): - from music21 import instrument from music21 import stream # basic case of instruments in Parts s = stream.Score() p1 = stream.Part() - p1.append(instrument.Piano()) + p1.append(Piano()) p1.repeatAppend(note.Note('a'), 6) # will go in next available offset - p1.append(instrument.AcousticGuitar()) + p1.append(AcousticGuitar()) p1.repeatAppend(note.Note('b'), 3) - p1.append(instrument.Piano()) + p1.append(Piano()) p1.repeatAppend(note.Note('e'), 5) p2 = stream.Part() - p2.append(instrument.Piccolo()) + p2.append(Piccolo()) p2.repeatAppend(note.Note('c'), 2) - p2.append(instrument.Flute()) + p2.append(Flute()) p2.repeatAppend(note.Note('d'), 4) - p2.append(instrument.Piano()) + p2.append(Piano()) p2.repeatAppend(note.Note('f'), 1) s.insert(0, p1) s.insert(0, p2) - post = instrument.partitionByInstrument(s) + post = partitionByInstrument(s) self.assertEqual(len(post), 4) # 4 instruments - self.assertEqual(len(post[instrument.Instrument]), 4) + self.assertEqual(len(post[Instrument]), 4) # piano spans are joined together self.assertEqual(post.parts[0].getInstrument().instrumentName, 'Piano') self.assertEqual(len(post.parts[0].notes), 12) @@ -2727,32 +3053,31 @@ def testPartitionByInstrumentD(self): # post.show('t') def testPartitionByInstrumentE(self): - from music21 import instrument from music21 import stream # basic case of instruments in Parts # s = stream.Score() p1 = stream.Part() - p1.append(instrument.Piano()) + p1.append(Piano()) p1.repeatAppend(note.Note('a'), 6) # will go in next available offset - p1.append(instrument.AcousticGuitar()) + p1.append(AcousticGuitar()) p1.repeatAppend(note.Note('b'), 3) - p1.append(instrument.Piano()) + p1.append(Piano()) p1.repeatAppend(note.Note('e'), 5) - p1.append(instrument.Piccolo()) + p1.append(Piccolo()) p1.repeatAppend(note.Note('c'), 2) - p1.append(instrument.Flute()) + p1.append(Flute()) p1.repeatAppend(note.Note('d'), 4) - p1.append(instrument.Piano()) + p1.append(Piano()) p1.repeatAppend(note.Note('f'), 1) s = p1 - post = instrument.partitionByInstrument(s) + post = partitionByInstrument(s) self.assertEqual(len(post), 4) # 4 instruments - self.assertEqual(len(post[instrument.Instrument]), 4) + self.assertEqual(len(post[Instrument]), 4) # piano spans are joined together self.assertEqual(post.parts[0].getInstrument().instrumentName, 'Piano') @@ -2766,16 +3091,15 @@ def testPartitionByInstrumentE(self): [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 9.0, 10.0, 11.0, 12.0, 13.0, 20.0]) def testPartitionByInstrumentF(self): - from music21 import instrument from music21 import stream s1 = stream.Stream() - s1.append(instrument.AcousticGuitar()) + s1.append(AcousticGuitar()) s1.append(note.Note()) - s1.append(instrument.Tuba()) + s1.append(Tuba()) s1.append(note.Note()) - post = instrument.partitionByInstrument(s1) + post = partitionByInstrument(s1) self.assertEqual(len(post), 2) # 4 instruments # def testPartitionByInstrumentDocTest(self): @@ -2802,14 +3126,12 @@ def testPartitionByInstrumentF(self): # p.makeRests(fillGaps=True, inPlace=True) def testLanguageChoice(self): - from music21 import instrument - # fromString testString = 'Klarinette' # German name # Works when language not specified - self.assertEqual(instrument.fromString(testString).instrumentName, + self.assertEqual(fromString(testString).instrumentName, testString) workingExamples = ['german', # Works with correct language for the term @@ -2817,7 +3139,7 @@ def testLanguageChoice(self): ] for langStr in workingExamples: - instrName = instrument.fromString(testString, language=langStr).instrumentName + instrName = fromString(testString, language=langStr).instrumentName self.assertEqual(instrName, testString) failingExamples = ['french', # Error when the language doesn't match the term @@ -2826,23 +3148,68 @@ def testLanguageChoice(self): for langStr in failingExamples: self.assertRaises(InstrumentException, - instrument.fromString, + fromString, testString, language=langStr) # getAllNamesForInstrument - inst = instrument.Flute() + inst = Flute() # Working example - self.assertEqual(instrument.getAllNamesForInstrument(inst, - language=SearchLanguage.ABBREVIATION), + self.assertEqual(getAllNamesForInstrument(inst, + language=SearchLanguage.ABBREVIATION), {'abbreviation': ['fl']}) # Error for unsupported language self.assertRaises(InstrumentException, - instrument.getAllNamesForInstrument, + getAllNamesForInstrument, inst, language='finnish') + def testDeduplicateKeepsInstrumentsAtDifferentOffsets(self): + ''' + AI-assisted (Claude). + ''' + from music21 import stream + + p = stream.Part() + m1 = stream.Measure(number=1) + m1.insert(0, Instrument()) + m1.insert(0, Viola()) + m1.append(note.Note(type='whole')) + m2 = stream.Measure(number=2) + m2.insert(0, Violin()) + m2.append(note.Note(type='whole')) + p.append([m1, m2]) + + deduplicate(p, inPlace=True) + self.assertEqual( + [(inst.classes[0], inst.partName, inst.getOffsetInHierarchy(p)) + for inst in p[Instrument]], + [('Viola', None, 0.0), ('Violin', None, 4.0)] + ) + + def testDeduplicateByOffset(self): + ''' + Instruments are deduplicated within each offset: a part name on an + instrument at another offset does not stop deduplication, and the + same instrument at two offsets is kept at both. + ''' + from music21 import stream + + def dedupedInstruments(offsetsAndInstruments): + p = stream.Part() + for offset, inst in offsetsAndInstruments: + p.insert(offset, inst) + deduplicate(p, inPlace=True) + return [(inst.classes[0], inst.getOffsetInHierarchy(p)) for inst in p[Instrument]] + + viola = Viola() + viola.partName = 'X' + self.assertEqual(dedupedInstruments([(0, Violin()), (0, Violin()), (4, viola)]), + [('Violin', 0.0), ('Viola', 4.0)]) + self.assertEqual(dedupedInstruments([(0, Violin()), (4, Violin())]), + [('Violin', 0.0), ('Violin', 4.0)]) + # ------------------------------------------------------------------------------ # define presented order in documentation diff --git a/music21/interval.py b/music21/interval.py index e90ad75a7e..dbd7299147 100644 --- a/music21/interval.py +++ b/music21/interval.py @@ -7,7 +7,7 @@ # Amy Hailes # Christopher Ariza # -# Copyright: Copyright © 2009-2024 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2009-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -1409,10 +1409,7 @@ def transposePitch(self, p: pitch.Pitch, *, inPlace=False): >>> gSharp ''' - if p.octave is None: - useImplicitOctave = True - else: - useImplicitOctave = False + useImplicitOctave = p.octaveIsImplicit pitchDNN = p.diatonicNoteNum if inPlace: @@ -1422,7 +1419,7 @@ def transposePitch(self, p: pitch.Pitch, *, inPlace=False): newPitch.diatonicNoteNum = pitchDNN + self.staffDistance if useImplicitOctave: - newPitch.octave = None + newPitch.octaveIsImplicit = True if not inPlace: return newPitch @@ -1736,7 +1733,7 @@ def __eq__(self, other): # if self.direction != other.direction: # return False if (self.generic == other.generic - and self.specifier == other.specifier + and self.specifier == other.specifier and self.direction == other.direction): return True else: @@ -1853,7 +1850,6 @@ def direction(self) -> Direction: >>> interval.DiatonicInterval('A', 1).generic.direction - ''' if self.generic.undirected != 1: return self.generic.direction @@ -2501,10 +2497,7 @@ def transposePitch(self, p: pitch.Pitch, *, inPlace=False): * Changed in v6: added inPlace. ''' - if p.octave is None: - useImplicitOctave = True - else: - useImplicitOctave = False + useImplicitOctave = p.octaveIsImplicit pps = p.ps if not inPlace: @@ -2513,8 +2506,8 @@ def transposePitch(self, p: pitch.Pitch, *, inPlace=False): newPitch = p newPitch.ps = pps + self.semitones - if useImplicitOctave is True: - newPitch.octave = None + if useImplicitOctave: + newPitch.octaveIsImplicit = True if not inPlace: return newPitch @@ -2777,7 +2770,6 @@ def intervalFromGenericAndChromatic( >>> interval.intervalFromGenericAndChromatic(1, 0.5) - ''' gIntV: GenericInterval if not isinstance(gInt, GenericInterval): @@ -3410,10 +3402,11 @@ def transposePitch(self, if self.implicitDiatonic: # this will not preserve diatonic relationships - pOut = self.chromatic.transposePitch( - p, - inPlace=inPlace, - ) + if inPlace: + self.chromatic.transposePitch(p, inPlace=True) + pOut = p + else: + pOut = self.chromatic.transposePitch(p) else: pOut = self._diatonicTransposePitch( p, @@ -3428,8 +3421,8 @@ def transposePitch(self, maxAccidental=maxAccidental, ) - if p.fundamental.octave is None: - pOut.fundamental.octave = None + if p.fundamental.octaveIsImplicit: + pOut.fundamental.octaveIsImplicit = True if not inPlace: return pOut @@ -3452,15 +3445,19 @@ def _diatonicTransposePitch(self, # true unison and any multiple of true octave inheritAccidentalDisplayStatus = True - if p.octave is None: - useImplicitOctave = True - else: - useImplicitOctave = False + useImplicitOctave = p.octaveIsImplicit pitch1 = p pitch2 = copy.deepcopy(pitch1) oldDiatonicNum = pitch1.diatonicNoteNum - # centsOrigin = pitch1.microtone.cents # unused!! + # Only carry pitch1's microtone through when the interval is not itself + # microtonal: a microtonal interval already supplies the result's cents + # (e.g. scale realization), so preserving here would double-count. + intervalIsMicrotonal = self.chromatic.semitones != int(self.chromatic.semitones) + if not pitch1.isTwelveTone() and not intervalIsMicrotonal: + centsOrigin = pitch1.microtone.cents + else: + centsOrigin = 0.0 distanceToMove = self.diatonic.generic.staffDistance newDiatonicNumber = oldDiatonicNum + distanceToMove @@ -3473,9 +3470,15 @@ def _diatonicTransposePitch(self, # if this is not set to None then terrible things happen pitch2.microtone = None # type: ignore - # We have the right note name but not the right accidental - interval2 = Interval(pitch1, pitch2) - # halfStepsToFix already has any microtones + # We have the right note name but not the right accidental. Measure it + # from pitch1 without its microtone (reapplied to the result below); a + # microtonal endpoint here yields a fractional chromatic with no + # interval specifier. + pitch1ForAccidental = pitch1 + if centsOrigin: + pitch1ForAccidental = copy.deepcopy(pitch1) + pitch1ForAccidental.microtone = None + interval2 = Interval(pitch1ForAccidental, pitch2) halfStepsToFix = self.chromatic.semitones - interval2.chromatic.semitones # environLocal.printDebug(['self', self, 'halfStepsToFix', halfStepsToFix, @@ -3527,8 +3530,12 @@ def _diatonicTransposePitch(self, and oldPitch2Accidental.name == 'natural'): pitch2.accidental = oldPitch2Accidental + if centsOrigin: + # restore the cents peeled off halfStepsToFix above + pitch2.microtone = pitch2.microtone.cents + centsOrigin + if useImplicitOctave: - pitch2.octave = None + pitch2.octaveIsImplicit = True if not inPlace: return pitch2 @@ -4079,7 +4086,6 @@ def subtract(intervalList): 'Descending Diminished Unison' >>> a.chromatic.semitones -1 - ''' from music21 import pitch if not intervalList: diff --git a/music21/key.py b/music21/key.py index 12f285f2f0..631a49a13c 100644 --- a/music21/key.py +++ b/music21/key.py @@ -85,7 +85,7 @@ def convertKeyStringToMusic21KeyString(textString): return textString -def sharpsToPitch(sharpCount): +def sharpsToPitch(sharpCount: int) -> pitch.Pitch: ''' Given a positive/negative number of sharps, return a Pitch object set to the appropriate major key value. @@ -122,15 +122,12 @@ def sharpsToPitch(sharpCount): >>> key._sharpsToPitchCache[1] ''' - if sharpCount is None: - sharpCount = 0 # fix for C major - if sharpCount in _sharpsToPitchCache: # return a deepcopy of the pitch return copy.deepcopy(_sharpsToPitchCache[sharpCount]) pitchInit = pitch.Pitch('C') - pitchInit.octave = None + pitchInit.octaveIsImplicit = True # keyPc = (self.sharps * 7) % 12 if sharpCount > 0: intervalStr = 'P5' @@ -142,7 +139,7 @@ def sharpsToPitch(sharpCount): intervalObj = interval.Interval(intervalStr) for i in range(abs(sharpCount)): pitchInit = intervalObj.transposePitch(pitchInit) - pitchInit.octave = None + pitchInit.octaveIsImplicit = True _sharpsToPitchCache[sharpCount] = pitchInit return pitchInit @@ -302,28 +299,16 @@ class KeySignature(base.Music21Object): >>> legal - To set a non-traditional Key Signature, create a KeySignature object - with `sharps=None`, and then set the `alteredPitches` list: - - >>> unusual = key.KeySignature(sharps=None) - >>> unusual.alteredPitches = ['E-', 'G#'] - >>> unusual - - >>> unusual.isNonTraditional - True - - To set a pitch as displayed in a particular octave, create a non-traditional - KeySignature and then set pitches with octaves: + To set a non-traditional Key Signature, set `isNonTraditional` to True and + give the `alteredPitches` list. A pitch with an octave is displayed in that + octave; set `accidentalsApplyOnlyToOctave` to True if it should affect only + that octave: - >>> unusual = key.KeySignature(sharps=None) - >>> unusual.alteredPitches = ['F#4'] + >>> unusual = key.KeySignature() + >>> unusual.isNonTraditional = True + >>> unusual.alteredPitches = ['E-', 'G#4'] >>> unusual - - - If the accidental applies to all octaves but is being displayed differently - then you are done, but if you want them to apply only to the octave displayed - in then set `.accidentalsApplyOnlyToOctave` to `True`: - + >>> unusual.accidentalsApplyOnlyToOctave False >>> unusual.accidentalsApplyOnlyToOctave = True @@ -343,9 +328,11 @@ class KeySignature(base.Music21Object): * Changed in v7: `sharps` defaults to 0 (key of no flats/sharps) rather than `None` for nontraditional keys. + * Changed in v11: `sharps` is always an int; `isNonTraditional` is a + settable attribute. Passing `sharps=None` is deprecated. ''' _styleClass = style.TextStyle - equalityAttributes = ('sharps',) + equalityAttributes = ('sharps', 'isNonTraditional') # note that musicxml permits non-traditional keys by specifying # one or more altered tones; these are given as pairs of @@ -353,12 +340,20 @@ class KeySignature(base.Music21Object): classSortOrder = 2 - def __init__(self, sharps: int|None = 0, **keywords): + def __init__(self, sharps: int = 0, **keywords): super().__init__(**keywords) - # position on the circle of fifths, where 1 is one sharp, -1 is one flat + self.isNonTraditional: bool = False + + if sharps is None: + warnings.warn( + 'sharps=None is deprecated: set isNonTraditional to True instead.', + exceptions21.Music21DeprecationWarning, + stacklevel=2) + sharps = 0 + self.isNonTraditional = True try: - if sharps is not None and (sharps != int(sharps)): + if sharps != int(sharps): raise KeySignatureException( f'Cannot get a KeySignature from this "number" of sharps: {sharps!r}; ' + 'did you mean to use a key.Key() object instead?') @@ -368,6 +363,7 @@ def __init__(self, sharps: int|None = 0, **keywords): + 'did you mean to use a key.Key() object instead?' ) from ve + # position on the circle of fifths, where 1 is one sharp, -1 is one flat self._sharps = sharps # need to store a list of pitch objects, used for creating a # non-traditional key @@ -379,7 +375,7 @@ def __init__(self, sharps: int|None = 0, **keywords): def _strDescription(self): output = '' ns = self.sharps - if ns is None: + if self.isNonTraditional: output = 'pitches: [' + ', '.join([str(p) for p in self.alteredPitches]) + ']' elif ns > 1: output = f'{ns} sharps' @@ -396,7 +392,7 @@ def _strDescription(self): def _reprInternal(self): return 'of ' + self._strDescription() - def asKey(self, mode: str|None = None, tonic: str|None = None): + def asKey(self, mode: str|None = None, tonic: str|None = None) -> Key: ''' Return a `key.Key` object representing this KeySignature object as a key in the given mode or in the given tonic. If `mode` is None, and `tonic` is not provided, @@ -429,7 +425,6 @@ def asKey(self, mode: str|None = None, tonic: str|None = None): * New in v7: `tonic` argument to solve for mode. ''' - our_sharps = self.sharps or 0 # || 0 in case of None -- non-standard key-signature if mode is not None and tonic is not None: warnings.warn(f'ignoring provided tonic: {tonic}', KeyWarning, stacklevel=2) if mode is None and tonic is None: @@ -438,7 +433,7 @@ def asKey(self, mode: str|None = None, tonic: str|None = None): majorSharpsToMode = {v: k for k, v in modeSharpsAlter.items()} majorSharps = pitchToSharps(tonic) try: - mode = majorSharpsToMode[our_sharps - majorSharps] + mode = majorSharpsToMode[self.sharps - majorSharps] except KeyError as ke: raise KeyException( f'Could not solve for mode from sharps={self.sharps}, tonic={tonic}') from ke @@ -448,7 +443,7 @@ def asKey(self, mode: str|None = None, tonic: str|None = None): raise KeyException(f'Mode {mode} is unknown') sharpAlterationFromMajor = modeSharpsAlter[mode] - pitchObj = sharpsToPitch(our_sharps - sharpAlterationFromMajor) + pitchObj = sharpsToPitch(self.sharps - sharpAlterationFromMajor) return Key(pitchObj.name, mode) @@ -491,7 +486,8 @@ def alteredPitches(self) -> list[pitch.Pitch]: Non-standard, non-traditional key signatures can set their own altered pitches cache. - >>> nonTrad = key.KeySignature(sharps=None) + >>> nonTrad = key.KeySignature() + >>> nonTrad.isNonTraditional = True >>> nonTrad.alteredPitches = ['B-', 'F#', 'E-', 'G#'] >>> nonTrad.alteredPitches [, @@ -503,16 +499,16 @@ def alteredPitches(self) -> list[pitch.Pitch]: Ensure at least something is provided when the user hasn't provided enough info: - >>> nonTrad2 = key.KeySignature(sharps=None) + >>> nonTrad2 = key.KeySignature() + >>> nonTrad2.isNonTraditional = True >>> nonTrad2.alteredPitches [] - ''' if self._alteredPitches is not None: return self._alteredPitches post: list[pitch.Pitch] = [] - if self.sharps is None: + if self.isNonTraditional: return post if self.sharps > 0: @@ -522,7 +518,7 @@ def alteredPitches(self) -> list[pitch.Pitch]: for i in range(self.sharps): pKeep.transpose('P5', inPlace=True) p = copy.deepcopy(pKeep) - p.octave = None + p.octaveIsImplicit = True post.append(p) elif self.sharps < 0: @@ -530,13 +526,13 @@ def alteredPitches(self) -> list[pitch.Pitch]: for i in range(abs(self.sharps)): pKeep.transpose('P4', inPlace=True) p = copy.deepcopy(pKeep) - p.octave = None + p.octaveIsImplicit = True post.append(p) return post @alteredPitches.setter - def alteredPitches(self, newAlteredPitches: list[str|pitch.Pitch|note.Note] + def alteredPitches(self, newAlteredPitches: t.Iterable[str|pitch.Pitch|note.Note] ) -> None: self.clearCache() newList: list[pitch.Pitch] = [] @@ -549,31 +545,6 @@ def alteredPitches(self, newAlteredPitches: list[str|pitch.Pitch|note.Note] newList.append(copy.deepcopy(p.pitch)) self._alteredPitches = newList - @property - def isNonTraditional(self) -> bool: - ''' - Returns bool if this is a non-traditional KeySignature: - - >>> g = key.KeySignature(3) - >>> g.isNonTraditional - False - - >>> g = key.KeySignature(sharps=None) - >>> g.alteredPitches = [pitch.Pitch('E`')] - >>> g.isNonTraditional - True - - >>> g - - - >>> g.accidentalByStep('E') - - ''' - if self.sharps is None and self.alteredPitches: - return True - else: - return False - def accidentalByStep(self, step: StepName) -> pitch.Accidental|None: ''' Given a step (C, D, E, F, etc.) return the accidental @@ -809,7 +780,7 @@ def transposePitchFromC(self, p: pitch.Pitch, *, inPlace=False) -> pitch.Pitch|N for i in range(transTimes): transInterval.transposePitch(p, inPlace=True) - if originalOctave is not None: + if not p.octaveIsImplicit: p.octave = originalOctave if not inPlace: @@ -844,16 +815,9 @@ def getScale(self, mode='major'): # -------------------------------------------------------------------------- # properties - def _getSharps(self) -> int|None: - return self._sharps - - def _setSharps(self, value: int|None): - if value != self._sharps: - self._sharps = value - self.clearCache() - - sharps = property(_getSharps, _setSharps, - doc=''' + @property + def sharps(self) -> int: + ''' Get or set the number of sharps. If the number is negative then it sets the number of flats. Equivalent to musicxml's 'fifths' attribute. @@ -864,9 +828,14 @@ def _setSharps(self, value: int|None): >>> ks1.sharps = -4 >>> ks1 + ''' + return self._sharps - Can be set to None for a non-traditional key signature - ''') + @sharps.setter + def sharps(self, value: int) -> None: + if value != self._sharps: + self._sharps = value + self.clearCache() class Key(KeySignature, scale.DiatonicScale): @@ -1295,7 +1264,7 @@ def transpose(self, >>> changingKey ''' - if inPlace is True: + if inPlace: super().transpose(value, inPlace=inPlace) post = self else: @@ -1416,6 +1385,32 @@ def testAsKey(self): # test exception chained from KeyError self.assertIsInstance(cm.exception.__cause__, KeyError) + def testNonTraditional(self): + ''' + AI-assisted (Claude). + ''' + ks = KeySignature(3) + self.assertFalse(ks.isNonTraditional) + + ks = KeySignature() + ks.isNonTraditional = True + ks.alteredPitches = [pitch.Pitch('E`')] + self.assertEqual(repr(ks), '') + self.assertEqual(ks.accidentalByStep('E'), pitch.Accidental('half-flat')) + + # a non-traditional key signature is not equal to the C-major signature + # it shares a `sharps` count with. + self.assertNotEqual(ks, KeySignature()) + + def testSharpsNoneDeprecated(self): + ''' + AI-assisted (Claude). + ''' + with self.assertWarns(exceptions21.Music21DeprecationWarning): + ks = KeySignature(sharps=None) + self.assertEqual(ks.sharps, 0) + self.assertTrue(ks.isNonTraditional) + # ------------------------------------------------------------------------------ # define presented order in documentation diff --git a/music21/languageExcerpts/instrumentLookup.py b/music21/languageExcerpts/instrumentLookup.py index 89a2c7b24e..8cc7d4f733 100644 --- a/music21/languageExcerpts/instrumentLookup.py +++ b/music21/languageExcerpts/instrumentLookup.py @@ -176,20 +176,25 @@ 'bassoons': 'Bassoon', 'bell lira': 'Glockenspiel', 'bell lyre': 'Glockenspiel', + 'bell tree': 'BellTree', 'bongo drums': 'BongoDrums', 'brass instrument': 'BrassInstrument', 'bullseye gong': 'TamTam', + 'cabasa': 'Cabasa', 'castanets': 'Castanets', + 'caxixi': 'Caxixi', 'celesta': 'Celesta', 'celeste': 'Celesta', 'cello': 'Violoncello', 'cellos': 'Violoncello', 'chau gong': 'TamTam', 'chimes': 'Glockenspiel', + 'chinese cymbal': 'ChineseCymbal', 'choir': 'Choir', 'church bells': 'ChurchBells', 'clarinet': 'Clarinet', 'clarinets': 'Clarinet', + 'claves': 'Claves', 'clavichord': 'Clavichord', 'conductor': 'Conductor', 'conga drum': 'CongaDrum', @@ -200,6 +205,8 @@ 'cor anglais': 'EnglishHorn', 'cowbells': 'Cowbell', 'crash cymbals': 'CrashCymbals', + 'cuica': 'Cuica', + 'cuíca': 'Cuica', 'cymbals': 'Cymbals', 'double bass': 'Contrabass', 'double basses': 'Contrabass', @@ -218,14 +225,19 @@ 'fretless bass': 'FretlessBass', 'glockenspiel': 'Glockenspiel', 'gong': 'Gong', + 'guiro': 'Guiro', 'guitar': 'AcousticGuitar', + 'güiro': 'Guiro', 'handbells': 'Handbells', 'harmonica': 'Harmonica', 'harp': 'Harp', 'harps': 'Harp', 'harpsichord': 'Harpsichord', - 'hi-hat': 'HiHatCymbal', - 'hi-hat cymbal': 'HiHatCymbal', + 'hi-hat': 'HiHat', + 'hi-hat cymbal': 'HiHat', + 'hi-hat cymbals': 'HiHat', + 'high hat': 'HiHat', + 'hihat': 'HiHat', 'horn': 'Horn', 'horns': 'Horn', 'jingle bells': 'SleighBells', @@ -263,11 +275,13 @@ 'rattle': 'Ratchet', 'recorder': 'Recorder', 'reed organ': 'ReedOrgan', - 'ride cymbals': 'RideCymbals', + 'ride cymbal': 'RideCymbal', + 'ride cymbals': 'RideCymbal', 'sampler': 'Sampler', 'sandpaper blocks': 'SandpaperBlocks', 'saxophone': 'Saxophone', 'saxophones': 'Saxophone', + 'shaker': 'Shaker', 'shakuhachi': 'Shakuhachi', 'shamisen': 'Shamisen', 'shehnai': 'Shehnai', @@ -277,17 +291,19 @@ 'slapstick': 'Whip', 'sleigh bells': 'SleighBells', 'snare drum': 'SnareDrum', - 'sock cymbal': 'HiHatCymbal', + 'sock cymbal': 'HiHat', 'soprano': 'Soprano', 'soprano saxophone': 'SopranoSaxophone', 'sopranos': 'Soprano', - 'splash cymbals': 'SplashCymbals', + 'splash cymbal': 'SplashCymbal', + 'splash cymbals': 'SplashCymbal', 'steel drum': 'SteelDrum', 'steel drums': 'SteelDrum', 'steel pan': 'SteelDrum', 'steel pans': 'SteelDrum', 'string instrument': 'StringInstrument', 'strings': 'StringInstrument', + 'surdo': 'Surdo', 'suspended cymbal': 'SuspendedCymbal', 'taiko': 'Taiko', 'tam-tam': 'TamTam', @@ -359,7 +375,7 @@ 'chant': 'Vocalist', 'chanteur': 'Vocalist', 'chanteuse': 'Vocalist', - 'charleston': 'HiHatCymbal', + 'charleston': 'HiHat', 'claquebois': 'Xylophone', 'clarinette': 'Clarinet', 'clarinette basse': 'BassClarinet', @@ -395,7 +411,7 @@ 'cuivres': 'BrassInstrument', 'cymbale sur tiges': 'SizzleCymbal', 'cymbale suspendue': 'SuspendedCymbal', - 'cymbale à pédale': 'HiHatCymbal', + 'cymbale à pédale': 'HiHat', 'cymbales': 'CrashCymbals', 'cymbales digitales': 'FingerCymbals', 'célesta': 'Celesta', @@ -424,7 +440,7 @@ 'harpe': 'Harp', 'harpes': 'Harp', 'hautbois': 'Oboe', - 'hi-hat': 'HiHatCymbal', + 'hi-hat': 'HiHat', 'instrument à cordes': 'StringInstrument', 'instrument à percussion': 'Percussion', 'instrument à vent': 'WoodwindInstrument', @@ -445,7 +461,7 @@ 'roseau organe': 'ReedOrgan', 'sagates': 'FingerCymbals', 'sagattes': 'FingerCymbals', - 'salut-chapeau cymbale': 'HiHatCymbal', + 'salut-chapeau cymbale': 'HiHat', 'saxophon alto': 'AltoSaxophone', 'saxophone alto': 'AltoSaxophone', 'saxophone baryton': 'BaritoneSaxophone', @@ -549,15 +565,15 @@ 'fingerzimbeln': 'FingerCymbals', 'flöte': 'Flute', 'flöten': 'Flute', - 'fussbecken': 'HiHatCymbal', + 'fussbecken': 'HiHat', 'geige': 'Violin', 'gitarre': 'AcousticGuitar', 'glocken': 'TubularBells', 'grosse trommel': 'BassDrum', 'guitarre': 'AcousticGuitar', - 'h-hat becken': 'HiHatCymbal', + 'h-hat becken': 'HiHat', 'hackbrett': 'Dulcimer', - 'hallo-hat-becken': 'HiHatCymbal', + 'hallo-hat-becken': 'HiHat', 'handglocken': 'Handbells', 'handharmonika': 'Accordion', 'harfe': 'Harp', @@ -649,7 +665,7 @@ 'streicher': 'StringInstrument', 'strohfiedel': 'Xylophone', 'stromgitarre': 'ElectricGuitar', - 'ständerbecken': 'HiHatCymbal', + 'ständerbecken': 'HiHat', 'syrinx': 'PanFlute', 'tambourin': 'Tambourine', 'tamburin': 'Tambourine', @@ -718,7 +734,7 @@ 'cembalo': 'Harpsichord', 'ceppi di carta vetro': 'SandpaperBlocks', 'cestello in acciaio': 'SteelDrum', - 'charleston': 'HiHatCymbal', + 'charleston': 'HiHat', 'chitarra': 'AcousticGuitar', 'chitarra acustica': 'AcousticGuitar', 'chitarra elettrica': 'ElectricGuitar', @@ -765,8 +781,8 @@ 'gigelira': 'Xylophone', 'gran cassa': 'BassDrum', 'grancassa': 'BassDrum', - 'hi-hat': 'HiHatCymbal', - 'hi-hat piatto': 'HiHatCymbal', + 'hi-hat': 'HiHat', + 'hi-hat piatto': 'HiHat', 'legni': 'WoodwindInstrument', 'liuto': 'Lute', 'macchina del vento': 'WindMachine', @@ -784,7 +800,7 @@ 'percussione': 'Percussion', 'pianoforte': 'Piano', 'piatti': 'CrashCymbals', - 'piatti a pedale': 'HiHatCymbal', + 'piatti a pedale': 'HiHat', 'piatti di crash': 'CrashCymbals', 'piatto chiodati': 'SizzleCymbal', 'piatto sospeso': 'SuspendedCymbal', @@ -986,8 +1002,8 @@ 'caña de órganos': 'ReedOrgan', 'cello': 'Violoncello', 'cencerros': 'Cowbell', - 'charles': 'HiHatCymbal', - 'charleston': 'HiHatCymbal', + 'charles': 'HiHat', + 'charleston': 'HiHat', 'chelo': 'Violoncello', 'chelos': 'Violoncello', 'chinchines': 'FingerCymbals', @@ -1050,8 +1066,8 @@ 'guitarra acústica': 'AcousticGuitar', 'guitarra eléctrica': 'ElectricGuitar', 'harmónica': 'Harmonica', - 'hi hat': 'HiHatCymbal', - 'hi-hat': 'HiHatCymbal', + 'hi hat': 'HiHat', + 'hi-hat': 'HiHat', 'instrumento de cuerda': 'StringInstrument', 'instrumento de madera': 'WoodwindInstrument', 'instrumento de percusión': 'Percussion', @@ -1074,7 +1090,7 @@ 'pandereta': 'Tambourine', 'papel de lija': 'SandpaperBlocks', 'percusión': 'Percussion', - 'platillo hi-hat': 'HiHatCymbal', + 'platillo hi-hat': 'HiHat', 'platillo sizzle': 'SizzleCymbal', 'platillo suspendido': 'SuspendedCymbal', 'platillos': 'Cymbals', diff --git a/music21/languageExcerpts/naturalLanguageObjects.py b/music21/languageExcerpts/naturalLanguageObjects.py index 40c8caff8b..05fbd7ed28 100644 --- a/music21/languageExcerpts/naturalLanguageObjects.py +++ b/music21/languageExcerpts/naturalLanguageObjects.py @@ -211,7 +211,7 @@ def testConvertNotes(self) -> None: self.assertEqual('', repr(toNote('Heses', 'de'))) self.assertEqual('', repr(toNote('Eisis', 'de'))) self.assertEqual('', - repr(toNote('la quadruple dièse', 'fr'))) + repr(toNote('la quadruple dièse', 'fr'))) self.assertEqual('', repr(toNote('si triple bémol', 'fr'))) def testConvertChords(self) -> None: diff --git a/music21/layout.py b/music21/layout.py index e423c2c817..580c8b2329 100644 --- a/music21/layout.py +++ b/music21/layout.py @@ -442,10 +442,27 @@ def __init__(self, # -------------------------------------------------------------------------- - def _getBarTogether(self) -> t.Literal[True, False, None, 'Mensurstrich']: + @property + def barTogether(self) -> t.Literal[True, False, None, 'Mensurstrich']: + ''' + Get or set the barTogether value, with either Boolean values + or yes or no strings. Or the string 'Mensurstrich' which + indicates barring between staves but not in staves. + + Currently Mensurstrich is not supported by most exporters. + + >>> sg = layout.StaffGroup() + >>> sg.barTogether = 'yes' + >>> sg.barTogether + True + >>> sg.barTogether = 'Mensurstrich' + >>> sg.barTogether + 'Mensurstrich' + ''' return self._barTogether - def _setBarTogether(self, value: t.Literal[True, False, None, 'Mensurstrich', 'yes', 'no']): + @barTogether.setter + def barTogether(self, value: t.Literal[True, False, None, 'Mensurstrich', 'yes', 'no']): if value is None: pass # do nothing for now; could set a default elif value in ['yes', True]: @@ -457,26 +474,20 @@ def _setBarTogether(self, value: t.Literal[True, False, None, 'Mensurstrich', 'y else: raise StaffGroupException(f'the bar together value {value} is not acceptable') - barTogether = property(_getBarTogether, _setBarTogether, doc=''' - Get or set the barTogether value, with either Boolean values - or yes or no strings. Or the string 'Mensurstrich' which - indicates barring between staves but not in staves. - - Currently Mensurstrich is not supported by most exporters. + @property + def symbol(self) -> t.Literal['bracket', 'line', 'brace', 'square']|None: + ''' + Get or set the symbol value, with either Boolean values or yes or no strings. >>> sg = layout.StaffGroup() - >>> sg.barTogether = 'yes' - >>> sg.barTogether - True - >>> sg.barTogether = 'Mensurstrich' - >>> sg.barTogether - 'Mensurstrich' - ''') - - def _getSymbol(self) -> t.Literal['bracket', 'line', 'brace', 'square']|None: + >>> sg.symbol = 'Brace' + >>> sg.symbol + 'brace' + ''' return self._symbol - def _setSymbol(self, value: t.Literal['bracket', 'line', 'brace', 'square']|None): + @symbol.setter + def symbol(self, value: t.Literal['bracket', 'line', 'brace', 'square']|None): if value is None or str(value).lower() == 'none': self._symbol = None elif value.lower() in ['brace', 'line', 'bracket', 'square']: @@ -484,15 +495,6 @@ def _setSymbol(self, value: t.Literal['bracket', 'line', 'brace', 'square']|None else: raise StaffGroupException(f'the symbol value {value} is not acceptable') - symbol = property(_getSymbol, _setSymbol, doc=''' - Get or set the symbol value, with either Boolean values or yes or no strings. - - >>> sg = layout.StaffGroup() - >>> sg.symbol = 'Brace' - >>> sg.symbol - 'brace' - ''') - # --------------------------------------------------------------- # Stream subclasses for layout @@ -1658,9 +1660,8 @@ def testGetStaffLayoutFromStaff(self): We have had problems with attributes disappearing. ''' from music21 import corpus - from music21 import layout lt = corpus.parse('demos/layoutTest.xml') - ls = layout.divideByPages(lt, fastMeasures=True) + ls = divideByPages(lt, fastMeasures=True) hiddenStaff = ls.pages[0].systems[3].staves[1] self.assertTrue(repr(hiddenStaff).endswith('Staff 11: p.1, sys.4, st.2>'), diff --git a/music21/lily/lilyObjects.py b/music21/lily/lilyObjects.py index c9a4eeb24f..468b78d8e5 100644 --- a/music21/lily/lilyObjects.py +++ b/music21/lily/lilyObjects.py @@ -13,18 +13,26 @@ local computer, can automatically generate .pdf, .png, and .svg versions of musical files using LilyPond. -The Grammar for LilyPond comes from -http://lilypond.org/doc/v2.14/Documentation/notation/lilypond-grammar +The class hierarchy mirrors the grammar in +https://lilypond.org/doc/v2.14/Documentation/notation/lilypond-grammar +(the last LilyPond to publish one is v2.19; since then it is `lily/parser.yy`). + +Output is written for LilyPond 2.24 and checked against it. Aim to support +3-4 years of LilyPond. ''' from __future__ import annotations import typing as t import unittest +import weakref from music21 import common from music21 import exceptions21 from music21 import prebase +if t.TYPE_CHECKING: + from music21 import base + class LilyObjectsException(exceptions21.Music21Exception): pass @@ -38,24 +46,25 @@ class LyObject(prebase.ProtoM21Object): >>> lyo.stringOutput() '' + * Changed in v11: `stringOutput()` always returns a `str`, never None. ''' - supportedClasses: list[object] = [] # ordered list of classes to support - m21toLy: dict[str, dict] = {} + supportedClasses: list[str] = [] # ordered list of classes to support + m21toLy: dict[str, dict[str, str]] = {} defaultAttributes: dict[str, t.Any] = {} backslash = '\\' - def __init__(self): + def __init__(self) -> None: # self.context = context - self.lilyAttributes = {} - self._parent = None + self.lilyAttributes: dict[str, t.Any] = {} + self._parent: weakref.ReferenceType[LyObject]|None = None self.thisIndent = 0 - self.markupTop = None - self.lyricMarkupOrIdentifier = None - self.markupListOrIdentifier = None - self.markupTopOrIdentifier = None + self.markupTop: LyObject|str|None = None + self.lyricMarkupOrIdentifier: LyObject|str|None = None + self.markupListOrIdentifier: LyObject|str|None = None + self.markupTopOrIdentifier: LyObject|str|None = None # self.setLilyAttributes(inObject, context, **keywords) - def __setattr__(self, name, value): + def __setattr__(self, name: str, value: t.Any) -> None: if isinstance(value, LyObject): value.setParent(self) elif common.isIterable(value): @@ -66,15 +75,15 @@ def __setattr__(self, name, value): object.__setattr__(self, name, value) - def getParent(self): - if self._parent is not None: - actualParent = common.unwrapWeakref(self._parent) - return actualParent + def getParent(self) -> LyObject|None: + if self._parent is None: + return None + return self._parent() - def setParent(self, parentObject): - self._parent = common.wrapWeakref(parentObject) + def setParent(self, parentObject: LyObject) -> None: + self._parent = weakref.ref(parentObject) - def ancestorList(self): + def ancestorList(self) -> list[LyObject]: r''' returns a list of all unwrapped parent objects for the current object ''' @@ -85,7 +94,11 @@ def ancestorList(self): currentParent = currentParent.getParent() return ancestors - def getAncestorByClass(self, classObj, getAncestorNumber=1): + def getAncestorByClass( + self, + classObj: type[LyObject], + getAncestorNumber: int = 1, + ) -> LyObject|None: currentIter = 1 for a in self.ancestorList(): if isinstance(a, classObj): @@ -96,7 +109,7 @@ def getAncestorByClass(self, classObj, getAncestorNumber=1): return None @property - def newlineIndent(self): + def newlineIndent(self) -> str: # totalIndents = self.thisIndent ancestors = self.ancestorList() # for ancestor in ancestors: @@ -105,7 +118,7 @@ def newlineIndent(self): indentSpaces = ' ' * totalIndents return '\n' + indentSpaces - def setAttributes(self, m21Object): + def setAttributes(self, m21Object: base.Music21Object) -> dict[str, t.Any]: r''' Returns a dictionary and sets self.lilyAttributes to that dictionary, for a m21Object of class classLookup using the mapping of self.m21toLy[classLookup] @@ -135,21 +148,19 @@ def setAttributes(self, m21Object): >>> lilyAttributes is lm.lilyAttributes True ''' - attrs = None - foundClass = False for tryClass in self.supportedClasses: if tryClass in m21Object.classes or tryClass == '*': - attrs = self.setAttributesFromClassObject(tryClass, m21Object) - foundClass = True - break + return self.setAttributesFromClassObject(tryClass, m21Object) - if not foundClass: # pragma: no cover - raise LilyObjectsException( - 'Could not support setting attributes from ' - f'{m21Object}: supported classes: {self.supportedClasses}') - return attrs + raise LilyObjectsException( # pragma: no cover + 'Could not support setting attributes from ' + f'{m21Object}: supported classes: {self.supportedClasses}') - def setAttributesFromClassObject(self, classLookup, m21Object): + def setAttributesFromClassObject( + self, + classLookup: str, + m21Object: base.Music21Object, + ) -> dict[str, t.Any]: r''' Returns a dictionary and sets self.lilyAttributes to that dictionary, for a m21Object of class classLookup using the mapping of self.m21toLy[classLookup] @@ -209,21 +220,24 @@ def _reprInternal(self) -> str: msg = msg[:10] + '...' return msg - def __str__(self): + def __str__(self) -> str: so = self.stringOutput() so = so.replace('\n\n', '\n') return so - def stringOutput(self): + def stringOutput(self) -> str: return '' - def getFirstNonNoneAttribute(self, attributeList): + def getFirstNonNoneAttribute(self, attributeList: t.Iterable[str]) -> t.Any: for a in attributeList: if getattr(self, a) is not None: return getattr(self, a) return None - def newlineSeparateStringOutputIfNotNone(self, contents): + def newlineSeparateStringOutputIfNotNone( + self, + contents: t.Iterable[LyObject|str|None], + ) -> str: c = '' for n in contents: if n is None: @@ -232,7 +246,7 @@ def newlineSeparateStringOutputIfNotNone(self, contents): return c - def encloseCurly(self, arg): + def encloseCurly(self, arg: t.Sequence[str]|LyObject|str|None) -> str: if isinstance(arg, list): strArg = self.newlineIndent.join(arg) return ''.join([' { ', self.newlineIndent, strArg, self.newlineIndent, @@ -243,19 +257,20 @@ def encloseCurly(self, arg): else: return ' { } ' - def quoteString(self, stringIn): + def quoteString(self, stringIn: str) -> str: r''' returns a string that is quoted with - internal quotation marks backslash'd out + internal quotation marks and backslashes backslash'd out and an extra space at the end. >>> m = lily.lilyObjects.LyObject() >>> print(m.quoteString(r'Hello "there"!')) "Hello \"there\"!" ''' - stringNew = stringIn.replace('"', r'\"') + stringNew = stringIn.replace('\\', '\\\\').replace('"', r'\"') return '"' + stringNew + '" ' + # noinspection GrazieInspection def comment(self, stringIn: str) -> str: r''' returns a comment that is %{ stringIn.strip() %} @@ -268,11 +283,10 @@ def comment(self, stringIn: str) -> str: class LyMock(LyObject): r''' A test object for trying various music21 to Lily conversions - ''' supportedClasses = ['Mock', 'Mocker'] m21toLy = {'Mock': {'mockAttribute': 'mock-attribute', - 'mockAttribute2': 'mock-attribute-2', + 'mockAttribute2': 'mock-attribute-2', }, 'Mocker': {'mockerAttribute': 'mock-attribute', 'greg': 'mock-attribute-2', }, @@ -299,13 +313,13 @@ class LyLilypondTop(LyObject): ''' canContain = [None, 'TopLevelExpression', 'Assignment'] - def __init__(self, contents=None): + def __init__(self, contents: list[LyObject|str]|None = None) -> None: if contents is None: contents = [] super().__init__() self.contents = contents - def stringOutput(self): + def stringOutput(self) -> str: return self.newlineSeparateStringOutputIfNotNone(self.contents) @@ -327,11 +341,16 @@ class LyTopLevelExpression(LyObject): >>> str(lyTopLevel) '\\book { } ' ''' - - def __init__(self, lilypondHeader=None, bookBlock=None, - bookPartBlock=None, scoreBlock=None, compositeMusic=None, - fullMarkup=None, fullMarkupList=None, outputDef=None - ): + def __init__(self, + lilypondHeader: LyLilypondHeader|None = None, + bookBlock: LyBookBlock|None = None, + bookPartBlock: LyBookpartBlock|None = None, + scoreBlock: LyScoreBlock|None = None, + compositeMusic: LyCompositeMusic|None = None, + fullMarkup: LyFullMarkup|None = None, + fullMarkupList: LyFullMarkupList|None = None, + outputDef: LyOutputDef|None = None, + ) -> None: super().__init__() self.lilypondHeader = lilypondHeader self.bookBlock = bookBlock @@ -342,7 +361,7 @@ def __init__(self, lilypondHeader=None, bookBlock=None, self.fullMarkupList = fullMarkupList self.outputDef = outputDef - def stringOutput(self): + def stringOutput(self) -> str: outputObject = self.getFirstNonNoneAttribute([ 'lilypondHeader', 'bookBlock', 'bookPartBlock', 'scoreBlock', 'compositeMusic', 'fullMarkup', 'fullMarkupList', 'outputDef']) @@ -359,12 +378,11 @@ class LyLilypondHeader(LyObject): >>> str(lyh) '\\header { } ' ''' - - def __init__(self, lilypondHeaderBody=None): + def __init__(self, lilypondHeaderBody: LyLilypondHeaderBody|None = None) -> None: super().__init__() self.lilypondHeaderBody = lilypondHeaderBody - def stringOutput(self): + def stringOutput(self) -> str: return self.backslash + 'header' + self.encloseCurly(self.lilypondHeaderBody) @@ -377,30 +395,29 @@ class LyEmbeddedScm(LyObject): Note that if any LyEmbeddedScm is found in an output then the output SHOULD be marked as unsafe. But a lot of standard lilypond functions are actually embedded scheme. For instance, \clef, which - as http://lilypond.org/doc/v2.12/input/lsr/lilypond-snippets/Pitches#Tweaking-clef-properties + as https://lilypond.org/doc/v2.12/input/lsr/lilypond-snippets/Pitches#Tweaking-clef-properties shows is a macro to run a lot of \set commands. >>> lyScheme = lily.lilyObjects.LyEmbeddedScm('##t') >>> str(lyScheme) '##t' ''' - - def __init__(self, content=None): + def __init__(self, content: str = '') -> None: super().__init__() self.content = content - def stringOutput(self): + def stringOutput(self) -> str: return self.content class LyLilypondHeaderBody(LyObject): - def __init__(self, assignments=None): + def __init__(self, assignments: list[LyAssignment]|None = None) -> None: if assignments is None: assignments = [] super().__init__() self.assignments = assignments - def stringOutput(self): + def stringOutput(self) -> str: return self.newlineSeparateStringOutputIfNotNone(self.assignments) @@ -410,13 +427,12 @@ class LyAssignmentId(LyObject): >>> str(lyAssignmentId) 'title' ''' - - def __init__(self, content=None, isLyricString=False): + def __init__(self, content: str = '', isLyricString: bool = False) -> None: super().__init__() self.content = content self.isLyricString = isLyricString - def stringOutput(self): + def stringOutput(self) -> str: return self.content @@ -440,16 +456,19 @@ class LyAssignment(LyObject): Note that you could also pass assignmentId a LyAssignmentId object, but that's overkill for a lot of things. ''' - - def __init__(self, assignmentId=None, identifierInit=None, - propertyPath=None, embeddedScm=None): + def __init__(self, + assignmentId: LyAssignmentId|str|None = None, + identifierInit: LyIdentifierInit|None = None, + propertyPath: LyPropertyPath|None = None, + embeddedScm: LyEmbeddedScm|None = None, + ) -> None: super().__init__() self.assignmentId = assignmentId self.identifierInit = identifierInit self.propertyPath = propertyPath self.embeddedScm = embeddedScm - def stringOutput(self): + def stringOutput(self) -> str: if self.embeddedScm is not None: return self.embeddedScm.stringOutput() elif self.propertyPath is not None: @@ -468,21 +487,26 @@ def stringOutput(self): class LyIdentifierInit(LyObject): r''' - >>> lyIdInit = lily.lilyObjects.LyIdentifierInit(string='hello') >>> print(lyIdInit) "hello" ''' - def __init__(self, - scoreBlock=None, - bookBlock=None, - bookPartBlock=None, - outputDef=None, - contextDefSpecBlock=None, - music=None, postEvent=None, numberExpression=None, - string=None, embeddedScm=None, fullMarkup=None, fullMarkupList=None, - digit=None, contextModification=None): + scoreBlock: LyScoreBlock|None = None, + bookBlock: LyBookBlock|None = None, + bookPartBlock: LyBookpartBlock|None = None, + outputDef: LyOutputDef|None = None, + contextDefSpecBlock: LyContextDefSpecBlock|None = None, + music: LyMusic|None = None, + postEvent: LyPostEvent|None = None, + numberExpression: LyNumberExpression|None = None, + string: str|None = None, + embeddedScm: LyEmbeddedScm|None = None, + fullMarkup: LyFullMarkup|None = None, + fullMarkupList: LyFullMarkupList|None = None, + digit: int|None = None, + contextModification: LyContextModification|None = None, + ) -> None: super().__init__() self.scoreBlock = scoreBlock self.bookBlock = bookBlock @@ -499,7 +523,7 @@ def __init__(self, self.digit = digit self.contextModification = contextModification - def stringOutput(self): + def stringOutput(self) -> str: outputObject = self.getFirstNonNoneAttribute([ 'scoreBlock', 'bookBlock', 'bookPartBlock', 'outputDef', 'contextDefSpecBlock', 'music', 'postEvent', 'numberExpression', @@ -510,18 +534,18 @@ def stringOutput(self): if outputObject is self.digit: # better test for digit return str(outputObject) - elif outputObject is self.string: - return self.quoteString(outputObject) + elif self.string is not None and outputObject is self.string: + return self.quoteString(self.string) else: return outputObject.stringOutput() class LyContextDefSpecBlock(LyObject): - def __init__(self, contextDefSpecBody=None): + def __init__(self, contextDefSpecBody: LyContextDefSpecBody|None = None) -> None: super().__init__() self.contextDefSpecBody = contextDefSpecBody - def stringOutput(self): + def stringOutput(self) -> str: return self.backslash + 'context ' + self.encloseCurly(self.contextDefSpecBody) @@ -544,9 +568,13 @@ class LyContextDefSpecBody(LyObject): >>> lyContextBody.stringOutput() 'body \\grobdescriptions #t' ''' - - def __init__(self, contextDefIdentifier=None, contextDefSpecBody=None, - embeddedScm=None, contextMod=None, contextModification=None): + def __init__(self, + contextDefIdentifier: str|None = None, + contextDefSpecBody: str|None = None, + embeddedScm: LyEmbeddedScm|None = None, + contextMod: LyContextMod|None = None, + contextModification: LyContextModification|None = None, + ) -> None: super().__init__() self.contextDefIdentifier = contextDefIdentifier self.contextDefSpecBody = contextDefSpecBody @@ -554,7 +582,7 @@ def __init__(self, contextDefIdentifier=None, contextDefSpecBody=None, self.contextMod = contextMod self.contextModification = contextModification - def stringOutput(self): + def stringOutput(self) -> str: if self.contextDefIdentifier is not None: return self.contextDefIdentifier elif self.embeddedScm is not None: @@ -576,15 +604,15 @@ def stringOutput(self): else: return self.contextModification.stringOutput() else: - return None + return '' class LyBookBlock(LyObject): - def __init__(self, bookBody=None): + def __init__(self, bookBody: LyBookBody|None = None) -> None: super().__init__() self.bookBody = bookBody - def stringOutput(self): + def stringOutput(self) -> str: return self.backslash + 'book' + ' ' + self.encloseCurly(self.bookBody) @@ -606,8 +634,8 @@ class LyBookBody(LyObject): 'bookId' >>> lyBookBody = lily.lilyObjects.LyBookBody() - >>> lyBookBody.stringOutput() is None - True + >>> lyBookBody.stringOutput() + '' >>> lyBookBody = lily.lilyObjects.LyBookBody(contents=['a', 'b', 'c']) >>> print(lyBookBody.stringOutput()) @@ -615,19 +643,21 @@ class LyBookBody(LyObject): b c ''' - - def __init__(self, contents=None, bookIdentifier=None): + def __init__(self, + contents: list[LyObject|str]|None = None, + bookIdentifier: str|None = None, + ) -> None: if contents is None: contents = [] super().__init__() self.contents = contents self.bookIdentifier = bookIdentifier - def stringOutput(self): + def stringOutput(self) -> str: if self.bookIdentifier is not None: return self.bookIdentifier elif not self.contents: - return None + return '' else: return self.newlineSeparateStringOutputIfNotNone(self.contents) @@ -638,12 +668,11 @@ class LyBookpartBlock(LyObject): >>> lbb.stringOutput() '\\bookpart { \n\n } \n' ''' - - def __init__(self, bookpartBody=None): + def __init__(self, bookpartBody: LyBookpartBody|None = None) -> None: super().__init__() self.bookpartBody = bookpartBody - def stringOutput(self): + def stringOutput(self) -> str: if self.bookpartBody is None: return self.backslash + 'bookpart ' + self.encloseCurly('') else: @@ -668,8 +697,8 @@ class LyBookpartBody(LyObject): 'bookId' >>> lyBookpartBody = lily.lilyObjects.LyBookpartBody() - >>> lyBookpartBody.stringOutput() is None - True + >>> lyBookpartBody.stringOutput() + '' >>> lyBookpartBody = lily.lilyObjects.LyBookpartBody(contents=['a', 'b', 'c']) >>> print(lyBookpartBody.stringOutput()) @@ -677,19 +706,21 @@ class LyBookpartBody(LyObject): b c ''' - - def __init__(self, contents=None, bookIdentifier=None): + def __init__(self, + contents: list[LyObject|str]|None = None, + bookIdentifier: str|None = None, + ) -> None: if contents is None: contents = [] super().__init__() self.contents = contents self.bookIdentifier = bookIdentifier - def stringOutput(self): + def stringOutput(self) -> str: if self.bookIdentifier is not None: return self.bookIdentifier elif not self.contents: - return None + return '' else: return self.newlineSeparateStringOutputIfNotNone(self.contents) @@ -704,12 +735,11 @@ class LyScoreBlock(LyObject): >>> print(lyScoreBlock) \score { hello } ''' - - def __init__(self, scoreBody=None): + def __init__(self, scoreBody: LyScoreBody|str|None = None) -> None: super().__init__() self.scoreBody = scoreBody - def stringOutput(self): + def stringOutput(self) -> str: if self.scoreBody is None: raise LilyObjectsException('scoreBody object cannot be empty!') # pragma: no cover @@ -728,9 +758,14 @@ class LyScoreBody(LyObject): >>> str(lsb) 'score' ''' - - def __init__(self, music=None, scoreIdentifier=None, scoreBody=None, lilypondHeader=None, - outputDef=None, error=None): + def __init__(self, + music: LyMusic|None = None, + scoreIdentifier: str|None = None, + scoreBody: LyScoreBody|None = None, + lilypondHeader: LyLilypondHeader|None = None, + outputDef: LyOutputDef|None = None, + error: LyObject|None = None, + ) -> None: super().__init__() self.music = music self.scoreIdentifier = scoreIdentifier @@ -739,7 +774,7 @@ def __init__(self, music=None, scoreIdentifier=None, scoreBody=None, lilypondHea self.outputDef = outputDef self.error = error - def stringOutput(self): + def stringOutput(self) -> str: if self.music is not None: return self.music.stringOutput() elif self.scoreIdentifier is not None: @@ -761,18 +796,18 @@ def stringOutput(self): class LyPaperBlock(LyObject): - def __init__(self, outputDef=None): + def __init__(self, outputDef: LyOutputDef|None = None) -> None: super().__init__() self.outputDef = outputDef - def stringOutput(self): + def stringOutput(self) -> str: if self.outputDef is None: # legal?? - return None + return '' else: return self.outputDef.stringOutput() class LyLayout(LyObject): - def stringOutput(self): + def stringOutput(self) -> str: theseStrings = [self.backslash + 'layout {', ' ' + self.backslash + 'context {', ' ' + self.backslash + 'RemoveEmptyStaves', @@ -786,12 +821,11 @@ class LyOutputDef(LyObject): r''' This is an ugly grammar, since it does not close the curly bracket. ''' - - def __init__(self, outputDefBody=None): + def __init__(self, outputDefBody: LyOutputDefBody|None = None) -> None: super().__init__() self.outputDefBody = outputDefBody - def stringOutput(self): + def stringOutput(self) -> str: if self.outputDefBody is None: raise LilyObjectsException('Need outputDefBody to be set') # pragma: no cover return self.outputDefBody.stringOutput() + '}' @@ -808,12 +842,11 @@ class LyOutputDefHead(LyObject): According to Appendix C, is the same as LyOutputDefHeadWithModeSwitch ''' - - def __init__(self, defType=None): + def __init__(self, defType: str|None = None) -> None: super().__init__() self.defType = defType - def stringOutput(self): + def stringOutput(self) -> str: if self.defType not in ('paper', 'midi', 'layout'): # pragma: no cover raise LilyObjectsException("self.defType must be one of 'paper', 'midi', or 'layout'") @@ -822,7 +855,6 @@ def stringOutput(self): class LyOutputDefBody(LyObject): r''' - output_def_body: output_def_head_with_mode_switch '{' | output_def_head_with_mode_switch '{' @@ -831,9 +863,14 @@ class LyOutputDefBody(LyObject): | output_def_body context_def_spec_block | output_def_body error ''' - - def __init__(self, outputDefHead=None, outputDefIdentifier=None, outputDefBody=None, - assignment=None, contextDefSpecBlock=None, error=None): + def __init__(self, + outputDefHead: LyOutputDefHead|None = None, + outputDefIdentifier: str|None = None, + outputDefBody: LyOutputDefBody|None = None, + assignment: LyAssignment|None = None, + contextDefSpecBlock: LyContextDefSpecBlock|None = None, + error: LyObject|None = None, + ) -> None: super().__init__() self.outputDefHead = outputDefHead self.outputDefIdentifier = outputDefIdentifier @@ -842,14 +879,14 @@ def __init__(self, outputDefHead=None, outputDefIdentifier=None, outputDefBody=N self.contextDefSpecBlock = contextDefSpecBlock self.error = error - def stringOutput(self): + def stringOutput(self) -> str: if self.outputDefHead is not None: out = str(self.outputDefHead) + ' { ' if self.outputDefIdentifier is not None: return out + str(self.outputDefIdentifier) else: return out - elif self.outputDefBody is not None: # pragma: no cover + elif self.outputDefBody is None: # pragma: no cover raise LilyObjectsException('Need embedded outputDefBody if outputDefIdentifier ' + 'or outputDefHead are not defined') elif self.assignment is not None: @@ -867,32 +904,43 @@ class LyTempoEvent(LyObject): r''' tempo_event: "\tempo" steno_duration '=' tempo_range | "\tempo" scalar steno_duration '=' tempo_range + | "\tempo" steno_duration '=' scalar | "\tempo" scalar >>> lte = lily.lilyObjects.LyTempoEvent(scalar='40') >>> str(lte) '\\tempo 40' - More complex: + A steno_duration paired with a single bpm scalar (and no tempoRange) + is the common case for a music21 MetronomeMark, e.g. quarter = 87: + + >>> steno = lily.lilyObjects.LyStenoDuration(4) + >>> lte = lily.lilyObjects.LyTempoEvent(stenoDuration=steno, scalar=87) + >>> str(lte) + '\\tempo 4 = 87' + + More complex, with a tempo range: - >>> steno = lily.lilyObjects.LyStenoDuration('quarter') >>> tempoRange = lily.lilyObjects.LyTempoRange(70, 100) >>> lte = lily.lilyObjects.LyTempoEvent(tempoRange=tempoRange, stenoDuration=steno) >>> str(lte) - '\\tempo quarter = 70~100 ' + '\\tempo 4 = 70-100 ' >>> lte.scalar = 85 >>> str(lte) - '\\tempo 85 quarter = 70~100 ' + '\\tempo 85 4 = 70-100 ' ''' - - def __init__(self, tempoRange=None, stenoDuration=None, scalar=None): + def __init__(self, + tempoRange: LyTempoRange|None = None, + stenoDuration: LyStenoDuration|None = None, + scalar: int|str|None = None, + ) -> None: super().__init__() self.tempoRange = tempoRange self.stenoDuration = stenoDuration self.scalar = scalar - def stringOutput(self): + def stringOutput(self) -> str: base = self.backslash + 'tempo' if self.tempoRange is not None: if self.stenoDuration is None: # pragma: no cover @@ -906,6 +954,11 @@ def stringOutput(self): else: return ' '.join([base, self.stenoDuration.stringOutput(), '=', self.tempoRange.stringOutput()]) + elif self.stenoDuration is not None: + if self.scalar is None: # pragma: no cover + raise LilyObjectsException( + 'If tempoRange is not defined but stenoDuration is, need a scalar') + return ' '.join([base, self.stenoDuration.stringOutput(), '=', str(self.scalar)]) elif self.scalar is None: # pragma: no cover raise LilyObjectsException('If tempoRange is not defined then need scalar') @@ -916,25 +969,27 @@ class LyMusicList(LyObject): r''' can take any number of LyMusic, LyEmbeddedScm, or LyError objects ''' - - def __init__(self, contents=None): + def __init__(self, contents: list[LyObject|str]|None = None) -> None: super().__init__() if contents is None: contents = [] self.contents = contents - def stringOutput(self): + def stringOutput(self) -> str: return self.newlineSeparateStringOutputIfNotNone(self.contents) class LyMusic(LyObject): - def __init__(self, simpleMusic=None, compositeMusic=None): + def __init__(self, + simpleMusic: LySimpleMusic|None = None, + compositeMusic: LyCompositeMusic|None = None, + ) -> None: super().__init__() self.simpleMusic = simpleMusic self.compositeMusic = compositeMusic - def stringOutput(self): + def stringOutput(self) -> str: if self.simpleMusic is not None: return self.simpleMusic.stringOutput() elif self.compositeMusic is not None: @@ -945,11 +1000,11 @@ def stringOutput(self): class LyAlternativeMusic(LyObject): - def __init__(self, musicList=None): + def __init__(self, musicList: LyMusicList|None = None) -> None: super().__init__() self.musicList = musicList - def stringOutput(self): + def stringOutput(self) -> str: if self.musicList is None: return '' else: @@ -958,14 +1013,24 @@ def stringOutput(self): class LyRepeatedMusic(LyObject): - def __init__(self, simpleString=None, unsignedNumber=None, music=None, alternativeMusic=None): + def __init__(self, + simpleString: LyObject|None = None, + unsignedNumber: LyObject|None = None, + music: LyMusic|None = None, + alternativeMusic: LyAlternativeMusic|None = None, + ) -> None: super().__init__() self.simpleString = simpleString self.unsignedNumber = unsignedNumber self.music = music self.alternativeMusic = alternativeMusic - def stringOutput(self): + def stringOutput(self) -> str: + if (self.simpleString is None + or self.unsignedNumber is None + or self.music is None): # pragma: no cover + raise LilyObjectsException( + 'need simpleString, unsignedNumber, and music to output repeated music') out = (self.backslash + 'repeat ' + self.simpleString.stringOutput() @@ -983,20 +1048,23 @@ class LySequentialMusic(LyObject): Can be explicitly tagged with "\sequential" if displayTag is True ''' - - def __init__(self, musicList=None, displayTag=False, beforeMatter=None): + def __init__(self, + musicList: LyObject|None = None, + displayTag: bool = False, + beforeMatter: str|None = None, + ) -> None: super().__init__() self.musicList = musicList self.displayTag = displayTag self.beforeMatter = beforeMatter - def stringOutput(self): + def stringOutput(self) -> str: if self.musicList is not None: musicListSO = self.musicList.stringOutput() else: musicListSO = '' tag = '' - if self.displayTag is True: + if self.displayTag: tag = self.backslash + 'sequential ' if self.beforeMatter == 'startStaff': @@ -1014,14 +1082,13 @@ class LyOssiaMusic(LyObject): Can be tagged with \startStaff and \stopStaff if startstop is True ''' - - def __init__(self, musicList=None, startstop=True): + def __init__(self, musicList: LyMusicList|None = None, startstop: bool = True) -> None: super().__init__() self.musicList = musicList self.startstop = startstop - def stringOutput(self): - if self.startstop is True: + def stringOutput(self) -> str: + if self.startstop: start = self.backslash + 'startStaff ' stop = self.backslash + 'stopStaff' else: @@ -1042,13 +1109,12 @@ class LySimultaneousMusic(LyObject): Can be explicitly tagged with '\simultaneous' if displayTag is True otherwise encloses in double angle brackets ''' - - def __init__(self, musicList=None, displayTag=False): + def __init__(self, musicList: LyMusicList|None = None, displayTag: bool = False) -> None: super().__init__() self.musicList = musicList self.displayTag = displayTag - def stringOutput(self): + def stringOutput(self) -> str: if self.musicList is not None: musicListSO = self.musicList.stringOutput() else: @@ -1062,15 +1128,19 @@ def stringOutput(self): class LySimpleMusic(LyObject): - def __init__(self, eventChord=None, musicIdentifier=None, - musicPropertyDef=None, contextChange=None): + def __init__(self, + eventChord: LyEventChord|None = None, + musicIdentifier: LyObject|None = None, + musicPropertyDef: LyMusicPropertyDef|None = None, + contextChange: LyContextChange|None = None, + ) -> None: super().__init__() self.eventChord = eventChord self.musicIdentifier = musicIdentifier self.musicPropertyDef = musicPropertyDef self.contextChange = contextChange - def stringOutput(self): + def stringOutput(self) -> str: outputObject = self.getFirstNonNoneAttribute(['eventChord', 'musicIdentifier', 'musicPropertyDef', 'contextChange']) if outputObject is None: @@ -1084,14 +1154,17 @@ class LyContextModification(LyObject): but not context_mod!!!!! ''' - - def __init__(self, contextModList=None, contextModIdentifier=None, displayWith=True): + def __init__(self, + contextModList: LyContextModList|list[str]|None = None, + contextModIdentifier: str|None = None, + displayWith: bool = True, + ) -> None: super().__init__() self.contextModList = contextModList - self.contextModIdentifier = contextModIdentifier # String? + self.contextModIdentifier = contextModIdentifier self.displayWith = displayWith # optional, but not supported without so far - def stringOutput(self): + def stringOutput(self) -> str: if self.contextModList is not None: return self.backslash + 'with ' + self.encloseCurly(self.contextModList) elif self.contextModIdentifier is not None: @@ -1104,15 +1177,17 @@ class LyContextModList(LyObject): r''' contains zero or more LyContextMod objects and an optional contextModIdentifier ''' - - def __init__(self, contents=None, contextModIdentifier=None): + def __init__(self, + contents: list[LyContextMod]|None = None, + contextModIdentifier: str|None = None, + ) -> None: if contents is None: contents = [] super().__init__() self.contents = contents - self.contextModIdentifier = contextModIdentifier # STRING + self.contextModIdentifier = contextModIdentifier - def stringOutput(self): + def stringOutput(self) -> str: output = self.newlineSeparateStringOutputIfNotNone(self.contents) if self.contextModIdentifier is not None: return output + ' ' + self.contextModIdentifier @@ -1124,21 +1199,25 @@ class LyCompositeMusic(LyObject): r''' one of LyPrefixCompositeMusic or LyGroupedMusicList stored in self.contents ''' - - def __init__(self, prefixCompositeMusic=None, groupedMusicList=None, newLyrics=None): + def __init__(self, + prefixCompositeMusic: LyPrefixCompositeMusic|None = None, + groupedMusicList: LyObject|None = None, + newLyrics: LyNewLyrics|None = None, + ) -> None: super().__init__() self.prefixCompositeMusic = prefixCompositeMusic self.groupedMusicList = groupedMusicList self.newLyrics = newLyrics @property - def contents(self): + def contents(self) -> LyObject|None: if self.prefixCompositeMusic is not None: return self.prefixCompositeMusic else: return self.groupedMusicList - def stringOutput(self): + def stringOutput(self) -> str: + newLyrics: LyNewLyrics|str if self.newLyrics is not None: newLyrics = self.newLyrics else: @@ -1157,13 +1236,15 @@ class LyGroupedMusicList(LyObject): r''' one of LySimultaneousMusic or LySequentialMusic ''' - - def __init__(self, simultaneousMusic=None, sequentialMusic=None): + def __init__(self, + simultaneousMusic: LySimultaneousMusic|None = None, + sequentialMusic: LySequentialMusic|None = None, + ) -> None: super().__init__() self.simultaneousMusic = simultaneousMusic self.sequentialMusic = sequentialMusic - def stringOutput(self): + def stringOutput(self) -> str: if self.simultaneousMusic is not None: return str(self.simultaneousMusic) elif self.sequentialMusic is not None: @@ -1211,14 +1292,13 @@ class LySchemeFunction(LyObject): We have usually been using LyEmbeddedScm for this ''' - - def __init__(self, content=None): + def __init__(self, content: LyObject|str|None = None) -> None: super().__init__() self.content = content - def stringOutput(self): + def stringOutput(self) -> str: if self.content is None: - return None + return '' else: return str(self.content) @@ -1227,14 +1307,13 @@ class LyOptionalId(LyObject): r''' an optional id setting ''' - - def __init__(self, content=None): + def __init__(self, content: str|None = None) -> None: super().__init__() self.content = content - def stringOutput(self): + def stringOutput(self) -> str: if self.content is None: - return None + return '' else: return ' = ' + self.content @@ -1243,10 +1322,12 @@ class LyPrefixCompositeMusic(LyObject): r''' type must be specified. Should be one of: - scheme, context, new, times, repeated, transpose, + scheme, context, new, tuplet, repeated, transpose, modeChanging, modeChangingWith, relative, rhythmed + The 'tuplet' fraction is actual/normal: `3/2` is three notes in the time of two. + prefix_composite_music: generic_prefix_music_scm | "\context" simple_string @@ -1258,7 +1339,7 @@ class LyPrefixCompositeMusic(LyObject): optional_id optional_context_mod music - | "\times" fraction music + | "\tuplet" fraction music | repeated_music | "\transpose" pitch_also_in_chords @@ -1272,14 +1353,23 @@ class LyPrefixCompositeMusic(LyObject): | re_rhythmed_music ''' # pylint: disable=redefined-builtin - def __init__(self, type=None, genericPrefixMusicScm=None, - simpleString=None, optionalId=None, optionalContextMod=None, - music=None, fraction=None, repeatedMusic=None, - pitchAlsoInChords1=None, pitchAlsoInChords2=None, - modeChangingHead=None, groupedMusicList=None, - modeChangingHeadWithContext=None, relativeMusic=None, - reRhythmedMusic=None - ): + def __init__(self, + type: str|None = None, + genericPrefixMusicScm: LyObject|None = None, + simpleString: str|None = None, + optionalId: LyOptionalId|None = None, + optionalContextMod: LyContextModification|None = None, + music: LyObject|str|None = None, + fraction: str|None = None, + repeatedMusic: LyRepeatedMusic|None = None, + pitchAlsoInChords1: LyPitch|None = None, + pitchAlsoInChords2: LyPitch|None = None, + modeChangingHead: LyModeChangingHead|None = None, + groupedMusicList: LyGroupedMusicList|None = None, + modeChangingHeadWithContext: LyModeChangingHead|None = None, + relativeMusic: LyRelativeMusic|None = None, + reRhythmedMusic: LyReRhythmedMusic|None = None, + ) -> None: super().__init__() self.type = type self.genericPrefixMusicScm = genericPrefixMusicScm @@ -1297,7 +1387,7 @@ def __init__(self, type=None, genericPrefixMusicScm=None, self.relativeMusic = relativeMusic self.reRhythmedMusic = reRhythmedMusic - def stringOutput(self): + def stringOutput(self) -> str: myType = self.type if myType == 'scheme': return str(self.genericPrefixMusicScm) @@ -1309,8 +1399,8 @@ def stringOutput(self): c += str(self.optionalContextMod) + ' ' c += str(self.music) + ' ' return c - elif myType == 'times': - return self.backslash + 'times ' + str(self.fraction) + ' ' + str(self.music) + ' ' + elif myType == 'tuplet': + return self.backslash + 'tuplet ' + str(self.fraction) + ' ' + str(self.music) + ' ' elif myType == 'repeated': return str(self.repeatedMusic) elif myType == 'transpose': @@ -1346,36 +1436,43 @@ class LyModeChangingHead(LyObject): >>> print(l2.stringOutput()) \chords + Mode 'note' always gives `\notemode`: + + >>> l3 = lily.lilyObjects.LyModeChangingHead(hasContext=False, mode='note') + >>> print(l3.stringOutput()) + \notemode ''' allowableModes = ['note', 'drum', 'figure', 'chord', 'lyric'] - def __init__(self, hasContext=False, mode=None): + def __init__(self, hasContext: bool = False, mode: str|None = None) -> None: super().__init__() self.hasContext = hasContext self.mode = mode - def stringOutput(self): - if self.mode is None: + def stringOutput(self) -> str: + mode = self.mode + if mode is None: raise LilyObjectsException('Mode must be set') # pragma: no cover - if self.mode not in self.allowableModes: - raise LilyObjectsException(f'Not an allowable mode {self.mode}') # pragma: no cover + if mode not in self.allowableModes: + raise LilyObjectsException(f'Not an allowable mode {mode}') # pragma: no cover - if self.hasContext: - return self.backslash + self.mode + 'mode' + if self.hasContext or mode == 'note': + return self.backslash + mode + 'mode' else: - return self.backslash + self.mode + 's' + return self.backslash + mode + 's' class LyRelativeMusic(LyObject): r''' relative music ''' - - def __init__(self, content=None): + def __init__(self, content: LyObject|None = None) -> None: super().__init__() self.content = content - def stringOutput(self): + def stringOutput(self) -> str: + if self.content is None: # pragma: no cover + raise LilyObjectsException('need content for relative music') return self.backslash + 'relative ' + self.content.stringOutput() @@ -1383,18 +1480,17 @@ class LyNewLyrics(LyObject): r''' contains a list of LyGroupedMusicList objects or identifiers ''' - - def __init__(self, groupedMusicLists=None): + def __init__(self, groupedMusicLists: list[LyGroupedMusicList|str]|None = None) -> None: if groupedMusicLists is None: groupedMusicLists = [] super().__init__() self.groupedMusicLists = groupedMusicLists - def stringOutput(self): + def stringOutput(self) -> str: outputString = '' for c in self.groupedMusicLists: outputString += self.backslash + 'addlyrics ' - if hasattr(c, 'stringOutput'): + if isinstance(c, LyObject): outputString += c.stringOutput() else: outputString += c + ' ' @@ -1403,19 +1499,25 @@ def stringOutput(self): class LyReRhythmedMusic(LyObject): - def __init__(self, groupedMusic=None, newLyrics=None): + def __init__(self, + groupedMusic: LyGroupedMusicList|str|None = None, + newLyrics: LyNewLyrics|None = None, + ) -> None: super().__init__() self.groupedMusic = groupedMusic self.newLyrics = newLyrics - def stringOutput(self): + def stringOutput(self) -> str: c = self.groupedMusic - if hasattr(c, 'stringOutput'): + if isinstance(c, LyObject): outputString = c.stringOutput() - else: + elif c is not None: outputString = c + ' ' - outputString += self.newLyrics.stringOutput() - return outputString # previously this did not return + else: # pragma: no cover + raise LilyObjectsException('need groupedMusic for re-rhythmed music') + if self.newLyrics is not None: + outputString += self.newLyrics.stringOutput() + return outputString class LyContextChange(LyObject): @@ -1424,13 +1526,12 @@ class LyContextChange(LyObject): >>> str(lcc) '\\change x = y ' ''' - - def __init__(self, before=None, after=None): + def __init__(self, before: str = '', after: str = '') -> None: super().__init__() self.before = before self.after = after - def stringOutput(self): + def stringOutput(self) -> str: return self.backslash + 'change ' + self.before + ' = ' + self.after + ' ' @@ -1440,15 +1541,14 @@ class LyPropertyPath(LyObject): has one or more of LyEmbeddedScm objects ''' - - def __init__(self, embeddedScheme=None): + def __init__(self, embeddedScheme: list[LyEmbeddedScm]|None = None) -> None: if embeddedScheme is None: embeddedScheme = [] super().__init__() self.embeddedScheme = embeddedScheme - def stringOutput(self): + def stringOutput(self) -> str: return ' '.join([es.stringOutput() for es in self.embeddedScheme]) @@ -1476,21 +1576,21 @@ class LyPropertyOperation(LyObject): >>> lpo = lily.lilyObjects.LyPropertyOperation('revert', 'x', 'y') >>> str(lpo) '\\revert x.y ' - - TODO: should \set be given? ''' - - def __init__(self, mode=None, value1=None, value2=None, value3=None): + # TODO: should \set be given? + def __init__(self, + mode: str|None = None, + value1: str = '', + value2: str = '', + value3: str = '', + ) -> None: super().__init__() self.mode = mode self.value1 = value1 self.value2 = value2 self.value3 = value3 - def stringOutput(self): - if self.mode not in ('set', 'unset', 'override', 'revert'): - raise LilyObjectsException(f'invalid mode {self.mode}') - + def stringOutput(self) -> str: if self.mode == 'set': return self.backslash + 'set ' + self.value1 + ' = ' + self.value2 + ' ' elif self.mode == 'unset': @@ -1500,28 +1600,34 @@ def stringOutput(self): ' = ', self.value3, ' ']) elif self.mode == 'revert': return self.backslash + 'revert ' + self.value1 + '.' + self.value2 + ' ' + else: + raise LilyObjectsException(f'invalid mode {self.mode}') class LyContextDefMod(LyObject): r''' one of consists, remove, accepts, defaultchild, denies, alias, type, description, name ''' - - def __init__(self, contextDef=None): + def __init__(self, contextDef: str = '') -> None: super().__init__() self.contextDef = contextDef - def stringOutput(self): + def stringOutput(self) -> str: return self.backslash + self.contextDef + ' ' class LyContextMod(LyObject): - def __init__(self, contextDefOrProperty=None, scalar=None): + def __init__(self, + contextDefOrProperty: LyContextDefMod|LyPropertyOperation|None = None, + scalar: str|None = None, + ) -> None: super().__init__() self.contextDefOrProperty = contextDefOrProperty self.scalar = scalar - def stringOutput(self): + def stringOutput(self) -> str: + if self.contextDefOrProperty is None: # pragma: no cover + raise LilyObjectsException('need a contextDef or property to modify a context') if self.scalar is None: return self.contextDefOrProperty.stringOutput() else: @@ -1533,12 +1639,14 @@ def stringOutput(self): class LyMusicPropertyDef(LyObject): - def __init__(self, isOnce=False, propertyDef=None): + def __init__(self, isOnce: bool = False, propertyDef: LyPropertyOperation|None = None) -> None: super().__init__() self.isOnce = isOnce self.propertyDef = propertyDef - def stringOutput(self): + def stringOutput(self) -> str: + if self.propertyDef is None: # pragma: no cover + raise LilyObjectsException('need a propertyDef for a music property definition') s = '' if self.isOnce: s += self.backslash + 'once ' @@ -1561,9 +1669,15 @@ class LyEventChord(LyObject): LyNewChord or LyFigureSpec + Duration once that is done. But there is no LySimpleChordElements object yet. ''' - - def __init__(self, simpleChordElements=None, postEvents=None, chordRepetition=None, - multiMeasureRest=None, duration=None, commandElement=None, noteChordElement=None): + def __init__(self, + simpleChordElements: LySimpleElement|None = None, + postEvents: list[LyObject|str]|None = None, + chordRepetition: LyObject|str|None = None, + multiMeasureRest: LyObject|str|None = None, + duration: str|None = None, + commandElement: LyCommandElement|None = None, + noteChordElement: LyNoteChordElement|None = None, + ) -> None: super().__init__() self.simpleChordElements = simpleChordElements self.postEvents = postEvents @@ -1573,7 +1687,7 @@ def __init__(self, simpleChordElements=None, postEvents=None, chordRepetition=No self.commandElement = commandElement self.noteChordElement = noteChordElement - def stringOutput(self): + def stringOutput(self) -> str: if self.noteChordElement is not None: return str(self.noteChordElement) + ' ' elif self.commandElement is not None: @@ -1606,7 +1720,11 @@ def stringOutput(self): class LyNoteChordElement(LyObject): - def __init__(self, chordBody=None, optionalNoteModeDuration=None, postEvents=None): + def __init__(self, + chordBody: LyChordBody|LyPitch|None = None, + optionalNoteModeDuration: LyMultipliedDuration|None = None, + postEvents: list[LyObject|str]|None = None, + ) -> None: if postEvents is None: postEvents = [] super().__init__() @@ -1614,7 +1732,7 @@ def __init__(self, chordBody=None, optionalNoteModeDuration=None, postEvents=Non self.optionalNoteModeDuration = optionalNoteModeDuration self.postEvents = postEvents - def stringOutput(self): + def stringOutput(self) -> str: c = str(self.chordBody) if self.optionalNoteModeDuration is not None: c += str(self.optionalNoteModeDuration) + ' ' @@ -1625,14 +1743,14 @@ def stringOutput(self): class LyChordBody(LyObject): - def __init__(self, chordBodyElements=None): + def __init__(self, chordBodyElements: list[LyChordBodyElement]|None = None) -> None: if chordBodyElements is None: chordBodyElements = [] super().__init__() self.chordBodyElements = chordBodyElements - def stringOutput(self): + def stringOutput(self) -> str: c = ' '.join([str(cbe) for cbe in self.chordBodyElements]) return ' '.join(['<', c, '> ']) @@ -1648,17 +1766,15 @@ class LyChordBodyElement(LyObject): post_events | DRUM_PITCH post_events | music_function_chord_body - - TODO: only the first form is currently supported in creation ''' - - def __init__(self, parts=None): + # TODO: only the first form is currently supported in creation + def __init__(self, parts: list[LyObject|str]|None = None) -> None: if parts is None: parts = [] super().__init__() self.parts = parts - def stringOutput(self): + def stringOutput(self) -> str: return ' '.join([str(p) for p in self.parts]) # music_function_identifier_musicless_prefix: MUSIC_FUNCTION @@ -1680,41 +1796,53 @@ def stringOutput(self): class LyCommandElement(LyObject): - def __init__(self, commandType=None, argument=None): + def __init__(self, + commandType: LyObject|str|None = None, + argument: LyObject|str|None = None, + ) -> None: super().__init__() self.commandType = commandType self.argument = argument - def stringOutput(self): + def stringOutput(self) -> str: ct = self.commandType - if ct == 'skip': - return self.backslash + 'skip ' + self.argument.stringOutput() + if not isinstance(ct, str): + if ct is None: # pragma: no cover + raise LilyObjectsException('need a commandType to output a command element') + return ct.stringOutput() + + arg = self.argument + if ct in ('skip', 'partial'): + argOut = arg.stringOutput() if isinstance(arg, LyObject) else str(arg) + return self.backslash + ct + ' ' + argOut elif ct == '[': + # ligature brackets; manual beams are plain [ and ] return self.backslash + '[ ' elif ct == ']': return self.backslash + '] ' elif ct == self.backslash: return ct + ' ' - elif ct == 'partial': - return self.backslash + 'partial ' + self.argument.stringOutput() - elif ct == 'time': - return self.backslash + 'time ' + self.argument + ' ' - elif ct == 'mark': - return self.backslash + 'mark ' + self.argument + ' ' - else: - return ct.stringOutput() + elif ct in ('time', 'mark'): + return self.backslash + ct + ' ' + str(arg) + ' ' + else: # pragma: no cover + raise LilyObjectsException(f'unknown commandType {ct}') class LyCommandEvent(LyObject): - def __init__(self, commandType=None, argument1=None, argument2=None): + def __init__(self, + commandType: LyTempoEvent|str|None = None, + argument1: str = '', + argument2: str = '', + ) -> None: super().__init__() self.commandType = commandType self.argument1 = argument1 self.argument2 = argument2 - def stringOutput(self): + def stringOutput(self) -> str: ct = self.commandType - if ct == '~': # ??? not tie? + if ct == '~': + # E_TILDE, the pes-or-flexa ligature event return self.backslash + '~ ' elif ct == 'mark-default': return self.backslash + 'mark ' + self.backslash + 'default ' @@ -1723,29 +1851,31 @@ def stringOutput(self): elif ct == 'key': # \key NOTENAME_PITCH SCM_IDENTIFIER return self.backslash + 'key ' + self.argument1 + ' ' + self.argument2 + ' ' - else: # tempo_event + elif isinstance(ct, LyTempoEvent): return ct.stringOutput() + else: # pragma: no cover + raise LilyObjectsException(f'unknown commandType {ct}') class LyPostEvents(LyObject): - def __init__(self, eventList=None): + def __init__(self, eventList: list[LyObject]|None = None) -> None: if eventList is None: eventList = [] super().__init__() self.eventList = eventList - def stringOutput(self): + def stringOutput(self) -> str: return ' '.join([e.stringOutput() for e in self.eventList]) class LyPostEvent(LyObject): - def __init__(self, arg1=None, arg2=None): + def __init__(self, arg1: LyObject|str|None = None, arg2: LyObject|str|None = None) -> None: super().__init__() self.arg1 = arg1 self.arg2 = arg2 - def stringOutput(self): + def stringOutput(self) -> str: c = str(self.arg1) if self.arg2 is not None: c += ' ' + str(self.arg2) @@ -1757,35 +1887,34 @@ class LyDirectionLessEvent(LyObject): represents ['[', ']', '~', '(', ')', '\!', '\(', '\)', '\>', '\<'] or an EVENT_IDENTIFIER or a tremolo_type ''' - - def __init__(self, event=None): + def __init__(self, event: LyObject|str|None = None) -> None: super().__init__() self.event = event - def stringOutput(self): + def stringOutput(self) -> str: return str(self.event) + ' ' # noinspection SpellCheckingInspection class LyDirectionReqdEvent(LyObject): - def __init__(self, event=None): + def __init__(self, event: LyObject|str|None = None) -> None: super().__init__() self.event = event - def stringOutput(self): + def stringOutput(self) -> str: return str(self.event) + ' ' class LyOctaveCheck(LyObject): - def __init__(self, equalOrQuotesOrNone=None): + def __init__(self, equalOrQuotesOrNone: str|None = None) -> None: super().__init__() self.equalOrQuotesOrNone = equalOrQuotesOrNone - def stringOutput(self): + def stringOutput(self) -> str: eqn = self.equalOrQuotesOrNone if eqn is None: - return None + return '' elif eqn == '=': return '= ' else: @@ -1797,14 +1926,13 @@ class LyPitch(LyObject): represents a pitch name and zero or more sup or sub quotes also used for steno_pitch and steno_tonic_pitch ''' - - def __init__(self, noteNamePitch=None, quotes=None): + def __init__(self, noteNamePitch: str = '', quotes: str = '') -> None: super().__init__() self.noteNamePitch = noteNamePitch self.quotes = quotes - def stringOutput(self): - return self.noteNamePitch + str(self.quotes) + ' ' + def stringOutput(self) -> str: + return self.noteNamePitch + self.quotes + ' ' # no need for pitch_also_in_chords @@ -1813,12 +1941,11 @@ class LyGenTextDef(LyObject): r''' holds either full_markup, string, or DIGIT ''' - - def __init__(self, value=None): + def __init__(self, value: LyFullMarkup|str|int|None = None) -> None: super().__init__() self.value = value - def stringOutput(self): + def stringOutput(self) -> str: return str(self.value) + ' ' @@ -1827,31 +1954,27 @@ class LyScriptAbbreviation(LyObject): Holds a script abbreviation (for articulations etc.), one of:: ^ + - | > . _ - ''' - - def __init__(self, value=None): + def __init__(self, value: str = '') -> None: super().__init__() self.value = value - def stringOutput(self): - return str(self.value) + ' ' + def stringOutput(self) -> str: + return self.value + ' ' class LyScriptDir(LyObject): r''' - Holds a script direction abbreviation (above below etc), one of:: + Holds a script direction abbreviation (above, below etc.), one of:: _ ^ - - ''' - - def __init__(self, value=None): + def __init__(self, value: str = '') -> None: super().__init__() self.value = value - def stringOutput(self): - return str(self.value) + ' ' + def stringOutput(self) -> str: + return self.value + ' ' # no need for absolute_pitch # no need for optional_notemode_duration -- we can use LyMultipliedDuration or None @@ -1866,15 +1989,13 @@ class LyStenoDuration(LyObject): >>> lsd = lily.lilyObjects.LyStenoDuration('2', 2) >>> print(lsd) 2.. - ''' - - def __init__(self, durationNumber=None, numDots=0): + def __init__(self, durationNumber: int|str|None = None, numDots: int = 0) -> None: super().__init__() self.durationNumber = durationNumber self.numDots = numDots - def stringOutput(self): + def stringOutput(self) -> str: dotStr = '.' * self.numDots return str(self.durationNumber) + dotStr + ' ' @@ -1884,20 +2005,22 @@ class LyMultipliedDuration(LyObject): represents either a simple LyStenoDuration or a list of things that the steno duration should be multiplied by. - if stenoDur is None then output is None -- thus also represents + if stenoDur is None then output is empty -- thus also represents optional_notemode_duration ''' - - def __init__(self, stenoDur=None, multiply=None): + def __init__(self, + stenoDur: LyStenoDuration|None = None, + multiply: list[int|str]|None = None, + ) -> None: if multiply is None: multiply = [] super().__init__() self.stenoDur = stenoDur self.multiply = multiply - def stringOutput(self): + def stringOutput(self) -> str: if self.stenoDur is None: - return None + return '' else: s = str(self.stenoDur) for m in self.multiply: @@ -1907,11 +2030,11 @@ def stringOutput(self): class LyTremoloType(LyObject): - def __init__(self, tremTypeOrNone=None): + def __init__(self, tremTypeOrNone: int|str|None = None) -> None: super().__init__() self.tremTypeOrNone = tremTypeOrNone - def stringOutput(self): + def stringOutput(self) -> str: if self.tremTypeOrNone is not None: return ':' + str(self.tremTypeOrNone) + ' ' else: @@ -1921,11 +2044,11 @@ def stringOutput(self): class LyOptionalRest(LyObject): - def __init__(self, rest=False): + def __init__(self, rest: bool = False) -> None: super().__init__() self.rest = rest - def stringOutput(self): + def stringOutput(self) -> str: if self.rest is False: return '' else: @@ -1946,14 +2069,13 @@ class LySimpleElement(LyObject): | RESTNAME optional_notemode_duration | lyric_element optional_notemode_duration ''' - - def __init__(self, parts=None): + def __init__(self, parts: list[LyObject|str]|None = None) -> None: if parts is None: parts = [] super().__init__() self.parts = parts - def stringOutput(self): + def stringOutput(self) -> str: return ''.join([str(p) for p in self.parts]) # SKIPPING ALL ChordSymbol Markup for now @@ -1969,44 +2091,47 @@ class LyLyricElement(LyObject): >>> print(lle) hel_ ''' - - def __init__(self, lyMarkupOrString=None): + def __init__(self, lyMarkupOrString: LyMarkup|str|None = None) -> None: super().__init__() self.lyMarkupOrString = lyMarkupOrString - def stringOutput(self): + def stringOutput(self) -> str: return str(self.lyMarkupOrString) + ' ' class LyTempoRange(LyObject): r''' defines either a single tempo or a range - ''' - def __init__(self, lowestOrOnlyTempo=None, highestTempoOrNone=None): + >>> print(lily.lilyObjects.LyTempoRange(70, 100)) + 70-100 + ''' + def __init__(self, + lowestOrOnlyTempo: int|str|None = None, + highestTempoOrNone: int|str|None = None, + ) -> None: super().__init__() self.lowestOrOnlyTempo = lowestOrOnlyTempo self.highestTempoOrNone = highestTempoOrNone - def stringOutput(self): + def stringOutput(self) -> str: if self.highestTempoOrNone is None: return str(self.lowestOrOnlyTempo) + ' ' else: - return str(self.lowestOrOnlyTempo) + '~' + str(self.highestTempoOrNone) + ' ' + return str(self.lowestOrOnlyTempo) + '-' + str(self.highestTempoOrNone) + ' ' class LyNumberExpression(LyObject): r''' any list of numbers or LyNumberTerms separated by '+' or '-' objects. ''' - - def __init__(self, numberAndSepList=None): + def __init__(self, numberAndSepList: list[LyNumberTerm|int|str]|None = None) -> None: if numberAndSepList is None: numberAndSepList = [] super().__init__() self.numberAndSepList = numberAndSepList - def stringOutput(self): + def stringOutput(self) -> str: c = ' '.join([str(s) for s in self.numberAndSepList]) return c + ' ' @@ -2015,25 +2140,27 @@ class LyNumberTerm(LyObject): r''' any list of numbers separated by '*' or '/' strings. ''' - - def __init__(self, numberAndSepList=None): + def __init__(self, numberAndSepList: list[int|str]|None = None) -> None: if numberAndSepList is None: numberAndSepList = [] super().__init__() self.numberAndSepList = numberAndSepList - def stringOutput(self): + def stringOutput(self) -> str: c = ' '.join([str(s) for s in self.numberAndSepList]) return c + ' ' class LyLyricMarkup(LyObject): - def __init__(self, lyricMarkupOrIdentifier=None, markupTop=None): + def __init__(self, + lyricMarkupOrIdentifier: LyObject|str|None = None, + markupTop: LyMarkupTop|None = None, + ) -> None: super().__init__() self.lyricMarkupOrIdentifier = lyricMarkupOrIdentifier self.markupTop = markupTop - def stringOutput(self): + def stringOutput(self) -> str: if self.markupTop is None: return str(self.lyricMarkupOrIdentifier) + ' ' else: @@ -2041,81 +2168,90 @@ def stringOutput(self): class LyFullMarkupList(LyObject): - def __init__(self, markupListOrIdentifier=None): + r''' + >>> markupList = lily.lilyObjects.LyMarkupList('{ "a" "b" }') + >>> print(lily.lilyObjects.LyFullMarkupList(markupList)) + \markuplist { "a" "b" } + ''' + def __init__(self, markupListOrIdentifier: LyMarkupList|str|None = None) -> None: super().__init__() self.markupListOrIdentifier = markupListOrIdentifier - def stringOutput(self): - if isinstance(self.markupListOrIdentifier, str): - return self.markupListOrIdentifier + ' ' + def stringOutput(self) -> str: + mli = self.markupListOrIdentifier + if isinstance(mli, str): + return mli + ' ' + elif mli is None: # pragma: no cover + raise LilyObjectsException('need a markup list or identifier') else: - # noinspection SpellCheckingInspection - return self.backslash + 'markuplines ' + self.markupListOrIdentifier.stringOutput() + return self.backslash + 'markuplist ' + mli.stringOutput() class LyFullMarkup(LyObject): - def __init__(self, markupTopOrIdentifier=None): + def __init__(self, markupTopOrIdentifier: LyMarkupTop|str|None = None) -> None: super().__init__() self.markupTopOrIdentifier = markupTopOrIdentifier - def stringOutput(self): - if isinstance(self.markupTopOrIdentifier, str): - return self.markupTopOrIdentifier + ' ' + def stringOutput(self) -> str: + mti = self.markupTopOrIdentifier + if isinstance(mti, str): + return mti + ' ' + elif mti is None: # pragma: no cover + raise LilyObjectsException('need a markup top or identifier') else: - return self.backslash + 'markup ' + self.markupTopOrIdentifier.stringOutput() + return self.backslash + 'markup ' + mti.stringOutput() class LyMarkupTop(LyObject): - def __init__(self, argument1=None, argument2=None): + def __init__(self, argument1: str = '', argument2: str|None = None) -> None: super().__init__() self.argument1 = argument1 self.argument2 = argument2 - def stringOutput(self): + def stringOutput(self) -> str: if self.argument2 is None: - return str(self.argument1) + return self.argument1 else: return ' '.join([self.argument1, self.argument2]) class LyMarkupList(LyObject): - def __init__(self, markupIdentifierOrList=None): + def __init__(self, markupIdentifierOrList: LyObject|str|None = None) -> None: super().__init__() self.markupIdentifierOrList = markupIdentifierOrList - def stringOutput(self): + def stringOutput(self) -> str: return str(self.markupIdentifierOrList) class LyMarkupComposedList(LyObject): - def __init__(self, markupHeadList=None, markupBracedList=None): + def __init__(self, markupHeadList: str = '', markupBracedList: str = '') -> None: super().__init__() self.markupHeadList = markupHeadList self.markupBracedList = markupBracedList - def stringOutput(self): + def stringOutput(self) -> str: return ' '.join([self.markupHeadList, self.markupBracedList]) class LyMarkupBracedList(LyObject): - def __init__(self, listBody=None): + def __init__(self, listBody: str = '') -> None: super().__init__() self.listBody = listBody - def stringOutput(self): + def stringOutput(self) -> str: return ' '.join(['{', self.listBody, '}']) class LyMarkupBracedListBody(LyObject): - def __init__(self, markupOrMarkupList=None): + def __init__(self, markupOrMarkupList: list[LyObject|str]|None = None) -> None: if markupOrMarkupList is None: markupOrMarkupList = [] super().__init__() self.markupOrMarkupList = markupOrMarkupList - def stringOutput(self): - super().__init__() + def stringOutput(self) -> str: c = '' for m in self.markupOrMarkupList: c += str(m) + ' ' @@ -2135,29 +2271,37 @@ class LySimpleMarkup(LyObject): takes 1 required arg, 2nd for markup_function ''' - - def __init__(self, simpleType='string', argument1=None, argument2=None): + def __init__(self, + simpleType: str = 'string', + argument1: str = '', + argument2: LyObject|str|None = None, + ) -> None: super().__init__() self.simpleType = simpleType self.argument1 = argument1 self.argument2 = argument2 - def stringOutput(self): + def stringOutput(self) -> str: if self.simpleType == 'string': return self.argument1 + ' ' elif self.simpleType == 'score-body': return self.backslash + 'score { ' + self.argument1 + ' } ' elif self.simpleType == 'markup-function': return self.argument1 + ' ' + str(self.argument2) + ' ' + else: # pragma: no cover + raise LilyObjectsException(f'unknown simpleType {self.simpleType}') class LyMarkup(LyObject): - def __init__(self, simpleMarkup=None, optionalMarkupHeadList=None): + def __init__(self, + simpleMarkup: LySimpleMarkup|None = None, + optionalMarkupHeadList: str|None = None, + ) -> None: super().__init__() self.simpleMarkup = simpleMarkup self.optionalMarkupHeadList = optionalMarkupHeadList - def stringOutput(self): + def stringOutput(self) -> str: if self.optionalMarkupHeadList is not None: c = self.optionalMarkupHeadList + ' ' else: @@ -2218,7 +2362,6 @@ def testOneNoteTheHardWay(self): r''' make a dotted-half note c. ''' - lyPitch = LyPitch('c', "''") stenoDuration = LyStenoDuration('2', 1) diff --git a/music21/lily/translate.py b/music21/lily/translate.py index 2975dcb20d..a9ab8e6bac 100644 --- a/music21/lily/translate.py +++ b/music21/lily/translate.py @@ -4,7 +4,7 @@ # # Authors: Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2007-2024 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2007-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -21,8 +21,11 @@ import re import subprocess import sys +import typing as t import unittest +from music21 import base +from music21 import chord from music21 import clef from music21 import common from music21.converter.subConverters import SubConverter @@ -31,8 +34,13 @@ from music21 import environment from music21 import exceptions21 from music21 import key +from music21 import layout +from music21 import metadata +from music21 import meter from music21 import note +from music21 import pitch from music21 import stream +from music21 import tempo from music21 import variant from music21.lily import lilyObjects as lyo @@ -58,7 +66,7 @@ class _sharedCorpusTestObject: sharedCacheObject = _sharedCorpusTestObject() -def _getCachedCorpusFile(keyName): +def _getCachedCorpusFile(keyName: str) -> stream.Stream: # return corpus.parse(keyName) if keyName not in sharedCacheObject.sharedCache: sharedCacheObject.sharedCache[keyName] = corpus.parse(keyName) @@ -67,7 +75,7 @@ def _getCachedCorpusFile(keyName): # b.parts[0].measure(4)[2].color = 'blue'#.rightBarline = 'double' -def makeLettersOnlyId(inputString): +def makeLettersOnlyId(inputString: str|int) -> str: # noinspection SpellCheckingInspection r''' Takes an id and makes it purely letters by substituting @@ -91,11 +99,11 @@ def makeLettersOnlyId(inputString): class LilypondConverter: fictaDef = ( r''' - ficta = #(define-music-function (parser location) () #{ \once \set suggestAccidentals = ##t #}) + ficta = #(define-music-function () () #{ \once \set suggestAccidentals = ##t #}) '''.lstrip()) colorDef = ( r''' - color = #(define-music-function (parser location color) (string?) #{ + color = #(define-music-function (color) (string?) #{ \once \override NoteHead.color = #(x11-color color) \once \override Stem.color = #(x11-color color) \once \override Rest.color = #(x11-color color) @@ -133,46 +141,47 @@ class LilypondConverter: 'half-flat': 'eh', } + # bar line names as defined in LilyPond's scm/lily/bar-line.scm barlineDict = {'regular': '|', - 'dotted': ':', - 'dashed': 'dashed', - 'heavy': '.', # ?? + 'dotted': ';', + 'dashed': '!', + 'heavy': '.', 'double': '||', 'final': '|.', 'heavy-light': '.|', - 'heavy-heavy': '.|.', - 'start-repeat': '|:', - 'end-repeat': ':|', + 'heavy-heavy': '..', + 'start-repeat': '.|:', + 'end-repeat': ':|.', # no music21 support for |.| lightHeavyLight yet 'tick': "'", - # 'short': '', # no lilypond support?? + 'short': ',', 'none': '', } - def __init__(self): + def __init__(self) -> None: self.majorVersion = '1' self.minorVersion = '0' self.versionString = '1.0' self.backend = 'ps' - self.versionScheme = '' - self.headerScheme = '' + self.versionScheme = lyo.LyEmbeddedScm() + self.headerScheme = lyo.LyEmbeddedScm() self.backendString = '--backend=' + self.LILYEXEC = '' self.topLevelObject = lyo.LyLilypondTop() self.setupTools() - self.context = self.topLevelObject - self.storedContexts = [] - self.doNotOutput = [] - self.currentMeasure = None - self.addedVariants = [] + self.context: lyo.LyObject = self.topLevelObject + self.storedContexts: list[lyo.LyObject] = [] + self.doNotOutput: list[base.Music21Object] = [] + self.currentMeasure: stream.Measure|None = None + self.addedVariants: list[str] = [] self.variantColors = ['blue', 'red', 'purple', 'green', 'orange', 'yellow', 'grey'] self.coloredVariants = False self.variantMode = False - self.LILYEXEC = None - self.tempName = None - self.inWord = None + self.tempName: pathlib.Path|None = None + self.inWord = False - def findLilyExec(self): + def findLilyExec(self) -> str: lpEnvironment = environLocal['lilypondPath'] if lpEnvironment is not None and lpEnvironment.exists(): LILYEXEC = str(lpEnvironment) # pragma: no cover @@ -196,17 +205,17 @@ def findLilyExec(self): self.LILYEXEC = LILYEXEC return LILYEXEC - def setupTools(self): + def setupTools(self) -> None: LILYEXEC = self.findLilyExec() command = [LILYEXEC, '--version'] platform = common.getPlatform() - creation_flags = subprocess.CREATE_NO_WINDOW if platform == 'win' else 0 + # CREATE_NO_WINDOW only exists on Windows + creation_flags = getattr(subprocess, 'CREATE_NO_WINDOW', 0) if platform == 'win' else 0 try: with subprocess.Popen(command, stdout=subprocess.PIPE, creationflags=creation_flags) as proc: - stdout, unused = proc.communicate() - stdout = stdout.decode(encoding='utf-8') - versionString = stdout.split()[2] + stdoutBytes, unused = proc.communicate() + versionString = stdoutBytes.decode(encoding='utf-8').split()[2] versionPieces = versionString.split('.') except OSError as exc: # pragma: no cover raise LilyTranslateException( @@ -237,11 +246,11 @@ def setupTools(self): # I had a note that said 2.12 and > should use # 'self.backendString = '--formats=' ' but doesn't seem true - def newContext(self, newContext): + def newContext(self, newContext: lyo.LyObject) -> None: self.storedContexts.append(self.context) self.context = newContext - def restoreContext(self): + def restoreContext(self) -> None: try: self.context = self.storedContexts.pop() except IndexError: # pragma: no cover @@ -249,7 +258,7 @@ def restoreContext(self): # ----------- Set a complete LilyPond Tree from a music21 object ----------# - def textFromMusic21Object(self, m21ObjectIn): + def textFromMusic21Object(self, m21ObjectIn: base.Music21Object) -> str: r''' get a proper lilypond text file for writing from a music21 object @@ -257,7 +266,7 @@ def textFromMusic21Object(self, m21ObjectIn): >>> print(lily.translate.LilypondConverter().textFromMusic21Object(n)) \version "2..." \include "lilypond-book-preamble.ly" - color = #(define-music-function (parser location color) (string?) #{ + color = #(define-music-function (color) (string?) #{ \once \override NoteHead.color = #(x11-color color) \once \override Stem.color = #(x11-color color) \once \override Rest.color = #(x11-color color) @@ -277,7 +286,7 @@ def textFromMusic21Object(self, m21ObjectIn): s = re.sub(r'\s*\n\s*\n', '\n', s).strip() return s - def loadFromMusic21Object(self, m21ObjectIn): + def loadFromMusic21Object(self, m21ObjectIn: base.Music21Object) -> None: r''' Create a LilyPond object hierarchy in self.topLevelObject from an arbitrary music21 object. @@ -285,35 +294,29 @@ def loadFromMusic21Object(self, m21ObjectIn): TODO: make lilypond automatically run makeNotation.makeTupletBrackets(s) TODO: Add tests. ''' - c = m21ObjectIn.classes - if 'Stream' in c: - if m21ObjectIn[variant.Variant]: - # has variants. so we need to make a deepcopy - m21ObjectIn = variant.makeAllVariantsReplacements(m21ObjectIn, recurse=True) - variant.makeVariantBlocks(m21ObjectIn) - - if ('Stream' not in c) or ('Measure' in c) or ('Voice' in c): + if isinstance(m21ObjectIn, stream.Stream) and m21ObjectIn[variant.Variant]: + # has variants. so we need to make a deepcopy + m21ObjectIn = variant.makeAllVariantsReplacements(m21ObjectIn, recurse=True) + variant.makeVariantBlocks(m21ObjectIn) + + if isinstance(m21ObjectIn, stream.Score): + self.loadObjectFromScore(m21ObjectIn, makeNotation=False) + elif isinstance(m21ObjectIn, stream.Opus): + self.loadObjectFromOpus(m21ObjectIn, makeNotation=False) + elif (not isinstance(m21ObjectIn, stream.Stream) + or isinstance(m21ObjectIn, (stream.Measure, stream.Voice))): scoreObj = stream.Score() partObj = stream.Part() # no need for measures or voices partObj.insert(0, m21ObjectIn) scoreObj.insert(0, partObj) self.loadObjectFromScore(scoreObj, makeNotation=False) - elif 'Part' in c: + else: # a Part, or treated as one scoreObj = stream.Score() scoreObj.insert(0, m21ObjectIn) self.loadObjectFromScore(scoreObj, makeNotation=False) - elif 'Score' in c: - self.loadObjectFromScore(m21ObjectIn, makeNotation=False) - elif 'Opus' in c: - self.loadObjectFromOpus(m21ObjectIn, makeNotation=False) - else: # treat as part - scoreObj = stream.Score() - scoreObj.insert(0, m21ObjectIn) - self.loadObjectFromScore(scoreObj, makeNotation=False) - # raise LilyTranslateException(f'Unknown stream type {m21ObjectIn.__class__}') - def loadObjectFromOpus(self, opusIn=None, makeNotation=True): + def loadObjectFromOpus(self, opusIn: stream.Opus, makeNotation: bool = True) -> None: r''' creates a filled topLevelObject (lily.lilyObjects.LyLilypondTop) whose string representation accurately reflects all the Score objects @@ -324,7 +327,7 @@ def loadObjectFromOpus(self, opusIn=None, makeNotation=True): >>> #_DOCS_SHOW lpc.loadObjectFromOpus(fifeOpus, makeNotation=False) >>> #_DOCS_SHOW lpc.showPDF() ''' - contents = [] + contents: list[lyo.LyObject|str] = [] lpVersionScheme = self.versionScheme lpHeaderScheme = self.headerScheme lpColorScheme = lyo.LyEmbeddedScm(self.colorDef) @@ -353,11 +356,10 @@ def loadObjectFromOpus(self, opusIn=None, makeNotation=True): contents.append(lpLayout) - self.context.contents = contents + self.setContextContents(contents) - def loadObjectFromScore(self, scoreIn=None, makeNotation=True): + def loadObjectFromScore(self, scoreIn: stream.Score, makeNotation: bool = True) -> None: r''' - creates a filled topLevelObject (lily.lilyObjects.LyLilypondTop) whose string representation accurately reflects this Score object. @@ -381,16 +383,16 @@ def loadObjectFromScore(self, scoreIn=None, makeNotation=True): lpOutputDefBody = lyo.LyOutputDefBody(outputDefHead=lpOutputDefHead) lpOutputDef = lyo.LyOutputDef(outputDefBody=lpOutputDefBody) lpLayout = lyo.LyLayout() - contents = [lpVersionScheme, lpHeaderScheme, lpColorScheme, - lpHeader, lpScoreBlock, lpOutputDef, lpLayout] + contents: list[lyo.LyObject|str] = [lpVersionScheme, lpHeaderScheme, lpColorScheme, + lpHeader, lpScoreBlock, lpOutputDef, lpLayout] if scoreIn.metadata is not None: self.setHeaderFromMetadata(scoreIn.metadata, lpHeader=lpHeader) - self.context.contents = contents + self.setContextContents(contents) # ------ return Lily objects or append to the current context -----------# - def lyScoreBlockFromScore(self, scoreIn): + def lyScoreBlockFromScore(self, scoreIn: stream.Score) -> lyo.LyScoreBlock: lpCompositeMusic = lyo.LyCompositeMusic() self.newContext(lpCompositeMusic) @@ -418,7 +420,7 @@ def lyScoreBlockFromScore(self, scoreIn): return lpScoreBlock - def lyPartsAndOssiaInitFromScore(self, scoreIn): + def lyPartsAndOssiaInitFromScore(self, scoreIn: stream.Score) -> lyo.LyMusicList: r''' Takes in a score and returns a block that starts each part context and variant context with an identifier and {\stopStaff s1*n} (or s, whatever is needed for the duration) @@ -462,8 +464,7 @@ def lyPartsAndOssiaInitFromScore(self, scoreIn): \override TupletBracket.bracket-visibility = ##f \override TupletNumber.stencil = ##f \override Clef.transparent = ##t - \override OctavateEight.transparent = ##t - \consists "Default_bar_line_engraver" + \override ClefModifier.transparent = ##t } { \stopStaff s1 s1 s1 s1 } \new Staff = romepa @@ -476,8 +477,7 @@ def lyPartsAndOssiaInitFromScore(self, scoreIn): \override TupletBracket.bracket-visibility = ##f \override TupletNumber.stencil = ##f \override Clef.transparent = ##t - \override OctavateEight.transparent = ##t - \consists "Default_bar_line_engraver" + \override ClefModifier.transparent = ##t } { \stopStaff s1 s1 s1 s1 } \new Staff = pb { \stopStaff s1 s1 s1 s1 } @@ -491,8 +491,7 @@ def lyPartsAndOssiaInitFromScore(self, scoreIn): \override TupletBracket.bracket-visibility = ##f \override TupletNumber.stencil = ##f \override Clef.transparent = ##t - \override OctavateEight.transparent = ##t - \consists "Default_bar_line_engraver" + \override ClefModifier.transparent = ##t } { \stopStaff s1 s1 s1 s1 } \new Staff = romepb @@ -505,14 +504,13 @@ def lyPartsAndOssiaInitFromScore(self, scoreIn): \override TupletBracket.bracket-visibility = ##f \override TupletNumber.stencil = ##f \override Clef.transparent = ##t - \override OctavateEight.transparent = ##t - \consists "Default_bar_line_engraver" + \override ClefModifier.transparent = ##t } { \stopStaff s1 s1 s1 s1 } ''' lpMusicList = lyo.LyMusicList() - musicList = [] + musicList: list[lyo.LyObject|str] = [] lpMusic = r'{ \stopStaff %s}' for p in scoreIn.parts: @@ -525,7 +523,7 @@ def lyPartsAndOssiaInitFromScore(self, scoreIn): music=lpMusic % spacerDuration) musicList.append(lpPrefixCompositeMusicPart) - variantsAddedForPart = [] + variantsAddedForPart: list[str] = [] for v in p.getElementsByClass(variant.Variant): variantName = v.groups[0] if variantName not in variantsAddedForPart: @@ -547,8 +545,7 @@ def lyPartsAndOssiaInitFromScore(self, scoreIn): r'\override TupletBracket.bracket-visibility = ##f', r'\override TupletNumber.stencil = ##f', r'\override Clef.transparent = ##t', - r'\override OctavateEight.transparent = ##t', - r'\consists "Default_bar_line_engraver"', + r'\override ClefModifier.transparent = ##t', ] optionalContextMod = lyo.LyContextModification(contextModList) lpPrefixCompositeMusicVariant.optionalContextMod = optionalContextMod @@ -558,7 +555,7 @@ def lyPartsAndOssiaInitFromScore(self, scoreIn): return lpMusicList - def getLySpacersFromStream(self, streamIn, measuresOnly=True): + def getLySpacersFromStream(self, streamIn: stream.Stream, measuresOnly: bool = True) -> str: # noinspection PyShadowingNames r''' Creates a series of Spacer objects for the measures in a Stream Part. @@ -606,7 +603,11 @@ def getLySpacersFromStream(self, streamIn, measuresOnly=True): return returnString - def lyGroupedMusicListFromScoreWithParts(self, scoreIn, scoreInit=None): + def lyGroupedMusicListFromScoreWithParts( + self, + scoreIn: stream.Score, + scoreInit: lyo.LyMusicList|None = None, + ) -> lyo.LyGroupedMusicList: # noinspection PyShadowingNames,GrazieInspection r''' More complex example showing how the score can be set up with ossia parts: @@ -673,7 +674,7 @@ def lyGroupedMusicListFromScoreWithParts(self, scoreIn, scoreInit=None): ''' - compositeMusicList = [] + compositeMusicList: list[lyo.LyObject|str] = [] lpGroupedMusicList = lyo.LyGroupedMusicList() lpSimultaneousMusic = lyo.LySimultaneousMusic() @@ -699,7 +700,12 @@ def lyGroupedMusicListFromScoreWithParts(self, scoreIn, scoreInit=None): return lpGroupedMusicList - def lyNewLyricsFromStream(self, streamIn, streamId=None, alignment='alignBelowContext'): + def lyNewLyricsFromStream( + self, + streamIn: stream.Stream, + streamId: str|None = None, + alignment: str = 'alignBelowContext', + ) -> lyo.LyNewLyrics: r''' returns a LyNewLyrics object @@ -726,9 +732,9 @@ def lyNewLyricsFromStream(self, streamIn, streamId=None, alignment='alignBelowCo streamId = '#' + lyo.LyObject().quoteString(streamId) - lpGroupedMusicLists = [] + lpGroupedMusicLists: list[lyo.LyGroupedMusicList|str] = [] for lyricNum in sorted(lyricsDict): - lyricList = [] + lyricList: list[lyo.LyObject|str] = [] lpAlignmentProperty = lyo.LyPropertyOperation(mode='set', value1=alignment, value2=streamId) @@ -736,6 +742,8 @@ def lyNewLyricsFromStream(self, streamIn, streamId=None, alignment='alignBelowCo self.inWord = False for el in lyricsDict[lyricNum]: + if isinstance(el, list): # only arises from .lyrics(recurse=True) + continue lpLyricElement = self.lyLyricElementFromM21Lyric(el) lyricList.append(lpLyricElement) @@ -751,7 +759,7 @@ def lyNewLyricsFromStream(self, streamIn, streamId=None, alignment='alignBelowCo return lpNewLyrics - def lyLyricElementFromM21Lyric(self, m21Lyric): + def lyLyricElementFromM21Lyric(self, m21Lyric: note.Lyric|None) -> lyo.LyLyricElement: r''' Returns a :class:`~music21.lily.lilyObjects.LyLyricElement` object from a :class:`~music21.note.Lyric` object. @@ -778,22 +786,17 @@ def lyLyricElementFromM21Lyric(self, m21Lyric): False ''' - if hasattr(self, 'inWord'): - inWord = self.inWord - else: - inWord = False + inWord = self.inWord el = m21Lyric - if el is None and inWord: - text = ' _ ' - elif el is None and inWord is False: + if el is None: text = ' _ ' elif el.text == '': text = ' _ ' elif el.text is None: text = '' else: - text = '"' + el.text + '"' + text = lyo.LyObject().quoteString(el.text).rstrip() # TODO: composite if el.syllabic == 'end': text = text + '__' @@ -807,7 +810,11 @@ def lyLyricElementFromM21Lyric(self, m21Lyric): lpLyricElement = lyo.LyLyricElement(text) return lpLyricElement - def lySequentialMusicFromStream(self, streamIn, beforeMatter=None): + def lySequentialMusicFromStream( + self, + streamIn: stream.Stream, + beforeMatter: str|None = None, + ) -> lyo.LySequentialMusic: r''' returns a LySequentialMusic object from a stream @@ -828,7 +835,7 @@ def lySequentialMusicFromStream(self, streamIn, beforeMatter=None): } ''' - musicList = [] + musicList: list[lyo.LyObject|str] = [] lpMusicList = lyo.LyMusicList(contents=musicList) lpSequentialMusic = lyo.LySequentialMusic(musicList=lpMusicList, @@ -846,11 +853,11 @@ def lySequentialMusicFromStream(self, streamIn, beforeMatter=None): # pylint: disable=redefined-builtin def lyPrefixCompositeMusicFromStream( self, - streamIn, - contextType=None, - type=None, - beforeMatter=None - ): + streamIn: stream.Stream, + contextType: str|None = None, + type: str|None = None, + beforeMatter: str|None = None, + ) -> lyo.LyPrefixCompositeMusic: # noinspection PyShadowingNames r''' returns an LyPrefixCompositeMusic object from @@ -882,7 +889,7 @@ def lyPrefixCompositeMusicFromStream( compositeMusicType = type optionalId = None - contextModList = [] + contextModList: list[str] = [] c = streamIn.classes if contextType is None: @@ -916,10 +923,7 @@ def lyPrefixCompositeMusicFromStream( if compositeMusicType is None: compositeMusicType = 'new' - if contextModList: - contextMod = lyo.LyContextModification(contextModList) - else: - contextMod = None + contextMod = lyo.LyContextModification(contextModList) if contextModList else None lpPrefixCompositeMusic = lyo.LyPrefixCompositeMusic(type=compositeMusicType, optionalId=optionalId, @@ -928,7 +932,7 @@ def lyPrefixCompositeMusicFromStream( music=lpMusic) return lpPrefixCompositeMusic - def appendObjectsToContextFromStream(self, streamObject): + def appendObjectsToContextFromStream(self, streamObject: stream.Stream) -> None: r''' takes a Stream and appends all the elements in it to the current context's .contents list, and deals with creating Voices in it. It also deals with @@ -975,18 +979,18 @@ def appendObjectsToContextFromStream(self, streamObject): >> ''' from music21.stream.iterator import OffsetIterator - for groupedElements in OffsetIterator(streamObject): + for groupedElements in OffsetIterator[base.Music21Object](streamObject): # print(groupedElements) if len(groupedElements) == 1: # one thing at that moment - el = groupedElements[0] - el.activeSite = streamObject - self.appendM21ObjectToContext(el) + soleElement = groupedElements[0] + soleElement.activeSite = streamObject + self.appendM21ObjectToContext(soleElement) else: # voices or other More than one thing at once # if voices - voiceList = [] - variantList = [] - otherList = [] + voiceList: list[stream.Voice] = [] + variantList: list[variant.Variant] = [] + otherList: list[base.Music21Object] = [] for el in groupedElements: if isinstance(el, stream.Voice): voiceList.append(el) @@ -1004,7 +1008,7 @@ def appendObjectsToContextFromStream(self, streamObject): coloredVariants=self.coloredVariants) if voiceList: - musicList2 = [] + musicList2: list[lyo.LyObject|str] = [] lp2GroupedMusicList = lyo.LyGroupedMusicList() lp2SimultaneousMusic = lyo.LySimultaneousMusic() lp2MusicList = lyo.LyMusicList() @@ -1018,8 +1022,7 @@ def appendObjectsToContextFromStream(self, streamObject): lp2MusicList.contents = musicList2 - contextObject = self.context - currentMusicList = contextObject.contents + currentMusicList = self.contextContents() currentMusicList.append(lp2GroupedMusicList) lp2GroupedMusicList.setParent(self.context) @@ -1027,7 +1030,7 @@ def appendObjectsToContextFromStream(self, streamObject): for el in otherList: self.appendM21ObjectToContext(el) - def appendM21ObjectToContext(self, thisObject): + def appendM21ObjectToContext(self, thisObject: base.Music21Object) -> None: r''' converts any type of object into a lilyObject of LyMusic ( LySimpleMusic, LyEmbeddedScm etc.) type @@ -1036,28 +1039,22 @@ def appendM21ObjectToContext(self, thisObject): return # treat complex duration objects as multiple objects - c = thisObject.classes - - if 'Stream' not in c and thisObject.duration.type == 'complex': + if not isinstance(thisObject, stream.Stream) and thisObject.duration.type == 'complex': thisObjectSplit = thisObject.splitAtDurations() for subComponent in thisObjectSplit: self.appendM21ObjectToContext(subComponent) return contextObject = self.context - if hasattr(contextObject, 'contents'): - currentMusicList = contextObject.contents - else: # pragma: no cover - raise LilyTranslateException( - f'Cannot get a currentMusicList from contextObject {contextObject!r}') + currentMusicList = self.contextContents() - if hasattr(thisObject, 'startTransparency') and thisObject.startTransparency is True: + if getattr(thisObject, 'startTransparency', False) is True: # old hack, replace with the better "hidden" attribute lyScheme = lyo.LyEmbeddedScm(self.transparencyStartScheme) currentMusicList.append(lyScheme) - lyObject = None - if 'Measure' in c: + lyObject: lyo.LyObject|None = None + if isinstance(thisObject, stream.Measure): # lilypond does not put groups around measures # it does however need barline ends # also, if variantMode is True, the last note in each "measure" should have \noBeam @@ -1075,46 +1072,48 @@ def appendM21ObjectToContext(self, thisObject): self.appendObjectsToContextFromStream(thisObject) self.currentMeasure = thisObject - elif 'Stream' in c: - # try: + elif isinstance(thisObject, stream.Stream): lyObject = self.lyPrefixCompositeMusicFromStream(thisObject) currentMusicList.append(lyObject) lyObject.setParent(contextObject) - elif 'Note' in c or 'Rest' in c: + elif isinstance(thisObject, (note.Note, note.Rest)): self.appendContextFromNoteOrRest(thisObject) - elif 'Chord' in c: + elif isinstance(thisObject, chord.Chord): self.appendContextFromChord(thisObject) - elif 'Clef' in c: + elif isinstance(thisObject, clef.Clef): lyObject = self.lyEmbeddedScmFromClef(thisObject) currentMusicList.append(lyObject) lyObject.setParent(contextObject) - elif 'KeySignature' in c: + elif isinstance(thisObject, key.KeySignature): lyObject = self.lyEmbeddedScmFromKeySignature(thisObject) currentMusicList.append(lyObject) lyObject.setParent(contextObject) - elif 'TimeSignature' in c and self.variantMode is False: + elif isinstance(thisObject, meter.TimeSignature) and self.variantMode is False: lyObject = self.lyEmbeddedScmFromTimeSignature(thisObject) currentMusicList.append(lyObject) lyObject.setParent(contextObject) - elif 'Variant' in c: + elif isinstance(thisObject, tempo.MetronomeMark): + lyObject = self.lyEmbeddedScmFromMetronomeMark(thisObject) + if lyObject is not None: + currentMusicList.append(lyObject) + lyObject.setParent(contextObject) + elif isinstance(thisObject, variant.Variant): self.appendContextFromVariant(thisObject, coloredVariants=self.coloredVariants) - elif 'SystemLayout' in c: + elif isinstance(thisObject, layout.SystemLayout): lyObject = lyo.LyEmbeddedScm(r'\break') currentMusicList.append(lyObject) lyObject.setParent(contextObject) - elif 'PageLayout' in c: + elif isinstance(thisObject, layout.PageLayout): lyObject = lyo.LyEmbeddedScm(r'\pageBreak') currentMusicList.append(lyObject) lyObject.setParent(contextObject) - else: - lyObject = None - if hasattr(thisObject, 'stopTransparency') and thisObject.stopTransparency is True: + if getattr(thisObject, 'stopTransparency', False) is True: # old hack, replace with the better "hidden" attribute lyScheme = lyo.LyEmbeddedScm(self.transparencyStopScheme) currentMusicList.append(lyScheme) - def appendContextFromNoteOrRest(self, noteOrRest): + def appendContextFromNoteOrRest(self, noteOrRest: note.GeneralNote) -> None: r''' appends lySimpleMusicFromNoteOrRest to the current context. @@ -1146,14 +1145,13 @@ def appendContextFromNoteOrRest(self, noteOrRest): >>> print(lpc.context) cis' 4 - \times 2/3 { dis' 8 + \tuplet 3/2 { dis' 8 e' 8 f' 8 } fis' 4 - ''' # to be removed once grace notes are supported if noteOrRest.duration.isGrace: @@ -1175,11 +1173,11 @@ def appendContextFromNoteOrRest(self, noteOrRest): self.appendStemCode(noteOrRest) lpSimpleMusic = self.lySimpleMusicFromNoteOrRest(noteOrRest) - self.context.contents.append(lpSimpleMusic) + self.contextContents().append(lpSimpleMusic) lpSimpleMusic.setParent(self.context) self.setContextForTupletStop(noteOrRest) - def appendContextFromChord(self, chord): + def appendContextFromChord(self, chordIn: chord.Chord) -> None: r''' appends lySimpleMusicFromChord to the current context. @@ -1211,25 +1209,42 @@ def appendContextFromChord(self, chord): >>> print(lpc.context) < c' e' g' > 4 - \times 2/3 { < d' fis' a' > 8 + \tuplet 3/2 { < d' fis' a' > 8 < d' f' g' > 8 < c' e' g' c'' > 8 } < c' f' aes' > 4 - ''' - self.setContextForTupletStart(chord) - self.appendBeamCode(chord) - self.appendStemCode(chord) + self.setContextForTupletStart(chordIn) + self.appendBeamCode(chordIn) + self.appendStemCode(chordIn) - lpSimpleMusic = self.lySimpleMusicFromChord(chord) - self.context.contents.append(lpSimpleMusic) + lpSimpleMusic = self.lySimpleMusicFromChord(chordIn) + self.contextContents().append(lpSimpleMusic) lpSimpleMusic.setParent(self.context) - self.setContextForTupletStop(chord) + self.setContextForTupletStop(chordIn) + + def contextContents(self) -> list[lyo.LyObject|str]: + ''' + The `.contents` list of the current context; raises a + LilyTranslateException if the context cannot hold contents. + ''' + contents: t.Any = getattr(self.context, 'contents', None) + if contents is None: # pragma: no cover + raise LilyTranslateException( + f'Cannot get a currentMusicList from contextObject {self.context!r}') + return contents - def lySimpleMusicFromNoteOrRest(self, noteOrRest): + def setContextContents(self, contents: list[lyo.LyObject|str]) -> None: + ''' + Replace the `.contents` list of the current context, reparenting its members. + ''' + context: t.Any = self.context + context.contents = contents + + def lySimpleMusicFromNoteOrRest(self, noteOrRest: note.GeneralNote) -> lyo.LySimpleMusic: r''' returns a lilyObjects.LySimpleMusic object for the generalNote containing this hierarchy:: @@ -1267,31 +1282,30 @@ def lySimpleMusicFromNoteOrRest(self, noteOrRest): >>> print(sm) s 4 ''' - c = noteOrRest.classes - - simpleElementParts = [] + simpleElementParts: list[lyo.LyObject|str] = [] # https://lilypond.org/doc/v2.22/Documentation/notation/inside-the-staff#coloring-objects if noteOrRest.hasStyleInformation: if noteOrRest.style.color and noteOrRest.style.hideObjectOnPrint is False: # LilyPond 2.22 (January 2021) supports hex values - noteheadColor = rf'\override NoteHead.color = "{noteOrRest.style.color}"' + '\n' - stemColor = rf'\override Stem.color = "{noteOrRest.style.color}"' + '\n' + color = lyo.LyObject().quoteString(noteOrRest.style.color).rstrip() + noteheadColor = rf'\override NoteHead.color = {color}' + '\n' + stemColor = rf'\override Stem.color = {color}' + '\n' simpleElementParts.append(noteheadColor) simpleElementParts.append(stemColor) - if 'Note' in c: + if isinstance(noteOrRest, note.Note): if not noteOrRest.hasStyleInformation or noteOrRest.style.hideObjectOnPrint is False: - lpPitch = self.lyPitchFromPitch(noteOrRest.pitch) - simpleElementParts.append(lpPitch) - if noteOrRest.pitch.accidental is not None: - if noteOrRest.pitch.accidental.displayType == 'always': + notePitch = noteOrRest.pitch + simpleElementParts.append(self.lyPitchFromPitch(notePitch)) + if notePitch.accidental is not None: + if notePitch.accidental.displayType == 'always': simpleElementParts.append('! ') - if noteOrRest.pitch.accidental.displayStyle == 'parentheses': + if notePitch.accidental.displayStyle == 'parentheses': simpleElementParts.append('? ') else: simpleElementParts.append('s ') - elif 'Rest' in c: + elif isinstance(noteOrRest, note.Rest): if noteOrRest.hasStyleInformation and noteOrRest.style.hideObjectOnPrint: simpleElementParts.append('s ') else: @@ -1315,7 +1329,7 @@ def lySimpleMusicFromNoteOrRest(self, noteOrRest): return mlSM - def appendBeamCode(self, noteOrChord): + def appendBeamCode(self, noteOrChord: note.GeneralNote) -> None: r''' Adds an LyEmbeddedScm object to the context's contents if the object's has a .beams attribute. @@ -1346,7 +1360,6 @@ def appendBeamCode(self, noteOrChord): [] >>> print(lpc.context) \set stemLeftBeamCount = #2 - ''' leftBeams = 0 rightBeams = 0 @@ -1368,16 +1381,16 @@ def appendBeamCode(self, noteOrChord): if leftBeams > 0: beamText = rf'''\set stemLeftBeamCount = #{leftBeams}''' lpBeamScheme = lyo.LyEmbeddedScm(beamText) - self.context.contents.append(lpBeamScheme) + self.contextContents().append(lpBeamScheme) lpBeamScheme.setParent(self.context) if rightBeams > 0: beamText = fr'''\set stemRightBeamCount = #{rightBeams}''' lpBeamScheme = lyo.LyEmbeddedScm(beamText) - self.context.contents.append(lpBeamScheme) + self.contextContents().append(lpBeamScheme) lpBeamScheme.setParent(self.context) - def appendStemCode(self, noteOrChord): + def appendStemCode(self, noteOrChord: note.GeneralNote) -> None: r''' Adds an LyEmbeddedScm object to the context's contents if the object's stem direction is set (currently, only "up" and "down" are supported). @@ -1400,12 +1413,11 @@ def appendStemCode(self, noteOrChord): if stemDirection in ['UP', 'DOWN']: stemFile = fr'''\once \override Stem.direction = #{stemDirection} ''' lpStemScheme = lyo.LyEmbeddedScm(stemFile) - self.context.contents.append(lpStemScheme) + self.contextContents().append(lpStemScheme) lpStemScheme.setParent(self.context) - def lySimpleMusicFromChord(self, chordObj): + def lySimpleMusicFromChord(self, chordObj: chord.Chord) -> lyo.LySimpleMusic: ''' - >>> conv = lily.translate.LilypondConverter() >>> c1 = chord.Chord(['C#2', 'E4', 'D#5']) >>> c1.quarterLength = 3.5 @@ -1419,6 +1431,7 @@ def lySimpleMusicFromChord(self, chordObj): >>> print(conv.lySimpleMusicFromChord(c1)) s 2.. ''' + lpChordBody: lyo.LyChordBody|lyo.LyPitch self.appendBeamCode(chordObj) if not chordObj.hasStyleInformation or chordObj.style.hideObjectOnPrint is not True: @@ -1426,7 +1439,7 @@ def lySimpleMusicFromChord(self, chordObj): chordBodyElements = [] for p in chordObj.pitches: - chordBodyElementParts = [] + chordBodyElementParts: list[lyo.LyObject|str] = [] lpPitch = self.lyPitchFromPitch(p) chordBodyElementParts.append(lpPitch) if p.accidental is not None: @@ -1452,12 +1465,12 @@ def lySimpleMusicFromChord(self, chordObj): return mlSM # TODO: Chord beaming - def postEventsFromObject(self, generalNote): + def postEventsFromObject(self, generalNote: note.GeneralNote) -> list[lyo.LyObject|str]: r''' attaches events that apply to notes and chords (and some other things) equally ''' - postEvents = [] + postEvents: list[lyo.LyObject|str] = [] # remove this hack once lyrics work # if generalNote.lyric is not None: # hack that uses markup @@ -1474,47 +1487,47 @@ def postEventsFromObject(self, generalNote): postEvents.append(r'\fermata ') return postEvents - def lyPitchFromPitch(self, pitch): + def lyPitchFromPitch(self, pitchObj: pitch.Pitch) -> lyo.LyPitch: r''' converts a music21.pitch.Pitch object to a lily.lilyObjects.LyPitch object. ''' - baseName = self.baseNameFromPitch(pitch) - octaveModChars = self.octaveCharactersFromPitch(pitch) + baseName = self.baseNameFromPitch(pitchObj) + octaveModChars = self.octaveCharactersFromPitch(pitchObj) lyPitch = lyo.LyPitch(baseName, octaveModChars) return lyPitch - def baseNameFromPitch(self, pitch): + def baseNameFromPitch(self, pitchObj: pitch.Pitch) -> str: r''' returns a string of the base name (including accidental) for a music21 pitch ''' - baseName = pitch.step.lower() - if pitch.accidental is not None: - if pitch.accidental.name in self.accidentalConvert: - baseName += self.accidentalConvert[pitch.accidental.name] + baseName = pitchObj.step.lower() + if pitchObj.accidental is not None: + if pitchObj.accidental.name in self.accidentalConvert: + baseName += self.accidentalConvert[pitchObj.accidental.name] return baseName - def octaveCharactersFromPitch(self, pitch): + def octaveCharactersFromPitch(self, pitchObj: pitch.Pitch) -> str: r''' returns a string of single-quotes or commas or '' representing the octave of a :class:`~music21.pitch.Pitch` object ''' - implicitOctave = pitch.implicitOctave - if implicitOctave < 3: - correctedOctave = 3 - implicitOctave + octave = pitchObj.octave + if octave < 3: + correctedOctave = 3 - octave octaveModChars = ',' * correctedOctave # C2 = c, C1 = c,, else: - correctedOctave = implicitOctave - 3 + correctedOctave = octave - 3 octaveModChars = "'" * correctedOctave # C4 = c', C5 = c'' etc. return octaveModChars def lyMultipliedDurationFromDuration( self, durationObj: duration.Duration|duration.DurationTuple, - ): + ) -> lyo.LyMultipliedDuration: r''' take a simple Duration (that is, one with one DurationTuple) object and return a LyMultipliedDuration object: @@ -1581,7 +1594,7 @@ def lyMultipliedDurationFromDuration( f'DurationException: Cannot translate durationObject {durationObj}: {de}') return multipliedDuration - def lyEmbeddedScmFromClef(self, clefObj): + def lyEmbeddedScmFromClef(self, clefObj: clef.Clef) -> lyo.LyEmbeddedScm: # noinspection PyShadowingNames r''' converts a Clef object to a @@ -1597,11 +1610,13 @@ def lyEmbeddedScmFromClef(self, clefObj): >>> lpEmbeddedScm = conv.lyEmbeddedScmFromClef(t8c) >>> print(lpEmbeddedScm) \clef "treble_8" - ''' dictTranslate = OrderedDict([ ('Treble8vbClef', 'treble_8'), + ('Treble8vaClef', 'treble^8'), ('TrebleClef', 'treble'), + ('Bass8vbClef', 'bass_8'), + ('Bass8vaClef', 'bass^8'), ('BassClef', 'bass'), ('AltoClef', 'alto'), ('TenorClef', 'tenor'), @@ -1627,7 +1642,7 @@ def lyEmbeddedScmFromClef(self, clefObj): lpEmbeddedScm.content = clefScheme return lpEmbeddedScm - def lyEmbeddedScmFromKeySignature(self, keyObj): + def lyEmbeddedScmFromKeySignature(self, keyObj: key.KeySignature) -> lyo.LyEmbeddedScm: # noinspection PyShadowingNames r''' converts a Key or KeySignature object @@ -1644,13 +1659,11 @@ def lyEmbeddedScmFromKeySignature(self, keyObj): >>> fSharp = key.KeySignature(6) >>> print(conv.lyEmbeddedScmFromKeySignature(fSharp)) \key fis \major - ''' - if not isinstance(keyObj, key.Key): - keyObj = keyObj.asKey('major') + keyAsKey = keyObj if isinstance(keyObj, key.Key) else keyObj.asKey('major') - p = keyObj.tonic - m = keyObj.mode + p = keyAsKey.tonic + m = keyAsKey.mode pn = self.baseNameFromPitch(p) @@ -1664,7 +1677,7 @@ def lyEmbeddedScmFromKeySignature(self, keyObj): lpEmbeddedScm.content = keyScheme return lpEmbeddedScm - def lyEmbeddedScmFromTimeSignature(self, ts): + def lyEmbeddedScmFromTimeSignature(self, ts: meter.TimeSignature) -> lyo.LyEmbeddedScm: # noinspection PyShadowingNames r''' convert a :class:`~music21.meter.TimeSignature` object @@ -1680,7 +1693,38 @@ def lyEmbeddedScmFromTimeSignature(self, ts): lpEmbeddedScm.content = keyScheme return lpEmbeddedScm - def setContextForTupletStart(self, inObj): + def lyEmbeddedScmFromMetronomeMark(self, mm: tempo.MetronomeMark) -> lyo.LyEmbeddedScm|None: + # noinspection PyShadowingNames + r''' + convert a :class:`~music21.tempo.MetronomeMark` object + to a lilyObjects.LyEmbeddedScm object + + >>> mm = tempo.MetronomeMark(number=87, referent=note.Note(type='quarter')) + >>> conv = lily.translate.LilypondConverter() + >>> print(conv.lyEmbeddedScmFromMetronomeMark(mm)) + \tempo 4 = 87 + + A MetronomeMark without a number produces no output: + + >>> mm = tempo.MetronomeMark() + >>> mm.number is None + True + >>> conv.lyEmbeddedScmFromMetronomeMark(mm) is None + True + + * New in v11. + ''' + if mm.number is None: + return None + + multipliedDuration = self.lyMultipliedDurationFromDuration(mm.referent) + tempoEvent = lyo.LyTempoEvent(stenoDuration=multipliedDuration.stenoDur, scalar=mm.number) + + lpEmbeddedScm = lyo.LyEmbeddedScm() + lpEmbeddedScm.content = tempoEvent.stringOutput() + lpEmbeddedScm.newlineIndent + return lpEmbeddedScm + + def setContextForTupletStart(self, inObj: base.Music21Object) -> lyo.LyMusicList|None: r''' if the inObj has tuplets then we set a new context for the tuplets and anything up till a tuplet stop. @@ -1699,20 +1743,25 @@ def setContextForTupletStart(self, inObj): if not inObj.duration.tuplets: return None elif inObj.duration.tuplets[0].type == 'start': - numerator = str(int(inObj.duration.tuplets[0].tupletNormal[0])) - denominator = str(int(inObj.duration.tuplets[0].tupletActual[0])) - lpMusicList = self.setContextForTimeFraction(numerator, denominator) + actual = str(int(inObj.duration.tuplets[0].tupletActual[0])) + normal = str(int(inObj.duration.tuplets[0].tupletNormal[0])) + lpMusicList = self.setContextForTimeFraction(actual, normal) return lpMusicList else: return None - def setContextForTimeFraction(self, numerator, denominator): + def setContextForTimeFraction( + self, + actual: int|str, + normal: int|str, + ) -> lyo.LyMusicList: r''' - Explicitly starts a new context for scaled music (tuplets, etc.) - for the given numerator and denominator (either an int or a string or unicode) + Explicitly starts a new context for scaled music (tuplets, etc.) for the + given fraction, actual/normal: 5/4 is five notes in the time of four. + Either part may be an int or a string. Returns an lpMusicList object contained in an lpSequentialMusic object - in an lpPrefixCompositeMusic object which sets the times object to a particular + in an lpPrefixCompositeMusic object which sets the tuplet to a particular fraction. >>> lpc = lily.translate.LilypondConverter() @@ -1729,37 +1778,35 @@ def setContextForTimeFraction(self, numerator, denominator): >>> lpc.context.getParent() >>> lpc.context.getParent().getParent() - + >>> lpc.context.getParent().getParent().fraction '5/4' >>> lpc.context.getParent().getParent().type - 'times' + 'tuplet' >>> lpc.context.getParent().getParent().getParent() - + >>> lpc.context.getParent().getParent().getParent() is lyTop True + + * Changed in v11: emits `\tuplet`; the arguments are now actual, normal. ''' - fraction = str(numerator) + '/' + str(denominator) + fraction = str(actual) + '/' + str(normal) lpMusicList = lyo.LyMusicList() lpSequentialMusic = lyo.LySequentialMusic(musicList=lpMusicList) # technically needed, but we can speed things up # lpGroupedMusicList = lyo.LyGroupedMusicList(sequentialMusic=lpSequentialMusic) # lpCompositeMusic = lyo.LyCompositeMusic(groupedMusicList=lpGroupedMusicList) # lpMusic = lyo.LyMusic(compositeMusic=lpCompositeMusic) - lpPrefixCompositeMusic = lyo.LyPrefixCompositeMusic(type='times', + lpPrefixCompositeMusic = lyo.LyPrefixCompositeMusic(type='tuplet', fraction=fraction, music=lpSequentialMusic) - currentContents = self.context.contents - if currentContents is None: # pragma: no cover - raise LilyTranslateException( - f'Cannot find contents for self.context: {self.context!r} ') - + currentContents = self.contextContents() currentContents.append(lpPrefixCompositeMusic) lpPrefixCompositeMusic.setParent(self.context) self.newContext(lpMusicList) return lpMusicList - def setContextForTupletStop(self, inObj): + def setContextForTupletStop(self, inObj: base.Music21Object) -> None: r''' Reverse of setContextForTupletStart ''' @@ -1767,15 +1814,18 @@ def setContextForTupletStop(self, inObj): return elif inObj.duration.tuplets[0].type == 'stop': self.restoreContext() - else: - return None - def appendContextFromVariant(self, variantObjectOrList, activeSite=None, coloredVariants=False): + def appendContextFromVariant( + self, + variantObjectOrList: variant.Variant|list[variant.Variant], + activeSite: stream.Stream|None = None, + coloredVariants: bool = False, + ) -> None: r''' Create a new context from the variant object or a list of variants and append. ''' - musicList = [] - longestReplacedElements = [] + musicList: list[lyo.LyObject|str] = [] + longestReplacedElements: stream.Stream = stream.Stream() if isinstance(variantObjectOrList, variant.Variant): variantObject = variantObjectOrList @@ -1788,8 +1838,8 @@ def appendContextFromVariant(self, variantObjectOrList, activeSite=None, colored musicList.append(lpSequentialMusicStandard) elif isinstance(variantObjectOrList, list): - longestReplacementLength = -1 - variantDict = {} + longestReplacementLength = -1.0 + variantDict: dict[str, list[variant.Variant]] = {} for variantObject in variantObjectOrList: if variantObject.groups: variantName = variantObject.groups[0] @@ -1831,18 +1881,18 @@ def appendContextFromVariant(self, variantObjectOrList, activeSite=None, colored lp2GroupedMusicList = lyo.LyGroupedMusicList() lp2GroupedMusicList.simultaneousMusic = lp2SimultaneousMusic - contextObject = self.context - currentMusicList = contextObject.contents + currentMusicList = self.contextContents() currentMusicList.append(lp2GroupedMusicList) lp2GroupedMusicList.setParent(self.context) - def lyPrefixCompositeMusicFromRelatedVariants(self, - variantList, - activeSite=None, - coloredVariants=False): + def lyPrefixCompositeMusicFromRelatedVariants( + self, + variantList: list[variant.Variant], + activeSite: stream.Stream|None = None, + coloredVariants: bool = False, + ) -> tuple[lyo.LyPrefixCompositeMusic, stream.Stream]: # noinspection PyShadowingNames r''' - >>> s1 = converter.parse('tinynotation: 4/4 a4 a a a a1') >>> s2 = converter.parse('tinynotation: 4/4 b4 b b b') >>> s3 = converter.parse('tinynotation: 4/4 c4 c c c') @@ -1895,7 +1945,7 @@ def lyPrefixCompositeMusicFromRelatedVariants(self, >>> print(lpc.lyPrefixCompositeMusicFromRelatedVariants(variantList, ... activeSite=activeSite)[0]) - \new Staff = london... { { \times 1/2 {\startStaff \clef "treble" + \new Staff = london... { { \tuplet 2/1 {\startStaff \clef "treble" a' 4 a' 4 a' 4 @@ -1934,17 +1984,17 @@ def lyPrefixCompositeMusicFromRelatedVariants(self, } - ''' # Order List - def findOffsetOfFirstNonSpacerElement(inputStream): + def findOffsetOfFirstNonSpacerElement(inputStream: stream.Stream) -> float: for el in inputStream: if isinstance(el, note.Rest) and el.style.hideObjectOnPrint: pass else: - return inputStream.elementOffset(el) + return float(inputStream.elementOffset(el)) + return 0.0 variantList.sort(key=lambda vv: findOffsetOfFirstNonSpacerElement(vv._stream)) @@ -1953,9 +2003,11 @@ def findOffsetOfFirstNonSpacerElement(inputStream): re0 = replacedElements[0] replacedElementsClef = re0.clef or re0.getContextByClass(clef.Clef) - variantContainerStream = variantList[0].getContextByClass(stream.Part) + variantContainerStream: stream.Stream|None = variantList[0].getContextByClass(stream.Part) if variantContainerStream is None: - variantContainerStream = variantList[0].getContextByClass('Stream') + variantContainerStream = variantList[0].getContextByClass(stream.Stream) + if variantContainerStream is None: # pragma: no cover + raise LilyTranslateException(f'Cannot find a container stream for {variantList[0]}') variantList[0].insert(0.0, replacedElementsClef) variantName = variantList[0].groups[0] @@ -1975,9 +2027,9 @@ def findOffsetOfFirstNonSpacerElement(inputStream): ####################### - musicList = [] + musicList: list[lyo.LyObject|str] = [] highestOffsetSoFar = 0.0 - longestVariant = None + longestVariant = variantList[-1] self.variantMode = True @@ -2018,12 +2070,13 @@ def findOffsetOfFirstNonSpacerElement(inputStream): replacedElementsLength = vStripped.replacementQuarterLength variantLength = vStripped.containedHighestTime - firstOffset + lpOssiaMusicVariant: lyo.LyObject if variantLength != replacedElementsLength: - numerator, denominator = common.decimalToTuplet( + normal, actual = common.decimalToTuplet( replacedElementsLength / variantLength) - fraction = str(numerator) + '/' + str(denominator) + fraction = str(actual) + '/' + str(normal) lpOssiaMusicVariantPreFraction = self.lyOssiaMusicFromVariant(vStripped) - lpVariantTuplet = lyo.LyPrefixCompositeMusic(type='times', + lpVariantTuplet = lyo.LyPrefixCompositeMusic(type='tuplet', fraction=fraction, music=lpOssiaMusicVariantPreFraction) @@ -2055,13 +2108,14 @@ def findOffsetOfFirstNonSpacerElement(inputStream): return lpPrefixCompositeMusicVariant, replacedElements - def lyPrefixCompositeMusicFromVariant(self, - variantObject, - replacedElements, - coloredVariants=False): + def lyPrefixCompositeMusicFromVariant( + self, + variantObject: variant.Variant, + replacedElements: stream.Stream, + coloredVariants: bool = False, + ) -> lyo.LyPrefixCompositeMusic: # noinspection PyShadowingNames r''' - >>> pStream = converter.parse('tinynotation: 4/4 a4 b c d e4 f g a') >>> pStream.makeMeasures(inPlace=True) >>> p = stream.Part(pStream.elements) @@ -2096,13 +2150,14 @@ def lyPrefixCompositeMusicFromVariant(self, >>> print(lpc.addedVariants) ['london'] - ''' replacedElementsClef = replacedElements[0].getContextByClass(clef.Clef) - variantContainerStream = variantObject.getContextByClass(stream.Part) + variantContainerStream: stream.Stream|None = variantObject.getContextByClass(stream.Part) if variantContainerStream is None: - variantContainerStream = variantObject.getContextByClass('Stream') + variantContainerStream = variantObject.getContextByClass(stream.Stream) + if variantContainerStream is None: # pragma: no cover + raise LilyTranslateException(f'Cannot find a container stream for {variantObject}') if replacedElementsClef is not None: if replacedElementsClef not in variantObject.elements: @@ -2126,7 +2181,7 @@ def lyPrefixCompositeMusicFromVariant(self, for n in variantObject._stream.recurse().notesAndRests: n.style.color = color - musicList = [] + musicList: list[lyo.LyObject|str] = [] varFilter = [r for r in variantObject.getElementsByClass(note.Rest) if r.style.hideObjectOnPrint] @@ -2148,9 +2203,9 @@ def lyPrefixCompositeMusicFromVariant(self, self.variantMode = True if variantLength != replacedElementsLength: - numerator, denominator = common.decimalToTuplet(replacedElementsLength / variantLength) - fraction = str(numerator) + '/' + str(denominator) - lpVariantTuplet = lyo.LyPrefixCompositeMusic(type='times', + normal, actual = common.decimalToTuplet(replacedElementsLength / variantLength) + fraction = str(actual) + '/' + str(normal) + lpVariantTuplet = lyo.LyPrefixCompositeMusic(type='tuplet', fraction=fraction, music=lpOssiaMusicVariant) lpInternalSequentialMusic = lyo.LySequentialMusic(musicList=lpVariantTuplet) @@ -2210,7 +2265,7 @@ def lyPrefixCompositeMusicFromVariant(self, # currentMusicList.append(lp2GroupedMusicList) # lp2GroupedMusicList.setParent(self.context) - def lyOssiaMusicFromVariant(self, variantIn): + def lyOssiaMusicFromVariant(self, variantIn: variant.Variant) -> lyo.LyOssiaMusic: r''' returns a LyOssiaMusic object from a stream @@ -2232,7 +2287,7 @@ def lyOssiaMusicFromVariant(self, variantIn): } ''' - musicList = [] + musicList: list[lyo.LyObject|str] = [] lpMusicList = lyo.LyMusicList(contents=musicList) lpOssiaMusic = lyo.LyOssiaMusic(musicList=lpMusicList) @@ -2251,7 +2306,11 @@ def lyOssiaMusicFromVariant(self, variantIn): return lpOssiaMusic - def setHeaderFromMetadata(self, metadataObject=None, lpHeader=None): + def setHeaderFromMetadata( + self, + metadataObject: metadata.Metadata|None = None, + lpHeader: lyo.LyLilypondHeader|None = None, + ) -> lyo.LyLilypondHeader: # noinspection PyShadowingNames r''' Returns a lilypond.lilyObjects.LyLilypondHeader object @@ -2302,7 +2361,7 @@ def setHeaderFromMetadata(self, metadataObject=None, lpHeader=None): lpHeaderBody.assignments = lpHeaderBodyAssignments return lpHeader - def closeMeasure(self, barChecksOnly=False): + def closeMeasure(self, barChecksOnly: bool = False) -> lyo.LyEmbeddedScm|None: # noinspection PyShadowingNames r''' return a LyObject or None for the end of the previous Measure @@ -2348,7 +2407,7 @@ def closeMeasure(self, barChecksOnly=False): lpBarline.content = barString return lpBarline - def getSchemeForPadding(self, measureObject): + def getSchemeForPadding(self, measureObject: stream.Measure) -> lyo.LyEmbeddedScm|None: r''' lilypond partial durations are very strange and are really of type LyMultipliedDuration. You notate how many @@ -2386,13 +2445,12 @@ def getSchemeForPadding(self, measureObject): return lyObject # -------------display and converter routines ---------------------# - def writeLyFile(self, ext='', fp=None): + def writeLyFile(self, ext: str = '', fp: str|pathlib.Path|None = None) -> pathlib.Path: r''' writes the contents of the self.topLevelObject to a file. The extension should be ly. If fp is None then a named temporary file is created by environment.getTempFile. - ''' tloOut = str(self.topLevelObject) if fp is None: @@ -2406,8 +2464,13 @@ def writeLyFile(self, ext='', fp=None): return self.tempName # noinspection PyShadowingBuiltins - def runThroughLily(self, format=None, - backend=None, fileName=None, skipWriting=False): + def runThroughLily( + self, + format: str, + backend: str|None = None, + fileName: str|pathlib.Path|None = None, + skipWriting: bool = False, + ) -> pathlib.Path: r''' creates a .ly file from self.topLevelObject via .writeLyFile then runs the file through LilyPond. @@ -2416,7 +2479,6 @@ def runThroughLily(self, format=None, If skipWriting is True and a fileName is given then it will run that file through lilypond instead - ''' LILYEXEC = self.findLilyExec() if fileName is None: @@ -2425,14 +2487,14 @@ def runThroughLily(self, format=None, if skipWriting is False: fileName = self.writeLyFile(ext='ly', fp=fileName) - lilyCommand = '"' + LILYEXEC + '" ' + lilyCommand = [LILYEXEC] if format is not None: - lilyCommand += '-f ' + format + ' ' + lilyCommand += ['-f', format] if backend is not None: - lilyCommand += self.backendString + backend + ' ' + lilyCommand.append(self.backendString + backend) - lilyCommand += '-o ' + str(fileName) + ' ' + str(fileName) - os.system(lilyCommand) + lilyCommand += ['-o', str(fileName), str(fileName)] + subprocess.run(lilyCommand, check=False) try: os.remove(str(fileName) + '.eps') @@ -2449,7 +2511,7 @@ def runThroughLily(self, format=None, fileForm = fileEnd return pathlib.Path(fileForm) - def createPDF(self, fileName=None): + def createPDF(self, fileName: str|pathlib.Path|None = None) -> pathlib.Path: r''' create a PDF file from self.topLevelObject and return the filepath of the file. @@ -2459,7 +2521,7 @@ def createPDF(self, fileName=None): lilyFile = self.runThroughLily(backend='ps', format='pdf', fileName=fileName) return lilyFile - def showPDF(self): + def showPDF(self) -> None: r''' create an SVG file from self.topLevelObject, show it with your pdf reader (often Adobe Acrobat/Adobe Reader or Apple Preview) @@ -2479,7 +2541,7 @@ def showPDF(self): command = '' os.system(command) - def createPNG(self, fileName=None): + def createPNG(self, fileName: str|pathlib.Path|None = None) -> pathlib.Path: r''' create a PNG file from self.topLevelObject and return the filepath of the file. @@ -2500,7 +2562,7 @@ def createPNG(self, fileName=None): pass # no big deal probably return lilyFile - def showPNG(self): + def showPNG(self) -> t.Any: r''' Take the object, run it through LilyPond, and then show it as a PNG file. On Windows, the PNG file will not be deleted, so you will need to clean out @@ -2515,7 +2577,7 @@ def showPNG(self): # self.showImageDirect(lilyFile) return SubConverter().launch(lilyFile, fmt='png') - def createSVG(self, fileName=None): + def createSVG(self, fileName: str|pathlib.Path|None = None) -> pathlib.Path: r''' create an SVG file from self.topLevelObject and return the filepath of the file. @@ -2525,7 +2587,7 @@ def createSVG(self, fileName=None): lilyFile = self.runThroughLily(format='svg', backend='svg', fileName=fileName) return lilyFile - def showSVG(self, fileName=None): + def showSVG(self, fileName: str|pathlib.Path|None = None) -> t.Any: r''' create an SVG file from self.topLevelObject, show it with your svg reader (often Internet Explorer on PC) @@ -2551,7 +2613,6 @@ def testExplicitConvertChorale(self): # print(lpc.topLevelObject) def testComplexDuration(self): - from music21 import meter s = stream.Stream() n1 = note.Note('C') # test no octave also! n1.duration.quarterLength = 2.5 # BUG 2.3333333333 doesn't work right @@ -2596,6 +2657,75 @@ def testColors(self): "c' 4 " ) + def testColorStaysInsideString(self): + n = note.Note() + n.style.color = 'red\\" #(system "touch x") "' + sm = LilypondConverter().lySimpleMusicFromNoteOrRest(n) + escaped = r'"red\\\" #(system \"touch x\") \""' + self.assertEqual( + sm.stringOutput(), + r'\override NoteHead.color = ' + escaped + '\n' + + r'\override Stem.color = ' + escaped + '\n' + + "c' 4 " + ) + + def testMetronomeMark(self): + mm = tempo.MetronomeMark(number=87, referent=note.Note(type='quarter')) + lpEmbeddedScm = LilypondConverter().lyEmbeddedScmFromMetronomeMark(mm) + self.assertEqual(str(lpEmbeddedScm).strip(), r'\tempo 4 = 87') + + # a MetronomeMark with no number produces no output + textOnly = tempo.MetronomeMark() + self.assertIsNone(LilypondConverter().lyEmbeddedScmFromMetronomeMark(textOnly)) + + def testMetronomeMarkWrittenInStream(self): + # https://github.com/cuthbertLab/music21/issues/1852 + keysig = key.Key('a-') + mm = tempo.MetronomeMark(number=87, referent=note.Note(type='quarter')) + timesig = meter.TimeSignature('3/4') + s = stream.Stream([keysig, mm, timesig, note.Note()]) + lpc = LilypondConverter() + lpc.loadObjectFromScore(s, makeNotation=False) + self.assertIn(r'\tempo 4 = 87', str(lpc.topLevelObject)) + + def testTextStaysInsideStrings(self): + schemeCall = '\\" #(system "touch x") %' + escaped = r'"\\\" #(system \"touch x\") %"' + + md = metadata.Metadata() + md.title = schemeCall + lpHeader = LilypondConverter().setHeaderFromMetadata(md) + self.assertIn('title = ' + escaped, str(lpHeader)) + + lpLyric = LilypondConverter().lyLyricElementFromM21Lyric(note.Lyric(schemeCall)) + self.assertEqual(str(lpLyric).strip(), escaped) + + def testOctaveClefs(self): + lpc = LilypondConverter() + for clefClass, lilyName in ( + (clef.Treble8vbClef, '"treble_8"'), + (clef.Treble8vaClef, '"treble^8"'), + (clef.Bass8vbClef, '"bass_8"'), + (clef.Bass8vaClef, '"bass^8"'), + ): + self.assertIn(lilyName, str(lpc.lyEmbeddedScmFromClef(clefClass()))) + + def testRunThroughLilyNoShell(self): + import tempfile + from unittest import mock + + lpc = LilypondConverter() + lpc.loadObjectFromScore(stream.Stream([note.Note()]), makeNotation=False) + with tempfile.TemporaryDirectory() as tempDir: + fp = pathlib.Path(tempDir, 'x;touch y;#.ly') + with (mock.patch.object(lpc, 'findLilyExec', return_value='lilypond'), + mock.patch('os.system') as mockSystem, + mock.patch('subprocess.run') as mockRun): + with self.assertRaises(LilyTranslateException): # no output was made + lpc.runThroughLily(format='pdf', backend='ps', fileName=fp) + mockSystem.assert_not_called() + self.assertIn(str(fp), mockRun.call_args.args[0]) + class TestExternal(unittest.TestCase): show = True @@ -2617,7 +2747,6 @@ def xtestSlowConvertOpus(self): fifeOpus.show('lily.png') def xtestBreve(self): - from music21 import meter n = note.Note('C5') n.duration.quarterLength = 8.0 m = stream.Measure() diff --git a/music21/mei/base.py b/music21/mei/base.py index ead4acb67c..80d06296db 100644 --- a/music21/mei/base.py +++ b/music21/mei/base.py @@ -188,6 +188,7 @@ from music21 import duration from music21 import environment from music21 import exceptions21 +from music21 import expressions from music21 import instrument from music21 import interval from music21 import key @@ -272,6 +273,21 @@ class MeiElementError(exceptions21.Music21Exception): _BAD_VERSE_NUMBER = 'Verse number must be an int (got "{}")' +# Mapping from an MEI @unitdur (the note value of one tremolo repetition) to the number of +# tremolo marks used by :class:`~music21.expressions.Tremolo`: an eighth-note unit is one mark, a +# sixteenth two, and so on (log2(unitdur) - 2). Durations outside this table produce no marks. +_MEI_UNITDUR_TO_TREMOLO_MARKS = { + '8': 1, + '16': 2, + '32': 3, + '64': 4, + '128': 5, + '256': 6, + '512': 7, + '1024': 8, +} + + # Module-level Functions # ----------------------------------------------------------------------------- class MeiToM21Converter: @@ -2632,7 +2648,7 @@ def beamFromElement(elem, slurBundle=None): **Attributes/Elements Implemented:** - - , , , , , , , + - , , , , , , , , **Attributes/Elements Ignored:** @@ -2660,7 +2676,7 @@ def beamFromElement(elem, slurBundle=None): **Contained Elements not Implemented:** - - MEI.cmn: bTrem beatRpt fTrem halfmRpt meterSig meterSigGrp + - MEI.cmn: beatRpt fTrem halfmRpt meterSig meterSigGrp - MEI.critapp: app - MEI.edittrans: (all) - MEI.mensural: ligature mensur proport @@ -2677,6 +2693,7 @@ def beamFromElement(elem, slurBundle=None): f'{MEI_NS}rest': restFromElement, f'{MEI_NS}tuplet': tupletFromElement, f'{MEI_NS}beam': beamFromElement, + f'{MEI_NS}bTrem': bTremFromElement, f'{MEI_NS}space': spaceFromElement, f'{MEI_NS}barLine': barLineFromElement, } @@ -2753,7 +2770,7 @@ def tupletFromElement(elem, slurBundle=None): **Attributes/Elements Implemented:** - - , , , , , , , + - , , , , , , , , - @num and @numbase **Attributes/Elements in Testing:** none @@ -2782,7 +2799,7 @@ def tupletFromElement(elem, slurBundle=None): **Contained Elements not Implemented:** - - MEI.cmn: bTrem beatRpt fTrem halfmRpt meterSig meterSigGrp + - MEI.cmn: beatRpt fTrem halfmRpt meterSig meterSigGrp - MEI.critapp: app - MEI.edittrans: (all) - MEI.mensural: ligature mensur proport @@ -2796,6 +2813,7 @@ def tupletFromElement(elem, slurBundle=None): f'{MEI_NS}rest': restFromElement, f'{MEI_NS}chord': chordFromElement, f'{MEI_NS}clef': clefFromElement, + f'{MEI_NS}bTrem': bTremFromElement, f'{MEI_NS}space': spaceFromElement, f'{MEI_NS}barLine': barLineFromElement, } @@ -2835,6 +2853,87 @@ def tupletFromElement(elem, slurBundle=None): return tuple(tupletMembers) +def bTremFromElement(elem, slurBundle=None): + ''' + A measured tremolo on a single note or chord (also called a bowed tremolo), in which + the enclosed note or chord is rapidly repeated in performance. + + In MEI 2013 (MEI.cmn module). + + :param elem: The ```` element to process. + :type elem: :class:`~xml.etree.ElementTree.Element` + :returns: An iterable of the objects contained within the ```` container. The wrapped + ```` or ```` is imported with its notated duration, so that the onsets of + following events are not shifted. + :rtype: list of :class:`~music21.base.Music21Object` + + **Example** + + A quarter-note chord notated as a measured tremolo of eighth notes (``@unitdur="8"``, one + tremolo mark). The function returns the chord itself, carrying its notated duration and a + :class:`~music21.expressions.Tremolo` expression. + + >>> from xml.etree import ElementTree as ET + >>> meiSnippet = """ + ... + ... + ... + ... + ... """ + >>> meiSnippet = ET.fromstring(meiSnippet) + >>> result = mei.base.bTremFromElement(meiSnippet) + >>> len(result) + 1 + >>> result[0] + + >>> result[0].quarterLength + 1.0 + >>> result[0].expressions + [] + >>> result[0].expressions[0].numberOfMarks + 1 + + **Attributes/Elements Implemented:** + + - , + - @unitdur (mapped to :attr:`~music21.expressions.Tremolo.numberOfMarks`) + + **Attributes/Elements in Testing:** none + + **Attributes not Implemented:** + + - att.common (@label, @n, @xml:base) (att.id (@xml:id)) + - att.typography (@fontfam, @fontname, @fontsize, @fontstyle, @fontweight) + - att.bTrem.log (att.tremmeasured (@num, @numbase)) + - att.bTrem.vis (all) + - att.bTrem.ges (att.duration.performed (@dur.ges)) + - att.bTrem.anl (all) + + **Contained Elements not Implemented:** none + ''' + # mapping from tag name to our converter function + tagToFunction = { + f'{MEI_NS}chord': chordFromElement, + f'{MEI_NS}note': noteFromElement, + } + + # Import the wrapped / as an ordinary note/chord so its notated duration survives; + # otherwise the tremolo (and every onset after it in the layer) would be silently dropped. + tremoloStuff = _processEmbeddedElements(elem.findall('*'), tagToFunction, elem.tag, slurBundle) + + # Attach a measured Tremolo, deriving the number of marks from @unitdur when it is a usable + # duration; timing correctness is preserved even when @unitdur is absent or unrecognized. + numberOfMarks = _MEI_UNITDUR_TO_TREMOLO_MARKS.get(elem.get('unitdur', '')) + if numberOfMarks is not None: + for eachObj in tremoloStuff: + if isinstance(eachObj, note.NotRest): + tremolo = expressions.Tremolo() + tremolo.numberOfMarks = numberOfMarks + eachObj.expressions.append(tremolo) + + return tremoloStuff + + def layerFromElement(elem, overrideN=None, slurBundle=None): ''' An independent stream of events on a staff. @@ -2860,7 +2959,7 @@ def layerFromElement(elem, overrideN=None, slurBundle=None): **Attributes/Elements Implemented:** - - , , , , , , , , , and + - , , , , , , , , , , and contained within - @n, from att.common @@ -2881,7 +2980,7 @@ def layerFromElement(elem, overrideN=None, slurBundle=None): **Contained Elements not Implemented:** - - MEI.cmn: arpeg bTrem beamSpan beatRpt bend breath fTrem fermata gliss hairpin halfmRpt + - MEI.cmn: arpeg beamSpan beatRpt bend breath fTrem fermata gliss hairpin halfmRpt harpPedal mRpt mRpt2 meterSig meterSigGrp multiRest multiRpt octave pedal reh slur tie tuplet tupletSpan - MEI.cmnOrnaments: mordent trill turn @@ -2906,6 +3005,7 @@ def layerFromElement(elem, overrideN=None, slurBundle=None): f'{MEI_NS}mRest': mRestFromElement, f'{MEI_NS}beam': beamFromElement, f'{MEI_NS}tuplet': tupletFromElement, + f'{MEI_NS}bTrem': bTremFromElement, f'{MEI_NS}space': spaceFromElement, f'{MEI_NS}mSpace': mSpaceFromElement, f'{MEI_NS}barLine': barLineFromElement, diff --git a/music21/mei/test_base.py b/music21/mei/test_base.py index 68c973f319..311110bd2b 100644 --- a/music21/mei/test_base.py +++ b/music21/mei/test_base.py @@ -36,6 +36,7 @@ from music21 import bar from music21 import clef from music21 import duration +from music21 import expressions from music21 import instrument from music21 import interval from music21 import key @@ -2223,6 +2224,64 @@ def testIntegration1cLayerFromElement(self): except base.MeiAttributeError as maError: self.assertEqual(base._MISSING_VOICE_ID, maError.args[0]) + # ----------------------------------------------------------------------------- + # Tests for bTremFromElement() + + def testIntegration1BTremFromElement(self): + ''' + bTremFromElement(): the wrapped is imported with its notated duration, and a + Tremolo expression is attached with the number of marks derived from @unitdur. + ''' + inputXML = ''' + + + + + ''' + elem = ETree.fromstring(inputXML) + + actual = base.bTremFromElement(elem) + + self.assertIsInstance(actual, list) + self.assertEqual(1, len(actual)) + self.assertEqual(1.0, actual[0].quarterLength) + self.assertEqual(['E4', 'E5'], [p.nameWithOctave for p in actual[0].pitches]) + self.assertEqual(1, len(actual[0].expressions)) + self.assertIsInstance(actual[0].expressions[0], expressions.Tremolo) + self.assertEqual(1, actual[0].expressions[0].numberOfMarks) + + def testIntegration2BTremFromElement(self): + ''' + bTremFromElement() within a : a must not be silently dropped. The tremolo + note/chord contributes its notated duration so the onsets of following events do not shift + (regression test for https://github.com/cuthbertLab/music21/issues/1994). + ''' + inputXML = ''' + + + + + + + + + ''' + elem = ETree.fromstring(inputXML) + + actual = base.layerFromElement(elem) + + self.assertEqual(3, len(actual)) + # onsets are not shifted: the tremolo chord keeps its place on beat 2 + self.assertEqual([0.0, 1.0, 2.0], [obj.offset for obj in actual]) + self.assertEqual([1.0, 1.0, 2.0], [obj.quarterLength for obj in actual]) + self.assertEqual('C4', actual[0].nameWithOctave) + # the beat-2 element is the E4/E5 chord, imported with a Tremolo expression + self.assertEqual(['E4', 'E5'], [p.nameWithOctave for p in actual[1].pitches]) + self.assertEqual(1, len(actual[1].expressions)) + self.assertIsInstance(actual[1].expressions[0], expressions.Tremolo) + self.assertEqual(1, actual[1].expressions[0].numberOfMarks) + self.assertEqual('G4', actual[2].nameWithOctave) + # ----------------------------------------------------------------------------- # Tests for staffFromElement() @@ -2593,7 +2652,7 @@ def testUnit4StaffFromElement(self, mockTrans, mockClef, mockKey, mockTime, ''' # 1.) prepare elem = ETree.Element(f'{MEI_NS}staffDef', attrib={'meter.count': '1', - 'meter.unit': '3'}) + 'meter.unit': '3'}) mockTime.return_value = 'mockTime return' mockFromString.side_effect = instrument.InstrumentException # otherwise staffDefFromElement() thinks it got a real Instrument @@ -2611,7 +2670,7 @@ def testIntegration4StaffFromElement(self): ''' # 1.) prepare elem = ETree.Element(f'{MEI_NS}staffDef', attrib={'meter.count': '1', - 'meter.unit': '3'}) + 'meter.unit': '3'}) # 2.) run actual = base.staffDefFromElement(elem) @@ -2627,7 +2686,7 @@ def testStaffGrpUnit1StaffFromElement(self, mockStaffDefFE): ''' elem = ETree.Element('staffGrp') innerElems = [ETree.Element(f'{MEI_NS}staffDef', attrib={'n': str(n)}) - for n in range(4)] + for n in range(4)] for eachElem in innerElems: elem.append(eachElem) mockStaffDefFE.side_effect = lambda x, unused_y: f"processed {x.get('n')}" diff --git a/music21/metadata/__init__.py b/music21/metadata/__init__.py index 7b94721977..8f01496125 100755 --- a/music21/metadata/__init__.py +++ b/music21/metadata/__init__.py @@ -6,7 +6,7 @@ # Greg Chapman # Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2010-2022 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2010-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ----------------------------------------------------------------------------- ''' @@ -707,7 +707,7 @@ def all( ('copyright', '© 2014, Creative Commons License (CC-BY)'), ('corpusFilePath', 'corelli/opus3no1/1grave.xml'), ('fileFormat', 'musicxml'), - ('filePath', '...corpus/corelli/opus3no1/1grave.xml'), + ('filePath', '...1grave.xml'), ('movementName', 'Sonata da Chiesa, No. I (opus 3, no. 1)'), ('software', 'Dolet...'), ('software', 'Finale...'), @@ -720,7 +720,7 @@ def all( ('corpusFilePath', 'corelli/opus3no1/1grave.xml'), ('dateCreated', '1689/--/-- or earlier'), ('fileFormat', 'musicxml'), - ('filePath', '...corpus/corelli/opus3no1/1grave.xml'), + ('filePath', '...1grave.xml'), ('localeOfComposition', 'Rome'), ('movementName', 'Sonata da Chiesa, No. I (opus 3, no. 1)'), ('software', 'Dolet...'), @@ -985,7 +985,6 @@ def __setitem__(self, key: str, value: t.Any|Iterable[t.Any]): >>> md[3] = ['180 minutes'] Traceback (most recent call last): KeyError: 'metadata key must be str' - ''' if not isinstance(key, str): raise KeyError('metadata key must be str') @@ -1229,8 +1228,12 @@ def search( # look for regex characters elif (isinstance(query, str) and any(character in query for character in '*.|+?{}')): - useRegex = True - reQuery = re.compile(query, flags=re.IGNORECASE) + try: + reQuery = re.compile(query, flags=re.IGNORECASE) + except re.error: + pass + else: + useRegex = True if useRegex and reQuery is not None: for value, innerField in valueFieldPairs: @@ -1456,7 +1459,7 @@ def fileNumber(self) -> str|None: return self._getSingularAttribute('fileNumber') @fileNumber.setter - def fileNumber(self, value: str) -> None: + def fileNumber(self, value: str|int|None) -> None: ''' For type checking only. Does not run. ''' @@ -1990,7 +1993,6 @@ def _isStandardUniqueName(self, uniqueName: str) -> bool: >>> md._isStandardUniqueName('ambitus') False - ''' return uniqueName in properties.UNIQUE_NAME_TO_PROPERTY_DESCRIPTION @@ -2498,7 +2500,6 @@ class RichMetadata(Metadata): Because RichMetadata is a Music21Object, `quarterLength` is a property that must return the length of the RichMetadata object itself and should not have been ovewritten - ''' # CLASS VARIABLES # @@ -2566,7 +2567,6 @@ def merge(self, other, favorSelf=False): >>> richMetadata.merge(md) >>> richMetadata.title 'Concerto in F' - ''' # specifically name attributes to copy, as do not want to get all # Metadata is a m21 object diff --git a/music21/metadata/bundles.py b/music21/metadata/bundles.py index efa17c2af6..3475312fb6 100644 --- a/music21/metadata/bundles.py +++ b/music21/metadata/bundles.py @@ -347,7 +347,6 @@ def __eq__(self, other): True >>> bachBundle == 'foo' False - ''' if hasattr(other, '_metadataEntries'): if self._metadataEntries == other._metadataEntries: @@ -1096,7 +1095,6 @@ def read(self, filePath=None): Traceback (most recent call last): music21.exceptions21.MetadataException: Unnamed MetadataBundles have no default file path to read from. - ''' timer = common.Timer() timer.start() diff --git a/music21/metadata/caching.py b/music21/metadata/caching.py index 01b4efe1bd..745b804941 100644 --- a/music21/metadata/caching.py +++ b/music21/metadata/caching.py @@ -316,7 +316,6 @@ def process_parallel(jobs, processCount=None): available cores. jobs is a list of :class:`~music21.metadata.MetadataCachingJob` objects. - ''' processCount = processCount or common.cpus() processCount = max(processCount, 1) diff --git a/music21/metadata/primitives.py b/music21/metadata/primitives.py index 8f73d12c07..02cd6de673 100644 --- a/music21/metadata/primitives.py +++ b/music21/metadata/primitives.py @@ -398,8 +398,9 @@ def datetime(self): >>> a.datetime Traceback (most recent call last): - TypeError: function missing required argument 'day' (pos 3) + TypeError: ... missing required argument 'day' (pos 3) ''' + # When 3.15 is the minimum change ... to datetime() post = [] # order here is order for datetime # TODO: need defaults for incomplete times. @@ -453,7 +454,6 @@ def hasError(self): ... ) >>> b.hasError False - ''' for attr in self.attrNames: if getattr(self, attr + 'Error') is not None: @@ -498,9 +498,9 @@ def __eq__(self, other) -> bool: False ''' return (type(self) is type(other) - and self._data == other._data - and self._dataUncertainty == other._dataUncertainty - and self.relevance == other.relevance) + and self._data == other._data + and self._dataUncertainty == other._dataUncertainty + and self.relevance == other.relevance) def _reprInternal(self) -> str: return str(self) diff --git a/music21/meter/base.py b/music21/meter/base.py index 673e750b87..37e55ddf7e 100644 --- a/music21/meter/base.py +++ b/music21/meter/base.py @@ -15,6 +15,7 @@ ''' from __future__ import annotations +from collections import OrderedDict from collections.abc import Sequence import copy import fractions @@ -35,21 +36,19 @@ from music21.meter.tools import slashToTuple, proportionToFraction from music21.meter.core import ( - MeterSequence, getMeterSequence, getMeterTerminal, makeSequence, + MeterSequence, getMeterSequence, getMeterTerminal, ) environLocal = environment.Environment('meter') -# Cache of the fully-configured MeterSequences a TimeSignature.load() produces for a -# given (value, divisions). Each value is a 4-tuple in the fixed order -# (displaySequence, beamSequence, beatSequence, accentSequence). The sequences are -# immutable and shared, and any later change to a TimeSignature is copy-on-write, so -# repeated TimeSignatures of the same meter can reuse them and skip the (hashing-heavy) -# rebuild entirely. -_defaultSequenceCache: dict[ +# TimeSignature.load's (value, divisions) -> +# (displaySequence, beamSequence, beatSequence, accentSequence) +# for the _DEFAULT_SEQUENCE_CACHE_SIZE most recently used keys +_defaultSequenceCache: OrderedDict[ tuple[str, t.Any], tuple[MeterSequence, MeterSequence, MeterSequence, MeterSequence], -] = {} +] = OrderedDict() +_DEFAULT_SEQUENCE_CACHE_SIZE = 512 if t.TYPE_CHECKING: from music21.common.types import OffsetQL, OffsetQLIn @@ -203,14 +202,15 @@ def bestTimeSignature(meas: stream.Stream) -> 'music21.meter.TimeSignature': break # numerator is the count of min parts in the sum multiplier = 1 + numeratorFloat = 0.0 while i > 0: - numerator = multiplier * sumDurQL / minDurQL - if numerator == int(numerator): + numeratorFloat = multiplier * sumDurQL / minDurQL + if numeratorFloat == int(numeratorFloat): break multiplier *= 2 i -= 1 - numerator = int(numerator) + numerator = int(numeratorFloat) floatDenominator *= multiplier denominator = int(floatDenominator) # simplifies to "simplest terms," with 4 in denominator, before testing beat strengths @@ -504,7 +504,6 @@ class TimeSignature(TimeSignatureBase): >>> another44 = meter.TimeSignature() # '4/4' by default >>> one44 == another44 True - ''' _styleClass = style.TextStyle classSortOrder = 4 @@ -608,8 +607,6 @@ def load(self, value: str, divisions=None): value = '2/2' self.symbol = 'cut' - # divisions may be list-like -- e.g. ['2/8', '3/8'] (Ariza & Cuthbert 2010) -- - # so normalize a list to a tuple to make it a usable cache key. keyDivisions = tuple(divisions) if isinstance(divisions, list) else divisions cacheKey = (value, keyDivisions) cached = None @@ -617,8 +614,9 @@ def load(self, value: str, divisions=None): try: cached = _defaultSequenceCache.get(cacheKey) except TypeError: - cacheable = False # still unhashable (rare): skip the cache + cacheable = False if cached is not None: + _defaultSequenceCache.move_to_end(cacheKey) (self.displaySequence, self.beamSequence, self.beatSequence, self.accentSequence) = cached return @@ -659,6 +657,8 @@ def load(self, value: str, divisions=None): _defaultSequenceCache[cacheKey] = ( self.displaySequence, self.beamSequence, self.beatSequence, self.accentSequence) + while len(_defaultSequenceCache) > _DEFAULT_SEQUENCE_CACHE_SIZE: + _defaultSequenceCache.popitem(last=False) @property def ratioString(self): @@ -812,9 +812,8 @@ def barDuration(self) -> duration.Duration: >>> meter.TimeSignature().barDuration - Note: this currently returns an ordinary (mutable) Duration, but it is - expected to return an immutable :class:`~music21.duration.FrozenDuration` - in v12 -- do not rely on being able to modify it in place. + It may become a :class:`~music21.duration.FrozenDuration` in v12, so + do not change it in place. ''' if self._overriddenBarDuration: return self._overriddenBarDuration @@ -823,8 +822,6 @@ def barDuration(self) -> duration.Duration: if beamSequence is not None: # could come from self.beamSequence, self.accentSequence, # self.displaySequence, self.accentSequence - # unfreeze() so callers get an ordinary, mutable Duration; the meter's - # own duration stays a shared, immutable FrozenDuration internally. return beamSequence.duration.unfreeze() # should never happen. @@ -911,12 +908,12 @@ def beatCount(self) -> int: @beatCount.setter def beatCount(self, value: int): try: - self.beatSequence = self.beatSequence.partition(value) + self.beatSequence = self.beatSequence.partitioned(value) except MeterException: raise TimeSignatureException(f'cannot partition beat with provided value: {value}') # create subdivisions using default parameters if len(self.beatSequence) > 1: # if partitioned - self.beatSequence = self.beatSequence.subdividePartitionsEqual() + self.beatSequence = self.beatSequence.subdividedPartitionsEqual() @property def beatCountName(self) -> str: @@ -937,7 +934,7 @@ def beatCountName(self) -> str: @property def beatDuration(self) -> duration.FrozenDuration: ''' - Return a :class:`~music21.duration.Duration` object equal to the beat unit + Return a :class:`~music21.duration.FrozenDuration` equal to the beat unit of this Time Signature, if and only if this TimeSignature has a uniform beat unit. Otherwise, raises an exception in v7.1 but will change to returning NaN @@ -1045,7 +1042,7 @@ def beatDivisionCountName(self) -> str: 5/8 + 5/8 with no further subdivisions: >>> ts = meter.TimeSignature('10/8') - >>> ts.beatSequence = ts.beatSequence.partition(2) + >>> ts.beatSequence = ts.beatSequence.partitioned(2) >>> ts.beatSequence >>> for i, mt in enumerate(ts.beatSequence): @@ -1067,7 +1064,7 @@ def beatDivisionCountName(self) -> str: def beatDivisionDurations(self) -> list[duration.FrozenDuration]: ''' Return the beat division, or the durations that make up one beat, - as a list of :class:`~music21.duration.Duration` objects, if and only if + as a list of :class:`~music21.duration.FrozenDuration` objects, if and only if the TimeSignature has a uniform beat division for all beats. >>> ts = meter.TimeSignature('3/4') @@ -1176,11 +1173,7 @@ def summedNumerator(self) -> bool: @summedNumerator.setter def summedNumerator(self, value: bool): if self.displaySequence is not None: - # displaySequence is immutable; swap in a copy with the new flag - ds = self.displaySequence - self.displaySequence = makeSequence( - ds.numerator, ds.denominator, ds._partition, - parenthesis=ds.parenthesis, summedNumerator=value) + self.displaySequence = self.displaySequence.modify(summedNumerator=value) # -------------------------------------------------------------------------- # private methods -- most to be put into the various sequences. @@ -1206,36 +1199,36 @@ def _setDefaultBeatPartitions(self, *, favorCompound=True) -> None: if len(self.displaySequence) == 1: # create toplevel partitions if self.numerator == 2: # duple meters - self.beatSequence = self.beatSequence.partition(2) + self.beatSequence = self.beatSequence.partitioned(2) elif self.numerator == 6 and favorCompound: # duple meters - self.beatSequence = self.beatSequence.partition(2) + self.beatSequence = self.beatSequence.partitioned(2) elif self.numerator == 3 and favorCompound: # 3/8, 3/16, but not 3/4 - self.beatSequence = self.beatSequence.partition(1) + self.beatSequence = self.beatSequence.partitioned(1) elif self.numerator == 3: # triple meters - self.beatSequence = self.beatSequence.partition([1, 1, 1]) + self.beatSequence = self.beatSequence.partitioned([1, 1, 1]) elif self.numerator == 9 and favorCompound: # triple meters - self.beatSequence = self.beatSequence.partition([3, 3, 3]) + self.beatSequence = self.beatSequence.partitioned([3, 3, 3]) elif self.numerator == 4: # quadruple meters - self.beatSequence = self.beatSequence.partition(4) + self.beatSequence = self.beatSequence.partitioned(4) elif self.numerator == 12 and favorCompound: - self.beatSequence = self.beatSequence.partition(4) + self.beatSequence = self.beatSequence.partitioned(4) elif self.numerator >= 15 and self.numerator % 3 == 0 and favorCompound: # quintuple meters and above. num_triples = self.numerator // 3 - self.beatSequence = self.beatSequence.partition([3] * num_triples) + self.beatSequence = self.beatSequence.partitioned([3] * num_triples) # skip 6 numerators; covered above else: # case of odd meters: 11, 13 - self.beatSequence = self.beatSequence.partition(self.numerator) + self.beatSequence = self.beatSequence.partitioned(self.numerator) # if a complex meter has been given else: # partition by display # TODO: remove partitionByMeterSequence usage. - self.beatSequence = self.beatSequence.partition(self.displaySequence) + self.beatSequence = self.beatSequence.partitioned(self.displaySequence) # create subdivisions, and thus define compound/simple distinction if len(self.beatSequence) > 1: # if partitioned try: - self.beatSequence = self.beatSequence.subdividePartitionsEqual() + self.beatSequence = self.beatSequence.subdividedPartitionsEqual() except MeterException: if self.denominator >= 128: pass # do not raise an exception for unable to subdivide smaller than 128 @@ -1258,7 +1251,7 @@ def _setDefaultBeamPartitions(self) -> None: # more general, based only on numerator elif self.numerator in (2, 3, 4): - self.beamSequence = self.beamSequence.partition(self.numerator) + self.beamSequence = self.beamSequence.partitioned(self.numerator) # if denominator is 4, subdivide each partition if self.denominator == 4: for i in range(len(self.beamSequence)): # subdivide each beat in 2 @@ -1266,7 +1259,7 @@ def _setDefaultBeamPartitions(self) -> None: i, self.beamSequence[i].subdivide(2)) elif self.numerator == 5: default = [2, 3] - self.beamSequence = self.beamSequence.partition(default) + self.beamSequence = self.beamSequence.partitioned(default) # if denominator is 4, subdivide each partition if self.denominator == 4: for i in range(len(self.beamSequence)): # subdivide each beat in 2 @@ -1274,10 +1267,10 @@ def _setDefaultBeamPartitions(self) -> None: i, self.beamSequence[i].subdivide(default[i])) elif self.numerator == 7: - self.beamSequence = self.beamSequence.partition(3) # divide into three groups + self.beamSequence = self.beamSequence.partitioned(3) # divide into three groups elif self.numerator in [6, 9, 12, 15, 18, 21]: - self.beamSequence = self.beamSequence.partition([3] * int(self.numerator / 3)) + self.beamSequence = self.beamSequence.partitioned([3] * int(self.numerator / 3)) else: pass # doing nothing will beam all together # environLocal.printDebug(f'default beam partitions set to: {self.beamSequence}') @@ -1303,7 +1296,6 @@ def _setDefaultAccentWeights(self, depth: int = 3) -> None: >>> ts2._setDefaultAccentWeights(3) # lower depth >>> [mt.weight for mt in ts2.accentSequence] [1.0, 0.125, 0.25, 0.125, 0.5, 0.125, 0.25, 0.125, 0.5, 0.125, 0.25, 0.125] - ''' # NOTE: this is a performance critical method firstPartitionForm: MeterSequence|int|None @@ -1321,7 +1313,7 @@ def _setDefaultAccentWeights(self, depth: int = 3) -> None: ms = MeterSequence(tsStr) # key operation here # div count needs to be the number of top-level beat divisions - ms = ms.subdivideNestedHierarchy(depth, firstPartitionForm=firstPartitionForm) + ms = ms.subdividedNestedHierarchy(depth, firstPartitionForm=firstPartitionForm) # provide a partition for each flattened division accentCount = len(ms.flatten()) @@ -1341,14 +1333,13 @@ def _setDefaultAccentWeights(self, depth: int = 3) -> None: weightValues[x + 1] = weightValueMin * pow(2, x) # set weights on accent partitions - self.accentSequence = self.accentSequence.partition([1] * accentCount) - for i in range(accentCount): - # get values from weightValues dictionary; terminals are - # immutable, so replace each with a re-weighted cached one - mt = self.accentSequence[i] - self.accentSequence = self.accentSequence.setIndex( - i, getMeterTerminal( - mt.numerator, mt.denominator, weight=weightValues[weightInts[i]])) + accentSequence = self.accentSequence.partitioned([1] * accentCount) + weighted = [] + for i, mt in enumerate(accentSequence): + # get values from weightValues dictionary + weighted.append(getMeterTerminal( + mt.numerator, mt.denominator, weight=weightValues[weightInts[i]])) + self.accentSequence = accentSequence.modify(partition=weighted) # -------------------------------------------------------------------------- # access data for other processing @@ -1659,7 +1650,7 @@ def getAccent(self, qLenPos: OffsetQL) -> bool: division. >>> a = meter.TimeSignature('3/4', 3) - >>> a.accentSequence = a.accentSequence.partition([2, 1]) + >>> a.accentSequence = a.accentSequence.partitioned([2, 1]) >>> a.accentSequence >>> a.getAccent(0.0) @@ -1669,7 +1660,7 @@ def getAccent(self, qLenPos: OffsetQL) -> bool: >>> a.getAccent(2.0) True ''' - pos = 0 + pos: OffsetQL = 0.0 qLenPos = opFrac(qLenPos) for i in range(len(self.accentSequence)): if pos == qLenPos: @@ -1710,8 +1701,7 @@ def setAccentWeight(self, else: weightList = weights - # sequences are immutable; setLevelWeight returns a new one - self.accentSequence = self.accentSequence.setLevelWeight(weightList, level) + self.accentSequence = self.accentSequence.withLevelWeight(weightList, level) def averageBeatStrength(self, streamIn, notesOnly=True): ''' @@ -1862,7 +1852,7 @@ def getBeat(self, offset): 1 >>> a.getBeat(2.5) 3 - >>> a.beatSequence = a.beatSequence.partition(['3/8', '3/8']) + >>> a.beatSequence = a.beatSequence.partitioned(['3/8', '3/8']) >>> a.getBeat(2.5) 2 ''' @@ -2043,7 +2033,7 @@ def getBeatProgress(self, qLenPos): Works for specifically partitioned meters too: - >>> a.beatSequence = a.beatSequence.partition(['3/8', '3/8']) + >>> a.beatSequence = a.beatSequence.partitioned(['3/8', '3/8']) >>> a.getBeatProgress(2.5) (2, 1.0) ''' @@ -2129,9 +2119,7 @@ def getBeatDepth(self, qLenPos, align='quantize'): >>> b = meter.TimeSignature('3/4', 1) >>> b.beatSequence = b.beatSequence.setIndex(0, b.beatSequence[0].subdivide(3)) - Subdivide every component of ``beatSequence[0]`` by 2. Since the sequences - are immutable, build up the new inner sequence in a variable and then set it - back into ``beatSequence`` once: + Subdivide each part of the first beat by 2: >>> inner = b.beatSequence[0] >>> for i in range(len(inner)): diff --git a/music21/meter/core.py b/music21/meter/core.py index df9bc39d2b..a86c0b6e83 100644 --- a/music21/meter/core.py +++ b/music21/meter/core.py @@ -21,7 +21,7 @@ from music21 import common from music21.common.numberTools import opFrac from music21.common.objects import FrozenObject -from music21.common.types import OffsetQLIn +from music21.common.types import OffsetQL, OffsetQLIn from music21.duration import FrozenDuration from music21 import environment from music21.exceptions21 import MeterException @@ -30,30 +30,23 @@ environLocal = environment.Environment('meter.core') -# a MeterTerminal or a MeterSequence, the two kinds of things that can appear -# inside a MeterSequence's partition. (PEP 695 type aliases are evaluated -# lazily, so the forward references resolve fine.) +# what a MeterSequence's partition holds type MeterNode = MeterTerminal | MeterSequence # ----------------------------------------------------------------------------- class MeterCore: ''' - A mixin holding the read-only behavior shared by both - :class:`~music21.meter.core.MeterTerminal` and - :class:`~music21.meter.core.MeterSequence`. - - Each subclass supplies ``numerator``, ``denominator``, and ``weight`` - properties; MeterCore provides the derived, immutable behaviors: the - duration, ratio comparison, and the various ``subdivide`` methods (which - build and return a new :class:`~music21.meter.core.MeterSequence`). + Behavior shared by :class:`~music21.meter.core.MeterTerminal` and + :class:`~music21.meter.core.MeterSequence`: the duration, ratio comparison, + and the ``subdivide`` methods. Subclasses supply ``numerator``, + ``denominator``, and ``weight``. AI-assisted (Claude). ''' __slots__ = () - # these are supplied by the concrete classes as read-only properties; - # declared here (as stubs) for the type checker and to document the contract. + # stubs for the type checker; subclasses supply these @property def numerator(self) -> int: raise NotImplementedError # pragma: no cover @@ -73,10 +66,7 @@ def _durationFromNumeratorDenominator( denominator: int, ) -> FrozenDuration: ''' - Return the (immutable) duration equal in length to a numerator/denominator - ratio. - - Cached, since a small set of ratios (1/4, 1/8, 3/8, ...) recurs constantly. + Return a cached FrozenDuration as long as numerator/denominator of a whole note. >>> meter.core.MeterTerminal._durationFromNumeratorDenominator(3, 8) @@ -89,8 +79,8 @@ def _durationFromNumeratorDenominator( @property def duration(self) -> FrozenDuration: ''' - Returns a :class:`~music21.duration.FrozenDuration` equal in length to this - terminal (or sequence). It is shared and cached, and therefore immutable: + Return a shared :class:`~music21.duration.FrozenDuration` as long as this + terminal or sequence. >>> a = meter.MeterTerminal('3/8') >>> a.duration @@ -100,27 +90,23 @@ def duration(self) -> FrozenDuration: >>> a.duration.dots 1 - Trying to change it raises an exception: + It cannot be changed: >>> a.duration.quarterLength = 5.0 Traceback (most recent call last): TypeError: This FrozenDuration instance is immutable. - If you need a changeable duration, unfreeze it first: + Unfreeze it for a Duration that can be changed: >>> changeable = a.duration.unfreeze() >>> changeable.quarterLength = 5.0 >>> changeable - * Changed in v11: returns a shared, immutable - :class:`~music21.duration.FrozenDuration` (was a mutable Duration). + * Changed in v11: returns a :class:`~music21.duration.FrozenDuration`. ''' return self._durationFromNumeratorDenominator(self.numerator, self.denominator) - def _reprInternal(self): - return str(self) - def ratioEqual(self, other) -> bool: ''' Compare the numerator and denominator of another object. @@ -176,11 +162,10 @@ def subdivideByCount(self, countRequest=None) -> MeterSequence: But what if you want to divide into 3/8+2/8 or something else? - for that, see the :meth:`~music21.meter.MeterSequence.partition` method + for that, see the :meth:`~music21.meter.MeterSequence.partitioned` method of :class:`~music21.meter.MeterSequence`. ''' # cannot set the weight of this MeterSequence w/o having offsets - # pass this object as an argument; when subdividing, use autoWeight return _buildSequence(self, countRequest, autoWeight=True, targetWeight=self.weight) def subdivideByList(self, numeratorList) -> MeterSequence: @@ -215,25 +200,25 @@ def subdivideByList(self, numeratorList) -> MeterSequence: >>> (c[0], c[1]) (, ) - See :meth:`~music21.meter.MeterSequence.partitionByList` method + See :meth:`~music21.meter.MeterSequence.partitionedByList` method of :class:`~music21.meter.MeterSequence` for more details. ''' - # elevate to meter sequence, then partition + # elevate to meter sequence ms = _buildSequence(self) # do not need to autoWeight here - return ms.partitionByList(numeratorList) # this will split weight + return ms.partitionedByList(numeratorList) # this will split weight def subdivide( self, value: Sequence[int | str] | int ) -> MeterSequence: ''' - Subdivision takes a MeterTerminal and, making it into a collection of MeterTerminals, - Returns a MeterSequence. + Return a MeterSequence that divides this terminal or sequence. - This is different from partitioning a MeterSequence. `subdivide` does not happen - in place and instead returns a new object. + A list is a numerator list, as in :meth:`subdivideByList`; an integer is a + count of parts, as in :meth:`subdivideByCount`. - If an integer is provided, assume it is a partition count. + >>> meter.MeterTerminal('3/4').subdivide(3) + ''' if common.isListLike(value): return self.subdivideByList(value) @@ -259,16 +244,16 @@ class MeterTerminal(prebase.ProtoM21Object, MeterCore, FrozenObject): >>> a.duration.quarterLength 10.0 - A MeterTerminal is an immutable value object defined solely by its - numerator, denominator, and weight. Its attributes cannot be changed - after creation; instead make a new one (see :func:`getMeterTerminal`): + A MeterTerminal cannot be changed. :meth:`modify` returns one with + different values: >>> a.numerator = 6 Traceback (most recent call last): TypeError: This MeterTerminal instance is immutable. + >>> a.modify(numerator=6) + - Because it is immutable, deep-copying a MeterTerminal returns the same - shared object, and equality is by value: + Copying returns the same object, and equality is by value: >>> import copy >>> copy.deepcopy(a) is a @@ -276,9 +261,9 @@ class MeterTerminal(prebase.ProtoM21Object, MeterCore, FrozenObject): >>> meter.MeterTerminal('2/4') == meter.MeterTerminal('2/4') True - * Changed in v11: MeterTerminal is immutable, deep-copies to itself, and - compares by value. Removed the numerator/denominator/weight/duration - setters (set values when creating the terminal, or use `modify()`). + * Changed in v11: immutable; copies to itself and compares by value. + The numerator, denominator, weight, and duration setters were removed: + use `modify()`. AI-assisted (Claude). ''' @@ -286,11 +271,11 @@ class MeterTerminal(prebase.ProtoM21Object, MeterCore, FrozenObject): __slots__ = ( '_denominator', + '_frozenHash', '_numerator', '_weight', ) - # slot type declarations (for the type checker); set via object.__setattr__ _numerator: int _denominator: int _weight: float @@ -299,14 +284,10 @@ class MeterTerminal(prebase.ProtoM21Object, MeterCore, FrozenObject): def __init__(self, slashNotation: str|None = None, weight: float = 1.0, *, numerator: int = 0, denominator: int = 1): if slashNotation is not None: - # raise MeterException early if there is a problem. values = tools.slashToTuple(slashNotation) numerator = values.numerator denominator = values.denominator - # Set the slots on the known-safe init path with object.__setattr__ to - # bypass FrozenObject's expensive per-set inspection (which walks the - # stack to find where in a sequence we are); afterwards the object is - # fully immutable. + # object.__setattr__ skips FrozenObject.__setattr__, which walks the stack object.__setattr__(self, '_numerator', numerator) object.__setattr__(self, '_denominator', denominator) object.__setattr__(self, '_weight', weight) @@ -315,19 +296,13 @@ def __init__(self, slashNotation: str|None = None, weight: float = 1.0, *, def __deepcopy__(self, memo=None): ''' - Helper method for the deepcopy function in copy.py. - - Do not call this directly. - - A MeterTerminal is immutable and shared, so deep-copying it simply - returns the same object (a large performance win, since MeterTerminals - are copied constantly as part of Streams and TimeSignatures). + A MeterTerminal is immutable, so deepcopy returns it. ''' return self def __copy__(self): ''' - A MeterTerminal is immutable, so a shallow copy is also just itself. + A MeterTerminal is immutable, so copy returns it. ''' return self @@ -339,13 +314,9 @@ def __str__(self): def modify(self, **keywords) -> MeterTerminal: ''' - Return a (cached) MeterTerminal identical to this one except for the - given ``numerator``, ``denominator`` and/or ``weight`` keyword(s); the - other values carry over. The original is unchanged, since a - MeterTerminal is immutable. - - This is how to "change" an immutable terminal -- analogous to - :func:`copy.replace` (Python 3.13+) or ``namedtuple._replace``. + Return a cached MeterTerminal like this one but with the given + ``numerator``, ``denominator``, or ``weight``. Also available as + :func:`copy.replace` (Python 3.13+). >>> a = meter.MeterTerminal('2/4', weight=0.5) >>> b = a.modify(weight=0.25) @@ -354,8 +325,7 @@ def modify(self, **keywords) -> MeterTerminal: >>> b.weight 0.25 - The original is unchanged, and the un-named values carry over (here the - weight stays 0.5): + The original is unchanged, and values not given carry over: >>> a.weight 0.5 @@ -365,16 +335,12 @@ def modify(self, **keywords) -> MeterTerminal: >>> c.weight 0.5 - modify goes through the cache, so identical results share one object: + Equal results are the same object: >>> a.modify(numerator=3) is c True - Any keyword other than numerator/denominator/weight raises a ValueError: - - >>> a.modify(parenthesis=True) - Traceback (most recent call last): - ValueError: MeterTerminal.modify() does not accept ['parenthesis']... + Other keywords raise a ValueError. AI-assisted (Claude). ''' @@ -389,7 +355,6 @@ def modify(self, **keywords) -> MeterTerminal: weight=keywords.get('weight', self._weight), ) - # copy.replace() support (Python 3.13+) __replace__ = modify # ------------------------------------------------------------------------- @@ -404,12 +369,7 @@ def weight(self) -> float: >>> a.weight 0.5 - To get a MeterTerminal with a different weight, use :meth:`modify`: - - >>> a.modify(weight=0.25) - - - * Changed in v11: weight is immutable and must be a float (not int). + * Changed in v11: read-only; use :meth:`modify`. ''' return self._weight @@ -422,12 +382,7 @@ def numerator(self) -> int: >>> a.numerator 2 - To get a MeterTerminal with a different numerator, use :meth:`modify`: - - >>> a.modify(numerator=3) - - - * Changed in v11: numerator is immutable. + * Changed in v11: read-only; use :meth:`modify`. ''' return self._numerator @@ -440,12 +395,7 @@ def denominator(self) -> int: >>> a.denominator 4 - To get a MeterTerminal with a different denominator, use :meth:`modify`: - - >>> a.modify(denominator=8) - - - * Changed in v11: denominator is immutable. + * Changed in v11: read-only; use :meth:`modify`. ''' return self._denominator @@ -459,18 +409,16 @@ def depth(self): # ----------------------------------------------------------------------------- -_meterTerminalCache: dict[tuple[int, int, float], MeterTerminal] = {} +@lru_cache(1024) +def _cachedMeterTerminal(numerator: int, denominator: int, weight: float) -> MeterTerminal: + return MeterTerminal(numerator=numerator, denominator=denominator, weight=weight) def getMeterTerminal(numerator: int, denominator: int, weight: float = 1.0) -> MeterTerminal: ''' - Return a shared, cached, immutable :class:`MeterTerminal` for these values. - - Because MeterTerminals are immutable, identical `(numerator, denominator, - weight)` triples can all share one object. This is how a "changed" terminal - is produced: instead of mutating an existing terminal, request the cached - terminal for the new values. + Return the cached :class:`MeterTerminal` for these values, making it if needed. + The cache keeps the 1024 most recently used. >>> mt = meter.core.getMeterTerminal(1, 4) >>> mt @@ -482,40 +430,52 @@ def getMeterTerminal(numerator: int, denominator: int, AI-assisted (Claude). ''' - key = (numerator, denominator, weight) - cached = _meterTerminalCache.get(key) - if cached is None: - cached = MeterTerminal(numerator=numerator, denominator=denominator, weight=weight) - _meterTerminalCache[key] = cached - return cached + return _cachedMeterTerminal(numerator, denominator, weight) # ----------------------------------------------------------------------------- +def _removedInPlaceMethod(oldName: str, newName: str): + ''' + Return a method named `oldName` that raises a MeterException pointing to + `newName`, the method that returns a new MeterSequence instead. + ''' + def removed(self, *arguments, **keywords): + raise MeterException( + f'MeterSequence is immutable, so {oldName}() cannot change it in place. ' + f'Use the new sequence that {newName}() returns: ' + f'ms = ms.{newName}(...), or for a TimeSignature, ' + f'ts.beatSequence = ts.beatSequence.{newName}(...)') + + removed.__name__ = oldName + removed.__qualname__ = f'MeterSequence.{oldName}' + removed.__doc__ = (f'Raises a MeterException. Use :meth:`{newName}`, which ' + 'returns a new MeterSequence.\n\n' + '* Changed in v11: no longer changes the sequence in place.') + return removed + + class MeterSequence(prebase.ProtoM21Object, MeterCore, FrozenObject): ''' - A MeterSequence is an immutable, hashable sequence of MeterTerminals, or + A MeterSequence is an immutable, hashable sequence of MeterTerminals or other MeterSequences. >>> ms = meter.MeterSequence('4/4', 4) >>> ms - Like :class:`~music21.meter.core.MeterTerminal`, a MeterSequence is an - immutable value object. Its structure cannot be changed after creation; - the "mutating" methods (:meth:`partition`, :meth:`subdividePartitionsEqual`, - :meth:`setLevelWeight`, :meth:`setIndex`, ...) instead RETURN a new - MeterSequence and leave ``self`` unchanged: + Methods such as :meth:`partitioned`, :meth:`subdividedPartitionsEqual`, + :meth:`withLevelWeight`, and :meth:`setIndex` return a new MeterSequence + and leave the original unchanged: - >>> ms2 = ms.partition(2) + >>> ms2 = ms.partitioned(2) >>> ms2 >>> ms - Because it is immutable, deep-copying a MeterSequence returns the same - shared object, and equality is by value: + Copying returns the same object, and equality is by value: >>> import copy >>> copy.deepcopy(ms) is ms @@ -523,12 +483,27 @@ class MeterSequence(prebase.ProtoM21Object, MeterCore, FrozenObject): >>> meter.MeterSequence('4/4', 4) == meter.MeterSequence('4/4', 4) True - * Changed in v11: MeterSequence is immutable, hashable, deep-copies to - itself, and its formerly in-place operations return a new sequence. - Removed `load()` (set values when creating the sequence), - `subdivideByOther()` (it only wrapped its argument in a new sequence), - the `weight` setter (use `withWeight()`), and item assignment - `ms[i] = x` (use `setIndex()`). + The methods that changed a sequence in place raise an exception and name + the method to use instead: + + >>> ms.partition(2) + Traceback (most recent call last): + music21.exceptions21.MeterException: MeterSequence is immutable, so partition() + cannot change it in place. Use the new sequence that partitioned() returns: + ms = ms.partitioned(...), or for a TimeSignature, + ts.beatSequence = ts.beatSequence.partitioned(...) + + * Changed in v11: immutable and hashable; copies to itself and compares by + value. `partition()`, `partitionByCount()`, `partitionByList()`, + `partitionByOtherMeterSequence()`, `subdividePartitionsEqual()`, + `subdivideNestedHierarchy()`, and `setLevelWeight()` raise a + MeterException: use `partitioned()`, `partitionedByCount()`, + `partitionedByList()`, `partitionedByOtherMeterSequence()`, + `subdividedPartitionsEqual()`, `subdividedNestedHierarchy()`, and + `withLevelWeight()`, which return a new sequence. `load()` raises a + MeterException (pass the value to the constructor) and item assignment + raises a TypeError (use `setIndex()`). Removed `subdivideByOther()` and + the `weight` setter (use `withWeight()`). AI-assisted (Claude). ''' @@ -537,13 +512,13 @@ class MeterSequence(prebase.ProtoM21Object, MeterCore, FrozenObject): __slots__ = ( '_denominator', + '_frozenHash', '_numerator', '_partition', 'parenthesis', 'summedNumerator', ) - # slot type declarations (for the type checker); set via object.__setattr__ _numerator: int _denominator: int _partition: tuple[MeterNode, ...] @@ -557,22 +532,20 @@ def __init__( value: str | MeterTerminal | Sequence[MeterTerminal] | Sequence[str] | None = None, partitionRequest: t.Any|None = None, ): - # Build the data on the known-safe init path using object.__setattr__ to - # bypass FrozenObject's expensive per-set inspection; after __init__ the - # object is fully immutable. numerator, denominator, partitionList, summedNumerator = _loadData(value) parenthesis = False if partitionRequest is not None: built = makeSequence( numerator, denominator, tuple(partitionList), summedNumerator=summedNumerator - ).partition(partitionRequest) + ).partitioned(partitionRequest) numerator = built._numerator denominator = built._denominator partitionList = list(built._partition) summedNumerator = built.summedNumerator parenthesis = built.parenthesis + # object.__setattr__ skips FrozenObject.__setattr__, which walks the stack object.__setattr__(self, '_numerator', numerator) object.__setattr__(self, '_denominator', denominator) object.__setattr__(self, '_partition', tuple(partitionList)) @@ -583,8 +556,7 @@ def __init__( def __deepcopy__(self, memo=None): ''' - A MeterSequence is immutable and shared, so deep-copying it simply - returns the same object. + A MeterSequence is immutable, so deepcopy returns it. >>> from copy import deepcopy >>> ms1 = meter.MeterSequence('4/4+3/8') @@ -595,30 +567,24 @@ def __deepcopy__(self, memo=None): def __copy__(self): ''' - A MeterSequence is immutable, so a shallow copy is also just itself. + A MeterSequence is immutable, so copy returns it. ''' return self def modify(self, **keywords) -> MeterSequence: ''' - Return a (cached) MeterSequence identical to this one except for the - given ``partition``, ``parenthesis`` and/or ``summedNumerator`` keyword(s). + Return a cached MeterSequence like this one but with the given + ``partition``, ``parenthesis``, or ``summedNumerator``. Also available as + :func:`copy.replace` (Python 3.13+). - The original is unchanged, since a MeterSequence is immutable. - - Analogous to :func:`copy.replace` (Python 3.13+). Unlike - :meth:`MeterTerminal.modify`, the numerator, denominator, and weight cannot - be changed here -- they follow from the partition -- so change the structure - with :meth:`partition`, :meth:`subdividePartitionsEqual`, :meth:`setIndex`, - etc. Any keyword other than partition/parenthesis/summedNumerator raises a - ValueError. + The numerator, denominator, and weight follow from the partition, so + they cannot be given here; other keywords raise a ValueError. >>> ms = meter.MeterSequence('4/4', 4) >>> ms - Modify so that there's a parenthesis around the whole sequence. The original - is unchanged. + Put parentheses around the whole sequence: >>> ms.parenthesis False @@ -628,15 +594,6 @@ def modify(self, **keywords) -> MeterSequence: >>> ms.parenthesis False - Trying to change the numerator, denominator, or weight raises a ValueError: - - >>> ms.modify(numerator=3) - Traceback (most recent call last): - ValueError: MeterSequence.modify() does not accept ['numerator']... - >>> ms.modify(weight=1.5) - Traceback (most recent call last): - ValueError: MeterSequence.modify() does not accept ['weight']... - AI-assisted (Claude). ''' extra = set(keywords) - {'partition', 'parenthesis', 'summedNumerator'} @@ -652,7 +609,6 @@ def modify(self, **keywords) -> MeterSequence: summedNumerator=keywords.get('summedNumerator', self.summedNumerator), ) - # copy.replace() support (Python 3.13+) __replace__ = modify def __getitem__(self, key: int) -> MeterNode: @@ -691,12 +647,23 @@ def __len__(self): ''' return len(self._partition) + def __setitem__(self, key: int, value: MeterNode) -> t.NoReturn: + ''' + Raises a TypeError: a MeterSequence is immutable. Use :meth:`setIndex`. + + >>> a = meter.MeterSequence('4/4', 4) + >>> a[0] = a[0].subdivide(2) + Traceback (most recent call last): + TypeError: MeterSequence is immutable: use ms = ms.setIndex(0, value) + + * Changed in v11: raises a TypeError. + ''' + raise TypeError(f'MeterSequence is immutable: use ms = ms.setIndex({key}, value)') + def setIndex(self, index: int, value: MeterNode) -> MeterSequence: ''' Return a new MeterSequence with the element at `index` replaced by - `value` (which must occupy the same ratio of time as the element it - replaces). This is the immutable replacement for the old - ``ms[index] = value`` item assignment. + `value`, which must have the same numerator and denominator. >>> a = meter.MeterSequence('4/4', 4) >>> a @@ -715,11 +682,10 @@ def setIndex(self, index: int, value: MeterNode) -> MeterSequence: Traceback (most recent call last): music21.exceptions21.MeterException: cannot insert {1/16+1/16} into space of 1/4 - * New in v11: functional replacement for ``ms[index] = value`` assignment. + * New in v11. AI-assisted (Claude). ''' - # comparison of numerator and denominator if not isinstance(value, MeterCore): raise MeterException('values in MeterSequences must be MeterTerminals or ' f'MeterSequences, not {value}') @@ -762,8 +728,7 @@ def partitionDisplay(self): def _rebuild(self, partition: list) -> MeterSequence: ''' - Return a cached MeterSequence with the same numerator, denominator, - parenthesis, and summedNumerator as ``self`` but a new partition. + Return a cached MeterSequence like this one with a new partition. AI-assisted (Claude). ''' @@ -809,7 +774,7 @@ def getPartitionOptions(self) -> tools.MeterOptions: # ------------------------------------------------------------------------- - def partitionByCount(self, countRequest: int, loadDefault: bool = True) -> MeterSequence: + def partitionedByCount(self, countRequest: int, loadDefault: bool = True) -> MeterSequence: ''' Return a new MeterSequence dividing this one into the requested number of parts. @@ -820,12 +785,12 @@ def partitionByCount(self, countRequest: int, loadDefault: bool = True) -> Meter >>> a = meter.MeterSequence('4/4') >>> a - >>> a = a.partitionByCount(2) + >>> a = a.partitionedByCount(2) >>> a >>> str(a) '{1/2+1/2}' - >>> a = a.partitionByCount(4) + >>> a = a.partitionedByCount(4) >>> a >>> str(a) @@ -835,13 +800,13 @@ def partitionByCount(self, countRequest: int, loadDefault: bool = True) -> Meter meter is irregular: >>> b = meter.MeterSequence('5/8') - >>> b = b.partitionByCount(2) + >>> b = b.partitionedByCount(2) >>> b This relies on a pre-defined exemption for partitioning 5 by 3: - >>> b = b.partitionByCount(3) + >>> b = b.partitionedByCount(3) >>> str(b) '{2/8+2/8+1/8}' @@ -849,17 +814,17 @@ def partitionByCount(self, countRequest: int, loadDefault: bool = True) -> Meter there is no known way to do this: >>> a = meter.MeterSequence('5/8') - >>> a = a.partitionByCount(11) + >>> a = a.partitionedByCount(11) >>> str(a) '{2/8+3/8}' If loadDefault is False then an error is raised: - >>> a.partitionByCount(11, loadDefault=False) + >>> a.partitionedByCount(11, loadDefault=False) Traceback (most recent call last): music21.exceptions21.MeterException: Cannot set partition by 11 (5/8) - * Changed in v11: returns a new MeterSequence rather than mutating in place. + * New in v11: replaces `partitionByCount()`, which changed the sequence in place. ''' opts = self.getPartitionOptions() optMatch = None @@ -884,7 +849,7 @@ def partitionByCount(self, countRequest: int, loadDefault: bool = True) -> Meter partition = _applyWeight(partition, self.numerator, self.denominator, targetWeight) return self._rebuild(partition) - def partitionByList( + def partitionedByList( self, numeratorList: Sequence[int] | Sequence[str] ) -> MeterSequence: @@ -893,35 +858,35 @@ def partitionByList( list of MeterTerminals. >>> a = meter.MeterSequence('4/4') - >>> a = a.partitionByList([1, 1, 1, 1]) + >>> a = a.partitionedByList([1, 1, 1, 1]) >>> str(a) '{1/4+1/4+1/4+1/4}' This divides it into two equal parts: - >>> a = a.partitionByList([1, 1]) + >>> a = a.partitionedByList([1, 1]) >>> str(a) '{1/2+1/2}' And now into one big part: - >>> a = a.partitionByList([1]) + >>> a = a.partitionedByList([1]) >>> str(a) '{1/1}' Here we divide 4/4 very unconventionally: - >>> a = a.partitionByList(['3/4', '1/8', '1/8']) + >>> a = a.partitionedByList(['3/4', '1/8', '1/8']) >>> a But the basics of the MeterSequence must be observed: - >>> a.partitionByList(['3/4', '1/8', '5/8']) + >>> a.partitionedByList(['3/4', '1/8', '5/8']) Traceback (most recent call last): music21.exceptions21.MeterException: Cannot set partition by ['3/4', '1/8', '5/8'] - * Changed in v11: returns a new MeterSequence rather than mutating in place. + * New in v11: replaces `partitionByList()`, which changed the sequence in place. ''' optMatch: Sequence[MeterNode] | tuple[str, ...] | None = None @@ -967,7 +932,7 @@ def partitionByList( partition = _applyWeight(partition, self.numerator, self.denominator, targetWeight) return self._rebuild(partition) - def partitionByOtherMeterSequence(self, other: MeterSequence) -> MeterSequence: + def partitionedByOtherMeterSequence(self, other: MeterSequence) -> MeterSequence: ''' Return a new MeterSequence with the partition found in another MeterSequence. @@ -977,25 +942,25 @@ def partitionByOtherMeterSequence(self, other: MeterSequence) -> MeterSequence: '{1/4+1/4+1/4+1/4}' >>> b = meter.MeterSequence('4/4', 2) - >>> a = a.partitionByOtherMeterSequence(b) + >>> a = a.partitionedByOtherMeterSequence(b) >>> len(a) 2 >>> str(a) '{1/2+1/2}' - * Changed in v11: returns a new MeterSequence rather than mutating in place. + * New in v11: replaces `partitionByOtherMeterSequence()`, which changed the + sequence in place. ''' if (self.numerator == other.numerator and self.denominator == other.denominator): targetWeight = self.weight - # other's members are immutable, so they can be shared directly partition = list(other._partition) partition = _applyWeight(partition, self.numerator, self.denominator, targetWeight) return self._rebuild(partition) else: raise MeterException('Cannot set partition for unequal MeterSequences') - def partition( + def partitioned( self, value: int | Sequence[str] | Sequence[MeterTerminal] | Sequence[int] | MeterSequence, loadDefault=False @@ -1005,7 +970,7 @@ def partition( A simple way to partition based on argument time. Single integers are treated as beat counts; lists are treated as numerator lists; - MeterSequence objects are partitioned by calling partitionByOtherMeterSequence(). + MeterSequence objects are partitioned by calling partitionedByOtherMeterSequence(). >>> a = meter.MeterSequence('5/4+3/8') >>> len(a) @@ -1018,13 +983,13 @@ def partition( 1 >>> str(b) '{13/8}' - >>> b = b.partition(13) + >>> b = b.partitioned(13) >>> len(b) 13 >>> str(b) '{1/8+1/8+1/8+...+1/8}' - >>> a = a.partition(b) + >>> a = a.partitioned(b) >>> len(a) 13 >>> str(a) @@ -1033,12 +998,12 @@ def partition( Demo of loadDefault: if impossible, then do it another way: >>> c = meter.MeterSequence('3/128') - >>> c.partition(2) + >>> c.partitioned(2) Traceback (most recent call last): music21.exceptions21.MeterException: Cannot set partition by 2 (3/128) >>> c = meter.MeterSequence('3/128') - >>> c = c.partition(2, loadDefault=True) + >>> c = c.partitioned(2, loadDefault=True) >>> len(c) 3 >>> str(c) @@ -1046,18 +1011,18 @@ def partition( * Changed in v9.3: if given a list it must either be all numbers, all strings, or all MeterTerminals, not a mix (which was undocumented and buggy) - * Changed in v11: returns a new MeterSequence rather than mutating in place. + * New in v11: replaces `partition()`, which changed the sequence in place. ''' if common.isListLike(value): - return self.partitionByList(value) + return self.partitionedByList(value) elif isinstance(value, MeterSequence): - return self.partitionByOtherMeterSequence(value) + return self.partitionedByOtherMeterSequence(value) elif isinstance(value, int): - return self.partitionByCount(value, loadDefault=loadDefault) + return self.partitionedByCount(value, loadDefault=loadDefault) else: raise MeterException(f'cannot process partition argument {value}') - def subdividePartitionsEqual(self, divisions: int|None = None) -> MeterSequence: + def subdividedPartitionsEqual(self, divisions: int|None = None) -> MeterSequence: ''' Return a new MeterSequence with all partitions subdivided by equally-spaced divisions, given a divisions value. @@ -1081,7 +1046,7 @@ def subdividePartitionsEqual(self, divisions: int|None = None) -> MeterSequence: Divide the Sequence into two parts, so now there are two MeterTerminals of 1/4 each: - >>> ms = ms.partition(2) + >>> ms = ms.partitioned(2) >>> ms >>> len(ms) @@ -1092,9 +1057,9 @@ def subdividePartitionsEqual(self, divisions: int|None = None) -> MeterSequence: But what happens if we want to divide each of those into 1/8+1/8 are replace - them by MeterSequences? subdividePartitionsEqual is what is needed. + them by MeterSequences? subdividedPartitionsEqual is what is needed. - >>> ms = ms.subdividePartitionsEqual(2) + >>> ms = ms.subdividedPartitionsEqual(2) >>> ms @@ -1110,17 +1075,17 @@ def subdividePartitionsEqual(self, divisions: int|None = None) -> MeterSequence: To subdivide a nested component, replace it functionally: - >>> ms = ms.setIndex(0, ms[0].subdividePartitionsEqual(2)) + >>> ms = ms.setIndex(0, ms[0].subdividedPartitionsEqual(2)) >>> ms - >>> ms = ms.setIndex(1, ms[1].subdividePartitionsEqual(2)) + >>> ms = ms.setIndex(1, ms[1].subdividedPartitionsEqual(2)) >>> ms If None is given as a parameter, then it will try to find something logical. >>> ms = meter.MeterSequence('2/4+3/4') - >>> ms = ms.subdividePartitionsEqual(None) + >>> ms = ms.subdividedPartitionsEqual(None) >>> ms @@ -1129,11 +1094,11 @@ def subdividePartitionsEqual(self, divisions: int|None = None) -> MeterSequence: >>> ms = meter.MeterSequence('5/8+3/8') >>> len(ms) 2 - >>> ms.subdividePartitionsEqual(5) + >>> ms.subdividedPartitionsEqual(5) Traceback (most recent call last): music21.exceptions21.MeterException: Cannot set partition by 5 (3/8) - * Changed in v11: returns a new MeterSequence rather than mutating in place. + * New in v11: replaces `subdividePartitionsEqual()`, which changed the sequence in place. ''' newPartition: list[MeterNode] = [] for i in range(len(self)): @@ -1155,7 +1120,7 @@ def subdividePartitionsEqual(self, divisions: int|None = None) -> MeterSequence: return self._rebuild(newPartition) - def subdivideNestedHierarchy(self, depth, firstPartitionForm=None, + def subdividedNestedHierarchy(self, depth, firstPartitionForm=None, normalizeDenominators=True) -> MeterSequence: ''' Return a new MeterSequence with a nested structure down to a specified @@ -1165,28 +1130,21 @@ def subdivideNestedHierarchy(self, depth, firstPartitionForm=None, `normalizeDenominators`, if True, will reduce all denominators to the same minimum level. >>> ms = meter.MeterSequence('4/4') - >>> ms.subdivideNestedHierarchy(1) + >>> ms.subdividedNestedHierarchy(1) - >>> ms.subdivideNestedHierarchy(2) + >>> ms.subdividedNestedHierarchy(2) - >>> ms.subdivideNestedHierarchy(3) + >>> ms.subdividedNestedHierarchy(3) I think you get the picture! - The effects above are not cumulative -- each call starts from ``ms`` -- - so users can skip directly to whatever level of hierarchy they want. - - >>> ms2 = meter.MeterSequence('4/4') - >>> ms2.subdivideNestedHierarchy(3) - - - * Changed in v11: returns a new MeterSequence rather than mutating in place. + * New in v11: replaces `subdivideNestedHierarchy()`, which changed the sequence in place. AI-assisted (Claude). ''' # as a hierarchical representation, zeroth subdivision must be 1 - ms = self.partition(1) + ms = self.partitioned(1) depthCount = 0 # initial divisions are often based on numerator or are provided @@ -1209,7 +1167,7 @@ def subdivideNestedHierarchy(self, depth, firstPartitionForm=None, divFirst = firstPartitionForm # use partitions equal, divide by number - ms = ms.subdividePartitionsEqual(divFirst) + ms = ms.subdividedPartitionsEqual(divFirst) depthCount += 1 # all other partitions are recursive; start first with list of paths @@ -1329,7 +1287,7 @@ def weight(self) -> float: >>> a = meter.MeterSequence('3/4') >>> a.weight 1.0 - >>> a = a.partition(3) + >>> a = a.partitioned(3) >>> a >>> a.weight @@ -1345,9 +1303,6 @@ def weight(self) -> float: >>> accentSequence = meter.MeterSequence([downbeat, upbeat]) >>> accentSequence.weight 0.75 - - A MeterSequence's weight is not stored; it is recomputed from its parts - on each call. ''' summation = 0.0 for obj in self._partition: @@ -1356,19 +1311,15 @@ def weight(self) -> float: def withWeight(self, targetWeight: float) -> MeterSequence: ''' - Return a new MeterSequence whose children are re-weighted so that they - sum (proportionally) to ``targetWeight``. - - This is the immutable replacement for the pre-v11 ``ms.weight = x`` - setter: rather than re-weighting the children in place, request a new - sequence. + Return a new MeterSequence whose parts are weighted by length to sum + to ``targetWeight``. - >>> ms = meter.MeterSequence('4/4', 4) - >>> ms2 = ms.withWeight(1.0) + >>> ms = meter.MeterSequence('3/4', [2, 1]) + >>> ms2 = ms.withWeight(3.0) >>> [mt.weight for mt in ms2] - [0.25, 0.25, 0.25, 0.25] + [2.0, 1.0] - * New in v11: replaces the ``weight`` setter. + * New in v11. AI-assisted (Claude). ''' @@ -1386,7 +1337,7 @@ def _getFlatList(self): to concatenate them. >>> a = meter.MeterSequence('3/4') - >>> a = a.partition(3) + >>> a = a.partitioned(3) >>> b = a._getFlatList() >>> b [, @@ -1505,7 +1456,7 @@ def isUniformPartition(self, *, depth=0): >>> ms = meter.MeterSequence('4/4') >>> ms.isUniformPartition() True - >>> ms = ms.partition(4) + >>> ms = ms.partitioned(4) >>> ms.isUniformPartition() True >>> ms = ms.setIndex(0, ms[0].subdivideByCount(2)) @@ -1522,7 +1473,7 @@ def isUniformPartition(self, *, depth=0): >>> ms = meter.MeterSequence('5/8', 5) >>> ms.isUniformPartition() True - >>> ms = ms.partition(2) + >>> ms = ms.partitioned(2) >>> ms.isUniformPartition() False @@ -1703,8 +1654,7 @@ def getLevelSpan(self, level=0): def getLevelWeight(self, level=0): ''' - The weightList is an array of weights found in the components. - The MeterSequence has a ._weight attribute, but it is not used here + Return a list of the weights of the terminals at `level`. >>> a = meter.MeterSequence('4/4', 4) >>> a.getLevelWeight() @@ -1734,18 +1684,18 @@ def getLevelWeight(self, level=0): post.append(mt.weight) return post - def setLevelWeight(self, weightList, level=0) -> MeterSequence: + def withLevelWeight(self, weightList, level=0) -> MeterSequence: ''' The `weightList` is an array of weights to be applied to a single level of the MeterSequence. Returns a new MeterSequence. >>> a = meter.MeterSequence('4/4', 4) - >>> a = a.setLevelWeight([1.0, 2.0, 3.0, 4.0]) + >>> a = a.withLevelWeight([1.0, 2.0, 3.0, 4.0]) >>> a.getLevelWeight() [1.0, 2.0, 3.0, 4.0] >>> b = meter.MeterSequence('4/4', 4) - >>> b = b.setLevelWeight([2.0, 3.0]) + >>> b = b.withLevelWeight([2.0, 3.0]) >>> b.getLevelWeight(0) [2.0, 3.0, 2.0, 3.0] @@ -1764,17 +1714,16 @@ def setLevelWeight(self, weightList, level=0) -> MeterSequence: >>> b.getLevelWeight(2) [2.0, 1.5, 1.5, 2.0, 0.75, 0.75, 1.5] - * Changed in v11: returns a new MeterSequence rather than mutating in place. + * New in v11: replaces `setLevelWeight()`, which changed the sequence in place. ''' new, unused_idx = self._withLevelWeights(weightList, level, 0) return new def _withLevelWeights(self, weightList, level, startIndex=0) -> tuple[MeterSequence, int]: ''' - Recursively re-weight the leaf terminals reached at `level`, mirroring - the flattening order of :meth:`getLevelList` (flat=True) so that the same - terminals are affected and the same running index into `weightList` is - used. Returns the new MeterSequence and the next running index. + Reweight the terminals that :meth:`getLevelList` returns for `level`, + starting at `startIndex` in `weightList`. Return the new MeterSequence + and the next index. AI-assisted (Claude). ''' @@ -1790,9 +1739,8 @@ def _withLevelWeights(self, weightList, level, startIndex=0) -> tuple[MeterSeque child, idx = partition_i._withLevelWeights(weightList, level - 1, idx) newPartition[i] = child else: - # getLevelList(flat=True) flattens this sequence to a single - # synthetic terminal that has no persistent home; only the index - # advances (matching the historical behavior). + # getLevelList makes a new terminal for this sequence; + # its weight has nowhere to go idx += 1 return self._rebuild(newPartition), idx @@ -1809,13 +1757,13 @@ def offsetToIndex(self, qLenPos, includeCoincidentBoundaries=False) -> int: 0 >>> a.offsetToIndex(3.5) 0 - >>> a = a.partition(4) + >>> a = a.partitioned(4) >>> a.offsetToIndex(0.5) 0 >>> a.offsetToIndex(3.5) 3 - >>> a = a.partition([1, 2, 1]) + >>> a = a.partitioned([1, 2, 1]) >>> len(a) 3 >>> a.offsetToIndex(2.9) @@ -1842,7 +1790,7 @@ def offsetToIndex(self, qLenPos, includeCoincidentBoundaries=False) -> int: f'where total duration is {self.duration.quarterLength}' ) - qPos = 0 + qPos: OffsetQL = 0.0 match = -1 # no match -- will not happen. for i in range(len(self)): start = qPos @@ -1944,7 +1892,6 @@ def offsetToSpan(self, qLenPos, permitMeterModulus=False): >>> a.offsetToSpan(4.33333336, permitMeterModulus=True) (1.0, 2.0) - ''' qLenPos = opFrac(qLenPos) if qLenPos >= self.duration.quarterLength or qLenPos < 0: @@ -1984,7 +1931,6 @@ def offsetToWeight(self, qLenPos): Fraction(1, 3) >>> a.offsetToWeight(1.5) Fraction(1, 3) - ''' # Not sure what this does! qLenPos = opFrac(qLenPos) @@ -2054,13 +2000,52 @@ def offsetToDepth(self, qLenPos: OffsetQLIn, align: str = 'quantize', index: int return score + def load(self, *arguments, **keywords) -> t.NoReturn: + ''' + Raises a MeterException: a MeterSequence is immutable. Pass the value + to the constructor or to :func:`getMeterSequence` instead. + + >>> ms = meter.MeterSequence() + >>> ms.load('4/4') + Traceback (most recent call last): + music21.exceptions21.MeterException: MeterSequence is immutable, so load() + cannot change it: use ms = meter.MeterSequence('4/4') + + * Changed in v11: no longer changes the sequence in place. + ''' + value = repr(arguments[0]) if arguments else '...' + raise MeterException('MeterSequence is immutable, so load() cannot change it: ' + f'use ms = meter.MeterSequence({value})') + + partition = _removedInPlaceMethod('partition', 'partitioned') + partitionByCount = _removedInPlaceMethod('partitionByCount', 'partitionedByCount') + partitionByList = _removedInPlaceMethod('partitionByList', 'partitionedByList') + partitionByOtherMeterSequence = _removedInPlaceMethod( + 'partitionByOtherMeterSequence', 'partitionedByOtherMeterSequence') + subdividePartitionsEqual = _removedInPlaceMethod( + 'subdividePartitionsEqual', 'subdividedPartitionsEqual') + subdivideNestedHierarchy = _removedInPlaceMethod( + 'subdivideNestedHierarchy', 'subdividedNestedHierarchy') + setLevelWeight = _removedInPlaceMethod('setLevelWeight', 'withLevelWeight') + # ----------------------------------------------------------------------------- -# module-level building helpers for the immutable MeterSequence -_meterSequenceCache: dict[ - tuple[int, int, tuple, bool, bool], MeterSequence -] = {} +@lru_cache(4096) +def _cachedSequence( + numerator: int, + denominator: int, + partition: tuple, + parenthesis: bool, + summedNumerator: bool, +) -> MeterSequence: + ms = MeterSequence.__new__(MeterSequence) + object.__setattr__(ms, '_numerator', numerator) + object.__setattr__(ms, '_denominator', denominator) + object.__setattr__(ms, '_partition', partition) + object.__setattr__(ms, 'parenthesis', parenthesis) + object.__setattr__(ms, 'summedNumerator', summedNumerator) + return ms def makeSequence( @@ -2072,23 +2057,20 @@ def makeSequence( summedNumerator: bool = False, ) -> MeterSequence: ''' - Return a shared, cached, immutable MeterSequence for these values, - building it directly (bypassing __init__) when it is not already cached. + Return the cached MeterSequence for these values, making it if needed. + The partition must be a tuple of MeterTerminals and MeterSequences whose + sum is numerator/denominator. The cache keeps the 4096 most recently used. + + >>> quarter = meter.core.getMeterTerminal(1, 4) + >>> ms = meter.core.makeSequence(2, 4, (quarter, quarter)) + >>> ms + + >>> meter.core.makeSequence(2, 4, (quarter, quarter)) is ms + True AI-assisted (Claude). ''' - key = (numerator, denominator, partition, parenthesis, summedNumerator) - cached = _meterSequenceCache.get(key) - if cached is not None: - return cached - ms = MeterSequence.__new__(MeterSequence) - object.__setattr__(ms, '_numerator', numerator) - object.__setattr__(ms, '_denominator', denominator) - object.__setattr__(ms, '_partition', partition) - object.__setattr__(ms, 'parenthesis', parenthesis) - object.__setattr__(ms, 'summedNumerator', summedNumerator) - _meterSequenceCache[key] = ms - return ms + return _cachedSequence(numerator, denominator, partition, parenthesis, summedNumerator) def getMeterSequence( @@ -2096,12 +2078,8 @@ def getMeterSequence( partitionRequest: t.Any | None = None, ) -> MeterSequence: ''' - Return a shared, cached, immutable :class:`MeterSequence` built from the - same inputs as the :class:`MeterSequence` constructor. - - Because MeterSequences are immutable, identical inputs all share one - object -- so, e.g., every default ``4/4`` TimeSignature reuses the same - beam/beat/accent/display sequences. + Return the cached :class:`MeterSequence` for the same arguments as the + :class:`MeterSequence` constructor. >>> ms = meter.core.getMeterSequence('4/4', 4) >>> ms @@ -2119,24 +2097,21 @@ def getMeterSequence( def _coerceTerminal(value: MeterTerminal | MeterSequence | str) -> MeterNode: ''' - Return a MeterTerminal/MeterSequence for the given value: if it is already a - MeterCore, return it as-is (they are immutable and shareable); if it is a - string, return the cached terminal for that slash notation. + Return `value` if it is a MeterTerminal or MeterSequence, otherwise the + cached MeterTerminal for a string such as '3/8'. AI-assisted (Claude). ''' # NOTE: this is a performance critical helper - if isinstance(value, MeterCore): # MeterTerminal or MeterSequence + if isinstance(value, MeterCore): return value - # assume it is a string; share the cached immutable terminal values = tools.slashToTuple(value) return getMeterTerminal(values.numerator, values.denominator) def _ratioFromPartition(partition) -> tuple[int, int]: ''' - Return the (numerator, denominator) sum (not reduced) for a partition of - MeterTerminals/MeterSequences. + Return the unreduced (numerator, denominator) sum of a partition. AI-assisted (Claude). ''' @@ -2145,9 +2120,8 @@ def _ratioFromPartition(partition) -> tuple[int, int]: def _applyWeight(partition, numerator: int, denominator: int, targetWeight: float) -> list: ''' - Return a new partition list whose members are re-weighted so that they - sum (proportionally by ratio) to ``targetWeight``. Terminals are replaced - by cached re-weighted ones; nested sequences are re-weighted recursively. + Return a new partition whose parts are weighted by length to sum to + ``targetWeight``. Nested sequences are reweighted recursively. AI-assisted (Claude). ''' @@ -2170,11 +2144,9 @@ def _loadData( targetWeight: float | None = None, ) -> tuple[int, int, list, bool]: ''' - Build the raw data for a MeterSequence from a value: return - ``(numerator, denominator, partitionList, summedNumerator)``. - - `autoWeight`/`targetWeight` mirror the old ``MeterSequence.load``: when - ``targetWeight`` is provided the members are re-weighted to sum to it. + Return ``(numerator, denominator, partitionList, summedNumerator)`` for a + MeterSequence made from `value`. If `autoWeight` is True and `targetWeight` + is given, the parts are weighted to sum to it. AI-assisted (Claude). ''' @@ -2201,7 +2173,6 @@ def _loadData( if isinstance(value, MeterSequence): value = value.withWeight(targetWeight) else: - # a terminal is immutable: swap in a re-weighted cached one value = getMeterTerminal(value.numerator, value.denominator, weight=targetWeight) partition.append(value) @@ -2227,8 +2198,8 @@ def _buildSequence( targetWeight: float | None = None, ) -> MeterSequence: ''' - Build a cached, immutable MeterSequence from a value (and optional - partitionRequest), applying weights as the old ``load`` did. + Return a cached MeterSequence made from `value`, partitioned by + `partitionRequest` if given, with weights as in :func:`_loadData`. AI-assisted (Claude). ''' @@ -2237,7 +2208,7 @@ def _buildSequence( ms = makeSequence(numerator, denominator, tuple(partition), summedNumerator=summedNumerator) if partitionRequest is not None: - ms = ms.partition(partitionRequest) + ms = ms.partitioned(partitionRequest) return ms @@ -2253,19 +2224,26 @@ def _getAtPath(root: MeterSequence, path: tuple[int, ...]) -> MeterNode: return node -def _replaceAtPath(root: MeterSequence, path: tuple[int, ...], newNode) -> MeterSequence: +def _replaceAtPaths( + node: MeterNode, + replacements: dict[tuple[int, ...], MeterNode], + ancestors: set[tuple[int, ...]], + path: tuple[int, ...] = (), +) -> MeterNode: ''' - Return a new root MeterSequence with the node at `path` replaced by - `newNode`, rebuilding each ancestor functionally. + Return `node` with each node at a path in `replacements` replaced, rebuilding + only the sequences in `ancestors`, the paths above a replacement. AI-assisted (Claude). ''' - idx = path[0] - if len(path) == 1: - return root.setIndex(idx, newNode) - child = t.cast('MeterSequence', root[idx]) - newChild = _replaceAtPath(child, path[1:], newNode) - return root.setIndex(idx, newChild) + if path in replacements: + return replacements[path] + if path not in ancestors: + return node + sequence = t.cast('MeterSequence', node) + newPartition = [_replaceAtPaths(child, replacements, ancestors, path + (i,)) + for i, child in enumerate(sequence)] + return sequence._rebuild(newPartition) def _subdivideNestedByPaths( @@ -2274,20 +2252,22 @@ def _subdivideNestedByPaths( divisions, ) -> tuple[MeterSequence, list[tuple[int, ...]]]: ''' - Subdivide (equally) each node reached by a path in `paths`, rebuilding the - root functionally, and return the new root plus the list of paths to the - newly created child nodes. + Subdivide each node at `paths` equally. No path may lie inside another. + Return the new root and the paths to the new child nodes. AI-assisted (Claude). ''' + replacements: dict[tuple[int, ...], MeterNode] = {} newPaths: list[tuple[int, ...]] = [] for p in paths: node = t.cast('MeterSequence', _getAtPath(root, p)) - newNode = node.subdividePartitionsEqual(divisions) - root = _replaceAtPath(root, p, newNode) + newNode = node.subdividedPartitionsEqual(divisions) + replacements[p] = newNode for j in range(len(newNode)): newPaths.append(p + (j,)) - return root, newPaths + ancestors = {p[:k] for p in paths for k in range(len(p))} + newRoot = t.cast('MeterSequence', _replaceAtPaths(root, replacements, ancestors)) + return newRoot, newPaths if __name__ == '__main__': diff --git a/music21/meter/tests.py b/music21/meter/tests.py index 8f7999c955..39d55592cc 100644 --- a/music21/meter/tests.py +++ b/music21/meter/tests.py @@ -13,6 +13,7 @@ import copy import random import unittest +from unittest import mock from music21 import common from music21 import duration @@ -90,7 +91,6 @@ def testMeterSubdivision(self): def testMeterSequenceDeepcopy(self): a = MeterSequence('4/4', 4) b = copy.deepcopy(a) - # a MeterSequence is immutable, so deepcopy returns the same object self.assertIs(a, b) self.assertEqual(a, b) @@ -105,50 +105,32 @@ def testTimeSignatureDeepcopy(self): self.assertNotEqual(e, f) def testMeterTerminalCache(self): - # this is an internal test, so it inspects the private cache directly - from music21.meter.core import _meterTerminalCache - # getMeterTerminal populates the cache under its (numerator, denominator, - # weight) key, and a repeat request is the very object stored there a = getMeterTerminal(1, 4) - self.assertIs(_meterTerminalCache[(1, 4, 1.0)], a) self.assertIs(a, getMeterTerminal(1, 4)) self.assertIs(a, getMeterTerminal(numerator=1, denominator=4, weight=1.0)) - # a different weight is a different terminal, stored under its own key b = getMeterTerminal(1, 4, weight=0.5) self.assertIsNot(a, b) - self.assertIs(_meterTerminalCache[(1, 4, 0.5)], b) - # value equality regardless of how they were built + self.assertIs(b, getMeterTerminal(1, 4, 0.5)) self.assertEqual(MeterTerminal('1/4'), getMeterTerminal(1, 4)) def testMeterTerminalModify(self): a = getMeterTerminal(2, 4, weight=0.5) - # modify changes only the named slot(s); the rest carry over b = a.modify(weight=0.25) self.assertEqual((b.numerator, b.denominator, b.weight), (2, 4, 0.25)) c = a.modify(numerator=3) self.assertEqual((c.numerator, c.denominator, c.weight), (3, 4, 0.5)) - # the original is unchanged (immutable) and setting a slot raises self.assertEqual(a.weight, 0.5) with self.assertRaises(TypeError): a.weight = 0.25 - # modify is wired through the cache, and __replace__ is an alias self.assertIs(a.modify(weight=0.25), b) self.assertIs(a.__replace__(numerator=3), c) - # an unhandled keyword raises rather than being silently ignored with self.assertRaises(ValueError): a.modify(parenthesis=True) def testMeterSequenceCache(self): - # this is an internal test, so it inspects the private caches directly - from music21.meter.core import _meterSequenceCache from music21.meter.base import _defaultSequenceCache - # getMeterSequence stores the built sequence in the sequence cache, and a - # repeat request is the very object stored there ms = getMeterSequence('4/4', 4) self.assertIs(getMeterSequence('4/4', 4), ms) - self.assertTrue(any(cached is ms for cached in _meterSequenceCache.values())) - # TimeSignature.load stores its configured (display, beam, beat, accent) - # sequences under (value, divisions); a repeat 4/4 reuses exactly those a = TimeSignature('4/4') display, beam, beat, accent = _defaultSequenceCache[('4/4', None)] self.assertIs(a.displaySequence, display) @@ -160,23 +142,87 @@ def testMeterSequenceCache(self): 'accentSequence', 'displaySequence'): self.assertIs(getattr(a, seqName), getattr(b, seqName), seqName) + def testCachesAreBounded(self): + from music21.meter import base as meterBase + cache = meterBase._defaultSequenceCache + first = TimeSignature('4/4') + with mock.patch.object(meterBase, '_DEFAULT_SEQUENCE_CACHE_SIZE', 4): + for numerator in range(1, 9): + TimeSignature(f'{numerator}/64') + TimeSignature('4/4') + self.assertLessEqual(len(cache), 4) + self.assertIn(('4/4', None), cache) + self.assertNotIn(('1/64', None), cache) + self.assertIs(TimeSignature('4/4').beatSequence, first.beatSequence) + evicted = TimeSignature('1/64') + self.assertEqual(evicted.beatSequence, MeterSequence('1/64')) + + def testHashIsCached(self): + import pickle + ms = TimeSignature('6/8').accentSequence + h = hash(ms) + self.assertEqual(ms._frozenHash, h) + self.assertEqual(hash(ms), h) + self.assertNotIn('_frozenHash', ms._getSlotsRecursive()) + self.assertEqual(ms, MeterSequence(list(ms))) + loaded = pickle.loads(pickle.dumps(ms)) + self.assertEqual(loaded, ms) + self.assertEqual(hash(loaded), h) + fd = duration.FrozenDuration(1.5) + self.assertEqual(hash(fd), fd._frozenHash) + self.assertEqual(hash(fd), hash(duration.FrozenDuration(1.5))) + def testMeterSequenceModify(self): a = MeterSequence('4/4', 4) self.assertFalse(a.parenthesis) b = a.modify(parenthesis=True) self.assertTrue(b.parenthesis) - self.assertFalse(a.parenthesis) # original unchanged - # a mutating attempt on the immutable sequence raises + self.assertFalse(a.parenthesis) with self.assertRaises(TypeError): a.parenthesis = True - # modify is cache-wired: same result from two independent sequences self.assertIs(b, MeterSequence('4/4', 4).modify(parenthesis=True)) - # numerator/denominator/weight are not modifiable here (they follow from - # the partition); an unhandled keyword raises + # these follow from the partition for kw in ({'numerator': 3}, {'denominator': 8}, {'weight': 1.5}): with self.assertRaises(ValueError): a.modify(**kw) + def testInPlaceCallsRaise(self): + from music21.exceptions21 import MeterException + ts = TimeSignature('6/8') + before = ts.beatSequence + removed = [ + ('partition', (6,), 'partitioned'), + ('partitionByCount', (6,), 'partitionedByCount'), + ('partitionByList', ([1, 1, 1, 1, 1, 1],), 'partitionedByList'), + ('partitionByOtherMeterSequence', (MeterSequence('6/8', 6),), + 'partitionedByOtherMeterSequence'), + ('subdividePartitionsEqual', (3,), 'subdividedPartitionsEqual'), + ('subdivideNestedHierarchy', (3,), 'subdividedNestedHierarchy'), + ('setLevelWeight', ([1, 2],), 'withLevelWeight'), + ] + for oldName, arguments, newName in removed: + with self.assertRaisesRegex(MeterException, rf'{newName}\(\)', msg=oldName): + getattr(ts.beatSequence, oldName)(*arguments) + getattr(ts.beatSequence, newName)(*arguments) + self.assertIs(ts.beatSequence, before) + + with self.assertRaisesRegex(MeterException, r"MeterSequence\('4/4'\)"): + MeterSequence().load('4/4') + with self.assertRaisesRegex(TypeError, r'setIndex\(0, value\)'): + ts.beatSequence[0] = ts.beatSequence[0].subdivide(3) + + mt = MeterTerminal('3/4') + for attribute, value in (('numerator', 2), ('denominator', 8), ('weight', 0.5), + ('duration', duration.Duration(2.0))): + with self.assertRaises(TypeError, msg=attribute): + setattr(mt, attribute, value) + for attribute, value in (('weight', 0.5), ('parenthesis', True), + ('summedNumerator', True)): + with self.assertRaises(TypeError, msg=attribute): + setattr(ts.beatSequence, attribute, value) + self.assertIs(ts.beatSequence, before) + self.assertEqual(str(mt), '3/4') + def testGetBeams(self): ts = TimeSignature('6/8') durList = [16, 16, 16, 16, 8, 16, 16, 16, 16, 8] @@ -262,7 +308,6 @@ def test_getBeams_offset(self): def testOffsetToDepth(self): # get a maximally divided 4/4 to the level of 1/8 def recSub(node, remaining): - # functionally subdivide node by 2 for `remaining` levels if remaining == 0: return node sub = node.subdivide(2) @@ -392,28 +437,28 @@ def testBeatProportionFromTimeSignature(self): beatDur[i]) def testSubdividePartitionsEqual(self): - ms = MeterSequence('2/4').subdividePartitionsEqual(None) + ms = MeterSequence('2/4').subdividedPartitionsEqual(None) self.assertEqual(str(ms), '{{1/4+1/4}}') - ms = MeterSequence('3/4').subdividePartitionsEqual(None) + ms = MeterSequence('3/4').subdividedPartitionsEqual(None) self.assertEqual(str(ms), '{{1/4+1/4+1/4}}') - ms = MeterSequence('6/8').subdividePartitionsEqual(None) + ms = MeterSequence('6/8').subdividedPartitionsEqual(None) self.assertEqual(str(ms), '{{3/8+3/8}}') - ms = MeterSequence('6/16').subdividePartitionsEqual(None) + ms = MeterSequence('6/16').subdividedPartitionsEqual(None) self.assertEqual(str(ms), '{{3/16+3/16}}') - ms = MeterSequence('3/8+3/8').subdividePartitionsEqual(None) + ms = MeterSequence('3/8+3/8').subdividedPartitionsEqual(None) self.assertEqual(str(ms), '{{1/8+1/8+1/8}+{1/8+1/8+1/8}}') - ms = MeterSequence('2/8+3/8').subdividePartitionsEqual(None) + ms = MeterSequence('2/8+3/8').subdividedPartitionsEqual(None) self.assertEqual(str(ms), '{{1/8+1/8}+{1/8+1/8+1/8}}') - ms = MeterSequence('5/8').subdividePartitionsEqual(None) + ms = MeterSequence('5/8').subdividedPartitionsEqual(None) self.assertEqual(str(ms), '{{1/8+1/8+1/8+1/8+1/8}}') - ms = MeterSequence('3/8+3/4').subdividePartitionsEqual(None) # can partition by another + ms = MeterSequence('3/8+3/4').subdividedPartitionsEqual(None) # can partition by another self.assertEqual(str(ms), '{{1/8+1/8+1/8}+{1/4+1/4+1/4}}') def testSetDefaultAccentWeights(self): @@ -511,7 +556,7 @@ def testSlowSixEight(self): from music21 import meter # create a meter with 6 beats but beams in 2 groups ts = meter.TimeSignature('6/8') - ts.beatSequence = ts.beatSequence.partition(6) + ts.beatSequence = ts.beatSequence.partitioned(6) self.assertEqual(str(ts.beatSequence), '{1/8+1/8+1/8+1/8+1/8+1/8}') self.assertEqual(str(ts.beamSequence), '{3/8+3/8}') diff --git a/music21/midi/percussion.py b/music21/midi/percussion.py index 3cb11ec5eb..abc380d6cd 100644 --- a/music21/midi/percussion.py +++ b/music21/midi/percussion.py @@ -5,12 +5,11 @@ # Authors: Michael Scott Asato Cuthbert # Ben Houge # -# Copyright: Copyright © 2012, 2017 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2012-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ from __future__ import annotations -import typing as t import unittest from music21 import pitch @@ -25,7 +24,7 @@ class MIDIPercussionException(exceptions21.Music21Exception): class PercussionMapper: ''' PercussionMapper provides tools to convert between 0-indexed MIDI pitches - and music21 instruments, based on the official General MIDI Level 1 Percussion Key Map. + and music21 instruments, based on the General MIDI Level 1 and 2 Percussion Key Maps. This mapping is conventionally applied to MIDI channel 10; see https://midi.org/general-midi-level-1 for more info. @@ -45,27 +44,26 @@ class PercussionMapper: 36: i.BassDrum, # Bass Drum 1 37: i.SnareDrum, # Side Stick 38: i.SnareDrum, # Acoustic Snare - # 39: i.Hand Clap, 40: i.SnareDrum, # Electric Snare 41: i.TomTom, # Low Floor Tom - 42: i.HiHatCymbal, # Closed Hi Hat + 42: i.HiHat, # Closed Hi Hat 43: i.TomTom, # High Floor Tom - 44: i.HiHatCymbal, # Pedal Hi-Hat + 44: i.HiHat, # Pedal Hi-Hat 45: i.TomTom, # Low Tom - 46: i.HiHatCymbal, # Open Hi-Hat + 46: i.HiHat, # Open Hi-Hat 47: i.TomTom, # Low-Mid Tom 48: i.TomTom, # Hi-Mid Tom 49: i.CrashCymbals, # Crash Cymbal 1 50: i.TomTom, # High Tom - # 51: i.Ride Cymbal 1, - # 52: i.Chinese Cymbal, - # 53: i.Ride Bell, + 51: i.RideCymbal, # Ride Cymbal 1 + 52: i.ChineseCymbal, + 53: i.RideCymbal, # Ride Bell 54: i.Tambourine, - # 55: i.Splash Cymbal, + 55: i.SplashCymbal, 56: i.Cowbell, 57: i.CrashCymbals, # Crash Cymbal 2 58: i.Vibraslap, - # 59: i.Ride Cymbal 2, + 59: i.RideCymbal, # Ride Cymbal 2 60: i.BongoDrums, # Hi Bongo 61: i.BongoDrums, # Low Bongo 62: i.CongaDrum, # Mute Hi Conga @@ -75,19 +73,26 @@ class PercussionMapper: 66: i.Timbales, # Low Timbale 67: i.Agogo, # High Agogo 68: i.Agogo, # Low Agogo - # 69: i.Cabasa, + 69: i.Cabasa, 70: i.Maracas, 71: i.Whistle, # Short Whistle 72: i.Whistle, # Long Whistle - # 73: i.Short Guiro, - # 74: i.Long Guiro, - # 75: i.Claves, + 73: i.Guiro, # Short Guiro + 74: i.Guiro, # Long Guiro + 75: i.Claves, 76: i.Woodblock, # Hi Wood Block 77: i.Woodblock, # Low Wood Block - # 78: i.Mute Cuica, - # 79: i.Open Cuica, + 78: i.Cuica, # Mute Cuica + 79: i.Cuica, # Open Cuica 80: i.Triangle, # Mute Triangle 81: i.Triangle, # Open Triangle + # GM Level 2 + 82: i.Shaker, + 83: i.SleighBells, # Jingle Bell + 84: i.BellTree, + 85: i.Castanets, + 86: i.Surdo, # Mute Surdo + 87: i.Surdo, # Open Surdo } # MIDI percussion mappings from https://midi.org/general-midi-level-1 download at @@ -95,7 +100,24 @@ class PercussionMapper: # formerly at: # https://www.midi.org/specifications/item/gm-level-1-sound-set - def midiPitchToInstrument(self, midiPitch: int|pitch.Pitch) -> instrument.Instrument: + # GM sounds that are not instruments get a name, not a class + namedSoundMapping = {27: 'High Q', + 28: 'Slap', + 29: 'Scratch Push', + 30: 'Scratch Pull', + 31: 'Sticks', + 32: 'Square Click', + 33: 'Metronome Click', + 34: 'Metronome Bell', + 39: 'Hand Clap', + } + + def midiPitchToInstrument( + self, + midiPitch: int|pitch.Pitch, + *, + raiseIfUnmapped: bool = True, + ) -> instrument.MidiPercussionMapMixin: ''' Takes a pitch.Pitch object or int ranging from 0-127 and returns the corresponding instrument in the GM Percussion Map. @@ -112,21 +134,13 @@ def midiPitchToInstrument(self, midiPitch: int|pitch.Pitch) -> instrument.Instru >>> moreCowbell - The standard GM Percussion list goes from 35 to 81; + The GM Percussion lists go from 27 to 87; pitches outside this range raise an exception. - >>> bassDrum1Pitch = pitch.Pitch('B-1') - >>> pm.midiPitchToInstrument(bassDrum1Pitch) + >>> lowPitch = pitch.Pitch('B-0') + >>> pm.midiPitchToInstrument(lowPitch) Traceback (most recent call last): - music21.midi.percussion.MIDIPercussionException: 34 does not map to a valid instrument! - - Also, certain GM instruments do not have corresponding music21 instruments, - so at present they also raise an exception. - - >>> cabasaPitch = 69 - >>> pm.midiPitchToInstrument(cabasaPitch) - Traceback (most recent call last): - music21.midi.percussion.MIDIPercussionException: 69 does not map to a valid instrument! + music21.midi.percussion.MIDIPercussionException: 22 does not map to a valid instrument! Some music21 Instruments have more than one MidiPitch. In this case you'll get the same Instrument object but with a different modifier @@ -134,37 +148,55 @@ def midiPitchToInstrument(self, midiPitch: int|pitch.Pitch) -> instrument.Instru >>> acousticBassDrumPitch = pitch.Pitch(35) >>> acousticBDInstrument = pm.midiPitchToInstrument(acousticBassDrumPitch) >>> acousticBDInstrument - + >>> acousticBDInstrument.modifier 'acoustic' >>> oneBassDrumPitch = pitch.Pitch(36) >>> oneBDInstrument = pm.midiPitchToInstrument(oneBassDrumPitch) >>> oneBDInstrument - + >>> oneBDInstrument.modifier - '1' + 'midi-1' + + Sounds in the map that are not instruments give an UnpitchedPercussion + object with a name: + + >>> clap = pm.midiPitchToInstrument(39) + >>> clap + + >>> clap.percMapPitch + 39 + + With `raiseIfUnmapped=False`, any other number gives an UnpitchedPercussion + object that keeps the number: + >>> other = pm.midiPitchToInstrument(22, raiseIfUnmapped=False) + >>> other + + >>> other.percMapPitch + 22 + + * Changed in v11: added GM Level 2, named sounds, and `raiseIfUnmapped`. + Modifiers '1' and '2' become 'midi-1' and 'midi-2'. ''' if isinstance(midiPitch, int): midiNumber = midiPitch else: midiNumber = midiPitch.midi - if midiNumber not in self.reverseInstrumentMapping: + inst: instrument.MidiPercussionMapMixin + if midiNumber in self.reverseInstrumentMapping: + inst = self.reverseInstrumentMapping[midiNumber]() + if midiNumber in inst._percMapPitchToModifier: + inst.modifier = inst._percMapPitchToModifier[midiNumber] + elif midiNumber in self.namedSoundMapping or not raiseIfUnmapped: + inst = instrument.UnpitchedPercussion() + inst.instrumentName = self.namedSoundMapping.get(midiNumber, inst.instrumentName) + else: raise MIDIPercussionException(f'{midiNumber!r} does not map to a valid instrument!') - midiInstrument = self.reverseInstrumentMapping[midiNumber] - - midiInstrumentObject = midiInstrument() - if (midiInstrumentObject.inGMPercMap is True - and hasattr(midiInstrumentObject, '_percMapPitchToModifier')): - midiUnpitchedPercussion = t.cast( - instrument.UnpitchedPercussion, midiInstrumentObject) - if midiNumber in midiUnpitchedPercussion._percMapPitchToModifier: - modifier = midiUnpitchedPercussion._percMapPitchToModifier[midiNumber] - midiUnpitchedPercussion.modifier = modifier - - return midiInstrumentObject + inst.percMapPitch = midiNumber + return inst def midiInstrumentToPitch(self, midiInstrument: instrument.Instrument) -> pitch.Pitch: ''' @@ -192,10 +224,10 @@ def midiInstrumentToPitch(self, midiInstrument: instrument.Instrument) -> pitch. music21.midi.percussion.MIDIPercussionException: is not in the GM Percussion Map! ''' - if not hasattr(midiInstrument, 'inGMPercMap') or midiInstrument.inGMPercMap is False: + if (not isinstance(midiInstrument, instrument.MidiPercussionMapMixin) + or midiInstrument.percMapPitch is None): raise MIDIPercussionException(f'{midiInstrument!r} is not in the GM Percussion Map!') - percInstrument = t.cast(instrument.Percussion, midiInstrument) - midiPitch = t.cast(int, percInstrument.percMapPitch) + midiPitch = midiInstrument.percMapPitch pitchObject = pitch.Pitch() pitchObject.midi = midiPitch return pitchObject @@ -209,6 +241,38 @@ def testCopyAndDeepcopy(self) -> None: from music21.test.commonTest import testCopyAll testCopyAll(self, globals()) + def testMidiPitchToInstrument(self) -> None: + pm = PercussionMapper() + for midiNumber, cls, modifier in [ + (27, instrument.UnpitchedPercussion, None), + (53, instrument.RideCymbal, 'bell'), + (57, instrument.CrashCymbals, 'midi-2'), + (68, instrument.Agogo, 'low'), + (69, instrument.Cabasa, None), + (72, instrument.Whistle, 'long'), + (74, instrument.Guiro, 'long'), + (78, instrument.Cuica, None), + (87, instrument.Surdo, None), + ]: + inst = pm.midiPitchToInstrument(midiNumber) + self.assertIs(type(inst), cls) + self.assertEqual(inst.modifier, modifier) + self.assertEqual(pm.midiInstrumentToPitch(inst).midi, midiNumber) + + cuica = pm.midiPitchToInstrument(78) + assert isinstance(cuica, instrument.Cuica) + self.assertTrue(cuica.muted) + + conga = pm.midiPitchToInstrument(62) + assert isinstance(conga, instrument.CongaDrum) + self.assertEqual(conga.modifier, 'high') + self.assertTrue(conga.muted) + self.assertEqual(pm.midiInstrumentToPitch(conga).midi, 62) + + bd = instrument.BassDrum() + bd.modifier = '1' + self.assertEqual(bd.modifier, 'midi-1') + # ------------------------------------------------------------------------------ # define presented order in documentation diff --git a/music21/midi/realtime.py b/music21/midi/realtime.py index d84247bf68..f9cf5b1799 100644 --- a/music21/midi/realtime.py +++ b/music21/midi/realtime.py @@ -17,7 +17,7 @@ https://stackoverflow.com/questions/10983462/how-can-i-produce-real-time-audio-output-from-music-made-with-music21 -Requires pygame 2 (or 1.9) install with: pip3 install pygame +Requires pygame 2 (or 1.9) install with: uv add pygame ''' from __future__ import annotations diff --git a/music21/midi/tests.py b/music21/midi/tests.py index 6a26f20548..33f3b4143c 100644 --- a/music21/midi/tests.py +++ b/music21/midi/tests.py @@ -32,6 +32,7 @@ midiEventToInstrument, midiEventsToNote, midiFileToStream, + midiTrackToStream, noteToMidiEvents, packetStorageFromSubstreamList, prepareStreamForMidi, @@ -508,7 +509,7 @@ def testChannelAllocation(self): instrument.Flute, instrument.Vibraphone, # not 10 instrument.BassDrum, # 10 - instrument.HiHatCymbal, # 10 + instrument.HiHat, # 10 ] iObjs = [] @@ -1548,6 +1549,60 @@ def testExportUnpitched(self): self.assertEqual(len(note_ons), 3) self.assertEqual([ev.pitch for ev in note_ons], [67, 60, 60]) + @staticmethod + def _unpitched(midiNumber): + u = note.Unpitched() + u.storedInstrument = instrument.UnpitchedPercussion() + u.storedInstrument.percMapPitch = midiNumber + return u + + def testUnmappedPercussionRoundTrip(self): + ''' + 22 is not in the GM map; 39 (Hand Clap) is, but has no class. + ''' + m = stream.Measure([ + instrument.UnpitchedPercussion(), + self._unpitched(22), + self._unpitched(39), + percussion.PercussionChord( + [self._unpitched(22), self._unpitched(36), self._unpitched(39)]), + ]) + b = converter.toData(m, fmt='midi') + s = converter.parseData(b, fmt='midi') + single22, single39, ch = s[note.NotRest] + self.assertEqual(single22.storedInstrument.percMapPitch, 22) + self.assertEqual(single22.storedInstrument.instrumentName, 'Percussion') + self.assertEqual(single39.storedInstrument.percMapPitch, 39) + self.assertEqual(single39.storedInstrument.instrumentName, 'Hand Clap') + self.assertEqual( + [u.storedInstrument.percMapPitch for u in ch.notes], [22, 36, 39]) + + tracks = streamHierarchyToMidiTracks(s) + note_ons = [ + ev for ev in tracks[1].events if ev.type is ChannelVoiceMessages.NOTE_ON] + self.assertEqual([ev.pitch for ev in note_ons], [22, 39, 22, 36, 39]) + + def testTambourineImportsHeadless(self): + b = converter.toData( + stream.Measure([instrument.UnpitchedPercussion(), self._unpitched(54)]), + fmt='midi') + s = converter.parseData(b, fmt='midi') + tamb = s[note.Unpitched].first().storedInstrument + self.assertIsInstance(tamb, instrument.Tambourine) + self.assertTrue(tamb.headless) + + def testWhistleRoundTrip(self): + b = converter.toData( + stream.Measure([instrument.UnpitchedPercussion(), self._unpitched(71)]), + fmt='midi') + s = converter.parseData(b, fmt='midi') + wh = s[note.Unpitched].first().storedInstrument + self.assertIsInstance(wh, instrument.Whistle) + tracks = streamHierarchyToMidiTracks(s) + note_ons = [ + ev for ev in tracks[1].events if ev.type is ChannelVoiceMessages.NOTE_ON] + self.assertEqual([ev.pitch for ev in note_ons], [71]) + def testMidiImportLyrics(self): lyricFactZh = ['明', '山', '涌', '水', '郁', '郁', '葱', '', '葱', '钟', '灵', '毓', '秀', '海', '天', '', '东', @@ -1638,6 +1693,50 @@ def testContainsWorksWithHexEnum(self): self.assertIn(c, ChannelVoiceMessages) self.assertTrue(c in ChannelVoiceMessages) + def testProgramChangeAfterTrackName(self): + ''' + A program change after the start of a track adds an instrument there + and keeps the instrument at the start. + + AI-assisted (Claude). + ''' + mt = MidiTrack(1) + + def addEvent(ticks: int, eventType, data=None, midiPitch=None): + mt.events.append(DeltaTime(mt, time=ticks, channel=1)) + me = MidiEvent(mt, type=eventType, channel=1) + if data is not None: + me.data = data + if midiPitch is not None: + me.pitch = midiPitch + me.velocity = 90 + mt.events.append(me) + + addEvent(0, MetaEvents.SEQUENCE_TRACK_NAME, data=b'Viola') + addEvent(0, ChannelVoiceMessages.PROGRAM_CHANGE, data=41) + addEvent(0, ChannelVoiceMessages.NOTE_ON, midiPitch=60) + addEvent(2048, ChannelVoiceMessages.NOTE_OFF, midiPitch=60) + addEvent(0, ChannelVoiceMessages.PROGRAM_CHANGE, data=40) + addEvent(0, ChannelVoiceMessages.NOTE_ON, midiPitch=62) + addEvent(2048, ChannelVoiceMessages.NOTE_OFF, midiPitch=62) + addEvent(0, MetaEvents.END_OF_TRACK, data=b'') + + p = midiTrackToStream(mt, ticksPerQuarter=1024) + self.assertEqual( + [(inst.classes[0], inst.partName, inst.getOffsetInHierarchy(p)) + for inst in p[instrument.Instrument]], + [('Viola', 'Viola', 0.0), ('Violin', None, 2.0)] + ) + + def testImportNoteTooLongForMeasures(self): + data = (b'MThd\x00\x00\x00\x06\x00\x00\x00\x01\x00\x01' + + b'MTrk\x00\x00\x00\x0f' + + b'\x00\x90\x3c\x40' + + b'\xff\xff\xff\x7f\x80\x3c\x00' + + b'\x00\xff\x2f\x00') + with self.assertRaisesRegex(stream.StreamException, 'more than 100000 measures'): + converter.parse(data, format='midi') + # ------------------------------------------------------------------------------ if __name__ == '__main__': diff --git a/music21/midi/translate.py b/music21/midi/translate.py index f39564330f..cd6ca0db51 100644 --- a/music21/midi/translate.py +++ b/music21/midi/translate.py @@ -5,7 +5,7 @@ # Authors: Christopher Ariza # Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2010-2024 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2010-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -42,7 +42,7 @@ MidiTrack, DeltaTime, MidiFile, MidiEvent, ChannelVoiceMessages, MetaEvents, putNumbersAsList, getNumber, putNumber, ) -from music21.midi.percussion import MIDIPercussionException, PercussionMapper +from music21.midi.percussion import PercussionMapper @@ -177,7 +177,6 @@ def ticksToDuration( '64th' >>> d2.components[2].dots 1 - ''' if inputM21DurationObject is None: d = duration.Duration() @@ -416,7 +415,7 @@ def midiEventsToNote( Access the `storedInstrument`: >>> unp.storedInstrument - + And with values that cannot be translated, a generic :class:`~music21.instrument.UnpitchedPercussion` instance is given: @@ -451,11 +450,7 @@ def midiEventsToNote( nr.pitch.midi = t.cast(int, midiOnEvent.pitch) elif isinstance(nr, note.Unpitched): onPitch = t.cast(int, midiOnEvent.pitch) - try: - i = PERCUSSION_MAPPER.midiPitchToInstrument(onPitch) - except MIDIPercussionException: - i = instrument.UnpitchedPercussion() - nr.storedInstrument = i + nr.storedInstrument = _midiNumberToPercussionInstrument(onPitch) # TODO: set reasonable displayPitch? else: raise TranslateException( @@ -638,7 +633,7 @@ def midiEventsToChord( >>> me2.channel = 10 >>> midi.translate.midiEventsToChord([tne1, tne2]) - + * Changed in v7: Uses the last DeltaTime in the list to get the end time. * Changed in v7.3: Returns a :class:`~music21.percussion.PercussionChord` if @@ -687,11 +682,7 @@ def midiEventsToChord( # Construct note.Unpitched objects for midi_pitch in pitches: unp = note.Unpitched() - try: - i = PERCUSSION_MAPPER.midiPitchToInstrument(midi_pitch) - except MIDIPercussionException: - i = instrument.UnpitchedPercussion() - unp.storedInstrument = i + unp.storedInstrument = _midiNumberToPercussionInstrument(midi_pitch.midi) c.add(unp) else: c.pitches = tuple(pitches) @@ -825,16 +816,26 @@ def chordToMidiEvents( return eventList +def _midiNumberToPercussionInstrument(midiNumber: int) -> instrument.MidiPercussionMapMixin: + ''' + Instrument for a channel-10 note; GM tambourines are taken to be headless. + ''' + i = PERCUSSION_MAPPER.midiPitchToInstrument(midiNumber, raiseIfUnmapped=False) + if isinstance(i, instrument.Tambourine): + i.headless = True + return i + + def _get_unpitched_pitch_value(unp: note.Unpitched) -> int: - unpitched_instrument: instrument.UnpitchedPercussion|None - if isinstance(unp.storedInstrument, instrument.UnpitchedPercussion): + unpitched_instrument: instrument.MidiPercussionMapMixin|None + if isinstance(unp.storedInstrument, instrument.MidiPercussionMapMixin): unpitched_instrument = unp.storedInstrument else: if unp._chordAttached is not None: unpitched_instrument = unp._chordAttached.getContextByClass( - instrument.UnpitchedPercussion) + instrument.MidiPercussionMapMixin) else: - unpitched_instrument = unp.getContextByClass(instrument.UnpitchedPercussion) + unpitched_instrument = unp.getContextByClass(instrument.MidiPercussionMapMixin) if unpitched_instrument is not None and unpitched_instrument.percMapPitch is not None: return unpitched_instrument.percMapPitch return 60 # e.g. lossy instrument recognition from musicxml @@ -984,7 +985,6 @@ def midiEventsToTimeSignature( >>> ts = midi.translate.midiEventsToTimeSignature(me2) >>> ts - ''' # http://www.sonicspot.com/guide/midifiles.html # The time signature defined with 4 bytes, a numerator, a denominator, diff --git a/music21/musedata/__init__.py b/music21/musedata/__init__.py index 941fc5e9c7..50fedf932f 100644 --- a/music21/musedata/__init__.py +++ b/music21/musedata/__init__.py @@ -5,7 +5,7 @@ # Authors: Christopher Ariza # Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2010, 2014 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2010, 2014, 2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -159,7 +159,6 @@ def isBack(self): def _getPitchParameters(self): ''' - >>> mdr = musedata.MuseDataRecord('Ef4 1 s d ==') >>> mdr.isNote() True @@ -397,7 +396,6 @@ def getBeams(self): >>> mdr = musedata.MuseDataRecord('E2 4 q u') >>> mdr.getBeams() is None True - ''' if self.stage == 1: return None @@ -458,7 +456,6 @@ def getArticulationObjects(self): >>> mdr = musedata.MuseDataRecord('C4 12 e u [ .p>') >>> mdr.getArticulationObjects() [, ] - ''' from music21 import articulations post = [] @@ -498,7 +495,6 @@ def getExpressionObjects(self): >>> mdr = musedata.MuseDataRecord('C4 12 e u [ .p>F') >>> mdr.getExpressionObjects() [] - ''' from music21 import expressions post = [] @@ -540,8 +536,8 @@ def getDynamicObjects(self): return post # find targets from largest to smallest targets = ('ppp', 'fff', - 'pp', 'ff', 'fp', 'mp', 'mf', - 'p', 'f', 'm', 'Z', 'Zp', 'R') + 'pp', 'ff', 'fp', 'mp', 'mf', + 'p', 'f', 'm', 'Z', 'Zp', 'R') for target in targets: pos = data.find(target) if pos < 0: @@ -574,7 +570,6 @@ def hasCautionaryAccidental(self): >>> mdr = musedata.MuseDataRecord('C4 12 e u [') >>> mdr.hasCautionaryAccidental() False - ''' data = self._getAdditionalNotations() if data is None: @@ -833,7 +828,6 @@ def _scrubStage1(self, src): def _getDigitsFollowingTag(self, line, tag): ''' - >>> mdp = musedata.MuseDataPart() >>> mdp._getDigitsFollowingTag('junk WK#:2345', 'WK#:') '2345' @@ -864,7 +858,6 @@ def _getDigitsFollowingTag(self, line, tag): def _getAlphasFollowingTag(self, line, tag, keepSpace=False, keepCase=False): ''' - >>> mdp = musedata.MuseDataPart() >>> mdp._getAlphasFollowingTag('Group memberships: sound, score', 'Group memberships:') 'sound,score' @@ -1386,7 +1379,6 @@ def getDivisionsPerQuarterNote(self): def _getMeasureBoundaryIndices(self, src=None): ''' - >>> mdp = musedata.MuseDataPart() >>> mdp.stage is None True @@ -1518,7 +1510,7 @@ def readstr(self, input_str): commentToggle = False lines = [] - srcLines = input_str.split('\n') + srcLines = input_str.replace('\r\n', '\n').split('\n') # lastLineIndex = len(srcLines) - 1 for i, line in enumerate(srcLines): # environLocal.printDebug(['reading line', i, line]) @@ -1773,6 +1765,18 @@ class Test(unittest.TestCase): # self.assertEqual(mdpObjs[0].getTimeSignatureParameters(), '3/4') # self.assertEqual(mdpObjs[0].getDivisionsPerQuarterNote(), 4) + def testReadCrlf(self): + fp = common.getSourceFilePath() / 'musedata' / 'testPrimitive' / 'test01' / '01.md' + text = fp.read_bytes().replace(b'\r\n', b'\n').decode('latin-1') + lfWork = MuseDataWork() + lfWork.addString(text) + crlfWork = MuseDataWork() + crlfWork.addString(text.replace('\n', '\r\n')) + crlfPart = crlfWork.getParts()[0] + self.assertEqual(crlfPart.src, lfWork.getParts()[0].src) + self.assertEqual(crlfPart.getWorkTitle(), 'Clarinet Quintet') + self.assertEqual(crlfPart.getMovementTitle(), 'Trio II') + def testLoadFromFile(self): fp = str(common.getSourceFilePath() / 'musedata' / 'testPrimitive') diff --git a/music21/musedata/base40.py b/music21/musedata/base40.py index 073537e524..3a4d6c5417 100644 --- a/music21/musedata/base40.py +++ b/music21/musedata/base40.py @@ -233,7 +233,7 @@ def base40ToPitch(base40Num: int) -> pitch.Pitch: ''' p = pitch.Pitch() p.octave = ((base40Num - 1) / 40) + 1 - tableNum = base40Num - 40 * (p.implicitOctave - 1) + tableNum = base40Num - 40 * (p.octave - 1) pitchName = base40Equivalent.get(tableNum, '') if pitchName: p.name = pitchName @@ -266,7 +266,7 @@ def pitchToBase40(pitchToConvert: str|pitch.Pitch) -> int: pitchObj = pitchToConvert if pitchObj.name in base40Representation: tableNum = base40Representation[pitchObj.name] - base40Num = (40 * (pitchObj.implicitOctave - 1)) + tableNum + base40Num = (40 * (pitchObj.octave - 1)) + tableNum return base40Num raise Base40Exception('Base40 cannot handle this pitch ' + pitchObj.nameWithOctave) diff --git a/music21/musedata/translate.py b/music21/musedata/translate.py index 58ded0dd53..ed517efb74 100644 --- a/music21/musedata/translate.py +++ b/music21/musedata/translate.py @@ -5,7 +5,7 @@ # Authors: Christopher Ariza # Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2010-2012 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2010-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -43,7 +43,6 @@ def _musedataBeamToBeams(beamSymbol): >>> translate._musedataBeamToBeams(r']/') # must escape backslash /> - ''' from music21 import beam @@ -166,8 +165,10 @@ def musedataPartToStreamPart(museDataPart, inputM21=None): s = inputM21 p = stream.Part() - p.id = museDataPart.getPartName() - p.partName = p.id + partName = museDataPart.getPartName() + if partName is not None: + p.id = partName + p.partName = partName # create and store objects mdmObjs = museDataPart.getMeasures() @@ -367,7 +368,7 @@ def testBasic(self): from music21 import common fp1 = (common.getSourceFilePath() - / 'musedata' / 'testPrimitive' / 'test01' / '01.md') + / 'musedata' / 'testPrimitive' / 'test01' / '01.md') mdw = musedata.MuseDataWork() mdw.addFile(fp1) @@ -391,6 +392,35 @@ def testBasic(self): # self.assertEqual(len(s.parts[0].recurse().notesAndRests), 291) # self.assertEqual(len(s.parts[1].recurse().notesAndRests), 293) + def testStage1PartName(self): + ''' + A stage-1 part has no name and writes to MusicXML. + + AI-assisted (Claude). + ''' + from music21 import musedata + from music21.musicxml import m21ToXml + + mdw = musedata.MuseDataWork() + mdw.addString('\n'.join([ + 'A Stage-One Work', + ' 12 3', + 'source one', + 'source two', + 'source three', + '1 1', + 'A 0 4 0 2 4', + '0 0 4', + 'measure 1', + 'C4 2', + 'E4 1', + 'G4 1', + '/END', + ])) + s = museDataWorkToStreamScore(mdw) + self.assertIsNone(s.parts[0].partName) + gex = m21ToXml.GeneralObjectExporter(s) + self.assertIn(b'', gex.parse()) # def testGetMetaData(self): diff --git a/music21/musicxml/archiveTools.py b/music21/musicxml/archiveTools.py index ccb9f07ed0..bbac112b01 100644 --- a/music21/musicxml/archiveTools.py +++ b/music21/musicxml/archiveTools.py @@ -121,7 +121,7 @@ def uncompressMXL(filename: str|pathlib.Path, fp: pathlib.Path = common.pathTools.cleanpath(filename, returnPathlib=True) if not silent: # pragma: no cover environLocal.warn(f'Updating file: {fp}') - extractPath = str(fp.parent) + extractPath = fp.parent unarchivedName = fp.with_suffix('.musicxml').name # Export container and original xml file to system as a compressed XML. with zipfile.ZipFile(filename, 'r', compression=zipfile.ZIP_DEFLATED) as myZip: @@ -139,9 +139,8 @@ def uncompressMXL(filename: str|pathlib.Path, myZip.extract(member=storedName, path=extractPath) if not found_one_file: # only rename one file: hope it is the right one. - extractPath_pathlib = pathlib.Path(extractPath) - wrongName = extractPath_pathlib / storedName - correctName = extractPath_pathlib / unarchivedName + wrongName = extractPath / storedName + correctName = extractPath / unarchivedName wrongName.rename(correctName) found_one_file = True diff --git a/music21/musicxml/helpers.py b/music21/musicxml/helpers.py index 804cb0c7c7..9722295cc6 100644 --- a/music21/musicxml/helpers.py +++ b/music21/musicxml/helpers.py @@ -151,6 +151,47 @@ def insertBeforeElements(root, insert, tagList=None): root.insert(min(insertIndices), insert) +def insertAfterElements(root, insert, tagList=None): + # noinspection PyShadowingNames + ''' + Insert element `insert` into element `root` directly after the last + instance of any child tag given in `tagList`. Insert at the beginning + if `tagList` is `None` or no instance is found. + + >>> from xml.etree.ElementTree import fromstring as EL + >>> from music21.musicxml.helpers import insertAfterElements, dump + >>> root = EL('1') + >>> insert = EL('') + + >>> insertAfterElements(root, insert, tagList=['grace']) + >>> dump(root) + + + + 1 + + + Insert at the beginning when nothing in `tagList` matches: + + >>> insert2 = EL('') + >>> insertAfterElements(root, insert2, tagList=['bar']) + >>> dump(root) + + + + + 1 + + ''' + insertIndex = 0 + if tagList: + # Iterate children only, not grandchildren + for i, child in enumerate(root.findall('*')): + if child.tag in tagList: + insertIndex = i + 1 + root.insert(insertIndex, insert) + + def measureNumberComesBefore(mNum1: str, mNum2: str) -> bool: ''' Determine whether `measureNumber1` strictly precedes diff --git a/music21/musicxml/m21ToXml.py b/music21/musicxml/m21ToXml.py index 5c525dacee..5fc349a3ea 100644 --- a/music21/musicxml/m21ToXml.py +++ b/music21/musicxml/m21ToXml.py @@ -6,7 +6,7 @@ # Christopher Ariza # Jacob Tyler Walls # -# Copyright: Copyright © 2010-22 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2010-26 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -218,7 +218,6 @@ def _setTagTextFromAttribute( >>> subEl = seta(acc, e, 'alter', transform=int) >>> subEl.text '-2' - ''' if attributeName is None: attributeName = common.hyphenToCamelCase(tag) @@ -242,6 +241,32 @@ def _setTagTextFromAttribute( return subElement +def moveOctaveIntoMusicxmlRange(octave: int, name: str) -> int: + ''' + Move an octave into MusicXML's 0-9 range, warning (with `name`) if it had to move. + + >>> musicxml.m21ToXml.moveOctaveIntoMusicxmlRange(4, 'C4') + 4 + >>> import warnings + >>> with warnings.catch_warnings(record=True) as w: + ... warnings.simplefilter('always') #_DOCS_HIDE + ... musicxml.m21ToXml.moveOctaveIntoMusicxmlRange(12, 'C12') + 9 + >>> w[0].message + MusicXMLWarning('C12 is out of MusicXML range; writing octave 9') + + * New in v11. + ''' + if 0 <= octave <= 9: + return octave + newOctave = min(max(octave, 0), 9) + warnings.warn( + MusicXMLWarning(f'{name} is out of MusicXML range; writing octave {newOctave}'), + stacklevel=3, + ) + return newOctave + + class GeneralObjectExporter: ''' Packs any `Music21Object` into a well-formed score and exports @@ -543,7 +568,6 @@ def fromDynamic(self, dynamicObject): ''' Provide a complete MusicXML string from a single dynamic by putting it into a Stream first. - ''' dCopy = copy.deepcopy(dynamicObject) out = stream.Stream() @@ -795,7 +819,7 @@ def setStyleAttributes(self, mxObject, m21Object, musicXMLNames, m21Names=None): ''' if isinstance(m21Object, style.Style): stObj = m21Object - elif m21Object.hasStyleInformation is False: + elif not m21Object.hasStyleInformation: return else: stObj = m21Object.style @@ -1000,7 +1024,7 @@ def setEditorial(self, mxObject, m21Object): hello ''' - if m21Object.hasEditorialInformation is False: + if not m21Object.hasEditorialInformation: return # MusicXML allows only one footnote or level, so we take the first. @@ -1233,7 +1257,6 @@ def staffLayoutToXmlStaffLayout(self, staffLayout, mxStaffLayoutIn=None): 40.0 - ''' if mxStaffLayoutIn is None: mxStaffLayout = Element('staff-layout') @@ -1494,7 +1517,6 @@ def scorePreliminaries(self) -> None: 0 >>> emptySX.spannerBundle - ''' self.setScoreLayouts() self.setMeterStream() @@ -2186,9 +2208,9 @@ def staffGroupToXmlPartGroup(self, staffGroup): self.setPosition(mxGroupSymbol, staffGroup) mxGroupBarline = SubElement(mxPartGroup, 'group-barline') - if staffGroup.barTogether is True: + if staffGroup.barTogether is True: # not a bool. mxGroupBarline.text = 'yes' - elif staffGroup.barTogether is False: + elif staffGroup.barTogether is False: # not a bool. mxGroupBarline.text = 'no' elif staffGroup.barTogether == 'Mensurstrich': mxGroupBarline.text = 'Mensurstrich' @@ -2268,7 +2290,7 @@ def setIdentification(self) -> Element: self.mxIdentification = mxId # creators - foundOne = False + foundOne: bool = False if self.scoreMetadata is not None: # We ignore the name ('namespace:name') here, and use # c.role instead so we can represent non-standard roles. @@ -2282,7 +2304,7 @@ def setIdentification(self) -> Element: mxId.append(mxCreator) foundOne = True - if foundOne is False and defaults.author: + if not foundOne and defaults.author: mxCreator = SubElement(mxId, 'creator') mxCreator.set('type', 'composer') mxCreator.text = defaults.author @@ -2482,7 +2504,6 @@ def getSupports(self) -> list[Element]: >>> SX.stream.definesExplicitPageBreaks = True >>> SX.dump(SX.getSupports()[-1]) - ''' def getSupport(element: str, supports_type: bool, attribute: str|None = None, value: str|None = None): @@ -3193,7 +3214,6 @@ def moveForward(self, byOffset: OffsetQL): >>> len(MEX.xmlRoot) 1 - ''' amountToMoveForward: int = int(round(byOffset * self.currentDivisions)) if amountToMoveForward: @@ -3417,12 +3437,12 @@ def parseOneElement( parsedObject = True break - if parsedObject is False: + if not parsedObject: for className in classes: if className in self.ignoreOnParseClasses: parsedObject = True break - if parsedObject is False: + if not parsedObject: environLocal.printDebug(['did not convert object', obj]) else: # appendSpanners == AppendSpanners.RELATED_ONLY @@ -3812,10 +3832,10 @@ def objectAttachedSpannersToNotations( isFirstOrLast = True # print('Trill is last') - if isFirstOrLast is False: + if not isFirstOrLast: continue # do not put a wavy-line tag on mid-trill notes ornaments.append(mxWavyLine) - if isFirstANDLast is True: + if isFirstANDLast: # make another one: mxWavyLine = Element('wavy-line') mxWavyLine.set('number', str(su.idLocal)) @@ -3935,7 +3955,9 @@ def appendArpeggioMarkSpannersToNotations( mxArpeggio = Element('arpeggiate') if ams.type != 'normal': mxArpeggio.set('direction', ams.type) - if mxArpeggio is not None and (len(ams) > 1 or (len(ams) == 1 and len(ams[0]) > 1)): + if mxArpeggio is not None and ( + len(ams) > 1 + or (isinstance(ams[0], chord.ChordBase) and len(ams[0]) > 1)): # There is more than one GeneralNote in the arpeggio, so we must # add a number attribute that will be the same for all GeneralNotes # in this spanner. In MusicXML this number must be between @@ -4034,7 +4056,7 @@ def noteToXml(self, n: note.GeneralNote, noteIndexInChord=0, chordParent=None): TODO: Test with spanners ''' - addChordTag = (noteIndexInChord != 0) + addChordTag: bool = (noteIndexInChord != 0) setb = setAttributeFromAttribute mxNote = Element('note') @@ -4106,7 +4128,7 @@ def noteToXml(self, n: note.GeneralNote, noteIndexInChord=0, chordParent=None): elif n.isRest: SubElement(mxNote, 'rest') - if d.isGrace is not True: + if not d.isGrace: mxDuration = self.durationXml(d) mxNote.append(mxDuration) # divisions only @@ -4168,7 +4190,7 @@ def noteToXml(self, n: note.GeneralNote, noteIndexInChord=0, chordParent=None): stemDirection = None # if we are not in a chord, or we are the first note of a chord, get stem # direction from the chordOrNote object - if (addChordTag is False + if (not addChordTag and isinstance(chordOrN, note.NotRest) and chordOrN.stemDirection != 'unspecified'): chordOrN = t.cast(note.NotRest, chordOrN) @@ -4211,7 +4233,7 @@ def noteToXml(self, n: note.GeneralNote, noteIndexInChord=0, chordParent=None): # TODO: notehead-text # beam - if addChordTag is False: + if not addChordTag: if isinstance(chordOrN, note.NotRest) and chordOrN.beams is not None: nBeamsList = self.beamsToXml(chordOrN.beams) for mxB in nBeamsList: @@ -4220,7 +4242,7 @@ def noteToXml(self, n: note.GeneralNote, noteIndexInChord=0, chordParent=None): mxNotationsList = self.noteToNotations(n, noteIndexInChord, chordParent) # add tuplets if it's a note or the first of a chord. - if addChordTag is False: + if not addChordTag: for i, tup in enumerate(d.tuplets): tupTagList = self.tupletToXmlTuplet(tup, i + 1) mxNotationsList.extend(tupTagList) @@ -4231,7 +4253,7 @@ def noteToXml(self, n: note.GeneralNote, noteIndexInChord=0, chordParent=None): mxNotations.append(mxN) # lyric - if addChordTag is False: + if not addChordTag: for lyricObj in chordOrN.lyrics: if lyricObj.text is None: continue # happens sometimes! @@ -4398,7 +4420,8 @@ def restToXml(self, r: note.Rest): tempPitch = pitch.Pitch() tempPitch.diatonicNoteNum = restObjectPseudoDNN mxDisplayStep.text = tempPitch.step - mxDisplayOctave.text = str(tempPitch.octave) + mxDisplayOctave.text = str( + moveOctaveIntoMusicxmlRange(tempPitch.octave, f'{r} display')) return mxNote @@ -4540,7 +4563,7 @@ def chordToXml(self, c: chord.ChordBase) -> list[Element]: ''' mxNoteList = [] if isinstance(c, chord.Chord): - c.sortAscending() + c.sortAscending(inPlace=True) for i, n in enumerate(c): if 'Unpitched' in n.classSet: @@ -4586,7 +4609,9 @@ def pitchToXml(self, p: pitch.Pitch): if p.accidental is not None: mxAlter = SubElement(mxPitch, 'alter') mxAlter.text = str(common.numToIntOrFloat(p.accidental.alter)) - _setTagTextFromAttribute(p, mxPitch, 'octave', 'implicitOctave') + _setTagTextFromAttribute( + p, mxPitch, 'octave', + transform=lambda o: moveOctaveIntoMusicxmlRange(o, p.nameWithOctave)) return mxPitch def unpitchedToXml(self, @@ -4622,14 +4647,39 @@ def unpitchedToXml(self, quarter + + must come before , even when there is + no , as on a grace note in a voice: + + >>> MEX.currentVoiceId = 1 + >>> mxUnpitched = MEX.unpitchedToXml(graceUp) + >>> MEX.dump(mxUnpitched) + + + + D + 5 + + 1 + quarter + ''' mxNote = self.noteToXml(up, noteIndexInChord=noteIndexInChord, chordParent=chordParent) mxUnpitched = Element('unpitched') _setTagTextFromAttribute(up, mxUnpitched, 'display-step') - _setTagTextFromAttribute(up, mxUnpitched, 'display-octave') - - helpers.insertBeforeElements(mxNote, mxUnpitched, tagList=['duration', 'type']) + _setTagTextFromAttribute( + up, mxUnpitched, 'display-octave', + transform=lambda o: moveOctaveIntoMusicxmlRange(o, up.displayName)) + + # within , only , , and may precede + # , so insert after those rather than trying to list + # everything that can follow it (#1732) + helpers.insertAfterElements( + mxNote, + mxUnpitched, + tagList=['grace', 'cue', 'chord'] + ) return mxNote @@ -4762,7 +4812,6 @@ def tupletToTimeModification(self, tup) -> Element: - ''' mxTimeModification = Element('time-modification') _setTagTextFromAttribute(tup, mxTimeModification, 'actual-notes', 'numberNotesActual') @@ -4792,7 +4841,7 @@ def dealWithNotehead( Returns nothing. The mxNote is modified in place. ''' - foundANotehead = False + foundANotehead: bool = False if (isinstance(n, note.NotRest) and (n.notehead != 'normal' or n.noteheadParenthesis @@ -4802,7 +4851,7 @@ def dealWithNotehead( foundANotehead = True mxNotehead = self.noteheadToXml(n) mxNote.append(mxNotehead) - if foundANotehead is False and chordParent is not None: + if not foundANotehead and chordParent is not None: if (hasattr(chordParent, 'notehead') and (chordParent.notehead != 'normal' or chordParent.noteheadParenthesis @@ -5714,7 +5763,7 @@ def noChordToXml(self, cs: harmony.NoChord) -> Element: quarter ''' - if cs.writeAsChord is True: + if cs.writeAsChord: r = note.Rest(duration=cs.duration) return self.restToXml(r) @@ -5913,7 +5962,7 @@ def chordSymbolToXml( >>> cs = harmony.ChordSymbol() >>> cs.root('E-') >>> cs.bass('B-', allow_add=True) - >>> cs.inversion(2, transposeOnSet=False) + >>> cs.setInversion(2, transpose=False, inPlace=True) >>> cs.chordKind = 'major' >>> cs.chordKindStr = 'M' >>> cs @@ -6212,7 +6261,6 @@ def dynamicToXml(self, d: dynamics.Dynamic) -> Element: 10080 - ''' mxDynamics = Element('dynamics') synchronizeIds(mxDynamics, d) @@ -6272,7 +6320,6 @@ def segnoToXml(self, segno: repeat.Segno) -> Element: - ''' mxSegno = Element('segno') synchronizeIds(mxSegno, segno) @@ -6425,7 +6472,6 @@ def tempoIndicationToXml(self, ti: tempo.TempoIndication) -> Element: Andante - ''' # if writing just a sound tag, place an empty words tag in a # direction type and then follow with sound declaration @@ -7317,7 +7363,7 @@ def timeSignatureToXml(self, ts: meter.TimeSignature|meter.SenzaMisuraTimeSignat self.setPrintObject(mxTime, ts) return mxTime - def keySignatureToXml(self, keyOrKeySignature): + def keySignatureToXml(self, keyOrKeySignature: key.KeySignature) -> Element: # noinspection PyShadowingNames ''' returns a key tag from a music21 @@ -7342,7 +7388,8 @@ def keySignatureToXml(self, keyOrKeySignature): major - >>> ksNonTrad = key.KeySignature(sharps=None) + >>> ksNonTrad = key.KeySignature() + >>> ksNonTrad.isNonTraditional = True >>> ksNonTrad.alteredPitches = ['C#', 'E-4'] >>> ksNonTrad @@ -7383,7 +7430,7 @@ def keySignatureToXml(self, keyOrKeySignature): # TODO: key-accidental for i, p in enumerate(keyOrKeySignature.alteredPitches): - if p.octave is not None: + if not p.octaveIsImplicit: mxKeyOctave = SubElement(mxKey, 'key-octave') mxKeyOctave.text = str(p.octave) mxKeyOctave.set('number', str(i + 1)) @@ -7492,7 +7539,6 @@ def intervalToXmlTranspose(self, i=None): -2 -1 - ''' # TODO: number attribute (staff number) # TODO: double empty attribute diff --git a/music21/musicxml/partStaffExporter.py b/music21/musicxml/partStaffExporter.py index b266576db9..9f90d2e5d6 100644 --- a/music21/musicxml/partStaffExporter.py +++ b/music21/musicxml/partStaffExporter.py @@ -5,7 +5,7 @@ # Authors: Jacob Tyler Walls # Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2020-22 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2020-26 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -85,8 +85,8 @@ def addStaffTags( mxStaff = Element('staff') mxStaff.text = str(staffNumber) helpers.insertBeforeElements(tag, mxStaff, - tagList=['beam', 'notations', 'lyric', 'play', - 'sound']) + tagList=['beam', 'notations', 'lyric', 'play', + 'sound']) class PartStaffExporterMixin: @@ -413,21 +413,7 @@ def makeDivider(inner_sourceNumber: int|str) -> Element[t.Any]: targetNumber = targetMeasure.get('number') sourceNumber = sourceMeasure.get('number') - # 99% of the time we expect identical sets of measure numbers - # So walking through each should yield the same numbers, whether ints or strings - if targetNumber == sourceNumber: - # No gaps found: move all contents - self.moveMeasureContents(sourceMeasure, targetMeasure, staffNum) - sourceMeasure = None - continue - - # Or, gap in measure numbers in the subsequent part: keep iterating through target - if (sourceNumber is not None - and targetNumber is not None - and helpers.measureNumberComesBefore(targetNumber, sourceNumber)): - continue # sourceMeasure is not None! - - # Or, gap in measure numbers in target: record necessary insertions until gap is closed + # Gap in measure numbers in target: record necessary insertions until gap is closed while (sourceNumber is not None and targetNumber is not None and helpers.measureNumberComesBefore(sourceNumber, targetNumber)): @@ -439,6 +425,21 @@ def makeDivider(inner_sourceNumber: int|str) -> Element[t.Any]: sourceMeasure = next(sourceMeasures) except StopIteration: return insertions + sourceNumber = sourceMeasure.get('number') + + # 99% of the time we expect identical sets of measure numbers + # So walking through each should yield the same numbers, whether ints or strings + if targetNumber == sourceNumber: + self.moveMeasureContents(sourceMeasure, targetMeasure, staffNum) + sourceMeasure = None + continue + + # Or, gap in measure numbers in the subsequent part: keep iterating through target + if (sourceNumber is not None + and targetNumber is not None + and helpers.measureNumberComesBefore(targetNumber, sourceNumber)): + continue # sourceMeasure is not None! + raise MusicXMLExportException( 'joinPartStaffs() was unable to order the measures ' f'{targetNumber}, {sourceNumber}') # pragma: no cover @@ -587,7 +588,7 @@ def isMultiAttribute(m21Class: type[M21ObjType], mxAttributes, mxStaves, tagList=['part-symbol', 'instruments', 'clef', 'staff-details', - 'transpose', 'directive', 'measure-style'] + 'transpose', 'directive', 'measure-style'] ) if multiKey and mxAttributes is not None: @@ -1182,6 +1183,78 @@ def testForwardRepeatMarks(self): ['forward', 'backward'] ) + def testJoinPartStaffsMeasureMissingFromFirstStaff(self): + ''' + A measure missing from the first PartStaff holds only the second staff; + the measures after it join both staves. + + AI-assisted (Claude). + ''' + from music21 import layout + from music21 import note + + both = ['1', '2'] + for firstNumbers, staffNumbers in ( + ([1, 2, 4, 5], [both, both, ['2'], both, both]), + ([2, 3], [['2'], both, both]), + ([1, 3, 5], [both, ['2'], both, ['2'], both]), + ): + with self.subTest(firstNumbers=firstNumbers): + ps1 = stream.PartStaff() + ps2 = stream.PartStaff() + secondNumbers = range(1, len(staffNumbers) + 1) + for ps, numbers in ((ps1, firstNumbers), (ps2, secondNumbers)): + for number in numbers: + ps.append(stream.Measure([note.Note(type='whole')], number=number)) + sg = layout.StaffGroup([ps1, ps2]) + s = stream.Score([sg, ps1, ps2]) + + root = self.getET(s) + measures = root.findall('part/measure') + self.assertEqual([m.get('number') for m in measures], + [str(number) for number in secondNumbers]) + self.assertEqual( + [[n.find('staff').text for n in m.findall('note')] for m in measures], + staffNumbers + ) + + def testJoinPartStaffsMeasureMissingFromSecondStaff(self): + ''' + A measure missing from the second PartStaff holds only the first staff; + every other measure joins both staves. + + AI-assisted (Claude). + ''' + from music21 import layout + from music21 import note + + both = ['1', '2'] + for secondNumbers, staffNumbers in ( + ([1, 2, 4, 5], [both, both, ['1'], both, both]), + ([2, 3], [['1'], both, both]), + ([1, 2], [both, both, ['1']]), + ([1, 3, 5], [both, ['1'], both, ['1'], both]), + ): + with self.subTest(secondNumbers=secondNumbers): + ps1 = stream.PartStaff() + ps2 = stream.PartStaff() + firstNumbers = range(1, len(staffNumbers) + 1) + for ps, numbers in ((ps1, firstNumbers), (ps2, secondNumbers)): + for number in numbers: + ps.insert((number - 1) * 4, + stream.Measure([note.Note(type='whole')], number=number)) + sg = layout.StaffGroup([ps1, ps2]) + s = stream.Score([sg, ps1, ps2]) + + root = self.getET(s) + measures = root.findall('part/measure') + self.assertEqual([m.get('number') for m in measures], + [str(number) for number in firstNumbers]) + self.assertEqual( + [[n.find('staff').text for n in m.findall('note')] for m in measures], + staffNumbers + ) + if __name__ == '__main__': import music21 diff --git a/music21/musicxml/test_m21ToXml.py b/music21/musicxml/test_m21ToXml.py index 5e0e47fc41..e74fec0ed1 100644 --- a/music21/musicxml/test_m21ToXml.py +++ b/music21/musicxml/test_m21ToXml.py @@ -12,6 +12,7 @@ from music21 import articulations from music21 import chord +from music21 import clef from music21 import common from music21 import converter from music21 import corpus @@ -24,6 +25,7 @@ from music21 import layout from music21 import meter from music21 import note +from music21 import pitch from music21 import repeat from music21 import spanner from music21 import stream @@ -33,7 +35,7 @@ from music21.musicxml import helpers from music21.musicxml import testPrimitive from music21.musicxml.m21ToXml import ( - GeneralObjectExporter, ScoreExporter, + GeneralObjectExporter, MeasureExporter, ScoreExporter, MusicXMLWarning, MusicXMLExportException ) from music21.musicxml.xmlToM21 import MeasureParser @@ -229,6 +231,36 @@ def testLowVoiceNumbers(self): xmlOut = self.getXml(m) self.assertIn('hello', xmlOut) + def testOctaveOutOfRange(self): + MEX = MeasureExporter() + with self.assertWarnsRegex(MusicXMLWarning, 'C-1'): + mxPitch = MEX.pitchToXml(pitch.Pitch('C', octave=-1)) + self.assertEqual(mxPitch.findtext('octave'), '0') + with self.assertWarnsRegex(MusicXMLWarning, 'G#12'): + mxPitch = MEX.pitchToXml(pitch.Pitch('G#', octave=12)) + self.assertEqual(mxPitch.findtext('octave'), '9') + + up = note.Unpitched() + up.displayOctave = -2 + with self.assertWarns(MusicXMLWarning): + mxNote = MEX.unpitchedToXml(up) + self.assertEqual(mxNote.findtext('unpitched/display-octave'), '0') + + def testUnpitchedGraceInVoice(self): + ''' + must precede even on a grace note, which has + no element to insert before. + ''' + # https://github.com/cuthbertLab/music21/issues/1732 + grace = note.Unpitched(duration=duration.GraceDuration()) + v1 = stream.Voice([grace]) + v2 = stream.Voice([note.Note()]) + m = stream.Measure([v1, v2]) + + tree = self.getET(m) + mxNote = tree.find('part/measure/note') + self.assertEqual([el.tag for el in mxNote], ['grace', 'unpitched', 'voice', 'type']) + def testVoiceNumberOffsetsThreeStaffsInGroup(self): n1_1 = note.Note() v1_1 = stream.Voice([n1_1]) @@ -989,6 +1021,92 @@ def testArpeggioMarkSpannersNonArpeggiate(self): arpNum = arp.get('number') self.assertEqual(arpNum, '1') + def testChordWrittenLowestToHighest(self): + c1 = chord.Chord(['G4', 'E4', 'C4']) + c1.expressions.append(expressions.ArpeggioMark('non-arpeggio')) + c2 = chord.Chord(['A4', 'F4', 'D4']) + m = stream.Measure([c1, c2]) + m.insert(0, spanner.Slur([c1, c2])) + x = self.getET(stream.Score([stream.Part([m])])) + + mxNotes = x.findall('part/measure/note') + self.assertEqual([mxNote.find('pitch/step').text for mxNote in mxNotes], + ['C', 'E', 'G', 'D', 'F', 'A']) + self.assertEqual(mxNotes[0].find('notations/non-arpeggiate').get('type'), 'bottom') + self.assertEqual(mxNotes[2].find('notations/non-arpeggiate').get('type'), 'top') + self.assertEqual(mxNotes[0].find('notations/slur').get('type'), 'start') + self.assertEqual(mxNotes[3].find('notations/slur').get('type'), 'stop') + + def testChordFingeringsFollowTheirNotes(self): + ''' + A chord whose notes a file gives out of order keeps each fingering + on its note when written again. + + AI-assisted (Claude). + ''' + def mxNote(step, finger, inChord): + chordTag = '' if inChord else '' + return (f'{chordTag}{step}4' + '1quarter' + f'{finger}') + + xml = ('' + 'P' + '1' + + mxNote('E', 3, False) + mxNote('C', 1, True) + mxNote('G', 5, True) + + '') + s = converter.parse(xml, format='musicxml') + x = self.getET(s) + mxNotes = x.findall('part/measure/note') + self.assertEqual([(n.find('pitch/step').text, + n.find('notations/technical/fingering').text) + for n in mxNotes], + [('C', '1'), ('E', '3'), ('G', '5')]) + + c = chord.Chord(['G4', 'E4', 'C4']) + c.articulations = [articulations.Fingering(1), + articulations.Accent(), + articulations.Fingering(3)] + x = self.getET(stream.Score([stream.Part([stream.Measure([c])])])) + mxNotes = x.findall('part/measure/note') + self.assertEqual([(n.find('pitch/step').text, + n.findtext('notations/technical/fingering')) + for n in mxNotes], + [('C', '1'), ('E', '3'), ('G', None)]) + self.assertIsNotNone(mxNotes[0].find('notations/articulations/accent')) + + def testArpeggioMarkSpannersOnNotes(self): + # one note alone: no number + n1 = note.Note('C4') + am1 = expressions.ArpeggioMarkSpanner([n1]) + # a note and a chord together: shared number + n2 = note.Note('D4') + c3 = chord.Chord(['E4', 'G4']) + am2 = expressions.ArpeggioMarkSpanner([n2, c3]) + m = stream.Measure([n1, n2, c3]) + s = stream.Score([am1, am2, stream.Part([m])]) + + x = self.getET(s) + mxNotes = x.find('part').find('measure').findall('note') + arpNumbers = [mxNote.find('notations').find('arpeggiate').get('number') + for mxNote in mxNotes] + self.assertEqual(arpNumbers, [None, '1', '1', '1']) + + def testArpeggioOnSingleNoteRoundTrip(self): + xmlIn = ''' + + + + 1 + C44 + whole + +''' + s = converter.parse(xmlIn, format='musicxml') + x = self.getET(s) + mxArpeggios = x.findall('.//arpeggiate') + self.assertEqual(len(mxArpeggios), 1) + self.assertIsNone(mxArpeggios[0].get('number')) def testExportChordSymbolsWithRealizedDurations(self): @@ -1105,6 +1223,37 @@ def test_stem_style_without_direction(self): xmlOut = GeneralObjectExporter().parse(one_note_tune).decode('utf-8') self.assertIn('up', xmlOut) + def testRestStepShiftInOctaveClef(self): + for clefClass, displayStep, displayOctave in ( + (clef.Treble8vbClef, 'D', '4'), + (clef.Treble8vaClef, 'D', '6'), + (clef.Bass8vbClef, 'F', '2'), + (clef.Bass8vaClef, 'F', '4'), + ): + m = stream.Measure() + m.append(clefClass()) + r = note.Rest() + r.stepShift = 2 + m.append(r) + tree = self.getET(m) + self.assertEqual(tree.find('.//display-step').text, displayStep) + self.assertEqual(tree.find('.//display-octave').text, displayOctave) + + def testNoteTooLongForMeasures(self): + xmlString = ''' + + x + + 1 + + C499999999 + +''' + s = converter.parse(xmlString, format='musicxml') + gex = GeneralObjectExporter(s) + with self.assertRaisesRegex(stream.StreamException, 'more than 100000 measures'): + gex.parse() + class TestExternal(unittest.TestCase): show = True diff --git a/music21/musicxml/test_xmlToM21.py b/music21/musicxml/test_xmlToM21.py index b9e5047fb0..c9f7a365cb 100644 --- a/music21/musicxml/test_xmlToM21.py +++ b/music21/musicxml/test_xmlToM21.py @@ -8,6 +8,7 @@ from music21 import articulations from music21 import bar from music21 import chord +from music21 import clef from music21 import common from music21 import defaults from music21 import duration @@ -510,6 +511,14 @@ def testInstrumentTranspositionA(self): self.assertEqual(str(i1.transposition), '') self.assertEqual(str(i2.transposition), '') + def testTransposeOctaveChangeWithoutDiatonic(self): + # https://github.com/cuthbertLab/music21/issues/2082 + MP = MeasureParser() + mxTranspose = self.EL('0' + '-1') + self.assertEqual(str(MP.xmlTransposeToInterval(mxTranspose)), + '') + def testInstrumentTranspositionB(self): from music21.musicxml import testPrimitive from music21 import converter @@ -813,7 +822,8 @@ def testBarException(self): def testChordSymbolException(self): MP = MeasureParser() mxHarmony = self.EL('A' - 'add') + '' + 'add') with self.assertRaisesRegex(MusicXMLImportException, 'degree-value missing'): MP.xmlToChordSymbol(mxHarmony) @@ -1391,6 +1401,72 @@ def testArpeggioMarkSpanners(self) -> None: for spanned, ch in zip(sp, chords): self.assertIs(spanned, ch) + @staticmethod + def arpeggiatedChordsXml(partIds: list[str]) -> str: + ''' + One part per id, each with two measures holding one arpeggiated chord, + every with number="1", as MuseScore writes them. + + AI-assisted (Claude). + ''' + def mxChord(steps: tuple[str, str]) -> str: + return ''.join( + f'{"" if i else ""}' + f'{step}44' + '' + for i, step in enumerate(steps) + ) + + mxParts = ''.join( + f'' + f'1' + f'{mxChord(("C", "E"))}' + f'{mxChord(("D", "F"))}' + '' + for partId in partIds + ) + mxScoreParts = ''.join( + f'' for partId in partIds + ) + return (f'{mxScoreParts}' + f'{mxParts}') + + def testArpeggioMarkSpannersSameNumberDifferentOffsets(self) -> None: + ''' + The same number at two offsets makes two arpeggios, not one. + + AI-assisted (Claude). + ''' + from music21 import converter + + s = t.cast(stream.Score, + converter.parse(self.arpeggiatedChordsXml(['P1']), format='musicxml')) + spanners = s.spannerBundle.getByClass(expressions.ArpeggioMarkSpanner) + chords = s.parts[0][chord.Chord] + self.assertEqual( + [[id(spanned) for spanned in sp] for sp in spanners], + [[id(ch)] for ch in chords], + ) + + def testArpeggioMarkSpannersSameNumberDifferentParts(self) -> None: + ''' + The same number at the same offset in two parts makes two arpeggios: + number only tells apart arpeggios within a part, where they may + cross staves (see testArpeggioMarkSpanners). + + AI-assisted (Claude). + ''' + from music21 import converter + + s = t.cast(stream.Score, + converter.parse(self.arpeggiatedChordsXml(['P1', 'P2']), format='musicxml')) + spanners = s.spannerBundle.getByClass(expressions.ArpeggioMarkSpanner) + chords = [ch for p in s.parts for ch in p[chord.Chord]] + self.assertEqual( + [[id(spanned) for spanned in sp] for sp in spanners], + [[id(ch)] for ch in chords], + ) + def testHiddenRests(self): from music21 import converter from music21 import corpus @@ -1530,6 +1606,25 @@ def testCompositeLyrics(self): self.assertEqual(ly3.components[2].syllabic, 'end') self.assertEqual(len(s.lyrics(recurse=True)[1][0]), 4) + def testLyricPrintObject(self): + # print-object="no" was read as hideObjectOnPrint = False, and "yes" as True + from music21 import converter + from music21.musicxml import m21ToXml + + MP = MeasureParser() + hiddenLyric = MP.xmlToLyric(self.EL('la')) + self.assertIs(hiddenLyric.style.hideObjectOnPrint, True) + shownLyric = MP.xmlToLyric(self.EL('la')) + self.assertIs(shownLyric.style.hideObjectOnPrint, False) + + n = note.Note('D4') + n.lyric = 'la' + n.lyrics[0].style.hideObjectOnPrint = True + GEX = m21ToXml.GeneralObjectExporter() + xmlOut = GEX.parse(n).decode('utf-8') + roundTrip = converter.parse(xmlOut, format='musicxml') + self.assertIs(roundTrip.flatten().notes[0].lyrics[0].style.hideObjectOnPrint, True) + def testDirectionPosition(self): from music21 import converter from music21 import corpus @@ -1548,7 +1643,7 @@ def testDirectionPosition(self): # TextExpression s = converter.parse(testPrimitive.textExpressions) positionedEls = [el for el in s.recurse() if el.hasStyleInformation - and el.style.relativeX is not None] + and el.style.relativeX is not None] self.assertEqual(len(positionedEls), 3) self.assertEqual( list(set(type(el) for el in positionedEls)), @@ -1558,7 +1653,7 @@ def testDirectionPosition(self): # Wedge s = corpus.parse('beach') positionedEls = [el for el in s.recurse() if el.hasStyleInformation - and el.style.relativeX is not None] + and el.style.relativeX is not None] self.assertEqual(len(positionedEls), 40) self.assertEqual( sorted(set(type(el) for el in positionedEls), key=repr), @@ -1617,7 +1712,7 @@ def testClearingTuplets(self): d = duration.Duration(2 / 3) self.assertEqual(len(d.tuplets), 1) mxNoteNoType = EL('D6' - '3') + '3') MP.xmlToDuration(mxNoteNoType, inputM21=d) self.assertEqual(len(d.tuplets), 0) self.assertEqual(d.linked, True) @@ -1643,17 +1738,17 @@ def testImportUnpitchedPercussion(self): self.assertIsInstance(tmb, instrument.Tambourine) self.assertEqual(tmb.percMapPitch, 54) # 1-indexed - # An instrument music21 doesn't have yet (Cabasa): - scorePart = scorePart.replace('Tambourine', 'Cabasa') - scorePart = scorePart.replace('Tamb.', 'Cab.') - scorePart = scorePart.replace('55', '70') # 1-indexed + # A number outside the GM percussion map: + scorePart = scorePart.replace('Tambourine', 'Hi-Hat Rim') + scorePart = scorePart.replace('Tamb.', 'HH Rim') + scorePart = scorePart.replace('55', '23') # 1-indexed pp = PartParser() mxScorePart = EL(scorePart) - msg = '69 does not map to a valid instrument!' + msg = '22 does not map to a valid instrument!' with self.assertWarnsRegex(MusicXMLWarning, msg): unp = pp.getDefaultInstrument(mxScorePart) self.assertIsInstance(unp, instrument.UnpitchedPercussion) - self.assertEqual(unp.percMapPitch, 69) + self.assertEqual(unp.percMapPitch, 22) def testImportImplicitMeasureNumber(self): from music21 import converter @@ -1738,6 +1833,56 @@ def testPianoStaffWithRepeatEndings(self): p_notes2 = [n.name for n in p_expanded[note.Note]] self.assertEqual(p_notes2, ['G', 'A', 'G', 'B']) + def testSegnoAndCodaInBarline(self): + ''' + MusicXML allows and inside a , not only in a + . They should be imported, so that a later D.S. can find the segno. + ''' + from music21 import converter + + def measure(number, step, before='', after=''): + return (f'{before}{step}' + f'44' + f'whole{after}') + + def score(*measures): + return ('' + 'Music' + + ''.join(measures) + '') + + attributes = '1' + ds = 'D.S.' + s = converter.parse(score(measure(1, 'C', attributes), + measure(2, 'D', ''), + measure(3, 'E', after=ds)), + format='musicxml') + segnos = s.parts.first().measure(2)[repeat.Segno] + self.assertEqual(len(segnos), 1) + self.assertEqual(segnos.first().offset, 0.0) + expanded = s.expandRepeats() + self.assertEqual([n.name for n in expanded[note.Note]], ['C', 'D', 'E', 'D', 'E']) + + s = converter.parse(score(measure(1, 'C', attributes), + measure(2, 'D', '')), + format='musicxml') + codas = s.parts.first().measure(2)[repeat.Coda] + self.assertEqual(len(codas), 1) + self.assertEqual(codas.first().offset, 0.0) + + def testRestOnMiddleLineOfOctaveClef(self): + MP = MeasureParser() + for clefObj, displayStep, displayOctave in ( + (clef.Treble8vbClef(), 'B', 3), + (clef.Treble8vaClef(), 'B', 5), + (clef.Bass8vbClef(), 'D', 2), + (clef.Bass8vaClef(), 'D', 4), + ): + MP.lastClefs[1] = clefObj + mxRest = self.EL(f'{displayStep}' + f'{displayOctave}' + '5eighth') + self.assertEqual(MP.xmlToRest(mxRest).stepShift, 0) + if __name__ == '__main__': import music21 diff --git a/music21/musicxml/xmlObjects.py b/music21/musicxml/xmlObjects.py index 2342e50d8b..7c8babf783 100644 --- a/music21/musicxml/xmlObjects.py +++ b/music21/musicxml/xmlObjects.py @@ -154,7 +154,6 @@ def booleanToYesNo(value): >>> musicxml.xmlObjects.booleanToYesNo(5) 'yes' - ''' if value: # purposely not "is True" return 'yes' @@ -173,7 +172,6 @@ def fractionToPercent(value): >>> musicxml.xmlObjects.fractionToPercent(0.251) '25' - ''' return str(int(value * 100)) @@ -205,7 +203,6 @@ def isValidXSDID(text): >>> musicxml.xmlObjects.isValidXSDID(12345) False - ''' if not isinstance(text, str): return False diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index fdc7b0a222..ee6b09d16b 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -6,7 +6,7 @@ # Christopher Ariza # Jacob Tyler Walls # -# Copyright: Copyright © 2009-2024 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2009-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ from __future__ import annotations @@ -301,6 +301,9 @@ def setStyleAttributes(self, mxObject, m21Object, musicXMLNames, m21Names=None): >>> m21Obj.style.hideObjectOnPrint True ''' + if not mxObject.attrib: + return + if isinstance(m21Object, style.Style): stObj = m21Object else: @@ -899,7 +902,6 @@ def parsePartList(self, mxScore): entries into self.mxScorePartDict[partId] and adds them to any open entries, stored as PartGroup objects in self.partGroupList - ''' mxPartList = mxScore.find('part-list') if mxPartList is None: @@ -1484,12 +1486,15 @@ def __init__(self, self.staffReferenceList: list[StaffReferenceType] = [] self.lastTimeSignature: meter.TimeSignature|None = None - self.lastMeasureWasShort = False - self.lastMeasureOffset = 0.0 + self.lastMeasureWasShort: bool = False + self.lastMeasureOffset: OffsetQL = 0.0 # a dict of clefs per staff number -- needed for converting rests w/ steps self.lastClefs: dict[int, clef.Clef|None] = {} self.activeTuplets: list[duration.Tuplet|None] = [None] * 7 + # keyed by number and offset: number only tells apart simultaneous + # arpeggios, which may cross staves but not parts + self.arpeggioSpanners: dict[tuple[str, OffsetQL], expressions.ArpeggioMarkSpanner] = {} self.maxStaves = 1 # will be changed in measure parsing @@ -1716,7 +1721,7 @@ def _adjustMidiData(mc): try: i = pm.midiPitchToInstrument(_adjustMidiData(midiUnpitchedText)) except MIDIPercussionException as mpe: - # objects not yet existing in m21 such as Cabasa + # numbers outside the GM percussion map warnings.warn(MusicXMLWarning(mpe), stacklevel=2) i = instrument.UnpitchedPercussion() i.percMapPitch = _adjustMidiData(midiUnpitchedText) @@ -2246,8 +2251,8 @@ def adjustTimeAttributesFromMeasure(self, m: stream.Measure): # If the measure is overfull by a "round" amount, assume that it was intended # otherwise it was likely the result of malformed MusicXML. if (diff > 0.5 - or nearestMultiple(diff, 0.0625)[1] < tol - or nearestMultiple(diff, 1 / 12)[1] < tol): + or nearestMultiple(diff, 0.0625)[1] < tol + or nearestMultiple(diff, 1 / 12)[1] < tol): mOffsetShift = mHighestTime else: mOffsetShift = lastTimeSignatureQuarterLength @@ -2282,7 +2287,7 @@ def adjustTimeAttributesFromMeasure(self, m: stream.Measure): mOffsetShift = mHighestTime else: mOffsetShift = mHighestTime # lastTimeSignatureQuarterLength - if self.lastMeasureWasShort is True: + if self.lastMeasureWasShort: if m.barDurationProportion() < 1.0: m.padAsAnacrusis() # probably a pickup after a repeat or phrase boundary # or something @@ -2336,7 +2341,6 @@ def applyMultiMeasureRest(self, r: note.Rest): >>> pp.applyMultiMeasureRest(r3) >>> pp.stream.show('text') {0.0} - ''' if self.activeMultiMeasureRestSpanner is None: return @@ -3875,15 +3879,15 @@ def flatten(mx, name): arpeggio = expressions.ArpeggioMark(arpeggioType) n.expressions.append(arpeggio) else: - sb = self.spannerBundle.getByClassIdLocalComplete( - expressions.ArpeggioMarkSpanner, idFound, False) - if sb: - # if we already have a spanner matching - arpeggioSpanner = t.cast(expressions.ArpeggioMarkSpanner, sb[0]) - else: + arpeggioSpanners = self.parent.arpeggioSpanners + offset = opFrac(self.parent.lastMeasureOffset + self.offsetMeasureNote) + numberAndOffset = (idFound, offset) + arpeggioSpanner = arpeggioSpanners.get(numberAndOffset) + if arpeggioSpanner is None: arpeggioSpanner = expressions.ArpeggioMarkSpanner(arpeggioType=arpeggioType) arpeggioSpanner.idLocal = idFound self.spannerBundle.append(arpeggioSpanner) + arpeggioSpanners[numberAndOffset] = arpeggioSpanner arpeggioSpanner.addSpannedElements(n) mostRecentOrnament: expressions.Ornament|None = None @@ -5047,9 +5051,8 @@ def xmlToLyric(self, mxLyric, inputM21=None) -> note.Lyric|None: if identifier is not None: ly.identifier = identifier - self.setStyleAttributes(mxLyric, ly, - ('justify', 'placement', 'print-object'), - ('justify', 'placement', 'hideObjectOnPrint')) + self.setStyleAttributes(mxLyric, ly, ('justify', 'placement')) + self.setPrintObject(mxLyric, ly) self.setColor(mxLyric, ly) self.setPosition(mxLyric, ly) @@ -5096,8 +5099,8 @@ def findM21VoiceFromXmlVoice( useVoice = self.lastVoice if useVoice is None: # pragma: no cover warnings.warn('Cannot put in an element with a missing voice tag when ' - + 'no previous voice tag was given. Assuming voice 1... ', - MusicXMLWarning, stacklevel=2) + + 'no previous voice tag was given. Assuming voice 1... ', + MusicXMLWarning, stacklevel=2) useVoice = 1 thisVoice: stream.Voice|None = None @@ -5161,10 +5164,16 @@ def xmlBarline(self, mxBarline: ET.Element) -> None: rb.addSpannedElements(m) if mxEndingObj.get('type') == 'start': - mxNumber = mxEndingObj.get('number') + mxNumberStr = mxEndingObj.get('number') + if mxNumberStr is None: + warnings.warn(f'number is required on object, {mxEndingObj}', + MusicXMLWarning, + stacklevel=2) + mxNumberStr = '1' + # RepeatBracket handles comma-separated values, such as "1,2" try: - rb.number = mxNumber + rb.number = mxNumberStr except spanner.SpannerException: rb.number = 1 @@ -5185,6 +5194,16 @@ def xmlBarline(self, mxBarline: ET.Element) -> None: # set number; '' or None is interpreted as 1 + # a barline may also carry a segno or coda (MusicXML allows both inside ); + # put them in the measure where the barline is, as a direction would + for tag, markClass in (('segno', repeat.Segno), ('coda', repeat.Coda)): + mxMark = mxBarline.find(tag) + if mxMark is not None: + rm = markClass() + synchronizeIds(mxMark, rm) + self.setPosition(mxMark, rm) + self.insertCoreAndRef(self.offsetMeasureNote, None, rm) + if barline.location == 'left': # environLocal.printDebug(['setting left barline', barline]) m.leftBarline = barline @@ -5897,7 +5916,8 @@ def xmlTransposeToInterval(self, mxTranspose): mxOctaveChange = mxTranspose.find('octave-change') if mxOctaveChange is not None: octaveChange = int(mxOctaveChange.text) * 12 - diatonicStep += 7 * int(mxOctaveChange.text) + if diatonicStep is not None: + diatonicStep += 7 * int(mxOctaveChange.text) # TODO: presently not dealing with # doubled one octave down from what is currently written @@ -6250,7 +6270,7 @@ def mxKeyOctaves(self, mxKey, ks): ks.alteredPitches = alteredPitches - def nonTraditionalKeySignature(self, mxKey): + def nonTraditionalKeySignature(self, mxKey: ET.Element) -> key.KeySignature: # noinspection PyShadowingNames ''' Returns a KeySignature object that represents a nonTraditional Key Signature @@ -6279,17 +6299,17 @@ def nonTraditionalKeySignature(self, mxKey): children = list(mxKey) lastTag = None - steps = [] - alters = [] - accidentals = [] + steps: list[str] = [] + alters: list[float] = [] + accidentals: list[str|None] = [] for c in children: tag = c.tag if lastTag == 'key-alter' and tag == 'key-step': accidentals.append(None) - if tag == 'key-step': + if tag == 'key-step' and c.text: steps.append(c.text) - elif tag == 'key-alter': + elif tag == 'key-alter' and c.text: alters.append(float(c.text)) elif tag == 'key-accidental': accidentals.append(c.text) @@ -6301,9 +6321,10 @@ def nonTraditionalKeySignature(self, mxKey): raise MusicXMLImportException( 'For non traditional signatures each step must have an alter') - ks = key.KeySignature(sharps=None) + ks = key.KeySignature() + ks.isNonTraditional = True - alteredPitches = [] + alteredPitches: list[pitch.Pitch] = [] for step, alter, accidental in zip(steps, alters, accidentals): p = pitch.Pitch(step) if accidental is not None: diff --git a/music21/note.py b/music21/note.py index 7a2ac2d9fb..2086113043 100644 --- a/music21/note.py +++ b/music21/note.py @@ -5,7 +5,7 @@ # Authors: Michael Scott Asato Cuthbert # Christopher Ariza # -# Copyright: Copyright © 2006-2024 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2006-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -542,13 +542,13 @@ def setTextAndSyllabic(self, rawText: str, applyRaw: bool = False) -> None: rawText = str(rawText) # check for hyphens - if applyRaw is False and rawText.startswith('-') and not rawText.endswith('-'): + if not applyRaw and rawText.startswith('-') and not rawText.endswith('-'): self.text = rawText[1:] self.syllabic = 'end' - elif applyRaw is False and not rawText.startswith('-') and rawText.endswith('-'): + elif not applyRaw and not rawText.startswith('-') and rawText.endswith('-'): self.text = rawText[:-1] self.syllabic = 'begin' - elif applyRaw is False and rawText.startswith('-') and rawText.endswith('-'): + elif not applyRaw and rawText.startswith('-') and rawText.endswith('-'): self.text = rawText[1:-1] self.syllabic = 'middle' else: # assume single @@ -616,20 +616,12 @@ def __init__(self, ): if duration is None: # ensure music21base does not automatically create a zero duration before we can. - if not keywords or ('type' not in keywords and 'quarterLength' not in keywords): + if keywords.get('type') is None and keywords.get('quarterLength') is None: tempDuration = Duration(1.0) else: tempDuration = Duration(**keywords) - if 'quarterLength' in keywords: - del keywords['quarterLength'] - if 'type' in keywords: - del keywords['type'] - # only apply default if components are empty - # looking at currentComponents so as not to trigger - # _updateComponents - if (tempDuration.quarterLength == 0 - and not tempDuration.currentComponents()): - tempDuration.quarterLength = 1.0 + keywords.pop('quarterLength', None) + keywords.pop('type', None) else: tempDuration = duration # this sets the stored duration defined in Music21Object @@ -823,7 +815,7 @@ def addLyric(self, thisLyric.text = text foundLyric = True break - if foundLyric is False: + if not foundLyric: self.lyrics.append(Lyric(text, lyricNumber, applyRaw=applyRaw, identifier=lyricIdentifier)) @@ -1327,7 +1319,7 @@ def storedInstrument(self) -> instrument.Instrument|None: >>> snare = note.Unpitched() >>> snare.storedInstrument = instrument.SnareDrum() >>> snare.storedInstrument - + >>> snare ''' @@ -1446,10 +1438,10 @@ class Note(NotRest): >>> n = note.Note('B-') >>> n.name 'B-' - >>> n.octave is None - True - >>> n.pitch.implicitOctave + >>> n.octave 4 + >>> n.pitch.octaveIsImplicit + True >>> n = note.Note(name='D#') >>> n.name @@ -1657,15 +1649,17 @@ def step(self, value: StepName): self.pitch.step = value @property - def octave(self) -> int|None: + def octave(self) -> int: ''' Return or set the octave value from the :class:`~music21.pitch.Pitch` object. See :attr:`~music21.pitch.Pitch.octave`. + + * Changed in v11: always an int; see :attr:`~music21.pitch.Pitch.octaveIsImplicit`. ''' return self.pitch.octave @octave.setter - def octave(self, value: int|None): + def octave(self, value: int|float|None): self.pitch.octave = value @property @@ -1761,7 +1755,6 @@ def transpose(self, value, *, inPlace=False): {0.0} {1.0} {1.0} - ''' from music21 import key if isinstance(value, interval.IntervalBase): @@ -1849,7 +1842,7 @@ class Unpitched(NotRest): >>> unp.displayStep = 'G' >>> unp.pitch Traceback (most recent call last): - AttributeError: 'Unpitched' object has no attribute 'pitch'. Did you mean: 'pitches'? + AttributeError: 'Unpitched' object has no attribute 'pitch'. Did you mean...'...pitches'... Unpitched elements generally have an instrument object associated with them: @@ -1872,6 +1865,7 @@ class Unpitched(NotRest): >>> unp == unp2 False ''' + # When 3.15 is the minimum change ... to "Did you mean '.pitches' instead of '.pitch'?" equalityAttributes = ('displayStep', 'displayOctave', 'storedInstrument') def __init__( @@ -1887,7 +1881,7 @@ def __init__( if displayName: display_pitch = Pitch(displayName) self.displayStep = display_pitch.step - self.displayOctave = display_pitch.implicitOctave + self.displayOctave = display_pitch.octave def _reprInternal(self): if not self.storedInstrument: @@ -2077,14 +2071,12 @@ def testSingle(self): ''' Need to test direct note creation w/o stream ''' - from music21 import note - a = note.Note('D-3') + a = Note('D-3') a.quarterLength = 2.25 if self.show: a.show() def testBasic(self): - from music21 import note from music21 import stream a = stream.Stream() @@ -2093,7 +2085,7 @@ def testBasic(self): ('d-3', 2.5), ('c#6', 3.25), ('a--5', 0.5), ('f#2', 1.75), ('g-3', (4 / 3)), ('d#6', (2 / 3)) ]: - b = note.Note() + b = Note() b.quarterLength = qLen b.name = pitchName # Pylint going crazy here diff --git a/music21/noteworthy/binaryTranslate.py b/music21/noteworthy/binaryTranslate.py index f9a292cedd..dd0a7dd99d 100644 --- a/music21/noteworthy/binaryTranslate.py +++ b/music21/noteworthy/binaryTranslate.py @@ -4,7 +4,7 @@ # # Authors: Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2006-2013 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2006-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -186,13 +186,13 @@ {36.0} {0.0} {3.0} - ''' from __future__ import annotations import pathlib import struct from typing import TypedDict +import unittest from music21 import environment from music21 import exceptions21 @@ -1340,9 +1340,22 @@ def dump(inner_self): ] +class Test(unittest.TestCase): + + def testClefNames(self): + for clefType, clefName in ((3, 'Tenor'), (4, 'Percussion')): + nwcc = NWCConverter() + nwcc.version = 201 + # object type 0 (clef), visibility, clef type, no octave shift + nwcc.fileContents = struct.pack(' {0.0} {0.0} - ''' # Main for pi in dataList: @@ -243,7 +242,6 @@ def setDurationForObject(self, generalNote, durationInfo): >>> nwt.setDurationForObject(n, 'Half') >>> n.duration - ''' parts = durationInfo.split(',') lengthNote = parts[0] @@ -467,7 +465,6 @@ def translateNote(self, attributes): >>> nwt.translateNote({'Dur': 'Half', 'Pos': '-3'}) >>> measure[1] - ''' durationInfo = attributes['Dur'] pitchInfo = attributes['Pos'] @@ -522,7 +519,6 @@ def translateChord(self, attributes): >>> nwt.translateChord({'Dur': ['Half'], 'Pos': ['1,3,5']}) >>> measure[1] - ''' durationInfos = attributes['Dur'] pitchInfos = attributes['Pos'] @@ -610,7 +606,6 @@ def translateRest(self, attributes): {0.0} {2.0} {2.75} - ''' durationInfo = attributes['Dur'] @@ -649,7 +644,6 @@ def createClef(self, attributes): Traceback (most recent call last): music21.noteworthy.translate.NoteworthyTranslateException: Did not find a proper clef in type, OrangeClef - ''' currentClef = None if 'OctaveShift' in attributes: @@ -778,7 +772,6 @@ def createBarlines(self, attributes): >>> nwt.currentMeasure.leftBarline - ''' self.activeAccidentals = {} diff --git a/music21/omr/correctors.py b/music21/omr/correctors.py index bbfed53183..3e3b9fc2c1 100644 --- a/music21/omr/correctors.py +++ b/music21/omr/correctors.py @@ -101,7 +101,6 @@ def getAllHashes(self): def getSinglePart(self, pn): ''' returns a NEW SinglePart object for part number pn from the score - ''' return SinglePart(self.score.parts[pn], pn) @@ -167,7 +166,6 @@ def verticalProbabilityDist(self): ''' Uses a score and returns an array of probabilities. For n in the array, n is the probability that the nth part - ''' if self.distributionArray is not None: return self.distributionArray @@ -464,7 +462,6 @@ def getIncorrectMeasureIndices(self, runFast=False): >>> p[1].insert(0, meter.TimeSignature('3/8')) >>> sp.getIncorrectMeasureIndices(runFast=False) [] - ''' from music21 import meter self.incorrectMeasures = [] @@ -555,7 +552,6 @@ def runHorizontalSearch(self, i): ''' Returns an array of the indices of the minimum distance measures given a measure (with index i) to compare to. - ''' unused_probabilityDistribution = self.horizontalProbabilityDist() incorrectMeasures = self.incorrectMeasures @@ -804,7 +800,6 @@ def hashNote(self, n): def hashGrace(self, n): ''' Gives a Grace Note a duration of a 128th note - ''' graceNoteDuration = self.hashQuarterLength(0.015625) byteEncoding = chr(graceNoteDuration) @@ -886,7 +881,6 @@ def getMeasureDifference(self, hashString): >>> hasher.setSequenceMatcher() >>> hasher.getMeasureDifference('VFUF') 1.0 - ''' self.sequenceMatcher.set_seq2(hashString) @@ -1114,7 +1108,6 @@ def getProbabilityOnSubstitute(self, source, destination): Take minimum length. Compare index to index. Any additional letters in the flagged measure get graded as additions. Any additional letters in the comparison measure get graded as omissions. - ''' ls = len(source) ld = len(destination) diff --git a/music21/omr/evaluators.py b/music21/omr/evaluators.py index 32841f35e3..bb8a02cd5c 100644 --- a/music21/omr/evaluators.py +++ b/music21/omr/evaluators.py @@ -28,7 +28,6 @@ class OmrGroundTruthPair: (or a pair of music21.stream.Score objects). See below for examples. - ''' def __init__(self, omr=None, ground=None): @@ -178,7 +177,7 @@ def minEditDist(self, target, source): distance[i][j] = min(distance[i - 1][j] + 1, distance[i][j - 1] + 1, distance[i - 1][j - 1] - + self.substCost(source[j - 1], target[i - 1])) + + self.substCost(source[j - 1], target[i - 1])) return distance[n][m] def getDifferences(self): @@ -335,7 +334,6 @@ def autoCorrelationBestMeasure(inputScore): (18, 6) >>> print( float(totalUnflaggedWithMatches) / totalUnflagged ) 0.333... - ''' ss = correctors.ScoreCorrector(inputScore) allHashes = ss.getAllHashes() diff --git a/music21/pitch.py b/music21/pitch.py index 2a3c63efe7..96a906242b 100644 --- a/music21/pitch.py +++ b/music21/pitch.py @@ -5,7 +5,7 @@ # Authors: Michael Scott Asato Cuthbert # Christopher Ariza # -# Copyright: Copyright © 2008-2019 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2008-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -29,7 +29,6 @@ from music21 import common from music21.common.objects import SlottedObjectMixin from music21.common.types import StepName -from music21 import defaults from music21 import environment from music21 import exceptions21 from music21 import interval @@ -64,6 +63,20 @@ 9: 'A', 11: 'B', } +pitchClassToDefaultStepAlter: tuple[tuple[StepName, int], ...] = ( + ('C', 0), # 0 + ('C', 1), # 1 + ('D', 0), # 2 + ('E', -1), # 3 + ('E', 0), # 4 + ('F', 0), # 5 + ('F', 1), # 6 + ('G', 0), # 7 + ('G', 1), # 8 + ('A', 0), # 9 + ('B', -1), # 10 + ('B', 0), # 11 +) STEPNAMES: set[StepName] = {'C', 'D', 'E', 'F', 'G', 'A', 'B'} # set STEP_TO_DNN_OFFSET: dict[StepName, int] = { 'C': 0, @@ -88,6 +101,8 @@ MICROTONE_OPEN = '(' MICROTONE_CLOSE = ')' + +# do not change this -- consider it fixed. accidentalNameToModifier = { 'natural': '', 'sharp': '#', @@ -98,9 +113,13 @@ 'double-flat': '--', 'triple-flat': '---', 'quadruple-flat': '----', - 'half-sharp': '~', + 'half-sharp': '~', # Might remove soon; use 'half-sharp' + # Might remove soon; use 'one-and-a-half-sharp' + # or 'three-quarter-sharp' 'one-and-a-half-sharp': '#~', - 'half-flat': '`', + 'half-flat': '`', # might remove soon; use 'half-flat' + # Might remove soon; use 'one-and-a-half-flat' + # or 'three-quarter-flat' 'one-and-a-half-flat': '-`', } @@ -114,11 +133,11 @@ ('#', '\u266f'), ('~', chr(0x1d132)), # 1D132 ('----', chr(0x1d12b) + chr(0x1d12b)), - ('---', '\u266D'), + ('---', '\u266D' + chr(0x1d12b)), ('--', chr(0x1d12b)), - ('-`', '\u266D' + chr(0x1d132)), + ('-`', '\u266D' + chr(0x1d133)), # 1D133 ('-', '\u266D'), - ('`', chr(0x1d132)), # 1D132; raised flat: 1D12C + ('`', chr(0x1d133)), # 1D133; raised flat: 1D12C ('', '\u266e'), # natural ]) @@ -150,6 +169,45 @@ } +def _makeAccidentalLookupTable() -> dict[str|float, tuple[str, float, str]]: + ''' + Build accidentalLookupTable from accidentalNameToModifier and + alternateNameToAccidentalName. + ''' + # AI-assisted (Claude) + nameToAlter = { + 'natural': 0.0, + 'sharp': 1.0, + 'double-sharp': 2.0, + 'triple-sharp': 3.0, + 'quadruple-sharp': 4.0, + 'flat': -1.0, + 'double-flat': -2.0, + 'triple-flat': -3.0, + 'quadruple-flat': -4.0, + 'half-sharp': 0.5, + 'one-and-a-half-sharp': 1.5, + 'half-flat': -0.5, + 'one-and-a-half-flat': -1.5, + } + table: dict[str|float, tuple[str, float, str]] = {} + for name, alter in nameToAlter.items(): + modifier = accidentalNameToModifier[name] + table[name] = table[alter] = (name, alter, modifier) + # not natural's '': set('') is an error, and .modifier = '' leaves the name alone + if modifier: + table[modifier] = table[name] + for alternateName, name in alternateNameToAccidentalName.items(): + table[alternateName] = table[name] + return table + + +# every lowercase name, modifier, alternate name, and alter that Accidental() and +# Accidental.set() accept, mapped to (name, alter, modifier), e.g. "eses" or -2 to +# ("double-flat", -2.0, "--") +accidentalLookupTable = _makeAccidentalLookupTable() + + def isValidAccidentalName(name: str) -> bool: ''' Check if name is a valid accidental name string that can @@ -301,9 +359,9 @@ def _convertPsToOct(ps: int|float) -> int: def _convertPsToStep( ps: int|float ) -> tuple[StepName, - Accidental, - Microtone, - int]: + Accidental, + Microtone|None, + int]: ''' Utility conversion; does not process internal representations. @@ -312,29 +370,31 @@ def _convertPsToStep( Returns a tuple of Step, an Accidental object, a Microtone object or None, and an int representing octave shift (which is nearly always zero, but can be 1 - for a B with very high microtones). + for a B with very high microtones). Whole numbers get None for the Microtone. >>> pitch._convertPsToStep(60) - ('C', , , 0) + ('C', , None, 0) + >>> pitch._convertPsToStep(60.0) + ('C', , None, 0) >>> pitch._convertPsToStep(66) - ('F', , , 0) + ('F', , None, 0) >>> pitch._convertPsToStep(67) - ('G', , , 0) + ('G', , None, 0) >>> pitch._convertPsToStep(68) - ('G', , , 0) + ('G', , None, 0) >>> pitch._convertPsToStep(-2) - ('B', , , 0) + ('B', , None, 0) >>> pitch._convertPsToStep(60.5) ('C', , , 0) >>> pitch._convertPsToStep(62) - ('D', , , 0) + ('D', , None, 0) >>> pitch._convertPsToStep(62.5) ('D', , , 0) >>> pitch._convertPsToStep(135) - ('E', , , 0) + ('E', , None, 0) >>> pitch._convertPsToStep(70) - ('B', , , 0) + ('B', , None, 0) >>> pitch._convertPsToStep(70.2) ('B', , , 0) >>> pitch._convertPsToStep(70.5) @@ -363,15 +423,17 @@ def _convertPsToStep( >>> pitch._convertPsToStep(59.9999999) ('C', , , 0) + + * Changed in v11: whole numbers return None instead of a Microtone of 0 cents. ''' - if isinstance(ps, int): - pc = ps % 12 - alter = 0.0 - micro = 0.0 - elif ps == int(ps): - pc = int(ps) % 12 - alter = 0.0 - micro = 0.0 + # TODO: make _convertPsToStep return an alter not an accidental, and then change + # the alter of the Pitch's existing accidental (setting displayStatus to None) + # to preserve style and displayType information + wholePs = int(ps) + if wholePs == ps: + # if this changes, update Pitch.__init__ too + step, wholeAlter = pitchClassToDefaultStepAlter[wholePs % 12] + return step, Accidental(wholeAlter), None, 0 else: # rounding here is essential ps = round(ps, PITCH_SPACE_SIG_DIGITS) @@ -539,6 +601,7 @@ def _convertHarmonicToCents(value: int|float) -> int: def _dissonanceScore(pitches: list[Pitch], + *, smallPythagoreanRatio: bool = True, accidentalPenalty: bool = True, triadAward: bool = True) -> float: @@ -549,7 +612,10 @@ def _dissonanceScore(pitches: list[Pitch], Pythagorean ratios of its constituent intervals are small (`smallPythagoreanRatio`), (2) it shows few double- or triple-accidentals (`accidentalPenalty`), and (3) it shows thirds that can form some triad (`triadAward`). + + * Changed in v11: all options except pitches are keyword only. ''' + # TODO: test other options. score_accidentals = 0.0 score_ratio = 0.0 score_triad = 0.0 @@ -569,7 +635,7 @@ def _dissonanceScore(pitches: list[Pitch], intervals = [] for p1, p2 in itertools.combinations(pitches, 2): p2 = copy.deepcopy(p2) - p2.octave = None + p2.octaveIsImplicit = True this_interval = interval.Interval(noteStart=p1, noteEnd=p2) intervals.append(this_interval) except interval.IntervalException: @@ -579,6 +645,10 @@ def _dissonanceScore(pitches: list[Pitch], for this_interval in intervals: # does not accept weird intervals, e.g. with semitones ratio = interval.intervalToPythagoreanRatio(this_interval) + # The constant 0.075853.... is 1 / ln(3**12), where 3**12 is 531441, the numerator + # of 531441/524288 -- the Pythagorean comma: it normalizes the penalty so a + # diminished second (the Pythagorean comma; slightly more than a unison), + # scores exactly 1.0, and all other penalties are based on that. penalty = math.log(ratio.denominator) * 0.07585326888 score_ratio += penalty @@ -626,6 +696,7 @@ def _greedyEnharmonicsSearch( def simplifyMultipleEnharmonics( pitches: t.Iterable[Pitch|str|int|float], + *, criterion: t.Callable[[list[Pitch]], float] = _dissonanceScore, keyContext: key.KeySignature|None = None ) -> list[Pitch]: @@ -675,13 +746,15 @@ def simplifyMultipleEnharmonics( ... keyContext=key.Key('C')) [, , ] - * Changed in v7.3: fixed a bug with compound intervals (such as formed against - the tonic of a KeySignature defaulting to octave 4): + Former bug: simplifying enharmonics in a key context will not remove octaves. >>> pitch.simplifyMultipleEnharmonics([pitch.Pitch('B5')], keyContext=key.Key('D')) [] - ''' + * Changed in v7.3: fixed a bug with compound intervals (such as those formed against + the tonic of a KeySignature defaulting to octave 4). + * Changed in v11: crtierion and keyContext are keyword only. + ''' oldPitches = [p if isinstance(p, Pitch) else Pitch(p) for p in pitches] if keyContext: @@ -774,7 +847,6 @@ class Microtone(prebase.ProtoM21Object, SlottedObjectMixin): >>> m.alter 0.3333... - ''' # CLASS VARIABLES # @@ -1002,11 +1074,18 @@ def __init__(self, specifier: int|str|float = 'natural') -> None: # store a reference to the Pitch that has this Accidental object as a property self._client: Pitch|None = None - self._name = '' - self._modifier = '' - self._alter = 0.0 # semitones to alter step - # potentially can be a fraction, but not exponent - self.set(specifier) + try: + standard = accidentalLookupTable.get(specifier) + except TypeError: # unhashable + standard = None + if standard is None: + self._name = '' + self._modifier = '' + self._alter = 0.0 + self.set(specifier) + else: + # alter: semitones to alter step; potentially a fraction, but not exponent + self._name, self._alter, self._modifier = standard # SPECIAL METHODS # def _hashValues(self) -> tuple[t.Any, ...]: @@ -1029,6 +1108,10 @@ def __deepcopy__(self, memo: dict[int, t.Any]) -> Accidental: new = Accidental.__new__(Accidental) for s in self._getSlotsRecursive(): setattr(new, s, getattr(self, s)) + if self._style is not None: + new._style = copy.deepcopy(self._style, memo) + if self._editorial is not None: + new._editorial = copy.deepcopy(self._editorial, memo) return new else: # pragma: no cover return common.defaultDeepcopy(self, memo) @@ -1061,7 +1144,6 @@ def __ge__(self, other: object) -> bool: >>> b = pitch.Accidental('flat') >>> a >= b True - ''' return self.__gt__(other) or self.__eq__(other) @@ -1209,80 +1291,29 @@ def set(self, name: str|int|float, *, allowNonStandardValue: bool = False) -> No This is the argument that .name and .alter use to allow non-standard names + `Accidental()` calls `set()` only for values not in `accidentalLookupTable`, + so a subclass overriding `set()` should override `__init__` too. + * Changed in v5: added allowNonStandardValue. ''' - if isinstance(name, str): + try: + standard = accidentalLookupTable.get(name) + except TypeError: # unhashable + standard = None + if standard is None and isinstance(name, str): name = name.lower() # sometimes args get capitalized - if name in ('natural', 'n', 0): - self._name = 'natural' - self._alter = 0.0 - elif name in ('sharp', accidentalNameToModifier['sharp'], 'is', 1): - self._name = 'sharp' - self._alter = 1.0 - elif name in ('double-sharp', accidentalNameToModifier['double-sharp'], - 'isis', 2): - self._name = 'double-sharp' - self._alter = 2.0 - elif name in ('flat', accidentalNameToModifier['flat'], 'es', 'b', -1): - self._name = 'flat' - self._alter = -1.0 - elif name in ('double-flat', accidentalNameToModifier['double-flat'], - 'eses', -2): - self._name = 'double-flat' - self._alter = -2.0 - - elif name in ('half-sharp', accidentalNameToModifier['half-sharp'], - 'quarter-sharp', 'ih', 'semisharp', 0.5): - self._name = 'half-sharp' - self._alter = 0.5 - elif name in ('one-and-a-half-sharp', - accidentalNameToModifier['one-and-a-half-sharp'], - 'three-quarter-sharp', 'three-quarters-sharp', 'isih', - 'sesquisharp', 1.5): - self._name = 'one-and-a-half-sharp' - self._alter = 1.5 - elif name in ('half-flat', accidentalNameToModifier['half-flat'], - 'quarter-flat', 'eh', 'semiflat', -0.5): - self._name = 'half-flat' - self._alter = -0.5 - elif name in ('one-and-a-half-flat', - accidentalNameToModifier['one-and-a-half-flat'], - 'three-quarter-flat', 'three-quarters-flat', 'eseh', - 'sesquiflat', -1.5): - self._name = 'one-and-a-half-flat' - self._alter = -1.5 - elif name in ('triple-sharp', accidentalNameToModifier['triple-sharp'], - 'isisis', 3): - self._name = 'triple-sharp' - self._alter = 3.0 - elif name in ('quadruple-sharp', - accidentalNameToModifier['quadruple-sharp'], 'isisisis', 4): - self._name = 'quadruple-sharp' - self._alter = 4.0 - elif name in ('triple-flat', accidentalNameToModifier['triple-flat'], - 'eseses', -3): - self._name = 'triple-flat' - self._alter = -3.0 - elif name in ('quadruple-flat', - accidentalNameToModifier['quadruple-flat'], 'eseseses', -4): - self._name = 'quadruple-flat' - self._alter = -4.0 + standard = accidentalLookupTable.get(name) + + if standard is not None: + self._name, self._alter, self._modifier = standard + if self._client: + self._client.informClient() + elif allowNonStandardValue and isinstance(name, str): + self._name = name + elif allowNonStandardValue and isinstance(name, (int, float)): + self._alter = name else: - if not allowNonStandardValue: - raise AccidentalException(f'{name} is not a supported accidental type') - - if isinstance(name, str): - self._name = name - return - elif isinstance(name, (int, float)): - self._alter = name - return - else: # pragma: no cover - raise AccidentalException(f'{name} is not a supported accidental type') - - self._modifier = accidentalNameToModifier[self._name] - if self._client: - self._client.informClient() + raise AccidentalException(f'{name} is not a supported accidental type') def isTwelveTone(self) -> bool: @@ -1608,6 +1639,12 @@ def fullName(self) -> str: # ------------------------------------------------------------------------------ +# strings that Pitch.name parsed: (step, accidental string or None, octave or None). +# Holds no Accidental objects, since those are mutable. Cleared when full. +_pitchNameCache: dict[str, tuple[StepName, str|None, int|None]] = {} +_pitchNameCacheSize = 1000 + + # tried as SlottedObjectMixin -- made creation time slower! Not worth the restrictions class Pitch(prebase.ProtoM21Object): ''' @@ -1665,31 +1702,28 @@ class Pitch(prebase.ProtoM21Object): >>> alters [1.0, -1.0] - If a `Pitch` doesn't have an associated octave, then its - `.octave` value is None. This means that it represents - any G#, regardless of octave. Transposing this note up - an octave doesn't change anything. + A `Pitch` created without an octave represents any G#, regardless + of octave: it prints without an octave number, and transposing it + up an octave changes nothing. >>> anyGSharp = pitch.Pitch('G#') - >>> anyGSharp.octave is None - True + >>> anyGSharp + >>> print(anyGSharp.transpose('P8')) G# - Sometimes we need an octave for a `Pitch` even if it's not - specified. For instance, we can't play an octave-less `Pitch` - in MIDI or display it on a staff. So there is an `.implicitOctave` - tag to deal with these situations; by default it's always 4 (unless - defaults.pitchOctave is changed) + Yet an octave is often needed anyhow, to play the `Pitch` in MIDI or + put it on a staff, so `.octave` is always an integer: the default octave, + 4, when none was given. `.octaveIsImplicit` tells the two cases apart. - >>> anyGSharp.implicitOctave + >>> anyGSharp.octave 4 - - If a `Pitch` has its `.octave` explicitly set, then `.implicitOctave` - always equals `.octave`. - - >>> highEflat.implicitOctave + >>> anyGSharp.octaveIsImplicit + True + >>> highEflat.octave 6 + >>> highEflat.octaveIsImplicit + False If an integer or float >= 12 is passed to the constructor then it is used as the `.ps` attribute, which is for most common piano notes, the @@ -1708,7 +1742,7 @@ class Pitch(prebase.ProtoM21Object): >>> p2 = pitch.Pitch(3) >>> p2 - >>> p2.octave is None + >>> p2.octaveIsImplicit True Since in instantiating pitches from numbers, @@ -1830,8 +1864,8 @@ class Pitch(prebase.ProtoM21Object): and cannot be put into Streams ''' # define order for presenting names in documentation; use strings - _DOC_ORDER = ['name', 'nameWithOctave', 'step', 'pitchClass', 'octave', 'midi', 'german', - 'french', 'spanish', 'italian', 'dutch'] + _DOC_ORDER = ['name', 'nameWithOctave', 'step', 'pitchClass', 'octave', 'octaveIsImplicit', + 'midi', 'german', 'french', 'spanish', 'italian', 'dutch'] # documentation for all attributes (not properties or methods) # _DOC_ATTR: dict[str, str] = { # } @@ -1903,11 +1937,11 @@ def __init__(self, ps: float|None = None, fundamental: Pitch|None = None, **keywords) -> None: - # No need for super().__init__() on protoM21Object + # No need for super().__init__() on ProtoM21Object self._groups: base.Groups|None = None - # this should not be set, as will be updated when needed - self._step: StepName = defaults.pitchStep # this is only the pitch step + # this will be updated below when needed + self._step: StepName = 'C' # this does not include the accidental self._overridden_freq440: float|None = None @@ -1918,12 +1952,7 @@ def __init__(self, # 5% of pitch creation time; it'll be created in a sec anyhow self._microtone: Microtone|None = None - # # CA, Q: should this remain an attribute or only refer to value in defaults? - # # MSC A: no, it's a useful attribute for cases such as scales where if there are - # # no octaves we give a defaultOctave higher than the previous - # # MSC 12 years later: maybe Chris was right! - # self.defaultOctave: int = defaults.pitchOctave - # # MSC: even later: Chris Ariza was right + # None means implicit: .octave then reports the default octave of 4 self._octave: int|None = None # if True, accidental is not known; is determined algorithmically @@ -1938,17 +1967,32 @@ def __init__(self, # name combines step, octave, and accidental if name is not None: - if isinstance(name, str): + if name == 'C': + pass # a new Pitch is already C + elif name == 'C4': + # no other change needed here. + self._octave = 4 + elif isinstance(name, str): self.name = name # set based on string else: # is a number # is a midiNumber or a ps -- a float midiNumber # get step and accidental w/o octave - self.step, self._accidental = _convertPsToStep(name)[0:2] + wholePs = int(name) + if wholePs == name: + # if this changes, update _convertPsToStep too + self._step, alter = pitchClassToDefaultStepAlter[wholePs % 12] + self._accidental = Accidental(alter) + else: + self._step, self._accidental = _convertPsToStep(name)[0:2] self.spellingIsInferred = True if name >= 12: # is not a pitchClass self._octave = int(name / 12) - 1 - elif step is not None: - self.step = step + elif step is not None and step != 'C': + if step in STEPNAMES: + # set it directly. + self._step = step + else: + self.step = step if octave is not None: self._octave = octave @@ -1964,8 +2008,7 @@ def __init__(self, else: self.microtone = Microtone(microtone) if pitchClass is not None: - # type ignore until https://github.com/python/mypy/issues/3004 resolved - self.pitchClass = pitchClass # type: ignore + self.pitchClass = pitchClass if fundamental is not None: self.fundamental = fundamental if midi is not None: @@ -2031,7 +2074,7 @@ def __eq__(self, other: object) -> bool: ''' if not isinstance(other, Pitch): return NotImplemented - if (self.octave == other.octave + if (self._octave == other._octave and self.step == other.step and self.accidental == other.accidental and self.microtone == other.microtone): @@ -2070,7 +2113,7 @@ def __hash__(self) -> int: self.fundamental, self.spellingIsInferred, self.microtone, - self.octave, + self._octave, self.step, type(self), ) @@ -2501,7 +2544,6 @@ def convertMicrotonesToQuarterTones(self, *, inPlace: bool = False) -> t.Self|No >>> p.convertMicrotonesToQuarterTones(inPlace=True) >>> str(p) 'F~2(+12c)' - ''' if inPlace: returnObj = self @@ -2578,15 +2620,15 @@ def ps(self) -> float: >>> print(f'{p.ps:.1f}') 60.2 - Octaveless pitches use their .implicitOctave attributes: + A pitch without an octave of its own uses the default octave, 4: >>> d = pitch.Pitch('D#') - >>> d.octave is None + >>> d.octaveIsImplicit True - >>> d.implicitOctave - 4 >>> d.ps 63.0 + >>> d.ps == pitch.Pitch('D#4').ps + True >>> d.octave = 5 >>> d.ps @@ -2614,7 +2656,7 @@ def ps(self) -> float: or self.accidental are changed. ''' step = self._step - ps = float(((self.implicitOctave + 1) * 12) + STEPREF[step]) + ps = float(((self.octave + 1) * 12) + STEPREF[step]) if self.accidental is not None: ps = ps + self.accidental.alter if self._microtone is not None: @@ -2623,19 +2665,23 @@ def ps(self) -> float: @ps.setter def ps(self, value: int|float) -> None: + # TODO: make _convertPsToStep return an alter not an accidental, and then change + # the alter of the Pitch's existing accidental (setting displayStatus to None) + # to preserve style and displayType information # can assign microtone here; will be either None or a Microtone object - self.step, acc, self._microtone, octShift = _convertPsToStep(value) + self._step, acc, self._microtone, octShift = _convertPsToStep(value) # replace a natural with a None if acc.name == 'natural': - self.accidental = None + self._accidental = None else: - self.accidental = acc - self.octave = _convertPsToOct(value) + octShift + self._accidental = acc + self._octave = _convertPsToOct(value) + octShift # all ps settings must set implicit to True, as we do not know # what accidental this is self.spellingIsInferred = True + self.informClient() @property def midi(self) -> int: @@ -2771,6 +2817,8 @@ def name(self) -> str: >>> a = pitch.Pitch('B---') >>> a.name 'B---' + + * Changed in v11: a name longer than 100 characters raises a ValueError. ''' if self.accidental is not None: return self.step + self.accidental.modifier @@ -2783,10 +2831,38 @@ def name(self, usrStr: str) -> None: Set name, which may be provided with or without octave values. C4 or D-3 are both accepted. ''' + try: + step, accidentalStr, octave = _pitchNameCache[usrStr] + except (KeyError, TypeError): # TypeError: not hashable, so not a str + self._cacheParsedName(usrStr) + step, accidentalStr, octave = _pitchNameCache[usrStr] + self._step = step + self.spellingIsInferred = False + self._accidental = (None if accidentalStr is None + else Accidental(accidentalStr)) + if octave is not None: + self._octave = octave + self.informClient() + + def _cacheParsedName(self, usrStr: str) -> None: + ''' + Parse a name not yet seen and remember its step, accidental, and + octave for the `.name` setter. Raises if it is not a name. + + >>> p = pitch.Pitch() + >>> p._cacheParsedName(' f#5') + >>> pitch._pitchNameCache[' f#5'] + ('F', '#', 5) + ''' + cacheKey = usrStr try: usrStr = usrStr.strip() except AttributeError: raise ValueError(f'Argument to name, {usrStr!r}, must be a string, not {type(usrStr)}.') + # remembered names are kept, so do not keep huge ones + if len(cacheKey) > 100: + raise ValueError( + f'Argument to name must be at most 100 characters, not {len(cacheKey)}.') # extract any numbers that may be octave designations octFound: list[str] = [] @@ -2803,20 +2879,24 @@ def name(self, usrStr: str) -> None: octNot.append(char) usrStr = ''.join(octNot) octFoundStr = ''.join(octFound) - # we have nothing but pitch specification - if len(usrStr) == 1: - self.step = usrStr # type: ignore - self.accidental = None - # assume everything following pitch is accidental specification - elif len(usrStr) > 1: - self.step = usrStr[0] # type: ignore - self.accidental = Accidental(usrStr[1:]) - else: + if not usrStr: raise PitchException(f'Cannot make a name out of {usrStr!r}') + step = t.cast(StepName, usrStr[0].upper()) + if step not in STEPNAMES: + raise PitchException(f'Cannot make a step out of {step!r}') + # assume everything following pitch is accidental specification + accidentalStr: str|None = None + if len(usrStr) > 1: + accidentalStr = usrStr[1:] + Accidental(accidentalStr) # raises if it is not an accidental + octave: int|None = None if octFoundStr: # bool('0') == True, so okay octave = int(octFoundStr) - self.octave = octave + + if len(_pitchNameCache) >= _pitchNameCacheSize: + _pitchNameCache.clear() + _pitchNameCache[cacheKey] = (step, accidentalStr, octave) @property def unicodeName(self) -> str: @@ -2878,9 +2958,9 @@ def nameWithOctave(self) -> str: Traceback (most recent call last): music21.pitch.PitchException: Cannot set a nameWithOctave with 'C#' - Set octave to None explicitly instead. + Set `.octaveIsImplicit = True` instead. ''' - if self.octave is None: + if self.octaveIsImplicit: return self.name else: return self.name + str(self.octave) @@ -2908,7 +2988,7 @@ def unicodeNameWithOctave(self) -> str: >>> p.unicodeNameWithOctave 'C♯4' ''' - if self.octave is None: + if self.octaveIsImplicit: return self.unicodeName else: return self.unicodeName + str(self.octave) @@ -2934,7 +3014,7 @@ def fullName(self) -> str: if self.accidental is not None: name += f'-{self.accidental.fullName}' - if self.octave is not None: + if not self.octaveIsImplicit: name += f' in octave {self.octave}' if self._microtone is not None and self.microtone.cents != 0: @@ -3152,31 +3232,42 @@ def pitchClassString(self, v: int|PitchClassString) -> None: @property - def octave(self) -> int|None: + def octave(self) -> int: ''' - Returns or sets the octave of the note. - Setting the octave updates the pitchSpace attribute. + Returns or sets the octave of the Pitch. - >>> a = pitch.Pitch('g') - >>> a.octave is None - True - >>> a.implicitOctave + >>> b = pitch.Pitch('B5') + >>> b.octave + 5 + + Always an int: a Pitch created without an octave reports the default + octave, 4, and has `.octaveIsImplicit` True. + + >>> g = pitch.Pitch('g') + >>> g.octave 4 - >>> a.ps ## will use implicitOctave + >>> g.octaveIsImplicit + True + >>> g.ps 67.0 - >>> a.name - 'G' - >>> a.octave = 14 - >>> a.octave - 14 - >>> a.implicitOctave + Setting the octave updates `.ps` and makes the octave explicit: + + >>> g.octave = 14 + >>> g.octave 14 - >>> a.name - 'G' - >>> a.ps + >>> g.octaveIsImplicit + False + >>> g.ps 187.0 + + To make the pitch octaveless, set `.octaveIsImplicit = True`. Setting + `.octave = None` is deprecated and will be removed in v13. + + * Changed in v11: always an int; `.octaveIsImplicit` says whether it was given. ''' + if self._octave is None: + return 4 return self._octave @octave.setter @@ -3184,28 +3275,64 @@ def octave(self, value: int|float|None) -> None: if value is not None: self._octave = int(value) else: + # None is deprecated, removed in v13. Add real deprecation message here in v12 self._octave = None self.informClient() + @property + def octaveIsImplicit(self) -> bool: + ''' + True if this Pitch was never given an octave, so it stands for its + pitch class in any octave: it prints without an octave number, and + `.octave` reports the default, 4. + + >>> anyFSharp = pitch.Pitch('F#') + >>> anyFSharp.octaveIsImplicit + True + >>> anyFSharp + + >>> anyFSharp.octave + 4 + + Setting `.octave` makes the octave explicit: + + >>> anyFSharp.octave = 5 + >>> anyFSharp.octaveIsImplicit + False + >>> anyFSharp + + + Set it back to True to make the pitch octaveless again: + + >>> anyFSharp.octaveIsImplicit = True + >>> anyFSharp + + + * New in v11. + ''' + return self._octave is None + + @octaveIsImplicit.setter + def octaveIsImplicit(self, value: bool) -> None: + if bool(value) == self.octaveIsImplicit: + return + self._octave = None if value else 4 + self.informClient() + @property def implicitOctave(self) -> int: ''' - Returns the octave of the Pitch, or defaultOctave if - octave was never set. To set an octave, use .octave. - Default octave is usually 4. + Synonym for `.octave`. >>> p = pitch.Pitch('C#') - >>> p.octave is None - True >>> p.implicitOctave 4 - Cannot be set. Instead, just change the `.octave` of the pitch + * Deprecated in v11: use `.octave`, which is now always an int. + A warning arrives in v12 and the property goes away later. ''' - if self.octave is None: - return defaults.pitchOctave - else: - return self.octave + # Add real deprecation message here in v12 + return self.octave # noinspection SpellCheckingInspection,GrazieInspection @property @@ -3992,7 +4119,7 @@ def isEnharmonic(self, other: Pitch) -> bool: >>> pD4.isEnharmonic(pEbb4) and pD4.step == pEbb4.step False ''' - if other.octave is None or self.octave is None: + if other.octaveIsImplicit or self.octaveIsImplicit: return (other.ps - self.ps) % 12 == 0 else: # if pitch spaces are equal, these are enharmonics @@ -4026,19 +4153,19 @@ def _getEnharmonicHelper(self, if intervalString not in self._transpositionIntervals: self._transpositionIntervals[intervalString] = interval.Interval(intervalString) intervalObj = self._transpositionIntervals[intervalString] - octaveStored = self.octave # may be None + octaveWasImplicit = self.octaveIsImplicit p = intervalObj.transposePitch(self, maxAccidental=None) if not inPlace: - if octaveStored is None: - p.octave = None + if octaveWasImplicit: + p.octaveIsImplicit = True return p else: self.step = p.step self.accidental = p.accidental if p.microtone is not None: self.microtone = p.microtone - if octaveStored is None: - self.octave = None + if octaveWasImplicit: + self.octaveIsImplicit = True else: self.octave = p.octave return None @@ -4217,10 +4344,10 @@ def simplifyEnharmonic( else: # by resetting the pitch space value, we will get a simpler # enharmonic spelling - saveOctave = self.octave + octaveWasImplicit = self.octaveIsImplicit returnObj.ps = self.ps - if saveOctave is None: - returnObj.octave = None + if octaveWasImplicit: + returnObj.octaveIsImplicit = True if mostCommon: if returnObj.name == 'D#': @@ -4452,7 +4579,7 @@ def diatonicNoteNum(self) -> int: >>> b.diatonicNoteNum 0 - An `implicitOctave` of 4 is used if octave is not set: + The default octave, 4, is used as a basis if the pitch had no octave: >>> c = pitch.Pitch('C') >>> c.diatonicNoteNum @@ -4482,11 +4609,11 @@ def diatonicNoteNum(self) -> int: >>> lowLowLowD.diatonicNoteNum -19 ''' - return STEP_TO_DNN_OFFSET[self.step] + 1 + (7 * self.implicitOctave) + return STEP_TO_DNN_OFFSET[self.step] + 1 + (7 * self.octave) @diatonicNoteNum.setter def diatonicNoteNum(self, newNum: int) -> None: - octave = int((newNum - 1) / 7) + octave = (newNum - 1) // 7 noteNameNum = newNum - 1 - (7 * octave) pitchList: tuple[StepName, ...] = ('C', 'D', 'E', 'F', 'G', 'A', 'B') noteName: StepName = pitchList[noteNameNum] @@ -4606,7 +4733,6 @@ def transpose( >>> dPitch.transpose(intv, inPlace=True) >>> dPitch - ''' # environLocal.printDebug(['Pitch.transpose()', value]) if isinstance(value, interval.IntervalBase): @@ -4628,16 +4754,15 @@ def transpose( # pitch attributes # NOTE: in some cases this may not return exactly the proper config self.name = p.name - if self.octave is not None: + if not self.octaveIsImplicit: self.octave = p.octave # manually copy accidental object self.accidental = p.accidental # set fundamental self.fundamental = p.fundamental self.spellingIsInferred = p.spellingIsInferred - # deepcopy _microtone if present (not thru microtone property to detect None) - if p._microtone is not None: - self._microtone = copy.deepcopy(p._microtone) + # deepcopy _microtone (not thru microtone property to detect None) + self._microtone = copy.deepcopy(p._microtone) return None # -------------------------------------------------------------------------- @@ -4712,14 +4837,13 @@ def transposeBelowTarget( * Changed in v3: default for inPlace=False. ''' - if self.octave is None: + if self.octaveIsImplicit: raise PitchException('Cannot call transposeBelowTarget with an octaveless Pitch.') if inPlace: src = self else: src = copy.deepcopy(self) - assert src.octave is not None while True: # ref 20, min 10, lower ref. @@ -4797,14 +4921,13 @@ def transposeAboveTarget(self, * Changed in v3: default for inPlace=False. ''' - if self.octave is None: + if self.octaveIsImplicit: raise PitchException('Cannot call transposeAboveTarget with an octaveless Pitch.') if inPlace: src = self else: src = copy.deepcopy(self) - assert src.octave is not None # case where self is below target while True: @@ -4889,7 +5012,6 @@ def _stepInKeySignature(self, alteredPitches: list[Pitch]) -> bool: >>> b._stepInKeySignature(ks.alteredPitches) False - ''' for p in alteredPitches: # all are altered tones, must have acc if p.step == self.step: # A# to A or A# to A-, etc @@ -4973,7 +5095,7 @@ def updateAccidentalDisplay( >>> a4.accidental is None True - v8 -- made keyword-only and added `otherSimultaneousPitches`. + * Changed in v8: all parameteres are keyword-only; added `otherSimultaneousPitches`. ''' # N.B. -- this is a very complex method # do not alter it without significant testing. @@ -5032,11 +5154,22 @@ def set_displayStatus(newDisplayStatus: bool) -> None: otherSimultaneousPitches and cautionaryPitchClass and any(pSimult.step == self.step and pSimult.pitchClass != self.pitchClass - for pSimult in otherSimultaneousPitches) + for pSimult in otherSimultaneousPitches) ): set_displayStatus(True) return + # here tied and always are treated the same; we assume that + # making ties sets the displayStatus, and thus we would not be + # overriding that display status here + if (cautionaryAll is True + or (acc is not None + and acc.displayType in ('even-tied', 'always'))): + # show all accidentals, no matter what is in the past + # or in the key signature + set_displayStatus(True) + return # do not search past + # no pitches in the past list if not pitchPastAll: # if we have no past, we show the accidental if this pitch name @@ -5078,16 +5211,6 @@ def set_displayStatus(newDisplayStatus: bool) -> None: break # nope, no conflicting accidentals at this name and octave in the past - # here tied and always are treated the same; we assume that - # making ties sets the displayStatus, and thus we would not be - # overriding that display status here - if (cautionaryAll is True - or (acc is not None - and acc.displayType in ('even-tied', 'always'))): - # show all accidentals, even if past encountered - set_displayStatus(True) - return # do not search past - # store if a match was found and display set from past pitches setFromPitchPast = False @@ -5145,6 +5268,7 @@ def set_displayStatus(newDisplayStatus: bool) -> None: # and it is not a natural, it should always be set to display if (pPastInMeasure is False and acc is not None + and acc.name != 'natural' and not self._nameInKeySignature(alteredPitches)): acc.displayStatus = True return # do not search past @@ -5265,9 +5389,9 @@ def set_displayStatus(newDisplayStatus: bool) -> None: # if An or A to A#: need to make sure display is set elif ((pPast.accidental is None or pPast.accidental.name == 'natural') - and acc is not None # redundant. for mypy - and pSelf.accidental is not None - and pSelf.accidental.name != 'natural'): + and acc is not None # redundant. for mypy + and pSelf.accidental is not None + and pSelf.accidental.name != 'natural'): acc.displayStatus = True setFromPitchPast = True break @@ -5372,7 +5496,6 @@ def getStringHarmonic(self, chordIn: chord.Chord) -> chord.Chord|t.Literal[False (, , ) otherwise returns False - ''' # Takes in a chord, finds the interval between the notes from music21 import note @@ -5431,6 +5554,35 @@ def testCopyAndDeepcopy(self) -> None: from music21.test.commonTest import testCopyAll testCopyAll(self, globals()) + def testUnicodeDistinguishesEveryAccidental(self) -> None: + ''' + Each accidental gets its own unicode spelling, and the flat side + mirrors the sharp side. AI-assisted (Claude). + ''' + spellings = {name: Accidental(name).unicode for name in accidentalNameToModifier} + self.assertEqual(len(set(spellings.values())), len(spellings)) + # QUARTER TONE FLAT, not QUARTER TONE SHARP + self.assertEqual(spellings['half-flat'], '\U0001d133') + self.assertEqual(spellings['one-and-a-half-flat'], '♭\U0001d133') + # three flats is a flat plus a double flat, as three sharps is a sharp + # plus a double sharp + self.assertEqual(spellings['triple-flat'], '♭\U0001d12b') + self.assertEqual(spellings['triple-sharp'], '♯\U0001d12a') + + def testSetDiatonicNoteNumLow(self) -> None: + p = Pitch('C4') + for dnn, step, octave in ( + (1, 'C', 0), + (0, 'B', -1), + (-1, 'A', -1), + (-6, 'C', -1), + (-7, 'B', -2), + (-19, 'D', -3), + ): + p.diatonicNoteNum = dnn + self.assertEqual(p.step, step) + self.assertEqual(p.octave, octave) + # define presented order in documentation _DOC_ORDER = [Pitch, Accidental, Microtone] diff --git a/music21/prebase.py b/music21/prebase.py index 355033e7c4..967fb03ba1 100644 --- a/music21/prebase.py +++ b/music21/prebase.py @@ -98,7 +98,7 @@ def classes(self) -> tuple[str, ...]: Having quick access to these things as strings makes it easier to do comparisons: - Example: find GClefs that are not Treble clefs (or treble 8vb, etc.): + Example: find GClefs that are not Treble clefs: >>> s = stream.Stream() >>> s.insert(10, clef.GClef()) @@ -113,6 +113,7 @@ def classes(self) -> tuple[str, ...]: >>> s2.show('text') {10.0} {30.0} + {40.0} * Changed in v2: returns a tuple, not a list. ''' diff --git a/music21/repeat.py b/music21/repeat.py index 02f614b23a..4ba93d5b6b 100644 --- a/music21/repeat.py +++ b/music21/repeat.py @@ -452,7 +452,7 @@ def insertRepeatEnding(s, start, end, endingNumber: int = 1, *, inPlace=False): rbOffset = measures[0].getOffsetBySite(s) s.insert(rbOffset, rb) - if inPlace is True: + if inPlace: return else: return s @@ -491,7 +491,6 @@ def insertRepeat(s, start, end, *, inPlace=False): 'start' >>> s.parts[0].measure(6).rightBarline.direction 'end' - ''' if s is None: @@ -774,7 +773,7 @@ def process(self, deepcopy: bool = True) -> StreamType: # need to copy source measures, as may later measures before copying # them, and this can result in orphaned spanners - if deepcopy is not False: + if deepcopy: srcStream = self._srcMeasureStream.coreCopyAsDerivation('expandRepeats') else: srcStream = self._srcMeasureStream @@ -951,7 +950,6 @@ def _daCapoOrSegno(self): Return a DaCapo object if this piece uses any form of DaCapo; return a Segno object if this piece uses any form of Segno. Returns None if incoherent or the piece uses neither. - ''' sumDc = self._dcCount + self._dcafCount + self._dcacCount # for now, only accepting one segno @@ -1943,7 +1941,6 @@ class RepeatFinder: .. image:: images/repeat-SimplifyExample_ChoraleSimplified.* :width: 600 - ''' _DOC_ORDER = ['simplify', 'getMeasureSimilarityList', @@ -2010,7 +2007,6 @@ def getQuarterLengthOfPickupMeasure(self): Traceback (most recent call last): music21.repeat.NoInternalStreamException: RepeatFinder must be initialized with a stream - ''' if self.s is None: raise NoInternalStreamException( @@ -2218,7 +2214,7 @@ def _getSimilarMeasuresHelper(self, measures, source, compare, resDict, useDict) # we have a repeated section at least 2 measures in length; # check to see how far it goes nextOne = self._getSimilarMeasuresHelper(measures, source + 1, compare + 1, - resDict, useDict) + resDict, useDict) # make sure we don't have overlap res = ([source], [compare]) res[0].extend(nextOne[0]) @@ -2563,7 +2559,6 @@ def getSimilarMeasureGroups(self, threshold=1): Notice that although measures 2-3 are the same as measures 6-7, we don't have ([2, 3], [6, 7]) in our result, since ([1, 2, 3], [5, 6, 7]) already contains that information. - ''' # see if we've already done this computation if self._mGroups is None: diff --git a/music21/roman.py b/music21/roman.py index 733261bd29..3ea8ea6319 100644 --- a/music21/roman.py +++ b/music21/roman.py @@ -5,7 +5,7 @@ # Authors: Michael Scott Asato Cuthbert # Christopher Ariza # -# Copyright: Copyright © 2011-2024 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2011-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ----------------------------------------------------------------------------- ''' @@ -19,11 +19,8 @@ import typing as t import unittest -from collections import namedtuple - from music21 import chord from music21 import common -from music21 import defaults from music21 import environment from music21 import exceptions21 from music21.figuredBass import notation as fbNotation @@ -32,19 +29,192 @@ from music21 import key from music21 import note from music21 import pitch +from music21.pitch import Pitch from music21 import scale -FigureTuple = namedtuple('FigureTuple', ['aboveBass', 'alter', 'prefix']) -ChordFigureTuple = namedtuple('ChordFigureTuple', ['aboveBass', 'alter', 'prefix', 'pitch']) +class FigureTuple(t.NamedTuple): + ''' + A namedtuple of a scale step above a reference pitch, a chromatic + alteration, and the accidental string to print before a figure. + + Produced by :meth:`~music21.roman.FigureTuple.fromPitchAndReference` + and used in :func:`~music21.roman.romanNumeralFromChord` and + :func:`~music21.roman.correctRNAlterationForMinor`. + + >>> ft = roman.FigureTuple(degFromRefPitch=6, alter=1.0, prefix='#') + >>> ft + FigureTuple(degFromRefPitch=6, alter=1.0, prefix='#') + >>> ft.alter + 1.0 + + `degFromRefPitch` is the generic (diatonic) degree of the pitch counted + from whatever reference pitch was supplied, reduced to a simple + interval: always 1-7, 1-based (unison, octave = 1, 10th = 3, etc.). + Descending intervals report complements, so a pitch a + second *below* the reference is 7. + + We use the vague term "RefPitch" because it varies based on use. + For instance, the reference pitch is the chord's sounding *bass* + in :func:`~music21.roman.figureTuples` (for generating figured bass + numerals). But the reference is the *tonic of the key* in + `romanNumeralFromChord`, where we are trying to figure out the + scale degree of the root. + + `alter` is the chromatic alteration in semitones (a float) of the pitch + relative to normal pitch for the degree in the diatonic scale of the key. + For minor keys the natural minor scale is always the reference, + so in c minor, A-flat has `alter=0.0` and A-natural has `alter=1.0`. + + `prefix` is the string of accidentals ('#', '##', 'b', 'bb', ...) to + print before the figure. It is a figured-bass-style alteration, + with '#' meaning raised a semitone and 'b' lowered, regardless of what + accidental would appear on the note in staff notation. So A-natural + on ^6 in c minor gets `prefix='#'` even though the note itself would + be printed with a natural sign. No notational context is consulted -- + not the measure's accidental state, not courtesy accidentals, only + pitch versus key. + + The `sixthMinor`/`seventhMinor` conventions + (:class:`~music21.roman.Minor67Default`) are likewise *not* consulted + when a FigureTuple is created: `fromPitchAndReference` renders `prefix` + purely from `alter`. + + * Changed in v11: became a typed NamedTuple (was a collections.namedtuple), + and the first field was renamed from `aboveBass` to `degFromRefPitch`, + since the reference pitch is not always the bass. + ''' + degFromRefPitch: int + alter: float + prefix: str + + @classmethod + def fromPitchAndReference( + cls, + pitchObj: Pitch, + keyObj: key.Key, + refPitch: Pitch, + ) -> FigureTuple: + ''' + Return a FigureTuple of the `pitchObj` above + `refPitch` in a given key + + E.g., get the FigureTuple for an A-flat above an F# bass in C major, showing that + it is a third above the F# and a half step down from the normally expected + note A in C major. + + >>> roman.FigureTuple.fromPitchAndReference( + ... pitch.Pitch('A-3'), + ... key.Key('C'), + ... pitch.Pitch('F#2'), + ... ) + FigureTuple(degFromRefPitch=3, alter=-1.0, prefix='b') + + These figures can be more complex in minor, but the natural minor is always used. + + Here are the various forms of ^3, ^6, and ^7 (including weird ones) that might + appear in C minor, above the tonic "C", expressed as FigureTuples. + + >>> c = key.Key('c') + >>> c_as_bass = pitch.Pitch('C3') + >>> for name in ('E--', 'E-', 'E', 'E#', 'A--', 'A-', 'A', 'A#', + ... 'B--', 'B-', 'B', 'B#'): + ... ft = roman.FigureTuple.fromPitchAndReference( + ... pitch.Pitch(name + '4'), c, c_as_bass) + ... print(f'{name:4s} {ft}') + E-- FigureTuple(degFromRefPitch=3, alter=-1.0, prefix='b') + E- FigureTuple(degFromRefPitch=3, alter=0.0, prefix='') + E FigureTuple(degFromRefPitch=3, alter=1.0, prefix='#') + E# FigureTuple(degFromRefPitch=3, alter=2.0, prefix='##') + A-- FigureTuple(degFromRefPitch=6, alter=-1.0, prefix='b') + A- FigureTuple(degFromRefPitch=6, alter=0.0, prefix='') + A FigureTuple(degFromRefPitch=6, alter=1.0, prefix='#') + A# FigureTuple(degFromRefPitch=6, alter=2.0, prefix='##') + B-- FigureTuple(degFromRefPitch=7, alter=-1.0, prefix='b') + B- FigureTuple(degFromRefPitch=7, alter=0.0, prefix='') + B FigureTuple(degFromRefPitch=7, alter=1.0, prefix='#') + B# FigureTuple(degFromRefPitch=7, alter=2.0, prefix='##') + + * New in v11: replaces `figureTupleSolo`, now a deprecated alias. + ''' + unused_scaleStep, scaleAccidental = ( + keyObj.getScaleDegreeAndAccidentalFromPitch(pitchObj)) + + thisInterval = interval.Interval(refPitch, pitchObj) + degFromRefPitch = thisInterval.diatonic.generic.mod7 + if scaleAccidental is None: + rootAlterationString = '' + alterDiff = 0.0 + else: + alterDiff = scaleAccidental.alter + alter = int(alterDiff) + if alter < 0: + rootAlterationString = 'b' * (-1 * alter) + elif alter > 0: + rootAlterationString = '#' * alter + else: + rootAlterationString = '' + + return cls(degFromRefPitch, alterDiff, rootAlterationString) + + +class PitchFigureTuple(t.NamedTuple): + ''' + Like a :class:`~music21.roman.FigureTuple` but which also stores the pitch + it describes as `.pitch`. + + >>> pft = roman.PitchFigureTuple(6, 0.0, '', pitch.Pitch('A-4')) + >>> pft + PitchFigureTuple(degFromRefPitch=6, alter=0.0, prefix='', pitch=) + + * Changed in v11: became a typed NamedTuple (was a collections.namedtuple); + renamed from `ChordFigureTuple`, since this tuple describes a single + pitch; first field renamed to `degFromRefPitch` as with FigureTuple above. + ''' + degFromRefPitch: int + alter: float + prefix: str + pitch: Pitch + + @classmethod + def fromPitchAndReference( + cls, + pitchObj: Pitch, + keyObj: key.Key, + refPitch: Pitch, + ) -> PitchFigureTuple: + ''' + Return a PitchFigureTuple of the `pitchObj` above `refPitch` in a given key. + + See FigureTuple.fromPitchAndReference for details. + + Here is a B-flat above an E in the key of F major (perhaps a member of a V65 chord) + + >>> roman.PitchFigureTuple.fromPitchAndReference(pitch.Pitch('B-4'), + ... key.Key('F'), + ... pitch.Pitch('E4')) + PitchFigureTuple(degFromRefPitch=5, alter=0.0, prefix='', + pitch=) + + And the same in G major: + + >>> roman.PitchFigureTuple.fromPitchAndReference(pitch.Pitch('B-4'), + ... key.Key('G'), + ... pitch.Pitch('E4')) + PitchFigureTuple(degFromRefPitch=5, alter=-1.0, prefix='b', + pitch=) + ''' + ft = FigureTuple.fromPitchAndReference(pitchObj, keyObj, refPitch) + return cls(ft.degFromRefPitch, ft.alter, ft.prefix, pitchObj) + environLocal = environment.Environment('roman') -# TODO: setting inversion should change the figure +# TODO: make inversion read-only and use a separate setInversion to change # ----------------------------------------------------------------------------- -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 @@ -232,18 +402,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 @@ -259,7 +427,6 @@ def correctSuffixForChordQuality(chordObj, inversionString): >>> c = chord.Chord('E3 C4 G-4') >>> roman.correctSuffixForChordQuality(c, '6') 'o6' - ''' fifthType = chordObj.semitonesFromChordStep(5) if fifthType == 6: @@ -361,21 +528,21 @@ def _postFigureFromChordAndKey(chordObj: chord.Chord, keyObj: key.Key) -> str: ) for ft in sorted(chordFigureTuples, - key=lambda tup: (-1 * tup.aboveBass, tup.alter, tup.pitch.ps)): + key=lambda tup: (-1 * tup.degFromRefPitch, tup.alter, tup.pitch.ps)): # (diatonicIntervalNum, alter, alterStr, pitchObj) = figureTuple prefix = ft.prefix - if ft.aboveBass != 1 and ft.pitch is third: + if ft.degFromRefPitch != 1 and ft.pitch is third: if isMajorTriad or isMinorTriad: prefix = '' # alterStr[1:] # elif isMinorTriad and ft.alter > 0: # prefix = '' # alterStr[1:] - elif (ft.aboveBass != 1 + elif (ft.degFromRefPitch != 1 and ft.pitch is fifth and chordObjIsStandardTriad): prefix = '' # alterStr[1:] - if ft.aboveBass == 1: + if ft.degFromRefPitch == 1: if ft.alter != bassFigureAlter and prefix != '': # mark altered octaves as 8 not 1 figureString = prefix + '8' @@ -383,7 +550,7 @@ def _postFigureFromChordAndKey(chordObj: chord.Chord, keyObj: key.Key) -> str: # filter duplicates and put at beginning allFigureStringList.insert(0, figureString) else: - figureString = prefix + str(ft.aboveBass) + figureString = prefix + str(ft.degFromRefPitch) # filter out duplicates. if figureString not in allFigureStringList: allFigureStringList.append(figureString) @@ -407,14 +574,12 @@ def _postFigureFromChordAndKey(chordObj: chord.Chord, keyObj: key.Key) -> str: return allFigureString -def figureTuples(chordObject: chord.Chord, keyObject: key.Key) -> list[ChordFigureTuple]: +def figureTuples(chordObject: chord.Chord, keyObject: key.Key) -> list[PitchFigureTuple]: ''' - Return a set of tuplets for each pitch showing the presence of a note, its - interval above the bass its alteration (float) from a step in the given - key, an `alterationString`, and the pitch object. - - Note though that for roman numerals, the applicable key is almost always - the root. + Return a list of :class:`~music21.roman.PitchFigureTuple` objects, one + per pitch of the chord, each showing the simplied (1-7) generic interval of the note + above the chord's bass, etc. (see PitchFigureTuple and :class:`~music21.roman.FigureTuple` + for reference). For instance, in C major, F# D A- C# would be: @@ -422,10 +587,10 @@ def figureTuples(chordObject: chord.Chord, keyObject: key.Key) -> list[ChordFigu ... chord.Chord(['F#2', 'D3', 'A-3', 'C#4']), ... key.Key('C'), ... ) - [ChordFigureTuple(aboveBass=1, alter=1.0, prefix='#', pitch=), - ChordFigureTuple(aboveBass=6, alter=0.0, prefix='', pitch=), - ChordFigureTuple(aboveBass=3, alter=-1.0, prefix='b', pitch=), - ChordFigureTuple(aboveBass=5, alter=1.0, prefix='#', pitch=)] + [PitchFigureTuple(degFromRefPitch=1, alter=1.0, prefix='#', pitch=), + PitchFigureTuple(degFromRefPitch=6, alter=0.0, prefix='', pitch=), + PitchFigureTuple(degFromRefPitch=3, alter=-1.0, prefix='b', pitch=), + PitchFigureTuple(degFromRefPitch=5, alter=1.0, prefix='#', pitch=)] In c-minor, the A- is a normal note, so the prefix is '' not 'b'. The natural minor is used exclusively. @@ -434,104 +599,53 @@ def figureTuples(chordObject: chord.Chord, keyObject: key.Key) -> list[ChordFigu ... chord.Chord(['F#2', 'D3', 'A-3', 'C#4']), ... key.Key('c'), ... ) - [ChordFigureTuple(aboveBass=1, alter=1.0, prefix='#', pitch=), - ChordFigureTuple(aboveBass=6, alter=0.0, prefix='', pitch=), - ChordFigureTuple(aboveBass=3, alter=0.0, prefix='', pitch=), - ChordFigureTuple(aboveBass=5, alter=1.0, prefix='#', pitch=)] + [PitchFigureTuple(degFromRefPitch=1, alter=1.0, prefix='#', pitch=), + PitchFigureTuple(degFromRefPitch=6, alter=0.0, prefix='', pitch=), + PitchFigureTuple(degFromRefPitch=3, alter=0.0, prefix='', pitch=), + PitchFigureTuple(degFromRefPitch=5, alter=1.0, prefix='#', pitch=)] - A C dominant-seventh chord in c minor alters the bass but not the 7th degree. + A C dominant seventh chord in c minor alters the bass but not the 7th degree. >>> roman.figureTuples( ... chord.Chord(['E3', 'C4', 'G4', 'B-5']), ... key.Key('c'), ... ) - [ChordFigureTuple(aboveBass=1, alter=1.0, prefix='#', pitch=), - ChordFigureTuple(aboveBass=6, alter=0.0, prefix='', pitch=), - ChordFigureTuple(aboveBass=3, alter=0.0, prefix='', pitch=), - ChordFigureTuple(aboveBass=5, alter=0.0, prefix='', pitch=)] + [PitchFigureTuple(degFromRefPitch=1, alter=1.0, prefix='#', pitch=), + PitchFigureTuple(degFromRefPitch=6, alter=0.0, prefix='', pitch=), + PitchFigureTuple(degFromRefPitch=3, alter=0.0, prefix='', pitch=), + PitchFigureTuple(degFromRefPitch=5, alter=0.0, prefix='', pitch=)] + + The same degree can appear more than once with the same or different alters and prefixes: >>> roman.figureTuples( - ... chord.Chord(['C4', 'E4', 'G4', 'C#4']), + ... chord.Chord(['C4', 'E4', 'G4', 'C#4', 'E5']), ... key.Key('C'), ... ) - [ChordFigureTuple(aboveBass=1, alter=0.0, prefix='', pitch=), - ChordFigureTuple(aboveBass=3, alter=0.0, prefix='', pitch=), - ChordFigureTuple(aboveBass=5, alter=0.0, prefix='', pitch=), - ChordFigureTuple(aboveBass=1, alter=1.0, prefix='#', pitch=)] + [PitchFigureTuple(degFromRefPitch=1, alter=0.0, prefix='', pitch=), + PitchFigureTuple(degFromRefPitch=3, alter=0.0, prefix='', pitch=), + PitchFigureTuple(degFromRefPitch=5, alter=0.0, prefix='', pitch=), + PitchFigureTuple(degFromRefPitch=1, alter=1.0, prefix='#', pitch=), + PitchFigureTuple(degFromRefPitch=3, alter=0.0, prefix='', pitch=)] ''' result = [] bass = chordObject.bass() for thisPitch in chordObject.pitches: - shortTuple = figureTupleSolo(thisPitch, keyObject, bass) - appendTuple = ChordFigureTuple(shortTuple.aboveBass, - shortTuple.alter, - shortTuple.prefix, - thisPitch) + appendTuple = PitchFigureTuple.fromPitchAndReference(thisPitch, keyObject, bass) result.append(appendTuple) return result +@common.deprecated('v11', 'v12', 'use FigureTuple.fromPitchAndReference() instead') def figureTupleSolo( - pitchObj: pitch.Pitch, + pitchObj: Pitch, keyObj: key.Key, - bass: pitch.Pitch + bass: Pitch ) -> FigureTuple: ''' - Return a single tuple for a pitch and key showing the interval above - the bass, its alteration from a step in the given key, an alteration - string, and the pitch object. - - For instance, in C major, an A-3 above an F# bass would be: - - >>> roman.figureTupleSolo( - ... pitch.Pitch('A-3'), - ... key.Key('C'), - ... pitch.Pitch('F#2'), - ... ) - FigureTuple(aboveBass=3, alter=-1.0, prefix='b') - - These figures can be more complex in minor, so this is a good reference, showing - that natural minor is always used. - - >>> c = key.Key('c') - >>> c_as_bass = pitch.Pitch('C3') - >>> for name in ('E--', 'E-', 'E', 'E#', 'A--', 'A-', 'A', 'A#', 'B--', 'B-', 'B', 'B#'): - ... ft = roman.figureTupleSolo(pitch.Pitch(name + '4'), c, c_as_bass) - ... print(f'{name:4s} {ft}') - E-- FigureTuple(aboveBass=3, alter=-1.0, prefix='b') - E- FigureTuple(aboveBass=3, alter=0.0, prefix='') - E FigureTuple(aboveBass=3, alter=1.0, prefix='#') - E# FigureTuple(aboveBass=3, alter=2.0, prefix='##') - A-- FigureTuple(aboveBass=6, alter=-1.0, prefix='b') - A- FigureTuple(aboveBass=6, alter=0.0, prefix='') - A FigureTuple(aboveBass=6, alter=1.0, prefix='#') - A# FigureTuple(aboveBass=6, alter=2.0, prefix='##') - B-- FigureTuple(aboveBass=7, alter=-1.0, prefix='b') - B- FigureTuple(aboveBass=7, alter=0.0, prefix='') - B FigureTuple(aboveBass=7, alter=1.0, prefix='#') - B# FigureTuple(aboveBass=7, alter=2.0, prefix='##') - - Returns a namedtuple called a FigureTuple. + Deprecated: use :meth:`~music21.roman.FigureTuple.fromPitchAndReference` + instead. ''' - unused_scaleStep, scaleAccidental = keyObj.getScaleDegreeAndAccidentalFromPitch(pitchObj) - - thisInterval = interval.Interval(bass, pitchObj) - aboveBass = thisInterval.diatonic.generic.mod7 - if scaleAccidental is None: - rootAlterationString = '' - alterDiff = 0.0 - else: - alterDiff = scaleAccidental.alter - alter = int(alterDiff) - if alter < 0: - rootAlterationString = 'b' * (-1 * alter) - elif alter > 0: - rootAlterationString = '#' * alter - else: - rootAlterationString = '' - - appendTuple = FigureTuple(aboveBass, alterDiff, rootAlterationString) - return appendTuple + return FigureTuple.fromPitchAndReference(pitchObj, keyObj, bass) def identifyAsTonicOrDominant( @@ -675,65 +789,123 @@ def romanInversionName(inChord: chord.Chord, inv: int|None = None) -> str: def correctRNAlterationForMinor( figureTuple: FigureTuple, - keyObj: key.Key + keyObj: key.Key, + *, + chordHasMajorThird: bool = False, ) -> FigureTuple: ''' - (This will become a private function in version 10) + (This may become a private function in a future version) Takes in a FigureTuple and a Key object and returns the same or a new FigureTuple correcting for the fact that, for instance, Ab in c minor is VI not vi. Works properly only if the note is the root of the chord. + The convention applied is the cautionary style of + :class:`~music21.roman.Minor67Default` `.CAUTIONARY` -- hardcoded: the + function does not (yet) take `sixthMinor`/`seventhMinor` arguments + (see issue #1349). + Used in RomanNumeralFromChord These return new FigureTuple objects - >>> ft5 = roman.FigureTuple(aboveBass=6, alter=-1, prefix='') + >>> ft5 = roman.FigureTuple(degFromRefPitch=6, alter=-1, prefix='') >>> ft5a = roman.correctRNAlterationForMinor(ft5, key.Key('c')) >>> ft5a - FigureTuple(aboveBass=6, alter=-1, prefix='b') + FigureTuple(degFromRefPitch=6, alter=-1, prefix='b') >>> ft5a is ft5 False - >>> ft6 = roman.FigureTuple(aboveBass=6, alter=0, prefix='') + >>> ft6 = roman.FigureTuple(degFromRefPitch=6, alter=0, prefix='') >>> roman.correctRNAlterationForMinor(ft6, key.Key('c')) - FigureTuple(aboveBass=6, alter=0, prefix='b') + FigureTuple(degFromRefPitch=6, alter=0, prefix='b') - >>> ft7 = roman.FigureTuple(aboveBass=7, alter=1, prefix='#') - >>> roman.correctRNAlterationForMinor(ft7, key.Key('c')) - FigureTuple(aboveBass=7, alter=0, prefix='') + For a chord whose quality is minor, diminished, or half-diminished, a raised + root is implied by the lowercase numeral, so the sharp prefix is removed. - Does nothing for major and passes in the original Figure Tuple unchanged: + Take for instance, the figure tuple of a B-natural in C minor: - >>> ft1 = roman.FigureTuple(aboveBass=6, alter=-1, prefix='b') + >>> ft_b_in_c_minor = roman.FigureTuple.fromPitchAndReference( + ... pitch.Pitch('B5'), + ... key.Key('c'), + ... pitch.Pitch('C3'), + ... ) + + Because B-natural is raised compared to the B-flat of the natural + minor scale, it has an alter of 1.0 and a prefix of '#' + + >>> ft_b_in_c_minor + FigureTuple(degFromRefPitch=7, alter=1.0, prefix='#') + + When used as the root of viio or viio6, for the purposes of + labeling as a Roman numeral (where QUALITY of chord determines + scale degrees), the Roman numeral needs no alter or prefix: + + >>> roman.correctRNAlterationForMinor(ft_b_in_c_minor, key.Key('c')) + FigureTuple(degFromRefPitch=7, alter=0.0, prefix='') + + But when the chord has a major third (and thus an uppercase numeral), + pass `chordHasMajorThird=True` so that the sharp is kept on pitches which + might otherwise lose them. For instance, when generating a Roman numeral from + a figure like (uppercase) VI or VII in minor, the figure generally refers to the chord + on the *lowered* (natural minor) degree, so + if we want to show a major chord on the *raised* degree + the figure needs a sharp to distinguish it. Take, for instance, + a B-natural in C minor that will be the root of a B-major triad + (B-D#-F# -- strange but possible). + In this case we want to retain the sharp on #VII. + + >>> roman.correctRNAlterationForMinor(ft_b_in_c_minor, key.Key('c'), chordHasMajorThird=True) + FigureTuple(degFromRefPitch=7, alter=1.0, prefix='#') + + This function does nothing to FigureTuples for major keys: + + >>> ft1 = roman.FigureTuple(degFromRefPitch=6, alter=-1, prefix='b') >>> ft2 = roman.correctRNAlterationForMinor(ft1, key.Key('C')) >>> ft2 - FigureTuple(aboveBass=6, alter=-1, prefix='b') + FigureTuple(degFromRefPitch=6, alter=-1, prefix='b') >>> ft1 is ft2 True - Does nothing for steps other than 6 or 7: + The function also does nothing to FigureTuples on degFromRefPitch + other than 6 or 7 (typically used to filter out chords not built on ^6 or ^7). - >>> ft3 = roman.FigureTuple(aboveBass=4, alter=-1, prefix='b') + >>> ft3 = roman.FigureTuple(degFromRefPitch=4, alter=-1, prefix='b') >>> ft4 = roman.correctRNAlterationForMinor(ft3, key.Key('c')) >>> ft4 - FigureTuple(aboveBass=4, alter=-1, prefix='b') + FigureTuple(degFromRefPitch=4, alter=-1, prefix='b') >>> ft3 is ft4 True + + * Changed in v11: the keyword-only argument `chordHasMajorThird` was + added, so that major-quality chords keep their sharp prefix on raised + ^6 and ^7 in minor (issue #1349). ''' + # Maintenance note: this is one of three places that implement the + # "what does an accidental mean on ^6/^7 in minor, given chord quality" + # convention, each with its own rules. The other two are the prefix + # suppression for triads in _postFigureFromChordAndKey (generation of the + # upper figures) and RomanNumeral._adjustMinorVIandVIIByQuality (parsing, + # in the reverse direction). If one changes, the others need to be checked. if keyObj.mode != 'minor': return figureTuple - if figureTuple.aboveBass not in (6, 7): + if figureTuple.degFromRefPitch not in (6, 7): + return figureTuple + if chordHasMajorThird and figureTuple.alter >= 1.0: + # Keep the sharp(s): plain VI or VII in minor means the chord on the + # lowered (natural minor) degree under both the QUALITY and CAUTIONARY + # conventions, so stripping the prefix would move the root down a + # semitone once the figure is parsed again (issue #1349). return figureTuple alter = figureTuple.alter rootAlterationString = figureTuple.prefix if alter == 1.0: - alter = 0 + alter = 0.0 rootAlterationString = '' elif alter == 0.0: - alter = 0 # NB! does not change! + # alter does not change, just prefix rootAlterationString = 'b' # more exotic: elif alter > 1.0: @@ -742,7 +914,36 @@ def correctRNAlterationForMinor( elif alter < 0.0: rootAlterationString = 'b' + rootAlterationString - return FigureTuple(figureTuple.aboveBass, alter, rootAlterationString) + return FigureTuple(figureTuple.degFromRefPitch, alter, rootAlterationString) + + +def _isDiatonicToKey(chordObj: chord.Chord, keyObj: key.Key) -> bool: + ''' + True if every pitch name in the chord is in the key's scale. + In minor, the raised sixth and seventh are in the scale too. + + AI-assisted (Claude). + + >>> roman._isDiatonicToKey(chord.Chord('F4 A4 C5'), key.Key('c')) + True + >>> roman._isDiatonicToKey(chord.Chord('F4 A4 C5'), key.Key('f')) + False + ''' + # keyObj.pitches is slow; the key signature gives the same scale + signatureAlters = {p.step: p.alter for p in keyObj.alteredPitches} + outsideScale = [p for p in chordObj.pitches + if p.alter != signatureAlters.get(p.step, 0)] + if not outsideScale: + return True + if keyObj.mode != 'minor': + return False + stepOrder = 'CDEFGAB' + tonicIndex = stepOrder.index(keyObj.tonic.step) + sixthAndSeventhSteps = (stepOrder[(tonicIndex + 5) % 7], + stepOrder[(tonicIndex + 6) % 7]) + return all(p.step in sixthAndSeventhSteps + and p.alter == signatureAlters.get(p.step, 0) + 1 + for p in outsideScale) def romanNumeralFromChord( @@ -922,6 +1123,24 @@ def romanNumeralFromChord( >>> romanNumeral11 + A (highly unusual) major triad on the raised sixth or seventh degree + of a minor key keeps its sharp, so that it is distinguished from the major triad on the lowered + (natural minor) degree, both in the figure and in + :attr:`~music21.roman.RomanNumeral.romanNumeral`: + + >>> majorOnRaisedSix = roman.romanNumeralFromChord( + ... chord.Chord('A3 C#4 E4'), key.Key('c')) + >>> majorOnRaisedSix + + >>> majorOnRaisedSix.romanNumeral + '#VI' + >>> majorOnLoweredSix = roman.romanNumeralFromChord( + ... chord.Chord('A-3 C4 E-4'), key.Key('c')) + >>> majorOnLoweredSix + + >>> majorOnLoweredSix.romanNumeral + 'VI' + >>> roman.romanNumeralFromChord(chord.Chord('A3 C4 E-4 G4'), key.Key('c')) @@ -952,7 +1171,8 @@ def romanNumeralFromChord( * the chord is a major triad or dominant seventh - * the chord is not diatonic to the primary key (i.e., chromatically altered) + * the chord is not diatonic to the primary key (i.e., chromatically altered; + in minor, the raised sixth and seventh count as diatonic) * the root of secondary key is diatonic to the primary key. @@ -1046,6 +1266,9 @@ def romanNumeralFromChord( iv6, V, I. This kind of context-sensitivity is not currently included. + * Changed in v11: chords with a major third built on the raised sixth or + seventh scale degrees of a minor key keep their sharp prefix. + OMIT_FROM_DOCS Note that this should be III+642 gives III+#642 (# before 6 is unnecessary) @@ -1148,15 +1371,12 @@ def romanNumeralFromChord( # 'augmented': {5: 1}, # } root = chordObj.root() - thirdType = chordObj.semitonesFromChordStep(3) - if thirdType == 4: - isMajorThird = True - else: - isMajorThird = False - + # semitonesFromChordStep(3) returns None for third-less chords, so this + # is False for them, and they get lowercase numerals below. + chordHasMajorThird = chordObj.semitonesFromChordStep(3) == 4 if keyObj is None: - if isMajorThird: + if chordHasMajorThird: rootKeyObj = _getKeyFromCache(root.name.upper()) else: rootKeyObj = _getKeyFromCache(root.name.lower()) @@ -1164,8 +1384,8 @@ def romanNumeralFromChord( elif isinstance(keyObj, str): keyObj = key.Key(keyObj) - ft = figureTupleSolo(root, keyObj, keyObj.tonic) # a FigureTuple - ft = correctRNAlterationForMinor(ft, keyObj) + ft = FigureTuple.fromPitchAndReference(root, keyObj, keyObj.tonic) + ft = correctRNAlterationForMinor(ft, keyObj, chordHasMajorThird=chordHasMajorThird) if ft.alter == 0: tonicPitch = keyObj.tonic @@ -1186,23 +1406,21 @@ def romanNumeralFromChord( alteredKeyObj = _getKeyFromCache(tonicPitchName) - stepRoman = common.toRoman(ft.aboveBass) - if isMajorThird: - pass - elif not isMajorThird: + stepRoman = common.toRoman(ft.degFromRefPitch) + if not chordHasMajorThird: stepRoman = stepRoman.lower() inversionString = _postFigureFromChordAndKey(chordObj, alteredKeyObj) rnString = ft.prefix + stepRoman + inversionString - if (not isMajorThird + if (not chordHasMajorThird and inversionString in minorSeventhSubs # only do expensive call in case it might be possible and chordObj.isSeventhOfType((0, 3, 7, 10))): rnString = ft.prefix + stepRoman + minorSeventhSubs[inversionString] - elif (not isMajorThird - and inversionString in minorMajorSeventhSubs - and chordObj.isSeventhOfType((0, 3, 7, 11))): + elif (not chordHasMajorThird + and inversionString in minorMajorSeventhSubs + and chordObj.isSeventhOfType((0, 3, 7, 11))): rnString = ft.prefix + stepRoman + minorMajorSeventhSubs[inversionString] elif (not noKeyGiven @@ -1229,6 +1447,7 @@ def romanNumeralFromChord( preferSecondaryDominants and stepRoman != 'V' # ignore if already "primary" dominant and (chordObj.isDominantSeventh() or chordObj.isMajorTriad()) + and not _isDiatonicToKey(chordObj, keyObj) ): possibleSecondaryTonic = chordObj.root().transpose('P4').name degree = keyObj.getScaleDegreeFromPitch(possibleSecondaryTonic) @@ -1245,8 +1464,9 @@ def romanNumeralFromChord( try: rn = RomanNumeral(rnString, keyObj, updatePitches=False, - # correctRNAlterationForMinor() adds cautionary - sixthMinor=Minor67Default.CAUTIONARY, seventhMinor=Minor67Default.CAUTIONARY) + # correctRNAlterationForMinor() adds cautionary + sixthMinor=Minor67Default.CAUTIONARY, + seventhMinor=Minor67Default.CAUTIONARY) except fbNotation.ModifierException as strerror: # pragma: no cover raise RomanNumeralException( 'Could not parse ' @@ -1444,7 +1664,7 @@ class RomanNumeral(harmony.Harmony): >>> V.scaleDegree 5 - Default key is C Major + The default key is C Major >>> for p in V.pitches: ... p @@ -2199,7 +2419,6 @@ class RomanNumeral(harmony.Harmony): >>> roman.RomanNumeral('II', 'C', caseMatters=False).impliedQuality '' - ''', 'impliedScale': ''' If no key or scale is passed in as the second object, then @@ -2422,6 +2641,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 @@ -2828,7 +3050,6 @@ def _parseOmittedSteps(self, workingFigure: str) -> str: '13b3' >>> rn.omittedSteps [4, 2, 7] - ''' omittedSteps = [] match = self._omittedStepsRegex.search(workingFigure) @@ -2882,7 +3103,6 @@ def _parseBracketedAlterations(self, workingFigure: str) -> str: '7' >>> rn.bracketedAlterations [('#', 5), ('b', 3)] - ''' matches = self._bracketedAlterationRegex.finditer(workingFigure) for m in matches: @@ -3021,7 +3241,7 @@ def _parseRNAloneAmidstAug6( and aug6type != 'It' and workingFigure[0] == '6' and (len(workingFigure) < 2 - or not workingFigure[1].isdigit())): + or not workingFigure[1].isdigit())): # Fr6 => Fr43 workingFigure = self._aug6defaultInversions[aug6type] + workingFigure[1:] @@ -3036,8 +3256,10 @@ def _parseRNAloneAmidstAug6( if t.TYPE_CHECKING: assert romanNormalMatch is not None romanNumeralAlone = romanNormalMatch.group(1) + workingFigure = workingFigure[romanNormalMatch.end():] + if workingFigure[:1] in ('I', 'V', 'X', 'i', 'v'): + raise RomanNumeralException(f'Invalid figure: {self.figure}') self.scaleDegree = common.fromRoman(romanNumeralAlone) - workingFigure = self._romanNumeralAloneRegex.sub('', workingFigure) self.romanNumeralAlone = romanNumeralAlone return workingFigure, useScale @@ -3220,18 +3442,15 @@ def _updatePitches(self) -> None: for j in range(numberNotes): i = numberNotes - j - 1 thisScaleDegree = (bassScaleDegree - + t.cast(int, self.figuresNotationObj.numbers[i]) - - 1) + + t.cast(int, self.figuresNotationObj.numbers[i]) + - 1) newPitch = t.cast(pitch.Pitch, useScale.pitchFromDegree( thisScaleDegree, direction=scale.Direction.ASCENDING)) pitchName = self.figuresNotationObj.modifiers[i].modifyPitchName(newPitch.name) newNewPitch = pitch.Pitch(pitchName) - if newPitch.octave is not None: - newNewPitch.octave = newPitch.octave - else: - newNewPitch.octave = defaults.pitchOctave + newNewPitch.octave = newPitch.octave if newNewPitch.ps < lastPitch.ps: - newNewPitch.octave += 1 # type: ignore + newNewPitch.octave += 1 pitches.append(newNewPitch) lastPitch = newNewPitch @@ -3290,13 +3509,13 @@ def _updatePitches(self) -> None: addedPitch.accidental = pitch.Accidental(alteration) while addedPitch.ps < bassPitch.ps: - addedPitch.octave = addedPitch.implicitOctave + 1 + addedPitch.octave += 1 if (addedPitch.ps == bassPitch.ps and addedPitch.diatonicNoteNum < bassPitch.diatonicNoteNum): # RN('IV[add#7]', 'C') would otherwise result # in E#4 as bass, not E#5 as highest note. - addedPitch.octave = addedPitch.implicitOctave + 1 + addedPitch.octave += 1 if addedPitch not in self.pitches: self.add(addedPitch) @@ -3334,17 +3553,22 @@ def transpose(self, value, *, inPlace=False) -> t.Self|None: @property def romanNumeral(self) -> str: ''' - Read-only property that returns either the romanNumeralAlone (e.g. just - II) or the frontAlterationAccidental.modifier (with 'b' for '-') + romanNumeralAlone - (e.g. #II, bII) + Read-only property that returns the roman numeral stripped of figures (7, 65, etc.) with + any frontAlterationAccidental.modifier with 'b' for '-' (but with some caveats + for minor that make it easier to for other parsers, see below). >>> rn = roman.RomanNumeral('#II7') >>> rn.romanNumeral '#II' - >>> rn = roman.RomanNumeral('Ger+6') + Diminished and augmented symbols are removed. + + >>> rn = roman.RomanNumeral('viio') >>> rn.romanNumeral - 'Ger' + 'vii' + + Double flats can be written as either `bb` or `--` in instantiation, + but will show `bb` on output, and applied chord's tonicized goals are removed. >>> rn = roman.RomanNumeral('bbII/V') >>> rn.romanNumeral @@ -3353,6 +3577,51 @@ def romanNumeral(self) -> str: >>> rn.romanNumeral 'bbII' + Augmented-sixth chord symbols (German, French, etc.) are considered Roman numerals. + This behavior may change at a future date. + + >>> rn = roman.RomanNumeral('Ger+6') + >>> rn.romanNumeral + 'Ger' + + + The accidental in `.romanNumeral` is the accidental needed compared to the standard scale + degree in major or *natural minor*. It will resolve to the same root + only if sixthMinor=FLAT and seventhMinor=FLAT are both used. + + For instance, in c minor, with natural-minor diatonic 6̂ = A♭, + RomanNumeral('vi', 'c') has root A♮ — a raised sixth degree — interpreted using QUALITY + since this is the only normally used minor triad on the sixth scale degree. + However because A♮ is raised compared to the natural minor, .romanNumeral returns '#vi' + even though no # was used in the construction. + + >>> a_min = roman.RomanNumeral('vi', 'c') + >>> ' '.join(p.name for p in a_min.pitches) + 'A C E' + >>> a_min.romanNumeral + '#vi' + + On the other hand, RomanNumeral('bVI', 'c', sixthMinor=CAUTIONARY) has + root A♭ which is part of the natural minor scale, so .romanNumeral returns + 'VI' even though a b *was* written in the original. + + >>> ab_maj = roman.RomanNumeral('bVI', 'c', + ... sixthMinor=roman.Minor67Default.CAUTIONARY) + >>> ' '.join(p.name for p in ab_maj.pitches) + 'A- C E-' + >>> ab_maj.romanNumeral + 'VI' + + To get back the notation as it was originally written, use + :attr:`~music21.roman.RomanNumeral.figure` + (if you also want any inversion marks, etc.) or combine + `frontAlterationString` with `romanNumeralAlone`: + + >>> a_min.frontAlterationString + a_min.romanNumeralAlone + 'vi' + >>> ab_maj.frontAlterationString + ab_maj.romanNumeralAlone + 'bVI' + OMIT_FROM_DOCS >>> rn.romanNumeral = 'V' @@ -3722,7 +3991,6 @@ def isNeapolitan(self, >>> rn = roman.RomanNumeral('N53') >>> rn.isNeapolitan(require1stInversion=False) True - ''' if self.scaleDegree != 2: return False @@ -4089,9 +4357,8 @@ def testYieldRemoveA(self): c.remove(e) def testScaleDegreesA(self): - from music21 import roman k = key.Key('f#') # 3-sharps minor - rn = roman.RomanNumeral('V', k) + rn = RomanNumeral('V', k) self.assertEqual(str(rn.key), 'f# minor') self.assertEqual( str(rn.pitches), @@ -4105,8 +4372,7 @@ def testScaleDegreesA(self): ) def testNeapolitanAndHalfDiminished(self): - from music21 import roman - alteredChordHalfDim3rdInv = roman.RomanNumeral( + alteredChordHalfDim3rdInv = RomanNumeral( 'bii/o42', scale.MajorScale('F')) self.assertEqual( [str(p) for p in alteredChordHalfDim3rdInv.pitches], @@ -4118,167 +4384,200 @@ def testNeapolitanAndHalfDiminished(self): self.assertEqual(cn, 'half-diminished seventh chord') def testOmittedFifth(self): - from music21 import roman c = chord.Chord('A3 E-4 G-4') k = key.Key('b-') - rnDim7 = roman.romanNumeralFromChord(c, k) + rnDim7 = romanNumeralFromChord(c, k) self.assertEqual(rnDim7.figure, 'viio7') def testAllFormsOfVII(self): - from music21 import roman - def p(c): return ' '.join([x.nameWithOctave for x in c.pitches]) k = key.Key('c') - rn = roman.RomanNumeral('viio', k) + rn = RomanNumeral('viio', k) self.assertEqual(p(rn), 'B4 D5 F5') - rn = roman.RomanNumeral('viio6', k) + rn = RomanNumeral('viio6', k) self.assertEqual(p(rn), 'D4 F4 B4') - rn = roman.RomanNumeral('viio64', k) + rn = RomanNumeral('viio64', k) self.assertEqual(p(rn), 'F4 B4 D5') - rn = roman.RomanNumeral('vii', k) + rn = RomanNumeral('vii', k) self.assertEqual(p(rn), 'B4 D5 F#5') - rn = roman.RomanNumeral('vii6', k) + rn = RomanNumeral('vii6', k) self.assertEqual(p(rn), 'D4 F#4 B4') - rn = roman.RomanNumeral('vii64', k) + rn = RomanNumeral('vii64', k) self.assertEqual(p(rn), 'F#4 B4 D5') - rn = roman.RomanNumeral('viio7', k) + rn = RomanNumeral('viio7', k) self.assertEqual(p(rn), 'B4 D5 F5 A-5') - rn = roman.RomanNumeral('viio65', k) + rn = RomanNumeral('viio65', k) self.assertEqual(p(rn), 'D4 F4 A-4 B4') - rn = roman.RomanNumeral('viio43', k) + rn = RomanNumeral('viio43', k) self.assertEqual(p(rn), 'F4 A-4 B4 D5') - rn = roman.RomanNumeral('viio42', k) + rn = RomanNumeral('viio42', k) self.assertEqual(p(rn), 'A-4 B4 D5 F5') - rn = roman.RomanNumeral('vii/o7', k) + rn = RomanNumeral('vii/o7', k) self.assertEqual(p(rn), 'B4 D5 F5 A5') # noinspection SpellCheckingInspection - rn = roman.RomanNumeral('viiø65', k) + rn = RomanNumeral('viiø65', k) self.assertEqual(p(rn), 'D4 F4 A4 B4') # noinspection SpellCheckingInspection - rn = roman.RomanNumeral('viiø43', k) + rn = RomanNumeral('viiø43', k) self.assertEqual(p(rn), 'F4 A4 B4 D5') - rn = roman.RomanNumeral('vii/o42', k) + rn = RomanNumeral('vii/o42', k) self.assertEqual(p(rn), 'A4 B4 D5 F5') - rn = roman.RomanNumeral('VII', k) + rn = RomanNumeral('VII', k) self.assertEqual(p(rn), 'B-4 D5 F5') - rn = roman.RomanNumeral('VII6', k) + rn = RomanNumeral('VII6', k) self.assertEqual(p(rn), 'D4 F4 B-4') - rn = roman.RomanNumeral('VII64', k) + rn = RomanNumeral('VII64', k) self.assertEqual(p(rn), 'F4 B-4 D5') - rn = roman.RomanNumeral('bVII', k) + rn = RomanNumeral('bVII', k) self.assertEqual(p(rn), 'B--4 D-5 F-5') - rn = roman.RomanNumeral('bVII6', k) + rn = RomanNumeral('bVII6', k) self.assertEqual(p(rn), 'D-4 F-4 B--4') - rn = roman.RomanNumeral('bVII64', k) + rn = RomanNumeral('bVII64', k) self.assertEqual(p(rn), 'F-4 B--4 D-5') - rn = roman.RomanNumeral('bvii', k) + rn = RomanNumeral('bvii', k) self.assertEqual(p(rn), 'B-4 D-5 F5') - rn = roman.RomanNumeral('bvii6', k) + rn = RomanNumeral('bvii6', k) self.assertEqual(p(rn), 'D-4 F4 B-4') - rn = roman.RomanNumeral('bvii64', k) + rn = RomanNumeral('bvii64', k) self.assertEqual(p(rn), 'F4 B-4 D-5') - rn = roman.RomanNumeral('bviio', k) + rn = RomanNumeral('bviio', k) self.assertEqual(p(rn), 'B-4 D-5 F-5') - rn = roman.RomanNumeral('bviio6', k) + rn = RomanNumeral('bviio6', k) self.assertEqual(p(rn), 'D-4 F-4 B-4') - rn = roman.RomanNumeral('bviio64', k) + rn = RomanNumeral('bviio64', k) self.assertEqual(p(rn), 'F-4 B-4 D-5') - rn = roman.RomanNumeral('#VII', k) + rn = RomanNumeral('#VII', k) self.assertEqual(p(rn), 'B4 D#5 F#5') - rn = roman.RomanNumeral('#vii', k) + rn = RomanNumeral('#vii', k) self.assertEqual(p(rn), 'B#4 D#5 F##5') - rn = roman.RomanNumeral('VII+', k) + rn = RomanNumeral('VII+', k) self.assertEqual(p(rn), 'B-4 D5 F#5') def testAllFormsOfVI(self): - from music21 import roman - def p(c): return ' '.join([x.nameWithOctave for x in c.pitches]) k = key.Key('c') - rn = roman.RomanNumeral('vio', k) + rn = RomanNumeral('vio', k) self.assertEqual(p(rn), 'A4 C5 E-5') - rn = roman.RomanNumeral('vio6', k) + rn = RomanNumeral('vio6', k) self.assertEqual(p(rn), 'C4 E-4 A4') - rn = roman.RomanNumeral('vio64', k) + rn = RomanNumeral('vio64', k) self.assertEqual(p(rn), 'E-4 A4 C5') - rn = roman.RomanNumeral('vi', k) + rn = RomanNumeral('vi', k) self.assertEqual(p(rn), 'A4 C5 E5') - rn = roman.RomanNumeral('vi6', k) + rn = RomanNumeral('vi6', k) self.assertEqual(p(rn), 'C4 E4 A4') - rn = roman.RomanNumeral('vi64', k) + rn = RomanNumeral('vi64', k) self.assertEqual(p(rn), 'E4 A4 C5') - rn = roman.RomanNumeral('vio7', k) + rn = RomanNumeral('vio7', k) self.assertEqual(p(rn), 'A4 C5 E-5 G-5') - rn = roman.RomanNumeral('vio65', k) + rn = RomanNumeral('vio65', k) self.assertEqual(p(rn), 'C4 E-4 G-4 A4') - rn = roman.RomanNumeral('vio43', k) + rn = RomanNumeral('vio43', k) self.assertEqual(p(rn), 'E-4 G-4 A4 C5') - rn = roman.RomanNumeral('vio42', k) + rn = RomanNumeral('vio42', k) self.assertEqual(p(rn), 'G-4 A4 C5 E-5') - rn = roman.RomanNumeral('viø7', k) + rn = RomanNumeral('viø7', k) self.assertEqual(p(rn), 'A4 C5 E-5 G5') - rn = roman.RomanNumeral('vi/o65', k) + rn = RomanNumeral('vi/o65', k) self.assertEqual(p(rn), 'C4 E-4 G4 A4') - rn = roman.RomanNumeral('vi/o43', k) + rn = RomanNumeral('vi/o43', k) self.assertEqual(p(rn), 'E-4 G4 A4 C5') - rn = roman.RomanNumeral('viø42', k) + rn = RomanNumeral('viø42', k) self.assertEqual(p(rn), 'G4 A4 C5 E-5') - rn = roman.RomanNumeral('VI', k) + rn = RomanNumeral('VI', k) self.assertEqual(p(rn), 'A-4 C5 E-5') - rn = roman.RomanNumeral('VI6', k) + rn = RomanNumeral('VI6', k) self.assertEqual(p(rn), 'C4 E-4 A-4') - rn = roman.RomanNumeral('VI64', k) + rn = RomanNumeral('VI64', k) self.assertEqual(p(rn), 'E-4 A-4 C5') - rn = roman.RomanNumeral('bVI', k) + rn = RomanNumeral('bVI', k) self.assertEqual(p(rn), 'A--4 C-5 E--5') - rn = roman.RomanNumeral('bVI6', k) + rn = RomanNumeral('bVI6', k) self.assertEqual(p(rn), 'C-4 E--4 A--4') - rn = roman.RomanNumeral('bVI64', k) + rn = RomanNumeral('bVI64', k) self.assertEqual(p(rn), 'E--4 A--4 C-5') - rn = roman.RomanNumeral('bvi', k) + rn = RomanNumeral('bvi', k) self.assertEqual(p(rn), 'A-4 C-5 E-5') - rn = roman.RomanNumeral('bvi6', k) + rn = RomanNumeral('bvi6', k) self.assertEqual(p(rn), 'C-4 E-4 A-4') - rn = roman.RomanNumeral('bvi64', k) + rn = RomanNumeral('bvi64', k) self.assertEqual(p(rn), 'E-4 A-4 C-5') - rn = roman.RomanNumeral('bvio', k) + rn = RomanNumeral('bvio', k) self.assertEqual(p(rn), 'A-4 C-5 E--5') - rn = roman.RomanNumeral('bvio6', k) + rn = RomanNumeral('bvio6', k) self.assertEqual(p(rn), 'C-4 E--4 A-4') - rn = roman.RomanNumeral('bvio64', k) + rn = RomanNumeral('bvio64', k) self.assertEqual(p(rn), 'E--4 A-4 C-5') - rn = roman.RomanNumeral('#VI', k) + rn = RomanNumeral('#VI', k) self.assertEqual(p(rn), 'A4 C#5 E5') - rn = roman.RomanNumeral('#vi', k) + rn = RomanNumeral('#vi', k) self.assertEqual(p(rn), 'A#4 C#5 E#5') - rn = roman.RomanNumeral('VI+', k) + rn = RomanNumeral('VI+', k) self.assertEqual(p(rn), 'A-4 C5 E5') - def testAugmented(self): - from music21 import roman + def testRomanNumeralFromChordRaised67(self): + ''' + Major-quality chords on the raised sixth and seventh scale degrees of + a minor key keep their sharp prefix, so their figures round-trip and + `.romanNumeral` distinguishes them from the chords on the lowered + (natural minor) degrees. + https://github.com/cuthbertLab/music21/issues/1349 + + This test was AI-assisted (Claude). + ''' + k = key.Key('c') + for pitchNames, expectedFigure, expectedRN in [ + (('A-4', 'C5', 'E-5'), 'bVI', 'VI'), + (('A4', 'C#5', 'E5'), '#VI', '#VI'), + (('C#4', 'E4', 'A4'), '#VI6', '#VI'), + (('E4', 'A4', 'C#5'), '#VI64', '#VI'), + (('B-4', 'D5', 'F5'), 'bVII', 'VII'), + (('B4', 'D#5', 'F#5'), '#VII', '#VII'), + # unchanged: lowercase quality already implies the raised degree + (('A4', 'C5', 'E5'), 'vi', '#vi'), + (('B4', 'D5', 'F5'), 'viio', '#vii'), + ]: + with self.subTest(pitches=pitchNames): + c = chord.Chord(pitchNames) + rn = romanNumeralFromChord(c, k) + self.assertEqual(rn.figure, expectedFigure) + self.assertEqual(rn.romanNumeral, expectedRN) + # the figure must round-trip to the same pitch names under + # the convention that romanNumeralFromChord itself uses. + roundTrip = RomanNumeral( + rn.figure, + k, + sixthMinor=Minor67Default.CAUTIONARY, + seventhMinor=Minor67Default.CAUTIONARY, + ) + self.assertEqual( + [p_.name for p_ in roundTrip.pitches], + [p_.name for p_ in c.pitches], + ) + def testAugmented(self): def p(c): return ' '.join([x.nameWithOctave for x in c.pitches]) @@ -4288,7 +4587,7 @@ def test_numeral(country, figure_list, result, key_in='a'): for kStr in (key_in, key_in.upper()): key_obj = key.Key(kStr) rn_str = country + with_plus + figure - rn = roman.RomanNumeral(rn_str, key_obj) + rn = RomanNumeral(rn_str, key_obj) self.assertEqual(p(rn), result) @@ -4355,14 +4654,13 @@ def testSetFigureAgain(self): self.assertEqual(sharp_four.pitches, pitches_before) def testZeroForDiminished(self): - from music21 import roman - rn = roman.RomanNumeral('vii07', 'c') + rn = RomanNumeral('vii07', 'c') self.assertEqual([p.name for p in rn.pitches], ['B', 'D', 'F', 'A-']) - rn = roman.RomanNumeral('vii/07', 'c') + rn = RomanNumeral('vii/07', 'c') self.assertEqual([p.name for p in rn.pitches], ['B', 'D', 'F', 'A']) # However, when there is a '10' somewhere in the figure, don't replace # the 0 (this occurs in DCML corpora) - rn = roman.RomanNumeral('V7[add10]', 'c') + rn = RomanNumeral('V7[add10]', 'c') self.assertEqual([p.name for p in rn.pitches], ['G', 'B-', 'B', 'D', 'F']) def testIII7(self): @@ -4630,6 +4928,37 @@ 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') + + def testPreferSecondaryDominantsSkipsDiatonicChords(self): + # https://github.com/cuthbertLab/music21/issues/2053 + # in minor, the raised sixth and seventh count as diatonic + for chordString, keyString, plainFigure, preferredFigure in ( + ('C4 E4 G4', 'C', 'I', 'I'), + ('E-4 G4 B-4', 'c', 'III', 'III'), + ('F4 A4 C5', 'c', 'IV', 'IV'), + ('D4 F#4 A4', 'C', 'II', 'V/V'), + ('F4 A4 C5', 'f', 'I', 'V/iv'), + ): + with self.subTest(chord=chordString, key=keyString): + cObj = chord.Chord(chordString) + plain = romanNumeralFromChord(cObj, keyString) + self.assertEqual(plain.figure, plainFigure) + preferred = romanNumeralFromChord(cObj, + keyString, + preferSecondaryDominants=True) + self.assertEqual(preferred.figure, preferredFigure) + + def testNumeralsPastSevenAreNotFigures(self): + for fig in ('VIII', 'viii7', 'IX', 'bVIII', 'V/VIII', 'Vv'): + with self.subTest(figure=fig): + with self.assertRaises(RomanNumeralException): + RomanNumeral(fig, 'C') + class TestExternal(unittest.TestCase): show = True diff --git a/music21/romanText/clercqTemperley.py b/music21/romanText/clercqTemperley.py index b4917e1d4f..0ae072c9d6 100644 --- a/music21/romanText/clercqTemperley.py +++ b/music21/romanText/clercqTemperley.py @@ -323,7 +323,6 @@ class CTSong(prebase.ProtoM21Object): Fadeout: I . . V | I . . V | I . . V | Co: [2/4] I | [4/4] . . . V | I . . V | $Fadeout S: [G] $In $Vr $Ch $In*2 $Ch $Vr2 $Ch $Ch $Co - """ _DOC_ORDER = ['text', 'toPart', 'title', 'homeTimeSig', 'homeKey', 'comments', 'rules'] _DOC_ATTR: dict[str, str] = { @@ -393,6 +392,7 @@ def parse(self, textFile: str|pathlib.Path) -> None: >>> s.lines[-1] 'S: [G] $In $Vr $Vr $Ch $VP $Vr $Ch2' ''' + # if this test changes, update ConverterClercqTemperley.parseData too if isinstance(textFile, str) and '|' in textFile and 'S:' in textFile: lines = textFile.split('\n') else: @@ -609,8 +609,7 @@ def toPart( m.number = i + 1 partObj.append(measures) - partObj.insert(0, metadata.Metadata()) - partObj.metadata.title = self.title + partObj.insert(0, metadata.Metadata(title=self.title)) self._partObj = partObj return partObj @@ -672,15 +671,17 @@ def _reprInternal(self) -> str: return f'text={self.text!r}' # -------------------------------------------------------------------------- - def _getParent(self) -> CTSong|None: + @property + def parent(self) -> CTSong|None: + r''' + A reference to the CTSong object housing the CTRule if any. + ''' return common.unwrapWeakref(self._parent) - def _setParent(self, parent: CTSong) -> None: + @parent.setter + def parent(self, parent: CTSong) -> None: self._parent = common.wrapWeakref(parent) - parent = property(_getParent, _setParent, doc=r''' - A reference to the CTSong object housing the CTRule if any. - ''') # -------------------------------------------------------------------------- def expand( @@ -996,7 +997,6 @@ def fixupChordAtom(self, atom: str) -> str: 'vii/o7' >>> s.fixupChordAtom('iia') 'ii+' - ''' if 'x' in atom: atom = atom.replace('x', 'o') @@ -1007,10 +1007,18 @@ def fixupChordAtom(self, atom: str) -> str: return atom # -------------------------------------------------------------------------- - def _setMusicText(self, value: str) -> None: - self._musicText = str(value) + @property + def musicText(self) -> str: + ''' + Gets just the music text of the CTRule, excluding the left hand side and comments - def _getMusicText(self) -> str: + >>> rs = 'In: $BP*3 I IV | I | $BP*3 I IV | I | R |*4 I |*4 % This is a comment' + >>> s = romanText.clercqTemperley.CTRule(rs) + >>> s.text + 'In: $BP*3 I IV | I | $BP*3 I IV | I | R |*4 I |*4 % This is a comment' + >>> s.musicText + '$BP*3 I IV | I | $BP*3 I IV | I | R |*4 I |*4' + ''' if self._musicText: return self._musicText @@ -1025,16 +1033,9 @@ def _getMusicText(self) -> str: self._musicText = text.strip() return self._musicText - musicText = property(_getMusicText, _setMusicText, doc=''' - Gets just the music text of the CTRule, excluding the left hand side and comments - - >>> rs = 'In: $BP*3 I IV | I | $BP*3 I IV | I | R |*4 I |*4 % This is a comment' - >>> s = romanText.clercqTemperley.CTRule(rs) - >>> s.text - 'In: $BP*3 I IV | I | $BP*3 I IV | I | R |*4 I |*4 % This is a comment' - >>> s.musicText - '$BP*3 I IV | I | $BP*3 I IV | I | R |*4 I |*4' - ''') + @musicText.setter + def musicText(self, value: str) -> None: + self._musicText = str(value) @property def comment(self) -> str|None: @@ -1050,7 +1051,18 @@ def comment(self) -> str|None: return self.text[self.text.index('%') + 1:].strip() return None - def _getLHS(self) -> str: + @property + def LHS(self) -> str: + ''' + Get the LHS (Left Hand Side) of the CTRule. + If not specified explicitly but CTtext present, searches + first characters up until ':' for rule and returns string) + + >>> rs = 'In: $BP*3 I IV | R |*4 I |*4 % This is a comment' + >>> s = romanText.clercqTemperley.CTRule(rs) + >>> s.LHS + 'In' + ''' if self._LHS: return self._LHS @@ -1066,20 +1078,10 @@ def _getLHS(self) -> str: else: return '' - def _setLHS(self, value: str) -> None: + @LHS.setter + def LHS(self, value: str) -> None: self._LHS = str(value) - LHS = property(_getLHS, _setLHS, doc=''' - Get the LHS (Left Hand Side) of the CTRule. - If not specified explicitly but CTtext present, searches - first characters up until ':' for rule and returns string) - - >>> rs = 'In: $BP*3 I IV | R |*4 I |*4 % This is a comment' - >>> s = romanText.clercqTemperley.CTRule(rs) - >>> s.LHS - 'In' - ''') - @property def sectionName(self) -> str: ''' @@ -1138,8 +1140,7 @@ class TestExternal(unittest.TestCase): show = True def testB(self) -> None: - from music21.romanText import clercqTemperley - s = clercqTemperley.CTSong(BlitzkriegBopCT) + s = CTSong(BlitzkriegBopCT) partObj = s.toPart() if self.show: partObj.show() diff --git a/music21/romanText/rtObjects.py b/music21/romanText/rtObjects.py index c373ed4679..a227876fc2 100644 --- a/music21/romanText/rtObjects.py +++ b/music21/romanText/rtObjects.py @@ -512,7 +512,6 @@ class RTMeasure(RTToken): ['a'] >>> rtm.isMeasure() True - ''' def __init__(self, src: str = ''): @@ -911,7 +910,6 @@ class RTAnalyticKey(RTKeyTypeAtom): >>> bMinor.getKey() - ''' footerStrip = ':' diff --git a/music21/romanText/translate.py b/music21/romanText/translate.py index e029534819..4fcc3534e3 100644 --- a/music21/romanText/translate.py +++ b/music21/romanText/translate.py @@ -5,7 +5,7 @@ # Authors: Christopher Ariza # Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2011-2023 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2011-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -129,6 +129,7 @@ from music21 import base from music21 import common from music21.common.types import OffsetQL +from music21 import defaults from music21 import environment from music21 import exceptions21 from music21 import harmony @@ -548,7 +549,7 @@ def translateMeasureLineToken(self, measureLineToken: rtObjects.RTMeasure) -> No ''' p = self.p skipsPriorMeasures = ((measureLineToken.number[0] > self.lastMeasureNumber + 1) - and (self.previousRn is not None)) + and (self.previousRn is not None)) isSingleMeasureCopy = (len(measureLineToken.number) == 1 and measureLineToken.isCopyDefinition) isMultipleMeasureCopy = (len(measureLineToken.number) > 1) @@ -604,6 +605,9 @@ def fillToMeasureToken(self, measureToken: rtObjects.RTMeasure) -> None: implied by `measureToken`. ''' p = self.p + if len(p) + measureToken.number[0] - self.lastMeasureNumber > defaults.maxMeasures: + raise RomanTextTranslateException( + f'cannot make more than {defaults.maxMeasures} measures') for i in range(self.lastMeasureNumber + 1, measureToken.number[0]): mFill = stream.Measure() mFill.number = i @@ -1575,7 +1579,6 @@ def testNoChord(self): def testUnprocessed(self): from music21 import converter - from music21.romanText import translate src = '''Note: Hello m1 G: IV || b3 d: III b4 NC varM1 I @@ -1583,7 +1586,7 @@ def testUnprocessed(self): ''' s = converter.parse(src, format='romantext') p = s.parts[0] - unprocessedElements = p[translate.RomanTextUnprocessedMetadata] + unprocessedElements = p[RomanTextUnprocessedMetadata] self.assertEqual(len(unprocessedElements), 3) note1, var1, note2 = unprocessedElements self.assertEqual(note1.tag, 'Note') @@ -1594,19 +1597,18 @@ def testUnprocessed(self): self.assertIn(' I', var1.data) def testUnprocessedWithAnacrusis(self): - from music21.romanText import translate src = ''' Time Signature: 4/4 m0 b4 f: i Note: Internal Note field after anacrusis. m1 V ''' - s = translate.romanTextToStreamScore(src) + s = romanTextToStreamScore(src) p = s.parts[0] self.assertEqual(len(p), 3) self.assertIsInstance(p[0], stream.Measure) self.assertEqual(p[0].paddingLeft, 3.0) - self.assertIsInstance(p[1], translate.RomanTextUnprocessedMetadata) + self.assertIsInstance(p[1], RomanTextUnprocessedMetadata) self.assertEqual(p[1].offset, 0.0) self.assertEqual(p[1].data, 'Internal Note field after anacrusis.') self.assertIsInstance(p[2], stream.Measure) @@ -1891,6 +1893,10 @@ def _test_ending_contents( _test_ending_contents(third_ending, ['2c', '3c']) _test_expanded(s, 27.0) + def testMeasureNumberTooHigh(self): + with self.assertRaisesRegex(RomanTextTranslateException, 'more than 100000 measures'): + romanTextToStreamScore('Time Signature: 4/4\nm1 C: I\nm99999999 V\n') + # ------------------------------------------------------------------------------ diff --git a/music21/romanText/tsvConverter.py b/music21/romanText/tsvConverter.py index 6ec96eed58..832b8ed8b6 100644 --- a/music21/romanText/tsvConverter.py +++ b/music21/romanText/tsvConverter.py @@ -4,7 +4,7 @@ # # Authors: Mark Gotham # -# Copyright: Copyright © 2019 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2019-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -522,7 +522,6 @@ class TsvHandler: >>> out_stream = handler.toM21Stream() >>> out_stream.parts[0].measure(1)[roman.RomanNumeral][0].figure 'I' - ''' def __init__(self, tsvFile: str|pathlib.Path, dcml_version: int = 1) -> None: if dcml_version == 1: @@ -568,8 +567,13 @@ def _importTsv(self) -> list[list[str]]: with open(fileName, 'r', encoding='utf-8') as f: tsvreader = csv.reader(f, delimiter='\t', quotechar='"') # The first row is the header - self._get_heading_indices(next(tsvreader)) - return list(tsvreader) + self._get_heading_indices([cell.strip() for cell in next(tsvreader)]) + rows = [] + for row in tsvreader: + cells = [cell.strip() for cell in row] + if any(cells): + rows.append(cells) + return rows def _makeTabChord(self, row: list[str]) -> TabChordBase: ''' @@ -660,21 +664,22 @@ def prepStream(self) -> stream.Score: if self.dcml_version == 1: # This sort of metadata seems to have been removed altogether from the # v2 files - s.insert(0, metadata.Metadata()) + md = metadata.Metadata() + s.insert(0, md) firstEntry = self.chordList[0] # Any entry will do title = [] if 'op' in firstEntry.extra: - s.metadata.opusNumber = firstEntry.extra['op'] - title.append('Op' + s.metadata.opusNumber) + md.opusNumber = firstEntry.extra['op'] + title.append('Op' + firstEntry.extra['op']) if 'no' in firstEntry.extra: - s.metadata.number = firstEntry.extra['no'] - title.append('No' + s.metadata.number) + md.number = firstEntry.extra['no'] + title.append('No' + firstEntry.extra['no']) if 'mov' in firstEntry.extra: - s.metadata.movementNumber = firstEntry.extra['mov'] - title.append('Mov' + s.metadata.movementNumber) + md.movementNumber = firstEntry.extra['mov'] + title.append('Mov' + firstEntry.extra['mov']) if title: - s.metadata.title = '_'.join(title) + md.title = '_'.join(title) startingKeySig = str(self.chordList[0].global_key) ks = key.Key(startingKeySig) @@ -816,9 +821,10 @@ def _m21ToTsv_v1(self) -> list[list[str]]: thisEntry.timesig = '' else: thisEntry.timesig = ts.ratioString - thisEntry.extra['op'] = self.m21Stream.metadata.opusNumber or '' - thisEntry.extra['no'] = self.m21Stream.metadata.number or '' - thisEntry.extra['mov'] = self.m21Stream.metadata.movementNumber or '' + md = self.m21Stream.metadata + thisEntry.extra['op'] = (md.opusNumber or '') if md is not None else '' + thisEntry.extra['no'] = (md.number or '') if md is not None else '' + thisEntry.extra['mov'] = (md.movementNumber or '') if md is not None else '' thisEntry.length = thisRN.quarterLength thisEntry.global_key = global_key thisEntry.local_key = thisRN.key.tonicPitchNameWithCase @@ -1070,13 +1076,6 @@ def localKeyAsRn(local_key: key.Key, global_key: key.Key) -> str: 'i' if letter.islower() else 'I', keyOrScale=local_key ) r = roman.romanNumeralFromChord(chord.Chord(rn.pitches), keyObj=global_key) - # Temporary hack: for some reason this gives VI and VII instead of #VI and #VII *only* - # when local_key is major and global_key is minor. - # see issue at https://github.com/cuthbertLab/music21/issues/1349#issue-1327713452 - if (local_key.mode == 'major' and global_key.mode == 'minor' - and r.romanNumeral in ('VI', 'VII') - and (r.pitchClasses[0] - global_key.pitches[0].pitchClass) % 12 in (9, 11)): - return '#' + r.romanNumeral return r.romanNumeral def isMinor(test_key: str) -> bool: @@ -1140,7 +1139,6 @@ def getLocalKey(local_key: str, global_key: str, convertDCMLToM21: bool = False) >>> romanText.tsvConverter.getLocalKey('vii', 'a', convertDCMLToM21=True) 'g' - ''' if convertDCMLToM21: local_key = characterSwaps(local_key, minor=isMinor(global_key[0]), direction='DCML-m21') @@ -1376,6 +1374,36 @@ def _test_ending_contents( _test_ending_contents(first_ending, ['2a']) _test_ending_contents(second_ending, ['2b']) + def testWhitespaceRows(self) -> None: + ''' + AI-assisted (Claude) regression coverage for whitespace in DCML TSV files. + ''' + # https://github.com/cuthbertLab/music21/issues/1541 + import os + path = common.getSourceFilePath() / 'romanText' / 'tsvEg_v2_repeats.tsv' + text = path.read_text(encoding='utf-8') + clean = TsvHandler(path, dcml_version=2).toM21Stream() + expected = [rn.figure for rn in clean[roman.RomanNumeral]] + paddedLines = [] + for line in text.splitlines(): + paddedCells = [f' {cell} ' for cell in line.split('\t')] + paddedLines.append('\t'.join(paddedCells)) + padded = '\n'.join(paddedLines) + variants = { + 'blank line': text + '\n', + 'tabs only': text + '\t\t\t\t\t\n', + 'padded cells': padded + '\n\t\t\t\t\t\t\n', + } + tempFile = environment.Environment().getTempFile() + try: + for name, variant in variants.items(): + with open(tempFile, 'w', encoding='utf-8') as f: + f.write(variant) + parsed = TsvHandler(tempFile, dcml_version=2).toM21Stream() + self.assertEqual([rn.figure for rn in parsed[roman.RomanNumeral]], expected, name) + finally: + os.remove(tempFile) + # ------------------------------------------------------------------------------ diff --git a/music21/romanText/writeRoman.py b/music21/romanText/writeRoman.py index 132564fce9..58baadb691 100644 --- a/music21/romanText/writeRoman.py +++ b/music21/romanText/writeRoman.py @@ -269,7 +269,6 @@ def prepSequentialListOfLines(self) -> None: >>> testCase.combinedList[-2] 'Time Signature: 3/4' - ''' for thisMeasure in self.container.getElementsByClass(stream.Measure): @@ -388,7 +387,6 @@ def rnString(measureNumber: int|str, As these examples show, the chordString can be a Roman numeral alone (e.g. 'viio6') or one prefixed by a change of key ('G: I'). - ''' if inString: diff --git a/music21/scale/__init__.py b/music21/scale/__init__.py index 27f0af1815..c12d574e5f 100644 --- a/music21/scale/__init__.py +++ b/music21/scale/__init__.py @@ -6,7 +6,7 @@ # Michael Scott Asato Cuthbert # Jose Cabal-Ugaz # -# Copyright: Copyright © 2009-2011 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2009-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -98,7 +98,6 @@ # ------------------------- from music21 import base from music21 import common -from music21 import defaults from music21 import environment from music21 import exceptions21 from music21 import note @@ -119,13 +118,14 @@ type, # scale class str, # scale type str, # tonic name with octave + str, # stringified mode int, # degree Direction, # direction bool # equating termini ] # a dictionary mapping an abstract scale class, tonic.nameWithOctave, -# and degree to a pitchNameWithOctave. +# _abstract.mode, and degree to a pitchNameWithOctave. _pitchDegreeCache: dict[_PitchDegreeCacheKey, str] = {} # a pitch given in any of the forms that can be coerced to a pitch.Pitch @@ -250,8 +250,7 @@ def extractPitchList( seen.add(hashValue) post.append(p) for p in post: - if p.octave is None: - p.octave = defaults.pitchOctave + p.octaveIsImplicit = False return post @@ -301,6 +300,8 @@ def __init__(self, **keywords) -> None: # in most cases tonic/final of scale is step one, but not always self.tonicDegree: int = 1 # step of tonic + self.mode: t.Any = None + # declare if this scale is octave duplicating # can be used as to optimize pitch gathering self.octaveDuplicating = True @@ -388,8 +389,7 @@ def buildNetworkFromPitches( self.octaveDuplicating = False else: p = copy.deepcopy(pitchListProcessed[0]) - if p.octave is None: - p.octave = p.implicitOctave + p.octaveIsImplicit = False if pitchListProcessed[-1] > pitchListProcessed[0]: # ascending while p.ps < pitchListProcessed[-1].ps: p.octave += 1 @@ -426,10 +426,10 @@ def fixDefaultOctaveForPitchList(pitchList: list[pitch.Pitch]) -> list[pitch.Pit >>> pitchListStrs = 'a b c d e f g a'.split() >>> pitchList = [pitch.Pitch(p) for p in pitchListStrs] - Here's the problem, between `pitchList[1]` and `pitchList[2]` the `.implicitOctave` - stays the same, so the `.ps` drops: + Here's the problem, between `pitchList[1]` and `pitchList[2]` the `.octave` + stays the same (since both pitches have .octaveIsImplicit == True), so the `.ps` drops: - >>> (pitchList[1].implicitOctave, pitchList[2].implicitOctave) + >>> (pitchList[1].octave, pitchList[2].octave) (4, 4) >>> (pitchList[1].ps, pitchList[2].ps) (71.0, 60.0) @@ -438,7 +438,7 @@ def fixDefaultOctaveForPitchList(pitchList: list[pitch.Pitch]) -> list[pitch.Pit one has a .ps above the previous: >>> pl2 = scale.AbstractScale.fixDefaultOctaveForPitchList(pitchList) - >>> (pl2[1].implicitOctave, pl2[2].implicitOctave, pl2[3].implicitOctave) + >>> (pl2[1].octave, pl2[2].octave, pl2[3].octave) (4, 5, 5) >>> (pl2[1].ps, pl2[2].ps) (71.0, 72.0) @@ -452,9 +452,9 @@ def fixDefaultOctaveForPitchList(pitchList: list[pitch.Pitch]) -> list[pitch.Pit ''' # fix defaultOctave for pitchList lastPs: float = 0 - lastOctave = pitchList[0].implicitOctave + lastOctave = pitchList[0].octave for p in pitchList: - if p.octave is None: + if p.octaveIsImplicit: if lastPs > p.ps: p.octave = lastOctave while lastPs > p.ps: @@ -462,7 +462,7 @@ def fixDefaultOctaveForPitchList(pitchList: list[pitch.Pitch]) -> list[pitch.Pit p.octave = lastOctave lastPs = p.ps - lastOctave = p.implicitOctave + lastOctave = p.octave return pitchList @@ -560,7 +560,7 @@ def getPitchFromNodeDegree(self, alteredDegrees=self._alteredDegrees, equateTermini=equateTermini ) - return copy.deepcopy(post) + return post def realizePitchByDegree(self, pitchReference: _PitchOrStr, @@ -614,7 +614,7 @@ def getRelativeNodeDegree(self, direction=direction, alteredDegrees=self._alteredDegrees ) - return copy.deepcopy(post) + return post def nextPitch(self, pitchReference: _PitchOrStr, @@ -629,14 +629,14 @@ def nextPitch(self, ''' net = t.cast('intervalNetwork.IntervalNetwork', self._net) post = net.nextPitch(pitchReference=pitchReference, - nodeName=nodeName, - pitchOrigin=pitchOrigin, - direction=direction, - stepSize=stepSize, - alteredDegrees=self._alteredDegrees, - getNeighbor=getNeighbor - ) - return copy.deepcopy(post) + nodeName=nodeName, + pitchOrigin=pitchOrigin, + direction=direction, + stepSize=stepSize, + alteredDegrees=self._alteredDegrees, + getNeighbor=getNeighbor + ) + return post def getNewTonicPitch(self, pitchReference: _PitchOrStr, @@ -657,7 +657,7 @@ def getNewTonicPitch(self, maxPitch=maxPitch, alteredDegrees=self._alteredDegrees ) - return copy.deepcopy(post) + return post # -------------------------------------------------------------------------- @@ -753,7 +753,6 @@ class AbstractDiatonicScale(AbstractScale): ''' def __init__(self, mode: str|None = None, **keywords) -> None: super().__init__(**keywords) - self.mode = mode self.type = 'Abstract diatonic' # tonicDegree and dominantDegree are set below by buildNetwork self.dominantDegree: int = 5 # step of dominant @@ -864,6 +863,7 @@ def buildNetwork(self, mode: str|None = None) -> None: intervalList, octaveDuplicating=self.octaveDuplicating, pitchSimplification=None) + self.mode = mode class AbstractOctatonicScale(AbstractScale): @@ -903,6 +903,7 @@ def buildNetwork(self, mode: str|int|None = None) -> None: self._net = intervalNetwork.IntervalNetwork(intervalList, octaveDuplicating=self.octaveDuplicating, pitchSimplification='maxAccidental') + self.mode = mode # might also set weights for tonic and dominant here @@ -911,9 +912,10 @@ class AbstractHarmonicMinorScale(AbstractScale): A true bidirectional scale that with the augmented second to a leading tone. - This is the only scale to use the "_alteredDegrees" property. - mode is not used + + * Changed in v11: the interval list ends with A2 and m2. + `alteredDegrees` are not used. ''' def __init__(self, mode: str|None = None, **keywords) -> None: super().__init__(**keywords) @@ -923,20 +925,13 @@ def __init__(self, mode: str|None = None, **keywords) -> None: self.buildNetwork() def buildNetwork(self, mode: t.Any = None) -> None: - intervalList = ['M2', 'm2', 'M2', 'M2', 'm2', 'M2', 'M2'] # a to A + intervalList = ['M2', 'm2', 'M2', 'M2', 'm2', 'A2', 'm2'] # a to A self.tonicDegree = 1 self.dominantDegree = 5 self._net = intervalNetwork.IntervalNetwork(intervalList, octaveDuplicating=self.octaveDuplicating, pitchSimplification=None) - # raise the seventh in all directions - # 7 here is scale step/degree, not node id - self._alteredDegrees[7] = { - 'direction': intervalNetwork.Direction.BI, - 'interval': interval.Interval('a1') - } - class AbstractMelodicMinorScale(AbstractScale): ''' @@ -1008,10 +1003,6 @@ def __init__(self, mode: t.Any = None, **keywords) -> None: mode = ['P8'] self.buildNetwork(mode=mode) - # by definition, these are forced to be octave duplicating - # though, do to some intervals, duplication may not happen every oct - self.octaveDuplicating = True - def buildNetwork(self, mode: t.Any = None) -> None: ''' Here, mode is the list of intervals. @@ -1022,8 +1013,17 @@ def buildNetwork(self, mode: t.Any = None) -> None: intervalSum = interval.add(mode) iComplement = intervalSum.complement - if iComplement is not None: - mode.append(iComplement) + span = intervalSum.semitones + if iComplement.semitones: + mode = [*mode, iComplement] # a copy: mode is the caller's list + span += iComplement.semitones + elif span == 12 and intervalSum.name != 'P8': + octaveShortfall = interval.subtract(['P8', intervalSum]) + mode = [*mode[:-1], interval.add([mode[-1], octaveShortfall])] + + # steps wider than an octave complete a pattern spanning several + # octaves, which does not repeat every octave + self.octaveDuplicating = (span == 12) self.tonicDegree = 1 self._net = intervalNetwork.IntervalNetwork(mode, @@ -1061,54 +1061,54 @@ def buildNetwork(self, mode: t.Any = None) -> None: edges = ( # ascending {'interval': 'M2', - 'connections': ( - [Terminus.LOW, 0, Direction.ASCENDING], # c to d - )}, + 'connections': ( + [Terminus.LOW, 0, Direction.ASCENDING], # c to d + )}, {'interval': 'm3', - 'connections': ( - [0, 1, Direction.ASCENDING], # d to f - )}, + 'connections': ( + [0, 1, Direction.ASCENDING], # d to f + )}, {'interval': 'M2', - 'connections': ( - [1, 2, Direction.ASCENDING], # f to g - )}, + 'connections': ( + [1, 2, Direction.ASCENDING], # f to g + )}, {'interval': 'm2', - 'connections': ( - [2, 3, Direction.ASCENDING], # g to a- - )}, + 'connections': ( + [2, 3, Direction.ASCENDING], # g to a- + )}, {'interval': 'M3', - 'connections': ( - [3, Terminus.HIGH, Direction.ASCENDING], # a- to c - )}, + 'connections': ( + [3, Terminus.HIGH, Direction.ASCENDING], # a- to c + )}, # descending {'interval': 'M2', - 'connections': ( - [Terminus.HIGH, 4, Direction.DESCENDING], # c to b- - )}, + 'connections': ( + [Terminus.HIGH, 4, Direction.DESCENDING], # c to b- + )}, {'interval': 'M2', - 'connections': ( - [4, 5, Direction.DESCENDING], # b- to a- - )}, + 'connections': ( + [4, 5, Direction.DESCENDING], # b- to a- + )}, {'interval': 'm2', - 'connections': ( - [5, 6, Direction.DESCENDING], # a- to g - )}, + 'connections': ( + [5, 6, Direction.DESCENDING], # a- to g + )}, {'interval': 'M2', - 'connections': ( - [6, 7, Direction.DESCENDING], # g to f - )}, + 'connections': ( + [6, 7, Direction.DESCENDING], # g to f + )}, {'interval': 'M2', - 'connections': ( - [7, 8, Direction.DESCENDING], # f to e- - )}, + 'connections': ( + [7, 8, Direction.DESCENDING], # f to e- + )}, {'interval': 'm2', - 'connections': ( - [8, 9, Direction.DESCENDING], # e- to d - )}, + 'connections': ( + [8, 9, Direction.DESCENDING], # e- to d + )}, {'interval': 'M2', - 'connections': ( - [9, Terminus.LOW, Direction.DESCENDING], # d to c - )}, + 'connections': ( + [9, Terminus.LOW, Direction.DESCENDING], # d to c + )}, ) self._net = intervalNetwork.IntervalNetwork( @@ -1231,33 +1231,33 @@ def buildNetwork(self, mode: t.Any = None) -> None: edges = ( # all bidirectional {'interval': 'm3', - 'connections': ( - [Terminus.LOW, 0, Direction.BI], # c to e- - )}, + 'connections': ( + [Terminus.LOW, 0, Direction.BI], # c to e- + )}, {'interval': 'M2', - 'connections': ( - [0, 1, Direction.BI], # e- to f - )}, + 'connections': ( + [0, 1, Direction.BI], # e- to f + )}, {'interval': 'M2', - 'connections': ( - [1, 3, Direction.BI], # f to g - )}, + 'connections': ( + [1, 3, Direction.BI], # f to g + )}, {'interval': 'a1', - 'connections': ( - [1, 2, Direction.BI], # f to f# - )}, + 'connections': ( + [1, 2, Direction.BI], # f to f# + )}, {'interval': 'm2', - 'connections': ( - [2, 3, Direction.BI], # f# to g - )}, + 'connections': ( + [2, 3, Direction.BI], # f# to g + )}, {'interval': 'm3', - 'connections': ( - [3, 4, Direction.BI], # g to b- - )}, + 'connections': ( + [3, 4, Direction.BI], # g to b- + )}, {'interval': 'M2', - 'connections': ( - [4, Terminus.HIGH, Direction.BI], # b- to c - )}, + 'connections': ( + [4, Terminus.HIGH, Direction.BI], # b- to c + )}, ) self._net = intervalNetwork.IntervalNetwork( @@ -1787,10 +1787,12 @@ def pitchFromDegree( if (self.usePitchDegreeCache and self.tonic and not minPitch and not maxPitch and getattr(self, 'type', None)): tonicCacheKey = self.tonic.nameWithOctave + modeCacheKey = str(self._abstract.mode) cacheKey = ( self.__class__, self.type, tonicCacheKey, + modeCacheKey, degree, direction, equateTermini @@ -1992,45 +1994,45 @@ def getScaleDegreeAndAccidentalFromPitch( # noinspection SpellCheckingInspection _solfegSyllables = {1: {-2: 'def', -1: 'de', - 0: 'do', - 1: 'di', - 2: 'dis', + 0: 'do', + 1: 'di', + 2: 'dis', }, 2: {-2: 'raf', -1: 'ra', - 0: 're', - 1: 'ri', - 2: 'ris', + 0: 're', + 1: 'ri', + 2: 'ris', }, 3: {-2: 'mef', -1: 'me', - 0: 'mi', - 1: 'mis', - 2: 'mish', + 0: 'mi', + 1: 'mis', + 2: 'mish', }, 4: {-2: 'fef', -1: 'fe', - 0: 'fa', - 1: 'fi', - 2: 'fis', + 0: 'fa', + 1: 'fi', + 2: 'fis', }, 5: {-2: 'sef', -1: 'se', - 0: 'sol', - 1: 'si', - 2: 'sis', + 0: 'sol', + 1: 'si', + 2: 'sis', }, 6: {-2: 'lef', -1: 'le', - 0: 'la', - 1: 'li', - 2: 'lis', + 0: 'la', + 1: 'li', + 2: 'lis', }, 7: {-2: 'tef', -1: 'te', - 0: 'ti', - 1: 'tis', - 2: 'tish', + 0: 'ti', + 1: 'tis', + 2: 'tish', }, } # TOO SLOW! @@ -2042,45 +2044,45 @@ def getScaleDegreeAndAccidentalFromPitch( _humdrumSolfegSyllables = { 1: {-2: 'def', -1: 'de', - 0: 'do', - 1: 'di', - 2: 'dis', + 0: 'do', + 1: 'di', + 2: 'dis', }, 2: {-2: 'raf', -1: 'ra', - 0: 're', - 1: 'ri', - 2: 'ris', + 0: 're', + 1: 'ri', + 2: 'ris', }, 3: {-2: 'mef', -1: 'me', - 0: 'mi', - 1: 'my', - 2: 'mish', + 0: 'mi', + 1: 'my', + 2: 'mish', }, 4: {-2: 'fef', -1: 'fe', - 0: 'fa', - 1: 'fi', - 2: 'fis', + 0: 'fa', + 1: 'fi', + 2: 'fis', }, 5: {-2: 'sef', -1: 'se', - 0: 'so', - 1: 'si', - 2: 'sis', + 0: 'so', + 1: 'si', + 2: 'sis', }, 6: {-2: 'lef', -1: 'le', - 0: 'la', - 1: 'li', - 2: 'lis', + 0: 'la', + 1: 'li', + 2: 'lis', }, 7: {-2: 'tef', -1: 'te', - 0: 'ti', - 1: 'ty', - 2: 'tish', + 0: 'ti', + 1: 'ty', + 2: 'tish', }, } @@ -2488,7 +2490,8 @@ def derive(self, other, comparisonAttribute: str = 'pitchClass') -> ConcreteScal abstract = t.cast(AbstractScale, self._abstract) net = t.cast('intervalNetwork.IntervalNetwork', abstract._net) pairs = net.find(pitchTarget=otherPitches, - comparisonAttribute=comparisonAttribute) + comparisonAttribute=comparisonAttribute, + alteredDegrees=abstract._alteredDegrees) newScale = self.__class__(tonic=pairs[0][1]) if newScale.abstract is None: diff --git a/music21/scale/intervalNetwork.py b/music21/scale/intervalNetwork.py index af6bf35c52..fda5bc467d 100644 --- a/music21/scale/intervalNetwork.py +++ b/music21/scale/intervalNetwork.py @@ -5,7 +5,7 @@ # Authors: Christopher Ariza # Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2010-2023 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2010-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -36,7 +36,9 @@ from collections import OrderedDict from collections.abc import Sequence import copy +import dataclasses import enum +import math import typing as t from music21 import common @@ -80,7 +82,36 @@ def __str__(self) -> str: type CacheKey = tuple[ - int|Terminus, str, str|None, str|None, bool, bool|None] + int|Terminus, str, str|None, str|None, bool, bool|None, + tuple[tuple[int, Direction, int, int|float], ...]] + +# node id, start pitch, and (degree, direction, generic, semitones) per altered degree +type NetworkWalkKey = tuple[ + Terminus|int, + str, + tuple[tuple[int, Direction, int, int|float], ...], +] + + +@dataclasses.dataclass +class NetworkWalkStep: + ''' + One pitch of an ascending walk: unaltered, as realized, and its node id. + ''' + unalteredPitch: pitch.Pitch + realizedPitch: pitch.Pitch + nodeId: Terminus|int + + +@dataclasses.dataclass +class NetworkWalkPath: + ''' + An ascending walk from one start, kept so later calls can extend it. + ''' + steps: list[NetworkWalkStep] + lastWalkedNode: Node + ended: bool = False + class AlteredDegree(t.TypedDict): ''' @@ -474,6 +505,7 @@ def __init__(self, CacheKey, tuple[list[pitch.Pitch], list[Terminus|int]] ] = OrderedDict() + self._ascendingWalks: dict[NetworkWalkKey, NetworkWalkPath] = {} def clear(self) -> None: ''' @@ -485,10 +517,10 @@ def clear(self) -> None: self.nodes = OrderedDict() self._ascendingCache = OrderedDict() self._descendingCache = OrderedDict() + self._ascendingWalks = {} def __eq__(self, other) -> bool: ''' - >>> edgeList1 = ['M2', 'M2', 'm2', 'M2', 'M2', 'M2', 'm2'] >>> edgeList2 = ['M2', 'M2', 'm2', 'M2', 'A3', 'm2'] @@ -1233,6 +1265,15 @@ def getUnalteredPitch( ) -> pitch.Pitch: ''' Given a node and alteredDegrees get the unaltered pitch, or return the current object + + >>> net = scale.intervalNetwork.IntervalNetwork(['M2', 'm2', 'M2', 'M2', 'm2', 'M2', 'M2']) + >>> raisedSeventh = {7: {'direction': scale.Direction.BI, + ... 'interval': interval.Interval('a1')}} + >>> seventh = net.nodes[5] + >>> seventh.degree + 7 + >>> net.getUnalteredPitch(pitch.Pitch('G#4'), seventh, alteredDegrees=raisedSeventh) + ''' if not alteredDegrees: return pitchObj @@ -1322,7 +1363,8 @@ def nextPitch( neighborIds = self.getNeighborNodeIds(pitchReference=pitchReference, nodeName=nodeName, pitchTarget=pitchOriginObj, - direction=direction) # must add direction + direction=direction, + alteredDegrees=alteredDegrees) lowId, highId = t.cast( 'tuple[Terminus|int, Terminus|int]', neighborIds) @@ -1339,45 +1381,15 @@ def nextPitch( # at this point a valid node id is required to continue foundNodeId = t.cast('Terminus|int', nodeId) - # realize the pitch from the found node degree - # we may be getting an altered - # tone, and we need to transpose an unaltered tone, thus - # leave out altered nodes argument - p = self.getPitchFromNodeDegree( - pitchReference=pitchReference, - nodeName=nodeName, - nodeDegreeTarget=self.nodes[foundNodeId].degree, - direction=direction, - minPitch=None, # not using a range here to - maxPitch=None, # get natural expansion - alteredDegrees=None # need unaltered tone here, thus omitted - ) - if p is None: - raise IntervalNetworkException( - 'Could not find a pitch for the requested node degree') - - # environLocal.printDebug(['nextPitch()', 'pitch obtained based on nodeName', - # nodeName, 'p', p, 'nodeId', nodeId, 'self.nodes[nodeId].degree', - # self.nodes[nodeId].degree]) - - # transfer octave from origin to new pitch derived from node - # note: this assumes octave equivalence and may be a problem - p.octave = pitchOriginObj.octave - - # correct for derived pitch crossing octave boundary - # https://github.com/cuthbertLab/music21/issues/319 - alterSemitones: int|float = 0 - degree = self.nodeIdToDegree(foundNodeId) - if alteredDegrees and degree in alteredDegrees: - alterSemitones = alteredDegrees[degree]['interval'].semitones - alterSemitonesInt = t.cast('int', alterSemitones) - if ((usedNeighbor and getNeighbor == Direction.DESCENDING) - or (not usedNeighbor and direction == Direction.ASCENDING)): - while p.octave is not None and p.transpose(alterSemitonesInt) > pitchOriginObj: - p.octave -= 1 - else: - while p.octave is not None and p.transpose(alterSemitonesInt) < pitchOriginObj: - p.octave += 1 + startBelowOrigin = ((usedNeighbor and getNeighbor == Direction.DESCENDING) + or (not usedNeighbor and direction == Direction.ASCENDING)) + p = self._nodePitchNear(pitchReference, + nodeName, + foundNodeId, + pitchOriginObj, + direction=direction, + alteredDegrees=alteredDegrees, + atOrBelow=startBelowOrigin) # pitchObj = p n = self.nodes[foundNodeId] @@ -1402,13 +1414,81 @@ def nextPitch( p = self.transposePitchAndApplySimplification(intervalObj, p) else: p = self.transposePitchAndApplySimplification(intervalObj.reverse(), p) - pCollect = self.processAlteredNodes(alteredDegrees=alteredDegrees, - n=n, - p=p, - direction=direction) + pCollect = self.processAlteredNodes( + alteredDegrees=alteredDegrees, + n=n, + p=p, + direction=direction) return pCollect + def _nodePitchNear( + self, + pitchReference: pitch.Pitch|str, + nodeName: Node|int|Terminus|None, + nodeId: Terminus|int, + pitchOrigin: pitch.Pitch, + *, + direction: Direction, + alteredDegrees: AlteredDegrees|None, + atOrBelow: bool, + ) -> pitch.Pitch: + ''' + Return the unaltered pitch of `nodeId` nearest `pitchOrigin`: the highest + at or below it if `atOrBelow`, otherwise the lowest at or above it. + + One node can stand for several pitch classes (a cycle of major thirds is + one edge, so D, F# and A# after the first D are all its high terminus), + so read the pitch off a realization around the origin. + + `pitchOrigin` is changed in place. + + AI-assisted (Claude). + ''' + pitchOrigin.octaveIsImplicit = False + span = self._searchSpan() + realizedPitches, realizedNodes = self.realize( + pitchReference, + nodeName, + minPitch=pitchOrigin.transpose(-span, inPlace=False), + maxPitch=pitchOrigin.transpose(span, inPlace=False), + direction=direction, + alteredDegrees=alteredDegrees, + ) + nodePitches = [realizedPitch + for realizedPitch, realizedNode in zip(realizedPitches, realizedNodes) + if realizedNode == nodeId] + if atOrBelow: + found = max((p for p in nodePitches if p.ps <= pitchOrigin.ps), + key=lambda p: p.ps, default=None) + else: + found = min((p for p in nodePitches if p.ps >= pitchOrigin.ps), + key=lambda p: p.ps, default=None) + if found is None: + raise IntervalNetworkException( + 'Could not find a pitch for the requested node degree') + + degree = self.nodeIdToDegree(nodeId) + if alteredDegrees and degree in alteredDegrees: + return found.transpose(alteredDegrees[degree]['interval'].reverse()) + return found + + def _searchSpan(self) -> int: + ''' + Return how many semitones either side of a pitch to realize so that + its neighbors on the network are included: an octave, or the widest + edge if that is wider. + + >>> net = scale.intervalNetwork.IntervalNetwork(['M2', 'M2', 'm2']) + >>> net._searchSpan() + 12 + >>> net = scale.intervalNetwork.IntervalNetwork(['m10']) + >>> net._searchSpan() + 15 + ''' + widest = max((abs(e.interval.semitones) for e in self.edges.values()), default=0) + return max(12, math.ceil(widest)) + # TODO: need to collect intervals as well def _getCacheKey( @@ -1420,6 +1500,7 @@ def _getCacheKey( *, includeFirst: bool, reverse: bool|None = None, # only meaningful for descending + alteredDegrees: AlteredDegrees|None = None, ) -> CacheKey: ''' Return key for caching based on critical components. @@ -1433,12 +1514,23 @@ def _getCacheKey( maxKey = maxPitch.nameWithOctave else: maxKey = None + + # an interval's generic number and semitones say what it is, and are + # cheaper to read than its name + alteredKey: tuple[tuple[int, Direction, int, int|float], ...] = () + if alteredDegrees: + alteredKey = tuple([(degree, + spec['direction'], + spec['interval'].generic.directed, + spec['interval'].semitones) + for degree, spec in alteredDegrees.items()]) return (nodeObj.id, pitchReference.nameWithOctave, minKey, maxKey, includeFirst, reverse, + alteredKey, ) def realizeAscending( @@ -1487,8 +1579,7 @@ def realizeAscending( nodeObj = t.cast(list[Node], self.nodeNameToNodes(nodeId))[0] # must set an octave for pitch reference, even if not given - if pitchReference.octave is None: - pitchReference.octave = pitchReference.implicitOctave + pitchReference.octaveIsImplicit = False if isinstance(minPitch, str): minPitch = pitch.Pitch(minPitch) @@ -1513,7 +1604,8 @@ def realizeAscending( pitchReference, minPitch, maxPitch, - includeFirst=False) + includeFirst=False, + alteredDegrees=alteredDegrees) if ck in self._ascendingCache: return self._ascendingCache[ck] else: @@ -1524,6 +1616,19 @@ def realizeAscending( if self.octaveDuplicating and minPitch is not None: pitchReference.transposeBelowTarget(minPitch, minimize=True, inPlace=True) + if ck is not None and minPitch is not None and maxPitch is not None: + walked = self._walkAscending(nodeObj, pitchReference, maxPitch, alteredDegrees) + if walked is not None: + post = [] + postNodeId = [] + for step in walked: + if (_gte(step.realizedPitch.ps, minPitch.ps) + and _lte(step.realizedPitch.ps, maxPitch.ps)): + post.append(step.realizedPitch) + postNodeId.append(step.nodeId) + self._ascendingCache[ck] = post, postNodeId + return post, postNodeId + # first, go upward from this pitch to the high terminus n = nodeObj p = pitchReference # we start with the pitch that is the reference @@ -1588,10 +1693,11 @@ def realizeAscending( p = self.transposePitchAndApplySimplification(intervalObj, p) pCollect = p - pCollect = self.processAlteredNodes(alteredDegrees=alteredDegrees, - n=n, - p=p, - direction=Direction.ASCENDING) + pCollect = self.processAlteredNodes( + alteredDegrees=alteredDegrees, + n=n, + p=p, + direction=Direction.ASCENDING) if attempts >= maxAttempts: raise IntervalNetworkException( @@ -1606,6 +1712,66 @@ def realizeAscending( return post, postNodeId + def _walkAscending( + self, + nodeObj: Node, + start: pitch.Pitch, + maxPitch: pitch.Pitch, + alteredDegrees: AlteredDegrees|None, + ) -> list[NetworkWalkStep]|None: + ''' + Return the pitches and node ids an ascending realization from `start` passes + through, up to and including the first at or above `maxPitch`, or None if + the walk reaches a node with more than one way up. + + The walk depends only on where it starts, so it is kept and extended rather + than repeated for every range starting there. + + AI-assisted (Claude). + ''' + key: NetworkWalkKey = (nodeObj.id, start.nameWithOctave, tuple( + (degree, + spec['direction'], + spec['interval'].generic.directed, + spec['interval'].semitones) + for degree, spec in (alteredDegrees or {}).items())) + walk = self._ascendingWalks.get(key) + if walk is None: + walk = NetworkWalkPath([NetworkWalkStep(start, start, nodeObj.id)], nodeObj) + self._ascendingWalks[key] = walk + while not walk.ended and not _gte(walk.steps[-1].unalteredPitch.ps, maxPitch.ps): + if len(walk.steps) >= 100: + break + n = walk.lastWalkedNode + if n.id == Terminus.HIGH: + n = self.terminusLowNodes[0] + nextBundle = self.getNext(n, Direction.ASCENDING) + if nextBundle is None: + walk.ended = True + break + postEdge, postNode = nextBundle + if len(postEdge) > 1: + return None + walk.lastWalkedNode = postNode[0] + p = self.transposePitchAndApplySimplification(postEdge[0].interval, + walk.steps[-1].unalteredPitch) + realizedPitch = self.processAlteredNodes(alteredDegrees=alteredDegrees, + n=walk.lastWalkedNode, + p=p, + direction=Direction.ASCENDING) + walk.steps.append(NetworkWalkStep(p, realizedPitch, walk.lastWalkedNode.id)) + + walked = [] + for step in walk.steps: + walked.append(step) + if _gte(step.unalteredPitch.ps, maxPitch.ps): + break + if len(walked) >= 100: + raise IntervalNetworkException( + 'Cannot realize these pitches; is the network ' + + "well-formed? (especially check if you're giving notes without octaves)") + return walked + def realizeDescending( self, pitchReference: pitch.Pitch|str, @@ -1674,8 +1840,7 @@ def realizeDescending( pitchRef = copy.deepcopy(pitchReference) # must set an octave for pitch reference, even if not given - if pitchRef.octave is None: - pitchRef.octave = 4 + pitchRef.octaveIsImplicit = False # get first node if no node is provided if isinstance(nodeId, Node): @@ -1720,6 +1885,7 @@ def realizeDescending( maxPitch=maxPitchObj, includeFirst=includeFirst, reverse=reverse, + alteredDegrees=alteredDegrees, ) if ck in self._descendingCache: return self._descendingCache[ck] @@ -1739,16 +1905,16 @@ def realizeDescending( while True: appendPitch = False if (minPitchObj is not None - and _gte(p.ps, minPitchObj.ps) + and _gte(pCollect.ps, minPitchObj.ps) and maxPitchObj is not None - and _lte(p.ps, maxPitchObj.ps)): + and _lte(pCollect.ps, maxPitchObj.ps)): appendPitch = True elif (minPitchObj is not None - and _gte(p.ps, minPitchObj.ps) + and _gte(pCollect.ps, minPitchObj.ps) and maxPitchObj is None): appendPitch = True elif (maxPitchObj is not None - and _lte(p.ps, maxPitchObj.ps) + and _lte(pCollect.ps, maxPitchObj.ps) and minPitchObj is None): appendPitch = True elif minPitchObj is None and maxPitchObj is None: @@ -1860,8 +2026,7 @@ def realize(self, pitchRef = copy.deepcopy(pitchReference) # must set an octave for pitch reference, even if not given - if pitchRef.octave is None: - pitchRef.octave = pitchRef.implicitOctave + pitchRef.octaveIsImplicit = False minPitchObj: pitch.Pitch|None if isinstance(minPitch, str): @@ -2054,7 +2219,6 @@ def realizePitch( ['C1', 'G1', 'D2', 'A2', 'E3', 'B3', 'F#4', 'D-5', 'A-5', 'E-6', 'B-6', 'F7', 'C8'] >>> [str(p) for p in net5ths.realizePitch(pitch.Pitch('C2'))] ['C2', 'G2', 'D3', 'A3', 'E4', 'B4', 'F#5', 'D-6', 'A-6', 'E-7', 'B-7', 'F8', 'C9'] - ''' components = self.realize( pitchReference=pitchReference, @@ -2383,14 +2547,12 @@ def getRelativeNodeId( else: pitchTargetObj = pitchTarget - saveOctave = pitchTargetObj.octave - if saveOctave is None: - pitchTargetObj.octave = pitchTargetObj.implicitOctave + octaveWasImplicit = pitchTargetObj.octaveIsImplicit + pitchTargetObj.octaveIsImplicit = False - # try an octave spread first - # if a scale degree is larger than an octave this will fail - minPitch = pitchTargetObj.transpose(-12, inPlace=False) - maxPitch = pitchTargetObj.transpose(12, inPlace=False) + span = self._searchSpan() + minPitch = pitchTargetObj.transpose(-span, inPlace=False) + maxPitch = pitchTargetObj.transpose(span, inPlace=False) realizedPitches, realizedNodes = self.realize(pitchReference, nodeObj, @@ -2413,8 +2575,8 @@ def getRelativeNodeId( if realizedNode not in post: # may be more than one match post.append(realizedNode) - if saveOctave is None: - pitchTargetObj.octave = None + if octaveWasImplicit: + pitchTargetObj.octaveIsImplicit = True if not post: return None @@ -2459,14 +2621,12 @@ def getNeighborNodeIds( else: pitchTargetObj = pitchTarget - savedOctave = pitchTargetObj.octave - if savedOctave is None: - # don't alter permanently, in case a Pitch object was passed in. - pitchTargetObj.octave = pitchTargetObj.implicitOctave - # try an octave spread first - # if a scale degree is larger than an octave this will fail - minPitch = pitchTargetObj.transpose(-12, inPlace=False) - maxPitch = pitchTargetObj.transpose(12, inPlace=False) + # don't alter permanently, in case a Pitch object was passed in. + octaveWasImplicit = pitchTargetObj.octaveIsImplicit + pitchTargetObj.octaveIsImplicit = False + span = self._searchSpan() + minPitch = pitchTargetObj.transpose(-span, inPlace=False) + maxPitch = pitchTargetObj.transpose(span, inPlace=False) realizedPitches, realizedNodes = self.realize(pitchReference, nodeId, @@ -2484,8 +2644,8 @@ def getNeighborNodeIds( return lowNeighbor, highNeighbor lowNeighbor = realizedNode - if savedOctave is None: - pitchTargetObj.octave = savedOctave + if octaveWasImplicit: + pitchTargetObj.octaveIsImplicit = True return None def getRelativeNodeDegree( @@ -2578,7 +2738,6 @@ def getRelativeNodeDegree( 2 >>> net.getRelativeNodeDegree('f6', 1, 'b3') 1 - ''' nId = self.getRelativeNodeId( pitchReference=pitchReference, @@ -2688,7 +2847,13 @@ def getPitchFromNodeDegree( # TODO: BUG: Does not work with bidirectional scales. # TODO: possibly cache results - for unused_counter in range(10): + + # A non-deterministic realization can skip the target node entirely, so try + # again; a deterministic one realizes the same way every time, so once is enough. + # 40 tries on Weighed Hexatonic or other scales where each node exists + # = 1 fail in 1.1 trillion tries, but still only about 25ms in case the node actually + # does not exist in the scale. + for unused_counter in range(1 if self.deterministic else 40): realizedPitch, realizedNode = self.realize( pitchReference=pitchReference, nodeId=nodeListForNames[0], @@ -2706,15 +2871,16 @@ def getPitchFromNodeDegree( # environLocal.printDebug(['comparing', realizedNId, # 'nodeTargetId', nodeTargetId]) + # realizedPitch may be a cached realization: hand back a copy if realizedNId == nodeTargetId.id: - return realizedPitch[i] + return copy.deepcopy(realizedPitch[i]) # NOTE: this condition may be too generous, and was added to solve # a non-tracked problem. # only match this generously if we are equating termini if equateTermini: if ((realizedNId in (Terminus.HIGH, Terminus.LOW)) - and (nodeTargetId.id in (Terminus.HIGH, Terminus.LOW))): - return realizedPitch[i] + and (nodeTargetId.id in (Terminus.HIGH, Terminus.LOW))): + return copy.deepcopy(realizedPitch[i]) # environLocal.printDebug(['getPitchFromNodeDegree() on trial', trial, ', # failed to find node', nodeTargetId]) @@ -2815,7 +2981,6 @@ def match(self, ['B-2', 'C3', 'E-3', 'E#3', 'F2', 'E--2'] >>> unmatched [] - ''' # these return a Node, not a nodeId # TODO: just getting first @@ -2902,7 +3067,7 @@ def findMissing(self, break # environLocal.printDebug(['looking at:', target, p, 'match', match]) if not match: - post.append(target) + post.append(copy.deepcopy(target)) return post diff --git a/music21/scale/scala/__init__.py b/music21/scale/scala/__init__.py index e96f7eb7f3..a937287f21 100644 --- a/music21/scale/scala/__init__.py +++ b/music21/scale/scala/__init__.py @@ -288,10 +288,12 @@ def parse(self) -> None: self.description = line elif count == 2: if line != '': - self.pitchCount = int(line) + # only the first token is data; the format ignores any + # trailing characters, so a comment can follow the value + self.pitchCount = int(line.split()[0]) else: # remaining counts are pitches if line != '': - sp = ScalaPitch(line) + sp = ScalaPitch(line.split()[0]) sp.parse() self.pitchValues.append(sp) @@ -648,31 +650,35 @@ def testScalaScaleB(self) -> None: self.assertEqual(ss.description, 'Franck Jedrzejewski continued fractions approx. of 12-tet') - self.assertEqual([f'{x:.9f}' for x in ss.getCentsAboveTonic()], ['100.099209825', - '199.979843291', - '299.973903610', - '400.108480470', - '498.044999135', - '600.088323762', - '699.997698171', - '800.909593096', - '900.026096390', - '1000.020156709', - '1088.268714730', - '1200.000000000']) - - self.assertEqual([f'{x:.9f}' for x in ss.getAdjacentCents()], ['100.099209825', - '99.880633466', - '99.994060319', - '100.134576860', - '97.936518664', - '102.043324627', - '99.909374409', - '100.911894925', - '99.116503294', - '99.994060319', - '88.248558022', - '111.731285270']) + self.assertEqual( + [f'{x:.9f}' for x in ss.getCentsAboveTonic()], + ['100.099209825', + '199.979843291', + '299.973903610', + '400.108480470', + '498.044999135', + '600.088323762', + '699.997698171', + '800.909593096', + '900.026096390', + '1000.020156709', + '1088.268714730', + '1200.000000000']) + + self.assertEqual( + [f'{x:.9f}' for x in ss.getAdjacentCents()], + ['100.099209825', + '99.880633466', + '99.994060319', + '100.134576860', + '97.936518664', + '102.043324627', + '99.909374409', + '100.911894925', + '99.116503294', + '99.994060319', + '88.248558022', + '111.731285270']) self.assertEqual([str(x) for x in ss.getIntervalSequence()], ['', @@ -740,6 +746,57 @@ def testScalaFileA(self) -> None: '', '']) + def testCommentAfterAPitchValue(self) -> None: + # noinspection SpellCheckingInspection + msg = r'''! commented.scl +! +A scale whose values carry trailing comments + 3 +! + 10/9 ! A\ ; 8 |mi|la|re| + 454.75 (427) + 2/1 ! octave +''' + ss = ScalaData(msg) + ss.parse() + self.assertEqual(ss.pitchCount, 3) + self.assertEqual(len(ss.pitchValues), 3) + self.assertEqual([f'{x.cents:.5f}' for x in ss.pitchValues], + ['182.40371', '454.75000', '1200.00000']) + + # two bundled files the old parser read without raising, and got + # wrong: the comment's digits were appended to the value + newton = parse('newton_15_out_of_53') + assert newton is not None + self.assertEqual(newton.pitchValues[0].src, '10/9') + self.assertEqual(f'{newton.pitchValues[0].cents:.5f}', '182.40371') + + loops = parse('sparschuh-jsbloops440') + assert loops is not None + self.assertEqual(loops.pitchValues[8].src, '1760/1055') + self.assertEqual(f'{loops.pitchValues[8].cents:.5f}', '885.99892') + + def testEveryBundledScalaFileParses(self) -> None: + # a guard on the archive itself: a file added or edited upstream that + # this parser cannot read should fail here rather than at the point + # some scale is asked for. The description line is not checked, as the + # format allows it to be empty. + paths = getPaths() + self.assertGreater(len(paths), 3000) + for fp in paths: + sf = ScalaFile() + sf.open(fp) + try: + ss = sf.read() + finally: + sf.close() + self.assertIsNotNone(ss.pitchCount, fp) + self.assertEqual(len(ss.pitchValues), ss.pitchCount, fp) + for sp in ss.pitchValues: + self.assertIsNotNone(sp.cents, f'{fp}: {sp.src!r}') + self.assertTrue(math.isfinite(t.cast(float, sp.cents)), + f'{fp}: {sp.src!r}') + # ------------------------------------------------------------------------------ # define presented order in documentation diff --git a/music21/scale/scala/scl/sparschuh-stanhope.scl b/music21/scale/scala/scl/sparschuh-stanhope.scl index 65f5b9bb2f..619ecd7192 100644 --- a/music21/scale/scala/scl/sparschuh-stanhope.scl +++ b/music21/scale/scala/scl/sparschuh-stanhope.scl @@ -9,7 +9,7 @@ Sparschuh's (2010) septenarian variant of Stanhopes (1806) idea 4/3 ! F 620/441 ! F# (45/32) * (3968/3969 ~-0.436...Cents flattend ) 3/2 ! G -697//441 ! G# (128/81) * (6273/6272 ~+0.276...Cents sharper ) +697/441 ! G# (128/81) * (6273/6272 ~+0.276...Cents sharper ) 82/49 ! A (5/3) * (246/245 ~+7.052...Cents sharper ) 16/9 ! Bb 15/8 ! B diff --git a/music21/scale/test_intervalNetwork.py b/music21/scale/test_intervalNetwork.py index da1f3061b3..9d751ae622 100644 --- a/music21/scale/test_intervalNetwork.py +++ b/music21/scale/test_intervalNetwork.py @@ -5,7 +5,7 @@ # Authors: Christopher Ariza # Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2010-2023 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2010-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ from __future__ import annotations @@ -627,6 +627,42 @@ def test_realize_descending_reversed_cached(self): self.assertEqual(descending_melodic_minor_reversed[0].nameWithOctave, 'C4') # was B-4 self.assertEqual(descending_melodic_minor_reversed[-1].nameWithOctave, 'B-4') # was C4 + def test_get_pitch_from_node_degree_returns_a_new_pitch(self): + ''' + A realization may be held in _ascendingCache or _descendingCache, so + the pitch taken out of one has to be the caller's own -- otherwise + writing to it edits the cached scale. + ''' + net = IntervalNetwork() + net.fillBiDirectedEdges(['M2', 'M2', 'm2', 'M2', 'M2', 'M2', 'm2']) + + first = net.getPitchFromNodeDegree('c4', 1, 1) + self.assertEqual(first.nameWithOctave, 'C4') + first.octave = 1 + + again = net.getPitchFromNodeDegree('c4', 1, 1) + self.assertEqual(again.nameWithOctave, 'C4') + + def test_next_pitch_does_not_move_a_cached_degree(self): + sc = scale.RagAsawari('c4') + self.assertEqual(str(sc.pitchFromDegree(1)), 'C4') + self.assertEqual(str(sc.nextPitch('c1', Direction.ASCENDING)), 'D1') + self.assertEqual(str(sc.pitchFromDegree(1)), 'C4') + + def testNextPitchNotReusePitchInstances(self): + sc = scale.MajorScale('c4') + p = sc.nextPitch('e4') + p.octave = 1 + self.assertEqual(str(sc.nextPitch('e4')), 'F4') + + def testFindMissingNotReusePitchInstances(self): + net = IntervalNetwork(['M2', 'M2', 'm2', 'M2', 'M2', 'M2', 'm2']) + missing = net.findMissing('c4', 1, ['d']) + self.assertEqual(self.pitchOut(missing), '[C4, E4, F4, G4, A4, B4, C5]') + missing[0].octave = 7 + self.assertEqual(self.pitchOut(net.realizePitch('c4')), + '[C4, D4, E4, F4, G4, A4, B4, C5]') + # ------------------------------------------------------------------------------ if __name__ == '__main__': diff --git a/music21/scale/test_scale_main.py b/music21/scale/test_scale_main.py index b0b4cf5735..ba3a30a644 100644 --- a/music21/scale/test_scale_main.py +++ b/music21/scale/test_scale_main.py @@ -5,7 +5,7 @@ # Authors: Christopher Ariza # Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2010-2024 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2010-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ from __future__ import annotations @@ -233,6 +233,76 @@ def testBasic(self): ], 'notMatched': [, ]}''', pformat(sc1.match(sc2))) + def testOctaveRepeatingScaleLeavesModeAlone(self): + mode = ['M3', 'M3'] + sc = scale.OctaveRepeatingScale('c4', mode) + self.assertEqual(mode, ['M3', 'M3']) + self.assertEqual(self.pitchOut(sc.pitches), '[C4, E4, G#4, C5]') + + again = scale.OctaveRepeatingScale('c4', mode) + self.assertEqual(self.pitchOut(again.pitches), '[C4, E4, G#4, C5]') + + def testOctaveRepeatingScaleWiderThanAnOctave(self): + sc = scale.OctaveRepeatingScale('c4', ['P5', 'P5']) + self.assertEqual(self.pitchOut(sc.pitches), '[C4, G4, D5, C6]') + self.assertEqual(self.pitchOut(sc.getPitches('c3', 'c6')), '[D3, C4, G4, D5, C6]') + + def testOctaveRepeatingScaleGivenAWholeOctave(self): + sc = scale.OctaveRepeatingScale('c4', ['m3', 'M2', 'm3', 'M2', 'M2']) + self.assertEqual(self.pitchOut(sc.pitches), '[C4, E-4, F4, A-4, B-4, C5]') + self.assertEqual(self.pitchOut(sc.getPitches('c3', 'c5')), + '[C3, E-3, F3, A-3, B-3, C4, E-4, F4, A-4, B-4, C5]') + self.assertEqual(str(sc.nextPitch('b-4')), 'C5') + self.assertEqual(str(sc.nextPitch('c5')), 'E-5') + self.assertEqual(str(sc.nextPitch('c5', Direction.DESCENDING)), 'B-4') + + fifthAndFourth = scale.OctaveRepeatingScale('c4', ['P5', 'P4']) + self.assertEqual(fifthAndFourth.getScaleDegreeFromPitch('c'), 1) + + octaves = scale.OctaveRepeatingScale('c4', ['P8']) + self.assertEqual(self.pitchOut(octaves.getPitches('c3', 'c6')), '[C3, C4, C5, C6]') + self.assertEqual(str(octaves.nextPitch('c5')), 'C6') + self.assertEqual(str(octaves.nextPitch('c5', Direction.DESCENDING)), 'C4') + + def testOctaveRepeatingScaleStepsReachingAnEnharmonicOctave(self): + thirds = scale.OctaveRepeatingScale('c4', ['M3', 'M3', 'M3']) + wholeTones = scale.OctaveRepeatingScale('c4', ['M2'] * 6) + self.assertEqual(self.pitchOut(thirds.getPitches('c4', 'c6')), + '[C4, E4, G#4, C5, E5, G#5, C6]') + self.assertEqual(self.pitchOut(wholeTones.getPitches('c4', 'c5')), + '[C4, D4, E4, F#4, G#4, A#4, C5]') + for unused_x in range(20): + self.assertEqual(str(thirds.nextPitch('g#4')), 'C5') + self.assertEqual(str(thirds.nextPitch('c5')), 'E5') + self.assertEqual(str(thirds.nextPitch('c5', Direction.DESCENDING)), 'G#4') + self.assertEqual(thirds.getScaleDegreeFromPitch('c'), 1) + + self.assertEqual(str(wholeTones.nextPitch('a#4')), 'C5') + self.assertEqual(str(wholeTones.nextPitch('c5')), 'D5') + self.assertEqual(str(wholeTones.nextPitch('c5', Direction.DESCENDING)), 'A#4') + self.assertEqual(wholeTones.getScaleDegreeFromPitch('c'), 1) + + def testOctatonicModesDoNotSharePitchDegreeCache(self): + sc1 = scale.OctatonicScale('C', mode=1) + sc2 = scale.OctatonicScale('C', mode=2) + + self.assertEqual(str(sc1.pitchFromDegree(2)), 'D-4') + self.assertEqual(str(sc2.pitchFromDegree(2)), 'D4') + + def testDiatonicModesDoNotSharePitchDegreeCache(self): + sc1 = scale.DiatonicScale('C', mode='dorian') + sc2 = scale.DiatonicScale('C', mode='major') + + self.assertEqual(str(sc1.pitchFromDegree(3)), 'E-4') + self.assertEqual(str(sc2.pitchFromDegree(3)), 'E4') + + def testSwapAbstractScaleDoesNotReuseCachedPitch(self): + sc = scale.OctatonicScale('C', mode=2) + self.assertEqual(str(sc.pitchFromDegree(2)), 'D4') + + sc.abstract = scale.AbstractOctatonicScale(mode=1) + self.assertEqual(str(sc.pitchFromDegree(2)), 'D-4') + def testCyclicalScales(self): sc = scale.CyclicalScale('c4', ['m2', 'm2']) @@ -272,6 +342,109 @@ def testCyclicalScales(self): sc2 = scale.CyclicalScale() self.assertEqual(self.pitchOut(sc2.getPitches()), '[C4, D-4]') + def testHarmonicMinorDescendingRange(self): + sc = scale.HarmonicMinorScale('c4') + descending = sc.getPitches('b2', 'e3', direction=Direction.DESCENDING) + self.assertEqual(self.pitchOut(descending), '[E-3, D3, C3, B2]') + + sc = scale.HarmonicMinorScale('b-3') + descending = sc.getPitches('c#4', 'g#4', direction=Direction.DESCENDING) + self.assertEqual(self.pitchOut(descending), '[G-4, F4, E-4, D-4]') + + def testHarmonicMinorMatch(self): + aMinor = scale.HarmonicMinorScale('a4') + matches = aMinor.match(['G#4', 'G4', 'F4']) + self.assertEqual(self.pitchOut(matches['matched']), '[G#4, F4]') + self.assertEqual(self.pitchOut(matches['notMatched']), '[G4]') + + eFlatMinor = scale.HarmonicMinorScale('e-4') + matches = eFlatMinor.match(['D4', 'D-4', 'C-4']) + self.assertEqual(self.pitchOut(matches['matched']), '[D4, C-4]') + self.assertEqual(self.pitchOut(matches['notMatched']), '[D-4]') + + def testDeriveHarmonicMinor(self): + sc = scale.HarmonicMinorScale() + self.assertEqual(sc.derive(['C', 'D', 'E-', 'B']).name, 'C harmonic minor') + + def testNextPitchOnCycle(self): + ''' + AI-assisted (Claude). + ''' + thirds = scale.CyclicalScale('d3', ['M3']) + self.assertEqual(str(thirds.nextPitch('c4')), 'D4') + self.assertEqual(str(thirds.nextPitch('g4')), 'A#4') + self.assertEqual(str(thirds.nextPitch('g4', Direction.DESCENDING)), 'F#4') + self.assertEqual(str(thirds.nextPitch('f#4')), 'A#4') + + fifths = scale.CyclicalScale('c4', ['P5']) + self.assertEqual(str(fifths.nextPitch('g4')), 'D5') + self.assertEqual(str(fifths.nextPitch('g4', Direction.DESCENDING)), 'C4') + + thirdsAndMinorThirds = scale.CyclicalScale('d3', ['M3', 'm3']) + self.assertEqual(str(thirdsAndMinorThirds.nextPitch('d4')), 'E4') + self.assertEqual(str(thirdsAndMinorThirds.nextPitch('d4', Direction.DESCENDING)), 'C#4') + self.assertEqual(str(thirdsAndMinorThirds.nextPitch('a4')), 'B4') + self.assertEqual(str(thirdsAndMinorThirds.nextPitch('a4', Direction.DESCENDING)), 'G#4') + + def testNextPitchFromEnharmonic(self): + thirds = scale.CyclicalScale('d3', ['M3']) + for enharmonicOfD in ('c##4', 'e--4'): + self.assertEqual(str(thirds.nextPitch(enharmonicOfD)), 'F#4') + self.assertEqual(str(thirds.nextPitch(enharmonicOfD, Direction.DESCENDING)), 'B-3') + + sc = scale.MajorScale('c4') + self.assertEqual(str(sc.nextPitch('b#3')), 'D4') + self.assertEqual(str(sc.nextPitch('d-4', Direction.DESCENDING)), 'C4') + + def testNextPitchNodeNearOrigin(self): + thirds = scale.CyclicalScale('d3', ['M3']) + self.assertEqual(str(thirds.nextPitch('d4', stepSize=2)), 'A#4') + + fifths = scale.CyclicalScale('c4', ['P5']) + self.assertEqual(str(fifths.nextPitch('f4', Direction.DESCENDING)), 'C4') + + aMinor = scale.HarmonicMinorScale('a4') + self.assertEqual(str(aMinor.nextPitch('f##4')), 'G#4') + self.assertEqual(str(aMinor.nextPitch('a4', Direction.DESCENDING)), 'G#4') + self.assertEqual(str(aMinor.nextPitch('g4')), 'G#4') + self.assertEqual(str(aMinor.nextPitch('g4', Direction.DESCENDING)), 'F4') + + gMinor = scale.HarmonicMinorScale('g4') + gMinor.nextPitch('a4') + self.assertEqual(str(gMinor.pitches[6]), 'F#5') + def testHarmonicMinorKeepsItsSeventhAfterNextPitch(self): + sc = scale.HarmonicMinorScale('g4') + sc.nextPitch('a4') + self.assertEqual(self.pitchOut(sc.pitches), '[G4, A4, B-4, C5, D5, E-5, F#5, G5]') + + def testNextPitchOnCycleWiderThanOctave(self): + ''' + nextPitch finds the neighbors on cycles whose step is wider than an + octave, including from D#5 and F--5, the enharmonics of E-5 one + letter lower and one higher. + ''' + ninths = scale.CyclicalScale('c4', ['M9']) + self.assertEqual(str(ninths.nextPitch('b3')), 'C4') + self.assertEqual(str(ninths.nextPitch('b3', Direction.DESCENDING)), 'B-2') + self.assertEqual(str(ninths.nextPitch('d#5')), 'E6') + self.assertEqual(str(ninths.nextPitch('d#5', Direction.DESCENDING)), 'D5') + + tenths = scale.CyclicalScale('c4', ['m10']) + self.assertEqual(str(tenths.nextPitch('d4')), 'E-5') + self.assertEqual(str(tenths.nextPitch('d4', Direction.DESCENDING)), 'C4') + self.assertEqual(str(tenths.nextPitch('d#5')), 'G-6') + self.assertEqual(str(tenths.nextPitch('f--5', Direction.DESCENDING)), 'C4') + + ninthsAndSeconds = scale.CyclicalScale('c4', ['M9', 'm2']) + self.assertEqual(str(ninthsAndSeconds.nextPitch('e6')), 'F6') + self.assertEqual(str(ninthsAndSeconds.nextPitch('e6', Direction.DESCENDING)), 'E-5') + self.assertEqual(str(ninthsAndSeconds.nextPitch('d#5')), 'F6') + + def testNeighborNodeIdsWiderThanOctave(self): + net = scale.CyclicalScale('c4', ['M9']).abstract._net + self.assertEqual(net.getNeighborNodeIds('c4', Terminus.LOW, 'b3'), + (Terminus.LOW, Terminus.LOW)) + def testDeriveByDegree(self): sc1 = scale.MajorScale() self.assertEqual(str(sc1.deriveByDegree(7, 'G#')), @@ -456,7 +629,7 @@ def testRagAsawari(self): getNeighbor=Direction.DESCENDING)), 'F1') - self.assertEqual(str(sc.pitchFromDegree(1)), 'C1') + self.assertEqual(str(sc.pitchFromDegree(1)), 'C4') # there is no third step in ascending form self.assertEqual(str(sc.pitchFromDegree(3)), 'None') self.assertEqual(str(sc.pitchFromDegree(3, direction=Direction.DESCENDING)), @@ -514,8 +687,8 @@ def testRagMarwaB(self): post = [] for unused_x in range(100): post.append(sc.getScaleDegreeFromPitch('A1', Direction.ASCENDING)) - self.assertGreater(post.count(5), 30) - self.assertGreater(post.count(7), 30) + self.assertGreater(post.count(5), 25) + self.assertGreater(post.count(7), 25) # for rag marwa, and given only the pitch d-, the scale can move to # either b or c; this selection is determined by weighted random @@ -523,8 +696,8 @@ def testRagMarwaB(self): post = [] for unused_x in range(100): post.append(sc.getScaleDegreeFromPitch('D-3', Direction.DESCENDING)) - self.assertGreater(post.count(2), 30) - self.assertGreater(post.count(7), 30) + self.assertGreater(post.count(2), 25) + self.assertGreater(post.count(7), 25) def testRagMarwaC(self): sc = scale.RagMarwa('c4') @@ -539,8 +712,8 @@ def testRagMarwaC(self): post = [] for unused_x in range(100): post.append(str(sc.nextPitch('D-2', Direction.DESCENDING))) - self.assertGreater(post.count('C2'), 30) - self.assertGreater(post.count('B1'), 30) + self.assertGreater(post.count('C2'), 25) + self.assertGreater(post.count('B1'), 25) def testWeightedHexatonicBluesA(self): sc = scale.WeightedHexatonicBlues('c4') @@ -585,8 +758,10 @@ def testWeightedHexatonicBluesA(self): self.assertEqual(str(sc.pitchFromDegree(1)), 'C4') self.assertEqual(str(sc.nextPitch('c4', Direction.ASCENDING)), 'E-4') - # degree 4 is always the blues note in this model - self.assertEqual(str(sc.pitchFromDegree(4)), 'F#4') + # degree 4 is always the blue note in this model, even though only half of + # the realizations pass through it + for dummy in range(20): + self.assertEqual(str(sc.pitchFromDegree(4)), 'F#4') # This never worked consistently and was not an important enough part of the project tp # continue to debug. @@ -892,9 +1067,9 @@ def testDerivedScaleNoOctaves(self): e = d.deriveRanked(['C', 'E', 'G'], comparisonAttribute='name') self.assertEqual(str(e), ''.join(['[(3, ), ', - '(3, ), ', - '(2, ), ', - '(2, )]']), + '(3, ), ', + '(2, ), ', + '(2, )]']), str(e) ) diff --git a/music21/search/base.py b/music21/search/base.py index c69485b352..7ec27fb758 100644 --- a/music21/search/base.py +++ b/music21/search/base.py @@ -4,7 +4,7 @@ # # Authors: Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2011-2013, 2017 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2011-2013, 2017-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -267,7 +267,7 @@ def run(self) -> list[SearchMatch]: break if result is False: break - if result is True: + if result: result = None if result is not False: @@ -1226,7 +1226,6 @@ def translateDurationToBytes(n: note.GeneralNote) -> str: '_' >>> (2 ** (ord(trans[0]) / 10)) / 256 # approximately 3 2.828... - ''' duration1to127 = 1 if n.duration.quarterLength: @@ -1305,16 +1304,16 @@ def mostCommonMeasureRhythms( 'number': 1, 'rhythmString': rhythmString, } - measureNotes = thisMeasure.notes - for measureNote in measureNotes: - if isinstance(measureNote, note.Note): - firstNote = t.cast(note.Note, measureNotes[0]) - distanceToTranspose = 72 - firstNote.pitch.ps + for measureNote in thisMeasure.notes: + if isinstance(measureNote, (note.Note, chord.Chord)): + referencePs = min(p.ps for p in measureNote.pitches) + distanceToTranspose = 72 - referencePs thisMeasureCopy = copy.deepcopy(thisMeasure) - for transposableNote in thisMeasureCopy.notes: + for transposableNote in thisMeasureCopy.getElementsByClass( + (note.Note, chord.Chord) + ): # TODO: Transpose Diatonic - nn = t.cast(note.Note, transposableNote) - nn.transpose(distanceToTranspose, inPlace=True) + transposableNote.transpose(distanceToTranspose, inPlace=True) newDict['rhythm'] = thisMeasureCopy break else: @@ -1336,6 +1335,24 @@ def testCopyAndDeepcopy(self) -> None: from music21.test.commonTest import testCopyAll testCopyAll(self, globals()) + def testMostCommonMeasureRhythmsChord(self) -> None: + def firstRhythm(elements: list[note.GeneralNote]) -> list[str]: + s: Stream = Stream([Measure(elements)]) + rhythmMeasure = mostCommonMeasureRhythms(s)[0]['rhythm'] + return [' '.join(p.nameWithOctave for p in n.pitches) or n.classes[0] + for n in rhythmMeasure.notesAndRests] + + self.assertEqual(firstRhythm([chord.Chord(['E4', 'G4']), note.Note('D4')]), + ['C5 E-5', 'B-4']) + self.assertEqual(firstRhythm([chord.Chord(['E4', 'G4']), chord.Chord(['D4', 'F4'])]), + ['C5 E-5', 'B-4 C#5']) + self.assertEqual(firstRhythm([note.Rest(), chord.Chord(['E4', 'G4'])]), + ['Rest', 'C5 E-5']) + self.assertEqual(firstRhythm([note.Unpitched(), note.Note('D4')]), + ['Unpitched', 'C5']) + self.assertEqual(firstRhythm([note.Note('D4'), note.Unpitched()]), + ['C5', 'Unpitched']) + # ------------------------------------------------------------------------------ # define presented order in documentation diff --git a/music21/search/lyrics.py b/music21/search/lyrics.py index c25120a841..8623c1b940 100644 --- a/music21/search/lyrics.py +++ b/music21/search/lyrics.py @@ -35,7 +35,6 @@ class IndexedLyric(namedtuple( )): ''' A Lyric that has been indexed to its attached element and position in a Stream. - ''' __slots__ = () _DOC_ATTR: dict[str, str] = { @@ -207,7 +206,7 @@ def index(self, s: stream.Stream|None = None) -> list[IndexedLyric]: indexByIdentifier: OrderedDict[str|int, list[IndexedLyric]] = OrderedDict() iTextByIdentifier: OrderedDict[str|int, str] = OrderedDict() lastSyllabicByIdentifier: OrderedDict[str|int, - str|None] = OrderedDict() + str|None] = OrderedDict() for n in s.recurse().notes: ls: list[note.Lyric] = n.lyrics diff --git a/music21/search/segment.py b/music21/search/segment.py index 7b25d30852..9afd5f3ede 100644 --- a/music21/search/segment.py +++ b/music21/search/segment.py @@ -18,7 +18,7 @@ If you really want to do lots of comparisons, the `scoreSimilarity` method will use python-Levenshtein if it is installed, unless forceDifflib is set to True. - python-Levenshtein can be installed via **pip install python-Levenshtein**. + python-Levenshtein can be installed via **uv add python-Levenshtein**. The ratios are very slightly different, but the speedup is between 10 and 100x! (But then PyPy probably won't work.) ''' @@ -97,7 +97,6 @@ def translateMonophonicPartToSegments( >>> measureLists[0:2] [(1, 12), (7, 18)] - ''' from music21 import search if algorithm is None: @@ -328,7 +327,7 @@ def getDifflibOrPyLev( try: # noinspection PyPackageRequirements from Levenshtein import StringMatcher as pyLevenshtein # type: ignore - smObject = pyLevenshtein.StringMatcher(junk, '', seq2Seq) + smObject = pyLevenshtein.StringMatcher(None, '', seq2Seq) except ImportError: smObject = difflib.SequenceMatcher(junk, '', seq2Seq) return smObject diff --git a/music21/search/serial.py b/music21/search/serial.py index d7b8cd79eb..b820fda448 100644 --- a/music21/search/serial.py +++ b/music21/search/serial.py @@ -852,9 +852,9 @@ def searchRowsOnlyInclude(self, n: note.NotRest, partNumber: int | None) -> None rowSuperset = csn.readPitchClassesFromBottom() lowerBound = max([0, len(rowSuperset) - - self.searchLength - - len(self.activeChordList[-1].pitches) - + 1]) + - self.searchLength + - len(self.activeChordList[-1].pitches) + + 1]) upperBound = min([len(self.activeChordList[0].pitches), len(rowSuperset) - self.searchLength + 1]) for j in range(lowerBound, upperBound): diff --git a/music21/serial.py b/music21/serial.py index 01bbc7dacf..c081368943 100644 --- a/music21/serial.py +++ b/music21/serial.py @@ -99,151 +99,151 @@ def _reprInternal(self): # noinspection SpellCheckingInspection historicalDict = { 'WebernOp29': ('Webern', 'Op. 29', 'Cantata I', - [3, 11, 2, 1, 5, 4, 7, 6, 10, 9, 0, 8]), + [3, 11, 2, 1, 5, 4, 7, 6, 10, 9, 0, 8]), 'WebernOp28': ('Webern', 'Op. 28', 'String Quartet', - [1, 0, 3, 2, 6, 7, 4, 5, 9, 8, 11, 10]), + [1, 0, 3, 2, 6, 7, 4, 5, 9, 8, 11, 10]), 'SchoenbergOp24Mvmt5': ('Schoenberg', 'Op. 24', 'Serenade, Mvt. 5, "Tanzscene"', - [9, 10, 0, 3, 4, 6, 5, 7, 8, 11, 1, 2]), + [9, 10, 0, 3, 4, 6, 5, 7, 8, 11, 1, 2]), 'SchoenbergOp24Mvmt4': ('Schoenberg', 'Op. 24', 'Serenade, Mvt. 4, "Sonett"', - [4, 2, 3, 11, 0, 1, 8, 6, 9, 5, 7, 10]), + [4, 2, 3, 11, 0, 1, 8, 6, 9, 5, 7, 10]), 'SchoenbergJakobsleiter': ('Schoenberg', None, 'Die Jakobsleiter', - [1, 2, 5, 4, 8, 7, 0, 3, 11, 10, 6, 9]), + [1, 2, 5, 4, 8, 7, 0, 3, 11, 10, 6, 9]), 'SchoenbergOp27No4': ('Schoenberg', 'Op. 27 No. 4', 'Four Pieces for Mixed Chorus, No. 4', - [1, 3, 10, 6, 8, 4, 11, 0, 2, 9, 5, 7]), + [1, 3, 10, 6, 8, 4, 11, 0, 2, 9, 5, 7]), 'WebernOp23': ('Webern', 'Op. 23', 'Three Songs', - [8, 3, 7, 4, 10, 6, 2, 5, 1, 0, 9, 11]), + [8, 3, 7, 4, 10, 6, 2, 5, 1, 0, 9, 11]), 'BergLuluActIIScene1': ('Berg', 'Lulu, Act II, Scene 1', - 'Perm. (Every 5th Note Of Transposed Primary Row)', - [10, 7, 1, 0, 9, 2, 4, 11, 5, 8, 3, 6]), + 'Perm. (Every 5th Note Of Transposed Primary Row)', + [10, 7, 1, 0, 9, 2, 4, 11, 5, 8, 3, 6]), 'SchoenbergOp27No1': ('Schoenberg', 'Op. 27 No. 1', 'Four Pieces for Mixed Chorus, No. 1', - [6, 5, 2, 8, 7, 1, 3, 4, 10, 9, 11, 0]), + [6, 5, 2, 8, 7, 1, 3, 4, 10, 9, 11, 0]), 'BergLuluActIScene20': ('Berg', 'Lulu, Act I , Scene XX', - 'Perm. (Every 7th Note Of Transposed Primary Row)', - [10, 6, 3, 8, 5, 11, 4, 2, 9, 0, 1, 7]), + 'Perm. (Every 7th Note Of Transposed Primary Row)', + [10, 6, 3, 8, 5, 11, 4, 2, 9, 0, 1, 7]), 'SchoenbergOp27No3': ('Schoenberg', 'Op. 27 No. 3', 'Four Pieces for Mixed Chorus, No. 3', - [7, 6, 2, 4, 5, 3, 11, 0, 8, 10, 9, 1]), + [7, 6, 2, 4, 5, 3, 11, 0, 8, 10, 9, 1]), 'SchoenbergOp27No2': ('Schoenberg', 'Op. 27 No. 2', 'Four Pieces for Mixed Chorus, No. 2', - [0, 11, 4, 10, 2, 8, 3, 7, 6, 5, 9, 1]), + [0, 11, 4, 10, 2, 8, 3, 7, 6, 5, 9, 1]), 'SchoenbergFragPiano': ('Schoenberg', None, 'Fragment For Piano', - [6, 9, 0, 7, 1, 2, 8, 11, 5, 10, 4, 3]), + [6, 9, 0, 7, 1, 2, 8, 11, 5, 10, 4, 3]), 'SchoenbergOp50B': ('Schoenberg', 'Op. 50B', 'De Profundis', - [3, 9, 8, 4, 2, 10, 7, 11, 0, 6, 5, 1]), + [3, 9, 8, 4, 2, 10, 7, 11, 0, 6, 5, 1]), 'SchoenbergOp50C': ('Schoenberg', 'Op. 50C', 'Modern Psalms, The First Psalm', - [4, 3, 0, 8, 11, 7, 5, 9, 6, 10, 1, 2]), + [4, 3, 0, 8, 11, 7, 5, 9, 6, 10, 1, 2]), 'SchoenbergOp50A': ('Schoenberg', 'Op. 50A', 'Three Times A Thousand Years', - [7, 9, 6, 4, 5, 11, 10, 2, 0, 1, 3, 8]), + [7, 9, 6, 4, 5, 11, 10, 2, 0, 1, 3, 8]), 'SchoenbergMosesAron': ('Schoenberg', None, 'Moses And Aron', - [9, 10, 4, 2, 3, 1, 7, 5, 6, 8, 11, 0]), + [9, 10, 4, 2, 3, 1, 7, 5, 6, 8, 11, 0]), 'WebernOp25': ('Webern', 'Op. 25', 'Three Songs', - [7, 4, 3, 6, 1, 5, 2, 11, 10, 0, 9, 8]), + [7, 4, 3, 6, 1, 5, 2, 11, 10, 0, 9, 8]), 'SchoenbergOp23No5': ('Schoenberg', 'Op. 23, No. 5', 'Five Piano Pieces', - [1, 9, 11, 7, 8, 6, 10, 2, 4, 3, 0, 5]), + [1, 9, 11, 7, 8, 6, 10, 2, 4, 3, 0, 5]), 'SchoenbergOp28No1': ('Schoenberg', 'Op. 28 No. 1', - 'Three Satires for Mixed Chorus, No. 1', - [0, 4, 7, 1, 9, 11, 5, 3, 2, 6, 8, 10]), + 'Three Satires for Mixed Chorus, No. 1', + [0, 4, 7, 1, 9, 11, 5, 3, 2, 6, 8, 10]), 'SchoenbergOp28No3': ('Schoenberg', 'Op. 28 No. 3', - 'Three Satires for Mixed Chorus, No. 3', - [5, 6, 4, 8, 2, 10, 7, 9, 3, 11, 1, 0]), + 'Three Satires for Mixed Chorus, No. 3', + [5, 6, 4, 8, 2, 10, 7, 9, 3, 11, 1, 0]), 'WebernOp21': ('Webern', 'Op. 21', 'Chamber Symphony', - [5, 8, 7, 6, 10, 9, 3, 4, 0, 1, 2, 11]), + [5, 8, 7, 6, 10, 9, 3, 4, 0, 1, 2, 11]), 'SchoenbergIsraelExists': ('Schoenberg', None, 'Israel Exists Again', - [0, 3, 4, 9, 11, 5, 2, 1, 10, 8, 6, 7]), + [0, 3, 4, 9, 11, 5, 2, 1, 10, 8, 6, 7]), 'SchoenbergOp35No2': ('Schoenberg', 'Op. 35', 'Six Pieces for Male Chorus, No. 2', - [6, 9, 7, 1, 0, 2, 5, 11, 10, 3, 4, 8]), + [6, 9, 7, 1, 0, 2, 5, 11, 10, 3, 4, 8]), 'SchoenbergOp35No3': ('Schoenberg', 'Op. 35', 'Six Pieces for Male Chorus, No. 3', - [3, 6, 7, 8, 5, 0, 9, 10, 4, 11, 2, 1]), + [3, 6, 7, 8, 5, 0, 9, 10, 4, 11, 2, 1]), 'SchoenbergOp35No1': ('Schoenberg', 'Op. 35', 'Six Pieces for Male Chorus, No. 1', - [2, 11, 3, 5, 4, 1, 8, 10, 9, 6, 0, 7]), + [2, 11, 3, 5, 4, 1, 8, 10, 9, 6, 0, 7]), 'SchoenbergOp48No1': ('Schoenberg', 'Op. 48', 'Three Songs, No. 1, "Sommermud"', - [1, 2, 0, 6, 3, 5, 4, 10, 11, 7, 9, 8]), + [1, 2, 0, 6, 3, 5, 4, 10, 11, 7, 9, 8]), 'SchoenbergOp35No5': ('Schoenberg', 'Op. 35', 'Six Pieces for Male Chorus, No. 5', - [1, 7, 10, 2, 3, 11, 8, 4, 0, 6, 5, 9]), + [1, 7, 10, 2, 3, 11, 8, 4, 0, 6, 5, 9]), 'SchoenbergOp29': ('Schoenberg', 'Op. 29', 'Suite', - [3, 7, 6, 10, 2, 11, 0, 9, 8, 4, 5, 1]), + [3, 7, 6, 10, 2, 11, 0, 9, 8, 4, 5, 1]), 'BergLyricSuitePerm': ('Berg', None, 'Lyric Suite, Last Mvt. Permutation', - [5, 6, 10, 4, 1, 9, 2, 8, 7, 3, 0, 11]), + [5, 6, 10, 4, 1, 9, 2, 8, 7, 3, 0, 11]), 'WebernOp20': ('Webern', 'Op. 20', 'String Trio', - [8, 7, 2, 1, 6, 5, 9, 10, 3, 4, 0, 11]), + [8, 7, 2, 1, 6, 5, 9, 10, 3, 4, 0, 11]), 'SchoenbergOp46': ('Schoenberg', 'Op. 46', 'A Survivor From Warsaw', - [6, 7, 0, 8, 4, 3, 11, 10, 5, 9, 1, 2]), + [6, 7, 0, 8, 4, 3, 11, 10, 5, 9, 1, 2]), 'SchoenbergFragOrganSonata': ('Schoenberg', None, 'Fragment of Sonata For Organ', - [1, 7, 11, 3, 9, 2, 8, 6, 10, 5, 0, 4]), + [1, 7, 11, 3, 9, 2, 8, 6, 10, 5, 0, 4]), 'SchoenbergOp44': ('Schoenberg', 'Op. 44', 'Prelude To A Suite From "Genesis"', - [10, 6, 2, 5, 4, 0, 11, 8, 1, 3, 9, 7]), + [10, 6, 2, 5, 4, 0, 11, 8, 1, 3, 9, 7]), 'SchoenbergOp45': ('Schoenberg', 'Op. 45', 'String Trio', - [2, 10, 3, 9, 4, 1, 11, 8, 6, 7, 5, 0]), + [2, 10, 3, 9, 4, 1, 11, 8, 6, 7, 5, 0]), 'SchoenbergOp33A': ('Schoenberg', 'Op. 33A', 'Two Piano Pieces, No. 1', - [10, 5, 0, 11, 9, 6, 1, 3, 7, 8, 2, 4]), + [10, 5, 0, 11, 9, 6, 1, 3, 7, 8, 2, 4]), 'SchoenbergOp25': ('Schoenberg', 'Op.25', 'Suite for Piano', - [4, 5, 7, 1, 6, 3, 8, 2, 11, 0, 9, 10]), + [4, 5, 7, 1, 6, 3, 8, 2, 11, 0, 9, 10]), 'SchoenbergOp26': ('Schoenberg', 'Op. 26', 'Wind Quintet', - [3, 7, 9, 11, 1, 0, 10, 2, 4, 6, 8, 5]), + [3, 7, 9, 11, 1, 0, 10, 2, 4, 6, 8, 5]), 'SchoenbergOp33B': ('Schoenberg', 'Op. 33B', 'Two Piano Pieces, No. 2', - [11, 1, 5, 3, 9, 8, 6, 10, 7, 4, 0, 2]), + [11, 1, 5, 3, 9, 8, 6, 10, 7, 4, 0, 2]), 'BergViolinConcerto': ('Berg', None, 'Concerto For Violin And Orchestra', - [7, 10, 2, 6, 9, 0, 4, 8, 11, 1, 3, 5]), + [7, 10, 2, 6, 9, 0, 4, 8, 11, 1, 3, 5]), 'WebernOp22': ('Webern', 'Op. 22', 'Quartet For Violin, Clarinet, Tenor Sax, And Piano', - [6, 3, 2, 5, 4, 8, 9, 10, 11, 1, 7, 0]), + [6, 3, 2, 5, 4, 8, 9, 10, 11, 1, 7, 0]), 'BergLulu': ('Berg', None, 'Lulu: Primary Row', - [0, 4, 5, 2, 7, 9, 6, 8, 11, 10, 3, 1]), + [0, 4, 5, 2, 7, 9, 6, 8, 11, 10, 3, 1]), 'WebernOp30': ('Webern', 'Op. 30', 'Variations For Orchestra', - [9, 10, 1, 0, 11, 2, 3, 6, 5, 4, 7, 8]), + [9, 10, 1, 0, 11, 2, 3, 6, 5, 4, 7, 8]), 'WebernOp31': ('Webern', 'Op. 31', 'Cantata II', - [6, 9, 5, 4, 8, 3, 7, 11, 10, 2, 1, 0]), + [6, 9, 5, 4, 8, 3, 7, 11, 10, 2, 1, 0]), 'WebernOpNo17No1': ('Webern', 'Op. 17, No. 1', '"Armer Sunder, Du"', - [11, 10, 5, 6, 3, 4, 7, 8, 9, 0, 1, 2]), + [11, 10, 5, 6, 3, 4, 7, 8, 9, 0, 1, 2]), 'WebernOp24': ('Webern', 'Op. 24', 'Concerto For Nine Instruments', - [11, 10, 2, 3, 7, 6, 8, 4, 5, 0, 1, 9]), + [11, 10, 2, 3, 7, 6, 8, 4, 5, 0, 1, 9]), 'SchoenbergOp48No2': ('Schoenberg', 'Op. 48', 'Three Songs, No. 2, "Tot"', - [2, 3, 9, 1, 10, 4, 8, 7, 0, 11, 5, 6]), + [2, 3, 9, 1, 10, 4, 8, 7, 0, 11, 5, 6]), 'WebernOp27': ('Webern', 'Op. 27', 'Variations For Piano', - [3, 11, 10, 2, 1, 0, 6, 4, 7, 5, 9, 8]), + [3, 11, 10, 2, 1, 0, 6, 4, 7, 5, 9, 8]), 'SchoenbergOp47': ('Schoenberg', 'Op. 47', 'Fantasy For Violin And Piano', - [10, 9, 1, 11, 5, 7, 3, 4, 0, 2, 8, 6]), + [10, 9, 1, 11, 5, 7, 3, 4, 0, 2, 8, 6]), 'WebernOp19No2': ('Webern', 'Op. 19, No. 2', '"Ziehn Die Schafe"', - [8, 4, 9, 6, 7, 0, 11, 5, 3, 2, 10, 1]), + [8, 4, 9, 6, 7, 0, 11, 5, 3, 2, 10, 1]), 'WebernOp19No1': ('Webern', 'Op. 19, No. 1', '"Weiss Wie Lilien"', - [7, 10, 6, 5, 3, 9, 8, 1, 2, 11, 4, 0]), + [7, 10, 6, 5, 3, 9, 8, 1, 2, 11, 4, 0]), 'WebernOp26': ('Webern', 'Op. 26', 'Das Augenlicht', - [8, 10, 9, 0, 11, 3, 4, 1, 5, 2, 6, 7]), + [8, 10, 9, 0, 11, 3, 4, 1, 5, 2, 6, 7]), 'SchoenbergFragPianoPhantasia': ('Schoenberg', None, 'Fragment of Phantasia For Piano', - [1, 5, 3, 6, 4, 8, 0, 11, 2, 9, 10, 7]), + [1, 5, 3, 6, 4, 8, 0, 11, 2, 9, 10, 7]), 'BergDerWein': ('Berg', None, 'Der Wein', - [2, 4, 5, 7, 9, 10, 1, 6, 8, 0, 11, 3]), + [2, 4, 5, 7, 9, 10, 1, 6, 8, 0, 11, 3]), 'BergWozzeckPassacaglia': ('Berg', None, 'Wozzeck, Act I, Scene 4 "Passacaglia"', - [3, 11, 7, 1, 0, 6, 4, 10, 9, 5, 8, 2]), + [3, 11, 7, 1, 0, 6, 4, 10, 9, 5, 8, 2]), 'WebernOp18No1': ('Webern', 'Op. 18, No. 1', '"Schatzerl Klein"', - [0, 11, 5, 8, 10, 9, 3, 4, 1, 7, 2, 6]), + [0, 11, 5, 8, 10, 9, 3, 4, 1, 7, 2, 6]), 'WebernOp18No2': ('Webern', 'Op. 18, No. 2', '"Erlosung"', - [6, 9, 5, 8, 4, 7, 3, 11, 2, 10, 1, 0]), + [6, 9, 5, 8, 4, 7, 3, 11, 2, 10, 1, 0]), 'WebernOp18No3': ('Webern', 'Op. 18, No. 3', '"Ave, Regina Coelorum"', - [4, 3, 7, 6, 5, 11, 10, 2, 1, 0, 9, 8]), + [4, 3, 7, 6, 5, 11, 10, 2, 1, 0, 9, 8]), 'SchoenbergOp42': ('Schoenberg', 'Op. 42', 'Concerto For Piano And Orchestra', - [3, 10, 2, 5, 4, 0, 6, 8, 1, 9, 11, 7]), + [3, 10, 2, 5, 4, 0, 6, 8, 1, 9, 11, 7]), 'SchoenbergOp48No3': ('Schoenberg', 'Op. 48', 'Three Songs, No, 3, "Madchenlied"', - [1, 7, 9, 11, 3, 5, 10, 6, 4, 0, 8, 2]), + [1, 7, 9, 11, 3, 5, 10, 6, 4, 0, 8, 2]), 'SchoenbergOp37': ('Schoenberg', 'Op. 37', 'Fourth String Quartet', - [2, 1, 9, 10, 5, 3, 4, 0, 8, 7, 6, 11]), + [2, 1, 9, 10, 5, 3, 4, 0, 8, 7, 6, 11]), 'SchoenbergOp36': ('Schoenberg', 'Op. 36', 'Concerto for Violin and Orchestra', - [9, 10, 3, 11, 4, 6, 0, 1, 7, 8, 2, 5]), + [9, 10, 3, 11, 4, 6, 0, 1, 7, 8, 2, 5]), 'SchoenbergOp34': ('Schoenberg', 'Op. 34', 'Accompaniment to a Film Scene', - [3, 6, 2, 4, 1, 0, 9, 11, 10, 8, 5, 7]), + [3, 6, 2, 4, 1, 0, 9, 11, 10, 8, 5, 7]), 'BergChamberConcerto': ('Berg', None, 'Chamber Concerto', - [11, 7, 5, 9, 2, 3, 6, 8, 0, 1, 4, 10]), + [11, 7, 5, 9, 2, 3, 6, 8, 0, 1, 4, 10]), 'SchoenbergOp32': ('Schoenberg', 'Op. 32', 'Von Heute Auf Morgen', - [2, 3, 9, 1, 11, 5, 8, 7, 4, 0, 10, 6]), + [2, 3, 9, 1, 11, 5, 8, 7, 4, 0, 10, 6]), 'SchoenbergOp31': ('Schoenberg', 'Op. 31', 'Variations for Orchestra', - [10, 4, 6, 3, 5, 9, 2, 1, 7, 8, 11, 0]), + [10, 4, 6, 3, 5, 9, 2, 1, 7, 8, 11, 0]), 'SchoenbergOp30': ('Schoenberg', 'Op. 30', 'Third String Quartet', - [7, 4, 3, 9, 0, 5, 6, 11, 10, 1, 8, 2]), + [7, 4, 3, 9, 0, 5, 6, 11, 10, 1, 8, 2]), 'BergLyricSuite': ('Berg', None, 'Lyric Suite Primary Row', - [5, 4, 0, 9, 7, 2, 8, 1, 3, 6, 10, 11]), + [5, 4, 0, 9, 7, 2, 8, 1, 3, 6, 10, 11]), 'SchoenbergOp41': ('Schoenberg', 'Op. 41', 'Ode To Napoleon', - [1, 0, 4, 5, 9, 8, 3, 2, 6, 7, 11, 10]), + [1, 0, 4, 5, 9, 8, 3, 2, 6, 7, 11, 10]), 'WebernOp17No3': ('Webern', 'Op. 17, No. 3', '"Heiland, Unsere Missetaten..."', - [8, 5, 4, 3, 7, 6, 0, 1, 2, 11, 10, 9]), + [8, 5, 4, 3, 7, 6, 0, 1, 2, 11, 10, 9]), 'WebernOp17No2': ('Webern', 'Op. 17, No. 2', '"Liebste Jungfrau"', - [1, 0, 11, 7, 8, 2, 3, 6, 5, 4, 9, 10]) + [1, 0, 11, 7, 8, 2, 3, 6, 5, 4, 9, 10]) } # ------------------------------------------------------------------------------ @@ -304,7 +304,7 @@ def __init__(self, row=None, **keywords): else: n = pc - n.pitch.octave = None + n.pitch.octaveIsImplicit = True self.append(n) def _reprInternal(self): @@ -399,7 +399,7 @@ def makeTwelveToneRow(self): n = note.Note() n.duration.quarterLength = 0.0 n.pitch.pitchClass = thisPc - n.pitch.octave = None + n.pitch.octaveIsImplicit = True a.append(n) return a @@ -427,7 +427,6 @@ def isSameRow(self, other): def getIntervalsAsString(self): ''' - Returns the string of intervals between consecutive pitch classes of a :class:`~music21.serial.ToneRow`. 'T' = 10, 'E' = 11. @@ -701,7 +700,7 @@ def matrix(self): n = note.Note() n.duration.quarterLength = 0.0 n.pitch.pitchClass = p - n.pitch.octave = None + n.pitch.octaveIsImplicit = True rowObject.append(n) matrixObj.insert(0, rowObject) @@ -1287,7 +1286,7 @@ def pcToToneRow(pcSet): for thisPc in pcSet: n = note.Note() n.pitch.pitchClass = thisPc - n.pitch.octave = None + n.pitch.octaveIsImplicit = True a.append(n) return a diff --git a/music21/sieve.py b/music21/sieve.py index 8a35fa31e7..5f60f01f2a 100644 --- a/music21/sieve.py +++ b/music21/sieve.py @@ -203,7 +203,7 @@ def rabinMiller(n): # primes up to 100; 2, 3 handled by mod 6 primes = [5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, - 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97] + 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97] if n <= 100: if n in primes: @@ -253,7 +253,6 @@ def discreteBinaryPad(series: Iterable[int], fixRange=None) -> list[int]: >>> sieve.discreteBinaryPad([3, 4, 5]) [1, 1, 1] - ''' # make sure these are ints for x in series: @@ -293,7 +292,6 @@ def unitNormRange(series, fixRange=None): >>> sieve.unitNormRange([1, 3, 4]) [0.0, 0.666..., 1.0] - ''' if fixRange is not None: fixRange.sort() @@ -367,7 +365,6 @@ def unitNormStep(step, a=0, b=1, normalized=True): >>> post = sieve.unitNormStep(0.25, 0, 20, normalized=False) >>> len(post) 81 - ''' if a == b: return [] # no range, return boundary @@ -487,7 +484,7 @@ def _fillRange(self): segNeg = [-x for x in segNeg] # make negative if len(segNeg) < self.length: segPos = self._fillRabinMiller(0, self.length - len(segNeg), - None, 'up') + None, 'up') self.seg = segNeg + segPos else: # add positive values self.seg = segNeg @@ -586,14 +583,11 @@ def segment(self, n=0, z=None, segmentFormat=None): segmentFormat = self._segmentFormat subset = [] - if self._m == 0: - return subset # empty - - n = (n + self._shift) % self._m # check for n >= m - - for value in z: - if n == value % self._m: - subset.append(value) + if self._m != 0: + n = (n + self._shift) % self._m # check for n >= m + for value in z: + if n == value % self._m: + subset.append(value) if self._neg: # find opposite compSet = copy.deepcopy(z) for value in subset: @@ -869,7 +863,6 @@ def _zUpdate(self, z=None): # -------------------------------------------------------------------------- def __call__(self): ''' - >>> a = sieve.CompressionSegment([3, 4, 5, 6, 7, 8]) >>> b = a() >>> str(b[0]) @@ -1053,7 +1046,7 @@ def _initCompression(self): try: self._cmpIntersection() self._expType = 'simple' # only if successful - except TypeError: # attempted intersection of complemented + except (TypeError, ResidualException): # attempted intersection of complemented try: self._cmpSegment() except IndexError: # no segments available @@ -2010,9 +2003,6 @@ def getIntervalSequence(self) -> list[interval.Interval]: # ------------------------------------------------------------------------------ class Test(unittest.TestCase): - def testDummy(self): - self.assertEqual(True, True) - def pitchOut(self, listIn): out = '[' for p in listIn: @@ -2042,6 +2032,15 @@ def testSieveParse(self): testObj = Sieve(arg) dummy = testObj(0, list(range(30))) + def testComplementOfEmptyResidual(self): + z = list(range(6)) + self.assertEqual(Residual(0, 0).segment(0, z), []) + self.assertEqual(Residual(0, 0, neg=True).segment(0, z), z) + self.assertEqual(Sieve('-0@0', z).segment('exp'), z) + self.assertEqual(Sieve('-0@0', z).segment('cmp'), z) + self.assertEqual(Residual(0, 0).segment(0, z, 'bin'), [0] * 6) + self.assertEqual(Sieve('-0@0|3@1', z).segment('exp'), z) + def testSievePitch(self): unused_testObj = PitchSieve('-5 | 4 & 4sub3 & 6', 'b3', 'f#4') testObj = PitchSieve('-5 | 4 & 4sub3 & 6') @@ -2097,22 +2096,18 @@ def testSieve(self): '{-{13@3|13@5|13@7|13@9}&11@2}|{-{11@4|11@8}&13@9}|{13@0|13@1|13@6}') def testPitchSieveA(self): - from music21 import sieve - - s1 = sieve.PitchSieve('3@0|7@0', 'c2', 'c6') + s1 = PitchSieve('3@0|7@0', 'c2', 'c6') self.assertEqual(self.pitchOut(s1()), '[C2, E-2, F#2, G2, A2, C3, D3, E-3, F#3, A3, C4, E-4, ' 'E4, F#4, A4, B4, C5, E-5, F#5, A5, C6]') - s1 = sieve.PitchSieve('3@0|7@0', 'c2', 'c6', eld=2) + s1 = PitchSieve('3@0|7@0', 'c2', 'c6', eld=2) self.assertEqual(self.pitchOut(s1()), '[C2, D2, F#2, C3, E3, F#3, C4, F#4, C5, F#5, G#5, C6]') def testPitchSieveB(self): - from music21 import sieve - # microtonal elds - s1 = sieve.PitchSieve('1@0', 'c2', 'c6', eld=0.5) + s1 = PitchSieve('1@0', 'c2', 'c6', eld=0.5) self.assertEqual(self.pitchOut(s1()), '[C2, C~2, C#2, C#~2, D2, D~2, E-2, E`2, E2, E~2, F2, F~2, F#2, ' 'F#~2, G2, G~2, G#2, G#~2, A2, A~2, B-2, B`2, B2, B~2, C3, C~3, C#3, ' @@ -2122,12 +2117,21 @@ def testPitchSieveB(self): 'B`4, B4, B~4, C5, C~5, C#5, C#~5, D5, D~5, E-5, E`5, E5, E~5, F5, F~5, ' 'F#5, F#~5, G5, G~5, G#5, G#~5, A5, A~5, B-5, B`5, B5, B~5, C6]') - s1 = sieve.PitchSieve('3@0', 'c2', 'c6', eld=0.5) + s1 = PitchSieve('3@0', 'c2', 'c6', eld=0.5) self.assertEqual(self.pitchOut(s1()), '[C2, C#~2, E-2, E~2, F#2, G~2, A2, B`2, C3, C#~3, E-3, E~3, F#3, G~3, ' 'A3, B`3, C4, C#~4, E-4, E~4, F#4, G~4, A4, B`4, C5, C#~5, E-5, E~5, F#5, ' 'G~5, A5, B`5, C6]') + def testIntersectComplementUnbracketed(self): + # an unbracketed intersection with a complemented residual + # should evaluate the same as the bracketed form + z = list(range(24)) + for formula in ('3@1&-4@2', '-4@2&3@1'): + self.assertEqual(Sieve(formula, z=z)(), + Sieve(f'({formula})', z=z)()) + self.assertEqual(Sieve('3@1&-4@2', z=z)(), [1, 4, 7, 13, 16, 19]) + # sieve that breaks LCM # >>> t = sieve.Sieve((3, 99, 123123, 2433, 2050)) diff --git a/music21/sites.py b/music21/sites.py index 9d9598f33c..645023faa9 100644 --- a/music21/sites.py +++ b/music21/sites.py @@ -114,7 +114,8 @@ def _reprInternal(self): return f'{self.siteIndex}/{self.globalSiteIndex} to {siteRepr}' - def _getAndUnwrapSite(self): + @property + def site(self): if WEAKREF_ACTIVE: ret = common.unwrapWeakref(self.siteWeakref) else: @@ -125,15 +126,14 @@ def _getAndUnwrapSite(self): return ret - def _setAndWrapSite(self, site): + @site.setter + def site(self, site): if WEAKREF_ACTIVE: self.siteWeakref = common.wrapWeakref(site) else: self.siteWeakref = site self.isDead = False - site = property(_getAndUnwrapSite, _setAndWrapSite) - # called before pickling. def __getstate__(self): currentSite = None @@ -281,7 +281,6 @@ def __len__(self): >>> aContexts.add(aObj) >>> len(aContexts) 2 - ''' return len(self.siteDict) @@ -585,7 +584,6 @@ def getAttrByName(self, attrName): >>> aSites.getAttrByName('blah') is None True - ''' post = None for obj in self.yieldSites(sortByCreationTime='reverse'): @@ -951,7 +949,6 @@ def remove(self, site): >>> aSites.remove(aSite) >>> len(aSites) 3 - ''' # must clear self._lastID = -1 # cannot be None diff --git a/music21/sorting.py b/music21/sorting.py index 626f6ccc18..0e2c48efd1 100644 --- a/music21/sorting.py +++ b/music21/sorting.py @@ -1,10 +1,10 @@ # ----------------------------------------------------------------------------- # Name: sorting.py -# Purpose: Music21 class for sorting +# Purpose: Music21 class for sorting Music21Objects # # Authors: Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2014-2015 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2014-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ----------------------------------------------------------------------------- ''' @@ -37,16 +37,7 @@ class SortingException(exceptions21.Music21Exception): pass -_SortTupleBase = t.NamedTuple('SortTuple', [ # type: ignore[name-match] - ('atEnd', int), - ('offset', OffsetQL), - ('priority', int), - ('classSortOrder', int), - ('isNotGrace', int), - ('insertIndex', int), -]) - -class SortTuple(_SortTupleBase): +class SortTuple(t.NamedTuple): ''' Derived class of namedTuple which allows for comparisons with pure ints/fractions. @@ -102,14 +93,20 @@ class SortTuple(_SortTupleBase): >>> st.shortRepr() '1.0 <0.20.323>' + * Changed in v11: priority and classSortOrder can be floats. ''' - def __new__(cls, *tupEls, **keywords): - # noinspection PyTypeChecker - return super(SortTuple, cls).__new__(cls, *tupEls, **keywords) + atEnd: int + offset: OffsetQL + priority: int | float + classSortOrder: int | float + isNotGrace: int + insertIndex: int def __eq__(self, other): if isinstance(other, tuple): - return super().__eq__(other) + # NB: tuple.__eq__ not super() -- bare super() in a class-syntax NamedTuple + # body triggers a __classcell__ RuntimeError at class creation. + return tuple.__eq__(self, other) try: if self.atEnd == 1 and other != INFINITY: return False @@ -122,7 +119,7 @@ def __eq__(self, other): def __lt__(self, other): if isinstance(other, tuple): - return super().__lt__(other) + return tuple.__lt__(self, other) try: if self.atEnd == 1: return False @@ -133,7 +130,7 @@ def __lt__(self, other): def __gt__(self, other): if isinstance(other, tuple): - return super().__gt__(other) + return tuple.__gt__(self, other) try: if self.atEnd == 1 and other != INFINITY: return True @@ -184,10 +181,11 @@ def shortRepr(self): reprParts.append('>') return ''.join(reprParts) - def modify(self, **keywords): + def modify(self, **keywords) -> SortTuple: ''' Return a new SortTuple identical to the previous, except with - the given keyword modified. Works only with keywords. + the given keyword modified. Works only with keywords. (Identical to + ``_replace()`` and ``copy.replace()`` on Py 3.13+) >>> st = sorting.SortTuple(atEnd=0, offset=1.0, priority=0, classSortOrder=20, ... isNotGrace=1, insertIndex=32) @@ -201,16 +199,16 @@ def modify(self, **keywords): >>> st3.shortRepr() 'End <0.20.[Grace].32>' - The original tuple is never modified (hence tuple): + The original tuple is never modified (hence why it's a tuple): >>> st.offset 1.0 - Changing offset, but nothing else, helps in creating .flatten() positions. + `Stream.flatten()` uses `.modify` to change offsets while keeping other values identical. + + * Changed in v11: a bad field name now raises ValueError ''' - # _fields are the namedtuple attributes - outList = [keywords.get(attr, getattr(self, attr)) for attr in self._fields] - return self.__class__(*outList) + return self._replace(**keywords) # pylint: disable=no-member def add(self, other): ''' @@ -221,42 +219,47 @@ def add(self, other): >>> n.offset = 10 >>> s = stream.Stream() >>> s.offset = 10 - >>> n.sortTuple() + >>> nt = n.sortTuple() + >>> nt SortTuple(atEnd=0, offset=10.0, priority=0, classSortOrder=20, isNotGrace=1, insertIndex=0) - >>> s.sortTuple() + >>> st = s.sortTuple() + >>> st SortTuple(atEnd=0, offset=10.0, priority=0, classSortOrder=-20, isNotGrace=1, insertIndex=0) - >>> s.sortTuple().add(n.sortTuple()) + >>> st.add(nt) SortTuple(atEnd=0, offset=20.0, priority=0, classSortOrder=0, isNotGrace=1, insertIndex=0) - Note that atEnd and isNotGrace are equal to other's value. are upper bounded at 1 and + Note that `atEnd` and `isNotGrace` are upper bounded at 1 and take the maxValue of either. ''' if not isinstance(other, self.__class__): raise SortingException('Cannot add attributes from a different class') outList = [max(getattr(self, attr), getattr(other, attr)) - if attr in ('atEnd', 'isNotGrace') - else (getattr(self, attr) + getattr(other, attr)) - for attr in self._fields] # _fields are the namedtuple attributes + if attr in ('atEnd', 'isNotGrace') + else (getattr(self, attr) + getattr(other, attr)) + for attr in self._fields] # pylint: disable=no-member return self.__class__(*outList) def sub(self, other): ''' - Subtract all attributes from to another. atEnd and isNotGrace take the min value of either. + Subtract all attributes from another SortTuple. + `atEnd` and `isNotGrace` take the min value of either. >>> n = note.Note() >>> n.offset = 10 >>> s = stream.Stream() >>> s.offset = 10 - >>> n.sortTuple() + >>> nt = n.sortTuple() + >>> nt SortTuple(atEnd=0, offset=10.0, priority=0, classSortOrder=20, isNotGrace=1, insertIndex=0) - >>> s.sortTuple() + >>> st = s.sortTuple() + >>> st SortTuple(atEnd=0, offset=10.0, priority=0, classSortOrder=-20, isNotGrace=1, insertIndex=0) - >>> s.sortTuple().sub(n.sortTuple()) + >>> st.sub(nt) SortTuple(atEnd=0, offset=0.0, priority=0, classSortOrder=-40, isNotGrace=1, insertIndex=0) - Note that atEnd and isNotGrace are lower bounded at 0. + Note that `atEnd` and `isNotGrace` are lower bounded at 0. ''' if not isinstance(other, self.__class__): raise SortingException('Cannot add attributes from a different class') @@ -264,7 +267,7 @@ def sub(self, other): outList = [min(getattr(self, attr), getattr(other, attr)) if attr in ('atEnd', 'isNotGrace') else (getattr(self, attr) - getattr(other, attr)) - for attr in self._fields] # _fields are the namedtuple attributes + for attr in self._fields] # pylint: disable=no-member return self.__class__(*outList) diff --git a/music21/spanner.py b/music21/spanner.py index 0ae0bb55f2..c12c6aab66 100644 --- a/music21/spanner.py +++ b/music21/spanner.py @@ -349,7 +349,6 @@ def purgeLocations(self, rescanIsDead=False): # -------------------------------------------------------------------------- def __getitem__(self, key): ''' - >>> n1 = note.Note('g') >>> n2 = note.Note('f#') >>> c1 = clef.BassClef() @@ -399,7 +398,6 @@ def getSpannedElements(self): def getSpannedElementsByClass(self, classFilterList): ''' - >>> n1 = note.Note('g') >>> n2 = note.Note('f#') >>> c1 = clef.AltoClef() @@ -699,7 +697,8 @@ def fill( else: endOffsetInHierarchy = opFrac(startOffsetInHierarchy + startElement.quarterLength) - matchIterator = (searchStream + matchIterator = ( + searchStream .recurse() .getElementsByOffsetInHierarchy( startOffsetInHierarchy, @@ -1772,10 +1771,21 @@ def _numberSpanIsContiguous(self) -> bool: return common.contiguousList(self.numberRange) # property to enforce numerical numbers - def _getNumber(self) -> str: + @property + def number(self) -> str: ''' - This must return a string, as we may have single numbers or lists. - For a raw numerical list, look at `.numberRange`. + Get or set the number -- returning a string always, as we may have + single numbers or lists. For a raw numerical list, look at `.numberRange`. + + >>> rb = spanner.RepeatBracket() + >>> rb.number + '' + >>> rb.number = '5-7' + >>> rb.number + '5-7' + >>> rb.numberRange + [5, 6, 7] + >>> rb.number = 1 ''' if len(self.numberRange) == 1: if self.numberRange[0] == 0: @@ -1789,10 +1799,9 @@ def _getNumber(self) -> str: else: # range of values return f'{self.numberRange[0]}-{self.numberRange[-1]}' - def _setNumber(self, value: int|str|Iterable[int]): - ''' - Set the bracket number. There may be a range of values provided. - ''' + @number.setter + def number(self, value: int|str|Iterable[int]): + # There may be a range of values provided. if value == '': # undefined. self.numberRange = [0] @@ -1822,20 +1831,6 @@ def _setNumber(self, value: int|str|Iterable[int]): else: raise SpannerException(f'number for RepeatBracket must be a number, not {value!r}') - number = property(_getNumber, _setNumber, doc=''' - Get or set the number -- returning a string always. - - >>> rb = spanner.RepeatBracket() - >>> rb.number - '' - >>> rb.number = '5-7' - >>> rb.number - '5-7' - >>> rb.numberRange - [5, 6, 7] - >>> rb.number = 1 - ''') - def _reprInternal(self): if self.overrideDisplay is not None: msg = self.overrideDisplay + ' ' @@ -1929,10 +1924,24 @@ def __init__(self, self.placement = placement # can above or below, after musicxml self.transposing = transposing - def _getType(self): + @property + def type(self): + ''' + Get or set Ottava type. This can be set by as complete string + (such as 8va or 15mb) or with a pair specifying size and direction. + + >>> os = spanner.Ottava() + >>> os.type = '8vb' + >>> os.type + '8vb' + >>> os.type = 15, 'down' + >>> os.type + '15mb' + ''' return self._type - def _setType(self, newType): + @type.setter + def type(self, newType): if common.isNum(newType) and newType in (8, 15): if newType == 8: self._type = '8va' @@ -1959,19 +1968,6 @@ def _setType(self, newType): f'cannot create Ottava of type: {newType}') self._type = newType.lower() - type = property(_getType, _setType, doc=''' - Get or set Ottava type. This can be set by as complete string - (such as 8va or 15mb) or with a pair specifying size and direction. - - >>> os = spanner.Ottava() - >>> os.type = '8vb' - >>> os.type - '8vb' - >>> os.type = 15, 'down' - >>> os.type - '15mb' - ''') - def _reprInternal(self): transposing = 'transposing' if not self.transposing: @@ -2155,62 +2151,56 @@ def __init__( if startHeight is not None: self.startHeight = startHeight # use property - def _getEndTick(self): + @property + def endTick(self): + ''' + Get or set the endTick property. + ''' return self._endTick - def _setEndTick(self, value): + @endTick.setter + def endTick(self, value): if value.lower() not in self.validTickTypes: raise SpannerException(f'not a valid value: {value}') self._endTick = value.lower() - endTick = property(_getEndTick, _setEndTick, doc=''' - Get or set the endTick property. - ''') - - def _getStartTick(self): + @property + def startTick(self): + ''' + Get or set the startTick property. + ''' return self._startTick - def _setStartTick(self, value): + @startTick.setter + def startTick(self, value): if value.lower() not in self.validTickTypes: raise SpannerException(f'not a valid value: {value}') self._startTick = value.lower() - startTick = property(_getStartTick, _setStartTick, doc=''' - Get or set the startTick property. - ''') - - def _getTick(self): - return self._startTick # just returning start - - def _setTick(self, value): - if value.lower() not in self.validTickTypes: - raise SpannerException(f'not a valid value: {value}') - self._startTick = value.lower() - self._endTick = value.lower() - - tick = property(_getTick, _setTick, doc=''' + @property + def tick(self): + ''' Set the start and end tick to the same value - >>> b = spanner.Line() >>> b.tick = 'arrow' >>> b.startTick 'arrow' >>> b.endTick 'arrow' - ''') - - def _getLineType(self): - return self._lineType + ''' + return self._startTick # just returning start - def _setLineType(self, value): - if value is not None and value.lower() not in self.validLineTypes: + @tick.setter + def tick(self, value): + if value.lower() not in self.validTickTypes: raise SpannerException(f'not a valid value: {value}') - # not sure if we should permit setting as None - if value is not None: - self._lineType = value.lower() + self._startTick = value.lower() + self._endTick = value.lower() - lineType = property(_getLineType, _setLineType, doc=''' + @property + def lineType(self): + ''' Get or set the lineType property. Valid line types are listed in .validLineTypes. >>> b = spanner.Line() @@ -2221,41 +2211,52 @@ def _setLineType(self, value): >>> b.validLineTypes ('solid', 'dashed', 'dotted', 'wavy') - ''') - - def _getEndHeight(self): - return self._endHeight + ''' + return self._lineType - def _setEndHeight(self, value): - if not (common.isNum(value) and value >= 0): + @lineType.setter + def lineType(self, value): + if value is not None and value.lower() not in self.validLineTypes: raise SpannerException(f'not a valid value: {value}') - self._endHeight = value + # not sure if we should permit setting as None + if value is not None: + self._lineType = value.lower() - endHeight = property(_getEndHeight, _setEndHeight, doc=''' + @property + def endHeight(self): + ''' Get or set the endHeight property. >>> b = spanner.Line() >>> b.endHeight = -20 Traceback (most recent call last): music21.spanner.SpannerException: not a valid value: -20 - ''') - - def _getStartHeight(self): - return self._startHeight + ''' + return self._endHeight - def _setStartHeight(self, value): + @endHeight.setter + def endHeight(self, value): if not (common.isNum(value) and value >= 0): raise SpannerException(f'not a valid value: {value}') - self._startHeight = value + self._endHeight = value - startHeight = property(_getStartHeight, _setStartHeight, doc=''' + @property + def startHeight(self): + ''' Get or set the startHeight property. >>> b = spanner.Line() >>> b.startHeight = None Traceback (most recent call last): music21.spanner.SpannerException: not a valid value: None - ''') + ''' + return self._startHeight + + @startHeight.setter + def startHeight(self, value): + if not (common.isNum(value) and value >= 0): + raise SpannerException(f'not a valid value: {value}') + self._startHeight = value class Glissando(Spanner): @@ -2299,18 +2300,19 @@ def __init__(self, if label is not None: self.label = label # use property - def _getLineType(self): + @property + def lineType(self): + ''' + Get or set the lineType property. See Line for valid line types. + ''' return self._lineType - def _setLineType(self, value): + @lineType.setter + def lineType(self, value): if value.lower() not in self.validLineTypes: raise SpannerException(f'not a valid value: {value}') self._lineType = value.lower() - lineType = property(_getLineType, _setLineType, doc=''' - Get or set the lineType property. See Line for valid line types. - ''') - @property def slideType(self): ''' @@ -2401,10 +2403,9 @@ def testBasic(self): def testSpannerAnchorRepr(self): from music21 import stream - from music21 import spanner # SpannerAnchor with no activeSite - sa1 = spanner.SpannerAnchor() + sa1 = SpannerAnchor() self.assertEqual(repr(sa1), '') # SpannerAnchor with activeSite, but no duration @@ -2417,19 +2418,17 @@ def testSpannerAnchorRepr(self): self.assertEqual(repr(sa1), '') def testSpannerRepr(self): - from music21 import spanner - su1 = spanner.Slur() + su1 = Slur() self.assertEqual(repr(su1), '') def testSpannerFill(self): from music21 import stream from music21 import note - from music21 import spanner theNotes = [note.Note('A'), note.Note('B'), note.Note('C'), note.Note('D')] m = stream.Measure(theNotes) # Spanner with no fillElementTypes - sp = spanner.Spanner(theNotes[0], theNotes[3]) + sp = Spanner(theNotes[0], theNotes[3]) sp.fill(m) # should not have done anything noFillElements = [theNotes[0], theNotes[3]] @@ -2438,7 +2437,7 @@ def testSpannerFill(self): self.assertIs(el, noFillElements[i]) # Ottava with filledStatus == True - ott1 = spanner.Ottava(noFillElements) + ott1 = Ottava(noFillElements) ott1.filledStatus = True # pretend it has already been filled ott1.fill(m) # should not have done anything @@ -2456,20 +2455,20 @@ def testSpannerFill(self): self.assertIs(el, theNotes[i]) # Ottava with no elements - ott2 = spanner.Ottava() + ott2 = Ottava() ott2.fill(m) self.assertEqual(len(ott2), 0) # Ottava with only element not in searchStream expectedElements = [note.Note('E')] - ott3 = spanner.Ottava(expectedElements) + ott3 = Ottava(expectedElements) ott3.fill(m) self.assertEqual(len(ott3), 1) self.assertIs(ott3.getFirst(), expectedElements[0]) # Ottava with start element not in searchStream, end element is expectedElements = [note.Note('F'), m.notes[0]] - ott4 = spanner.Ottava(expectedElements) + ott4 = Ottava(expectedElements) ott4.fill(m) self.assertEqual(len(ott4), 2) for i, el in enumerate(ott4.getSpannedElements()): @@ -2477,40 +2476,38 @@ def testSpannerFill(self): # Ottava with endElement not in searchStream, startElement is expectedElements = [m.notes[0], note.Note('G')] - ott5 = spanner.Ottava(expectedElements) + ott5 = Ottava(expectedElements) ott5.fill(m) self.assertEqual(len(ott5), 2) for i, el in enumerate(ott5.getSpannedElements()): self.assertIs(el, expectedElements[i]) def testSpannerBundle(self): - from music21 import spanner from music21 import stream - su1 = spanner.Slur() + su1 = Slur() su1.idLocal = 1 - su2 = spanner.Slur() + su2 = Slur() su2.idLocal = 2 - sb = spanner.SpannerBundle() + sb = SpannerBundle() sb.append(su1) sb.append(su2) self.assertEqual(len(sb), 2) self.assertEqual(sb[0], su1) self.assertEqual(sb[1], su2) - su3 = spanner.Slur() - su4 = spanner.Slur() + su3 = Slur() + su4 = Slur() s = stream.Stream() s.append(su3) s.append(su4) - sb2 = spanner.SpannerBundle(list(s)) + sb2 = SpannerBundle(list(s)) self.assertEqual(len(sb2), 2) self.assertEqual(sb2[0], su3) self.assertEqual(sb2[1], su4) def testDeepcopySpanner(self): - from music21 import spanner from music21 import note # how slurs might be defined @@ -2532,7 +2529,7 @@ def testDeepcopySpanner(self): self.assertEqual(n1.getSpannerSites(), [su1, su2]) self.assertEqual(n3.getSpannerSites(), [su1, su2]) - sb1 = spanner.SpannerBundle([su1, su2]) + sb1 = SpannerBundle([su1, su2]) sb2 = copy.deepcopy(sb1) self.assertEqual(sb1[0].getSpannedElements(), [n1, n3]) self.assertEqual(sb2[0].getSpannedElements(), [n1, n3]) @@ -2541,7 +2538,6 @@ def testDeepcopySpanner(self): def testReplaceSpannedElement(self): from music21 import note - from music21 import spanner n1 = note.Note() n2 = note.Note() @@ -2549,7 +2545,7 @@ def testReplaceSpannedElement(self): n4 = note.Note() n5 = note.Note() - su1 = spanner.Slur() + su1 = Slur() su1.addSpannedElements([n1, n3]) self.assertEqual(su1.getSpannedElements(), [n1, n3]) @@ -2567,10 +2563,10 @@ def testReplaceSpannedElement(self): self.assertEqual(n2.getSpannerSites(), []) self.assertEqual(n1.getSpannerSites(), [su1]) - su2 = spanner.Slur() + su2 = Slur() su2.addSpannedElements([n3, n4]) - su3 = spanner.Slur() + su3 = Slur() su3.addSpannedElements([n4, n5]) # n1a = note.Note() @@ -2579,7 +2575,7 @@ def testReplaceSpannedElement(self): n4a = note.Note() # n5a = note.Note() - sb1 = spanner.SpannerBundle([su1, su2, su3]) + sb1 = SpannerBundle([su1, su2, su3]) self.assertEqual(len(sb1), 3) self.assertEqual(list(sb1), [su1, su2, su3]) @@ -2600,18 +2596,16 @@ def testReplaceSpannedElement(self): self.assertEqual(sb1[2].getSpannedElements(), [n4a, n5]) def testRepeatBracketA(self): - from music21 import spanner from music21 import stream m1 = stream.Measure() - rb1 = spanner.RepeatBracket(m1) + rb1 = RepeatBracket(m1) # if added again; it is not really added, it simply is ignored rb1.addSpannedElements(m1) self.assertEqual(len(rb1), 1) def testRepeatBracketB(self): from music21 import note - from music21 import spanner from music21 import stream from music21 import bar @@ -2627,19 +2621,19 @@ def testRepeatBracketB(self): m3.repeatAppend(note.Note('g#4'), 4) m3.rightBarline = bar.Repeat(direction='end') p.append(m3) - p.append(spanner.RepeatBracket(m3, number=1)) + p.append(RepeatBracket(m3, number=1)) m4 = stream.Measure() m4.repeatAppend(note.Note('a4'), 4) m4.rightBarline = bar.Repeat(direction='end') p.append(m4) - p.append(spanner.RepeatBracket(m4, number=2)) + p.append(RepeatBracket(m4, number=2)) m5 = stream.Measure() m5.repeatAppend(note.Note('b4'), 4) m5.rightBarline = bar.Repeat(direction='end') p.append(m5) - p.append(spanner.RepeatBracket(m5, number=3)) + p.append(RepeatBracket(m5, number=3)) m6 = stream.Measure() m6.repeatAppend(note.Note('c#5'), 4) @@ -2651,7 +2645,6 @@ def testRepeatBracketB(self): # noinspection DuplicatedCode def testRepeatBracketC(self): from music21 import note - from music21 import spanner from music21 import stream from music21 import bar @@ -2668,7 +2661,7 @@ def testRepeatBracketC(self): m3.repeatAppend(note.Note('g#4'), 4) m3.rightBarline = bar.Repeat(direction='end') p.append(m3) - rb1 = spanner.RepeatBracket(number=1) + rb1 = RepeatBracket(number=1) rb1.addSpannedElements(m2, m3) self.assertEqual(len(rb1), 2) p.insert(0, rb1) @@ -2677,7 +2670,7 @@ def testRepeatBracketC(self): m4.repeatAppend(note.Note('a4'), 4) m4.rightBarline = bar.Repeat(direction='end') p.append(m4) - p.append(spanner.RepeatBracket(m4, number=2)) + p.append(RepeatBracket(m4, number=2)) m5 = stream.Measure() m5.repeatAppend(note.Note('b4'), 4) @@ -2696,7 +2689,6 @@ def testRepeatBracketC(self): # noinspection DuplicatedCode def testRepeatBracketD(self): from music21 import note - from music21 import spanner from music21 import stream from music21 import bar @@ -2713,7 +2705,7 @@ def testRepeatBracketD(self): m3.repeatAppend(note.Note('g#4'), 4) m3.rightBarline = bar.Repeat(direction='end') p.append(m3) - rb1 = spanner.RepeatBracket(number=1) + rb1 = RepeatBracket(number=1) rb1.addSpannedElements(m2, m3) self.assertEqual(len(rb1), 2) p.insert(0, rb1) @@ -2727,7 +2719,7 @@ def testRepeatBracketD(self): m5.rightBarline = bar.Repeat(direction='end') p.append(m5) - rb2 = spanner.RepeatBracket(number=2) + rb2 = RepeatBracket(number=2) rb2.addSpannedElements(m4, m5) self.assertEqual(len(rb2), 2) p.insert(0, rb2) @@ -2745,7 +2737,7 @@ def testRepeatBracketD(self): m8.rightBarline = bar.Repeat(direction='end') p.append(m8) - rb3 = spanner.RepeatBracket(number=3) + rb3 = RepeatBracket(number=3) rb3.addSpannedElements(m6, m8) self.assertEqual(len(rb3), 2) p.insert(0, rb3) @@ -2767,7 +2759,7 @@ def testRepeatBracketD(self): m12.rightBarline = bar.Repeat(direction='end') p.append(m12) - rb4 = spanner.RepeatBracket(number=4) + rb4 = RepeatBracket(number=4) rb4.addSpannedElements(m9, m10, m11, m12) self.assertEqual(len(rb4), 4) p.insert(0, rb4) @@ -2796,7 +2788,6 @@ def testRepeatBracketD(self): def testRepeatBracketE(self): from music21 import note - from music21 import spanner from music21 import stream from music21 import bar @@ -2812,19 +2803,19 @@ def testRepeatBracketE(self): m3.repeatAppend(note.Note('g#4'), 1) m3.rightBarline = bar.Repeat(direction='end') p.append(m3) - p.append(spanner.RepeatBracket(m3, number=1)) + p.append(RepeatBracket(m3, number=1)) m4 = stream.Measure(number=4) m4.repeatAppend(note.Note('a4'), 1) m4.rightBarline = bar.Repeat(direction='end') p.append(m4) - p.append(spanner.RepeatBracket(m4, number=2)) + p.append(RepeatBracket(m4, number=2)) m5 = stream.Measure(number=5) m5.repeatAppend(note.Note('b4'), 1) m5.rightBarline = bar.Repeat(direction='end') p.append(m5) - p.append(spanner.RepeatBracket(m5, number=3)) + p.append(RepeatBracket(m5, number=3)) m6 = stream.Measure(number=6) m6.repeatAppend(note.Note('c#5'), 1) @@ -2865,7 +2856,6 @@ def testOttavaShiftA(self): from music21 import stream from music21 import note from music21 import chord - from music21.spanner import Ottava # need to do it this way for classSet s = stream.Stream() s.repeatAppend(chord.Chord(['c-3', 'g4']), 12) # s.repeatAppend(note.Note(), 12) @@ -2917,10 +2907,9 @@ def testOttavaShiftB(self): ''' from music21 import stream from music21 import note - from music21 import spanner s = stream.Stream() n = note.Note('c4') - sp = spanner.Ottava(n) + sp = Ottava(n) s.append(n) s.append(sp) # s.show() @@ -2969,16 +2958,15 @@ def testCrescendoA(self): def testLineA(self): from music21 import stream from music21 import note - from music21 import spanner s = stream.Stream() s.repeatAppend(note.Note(), 12) n1 = s.notes[0] n2 = s.notes[len(s.notes) // 2] n3 = s.notes[-1] - sp1 = spanner.Line(n1, n2, startTick='up', lineType='dotted') - sp2 = spanner.Line(n2, n3, startTick='down', lineType='dashed', - endHeight=40) + sp1 = Line(n1, n2, startTick='up', lineType='dotted') + sp2 = Line(n2, n3, startTick='down', lineType='dashed', + endHeight=40) s.append(sp1) s.append(sp2) # s.show('t') @@ -2989,7 +2977,6 @@ def testLineA(self): def testLineB(self): from music21 import stream from music21 import note - from music21 import spanner s = stream.Stream() s.repeatAppend(note.Note(), 12) @@ -2999,8 +2986,8 @@ def testLineB(self): n3 = s.notes[0] n4 = s.notes[2] - sp1 = spanner.Line(n1, n2, startTick='up', endTick='down', lineType='solid') - sp2 = spanner.Line(n3, n4, startTick='arrow', endTick='none', lineType='solid') + sp1 = Line(n1, n2, startTick='up', endTick='down', lineType='solid') + sp2 = Line(n3, n4, startTick='arrow', endTick='none', lineType='solid') s.append(sp1) s.append(sp2) @@ -3016,7 +3003,6 @@ def testLineB(self): def testGlissandoA(self): from music21 import stream from music21 import note - from music21 import spanner s = stream.Stream() s.repeatAppend(note.Note(), 3) @@ -3027,8 +3013,8 @@ def testGlissandoA(self): n1 = s.notes[0] n2 = s.notes[len(s.notes) // 2] n3 = s.notes[-1] - sp1 = spanner.Glissando(n1, n2) - sp2 = spanner.Glissando(n2, n3) + sp1 = Glissando(n1, n2) + sp2 = Glissando(n2, n3) sp2.lineType = 'dashed' s.append(sp1) s.append(sp2) @@ -3042,7 +3028,6 @@ def testGlissandoA(self): def testGlissandoB(self): from music21 import stream from music21 import note - from music21 import spanner s = stream.Stream() s.repeatAppend(note.Note(), 12) @@ -3052,7 +3037,7 @@ def testGlissandoB(self): # note: this does not support glissandi between non-adjacent notes n1 = s.notes[0] n2 = s.notes[1] - sp1 = spanner.Glissando(n1, n2) + sp1 = Glissando(n1, n2) sp1.lineType = 'solid' sp1.label = 'gliss.' s.append(sp1) @@ -3083,7 +3068,6 @@ def testGlissandoB(self): def testOneElementSpanners(self): from music21 import note - from music21.spanner import Spanner n1 = note.Note() sp = Spanner() @@ -3096,7 +3080,6 @@ def testOneElementSpanners(self): def testRemoveSpanners(self): from music21 import stream from music21 import note - from music21.spanner import Spanner, Slur p = stream.Part() m1 = stream.Measure() @@ -3120,7 +3103,6 @@ def testFreezeSpanners(self): from music21 import stream from music21 import note from music21 import converter - from music21.spanner import Slur p = stream.Part() m1 = stream.Measure() @@ -3140,7 +3122,6 @@ def testFreezeSpanners(self): def testDeepcopyJustSpannerAndNotes(self): from music21 import note from music21 import clef - from music21.spanner import Spanner n1 = note.Note('g') n2 = note.Note('f#') @@ -3159,7 +3140,6 @@ def testDeepcopySpannerInStreamNotNotes(self): from music21 import note from music21 import clef from music21 import stream - from music21.spanner import Spanner n1 = note.Note('g') n2 = note.Note('f#') @@ -3182,7 +3162,6 @@ def testDeepcopyNotesInStreamNotSpanner(self): from music21 import note from music21 import clef from music21 import stream - from music21.spanner import Spanner n1 = note.Note('g') n2 = note.Note('f#') @@ -3206,7 +3185,6 @@ def testDeepcopyNotesInStreamNotSpanner(self): def testDeepcopyNotesAndSpannerInStream(self): from music21 import note from music21 import stream - from music21.spanner import Spanner n1 = note.Note('G4') n2 = note.Note('F#4') @@ -3231,7 +3209,6 @@ def testDeepcopyNotesAndSpannerInStream(self): def testDeepcopyStreamWithSpanners(self): from music21 import note from music21 import stream - from music21.spanner import Slur n1 = note.Note() su1 = Slur((n1,)) @@ -3255,7 +3232,6 @@ def testDeepcopyStreamWithSpanners(self): def testGetSpannedElementIds(self): from music21 import note - from music21.spanner import Spanner n1 = note.Note('g') n2 = note.Note('f#') diff --git a/music21/stream/base.py b/music21/stream/base.py index a44db74e67..e2a074e1d9 100644 --- a/music21/stream/base.py +++ b/music21/stream/base.py @@ -7,7 +7,7 @@ # Joséphine Wolf Oberholtzer # Evan Lynch # -# Copyright: Copyright © 2008-2024 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2008-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ----------------------------------------------------------------------------- ''' @@ -351,7 +351,7 @@ def __init__(self, self._atSoundingPitch: bool|t.Literal['unknown'] = 'unknown' # experimental - self._mutable = True + self._mutable: bool = True if givenElements is None: return @@ -662,7 +662,6 @@ def __getitem__(self, still works until v9. This is an attempt to unify __getitem__ behavior in StreamIterators and Streams. - allowed iterables of qualified class names, e.g. `[note.Note, note.Rest]` - ''' # need to sort if not sorted, as this call may rely on index positions if not self.isSorted and self.autoSort: @@ -1557,7 +1556,6 @@ def index(self, el: base.Music21Object) -> int: >>> tuple(s).index(n3) 0 - ''' if not self.isSorted and self.autoSort: self.sort() # will set isSorted to True @@ -2699,6 +2697,35 @@ def storeAtEnd(self, itemOrList, ignoreSort=False): # Streams cannot reside in end elements, thus do not update is flat self.coreElementsChanged(updateIsFlat=False) + def isAtEnd(self, element: base.Music21Object) -> bool: + ''' + Return True if `element` is stored at the end of this Stream + (via :meth:`~music21.stream.base.Stream.storeAtEnd`). + + >>> s = stream.Stream() + >>> n = note.Note('C') + >>> s.append(n) + >>> b = bar.Barline() + >>> s.storeAtEnd(b) + >>> s.isAtEnd(b) + True + >>> s.isAtEnd(n) + False + + If the element is not in this Stream, :exc:`~music21.sites.SitesException` + is raised: + + >>> s.isAtEnd(note.Note('E')) + Traceback (most recent call last): + music21.sites.SitesException: an entry for this object 0x... is not stored in stream + + + * New in v11. + + AI-assisted. + ''' + return self.elementOffset(element, returnSpecial=True) is OffsetSpecial.AT_END + # -------------------------------------------------------------------------- # all the following call either insert() or append() @@ -3294,7 +3321,10 @@ def splitAtQuarterLength(self, sRight.insert(0, eRight) for target in targetMove: - sRight.insert(target.getOffsetBySite(sLeft) - quarterLength, target) + if any(target is e for e in sLeft._endElements): + sRight.storeAtEnd(target) + else: + sRight.insert(target.getOffsetBySite(sLeft) - quarterLength, target) sLeft.remove(target) return base._SplitTuple([sLeft, sRight]) @@ -4288,7 +4318,6 @@ def getElementAfterElement(self, element, classList=None): Traceback (most recent call last): music21.exceptions21.StreamException: cannot find object () in Stream - ''' if classList is not None: classSet = set(classList) @@ -4383,13 +4412,13 @@ def hasMeasureNumberInformation(measureIterator: iterator.StreamIterator[Measure matches = [m for m in mStreamIter if m.number in matchingMeasureNumbers] else: matches = [m for i, m in enumerate(mStreamIter) - if i + 1 in matchingMeasureNumbers] + if i + 1 in matchingMeasureNumbers] else: if hasUniqueMeasureNumbers: matches = [m for m in mStreamIter if m.number >= numberStart] else: matches = [m for i, m in enumerate(mStreamIter) - if i + 1 >= numberStart] + if i + 1 >= numberStart] if startSuffix is not None: oldMatches = matches @@ -4624,7 +4653,7 @@ def measures( # placing missing objects in outer container, not Measure found = startMeasure.getContextByClass(className) if found is not None: - if startMeasure is not None: + if found.priority >= startMeasure.priority: found.priority = startMeasure.priority - 1 # TODO: This should not change global priority on found, but # instead priority, like offset, should be a per-site attribute @@ -5099,42 +5128,9 @@ def measureOffsetMap( orderedOffsetMap = OrderedDict(sorted(offsetMap.items(), key=lambda o: o[0])) return orderedOffsetMap - def _getFinalBarline(self): - # if we have part-like streams, process each part - if self.hasPartLikeStreams(): - post = [] - for p in self.getElementsByClass('Stream'): - post.append(p._getFinalBarline()) - return post # a list of barlines - # core routines for a single Stream - else: - if self.hasMeasures(): - return self.getElementsByClass(Measure).last().rightBarline - elif hasattr(self, 'rightBarline'): - return self.rightBarline - else: - return None - - def _setFinalBarline(self, value): - # if we have part-like streams, process each part - if self.hasPartLikeStreams(): - if not common.isListLike(value): - value = [value] - for i, p in enumerate(self.getElementsByClass('Stream')): - # set final barline w/ mod iteration of value list - bl = value[i % len(value)] - # environLocal.printDebug(['enumerating measures', i, p, 'setting barline', bl]) - p._setFinalBarline(bl) - return - - # core routines for a single Stream - if self.hasMeasures(): - self.getElementsByClass(Measure).last().rightBarline = value - elif hasattr(self, 'rightBarline'): - self.rightBarline = value # pylint: disable=attribute-defined-outside-init - # do nothing for other streams - - finalBarline = property(_getFinalBarline, _setFinalBarline, doc=''' + @property + def finalBarline(self): + ''' Get or set the final barline of this Stream's Measures, if and only if there are Measures defined as elements in this Stream. This method will not create Measures if none exist. @@ -5193,7 +5189,41 @@ def _setFinalBarline(self, value): * Changed in v6.3: does not raise an exception if queried or set on a measure-less stream. Previously raised a StreamException - ''') + ''' + # if we have part-like streams, process each part + if self.hasPartLikeStreams(): + post = [] + for p in self.getElementsByClass('Stream'): + post.append(p.finalBarline) + return post # a list of barlines + # core routines for a single Stream + else: + if self.hasMeasures(): + return self.getElementsByClass(Measure).last().rightBarline + elif hasattr(self, 'rightBarline'): + return self.rightBarline + else: + return None + + @finalBarline.setter + def finalBarline(self, value): + # if we have part-like streams, process each part + if self.hasPartLikeStreams(): + if not common.isListLike(value): + value = [value] + for i, p in enumerate(self.getElementsByClass('Stream')): + # set final barline w/ mod iteration of value list + bl = value[i % len(value)] + # environLocal.printDebug(['enumerating measures', i, p, 'setting barline', bl]) + p.finalBarline = bl + return + + # core routines for a single Stream + if self.hasMeasures(): + self.getElementsByClass(Measure).last().rightBarline = value + elif hasattr(self, 'rightBarline'): + self.rightBarline = value # pylint: disable=attribute-defined-outside-init + # do nothing for other streams @property def voices(self): @@ -5783,7 +5813,7 @@ def getInstruments(self, if searchActiveSite: # if isinstance(self.activeSite, Stream) and self.activeSite != self: if (self.activeSite is not None - and self.activeSite.isStream + and self.activeSite.isStream and self.activeSite is not self): # environLocal.printDebug(['searching activeSite Stream', # self, self.activeSite]) @@ -6151,7 +6181,6 @@ def extractContext(self, searchElement, before=4.0, after=4.0, NOTE: RENAME: this probably should be renamed, as we use Context in a special way. Perhaps better is extractNeighbors? - ''' display = self.cloneEmpty('extractContext') @@ -6216,7 +6245,6 @@ def _uniqueOffsetsAndEndTimes(self, offsetsOnly=False, endTimesOnly=False): >>> s.flatten()._uniqueOffsetsAndEndTimes(offsetsOnly=True, endTimesOnly=True) [] - ''' offsetDictValues = self._offsetDict.values() if endTimesOnly: @@ -6228,7 +6256,7 @@ def _uniqueOffsetsAndEndTimes(self, offsetsOnly=False, endTimesOnly=False): endTimes = set() else: endTimes = {opFrac(v[0] + v[1].duration.quarterLength) - for v in offsetDictValues} + for v in offsetDictValues} return sorted(offsets.union(endTimes)) def chordify( @@ -8295,7 +8323,7 @@ def makeImmutable(self): Clean this Stream: for self and all elements, purge all dead locations and remove all non-contained sites. Further, restore all active sites. ''' - if self._mutable is not False: + if self._mutable: self.sort() # must sort before making immutable for e in self.recurse(streamsOnly=True, includeSelf=False): # e.purgeLocations(rescanIsDead=True) @@ -8308,6 +8336,10 @@ def makeImmutable(self): self._mutable = False def makeMutable(self, recurse=True): + ''' + Soft-Deprecated -- this will return a New Stream at some point -- + once immutable, never mutable. + ''' self._mutable = True if recurse: for e in self.recurse(streamsOnly=True): @@ -8589,10 +8621,47 @@ def duration(self) -> 'music21.duration.Duration': def duration(self, value: 'music21.duration.Duration'): self._setDuration(value) - def _setSeconds(self, value): - pass + @property + def seconds(self): + ''' + Get or set the duration of this Stream in seconds, assuming that + this object contains a :class:`~music21.tempo.MetronomeMark` or + :class:`~music21.tempo.MetricModulation`. + + >>> s = corpus.parse('bwv66.6') # piece without a tempo + >>> sFlat = s.flatten() + >>> t = tempo.MetronomeMark('adagio') + >>> sFlat.insert(0, t) + >>> sFlat.seconds + 38.57142857... + >>> tFast = tempo.MetronomeMark('allegro') + >>> sFlat.replace(t, tFast) + >>> sFlat.seconds + 16.363... - def _getSeconds(self): + Setting seconds on streams is not supported. Ideally it would instead + scale all elements to fit, but this is a long way off. + + If a stream does not have a tempo-indication in it then the property + returns 0.0 if an empty Stream (or self.highestTime is 0.0) or 'nan' + if there are non-zero duration objects in the stream: + + >>> s = stream.Stream() + >>> s.seconds + 0.0 + >>> s.insert(0, clef.TrebleClef()) + >>> s.seconds + 0.0 + >>> s.append(note.Note(type='half')) + >>> s.seconds + nan + >>> import math + >>> math.isnan(s.seconds) + True + + * Changed in v6.3: return nan rather than raising an exception. Do not + attempt to change seconds on a stream, as it did not do what you would expect. + ''' getTempoFromContext = False # need to find all tempo indications and the number of quarter lengths # under each @@ -8639,45 +8708,9 @@ def _getSeconds(self): return sec - seconds = property(_getSeconds, _setSeconds, doc=''' - Get or set the duration of this Stream in seconds, assuming that - this object contains a :class:`~music21.tempo.MetronomeMark` or - :class:`~music21.tempo.MetricModulation`. - - >>> s = corpus.parse('bwv66.6') # piece without a tempo - >>> sFlat = s.flatten() - >>> t = tempo.MetronomeMark('adagio') - >>> sFlat.insert(0, t) - >>> sFlat.seconds - 38.57142857... - >>> tFast = tempo.MetronomeMark('allegro') - >>> sFlat.replace(t, tFast) - >>> sFlat.seconds - 16.363... - - Setting seconds on streams is not supported. Ideally it would instead - scale all elements to fit, but this is a long way off. - - If a stream does not have a tempo-indication in it then the property - returns 0.0 if an empty Stream (or self.highestTime is 0.0) or 'nan' - if there are non-zero duration objects in the stream: - - >>> s = stream.Stream() - >>> s.seconds - 0.0 - >>> s.insert(0, clef.TrebleClef()) - >>> s.seconds - 0.0 - >>> s.append(note.Note(type='half')) - >>> s.seconds - nan - >>> import math - >>> math.isnan(s.seconds) - True - - * Changed in v6.3: return nan rather than raising an exception. Do not - attempt to change seconds on a stream, as it did not do what you would expect. - ''') + @seconds.setter + def seconds(self, value): + pass def metronomeMarkBoundaries(self, srcObj=None): ''' @@ -8859,22 +8892,28 @@ def _getSecondsMap(self, srcObj=None) -> list[SecondsMapEntry]: # -------------------------------------------------------------------------- # Metadata access - def _getMetadata(self) -> metadata.Metadata|None: + @property + def metadata(self) -> metadata.Metadata|None: ''' - >>> a = stream.Stream() - >>> a.metadata = metadata.Metadata() + Get or set the :class:`~music21.metadata.Metadata` object + found at the beginning (offset 0) of this Stream. + + >>> s = stream.Stream() + >>> s.metadata = metadata.Metadata() + >>> s.metadata.composer = 'frank' + >>> s.metadata.composer + 'frank' + + May also return None if nothing is there. ''' mdList = self.getElementsByClass(metadata.Metadata) # only return metadata that has an offset = 0.0 mdList = mdList.getElementsByOffset(0) return mdList.first() - def _setMetadata(self, metadataObj: metadata.Metadata|None) -> None: - ''' - >>> a = stream.Stream() - >>> a.metadata = metadata.Metadata() - ''' - oldMetadata = self._getMetadata() + @metadata.setter + def metadata(self, metadataObj: metadata.Metadata|None) -> None: + oldMetadata = self.metadata if oldMetadata is not None: # environLocal.printDebug(['removing old metadata', oldMetadata]) junk = self.pop(self.index(oldMetadata)) @@ -8882,20 +8921,6 @@ def _setMetadata(self, metadataObj: metadata.Metadata|None) -> None: if metadataObj is not None and isinstance(metadataObj, metadata.Metadata): self.insert(0, metadataObj) - metadata = property(_getMetadata, _setMetadata, - doc=''' - Get or set the :class:`~music21.metadata.Metadata` object - found at the beginning (offset 0) of this Stream. - - >>> s = stream.Stream() - >>> s.metadata = metadata.Metadata() - >>> s.metadata.composer = 'frank' - >>> s.metadata.composer - 'frank' - - May also return None if nothing is there. - ''') - # -------------------------------------------------------------------------- # these methods override the behavior inherited from base.py @@ -9022,20 +9047,35 @@ def beatAndMeasureFromOffset(self, searchOffset, fixZeros=True): if numSuffix is not None or (fixZeros and foundMeasureNumber == 0): prevMeas = myStream.getElementBeforeOffset(myMeas.offset, classList=['Measure']) if prevMeas: - ts2 = prevMeas.getContextByClass(meter.TimeSignature) - if not ts2: + segments: list[tuple[meter.TimeSignature, OffsetQL]] = [] + contextTs = prevMeas.getContextByClass(meter.TimeSignature) + if contextTs is not None: + segments.append((contextTs, 0.0)) + for ts in prevMeas.getElementsByClass(meter.TimeSignature): + tsOffset = prevMeas.elementOffset(ts) + if segments and segments[-1][1] == tsOffset: + segments.pop() + segments.append((ts, tsOffset)) + if not segments or segments[0][1] != 0: raise StreamException( 'beatAndMeasureFromOffset: partial measure found, ' + 'but could not find a time signature for the preceding measure') # foundMeasureNumber = prevMeas.number - # need this for chorales 197 and 280, where we - # have a full-length measure followed by a pickup in - # a new time signature - if prevMeas.highestTime == ts2.barDuration.quarterLength: - padBeats = ts2.beatCount - else: - padBeats = ts2.getBeatProportion(prevMeas.highestTime) - 1 + segmentEnds = [start for unused_ts, start in segments[1:]] + segmentEnds.append(prevMeas.highestTime) + padBeats = 0 + for (ts, start), end in zip(segments, segmentEnds): + segmentLength = opFrac(end - start) + # need this for chorales 197 and 280, where we + # have a full-length measure followed by a pickup in + # a new time signature + if segmentLength == ts.barDuration.quarterLength: + padBeats += ts.beatCount + else: + padBeats += ts.getBeatProportion(segmentLength) - 1 + if myMeas.timeSignature is None: + myBeat = segments[-1][0].getBeatProportion(searchOffset - myMeas.offset) return (myBeat + padBeats, prevMeas) else: # partial measure at start of piece @@ -9496,7 +9536,10 @@ def findNextElementNotCoincident( # is unsorted originally, this "looking ahead" could become O(n^2). originallySorted = useStream.isSorted rests_lacking_durations: list[note.Rest] = [] - for i, e in enumerate(useStream._elements): + for i, e in enumerate(useStream.elements): + if useStream.isAtEnd(e): + # Elements stored at the end carry no quantizable offset. + continue if processOffsets: o = useStream.elementOffset(e) sign = 1 @@ -9601,7 +9644,6 @@ def sliceByQuarterLengths(self, quarterLengthList, *, target=None, If `target` is None, the entire Stream is processed. Otherwise, only the element specified is manipulated. - ''' if not inPlace: # make a copy returnObj = self.coreCopyAsDerivation('sliceByQuarterLengths') @@ -10297,6 +10339,9 @@ def findConsecutiveNotes( x.findConsecutiveNotes() - x.findConsecutiveNotes(skipUnisons=True) will give you the number of P1s in the piece, because there could be d2's in there as well. + * A Chord counts as a unison of the Chord before it if both have the + same pitches, in any order. A skipped unison still lasts until its + own end, so the next note follows it without a `None` between. See test.TestStream.testFindConsecutiveNotes() for usage details. @@ -10326,9 +10371,6 @@ def findConsecutiveNotes( OMIT_FROM_DOCS - N.B. for chords, currently, only the first pitch is tested for unison. - this is a bug TODO: FIX - (\*\*keywords is there so that other methods that pass along dicts to findConsecutiveNotes don't have to remove their own args; this method is used in melodicIntervals.) @@ -10377,6 +10419,7 @@ def findConsecutiveNotes( or e.pitch.pitchClass != lastPitches[0].pitchClass or (not skipOctaves and e.pitch.ps != lastPitches[0].ps)): + lastEnd = max(lastEnd, opFrac(e.offset + e.duration.quarterLength)) continue if not getOverlaps and e.offset < lastEnd: continue @@ -10398,11 +10441,12 @@ def findConsecutiveNotes( lastWasNone = True lastPitches = () # if we have a chord - elif (not (skipUnisons - and len(lastPitches) == len(e.pitches) - and (p.ps for p in e.pitches) == (p.ps for p in lastPitches) - ) - and (getOverlaps or e.offset >= lastEnd)): + elif (skipUnisons + and len(lastPitches) == len(e.pitches) + and (sorted(p.ps for p in e.pitches) + == sorted(p.ps for p in lastPitches))): + lastEnd = max(lastEnd, opFrac(e.offset + e.duration.quarterLength)) + elif getOverlaps or e.offset >= lastEnd: returnList.append(e) if e.offset < lastEnd: # is an overlap continue @@ -10435,6 +10479,8 @@ def melodicIntervals(self, **skipKeywords): the second note, but if skipRests is set to True or there is a gap in the Stream, then these two numbers will be different). + Offsets are measured from the start of this Stream, so the intervals + of a Part with Measures stand at their offsets in the Part. See :meth:`~music21.stream.Stream.findConsecutiveNotes` in this class for a discussion of what is meant by default for "consecutive notes", and @@ -10482,6 +10528,11 @@ def melodicIntervals(self, **skipKeywords): return self.cloneEmpty(derivationMethod='melodicIntervals') returnStream = self.cloneEmpty(derivationMethod='melodicIntervals') + containerOffsets = {id(self): 0.0} + for container in self.recurse(streamsOnly=True): + containerOffsets[id(container)] = opFrac( + containerOffsets[id(container.activeSite)] + container.offset) + for thisNote, nextNote in zip(returnList, returnList[1:]): # returnList could contain None to represent a rest if thisNote is None or nextNote is None: @@ -10499,9 +10550,11 @@ def melodicIntervals(self, **skipKeywords): noteEnd = nextNote # Prefer Note objects over Pitch objects so that noteStart is set correctly returnInterval = interval.Interval(noteStart, noteEnd) - returnInterval.offset = opFrac(thisNote.offset + thisNote.quarterLength) + thisOffset = containerOffsets[id(thisNote.activeSite)] + thisNote.offset + nextOffset = containerOffsets[id(nextNote.activeSite)] + nextNote.offset + returnInterval.offset = opFrac(thisOffset + thisNote.quarterLength) returnInterval.duration = duration.Duration(opFrac( - nextNote.offset - returnInterval.offset)) + nextOffset - returnInterval.offset)) returnStream.insert(returnInterval) return returnStream @@ -10807,7 +10860,6 @@ def getOverlaps(self): >>> d = a.getOverlaps() >>> len(d[0]) 7 - ''' overlapMap = self._findLayering() # environLocal.printDebug(['overlapMap', overlapMap]) @@ -11121,7 +11173,6 @@ def allPlayingWhileSounding(self, el, elStream=None): is list, it is used like classList in elsewhere in stream to provide a list of classes that the el must be a part of. - ''' if elStream is not None: # a bit of safety elOffset = el.getOffsetBySite(elStream) @@ -11537,7 +11588,7 @@ def doOneMeasureWithVoices(mInner): mEmpty.mergeAttributes(m) # Propagate bar, meter, key elements to lower parts mEmpty.mergeElements(m, classFilterList=('Barline', - 'TimeSignature', 'KeySignature')) + 'TimeSignature', 'KeySignature')) s.parts[i].insert(self.elementOffset(m), mEmpty) # if part has no measures but has voices, contents of each voice go into the part elif self.hasVoices(): @@ -11704,7 +11755,6 @@ def lyrics( >>> list(scr.lyrics(ignoreBarlines=True, recurse=False).keys()) [] - ''' returnLists: dict[int, list[RecursiveLyricList]] = {} numNotes = 0 @@ -12286,7 +12336,6 @@ def _insertDeletionVariant(self, v, matchBySpan=True): {1.0} {2.0} {3.0} - ''' from music21 import variant @@ -12422,7 +12471,6 @@ def _insertInsertionVariant(self, v, matchBySpan=True): {1.0} {2.0} {3.0} - ''' from music21 import variant @@ -12704,7 +12752,6 @@ def _fixMeasureNumbers(self, deletedMeasures, insertedMeasures): ... fixedNumbers.append( m.number ) >>> fixedNumbers [1, 2, 3, 4, 5] - ''' deletedMeasures.extend(insertedMeasures) allMeasures = deletedMeasures @@ -12827,7 +12874,6 @@ def showVariantAsOssialikePart(self, containedPart, variantGroups, *, inPlace=Fa >>> streamWithOssia = s.showVariantAsOssialikePart(sPart, ... ['variant1', 'variant2', 'variant3'], inPlace=False) >>> #_DOCS_SHOW streamWithOssia.show() - ''' from music21 import variant @@ -13430,11 +13476,18 @@ def bestTimeSignature(self): For further details about complex time signatures, etc. see `meter.bestTimeSignature()` - ''' return meter.bestTimeSignature(self) - def _getLeftBarline(self): + @property + def leftBarline(self): + ''' + Get or set the left barline, or the Barline object + found at offset zero of the Measure. Can be set either with a string + representing barline style or a bar.Barline() object or None. + Note that not all bars have + barline objects here -- regular barlines don't need them. + ''' barList = [] # directly access _elements, as do not want to get any bars # in _endElements @@ -13448,7 +13501,8 @@ def _getLeftBarline(self): else: return barList[0] - def _setLeftBarline(self, barlineObj): + @leftBarline.setter + def leftBarline(self, barlineObj): insert = True if isinstance(barlineObj, str): barlineObj = bar.Barline(barlineObj) @@ -13458,26 +13512,45 @@ def _setLeftBarline(self, barlineObj): else: # assume a Barline object barlineObj.location = 'left' - oldLeftBarline = self._getLeftBarline() + oldLeftBarline = self.leftBarline if oldLeftBarline is not None: - # environLocal.printDebug(['_setLeftBarline()', 'removing left barline']) + # environLocal.printDebug(['leftBarline setter', 'removing left barline']) junk = self.pop(self.index(oldLeftBarline)) if insert: - # environLocal.printDebug(['_setLeftBarline()', + # environLocal.printDebug(['leftBarline setter', # 'inserting new left barline', barlineObj]) self.insert(0, barlineObj) - leftBarline = property(_getLeftBarline, - _setLeftBarline, - doc=''' - Get or set the left barline, or the Barline object - found at offset zero of the Measure. Can be set either with a string - representing barline style or a bar.Barline() object or None. - Note that not all bars have - barline objects here -- regular barlines don't need them. - ''') + @property + def rightBarline(self): + ''' + Get or set the right barline, or the Barline object + found at the offset equal to the bar duration. + + >>> b = bar.Barline('final') + >>> m = stream.Measure() + >>> print(m.rightBarline) + None + >>> m.rightBarline = b + >>> m.rightBarline.type + 'final' + + + A string can also be used instead: - def _getRightBarline(self): + >>> c = converter.parse('tinynotation: 3/8 C8 D E F G A B4.') + >>> c.measure(1).rightBarline = 'light-light' + >>> c.measure(3).rightBarline = 'light-heavy' + >>> #_DOCS_SHOW c.show() + + .. image:: images/stream_barline_demo.* + :width: 211 + + OMIT_FROM_DOCS + + .measure currently isn't the same as the + original measure. + ''' # TODO: Move to Stream or make setting .rightBarline, etc. on Stream raise an exception # look on _endElements barList = [] @@ -13491,7 +13564,8 @@ def _getRightBarline(self): else: return barList[0] - def _setRightBarline(self, barlineObj): + @rightBarline.setter + def rightBarline(self, barlineObj): insert = True if isinstance(barlineObj, str): barlineObj = bar.Barline(barlineObj) @@ -13506,50 +13580,18 @@ def _setRightBarline(self, barlineObj): # environLocal.printDebug(['got barline obj w/ direction', barlineObj.direction]) if barlineObj.direction in ['start', None]: barlineObj.direction = 'end' - oldRightBarline = self._getRightBarline() + oldRightBarline = self.rightBarline if oldRightBarline is not None: - # environLocal.printDebug(['_setRightBarline()', 'removing right barline']) + # environLocal.printDebug(['rightBarline setter', 'removing right barline']) junk = self.pop(self.index(oldRightBarline)) # insert into _endElements if insert: self.storeAtEnd(barlineObj) - # environLocal.printDebug(['post _setRightBarline', barlineObj, + # environLocal.printDebug(['post rightBarline setter', barlineObj, # 'len of elements highest', len(self._endElements)]) - rightBarline = property(_getRightBarline, - _setRightBarline, - doc=''' - Get or set the right barline, or the Barline object - found at the offset equal to the bar duration. - - >>> b = bar.Barline('final') - >>> m = stream.Measure() - >>> print(m.rightBarline) - None - >>> m.rightBarline = b - >>> m.rightBarline.type - 'final' - - - A string can also be used instead: - - >>> c = converter.parse('tinynotation: 3/8 C8 D E F G A B4.') - >>> c.measure(1).rightBarline = 'light-light' - >>> c.measure(3).rightBarline = 'light-heavy' - >>> #_DOCS_SHOW c.show() - - .. image:: images/stream_barline_demo.* - :width: 211 - - OMIT_FROM_DOCS - - .measure currently isn't the same as the - original measure. - - ''') - class Part(Stream): ''' @@ -13583,26 +13625,9 @@ def __init__(self, *args, **keywords): self._partName = None self._partAbbreviation = None - def _getPartName(self): - if self._partName is not None: - return self._partName - elif '_partName' in self._cache: - return self._cache['_partName'] - else: - pn = None - for e in self[instrument.Instrument]: - pn = e.partName - if pn is None: - pn = e.instrumentName - if pn is not None: - break - self._cache['_partName'] = pn - return pn - - def _setPartName(self, newName): - self._partName = newName - - partName = property(_getPartName, _setPartName, doc=''' + @property + def partName(self): + ''' Gets or sets a string representing the name of this part as a whole (not counting instrument changes, etc.). @@ -13635,28 +13660,29 @@ def _setPartName(self, newName): .coreElementsChanged() is called or this Stream's elements are otherwise altered. This is because the value is cached so that O(n) searches through the Stream do not need to be done every time. - ''') - - def _getPartAbbreviation(self): - if self._partAbbreviation is not None: - return self._partAbbreviation - elif '_partAbbreviation' in self._cache: - return self._cache['_partAbbreviation'] + ''' + if self._partName is not None: + return self._partName + elif '_partName' in self._cache: + return self._cache['_partName'] else: pn = None for e in self[instrument.Instrument]: - pn = e.partAbbreviation + pn = e.partName if pn is None: - pn = e.instrumentAbbreviation + pn = e.instrumentName if pn is not None: break - self._cache['_partAbbreviation'] = pn + self._cache['_partName'] = pn return pn - def _setPartAbbreviation(self, newName): - self._partAbbreviation = newName + @partName.setter + def partName(self, newName): + self._partName = newName - partAbbreviation = property(_getPartAbbreviation, _setPartAbbreviation, doc=''' + @property + def partAbbreviation(self): + ''' Gets or sets a string representing the abbreviated name of this part as a whole (not counting instrument changes, etc.). @@ -13689,7 +13715,25 @@ def _setPartAbbreviation(self, newName): .coreElementsChanged() is called or this Stream's elements are otherwise altered. This is because the value is cached so that O(n) searches through the Stream do not need to be done every time. - ''') + ''' + if self._partAbbreviation is not None: + return self._partAbbreviation + elif '_partAbbreviation' in self._cache: + return self._cache['_partAbbreviation'] + else: + pn = None + for e in self[instrument.Instrument]: + pn = e.partAbbreviation + if pn is None: + pn = e.instrumentAbbreviation + if pn is not None: + break + self._cache['_partAbbreviation'] = pn + return pn + + @partAbbreviation.setter + def partAbbreviation(self, newName): + self._partAbbreviation = newName def makeAccidentals( self, @@ -14164,7 +14208,6 @@ def partsToVoices(self, 4 >>> len(post.flatten().notes) 165 - ''' from music21 import spanner diff --git a/music21/stream/core.py b/music21/stream/core.py index e0fe04e3ec..4fd3e901fc 100644 --- a/music21/stream/core.py +++ b/music21/stream/core.py @@ -104,9 +104,9 @@ def coreInsert( if not ignoreSort: # # if sorted and our insertion is > the highest time, then # # are still inserted - # if self.isSorted is True and self.highestTime <= offset: + # if self.isSorted and self.highestTime <= offset: # storeSorted = True - if self.isSorted is True: + if self.isSorted: ht = self.highestTime # type: ignore if ht < offset: storeSorted = True @@ -403,7 +403,6 @@ def coreGuardBeforeAddElement(self, element, *, checkRedundancy=True): music21.exceptions21.StreamException: The object you tried to add to the Stream, 3.14159, is not a Music21Object. Use an ElementWrapper object if this is what you intend. - ''' if element is self: # cannot add this Stream into itself raise StreamException('this Stream cannot be contained within itself') diff --git a/music21/stream/filters.py b/music21/stream/filters.py index ef13f1e491..cbc9af19c8 100644 --- a/music21/stream/filters.py +++ b/music21/stream/filters.py @@ -63,7 +63,6 @@ class StreamFilter(prebase.ProtoM21Object): True >>> sf.classes ('StreamFilter', 'ProtoM21Object', 'object') - ''' derivationStr = 'streamFilter' @@ -115,7 +114,6 @@ class IsFilter(StreamFilter): ... print(el) - ''' derivationStr = 'is' @@ -193,7 +191,6 @@ class IdFilter(StreamFilter): No corresponding iterator call. Only a single ID can be passed in. Always returns a single item. - ''' derivationStr = 'getElementById' @@ -241,7 +238,6 @@ class ClassFilter(StreamFilter): ... print(x) - ''' derivationStr = 'getElementsByClass' @@ -361,15 +357,13 @@ def __init__(self, super().__init__() self.offsetStart = opFrac(offsetStart) + self.zeroLengthSearch: bool = True if offsetEnd is None: - self.offsetEnd = offsetStart - self.zeroLengthSearch = True + self.offsetEnd = self.offsetStart else: self.offsetEnd = opFrac(offsetEnd) if offsetEnd > offsetStart: self.zeroLengthSearch = False - else: - self.zeroLengthSearch = True self.mustFinishInSpan = mustFinishInSpan self.mustBeginInSpan = mustBeginInSpan @@ -432,35 +426,35 @@ def isElementOffsetInRange(self, e, offset, *, stopAfterEnd=False) -> bool: else: elementIsZeroLength = False - if self.zeroLengthSearch is True and elementIsZeroLength is True: + if self.zeroLengthSearch and elementIsZeroLength: # zero Length Searches -- include all zeroLengthElements return True - if self.mustFinishInSpan is True: + if self.mustFinishInSpan: if elementEnd > self.offsetEnd: # environLocal.warn([elementEnd, offsetEnd, e]) return False - if self.includeEndBoundary is False: + if not self.includeEndBoundary: # we include the end boundary if the search is zeroLength -- # otherwise nothing can be retrieved if elementEnd == self.offsetEnd: return False - if self.mustBeginInSpan is True: + if self.mustBeginInSpan: if offset < self.offsetStart: return False - if self.includeEndBoundary is False and offset == self.offsetEnd: + if not self.includeEndBoundary and offset == self.offsetEnd: return False - elif (elementIsZeroLength is False + elif (not elementIsZeroLength and elementEnd == self.offsetEnd - and self.zeroLengthSearch is True): + and self.zeroLengthSearch): return False - if self.includeEndBoundary is False and offset == self.offsetEnd: + if not self.includeEndBoundary and offset == self.offsetEnd: return False - if self.includeElementsThatEndAtStart is False and elementEnd == self.offsetStart: + if not self.includeElementsThatEndAtStart and elementEnd == self.offsetStart: return False return True @@ -473,7 +467,7 @@ class OffsetHierarchyFilter(OffsetFilter): Finds elements that match a given offset range in the hierarchy. - Do not call .stream() afterwards or unstable results can occur. + Do not call .stream() afterward or unstable results can occur. ''' derivationStr = 'getElementsByOffsetInHierarchy' diff --git a/music21/stream/iterator.py b/music21/stream/iterator.py index 06063512fb..898d95d4e4 100644 --- a/music21/stream/iterator.py +++ b/music21/stream/iterator.py @@ -204,10 +204,10 @@ def __next__(self) -> M21ObjType: continue self.elementIndex += 1 - if self.matchesFilters(e) is False: + if not self.matchesFilters(e): continue - if self.restoreActiveSites is True: + if self.restoreActiveSites: self.srcStream.coreSelfActiveSite(e) self.updateActiveInformation() @@ -219,7 +219,7 @@ def __next__(self) -> M21ObjType: def __getattr__(self, attr): ''' - DEPRECATED in v8 -- will be removed in v9. + DEPRECATED in v8 -- will be removed in soon. In case an attribute is defined on Stream but not on a StreamIterator, create a Stream and then return that attribute. This is NOT performance @@ -466,7 +466,6 @@ def __bool__(self) -> bool: 0 >>> bool(iterator.getElementsByClass(chord.Chord)) False - ''' if self._len is not None: return bool(self._len) @@ -1827,10 +1826,10 @@ def __next__(self) -> M21ObjType: childRecursiveIterator.iteratorStartOffsetInHierarchy = newStartOffset self.childRecursiveIterator = childRecursiveIterator - if self.matchesFilters(e) is False: + if not self.matchesFilters(e): continue - if self.restoreActiveSites is True: + if self.restoreActiveSites: self.srcStream.coreSelfActiveSite(e) self.updateActiveInformation() @@ -2132,8 +2131,6 @@ def testCurrentHierarchyOffsetReset(self): def testAddingFiltersMidRecursiveIteration(self): from music21 import stream - # noinspection PyUnresolvedReferences - from music21.stream.iterator import RecursiveIterator as ImportedRecursiveIterator m = stream.Measure() r = note.Rest() n = note.Note() @@ -2149,7 +2146,7 @@ def testAddingFiltersMidRecursiveIteration(self): self.assertIs(p0, p) child = sIter.childRecursiveIterator - self.assertIsInstance(child, ImportedRecursiveIterator) + self.assertIsInstance(child, RecursiveIterator) diff --git a/music21/stream/makeNotation.py b/music21/stream/makeNotation.py index b3523a4404..4301a202ab 100644 --- a/music21/stream/makeNotation.py +++ b/music21/stream/makeNotation.py @@ -7,7 +7,7 @@ # Jacob Walls # Evan Lynch # -# Copyright: Copyright © 2008-2024 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2008-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ----------------------------------------------------------------------------- from __future__ import annotations @@ -530,6 +530,11 @@ def makeMeasures( post.derivation.origin = s post.derivation.method = 'makeMeasures' + longestBar = max( + (ts.barDuration.quarterLength + for ts in meterStream.getElementsByClass(meter.TimeSignature)), + default=0.0, + ) o = 0.0 # initial position of first measure is assumed to be zero measureCount = 0 lastTimeSignature = None @@ -581,6 +586,9 @@ def makeMeasures( if thisTimeSignature.barDuration.quarterLength == 0: raise stream.StreamException( f'time signature {thisTimeSignature!r} has no duration') + if measureCount + (oMax - o) / longestBar > defaults.maxMeasures: + raise stream.StreamException( + f'cannot make more than {defaults.maxMeasures} measures') post.coreInsert(o, m) # insert measure # increment by meter length o += thisTimeSignature.barDuration.quarterLength @@ -1242,6 +1250,11 @@ def makeTies( ) meterStream.insert(0, ts) + longestBar = max( + (ts.barDuration.quarterLength + for ts in meterStream.getElementsByClass(meter.TimeSignature)), + default=0.0, + ) mCount = 0 measureList = list(returnObj.getElementsByClass(stream.Measure)) @@ -1309,6 +1322,9 @@ def makeTies( # f'({e}) has offset {eOffset} within a measure ' # f'that ends at offset {mEnd}' # ) + if mCount + overshot / longestBar > defaults.maxMeasures: + raise stream.StreamException( + f'cannot make more than {defaults.maxMeasures} measures') qLenWithinMeasure = mEnd - eOffset e, eRemain = e.splitAtQuarterLength( @@ -1320,20 +1336,21 @@ def makeTies( # manage bridging voices if mNextHasVoices: if mHasVoices: # try to match voice id - if not isinstance(vId, int): - dst = mNext.voices[vId] - else: - dst = mNext.getElementById(vId) + dst = mNext.voices.getElementById(vId) # src does not have voice, but dst does else: # place in top-most voice dst = mNext.voices[0] else: # mNext has no voices but this one does if mHasVoices: - # internalize all components in a voice - moveNotesToVoices(mNext) - # place in first voice - dst = mNext.voices[0] + # internalize all components in a voice, once + if not mNext.hasVoices(): + moveNotesToVoices(mNext) + # place in a voice of the same id, made if missing + dst = mNext.voices.getElementById(vId) + if dst is None: + dst = stream.Voice(id=vId) + mNext.insert(0, dst) else: # no voices in either dst = None @@ -1355,6 +1372,7 @@ def makeTies( returnObj.insert(mNext.offset, mNext) # need to make sure that the new measure is processed. measureList.append(mNext) + mNextAdd = False mCount += 1 for measure in returnObj.getElementsByClass(stream.Measure): @@ -1701,9 +1719,11 @@ def makeAccidentalsInMeasureStream( # just get the chromatic pitches from previous measure # G-naturals in C major following G-flats in F major need cautionary # G-naturals in C major following G-flats in Db major don't - pitchPastMeasure = [p for p in - measuresOnly[i - 1].pitches + ornamentalPitches(measuresOnly[i - 1]) - if p.name not in ksLastDiatonic] + previousMeasure = measuresOnly[i - 1] + pitchPastMeasure = [ + p for p in previousMeasure.pitches + ornamentalPitches(previousMeasure) + if p.name not in ksLastDiatonic + ] # Get tiePitchSet from previous measure try: previousNoteOrChord = measuresOnly[i - 1][note.NotRest][-1] @@ -2319,7 +2339,7 @@ def testStreamExceptions(self): with self.assertRaises(stream.StreamException) as cm: p.makeMeasures(meterStream=duration.Duration()) self.assertEqual(str(cm.exception), - 'meterStream is neither a Stream nor a TimeSignature!') + 'meterStream is neither a Stream nor a TimeSignature!') def testMakeTiesChangingTimeSignatures(self): ''' @@ -2345,6 +2365,95 @@ def testMakeTiesChangingTimeSignatures(self): self.assertEqual(len(pp[stream.Measure][2].notes), 1) self.assertEqual(pp[stream.Measure][2].notes.first().duration.quarterLength, 24.0) + def testMakeTiesVoiceIdMissingFromNextMeasure(self): + ''' + A note crossing the barline in a voice whose (string) id is not + in the next measure goes to the measure itself, as it already + does for an int id, instead of raising KeyError. + + AI-assisted (Claude). + ''' + from music21 import stream + p = stream.Part() + m1 = stream.Measure(number=1) + m1.insert(0, meter.TimeSignature('4/4')) + soprano = stream.Voice(id='soprano') + soprano.insert(3, note.Note('C4', quarterLength=2.0)) + m1.insert(0, soprano) + m2 = stream.Measure(number=2) + m2.insert(0, stream.Voice(id='alto')) + p.insert(0, m1) + p.insert(4, m2) + + p.makeTies(inPlace=True) + self.assertEqual(soprano.notes.first().duration.quarterLength, 1.0) + self.assertEqual(soprano.notes.first().tie.type, 'start') + remainder = m2.getElementsByClass(note.Note).first() + self.assertEqual(remainder.duration.quarterLength, 1.0) + self.assertEqual(remainder.tie.type, 'stop') + + def testMakeTiesNewMeasureSharedByVoices(self): + ''' + Every voice running past the last barline continues in the one + measure made after it. + + AI-assisted (Claude). + ''' + from music21 import stream + + def overrunning(voiceCount): + m1 = stream.Measure(number=1) + m1.insert(0, meter.TimeSignature('2/4')) + for i in range(voiceCount): + v = stream.Voice(id=str(i + 1)) + v.append(note.Note(60 + 4 * i, quarterLength=4.0)) + m1.insert(0, v) + return stream.Part([m1]) + + for voiceCount in (1, 2, 3): + p = overrunning(voiceCount) + p.makeTies(inPlace=True) + measures = p.getElementsByClass(stream.Measure) + self.assertEqual([m.number for m in measures], [1, 2]) + self.assertEqual(measures[1].offset, 2.0) + remainders = measures[1].recurse().notes + self.assertEqual([n.tie.type for n in remainders], ['stop'] * voiceCount) + self.assertEqual([n.quarterLength for n in remainders], [2.0] * voiceCount) + + def testMakeTiesKeepsVoicesInNextMeasure(self): + ''' + Voices running into a measure that has none continue there in + voices of the same ids; notes the measure already holds keep a + voice of their own. + + AI-assisted (Claude). + ''' + from music21 import stream + + def tiedInto(nextMeasureNotes): + m1 = stream.Measure(number=1) + m1.insert(0, meter.TimeSignature('2/4')) + for voiceId, pitchName in (('1', 'C5'), ('2', 'C4')): + v = stream.Voice(id=voiceId) + v.append(note.Note(pitchName, quarterLength=4.0)) + m1.insert(0, v) + m2 = stream.Measure(number=2) + for n in nextMeasureNotes: + m2.append(n) + p = stream.Part() + p.insert(0, m1) + p.insert(2, m2) + p.makeTies(inPlace=True) + return {v.id: [n.nameWithOctave for n in v.notes] for v in m2.voices} + + self.assertEqual(tiedInto([]), {'1': ['C5'], '2': ['C4']}) + + kept = tiedInto([note.Note('G4', quarterLength=2.0)]) + self.assertEqual(kept['1'], ['C5']) + self.assertEqual(kept['2'], ['C4']) + self.assertEqual(len(kept), 3) + self.assertIn(['G4'], kept.values()) + def testConsolidateCompletedTupletsNoFalsePositive(self): from fractions import Fraction from music21 import converter @@ -2430,6 +2539,29 @@ def getScore(): op.makeTies(inPlace=True) self.assertEqual(op.scores[1][note.Note][2].tie, tie.Tie('stop')) + def testMakeMeasuresTooManyMeasures(self): + from music21 import stream + p = stream.Part() + p.append(note.Note(quarterLength=4 * 2000)) + self.assertEqual(len(p.makeMeasures().getElementsByClass(stream.Measure)), 2000) + + p = stream.Part() + p.append(note.Note(quarterLength=2 ** 28)) + with self.assertRaisesRegex(stream.StreamException, 'more than 100000 measures'): + p.makeMeasures() + + p = stream.Part() + p.append(note.Note()) + with self.assertRaisesRegex(stream.StreamException, 'more than 100000 measures'): + p.makeMeasures(refStreamOrTimeRange=[0.0, 2 ** 28]) + + def testMakeTiesTooManyMeasures(self): + from music21 import stream + m = stream.Measure([meter.TimeSignature('4/4'), note.Note(quarterLength=2 ** 28)]) + p = stream.Part([m]) + with self.assertRaisesRegex(stream.StreamException, 'more than 100000 measures'): + p.makeTies() + # ----------------------------------------------------------------------------- if __name__ == '__main__': diff --git a/music21/stream/streamStatus.py b/music21/stream/streamStatus.py index 1830ec5cfc..36a4d18d19 100644 --- a/music21/stream/streamStatus.py +++ b/music21/stream/streamStatus.py @@ -138,7 +138,6 @@ def haveTupletBracketsBeenMade(self): >>> nTuplet.duration.tuplets[0].type = 'start' >>> s.streamStatus.haveTupletBracketsBeenMade() True - ''' foundTuplet = False for n in self.client.recurse(classFilter='GeneralNote', restoreActiveSites=False): diff --git a/music21/stream/tests.py b/music21/stream/tests.py index 5f28356852..5a7d9b441c 100644 --- a/music21/stream/tests.py +++ b/music21/stream/tests.py @@ -526,8 +526,8 @@ def testStreamSortRecursion(self): offsets = [a.offset for a in fs_fs] # safer is a.getOffsetBySite(fs_fs) offsetsBrief = offsets[:20] self.assertEqual(offsetsBrief, - [0, 2, 4, 5, 6, 7, 9, 10, - 11, 12, 13, 14, 15, 15, 16, 17, 17, 18, 19, 19]) + [0, 2, 4, 5, 6, 7, 9, 10, + 11, 12, 13, 14, 15, 15, 16, 17, 17, 18, 19, 19]) def testOverlapsA(self): a = Stream() @@ -1045,7 +1045,8 @@ def testFindConsecutiveNotes(self): consec = m.findConsecutiveNotes() - self.assertEqual([repr(x) for x in consec], + self.assertEqual( + [repr(x) for x in consec], ['', '', '', @@ -1088,6 +1089,64 @@ def testFindConsecutiveNotes(self): self.assertEqual([repr(x) for x in consec3], expected2 + ['None'] + expected2) + def testFindConsecutiveNotesChordUnisons(self): + ''' + A repeated Chord, with its pitches in any order, is skipped as a + unison; a rest after it still gives a None. + ''' + ch1 = chord.Chord('C4 E4 G4') + ch2 = chord.Chord('C4 E4 G4') + ch3 = chord.Chord('C4 E4 A4') + s = Stream([ch1, ch2, ch3]) + self.assertEqual(s.findConsecutiveNotes(), [ch1, ch2, ch3]) + self.assertEqual(s.findConsecutiveNotes(skipUnisons=True), [ch1, ch3]) + + chords = [chord.Chord('C4 E4 G4'), chord.Chord('C4 E4 G4'), chord.Chord('C4 E4 A4')] + s2 = Stream() + s2.append([chords[0], chords[1], note.Rest(), chords[2]]) + self.assertEqual(s2.findConsecutiveNotes(skipUnisons=True), + [chords[0], None, chords[2]]) + + ch4 = chord.Chord('C4 E4 G4') + s3 = Stream() + s3.append([ch4, chord.Chord('G4 C4 E4')]) + self.assertEqual(s3.findConsecutiveNotes(skipUnisons=True), [ch4]) + + def testFindConsecutiveNotesSkippedUnisonKeepsContinuity(self): + ''' + A skipped unison lasts until its own end, so the next note follows + without a None, while a rest or a gap after it still gives a None. + ''' + s = Stream() + s.append([note.Note('C4'), note.Note('C4'), note.Note('D4')]) + consec = s.findConsecutiveNotes(skipUnisons=True) + self.assertEqual([repr(x) for x in consec], + ['', '']) + + s2 = Stream() + s2.append([note.Note('C4'), note.Note('C4'), note.Rest(), note.Note('D4')]) + consec2 = s2.findConsecutiveNotes(skipUnisons=True) + self.assertEqual([repr(x) for x in consec2], + ['', 'None', '']) + + s3 = Stream() + s3.append([note.Note('C4'), note.Note('C5'), note.Note('D4')]) + consec3 = s3.findConsecutiveNotes(skipUnisons=True, skipOctaves=True) + self.assertEqual([x.nameWithOctave for x in consec3], ['C4', 'D4']) + + s4 = Stream() + s4.insert(0, note.Note('C4')) + s4.insert(1, note.Note('C4')) + s4.insert(3, note.Note('D4')) + consec4 = s4.findConsecutiveNotes(skipUnisons=True) + self.assertEqual([repr(x) for x in consec4], + ['', 'None', '']) + + s5 = Stream() + s5.append([note.Note('C4'), note.Note('C4'), note.Note('E4')]) + intervals = s5.melodicIntervals(skipUnisons=True) + self.assertEqual([i.name for i in intervals], ['M3']) + def testMelodicIntervals(self): c4 = note.Note('C4') d5 = note.Note('D5') @@ -1120,6 +1179,20 @@ def testMelodicIntervalsB(self): intS1 = s1.melodicIntervals() self.assertEqual(len(intS1), 2) + def testMelodicIntervalsInMeasures(self): + m1 = Measure([note.Note('C4', type='half'), note.Note('D4', type='half')]) + m2 = Measure([note.Note('F4', type='half'), note.Note('G4', type='half')]) + p = Part() + p.append([m1, m2]) + intervals = p.melodicIntervals() + self.assertEqual([i.offset for i in intervals], [2.0, 4.0, 6.0]) + self.assertEqual([i.quarterLength for i in intervals], [0.0, 0.0, 0.0]) + + s = converter.parse('tinynotation: 4/4 c2 r2 f1 g1') + withGap = s.melodicIntervals(skipRests=True) + self.assertEqual([(i.name, i.offset, i.quarterLength) for i in withGap], + [('P4', 2.0, 2.0), ('M2', 8.0, 0.0)]) + def testStripTiesBuiltA(self): s1 = Stream() n1 = note.Note('D#2') @@ -1355,7 +1428,8 @@ def testStripTiesClearBeaming(self): self.assertEqual(p.streamStatus.beams, False) p = p.splitAtDurations(recurse=True)[0] p.makeBeams(inPlace=True) - self.assertEqual([repr(el.beams) for el in p[note.Note]], + self.assertEqual( + [repr(el.beams) for el in p[note.Note]], ['', '>', '>', @@ -1395,6 +1469,84 @@ def testGetElementsByOffsetZeroLength(self): self.assertEqual(l1, 3) self.assertEqual(l2, 3) + def testGetElementsByOffsetFloatAtFraction(self): + ''' + A float offset is found where the fraction it stands for is, + whether or not an end is given. + ''' + from fractions import Fraction + s = Stream() + n = note.Note() + s.insert(Fraction(5, 6), n) + at = 1 / 3 + 0.5 # slightly below 5/6 + self.assertEqual(list(s.getElementsByOffset(at)), [n]) + self.assertEqual(list(s.getElementsByOffset(at, at)), [n]) + + # extendTies looks where each note ends, so it ties across + # a note that starts on a triplet + s = Stream() + s.insert(0, note.Note('C4', quarterLength=Fraction(1, 3))) + first = note.Note('D4', quarterLength=0.5) + second = note.Note('D4', quarterLength=Fraction(1, 6)) + s.insert(Fraction(1, 3), first) + s.insert(Fraction(5, 6), second) + s.extendTies() + self.assertEqual(first.tie.type, 'start') + self.assertEqual(second.tie.type, 'stop') + + def testBeatAndMeasureFromOffsetPartialAfterNewMeter(self): + ''' + The second half of a measure split in two counts its beats on in + the time signature the first half opens with. + ''' + p = Part() + m1 = Measure(number=1) + m1.append(meter.TimeSignature('4/4')) + m1.append(note.Note(type='whole')) + m2 = Measure(number=2) + m2.append(meter.TimeSignature('6/8')) + m2.append(note.Note(quarterLength=1.5)) + m3 = Measure(number=2) + m3.numberSuffix = 'X1' + m3.append(note.Note(quarterLength=1.5)) + p.append([m1, m2, m3]) + beat, measure = p.beatAndMeasureFromOffset(5.5) + self.assertEqual(beat, 2.0) + self.assertIs(measure, m2) + + def testBeatAndMeasureFromOffsetPartialAfterMeterChangeAtSplit(self): + ''' + Where the meter changes at the split, the first half counts its + beats in the old meter and the second half in the new one. + ''' + p = Part() + m1 = Measure(number=1) + m1.append(meter.TimeSignature('4/4')) + m1.append(note.Note(type='whole')) + m2 = Measure(number=2) + m2.append(note.Note(type='half')) + m2.insert(2, meter.TimeSignature('12/16')) + m3 = Measure(number=2) + m3.numberSuffix = 'X1' + m3.append(note.Note(quarterLength=0.75)) + m3.append(note.Note(quarterLength=0.75)) + p.append([m1, m2, m3]) + self.assertEqual([p.beatAndMeasureFromOffset(offset)[0] for offset in (5, 6, 6.75)], + [2.0, 3.0, 4.0]) + self.assertIs(p.beatAndMeasureFromOffset(6.75)[1], m2) + + def testSplitMeasureKeepsRightBarline(self): + ''' + The second half of a split measure ends with the measure's right + barline. + ''' + m = Measure([meter.TimeSignature('4/4'), note.Note(type='whole')]) + m.rightBarline = bar.Barline('final') + left, right = m.splitAtQuarterLength(1.0) + self.assertIsNone(left.rightBarline) + self.assertIsNone(right.leftBarline) + self.assertEqual(right.rightBarline.type, 'final') + def testStripTiesScore(self): ''' Test stripTies using the Score method @@ -2219,17 +2371,18 @@ def testMakeRestsB(self): # m2.show() match = str(list(s.flatten().notesAndRests)) - self.assertEqual(match, '[, , ' - + ', , ' - + ', ]') + self.assertEqual(match, + '[, , ' + + ', , ' + + ', ]') match = str([(n, n.duration) for n in s.flatten().notesAndRests]) self.assertEqual(match, '[(, ), ' - + '(, ), ' - + '(, ), ' - + '(, ), ' - + '(, ), ' - + '(, )]') + + '(, ), ' + + '(, ), ' + + '(, ), ' + + '(, ), ' + + '(, )]') GEX = m21ToXml.GeneralObjectExporter() unused_mx = GEX.parse(s).decode('utf-8') @@ -2738,7 +2891,7 @@ def testMakeAccidentalsB(self): c = m34.getElementsByClass(chord.Chord) # assuming not showing accidental b/c of key self.assertEqual(str(c[1].pitches), '(, ' - + ', )') + + ', )') # because of key self.assertEqual(str(c[1].pitches[0].accidental.displayStatus), 'False') @@ -2748,7 +2901,7 @@ def testMakeAccidentalsB(self): # has correct pitches but natural not showing on C self.assertEqual(str(c[0].pitches), '(, , ' - + ')') + + ')') self.assertEqual(str(c[0].pitches[0].accidental), 'None') def testMakeAccidentalsC(self): @@ -2906,9 +3059,9 @@ def testMakeAccidentalsOnChord(self): # Repeat the test without octaves and reset state low, high = augmented_octave.pitches - low.octave = None + low.octaveIsImplicit = True low.accidental = None - high.octave = None + high.octaveIsImplicit = True high.accidental.displayStatus = None s2.makeAccidentals(inPlace=True) @@ -4140,7 +4293,7 @@ def testAnalyze(self): s = corpus.parse('bach/bwv66.6') sub = [s.parts[0], s.parts[1], s.measures(4, 5), - s.parts[2].measures(4, 5)] + s.parts[2].measures(4, 5)] matchAmbitus = [interval.Interval(12), interval.Interval(15), @@ -4866,11 +5019,11 @@ def testMakeChordsBuiltC(self): sMod = s1.chordify(removeRedundantPitches=True) self.assertEqual([p.nameWithOctave for p in sMod.getElementsByClass(chord.Chord)[0].pitches], - ['C2', 'G2']) + ['C2', 'G2']) self.assertEqual([p.nameWithOctave for p in sMod.getElementsByClass(chord.Chord)[1].pitches], - ['E4', 'F#4']) + ['E4', 'F#4']) # without redundant pitch gathering sMod = s1.chordify(removeRedundantPitches=False) @@ -5063,6 +5216,52 @@ def testStoreAtEndFailures(self): with self.assertRaises(StreamException): s.storeAtEnd(b2) + def testIsAtEnd(self): + ''' + https://github.com/cuthbertLab/music21/issues/1069 + + AI-assisted. + ''' + s = Stream() + n = note.Note('C') + s.append(n) + b = bar.Barline() + s.storeAtEnd(b) + self.assertTrue(s.isAtEnd(b)) + self.assertFalse(s.isAtEnd(n)) + + m = Measure() + nM = note.Note('D') + m.append(nM) + rb = bar.Barline('final') + m.storeAtEnd(rb) + self.assertTrue(m.isAtEnd(rb)) + self.assertFalse(m.isAtEnd(nM)) + + outsider = note.Note('E') + with self.assertRaises(sites.SitesException): + s.isAtEnd(outsider) + + def testQuantizeKeepsElementsStoredAtEnd(self): + ''' + quantize() iterates .elements and skips elements stored at the end, so a + barline keeps its AT_END position instead of being given a real offset. + + AI-assisted. + ''' + s = Stream() + n = note.Note() + n.quarterLength = 0.26 + s.repeatInsert(n, [0.1, 0.49, 0.9]) + b = bar.Barline() + s.storeAtEnd(b) + + q = s.quantize(processOffsets=True, processDurations=True, inPlace=False) + + quantizedBarline = q._endElements[0] + self.assertTrue(q.isAtEnd(quantizedBarline)) + self.assertEqual([q.elementOffset(e) for e in q._elements], [0.0, 0.5, 1.0]) + def testElementsHighestTimeB(self): ''' Test adding elements at the highest time position @@ -5483,8 +5682,8 @@ def testChordifyImported(self): 21.0, 21.5, 22.0, 22.5, 23.0, 23.5, 24.0, 24.5, 25.0, 25.5, 26.0, 26.5, 27.0, 30.0, 33.0, 34.5, 35.5, 36.0, 37.5, 38.0, 39.0, 40.0, 40.5, 41.0, 42.0, 43.5, 45.0, 45.5, 46.0, 46.5, - 47.0, 47.5, 48.0, 49.5, 51.0, 51.5, 52.0, 52.5, 53.0, 53.5, - 54.0, 54.5, 55.0, 55.5, 56.0, 56.5, 57.0, 58.5, 59.5]) + 47.0, 47.5, 48.0, 49.5, 51.0, 51.5, 52.0, 52.5, 53.0, 53.5, + 54.0, 54.5, 55.0, 55.5, 56.0, 56.5, 57.0, 58.5, 59.5]) self.assertEqual(len(post[chord.Chord]), 71) # Careful! one version of the caching is screwing up m. 20 which definitely should # not have rests in it -- was creating 69 notes, not 71. @@ -6295,7 +6494,7 @@ def testStripTiesBuiltB(self): self.assertEqual([n.offset for n in first_m_notesAndRests], [0.0, 1.0, 3.0]) self.assertEqual([n.quarterLength - for n in first_m_notesAndRests], + for n in first_m_notesAndRests], [1.0, 2.0, 2.0]) self.assertEqual([n.beatStr for n in first_m_notesAndRests], ['1', '2', '4']) @@ -6394,7 +6593,7 @@ def testDerivationA(self): m4 = p1.measure(4) self.assertIs(m4.flatten().notesAndRests.stream().derivation.rootDerivation, m4, - list(m4.flatten().notesAndRests.stream().derivation.chain())) + list(m4.flatten().notesAndRests.stream().derivation.chain())) # part is the root derivation of a measures() call mRange = p1.measures(4, 6) @@ -7503,12 +7702,12 @@ def testTransposeByPitchB(self): self.assertEqual([str(p) for p in s.parts[0].pitches], ['D4', 'E4', 'F#4', 'G4', 'A4', 'B4', 'C#5', 'D5']) self.assertEqual([None if p.accidental is None else p.accidental.displayStatus - for p in s.parts[0].pitches], + for p in s.parts[0].pitches], [True, None, False, None, None, None, False, None]) self.assertEqual([str(p) for p in s.parts[1].pitches], ['A4', 'B4', 'C#5', 'D5', 'E5', 'F#5', 'G#5', 'A5']) self.assertEqual([None if p.accidental is None else p.accidental.displayStatus - for p in s.parts[1].pitches], + for p in s.parts[1].pitches], [True, None, False, None, None, False, False, None]) self.assertEqual(s.atSoundingPitch, 'unknown') @@ -7517,12 +7716,12 @@ def testTransposeByPitchB(self): self.assertEqual([str(p) for p in s.parts[0].pitches], ['C4', 'D4', 'E4', 'F4', 'G4', 'A4', 'B4', 'C5']) self.assertEqual([None if p.accidental is None else p.accidental.displayStatus - for p in s.parts[0].pitches], + for p in s.parts[0].pitches], [True, None, None, None, None, None, None, None]) self.assertEqual([str(p) for p in s.parts[1].pitches], ['C4', 'D4', 'E4', 'F4', 'G4', 'A4', 'B4', 'C5']) self.assertEqual([None if p.accidental is None else p.accidental.displayStatus - for p in s.parts[1].pitches], + for p in s.parts[1].pitches], [True, None, None, None, None, None, None, None]) def testTransposeByPitchC(self): @@ -7563,9 +7762,9 @@ def testExtendTiesA(self): '' ], '', - ['None', - '' - ] + ['None', + '' + ] ]) def testExtendTiesB(self): @@ -7732,6 +7931,14 @@ def testInvertDiatonicA(self): qj.getElementsByClass(Measure)[1].insert(0, key.KeySignature(5)) unused_qj2 = qj.invertDiatonic(note.Note('F4'), inPlace=False) + def testInvertDiatonicBelowOctaveZero(self): + s = Stream() + s.append(note.Note('E4')) + inverted = s.invertDiatonic(note.Note('C2')) + invertedPitch = inverted[note.Note].first().pitch + self.assertEqual(invertedPitch.step, 'A') + self.assertEqual(invertedPitch.octave, -1) + def testMeasuresA(self): s = corpus.parse('bwv66.6') ex = s.parts[0].measures(3, 6) @@ -7833,6 +8040,28 @@ def numM(p_excerpt): self.assertIn(m2b, mm5) self.assertIn(m3, mm5) + def testMeasuresKeepsSourceOrder(self): + ''' + Collecting a time signature for an excerpt keeps it before the notes + of its own measure, as in Humdrum files, where priority is the line number. + ''' + p = Part() + for i in range(1, 4): + m = Measure(number=i) + m.priority = 10 * i + n = note.Note(type='whole') + n.priority = 10 * i + 2 + m.insert(0, n) + p.append(m) + m1 = p.getElementsByClass(Measure).first() + ts = meter.TimeSignature('4/4') + ts.priority = 11 + m1.insert(0, ts) + for number in (2, 3): + excerpt = p.measures(number, number) + self.assertIs(excerpt.first(), ts) + self.assertIs(m1.first(), ts) + def testChordifyF(self): # testing chordify handling of triplets from music21.musicxml import testPrimitive @@ -7843,7 +8072,7 @@ def testChordifyF(self): self.maxDiff = None self.assertMultiLineEqual( s.parts[0].getElementsByClass(Measure)[0]._reprText(addEndTimes=True, - useMixedNumerals=True), + useMixedNumerals=True), '''{0 - 0} {0 - 0} {0 - 0} diff --git a/music21/stream/tools.py b/music21/stream/tools.py index efd1f3fe6b..1fa145f8de 100644 --- a/music21/stream/tools.py +++ b/music21/stream/tools.py @@ -154,7 +154,6 @@ def removeDuplicates(thisStream: stream.Stream, >>> t = stream.tools.removeDuplicates(s, inPlace=False) >>> s.parts[0] == testInPlace True - ''' supportedClasses = (meter.TimeSignature, key.KeySignature, clef.Clef) diff --git a/music21/style.py b/music21/style.py index 127b7e81cb..dfdb43db8b 100644 --- a/music21/style.py +++ b/music21/style.py @@ -61,7 +61,6 @@ class Style(ProtoM21Object): >>> st.absoluteX = 20.4 >>> st.absoluteX 20.4 - ''' _DOC_ATTR: dict[str, str] = { 'hideObjectOnPrint': ''' @@ -176,27 +175,9 @@ def enclosure(self, value: Enclosure|str|None): else: raise TextFormatException(f'Not a supported enclosure: {value!r}') - def _getAbsoluteY(self): - return self._absoluteY - - def _setAbsoluteY(self, value): - if value is None: - self._absoluteY = None - elif value == 'above': # TODO: convert to Enum and keep it - self._absoluteY = 10 - elif value == 'below': - self._absoluteY = -70 - else: - try: - self._absoluteY = common.numToIntOrFloat(value) - except ValueError as ve: - raise TextFormatException( - f'Not a supported absoluteY position: {value!r}' - ) from ve - - absoluteY = property(_getAbsoluteY, - _setAbsoluteY, - doc=''' + @property + def absoluteY(self): + ''' Get or set the vertical position, where 0 is the top line of the staff and units are whatever is defined in `.units`, generally "tenths", meaning @@ -222,7 +203,24 @@ def _setAbsoluteY(self, value): Traceback (most recent call last): music21.style.TextFormatException: Not a supported absoluteY position: 'hello' - ''') + ''' + return self._absoluteY + + @absoluteY.setter + def absoluteY(self, value): + if value is None: + self._absoluteY = None + elif value == 'above': # TODO: convert to Enum and keep it + self._absoluteY = 10 + elif value == 'below': + self._absoluteY = -70 + else: + try: + self._absoluteY = common.numToIntOrFloat(value) + except ValueError as ve: + raise TextFormatException( + f'Not a supported absoluteY position: {value!r}' + ) from ve class NoteStyle(Style): @@ -307,19 +305,9 @@ def __init__(self): self._alignHorizontal = None self._alignVertical = None - def _getAlignVertical(self): - return self._alignVertical - - def _setAlignVertical(self, value): - # TODO: convert to StrEnum - if value in (None, 'top', 'middle', 'bottom', 'baseline'): - self._alignVertical = value - else: - raise TextFormatException(f'Invalid vertical align: {value!r}') - - alignVertical = property(_getAlignVertical, - _setAlignVertical, - doc=''' + @property + def alignVertical(self): + ''' Get or set the vertical align. Valid values are top, middle, bottom, baseline or None. @@ -334,20 +322,20 @@ def _setAlignVertical(self, value): Traceback (most recent call last): music21.style.TextFormatException: Invalid vertical align: 'hello' - ''') - - def _getAlignHorizontal(self): - return self._alignHorizontal + ''' + return self._alignVertical - def _setAlignHorizontal(self, value): - if value in (None, 'left', 'right', 'center'): - self._alignHorizontal = value + @alignVertical.setter + def alignVertical(self, value): + # TODO: convert to StrEnum + if value in (None, 'top', 'middle', 'bottom', 'baseline'): + self._alignVertical = value else: - raise TextFormatException(f'Invalid horizontal align: {value!r}') + raise TextFormatException(f'Invalid vertical align: {value!r}') - alignHorizontal = property(_getAlignHorizontal, - _setAlignHorizontal, - doc=''' + @property + def alignHorizontal(self): + ''' Get or set the horizontal alignment. Valid values are left, right, center, or None. @@ -362,7 +350,15 @@ def _setAlignHorizontal(self, value): Traceback (most recent call last): music21.style.TextFormatException: Invalid horizontal align: 'hello' - ''') + ''' + return self._alignHorizontal + + @alignHorizontal.setter + def alignHorizontal(self, value): + if value in (None, 'left', 'right', 'center'): + self._alignHorizontal = value + else: + raise TextFormatException(f'Invalid horizontal align: {value!r}') @property @@ -423,10 +419,22 @@ def fontStyle(self, value: str|None) -> None: raise TextFormatException(f'Not a supported fontStyle: {value!r}') self._fontStyle = value.lower() - def _getWeight(self): + # TODO: figure out if we want to use fontStyle for all weights. + + @property + def fontWeight(self): + ''' + Get or set the weight, as normal, or bold. + + >>> tst = style.TextStyle() + >>> tst.fontWeight = 'bold' + >>> tst.fontWeight + 'bold' + ''' return self._fontWeight - def _setWeight(self, value): + @fontWeight.setter + def fontWeight(self, value): if value is None: self._fontWeight = None else: @@ -434,23 +442,20 @@ def _setWeight(self, value): raise TextFormatException(f'Not a supported fontWeight: {value}') self._fontWeight = value.lower() - # TODO: figure out if we want to use fontStyle for all weights. - - fontWeight = property(_getWeight, - _setWeight, - doc=''' - Get or set the weight, as normal, or bold. + @property + def fontSize(self): + ''' + Get or set the size. Best, an int or float, but also a css font size. >>> tst = style.TextStyle() - >>> tst.fontWeight = 'bold' - >>> tst.fontWeight - 'bold' - ''') - - def _getSize(self): + >>> tst.fontSize = 20 + >>> tst.fontSize + 20 + ''' return self._fontSize - def _setSize(self, value): + @fontSize.setter + def fontSize(self, value): if value is not None: try: value = common.numToIntOrFloat(value) @@ -459,21 +464,21 @@ def _setSize(self, value): # raise TextFormatException(f'Not a supported size: {value}') self._fontSize = value - fontSize = property(_getSize, - _setSize, - doc=''' - Get or set the size. Best, an int or float, but also a css font size. + @property + def letterSpacing(self): + ''' + Get or set the letter spacing. >>> tst = style.TextStyle() - >>> tst.fontSize = 20 - >>> tst.fontSize - 20 - ''') - - def _getLetterSpacing(self): + >>> tst.letterSpacing = 20 + >>> tst.letterSpacing + 20.0 + >>> tst.letterSpacing = 'normal' + ''' return self._letterSpacing - def _setLetterSpacing(self, value): + @letterSpacing.setter + def letterSpacing(self, value): if value != 'normal' and value is not None: # convert to number try: @@ -485,18 +490,6 @@ def _setLetterSpacing(self, value): self._letterSpacing = value - letterSpacing = property(_getLetterSpacing, - _setLetterSpacing, - doc=''' - Get or set the letter spacing. - - >>> tst = style.TextStyle() - >>> tst.letterSpacing = 20 - >>> tst.letterSpacing - 20.0 - >>> tst.letterSpacing = 'normal' - ''') - @property def fontFamily(self): ''' diff --git a/music21/tablature.py b/music21/tablature.py index cf529a6d82..2638e87711 100644 --- a/music21/tablature.py +++ b/music21/tablature.py @@ -363,8 +363,7 @@ def testStupidFretNote(self): self.assertEqual(FretNote().string, None) def testFretNoteWeirdRepr(self): - from music21 import tablature - weirdFretNote = tablature.FretNote(6, 133) + weirdFretNote = FretNote(6, 133) expectedRepr = '' diff --git a/music21/tempo.py b/music21/tempo.py index 888ee19b72..0e2a6db74e 100644 --- a/music21/tempo.py +++ b/music21/tempo.py @@ -107,7 +107,6 @@ def convertTempoByReferent( >>> tempo.convertTempoByReferent(60, 1.5, common.opFrac(1/3)) 270.0 - ''' # find duration in seconds of quarter length srcDurPerBeat = 60 / numberSrc @@ -181,8 +180,10 @@ def getPreviousMetronomeMark(self): # ------------------------------------------------------------------------------ class TempoText(TempoIndication): ''' - >>> import music21 - >>> tm = music21.tempo.TempoText('adagio') + TempoText is a TempoIndication that uses words (not metronome numbers) + to indicate tempo. + + >>> tm = tempo.TempoText('adagio') >>> tm >>> print(tm.text) @@ -200,17 +201,22 @@ def __init__(self, text=None, **keywords): def _reprInternal(self): return repr(self.text) - def _getText(self): + @property + def text(self): ''' - Get the text used for this expression. + Get or set the text as a string. Setting is also the primary way that + the stored TextExpression object is created. + + >>> tm = tempo.TempoText('adagio') + >>> tm.text + 'adagio' + >>> tm.getTextExpression() + ''' return self._textExpression.content - def _setText(self, value): - ''' - Set the text of this repeat expression. This is also the primary way - that the stored TextExpression object is created. - ''' + @text.setter + def text(self, value): if self._textExpression is None: self._textExpression = expressions.TextExpression(value) if self.hasStyleInformation: @@ -222,17 +228,6 @@ def _setText(self, value): else: self._textExpression.content = value - text = property(_getText, _setText, doc=''' - Get or set the text as a string. - - >>> import music21 - >>> tm = music21.tempo.TempoText('adagio') - >>> tm.text - 'adagio' - >>> tm.getTextExpression() - - ''') - def getMetronomeMark(self): # noinspection PyShadowingNames ''' @@ -486,10 +481,16 @@ def _updateNumberFromText(self): self.numberImplicit = True # ------------------------------------------------------------------------- - def _getReferent(self): + @property + def referent(self): + ''' + Get or set the referent, or the Duration object that is the + reference for the tempo value in BPM. + ''' return self._referent - def _setReferent(self, value): + @referent.setter + def referent(self, value): if value is None: # this may be better not here # if referent is None, set a default quarter note duration self._referent = duration.Duration(type='quarter') @@ -506,11 +507,6 @@ def _setReferent(self, value): else: raise TempoException(f'Cannot get a Duration from the supplied object: {value}') - referent = property(_getReferent, _setReferent, doc=''' - Get or set the referent, or the Duration object that is the - reference for the tempo value in BPM. - ''') - # properties and conversions def _getText(self): if self._tempoText is None: @@ -576,15 +572,9 @@ def _setNumber(self, value: int|float|None, False ''') - def _getNumberSounding(self): - return self._numberSounding # may be None - - def _setNumberSounding(self, value): - if not common.isNum(value) and value is not None: - raise TempoException('cannot set numberSounding to a string') - self._numberSounding = value - - numberSounding = property(_getNumberSounding, _setNumberSounding, doc=''' + @property + def numberSounding(self): + ''' Get and set the numberSounding, or the numerical value of the Metronome that is used for playback independent of display. If numberSounding is None, number is assumed to be numberSounding. @@ -599,7 +589,14 @@ def _setNumberSounding(self, value): >>> mm.numberSounding = 120 >>> mm.numberSounding 120 - ''') + ''' + return self._numberSounding # may be None + + @numberSounding.setter + def numberSounding(self, value): + if not common.isNum(value) and value is not None: + raise TempoException('cannot set numberSounding to a string') + self._numberSounding = value # ------------------------------------------------------------------------- def getQuarterBPM(self, useNumberSounding=True) -> float|None: @@ -756,7 +753,6 @@ def getEquivalentByReferent(self, referent): >>> mm1.getEquivalentByReferent('longa') - ''' if common.isNum(referent): # assume quarter length quarterLength = referent @@ -949,21 +945,9 @@ def _reprInternal(self): # -------------------------------------------------------------------------- # core properties - def _setOldMetronome(self, value): - if value is None: - pass # allow setting as None - elif not hasattr(value, 'classes') or 'MetronomeMark' not in value.classes: - raise MetricModulationException( - 'oldMetronome property must be set with a MetronomeMark instance') - self._oldMetronome = value - - def _getOldMetronome(self): - if self._oldMetronome is not None: - if self._oldMetronome.number is None: - self.updateByContext() - return self._oldMetronome - - oldMetronome = property(_getOldMetronome, _setOldMetronome, doc=''' + @property + def oldMetronome(self): + ''' Get or set the left :class:`~music21.tempo.MetronomeMark` object for the old, or previous value. @@ -979,9 +963,40 @@ def _getOldMetronome(self): Traceback (most recent call last): music21.tempo.MetricModulationException: oldMetronome property must be set with a MetronomeMark instance - ''') + ''' + if self._oldMetronome is not None: + if self._oldMetronome.number is None: + self.updateByContext() + return self._oldMetronome - def _setOldReferent(self, value): + @oldMetronome.setter + def oldMetronome(self, value): + if value is None: + pass # allow setting as None + elif not hasattr(value, 'classes') or 'MetronomeMark' not in value.classes: + raise MetricModulationException( + 'oldMetronome property must be set with a MetronomeMark instance') + self._oldMetronome = value + + @property + def oldReferent(self): + ''' + Get or set the referent of the old MetronomeMark. + + >>> mm1 = tempo.MetronomeMark(number=60, referent=1) + >>> mmod1 = tempo.MetricModulation() + >>> mmod1.oldMetronome = mm1 + >>> mmod1.oldMetronome + + >>> mmod1.oldReferent = 0.25 + >>> mmod1.oldMetronome + + ''' + if self._oldMetronome is not None: + return self._oldMetronome.referent + + @oldReferent.setter + def oldReferent(self, value): if value is None: raise MetricModulationException('cannot set old referent to None') # try to get and reassign equivalent @@ -1000,25 +1015,30 @@ def _setOldReferent(self, value): self._oldMetronome = MetronomeMark(referent=value) # raise MetricModulationException('cannot set old MetronomeMark from provided value.') - def _getOldReferent(self): - if self._oldMetronome is not None: - return self._oldMetronome.referent - - oldReferent = property(_getOldReferent, _setOldReferent, doc=''' - Get or set the referent of the old MetronomeMark. + @property + def newMetronome(self): + ''' + Get or set the right :class:`~music21.tempo.MetronomeMark` + object for the new, or following value. >>> mm1 = tempo.MetronomeMark(number=60, referent=1) - >>> mmod1 = tempo.MetricModulation() - >>> mmod1.oldMetronome = mm1 - >>> mmod1.oldMetronome + >>> mm1 - >>> mmod1.oldReferent = 0.25 - >>> mmod1.oldMetronome - - - ''') + >>> mmod1 = tempo.MetricModulation() + >>> mmod1.newMetronome = mm1 + >>> mmod1.newMetronome = 'junk' + Traceback (most recent call last): + music21.tempo.MetricModulationException: newMetronome property must be + set with a MetronomeMark instance + ''' + # before returning the referent, see if we can update the number + if self._newMetronome is not None: + if self._newMetronome.number is None: + self.updateByContext() + return self._newMetronome - def _setNewMetronome(self, value): + @newMetronome.setter + def newMetronome(self, value): if value is None: pass # allow setting as None elif not hasattr(value, 'classes') or 'MetronomeMark' not in value.classes: @@ -1026,29 +1046,25 @@ def _setNewMetronome(self, value): 'newMetronome property must be set with a MetronomeMark instance') self._newMetronome = value - def _getNewMetronome(self): - # before returning the referent, see if we can update the number - if self._newMetronome is not None: - if self._newMetronome.number is None: - self.updateByContext() - return self._newMetronome - - newMetronome = property(_getNewMetronome, _setNewMetronome, doc=''' - Get or set the right :class:`~music21.tempo.MetronomeMark` - object for the new, or following value. + @property + def newReferent(self): + ''' + Get or set the referent of the new MetronomeMark. >>> mm1 = tempo.MetronomeMark(number=60, referent=1) - >>> mm1 - >>> mmod1 = tempo.MetricModulation() >>> mmod1.newMetronome = mm1 - >>> mmod1.newMetronome = 'junk' - Traceback (most recent call last): - music21.tempo.MetricModulationException: newMetronome property must be - set with a MetronomeMark instance - ''') + >>> mmod1.newMetronome + + >>> mmod1.newReferent = 0.25 + >>> mmod1.newMetronome + + ''' + if self._newMetronome is not None: + return self._newMetronome.referent - def _setNewReferent(self, value): + @newReferent.setter + def newReferent(self, value): if value is None: raise MetricModulationException('cannot set new referent to None') # if oldMetronome is defined, get new metronome from old @@ -1065,23 +1081,6 @@ def _setNewReferent(self, value): # raise MetricModulationException('cannot set old MetronomeMark from provided value.') self._newMetronome = mm - def _getNewReferent(self): - if self._newMetronome is not None: - return self._newMetronome.referent - - newReferent = property(_getNewReferent, _setNewReferent, doc=''' - Get or set the referent of the new MetronomeMark. - - >>> mm1 = tempo.MetronomeMark(number=60, referent=1) - >>> mmod1 = tempo.MetricModulation() - >>> mmod1.newMetronome = mm1 - >>> mmod1.newMetronome - - >>> mmod1.newReferent = 0.25 - >>> mmod1.newMetronome - - ''') - @property def number(self): ''' @@ -1156,7 +1155,6 @@ def setEqualityByReferent(self, side=None, referent=1.0): => - ''' if side is None: if self._oldMetronome is None: @@ -1370,8 +1368,7 @@ def testUnicode(self): self.assertEqual(mm.number, 144) def testTempoTextStyle(self): - from music21 import tempo - tm = tempo.TempoText('adagio') + tm = TempoText('adagio') self.assertEqual(tm.style.absoluteY, 45) self.assertEqual(tm.style.fontStyle, 'bold') tm.style.absoluteY = 33 @@ -1435,8 +1432,7 @@ def testTempoTextStyle(self): self.assertIs(tm.style, te4.style) # check for linked styles def testMetronomeMarkA(self): - from music21 import tempo - mm = tempo.MetronomeMark() + mm = MetronomeMark() mm.number = 56 # should implicitly set text self.assertEqual(mm.text, 'adagio') self.assertTrue(mm.textImplicit) @@ -1447,7 +1443,7 @@ def testMetronomeMarkA(self): self.assertEqual(mm.referent.quarterLength, 1.0) # setting the text first - mm = tempo.MetronomeMark() + mm = MetronomeMark() mm.text = 'presto' mm.referent = duration.Duration(3.0) self.assertEqual(mm.text, 'presto') @@ -1480,13 +1476,12 @@ def testMetronomeMarkB(self): self.assertFalse(mm.textImplicit) def testMetronomeModulationA(self): - from music21 import tempo # need to create a mm without a speed # want to say that an eighth is becoming the speed of a sixteenth - mm1 = tempo.MetronomeMark(referent=0.5, number=120) - mm2 = tempo.MetronomeMark(referent='16th') + mm1 = MetronomeMark(referent=0.5, number=120) + mm2 = MetronomeMark(referent='16th') - mmod1 = tempo.MetricModulation() + mmod1 = MetricModulation() mmod1.oldMetronome = mm1 mmod1.newMetronome = mm2 @@ -1497,8 +1492,8 @@ def testMetronomeModulationA(self): + '>') # we can get the same result by using setEqualityByReferent() - mm1 = tempo.MetronomeMark(referent=0.5, number=120) - mmod1 = tempo.MetricModulation() + mm1 = MetronomeMark(referent=0.5, number=120) + mmod1 = MetricModulation() mmod1.oldMetronome = mm1 # will automatically set right mm, as presently is None mmod1.setOtherByReferent(referent='16th') @@ -1512,7 +1507,6 @@ def testMetronomeModulationA(self): self.assertEqual(mmod1.newMetronome.getQuarterBPM(), 30.0) def testGetPreviousMetronomeMarkA(self): - from music21 import tempo from music21 import stream # test getting basic metronome marks @@ -1520,9 +1514,9 @@ def testGetPreviousMetronomeMarkA(self): m1 = stream.Measure() m1.repeatAppend(note.Note(quarterLength=1), 4) m2 = copy.deepcopy(m1) - mm1 = tempo.MetronomeMark(number=56, referent=0.25) + mm1 = MetronomeMark(number=56, referent=0.25) m1.insert(0, mm1) - mm2 = tempo.MetronomeMark(number=150, referent=0.5) + mm2 = MetronomeMark(number=150, referent=0.5) m2.insert(0, mm2) p.append([m1, m2]) self.assertEqual(str(mm2.getPreviousMetronomeMark()), @@ -1530,7 +1524,6 @@ def testGetPreviousMetronomeMarkA(self): # p.show() def testGetPreviousMetronomeMarkB(self): - from music21 import tempo from music21 import stream # test using a tempo text, will return a default metronome mark if possible @@ -1538,9 +1531,9 @@ def testGetPreviousMetronomeMarkB(self): m1 = stream.Measure() m1.repeatAppend(note.Note(quarterLength=1), 4) m2 = copy.deepcopy(m1) - mm1 = tempo.TempoText('slow') + mm1 = TempoText('slow') m1.insert(0, mm1) - mm2 = tempo.MetronomeMark(number=150, referent=0.5) + mm2 = MetronomeMark(number=150, referent=0.5) m2.insert(0, mm2) p.append([m1, m2]) self.assertEqual(str(mm2.getPreviousMetronomeMark()), @@ -1548,7 +1541,6 @@ def testGetPreviousMetronomeMarkB(self): # p.show() def testGetPreviousMetronomeMarkC(self): - from music21 import tempo from music21 import stream # test using a metric modulation @@ -1558,15 +1550,15 @@ def testGetPreviousMetronomeMarkC(self): m2 = copy.deepcopy(m1) m3 = copy.deepcopy(m2) - mm1 = tempo.MetronomeMark('slow') + mm1 = MetronomeMark('slow') m1.insert(0, mm1) - mm2 = tempo.MetricModulation() - mm2.oldMetronome = tempo.MetronomeMark(referent=1, number=52) + mm2 = MetricModulation() + mm2.oldMetronome = MetronomeMark(referent=1, number=52) mm2.setOtherByReferent(referent='16th') m2.insert(0, mm2) - mm3 = tempo.MetronomeMark(number=150, referent=0.5) + mm3 = MetronomeMark(number=150, referent=0.5) m3.insert(0, mm3) p.append([m1, m2, m3]) @@ -1580,17 +1572,16 @@ def testSetReferentA(self): Test setting referents directly via context searches. ''' from music21 import stream - from music21 import tempo p = stream.Part() m1 = stream.Measure() m1.repeatAppend(note.Note(quarterLength=1), 4) m2 = copy.deepcopy(m1) m3 = copy.deepcopy(m2) - mm1 = tempo.MetronomeMark(number=92) + mm1 = MetronomeMark(number=92) m1.insert(0, mm1) - mm2 = tempo.MetricModulation() + mm2 = MetricModulation() m2.insert(0, mm2) p.append([m1, m2, m3]) @@ -1604,15 +1595,14 @@ def testSetReferentA(self): # p.show() def testSetReferentB(self): - from music21 import tempo from music21 import stream s = stream.Stream() - mm1 = tempo.MetronomeMark(number=60) + mm1 = MetronomeMark(number=60) s.append(mm1) s.repeatAppend(note.Note(quarterLength=1), 2) s.repeatAppend(note.Note(quarterLength=0.5), 4) - mmod1 = tempo.MetricModulation() + mmod1 = MetricModulation() mmod1.oldReferent = 0.5 # can use Duration objects mmod1.newReferent = 'quarter' # can use Duration objects s.append(mmod1) @@ -1626,7 +1616,7 @@ def testSetReferentB(self): s.append(note.Note()) s.repeatAppend(note.Note(quarterLength=1.5), 2) - mmod2 = tempo.MetricModulation() + mmod2 = MetricModulation() mmod2.oldReferent = 1.5 mmod2.newReferent = 'quarter' # can use Duration objects s.append(mmod2) @@ -1640,15 +1630,14 @@ def testSetReferentB(self): # s.show() def testSetReferentC(self): - from music21 import tempo from music21 import stream s = stream.Stream() - mm1 = tempo.MetronomeMark(number=60) + mm1 = MetronomeMark(number=60) s.append(mm1) s.repeatAppend(note.Note(quarterLength=1), 2) s.repeatAppend(note.Note(quarterLength=0.5), 4) - mmod1 = tempo.MetricModulation() + mmod1 = MetricModulation() s.append(mmod1) mmod1.oldReferent = 0.5 # can use Duration objects mmod1.newReferent = 'quarter' # can use Duration objects @@ -1661,7 +1650,7 @@ def testSetReferentC(self): s.append(note.Note()) s.repeatAppend(note.Note(quarterLength=1.5), 2) - mmod2 = tempo.MetricModulation() + mmod2 = MetricModulation() s.append(mmod2) mmod2.oldReferent = 1.5 mmod2.newReferent = 'quarter' # can use Duration objects @@ -1674,15 +1663,14 @@ def testSetReferentC(self): # s.show() def testSetReferentD(self): - from music21 import tempo from music21 import stream s = stream.Stream() - mm1 = tempo.MetronomeMark(number=60) + mm1 = MetronomeMark(number=60) s.append(mm1) s.repeatAppend(note.Note(quarterLength=1), 2) s.repeatAppend(note.Note(quarterLength=0.5), 4) - mmod1 = tempo.MetricModulation() + mmod1 = MetricModulation() s.append(mmod1) # even with we have no assigned metronome, update context will create mmod1.updateByContext() diff --git a/music21/test/commonTest.py b/music21/test/commonTest.py index d473a02ab0..dc7d1d56a0 100644 --- a/music21/test/commonTest.py +++ b/music21/test/commonTest.py @@ -16,9 +16,7 @@ import copy import doctest import importlib -import importlib.util import os -import sys import typing import types import unittest.runner @@ -61,28 +59,6 @@ def testCopyAll(testInstance: unittest.TestCase, globals_: typing.Dict[str, typi testInstance.fail(f'Could not deepcopy obj {part}: {e}') -def load_source(name: str, path: str) -> types.ModuleType: - ''' - Replacement for deprecated imp.load_source() - - Thanks to: - https://github.com/epfl-scitas/spack for pointing out the - important missing "spec.loader.exec_module(module)" line. - ''' - spec = importlib.util.spec_from_file_location(name, path) - if spec is None or spec.loader is None: - raise FileNotFoundError(f'No such file or directory: {path!r}') - if name in sys.modules: - module = sys.modules[name] - else: - module = importlib.util.module_from_spec(spec) - if module is None: - raise FileNotFoundError(f'No such file or directory: {path!r}') - sys.modules[name] = module - spec.loader.exec_module(module) - - return module - # noinspection PyPackageRequirements def testImports(): ''' @@ -92,12 +68,12 @@ def testImports(): try: import scipy # type: ignore # noqa: F401 except ImportError as e: - raise ImportError('pip install scipy : needed for running test suites') from e + raise ImportError('scipy is needed for test suites; run uv sync') from e try: from Levenshtein import StringMatcher # type: ignore # noqa: F401 except ImportError as e: - raise ImportError('pip install python-Levenshtein : needed for running test suites') from e + raise ImportError('python-Levenshtein is needed for test suites; run uv sync') from e from music21.lily.translate import LilypondConverter, LilyTranslateException try: @@ -417,11 +393,11 @@ def getModule(self, fp, restoreEnvironmentDefaults=False): if skip: return None - name = self._getNamePeriod(fp, addM21=False) + name = self._getNamePeriod(fp, addM21=True) try: with warnings.catch_warnings(): - mod = load_source(name, fp) + mod = importlib.import_module(name) except Exception as excp: # pylint: disable=broad-exception-caught environLocal.warn(['failed import:', name, '\t', fp, '\n', '\tEXCEPTION:', str(excp).strip()]) diff --git a/music21/test/coverageM21.py b/music21/test/coverageM21.py deleted file mode 100644 index 51f7d10fd3..0000000000 --- a/music21/test/coverageM21.py +++ /dev/null @@ -1,70 +0,0 @@ -# ------------------------------------------------------------------------------ -# Name: coverageM21.py -# Purpose: Starts Coverage w/ default arguments -# -# Authors: Christopher Ariza -# Michael Scott Asato Cuthbert -# -# Copyright: Copyright © 2014-15 Michael Scott Asato Cuthbert -# License: BSD, see license.txt -# ------------------------------------------------------------------------------ -from __future__ import annotations - -import sys - -omit_modules = [ - 'dist/dist.py', - 'music21/test/*', - 'music21/configure.py', - 'music21/figuredBass/examples.py', - 'music21/alpha/*', -] - -# THESE ARE NOT RELEVANT FOR coveralls.io -- edit .coveragerc to change that -exclude_lines = [ - r'\s*import music21\s*', - r'\s*music21.mainTest\(\)\s*', - r'.*#\s*pragma:\s*no cover.*', - r'class TestExternal.*', - r'class TestSlow.*', - r'\s*if TYPE_CHECKING:\s*', - r'\s*if t.TYPE_CHECKING:\s*', -] - - -def getCoverage(overrideVersion=False): - # MEMORY / NOTE FOR UPDATING PYTHON: - # Run this on a MIDDLE supported Python version so that we can - # check timing of newest vs oldest, AND so that - # we can quickly see failures on newest and oldest. - # (The odds of a failure on the middle version are low if - # the newest and oldest are passing.) - # - # Note the .minor == 13 -- that makes it only run on 3.13 - # - # When changing the version, be sure also to change - # .github/workflows/maincheck.yml's line: - # if: ${{ matrix.python-version == '3.13' }} - if overrideVersion or sys.version_info.minor == 13: - try: - # noinspection PyPackageRequirements - import coverage # type: ignore - cov = coverage.Coverage(omit=omit_modules) # , debug='trace') - for e in exclude_lines: - cov.exclude(e, which='exclude') - cov.start() - import music21 # pylint: disable=unused-import # noqa: F401 - except ImportError: - cov = None - else: - cov = None - return cov - -def startCoverage(cov): - if cov is not None: - cov.start() - -def stopCoverage(cov): - if cov is not None: - cov.stop() - cov.save() diff --git a/music21/test/multiprocessTest.py b/music21/test/multiprocessTest.py index bfec2e3dea..f45cbaa144 100644 --- a/music21/test/multiprocessTest.py +++ b/music21/test/multiprocessTest.py @@ -158,7 +158,7 @@ def mainPoolRunner(testGroup=('test',), restoreEnvironmentDefaults=False, leaveO # unordered says that results can RETURN in any order; not that # they'd be pooled out in any order. res = pool.imap_unordered(runOneModuleWithoutImp, - ((modGather, fp) for fp in pathsToRun)) + ((modGather, fp) for fp in pathsToRun)) continueIt = True timeouts = 0 diff --git a/music21/test/pytest_plugin.py b/music21/test/pytest_plugin.py index 28c6f43971..386142848b 100644 --- a/music21/test/pytest_plugin.py +++ b/music21/test/pytest_plugin.py @@ -13,6 +13,7 @@ class DoctestItem: # type: ignore[no-redef] import music21 from music21.test.testRunner import stripAddresses +from music21.test.testRunner import windowsPaths @pytest.fixture(scope='session') def doctest_namespace() -> dict[str, Any]: @@ -64,4 +65,4 @@ def pytest_collection_modifyitems(config, items) -> None: example.want = stripAddresses(example.want, '0x...') if windows: - example.want = example.want.replace('PosixPath', 'WindowsPath') + example.want = windowsPaths(example.want) diff --git a/music21/test/testLint.py b/music21/test/testLint.py index fdda175a65..4c57a329bf 100644 --- a/music21/test/testLint.py +++ b/music21/test/testLint.py @@ -54,7 +54,7 @@ def main(fnAccept=None, strict=False): poolSize = common.cpus() if pylintRun is None: - print("make sure that 'sudo pip3 install pylint' is there. exiting.") + print("make sure that pylint is installed ('uv sync'). exiting.") return mg = commonTest.ModuleGather() diff --git a/music21/test/testRunner.py b/music21/test/testRunner.py index 62c822ba4f..33d39e8e31 100644 --- a/music21/test/testRunner.py +++ b/music21/test/testRunner.py @@ -5,7 +5,7 @@ # Authors: Michael Scott Asato Cuthbert # Christopher Ariza # -# Copyright: Copyright © 2006-2016 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2006-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ----------------------------------------------------------------------------- ''' @@ -98,7 +98,21 @@ def fixDoctests(doctestSuite: doctest._DocTestSuite) -> None: for example in dt.examples: example.want = stripAddresses(example.want, '0x...') if windows: - example.want = example.want.replace('PosixPath', 'WindowsPath') + example.want = windowsPaths(example.want) + + +def windowsPaths(want: str) -> str: + ''' + Change the PosixPaths a doctest expects into WindowsPaths that may start + with a drive. + + >>> from music21.test.testRunner import windowsPaths + >>> windowsPaths("PosixPath('/Applications/Finale Reader.app')") + "WindowsPath('...Applications/Finale Reader.app')" + >>> windowsPaths("PosixPath('scores/a.xml')") + "WindowsPath('scores/a.xml')" + ''' + return want.replace("PosixPath('/", "WindowsPath('...").replace('PosixPath', 'WindowsPath') ADDRESS = re.compile('0x[0-9A-Fa-f]+') @@ -253,8 +267,8 @@ def testHello(self): # search all names for case-insensitive match for name in dir(tObj): if (name.lower() == runThisTest.lower() - or name.lower() == ('test' + runThisTest.lower()) - or name.lower() == ('xtest' + runThisTest.lower())): + or name.lower() == ('test' + runThisTest.lower()) + or name.lower() == ('xtest' + runThisTest.lower())): runThisTest = name break if hasattr(tObj, runThisTest): diff --git a/music21/test/testSingleCoreAll.py b/music21/test/testSingleCoreAll.py index 6fd06d6dda..547416de2a 100644 --- a/music21/test/testSingleCoreAll.py +++ b/music21/test/testSingleCoreAll.py @@ -25,17 +25,11 @@ from music21 import environment from music21.test import commonTest -from music21.test import coverageM21 from music21.test import testRunner environLocal = environment.Environment('test.testSingleCoreAll') -# this is designed to be None for all but one system and a Coverage() object -# for one system. -cov = coverageM21.getCoverage() - - def main(testGroup: Sequence[str] = ('test',), restoreEnvironmentDefaults=False, limit: bool|None = None, @@ -118,8 +112,6 @@ def main(testGroup: Sequence[str] = ('test',), runner = unittest.TextTestRunner(verbosity=verbosity) finalTestResults = runner.run(s1) - coverageM21.stopCoverage(cov) - if (finalTestResults.errors or finalTestResults.failures or finalTestResults.unexpectedSuccesses): @@ -136,18 +128,17 @@ def ciMain(): # and TestExternal (without doctests) with show=False # exits with the aggregated returnCode returnCodeTest = main(testGroup=('test',), verbosity=1) - # restart coverage if running main() twice - coverageM21.startCoverage(cov) returnCodeExternal = main(testGroup=('external',), verbosity=1, show=False) sys.exit(returnCodeTest + returnCodeExternal) # ------------------------------------------------------------------------------ if __name__ == '__main__': - # if optional command line arguments are given, assume they are - # test group arguments - if len(sys.argv) >= 2: - unused_returnCode = main(sys.argv[1:]) + # 'ci' runs what GitHub Actions runs; other arguments are test group names. + if len(sys.argv) < 2: + main() + elif sys.argv[1] == 'ci': + ciMain() else: - unused_returnCode = main() + main(sys.argv[1:]) diff --git a/music21/test/test_base.py b/music21/test/test_base.py index e2a05d012a..79af46557b 100644 --- a/music21/test/test_base.py +++ b/music21/test/test_base.py @@ -82,11 +82,8 @@ def testNoteCreation(self): def testAssignFrozenDurationStaysFrozen(self): ''' - A FrozenDuration (e.g. a MeterSequence's duration) may be assigned as an - element's .duration; it is held shared -- not thawed -- and its client is - left unset, since a frozen duration never changes. It stays frozen: trying - to change the element's length raises, and it is up to the caller to - unfreeze first if they want a mutable duration. + A FrozenDuration assigned to an element's .duration stays frozen and + shared, and gets no client. ''' frozen = meter.MeterSequence('4/4').duration self.assertIsInstance(frozen, duration.FrozenDuration) @@ -96,11 +93,9 @@ def testAssignFrozenDurationStaysFrozen(self): self.assertIs(n.duration, frozen) self.assertIsNone(frozen.client) - # the duration stays frozen: n.quarterLength = x (i.e. - # n.duration.quarterLength = x) raises rather than silently copying. with self.assertRaises(TypeError): n.quarterLength = 3.0 - self.assertEqual(frozen.quarterLength, 4.0) # untouched + self.assertEqual(frozen.quarterLength, 4.0) def testOffsets(self): a = ElementWrapper(note.Note('A#')) diff --git a/music21/test/test_chord.py b/music21/test/test_chord.py index 3c06f08ac4..90fc63afbb 100644 --- a/music21/test/test_chord.py +++ b/music21/test/test_chord.py @@ -619,15 +619,15 @@ def testVolumePerPitchC(self): amps = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8] for accent in [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 1, 0.5, 1, 0.5, 0.5, 0.5, 0.5, 0.5, 1, 0.5, 0.5, 1, 0.5, 0.5, 0.5, - 1, 0.5, 0.5, 0.5, 0.5, 1, 0.5, 0.5, - None, None, None, None, - None, None, None, None, - None, None, None, None, - None, None, None, None, - 0.5, 0.5, 0.5, 0.5, 0.5, 1, 0.5, 1, 0.5, 0.5, 0.5, 0.5, - 0.5, 1, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, - 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, - 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, + 1, 0.5, 0.5, 0.5, 0.5, 1, 0.5, 0.5, + None, None, None, None, + None, None, None, None, + None, None, None, None, + None, None, None, None, + 0.5, 0.5, 0.5, 0.5, 0.5, 1, 0.5, 1, 0.5, 0.5, 0.5, 0.5, + 0.5, 1, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, + 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, + 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, ]: cNew = copy.deepcopy(c) if accent is not None: @@ -713,9 +713,34 @@ def testChordComponentsA(self): def testInvertingSimple(self): a = chord.Chord(['g4', 'b4', 'd5', 'f5']) self.assertEqual(a.inversion(), 0) - a.inversion(1) + a.setInversion(1, inPlace=True) self.assertEqual(repr(a), '') + def testSetInversionCopies(self): + a = chord.Chord('C4 E4 G4') + b = a.setInversion(2) + self.assertEqual(repr(a), '') + self.assertEqual(repr(b), '') + self.assertIs(b.derivation.origin, a) + with self.assertRaises(chord.ChordException): + chord.Chord().setInversion(1) + with self.assertRaises(chord.ChordException): + a.setInversion('six-four') + + a.setInversion(1, transpose=False, inPlace=True) + cleared = a.setInversion(None) + self.assertEqual(cleared.inversion(), 0) + self.assertEqual(a.inversion(), 1) + + def testInversionSetterLegacy(self): + # setting through inversion() works in place until its v13 removal + a = chord.Chord('C4 E4 G4') + self.assertIsNone(a.inversion(1)) + self.assertEqual(repr(a), '') + a.inversion(2, transposeOnSet=False) + self.assertEqual(a.inversion(), 2) + self.assertEqual(chord.Chord().inversion(1), -1) + def testDeepcopyChord(self): ch = Chord('C4 E4 G4') ch2 = copy.deepcopy(ch) @@ -739,6 +764,34 @@ def testChordCannotContainUnpitched(self): # noinspection PyTypeChecker Chord([note.Unpitched()]) # type: ignore + def testRemoveRedundantPitchesTellsNegativeOctavesFromFlats(self): + ''' + nameWithOctave spells both B-flat in octave 1 and B-natural in + octave -1 as 'B-1', so comparing pitches by that string made the + second look like a repeat of the first. + ''' + flat = pitch.Pitch('B-') + flat.octave = 1 + natural = pitch.Pitch('B') + natural.octave = -1 + self.assertEqual(flat.nameWithOctave, natural.nameWithOctave) + + ch = Chord([flat, natural]) + self.assertEqual(ch.removeRedundantPitches(inPlace=True), []) + self.assertEqual([(p.name, p.octave) for p in ch.pitches], + [('B-', 1), ('B', -1)]) + + # a real repeat still goes, whatever octave it is in + repeated = Chord([flat, natural, pitch.Pitch('B-1')]) + removed = repeated.removeRedundantPitches(inPlace=True) + self.assertEqual([p.nameWithOctave for p in removed], ['B-1']) + self.assertEqual(len(repeated.pitches), 2) + + # and a pitch with no octave is not the one in the default octave + octaveless = Chord([pitch.Pitch('C'), pitch.Pitch('C4')]) + self.assertEqual(octaveless.removeRedundantPitches(inPlace=True), []) + self.assertEqual(len(octaveless.pitches), 2) + def testCacheClearedOnAdd(self): ch = chord.Chord('C4 E4 G4') self.assertTrue(ch.isConsonant()) diff --git a/music21/test/test_clef.py b/music21/test/test_clef.py index f3175e15b2..32c212bfa8 100644 --- a/music21/test/test_clef.py +++ b/music21/test/test_clef.py @@ -7,6 +7,7 @@ from music21 import meter from music21.musicxml.xmlToM21 import MeasureParser from music21 import note +from music21 import pitch from music21 import stream @@ -120,6 +121,36 @@ def testTabClefBeamDirections(self): self.assertEqual(m.notes[0].stemDirection, 'down') + def testOctaveClefLowestLine(self): + self.assertEqual(clef.Treble8vbClef().lowestLine, 24) + self.assertEqual(clef.Treble8vaClef().lowestLine, 38) + self.assertEqual(clef.Bass8vbClef().lowestLine, 12) + self.assertEqual(clef.Bass8vaClef().lowestLine, 26) + + def testOctaveClefStemDirection(self): + # pitches just below, and on, the middle line of each staff + for clefClass, belowMiddle, onMiddle in ( + (clef.Treble8vbClef, 'A3', 'B3'), + (clef.Treble8vaClef, 'A5', 'B5'), + (clef.Bass8vbClef, 'C2', 'D2'), + (clef.Bass8vaClef, 'C4', 'D4'), + ): + clefObj = clefClass() + self.assertEqual(clefObj.getStemDirectionForPitches(pitch.Pitch(belowMiddle)), 'up') + self.assertEqual(clefObj.getStemDirectionForPitches(pitch.Pitch(onMiddle)), 'down') + + def testOctaveClefsAreNotPlainClefs(self): + for clefClass in (clef.Treble8vbClef, clef.Treble8vaClef): + clefObj = clefClass() + self.assertIsInstance(clefObj, clef.GClef) + self.assertNotIsInstance(clefObj, clef.TrebleClef) + self.assertEqual(clefObj.line, 2) + for clefClass in (clef.Bass8vbClef, clef.Bass8vaClef): + clefObj = clefClass() + self.assertIsInstance(clefObj, clef.FClef) + self.assertNotIsInstance(clefObj, clef.BassClef) + self.assertEqual(clefObj.line, 4) + if __name__ == '__main__': import music21 diff --git a/music21/test/test_expressions.py b/music21/test/test_expressions.py index 9504eddc4c..a7096cd78f 100644 --- a/music21/test/test_expressions.py +++ b/music21/test/test_expressions.py @@ -402,6 +402,34 @@ def testUpdateAccidentalDisplayWithAccidentNameSet(self): self.assertEqual(turn.ornamentalPitches[1].accidental, pitch.Accidental('natural')) self.assertTrue(turn.ornamentalPitches[1].accidental.displayStatus) + def testUpdateAccidentalDisplayWithUndecidedAccidental(self): + noSharpsOrFlats = key.KeySignature(0) + for ornament in (expressions.Trill(), expressions.InvertedMordent()): + with self.subTest(ornament=ornament): + ornament.accidental = pitch.Accidental('sharp') + ornament.resolveOrnamentalPitches(note.Note('G4'), keySig=noSharpsOrFlats) + ornament.updateAccidentalDisplay(pitchPast=[]) + self.assertEqual(ornament.ornamentalPitch.nameWithOctave, 'A#4') + self.assertTrue(ornament.ornamentalPitch.accidental.displayStatus) + + trill = expressions.Trill() + trill.accidental = pitch.Accidental('sharp') + trill.resolveOrnamentalPitches(note.Note('G4'), keySig=noSharpsOrFlats) + trill.updateAccidentalDisplay(pitchPast=[pitch.Pitch('A#4')]) + self.assertIs(trill.ornamentalPitch.accidental.displayStatus, False) + + trill = expressions.Trill() + trill.accidental = pitch.Accidental('sharp') + trill.accidental.displayStatus = False + trill.resolveOrnamentalPitches(note.Note('G4'), keySig=noSharpsOrFlats) + trill.updateAccidentalDisplay(pitchPast=[]) + self.assertIs(trill.ornamentalPitch.accidental.displayStatus, False) + + n = note.Note('G4') + n.expressions.append(expressions.Trill(accidental=pitch.Accidental('sharp'))) + raw = m21ToXml.GeneralObjectExporter().parse(stream.Stream([n])) + self.assertIn(b'') + def testTransposeMicrotonePreserved(self): + # A microtone (cents offset) must survive transposition unchanged and + # must not be turned into a spurious quarter-tone accidental. Formerly + # the source microtone leaked into the accidental computation, so e.g. + # C4(+30c) transposed by a unison became C~4(-20c), or larger microtones + # raised AccidentalException. + + # Transposing by a perfect unison is the identity. + p = pitch.Pitch('C4') + p.microtone = 30 + out = p.transpose(interval.Interval('P1')) + self.assertEqual(out.nameWithOctave, 'C4') + self.assertEqual(out.microtone.cents, 30) + + # A formerly-crashing case: G#5(+50c) transposed by an augmented unison. + p = pitch.Pitch('G#5') + p.microtone = 50 + out = p.transpose(interval.Interval('A1')) + self.assertEqual(out.nameWithOctave, 'G##5') + self.assertEqual(out.microtone.cents, 50) + + # A quarter-tone accidental and a microtone both survive a unison. + p = pitch.Pitch('C~4') + p.microtone = 30 + out = p.transpose(interval.Interval('P1')) + self.assertEqual(out.name, 'C~') + self.assertEqual(out.microtone.cents, 30) + + # Across intervals and microtone magnitudes: the cents are preserved, + # the sounding pitch moves by exactly the interval, and no quarter-tone + # accidental is introduced on an integer-semitone interval. + for iName in ['P1', 'm2', 'M3', 'P5', 'm7', 'P8', 'A4', 'd5']: + iv = interval.Interval(iName) + for cents in (-49, -25, 26, 49): + with self.subTest(interval=iName, cents=cents): + src = pitch.Pitch('C4') + src.microtone = cents + psBefore = src.ps + out = src.transpose(iv) + self.assertAlmostEqual(out.microtone.cents, cents) + self.assertAlmostEqual(out.ps - psBefore, + iv.chromatic.semitones) + if out.accidental is not None: + self.assertEqual(out.accidental.alter % 1, 0.0) + def testDescendingAugmentedUnison(self): ns = note.Note('C4') ne = note.Note('C-4') @@ -398,6 +443,18 @@ def testRepeatedTransposePitch(self): p2 = intv.transposePitch(p) self.assertEqual(p2.nameWithOctave, 'B-20') + def testTransposePitchInPlaceImplicitDiatonicFundamental(self): + ''' + transposePitch(inPlace=True) with an Interval made from a number of semitones + also transposes the pitch's fundamental. + ''' + intv = interval.Interval(2) + self.assertTrue(intv.implicitDiatonic) + p = pitch.Pitch('E5', fundamental=pitch.Pitch('C3')) + self.assertIsNone(intv.transposePitch(p, inPlace=True)) + self.assertEqual(p.nameWithOctave, 'F#5') + self.assertEqual(p.fundamental.nameWithOctave, 'D3') + def testIntervalWithOneNoteGiven(self): noteC = note.Note('C4') @@ -447,6 +504,11 @@ def testEmptyIntervalProperties(self): self.assertEqual(empty.intervalClass, 0) self.assertEqual(empty.name, 'P1') + def testGenericTransposePitchBelowOctaveZero(self): + lowered = interval.GenericInterval(-2).transposePitch(pitch.Pitch('C0')) + self.assertEqual(lowered.step, 'B') + self.assertEqual(lowered.octave, -1) + if __name__ == '__main__': import music21 diff --git a/music21/test/test_metadata.py b/music21/test/test_metadata.py index 3784dd4e36..7b1672fb07 100644 --- a/music21/test/test_metadata.py +++ b/music21/test/test_metadata.py @@ -7,6 +7,7 @@ from music21 import converter from music21 import corpus from music21 import metadata +from music21 import stream from music21.musicxml import testFiles as mTF @@ -186,6 +187,24 @@ def testMetadataSearch(self): (True, 'movementName'), ) + def testMetadataSearchUncompilablePattern(self): + md = metadata.Metadata() + md['title'] = metadata.Text('Der Fisch(?) Concelebrant (4-5), S. 7') + self.assertEqual( + md.search('Der Fisch(?) Concelebrant (4-5), S. 7', field='title'), + (True, 'title'), + ) + self.assertEqual(md.search('Runckelstube(?)', field='title'), (False, None)) + o = stream.Opus() + for title in ('Der Fisch(?) Concelebrant (4-5), S. 7', 'Mai cai vgl. Kopie)'): + s = stream.Score() + s.insert(0, metadata.Metadata(title=title)) + o.insert(0, s) + self.assertEqual( + o.getScoreByTitle('Mai cai vgl. Kopie)').metadata.title, + 'Mai cai vgl. Kopie)', + ) + def testRichMetadata02(self): score = corpus.parse('bwv66.6') richMetadata = metadata.RichMetadata() @@ -430,7 +449,7 @@ def checkUniqueNamedItem( # self.assertIsInstance isn't sufficient, apparently. assert isinstance(itemNamespaceName, metadata.Contributor) self.assertEqual(itemNamespaceName.role, - contributorRole if contributorRole else uniqueName) + contributorRole if contributorRole else uniqueName) def testUniqueNameAccess(self): self.checkUniqueNamedItem('abstract', 'dcterms') diff --git a/music21/test/test_note.py b/music21/test/test_note.py index 35fa34cc66..9d7926de04 100644 --- a/music21/test/test_note.py +++ b/music21/test/test_note.py @@ -4,6 +4,7 @@ import unittest from music21 import articulations +from music21 import chord from music21 import corpus from music21.duration import DurationTuple from music21 import expressions @@ -76,6 +77,22 @@ def testCopyNote(self): b = copy.deepcopy(a) self.assertEqual(b.name, a.name) + def testZeroQuarterLength(self): + for obj in (note.Rest(quarterLength=0.0), + note.Rest(0), + note.Note('C4', quarterLength=0), + note.Unpitched(quarterLength=0), + chord.Chord(['C4', 'E4'], quarterLength=0)): + self.assertEqual(obj.quarterLength, 0.0) + self.assertEqual(obj.duration.type, 'zero') + + for obj in (note.Rest(), + note.Rest(quarterLength=None), + note.Note('C4', type=None), + chord.Chord(['C4', 'E4'])): + self.assertEqual(obj.quarterLength, 1.0) + self.assertEqual(obj.duration.type, 'quarter') + def testMusicXMLFermata(self): a = corpus.parse('bach/bwv5.7') found = [] @@ -246,7 +263,7 @@ def testNoteEquality(self): articulations.StrongAccent()] for a, b, c, d, match in [(n1, n4, a1, a1, True), - (n1, n2, a1, a1, False), (n1, n3, a1, a1, False), + (n1, n2, a1, a1, False), (n1, n3, a1, a1, False), # same pitch different orderings (n1, n4, a2, a3, True), (n1, n4, a4, a5, True), # different pitch same orderings diff --git a/music21/test/test_pitch.py b/music21/test/test_pitch.py index 3e92d0479d..6c57994ab0 100644 --- a/music21/test/test_pitch.py +++ b/music21/test/test_pitch.py @@ -5,24 +5,28 @@ # Authors: Michael Scott Asato Cuthbert # Christopher Ariza # -# Copyright: Copyright © 2008-2024 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2008-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ from __future__ import annotations import copy +from fractions import Fraction import unittest +from unittest import mock from music21 import common from music21 import converter from music21 import corpus +from music21 import editorial +from music21 import interval from music21 import key from music21 import note from music21 import pitch from music21 import scale from music21 import stream from music21.musicxml import m21ToXml -from music21.pitch import Pitch, Accidental +from music21.pitch import Pitch, Accidental, AccidentalException, PitchException class Test(unittest.TestCase): @@ -41,6 +45,69 @@ def testOctave(self): b = Pitch('B#3') self.assertEqual(b.octave, 3) + def testOctaveIsImplicit(self): + anyFSharp = Pitch('F#') + self.assertTrue(anyFSharp.octaveIsImplicit) + self.assertEqual(anyFSharp.octave, 4) + self.assertEqual(anyFSharp.implicitOctave, 4) + self.assertEqual(anyFSharp.nameWithOctave, 'F#') + self.assertEqual(anyFSharp.ps, 66.0) + + anyFSharp.octave = 5 + self.assertFalse(anyFSharp.octaveIsImplicit) + self.assertEqual(anyFSharp.octave, 5) + self.assertEqual(anyFSharp.implicitOctave, 5) + self.assertEqual(anyFSharp.nameWithOctave, 'F#5') + + anyFSharp.octaveIsImplicit = True + self.assertEqual(anyFSharp.octave, 4) + self.assertEqual(anyFSharp.nameWithOctave, 'F#') + self.assertEqual(anyFSharp.ps, 66.0) + + # False gives the default octave explicitly + anyFSharp.octaveIsImplicit = False + self.assertEqual(anyFSharp.nameWithOctave, 'F#4') + + # octave = None still forgets the octave + anyFSharp.octave = None + self.assertTrue(anyFSharp.octaveIsImplicit) + self.assertEqual(anyFSharp.octave, 4) + + # Notes proxy the int + self.assertEqual(note.Note('B-').octave, 4) + self.assertEqual(note.Note('B-3').octave, 3) + + # creation paths + self.assertTrue(Pitch().octaveIsImplicit) + self.assertTrue(Pitch(3).octaveIsImplicit) # pitch class + self.assertFalse(Pitch(65).octaveIsImplicit) # midi + self.assertFalse(Pitch('C4').octaveIsImplicit) + self.assertFalse(Pitch('C', octave=4).octaveIsImplicit) + self.assertTrue(Pitch(step='D', accidental='#').octaveIsImplicit) + self.assertTrue(note.Note('B-').pitch.octaveIsImplicit) + self.assertFalse(note.Note().pitch.octaveIsImplicit) + + # implicitness survives copying and transposition + anyD = Pitch('D') + self.assertTrue(copy.deepcopy(anyD).octaveIsImplicit) + self.assertTrue(anyD.transpose('M2').octaveIsImplicit) + self.assertTrue(anyD.transpose(3).octaveIsImplicit) + self.assertTrue(anyD.getEnharmonic().octaveIsImplicit) + self.assertFalse(Pitch('D4').transpose('M2').octaveIsImplicit) + + # an implicit octave is not the same as an explicit default octave + self.assertNotEqual(Pitch('C'), Pitch('C4')) + self.assertEqual(Pitch('C'), Pitch('C')) + self.assertNotEqual(hash(Pitch('C')), hash(Pitch('C4'))) + + # the setter informs a Note client only when something changed + n = note.Note('C4') + with mock.patch.object(n, 'pitchChanged') as pitchChanged: + n.pitch.octaveIsImplicit = False + pitchChanged.assert_not_called() + n.pitch.octaveIsImplicit = True + pitchChanged.assert_called_once() + def testNameSetting(self): with self.assertRaisesRegex(ValueError, r"Cannot have octave given before pitch name in '8D-4'\."): @@ -55,6 +122,99 @@ def testNameSetting(self): ): p.name = 32 + def testNameCache(self): + ''' + A name is parsed once and remembered; names that cannot be parsed are not. + ''' + with mock.patch.dict(pitch._pitchNameCache, clear=True): + first = Pitch('E-6') + self.assertEqual(pitch._pitchNameCache, {'E-6': ('E', '-', 6)}) + second = Pitch('E-6') + self.assertEqual(second.nameWithOctave, 'E-6') + # Accidentals can be changed, so each Pitch gets its own + self.assertIsNot(second.accidental, first.accidental) + + # a name that cannot be parsed changes nothing + with self.assertRaises(AccidentalException): + second.name = 'D$' + self.assertEqual(second.nameWithOctave, 'E-6') + with self.assertRaisesRegex(ValueError, 'must be a string'): + second.name = ['C4'] # not hashable + with self.assertRaisesRegex(ValueError, 'at most 100 characters'): + second.name = 'C4'.center(101) # padding counts + self.assertEqual(list(pitch._pitchNameCache), ['E-6']) + self.assertEqual(Pitch('C4'.center(100)).nameWithOctave, 'C4') + + # cleared when full + with mock.patch.object(pitch, '_pitchNameCacheSize', 1): + Pitch('F4') + self.assertEqual(list(pitch._pitchNameCache), ['F4']) + + def testNameSetterInformsNoteOnce(self): + ''' + Setting a name without an octave, even one not seen before, keeps the + octave and the microtone, makes the spelling explicit, and tells the Note once. + ''' + n = note.Note(73) # C#5, spelling inferred + n.pitch.microtone = 20 + with (mock.patch.dict(pitch._pitchNameCache, clear=True), + mock.patch.object(n, 'pitchChanged') as pitchChanged): + n.pitch.name = 'B-' + pitchChanged.assert_called_once() + self.assertEqual(str(n.pitch), 'B-5(+20c)') + self.assertFalse(n.pitch.spellingIsInferred) + + def testInitShortcutsMatchParsing(self): + # 'C', 'C4', and step= skip the name and step setters + self.assertEqual(Pitch('C'), Pitch('c')) + self.assertTrue(Pitch('C').octaveIsImplicit) + self.assertEqual(Pitch('C4'), Pitch('c4')) + self.assertFalse(Pitch('C4').octaveIsImplicit) + self.assertEqual(Pitch('C', step='D').step, 'C') + self.assertEqual(Pitch(step='D'), Pitch('d')) + self.assertEqual(Pitch(step='d').step, 'D') + with self.assertRaises(PitchException): + Pitch(step='CD') + + def testPsAndMidiSettersInformNoteOnce(self): + for attribute in ('ps', 'midi'): + with self.subTest(attribute=attribute): + n = note.Note('D4') + with mock.patch.object(n, 'pitchChanged') as pitchChanged: + setattr(n.pitch, attribute, 61) + pitchChanged.assert_called_once() + + def testWholeNumberPsAndMidi(self): + ''' + Setting a whole-number .ps or .midi replaces a quarter-tone accidental + and clears a microtone. + ''' + p = Pitch('D~5') + p.microtone = 20 + p.ps = 61 + self.assertEqual(p.nameWithOctave, 'C#4') + self.assertEqual(p.microtone.cents, 0) + + # a natural is None, and a negative ps floors to the octave below + p.ps = 60.0 + self.assertEqual(p.nameWithOctave, 'C4') + self.assertIsNone(p.accidental) + p.ps = -1 + self.assertEqual((p.step, p.octave), ('B', -2)) + + # above 127, the midi setter wraps into the top octave; ps does not + p = Pitch() + p.midi = 130 + self.assertEqual(p.nameWithOctave, 'B-8') + p.ps = 130 + self.assertEqual(p.nameWithOctave, 'B-9') + + # Pitch(number) keeps the natural + for value in (60, 60.0): + p = Pitch(value) + self.assertEqual(p.nameWithOctave, 'C4') + self.assertEqual(p.accidental.name, 'natural') + def testAccidentalImport(self): @@ -74,6 +234,140 @@ def testAccidentalImport(self): self.assertEqual(pAltered.accidental.name, 'sharp') self.assertTrue(pAltered.accidental.displayStatus) + def testAccidentalDeepcopyDoesNotShareStyleOrEditorial(self): + ''' + A deepcopy of an Accidental gets its own Style and Editorial. + ''' + a = Accidental('sharp') + a.style.color = 'red' + a.editorial.comments.append(editorial.Comment('cautionary')) + b = copy.deepcopy(a) + self.assertEqual(b.style.color, 'red') + self.assertEqual(len(b.editorial.comments), 1) + b.style.color = 'blue' + b.editorial.comments.append(editorial.Comment('ficta')) + self.assertEqual(a.style.color, 'red') + self.assertEqual(len(a.editorial.comments), 1) + + def testAccidentalLookupTable(self): + ''' + Every way of writing each standard accidental gives the same accidental + from Accidental(), set(), and set(allowNonStandardValue=True), + and set() tells the client once. AI-assisted (Claude). + ''' + import numpy + valuesByResult = { + ('natural', 0.0, ''): ['natural', 'n', 0, 0.0, False, 'Natural', 'N'], + ('sharp', 1.0, '#'): ['sharp', '#', 'is', 1, 1.0, True, 'Sharp', 'IS'], + ('double-sharp', 2.0, '##'): [ + 'double-sharp', '##', 'isis', 2, 2.0, 'Double-Sharp', numpy.int64(2)], + ('triple-sharp', 3.0, '###'): ['triple-sharp', '###', 'isisis', 3, 3.0], + ('quadruple-sharp', 4.0, '####'): ['quadruple-sharp', '####', 'isisisis', 4, 4.0], + ('flat', -1.0, '-'): [ + 'flat', '-', 'es', 'b', -1, -1.0, 'FLAT', 'B', numpy.float64(-1.0)], + ('double-flat', -2.0, '--'): ['double-flat', '--', 'eses', -2, -2.0, 'ESES'], + ('triple-flat', -3.0, '---'): ['triple-flat', '---', 'eseses', -3, -3.0], + ('quadruple-flat', -4.0, '----'): ['quadruple-flat', '----', 'eseseses', -4, -4.0], + ('half-sharp', 0.5, '~'): [ + 'half-sharp', '~', 'quarter-sharp', 'ih', 'semisharp', 0.5, Fraction(1, 2)], + ('one-and-a-half-sharp', 1.5, '#~'): [ + 'one-and-a-half-sharp', '#~', 'three-quarter-sharp', 'three-quarters-sharp', + 'isih', 'sesquisharp', 1.5], + ('half-flat', -0.5, '`'): [ + 'half-flat', '`', 'quarter-flat', 'eh', 'semiflat', -0.5], + ('one-and-a-half-flat', -1.5, '-`'): [ + 'one-and-a-half-flat', '-`', 'three-quarter-flat', 'three-quarters-flat', + 'eseh', 'sesquiflat', -1.5, Fraction(-3, 2)], + } + listed = set() + for values in valuesByResult.values(): + listed.update(values) + # every name, every alternate name, and every modifier except natural's '' + self.assertLessEqual(set(pitch.accidentalNameToModifier), listed) + self.assertLessEqual(set(pitch.alternateNameToAccidentalName), listed) + self.assertLessEqual(set(pitch.accidentalNameToModifier.values()) - {''}, listed) + + for (name, alter, modifier), values in valuesByResult.items(): + for value in values: + for allowNonStandardValue in (None, False, True): + with self.subTest(value=value, allowNonStandardValue=allowNonStandardValue): + if allowNonStandardValue is None: + acc = Accidental(value) + else: + acc = Accidental('sharp') + acc.setAttributeIndependently('name', 'unset') + acc.setAttributeIndependently('alter', 99) + acc.setAttributeIndependently('modifier', '?') + acc._client = mock.Mock() + acc.set(value, allowNonStandardValue=allowNonStandardValue) + self.assertEqual(acc._client.informClient.call_count, 1) + self.assertEqual(acc.name, name) + self.assertIs(type(acc.alter), float) + self.assertEqual(acc.alter, alter) + self.assertEqual(acc.modifier, modifier) + + def testUnsupportedAccidentalValues(self): + ''' + Anything else raises, naming the value (lowercased). + ''' + # '' is natural's modifier, but set() does not take it + for value, shown in [ + ('flat-flat-up', 'flat-flat-up'), + ('FLAT-FLAT-UP', 'flat-flat-up'), + ('Dièse', 'dièse'), + ('', ''), + (5, '5'), + (0.25, '0.25'), + (None, 'None'), + (['flat'], "['flat']"), + ]: + with self.subTest(value=value): + message = f'{shown} is not a supported accidental type' + with self.assertRaises(AccidentalException) as cm: + Accidental(value) + self.assertEqual(str(cm.exception), message) + + acc = Accidental('sharp') + acc._client = mock.Mock() + with self.assertRaises(AccidentalException) as cm: + acc.set(value) + self.assertEqual(str(cm.exception), message) + self.assertEqual((acc.name, acc.alter, acc.modifier), ('sharp', 1.0, '#')) + self.assertEqual(acc._client.informClient.call_count, 0) + + def testAccidentalSetNonStandardValue(self): + ''' + A nonstandard string changes only the name (lowercased); a nonstandard + number changes only the alter. Neither tells the client. + ''' + for value, name, alter in [ + ('quintuple-sharp', 'quintuple-sharp', 1.0), + ('FLAT-FLAT-UP', 'flat-flat-up', 1.0), + ('', '', 1.0), + (5, 'sharp', 5), + (0.25, 'sharp', 0.25), + ]: + with self.subTest(value=value): + acc = Accidental('sharp') + acc._client = mock.Mock() + acc.set(value, allowNonStandardValue=True) + self.assertEqual(acc.name, name) + self.assertEqual(acc.alter, alter) + self.assertEqual(acc.modifier, '#') + self.assertEqual(acc._client.informClient.call_count, 0) + + def testAccidentalInitDefaults(self): + for acc in (Accidental(), Accidental('Flat')): + self.assertEqual(acc.displayType, 'normal') + self.assertIsNone(acc.displayStatus) + self.assertEqual(acc.displayStyle, 'normal') + self.assertEqual(acc.displaySize, 'full') + self.assertEqual(acc.displayLocation, 'normal') + self.assertIsNone(acc._client) + self.assertFalse(acc.hasStyleInformation) + self.assertIsNone(acc._editorial) + self.assertEqual(repr(Accidental()), '') + def testUpdateAccidentalDisplaySimple(self): ''' Test updating accidental display. @@ -266,7 +560,7 @@ def compare(_past, _result): pList = [Pitch('f#3'), Pitch('f3'), Pitch('f#3'), Pitch('g3'), Pitch('f#4'), Pitch('f#4')] result = [('sharp', False), ('natural', True), ('sharp', True), - (None, None), ('sharp', True), ('sharp', False)] + (None, None), ('sharp', True), ('sharp', False)] # no 4 is a dicey affair; could go either way ks = key.KeySignature(1) proc(pList, [], ks.alteredPitches) @@ -396,6 +690,55 @@ def testOverrideDisplayStatus(self): n.pitch.updateAccidentalDisplay(overrideStatus=True, alteredPitches=k.alteredPitches) self.assertIs(n.pitch.accidental.displayStatus, False) + def testDisplayTypeAlwaysWithNothingInPast(self): + ''' + `displayType='always'` and `cautionaryAll` win over the key signature + even for the first pitch of a part, where nothing precedes it. + + AI-assisted (Claude). + ''' + p = Pitch('An2') + p.accidental.displayType = 'always' + p.updateAccidentalDisplay() + self.assertIs(p.accidental.displayStatus, True) + + # a sharp already implied by the key signature still shows + p = Pitch('F#4') + p.accidental.displayType = 'always' + p.updateAccidentalDisplay(alteredPitches=key.Key('G').alteredPitches) + self.assertIs(p.accidental.displayStatus, True) + + # cautionaryAll creates the natural that was not there + p = Pitch('B4') + p.updateAccidentalDisplay(cautionaryAll=True) + self.assertEqual(p.accidental.name, 'natural') + self.assertIs(p.accidental.displayStatus, True) + + # 'even-tied' behaves the same as 'always' here + p = Pitch('E-3') + p.accidental.displayType = 'even-tied' + p.updateAccidentalDisplay(alteredPitches=key.Key('E-').alteredPitches) + self.assertIs(p.accidental.displayStatus, True) + + # in a stream, every note of the part shows, not just the ones after the first + s = converter.parse('tinyNotation: AAn2 Fn') + for n in s.recurse().notes: + n.pitch.accidental.displayType = 'always' + s.makeAccidentals(inPlace=True) + self.assertEqual([n.pitch.accidental.displayStatus for n in s.recurse().notes], + [True, True]) + + # regression guard: a normal displayType is untouched by the above + p = Pitch('F#4') + p.updateAccidentalDisplay(alteredPitches=key.Key('G').alteredPitches) + self.assertIs(p.accidental.displayStatus, False) + + # regression guard: with a non-empty past, repeated pitches are unchanged + past = [Pitch('c#4')] + p = Pitch('c#4') + p.updateAccidentalDisplay(pitchPast=past) + self.assertIs(p.accidental.displayStatus, False) + def testImplicitToExplicitNatural(self): p = converter.parse('tinyNotation: 2/4 f4 fn4') last_note = p.recurse().notes.last() @@ -407,6 +750,18 @@ def testImplicitToExplicitNatural(self): p.makeAccidentals(inPlace=True) self.assertIs(last_note.pitch.accidental.displayStatus, False) + def testExplicitNaturalDisplayStatusNone(self): + p = converter.parse('tinyNotation: d1 d1') + first_note = p.recurse().notes.first() + last_note = p.recurse().notes.last() + first_note.pitch.accidental = pitch.Accidental('natural') + last_note.pitch.accidental = pitch.Accidental('natural') + self.assertIsNone(first_note.pitch.accidental.displayStatus) + self.assertIsNone(last_note.pitch.accidental.displayStatus) + p.makeAccidentals(inPlace=True) + self.assertIs(first_note.pitch.accidental.displayStatus, False) + self.assertIs(last_note.pitch.accidental.displayStatus, False) + def testIfAbsolutelyNecessary(self): ''' Beginning of test cases for if-absolutely-necessary. @@ -489,6 +844,12 @@ def testLowNotes(self): self.assertEqual(lowC.name, 'C') self.assertEqual(lowC.octave, -1) + def testTransposeInPlaceReplacesMicrotone(self): + p = Pitch('C4') + p.microtone = 50 + p.transpose(interval.ChromaticInterval(0.5), inPlace=True) + self.assertEqual(str(p), 'C#4') + def testQuarterToneA(self): p1 = Pitch('D#~') # environLocal.printDebug([p1, p1.accidental]) diff --git a/music21/text.py b/music21/text.py index 92426e02f3..28a6dfab09 100644 --- a/music21/text.py +++ b/music21/text.py @@ -291,7 +291,6 @@ class TextBox(base.Music21Object): .. image:: images/textBoxes-01.* :width: 600 - ''' _styleClass = style.TextStyle classSortOrder = -31 # text expressions are -30 @@ -417,7 +416,7 @@ def readExcerpts(cls): ''' for languageCode in cls.languageCodes: thisExcerpt = (common.getSourceFilePath() / 'languageExcerpts' - / 'trainingData' / (languageCode + '.txt')) + / 'trainingData' / (languageCode + '.txt')) with thisExcerpt.open(encoding='utf-8') as f: excerptWords = f.read().split() diff --git a/music21/tinyNotation.py b/music21/tinyNotation.py index a9b3f21c6a..f510e9e90a 100644 --- a/music21/tinyNotation.py +++ b/music21/tinyNotation.py @@ -4,7 +4,7 @@ # # Authors: Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2009-2024 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2009-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------- ''' @@ -340,6 +340,14 @@ class TieState(State): ''' autoExpires = 2 + def affectTokenAfterParse(self, m21Obj): + ''' + Ties only notes and rests; other tokens pass through. + ''' + if not isinstance(m21Obj, note.GeneralNote): + return m21Obj + return super().affectTokenAfterParse(m21Obj) + def end(self): ''' End the tie state by applying tie ties to the appropriate notes. @@ -372,8 +380,10 @@ def end(self): def affectTokenAfterParse(self, n): ''' - Puts a tuplet on the note. + Puts a tuplet on a note or rest. ''' + if not isinstance(n, note.GeneralNote): + return n super().affectTokenAfterParse(n) newTup = duration.Tuplet() newTup.durationActual = duration.durationTupleFromTypeDots(n.duration.type, 0) @@ -517,6 +527,8 @@ def durationType(self, element, search, pm, t, parent): self.durationFound = True typeNum = int(search.group(1)) if typeNum == 0: + # NOTE: using 0 to mean full-bar + fermata was never fully documented. + # it is considered deprecated. if parent.stateDict['currentTimeSignature'] is not None: element.duration = copy.deepcopy( parent.stateDict['currentTimeSignature'].barDuration @@ -1133,7 +1145,7 @@ def __init__( self.modifierSquare = None self.modifierUnderscore = LyricModifier - self.makeNotation = makeNotation + self.makeNotation: bool = makeNotation self.raiseExceptions = raiseExceptions # will be filled by self.setupRegularExpressions() self._tokenMapRe: list[tuple[typing.Pattern, type]] = [] @@ -1270,7 +1282,10 @@ def parseOne(self, i: int, t: str): numberOfStatesToEnd = len(self.activeStates) for i in range(numberOfStatesToEnd): - stateToRemove = self.activeStates.pop() + stateIndex = len(self.activeStates) - 1 + while stateIndex > 0 and self.activeStates[stateIndex].autoExpires is not False: + stateIndex -= 1 + stateToRemove = self.activeStates.pop(stateIndex) possibleObj = stateToRemove.end() if possibleObj is not None: self.stream.coreAppend(possibleObj) @@ -1408,7 +1423,7 @@ def postParse(self): It currently runs `.makeMeasures()` on `.stream` unless `.makeNotation` is `False`. ''' - if self.makeNotation is not False: + if self.makeNotation: self.stream.makeMeasures(inPlace=True) @@ -1557,6 +1572,32 @@ def test_too_many_states(self): ): c.parse() + def testCloseBracketAfterTie(self): + c = Converter('4/4 trip{c8 d e~} e4 f2') + c.parse() + notes = list(c.stream.recurse().notes) + self.assertEqual([n.tie.type if n.tie else None for n in notes], + [None, None, 'start', 'stop', None]) + self.assertEqual([n.duration.quarterLength for n in notes], + [fractions.Fraction(1, 3)] * 3 + [1.0, 2.0]) + self.assertEqual(notes[2].duration.tuplets[0].type, 'stop') + + def testStateDoesNotAffectTimeSignature(self): + c = Converter('2/4 trip{c8 d 4/4} e4 f4 g2') + c.parse() + s = c.stream + self.assertEqual(s[meter.TimeSignature].last().ratioString, '4/4') + notes = list(s.recurse().notes) + self.assertEqual([n.duration.quarterLength for n in notes], + [fractions.Fraction(1, 3)] * 2 + [1.0, 1.0, 2.0]) + self.assertEqual([n.duration.tuplets[0].type for n in notes[:2]], ['start', 'stop']) + + def testTiePastTimeSignature(self): + c = Converter('4/4 c1~ 3/4 c2.') + c.parse() + notes = list(c.stream.recurse().notes) + self.assertEqual([n.tie.type if n.tie else None for n in notes], ['start', 'stop']) + class TestExternal(unittest.TestCase): show = True diff --git a/music21/tree/core.py b/music21/tree/core.py index 5de72e4654..ec6980294d 100644 --- a/music21/tree/core.py +++ b/music21/tree/core.py @@ -378,7 +378,6 @@ def update(self) -> None: child notes of `n` then this would not fix that node's balance/height. This method assumes that children have the correct information and only updates the information for this node. - ''' leftHeight = self.leftChild.height if self.leftChild else -1 rightHeight = self.rightChild.height if self.rightChild else -1 diff --git a/music21/tree/fromStream.py b/music21/tree/fromStream.py index 46ab6da851..edd90cd3fb 100644 --- a/music21/tree/fromStream.py +++ b/music21/tree/fromStream.py @@ -5,7 +5,7 @@ # Authors: Joséphine Wolf Oberholtzer # Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2013-2022 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2013-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ----------------------------------------------------------------------------- ''' @@ -18,7 +18,7 @@ import unittest from music21.base import Music21Object -from music21.common.types import M21ObjType, StreamType +from music21.common.types import M21ObjType, OffsetQL, StreamType from music21 import common from music21 import note from music21.tree import spans @@ -34,7 +34,7 @@ def listOfTreesByClass( *, classLists: Sequence[Sequence[type[M21ObjType]]] = (), currentParentage: tuple[stream.Stream, ...]|None = None, - initialOffset: float = 0.0, + initialOffset: OffsetQL = 0.0, flatten: bool|str = False, useTimespans: bool = False ) -> list[trees.OffsetTree|timespanTree.TimespanTree]: @@ -107,7 +107,7 @@ def listOfTreesByClass( # do this to avoid munging activeSites inputStreamElements = inputStream._elements[:] + inputStream._endElements for element in inputStreamElements: - offset = lastParentage.elementOffset(element) + initialOffset + offset = common.opFrac(lastParentage.elementOffset(element) + initialOffset) wasStream = False if element.isStream: @@ -128,8 +128,8 @@ def listOfTreesByClass( if not wasStream or flatten == 'semiFlat': parentOffset = initialOffset - parentEndTime = initialOffset + lastParentage.duration.quarterLength - endTime = offset + element.duration.quarterLength + parentEndTime = common.opFrac(initialOffset + lastParentage.duration.quarterLength) + endTime = common.opFrac(offset + element.duration.quarterLength) for classBasedTree, classList in zip(outputTrees, classLists): if classList and element.classSet.isdisjoint(classList): @@ -207,7 +207,6 @@ def asTree( >>> etFlatNotes = tree.fromStream.asTree(score, flatten=True, classList=(note.Note,)) >>> etFlatNotes to 8.0) > - ''' def recurseGetTreeByClass( innerStream, @@ -221,7 +220,7 @@ def recurseGetTreeByClass( # do this to avoid munging activeSites innerStreamElements = innerStream._elements[:] + innerStream._endElements - parentEndTime = initialOffset + lastParentage.duration.quarterLength + parentEndTime = common.opFrac(initialOffset + lastParentage.duration.quarterLength) for element in innerStreamElements: flatOffset = common.opFrac(lastParentage.elementOffset(element) + initialOffset) @@ -239,7 +238,7 @@ def recurseGetTreeByClass( continue if useTimespans: - endTime = flatOffset + element.duration.quarterLength + endTime = common.opFrac(flatOffset + element.duration.quarterLength) pitchedTimespan = spans.PitchedTimespan( element=element, parentage=tuple(reversed(currentParentage)), @@ -404,6 +403,22 @@ def testAutoSortExample(self): self.assertEqual(scTree.endTime, 8.0) # print(repr(scTree)) + def testTripletEndsWhereTheNextNoteStarts(self): + from fractions import Fraction + from music21 import stream + triplet = note.Note('F#3', quarterLength=Fraction(2, 3)) + after = note.Note('E-3') + measure = stream.Measure() + measure.insert(Fraction(8, 3), triplet) + measure.insert(Fraction(10, 3), after) + part = stream.Part() + part.insert(25.0, measure) + + timespans = part.asTimespans() + verticality = list(timespans.iterateVerticalities())[-1] + self.assertEqual([ts.element for ts in verticality.startTimespans], [after]) + self.assertEqual(list(verticality.overlapTimespans), []) + # -------------------- diff --git a/music21/tree/spans.py b/music21/tree/spans.py index 36ed0e2731..4c34d892be 100644 --- a/music21/tree/spans.py +++ b/music21/tree/spans.py @@ -560,7 +560,6 @@ def pitches(self) -> tuple[pitch.Pitch, ...]: True >>> pts.pitches is c.pitches False - ''' return self.element.pitches @@ -623,7 +622,6 @@ def canMerge(self, other: Timespan) -> tuple[bool, str]: > >>> print(timespan_twoWrong.part) - ''' can, message = super().canMerge(other) if can is True: diff --git a/music21/tree/timespanTree.py b/music21/tree/timespanTree.py index 1a2187116d..fe4010ecde 100644 --- a/music21/tree/timespanTree.py +++ b/music21/tree/timespanTree.py @@ -5,7 +5,7 @@ # Authors: Joséphine Wolf Oberholtzer # Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2013-2024 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2013-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ----------------------------------------------------------------------------- ''' @@ -494,6 +494,8 @@ def iterateVerticalities( ''' + if self.rootNode is None: + return if reverse: offset = self.highestPosition() verticality = self.getVerticalityAt(offset) @@ -687,12 +689,13 @@ def toPartwiseTimespanTrees(self): Used by reduceChords. May disappear at any time without a deprecation notice. ''' + partwiseTimespans = {part: [] for part in self.allParts()} + for timespan in self: + partwiseTimespans[timespan.part].append(timespan) partwiseTimespanTrees = {} - for part in self.allParts(): + for part, timespans in partwiseTimespans.items(): partwiseTimespanTrees[part] = TimespanTree() - for timespan in self: - partwiseTimespanTree = partwiseTimespanTrees[timespan.part] - partwiseTimespanTree.insert(timespan) + partwiseTimespanTrees[part].insert(timespans) return partwiseTimespanTrees @staticmethod @@ -737,11 +740,33 @@ def unwrapVerticalities(verticalities): # PUBLIC PROPERTIES # def allParts(self): - parts = set() - for timespan in self: - parts.add(timespan.part) - parts = sorted(parts, key=lambda x: x.getInstrument().partId) - return parts + ''' + Returns a list of the parts that the timespans in this tree belong to. + + If the tree's source has parts (such as a Score), the parts are returned + in the order they appear in the source. Other parts come after them, + sorted by the partId of their instrument. + + Parts do not need a partId: + + >>> upper = stream.Part([instrument.Instrument(), note.Note('E4')]) + >>> lower = stream.Part([instrument.Instrument(), note.Note('C3')]) + >>> score = stream.Score([upper, lower]) + >>> scoreTree = score.asTimespans() + >>> scoreTree.allParts() == [upper, lower] + True + ''' + parts = {timespan.part for timespan in self} + source = self.source + sourceOrder = {} + if source is not None and hasattr(source, 'parts'): + sourceOrder = {id(part): i for i, part in enumerate(source.parts)} + + def sortKey(part): + partId = part.getInstrument().partId + return (sourceOrder.get(id(part), len(sourceOrder)), partId is None, str(partId)) + + return sorted(parts, key=sortKey) def maximumOverlap(self): ''' @@ -817,6 +842,22 @@ def testGetVerticalityAtWithKey(self): ps = v.pitchSet self.assertEqual(len(ps), 1) + def testIterateVerticalitiesEmptyTree(self): + emptyTree = TimespanTree() + self.assertEqual(list(emptyTree.iterateVerticalities()), []) + self.assertEqual(list(emptyTree.iterateVerticalities(reverse=True)), []) + self.assertEqual(list(emptyTree.iterateVerticalitiesNwise()), []) + + def testToPartwiseTimespanTrees(self): + from music21 import corpus + from music21 import note + score = corpus.parse('bwv66.6') + scoreTree = score.asTimespans(classList=(note.Note,)) + partwise = scoreTree.toPartwiseTimespanTrees() + self.assertEqual(list(partwise), list(score.parts)) + for part, subtree in partwise.items(): + self.assertEqual(list(subtree), [ts for ts in scoreTree if ts.part is part]) + def testTimespanTree(self): for attempt in range(100): starts = list(range(20)) diff --git a/music21/tree/trees.py b/music21/tree/trees.py index 2cae3ecf14..c9d5b4f304 100644 --- a/music21/tree/trees.py +++ b/music21/tree/trees.py @@ -400,7 +400,6 @@ def __iter__(self): ... - ''' for node in self.iterNodes(): yield node.payload @@ -736,7 +735,6 @@ def _getPositionsFromElements(self, elements): In an ElementTree, this will be a list of .sortTuple() calls. In an OffsetTree, this will be a list of .offset calls - ''' return [self.getPositionFromElementUnsafe(el) for el in elements] @@ -1077,7 +1075,6 @@ def __iter__(self): ... - ''' for node in self.iterNodes(): for el in node.payload: @@ -1381,7 +1378,6 @@ def overlapTimePoints(self, includeStopPoints=False, returnVerticality=False): >>> otp = scoreOffsetTree.overlapTimePoints(returnVerticality=True) >>> otp[0] {0.5: } - ''' checkPoints = self.allOffsets() if includeStopPoints is False else self.allTimePoints() overlaps = [] diff --git a/music21/tree/verticality.py b/music21/tree/verticality.py index b37257bb66..07d6f1f920 100644 --- a/music21/tree/verticality.py +++ b/music21/tree/verticality.py @@ -1051,7 +1051,7 @@ def getAllVoiceLeadingQuartets( if not hasattr(pairedMotion[0][0], 'pitches'): continue # not a PitchedTimespan - if includeNoMotion is False: + if not includeNoMotion: if (pairedMotion[0][0].pitches == pairedMotion[0][1].pitches and pairedMotion[1][0].pitches == pairedMotion[1][1].pitches): continue @@ -1064,13 +1064,13 @@ def getAllVoiceLeadingQuartets( if ((verticalityStreamParts[pp[0]] == thisQuartetTopPart or verticalityStreamParts[pp[0]] == thisQuartetBottomPart) and (verticalityStreamParts[pp[1]] == thisQuartetTopPart - or verticalityStreamParts[pp[1]] == thisQuartetBottomPart)): + or verticalityStreamParts[pp[1]] == thisQuartetBottomPart)): isAppropriate = True break if not isAppropriate: continue - if returnObjects is False: + if not returnObjects: filteredList.append(pairedMotion) else: n11 = pairedMotion[0][0].element @@ -1160,15 +1160,15 @@ def getPairedMotion( if previousTs is None or not isinstance(previousTs, spans.PitchedTimespan): continue # first not in piece in this part - if includeRests is False: + if not includeRests: if previousTs not in stopTss: continue - if includeOblique is False and startingTs.pitches == previousTs.pitches: + if not includeOblique and startingTs.pitches == previousTs.pitches: continue tsTuple = (previousTs, startingTs) allPairedMotions.append(tsTuple) - if includeOblique is True: + if includeOblique: for overlapTs in overlapTss: if not isinstance(overlapTs, spans.PitchedTimespan): continue diff --git a/music21/variant.py b/music21/variant.py index cf1f6ae392..77f997c8cf 100644 --- a/music21/variant.py +++ b/music21/variant.py @@ -271,19 +271,9 @@ def containedSite(self): ''' return self._stream - def _getReplacementQuarterLength(self): - if self._replacementQuarterLength is None: - return self._stream.duration.quarterLength - else: - return self._replacementQuarterLength - - def _setReplacementQuarterLength(self, value): - self._replacementQuarterLength = value - - replacementQuarterLength = property( - _getReplacementQuarterLength, - _setReplacementQuarterLength, - doc=''' + @property + def replacementQuarterLength(self): + ''' Set or Return the quarterLength in the main stream which this variant object replaces in the variant version of the stream. If replacementQuarterLength is not set, it is assumed to be the same length as the variant. If it is set to 0, @@ -292,23 +282,19 @@ def _setReplacementQuarterLength(self, value): itself. * New in v10.3: renamed from ``replacementDuration``. - ''') - - def _getReplacementDuration(self): - return self.replacementQuarterLength + ''' + if self._replacementQuarterLength is None: + return self._stream.duration.quarterLength + else: + return self._replacementQuarterLength - def _setReplacementDuration(self, value): - warnings.warn( - "'replacementDuration' is deprecated as of v11 and will be removed in v12; " - + 'use the synonym replacementQuarterLength instead.', - exceptions21.Music21DeprecationWarning, - stacklevel=2) - self.replacementQuarterLength = value + @replacementQuarterLength.setter + def replacementQuarterLength(self, value): + self._replacementQuarterLength = value - replacementDuration = property( - _getReplacementDuration, - _setReplacementDuration, - doc=''' + @property + def replacementDuration(self): + ''' Synonym for :attr:`replacementQuarterLength`. .. note:: @@ -317,7 +303,17 @@ def _setReplacementDuration(self, value): avoids referring to an offset/quarterLength value as a "Duration"). Setting it raises a deprecation warning, and it will be removed in v12. Use :attr:`replacementQuarterLength` instead. - ''') + ''' + return self.replacementQuarterLength + + @replacementDuration.setter + def replacementDuration(self, value): + warnings.warn( + "'replacementDuration' is deprecated as of v11 and will be removed in v12; " + + 'use the synonym replacementQuarterLength instead.', + exceptions21.Music21DeprecationWarning, + stacklevel=2) + self.replacementQuarterLength = value @property def lengthType(self): @@ -473,7 +469,9 @@ def replacedElements(self, contextStream=None, classList=None, classes.append(e.classes[0]) if classList is not None: classes.extend(classList) - returnStream = contextStream.getElementsByOffset(vStart, vEnd, + returnStream = contextStream.getElementsByOffset( + vStart, + vEnd, includeEndBoundary=False, mustFinishInSpan=False, mustBeginInSpan=True, @@ -487,12 +485,16 @@ def replacedElements(self, contextStream=None, classList=None, classes.append(e.classes[0]) if classList is not None: classes.extend(classList) - returnPart1 = contextStream.getElementsByOffset(vStart, vMiddle, + returnPart1 = contextStream.getElementsByOffset( + vStart, + vMiddle, includeEndBoundary=False, mustFinishInSpan=False, mustBeginInSpan=True, classList=classes).stream() - returnPart2 = contextStream.getElementsByOffset(vMiddle, vEnd, + returnPart2 = contextStream.getElementsByOffset( + vMiddle, + vEnd, includeEndBoundary=False, mustFinishInSpan=False, mustBeginInSpan=True).stream() @@ -773,7 +775,7 @@ def mergeVariantScores(aScore, vScore, variantName='variant', *, inPlace=False): raise VariantException( 'These scores do not have the same number of parts and cannot be merged.') - if inPlace is True: + if inPlace: returnObj = aScore else: returnObj = aScore.coreCopyAsDerivation('mergeVariantScores') @@ -781,7 +783,7 @@ def mergeVariantScores(aScore, vScore, variantName='variant', *, inPlace=False): for returnPart, vPart in zip(returnObj.parts, vScore.parts): mergeVariantMeasureStreams(returnPart, vPart, variantName, inPlace=True) - if inPlace is False: + if not inPlace: return returnObj @@ -933,7 +935,7 @@ def mergeVariantMeasureStreams(streamX, streamY, variantName='variant', *, inPla >>> parisStream[variant.Variant][2].replacementQuarterLength 8.0 ''' - if inPlace is True: + if inPlace: returnObj = streamX else: returnObj = streamX.coreCopyAsDerivation('mergeVariantMeasureStreams') @@ -972,7 +974,7 @@ def mergeVariantMeasureStreams(streamX, streamY, variantName='variant', *, inPla addVariant(returnObj, startOffset, yRegion, variantName=variantName, replacementQuarterLength=replacementQuarterLength) - if inPlace is True: + if inPlace: return else: return returnObj @@ -1182,7 +1184,7 @@ def mergeVariantsEqualDuration(streams, variantNames, *, inPlace=False): which are of different lengths ''' - if inPlace is True: + if inPlace: returnObj = streams[0] else: returnObj = streams[0].coreCopyAsDerivation('mergeVariantsEqualDuration') @@ -1331,9 +1333,8 @@ def mergePartAsOssia(mainPart, ossiaPart, ossiaName, {0.0} {2.0} ... - ''' - if inPlace is True: + if inPlace: returnObj = mainPart else: returnObj = mainPart.coreCopyAsDerivation('mergePartAsOssia') @@ -1376,7 +1377,7 @@ def mergePartAsOssia(mainPart, ossiaPart, ossiaName, variantName=ossiaName, variantGroups=None, replacementQuarterLength=None) - if inPlace is True: + if inPlace: return else: return returnObj @@ -1592,13 +1593,12 @@ def refineVariant(s, sVariant, *, inPlace=False): {1.0} {2.0} {3.0} - ''' # stream that will be returned if sVariant not in s.getElementsByClass(Variant): raise VariantException(f'{sVariant} not found in stream {s}.') - if inPlace is True: + if inPlace: returnObject = s variantRegion = sVariant else: @@ -1686,10 +1686,9 @@ def _mergeVariantMeasureStreamsCarefully(streamX, streamY, variantName, *, inPla ''' There seem to be some problems with this function, and it isn't well tested. It is not recommended to use it at this time. - ''' # stream that will be returned - if inPlace is True: + if inPlace: returnObject = streamX variantObject = streamY else: @@ -1867,7 +1866,7 @@ def _getBestListAndScore(streamX, streamY, badnessDict, listDict, # Check the added bar case: kList, kBadness = _getBestListAndScore(streamX, streamY, badnessDict, listDict, - isNone=True, streamXIndex=streamXIndex, streamYIndex=streamYIndex + 1) + isNone=True, streamXIndex=streamXIndex, streamYIndex=streamYIndex + 1) if kList is None: kList = [] if kList: @@ -1926,7 +1925,6 @@ def _diffScore(measureX, measureY): >>> m2.append([note.Note('e'), note.Note('f'), note.Note('g#'), note.Note('a')]) >>> variant._diffScore(m1, m2) 0.4... - ''' hashes = getMeasureHashes([measureX, measureY]) if hashes[0] == hashes[1]: @@ -1961,7 +1959,6 @@ def _getRegionsFromStreams(streamX, streamY): ('replace', 1, 3, 1, 2), ('equal', 3, 6, 2, 5), ('insert', 6, 6, 5, 6)] - ''' hashesX = getMeasureHashes(streamX) hashesY = getMeasureHashes(streamY) @@ -2057,7 +2054,7 @@ def _mergeVariants(streamA, streamB, *, variantName=None, inPlace=False): '_mergeVariants cannot merge streams which are of different lengths' ) - if inPlace is True: + if inPlace: returnObj = streamA else: returnObj = copy.deepcopy(streamA) @@ -2138,7 +2135,7 @@ def _mergeVariants(streamA, streamB, *, variantName=None, inPlace=False): inVariant = False noteBuffer = [] - if inPlace is True: + if inPlace: return None else: return returnObj @@ -2174,7 +2171,6 @@ def _generateVariant(noteList, originStream, start, variantName=None): >>> v.groups ['paris'] - ''' returnVariant = Variant() for n in noteList: @@ -2234,10 +2230,9 @@ def makeAllVariantsReplacements(streamWithVariants, (4.0, 'replacement', 4.0, 4.0) (12.0, 'elongation', 4.0, 12.0) (20.0, 'deletion', 8.0, 4.0) - ''' - if inPlace is True: + if inPlace: returnStream = streamWithVariants else: returnStream = copy.deepcopy(streamWithVariants) @@ -2249,7 +2244,7 @@ def makeAllVariantsReplacements(streamWithVariants, _doVariantFixingOnStream(returnStream, variantNames=variantNames) - if inPlace is True: + if inPlace: return else: return returnStream @@ -2437,23 +2432,25 @@ def _getNextElements(s, v, numberOfElements=1): # Get next element in s after v which is of type vClass if lengthType == 'elongation': variantOffset = v.getOffsetBySite(s) - potentialTargets = s.getElementsByOffset(variantOffset, - offsetEnd=s.highestTime, - includeEndBoundary=True, - mustFinishInSpan=False, - mustBeginInSpan=True, - classList=[vClass]) + potentialTargets = s.getElementsByOffset( + variantOffset, + offsetEnd=s.highestTime, + includeEndBoundary=True, + mustFinishInSpan=False, + mustBeginInSpan=True, + classList=[vClass]) returnElement = potentialTargets.first() else: replacementDuration = v.replacementQuarterLength variantOffset = v.getOffsetBySite(s) - potentialTargets = s.getElementsByOffset(variantOffset + replacementDuration, - offsetEnd=s.highestTime, - includeEndBoundary=True, - mustFinishInSpan=False, - mustBeginInSpan=True, - classList=[vClass]) + potentialTargets = s.getElementsByOffset( + variantOffset + replacementDuration, + offsetEnd=s.highestTime, + includeEndBoundary=True, + mustFinishInSpan=False, + mustBeginInSpan=True, + classList=[vClass]) returnElement = potentialTargets.first() @@ -2534,18 +2531,18 @@ def makeVariantBlocks(s): ''' Unknown and undocumented. Used only in lily/translate -- for musicdiff. ''' - from music21 import variant - variantsToBeDone = s.getElementsByClass(variant.Variant) + variantsToBeDone = s.getElementsByClass(Variant) for v in variantsToBeDone: startOffset = s.elementOffset(v) endOffset = v.replacementQuarterLength + startOffset - conflictingVariants = s.getElementsByOffset(offsetStart=startOffset, - offsetEnd=endOffset, - includeEndBoundary=False, - mustFinishInSpan=False, - mustBeginInSpan=True, - classList=[variant.Variant]) + conflictingVariants = s.getElementsByOffset( + offsetStart=startOffset, + offsetEnd=endOffset, + includeEndBoundary=False, + mustFinishInSpan=False, + mustBeginInSpan=True, + classList=[Variant]) for cV in conflictingVariants: oldReplacementDuration = cV.replacementQuarterLength if s.elementOffset(cV) == startOffset: @@ -2637,14 +2634,12 @@ def testVariantClassA(self): self.assertTrue(v1.getElementsByClass(stream.Measure)) def testDeepCopyVariantA(self): - from music21 import variant - s = stream.Stream() s.repeatAppend(note.Note('G4'), 8) vn1 = note.Note('F#4') vn2 = note.Note('A-4') - v1 = variant.Variant() + v1 = Variant() v1.insert(0, vn1) v1.insert(0, vn2) v1Copy = copy.deepcopy(v1) @@ -2657,28 +2652,26 @@ def testDeepCopyVariantA(self): # normal in-place variant functionality s.insert(5, v1) self.assertEqual(self.pitchOut(s.pitches), - '[G4, G4, G4, G4, G4, G4, G4, G4]') + '[G4, G4, G4, G4, G4, G4, G4, G4]') sv = s.activateVariants(inPlace=False) self.assertEqual(self.pitchOut(sv.pitches), - '[G4, G4, G4, G4, G4, F#4, A-4, G4, G4]') + '[G4, G4, G4, G4, G4, F#4, A-4, G4, G4]') # test functionality on a deepcopy sCopy = copy.deepcopy(s) - self.assertEqual(len(sCopy.getElementsByClass(variant.Variant)), 1) + self.assertEqual(len(sCopy.getElementsByClass(Variant)), 1) self.assertEqual(self.pitchOut(sCopy.pitches), - '[G4, G4, G4, G4, G4, G4, G4, G4]') + '[G4, G4, G4, G4, G4, G4, G4, G4]') sCopy.activateVariants(inPlace=True) self.assertEqual(self.pitchOut(sCopy.pitches), - '[G4, G4, G4, G4, G4, F#4, A-4, G4, G4]') + '[G4, G4, G4, G4, G4, F#4, A-4, G4, G4]') def testDeepCopyVariantB(self): - from music21 import variant - s = stream.Stream() s.repeatAppend(note.Note('G4'), 8) vn1 = note.Note('F#4') vn2 = note.Note('A-4') - v1 = variant.Variant() + v1 = Variant() v1.insert(0, vn1) v1.insert(0, vn2) s.insert(5, v1) @@ -2687,11 +2680,11 @@ def testDeepCopyVariantB(self): sCopy = copy.deepcopy(s) sCopy.activateVariants(inPlace=True) self.assertEqual(self.pitchOut(sCopy.pitches), - '[G4, G4, G4, G4, G4, F#4, A-4, G4, G4]') + '[G4, G4, G4, G4, G4, F#4, A-4, G4, G4]') # can transpose the note in place sCopy.notes[5].transpose(12, inPlace=True) self.assertEqual(self.pitchOut(sCopy.pitches), - '[G4, G4, G4, G4, G4, F#5, A-4, G4, G4]') + '[G4, G4, G4, G4, G4, F#5, A-4, G4, G4]') # however, if the Variant deepcopy still references the original # notes it had, then when we try to activate the variant in the @@ -2699,7 +2692,7 @@ def testDeepCopyVariantB(self): s.activateVariants(inPlace=True) self.assertEqual(self.pitchOut(s.pitches), - '[G4, G4, G4, G4, G4, F#4, A-4, G4, G4]') + '[G4, G4, G4, G4, G4, F#4, A-4, G4, G4]') class TestExternal(unittest.TestCase): diff --git a/music21/voiceLeading.py b/music21/voiceLeading.py index b6a23659bb..827f3be2f7 100644 --- a/music21/voiceLeading.py +++ b/music21/voiceLeading.py @@ -102,7 +102,7 @@ class VoiceLeadingQuartet(base.Music21Object): * Changed in v11: all four notes are now required arguments and can no longer be set later; they are always present as :class:`~music21.note.Note` - objects. `analyticKey` renamed to `key`. `vIntervals` and ``hIntervals` are now tuples. + objects. `analyticKey` renamed to `key`. `vIntervals` and `hIntervals` are now tuples. ''' _DOC_ATTR: dict[str, str] = { @@ -577,15 +577,15 @@ def parallelMotion( if isinstance(requiredInterval, str): requiredInterval = interval.Interval(requiredInterval) intervalsAreValid = (vInt0.semiSimpleName - == requiredInterval.semiSimpleName + == requiredInterval.semiSimpleName and vInt1.semiSimpleName - == requiredInterval.semiSimpleName) + == requiredInterval.semiSimpleName) elif isinstance(requiredInterval, (interval.Interval, interval.DiatonicInterval)): intervalsAreValid = (vInt0.semiSimpleName - == requiredInterval.semiSimpleName + == requiredInterval.semiSimpleName and vInt1.semiSimpleName - == requiredInterval.semiSimpleName) + == requiredInterval.semiSimpleName) return intervalsAreValid @@ -1161,7 +1161,7 @@ def isProperResolution(self) -> bool: if keyScale and n2degree != 3: return False return (self.outwardContraryMotion() - and secondHarmony == 6) + and secondHarmony == 6) elif firstHarmony == 'd5': if keyScale and n1degree != 7: @@ -1169,7 +1169,7 @@ def isProperResolution(self) -> bool: if keyScale and n2degree != 1: return False return (self.inwardContraryMotion() - and secondHarmony == 3) + and secondHarmony == 3) elif firstHarmony == 'm7': if keyScale and n1degree != 5: @@ -1291,9 +1291,9 @@ def modalOpening(self) -> bool: openingIntervals = ('P1', 'P5') openingFunctions = ('I', 'V') return ((v0.simpleName in openingIntervals - or v1.simpleName in openingIntervals) - and (r1[0].upper() in openingFunctions if r1 is not False else False - or r2[0].upper() in openingFunctions if r2 is not False else False)) + or v1.simpleName in openingIntervals) + and (r1[0].upper() in openingFunctions if r1 is not False else False + or r2[0].upper() in openingFunctions if r2 is not False else False)) @common.decorators.deprecated( 'June 2026', 'v12', 'Use `not vlq.modalOpening()` instead.' @@ -1367,10 +1367,10 @@ def clausulaVera(self) -> bool: tonicName = self.key.tonic.name hIntervalNames = {self.hIntervals[0].name, self.hIntervals[1].name} return (hIntervalNames == {'m2', 'M2'} - and self.contraryMotion() - and self.vIntervals[1].name in ('P1', 'P8') - and self.v1n2.name == tonicName - and self.v2n2.name == tonicName) + and self.contraryMotion() + and self.vIntervals[1].name in ('P1', 'P8') + and self.v1n2.name == tonicName + and self.v2n2.name == tonicName) @common.decorators.deprecated( 'June 2026', 'v12', 'Use `not vlq.clausulaVera()` instead.' @@ -1684,7 +1684,6 @@ def getObjectsByClass(self, classFilterList, partNums=None): , ] >>> vs1.getObjectsByClass('Note', [1, 2]) [, , ] - ''' if not common.isIterable(classFilterList): classFilterList = [classFilterList] @@ -1801,42 +1800,44 @@ def getVerticalityOffset(self, *, leftAlign=True): else: return sorted(self.objects, key=lambda m21Obj: m21Obj.offset)[-1].offset - def _setLyric(self, value): - newList = sorted(self.objects, key=lambda x: x.offset, reverse=True) - newList[0].lyric = value - - def _getLyric(self): - newList = sorted(self.objects, key=lambda x: x.offset, reverse=True) - return newList[0].lyric - - lyric = property(_getLyric, _setLyric, doc=''' + @property + def lyric(self): + ''' Sets each object on the Verticality to have the passed in lyric. >>> h = voiceLeading.Verticality({1: note.Note('C'), 2: harmony.ChordSymbol('C')}) >>> h.lyric = 'Verticality 1' >>> h.getStream().flatten().getElementsByClass(note.Note).first().lyric 'Verticality 1' - ''') + ''' + newList = sorted(self.objects, key=lambda x: x.offset, reverse=True) + return newList[0].lyric + + @lyric.setter + def lyric(self, value): + newList = sorted(self.objects, key=lambda x: x.offset, reverse=True) + newList[0].lyric = value def _reprInternal(self): return f'contentDict={self.contentDict}' - def _setColor(self, color): - self.style.color = color - for obj in self.objects: - obj.style.color = color - - def _getColor(self): - return self.style.color - - color = property(_getColor, _setColor, doc=''' + @property + def color(self): + ''' Sets the color of each element in the Verticality. >>> vs1 = voiceLeading.Verticality({1:note.Note('C'), 2:harmony.ChordSymbol('D')}) >>> vs1.color = 'blue' >>> [(x, x.style.color) for x in vs1.objects] [(, 'blue'), (, 'blue')] - ''') + ''' + return self.style.color + + @color.setter + def color(self, color): + self.style.color = color + for obj in self.objects: + obj.style.color = color class VerticalityNTuplet(base.Music21Object): @@ -1881,8 +1882,8 @@ def _calcTNLS(self): Calculates the three note linear segments if only three Verticalities provided. ''' for partNum in range(min(len(self.verticalities[0].getObjectsByClass(note.Note)), - len(self.verticalities[1].getObjectsByClass(note.Note)), - len(self.verticalities[2].getObjectsByClass(note.Note))) + len(self.verticalities[1].getObjectsByClass(note.Note)), + len(self.verticalities[2].getObjectsByClass(note.Note))) ): self.tnlsDict[partNum] = ThreeNoteLinearSegment( [ @@ -1914,7 +1915,6 @@ def hasPassingTone(self, partNumToIdentify, unaccentedOnly=False): True >>> vt.hasPassingTone(1) False - ''' if partNumToIdentify in self.tnlsDict: ret = self.tnlsDict[partNumToIdentify].couldBePassingTone() @@ -2010,18 +2010,9 @@ def noteList(self): return self._noteList[:] - def _getMelodicIntervals(self): - tempListOne = self.noteList[:-1] - tempListTwo = self.noteList[1:] - melodicIntervalList = [] - for n1, n2 in zip(tempListOne, tempListTwo, strict=True): - if n1 and n2: - melodicIntervalList.append(interval.Interval(n1, n2)) - else: - melodicIntervalList.append(None) - return melodicIntervalList - - melodicIntervals = property(_getMelodicIntervals, doc=''' + @property + def melodicIntervals(self): + ''' Calculates the melodic intervals and returns them as a list, with the interval at 0 being the interval between the first and second note. @@ -2031,7 +2022,16 @@ def _getMelodicIntervals(self): [, , ] - ''') + ''' + tempListOne = self.noteList[:-1] + tempListTwo = self.noteList[1:] + melodicIntervalList = [] + for n1, n2 in zip(tempListOne, tempListTwo, strict=True): + if n1 and n2: + melodicIntervalList.append(interval.Interval(n1, n2)) + else: + melodicIntervalList.append(None) + return melodicIntervalList class ThreeNoteLinearSegmentException(exceptions21.Music21Exception): @@ -2078,9 +2078,8 @@ class ThreeNoteLinearSegment(NNoteLinearSegment): >>> ex2 = voiceLeading.ThreeNoteLinearSegment('a', 'b', 'c') >>> ex2.n1 - >>> defaults.pitchOctave + >>> ex2.n1.octave 4 - ''' _DOC_ORDER = ['couldBePassingTone', 'couldBeDiatonicPassingTone', @@ -2095,22 +2094,37 @@ def __init__(self, noteListOrN1=None, n2=None, n3=None, **keywords): else: super().__init__([noteListOrN1, n2, n3], **keywords) - def _getN1(self): + @property + def n1(self): + ''' + Get or set the first note (left-most) in the segment. + ''' return self.noteList[0] - def _setN1(self, value): + @n1.setter + def n1(self, value): self.noteList[0] = self._correctNoteInput(value) - def _getN2(self): + @property + def n2(self): + ''' + Get or set the middle note in the segment. + ''' return self.noteList[1] - def _setN2(self, value): + @n2.setter + def n2(self, value): self.noteList[1] = self._correctNoteInput(value) - def _getN3(self): + @property + def n3(self): + ''' + Get or set the last note (right-most) in the segment. + ''' return self.noteList[2] - def _setN3(self, value): + @n3.setter + def n3(self, value): self.noteList[2] = self._correctNoteInput(value) def _correctNoteInput(self, value): @@ -2130,53 +2144,44 @@ def _correctNoteInput(self, value): ) from e - n1 = property(_getN1, _setN1, doc=''' - Get or set the first note (left-most) in the segment. - ''') - n2 = property(_getN2, _setN2, doc=''' - Get or set the middle note in the segment. - ''') - n3 = property(_getN3, _setN3, doc=''' - Get or set the last note (right-most) in the segment. - ''') - - def _getILeftToRight(self): - if self.n1 and self.n3: - return interval.Interval(self.n1, self.n3) - else: - return None - - def _getILeft(self): - return self.melodicIntervals[0] - - def _getIRight(self): - return self.melodicIntervals[1] - - iLeftToRight = property(_getILeftToRight, doc=''' + @property + def iLeftToRight(self): + ''' Get the interval between the left-most note and the right-most note (read-only property). >>> tnls = voiceLeading.ThreeNoteLinearSegment('C', 'E', 'G') >>> tnls.iLeftToRight - ''') + ''' + if self.n1 and self.n3: + return interval.Interval(self.n1, self.n3) + else: + return None - iLeft = property(_getILeft, doc=''' + @property + def iLeft(self): + ''' Get the interval between the left-most note and the middle note (read-only property). >>> tnls = voiceLeading.ThreeNoteLinearSegment('A', 'B', 'G') >>> tnls.iLeft - ''') - iRight = property(_getIRight, doc=''' + ''' + return self.melodicIntervals[0] + + @property + def iRight(self): + ''' Get the interval between the middle note and the right-most note (read-only property). >>> tnls = voiceLeading.ThreeNoteLinearSegment('A', 'B', 'G') >>> tnls.iRight - ''') + ''' + return self.melodicIntervals[1] def _reprInternal(self): return f'n1={self.n1} n2={self.n2} n3={self.n3}' @@ -2211,7 +2216,7 @@ def couldBePassingTone(self) -> bool: >>> voiceLeading.ThreeNoteLinearSegment('B##3', 'C4', 'D--4').couldBePassingTone() False - If no octave is given then ._defaultOctave is used. This is generally octave 4. + If no octave is given then octave 4 is used: >>> voiceLeading.ThreeNoteLinearSegment('C', 'D', 'E').couldBePassingTone() True @@ -2220,6 +2225,14 @@ def couldBePassingTone(self) -> bool: >>> voiceLeading.ThreeNoteLinearSegment('C5', 'D', 'E').couldBePassingTone() False + But I wouldn't skip the octave, because otherwise this will always seem baffling: + + >>> voiceLeading.ThreeNoteLinearSegment('G', 'A', 'B').couldBePassingTone() + True + >>> voiceLeading.ThreeNoteLinearSegment('A', 'B', 'C').couldBePassingTone() + False + + Method returns True if either a chromatic passing tone or a diatonic passing tone is identified. Spelling of the pitch does matter! @@ -2334,10 +2347,10 @@ def couldBeDiatonicNeighborTone(self) -> bool: ''' return (self._isComplete() - and self.n1.nameWithOctave == self.n3.nameWithOctave - and self.iLeft.chromatic.undirected == 2 - and self.iRight.chromatic.undirected == 2 - and self.iLeft.direction * self.iRight.direction == -1) + and self.n1.nameWithOctave == self.n3.nameWithOctave + and self.iLeft.chromatic.undirected == 2 + and self.iRight.chromatic.undirected == 2 + and self.iLeft.direction * self.iRight.direction == -1) def couldBeChromaticNeighborTone(self) -> bool: @@ -2355,10 +2368,10 @@ def couldBeChromaticNeighborTone(self) -> bool: False ''' return (self._isComplete() - and (self.n1.nameWithOctave == self.n3.nameWithOctave - and self.iLeft.isChromaticStep - and self.iRight.isChromaticStep - and (self.iLeft.direction * self.iRight.direction == -1))) + and (self.n1.nameWithOctave == self.n3.nameWithOctave + and self.iLeft.isChromaticStep + and self.iRight.isChromaticStep + and (self.iLeft.direction * self.iRight.direction == -1))) # Below: beginnings of an implementation for any object segments, diff --git a/music21/volpiano.py b/music21/volpiano.py index 58f8b30e95..a80a49fd84 100644 --- a/music21/volpiano.py +++ b/music21/volpiano.py @@ -4,7 +4,7 @@ # # Authors: Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2017 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2017-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -19,6 +19,7 @@ from __future__ import annotations import enum +import unittest from music21 import bar from music21 import base @@ -192,7 +193,6 @@ def toPart(volpianoText, *, breaksToLayout=False): p = stream.Part() m = stream.Measure() - currentMeasure = m currentNeumeSpanner = None noteThatWouldGoInSpanner = None lastClef = clef.TrebleClef() @@ -217,14 +217,14 @@ def toPart(volpianoText, *, breaksToLayout=False): else: breakToken = breakClass() # pylint: disable=not-callable - currentMeasure.append(breakToken) + m.append(breakToken) continuousNumberOfBreakTokens = 0 if token == '-': noteThatWouldGoInSpanner = None if currentNeumeSpanner: - currentMeasure.append(currentNeumeSpanner) + m.append(currentNeumeSpanner) currentNeumeSpanner = None continue @@ -295,7 +295,7 @@ def toPart(volpianoText, *, breaksToLayout=False): if continuousNumberOfBreakTokens > 0: breakClass = classByNumBreakTokens[continuousNumberOfBreakTokens] breakToken = breakClass() - currentMeasure.append(breakToken) + m.append(breakToken) if m: p.append(m) @@ -368,9 +368,9 @@ def setAccFromPitch(dist, setNatural=False): elClasses = el.classes if 'Clef' in elClasses: lastClef = el - if 'TrebleClef' in elClasses: + if isinstance(el, (clef.TrebleClef, clef.Treble8vbClef, clef.Treble8vaClef)): ap('1---') - elif 'BassClef' in elClasses: + elif isinstance(el, (clef.BassClef, clef.Bass8vbClef, clef.Bass8vaClef)): ap('2---') else: error(el, ErrorLevel.WARN) @@ -392,8 +392,8 @@ def setAccFromPitch(dist, setNatural=False): continue if n.notehead == 'x' or (n.hasEditorialInformation - and 'liquescence' in n.editorial - and n.editorial.liquescence): + and 'liquescence' in n.editorial + and n.editorial.liquescence): tokenName = liquescentPitches[indexInPitchString] else: tokenName = normalPitches[indexInPitchString] @@ -456,8 +456,29 @@ def setAccFromPitch(dist, setNatural=False): return ''.join(volpianoTokens) +class Test(unittest.TestCase): + def testBreaksAndNeumesAfterBarline(self): + part = toPart('1---e-3-ef-g-7-e-4-gh--j-77') + self.assertEqual( + [[type(el).__name__ for el in m.getElementsByClass([Neume, LineBreak, PageBreak])] + for m in part.getElementsByClass(stream.Measure)], + [[], ['Neume', 'LineBreak'], ['Neume', 'PageBreak']]) + self.assertEqual(fromStream(part), '1---e----3ef-g7---e----4gh-j77---') + + def testOctaveClefsWritePlainClef(self): + for clefClass, volpianoClef in ( + (clef.Treble8vbClef, '1---'), + (clef.Treble8vaClef, '1---'), + (clef.Bass8vbClef, '2---'), + (clef.Bass8vaClef, '2---'), + ): + self.assertTrue(fromStream(stream.Stream([clefClass()])).startswith(volpianoClef)) + self.assertEqual(fromStream(stream.Stream([clef.Bass8vbClef(), note.Note('G1')])), + fromStream(stream.Stream([clef.BassClef(), note.Note('G2')]))) + + if __name__ == '__main__': import music21 # allow things like "fromStream" to be called in doctests as "fromStream" # and not just "volpiano.fromStream" - music21.mainTest('importPlusRelative') + music21.mainTest(Test, 'importPlusRelative') diff --git a/music21/volume.py b/music21/volume.py index 1b798c7efa..85f81ba615 100644 --- a/music21/volume.py +++ b/music21/volume.py @@ -231,7 +231,6 @@ def getRealized( >>> s.notes[7].volume.velocityIsRelative = False >>> s.notes[7].volume.getRealized() 0.1574803... - ''' # velocityIsRelative might be best set at import. e.g., from MIDI, # velocityIsRelative is False, but in other applications, it may not @@ -508,10 +507,9 @@ class Test(unittest.TestCase): def testBasic(self): import gc - from music21 import volume n1 = note.Note('G#4') - v = volume.Volume(client=n1) + v = Volume(client=n1) self.assertEqual(v.client, n1) del n1 gc.collect() @@ -521,7 +519,6 @@ def testBasic(self): def testGetContextSearchA(self): from music21 import stream - from music21 import volume s = stream.Stream() d1 = dynamics.Dynamic('mf') @@ -530,7 +527,7 @@ def testGetContextSearchA(self): s.insert(2, d2) n1 = note.Note('g') - v1 = volume.Volume(client=n1) + v1 = Volume(client=n1) s.insert(4, n1) # can get dynamics from volume object @@ -556,10 +553,9 @@ def testGetContextSearchB(self): def testDeepCopyA(self): import copy - from music21 import volume n1 = note.Note() - v1 = volume.Volume() + v1 = Volume() v1.velocity = 111 v1.client = n1 @@ -572,9 +568,7 @@ def testDeepCopyA(self): def testGetRealizedA(self): - from music21 import volume - - v1 = volume.Volume(velocity=64) + v1 = Volume(velocity=64) self.assertEqual(v1.getRealizedStr(), '0.5') d1 = dynamics.Dynamic('p') @@ -589,7 +583,7 @@ def testGetRealizedA(self): # if vel is at max, can scale down with a dynamic - v1 = volume.Volume(velocity=127) + v1 = Volume(velocity=127) d1 = dynamics.Dynamic('fff') self.assertEqual(v1.getRealizedStr(useDynamicContext=d1), '1.0') @@ -620,7 +614,6 @@ def testGetRealizedB(self): def testRealizeVolumeA(self): from music21 import stream - from music21 import volume s = stream.Stream() s.repeatAppend(note.Note('g3'), 16) @@ -637,7 +630,7 @@ def testRealizeVolumeA(self): self.assertEqual(match, ['0.71'] * 16) # calling realize will set all to new cached values - volume.realizeVolume(s) + realizeVolume(s) match = [n.volume.cachedRealizedStr for n in s.notes] self.assertEqual(match, ['0.35', '0.35', '0.5', '0.5', '0.64', '0.64', '0.99', '0.99', @@ -666,7 +659,7 @@ def testRealizeVolumeA(self): self.assertEqual(match, [None] * 16) # can set velocity with realized values - volume.realizeVolume(s, setAbsoluteVelocity=True) + realizeVolume(s, setAbsoluteVelocity=True) match = [n.volume.velocity for n in s.notes] self.assertEqual(match, [45, 45, 63, 63, 81, 81, 126, 126, 99, 99, 127, 127, 27, 27, 99, 99]) diff --git a/pyproject.toml b/pyproject.toml index dd46d7cca8..5454841df7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Topic :: Artistic Software", "Topic :: Multimedia :: Sound/Audio", "Topic :: Multimedia :: Sound/Audio :: Conversion", @@ -69,12 +70,12 @@ dev = [ 'hatchling', 'ipython', 'jupyter', - 'mypy>=1.16.0', + 'mypy==2.4.0', 'nbconvert', 'nbval', - 'pylint>=3.2.0', + 'pylint==4.1.3', 'pytest', - 'ruff', + 'ruff==0.17.1', 'sphinx', 'twine', 'types-requests', @@ -96,13 +97,19 @@ default-groups = ["dev"] [tool.hatch.version] path = "music21/_version.py" +[tool.hatch.build] +# hatchling reads only this repo's .gitignore, not a global one +exclude = [ + ".*", +] + [tool.hatch.build.targets.sdist] include = [ "music21", ] [tool.mypy] -allow_redefinition = true +allow_redefinition_old = true warn_no_return = false [tool.ruff] diff --git a/scripts/README.txt b/scripts/README.txt new file mode 100644 index 0000000000..d73637e3fe --- /dev/null +++ b/scripts/README.txt @@ -0,0 +1,2 @@ +Scripts for building music21 etc. go here, but not anything for running music21. +This folder is not included in uv/pip installations. diff --git a/scripts/buildNoCorpus.py b/scripts/buildNoCorpus.py new file mode 100644 index 0000000000..85250f3618 --- /dev/null +++ b/scripts/buildNoCorpus.py @@ -0,0 +1,68 @@ +# ------------------------------------------------------------------------------ +# Name: buildNoCorpus.py +# Purpose: Build the no-corpus music21 distribution (.tar.gz) +# +# Authors: Christopher Ariza +# Michael Scott Asato Cuthbert +# +# Copyright: Copyright © 2010-2026 Michael Scott Asato Cuthbert +# License: BSD, see license.txt +# ------------------------------------------------------------------------------ +''' +Build the "no-corpus" edition of music21: take a built `.tar.gz` and +strip out the corpus (which carries licenses such as +CC-SA or old copyrighted files are not fully public domain in the entire world +like music from 1935 but in Mexico and other life+100 countries) +to produce a `music21-noCorpus-*.tar.gz` suitable for Linux distros that +require a fully PD package. + +Part of `RELEASING.md` at the repository root:: + + uv run scripts/buildNoCorpus.py + +This file and `removeCorpus()` were reorganized out of the old `dist/dist.py`. +''' +import tarfile + +from music21 import __version__ as version +from music21.common.pathTools import getRootFilePath, getCorpusContentDirs + +def removeCorpus(): + ''' + Remove the corpus from a compressed file (.tar.gz) and + create a new music21-noCorpus version. + + Return the completed file path of the newly created edition. + ''' + distDir = getRootFilePath() / 'dist' + srcName = 'music21-' + version + dstName = 'music21-noCorpus-' + version + fpDst = distDir / (dstName + '.tar.gz') + corpusPrefixes = tuple( + f'{srcName}/music21/corpus/{d}/' + for d in [*getCorpusContentDirs(), '_metadataCache'] + ) + + # iterating over tar "members" rather than files/filenames in order to preserve + # the permissions etc. that the original hatch build set + with (tarfile.open(distDir / (srcName + '.tar.gz')) as src, + tarfile.open(fpDst, 'w:gz') as dst): + for member in src: + if member.name.startswith(corpusPrefixes): + continue + fileObj = src.extractfile(member) if member.isfile() else None + member.name = dstName + member.name[len(srcName):] + if 'path' in member.pax_headers: + # long paths (over 100 characters) and non-ASCII names store a short name + # in the tar and the longer name in this "path" header -- but it will be + # inaccurate in the new tar (because of the added "noCorpus-" in the path). + # Deleting this header will make the dst tar file regenerate it. + del member.pax_headers['path'] + dst.addfile(member, fileObj) + + return fpDst + + +# ------------------------------------------------------------------------------ +if __name__ == '__main__': + removeCorpus() diff --git a/uv.lock b/uv.lock deleted file mode 100644 index f442b84f88..0000000000 --- a/uv.lock +++ /dev/null @@ -1,3332 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.12" -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version == '3.14.*'", - "python_full_version < '3.14'", -] - -[[package]] -name = "alabaster" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, -] - -[[package]] -name = "annotated-doc" -version = "0.0.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, -] - -[[package]] -name = "anyio" -version = "4.13.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, -] - -[[package]] -name = "appnope" -version = "0.1.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, -] - -[[package]] -name = "argon2-cffi" -version = "25.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "argon2-cffi-bindings" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, -] - -[[package]] -name = "argon2-cffi-bindings" -version = "25.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" }, - { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" }, - { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" }, - { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" }, - { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" }, - { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" }, - { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" }, - { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" }, - { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" }, - { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, - { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, - { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, - { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, - { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, - { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, - { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, - { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, - { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, - { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, -] - -[[package]] -name = "arrow" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "python-dateutil" }, - { name = "tzdata" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, -] - -[[package]] -name = "astroid" -version = "4.0.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" }, -] - -[[package]] -name = "asttokens" -version = "3.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, -] - -[[package]] -name = "async-lru" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/1f/989ecfef8e64109a489fff357450cb73fa73a865a92bd8c272170a6922c2/async_lru-2.3.0.tar.gz", hash = "sha256:89bdb258a0140d7313cf8f4031d816a042202faa61d0ab310a0a538baa1c24b6", size = 16332, upload-time = "2026-03-19T01:04:32.413Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/e2/c2e3abf398f80732e58b03be77bde9022550d221dd8781bf586bd4d97cc1/async_lru-2.3.0-py3-none-any.whl", hash = "sha256:eea27b01841909316f2cc739807acea1c623df2be8c5cfad7583286397bb8315", size = 8403, upload-time = "2026-03-19T01:04:30.883Z" }, -] - -[[package]] -name = "attrs" -version = "26.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, -] - -[[package]] -name = "babel" -version = "2.18.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, -] - -[[package]] -name = "backports-zstd" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f4/b1/36a5182ce1d8ef9ef32bff69037bd28b389bbdb66338f8069e61da7028cb/backports_zstd-1.3.0.tar.gz", hash = "sha256:e8b2d68e2812f5c9970cabc5e21da8b409b5ed04e79b4585dbffa33e9b45ebe2", size = 997138, upload-time = "2025-12-29T17:28:06.143Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/72/d4/356da49d3053f4bc50e71a8535631b57bc9ca4e8c6d2442e073e0ab41c44/backports_zstd-1.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f4a292e357f3046d18766ce06d990ccbab97411708d3acb934e63529c2ea7786", size = 435972, upload-time = "2025-12-29T17:26:18.752Z" }, - { url = "https://files.pythonhosted.org/packages/30/8f/dbe389e60c7e47af488520f31a4aa14028d66da5bf3c60d3044b571eb906/backports_zstd-1.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fb4c386f38323698991b38edcc9c091d46d4713f5df02a3b5c80a28b40e289ea", size = 362124, upload-time = "2025-12-29T17:26:19.995Z" }, - { url = "https://files.pythonhosted.org/packages/55/4b/173beafc99e99e7276ce008ef060b704471e75124c826bc5e2092815da37/backports_zstd-1.3.0-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f52523d2bdada29e653261abdc9cfcecd9e5500d305708b7e37caddb24909d4e", size = 506378, upload-time = "2025-12-29T17:26:21.855Z" }, - { url = "https://files.pythonhosted.org/packages/df/c8/3f12a411d9a99d262cdb37b521025eecc2aa7e4a93277be3f4f4889adb74/backports_zstd-1.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3321d00beaacbd647252a7f581c1e1cdbdbda2407f2addce4bfb10e8e404b7c7", size = 476201, upload-time = "2025-12-29T17:26:23.047Z" }, - { url = "https://files.pythonhosted.org/packages/43/dc/73c090e4a2d5671422512e1b6d276ca6ea0cc0c45ec4634789106adc0d66/backports_zstd-1.3.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:88f94d238ef36c639c0ae17cf41054ce103da9c4d399c6a778ce82690d9f4919", size = 581659, upload-time = "2025-12-29T17:26:24.189Z" }, - { url = "https://files.pythonhosted.org/packages/08/4f/11bfcef534aa2bf3f476f52130217b45337f334d8a287edb2e06744a6515/backports_zstd-1.3.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97d8c78fe20c7442c810adccfd5e3ea6a4e6f4f1fa4c73da2bc083260ebead17", size = 640388, upload-time = "2025-12-29T17:26:25.47Z" }, - { url = "https://files.pythonhosted.org/packages/71/17/8faea426d4f49b63238bdfd9f211a9f01c862efe0d756d3abeb84265a4e2/backports_zstd-1.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eefda80c3dbfbd924f1c317e7b0543d39304ee645583cb58bae29e19f42948ed", size = 494173, upload-time = "2025-12-29T17:26:26.736Z" }, - { url = "https://files.pythonhosted.org/packages/ba/9d/901f19ac90f3cd999bdcfb6edb4d7b4dc383dfba537f06f533fc9ac4777b/backports_zstd-1.3.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2ab5d3b5a54a674f4f6367bb9e0914063f22cd102323876135e9cc7a8f14f17e", size = 568628, upload-time = "2025-12-29T17:26:28.12Z" }, - { url = "https://files.pythonhosted.org/packages/60/39/4d29788590c2465a570c2fae49dbff05741d1f0c8e4a0fb2c1c310f31804/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7558fb0e8c8197c59a5f80c56bf8f56c3690c45fd62f14e9e2081661556e3e64", size = 482233, upload-time = "2025-12-29T17:26:29.399Z" }, - { url = "https://files.pythonhosted.org/packages/d9/4b/24c7c9e8ef384b19d515a7b1644a500ceb3da3baeff6d579687da1a0f62b/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:27744870e38f017159b9c0241ea51562f94c7fefcfa4c5190fb3ec4a65a7fc63", size = 509806, upload-time = "2025-12-29T17:26:30.605Z" }, - { url = "https://files.pythonhosted.org/packages/3f/7e/7ba1aeecf0b5859f1855c0e661b4559566b64000f0627698ebd9e83f2138/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b099750755bb74c280827c7d68de621da0f245189082ab48ff91bda0ec2db9df", size = 586037, upload-time = "2025-12-29T17:26:32.201Z" }, - { url = "https://files.pythonhosted.org/packages/4a/1a/18f0402b36b9cfb0aea010b5df900cfd42c214f37493561dba3abac90c4e/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5434e86f2836d453ae3e19a2711449683b7e21e107686838d12a255ad256ca99", size = 566220, upload-time = "2025-12-29T17:26:33.5Z" }, - { url = "https://files.pythonhosted.org/packages/dc/d9/44c098ab31b948bbfd909ec4ae08e1e44c5025a2d846f62991a62ab3ebea/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:407e451f64e2f357c9218f5be4e372bb6102d7ae88582d415262a9d0a4f9b625", size = 630847, upload-time = "2025-12-29T17:26:35.273Z" }, - { url = "https://files.pythonhosted.org/packages/30/33/e74cb2cfb162d2e9e00dad8bcdf53118ca7786cfd467925d6864732f79cc/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:58a071f3c198c781b2df801070290b7174e3ff61875454e9df93ab7ea9ea832b", size = 498665, upload-time = "2025-12-29T17:26:37.123Z" }, - { url = "https://files.pythonhosted.org/packages/a2/a9/67a24007c333ed22736d5cd79f1aa1d7209f09be772ff82a8fd724c1978e/backports_zstd-1.3.0-cp312-cp312-win32.whl", hash = "sha256:21a9a542ccc7958ddb51ae6e46d8ed25d585b54d0d52aaa1c8da431ea158046a", size = 288809, upload-time = "2025-12-29T17:26:38.373Z" }, - { url = "https://files.pythonhosted.org/packages/42/24/34b816118ea913debb2ea23e71ffd0fb2e2ac738064c4ac32e3fb62c18bb/backports_zstd-1.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:89ea8281821123b071a06b30b80da8e4d8a2b40a4f57315a19850337a21297ac", size = 313815, upload-time = "2025-12-29T17:26:39.665Z" }, - { url = "https://files.pythonhosted.org/packages/4e/2f/babd02c9fc4ca35376ada7c291193a208165c7be2455f0f98bc1e1243f31/backports_zstd-1.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:f6843ecb181480e423b02f60fe29e393cbc31a95fb532acdf0d3a2c87bd50ce3", size = 288927, upload-time = "2025-12-29T17:26:40.923Z" }, - { url = "https://files.pythonhosted.org/packages/0c/7d/53e8da5950cdfc5e8fe23efd5165ce2f4fed5222f9a3292e0cdb03dd8c0d/backports_zstd-1.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e86e03e3661900955f01afed6c59cae9baa63574e3b66896d99b7de97eaffce9", size = 435463, upload-time = "2025-12-29T17:26:42.152Z" }, - { url = "https://files.pythonhosted.org/packages/da/78/f98e53870f7404071a41e3d04f2ff514302eeeb3279d931d02b220f437aa/backports_zstd-1.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:41974dcacc9824c1effe1c8d2f9d762bcf47d265ca4581a3c63321c7b06c61f0", size = 361740, upload-time = "2025-12-29T17:26:43.377Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ed/2c64706205a944c9c346d95c17f632d4e3468db3ce60efb6f5caa7c0dcae/backports_zstd-1.3.0-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:3090a97738d6ce9545d3ca5446df43370928092a962cbc0153e5445a947e98ed", size = 505651, upload-time = "2025-12-29T17:26:44.495Z" }, - { url = "https://files.pythonhosted.org/packages/7b/7b/22998f691dc6e0c7e6fa81d611eb4b1f6a72fb27327f322366d4a7ca8fb3/backports_zstd-1.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ddc874638abf03ea1ff3b0525b4a26a8d0adf7cb46a448c3449f08e4abc276b3", size = 475859, upload-time = "2025-12-29T17:26:45.722Z" }, - { url = "https://files.pythonhosted.org/packages/0b/78/0cde898339a339530e5f932634872d2d64549969535447a48d3b98959e11/backports_zstd-1.3.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:db609e57b8ed88b3472930c87e93c08a4bbd5ffeb94608cd9c7c6f0ac0e166c6", size = 581339, upload-time = "2025-12-29T17:26:46.93Z" }, - { url = "https://files.pythonhosted.org/packages/e2/1d/e0973e0eebe678c12c146473af2c54cda8a3e63b179785ca1a20727ad69c/backports_zstd-1.3.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5f13033a3dd95f323c067199f2e61b4589a7880188ef4ef356c7ffbdb78a9f11", size = 642182, upload-time = "2025-12-29T17:26:48.545Z" }, - { url = "https://files.pythonhosted.org/packages/82/a2/ac67e79e137eb98aead66c7162bafe3cffcb82ef9cdeb6367ec18d88fbce/backports_zstd-1.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c4c7bcda5619a754726e7f5b391827f5efbe4bed8e62e9ec7490d42bff18aa6", size = 490807, upload-time = "2025-12-29T17:26:49.789Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e9/3514b1d065801ae7dce05246e9389003ed8fb1d7c3d71f85aa07a80f41e6/backports_zstd-1.3.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:884a94c40f27affe986f394f219a4fd3cbbd08e1cff2e028d29d467574cd266e", size = 566103, upload-time = "2025-12-29T17:26:51.062Z" }, - { url = "https://files.pythonhosted.org/packages/1b/03/10ddb54cbf032e5fe390c0776d3392611b1fc772d6c3cb5a9bcdff4f915f/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497f5765126f11a5b3fd8fedfdae0166d1dd867e7179b8148370a3313d047197", size = 481614, upload-time = "2025-12-29T17:26:52.255Z" }, - { url = "https://files.pythonhosted.org/packages/5c/13/21efa7f94c41447f43aee1563b05fc540a235e61bce4597754f6c11c2e97/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a6ff6769948bb29bba07e1c2e8582d5a9765192a366108e42d6581a458475881", size = 509207, upload-time = "2025-12-29T17:26:53.496Z" }, - { url = "https://files.pythonhosted.org/packages/de/e7/12da9256d9e49e71030f0ff75e9f7c258e76091a4eaf5b5f414409be6a57/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1623e5bff1acd9c8ef90d24fc548110f20df2d14432bfe5de59e76fc036824ef", size = 585765, upload-time = "2025-12-29T17:26:54.99Z" }, - { url = "https://files.pythonhosted.org/packages/24/bf/59ca9cb4e7be1e59331bb792e8ef1331828efe596b1a2f8cbbc4e3f70d75/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:622c28306dcc429c8f2057fc4421d5722b1f22968d299025b35d71b50cfd4e03", size = 563852, upload-time = "2025-12-29T17:26:56.371Z" }, - { url = "https://files.pythonhosted.org/packages/7c/ee/5a3eaed9a73bdf2c35dc0c7adc0616a99588e0de28f5ab52f3e0caaaa96f/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09a2785e410ed2e812cb39b684ef5eb55083a5897bfd0e6f5de3bbd2c6345f70", size = 632549, upload-time = "2025-12-29T17:26:57.598Z" }, - { url = "https://files.pythonhosted.org/packages/75/b9/c823633afc48a1ac56d6ad34289c8f51b0234685142531bfa8197ca91777/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ade1f4127fdbe36a02f8067d75aa79c1ea1c8a306bf63c7b818bb7b530e1beaa", size = 495104, upload-time = "2025-12-29T17:26:58.826Z" }, - { url = "https://files.pythonhosted.org/packages/a3/8f/6f7030f18fa7307f87b0f57108a50a3a540b6350e2486d1739c0567629a3/backports_zstd-1.3.0-cp313-cp313-win32.whl", hash = "sha256:668e6fb1805b825cb7504c71436f7b28d4d792bb2663ee901ec9a2bb15804437", size = 288447, upload-time = "2025-12-29T17:27:00.036Z" }, - { url = "https://files.pythonhosted.org/packages/a2/82/b1df1bbbe4e6d3ffd364d0bcffdeb6c4361115c1eccd91238dbdd0c07fec/backports_zstd-1.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:385bdadf0ea8fe6ba780a95e4c7d7f018db7bafdd630932f0f9f0fad05d608ff", size = 313664, upload-time = "2025-12-29T17:27:01.267Z" }, - { url = "https://files.pythonhosted.org/packages/45/0f/60918fe4d3f2881de8f4088d73be4837df9e4c6567594109d355a2d548b6/backports_zstd-1.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:4321a8a367537224b3559fe7aeb8012b98aea2a60a737e59e51d86e2e856fe0a", size = 288678, upload-time = "2025-12-29T17:27:02.506Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b9/35f423c0bcd85020d5e7be6ab8d7517843e3e4441071beb5c3bd8c5216cb/backports_zstd-1.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:10057d66fa4f0a7d3f6419ffb84b4fe61088da572e3ac4446134a1c8089e4166", size = 436155, upload-time = "2025-12-29T17:27:03.859Z" }, - { url = "https://files.pythonhosted.org/packages/f6/14/e504daea24e8916f14ecbc223c354b558d8410cfc846606668ab91d96b38/backports_zstd-1.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4abf29d706ba05f658ca0247eb55675bcc00e10f12bca15736e45b05f1f2d2dc", size = 362436, upload-time = "2025-12-29T17:27:05.076Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f7/06e178dbab7edb88c2872aebd68b54137e07a169eba1aeedf614014f7036/backports_zstd-1.3.0-cp313-cp313t-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:127b0d73c745b0684da3d95c31c0939570810dad8967dfe8231eea8f0e047b2f", size = 507600, upload-time = "2025-12-29T17:27:06.254Z" }, - { url = "https://files.pythonhosted.org/packages/3e/f1/2ce499b81c4389d6fa1eeea7e76f6e0bad48effdbb239da7cbcdaaf24b76/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0205ef809fb38bb5ca7f59fa03993596f918768b9378fb7fbd8a68889a6ce028", size = 475496, upload-time = "2025-12-29T17:27:07.939Z" }, - { url = "https://files.pythonhosted.org/packages/18/1e/c82a586f2866aabf3a601a521af3c58756d83d98b724fda200016ac5e7e2/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c389b667b0b07915781aa28beabf2481f11a6062a1a081873c4c443b98601a7", size = 580919, upload-time = "2025-12-29T17:27:09.1Z" }, - { url = "https://files.pythonhosted.org/packages/1b/a3/eb5d9b7c4cb69d1b8ccd011abe244ba6815693b70bed07ed4b77ddda4535/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8e7ac5ef693d49d6fb35cd7bbb98c4762cfea94a8bd2bf2ab112027004f70b11", size = 639913, upload-time = "2025-12-29T17:27:10.433Z" }, - { url = "https://files.pythonhosted.org/packages/11/2c/7296b99df79d9f31174a99c81c1964a32de8996ce2b3068f5bc66b413615/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d5543945aae2a76a850b23f283249424f535de6a622d6002957b7d971e6a36d", size = 494800, upload-time = "2025-12-29T17:27:11.59Z" }, - { url = "https://files.pythonhosted.org/packages/f9/fc/b8ae6e104ba72d20cd5f9dfd9baee36675e89c81d432434927967114f30f/backports_zstd-1.3.0-cp313-cp313t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e38be15ebce82737deda2c9410c1f942f1df9da74121049243a009810432db75", size = 570396, upload-time = "2025-12-29T17:27:13.063Z" }, - { url = "https://files.pythonhosted.org/packages/30/56/60a7a9de7a5bc951ea1106358b413c95183c93480394f3abc541313c8679/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3e3f58c76f4730607a4e0130d629173aa114ae72a5c8d3d5ad94e1bf51f18d8", size = 481980, upload-time = "2025-12-29T17:27:14.317Z" }, - { url = "https://files.pythonhosted.org/packages/4b/bb/93fc1e8e81b8ecba58b0e53a14f7b44375cf837db6354410998f0c4cb6ff/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:b808bf889722d889b792f7894e19c1f904bb0e9092d8c0eb0787b939b08bad9a", size = 511358, upload-time = "2025-12-29T17:27:15.669Z" }, - { url = "https://files.pythonhosted.org/packages/ae/0f/b165c2a6080d22306975cd86ce97270208493f31a298867e343110570370/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f7be27d56f2f715bcd252d0c65c232146d8e1e039c7e2835b8a3ad3dc88bc508", size = 585492, upload-time = "2025-12-29T17:27:16.986Z" }, - { url = "https://files.pythonhosted.org/packages/26/76/85b4bde76e982b24a7eb57a2fb9868807887bef4d2114a3654a6530a67ef/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:cbe341c7fcc723893663a37175ba859328b907a4e6d2d40a4c26629cc55efb67", size = 568309, upload-time = "2025-12-29T17:27:18.28Z" }, - { url = "https://files.pythonhosted.org/packages/83/64/9490667827a320766fb883f358a7c19171fdc04f19ade156a8c341c36967/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:b4116a9e12dfcd834dd9132cf6a94657bf0d328cba5b295f26de26ea0ae1adc8", size = 630518, upload-time = "2025-12-29T17:27:19.525Z" }, - { url = "https://files.pythonhosted.org/packages/ea/43/258587233b728bbff457bdb0c52b3e08504c485a8642b3daeb0bdd5a76bc/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1049e804cc8754290b24dab383d4d6ed0b7f794ad8338813ddcb3907d15a89d0", size = 499429, upload-time = "2025-12-29T17:27:21.063Z" }, - { url = "https://files.pythonhosted.org/packages/32/04/cfab76878f360f124dbb533779e1e4603c801a0f5ada72ae5c742b7c4d7d/backports_zstd-1.3.0-cp313-cp313t-win32.whl", hash = "sha256:7d3f0f2499d2049ec53d2674c605a4b3052c217cc7ee49c05258046411685adc", size = 289389, upload-time = "2025-12-29T17:27:22.287Z" }, - { url = "https://files.pythonhosted.org/packages/cb/ff/dbcfb6c9c922ab6d98f3d321e7d0c7b34ecfa26f3ca71d930fe1ef639737/backports_zstd-1.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:eb2f8fab0b1ea05148394cb34a9e543a43477178765f2d6e7c84ed332e34935e", size = 314776, upload-time = "2025-12-29T17:27:23.458Z" }, - { url = "https://files.pythonhosted.org/packages/01/4b/82e4baae3117806639fe1c693b1f2f7e6133a7cefd1fa2e38018c8edcd68/backports_zstd-1.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c66ad9eb5bfbe28c2387b7fc58ddcdecfb336d6e4e60bcba1694a906c1f21a6c", size = 289315, upload-time = "2025-12-29T17:27:24.601Z" }, -] - -[[package]] -name = "beautifulsoup4" -version = "4.14.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "soupsieve" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, -] - -[[package]] -name = "bleach" -version = "6.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "webencodings" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/48/3c/e12ac860709702bd5ebeb9b56a4fe334f1001246ee1b8f2b7ee28912df7d/bleach-6.4.0.tar.gz", hash = "sha256:4202482733d85cedd04e59fcb2f89f4e4c7c385a78d3c3c23c30446843a37452", size = 204857, upload-time = "2026-06-05T13:01:13.734Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/9d/40b6267367182187139a4000b82a3b287d84d745bccd808e75d916920e9d/bleach-6.4.0-py3-none-any.whl", hash = "sha256:4b6b6a54fff2e69a3dde9d21cc6301220bee3c3cb792187d11403fd795031081", size = 165109, upload-time = "2026-06-05T13:01:12.504Z" }, -] - -[package.optional-dependencies] -css = [ - { name = "tinycss2" }, -] - -[[package]] -name = "certifi" -version = "2026.4.22" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, -] - -[[package]] -name = "cffi" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, -] - -[[package]] -name = "chardet" -version = "7.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/b6/9df434a8eeba2e6628c465a1dfa31034228ef79b26f76f46278f4ef7e49d/chardet-7.4.3.tar.gz", hash = "sha256:cc1d4eb92a4ec1c2df3b490836ffa46922e599d34ce0bb75cf41fd2bf6303d56", size = 784800, upload-time = "2026-04-13T21:33:39.803Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/33/29de185079e6675c3f375546e30a559b7ddc75ce972f18d6e566cd9ea4eb/chardet-7.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:75d3c65cc16bddf40b8da1fd25ba84fca5f8070f2b14e86083653c1c85aee971", size = 874870, upload-time = "2026-04-13T21:33:05.977Z" }, - { url = "https://files.pythonhosted.org/packages/9c/2f/4c5af01fd1a7506a1d5375403d68925eac70289229492db5aa68b58103d8/chardet-7.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:29af5999f654e8729d251f1724a62b538b1262d9292cccaefddf8a02aae1ef6a", size = 854859, upload-time = "2026-04-13T21:33:07.381Z" }, - { url = "https://files.pythonhosted.org/packages/36/21/edb36ad5dfa48d7f8eed97ab43931ecdaa8c15166c21b1d614967e49d681/chardet-7.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:626f00299ad62dfe937058a09572beed442ccc7b58f87aa667949b20fd3db235", size = 875032, upload-time = "2026-04-13T21:33:08.741Z" }, - { url = "https://files.pythonhosted.org/packages/e5/59/a32a241d861cf180853a11c8e5a67641cb1b2af13c3a5ccce83ec07e2c9f/chardet-7.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a4904dd5f071b7a7d7f50b4a67a86db3c902d243bf31708f1d5cde2f68239cb", size = 888283, upload-time = "2026-04-13T21:33:10.213Z" }, - { url = "https://files.pythonhosted.org/packages/87/2e/e1ee6a77abf3782c00e05b89c4d4328c8353bf9500661c4348df1dd68614/chardet-7.4.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d2879598bc220689e8ce509fe9c3f37ad2fca53a36be9c9bd91abdd91dd364f", size = 879974, upload-time = "2026-04-13T21:33:11.448Z" }, - { url = "https://files.pythonhosted.org/packages/32/60/fca69c534602a7ced04280c952a246ad1edde2a6ca3a164f65d32ac41fe7/chardet-7.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:4b2799bd58e7245cfa8d4ab2e8ad1d76a5c3a5b1f32318eb6acca4c69a3e7101", size = 943973, upload-time = "2026-04-13T21:33:12.756Z" }, - { url = "https://files.pythonhosted.org/packages/7c/43/79ac9b4db5bc87020c9dbc419125371d80882d1d197e9c4765ba8682b605/chardet-7.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a9e4486df251b8962e86ea9f139ca235aa6e0542a00f7844c9a04160afb99aa9", size = 873769, upload-time = "2026-04-13T21:33:14.002Z" }, - { url = "https://files.pythonhosted.org/packages/55/5f/25bdec773905bff0ff6cf35ca73b17bd05593b4f87bd8c5fa43705f7167d/chardet-7.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fbff1907925b0c5a1064cffb5e040cd5e338585c9c552625f30de6bc2f3107a", size = 853991, upload-time = "2026-04-13T21:33:15.564Z" }, - { url = "https://files.pythonhosted.org/packages/b4/07/a29380ee0b215d23d77733b5ad60c5c0c7969650e080c667acdf9462040d/chardet-7.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:365135eaf37ba65a828f8e668eb0a8c38c479dcbec724dc25f4dfd781049c357", size = 874024, upload-time = "2026-04-13T21:33:16.915Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b1/3338e121cbd4c8a126b8ccb1061170c2ce51a53f678c502793ea49c6fd6d/chardet-7.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfc134b70c846c21ead8e43ada3ae1a805fff732f6922f8abcf2ff27b8f6493d", size = 887410, upload-time = "2026-04-13T21:33:18.368Z" }, - { url = "https://files.pythonhosted.org/packages/63/1c/44a9a9e0c59c185a5d307ceaeee8768afa1558f0a24f7a4b5fa11b67586b/chardet-7.4.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9acd9988a93e09390f3cd231201ea7166c415eb8da1b735928990ffc05cb9fbb", size = 879269, upload-time = "2026-04-13T21:33:20.377Z" }, - { url = "https://files.pythonhosted.org/packages/1b/b3/5d0e77ea774bd3224321c248880ea0c0379000ac5c2bb6d77609549de247/chardet-7.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:e1b98790c284ff813f18f7cf7de5f05ea2435a080030c7f1a8318f3a4f80b131", size = 944155, upload-time = "2026-04-13T21:33:21.694Z" }, - { url = "https://files.pythonhosted.org/packages/70/a8/bf0811d859e13801279a2ae64f37a408027b282f2047bc0001c75dd356ad/chardet-7.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d892d3dcd652fdef53e3d6327d39b17c0df40a899dfc919abaeb64c974497531", size = 872887, upload-time = "2026-04-13T21:33:23.328Z" }, - { url = "https://files.pythonhosted.org/packages/51/ac/b9d68ebddfe1b02c77af5bf81120e12b036b4432dc6af7a303d90e2bc38b/chardet-7.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:acc46d1b8b7d5783216afe15db56d1c179b9a40e5a1558bc13164c4fd20674c4", size = 853964, upload-time = "2026-04-13T21:33:24.724Z" }, - { url = "https://files.pythonhosted.org/packages/2a/81/17fa103ea9caf5d325a5e4051ab2ba65996fd66baa60b81ee41af1f54e10/chardet-7.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ac3bf11c645734a1701a3804e43eabd98851838192267d08c353a834ab79fea", size = 876006, upload-time = "2026-04-13T21:33:26.098Z" }, - { url = "https://files.pythonhosted.org/packages/c2/20/193faab46a68ea550587331a698c3dca8099f8901d10937c4443135c7ed9/chardet-7.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e3bd9f936e04bae89c254262af08d9e5b98f805175ba1e29d454e6cba3107b7", size = 887680, upload-time = "2026-04-13T21:33:27.49Z" }, - { url = "https://files.pythonhosted.org/packages/40/c6/94a3c673327392652ee8bdea9a45bc8a5f5365197a7387d68f0eed007115/chardet-7.4.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:27cc23da03630cdecc9aa81a895aa86629c211f995cd57651f0fbc280717bf93", size = 879865, upload-time = "2026-04-13T21:33:29.052Z" }, - { url = "https://files.pythonhosted.org/packages/b1/2c/cad8b5e3623a987f3c930b68e2bdd06cfc388cd91cd42ed05f1227701b73/chardet-7.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:b95c934b9ad59e2ba8abb9be49df70d3ad1b0d95d864b9fdb7588d4fa8bd921c", size = 939594, upload-time = "2026-04-13T21:33:31.391Z" }, - { url = "https://files.pythonhosted.org/packages/33/e0/d06e42fd6f02a58e5e227e5106587751cb38adcff0aaf949add744b78b6e/chardet-7.4.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c77867f0c1cb8bd819502249fcdc500364aedb07881e11b743726fa2148e7b6e", size = 889714, upload-time = "2026-04-13T21:33:32.772Z" }, - { url = "https://files.pythonhosted.org/packages/d4/ed/40d091954d48abea037baae6be8fb79905e5f78d34d12ea955132c7d8011/chardet-7.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cf1efeaf65a6ef2f5b9cc3a1df6f08ba2831b369ccaa4c7018eaf90aa757bb11", size = 872319, upload-time = "2026-04-13T21:33:34.427Z" }, - { url = "https://files.pythonhosted.org/packages/bb/77/82a46821dbfbdfe062710d2bf2ede13426304e3567a23c57d919c0c31630/chardet-7.4.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f3504c139a2ad544077dd2d9e412cd08b01786843d76997cd43bb6de311723c", size = 892021, upload-time = "2026-04-13T21:33:35.766Z" }, - { url = "https://files.pythonhosted.org/packages/49/57/42d30c562bda5b4a839766c1aad8d5856b798ad2a1c3247b72a679afec94/chardet-7.4.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457f619882ba66327d4d8d14c6c342269bdb1e4e1c38e8117df941d14d351b04", size = 902509, upload-time = "2026-04-13T21:33:37.096Z" }, - { url = "https://files.pythonhosted.org/packages/8c/6c/0a40afdb50a0fe041ab95553b835a8160b6cf0e81edf2ae2fe9f5224cbf9/chardet-7.4.3-py3-none-any.whl", hash = "sha256:1173b74051570cf08099d7429d92e4882d375ad4217f92a6e5240ccfb26f231e", size = 626562, upload-time = "2026-04-13T21:33:38.559Z" }, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, - { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, - { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, - { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, - { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, - { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, - { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, - { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, - { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, - { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, - { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, - { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, - { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, - { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, - { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, - { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, - { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, - { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, - { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, - { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, - { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, - { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, - { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, - { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, - { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, - { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, - { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, - { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, - { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, - { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, - { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, - { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, - { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, - { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, - { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, - { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, - { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, - { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, - { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, - { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, -] - -[[package]] -name = "click" -version = "8.3.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "comm" -version = "0.2.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, -] - -[[package]] -name = "contourpy" -version = "1.3.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, - { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, - { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, - { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, - { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, - { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, - { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, - { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, - { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, - { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, - { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, - { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, - { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, - { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, - { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, - { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, - { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, - { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, - { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, - { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, - { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, - { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, - { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, - { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, - { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, - { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, - { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, - { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, - { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, - { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, - { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, - { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, - { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, - { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, - { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, - { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, - { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, - { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, - { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, - { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, - { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, - { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, - { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, - { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, - { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, -] - -[[package]] -name = "coverage" -version = "7.13.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, - { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, - { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, - { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, - { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, - { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, - { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, - { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, - { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, - { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, - { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, - { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, - { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, - { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, - { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, - { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, - { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, - { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, - { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, - { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, - { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, - { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, - { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, - { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, - { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, - { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, - { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, - { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, - { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, - { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, - { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, - { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, - { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, - { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, - { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, - { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, - { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, - { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, - { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, - { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, - { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, - { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, - { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, - { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, - { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, - { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, - { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, - { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, - { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, - { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, - { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, - { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, - { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, - { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, - { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, - { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, - { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, - { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, - { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, - { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, - { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, - { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, -] - -[[package]] -name = "coveralls" -version = "4.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "coverage" }, - { name = "requests" }, - { name = "typer" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/97/16/aa263cb5450de470234f5176ce42664c5e0535aab1a2094b739447065797/coveralls-4.1.0.tar.gz", hash = "sha256:dab364025ba80cbb95ce56c6fc62cd9172d7fd637060ea235dde99d9b46a4494", size = 12736, upload-time = "2026-02-28T15:16:47.591Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/f1/5a884d2e8e3e7e7d854d3032b777bd22aad1598161cf9aa77ac37d684908/coveralls-4.1.0-py3-none-any.whl", hash = "sha256:bfacfda2d443c24fc90d67035027cec15015fff2dbd036427e8bf8f4953dda2e", size = 14168, upload-time = "2026-02-28T15:16:46.181Z" }, -] - -[[package]] -name = "cryptography" -version = "48.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" }, - { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" }, - { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" }, - { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" }, - { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" }, - { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" }, - { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" }, - { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" }, - { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" }, - { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" }, - { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" }, - { url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" }, - { url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" }, - { url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" }, - { url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" }, - { url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" }, - { url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" }, - { url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" }, - { url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" }, - { url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" }, - { url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" }, - { url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" }, - { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" }, - { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" }, - { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" }, - { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" }, - { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" }, - { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" }, - { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" }, - { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" }, - { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" }, -] - -[[package]] -name = "cycler" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, -] - -[[package]] -name = "debugpy" -version = "1.8.20" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/b7/cd8080344452e4874aae67c40d8940e2b4d47b01601a8fd9f44786c757c7/debugpy-1.8.20.tar.gz", hash = "sha256:55bc8701714969f1ab89a6d5f2f3d40c36f91b2cbe2f65d98bf8196f6a6a2c33", size = 1645207, upload-time = "2026-01-29T23:03:28.199Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/57/7f34f4736bfb6e00f2e4c96351b07805d83c9a7b33d28580ae01374430f7/debugpy-1.8.20-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:4ae3135e2089905a916909ef31922b2d733d756f66d87345b3e5e52b7a55f13d", size = 2550686, upload-time = "2026-01-29T23:03:42.023Z" }, - { url = "https://files.pythonhosted.org/packages/ab/78/b193a3975ca34458f6f0e24aaf5c3e3da72f5401f6054c0dfd004b41726f/debugpy-1.8.20-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:88f47850a4284b88bd2bfee1f26132147d5d504e4e86c22485dfa44b97e19b4b", size = 4310588, upload-time = "2026-01-29T23:03:43.314Z" }, - { url = "https://files.pythonhosted.org/packages/c1/55/f14deb95eaf4f30f07ef4b90a8590fc05d9e04df85ee379712f6fb6736d7/debugpy-1.8.20-cp312-cp312-win32.whl", hash = "sha256:4057ac68f892064e5f98209ab582abfee3b543fb55d2e87610ddc133a954d390", size = 5331372, upload-time = "2026-01-29T23:03:45.526Z" }, - { url = "https://files.pythonhosted.org/packages/a1/39/2bef246368bd42f9bd7cba99844542b74b84dacbdbea0833e610f384fee8/debugpy-1.8.20-cp312-cp312-win_amd64.whl", hash = "sha256:a1a8f851e7cf171330679ef6997e9c579ef6dd33c9098458bd9986a0f4ca52e3", size = 5372835, upload-time = "2026-01-29T23:03:47.245Z" }, - { url = "https://files.pythonhosted.org/packages/15/e2/fc500524cc6f104a9d049abc85a0a8b3f0d14c0a39b9c140511c61e5b40b/debugpy-1.8.20-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:5dff4bb27027821fdfcc9e8f87309a28988231165147c31730128b1c983e282a", size = 2539560, upload-time = "2026-01-29T23:03:48.738Z" }, - { url = "https://files.pythonhosted.org/packages/90/83/fb33dcea789ed6018f8da20c5a9bc9d82adc65c0c990faed43f7c955da46/debugpy-1.8.20-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:84562982dd7cf5ebebfdea667ca20a064e096099997b175fe204e86817f64eaf", size = 4293272, upload-time = "2026-01-29T23:03:50.169Z" }, - { url = "https://files.pythonhosted.org/packages/a6/25/b1e4a01bfb824d79a6af24b99ef291e24189080c93576dfd9b1a2815cd0f/debugpy-1.8.20-cp313-cp313-win32.whl", hash = "sha256:da11dea6447b2cadbf8ce2bec59ecea87cc18d2c574980f643f2d2dfe4862393", size = 5331208, upload-time = "2026-01-29T23:03:51.547Z" }, - { url = "https://files.pythonhosted.org/packages/13/f7/a0b368ce54ffff9e9028c098bd2d28cfc5b54f9f6c186929083d4c60ba58/debugpy-1.8.20-cp313-cp313-win_amd64.whl", hash = "sha256:eb506e45943cab2efb7c6eafdd65b842f3ae779f020c82221f55aca9de135ed7", size = 5372930, upload-time = "2026-01-29T23:03:53.585Z" }, - { url = "https://files.pythonhosted.org/packages/33/2e/f6cb9a8a13f5058f0a20fe09711a7b726232cd5a78c6a7c05b2ec726cff9/debugpy-1.8.20-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9c74df62fc064cd5e5eaca1353a3ef5a5d50da5eb8058fcef63106f7bebe6173", size = 2538066, upload-time = "2026-01-29T23:03:54.999Z" }, - { url = "https://files.pythonhosted.org/packages/c5/56/6ddca50b53624e1ca3ce1d1e49ff22db46c47ea5fb4c0cc5c9b90a616364/debugpy-1.8.20-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:077a7447589ee9bc1ff0cdf443566d0ecf540ac8aa7333b775ebcb8ce9f4ecad", size = 4269425, upload-time = "2026-01-29T23:03:56.518Z" }, - { url = "https://files.pythonhosted.org/packages/c5/d9/d64199c14a0d4c476df46c82470a3ce45c8d183a6796cfb5e66533b3663c/debugpy-1.8.20-cp314-cp314-win32.whl", hash = "sha256:352036a99dd35053b37b7803f748efc456076f929c6a895556932eaf2d23b07f", size = 5331407, upload-time = "2026-01-29T23:03:58.481Z" }, - { url = "https://files.pythonhosted.org/packages/e0/d9/1f07395b54413432624d61524dfd98c1a7c7827d2abfdb8829ac92638205/debugpy-1.8.20-cp314-cp314-win_amd64.whl", hash = "sha256:a98eec61135465b062846112e5ecf2eebb855305acc1dfbae43b72903b8ab5be", size = 5372521, upload-time = "2026-01-29T23:03:59.864Z" }, - { url = "https://files.pythonhosted.org/packages/e0/c3/7f67dea8ccf8fdcb9c99033bbe3e90b9e7395415843accb81428c441be2d/debugpy-1.8.20-py2.py3-none-any.whl", hash = "sha256:5be9bed9ae3be00665a06acaa48f8329d2b9632f15fd09f6a9a8c8d9907e54d7", size = 5337658, upload-time = "2026-01-29T23:04:17.404Z" }, -] - -[[package]] -name = "decorator" -version = "5.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, -] - -[[package]] -name = "defusedxml" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, -] - -[[package]] -name = "dill" -version = "0.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, -] - -[[package]] -name = "distlib" -version = "0.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, -] - -[[package]] -name = "docutils" -version = "0.22.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, -] - -[[package]] -name = "executing" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, -] - -[[package]] -name = "fastjsonschema" -version = "2.21.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, -] - -[[package]] -name = "filelock" -version = "3.29.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, -] - -[[package]] -name = "fonttools" -version = "4.62.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/08/7012b00a9a5874311b639c3920270c36ee0c445b69d9989a85e5c92ebcb0/fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d", size = 3580737, upload-time = "2026-03-13T13:54:25.52Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/d4/dbacced3953544b9a93088cc10ef2b596d348c983d5c67a404fa41ec51ba/fonttools-4.62.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974", size = 2870219, upload-time = "2026-03-13T13:52:53.664Z" }, - { url = "https://files.pythonhosted.org/packages/66/9e/a769c8e99b81e5a87ab7e5e7236684de4e96246aae17274e5347d11ebd78/fonttools-4.62.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9", size = 2414891, upload-time = "2026-03-13T13:52:56.493Z" }, - { url = "https://files.pythonhosted.org/packages/69/64/f19a9e3911968c37e1e620e14dfc5778299e1474f72f4e57c5ec771d9489/fonttools-4.62.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936", size = 5033197, upload-time = "2026-03-13T13:52:59.179Z" }, - { url = "https://files.pythonhosted.org/packages/9b/8a/99c8b3c3888c5c474c08dbfd7c8899786de9604b727fcefb055b42c84bba/fonttools-4.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392", size = 4988768, upload-time = "2026-03-13T13:53:02.761Z" }, - { url = "https://files.pythonhosted.org/packages/d1/c6/0f904540d3e6ab463c1243a0d803504826a11604c72dd58c2949796a1762/fonttools-4.62.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04", size = 4971512, upload-time = "2026-03-13T13:53:05.678Z" }, - { url = "https://files.pythonhosted.org/packages/29/0b/5cbef6588dc9bd6b5c9ad6a4d5a8ca384d0cea089da31711bbeb4f9654a6/fonttools-4.62.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d", size = 5122723, upload-time = "2026-03-13T13:53:08.662Z" }, - { url = "https://files.pythonhosted.org/packages/4a/47/b3a5342d381595ef439adec67848bed561ab7fdb1019fa522e82101b7d9c/fonttools-4.62.1-cp312-cp312-win32.whl", hash = "sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c", size = 2281278, upload-time = "2026-03-13T13:53:10.998Z" }, - { url = "https://files.pythonhosted.org/packages/28/b1/0c2ab56a16f409c6c8a68816e6af707827ad5d629634691ff60a52879792/fonttools-4.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42", size = 2331414, upload-time = "2026-03-13T13:53:13.992Z" }, - { url = "https://files.pythonhosted.org/packages/3b/56/6f389de21c49555553d6a5aeed5ac9767631497ac836c4f076273d15bd72/fonttools-4.62.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79", size = 2865155, upload-time = "2026-03-13T13:53:16.132Z" }, - { url = "https://files.pythonhosted.org/packages/03/c5/0e3966edd5ec668d41dfe418787726752bc07e2f5fd8c8f208615e61fa89/fonttools-4.62.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe", size = 2412802, upload-time = "2026-03-13T13:53:18.878Z" }, - { url = "https://files.pythonhosted.org/packages/52/94/e6ac4b44026de7786fe46e3bfa0c87e51d5d70a841054065d49cd62bb909/fonttools-4.62.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68", size = 5013926, upload-time = "2026-03-13T13:53:21.379Z" }, - { url = "https://files.pythonhosted.org/packages/e2/98/8b1e801939839d405f1f122e7d175cebe9aeb4e114f95bfc45e3152af9a7/fonttools-4.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1", size = 4964575, upload-time = "2026-03-13T13:53:23.857Z" }, - { url = "https://files.pythonhosted.org/packages/46/76/7d051671e938b1881670528fec69cc4044315edd71a229c7fd712eaa5119/fonttools-4.62.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069", size = 4953693, upload-time = "2026-03-13T13:53:26.569Z" }, - { url = "https://files.pythonhosted.org/packages/1f/ae/b41f8628ec0be3c1b934fc12b84f4576a5c646119db4d3bdd76a217c90b5/fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9", size = 5094920, upload-time = "2026-03-13T13:53:29.329Z" }, - { url = "https://files.pythonhosted.org/packages/f2/f6/53a1e9469331a23dcc400970a27a4caa3d9f6edbf5baab0260285238b884/fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24", size = 2279928, upload-time = "2026-03-13T13:53:32.352Z" }, - { url = "https://files.pythonhosted.org/packages/38/60/35186529de1db3c01f5ad625bde07c1f576305eab6d86bbda4c58445f721/fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056", size = 2330514, upload-time = "2026-03-13T13:53:34.991Z" }, - { url = "https://files.pythonhosted.org/packages/36/f0/2888cdac391807d68d90dcb16ef858ddc1b5309bfc6966195a459dd326e2/fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca", size = 2864442, upload-time = "2026-03-13T13:53:37.509Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b2/e521803081f8dc35990816b82da6360fa668a21b44da4b53fc9e77efcd62/fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca", size = 2410901, upload-time = "2026-03-13T13:53:40.55Z" }, - { url = "https://files.pythonhosted.org/packages/00/a4/8c3511ff06e53110039358dbbdc1a65d72157a054638387aa2ada300a8b8/fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782", size = 4999608, upload-time = "2026-03-13T13:53:42.798Z" }, - { url = "https://files.pythonhosted.org/packages/28/63/cd0c3b26afe60995a5295f37c246a93d454023726c3261cfbb3559969bb9/fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae", size = 4912726, upload-time = "2026-03-13T13:53:45.405Z" }, - { url = "https://files.pythonhosted.org/packages/70/b9/ac677cb07c24c685cf34f64e140617d58789d67a3dd524164b63648c6114/fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7", size = 4951422, upload-time = "2026-03-13T13:53:48.326Z" }, - { url = "https://files.pythonhosted.org/packages/e6/10/11c08419a14b85b7ca9a9faca321accccc8842dd9e0b1c8a72908de05945/fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a", size = 5060979, upload-time = "2026-03-13T13:53:51.366Z" }, - { url = "https://files.pythonhosted.org/packages/4e/3c/12eea4a4cf054e7ab058ed5ceada43b46809fce2bf319017c4d63ae55bb4/fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800", size = 2283733, upload-time = "2026-03-13T13:53:53.606Z" }, - { url = "https://files.pythonhosted.org/packages/6b/67/74b070029043186b5dd13462c958cb7c7f811be0d2e634309d9a1ffb1505/fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e", size = 2335663, upload-time = "2026-03-13T13:53:56.23Z" }, - { url = "https://files.pythonhosted.org/packages/42/c5/4d2ed3ca6e33617fc5624467da353337f06e7f637707478903c785bd8e20/fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82", size = 2947288, upload-time = "2026-03-13T13:53:59.397Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e9/7ab11ddfda48ed0f89b13380e5595ba572619c27077be0b2c447a63ff351/fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260", size = 2449023, upload-time = "2026-03-13T13:54:01.642Z" }, - { url = "https://files.pythonhosted.org/packages/b2/10/a800fa090b5e8819942e54e19b55fc7c21fe14a08757c3aa3ca8db358939/fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4", size = 5137599, upload-time = "2026-03-13T13:54:04.495Z" }, - { url = "https://files.pythonhosted.org/packages/37/dc/8ccd45033fffd74deb6912fa1ca524643f584b94c87a16036855b498a1ed/fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b", size = 4920933, upload-time = "2026-03-13T13:54:07.557Z" }, - { url = "https://files.pythonhosted.org/packages/99/eb/e618adefb839598d25ac8136cd577925d6c513dc0d931d93b8af956210f0/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87", size = 5016232, upload-time = "2026-03-13T13:54:10.611Z" }, - { url = "https://files.pythonhosted.org/packages/d9/5f/9b5c9bfaa8ec82def8d8168c4f13615990d6ce5996fe52bd49bfb5e05134/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c", size = 5042987, upload-time = "2026-03-13T13:54:13.569Z" }, - { url = "https://files.pythonhosted.org/packages/90/aa/dfbbe24c6a6afc5c203d90cc0343e24bcbb09e76d67c4d6eef8c2558d7ba/fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a", size = 2348021, upload-time = "2026-03-13T13:54:16.98Z" }, - { url = "https://files.pythonhosted.org/packages/13/6f/ae9c4e4dd417948407b680855c2c7790efb52add6009aaecff1e3bc50e8e/fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e", size = 2414147, upload-time = "2026-03-13T13:54:19.416Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" }, -] - -[[package]] -name = "fqdn" -version = "1.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/3e/a80a8c077fd798951169626cde3e239adeba7dab75deb3555716415bd9b0/fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f", size = 6015, upload-time = "2021-03-11T07:16:29.08Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" }, -] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "hatch" -version = "1.16.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "backports-zstd", marker = "python_full_version < '3.14'" }, - { name = "click" }, - { name = "hatchling" }, - { name = "httpx" }, - { name = "hyperlink" }, - { name = "keyring" }, - { name = "packaging" }, - { name = "pexpect" }, - { name = "platformdirs" }, - { name = "pyproject-hooks" }, - { name = "python-discovery" }, - { name = "rich" }, - { name = "shellingham" }, - { name = "tomli-w" }, - { name = "tomlkit" }, - { name = "userpath" }, - { name = "uv" }, - { name = "virtualenv" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d2/02/ce9c4c439fa3f195b21b4b5bb18b44d1076297c86477ef7e3d2de6064ec3/hatch-1.16.5.tar.gz", hash = "sha256:57bdeeaa72577859ce37091a5449583875331c06f9cb6af9077947ad40b3a1de", size = 5220741, upload-time = "2026-02-27T18:45:31.21Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/8a/11ae7e271870f0ad8fa0012e4265982bebe0fdc21766b161fb8b8fc3aefc/hatch-1.16.5-py3-none-any.whl", hash = "sha256:d9b8047f2cd10d3349eb6e8f278ad728a04f91495aace305c257d5c2747188fb", size = 141269, upload-time = "2026-02-27T18:45:29.573Z" }, -] - -[[package]] -name = "hatchling" -version = "1.29.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, - { name = "pathspec" }, - { name = "pluggy" }, - { name = "trove-classifiers" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cf/9c/b4cfe330cd4f49cff17fd771154730555fa4123beb7f292cf0098b4e6c20/hatchling-1.29.0.tar.gz", hash = "sha256:793c31816d952cee405b83488ce001c719f325d9cda69f1fc4cd750527640ea6", size = 55656, upload-time = "2026-02-23T19:42:06.539Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/8a/44032265776062a89171285ede55a0bdaadc8ac00f27f0512a71a9e3e1c8/hatchling-1.29.0-py3-none-any.whl", hash = "sha256:50af9343281f34785fab12da82e445ed987a6efb34fd8c2fc0f6e6630dbcc1b0", size = 76356, upload-time = "2026-02-23T19:42:05.197Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - -[[package]] -name = "hyperlink" -version = "21.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3a/51/1947bd81d75af87e3bb9e34593a4cf118115a8feb451ce7a69044ef1412e/hyperlink-21.0.0.tar.gz", hash = "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b", size = 140743, upload-time = "2021-01-08T05:51:20.972Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/aa/8caf6a0a3e62863cbb9dab27135660acba46903b703e224f14f447e57934/hyperlink-21.0.0-py2.py3-none-any.whl", hash = "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4", size = 74638, upload-time = "2021-01-08T05:51:22.906Z" }, -] - -[[package]] -name = "id" -version = "1.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6d/04/c2156091427636080787aac190019dc64096e56a23b7364d3c1764ee3a06/id-1.6.1.tar.gz", hash = "sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069", size = 18088, upload-time = "2026-02-04T16:19:41.26Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl", hash = "sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca", size = 14689, upload-time = "2026-02-04T16:19:40.051Z" }, -] - -[[package]] -name = "idna" -version = "3.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, -] - -[[package]] -name = "imagesize" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - -[[package]] -name = "ipykernel" -version = "7.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "appnope", marker = "sys_platform == 'darwin'" }, - { name = "comm" }, - { name = "debugpy" }, - { name = "ipython" }, - { name = "jupyter-client" }, - { name = "jupyter-core" }, - { name = "matplotlib-inline" }, - { name = "nest-asyncio" }, - { name = "packaging" }, - { name = "psutil" }, - { name = "pyzmq" }, - { name = "tornado" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ca/8d/b68b728e2d06b9e0051019640a40a9eb7a88fcd82c2e1b5ce70bef5ff044/ipykernel-7.2.0.tar.gz", hash = "sha256:18ed160b6dee2cbb16e5f3575858bc19d8f1fe6046a9a680c708494ce31d909e", size = 176046, upload-time = "2026-02-06T16:43:27.403Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/82/b9/e73d5d9f405cba7706c539aa8b311b49d4c2f3d698d9c12f815231169c71/ipykernel-7.2.0-py3-none-any.whl", hash = "sha256:3bbd4420d2b3cc105cbdf3756bfc04500b1e52f090a90716851f3916c62e1661", size = 118788, upload-time = "2026-02-06T16:43:25.149Z" }, -] - -[[package]] -name = "ipython" -version = "9.13.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "ipython-pygments-lexers" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "psutil" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/87cda5842cf5c31837c06ddb588e11c3c35d8ece89b7a0108c06b8c9b00a/ipython-9.13.0.tar.gz", hash = "sha256:7e834b6afc99f020e3f05966ced34792f40267d64cb1ea9043886dab0dde5967", size = 4430549, upload-time = "2026-04-24T12:24:55.221Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/86/3060e8029b7cc505cce9a0137431dda81d0a3fde93a8f0f50ee0bf37a795/ipython-9.13.0-py3-none-any.whl", hash = "sha256:57f9d4639e20818d328d287c7b549af3d05f12486ea8f2e7f73e52a36ec4d201", size = 627274, upload-time = "2026-04-24T12:24:53.038Z" }, -] - -[[package]] -name = "ipython-pygments-lexers" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, -] - -[[package]] -name = "ipywidgets" -version = "8.1.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "comm" }, - { name = "ipython" }, - { name = "jupyterlab-widgets" }, - { name = "traitlets" }, - { name = "widgetsnbextension" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4c/ae/c5ce1edc1afe042eadb445e95b0671b03cee61895264357956e61c0d2ac0/ipywidgets-8.1.8.tar.gz", hash = "sha256:61f969306b95f85fba6b6986b7fe45d73124d1d9e3023a8068710d47a22ea668", size = 116739, upload-time = "2025-11-01T21:18:12.393Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl", hash = "sha256:ecaca67aed704a338f88f67b1181b58f821ab5dc89c1f0f5ef99db43c1c2921e", size = 139808, upload-time = "2025-11-01T21:18:10.956Z" }, -] - -[[package]] -name = "isoduration" -version = "20.11.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "arrow" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9", size = 11649, upload-time = "2020-11-01T11:00:00.312Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321, upload-time = "2020-11-01T10:59:58.02Z" }, -] - -[[package]] -name = "isort" -version = "8.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, -] - -[[package]] -name = "jaraco-classes" -version = "3.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "more-itertools" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, -] - -[[package]] -name = "jaraco-context" -version = "6.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, -] - -[[package]] -name = "jaraco-functools" -version = "4.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "more-itertools" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" }, -] - -[[package]] -name = "jedi" -version = "0.19.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "parso" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, -] - -[[package]] -name = "jeepney" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, -] - -[[package]] -name = "jinja2" -version = "3.1.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, -] - -[[package]] -name = "joblib" -version = "1.5.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, -] - -[[package]] -name = "json5" -version = "0.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9c/4b/6f8906aaf67d501e259b0adab4d312945bb7211e8b8d4dcc77c92320edaa/json5-0.14.0.tar.gz", hash = "sha256:b3f492fad9f6cdbced8b7d40b28b9b1c9701c5f561bef0d33b81c2ff433fefcb", size = 52656, upload-time = "2026-03-27T22:50:48.108Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/42/cf027b4ac873b076189d935b135397675dac80cb29acb13e1ab86ad6c631/json5-0.14.0-py3-none-any.whl", hash = "sha256:56cf861bab076b1178eb8c92e1311d273a9b9acea2ccc82c276abf839ebaef3a", size = 36271, upload-time = "2026-03-27T22:50:47.073Z" }, -] - -[[package]] -name = "jsonpickle" -version = "4.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/a6/d07afcfdef402900229bcca795f80506b207af13a838d4d99ad45abf530c/jsonpickle-4.1.1.tar.gz", hash = "sha256:f86e18f13e2b96c1c1eede0b7b90095bbb61d99fedc14813c44dc2f361dbbae1", size = 316885, upload-time = "2025-06-02T20:36:11.57Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/73/04df8a6fa66d43a9fd45c30f283cc4afff17da671886e451d52af60bdc7e/jsonpickle-4.1.1-py3-none-any.whl", hash = "sha256:bb141da6057898aa2438ff268362b126826c812a1721e31cf08a6e142910dc91", size = 47125, upload-time = "2025-06-02T20:36:08.647Z" }, -] - -[[package]] -name = "jsonpointer" -version = "3.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, -] - -[[package]] -name = "jsonschema" -version = "4.26.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "jsonschema-specifications" }, - { name = "referencing" }, - { name = "rpds-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, -] - -[package.optional-dependencies] -format-nongpl = [ - { name = "fqdn" }, - { name = "idna" }, - { name = "isoduration" }, - { name = "jsonpointer" }, - { name = "rfc3339-validator" }, - { name = "rfc3986-validator" }, - { name = "rfc3987-syntax" }, - { name = "uri-template" }, - { name = "webcolors" }, -] - -[[package]] -name = "jsonschema-specifications" -version = "2025.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "referencing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, -] - -[[package]] -name = "jupyter" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ipykernel" }, - { name = "ipywidgets" }, - { name = "jupyter-console" }, - { name = "jupyterlab" }, - { name = "nbconvert" }, - { name = "notebook" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/58/f3/af28ea964ab8bc1e472dba2e82627d36d470c51f5cd38c37502eeffaa25e/jupyter-1.1.1.tar.gz", hash = "sha256:d55467bceabdea49d7e3624af7e33d59c37fff53ed3a350e1ac957bed731de7a", size = 5714959, upload-time = "2024-08-30T07:15:48.299Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/64/285f20a31679bf547b75602702f7800e74dbabae36ef324f716c02804753/jupyter-1.1.1-py2.py3-none-any.whl", hash = "sha256:7a59533c22af65439b24bbe60373a4e95af8f16ac65a6c00820ad378e3f7cc83", size = 2657, upload-time = "2024-08-30T07:15:47.045Z" }, -] - -[[package]] -name = "jupyter-client" -version = "8.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jupyter-core" }, - { name = "python-dateutil" }, - { name = "pyzmq" }, - { name = "tornado" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/05/e4/ba649102a3bc3fbca54e7239fb924fd434c766f855693d86de0b1f2bec81/jupyter_client-8.8.0.tar.gz", hash = "sha256:d556811419a4f2d96c869af34e854e3f059b7cc2d6d01a9cd9c85c267691be3e", size = 348020, upload-time = "2026-01-08T13:55:47.938Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/0b/ceb7694d864abc0a047649aec263878acb9f792e1fec3e676f22dc9015e3/jupyter_client-8.8.0-py3-none-any.whl", hash = "sha256:f93a5b99c5e23a507b773d3a1136bd6e16c67883ccdbd9a829b0bbdb98cd7d7a", size = 107371, upload-time = "2026-01-08T13:55:45.562Z" }, -] - -[[package]] -name = "jupyter-console" -version = "6.6.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ipykernel" }, - { name = "ipython" }, - { name = "jupyter-client" }, - { name = "jupyter-core" }, - { name = "prompt-toolkit" }, - { name = "pygments" }, - { name = "pyzmq" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bd/2d/e2fd31e2fc41c14e2bcb6c976ab732597e907523f6b2420305f9fc7fdbdb/jupyter_console-6.6.3.tar.gz", hash = "sha256:566a4bf31c87adbfadf22cdf846e3069b59a71ed5da71d6ba4d8aaad14a53539", size = 34363, upload-time = "2023-03-06T14:13:31.02Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/77/71d78d58f15c22db16328a476426f7ac4a60d3a5a7ba3b9627ee2f7903d4/jupyter_console-6.6.3-py3-none-any.whl", hash = "sha256:309d33409fcc92ffdad25f0bcdf9a4a9daa61b6f341177570fdac03de5352485", size = 24510, upload-time = "2023-03-06T14:13:28.229Z" }, -] - -[[package]] -name = "jupyter-core" -version = "5.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "platformdirs" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, -] - -[[package]] -name = "jupyter-events" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jsonschema", extra = ["format-nongpl"] }, - { name = "packaging" }, - { name = "python-json-logger" }, - { name = "pyyaml" }, - { name = "referencing" }, - { name = "rfc3339-validator" }, - { name = "rfc3986-validator" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/18/f8/475c4241b2b75af0deaae453ed003c6c851766dbc44d332d8baf245dc931/jupyter_events-0.12.1.tar.gz", hash = "sha256:faff25f77218335752f35f23c5fe6e4a392a7bd99a5939ccb9b8fbf594636cf3", size = 62854, upload-time = "2026-04-20T23:17:50.66Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/6c/6fcde0c8f616ed360ffd3587f7db9e225a7e62b583a04494d2f069cf64ea/jupyter_events-0.12.1-py3-none-any.whl", hash = "sha256:c366585253f537a627da52fa7ca7410c5b5301fe893f511e7b077c2d93ec8bcf", size = 19512, upload-time = "2026-04-20T23:17:48.927Z" }, -] - -[[package]] -name = "jupyter-lsp" -version = "2.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jupyter-server" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/36/ff/1e4a61f5170a9a1d978f3ac3872449de6c01fc71eaf89657824c878b1549/jupyter_lsp-2.3.1.tar.gz", hash = "sha256:fdf8a4aa7d85813976d6e29e95e6a2c8f752701f926f2715305249a3829805a6", size = 55677, upload-time = "2026-04-02T08:10:06.749Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/23/e8/9d61dcbd1dce8ef418f06befd4ac084b4720429c26b0b1222bc218685eff/jupyter_lsp-2.3.1-py3-none-any.whl", hash = "sha256:71b954d834e85ff3096400554f2eefaf7fe37053036f9a782b0f7c5e42dadb81", size = 77513, upload-time = "2026-04-02T08:10:01.753Z" }, -] - -[[package]] -name = "jupyter-server" -version = "2.20.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "argon2-cffi" }, - { name = "jinja2" }, - { name = "jupyter-client" }, - { name = "jupyter-core" }, - { name = "jupyter-events" }, - { name = "jupyter-server-terminals" }, - { name = "nbconvert" }, - { name = "nbformat" }, - { name = "packaging" }, - { name = "prometheus-client" }, - { name = "pywinpty", marker = "os_name == 'nt'" }, - { name = "pyzmq" }, - { name = "send2trash" }, - { name = "terminado" }, - { name = "tornado" }, - { name = "traitlets" }, - { name = "websocket-client" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6b/dc/db3a582633170186f8c8b31298d7eb26ad0eb031a1f53476c258b64eed05/jupyter_server-2.20.0.tar.gz", hash = "sha256:b5778ba337d8015a3dc2b80803ecdd5ac18d3797fddf61a50ea5fb472b4ebe14", size = 756523, upload-time = "2026-06-17T12:09:09.435Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/71/8c002223e873a870f5c41dc69b0a7c922301123e4a31d5d01ecb700aef77/jupyter_server-2.20.0-py3-none-any.whl", hash = "sha256:c3b67c93c471e947c18b5026f04f21614218adb706df8f48227d3ee8e0a7cdcc", size = 393143, upload-time = "2026-06-17T12:09:07.234Z" }, -] - -[[package]] -name = "jupyter-server-terminals" -version = "0.5.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pywinpty", marker = "os_name == 'nt'" }, - { name = "terminado" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f4/a7/bcd0a9b0cbba88986fe944aaaf91bfda603e5a50bda8ed15123f381a3b2f/jupyter_server_terminals-0.5.4.tar.gz", hash = "sha256:bbda128ed41d0be9020349f9f1f2a4ab9952a73ed5f5ac9f1419794761fb87f5", size = 31770, upload-time = "2026-01-14T16:53:20.213Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/2d/6674563f71c6320841fc300911a55143925112a72a883e2ca71fba4c618d/jupyter_server_terminals-0.5.4-py3-none-any.whl", hash = "sha256:55be353fc74a80bc7f3b20e6be50a55a61cd525626f578dcb66a5708e2007d14", size = 13704, upload-time = "2026-01-14T16:53:18.738Z" }, -] - -[[package]] -name = "jupyterlab" -version = "4.5.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "async-lru" }, - { name = "httpx" }, - { name = "ipykernel" }, - { name = "jinja2" }, - { name = "jupyter-core" }, - { name = "jupyter-lsp" }, - { name = "jupyter-server" }, - { name = "jupyterlab-server" }, - { name = "notebook-shim" }, - { name = "packaging" }, - { name = "setuptools" }, - { name = "tornado" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e8/52/a8d4895bef501ffeb6af448e8bf7079541c7772978211963aa653518c2d9/jupyterlab-4.5.9.tar.gz", hash = "sha256:dd79a073fecae7a39066ea99e4627ed6c76269ac926e95a810e1e1df6358d865", size = 23994445, upload-time = "2026-06-17T15:42:16.406Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/bb/2f9b425062416fba58f580c9b89c3b07277ccdf0a292501fedbca8ea00ea/jupyterlab-4.5.9-py3-none-any.whl", hash = "sha256:5ff0f908e8ac0afbed32b106fdef360f101c0a6654d1bf4a81e98a293ae1b336", size = 12449803, upload-time = "2026-06-17T15:42:12.18Z" }, -] - -[[package]] -name = "jupyterlab-pygments" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d", size = 512900, upload-time = "2023-11-23T09:26:37.44Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884, upload-time = "2023-11-23T09:26:34.325Z" }, -] - -[[package]] -name = "jupyterlab-server" -version = "2.28.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "babel" }, - { name = "jinja2" }, - { name = "json5" }, - { name = "jsonschema" }, - { name = "jupyter-server" }, - { name = "packaging" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d6/2c/90153f189e421e93c4bb4f9e3f59802a1f01abd2ac5cf40b152d7f735232/jupyterlab_server-2.28.0.tar.gz", hash = "sha256:35baa81898b15f93573e2deca50d11ac0ae407ebb688299d3a5213265033712c", size = 76996, upload-time = "2025-10-22T13:59:18.37Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/07/a000fe835f76b7e1143242ab1122e6362ef1c03f23f83a045c38859c2ae0/jupyterlab_server-2.28.0-py3-none-any.whl", hash = "sha256:e4355b148fdcf34d312bbbc80f22467d6d20460e8b8736bf235577dd18506968", size = 59830, upload-time = "2025-10-22T13:59:16.767Z" }, -] - -[[package]] -name = "jupyterlab-widgets" -version = "3.0.16" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/2d/ef58fed122b268c69c0aa099da20bc67657cdfb2e222688d5731bd5b971d/jupyterlab_widgets-3.0.16.tar.gz", hash = "sha256:423da05071d55cf27a9e602216d35a3a65a3e41cdf9c5d3b643b814ce38c19e0", size = 897423, upload-time = "2025-11-01T21:11:29.724Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl", hash = "sha256:45fa36d9c6422cf2559198e4db481aa243c7a32d9926b500781c830c80f7ecf8", size = 914926, upload-time = "2025-11-01T21:11:28.008Z" }, -] - -[[package]] -name = "keyring" -version = "25.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jaraco-classes" }, - { name = "jaraco-context" }, - { name = "jaraco-functools" }, - { name = "jeepney", marker = "sys_platform == 'linux'" }, - { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, - { name = "secretstorage", marker = "sys_platform == 'linux'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, -] - -[[package]] -name = "kiwisolver" -version = "1.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, - { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, - { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, - { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, - { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, - { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, - { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, - { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, - { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, - { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, - { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, - { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, - { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, - { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, - { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, - { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, - { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, - { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, - { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, - { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, - { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, - { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, - { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, - { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, - { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, - { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, - { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, - { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, - { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, - { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, - { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, - { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, - { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, - { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, - { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, - { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, - { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, - { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, - { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, - { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, - { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, - { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, - { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, - { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, - { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, - { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, - { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, - { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, - { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, - { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, - { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, - { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, - { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, - { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, - { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, - { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, - { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, - { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, - { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, - { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, - { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, - { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, - { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, - { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, - { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, - { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, - { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, -] - -[[package]] -name = "lark" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, -] - -[[package]] -name = "levenshtein" -version = "0.27.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "rapidfuzz" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/82/56/dcf68853b062e3b94bdc3d011cc4198779abc5b9dc134146a062920ce2e2/levenshtein-0.27.3.tar.gz", hash = "sha256:1ac326b2c84215795163d8a5af471188918b8797b4953ec87aaba22c9c1f9fc0", size = 393269, upload-time = "2025-11-01T12:14:31.04Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/8e/3be9d8e0245704e3af5258fb6cb157c3d59902e1351e95edf6ed8a8c0434/levenshtein-0.27.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2de7f095b0ca8e44de9de986ccba661cd0dec3511c751b499e76b60da46805e9", size = 169622, upload-time = "2025-11-01T12:13:10.026Z" }, - { url = "https://files.pythonhosted.org/packages/a6/42/a2b2fda5e8caf6ecd5aac142f946a77574a3961e65da62c12fd7e48e5cb1/levenshtein-0.27.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9b8b29e5d5145a3c958664c85151b1bb4b26e4ca764380b947e6a96a321217c", size = 159183, upload-time = "2025-11-01T12:13:11.197Z" }, - { url = "https://files.pythonhosted.org/packages/eb/c4/f083fabbd61c449752df1746533538f4a8629e8811931b52f66e6c4290ad/levenshtein-0.27.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fc975465a51b1c5889eadee1a583b81fba46372b4b22df28973e49e8ddb8f54a", size = 133120, upload-time = "2025-11-01T12:13:12.363Z" }, - { url = "https://files.pythonhosted.org/packages/4e/e5/b6421e04cb0629615b8efd6d4d167dd2b1afb5097b87bb83cd992004dcca/levenshtein-0.27.3-cp312-cp312-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:57573ed885118554770979fdee584071b66103f6d50beddeabb54607a1213d81", size = 114988, upload-time = "2025-11-01T12:13:13.486Z" }, - { url = "https://files.pythonhosted.org/packages/e5/77/39ee0e8d3028e90178e1031530ccc98563f8f2f0d905ec784669dcf0fa90/levenshtein-0.27.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23aff800a6dd5d91bb3754a6092085aa7ad46b28e497682c155c74f681cfaa2d", size = 153346, upload-time = "2025-11-01T12:13:14.744Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/c0f367bbd260dbd7a4e134fd21f459e0f5eac43deac507952b46a1d8a93a/levenshtein-0.27.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c08a952432b8ad9dccb145f812176db94c52cda732311ddc08d29fd3bf185b0a", size = 1114538, upload-time = "2025-11-01T12:13:15.851Z" }, - { url = "https://files.pythonhosted.org/packages/d8/ef/ae71433f7b4db0bd2af7974785e36cdec899919203fb82e647c5a6109c07/levenshtein-0.27.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:3bfcb2d78ab9cc06a1e75da8fcfb7a430fe513d66cfe54c07e50f32805e5e6db", size = 1009734, upload-time = "2025-11-01T12:13:17.212Z" }, - { url = "https://files.pythonhosted.org/packages/27/dc/62c28b812dcb0953fc32ab7adf3d0e814e43c8560bb28d9269a44d874adf/levenshtein-0.27.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba7235f6dcb31a217247468295e2dd4c6c1d3ac81629dc5d355d93e1a5f4c185", size = 1185581, upload-time = "2025-11-01T12:13:18.661Z" }, - { url = "https://files.pythonhosted.org/packages/56/e8/2e7ab9c565793220edb8e5432f9a846386a157075bdd032a90e9585bce38/levenshtein-0.27.3-cp312-cp312-win32.whl", hash = "sha256:ea80d70f1d18c161a209be556b9094968627cbaae620e102459ef9c320a98cbb", size = 84660, upload-time = "2025-11-01T12:13:19.87Z" }, - { url = "https://files.pythonhosted.org/packages/2c/a6/907a1fc8587dc91c40156973e09d106ab064c06eb28dc4700ba0fe54d654/levenshtein-0.27.3-cp312-cp312-win_amd64.whl", hash = "sha256:fbaa1219d9b2d955339a37e684256a861e9274a3fe3a6ee1b8ea8724c3231ed9", size = 94909, upload-time = "2025-11-01T12:13:21.323Z" }, - { url = "https://files.pythonhosted.org/packages/d5/d6/e04f0ddf6a71df3cdd1817b71703490ac874601ed460b2af172d3752c321/levenshtein-0.27.3-cp312-cp312-win_arm64.whl", hash = "sha256:2edbaa84f887ea1d9d8e4440af3fdda44769a7855d581c6248d7ee51518402a8", size = 87358, upload-time = "2025-11-01T12:13:22.393Z" }, - { url = "https://files.pythonhosted.org/packages/3e/f2/162e9ea7490b36bbf05776c8e3a8114c75aa78546ddda8e8f36731db3da6/levenshtein-0.27.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e55aa9f9453fd89d4a9ff1f3c4a650b307d5f61a7eed0568a52fbd2ff2eba107", size = 169230, upload-time = "2025-11-01T12:13:23.735Z" }, - { url = "https://files.pythonhosted.org/packages/01/2d/7316ba7f94e3d60e89bd120526bc71e4812866bb7162767a2a10f73f72c5/levenshtein-0.27.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae4d484453c48939ecd01c5c213530c68dd5cd6e5090f0091ef69799ec7a8a9f", size = 158643, upload-time = "2025-11-01T12:13:25.549Z" }, - { url = "https://files.pythonhosted.org/packages/5e/87/85433cb1e51c45016f061d96fea3106b6969f700e2cbb56c15de82d0deeb/levenshtein-0.27.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d18659832567ee387b266be390da0de356a3aa6cf0e8bc009b6042d8188e131f", size = 132881, upload-time = "2025-11-01T12:13:26.822Z" }, - { url = "https://files.pythonhosted.org/packages/40/1c/3ce66c9a7da169a43dd89146d69df9dec935e6f86c70c6404f48d1291d2c/levenshtein-0.27.3-cp313-cp313-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027b3d142cc8ea2ab4e60444d7175f65a94dde22a54382b2f7b47cc24936eb53", size = 114650, upload-time = "2025-11-01T12:13:28.382Z" }, - { url = "https://files.pythonhosted.org/packages/73/60/7138e98884ca105c76ef192f5b43165d6eac6f32b432853ebe9f09ee50c9/levenshtein-0.27.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdca6989368cc64f347f0423c528520f12775b812e170a0eb0c10e4c9b0f3ff", size = 153127, upload-time = "2025-11-01T12:13:29.781Z" }, - { url = "https://files.pythonhosted.org/packages/df/8f/664ac8b83026d7d1382866b68babae17e92b7b6ff8dc3c6205c0066b8ce1/levenshtein-0.27.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fa00ab389386032b02a1c9050ec3c6aa824d2bbcc692548fdc44a46b71c058c6", size = 1114602, upload-time = "2025-11-01T12:13:31.651Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c8/8905d96cf2d7ed6af7eb39a8be0925ef335729473c1e9d1f56230ecaffc5/levenshtein-0.27.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:691c9003c6c481b899a5c2f72e8ce05a6d956a9668dc75f2a3ce9f4381a76dc6", size = 1008036, upload-time = "2025-11-01T12:13:33.006Z" }, - { url = "https://files.pythonhosted.org/packages/c7/57/01c37608121380a6357a297625562adad1c1fc8058d4f62279b735108927/levenshtein-0.27.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:12f7fc8bf0c24492fe97905348e020b55b9fc6dbaab7cd452566d1a466cb5e15", size = 1185338, upload-time = "2025-11-01T12:13:34.452Z" }, - { url = "https://files.pythonhosted.org/packages/dd/57/bceab41d40b58dee7927a8d1d18ed3bff7c95c5e530fb60093ce741a8c26/levenshtein-0.27.3-cp313-cp313-win32.whl", hash = "sha256:9f4872e4e19ee48eed39f214eea4eca42e5ef303f8a4a488d8312370674dbf3a", size = 84562, upload-time = "2025-11-01T12:13:35.858Z" }, - { url = "https://files.pythonhosted.org/packages/42/1d/74f1ff589bb687d0cad2bbdceef208dc070f56d1e38a3831da8c00bf13bb/levenshtein-0.27.3-cp313-cp313-win_amd64.whl", hash = "sha256:83aa2422e9a9af2c9d3e56a53e3e8de6bae58d1793628cae48c4282577c5c2c6", size = 94658, upload-time = "2025-11-01T12:13:36.963Z" }, - { url = "https://files.pythonhosted.org/packages/21/3c/22c86d3c8f254141096fd6089d2e9fdf98b1472c7a5d79d36d3557ec2d83/levenshtein-0.27.3-cp313-cp313-win_arm64.whl", hash = "sha256:d4adaf1edbcf38c3f2e290b52f4dcb5c6deff20308c26ef1127a106bc2d23e9f", size = 86929, upload-time = "2025-11-01T12:13:37.997Z" }, - { url = "https://files.pythonhosted.org/packages/0e/bc/9b7cf1b5fa098b86844d42de22549304699deff309c5c9e28b9a3fc4076a/levenshtein-0.27.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:272e24764b8210337b65a1cfd69ce40df5d2de1a3baf1234e7f06d2826ba2e7a", size = 170360, upload-time = "2025-11-01T12:13:39.019Z" }, - { url = "https://files.pythonhosted.org/packages/dc/95/997f2c83bd4712426bf0de8143b5e4403c7ebbafb5d1271983e774de3ae7/levenshtein-0.27.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:329a8e748a4e14d56daaa11f07bce3fde53385d05bad6b3f6dd9ee7802cdc915", size = 159098, upload-time = "2025-11-01T12:13:40.17Z" }, - { url = "https://files.pythonhosted.org/packages/fc/96/123c3316ae2f72c73be4fba9756924af015da4c0e5b12804f5753c0ee511/levenshtein-0.27.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5fea1a9c6b9cc8729e467e2174b4359ff6bac27356bb5f31898e596b4ce133a", size = 136655, upload-time = "2025-11-01T12:13:41.262Z" }, - { url = "https://files.pythonhosted.org/packages/45/72/a3180d437736b1b9eacc3100be655a756deafb91de47c762d40eb45a9d91/levenshtein-0.27.3-cp313-cp313t-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3a61aa825819b6356555091d8a575d1235bd9c3753a68316a261af4856c3b487", size = 117511, upload-time = "2025-11-01T12:13:42.647Z" }, - { url = "https://files.pythonhosted.org/packages/61/f9/ba7c546a4b99347938e6661104064ab6a3651c601d59f241ffdc37510ecc/levenshtein-0.27.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51de7a514e8183f0a82f2947d01b014d2391426543b1c076bf5a26328cec4e4", size = 155656, upload-time = "2025-11-01T12:13:44.208Z" }, - { url = "https://files.pythonhosted.org/packages/42/cd/5edd6e1e02c3e47c8121761756dd0f85f816b636f25509118b687e6b0f96/levenshtein-0.27.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:53cbf726d6e92040c9be7e594d959d496bd62597ea48eba9d96105898acbeafe", size = 1116689, upload-time = "2025-11-01T12:13:45.485Z" }, - { url = "https://files.pythonhosted.org/packages/95/67/25ca0119e0c6ec17226c72638f48ef8887124597ac48ad5da111c0b3a825/levenshtein-0.27.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:191b358afead8561c4fcfed22f83c13bb6c8da5f5789e277f0c5aa1c45ca612f", size = 1003166, upload-time = "2025-11-01T12:13:47.126Z" }, - { url = "https://files.pythonhosted.org/packages/45/64/ab216f3fb3cef1ee7e222665537f9340d828ef84c99409ba31f2ef2a3947/levenshtein-0.27.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ba1318d0635b834b8f0397014a7c43f007e65fce396a47614780c881bdff828b", size = 1189362, upload-time = "2025-11-01T12:13:48.627Z" }, - { url = "https://files.pythonhosted.org/packages/31/58/b150034858de0899a5a222974b6710618ebc0779a0695df070f7ab559a0b/levenshtein-0.27.3-cp313-cp313t-win32.whl", hash = "sha256:8dd9e1db6c3b35567043e155a686e4827c4aa28a594bd81e3eea84d3a1bd5875", size = 86149, upload-time = "2025-11-01T12:13:50.588Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c4/bbe46a11073641450200e6a604b3b62d311166e8061c492612a40e560e85/levenshtein-0.27.3-cp313-cp313t-win_amd64.whl", hash = "sha256:7813ecdac7a6223264ebfea0c8d69959c43d21a99694ef28018d22c4265c2af6", size = 96685, upload-time = "2025-11-01T12:13:51.641Z" }, - { url = "https://files.pythonhosted.org/packages/23/65/30b362ad9bfc1085741776a08b6ddee3f434e9daac2920daaee2e26271bf/levenshtein-0.27.3-cp313-cp313t-win_arm64.whl", hash = "sha256:8f05a0d23d13a6f802c7af595d0e43f5b9b98b6ed390cec7a35cb5d6693b882b", size = 88538, upload-time = "2025-11-01T12:13:52.757Z" }, - { url = "https://files.pythonhosted.org/packages/f3/e1/2f705da403f865a5fa3449b155738dc9c53021698fd6926253a9af03180b/levenshtein-0.27.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a6728bfae9a86002f0223576675fc7e2a6e7735da47185a1d13d1eaaa73dd4be", size = 169457, upload-time = "2025-11-01T12:13:53.778Z" }, - { url = "https://files.pythonhosted.org/packages/76/2c/bb6ef359e007fe7b6b3195b68a94f4dd3ecd1885ee337ee8fbd4df55996f/levenshtein-0.27.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8e5037c4a6f97a238e24aad6f98a1e984348b7931b1b04b6bd02bd4f8238150d", size = 158680, upload-time = "2025-11-01T12:13:55.005Z" }, - { url = "https://files.pythonhosted.org/packages/51/7b/de1999f4cf1cfebc3fbbf03a6d58498952d6560d9798af4b0a566e6b6f30/levenshtein-0.27.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6cf5ecf9026bf24cf66ad019c6583f50058fae3e1b3c20e8812455b55d597f1", size = 133167, upload-time = "2025-11-01T12:13:56.426Z" }, - { url = "https://files.pythonhosted.org/packages/c7/da/aaa7f3a0a8ae8744b284043653652db3d7d93595517f9ed8158c03287692/levenshtein-0.27.3-cp314-cp314-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9285084bd2fc19adb47dab54ed4a71f57f78fe0d754e4a01e3c75409a25aed24", size = 114530, upload-time = "2025-11-01T12:13:57.883Z" }, - { url = "https://files.pythonhosted.org/packages/29/ce/ed422816fb30ffa3bc11597b30d5deca06b4a1388707a04215da73c65b53/levenshtein-0.27.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce3bbbe92172a08b599d79956182c6b7ab6ec8d4adbe7237417a363b968ad87b", size = 153325, upload-time = "2025-11-01T12:13:59.318Z" }, - { url = "https://files.pythonhosted.org/packages/d9/5a/a225477a0bda154f19f1c07a5e35500d631ae25dfd620b479027d79f0d4c/levenshtein-0.27.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9dac48fab9d166ca90e12fb6cf6c7c8eb9c41aacf7136584411e20f7f136f745", size = 1114956, upload-time = "2025-11-01T12:14:00.543Z" }, - { url = "https://files.pythonhosted.org/packages/ca/c4/a1be1040f3cce516a5e2be68453fd0c32ac63b2e9d31f476723fd8002c09/levenshtein-0.27.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d37a83722dc5326c93d17078e926c4732dc4f3488dc017c6839e34cd16af92b7", size = 1007610, upload-time = "2025-11-01T12:14:02.036Z" }, - { url = "https://files.pythonhosted.org/packages/86/d7/6f50e8a307e0c2befd819b481eb3a4c2eacab3dd8101982423003fac8ea3/levenshtein-0.27.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3466cb8294ce586e49dd467560a153ab8d296015c538223f149f9aefd3d9f955", size = 1185379, upload-time = "2025-11-01T12:14:03.385Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e5/5d8fb1b3ebd5735f53221bf95c923066bcfc132234925820128f7eee5b47/levenshtein-0.27.3-cp314-cp314-win32.whl", hash = "sha256:c848bf2457b268672b7e9e73b44f18f49856420ac50b2564cf115a6e4ef82688", size = 86328, upload-time = "2025-11-01T12:14:04.74Z" }, - { url = "https://files.pythonhosted.org/packages/30/82/8a9ccbdb4e38bd4d516f2804999dccb8cb4bcb4e33f52851735da0c73ea7/levenshtein-0.27.3-cp314-cp314-win_amd64.whl", hash = "sha256:742633f024362a4ed6ef9d7e75d68f74b041ae738985fcf55a0e6d1d4cade438", size = 96640, upload-time = "2025-11-01T12:14:06.24Z" }, - { url = "https://files.pythonhosted.org/packages/14/86/f9d15919f59f5d92c6baa500315e1fa0143a39d811427b83c54f038267ca/levenshtein-0.27.3-cp314-cp314-win_arm64.whl", hash = "sha256:9eed6851224b19e8d588ddb8eb8a4ae3c2dcabf3d1213985f0b94a67e517b1df", size = 89689, upload-time = "2025-11-01T12:14:07.379Z" }, - { url = "https://files.pythonhosted.org/packages/ed/f6/10f44975ae6dc3047b2cd260e3d4c3a5258b8d10690a42904115de24fc51/levenshtein-0.27.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:77de69a345c76227b51a4521cd85442eb3da54c7eb6a06663a20c058fc49e683", size = 170518, upload-time = "2025-11-01T12:14:09.196Z" }, - { url = "https://files.pythonhosted.org/packages/08/07/fa294a145a0c99a814a9a807614962c1ee0f5749ca691645980462027d5d/levenshtein-0.27.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:eba2756dc1f5b962b0ff80e49abb2153d5e809cc5e7fa5e85be9410ce474795d", size = 159097, upload-time = "2025-11-01T12:14:10.404Z" }, - { url = "https://files.pythonhosted.org/packages/ae/50/24bdf37813fc30f293e53b46022b091144f4737a6a66663d2235b311bb98/levenshtein-0.27.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c8fcb498287e971d84260f67808ff1a06b3f6212d80fea75cf5155db80606ff", size = 136650, upload-time = "2025-11-01T12:14:11.579Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a9/0399c7a190b277cdea3acc801129d9d30da57c3fa79519e7b8c3f080d86c/levenshtein-0.27.3-cp314-cp314t-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f067092c67464faab13e00a5c1a80da93baca8955d4d49579861400762e35591", size = 117515, upload-time = "2025-11-01T12:14:12.877Z" }, - { url = "https://files.pythonhosted.org/packages/bf/a4/1c27533e97578b385a4b8079abe8d1ce2e514717c761efbe4bf7bbd0ac2e/levenshtein-0.27.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92415f32c68491203f2855d05eef3277d376182d014cf0859c013c89f277fbbf", size = 155711, upload-time = "2025-11-01T12:14:13.985Z" }, - { url = "https://files.pythonhosted.org/packages/50/35/bbc26638394a72b1e31a685ec251c995ee66a630c7e5c86f98770928b632/levenshtein-0.27.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ef61eeaf1e0a42d7d947978d981fe4b9426b98b3dd8c1582c535f10dee044c3f", size = 1116692, upload-time = "2025-11-01T12:14:15.359Z" }, - { url = "https://files.pythonhosted.org/packages/cd/83/32fcf28b388f8dc6c36b54552b9bae289dab07d43df104893158c834cbcc/levenshtein-0.27.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:103bb2e9049d1aa0d1216dd09c1c9106ecfe7541bbdc1a0490b9357d42eec8f2", size = 1003167, upload-time = "2025-11-01T12:14:17.469Z" }, - { url = "https://files.pythonhosted.org/packages/d1/79/1fbf2877ec4b819f373a32ebe3c48a61ee810693593a6015108b0be97b78/levenshtein-0.27.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6a64ddd1986b2a4c468b09544382287315c53585eb067f6e200c337741e057ee", size = 1189417, upload-time = "2025-11-01T12:14:19.081Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ac/dad4e09f1f7459c64172e48e40ed2baf3aa92d38205bcbd1b4ff00853701/levenshtein-0.27.3-cp314-cp314t-win32.whl", hash = "sha256:957244f27dc284ccb030a8b77b8a00deb7eefdcd70052a4b1d96f375780ae9dc", size = 88144, upload-time = "2025-11-01T12:14:20.667Z" }, - { url = "https://files.pythonhosted.org/packages/c0/61/cd51dc8b8a382e17c559a9812734c3a9afc2dab7d36253516335ee16ae50/levenshtein-0.27.3-cp314-cp314t-win_amd64.whl", hash = "sha256:ccd7eaa6d8048c3ec07c93cfbcdefd4a3ae8c6aca3a370f2023ee69341e5f076", size = 98516, upload-time = "2025-11-01T12:14:21.786Z" }, - { url = "https://files.pythonhosted.org/packages/27/5e/3fb67e882c1fee01ebb7abc1c0a6669e5ff8acd060e93bfe7229e9ce6e4f/levenshtein-0.27.3-cp314-cp314t-win_arm64.whl", hash = "sha256:1d8520b89b7a27bb5aadbcc156715619bcbf556a8ac46ad932470945dca6e1bd", size = 91020, upload-time = "2025-11-01T12:14:22.944Z" }, -] - -[[package]] -name = "librt" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/eb/6b/3d5c13fb3e3c4f43206c8f9dfed13778c2ed4f000bacaa0b7ce3c402a265/librt-0.9.0.tar.gz", hash = "sha256:a0951822531e7aee6e0dfb556b30d5ee36bbe234faf60c20a16c01be3530869d", size = 184368, upload-time = "2026-04-09T16:06:26.173Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/90/89ddba8e1c20b0922783cd93ed8e64f34dc05ab59c38a9c7e313632e20ff/librt-0.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b3e3bc363f71bda1639a4ee593cb78f7fbfeacc73411ec0d4c92f00730010a4", size = 68332, upload-time = "2026-04-09T16:05:00.09Z" }, - { url = "https://files.pythonhosted.org/packages/a8/40/7aa4da1fb08bdeeb540cb07bfc8207cb32c5c41642f2594dbd0098a0662d/librt-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a09c2f5869649101738653a9b7ab70cf045a1105ac66cbb8f4055e61df78f2d", size = 70581, upload-time = "2026-04-09T16:05:01.213Z" }, - { url = "https://files.pythonhosted.org/packages/48/ac/73a2187e1031041e93b7e3a25aae37aa6f13b838c550f7e0f06f66766212/librt-0.9.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ca8e133d799c948db2ab1afc081c333a825b5540475164726dcbf73537e5c2f", size = 203984, upload-time = "2026-04-09T16:05:02.542Z" }, - { url = "https://files.pythonhosted.org/packages/5e/3d/23460d571e9cbddb405b017681df04c142fb1b04cbfce77c54b08e28b108/librt-0.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:603138ee838ee1583f1b960b62d5d0007845c5c423feb68e44648b1359014e27", size = 215762, upload-time = "2026-04-09T16:05:04.127Z" }, - { url = "https://files.pythonhosted.org/packages/de/1e/42dc7f8ab63e65b20640d058e63e97fd3e482c1edbda3570d813b4d0b927/librt-0.9.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4003f70c56a5addd6aa0897f200dd59afd3bf7bcd5b3cce46dd21f925743bc2", size = 230288, upload-time = "2026-04-09T16:05:05.883Z" }, - { url = "https://files.pythonhosted.org/packages/dc/08/ca812b6d8259ad9ece703397f8ad5c03af5b5fedfce64279693d3ce4087c/librt-0.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78042f6facfd98ecb25e9829c7e37cce23363d9d7c83bc5f72702c5059eb082b", size = 224103, upload-time = "2026-04-09T16:05:07.148Z" }, - { url = "https://files.pythonhosted.org/packages/b6/3f/620490fb2fa66ffd44e7f900254bc110ebec8dac6c1b7514d64662570e6f/librt-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a361c9434a64d70a7dbb771d1de302c0cc9f13c0bffe1cf7e642152814b35265", size = 232122, upload-time = "2026-04-09T16:05:08.386Z" }, - { url = "https://files.pythonhosted.org/packages/e9/83/12864700a1b6a8be458cf5d05db209b0d8e94ae281e7ec261dbe616597b4/librt-0.9.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:dd2c7e082b0b92e1baa4da28163a808672485617bc855cc22a2fd06978fa9084", size = 225045, upload-time = "2026-04-09T16:05:09.707Z" }, - { url = "https://files.pythonhosted.org/packages/fd/1b/845d339c29dc7dbc87a2e992a1ba8d28d25d0e0372f9a0a2ecebde298186/librt-0.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7e6274fd33fc5b2a14d41c9119629d3ff395849d8bcbc80cf637d9e8d2034da8", size = 227372, upload-time = "2026-04-09T16:05:10.942Z" }, - { url = "https://files.pythonhosted.org/packages/8d/fe/277985610269d926a64c606f761d58d3db67b956dbbf40024921e95e7fcb/librt-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5093043afb226ecfa1400120d1ebd4442b4f99977783e4f4f7248879009b227f", size = 248224, upload-time = "2026-04-09T16:05:12.254Z" }, - { url = "https://files.pythonhosted.org/packages/92/1b/ee486d244b8de6b8b5dbaefabe6bfdd4a72e08f6353edf7d16d27114da8d/librt-0.9.0-cp312-cp312-win32.whl", hash = "sha256:9edcc35d1cae9fd5320171b1a838c7da8a5c968af31e82ecc3dff30b4be0957f", size = 55986, upload-time = "2026-04-09T16:05:13.529Z" }, - { url = "https://files.pythonhosted.org/packages/89/7a/ba1737012308c17dc6d5516143b5dce9a2c7ba3474afd54e11f44a4d1ef3/librt-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc2917258e131ae5f958a4d872e07555b51cb7466a43433218061c74ef33745", size = 63260, upload-time = "2026-04-09T16:05:14.68Z" }, - { url = "https://files.pythonhosted.org/packages/36/e4/01752c113da15127f18f7bf11142f5640038f062407a611c059d0036c6aa/librt-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:90e6d5420fc8a300518d4d2288154ff45005e920425c22cbbfe8330f3f754bd9", size = 53694, upload-time = "2026-04-09T16:05:16.095Z" }, - { url = "https://files.pythonhosted.org/packages/5f/d7/1b3e26fffde1452d82f5666164858a81c26ebe808e7ae8c9c88628981540/librt-0.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f29b68cd9714531672db62cc54f6e8ff981900f824d13fa0e00749189e13778e", size = 68367, upload-time = "2026-04-09T16:05:17.243Z" }, - { url = "https://files.pythonhosted.org/packages/a5/5b/c61b043ad2e091fbe1f2d35d14795e545d0b56b03edaa390fa1dcee3d160/librt-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d5c8a5929ac325729f6119802070b561f4db793dffc45e9ac750992a4ed4d22", size = 70595, upload-time = "2026-04-09T16:05:18.471Z" }, - { url = "https://files.pythonhosted.org/packages/a3/22/2448471196d8a73370aa2f23445455dc42712c21404081fcd7a03b9e0749/librt-0.9.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:756775d25ec8345b837ab52effee3ad2f3b2dfd6bbee3e3f029c517bd5d8f05a", size = 204354, upload-time = "2026-04-09T16:05:19.593Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5e/39fc4b153c78cfd2c8a2dcb32700f2d41d2312aa1050513183be4540930d/librt-0.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8f5d00b49818f4e2b1667db994488b045835e0ac16fe2f924f3871bd2b8ac5", size = 216238, upload-time = "2026-04-09T16:05:20.868Z" }, - { url = "https://files.pythonhosted.org/packages/d7/42/bc2d02d0fa7badfa63aa8d6dcd8793a9f7ef5a94396801684a51ed8d8287/librt-0.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c81aef782380f0f13ead670aae01825eb653b44b046aa0e5ebbb79f76ed4aa11", size = 230589, upload-time = "2026-04-09T16:05:22.305Z" }, - { url = "https://files.pythonhosted.org/packages/c8/7b/e2d95cc513866373692aa5edf98080d5602dd07cabfb9e5d2f70df2f25f7/librt-0.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66b58fed90a545328e80d575467244de3741e088c1af928f0b489ebec3ef3858", size = 224610, upload-time = "2026-04-09T16:05:23.647Z" }, - { url = "https://files.pythonhosted.org/packages/31/d5/6cec4607e998eaba57564d06a1295c21b0a0c8de76e4e74d699e627bd98c/librt-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e78fb7419e07d98c2af4b8567b72b3eaf8cb05caad642e9963465569c8b2d87e", size = 232558, upload-time = "2026-04-09T16:05:25.025Z" }, - { url = "https://files.pythonhosted.org/packages/95/8c/27f1d8d3aaf079d3eb26439bf0b32f1482340c3552e324f7db9dca858671/librt-0.9.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c3786f0f4490a5cd87f1ed6cefae833ad6b1060d52044ce0434a2e85893afd0", size = 225521, upload-time = "2026-04-09T16:05:26.311Z" }, - { url = "https://files.pythonhosted.org/packages/6b/d8/1e0d43b1c329b416017619469b3c3801a25a6a4ef4a1c68332aeaa6f72ca/librt-0.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8494cfc61e03542f2d381e71804990b3931175a29b9278fdb4a5459948778dc2", size = 227789, upload-time = "2026-04-09T16:05:27.624Z" }, - { url = "https://files.pythonhosted.org/packages/2c/b4/d3d842e88610fcd4c8eec7067b0c23ef2d7d3bff31496eded6a83b0f99be/librt-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:07cf11f769831186eeac424376e6189f20ace4f7263e2134bdb9757340d84d4d", size = 248616, upload-time = "2026-04-09T16:05:29.181Z" }, - { url = "https://files.pythonhosted.org/packages/ec/28/527df8ad0d1eb6c8bdfa82fc190f1f7c4cca5a1b6d7b36aeabf95b52d74d/librt-0.9.0-cp313-cp313-win32.whl", hash = "sha256:850d6d03177e52700af605fd60db7f37dcb89782049a149674d1a9649c2138fd", size = 56039, upload-time = "2026-04-09T16:05:30.709Z" }, - { url = "https://files.pythonhosted.org/packages/f3/a7/413652ad0d92273ee5e30c000fc494b361171177c83e57c060ecd3c21538/librt-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:a5af136bfba820d592f86c67affcef9b3ff4d4360ac3255e341e964489b48519", size = 63264, upload-time = "2026-04-09T16:05:31.881Z" }, - { url = "https://files.pythonhosted.org/packages/a4/0a/92c244309b774e290ddb15e93363846ae7aa753d9586b8aad511c5e6145b/librt-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:4c4d0440a3a8e31d962340c3e1cc3fc9ee7febd34c8d8f770d06adb947779ea5", size = 53728, upload-time = "2026-04-09T16:05:33.31Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c1/184e539543f06ea2912f4b92a5ffaede4f9b392689e3f00acbf8134bee92/librt-0.9.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:3f05d145df35dca5056a8bc3838e940efebd893a54b3e19b2dda39ceaa299bcb", size = 67830, upload-time = "2026-04-09T16:05:34.517Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ad/23399bdcb7afca819acacdef31b37ee59de261bd66b503a7995c03c4b0dc/librt-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1c587494461ebd42229d0f1739f3aa34237dd9980623ecf1be8d3bcba79f4499", size = 70280, upload-time = "2026-04-09T16:05:35.649Z" }, - { url = "https://files.pythonhosted.org/packages/9f/0b/4542dc5a2b8772dbf92cafb9194701230157e73c14b017b6961a23598b03/librt-0.9.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0a2040f801406b93657a70b72fa12311063a319fee72ce98e1524da7200171f", size = 201925, upload-time = "2026-04-09T16:05:36.739Z" }, - { url = "https://files.pythonhosted.org/packages/31/d4/8ee7358b08fd0cfce051ef96695380f09b3c2c11b77c9bfbc367c921cce5/librt-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f38bc489037eca88d6ebefc9c4d41a4e07c8e8b4de5188a9e6d290273ad7ebb1", size = 212381, upload-time = "2026-04-09T16:05:38.043Z" }, - { url = "https://files.pythonhosted.org/packages/f2/94/a2025fe442abedf8b038038dab3dba942009ad42b38ea064a1a9e6094241/librt-0.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3fd278f5e6bf7c75ccd6d12344eb686cc020712683363b66f46ac79d37c799f", size = 227065, upload-time = "2026-04-09T16:05:39.394Z" }, - { url = "https://files.pythonhosted.org/packages/7c/e9/b9fcf6afa909f957cfbbf918802f9dada1bd5d3c1da43d722fd6a310dc3f/librt-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fcbdf2a9ca24e87bbebb47f1fe34e531ef06f104f98c9ccfc953a3f3344c567a", size = 221333, upload-time = "2026-04-09T16:05:40.999Z" }, - { url = "https://files.pythonhosted.org/packages/ac/7c/ba54cd6aa6a3c8cd12757a6870e0c79a64b1e6327f5248dcff98423f4d43/librt-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e306d956cfa027fe041585f02a1602c32bfa6bb8ebea4899d373383295a6c62f", size = 229051, upload-time = "2026-04-09T16:05:42.605Z" }, - { url = "https://files.pythonhosted.org/packages/4b/4b/8cfdbad314c8677a0148bf0b70591d6d18587f9884d930276098a235461b/librt-0.9.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:465814ab157986acb9dfa5ccd7df944be5eefc0d08d31ec6e8d88bc71251d845", size = 222492, upload-time = "2026-04-09T16:05:43.842Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d1/2eda69563a1a88706808decdce035e4b32755dbfbb0d05e1a65db9547ed1/librt-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:703f4ae36d6240bfe24f542bac784c7e4194ec49c3ba5a994d02891649e2d85b", size = 223849, upload-time = "2026-04-09T16:05:45.054Z" }, - { url = "https://files.pythonhosted.org/packages/04/44/b2ed37df6be5b3d42cfe36318e0598e80843d5c6308dd63d0bf4e0ce5028/librt-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3be322a15ee5e70b93b7a59cfd074614f22cc8c9ff18bd27f474e79137ea8d3b", size = 245001, upload-time = "2026-04-09T16:05:46.34Z" }, - { url = "https://files.pythonhosted.org/packages/47/e7/617e412426df89169dd2a9ed0cc8752d5763336252c65dbf945199915119/librt-0.9.0-cp314-cp314-win32.whl", hash = "sha256:b8da9f8035bb417770b1e1610526d87ad4fc58a2804dc4d79c53f6d2cf5a6eb9", size = 51799, upload-time = "2026-04-09T16:05:47.738Z" }, - { url = "https://files.pythonhosted.org/packages/24/ed/c22ca4db0ca3cbc285e4d9206108746beda561a9792289c3c31281d7e9df/librt-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:b8bd70d5d816566a580d193326912f4a76ec2d28a97dc4cd4cc831c0af8e330e", size = 59165, upload-time = "2026-04-09T16:05:49.198Z" }, - { url = "https://files.pythonhosted.org/packages/24/56/875398fafa4cbc8f15b89366fc3287304ddd3314d861f182a4b87595ace0/librt-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:fc5758e2b7a56532dc33e3c544d78cbaa9ecf0a0f2a2da2df882c1d6b99a317f", size = 49292, upload-time = "2026-04-09T16:05:50.362Z" }, - { url = "https://files.pythonhosted.org/packages/4c/61/bc448ecbf9b2d69c5cff88fe41496b19ab2a1cbda0065e47d4d0d51c0867/librt-0.9.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f24b90b0e0c8cc9491fb1693ae91fe17cb7963153a1946395acdbdd5818429a4", size = 70175, upload-time = "2026-04-09T16:05:51.564Z" }, - { url = "https://files.pythonhosted.org/packages/60/f2/c47bb71069a73e2f04e70acbd196c1e5cc411578ac99039a224b98920fd4/librt-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fe56e80badb66fdcde06bef81bbaa5bfcf6fbd7aefb86222d9e369c38c6b228", size = 72951, upload-time = "2026-04-09T16:05:52.699Z" }, - { url = "https://files.pythonhosted.org/packages/29/19/0549df59060631732df758e8886d92088da5fdbedb35b80e4643664e8412/librt-0.9.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:527b5b820b47a09e09829051452bb0d1dd2122261254e2a6f674d12f1d793d54", size = 225864, upload-time = "2026-04-09T16:05:53.895Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f8/3b144396d302ac08e50f89e64452c38db84bc7b23f6c60479c5d3abd303c/librt-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d429bdd4ac0ab17c8e4a8af0ed2a7440b16eba474909ab357131018fe8c7e71", size = 241155, upload-time = "2026-04-09T16:05:55.191Z" }, - { url = "https://files.pythonhosted.org/packages/7a/ce/ee67ec14581de4043e61d05786d2aed6c9b5338816b7859bcf07455c6a9f/librt-0.9.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7202bdcac47d3a708271c4304a474a8605a4a9a4a709e954bf2d3241140aa938", size = 252235, upload-time = "2026-04-09T16:05:56.549Z" }, - { url = "https://files.pythonhosted.org/packages/8a/fa/0ead15daa2b293a54101550b08d4bafe387b7d4a9fc6d2b985602bae69b6/librt-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0d620e74897f8c2613b3c4e2e9c1e422eb46d2ddd07df540784d44117836af3", size = 244963, upload-time = "2026-04-09T16:05:57.858Z" }, - { url = "https://files.pythonhosted.org/packages/29/68/9fbf9a9aa704ba87689e40017e720aced8d9a4d2b46b82451d8142f91ec9/librt-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d69fc39e627908f4c03297d5a88d9284b73f4d90b424461e32e8c2485e21c283", size = 257364, upload-time = "2026-04-09T16:05:59.686Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8d/9d60869f1b6716c762e45f66ed945b1e5dd649f7377684c3b176ae424648/librt-0.9.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c2640e23d2b7c98796f123ffd95cf2022c7777aa8a4a3b98b36c570d37e85eee", size = 247661, upload-time = "2026-04-09T16:06:00.938Z" }, - { url = "https://files.pythonhosted.org/packages/70/ff/a5c365093962310bfdb4f6af256f191085078ffb529b3f0cbebb5b33ebe2/librt-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:451daa98463b7695b0a30aa56bf637831ea559e7b8101ac2ef6382e8eb15e29c", size = 248238, upload-time = "2026-04-09T16:06:02.537Z" }, - { url = "https://files.pythonhosted.org/packages/a0/3c/2d34365177f412c9e19c0a29f969d70f5343f27634b76b765a54d8b27705/librt-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:928bd06eca2c2bbf4349e5b817f837509b0604342e65a502de1d50a7570afd15", size = 269457, upload-time = "2026-04-09T16:06:03.833Z" }, - { url = "https://files.pythonhosted.org/packages/bc/cd/de45b239ea3bdf626f982a00c14bfcf2e12d261c510ba7db62c5969a27cd/librt-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:a9c63e04d003bc0fb6a03b348018b9a3002f98268200e22cc80f146beac5dc40", size = 52453, upload-time = "2026-04-09T16:06:05.229Z" }, - { url = "https://files.pythonhosted.org/packages/7f/f9/bfb32ae428aa75c0c533915622176f0a17d6da7b72b5a3c6363685914f70/librt-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f162af66a2ed3f7d1d161a82ca584efd15acd9c1cff190a373458c32f7d42118", size = 60044, upload-time = "2026-04-09T16:06:06.398Z" }, - { url = "https://files.pythonhosted.org/packages/aa/47/7d70414bcdbb3bc1f458a8d10558f00bbfdb24e5a11740fc8197e12c3255/librt-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a4b25c6c25cac5d0d9d6d6da855195b254e0021e513e0249f0e3b444dc6e0e61", size = 50009, upload-time = "2026-04-09T16:06:07.995Z" }, -] - -[[package]] -name = "markdown-it-py" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, -] - -[[package]] -name = "markupsafe" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, -] - -[[package]] -name = "matplotlib" -version = "3.10.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "contourpy" }, - { name = "cycler" }, - { name = "fonttools" }, - { name = "kiwisolver" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pillow" }, - { name = "pyparsing" }, - { name = "python-dateutil" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/35/c6/5581e26c72233ebb2a2a6fed2d24fb7c66b4700120b813f51b0555acf0b6/matplotlib-3.10.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1", size = 8319908, upload-time = "2026-04-24T00:12:21.323Z" }, - { url = "https://files.pythonhosted.org/packages/b7/18/4880dd762e40cd360c1bf06e890c5a97b997e91cb324602b1a19950ad5ce/matplotlib-3.10.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cb28c2bd769aa3e98322c6ab09854cbcc52ab69d2759d681bba3e327b2b320", size = 8216016, upload-time = "2026-04-24T00:12:23.4Z" }, - { url = "https://files.pythonhosted.org/packages/32/91/d024616abdba99e83120e07a20658976f6a343646710760c4a51df126029/matplotlib-3.10.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285", size = 8789336, upload-time = "2026-04-24T00:12:26.096Z" }, - { url = "https://files.pythonhosted.org/packages/5c/04/030a2f61ef2158f5e4c259487a92ac877732499fb33d871585d89e03c42d/matplotlib-3.10.9-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c63ebcd8b4b169eb2f5c200552ae6b8be8999a005b6b507ed76fb8d7d674fe2", size = 9604602, upload-time = "2026-04-24T00:12:29.052Z" }, - { url = "https://files.pythonhosted.org/packages/fc/c2/541e4d09d87bb6b5830fc28b4c887a9a8cf4e1c6cee698a8c05552ae2003/matplotlib-3.10.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf", size = 9670966, upload-time = "2026-04-24T00:12:32.131Z" }, - { url = "https://files.pythonhosted.org/packages/04/a1/4571fc46e7702de8d0c2dc54ad1b2f8e29328dea3ee90831181f7353d93c/matplotlib-3.10.9-cp312-cp312-win_amd64.whl", hash = "sha256:d091f9d758b34aaaaa6331d13574bf01891d903b3dec59bfff458ef7551de5d6", size = 8217462, upload-time = "2026-04-24T00:12:35.226Z" }, - { url = "https://files.pythonhosted.org/packages/4b/d0/2269edb12aa30c13c8bcc9382892e39943ce1d28aab4ec296e0381798e81/matplotlib-3.10.9-cp312-cp312-win_arm64.whl", hash = "sha256:10cc5ce06d10231c36f40e875f3c7e8050362a4ee8f0ee5d29a6b3277d57bb42", size = 8136688, upload-time = "2026-04-24T00:12:37.442Z" }, - { url = "https://files.pythonhosted.org/packages/aa/d3/8d4f6afbecb49fc04e060a57c0fce39ea51cc163a6bd87303ccd698e4fa6/matplotlib-3.10.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b580440f1ff81a0e34122051a3dfabb7e4b7f9e380629929bde0eff9af72165f", size = 8320331, upload-time = "2026-04-24T00:12:39.688Z" }, - { url = "https://files.pythonhosted.org/packages/63/d9/9e14bc7564bf92d5ffa801ae5fac819ce74b925dfb55e3ebde61a3bbad3e/matplotlib-3.10.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b1b745c489cd1a77a0dc1120a05dc87af9798faebc913601feb8c73d89bf2d1e", size = 8216461, upload-time = "2026-04-24T00:12:42.494Z" }, - { url = "https://files.pythonhosted.org/packages/8a/17/4402d0d14ccf1dfc70932600b68097fbbf9c898a4871d2cbbe79c7801a32/matplotlib-3.10.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f3bcac1ca5ed000a6f4337d47ba67dfddf37ed6a46c15fd7f014997f7bf865f", size = 8790091, upload-time = "2026-04-24T00:12:44.789Z" }, - { url = "https://files.pythonhosted.org/packages/3e/0b/322aeec06dd9b91411f92028b37d447342770a24392aa4813e317064dad5/matplotlib-3.10.9-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a8d66a55def891c33147ba3ba9bfcabf0b526a43764c818acbb4525e5ed0838", size = 9605027, upload-time = "2026-04-24T00:12:47.583Z" }, - { url = "https://files.pythonhosted.org/packages/74/88/5f13482f55e7b00bcfc09838b093c2456e1379978d2a146844aae05350ad/matplotlib-3.10.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d843374407c4017a6403b59c6c81606773d136f3259d5b6da3131bc814542cc2", size = 9671269, upload-time = "2026-04-24T00:12:50.878Z" }, - { url = "https://files.pythonhosted.org/packages/c5/e0/0840fd2f93da988ec660b8ad1984abe9f25d2aed22a5e394ff1c68c88307/matplotlib-3.10.9-cp313-cp313-win_amd64.whl", hash = "sha256:f4399f64b3e94cd500195490972ae1ee81170df1636fa15364d157d5bdd7b921", size = 8217588, upload-time = "2026-04-24T00:12:53.784Z" }, - { url = "https://files.pythonhosted.org/packages/47/b9/d706d06dd605c49b9f83a2aed8c13e3e5db70697d7a80b7e3d7915de6b17/matplotlib-3.10.9-cp313-cp313-win_arm64.whl", hash = "sha256:ba7b3b8ef09eab7df0e86e9ae086faa433efbfbdb46afcb3aa16aabf779469a8", size = 8136913, upload-time = "2026-04-24T00:12:56.501Z" }, - { url = "https://files.pythonhosted.org/packages/9b/45/6e32d96978264c8ca8c4b1010adb955a1a49cfaf314e212bbc8908f04a61/matplotlib-3.10.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:09218df8a93712bd6ea133e83a153c755448cf7868316c531cffcc43f69d1cc9", size = 8368019, upload-time = "2026-04-24T00:12:58.896Z" }, - { url = "https://files.pythonhosted.org/packages/86/0a/c8e3d3bba245f0f7fc424937f8ff7ef77291a36af3edb97ccd78aa93d84f/matplotlib-3.10.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:82368699727bfb7b0182e1aa13082e3c08e092fa1a25d3e1fd92405bff96f6d4", size = 8264645, upload-time = "2026-04-24T00:13:01.406Z" }, - { url = "https://files.pythonhosted.org/packages/3d/aa/5bf5a14fe4fed73a4209a155606f8096ff797aad89c6c35179026571133e/matplotlib-3.10.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3225f4e1edcb8c86c884ddf79ebe20ecd0a67d30188f279897554ccd8fded4dc", size = 8802194, upload-time = "2026-04-24T00:13:03.702Z" }, - { url = "https://files.pythonhosted.org/packages/dd/5e/b4be852d6bba6fd15893fadf91ff26ae49cb91aac789e95dde9d342e664f/matplotlib-3.10.9-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de2445a0c6690d21b7eb6ce071cebad6d40a2e9bdf10d039074a96ba19797b99", size = 9622684, upload-time = "2026-04-24T00:13:06.647Z" }, - { url = "https://files.pythonhosted.org/packages/4c/3d/ed428c971139112ef730f62770654d609467346d09d4b62617e1afd68a5a/matplotlib-3.10.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b2b9516251cb89ff618d757daec0e2ed1bf21248013844a853d87ef85ab3081d", size = 9680790, upload-time = "2026-04-24T00:13:10.009Z" }, - { url = "https://files.pythonhosted.org/packages/e7/09/052e884aaf2b985c63cb79f715f1d5b6a3eaa7de78f6a52b9dbc077d5b53/matplotlib-3.10.9-cp313-cp313t-win_amd64.whl", hash = "sha256:e9fae004b941b23ff2edcf1567a857ed77bafc8086ffa258190462328434faf8", size = 8287571, upload-time = "2026-04-24T00:13:13.087Z" }, - { url = "https://files.pythonhosted.org/packages/f4/38/ae27288e788c35a4250491422f3db7750366fc8c97d6f36fbdecfc1f5518/matplotlib-3.10.9-cp313-cp313t-win_arm64.whl", hash = "sha256:6b63d9c7c769b88ab81e10dc86e4e0607cf56817b9f9e6cf24b2a5f1693b8e38", size = 8188292, upload-time = "2026-04-24T00:13:15.546Z" }, - { url = "https://files.pythonhosted.org/packages/d6/e6/3bd8afd04949f02eabc1c17115ea5255e19cacd4d06fc5abdde4eeb0052c/matplotlib-3.10.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:172db52c9e683f5d12eaf57f0f54834190e12581fe1cc2a19595a8f5acb4e77d", size = 8321276, upload-time = "2026-04-24T00:13:18.318Z" }, - { url = "https://files.pythonhosted.org/packages/41/86/86231232fff41c9f8e4a1a7d7a597d349a02527109c3af7d618366122139/matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97e35e8d39ccc85859095e01a53847432ba9a53ddf7986f7a54a11b73d0e143f", size = 8218218, upload-time = "2026-04-24T00:13:20.974Z" }, - { url = "https://files.pythonhosted.org/packages/85/8f/becc9722cafc64f5d2eb0b7c1bf5f585271c618a45dbd8fabeb021f898b6/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aba1615dabe83188e19d4f75a253c6a08423e04c1425e64039f800050a69de6b", size = 9608145, upload-time = "2026-04-24T00:13:23.228Z" }, - { url = "https://files.pythonhosted.org/packages/32/5d/f7e914f7d9325abff4057cee62c0fa70263683189f774473cbfb534cd13b/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34cf8167e023ad956c15f36302911d5406bd99a9862c1a8499ea6f7c0e015dc2", size = 9885085, upload-time = "2026-04-24T00:13:25.849Z" }, - { url = "https://files.pythonhosted.org/packages/a5/fd/fa69f2221534e80cc5772ac2b7d222011a2acafc2ec7216d5dd174c864ae/matplotlib-3.10.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59476c6d29d612b8e9bb6ce8c5b631be6ba8f9e3a2421f22a02b192c7dd28716", size = 9672358, upload-time = "2026-04-24T00:13:28.906Z" }, - { url = "https://files.pythonhosted.org/packages/ab/1a/5a4f747a8b271cbb024946d2dd3c913ab5032ba430626f8c3528ada96b4b/matplotlib-3.10.9-cp314-cp314-win_amd64.whl", hash = "sha256:336b9acc64d309063126edcdaca00db9373af3c476bb94388fe9c5a53ad13e6f", size = 8349970, upload-time = "2026-04-24T00:13:31.904Z" }, - { url = "https://files.pythonhosted.org/packages/64/dc/95d60ecaefe30680a154b52ea96ab4b0dab547f1fd6aa12f5fb655e89cae/matplotlib-3.10.9-cp314-cp314-win_arm64.whl", hash = "sha256:2dc9477819ffd78ad12a20df1d9d6a6bd4fec6aaa9072681465fddca052f1456", size = 8272785, upload-time = "2026-04-24T00:13:34.511Z" }, - { url = "https://files.pythonhosted.org/packages/70/a0/005d68bc8b8418300ce6591f18586910a8526806e2ab663933d9f20a41e9/matplotlib-3.10.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:da4e09638420548f31c354032a6250e473c68e5a4e96899b4844cf39ddea23fe", size = 8367999, upload-time = "2026-04-24T00:13:36.962Z" }, - { url = "https://files.pythonhosted.org/packages/22/05/1236cc9290be70b2498af20ca348add76e3fffe7f67b477db5133a84f3ea/matplotlib-3.10.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:345f6f68ecc8da0ca56fad2ea08fde1a115eda530079eca185d50a7bc3e146c6", size = 8264543, upload-time = "2026-04-24T00:13:39.851Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c2/071f5a5ff6c5bd63aaaf2f45c811d9bf2ced94bde188d9e1a519e21d0cba/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4edcfbd8565339aa62f1cd4012f7180926fdbe71850f7b0d3c379c175cd6b66c", size = 9622800, upload-time = "2026-04-24T00:13:42.296Z" }, - { url = "https://files.pythonhosted.org/packages/95/57/da7d1f10a85624b9e7db68e069dd94e58dc41dbf9463c5921632ecbe3661/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be157fe17fc37cb95ac1d7374cf717ce9259616edec911a78d9d26dae8522d4", size = 9888561, upload-time = "2026-04-24T00:13:45.026Z" }, - { url = "https://files.pythonhosted.org/packages/67/b2/ef8d6bb59b0edb6c16c968b70f548aa13b54348972def5aa6ac85df67145/matplotlib-3.10.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4e42042d54db34fda4e95a7bd3e5789c2a995d2dad3eb8850232ee534092fbbf", size = 9680884, upload-time = "2026-04-24T00:13:48.066Z" }, - { url = "https://files.pythonhosted.org/packages/61/1c/d21bfeb9931881ebe96bcfcff27c7ae4b160ae0ec291a714c42641a56d75/matplotlib-3.10.9-cp314-cp314t-win_amd64.whl", hash = "sha256:c27df8b3848f32a83d1767566595e43cfaa4460380974da06f4279a7ec143c39", size = 8432333, upload-time = "2026-04-24T00:13:51.008Z" }, - { url = "https://files.pythonhosted.org/packages/78/23/92493c3e6e1b635ccfff146f7b99e674808787915420373ac399283764c2/matplotlib-3.10.9-cp314-cp314t-win_arm64.whl", hash = "sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c", size = 8324785, upload-time = "2026-04-24T00:13:53.633Z" }, -] - -[[package]] -name = "matplotlib-inline" -version = "0.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110, upload-time = "2025-10-23T09:00:22.126Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, -] - -[[package]] -name = "mccabe" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, -] - -[[package]] -name = "mistune" -version = "3.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/84/620cc3f7e3adf6f5067e10f4dbae71295d8f9e16d5d3f9ef97c40f2f592c/mistune-3.2.1.tar.gz", hash = "sha256:7c8e5501d38bac1582e067e46c8343f17d57ea1aaa735823f3aba1fd59c88a28", size = 98003, upload-time = "2026-05-03T14:33:22.312Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/7f/a946aa4f8752b37102b41e64dca18a1976ac705c3a0d1dfe74d820a02552/mistune-3.2.1-py3-none-any.whl", hash = "sha256:78cdb0ba5e938053ccf63651b352508d2efa9411dc8810bfb05f2dc5140c0048", size = 53749, upload-time = "2026-05-03T14:33:20.551Z" }, -] - -[[package]] -name = "more-itertools" -version = "11.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/f7/139d22fef48ac78127d18e01d80cf1be40236ae489769d17f35c3d425293/more_itertools-11.0.2.tar.gz", hash = "sha256:392a9e1e362cbc106a2457d37cabf9b36e5e12efd4ebff1654630e76597df804", size = 144659, upload-time = "2026-04-09T15:01:33.297Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/98/6af411189d9413534c3eb691182bff1f5c6d44ed2f93f2edfe52a1bbceb8/more_itertools-11.0.2-py3-none-any.whl", hash = "sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4", size = 71939, upload-time = "2026-04-09T15:01:32.21Z" }, -] - -[[package]] -name = "music21" -source = { editable = "." } -dependencies = [ - { name = "chardet" }, - { name = "joblib" }, - { name = "jsonpickle" }, - { name = "matplotlib" }, - { name = "more-itertools" }, - { name = "numpy" }, - { name = "requests" }, - { name = "webcolors" }, -] - -[package.optional-dependencies] -extras = [ - { name = "python-levenshtein" }, - { name = "scipy" }, -] - -[package.dev-dependencies] -dev = [ - { name = "coverage" }, - { name = "coveralls" }, - { name = "docutils" }, - { name = "hatch" }, - { name = "hatchling" }, - { name = "ipython" }, - { name = "jupyter" }, - { name = "music21", extra = ["extras"] }, - { name = "mypy" }, - { name = "nbconvert" }, - { name = "nbval" }, - { name = "pylint" }, - { name = "pytest" }, - { name = "ruff" }, - { name = "sphinx" }, - { name = "twine" }, - { name = "types-requests" }, - { name = "wheel" }, -] - -[package.metadata] -requires-dist = [ - { name = "chardet" }, - { name = "joblib" }, - { name = "jsonpickle" }, - { name = "matplotlib" }, - { name = "more-itertools" }, - { name = "numpy", specifier = ">=1.26.4" }, - { name = "python-levenshtein", marker = "extra == 'extras'" }, - { name = "requests" }, - { name = "scipy", marker = "extra == 'extras'" }, - { name = "webcolors", specifier = ">=1.5" }, -] -provides-extras = ["extras"] - -[package.metadata.requires-dev] -dev = [ - { name = "coverage" }, - { name = "coveralls" }, - { name = "docutils" }, - { name = "hatch" }, - { name = "hatchling" }, - { name = "ipython" }, - { name = "jupyter" }, - { name = "music21", extras = ["extras"] }, - { name = "mypy", specifier = ">=1.16.0" }, - { name = "nbconvert" }, - { name = "nbval" }, - { name = "pylint", specifier = ">=3.2.0" }, - { name = "pytest" }, - { name = "ruff" }, - { name = "sphinx" }, - { name = "twine" }, - { name = "types-requests" }, - { name = "wheel" }, -] - -[[package]] -name = "mypy" -version = "1.20.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, - { name = "mypy-extensions" }, - { name = "pathspec" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" }, - { url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" }, - { url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" }, - { url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" }, - { url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" }, - { url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" }, - { url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" }, - { url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" }, - { url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" }, - { url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" }, - { url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" }, - { url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" }, - { url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" }, - { url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" }, - { url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" }, - { url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" }, - { url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" }, - { url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" }, - { url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" }, - { url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" }, - { url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" }, - { url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" }, - { url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" }, - { url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" }, - { url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" }, - { url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" }, - { url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" }, - { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, -] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, -] - -[[package]] -name = "nbclient" -version = "0.10.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jupyter-client" }, - { name = "jupyter-core" }, - { name = "nbformat" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/56/91/1c1d5a4b9a9ebba2b4e32b8c852c2975c872aec1fe42ab5e516b2cecd193/nbclient-0.10.4.tar.gz", hash = "sha256:1e54091b16e6da39e297b0ece3e10f6f29f4ac4e8ee515d29f8a7099bd6553c9", size = 62554, upload-time = "2025-12-23T07:45:46.369Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/a0/5b0c2f11142ed1dddec842457d3f65eaf71a0080894eb6f018755b319c3a/nbclient-0.10.4-py3-none-any.whl", hash = "sha256:9162df5a7373d70d606527300a95a975a47c137776cd942e52d9c7e29ff83440", size = 25465, upload-time = "2025-12-23T07:45:44.51Z" }, -] - -[[package]] -name = "nbconvert" -version = "7.17.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beautifulsoup4" }, - { name = "bleach", extra = ["css"] }, - { name = "defusedxml" }, - { name = "jinja2" }, - { name = "jupyter-core" }, - { name = "jupyterlab-pygments" }, - { name = "markupsafe" }, - { name = "mistune" }, - { name = "nbclient" }, - { name = "nbformat" }, - { name = "packaging" }, - { name = "pandocfilters" }, - { name = "pygments" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/01/b1/708e53fe2e429c103c6e6e159106bcf0357ac41aa4c28772bd8402339051/nbconvert-7.17.1.tar.gz", hash = "sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2", size = 865311, upload-time = "2026-04-08T00:44:14.914Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl", hash = "sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8", size = 261927, upload-time = "2026-04-08T00:44:12.845Z" }, -] - -[[package]] -name = "nbformat" -version = "5.10.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "fastjsonschema" }, - { name = "jsonschema" }, - { name = "jupyter-core" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749, upload-time = "2024-04-04T11:20:37.371Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, -] - -[[package]] -name = "nbval" -version = "0.11.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "coverage" }, - { name = "ipykernel" }, - { name = "jupyter-client" }, - { name = "nbformat" }, - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/28/be/22bd64d09e0cb53258f83b6fc455f05f18a78e3e5c109ccb6af42f1f49a2/nbval-0.11.0.tar.gz", hash = "sha256:77c95797607b0a968babd2597ee3494102d25c3ad37435debbdac0e46e379094", size = 62718, upload-time = "2024-03-04T14:36:58.256Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/5c/eb1e3ce54c4e94c7734b3831756c63f21badb3de91a98d77b9e23c0ca76a/nbval-0.11.0-py2.py3-none-any.whl", hash = "sha256:307aecc866c9a1e8a13bb5bbb008a702bacfda2394dff6fe504a3108a58042a0", size = 24013, upload-time = "2024-03-04T14:36:57.126Z" }, -] - -[[package]] -name = "nest-asyncio" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, -] - -[[package]] -name = "nh3" -version = "0.3.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9c/5f/1d19bdc7d27238e37f3672cdc02cb77c56a4a86d140cd4f4f23c90df6e16/nh3-0.3.5.tar.gz", hash = "sha256:45855e14ff056064fec77133bfcf7cd691838168e5e17bbef075394954dc9dc8", size = 20743, upload-time = "2026-04-25T10:44:16.066Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/63/b0/8587ac42a9627ab88e7e221601f1dfccbf4db80b2a29222ea63266dc9abc/nh3-0.3.5-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:23a312224875f72cd16bde417f49071451877e29ef646a60e50fcb69407cc18a", size = 1420126, upload-time = "2026-04-25T10:43:39.834Z" }, - { url = "https://files.pythonhosted.org/packages/c0/1b/1dbc4d0c43f12e8c1784ede17eaee6f061d4fbe5505757c65c49b2ceab95/nh3-0.3.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:387abd011e81959d5a35151a11350a0795c6edeb53ebfa02d2e882dc01299263", size = 793943, upload-time = "2026-04-25T10:43:41.363Z" }, - { url = "https://files.pythonhosted.org/packages/47/9f/d6758d7a14ee964bf439cc35ae4fa24a763a93399c8ef6f22bd11d532d29/nh3-0.3.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:48f45e3e914be93a596431aa143dedf1582557bf41a58153c296048d6e3798c9", size = 841150, upload-time = "2026-04-25T10:43:43.007Z" }, - { url = "https://files.pythonhosted.org/packages/b6/36/d5d1ae8374612c98f390e1ea7c610fa6c9716259a03bbf4d15b269f40073/nh3-0.3.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0a09f51806fd51b4fedbf9ea2b61fef388f19aef0d62fe51199d41648be14588", size = 1008415, upload-time = "2026-04-25T10:43:44.324Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8f/d13a9c3fd2d9c131a2a281737380e9379eb0f8c33fea24c2b923aaafbb15/nh3-0.3.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c357f1d042c67f135a5e6babb2b0e3b9d9224ff4a3543240f597767b01384ffd", size = 1092706, upload-time = "2026-04-25T10:43:45.653Z" }, - { url = "https://files.pythonhosted.org/packages/bb/57/2f3add7f8680fcc896afa6a675cb2bab09982853ee8af40bad621f6b61c4/nh3-0.3.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:38748140bf76383ab7ce2dce0ad4cb663855d8fbc9098f7f3483673d09616a17", size = 1048346, upload-time = "2026-04-25T10:43:46.974Z" }, - { url = "https://files.pythonhosted.org/packages/c1/c3/2f9e4ffa82863074d1361bfe949bc46393d91b3411579dfbbd090b24cac5/nh3-0.3.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:84bdeb082544fbcb77a12c034dd77d7da0556fdc0727b787eb6214b958c15e29", size = 1029038, upload-time = "2026-04-25T10:43:48.569Z" }, - { url = "https://files.pythonhosted.org/packages/e8/10/2804deb3f3315184c9cae41702e293c87524b5a21f766b07d7fe3ffbcfbb/nh3-0.3.5-cp314-cp314t-win32.whl", hash = "sha256:c3aae321f67ae66cff2a627115f106a377d4475d10b0e13d97959a13486b9a88", size = 603263, upload-time = "2026-04-25T10:43:49.851Z" }, - { url = "https://files.pythonhosted.org/packages/eb/a2/f6685248b49f7548fc9a8c335ab3a52f68610b72e8a61576447151e4e2e6/nh3-0.3.5-cp314-cp314t-win_amd64.whl", hash = "sha256:c88605d8d468f7fc1b31e06129bc91d6c96f6c621776c9b504a0da9beac9df5f", size = 616866, upload-time = "2026-04-25T10:43:51.005Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b6/d8c9018635d4acfefde6b68470daa510eed715a350cbaa2f928ba0609f81/nh3-0.3.5-cp314-cp314t-win_arm64.whl", hash = "sha256:72c5bdedec27fa33de6a5326346ea8aa3fe54f6ac294d54c4b204fb66a9f1e79", size = 602566, upload-time = "2026-04-25T10:43:52.283Z" }, - { url = "https://files.pythonhosted.org/packages/85/30/d162e99746a2fb1d98bb0ef23af3e201b156cf09f7de867c7390c8fe1c06/nh3-0.3.5-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:3bb854485c9b33e5bb143ff3e49e577073bc6bc320f0ff8fc316dd89c0d3c101", size = 1442393, upload-time = "2026-04-25T10:43:53.556Z" }, - { url = "https://files.pythonhosted.org/packages/25/8c/072120d506978ab053e1732d0efa7c86cb478fee0ee098fda0ac0d31cb34/nh3-0.3.5-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50d401ab2d8e86d59e2126e3ab2a2f45840c405842b626d9a51624b3a33b6878", size = 837722, upload-time = "2026-04-25T10:43:55.073Z" }, - { url = "https://files.pythonhosted.org/packages/52/86/d4e06e28c5ad1c4b065f89737d02631bd49f1660b6ebcf17a87ffcd201da/nh3-0.3.5-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:acfd354e61accbe4c74f8017c6e397a776916dfe47c48643cf7fd84ade826f93", size = 822872, upload-time = "2026-04-25T10:43:56.581Z" }, - { url = "https://files.pythonhosted.org/packages/0a/62/50659255213f241ec5797ae7427464c969397373e83b3659372b341ae869/nh3-0.3.5-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:52d877980d7ca01dc3baf3936bf844828bc6f332962227a684ed79c18cce14c3", size = 1100031, upload-time = "2026-04-25T10:43:58.098Z" }, - { url = "https://files.pythonhosted.org/packages/00/7a/a12ae77593b2fcf3be25df7bc1c01967d0de448bdb4b6c7ec80fe4f5a74f/nh3-0.3.5-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:207c01801d3e9bb8ec08f08689346bdd30ce15b8bf60013a925d08b5388962a4", size = 1057669, upload-time = "2026-04-25T10:43:59.328Z" }, - { url = "https://files.pythonhosted.org/packages/2d/71/5647dc04c0233192a3956fc91708822b21403a06508cacf78083c68e7bf0/nh3-0.3.5-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea232933394d1d58bf7c4bb348dc4660eae6604e1ae81cd2ba6d9ed80d390f3b", size = 914795, upload-time = "2026-04-25T10:44:00.52Z" }, - { url = "https://files.pythonhosted.org/packages/1b/0e/bf298920729f216adcb002acf7ea01b90842603d2e4e2ce9b900d9ee8fab/nh3-0.3.5-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe3a787dc76b50de6bee54ef242f26c41dfe47654428e3e94f0fae5bb6dd2cc1", size = 806976, upload-time = "2026-04-25T10:44:01.743Z" }, - { url = "https://files.pythonhosted.org/packages/85/01/26761e1dc2b848e65a62c19e5d39ad446283287cd4afddc89f364ab86bc9/nh3-0.3.5-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:488928988caad25ba14b1eb5bc74e25e21f3b5e40341d956f3ce4a8bc19460dc", size = 834904, upload-time = "2026-04-25T10:44:03.454Z" }, - { url = "https://files.pythonhosted.org/packages/33/53/0766113e679540ac1edc1b82b1295aecd321eeb75d6fead70109a838b6ee/nh3-0.3.5-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2c069570b06aa848457713ad7af4a9905691291548c4466a9ad78ee95808382b", size = 857159, upload-time = "2026-04-25T10:44:05.003Z" }, - { url = "https://files.pythonhosted.org/packages/58/36/734d353dfaf292fed574b8b3092f0ef79dc6404f3879f7faaa61a4701fad/nh3-0.3.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:eeedc90ed8c42c327e8e10e621ccfa314fc6cce35d5929f4297ff1cdb89667c4", size = 1018600, upload-time = "2026-04-25T10:44:06.18Z" }, - { url = "https://files.pythonhosted.org/packages/6b/aa/d9c59c1b49669fcb7bababa55df82385f029ad5c2651f583c3a1141cfdd1/nh3-0.3.5-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:de8e8621853b6470fe928c684ee0d3f39ea8086cebafe4c416486488dea7b68d", size = 1103530, upload-time = "2026-04-25T10:44:07.68Z" }, - { url = "https://files.pythonhosted.org/packages/90/b0/cdd210bfb8d9d43fb02fc3c868336b9955934d8e15e66eb1d15a147b8af0/nh3-0.3.5-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:6ea58cc44d274c643b83547ca9654a0b1a817609b160601356f76a2b744c49ad", size = 1061754, upload-time = "2026-04-25T10:44:09.362Z" }, - { url = "https://files.pythonhosted.org/packages/ce/cb/7a39e72e668c8445bdd95e494b3e21cfdddc68329be8ea3522c8befb46c4/nh3-0.3.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e49c9b564e6bcb03ecd2f057213df9a0de15a95812ac9db9600b590db23d3ae9", size = 1040938, upload-time = "2026-04-25T10:44:10.775Z" }, - { url = "https://files.pythonhosted.org/packages/af/4c/fc2f9ed208a3801a319f59b5fea03cdc20cf3bd8af14be930d3a8de01224/nh3-0.3.5-cp38-abi3-win32.whl", hash = "sha256:559e4c73b689e9a7aa97ac9760b1bc488038d7c1a575aa4ab5a0e19ee9630c0f", size = 611445, upload-time = "2026-04-25T10:44:12.317Z" }, - { url = "https://files.pythonhosted.org/packages/db/1a/e4c9b5e2ae13e6092c9ec16d8ca30646cb01fcdea245f36c5b08fd21fbd5/nh3-0.3.5-cp38-abi3-win_amd64.whl", hash = "sha256:45e6a65dc88a300a2e3502cb9c8e6d1d6b831d6fba7470643333609c6aab1f30", size = 626502, upload-time = "2026-04-25T10:44:13.682Z" }, - { url = "https://files.pythonhosted.org/packages/80/7c/19cd0671d1ba2762fb388fc149697d20d0568ccfeef833b11280a619e526/nh3-0.3.5-cp38-abi3-win_arm64.whl", hash = "sha256:8f85285700a18e9f3fc5bff41fe573fa84f81542ef13b48a89f9fecca0474d3b", size = 611069, upload-time = "2026-04-25T10:44:14.934Z" }, -] - -[[package]] -name = "notebook" -version = "7.5.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jupyter-server" }, - { name = "jupyterlab" }, - { name = "jupyterlab-server" }, - { name = "notebook-shim" }, - { name = "tornado" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2a/c2/cf59bd2e6f2c8b976b52477e3e53bf6f97bc714ed046a51821afb428eaee/notebook-7.5.6.tar.gz", hash = "sha256:621174aade80108f0020b0f00738000b215f75fa3cd90771ad7aa0f24536a4e1", size = 14170814, upload-time = "2026-04-30T11:46:26.613Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/d6/1fd0646b9bbd9efbb0b8ae21b2325fbef515769a5621c03e31d8eb8da587/notebook-7.5.6-py3-none-any.whl", hash = "sha256:4dde3f8fb55fa8fb7946d58c6e869ce9baf46d00fc070664f62604569d0faca0", size = 14581730, upload-time = "2026-04-30T11:46:22.342Z" }, -] - -[[package]] -name = "notebook-shim" -version = "0.2.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jupyter-server" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/54/d2/92fa3243712b9a3e8bafaf60aac366da1cada3639ca767ff4b5b3654ec28/notebook_shim-0.2.4.tar.gz", hash = "sha256:b4b2cfa1b65d98307ca24361f5b30fe785b53c3fd07b7a47e89acb5e6ac638cb", size = 13167, upload-time = "2024-02-14T23:35:18.353Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/33/bd5b9137445ea4b680023eb0469b2bb969d61303dedb2aac6560ff3d14a1/notebook_shim-0.2.4-py3-none-any.whl", hash = "sha256:411a5be4e9dc882a074ccbcae671eda64cceb068767e9a3419096986560e1cef", size = 13307, upload-time = "2024-02-14T23:35:16.286Z" }, -] - -[[package]] -name = "numpy" -version = "2.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, - { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, - { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, - { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, - { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, - { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, - { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, - { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, - { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, - { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, - { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, - { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, - { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, - { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, - { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, - { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, - { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, - { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, - { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, - { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, - { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, - { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, - { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, - { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, - { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, - { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, - { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, - { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, - { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, - { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, - { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, - { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, - { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, - { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, - { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, - { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, - { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, - { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, - { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, - { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, - { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, - { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, - { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, - { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, - { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, - { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, - { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, - { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, - { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, -] - -[[package]] -name = "packaging" -version = "26.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, -] - -[[package]] -name = "pandocfilters" -version = "1.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454, upload-time = "2024-01-18T20:08:13.726Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663, upload-time = "2024-01-18T20:08:11.28Z" }, -] - -[[package]] -name = "parso" -version = "0.8.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/81/76/a1e769043c0c0c9fe391b702539d594731a4362334cdf4dc25d0c09761e7/parso-0.8.6.tar.gz", hash = "sha256:2b9a0332696df97d454fa67b81618fd69c35a7b90327cbe6ba5c92d2c68a7bfd", size = 401621, upload-time = "2026-02-09T15:45:24.425Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/61/fae042894f4296ec49e3f193aff5d7c18440da9e48102c3315e1bc4519a7/parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff", size = 106894, upload-time = "2026-02-09T15:45:21.391Z" }, -] - -[[package]] -name = "pathspec" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, -] - -[[package]] -name = "pexpect" -version = "4.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ptyprocess" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, -] - -[[package]] -name = "pillow" -version = "12.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, - { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, - { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, - { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, - { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, - { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, - { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, - { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, - { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, - { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, - { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, - { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, - { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, - { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, - { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, - { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, - { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, - { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, - { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, - { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, - { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, - { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, - { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, - { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, - { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, - { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, - { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, - { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, - { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, - { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, - { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, - { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, - { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, - { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, - { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, - { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, - { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, - { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, - { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, - { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, - { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, - { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, - { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, - { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, -] - -[[package]] -name = "platformdirs" -version = "4.9.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "prometheus-client" -version = "0.25.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/fb/d9aa83ffe43ce1f19e557c0971d04b90561b0cfd50762aafb01968285553/prometheus_client-0.25.0.tar.gz", hash = "sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28", size = 86035, upload-time = "2026-04-09T19:53:42.359Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/9b/d4b1e644385499c8346fa9b622a3f030dce14cd6ef8a1871c221a17a67e7/prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1", size = 64154, upload-time = "2026-04-09T19:53:41.324Z" }, -] - -[[package]] -name = "prompt-toolkit" -version = "3.0.52" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wcwidth" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, -] - -[[package]] -name = "psutil" -version = "7.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, - { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, - { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, - { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, - { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, - { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, - { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, - { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, - { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, - { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, - { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, - { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, - { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, - { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, - { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, - { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, - { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, - { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, - { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, -] - -[[package]] -name = "ptyprocess" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, -] - -[[package]] -name = "pure-eval" -version = "0.2.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, -] - -[[package]] -name = "pycparser" -version = "3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, -] - -[[package]] -name = "pygments" -version = "2.20.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, -] - -[[package]] -name = "pylint" -version = "4.0.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "astroid" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "dill" }, - { name = "isort" }, - { name = "mccabe" }, - { name = "platformdirs" }, - { name = "tomlkit" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e4/b6/74d9a8a68b8067efce8d07707fe6a236324ee1e7808d2eb3646ec8517c7d/pylint-4.0.5.tar.gz", hash = "sha256:8cd6a618df75deb013bd7eb98327a95f02a6fb839205a6bbf5456ef96afb317c", size = 1572474, upload-time = "2026-02-20T09:07:33.621Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" }, -] - -[[package]] -name = "pyparsing" -version = "3.3.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, -] - -[[package]] -name = "pyproject-hooks" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, -] - -[[package]] -name = "pytest" -version = "9.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, -] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, -] - -[[package]] -name = "python-discovery" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "filelock" }, - { name = "platformdirs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/de/ef/3bae0e537cfe91e8431efcba4434463d2c5a65f5a89edd47c6cf2f03c55f/python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb", size = 58872, upload-time = "2026-04-07T17:28:49.249Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" }, -] - -[[package]] -name = "python-json-logger" -version = "4.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f7/ff/3cc9165fd44106973cd7ac9facb674a65ed853494592541d339bdc9a30eb/python_json_logger-4.1.0.tar.gz", hash = "sha256:b396b9e3ed782b09ff9d6e4f1683d46c83ad0d35d2e407c09a9ebbf038f88195", size = 17573, upload-time = "2026-03-29T04:39:56.805Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/be/0631a861af4d1c875f096c07d34e9a63639560a717130e7a87cbc82b7e3f/python_json_logger-4.1.0-py3-none-any.whl", hash = "sha256:132994765cf75bf44554be9aa49b06ef2345d23661a96720262716438141b6b2", size = 15021, upload-time = "2026-03-29T04:39:55.266Z" }, -] - -[[package]] -name = "python-levenshtein" -version = "0.27.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "levenshtein" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f6/b4/36eda4188dd19d3cb53d8a8749d7520bd23dfe1c1f44e56ea9dcd0232274/python_levenshtein-0.27.3.tar.gz", hash = "sha256:27dc2d65aeb62a7d6852388f197073296370779286c0860b087357f3b8129a62", size = 12446, upload-time = "2025-11-01T12:54:59.712Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/5b/26e3cca2589252ceabf964ba81514e6f48556553c9c2766e1a0fdceec696/python_levenshtein-0.27.3-py3-none-any.whl", hash = "sha256:5d6168a8e8befb25abf04d2952368a446722be10e8ced218d0dc4fd3703a43a1", size = 9504, upload-time = "2025-11-01T12:54:58.933Z" }, -] - -[[package]] -name = "pywin32-ctypes" -version = "0.2.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, -] - -[[package]] -name = "pywinpty" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f7/54/37c7370ba91f579235049dc26cd2c5e657d2a943e01820844ffc81f32176/pywinpty-3.0.3.tar.gz", hash = "sha256:523441dc34d231fb361b4b00f8c99d3f16de02f5005fd544a0183112bcc22412", size = 31309, upload-time = "2026-02-04T21:51:09.524Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/d4/aeb5e1784d2c5bff6e189138a9ca91a090117459cea0c30378e1f2db3d54/pywinpty-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c9081df0e49ffa86d15db4a6ba61530630e48707f987df42c9d3313537e81fc0", size = 2113098, upload-time = "2026-02-04T21:54:37.711Z" }, - { url = "https://files.pythonhosted.org/packages/b9/53/7278223c493ccfe4883239cf06c823c56460a8010e0fc778eef67858dc14/pywinpty-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:15e79d870e18b678fb8a5a6105fd38496b55697c66e6fc0378236026bc4d59e9", size = 234901, upload-time = "2026-02-04T21:53:31.35Z" }, - { url = "https://files.pythonhosted.org/packages/e5/cb/58d6ed3fd429c96a90ef01ac9a617af10a6d41469219c25e7dc162abbb71/pywinpty-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9c91dbb026050c77bdcef964e63a4f10f01a639113c4d3658332614544c467ab", size = 2112686, upload-time = "2026-02-04T21:52:03.035Z" }, - { url = "https://files.pythonhosted.org/packages/fd/50/724ed5c38c504d4e58a88a072776a1e880d970789deaeb2b9f7bd9a5141a/pywinpty-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:fe1f7911805127c94cf51f89ab14096c6f91ffdcacf993d2da6082b2142a2523", size = 234591, upload-time = "2026-02-04T21:52:29.821Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ad/90a110538696b12b39fd8758a06d70ded899308198ad2305ac68e361126e/pywinpty-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:3f07a6cf1c1d470d284e614733c3d0f726d2c85e78508ea10a403140c3c0c18a", size = 2112360, upload-time = "2026-02-04T21:55:33.397Z" }, - { url = "https://files.pythonhosted.org/packages/44/0f/7ffa221757a220402bc79fda44044c3f2cc57338d878ab7d622add6f4581/pywinpty-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:15c7c0b6f8e9d87aabbaff76468dabf6e6121332c40fc1d83548d02a9d6a3759", size = 233107, upload-time = "2026-02-04T21:51:45.455Z" }, - { url = "https://files.pythonhosted.org/packages/28/88/2ff917caff61e55f38bcdb27de06ee30597881b2cae44fbba7627be015c4/pywinpty-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:d4b6b7b0fe0cdcd02e956bd57cfe9f4e5a06514eecf3b5ae174da4f951b58be9", size = 2113282, upload-time = "2026-02-04T21:52:08.188Z" }, - { url = "https://files.pythonhosted.org/packages/63/32/40a775343ace542cc43ece3f1d1fce454021521ecac41c4c4573081c2336/pywinpty-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:34789d685fc0d547ce0c8a65e5a70e56f77d732fa6e03c8f74fefb8cbb252019", size = 234207, upload-time = "2026-02-04T21:51:58.687Z" }, - { url = "https://files.pythonhosted.org/packages/8d/54/5d5e52f4cb75028104ca6faf36c10f9692389b1986d34471663b4ebebd6d/pywinpty-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:0c37e224a47a971d1a6e08649a1714dac4f63c11920780977829ed5c8cadead1", size = 2112910, upload-time = "2026-02-04T21:52:30.976Z" }, - { url = "https://files.pythonhosted.org/packages/0a/44/dcd184824e21d4620b06c7db9fbb15c3ad0a0f1fa2e6de79969fb82647ec/pywinpty-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:c4e9c3dff7d86ba81937438d5819f19f385a39d8f592d4e8af67148ceb4f6ab5", size = 233425, upload-time = "2026-02-04T21:51:56.754Z" }, -] - -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, -] - -[[package]] -name = "pyzmq" -version = "27.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "implementation_name == 'pypy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, - { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, - { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, - { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, - { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, - { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, - { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, - { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, - { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, - { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, - { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, - { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, - { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, - { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, - { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, - { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, - { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, - { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" }, - { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" }, - { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" }, - { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" }, - { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" }, - { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" }, - { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" }, - { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, - { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, - { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, -] - -[[package]] -name = "rapidfuzz" -version = "3.14.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/21/ef6157213316e85790041254259907eb722e00b03480256c0545d98acd33/rapidfuzz-3.14.5.tar.gz", hash = "sha256:ba10ac57884ce82112f7ed910b67e7fb6072d8ef2c06e30dc63c0f604a112e0e", size = 57901753, upload-time = "2026-04-07T11:16:31.931Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/e3/574435c6aafb80254c191ef40d7aca2cb2bb97a095ec9395e9fa59ac307a/rapidfuzz-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d3378f471ef440473a396ce2f8e97ee12f89a78b495540e0a5617bbfe895638", size = 1944601, upload-time = "2026-04-07T11:14:18.771Z" }, - { url = "https://files.pythonhosted.org/packages/d0/1f/fbad3102a255ecc112ce9a7e779bacab7fd14398217be8868dc9082ba363/rapidfuzz-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e910eebca9fd0eba245c0555e764597e8a0cccb673a92da2dc2397050725f48", size = 1164293, upload-time = "2026-04-07T11:14:20.534Z" }, - { url = "https://files.pythonhosted.org/packages/88/37/a3eb7ff6121ed3a5f199a8c38cc86c8e481816f879cb0e0b738b078c9a7e/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01550fe5f60fd176aa66b7611289d46dc4aa4b1b904874c7b6d1d54e581c5ec1", size = 1371999, upload-time = "2026-04-07T11:14:22.63Z" }, - { url = "https://files.pythonhosted.org/packages/79/72/97a9728c711c7c1b06e107d3f0623880fb4ef90e147ed13c551a1730e7cc/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48bee0b91bebfaec41e1081e351000659ab7570cc4598d617aa04d5bf827f9e6", size = 3145715, upload-time = "2026-04-07T11:14:24.508Z" }, - { url = "https://files.pythonhosted.org/packages/ed/54/d5caabbea233ac90c286c87c260e49d7641467e87438a18d858e41c82e91/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:7e580cb04ad849ae9b786fa21383c6b994b6e6c1444ad1cb9f22392759d72741", size = 1456304, upload-time = "2026-04-07T11:14:26.515Z" }, - { url = "https://files.pythonhosted.org/packages/fc/a7/2d1a81250ac8c01a0100c026018e76f0e7a097ff63e4c553e02a6938c6fb/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:09d6c9ba091854f07817055d795d604179c12a8f308ba4c7d56f3719dfea1646", size = 2389089, upload-time = "2026-04-07T11:14:28.635Z" }, - { url = "https://files.pythonhosted.org/packages/65/0d/c47c3872203ae88e6506997c0b576ad731f5261daa25d559be09c9756658/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1e989f86113be66574113b9c7bdf4793f3f863d248e47d911b355e05ca6b6b10", size = 2493404, upload-time = "2026-04-07T11:14:30.577Z" }, - { url = "https://files.pythonhosted.org/packages/8f/2f/71e0a5a3130792146c8a200a2dd1e52aa16f7c1074012e17f2601eea9a90/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ebd1a18e2e47bc0b292a07e6ed9c3642f8aaa672d12253885f599b50807a4f9", size = 4251709, upload-time = "2026-04-07T11:14:32.451Z" }, - { url = "https://files.pythonhosted.org/packages/86/45/d39874901abacef325adb5b34ae416817c8486dfb4fb87c7a9b74ec5b072/rapidfuzz-3.14.5-cp312-cp312-win32.whl", hash = "sha256:9981d38a703b86f0e315a3cd229fd1906fe1d91c989ed121fb975b3c849f89f5", size = 1710069, upload-time = "2026-04-07T11:14:34.37Z" }, - { url = "https://files.pythonhosted.org/packages/85/0b/f65572c53de8a1c704bda707f63a447b67bdbe95d7cdc70d18885e191df5/rapidfuzz-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:d8375e3da319593389727c3187ccaf3e0e84199accc530866b8e0f2b79af05e9", size = 1540630, upload-time = "2026-04-07T11:14:36.287Z" }, - { url = "https://files.pythonhosted.org/packages/5e/c3/143be3a578f989758cae516f3270d5cbb49783a7bfdf57cc27a670e00456/rapidfuzz-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:478b59bb018a6780d73f33e38d0b3ec5e968a6c1ed42876b993dd456b7aa20e8", size = 813137, upload-time = "2026-04-07T11:14:38.289Z" }, - { url = "https://files.pythonhosted.org/packages/11/66/252803f2010ba699618cdc048b6e1f7cc1f433c08b4a9a17579b92ab0142/rapidfuzz-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ebd8fd343bf8492a1e60bcb6dc99f90f74f65d98d8241a6b3e1fed225b76ecd6", size = 1940205, upload-time = "2026-04-07T11:14:40.319Z" }, - { url = "https://files.pythonhosted.org/packages/ea/59/b2afd98e41af9cd54554a4c1c423d84cdd60e6b1c0a09496f033b55f60ec/rapidfuzz-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6737b35d5af7479c5bf9710f7b17edd9d2c43128d974d25fb4ea653e42c64609", size = 1159639, upload-time = "2026-04-07T11:14:42.52Z" }, - { url = "https://files.pythonhosted.org/packages/a3/31/7aa7e62c4c516a7af322ed0c4f0774208b72d457d0cfec808bad0df12f4a/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b002c7994cc9f2bc9d9856f0fbaee6e8072c983873846c92f25cefba5b2a925f", size = 1367194, upload-time = "2026-04-07T11:14:44.25Z" }, - { url = "https://files.pythonhosted.org/packages/90/79/2fc252a63bc91d3c3b234d0a3a6ad4ebc460037a23cdcdaf9285f986e6c9/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17a34330cd2a538c1ce5d400b61ba358c5b72c654b928ff87b362e88f8b864c7", size = 3151805, upload-time = "2026-04-07T11:14:46.21Z" }, - { url = "https://files.pythonhosted.org/packages/17/54/0c83508f2683ea70e2d05f8527eb07328acf7bb1e9d97a3bece5702378e7/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:95d937e74c1a7a1287dfb03b62a827be08ede10a155cf1af73bbf47f2b73ee6e", size = 1455667, upload-time = "2026-04-07T11:14:47.991Z" }, - { url = "https://files.pythonhosted.org/packages/71/1b/070175e873177814d58850a01ebe80e20ae11e93eb4da894d563988660fa/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:46b92a9970dcc34f0096901c792644094cab49554ac3547f35e3aebbdf0a3610", size = 2388246, upload-time = "2026-04-07T11:14:50.098Z" }, - { url = "https://files.pythonhosted.org/packages/c9/dd/77caf7aaf9c2be050ad1f128d7c24ff0f59079aa62c5f62f9df41c0af45e/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e012177c8e8a8a0754ae0d6027d63042aa5ff036d9f40f07cb3466a6082e21b8", size = 2494333, upload-time = "2026-04-07T11:14:52.303Z" }, - { url = "https://files.pythonhosted.org/packages/2c/e2/dd7e1f2aa31a8fbbfc16b0610af1d770ffaf1287490f3c8c5b1c52da264f/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a2ae6f53f99c9a0eca7a0afc5b4e45fc73bc1dd4ac74c00509031d76df80ed98", size = 4258579, upload-time = "2026-04-07T11:14:54.538Z" }, - { url = "https://files.pythonhosted.org/packages/9c/0a/ac99e1ba347ba0e85e0bb60b74231d55fb93c0eff43f2920ccb413d0be08/rapidfuzz-3.14.5-cp313-cp313-win32.whl", hash = "sha256:4a60f0057231188e3bd30216f7b4e0f279b11fa4ec818bb6c1d9f014d1562fbc", size = 1709231, upload-time = "2026-04-07T11:14:56.524Z" }, - { url = "https://files.pythonhosted.org/packages/cf/cb/0e251d731b3166378644238e8f0cf9e89858c024e19f75ca9f7e3ae83fd5/rapidfuzz-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:11bfc2ed8fbe4ab86bd516fadefab126f90e6dcadffa761739fcb304707dfd35", size = 1538519, upload-time = "2026-04-07T11:14:58.635Z" }, - { url = "https://files.pythonhosted.org/packages/30/6f/4548132acc947db6d5346a248e44a8b3a22d608ef30e770fb578caaf2d00/rapidfuzz-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:b486b5218808f6f4dc471b114b1054e63553db69705c97da0271f47bd706aedd", size = 812628, upload-time = "2026-04-07T11:15:00.552Z" }, - { url = "https://files.pythonhosted.org/packages/00/60/69b177577290c5eab892c6f75fe89c3aff3f9ae80298a78d9372b1cecb9a/rapidfuzz-3.14.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:39ef8658aaf67d51667e7bdaf7096f432333377d8302ac43c70b5df8a4cf89b8", size = 1970231, upload-time = "2026-04-07T11:15:02.603Z" }, - { url = "https://files.pythonhosted.org/packages/48/38/2fd790052659cc4e2907b63c25433f0987864b445c1aeec1a302ef5ad948/rapidfuzz-3.14.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ad37a0be705b544af6296da8edddc260d10a8ae5462530fc9991f66498bb1f9", size = 1194394, upload-time = "2026-04-07T11:15:04.572Z" }, - { url = "https://files.pythonhosted.org/packages/80/f4/28430ad8472fc3536e8ebd51a864a226e979cfe924c6e3f83d111373aa74/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d45e06f60729e07d9b20c205f7e5cff90b6ef2584e852eecf46e045aea69627d", size = 1377051, upload-time = "2026-04-07T11:15:06.728Z" }, - { url = "https://files.pythonhosted.org/packages/77/7e/9aeacabcfd1e77397968362e5b98fe14248b8307011136b17daf99752a8e/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e52da10236aa6212de71b9e170bace65b64b129c0dea7fc243d6c9ce976f5074", size = 3160565, upload-time = "2026-04-07T11:15:08.667Z" }, - { url = "https://files.pythonhosted.org/packages/56/f4/db4dd7be0cd2f2022117ac5407d905f435d60e48baaea313a567ad27e865/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:440d30faaf682ca496170a7f0cc5453ec942e3e079f0fd802c9a7f938dfb50a3", size = 1442113, upload-time = "2026-04-07T11:15:11.138Z" }, - { url = "https://files.pythonhosted.org/packages/a4/99/0e9f6aa57f3e32a767216f797e56dc96b720fcecfb9d8ee907ecc82f8d66/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:56227a61fd3d17b0cd9793132431f3a3d07c8654be96794ba9f89fe0fc8b2d09", size = 2396618, upload-time = "2026-04-07T11:15:13.154Z" }, - { url = "https://files.pythonhosted.org/packages/60/94/44a78e39ffce17cbdd3e2b53b696acc751d5d153be0f499d052b07a4d904/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:2e83cd2e25bb4edd97b689d9979d9c3acccdaaf26ceac08212ceece202febcfa", size = 2478220, upload-time = "2026-04-07T11:15:15.193Z" }, - { url = "https://files.pythonhosted.org/packages/dd/df/454311469a09a507e9d784a35796742bec22e4cebe75551e2da4e0e290fd/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:af3b859726cd3374287e405e14b9634563c078c5531a4f62375508addebddad1", size = 4265027, upload-time = "2026-04-07T11:15:17.28Z" }, - { url = "https://files.pythonhosted.org/packages/fc/01/175465a9ab3e3b70ba669058372f009d1d49c1746e2dcd56b69df188d3a5/rapidfuzz-3.14.5-cp313-cp313t-win32.whl", hash = "sha256:8ce1d850b3c0178440efde9e884d98421b5e87ff925f364d6d79e23910d7593f", size = 1766814, upload-time = "2026-04-07T11:15:19.687Z" }, - { url = "https://files.pythonhosted.org/packages/1b/a0/a9b84a47af06ebed94a1439eb2f02adebfb8628bcd30af1fe3e02f5ef56c/rapidfuzz-3.14.5-cp313-cp313t-win_amd64.whl", hash = "sha256:c84af70bcf34e99aee894e46a0f1ac77f17d0ef828179c387407642e2466d28a", size = 1582448, upload-time = "2026-04-07T11:15:21.98Z" }, - { url = "https://files.pythonhosted.org/packages/1e/f1/5937800238b3f8248e70860d79f69ba8f73e764fff47e36bc9e2f26dbcc6/rapidfuzz-3.14.5-cp313-cp313t-win_arm64.whl", hash = "sha256:aac0ad28c686a5e72b81668b906c030ee28050b244544b8af68e12fb32543895", size = 832932, upload-time = "2026-04-07T11:15:24.358Z" }, - { url = "https://files.pythonhosted.org/packages/81/41/aa3ffb3355e62e1bf91f6599b3092e866bc88487a07c524004943c7676df/rapidfuzz-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1a31cc6d7d03e7318a0974c038959c59e19c752b81115f2e9138b3331cd64d45", size = 1943327, upload-time = "2026-04-07T11:15:26.266Z" }, - { url = "https://files.pythonhosted.org/packages/2d/e1/c2141f1840a41e07ad2db6f724945f8f8ff3065463899a22939152dd6e09/rapidfuzz-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0298d357e2bc59d572da4db0bc631009b6f8f6c9bc8c11e99a12b833f16b6575", size = 1161755, upload-time = "2026-04-07T11:15:28.659Z" }, - { url = "https://files.pythonhosted.org/packages/ca/07/66e753eeaa353161d1d331b7dd517bb349b0bacfebe8496d7b26be26f81f/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:59b3dba758661a318995655435c6ab20a04ade79fa51e75bc8dc107cac8df280", size = 1376571, upload-time = "2026-04-07T11:15:31.225Z" }, - { url = "https://files.pythonhosted.org/packages/c8/85/9535df0b78ba51f478c9ce7eb6d1f85535cc31fe356773b48fd9d3e563ca/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4900143d82071bdda533b00300c40b14b963ff826b3642cc463b6dd0f036585e", size = 3156468, upload-time = "2026-04-07T11:15:33.428Z" }, - { url = "https://files.pythonhosted.org/packages/81/ee/b667eb93bba6dc4e0de658edd778e1619dc4d6aab68fa5e5c7f075152735/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:feedf219672eef83ea6be6f3bb093bba396a8560fc75be85ba225f082903df0a", size = 1458311, upload-time = "2026-04-07T11:15:35.557Z" }, - { url = "https://files.pythonhosted.org/packages/7d/ce/479074f5624364a48df3403c538797ef22d3ac49c19dc76c3f79fcdcc70c/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:419e4397a36e2665ec992d8d64c20ba4b2a42500c76ecadeca78a4f19cb9cc32", size = 2398228, upload-time = "2026-04-07T11:15:37.669Z" }, - { url = "https://files.pythonhosted.org/packages/0b/15/a8982f649150fffbdcd6f17565974501f6ab33b2795267bffbd4a7ba905b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:97131ab2be39043054ee28d99e09efe316e6d53449b7e962dfcf3c2de8b2b246", size = 2497226, upload-time = "2026-04-07T11:15:39.857Z" }, - { url = "https://files.pythonhosted.org/packages/19/52/5267c03ef6759831b7d4625a0c9c06e87baa2fae084b61ac9c388858317b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:593c00dac4e30231c35bf3b4f1da8ec0998762e9e94425586a5d636fcd57f9d0", size = 4262283, upload-time = "2026-04-07T11:15:42.279Z" }, - { url = "https://files.pythonhosted.org/packages/71/c0/2579f343a97f5254c43bb5853baccc01488357dcb64a27bcb869b7888a4a/rapidfuzz-3.14.5-cp314-cp314-win32.whl", hash = "sha256:0084b687b02b4e569b46d8d6d4ad25659528e6081cd6d067ca453a69035f07e4", size = 1744614, upload-time = "2026-04-07T11:15:44.498Z" }, - { url = "https://files.pythonhosted.org/packages/17/eb/8edfed1e80119dc9c35b11df4bc701eea85622ad681fff0263b6961d3224/rapidfuzz-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:5dfa89d78f22cd773054caff44827b846161a29f2dcf7e78b8f90d086621e502", size = 1588971, upload-time = "2026-04-07T11:15:46.86Z" }, - { url = "https://files.pythonhosted.org/packages/f6/04/5676df93c85cfa57a3045d8047318df9f3cd58c7b8a99340dd95f874795e/rapidfuzz-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:67f3f9d2b444268ab53e47d31bab89954888d23c04c6789f2c727e51fe4b1d13", size = 834985, upload-time = "2026-04-07T11:15:49.411Z" }, - { url = "https://files.pythonhosted.org/packages/f7/0d/4a8988cea658fe335048ddef8c876addff1b6daa3c9ca8ad65a5a2196e69/rapidfuzz-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:77eac0526899b3c3ad1454bb2b03cdb491d67358ec8ef0c9c48bd61b632b431d", size = 1972517, upload-time = "2026-04-07T11:15:51.819Z" }, - { url = "https://files.pythonhosted.org/packages/1c/a3/f5cfd9965a9d9a9e32249159797c47b5d6299ea6d1629f9126b25f1c10a3/rapidfuzz-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b9c6bd754d11f6e78ac54e3d86b4b11dc1ba2f13e5fc958899574532897f5a99", size = 1196056, upload-time = "2026-04-07T11:15:54.292Z" }, - { url = "https://files.pythonhosted.org/packages/64/07/561c2e40cfd10e6630a7b0ac5a2a813aef50d944bcd1f3d260319d659d5b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:738c96944d076deeaff70e92b65696ab4f7ecb8081d7791c5403a3257dfaf8ff", size = 1374732, upload-time = "2026-04-07T11:15:56.584Z" }, - { url = "https://files.pythonhosted.org/packages/c2/39/123bb94fee40e2fb3b7c49b80827c7ef42d838e18def3fc2fef5a3cf817a/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4c1bca487a17fe4226b4ffb2d30e799d2b274d692cffa76bd0746f56235fca3", size = 3166902, upload-time = "2026-04-07T11:15:58.768Z" }, - { url = "https://files.pythonhosted.org/packages/75/0a/45716fafc9fd2e028cf20b5ac5bc704887081cd312f84edb0e325599414b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:af6a90a4ed2a48fa1a2d17e9d824e6c7c950bea5bad0b707c77fd55751e6bfef", size = 1452130, upload-time = "2026-04-07T11:16:01.453Z" }, - { url = "https://files.pythonhosted.org/packages/ca/49/4e96c413114398481c0a5b0086af32c364a18613c9a2ea578d17c4bea4ee/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bf5018938208d4597b2e679a4f8cff9fd252f1df53583130ae56281a21801b64", size = 2396308, upload-time = "2026-04-07T11:16:03.588Z" }, - { url = "https://files.pythonhosted.org/packages/89/b7/49fea9fc6878d59bd259d01dd1972d9b86117992b1c66d9b16f0a65273c3/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c0919d1f89ddf91129906705723118ea09754171e4116f5a5dbc667c7bc9b261", size = 2488210, upload-time = "2026-04-07T11:16:05.871Z" }, - { url = "https://files.pythonhosted.org/packages/0c/44/a1f732b93ffacbdad077b7c801149549b2938e1bece6addb5ad85ed74df8/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:93d8da883a35116d6813432177f35e570db5b0a5e30ecb0cbd7cb39c815735df", size = 4270621, upload-time = "2026-04-07T11:16:08.483Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ce/ff942d19fce5385054650bb71a58495ddda299d94661ccc4e6e7fa44868b/rapidfuzz-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:0f23e37019ec07712d58976b1ab2b889f8649a7f7c2f626a2f34ea9139e79279", size = 1803950, upload-time = "2026-04-07T11:16:10.873Z" }, - { url = "https://files.pythonhosted.org/packages/5c/0f/9aafc63f9661222b819b391c187eed29fc90ad5935f9690e5ecc2d2047a4/rapidfuzz-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7d5ca9c7832e6879a707296d1463685f7c243a27846227044504741640caec66", size = 1632357, upload-time = "2026-04-07T11:16:13.1Z" }, - { url = "https://files.pythonhosted.org/packages/70/a6/51fc1b0e61e3326e1c68a61cfd0c6b3c34c843681c4b1eefbf0596f59162/rapidfuzz-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3e91dcd2549b8f8d843f98ba03a17e01f3d8b72ce942adbbb6761bc58ffce813", size = 855409, upload-time = "2026-04-07T11:16:15.787Z" }, -] - -[[package]] -name = "readme-renderer" -version = "44.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "docutils" }, - { name = "nh3" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5a/a9/104ec9234c8448c4379768221ea6df01260cd6c2ce13182d4eac531c8342/readme_renderer-44.0.tar.gz", hash = "sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1", size = 32056, upload-time = "2024-07-08T15:00:57.805Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl", hash = "sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151", size = 13310, upload-time = "2024-07-08T15:00:56.577Z" }, -] - -[[package]] -name = "referencing" -version = "0.37.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "rpds-py" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, -] - -[[package]] -name = "requests" -version = "2.33.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, -] - -[[package]] -name = "requests-toolbelt" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, -] - -[[package]] -name = "rfc3339-validator" -version = "0.1.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, -] - -[[package]] -name = "rfc3986" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", size = 49026, upload-time = "2022-01-10T00:52:30.832Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", size = 31326, upload-time = "2022-01-10T00:52:29.594Z" }, -] - -[[package]] -name = "rfc3986-validator" -version = "0.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/da/88/f270de456dd7d11dcc808abfa291ecdd3f45ff44e3b549ffa01b126464d0/rfc3986_validator-0.1.1.tar.gz", hash = "sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055", size = 6760, upload-time = "2019-10-28T16:00:19.144Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl", hash = "sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9", size = 4242, upload-time = "2019-10-28T16:00:13.976Z" }, -] - -[[package]] -name = "rfc3987-syntax" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "lark" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2c/06/37c1a5557acf449e8e406a830a05bf885ac47d33270aec454ef78675008d/rfc3987_syntax-1.1.0.tar.gz", hash = "sha256:717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d", size = 14239, upload-time = "2025-07-18T01:05:05.015Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/71/44ce230e1b7fadd372515a97e32a83011f906ddded8d03e3c6aafbdedbb7/rfc3987_syntax-1.1.0-py3-none-any.whl", hash = "sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f", size = 8046, upload-time = "2025-07-18T01:05:03.843Z" }, -] - -[[package]] -name = "rich" -version = "15.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, -] - -[[package]] -name = "roman-numerals" -version = "4.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, -] - -[[package]] -name = "rpds-py" -version = "0.30.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, - { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, - { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, - { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, - { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, - { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, - { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, - { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, - { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, - { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, - { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, - { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, - { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, - { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, - { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, - { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, - { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, - { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, - { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, - { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, - { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, - { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, - { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, - { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, - { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, - { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, - { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, - { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, - { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, - { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, - { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, - { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, - { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, - { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, - { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, - { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, - { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, - { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, - { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, - { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, - { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, - { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, - { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, - { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, - { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, - { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, - { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, - { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, - { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, - { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, - { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, - { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, - { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, - { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, - { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, - { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, - { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, - { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, - { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, - { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, -] - -[[package]] -name = "ruff" -version = "0.15.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, - { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, - { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, - { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, - { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, - { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, - { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, - { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, - { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, - { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, - { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, - { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, - { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, -] - -[[package]] -name = "scipy" -version = "1.17.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, - { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, - { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, - { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, - { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, - { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, - { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, - { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, - { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, - { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, - { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, - { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, - { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, - { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, - { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, - { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, - { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, - { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, - { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, - { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, - { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, - { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, - { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, - { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, - { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, - { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, - { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, - { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, - { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, - { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, - { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, - { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, - { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, - { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, - { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, - { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, - { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, - { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, - { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, - { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, - { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, - { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, - { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, -] - -[[package]] -name = "secretstorage" -version = "3.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "jeepney" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, -] - -[[package]] -name = "send2trash" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c5/f0/184b4b5f8d00f2a92cf96eec8967a3d550b52cf94362dad1100df9e48d57/send2trash-2.1.0.tar.gz", hash = "sha256:1c72b39f09457db3c05ce1d19158c2cbef4c32b8bedd02c155e49282b7ea7459", size = 17255, upload-time = "2026-01-14T06:27:36.056Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/78/504fdd027da3b84ff1aecd9f6957e65f35134534ccc6da8628eb71e76d3f/send2trash-2.1.0-py3-none-any.whl", hash = "sha256:0da2f112e6d6bb22de6aa6daa7e144831a4febf2a87261451c4ad849fe9a873c", size = 17610, upload-time = "2026-01-14T06:27:35.218Z" }, -] - -[[package]] -name = "setuptools" -version = "82.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, -] - -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, -] - -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, -] - -[[package]] -name = "snowballstemmer" -version = "3.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, -] - -[[package]] -name = "soupsieve" -version = "2.8.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, -] - -[[package]] -name = "sphinx" -version = "9.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "alabaster" }, - { name = "babel" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "docutils" }, - { name = "imagesize" }, - { name = "jinja2" }, - { name = "packaging" }, - { name = "pygments" }, - { name = "requests" }, - { name = "roman-numerals" }, - { name = "snowballstemmer" }, - { name = "sphinxcontrib-applehelp" }, - { name = "sphinxcontrib-devhelp" }, - { name = "sphinxcontrib-htmlhelp" }, - { name = "sphinxcontrib-jsmath" }, - { name = "sphinxcontrib-qthelp" }, - { name = "sphinxcontrib-serializinghtml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, -] - -[[package]] -name = "sphinxcontrib-applehelp" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, -] - -[[package]] -name = "sphinxcontrib-devhelp" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, -] - -[[package]] -name = "sphinxcontrib-htmlhelp" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, -] - -[[package]] -name = "sphinxcontrib-jsmath" -version = "1.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, -] - -[[package]] -name = "sphinxcontrib-qthelp" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, -] - -[[package]] -name = "sphinxcontrib-serializinghtml" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, -] - -[[package]] -name = "stack-data" -version = "0.6.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "asttokens" }, - { name = "executing" }, - { name = "pure-eval" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, -] - -[[package]] -name = "terminado" -version = "0.18.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ptyprocess", marker = "os_name != 'nt'" }, - { name = "pywinpty", marker = "os_name == 'nt'" }, - { name = "tornado" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8a/11/965c6fd8e5cc254f1fe142d547387da17a8ebfd75a3455f637c663fb38a0/terminado-0.18.1.tar.gz", hash = "sha256:de09f2c4b85de4765f7714688fff57d3e75bad1f909b589fde880460c753fd2e", size = 32701, upload-time = "2024-03-12T14:34:39.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0", size = 14154, upload-time = "2024-03-12T14:34:36.569Z" }, -] - -[[package]] -name = "tinycss2" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "webencodings" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085, upload-time = "2024-10-24T14:58:29.895Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610, upload-time = "2024-10-24T14:58:28.029Z" }, -] - -[[package]] -name = "tomli-w" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, -] - -[[package]] -name = "tomlkit" -version = "0.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, -] - -[[package]] -name = "tornado" -version = "6.5.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" }, - { url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" }, - { url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" }, - { url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" }, - { url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" }, - { url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" }, - { url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" }, - { url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" }, -] - -[[package]] -name = "traitlets" -version = "5.14.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, -] - -[[package]] -name = "trove-classifiers" -version = "2026.1.14.14" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/43/7935f8ea93fcb6680bc10a6fdbf534075c198eeead59150dd5ed68449642/trove_classifiers-2026.1.14.14.tar.gz", hash = "sha256:00492545a1402b09d4858605ba190ea33243d361e2b01c9c296ce06b5c3325f3", size = 16997, upload-time = "2026-01-14T14:54:50.526Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/4a/2e5583e544bc437d5e8e54b47db87430df9031b29b48d17f26d129fa60c0/trove_classifiers-2026.1.14.14-py3-none-any.whl", hash = "sha256:1f9553927f18d0513d8e5ff80ab8980b8202ce37ecae0e3274ed2ef11880e74d", size = 14197, upload-time = "2026-01-14T14:54:49.067Z" }, -] - -[[package]] -name = "twine" -version = "6.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "id" }, - { name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" }, - { name = "packaging" }, - { name = "readme-renderer" }, - { name = "requests" }, - { name = "requests-toolbelt" }, - { name = "rfc3986" }, - { name = "rich" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e0/a8/949edebe3a82774c1ec34f637f5dd82d1cf22c25e963b7d63771083bbee5/twine-6.2.0.tar.gz", hash = "sha256:e5ed0d2fd70c9959770dce51c8f39c8945c574e18173a7b81802dab51b4b75cf", size = 172262, upload-time = "2025-09-04T15:43:17.255Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl", hash = "sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8", size = 42727, upload-time = "2025-09-04T15:43:15.994Z" }, -] - -[[package]] -name = "typer" -version = "0.25.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-doc" }, - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7b/27/ede8cec7596e0041ba7e7b80b47d132562f56ff454313a16f6084e555c9f/typer-0.25.0.tar.gz", hash = "sha256:123eaf9f19bb40fd268310e12a542c0c6b4fab9c98d9d23342a01ff95e3ce930", size = 120150, upload-time = "2026-04-26T08:46:14.767Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/72/193d4e586ec5a4db834a36bbeb47641a62f951f114ffd0fe5b1b46e8d56f/typer-0.25.0-py3-none-any.whl", hash = "sha256:ac01b48823d3db9a83c9e164338057eadbb1c9957a2a6b4eeb486669c560b5dc", size = 55993, upload-time = "2026-04-26T08:46:15.889Z" }, -] - -[[package]] -name = "types-requests" -version = "2.33.0.20260408" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/69/6a/749dc53a54a3f35842c1f8197b3ca6b54af6d7458a1bfc75f6629b6da666/types_requests-2.33.0.20260408.tar.gz", hash = "sha256:95b9a86376807a216b2fb412b47617b202091c3ea7c078f47cc358d5528ccb7b", size = 23882, upload-time = "2026-04-08T04:34:49.33Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/b8/78fd6c037de4788c040fdd323b3369804400351b7827473920f6c1d03c10/types_requests-2.33.0.20260408-py3-none-any.whl", hash = "sha256:81f31d5ea4acb39f03be7bc8bed569ba6d5a9c5d97e89f45ac43d819b68ca50f", size = 20739, upload-time = "2026-04-08T04:34:48.325Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, -] - -[[package]] -name = "tzdata" -version = "2026.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, -] - -[[package]] -name = "uri-template" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/31/c7/0336f2bd0bcbada6ccef7aaa25e443c118a704f828a0620c6fa0207c1b64/uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7", size = 21678, upload-time = "2023-06-21T01:49:05.374Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/00/3fca040d7cf8a32776d3d81a00c8ee7457e00f80c649f1e4a863c8321ae9/uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363", size = 11140, upload-time = "2023-06-21T01:49:03.467Z" }, -] - -[[package]] -name = "urllib3" -version = "2.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, -] - -[[package]] -name = "userpath" -version = "1.9.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d5/b7/30753098208505d7ff9be5b3a32112fb8a4cb3ddfccbbb7ba9973f2e29ff/userpath-1.9.2.tar.gz", hash = "sha256:6c52288dab069257cc831846d15d48133522455d4677ee69a9781f11dbefd815", size = 11140, upload-time = "2024-02-29T21:39:08.742Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/99/3ec6335ded5b88c2f7ed25c56ffd952546f7ed007ffb1e1539dc3b57015a/userpath-1.9.2-py3-none-any.whl", hash = "sha256:2cbf01a23d655a1ff8fc166dfb78da1b641d1ceabf0fe5f970767d380b14e89d", size = 9065, upload-time = "2024-02-29T21:39:07.551Z" }, -] - -[[package]] -name = "uv" -version = "0.11.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/da/34/609d5d01ba21dc8f0974610ca7802fbb2c946a0c38665cfe5c5aeddbefb5/uv-0.11.15.tar.gz", hash = "sha256:755f959ec6a2fd8ccb6ee76ad90ab759d2eb1f4797444078645dd1ee4bca92d6", size = 4159545, upload-time = "2026-05-18T19:57:48.133Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/7c/dcc230c5911884d8848145dabcac8fb95a5ed6f9fe1c57fae8242618f28a/uv-0.11.15-py3-none-linux_armv6l.whl", hash = "sha256:83b04ab49514a0a761ffedb36a748ee81f87746671e72088e5f32c9585e5f1a9", size = 23110183, upload-time = "2026-05-18T19:57:23.051Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f3/efd4e044b60eb9c3c12ee386be098d56c335538ccec7caa49349cfba9344/uv-0.11.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b6cae61f737be075b90be9e3f07d961072aed7019f4c9b8ed5c5d41c4d6cade3", size = 22637941, upload-time = "2026-05-18T19:57:26.752Z" }, - { url = "https://files.pythonhosted.org/packages/a6/b8/48627f895a1569e576822e0a8416aa4797eb4a4551de21a4ad97b9b5819d/uv-0.11.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9accae33619a9166e5c48531deb455d672cfb89f9357a00975e669c76b0bd49f", size = 21258803, upload-time = "2026-05-18T19:57:05.473Z" }, - { url = "https://files.pythonhosted.org/packages/af/50/4bc8a148274feabee2d9c9f1fa15009e10c0228dfe57981ee3ea2ef1d481/uv-0.11.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:c0cf52cd6d50bb9e05e2d968f45f80761107e4cbc8d4a26d9758f9d8274aaec1", size = 23066178, upload-time = "2026-05-18T19:57:33.058Z" }, - { url = "https://files.pythonhosted.org/packages/a9/56/139fc3bec9a8b0a25bfe2196123adb9f16124da437bf4fbcf0d21cfcafb2/uv-0.11.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:49dc6ed70bff00937384f96cdc4b1a4742d18e5504ec2c4a1214dba2dee5687a", size = 22705332, upload-time = "2026-05-18T19:57:36.714Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b0/b18b3dd204f8c213236a1ebd148e009861637129a8cce34df0e9aa22ed40/uv-0.11.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:adb9a89352539fdd8f7cd5f9966cf9f94fc5b98e0ccdf5003a04123dc6423bec", size = 22707534, upload-time = "2026-05-18T19:58:04.117Z" }, - { url = "https://files.pythonhosted.org/packages/76/36/3ca09f95572df99d361b49c96b1297149e96e120d8d1ecf074095a4b6da4/uv-0.11.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40ff67e3f8e8a7533781a2e892a534975a93acb83ea35460e64e7b2bf2111774", size = 24096607, upload-time = "2026-05-18T19:58:11.625Z" }, - { url = "https://files.pythonhosted.org/packages/64/be/3bdee21a296bbf5336a526e3613d0e7d4538dacc39c62d7fcba55d15f6b0/uv-0.11.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6463a299ed7e6b5a800ed6f108af8e1588352629424133ddef7572b0e1e1118", size = 25082562, upload-time = "2026-05-18T19:57:40.69Z" }, - { url = "https://files.pythonhosted.org/packages/cd/73/f371f3689ffe741066468d001d85f739fc4b5574de83b639ef19b5e8a7f4/uv-0.11.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:68c1e62d4b78578b90b833553286b65d6a7e327537716441068583ba652ec4f5", size = 24253391, upload-time = "2026-05-18T19:57:18.47Z" }, - { url = "https://files.pythonhosted.org/packages/d3/16/fe392d618af6b00c064b3e718d585dcf791546a77c5123a5bec07ce53a0a/uv-0.11.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98edf1bdaf82447014852051d93e3ee95012509c567bf057fd117e6bdbd9a807", size = 24415871, upload-time = "2026-05-18T19:58:19.651Z" }, - { url = "https://files.pythonhosted.org/packages/6e/24/2e92a052fb6334fcd746d1c7cb57847c204b118c84f5da53c0f9e129f7b7/uv-0.11.15-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:be8f76d25bcf4c92bb384240ac1bf9aa7f51063d0bdeca4c9cf0ec3ed8b145e0", size = 23159007, upload-time = "2026-05-18T19:57:10.653Z" }, - { url = "https://files.pythonhosted.org/packages/3d/2e/6923d0658d164bb2c435ed1868aa2d49b3074594679917a001ff92dc95bb/uv-0.11.15-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:f9f4fbbf4fe485522054f3c7496c6e8e932d6436e4200ff3daf718db0b7c7bd5", size = 23769385, upload-time = "2026-05-18T19:58:15.856Z" }, - { url = "https://files.pythonhosted.org/packages/a4/99/7e34cd949e57360814e8064cc9fb7104df445d0f6a663504e5f7473480aa/uv-0.11.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0ed920e896b2fd13a35031707e307e42fbb2681458b967440a17272d86d49137", size = 23860973, upload-time = "2026-05-18T19:57:55.575Z" }, - { url = "https://files.pythonhosted.org/packages/28/98/8fe1f5f9d816e94569a0298dd8e0936801097625fa1952162951f0d628b6/uv-0.11.15-py3-none-musllinux_1_1_i686.whl", hash = "sha256:41d907611f3e6a13262807fd7f0a17849f76285ca80f536f6b3943732bdc6656", size = 23431392, upload-time = "2026-05-18T19:57:59.814Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6b/76a1ce2fa860026913a5941700cdc7d715fce9c3277a3fa3489cf2523ca0/uv-0.11.15-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:e3b68f8bf1a4568710f77e5bda9182ce7682811d89a8e7468c22460e032b234d", size = 24519478, upload-time = "2026-05-18T19:57:51.165Z" }, - { url = "https://files.pythonhosted.org/packages/43/60/1d58e8a05718cb50494763115710b73846cacb651fd735d285233fd72c59/uv-0.11.15-py3-none-win32.whl", hash = "sha256:8e2da3076761086a5b76869c3f38ef0509c836046ef41ddd19485dfd7271dca9", size = 22020178, upload-time = "2026-05-18T19:58:07.64Z" }, - { url = "https://files.pythonhosted.org/packages/55/53/40fcefcb348af660488597ed3c01363df7344e60611f8883750dc596f5c6/uv-0.11.15-py3-none-win_amd64.whl", hash = "sha256:cc3915ab291a1ecaf31de05f5d8bd70d09c66fe9911a53f70d9efa62ff0dbd8a", size = 24668779, upload-time = "2026-05-18T19:57:44.894Z" }, - { url = "https://files.pythonhosted.org/packages/e5/7d/fa3a9960c95af9bbe2a629048760d0b9b4fead8ccd4f2235af747ec7cdf0/uv-0.11.15-py3-none-win_arm64.whl", hash = "sha256:4f39426a13dee24897aed60c4b98058c66f18bd983885ac5f4a54a04b24fbddf", size = 23198178, upload-time = "2026-05-18T19:57:14.68Z" }, -] - -[[package]] -name = "virtualenv" -version = "21.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "distlib" }, - { name = "filelock" }, - { name = "platformdirs" }, - { name = "python-discovery" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3f/8b/6331f7a7fe70131c301106ec1e7cf23e2501bf7d4ca3636805801ca191bb/virtualenv-21.3.0.tar.gz", hash = "sha256:733750db978ec95c2d8eb4feadaa57091002bce404cb39ba69899cf7bd28944e", size = 7614069, upload-time = "2026-04-27T17:05:58.927Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/eb/03bfb1299d4c4510329e470f13f9a4ce793df7fcb5a2fd3510f911066f61/virtualenv-21.3.0-py3-none-any.whl", hash = "sha256:4d28ee41f6d9ec8f1f00cd472b9ffbcedda1b3d3b9a575b5c94a2d004fd51bd7", size = 7594690, upload-time = "2026-04-27T17:05:55.468Z" }, -] - -[[package]] -name = "wcwidth" -version = "0.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, -] - -[[package]] -name = "webcolors" -version = "25.10.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/7a/eb316761ec35664ea5174709a68bbd3389de60d4a1ebab8808bfc264ed67/webcolors-25.10.0.tar.gz", hash = "sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf", size = 53491, upload-time = "2025-10-31T07:51:03.977Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/cc/e097523dd85c9cf5d354f78310927f1656c422bd7b2613b2db3e3f9a0f2c/webcolors-25.10.0-py3-none-any.whl", hash = "sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d", size = 14905, upload-time = "2025-10-31T07:51:01.778Z" }, -] - -[[package]] -name = "webencodings" -version = "0.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, -] - -[[package]] -name = "websocket-client" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, -] - -[[package]] -name = "wheel" -version = "0.47.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/39/62/75f18a0f03b4219c456652c7780e4d749b929eb605c098ce3a5b6b6bc081/wheel-0.47.0.tar.gz", hash = "sha256:cc72bd1009ba0cf63922e28f94d9d83b920aa2bb28f798a31d0691b02fa3c9b3", size = 63854, upload-time = "2026-04-22T15:51:27.727Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/1b/9e33c09813d65e248f7f773119148a612516a4bea93e9c6f545f78455b7c/wheel-0.47.0-py3-none-any.whl", hash = "sha256:212281cab4dff978f6cedd499cd893e1f620791ca6ff7107cf270781e587eced", size = 32218, upload-time = "2026-04-22T15:51:26.296Z" }, -] - -[[package]] -name = "widgetsnbextension" -version = "4.0.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bd/f4/c67440c7fb409a71b7404b7aefcd7569a9c0d6bd071299bf4198ae7a5d95/widgetsnbextension-4.0.15.tar.gz", hash = "sha256:de8610639996f1567952d763a5a41af8af37f2575a41f9852a38f947eb82a3b9", size = 1097402, upload-time = "2025-11-01T21:15:55.178Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl", hash = "sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366", size = 2196503, upload-time = "2025-11-01T21:15:53.565Z" }, -]