diff --git a/CHANGELOG.md b/CHANGELOG.md index f117f390..bc5abe74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,9 @@ - Constructing a call-based `TypedDict` using keyword arguments for the fields now causes a `DeprecationWarning` to be emitted. This matches the behaviour of `typing.TypedDict` on 3.11 and 3.12. +- Backport the implementation of `NewType` from 3.10 (where it is implemented + as a class rather than a function). This allows user-defined `NewType`s to be + pickled. Patch by Alex Waygood. # Release 4.5.0 (February 14, 2023) diff --git a/README.md b/README.md index b7e6a7a6..11434d18 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,9 @@ Certain objects were changed after they were added to `typing`, and caching bug was fixed in 3.10.1/3.9.8. The `typing_extensions` version flattens and deduplicates parameters on all Python versions, and the caching bug is also fixed on all versions. +- `NewType` has been in the `typing` module since Python 3.5.2, but + user-defined `NewType`s are only pickleable on Python 3.10+. + `typing_extensions.NewType` backports this feature to all Python versions. There are a few types whose interface was modified between different versions of typing. For example, `typing.Sequence` was modified to diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 4a5d3a14..469c31b6 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -11,6 +11,7 @@ import importlib import inspect import pickle +import re import subprocess import tempfile import types @@ -1539,23 +1540,90 @@ def foo(a: A) -> Optional[BaseException]: class NewTypeTests(BaseTestCase): + @classmethod + def setUpClass(cls): + global UserId + UserId = NewType('UserId', int) + cls.UserName = NewType(cls.__qualname__ + '.UserName', str) + + @classmethod + def tearDownClass(cls): + global UserId + del UserId + del cls.UserName def test_basic(self): - UserId = NewType('UserId', int) - UserName = NewType('UserName', str) self.assertIsInstance(UserId(5), int) - self.assertIsInstance(UserName('Joe'), str) + self.assertIsInstance(self.UserName('Joe'), str) self.assertEqual(UserId(5) + 1, 6) def test_errors(self): - UserId = NewType('UserId', int) - UserName = NewType('UserName', str) with self.assertRaises(TypeError): issubclass(UserId, int) with self.assertRaises(TypeError): - class D(UserName): + class D(UserId): pass + @skipUnless(TYPING_3_10_0, "PEP 604 has yet to be") + def test_or(self): + for cls in (int, self.UserName): + with self.subTest(cls=cls): + self.assertEqual(UserId | cls, Union[UserId, cls]) + self.assertEqual(cls | UserId, Union[cls, UserId]) + + self.assertEqual(get_args(UserId | cls), (UserId, cls)) + self.assertEqual(get_args(cls | UserId), (cls, UserId)) + + def test_special_attrs(self): + self.assertEqual(UserId.__name__, 'UserId') + self.assertEqual(UserId.__qualname__, 'UserId') + self.assertEqual(UserId.__module__, __name__) + self.assertEqual(UserId.__supertype__, int) + + UserName = self.UserName + self.assertEqual(UserName.__name__, 'UserName') + self.assertEqual(UserName.__qualname__, + self.__class__.__qualname__ + '.UserName') + self.assertEqual(UserName.__module__, __name__) + self.assertEqual(UserName.__supertype__, str) + + def test_repr(self): + self.assertEqual(repr(UserId), f'{__name__}.UserId') + self.assertEqual(repr(self.UserName), + f'{__name__}.{self.__class__.__qualname__}.UserName') + + def test_pickle(self): + UserAge = NewType('UserAge', float) + for proto in range(pickle.HIGHEST_PROTOCOL + 1): + with self.subTest(proto=proto): + pickled = pickle.dumps(UserId, proto) + loaded = pickle.loads(pickled) + self.assertIs(loaded, UserId) + + pickled = pickle.dumps(self.UserName, proto) + loaded = pickle.loads(pickled) + self.assertIs(loaded, self.UserName) + + with self.assertRaises(pickle.PicklingError): + pickle.dumps(UserAge, proto) + + def test_missing__name__(self): + code = ("import typing_extensions\n" + "NT = typing_extensions.NewType('NT', int)\n" + ) + exec(code, {}) + + def test_error_message_when_subclassing(self): + with self.assertRaisesRegex( + TypeError, + re.escape( + "Cannot subclass an instance of NewType. Perhaps you were looking for: " + "`ProUserId = NewType('ProUserId', UserId)`" + ) + ): + class ProUserId(UserId): + ... + class Coordinate(Protocol): x: int @@ -3849,7 +3917,7 @@ def test_typing_extensions_defers_when_possible(self): if sys.version_info < (3, 10, 1): exclude |= {"Literal"} if sys.version_info < (3, 11): - exclude |= {'final', 'Any'} + exclude |= {'final', 'Any', 'NewType'} if sys.version_info < (3, 12): exclude |= { 'Protocol', 'runtime_checkable', 'SupportsAbs', 'SupportsBytes', diff --git a/src/typing_extensions.py b/src/typing_extensions.py index cce31f84..dd12cfb8 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -440,7 +440,6 @@ def clear_overloads(): Counter = typing.Counter ChainMap = typing.ChainMap AsyncGenerator = typing.AsyncGenerator -NewType = typing.NewType Text = typing.Text TYPE_CHECKING = typing.TYPE_CHECKING @@ -2546,3 +2545,68 @@ class Baz(list[str]): ... raise TypeError( f'Expected an instance of type, not {type(__cls).__name__!r}' ) from None + + +# NewType is a class on Python 3.10+, making it pickleable +# The error message for subclassing instances of NewType was improved on 3.11+ +if sys.version_info >= (3, 11): + NewType = typing.NewType +else: + class NewType: + """NewType creates simple unique types with almost zero + runtime overhead. NewType(name, tp) is considered a subtype of tp + by static type checkers. At runtime, NewType(name, tp) returns + a dummy callable that simply returns its argument. Usage:: + UserId = NewType('UserId', int) + def name_by_id(user_id: UserId) -> str: + ... + UserId('user') # Fails type check + name_by_id(42) # Fails type check + name_by_id(UserId(42)) # OK + num = UserId(5) + 1 # type: int + """ + + def __call__(self, obj): + return obj + + def __init__(self, name, tp): + self.__qualname__ = name + if '.' in name: + name = name.rpartition('.')[-1] + self.__name__ = name + self.__supertype__ = tp + def_mod = _caller() + if def_mod != 'typing_extensions': + self.__module__ = def_mod + + def __mro_entries__(self, bases): + # We defined __mro_entries__ to get a better error message + # if a user attempts to subclass a NewType instance. bpo-46170 + supercls_name = self.__name__ + + class Dummy: + def __init_subclass__(cls): + subcls_name = cls.__name__ + raise TypeError( + f"Cannot subclass an instance of NewType. " + f"Perhaps you were looking for: " + f"`{subcls_name} = NewType({subcls_name!r}, {supercls_name})`" + ) + + return (Dummy,) + + def __repr__(self): + return f'{self.__module__}.{self.__qualname__}' + + def __reduce__(self): + return self.__qualname__ + + if sys.version_info >= (3, 10): + # PEP 604 methods + # It doesn't make sense to have these methods on Python <3.10 + + def __or__(self, other): + return typing.Union[self, other] + + def __ror__(self, other): + return typing.Union[other, self]