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,997 @@
1
+ """Interactive WebSocket server for py2max with bidirectional communication.
2
+
3
+ This module provides a WebSocket-based server for real-time interactive
4
+ editing of Max patches in the browser.
5
+
6
+ Features:
7
+ - Bidirectional WebSocket communication
8
+ - Real-time updates (Python → Browser)
9
+ - Interactive editing (Browser → Python)
10
+ - Drag-and-drop object repositioning
11
+ - Connection drawing
12
+ - Object creation from browser
13
+ - Token-based authentication for WebSocket connections
14
+
15
+ Example:
16
+ >>> from py2max import Patcher
17
+ >>> p = Patcher('demo.maxpat')
18
+ >>> await p.serve_interactive() # Opens browser with interactive editor
19
+ >>> # Edit in browser - changes sync back to Python!
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import http.server
26
+ import json
27
+ import secrets
28
+ import threading
29
+ import webbrowser
30
+ from pathlib import Path
31
+ from typing import TYPE_CHECKING, Any, Optional, Set
32
+
33
+ try:
34
+ import websockets
35
+ from websockets.asyncio.server import Server, ServerConnection, serve
36
+ except ImportError:
37
+ raise ImportError(
38
+ "websockets package required for interactive server. "
39
+ "Install with: pip install websockets"
40
+ )
41
+
42
+ if TYPE_CHECKING:
43
+ from ..core import Patcher
44
+
45
+
46
+ # Message validation schemas
47
+ # Each schema defines required fields and their expected types
48
+ # Values can be a single type or a tuple of types for isinstance() checks
49
+ MESSAGE_SCHEMAS: dict[str, dict[str, type | tuple[type, ...]]] = {
50
+ "update_position": {"box_id": str, "x": (int, float), "y": (int, float)},
51
+ "create_object": {"text": str}, # x, y optional
52
+ "create_connection": {
53
+ "src_id": str,
54
+ "dst_id": str,
55
+ }, # src_outlet, dst_inlet optional
56
+ "delete_object": {"box_id": str},
57
+ "delete_connection": {"src_id": str, "dst_id": str},
58
+ "save": {}, # No required fields
59
+ "save_as": {"filepath": str},
60
+ "navigate_to_subpatcher": {"box_id": str},
61
+ "navigate_to_parent": {},
62
+ "navigate_to_root": {},
63
+ }
64
+
65
+ # Maximum lengths for string fields to prevent abuse
66
+ MAX_STRING_LENGTHS: dict[str, int] = {
67
+ "box_id": 256,
68
+ "src_id": 256,
69
+ "dst_id": 256,
70
+ "text": 10000, # Max object text length
71
+ "filepath": 4096, # Max filepath length
72
+ }
73
+
74
+ # Coordinate bounds
75
+ COORDINATE_BOUNDS = {"min": -100000, "max": 100000}
76
+
77
+
78
+ class ValidationError(Exception):
79
+ """Raised when message validation fails."""
80
+
81
+ pass
82
+
83
+
84
+ def validate_message(data: dict) -> tuple[bool, Optional[str]]:
85
+ """Validate an incoming WebSocket message.
86
+
87
+ Args:
88
+ data: The parsed message data
89
+
90
+ Returns:
91
+ Tuple of (is_valid, error_message)
92
+ """
93
+ message_type = data.get("type")
94
+
95
+ # Check message type exists and is known
96
+ if not message_type:
97
+ return False, "Missing 'type' field"
98
+
99
+ if not isinstance(message_type, str):
100
+ return False, "'type' must be a string"
101
+
102
+ if message_type not in MESSAGE_SCHEMAS:
103
+ return False, f"Unknown message type: {message_type}"
104
+
105
+ schema = MESSAGE_SCHEMAS[message_type]
106
+
107
+ # Check required fields
108
+ for field, expected_type in schema.items():
109
+ if field not in data:
110
+ return False, f"Missing required field: {field}"
111
+
112
+ value = data[field]
113
+
114
+ # Check type (expected_type can be a single type or tuple of types)
115
+ if isinstance(expected_type, tuple):
116
+ if not isinstance(value, expected_type):
117
+ type_names = " or ".join(t.__name__ for t in expected_type)
118
+ return False, f"Field '{field}' must be {type_names}"
119
+ else:
120
+ if not isinstance(value, expected_type):
121
+ return False, f"Field '{field}' must be {expected_type.__name__}"
122
+
123
+ # String length validation
124
+ if isinstance(value, str) and field in MAX_STRING_LENGTHS:
125
+ if len(value) > MAX_STRING_LENGTHS[field]:
126
+ return (
127
+ False,
128
+ f"Field '{field}' exceeds max length ({MAX_STRING_LENGTHS[field]})",
129
+ )
130
+
131
+ # Check for null bytes or control characters (except newline, tab)
132
+ if any(ord(c) < 32 and c not in "\n\t\r" for c in value):
133
+ return False, f"Field '{field}' contains invalid control characters"
134
+
135
+ # Coordinate bounds validation
136
+ if field in ("x", "y") and isinstance(value, (int, float)):
137
+ if value < COORDINATE_BOUNDS["min"] or value > COORDINATE_BOUNDS["max"]:
138
+ return (
139
+ False,
140
+ f"Field '{field}' out of bounds ({COORDINATE_BOUNDS['min']} to {COORDINATE_BOUNDS['max']})",
141
+ )
142
+
143
+ # Validate optional fields if present
144
+ if message_type == "update_position":
145
+ # x and y are required and already validated above
146
+ pass
147
+
148
+ if message_type == "create_object":
149
+ # Validate optional x, y if present
150
+ for field in ("x", "y"):
151
+ if field in data:
152
+ value = data[field]
153
+ if not isinstance(value, (int, float)):
154
+ return False, f"Optional field '{field}' must be a number"
155
+ if value < COORDINATE_BOUNDS["min"] or value > COORDINATE_BOUNDS["max"]:
156
+ return False, f"Optional field '{field}' out of bounds"
157
+
158
+ if message_type == "create_connection":
159
+ # Validate optional outlet/inlet indices
160
+ for field in ("src_outlet", "dst_inlet"):
161
+ if field in data:
162
+ value = data[field]
163
+ if not isinstance(value, int):
164
+ return False, f"Optional field '{field}' must be an integer"
165
+ if value < 0 or value > 255: # Max 256 inlets/outlets
166
+ return False, f"Optional field '{field}' out of range (0-255)"
167
+
168
+ if message_type == "delete_connection":
169
+ for field in ("src_outlet", "dst_inlet"):
170
+ if field in data:
171
+ value = data[field]
172
+ if not isinstance(value, int):
173
+ return False, f"Optional field '{field}' must be an integer"
174
+ if value < 0 or value > 255:
175
+ return False, f"Optional field '{field}' out of range (0-255)"
176
+
177
+ return True, None
178
+
179
+
180
+ def get_patcher_state_json(patcher: Optional["Patcher"]) -> dict:
181
+ """Convert patcher to JSON state for browser.
182
+
183
+ Args:
184
+ patcher: The patcher to convert
185
+
186
+ Returns:
187
+ Dictionary with boxes and lines data
188
+ """
189
+ if not patcher:
190
+ return {
191
+ "type": "update",
192
+ "boxes": [],
193
+ "lines": [],
194
+ "patcher_path": [],
195
+ "patcher_title": "Untitled",
196
+ }
197
+
198
+ boxes = []
199
+ for box in patcher._boxes:
200
+ box_data = {
201
+ "id": getattr(box, "id", ""),
202
+ "text": getattr(box, "text", ""),
203
+ "maxclass": getattr(box, "maxclass", "newobj"),
204
+ "patching_rect": {"x": 0, "y": 0, "w": 100, "h": 22},
205
+ }
206
+
207
+ # Check if box has a subpatcher
208
+ has_patcher = hasattr(box, "subpatcher") and box.subpatcher is not None
209
+ box_data["has_subpatcher"] = has_patcher
210
+
211
+ # Get patching_rect
212
+ rect = getattr(box, "patching_rect", None)
213
+ if rect:
214
+ if hasattr(rect, "x"):
215
+ box_data["patching_rect"] = {
216
+ "x": rect.x,
217
+ "y": rect.y,
218
+ "w": rect.w,
219
+ "h": rect.h,
220
+ }
221
+ elif isinstance(rect, (list, tuple)) and len(rect) >= 4:
222
+ box_data["patching_rect"] = {
223
+ "x": rect[0],
224
+ "y": rect[1],
225
+ "w": rect[2],
226
+ "h": rect[3],
227
+ }
228
+
229
+ # Get inlet/outlet counts
230
+ inlet_count = 0
231
+ outlet_count = 0
232
+
233
+ # Try get_inlet_count() method first
234
+ if hasattr(box, "get_inlet_count"):
235
+ try:
236
+ inlet_count = box.get_inlet_count()
237
+ except Exception:
238
+ pass
239
+
240
+ # If get_inlet_count() returned None, try numinlets attribute (from loaded files)
241
+ if inlet_count is None and hasattr(box, "numinlets"):
242
+ inlet_count = getattr(box, "numinlets", 0)
243
+
244
+ box_data["inlet_count"] = inlet_count or 0
245
+
246
+ # Try get_outlet_count() method first
247
+ if hasattr(box, "get_outlet_count"):
248
+ try:
249
+ outlet_count = box.get_outlet_count()
250
+ except Exception:
251
+ pass
252
+
253
+ # If get_outlet_count() returned None, try numoutlets attribute (from loaded files)
254
+ if outlet_count is None and hasattr(box, "numoutlets"):
255
+ outlet_count = getattr(box, "numoutlets", 0)
256
+
257
+ box_data["outlet_count"] = outlet_count or 0
258
+
259
+ boxes.append(box_data)
260
+
261
+ lines = []
262
+ for line in patcher._lines:
263
+ line_data = {
264
+ "src": getattr(line, "src", ""),
265
+ "dst": getattr(line, "dst", ""),
266
+ "src_outlet": 0,
267
+ "dst_inlet": 0,
268
+ }
269
+
270
+ source = getattr(line, "source", None)
271
+ if source and len(source) > 1:
272
+ line_data["src_outlet"] = source[1]
273
+
274
+ destination = getattr(line, "destination", None)
275
+ if destination and len(destination) > 1:
276
+ line_data["dst_inlet"] = destination[1]
277
+
278
+ lines.append(line_data)
279
+
280
+ # Build patcher path (breadcrumb trail)
281
+ patcher_path: list[str] = []
282
+ current: Any = patcher
283
+ while current:
284
+ title = getattr(current, "title", None) or "Main"
285
+ patcher_path.insert(0, title)
286
+ current = getattr(current, "_parent", None)
287
+
288
+ # Get current patcher title
289
+ patcher_title = getattr(patcher, "title", None) or getattr(
290
+ patcher, "_path", "Untitled"
291
+ )
292
+ if patcher_title and hasattr(patcher_title, "name"):
293
+ patcher_title = patcher_title.name
294
+
295
+ # Get filepath for save/save-as logic
296
+ filepath = None
297
+ if hasattr(patcher, "filepath") and patcher.filepath:
298
+ filepath = str(patcher.filepath)
299
+ elif hasattr(patcher, "_path") and patcher._path:
300
+ filepath = str(patcher._path)
301
+
302
+ return {
303
+ "type": "update",
304
+ "boxes": boxes,
305
+ "lines": lines,
306
+ "patcher_path": patcher_path,
307
+ "patcher_title": str(patcher_title),
308
+ "filepath": filepath,
309
+ }
310
+
311
+
312
+ class InteractiveHTTPHandler(http.server.SimpleHTTPRequestHandler):
313
+ """HTTP handler for serving static files with token injection."""
314
+
315
+ # Class variable to store the session token
316
+ session_token: Optional[str] = None
317
+
318
+ def __init__(self, *args, **kwargs):
319
+ # Static files are in py2max/static/, not py2max/server/static/
320
+ static_dir = Path(__file__).parent.parent / "static"
321
+ super().__init__(*args, directory=str(static_dir), **kwargs)
322
+
323
+ def do_GET(self):
324
+ """Handle GET requests."""
325
+ if self.path == "/" or self.path == "/index.html":
326
+ self.serve_interactive_html()
327
+ else:
328
+ super().do_GET()
329
+
330
+ def serve_interactive_html(self):
331
+ """Serve the interactive editor HTML with injected session token."""
332
+ # Static files are in py2max/static/, not py2max/server/static/
333
+ html_file = Path(__file__).parent.parent / "static" / "interactive.html"
334
+ if html_file.exists():
335
+ html_content = html_file.read_text(encoding="utf-8")
336
+
337
+ # Inject session token into HTML
338
+ if self.session_token:
339
+ token_script = f'<script>window.PY2MAX_SESSION_TOKEN = "{self.session_token}";</script>'
340
+ # Inject before </head> or at the beginning of <body>
341
+ if "</head>" in html_content:
342
+ html_content = html_content.replace(
343
+ "</head>", f"{token_script}\n</head>"
344
+ )
345
+ else:
346
+ html_content = token_script + html_content
347
+
348
+ self.send_response(200)
349
+ self.send_header("Content-Type", "text/html; charset=utf-8")
350
+ # Add security headers
351
+ self.send_header("X-Content-Type-Options", "nosniff")
352
+ self.send_header("X-Frame-Options", "DENY")
353
+ self.send_header("X-XSS-Protection", "1; mode=block")
354
+ self.end_headers()
355
+ self.wfile.write(html_content.encode("utf-8"))
356
+ else:
357
+ self.send_error(404, "interactive.html not found")
358
+
359
+ def log_message(self, format, *args):
360
+ """Suppress logging."""
361
+ pass
362
+
363
+
364
+ class InteractiveWebSocketHandler:
365
+ """WebSocket handler for interactive patcher editing with authentication."""
366
+
367
+ def __init__(self, patcher: Optional["Patcher"], auto_save: bool = False):
368
+ self.root_patcher = patcher # Keep reference to root patcher
369
+ self.patcher = patcher # Current patcher being viewed
370
+ self.clients: Set[ServerConnection] = set()
371
+ self._lock = asyncio.Lock()
372
+ self._save_task: Optional[asyncio.Task[Any]] = None # Track pending save task
373
+ self.auto_save = auto_save # Auto-save configuration
374
+ # Generate a secure session token
375
+ self.session_token = secrets.token_urlsafe(32)
376
+ print(f"WebSocket session token: {self.session_token}")
377
+
378
+ def verify_token(self, token: str) -> bool:
379
+ """Verify authentication token using constant-time comparison.
380
+
381
+ Args:
382
+ token: The token to verify
383
+
384
+ Returns:
385
+ True if token is valid, False otherwise
386
+ """
387
+ if not token or not self.session_token:
388
+ return False
389
+ # Use constant-time comparison to prevent timing attacks
390
+ return secrets.compare_digest(token, self.session_token)
391
+
392
+ async def register(self, websocket: ServerConnection):
393
+ """Register a new client connection."""
394
+ async with self._lock:
395
+ self.clients.add(websocket)
396
+ print(f"Client connected. Total clients: {len(self.clients)}")
397
+
398
+ async def unregister(self, websocket: ServerConnection):
399
+ """Unregister a client connection."""
400
+ async with self._lock:
401
+ self.clients.discard(websocket)
402
+ print(f"Client disconnected. Total clients: {len(self.clients)}")
403
+
404
+ async def broadcast(self, message: dict):
405
+ """Broadcast message to all connected clients."""
406
+ if not self.clients:
407
+ return
408
+
409
+ message_str = json.dumps(message)
410
+ async with self._lock:
411
+ # Send to all clients concurrently
412
+ await asyncio.gather(
413
+ *[client.send(message_str) for client in self.clients],
414
+ return_exceptions=True,
415
+ )
416
+
417
+ async def handle_client(self, websocket: ServerConnection):
418
+ """Handle a client WebSocket connection with authentication."""
419
+ try:
420
+ # First message must be authentication token
421
+ auth_message = await asyncio.wait_for(websocket.recv(), timeout=5.0)
422
+ auth_str = (
423
+ auth_message.decode("utf-8")
424
+ if isinstance(auth_message, bytes)
425
+ else auth_message
426
+ )
427
+
428
+ try:
429
+ auth_data = json.loads(auth_str)
430
+ if auth_data.get("type") != "auth" or not self.verify_token(
431
+ auth_data.get("token", "")
432
+ ):
433
+ await websocket.send(
434
+ json.dumps(
435
+ {"type": "error", "message": "Authentication failed"}
436
+ )
437
+ )
438
+ await websocket.close(1008, "Unauthorized")
439
+ print("Client authentication failed")
440
+ return
441
+ except (json.JSONDecodeError, KeyError):
442
+ await websocket.send(
443
+ json.dumps(
444
+ {"type": "error", "message": "Invalid authentication message"}
445
+ )
446
+ )
447
+ await websocket.close(1008, "Invalid auth format")
448
+ print("Client sent invalid authentication message")
449
+ return
450
+
451
+ # Authentication successful
452
+ await self.register(websocket)
453
+ await websocket.send(json.dumps({"type": "auth_success"}))
454
+ print("Client authenticated successfully")
455
+
456
+ # Send initial state
457
+ if self.patcher:
458
+ state = get_patcher_state_json(self.patcher)
459
+ await websocket.send(json.dumps(state))
460
+
461
+ # Listen for messages from client
462
+ async for message in websocket:
463
+ msg_str = (
464
+ message.decode("utf-8") if isinstance(message, bytes) else message
465
+ )
466
+ await self.handle_message(websocket, msg_str)
467
+
468
+ except asyncio.TimeoutError:
469
+ print("Client authentication timeout")
470
+ await websocket.close(1008, "Authentication timeout")
471
+ except websockets.exceptions.ConnectionClosed:
472
+ pass
473
+ finally:
474
+ await self.unregister(websocket)
475
+
476
+ async def handle_message(self, websocket: ServerConnection, message: str):
477
+ """Handle incoming message from client."""
478
+ try:
479
+ data = json.loads(message)
480
+
481
+ # Validate message before processing
482
+ is_valid, error_msg = validate_message(data)
483
+ if not is_valid:
484
+ print(f"Message validation failed: {error_msg}")
485
+ await websocket.send(
486
+ json.dumps({"type": "error", "message": f"Validation error: {error_msg}"})
487
+ )
488
+ return
489
+
490
+ message_type = data.get("type")
491
+
492
+ if message_type == "update_position":
493
+ await self.handle_update_position(data)
494
+ elif message_type == "create_object":
495
+ await self.handle_create_object(data)
496
+ elif message_type == "create_connection":
497
+ await self.handle_create_connection(data)
498
+ elif message_type == "delete_object":
499
+ await self.handle_delete_object(data)
500
+ elif message_type == "delete_connection":
501
+ await self.handle_delete_connection(data)
502
+ elif message_type == "save":
503
+ await self.handle_save()
504
+ elif message_type == "save_as":
505
+ await self.handle_save_as(data)
506
+ elif message_type == "navigate_to_subpatcher":
507
+ await self.handle_navigate_to_subpatcher(data)
508
+ elif message_type == "navigate_to_parent":
509
+ await self.handle_navigate_to_parent()
510
+ elif message_type == "navigate_to_root":
511
+ await self.handle_navigate_to_root()
512
+
513
+ except json.JSONDecodeError as e:
514
+ print(f"Invalid JSON: {e}")
515
+ await websocket.send(
516
+ json.dumps({"type": "error", "message": "Invalid JSON format"})
517
+ )
518
+ except Exception as e:
519
+ print(f"Error handling message: {e}")
520
+ await websocket.send(
521
+ json.dumps({"type": "error", "message": f"Server error: {str(e)}"})
522
+ )
523
+
524
+ async def handle_update_position(self, data: dict):
525
+ """Handle object position update from browser."""
526
+ if not self.patcher:
527
+ return
528
+
529
+ box_id = data.get("box_id")
530
+ x = data.get("x")
531
+ y = data.get("y")
532
+
533
+ # Find box and update position
534
+ for box in self.patcher._boxes:
535
+ if box.id == box_id:
536
+ # Update position
537
+ if hasattr(box, "patching_rect"):
538
+ rect = box.patching_rect
539
+ if hasattr(rect, "x"):
540
+ # Rect is a NamedTuple (immutable), create new one
541
+ from ..core.common import Rect
542
+
543
+ box.patching_rect = Rect(
544
+ float(x) if x is not None else 0.0,
545
+ float(y) if y is not None else 0.0,
546
+ rect.w,
547
+ rect.h,
548
+ )
549
+ elif isinstance(rect, list):
550
+ rect[0] = x
551
+ rect[1] = y
552
+
553
+ # Send delta update instead of full state (performance optimization)
554
+ await self.broadcast(
555
+ {
556
+ "type": "position_update",
557
+ "box_id": box_id,
558
+ "x": float(x) if x is not None else 0.0,
559
+ "y": float(y) if y is not None else 0.0,
560
+ }
561
+ )
562
+
563
+ # Schedule debounced save
564
+ await self.schedule_save()
565
+ break
566
+
567
+ async def schedule_save(self):
568
+ """Schedule a debounced save after 2 seconds of no updates (if auto-save enabled)."""
569
+ if not self.auto_save:
570
+ return # Auto-save disabled
571
+
572
+ # Cancel previous save task if exists
573
+ if self._save_task and not self._save_task.done():
574
+ self._save_task.cancel()
575
+
576
+ # Schedule new save task
577
+ self._save_task = asyncio.create_task(self._debounced_save())
578
+
579
+ async def _debounced_save(self):
580
+ """Save patch after delay (debounced)."""
581
+ try:
582
+ await asyncio.sleep(2.0) # Wait 2 seconds
583
+ if (
584
+ self.patcher
585
+ and hasattr(self.patcher, "filepath")
586
+ and self.patcher.filepath
587
+ ):
588
+ self.patcher.save()
589
+ print(f"Auto-saved: {self.patcher.filepath}")
590
+ except asyncio.CancelledError:
591
+ # Task was cancelled (new position update came in)
592
+ pass
593
+ except Exception as e:
594
+ print(f"Error during auto-save: {e}")
595
+
596
+ async def handle_create_object(self, data: dict):
597
+ """Handle object creation from browser."""
598
+ if not self.patcher:
599
+ return
600
+
601
+ text = data.get("text", "newobj")
602
+ x = data.get("x", 100)
603
+ y = data.get("y", 100)
604
+
605
+ # Create new object
606
+ box = self.patcher.add_textbox(text)
607
+
608
+ # Set position
609
+ if hasattr(box, "patching_rect"):
610
+ rect = box.patching_rect
611
+ if hasattr(rect, "x"):
612
+ # Rect is a NamedTuple (immutable), create new one
613
+ from ..core.common import Rect
614
+
615
+ box.patching_rect = Rect(x, y, rect.w, rect.h)
616
+ elif isinstance(rect, list):
617
+ rect[0] = x
618
+ rect[1] = y
619
+
620
+ # Broadcast update to all clients
621
+ state = get_patcher_state_json(self.patcher)
622
+ await self.broadcast(state)
623
+
624
+ # Schedule auto-save
625
+ await self.schedule_save()
626
+
627
+ async def handle_create_connection(self, data: dict):
628
+ """Handle connection creation from browser."""
629
+ if not self.patcher:
630
+ return
631
+
632
+ src_id = data.get("src_id")
633
+ dst_id = data.get("dst_id")
634
+ src_outlet = data.get("src_outlet", 0)
635
+ dst_inlet = data.get("dst_inlet", 0)
636
+
637
+ # Find source and destination boxes
638
+ src_box = None
639
+ dst_box = None
640
+
641
+ for box in self.patcher._boxes:
642
+ if box.id == src_id:
643
+ src_box = box
644
+ if box.id == dst_id:
645
+ dst_box = box
646
+
647
+ if src_box and dst_box:
648
+ # Create connection
649
+ self.patcher.add_line(src_box, dst_box, outlet=src_outlet, inlet=dst_inlet) # type: ignore[arg-type]
650
+
651
+ # Broadcast update to all clients
652
+ state = get_patcher_state_json(self.patcher)
653
+ await self.broadcast(state)
654
+
655
+ # Schedule auto-save
656
+ await self.schedule_save()
657
+
658
+ async def handle_delete_object(self, data: dict):
659
+ """Handle object deletion from browser."""
660
+ if not self.patcher:
661
+ return
662
+
663
+ box_id = data.get("box_id")
664
+
665
+ # Find and remove box
666
+ for i, box in enumerate(self.patcher._boxes):
667
+ if box.id == box_id:
668
+ self.patcher._boxes.pop(i)
669
+
670
+ # Also remove any connected lines
671
+ self.patcher._lines = [
672
+ line
673
+ for line in self.patcher._lines
674
+ if line.src != box_id and line.dst != box_id
675
+ ]
676
+
677
+ # Broadcast update to all clients
678
+ state = get_patcher_state_json(self.patcher)
679
+ await self.broadcast(state)
680
+
681
+ # Schedule auto-save
682
+ await self.schedule_save()
683
+ break
684
+
685
+ async def handle_delete_connection(self, data: dict):
686
+ """Handle connection deletion from browser."""
687
+ if not self.patcher:
688
+ return
689
+
690
+ src_id = data.get("src_id")
691
+ dst_id = data.get("dst_id")
692
+ src_outlet = data.get("src_outlet", 0)
693
+ dst_inlet = data.get("dst_inlet", 0)
694
+
695
+ # Find and remove matching line
696
+ for i, line in enumerate(self.patcher._lines):
697
+ source = getattr(line, "source", [None, 0])
698
+ destination = getattr(line, "destination", [None, 0])
699
+ if (
700
+ line.src == src_id
701
+ and line.dst == dst_id
702
+ and source[1] == src_outlet
703
+ and destination[1] == dst_inlet
704
+ ):
705
+ self.patcher._lines.pop(i)
706
+
707
+ # Broadcast update to all clients
708
+ state = get_patcher_state_json(self.patcher)
709
+ await self.broadcast(state)
710
+
711
+ # Schedule auto-save
712
+ await self.schedule_save()
713
+ break
714
+
715
+ async def handle_save(self):
716
+ """Handle manual save request from browser."""
717
+ if not self.root_patcher: # Save root patcher
718
+ return
719
+
720
+ try:
721
+ if hasattr(self.root_patcher, "filepath") and self.root_patcher.filepath:
722
+ self.root_patcher.save()
723
+ print(f"Saved: {self.root_patcher.filepath}")
724
+
725
+ # Notify clients that save completed
726
+ await self.broadcast(
727
+ {
728
+ "type": "save_complete",
729
+ "filepath": str(self.root_patcher.filepath),
730
+ }
731
+ )
732
+ else:
733
+ # No filepath set - request filename from client
734
+ print("No filepath set, requesting filename from client")
735
+ await self.broadcast(
736
+ {"type": "save_as_required", "message": "Please enter a filename"}
737
+ )
738
+ except Exception as e:
739
+ print(f"Error saving: {e}")
740
+ await self.broadcast({"type": "save_error", "message": str(e)})
741
+
742
+ async def handle_save_as(self, data: dict):
743
+ """Handle save-as request with specified filepath."""
744
+ if not self.root_patcher:
745
+ return
746
+
747
+ filepath = data.get("filepath", "")
748
+ if not filepath:
749
+ await self.broadcast(
750
+ {"type": "save_error", "message": "No filepath provided"}
751
+ )
752
+ return
753
+
754
+ try:
755
+ # Ensure .maxpat extension
756
+ if not filepath.endswith((".maxpat", ".maxhelp", ".rbnopat")):
757
+ filepath = filepath + ".maxpat"
758
+
759
+ # Set the filepath on the patcher
760
+ from pathlib import Path
761
+
762
+ self.root_patcher._path = Path(filepath)
763
+
764
+ # Save the patcher
765
+ self.root_patcher.save()
766
+ print(f"Saved as: {filepath}")
767
+
768
+ # Notify clients that save completed
769
+ await self.broadcast(
770
+ {
771
+ "type": "save_complete",
772
+ "filepath": filepath,
773
+ }
774
+ )
775
+
776
+ # Also update the state to include the new filepath
777
+ state = get_patcher_state_json(self.patcher)
778
+ state["filepath"] = filepath
779
+ await self.broadcast(state)
780
+
781
+ except Exception as e:
782
+ print(f"Error saving: {e}")
783
+ await self.broadcast({"type": "save_error", "message": str(e)})
784
+
785
+ async def handle_navigate_to_subpatcher(self, data: dict):
786
+ """Handle navigation to a subpatcher."""
787
+ if not self.patcher:
788
+ return
789
+
790
+ box_id = data.get("box_id")
791
+ if not box_id:
792
+ return
793
+
794
+ # Find box with matching ID
795
+ for box in self.patcher._boxes:
796
+ if box.id == box_id:
797
+ # Check if box has a subpatcher
798
+ if hasattr(box, "subpatcher") and box.subpatcher is not None:
799
+ # Navigate to subpatcher
800
+ self.patcher = box.subpatcher
801
+ box_text = getattr(box, "text", box.id)
802
+ print(f"Navigated to subpatcher: {box_text}")
803
+
804
+ # Send updated state to all clients
805
+ state = get_patcher_state_json(self.patcher)
806
+ await self.broadcast(state)
807
+ break
808
+
809
+ async def handle_navigate_to_parent(self):
810
+ """Handle navigation to parent patcher."""
811
+ if not self.patcher:
812
+ return
813
+
814
+ # Get parent patcher
815
+ parent = getattr(self.patcher, "_parent", None)
816
+ if parent:
817
+ self.patcher = parent
818
+ print("Navigated to parent patcher")
819
+
820
+ # Send updated state to all clients
821
+ state = get_patcher_state_json(self.patcher)
822
+ await self.broadcast(state)
823
+ else:
824
+ print("Already at root patcher")
825
+
826
+ async def handle_navigate_to_root(self):
827
+ """Handle navigation to root patcher."""
828
+ if not self.root_patcher:
829
+ return
830
+
831
+ self.patcher = self.root_patcher
832
+ print("Navigated to root patcher")
833
+
834
+ # Send updated state to all clients
835
+ state = get_patcher_state_json(self.patcher)
836
+ await self.broadcast(state)
837
+
838
+ async def notify_update(self):
839
+ """Notify all clients of a patcher update."""
840
+ if self.patcher:
841
+ state = get_patcher_state_json(self.patcher)
842
+ await self.broadcast(state)
843
+
844
+
845
+ class InteractivePatcherServer:
846
+ """WebSocket server for interactive patcher editing.
847
+
848
+ Can be used as a context manager for automatic cleanup:
849
+
850
+ >>> async with p.serve_interactive() as server:
851
+ ... await asyncio.sleep(10) # Server runs
852
+ # Server automatically stopped
853
+ """
854
+
855
+ def __init__(
856
+ self,
857
+ patcher: "Patcher",
858
+ port: int = 8000,
859
+ auto_open: bool = True,
860
+ auto_save: bool = False,
861
+ ):
862
+ """Initialize the interactive server.
863
+
864
+ Args:
865
+ patcher: Patcher instance to serve
866
+ port: HTTP/WebSocket server port (default: 8000)
867
+ auto_open: Automatically open browser (default: True)
868
+ auto_save: Automatically save changes after 2 seconds (default: False)
869
+ """
870
+ self.patcher = patcher
871
+ self.port = port
872
+ self.ws_port = port + 1 # WebSocket on different port
873
+ self.auto_open = auto_open
874
+ self.handler = InteractiveWebSocketHandler(patcher, auto_save=auto_save)
875
+ self.ws_server: Optional[Server] = None
876
+ self.http_server: Optional[http.server.HTTPServer] = None
877
+ self.http_thread: Optional[threading.Thread] = None
878
+ self._running = False
879
+
880
+ async def start(self):
881
+ """Start the HTTP and WebSocket servers.
882
+
883
+ Returns:
884
+ self: For method chaining
885
+ """
886
+ if self._running:
887
+ print("Server already running")
888
+ return self
889
+
890
+ # Set the session token in the HTTP handler class
891
+ InteractiveHTTPHandler.session_token = self.handler.session_token
892
+
893
+ # Start HTTP server in background thread
894
+ self.http_server = http.server.HTTPServer(
895
+ ("localhost", self.port), InteractiveHTTPHandler
896
+ )
897
+ self.http_thread = threading.Thread(
898
+ target=self.http_server.serve_forever, daemon=True
899
+ )
900
+ self.http_thread.start()
901
+
902
+ # Start WebSocket server
903
+ self.ws_server = await serve(
904
+ self.handler.handle_client, "localhost", self.ws_port
905
+ )
906
+ self._running = True
907
+
908
+ url = f"http://localhost:{self.port}"
909
+ print(f"Interactive server started: {url}")
910
+ print(f"WebSocket endpoint: ws://localhost:{self.ws_port}/ws")
911
+
912
+ # Open browser
913
+ if self.auto_open:
914
+ await asyncio.sleep(0.5) # Give server time to start
915
+ webbrowser.open(url)
916
+
917
+ return self
918
+
919
+ async def stop(self):
920
+ """Stop the servers."""
921
+ await self.shutdown()
922
+
923
+ async def shutdown(self):
924
+ """Shutdown the servers gracefully."""
925
+ if not self._running:
926
+ return
927
+
928
+ # Stop WebSocket server
929
+ if self.ws_server:
930
+ self.ws_server.close()
931
+ await self.ws_server.wait_closed()
932
+
933
+ # Stop HTTP server
934
+ if self.http_server:
935
+ self.http_server.shutdown()
936
+ self.http_server.server_close()
937
+
938
+ self._running = False
939
+ print("Interactive server stopped")
940
+
941
+ # Clear clients
942
+ self.handler.clients.clear()
943
+
944
+ async def __aenter__(self):
945
+ """Async context manager entry."""
946
+ await self.start()
947
+ return self
948
+
949
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
950
+ """Async context manager exit."""
951
+ await self.shutdown()
952
+ return False
953
+
954
+ async def notify_update(self):
955
+ """Notify all connected clients of an update."""
956
+ await self.handler.notify_update()
957
+
958
+
959
+ async def serve_interactive(
960
+ patcher: "Patcher",
961
+ port: int = 8000,
962
+ auto_open: bool = True,
963
+ auto_save: bool = False,
964
+ ) -> InteractivePatcherServer:
965
+ """Start an interactive WebSocket server for a patcher.
966
+
967
+ The server can be used as an async context manager:
968
+
969
+ >>> async with serve_interactive(p) as server:
970
+ ... await asyncio.sleep(10)
971
+ # Server automatically stopped
972
+
973
+ Or managed manually:
974
+
975
+ >>> server = await serve_interactive(p)
976
+ >>> # ... interact ...
977
+ >>> await server.shutdown()
978
+
979
+ Args:
980
+ patcher: Patcher instance to serve
981
+ port: WebSocket server port (default: 8000)
982
+ auto_open: Automatically open browser (default: True)
983
+ auto_save: Automatically save changes after 2 seconds (default: False)
984
+
985
+ Returns:
986
+ InteractivePatcherServer instance
987
+ """
988
+ server = InteractivePatcherServer(patcher, port, auto_open, auto_save)
989
+ await server.start()
990
+ return server
991
+
992
+
993
+ __all__ = [
994
+ "InteractiveWebSocketHandler",
995
+ "InteractivePatcherServer",
996
+ "serve_interactive",
997
+ ]