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