socketwrapper 0.0.1__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.
- socketwrapper/__init__.py +476 -0
- socketwrapper/_utils.py +283 -0
- socketwrapper/_varint.py +94 -0
- socketwrapper/framing.py +143 -0
- socketwrapper/protocols.py +39 -0
- socketwrapper/py.typed +0 -0
- socketwrapper-0.0.1.dist-info/METADATA +294 -0
- socketwrapper-0.0.1.dist-info/RECORD +12 -0
- socketwrapper-0.0.1.dist-info/WHEEL +5 -0
- socketwrapper-0.0.1.dist-info/licenses/LICENSE +21 -0
- socketwrapper-0.0.1.dist-info/top_level.txt +1 -0
- socketwrapper-0.0.1.dist-info/zip-safe +1 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
"""socketwrapper - Message socket wrappers."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import collections.abc
|
|
5
|
+
import os
|
|
6
|
+
import selectors
|
|
7
|
+
import socket
|
|
8
|
+
import stat
|
|
9
|
+
import sys
|
|
10
|
+
import typing
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
if sys.version_info < (3, 13):
|
|
14
|
+
import typing_extensions
|
|
15
|
+
else:
|
|
16
|
+
typing_extensions = typing
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
from . import _utils, framing, protocols
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
__all__ = (
|
|
23
|
+
# type aliases and protocols
|
|
24
|
+
'MessageFraming',
|
|
25
|
+
'RecvSize',
|
|
26
|
+
'SendPayload',
|
|
27
|
+
'SocketLike',
|
|
28
|
+
'SocketOrDescriptor',
|
|
29
|
+
|
|
30
|
+
# wrappers
|
|
31
|
+
'SocketWrapper',
|
|
32
|
+
'SocketReader',
|
|
33
|
+
'SocketWriter',
|
|
34
|
+
'SocketDuplex',
|
|
35
|
+
|
|
36
|
+
'MessageWrapper',
|
|
37
|
+
'MessageReader',
|
|
38
|
+
'MessageWriter',
|
|
39
|
+
'MessageDuplex',
|
|
40
|
+
|
|
41
|
+
# utils
|
|
42
|
+
'pipe',
|
|
43
|
+
'socketpair',
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
DEFAULT_SOCKETPAIR_FAMILY = getattr(socket, 'AF_UNIX', socket.AF_INET)
|
|
48
|
+
DEFAULT_FRAMING = framing.VarIntBytes
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
SocketLike = protocols.SocketLike
|
|
52
|
+
MessageFraming = protocols.MessageFraming
|
|
53
|
+
SocketOrDescriptor = SocketLike | protocols.HasFileno | int
|
|
54
|
+
RecvSize = _utils.RecvSize
|
|
55
|
+
SendPayload = _utils.SendPayload
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class DescriptorSocket:
|
|
59
|
+
"""Generic socket-like accepting any file descriptor (usualy pipes) as input."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, fd: int) -> None:
|
|
62
|
+
"""Initialize."""
|
|
63
|
+
self._fd = fd
|
|
64
|
+
|
|
65
|
+
def fileno(self) -> int:
|
|
66
|
+
"""Get underlying file descriptor."""
|
|
67
|
+
return self._fd
|
|
68
|
+
|
|
69
|
+
def send(self, data: collections.abc.Buffer) -> int:
|
|
70
|
+
"""Write data to descriptor."""
|
|
71
|
+
return os.write(self._fd, data)
|
|
72
|
+
|
|
73
|
+
def recv(self, bufsize: int) -> bytes:
|
|
74
|
+
"""Read data from descriptor."""
|
|
75
|
+
return os.read(self._fd, bufsize)
|
|
76
|
+
|
|
77
|
+
def settimeout(self, timeout: float | None) -> None:
|
|
78
|
+
"""Set blocking or non-blocking based on timeout being 0 or None."""
|
|
79
|
+
if timeout:
|
|
80
|
+
raise ValueError(timeout)
|
|
81
|
+
|
|
82
|
+
os.set_blocking(self._fd, timeout is None)
|
|
83
|
+
|
|
84
|
+
def close(self) -> None:
|
|
85
|
+
"""Close file descriptor."""
|
|
86
|
+
os.close(self._fd)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class SocketWrapper(_utils.ClosingContext):
|
|
90
|
+
"""Base class for socket wrappers supporting asynchronous operations."""
|
|
91
|
+
|
|
92
|
+
_sock: SocketLike
|
|
93
|
+
_duplex: bool = False
|
|
94
|
+
_wselector: selectors.BaseSelector | None = None
|
|
95
|
+
_rselector: selectors.BaseSelector | None = None
|
|
96
|
+
|
|
97
|
+
def __init__(self, sock: SocketOrDescriptor) -> None:
|
|
98
|
+
"""Initialize for socket."""
|
|
99
|
+
self._sock = _socketlike(sock)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def inheritable(self) -> bool:
|
|
103
|
+
"""Get whether or not this socket is inheritable by subprocesses."""
|
|
104
|
+
return os.get_inheritable(self._sock.fileno())
|
|
105
|
+
|
|
106
|
+
@inheritable.setter
|
|
107
|
+
def inheritable(self, inheritable: bool) -> None:
|
|
108
|
+
"""Set whether or not this socket is inheritable by subprocesses."""
|
|
109
|
+
os.set_inheritable(self._sock.fileno(), inheritable)
|
|
110
|
+
|
|
111
|
+
def fileno(self) -> int:
|
|
112
|
+
"""Get underlying socket file descriptor."""
|
|
113
|
+
return self._sock.fileno()
|
|
114
|
+
|
|
115
|
+
def close(self) -> None:
|
|
116
|
+
"""Close underlying socket."""
|
|
117
|
+
for obj in (self._rselector, self._wselector, self._sock):
|
|
118
|
+
if obj:
|
|
119
|
+
obj.close()
|
|
120
|
+
|
|
121
|
+
def _selector(self, *, write: bool = False) -> collections.abc.Callable[[float | None], list[tuple]]:
|
|
122
|
+
"""Get selector for socket."""
|
|
123
|
+
sel = self._wselector if write else self._rselector
|
|
124
|
+
if not sel:
|
|
125
|
+
sel = selectors.DefaultSelector()
|
|
126
|
+
if write:
|
|
127
|
+
sel.register(self._sock.fileno(), selectors.EVENT_WRITE)
|
|
128
|
+
self._wselector = sel
|
|
129
|
+
|
|
130
|
+
else:
|
|
131
|
+
sel.register(self._sock.fileno(), selectors.EVENT_READ)
|
|
132
|
+
self._rselector = sel
|
|
133
|
+
|
|
134
|
+
return sel.select
|
|
135
|
+
|
|
136
|
+
def _consume(self, processor: collections.abc.Iterable, deadline: float | None, *, write: bool = False) -> None:
|
|
137
|
+
"""Configure socket and consume processor."""
|
|
138
|
+
timeout = _utils.timeout_checker(deadline, 'timeout during transmission') if deadline else None
|
|
139
|
+
wait = self._sock.settimeout if timeout else None
|
|
140
|
+
if self._duplex or _utils.capture(ValueError, self._sock.settimeout, timeout() if timeout else None):
|
|
141
|
+
self._sock.settimeout(.0)
|
|
142
|
+
wait = self._selector(write=write)
|
|
143
|
+
|
|
144
|
+
if wait and timeout:
|
|
145
|
+
for _ in processor:
|
|
146
|
+
wait(timeout())
|
|
147
|
+
|
|
148
|
+
elif wait:
|
|
149
|
+
for _ in processor:
|
|
150
|
+
wait(None)
|
|
151
|
+
|
|
152
|
+
else:
|
|
153
|
+
for _ in processor:
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
async def _consume_async(self, processor: collections.abc.Iterable, *, write: bool = False) -> None:
|
|
157
|
+
"""Configure socket as non-blocking and consume processor."""
|
|
158
|
+
|
|
159
|
+
def notify() -> None:
|
|
160
|
+
"""Pass buffer to target and update future (and clean up) on success or error."""
|
|
161
|
+
try:
|
|
162
|
+
if next(iterator, True):
|
|
163
|
+
future.set_result(None)
|
|
164
|
+
remove_io(self)
|
|
165
|
+
|
|
166
|
+
except asyncio.InvalidStateError:
|
|
167
|
+
remove_io(self)
|
|
168
|
+
|
|
169
|
+
except Exception as e:
|
|
170
|
+
future.set_exception(e)
|
|
171
|
+
remove_io(self)
|
|
172
|
+
|
|
173
|
+
loop = asyncio.get_event_loop()
|
|
174
|
+
future = loop.create_future()
|
|
175
|
+
iterator = iter(processor)
|
|
176
|
+
add_io, remove_io = (
|
|
177
|
+
(loop.add_writer, loop.remove_writer) if write else
|
|
178
|
+
(loop.add_reader, loop.remove_reader)
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
self._sock.settimeout(.0)
|
|
182
|
+
add_io(self, notify)
|
|
183
|
+
try:
|
|
184
|
+
await future
|
|
185
|
+
|
|
186
|
+
finally:
|
|
187
|
+
remove_io(self)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class SocketReader(SocketWrapper):
|
|
191
|
+
"""Readable socket wrapper."""
|
|
192
|
+
|
|
193
|
+
_rlock: _utils.CrossLock
|
|
194
|
+
|
|
195
|
+
def __init__(self, sock: SocketOrDescriptor) -> None:
|
|
196
|
+
"""Initialize for socket."""
|
|
197
|
+
super().__init__(sock)
|
|
198
|
+
self._rlock = _utils.CrossLock()
|
|
199
|
+
|
|
200
|
+
def _recv(self, bufsize: RecvSize, timeout: float | None = None) -> bytearray:
|
|
201
|
+
"""Receive data from socket as bytearray."""
|
|
202
|
+
with _utils.lock_timeout(self._rlock, timeout=timeout) as deadline:
|
|
203
|
+
buffer = bytearray()
|
|
204
|
+
reader = _utils.reader(self._sock, buffer, bufsize)
|
|
205
|
+
self._consume(reader, deadline)
|
|
206
|
+
return buffer
|
|
207
|
+
|
|
208
|
+
async def _recv_async(self, bufsize: RecvSize) -> bytearray:
|
|
209
|
+
"""Receive data from socket (async) as bytearray."""
|
|
210
|
+
async with self._rlock:
|
|
211
|
+
buffer = bytearray()
|
|
212
|
+
reader = _utils.reader(self._sock, buffer, bufsize)
|
|
213
|
+
await self._consume_async(reader)
|
|
214
|
+
return buffer
|
|
215
|
+
|
|
216
|
+
def recv(self, bufsize: int, timeout: float | None = None) -> bytes:
|
|
217
|
+
"""Receive data from socket as bytes."""
|
|
218
|
+
return bytes(self._recv(bufsize, timeout))
|
|
219
|
+
|
|
220
|
+
async def recv_async(self, bufsize: int) -> bytes:
|
|
221
|
+
"""Receive data from socket (async) as bytes."""
|
|
222
|
+
return bytes(await self._recv_async(bufsize))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class SocketWriter(SocketWrapper):
|
|
226
|
+
"""Writable socket wrapper."""
|
|
227
|
+
|
|
228
|
+
_wlock: _utils.CrossLock
|
|
229
|
+
|
|
230
|
+
def __init__(self, sock: SocketOrDescriptor) -> None:
|
|
231
|
+
"""Initialize for socket."""
|
|
232
|
+
super().__init__(sock)
|
|
233
|
+
self._wlock = _utils.CrossLock()
|
|
234
|
+
|
|
235
|
+
def send(self, data: SendPayload, timeout: float | None = None) -> None:
|
|
236
|
+
"""Send data to socket."""
|
|
237
|
+
with _utils.lock_timeout(self._wlock, timeout=timeout) as deadline:
|
|
238
|
+
writer = _utils.writer(self._sock, data)
|
|
239
|
+
self._consume(writer, deadline, write=True)
|
|
240
|
+
|
|
241
|
+
async def send_async(self, data: SendPayload) -> None:
|
|
242
|
+
"""Send data to socket (async)."""
|
|
243
|
+
async with self._wlock:
|
|
244
|
+
writer = _utils.writer(self._sock, data)
|
|
245
|
+
await self._consume_async(writer, write=True)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class SocketDuplex(SocketWriter, SocketReader):
|
|
249
|
+
"""Duplex (both readable and writable) socket wrapper."""
|
|
250
|
+
|
|
251
|
+
_duplex: bool = True
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# TODO(py313+): use new syntax
|
|
255
|
+
R = typing.TypeVar('R')
|
|
256
|
+
W = typing.TypeVar('W')
|
|
257
|
+
KS = typing_extensions.TypeVar('KS', bound=SocketWrapper, default=SocketWrapper)
|
|
258
|
+
KSR = typing_extensions.TypeVar('KSR', bound=SocketReader, default=SocketReader)
|
|
259
|
+
KSW = typing_extensions.TypeVar('KSW', bound=SocketWriter, default=SocketWriter)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class MessageWrapper(_utils.ClosingContext, typing_extensions.Generic[R, W, KS]):
|
|
263
|
+
"""Base class for message socket wrappers supporting asynchronous operations."""
|
|
264
|
+
|
|
265
|
+
_wrapper: typing.ClassVar[type[SocketWrapper]] = SocketWrapper
|
|
266
|
+
_raw: KS
|
|
267
|
+
framing: MessageFraming[R, W]
|
|
268
|
+
|
|
269
|
+
def __init__(self, sock: KS | SocketOrDescriptor, framing: MessageFraming[R, W] = DEFAULT_FRAMING) -> None:
|
|
270
|
+
"""Initialize for socket."""
|
|
271
|
+
self._raw = sock if isinstance(sock, SocketWrapper) else self._wrapper(sock)
|
|
272
|
+
self.framing = framing
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def inheritable(self) -> bool:
|
|
276
|
+
"""Get whether or not this socket is inheritable by subprocesses."""
|
|
277
|
+
return self._raw.inheritable
|
|
278
|
+
|
|
279
|
+
@inheritable.setter
|
|
280
|
+
def inheritable(self, inheritable: bool) -> None:
|
|
281
|
+
"""Set whether or not this socket is inheritable by subprocesses."""
|
|
282
|
+
self._raw.inheritable = inheritable
|
|
283
|
+
|
|
284
|
+
def fileno(self) -> int:
|
|
285
|
+
"""Get underlying socket file descriptor."""
|
|
286
|
+
return self._raw.fileno()
|
|
287
|
+
|
|
288
|
+
def close(self) -> None:
|
|
289
|
+
"""Close underlying socket."""
|
|
290
|
+
self._raw.close()
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class MessageReader(MessageWrapper[R, typing.Any, KSR], typing_extensions.Generic[R, KSR]):
|
|
294
|
+
"""Readable message socket wrapper."""
|
|
295
|
+
|
|
296
|
+
_wrapper: typing.ClassVar = SocketReader
|
|
297
|
+
|
|
298
|
+
def recv(self, timeout: float | None = None) -> R:
|
|
299
|
+
"""Receive data from socket."""
|
|
300
|
+
return self.framing.loads(self._raw._recv(self.framing.frames, timeout=timeout))
|
|
301
|
+
|
|
302
|
+
async def recv_async(self) -> R:
|
|
303
|
+
"""Receive data from socket (async)."""
|
|
304
|
+
return self.framing.loads(await self._raw._recv_async(self.framing.frames))
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class MessageWriter(MessageWrapper[typing.Any, W, KSW], typing_extensions.Generic[W, KSW]):
|
|
308
|
+
"""Writable message socket wrapper."""
|
|
309
|
+
|
|
310
|
+
_wrapper: typing.ClassVar = SocketWriter
|
|
311
|
+
|
|
312
|
+
def send(self, data: W, timeout: float | None = None) -> None:
|
|
313
|
+
"""Send data to socket."""
|
|
314
|
+
self._raw.send(self.framing.dumps(data), timeout=timeout)
|
|
315
|
+
|
|
316
|
+
async def send_async(self, data: W) -> None:
|
|
317
|
+
"""Send data to socket (async)."""
|
|
318
|
+
await self._raw.send_async(self.framing.dumps(data))
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class MessageDuplex[R, W](MessageWriter[W, SocketDuplex], MessageReader[R, SocketDuplex]):
|
|
322
|
+
"""Duplex (both readable and writable) message socket wrapper."""
|
|
323
|
+
|
|
324
|
+
_wrapper: typing.ClassVar = SocketDuplex
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# TODO: move to namespace
|
|
328
|
+
_WrapperPair = _utils.ContextPair[SocketReader, SocketWriter]
|
|
329
|
+
_DuplexWrapperPair = _utils.ContextPair[SocketDuplex, SocketDuplex]
|
|
330
|
+
_DefaultMessagePair = _utils.ContextPair[MessageReader[bytes], MessageWriter[protocols.SizedBuffer]]
|
|
331
|
+
_DuplexDefaultMessagePair = _utils.ContextPair[
|
|
332
|
+
MessageDuplex[bytes, protocols.SizedBuffer],
|
|
333
|
+
MessageDuplex[bytes, protocols.SizedBuffer],
|
|
334
|
+
]
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@typing.overload
|
|
338
|
+
def socketpair(
|
|
339
|
+
family: int = ...,
|
|
340
|
+
type: typing.Literal[socket.SocketKind.SOCK_STREAM] = socket.SOCK_STREAM,
|
|
341
|
+
proto: int = ...,
|
|
342
|
+
framing: typing.Literal[False] = False,
|
|
343
|
+
) -> _DuplexWrapperPair: ...
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@typing.overload
|
|
347
|
+
def socketpair(
|
|
348
|
+
family: int = ...,
|
|
349
|
+
type: socket.SocketKind | int = socket.SOCK_STREAM,
|
|
350
|
+
proto: int = ...,
|
|
351
|
+
framing: typing.Literal[False] = False,
|
|
352
|
+
) -> _WrapperPair: ...
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
@typing.overload
|
|
356
|
+
def socketpair(
|
|
357
|
+
family: int = ...,
|
|
358
|
+
type: typing.Literal[socket.SocketKind.SOCK_STREAM] = socket.SOCK_STREAM,
|
|
359
|
+
proto: int = ...,
|
|
360
|
+
framing: typing.Literal[True] = ...,
|
|
361
|
+
) -> _DefaultMessagePair: ...
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@typing.overload
|
|
365
|
+
def socketpair(
|
|
366
|
+
family: int = ...,
|
|
367
|
+
type: socket.SocketKind | int = ...,
|
|
368
|
+
proto: int = ...,
|
|
369
|
+
framing: typing.Literal[True] = ...,
|
|
370
|
+
) -> _DuplexDefaultMessagePair: ...
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@typing.overload
|
|
374
|
+
def socketpair[R, W](
|
|
375
|
+
family: int = ...,
|
|
376
|
+
type: typing.Literal[socket.SocketKind.SOCK_STREAM] = socket.SOCK_STREAM,
|
|
377
|
+
proto: int = ...,
|
|
378
|
+
framing: MessageFraming[R, W] = ...,
|
|
379
|
+
) -> _utils.ContextPair[MessageDuplex[R, W], MessageDuplex[R, W]]: ...
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@typing.overload
|
|
383
|
+
def socketpair[R, W](
|
|
384
|
+
family: int = ...,
|
|
385
|
+
type: socket.SocketKind | int = ...,
|
|
386
|
+
proto: int = ...,
|
|
387
|
+
framing: MessageFraming[R, W] = ...,
|
|
388
|
+
) -> _utils.ContextPair[MessageReader[R], MessageWriter[W]]: ...
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def socketpair(
|
|
392
|
+
family=DEFAULT_SOCKETPAIR_FAMILY,
|
|
393
|
+
type=socket.SOCK_STREAM, # noqa: A002
|
|
394
|
+
proto=0,
|
|
395
|
+
framing=False,
|
|
396
|
+
):
|
|
397
|
+
"""Initialize connected socket pair as socket wrappers, or message wrappers if framing is enabled."""
|
|
398
|
+
sock_a, sock_b = socket.socketpair(family, type, proto)
|
|
399
|
+
return (
|
|
400
|
+
_utils.ContextPair(_duplex(sock_a, framing), _duplex(sock_b, framing)) if type == socket.SOCK_STREAM else
|
|
401
|
+
_utils.ContextPair(_reader(sock_a, framing), _writer(sock_b, framing))
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
@typing.overload
|
|
406
|
+
def pipe(framing: typing.Literal[False] = False) -> _WrapperPair: ...
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@typing.overload
|
|
410
|
+
def pipe(framing: typing.Literal[True]) -> _DefaultMessagePair: ...
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@typing.overload
|
|
414
|
+
def pipe[R, W](framing: MessageFraming[R, W]) -> _utils.ContextPair[MessageReader[R], MessageWriter[W]]: ...
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def pipe(framing=False):
|
|
418
|
+
"""Initialize pipe (readable, writable) as socket wrapper pair, or message wrappers pair if framing is enabled."""
|
|
419
|
+
read_fd, write_fd = os.pipe()
|
|
420
|
+
return _utils.ContextPair(_reader(read_fd, framing), _writer(write_fd, framing))
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _reader[R, W](sock: SocketOrDescriptor, framing: MessageFraming[R, W] | bool) -> SocketReader | MessageReader[R]:
|
|
424
|
+
"""Initialize bytes or message reader wrapper based on framing."""
|
|
425
|
+
return MessageReader(sock, fr) if (fr := _framing(framing)) else SocketReader(sock)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _writer[R, W](sock: SocketOrDescriptor, framing: MessageFraming[R, W] | bool) -> SocketWriter | MessageWriter[W]:
|
|
429
|
+
"""Initialize bytes or message writer wrapper based on framing."""
|
|
430
|
+
return MessageWriter(sock, fr) if (fr := _framing(framing)) else SocketWriter(sock)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _duplex[R, W](sock: SocketOrDescriptor, framing: MessageFraming[R, W] | bool) -> SocketDuplex | MessageDuplex[R, W]:
|
|
434
|
+
"""Initialize duplex bytes or message wrapper based on framing."""
|
|
435
|
+
return MessageDuplex(sock, fr) if (fr := _framing(framing)) else SocketDuplex(sock)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
# TODO(py313+): use new syntax
|
|
439
|
+
DR = typing_extensions.TypeVar('DR', default=bytes)
|
|
440
|
+
DW = typing_extensions.TypeVar('DW', default=protocols.SizedBuffer)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _framing(framing: MessageFraming[DR, DW] | bool) -> MessageFraming[DR, DW] | None:
|
|
444
|
+
"""Pick framing (or default framing) based on framing parameter."""
|
|
445
|
+
return DEFAULT_FRAMING if framing is True else framing if framing else None
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _socketlike(sock: SocketOrDescriptor) -> SocketLike:
|
|
449
|
+
"""Use or initialize socketlike, reporting if it's native or not."""
|
|
450
|
+
if isinstance(sock, SocketLike):
|
|
451
|
+
return sock # socket object, use straight
|
|
452
|
+
|
|
453
|
+
fd, dup = (sock, False) if isinstance(sock, int) else (sock.fileno(), True)
|
|
454
|
+
|
|
455
|
+
try:
|
|
456
|
+
maybe_socket = os.name == 'nt' or stat.S_ISSOCK(os.fstat(fd).st_mode)
|
|
457
|
+
|
|
458
|
+
except Exception:
|
|
459
|
+
maybe_socket = False
|
|
460
|
+
|
|
461
|
+
if maybe_socket:
|
|
462
|
+
sock = socket.socket(fileno=fd)
|
|
463
|
+
try:
|
|
464
|
+
sock.getsockopt(socket.SOL_SOCKET, socket.SO_TYPE)
|
|
465
|
+
if dup: # obj backed by socket, duplicate to not interfere with original ref lifetime
|
|
466
|
+
sock2 = sock.dup()
|
|
467
|
+
sock.detach()
|
|
468
|
+
return sock2
|
|
469
|
+
|
|
470
|
+
except OSError:
|
|
471
|
+
sock.detach()
|
|
472
|
+
|
|
473
|
+
else:
|
|
474
|
+
return sock # socket descriptor, use directly
|
|
475
|
+
|
|
476
|
+
return DescriptorSocket(fd) # assume pipe-like
|
socketwrapper/_utils.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Utility module."""
|
|
2
|
+
|
|
3
|
+
import abc
|
|
4
|
+
import asyncio
|
|
5
|
+
import collections.abc
|
|
6
|
+
import contextlib
|
|
7
|
+
import socket
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import types
|
|
11
|
+
import typing
|
|
12
|
+
import weakref
|
|
13
|
+
|
|
14
|
+
from . import protocols
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
FrameFunction = collections.abc.Callable[[bytearray], collections.abc.Iterable[int]]
|
|
18
|
+
RecvSize = FrameFunction | int
|
|
19
|
+
SendPayload = collections.abc.Iterable[protocols.SizedBuffer] | protocols.SizedBuffer
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ClosingContext(abc.ABC):
|
|
23
|
+
|
|
24
|
+
@abc.abstractmethod
|
|
25
|
+
def close(self) -> None:
|
|
26
|
+
"""Perform close operations (abstract)."""
|
|
27
|
+
|
|
28
|
+
def __enter__(self) -> typing.Self:
|
|
29
|
+
"""Get self class as context manager."""
|
|
30
|
+
return self
|
|
31
|
+
|
|
32
|
+
def __exit__(
|
|
33
|
+
self,
|
|
34
|
+
exc_type: type | None,
|
|
35
|
+
exc_value: BaseException | None,
|
|
36
|
+
exc_traceback: types.TracebackType | None,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Leave context manager closing the socket."""
|
|
39
|
+
self.close()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ContextPair[A: contextlib.AbstractContextManager, B: contextlib.AbstractContextManager](tuple[A, B]):
|
|
43
|
+
"""Tuple supporting context manager protocol."""
|
|
44
|
+
|
|
45
|
+
def __new__(cls, a: A, b: B) -> typing.Self:
|
|
46
|
+
"""Initialize with variadic arguments as items (instead of iterable)."""
|
|
47
|
+
return super().__new__(cls, (a, b))
|
|
48
|
+
|
|
49
|
+
def __enter__(self) -> typing.Self:
|
|
50
|
+
"""Enter context on all items and return tuple itself."""
|
|
51
|
+
errors = []
|
|
52
|
+
for item in self:
|
|
53
|
+
try:
|
|
54
|
+
item.__enter__()
|
|
55
|
+
|
|
56
|
+
except Exception as err:
|
|
57
|
+
errors.append(err)
|
|
58
|
+
|
|
59
|
+
if errors:
|
|
60
|
+
message = 'Context manager exceptions'
|
|
61
|
+
raise ExceptionGroup(message, errors)
|
|
62
|
+
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
def __exit__(
|
|
66
|
+
self,
|
|
67
|
+
exc_type: type | None,
|
|
68
|
+
exc_value: BaseException | None,
|
|
69
|
+
exc_traceback: types.TracebackType | None,
|
|
70
|
+
) -> bool | None:
|
|
71
|
+
"""Leave context on all items."""
|
|
72
|
+
booleans = 0
|
|
73
|
+
truthies = 0
|
|
74
|
+
errors = []
|
|
75
|
+
for item in self:
|
|
76
|
+
try:
|
|
77
|
+
res = item.__exit__(exc_type, exc_value, exc_traceback)
|
|
78
|
+
booleans += res is not None
|
|
79
|
+
truthies += bool(res)
|
|
80
|
+
|
|
81
|
+
except Exception as err:
|
|
82
|
+
errors.append(err)
|
|
83
|
+
|
|
84
|
+
if errors:
|
|
85
|
+
message = 'Context manager exceptions'
|
|
86
|
+
raise ExceptionGroup(message, errors)
|
|
87
|
+
|
|
88
|
+
return True if truthies else False if booleans else None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class CrossLock:
|
|
92
|
+
"""A higher level lock for both sync and async."""
|
|
93
|
+
|
|
94
|
+
__slots__: typing.ClassVar = '_aiolocks', '_aiowaits', '_busy', '_lock'
|
|
95
|
+
|
|
96
|
+
_aiolocks: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock]
|
|
97
|
+
_aiowaits: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Future]
|
|
98
|
+
_busy: threading.Lock
|
|
99
|
+
_lock: threading.Lock
|
|
100
|
+
|
|
101
|
+
def __init__(self) -> None:
|
|
102
|
+
"""Initialize lock state."""
|
|
103
|
+
self._lock = threading.Lock()
|
|
104
|
+
self._busy = threading.Lock()
|
|
105
|
+
self._aiolocks = weakref.WeakKeyDictionary()
|
|
106
|
+
self._aiowaits = weakref.WeakKeyDictionary()
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def locked(self) -> bool:
|
|
110
|
+
return self._lock.locked()
|
|
111
|
+
|
|
112
|
+
def acquire(self, *, timeout: float | None = None) -> bool:
|
|
113
|
+
return (
|
|
114
|
+
self._lock.acquire() if timeout in {None, -1} else
|
|
115
|
+
self._lock.acquire(timeout=timeout) if timeout else
|
|
116
|
+
self._lock.acquire(blocking=False)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def release(self) -> None:
|
|
120
|
+
with self._busy:
|
|
121
|
+
aiowaits = dict(self._aiowaits)
|
|
122
|
+
self._aiowaits.clear()
|
|
123
|
+
self._lock.release()
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
if future := aiowaits.pop(asyncio.get_running_loop(), None):
|
|
127
|
+
future.set_result(None)
|
|
128
|
+
|
|
129
|
+
except RuntimeError:
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
for loop, future in aiowaits.items():
|
|
133
|
+
loop.call_soon_threadsafe(future.set_result, None)
|
|
134
|
+
|
|
135
|
+
async def acquire_async(self) -> typing.Literal[True]:
|
|
136
|
+
loop = asyncio.get_running_loop()
|
|
137
|
+
aiolock = self._aiolocks.get(loop) or self._aiolocks.setdefault(loop, asyncio.Lock())
|
|
138
|
+
|
|
139
|
+
async with aiolock: # per-loop lock mitigating thundering herd
|
|
140
|
+
while True:
|
|
141
|
+
with self._busy:
|
|
142
|
+
if self._lock.acquire(blocking=False):
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
future = self._aiowaits.get(loop) or self._aiowaits.setdefault(loop, loop.create_future())
|
|
146
|
+
|
|
147
|
+
await asyncio.shield(future) # wait for release notification
|
|
148
|
+
|
|
149
|
+
def __await__(self) -> collections.abc.Generator[None, None, None]:
|
|
150
|
+
yield from self.acquire_async().__await__()
|
|
151
|
+
|
|
152
|
+
def __enter__(self) -> typing.Self:
|
|
153
|
+
self.acquire()
|
|
154
|
+
return self
|
|
155
|
+
|
|
156
|
+
def __exit__(
|
|
157
|
+
self,
|
|
158
|
+
exc_type: type | None,
|
|
159
|
+
exc_value: BaseException | None,
|
|
160
|
+
exc_traceback: types.TracebackType | None,
|
|
161
|
+
) -> None:
|
|
162
|
+
self.release()
|
|
163
|
+
|
|
164
|
+
async def __aenter__(self) -> typing.Self:
|
|
165
|
+
await self.acquire_async()
|
|
166
|
+
return self
|
|
167
|
+
|
|
168
|
+
async def __aexit__(
|
|
169
|
+
self,
|
|
170
|
+
exc_type: type | None,
|
|
171
|
+
exc_value: BaseException | None,
|
|
172
|
+
exc_traceback: types.TracebackType | None,
|
|
173
|
+
) -> None:
|
|
174
|
+
self.release()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def safe_read(sock: protocols.SocketLike, buffer: bytearray, size: int) -> int:
|
|
178
|
+
"""Read socket into buffer and return bytes read."""
|
|
179
|
+
try:
|
|
180
|
+
data = sock.recv(size)
|
|
181
|
+
|
|
182
|
+
except (TimeoutError, BlockingIOError):
|
|
183
|
+
return 0
|
|
184
|
+
|
|
185
|
+
buffer.extend(data)
|
|
186
|
+
return len(data)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def safe_write(sock: protocols.SocketLike, data: collections.abc.Buffer) -> int:
|
|
190
|
+
"""Write buffer data into socket and return bytes written, if possible."""
|
|
191
|
+
try:
|
|
192
|
+
return sock.send(data)
|
|
193
|
+
|
|
194
|
+
except BlockingIOError as e:
|
|
195
|
+
return getattr(e, 'characters_written', 0) # handle buffered-io-based socket-likes
|
|
196
|
+
|
|
197
|
+
except TimeoutError:
|
|
198
|
+
return 0
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def safe_timeout(sock: protocols.SocketLike, timeout: float | None) -> bool:
|
|
202
|
+
"""Set timeout and return True, or return False on ValueError."""
|
|
203
|
+
try:
|
|
204
|
+
sock.settimeout(timeout)
|
|
205
|
+
except ValueError:
|
|
206
|
+
return False
|
|
207
|
+
return True
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def capture(
|
|
211
|
+
exceptions: type[Exception] | tuple[type[Exception], ...],
|
|
212
|
+
func: collections.abc.Callable, *args, **kwargs,
|
|
213
|
+
) -> Exception | None:
|
|
214
|
+
"""Call function and return matching exceptions on raise."""
|
|
215
|
+
try:
|
|
216
|
+
func(*args, **kwargs)
|
|
217
|
+
except exceptions as e:
|
|
218
|
+
return e
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def reader(
|
|
223
|
+
sock: protocols.SocketLike,
|
|
224
|
+
buffer: bytearray,
|
|
225
|
+
size: RecvSize,
|
|
226
|
+
) -> collections.abc.Generator[None, None, None]:
|
|
227
|
+
"""Fully consume bytes from the socket."""
|
|
228
|
+
read = safe_read
|
|
229
|
+
sizes = (size,) if isinstance(size, int) else size(buffer)
|
|
230
|
+
for sz in sizes:
|
|
231
|
+
if sz:
|
|
232
|
+
while sz := (sz - read(sock, buffer, sz)):
|
|
233
|
+
yield
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def writer(sock: protocols.SocketLike, data: SendPayload) -> collections.abc.Generator[None, None, None]:
|
|
237
|
+
"""Fully write iterable of buffer into socket as an iterator."""
|
|
238
|
+
write = safe_write
|
|
239
|
+
chunks = (data,) if isinstance(data, protocols.SizedBuffer) else data
|
|
240
|
+
for chunk in chunks:
|
|
241
|
+
if chunk:
|
|
242
|
+
offset = 0
|
|
243
|
+
while not (offset := write(sock, chunk)): # write first chunk without slicing
|
|
244
|
+
yield
|
|
245
|
+
|
|
246
|
+
if offset >= len(chunk): # finished
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
yield
|
|
250
|
+
buffer = memoryview(chunk)[offset:] # turn slicing into zero-copy
|
|
251
|
+
while not (offset := write(sock, buffer)) or (buffer := buffer[offset:]):
|
|
252
|
+
yield
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@contextlib.contextmanager
|
|
256
|
+
def lock_timeout(lock: CrossLock, timeout: float | None = None) -> collections.abc.Generator[float | None, None, None]:
|
|
257
|
+
"""Acquire lock and calculate deadline for timeout."""
|
|
258
|
+
timeout = socket.getdefaulttimeout() if timeout is None else timeout
|
|
259
|
+
deadline = time.monotonic() + timeout if timeout else None
|
|
260
|
+
if not lock.acquire(timeout=timeout):
|
|
261
|
+
msg = 'timeout waiting for a concurrent operation'
|
|
262
|
+
raise TimeoutError(msg)
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
yield deadline
|
|
266
|
+
|
|
267
|
+
finally:
|
|
268
|
+
lock.release()
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def timeout_checker(deadline: float, error: str | None) -> collections.abc.Callable[[], float]:
|
|
272
|
+
"""Get function returning seconds left before monotonic timestamp, optionally raising on timeout."""
|
|
273
|
+
|
|
274
|
+
def func() -> float:
|
|
275
|
+
if (seconds := deadline - time.monotonic()) > 0:
|
|
276
|
+
return seconds
|
|
277
|
+
|
|
278
|
+
if error:
|
|
279
|
+
raise TimeoutError(error)
|
|
280
|
+
|
|
281
|
+
return 0
|
|
282
|
+
|
|
283
|
+
return func
|
socketwrapper/_varint.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
r"""SQLite4 varint tools.
|
|
2
|
+
|
|
3
|
+
Implementation of SQLite4 Variable-Length Integers
|
|
4
|
+
==================================================
|
|
5
|
+
|
|
6
|
+
Based on spec at https://sqlite.org/src4/doc/trunk/www/varint.wiki
|
|
7
|
+
|
|
8
|
+
Examples
|
|
9
|
+
--------
|
|
10
|
+
>>> tests = {
|
|
11
|
+
... b'\x00': 0,
|
|
12
|
+
... b'\xf0': 240,
|
|
13
|
+
... b'\xf8\xff': 2287,
|
|
14
|
+
... b'\xf9\xff\xff': 67823,
|
|
15
|
+
... b'\xfa\xff\xff\xff': 2**24 - 1,
|
|
16
|
+
... b'\xfb\xff\xff\xff\xff': 2**32 - 1,
|
|
17
|
+
... b'\xfc\xff\xff\xff\xff\xff': 2**40 - 1,
|
|
18
|
+
... b'\xfd\xff\xff\xff\xff\xff\xff': 2**48 - 1,
|
|
19
|
+
... b'\xfe\xff\xff\xff\xff\xff\xff\xff': 2**56 - 1,
|
|
20
|
+
... b'\xff\xff\xff\xff\xff\xff\xff\xff\xff': 2**64 - 1,
|
|
21
|
+
... }
|
|
22
|
+
|
|
23
|
+
>>> [peek(i[:1]) for i in tests] == [len(i) for i in tests]
|
|
24
|
+
True
|
|
25
|
+
|
|
26
|
+
>>> [dumps(i) for i in tests.values()] == list(tests)
|
|
27
|
+
True
|
|
28
|
+
|
|
29
|
+
>>> [loads(i) for i in tests] == list(tests.values())
|
|
30
|
+
True
|
|
31
|
+
|
|
32
|
+
>>> dumps(2**64)
|
|
33
|
+
Traceback (most recent call last):
|
|
34
|
+
...
|
|
35
|
+
OverflowError: int too big to convert
|
|
36
|
+
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
import collections.abc
|
|
40
|
+
import typing
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class VarIntBytes(typing.Protocol):
|
|
44
|
+
@typing.overload
|
|
45
|
+
def __getitem__(self, key: typing.SupportsIndex, /) -> int: ...
|
|
46
|
+
|
|
47
|
+
@typing.overload
|
|
48
|
+
def __getitem__(self, key: slice, /) -> (
|
|
49
|
+
collections.abc.Iterable[typing.SupportsIndex]
|
|
50
|
+
| typing.SupportsBytes
|
|
51
|
+
| collections.abc.Buffer
|
|
52
|
+
): ...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def peek(data: VarIntBytes | int) -> int:
|
|
56
|
+
"""Get total varint bytesize from its first byte."""
|
|
57
|
+
start = data if isinstance(data, int) else data[0]
|
|
58
|
+
return (
|
|
59
|
+
1 if start < 0xF1 else
|
|
60
|
+
2 if start < 0xF9 else
|
|
61
|
+
3 if start < 0xFA else
|
|
62
|
+
start - 0xF6
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def dumps(value: int) -> bytes:
|
|
67
|
+
"""Encode given unsigned integer as varint bytes."""
|
|
68
|
+
return (
|
|
69
|
+
value.to_bytes() if value < 0xF1 else
|
|
70
|
+
(0xF010 + value).to_bytes(2) if value < 0x8F0 else
|
|
71
|
+
(0xF8F710 + value).to_bytes(3) if value < 0x108F0 else
|
|
72
|
+
(0xFA000000 | value).to_bytes(4) if value < 0x1000000 else
|
|
73
|
+
(0xFB00000000 | value).to_bytes(5) if value < 0x100000000 else
|
|
74
|
+
(0xFC0000000000 | value).to_bytes(6) if value < 0x10000000000 else
|
|
75
|
+
(0xFD000000000000 | value).to_bytes(7) if value < 0x1000000000000 else
|
|
76
|
+
(0xFE00000000000000 | value).to_bytes(8) if value < 0x100000000000000 else
|
|
77
|
+
(0xFF0000000000000000 + value).to_bytes(9)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def loads(data: VarIntBytes) -> int:
|
|
82
|
+
"""Decode given varint bytes as integer."""
|
|
83
|
+
start = data[0]
|
|
84
|
+
return (
|
|
85
|
+
start if start < 0xF1 else
|
|
86
|
+
int.from_bytes(data[:2]) - 0xF010 if start < 0xF9 else
|
|
87
|
+
int.from_bytes(data[:3]) - 0xF8F710 if start < 0xFA else
|
|
88
|
+
int.from_bytes(data[1:start - 0xF6])
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == '__main__':
|
|
93
|
+
import doctest
|
|
94
|
+
doctest.testmod(verbose=True, raise_on_error=True, exclude_empty=True, optionflags=doctest.ELLIPSIS)
|
socketwrapper/framing.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Standard message framing implementations for socketwrapper."""
|
|
2
|
+
|
|
3
|
+
import abc
|
|
4
|
+
import collections.abc
|
|
5
|
+
import io
|
|
6
|
+
import multiprocessing.connection as mp_conn
|
|
7
|
+
import struct
|
|
8
|
+
|
|
9
|
+
from . import _varint, protocols
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MessageFramingBase[R, W](abc.ABC):
|
|
13
|
+
"""Base abstract class for message framing implementations."""
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
@abc.abstractmethod
|
|
17
|
+
def frames(cls, buffer: bytearray) -> collections.abc.Iterable[int]:
|
|
18
|
+
"""Get iterator with required read sizes."""
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
@abc.abstractmethod
|
|
22
|
+
def loads(cls, buffer: bytearray) -> R:
|
|
23
|
+
"""Get frame payload from full buffer."""
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
@abc.abstractmethod
|
|
27
|
+
def dumps(cls, data: W) -> collections.abc.Iterable[collections.abc.Buffer]:
|
|
28
|
+
"""Get iterable with frame parts (usually header and payload)."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class VarIntBytes(MessageFramingBase):
|
|
32
|
+
"""VarInt-headered bytes message frames.
|
|
33
|
+
|
|
34
|
+
This is the optimum possible framing for bytes, using SQLite4
|
|
35
|
+
variable-length integers for the size header:
|
|
36
|
+
- Header size is variable length from 1 to 9 bytes.
|
|
37
|
+
- Maximum message length of 2**64 - 1.
|
|
38
|
+
- 2 or 3 reads in total.
|
|
39
|
+
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def frames(cls, buffer: bytearray) -> collections.abc.Generator[int, None, None]:
|
|
44
|
+
"""Iterate read size requests based on buffer contents."""
|
|
45
|
+
yield 1
|
|
46
|
+
yield _varint.peek(buffer) - 1
|
|
47
|
+
yield _varint.loads(buffer)
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def loads(cls, buffer: bytearray) -> bytes:
|
|
51
|
+
"""Get frame payload from full buffer."""
|
|
52
|
+
return bytes(buffer[_varint.peek(buffer):])
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def dumps[T: protocols.SizedBuffer](cls, data: T) -> tuple[bytes, T]:
|
|
56
|
+
"""Get tuple with header and payload."""
|
|
57
|
+
return _varint.dumps(len(data)), data
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class MultiprocessingFramingBase(MessageFramingBase):
|
|
61
|
+
"""Base partially-abstract class for multiprocessing connection message frames.
|
|
62
|
+
|
|
63
|
+
This framing implementation uses multiprocessing connection bytes messages:
|
|
64
|
+
- Header of 4 or 12 bytes.
|
|
65
|
+
- Maximum message length of 2**64 - 1.
|
|
66
|
+
- 2 or 3 reads in total.
|
|
67
|
+
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
class ReadableConnection(mp_conn.Connection):
|
|
71
|
+
"""Multiprocessing connection with buffered IO."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, buffer: collections.abc.Buffer = b'') -> None:
|
|
74
|
+
"""Initialize as readable with data."""
|
|
75
|
+
super().__init__(0, readable=True)
|
|
76
|
+
self.buffer = memoryview(buffer)
|
|
77
|
+
|
|
78
|
+
def _recv(self, size: int) -> io.BytesIO:
|
|
79
|
+
"""Read data from buffer."""
|
|
80
|
+
data, self.buffer = self.buffer[:size], self.buffer[size:]
|
|
81
|
+
return io.BytesIO(data)
|
|
82
|
+
|
|
83
|
+
def _close(self) -> None:
|
|
84
|
+
"""Flag as closed."""
|
|
85
|
+
self._handle = None
|
|
86
|
+
|
|
87
|
+
class WritableConnection(mp_conn.Connection):
|
|
88
|
+
"""Multiprocessing connection with buffered IO."""
|
|
89
|
+
|
|
90
|
+
def __init__(self) -> None:
|
|
91
|
+
"""Initialize as writable."""
|
|
92
|
+
super().__init__(0, writable=True)
|
|
93
|
+
self.chunks = []
|
|
94
|
+
|
|
95
|
+
def _send(self, buf: bytes) -> None:
|
|
96
|
+
"""Record data chunk."""
|
|
97
|
+
self.chunks.append(buf)
|
|
98
|
+
|
|
99
|
+
def _close(self) -> None:
|
|
100
|
+
"""Flag as closed."""
|
|
101
|
+
self._handle = None
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def frames(cls, buffer: bytearray) -> collections.abc.Generator[int, None, None]:
|
|
105
|
+
"""Iterate read size requests based on buffer contents."""
|
|
106
|
+
yield 4
|
|
107
|
+
size, = struct.unpack('!i', buffer)
|
|
108
|
+
if size == -1:
|
|
109
|
+
yield 8
|
|
110
|
+
size, = struct.unpack('!Q', buffer[4:])
|
|
111
|
+
yield size
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class MultiprocessingBytes(MultiprocessingFramingBase):
|
|
115
|
+
"""Multiprocessing connection bytes message frames."""
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def loads(cls, buffer: bytearray) -> bytes:
|
|
119
|
+
"""Get frame payload from full buffer."""
|
|
120
|
+
return cls.ReadableConnection(buffer).recv_bytes()
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def dumps(cls, data: collections.abc.Buffer) -> list[bytes]:
|
|
124
|
+
"""Get tuple with message data."""
|
|
125
|
+
c = cls.WritableConnection()
|
|
126
|
+
c.send_bytes(data)
|
|
127
|
+
return c.chunks
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class Multiprocessing[T](MultiprocessingFramingBase):
|
|
131
|
+
"""Multiprocessing connection pickled message frames."""
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def loads(cls, buffer: bytearray) -> T:
|
|
135
|
+
"""Get unserialized frame payload from full buffer."""
|
|
136
|
+
return cls.ReadableConnection(buffer).recv()
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def dumps(cls, data: T) -> list[bytes]:
|
|
140
|
+
"""Get tuple with serialized message data."""
|
|
141
|
+
c = cls.WritableConnection()
|
|
142
|
+
c.send(data)
|
|
143
|
+
return c.chunks
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Standard python protocols used by socketwrapper."""
|
|
2
|
+
|
|
3
|
+
import collections.abc
|
|
4
|
+
import typing
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@typing.runtime_checkable
|
|
8
|
+
class SizedBuffer(typing.Protocol):
|
|
9
|
+
"""Protocol for buffers with size."""
|
|
10
|
+
|
|
11
|
+
def __buffer__(self, flags: int, /) -> memoryview: ...
|
|
12
|
+
def __len__(self) -> int: ...
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@typing.runtime_checkable
|
|
16
|
+
class SocketLike(typing.Protocol):
|
|
17
|
+
"""Protocol for socket-like objects accepted by socketwrapper socket classes."""
|
|
18
|
+
|
|
19
|
+
def fileno(self) -> int: ...
|
|
20
|
+
def send(self, data: collections.abc.Buffer, /) -> int: ...
|
|
21
|
+
def recv(self, bufsize: int, /) -> bytes: ...
|
|
22
|
+
def settimeout(self, timeout: float | None, /) -> None: ...
|
|
23
|
+
def close(self) -> None: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@typing.runtime_checkable
|
|
27
|
+
class HasFileno(typing.Protocol):
|
|
28
|
+
"""Protocol for objects backed by a file descriptor."""
|
|
29
|
+
|
|
30
|
+
def fileno(self) -> int: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@typing.runtime_checkable
|
|
34
|
+
class MessageFraming[R, W](typing.Protocol):
|
|
35
|
+
"""Protocol for message framing implementations accepted by socketwrapper message classes."""
|
|
36
|
+
|
|
37
|
+
def frames(self, buffer: bytearray, /) -> collections.abc.Iterable[int]: ...
|
|
38
|
+
def loads(self, buffer: bytearray, /) -> R: ...
|
|
39
|
+
def dumps(self, data: W, /) -> collections.abc.Iterable[SizedBuffer]: ...
|
socketwrapper/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: socketwrapper
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: high level socket and pipe wrapper
|
|
5
|
+
Author: Felipe A Hernandez
|
|
6
|
+
Author-email: ergoithz@gmail.com
|
|
7
|
+
License: MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2025 Felipe A Hernandez <ergoithz@gmail.com>
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
28
|
+
|
|
29
|
+
Project-URL: homepage, https://gitlab.com/ergoithz/socketwrapper
|
|
30
|
+
Project-URL: issue-tracker, https://gitlab.com/ergoithz/socketwrapper/-/issues
|
|
31
|
+
Project-URL: release-notes, https://gitlab.com/ergoithz/socketwrapper/-/releases
|
|
32
|
+
Project-URL: issue-new, https://gitlab.com/ergoithz/socketwrapper/-/issues/new
|
|
33
|
+
Project-URL: donations, https://ko-fi.com/s26me
|
|
34
|
+
Keywords: socket,pipe,ipc,asyncio
|
|
35
|
+
Classifier: Framework :: AsyncIO
|
|
36
|
+
Classifier: Intended Audience :: Developers
|
|
37
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
39
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
40
|
+
Classifier: Topic :: System :: Networking
|
|
41
|
+
Requires-Python: >=3.12
|
|
42
|
+
Description-Content-Type: text/markdown
|
|
43
|
+
License-File: LICENSE
|
|
44
|
+
Requires-Dist: typing-extensions>=4.15; python_version < "3.13"
|
|
45
|
+
Provides-Extra: dev
|
|
46
|
+
Requires-Dist: coverage; extra == "dev"
|
|
47
|
+
Requires-Dist: ruff; extra == "dev"
|
|
48
|
+
Requires-Dist: wheel; extra == "dev"
|
|
49
|
+
Requires-Dist: yapf; extra == "dev"
|
|
50
|
+
Dynamic: license-file
|
|
51
|
+
|
|
52
|
+
# socketwrapper
|
|
53
|
+
|
|
54
|
+
This package provides high level wrappers for sockets and pipes:
|
|
55
|
+
- Thread-safe within both and between threads and asyncio realms.
|
|
56
|
+
- Managed sync recv and send operations with timeouts.
|
|
57
|
+
- Native asyncio recv_async and send_async operations.
|
|
58
|
+
- Pluggable message protocol (headered variable length data) supporting
|
|
59
|
+
header parsing, serialization and deserialization.
|
|
60
|
+
- Pluggable I/O protocol (file descriptor still required due asyncio).
|
|
61
|
+
|
|
62
|
+
## Motivation
|
|
63
|
+
|
|
64
|
+
There aren't a ton of high level asyncio socket wrappers out there providing
|
|
65
|
+
all we need for IPC: header/payload logic and support for both sockets and pipes.
|
|
66
|
+
|
|
67
|
+
Most implementations either don't have pluggable messaging protocols or it
|
|
68
|
+
doesn't support variable-size headers.
|
|
69
|
+
|
|
70
|
+
No other implementation is thread-safe across both asyncio and threading realms.
|
|
71
|
+
|
|
72
|
+
No other implementation directly supports wrapping multiprocessing Connections.
|
|
73
|
+
|
|
74
|
+
## Installation
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
uv pip install socketwrapper
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Documentation
|
|
81
|
+
|
|
82
|
+
None other than this README, life's too short and I'm too busy with real life,
|
|
83
|
+
if you need better documentation consider donating to
|
|
84
|
+
[my ko-fi](https://ko-fi.com/s26me) stating that as the tip message,
|
|
85
|
+
check out how my docs look like at [mstache docs](https://mstache.readthedocs.io/en/latest/)
|
|
86
|
+
and [uactor docs](https://mstache.readthedocs.io/en/latest/).
|
|
87
|
+
|
|
88
|
+
### Puggable I/O: SocketLike protocol
|
|
89
|
+
|
|
90
|
+
The `socketwrapper.SocketLike` protocol, an small subset the socket interface,
|
|
91
|
+
is all what's required for any object to be wrap-able by `socketwrapper`.
|
|
92
|
+
|
|
93
|
+
```py
|
|
94
|
+
@typing.runtime_checkable
|
|
95
|
+
class SocketLike(typing.Protocol):
|
|
96
|
+
"""Protocol for socket-like objects accepted by socketwrapper socket classes."""
|
|
97
|
+
|
|
98
|
+
def fileno(self) -> int: ...
|
|
99
|
+
def send(self, data: collections.abc.Buffer, /) -> int: ...
|
|
100
|
+
def recv(self, bufsize: int, /) -> bytes: ...
|
|
101
|
+
def settimeout(self, timeout: float | None, /) -> None: ...
|
|
102
|
+
def close(self) -> None: ...
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Special attention to:
|
|
106
|
+
- [fileno](https://docs.python.org/3.14/library/socket.html#socket.socket.fileno)
|
|
107
|
+
has to be a valid OS file descriptor.
|
|
108
|
+
- [settimeout](https://docs.python.org/3.14/library/socket.html#socket.socket.settimeout)
|
|
109
|
+
only requires support for `settimeout(.0)` (
|
|
110
|
+
[non-blocking semantics](https://docs.python.org/3.14/library/socket.html#notes-on-socket-timeouts)
|
|
111
|
+
), raising [ValueError](https://docs.python.org/3.14/library/exceptions.html#ValueError)
|
|
112
|
+
for any other value is fully supported in which case
|
|
113
|
+
[selectors.DefaultSelector](https://docs.python.org/3.14/library/selectors.html#selectors.DefaultSelector)
|
|
114
|
+
will be used for synchronous operations.
|
|
115
|
+
|
|
116
|
+
## Usage
|
|
117
|
+
|
|
118
|
+
### Simple IPC with pipe
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
import os
|
|
122
|
+
import socketwrapper
|
|
123
|
+
|
|
124
|
+
with socketwrapper.pipe(framing=True) as (parent_reader, child_writer):
|
|
125
|
+
child_writer.inheritable = True
|
|
126
|
+
child_pid = os.fork() # replace with your own process fork/spawn logic
|
|
127
|
+
child_writer.inheritable = False # important, prevent socket leaks!
|
|
128
|
+
|
|
129
|
+
if child_pid:
|
|
130
|
+
print(f'Message {parent_reader.recv()!r} received')
|
|
131
|
+
else:
|
|
132
|
+
child_writer.send(b'Hello world!')
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
Message b'Hello world!' received
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Simple asyncio IPC with pipe
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
import asyncio
|
|
143
|
+
import os
|
|
144
|
+
import socketwrapper
|
|
145
|
+
|
|
146
|
+
async def parent(readable: socketwrapper.MessageReader) -> None:
|
|
147
|
+
print(f'Message {await readable.recv_async()!r} received')
|
|
148
|
+
|
|
149
|
+
async def child(writable: socketwrapper.MessageWriter) -> None:
|
|
150
|
+
await writable.send_async(b'Hello world!')
|
|
151
|
+
|
|
152
|
+
with socketwrapper.pipe(framing=True) as (parent_reader, child_writer):
|
|
153
|
+
child_writer.inheritable = True
|
|
154
|
+
child_pid = os.fork() # replace with your own process fork/spawn logic
|
|
155
|
+
child_writer.inheritable = False # important, prevent socket leaks!
|
|
156
|
+
|
|
157
|
+
asyncio.run(parent(parent_reader) if child_pid else child(child_writer))
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
Message b'Hello world!' received
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Simple bidirectional IPC with socketpair
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
import os
|
|
168
|
+
import socketwrapper
|
|
169
|
+
|
|
170
|
+
with socketwrapper.socketpair(framing=True) as (parent_duplex, child_duplex):
|
|
171
|
+
child_duplex.inheritable = True
|
|
172
|
+
child_pid = os.fork() # replace with your own process fork/spawn logic
|
|
173
|
+
child_duplex.inheritable = False # important, prevent socket leaks!
|
|
174
|
+
|
|
175
|
+
if child_pid:
|
|
176
|
+
parent_duplex.send(b'Hello child!')
|
|
177
|
+
print(f'Message {parent_duplex.recv()!r} received in parent')
|
|
178
|
+
|
|
179
|
+
else:
|
|
180
|
+
print(f'Message {child_duplex.recv()!r} received in child')
|
|
181
|
+
child_duplex.send(b'Hello parent!')
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
Message b'Hello child!' received in child
|
|
186
|
+
Message b'Hello parent!' received in parent
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Socketwrapper with multiprocessing.Pipe and asyncio
|
|
190
|
+
|
|
191
|
+
```py
|
|
192
|
+
import asyncio
|
|
193
|
+
import multiprocessing
|
|
194
|
+
import multiprocessing.connection
|
|
195
|
+
import socketwrapper
|
|
196
|
+
import socketwrapper.framing
|
|
197
|
+
|
|
198
|
+
def child(conn: multiprocessing.connection.Connection) -> None:
|
|
199
|
+
|
|
200
|
+
async def main() -> None:
|
|
201
|
+
with socketwrapper.MessageDuplex(conn, framing=socketwrapper.framing.MultiprocessingBytes) as child_duplex:
|
|
202
|
+
print(f'Message {await child_duplex.recv_async()!r} received in child')
|
|
203
|
+
await child_duplex.send_async(b'Hello parent!')
|
|
204
|
+
|
|
205
|
+
asyncio.run(main())
|
|
206
|
+
|
|
207
|
+
parent_conn, child_conn = multiprocessing.Pipe()
|
|
208
|
+
with parent_conn, child_conn:
|
|
209
|
+
child_process = multiprocessing.Process(target=child, args=(child_conn,))
|
|
210
|
+
child_process.start()
|
|
211
|
+
|
|
212
|
+
parent_conn.send_bytes(b'Hello child!')
|
|
213
|
+
print(f'Message {parent_conn.recv_bytes()!r} received in parent')
|
|
214
|
+
child_process.join(1)
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
```
|
|
218
|
+
Message b'Hello child!' received in child
|
|
219
|
+
Message b'Hello parent!' received in parent
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Custom socketwrapper framing with progress
|
|
223
|
+
|
|
224
|
+
```py
|
|
225
|
+
import collections.abc
|
|
226
|
+
import itertools
|
|
227
|
+
import os
|
|
228
|
+
import socketwrapper
|
|
229
|
+
import socketwrapper.framing
|
|
230
|
+
|
|
231
|
+
def progress(arrow: str, size: int, min_chunk: int = 1024) -> collections.abc.Generator[int, None, None]:
|
|
232
|
+
part_size = max(min_chunk, size // 100)
|
|
233
|
+
full_parts, last_size = divmod(size, part_size)
|
|
234
|
+
percent = 100 / (full_parts + 1 if last_size else full_parts)
|
|
235
|
+
|
|
236
|
+
for i in range(full_parts):
|
|
237
|
+
print(f'{arrow} {i * percent:6.2f}%')
|
|
238
|
+
yield part_size
|
|
239
|
+
|
|
240
|
+
if last_size:
|
|
241
|
+
print(f'{arrow} {full_parts * percent:6.2f}%')
|
|
242
|
+
yield last_size
|
|
243
|
+
|
|
244
|
+
print(f'{arrow} 100%')
|
|
245
|
+
|
|
246
|
+
class ProgressFraming(socketwrapper.framing.VarIntBytes):
|
|
247
|
+
|
|
248
|
+
@classmethod
|
|
249
|
+
def frames(cls, buffer: bytearray) -> collections.abc.Generator[int, None, None]:
|
|
250
|
+
frames = super().frames(buffer)
|
|
251
|
+
yield from itertools.islice(frames, 2)
|
|
252
|
+
yield from progress('>', next(frames))
|
|
253
|
+
|
|
254
|
+
@classmethod
|
|
255
|
+
def dumps(cls, data: bytes) -> collections.abc.Generator[memoryview, None, None]:
|
|
256
|
+
buffer = memoryview(b''.join(super().dumps(data)))
|
|
257
|
+
for size in progress('<', len(buffer)):
|
|
258
|
+
chunk, buffer = buffer[:size], buffer[size:]
|
|
259
|
+
yield chunk
|
|
260
|
+
|
|
261
|
+
with socketwrapper.socketpair(framing=ProgressFraming) as (parent_duplex, child_duplex):
|
|
262
|
+
child_duplex.inheritable = True
|
|
263
|
+
child_pid = os.fork() # replace with your own multiprocessing fork logic
|
|
264
|
+
child_duplex.inheritable = False # important, prevent socket leaks!
|
|
265
|
+
|
|
266
|
+
if child_pid:
|
|
267
|
+
payload = os.urandom(1024) * 1024
|
|
268
|
+
print(f'Sending {len(payload)} bytes!')
|
|
269
|
+
parent_duplex.send(payload)
|
|
270
|
+
else:
|
|
271
|
+
print(f'Received {len(child_duplex.recv())} bytes!')
|
|
272
|
+
```
|
|
273
|
+
```
|
|
274
|
+
Sending 1048576 bytes!
|
|
275
|
+
< 0.00%
|
|
276
|
+
< 0.99%
|
|
277
|
+
< 1.98%
|
|
278
|
+
< 2.97%
|
|
279
|
+
...
|
|
280
|
+
> 0.99%
|
|
281
|
+
< 13.86%
|
|
282
|
+
> 1.98%
|
|
283
|
+
< 14.85%
|
|
284
|
+
...
|
|
285
|
+
> 91.09%
|
|
286
|
+
< 99.01%
|
|
287
|
+
> 92.08%
|
|
288
|
+
< 100%
|
|
289
|
+
...
|
|
290
|
+
> 98.02%
|
|
291
|
+
> 99.01%
|
|
292
|
+
> 100%
|
|
293
|
+
Received 1048576 bytes!
|
|
294
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
socketwrapper/__init__.py,sha256=XZxrB-6eF3Ntn0DDsgE0gRM0cvE7i1aNMo3X_SnzDAg,15463
|
|
2
|
+
socketwrapper/_utils.py,sha256=1iTOnnII0McX4p_ikjQ8INdz-DDigSR6hMrQ2He7eKw,8568
|
|
3
|
+
socketwrapper/_varint.py,sha256=VPDqH8vS4Oixutk8ZWsmqBMDajjODrZ-7xM9Vm3-u1E,2786
|
|
4
|
+
socketwrapper/framing.py,sha256=YL8QHR9LbuCOC4xJ1wH0mrK5pFmiJn7jec8U0PDjtCU,4563
|
|
5
|
+
socketwrapper/protocols.py,sha256=xpGNqxRXiSy0LiGZ9WamZsJy2w6wmvv-Kwy9oCHyTyQ,1225
|
|
6
|
+
socketwrapper/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
socketwrapper-0.0.1.dist-info/licenses/LICENSE,sha256=tIR671OctNcul-PHVKPNff7pMLcvO5z1AFeDNH9nb-0,1096
|
|
8
|
+
socketwrapper-0.0.1.dist-info/METADATA,sha256=vJoJobF2fogbgqUQb1zyjDiaA50zijKKWtl_AtYWiDs,9932
|
|
9
|
+
socketwrapper-0.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
10
|
+
socketwrapper-0.0.1.dist-info/top_level.txt,sha256=-NM1Y47ygwdVOVqMF16S23zt2P1FluEV-3z4Jzmavvo,14
|
|
11
|
+
socketwrapper-0.0.1.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
12
|
+
socketwrapper-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Felipe A Hernandez <ergoithz@gmail.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
socketwrapper
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|