py2tosc 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.
py2tosc/__init__.py ADDED
@@ -0,0 +1,110 @@
1
+ """Generate and edit TouchOSC layouts from Python.
2
+
3
+ ```python
4
+ import py2tosc
5
+
6
+ doc = py2tosc.load("layout.tosc")
7
+ for fader in doc.find_all(type="FADER"):
8
+ fader.color = "#e76f51"
9
+ doc.save("out.tosc")
10
+ ```
11
+
12
+ This project has no relation to Hexler, the developer of TouchOSC. Back up your
13
+ layouts before editing them with third party tools.
14
+ """
15
+
16
+ from . import layout
17
+ from .control import (
18
+ Control,
19
+ box,
20
+ button,
21
+ encoder,
22
+ fader,
23
+ grid,
24
+ group,
25
+ label,
26
+ pager,
27
+ radar,
28
+ radial,
29
+ radio,
30
+ text,
31
+ xy,
32
+ )
33
+ from .document import Document, dumps, load, loads, save
34
+ from .enums import (
35
+ ControlType,
36
+ Conversion,
37
+ MidiType,
38
+ PartialType,
39
+ PropertyType,
40
+ TriggerCondition,
41
+ )
42
+ from .messages import (
43
+ GamepadMessage,
44
+ LocalMessage,
45
+ Message,
46
+ MidiCommand,
47
+ MidiMessage,
48
+ MidiValue,
49
+ OscMessage,
50
+ Partial,
51
+ Trigger,
52
+ Value,
53
+ )
54
+ from .properties import Color, Frame, Property, to_color, to_frame
55
+ from .validate import Issue, ValidationError, validate
56
+
57
+ #: Keep in step with `version` in pyproject.toml, which the build backend reads
58
+ #: and which `uv_build` requires to be static. `test_version_is_declared_once`
59
+ #: fails if the two drift apart.
60
+ __version__ = "0.1.0"
61
+
62
+ # Sorted rather than grouped by topic, because RUF022 asks for it and the
63
+ # grouping lives in the API reference instead. See docs/api/.
64
+ __all__ = [
65
+ "Color",
66
+ "Control",
67
+ "ControlType",
68
+ "Conversion",
69
+ "Document",
70
+ "Frame",
71
+ "GamepadMessage",
72
+ "Issue",
73
+ "LocalMessage",
74
+ "Message",
75
+ "MidiCommand",
76
+ "MidiMessage",
77
+ "MidiType",
78
+ "MidiValue",
79
+ "OscMessage",
80
+ "Partial",
81
+ "PartialType",
82
+ "Property",
83
+ "PropertyType",
84
+ "Trigger",
85
+ "TriggerCondition",
86
+ "ValidationError",
87
+ "Value",
88
+ "__version__",
89
+ "box",
90
+ "button",
91
+ "dumps",
92
+ "encoder",
93
+ "fader",
94
+ "grid",
95
+ "group",
96
+ "label",
97
+ "layout",
98
+ "load",
99
+ "loads",
100
+ "pager",
101
+ "radar",
102
+ "radial",
103
+ "radio",
104
+ "save",
105
+ "text",
106
+ "to_color",
107
+ "to_frame",
108
+ "validate",
109
+ "xy",
110
+ ]
py2tosc/codec.py ADDED
@@ -0,0 +1,512 @@
1
+ """Reading and writing the `.tosc` XML dialect.
2
+
3
+ Serialization is hand-rolled rather than delegated to `ElementTree` for one
4
+ reason: TouchOSC wraps keys and string values in CDATA sections, and
5
+ `ElementTree` cannot emit them. Writing the format directly also fixes element
6
+ order and the handling of the `<includes>` element, which together make output
7
+ byte-for-byte identical to what the TouchOSC editor itself produces.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import xml.etree.ElementTree as ET
13
+ from typing import Any
14
+
15
+ from .control import Control
16
+ from .enums import ControlType, PropertyType
17
+ from .messages import (
18
+ GamepadMessage,
19
+ LocalMessage,
20
+ Message,
21
+ MidiCommand,
22
+ MidiMessage,
23
+ MidiValue,
24
+ OscMessage,
25
+ Partial,
26
+ Trigger,
27
+ Value,
28
+ )
29
+ from .properties import Color, Frame, Property
30
+
31
+ __all__ = ["from_xml", "to_xml"]
32
+
33
+ _DECLARATION = "<?xml version='1.0' encoding='UTF-8'?>"
34
+
35
+
36
+ def _num(value: Any) -> str:
37
+ """Render a number the way TouchOSC does: no trailing `.0`."""
38
+ if isinstance(value, bool):
39
+ return "1" if value else "0"
40
+ if isinstance(value, float) and value.is_integer():
41
+ return str(int(value))
42
+ return str(value)
43
+
44
+
45
+ def _escape(text: str) -> str:
46
+ return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
47
+
48
+
49
+ class _Writer:
50
+ """Collects the document as a list of lines, one element or tag per line."""
51
+
52
+ def __init__(self) -> None:
53
+ self.lines: list[str] = []
54
+
55
+ def open(self, tag: str, **attrs: str) -> None:
56
+ rendered = "".join(f" {k}='{_escape(v)}'" for k, v in attrs.items())
57
+ self.lines.append(f"<{tag}{rendered}>")
58
+
59
+ def close(self, tag: str) -> None:
60
+ self.lines.append(f"</{tag}>")
61
+
62
+ def leaf(self, tag: str, text: Any, cdata: bool = False) -> None:
63
+ body = f"<![CDATA[{text}]]>" if cdata else _escape(str(text))
64
+ self.lines.append(f"<{tag}>{body}</{tag}>")
65
+
66
+ def empty(self, tag: str) -> None:
67
+ self.open(tag)
68
+ self.close(tag)
69
+
70
+ def render(self, pretty: bool) -> str:
71
+ if pretty:
72
+ return "\n".join(self.lines) + "\n"
73
+ return "".join(self.lines)
74
+
75
+
76
+ # -- writing ----------------------------------------------------------------
77
+
78
+
79
+ def _write_property(w: _Writer, prop: Property) -> None:
80
+ w.open("property", type=prop.type.value)
81
+ w.leaf("key", prop.key, cdata=True)
82
+ value = prop.value
83
+ if prop.type is PropertyType.FRAME:
84
+ w.open("value")
85
+ for field, item in zip(("x", "y", "w", "h"), value):
86
+ w.leaf(field, _num(item))
87
+ w.close("value")
88
+ elif prop.type is PropertyType.COLOR:
89
+ w.open("value")
90
+ for field, item in zip(("r", "g", "b", "a"), value):
91
+ w.leaf(field, _num(item))
92
+ w.close("value")
93
+ elif prop.type is PropertyType.STRING:
94
+ w.leaf("value", value, cdata=True)
95
+ else:
96
+ w.leaf("value", _num(value))
97
+ w.close("property")
98
+
99
+
100
+ def _default_text(default: Any) -> str:
101
+ """Render a value's default. Booleans are words here, not 1 and 0."""
102
+ if isinstance(default, bool):
103
+ return "true" if default else "false"
104
+ if isinstance(default, str):
105
+ return default
106
+ return _num(default)
107
+
108
+
109
+ def _write_value(w: _Writer, value: Value) -> None:
110
+ w.open("value")
111
+ w.leaf("key", value.key, cdata=True)
112
+ w.leaf("locked", _num(value.locked))
113
+ w.leaf("lockedDefaultCurrent", _num(value.locked_default_current))
114
+ w.leaf("default", _default_text(value.default), cdata=True)
115
+ w.leaf("defaultPull", _num(value.default_pull))
116
+ w.close("value")
117
+
118
+
119
+ def _write_triggers(w: _Writer, triggers: list[Trigger]) -> None:
120
+ # Omitted entirely when empty: across 4817 trigger-less messages in the
121
+ # bundled TouchOSC examples, the editor never writes <triggers></triggers>.
122
+ if not triggers:
123
+ return
124
+ w.open("triggers")
125
+ for trigger in triggers:
126
+ w.open("trigger")
127
+ w.leaf("var", trigger.var, cdata=True)
128
+ w.leaf("condition", str(trigger.condition))
129
+ w.close("trigger")
130
+ w.close("triggers")
131
+
132
+
133
+ def _write_partials(w: _Writer, tag: str, partials: list[Partial]) -> None:
134
+ w.open(tag)
135
+ for partial in partials:
136
+ w.open("partial")
137
+ w.leaf("type", str(partial.type))
138
+ w.leaf("conversion", str(partial.conversion))
139
+ w.leaf("value", partial.value, cdata=True)
140
+ w.leaf("scaleMin", _num(partial.scale_min))
141
+ w.leaf("scaleMax", _num(partial.scale_max))
142
+ w.close("partial")
143
+ w.close(tag)
144
+
145
+
146
+ def _write_message(w: _Writer, message: Message, modern: bool) -> None:
147
+ if isinstance(message, OscMessage):
148
+ w.open("osc")
149
+ w.leaf("enabled", _num(message.enabled))
150
+ w.leaf("send", _num(message.send))
151
+ w.leaf("receive", _num(message.receive))
152
+ w.leaf("feedback", _num(message.feedback))
153
+ if modern:
154
+ w.leaf("noDuplicates", _num(message.no_duplicates))
155
+ w.leaf("connections", message.connections)
156
+ _write_triggers(w, message.triggers)
157
+ _write_partials(w, "path", message.path)
158
+ _write_partials(w, "arguments", message.arguments)
159
+ w.close("osc")
160
+
161
+ elif isinstance(message, MidiMessage):
162
+ w.open("midi")
163
+ w.leaf("enabled", _num(message.enabled))
164
+ w.leaf("send", _num(message.send))
165
+ w.leaf("receive", _num(message.receive))
166
+ w.leaf("feedback", _num(message.feedback))
167
+ if modern:
168
+ w.leaf("noDuplicates", _num(message.no_duplicates))
169
+ w.leaf("connections", message.connections)
170
+ _write_triggers(w, message.triggers)
171
+ w.open("message")
172
+ w.leaf("type", str(message.message.type))
173
+ w.leaf("channel", _num(message.message.channel))
174
+ w.leaf("data1", _num(message.message.data1))
175
+ w.leaf("data2", _num(message.message.data2))
176
+ w.close("message")
177
+ w.open("values")
178
+ for item in message.values:
179
+ w.open("value")
180
+ w.leaf("type", str(item.type))
181
+ w.leaf("key", item.key, cdata=True)
182
+ w.leaf("scaleMin", _num(item.scale_min))
183
+ w.leaf("scaleMax", _num(item.scale_max))
184
+ w.close("value")
185
+ w.close("values")
186
+ w.close("midi")
187
+
188
+ elif isinstance(message, LocalMessage):
189
+ w.open("local")
190
+ w.leaf("enabled", _num(message.enabled))
191
+ _write_triggers(w, message.triggers)
192
+ w.leaf("type", str(message.type))
193
+ w.leaf("conversion", str(message.conversion))
194
+ w.leaf("value", message.value, cdata=True)
195
+ w.leaf("scaleMin", _num(message.scale_min))
196
+ w.leaf("scaleMax", _num(message.scale_max))
197
+ w.leaf("dstType", message.dst_type)
198
+ w.leaf("dstVar", message.dst_var, cdata=True)
199
+ w.leaf("dstID", message.dst_id, cdata=True)
200
+ w.close("local")
201
+
202
+ elif isinstance(message, GamepadMessage):
203
+ w.open("gamepad")
204
+ w.leaf("enabled", _num(message.enabled))
205
+ w.leaf("connections", message.connections)
206
+ w.leaf("type", str(message.type))
207
+ w.leaf("conversion", str(message.conversion))
208
+ w.leaf("scaleMin", _num(message.scale_min))
209
+ w.leaf("scaleMax", _num(message.scale_max))
210
+ w.leaf("targetType", str(message.target_type))
211
+ w.leaf("targetVar", message.target_var, cdata=True)
212
+ w.close("gamepad")
213
+
214
+ else:
215
+ raise TypeError(f"{type(message).__name__} is not a message type")
216
+
217
+
218
+ def _is_modern(version: str) -> bool:
219
+ """Whether the document uses lexml 6 conventions.
220
+
221
+ Two elements distinguish version 6 from the version 3 files older editors
222
+ and older tosclib releases produced: `<includes>` on the root node, and
223
+ `<noDuplicates>` on OSC and MIDI bindings. Writing either into a version 3
224
+ document would make it something the editor never wrote, so both are gated
225
+ on the version the document declares.
226
+
227
+ An unrecognised version is treated as modern, on the grounds that anything
228
+ newer than 6 will keep them.
229
+ """
230
+ try:
231
+ return int(version) >= 6
232
+ except ValueError:
233
+ return True
234
+
235
+
236
+ def _write_control(w: _Writer, control: Control, is_root: bool, includes: bool) -> None:
237
+ w.open("node", ID=control.id, type=control.control_type.value)
238
+
239
+ if (is_root and includes) or getattr(control, "_has_includes", False):
240
+ w.empty("includes")
241
+
242
+ w.open("properties")
243
+ for key in sorted(control.properties):
244
+ _write_property(w, control.properties[key])
245
+ w.close("properties")
246
+
247
+ if control.values:
248
+ w.open("values")
249
+ for value in control.values:
250
+ _write_value(w, value)
251
+ w.close("values")
252
+
253
+ if control.messages:
254
+ w.open("messages")
255
+ for message in control.messages:
256
+ _write_message(w, message, modern=includes)
257
+ w.close("messages")
258
+
259
+ if control.children:
260
+ w.open("children")
261
+ for child in control.children:
262
+ _write_control(w, child, is_root=False, includes=includes)
263
+ w.close("children")
264
+
265
+ w.close("node")
266
+
267
+
268
+ def to_xml(root: Control, version: str = "6", pretty: bool = False) -> str:
269
+ """Serialize a control tree to the `.tosc` XML dialect.
270
+
271
+ Args:
272
+ root: The root control of the layout.
273
+ version: The `lexml` format version to declare. Versions below 6 are
274
+ written without the `<includes>` element, which did not exist yet.
275
+ pretty: Emit one element per line, matching the editor's XML export.
276
+ The default single-line form matches what the editor saves inside a
277
+ `.tosc`.
278
+
279
+ Returns:
280
+ The complete XML document, including its declaration.
281
+ """
282
+ w = _Writer()
283
+ w.lines.append(_DECLARATION)
284
+ w.open("lexml", version=version)
285
+ _write_control(w, root, is_root=True, includes=_is_modern(version))
286
+ w.close("lexml")
287
+ return w.render(pretty)
288
+
289
+
290
+ # -- reading ----------------------------------------------------------------
291
+
292
+
293
+ def _text(element: ET.Element | None, default: str = "") -> str:
294
+ if element is None or element.text is None:
295
+ return default
296
+ return element.text
297
+
298
+
299
+ def _flag(element: ET.Element | None, default: bool = False) -> bool:
300
+ raw = _text(element)
301
+ return default if raw == "" else raw not in ("0", "false")
302
+
303
+
304
+ def _number(element: ET.Element | None, default: float = 0) -> float:
305
+ raw = _text(element)
306
+ try:
307
+ return float(raw)
308
+ except ValueError:
309
+ return default
310
+
311
+
312
+ def _read_property(element: ET.Element) -> Property:
313
+ key = _text(element.find("key"))
314
+ kind = PropertyType(element.get("type", "s"))
315
+ value_element = element.find("value")
316
+
317
+ def part(name: str) -> ET.Element | None:
318
+ """A composite value's child, tolerating a property with no <value>."""
319
+ return None if value_element is None else value_element.find(name)
320
+
321
+ raw: Any
322
+ match kind:
323
+ case PropertyType.FRAME:
324
+ raw = Frame(*(_number(part(f)) for f in "xywh"))
325
+ case PropertyType.COLOR:
326
+ raw = Color(*(_number(part(f)) for f in "rgba"))
327
+ case PropertyType.BOOLEAN:
328
+ raw = _flag(value_element)
329
+ case PropertyType.INTEGER:
330
+ raw = int(_number(value_element))
331
+ case PropertyType.FLOAT:
332
+ raw = _number(value_element)
333
+ case _:
334
+ raw = _text(value_element)
335
+
336
+ return Property(key, raw, kind)
337
+
338
+
339
+ def _read_default(key: str, raw: str) -> bool | float | str:
340
+ if key == "text":
341
+ return raw
342
+ if raw in ("true", "false"):
343
+ return raw == "true"
344
+ try:
345
+ return float(raw)
346
+ except ValueError:
347
+ return raw
348
+
349
+
350
+ def _read_value(element: ET.Element) -> Value:
351
+ key = _text(element.find("key"))
352
+ return Value(
353
+ key=key,
354
+ locked=_flag(element.find("locked")),
355
+ locked_default_current=_flag(element.find("lockedDefaultCurrent")),
356
+ default=_read_default(key, _text(element.find("default"))),
357
+ default_pull=int(_number(element.find("defaultPull"))),
358
+ )
359
+
360
+
361
+ def _read_triggers(element: ET.Element | None) -> list[Trigger]:
362
+ if element is None:
363
+ return []
364
+ return [
365
+ Trigger(var=_text(t.find("var")), condition=_text(t.find("condition"), "ANY"))
366
+ for t in element.findall("trigger")
367
+ ]
368
+
369
+
370
+ def _read_partials(element: ET.Element | None) -> list[Partial]:
371
+ if element is None:
372
+ return []
373
+ return [
374
+ Partial(
375
+ type=_text(p.find("type"), "CONSTANT"),
376
+ conversion=_text(p.find("conversion"), "STRING"),
377
+ value=_text(p.find("value")),
378
+ scale_min=_number(p.find("scaleMin")),
379
+ scale_max=_number(p.find("scaleMax"), 1),
380
+ )
381
+ for p in element.findall("partial")
382
+ ]
383
+
384
+
385
+ def _read_message(element: ET.Element) -> Message:
386
+ if element.tag == "osc":
387
+ return OscMessage(
388
+ enabled=_flag(element.find("enabled"), True),
389
+ send=_flag(element.find("send"), True),
390
+ receive=_flag(element.find("receive"), True),
391
+ feedback=_flag(element.find("feedback")),
392
+ no_duplicates=_flag(element.find("noDuplicates")),
393
+ connections=_text(element.find("connections")),
394
+ triggers=_read_triggers(element.find("triggers")),
395
+ path=_read_partials(element.find("path")),
396
+ arguments=_read_partials(element.find("arguments")),
397
+ )
398
+
399
+ if element.tag == "midi":
400
+ command = element.find("message")
401
+ values = element.find("values")
402
+ return MidiMessage(
403
+ enabled=_flag(element.find("enabled"), True),
404
+ send=_flag(element.find("send"), True),
405
+ receive=_flag(element.find("receive"), True),
406
+ feedback=_flag(element.find("feedback")),
407
+ no_duplicates=_flag(element.find("noDuplicates")),
408
+ connections=_text(element.find("connections")),
409
+ triggers=_read_triggers(element.find("triggers")),
410
+ message=MidiCommand(
411
+ type=_text(command.find("type"), "CONTROLCHANGE")
412
+ if command is not None
413
+ else "CONTROLCHANGE",
414
+ channel=int(_number(command.find("channel")))
415
+ if command is not None
416
+ else 0,
417
+ data1=int(_number(command.find("data1"))) if command is not None else 0,
418
+ data2=int(_number(command.find("data2"))) if command is not None else 0,
419
+ ),
420
+ values=[
421
+ MidiValue(
422
+ type=_text(v.find("type"), "CONSTANT"),
423
+ key=_text(v.find("key")),
424
+ scale_min=_number(v.find("scaleMin")),
425
+ scale_max=_number(v.find("scaleMax")),
426
+ )
427
+ for v in (values.findall("value") if values is not None else [])
428
+ ],
429
+ )
430
+
431
+ if element.tag == "gamepad":
432
+ return GamepadMessage(
433
+ enabled=_flag(element.find("enabled"), True),
434
+ connections=_text(element.find("connections")),
435
+ type=_text(element.find("type"), "BUTTON_A"),
436
+ conversion=_text(element.find("conversion"), "FLOAT"),
437
+ scale_min=_number(element.find("scaleMin")),
438
+ scale_max=_number(element.find("scaleMax"), 1),
439
+ target_type=_text(element.find("targetType"), "VALUE"),
440
+ target_var=_text(element.find("targetVar")),
441
+ )
442
+
443
+ if element.tag == "local":
444
+ return LocalMessage(
445
+ enabled=_flag(element.find("enabled"), True),
446
+ triggers=_read_triggers(element.find("triggers")),
447
+ type=_text(element.find("type"), "VALUE"),
448
+ conversion=_text(element.find("conversion"), "FLOAT"),
449
+ value=_text(element.find("value")),
450
+ scale_min=_number(element.find("scaleMin")),
451
+ scale_max=_number(element.find("scaleMax"), 1),
452
+ dst_type=_text(element.find("dstType")),
453
+ dst_var=_text(element.find("dstVar")),
454
+ dst_id=_text(element.find("dstID")),
455
+ )
456
+
457
+ raise ValueError(f"<{element.tag}> is not a known message type")
458
+
459
+
460
+ def _read_control(element: ET.Element) -> Control:
461
+ control = Control(
462
+ ControlType(element.get("type", "GROUP")),
463
+ id=element.get("ID"),
464
+ properties={},
465
+ values=[],
466
+ messages=[],
467
+ children=[],
468
+ )
469
+ # Replace the type defaults wholesale: the file is the source of truth.
470
+ control.properties.clear()
471
+
472
+ for child in element.findall("./properties/property"):
473
+ prop = _read_property(child)
474
+ control.properties[prop.key] = prop
475
+
476
+ control.values.extend(_read_value(v) for v in element.findall("./values/value"))
477
+
478
+ messages = element.find("messages")
479
+ if messages is not None:
480
+ control.messages.extend(_read_message(m) for m in messages)
481
+
482
+ control.children.extend(
483
+ _read_control(node) for node in element.findall("./children/node")
484
+ )
485
+
486
+ if element.find("includes") is not None:
487
+ object.__setattr__(control, "_has_includes", True)
488
+
489
+ return control
490
+
491
+
492
+ def from_xml(source: str | bytes) -> tuple[Control, str]:
493
+ """Parse the `.tosc` XML dialect into a control tree.
494
+
495
+ Args:
496
+ source: The XML document, as text or bytes.
497
+
498
+ Returns:
499
+ The root control and the `lexml` version it declared.
500
+
501
+ Raises:
502
+ ValueError: If the document is not a `lexml` root holding one node.
503
+ """
504
+ root = ET.fromstring(source)
505
+ if root.tag != "lexml":
506
+ raise ValueError(f"expected a <lexml> root, found <{root.tag}>")
507
+
508
+ node = root.find("node")
509
+ if node is None:
510
+ raise ValueError("<lexml> holds no <node>")
511
+
512
+ return _read_control(node), root.get("version", "6")