simple-socket-client 2.0__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.
- simple_socket_client/__init__.py +302 -0
- simple_socket_client/py.typed +0 -0
- simple_socket_client-2.0.dist-info/METADATA +238 -0
- simple_socket_client-2.0.dist-info/RECORD +7 -0
- simple_socket_client-2.0.dist-info/WHEEL +5 -0
- simple_socket_client-2.0.dist-info/licenses/LICENSE.md +13 -0
- simple_socket_client-2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Simple TCP socket client built on select().
|
|
3
|
+
|
|
4
|
+
The client keeps its connection in a background thread: send() queues the bytes
|
|
5
|
+
for that thread, which polls the socket and calls the handlers registered for its
|
|
6
|
+
events. No third-party networking code is involved, the only runtime dependency
|
|
7
|
+
is the event bus used to dispatch those events.
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2023–2026 webtoucher
|
|
10
|
+
Distributed under the BSD 3-Clause license. See LICENSE.md for more info.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import queue
|
|
14
|
+
import select
|
|
15
|
+
import socket
|
|
16
|
+
import threading
|
|
17
|
+
|
|
18
|
+
from collections import deque
|
|
19
|
+
from typing import Deque, Optional
|
|
20
|
+
|
|
21
|
+
from event_bus import EventBus
|
|
22
|
+
|
|
23
|
+
__all__ = ["SimpleSocketClient", "SimpleSocketClientException"]
|
|
24
|
+
|
|
25
|
+
__version__ = "2.0"
|
|
26
|
+
|
|
27
|
+
#: Size of a single recv() chunk; a message event carries one such chunk.
|
|
28
|
+
_RECV_SIZE = 1024
|
|
29
|
+
|
|
30
|
+
#: How long select() waits for socket activity, in seconds.
|
|
31
|
+
_POLL_INTERVAL = 0.1
|
|
32
|
+
|
|
33
|
+
#: How long disconnect() waits for the queued messages before it lets them go.
|
|
34
|
+
_DRAIN_TIMEOUT = 5.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SimpleSocketClientException(socket.error):
|
|
38
|
+
"""Raised when the connection cannot be opened or is not available."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SimpleSocketClient(EventBus):
|
|
42
|
+
"""TCP client dispatching events through an event bus.
|
|
43
|
+
|
|
44
|
+
Events, in the order they may arrive:
|
|
45
|
+
|
|
46
|
+
``connect(host, port)``
|
|
47
|
+
The connection is open. Emitted by connect(), so it is called from the
|
|
48
|
+
thread that called connect().
|
|
49
|
+
``message(data)``
|
|
50
|
+
A chunk of bytes has been received.
|
|
51
|
+
``disconnect(host, port)``
|
|
52
|
+
The connection is closed: the server closed it, reset it, a send failed
|
|
53
|
+
or disconnect() was called. Queued messages are delivered before it.
|
|
54
|
+
``error(error)``
|
|
55
|
+
A socket error occurred; the connection is closed right after and
|
|
56
|
+
``disconnect`` follows.
|
|
57
|
+
|
|
58
|
+
Every handler but the ``connect`` one is called from the thread that serves
|
|
59
|
+
the connection, one at a time, so a slow handler delays reading and writing.
|
|
60
|
+
**Handlers must not raise**: the exception breaks the serving loop, the
|
|
61
|
+
connection is closed and the exception is printed by the threading module.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, host: str, port: int) -> None:
|
|
65
|
+
super().__init__()
|
|
66
|
+
self.__host = host
|
|
67
|
+
self.__port = port
|
|
68
|
+
self.__socket: Optional[socket.socket] = None
|
|
69
|
+
self.__thread: Optional[threading.Thread] = None
|
|
70
|
+
self.__pending = bytearray()
|
|
71
|
+
self.__answers: Deque[queue.Queue] = deque()
|
|
72
|
+
self.__closing = False
|
|
73
|
+
self.__lock = threading.Lock()
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def is_connected(self) -> bool:
|
|
77
|
+
"""True while the connection is open and serving messages."""
|
|
78
|
+
return self.__socket is not None and not self.__closing
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def host(self) -> str:
|
|
82
|
+
"""Host the client connects to."""
|
|
83
|
+
return self.__host
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def port(self) -> int:
|
|
87
|
+
"""Port the client connects to."""
|
|
88
|
+
return self.__port
|
|
89
|
+
|
|
90
|
+
def connect(self, timeout: Optional[float] = None) -> None:
|
|
91
|
+
"""Open the connection to host:port.
|
|
92
|
+
|
|
93
|
+
The call blocks until the connection is established or fails. timeout is
|
|
94
|
+
passed to the socket, so None waits as long as the system allows.
|
|
95
|
+
A rejected, unreachable or timed out connection raises
|
|
96
|
+
SimpleSocketClientException.
|
|
97
|
+
|
|
98
|
+
A second connect() on a live connection raises RuntimeError; after
|
|
99
|
+
disconnect() the previous queue is delivered first.
|
|
100
|
+
"""
|
|
101
|
+
if self.is_connected:
|
|
102
|
+
raise RuntimeError("the client is already connected")
|
|
103
|
+
thread = self.__thread
|
|
104
|
+
if thread is not None and thread.is_alive():
|
|
105
|
+
# The previous connection is still draining its queue.
|
|
106
|
+
self.__wait_closed(thread)
|
|
107
|
+
sock = socket.socket(
|
|
108
|
+
socket.AF_INET,
|
|
109
|
+
socket.SOCK_STREAM,
|
|
110
|
+
socket.IPPROTO_TCP,
|
|
111
|
+
)
|
|
112
|
+
try:
|
|
113
|
+
sock.settimeout(timeout)
|
|
114
|
+
sock.connect((self.__host, self.__port))
|
|
115
|
+
sock.setblocking(False)
|
|
116
|
+
except socket.timeout as error:
|
|
117
|
+
sock.close()
|
|
118
|
+
raise SimpleSocketClientException(
|
|
119
|
+
"connection to %s:%s timed out" % (self.__host, self.__port)
|
|
120
|
+
) from error
|
|
121
|
+
except OSError as error:
|
|
122
|
+
sock.close()
|
|
123
|
+
raise SimpleSocketClientException(
|
|
124
|
+
"cannot connect to %s:%s" % (self.__host, self.__port)
|
|
125
|
+
) from error
|
|
126
|
+
with self.__lock:
|
|
127
|
+
self.__socket = sock
|
|
128
|
+
self.__pending.clear()
|
|
129
|
+
self.__closing = False
|
|
130
|
+
self.__thread = threading.Thread(target=self.__serve, args=(sock,), daemon=True)
|
|
131
|
+
self.__thread.start()
|
|
132
|
+
self.emit("connect", self.__host, self.__port)
|
|
133
|
+
|
|
134
|
+
def disconnect(self) -> None:
|
|
135
|
+
"""Close the connection gracefully.
|
|
136
|
+
|
|
137
|
+
The socket stops being read, the messages already queued by send() are
|
|
138
|
+
delivered, and only then the socket is closed and the ``disconnect``
|
|
139
|
+
event is emitted. A call from another thread waits for that to finish, for
|
|
140
|
+
up to 5 seconds (_DRAIN_TIMEOUT): a server that stopped reading makes the
|
|
141
|
+
rest of the queue be dropped. A call from an event handler only raises
|
|
142
|
+
the flag, the serving thread closes the socket itself.
|
|
143
|
+
|
|
144
|
+
Calling it on a closed client does nothing.
|
|
145
|
+
"""
|
|
146
|
+
with self.__lock:
|
|
147
|
+
if self.__socket is None:
|
|
148
|
+
return
|
|
149
|
+
self.__closing = True
|
|
150
|
+
thread = self.__thread
|
|
151
|
+
if thread is None or thread is threading.current_thread():
|
|
152
|
+
return
|
|
153
|
+
self.__wait_closed(thread)
|
|
154
|
+
|
|
155
|
+
def send(self, message: bytes) -> None:
|
|
156
|
+
"""Queue message for delivery to the server.
|
|
157
|
+
|
|
158
|
+
The socket is non-blocking, so the bytes are buffered and the unwritten
|
|
159
|
+
tail is retried on the next poll; a message larger than the socket buffer
|
|
160
|
+
is delivered in full. Empty messages are ignored.
|
|
161
|
+
|
|
162
|
+
Safe to call from another thread: delivery happens during the next poll,
|
|
163
|
+
so it may take up to the poll interval. Raises
|
|
164
|
+
SimpleSocketClientException when the client is not connected.
|
|
165
|
+
"""
|
|
166
|
+
if not message:
|
|
167
|
+
return
|
|
168
|
+
with self.__lock:
|
|
169
|
+
if self.__socket is None or self.__closing:
|
|
170
|
+
raise SimpleSocketClientException("the client is not connected")
|
|
171
|
+
self.__pending.extend(message)
|
|
172
|
+
|
|
173
|
+
def ask(self, message: bytes, timeout: Optional[float] = 1.) -> bytes:
|
|
174
|
+
"""Send message and return the first chunk of the answer.
|
|
175
|
+
|
|
176
|
+
The call blocks until data arrives: TimeoutError when nothing comes
|
|
177
|
+
within timeout, SimpleSocketClientException when the client is not
|
|
178
|
+
connected or the connection is closed while waiting. The chunk is
|
|
179
|
+
emitted through the ``message`` event as well, so the two ways of
|
|
180
|
+
receiving it see the same bytes. When several threads wait at once, each
|
|
181
|
+
chunk goes to the oldest of them.
|
|
182
|
+
"""
|
|
183
|
+
waiter: queue.Queue = queue.Queue()
|
|
184
|
+
with self.__lock:
|
|
185
|
+
self.__answers.append(waiter)
|
|
186
|
+
try:
|
|
187
|
+
self.send(message)
|
|
188
|
+
try:
|
|
189
|
+
answer = waiter.get(timeout=timeout)
|
|
190
|
+
except queue.Empty:
|
|
191
|
+
raise TimeoutError(
|
|
192
|
+
"no answer from %s:%s in %s seconds"
|
|
193
|
+
% (self.__host, self.__port, timeout)
|
|
194
|
+
) from None
|
|
195
|
+
finally:
|
|
196
|
+
with self.__lock:
|
|
197
|
+
if waiter in self.__answers:
|
|
198
|
+
self.__answers.remove(waiter)
|
|
199
|
+
if answer is None:
|
|
200
|
+
raise SimpleSocketClientException("the connection is closed")
|
|
201
|
+
return answer
|
|
202
|
+
|
|
203
|
+
def __serve(self, sock: socket.socket) -> None:
|
|
204
|
+
"""Poll the socket, write the queue and hand the answers over."""
|
|
205
|
+
try:
|
|
206
|
+
while True:
|
|
207
|
+
with self.__lock:
|
|
208
|
+
pending = bool(self.__pending)
|
|
209
|
+
if self.__closing and not pending:
|
|
210
|
+
break
|
|
211
|
+
try:
|
|
212
|
+
readable, writable, _ = select.select(
|
|
213
|
+
[] if self.__closing else [sock],
|
|
214
|
+
[sock] if pending else [],
|
|
215
|
+
[],
|
|
216
|
+
_POLL_INTERVAL,
|
|
217
|
+
)
|
|
218
|
+
except (OSError, ValueError) as error:
|
|
219
|
+
# select() broke: the socket was closed behind our back or
|
|
220
|
+
# is unusable, so the connection cannot be served any more.
|
|
221
|
+
self.emit("error", error)
|
|
222
|
+
break
|
|
223
|
+
if writable and not self.__flush(sock):
|
|
224
|
+
break
|
|
225
|
+
if readable and not self.__receive(sock):
|
|
226
|
+
break
|
|
227
|
+
finally:
|
|
228
|
+
self.__close(sock)
|
|
229
|
+
|
|
230
|
+
def __flush(self, sock: socket.socket) -> bool:
|
|
231
|
+
"""Write as much of the queue as the socket accepts. False on failure."""
|
|
232
|
+
failure: Optional[OSError] = None
|
|
233
|
+
with self.__lock:
|
|
234
|
+
if not self.__pending:
|
|
235
|
+
return True
|
|
236
|
+
try:
|
|
237
|
+
sent = sock.send(self.__pending)
|
|
238
|
+
except BlockingIOError:
|
|
239
|
+
# The kernel buffer is full again; the tail stays queued.
|
|
240
|
+
return True
|
|
241
|
+
except OSError as error:
|
|
242
|
+
failure = error
|
|
243
|
+
else:
|
|
244
|
+
del self.__pending[:sent]
|
|
245
|
+
if failure is None:
|
|
246
|
+
return True
|
|
247
|
+
self.emit("error", failure)
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
def __receive(self, sock: socket.socket) -> bool:
|
|
251
|
+
"""Read one chunk. False when the connection cannot be served anymore."""
|
|
252
|
+
try:
|
|
253
|
+
data = sock.recv(_RECV_SIZE)
|
|
254
|
+
except BlockingIOError:
|
|
255
|
+
# select() woke us up for nothing; try again on the next poll.
|
|
256
|
+
return True
|
|
257
|
+
except OSError as error:
|
|
258
|
+
self.emit("error", error)
|
|
259
|
+
return False
|
|
260
|
+
if not data:
|
|
261
|
+
# An empty chunk means the server closed its end of the connection.
|
|
262
|
+
# The messages queued for it are still delivered before the socket
|
|
263
|
+
# goes, the way a half-closed connection can still be written to.
|
|
264
|
+
with self.__lock:
|
|
265
|
+
self.__closing = True
|
|
266
|
+
return True
|
|
267
|
+
self.__deliver(data)
|
|
268
|
+
return True
|
|
269
|
+
|
|
270
|
+
def __deliver(self, data: bytes) -> None:
|
|
271
|
+
"""Hand the chunk over to the oldest ask() waiting and emit the event."""
|
|
272
|
+
with self.__lock:
|
|
273
|
+
waiter = self.__answers.popleft() if self.__answers else None
|
|
274
|
+
if waiter is not None:
|
|
275
|
+
waiter.put(data)
|
|
276
|
+
self.emit("message", data)
|
|
277
|
+
|
|
278
|
+
def __close(self, sock: socket.socket) -> None:
|
|
279
|
+
"""Drop the connection, release the waiting ask() calls and close."""
|
|
280
|
+
with self.__lock:
|
|
281
|
+
if self.__socket is not sock:
|
|
282
|
+
return
|
|
283
|
+
self.__socket = None
|
|
284
|
+
self.__closing = True
|
|
285
|
+
self.__pending.clear()
|
|
286
|
+
waiters, self.__answers = list(self.__answers), deque()
|
|
287
|
+
for waiter in waiters:
|
|
288
|
+
waiter.put(None)
|
|
289
|
+
try:
|
|
290
|
+
self.emit("disconnect", self.__host, self.__port)
|
|
291
|
+
finally:
|
|
292
|
+
sock.close()
|
|
293
|
+
|
|
294
|
+
def __wait_closed(self, thread: threading.Thread) -> None:
|
|
295
|
+
"""Wait until the serving loop has written its queue and closed."""
|
|
296
|
+
thread.join(_DRAIN_TIMEOUT)
|
|
297
|
+
if not thread.is_alive():
|
|
298
|
+
return
|
|
299
|
+
# The server stopped reading: let the queue go instead of waiting.
|
|
300
|
+
with self.__lock:
|
|
301
|
+
self.__pending.clear()
|
|
302
|
+
thread.join()
|
|
File without changes
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: simple-socket-client
|
|
3
|
+
Version: 2.0
|
|
4
|
+
Summary: Simple TCP socket client with select
|
|
5
|
+
Author-email: Alexey Kuznetsov <mirakuru@webtoucher.ru>
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
Project-URL: Homepage, https://github.com/webtoucher/simple-socket-client
|
|
8
|
+
Project-URL: Repository, https://github.com/webtoucher/simple-socket-client
|
|
9
|
+
Project-URL: Issues, https://github.com/webtoucher/simple-socket-client/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/webtoucher/simple-socket-client/blob/master/CHANGELOG.md
|
|
11
|
+
Keywords: socket,tcp,client,networking,event-driven
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
24
|
+
Classifier: Topic :: System :: Networking
|
|
25
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
26
|
+
Classifier: Operating System :: POSIX
|
|
27
|
+
Classifier: Operating System :: Unix
|
|
28
|
+
Classifier: Operating System :: MacOS
|
|
29
|
+
Classifier: Typing :: Typed
|
|
30
|
+
Requires-Python: >=3.9
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
License-File: LICENSE.md
|
|
33
|
+
Requires-Dist: event-bus~=1.0.2
|
|
34
|
+
Provides-Extra: dev
|
|
35
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
|
|
38
|
+
# Simple TCP socket client with select
|
|
39
|
+
|
|
40
|
+

|
|
41
|
+
[](https://pypi.python.org/pypi/simple-socket-client)
|
|
42
|
+
[](https://pypi.python.org/pypi/simple-socket-client)
|
|
43
|
+
[](https://pypi.python.org/pypi/simple-socket-client)
|
|
44
|
+
[](https://github.com/webtoucher/simple-socket-client/actions/workflows/tests.yml)
|
|
45
|
+
|
|
46
|
+
A TCP client that keeps one connection in a background thread on a `select()` poll
|
|
47
|
+
with no networking framework underneath: you subscribe handlers to events and the
|
|
48
|
+
client calls them. Type hints are shipped with the package, the only runtime
|
|
49
|
+
dependency is the event bus that dispatches those events.
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
Install it with pip:
|
|
54
|
+
|
|
55
|
+
```shell
|
|
56
|
+
$ pip install simple-socket-client
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Or you can add it as dependency in requirements.txt file of your python application:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
simple-socket-client~=2.0
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Usage
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from simple_socket_client import SimpleSocketClient
|
|
69
|
+
|
|
70
|
+
client = SimpleSocketClient('192.168.0.2', 6666)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@client.on('message')
|
|
74
|
+
def on_message(data: bytes):
|
|
75
|
+
print('Incoming data:', data)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@client.on('disconnect')
|
|
79
|
+
def on_disconnect(host, port):
|
|
80
|
+
print('Connection to %s:%s is closed' % (host, port))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
client.connect(timeout=10)
|
|
84
|
+
|
|
85
|
+
client.send(b'Test') # if you don't need an answer
|
|
86
|
+
answer = client.ask(b'Hi!')
|
|
87
|
+
print(answer.decode())
|
|
88
|
+
|
|
89
|
+
client.disconnect()
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`connect()` blocks the calling thread until the connection is established and
|
|
93
|
+
raises `SimpleSocketClientException` when it fails, so there is no need to wait
|
|
94
|
+
for the `connect` event to start using the client.
|
|
95
|
+
|
|
96
|
+
`ask()` is a shorthand for "send and wait for the first chunk of the answer":
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
answer = client.ask(b'PING\r\n', timeout=5)
|
|
100
|
+
if answer == b'PONG\r\n':
|
|
101
|
+
print('the server is alive')
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The same client works against [`simple-socket-server`](https://pypi.org/project/simple-socket-server/)
|
|
105
|
+
and any other TCP server:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from simple_socket_server import SimpleSocketServer
|
|
109
|
+
|
|
110
|
+
server = SimpleSocketServer()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@server.on('message')
|
|
114
|
+
def on_message(sock, peer, data):
|
|
115
|
+
server.send(sock, b'Hi, ' + data)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
server.run(host='0.0.0.0', port=6666)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Events
|
|
122
|
+
|
|
123
|
+
Handlers are called one at a time from the thread that serves the connection;
|
|
124
|
+
the `connect` handler is the exception, it is called by `connect()` itself.
|
|
125
|
+
|
|
126
|
+
| Event | Arguments | When it fires |
|
|
127
|
+
|--------------|-----------------|-------------------------------------------------------------------------------|
|
|
128
|
+
| `connect` | `host, port` | the connection is open |
|
|
129
|
+
| `message` | `data` | a chunk of bytes has been received |
|
|
130
|
+
| `disconnect` | `host, port` | the connection is closed; the messages queued by `send()` were delivered first |
|
|
131
|
+
| `error` | `error` | a socket error occurred; the connection is closed and `disconnect` follows |
|
|
132
|
+
|
|
133
|
+
`data` is `bytes`, `error` is an `OSError`.
|
|
134
|
+
|
|
135
|
+
## API
|
|
136
|
+
|
|
137
|
+
| Member | Description |
|
|
138
|
+
|------------------------------------------|-----------------------------------------------------------------------|
|
|
139
|
+
| `SimpleSocketClient(host, port)` | a client for one address; nothing is opened until `connect()` |
|
|
140
|
+
| `connect(timeout=None)` | open the connection; blocks, `SimpleSocketClientException` on failure |
|
|
141
|
+
| `disconnect()` | stop reading, deliver the queue, then close the connection |
|
|
142
|
+
| `send(message)` | queue `bytes` for the server |
|
|
143
|
+
| `ask(message, timeout=1.)` | send `bytes` and return the first chunk of the answer |
|
|
144
|
+
| `is_connected` | `True` while the connection is open and serving messages |
|
|
145
|
+
| `host`, `port` | the address the client connects to |
|
|
146
|
+
| `on(event)`, `emit(...)` | inherited from `event_bus.EventBus` |
|
|
147
|
+
|
|
148
|
+
Notes:
|
|
149
|
+
|
|
150
|
+
- `connect()` raises `RuntimeError` for a live connection: one client object
|
|
151
|
+
serves one connection at a time. After `disconnect()` it can be called again,
|
|
152
|
+
and the queue of the previous connection is delivered first.
|
|
153
|
+
- `send()` never blocks and never truncates: the socket is non-blocking, so the
|
|
154
|
+
bytes are buffered and the unwritten tail is retried on the next poll, so a
|
|
155
|
+
message larger than the socket buffer arrives in full.
|
|
156
|
+
- `send()` and `disconnect()` are safe to call from another thread. Delivery
|
|
157
|
+
happens during the next poll, so it can take up to the poll interval (0.1 s).
|
|
158
|
+
- `disconnect()` delivers the messages already queued, so a `send()` followed by
|
|
159
|
+
a `disconnect()` still reaches the server. From another thread the call waits
|
|
160
|
+
for that to finish, for up to 5 seconds: a server that stopped reading makes the
|
|
161
|
+
rest of the queue be dropped. From an event handler it only raises the flag and
|
|
162
|
+
the serving thread closes the socket itself.
|
|
163
|
+
- **A handler must not raise.** The exception breaks the serving loop, the
|
|
164
|
+
connection is closed and `message` never arrives again; the exception itself is
|
|
165
|
+
printed by the `threading` module. Do the long work in your own thread and
|
|
166
|
+
handle per-message errors inside the handler.
|
|
167
|
+
- `message` carries one raw `recv()` chunk of up to 1024 bytes. There is no
|
|
168
|
+
framing: the protocol you put on top has to delimit its own messages, and
|
|
169
|
+
`ask()` returns one chunk, which may hold more than the answer or less than a
|
|
170
|
+
full line.
|
|
171
|
+
- `ask()` and a `message` handler see the same chunk. `ask()` raises
|
|
172
|
+
`TimeoutError` when nothing comes within the timeout and
|
|
173
|
+
`SimpleSocketClientException` when the connection is closed while waiting.
|
|
174
|
+
- The serving thread is a daemon, so a forgotten `disconnect()` does not keep the
|
|
175
|
+
interpreter alive; the messages that were not written yet are lost then.
|
|
176
|
+
- Do not call `sock.close()` yourself: the client owns the socket and drops it
|
|
177
|
+
with an `error` event of `select()` if it goes away behind its back.
|
|
178
|
+
|
|
179
|
+
## Compatibility
|
|
180
|
+
|
|
181
|
+
- Python **3.9 and newer**, including free-threaded builds: CI runs every version
|
|
182
|
+
from 3.9 to 3.15 plus the free-threaded `3.13t`.
|
|
183
|
+
- One runtime dependency: [`event-bus`](https://pypi.org/project/event-bus/).
|
|
184
|
+
- Type hints are shipped with the package (`py.typed`, PEP 561), so mypy and
|
|
185
|
+
pyright see real signatures instead of `Any`.
|
|
186
|
+
- Any client instance is independent: several clients may serve several
|
|
187
|
+
connections in one process.
|
|
188
|
+
|
|
189
|
+
## Development
|
|
190
|
+
|
|
191
|
+
```shell
|
|
192
|
+
$ python -m venv .venv
|
|
193
|
+
$ .venv\Scripts\activate # Windows; on POSIX: source .venv/bin/activate
|
|
194
|
+
$ pip install -e ".[dev]" # installs the package and pytest
|
|
195
|
+
$ python -m pytest
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The test suite covers:
|
|
199
|
+
|
|
200
|
+
- the lifecycle: `connect`, `is_connected`, `host`, `port`, a rejected
|
|
201
|
+
connection, a second `connect()` on a live connection and a reconnect after a
|
|
202
|
+
graceful close,
|
|
203
|
+
- the message flow: the `message` event for data the server sends on its own,
|
|
204
|
+
`ask()` round trips, `send()` ordering and a 256 KiB payload in both directions,
|
|
205
|
+
- concurrency: several threads calling `send()` at once,
|
|
206
|
+
- disconnects: the queue delivered before `disconnect()` closes the connection, an
|
|
207
|
+
idempotent `disconnect()`, a close and a reset by the server, and `ask()` that
|
|
208
|
+
waits when the connection goes away,
|
|
209
|
+
- error handling: `send()` and `ask()` on a closed client, an empty message, an
|
|
210
|
+
`ask()` timeout, and a handler that raises,
|
|
211
|
+
- the public API: `__all__`, the version and the package metadata.
|
|
212
|
+
|
|
213
|
+
Project layout:
|
|
214
|
+
|
|
215
|
+
```
|
|
216
|
+
simple_socket_client/__init__.py the whole library
|
|
217
|
+
tests/ test suite
|
|
218
|
+
pyproject.toml metadata, build config and pytest settings
|
|
219
|
+
.github/workflows/ CI: tests on Python 3.9-3.15 and 3.13t, release pipeline
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
To build and verify a release:
|
|
223
|
+
|
|
224
|
+
```shell
|
|
225
|
+
$ pip install build twine
|
|
226
|
+
$ python -m build
|
|
227
|
+
$ python -m twine check dist/*
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## Changelog
|
|
231
|
+
|
|
232
|
+
See [CHANGELOG.md](CHANGELOG.md). GitHub Releases are generated from the matching
|
|
233
|
+
entry of that file, so the history is available both in git and on the releases
|
|
234
|
+
page.
|
|
235
|
+
|
|
236
|
+
## License
|
|
237
|
+
|
|
238
|
+
Distributed under the BSD 3-Clause license. See [LICENSE.md](LICENSE.md) for details.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
simple_socket_client/__init__.py,sha256=m66RyZ-aS9moaqDNWpYvZurF9Q6XP03R3cAjZO2yxAU,11566
|
|
2
|
+
simple_socket_client/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
simple_socket_client-2.0.dist-info/licenses/LICENSE.md,sha256=viUOCJf97qThnFWXx3lGwpWwhWfuo8ug8QQAHtAFh8c,1496
|
|
4
|
+
simple_socket_client-2.0.dist-info/METADATA,sha256=Gz7flsvCTNLwqa57j9Nh4GP186CNA9JuwWAxCXPvnxQ,10386
|
|
5
|
+
simple_socket_client-2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
6
|
+
simple_socket_client-2.0.dist-info/top_level.txt,sha256=KrID74qMjx5n9HChIxZbXInlAYezvB0-ifISpLKCtOI,21
|
|
7
|
+
simple_socket_client-2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Copyright (c) 2023–2026, webtoucher
|
|
2
|
+
|
|
3
|
+
All rights reserved.
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
|
6
|
+
|
|
7
|
+
* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
|
8
|
+
|
|
9
|
+
* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
|
10
|
+
|
|
11
|
+
* Neither the names of Alexey Kuznetsov or webtoucher nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
|
12
|
+
|
|
13
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
simple_socket_client
|