Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Backport NewType as it exists on py310+ #157

Merged
merged 2 commits into from
May 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 75 additions & 7 deletions src/test_typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import importlib
import inspect
import pickle
import re
import subprocess
import tempfile
import types
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down
66 changes: 65 additions & 1 deletion src/typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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. "
Copy link
Member

Choose a reason for hiding this comment

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

I feel like this would be clearer as

Suggested change
f"Cannot subclass an instance of NewType. "
f"Cannot subclass a NewType. "

as it may not be obvious to users that "an instance of NewType" refers to a type. I suppose this is the same error as CPython though, so up to you whether it's worth changing.

Copy link
Member Author

@AlexWaygood AlexWaygood May 8, 2023

Choose a reason for hiding this comment

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

The nice thing about the "an instance of NewType" wording is that it explains why you can't subclass the thing you're trying to subclass (it's an instance, not a class). But maybe the current phrasing is too terse for the explanation to be useful to people who aren't familiar with how NewType works.

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
Copy link
Member

Choose a reason for hiding this comment

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

We could allow this in theory, but it would probably be more confusing than useful to users to allow | on only a small subset of types.

Copy link
Member

Choose a reason for hiding this comment

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

On the other hand:

Python 3.7.16 (default, Dec 26 2022, 20:28:00) 
[Clang 14.0.0 (clang-1400.0.29.202)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from typing_extensions import Self
>>> Self | int
typing.Union[typing_extensions.Self, int]

Copy link
Member Author

Choose a reason for hiding this comment

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

On the other hand:

Python 3.7.16 (default, Dec 26 2022, 20:28:00) 
[Clang 14.0.0 (clang-1400.0.29.202)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from typing_extensions import Self
>>> Self | int
typing.Union[typing_extensions.Self, int]

Hahaha that seems wrong though :)

Copy link
Member Author

Choose a reason for hiding this comment

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

I guess there's no harm in implementing the methods on Python <3.10, but it feels really weird doing an ad-hoc backport of PEP 604 based on the classes we happen to be reimplementing in typing_extensions.

Copy link
Member Author

Choose a reason for hiding this comment

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

Seems like we don't backport PEP 604 for TypeVar or ParamSpec:

Python 3.7.16 (default, Jan 17 2023, 16:06:28) [MSC v.1916 64 bit (AMD64)] :: Anaconda, Inc. on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> import typing_extensions
>>> typing_extensions.TypeVar("T") | int
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for |: 'TypeVar' and 'type'
>>> typing_extensions.ParamSpec("P") | int
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for |: 'ParamSpec' and 'type'

Copy link
Member

Choose a reason for hiding this comment

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

Yes, let's leave this as you wrote it. | works on objects that use typing_extensions._SpecialForm on 3.7-3.9, which is arguably wrong but changing it would break compatibility, so let's just leave it as is.

Copy link
Member Author

Choose a reason for hiding this comment

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

Agreed that it's not worth it to remove __(r)or__ for objects where we've already accidentally backported PEP 604!


def __or__(self, other):
return typing.Union[self, other]

def __ror__(self, other):
return typing.Union[other, self]