From 7626068f99ea3c5ec922cd6b40fd7a6b35ea04aa Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Thu, 8 May 2025 17:29:22 +0200 Subject: [PATCH 1/7] Add support for sentinels (PEP 661) --- CHANGELOG.md | 1 + src/test_typing_extensions.py | 37 ++++++++++++++++++++++++++++ src/typing_extensions.py | 46 +++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ba8e152..6b1e37d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ New features: - Add `typing_extensions.Reader` and `typing_extensions.Writer`. Patch by Sebastian Rittau. - Fix tests for Python 3.14. Patch by Jelle Zijlstra. +- Add support for sentinels ([PEP 661](https://peps.python.org/pep-0661/)). # Release 4.13.2 (April 10, 2025) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index dc882f9f..ab3f2470 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -66,6 +66,7 @@ ReadOnly, Required, Self, + Sentinel, Set, Tuple, Type, @@ -9088,5 +9089,41 @@ def test_invalid_special_forms(self): self.assertIs(evaluate_forward_ref(typing.ForwardRef("ClassVar", is_argument=False), globals=vars(typing)), ClassVar) +class TestSentinels(BaseTestCase): + def test_sentinel_no_repr(self): + sentinel_no_repr = Sentinel('sentinel_no_repr') + + self.assertEqual(sentinel_no_repr._name, 'sentinel_no_repr') + self.assertEqual(repr(sentinel_no_repr), '') + + sentinel_no_repr_dots = Sentinel('Test.sentinel_no_repr') + + self.assertEqual(sentinel_no_repr_dots._name, 'Test.sentinel_no_repr') + self.assertEqual(repr(sentinel_no_repr), '') + + def test_sentinel_explicit_repr(self): + sentinel_explicit_repr = Sentinel('sentinel_explicit_repr', repr='explicit_repr') + + self.assertEqual(repr(sentinel_explicit_repr), 'explicit_repr') + + @skipIf(sys.version_info < (3, 10), reason='New unions not available in 3.9') + def test_sentinel_type_expression_union(self): + sentinel = Sentinel('sentinel') + + def func1(a: int | sentinel = sentinel): pass + def func2(a: sentinel | int = sentinel): pass + + self.assertEqual(func1.__annotations__['a'], Union[int, sentinel]) + self.assertEqual(func2.__annotations__['a'], Union[sentinel, int]) + + def test_sentinel_not_callable(self): + sentinel = Sentinel('sentinel') + with self.assertRaisesRegex( + TypeError, + "'Sentinel' object is not callable" + ): + sentinel() + + if __name__ == '__main__': main() diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 269ca650..4c3c847b 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -1,3 +1,4 @@ +# pyright: ignore import abc import builtins import collections @@ -89,6 +90,7 @@ 'overload', 'override', 'Protocol', + 'Sentinel', 'reveal_type', 'runtime', 'runtime_checkable', @@ -4222,6 +4224,50 @@ def evaluate_forward_ref( ) +class Sentinel: + """Create a unique sentinel object. + + *name* should be the fully-qualified name of the variable to which the + return value shall be assigned. + + *repr*, if supplied, will be used for the repr of the sentinel object. + If not provided, "" will be used (with any leading class names + removed). + """ + + def __init__( + self, + name: str, + repr: str | None = None, + ): + self._name = name + self._repr = repr if repr is not None else f'<{name.split(".")[-1]}>' + + def __repr__(self): + return self._repr + + def __reduce__(self): + return ( + type(self), + ( + self._name, + self._repr, + ) + ) + + if sys.version_info < (3, 11): + # The presence of this method convinces typing._type_check + # that Sentinels are types. + def __call__(self, *args, **kwargs): + raise TypeError(f"{type(self).__name__!r} object is not callable") + + def __or__(self, other): + return Union[self, other] + + def __ror__(self, other): + return Union[other, self] + + # Aliases for items that are in typing in all supported versions. # Explicitly assign these (rather than using `from typing import *` at the top), # so that we get a CI error if one of these is deleted from typing.py From 2be531efc102781a3a720d0339dc58c0914acf56 Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Fri, 9 May 2025 09:32:12 +0200 Subject: [PATCH 2/7] Remove __reduce__ --- src/typing_extensions.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 4c3c847b..c209d5aa 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -4246,15 +4246,6 @@ def __init__( def __repr__(self): return self._repr - def __reduce__(self): - return ( - type(self), - ( - self._name, - self._repr, - ) - ) - if sys.version_info < (3, 11): # The presence of this method convinces typing._type_check # that Sentinels are types. From eb0e089c2b8b8525df1a963983a87fd3e0ee553a Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Fri, 9 May 2025 10:45:38 +0200 Subject: [PATCH 3/7] Fix --- src/typing_extensions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index c209d5aa..00844ecc 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -4238,7 +4238,7 @@ class Sentinel: def __init__( self, name: str, - repr: str | None = None, + repr: typing.Optional[str] = None, ): self._name = name self._repr = repr if repr is not None else f'<{name.split(".")[-1]}>' From 6333253c00e444dc223bedfbf8889e1b2e897980 Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Fri, 9 May 2025 17:11:45 +0200 Subject: [PATCH 4/7] Disable pickling --- src/test_typing_extensions.py | 8 ++++++++ src/typing_extensions.py | 3 +++ 2 files changed, 11 insertions(+) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index ab3f2470..f197980a 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -9124,6 +9124,14 @@ def test_sentinel_not_callable(self): ): sentinel() + def test_sentinel_not_picklable(self): + sentinel = Sentinel('sentinel') + with self.assertRaisesRegex( + TypeError, + "Cannot pickle 'Sentinel' object" + ): + pickle.dumps(sentinel) + if __name__ == '__main__': main() diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 00844ecc..26c5ed22 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -4258,6 +4258,9 @@ def __or__(self, other): def __ror__(self, other): return Union[other, self] + def __getstate__(self): + raise TypeError(f"Cannot pickle {type(self).__name__!r} object") + # Aliases for items that are in typing in all supported versions. # Explicitly assign these (rather than using `from typing import *` at the top), From fe206a4449ee361da662778f34d0ef62d544fb61 Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Fri, 9 May 2025 18:34:50 +0200 Subject: [PATCH 5/7] Feedback --- doc/index.rst | 28 ++++++++++++++++++++++++++++ src/test_typing_extensions.py | 5 ----- src/typing_extensions.py | 9 +++------ 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/doc/index.rst b/doc/index.rst index 325182eb..f8e3a27b 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -1017,6 +1017,34 @@ Capsule objects .. versionadded:: 4.12.0 +Sentinel objects +~~~~~~~~~~~~~~~~ + +.. class:: Sentinel(name, repr=None) + + A type used to define sentinel values. The *name* argument should be the + name of the variable to which the return value shall be assigned. + + If *repr* is provided, it will be used for the :meth:`~object.__repr__` + of the sentinel object. If not provided, ``""`` will be used. + + Example:: + + >>> from typing_extensions import Sentinel, assert_type + >>> MISSING = Sentinel('MISSING') + >>> def func(arg: int | MISSING = MISSING) -> None: + ... if arg is MISSING: + ... assert_type(arg, MISSING) + ... else: + ... assert_type(arg, int) + ... + >>> func(MISSING) + + .. versionadded:: 4.14.0 + + See :pep:`661` + + Pure aliases ~~~~~~~~~~~~ diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index f197980a..f1cfd319 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -9096,11 +9096,6 @@ def test_sentinel_no_repr(self): self.assertEqual(sentinel_no_repr._name, 'sentinel_no_repr') self.assertEqual(repr(sentinel_no_repr), '') - sentinel_no_repr_dots = Sentinel('Test.sentinel_no_repr') - - self.assertEqual(sentinel_no_repr_dots._name, 'Test.sentinel_no_repr') - self.assertEqual(repr(sentinel_no_repr), '') - def test_sentinel_explicit_repr(self): sentinel_explicit_repr = Sentinel('sentinel_explicit_repr', repr='explicit_repr') diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 26c5ed22..4df92004 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -1,4 +1,3 @@ -# pyright: ignore import abc import builtins import collections @@ -4227,12 +4226,10 @@ def evaluate_forward_ref( class Sentinel: """Create a unique sentinel object. - *name* should be the fully-qualified name of the variable to which the - return value shall be assigned. + *name* should be the name of the variable to which the return value shall be assigned. *repr*, if supplied, will be used for the repr of the sentinel object. - If not provided, "" will be used (with any leading class names - removed). + If not provided, "" will be used. """ def __init__( @@ -4241,7 +4238,7 @@ def __init__( repr: typing.Optional[str] = None, ): self._name = name - self._repr = repr if repr is not None else f'<{name.split(".")[-1]}>' + self._repr = repr if repr is not None else f'<{name}>' def __repr__(self): return self._repr From a724058ee5b700004a1922eb2f095cbbfb10b720 Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Tue, 13 May 2025 09:30:22 +0200 Subject: [PATCH 6/7] lint --- src/typing_extensions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 120d4352..d4e92a4c 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -4238,10 +4238,10 @@ def __call__(self, *args, **kwargs): raise TypeError(f"{type(self).__name__!r} object is not callable") def __or__(self, other): - return Union[self, other] + return typing.Union[self, other] def __ror__(self, other): - return Union[other, self] + return typing.Union[other, self] def __getstate__(self): raise TypeError(f"Cannot pickle {type(self).__name__!r} object") From 03c18eb8750768237cc823844b4ce7ed3c810a37 Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Tue, 13 May 2025 09:32:35 +0200 Subject: [PATCH 7/7] Update changelog --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5e38e06..92a19a3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,8 @@ New features: Patch by [Victorien Plot](https://github.com/Viicos). - Add `typing_extensions.Reader` and `typing_extensions.Writer`. Patch by Sebastian Rittau. -- Add support for sentinels ([PEP 661](https://peps.python.org/pep-0661/)). +- Add support for sentinels ([PEP 661](https://peps.python.org/pep-0661/)). Patch by + [Victorien Plot](https://github.com/Viicos). # Release 4.13.2 (April 10, 2025)