@scrypted/server 0.94.34 → 0.94.36
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.
- package/package.json +1 -1
- package/python/plugin_repl.py +150 -60
package/package.json
CHANGED
package/python/plugin_repl.py
CHANGED
@@ -2,6 +2,7 @@ import asyncio
|
|
2
2
|
import concurrent.futures
|
3
3
|
from functools import partial
|
4
4
|
import inspect
|
5
|
+
import os
|
5
6
|
import prompt_toolkit
|
6
7
|
from prompt_toolkit import print_formatted_text
|
7
8
|
from prompt_toolkit.application import Application
|
@@ -9,13 +10,15 @@ import prompt_toolkit.application.current
|
|
9
10
|
import prompt_toolkit.key_binding.key_processor
|
10
11
|
import prompt_toolkit.contrib.telnet.server
|
11
12
|
from prompt_toolkit.contrib.telnet.server import TelnetServer, TelnetConnection
|
13
|
+
from prompt_toolkit.output.color_depth import ColorDepth
|
12
14
|
from prompt_toolkit.shortcuts import clear_title, set_title
|
13
|
-
from ptpython.repl import embed, PythonRepl
|
15
|
+
from ptpython.repl import embed, PythonRepl, _has_coroutine_flag
|
14
16
|
import ptpython.key_bindings
|
15
17
|
import ptpython.python_input
|
16
18
|
import ptpython.history_browser
|
17
19
|
import ptpython.layout
|
18
20
|
import socket
|
21
|
+
import sys
|
19
22
|
import telnetlib
|
20
23
|
import threading
|
21
24
|
import traceback
|
@@ -27,6 +30,10 @@ from scrypted_python.scrypted_sdk import ScryptedStatic, ScryptedDevice
|
|
27
30
|
from rpc import maybe_await
|
28
31
|
|
29
32
|
|
33
|
+
# Our client is xtermjs, so no need to perform any color depth detection
|
34
|
+
ColorDepth.default = lambda *args, **kwargs: ColorDepth.DEPTH_4_BIT
|
35
|
+
|
36
|
+
|
30
37
|
# This section is a bit of a hack - prompt_toolkit has many assumptions
|
31
38
|
# that there is only one global Application, so multiple REPLs will confuse
|
32
39
|
# the library. The patches here allow us to scope a particular call stack
|
@@ -48,11 +55,10 @@ ptpython.history_browser.get_app = get_app_patched
|
|
48
55
|
ptpython.layout.get_app = get_app_patched
|
49
56
|
|
50
57
|
|
58
|
+
# This is a patched version of PythonRepl.run_async to handle an
|
59
|
+
# AssertionError raised by prompt_toolkit when the TelnetServer exits.
|
60
|
+
# Original: https://github.com/prompt-toolkit/ptpython/blob/3.0.26/ptpython/repl.py#L215
|
51
61
|
async def run_async_patched(self: PythonRepl) -> None:
|
52
|
-
# This is a patched version of PythonRepl.run_async to handle an
|
53
|
-
# AssertionError raised by prompt_toolkit when the TelnetServer exits.
|
54
|
-
# Original: https://github.com/prompt-toolkit/ptpython/blob/3.0.26/ptpython/repl.py#L215
|
55
|
-
|
56
62
|
"""
|
57
63
|
Run the REPL loop, but run the blocking parts in an executor, so that
|
58
64
|
we don't block the event loop. Both the input and output (which can
|
@@ -106,11 +112,75 @@ async def run_async_patched(self: PythonRepl) -> None:
|
|
106
112
|
self._remove_from_namespace()
|
107
113
|
|
108
114
|
|
109
|
-
|
115
|
+
# This is a patched version of PythonRepl.eval_async to cross thread
|
116
|
+
# boundaries when running user REPL instructions.
|
117
|
+
# Original: https://github.com/prompt-toolkit/ptpython/blob/3.0.26/ptpython/repl.py#L304
|
118
|
+
async def eval_async_patched(self: PythonRepl, line: str) -> object:
|
119
|
+
"""
|
120
|
+
Evaluate the line and print the result.
|
121
|
+
"""
|
122
|
+
scrypted_loop: asyncio.AbstractEventLoop = self.scrypted_loop
|
123
|
+
|
124
|
+
def task_done_cb(future: concurrent.futures.Future, task: asyncio.Task):
|
125
|
+
try:
|
126
|
+
result = task.result()
|
127
|
+
future.set_result(result)
|
128
|
+
except BaseException as e:
|
129
|
+
future.set_exception(e)
|
130
|
+
|
131
|
+
def eval_in_scrypted(future: concurrent.futures.Future, code, *args, **kwargs):
|
132
|
+
try:
|
133
|
+
result = eval(code, *args, **kwargs)
|
134
|
+
if _has_coroutine_flag(code):
|
135
|
+
task = scrypted_loop.create_task(result)
|
136
|
+
task.add_done_callback(partial(task_done_cb, future))
|
137
|
+
else:
|
138
|
+
future.set_result(result)
|
139
|
+
except BaseException as e:
|
140
|
+
future.set_exception(e)
|
141
|
+
|
142
|
+
def eval_across_loops(code, *args, **kwargs):
|
143
|
+
future = concurrent.futures.Future()
|
144
|
+
scrypted_loop.call_soon_threadsafe(partial(eval_in_scrypted, future), code, *args, **kwargs)
|
145
|
+
return future.result()
|
146
|
+
|
147
|
+
# WORKAROUND: Due to a bug in Jedi, the current directory is removed
|
148
|
+
# from sys.path. See: https://github.com/davidhalter/jedi/issues/1148
|
149
|
+
if "" not in sys.path:
|
150
|
+
sys.path.insert(0, "")
|
151
|
+
|
152
|
+
if line.lstrip().startswith("!"):
|
153
|
+
# Run as shell command
|
154
|
+
os.system(line[1:])
|
155
|
+
else:
|
156
|
+
# Try eval first
|
157
|
+
try:
|
158
|
+
code = self._compile_with_flags(line, "eval")
|
159
|
+
except SyntaxError:
|
160
|
+
pass
|
161
|
+
else:
|
162
|
+
# No syntax errors for eval. Do eval.
|
163
|
+
|
164
|
+
result = eval_across_loops(code, self.get_globals(), self.get_locals())
|
165
|
+
self._store_eval_result(result)
|
166
|
+
return result
|
167
|
+
|
168
|
+
# If not a valid `eval` expression, compile as `exec` expression
|
169
|
+
# but still run with eval to get an awaitable in case of a
|
170
|
+
# awaitable expression.
|
171
|
+
code = self._compile_with_flags(line, "exec")
|
172
|
+
result = eval_across_loops(code, self.get_globals(), self.get_locals())
|
173
|
+
|
174
|
+
return None
|
175
|
+
|
176
|
+
|
177
|
+
def configure(scrypted_loop: asyncio.AbstractEventLoop, repl: PythonRepl) -> None:
|
110
178
|
repl.confirm_exit = False
|
111
179
|
repl.enable_system_bindings = False
|
112
180
|
repl.enable_mouse_support = False
|
113
181
|
repl.run_async = types.MethodType(run_async_patched, repl)
|
182
|
+
repl.eval_async = types.MethodType(eval_async_patched, repl)
|
183
|
+
repl.scrypted_loop = scrypted_loop
|
114
184
|
|
115
185
|
|
116
186
|
async def createREPLServer(sdk: ScryptedStatic, plugin: ScryptedDevice) -> int:
|
@@ -125,9 +195,11 @@ async def createREPLServer(sdk: ScryptedStatic, plugin: ScryptedDevice) -> int:
|
|
125
195
|
sock.bind(('localhost', 0))
|
126
196
|
sock.listen()
|
127
197
|
|
128
|
-
|
198
|
+
scrypted_loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
|
129
199
|
|
130
200
|
async def start_telnet_repl(future: concurrent.futures.Future, filter: str) -> None:
|
201
|
+
repl_loop = asyncio.get_event_loop()
|
202
|
+
|
131
203
|
if filter == "undefined":
|
132
204
|
filter = None
|
133
205
|
|
@@ -155,12 +227,17 @@ async def createREPLServer(sdk: ScryptedStatic, plugin: ScryptedDevice) -> int:
|
|
155
227
|
s.close()
|
156
228
|
|
157
229
|
async def interact(connection: TelnetConnection) -> None:
|
230
|
+
# repl_loop owns the print capabilities, but the prints will
|
231
|
+
# be executed in scrypted_loop. We need to bridge the two here
|
158
232
|
repl_print = partial(print_formatted_text, output=connection.vt100_output)
|
233
|
+
def print_across_loops(*args, **kwargs):
|
234
|
+
repl_loop.call_soon_threadsafe(repl_print, *args, **kwargs)
|
235
|
+
|
159
236
|
global_dict = {
|
160
237
|
**globals(),
|
161
|
-
"print":
|
162
|
-
"help": lambda *args, **kwargs:
|
163
|
-
"input": lambda *args, **kwargs:
|
238
|
+
"print": print_across_loops,
|
239
|
+
"help": lambda *args, **kwargs: print_across_loops("Help is not available in this environment"),
|
240
|
+
"input": lambda *args, **kwargs: print_across_loops("Input is not available in this environment"),
|
164
241
|
}
|
165
242
|
locals_dict = {
|
166
243
|
"device": device,
|
@@ -173,67 +250,80 @@ async def createREPLServer(sdk: ScryptedStatic, plugin: ScryptedDevice) -> int:
|
|
173
250
|
vars_prompt = '\n'.join([f" {k}" for k in locals_dict.keys()])
|
174
251
|
banner = f"Python REPL variables:\n{vars_prompt}"
|
175
252
|
print_formatted_text(banner)
|
176
|
-
await embed(return_asyncio_coroutine=True, globals=global_dict, locals=locals_dict, configure=configure)
|
253
|
+
await embed(return_asyncio_coroutine=True, globals=global_dict, locals=locals_dict, configure=partial(configure, scrypted_loop))
|
177
254
|
|
178
255
|
server_task: asyncio.Task = None
|
179
256
|
def ready_cb():
|
180
|
-
future.set_result((telnet_port, lambda:
|
257
|
+
future.set_result((telnet_port, lambda: repl_loop.call_soon_threadsafe(server_task.cancel)))
|
181
258
|
|
182
259
|
# Start the REPL server
|
183
260
|
telnet_server = TelnetServer(interact=interact, port=telnet_port, enable_cpr=False)
|
184
261
|
server_task = asyncio.create_task(telnet_server.run(ready_cb=ready_cb))
|
262
|
+
try:
|
263
|
+
await server_task
|
264
|
+
except:
|
265
|
+
pass
|
185
266
|
|
186
267
|
def handle_connection(conn: socket.socket):
|
187
268
|
conn.settimeout(None)
|
188
269
|
filter = conn.recv(1024).decode()
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
def
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
270
|
+
server_started_future = concurrent.futures.Future()
|
271
|
+
repl_loop = asyncio.SelectorEventLoop()
|
272
|
+
|
273
|
+
# we're not in the main loop, so can't handle any signals anyways
|
274
|
+
repl_loop.add_signal_handler = lambda sig, cb: None
|
275
|
+
repl_loop.remove_signal_handler = lambda sig: True
|
276
|
+
|
277
|
+
def finish_setup():
|
278
|
+
telnet_port, exit_server = server_started_future.result()
|
279
|
+
|
280
|
+
telnet_client = telnetlib.Telnet('localhost', telnet_port, timeout=None)
|
281
|
+
|
282
|
+
def telnet_negotiation_cb(telnet_socket, command, option):
|
283
|
+
pass # ignore telnet negotiation
|
284
|
+
telnet_client.set_option_negotiation_callback(telnet_negotiation_cb)
|
285
|
+
|
286
|
+
# initialize telnet terminal
|
287
|
+
# this tells the telnet server we are a vt100 terminal
|
288
|
+
telnet_client.get_socket().sendall(b'\xff\xfb\x18\xff\xfa\x18\x00\x61\x6e\x73\x69\xff\xf0')
|
289
|
+
telnet_client.get_socket().sendall(b'\r\n')
|
290
|
+
|
291
|
+
# Bridge the connection to the telnet server, two way
|
292
|
+
def forward_to_telnet():
|
293
|
+
while True:
|
294
|
+
data = conn.recv(1024)
|
295
|
+
if not data:
|
296
|
+
break
|
297
|
+
telnet_client.write(data)
|
298
|
+
telnet_client.close()
|
299
|
+
exit_server()
|
300
|
+
|
301
|
+
def forward_to_socket():
|
302
|
+
prompt_count = 0
|
303
|
+
while True:
|
304
|
+
data = telnet_client.read_some()
|
305
|
+
if not data:
|
306
|
+
conn.sendall('REPL exited'.encode())
|
307
|
+
break
|
308
|
+
if b">>>" in data:
|
309
|
+
# This is an ugly hack - somewhere in ptpython, the
|
310
|
+
# initial prompt is being printed many times. Normal
|
311
|
+
# telnet clients handle it properly, but xtermjs doesn't
|
312
|
+
# like it. We just replace the first few with spaces
|
313
|
+
# so it's not too ugly.
|
314
|
+
prompt_count += 1
|
315
|
+
if prompt_count < 5:
|
316
|
+
data = data.replace(b">>>", b" ")
|
317
|
+
conn.sendall(data)
|
318
|
+
conn.close()
|
319
|
+
exit_server()
|
320
|
+
|
321
|
+
threading.Thread(target=forward_to_telnet).start()
|
322
|
+
threading.Thread(target=forward_to_socket).start()
|
323
|
+
|
324
|
+
threading.Thread(target=finish_setup).start()
|
325
|
+
|
326
|
+
repl_loop.run_until_complete(start_telnet_repl(server_started_future, filter))
|
237
327
|
|
238
328
|
def accept_connection():
|
239
329
|
while True:
|