Skip to content

Conversation

@JelleZijlstra
Copy link
Contributor

With this change, the tests run for me on a local build of Python 3.14.
There are a lot of failures related to sys.getrefcount() but that seems
to be an unrelated issue.

Closes #810. Fixes #651. Fixes #795.

With this change, the tests run for me on a local build of Python 3.14.
There are a lot of failures related to sys.getrefcount() but that seems
to be an unrelated issue.

Closes jcrist#810. Fixes jcrist#651. Fixes jcrist#795.
if (annotations == NULL) {
if (mod->get_annotate_from_class_namespace != NULL) {
PyObject *annotate = PyObject_CallOneArg(
mod->get_annotate_from_class_namespace, info->namespace
Copy link
Contributor Author

@JelleZijlstra JelleZijlstra May 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the portable approach, which should continue to work on future Python versions. If you value performance over portability, you can instead inline this function https://github.com/python/cpython/blob/9ddc7c548d45b73c84131e6d75b03c26a3e8b6e8/Lib/annotationlib.py#L824 ; it's just dict operations.

I made it a separate function in CPython so that we can be free to optimize the internal representation of annotate functions in the future. For example, perhaps in 3.15 the class will store just a code object instead of a function.

value = _eval_type(value, cls_locals, cls_globals)
if mapping is not None:
value = _apply_params(value, mapping)
if value is None:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed _eval_type so it no longer turns None into NoneType.

@hroncok
Copy link
Contributor

hroncok commented May 26, 2025

I get a copule of SystemError: error return without exception set:

___________ TestTypedDict.test_total_partially_optional[json-False] ____________

self = <test_common.TestTypedDict object at 0x7f25e7f160a0>
proto = <module 'msgspec.json' from '/builddir/build/BUILD/python-msgspec-0.19.0-build/BUILDROOT/usr/lib64/python3.14/site-packages/msgspec/json.py'>
use_typing_extensions = False

    @pytest.mark.parametrize("use_typing_extensions", [False, True])
    def test_total_partially_optional(self, proto, use_typing_extensions):
        if use_typing_extensions:
            tex = pytest.importorskip("typing_extensions")
            cls = tex.TypedDict
        else:
            cls = TypedDict
    
        class Base(cls):
            a: int
            b: str
    
        class Ex(Base, total=False):
            c: str
    
        dec = proto.Decoder(Ex)
    
        x = {"a": 1, "b": "two", "c": "extra"}
>       assert dec.decode(proto.encode(x)) == x
E       SystemError: <built-in method decode of msgspec.json.Decoder object at 0x7f25e89b85e0> returned NULL without setting an exception

tests/test_common.py:2106: SystemError
__________ TestTypedDict.test_total_partially_optional[msgpack-False] __________

self = <test_common.TestTypedDict object at 0x7f25e7fcab70>
proto = <module 'msgspec.msgpack' from '/builddir/build/BUILD/python-msgspec-0.19.0-build/BUILDROOT/usr/lib64/python3.14/site-packages/msgspec/msgpack.py'>
use_typing_extensions = False

    @pytest.mark.parametrize("use_typing_extensions", [False, True])
    def test_total_partially_optional(self, proto, use_typing_extensions):
        if use_typing_extensions:
            tex = pytest.importorskip("typing_extensions")
            cls = tex.TypedDict
        else:
            cls = TypedDict
    
        class Base(cls):
            a: int
            b: str
    
        class Ex(Base, total=False):
            c: str
    
        dec = proto.Decoder(Ex)
    
        x = {"a": 1, "b": "two", "c": "extra"}
>       assert dec.decode(proto.encode(x)) == x
E       SystemError: error return without exception set

tests/test_common.py:2106: SystemError
___________ TestTypedDict.test_required_and_notrequired[json-False] ____________

self = <test_common.TestTypedDict object at 0x7f25e813e180>
proto = <module 'msgspec.json' from '/builddir/build/BUILD/python-msgspec-0.19.0-build/BUILDROOT/usr/lib64/python3.14/site-packages/msgspec/json.py'>
use_typing_extensions = False

    @pytest.mark.parametrize("use_typing_extensions", [False, True])
    def test_required_and_notrequired(self, proto, use_typing_extensions):
        if use_typing_extensions:
            module = "typing_extensions"
        else:
            module = "typing"
    
        ns = pytest.importorskip(module)
    
        if not hasattr(ns, "Required"):
            pytest.skip(f"{module}.Required is not available")
    
        source = f"""
        from __future__ import annotations
        from {module} import TypedDict, Required, NotRequired
    
        class Base(TypedDict):
            a: int
            b: NotRequired[str]
    
        class Ex(Base, total=False):
            c: str
            d: Required[bool]
        """
    
        with temp_module(source) as mod:
            dec = proto.Decoder(mod.Ex)
    
            x = {"a": 1, "b": "two", "c": "extra", "d": False}
>           assert dec.decode(proto.encode(x)) == x
E           SystemError: <built-in method decode of msgspec.json.Decoder object at 0x7f25e91a0590> returned NULL without setting an exception

tests/test_common.py:2144: SystemError
__________ TestTypedDict.test_required_and_notrequired[msgpack-False] __________

self = <test_common.TestTypedDict object at 0x7f25e8199550>
proto = <module 'msgspec.msgpack' from '/builddir/build/BUILD/python-msgspec-0.19.0-build/BUILDROOT/usr/lib64/python3.14/site-packages/msgspec/msgpack.py'>
use_typing_extensions = False

    @pytest.mark.parametrize("use_typing_extensions", [False, True])
    def test_required_and_notrequired(self, proto, use_typing_extensions):
        if use_typing_extensions:
            module = "typing_extensions"
        else:
            module = "typing"
    
        ns = pytest.importorskip(module)
    
        if not hasattr(ns, "Required"):
            pytest.skip(f"{module}.Required is not available")
    
        source = f"""
        from __future__ import annotations
        from {module} import TypedDict, Required, NotRequired
    
        class Base(TypedDict):
            a: int
            b: NotRequired[str]
    
        class Ex(Base, total=False):
            c: str
            d: Required[bool]
        """
    
        with temp_module(source) as mod:
            dec = proto.Decoder(mod.Ex)
    
            x = {"a": 1, "b": "two", "c": "extra", "d": False}
>           assert dec.decode(proto.encode(x)) == x
E           SystemError: error return without exception set

tests/test_common.py:2144: SystemError

Do you also get those?

@JelleZijlstra
Copy link
Contributor Author

I didn't how, are you running the test suite exactly?

I get these failures which all seem related to getrefcount calls:

FAILED tests/test_common.py::TestGenericStruct::test_generic_struct_info_cached[json] - assert 3 == 4
FAILED tests/test_common.py::TestGenericStruct::test_generic_struct_info_cached[msgpack] - assert 3 == 4
FAILED tests/test_common.py::TestGenericDataclassOrAttrs::test_generic_info_cached[dataclass-json] - assert 3 == 4
FAILED tests/test_common.py::TestGenericDataclassOrAttrs::test_generic_info_cached[dataclass-msgpack] - assert 3 == 4
FAILED tests/test_convert.py::TestConvert::test_custom_input_type_works_with_any - assert 2 == 3
FAILED tests/test_convert.py::TestConvert::test_custom_input_type_works_with_custom - assert 2 == 3
FAILED tests/test_convert.py::TestConvert::test_custom_input_type_works_with_dec_hook - assert 1 == 2
FAILED tests/test_convert.py::TestInt::test_int_subclass - assert 2 == 3
FAILED tests/test_convert.py::TestBinary::test_bytes_subclass - AssertionError: assert 1 == 2
FAILED tests/test_convert.py::TestEnum::test_int_enum_int_subclass - assert 1 == 2
FAILED tests/test_json.py::TestDatetime::test_decode_timezone_cache - assert 2 == 3
FAILED tests/test_json.py::TestStruct::test_decode_struct - AssertionError: assert 2 == 3
FAILED tests/test_msgpack.py::TestTypedDecoder::test_decode_memoryview_zerocopy[bytes] - AssertionError: assert 2 == 3
FAILED tests/test_msgpack.py::TestTypedDecoder::test_decode_memoryview_zerocopy[memoryview] - AssertionError: assert 2 == 3
FAILED tests/test_msgpack.py::TestTypedDecoder::test_vartuple_lengths[1] - AssertionError: assert 2 == 3
FAILED tests/test_msgpack.py::TestTypedDecoder::test_vartuple_lengths[31] - AssertionError: assert 2 == 3
FAILED tests/test_msgpack.py::TestTypedDecoder::test_vartuple_lengths[32] - AssertionError: assert 2 == 3
FAILED tests/test_msgpack.py::TestTypedDecoder::test_vartuple_lengths[255] - AssertionError: assert 2 == 3
FAILED tests/test_msgpack.py::TestTypedDecoder::test_vartuple_lengths[256] - AssertionError: assert 2 == 3
FAILED tests/test_msgpack.py::TestTypedDecoder::test_vartuple_lengths[65535] - AssertionError: assert 2 == 3
FAILED tests/test_msgpack.py::TestTypedDecoder::test_vartuple_lengths[65536] - AssertionError: assert 2 == 3
FAILED tests/test_struct.py::test_struct_reference_counting - assert 2 == 3

To run it I do:

$ python -VV
Python 3.14.0b1+ (heads/3.14:2a089244f0d, May 26 2025, 08:38:42) [Clang 15.0.0 (clang-1500.3.9.4)]
$ python -m pytest -s -m "not mypy and not pyright" 

That's the latest tip of the 3.14 branch.

@JelleZijlstra
Copy link
Contributor Author

The failures you post feel like they wouldn't be related to retrieving annotations; the code I'm changing is just in gathering annotations at class creation time, and whatever is happening in those tests is after the class is already created.

@JelleZijlstra
Copy link
Contributor Author

Oh actually this is because of a bug in b1 that I fixed (python/cpython#133701); the fix will be in b2 which is about to go out.

I can reproduce it with the following change in the test:

$ git diff
diff --git a/tests/test_common.py b/tests/test_common.py
index 38898be..1c4c969 100644
--- a/tests/test_common.py
+++ b/tests/test_common.py
@@ -2099,6 +2099,7 @@ class TestTypedDict:
 
         class Ex(Base, total=False):
             c: str
+        Ex.__annotations__ = {"c": "str"}
 
         dec = proto.Decoder(Ex)
 

I do think that indicates a bug in msgspec; presumably it shouldn't crash even if people mess with the __annotations__ manually. I'll see if I can submit a fix.

@hroncok
Copy link
Contributor

hroncok commented May 26, 2025

The failures occurred on b1 indeed.

JelleZijlstra added a commit to JelleZijlstra/msgspec that referenced this pull request May 26, 2025
Fixes at least some of the failures reported in:
jcrist#852 (comment)

These were exposed by a bug in 3.14b1 where TypedDict reported
incorrect `__annotations__` but correct `__required_keys__`. msgspec
would crash in this case. The bug is reproducible on earlier Python
versions by manually manipulating attributes on a TypedDict class.

It's a pretty marginal bug but I would argue the extension should
be robust to this sort of edge case.
@JelleZijlstra
Copy link
Contributor Author

#853 for that one.

@hroncok
Copy link
Contributor

hroncok commented May 26, 2025

There are a lot of failures related to sys.getrefcount() but that seems to be an unrelated issue.

I opened #854

@ncoghlan
Copy link

I've confirmed via local testing that this PR fixes the Python 3.14 compatibility issue I noted in lmstudio-ai/lmstudio-python#153

@ngoldbaum
Copy link

Ping @jcrist - we're getting close to the final release date of Python 3.14. It'd be nice to have this merged to unblock further work to add 3.14 support here and in downstream packages that use msgspec.

@ngoldbaum
Copy link

Another ping here. I know @kumaraditya303 has a followup for this to add support for the free-threaded build which he plans to send in as soon as this PR is merged.

@ofek
Copy link
Collaborator

ofek commented Oct 8, 2025

We are also dependent on this library and eager to upgrade.

@btakita
Copy link

btakita commented Oct 10, 2025

@stefanor
Copy link

There are a lot of failures related to sys.getrefcount() but that seems to be an unrelated issue.

Those are also to be expected for Python 3.14. There is a new GC that will count a number of references, so any unit tests depending on sys.getrefcount() will have to deal with different values before and after 3.14. I've seen lots of if...else... branches in other libraries with similar tests.

@ncoghlan
Copy link

#854 is a draft PR to tackle the refcount tests just by allowing more relaxed measurements in general.

manzt added a commit to marimo-team/msgspec that referenced this pull request Oct 13, 2025
manzt added a commit to marimo-team/msgspec that referenced this pull request Oct 13, 2025
@kumaraditya303
Copy link
Contributor

Is @kumaraditya303's free-thread branch at
https://github.com/kumaraditya303/msgspec/tree/thread-safe?

Yes, I created #877 for adding free-threading support.

@ofek
Copy link
Collaborator

ofek commented Oct 15, 2025

I'm in talks with Jim about co-maintenance and should hopefully hear back in the next few days. If all goes well I plan to merge and release everything rapidly.

@sobolevn sobolevn mentioned this pull request Oct 16, 2025


if sys.version_info >= (3, 10):
from inspect import get_annotations as _get_class_annotations
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can't use this module as it introduces a large regression in terms of startup overhead:

❯ docker run --rm -it python:3.13 bash
root@fe224bc3d8c0:/# pip install -qqq msgspec
root@fe224bc3d8c0:/# python -m timeit -n 1 -r 1 "import msgspec"
1 loop, best of 1: 11.7 msec per loop
root@fe224bc3d8c0:/# python -m timeit -n 1 -r 1 "import inspect"
1 loop, best of 1: 7.84 msec per loop

It's worth noting that as of 3.14 this function moved to annotationlib (which your C code imports directly) and inspect re-exports it. That module loads twice as fast but ideally we wouldn't incur any unnecessary overhead:

❯ docker run --rm -it python:3.14 bash
root@8130942e2641:/# python -m timeit -n 1 -r 1 "import annotationlib"
1 loop, best of 1: 3.34 msec per loop

How would you recommend we proceed? I'm going to merge but this will be considered a known regression and will have to be fixed in a patch release right after.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussion in #880

@ofek
Copy link
Collaborator

ofek commented Oct 19, 2025

Merging, thanks a lot!

@ofek ofek merged commit 26cc7e7 into jcrist:main Oct 19, 2025
@ofek
Copy link
Collaborator

ofek commented Oct 19, 2025

Here's the issue tracking the performance regression #880

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python 3.14.0a3: TypeError: Extra positional arguments provided Update annotation parsing to work with PEP 649 in Python 3.14

8 participants