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.
@@ -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
@@ -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
@@ -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)
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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
+