py2pd 0.2.1__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.1
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.1"
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.1"
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,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.3"
@@ -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 PdCoords, PdDeclare, _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:
@@ -157,6 +221,20 @@ class Node:
157
221
  hidden: bool = False
158
222
  num_inlets: Optional[int] = None
159
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
160
238
 
161
239
  class Outlet:
162
240
  """Reference to a specific outlet of a Node, used for creating connections."""
@@ -399,6 +477,7 @@ class Float(Node):
399
477
  send: str = "-",
400
478
  num_inlets: Optional[int] = 1,
401
479
  num_outlets: Optional[int] = 1,
480
+ font_size: Optional[int] = None,
402
481
  ) -> None:
403
482
  self.parameters = {
404
483
  "x_pos": x_pos,
@@ -410,16 +489,18 @@ class Float(Node):
410
489
  "label": label,
411
490
  "receive": receive,
412
491
  "send": send,
492
+ "font_size": font_size,
413
493
  }
414
494
  self.num_inlets = num_inlets
415
495
  self.num_outlets = num_outlets
416
496
 
417
497
  def __str__(self) -> str:
418
498
  p = self.parameters
499
+ tail = "" if p["font_size"] is None else f" {p['font_size']}"
419
500
  return (
420
501
  f"#X floatatom {p['x_pos']} {p['y_pos']} {p['width']} "
421
- f"{p['lower_limit']} {p['upper_limit']} {p['label_pos']} "
422
- f"{p['label']} {p['receive']} {p['send']};\n"
502
+ f"{_fmt_num(p['lower_limit'])} {_fmt_num(p['upper_limit'])} {p['label_pos']} "
503
+ f"{p['label']} {p['receive']} {p['send']}{tail};\n"
423
504
  )
424
505
 
425
506
  @property
@@ -469,6 +550,9 @@ class Comment(Node):
469
550
 
470
551
 
471
552
  # Default subpatch canvas dimensions (pixels)
553
+ # Horizontal gap applied when spreading a subpatch's inlet/outlet objects so
554
+ # PureData orders them by creation rather than by an arbitrary tie-break.
555
+ SUBPATCH_IO_SPACING = 70
472
556
  SUBPATCH_CANVAS_WIDTH = 300
473
557
  SUBPATCH_CANVAS_HEIGHT = 180
474
558
 
@@ -543,6 +627,12 @@ class Subpatch(Node):
543
627
  is_graph: bool = False,
544
628
  gop_rect: Tuple[float, float, float, float] = (0, 1, 1, 0),
545
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,
546
636
  ) -> None:
547
637
  """Create a subpatch node.
548
638
 
@@ -600,6 +690,12 @@ class Subpatch(Node):
600
690
  "is_graph": is_graph,
601
691
  "gop_rect": gop_rect,
602
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,
603
699
  }
604
700
  self.num_inlets = num_inlets
605
701
  self.num_outlets = num_outlets
@@ -607,23 +703,28 @@ class Subpatch(Node):
607
703
  def __str__(self) -> str:
608
704
  p = self.parameters
609
705
  coords_line = ""
610
- 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:
611
708
  # PureData encodes "hide object name and arguments" in the
612
709
  # graph-on-parent flag itself: 1 = shown, 2 = hidden. There is no
613
710
  # separate field, and the two values after the flag are the
614
711
  # viewport margins.
615
712
  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)))
713
+ rect = " ".join(_fmt_num(v) for v in p.get("gop_rect", (0, 1, 1, 0)))
617
714
  margins = p.get("gop_margins", (0, 0))
618
715
  tail = "" if margins is None else f" {margins[0]} {margins[1]}"
619
716
  coords_line = f"#X coords {rect} {p['gop_width']} {p['gop_height']} {gop_flag}{tail};\n"
620
- restore = (
621
- f"#X restore {p['x_pos']} {p['y_pos']} graph;\n"
622
- if p.get("is_graph")
623
- else f"#X restore {p['x_pos']} {p['y_pos']} pd {p['name']};\n"
624
- )
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"
625
724
  return (
626
- 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"
627
728
  f"{self.src._subpatch_str()}"
628
729
  f"{coords_line}"
629
730
  f"{restore}"
@@ -778,6 +879,89 @@ class Array(Node):
778
879
  return f"Array({p['name']!r}, {p['length']})"
779
880
 
780
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
+
781
965
  # An IEM GUI colour is either a legacy packed negative integer (PureData < 0.47)
782
966
  # or a hex string such as ``#fcfcfc`` (PureData >= 0.47). Both are accepted and
783
967
  # written out unchanged.
@@ -976,6 +1160,7 @@ class Symbol(Node):
976
1160
  label: str = "-",
977
1161
  receive: str = "-",
978
1162
  send: str = "-",
1163
+ font_size: Optional[int] = None,
979
1164
  ) -> None:
980
1165
  self.parameters = {
981
1166
  "x_pos": x_pos,
@@ -987,16 +1172,18 @@ class Symbol(Node):
987
1172
  "label": label,
988
1173
  "receive": receive,
989
1174
  "send": send,
1175
+ "font_size": font_size,
990
1176
  }
991
1177
  self.num_inlets = 1
992
1178
  self.num_outlets = 1
993
1179
 
994
1180
  def __str__(self) -> str:
995
1181
  p = self.parameters
1182
+ tail = "" if p["font_size"] is None else f" {p['font_size']}"
996
1183
  return (
997
1184
  f"#X symbolatom {p['x_pos']} {p['y_pos']} {p['width']} "
998
- f"{p['lower_limit']} {p['upper_limit']} {p['label_pos']} "
999
- f"{p['label']} {p['receive']} {p['send']};\n"
1185
+ f"{_fmt_num(p['lower_limit'])} {_fmt_num(p['upper_limit'])} {p['label_pos']} "
1186
+ f"{p['label']} {p['receive']} {p['send']}{tail};\n"
1000
1187
  )
1001
1188
 
1002
1189
  @property
@@ -1073,11 +1260,11 @@ class NumberBox(Node):
1073
1260
  p = self.parameters
1074
1261
  return (
1075
1262
  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']} "
1263
+ f"{_fmt_num(p['min_val'])} {_fmt_num(p['max_val'])} {p['log_flag']} {p['init']} "
1077
1264
  f"{p['send']} {p['receive']} {p['label']} "
1078
1265
  f"{p['label_x']} {p['label_y']} {p['font']} {p['font_size']} "
1079
1266
  f"{p['bg_color']} {p['fg_color']} {p['label_color']} "
1080
- f"{p['init_value']} {p['log_height']};\n"
1267
+ f"{_fmt_num(p['init_value'])} {p['log_height']};\n"
1081
1268
  )
1082
1269
 
1083
1270
  @property
@@ -1151,11 +1338,11 @@ class VSlider(Node):
1151
1338
  p = self.parameters
1152
1339
  return (
1153
1340
  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']} "
1341
+ f"{_fmt_num(p['min_val'])} {_fmt_num(p['max_val'])} {p['log_flag']} {p['init']} "
1155
1342
  f"{p['send']} {p['receive']} {p['label']} "
1156
1343
  f"{p['label_x']} {p['label_y']} {p['font']} {p['font_size']} "
1157
1344
  f"{p['bg_color']} {p['fg_color']} {p['label_color']} "
1158
- f"{p['init_value']} {p['steady']};\n"
1345
+ f"{_fmt_num(p['init_value'])} {p['steady']};\n"
1159
1346
  )
1160
1347
 
1161
1348
  @property
@@ -1226,11 +1413,11 @@ class HSlider(Node):
1226
1413
  p = self.parameters
1227
1414
  return (
1228
1415
  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']} "
1416
+ f"{_fmt_num(p['min_val'])} {_fmt_num(p['max_val'])} {p['log_flag']} {p['init']} "
1230
1417
  f"{p['send']} {p['receive']} {p['label']} "
1231
1418
  f"{p['label_x']} {p['label_y']} {p['font']} {p['font_size']} "
1232
1419
  f"{p['bg_color']} {p['fg_color']} {p['label_color']} "
1233
- f"{p['init_value']} {p['steady']};\n"
1420
+ f"{_fmt_num(p['init_value'])} {p['steady']};\n"
1234
1421
  )
1235
1422
 
1236
1423
  @property
@@ -2213,6 +2400,17 @@ class Patcher:
2213
2400
  self.canvas_height = canvas_height
2214
2401
  self.font_size = font_size
2215
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
2411
+ # Nodes whose x the caller set. Subpatch I/O ordering may reposition
2412
+ # the others; it must never move one the caller placed.
2413
+ self._explicit_x: Set[int] = set()
2216
2414
 
2217
2415
  @property
2218
2416
  def row_head(self) -> Optional[Node]:
@@ -2258,10 +2456,13 @@ class Patcher:
2258
2456
  ) -> Tuple[int, int, Callable[[Node], None]]:
2259
2457
  """Resolve position for a new element."""
2260
2458
  was_absolute = x_pos >= 0 and y_pos >= 0
2459
+ x_was_given = x_pos >= 0
2261
2460
  computed_x, computed_y = self.layout.compute_position(new_row, new_col, x_pos, y_pos)
2262
2461
 
2263
2462
  def position_update(node: Node) -> None:
2264
2463
  self.layout.register_node(node, new_row, new_col, was_absolute)
2464
+ if x_was_given:
2465
+ self._explicit_x.add(id(node))
2265
2466
 
2266
2467
  return (computed_x, computed_y, position_update)
2267
2468
 
@@ -2492,6 +2693,12 @@ class Patcher:
2492
2693
  is_graph: bool = False,
2493
2694
  gop_rect: Tuple[float, float, float, float] = (0, 1, 1, 0),
2494
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,
2495
2702
  ) -> Subpatch:
2496
2703
  """Add a subpatch to the patch.
2497
2704
 
@@ -2576,6 +2783,9 @@ class Patcher:
2576
2783
  and n.parameters["text"].split()[:1] in (["outlet"], ["outlet~"])
2577
2784
  )
2578
2785
 
2786
+ _order_subpatch_io(src)
2787
+ _warn_on_ambiguous_subpatch_io(src, name)
2788
+
2579
2789
  x_pos, y_pos, pos_update = self._resolve_position(x_pos, y_pos, new_row, new_col)
2580
2790
  node = Subpatch(
2581
2791
  x_pos,
@@ -2593,6 +2803,12 @@ class Patcher:
2593
2803
  is_graph=is_graph,
2594
2804
  gop_rect=gop_rect,
2595
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,
2596
2812
  )
2597
2813
  self._register(node, pos_update)
2598
2814
  return node
@@ -2654,7 +2870,9 @@ class Patcher:
2654
2870
  self._register(node, pos_update)
2655
2871
  return node
2656
2872
 
2657
- 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:
2658
2876
  """Declare an array in the subpatch.
2659
2877
 
2660
2878
  Parameters
@@ -2665,6 +2883,12 @@ class Patcher:
2665
2883
  length : int
2666
2884
  the array length
2667
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
+
2668
2892
  Returns
2669
2893
  -------
2670
2894
  node : Array
@@ -2672,9 +2896,9 @@ class Patcher:
2672
2896
 
2673
2897
  Notes
2674
2898
  -----
2675
- The array will not have a graph. Its contents are not stored.
2899
+ The array will not have a graph.
2676
2900
  """
2677
- node = Array(name, length)
2901
+ node = Array(name, length, element_type, save_flag)
2678
2902
  self._register(node)
2679
2903
  return node
2680
2904
 
@@ -3359,8 +3583,8 @@ class Patcher:
3359
3583
  outlet = source.index
3360
3584
  source = source.owner
3361
3585
 
3362
- source_index = self._index_of(source, "Source")
3363
- 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")
3364
3588
 
3365
3589
  if outlet < 0:
3366
3590
  raise PdConnectionError(f"Outlet index must be non-negative, got {outlet}")
@@ -3380,6 +3604,62 @@ class Patcher:
3380
3604
 
3381
3605
  self.connections.append(Connection(source_index, outlet, sink_index, inlet))
3382
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
+
3383
3663
  def _index_of(self, node: Node, role: str) -> int:
3384
3664
  """Return *node*'s position in ``self.nodes``.
3385
3665
 
@@ -3415,20 +3695,27 @@ class Patcher:
3415
3695
  add_link = link
3416
3696
 
3417
3697
  def __str__(self) -> str:
3698
+ preamble = "".join(f"{line};\n" for line in self.preamble)
3418
3699
  canvas = (
3419
3700
  f"#N canvas {self.canvas_x} {self.canvas_y} "
3420
3701
  f"{self.canvas_width} {self.canvas_height} {self.font_size};\n"
3421
3702
  )
3422
- return f"{canvas}{self._subpatch_str().rstrip()}"
3703
+ return f"{preamble}{canvas}{self._subpatch_str().rstrip()}"
3423
3704
 
3424
3705
  def __repr__(self) -> str:
3425
3706
  return f"Patcher(nodes={len(self.nodes)}, connections={len(self.connections)})"
3426
3707
 
3427
3708
  def _subpatch_str(self) -> str:
3428
- """Internal: generate string for patch contents."""
3429
- 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)
3430
3716
  connections_str = "".join(str(c) for c in self.connections)
3431
- 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}"
3432
3719
 
3433
3720
  def save(
3434
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