socket-netty 0.3.1__tar.gz → 0.4.0__tar.gz
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.
- socket_netty-0.4.0/PKG-INFO +650 -0
- socket_netty-0.4.0/README.md +630 -0
- {socket_netty-0.3.1 → socket_netty-0.4.0}/examples/02_game_protocol_client.py +1 -1
- {socket_netty-0.3.1 → socket_netty-0.4.0}/pyproject.toml +1 -1
- {socket_netty-0.3.1 → socket_netty-0.4.0}/socket_netty/__init__.py +70 -1
- {socket_netty-0.3.1 → socket_netty-0.4.0}/socket_netty/bootstrap/bootstrap.py +38 -1
- {socket_netty-0.3.1 → socket_netty-0.4.0}/socket_netty/buffer/allocator.py +32 -0
- socket_netty-0.4.0/socket_netty/buffer/bytebuf.py +597 -0
- {socket_netty-0.3.1 → socket_netty-0.4.0}/socket_netty/channel/channel.py +79 -4
- {socket_netty-0.3.1 → socket_netty-0.4.0}/socket_netty/channel/channel_future.py +9 -1
- socket_netty-0.4.0/socket_netty/channel/channel_pipeline.py +301 -0
- {socket_netty-0.3.1 → socket_netty-0.4.0}/socket_netty/channel/datagram_channel.py +35 -5
- {socket_netty-0.3.1 → socket_netty-0.4.0}/socket_netty/channel/flow_control.py +8 -0
- socket_netty-0.4.0/socket_netty/channel/protocol_adapter.py +132 -0
- socket_netty-0.4.0/socket_netty/handler/__init__.py +117 -0
- {socket_netty-0.3.1 → socket_netty-0.4.0}/socket_netty/handler/channel_handler.py +23 -0
- {socket_netty-0.3.1 → socket_netty-0.4.0}/socket_netty/handler/channel_handler_context.py +45 -1
- socket_netty-0.4.0/socket_netty/handler/http/__init__.py +80 -0
- socket_netty-0.4.0/socket_netty/handler/http/http_aggregator.py +128 -0
- socket_netty-0.4.0/socket_netty/handler/http/http_codec.py +392 -0
- socket_netty-0.4.0/socket_netty/handler/http/http_message.py +177 -0
- socket_netty-0.4.0/socket_netty/handler/http/http_types.py +254 -0
- socket_netty-0.4.0/socket_netty/handler/http/websocket_codec.py +220 -0
- socket_netty-0.4.0/socket_netty/handler/http/websocket_frame.py +66 -0
- socket_netty-0.4.0/socket_netty/handler/http/websocket_handshake.py +138 -0
- {socket_netty-0.3.1 → socket_netty-0.4.0}/socket_netty/handler/timeout.py +21 -6
- socket_netty-0.4.0/socket_netty.egg-info/PKG-INFO +650 -0
- {socket_netty-0.3.1 → socket_netty-0.4.0}/socket_netty.egg-info/SOURCES.txt +8 -0
- {socket_netty-0.3.1 → socket_netty-0.4.0}/tests/test_bytebuf.py +3 -3
- {socket_netty-0.3.1 → socket_netty-0.4.0}/tests/test_codec.py +4 -1
- {socket_netty-0.3.1 → socket_netty-0.4.0}/tests/test_concurrency.py +1 -1
- {socket_netty-0.3.1 → socket_netty-0.4.0}/tests/test_core_extras.py +1 -1
- {socket_netty-0.3.1 → socket_netty-0.4.0}/tests/test_echo.py +3 -3
- {socket_netty-0.3.1 → socket_netty-0.4.0}/tests/test_flow_and_allocator.py +1 -1
- {socket_netty-0.3.1 → socket_netty-0.4.0}/tests/test_network_extras.py +1 -1
- {socket_netty-0.3.1 → socket_netty-0.4.0}/tests/test_pipeline.py +33 -40
- {socket_netty-0.3.1 → socket_netty-0.4.0}/tests/test_tls.py +1 -1
- socket_netty-0.3.1/PKG-INFO +0 -399
- socket_netty-0.3.1/README.md +0 -379
- socket_netty-0.3.1/socket_netty/buffer/bytebuf.py +0 -256
- socket_netty-0.3.1/socket_netty/channel/channel_pipeline.py +0 -173
- socket_netty-0.3.1/socket_netty/channel/protocol_adapter.py +0 -59
- socket_netty-0.3.1/socket_netty/handler/__init__.py +0 -47
- socket_netty-0.3.1/socket_netty.egg-info/PKG-INFO +0 -399
- {socket_netty-0.3.1 → socket_netty-0.4.0}/MANIFEST.in +0 -0
- {socket_netty-0.3.1 → socket_netty-0.4.0}/examples/01_echo_client.py +0 -0
- {socket_netty-0.3.1 → socket_netty-0.4.0}/examples/01_echo_server.py +0 -0
- {socket_netty-0.3.1 → socket_netty-0.4.0}/examples/02_game_protocol_server.py +0 -0
- {socket_netty-0.3.1 → socket_netty-0.4.0}/setup.cfg +0 -0
- {socket_netty-0.3.1 → socket_netty-0.4.0}/socket_netty/bootstrap/__init__.py +0 -0
- {socket_netty-0.3.1 → socket_netty-0.4.0}/socket_netty/buffer/__init__.py +0 -0
- {socket_netty-0.3.1 → socket_netty-0.4.0}/socket_netty/channel/__init__.py +0 -0
- {socket_netty-0.3.1 → socket_netty-0.4.0}/socket_netty/channel/channel_option.py +0 -0
- {socket_netty-0.3.1 → socket_netty-0.4.0}/socket_netty/channel/event_loop.py +0 -0
- {socket_netty-0.3.1 → socket_netty-0.4.0}/socket_netty/exceptions.py +0 -0
- {socket_netty-0.3.1 → socket_netty-0.4.0}/socket_netty/handler/codec.py +0 -0
- {socket_netty-0.3.1 → socket_netty-0.4.0}/socket_netty/handler/protolib_codec.py +0 -0
- {socket_netty-0.3.1 → socket_netty-0.4.0}/socket_netty/handler/ssl_context.py +0 -0
- {socket_netty-0.3.1 → socket_netty-0.4.0}/socket_netty.egg-info/dependency_links.txt +0 -0
- {socket_netty-0.3.1 → socket_netty-0.4.0}/socket_netty.egg-info/requires.txt +0 -0
- {socket_netty-0.3.1 → socket_netty-0.4.0}/socket_netty.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: socket-netty
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Asynchronous networking library for Python, inspired by Netty
|
|
5
|
+
Author: Button
|
|
6
|
+
Keywords: networking,asyncio,netty,tcp,udp,game-server,protocol
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
15
|
+
Classifier: Topic :: System :: Networking
|
|
16
|
+
Classifier: Framework :: AsyncIO
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: protolib>=0.4.3
|
|
20
|
+
|
|
21
|
+
# socket-netty
|
|
22
|
+
|
|
23
|
+
Asynchronous networking library for Python, closely inspired by
|
|
24
|
+
[Netty](https://netty.io) (Java). Built on top of `asyncio`, with no
|
|
25
|
+
external dependencies.
|
|
26
|
+
|
|
27
|
+
## Coverage relative to Netty
|
|
28
|
+
|
|
29
|
+
| Netty | Python (socket-netty) | Notes |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| Bootstrap | Yes | + `child_option` for per-connection socket options |
|
|
32
|
+
| ServerBootstrap | Yes | |
|
|
33
|
+
| Channel | Yes | TCP, `write`/`queue_write`/`flush` |
|
|
34
|
+
| ChannelPipeline | Yes | `add_first/last/before/after`, `replace`, `remove_first/last`, `first`/`last` |
|
|
35
|
+
| ChannelHandler | Yes | inbound + outbound, `channel_writability_changed`, `user_event_triggered` |
|
|
36
|
+
| ChannelHandlerContext | Yes | `is_removed()` |
|
|
37
|
+
| EventLoop | Yes | |
|
|
38
|
+
| EventLoopGroup | Yes | |
|
|
39
|
+
| ChannelFuture | Yes | + ChannelPromise |
|
|
40
|
+
| ByteBuf | Yes | `get_*`/`set_*`, little-endian, `slice`/`copy`/`discard_read_bytes` |
|
|
41
|
+
| Allocator | Yes | Unpooled + Pooled |
|
|
42
|
+
| TCP | Yes | |
|
|
43
|
+
| UDP | Yes | DatagramChannel |
|
|
44
|
+
| Socket options | Yes | ChannelOption, `option`/`child_option` |
|
|
45
|
+
| Backpressure | Yes | WriteBufferWaterMark, `channel_writability_changed` |
|
|
46
|
+
| Concurrency | Yes | ChannelExecutor |
|
|
47
|
+
| TLS/SSL | Yes | SslContextBuilder |
|
|
48
|
+
| HTTP/1.1 | Yes | HttpServerCodec/HttpClientCodec, chunked encoding, keep-alive, HttpObjectAggregator |
|
|
49
|
+
| HTTPS | Yes | Same HTTP pipeline + `.ssl(...)`, TLS handled transparently by the transport |
|
|
50
|
+
| WebSocket | Yes | RFC 6455 server-side upgrade, all frame types, WSS (WebSocket over TLS) |
|
|
51
|
+
| Encoders / Decoders | Yes | length-based framing |
|
|
52
|
+
| Idle handlers | Yes | IdleStateHandler |
|
|
53
|
+
| Timeouts | Yes | Read/WriteTimeoutHandler |
|
|
54
|
+
| Exception handling | Yes | `exception_caught` via the pipeline, `io.netty.*`-style exception classes |
|
|
55
|
+
| Declarative packet codec | Yes | ProtolibCodec, via the `protolib` dependency |
|
|
56
|
+
|
|
57
|
+
## Installation
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install socket-netty
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Or, for local development from a clone of this repo:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install -e .
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`socket_netty` declares [`protolib`](https://pypi.org/project/protolib/) as
|
|
70
|
+
an install dependency, so either install method pulls it in
|
|
71
|
+
automatically — you don't need a separate install step to use
|
|
72
|
+
`ProtolibCodec` below.
|
|
73
|
+
|
|
74
|
+
## Structure
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
socket_netty/
|
|
78
|
+
exceptions.py -> Netty-style exception hierarchy (DecoderException,
|
|
79
|
+
EncoderException, CorruptedFrameException, etc.)
|
|
80
|
+
buffer/ -> ByteBuf, ByteBufAllocator (Unpooled/Pooled)
|
|
81
|
+
handler/ -> ChannelHandler, codecs, protolib bridge, SSL,
|
|
82
|
+
timeouts/idle
|
|
83
|
+
handler/http/ -> HTTP/1.1 (HttpServerCodec, HttpObjectAggregator) and
|
|
84
|
+
WebSocket (RFC 6455 handshake + frame codec)
|
|
85
|
+
channel/ -> Channel, DatagramChannel, ChannelPipeline,
|
|
86
|
+
EventLoop/Group, ChannelFuture, ChannelOption,
|
|
87
|
+
flow control (backpressure + concurrency)
|
|
88
|
+
bootstrap/ -> ServerBootstrap (server), Bootstrap (client)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Example: TCP echo server
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
import asyncio
|
|
95
|
+
from socket_netty import ServerBootstrap, ChannelInboundHandler
|
|
96
|
+
|
|
97
|
+
class EchoHandler(ChannelInboundHandler):
|
|
98
|
+
async def channel_active(self, ctx):
|
|
99
|
+
print("Client connected:", ctx.channel.remote_address())
|
|
100
|
+
|
|
101
|
+
async def channel_read(self, ctx, msg):
|
|
102
|
+
await ctx.write(msg) # echo
|
|
103
|
+
|
|
104
|
+
async def exception_caught(self, ctx, exc):
|
|
105
|
+
print("Error:", exc)
|
|
106
|
+
await ctx.close()
|
|
107
|
+
|
|
108
|
+
async def main():
|
|
109
|
+
def init_channel(channel):
|
|
110
|
+
channel.pipeline.add_last("echo", EchoHandler())
|
|
111
|
+
|
|
112
|
+
server = await ServerBootstrap().child_handler(init_channel).bind("0.0.0.0", 9000)
|
|
113
|
+
await server.serve_forever()
|
|
114
|
+
|
|
115
|
+
asyncio.run(main())
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Exceptions
|
|
119
|
+
|
|
120
|
+
socket_netty raises the same exception hierarchy Netty does, under
|
|
121
|
+
`io.netty.*`. Every exception's `str()` is prefixed with its
|
|
122
|
+
fully-qualified Java-style path, so logs and `exception_caught` output
|
|
123
|
+
look exactly like a real Netty stack:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
from socket_netty import DecoderException
|
|
127
|
+
|
|
128
|
+
async def exception_caught(self, ctx, exc):
|
|
129
|
+
print(exc)
|
|
130
|
+
# io.netty.handler.codec.DecoderException: Failed to decode packet
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Available exceptions (all subclass `NettyException`):
|
|
134
|
+
|
|
135
|
+
| Class | Netty path |
|
|
136
|
+
|---|---|
|
|
137
|
+
| `ChannelException` | `io.netty.channel.ChannelException` |
|
|
138
|
+
| `DuplicateHandlerNameError` | `io.netty.channel.ChannelPipelineException` |
|
|
139
|
+
| `CodecException` | `io.netty.handler.codec.CodecException` |
|
|
140
|
+
| `DecoderException` | `io.netty.handler.codec.DecoderException` |
|
|
141
|
+
| `EncoderException` | `io.netty.handler.codec.EncoderException` |
|
|
142
|
+
| `CorruptedFrameException` | `io.netty.handler.codec.CorruptedFrameException` |
|
|
143
|
+
| `TooLongFrameException` | `io.netty.handler.codec.TooLongFrameException` |
|
|
144
|
+
| `ReadTimeoutError` | `io.netty.handler.timeout.ReadTimeoutException` |
|
|
145
|
+
| `WriteTimeoutError` | `io.netty.handler.timeout.WriteTimeoutException` |
|
|
146
|
+
| `IndexOutOfBoundsError` | `io.netty.buffer.IndexOutOfBoundsException` |
|
|
147
|
+
|
|
148
|
+
`LengthFieldBasedFrameDecoder` and `ByteToMessageCodec` automatically
|
|
149
|
+
wrap any unexpected error raised inside `decode()` into a
|
|
150
|
+
`DecoderException`, and `LengthFieldPrepender` does the same for
|
|
151
|
+
`EncoderException` — matching Netty's own behavior of never letting a
|
|
152
|
+
raw internal error escape a codec unwrapped.
|
|
153
|
+
|
|
154
|
+
## Pipeline management
|
|
155
|
+
|
|
156
|
+
Beyond `add_first`/`add_last`/`remove`/`get`, the pipeline supports
|
|
157
|
+
inserting or swapping handlers at any position:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
pipeline.add_before("game", "auth", AuthHandler()) # right before "game"
|
|
161
|
+
pipeline.add_after("auth", "logging", LoggingHandler()) # right after "auth"
|
|
162
|
+
|
|
163
|
+
pipeline.first() # handler closest to the head, or None
|
|
164
|
+
pipeline.last() # handler closest to the tail, or None
|
|
165
|
+
pipeline.names() # names in actual pipeline order
|
|
166
|
+
|
|
167
|
+
pipeline.remove_first()
|
|
168
|
+
pipeline.remove_last()
|
|
169
|
+
|
|
170
|
+
# Common pattern: swap a login handler for the real game handler once
|
|
171
|
+
# a player authenticates.
|
|
172
|
+
pipeline.replace("auth", "game", GameHandler())
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
A handler can check `ctx.is_removed()` to see whether it's still part
|
|
176
|
+
of the pipeline before touching `ctx` again from an async task started
|
|
177
|
+
earlier (e.g. one kicked off from `channel_read`).
|
|
178
|
+
|
|
179
|
+
## Socket options
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
from socket_netty import ServerBootstrap, ChannelOption
|
|
183
|
+
|
|
184
|
+
server = (
|
|
185
|
+
ServerBootstrap()
|
|
186
|
+
.child_handler(init_channel)
|
|
187
|
+
.option(ChannelOption.SO_REUSEADDR, True) # applies to the listening socket
|
|
188
|
+
.option(ChannelOption.SO_BACKLOG, 128) # applies to the listening socket
|
|
189
|
+
.child_option(ChannelOption.TCP_NODELAY, True) # applies to each ACCEPTED connection
|
|
190
|
+
)
|
|
191
|
+
await server.bind("0.0.0.0", 9000)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`option()` configures the listening socket itself. `child_option()`
|
|
195
|
+
configures each accepted connection instead — this is what you want
|
|
196
|
+
for per-connection tuning like `TCP_NODELAY` (disabling Nagle's
|
|
197
|
+
algorithm for lower latency), since the listening socket never
|
|
198
|
+
carries application traffic.
|
|
199
|
+
|
|
200
|
+
## TLS/SSL
|
|
201
|
+
|
|
202
|
+
```python
|
|
203
|
+
from socket_netty import ServerBootstrap, Bootstrap, SslContextBuilder
|
|
204
|
+
|
|
205
|
+
# Server
|
|
206
|
+
server_ctx = SslContextBuilder.for_server("cert.pem", "key.pem").build()
|
|
207
|
+
server = ServerBootstrap().child_handler(init_channel).ssl(server_ctx)
|
|
208
|
+
await server.bind("0.0.0.0", 9443)
|
|
209
|
+
|
|
210
|
+
# Client
|
|
211
|
+
client_ctx = SslContextBuilder.for_client().trust_manager("cert.pem").build()
|
|
212
|
+
channel = await Bootstrap().handler(init_client).ssl(client_ctx).connect("myserver.com", 9443)
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## HTTP / HTTPS
|
|
216
|
+
|
|
217
|
+
`HttpServerCodec` decodes incoming requests and encodes outgoing
|
|
218
|
+
responses through a single pipeline handler. Pair it with
|
|
219
|
+
`HttpObjectAggregator` if you don't need streaming and just want a
|
|
220
|
+
complete `FullHttpRequest` per request:
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
from socket_netty import (
|
|
224
|
+
ServerBootstrap, ChannelInboundHandler,
|
|
225
|
+
HttpServerCodec, HttpObjectAggregator,
|
|
226
|
+
FullHttpRequest, FullHttpResponse, HttpResponseStatus, HttpVersion, HttpUtil,
|
|
227
|
+
LastHttpContent,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
class HelloHandler(ChannelInboundHandler):
|
|
231
|
+
async def channel_read(self, ctx, msg):
|
|
232
|
+
if isinstance(msg, FullHttpRequest):
|
|
233
|
+
body = f"Hello, {msg.uri}".encode()
|
|
234
|
+
resp = FullHttpResponse(HttpResponseStatus.OK, body, HttpVersion.HTTP_1_1)
|
|
235
|
+
resp.headers.set("Content-Type", "text/plain")
|
|
236
|
+
HttpUtil.set_content_length(resp, len(body))
|
|
237
|
+
HttpUtil.set_keep_alive(resp, HttpUtil.is_keep_alive(msg))
|
|
238
|
+
await ctx.write_no_flush(resp)
|
|
239
|
+
await ctx.write(LastHttpContent(b""))
|
|
240
|
+
|
|
241
|
+
def init_channel(channel):
|
|
242
|
+
channel.pipeline.add_last("http", HttpServerCodec())
|
|
243
|
+
channel.pipeline.add_last("aggregator", HttpObjectAggregator(1024 * 1024)) # max body size
|
|
244
|
+
channel.pipeline.add_last("hello", HelloHandler())
|
|
245
|
+
|
|
246
|
+
server = await ServerBootstrap().child_handler(init_channel).bind("0.0.0.0", 8080)
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
**HTTPS is the exact same pipeline** with `.ssl(...)` added — TLS is
|
|
250
|
+
handled transparently by the underlying transport, the HTTP handlers
|
|
251
|
+
above don't change at all:
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
server_ctx = SslContextBuilder.for_server("cert.pem", "key.pem").build()
|
|
255
|
+
server = ServerBootstrap().child_handler(init_channel).ssl(server_ctx)
|
|
256
|
+
await server.bind("0.0.0.0", 8443)
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Chunked request/response bodies are decoded/encoded transparently -
|
|
260
|
+
`HttpObjectAggregator` reassembles a chunked body into a normal
|
|
261
|
+
`FullHttpRequest`/`FullHttpResponse` (with `Content-Length` set and
|
|
262
|
+
`Transfer-Encoding` removed) before your handler ever sees it. If you
|
|
263
|
+
want to stream a large response instead of buffering it all, skip the
|
|
264
|
+
aggregator and write `HttpResponse` + one or more `HttpContent` +
|
|
265
|
+
`LastHttpContent` yourself.
|
|
266
|
+
|
|
267
|
+
Keep-alive follows the real HTTP/1.0 vs 1.1 rules via `HttpUtil`:
|
|
268
|
+
HTTP/1.1 connections stay open by default (`Connection: close` closes
|
|
269
|
+
them), HTTP/1.0 connections close by default (`Connection: keep-alive`
|
|
270
|
+
keeps them open).
|
|
271
|
+
|
|
272
|
+
Not implemented (documented gap): the `Expect: 100-continue`
|
|
273
|
+
request/response flow.
|
|
274
|
+
|
|
275
|
+
## WebSocket
|
|
276
|
+
|
|
277
|
+
`WebSocketServerProtocolHandler` sits after the HTTP codec/aggregator
|
|
278
|
+
and handles the RFC 6455 upgrade handshake automatically - once a
|
|
279
|
+
client connects, it swaps the pipeline from HTTP handling to
|
|
280
|
+
WebSocket frames for that connection:
|
|
281
|
+
|
|
282
|
+
```python
|
|
283
|
+
from socket_netty import (
|
|
284
|
+
ServerBootstrap, ChannelInboundHandler,
|
|
285
|
+
HttpServerCodec, HttpObjectAggregator,
|
|
286
|
+
TextWebSocketFrame, CloseWebSocketFrame,
|
|
287
|
+
WebSocketServerProtocolHandler,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
class EchoHandler(ChannelInboundHandler):
|
|
291
|
+
async def channel_read(self, ctx, msg):
|
|
292
|
+
if isinstance(msg, TextWebSocketFrame):
|
|
293
|
+
await ctx.write(TextWebSocketFrame(text=f"echo: {msg.text}"))
|
|
294
|
+
elif isinstance(msg, CloseWebSocketFrame):
|
|
295
|
+
await ctx.write(CloseWebSocketFrame(1000, "bye"))
|
|
296
|
+
await ctx.close()
|
|
297
|
+
|
|
298
|
+
def init_channel(channel):
|
|
299
|
+
channel.pipeline.add_last("http", HttpServerCodec())
|
|
300
|
+
channel.pipeline.add_last("aggregator", HttpObjectAggregator(1024 * 1024))
|
|
301
|
+
channel.pipeline.add_last(
|
|
302
|
+
"upgrade",
|
|
303
|
+
WebSocketServerProtocolHandler(path="/ws", http_handler_names=["http", "aggregator"]),
|
|
304
|
+
)
|
|
305
|
+
channel.pipeline.add_last("echo", EchoHandler())
|
|
306
|
+
|
|
307
|
+
server = await ServerBootstrap().child_handler(init_channel).bind("0.0.0.0", 8080)
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
`path` restricts the upgrade to that specific request path, so the
|
|
311
|
+
same server can serve normal HTTP on other paths. `http_handler_names`
|
|
312
|
+
tells the handshake handler which HTTP-side handlers to remove from
|
|
313
|
+
the pipeline once the connection becomes a WebSocket (there's no
|
|
314
|
+
reliable way to infer this automatically, since you choose those
|
|
315
|
+
names yourself). **WSS (WebSocket over TLS)** is, again, the same
|
|
316
|
+
pipeline with `.ssl(...)` on the bootstrap — nothing else changes.
|
|
317
|
+
|
|
318
|
+
Frame types: `TextWebSocketFrame`, `BinaryWebSocketFrame`,
|
|
319
|
+
`ContinuationWebSocketFrame`, `PingWebSocketFrame`,
|
|
320
|
+
`PongWebSocketFrame`, `CloseWebSocketFrame`. Not implemented
|
|
321
|
+
(documented gap): WebSocket extensions (permessage-deflate
|
|
322
|
+
compression) and the client-side handshaker (`WebSocketClientHandshaker`)
|
|
323
|
+
- the server side above covers the common "game server exposes a
|
|
324
|
+
WebSocket API/panel" case.
|
|
325
|
+
|
|
326
|
+
## UDP
|
|
327
|
+
|
|
328
|
+
```python
|
|
329
|
+
from socket_netty import DatagramBootstrap, ChannelInboundHandler
|
|
330
|
+
|
|
331
|
+
class UdpHandler(ChannelInboundHandler):
|
|
332
|
+
async def channel_read(self, ctx, msg):
|
|
333
|
+
data, addr = msg # UDP delivers (bytes, (host, port))
|
|
334
|
+
await ctx.write((b"pong", addr))
|
|
335
|
+
|
|
336
|
+
channel = await DatagramBootstrap().handler(
|
|
337
|
+
lambda ch: ch.pipeline.add_last("udp", UdpHandler())
|
|
338
|
+
).bind("0.0.0.0", 9001)
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
## Idle / Timeouts
|
|
342
|
+
|
|
343
|
+
```python
|
|
344
|
+
from socket_netty import IdleStateHandler, ReadTimeoutHandler
|
|
345
|
+
|
|
346
|
+
async def on_idle(ctx, event):
|
|
347
|
+
print("Idle channel:", event.state)
|
|
348
|
+
await ctx.write(b"PING") # e.g. a game protocol heartbeat
|
|
349
|
+
|
|
350
|
+
def init_channel(channel):
|
|
351
|
+
idle = IdleStateHandler(reader_idle_seconds=30)
|
|
352
|
+
idle.on_idle = on_idle
|
|
353
|
+
channel.pipeline.add_last("idle", idle)
|
|
354
|
+
channel.pipeline.add_last("read_timeout", ReadTimeoutHandler(60)) # closes if no data in 60s
|
|
355
|
+
channel.pipeline.add_last("my_handler", MyHandler())
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
## Custom (non-I/O) events
|
|
359
|
+
|
|
360
|
+
`ChannelInboundHandler.user_event_triggered(ctx, event)` lets an
|
|
361
|
+
application signal something through the pipeline that isn't a
|
|
362
|
+
network read - e.g. "player finished login" triggered from
|
|
363
|
+
application code rather than `channel_read`:
|
|
364
|
+
|
|
365
|
+
```python
|
|
366
|
+
class GameHandler(ChannelInboundHandler):
|
|
367
|
+
async def user_event_triggered(self, ctx, event):
|
|
368
|
+
if event == "login_complete":
|
|
369
|
+
print("Player is ready:", ctx.channel.remote_address())
|
|
370
|
+
|
|
371
|
+
# From anywhere with access to the channel/ctx:
|
|
372
|
+
await ctx.fire_user_event_triggered("login_complete")
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
## EventLoopGroup (real multi-threaded concurrency)
|
|
376
|
+
|
|
377
|
+
```python
|
|
378
|
+
from socket_netty import EventLoopGroup
|
|
379
|
+
|
|
380
|
+
worker_group = EventLoopGroup(num_threads=4)
|
|
381
|
+
worker_group.start()
|
|
382
|
+
|
|
383
|
+
loop = worker_group.next_loop() # round-robin
|
|
384
|
+
future = loop.submit(lambda: my_heavy_coroutine())
|
|
385
|
+
result = future.result(timeout=5) # blocking, from any thread
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
## ChannelFuture / ChannelPromise
|
|
389
|
+
|
|
390
|
+
```python
|
|
391
|
+
from socket_netty import ChannelFuture
|
|
392
|
+
|
|
393
|
+
future = channel.new_future()
|
|
394
|
+
future.add_listener(lambda f: print("Finished:", f.is_success()))
|
|
395
|
+
|
|
396
|
+
# Still directly awaitable, Python-style:
|
|
397
|
+
result = await future
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
## Backpressure (WriteBufferWaterMark)
|
|
401
|
+
|
|
402
|
+
```python
|
|
403
|
+
from socket_netty import ServerBootstrap, WriteBufferWaterMark
|
|
404
|
+
|
|
405
|
+
server = (
|
|
406
|
+
ServerBootstrap()
|
|
407
|
+
.child_handler(init_channel)
|
|
408
|
+
.water_mark(WriteBufferWaterMark(low=32*1024, high=64*1024))
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
# In the handler:
|
|
412
|
+
if not ctx.channel.is_writable():
|
|
413
|
+
# pause sending more data until it becomes writable again
|
|
414
|
+
...
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
`ChannelInboundHandler.channel_writability_changed(ctx)` is fired
|
|
418
|
+
automatically through the pipeline whenever writability flips, so you
|
|
419
|
+
don't have to poll `is_writable()` to react to backpressure:
|
|
420
|
+
|
|
421
|
+
```python
|
|
422
|
+
class MyHandler(ChannelInboundHandler):
|
|
423
|
+
async def channel_writability_changed(self, ctx):
|
|
424
|
+
if not ctx.channel.is_writable():
|
|
425
|
+
print("Backpressure: pausing outgoing updates")
|
|
426
|
+
else:
|
|
427
|
+
print("Writable again: resuming")
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
## Batched writes (write / queue_write / flush)
|
|
431
|
+
|
|
432
|
+
`write()` keeps its historical behavior: it queues a message through
|
|
433
|
+
the outbound handlers and immediately flushes it (equivalent to
|
|
434
|
+
Netty's `writeAndFlush()`). For a game server updating many entities
|
|
435
|
+
per tick, `queue_write()` + a single `flush()` sends everything
|
|
436
|
+
queued as one combined write, paying the pipeline traversal cost once
|
|
437
|
+
instead of once per message:
|
|
438
|
+
|
|
439
|
+
```python
|
|
440
|
+
for entity in updated_entities:
|
|
441
|
+
await channel.queue_write(encode(entity)) # queued, not sent yet
|
|
442
|
+
await channel.flush() # sends everything queued, combined
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
The same pair (`write_no_flush()`/`flush()`) is available on
|
|
446
|
+
`ChannelHandlerContext` for handlers that want the same control.
|
|
447
|
+
|
|
448
|
+
## Concurrency (ChannelExecutor)
|
|
449
|
+
|
|
450
|
+
Guarantees a channel's messages are processed one at a time, in
|
|
451
|
+
order, even across `await` points (avoids race conditions on shared
|
|
452
|
+
handler state):
|
|
453
|
+
|
|
454
|
+
```python
|
|
455
|
+
from socket_netty import ChannelExecutor
|
|
456
|
+
|
|
457
|
+
executor = ChannelExecutor()
|
|
458
|
+
executor.start()
|
|
459
|
+
|
|
460
|
+
async def channel_read(self, ctx, msg):
|
|
461
|
+
await executor.submit(self._process(ctx, msg))
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
## Allocator (buffer pool)
|
|
465
|
+
|
|
466
|
+
```python
|
|
467
|
+
from socket_netty import PooledByteBufAllocator
|
|
468
|
+
|
|
469
|
+
allocator = PooledByteBufAllocator()
|
|
470
|
+
buf = allocator.buffer(256)
|
|
471
|
+
buf.write_int(42)
|
|
472
|
+
# ... use the buffer ...
|
|
473
|
+
allocator.release(buf) # goes back to the pool, ready to be reused
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
## Protocol framing (length-prefixed packets)
|
|
477
|
+
|
|
478
|
+
Very common in game protocols (Minecraft, Free Fire, etc.):
|
|
479
|
+
|
|
480
|
+
```python
|
|
481
|
+
from socket_netty import LengthFieldBasedFrameDecoder, LengthFieldPrepender
|
|
482
|
+
|
|
483
|
+
def init_channel(channel):
|
|
484
|
+
channel.pipeline.add_last("frame_decoder", LengthFieldBasedFrameDecoder(4))
|
|
485
|
+
channel.pipeline.add_last("frame_prepender", LengthFieldPrepender(4))
|
|
486
|
+
channel.pipeline.add_last("my_handler", MyHandler())
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
## Protocol decoding with protolib (declarative packets)
|
|
490
|
+
|
|
491
|
+
`LengthFieldBasedFrameDecoder`/`LengthFieldPrepender` above only solve
|
|
492
|
+
framing (where one packet ends and the next begins) — you still have
|
|
493
|
+
to hand-write the code that turns those raw bytes into a meaningful
|
|
494
|
+
packet. [`protolib`](https://pypi.org/project/protolib/) solves that
|
|
495
|
+
second half: you describe every packet's fields in a `.yml`/`.json`
|
|
496
|
+
file, and `ProtolibCodec` plugs that description directly into the
|
|
497
|
+
pipeline. `channel_read` then hands your handler a ready-made
|
|
498
|
+
`{"name": ..., "params": {...}}` dict instead of raw bytes, and
|
|
499
|
+
`write` accepts the same shape (or a `(name, params)` tuple) going
|
|
500
|
+
out.
|
|
501
|
+
|
|
502
|
+
`ProtolibCodec` has two framing modes, controlled by `framed`:
|
|
503
|
+
|
|
504
|
+
```python
|
|
505
|
+
from socket_netty import ServerBootstrap, ChannelInboundHandler, ProtolibCodec
|
|
506
|
+
|
|
507
|
+
# framed=True (default): the codec frames the stream itself via
|
|
508
|
+
# protolib's own PacketFramer (varint length-prefix, Minecraft-style).
|
|
509
|
+
# Don't also add LengthFieldBasedFrameDecoder in this mode.
|
|
510
|
+
def init_channel(channel):
|
|
511
|
+
channel.pipeline.add_last(
|
|
512
|
+
"protolib", ProtolibCodec("my_protocol.yml", state="play",
|
|
513
|
+
direction_in="toServer", direction_out="toClient"),
|
|
514
|
+
)
|
|
515
|
+
channel.pipeline.add_last("game", GameHandler())
|
|
516
|
+
|
|
517
|
+
class GameHandler(ChannelInboundHandler):
|
|
518
|
+
async def channel_read(self, ctx, msg):
|
|
519
|
+
print(msg["name"], msg["params"]) # ready-made dict
|
|
520
|
+
await ctx.write(("keep_alive", {"id": 1})) # (name, params) out
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
```python
|
|
524
|
+
# framed=False: for fixed-size / non-varint protocols (e.g. Minecraft
|
|
525
|
+
# Classic/ClassiCube), keep your own LengthFieldBasedFrameDecoder in
|
|
526
|
+
# front and let ProtolibCodec just parse/serialize the complete frame
|
|
527
|
+
# it's handed.
|
|
528
|
+
def init_channel(channel):
|
|
529
|
+
channel.pipeline.add_last("frame_decoder", LengthFieldBasedFrameDecoder(1))
|
|
530
|
+
channel.pipeline.add_last(
|
|
531
|
+
"protolib", ProtolibCodec("classicube_protocol.yml", framed=False),
|
|
532
|
+
)
|
|
533
|
+
channel.pipeline.add_last("game", GameHandler())
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
`protocol` accepts a `protolib.Protocol` instance you already built,
|
|
537
|
+
or anything `Protocol(...)` itself accepts (a `.yml`/`.json` path, an
|
|
538
|
+
in-memory string, or a parsed dict) — `ProtolibCodec` builds the
|
|
539
|
+
`Protocol` for you in that case. Any parsing/serialization error
|
|
540
|
+
protolib raises is wrapped into `DecoderException`/`EncoderException`,
|
|
541
|
+
same as the rest of the codecs in this library.
|
|
542
|
+
|
|
543
|
+
## ByteBuf
|
|
544
|
+
|
|
545
|
+
```python
|
|
546
|
+
from socket_netty import ByteBuf
|
|
547
|
+
|
|
548
|
+
buf = ByteBuf()
|
|
549
|
+
buf.write_varint(1000)
|
|
550
|
+
buf.write_string("hello")
|
|
551
|
+
buf.write_int(-42)
|
|
552
|
+
|
|
553
|
+
data = buf.to_bytes()
|
|
554
|
+
|
|
555
|
+
read_buf = ByteBuf.wrapped(data)
|
|
556
|
+
n = read_buf.read_varint()
|
|
557
|
+
s = read_buf.read_string()
|
|
558
|
+
i = read_buf.read_int()
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
Supports: `byte`, `unsigned_byte`, `short`, `unsigned_short`, `int`,
|
|
562
|
+
`unsigned_int`, `long`, `float`, `double`, `boolean`, `varint`,
|
|
563
|
+
`varlong` (Minecraft/protobuf style), `string`, and raw bytes — in
|
|
564
|
+
both big-endian (default) and little-endian (`*_le` suffix, e.g.
|
|
565
|
+
`write_int_le`/`read_int_le`) for every fixed-size numeric type.
|
|
566
|
+
|
|
567
|
+
### Absolute get/set (don't move reader_index/writer_index)
|
|
568
|
+
|
|
569
|
+
```python
|
|
570
|
+
buf = ByteBuf()
|
|
571
|
+
buf.write_int(0) # placeholder for a length prefix
|
|
572
|
+
start = buf.writer_index
|
|
573
|
+
buf.write_string("hello world")
|
|
574
|
+
buf.set_int(0, buf.writer_index - start) # patch the length in afterwards
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
Every fixed-size type has a `get_*`/`set_*` pair (`get_int`/`set_int`,
|
|
578
|
+
`get_short`/`set_short`, etc., plus `_le` variants) for reading/writing
|
|
579
|
+
at an absolute index without touching `reader_index`/`writer_index` —
|
|
580
|
+
the classic "write payload, go back and patch the length prefix"
|
|
581
|
+
pattern.
|
|
582
|
+
|
|
583
|
+
### Views: slice / duplicate / copy
|
|
584
|
+
|
|
585
|
+
```python
|
|
586
|
+
buf = ByteBuf(b"hello world")
|
|
587
|
+
buf.read_bytes(6) # consume "hello "
|
|
588
|
+
view = buf.slice() # shares memory with buf - "world"
|
|
589
|
+
independent = buf.copy() # independent deep copy
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
`slice()`/`duplicate()`/`retained_slice()` share backing storage with
|
|
593
|
+
the original buffer (writes through one are visible through the
|
|
594
|
+
other) and can't grow past their fixed window. `copy()` is a fully
|
|
595
|
+
independent deep copy. Note a CPython-specific limitation: while a
|
|
596
|
+
slice is alive, the parent buffer can't grow, `clear()`, or
|
|
597
|
+
`discard_read_bytes()` — doing so raises `IndexOutOfBoundsError` with
|
|
598
|
+
a clear message; drop every reference to a slice once you're done
|
|
599
|
+
with it.
|
|
600
|
+
|
|
601
|
+
### Other utilities
|
|
602
|
+
|
|
603
|
+
- `discard_read_bytes()` — compacts the buffer, dropping the
|
|
604
|
+
already-consumed prefix. Useful for long-lived accumulation buffers
|
|
605
|
+
(frame decoders) so they don't grow indefinitely in memory.
|
|
606
|
+
- `mark_writer_index()` / `reset_writer_index()` — writer-side
|
|
607
|
+
counterpart to `mark_reader_index()`/`reset_to_mark()`.
|
|
608
|
+
- `bytes_before(value)` — position of the first occurrence of a byte
|
|
609
|
+
ahead of `reader_index`, without moving it (handy for
|
|
610
|
+
null-terminated C-style strings).
|
|
611
|
+
- `ensure_writable(length)` — pre-reserves capacity.
|
|
612
|
+
|
|
613
|
+
## Tests
|
|
614
|
+
|
|
615
|
+
```bash
|
|
616
|
+
python3 tests/test_bytebuf.py
|
|
617
|
+
python3 tests/test_echo.py
|
|
618
|
+
python3 tests/test_core_extras.py # EventLoop, ChannelFuture, Allocator
|
|
619
|
+
python3 tests/test_network_extras.py # UDP, socket options, timeouts, idle
|
|
620
|
+
python3 tests/test_tls.py # end-to-end TLS (generates temporary certs)
|
|
621
|
+
python3 tests/test_concurrency.py # ChannelExecutor
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
## Design notes
|
|
625
|
+
|
|
626
|
+
- The entire pipeline is 100% `async`/`await`.
|
|
627
|
+
- `EventLoopGroup` uses real OS threads (each with its own asyncio
|
|
628
|
+
loop), unlike the rest of the library which normally runs on a
|
|
629
|
+
single loop — it's the honest way to replicate Netty's model
|
|
630
|
+
(thread pool) in Python.
|
|
631
|
+
- `ByteBuf` is a simple implementation backed by `bytearray`, with no
|
|
632
|
+
manual refcounting (Python already has GC). `slice()`/`duplicate()`
|
|
633
|
+
share memory via a `memoryview` instead of copying, but inherit a
|
|
634
|
+
CPython-specific limitation from it: a `bytearray` can't be resized
|
|
635
|
+
while a `memoryview` into it is alive, so the parent buffer can't
|
|
636
|
+
grow/`clear()`/`discard_read_bytes()` while a slice of it still is.
|
|
637
|
+
- `PooledByteBufAllocator` recycles buffers by size "bucket" (powers
|
|
638
|
+
of 2), useful in high-traffic game servers to reduce GC pressure.
|
|
639
|
+
- Per-connection events (`data_received`, `connection_made`,
|
|
640
|
+
`connection_lost`, writability changes) are processed one at a time,
|
|
641
|
+
in the exact order asyncio delivered them, via a small internal
|
|
642
|
+
queue — this preserves TCP's in-order delivery guarantee at the
|
|
643
|
+
application level even when a handler's processing of one message
|
|
644
|
+
takes an `await` long enough for a later message to otherwise finish
|
|
645
|
+
first.
|
|
646
|
+
- Exceptions mirror Netty's own `io.netty.*` hierarchy (see
|
|
647
|
+
[Exceptions](#exceptions) above), so error messages and logs read
|
|
648
|
+
the same as a real Netty stack trace.
|
|
649
|
+
- Designed for use both in game projects (custom servers, protocol
|
|
650
|
+
reversing) and as a general-purpose library.
|