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.
- py2max/__init__.py +67 -0
- py2max/__main__.py +6 -0
- py2max/cli.py +1251 -0
- py2max/core/__init__.py +39 -0
- py2max/core/abstract.py +146 -0
- py2max/core/box.py +231 -0
- py2max/core/common.py +19 -0
- py2max/core/patcher.py +1658 -0
- py2max/core/patchline.py +68 -0
- py2max/exceptions.py +385 -0
- py2max/export/__init__.py +20 -0
- py2max/export/converters.py +345 -0
- py2max/export/svg.py +393 -0
- py2max/layout/__init__.py +26 -0
- py2max/layout/base.py +463 -0
- py2max/layout/flow.py +405 -0
- py2max/layout/grid.py +374 -0
- py2max/layout/matrix.py +628 -0
- py2max/log.py +338 -0
- py2max/maxref/__init__.py +78 -0
- py2max/maxref/category.py +163 -0
- py2max/maxref/db.py +1082 -0
- py2max/maxref/legacy.py +324 -0
- py2max/maxref/parser.py +703 -0
- py2max/py.typed +0 -0
- py2max/server/__init__.py +54 -0
- py2max/server/client.py +295 -0
- py2max/server/inline.py +312 -0
- py2max/server/repl.py +561 -0
- py2max/server/rpc.py +240 -0
- py2max/server/websocket.py +997 -0
- py2max/static/cola.min.js +4 -0
- py2max/static/d3.v7.min.js +2 -0
- py2max/static/dagre-bundle.js +328 -0
- py2max/static/elk.bundled.js +6663 -0
- py2max/static/index.html +168 -0
- py2max/static/interactive.html +589 -0
- py2max/static/interactive.js +2111 -0
- py2max/static/live-preview.js +324 -0
- py2max/static/svg.min.js +13 -0
- py2max/static/svg.min.js.map +1 -0
- py2max/transformers.py +168 -0
- py2max/utils.py +83 -0
- py2max-0.2.1.dist-info/METADATA +390 -0
- py2max-0.2.1.dist-info/RECORD +48 -0
- py2max-0.2.1.dist-info/WHEEL +4 -0
- py2max-0.2.1.dist-info/entry_points.txt +3 -0
- py2max-0.2.1.dist-info/licenses/LICENSE +19 -0
py2max/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Interactive server and REPL for py2max.
|
|
2
|
+
|
|
3
|
+
This subpackage provides WebSocket-based server and REPL functionality
|
|
4
|
+
for real-time interactive editing of Max patches.
|
|
5
|
+
|
|
6
|
+
Modules:
|
|
7
|
+
- websocket: WebSocket server for bidirectional communication
|
|
8
|
+
- repl: Interactive REPL using ptpython
|
|
9
|
+
- client: Remote REPL client
|
|
10
|
+
- inline: Inline REPL with background server
|
|
11
|
+
- rpc: Remote procedure call server for REPL
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .websocket import (
|
|
15
|
+
InteractivePatcherServer,
|
|
16
|
+
InteractiveWebSocketHandler,
|
|
17
|
+
get_patcher_state_json,
|
|
18
|
+
serve_interactive,
|
|
19
|
+
)
|
|
20
|
+
from .repl import AutoSyncWrapper, ReplCommands, start_repl, start_repl_with_refresh
|
|
21
|
+
from .client import ReplClient, start_repl_client
|
|
22
|
+
from .inline import (
|
|
23
|
+
BackgroundServerREPL,
|
|
24
|
+
restore_console_logging,
|
|
25
|
+
setup_file_logging,
|
|
26
|
+
start_background_server_repl,
|
|
27
|
+
start_inline_repl,
|
|
28
|
+
)
|
|
29
|
+
from .rpc import ReplServer, start_repl_server
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
# WebSocket server
|
|
33
|
+
"InteractivePatcherServer",
|
|
34
|
+
"InteractiveWebSocketHandler",
|
|
35
|
+
"get_patcher_state_json",
|
|
36
|
+
"serve_interactive",
|
|
37
|
+
# REPL
|
|
38
|
+
"AutoSyncWrapper",
|
|
39
|
+
"ReplCommands",
|
|
40
|
+
"start_repl",
|
|
41
|
+
"start_repl_with_refresh",
|
|
42
|
+
# Client
|
|
43
|
+
"ReplClient",
|
|
44
|
+
"start_repl_client",
|
|
45
|
+
# Inline
|
|
46
|
+
"BackgroundServerREPL",
|
|
47
|
+
"start_inline_repl",
|
|
48
|
+
"start_background_server_repl",
|
|
49
|
+
"setup_file_logging",
|
|
50
|
+
"restore_console_logging",
|
|
51
|
+
# RPC
|
|
52
|
+
"ReplServer",
|
|
53
|
+
"start_repl_server",
|
|
54
|
+
]
|
py2max/server/client.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Remote REPL client for py2max.
|
|
2
|
+
|
|
3
|
+
This module provides a client that connects to a running py2max server
|
|
4
|
+
and provides an interactive REPL in a separate terminal, keeping server
|
|
5
|
+
logs separate from the REPL interface.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
# Terminal 1: Start server
|
|
9
|
+
$ py2max serve my-patch.maxpat
|
|
10
|
+
|
|
11
|
+
# Terminal 2: Connect REPL
|
|
12
|
+
$ py2max repl localhost:9000
|
|
13
|
+
|
|
14
|
+
Architecture:
|
|
15
|
+
- Server runs HTTP (8000), WebSocket (8001), and REPL (9000)
|
|
16
|
+
- REPL client connects to port 9000 via WebSocket
|
|
17
|
+
- Commands sent to server for execution
|
|
18
|
+
- Results returned and displayed in client
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import sys
|
|
25
|
+
from typing import Any, Dict, Optional
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from websockets.asyncio.client import ClientConnection, connect
|
|
29
|
+
except ImportError:
|
|
30
|
+
raise ImportError(
|
|
31
|
+
"websockets package required for REPL client. "
|
|
32
|
+
"Install with: pip install websockets"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ReplClient:
|
|
37
|
+
"""Remote REPL client that connects to py2max server."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, host: str = "localhost", port: int = 9000):
|
|
40
|
+
self.host = host
|
|
41
|
+
self.port = port
|
|
42
|
+
self.ws: Optional[ClientConnection] = None
|
|
43
|
+
self.namespace: Dict[str, Any] = {}
|
|
44
|
+
self.connected = False
|
|
45
|
+
|
|
46
|
+
async def connect(self):
|
|
47
|
+
"""Connect to remote REPL server."""
|
|
48
|
+
uri = f"ws://{self.host}:{self.port}"
|
|
49
|
+
print(f"Connecting to py2max server at {uri}...")
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
self.ws = await connect(uri)
|
|
53
|
+
self.connected = True
|
|
54
|
+
|
|
55
|
+
# Get initial state from server
|
|
56
|
+
await self.ws.send(json.dumps({"type": "init"}))
|
|
57
|
+
response = await self.ws.recv()
|
|
58
|
+
data = json.loads(response)
|
|
59
|
+
|
|
60
|
+
if data.get("type") == "init_response":
|
|
61
|
+
info = data.get("info", {})
|
|
62
|
+
print()
|
|
63
|
+
print("=" * 70)
|
|
64
|
+
print("py2max Remote REPL")
|
|
65
|
+
print("=" * 70)
|
|
66
|
+
print()
|
|
67
|
+
print(f"Connected to: {self.host}:{self.port}")
|
|
68
|
+
print(f"Patcher: {info.get('patcher_path', 'Unknown')}")
|
|
69
|
+
print(f"Objects: {info.get('num_objects', 0)} boxes")
|
|
70
|
+
print(f"Connections: {info.get('num_connections', 0)} lines")
|
|
71
|
+
print()
|
|
72
|
+
print("Available commands:")
|
|
73
|
+
print(" save() - Save patcher")
|
|
74
|
+
print(" info() - Show patcher info")
|
|
75
|
+
print(" layout(type) - Change layout")
|
|
76
|
+
print(" optimize() - Optimize layout")
|
|
77
|
+
print(" clear() - Clear all objects")
|
|
78
|
+
print(" help_obj(name) - Show Max object help")
|
|
79
|
+
print()
|
|
80
|
+
print("Use Ctrl+D to exit")
|
|
81
|
+
print("=" * 70)
|
|
82
|
+
print()
|
|
83
|
+
|
|
84
|
+
except Exception as e:
|
|
85
|
+
print(f"Error connecting to server: {e}", file=sys.stderr)
|
|
86
|
+
print()
|
|
87
|
+
print("Make sure the server is running with:")
|
|
88
|
+
print(" py2max serve <patch.maxpat>")
|
|
89
|
+
print()
|
|
90
|
+
raise
|
|
91
|
+
|
|
92
|
+
async def disconnect(self):
|
|
93
|
+
"""Disconnect from server."""
|
|
94
|
+
if self.ws:
|
|
95
|
+
await self.ws.close()
|
|
96
|
+
self.connected = False
|
|
97
|
+
|
|
98
|
+
async def execute(self, code: str) -> Any:
|
|
99
|
+
"""Execute code on remote server.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
code: Python code to execute
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Result from server
|
|
106
|
+
"""
|
|
107
|
+
if not self.connected or self.ws is None:
|
|
108
|
+
print("Not connected to server", file=sys.stderr)
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
# Send code to server
|
|
113
|
+
await self.ws.send(
|
|
114
|
+
json.dumps({"type": "eval", "code": code, "mode": "exec"})
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Get response
|
|
118
|
+
response = await self.ws.recv()
|
|
119
|
+
data = json.loads(response)
|
|
120
|
+
|
|
121
|
+
if data.get("type") == "error":
|
|
122
|
+
error = data.get("error", "Unknown error")
|
|
123
|
+
traceback = data.get("traceback", "")
|
|
124
|
+
print(f"Error: {error}", file=sys.stderr)
|
|
125
|
+
if traceback:
|
|
126
|
+
print(traceback, file=sys.stderr)
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
elif data.get("type") == "result":
|
|
130
|
+
result = data.get("result")
|
|
131
|
+
display = data.get("display")
|
|
132
|
+
|
|
133
|
+
# Print display if available
|
|
134
|
+
if display:
|
|
135
|
+
print(display)
|
|
136
|
+
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
else:
|
|
140
|
+
print(f"Unknown response type: {data.get('type')}", file=sys.stderr)
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
except Exception as e:
|
|
144
|
+
print(f"Error executing code: {e}", file=sys.stderr)
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
async def run_repl(self):
|
|
148
|
+
"""Run interactive REPL using ptpython."""
|
|
149
|
+
|
|
150
|
+
# Create proxy functions that execute remotely
|
|
151
|
+
async def remote_eval(code: str) -> Any:
|
|
152
|
+
"""Evaluate code on remote server."""
|
|
153
|
+
return await self.execute(code)
|
|
154
|
+
|
|
155
|
+
# Build namespace with remote execution (reserved for future use)
|
|
156
|
+
_namespace = { # noqa: F841
|
|
157
|
+
"__name__": "__main__",
|
|
158
|
+
"__doc__": None,
|
|
159
|
+
"_remote_eval": remote_eval,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
def configure(repl):
|
|
163
|
+
"""Configure ptpython."""
|
|
164
|
+
repl.show_signature = True
|
|
165
|
+
repl.enable_auto_suggest = True
|
|
166
|
+
repl.show_docstring = True
|
|
167
|
+
repl.complete_while_typing = False # Disable - can't complete remote
|
|
168
|
+
repl.enable_history_search = True
|
|
169
|
+
repl.vi_mode = False
|
|
170
|
+
repl.enable_syntax_highlighting = True
|
|
171
|
+
repl.highlight_matching_parenthesis = True
|
|
172
|
+
repl.enable_mouse_support = True
|
|
173
|
+
|
|
174
|
+
# Custom input handler that sends to server
|
|
175
|
+
# Note: This is a simplified version - full implementation would need
|
|
176
|
+
# to intercept ptpython's execution and route through websocket
|
|
177
|
+
print("Starting REPL...")
|
|
178
|
+
print()
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
# Use ptpython with syntax highlighting and history
|
|
182
|
+
await self._ptpython_repl()
|
|
183
|
+
|
|
184
|
+
except (EOFError, KeyboardInterrupt):
|
|
185
|
+
print()
|
|
186
|
+
print("Exiting REPL...")
|
|
187
|
+
|
|
188
|
+
async def _ptpython_repl(self) -> None:
|
|
189
|
+
"""Full-featured ptpython REPL with remote execution.
|
|
190
|
+
|
|
191
|
+
Uses ptpython for editing features (syntax highlighting, completion)
|
|
192
|
+
but executes code on the remote server.
|
|
193
|
+
"""
|
|
194
|
+
# Create a remote execution namespace (reserved for future use)
|
|
195
|
+
_namespace = { # noqa: F841
|
|
196
|
+
"_client": self,
|
|
197
|
+
"_execute": self.execute,
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
# Custom evaluator that executes remotely (reserved for future use)
|
|
201
|
+
class RemoteEvaluator:
|
|
202
|
+
def __init__(self, client: "ReplClient") -> None:
|
|
203
|
+
self.client = client
|
|
204
|
+
|
|
205
|
+
async def __call__(self, code: str) -> Optional[str]:
|
|
206
|
+
"""Execute code on remote server."""
|
|
207
|
+
if not code.strip():
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
# Execute on server
|
|
211
|
+
result = await self.client.execute(code)
|
|
212
|
+
return result
|
|
213
|
+
|
|
214
|
+
_evaluator = RemoteEvaluator(self) # noqa: F841
|
|
215
|
+
|
|
216
|
+
# Configure ptpython
|
|
217
|
+
def configure(repl: Any) -> None:
|
|
218
|
+
"""Configure ptpython REPL."""
|
|
219
|
+
# Enable auto-completion
|
|
220
|
+
repl.enable_auto_suggest = True
|
|
221
|
+
repl.complete_while_typing = False # Disable since completion is local
|
|
222
|
+
|
|
223
|
+
# Enable syntax highlighting
|
|
224
|
+
repl.enable_syntax_highlighting = True
|
|
225
|
+
|
|
226
|
+
# Show signature
|
|
227
|
+
repl.show_signature = True
|
|
228
|
+
|
|
229
|
+
# Vi mode (optional - can be toggled with F4)
|
|
230
|
+
repl.vi_mode = False
|
|
231
|
+
|
|
232
|
+
# Run ptpython REPL with remote execution
|
|
233
|
+
# Note: We use a custom event loop integration
|
|
234
|
+
while True:
|
|
235
|
+
try:
|
|
236
|
+
# Get input using ptpython
|
|
237
|
+
from prompt_toolkit import PromptSession
|
|
238
|
+
from prompt_toolkit.lexers import PygmentsLexer
|
|
239
|
+
from pygments.lexers.python import PythonLexer # type: ignore[import-untyped]
|
|
240
|
+
|
|
241
|
+
session: PromptSession[str] = PromptSession(
|
|
242
|
+
message="py2max[remote]>>> ",
|
|
243
|
+
lexer=PygmentsLexer(PythonLexer),
|
|
244
|
+
enable_history_search=True,
|
|
245
|
+
complete_while_typing=False,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
code = await session.prompt_async()
|
|
249
|
+
|
|
250
|
+
if not code.strip():
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
# Handle exit
|
|
254
|
+
if code.strip() in ("exit", "exit()", "quit", "quit()"):
|
|
255
|
+
break
|
|
256
|
+
|
|
257
|
+
# Execute on server
|
|
258
|
+
result = await self.execute(code)
|
|
259
|
+
|
|
260
|
+
# Display result if not None
|
|
261
|
+
if result is not None:
|
|
262
|
+
print(result)
|
|
263
|
+
|
|
264
|
+
except EOFError:
|
|
265
|
+
break
|
|
266
|
+
except KeyboardInterrupt:
|
|
267
|
+
print()
|
|
268
|
+
continue
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
async def start_repl_client(host: str = "localhost", port: int = 9000):
|
|
272
|
+
"""Start remote REPL client.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
host: Server hostname
|
|
276
|
+
port: REPL server port (default: 9000)
|
|
277
|
+
|
|
278
|
+
Example:
|
|
279
|
+
>>> await start_repl_client("localhost", 9000)
|
|
280
|
+
"""
|
|
281
|
+
client = ReplClient(host, port)
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
await client.connect()
|
|
285
|
+
await client.run_repl()
|
|
286
|
+
except Exception as e:
|
|
287
|
+
print(f"REPL client error: {e}", file=sys.stderr)
|
|
288
|
+
return 1
|
|
289
|
+
finally:
|
|
290
|
+
await client.disconnect()
|
|
291
|
+
|
|
292
|
+
return 0
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
__all__ = ["ReplClient", "start_repl_client"]
|
py2max/server/inline.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Inline REPL with background server and log redirection.
|
|
2
|
+
|
|
3
|
+
This module provides an inline REPL mode where the server runs in the
|
|
4
|
+
background with logs redirected to a file, allowing a clean single-terminal
|
|
5
|
+
experience.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
$ py2max serve my-patch.maxpat --repl --log-file server.log
|
|
9
|
+
|
|
10
|
+
Features:
|
|
11
|
+
- Single terminal (no need for separate client terminal)
|
|
12
|
+
- Server logs redirected to file
|
|
13
|
+
- Clean REPL interface
|
|
14
|
+
- Can tail log file in separate terminal if needed
|
|
15
|
+
|
|
16
|
+
Architecture:
|
|
17
|
+
┌─────────────────────────────────────┐
|
|
18
|
+
│ Single Terminal │
|
|
19
|
+
│ │
|
|
20
|
+
│ ┌─────────────────────────────────┐ │
|
|
21
|
+
│ │ Server (background thread) │ │
|
|
22
|
+
│ │ - HTTP: 8000 │ │
|
|
23
|
+
│ │ - WebSocket: 8001 │ │
|
|
24
|
+
│ │ - Logs → server.log │ │
|
|
25
|
+
│ └─────────────────────────────────┘ │
|
|
26
|
+
│ │
|
|
27
|
+
│ ┌─────────────────────────────────┐ │
|
|
28
|
+
│ │ REPL (foreground, ptpython) │ │
|
|
29
|
+
│ │ py2max[demo.maxpat]>>> │ │
|
|
30
|
+
│ │ [clean, no logs] │ │
|
|
31
|
+
│ └─────────────────────────────────┘ │
|
|
32
|
+
└─────────────────────────────────────┘
|
|
33
|
+
|
|
34
|
+
Optional: $ tail -f server.log (in another terminal)
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import asyncio
|
|
40
|
+
import logging
|
|
41
|
+
import sys
|
|
42
|
+
import threading
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from typing import TYPE_CHECKING, Optional
|
|
45
|
+
|
|
46
|
+
if TYPE_CHECKING:
|
|
47
|
+
from ..core import Patcher
|
|
48
|
+
from .websocket import InteractivePatcherServer
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def setup_file_logging(log_file: Path, level: int = logging.INFO) -> logging.Handler:
|
|
52
|
+
"""Setup file logging for server.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
log_file: Path to log file
|
|
56
|
+
level: Logging level (default: INFO)
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
File handler that was added
|
|
60
|
+
"""
|
|
61
|
+
# Get root logger
|
|
62
|
+
root_logger = logging.getLogger()
|
|
63
|
+
|
|
64
|
+
# Remove existing handlers (console output)
|
|
65
|
+
for handler in root_logger.handlers[:]:
|
|
66
|
+
root_logger.removeHandler(handler)
|
|
67
|
+
|
|
68
|
+
# Add file handler
|
|
69
|
+
file_handler = logging.FileHandler(log_file, mode="a")
|
|
70
|
+
file_handler.setLevel(level)
|
|
71
|
+
|
|
72
|
+
# Format with timestamp
|
|
73
|
+
formatter = logging.Formatter(
|
|
74
|
+
"[%(asctime)s] - %(levelname)s - %(name)s - %(message)s",
|
|
75
|
+
datefmt="%H:%M:%S",
|
|
76
|
+
)
|
|
77
|
+
file_handler.setFormatter(formatter)
|
|
78
|
+
|
|
79
|
+
root_logger.addHandler(file_handler)
|
|
80
|
+
root_logger.setLevel(level)
|
|
81
|
+
|
|
82
|
+
return file_handler
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def restore_console_logging():
|
|
86
|
+
"""Restore console logging (remove file handler)."""
|
|
87
|
+
root_logger = logging.getLogger()
|
|
88
|
+
|
|
89
|
+
# Remove file handlers
|
|
90
|
+
for handler in root_logger.handlers[:]:
|
|
91
|
+
if isinstance(handler, logging.FileHandler):
|
|
92
|
+
handler.close()
|
|
93
|
+
root_logger.removeHandler(handler)
|
|
94
|
+
|
|
95
|
+
# Add console handler back
|
|
96
|
+
console_handler = logging.StreamHandler(sys.stderr)
|
|
97
|
+
console_handler.setLevel(logging.INFO)
|
|
98
|
+
formatter = logging.Formatter(
|
|
99
|
+
"\x1b[97;20m%(asctime)s\x1b[0m - "
|
|
100
|
+
"\x1b[38;20m%(levelname)s\x1b[0m - "
|
|
101
|
+
"\x1b[97;20m%(name)s\x1b[0m - "
|
|
102
|
+
"\x1b[38;20m%(message)s\x1b[0m",
|
|
103
|
+
datefmt="%H:%M:%S",
|
|
104
|
+
)
|
|
105
|
+
console_handler.setFormatter(formatter)
|
|
106
|
+
root_logger.addHandler(console_handler)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def start_inline_repl(
|
|
110
|
+
patcher: "Patcher",
|
|
111
|
+
server: "InteractivePatcherServer",
|
|
112
|
+
log_file: Optional[Path] = None,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Start inline REPL with server running in background.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
patcher: Patcher instance
|
|
118
|
+
server: Server instance (already started)
|
|
119
|
+
log_file: Optional log file path (default: outputs/py2max_server.log)
|
|
120
|
+
|
|
121
|
+
Example:
|
|
122
|
+
>>> p = Patcher('demo.maxpat')
|
|
123
|
+
>>> server = await p.serve(port=8000)
|
|
124
|
+
>>> await start_inline_repl(p, server, Path('server.log'))
|
|
125
|
+
"""
|
|
126
|
+
from .repl import start_repl
|
|
127
|
+
|
|
128
|
+
# Setup log file
|
|
129
|
+
if log_file is None:
|
|
130
|
+
log_file = Path("outputs/py2max_server.log")
|
|
131
|
+
|
|
132
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
|
|
134
|
+
# Redirect logging to file
|
|
135
|
+
file_handler = setup_file_logging(log_file)
|
|
136
|
+
|
|
137
|
+
# Print info about logging
|
|
138
|
+
print()
|
|
139
|
+
print("=" * 70)
|
|
140
|
+
print("py2max Inline REPL (Single Terminal Mode)")
|
|
141
|
+
print("=" * 70)
|
|
142
|
+
print()
|
|
143
|
+
print(f"Server logs redirected to: {log_file}")
|
|
144
|
+
print(f"To monitor logs: tail -f {log_file}")
|
|
145
|
+
print()
|
|
146
|
+
print("Starting REPL...")
|
|
147
|
+
print()
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
# Start REPL in foreground
|
|
151
|
+
await start_repl(patcher, server)
|
|
152
|
+
|
|
153
|
+
finally:
|
|
154
|
+
# Restore console logging
|
|
155
|
+
file_handler.close()
|
|
156
|
+
restore_console_logging()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class BackgroundServerREPL:
|
|
160
|
+
"""REPL with server running in background thread.
|
|
161
|
+
|
|
162
|
+
This class manages the server lifecycle in a background thread
|
|
163
|
+
while the REPL runs in the foreground.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
def __init__(
|
|
167
|
+
self,
|
|
168
|
+
patcher: "Patcher",
|
|
169
|
+
port: int = 8000,
|
|
170
|
+
log_file: Optional[Path] = None,
|
|
171
|
+
):
|
|
172
|
+
self.patcher = patcher
|
|
173
|
+
self.port = port
|
|
174
|
+
self.log_file = log_file or Path("outputs/py2max_server.log")
|
|
175
|
+
self.server: Optional[InteractivePatcherServer] = None
|
|
176
|
+
self.server_thread: Optional[threading.Thread] = None
|
|
177
|
+
self.loop: Optional[asyncio.AbstractEventLoop] = None
|
|
178
|
+
self.ready_event = threading.Event()
|
|
179
|
+
|
|
180
|
+
def notify_update_sync(self):
|
|
181
|
+
"""Synchronously notify server (running in background thread) of updates.
|
|
182
|
+
|
|
183
|
+
This method is called from the REPL (main thread) and schedules
|
|
184
|
+
the notify_update coroutine in the server's event loop.
|
|
185
|
+
"""
|
|
186
|
+
if self.loop and self.server:
|
|
187
|
+
# Schedule coroutine in the server's event loop
|
|
188
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
189
|
+
self.server.notify_update(), self.loop
|
|
190
|
+
)
|
|
191
|
+
# Wait for completion (with timeout to avoid hanging)
|
|
192
|
+
try:
|
|
193
|
+
future.result(timeout=2.0)
|
|
194
|
+
except Exception as e:
|
|
195
|
+
print(f"Warning: Failed to notify server: {e}", file=sys.stderr)
|
|
196
|
+
|
|
197
|
+
def _run_server(self):
|
|
198
|
+
"""Run server in background thread."""
|
|
199
|
+
# Create new event loop for this thread
|
|
200
|
+
self.loop = asyncio.new_event_loop()
|
|
201
|
+
asyncio.set_event_loop(self.loop)
|
|
202
|
+
|
|
203
|
+
async def run():
|
|
204
|
+
# Start server
|
|
205
|
+
self.server = await self.patcher.serve(
|
|
206
|
+
port=self.port,
|
|
207
|
+
auto_open=True,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Signal that server is ready
|
|
211
|
+
self.ready_event.set()
|
|
212
|
+
|
|
213
|
+
# Keep running
|
|
214
|
+
try:
|
|
215
|
+
while True:
|
|
216
|
+
await asyncio.sleep(1)
|
|
217
|
+
except asyncio.CancelledError:
|
|
218
|
+
# Shutdown requested
|
|
219
|
+
await self.server.stop()
|
|
220
|
+
|
|
221
|
+
# Run server
|
|
222
|
+
try:
|
|
223
|
+
self.loop.run_until_complete(run())
|
|
224
|
+
except Exception as e:
|
|
225
|
+
print(f"Server error: {e}", file=sys.stderr)
|
|
226
|
+
finally:
|
|
227
|
+
self.loop.close()
|
|
228
|
+
|
|
229
|
+
async def start(self):
|
|
230
|
+
"""Start server in background and REPL in foreground."""
|
|
231
|
+
# Setup logging
|
|
232
|
+
self.log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
233
|
+
file_handler = setup_file_logging(self.log_file)
|
|
234
|
+
|
|
235
|
+
# Start server in background thread
|
|
236
|
+
self.server_thread = threading.Thread(
|
|
237
|
+
target=self._run_server,
|
|
238
|
+
daemon=True,
|
|
239
|
+
)
|
|
240
|
+
self.server_thread.start()
|
|
241
|
+
|
|
242
|
+
# Wait for server to be ready
|
|
243
|
+
self.ready_event.wait(timeout=10)
|
|
244
|
+
|
|
245
|
+
if not self.ready_event.is_set():
|
|
246
|
+
raise RuntimeError("Server failed to start")
|
|
247
|
+
|
|
248
|
+
print()
|
|
249
|
+
print("=" * 70)
|
|
250
|
+
print("py2max Inline REPL (Single Terminal Mode)")
|
|
251
|
+
print("=" * 70)
|
|
252
|
+
print()
|
|
253
|
+
print("Server running:")
|
|
254
|
+
print(f" HTTP: http://localhost:{self.port}")
|
|
255
|
+
print(f" WebSocket: ws://localhost:{self.port + 1}")
|
|
256
|
+
print()
|
|
257
|
+
print(f"Server logs: {self.log_file}")
|
|
258
|
+
print(f"To monitor: tail -f {self.log_file}")
|
|
259
|
+
print()
|
|
260
|
+
print("Starting REPL...")
|
|
261
|
+
print()
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
# Start REPL in foreground with refresh() function
|
|
265
|
+
from .repl import start_repl_with_refresh
|
|
266
|
+
|
|
267
|
+
assert self.server is not None, "Server failed to start"
|
|
268
|
+
await start_repl_with_refresh(
|
|
269
|
+
self.patcher, self.server, self.notify_update_sync
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
finally:
|
|
273
|
+
# Cleanup
|
|
274
|
+
self.stop()
|
|
275
|
+
file_handler.close()
|
|
276
|
+
restore_console_logging()
|
|
277
|
+
|
|
278
|
+
def stop(self):
|
|
279
|
+
"""Stop background server."""
|
|
280
|
+
if self.loop and self.server:
|
|
281
|
+
# Cancel the server loop
|
|
282
|
+
for task in asyncio.all_tasks(self.loop):
|
|
283
|
+
task.cancel()
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
async def start_background_server_repl(
|
|
287
|
+
patcher: "Patcher",
|
|
288
|
+
port: int = 8000,
|
|
289
|
+
log_file: Optional[Path] = None,
|
|
290
|
+
) -> None:
|
|
291
|
+
"""Start REPL with server in background (single terminal mode).
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
patcher: Patcher instance
|
|
295
|
+
port: Server port (default: 8000)
|
|
296
|
+
log_file: Log file path (default: outputs/py2max_server.log)
|
|
297
|
+
|
|
298
|
+
Example:
|
|
299
|
+
>>> p = Patcher('demo.maxpat')
|
|
300
|
+
>>> await start_background_server_repl(p, port=8000)
|
|
301
|
+
"""
|
|
302
|
+
repl = BackgroundServerREPL(patcher, port, log_file)
|
|
303
|
+
await repl.start()
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
__all__ = [
|
|
307
|
+
"start_inline_repl",
|
|
308
|
+
"start_background_server_repl",
|
|
309
|
+
"BackgroundServerREPL",
|
|
310
|
+
"setup_file_logging",
|
|
311
|
+
"restore_console_logging",
|
|
312
|
+
]
|