From 2b21942b8f340669c31881ce5cb79e36a9c41145 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:40:36 +0200 Subject: [PATCH 1/2] feat(core): move the beat pattern parser into tilia_core.timemap Moved from tilia/timelines/beat/pattern.py (desktop#621), unchanged: the file is the same blob as at 0ee8ea45, 07cb6d4f and a17bbd83. It sits next to units.py, the other module from #621. The beat importer and adding a beat timeline will call it, and so will the app's beat timeline, its validators and the beat pattern dialog once the app uses the core's copy. Its tests move to test_timemap_pattern.py with only the import changed. Part of TimeLineAnnotator/project-management#270 Co-Authored-By: Claude Opus 5.5 --- .../src/tilia_core/timemap/pattern.py | 197 ++++++++++++++++++ .../tests/timemap/test_timemap_pattern.py | 146 +++++++++++++ 2 files changed, 343 insertions(+) create mode 100644 packages/tilia-core/src/tilia_core/timemap/pattern.py create mode 100644 packages/tilia-core/tests/timemap/test_timemap_pattern.py diff --git a/packages/tilia-core/src/tilia_core/timemap/pattern.py b/packages/tilia-core/src/tilia_core/timemap/pattern.py new file mode 100644 index 000000000..236c76e56 --- /dev/null +++ b/packages/tilia-core/src/tilia_core/timemap/pattern.py @@ -0,0 +1,197 @@ +""" +Parsing for the beat pattern syntax. + +A pattern is a whitespace-separated list of items. An item is either a bar +length or a repeated group of items:: + + items := item (whitespace item)* + item := INT | INT? "[" items "]" + +So ``4 4 3`` is three bars, ``10[4] 3 15[4]`` is ten bars of 4, one of 3 and +fifteen of 4, and ``2[4 3[3]]`` expands to ``4 3 3 3 4 3 3 3``. A group with no +count (``[4 3]``) is played once. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum, auto + +# Guards against inputs like "999999[999999[4]]" expanding into a list that +# would exhaust memory. Far above the bar count of any real piece. +MAX_BARS = 100_000 +# Groups are parsed recursively, so nesting is capped well below Python's +# recursion limit. Far deeper than any pattern worth writing. +MAX_DEPTH = 32 +# Significant digits in a number. Any more and the number is far above +# MAX_BARS, and int() refuses strings past a few thousand digits. +MAX_DIGITS = 9 +# Only ASCII digits: str.isdigit() also accepts characters such as "²" that +# int() rejects. +_DIGITS = frozenset("0123456789") + + +class ParseStatus(Enum): + COMPLETE = auto() + # A valid start that more typing could complete, e.g. "10[" or "". + INCOMPLETE = auto() + INVALID = auto() + + +@dataclass(frozen=True) +class ParseResult: + status: ParseStatus + bars: list[int] = field(default_factory=list) + error: str = "" + position: int | None = None + + @property + def is_complete(self) -> bool: + return self.status == ParseStatus.COMPLETE + + +@dataclass(frozen=True) +class BarRun: + """Consecutive bars with the same length. `start` is a 0-based bar index.""" + + start: int + count: int + beats: int + + +class _Incomplete(Exception): + def __init__(self, message: str, position: int) -> None: + super().__init__(message) + self.message = message + self.position = position + + +class _Invalid(Exception): + def __init__(self, message: str, position: int) -> None: + super().__init__(message) + self.message = message + self.position = position + + +class _Parser: + def __init__(self, text: str) -> None: + self.text = text + self.pos = 0 + self.depth = 0 + + def at_end(self) -> bool: + return self.pos >= len(self.text) + + def peek(self) -> str: + return self.text[self.pos] + + def at_digit(self) -> bool: + return not self.at_end() and self.peek() in _DIGITS + + def skip_whitespace(self) -> None: + while not self.at_end() and self.peek().isspace(): + self.pos += 1 + + def parse_int(self) -> int: + start = self.pos + while self.at_digit(): + self.pos += 1 + digits = self.text[start : self.pos].lstrip("0") + if len(digits) > MAX_DIGITS: + raise _Invalid(f"Numbers can have at most {MAX_DIGITS} digits.", start) + return int(digits or "0") + + def parse_items(self, open_position: int | None = None) -> list[int]: + """ + Parses items up to the end of the text, or up to the "]" closing the + group opened at `open_position`. Leaves that "]" unconsumed. + """ + in_group = open_position is not None + bars: list[int] = [] + while True: + self.skip_whitespace() + if self.at_end(): + if in_group: + raise _Incomplete("Unclosed '['.", open_position) + break + if self.peek() == "]": + if in_group: + break + raise _Invalid("Unmatched ']'.", self.pos) + item_start = self.pos + bars.extend(self.parse_item()) + if len(bars) > MAX_BARS: + raise _Invalid( + f"Pattern expands to more than {MAX_BARS} bars.", item_start + ) + + if not bars: + if in_group: + raise _Invalid("Empty group '[]'.", open_position) + raise _Incomplete("Enter at least one bar length.", 0) + + return bars + + def parse_item(self) -> list[int]: + start = self.pos + count = self.parse_int() if self.at_digit() else None + self.skip_whitespace() + + if not self.at_end() and self.peek() == "[": + open_position = self.pos + if self.depth >= MAX_DEPTH: + raise _Invalid( + f"Groups can be nested at most {MAX_DEPTH} deep.", open_position + ) + self.pos += 1 + self.depth += 1 + group = self.parse_items(open_position) + self.depth -= 1 + self.pos += 1 # the closing "]" + repeats = 1 if count is None else count + if repeats < 1: + raise _Invalid("Repeat count must be at least 1.", start) + if len(group) * repeats > MAX_BARS: + raise _Invalid(f"Pattern expands to more than {MAX_BARS} bars.", start) + return group * repeats + + if count is None: + raise _Invalid(f"Unexpected '{self.peek()}'.", self.pos) + if count < 1: + raise _Invalid("Bar lengths must be at least 1.", start) + return [count] + + +def parse(text: str) -> ParseResult: + """Parses a beat pattern into the list of bar lengths it expands to.""" + try: + bars = _Parser(text).parse_items() + except _Incomplete as e: + return ParseResult(ParseStatus.INCOMPLETE, error=e.message, position=e.position) + except _Invalid as e: + return ParseResult(ParseStatus.INVALID, error=e.message, position=e.position) + + return ParseResult(ParseStatus.COMPLETE, bars) + + +def group_runs(bars: list[int]) -> list[BarRun]: + """Groups consecutive bars of equal length, e.g. for previewing a pattern.""" + runs: list[BarRun] = [] + for index, beats in enumerate(bars): + if runs and runs[-1].beats == beats: + last = runs[-1] + runs[-1] = BarRun(last.start, last.count + 1, beats) + else: + runs.append(BarRun(index, 1, beats)) + return runs + + +def format_bars(bars: list[int]) -> str: + """ + Writes bar lengths as a pattern, collapsing runs of equal bars: + ``[4, 4, 4, 3]`` becomes ``3[4] 3``. Nested groups are not recovered. + """ + return " ".join( + f"{run.count}[{run.beats}]" if run.count > 1 else str(run.beats) + for run in group_runs(bars) + ) diff --git a/packages/tilia-core/tests/timemap/test_timemap_pattern.py b/packages/tilia-core/tests/timemap/test_timemap_pattern.py new file mode 100644 index 000000000..28472159a --- /dev/null +++ b/packages/tilia-core/tests/timemap/test_timemap_pattern.py @@ -0,0 +1,146 @@ +import pytest + +from tilia_core.timemap.pattern import ( + MAX_BARS, + MAX_DEPTH, + MAX_DIGITS, + BarRun, + ParseStatus, + group_runs, + parse, +) + + +class TestComplete: + @pytest.mark.parametrize( + "text, bars", + [ + ("4", [4]), + ("4 4 3", [4, 4, 3]), + (" 4\t4\n3 ", [4, 4, 3]), + ("10[4] 3 15[4]", [4] * 10 + [3] + [4] * 15), + ("2[4 3[3]]", [4, 3, 3, 3, 4, 3, 3, 3]), + ("2[[4] 3[3]]", [4, 3, 3, 3, 4, 3, 3, 3]), + ("[4 3]", [4, 3]), + ("1[4]", [4]), + ("3 [2]", [2, 2, 2]), + ("2[3]4", [3, 3, 4]), + ("12", [12]), + ("007", [7]), + ], + ) + def test_expands(self, text, bars): + result = parse(text) + assert result.status == ParseStatus.COMPLETE + assert result.is_complete + assert result.bars == bars + assert result.error == "" + + def test_max_bars_is_allowed(self): + assert parse(f"{MAX_BARS}[1]").bars == [1] * MAX_BARS + + +class TestIncomplete: + @pytest.mark.parametrize( + "text, position", + [ + ("", 0), + (" ", 0), + ("10[", 2), + ("2[4 3", 1), + ("2[4 3[3]", 1), + ("2[4 3[3", 5), + ], + ) + def test_incomplete(self, text, position): + result = parse(text) + assert result.status == ParseStatus.INCOMPLETE + assert not result.is_complete + assert result.bars == [] + assert result.error + assert result.position == position + + +class TestInvalid: + @pytest.mark.parametrize( + "text, position, message", + [ + ("4 ]", 2, "Unmatched ']'."), + ("2[4]]", 4, "Unmatched ']'."), + ("4 a", 2, "Unexpected 'a'."), + ("4, 4", 1, "Unexpected ','."), + ("-4", 0, "Unexpected '-'."), + ("0", 0, "Bar lengths must be at least 1."), + ("4 0", 2, "Bar lengths must be at least 1."), + ("0[4]", 0, "Repeat count must be at least 1."), + ("2[]", 1, "Empty group '[]'."), + ("2[ ]", 1, "Empty group '[]'."), + ], + ) + def test_invalid(self, text, position, message): + result = parse(text) + assert result.status == ParseStatus.INVALID + assert result.bars == [] + assert result.error == message + assert result.position == position + + @pytest.mark.parametrize( + "text", + [f"{MAX_BARS + 1}[1]", "999999999[999999999[4]]", f"{MAX_BARS}[1] 1"], + ) + def test_too_many_bars(self, text): + result = parse(text) + assert result.status == ParseStatus.INVALID + assert "more than" in result.error + + @pytest.mark.parametrize("text", ["²", "4 ²", "①[4]", "٣"]) + def test_non_ascii_digits(self, text): + result = parse(text) + assert result.status == ParseStatus.INVALID + assert result.error.startswith("Unexpected") + + @pytest.mark.parametrize("text", ["9" * 5000, f"{'9' * 10}[4]"]) + def test_too_many_digits(self, text): + result = parse(text) + assert result.status == ParseStatus.INVALID + assert result.error == f"Numbers can have at most {MAX_DIGITS} digits." + assert result.position == 0 + + def test_leading_zeros_do_not_count_as_digits(self): + assert parse("0" * 20 + "4").bars == [4] + + def test_max_depth_is_allowed(self): + text = "[" * MAX_DEPTH + "4" + "]" * MAX_DEPTH + assert parse(text).bars == [4] + + @pytest.mark.parametrize("closed", [True, False]) + def test_too_deep(self, closed): + depth = MAX_DEPTH + 1 + text = "[" * depth + "4" + ("]" * depth if closed else "") + result = parse(text) + assert result.status == ParseStatus.INVALID + assert result.error == f"Groups can be nested at most {MAX_DEPTH} deep." + assert result.position == MAX_DEPTH + + def test_very_deep_nesting_does_not_recurse(self): + result = parse("[" * 5000 + "4" + "]" * 5000) + assert result.status == ParseStatus.INVALID + + +class TestGroupRuns: + def test_empty(self): + assert group_runs([]) == [] + + def test_groups_consecutive_equal_bars(self): + assert group_runs([4] * 10 + [3] + [4] * 15) == [ + BarRun(0, 10, 4), + BarRun(10, 1, 3), + BarRun(11, 15, 4), + ] + + def test_does_not_merge_non_consecutive_bars(self): + assert group_runs([4, 3, 4]) == [ + BarRun(0, 1, 4), + BarRun(1, 1, 3), + BarRun(2, 1, 4), + ] From 775e2037967278535d58ca0c1fa4f58b6909edd0 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:40:38 +0200 Subject: [PATCH 2/2] test(core): test format_bars The moved tests cover parse and group_runs only. format_bars is what the beat importer stores as a timeline's beat pattern, so its runs, its flattening of nested groups and its round trip through parse are tested here. Part of TimeLineAnnotator/project-management#270 Co-Authored-By: Claude Opus 5.5 --- .../tests/timemap/test_timemap_pattern.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/tilia-core/tests/timemap/test_timemap_pattern.py b/packages/tilia-core/tests/timemap/test_timemap_pattern.py index 28472159a..90d052b86 100644 --- a/packages/tilia-core/tests/timemap/test_timemap_pattern.py +++ b/packages/tilia-core/tests/timemap/test_timemap_pattern.py @@ -6,6 +6,7 @@ MAX_DIGITS, BarRun, ParseStatus, + format_bars, group_runs, parse, ) @@ -144,3 +145,27 @@ def test_does_not_merge_non_consecutive_bars(self): BarRun(1, 1, 3), BarRun(2, 1, 4), ] + + +class TestFormatBars: + @pytest.mark.parametrize( + "bars, text", + [ + ([4], "4"), + ([4, 4, 4, 3], "3[4] 3"), + ([4] * 10 + [3] + [4] * 15, "10[4] 3 15[4]"), + ([4, 3, 4], "4 3 4"), + ], + ) + def test_collapses_runs_of_equal_bars(self, bars, text): + assert format_bars(bars) == text + + def test_does_not_recover_nested_groups(self): + assert format_bars(parse("2[4 3]").bars) == "4 3 4 3" + + @pytest.mark.parametrize( + "text", ["4 4 3", "10[4] 3 15[4]", "2[4 3[3]]", f"{MAX_BARS}[1]"] + ) + def test_parses_back_to_the_same_bars(self, text): + bars = parse(text).bars + assert parse(format_bars(bars)).bars == bars