jsocket 1.9.3__py3-none-any.whl → 1.9.6__py3-none-any.whl

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.
jsocket/tserver.py CHANGED
@@ -1,204 +1,372 @@
1
1
  """ @namespace tserver
2
- Contains ThreadedServer, ServerFactoryThread and ServerFactory implementations.
2
+ Contains ThreadedServer, ServerFactoryThread and ServerFactory implementations.
3
3
  """
4
4
 
5
- __author__ = "Christopher Piekarski"
6
- __email__ = "chris@cpiekarski.com"
5
+ __author__ = "Christopher Piekarski"
6
+ __email__ = "chris@cpiekarski.com"
7
7
  __copyright__= """
8
- This file is part of the jsocket package.
9
- Copyright (C) 2011 by
10
- Christopher Piekarski <chris@cpiekarski.com>
8
+ Copyright (C) 2011 by
9
+ Christopher Piekarski <chris@cpiekarski.com>
11
10
 
12
- The tserver module is free software: you can redistribute it and/or modify
13
- it under the terms of the GNU General Public License as published by
14
- the Free Software Foundation, either version 3 of the License, or
15
- (at your option) any later version.
11
+ Licensed under the Apache License, Version 2.0 (the "License");
12
+ you may not use this file except in compliance with the License.
13
+ You may obtain a copy of the License at
16
14
 
17
- The jsocket package is distributed in the hope that it will be useful,
18
- but WITHOUT ANY WARRANTY; without even the implied warranty of
19
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20
- GNU General Public License for more details.
15
+ http://www.apache.org/licenses/LICENSE-2.0
21
16
 
22
- You should have received a copy of the GNU General Public License
23
- along with tserver module. If not, see <http://www.gnu.org/licenses/>."""
24
- __version__ = "1.0.2"
25
-
26
- import jsocket.jsocket_base as jsocket_base
17
+ Unless required by applicable law or agreed to in writing, software
18
+ distributed under the License is distributed on an "AS IS" BASIS,
19
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20
+ See the License for the specific language governing permissions and
21
+ limitations under the License.
22
+ """
27
23
  import threading
28
24
  import socket
29
25
  import time
30
26
  import logging
27
+ import abc
28
+ from typing import Optional
29
+
30
+ from jsocket import jsocket_base
31
+ from ._version import __version__
31
32
 
32
33
  logger = logging.getLogger("jsocket.tserver")
33
34
 
34
- class ThreadedServer(threading.Thread, jsocket_base.JsonServer):
35
- def __init__(self, **kwargs):
36
- threading.Thread.__init__(self)
37
- jsocket_base.JsonServer.__init__(self, **kwargs)
38
- self._isAlive = False
39
-
40
- def _process_message(self, obj):
41
- """ Pure Virtual Method
42
-
43
- This method is called every time a JSON object is received from a client
44
-
45
- @param obj JSON "key: value" object received from client
46
- @retval None or a response object
47
- """
48
- pass
49
-
50
- def run(self):
51
- while self._isAlive:
52
- try:
53
- self.accept_connection()
54
- except socket.timeout as e:
55
- logger.debug("socket.timeout: %s" % e)
56
- continue
57
- except Exception as e:
58
- logger.exception(e)
59
- continue
60
-
61
- while self._isAlive:
62
- try:
63
- obj = self.read_obj()
64
- resp_obj = self._process_message(obj)
65
- if resp_obj is not None:
66
- logger.debug("message has a response")
67
- self.send_obj(resp_obj)
68
- except socket.timeout as e:
69
- logger.debug("socket.timeout: %s" % e)
70
- continue
71
- except Exception as e:
72
- logger.exception(e)
73
- self._close_connection()
74
- break
75
- self._close_socket()
76
-
77
- def start(self):
78
- """ Starts the threaded server.
79
- The newly living know nothing of the dead
80
-
81
- @retval None
82
- """
83
- self._isAlive = True
84
- super(ThreadedServer, self).start()
85
- logger.debug("Threaded Server has been started.")
86
-
87
- def stop(self):
88
- """ Stops the threaded server.
89
- The life of the dead is in the memory of the living
90
-
91
- @retval None
92
- """
93
- self._isAlive = False
94
- logger.debug("Threaded Server has been stopped.")
95
-
96
- class ServerFactoryThread(threading.Thread, jsocket_base.JsonSocket):
97
- def __init__(self, **kwargs):
98
- threading.Thread.__init__(self, **kwargs)
99
- jsocket_base.JsonSocket.__init__(self, **kwargs)
100
- self._isAlive = False
101
-
102
- def swap_socket(self, new_sock):
103
- """ Swaps the existing socket with a new one. Useful for setting socket after a new connection.
104
-
105
- @param new_sock socket to replace the existing default jsocket.JsonSocket object
106
- @retval None
107
- """
108
- del self.socket
109
- self.socket = new_sock
110
- self.conn = self.socket
111
-
112
- def run(self):
113
- """ Should exit when client closes socket conn.
114
- Can force an exit with force_stop.
115
- """
116
- while self._isAlive:
117
- try:
118
- obj = self.read_obj()
119
- resp_obj = self._process_message(obj)
120
- if resp_obj is not None:
121
- logger.debug("message has a response")
122
- self.send_obj(resp_obj)
123
- except socket.timeout as e:
124
- logger.debug("socket.timeout: %s" % e)
125
- continue
126
- except Exception as e:
127
- logger.info("client connection broken, exit and close connection socket")
128
- self._isAlive = False
129
- break
130
- self._close_connection()
131
-
132
- def start(self):
133
- """ Starts the factory thread.
134
- The newly living know nothing of the dead
135
-
136
- @retval None
137
- """
138
- self._isAlive = True
139
- super(ServerFactoryThread, self).start()
140
- logger.debug("ServerFactoryThread has been started.")
141
-
142
- def force_stop(self):
143
- """ Force stops the factory thread.
144
- Should exit when client socket is closed under normal conditions.
145
- The life of the dead is in the memory of the living.
146
-
147
- @retval None
148
- """
149
- self._isAlive = False
150
- logger.debug("ServerFactoryThread has been stopped.")
151
-
152
-
35
+
36
+ def _response_summary(resp_obj) -> str:
37
+ if isinstance(resp_obj, dict):
38
+ return f"type=dict keys={len(resp_obj)}"
39
+ if isinstance(resp_obj, (list, tuple)):
40
+ return f"type={type(resp_obj).__name__} items={len(resp_obj)}"
41
+ return f"type={type(resp_obj).__name__}"
42
+
43
+
44
+ def _format_client_id(addr) -> str:
45
+ try:
46
+ host, port = addr[0], addr[1]
47
+ if ":" in host:
48
+ return f"[{host}]:{port}"
49
+ return f"{host}:{port}"
50
+ except Exception: # pylint: disable=broad-exception-caught
51
+ return "unknown"
52
+
53
+
54
+ class ThreadedServer(threading.Thread, jsocket_base.JsonServer, metaclass=abc.ABCMeta):
55
+ """Single-threaded server that accepts one connection and processes messages in its thread."""
56
+
57
+ def __init__(self, **kwargs):
58
+ threading.Thread.__init__(self)
59
+ jsocket_base.JsonServer.__init__(self, **kwargs)
60
+ self._is_alive = False
61
+ self._stats_lock = threading.Lock()
62
+ self._client_started_at = None
63
+ self._client_id = None
64
+
65
+ @abc.abstractmethod
66
+ def _process_message(self, obj) -> Optional[dict]:
67
+ """Pure Virtual Method
68
+
69
+ This method is called every time a JSON object is received from a client
70
+
71
+ @param obj JSON "key: value" object received from client
72
+ @retval None or a response object
73
+ """
74
+ # Return None in the base class to satisfy linters; subclasses should override.
75
+ return None
76
+
77
+ def _record_client_start(self):
78
+ addr = getattr(self, "_last_client_addr", None)
79
+ if addr is None:
80
+ try:
81
+ addr = self.conn.getpeername()
82
+ except OSError:
83
+ addr = None
84
+ with self._stats_lock:
85
+ self._client_started_at = time.monotonic()
86
+ self._client_id = _format_client_id(addr)
87
+
88
+ def _clear_client_stats(self):
89
+ with self._stats_lock:
90
+ self._client_started_at = None
91
+ self._client_id = None
92
+
93
+ def get_client_stats(self) -> dict:
94
+ """Return connected client count and per-client durations in seconds."""
95
+ with self._stats_lock:
96
+ started_at = self._client_started_at
97
+ client_id = self._client_id
98
+ if not started_at or not client_id or not self.connected:
99
+ return {"connected_clients": 0, "clients": {}}
100
+ duration = time.monotonic() - started_at
101
+ return {"connected_clients": 1, "clients": {client_id: duration}}
102
+
103
+ def _accept_client(self) -> bool:
104
+ """Accept an incoming connection; return True when a client connects."""
105
+ try:
106
+ self.accept_connection()
107
+ except socket.timeout as e:
108
+ logger.debug("accept timeout on %s:%s: %s", self.address, self.port, e)
109
+ return False
110
+ except Exception as e: # pylint: disable=broad-exception-caught
111
+ # Avoid noisy error logs during normal shutdown/sequencing
112
+ if self._is_alive:
113
+ logger.debug("accept error on %s:%s: %s", self.address, self.port, e)
114
+ return False
115
+ logger.debug("server stopping; accept loop exiting (%s:%s)", self.address, self.port)
116
+ self._is_alive = False
117
+ return False
118
+ self._record_client_start()
119
+ return True
120
+
121
+ def _handle_client_messages(self):
122
+ """Read, process, and respond to client messages until disconnect."""
123
+ while self._is_alive:
124
+ try:
125
+ obj = self.read_obj()
126
+ resp_obj = self._process_message(obj)
127
+ if resp_obj is not None:
128
+ logger.debug("sending response (%s)", _response_summary(resp_obj))
129
+ self.send_obj(resp_obj)
130
+ except socket.timeout as e:
131
+ logger.debug("read timeout waiting for client data: %s", e)
132
+ continue
133
+ except Exception as e: # pylint: disable=broad-exception-caught
134
+ # Treat client disconnects as normal; keep logs at info/debug
135
+ msg = str(e)
136
+ if isinstance(e, RuntimeError) and 'socket connection broken' in msg:
137
+ logger.info("client connection broken, closing connection")
138
+ else:
139
+ logger.debug("handler error (%s): %s", type(e).__name__, e)
140
+ self._close_connection()
141
+ break
142
+ self._clear_client_stats()
143
+
144
+ def run(self):
145
+ # Ensure the run loop is active even when run() is invoked directly
146
+ # (tests may call run() in a separate thread without invoking start()).
147
+ if not self._is_alive:
148
+ self._is_alive = True
149
+ while self._is_alive:
150
+ if not self._accept_client():
151
+ continue
152
+ self._handle_client_messages()
153
+ # Ensure sockets are cleaned up when the server stops
154
+ try:
155
+ self.close()
156
+ except OSError:
157
+ pass
158
+
159
+ def start(self):
160
+ """ Starts the threaded server.
161
+ The newly living know nothing of the dead
162
+
163
+ @retval None
164
+ """
165
+ self._is_alive = True
166
+ super().start()
167
+ logger.debug("Threaded Server started on %s:%s", self.address, self.port)
168
+
169
+ def stop(self):
170
+ """ Stops the threaded server.
171
+ The life of the dead is in the memory of the living
172
+
173
+ @retval None
174
+ """
175
+ self._is_alive = False
176
+ self._clear_client_stats()
177
+ logger.debug("Threaded Server stopped on %s:%s", self.address, self.port)
178
+
179
+
180
+ class ServerFactoryThread(threading.Thread, jsocket_base.JsonSocket, metaclass=abc.ABCMeta):
181
+ """Per-connection worker thread used by ServerFactory."""
182
+
183
+ def __init__(self, **kwargs):
184
+ threading.Thread.__init__(self, **kwargs)
185
+ self.socket = None
186
+ self.conn = None
187
+ jsocket_base.JsonSocket.__init__(self, **kwargs)
188
+ self._is_alive = False
189
+ self._client_started_at = None
190
+ self._client_id = None
191
+
192
+ def swap_socket(self, new_sock):
193
+ """ Swaps the existing socket with a new one. Useful for setting socket after a new connection.
194
+
195
+ @param new_sock socket to replace the existing default jsocket.JsonSocket object
196
+ @retval None
197
+ """
198
+ self.socket = new_sock
199
+ self.conn = self.socket
200
+ try:
201
+ addr = new_sock.getpeername()
202
+ except OSError:
203
+ addr = None
204
+ self._client_id = _format_client_id(addr)
205
+ self._client_started_at = time.monotonic()
206
+
207
+ def run(self):
208
+ """ Should exit when client closes socket conn.
209
+ Can force an exit with force_stop.
210
+ """
211
+ while self._is_alive:
212
+ try:
213
+ obj = self.read_obj()
214
+ resp_obj = self._process_message(obj)
215
+ if resp_obj is not None:
216
+ logger.debug("sending response (%s)", _response_summary(resp_obj))
217
+ self.send_obj(resp_obj)
218
+ except socket.timeout as e:
219
+ logger.debug("worker read timeout waiting for data: %s", e)
220
+ continue
221
+ except Exception as e: # pylint: disable=broad-exception-caught
222
+ logger.info("client connection broken, closing connection: %s", e)
223
+ self._is_alive = False
224
+ break
225
+ self._close_connection()
226
+ if hasattr(self, "socket"):
227
+ self._close_socket()
228
+
229
+ @abc.abstractmethod
230
+ def _process_message(self, obj) -> Optional[dict]:
231
+ """Pure Virtual Method - Implementer must define protocol
232
+
233
+ @param obj JSON "key: value" object received from client
234
+ @retval None or a response object
235
+ """
236
+ # Return None in the base class to satisfy linters; subclasses should override.
237
+ return None
238
+
239
+ def start(self):
240
+ """ Starts the factory thread.
241
+ The newly living know nothing of the dead
242
+
243
+ @retval None
244
+ """
245
+ self._is_alive = True
246
+ super().start()
247
+ logger.debug("ServerFactoryThread started (%s)", self.name)
248
+
249
+ def force_stop(self):
250
+ """ Force stops the factory thread.
251
+ Should exit when client socket is closed under normal conditions.
252
+ The life of the dead is in the memory of the living.
253
+
254
+ @retval None
255
+ """
256
+ self._is_alive = False
257
+ logger.debug("ServerFactoryThread stopped (%s)", self.name)
258
+
259
+
153
260
  class ServerFactory(ThreadedServer):
154
- def __init__(self, server_thread, **kwargs):
155
- ThreadedServer.__init__(self, address=kwargs['address'], port=kwargs['port'])
156
- if not issubclass(server_thread, ServerFactoryThread):
157
- raise TypeError("serverThread not of type", ServerFactoryThread)
158
- self._thread_type = server_thread
159
- self._threads = []
160
- self._thread_args = kwargs
161
- self._thread_args.pop('address', None)
162
- self._thread_args.pop('port', None)
163
-
164
- def run(self):
165
- while self._isAlive:
166
- tmp = self._thread_type(**self._thread_args)
167
- self._purge_threads()
168
- while not self.connected and self._isAlive:
169
- try:
170
- self.accept_connection()
171
- except socket.timeout as e:
172
- logger.debug("socket.timeout: %s" % e)
173
- continue
174
- except Exception as e:
175
- logger.exception(e)
176
- continue
177
- else:
178
- tmp.swap_socket(self.conn)
179
- tmp.start()
180
- self._threads.append(tmp)
181
- break
182
-
183
- self._wait_to_exit()
184
- self.close()
185
-
186
- def stop_all(self):
187
- for t in self._threads:
188
- if t.is_alive():
189
- t.force_stop()
190
- t.join()
191
-
192
- def _purge_threads(self):
193
- for t in self._threads:
194
- if not t.is_alive():
195
- self._threads.remove(t)
196
-
197
- def _wait_to_exit(self):
198
- while self._get_num_of_active_threads():
199
- time.sleep(0.2)
200
-
201
- def _get_num_of_active_threads(self):
202
- return len([True for x in self._threads if x.is_alive()])
203
-
204
- active = property(_get_num_of_active_threads, doc="number of active threads")
261
+ """Accepts clients and spawns a ServerFactoryThread per connection."""
262
+ def __init__(self, server_thread, **kwargs):
263
+ ThreadedServer.__init__(self, address=kwargs['address'], port=kwargs['port'])
264
+ if not issubclass(server_thread, ServerFactoryThread):
265
+ raise TypeError("serverThread not of type", ServerFactoryThread)
266
+ self._thread_type = server_thread
267
+ self._threads = []
268
+ self._threads_lock = threading.Lock()
269
+ self._thread_args = kwargs
270
+ self._thread_args.pop('address', None)
271
+ self._thread_args.pop('port', None)
272
+
273
+ def _process_message(self, obj) -> Optional[dict]:
274
+ """ServerFactory does not process messages itself."""
275
+ return None
276
+
277
+ def run(self):
278
+ # Ensure the run loop is active even when run() is invoked directly
279
+ # (tests may call run() in a separate thread without invoking start()).
280
+ if not self._is_alive:
281
+ self._is_alive = True
282
+ while self._is_alive:
283
+ tmp = self._thread_type(**self._thread_args)
284
+ self._purge_threads()
285
+ while not self.connected and self._is_alive:
286
+ try:
287
+ self.accept_connection()
288
+ except socket.timeout as e:
289
+ logger.debug("factory accept timeout on %s:%s: %s", self.address, self.port, e)
290
+ continue
291
+ except Exception as e: # pylint: disable=broad-exception-caught
292
+ logger.exception("accept error: %s", e)
293
+ continue
294
+ else:
295
+ # Hand off the accepted connection to the worker
296
+ accepted_conn = self.conn
297
+ # Reset server connection reference so we can accept again
298
+ self._reset_connection_ref()
299
+ if not self._is_alive:
300
+ # Server is stopping; close the accepted connection without spawning a worker.
301
+ try:
302
+ accepted_conn.shutdown(socket.SHUT_RDWR)
303
+ except OSError:
304
+ pass
305
+ try:
306
+ accepted_conn.close()
307
+ except OSError:
308
+ pass
309
+ break
310
+ tmp.swap_socket(accepted_conn)
311
+ tmp.start()
312
+ with self._threads_lock:
313
+ self._threads.append(tmp)
314
+ break
315
+
316
+ self._wait_to_exit()
317
+ self.close()
318
+
319
+ def stop_all(self):
320
+ """Stop and join all active worker threads."""
321
+ while True:
322
+ with self._threads_lock:
323
+ threads = [t for t in self._threads if t.is_alive()]
324
+ if not threads:
325
+ break
326
+ for t in threads:
327
+ t.force_stop()
328
+ t.join()
329
+ self._purge_threads()
330
+
331
+ def _purge_threads(self):
332
+ # Rebuild list to avoid mutating while iterating
333
+ with self._threads_lock:
334
+ self._threads = [t for t in self._threads if t.is_alive()]
335
+
336
+ def stop(self):
337
+ # Stop accepting and stop all workers
338
+ self._is_alive = False
339
+ try:
340
+ self.stop_all()
341
+ except Exception: # pylint: disable=broad-exception-caught
342
+ pass
343
+ logger.debug("ServerFactory stopped on %s:%s", self.address, self.port)
344
+
345
+ def _wait_to_exit(self):
346
+ """Block until all worker threads have finished."""
347
+ while self._get_num_of_active_threads():
348
+ time.sleep(0.2)
349
+
350
+ def _get_num_of_active_threads(self):
351
+ with self._threads_lock:
352
+ threads = list(self._threads)
353
+ return len([True for x in threads if x.is_alive()])
354
+
355
+ def get_client_stats(self) -> dict:
356
+ """Return connected client count and per-client durations in seconds."""
357
+ with self._threads_lock:
358
+ threads = list(self._threads)
359
+ now = time.monotonic()
360
+ clients = {}
361
+ active = 0
362
+ for t in threads:
363
+ if not t.is_alive():
364
+ continue
365
+ active += 1
366
+ started_at = getattr(t, "_client_started_at", None)
367
+ client_id = getattr(t, "_client_id", None) or f"thread-{t.name}"
368
+ duration = now - started_at if started_at else 0.0
369
+ clients[client_id] = duration
370
+ return {"connected_clients": active, "clients": clients}
371
+
372
+ active = property(_get_num_of_active_threads, doc="number of active threads")