diff --git a/msgspec/_core.c b/msgspec/_core.c index b99fe8db..4b964d38 100644 --- a/msgspec/_core.c +++ b/msgspec/_core.c @@ -59,6 +59,10 @@ ms_popcount(uint64_t i) { \ /* Capacity of a list */ #define LIST_CAPACITY(x) (((PyListObject *)x)->allocated) +/* Get the raw items pointer for a list and tuple */ +#define LIST_ITEMS(x) (((PyListObject *)(x))->ob_item) +#define TUPLE_ITEMS(x) (((PyTupleObject *)(x))->ob_item) + /* Fast shrink of bytes & bytearray objects. This doesn't do any memory * allocations, it just shrinks the size of the view presented to Python. Since * outputs of `encode` should be short lived (immediately written to a @@ -18241,10 +18245,10 @@ convert_seq_to_struct_array_union( } static PyObject * -convert_seq(ConvertState *self, PyObject *obj, TypeNode *type, PathNode *path) { - PyObject **items = PySequence_Fast_ITEMS(obj); - Py_ssize_t size = PySequence_Fast_GET_SIZE(obj); - +convert_seq( + ConvertState *self, PyObject **items, Py_ssize_t size, + TypeNode *type, PathNode *path +) { if (!ms_passes_array_constraints(size, type, path)) return NULL; if (type->types & MS_TYPE_LIST) { @@ -18824,7 +18828,13 @@ convert_other( } } - /* No luck. Next try converting from a mapping or by attribute */ + /* No luck. Next check if it's a tuple subclass (standard tuples are + * handled earlier), and if so try converting it as a sequence */ + if (PyTuple_Check(obj)) { + return convert_seq(self, TUPLE_ITEMS(obj), PyTuple_GET_SIZE(obj), type, path); + } + + /* Next try converting from a mapping or by attribute */ bool is_mapping = PyMapping_Check(obj); if (is_mapping && type->types & MS_TYPE_DICT) { return convert_mapping_to_dict(self, obj, type, path); @@ -18886,10 +18896,14 @@ convert( else if (pytype == &PyFloat_Type) { return convert_float(self, obj, type, path); } - else if (pytype == &PyList_Type || pytype == &PyTuple_Type) { - return convert_seq(self, obj, type, path); + else if (PyList_Check(obj)) { + return convert_seq(self, LIST_ITEMS(obj), PyList_GET_SIZE(obj), type, path); } - else if (pytype == &PyDict_Type) { + else if (pytype == &PyTuple_Type) { + /* Tuple subclasses are handled later on */ + return convert_seq(self, TUPLE_ITEMS(obj), PyTuple_GET_SIZE(obj), type, path); + } + else if (PyDict_Check(obj)) { return convert_dict(self, obj, type, path); } else if (obj == Py_None) { @@ -18910,9 +18924,6 @@ convert( else if (pytype == PyDateTimeAPI->DateType) { return convert_immutable(self, MS_TYPE_DATE, "date", obj, type, path); } - else if (pytype == &PySet_Type || pytype == &PyFrozenSet_Type) { - return convert_any_set(self, obj, type, path); - } else if (pytype == (PyTypeObject *)self->mod->UUIDType) { return convert_immutable(self, MS_TYPE_UUID, "uuid", obj, type, path); } @@ -18925,6 +18936,9 @@ convert( else if (pytype == &Ext_Type) { return convert_immutable(self, MS_TYPE_EXT, "ext", obj, type, path); } + else if (PyAnySet_Check(obj)) { + return convert_any_set(self, obj, type, path); + } else { return convert_other(self, obj, type, path); } diff --git a/tests/test_convert.py b/tests/test_convert.py index b6272ff0..97d0dc28 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -97,14 +97,36 @@ def KWList(**kwargs): return list(kwargs.values()) +class SubList(list): + pass + + +class SubTuple(tuple): + pass + + +class SubSet(set): + pass + + +class SubFrozenSet(frozenset): + pass + + +class SubDict(dict): + pass + + mapcls_and_from_attributes = pytest.mark.parametrize( - "mapcls, from_attributes", [(dict, False), (GetAttrObj, True), (GetItemObj, False)] + "mapcls, from_attributes", + [(dict, False), (SubDict, False), (GetAttrObj, True), (GetItemObj, False)], ) mapcls_from_attributes_and_array_like = pytest.mark.parametrize( "mapcls, from_attributes, array_like", [ (dict, False, False), + (SubDict, False, False), (KWList, False, True), (GetAttrObj, True, True), (GetAttrObj, True, False), @@ -112,10 +134,29 @@ def KWList(**kwargs): ], ) +seq_in_type = pytest.mark.parametrize( + "in_type", + [ + list, + tuple, + set, + frozenset, + SubList, + SubTuple, + SubSet, + SubFrozenSet, + ], +) + -@pytest.fixture(params=["dict", "mapping"]) +@pytest.fixture(params=["dict", "subclass", "mapping"]) def dictcls(request): - return dict if request.param == "dict" else GetItemObj + if request.param == "dict": + return dict + elif request.param == "subclass": + return SubDict + else: + return GetItemObj def assert_eq(x, y): @@ -773,12 +814,12 @@ def test_any_sequence(self): msg = (1, 2, 3) assert convert(msg, Any) is msg - @pytest.mark.parametrize("in_type", [list, tuple, set, frozenset]) + @seq_in_type @pytest.mark.parametrize("out_type", [list, tuple, set, frozenset]) def test_empty_sequence(self, in_type, out_type): assert convert(in_type(), out_type) == out_type() - @pytest.mark.parametrize("in_type", [list, tuple, set, frozenset]) + @seq_in_type @pytest.mark.parametrize( "out_type_annot", [(list, List), (tuple, Tuple), (set, Set), (frozenset, FrozenSet)], @@ -796,7 +837,7 @@ def test_sequence(self, in_type, out_type_annot, item_annot): assert res == sol assert isinstance(res, out_type) - @pytest.mark.parametrize("in_type", [list, tuple, set, frozenset]) + @seq_in_type @pytest.mark.parametrize( "out_annot", [List[int], Tuple[int, ...], Set[int], FrozenSet[int]] ) @@ -959,11 +1000,15 @@ class Ex1(NamedTuple): class Ex2(NamedTuple): x: int + class Ex3(NamedTuple): + x: str + msg = Ex1(1) assert convert(msg, Ex1) is msg + assert convert(msg, Ex2) == Ex2(1) - with pytest.raises(ValidationError, match="got `Ex1`"): - convert(msg, Ex2) + with pytest.raises(ValidationError, match="Expected `str`, got `int`"): + convert(msg, Ex3) class TestDict: @@ -1542,7 +1587,7 @@ class Ex(Struct, forbid_unknown_fields=forbid_unknown_fields): b: int msg = mapcls(x=1, a=2, y=3, b=4, z=5) - if forbid_unknown_fields and mapcls is dict: + if forbid_unknown_fields and issubclass(mapcls, dict): with pytest.raises(ValidationError, match="unknown field `x`"): convert(msg, Ex, from_attributes=from_attributes) else: