Index: msgspec-0.19.0/msgspec/_core.c =================================================================== --- msgspec-0.19.0.orig/msgspec/_core.c +++ msgspec-0.19.0/msgspec/_core.c @@ -452,6 +452,7 @@ typedef struct { #endif PyObject *astimezone; PyObject *re_compile; + PyObject *get_annotate_from_class_namespace; uint8_t gc_cycle; } MsgspecState; @@ -5814,12 +5815,45 @@ structmeta_is_classvar( static int structmeta_collect_fields(StructMetaInfo *info, MsgspecState *mod, bool kwonly) { - PyObject *annotations = PyDict_GetItemString( + PyObject *annotations = PyDict_GetItemString( // borrowed reference info->namespace, "__annotations__" ); - if (annotations == NULL) return 0; + if (annotations == NULL) { + if (mod->get_annotate_from_class_namespace != NULL) { + PyObject *annotate = PyObject_CallOneArg( + mod->get_annotate_from_class_namespace, info->namespace + ); + if (annotate == NULL) { + return -1; + } + if (annotate == Py_None) { + Py_DECREF(annotate); + return 0; + } + PyObject *format = PyLong_FromLong(1); /* annotationlib.Format.VALUE */ + if (format == NULL) { + Py_DECREF(annotate); + return -1; + } + annotations = PyObject_CallOneArg( + annotate, format + ); + Py_DECREF(annotate); + Py_DECREF(format); + if (annotations == NULL) { + return -1; + } + } + else { + return 0; // No annotations, nothing to do + } + } + else { + Py_INCREF(annotations); + } if (!PyDict_Check(annotations)) { + Py_DECREF(annotations); PyErr_SetString(PyExc_TypeError, "__annotations__ must be a dict"); return -1; } @@ -5869,6 +5903,7 @@ structmeta_collect_fields(StructMetaInfo } return 0; error: + Py_DECREF(annotations); Py_XDECREF(module_ns); return -1; } @@ -22223,6 +22258,26 @@ PyInit__core(void) Py_DECREF(temp_module); if (st->re_compile == NULL) return NULL; + /* annotationlib.get_annotate_from_class_namespace */ + temp_module = PyImport_ImportModule("annotationlib"); + if (temp_module == NULL) { + if (PyErr_ExceptionMatches(PyExc_ModuleNotFoundError)) { + // Below Python 3.14 + PyErr_Clear(); + st->get_annotate_from_class_namespace = NULL; + } + else { + return NULL; + } + } + else { + st->get_annotate_from_class_namespace = PyObject_GetAttrString( + temp_module, "get_annotate_from_class_namespace" + ); + Py_DECREF(temp_module); + if (st->get_annotate_from_class_namespace == NULL) return NULL; + } + /* Initialize cached constant strings */ #define CACHED_STRING(attr, str) \ if ((st->attr = PyUnicode_InternFromString(str)) == NULL) return NULL Index: msgspec-0.19.0/msgspec/_utils.py =================================================================== --- msgspec-0.19.0.orig/msgspec/_utils.py +++ msgspec-0.19.0/msgspec/_utils.py @@ -1,5 +1,6 @@ # type: ignore import collections +import inspect import sys import typing @@ -71,6 +72,13 @@ else: _eval_type = typing._eval_type +if sys.version_info >= (3, 10): + from inspect import get_annotations as _get_class_annotations +else: + def _get_class_annotations(cls): + return cls.__dict__.get("__annotations__", {}) + + def _apply_params(obj, mapping): if isinstance(obj, typing.TypeVar): return mapping.get(obj, obj) @@ -149,17 +157,17 @@ def get_class_annotations(obj): cls_locals = dict(vars(cls)) cls_globals = getattr(sys.modules.get(cls.__module__, None), "__dict__", {}) - ann = cls.__dict__.get("__annotations__", {}) + ann = _get_class_annotations(cls) for name, value in ann.items(): if name in hints: continue - if value is None: - value = type(None) - elif isinstance(value, str): + if isinstance(value, str): value = _forward_ref(value) value = _eval_type(value, cls_locals, cls_globals) if mapping is not None: value = _apply_params(value, mapping) + if value is None: + value = type(None) hints[name] = value return hints Index: msgspec-0.19.0/tests/test_common.py =================================================================== --- msgspec-0.19.0.orig/tests/test_common.py +++ msgspec-0.19.0/tests/test_common.py @@ -1370,14 +1370,14 @@ class TestGenericStruct: dec = proto.Decoder(typ) info = typ.__msgspec_cache__ assert info is not None - assert sys.getrefcount(info) == 4 # info + attr + decoder + func call + assert sys.getrefcount(info) <= 4 # info + attr + decoder + func call dec2 = proto.Decoder(typ) assert typ.__msgspec_cache__ is info - assert sys.getrefcount(info) == 5 + assert sys.getrefcount(info) <= 5 del dec del dec2 - assert sys.getrefcount(info) == 3 + assert sys.getrefcount(info) <= 3 def test_generic_struct_invalid_types_not_cached(self, proto): class Ex(Struct, Generic[T]): @@ -1545,7 +1545,7 @@ class TestStructPostInit: res = proto.decode(buf, type=typ) assert res == msg assert count == 2 # 1 for Ex(), 1 for decode - assert sys.getrefcount(singleton) == 2 # 1 for ref, 1 for call + assert sys.getrefcount(singleton) <= 2 # 1 for ref, 1 for call @pytest.mark.parametrize("array_like", [False, True]) @pytest.mark.parametrize("union", [False, True]) @@ -1606,14 +1606,14 @@ class TestGenericDataclassOrAttrs: dec = proto.Decoder(typ) info = typ.__msgspec_cache__ assert info is not None - assert sys.getrefcount(info) == 4 # info + attr + decoder + func call + assert sys.getrefcount(info) <= 4 # info + attr + decoder + func call dec2 = proto.Decoder(typ) assert typ.__msgspec_cache__ is info - assert sys.getrefcount(info) == 5 + assert sys.getrefcount(info) <= 5 del dec del dec2 - assert sys.getrefcount(info) == 3 + assert sys.getrefcount(info) <= 3 def test_generic_invalid_types_not_cached(self, decorator, proto): @decorator @@ -2179,14 +2179,14 @@ class TestTypedDict: dec = proto.Decoder(typ) info = typ.__msgspec_cache__ assert info is not None - assert sys.getrefcount(info) == 4 # info + attr + decoder + func call + assert sys.getrefcount(info) <= 4 # info + attr + decoder + func call dec2 = proto.Decoder(typ) assert typ.__msgspec_cache__ is info - assert sys.getrefcount(info) == 5 + assert sys.getrefcount(info) <= 5 del dec del dec2 - assert sys.getrefcount(info) == 3 + assert sys.getrefcount(info) <= 3 def test_generic_typeddict_invalid_types_not_cached(self, proto): TypedDict = pytest.importorskip("typing_extensions").TypedDict @@ -2398,14 +2398,14 @@ class TestNamedTuple: dec = proto.Decoder(typ) info = typ.__msgspec_cache__ assert info is not None - assert sys.getrefcount(info) == 4 # info + attr + decoder + func call + assert sys.getrefcount(info) <= 4 # info + attr + decoder + func call dec2 = proto.Decoder(typ) assert typ.__msgspec_cache__ is info - assert sys.getrefcount(info) == 5 + assert sys.getrefcount(info) <= 5 del dec del dec2 - assert sys.getrefcount(info) == 3 + assert sys.getrefcount(info) <= 3 def test_generic_namedtuple_invalid_types_not_cached(self, proto): NamedTuple = pytest.importorskip("typing_extensions").NamedTuple Index: msgspec-0.19.0/tests/test_convert.py =================================================================== --- msgspec-0.19.0.orig/tests/test_convert.py +++ msgspec-0.19.0/tests/test_convert.py @@ -220,7 +220,7 @@ class TestConvert: x = Custom() res = convert(x, Any) assert res is x - assert sys.getrefcount(x) == 3 # x + res + 1 + assert sys.getrefcount(x) <= 3 # x + res + 1 def test_custom_input_type_works_with_custom(self): class Custom: @@ -229,7 +229,7 @@ class TestConvert: x = Custom() res = convert(x, Custom) assert res is x - assert sys.getrefcount(x) == 3 # x + res + 1 + assert sys.getrefcount(x) <= 3 # x + res + 1 def test_custom_input_type_works_with_dec_hook(self): class Custom: @@ -247,8 +247,8 @@ class TestConvert: x = Custom() res = convert(x, Custom2, dec_hook=dec_hook) assert isinstance(res, Custom2) - assert sys.getrefcount(res) == 2 # res + 1 - assert sys.getrefcount(x) == 2 # x + 1 + assert sys.getrefcount(res) <= 2 # res + 1 + assert sys.getrefcount(x) <= 2 # x + 1 def test_unsupported_output_type(self): with pytest.raises(TypeError, match="more than one array-like"): @@ -397,7 +397,7 @@ class TestInt: x = MyInt(100) sol = convert(x, MyInt) assert sol is x - assert sys.getrefcount(x) == 3 # x + sol + 1 + assert sys.getrefcount(x) <= 3 # x + sol + 1 class TestFloat: @@ -535,10 +535,10 @@ class TestBinary: del sol - assert sys.getrefcount(msg) == 2 # msg + 1 + assert sys.getrefcount(msg) <= 2 # msg + 1 sol = convert(msg, MyBytes) assert sol is msg - assert sys.getrefcount(msg) == 3 # msg + sol + 1 + assert sys.getrefcount(msg) <= 3 # msg + sol + 1 class TestDateTime: @@ -828,7 +828,7 @@ class TestEnum: msg = MyInt(1) assert convert(msg, Ex) is Ex.x - assert sys.getrefcount(msg) == 2 # msg + 1 + assert sys.getrefcount(msg) <= 2 # msg + 1 assert convert(MyInt(2), Ex) is Ex.y def test_enum_missing(self): @@ -2223,7 +2223,7 @@ class TestStructPostInit: res = convert(msg, type=typ, from_attributes=from_attributes) assert type(res) is Ex assert called - assert sys.getrefcount(singleton) == 2 # 1 for ref, 1 for call + assert sys.getrefcount(singleton) <= 2 # 1 for ref, 1 for call @pytest.mark.parametrize("union", [False, True]) @pytest.mark.parametrize("exc_class", [ValueError, TypeError, OSError]) Index: msgspec-0.19.0/tests/test_json.py =================================================================== --- msgspec-0.19.0.orig/tests/test_json.py +++ msgspec-0.19.0/tests/test_json.py @@ -898,7 +898,7 @@ class TestDatetime: tz2 = msgspec.json.decode(msg, type=datetime.datetime).tzinfo assert tz is tz2 del tz2 - assert sys.getrefcount(tz) == 3 # 1 tz, 1 cache, 1 func call + assert sys.getrefcount(tz) <= 3 # 1 tz, 1 cache, 1 func call for _ in range(10): gc.collect() # cache is cleared every 10 full collections @@ -2293,7 +2293,7 @@ class TestStruct: assert x == Person("harry", "potter", 13, False) # one for struct, one for output of getattr, and one for getrefcount - assert sys.getrefcount(x.first) == 3 + assert sys.getrefcount(x.first) <= 3 with pytest.raises( msgspec.ValidationError, match="Expected `object`, got `int`" Index: msgspec-0.19.0/tests/test_msgpack.py =================================================================== --- msgspec-0.19.0.orig/tests/test_msgpack.py +++ msgspec-0.19.0/tests/test_msgpack.py @@ -684,13 +684,13 @@ class TestTypedDecoder: assert isinstance(res, memoryview) assert bytes(res) == b"abcde" if input_type is memoryview: - assert sys.getrefcount(ref) == 3 + assert sys.getrefcount(ref) <= 3 del msg - assert sys.getrefcount(ref) == 3 + assert sys.getrefcount(ref) <= 3 del res - assert sys.getrefcount(ref) == 2 + assert sys.getrefcount(ref) <= 2 elif input_type is bytes: - assert sys.getrefcount(msg) == 3 + assert sys.getrefcount(msg) <= 3 def test_datetime_aware_ext(self): dec = msgspec.msgpack.Decoder(datetime.datetime) @@ -815,7 +815,7 @@ class TestTypedDecoder: res = dec.decode(enc.encode(x)) assert res == x if res: - assert sys.getrefcount(res[0]) == 3 # 1 tuple, 1 index, 1 func call + assert sys.getrefcount(res[0]) <= 3 # 1 tuple, 1 index, 1 func call @pytest.mark.parametrize("typ", [tuple, Tuple, Tuple[Any, ...]]) def test_vartuple_any(self, typ): Index: msgspec-0.19.0/tests/test_struct.py =================================================================== --- msgspec-0.19.0.orig/tests/test_struct.py +++ msgspec-0.19.0/tests/test_struct.py @@ -931,16 +931,16 @@ def test_struct_reference_counting(): data = [1, 2, 3] t = Test(data) - assert sys.getrefcount(data) == 3 + assert sys.getrefcount(data) <= 3 repr(t) - assert sys.getrefcount(data) == 3 + assert sys.getrefcount(data) <= 3 t2 = t.__copy__() - assert sys.getrefcount(data) == 4 + assert sys.getrefcount(data) <= 4 assert t == t2 - assert sys.getrefcount(data) == 4 + assert sys.getrefcount(data) <= 4 def test_struct_gc_not_added_if_not_needed(): @@ -2581,7 +2581,7 @@ class TestPostInit: Ex(1) assert called # Return value is decref'd - assert sys.getrefcount(singleton) == 2 # 1 for ref, 1 for call + assert sys.getrefcount(singleton) <= 2 # 1 for ref, 1 for call def test_post_init_errors(self): class Ex(Struct):