From e5337b28b19a8c49c7c7a3b696b64f93d0a22726 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:35:39 +0200 Subject: [PATCH 1/3] feat(core): copy units.py from desktop#621 again, unchanged Copied from tilia/timelines/beat/units.py (desktop#621 at 07cb6d4f), unchanged: the file is the same blob as there. Since a17bbd83, #621 refuses a unit longer than MAX_UNIT_LENGTH (32 characters) before Fraction() reads it, so a unit reads the same on every Python, and cuts it short in the message; MeasureMeter holds tuples, so it can be hashed; beat_unit_id is str | None, with a docstring for both copies; and quarter_length is exact for an int. The new TestParseUnits cases are ported. Part of TimeLineAnnotator/project-management#247 Co-Authored-By: Claude Opus 5.5 --- .../src/tilia_core/timemap/units.py | 37 +++++++++++++------ .../tests/timemap/test_timemap_units.py | 37 +++++++++++++++++++ 2 files changed, 62 insertions(+), 12 deletions(-) diff --git a/packages/tilia-core/src/tilia_core/timemap/units.py b/packages/tilia-core/src/tilia_core/timemap/units.py index 7295d85d6..3ca97a740 100644 --- a/packages/tilia-core/src/tilia_core/timemap/units.py +++ b/packages/tilia-core/src/tilia_core/timemap/units.py @@ -10,6 +10,7 @@ from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass from fractions import Fraction @@ -18,6 +19,10 @@ # Far above any denominator used in notation. Enforced wherever a beat unit # is set, so the dialog and inspector, which can't show more, never meet one. MAX_DENOMINATOR = 128 +# Far above any unit written in notation, and far below the 4300 digits +# Python 3.10.7 and later refuse to read as an int (earlier versions have no +# limit), so a unit reads the same on every Python. +MAX_UNIT_LENGTH = 32 _UNIT_CHARACTERS = set("0123456789/") @@ -50,12 +55,15 @@ def parse_units(text: str) -> UnitsParseResult: item = item.strip() if not item: return UnitsParseResult([], "Missing unit around '+'.") + too_long = len(item) > MAX_UNIT_LENGTH + shown = item[:MAX_UNIT_LENGTH] + "…" if too_long else item not_a_fraction = UnitsParseResult( - [], f"'{item}' is not a whole number or fraction such as 1/2." + [], f"'{shown}' is not a whole number or fraction such as 1/2." ) # Fraction() also accepts decimals, signs and exponents; units are - # written the way they are in notation. - if not set(item) <= _UNIT_CHARACTERS: + # written the way they are in notation. A longer unit is refused + # before Fraction() sees it, which may or may not read it. + if too_long or not set(item) <= _UNIT_CHARACTERS: return not_a_fraction try: unit = Fraction(item) @@ -68,7 +76,7 @@ def parse_units(text: str) -> UnitsParseResult: return UnitsParseResult(units) -def format_units(units: list[Fraction]) -> str: +def format_units(units: Sequence[Fraction]) -> str: """Writes units in canonical form, e.g. [2, 3] as "2+3".""" return "+".join(str(unit) for unit in units) @@ -95,15 +103,19 @@ def is_fit_ambiguous(units: list[Fraction], beat_count: int) -> bool: @dataclass(frozen=True) class MeasureMeter: """ - The meter a measure ends up with. `beat_unit_id` is the beat unit that - governs the measure, or None where the default applies. `is_assumed` is - True when its values weren't set by the user (see BeatUnit.assumed). + The meter a measure ends up with. `beat_unit_id` names the beat unit + that governs the measure, or is None where the default applies. In the + app it is that BeatUnit component's own id. In tilia_core's copy of this + module it is the id of the beat that carries the governing unit, since + tilia_core reads a .tla beat unit as part of its beat, without an id of + its own. `is_assumed` is True when its values weren't set by the user + (see BeatUnit.assumed). """ denominator: int - units: list[Fraction] - beat_values: list[Fraction] - beat_unit_id: int | None + units: tuple[Fraction, ...] + beat_values: tuple[Fraction, ...] + beat_unit_id: str | None starts_here: bool is_ambiguous: bool is_conflicting: bool @@ -117,5 +129,6 @@ def numerator(self) -> Fraction: def time_signature(self) -> tuple[Fraction, int]: return self.numerator, self.denominator - def quarter_length(self, beat_value: Fraction) -> Fraction: - return beat_value * 4 / self.denominator + def quarter_length(self, beat_value: Fraction | int) -> Fraction: + # An int divided by the denominator would give a float. + return Fraction(beat_value) * 4 / self.denominator diff --git a/packages/tilia-core/tests/timemap/test_timemap_units.py b/packages/tilia-core/tests/timemap/test_timemap_units.py index 4eba3a216..6711b687d 100644 --- a/packages/tilia-core/tests/timemap/test_timemap_units.py +++ b/packages/tilia-core/tests/timemap/test_timemap_units.py @@ -1,9 +1,11 @@ +import sys from fractions import Fraction as F import pytest from tilia_core.timemap.units import ( MAX_DENOMINATOR, + MAX_UNIT_LENGTH, fit_units, format_units, is_fit_ambiguous, @@ -39,6 +41,41 @@ def test_invalid(self, text): assert result.error assert result.units == [] + def test_longest_unit_is_read(self): + assert parse_units("1" * MAX_UNIT_LENGTH).is_valid + + def test_longer_unit_is_refused_and_cut_short_in_the_message(self): + result = parse_units("1" * (MAX_UNIT_LENGTH + 1)) + + assert not result.is_valid + assert result.units == [] + assert result.error == ( + f"'{'1' * MAX_UNIT_LENGTH}…' is not a whole number or fraction " + "such as 1/2." + ) + + @pytest.mark.skipif( + not hasattr(sys, "set_int_max_str_digits"), + reason="this Python has no limit on the digits int() reads", + ) + @pytest.mark.parametrize("digit_limit", [0, 4300]) + def test_very_long_unit_is_refused_whatever_the_digit_limit(self, digit_limit): + # Python 3.10.7 and later refuse to read an integer of more than 4300 + # digits by default; earlier versions read any length, as 0 does. + before = sys.get_int_max_str_digits() + sys.set_int_max_str_digits(digit_limit) + try: + result = parse_units("1" * 5000) + finally: + sys.set_int_max_str_digits(before) + + assert not result.is_valid + assert result.units == [] + assert result.error == ( + f"'{'1' * MAX_UNIT_LENGTH}…' is not a whole number or fraction " + "such as 1/2." + ) + def test_format(self): assert format_units([F(2), F(3)]) == "2+3" assert format_units([F(1, 3)]) == "1/3" From e2b1e6a86dd794c71d80b7dc2d8edcb428c91c51 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:36:14 +0200 Subject: [PATCH 2/3] feat(core): give MeasureMeter tuples in meters_of, as desktop#621 does meters_of passes the units and beat values as tuples, as #621's _compute_measure_meters now does, so a meter can be hashed. The tests compare tuples, and check that meters hash and that quarter_length is exact for an int, as #621's do. Part of TimeLineAnnotator/project-management#247 Co-Authored-By: Claude Opus 5.5 --- .../src/tilia_core/timemap/meters.py | 4 +-- .../tests/timemap/test_timemap_meters.py | 32 ++++++++++++++----- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/packages/tilia-core/src/tilia_core/timemap/meters.py b/packages/tilia-core/src/tilia_core/timemap/meters.py index 123945daf..d57904798 100644 --- a/packages/tilia-core/src/tilia_core/timemap/meters.py +++ b/packages/tilia-core/src/tilia_core/timemap/meters.py @@ -73,8 +73,8 @@ def meters_of(rows: Sequence[RowIn]) -> list[MeasureMeter]: meters.append( MeasureMeter( denominator=denominator, - units=units, - beat_values=fit_units(units, beat_count), + units=tuple(units), + beat_values=tuple(fit_units(units, beat_count)), beat_unit_id=governing.id if governing else None, starts_here=bool(in_measure) or index == 0, is_ambiguous=is_fit_ambiguous(units, beat_count), diff --git a/packages/tilia-core/tests/timemap/test_timemap_meters.py b/packages/tilia-core/tests/timemap/test_timemap_meters.py index 99c08b70e..a3cbf27b4 100644 --- a/packages/tilia-core/tests/timemap/test_timemap_meters.py +++ b/packages/tilia-core/tests/timemap/test_timemap_meters.py @@ -91,22 +91,22 @@ def test_ambiguous_fit(self): timeline = tapped(5, (2, 3), {0: unit(4, "2+3")}) result = meters(timeline) - assert [m.beat_values for m in result] == [[2, 3], [2, 3, 2]] + assert [m.beat_values for m in result] == [(2, 3), (2, 3, 2)] assert [m.is_ambiguous for m in result] == [False, True] def test_short_measures_take_the_start_of_the_pattern(self): timeline = tapped(6, (2,), {0: unit(4, "1+2+3")}) result = meters(timeline) - assert [m.beat_values for m in result] == [[1, 2]] * 3 + assert [m.beat_values for m in result] == [(1, 2)] * 3 assert all(m.is_ambiguous for m in result) def test_pickup_also_takes_the_start_of_the_pattern(self): timeline = tapped(3, (1, 2), {0: unit(4, "3+2")}) result = meters(timeline) - assert result[0].beat_values == [3] - assert result[1].beat_values == [3, 2] + assert result[0].beat_values == (3,) + assert result[1].beat_values == (3, 2) def test_two_beat_units_in_a_measure_first_wins(self): timeline = tapped(8, units={4: unit(8, "1"), 6: unit(2, "1")}) @@ -116,6 +116,22 @@ def test_two_beat_units_in_a_measure_first_wins(self): assert meter.beat_unit_id == beat_ids(timeline)[4] assert meter.denominator == 8 + def test_meters_can_be_hashed(self): + first, second, third = meters(tapped(6, (2,))) + + # The last two are both 2/4 under the first measure's beat unit. + assert hash(second) == hash(third) + assert {first, second, third} == {first, second} + + @pytest.mark.parametrize("beat_value", [1, F(1)]) + def test_quarter_length_is_exact(self, beat_value): + (meter,) = meters(tapped(3, (3,), {0: unit(6, "1")})) + + length = meter.quarter_length(beat_value) + + assert isinstance(length, F) + assert length == F(2, 3) + class TestBeforeTheFirstUnit: # TiLiA's loader keeps a unit in the first measure: a copy of the first @@ -127,7 +143,7 @@ def test_the_measures_take_the_first_units_values_assumed(self, first): result = meters(timeline) assert [m.time_signature for m in result] == [(6, 8)] * 4 - assert [m.units for m in result] == [[3]] * 4 + assert [m.units for m in result] == [(3,)] * 4 assert [m.is_assumed for m in result] == [True, True, True, False] assert [m.starts_here for m in result] == [True, False, False, True] assert [m.beat_unit_id for m in result] == [ @@ -142,7 +158,7 @@ def test_a_pattern_is_read_from_its_start_in_those_measures(self): timeline = tapped(5, (1, 2, 2), {0: None, 3: unit(8, "2+3")}) result = meters(timeline) - assert [m.beat_values for m in result] == [[2], [2, 3], [2, 3]] + assert [m.beat_values for m in result] == [(2,), (2, 3), (2, 3)] assert [m.is_ambiguous for m in result] == [True, False, False] @pytest.mark.parametrize("first", [None, unit(8, "x")]) @@ -151,7 +167,7 @@ def test_with_no_readable_unit_a_measure_of_n_taps_is_n_over_4(self, first): result = meters(timeline) assert [m.time_signature for m in result] == [(4, 4), (3, 4)] - assert [m.units for m in result] == [[1], [1]] + assert [m.units for m in result] == [(1,), (1,)] assert [m.beat_unit_id for m in result] == [None, None] assert [m.starts_here for m in result] == [True, False] assert all(m.is_assumed for m in result) @@ -251,7 +267,7 @@ def test_scenario( (meter,) = meters(tapped(taps, (taps,), {0: beat_unit})) assert meter.time_signature == time_signature - assert meter.beat_values == beat_values + assert meter.beat_values == tuple(beat_values) assert [meter.quarter_length(value) for value in meter.beat_values] == quarters assert meter.is_assumed == assumed assert meter.is_ambiguous == ambiguous From c6584d8faa477b10538f92a4f3c0fc0dec20c791 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:53:39 +0200 Subject: [PATCH 3/3] test(core): a beat unit longer than 32 characters is left out when read parse_units now refuses a unit longer than MAX_UNIT_LENGTH, so rows_of leaves such a beat unit out and the unit before it carries on. The tests of rows_of and meters_of cover it. _BeatUnit's docstring says when its units are parsed. Part of TimeLineAnnotator/project-management#247 Co-Authored-By: Claude Opus 5.5 --- packages/tilia-core/src/tilia_core/timemap/meters.py | 2 +- packages/tilia-core/tests/timemap/test_timemap_meters.py | 1 + packages/tilia-core/tests/timemap/test_timemap_rows.py | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/tilia-core/src/tilia_core/timemap/meters.py b/packages/tilia-core/src/tilia_core/timemap/meters.py index d57904798..bbc890970 100644 --- a/packages/tilia-core/src/tilia_core/timemap/meters.py +++ b/packages/tilia-core/src/tilia_core/timemap/meters.py @@ -26,7 +26,7 @@ class _BeatUnit: """A beat unit as desktop#621's component holds it, with its beat's id as its id. - Its units are parsed once for each call of `meters_of`. + Its units are parsed when it is read, not again for each measure it governs. """ id: str diff --git a/packages/tilia-core/tests/timemap/test_timemap_meters.py b/packages/tilia-core/tests/timemap/test_timemap_meters.py index a3cbf27b4..9e9bd4560 100644 --- a/packages/tilia-core/tests/timemap/test_timemap_meters.py +++ b/packages/tilia-core/tests/timemap/test_timemap_meters.py @@ -208,6 +208,7 @@ class TestUnreadableUnits: unit(8, 3), unit(0, "1"), unit(129, "1"), + unit(8, "1" * 33), ], ) def test_is_ignored_and_the_unit_before_carries_on(self, bad, caplog): diff --git a/packages/tilia-core/tests/timemap/test_timemap_rows.py b/packages/tilia-core/tests/timemap/test_timemap_rows.py index 7bf548f65..09b7bbee3 100644 --- a/packages/tilia-core/tests/timemap/test_timemap_rows.py +++ b/packages/tilia-core/tests/timemap/test_timemap_rows.py @@ -161,6 +161,7 @@ def test_a_beat_unit_that_cannot_be_read_is_ignored(caplog): {"denominator": 0, "units": "1"}, {"denominator": 129, "units": "1"}, {"denominator": "8", "units": "1"}, + {"denominator": 8, "units": "1" * 33}, ], ) def test_a_beat_unit_desktop_621_refuses_is_left_out(beat_unit, caplog):