jsocket 1.9.5__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/__init__.py CHANGED
@@ -73,3 +73,4 @@
73
73
  """
74
74
  from jsocket.jsocket_base import *
75
75
  from jsocket.tserver import *
76
+ from ._version import __version__
jsocket/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Package version."""
2
+
3
+ __version__ = "1.9.6"
jsocket/jsocket_base.py CHANGED
@@ -19,14 +19,14 @@ __copyright__= """
19
19
  See the License for the specific language governing permissions and
20
20
  limitations under the License.
21
21
  """
22
- __version__ = "1.0.3"
23
-
24
22
  import json
25
23
  import socket
26
24
  import struct
27
25
  import logging
28
26
  import time
29
27
 
28
+ from ._version import __version__
29
+
30
30
  logger = logging.getLogger("jsocket")
31
31
 
32
32
 
@@ -46,6 +46,7 @@ class JsonSocket:
46
46
  self._timeout = timeout
47
47
  self._address = address
48
48
  self._port = port
49
+ self._last_client_addr = None
49
50
  # Ensure the primary socket respects timeout for accept/connect operations
50
51
  self.socket.settimeout(self._timeout)
51
52
 
@@ -183,6 +184,7 @@ class JsonServer(JsonSocket):
183
184
  """Listen and accept a single client connection; set timeout accordingly."""
184
185
  self._listen()
185
186
  self.conn, addr = self._accept()
187
+ self._last_client_addr = addr
186
188
  self.conn.settimeout(self.timeout)
187
189
  logger.debug(
188
190
  "connection accepted, conn socket (%s,%d,%s)", addr[0], addr[1], str(self.conn.gettimeout())
jsocket/tserver.py CHANGED
@@ -20,15 +20,15 @@ __copyright__= """
20
20
  See the License for the specific language governing permissions and
21
21
  limitations under the License.
22
22
  """
23
- __version__ = "1.0.3"
24
-
25
23
  import threading
26
24
  import socket
27
25
  import time
28
26
  import logging
29
27
  import abc
30
28
  from typing import Optional
29
+
31
30
  from jsocket import jsocket_base
31
+ from ._version import __version__
32
32
 
33
33
  logger = logging.getLogger("jsocket.tserver")
34
34
 
@@ -41,6 +41,16 @@ def _response_summary(resp_obj) -> str:
41
41
  return f"type={type(resp_obj).__name__}"
42
42
 
43
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
+
44
54
  class ThreadedServer(threading.Thread, jsocket_base.JsonServer, metaclass=abc.ABCMeta):
45
55
  """Single-threaded server that accepts one connection and processes messages in its thread."""
46
56
 
@@ -48,6 +58,9 @@ class ThreadedServer(threading.Thread, jsocket_base.JsonServer, metaclass=abc.AB
48
58
  threading.Thread.__init__(self)
49
59
  jsocket_base.JsonServer.__init__(self, **kwargs)
50
60
  self._is_alive = False
61
+ self._stats_lock = threading.Lock()
62
+ self._client_started_at = None
63
+ self._client_id = None
51
64
 
52
65
  @abc.abstractmethod
53
66
  def _process_message(self, obj) -> Optional[dict]:
@@ -61,6 +74,32 @@ class ThreadedServer(threading.Thread, jsocket_base.JsonServer, metaclass=abc.AB
61
74
  # Return None in the base class to satisfy linters; subclasses should override.
62
75
  return None
63
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
+
64
103
  def _accept_client(self) -> bool:
65
104
  """Accept an incoming connection; return True when a client connects."""
66
105
  try:
@@ -76,6 +115,7 @@ class ThreadedServer(threading.Thread, jsocket_base.JsonServer, metaclass=abc.AB
76
115
  logger.debug("server stopping; accept loop exiting (%s:%s)", self.address, self.port)
77
116
  self._is_alive = False
78
117
  return False
118
+ self._record_client_start()
79
119
  return True
80
120
 
81
121
  def _handle_client_messages(self):
@@ -99,6 +139,7 @@ class ThreadedServer(threading.Thread, jsocket_base.JsonServer, metaclass=abc.AB
99
139
  logger.debug("handler error (%s): %s", type(e).__name__, e)
100
140
  self._close_connection()
101
141
  break
142
+ self._clear_client_stats()
102
143
 
103
144
  def run(self):
104
145
  # Ensure the run loop is active even when run() is invoked directly
@@ -132,6 +173,7 @@ class ThreadedServer(threading.Thread, jsocket_base.JsonServer, metaclass=abc.AB
132
173
  @retval None
133
174
  """
134
175
  self._is_alive = False
176
+ self._clear_client_stats()
135
177
  logger.debug("Threaded Server stopped on %s:%s", self.address, self.port)
136
178
 
137
179
 
@@ -144,6 +186,8 @@ class ServerFactoryThread(threading.Thread, jsocket_base.JsonSocket, metaclass=a
144
186
  self.conn = None
145
187
  jsocket_base.JsonSocket.__init__(self, **kwargs)
146
188
  self._is_alive = False
189
+ self._client_started_at = None
190
+ self._client_id = None
147
191
 
148
192
  def swap_socket(self, new_sock):
149
193
  """ Swaps the existing socket with a new one. Useful for setting socket after a new connection.
@@ -153,6 +197,12 @@ class ServerFactoryThread(threading.Thread, jsocket_base.JsonSocket, metaclass=a
153
197
  """
154
198
  self.socket = new_sock
155
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()
156
206
 
157
207
  def run(self):
158
208
  """ Should exit when client closes socket conn.
@@ -215,6 +265,7 @@ class ServerFactory(ThreadedServer):
215
265
  raise TypeError("serverThread not of type", ServerFactoryThread)
216
266
  self._thread_type = server_thread
217
267
  self._threads = []
268
+ self._threads_lock = threading.Lock()
218
269
  self._thread_args = kwargs
219
270
  self._thread_args.pop('address', None)
220
271
  self._thread_args.pop('port', None)
@@ -245,9 +296,21 @@ class ServerFactory(ThreadedServer):
245
296
  accepted_conn = self.conn
246
297
  # Reset server connection reference so we can accept again
247
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
248
310
  tmp.swap_socket(accepted_conn)
249
311
  tmp.start()
250
- self._threads.append(tmp)
312
+ with self._threads_lock:
313
+ self._threads.append(tmp)
251
314
  break
252
315
 
253
316
  self._wait_to_exit()
@@ -255,14 +318,20 @@ class ServerFactory(ThreadedServer):
255
318
 
256
319
  def stop_all(self):
257
320
  """Stop and join all active worker threads."""
258
- for t in self._threads:
259
- if t.is_alive():
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:
260
327
  t.force_stop()
261
328
  t.join()
329
+ self._purge_threads()
262
330
 
263
331
  def _purge_threads(self):
264
332
  # Rebuild list to avoid mutating while iterating
265
- self._threads = [t for t in self._threads if t.is_alive()]
333
+ with self._threads_lock:
334
+ self._threads = [t for t in self._threads if t.is_alive()]
266
335
 
267
336
  def stop(self):
268
337
  # Stop accepting and stop all workers
@@ -279,6 +348,25 @@ class ServerFactory(ThreadedServer):
279
348
  time.sleep(0.2)
280
349
 
281
350
  def _get_num_of_active_threads(self):
282
- return len([True for x in self._threads if x.is_alive()])
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}
283
371
 
284
372
  active = property(_get_num_of_active_threads, doc="number of active threads")
@@ -0,0 +1,166 @@
1
+ Metadata-Version: 2.4
2
+ Name: jsocket
3
+ Version: 1.9.6
4
+ Summary: Python JSON Server & Client
5
+ Author-email: Christopher Piekarski <chris@cpiekarski.com>
6
+ Maintainer-email: Christopher Piekarski <chris@cpiekarski.com>
7
+ License-Expression: Apache-2.0
8
+ Project-URL: Homepage, https://cpiekarski.com/2012/01/25/python-json-client-server-redux/
9
+ Keywords: json,socket,server,client
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Topic :: System :: Networking
15
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
16
+ Classifier: Topic :: System :: Distributed Computing
17
+ Classifier: Topic :: System :: Hardware :: Symmetric Multi-processing
18
+ Requires-Python: >=3.8
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Dynamic: license-file
22
+
23
+ python-json-socket (jsocket)
24
+ ============================
25
+
26
+ [![CI](https://github.com/chris-piekarski/python-json-socket/actions/workflows/ci.yml/badge.svg)](https://github.com/chris-piekarski/python-json-socket/actions/workflows/ci.yml)
27
+ ![PyPI](https://img.shields.io/pypi/v/jsocket.svg)
28
+ ![Python Versions](https://img.shields.io/pypi/pyversions/jsocket.svg)
29
+ ![License](https://img.shields.io/pypi/l/jsocket.svg)
30
+
31
+ Simple JSON-over-TCP sockets for Python. This library provides:
32
+
33
+ - JsonClient/JsonServer: length‑prefixed JSON message framing over TCP
34
+ - ThreadedServer: a single-connection server running in its own thread
35
+ - ServerFactory/ServerFactoryThread: a per‑connection worker model for multiple clients
36
+
37
+ It aims to be small, predictable, and easy to integrate in tests or small services.
38
+
39
+
40
+ Install
41
+ -------
42
+
43
+ ```
44
+ pip install jsocket
45
+ ```
46
+
47
+ Requires Python 3.8+.
48
+
49
+
50
+ Quickstart
51
+ ----------
52
+
53
+ Echo server with `ThreadedServer` and a client:
54
+
55
+ ```python
56
+ import time
57
+ import jsocket
58
+
59
+ class Echo(jsocket.ThreadedServer):
60
+ def __init__(self, **kwargs):
61
+ super().__init__(**kwargs)
62
+ self.timeout = 2.0
63
+
64
+ # Return a dict to send a response back to the client
65
+ def _process_message(self, obj):
66
+ if isinstance(obj, dict) and 'echo' in obj:
67
+ return obj
68
+ return None
69
+
70
+ # Bind to an ephemeral port (port=0)
71
+ server = Echo(address='127.0.0.1', port=0)
72
+ _, port = server.socket.getsockname()
73
+ server.start()
74
+
75
+ client = jsocket.JsonClient(address='127.0.0.1', port=port)
76
+ assert client.connect() is True
77
+
78
+ payload = {"echo": "hello"}
79
+ client.send_obj(payload)
80
+ assert client.read_obj() == payload
81
+
82
+ client.close()
83
+ server.stop()
84
+ server.join()
85
+ ```
86
+
87
+ Per‑connection workers with `ServerFactory`:
88
+
89
+ ```python
90
+ import jsocket
91
+
92
+ class Worker(jsocket.ServerFactoryThread):
93
+ def __init__(self):
94
+ super().__init__()
95
+ self.timeout = 2.0
96
+
97
+ def _process_message(self, obj):
98
+ if isinstance(obj, dict) and 'message' in obj:
99
+ return {"reply": f"got: {obj['message']}"}
100
+
101
+ server = jsocket.ServerFactory(Worker, address='127.0.0.1', port=5489)
102
+ server.start()
103
+ # Connect one or more clients; one Worker is spawned per connection
104
+ ```
105
+
106
+
107
+ API Highlights
108
+ --------------
109
+
110
+ - JsonClient:
111
+ - `connect()` returns True on success
112
+ - `send_obj(dict)` sends a JSON object
113
+ - `read_obj()` blocks until a full message is received; raises `socket.timeout` or `RuntimeError("socket connection broken")`
114
+ - `timeout` property controls socket timeouts
115
+
116
+ - ThreadedServer:
117
+ - Subclass and implement `_process_message(self, obj) -> Optional[dict]`
118
+ - Return a dict to send a response; return `None` to send nothing
119
+ - `start()`, `stop()`, `join()` manage the server thread
120
+ - `send_obj(dict)` sends to the currently connected client
121
+
122
+ - ServerFactory / ServerFactoryThread:
123
+ - `ServerFactoryThread` is a worker that handles one client connection
124
+ - `ServerFactory` accepts connections and spawns a worker per client
125
+
126
+
127
+ Examples and Tests
128
+ ------------------
129
+
130
+ - Examples: see `examples/example_servers.py` and `scripts/smoke_test.py`
131
+ - Pytest: end-to-end and listener tests under `tests/`
132
+ - Run: `pytest -q`
133
+
134
+
135
+ Behavior-Driven Tests (Behave)
136
+ ------------------------------
137
+
138
+ - Steps live under `features/steps/` and environment hooks in `features/environment.py`.
139
+ - To run Behave scenarios, add one or more `.feature` files under `features/` and run:
140
+ - `pip install -r requirements-dev.txt`
141
+ - `PYTHONPATH=. behave -f progress2`
142
+ - A minimal example feature:
143
+
144
+ ```gherkin
145
+ Feature: Echo round-trip
146
+ Scenario: client/server echo
147
+ Given I start the server
148
+ And I connect the client
149
+ When the client sends the object {"echo": "hi"}
150
+ Then the client sees a message {"echo": "hi"}
151
+ ```
152
+
153
+
154
+ Notes
155
+ -----
156
+
157
+ - Message framing uses a 4‑byte big‑endian length header followed by a JSON payload encoded as UTF‑8.
158
+ - On disconnect, reads raise `RuntimeError("socket connection broken")` so callers can distinguish cleanly from timeouts.
159
+ - Binding with `port=0` lets the OS choose an ephemeral port; find it with `server.socket.getsockname()`.
160
+
161
+
162
+ Links
163
+ -----
164
+
165
+ - PyPI: https://pypi.org/project/jsocket/
166
+ - License: see `LICENSE`
@@ -0,0 +1,9 @@
1
+ jsocket/__init__.py,sha256=V2M4mp2IwcL1Zy_yoD-6Y5h7PO2sdt5Plf1U0Xw27N8,2870
2
+ jsocket/_version.py,sha256=qOzpkpbIx06kwOoN9b1utOqUXOBX3mBIrcqYtuZOMkQ,46
3
+ jsocket/jsocket_base.py,sha256=lc72kDs-6ID-e4006jiVCZPRhVsRIDuQrSV5-oSdah0,8112
4
+ jsocket/tserver.py,sha256=h19so2UgUc1RIMmkNcJamwZ7gbFFGmaSJqrI2l6XUts,14098
5
+ jsocket-1.9.6.dist-info/licenses/LICENSE,sha256=TIwob4kUNx1DKZ0NVKToEDAFgWsevvTgtZgr_obkDhg,11355
6
+ jsocket-1.9.6.dist-info/METADATA,sha256=ebDsoiSN1UF_vqugNBMPWlA54HVCzeodYN7ow1kvwPE,5082
7
+ jsocket-1.9.6.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
8
+ jsocket-1.9.6.dist-info/top_level.txt,sha256=QqfmeUi7avy9cdcsVVvG68CP-4mfg_P6E7OuBuNEcN4,8
9
+ jsocket-1.9.6.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.37.1)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,27 +0,0 @@
1
- Metadata-Version: 2.1
2
- Name: jsocket
3
- Version: 1.9.5
4
- Summary: Python JSON Server & Client
5
- Home-page: https://cpiekarski.com/2012/01/25/python-json-client-server-redux/
6
- Author: Christopher Piekarski
7
- Author-email: chris@cpiekarski.com
8
- Maintainer: Christopher Piekarski
9
- Maintainer-email: chris@cpiekarski.com
10
- License: OSI Approved Apache Software License
11
- Keywords: json,socket,server,client
12
- Platform: UNKNOWN
13
- Classifier: Intended Audience :: Developers
14
- Classifier: License :: OSI Approved :: Apache Software License
15
- Classifier: Programming Language :: Python :: 3.9
16
- Classifier: Operating System :: OS Independent
17
- Classifier: Development Status :: 5 - Production/Stable
18
- Classifier: Topic :: System :: Networking
19
- Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
20
- Classifier: Topic :: System :: Distributed Computing
21
- Classifier: Topic :: System :: Hardware :: Symmetric Multi-processing
22
- Provides: jsocket
23
- Requires-Python: >=3.8
24
- License-File: LICENSE
25
-
26
- UNKNOWN
27
-
@@ -1,8 +0,0 @@
1
- jsocket/__init__.py,sha256=Im4nFil0iBOXF0G5K5I2nVMkSDIlGNev-rOjy3XUwn8,2836
2
- jsocket/jsocket_base.py,sha256=65-0DMdgQKymiE3poBpvRnyTMgEv3-VOfqWe-C-6ge4,8025
3
- jsocket/tserver.py,sha256=bez8pKWwzPQXD0R33sTV-6Z579DS4vfQnnPkklzxt64,10746
4
- jsocket-1.9.5.dist-info/LICENSE,sha256=TIwob4kUNx1DKZ0NVKToEDAFgWsevvTgtZgr_obkDhg,11355
5
- jsocket-1.9.5.dist-info/METADATA,sha256=hV3r6UToy38ZkkrSi8ohRGndzCN_xYa8329Ue_hUhXQ,983
6
- jsocket-1.9.5.dist-info/WHEEL,sha256=G16H4A3IeoQmnOrYV4ueZGKSjhipXx8zc8nu9FGlvMA,92
7
- jsocket-1.9.5.dist-info/top_level.txt,sha256=QqfmeUi7avy9cdcsVVvG68CP-4mfg_P6E7OuBuNEcN4,8
8
- jsocket-1.9.5.dist-info/RECORD,,