@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/python/plugin_repl.py +150 -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.36",
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
@@ -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
- def configure(repl: PythonRepl) -> None:
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
- loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
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": 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"),
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: loop.call_soon_threadsafe(server_task.cancel)))
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
- 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()
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: