py2pd 0.2.2__tar.gz → 0.2.3__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.2
3
+ Version: 0.2.3
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
@@ -483,4 +483,4 @@ from py2pd import (
483
483
 
484
484
  `PdConnectionError` is raised eagerly by `link()` when outlet or inlet indices exceed the node's known I/O counts. `Node.__getitem__` (e.g., `osc[2]`) raises `ValueError` for out-of-range outlet indices. Both checks are skipped for objects with unknown counts (`num_outlets=None` / `num_inlets=None`), and `link()` warns instead of raising when the patch was created with `validate_links=False`.
485
485
 
486
- `to_builder()` issues `UnsupportedElementWarning` (from `py2pd.ast`) for any statement the Builder cannot represent, rather than dropping patch content silently.
486
+ `to_builder()` issues `UnsupportedElementWarning` (from `py2pd.ast`) for a connection it cannot rebuild, rather than dropping patch content silently. Statements the Builder does not model are carried verbatim instead of warned about, so `parse -> to_builder -> from_builder -> serialize` returns the bytes it started with.
@@ -451,4 +451,4 @@ from py2pd import (
451
451
 
452
452
  `PdConnectionError` is raised eagerly by `link()` when outlet or inlet indices exceed the node's known I/O counts. `Node.__getitem__` (e.g., `osc[2]`) raises `ValueError` for out-of-range outlet indices. Both checks are skipped for objects with unknown counts (`num_outlets=None` / `num_inlets=None`), and `link()` warns instead of raising when the patch was created with `validate_links=False`.
453
453
 
454
- `to_builder()` issues `UnsupportedElementWarning` (from `py2pd.ast`) for any statement the Builder cannot represent, rather than dropping patch content silently.
454
+ `to_builder()` issues `UnsupportedElementWarning` (from `py2pd.ast`) for a connection it cannot rebuild, rather than dropping patch content silently. Statements the Builder does not model are carried verbatim instead of warned about, so `parse -> to_builder -> from_builder -> serialize` returns the bytes it started with.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "py2pd"
3
- version = "0.2.2"
3
+ version = "0.2.3"
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.2"
3
+ version = "0.2.3"
4
4
  description = "Roundtrip parsing and generation of pure-data patches from python"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -91,4 +91,4 @@ __all__ = [
91
91
  "extract_declare_paths",
92
92
  ]
93
93
 
94
- __version__ = "0.2.2"
94
+ __version__ = "0.2.3"
@@ -6,7 +6,7 @@ import warnings
6
6
 
7
7
  # The builder writes the same number format as the AST serializer, so it uses
8
8
  # the same helper. ast imports api only lazily, so this direction does not cycle.
9
- from .ast import _fmt_num
9
+ from .ast import PdCoords, PdDeclare, _fmt_num
10
10
 
11
11
  # Layout constants (pixels)
12
12
  ROW_HEIGHT = 25
@@ -221,6 +221,20 @@ class Node:
221
221
  hidden: bool = False
222
222
  num_inlets: Optional[int] = None
223
223
  num_outlets: Optional[int] = None
224
+ # A carried statement that followed the #X connect lines in the file it was
225
+ # read from, and so must be written back after them.
226
+ after_connections: bool = False
227
+
228
+ @property
229
+ def occupies_connect_index(self) -> bool:
230
+ """Whether PureData counts this node when numbering ``#X connect``.
231
+
232
+ Almost everything does. ``#X declare``, ``#A`` array data and ``#X f``
233
+ box widths are statements rather than objects, so they sit in the node
234
+ list without taking an index; a node that returns False here shifts no
235
+ connection.
236
+ """
237
+ return True
224
238
 
225
239
  class Outlet:
226
240
  """Reference to a specific outlet of a Node, used for creating connections."""
@@ -463,6 +477,7 @@ class Float(Node):
463
477
  send: str = "-",
464
478
  num_inlets: Optional[int] = 1,
465
479
  num_outlets: Optional[int] = 1,
480
+ font_size: Optional[int] = None,
466
481
  ) -> None:
467
482
  self.parameters = {
468
483
  "x_pos": x_pos,
@@ -474,16 +489,18 @@ class Float(Node):
474
489
  "label": label,
475
490
  "receive": receive,
476
491
  "send": send,
492
+ "font_size": font_size,
477
493
  }
478
494
  self.num_inlets = num_inlets
479
495
  self.num_outlets = num_outlets
480
496
 
481
497
  def __str__(self) -> str:
482
498
  p = self.parameters
499
+ tail = "" if p["font_size"] is None else f" {p['font_size']}"
483
500
  return (
484
501
  f"#X floatatom {p['x_pos']} {p['y_pos']} {p['width']} "
485
502
  f"{_fmt_num(p['lower_limit'])} {_fmt_num(p['upper_limit'])} {p['label_pos']} "
486
- f"{p['label']} {p['receive']} {p['send']};\n"
503
+ f"{p['label']} {p['receive']} {p['send']}{tail};\n"
487
504
  )
488
505
 
489
506
  @property
@@ -610,6 +627,12 @@ class Subpatch(Node):
610
627
  is_graph: bool = False,
611
628
  gop_rect: Tuple[float, float, float, float] = (0, 1, 1, 0),
612
629
  gop_margins: Optional[Tuple[int, int]] = (0, 0),
630
+ canvas_x: int = 0,
631
+ canvas_y: int = 0,
632
+ canvas_name: str = "(subpatch)",
633
+ canvas_font_size: int = 10,
634
+ open_on_load: int = 0,
635
+ restore_kind: Optional[str] = None,
613
636
  ) -> None:
614
637
  """Create a subpatch node.
615
638
 
@@ -667,6 +690,12 @@ class Subpatch(Node):
667
690
  "is_graph": is_graph,
668
691
  "gop_rect": gop_rect,
669
692
  "gop_margins": gop_margins,
693
+ "canvas_x": canvas_x,
694
+ "canvas_y": canvas_y,
695
+ "canvas_name": canvas_name,
696
+ "canvas_font_size": canvas_font_size,
697
+ "open_on_load": open_on_load,
698
+ "restore_kind": restore_kind,
670
699
  }
671
700
  self.num_inlets = num_inlets
672
701
  self.num_outlets = num_outlets
@@ -674,7 +703,8 @@ class Subpatch(Node):
674
703
  def __str__(self) -> str:
675
704
  p = self.parameters
676
705
  coords_line = ""
677
- if p["graph_on_parent"]:
706
+ carries_own_coords = any(isinstance(n, Coords) for n in self.src.nodes)
707
+ if p["graph_on_parent"] and not carries_own_coords:
678
708
  # PureData encodes "hide object name and arguments" in the
679
709
  # graph-on-parent flag itself: 1 = shown, 2 = hidden. There is no
680
710
  # separate field, and the two values after the flag are the
@@ -684,13 +714,17 @@ class Subpatch(Node):
684
714
  margins = p.get("gop_margins", (0, 0))
685
715
  tail = "" if margins is None else f" {margins[0]} {margins[1]}"
686
716
  coords_line = f"#X coords {rect} {p['gop_width']} {p['gop_height']} {gop_flag}{tail};\n"
687
- restore = (
688
- f"#X restore {p['x_pos']} {p['y_pos']} graph;\n"
689
- if p.get("is_graph")
690
- else f"#X restore {p['x_pos']} {p['y_pos']} pd {p['name']};\n"
691
- )
717
+ # A parsed subpatch keeps the kind PureData wrote ("page" and others
718
+ # exist); one the builder created derives it from is_graph. A graph
719
+ # canvas closes without a name.
720
+ kind = p.get("restore_kind") or ("graph" if p.get("is_graph") else "pd")
721
+ name = "" if kind == "graph" else p["name"]
722
+ suffix = f" {name}" if name else ""
723
+ restore = f"#X restore {p['x_pos']} {p['y_pos']} {kind}{suffix};\n"
692
724
  return (
693
- f"#N canvas 0 0 {self.canvas_width} {self.canvas_height} (subpatch) 0;\n"
725
+ f"#N canvas {p['canvas_x']} {p['canvas_y']} "
726
+ f"{self.canvas_width} {self.canvas_height} "
727
+ f"{p['canvas_name']} {p['open_on_load']};\n"
694
728
  f"{self.src._subpatch_str()}"
695
729
  f"{coords_line}"
696
730
  f"{restore}"
@@ -845,6 +879,89 @@ class Array(Node):
845
879
  return f"Array({p['name']!r}, {p['length']})"
846
880
 
847
881
 
882
+ class Raw(Node):
883
+ """A statement py2pd has no builder node for, carried verbatim.
884
+
885
+ ``#X scalar``, ``#X listbox``, ``#A`` array data and ``#X f`` box widths
886
+ have no builder representation, but a patch read with ``to_builder()`` must
887
+ still write back what it read. The text is stored exactly as parsed.
888
+
889
+ Parameters
890
+ ----------
891
+ text : str
892
+ The statement without its trailing semicolon.
893
+ is_object : bool
894
+ Whether PureData counts the statement as an object on the canvas, which
895
+ decides whether it takes a ``#X connect`` index.
896
+ """
897
+
898
+ def __init__(self, text: str, is_object: bool = False) -> None:
899
+ self.hidden = True
900
+ self.parameters = {"text": text, "is_object": is_object}
901
+ self.num_inlets = None
902
+ self.num_outlets = None
903
+
904
+ @property
905
+ def occupies_connect_index(self) -> bool:
906
+ return bool(self.parameters["is_object"])
907
+
908
+ def __str__(self) -> str:
909
+ return f"{self.parameters['text']};\n"
910
+
911
+ def __repr__(self) -> str:
912
+ return f"Raw({self.parameters['text'][:40]!r})"
913
+
914
+
915
+ class Declare(Node):
916
+ """A ``#X declare`` statement (search paths and libraries).
917
+
918
+ Wraps the AST's ``PdDeclare`` rather than re-modelling it, so the builder
919
+ writes back exactly what the parser read and ``extract_declare_paths()``
920
+ keeps working on a patch that went through the builder.
921
+ """
922
+
923
+ def __init__(self, declare: "PdDeclare") -> None:
924
+ self.hidden = True
925
+ self.parameters = {"declare": declare}
926
+ self.num_inlets = 0
927
+ self.num_outlets = 0
928
+
929
+ @property
930
+ def occupies_connect_index(self) -> bool:
931
+ return False
932
+
933
+ def __str__(self) -> str:
934
+ return f"{self.parameters['declare']}\n"
935
+
936
+ def __repr__(self) -> str:
937
+ return f"Declare({self.parameters['declare']!r})"
938
+
939
+
940
+ class Coords(Node):
941
+ """An ``#X coords`` statement that no subpatch consumed.
942
+
943
+ A subpatch folds its own ``#X coords`` into graph-on-parent parameters. One
944
+ on the top-level canvas, or a second one inside a subpatch, has nowhere to
945
+ go, so it is carried as its parsed ``PdCoords``.
946
+ """
947
+
948
+ def __init__(self, coords: "PdCoords") -> None:
949
+ self.hidden = True
950
+ self.parameters = {"coords": coords}
951
+ self.num_inlets = 0
952
+ self.num_outlets = 0
953
+
954
+ @property
955
+ def occupies_connect_index(self) -> bool:
956
+ return False
957
+
958
+ def __str__(self) -> str:
959
+ return f"{self.parameters['coords']}\n"
960
+
961
+ def __repr__(self) -> str:
962
+ return f"Coords({self.parameters['coords']!r})"
963
+
964
+
848
965
  # An IEM GUI colour is either a legacy packed negative integer (PureData < 0.47)
849
966
  # or a hex string such as ``#fcfcfc`` (PureData >= 0.47). Both are accepted and
850
967
  # written out unchanged.
@@ -1043,6 +1160,7 @@ class Symbol(Node):
1043
1160
  label: str = "-",
1044
1161
  receive: str = "-",
1045
1162
  send: str = "-",
1163
+ font_size: Optional[int] = None,
1046
1164
  ) -> None:
1047
1165
  self.parameters = {
1048
1166
  "x_pos": x_pos,
@@ -1054,16 +1172,18 @@ class Symbol(Node):
1054
1172
  "label": label,
1055
1173
  "receive": receive,
1056
1174
  "send": send,
1175
+ "font_size": font_size,
1057
1176
  }
1058
1177
  self.num_inlets = 1
1059
1178
  self.num_outlets = 1
1060
1179
 
1061
1180
  def __str__(self) -> str:
1062
1181
  p = self.parameters
1182
+ tail = "" if p["font_size"] is None else f" {p['font_size']}"
1063
1183
  return (
1064
1184
  f"#X symbolatom {p['x_pos']} {p['y_pos']} {p['width']} "
1065
1185
  f"{_fmt_num(p['lower_limit'])} {_fmt_num(p['upper_limit'])} {p['label_pos']} "
1066
- f"{p['label']} {p['receive']} {p['send']};\n"
1186
+ f"{p['label']} {p['receive']} {p['send']}{tail};\n"
1067
1187
  )
1068
1188
 
1069
1189
  @property
@@ -2280,6 +2400,14 @@ class Patcher:
2280
2400
  self.canvas_height = canvas_height
2281
2401
  self.font_size = font_size
2282
2402
  self._node_positions: Dict[int, int] = {}
2403
+ # Statements above the #N canvas line (#N struct and friends), carried
2404
+ # verbatim so a parsed patch writes back what it read.
2405
+ self.preamble: List[str] = []
2406
+ # Set once a node that takes no #X connect index is added. While it is
2407
+ # False the connect index is the list position, which is the common case.
2408
+ self._has_unindexed = False
2409
+ self._connect_prefix: List[int] = []
2410
+ self._connect_prefix_len = -1
2283
2411
  # Nodes whose x the caller set. Subpatch I/O ordering may reposition
2284
2412
  # the others; it must never move one the caller placed.
2285
2413
  self._explicit_x: Set[int] = set()
@@ -2565,6 +2693,12 @@ class Patcher:
2565
2693
  is_graph: bool = False,
2566
2694
  gop_rect: Tuple[float, float, float, float] = (0, 1, 1, 0),
2567
2695
  gop_margins: Optional[Tuple[int, int]] = (0, 0),
2696
+ canvas_x: int = 0,
2697
+ canvas_y: int = 0,
2698
+ canvas_name: str = "(subpatch)",
2699
+ canvas_font_size: int = 10,
2700
+ open_on_load: int = 0,
2701
+ restore_kind: Optional[str] = None,
2568
2702
  ) -> Subpatch:
2569
2703
  """Add a subpatch to the patch.
2570
2704
 
@@ -2669,6 +2803,12 @@ class Patcher:
2669
2803
  is_graph=is_graph,
2670
2804
  gop_rect=gop_rect,
2671
2805
  gop_margins=gop_margins,
2806
+ canvas_x=canvas_x,
2807
+ canvas_y=canvas_y,
2808
+ canvas_name=canvas_name,
2809
+ canvas_font_size=canvas_font_size,
2810
+ open_on_load=open_on_load,
2811
+ restore_kind=restore_kind,
2672
2812
  )
2673
2813
  self._register(node, pos_update)
2674
2814
  return node
@@ -2730,7 +2870,9 @@ class Patcher:
2730
2870
  self._register(node, pos_update)
2731
2871
  return node
2732
2872
 
2733
- def add_array(self, name: str, length: int) -> Array:
2873
+ def add_array(
2874
+ self, name: str, length: int, element_type: str = "float", save_flag: int = 0
2875
+ ) -> Array:
2734
2876
  """Declare an array in the subpatch.
2735
2877
 
2736
2878
  Parameters
@@ -2741,6 +2883,12 @@ class Patcher:
2741
2883
  length : int
2742
2884
  the array length
2743
2885
 
2886
+ element_type : str
2887
+ the array's data type (default ``'float'``)
2888
+
2889
+ save_flag : int
2890
+ whether PureData saves the contents with the patch (default 0)
2891
+
2744
2892
  Returns
2745
2893
  -------
2746
2894
  node : Array
@@ -2748,9 +2896,9 @@ class Patcher:
2748
2896
 
2749
2897
  Notes
2750
2898
  -----
2751
- The array will not have a graph. Its contents are not stored.
2899
+ The array will not have a graph.
2752
2900
  """
2753
- node = Array(name, length)
2901
+ node = Array(name, length, element_type, save_flag)
2754
2902
  self._register(node)
2755
2903
  return node
2756
2904
 
@@ -3435,8 +3583,8 @@ class Patcher:
3435
3583
  outlet = source.index
3436
3584
  source = source.owner
3437
3585
 
3438
- source_index = self._index_of(source, "Source")
3439
- sink_index = self._index_of(sink, "Sink")
3586
+ source_index = self._connect_index_of(source, "Source")
3587
+ sink_index = self._connect_index_of(sink, "Sink")
3440
3588
 
3441
3589
  if outlet < 0:
3442
3590
  raise PdConnectionError(f"Outlet index must be non-negative, got {outlet}")
@@ -3456,6 +3604,62 @@ class Patcher:
3456
3604
 
3457
3605
  self.connections.append(Connection(source_index, outlet, sink_index, inlet))
3458
3606
 
3607
+ def _connect_index_of(self, node: Node, role: str) -> int:
3608
+ """Return *node*'s ``#X connect`` index.
3609
+
3610
+ Equal to the list position until a node that takes no index is added,
3611
+ after which it is the count of index-taking nodes before it. The prefix
3612
+ sums are cached against the node count, so the link pass over a parsed
3613
+ patch rebuilds them once rather than per connection.
3614
+ """
3615
+ position = self._index_of(node, role)
3616
+ if not self._has_unindexed:
3617
+ return position
3618
+ if self._connect_prefix_len != len(self.nodes):
3619
+ prefix: List[int] = []
3620
+ count = 0
3621
+ for existing in self.nodes:
3622
+ prefix.append(count)
3623
+ if existing.occupies_connect_index:
3624
+ count += 1
3625
+ self._connect_prefix = prefix
3626
+ self._connect_prefix_len = len(self.nodes)
3627
+ return self._connect_prefix[position]
3628
+
3629
+ def add_raw(self, text: str, is_object: bool = False, after_connections: bool = False) -> Raw:
3630
+ """Append a statement the builder does not model, written back verbatim.
3631
+
3632
+ Parameters
3633
+ ----------
3634
+ text : str
3635
+ The statement without its trailing semicolon.
3636
+ is_object : bool
3637
+ Whether PureData counts it as an object, and so whether it takes a
3638
+ ``#X connect`` index.
3639
+ """
3640
+ node = Raw(text, is_object)
3641
+ node.after_connections = after_connections
3642
+ if not node.occupies_connect_index:
3643
+ self._has_unindexed = True
3644
+ self._register(node)
3645
+ return node
3646
+
3647
+ def add_declare(self, declare: PdDeclare, after_connections: bool = False) -> Declare:
3648
+ """Append a ``#X declare`` statement, written back verbatim."""
3649
+ node = Declare(declare)
3650
+ node.after_connections = after_connections
3651
+ self._has_unindexed = True
3652
+ self._register(node)
3653
+ return node
3654
+
3655
+ def add_coords(self, coords: PdCoords, after_connections: bool = False) -> Coords:
3656
+ """Append an ``#X coords`` statement no subpatch consumed."""
3657
+ node = Coords(coords)
3658
+ node.after_connections = after_connections
3659
+ self._has_unindexed = True
3660
+ self._register(node)
3661
+ return node
3662
+
3459
3663
  def _index_of(self, node: Node, role: str) -> int:
3460
3664
  """Return *node*'s position in ``self.nodes``.
3461
3665
 
@@ -3491,20 +3695,27 @@ class Patcher:
3491
3695
  add_link = link
3492
3696
 
3493
3697
  def __str__(self) -> str:
3698
+ preamble = "".join(f"{line};\n" for line in self.preamble)
3494
3699
  canvas = (
3495
3700
  f"#N canvas {self.canvas_x} {self.canvas_y} "
3496
3701
  f"{self.canvas_width} {self.canvas_height} {self.font_size};\n"
3497
3702
  )
3498
- return f"{canvas}{self._subpatch_str().rstrip()}"
3703
+ return f"{preamble}{canvas}{self._subpatch_str().rstrip()}"
3499
3704
 
3500
3705
  def __repr__(self) -> str:
3501
3706
  return f"Patcher(nodes={len(self.nodes)}, connections={len(self.connections)})"
3502
3707
 
3503
3708
  def _subpatch_str(self) -> str:
3504
- """Internal: generate string for patch contents."""
3505
- nodes_str = "".join(str(n) for n in self.nodes)
3709
+ """Internal: generate string for patch contents.
3710
+
3711
+ A statement read from after the ``#X connect`` block goes back there.
3712
+ PureData writes canvas properties such as ``#X coords`` below the
3713
+ connections, and moving one above them changes the file it wrote.
3714
+ """
3715
+ nodes_str = "".join(str(n) for n in self.nodes if not n.after_connections)
3506
3716
  connections_str = "".join(str(c) for c in self.connections)
3507
- return f"{nodes_str}{connections_str}"
3717
+ trailing_str = "".join(str(n) for n in self.nodes if n.after_connections)
3718
+ return f"{nodes_str}{connections_str}{trailing_str}"
3508
3719
 
3509
3720
  def save(
3510
3721
  self,
@@ -782,10 +782,10 @@ class ParseError(Exception):
782
782
  class UnsupportedElementWarning(UserWarning):
783
783
  """Warning issued when converting to the builder API would lose an element.
784
784
 
785
- The builder models the common subset of the file format. Statements it has
786
- no node for -- data structure templates, scalars, array data, box widths --
787
- survive in the AST but cannot be carried into a ``Patcher``. Parse and
788
- serialize through :func:`parse` / :func:`serialize` to keep them.
785
+ Statements the builder does not model -- data structure templates, scalars,
786
+ array data, box widths -- are carried verbatim by ``Raw``, ``Declare`` and
787
+ ``Coords`` nodes, so they no longer raise this. It is left for a connection
788
+ whose endpoint has no builder node at all.
789
789
  """
790
790
 
791
791
  pass
@@ -1512,13 +1512,16 @@ def from_builder(patch: "api.Patcher") -> PdPatch:
1512
1512
  patch.font_size,
1513
1513
  )
1514
1514
  elements: List[PdElement] = []
1515
+ # Carried statements PureData wrote below the #X connect block.
1516
+ trailing: List[PdElement] = []
1515
1517
 
1516
1518
  for node in patch.nodes:
1517
1519
  if isinstance(node, api.Obj):
1518
- text = node.parameters["text"]
1519
- parts = text.split(None, 1)
1520
- class_name = parts[0] if parts else ""
1521
- args = tuple(parts[1].split()) if len(parts) > 1 else ()
1520
+ # The parser's tokenizer, not str.split(): a plain whitespace split
1521
+ # eats an escaped trailing space, rewriting the object's last atom.
1522
+ tokens = _tokenize(node.parameters["text"])
1523
+ class_name = tokens[0] if tokens else ""
1524
+ args = tuple(tokens[1:])
1522
1525
  pos = Position(node.parameters["x_pos"], node.parameters["y_pos"])
1523
1526
  elements.append(PdObj(pos, class_name, args))
1524
1527
 
@@ -1539,13 +1542,23 @@ def from_builder(patch: "api.Patcher") -> PdPatch:
1539
1542
  p["width"],
1540
1543
  p["lower_limit"],
1541
1544
  p["upper_limit"],
1542
- 0,
1545
+ p["label_pos"],
1543
1546
  p["label"],
1544
1547
  p["receive"],
1545
1548
  p["send"],
1549
+ p["font_size"],
1546
1550
  )
1547
1551
  )
1548
1552
 
1553
+ elif isinstance(node, (api.Raw, api.Declare, api.Coords)):
1554
+ if isinstance(node, api.Raw):
1555
+ carried: PdElement = PdRaw(node.parameters["text"], node.parameters["is_object"])
1556
+ elif isinstance(node, api.Declare):
1557
+ carried = node.parameters["declare"]
1558
+ else:
1559
+ carried = node.parameters["coords"]
1560
+ (trailing if node.after_connections else elements).append(carried)
1561
+
1549
1562
  elif isinstance(node, api.Array):
1550
1563
  p = node.parameters
1551
1564
  elements.append(PdArray(p["name"], p["length"], p["element_type"], p["save_flag"]))
@@ -1556,15 +1569,19 @@ def from_builder(patch: "api.Patcher") -> PdPatch:
1556
1569
  p = node.parameters
1557
1570
  pos = Position(p["x_pos"], p["y_pos"])
1558
1571
  subpatch_canvas = CanvasProperties(
1559
- 0, 0, node.canvas_width, node.canvas_height, 10, "(subpatch)", 0
1560
- )
1561
- restore = (
1562
- PdRestore(pos, "", "graph")
1563
- if p.get("is_graph")
1564
- else PdRestore(pos, p["name"], "pd")
1572
+ p["canvas_x"],
1573
+ p["canvas_y"],
1574
+ node.canvas_width,
1575
+ node.canvas_height,
1576
+ p["canvas_font_size"],
1577
+ p["canvas_name"],
1578
+ p["open_on_load"],
1565
1579
  )
1580
+ kind = p.get("restore_kind") or ("graph" if p.get("is_graph") else "pd")
1581
+ restore = PdRestore(pos, "" if kind == "graph" else p["name"], kind)
1566
1582
  inner_elements = list(inner_ast.elements)
1567
- if p["graph_on_parent"]:
1583
+ carries_own_coords = any(isinstance(e, PdCoords) for e in inner_elements)
1584
+ if p["graph_on_parent"] and not carries_own_coords:
1568
1585
  rect = p.get("gop_rect", (0, 1, 1, 0))
1569
1586
  margins = p.get("gop_margins", (0, 0))
1570
1587
  inner_elements.append(
@@ -1641,6 +1658,7 @@ def from_builder(patch: "api.Patcher") -> PdPatch:
1641
1658
  p["label"],
1642
1659
  p["receive"],
1643
1660
  p["send"],
1661
+ p["font_size"],
1644
1662
  )
1645
1663
  )
1646
1664
 
@@ -1817,8 +1835,9 @@ def from_builder(patch: "api.Patcher") -> PdPatch:
1817
1835
  # Add connections
1818
1836
  for conn in patch.connections:
1819
1837
  elements.append(PdConnect(conn.source, conn.outlet_index, conn.sink, conn.inlet_index))
1838
+ elements.extend(trailing)
1820
1839
 
1821
- return PdPatch(canvas, elements)
1840
+ return PdPatch(canvas, elements, [PdRaw(line) for line in patch.preamble])
1822
1841
 
1823
1842
 
1824
1843
  def to_builder(ast: PdPatch) -> "api.Patcher":
@@ -1853,9 +1872,14 @@ def to_builder(ast: PdPatch) -> "api.Patcher":
1853
1872
  font_size=ast.canvas.font_size,
1854
1873
  )
1855
1874
 
1875
+ patch.preamble = [raw.text for raw in ast.preamble]
1876
+
1856
1877
  # First pass: create all nodes (non-connections)
1857
1878
  node_map: List[Optional[api.Node]] = [] # Track nodes for linking
1858
1879
  node: api.Node
1880
+ # PureData writes canvas properties below the #X connect block. A carried
1881
+ # statement remembers which side of it that block it came from.
1882
+ seen_connect = False
1859
1883
  for elem in ast.elements:
1860
1884
  if isinstance(elem, PdObj):
1861
1885
  # elem.text is already in PureData's escaped form; escaping it a
@@ -1881,6 +1905,7 @@ def to_builder(ast: PdPatch) -> "api.Patcher":
1881
1905
  label=elem.label,
1882
1906
  receive=elem.receive,
1883
1907
  send=elem.send,
1908
+ font_size=elem.font_size,
1884
1909
  )
1885
1910
  patch.nodes.append(node)
1886
1911
  node_map.append(node)
@@ -1896,6 +1921,7 @@ def to_builder(ast: PdPatch) -> "api.Patcher":
1896
1921
  label=elem.label,
1897
1922
  receive=elem.receive,
1898
1923
  send=elem.send,
1924
+ font_size=elem.font_size,
1899
1925
  )
1900
1926
  patch.nodes.append(node)
1901
1927
  node_map.append(node)
@@ -1906,12 +1932,10 @@ def to_builder(ast: PdPatch) -> "api.Patcher":
1906
1932
  node_map.append(node)
1907
1933
 
1908
1934
  elif isinstance(elem, PdArray):
1909
- node = patch.add_array(elem.name, elem.size)
1935
+ node = patch.add_array(elem.name, elem.size, elem.dtype, elem.save_flag)
1910
1936
  node_map.append(node)
1911
1937
 
1912
1938
  elif isinstance(elem, PdSubpatch):
1913
- # Recursively convert subpatch
1914
- inner_patch = to_builder(PdPatch(elem.canvas, elem.elements))
1915
1939
  name = elem.restore.name if elem.restore else "subpatch"
1916
1940
  pos = elem.restore.position if elem.restore else Position(0, 0)
1917
1941
  # Extract GOP settings from PdCoords if present
@@ -1934,6 +1958,10 @@ def to_builder(ast: PdPatch) -> "api.Patcher":
1934
1958
  else (sub_elem.x_margin or 0, sub_elem.y_margin or 0)
1935
1959
  )
1936
1960
  break
1961
+ # The folded coords stays in the recursion as a carried node: the
1962
+ # writers below regenerate one only when the inner patch has none,
1963
+ # which keeps a parsed coords at the line PureData wrote it on.
1964
+ inner_patch = to_builder(PdPatch(elem.canvas, elem.elements))
1937
1965
  if elem.restore is not None and elem.restore.is_graph:
1938
1966
  gop_kwargs["is_graph"] = True
1939
1967
  node = patch.add_subpatch(
@@ -1943,6 +1971,12 @@ def to_builder(ast: PdPatch) -> "api.Patcher":
1943
1971
  y_pos=pos.y,
1944
1972
  canvas_width=elem.canvas.width,
1945
1973
  canvas_height=elem.canvas.height,
1974
+ canvas_x=elem.canvas.x,
1975
+ canvas_y=elem.canvas.y,
1976
+ canvas_name=elem.canvas.name if elem.canvas.name is not None else "(subpatch)",
1977
+ canvas_font_size=elem.canvas.font_size,
1978
+ open_on_load=elem.canvas.open_on_load,
1979
+ restore_kind=elem.restore.kind if elem.restore is not None else None,
1946
1980
  **gop_kwargs,
1947
1981
  )
1948
1982
  node_map.append(node)
@@ -2155,26 +2189,26 @@ def to_builder(ast: PdPatch) -> "api.Patcher":
2155
2189
  node_map.append(node)
2156
2190
 
2157
2191
  elif isinstance(elem, PdRaw):
2158
- # The builder has no representation for these, so converting drops
2159
- # them. Say so rather than losing patch content silently; the AST
2160
- # API round-trips them intact.
2161
- kind = " ".join(elem.text.split()[:2]) or "?"
2162
- warnings.warn(
2163
- f"to_builder() cannot represent {kind!r} statements; "
2164
- f"dropping: {elem.text[:60]!r}. Use the AST API to preserve them.",
2165
- UnsupportedElementWarning,
2166
- stacklevel=2,
2167
- )
2192
+ # The builder models no such statement, but it must still write back
2193
+ # what it read, so the text is carried verbatim.
2194
+ node = patch.add_raw(elem.text, elem.is_object, after_connections=seen_connect)
2168
2195
  # Only occupy a connect index if Pd counts the statement as an
2169
2196
  # object, otherwise every following index shifts.
2170
2197
  if elem.is_object:
2171
- node_map.append(None)
2198
+ node_map.append(node)
2199
+
2200
+ elif isinstance(elem, PdDeclare):
2201
+ # Not an object, so it consumes no connect index.
2202
+ patch.add_declare(elem, after_connections=seen_connect)
2203
+
2204
+ elif isinstance(elem, PdCoords):
2205
+ # A subpatch strips the coords it folds into its own parameters
2206
+ # before recursing, so anything reaching here belongs to this canvas.
2207
+ patch.add_coords(elem, after_connections=seen_connect)
2172
2208
 
2173
- elif isinstance(elem, (PdConnect, PdCoords, PdDeclare)):
2174
- # None of these is an object, so none consumes a connect index.
2175
- # Connections are handled in the second pass; coords are folded into
2176
- # the enclosing subpatch; declare has no builder equivalent.
2177
- pass
2209
+ elif isinstance(elem, PdConnect):
2210
+ # Not an object, and handled in the second pass.
2211
+ seen_connect = True
2178
2212
 
2179
2213
  else:
2180
2214
  node_map.append(None) # Placeholder for unknown elements
@@ -2231,7 +2265,7 @@ def transform(patch: PdPatch, transformer: ElementTransformer) -> PdPatch:
2231
2265
  if transformed is not None:
2232
2266
  new_elements.append(transformed)
2233
2267
 
2234
- return PdPatch(patch.canvas, new_elements)
2268
+ return PdPatch(patch.canvas, new_elements, patch.preamble)
2235
2269
 
2236
2270
 
2237
2271
  def find_objects(patch: PdPatch, predicate: ElementPredicate) -> List[PdElement]:
@@ -12,7 +12,7 @@ import sys
12
12
  from typing import Dict, List, Optional, Tuple
13
13
 
14
14
  from .api import _infer_abstraction_io
15
- from .ast import PdDeclare, PdElement, PdPatch, PdSubpatch
15
+ from .ast import ParseError, PdDeclare, PdElement, PdPatch, PdSubpatch
16
16
 
17
17
  # Maps sys.platform prefix to recognized binary extensions for externals
18
18
  _EXTERNAL_EXTENSIONS = {
@@ -98,6 +98,7 @@ def discover_externals(
98
98
  Mapping of external name to (num_inlets, num_outlets).
99
99
  Binary externals have (None, None) since I/O cannot be inferred.
100
100
  First-found wins when the same name appears in multiple paths.
101
+ A ``.pd`` file that cannot be read or parsed is skipped.
101
102
  """
102
103
  paths: List[str] = []
103
104
  if search_paths:
@@ -130,7 +131,9 @@ def discover_externals(
130
131
  try:
131
132
  inlets, outlets = _infer_abstraction_io(full_path)
132
133
  registry[name] = (inlets, outlets)
133
- except OSError:
134
+ except (OSError, ParseError):
135
+ # An unreadable or unparseable candidate is an unusable
136
+ # candidate, not a reason to abandon the whole scan.
134
137
  continue
135
138
  continue
136
139
 
@@ -220,13 +220,13 @@ def validate_patch(
220
220
  TypeError
221
221
  If *patch* is not a ``Patcher`` or ``PdPatch``.
222
222
  """
223
- _ensure_libpd()
224
-
225
- import cypd
226
-
227
223
  content = _serialize_input(patch)
228
224
 
229
225
  with _libpd_lock:
226
+ _ensure_libpd()
227
+
228
+ import cypd
229
+
230
230
  return _validate_locked(
231
231
  cypd,
232
232
  content,
File without changes
File without changes