Skip to content

Fix annotations support on 3.14#852

Merged
ofek merged 1 commit intojcrist:mainfrom
JelleZijlstra:314-annos
Oct 19, 2025
Merged

Fix annotations support on 3.14#852
ofek merged 1 commit intojcrist:mainfrom
JelleZijlstra:314-annos

Conversation

@JelleZijlstra
Copy link
Copy Markdown
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.
Comment thread msgspec/_core.c
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
Copy Markdown
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.

Comment thread msgspec/_utils.py
value = _eval_type(value, cls_locals, cls_globals)
if mapping is not None:
value = _apply_params(value, mapping)
if value is None:
Copy link
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
Contributor Author

#853 for that one.

@hroncok
Copy link
Copy Markdown
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
Copy Markdown

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
Copy Markdown

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
Copy Markdown

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
Copy Markdown
Collaborator

ofek commented Oct 8, 2025

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

@btakita
Copy link
Copy Markdown

btakita commented Oct 10, 2025

@stefanor
Copy link
Copy Markdown

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
Copy Markdown

#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
Copy Markdown
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
Copy Markdown
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
Comment thread msgspec/_utils.py


if sys.version_info >= (3, 10):
from inspect import get_annotations as _get_class_annotations
Copy link
Copy Markdown
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
Copy Markdown
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
Copy Markdown
Collaborator

ofek commented Oct 19, 2025

Merging, thanks a lot!

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

ofek commented Oct 19, 2025

Here's the issue tracking the performance regression #880

manzt pushed a commit to marimo-team/msgspec that referenced this pull request Nov 14, 2025
manzt added a commit to marimo-team/msgspec that referenced this pull request Nov 14, 2025
* Fix crash when TypedDict contains incorrect metadata (jcrist#853)

* Fix annotations support on 3.14 (jcrist#852)

* Relax all getrefcount tests to allow lower numbers (jcrist#854)

* Add 3.14 to CI (jcrist#888)

* add free-threading support  (jcrist#877)

Co-authored-by: Jelle Zijlstra <jelle.zijlstra@gmail.com>

* Enable Python 3.14 tests in CI

Remove cp314-* from CIBW_TEST_SKIP to allow Python 3.14 tests to run.
Previously, 3.14 wheels were built but tests were skipped.

---------

Co-authored-by: Jelle Zijlstra <jelle.zijlstra@gmail.com>
Co-authored-by: Miro Hrončok <miro@hroncok.cz>
Co-authored-by: Ofek Lev <ofekmeister@gmail.com>
Co-authored-by: Kumar Aditya <kumaraditya@python.org>
@ofek ofek mentioned this pull request Nov 20, 2025
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