tonalis 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tonalis-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DRYCodeWorks
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
tonalis-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.4
2
+ Name: tonalis
3
+ Version: 0.1.0
4
+ Summary: Tonalis — a generic, format-agnostic music-harmony DSL (parse/lint/AST/JSON/text).
5
+ License: MIT License
6
+
7
+ Copyright (c) 2026 DRYCodeWorks
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+
27
+ Project-URL: Homepage, https://github.com/drycode/tonalis
28
+ Project-URL: Repository, https://github.com/drycode/tonalis
29
+ Classifier: License :: OSI Approved :: MIT License
30
+ Requires-Python: >=3.11
31
+ Description-Content-Type: text/markdown
32
+ License-File: LICENSE
33
+ Requires-Dist: tonalis-music-dsl==0.1.0
34
+ Dynamic: license-file
35
+
36
+ # tonalis
37
+
38
+ A generic, format-agnostic music-harmony DSL for lead sheets: parse chord
39
+ charts to a canonical AST, lint them with structured findings, and render back
40
+ to canonical text or JSON. This is the Python reference implementation;
41
+ TypeScript (`tonalis` on npm) and Rust (`tonalis` on crates.io) ports conform
42
+ to the same normative spec and a shared 255-case conformance suite.
43
+
44
+ Music-theory questions (chord validity, scales, keys) are delegated to the
45
+ pure [`tonalis-music-dsl`](https://pypi.org/project/tonalis-music-dsl/) theory library.
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ pip install tonalis
51
+ ```
52
+
53
+ ## Use
54
+
55
+ ```python
56
+ from tonalis import parse_dsl, lint, serialize, to_json
57
+
58
+ result = parse_dsl(open("chart.txt").read())
59
+ findings = list(result.findings) + (lint(result.chart) if result.chart else [])
60
+ chart = result.chart # a LeadSheet AST
61
+ json_obj = to_json(chart) # canonical JSON (a dict)
62
+ text = serialize(chart) # canonical text rendering
63
+ ```
64
+
65
+ A small CLI is included: `python -m tonalis chart.txt` prints lint findings
66
+ (exit 1 if any error).
67
+
68
+ ## Links
69
+
70
+ - Source, spec, docs, and conformance suite: <https://github.com/drycode/tonalis>
71
+ - License: MIT
@@ -0,0 +1,36 @@
1
+ # tonalis
2
+
3
+ A generic, format-agnostic music-harmony DSL for lead sheets: parse chord
4
+ charts to a canonical AST, lint them with structured findings, and render back
5
+ to canonical text or JSON. This is the Python reference implementation;
6
+ TypeScript (`tonalis` on npm) and Rust (`tonalis` on crates.io) ports conform
7
+ to the same normative spec and a shared 255-case conformance suite.
8
+
9
+ Music-theory questions (chord validity, scales, keys) are delegated to the
10
+ pure [`tonalis-music-dsl`](https://pypi.org/project/tonalis-music-dsl/) theory library.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install tonalis
16
+ ```
17
+
18
+ ## Use
19
+
20
+ ```python
21
+ from tonalis import parse_dsl, lint, serialize, to_json
22
+
23
+ result = parse_dsl(open("chart.txt").read())
24
+ findings = list(result.findings) + (lint(result.chart) if result.chart else [])
25
+ chart = result.chart # a LeadSheet AST
26
+ json_obj = to_json(chart) # canonical JSON (a dict)
27
+ text = serialize(chart) # canonical text rendering
28
+ ```
29
+
30
+ A small CLI is included: `python -m tonalis chart.txt` prints lint findings
31
+ (exit 1 if any error).
32
+
33
+ ## Links
34
+
35
+ - Source, spec, docs, and conformance suite: <https://github.com/drycode/tonalis>
36
+ - License: MIT
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tonalis"
7
+ version = "0.1.0"
8
+ description = "Tonalis — a generic, format-agnostic music-harmony DSL (parse/lint/AST/JSON/text)."
9
+ requires-python = ">=3.11"
10
+ readme = "README.md"
11
+ license = { file = "LICENSE" }
12
+ classifiers = [
13
+ "License :: OSI Approved :: MIT License",
14
+ ]
15
+ dependencies = ["tonalis-music-dsl==0.1.0"]
16
+
17
+ [project.urls]
18
+ Homepage = "https://github.com/drycode/tonalis"
19
+ Repository = "https://github.com/drycode/tonalis"
20
+
21
+ [tool.setuptools]
22
+ packages = ["tonalis"]
23
+
24
+ [tool.setuptools.package-data]
25
+ tonalis = ["GRAMMAR.md"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,118 @@
1
+ # Tonalis DSL v1 — Grammar Reference
2
+
3
+ Emit a chart in this DSL. Output **only** the DSL, nothing else.
4
+
5
+ ## Header (required keys: title, key, time)
6
+
7
+ ```
8
+ title: All The Things You Are
9
+ composer: Jerome Kern
10
+ style: Medium Swing
11
+ key: Ab
12
+ time: 4/4
13
+ ```
14
+
15
+ Rules: one `key: value` per line; **no `=` in any value**; `time` is `n/d`.
16
+
17
+ ## Sections and measures
18
+
19
+ Sections are `[A]`, `[B]`, `[C]`, `[D]`, `[Intro]`/`[i]`, `[Verse]`/`[v]`.
20
+ Measures are separated by `|`. Chords inside a measure split the bar evenly;
21
+ add `:N` for an explicit beat count.
22
+
23
+ ```
24
+ [A]
25
+ | F-7 | Bb7 | Eb^7 | Ab^7 |
26
+ | D-7 G7 | C^7 | C^7 | C^7 |
27
+ ```
28
+
29
+ ## Repeats, endings, navigation
30
+
31
+ `{ ... }` is a repeat; `1.`/`2.` mark first/second endings; `@segno`, `@coda`,
32
+ `[time: 3/4]` mid-chart, and `<staff text>` are supported. `@break` (or
33
+ `@newline`) on its own line forces the next section/measure onto a new line
34
+ (a vertical-space / line-break layout hint); use it for layout, e.g. to put an intro on its own row.
35
+
36
+ Layout model: grid-style chart renderers commonly display a fixed **4-cell bar**
37
+ regardless of meter (the time signature affects playback only) and auto-wrap
38
+ every **4 bars per row**.
39
+ So rows come out clean by default; emit `@break` only where a section must
40
+ start on a fresh row after a short tail (e.g. a 2-bar second ending). Bars
41
+ holding 4+ chords render wider than the grid — unavoidable in v1.
42
+ For a D.S./D.C. al Coda: `@segno` marks the jump-back point, `@tocoda` (placed
43
+ *after* a measure) marks the "To Coda" jump-from, and `@coda` marks the coda
44
+ section (the destination). `@fine` marks the Fine (end) point for a D.C./D.S. al Fine.
45
+
46
+ ```
47
+ [Intro]
48
+ | D-7 | G7 |
49
+ @break
50
+ [A]
51
+ { | C^7 | A-7 | 1. D-7 | G7 | }
52
+ | 2. D-7 | G7 |
53
+ ```
54
+
55
+ ## Chord vocabulary
56
+
57
+ Quality: `-` minor, `^` major-7, `o` dim, `h` half-dim, `+` aug, `sus`.
58
+ Extensions: `b5 #5 6 b9 9 #9 11 #11 b13 13`. No-chord: `N.C.`.
59
+
60
+ The following are all VALID chord tokens (the drift gate asserts each passes
61
+ the parser's `is_valid_chord`):
62
+
63
+ <!-- valid-chords -->
64
+ ```
65
+ C
66
+ C^7
67
+ A-7
68
+ G7
69
+ D-7
70
+ F^7
71
+ Bbo7
72
+ Eh7
73
+ C+
74
+ Csus
75
+ C7b9
76
+ G7#5
77
+ F#-7b5
78
+ Ab^7
79
+ C/E
80
+ N.C.
81
+ ```
82
+ <!-- /valid-chords -->
83
+
84
+ ## Banned constructs
85
+
86
+ The following DSL snippets MUST fail to compile (the drift gate asserts each
87
+ yields an error finding):
88
+
89
+ <!-- banned-dsl -->
90
+ ```
91
+ title: Has = Sign
92
+ key: C
93
+ time: 4/4
94
+
95
+ [A]
96
+ | C |
97
+ ---
98
+ title: Bad Section
99
+ key: C
100
+ time: 4/4
101
+
102
+ [Z]
103
+ | C |
104
+ ---
105
+ title: Bad Time
106
+ key: C
107
+ time: 9
108
+ ---
109
+ title: Bad Chord
110
+ key: C
111
+ time: 4/4
112
+
113
+ [A]
114
+ | Zxq9 |
115
+ ```
116
+ <!-- /banned-dsl -->
117
+
118
+ Each banned snippet above is separated by a line containing only `---`.
@@ -0,0 +1,36 @@
1
+ """Pure lead-sheet / chord-chart language core (parse/lint/AST/JSON/text). Format-agnostic."""
2
+
3
+ from tonalis.ast import (
4
+ Barline,
5
+ Cell,
6
+ LeadSheet,
7
+ LintFinding,
8
+ Measure,
9
+ ParseResult,
10
+ Section,
11
+ SectionKind,
12
+ )
13
+ from tonalis.chords import is_valid_chord
14
+ from tonalis.lint import lint
15
+ from tonalis.parser import parse_dsl
16
+ from tonalis.serialize import ast_from_json, ast_to_json, from_json, to_json
17
+ from tonalis.serialize_text import serialize
18
+
19
+ __all__ = [
20
+ "parse_dsl",
21
+ "lint",
22
+ "is_valid_chord",
23
+ "serialize",
24
+ "ast_to_json",
25
+ "ast_from_json",
26
+ "to_json",
27
+ "from_json",
28
+ "LeadSheet",
29
+ "Cell",
30
+ "Measure",
31
+ "Section",
32
+ "SectionKind",
33
+ "Barline",
34
+ "LintFinding",
35
+ "ParseResult",
36
+ ]
@@ -0,0 +1,26 @@
1
+ import argparse
2
+ import sys
3
+
4
+ from tonalis.parser import parse_dsl
5
+ from tonalis.lint import lint
6
+
7
+
8
+ def main(argv=None) -> int:
9
+ ap = argparse.ArgumentParser(prog="tonalis", description="Lint/parse a lead-sheet DSL chart.")
10
+ ap.add_argument("file")
11
+ ap.add_argument("--lint-only", action="store_true")
12
+ args = ap.parse_args(argv)
13
+ try:
14
+ text = open(args.file, encoding="utf-8").read()
15
+ except OSError as e:
16
+ print(f"error: cannot read {args.file}: {e}", file=sys.stderr)
17
+ return 2
18
+ pr = parse_dsl(text)
19
+ findings = list(pr.findings) + (lint(pr.chart) if pr.chart else [])
20
+ for f in findings:
21
+ print(f"{f.severity}:{f.line}: [{f.code}] {f.message}", file=sys.stderr)
22
+ return 1 if any(f.severity == "error" for f in findings) else 0
23
+
24
+
25
+ if __name__ == "__main__":
26
+ raise SystemExit(main())
@@ -0,0 +1,93 @@
1
+ """DSL abstract syntax — frozen-ish dataclasses produced by the parser, consumed by the
2
+ linter and a downstream codec.
3
+
4
+ Phase 2: the IR is NEUTRAL. The root is ``LeadSheet`` (was ``DslChart``); a section carries a
5
+ neutral ``kind`` enum (derived by the parser from the bracket name); a measure carries a
6
+ ``barline`` enum (was the raw ``bar_close`` glyph) plus an opaque ``hints`` list (where
7
+ ``@break`` lives — it has no lead-sheet semantics). No field stores a format-specific value; a
8
+ downstream codec maps kind/barline/hints to the target format's section/barline markers itself.
9
+ """
10
+ from dataclasses import dataclass, field
11
+ from enum import Enum
12
+ from typing import List, Optional, Tuple
13
+
14
+
15
+ class SectionKind(str, Enum):
16
+ """Neutral section role, derived by the parser from the bracket name."""
17
+ A = "a"
18
+ B = "b"
19
+ C = "c"
20
+ D = "d"
21
+ INTRO = "intro"
22
+ VERSE = "verse"
23
+
24
+
25
+ class Barline(str, Enum):
26
+ """Neutral right-barline. ``'|'`` -> NORMAL, ``'}'`` -> REPEAT_END, ``'Z'``/``'||'`` -> FINAL."""
27
+ NORMAL = "normal"
28
+ REPEAT_END = "repeat_end"
29
+ FINAL = "final"
30
+
31
+
32
+ @dataclass
33
+ class Cell:
34
+ chord: str
35
+ beats: Optional[int] = None # explicit :N, else None (even split of the bar)
36
+ alt: Optional[str] = None # raw "(A-7 D7)" alt-chord text, if any
37
+
38
+ @property
39
+ def chord_obj(self):
40
+ """Lazy MusicDSL Chord for a present chord token; None for empty / no-chord /
41
+ bare-slash cells; raises InvalidChordStringException for a malformed-present token."""
42
+ from music_dsl.domain.chords.chord import Chord # lazy: keeps the dep at use-time
43
+ t = (self.chord or "").replace("*", "")
44
+ if not t or t in ("N.C.", "n") or t.startswith("/"):
45
+ return None
46
+ return Chord(t)
47
+
48
+ @property
49
+ def chord_obj_or_none(self):
50
+ """Non-raising form of chord_obj (None for malformed tokens too)."""
51
+ from music_dsl.domain.chords.abstract_chord import InvalidChordStringException
52
+ try:
53
+ return self.chord_obj
54
+ except InvalidChordStringException:
55
+ return None
56
+
57
+
58
+ @dataclass
59
+ class Measure:
60
+ cells: List[Cell] = field(default_factory=list)
61
+ ending: Optional[int] = None # 1./2. nth-ending number
62
+ bar_open: bool = False # preceded by '{'
63
+ barline: Barline = Barline.NORMAL # neutral right-barline (was bar_close: str)
64
+ nav: Tuple = () # ('segno',), ('coda',), ('fine',), ('text', '<...>'), ('time', (n, d))
65
+ hints: Tuple[str, ...] = () # opaque render-hints, e.g. ('break',); NO semantics
66
+ line: int = 0 # source line for findings
67
+
68
+
69
+ @dataclass
70
+ class Section:
71
+ label: str
72
+ kind: SectionKind # neutral role (the parser derives it from the bracket name)
73
+ measures: List[Measure] = field(default_factory=list)
74
+
75
+
76
+ @dataclass
77
+ class LeadSheet:
78
+ meta: dict = field(default_factory=dict) # title/composer/style/key/time
79
+ sections: List[Section] = field(default_factory=list)
80
+
81
+
82
+ @dataclass
83
+ class LintFinding:
84
+ severity: str # "error" | "warning"
85
+ line: int
86
+ code: str
87
+ message: str
88
+
89
+
90
+ @dataclass
91
+ class ParseResult:
92
+ chart: Optional[LeadSheet]
93
+ findings: List[LintFinding] = field(default_factory=list)
@@ -0,0 +1,4 @@
1
+ """Deprecated: chord validation moved to tonalis.chords (MusicDSL-backed).
2
+ Kept as a shim so any `from tonalis.chord_grammar import is_valid_chord` still resolves.
3
+ """
4
+ from tonalis.chords import is_valid_chord # noqa: F401
@@ -0,0 +1,39 @@
1
+ """Chord-token validation for the lead-sheet linter, standing on MusicDSL.
2
+
3
+ Replaces ``chord_grammar``'s hand-rolled regex: the "is this a real chord" decision is
4
+ delegated to MusicDSL's ``Chord()`` (the theory library is the single source of truth for
5
+ what a chord is). The handful of non-chord lead-sheet tokens that ``Chord()`` rightly
6
+ rejects but the linter must still accept are preserved here verbatim:
7
+
8
+ * ``N.C.`` / ``n`` — explicit no-chord markers.
9
+ * a standalone slash-bass continuation (``/A``) — a bass note carried over from the
10
+ previous chord, with no quality of its own.
11
+ * the augmented layout-star artifact (``Bb*7+*``) — lead-sheet notation sometimes glues a
12
+ ``*`` to a chord as a layout marker; strip it before validating.
13
+
14
+ This module may import ``music_dsl`` (the theory lib tonalis stands on) per the Phase 1
15
+ one-way dependency rule (leadsheet -> music_dsl); ``music_dsl`` must never import tonalis.
16
+ """
17
+
18
+ import re
19
+
20
+ from music_dsl.domain.chords.abstract_chord import InvalidChordStringException
21
+ from music_dsl.domain.chords.chord import Chord
22
+
23
+ _SLASH_BASS = re.compile(r"^/[A-G][b#]?$") # standalone bass continuation, e.g. /A
24
+
25
+
26
+ def is_valid_chord(token: str) -> bool:
27
+ """True iff the token is a well-formed chord OR a no-chord / bass marker."""
28
+ if token in ("N.C.", "n"):
29
+ return True
30
+ if not token:
31
+ return False
32
+ t = token.replace("*", "") # tolerate the augmented layout-star artifact (Bb*7+*)
33
+ if _SLASH_BASS.match(t):
34
+ return True
35
+ try:
36
+ Chord(t)
37
+ return True
38
+ except InvalidChordStringException:
39
+ return False
@@ -0,0 +1,140 @@
1
+ """DSL linter: structural + chord-grammar checks over a parsed LeadSheet.
2
+
3
+ Errors block compilation; warnings don't. Uses the real chord grammar (chord_grammar) for
4
+ bad-chord, not the permissive CST scanner.
5
+ """
6
+
7
+ from tonalis.ast import Barline, LintFinding
8
+ from tonalis.chords import is_valid_chord
9
+ from tonalis.parser import MAX_CHORD_TOKEN_LEN
10
+
11
+ # corpus-derived known-good uneven :N layouts (4/4); all-equal splits are always fine.
12
+ _ALLOWED_UNEVEN = {(2, 1, 1), (1, 1, 1, 1), (1, 1, 2), (3, 1), (1, 3), (2, 2)}
13
+
14
+
15
+ def _all_measures(chart):
16
+ return [(s, m) for s in chart.sections for m in s.measures]
17
+
18
+
19
+ def _beat_pattern(measure, numerator):
20
+ """Per-cell beat counts: explicit :N consume their count; unannotated split the
21
+ remainder evenly. Returns (pattern_tuple, ok) where ok is False if it can't fill the bar.
22
+ """
23
+ cells = measure.cells
24
+ if not cells:
25
+ return (), True
26
+ explicit = sum(c.beats for c in cells if c.beats)
27
+ unannotated = [c for c in cells if not c.beats]
28
+ remainder = numerator - explicit
29
+ if unannotated:
30
+ if remainder <= 0 or remainder % len(unannotated) != 0:
31
+ return tuple(c.beats or 0 for c in cells), False
32
+ each = remainder // len(unannotated)
33
+ return tuple(c.beats if c.beats else each for c in cells), True
34
+ return tuple(c.beats for c in cells), explicit == numerator
35
+
36
+
37
+ def lint(chart):
38
+ findings = []
39
+
40
+ def err(line, code, msg):
41
+ findings.append(LintFinding("error", line, code, msg))
42
+
43
+ def warn(line, code, msg):
44
+ findings.append(LintFinding("warning", line, code, msg))
45
+
46
+ measures = _all_measures(chart)
47
+ numerator = (chart.meta.get("time") or (4, 4))[0]
48
+
49
+ # repeats / endings balance, codas/segno, final bar, per-measure beats + chords
50
+ balance = n_coda = n_segno = 0
51
+ last_idx = len(measures) - 1
52
+ for idx, (section, m) in enumerate(measures):
53
+ for nav in m.nav:
54
+ if nav == ("coda",):
55
+ n_coda += 1
56
+ elif nav == ("segno",):
57
+ n_segno += 1
58
+ elif nav and nav[0] == "time":
59
+ numerator = nav[1][0]
60
+ if m.bar_open:
61
+ balance += 1
62
+ if m.barline == Barline.REPEAT_END:
63
+ if balance == 0:
64
+ err(m.line, "unbalanced-repeat", "'}' with no matching '{'")
65
+ else:
66
+ balance -= 1
67
+ if m.barline == Barline.FINAL and idx != last_idx:
68
+ warn(
69
+ m.line,
70
+ "mid-final-bar",
71
+ "final barline (Z/||) is not on the last measure",
72
+ )
73
+
74
+ # chords
75
+ for c in m.cells:
76
+ # skip is_valid_chord for tokens that already triggered token-too-long (parser
77
+ # error already emitted; MusicDSL would reject them as bad-chord too but the
78
+ # conformance spec treats over-length tokens as a single token-too-long error)
79
+ if c.chord and len(c.chord) <= MAX_CHORD_TOKEN_LEN and not is_valid_chord(c.chord):
80
+ err(m.line, "bad-chord", f"invalid chord token: {c.chord!r}")
81
+ if c.alt:
82
+ for tok in c.alt.strip("()").split():
83
+ if not is_valid_chord(tok):
84
+ err(m.line, "bad-chord", f"invalid alt chord: {tok!r}")
85
+
86
+ # empty measure
87
+ if not m.cells:
88
+ warn(m.line, "empty-measure", "measure has no chords (emitted as N.C.)")
89
+ continue
90
+
91
+ # beats. Pickup relaxation only excuses an UNDER-full first/last bar (anacrusis);
92
+ # an OVER-full bar is always wrong, even for a pickup.
93
+ pattern, ok = _beat_pattern(m, numerator)
94
+ total = sum(pattern)
95
+ is_pickup = idx in (0, last_idx)
96
+ if total > numerator:
97
+ err(m.line, "beat-sum", f"beats {pattern} overflow a {numerator}-beat bar")
98
+ elif not ok and not is_pickup:
99
+ err(
100
+ m.line,
101
+ "beat-sum",
102
+ f"beats {pattern} do not fill a {numerator}-beat bar",
103
+ )
104
+ elif ok and len(set(pattern)) > 1 and pattern not in _ALLOWED_UNEVEN:
105
+ warn(
106
+ m.line,
107
+ "beat-unsupported",
108
+ f"uneven beat layout {pattern} not in the allowlist; even-split fallback",
109
+ )
110
+
111
+ if balance != 0:
112
+ err(0, "unbalanced-repeat", f"{balance} unclosed '{{'")
113
+ # an empty section (header but no measures) silently drops in render — surface it
114
+ for section in chart.sections:
115
+ if not section.measures:
116
+ warn(
117
+ 0,
118
+ "empty-section",
119
+ f"section [{section.label}] has no measures (dropped)",
120
+ )
121
+ # nth endings must belong to a repeat (a section with endings needs a bar_open)
122
+ for section in chart.sections:
123
+ has_open = any(m.bar_open for m in section.measures)
124
+ if any(m.ending for m in section.measures) and not has_open:
125
+ ln = next((m.line for m in section.measures if m.ending), 0)
126
+ err(ln, "ending-without-repeat", "nth ending outside a repeat block")
127
+ if n_coda > 2:
128
+ err(
129
+ 0,
130
+ "coda-count",
131
+ f"{n_coda} coda points (max 2 — the encoder cannot flatten more)",
132
+ )
133
+ elif n_coda == 2 and n_segno == 0:
134
+ warn(
135
+ 0,
136
+ "coda-needs-segno",
137
+ "2-coda jump without @segno (will play D.C., not D.S.)",
138
+ )
139
+
140
+ return findings
@@ -0,0 +1,347 @@
1
+ """Bounded, single-pass DSL parser. Never eval/exec; limit violations are findings.
2
+
3
+ DSL → ParseResult{LeadSheet, findings}. The parser records structure; the linter (lint.py)
4
+ judges chord validity, beat sums, etc. Header values are read literally to end-of-line.
5
+ """
6
+
7
+ import re
8
+
9
+ from tonalis.ast import (
10
+ Barline,
11
+ Cell,
12
+ LeadSheet,
13
+ LintFinding,
14
+ Measure,
15
+ ParseResult,
16
+ Section,
17
+ SectionKind,
18
+ )
19
+
20
+ MAX_BYTES = 256 * 1024
21
+ MAX_LINES = 5000
22
+ MAX_LINE_LEN = 1000
23
+ MAX_MEASURES = 2000
24
+ MAX_CELLS_PER_MEASURE = 16
25
+ MAX_CHORD_TOKEN_LEN = 50
26
+ MAX_ALT_CHORD_TOKENS = 8
27
+ # Numeric value cap (SPEC.md §1.4): a meter num/den, a :N beat count, and an ending number must be
28
+ # <= MAX_METER. Exceeding it never reaches rendering, so no port can be driven into a multi-GB
29
+ # " "-padding allocation or a fixed-width-int overflow.
30
+ MAX_METER = 64
31
+
32
+ # Line terminators (SPEC.md §1.1): ONLY \r\n, \n, \r. NOT Python's broader str.splitlines() set
33
+ # (which also splits \v \f \x1c \x1d \x1e \x85 
 
) — those are ordinary characters here,
34
+ # so all ports split identically.
35
+ _LINE_SPLIT_RE = re.compile(r"\r\n|\r|\n")
36
+
37
+ _REQUIRED = ("title", "key", "time")
38
+ _META_KEYS = ("title", "composer", "style", "key", "time")
39
+ _SECTIONS = {
40
+ "A": SectionKind.A,
41
+ "B": SectionKind.B,
42
+ "C": SectionKind.C,
43
+ "D": SectionKind.D,
44
+ "Intro": SectionKind.INTRO,
45
+ "i": SectionKind.INTRO,
46
+ "Verse": SectionKind.VERSE,
47
+ "v": SectionKind.VERSE,
48
+ }
49
+ _BARLINE_RE = re.compile(r"(\{|\}|\|\||\||Z)")
50
+ _CELL_RE = re.compile(r"\([^)]*\)|\S+") # a (alt) group, or a non-space run
51
+ _INLINE_ALT_RE = re.compile(r"^([^(]+)(\([^)]*\))$") # "G7(Db7)" -> chord + (alt)
52
+ # ASCII digits only (SPEC.md §1.1): explicit [0-9] classes, never \d (Unicode-aware in Python).
53
+ _ENDING_RE = re.compile(r"^([0-9]+)\.\s*(.*)$")
54
+ _TIME_RE = re.compile(r"\[time:\s*([0-9]+)\s*/\s*([0-9]+)\s*\]\s*$")
55
+ _SECTION_RE = re.compile(r"\[([A-Za-z]+)([0-9]*)\]\s*$")
56
+
57
+
58
+ def _split_lines(text):
59
+ """SPEC.md §1.1 line split: ONLY \\r\\n, \\n, \\r (not Python's wider splitlines() set).
60
+
61
+ Mirrors ``str.splitlines()`` for these three forms — including dropping the trailing empty
62
+ element a terminal newline would otherwise leave — so the common case is byte-identical, but
63
+ a stray \\x1c/\\v/\\x85/etc. stays inside its line (an ordinary char), matching TS/Rust.
64
+ """
65
+ if text == "":
66
+ return []
67
+ parts = _LINE_SPLIT_RE.split(text)
68
+ if parts and parts[-1] == "":
69
+ parts.pop()
70
+ return parts
71
+
72
+
73
+ def _is_ascii_digits(s):
74
+ """ASCII-only digit check (SPEC.md §1.1). Unlike ``str.isdigit()`` this rejects non-ASCII
75
+ digits (``٣``) AND numeric-but-non-decimal characters (``²``, which would crash ``int()``).
76
+ """
77
+ return s != "" and all("0" <= c <= "9" for c in s)
78
+
79
+
80
+ def parse_dsl(text):
81
+ findings = []
82
+
83
+ def err(line, code, msg):
84
+ findings.append(LintFinding("error", line, code, msg))
85
+
86
+ def warn(line, code, msg):
87
+ findings.append(LintFinding("warning", line, code, msg))
88
+
89
+ if len(text.encode("utf-8", "replace")) > MAX_BYTES:
90
+ err(0, "too-big", "input exceeds MAX_BYTES")
91
+ return ParseResult(None, findings)
92
+ text = text.lstrip("")
93
+ lines = _split_lines(text)
94
+ if len(lines) > MAX_LINES:
95
+ err(0, "too-big", "too many lines")
96
+ return ParseResult(None, findings)
97
+ for n, raw in enumerate(lines, 1):
98
+ if len(raw) > MAX_LINE_LEN:
99
+ err(n, "too-big", "line too long")
100
+ return ParseResult(None, findings)
101
+
102
+ meta = {}
103
+ i, total = 0, len(lines)
104
+
105
+ def assign_meta(k, v, lineno):
106
+ # Header keys are singletons. Re-stating one is a duplicate (same value, redundant)
107
+ # or a conflict (different value, mutually exclusive) — never silently last-wins.
108
+ if k in meta:
109
+ if meta[k] == v:
110
+ warn(lineno, "duplicate-header", f"duplicate header {k!r}")
111
+ else:
112
+ err(
113
+ lineno,
114
+ "conflicting-header",
115
+ f"conflicting header {k!r}: {meta[k]!r} vs {v!r}",
116
+ )
117
+ return # keep the first value; a conflict already blocks compile
118
+ meta[k] = v
119
+
120
+ # --- header ---
121
+ while i < total:
122
+ raw = lines[i]
123
+ line = raw.strip()
124
+ if line == "" or line.startswith("#"):
125
+ i += 1
126
+ continue
127
+ if line[0] in "[|{":
128
+ break
129
+ m = re.match(r"([A-Za-z]+)\s*:\s*(.*)$", raw) # value read literally to EOL
130
+ if not m:
131
+ break
132
+ key, val = m.group(1).lower(), m.group(2).rstrip()
133
+ if "=" in val:
134
+ err(i + 1, "meta-delimiter", f"'=' not allowed in metadata value: {val!r}")
135
+ if key == "time":
136
+ # ASCII digits only (SPEC.md §1.1); both components must be <= MAX_METER (§1.4).
137
+ tm = re.match(r"([0-9]+)\s*/\s*([0-9]+)$", val)
138
+ if tm and int(tm.group(1)) <= MAX_METER and int(tm.group(2)) <= MAX_METER:
139
+ assign_meta("time", (int(tm.group(1)), int(tm.group(2))), i + 1)
140
+ else:
141
+ err(i + 1, "bad-time", f"bad time signature: {val!r}")
142
+ elif key in _META_KEYS:
143
+ assign_meta(key, val, i + 1)
144
+ else:
145
+ warn(i + 1, "unknown-key", f"unknown header key: {key}")
146
+ i += 1
147
+
148
+ for k in _REQUIRED:
149
+ if k not in meta:
150
+ err(0, "missing-header", f"missing required header: {k}")
151
+ if meta.get("title", "") == "":
152
+ err(0, "missing-header", "title must be non-empty")
153
+
154
+ # --- body ---
155
+ sections = []
156
+ cur = None
157
+ pending_nav = []
158
+ pending_hints = []
159
+ measure_count = 0
160
+ current_time = meta.get("time", (4, 4)) # for redundant mid-chart [time:] detection
161
+
162
+ def ensure_section():
163
+ nonlocal cur
164
+ if cur is None:
165
+ cur = Section(label="A", kind=SectionKind.A)
166
+ sections.append(cur)
167
+ return cur
168
+
169
+ while i < total:
170
+ raw = lines[i]
171
+ line = raw.strip()
172
+ ln = i + 1
173
+ i += 1
174
+ if line == "" or line.startswith("#"):
175
+ continue
176
+ tm = _TIME_RE.match(line)
177
+ if line.startswith("[time") and tm:
178
+ n, d = int(tm.group(1)), int(tm.group(2))
179
+ if n > MAX_METER or d > MAX_METER:
180
+ # recognized [time:…] shape but the meter is out of range (SPEC.md §1.4/§4.4):
181
+ # reject it as bad-time; it contributes no nav.
182
+ err(ln, "bad-time", f"meter component exceeds MAX_METER: {n}/{d}")
183
+ continue
184
+ newt = (n, d)
185
+ if newt == current_time:
186
+ warn(
187
+ ln,
188
+ "redundant-time",
189
+ f"[time: {newt[0]}/{newt[1]}] repeats the current meter",
190
+ )
191
+ current_time = newt
192
+ pending_nav.append(("time", newt))
193
+ continue
194
+ sec = _SECTION_RE.match(line)
195
+ if sec:
196
+ name = sec.group(1)
197
+ kind = _SECTIONS.get(name)
198
+ if kind is None:
199
+ err(
200
+ ln,
201
+ "unknown-section",
202
+ f"unknown section {name!r}; allowed: A-D, Intro, Verse",
203
+ )
204
+ kind = SectionKind.A
205
+ cur = Section(label=name + sec.group(2), kind=kind)
206
+ sections.append(cur)
207
+ continue
208
+ if line.startswith("@"):
209
+ if "segno" in line:
210
+ pending_nav.append(("segno",))
211
+ elif "tocoda" in line or "to coda" in line or "to-coda" in line:
212
+ # "To Coda" jump-point: a Q AFTER the PRECEDING measure's chords (not the
213
+ # next one). Checked before "coda" because "tocoda" contains "coda".
214
+ if cur is not None and cur.measures:
215
+ cur.measures[-1].nav = cur.measures[-1].nav + (("tocoda",),)
216
+ else:
217
+ warn(
218
+ ln, "tocoda-without-measure", "@tocoda has no preceding measure"
219
+ )
220
+ elif "fine" in line:
221
+ pending_nav.append(("fine",))
222
+ elif "coda" in line:
223
+ pending_nav.append(("coda",))
224
+ elif "break" in line or "newline" in line:
225
+ # page-layout only — NO lead-sheet semantics. Demoted to an opaque
226
+ # render-hint (Phase 2 §3.3); a downstream codec reads it, text-target ignores it.
227
+ pending_hints.append("break")
228
+ else:
229
+ warn(ln, "unknown-nav", f"unknown @directive: {line}")
230
+ continue
231
+ if line.startswith("<") and line.endswith(">"):
232
+ pending_nav.append(("text", line))
233
+ continue
234
+ # otherwise: a measure line
235
+ section = ensure_section()
236
+ measures = _parse_measure_line(line, ln, err, warn)
237
+ for m in measures:
238
+ if pending_nav:
239
+ m.nav = tuple(pending_nav) + m.nav
240
+ pending_nav = []
241
+ if pending_hints:
242
+ m.hints = tuple(pending_hints) + m.hints
243
+ pending_hints = []
244
+ section.measures.append(m)
245
+ measure_count += 1
246
+ if measure_count > MAX_MEASURES:
247
+ err(ln, "too-big", "too many measures")
248
+ return ParseResult(LeadSheet(meta=meta, sections=sections), findings)
249
+
250
+ if not sections or all(not s.measures for s in sections):
251
+ err(0, "empty-chart", "no sections or measures")
252
+ return ParseResult(LeadSheet(meta=meta, sections=sections), findings)
253
+
254
+
255
+ def _parse_measure_line(line, ln, err, warn):
256
+ parts = _BARLINE_RE.split(line)
257
+ measures = []
258
+ bar_open = False
259
+ buf = None
260
+ for p in parts:
261
+ if p == "{":
262
+ bar_open = True
263
+ continue
264
+ if p in ("|", "}", "||", "Z"):
265
+ if buf is not None and buf.strip() != "":
266
+ if p in ("||", "Z"):
267
+ close = Barline.FINAL
268
+ elif p == "}":
269
+ close = Barline.REPEAT_END
270
+ else:
271
+ close = Barline.NORMAL
272
+ ending, cells = _parse_cells(buf, ln, err, warn)
273
+ measures.append(
274
+ Measure(
275
+ cells=cells,
276
+ ending=ending,
277
+ bar_open=bar_open,
278
+ barline=close,
279
+ line=ln,
280
+ )
281
+ )
282
+ bar_open = False
283
+ elif p == "}" and measures:
284
+ measures[-1].barline = Barline.REPEAT_END
285
+ buf = None
286
+ continue
287
+ if p.strip() != "":
288
+ buf = p
289
+ if buf is not None and buf.strip() != "":
290
+ ending, cells = _parse_cells(buf, ln, err, warn)
291
+ measures.append(
292
+ Measure(
293
+ cells=cells, ending=ending, bar_open=bar_open, barline=Barline.NORMAL, line=ln
294
+ )
295
+ )
296
+ return measures
297
+
298
+
299
+ def _parse_cells(content, ln, err, warn):
300
+ content = content.strip()
301
+ ending = None
302
+ m = _ENDING_RE.match(content)
303
+ if m:
304
+ ending = int(m.group(1))
305
+ content = m.group(2).strip()
306
+ # An ending number > MAX_METER (SPEC.md §1.4) is a typo and would otherwise overflow a
307
+ # fixed-width int in the ports; reject with the generic size-cap code.
308
+ if ending > MAX_METER:
309
+ err(ln, "too-big", f"ending number exceeds MAX_METER: {ending}")
310
+ cells = []
311
+ for tok in _CELL_RE.findall(content):
312
+ if tok.startswith("("):
313
+ if len(tok.strip("()").split()) > MAX_ALT_CHORD_TOKENS:
314
+ err(ln, "alt-too-long", "alt chord has too many tokens")
315
+ if cells:
316
+ cells[-1].alt = tok
317
+ else:
318
+ warn(ln, "alt-without-chord", f"alt group {tok} has no preceding chord")
319
+ continue
320
+ # Split an inline alt off the chord, e.g. "G7(Db7)" -> chord "G7" + alt "(Db7)"
321
+ # (lead-sheet notation writes alts with no space; the space-separated "G7 (Db7)" form is handled
322
+ # by the `tok.startswith("(")` branch above).
323
+ inline_alt = None
324
+ am = _INLINE_ALT_RE.match(tok)
325
+ if am:
326
+ tok, inline_alt = am.group(1), am.group(2)
327
+ if len(inline_alt.strip("()").split()) > MAX_ALT_CHORD_TOKENS:
328
+ err(ln, "alt-too-long", "alt chord has too many tokens")
329
+ chord, beats = tok, None
330
+ if ":" in tok:
331
+ chord, _, b = tok.partition(":")
332
+ # ASCII digits only (SPEC.md §1.1) — str.isdigit() accepts non-ASCII digits and
333
+ # numeric-but-non-decimal chars like '²' (which would crash int()). Cap at MAX_METER.
334
+ if _is_ascii_digits(b):
335
+ if int(b) > MAX_METER:
336
+ err(ln, "bad-beats", f"beat count exceeds MAX_METER in {tok!r}")
337
+ else:
338
+ beats = int(b)
339
+ else:
340
+ err(ln, "bad-beats", f"bad beat count in {tok!r} (expected :<digits>)")
341
+ if len(chord) > MAX_CHORD_TOKEN_LEN:
342
+ err(ln, "token-too-long", f"chord token too long: {chord[:20]}…")
343
+ cells.append(Cell(chord=chord, beats=beats, alt=inline_alt))
344
+ if len(cells) > MAX_CELLS_PER_MEASURE:
345
+ err(ln, "too-big", "too many cells in a measure")
346
+ break
347
+ return ending, cells
@@ -0,0 +1,152 @@
1
+ """Canonical IR <-> JSON serialization for the conformance suite.
2
+
3
+ Phase 2: the JSON reflects the NEUTRAL IR and carries a top-level ``schema_version: 1``.
4
+ ``ast_from_json`` rejects an unknown major version. ``LeadSheet`` replaces ``DslChart``;
5
+ ``Section.kind`` replaces ``marker``; ``Measure.barline`` (enum) replaces ``bar_close`` (glyph);
6
+ ``Measure.hints`` is a new opaque string list. ``nav`` round-trips list<->tuple as before; the
7
+ new ``fine`` nav item is a bare ``["fine"]``.
8
+ """
9
+
10
+ from tonalis.ast import Barline, Cell, LeadSheet, Measure, Section, SectionKind
11
+
12
+ SCHEMA_VERSION = 1
13
+
14
+
15
+ def _meta_to_json(meta: dict) -> dict:
16
+ out = {}
17
+ for k, v in meta.items():
18
+ if k == "time" and isinstance(v, tuple):
19
+ out[k] = list(v)
20
+ else:
21
+ out[k] = v
22
+ return out
23
+
24
+
25
+ def _meta_from_json(d: dict) -> dict:
26
+ out = {}
27
+ for k, v in d.items():
28
+ if k == "time" and isinstance(v, list):
29
+ out[k] = tuple(v)
30
+ else:
31
+ out[k] = v
32
+ return out
33
+
34
+
35
+ def _nav_to_json(nav) -> list:
36
+ return [_nav_item_to_json(item) for item in nav]
37
+
38
+
39
+ def _nav_item_to_json(item):
40
+ return [_nav_atom_to_json(a) for a in item]
41
+
42
+
43
+ def _nav_atom_to_json(a):
44
+ if isinstance(a, tuple):
45
+ return list(a)
46
+ return a
47
+
48
+
49
+ def _nav_from_json(nav) -> tuple:
50
+ return tuple(_nav_item_from_json(item) for item in nav)
51
+
52
+
53
+ def _nav_item_from_json(item) -> tuple:
54
+ return tuple(_nav_atom_from_json(a) for a in item)
55
+
56
+
57
+ def _nav_atom_from_json(a):
58
+ if isinstance(a, list):
59
+ return tuple(a)
60
+ return a
61
+
62
+
63
+ def _cell_to_json(c: Cell) -> dict:
64
+ return {"chord": c.chord, "beats": c.beats, "alt": c.alt}
65
+
66
+
67
+ def _cell_from_json(d: dict) -> Cell:
68
+ return Cell(chord=d["chord"], beats=d.get("beats"), alt=d.get("alt"))
69
+
70
+
71
+ def _measure_to_json(m: Measure) -> dict:
72
+ return {
73
+ "cells": [_cell_to_json(c) for c in m.cells],
74
+ "ending": m.ending,
75
+ "bar_open": m.bar_open,
76
+ "barline": m.barline.value,
77
+ "nav": _nav_to_json(m.nav),
78
+ "hints": list(m.hints),
79
+ "line": m.line,
80
+ }
81
+
82
+
83
+ def _measure_from_json(d: dict) -> Measure:
84
+ return Measure(
85
+ cells=[_cell_from_json(c) for c in d["cells"]],
86
+ ending=d.get("ending"),
87
+ bar_open=d.get("bar_open", False),
88
+ barline=Barline(d.get("barline", "normal")),
89
+ nav=_nav_from_json(d.get("nav", [])),
90
+ hints=tuple(d.get("hints", [])),
91
+ line=d.get("line", 0),
92
+ )
93
+
94
+
95
+ def _section_to_json(s: Section) -> dict:
96
+ return {
97
+ "label": s.label,
98
+ "kind": s.kind.value,
99
+ "measures": [_measure_to_json(m) for m in s.measures],
100
+ }
101
+
102
+
103
+ def _section_from_json(d: dict) -> Section:
104
+ return Section(
105
+ label=d["label"],
106
+ kind=SectionKind(d["kind"]),
107
+ measures=[_measure_from_json(m) for m in d["measures"]],
108
+ )
109
+
110
+
111
+ def ast_to_json(chart: LeadSheet) -> dict:
112
+ """Serialize a :class:`LeadSheet` to the canonical JSON-able dict (SPEC.md §2.1)."""
113
+ return {
114
+ "schema_version": SCHEMA_VERSION,
115
+ "meta": _meta_to_json(chart.meta),
116
+ "sections": [_section_to_json(s) for s in chart.sections],
117
+ }
118
+
119
+
120
+ # Public aliases (spec §1.1 surface names): to_json / from_json.
121
+ to_json = ast_to_json
122
+
123
+
124
+ def _check_schema_version(d: dict) -> None:
125
+ """Reject an unknown/malformed ``schema_version`` with a clean ValueError.
126
+
127
+ Missing -> default 1 (accepted). Present must be the INTEGER ``SCHEMA_VERSION`` — a string
128
+ ``"2"``, ``null`` (Python ``None``), a bool, or any non-1 int are all rejected with a
129
+ ValueError (never a TypeError crash on ``int(None)``). Consistent with the TS/Rust ports.
130
+ """
131
+ if "schema_version" not in d:
132
+ return
133
+ version = d["schema_version"]
134
+ # bool is an int subclass but is never a valid version; reject it explicitly.
135
+ if isinstance(version, bool) or not isinstance(version, int) or version != SCHEMA_VERSION:
136
+ raise ValueError(
137
+ f"unsupported schema_version {version!r}; this build understands {SCHEMA_VERSION}"
138
+ )
139
+
140
+
141
+ def ast_from_json(d: dict) -> LeadSheet:
142
+ """Inverse of :func:`ast_to_json`. Rejects an unknown major schema version, an unknown
143
+ ``Section.kind``, or an unknown ``Measure.barline`` with a clean ValueError (the canonical
144
+ JSON is a public interface — malformed input is rejected uniformly across all three ports)."""
145
+ _check_schema_version(d)
146
+ return LeadSheet(
147
+ meta=_meta_from_json(d.get("meta", {})),
148
+ sections=[_section_from_json(s) for s in d.get("sections", [])],
149
+ )
150
+
151
+
152
+ from_json = ast_from_json
@@ -0,0 +1,111 @@
1
+ """Deterministic canonical-DSL pretty-printer: LeadSheet -> DSL text (Phase 2 §1.1).
2
+
3
+ The inverse direction of ``parser.parse_dsl`` for the canonical form. ``parse(serialize(ir)) == ir``
4
+ is gated by the conformance suite; this printer is the only place IR -> text lives.
5
+ """
6
+
7
+ from tonalis.ast import Barline, LeadSheet, Measure, Section
8
+
9
+ _HEADER_ORDER = ("title", "composer", "style", "key", "time")
10
+ _BARLINE_GLYPH = {
11
+ Barline.NORMAL: "|",
12
+ Barline.REPEAT_END: "}",
13
+ Barline.FINAL: "||",
14
+ }
15
+
16
+
17
+ def _header(meta: dict) -> str:
18
+ out = []
19
+ for k in _HEADER_ORDER:
20
+ if k not in meta:
21
+ continue
22
+ v = meta[k]
23
+ if k == "time":
24
+ out.append(f"time: {v[0]}/{v[1]}")
25
+ else:
26
+ out.append(f"{k}: {v}")
27
+ return "\n".join(out)
28
+
29
+
30
+ def _cell_str(cell) -> str:
31
+ s = cell.chord
32
+ if cell.beats is not None:
33
+ s += f":{cell.beats}"
34
+ if cell.alt:
35
+ # A single-token alt glues inline (`G7(Db7)`, the grammar's "inline alt, no space" form),
36
+ # but a MULTI-token alt carries an interior space (`(A-7 D7)`) and the glued form
37
+ # `G7(A-7 D7)` re-tokenizes wrong (the cell scanner `\([^)]*\)|\S+` breaks at the space).
38
+ # Emit the separated `chord (alt)` form so `parse(serialize(ir)) == ir` holds (SPEC §2.1).
39
+ s += (" " if " " in cell.alt else "") + cell.alt
40
+ return s
41
+
42
+
43
+ def _nav_lines_before(m: Measure) -> list:
44
+ lines = []
45
+ for item in m.nav:
46
+ head = item[0]
47
+ if head == "segno":
48
+ lines.append("@segno")
49
+ elif head == "coda":
50
+ lines.append("@coda")
51
+ elif head == "fine":
52
+ lines.append("@fine")
53
+ elif head == "text":
54
+ lines.append(item[1])
55
+ elif head == "time":
56
+ lines.append(f"[time: {item[1][0]}/{item[1][1]}]")
57
+ # tocoda is emitted AFTER the measure (see _nav_after)
58
+ return lines
59
+
60
+
61
+ def _nav_after(m: Measure) -> list:
62
+ return ["@tocoda" for item in m.nav if item and item[0] == "tocoda"]
63
+
64
+
65
+ def _measure_str(m: Measure, *, first_on_line: bool) -> str:
66
+ # Canonical body line: a leading "| " opens the line, measures are "| "-joined, and each
67
+ # measure ends with its barline glyph. A repeat-open prefixes "{ ".
68
+ prefix = "{ " if m.bar_open else ("| " if first_on_line else "")
69
+ body = ""
70
+ if m.ending is not None:
71
+ body += f"{m.ending}. "
72
+ body += " ".join(_cell_str(c) for c in m.cells)
73
+ return f"{prefix}{body} {_BARLINE_GLYPH[m.barline]}"
74
+
75
+
76
+ def _section_text(s: Section) -> str:
77
+ # One body line per section (parser-faithful): directives (@break/@segno/.../<text>) sit on
78
+ # their own line and FLUSH the in-progress measure line before them; @tocoda flushes after.
79
+ lines = [f"[{s.label}]"]
80
+ run = [] # measures accumulating on the current body line
81
+
82
+ def flush():
83
+ if run:
84
+ lines.append(" ".join(run))
85
+ run.clear()
86
+
87
+ for m in s.measures:
88
+ before = []
89
+ for h in m.hints:
90
+ if h == "break":
91
+ before.append("@break")
92
+ before.extend(_nav_lines_before(m))
93
+ if before:
94
+ flush()
95
+ lines.extend(before)
96
+ run.append(_measure_str(m, first_on_line=not run))
97
+ after = _nav_after(m)
98
+ if after:
99
+ flush()
100
+ lines.extend(after)
101
+ flush()
102
+ return "\n".join(lines)
103
+
104
+
105
+ def serialize(chart: LeadSheet) -> str:
106
+ """Canonical DSL text for a LeadSheet (trailing newline; section blocks blank-line separated)."""
107
+ parts = [_header(chart.meta)]
108
+ for s in chart.sections:
109
+ parts.append("") # blank line before each section
110
+ parts.append(_section_text(s))
111
+ return "\n".join(parts) + "\n"
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.4
2
+ Name: tonalis
3
+ Version: 0.1.0
4
+ Summary: Tonalis — a generic, format-agnostic music-harmony DSL (parse/lint/AST/JSON/text).
5
+ License: MIT License
6
+
7
+ Copyright (c) 2026 DRYCodeWorks
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+
27
+ Project-URL: Homepage, https://github.com/drycode/tonalis
28
+ Project-URL: Repository, https://github.com/drycode/tonalis
29
+ Classifier: License :: OSI Approved :: MIT License
30
+ Requires-Python: >=3.11
31
+ Description-Content-Type: text/markdown
32
+ License-File: LICENSE
33
+ Requires-Dist: tonalis-music-dsl==0.1.0
34
+ Dynamic: license-file
35
+
36
+ # tonalis
37
+
38
+ A generic, format-agnostic music-harmony DSL for lead sheets: parse chord
39
+ charts to a canonical AST, lint them with structured findings, and render back
40
+ to canonical text or JSON. This is the Python reference implementation;
41
+ TypeScript (`tonalis` on npm) and Rust (`tonalis` on crates.io) ports conform
42
+ to the same normative spec and a shared 255-case conformance suite.
43
+
44
+ Music-theory questions (chord validity, scales, keys) are delegated to the
45
+ pure [`tonalis-music-dsl`](https://pypi.org/project/tonalis-music-dsl/) theory library.
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ pip install tonalis
51
+ ```
52
+
53
+ ## Use
54
+
55
+ ```python
56
+ from tonalis import parse_dsl, lint, serialize, to_json
57
+
58
+ result = parse_dsl(open("chart.txt").read())
59
+ findings = list(result.findings) + (lint(result.chart) if result.chart else [])
60
+ chart = result.chart # a LeadSheet AST
61
+ json_obj = to_json(chart) # canonical JSON (a dict)
62
+ text = serialize(chart) # canonical text rendering
63
+ ```
64
+
65
+ A small CLI is included: `python -m tonalis chart.txt` prints lint findings
66
+ (exit 1 if any error).
67
+
68
+ ## Links
69
+
70
+ - Source, spec, docs, and conformance suite: <https://github.com/drycode/tonalis>
71
+ - License: MIT
@@ -0,0 +1,18 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ tonalis/GRAMMAR.md
5
+ tonalis/__init__.py
6
+ tonalis/__main__.py
7
+ tonalis/ast.py
8
+ tonalis/chord_grammar.py
9
+ tonalis/chords.py
10
+ tonalis/lint.py
11
+ tonalis/parser.py
12
+ tonalis/serialize.py
13
+ tonalis/serialize_text.py
14
+ tonalis.egg-info/PKG-INFO
15
+ tonalis.egg-info/SOURCES.txt
16
+ tonalis.egg-info/dependency_links.txt
17
+ tonalis.egg-info/requires.txt
18
+ tonalis.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ tonalis-music-dsl==0.1.0
@@ -0,0 +1 @@
1
+ tonalis