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
py2max/cli.py ADDED
@@ -0,0 +1,1251 @@
1
+ """Command line interface for the py2max package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from pprint import pprint
10
+ from textwrap import fill
11
+ from typing import Any, Dict, Iterable, List, cast
12
+
13
+ try: # pragma: no cover - optional dependency
14
+ import yaml # type: ignore
15
+ except Exception: # pragma: no cover - optional dependency
16
+ yaml = None
17
+
18
+ from .core import Patcher, Patchline
19
+ from .core.common import Rect
20
+ from .exceptions import InvalidConnectionError
21
+ from .export import export_svg
22
+ from .export.converters import maxpat_to_python, maxref_to_sqlite
23
+ from .maxref import MaxRefCache, MaxRefDB, validate_connection
24
+ from .transformers import available_transformers, create_transformer, run_pipeline
25
+
26
+ LAYOUT_CHOICES = ["horizontal", "vertical", "grid", "flow", "matrix"]
27
+ FLOW_CHOICES = ["horizontal", "vertical", "column"]
28
+
29
+
30
+ def _sanitize_identifier(name: str) -> str:
31
+ cleaned = "".join(ch if ch.isalnum() else "_" for ch in name)
32
+ cleaned = cleaned.strip("_") or "identifier"
33
+ if cleaned[0].isdigit():
34
+ cleaned = f"_{cleaned}"
35
+ return cleaned
36
+
37
+
38
+ def _to_pascal_case(name: str) -> str:
39
+ return (
40
+ "".join(part.capitalize() for part in _sanitize_identifier(name).split("_"))
41
+ or "Object"
42
+ )
43
+
44
+
45
+ def _object_name(box) -> str:
46
+ maxclass = getattr(box, "maxclass", "newobj")
47
+ text = getattr(box, "text", "") or ""
48
+ if maxclass == "newobj" and text:
49
+ return text.split()[0]
50
+ return maxclass
51
+
52
+
53
+ def _unique_object_labels(boxes: Iterable) -> List[str]:
54
+ labels: set[str] = set()
55
+ for box in boxes:
56
+ labels.add(_object_name(box))
57
+ return sorted(labels)
58
+
59
+
60
+ def _coerce_rect(patcher: Patcher) -> None:
61
+ rect = getattr(patcher, "rect", None)
62
+ if isinstance(rect, (list, tuple)) and len(rect) == 4:
63
+ patcher.rect = Rect(*rect)
64
+
65
+
66
+ def _format_args(method: dict) -> List[str]:
67
+ args: List[str] = []
68
+ for arg in method.get("args", []):
69
+ name = _sanitize_identifier(arg.get("name", "arg"))
70
+ optional = str(arg.get("optional", "0")) == "1"
71
+ if optional:
72
+ args.append(f"{name}=None")
73
+ else:
74
+ args.append(name)
75
+ return args
76
+
77
+
78
+ def _dump_code(name: str, data: dict) -> None:
79
+ class_name = _to_pascal_case(name)
80
+ digest = data.get("digest", "")
81
+ description = data.get("description", "")
82
+
83
+ print(f"class {class_name}:")
84
+ print(' """' + digest)
85
+ if description:
86
+ formatted = fill(
87
+ description, width=76, initial_indent=" ", subsequent_indent=" "
88
+ )
89
+ for line in formatted.splitlines():
90
+ print(line)
91
+ print(' """')
92
+
93
+ for method_name, method in sorted(data.get("methods", {}).items()):
94
+ if method_name.startswith("("):
95
+ continue
96
+ identifier = _sanitize_identifier(method_name)
97
+ signature = ", ".join(["self"] + _format_args(method))
98
+ print(f" def {identifier}({signature}):")
99
+ digest = method.get("digest")
100
+ if digest:
101
+ print(f' """{digest}"""')
102
+ description = method.get("description")
103
+ if description and description != "TEXT_HERE":
104
+ formatted = fill(
105
+ description,
106
+ width=70,
107
+ initial_indent=" ",
108
+ subsequent_indent=" ",
109
+ )
110
+ for line in formatted.splitlines():
111
+ print(line)
112
+ print(" raise NotImplementedError\n")
113
+
114
+
115
+ def _dump_tests(name: str, data: dict) -> None:
116
+ base = _sanitize_identifier(name)
117
+ for method_name, method in sorted(data.get("methods", {}).items()):
118
+ identifier = _sanitize_identifier(method_name)
119
+ digest = method.get("digest", "")
120
+ print(f"def test_{base}_{identifier}():")
121
+ if digest:
122
+ print(f' """{digest}"""')
123
+ print(" # TODO: implement test\n")
124
+
125
+
126
+ def _generate_test_source(name: str, data: dict) -> str:
127
+ base = _sanitize_identifier(name)
128
+ lines = [
129
+ "from py2max.maxref import MaxRefCache",
130
+ "",
131
+ f"def test_{base}_maxref():",
132
+ " cache = MaxRefCache()",
133
+ f' data = cache.get_object_data("{name}")',
134
+ " assert data is not None",
135
+ ]
136
+
137
+ digest = data.get("digest")
138
+ if digest:
139
+ lines.append(f' assert data.get("digest") == {digest!r}')
140
+
141
+ inlet_count = len(data.get("inlets", []) or [])
142
+ if inlet_count:
143
+ lines.append(f' assert len(data.get("inlets", [])) == {inlet_count}')
144
+
145
+ outlet_count = len(data.get("outlets", []) or [])
146
+ if outlet_count:
147
+ lines.append(f' assert len(data.get("outlets", [])) == {outlet_count}')
148
+
149
+ return "\n".join(lines) + "\n"
150
+
151
+
152
+ def cmd_new(args: argparse.Namespace) -> int:
153
+ path = Path(args.path)
154
+ if path.exists() and not args.force:
155
+ print(f"Refusing to overwrite existing file: {path}", file=sys.stderr)
156
+ return 1
157
+
158
+ patcher = Patcher(
159
+ path=path,
160
+ title=args.title,
161
+ layout=args.layout,
162
+ flow_direction=args.flow_direction,
163
+ )
164
+
165
+ if args.template == "stereo":
166
+ osc = patcher.add("cycle~ 440")
167
+ gain = patcher.add("gain~")
168
+ dac = patcher.add("ezdac~")
169
+ patcher.link(osc, gain)
170
+ patcher.link(gain, dac)
171
+ patcher.link(gain, dac, inlet=1)
172
+ elif args.template == "blank":
173
+ pass
174
+ else:
175
+ print(f"Unknown template: {args.template}", file=sys.stderr)
176
+ return 2
177
+
178
+ patcher.save()
179
+ print(f"Created patcher at {path}")
180
+ return 0
181
+
182
+
183
+ def cmd_info(args: argparse.Namespace) -> int:
184
+ path = Path(args.path)
185
+ patcher = Patcher.from_file(path)
186
+ _coerce_rect(patcher)
187
+ boxes = patcher._boxes
188
+ lines = patcher._lines
189
+
190
+ print(f"Path: {path}")
191
+ print(f"Boxes: {len(boxes)}")
192
+ print(f"Patchlines: {len(lines)}")
193
+ labels = _unique_object_labels(boxes)
194
+ print("Objects: " + (", ".join(labels) if labels else "(none)"))
195
+
196
+ if args.verbose:
197
+ for box in boxes:
198
+ identifier = getattr(box, "id", "(unknown)")
199
+ obj_label = getattr(box, "text", None) or getattr(box, "maxclass", "newobj")
200
+ print(f" - {identifier}: {obj_label}")
201
+
202
+ return 0
203
+
204
+
205
+ def cmd_optimize(args: argparse.Namespace) -> int:
206
+ input_path = Path(args.input)
207
+ save_path = Path(args.output) if args.output else input_path
208
+
209
+ patcher = Patcher.from_file(input_path, save_to=str(save_path))
210
+ _coerce_rect(patcher)
211
+
212
+ if args.flow_direction:
213
+ patcher._flow_direction = args.flow_direction
214
+ if hasattr(patcher._layout_mgr, "flow_direction"):
215
+ patcher._layout_mgr.flow_direction = args.flow_direction
216
+
217
+ if args.layout:
218
+ patcher._layout_mgr = patcher.set_layout_mgr(args.layout)
219
+
220
+ patcher.optimize_layout()
221
+ patcher.save_as(save_path)
222
+ print(f"Optimized layout saved to {save_path}")
223
+ return 0
224
+
225
+
226
+ def cmd_validate(args: argparse.Namespace) -> int:
227
+ path = Path(args.path)
228
+ patcher = Patcher.from_file(path)
229
+ _coerce_rect(patcher)
230
+
231
+ errors: List[str] = []
232
+
233
+ for line in patcher._lines:
234
+ patchline = cast(Patchline, line)
235
+ src_id, dst_id = patchline.src, patchline.dst
236
+ src_port = int(patchline.source[1]) if len(patchline.source) > 1 else 0
237
+ dst_port = (
238
+ int(patchline.destination[1]) if len(patchline.destination) > 1 else 0
239
+ )
240
+
241
+ src_obj = patcher._objects.get(src_id)
242
+ dst_obj = patcher._objects.get(dst_id)
243
+
244
+ if not src_obj or not dst_obj:
245
+ errors.append(f"Dangling connection: {src_id} -> {dst_id}")
246
+ continue
247
+
248
+ src_name = _object_name(src_obj)
249
+ dst_name = _object_name(dst_obj)
250
+
251
+ is_valid, message = validate_connection(src_name, src_port, dst_name, dst_port)
252
+ if not is_valid:
253
+ errors.append(
254
+ f"Invalid connection {src_name}[{src_port}] -> {dst_name}[{dst_port}]: {message}"
255
+ )
256
+
257
+ if errors:
258
+ print("Connection validation failed:", file=sys.stderr)
259
+ for error in errors:
260
+ print(f" - {error}", file=sys.stderr)
261
+ return 1
262
+
263
+ print("All connections are valid.")
264
+ return 0
265
+
266
+
267
+ def _parse_transform_spec(spec: str) -> tuple[str, str | None]:
268
+ if "=" in spec:
269
+ name, value = spec.split("=", 1)
270
+ name = name.strip()
271
+ value = value.strip()
272
+ return name, value or None
273
+ return spec.strip(), None
274
+
275
+
276
+ def cmd_transform(args: argparse.Namespace) -> int:
277
+ if args.list_transformers:
278
+ for name, desc in available_transformers().items():
279
+ print(f"{name}: {desc}")
280
+ return 0
281
+
282
+ if not args.input:
283
+ print("Please provide an input .maxpat file.", file=sys.stderr)
284
+ return 1
285
+
286
+ if not args.apply:
287
+ print(
288
+ "No transformers specified. Use --apply name or --apply name=value.",
289
+ file=sys.stderr,
290
+ )
291
+ return 1
292
+
293
+ input_path = Path(args.input)
294
+ output_path = Path(args.output) if args.output else input_path
295
+
296
+ patcher = Patcher.from_file(input_path, save_to=str(output_path))
297
+
298
+ transformers = []
299
+ for spec in args.apply:
300
+ name, value = _parse_transform_spec(spec)
301
+ try:
302
+ transformer = create_transformer(name, value)
303
+ except KeyError:
304
+ print(
305
+ f"Unknown transformer '{name}'. Use --list-transformers to see options.",
306
+ file=sys.stderr,
307
+ )
308
+ return 1
309
+ except ValueError as exc:
310
+ print(str(exc), file=sys.stderr)
311
+ return 1
312
+ transformers.append(transformer)
313
+
314
+ run_pipeline(patcher, transformers)
315
+ patcher.save_as(output_path)
316
+ print(f"Saved transformed patcher to {output_path}")
317
+ return 0
318
+
319
+
320
+ def cmd_convert(args: argparse.Namespace) -> int:
321
+ if args.mode == "maxpat-to-python":
322
+ if not args.input or not args.output:
323
+ print(
324
+ "Usage: py2max convert maxpat-to-python <input.maxpat> <output.py>",
325
+ file=sys.stderr,
326
+ )
327
+ return 1
328
+
329
+ maxpat_to_python(args.input, args.output, default_output=args.default_output)
330
+ print(f"Wrote Python builder to {args.output}")
331
+ return 0
332
+
333
+ if args.mode == "maxref-to-sqlite":
334
+ if not args.output:
335
+ print(
336
+ "Usage: py2max convert maxref-to-sqlite --output cache.db [--names name1 name2]",
337
+ file=sys.stderr,
338
+ )
339
+ return 1
340
+
341
+ names = args.names if args.names else None
342
+ count = maxref_to_sqlite(args.output, names=names, overwrite=args.overwrite)
343
+ print(
344
+ f"Stored {count} maxref entr{'y' if count == 1 else 'ies'} in {args.output}"
345
+ )
346
+ return 0
347
+
348
+ print(
349
+ "Unknown convert mode. Supported: maxpat-to-python, maxref-to-sqlite",
350
+ file=sys.stderr,
351
+ )
352
+ return 1
353
+
354
+
355
+ def cmd_db(args: argparse.Namespace) -> int:
356
+ """Handle database subcommands"""
357
+ if args.db_command == "create":
358
+ return cmd_db_create(args)
359
+ elif args.db_command == "populate":
360
+ return cmd_db_populate(args)
361
+ elif args.db_command == "info":
362
+ return cmd_db_info(args)
363
+ elif args.db_command == "search":
364
+ return cmd_db_search(args)
365
+ elif args.db_command == "query":
366
+ return cmd_db_query(args)
367
+ elif args.db_command == "export":
368
+ return cmd_db_export(args)
369
+ elif args.db_command == "import":
370
+ return cmd_db_import(args)
371
+ elif args.db_command == "cache":
372
+ return cmd_db_cache(args)
373
+ else:
374
+ print(f"Unknown db subcommand: {args.db_command}", file=sys.stderr)
375
+ return 1
376
+
377
+
378
+ def cmd_db_create(args: argparse.Namespace) -> int:
379
+ """Create a new MaxRefDB database"""
380
+ db_path = Path(args.database)
381
+
382
+ if db_path.exists() and not args.force:
383
+ print(f"Database already exists: {db_path}", file=sys.stderr)
384
+ print(
385
+ "Use --force to overwrite or use 'db populate' to add to existing database",
386
+ file=sys.stderr,
387
+ )
388
+ return 1
389
+
390
+ if db_path.exists():
391
+ db_path.unlink()
392
+
393
+ db = MaxRefDB(db_path, auto_populate=False)
394
+
395
+ if not args.empty:
396
+ # Populate with specified category or all
397
+ category = args.category if hasattr(args, "category") else None
398
+ print(f"Creating database and populating with {category or 'all'} objects...")
399
+ db.populate(category=category)
400
+ print(f"Created database with {db.count} objects at {db_path}")
401
+ else:
402
+ print(f"Created empty database at {db_path}")
403
+
404
+ return 0
405
+
406
+
407
+ def cmd_db_populate(args: argparse.Namespace) -> int:
408
+ """Populate an existing MaxRefDB database"""
409
+ db_path = Path(args.database)
410
+
411
+ if not db_path.exists():
412
+ print(f"Database not found: {db_path}", file=sys.stderr)
413
+ print("Use 'db create' to create a new database", file=sys.stderr)
414
+ return 1
415
+
416
+ db = MaxRefDB(db_path, auto_populate=False)
417
+ initial_count = db.count
418
+
419
+ if args.objects:
420
+ print(f"Populating with {len(args.objects)} specific objects...")
421
+ db.populate(args.objects)
422
+ elif args.category:
423
+ print(f"Populating with {args.category} objects...")
424
+ db.populate(category=args.category)
425
+ else:
426
+ print("Populating with all objects...")
427
+ db.populate()
428
+
429
+ added = db.count - initial_count
430
+ print(f"Added {added} objects (total: {db.count})")
431
+ return 0
432
+
433
+
434
+ def cmd_db_info(args: argparse.Namespace) -> int:
435
+ """Show information about a MaxRefDB database"""
436
+ db_path = Path(args.database)
437
+
438
+ if not db_path.exists():
439
+ print(f"Database not found: {db_path}", file=sys.stderr)
440
+ return 1
441
+
442
+ db = MaxRefDB(db_path, auto_populate=False)
443
+
444
+ print(f"Database: {db_path}")
445
+ print(f"Total objects: {db.count}")
446
+
447
+ if args.summary:
448
+ summary = db.summary()
449
+ print("\nCategories:")
450
+ for category, count in sorted(summary["categories"].items()):
451
+ print(f" {category}: {count} objects")
452
+
453
+ if args.list:
454
+ print("\nObjects:")
455
+ for obj_name in db.objects:
456
+ print(f" {obj_name}")
457
+
458
+ if args.categories:
459
+ print("\nCategories:")
460
+ for category in db.categories:
461
+ print(f" {category}")
462
+
463
+ return 0
464
+
465
+
466
+ def cmd_db_search(args: argparse.Namespace) -> int:
467
+ """Search for objects in a MaxRefDB database"""
468
+ db_path = Path(args.database)
469
+
470
+ if not db_path.exists():
471
+ print(f"Database not found: {db_path}", file=sys.stderr)
472
+ return 1
473
+
474
+ db = MaxRefDB(db_path, auto_populate=False)
475
+
476
+ if args.category:
477
+ results = db.by_category(args.category)
478
+ print(f"Objects in category '{args.category}':")
479
+ else:
480
+ fields = args.fields.split(",") if args.fields else None
481
+ results = db.search(args.query, fields=fields)
482
+ print(f"Search results for '{args.query}':")
483
+
484
+ if not results:
485
+ print(" (no matches)")
486
+ return 0
487
+
488
+ for name in results:
489
+ if args.verbose:
490
+ obj = db[name]
491
+ digest = obj.get("digest", "")
492
+ print(f" {name}: {digest}")
493
+ else:
494
+ print(f" {name}")
495
+
496
+ print(f"\nFound {len(results)} objects")
497
+ return 0
498
+
499
+
500
+ def cmd_db_query(args: argparse.Namespace) -> int:
501
+ """Query object details from a MaxRefDB database"""
502
+ db_path = Path(args.database)
503
+
504
+ if not db_path.exists():
505
+ print(f"Database not found: {db_path}", file=sys.stderr)
506
+ return 1
507
+
508
+ db = MaxRefDB(db_path, auto_populate=False)
509
+
510
+ if args.name not in db:
511
+ print(f"Object not found: {args.name}", file=sys.stderr)
512
+ return 1
513
+
514
+ obj = db[args.name]
515
+
516
+ if args.json:
517
+ print(json.dumps(obj, indent=2))
518
+ elif args.dict:
519
+ pprint(obj)
520
+ else:
521
+ # Human-readable output
522
+ print(f"{args.name}")
523
+ if obj.get("digest"):
524
+ print(f" Digest: {obj['digest']}")
525
+ if obj.get("description"):
526
+ print(f" Description: {obj['description']}")
527
+ if obj.get("category"):
528
+ print(f" Category: {obj['category']}")
529
+
530
+ inlets = obj.get("inlets", [])
531
+ outlets = obj.get("outlets", [])
532
+ if inlets:
533
+ print(f" Inlets: {len(inlets)}")
534
+ if outlets:
535
+ print(f" Outlets: {len(outlets)}")
536
+
537
+ methods = obj.get("methods", {})
538
+ attributes = obj.get("attributes", {})
539
+ if methods:
540
+ print(f" Methods: {len(methods)}")
541
+ if attributes:
542
+ print(f" Attributes: {len(attributes)}")
543
+
544
+ return 0
545
+
546
+
547
+ def cmd_db_export(args: argparse.Namespace) -> int:
548
+ """Export MaxRefDB database to JSON"""
549
+ db_path = Path(args.database)
550
+
551
+ if not db_path.exists():
552
+ print(f"Database not found: {db_path}", file=sys.stderr)
553
+ return 1
554
+
555
+ output_path = Path(args.output)
556
+
557
+ if output_path.exists() and not args.force:
558
+ print(f"Output file already exists: {output_path}", file=sys.stderr)
559
+ print("Use --force to overwrite", file=sys.stderr)
560
+ return 1
561
+
562
+ db = MaxRefDB(db_path, auto_populate=False)
563
+ db.export(output_path)
564
+ print(f"Exported {db.count} objects to {output_path}")
565
+ return 0
566
+
567
+
568
+ def cmd_db_import(args: argparse.Namespace) -> int:
569
+ """Import JSON data into MaxRefDB database"""
570
+ db_path = Path(args.database)
571
+ input_path = Path(args.input)
572
+
573
+ if not input_path.exists():
574
+ print(f"Input file not found: {input_path}", file=sys.stderr)
575
+ return 1
576
+
577
+ if not db_path.exists():
578
+ print(f"Database not found: {db_path}", file=sys.stderr)
579
+ print("Use 'db create --empty' to create a new database first", file=sys.stderr)
580
+ return 1
581
+
582
+ db = MaxRefDB(db_path, auto_populate=False)
583
+ initial_count = db.count
584
+
585
+ db.load(input_path)
586
+ added = db.count - initial_count
587
+ print(f"Imported {added} objects from {input_path} (total: {db.count})")
588
+ return 0
589
+
590
+
591
+ def cmd_db_cache(args: argparse.Namespace) -> int:
592
+ """Manage cache database"""
593
+ if args.cache_command == "location":
594
+ cache_dir = MaxRefDB.get_cache_dir()
595
+ db_path = MaxRefDB.get_default_db_path()
596
+ print(f"Cache directory: {cache_dir}")
597
+ print(f"Database path: {db_path}")
598
+ if db_path.exists():
599
+ db = MaxRefDB(auto_populate=False)
600
+ print(f"Status: Populated with {db.count} objects")
601
+ else:
602
+ print("Status: Not initialized")
603
+ return 0
604
+
605
+ elif args.cache_command == "init":
606
+ db_path = MaxRefDB.get_default_db_path()
607
+ if db_path.exists() and not args.force:
608
+ print(f"Cache already exists at {db_path}", file=sys.stderr)
609
+ print("Use --force to reinitialize", file=sys.stderr)
610
+ return 1
611
+
612
+ if db_path.exists():
613
+ db_path.unlink()
614
+
615
+ print(f"Initializing cache at {db_path}...")
616
+ db = MaxRefDB(auto_populate=True)
617
+ print(f"Cache initialized with {db.count} objects")
618
+ return 0
619
+
620
+ elif args.cache_command == "clear":
621
+ db_path = MaxRefDB.get_default_db_path()
622
+ if not db_path.exists():
623
+ print("Cache does not exist", file=sys.stderr)
624
+ return 1
625
+
626
+ if not args.force:
627
+ response = input(f"Delete cache at {db_path}? [y/N]: ")
628
+ if response.lower() != "y":
629
+ print("Cancelled")
630
+ return 0
631
+
632
+ db_path.unlink()
633
+ print(f"Cache cleared: {db_path}")
634
+ return 0
635
+
636
+ else:
637
+ print(f"Unknown cache subcommand: {args.cache_command}", file=sys.stderr)
638
+ return 1
639
+
640
+
641
+ def cmd_serve(args: argparse.Namespace) -> int:
642
+ """Start interactive WebSocket server for a patcher."""
643
+ import asyncio
644
+
645
+ input_path = Path(args.input)
646
+
647
+ if not input_path.exists():
648
+ print(f"Input file not found: {input_path}", file=sys.stderr)
649
+ return 1
650
+
651
+ # Check if websockets is installed
652
+ import importlib.util
653
+
654
+ if importlib.util.find_spec("websockets") is None:
655
+ print("Error: websockets package required for server.", file=sys.stderr)
656
+ print("Install with: pip install websockets", file=sys.stderr)
657
+ return 1
658
+
659
+ # Check if ptpython is installed (if --repl requested)
660
+ if args.repl:
661
+ if importlib.util.find_spec("ptpython") is None:
662
+ print("Error: ptpython package required for REPL.", file=sys.stderr)
663
+ print(
664
+ "Install with: pip install ptpython or uv add ptpython", file=sys.stderr
665
+ )
666
+ return 1
667
+
668
+ # Load patcher
669
+ patcher = Patcher.from_file(input_path)
670
+ _coerce_rect(patcher)
671
+
672
+ # Start interactive server
673
+ try:
674
+ print(f"Starting server for: {input_path}")
675
+ print(f"HTTP server: http://localhost:{args.port}")
676
+ print(f"WebSocket server: ws://localhost:{args.port + 1}")
677
+ print("Interactive editing enabled - changes sync bidirectionally")
678
+ if not args.no_save:
679
+ print(f"Auto-save enabled: changes will be saved to {input_path}")
680
+ if args.repl:
681
+ print("REPL mode enabled - type 'commands()' for help")
682
+ print("Press Ctrl+C to stop")
683
+
684
+ async def run_server():
685
+ # Check if using single-terminal mode (Option 2b)
686
+ if args.repl and args.log_file:
687
+ # Option 2b: Single terminal with log redirection
688
+ from .server.inline import start_background_server_repl
689
+
690
+ log_file_path = Path(args.log_file)
691
+ await start_background_server_repl(
692
+ patcher, port=args.port, log_file=log_file_path
693
+ )
694
+ return
695
+
696
+ # Option 2a: Client-server mode (default)
697
+ server = await patcher.serve(port=args.port, auto_open=not args.no_open)
698
+
699
+ # Start REPL server (always running, can connect remotely)
700
+ from .server.rpc import start_repl_server
701
+
702
+ repl_port = args.port + 2 # HTTP=8000, WS=8001, REPL=8002
703
+ repl_server = await start_repl_server(patcher, server, port=repl_port)
704
+
705
+ print()
706
+ print("=" * 70)
707
+ print("REPL server started")
708
+ print(f"Connect with: py2max repl localhost:{repl_port}")
709
+ print("=" * 70)
710
+ print()
711
+
712
+ if args.repl:
713
+ # Show deprecation warning if --repl used without --log-file
714
+ print("WARNING: --repl flag without --log-file is deprecated.")
715
+ print("For single-terminal mode, use: --repl --log-file server.log")
716
+ print(
717
+ "For client-server mode (recommended), in a separate terminal run:"
718
+ )
719
+ print(f" py2max repl localhost:{repl_port}")
720
+ print()
721
+
722
+ # Keep running until interrupted
723
+ try:
724
+ while True:
725
+ await asyncio.sleep(1)
726
+ except KeyboardInterrupt:
727
+ print("\nStopping server...")
728
+ repl_server.close()
729
+ await repl_server.wait_closed()
730
+ await server.stop()
731
+
732
+ asyncio.run(run_server())
733
+ return 0
734
+
735
+ except KeyboardInterrupt:
736
+ print("\nStopping server...")
737
+ return 0
738
+ except Exception as e:
739
+ print(f"Error starting server: {e}", file=sys.stderr)
740
+ import traceback
741
+
742
+ traceback.print_exc()
743
+ return 1
744
+
745
+
746
+ def cmd_preview(args: argparse.Namespace) -> int:
747
+ """Generate SVG preview of a patcher."""
748
+ import tempfile
749
+ import webbrowser
750
+
751
+ input_path = Path(args.input)
752
+
753
+ if not input_path.exists():
754
+ print(f"Input file not found: {input_path}", file=sys.stderr)
755
+ return 1
756
+
757
+ # Load patcher
758
+ patcher = Patcher.from_file(input_path)
759
+ _coerce_rect(patcher)
760
+
761
+ # Determine output path
762
+ if args.output:
763
+ output_path = Path(args.output)
764
+ else:
765
+ # Use temporary file
766
+ temp_dir = Path(tempfile.gettempdir())
767
+ output_path = temp_dir / f"{input_path.stem}_preview.svg"
768
+
769
+ # Export to SVG
770
+ try:
771
+ title = args.title or input_path.name
772
+ export_svg(
773
+ patcher,
774
+ output_path,
775
+ show_ports=args.show_ports,
776
+ title=title if not args.no_title else None,
777
+ )
778
+ print(f"SVG preview saved to: {output_path}")
779
+
780
+ # Open in browser if requested
781
+ if args.open:
782
+ print("Opening preview in browser...")
783
+ webbrowser.open(f"file://{output_path.absolute()}")
784
+
785
+ return 0
786
+
787
+ except Exception as e:
788
+ print(f"Error generating SVG preview: {e}", file=sys.stderr)
789
+ return 1
790
+
791
+
792
+ def cmd_repl(args: argparse.Namespace) -> int:
793
+ """Connect to remote REPL server."""
794
+ import asyncio
795
+
796
+ # Parse server address
797
+ server = args.server
798
+ if ":" in server:
799
+ host, port_str = server.rsplit(":", 1)
800
+ try:
801
+ port = int(port_str)
802
+ except ValueError:
803
+ print(f"Invalid port number: {port_str}", file=sys.stderr)
804
+ return 1
805
+ else:
806
+ host = server
807
+ port = 9000
808
+
809
+ # Check if websockets is installed
810
+ import importlib.util
811
+
812
+ if importlib.util.find_spec("websockets") is None:
813
+ print("Error: websockets package required for REPL client.", file=sys.stderr)
814
+ print("Install with: pip install websockets", file=sys.stderr)
815
+ return 1
816
+
817
+ # Check if ptpython is installed
818
+ if importlib.util.find_spec("ptpython") is None:
819
+ print("Error: ptpython package required for REPL.", file=sys.stderr)
820
+ print("Install with: pip install ptpython or uv add ptpython", file=sys.stderr)
821
+ return 1
822
+
823
+ # Start REPL client
824
+ try:
825
+ from .server.client import start_repl_client
826
+
827
+ return asyncio.run(start_repl_client(host, port))
828
+
829
+ except KeyboardInterrupt:
830
+ print("\nDisconnected.")
831
+ return 0
832
+ except Exception as e:
833
+ print(f"Error: {e}", file=sys.stderr)
834
+ return 1
835
+
836
+
837
+ def cmd_maxref(args: argparse.Namespace) -> int:
838
+ cache = MaxRefCache()
839
+
840
+ if args.list:
841
+ names = sorted(cache.refdict.keys())
842
+ for name in names:
843
+ print(name)
844
+ return 0
845
+
846
+ if args.info:
847
+ names = sorted(cache.refdict.keys())
848
+ for name in names:
849
+ info = cache.get_object_data(name) or {}
850
+ digest = info.get("digest", "")
851
+ print(f"{name}: {digest}")
852
+ return 0
853
+
854
+ if not args.name:
855
+ print("Please specify a Max object name (e.g. 'cycle~').", file=sys.stderr)
856
+ return 1
857
+
858
+ data_dict = cache.get_object_data(args.name)
859
+ if not data_dict:
860
+ print(f"Could not load maxref for '{args.name}'.", file=sys.stderr)
861
+ return 1
862
+ data: Dict[str, Any] = data_dict
863
+
864
+ if args.dict:
865
+ pprint(data)
866
+ return 0
867
+
868
+ if args.code:
869
+ _dump_code(args.name, data)
870
+ return 0
871
+
872
+ if args.json:
873
+ print(json.dumps(data, indent=2))
874
+ return 0
875
+
876
+ if args.test:
877
+ if args.output:
878
+ output_path = Path(args.output)
879
+ if output_path.parent:
880
+ output_path.parent.mkdir(parents=True, exist_ok=True)
881
+ output_path.write_text(
882
+ _generate_test_source(args.name, data), encoding="utf8"
883
+ )
884
+ print(f"Wrote test skeleton to {output_path}")
885
+ else:
886
+ _dump_tests(args.name, data)
887
+ return 0
888
+
889
+ if args.yaml:
890
+ if yaml is None:
891
+ print("PyYAML is not installed; cannot emit YAML output.", file=sys.stderr)
892
+ return 1
893
+ print(yaml.safe_dump(data))
894
+ return 0
895
+
896
+ digest = data.get("digest", "")
897
+ description = data.get("description", "")
898
+ print(f"{args.name}")
899
+ if digest:
900
+ print(f" Digest: {digest}")
901
+ if description:
902
+ print(" Description:")
903
+ formatted = fill(
904
+ description, width=76, initial_indent=" ", subsequent_indent=" "
905
+ )
906
+ for line in formatted.splitlines():
907
+ print(line)
908
+ return 0
909
+
910
+
911
+ def build_parser() -> argparse.ArgumentParser:
912
+ parser = argparse.ArgumentParser(
913
+ prog="py2max", description="Utilities for working with Max patchers."
914
+ )
915
+ subparsers = parser.add_subparsers(dest="command")
916
+
917
+ new_parser = subparsers.add_parser("new", help="Create a new patcher file")
918
+ new_parser.add_argument("path", help="Destination .maxpat path")
919
+ new_parser.add_argument("--title", help="Optional patcher title")
920
+ new_parser.add_argument("--layout", choices=LAYOUT_CHOICES, default="horizontal")
921
+ new_parser.add_argument(
922
+ "--flow-direction", choices=FLOW_CHOICES, default="horizontal"
923
+ )
924
+ new_parser.add_argument(
925
+ "--template",
926
+ choices=["blank", "stereo"],
927
+ default="blank",
928
+ help="Preset object layout to populate the patch",
929
+ )
930
+ new_parser.add_argument(
931
+ "--force", action="store_true", help="Overwrite existing files"
932
+ )
933
+ new_parser.set_defaults(func=cmd_new)
934
+
935
+ info_parser = subparsers.add_parser("info", help="Summarize an existing patcher")
936
+ info_parser.add_argument("path", help="Target .maxpat path")
937
+ info_parser.add_argument(
938
+ "--verbose", action="store_true", help="List every object in the patch"
939
+ )
940
+ info_parser.set_defaults(func=cmd_info)
941
+
942
+ opt_parser = subparsers.add_parser(
943
+ "optimize", help="Run layout optimization on a patch"
944
+ )
945
+ opt_parser.add_argument("input", help="Existing .maxpat file")
946
+ opt_parser.add_argument("-o", "--output", help="Output path (defaults to in-place)")
947
+ opt_parser.add_argument(
948
+ "--layout",
949
+ choices=LAYOUT_CHOICES,
950
+ help="Override layout manager before optimizing",
951
+ )
952
+ opt_parser.add_argument(
953
+ "--flow-direction",
954
+ choices=FLOW_CHOICES,
955
+ help="Set flow direction before optimizing",
956
+ )
957
+ opt_parser.set_defaults(func=cmd_optimize)
958
+
959
+ val_parser = subparsers.add_parser(
960
+ "validate", help="Validate patcher connections against maxref metadata"
961
+ )
962
+ val_parser.add_argument("path", help="Target .maxpat path")
963
+ val_parser.set_defaults(func=cmd_validate)
964
+
965
+ serve_parser = subparsers.add_parser(
966
+ "serve", help="Start interactive server with live preview"
967
+ )
968
+ serve_parser.add_argument("input", help="Input .maxpat file")
969
+ serve_parser.add_argument(
970
+ "--port",
971
+ type=int,
972
+ default=8000,
973
+ help="HTTP server port (default: 8000, WebSocket on port+1)",
974
+ )
975
+ serve_parser.add_argument(
976
+ "--no-open", action="store_true", help="Don't automatically open browser"
977
+ )
978
+ serve_parser.add_argument(
979
+ "--no-save", action="store_true", help="Disable auto-save on changes"
980
+ )
981
+ serve_parser.add_argument(
982
+ "--repl",
983
+ action="store_true",
984
+ help="Start interactive REPL for live patch editing",
985
+ )
986
+ serve_parser.add_argument(
987
+ "--log-file",
988
+ help="Redirect server logs to file (enables single-terminal REPL mode when used with --repl)",
989
+ )
990
+ serve_parser.set_defaults(func=cmd_serve)
991
+
992
+ preview_parser = subparsers.add_parser(
993
+ "preview", help="Generate SVG preview of a patcher"
994
+ )
995
+ preview_parser.add_argument("input", help="Input .maxpat file")
996
+ preview_parser.add_argument(
997
+ "-o", "--output", help="Output SVG file path (default: /tmp/<name>_preview.svg)"
998
+ )
999
+ preview_parser.add_argument("--title", help="Custom title for the SVG")
1000
+ preview_parser.add_argument(
1001
+ "--no-title", action="store_true", help="Don't show title in SVG"
1002
+ )
1003
+ preview_parser.add_argument(
1004
+ "--no-ports",
1005
+ dest="show_ports",
1006
+ action="store_false",
1007
+ default=True,
1008
+ help="Don't show inlet/outlet ports",
1009
+ )
1010
+ preview_parser.add_argument(
1011
+ "--open", action="store_true", help="Open preview in web browser"
1012
+ )
1013
+ preview_parser.set_defaults(func=cmd_preview)
1014
+
1015
+ transform_parser = subparsers.add_parser(
1016
+ "transform", help="Apply transformer pipeline to a patcher"
1017
+ )
1018
+ transform_parser.add_argument("input", nargs="?", help="Source .maxpat file")
1019
+ transform_parser.add_argument(
1020
+ "-o", "--output", help="Destination path (defaults to input)"
1021
+ )
1022
+ transform_parser.add_argument(
1023
+ "-t",
1024
+ "--apply",
1025
+ metavar="NAME[=VALUE]",
1026
+ action="append",
1027
+ help="Transformer to apply (may be specified multiple times)",
1028
+ )
1029
+ transform_parser.add_argument(
1030
+ "-l",
1031
+ "--list-transformers",
1032
+ action="store_true",
1033
+ dest="list_transformers",
1034
+ help="List available transformers and exit",
1035
+ )
1036
+ transform_parser.set_defaults(func=cmd_transform)
1037
+
1038
+ convert_parser = subparsers.add_parser(
1039
+ "convert", help="Convert between patch representations"
1040
+ )
1041
+ convert_sub = convert_parser.add_subparsers(dest="mode")
1042
+
1043
+ convert_mp_py = convert_sub.add_parser(
1044
+ "maxpat-to-python",
1045
+ help="Generate a Python script that recreates a .maxpat file",
1046
+ )
1047
+ convert_mp_py.add_argument("input", help="Source .maxpat file")
1048
+ convert_mp_py.add_argument("output", help="Destination Python file")
1049
+ convert_mp_py.add_argument(
1050
+ "--default-output",
1051
+ help="Default output path embedded in the generated script",
1052
+ )
1053
+ convert_mp_py.set_defaults(func=cmd_convert)
1054
+
1055
+ convert_maxref = convert_sub.add_parser(
1056
+ "maxref-to-sqlite",
1057
+ help="Cache maxref metadata into an SQLite database",
1058
+ )
1059
+ convert_maxref.add_argument(
1060
+ "--output",
1061
+ required=True,
1062
+ help="SQLite database path to write",
1063
+ )
1064
+ convert_maxref.add_argument(
1065
+ "--names",
1066
+ nargs="*",
1067
+ help="Optional list of object names to include (defaults to all)",
1068
+ )
1069
+ convert_maxref.add_argument(
1070
+ "--overwrite",
1071
+ action="store_true",
1072
+ help="Remove existing database before writing",
1073
+ )
1074
+ convert_maxref.set_defaults(func=cmd_convert)
1075
+
1076
+ maxref_parser = subparsers.add_parser(
1077
+ "maxref", help="Inspect Cycling '74 maxref metadata"
1078
+ )
1079
+ maxref_parser.add_argument(
1080
+ "name", nargs="?", help="Max object name (without .maxref.xml)"
1081
+ )
1082
+ maxref_parser.add_argument(
1083
+ "--dict", action="store_true", help="Dump parsed maxref as a Python dict"
1084
+ )
1085
+ maxref_parser.add_argument(
1086
+ "--json", action="store_true", help="Dump parsed maxref as JSON"
1087
+ )
1088
+ maxref_parser.add_argument(
1089
+ "--code", action="store_true", help="Generate a Python class outline"
1090
+ )
1091
+ maxref_parser.add_argument(
1092
+ "--test", action="store_true", help="Generate pytest skeletons"
1093
+ )
1094
+ maxref_parser.add_argument(
1095
+ "-o", "--output", help="Write generated code/tests to this file"
1096
+ )
1097
+ maxref_parser.add_argument(
1098
+ "--yaml",
1099
+ action="store_true",
1100
+ help="Dump parsed maxref as YAML (requires PyYAML)",
1101
+ )
1102
+ maxref_parser.add_argument(
1103
+ "--list", action="store_true", help="List all available maxref entries"
1104
+ )
1105
+ maxref_parser.add_argument(
1106
+ "--info", action="store_true", help="List all entries with their digests"
1107
+ )
1108
+ maxref_parser.set_defaults(func=cmd_maxref)
1109
+
1110
+ # Database (db) command
1111
+ db_parser = subparsers.add_parser("db", help="Manage MaxRefDB databases")
1112
+ db_subparsers = db_parser.add_subparsers(dest="db_command")
1113
+
1114
+ # db create
1115
+ db_create = db_subparsers.add_parser(
1116
+ "create", help="Create a new MaxRefDB database"
1117
+ )
1118
+ db_create.add_argument("database", help="Database file path (e.g., maxref.db)")
1119
+ db_create.add_argument(
1120
+ "--category",
1121
+ choices=["max", "msp", "jit", "m4l"],
1122
+ help="Populate with specific category",
1123
+ )
1124
+ db_create.add_argument(
1125
+ "--empty", action="store_true", help="Create empty database without populating"
1126
+ )
1127
+ db_create.add_argument(
1128
+ "--force", action="store_true", help="Overwrite existing database"
1129
+ )
1130
+ db_create.set_defaults(func=cmd_db)
1131
+
1132
+ # db populate
1133
+ db_populate = db_subparsers.add_parser(
1134
+ "populate", help="Populate existing database with objects"
1135
+ )
1136
+ db_populate.add_argument("database", help="Database file path")
1137
+ db_populate.add_argument(
1138
+ "--category",
1139
+ choices=["max", "msp", "jit", "m4l"],
1140
+ help="Populate with specific category",
1141
+ )
1142
+ db_populate.add_argument(
1143
+ "--objects", nargs="+", help="Specific object names to add"
1144
+ )
1145
+ db_populate.set_defaults(func=cmd_db)
1146
+
1147
+ # db info
1148
+ db_info = db_subparsers.add_parser("info", help="Show database information")
1149
+ db_info.add_argument("database", help="Database file path")
1150
+ db_info.add_argument("--summary", action="store_true", help="Show category summary")
1151
+ db_info.add_argument("--list", action="store_true", help="List all object names")
1152
+ db_info.add_argument(
1153
+ "--categories", action="store_true", help="List all categories"
1154
+ )
1155
+ db_info.set_defaults(func=cmd_db)
1156
+
1157
+ # db search
1158
+ db_search = db_subparsers.add_parser(
1159
+ "search", help="Search for objects in database"
1160
+ )
1161
+ db_search.add_argument("database", help="Database file path")
1162
+ db_search.add_argument("query", nargs="?", help="Search query")
1163
+ db_search.add_argument("--category", help="Search within specific category")
1164
+ db_search.add_argument(
1165
+ "--fields", help="Comma-separated fields to search (name,digest,description)"
1166
+ )
1167
+ db_search.add_argument(
1168
+ "-v", "--verbose", action="store_true", help="Show object digests"
1169
+ )
1170
+ db_search.set_defaults(func=cmd_db)
1171
+
1172
+ # db query
1173
+ db_query = db_subparsers.add_parser("query", help="Get detailed object information")
1174
+ db_query.add_argument("database", help="Database file path")
1175
+ db_query.add_argument("name", help="Object name to query")
1176
+ db_query.add_argument("--json", action="store_true", help="Output as JSON")
1177
+ db_query.add_argument("--dict", action="store_true", help="Output as Python dict")
1178
+ db_query.set_defaults(func=cmd_db)
1179
+
1180
+ # db export
1181
+ db_export = db_subparsers.add_parser("export", help="Export database to JSON")
1182
+ db_export.add_argument("database", help="Database file path")
1183
+ db_export.add_argument("output", help="Output JSON file path")
1184
+ db_export.add_argument(
1185
+ "--force", action="store_true", help="Overwrite existing output file"
1186
+ )
1187
+ db_export.set_defaults(func=cmd_db)
1188
+
1189
+ # db import
1190
+ db_import = db_subparsers.add_parser(
1191
+ "import", help="Import JSON data into database"
1192
+ )
1193
+ db_import.add_argument("database", help="Database file path")
1194
+ db_import.add_argument("input", help="Input JSON file path")
1195
+ db_import.set_defaults(func=cmd_db)
1196
+
1197
+ # db cache
1198
+ db_cache = db_subparsers.add_parser("cache", help="Manage cache database")
1199
+ cache_subparsers = db_cache.add_subparsers(dest="cache_command")
1200
+
1201
+ # db cache location
1202
+ cache_location = cache_subparsers.add_parser(
1203
+ "location", help="Show cache location and status"
1204
+ )
1205
+ cache_location.set_defaults(func=cmd_db)
1206
+
1207
+ # db cache init
1208
+ cache_init = cache_subparsers.add_parser(
1209
+ "init", help="Initialize/reinitialize cache"
1210
+ )
1211
+ cache_init.add_argument(
1212
+ "--force", action="store_true", help="Force reinitialize existing cache"
1213
+ )
1214
+ cache_init.set_defaults(func=cmd_db)
1215
+
1216
+ # db cache clear
1217
+ cache_clear = cache_subparsers.add_parser("clear", help="Clear cache database")
1218
+ cache_clear.add_argument(
1219
+ "--force", action="store_true", help="Skip confirmation prompt"
1220
+ )
1221
+ cache_clear.set_defaults(func=cmd_db)
1222
+
1223
+ # REPL client command
1224
+ repl_parser = subparsers.add_parser("repl", help="Connect to remote REPL server")
1225
+ repl_parser.add_argument(
1226
+ "server",
1227
+ nargs="?",
1228
+ default="localhost:9000",
1229
+ help="Server address (default: localhost:9000)",
1230
+ )
1231
+ repl_parser.set_defaults(func=cmd_repl)
1232
+
1233
+ return parser
1234
+
1235
+
1236
+ def main(argv: List[str] | None = None) -> int:
1237
+ parser = build_parser()
1238
+ args = parser.parse_args(argv)
1239
+
1240
+ if not hasattr(args, "func"):
1241
+ parser.print_help()
1242
+ return 1
1243
+
1244
+ try:
1245
+ return args.func(args)
1246
+ except InvalidConnectionError as exc:
1247
+ print(f"Error: {exc}", file=sys.stderr)
1248
+ return 1
1249
+
1250
+
1251
+ __all__ = ["main"]