@scrypted/server 0.94.2 → 0.94.3
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_pip.py +101 -0
- package/python/plugin_remote.py +44 -77
- package/python/plugin_repl.py +143 -0
package/package.json
CHANGED
@@ -0,0 +1,101 @@
|
|
1
|
+
import os
|
2
|
+
import subprocess
|
3
|
+
import sys
|
4
|
+
from typing import Any
|
5
|
+
import shutil
|
6
|
+
|
7
|
+
def get_requirements_files(requirements: str):
|
8
|
+
want_requirements = requirements + '.txt'
|
9
|
+
installed_requirementstxt = requirements + '.installed.txt'
|
10
|
+
return want_requirements, installed_requirementstxt
|
11
|
+
|
12
|
+
def need_requirements(requirements_basename: str, requirements_str: str):
|
13
|
+
_, installed_requirementstxt = get_requirements_files(requirements_basename)
|
14
|
+
if not os.path.exists(installed_requirementstxt):
|
15
|
+
return True
|
16
|
+
try:
|
17
|
+
f = open(installed_requirementstxt, "rb")
|
18
|
+
installed_requirements = f.read().decode('utf8')
|
19
|
+
return requirements_str != installed_requirements
|
20
|
+
except:
|
21
|
+
return True
|
22
|
+
|
23
|
+
def remove_pip_dirs(plugin_volume: str):
|
24
|
+
try:
|
25
|
+
for de in os.listdir(plugin_volume):
|
26
|
+
if (
|
27
|
+
de.startswith("linux")
|
28
|
+
or de.startswith("darwin")
|
29
|
+
or de.startswith("win32")
|
30
|
+
or de.startswith("python")
|
31
|
+
or de.startswith("node")
|
32
|
+
):
|
33
|
+
filePath = os.path.join(plugin_volume, de)
|
34
|
+
print("Removing old dependencies: %s" % filePath)
|
35
|
+
try:
|
36
|
+
shutil.rmtree(filePath)
|
37
|
+
except:
|
38
|
+
pass
|
39
|
+
except:
|
40
|
+
pass
|
41
|
+
|
42
|
+
|
43
|
+
def install_with_pip(
|
44
|
+
python_prefix: str,
|
45
|
+
packageJson: Any,
|
46
|
+
requirements_str: str,
|
47
|
+
requirements_basename: str,
|
48
|
+
ignore_error: bool = False,
|
49
|
+
):
|
50
|
+
requirementstxt, installed_requirementstxt = get_requirements_files(requirements_basename)
|
51
|
+
|
52
|
+
os.makedirs(python_prefix, exist_ok=True)
|
53
|
+
|
54
|
+
print(f"{os.path.basename(requirementstxt)} (outdated)")
|
55
|
+
print(requirements_str)
|
56
|
+
|
57
|
+
f = open(requirementstxt, "wb")
|
58
|
+
f.write(requirements_str.encode())
|
59
|
+
f.close()
|
60
|
+
|
61
|
+
try:
|
62
|
+
pythonVersion = packageJson["scrypted"]["pythonVersion"]
|
63
|
+
except:
|
64
|
+
pythonVersion = None
|
65
|
+
|
66
|
+
pipArgs = [
|
67
|
+
sys.executable,
|
68
|
+
"-m",
|
69
|
+
"pip",
|
70
|
+
"install",
|
71
|
+
"-r",
|
72
|
+
requirementstxt,
|
73
|
+
"--prefix",
|
74
|
+
python_prefix,
|
75
|
+
]
|
76
|
+
if pythonVersion:
|
77
|
+
print("Specific Python version requested. Forcing reinstall.")
|
78
|
+
# prevent uninstalling system packages.
|
79
|
+
pipArgs.append("--ignore-installed")
|
80
|
+
# force reinstall even if it exists in system packages.
|
81
|
+
pipArgs.append("--force-reinstall")
|
82
|
+
|
83
|
+
p = subprocess.Popen(pipArgs, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
84
|
+
|
85
|
+
while True:
|
86
|
+
line = p.stdout.readline()
|
87
|
+
if not line:
|
88
|
+
break
|
89
|
+
line = line.decode("utf8").rstrip("\r\n")
|
90
|
+
print(line)
|
91
|
+
result = p.wait()
|
92
|
+
print("pip install result %s" % result)
|
93
|
+
if result:
|
94
|
+
if not ignore_error:
|
95
|
+
raise Exception("non-zero result from pip %s" % result)
|
96
|
+
else:
|
97
|
+
print("ignoring non-zero result from pip %s" % result)
|
98
|
+
else:
|
99
|
+
f = open(installed_requirementstxt, "wb")
|
100
|
+
f.write(requirements_str.encode())
|
101
|
+
f.close()
|
package/python/plugin_remote.py
CHANGED
@@ -6,7 +6,7 @@ import gc
|
|
6
6
|
import os
|
7
7
|
import platform
|
8
8
|
import shutil
|
9
|
-
import
|
9
|
+
from plugin_pip import install_with_pip, remove_pip_dirs, need_requirements
|
10
10
|
import sys
|
11
11
|
import threading
|
12
12
|
import time
|
@@ -41,6 +41,12 @@ import rpc
|
|
41
41
|
import rpc_reader
|
42
42
|
|
43
43
|
|
44
|
+
OPTIONAL_REQUIREMENTS = """
|
45
|
+
ptpython
|
46
|
+
""".strip()
|
47
|
+
|
48
|
+
|
49
|
+
|
44
50
|
class ClusterObject(TypedDict):
|
45
51
|
id: str
|
46
52
|
port: int
|
@@ -337,6 +343,7 @@ class PluginRemote:
|
|
337
343
|
self.pluginId = pluginId
|
338
344
|
self.hostInfo = hostInfo
|
339
345
|
self.loop = loop
|
346
|
+
self.replPort = None
|
340
347
|
self.__dict__['__proxy_oneway_methods'] = [
|
341
348
|
'notify',
|
342
349
|
'updateDeviceState',
|
@@ -567,81 +574,28 @@ class PluginRemote:
|
|
567
574
|
if not os.path.exists(python_prefix):
|
568
575
|
os.makedirs(python_prefix)
|
569
576
|
|
577
|
+
str_requirements = ""
|
570
578
|
if 'requirements.txt' in zip.namelist():
|
571
|
-
|
572
|
-
|
573
|
-
|
574
|
-
|
575
|
-
|
576
|
-
|
577
|
-
|
578
|
-
|
579
|
-
|
580
|
-
|
581
|
-
|
582
|
-
|
583
|
-
|
584
|
-
|
585
|
-
|
586
|
-
|
587
|
-
|
588
|
-
|
589
|
-
|
590
|
-
|
591
|
-
print('Removing old dependencies: %s' %
|
592
|
-
filePath)
|
593
|
-
try:
|
594
|
-
shutil.rmtree(filePath)
|
595
|
-
except:
|
596
|
-
pass
|
597
|
-
except:
|
598
|
-
pass
|
599
|
-
|
600
|
-
os.makedirs(python_prefix)
|
601
|
-
|
602
|
-
print('requirements.txt (outdated)')
|
603
|
-
print(str_requirements)
|
604
|
-
|
605
|
-
f = open(requirementstxt, 'wb')
|
606
|
-
f.write(requirements)
|
607
|
-
f.close()
|
608
|
-
|
609
|
-
try:
|
610
|
-
pythonVersion = packageJson['scrypted']['pythonVersion']
|
611
|
-
except:
|
612
|
-
pythonVersion = None
|
613
|
-
|
614
|
-
pipArgs = [
|
615
|
-
sys.executable,
|
616
|
-
'-m', 'pip', 'install', '-r', requirementstxt,
|
617
|
-
'--prefix', python_prefix
|
618
|
-
]
|
619
|
-
if pythonVersion:
|
620
|
-
print('Specific Python version requested. Forcing reinstall.')
|
621
|
-
# prevent uninstalling system packages.
|
622
|
-
pipArgs.append('--ignore-installed')
|
623
|
-
# force reinstall even if it exists in system packages.
|
624
|
-
pipArgs.append('--force-reinstall')
|
625
|
-
|
626
|
-
p = subprocess.Popen(pipArgs, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
627
|
-
|
628
|
-
while True:
|
629
|
-
line = p.stdout.readline()
|
630
|
-
if not line:
|
631
|
-
break
|
632
|
-
line = line.decode('utf8').rstrip('\r\n')
|
633
|
-
print(line)
|
634
|
-
result = p.wait()
|
635
|
-
print('pip install result %s' % result)
|
636
|
-
if result:
|
637
|
-
raise Exception('non-zero result from pip %s' % result)
|
638
|
-
|
639
|
-
f = open(installed_requirementstxt, 'wb')
|
640
|
-
f.write(requirements)
|
641
|
-
f.close()
|
642
|
-
else:
|
643
|
-
print('requirements.txt (up to date)')
|
644
|
-
print(str_requirements)
|
579
|
+
str_requirements = zip.open('requirements.txt').read().decode('utf8')
|
580
|
+
|
581
|
+
optional_requirements_basename = os.path.join(
|
582
|
+
python_prefix, 'requirements.optional')
|
583
|
+
requirements_basename = os.path.join(
|
584
|
+
python_prefix, 'requirements')
|
585
|
+
|
586
|
+
need_pip = True
|
587
|
+
if str_requirements:
|
588
|
+
need_pip = need_requirements(requirements_basename, str_requirements)
|
589
|
+
if not need_pip:
|
590
|
+
need_pip = need_requirements(optional_requirements_basename, OPTIONAL_REQUIREMENTS)
|
591
|
+
|
592
|
+
if need_pip:
|
593
|
+
remove_pip_dirs(plugin_volume)
|
594
|
+
install_with_pip(python_prefix, packageJson, OPTIONAL_REQUIREMENTS, optional_requirements_basename, ignore_error=True)
|
595
|
+
install_with_pip(python_prefix, packageJson, str_requirements, requirements_basename, ignore_error=False)
|
596
|
+
else:
|
597
|
+
print('requirements.txt (up to date)')
|
598
|
+
print(str_requirements)
|
645
599
|
|
646
600
|
sys.path.insert(0, zipPath)
|
647
601
|
if platform.system() != 'Windows':
|
@@ -744,7 +698,14 @@ class PluginRemote:
|
|
744
698
|
|
745
699
|
if not forkMain:
|
746
700
|
from main import create_scrypted_plugin # type: ignore
|
747
|
-
|
701
|
+
pluginInstance = await rpc.maybe_await(create_scrypted_plugin())
|
702
|
+
try:
|
703
|
+
from plugin_repl import createREPLServer
|
704
|
+
self.replPort = await createREPLServer(sdk, pluginInstance)
|
705
|
+
except Exception as e:
|
706
|
+
print(f"Warning: Python REPL cannot be loaded: {e}")
|
707
|
+
self.replPort = 0
|
708
|
+
return pluginInstance
|
748
709
|
|
749
710
|
from main import fork # type: ignore
|
750
711
|
forked = await rpc.maybe_await(fork())
|
@@ -795,7 +756,13 @@ class PluginRemote:
|
|
795
756
|
pass
|
796
757
|
|
797
758
|
async def getServicePort(self, name):
|
798
|
-
|
759
|
+
if name == "repl":
|
760
|
+
if self.replPort is None:
|
761
|
+
raise Exception('REPL unavailable: Plugin not loaded.')
|
762
|
+
if self.replPort == 0:
|
763
|
+
raise Exception('REPL unavailable: Python REPL not available.')
|
764
|
+
return self.replPort
|
765
|
+
raise Exception(f'unknown service {name}')
|
799
766
|
|
800
767
|
async def start_stats_runner(self):
|
801
768
|
update_stats = await self.peer.getParam('updateStats')
|
@@ -0,0 +1,143 @@
|
|
1
|
+
import asyncio
|
2
|
+
import concurrent.futures
|
3
|
+
from prompt_toolkit import print_formatted_text
|
4
|
+
from prompt_toolkit.contrib.telnet.server import TelnetServer
|
5
|
+
from ptpython.repl import embed, PythonRepl
|
6
|
+
import socket
|
7
|
+
import telnetlib
|
8
|
+
import threading
|
9
|
+
from typing import List, Dict, Any
|
10
|
+
|
11
|
+
from scrypted_python.scrypted_sdk import ScryptedStatic, ScryptedDevice
|
12
|
+
|
13
|
+
from rpc import maybe_await
|
14
|
+
|
15
|
+
|
16
|
+
def configure(repl: PythonRepl) -> None:
|
17
|
+
repl.confirm_exit = False
|
18
|
+
repl.enable_system_bindings = False
|
19
|
+
repl.enable_mouse_support = False
|
20
|
+
|
21
|
+
|
22
|
+
async def createREPLServer(sdk: ScryptedStatic, plugin: ScryptedDevice) -> int:
|
23
|
+
deviceManager = sdk.deviceManager
|
24
|
+
systemManager = sdk.systemManager
|
25
|
+
mediaManager = sdk.mediaManager
|
26
|
+
|
27
|
+
# Create the proxy server to handle initial control messages
|
28
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
29
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
30
|
+
sock.settimeout(None)
|
31
|
+
sock.bind(('localhost', 0))
|
32
|
+
sock.listen(1)
|
33
|
+
|
34
|
+
async def start_telnet_repl(future, filter) -> None:
|
35
|
+
if filter == "undefined":
|
36
|
+
filter = None
|
37
|
+
|
38
|
+
chain: List[str] = []
|
39
|
+
nativeIds: Dict[str, Any] = deviceManager.nativeIds
|
40
|
+
reversed: Dict[str, str] = {v.id: k for k, v in nativeIds.items()}
|
41
|
+
|
42
|
+
while filter is not None:
|
43
|
+
id = nativeIds.get(filter).id
|
44
|
+
d = systemManager.getDeviceById(id)
|
45
|
+
chain.append(filter)
|
46
|
+
filter = reversed.get(d.providerId)
|
47
|
+
|
48
|
+
chain.reverse()
|
49
|
+
device = plugin
|
50
|
+
for c in chain:
|
51
|
+
device = await maybe_await(device.getDevice(c))
|
52
|
+
|
53
|
+
realDevice = systemManager.getDeviceById(device.id)
|
54
|
+
|
55
|
+
# Select a free port for the telnet server
|
56
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
57
|
+
s.bind(('localhost', 0))
|
58
|
+
telnet_port = s.getsockname()[1]
|
59
|
+
s.close()
|
60
|
+
|
61
|
+
async def interact(connection) -> None:
|
62
|
+
global_dict = {
|
63
|
+
**globals(),
|
64
|
+
"print": print_formatted_text,
|
65
|
+
"help": lambda *args, **kwargs: print_formatted_text("Help is not available in this environment"),
|
66
|
+
}
|
67
|
+
locals_dict = {
|
68
|
+
"device": device,
|
69
|
+
"systemManager": systemManager,
|
70
|
+
"deviceManager": deviceManager,
|
71
|
+
"mediaManager": mediaManager,
|
72
|
+
"sdk": sdk,
|
73
|
+
"realDevice": realDevice
|
74
|
+
}
|
75
|
+
vars_prompt = '\n'.join([f" {k}" for k in locals_dict.keys()])
|
76
|
+
banner = f"Python REPL variables:\n{vars_prompt}"
|
77
|
+
print_formatted_text(banner)
|
78
|
+
await embed(return_asyncio_coroutine=True, globals=global_dict, locals=locals_dict, configure=configure)
|
79
|
+
|
80
|
+
# Start the REPL server
|
81
|
+
telnet_server = TelnetServer(interact=interact, port=telnet_port, enable_cpr=False)
|
82
|
+
telnet_server.start()
|
83
|
+
|
84
|
+
future.set_result(telnet_port)
|
85
|
+
|
86
|
+
loop = asyncio.get_event_loop()
|
87
|
+
|
88
|
+
def handle_connection(conn: socket.socket):
|
89
|
+
conn.settimeout(None)
|
90
|
+
filter = conn.recv(1024).decode()
|
91
|
+
|
92
|
+
future = concurrent.futures.Future()
|
93
|
+
loop.call_soon_threadsafe(loop.create_task, start_telnet_repl(future, filter))
|
94
|
+
telnet_port = future.result()
|
95
|
+
|
96
|
+
telnet_client = telnetlib.Telnet('localhost', telnet_port, timeout=None)
|
97
|
+
|
98
|
+
def telnet_negotiation_cb(telnet_socket, command, option):
|
99
|
+
pass # ignore telnet negotiation
|
100
|
+
telnet_client.set_option_negotiation_callback(telnet_negotiation_cb)
|
101
|
+
|
102
|
+
# initialize telnet terminal
|
103
|
+
# this tells the telnet server we are a vt100 terminal
|
104
|
+
telnet_client.get_socket().sendall(b'\xff\xfb\x18\xff\xfa\x18\x00\x61\x6e\x73\x69\xff\xf0')
|
105
|
+
telnet_client.get_socket().sendall(b'\r\n')
|
106
|
+
|
107
|
+
# Bridge the connection to the telnet server, two way
|
108
|
+
def forward_to_telnet():
|
109
|
+
while True:
|
110
|
+
data = conn.recv(1024)
|
111
|
+
if not data:
|
112
|
+
break
|
113
|
+
telnet_client.write(data)
|
114
|
+
def forward_to_socket():
|
115
|
+
prompt_count = 0
|
116
|
+
while True:
|
117
|
+
data = telnet_client.read_some()
|
118
|
+
if not data:
|
119
|
+
conn.sendall('REPL exited'.encode())
|
120
|
+
break
|
121
|
+
if b">>>" in data:
|
122
|
+
# This is an ugly hack - somewhere in ptpython, the
|
123
|
+
# initial prompt is being printed many times. Normal
|
124
|
+
# telnet clients handle it properly, but xtermjs doesn't
|
125
|
+
# like it. We just replace the first few with spaces
|
126
|
+
# so it's not too ugly.
|
127
|
+
prompt_count += 1
|
128
|
+
if prompt_count < 5:
|
129
|
+
data = data.replace(b">>>", b" ")
|
130
|
+
conn.sendall(data)
|
131
|
+
|
132
|
+
threading.Thread(target=forward_to_telnet).start()
|
133
|
+
threading.Thread(target=forward_to_socket).start()
|
134
|
+
|
135
|
+
def accept_connection():
|
136
|
+
while True:
|
137
|
+
conn, addr = sock.accept()
|
138
|
+
threading.Thread(target=handle_connection, args=(conn,)).start()
|
139
|
+
|
140
|
+
threading.Thread(target=accept_connection).start()
|
141
|
+
|
142
|
+
proxy_port = sock.getsockname()[1]
|
143
|
+
return proxy_port
|