socketwrapper 0.0.2__tar.gz → 0.0.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: socketwrapper
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Summary: high level socket and pipe wrappers
5
5
  Author: Felipe A Hernandez
6
6
  Author-email: ergoithz@gmail.com
@@ -27,6 +27,7 @@ License: MIT License
27
27
  SOFTWARE.
28
28
 
29
29
  Project-URL: homepage, https://gitlab.com/ergoithz/socketwrapper
30
+ Project-URL: repository, https://gitlab.com/ergoithz/socketwrapper
30
31
  Project-URL: issue-tracker, https://gitlab.com/ergoithz/socketwrapper/-/issues
31
32
  Project-URL: release-notes, https://gitlab.com/ergoithz/socketwrapper/-/releases
32
33
  Project-URL: issue-new, https://gitlab.com/ergoithz/socketwrapper/-/issues/new
@@ -89,12 +90,17 @@ uv pip install 'socketwrapper[msgpack]'
89
90
 
90
91
  ## Changelog
91
92
 
92
- ### v0.0.2 - 2025.11.05
93
+ ### 0.0.3 - 2025.11.08
94
+
95
+ - New: optional support for `recv_into` in socket-like objects.
96
+ - Optimization: recv operations will now use `sock.recv_into` (if available) to reduce memory allocation overhead.
97
+
98
+ ### 0.0.2 - 2025.11.05
93
99
 
94
100
  - Breaking: `MessageFraming.frames` and `MessageFraming.loads` now receive `io.BytesIO` instead of `bytearray`.
95
101
  - New: optional msgpack support (`msgpack` extra).
96
102
  - Fix: busy reads no longer blocking asyncio event loop.
97
- - Typing: expose `SizedBuffer` and deprecate `SendPayload`.
103
+ - Typing: expose `SizedBuffer` and deprecate `SendPayload` (removal expected in `0.1.0`).
98
104
  - Typing: `SocketWriter.send` and `SocketWriter.send_async` data is now `SizedBuffer`.
99
105
 
100
106
  ## Documentation
@@ -103,7 +109,9 @@ None other than this README, life's too short and I'm too busy with real life, i
103
109
 
104
110
  ### Puggable I/O: SocketLike protocol
105
111
 
106
- The `socketwrapper.SocketLike` protocol, a small subset the socket interface, is all what's required for any object to be wrap-able by `socketwrapper`.
112
+ The `protocols.SocketLike` protocol, a small subset the socket interface, is all what's required for any object to be wrap-able by `socketwrapper`.
113
+
114
+ Additional methods defined in `protocols.OptionalSocketLike` can be optionally implemented enabling optimized code paths.
107
115
 
108
116
  ```py
109
117
  @typing.runtime_checkable
@@ -115,6 +123,14 @@ class SocketLike(typing.Protocol):
115
123
  def recv(self, bufsize: int, /) -> bytes: ...
116
124
  def settimeout(self, timeout: float | None, /) -> None: ...
117
125
  def close(self) -> None: ...
126
+
127
+
128
+ @typing.runtime_checkable
129
+ class OptionalSocketLike(SocketLike, typing.Protocol):
130
+ """Protocol for optional socket-like object methods accepted by socketwrapper socket classes."""
131
+
132
+ def recv_into(self, buffer: collections.abc.Buffer, /) -> bytes: ...
133
+
118
134
  ```
119
135
 
120
136
  Special attention to:
@@ -35,12 +35,17 @@ uv pip install 'socketwrapper[msgpack]'
35
35
 
36
36
  ## Changelog
37
37
 
38
- ### v0.0.2 - 2025.11.05
38
+ ### 0.0.3 - 2025.11.08
39
+
40
+ - New: optional support for `recv_into` in socket-like objects.
41
+ - Optimization: recv operations will now use `sock.recv_into` (if available) to reduce memory allocation overhead.
42
+
43
+ ### 0.0.2 - 2025.11.05
39
44
 
40
45
  - Breaking: `MessageFraming.frames` and `MessageFraming.loads` now receive `io.BytesIO` instead of `bytearray`.
41
46
  - New: optional msgpack support (`msgpack` extra).
42
47
  - Fix: busy reads no longer blocking asyncio event loop.
43
- - Typing: expose `SizedBuffer` and deprecate `SendPayload`.
48
+ - Typing: expose `SizedBuffer` and deprecate `SendPayload` (removal expected in `0.1.0`).
44
49
  - Typing: `SocketWriter.send` and `SocketWriter.send_async` data is now `SizedBuffer`.
45
50
 
46
51
  ## Documentation
@@ -49,7 +54,9 @@ None other than this README, life's too short and I'm too busy with real life, i
49
54
 
50
55
  ### Puggable I/O: SocketLike protocol
51
56
 
52
- The `socketwrapper.SocketLike` protocol, a small subset the socket interface, is all what's required for any object to be wrap-able by `socketwrapper`.
57
+ The `protocols.SocketLike` protocol, a small subset the socket interface, is all what's required for any object to be wrap-able by `socketwrapper`.
58
+
59
+ Additional methods defined in `protocols.OptionalSocketLike` can be optionally implemented enabling optimized code paths.
53
60
 
54
61
  ```py
55
62
  @typing.runtime_checkable
@@ -61,6 +68,14 @@ class SocketLike(typing.Protocol):
61
68
  def recv(self, bufsize: int, /) -> bytes: ...
62
69
  def settimeout(self, timeout: float | None, /) -> None: ...
63
70
  def close(self) -> None: ...
71
+
72
+
73
+ @typing.runtime_checkable
74
+ class OptionalSocketLike(SocketLike, typing.Protocol):
75
+ """Protocol for optional socket-like object methods accepted by socketwrapper socket classes."""
76
+
77
+ def recv_into(self, buffer: collections.abc.Buffer, /) -> bytes: ...
78
+
64
79
  ```
65
80
 
66
81
  Special attention to:
@@ -5,7 +5,7 @@ build-backend = 'setuptools.build_meta'
5
5
  [project]
6
6
  name = 'socketwrapper'
7
7
  description = 'high level socket and pipe wrappers'
8
- version = '0.0.2'
8
+ version = '0.0.3'
9
9
  requires-python = '>=3.12'
10
10
  keywords = ['socket', 'pipe', 'ipc', 'asyncio']
11
11
  readme = { file = 'README.md', content-type = 'text/markdown' }
@@ -48,6 +48,7 @@ dev = [
48
48
 
49
49
  [project.urls]
50
50
  homepage = 'https://gitlab.com/ergoithz/socketwrapper'
51
+ repository = 'https://gitlab.com/ergoithz/socketwrapper'
51
52
  issue-tracker = 'https://gitlab.com/ergoithz/socketwrapper/-/issues'
52
53
  release-notes = 'https://gitlab.com/ergoithz/socketwrapper/-/releases'
53
54
  issue-new = 'https://gitlab.com/ergoithz/socketwrapper/-/issues/new'
@@ -23,7 +23,7 @@ from . import _utils, framing, protocols
23
23
  __all__ = (
24
24
  # type aliases and protocols
25
25
  'MessageFraming',
26
- 'SendPayload', # FIXME(docs): deprecated since 0.0.2
26
+ 'SendPayload', # TODO(0.1.0): remove, deprecated since 0.0.2
27
27
  'SizedBuffer',
28
28
  'SocketLike',
29
29
  'SocketOrDescriptor',
@@ -50,7 +50,7 @@ DEFAULT_FRAMING = framing.VarIntBytes
50
50
 
51
51
  SocketLike = protocols.SocketLike
52
52
  MessageFraming = protocols.MessageFraming
53
- SocketOrDescriptor = SocketLike | protocols.HasFileno | int
53
+ SocketOrDescriptor = protocols.SocketLike | protocols.ExtendedSocketLike | protocols.HasFileno | int
54
54
  SizedBuffer = protocols.SizedBuffer
55
55
 
56
56
  RecvSize = _utils.RecvSize
@@ -58,7 +58,7 @@ SendPayload = _utils.SendPayload
58
58
 
59
59
 
60
60
  class DescriptorSocket:
61
- """Generic socket-like accepting any file descriptor (usualy pipes) as input."""
61
+ """Generic socket-like accepting any file descriptor (usually pipes) as input."""
62
62
 
63
63
  def __init__(self, fd: int) -> None:
64
64
  """Initialize."""
@@ -76,6 +76,16 @@ class DescriptorSocket:
76
76
  """Read data from descriptor."""
77
77
  return os.read(self._fd, bufsize)
78
78
 
79
+ if sys.version_info < (3, 14):
80
+ def recv_into(self, buffer: collections.abc.Buffer) -> int:
81
+ """Read data from descriptor into buffer."""
82
+ return os.readv(self._fd, (buffer,))
83
+
84
+ else:
85
+ def recv_into(self, buffer: collections.abc.Buffer) -> int:
86
+ """Read data from descriptor into buffer."""
87
+ return os.readinto(self._fd, buffer)
88
+
79
89
  def settimeout(self, timeout: float | None) -> None:
80
90
  """Set blocking or non-blocking based on timeout being 0 or None."""
81
91
  if timeout:
@@ -262,7 +272,7 @@ KSW = typing_extensions.TypeVar('KSW', bound=SocketWriter, default=SocketWriter)
262
272
  class MessageWrapper(_utils.ClosingContext, typing_extensions.Generic[R, W, KS]):
263
273
  """Base class for message socket wrappers supporting asynchronous operations."""
264
274
 
265
- _wrapper: typing.ClassVar[type[SocketWrapper]] = SocketWrapper
275
+ _wrapper = SocketWrapper
266
276
  _raw: KS
267
277
  framing: MessageFraming[R, W]
268
278
 
@@ -293,7 +303,7 @@ class MessageWrapper(_utils.ClosingContext, typing_extensions.Generic[R, W, KS])
293
303
  class MessageReader(MessageWrapper[R, typing.Any, KSR], typing_extensions.Generic[R, KSR]):
294
304
  """Readable message socket wrapper."""
295
305
 
296
- _wrapper: typing.ClassVar = SocketReader
306
+ _wrapper = SocketReader
297
307
 
298
308
  def recv(self, timeout: float | None = None) -> R:
299
309
  """Receive data from socket."""
@@ -307,7 +317,7 @@ class MessageReader(MessageWrapper[R, typing.Any, KSR], typing_extensions.Generi
307
317
  class MessageWriter(MessageWrapper[typing.Any, W, KSW], typing_extensions.Generic[W, KSW]):
308
318
  """Writable message socket wrapper."""
309
319
 
310
- _wrapper: typing.ClassVar = SocketWriter
320
+ _wrapper = SocketWriter
311
321
 
312
322
  def send(self, data: W, timeout: float | None = None) -> None:
313
323
  """Send data to socket."""
@@ -321,7 +331,7 @@ class MessageWriter(MessageWrapper[typing.Any, W, KSW], typing_extensions.Generi
321
331
  class MessageDuplex[R, W](MessageWriter[W, SocketDuplex], MessageReader[R, SocketDuplex]):
322
332
  """Duplex (both readable and writable) message socket wrapper."""
323
333
 
324
- _wrapper: typing.ClassVar = SocketDuplex
334
+ _wrapper = SocketDuplex
325
335
 
326
336
 
327
337
  # TODO: move to namespace
@@ -445,9 +455,9 @@ def _framing(framing: MessageFraming[DR, DW] | bool) -> MessageFraming[DR, DW] |
445
455
  return DEFAULT_FRAMING if framing is True else framing if framing else None
446
456
 
447
457
 
448
- def _socketlike(sock: SocketOrDescriptor) -> SocketLike:
458
+ def _socketlike[S: SocketLike](sock: S | protocols.HasFileno | int) -> S | socket.socket | DescriptorSocket:
449
459
  """Use or initialize socketlike, reporting if it's native or not."""
450
- if isinstance(sock, SocketLike):
460
+ if isinstance(sock, protocols.SocketLike):
451
461
  return sock # socket object, use straight
452
462
 
453
463
  fd, dup = (sock, False) if isinstance(sock, int) else (sock.fileno(), True)
@@ -459,18 +469,18 @@ def _socketlike(sock: SocketOrDescriptor) -> SocketLike:
459
469
  maybe_socket = False
460
470
 
461
471
  if maybe_socket:
462
- sock = socket.socket(fileno=fd)
472
+ sck = socket.socket(fileno=fd)
463
473
  try:
464
- sock.getsockopt(socket.SOL_SOCKET, socket.SO_TYPE)
474
+ sck.getsockopt(socket.SOL_SOCKET, socket.SO_TYPE)
465
475
  if dup: # obj backed by socket, duplicate to not interfere with original ref lifetime
466
- sock2 = sock.dup()
467
- sock.detach()
468
- return sock2
476
+ sck2 = sck.dup()
477
+ sck.detach()
478
+ return sck2
469
479
 
470
480
  except OSError:
471
- sock.detach()
481
+ sck.detach()
472
482
 
473
483
  else:
474
- return sock # socket descriptor, use directly
484
+ return sck # socket descriptor, use directly
475
485
 
476
486
  return DescriptorSocket(fd) # assume pipe-like
@@ -8,6 +8,7 @@ import errno
8
8
  import io
9
9
  import os
10
10
  import socket
11
+ import sys
11
12
  import threading
12
13
  import time
13
14
  import types
@@ -20,6 +21,9 @@ from . import protocols
20
21
  RecvSize = collections.abc.Callable[[io.BytesIO], collections.abc.Iterable[int]] | int
21
22
  SendPayload = collections.abc.Iterable[protocols.SizedBuffer] | protocols.SizedBuffer
22
23
 
24
+ COPY_BUFFER_MINSIZE = 65536 # 64KB
25
+ COPY_BUFFER_THRESHOLD = 512 if os.getenv('PYTHONMALLOC') in {None, 'default', 'pymalloc'} else 0
26
+
23
27
 
24
28
  class ClosingContext(abc.ABC):
25
29
 
@@ -103,7 +107,7 @@ class ContextPair[A: contextlib.AbstractContextManager, B: contextlib.AbstractCo
103
107
  class CrossLock:
104
108
  """A higher level lock for both sync and async."""
105
109
 
106
- __slots__: typing.ClassVar = '_aiolocks', '_aiowaits', '_busy', '_lock'
110
+ __slots__ = '_aiolocks', '_aiowaits', '_busy', '_lock'
107
111
 
108
112
  _aiolocks: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock]
109
113
  _aiowaits: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Future]
@@ -190,41 +194,79 @@ def safe_timeout(sock: protocols.SocketLike, timeout: float | None) -> bool:
190
194
  """Check if timeout if supported by setting it and handling ValueError."""
191
195
  try:
192
196
  sock.settimeout(timeout)
197
+
193
198
  except ValueError:
194
199
  return False
200
+
195
201
  return True
196
202
 
197
203
 
198
- def reader(
199
- sock: protocols.SocketLike, buffer: io.BytesIO, size: RecvSize, *,
204
+ def reader( # noqa: C901
205
+ sock: protocols.SocketLike | protocols.ExtendedSocketLike, buffer: io.BytesIO, size: RecvSize, *,
200
206
  throttle: bool = False,
201
207
  ) -> collections.abc.Iterator[bool]:
202
- """Fully consume bytes from the socket."""
208
+ r"""
209
+ Fully consume bytes from the socket.
210
+
211
+ Implementation notes
212
+ ====================
213
+
214
+ 1. We yield `True` for when the consumer should wait for data.
215
+ 2. We rely on ``pymalloc`` speed for small strings (512B) and whenever
216
+ :meth:`protocols.OptionalSocketLike.recv_into` isn't available.
217
+ 3. We use a copy buffer for bigger reads instead of writing directly via
218
+ :meth:`io.BytesIO.getbuffer` because of issues with preallocation:
219
+ - :meth:`io.BytesIO.truncate` doesn't extend https://github.com/python/cpython/issues/71448
220
+ - ``buffer.write(b'\0' * size)`` is slower.
221
+ - ``buffer.seek(size - 1, os.SEEK_END) + buffer.write(b'\0')`` breaks smart resizing.
222
+ 4. Our copy buffer can grow by duplicating its size on each partial read
223
+ until its size is above the source socket-like buffer size.
224
+
225
+ """
203
226
  buff_write = buffer.write
204
227
  sock_recv = sock.recv
228
+ sock_recv_into = getattr(sock, 'recv_into', None) # optional
205
229
  tell = buffer.tell
206
230
  seek = buffer.seek
207
231
  seek_end = os.SEEK_END
208
232
  sizes = (size,) if isinstance(size, int) else size(buffer)
233
+ bufskip, bufview = (
234
+ (COPY_BUFFER_THRESHOLD, memoryview(bytearray(COPY_BUFFER_MINSIZE))) if sock_recv_into else
235
+ (sys.maxsize, b'')
236
+ )
209
237
  for sz in sizes:
210
- if sz > 0:
211
- pos = tell()
212
- seek(0, seek_end)
213
- while True:
214
- try:
215
- if not (data := sock_recv(sz)):
238
+ if sz < 1:
239
+ continue
240
+
241
+ # FIXME: rework implementation if cpython ever fixes https://github.com/python/cpython/issues/71448
242
+ pos = tell()
243
+ seek(0, seek_end)
244
+ while True:
245
+ try:
246
+ if sz > bufskip: # use buffer for long strings (if recv_into is available)
247
+ written = sock_recv_into(bufview[:sz])
248
+ if written == len(bufview):
249
+ chunk, bufview = bufview, memoryview(bytearray(len(bufview) * 2))
250
+
251
+ elif written:
252
+ chunk = bufview[:written]
253
+
254
+ else:
216
255
  raise EOFError
217
256
 
218
- if (sz := sz - buff_write(data)) < 1:
219
- break
257
+ elif not (chunk := sock_recv(sz)): # use pymalloc (faster than buffers for small strings)
258
+ raise EOFError
220
259
 
221
- if throttle:
222
- yield False
260
+ if (sz := sz - buff_write(chunk)) < 1:
261
+ break
223
262
 
224
- except BlockingIOError:
225
- yield True
263
+ if throttle:
264
+ yield False
265
+
266
+ except BlockingIOError:
267
+ yield True
226
268
 
227
- seek(pos)
269
+ seek(pos)
228
270
 
229
271
 
230
272
  def writer( # noqa: C901
@@ -244,22 +286,27 @@ def writer( # noqa: C901
244
286
  sock_send = sock.send
245
287
  chunks = (data,) if isinstance(data, protocols.SizedBuffer) else data
246
288
  for chunk in chunks:
247
- if chunk:
248
- offset = 0
249
- while (offset := safe_write(chunk)) < 1: # write first chunk without slicing
250
- yield True
289
+ if not chunk:
290
+ continue
251
291
 
252
- if offset < len(chunk): # finished
253
- buffer = memoryview(chunk)[offset:] # turn slicing into zero-copy
254
- while True:
255
- if (offset := safe_write(buffer)) < 1:
256
- yield True
292
+ written = 0
293
+ while (written := safe_write(chunk)) < 1: # write first chunk without slicing
294
+ yield True
295
+
296
+ sz = len(chunk) - written
297
+ if sz < 1:
298
+ continue
299
+
300
+ with memoryview(chunk) as buffer: # turn slicing into zero-copy
301
+ while True:
302
+ if (written := safe_write(buffer[-sz:])) < 1:
303
+ yield True
257
304
 
258
- elif not (buffer := buffer[offset:]):
259
- break
305
+ elif (sz := sz - written) < 1:
306
+ break
260
307
 
261
- elif throttle:
262
- yield False
308
+ elif throttle:
309
+ yield False
263
310
 
264
311
 
265
312
  @contextlib.contextmanager
@@ -4,7 +4,6 @@ import abc
4
4
  import collections.abc
5
5
  import io
6
6
  import os
7
- import typing
8
7
 
9
8
  from . import _varint, protocols
10
9
 
@@ -178,7 +177,7 @@ try:
178
177
  packer: PackerTypes
179
178
  unpacker: UnpackerTypes
180
179
 
181
- _typedefs: typing.ClassVar = (
180
+ _typedefs = (
182
181
  # headsize, headskip, varsized, recurse, factor
183
182
  *((0, 0, False, False, 1) for _ in range(0x80)), # 0x00-0x80: fixint (+)
184
183
  *((i, 0, False, True, 2) for i in range(16)), # 0x80-0x90: fixmap
@@ -24,6 +24,13 @@ class SocketLike(typing.Protocol):
24
24
  def close(self) -> None: ...
25
25
 
26
26
 
27
+ @typing.runtime_checkable
28
+ class ExtendedSocketLike(SocketLike, typing.Protocol):
29
+ """Protocol for optional socket-like object methods accepted by socketwrapper socket classes."""
30
+
31
+ def recv_into(self, buffer: collections.abc.Buffer, /) -> bytes: ...
32
+
33
+
27
34
  @typing.runtime_checkable
28
35
  class HasFileno(typing.Protocol):
29
36
  """Protocol for objects backed by a file descriptor."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: socketwrapper
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Summary: high level socket and pipe wrappers
5
5
  Author: Felipe A Hernandez
6
6
  Author-email: ergoithz@gmail.com
@@ -27,6 +27,7 @@ License: MIT License
27
27
  SOFTWARE.
28
28
 
29
29
  Project-URL: homepage, https://gitlab.com/ergoithz/socketwrapper
30
+ Project-URL: repository, https://gitlab.com/ergoithz/socketwrapper
30
31
  Project-URL: issue-tracker, https://gitlab.com/ergoithz/socketwrapper/-/issues
31
32
  Project-URL: release-notes, https://gitlab.com/ergoithz/socketwrapper/-/releases
32
33
  Project-URL: issue-new, https://gitlab.com/ergoithz/socketwrapper/-/issues/new
@@ -89,12 +90,17 @@ uv pip install 'socketwrapper[msgpack]'
89
90
 
90
91
  ## Changelog
91
92
 
92
- ### v0.0.2 - 2025.11.05
93
+ ### 0.0.3 - 2025.11.08
94
+
95
+ - New: optional support for `recv_into` in socket-like objects.
96
+ - Optimization: recv operations will now use `sock.recv_into` (if available) to reduce memory allocation overhead.
97
+
98
+ ### 0.0.2 - 2025.11.05
93
99
 
94
100
  - Breaking: `MessageFraming.frames` and `MessageFraming.loads` now receive `io.BytesIO` instead of `bytearray`.
95
101
  - New: optional msgpack support (`msgpack` extra).
96
102
  - Fix: busy reads no longer blocking asyncio event loop.
97
- - Typing: expose `SizedBuffer` and deprecate `SendPayload`.
103
+ - Typing: expose `SizedBuffer` and deprecate `SendPayload` (removal expected in `0.1.0`).
98
104
  - Typing: `SocketWriter.send` and `SocketWriter.send_async` data is now `SizedBuffer`.
99
105
 
100
106
  ## Documentation
@@ -103,7 +109,9 @@ None other than this README, life's too short and I'm too busy with real life, i
103
109
 
104
110
  ### Puggable I/O: SocketLike protocol
105
111
 
106
- The `socketwrapper.SocketLike` protocol, a small subset the socket interface, is all what's required for any object to be wrap-able by `socketwrapper`.
112
+ The `protocols.SocketLike` protocol, a small subset the socket interface, is all what's required for any object to be wrap-able by `socketwrapper`.
113
+
114
+ Additional methods defined in `protocols.OptionalSocketLike` can be optionally implemented enabling optimized code paths.
107
115
 
108
116
  ```py
109
117
  @typing.runtime_checkable
@@ -115,6 +123,14 @@ class SocketLike(typing.Protocol):
115
123
  def recv(self, bufsize: int, /) -> bytes: ...
116
124
  def settimeout(self, timeout: float | None, /) -> None: ...
117
125
  def close(self) -> None: ...
126
+
127
+
128
+ @typing.runtime_checkable
129
+ class OptionalSocketLike(SocketLike, typing.Protocol):
130
+ """Protocol for optional socket-like object methods accepted by socketwrapper socket classes."""
131
+
132
+ def recv_into(self, buffer: collections.abc.Buffer, /) -> bytes: ...
133
+
118
134
  ```
119
135
 
120
136
  Special attention to:
File without changes
File without changes