doricomcp 0.1.0__py3-none-any.whl

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.
Files changed (83) hide show
  1. doricomcp/__init__.py +0 -0
  2. doricomcp/analysis.py +161 -0
  3. doricomcp/bridge.py +153 -0
  4. doricomcp/catalog.py +154 -0
  5. doricomcp/confirm.py +684 -0
  6. doricomcp/data/commands.json +14827 -0
  7. doricomcp/data/commands_not_in_getcommands.json +123 -0
  8. doricomcp/data/confirmed.json +436 -0
  9. doricomcp/data/enums.json +15970 -0
  10. doricomcp/data/library/library_collections.json +58 -0
  11. doricomcp/data/library/library_kAccidentalDefinitionCollection.json +272 -0
  12. doricomcp/data/library/library_kAccidentalSystemCollection.json +32 -0
  13. doricomcp/data/library/library_kBLOBEntityCollection.json +7 -0
  14. doricomcp/data/library/library_kBrushStyleCollection.json +16 -0
  15. doricomcp/data/library/library_kCharacterStyleCollection.json +20 -0
  16. doricomcp/data/library/library_kChordDiagramCollection.json +1924 -0
  17. doricomcp/data/library/library_kChordSymbolAppearanceCollection.json +7 -0
  18. doricomcp/data/library/library_kChordSymbolAppearanceComponentCollection.json +1568 -0
  19. doricomcp/data/library/library_kChordSymbolComponentKerningPairsCollection.json +24 -0
  20. doricomcp/data/library/library_kClefCollection.json +112 -0
  21. doricomcp/data/library/library_kCompositeCollection.json +7200 -0
  22. doricomcp/data/library/library_kCustomChordDataCollection.json +7 -0
  23. doricomcp/data/library/library_kDrawingPrimitiveCollection.json +12 -0
  24. doricomcp/data/library/library_kDrumKitNoteMapCollection.json +136 -0
  25. doricomcp/data/library/library_kEnsembleCollection.json +140 -0
  26. doricomcp/data/library/library_kExpressionMapCollection.json +172 -0
  27. doricomcp/data/library/library_kFlagCollection.json +168 -0
  28. doricomcp/data/library/library_kFlagSetCollection.json +16 -0
  29. doricomcp/data/library/library_kFontStyleCollection.json +256 -0
  30. doricomcp/data/library/library_kFrettedInstrumentDataCollection.json +220 -0
  31. doricomcp/data/library/library_kGlyphPrimitiveCollection.json +5892 -0
  32. doricomcp/data/library/library_kGraphicPrimitiveCollection.json +7 -0
  33. doricomcp/data/library/library_kInstrumentCollection.json +2504 -0
  34. doricomcp/data/library/library_kInstrumentFamilyCollection.json +56 -0
  35. doricomcp/data/library/library_kInstrumentNameCollection.json +12 -0
  36. doricomcp/data/library/library_kInstrumentScoreOrderCollection.json +20 -0
  37. doricomcp/data/library/library_kLineAnnotationCollection.json +84 -0
  38. doricomcp/data/library/library_kLineBodyStyleCollection.json +144 -0
  39. doricomcp/data/library/library_kLineStyleCollection.json +192 -0
  40. doricomcp/data/library/library_kMultiSegmentLineCollection.json +12 -0
  41. doricomcp/data/library/library_kNoteheadCollection.json +856 -0
  42. doricomcp/data/library/library_kNoteheadSetCollection.json +156 -0
  43. doricomcp/data/library/library_kOrnamentCollection.json +188 -0
  44. doricomcp/data/library/library_kOttavaLineCollection.json +36 -0
  45. doricomcp/data/library/library_kPageDimensionsPresetCollection.json +84 -0
  46. doricomcp/data/library/library_kPagePairDefinitionSetCollection.json +16 -0
  47. doricomcp/data/library/library_kParagraphStyleCollection.json +156 -0
  48. doricomcp/data/library/library_kPenStyleCollection.json +16 -0
  49. doricomcp/data/library/library_kPercussionInstrumentDataCollection.json +100 -0
  50. doricomcp/data/library/library_kPercussionKitDefinitionCollection.json +116 -0
  51. doricomcp/data/library/library_kPlayingTechniqueAppearanceCollection.json +1238 -0
  52. doricomcp/data/library/library_kPlayingTechniqueCollection.json +1200 -0
  53. doricomcp/data/library/library_kRepeatableCompositeCollection.json +408 -0
  54. doricomcp/data/library/library_kRestCollection.json +72 -0
  55. doricomcp/data/library/library_kRestSetCollection.json +20 -0
  56. doricomcp/data/library/library_kRhythmicFeelCollection.json +52 -0
  57. doricomcp/data/library/library_kTemperamentDefinitionCollection.json +28 -0
  58. doricomcp/data/library/library_kTempoPresetCollection.json +320 -0
  59. doricomcp/data/library/library_kTextPrimitiveCollection.json +1136 -0
  60. doricomcp/data/library/library_kTonalitySystemDefinitionCollection.json +20 -0
  61. doricomcp/data/property_names.json +1169 -0
  62. doricomcp/data/rc_property_schemas.json +560 -0
  63. doricomcp/fixture.py +132 -0
  64. doricomcp/logtail.py +47 -0
  65. doricomcp/lua/lib.lua +516 -0
  66. doricomcp/model.py +326 -0
  67. doricomcp/ops/__init__.py +47 -0
  68. doricomcp/ops/__main__.py +121 -0
  69. doricomcp/ops/common.py +310 -0
  70. doricomcp/ops/dynamics.py +226 -0
  71. doricomcp/ops/markings.py +293 -0
  72. doricomcp/ops/notes.py +498 -0
  73. doricomcp/probe.py +86 -0
  74. doricomcp/rc.py +185 -0
  75. doricomcp/reads.py +288 -0
  76. doricomcp/server.py +982 -0
  77. doricomcp/session.py +342 -0
  78. doricomcp/windows.py +245 -0
  79. doricomcp-0.1.0.dist-info/METADATA +249 -0
  80. doricomcp-0.1.0.dist-info/RECORD +83 -0
  81. doricomcp-0.1.0.dist-info/WHEEL +4 -0
  82. doricomcp-0.1.0.dist-info/entry_points.txt +2 -0
  83. doricomcp-0.1.0.dist-info/licenses/LICENSE +21 -0
doricomcp/__init__.py ADDED
File without changes
doricomcp/analysis.py ADDED
@@ -0,0 +1,161 @@
1
+ """Voice-leading check over read_music data: parallel and hidden fifths/octaves, crossings, spacing, leaps and,
2
+ with a chord plan, doublings and missing chord tones. Pure Python on what read_music returns; no Dorico access.
3
+
4
+ Depends on: model.
5
+ """
6
+ import re
7
+ from fractions import Fraction
8
+ from itertools import pairwise
9
+
10
+ from .model import frac, spelling_to_midi
11
+
12
+ PC = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
13
+ # chord quality -> semitones above the root of (third, fifth, seventh); None = not in the chord
14
+ QUALITIES = {"": (4, 7, None), "m": (3, 7, None), "dim": (3, 6, None), "aug": (4, 8, None), "sus4": (5, 7, None),
15
+ "sus2": (2, 7, None), "7": (4, 7, 10), "maj7": (4, 7, 11), "m7": (3, 7, 10), "m7b5": (3, 6, 10),
16
+ "dim7": (3, 6, 9), "mmaj7": (3, 7, 11)}
17
+ CHORD_RX = re.compile(r"^([A-G])(#|b)?(maj7|mmaj7|m7b5|dim7|dim|aug|sus4|sus2|m7|m|7|ø7|o7|ø|o|)(?:/([A-G](?:#|b)?))?$")
18
+ ALIASES = {"ø7": "m7b5", "ø": "m7b5", "o7": "dim7", "o": "dim"}
19
+ NAMES = {0: "unison/octave", 7: "fifth"}
20
+
21
+
22
+ def _pc(name: str) -> int:
23
+ return (PC[name[0]] + {"#": 1, "b": -1}.get(name[1:2], 0)) % 12
24
+
25
+
26
+ def parse_chord(sym: str) -> dict:
27
+ m = CHORD_RX.match(sym.strip())
28
+ if not m:
29
+ raise ValueError(f"chord {sym!r}: like D, Bm, A7, F#dim, Gmaj7, Bm7b5, D/F#")
30
+ root = _pc(m.group(1) + (m.group(2) or ""))
31
+ third, fifth, seventh = QUALITIES[ALIASES.get(m.group(3), m.group(3))]
32
+ roles = {root: "root", (root + third) % 12: "third", (root + fifth) % 12: "fifth"}
33
+ if seventh is not None:
34
+ roles[(root + seventh) % 12] = "seventh"
35
+ return {"symbol": sym.strip(), "roles": roles, "bass": _pc(m.group(4)) if m.group(4) else None,
36
+ "major": third == 4}
37
+
38
+
39
+ def parse_plan(plan: str) -> list[tuple[int, Fraction, dict]]:
40
+ """"28 D, 28:2 A7, 29 Bm/F#" -> [(bar, beat, chord)], sorted."""
41
+ out = []
42
+ for item in filter(None, (x.strip() for x in re.split(r"[,;\n]", plan))):
43
+ where, _, sym = item.partition(" ")
44
+ bar, _, beat = where.partition(":")
45
+ out.append((int(bar), frac(beat or 0), parse_chord(sym)))
46
+ return sorted(out, key=lambda x: (x[0], x[1]))
47
+
48
+
49
+ def _lines(read: dict) -> dict[int, list[tuple]]:
50
+ """voice -> [(start, end, midi, pitch, bar, beat)]; a chord in one voice counts by its top note."""
51
+ out = {}
52
+ for v in read["voices"]:
53
+ notes = []
54
+ for i in v["items"]:
55
+ if i["type"] not in ("note", "chord"):
56
+ continue
57
+ k = max(range(len(i["midi"])), key=lambda j: i["midi"][j] or spelling_to_midi(i["pitches"][j]) or 0)
58
+ dur = max(frac(d) for d in (i["dur"] if isinstance(i["dur"], list) else [i["dur"]]))
59
+ notes.append((frac(i["pos"]), frac(i["pos"]) + dur, i["midi"][k] or spelling_to_midi(i["pitches"][k]),
60
+ i["pitches"][k], i["bar"], i["beat"]))
61
+ out[v["voice"]] = notes
62
+ return out
63
+
64
+
65
+ def check(read: dict, order: list[int] | None = None, plan: str | None = None, key: str | None = None,
66
+ max_leap: int = 12, limit: int = 300) -> dict:
67
+ """order: voices from highest to lowest (default: by average pitch). key: tonic for leading-tone doubling
68
+ ("D", "Bm"; minor keys use the raised seventh). plan: chords in force, "bar[:beat] symbol, ..."."""
69
+ lines = _lines(read)
70
+ lines = {v: ns for v, ns in lines.items() if ns}
71
+ order = [v for v in (order or sorted(lines, key=lambda v: -sum(n[2] for n in lines[v]) / len(lines[v]))) if v in lines]
72
+ chords = parse_plan(plan) if plan else []
73
+ leading = (_pc(key.rstrip("m")) - 1) % 12 if key else None
74
+ problems = []
75
+
76
+ def add(kind, at, voices, detail):
77
+ problems.append({"bar": at[0], "beat": at[1], "kind": kind, "voices": voices, "detail": detail})
78
+
79
+ for v in order: # melodic leaps
80
+ for a, b in pairwise(lines[v]):
81
+ d = abs(b[2] - a[2])
82
+ if d > max_leap or d == 6:
83
+ add("leap" if d != 6 else "tritone leap", (b[4], b[5]), [v], f"{a[3]} -> {b[3]} ({d} semitones)")
84
+
85
+ times = sorted({n[0] for ns in lines.values() for n in ns})
86
+ prev = None
87
+ for t in times:
88
+ cur = {v: next((n for n in lines[v] if n[0] <= t < n[1]), None) for v in order}
89
+ at = next((n[4], n[5]) for n in cur.values() if n and n[0] == t)
90
+ here = [v for v in order if cur[v]]
91
+ for hi, lo in pairwise(here):
92
+ if cur[hi][2] < cur[lo][2]:
93
+ add("crossing", at, [hi, lo], f"{cur[hi][3]} below {cur[lo][3]}")
94
+ for hi, lo in pairwise(here[:-1]): # upper voices; the bass may be further away
95
+ if cur[hi][2] - cur[lo][2] > 12:
96
+ add("spacing", at, [hi, lo], f"{cur[hi][3]} - {cur[lo][3]} wider than an octave")
97
+ if prev:
98
+ for i, a in enumerate(order):
99
+ for b in order[i + 1:]:
100
+ pa, pb, ca, cb = prev.get(a), prev.get(b), cur[a], cur[b]
101
+ if not (pa and pb and ca and cb) or (pa is ca and pb is cb):
102
+ continue
103
+ da, db = ca[2] - pa[2], cb[2] - pb[2]
104
+ if not (da and db):
105
+ continue
106
+ i1, i2 = (pa[2] - pb[2]) % 12, (ca[2] - cb[2]) % 12
107
+ if i1 == i2 and i1 in NAMES:
108
+ name = "unisons" if pa[2] == pb[2] and ca[2] == cb[2] else NAMES[i1] + "s"
109
+ motion = "parallel" if da * db > 0 else "contrary"
110
+ add(f"{motion} {name}", at, [a, b], f"{pa[3]}/{pb[3]} -> {ca[3]}/{cb[3]}")
111
+ elif (a, b) == (order[0], order[-1]) and da * db > 0 and i2 in NAMES and abs(da) > 2:
112
+ add(f"hidden {NAMES[i2]}", at, [a, b], f"{pa[3]}/{pb[3]} -> {ca[3]}/{cb[3]}, upper voice leaps")
113
+ if chords and len(here) >= 3:
114
+ ch = next((c for bar, beat, c in reversed(chords) if (bar, beat) <= (at[0], frac(at[1]))), None)
115
+ if ch:
116
+ pcs = [cur[v][2] % 12 for v in here]
117
+ count = {p: pcs.count(p) for p in set(pcs)}
118
+ roles = {ch["roles"].get(p): n for p, n in count.items()}
119
+ if roles.get("third", 0) > 1 and ch["major"]:
120
+ add("doubled third", at, here, f"{ch['symbol']}: major third doubled")
121
+ if roles.get("seventh", 0) > 1:
122
+ add("doubled seventh", at, here, ch["symbol"])
123
+ if leading is not None and count.get(leading, 0) > 1:
124
+ add("doubled leading tone", at, here, ch["symbol"])
125
+ if "third" not in roles:
126
+ add("missing third", at, here, ch["symbol"])
127
+ if foreign := sorted({cur[v][3] for v in here if cur[v][2] % 12 not in ch["roles"]}):
128
+ add("non-chord tone", at, here, f"{ch['symbol']}: {' '.join(foreign)}")
129
+ if ch["bass"] is not None and cur[here[-1]][2] % 12 != ch["bass"]:
130
+ add("bass", at, [here[-1]], f"{ch['symbol']}: bass is {cur[here[-1]][3]}")
131
+ prev = cur
132
+ summary = {}
133
+ for p in problems:
134
+ summary[p["kind"]] = summary.get(p["kind"], 0) + 1
135
+ problems.sort(key=lambda p: (p["bar"], frac(p["beat"])))
136
+ return {"voices_high_to_low": order, "verticals": len(times), "summary": summary, "problems": problems[:limit],
137
+ **({"truncated": len(problems) - limit} if len(problems) > limit else {})}
138
+
139
+
140
+ if __name__ == "__main__":
141
+ def voice(vid, notes):
142
+ items, t = [], Fraction(0)
143
+ for p, d in notes:
144
+ items.append({"type": "note", "pos": str(t), "dur": d, "bar": 1 + int(t // 3), "beat": str(t % 3),
145
+ "pitches": [p], "midi": [spelling_to_midi(p)]})
146
+ t += frac(d)
147
+ return {"voice": vid, "items": items}
148
+
149
+ # outer voices in parallel fifths D5/G4 -> E5/A4; the middle voice drops below the bass, then leaps a ninth
150
+ rd = {"voices": [voice(1, [("D5", "1"), ("E5", "1"), ("F#5", "1")]), voice(2, [("B4", "1"), ("C#4", "1"), ("D5", "1")]),
151
+ voice(3, [("G4", "1"), ("A4", "1"), ("A4", "1")])]}
152
+ r = check(rd, order=[1, 2, 3])
153
+ kinds = r["summary"]
154
+ assert kinds == {"parallel fifths": 1, "crossing": 1, "spacing": 1, "leap": 1}, r
155
+ # a chord plan: D major with a doubled F#, then A7 with no third
156
+ rd = {"voices": [voice(1, [("F#5", "1"), ("G5", "1")]), voice(2, [("F#4", "1"), ("E4", "1")]),
157
+ voice(3, [("D3", "1"), ("A2", "1")])]}
158
+ r = check(rd, order=[1, 2, 3], plan="1 D, 1:1 A7", key="D")
159
+ assert r["summary"].get("doubled third") == 1 and r["summary"].get("missing third") == 1, r
160
+ assert parse_chord("Bm7b5")["roles"] == {11: "root", 2: "third", 5: "fifth", 9: "seventh"}
161
+ print("analysis self-check ok")
doricomcp/bridge.py ADDED
@@ -0,0 +1,153 @@
1
+ """Lua transaction bridge: one call = one generated self-contained Lua script run by Script.RunScript.
2
+
3
+ The script is lua/lib.lua + `local REQ = <request literal>` + a dispatcher. It writes `<id>.json`
4
+ then `<id>.done` (the marker is written last, so a present marker means the JSON is complete).
5
+ Script.RunScript returns before the Lua runs, so Python waits on the marker.
6
+ A script is one undo step in Dorico; a read-only script adds none.
7
+
8
+ Depends on: logtail, rc, windows.
9
+ """
10
+ import asyncio
11
+ import json
12
+ import pathlib
13
+ import subprocess
14
+ import time
15
+ import uuid
16
+
17
+ from .logtail import LOG
18
+ from .rc import HOME, RemoteControl
19
+
20
+ RUN_DIR = HOME / "run" # no spaces: the path goes into a command string unescaped
21
+ LIB = pathlib.Path(__file__).with_name("lua") / "lib.lua"
22
+
23
+
24
+ class LuaError(RuntimeError):
25
+ pass
26
+
27
+
28
+ class StillRunning(LuaError):
29
+ """The script has not finished: Dorico is blocked (dialog) or very slow. It may still change the score, so
30
+ nothing may be read back or undone against it."""
31
+
32
+
33
+ HARD_LIMIT = 600.0 # one fixed ceiling for a genuinely hung Dorico; dialogs are caught much earlier
34
+
35
+
36
+ def lua_literal(v) -> str:
37
+ """Python value -> Lua literal (dicts become tables keyed by string, lists 1-based arrays)."""
38
+ if v is None:
39
+ return "nil"
40
+ if v is True or v is False:
41
+ return "true" if v else "false"
42
+ if isinstance(v, int):
43
+ return str(v)
44
+ if isinstance(v, float):
45
+ return repr(v)
46
+ if isinstance(v, str):
47
+ return '"' + "".join(c if 32 <= ord(c) < 127 and c not in '"\\' else "".join(f"\\{b:03d}" for b in c.encode()) for c in v) + '"'
48
+ if isinstance(v, (list, tuple)):
49
+ return "{" + ",".join(lua_literal(x) for x in v) + "}"
50
+ if isinstance(v, dict):
51
+ return "{" + ",".join(f"[{lua_literal(str(k))}]={lua_literal(x)}" for k, x in v.items()) + "}"
52
+ raise TypeError(f"cannot pass {type(v).__name__} to Lua")
53
+
54
+
55
+ async def _wait_finished(script: str, offset: int, deadline: float):
56
+ needle = f"notifyPostCommandExecute: Script.RunScript?ScriptPath={script}".encode()
57
+ def seen() -> bool:
58
+ try:
59
+ with open(LOG, "rb") as f:
60
+ f.seek(offset)
61
+ return needle in f.read()
62
+ except FileNotFoundError:
63
+ return True
64
+ while time.monotonic() < deadline:
65
+ if await asyncio.to_thread(seen):
66
+ return
67
+ await asyncio.sleep(0.01)
68
+ raise LuaError(f"{script}: Dorico did not finish the script command in time")
69
+
70
+
71
+ def _dialogs() -> list[dict]:
72
+ from .windows import dialogs
73
+ try:
74
+ return dialogs()
75
+ except (OSError, RuntimeError, subprocess.SubprocessError, ValueError): # no swiftc / no permission: best effort
76
+ return []
77
+
78
+
79
+ class Bridge:
80
+ def __init__(self, rc: RemoteControl):
81
+ self.rc = rc
82
+ self._lock = asyncio.Lock()
83
+
84
+ async def run(self, op: str, timeout: float = 20.0, extra_lua: str = "", **req):
85
+ """Run lib op `op` with request fields `req`; return its result or raise LuaError."""
86
+ RUN_DIR.mkdir(parents=True, exist_ok=True)
87
+ rid = uuid.uuid4().hex
88
+ script, out, done = (RUN_DIR / f"{rid}{ext}" for ext in (".lua", ".json", ".done"))
89
+ req["op"] = op
90
+ script.write_text(
91
+ f"local REQ = {lua_literal(req)}\n"
92
+ f"local OUT, DONE = {lua_literal(str(out))}, {lua_literal(str(done))}\n"
93
+ + LIB.read_text() + "\n" + extra_lua + "\nreturn RUN()\n")
94
+ async with self._lock:
95
+ running = False
96
+ try:
97
+ log0 = LOG.stat().st_size if LOG.exists() else 0
98
+ await self.rc.command(f"Script.RunScript?ScriptPath={script}")
99
+ running = True
100
+ t0 = time.monotonic()
101
+ # Never give up on a running script: Dorico interleaves Remote Control commands with the script's
102
+ # own doCommand calls, so a read or Edit.Undo sent now would land in the middle of it (an undo
103
+ # once landed between two NoteInput.Pitch commands, and the script kept typing).
104
+ check = t0 + timeout
105
+ while not done.exists():
106
+ now = time.monotonic()
107
+ if now > check:
108
+ if d := _dialogs():
109
+ raise StillRunning(f"op {op!r}: Dorico shows a dialog ({d[0]['w']}x{d[0]['h']} '{d[0]['name']}') "
110
+ "and the edit has not finished; answer it with list_dialogs + dialog_click (or in Dorico)")
111
+ if now - t0 > HARD_LIMIT:
112
+ raise StillRunning(f"op {op!r}: Dorico has not finished after {now - t0:.0f}s")
113
+ check = now + 5
114
+ await asyncio.sleep(0.02)
115
+ running = False
116
+ payload = json.loads(out.read_text())
117
+ # The marker is written inside the script; Dorico is still executing RunScript until it logs
118
+ # notifyPostCommandExecute. Commands sent before that (e.g. Edit.Undo) get kCommandNotAllowed.
119
+ await _wait_finished(str(script), log0, time.monotonic() + timeout)
120
+ finally:
121
+ # a script still running writes its markers later; leave them rather than race it
122
+ for p in () if running else (script, out, done):
123
+ p.unlink(missing_ok=True)
124
+ if not payload.get("ok"):
125
+ raise LuaError(f"op {op!r} failed in Dorico: {payload.get('error')}")
126
+ return payload.get("result")
127
+
128
+
129
+ if __name__ == "__main__":
130
+ import tempfile
131
+
132
+ class SlowDorico: # writes the result 0.5 s after RunScript, well past the 0.1 s timeout
133
+ def __init__(self):
134
+ self.sent = []
135
+
136
+ async def command(self, c):
137
+ self.sent.append(c)
138
+ script = pathlib.Path(c.split("ScriptPath=", 1)[1])
139
+ out, done = (script.with_suffix(x) for x in (".json", ".done"))
140
+ asyncio.get_running_loop().call_later(0.5, lambda: (out.write_text('{"ok": true, "result": 42}'), done.write_text("1")))
141
+
142
+ async def main():
143
+ global RUN_DIR, _wait_finished
144
+ RUN_DIR = pathlib.Path(tempfile.mkdtemp())
145
+
146
+ async def finished(*a):
147
+ return None
148
+ _wait_finished = finished
149
+ rc = SlowDorico()
150
+ assert await Bridge(rc).run("tx", timeout=0.1) == 42
151
+ assert len(rc.sent) == 1 and not list(RUN_DIR.iterdir()), (rc.sent, list(RUN_DIR.iterdir()))
152
+ asyncio.run(main())
153
+ print("bridge self-check ok: a slow script is waited for, nothing else is sent meanwhile")
doricomcp/catalog.py ADDED
@@ -0,0 +1,154 @@
1
+ """Dorico's command list, enum values, property schemas and library entries (doricomcp/data/), the live
2
+ confirmation matrix, and command validation.
3
+
4
+ Depends on: nothing else in the package.
5
+ """
6
+ import json
7
+ import pathlib
8
+ import re
9
+ from functools import cache
10
+ from urllib.parse import parse_qsl
11
+
12
+ DATA = pathlib.Path(__file__).resolve().parent / "data"
13
+ CONFIRMED = DATA / "confirmed.json"
14
+
15
+
16
+ def _load(p):
17
+ return json.loads(pathlib.Path(p).read_text())
18
+
19
+
20
+ @cache
21
+ def commands() -> dict[str, dict]:
22
+ d = _load(DATA / "commands.json")
23
+ out = {c["name"]: c for c in d["commands"]}
24
+ for n in d.get("observed_but_not_listed", []):
25
+ out.setdefault(n, {"name": n, "group": n.split(".")[0], "parameters": {}, "internal": True})
26
+ for n in _load(DATA / "commands_not_in_getcommands.json"):
27
+ out.setdefault(n, {"name": n, "group": n.split(".")[0], "parameters": {}, "internal": True})
28
+ return out
29
+
30
+
31
+ @cache
32
+ def enums() -> dict[str, list]:
33
+ return _load(DATA / "enums.json")
34
+
35
+
36
+ @cache
37
+ def property_names() -> dict[str, list]:
38
+ return _load(DATA / "property_names.json")["by_family"]
39
+
40
+
41
+ @cache
42
+ def property_schemas() -> dict:
43
+ return _load(DATA / "rc_property_schemas.json")
44
+
45
+
46
+ def property_info(name: str) -> dict | None:
47
+ """Schema (valueType, enumValues, displayName) for a property name from the live RC dumps, if known."""
48
+ for et, d in property_schemas().items():
49
+ for p in d["propertieslist"]["properties"]:
50
+ if p["name"] == name:
51
+ return {**p, "seen_on": et}
52
+ fam = next((f for f, names in property_names().items() if name in names), None)
53
+ return {"name": name, "family": fam} if fam else None
54
+
55
+
56
+ @cache
57
+ def library_dump(collection: str) -> list[dict]:
58
+ p = DATA / "library" / f"library_{collection}.json"
59
+ return _load(p)[0]["libraryentities"] if p.exists() else []
60
+
61
+
62
+ def _norm(s: str) -> str:
63
+ return re.sub(r"[^a-z0-9]", "", s.lower())
64
+
65
+
66
+ def technique(value: str, lib: list[dict] | None = None) -> dict:
67
+ """A playing technique by library id ("pta.pizzText"), name or abbreviation ("pizz.", "arco", "con sord.",
68
+ "sul pont.", "ord."): {entityID, name}. lib: the score's live library (with the user's own techniques); default
69
+ the catalogued one. Raises ValueError listing candidates when unknown or ambiguous."""
70
+ lib = lib or library_dump("kPlayingTechniqueAppearanceCollection")
71
+ if any(e["entityID"] == value for e in lib):
72
+ return next(e for e in lib if e["entityID"] == value)
73
+ want = _norm(value)
74
+
75
+ def keys(e):
76
+ abbr = re.findall(r"\(([^)]*)\)", e["name"])
77
+ return {_norm(e["name"]), _norm(e["entityID"].removeprefix("pta.").removesuffix("Text")), *map(_norm, abbr)}
78
+
79
+ hits = [e for e in lib if want in keys(e)] or [e for e in lib if want and any(k.startswith(want) for k in keys(e))]
80
+ # prefer the plain text form over tab/abbreviated/instrument-specific variants
81
+ hits.sort(key=lambda e: (not e["entityID"].endswith("Text"), len(e["name"])))
82
+ if len(hits) == 1 or (hits and len(hits[0]["name"]) < len(hits[1]["name"]) and hits[0]["entityID"].endswith("Text")):
83
+ return hits[0]
84
+ names = ", ".join(f"{e['name']} ({e['entityID']})" for e in hits[:8]) or "none"
85
+ raise ValueError(f"playing technique {value!r}: {'ambiguous' if hits else 'unknown'}; candidates: {names}")
86
+
87
+
88
+ def confirmed() -> dict[str, dict]:
89
+ return {r["capability"]: r for r in _load(CONFIRMED)} if CONFIRMED.exists() else {}
90
+
91
+
92
+ def status(capability: str) -> str:
93
+ return confirmed().get(capability, {}).get("status", "unconfirmed")
94
+
95
+
96
+ def describe_commands(filter: str = "", limit: int = 60) -> list[dict]:
97
+ rx = re.compile(filter, re.IGNORECASE) if filter else None
98
+ out = []
99
+ for c in commands().values():
100
+ if rx and not (rx.search(c["name"]) or rx.search(c.get("display_name") or "")):
101
+ continue
102
+ params = {k: {kk: vv for kk, vv in v.items() if kk in ("required", "enum_values", "observed_values")}
103
+ for k, v in c.get("parameters", {}).items()}
104
+ out.append({"name": c["name"], "display_name": c.get("display_name"), "parameters": params,
105
+ **({"internal": True} if c.get("internal") else {})})
106
+ if len(out) >= limit:
107
+ break
108
+ return out
109
+
110
+
111
+ def describe_enums(name: str) -> dict:
112
+ e = enums()
113
+ if name in e:
114
+ return {name: [v[0] for v in e[name]]}
115
+ rx = re.compile(name, re.IGNORECASE)
116
+ hits = {k: [v[0] for v in vals] for k, vals in e.items() if rx.search(k)}
117
+ return dict(list(hits.items())[:20])
118
+
119
+
120
+ class CommandError(ValueError):
121
+ pass
122
+
123
+
124
+ def validate(command: str) -> str:
125
+ """Check a `Namespace.Command?Param=value&...` string against the catalogue; returns it unchanged."""
126
+ name, _, query = command.partition("?")
127
+ c = commands().get(name)
128
+ if c is None:
129
+ close = [n for n in commands() if n.lower().startswith(name.lower().split(".")[0] + ".")][:10]
130
+ raise CommandError(f"unknown command {name!r}" + (f"; same namespace: {close}" if close else ""))
131
+ params = c.get("parameters", {})
132
+ for k, v in parse_qsl(query, keep_blank_values=True):
133
+ if params and k not in params and not c.get("internal"):
134
+ raise CommandError(f"{name} has no parameter {k!r}; parameters: {sorted(params)}")
135
+ allowed = params.get(k, {}).get("enum_values")
136
+ if allowed and v not in allowed:
137
+ raise CommandError(f"{name}: {k}={v!r} not in {allowed}")
138
+ missing = [k for k, v in params.items() if v.get("required") and f"{k}=" not in query]
139
+ if missing:
140
+ raise CommandError(f"{name} requires {missing}")
141
+ return command
142
+
143
+
144
+ if __name__ == "__main__":
145
+ assert validate("NoteInput.SetArticulation?Value=kStaccato")
146
+ for bad in ("Nope.Command", "Edit.ChangeVoice?VoiceDirection=kSideways", "NoteInput.SetArticulation?Bogus=1"):
147
+ try:
148
+ validate(bad)
149
+ raise AssertionError(bad)
150
+ except CommandError:
151
+ pass
152
+ assert describe_enums("LogDuration")["LogDuration"][0] == "k256Note" or describe_enums("LogDuration")
153
+ assert property_info("kNoteVelocity")
154
+ print("catalog self-check ok", len(commands()), "commands")