From 610ad76f7c4b8ff67deb6d688c00ccc7530ef363 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Mon, 5 May 2025 07:52:14 -0700 Subject: [PATCH 1/5] Fix 3.14 --- .github/workflows/ci.yml | 2 -- CHANGELOG.md | 1 + src/test_typing_extensions.py | 68 ++++++++++++++++++++++++++++++----- src/typing_extensions.py | 63 ++++++++++++++++++++++++++------ 4 files changed, 113 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 451fc313..3df842da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,7 +70,6 @@ jobs: cd src python --version # just to make sure we're running the right one python -m unittest test_typing_extensions.py - continue-on-error: ${{ endsWith(matrix.python-version, '-dev') }} - name: Test CPython typing test suite # Test suite fails on PyPy even without typing_extensions @@ -80,7 +79,6 @@ jobs: # Run the typing test suite from CPython with typing_extensions installed, # because we monkeypatch typing under some circumstances. python -c 'import typing_extensions; import test.__main__' test_typing -v - continue-on-error: ${{ endsWith(matrix.python-version, '-dev') }} linting: name: Lint diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f9523f1..5ba8e152 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ New features: Patch by [Victorien Plot](https://github.com/Viicos). - Add `typing_extensions.Reader` and `typing_extensions.Writer`. Patch by Sebastian Rittau. +- Fix tests for Python 3.14. Patch by Jelle Zijlstra. # Release 4.13.2 (April 10, 2025) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 92e1e4cd..0a3d1929 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -5152,6 +5152,65 @@ def test_inline(self): self.assertIs(type(inst), dict) self.assertEqual(inst["a"], 1) + def test_annotations(self): + # _type_check is applied + with self.assertRaisesRegex(TypeError, "Plain typing.Final is not valid as type argument"): + class X(TypedDict): + a: Final + + # _type_convert is applied + class Y(TypedDict): + a: None + b: "int" + if sys.version_info >= (3, 14): + import annotationlib + from test.support import EqualToForwardRef + + fwdref = EqualToForwardRef('int', module=__name__) + self.assertEqual(Y.__annotations__, {'a': type(None), 'b': fwdref}) + self.assertEqual(Y.__annotate__(annotationlib.Format.FORWARDREF), {'a': type(None), 'b': fwdref}) + else: + self.assertEqual(Y.__annotations__, {'a': type(None), 'b': typing.ForwardRef('int', module=__name__)}) + + @skipUnless(TYPING_3_14_0, "Only supported on 3.14") + def test_delayed_type_check(self): + # _type_check is also applied later + class Z(TypedDict): + a: undefined + + with self.assertRaises(NameError): + Z.__annotations__ + + undefined = Final + with self.assertRaisesRegex(TypeError, "Plain typing.Final is not valid as type argument"): + Z.__annotations__ + + undefined = None + self.assertEqual(Z.__annotations__, {'a': type(None)}) + + @skipUnless(TYPING_3_14_0, "Only supported on 3.14") + def test_deferred_evaluation(self): + class A(TypedDict): + x: NotRequired[undefined] + y: ReadOnly[undefined] + z: Required[undefined] + + self.assertEqual(A.__required_keys__, frozenset({'y', 'z'})) + self.assertEqual(A.__optional_keys__, frozenset({'x'})) + self.assertEqual(A.__readonly_keys__, frozenset({'y'})) + self.assertEqual(A.__mutable_keys__, frozenset({'x', 'z'})) + + with self.assertRaises(NameError): + A.__annotations__ + + import annotationlib + self.assertEqual( + A.__annotate__(annotationlib.Format.STRING), + {'x': 'NotRequired[undefined]', 'y': 'ReadOnly[undefined]', + 'z': 'Required[undefined]'}, + ) + + class AnnotatedTests(BaseTestCase): def test_repr(self): @@ -8906,13 +8965,6 @@ def test_fwdref_with_globals(self): obj = object() self.assertIs(evaluate_forward_ref(typing.ForwardRef("int"), globals={"int": obj}), obj) - def test_fwdref_value_is_cached(self): - fr = typing.ForwardRef("hello") - with self.assertRaises(NameError): - evaluate_forward_ref(fr) - self.assertIs(evaluate_forward_ref(fr, globals={"hello": str}), str) - self.assertIs(evaluate_forward_ref(fr), str) - def test_fwdref_with_owner(self): self.assertEqual( evaluate_forward_ref(typing.ForwardRef("Counter[int]"), owner=collections), @@ -8956,7 +9008,7 @@ class Y(Generic[Tx]): self.assertEqual(get_args(evaluated_ref1b), (Y[Tx],)) with self.subTest("nested string of TypeVar"): - evaluated_ref2 = evaluate_forward_ref(typing.ForwardRef("""Y["Y['Tx']"]"""), locals={"Y": Y}) + evaluated_ref2 = evaluate_forward_ref(typing.ForwardRef("""Y["Y['Tx']"]"""), locals={"Y": Y, "Tx": Tx}) self.assertEqual(get_origin(evaluated_ref2), Y) self.assertEqual(get_args(evaluated_ref2), (Y[Tx],)) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 04fa2cb8..e8dfa6d6 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -14,6 +14,9 @@ import typing import warnings +if sys.version_info >= (3, 14): + import annotationlib + __all__ = [ # Super-special typing primitives. 'Any', @@ -1018,21 +1021,27 @@ def __new__(cls, name, bases, ns, *, total=True, closed=None, tp_dict.__orig_bases__ = bases annotations = {} + own_annotate = None if "__annotations__" in ns: own_annotations = ns["__annotations__"] - elif "__annotate__" in ns: - # TODO: Use inspect.VALUE here, and make the annotations lazily evaluated - own_annotations = ns["__annotate__"](1) + elif sys.version_info >= (3, 14): + own_annotate = annotationlib.get_annotate_from_class_namespace(ns) + if own_annotate is not None: + own_annotations = annotationlib.call_annotate_function( + own_annotate, annotationlib.Format.FORWARDREF, owner=tp_dict + ) + else: + own_annotations = {} else: own_annotations = {} msg = "TypedDict('Name', {f0: t0, f1: t1, ...}); each t must be a type" if _TAKES_MODULE: - own_annotations = { + own_checked_annotations = { n: typing._type_check(tp, msg, module=tp_dict.__module__) for n, tp in own_annotations.items() } else: - own_annotations = { + own_checked_annotations = { n: typing._type_check(tp, msg) for n, tp in own_annotations.items() } @@ -1045,7 +1054,8 @@ def __new__(cls, name, bases, ns, *, total=True, closed=None, for base in bases: base_dict = base.__dict__ - annotations.update(base_dict.get('__annotations__', {})) + if sys.version_info <= (3, 14): + annotations.update(base_dict.get('__annotations__', {})) required_keys.update(base_dict.get('__required_keys__', ())) optional_keys.update(base_dict.get('__optional_keys__', ())) readonly_keys.update(base_dict.get('__readonly_keys__', ())) @@ -1055,8 +1065,8 @@ def __new__(cls, name, bases, ns, *, total=True, closed=None, # is retained for backwards compatibility, but only for Python # 3.13 and lower. if (closed and sys.version_info < (3, 14) - and "__extra_items__" in own_annotations): - annotation_type = own_annotations.pop("__extra_items__") + and "__extra_items__" in own_checked_annotations): + annotation_type = own_checked_annotations.pop("__extra_items__") qualifiers = set(_get_typeddict_qualifiers(annotation_type)) if Required in qualifiers: raise TypeError( @@ -1070,8 +1080,8 @@ def __new__(cls, name, bases, ns, *, total=True, closed=None, ) extra_items_type = annotation_type - annotations.update(own_annotations) - for annotation_key, annotation_type in own_annotations.items(): + annotations.update(own_checked_annotations) + for annotation_key, annotation_type in own_checked_annotations.items(): qualifiers = set(_get_typeddict_qualifiers(annotation_type)) if Required in qualifiers: @@ -1089,7 +1099,38 @@ def __new__(cls, name, bases, ns, *, total=True, closed=None, mutable_keys.add(annotation_key) readonly_keys.discard(annotation_key) - tp_dict.__annotations__ = annotations + if sys.version_info >= (3, 14): + def __annotate__(format): + annos = {} + for base in bases: + if base is Generic: + continue + base_annotate = base.__annotate__ + if base_annotate is None: + continue + base_annos = annotationlib.call_annotate_function( + base.__annotate__, format, owner=base) + annos.update(base_annos) + if own_annotate is not None: + own = annotationlib.call_annotate_function( + own_annotate, format, owner=tp_dict) + if format != annotationlib.Format.STRING: + own = { + n: typing._type_check(tp, msg, module=tp_dict.__module__) + for n, tp in own.items() + } + elif format == annotationlib.Format.STRING: + own = annotationlib.annotations_to_string(own_annotations) + elif format in (annotationlib.Format.FORWARDREF, annotationlib.Format.VALUE): + own = own_checked_annotations + else: + raise NotImplementedError(format) + annos.update(own) + return annos + + tp_dict.__annotate__ = __annotate__ + else: + tp_dict.__annotations__ = annotations tp_dict.__required_keys__ = frozenset(required_keys) tp_dict.__optional_keys__ = frozenset(optional_keys) tp_dict.__readonly_keys__ = frozenset(readonly_keys) From 83ff33aad4e53ecbd026bfdd683b3c460708e045 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Mon, 5 May 2025 08:03:32 -0700 Subject: [PATCH 2/5] fix lint --- src/test_typing_extensions.py | 10 +++++----- src/typing_extensions.py | 14 +++++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 0a3d1929..53b61b59 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -5176,7 +5176,7 @@ class Y(TypedDict): def test_delayed_type_check(self): # _type_check is also applied later class Z(TypedDict): - a: undefined + a: undefined # noqa: F821 with self.assertRaises(NameError): Z.__annotations__ @@ -5185,15 +5185,15 @@ class Z(TypedDict): with self.assertRaisesRegex(TypeError, "Plain typing.Final is not valid as type argument"): Z.__annotations__ - undefined = None + undefined = None # noqa: F841 self.assertEqual(Z.__annotations__, {'a': type(None)}) @skipUnless(TYPING_3_14_0, "Only supported on 3.14") def test_deferred_evaluation(self): class A(TypedDict): - x: NotRequired[undefined] - y: ReadOnly[undefined] - z: Required[undefined] + x: NotRequired[undefined] # noqa: F821 + y: ReadOnly[undefined] # noqa: F821 + z: Required[undefined] # noqa: F821 self.assertEqual(A.__required_keys__, frozenset({'y', 'z'})) self.assertEqual(A.__optional_keys__, frozenset({'x'})) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index e8dfa6d6..269ca650 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -1025,10 +1025,14 @@ def __new__(cls, name, bases, ns, *, total=True, closed=None, if "__annotations__" in ns: own_annotations = ns["__annotations__"] elif sys.version_info >= (3, 14): - own_annotate = annotationlib.get_annotate_from_class_namespace(ns) + if hasattr(annotationlib, "get_annotate_from_class_namespace"): + own_annotate = annotationlib.get_annotate_from_class_namespace(ns) + else: + # 3.14.0a7 and earlier + own_annotate = ns.get("__annotate__") if own_annotate is not None: own_annotations = annotationlib.call_annotate_function( - own_annotate, annotationlib.Format.FORWARDREF, owner=tp_dict + own_annotate, Format.FORWARDREF, owner=tp_dict ) else: own_annotations = {} @@ -1114,14 +1118,14 @@ def __annotate__(format): if own_annotate is not None: own = annotationlib.call_annotate_function( own_annotate, format, owner=tp_dict) - if format != annotationlib.Format.STRING: + if format != Format.STRING: own = { n: typing._type_check(tp, msg, module=tp_dict.__module__) for n, tp in own.items() } - elif format == annotationlib.Format.STRING: + elif format == Format.STRING: own = annotationlib.annotations_to_string(own_annotations) - elif format in (annotationlib.Format.FORWARDREF, annotationlib.Format.VALUE): + elif format in (Format.FORWARDREF, Format.VALUE): own = own_checked_annotations else: raise NotImplementedError(format) From d932665dc4a97d25e06b77063521baf97e89aceb Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Mon, 5 May 2025 08:06:31 -0700 Subject: [PATCH 3/5] fix 3.9/3.10 --- src/test_typing_extensions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 53b61b59..dc057bb2 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -5154,9 +5154,9 @@ def test_inline(self): def test_annotations(self): # _type_check is applied - with self.assertRaisesRegex(TypeError, "Plain typing.Final is not valid as type argument"): + with self.assertRaisesRegex(TypeError, "Plain typing.Optional is not valid as type argument"): class X(TypedDict): - a: Final + a: Optional # _type_convert is applied class Y(TypedDict): From 34293d775b66bc6c6370b517d11f80719fb4d316 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Mon, 5 May 2025 08:20:26 -0700 Subject: [PATCH 4/5] fix a7? --- src/test_typing_extensions.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index dc057bb2..92dc4eab 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -103,6 +103,11 @@ runtime_checkable, ) +if sys.version_info >= (3, 14): + from test.support import EqualToForwardRef +else: + EqualToForwardRef = typing.ForwardRef + NoneType = type(None) T = TypeVar("T") KT = TypeVar("KT") @@ -5164,7 +5169,6 @@ class Y(TypedDict): b: "int" if sys.version_info >= (3, 14): import annotationlib - from test.support import EqualToForwardRef fwdref = EqualToForwardRef('int', module=__name__) self.assertEqual(Y.__annotations__, {'a': type(None), 'b': fwdref}) @@ -6022,7 +6026,7 @@ def test_substitution(self): U2 = Unpack[Ts] self.assertEqual(C2[U1], (str, int, str)) self.assertEqual(C2[U2], (str, Unpack[Ts])) - self.assertEqual(C2["U2"], (str, typing.ForwardRef("U2"))) + self.assertEqual(C2["U2"], (str, EqualToForwardRef("U2"))) if (3, 12, 0) <= sys.version_info < (3, 12, 4): with self.assertRaises(AssertionError): @@ -7309,8 +7313,8 @@ def test_or(self): self.assertEqual(X | "x", Union[X, "x"]) self.assertEqual("x" | X, Union["x", X]) # make sure the order is correct - self.assertEqual(get_args(X | "x"), (X, typing.ForwardRef("x"))) - self.assertEqual(get_args("x" | X), (typing.ForwardRef("x"), X)) + self.assertEqual(get_args(X | "x"), (X, EqualToForwardRef("x"))) + self.assertEqual(get_args("x" | X), (EqualToForwardRef("x"), X)) def test_union_constrained(self): A = TypeVar('A', str, bytes) @@ -8878,7 +8882,7 @@ class X: type_params=None, format=Format.FORWARDREF, ) - self.assertEqual(evaluated_ref, typing.ForwardRef("doesnotexist2")) + self.assertEqual(evaluated_ref, EqualToForwardRef("doesnotexist2")) def test_evaluate_with_type_params(self): # Use a T name that is not in globals From 993ac2fd77b89577a137819ffdb8478d25958742 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Mon, 5 May 2025 08:24:26 -0700 Subject: [PATCH 5/5] no support --- src/test_typing_extensions.py | 47 +++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 92dc4eab..dc882f9f 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -103,11 +103,6 @@ runtime_checkable, ) -if sys.version_info >= (3, 14): - from test.support import EqualToForwardRef -else: - EqualToForwardRef = typing.ForwardRef - NoneType = type(None) T = TypeVar("T") KT = TypeVar("KT") @@ -444,6 +439,48 @@ def assertNotIsSubclass(self, cls, class_or_tuple, msg=None): raise self.failureException(message) +class EqualToForwardRef: + """Helper to ease use of annotationlib.ForwardRef in tests. + + This checks only attributes that can be set using the constructor. + + """ + + def __init__( + self, + arg, + *, + module=None, + owner=None, + is_class=False, + ): + self.__forward_arg__ = arg + self.__forward_is_class__ = is_class + self.__forward_module__ = module + self.__owner__ = owner + + def __eq__(self, other): + if not isinstance(other, (EqualToForwardRef, typing.ForwardRef)): + return NotImplemented + if sys.version_info >= (3, 14) and self.__owner__ != other.__owner__: + return False + return ( + self.__forward_arg__ == other.__forward_arg__ + and self.__forward_module__ == other.__forward_module__ + and self.__forward_is_class__ == other.__forward_is_class__ + ) + + def __repr__(self): + extra = [] + if self.__forward_module__ is not None: + extra.append(f", module={self.__forward_module__!r}") + if self.__forward_is_class__: + extra.append(", is_class=True") + if sys.version_info >= (3, 14) and self.__owner__ is not None: + extra.append(f", owner={self.__owner__!r}") + return f"EqualToForwardRef({self.__forward_arg__!r}{''.join(extra)})" + + class Employee: pass