Coverage for larch/xmlrpc_server.py: 0%

264 statements  

« prev     ^ index     » next       coverage.py v7.6.0, created at 2024-10-16 21:04 +0000

1#!/usr/bin/env python 

2""" 

3XML RPC 

4""" 

5from __future__ import print_function 

6 

7import os 

8import sys 

9from time import time, sleep, ctime 

10import signal 

11import socket 

12from pathlib import Path 

13from subprocess import Popen 

14from threading import Thread 

15from argparse import ArgumentParser, RawDescriptionHelpFormatter 

16 

17from xmlrpc.server import SimpleXMLRPCServer 

18from xmlrpc.client import ServerProxy 

19 

20from .interpreter import Interpreter 

21from .utils import uname, get_cwd 

22from .utils.jsonutils import encode4js 

23 

24try: 

25 import psutil 

26 HAS_PSUTIL = True 

27except ImportError: 

28 HAS_PSUTIL = False 

29 

30NOT_IN_USE, CONNECTED, NOT_LARCHSERVER = range(3) 

31POLL_TIME = 0.50 

32 

33"""Notes: 

34 0. test server with HOST/PORT, report status (CREATED, ALREADY_RUNNING, FAILED). 

35 1. prompt to kill a running server on HOST/PORT, preferably giving a 

36 'last used by {APPNAME} with {PROCESS_ID} at {DATETIME}' 

37 2. launch server on next unused PORT on HOST, increment by 1 to 100, report status. 

38 3. connect to running server on HOST/PORT. 

39 4. have each client set a keepalive time (that is, 

40 'die after having no activity for X seconds') for each server (default=3*24*3600.0). 

41""" 

42 

43def test_server(host='localhost', port=4966): 

44 """Test for a Larch server on host and port 

45 

46 Arguments 

47 host (str): host name ['localhost'] 

48 port (int): port number [4966] 

49 

50 Returns 

51 integer status number: 

52 0 Not in use. 

53 1 Connected, valid Larch server 

54 2 In use, but not a valid Larch server 

55 """ 

56 server = ServerProxy(f'http://{host:s}:{port:d}') 

57 try: 

58 methods = server.system.listMethods() 

59 except socket.error: 

60 return NOT_IN_USE 

61 

62 # verify that this is a valid larch server 

63 if len(methods) < 5 or 'larch' not in methods: 

64 return NOT_LARCHSERVER 

65 ret = '' 

66 try: 

67 ret = server.get_rawdata('_sys.config.user_larchdir') 

68 except: 

69 return NOT_LARCHSERVER 

70 if len(ret) < 1: 

71 return NOT_LARCHSERVER 

72 

73 return CONNECTED 

74 

75 

76def get_next_port(host='localhost', port=4966, nmax=100): 

77 """Return next available port for a Larch server on host 

78 

79 Arguments 

80 host (str): host name ['localhost'] 

81 port (int): starting port number [4966] 

82 nmax (int): maximum number to try [100] 

83 

84 Returns 

85 integer: next unused port number or None in nmax exceeded. 

86 """ 

87 # special case for localhost: 

88 # use psutil to find next unused port 

89 if host.lower() == 'localhost': 

90 if HAS_PSUTIL and uname == 'win': 

91 available = [True]*nmax 

92 try: 

93 conns = psutil.net_connections() 

94 except: 

95 conns = [] 

96 if len(conns) > 0: 

97 for conn in conns: 

98 ptest = conn.laddr[1] - port 

99 if ptest >= 0 and ptest < nmax: 

100 available[ptest] = False 

101 for index, status in enumerate(available): 

102 if status: 

103 return port+index 

104 # now test with brute attempt to open the socket: 

105 for index in range(nmax): 

106 ptest = port + index 

107 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0) 

108 success = False 

109 try: 

110 sock.bind(('', ptest)) 

111 success = True 

112 except socket.error: 

113 pass 

114 finally: 

115 sock.close() 

116 if success: 

117 return ptest 

118 

119 # for remote servers or if the above did not work, need to test ports 

120 for index in range(nmax): 

121 ptest = port + index 

122 if NOT_IN_USE == test_server(host=host, port=ptest): 

123 return ptest 

124 return None 

125 

126class LarchServer(SimpleXMLRPCServer): 

127 "xml-rpc server" 

128 def __init__(self, host='localhost', port=4966, 

129 logRequests=False, allow_none=True, 

130 keepalive_time=3*24*3600): 

131 self.out_buffer = [] 

132 

133 self.larch = Interpreter(writer=self) 

134 self.larch.input.prompt = '' 

135 self.larch.input.prompt2 = '' 

136 self.larch.run_init_scripts() 

137 

138 self.larch('_sys.client = group(keepalive_time=%f)' % keepalive_time) 

139 self.larch('_sys.wx = group(wxapp=None)') 

140 _sys = self.larch.symtable._sys 

141 _sys.color_exceptions = False 

142 _sys.client.last_event = int(time()) 

143 _sys.client.pid_server = int(os.getpid()) 

144 _sys.client.app = 'unknown' 

145 _sys.client.pid = 0 

146 _sys.client.user = 'unknown' 

147 _sys.client.machine = socket.getfqdn() 

148 

149 self.client = self.larch.symtable._sys.client 

150 self.port = port 

151 SimpleXMLRPCServer.__init__(self, (host, port), 

152 logRequests=logRequests, 

153 allow_none=allow_none) 

154 

155 self.register_introspection_functions() 

156 self.register_function(self.larch_exec, 'larch') 

157 

158 for method in ('ls', 'chdir', 'cd', 'cwd', 'shutdown', 

159 'set_keepalive_time', 'set_client_info', 

160 'get_client_info', 'get_data', 'get_rawdata', 

161 'get_messages', 'len_messages'): 

162 self.register_function(getattr(self, method), method) 

163 

164 # sys.stdout = self 

165 self.finished = False 

166 signal.signal(signal.SIGINT, self.signal_handler) 

167 self.activity_thread = Thread(target=self.check_activity) 

168 

169 def write(self, text): 

170 if text is None: 

171 text = '' 

172 self.out_buffer.append(str(text)) 

173 

174 def flush(self): 

175 pass 

176 

177 def set_keepalive_time(self, keepalive_time): 

178 """set keepalive time 

179 the server will self destruct after keepalive_time of inactivity 

180 

181 Arguments: 

182 keepalive_time (number): time in seconds 

183 

184 """ 

185 self.larch("_sys.client.keepalive_time = %f" % keepalive_time) 

186 

187 def set_client_info(self, key, value): 

188 """set client info 

189 

190 Arguments: 

191 key (str): category 

192 value (str): value to use 

193 

194 Notes: 

195 the key can actually be any string but include by convention: 

196 app application name 

197 user user name 

198 machine machine name 

199 pid process id 

200 """ 

201 self.larch("_sys.client.%s = '%s'" % (key, value)) 

202 

203 def get_client_info(self): 

204 """get client info: 

205 returns json dictionary of client information 

206 """ 

207 out = {'port': self.port} 

208 client = self.larch.symtable._sys.client 

209 for attr in dir(client): 

210 out[attr] = getattr(client, attr) 

211 return encode4js(out) 

212 

213 def get_messages(self): 

214 """get (and clear) all output messages (say, from "print()") 

215 """ 

216 out = "".join(self.out_buffer) 

217 self.out_buffer = [] 

218 return out 

219 

220 def len_messages(self): 

221 "length of message buffer" 

222 return len(self.out_buffer) 

223 

224 def ls(self, dir_name): 

225 """list contents of a directory: """ 

226 return os.listdir(dir_name) 

227 

228 def chdir(self, dir_name): 

229 """change directory""" 

230 return os.chdir(dir_name) 

231 

232 def cd(self, dir_name): 

233 """change directory""" 

234 return os.chdir(dir_name) 

235 

236 def cwd(self): 

237 """change directory""" 

238 ret = get_cwd() 

239 if uname == 'win': 

240 ret = ret.replace('\\','/') 

241 return ret 

242 

243 def signal_handler(self, sig=0, frame=None): 

244 self.kill() 

245 

246 def kill(self): 

247 """handle alarm signal, generated by signal.alarm(t)""" 

248 sleep(POLL_TIME) 

249 self.shutdown() 

250 self.server_close() 

251 

252 def shutdown(self): 

253 "shutdown LarchServer" 

254 self.finished = True 

255 if self.activity_thread.is_alive(): 

256 self.activity_thread.join(POLL_TIME) 

257 return 1 

258 

259 def check_activity(self): 

260 while not self.finished: 

261 sleep(POLL_TIME) 

262 # print("Tick ", time()- (self.client.keepalive_time + self.client.last_event)) 

263 if time() > (self.client.keepalive_time + self.client.last_event): 

264 t = Thread(target=self.kill) 

265 t.start() 

266 break 

267 

268 def larch_exec(self, text): 

269 "execute larch command" 

270 text = text.strip() 

271 if text in ('quit', 'exit', 'EOF'): 

272 self.shutdown() 

273 else: 

274 ret = self.larch.eval(text, lineno=0) 

275 if ret is not None: 

276 self.write(repr(ret)) 

277 self.client.last_event = time() 

278 self.flush() 

279 return 1 

280 

281 def get_rawdata(self, expr): 

282 "return non-json encoded data for a larch expression" 

283 return self.larch.eval(expr) 

284 

285 def get_data(self, expr): 

286 "return json encoded data for a larch expression" 

287 self.larch('_sys.client.last_event = %i' % time()) 

288 return encode4js(self.larch.eval(expr)) 

289 

290 def run(self): 

291 """run server until times out""" 

292 self.activity_thread.start() 

293 while not self.finished: 

294 try: 

295 self.handle_request() 

296 except: 

297 break 

298 

299def spawn_server(port=4966, wait=True, timeout=30): 

300 """ 

301 start a new process for a LarchServer on selected port, 

302 optionally waiting to confirm connection 

303 """ 

304 topdir = sys.exec_prefix 

305 pyexe = Path(topdir, 'bin', 'python3').as_posix() 

306 bindir = 'bin' 

307 if uname.startswith('win'): 

308 bindir = 'Scripts' 

309 pyexe = pyexe + '.exe' 

310 

311 args = [pyexe, Path(topdir, bindir, 'larch').as_posix(), 

312 '-r', '-p', '%d' % port] 

313 pipe = Popen(args) 

314 if wait: 

315 time0 = time() 

316 while time() - time0 < timeout: 

317 sleep(POLL_TIME) 

318 if CONNECTED == test_server(port=port): 

319 break 

320 return pipe 

321 

322 

323### 

324def larch_server_cli(): 

325 """command-line program to control larch XMLRPC server""" 

326 command_desc = """ 

327command must be one of the following: 

328 start start server on specified port 

329 stop stop server on specified port 

330 restart restart server on specified port 

331 next start server on next avaialable port (see also '-n' option) 

332 status print a short status message: whether server< is running on port 

333 report print a multi-line status report 

334""" 

335 

336 parser = ArgumentParser(description='run larch XML-RPC server', 

337 formatter_class=RawDescriptionHelpFormatter, 

338 epilog=command_desc) 

339 

340 parser.add_argument("-p", "--port", dest="port", default='4966', 

341 help="port number for remote server [4966]") 

342 

343 parser.add_argument("-n", "--next", dest="next", action="store_true", 

344 default=False, 

345 help="show next available port, but do not start [False]") 

346 

347 parser.add_argument("-q", "--quiet", dest="quiet", action="store_true", 

348 default=False, help="suppress messaages [False]") 

349 

350 parser.add_argument("command", nargs='?', help="server command ['status']") 

351 

352 args = parser.parse_args() 

353 

354 

355 port = int(args.port) 

356 command = args.command or 'status' 

357 command = command.lower() 

358 

359 def smsg(port, txt): 

360 if not args.quiet: 

361 print('larch_server port=%i: %s' % (port, txt)) 

362 

363 

364 if args.next: 

365 port = get_next_port(port=port) 

366 print(port) 

367 sys.exit(0) 

368 

369 server_state = test_server(port=port) 

370 

371 if command == 'start': 

372 if server_state == CONNECTED: 

373 smsg(port, 'already running') 

374 elif server_state == NOT_IN_USE: 

375 spawn_server(port=port) 

376 smsg(port, 'started') 

377 else: 

378 smsg(port, 'port is in use, cannot start') 

379 

380 elif command == 'stop': 

381 if server_state == CONNECTED: 

382 ServerProxy(f'http://localhost:{port:d}').shutdown() 

383 smsg(port, 'stopped') 

384 

385 elif command == 'next': 

386 port = get_next_port(port=port) 

387 spawn_server(port=port) 

388 smsg(port, 'started') 

389 

390 elif command == 'restart': 

391 if server_state == CONNECTED: 

392 ServerProxy(f'http://localhost:{port:d}').shutdown() 

393 sleep(POLL_TIME) 

394 spawn_server(port=port) 

395 

396 elif command == 'status': 

397 if server_state == CONNECTED: 

398 smsg(port, 'running') 

399 sys.exit(0) 

400 elif server_state == NOT_IN_USE: 

401 smsg(port, 'not running') 

402 sys.exit(1) 

403 else: 

404 smsg(port, 'port is in use by non-larch server') 

405 elif command == 'report': 

406 if server_state == CONNECTED: 

407 s = ServerProxy(f'http://localhost:{port:d}') 

408 info = s.get_client_info() 

409 last_event = info.get('last_event', 0) 

410 last_used = ctime(last_event) 

411 serverid = int(info.get('pid_server', 0)) 

412 serverport= int(info.get('port', 0)) 

413 procid = int(info.get('pid', 0)) 

414 appname = info.get('app', 'unknown') 

415 machname = info.get('machine', 'unknown') 

416 username = info.get('user', 'unknown') 

417 keepalive_time = info.get('keepalive_time', -1) 

418 keepalive_time += (last_event - time()) 

419 keepalive_units = 'seconds' 

420 if keepalive_time > 150: 

421 keepalive_time = round(keepalive_time/60.0) 

422 keepalive_units = 'minutes' 

423 if keepalive_time > 150: 

424 keepalive_time = round(keepalive_time/60.0) 

425 keepalive_units = 'hours' 

426 

427 print(f"""larch_server report: 

428 Server Port Number = {serverport} 

429 Server Process ID = {serverid} 

430 Server Last Used = {last_used} 

431 Server will expire in {keepalive_time} {keepalive_units} if not used. 

432 Client Machine Name = {machname} 

433 Client Process ID = {procid:d} 

434 Client Application = {appname} 

435 Client User Name = {username} 

436""") 

437 elif server_state == NOT_IN_USE: 

438 smsg(port, 'not running') 

439 sys.exit(1) 

440 else: 

441 smsg(port, 'port is in use by non-larch server') 

442 

443 else: 

444 print(f"larch_server: unknown command '{command}'. Try -h") 

445 

446 

447if __name__ == '__main__': 

448 spawn_server(port=4966)