@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/python/plugin_repl.py +144 -60
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scrypted/server",
3
- "version": "0.94.34",
3
+ "version": "0.94.35",
4
4
  "description": "",
5
5
  "dependencies": {
6
6
  "@mapbox/node-pre-gyp": "^1.0.11",
@@ -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
- def configure(repl: PythonRepl) -> None:
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
- loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
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": repl_print,
162
- "help": lambda *args, **kwargs: repl_print("Help is not available in this environment"),
163
- "input": lambda *args, **kwargs: repl_print("Input is not available in this environment"),
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: loop.call_soon_threadsafe(server_task.cancel)))
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
- future = concurrent.futures.Future()
191
- loop.call_soon_threadsafe(loop.create_task, start_telnet_repl(future, filter))
192
- telnet_port, exit_server = future.result()
193
-
194
- telnet_client = telnetlib.Telnet('localhost', telnet_port, timeout=None)
195
-
196
- def telnet_negotiation_cb(telnet_socket, command, option):
197
- pass # ignore telnet negotiation
198
- telnet_client.set_option_negotiation_callback(telnet_negotiation_cb)
199
-
200
- # initialize telnet terminal
201
- # this tells the telnet server we are a vt100 terminal
202
- telnet_client.get_socket().sendall(b'\xff\xfb\x18\xff\xfa\x18\x00\x61\x6e\x73\x69\xff\xf0')
203
- telnet_client.get_socket().sendall(b'\r\n')
204
-
205
- # Bridge the connection to the telnet server, two way
206
- def forward_to_telnet():
207
- while True:
208
- data = conn.recv(1024)
209
- if not data:
210
- break
211
- telnet_client.write(data)
212
- telnet_client.close()
213
- exit_server()
214
-
215
- def forward_to_socket():
216
- prompt_count = 0
217
- while True:
218
- data = telnet_client.read_some()
219
- if not data:
220
- conn.sendall('REPL exited'.encode())
221
- break
222
- if b">>>" in data:
223
- # This is an ugly hack - somewhere in ptpython, the
224
- # initial prompt is being printed many times. Normal
225
- # telnet clients handle it properly, but xtermjs doesn't
226
- # like it. We just replace the first few with spaces
227
- # so it's not too ugly.
228
- prompt_count += 1
229
- if prompt_count < 5:
230
- data = data.replace(b">>>", b" ")
231
- conn.sendall(data)
232
- conn.close()
233
- exit_server()
234
-
235
- threading.Thread(target=forward_to_telnet).start()
236
- threading.Thread(target=forward_to_socket).start()
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: