socket-netty 0.3.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.
- pynetty/__init__.py +137 -0
- pynetty/bootstrap/__init__.py +3 -0
- pynetty/bootstrap/bootstrap.py +162 -0
- pynetty/buffer/__init__.py +18 -0
- pynetty/buffer/allocator.py +113 -0
- pynetty/buffer/bytebuf.py +256 -0
- pynetty/channel/__init__.py +24 -0
- pynetty/channel/channel.py +130 -0
- pynetty/channel/channel_future.py +143 -0
- pynetty/channel/channel_option.py +59 -0
- pynetty/channel/channel_pipeline.py +173 -0
- pynetty/channel/datagram_channel.py +164 -0
- pynetty/channel/event_loop.py +157 -0
- pynetty/channel/flow_control.py +99 -0
- pynetty/channel/protocol_adapter.py +59 -0
- pynetty/exceptions.py +94 -0
- pynetty/handler/__init__.py +47 -0
- pynetty/handler/channel_handler.py +83 -0
- pynetty/handler/channel_handler_context.py +104 -0
- pynetty/handler/codec.py +152 -0
- pynetty/handler/protolib_codec.py +152 -0
- pynetty/handler/ssl_context.py +62 -0
- pynetty/handler/timeout.py +159 -0
- socket_netty-0.3.0.dist-info/METADATA +402 -0
- socket_netty-0.3.0.dist-info/RECORD +27 -0
- socket_netty-0.3.0.dist-info/WHEEL +5 -0
- socket_netty-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from pynetty.channel.channel import Channel
|
|
2
|
+
from pynetty.channel.channel_pipeline import ChannelPipeline, DuplicateHandlerNameError
|
|
3
|
+
from pynetty.channel.channel_future import ChannelFuture, ChannelPromise
|
|
4
|
+
from pynetty.channel.event_loop import EventLoop, EventLoopGroup
|
|
5
|
+
from pynetty.channel.channel_option import ChannelOption, apply_socket_options
|
|
6
|
+
from pynetty.channel.flow_control import WriteBufferWaterMark, ChannelExecutor, FlowControlInboundHandler
|
|
7
|
+
from pynetty.channel.datagram_channel import DatagramChannel, DatagramBootstrap
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Channel",
|
|
11
|
+
"ChannelPipeline",
|
|
12
|
+
"DuplicateHandlerNameError",
|
|
13
|
+
"ChannelFuture",
|
|
14
|
+
"ChannelPromise",
|
|
15
|
+
"EventLoop",
|
|
16
|
+
"EventLoopGroup",
|
|
17
|
+
"ChannelOption",
|
|
18
|
+
"apply_socket_options",
|
|
19
|
+
"WriteBufferWaterMark",
|
|
20
|
+
"ChannelExecutor",
|
|
21
|
+
"FlowControlInboundHandler",
|
|
22
|
+
"DatagramChannel",
|
|
23
|
+
"DatagramBootstrap",
|
|
24
|
+
]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Channel: represents a network connection (socket) and its associated
|
|
3
|
+
pipeline. Wraps an asyncio.Transport and exposes the Netty-style API
|
|
4
|
+
(write, close, is_active, remote_address, attr).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
from pynetty.channel.channel_pipeline import ChannelPipeline
|
|
13
|
+
from pynetty.channel.channel_future import ChannelFuture
|
|
14
|
+
from pynetty.exceptions import ChannelException
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Channel:
|
|
18
|
+
def __init__(self, transport: Optional[asyncio.BaseTransport] = None) -> None:
|
|
19
|
+
self.transport = transport
|
|
20
|
+
self.pipeline = ChannelPipeline(self)
|
|
21
|
+
self._active = False
|
|
22
|
+
self._attrs: Dict[str, Any] = {}
|
|
23
|
+
self._close_event = asyncio.Event()
|
|
24
|
+
self._writable = True
|
|
25
|
+
|
|
26
|
+
# ------------------------------------------------------------------
|
|
27
|
+
# Lifecycle — invoked by asyncio's Protocol
|
|
28
|
+
# ------------------------------------------------------------------
|
|
29
|
+
def _attach_transport(self, transport: asyncio.BaseTransport) -> None:
|
|
30
|
+
self.transport = transport
|
|
31
|
+
|
|
32
|
+
async def _mark_active(self) -> None:
|
|
33
|
+
self._active = True
|
|
34
|
+
await self.pipeline.fire_channel_registered()
|
|
35
|
+
await self.pipeline.fire_channel_active()
|
|
36
|
+
|
|
37
|
+
async def _mark_inactive(self) -> None:
|
|
38
|
+
if not self._active:
|
|
39
|
+
return
|
|
40
|
+
self._active = False
|
|
41
|
+
await self.pipeline.fire_channel_inactive()
|
|
42
|
+
self._close_event.set()
|
|
43
|
+
|
|
44
|
+
async def _fire_read(self, data: Any) -> None:
|
|
45
|
+
await self.pipeline.fire_channel_read(data)
|
|
46
|
+
await self.pipeline.fire_channel_read_complete()
|
|
47
|
+
|
|
48
|
+
async def _fire_exception(self, exc: Exception) -> None:
|
|
49
|
+
await self.pipeline.fire_exception_caught(exc)
|
|
50
|
+
|
|
51
|
+
# ------------------------------------------------------------------
|
|
52
|
+
# Public Netty-style API
|
|
53
|
+
# ------------------------------------------------------------------
|
|
54
|
+
def is_active(self) -> bool:
|
|
55
|
+
return self._active and self.transport is not None and not self.transport.is_closing()
|
|
56
|
+
|
|
57
|
+
def is_writable(self) -> bool:
|
|
58
|
+
"""
|
|
59
|
+
Reflects the backpressure state of the underlying transport
|
|
60
|
+
(equivalent to Netty's Channel.isWritable()). Updated via
|
|
61
|
+
asyncio's Protocol pause_writing()/resume_writing().
|
|
62
|
+
"""
|
|
63
|
+
return self._writable
|
|
64
|
+
|
|
65
|
+
def _set_writable(self, value: bool) -> None:
|
|
66
|
+
self._writable = value
|
|
67
|
+
|
|
68
|
+
def new_future(self) -> ChannelFuture:
|
|
69
|
+
"""Creates a new ChannelFuture associated with this channel (equivalent to a new ChannelPromise)."""
|
|
70
|
+
return ChannelFuture(self)
|
|
71
|
+
|
|
72
|
+
async def write(self, msg: Any) -> None:
|
|
73
|
+
"""Writes a message through the pipeline (passes through the outbound handlers)."""
|
|
74
|
+
await self.pipeline.write(msg)
|
|
75
|
+
|
|
76
|
+
async def close(self) -> None:
|
|
77
|
+
await self.pipeline.close()
|
|
78
|
+
# Wait for the transport's connection_lost() to fire
|
|
79
|
+
# _mark_inactive() and finish propagating channel_inactive, so
|
|
80
|
+
# `await channel.close()` is a real guarantee that the
|
|
81
|
+
# channel's lifecycle has fully closed.
|
|
82
|
+
if self.transport is not None:
|
|
83
|
+
try:
|
|
84
|
+
await asyncio.wait_for(self.wait_closed(), timeout=5)
|
|
85
|
+
except asyncio.TimeoutError:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
async def wait_closed(self) -> None:
|
|
89
|
+
await self._close_event.wait()
|
|
90
|
+
|
|
91
|
+
def remote_address(self) -> Optional[tuple]:
|
|
92
|
+
if self.transport is None:
|
|
93
|
+
return None
|
|
94
|
+
return self.transport.get_extra_info("peername")
|
|
95
|
+
|
|
96
|
+
def local_address(self) -> Optional[tuple]:
|
|
97
|
+
if self.transport is None:
|
|
98
|
+
return None
|
|
99
|
+
return self.transport.get_extra_info("sockname")
|
|
100
|
+
|
|
101
|
+
def attr(self, key: str, default: Any = None) -> Any:
|
|
102
|
+
return self._attrs.get(key, default)
|
|
103
|
+
|
|
104
|
+
def set_attr(self, key: str, value: Any) -> None:
|
|
105
|
+
self._attrs[key] = value
|
|
106
|
+
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
# Actual I/O implementation, invoked only once a message reaches the
|
|
109
|
+
# end of the outbound chain (or the head, in close()'s case)
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
async def _do_write(self, msg: Any) -> None:
|
|
112
|
+
if self.transport is None:
|
|
113
|
+
raise ChannelException("Channel has no associated transport; cannot write")
|
|
114
|
+
if isinstance(msg, (bytes, bytearray)):
|
|
115
|
+
self.transport.write(bytes(msg))
|
|
116
|
+
elif hasattr(msg, "to_bytes"):
|
|
117
|
+
# Supports ByteBuf and similar objects
|
|
118
|
+
self.transport.write(msg.to_bytes())
|
|
119
|
+
else:
|
|
120
|
+
raise TypeError(
|
|
121
|
+
f"Cannot write a message of type {type(msg).__name__}; "
|
|
122
|
+
"add an encoder to the pipeline that converts it to bytes"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
async def _do_close(self) -> None:
|
|
126
|
+
if self.transport is not None and not self.transport.is_closing():
|
|
127
|
+
self.transport.close()
|
|
128
|
+
|
|
129
|
+
def __repr__(self) -> str:
|
|
130
|
+
return f"Channel(remote={self.remote_address()}, active={self.is_active()})"
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ChannelFuture / ChannelPromise: equivalent to io.netty.channel.ChannelFuture.
|
|
3
|
+
|
|
4
|
+
In Netty, nearly every async operation (connect, write, close, bind)
|
|
5
|
+
returns a ChannelFuture you can add listeners to instead of blocking.
|
|
6
|
+
Here it's modeled as a thin wrapper over asyncio.Future that:
|
|
7
|
+
- Is directly awaitable (`await future`), for Python-style usage.
|
|
8
|
+
- Supports add_listener()/remove_listener(), for Netty-style usage.
|
|
9
|
+
- Exposes is_done(), is_success(), cause(), get_now(), sync(), await_().
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
from typing import Any, Callable, List, Optional, TypeVar
|
|
16
|
+
|
|
17
|
+
T = TypeVar("T")
|
|
18
|
+
|
|
19
|
+
Listener = Callable[["ChannelFuture"], None]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ChannelFuture:
|
|
23
|
+
def __init__(self, channel: Any, aio_future: Optional[asyncio.Future] = None) -> None:
|
|
24
|
+
self.channel = channel
|
|
25
|
+
self._future: asyncio.Future = aio_future if aio_future is not None else asyncio.get_event_loop().create_future()
|
|
26
|
+
self._listeners: List[Listener] = []
|
|
27
|
+
self._notified = False
|
|
28
|
+
self._future.add_done_callback(self._on_future_done)
|
|
29
|
+
|
|
30
|
+
# ------------------------------------------------------------------
|
|
31
|
+
# Construction / resolution
|
|
32
|
+
# ------------------------------------------------------------------
|
|
33
|
+
@classmethod
|
|
34
|
+
def succeeded(cls, channel: Any, result: Any = None) -> "ChannelFuture":
|
|
35
|
+
fut = cls(channel)
|
|
36
|
+
fut.set_result(result)
|
|
37
|
+
return fut
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def failed(cls, channel: Any, exc: Exception) -> "ChannelFuture":
|
|
41
|
+
fut = cls(channel)
|
|
42
|
+
fut.set_exception(exc)
|
|
43
|
+
return fut
|
|
44
|
+
|
|
45
|
+
def set_result(self, result: Any = None) -> "ChannelFuture":
|
|
46
|
+
if not self._future.done():
|
|
47
|
+
self._future.set_result(result)
|
|
48
|
+
self._on_done(self._future)
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
def set_exception(self, exc: Exception) -> "ChannelFuture":
|
|
52
|
+
if not self._future.done():
|
|
53
|
+
self._future.set_exception(exc)
|
|
54
|
+
self._on_done(self._future)
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
def _on_future_done(self, _fut: asyncio.Future) -> None:
|
|
58
|
+
# Fallback callback for external futures (passed via
|
|
59
|
+
# aio_future) that complete through another path (e.g. cancel()).
|
|
60
|
+
# If set_result/set_exception already fired _on_done
|
|
61
|
+
# synchronously, this callback arrives later and must not repeat listeners.
|
|
62
|
+
self._on_done(_fut)
|
|
63
|
+
|
|
64
|
+
def _on_done(self, _fut: asyncio.Future) -> None:
|
|
65
|
+
if self._notified:
|
|
66
|
+
return
|
|
67
|
+
self._notified = True
|
|
68
|
+
for listener in self._listeners:
|
|
69
|
+
try:
|
|
70
|
+
listener(self)
|
|
71
|
+
except Exception:
|
|
72
|
+
# A listener that blows up must not take down the rest.
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
# ------------------------------------------------------------------
|
|
76
|
+
# Netty-style API
|
|
77
|
+
# ------------------------------------------------------------------
|
|
78
|
+
def add_listener(self, listener: Listener) -> "ChannelFuture":
|
|
79
|
+
"""
|
|
80
|
+
Registers a callback that receives this ChannelFuture once it
|
|
81
|
+
completes. If it's already complete, it's invoked immediately
|
|
82
|
+
(same behavior as Netty).
|
|
83
|
+
"""
|
|
84
|
+
self._listeners.append(listener)
|
|
85
|
+
if self._future.done():
|
|
86
|
+
listener(self)
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
def remove_listener(self, listener: Listener) -> "ChannelFuture":
|
|
90
|
+
if listener in self._listeners:
|
|
91
|
+
self._listeners.remove(listener)
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
def is_done(self) -> bool:
|
|
95
|
+
return self._future.done()
|
|
96
|
+
|
|
97
|
+
def is_success(self) -> bool:
|
|
98
|
+
return self._future.done() and self._future.exception() is None
|
|
99
|
+
|
|
100
|
+
def is_cancelled(self) -> bool:
|
|
101
|
+
return self._future.cancelled()
|
|
102
|
+
|
|
103
|
+
def cause(self) -> Optional[BaseException]:
|
|
104
|
+
if not self._future.done() or self._future.cancelled():
|
|
105
|
+
return None
|
|
106
|
+
return self._future.exception()
|
|
107
|
+
|
|
108
|
+
def get_now(self) -> Any:
|
|
109
|
+
"""Returns the result if it's already ready, otherwise None (does not block)."""
|
|
110
|
+
if self._future.done() and not self._future.cancelled() and self._future.exception() is None:
|
|
111
|
+
return self._future.result()
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
async def sync(self) -> "ChannelFuture":
|
|
115
|
+
"""Waits for the operation to finish and re-raises the exception if it failed (equivalent to Netty's sync())."""
|
|
116
|
+
await self._future
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
def __await__(self):
|
|
120
|
+
return self._future.__await__()
|
|
121
|
+
|
|
122
|
+
def __repr__(self) -> str:
|
|
123
|
+
state = "pending" if not self.is_done() else ("success" if self.is_success() else "failed")
|
|
124
|
+
return f"ChannelFuture({state})"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ChannelPromise(ChannelFuture):
|
|
128
|
+
"""
|
|
129
|
+
Equivalent to io.netty.channel.ChannelPromise: a ChannelFuture that
|
|
130
|
+
can also be completed manually from the outside (writer side).
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
def try_success(self, result: Any = None) -> bool:
|
|
134
|
+
if self._future.done():
|
|
135
|
+
return False
|
|
136
|
+
self.set_result(result)
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
def try_failure(self, exc: Exception) -> bool:
|
|
140
|
+
if self._future.done():
|
|
141
|
+
return False
|
|
142
|
+
self.set_exception(exc)
|
|
143
|
+
return True
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ChannelOption: equivalent to io.netty.channel.ChannelOption.
|
|
3
|
+
|
|
4
|
+
In Netty you configure socket options via
|
|
5
|
+
`.option(ChannelOption.SO_BACKLOG, 128)` before bind()/connect().
|
|
6
|
+
Here we replicate that declarative API and apply it to the real Python
|
|
7
|
+
socket (via setsockopt) when the Channel is created.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import socket
|
|
13
|
+
from typing import Any, Dict
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ChannelOption:
|
|
17
|
+
"""Namespace of option constants, like Netty's ChannelOption class."""
|
|
18
|
+
|
|
19
|
+
SO_BACKLOG = "SO_BACKLOG" # size of the pending-connections queue (server only)
|
|
20
|
+
SO_REUSEADDR = "SO_REUSEADDR" # reuse the address/port immediately after closing
|
|
21
|
+
SO_KEEPALIVE = "SO_KEEPALIVE" # kernel-level TCP keepalive
|
|
22
|
+
SO_RCVBUF = "SO_RCVBUF" # kernel receive-buffer size
|
|
23
|
+
SO_SNDBUF = "SO_SNDBUF" # kernel send-buffer size
|
|
24
|
+
TCP_NODELAY = "TCP_NODELAY" # disables Nagle's algorithm (low latency, useful for games)
|
|
25
|
+
SO_LINGER = "SO_LINGER" # seconds to wait on close with pending data
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def apply_socket_options(sock: socket.socket, options: Dict[str, Any]) -> None:
|
|
29
|
+
"""Applies a dict of ChannelOption -> value onto a real socket."""
|
|
30
|
+
for name, value in options.items():
|
|
31
|
+
if name == ChannelOption.SO_REUSEADDR:
|
|
32
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1 if value else 0)
|
|
33
|
+
elif name == ChannelOption.SO_KEEPALIVE:
|
|
34
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1 if value else 0)
|
|
35
|
+
elif name == ChannelOption.SO_RCVBUF:
|
|
36
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, int(value))
|
|
37
|
+
elif name == ChannelOption.SO_SNDBUF:
|
|
38
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, int(value))
|
|
39
|
+
elif name == ChannelOption.TCP_NODELAY:
|
|
40
|
+
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1 if value else 0)
|
|
41
|
+
elif name == ChannelOption.SO_LINGER:
|
|
42
|
+
# value: seconds (int) or None to disable
|
|
43
|
+
if value is None:
|
|
44
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct_linger(0, 0))
|
|
45
|
+
else:
|
|
46
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct_linger(1, int(value)))
|
|
47
|
+
elif name == ChannelOption.SO_BACKLOG:
|
|
48
|
+
# SO_BACKLOG is applied when creating the listener
|
|
49
|
+
# (loop.create_server(backlog=...)), not via setsockopt;
|
|
50
|
+
# the bootstrap handles it directly.
|
|
51
|
+
continue
|
|
52
|
+
else:
|
|
53
|
+
raise ValueError(f"Unknown ChannelOption: {name}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def struct_linger(onoff: int, linger: int) -> bytes:
|
|
57
|
+
import struct
|
|
58
|
+
|
|
59
|
+
return struct.pack("ii", onoff, linger)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ChannelPipeline: doubly-linked list of ChannelHandlerContext instances,
|
|
3
|
+
each wrapping a ChannelHandler added to a Channel. It's the central
|
|
4
|
+
entry point for firing inbound events and outbound operations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Dict, TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from pynetty.exceptions import DuplicateHandlerNameError
|
|
12
|
+
from pynetty.handler.channel_handler import (
|
|
13
|
+
ChannelHandler,
|
|
14
|
+
ChannelInboundHandler,
|
|
15
|
+
ChannelOutboundHandler,
|
|
16
|
+
)
|
|
17
|
+
from pynetty.handler.channel_handler_context import ChannelHandlerContext
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from pynetty.channel.channel import Channel
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ChannelPipeline:
|
|
24
|
+
def __init__(self, channel: "Channel") -> None:
|
|
25
|
+
self.channel = channel
|
|
26
|
+
# Sentinel head and tail (no real handler), as in Netty
|
|
27
|
+
# (HeadContext / TailContext).
|
|
28
|
+
self._head = ChannelHandlerContext("__head__", _HeadHandler(), self)
|
|
29
|
+
self._tail = ChannelHandlerContext("__tail__", _TailHandler(), self)
|
|
30
|
+
self._head._next = self._tail
|
|
31
|
+
self._tail._prev = self._head
|
|
32
|
+
self._contexts: Dict[str, ChannelHandlerContext] = {}
|
|
33
|
+
|
|
34
|
+
# ------------------------------------------------------------------
|
|
35
|
+
# Handler registration
|
|
36
|
+
# ------------------------------------------------------------------
|
|
37
|
+
def add_last(self, name: str, handler: ChannelHandler) -> "ChannelPipeline":
|
|
38
|
+
if name in self._contexts:
|
|
39
|
+
raise DuplicateHandlerNameError(f"Duplicate handler name: {name!r}")
|
|
40
|
+
ctx = ChannelHandlerContext(name, handler, self)
|
|
41
|
+
prev = self._tail._prev
|
|
42
|
+
prev._next = ctx
|
|
43
|
+
ctx._prev = prev
|
|
44
|
+
ctx._next = self._tail
|
|
45
|
+
self._tail._prev = ctx
|
|
46
|
+
self._contexts[name] = ctx
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
def add_first(self, name: str, handler: ChannelHandler) -> "ChannelPipeline":
|
|
50
|
+
if name in self._contexts:
|
|
51
|
+
raise DuplicateHandlerNameError(f"Duplicate handler name: {name!r}")
|
|
52
|
+
ctx = ChannelHandlerContext(name, handler, self)
|
|
53
|
+
nxt = self._head._next
|
|
54
|
+
self._head._next = ctx
|
|
55
|
+
ctx._prev = self._head
|
|
56
|
+
ctx._next = nxt
|
|
57
|
+
nxt._prev = ctx
|
|
58
|
+
self._contexts[name] = ctx
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
def remove(self, name: str) -> "ChannelPipeline":
|
|
62
|
+
ctx = self._contexts.pop(name, None)
|
|
63
|
+
if ctx is None:
|
|
64
|
+
return self
|
|
65
|
+
ctx._prev._next = ctx._next
|
|
66
|
+
ctx._next._prev = ctx._prev
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def get(self, name: str) -> "ChannelHandler | None":
|
|
70
|
+
ctx = self._contexts.get(name)
|
|
71
|
+
return ctx.handler if ctx else None
|
|
72
|
+
|
|
73
|
+
def context(self, name: str) -> "ChannelHandlerContext | None":
|
|
74
|
+
return self._contexts.get(name)
|
|
75
|
+
|
|
76
|
+
def names(self) -> list[str]:
|
|
77
|
+
return list(self._contexts.keys())
|
|
78
|
+
|
|
79
|
+
# ------------------------------------------------------------------
|
|
80
|
+
# Firing inbound events (enter through the head)
|
|
81
|
+
# ------------------------------------------------------------------
|
|
82
|
+
async def fire_channel_registered(self) -> None:
|
|
83
|
+
await self._head.handler.channel_registered(self._head)
|
|
84
|
+
|
|
85
|
+
async def fire_channel_active(self) -> None:
|
|
86
|
+
await self._head.handler.channel_active(self._head)
|
|
87
|
+
|
|
88
|
+
async def fire_channel_inactive(self) -> None:
|
|
89
|
+
await self._head.handler.channel_inactive(self._head)
|
|
90
|
+
|
|
91
|
+
async def fire_channel_read(self, msg: Any) -> None:
|
|
92
|
+
await self._head.handler.channel_read(self._head, msg)
|
|
93
|
+
|
|
94
|
+
async def fire_channel_read_complete(self) -> None:
|
|
95
|
+
await self._head.handler.channel_read_complete(self._head)
|
|
96
|
+
|
|
97
|
+
async def fire_exception_caught(self, exc: Exception) -> None:
|
|
98
|
+
await self._head.handler.exception_caught(self._head, exc)
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
# Outbound operations (enter through the tail, travel toward the head)
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
async def write(self, msg: Any) -> None:
|
|
104
|
+
await self._tail.handler.write(self._tail, msg)
|
|
105
|
+
|
|
106
|
+
async def close(self) -> None:
|
|
107
|
+
await self._tail.handler.close(self._tail)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class _HeadHandler(ChannelInboundHandler, ChannelOutboundHandler):
|
|
111
|
+
"""Sentinel head handler: both inbound AND outbound (marks the start of the chain)."""
|
|
112
|
+
|
|
113
|
+
async def channel_registered(self, ctx: ChannelHandlerContext) -> None:
|
|
114
|
+
await ctx.fire_channel_registered()
|
|
115
|
+
|
|
116
|
+
async def channel_active(self, ctx: ChannelHandlerContext) -> None:
|
|
117
|
+
await ctx.fire_channel_active()
|
|
118
|
+
|
|
119
|
+
async def channel_inactive(self, ctx: ChannelHandlerContext) -> None:
|
|
120
|
+
await ctx.fire_channel_inactive()
|
|
121
|
+
|
|
122
|
+
async def channel_read(self, ctx: ChannelHandlerContext, msg: Any) -> None:
|
|
123
|
+
await ctx.fire_channel_read(msg)
|
|
124
|
+
|
|
125
|
+
async def channel_read_complete(self, ctx: ChannelHandlerContext) -> None:
|
|
126
|
+
await ctx.fire_channel_read_complete()
|
|
127
|
+
|
|
128
|
+
async def write(self, ctx: ChannelHandlerContext, msg: Any) -> None:
|
|
129
|
+
await ctx.channel._do_write(msg)
|
|
130
|
+
|
|
131
|
+
async def close(self, ctx: ChannelHandlerContext) -> None:
|
|
132
|
+
await ctx.channel._do_close()
|
|
133
|
+
|
|
134
|
+
async def exception_caught(self, ctx: ChannelHandlerContext, exc: Exception) -> None:
|
|
135
|
+
# See the note in _TailHandler.exception_caught: with no one
|
|
136
|
+
# else left in the chain, we don't re-raise so we don't kill the
|
|
137
|
+
# task that fired the event.
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class _TailHandler(ChannelInboundHandler, ChannelOutboundHandler):
|
|
142
|
+
"""Sentinel tail handler: end of the inbound chain, start of the outbound chain."""
|
|
143
|
+
|
|
144
|
+
async def channel_registered(self, ctx: ChannelHandlerContext) -> None:
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
async def channel_active(self, ctx: ChannelHandlerContext) -> None:
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
async def channel_inactive(self, ctx: ChannelHandlerContext) -> None:
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
async def channel_read(self, ctx: ChannelHandlerContext, msg: Any) -> None:
|
|
154
|
+
# If a message reaches the tail without being consumed, Netty
|
|
155
|
+
# logs it as "unhandled". Here it's simply discarded.
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
async def channel_read_complete(self, ctx: ChannelHandlerContext) -> None:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
async def write(self, ctx: ChannelHandlerContext, msg: Any) -> None:
|
|
162
|
+
await ctx.write(msg)
|
|
163
|
+
|
|
164
|
+
async def close(self, ctx: ChannelHandlerContext) -> None:
|
|
165
|
+
await ctx.close()
|
|
166
|
+
|
|
167
|
+
async def exception_caught(self, ctx: ChannelHandlerContext, exc: Exception) -> None:
|
|
168
|
+
# If the exception reaches the end of the pipeline without any
|
|
169
|
+
# handler having "consumed" it (stopped propagating it), there's
|
|
170
|
+
# no one else left to notify. Netty logs this as a warning; here
|
|
171
|
+
# we don't re-raise so we don't kill the task that fired the
|
|
172
|
+
# event (e.g. ReadTimeoutHandler's watcher).
|
|
173
|
+
pass
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DatagramChannel: equivalent to io.netty.channel.socket.DatagramChannel.
|
|
3
|
+
|
|
4
|
+
Unlike TCP, UDP has no "connection" or framing — each datagram arrives
|
|
5
|
+
together with the sender's address. The pipeline receives
|
|
6
|
+
(data: bytes, addr: (host, port)) tuples in channel_read, and expects
|
|
7
|
+
the same from write() unless you use connect() to fix a default peer.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
from typing import Any, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
from pynetty.channel.channel_pipeline import ChannelPipeline
|
|
16
|
+
from pynetty.exceptions import ChannelException
|
|
17
|
+
|
|
18
|
+
Address = Tuple[str, int]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DatagramChannel:
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
self.transport: Optional[asyncio.DatagramTransport] = None
|
|
24
|
+
self.pipeline = ChannelPipeline(self)
|
|
25
|
+
self._active = False
|
|
26
|
+
self._remote_addr: Optional[Address] = None
|
|
27
|
+
self._close_event = asyncio.Event()
|
|
28
|
+
|
|
29
|
+
def _attach_transport(self, transport: asyncio.DatagramTransport) -> None:
|
|
30
|
+
self.transport = transport
|
|
31
|
+
|
|
32
|
+
async def _mark_active(self) -> None:
|
|
33
|
+
self._active = True
|
|
34
|
+
await self.pipeline.fire_channel_registered()
|
|
35
|
+
await self.pipeline.fire_channel_active()
|
|
36
|
+
|
|
37
|
+
async def _mark_inactive(self) -> None:
|
|
38
|
+
if not self._active:
|
|
39
|
+
return
|
|
40
|
+
self._active = False
|
|
41
|
+
await self.pipeline.fire_channel_inactive()
|
|
42
|
+
self._close_event.set()
|
|
43
|
+
|
|
44
|
+
async def _fire_read(self, data: bytes, addr: Address) -> None:
|
|
45
|
+
await self.pipeline.fire_channel_read((data, addr))
|
|
46
|
+
await self.pipeline.fire_channel_read_complete()
|
|
47
|
+
|
|
48
|
+
async def _fire_exception(self, exc: Exception) -> None:
|
|
49
|
+
await self.pipeline.fire_exception_caught(exc)
|
|
50
|
+
|
|
51
|
+
def is_active(self) -> bool:
|
|
52
|
+
return self._active and self.transport is not None and not self.transport.is_closing()
|
|
53
|
+
|
|
54
|
+
def connect(self, addr: Address) -> "DatagramChannel":
|
|
55
|
+
"""Fixes a default remote peer (optional; UDP doesn't require a real connection)."""
|
|
56
|
+
self._remote_addr = addr
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
async def write(self, msg: Any, addr: Optional[Address] = None) -> None:
|
|
60
|
+
"""
|
|
61
|
+
Writes through the pipeline. `msg` can be raw bytes or
|
|
62
|
+
(bytes, addr) to specify an explicit destination per send.
|
|
63
|
+
"""
|
|
64
|
+
if addr is not None:
|
|
65
|
+
await self.pipeline.write((msg, addr))
|
|
66
|
+
else:
|
|
67
|
+
await self.pipeline.write(msg)
|
|
68
|
+
|
|
69
|
+
async def close(self) -> None:
|
|
70
|
+
await self.pipeline.close()
|
|
71
|
+
if self.transport is not None:
|
|
72
|
+
try:
|
|
73
|
+
await asyncio.wait_for(self.wait_closed(), timeout=5)
|
|
74
|
+
except asyncio.TimeoutError:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
async def wait_closed(self) -> None:
|
|
78
|
+
await self._close_event.wait()
|
|
79
|
+
|
|
80
|
+
def local_address(self) -> Optional[Address]:
|
|
81
|
+
if self.transport is None:
|
|
82
|
+
return None
|
|
83
|
+
return self.transport.get_extra_info("sockname")
|
|
84
|
+
|
|
85
|
+
async def _do_write(self, msg: Any) -> None:
|
|
86
|
+
if self.transport is None:
|
|
87
|
+
raise ChannelException("DatagramChannel has no associated transport")
|
|
88
|
+
|
|
89
|
+
if isinstance(msg, tuple) and len(msg) == 2 and isinstance(msg[1], tuple):
|
|
90
|
+
data, addr = msg
|
|
91
|
+
else:
|
|
92
|
+
data = msg
|
|
93
|
+
addr = self._remote_addr
|
|
94
|
+
|
|
95
|
+
if addr is None:
|
|
96
|
+
raise ChannelException(
|
|
97
|
+
"No destination address: use connect(addr) or write(msg, addr=...)"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
payload = data if isinstance(data, (bytes, bytearray)) else data.to_bytes()
|
|
101
|
+
self.transport.sendto(bytes(payload), addr)
|
|
102
|
+
|
|
103
|
+
async def _do_close(self) -> None:
|
|
104
|
+
if self.transport is not None and not self.transport.is_closing():
|
|
105
|
+
self.transport.close()
|
|
106
|
+
|
|
107
|
+
def __repr__(self) -> str:
|
|
108
|
+
return f"DatagramChannel(local={self.local_address()}, active={self.is_active()})"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class _DatagramProtocolAdapter(asyncio.DatagramProtocol):
|
|
112
|
+
def __init__(self, channel: DatagramChannel) -> None:
|
|
113
|
+
self.channel = channel
|
|
114
|
+
self._loop = asyncio.get_event_loop()
|
|
115
|
+
|
|
116
|
+
def connection_made(self, transport: asyncio.DatagramTransport) -> None:
|
|
117
|
+
self.channel._attach_transport(transport)
|
|
118
|
+
self._loop.create_task(self._safe(self.channel._mark_active()))
|
|
119
|
+
|
|
120
|
+
def datagram_received(self, data: bytes, addr: Address) -> None:
|
|
121
|
+
self._loop.create_task(self._safe(self.channel._fire_read(data, addr)))
|
|
122
|
+
|
|
123
|
+
def error_received(self, exc: Exception) -> None:
|
|
124
|
+
self._loop.create_task(self._safe(self.channel._fire_exception(exc)))
|
|
125
|
+
|
|
126
|
+
def connection_lost(self, exc: Optional[Exception]) -> None:
|
|
127
|
+
if exc is not None:
|
|
128
|
+
self._loop.create_task(self._safe(self.channel._fire_exception(exc)))
|
|
129
|
+
self._loop.create_task(self._safe(self.channel._mark_inactive()))
|
|
130
|
+
|
|
131
|
+
async def _safe(self, coro) -> None:
|
|
132
|
+
try:
|
|
133
|
+
await coro
|
|
134
|
+
except Exception as exc:
|
|
135
|
+
try:
|
|
136
|
+
await self.channel._fire_exception(exc)
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class DatagramBootstrap:
|
|
142
|
+
"""Equivalent to a Bootstrap configured with NioDatagramChannel in Netty."""
|
|
143
|
+
|
|
144
|
+
def __init__(self) -> None:
|
|
145
|
+
self._initializer = None
|
|
146
|
+
|
|
147
|
+
def handler(self, initializer) -> "DatagramBootstrap":
|
|
148
|
+
self._initializer = initializer
|
|
149
|
+
return self
|
|
150
|
+
|
|
151
|
+
async def bind(self, host: str = "0.0.0.0", port: int = 0) -> DatagramChannel:
|
|
152
|
+
if self._initializer is None:
|
|
153
|
+
raise ChannelException("You must call handler(...) before bind()")
|
|
154
|
+
|
|
155
|
+
loop = asyncio.get_event_loop()
|
|
156
|
+
channel = DatagramChannel()
|
|
157
|
+
result = self._initializer(channel)
|
|
158
|
+
if asyncio.iscoroutine(result):
|
|
159
|
+
await result
|
|
160
|
+
|
|
161
|
+
await loop.create_datagram_endpoint(
|
|
162
|
+
lambda: _DatagramProtocolAdapter(channel), local_addr=(host, port)
|
|
163
|
+
)
|
|
164
|
+
return channel
|