py2max 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. py2max/__init__.py +67 -0
  2. py2max/__main__.py +6 -0
  3. py2max/cli.py +1251 -0
  4. py2max/core/__init__.py +39 -0
  5. py2max/core/abstract.py +146 -0
  6. py2max/core/box.py +231 -0
  7. py2max/core/common.py +19 -0
  8. py2max/core/patcher.py +1658 -0
  9. py2max/core/patchline.py +68 -0
  10. py2max/exceptions.py +385 -0
  11. py2max/export/__init__.py +20 -0
  12. py2max/export/converters.py +345 -0
  13. py2max/export/svg.py +393 -0
  14. py2max/layout/__init__.py +26 -0
  15. py2max/layout/base.py +463 -0
  16. py2max/layout/flow.py +405 -0
  17. py2max/layout/grid.py +374 -0
  18. py2max/layout/matrix.py +628 -0
  19. py2max/log.py +338 -0
  20. py2max/maxref/__init__.py +78 -0
  21. py2max/maxref/category.py +163 -0
  22. py2max/maxref/db.py +1082 -0
  23. py2max/maxref/legacy.py +324 -0
  24. py2max/maxref/parser.py +703 -0
  25. py2max/py.typed +0 -0
  26. py2max/server/__init__.py +54 -0
  27. py2max/server/client.py +295 -0
  28. py2max/server/inline.py +312 -0
  29. py2max/server/repl.py +561 -0
  30. py2max/server/rpc.py +240 -0
  31. py2max/server/websocket.py +997 -0
  32. py2max/static/cola.min.js +4 -0
  33. py2max/static/d3.v7.min.js +2 -0
  34. py2max/static/dagre-bundle.js +328 -0
  35. py2max/static/elk.bundled.js +6663 -0
  36. py2max/static/index.html +168 -0
  37. py2max/static/interactive.html +589 -0
  38. py2max/static/interactive.js +2111 -0
  39. py2max/static/live-preview.js +324 -0
  40. py2max/static/svg.min.js +13 -0
  41. py2max/static/svg.min.js.map +1 -0
  42. py2max/transformers.py +168 -0
  43. py2max/utils.py +83 -0
  44. py2max-0.2.1.dist-info/METADATA +390 -0
  45. py2max-0.2.1.dist-info/RECORD +48 -0
  46. py2max-0.2.1.dist-info/WHEEL +4 -0
  47. py2max-0.2.1.dist-info/entry_points.txt +3 -0
  48. py2max-0.2.1.dist-info/licenses/LICENSE +19 -0
@@ -0,0 +1,345 @@
1
+ """Utilities for converting between Max patch formats."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import textwrap
7
+ from pathlib import Path
8
+ from typing import Iterable, Optional
9
+
10
+ from ..core import Patcher
11
+ from ..maxref.db import MaxRefDB
12
+
13
+ PY_TEMPLATE = '''"""Auto-generated by py2max. Recreates {source_name} using py2max."""
14
+ from __future__ import annotations
15
+
16
+ from py2max import Box, Patcher
17
+ from py2max.core.common import Rect
18
+
19
+ {helper_functions_block}
20
+
21
+ def build(output_path: str = {default_output_repr}) -> Patcher:
22
+ """Rebuild the patcher and write it to ``output_path``."""
23
+ p = Patcher(output_path)
24
+ {patcher_attr_block}
25
+ {box_block}
26
+ {line_block}
27
+ p.save_as(output_path)
28
+ return p
29
+
30
+
31
+ if __name__ == "__main__":
32
+ build()
33
+ '''
34
+
35
+
36
+ def _format_rect(rect: object) -> str:
37
+ if isinstance(rect, dict):
38
+ x = rect.get("x", 0.0)
39
+ y = rect.get("y", 0.0)
40
+ w = rect.get("w", 0.0)
41
+ h = rect.get("h", 0.0)
42
+ elif isinstance(rect, (list, tuple)) and len(rect) == 4:
43
+ x, y, w, h = rect
44
+ else:
45
+ raise ValueError(f"Unsupported rect value: {rect}")
46
+ return f"Rect({x}, {y}, {w}, {h})"
47
+
48
+
49
+ def _format_value(value: object) -> str:
50
+ if isinstance(value, str):
51
+ return repr(value)
52
+ if isinstance(value, (int, float, bool)) or value is None:
53
+ return repr(value)
54
+ if isinstance(value, (list, tuple, dict)):
55
+ return repr(value)
56
+ return repr(value)
57
+
58
+
59
+ def _build_subpatch_function(
60
+ name: str, patcher_dict: dict, ctx: _CodeGenContext
61
+ ) -> str:
62
+ attr_block, box_block, line_block, _ = _build_patcher_sections(
63
+ patcher_dict, ctx, "sp", f"{name}_obj", " "
64
+ )
65
+ body = (
66
+ f"def {name}() -> Patcher:\n"
67
+ f" sp = Patcher()\n"
68
+ f"{attr_block}{box_block}{line_block}"
69
+ " return sp\n"
70
+ )
71
+ return body
72
+
73
+
74
+ def _build_patcher_attribute_lines(
75
+ patcher_dict: dict, patcher_var: str, indent: str
76
+ ) -> str:
77
+ lines: list[str] = []
78
+ keys_present: set[str] = set()
79
+ for key, value in patcher_dict.items():
80
+ if key in {"boxes", "lines"}:
81
+ continue
82
+ if key.startswith("_"):
83
+ continue
84
+ if key == "rect":
85
+ formatted = _format_rect(value)
86
+ else:
87
+ formatted = _format_value(value)
88
+ line = f"{indent}{patcher_var}.{key} = {formatted}"
89
+ lines.append(line)
90
+ keys_present.add(key)
91
+ missing = sorted(_DEFAULT_PUBLIC_ATTRS - keys_present)
92
+ for key in missing:
93
+ lines.append(f"{indent}if hasattr({patcher_var}, '{key}'):")
94
+ lines.append(f"{indent} delattr({patcher_var}, '{key}')")
95
+ return "\n".join(lines) + ("\n" if lines else "")
96
+
97
+
98
+ def _format_box(box: dict) -> tuple[str, dict[str, object]]:
99
+ if "patcher" in box:
100
+ raise NotImplementedError("Subpatcher conversion is not yet supported")
101
+
102
+ key_order = [
103
+ "id",
104
+ "maxclass",
105
+ "text",
106
+ "numinlets",
107
+ "numoutlets",
108
+ "outlettype",
109
+ "patching_rect",
110
+ ]
111
+
112
+ parts: list[str] = []
113
+ handled = set()
114
+ post_assign: dict[str, object] = {}
115
+ for key in key_order:
116
+ if key not in box:
117
+ continue
118
+ value = box[key]
119
+ if key == "patching_rect":
120
+ formatted = _format_rect(value)
121
+ elif key == "numoutlets" and value == 0:
122
+ post_assign[key] = value
123
+ handled.add(key)
124
+ continue
125
+ else:
126
+ formatted = _format_value(value)
127
+ parts.append(f"{key}={formatted}")
128
+ handled.add(key)
129
+
130
+ for key in sorted(box.keys()):
131
+ if key in handled or key == "patcher":
132
+ continue
133
+ parts.append(f"{key}={_format_value(box[key])}")
134
+
135
+ return f"Box({', '.join(parts)})", post_assign
136
+
137
+
138
+ class _CodeGenContext:
139
+ def __init__(self) -> None:
140
+ self.counter = 0
141
+ self.helper_functions: list[str] = []
142
+
143
+ def new_subpatch_name(self) -> str:
144
+ name = f"_build_subpatch_{self.counter}"
145
+ self.counter += 1
146
+ return name
147
+
148
+
149
+ def _build_patcher_sections(
150
+ patcher_dict: dict,
151
+ ctx: _CodeGenContext,
152
+ patcher_var: str,
153
+ obj_prefix: str,
154
+ indent: str,
155
+ ) -> tuple[str, str, str, dict[str, str]]:
156
+ attr_block = _build_patcher_attribute_lines(patcher_dict, patcher_var, indent)
157
+ box_block, id_map = _build_boxes_block(
158
+ patcher_dict.get("boxes", []), ctx, patcher_var, obj_prefix, indent
159
+ )
160
+ line_block = _build_lines_block(
161
+ patcher_dict.get("lines", []), id_map, patcher_var, indent
162
+ )
163
+ return attr_block, box_block, line_block, id_map
164
+
165
+
166
+ def _build_boxes_block(
167
+ box_entries: list,
168
+ ctx: _CodeGenContext,
169
+ patcher_var: str,
170
+ obj_prefix: str,
171
+ indent: str,
172
+ ) -> tuple[str, dict[str, str]]:
173
+ lines: list[str] = []
174
+ id_map: dict[str, str] = {}
175
+ for idx, entry in enumerate(box_entries, start=1):
176
+ box_data = dict(entry.get("box", {}))
177
+ box_id = box_data.get("id", f"obj-{idx}")
178
+ var_name = f"{obj_prefix}_{idx}"
179
+ id_map[box_id] = var_name
180
+ subpatch_dict = box_data.pop("patcher", None)
181
+ box_repr, post_assign = _format_box(box_data)
182
+ lines.append(f"{indent}{var_name} = {patcher_var}.add_box({box_repr})")
183
+ for attr, value in post_assign.items():
184
+ lines.append(f"{indent}{var_name}.{attr} = {_format_value(value)}")
185
+ if subpatch_dict:
186
+ func_name = ctx.new_subpatch_name()
187
+ helper = _build_subpatch_function(func_name, subpatch_dict, ctx)
188
+ ctx.helper_functions.append(helper)
189
+ lines.append(f"{indent}{var_name}._patcher = {func_name}()")
190
+ lines.append(f"{indent}{var_name}._patcher._parent = {patcher_var}")
191
+ return ("\n".join(lines) + ("\n" if lines else ""), id_map)
192
+
193
+
194
+ def _build_lines_block(
195
+ lines_data: list, id_var_map: dict[str, str], patcher_var: str, indent: str
196
+ ) -> str:
197
+ lines_out: list[str] = []
198
+ for idx, entry in enumerate(lines_data, start=1):
199
+ line_data = entry.get("patchline", {})
200
+ source = line_data.get("source", [None, 0])
201
+ dest = line_data.get("destination", [None, 0])
202
+ src_id = source[0]
203
+ src_outlet = source[1] if len(source) > 1 else 0
204
+ dst_id = dest[0]
205
+ dst_inlet = dest[1] if len(dest) > 1 else 0
206
+
207
+ var_name = f"line_{idx}"
208
+ lines_out.append(
209
+ f"{indent}{var_name} = {patcher_var}.add_patchline('{src_id}', {src_outlet}, '{dst_id}', {dst_inlet})"
210
+ )
211
+
212
+ for key, value in line_data.items():
213
+ if key in {"source", "destination"}:
214
+ continue
215
+ formatted = _format_value(value)
216
+ lines_out.append(f"{indent}{var_name}.{key} = {formatted}")
217
+
218
+ return "\n".join(lines_out) + ("\n" if lines_out else "")
219
+
220
+
221
+ def maxpat_to_python(
222
+ source: str | Path,
223
+ destination: str | Path,
224
+ default_output: Optional[str] = None,
225
+ ) -> Path:
226
+ """Convert a ``.maxpat`` file into a standalone Python script.
227
+
228
+ Args:
229
+ source: Path to the source `.maxpat` file.
230
+ destination: Path where the Python script will be written.
231
+ default_output: Default output path baked into the generated script.
232
+
233
+ Returns:
234
+ Path to the generated Python script.
235
+ """
236
+
237
+ source_path = Path(source)
238
+ dest_path = Path(destination)
239
+
240
+ with source_path.open(encoding="utf8") as fh:
241
+ payload = json.load(fh)
242
+
243
+ if "patcher" not in payload:
244
+ raise ValueError(f"File {source_path} does not appear to be a .maxpat file")
245
+
246
+ patcher_dict = payload["patcher"]
247
+
248
+ default_output_repr = repr(default_output or f"{source_path.stem}.maxpat")
249
+
250
+ ctx = _CodeGenContext()
251
+ patcher_attr_block, box_block, line_block, _ = _build_patcher_sections(
252
+ patcher_dict, ctx, "p", "obj", " "
253
+ )
254
+ helper_functions_block = "\n".join(ctx.helper_functions)
255
+ if helper_functions_block:
256
+ helper_functions_block += "\n\n"
257
+
258
+ module_text = PY_TEMPLATE.format(
259
+ source_name=source_path.name,
260
+ default_output_repr=default_output_repr,
261
+ patcher_attr_block=patcher_attr_block,
262
+ box_block=box_block,
263
+ line_block=line_block,
264
+ helper_functions_block=helper_functions_block,
265
+ )
266
+
267
+ module_text = textwrap.dedent(module_text)
268
+
269
+ if dest_path.parent:
270
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
271
+
272
+ dest_path.write_text(module_text, encoding="utf8")
273
+ return dest_path
274
+
275
+
276
+ __all__ = ["maxpat_to_python", "maxref_to_sqlite"]
277
+
278
+
279
+ def _infer_category(ref_path: Path, data: dict) -> str:
280
+ parts = {part.lower() for part in ref_path.parts}
281
+ if "jit-ref" in parts or "jit" in parts:
282
+ return "jit"
283
+ if "msp-ref" in parts or "msp" in parts:
284
+ return "msp"
285
+ if "m4l-ref" in parts or "m4l" in parts:
286
+ return "m4l"
287
+ if "max-ref" in parts or "max" in parts:
288
+ return "max"
289
+
290
+ module = (data.get("module") or "").lower()
291
+ if module in {"jit", "msp", "m4l", "max"}:
292
+ return module
293
+
294
+ tags = data.get("metadata", {}).get("tag")
295
+ if isinstance(tags, list):
296
+ for tag in tags:
297
+ lowered = str(tag).lower()
298
+ if lowered in {"jit", "msp", "m4l", "max"}:
299
+ return lowered
300
+ elif isinstance(tags, str):
301
+ lowered = tags.lower()
302
+ if lowered in {"jit", "msp", "m4l", "max"}:
303
+ return lowered
304
+
305
+ return "unknown"
306
+
307
+
308
+ def maxref_to_sqlite(
309
+ sqlite_path: str | Path,
310
+ names: Optional[Iterable[str]] = None,
311
+ overwrite: bool = False,
312
+ ) -> int:
313
+ """Populate an SQLite database with maxref metadata using MaxRefDB.
314
+
315
+ Args:
316
+ sqlite_path: Destination SQLite file path.
317
+ names: Optional iterable of object names to include. Defaults to all available.
318
+ overwrite: Whether to remove any existing database before writing.
319
+
320
+ Returns:
321
+ Number of maxref records written.
322
+ """
323
+
324
+ path = Path(sqlite_path)
325
+ if overwrite and path.exists():
326
+ path.unlink()
327
+
328
+ # Use MaxRefDB for database operations
329
+ db = MaxRefDB(path)
330
+
331
+ if names is None:
332
+ # Populate with all objects
333
+ db.populate()
334
+ else:
335
+ # Populate with specific objects
336
+ db.populate(list(names))
337
+
338
+ return db.count
339
+
340
+
341
+ _DEFAULT_PUBLIC_ATTRS = {
342
+ name
343
+ for name in vars(Patcher()).keys()
344
+ if not name.startswith("_") and name not in {"boxes", "lines"}
345
+ }