py2pd 0.2.1__tar.gz → 0.2.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py2pd
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Roundtrip parsing and generation of pure-data patches from python
5
5
  Keywords: puredata,pd,audio,dsp,music,synthesis,patching
6
6
  Author: Shakeeb Alireza
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "py2pd"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "Roundtrip parsing and generation of pure-data patches from python"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "py2pd"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "Roundtrip parsing and generation of pure-data patches from python"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -1,92 +1,94 @@
1
- """
2
- py2pd - Python to PureData
3
- ==========================
4
-
5
- Write PureData patches as Python programs.
6
-
7
- Builder API example:
8
- >>> from py2pd import Patcher
9
- >>> p = Patcher('patch.pd')
10
- >>> osc = p.add('osc~ 440')
11
- >>> dac = p.add('dac~')
12
- >>> p.link(osc, dac)
13
- >>> p.link(osc, dac, inlet=1) # stereo
14
- >>> p.save()
15
-
16
- AST API example (round-trip):
17
- >>> from py2pd import parse_file, serialize
18
- >>> ast = parse_file('input.pd')
19
- >>> # Modify the AST...
20
- >>> with open('output.pd', 'w') as f:
21
- ... f.write(serialize(ast))
22
- """
23
-
24
- from .api import (
25
- COLUMN_WIDTH,
26
- DEFAULT_MARGIN,
27
- ROW_HEIGHT,
28
- SUBPATCH_CANVAS_HEIGHT,
29
- SUBPATCH_CANVAS_WIDTH,
30
- Abstraction,
31
- CycleWarning,
32
- GridLayoutManager,
33
- InvalidConnectionError,
34
- LayoutManager,
35
- NodeNotFoundError,
36
- Patcher,
37
- PdConnectionError,
38
- PdConnectionWarning,
39
- lookup_object_io,
40
- )
41
- from .ast import (
42
- ParseError,
43
- UnsupportedElementWarning,
44
- from_builder,
45
- parse,
46
- parse_file,
47
- serialize,
48
- serialize_to_file,
49
- to_builder,
50
- )
51
- from .discover import (
52
- default_search_paths,
53
- discover_externals,
54
- extract_declare_paths,
55
- )
56
-
57
- __all__ = [
58
- # Builder API
59
- "Patcher",
60
- "Abstraction",
61
- "LayoutManager",
62
- "GridLayoutManager",
63
- # Exceptions
64
- "PdConnectionError",
65
- "NodeNotFoundError",
66
- "InvalidConnectionError",
67
- "CycleWarning",
68
- "PdConnectionWarning",
69
- "UnsupportedElementWarning",
70
- "ParseError",
71
- # Object registry
72
- "lookup_object_io",
73
- # Layout constants
74
- "ROW_HEIGHT",
75
- "COLUMN_WIDTH",
76
- "DEFAULT_MARGIN",
77
- "SUBPATCH_CANVAS_WIDTH",
78
- "SUBPATCH_CANVAS_HEIGHT",
79
- # AST functions
80
- "parse",
81
- "parse_file",
82
- "serialize",
83
- "serialize_to_file",
84
- "from_builder",
85
- "to_builder",
86
- # Discovery
87
- "discover_externals",
88
- "default_search_paths",
89
- "extract_declare_paths",
90
- ]
91
-
92
- __version__ = "0.2.1"
1
+ """
2
+ py2pd - Python to PureData
3
+ ==========================
4
+
5
+ Write PureData patches as Python programs.
6
+
7
+ Builder API example:
8
+ >>> from py2pd import Patcher
9
+ >>> p = Patcher('patch.pd')
10
+ >>> osc = p.add('osc~ 440')
11
+ >>> dac = p.add('dac~')
12
+ >>> p.link(osc, dac)
13
+ >>> p.link(osc, dac, inlet=1) # stereo
14
+ >>> p.save()
15
+
16
+ AST API example (round-trip):
17
+ >>> from py2pd import parse_file, serialize
18
+ >>> ast = parse_file('input.pd')
19
+ >>> # Modify the AST...
20
+ >>> with open('output.pd', 'w') as f:
21
+ ... f.write(serialize(ast))
22
+ """
23
+
24
+ from .api import (
25
+ COLUMN_WIDTH,
26
+ DEFAULT_MARGIN,
27
+ ROW_HEIGHT,
28
+ SUBPATCH_CANVAS_HEIGHT,
29
+ SUBPATCH_CANVAS_WIDTH,
30
+ Abstraction,
31
+ CycleWarning,
32
+ GridLayoutManager,
33
+ InvalidConnectionError,
34
+ LayoutManager,
35
+ NodeNotFoundError,
36
+ Patcher,
37
+ PdConnectionError,
38
+ PdConnectionWarning,
39
+ SubpatchIOOrderWarning,
40
+ lookup_object_io,
41
+ )
42
+ from .ast import (
43
+ ParseError,
44
+ UnsupportedElementWarning,
45
+ from_builder,
46
+ parse,
47
+ parse_file,
48
+ serialize,
49
+ serialize_to_file,
50
+ to_builder,
51
+ )
52
+ from .discover import (
53
+ default_search_paths,
54
+ discover_externals,
55
+ extract_declare_paths,
56
+ )
57
+
58
+ __all__ = [
59
+ # Builder API
60
+ "Patcher",
61
+ "Abstraction",
62
+ "LayoutManager",
63
+ "GridLayoutManager",
64
+ # Exceptions
65
+ "PdConnectionError",
66
+ "NodeNotFoundError",
67
+ "InvalidConnectionError",
68
+ "CycleWarning",
69
+ "PdConnectionWarning",
70
+ "SubpatchIOOrderWarning",
71
+ "UnsupportedElementWarning",
72
+ "ParseError",
73
+ # Object registry
74
+ "lookup_object_io",
75
+ # Layout constants
76
+ "ROW_HEIGHT",
77
+ "COLUMN_WIDTH",
78
+ "DEFAULT_MARGIN",
79
+ "SUBPATCH_CANVAS_WIDTH",
80
+ "SUBPATCH_CANVAS_HEIGHT",
81
+ # AST functions
82
+ "parse",
83
+ "parse_file",
84
+ "serialize",
85
+ "serialize_to_file",
86
+ "from_builder",
87
+ "to_builder",
88
+ # Discovery
89
+ "discover_externals",
90
+ "default_search_paths",
91
+ "extract_declare_paths",
92
+ ]
93
+
94
+ __version__ = "0.2.2"
@@ -4,6 +4,10 @@ import re
4
4
  from typing import Any, Callable, Dict, FrozenSet, List, Optional, Sequence, Set, Tuple, Union
5
5
  import warnings
6
6
 
7
+ # The builder writes the same number format as the AST serializer, so it uses
8
+ # the same helper. ast imports api only lazily, so this direction does not cycle.
9
+ from .ast import _fmt_num
10
+
7
11
  # Layout constants (pixels)
8
12
  ROW_HEIGHT = 25
9
13
  COLUMN_WIDTH = 50
@@ -53,6 +57,12 @@ class CycleWarning(UserWarning):
53
57
  pass
54
58
 
55
59
 
60
+ class SubpatchIOOrderWarning(UserWarning):
61
+ """A subpatch's inlet or outlet order will not match creation order."""
62
+
63
+ pass
64
+
65
+
56
66
  class PdConnectionWarning(UserWarning):
57
67
  """Warning raised for an out-of-range connection when validation is advisory.
58
68
 
@@ -64,11 +74,65 @@ class PdConnectionWarning(UserWarning):
64
74
  pass
65
75
 
66
76
 
67
- def _fmt_coord(value: float) -> str:
68
- """Format a coordinate the way PureData writes it -- integral values as integers."""
69
- if isinstance(value, int) or float(value).is_integer():
70
- return str(int(value))
71
- return repr(float(value))
77
+ _IO_KINDS = (("inlet", ("inlet", "inlet~")), ("outlet", ("outlet", "outlet~")))
78
+
79
+
80
+ def _subpatch_io_nodes(src: "Patcher", names: Tuple[str, str]) -> List["Obj"]:
81
+ """The inlet or outlet objects of *src*, in creation order."""
82
+ return [
83
+ n
84
+ for n in src.nodes
85
+ if isinstance(n, Obj) and n.parameters["text"].split()[:1] in ([names[0]], [names[1]])
86
+ ]
87
+
88
+
89
+ def _order_subpatch_io(src: "Patcher") -> None:
90
+ """Spread a subpatch's inlet and outlet objects so PureData orders them by creation.
91
+
92
+ PureData derives a subpatch's inlet and outlet order from the x position of
93
+ the ``inlet`` / ``outlet`` objects, breaking a tie in reverse file order.
94
+ The default layout stacks every node at one x, so two inlets created in
95
+ order reach the parent reversed -- a patch that loads without complaint and
96
+ is wired backwards.
97
+
98
+ Only a tie is repaired, and only among objects the caller never positioned:
99
+ distinct positions already give an unambiguous order, and an explicit
100
+ ``x_pos`` is a decision to respect. Repositioning is by creation order, so
101
+ ``link(..., inlet=n)`` means the n-th ``inlet`` object created.
102
+ """
103
+ for _, names in _IO_KINDS:
104
+ nodes = _subpatch_io_nodes(src, names)
105
+ if len(nodes) < 2:
106
+ continue
107
+ xs = [n.parameters["x_pos"] for n in nodes]
108
+ if len(set(xs)) == len(xs):
109
+ continue # already unambiguous
110
+ if any(id(n) in src._explicit_x for n in nodes):
111
+ continue # the caller placed at least one; leave the layout alone
112
+ base = min(xs)
113
+ for i, node in enumerate(nodes):
114
+ node.parameters["x_pos"] = base + i * SUBPATCH_IO_SPACING
115
+
116
+
117
+ def _warn_on_ambiguous_subpatch_io(src: "Patcher", name: str) -> None:
118
+ """Warn when a subpatch's inlet or outlet order will not match creation order.
119
+
120
+ PureData orders a subpatch's inlets and outlets by the x position of the
121
+ ``inlet`` / ``outlet`` objects inside it, and breaks a tie in reverse file
122
+ order. The default layout stacks every node at the same x, so two inlets
123
+ created in order arrive at the parent reversed -- a patch that loads
124
+ without complaint and is wired wrong.
125
+ """
126
+ for kind, names in _IO_KINDS:
127
+ xs = [n.parameters["x_pos"] for n in _subpatch_io_nodes(src, names)]
128
+ if len(xs) > 1 and len(set(xs)) != len(xs):
129
+ warnings.warn(
130
+ f"subpatch {name!r} has {len(xs)} {kind}s sharing an x position; "
131
+ f"PureData orders them by x and will not use creation order. "
132
+ f"Give each {kind} a distinct x_pos to fix the order.",
133
+ SubpatchIOOrderWarning,
134
+ stacklevel=3,
135
+ )
72
136
 
73
137
 
74
138
  def escape(text: str) -> str:
@@ -418,7 +482,7 @@ class Float(Node):
418
482
  p = self.parameters
419
483
  return (
420
484
  f"#X floatatom {p['x_pos']} {p['y_pos']} {p['width']} "
421
- f"{p['lower_limit']} {p['upper_limit']} {p['label_pos']} "
485
+ f"{_fmt_num(p['lower_limit'])} {_fmt_num(p['upper_limit'])} {p['label_pos']} "
422
486
  f"{p['label']} {p['receive']} {p['send']};\n"
423
487
  )
424
488
 
@@ -469,6 +533,9 @@ class Comment(Node):
469
533
 
470
534
 
471
535
  # Default subpatch canvas dimensions (pixels)
536
+ # Horizontal gap applied when spreading a subpatch's inlet/outlet objects so
537
+ # PureData orders them by creation rather than by an arbitrary tie-break.
538
+ SUBPATCH_IO_SPACING = 70
472
539
  SUBPATCH_CANVAS_WIDTH = 300
473
540
  SUBPATCH_CANVAS_HEIGHT = 180
474
541
 
@@ -613,7 +680,7 @@ class Subpatch(Node):
613
680
  # separate field, and the two values after the flag are the
614
681
  # viewport margins.
615
682
  gop_flag = 2 if p["hide_name"] else 1
616
- rect = " ".join(_fmt_coord(v) for v in p.get("gop_rect", (0, 1, 1, 0)))
683
+ rect = " ".join(_fmt_num(v) for v in p.get("gop_rect", (0, 1, 1, 0)))
617
684
  margins = p.get("gop_margins", (0, 0))
618
685
  tail = "" if margins is None else f" {margins[0]} {margins[1]}"
619
686
  coords_line = f"#X coords {rect} {p['gop_width']} {p['gop_height']} {gop_flag}{tail};\n"
@@ -995,7 +1062,7 @@ class Symbol(Node):
995
1062
  p = self.parameters
996
1063
  return (
997
1064
  f"#X symbolatom {p['x_pos']} {p['y_pos']} {p['width']} "
998
- f"{p['lower_limit']} {p['upper_limit']} {p['label_pos']} "
1065
+ f"{_fmt_num(p['lower_limit'])} {_fmt_num(p['upper_limit'])} {p['label_pos']} "
999
1066
  f"{p['label']} {p['receive']} {p['send']};\n"
1000
1067
  )
1001
1068
 
@@ -1073,11 +1140,11 @@ class NumberBox(Node):
1073
1140
  p = self.parameters
1074
1141
  return (
1075
1142
  f"#X obj {p['x_pos']} {p['y_pos']} nbx {p['width']} {p['height']} "
1076
- f"{p['min_val']} {p['max_val']} {p['log_flag']} {p['init']} "
1143
+ f"{_fmt_num(p['min_val'])} {_fmt_num(p['max_val'])} {p['log_flag']} {p['init']} "
1077
1144
  f"{p['send']} {p['receive']} {p['label']} "
1078
1145
  f"{p['label_x']} {p['label_y']} {p['font']} {p['font_size']} "
1079
1146
  f"{p['bg_color']} {p['fg_color']} {p['label_color']} "
1080
- f"{p['init_value']} {p['log_height']};\n"
1147
+ f"{_fmt_num(p['init_value'])} {p['log_height']};\n"
1081
1148
  )
1082
1149
 
1083
1150
  @property
@@ -1151,11 +1218,11 @@ class VSlider(Node):
1151
1218
  p = self.parameters
1152
1219
  return (
1153
1220
  f"#X obj {p['x_pos']} {p['y_pos']} vsl {p['width']} {p['height']} "
1154
- f"{p['min_val']} {p['max_val']} {p['log_flag']} {p['init']} "
1221
+ f"{_fmt_num(p['min_val'])} {_fmt_num(p['max_val'])} {p['log_flag']} {p['init']} "
1155
1222
  f"{p['send']} {p['receive']} {p['label']} "
1156
1223
  f"{p['label_x']} {p['label_y']} {p['font']} {p['font_size']} "
1157
1224
  f"{p['bg_color']} {p['fg_color']} {p['label_color']} "
1158
- f"{p['init_value']} {p['steady']};\n"
1225
+ f"{_fmt_num(p['init_value'])} {p['steady']};\n"
1159
1226
  )
1160
1227
 
1161
1228
  @property
@@ -1226,11 +1293,11 @@ class HSlider(Node):
1226
1293
  p = self.parameters
1227
1294
  return (
1228
1295
  f"#X obj {p['x_pos']} {p['y_pos']} hsl {p['width']} {p['height']} "
1229
- f"{p['min_val']} {p['max_val']} {p['log_flag']} {p['init']} "
1296
+ f"{_fmt_num(p['min_val'])} {_fmt_num(p['max_val'])} {p['log_flag']} {p['init']} "
1230
1297
  f"{p['send']} {p['receive']} {p['label']} "
1231
1298
  f"{p['label_x']} {p['label_y']} {p['font']} {p['font_size']} "
1232
1299
  f"{p['bg_color']} {p['fg_color']} {p['label_color']} "
1233
- f"{p['init_value']} {p['steady']};\n"
1300
+ f"{_fmt_num(p['init_value'])} {p['steady']};\n"
1234
1301
  )
1235
1302
 
1236
1303
  @property
@@ -2213,6 +2280,9 @@ class Patcher:
2213
2280
  self.canvas_height = canvas_height
2214
2281
  self.font_size = font_size
2215
2282
  self._node_positions: Dict[int, int] = {}
2283
+ # Nodes whose x the caller set. Subpatch I/O ordering may reposition
2284
+ # the others; it must never move one the caller placed.
2285
+ self._explicit_x: Set[int] = set()
2216
2286
 
2217
2287
  @property
2218
2288
  def row_head(self) -> Optional[Node]:
@@ -2258,10 +2328,13 @@ class Patcher:
2258
2328
  ) -> Tuple[int, int, Callable[[Node], None]]:
2259
2329
  """Resolve position for a new element."""
2260
2330
  was_absolute = x_pos >= 0 and y_pos >= 0
2331
+ x_was_given = x_pos >= 0
2261
2332
  computed_x, computed_y = self.layout.compute_position(new_row, new_col, x_pos, y_pos)
2262
2333
 
2263
2334
  def position_update(node: Node) -> None:
2264
2335
  self.layout.register_node(node, new_row, new_col, was_absolute)
2336
+ if x_was_given:
2337
+ self._explicit_x.add(id(node))
2265
2338
 
2266
2339
  return (computed_x, computed_y, position_update)
2267
2340
 
@@ -2576,6 +2649,9 @@ class Patcher:
2576
2649
  and n.parameters["text"].split()[:1] in (["outlet"], ["outlet~"])
2577
2650
  )
2578
2651
 
2652
+ _order_subpatch_io(src)
2653
+ _warn_on_ambiguous_subpatch_io(src, name)
2654
+
2579
2655
  x_pos, y_pos, pos_update = self._resolve_position(x_pos, y_pos, new_row, new_col)
2580
2656
  node = Subpatch(
2581
2657
  x_pos,
File without changes
File without changes
File without changes
File without changes
File without changes