Skip to content

Commit e374362

Browse files
committed
Allow default handler to take a position parameter
This enables padding to be computed when packing a custom type and enables zero-copy unpacking by aligning primitives correctly
1 parent c6b4a48 commit e374362

3 files changed

Lines changed: 152 additions & 8 deletions

File tree

msgpack/_packer.pyx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ from cpython.datetime cimport (
88
cdef ExtType
99
cdef Timestamp
1010

11+
import inspect
1112
from .ext import ExtType, Timestamp
1213

1314

@@ -65,7 +66,8 @@ cdef class Packer:
6566
:param default:
6667
When specified, it should be callable.
6768
Convert user type to builtin type that Packer supports.
68-
See also simplejson's document.
69+
See also simplejson's document. In addition, this callable may have two parameters
70+
where the second parameter is given the current position of the stream.
6971
7072
:param bool use_single_float:
7173
Use single precision float type for float. (default: False)
@@ -106,6 +108,7 @@ cdef class Packer:
106108
cdef const char *unicode_errors
107109
cdef size_t exports # number of exported buffers
108110
cdef bint strict_types
111+
cdef bint _pass_posn
109112
cdef bint use_float
110113
cdef bint autoreset
111114
cdef bint datetime
@@ -140,6 +143,14 @@ cdef class Packer:
140143
if default is not None:
141144
if not PyCallable_Check(default):
142145
raise TypeError("default must be a callable.")
146+
default_argc = len(inspect.signature(default).parameters)
147+
if default_argc == 1:
148+
self._pass_posn = False
149+
elif default_argc == 2:
150+
self._pass_posn = True
151+
else:
152+
raise ValueError("default must take one or two parameters")
153+
143154
self._default = default
144155

145156
self._berrors = unicode_errors
@@ -263,7 +274,10 @@ cdef class Packer:
263274
if self._default is not None:
264275
ret = self._pack_inner(o, 1, nest_limit)
265276
if ret == -2:
266-
o = self._default(o)
277+
if self._pass_posn:
278+
o = self._default(o, self.pk.length)
279+
else:
280+
o = self._default(o)
267281
else:
268282
return ret
269283
return self._pack_inner(o, 0, nest_limit)

msgpack/fallback.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Fallback pure Python implementation of msgpack"""
22

3+
import inspect
34
import struct
45
import sys
56
from datetime import datetime as _DateTime
@@ -622,7 +623,8 @@ class Packer:
622623
:param default:
623624
When specified, it should be callable.
624625
Convert user type to builtin type that Packer supports.
625-
See also simplejson's document.
626+
See also simplejson's document. In addition, this callable may have two parameters
627+
where the second parameter is given the current position of the stream.
626628
627629
:param bool use_single_float:
628630
Use single precision float type for float. (default: False)
@@ -675,8 +677,16 @@ def __init__(
675677
self._buffer = BytesIO()
676678
self._datetime = bool(datetime)
677679
self._unicode_errors = unicode_errors or "strict"
678-
if default is not None and not callable(default):
679-
raise TypeError("default must be callable")
680+
if default is not None:
681+
if not callable(default):
682+
raise TypeError("default must be callable")
683+
default_argc = len(inspect.signature(default).parameters)
684+
if default_argc == 1:
685+
self._pass_posn = False
686+
elif default_argc == 2:
687+
self._pass_posn = True
688+
else:
689+
raise ValueError("default must take one or two parameters")
680690
self._default = default
681691

682692
def _pack(
@@ -723,8 +733,11 @@ def _pack(
723733
if -0x8000000000000000 <= obj < -0x80000000:
724734
return self._buffer.write(struct.pack(">Bq", 0xD3, obj))
725735
if not default_used and self._default is not None:
726-
obj = self._default(obj)
727736
default_used = True
737+
if self._pass_posn:
738+
obj = self._default(obj, self._buffer.tell())
739+
else:
740+
obj = self._default(obj)
728741
continue
729742
raise OverflowError("Integer value out of range")
730743
if check(obj, (bytes, bytearray)):
@@ -794,8 +807,11 @@ def _pack(
794807
continue
795808

796809
if not default_used and self._default is not None:
797-
obj = self._default(obj)
798-
default_used = 1
810+
default_used = True
811+
if self._pass_posn:
812+
obj = self._default(obj, self._buffer.tell())
813+
else:
814+
obj = self._default(obj)
799815
continue
800816

801817
if self._datetime and check(obj, _DateTime):

test/test_default.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import logging
2+
from array import array
3+
from typing import Any
4+
5+
import msgpack
6+
7+
8+
def dfa(o: array):
9+
logging.info("Default called for %s", o)
10+
return dict(data=o.tobytes(), tcode=o.typecode)
11+
12+
13+
def decode_array(obj):
14+
if isinstance(obj, dict) and "tcode" in obj:
15+
return memoryview(obj["data"]).cast(obj["tcode"])
16+
return obj
17+
18+
19+
def encode_array(obj: Any, posn: int) -> Any | dict[str, Any]:
20+
if isinstance(obj, array):
21+
tcode = obj.typecode
22+
itemsize = obj.itemsize
23+
length = len(obj)
24+
bytesize = itemsize * length
25+
offset = posn + 1 + 5 + 1 # fixmap, fixstr + 4, binX
26+
if bytesize < 256:
27+
offset += 1
28+
elif bytesize < 65536:
29+
offset += 2
30+
elif bytesize < 2**32:
31+
offset += 4
32+
else:
33+
raise ValueError("Array too large (>= 4GiB)")
34+
35+
print(f"{offset=}, {itemsize=}")
36+
if offset % itemsize == 0:
37+
pad = 0
38+
else:
39+
offset += 4 # prefix with pad so fixstr + 3 + ?
40+
pad = itemsize - (offset % itemsize)
41+
# use fixstr for padding so deduct one and wrap
42+
pad = (pad - 1) % itemsize
43+
44+
if pad:
45+
obj = dict(
46+
pad="." * pad,
47+
data=obj.tobytes(),
48+
tcode=tcode,
49+
)
50+
else:
51+
obj = dict(
52+
data=obj.tobytes(),
53+
tcode=tcode,
54+
)
55+
return obj
56+
57+
58+
def check_align(pa, itemsize):
59+
"""
60+
check of start of data, 0xc4 + 1/0xc5 + 2, 0xc6 + 4
61+
has aligned offset
62+
"""
63+
offset = pa.index(b"\xa4data") + 5
64+
print(f"After data={offset}")
65+
if pa.startswith(b"\xc4", offset):
66+
offset += 2
67+
elif pa.startswith(b"\xc5", offset):
68+
offset += 3
69+
elif pa.startswith(b"\xc6", offset):
70+
offset += 5
71+
print(f"{offset=}, align={offset % itemsize}")
72+
assert offset % itemsize == 0
73+
74+
75+
def test_default_top():
76+
eo = array("f", [0.1])
77+
pa = msgpack.packb(eo, default=dfa)
78+
print(pa)
79+
ao = msgpack.unpackb(pa, object_hook=decode_array)
80+
assert eo == ao
81+
82+
pa = msgpack.packb(eo, default=encode_array)
83+
print(pa)
84+
check_align(pa, eo.itemsize)
85+
ad = msgpack.unpackb(pa)
86+
assert eo == memoryview(ad["data"]).cast(ad["tcode"])
87+
ao = msgpack.unpackb(pa, object_hook=decode_array)
88+
assert eo == ao
89+
print("Unpacked as", ao.tolist())
90+
91+
92+
def test_default_second():
93+
eo = dict(config="ladybug", ndata=array("d", [0.1]))
94+
pa = msgpack.packb(eo, default=dfa)
95+
print(pa)
96+
ao = msgpack.unpackb(pa, object_hook=decode_array)
97+
assert eo == ao
98+
99+
pa = msgpack.packb(eo, default=encode_array)
100+
print(pa)
101+
check_align(pa, eo["ndata"].itemsize)
102+
print(eo)
103+
ao = msgpack.unpackb(pa)
104+
ad = ao["ndata"]
105+
assert eo["ndata"] == memoryview(ad["data"]).cast(ad["tcode"])
106+
ao = msgpack.unpackb(pa, object_hook=decode_array)
107+
assert eo == ao
108+
print("Unpacked as", ao, ao["ndata"].tolist())
109+
110+
111+
if __name__ == "__main__":
112+
print(msgpack.__file__)
113+
test_default_top()
114+
test_default_second()

0 commit comments

Comments
 (0)