simple-socket-server 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.
@@ -0,0 +1,262 @@
1
+ """
2
+ Simple TCP socket server built on select().
3
+
4
+ The server is single-threaded: run() polls the sockets and calls the handlers
5
+ registered for its events. No third-party networking code is involved, the only
6
+ runtime dependency is the event bus used to dispatch those events.
7
+
8
+ Copyright (c) 2023–2026 webtoucher
9
+ Distributed under the BSD 3-Clause license. See LICENSE.md for more info.
10
+ """
11
+
12
+ import select
13
+ import socket
14
+ import threading
15
+
16
+ from typing import Dict, List, Optional, Tuple
17
+
18
+ from event_bus import EventBus
19
+
20
+ __all__ = ["SimpleSocketServer", "SimpleSocketServerException"]
21
+
22
+ __version__ = "2.0"
23
+
24
+ #: Peer address as returned by accept(): (host, port).
25
+ Address = Tuple[str, int]
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
+
34
+ class SimpleSocketServerException(socket.error):
35
+ """Raised when the listening socket cannot be created, bound or enabled."""
36
+
37
+
38
+ class SimpleSocketServer(EventBus):
39
+ """Single-threaded TCP server dispatching events through an event bus.
40
+
41
+ Events, in the order they may arrive:
42
+
43
+ ``start(host, port)``
44
+ The listening socket is ready.
45
+ ``connect(sock, peer)``
46
+ A client has been accepted.
47
+ ``message(sock, peer, data)``
48
+ A chunk of bytes has been received.
49
+ ``disconnect(sock, peer)``
50
+ The connection is gone: the peer closed it, reset it or a send failed.
51
+ ``error(sock, peer, error)``
52
+ A socket error occurred; the connection is closed right after.
53
+ ``stop(host, port)``
54
+ run() has returned and every socket is closed.
55
+
56
+ Handlers are called from the thread that runs run(), one at a time, so a
57
+ slow handler delays every other connection.
58
+ """
59
+
60
+ def __init__(self) -> None:
61
+ super().__init__()
62
+ self.__host = "0.0.0.0"
63
+ self.__port = 6666
64
+ self.__max_conn = 5
65
+ self.__server_socket: Optional[socket.socket] = None
66
+ self.__inputs: List[socket.socket] = []
67
+ self.__outputs: List[socket.socket] = []
68
+ self.__pending: Dict[socket.socket, bytearray] = {}
69
+ self.__clients: Dict[socket.socket, Address] = {}
70
+ self.__stop = threading.Event()
71
+
72
+ @property
73
+ def is_running(self) -> bool:
74
+ """True while run() is serving connections."""
75
+ return self.__server_socket is not None and not self.__stop.is_set()
76
+
77
+ @property
78
+ def host(self) -> str:
79
+ """Host the listening socket is bound to."""
80
+ return self.__host
81
+
82
+ @property
83
+ def port(self) -> int:
84
+ """Port the listening socket is bound to."""
85
+ return self.__port
86
+
87
+ def run(self, host: str = "0.0.0.0", port: int = 6666, max_conn: int = 5) -> None:
88
+ """Serve connections until stop() is called.
89
+
90
+ The call blocks the calling thread. Once it returns, every client socket
91
+ and the listening socket are closed. A port of 0 makes the operating
92
+ system pick a free port; the actual address is reported by the start
93
+ event and by the host and port properties.
94
+ """
95
+ if self.is_running:
96
+ raise RuntimeError("the server is already running")
97
+ self.__host = host
98
+ self.__port = port
99
+ self.__max_conn = max_conn
100
+ self.__stop.clear()
101
+ self.__initialize()
102
+ try:
103
+ while not self.__stop.is_set():
104
+ self.__select()
105
+ finally:
106
+ self.__shutdown()
107
+
108
+ def stop(self) -> None:
109
+ """Ask run() to return.
110
+
111
+ It only raises a flag, so it is safe to call from an event handler or
112
+ from another thread. run() finishes the current poll and then closes
113
+ every socket.
114
+ """
115
+ self.__stop.set()
116
+
117
+ def send(self, sock: socket.socket, message: bytes) -> None:
118
+ """Queue message for delivery to a client of this server.
119
+
120
+ The socket is non-blocking, so the bytes are buffered and the unwritten
121
+ tail is retried on the next poll; a message larger than the socket
122
+ buffer is delivered in full. Empty messages are ignored.
123
+ """
124
+ if sock not in self.__pending:
125
+ raise ValueError("the socket is not a client of this server")
126
+ if not message:
127
+ return
128
+ self.__pending[sock].extend(message)
129
+ if sock not in self.__outputs:
130
+ self.__outputs.append(sock)
131
+
132
+ def __initialize(self) -> None:
133
+ server_socket = socket.socket(
134
+ socket.AF_INET,
135
+ socket.SOCK_STREAM,
136
+ socket.IPPROTO_TCP,
137
+ )
138
+ try:
139
+ server_socket.setblocking(False)
140
+ server_socket.setsockopt(
141
+ socket.SOL_SOCKET,
142
+ socket.SO_REUSEADDR,
143
+ 1,
144
+ )
145
+ server_socket.bind((self.__host, self.__port))
146
+ server_socket.listen(self.__max_conn)
147
+ except OSError as error:
148
+ server_socket.close()
149
+ raise SimpleSocketServerException(
150
+ "cannot listen on %s:%s" % (self.__host, self.__port)
151
+ ) from error
152
+ self.__server_socket = server_socket
153
+ self.__inputs.append(server_socket)
154
+ # A port of 0 is resolved by the system, so report the real address.
155
+ self.__host, self.__port = server_socket.getsockname()[:2]
156
+ self.emit("start", self.__host, self.__port)
157
+
158
+ def __select(self) -> None:
159
+ try:
160
+ sock_to_read, sock_to_write, sock_errors = select.select(
161
+ self.__inputs,
162
+ self.__outputs,
163
+ self.__inputs,
164
+ _POLL_INTERVAL,
165
+ )
166
+ except OSError as error:
167
+ # select() itself failed, so the listening socket is unusable.
168
+ self.emit("error", None, None, error)
169
+ self.stop()
170
+ return
171
+ for sock in sock_errors:
172
+ if sock is self.__server_socket:
173
+ self.stop()
174
+ else:
175
+ self.__close(sock)
176
+ for sock in sock_to_read:
177
+ if sock is self.__server_socket:
178
+ self.__accept()
179
+ else:
180
+ self.__receive(sock)
181
+ for sock in sock_to_write:
182
+ self.__flush(sock)
183
+
184
+ def __accept(self) -> None:
185
+ try:
186
+ client_socket, client_address = self.__server_socket.accept()
187
+ except OSError as error:
188
+ self.emit("error", self.__server_socket, None, error)
189
+ return
190
+ client_socket.setblocking(False)
191
+ self.__clients[client_socket] = client_address
192
+ self.__pending[client_socket] = bytearray()
193
+ self.__inputs.append(client_socket)
194
+ self.emit("connect", client_socket, client_address)
195
+
196
+ def __receive(self, sock: socket.socket) -> None:
197
+ peer = self.__clients.get(sock)
198
+ if peer is None:
199
+ # The socket was already dropped during this poll.
200
+ return
201
+ try:
202
+ data = sock.recv(_RECV_SIZE)
203
+ except BlockingIOError:
204
+ # select() woke us up for nothing; try again on the next poll.
205
+ return
206
+ except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError):
207
+ self.__close(sock)
208
+ return
209
+ except OSError as error:
210
+ self.emit("error", sock, peer, error)
211
+ self.__close(sock)
212
+ return
213
+ if not data:
214
+ # An empty chunk means the peer closed its end of the connection.
215
+ self.__close(sock)
216
+ return
217
+ self.emit("message", sock, peer, data)
218
+
219
+ def __flush(self, sock: socket.socket) -> None:
220
+ pending = self.__pending.get(sock)
221
+ if pending is None:
222
+ if sock in self.__outputs:
223
+ self.__outputs.remove(sock)
224
+ return
225
+ try:
226
+ sent = sock.send(pending)
227
+ except BlockingIOError:
228
+ # The kernel buffer is full again; the tail stays queued.
229
+ return
230
+ except OSError as error:
231
+ self.emit("error", sock, self.__clients.get(sock), error)
232
+ self.__close(sock)
233
+ return
234
+ del pending[:sent]
235
+ if not pending and sock in self.__outputs:
236
+ self.__outputs.remove(sock)
237
+
238
+ def __close(self, sock: socket.socket) -> None:
239
+ """Close a client connection and forget it. Safe to call twice."""
240
+ peer = self.__clients.pop(sock, None)
241
+ if sock in self.__inputs:
242
+ self.__inputs.remove(sock)
243
+ if sock in self.__outputs:
244
+ self.__outputs.remove(sock)
245
+ self.__pending.pop(sock, None)
246
+ if peer is None:
247
+ sock.close()
248
+ return
249
+ try:
250
+ self.emit("disconnect", sock, peer)
251
+ finally:
252
+ sock.close()
253
+
254
+ def __shutdown(self) -> None:
255
+ for sock in list(self.__clients):
256
+ self.__close(sock)
257
+ server_socket, self.__server_socket = self.__server_socket, None
258
+ if server_socket is not None:
259
+ if server_socket in self.__inputs:
260
+ self.__inputs.remove(server_socket)
261
+ server_socket.close()
262
+ self.emit("stop", self.__host, self.__port)
File without changes
@@ -0,0 +1,218 @@
1
+ Metadata-Version: 2.4
2
+ Name: simple-socket-server
3
+ Version: 2.0
4
+ Summary: Simple TCP socket server with select
5
+ Author-email: webtoucher <webtoucher@ya.ru>
6
+ License-Expression: BSD-3-Clause
7
+ Project-URL: Homepage, https://github.com/webtoucher/simple-socket-server
8
+ Project-URL: Repository, https://github.com/webtoucher/simple-socket-server
9
+ Project-URL: Issues, https://github.com/webtoucher/simple-socket-server/issues
10
+ Project-URL: Changelog, https://github.com/webtoucher/simple-socket-server/blob/master/CHANGELOG.md
11
+ Keywords: socket,tcp,server,select,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 server with select
39
+
40
+ ![License](https://img.shields.io/badge/License-BSD%203--Clause-green)
41
+ [![Downloads](https://img.shields.io/pypi/dm/simple-socket-server.svg?color=orange)](https://pypi.python.org/pypi/simple-socket-server)
42
+ [![Latest Version](https://img.shields.io/pypi/v/simple-socket-server.svg)](https://pypi.python.org/pypi/simple-socket-server)
43
+ [![Supported Python versions](https://img.shields.io/pypi/pyversions/simple-socket-server.svg)](https://pypi.python.org/pypi/simple-socket-server)
44
+ [![tests](https://github.com/webtoucher/simple-socket-server/actions/workflows/tests.yml/badge.svg)](https://github.com/webtoucher/simple-socket-server/actions/workflows/tests.yml)
45
+
46
+ A TCP server on a single `select()` loop with no networking framework underneath:
47
+ you subscribe handlers to events and the server calls them. Type hints are shipped
48
+ with the package, the only runtime dependency is the event bus that dispatches
49
+ those events.
50
+
51
+ ## Installation
52
+
53
+ Install it with pip:
54
+
55
+ ```shell
56
+ $ pip install simple-socket-server
57
+ ```
58
+
59
+ Or you can add it as dependency in requirements.txt file of your python application:
60
+
61
+ ```
62
+ simple-socket-server~=2.0
63
+ ```
64
+
65
+ ## Usage
66
+
67
+ Easy way to understand how it works is testing socket server via telnet terminal:
68
+
69
+ ```python
70
+ from socket import socket
71
+ from simple_socket_server import SimpleSocketServer
72
+
73
+ socket_server = SimpleSocketServer()
74
+
75
+
76
+ @socket_server.on('start')
77
+ def on_start(host, port):
78
+ print('Listening on %s:%s' % (host, port))
79
+
80
+
81
+ @socket_server.on('connect')
82
+ def on_connect(sock: socket, peer):
83
+ print('New connection from %s:%s' % peer)
84
+ socket_server.send(sock, b'What is your name?\r\n')
85
+
86
+
87
+ @socket_server.on('disconnect')
88
+ def on_disconnect(_sock, peer):
89
+ print('Connection from %s:%s is closed' % peer)
90
+
91
+
92
+ @socket_server.on('message')
93
+ def on_message(sock: socket, peer, message: bytes):
94
+ print('Incoming data from %s:%s' % peer, message)
95
+ socket_server.send(sock, b'Hi, ' + message)
96
+
97
+
98
+ socket_server.run(host='0.0.0.0', port=5000)
99
+ ```
100
+
101
+ Then you can connect to server:
102
+
103
+ ```shell
104
+ telnet 127.0.0.1 5000
105
+ ```
106
+
107
+ `run()` blocks, so the server stops when someone asks it to. That may be a signal
108
+ handler, a timer or an event handler:
109
+
110
+ ```python
111
+ @another_server.on('message')
112
+ def on_message(sock, peer, message):
113
+ if message.strip() == b'quit':
114
+ another_server.stop()
115
+ ```
116
+
117
+ A port of `0` lets the operating system pick a free port; the real address comes
118
+ back in the `start` event and in the `host` and `port` properties:
119
+
120
+ ```python
121
+ socket_server.run(host='127.0.0.1', port=0)
122
+ # ... inside the start handler:
123
+ print(socket_server.host, socket_server.port)
124
+ ```
125
+
126
+ ## Events
127
+
128
+ Handlers are called from the thread that runs `run()`, one at a time.
129
+
130
+ | Event | Arguments | When it fires |
131
+ |--------------|-------------------------|------------------------------------------------------------------|
132
+ | `start` | `host, port` | the listening socket is ready |
133
+ | `connect` | `sock, peer` | a client has been accepted |
134
+ | `message` | `sock, peer, data` | a chunk of bytes has been received |
135
+ | `disconnect` | `sock, peer` | the connection is gone; the socket is closed right after |
136
+ | `error` | `sock, peer, error` | a socket error occurred; `disconnect` follows |
137
+ | `stop` | `host, port` | `run()` has returned and every socket is closed |
138
+
139
+ `peer` is an `(host, port)` tuple, `data` is `bytes`, `error` is an `OSError`.
140
+
141
+ ## API
142
+
143
+ | Member | Description |
144
+ |---------------------------------------|----------------------------------------------------------------------|
145
+ | `run(host='0.0.0.0', port=6666, max_conn=5)` | serve connections until `stop()` is called |
146
+ | `stop()` | ask `run()` to return; safe from a handler or another thread |
147
+ | `send(sock, message)` | queue `bytes` for a client; `ValueError` if it is not a client |
148
+ | `is_running` | `True` while `run()` is serving connections |
149
+ | `host`, `port` | the address the listening socket is bound to |
150
+ | `on(event)`, `emit(...)` | inherited from `event_bus.EventBus` |
151
+
152
+ Notes:
153
+
154
+ - The server is single-threaded and handlers are called synchronously, so a slow
155
+ handler delays every other connection. Hand off long work to your own thread.
156
+ - `message` carries one raw `recv()` chunk of up to 1024 bytes. There is no
157
+ framing: the protocol you put on top has to delimit its own messages.
158
+ - `send()` never blocks and never truncates: because client sockets are
159
+ non-blocking, the bytes are buffered and the unwritten tail is retried on the
160
+ next poll, so a message larger than the socket buffer arrives in full.
161
+ - Do not use the socket after `disconnect` or `error`: it is closed.
162
+ - An exception raised by a handler propagates out of `run()`. The socket state is
163
+ still cleaned up before the call returns.
164
+
165
+ ## Compatibility
166
+
167
+ - Python **3.9 and newer**, including free-threaded builds: CI runs every version
168
+ from 3.9 to 3.15 plus the free-threaded `3.13t`.
169
+ - One runtime dependency: [`event-bus`](https://pypi.org/project/event-bus/).
170
+ - Type hints are shipped with the package (`py.typed`, PEP 561), so mypy and
171
+ pyright see real signatures instead of `Any`.
172
+ - Any server instance is independent: several servers may run in one process, each
173
+ in its own thread.
174
+
175
+ ## Development
176
+
177
+ ```shell
178
+ $ python -m venv .venv
179
+ $ .venv\Scripts\activate # Windows; on POSIX: source .venv/bin/activate
180
+ $ pip install -e ".[dev]" # installs the package and pytest
181
+ $ python -m pytest
182
+ ```
183
+
184
+ The test suite covers:
185
+
186
+ - the lifecycle: `start`, `stop`, `is_running`, socket cleanup and port reuse,
187
+ - the message flow: `connect`, echo round trips, ordering and large payloads,
188
+ - disconnects: graceful close by the peer, an abrupt reset and a send to a
189
+ connection that is already gone,
190
+ - error handling: an unknown socket for `send()` and a port that is already taken,
191
+ - independence of two servers running in one process.
192
+
193
+ Project layout:
194
+
195
+ ```
196
+ simple_socket_server/__init__.py the whole library
197
+ tests/ test suite
198
+ pyproject.toml metadata, build config and pytest settings
199
+ .github/workflows/ CI: tests on Python 3.9-3.15 and 3.13t, release pipeline
200
+ ```
201
+
202
+ To build and verify a release:
203
+
204
+ ```shell
205
+ $ pip install build twine
206
+ $ python -m build
207
+ $ python -m twine check dist/*
208
+ ```
209
+
210
+ ## Changelog
211
+
212
+ See [CHANGELOG.md](CHANGELOG.md). GitHub Releases are generated from the matching
213
+ entry of that file, so the history is available both in git and on the releases
214
+ page.
215
+
216
+ ## License
217
+
218
+ Distributed under the BSD 3-Clause license. See [LICENSE.md](LICENSE.md) for details.
@@ -0,0 +1,7 @@
1
+ simple_socket_server/__init__.py,sha256=NG1iCg4yUKkq6G77v7ffvkGdrkMivnqu_vcFq9wwYQk,9173
2
+ simple_socket_server/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ simple_socket_server-2.0.dist-info/licenses/LICENSE.md,sha256=viUOCJf97qThnFWXx3lGwpWwhWfuo8ug8QQAHtAFh8c,1496
4
+ simple_socket_server-2.0.dist-info/METADATA,sha256=pehdXAJEjZO1_hgYGB3BGO49nrqExDJVQRgbwEsOUKc,8769
5
+ simple_socket_server-2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
6
+ simple_socket_server-2.0.dist-info/top_level.txt,sha256=U3g9Q_CXbmmzcFW0pQ4cEZgmkbi0TORT9q3EA_C7I04,21
7
+ simple_socket_server-2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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_server