py2pd 0.2.0__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.0
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.0"
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.0"
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.0"
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"
@@ -1,8 +1,13 @@
1
1
  from collections import deque
2
+ from pathlib import Path
2
3
  import re
3
4
  from typing import Any, Callable, Dict, FrozenSet, List, Optional, Sequence, Set, Tuple, Union
4
5
  import warnings
5
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
+
6
11
  # Layout constants (pixels)
7
12
  ROW_HEIGHT = 25
8
13
  COLUMN_WIDTH = 50
@@ -52,6 +57,12 @@ class CycleWarning(UserWarning):
52
57
  pass
53
58
 
54
59
 
60
+ class SubpatchIOOrderWarning(UserWarning):
61
+ """A subpatch's inlet or outlet order will not match creation order."""
62
+
63
+ pass
64
+
65
+
55
66
  class PdConnectionWarning(UserWarning):
56
67
  """Warning raised for an out-of-range connection when validation is advisory.
57
68
 
@@ -63,18 +74,74 @@ class PdConnectionWarning(UserWarning):
63
74
  pass
64
75
 
65
76
 
66
- def _fmt_coord(value: float) -> str:
67
- """Format a coordinate the way PureData writes it -- integral values as integers."""
68
- if isinstance(value, int) or float(value).is_integer():
69
- return str(int(value))
70
- 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
+ )
71
136
 
72
137
 
73
138
  def escape(text: str) -> str:
74
139
  """Escape special characters for PureData format."""
75
140
  save = re.sub(r"\\", r"\\\\", text)
76
- save = re.sub(r";", r" \; ", save)
77
- save = re.sub(r",", r" \, ", save)
141
+ # Absorb whitespace already flanking the separator: PureData puts one space
142
+ # between atoms, and "0, 1 10" would otherwise gain a second one.
143
+ save = re.sub(r"[ \t]*;[ \t]*", r" \; ", save)
144
+ save = re.sub(r"[ \t]*,[ \t]*", r" \, ", save)
78
145
  save = re.sub(r"\$(?=[0-9])", r"\$", save)
79
146
  return save
80
147
 
@@ -310,7 +377,8 @@ class Msg(Node):
310
377
  text : str
311
378
  Message content. Will be escaped for the PureData file format.
312
379
  num_inlets : int, optional
313
- Number of inlets (default: 2 -- hot inlet + cold inlet for setting contents)
380
+ Number of inlets (default: 1). A message box has one inlet; contents
381
+ are replaced by sending ``set ...`` to it, not through a second inlet.
314
382
  num_outlets : int, optional
315
383
  Number of outlets (default: 1)
316
384
  escaped : bool, optional
@@ -323,7 +391,7 @@ class Msg(Node):
323
391
  x_pos: int,
324
392
  y_pos: int,
325
393
  text: str,
326
- num_inlets: Optional[int] = 2,
394
+ num_inlets: Optional[int] = 1,
327
395
  num_outlets: Optional[int] = 1,
328
396
  *,
329
397
  escaped: bool = False,
@@ -368,10 +436,12 @@ class Float(Node):
368
436
  Y position in patch coordinates
369
437
  width : int
370
438
  Display width in characters (default: 5)
371
- upper_limit : float
372
- Maximum value; 0 means no limit (default: 0)
373
439
  lower_limit : float
374
440
  Minimum value; 0 means no limit (default: 0)
441
+ upper_limit : float
442
+ Maximum value; 0 means no limit (default: 0)
443
+ label_pos : int
444
+ Label placement: 0=left, 1=right, 2=top, 3=bottom (default: 0)
375
445
  label : str
376
446
  Label text (default: ``'-'`` for none)
377
447
  receive : str
@@ -385,8 +455,9 @@ class Float(Node):
385
455
  x_pos: int,
386
456
  y_pos: int,
387
457
  width: int = 5,
388
- upper_limit: float = 0,
389
458
  lower_limit: float = 0,
459
+ upper_limit: float = 0,
460
+ label_pos: int = 0,
390
461
  label: str = "-",
391
462
  receive: str = "-",
392
463
  send: str = "-",
@@ -397,8 +468,9 @@ class Float(Node):
397
468
  "x_pos": x_pos,
398
469
  "y_pos": y_pos,
399
470
  "width": width,
400
- "upper_limit": upper_limit,
401
471
  "lower_limit": lower_limit,
472
+ "upper_limit": upper_limit,
473
+ "label_pos": label_pos,
402
474
  "label": label,
403
475
  "receive": receive,
404
476
  "send": send,
@@ -410,8 +482,8 @@ class Float(Node):
410
482
  p = self.parameters
411
483
  return (
412
484
  f"#X floatatom {p['x_pos']} {p['y_pos']} {p['width']} "
413
- f"{p['upper_limit']} {p['lower_limit']} {p['label']} "
414
- f"{p['receive']} {p['send']};\n"
485
+ f"{_fmt_num(p['lower_limit'])} {_fmt_num(p['upper_limit'])} {p['label_pos']} "
486
+ f"{p['label']} {p['receive']} {p['send']};\n"
415
487
  )
416
488
 
417
489
  @property
@@ -461,6 +533,9 @@ class Comment(Node):
461
533
 
462
534
 
463
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
464
539
  SUBPATCH_CANVAS_WIDTH = 300
465
540
  SUBPATCH_CANVAS_HEIGHT = 180
466
541
 
@@ -605,7 +680,7 @@ class Subpatch(Node):
605
680
  # separate field, and the two values after the flag are the
606
681
  # viewport margins.
607
682
  gop_flag = 2 if p["hide_name"] else 1
608
- 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)))
609
684
  margins = p.get("gop_margins", (0, 0))
610
685
  tail = "" if margins is None else f" {margins[0]} {margins[1]}"
611
686
  coords_line = f"#X coords {rect} {p['gop_width']} {p['gop_height']} {gop_flag}{tail};\n"
@@ -987,7 +1062,7 @@ class Symbol(Node):
987
1062
  p = self.parameters
988
1063
  return (
989
1064
  f"#X symbolatom {p['x_pos']} {p['y_pos']} {p['width']} "
990
- 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']} "
991
1066
  f"{p['label']} {p['receive']} {p['send']};\n"
992
1067
  )
993
1068
 
@@ -1065,11 +1140,11 @@ class NumberBox(Node):
1065
1140
  p = self.parameters
1066
1141
  return (
1067
1142
  f"#X obj {p['x_pos']} {p['y_pos']} nbx {p['width']} {p['height']} "
1068
- 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']} "
1069
1144
  f"{p['send']} {p['receive']} {p['label']} "
1070
1145
  f"{p['label_x']} {p['label_y']} {p['font']} {p['font_size']} "
1071
1146
  f"{p['bg_color']} {p['fg_color']} {p['label_color']} "
1072
- f"{p['init_value']} {p['log_height']};\n"
1147
+ f"{_fmt_num(p['init_value'])} {p['log_height']};\n"
1073
1148
  )
1074
1149
 
1075
1150
  @property
@@ -1143,11 +1218,11 @@ class VSlider(Node):
1143
1218
  p = self.parameters
1144
1219
  return (
1145
1220
  f"#X obj {p['x_pos']} {p['y_pos']} vsl {p['width']} {p['height']} "
1146
- 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']} "
1147
1222
  f"{p['send']} {p['receive']} {p['label']} "
1148
1223
  f"{p['label_x']} {p['label_y']} {p['font']} {p['font_size']} "
1149
1224
  f"{p['bg_color']} {p['fg_color']} {p['label_color']} "
1150
- f"{p['init_value']} {p['steady']};\n"
1225
+ f"{_fmt_num(p['init_value'])} {p['steady']};\n"
1151
1226
  )
1152
1227
 
1153
1228
  @property
@@ -1218,11 +1293,11 @@ class HSlider(Node):
1218
1293
  p = self.parameters
1219
1294
  return (
1220
1295
  f"#X obj {p['x_pos']} {p['y_pos']} hsl {p['width']} {p['height']} "
1221
- 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']} "
1222
1297
  f"{p['send']} {p['receive']} {p['label']} "
1223
1298
  f"{p['label_x']} {p['label_y']} {p['font']} {p['font_size']} "
1224
1299
  f"{p['bg_color']} {p['fg_color']} {p['label_color']} "
1225
- f"{p['init_value']} {p['steady']};\n"
1300
+ f"{_fmt_num(p['init_value'])} {p['steady']};\n"
1226
1301
  )
1227
1302
 
1228
1303
  @property
@@ -2143,7 +2218,7 @@ class Patcher:
2143
2218
 
2144
2219
  Attributes
2145
2220
  ----------
2146
- filename : str or None
2221
+ filename : Path or None
2147
2222
  Default filename for save()
2148
2223
  nodes : list of Node
2149
2224
  All nodes in the patch
@@ -2153,7 +2228,7 @@ class Patcher:
2153
2228
  Manages automatic element positioning
2154
2229
  """
2155
2230
 
2156
- filename: Optional[str]
2231
+ filename: Optional[Path]
2157
2232
  nodes: List[Node]
2158
2233
  connections: List[Connection]
2159
2234
  layout: LayoutManager
@@ -2161,7 +2236,7 @@ class Patcher:
2161
2236
 
2162
2237
  def __init__(
2163
2238
  self,
2164
- filename: Optional[str] = None,
2239
+ filename: Optional[Union[str, Path]] = None,
2165
2240
  layout: Optional[LayoutManager] = None,
2166
2241
  *,
2167
2242
  validate_links: bool = True,
@@ -2175,8 +2250,9 @@ class Patcher:
2175
2250
 
2176
2251
  Parameters
2177
2252
  ----------
2178
- filename : str, optional
2179
- Default filename for save(). Can be overridden in save().
2253
+ filename : str or Path, optional
2254
+ Default filename for save(), stored as a ``Path``. Can be
2255
+ overridden in save().
2180
2256
  layout : LayoutManager, optional
2181
2257
  Custom layout manager. If None, creates a default LayoutManager.
2182
2258
  validate_links : bool, optional
@@ -2193,7 +2269,7 @@ class Patcher:
2193
2269
  font_size : int, optional
2194
2270
  Patch font size written on the ``#N canvas`` line (default: 10).
2195
2271
  """
2196
- self.filename = filename
2272
+ self.filename = Path(filename) if filename is not None else None
2197
2273
  self.nodes = []
2198
2274
  self.connections = []
2199
2275
  self.layout = layout if layout is not None else LayoutManager()
@@ -2204,6 +2280,9 @@ class Patcher:
2204
2280
  self.canvas_height = canvas_height
2205
2281
  self.font_size = font_size
2206
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()
2207
2286
 
2208
2287
  @property
2209
2288
  def row_head(self) -> Optional[Node]:
@@ -2249,10 +2328,13 @@ class Patcher:
2249
2328
  ) -> Tuple[int, int, Callable[[Node], None]]:
2250
2329
  """Resolve position for a new element."""
2251
2330
  was_absolute = x_pos >= 0 and y_pos >= 0
2331
+ x_was_given = x_pos >= 0
2252
2332
  computed_x, computed_y = self.layout.compute_position(new_row, new_col, x_pos, y_pos)
2253
2333
 
2254
2334
  def position_update(node: Node) -> None:
2255
2335
  self.layout.register_node(node, new_row, new_col, was_absolute)
2336
+ if x_was_given:
2337
+ self._explicit_x.add(id(node))
2256
2338
 
2257
2339
  return (computed_x, computed_y, position_update)
2258
2340
 
@@ -2412,8 +2494,9 @@ class Patcher:
2412
2494
  self,
2413
2495
  *,
2414
2496
  width: int = 5,
2415
- upper_limit: float = 0,
2416
2497
  lower_limit: float = 0,
2498
+ upper_limit: float = 0,
2499
+ label_pos: int = 0,
2417
2500
  label: str = "-",
2418
2501
  receive: str = "-",
2419
2502
  send: str = "-",
@@ -2428,10 +2511,12 @@ class Patcher:
2428
2511
  ----------
2429
2512
  width : int
2430
2513
  Display width in characters (default: 5)
2431
- upper_limit : int
2432
- Maximum value (0 = no limit)
2433
2514
  lower_limit : int
2434
2515
  Minimum value (0 = no limit)
2516
+ upper_limit : int
2517
+ Maximum value (0 = no limit)
2518
+ label_pos : int
2519
+ Label placement: 0=left, 1=right, 2=top, 3=bottom
2435
2520
  label : str
2436
2521
  Label text (default: '-' for none)
2437
2522
  receive : str
@@ -2445,7 +2530,17 @@ class Patcher:
2445
2530
  The created number box
2446
2531
  """
2447
2532
  x_pos, y_pos, pos_update = self._resolve_position(x_pos, y_pos, new_row, new_col)
2448
- node = Float(x_pos, y_pos, width, upper_limit, lower_limit, label, receive, send)
2533
+ node = Float(
2534
+ x_pos,
2535
+ y_pos,
2536
+ width=width,
2537
+ lower_limit=lower_limit,
2538
+ upper_limit=upper_limit,
2539
+ label_pos=label_pos,
2540
+ label=label,
2541
+ receive=receive,
2542
+ send=send,
2543
+ )
2449
2544
  self._register(node, pos_update)
2450
2545
  return node
2451
2546
 
@@ -2554,6 +2649,9 @@ class Patcher:
2554
2649
  and n.parameters["text"].split()[:1] in (["outlet"], ["outlet~"])
2555
2650
  )
2556
2651
 
2652
+ _order_subpatch_io(src)
2653
+ _warn_on_ambiguous_subpatch_io(src, name)
2654
+
2557
2655
  x_pos, y_pos, pos_update = self._resolve_position(x_pos, y_pos, new_row, new_col)
2558
2656
  node = Subpatch(
2559
2657
  x_pos,
@@ -3408,23 +3506,36 @@ class Patcher:
3408
3506
  connections_str = "".join(str(c) for c in self.connections)
3409
3507
  return f"{nodes_str}{connections_str}"
3410
3508
 
3411
- def save(self, filename: Optional[str] = None) -> None:
3509
+ def save(
3510
+ self,
3511
+ filename: Optional[Union[str, Path]] = None,
3512
+ *,
3513
+ to_dir: Optional[Union[str, Path]] = None,
3514
+ ) -> None:
3412
3515
  """Save the patch to a file.
3413
3516
 
3414
3517
  Parameters
3415
3518
  ----------
3416
- filename : str, optional
3519
+ filename : str or Path, optional
3417
3520
  Path to the output .pd file. If not provided, uses the filename
3418
3521
  from the constructor.
3522
+ to_dir : str or Path, optional
3523
+ Directory to write into, created if missing. Only the base name of
3524
+ *filename* is used, so one patch can be written to several
3525
+ directories.
3419
3526
 
3420
3527
  Raises
3421
3528
  ------
3422
3529
  ValueError
3423
3530
  If no filename is provided and none was set in constructor.
3424
3531
  """
3425
- fn = filename or self.filename
3532
+ fn = Path(filename) if filename is not None else self.filename
3426
3533
  if fn is None:
3427
3534
  raise ValueError("No filename specified. Provide filename or set in constructor.")
3535
+ if to_dir is not None:
3536
+ d = Path(to_dir)
3537
+ d.mkdir(parents=True, exist_ok=True)
3538
+ fn = d / fn.name
3428
3539
  with open(fn, "w", encoding="utf-8", newline="\n") as f:
3429
3540
  f.write(str(self) + "\n")
3430
3541
 
@@ -1874,12 +1874,13 @@ def to_builder(ast: PdPatch) -> "api.Patcher":
1874
1874
  node = api.Float(
1875
1875
  elem.position.x,
1876
1876
  elem.position.y,
1877
- elem.width,
1878
- elem.upper_limit,
1879
- elem.lower_limit,
1880
- elem.label,
1881
- elem.receive,
1882
- elem.send,
1877
+ width=elem.width,
1878
+ lower_limit=elem.lower_limit,
1879
+ upper_limit=elem.upper_limit,
1880
+ label_pos=elem.label_pos,
1881
+ label=elem.label,
1882
+ receive=elem.receive,
1883
+ send=elem.send,
1883
1884
  )
1884
1885
  patch.nodes.append(node)
1885
1886
  node_map.append(node)
@@ -28,6 +28,7 @@ Usage::
28
28
  from dataclasses import dataclass, field
29
29
  from enum import Enum
30
30
  import os
31
+ from pathlib import Path
31
32
  import re
32
33
  import shutil
33
34
  import subprocess
@@ -425,7 +426,7 @@ class HeavyPatcher(Patcher):
425
426
 
426
427
  Parameters
427
428
  ----------
428
- filename : str, optional
429
+ filename : str or Path, optional
429
430
  Default filename for save().
430
431
  layout : LayoutManager, optional
431
432
  Custom layout manager.
@@ -435,7 +436,7 @@ class HeavyPatcher(Patcher):
435
436
 
436
437
  def __init__(
437
438
  self,
438
- filename: Optional[str] = None,
439
+ filename: Optional[Union[str, Path]] = None,
439
440
  layout: Optional[LayoutManager] = None,
440
441
  generators: Sequence[HvccGenerator] | None = None,
441
442
  ) -> None:
File without changes
File without changes
File without changes
File without changes