gh-117516: Implement typing.TypeIs (#117517)

See PEP 742.

Co-authored-by: Carl Meyer <carl@oddbird.net>
Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com>
This commit is contained in:
Jelle Zijlstra 2024-04-09 06:50:37 -04:00 committed by GitHub
parent 57183241af
commit f2132fcd2a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 235 additions and 38 deletions

View File

@ -1385,22 +1385,23 @@ These can be used as types in annotations. They all support subscription using
.. versionadded:: 3.9
.. data:: TypeGuard
.. data:: TypeIs
Special typing construct for marking user-defined type guard functions.
Special typing construct for marking user-defined type predicate functions.
``TypeGuard`` can be used to annotate the return type of a user-defined
type guard function. ``TypeGuard`` only accepts a single type argument.
At runtime, functions marked this way should return a boolean.
``TypeIs`` can be used to annotate the return type of a user-defined
type predicate function. ``TypeIs`` only accepts a single type argument.
At runtime, functions marked this way should return a boolean and take at
least one positional argument.
``TypeGuard`` aims to benefit *type narrowing* -- a technique used by static
``TypeIs`` aims to benefit *type narrowing* -- a technique used by static
type checkers to determine a more precise type of an expression within a
program's code flow. Usually type narrowing is done by analyzing
conditional code flow and applying the narrowing to a block of code. The
conditional expression here is sometimes referred to as a "type guard"::
conditional expression here is sometimes referred to as a "type predicate"::
def is_str(val: str | float):
# "isinstance" type guard
# "isinstance" type predicate
if isinstance(val, str):
# Type of ``val`` is narrowed to ``str``
...
@ -1409,8 +1410,73 @@ These can be used as types in annotations. They all support subscription using
...
Sometimes it would be convenient to use a user-defined boolean function
as a type guard. Such a function should use ``TypeGuard[...]`` as its
return type to alert static type checkers to this intention.
as a type predicate. Such a function should use ``TypeIs[...]`` or
:data:`TypeGuard` as its return type to alert static type checkers to
this intention. ``TypeIs`` usually has more intuitive behavior than
``TypeGuard``, but it cannot be used when the input and output types
are incompatible (e.g., ``list[object]`` to ``list[int]``) or when the
function does not return ``True`` for all instances of the narrowed type.
Using ``-> TypeIs[NarrowedType]`` tells the static type checker that for a given
function:
1. The return value is a boolean.
2. If the return value is ``True``, the type of its argument
is the intersection of the argument's original type and ``NarrowedType``.
3. If the return value is ``False``, the type of its argument
is narrowed to exclude ``NarrowedType``.
For example::
from typing import assert_type, final, TypeIs
class Parent: pass
class Child(Parent): pass
@final
class Unrelated: pass
def is_parent(val: object) -> TypeIs[Parent]:
return isinstance(val, Parent)
def run(arg: Child | Unrelated):
if is_parent(arg):
# Type of ``arg`` is narrowed to the intersection
# of ``Parent`` and ``Child``, which is equivalent to
# ``Child``.
assert_type(arg, Child)
else:
# Type of ``arg`` is narrowed to exclude ``Parent``,
# so only ``Unrelated`` is left.
assert_type(arg, Unrelated)
The type inside ``TypeIs`` must be consistent with the type of the
function's argument; if it is not, static type checkers will raise
an error. An incorrectly written ``TypeIs`` function can lead to
unsound behavior in the type system; it is the user's responsibility
to write such functions in a type-safe manner.
If a ``TypeIs`` function is a class or instance method, then the type in
``TypeIs`` maps to the type of the second parameter after ``cls`` or
``self``.
In short, the form ``def foo(arg: TypeA) -> TypeIs[TypeB]: ...``,
means that if ``foo(arg)`` returns ``True``, then ``arg`` is an instance
of ``TypeB``, and if it returns ``False``, it is not an instance of ``TypeB``.
``TypeIs`` also works with type variables. For more information, see
:pep:`742` (Narrowing types with ``TypeIs``).
.. versionadded:: 3.13
.. data:: TypeGuard
Special typing construct for marking user-defined type predicate functions.
Type predicate functions are user-defined functions that return whether their
argument is an instance of a particular type.
``TypeGuard`` works similarly to :data:`TypeIs`, but has subtly different
effects on type checking behavior (see below).
Using ``-> TypeGuard`` tells the static type checker that for a given
function:
@ -1419,6 +1485,8 @@ These can be used as types in annotations. They all support subscription using
2. If the return value is ``True``, the type of its argument
is the type inside ``TypeGuard``.
``TypeGuard`` also works with type variables. See :pep:`647` for more details.
For example::
def is_str_list(val: list[object]) -> TypeGuard[list[str]]:
@ -1433,23 +1501,19 @@ These can be used as types in annotations. They all support subscription using
# Type of ``val`` remains as ``list[object]``.
print("Not a list of strings!")
If ``is_str_list`` is a class or instance method, then the type in
``TypeGuard`` maps to the type of the second parameter after ``cls`` or
``self``.
``TypeIs`` and ``TypeGuard`` differ in the following ways:
In short, the form ``def foo(arg: TypeA) -> TypeGuard[TypeB]: ...``,
means that if ``foo(arg)`` returns ``True``, then ``arg`` narrows from
``TypeA`` to ``TypeB``.
.. note::
``TypeB`` need not be a narrower form of ``TypeA`` -- it can even be a
wider form. The main reason is to allow for things like
narrowing ``list[object]`` to ``list[str]`` even though the latter
is not a subtype of the former, since ``list`` is invariant.
The responsibility of writing type-safe type guards is left to the user.
``TypeGuard`` also works with type variables. See :pep:`647` for more details.
* ``TypeIs`` requires the narrowed type to be a subtype of the input type, while
``TypeGuard`` does not. The main reason is to allow for things like
narrowing ``list[object]`` to ``list[str]`` even though the latter
is not a subtype of the former, since ``list`` is invariant.
* When a ``TypeGuard`` function returns ``True``, type checkers narrow the type of the
variable to exactly the ``TypeGuard`` type. When a ``TypeIs`` function returns ``True``,
type checkers can infer a more precise type combining the previously known type of the
variable with the ``TypeIs`` type. (Technically, this is known as an intersection type.)
* When a ``TypeGuard`` function returns ``False``, type checkers cannot narrow the type of
the variable at all. When a ``TypeIs`` function returns ``False``, type checkers can narrow
the type of the variable to exclude the ``TypeIs`` type.
.. versionadded:: 3.10

View File

@ -87,6 +87,10 @@ Interpreter improvements:
Performance improvements are modest -- we expect to be improving this
over the next few releases.
New typing features:
* :pep:`742`: :data:`typing.TypeIs` was added, providing more intuitive
type narrowing behavior.
New Features
============

View File

@ -38,7 +38,7 @@ from typing import Annotated, ForwardRef
from typing import Self, LiteralString
from typing import TypeAlias
from typing import ParamSpec, Concatenate, ParamSpecArgs, ParamSpecKwargs
from typing import TypeGuard
from typing import TypeGuard, TypeIs
import abc
import textwrap
import typing
@ -5207,6 +5207,7 @@ class GenericTests(BaseTestCase):
Literal[1, 2],
Concatenate[int, ParamSpec("P")],
TypeGuard[int],
TypeIs[range],
):
with self.subTest(msg=obj):
with self.assertRaisesRegex(
@ -6748,6 +6749,7 @@ class GetUtilitiesTestCase(TestCase):
self.assertEqual(get_args(NotRequired[int]), (int,))
self.assertEqual(get_args(TypeAlias), ())
self.assertEqual(get_args(TypeGuard[int]), (int,))
self.assertEqual(get_args(TypeIs[range]), (range,))
Ts = TypeVarTuple('Ts')
self.assertEqual(get_args(Ts), ())
self.assertEqual(get_args((*Ts,)[0]), (Ts,))
@ -9592,6 +9594,56 @@ class TypeGuardTests(BaseTestCase):
issubclass(int, TypeGuard)
class TypeIsTests(BaseTestCase):
def test_basics(self):
TypeIs[int] # OK
def foo(arg) -> TypeIs[int]: ...
self.assertEqual(gth(foo), {'return': TypeIs[int]})
with self.assertRaises(TypeError):
TypeIs[int, str]
def test_repr(self):
self.assertEqual(repr(TypeIs), 'typing.TypeIs')
cv = TypeIs[int]
self.assertEqual(repr(cv), 'typing.TypeIs[int]')
cv = TypeIs[Employee]
self.assertEqual(repr(cv), 'typing.TypeIs[%s.Employee]' % __name__)
cv = TypeIs[tuple[int]]
self.assertEqual(repr(cv), 'typing.TypeIs[tuple[int]]')
def test_cannot_subclass(self):
with self.assertRaisesRegex(TypeError, CANNOT_SUBCLASS_TYPE):
class C(type(TypeIs)):
pass
with self.assertRaisesRegex(TypeError, CANNOT_SUBCLASS_TYPE):
class D(type(TypeIs[int])):
pass
with self.assertRaisesRegex(TypeError,
r'Cannot subclass typing\.TypeIs'):
class E(TypeIs):
pass
with self.assertRaisesRegex(TypeError,
r'Cannot subclass typing\.TypeIs\[int\]'):
class F(TypeIs[int]):
pass
def test_cannot_init(self):
with self.assertRaises(TypeError):
TypeIs()
with self.assertRaises(TypeError):
type(TypeIs)()
with self.assertRaises(TypeError):
type(TypeIs[Optional[int]])()
def test_no_isinstance(self):
with self.assertRaises(TypeError):
isinstance(1, TypeIs[int])
with self.assertRaises(TypeError):
issubclass(int, TypeIs)
SpecialAttrsP = typing.ParamSpec('SpecialAttrsP')
SpecialAttrsT = typing.TypeVar('SpecialAttrsT', int, float, complex)
@ -9691,6 +9743,7 @@ class SpecialAttrsTests(BaseTestCase):
typing.Optional: 'Optional',
typing.TypeAlias: 'TypeAlias',
typing.TypeGuard: 'TypeGuard',
typing.TypeIs: 'TypeIs',
typing.TypeVar: 'TypeVar',
typing.Union: 'Union',
typing.Self: 'Self',
@ -9705,6 +9758,7 @@ class SpecialAttrsTests(BaseTestCase):
typing.Literal[True, 2]: 'Literal',
typing.Optional[Any]: 'Optional',
typing.TypeGuard[Any]: 'TypeGuard',
typing.TypeIs[Any]: 'TypeIs',
typing.Union[Any]: 'Any',
typing.Union[int, float]: 'Union',
# Incompatible special forms (tested in test_special_attrs2)

View File

@ -153,6 +153,7 @@ __all__ = [
'TYPE_CHECKING',
'TypeAlias',
'TypeGuard',
'TypeIs',
'TypeAliasType',
'Unpack',
]
@ -818,28 +819,31 @@ def Concatenate(self, parameters):
@_SpecialForm
def TypeGuard(self, parameters):
"""Special typing construct for marking user-defined type guard functions.
"""Special typing construct for marking user-defined type predicate functions.
``TypeGuard`` can be used to annotate the return type of a user-defined
type guard function. ``TypeGuard`` only accepts a single type argument.
type predicate function. ``TypeGuard`` only accepts a single type argument.
At runtime, functions marked this way should return a boolean.
``TypeGuard`` aims to benefit *type narrowing* -- a technique used by static
type checkers to determine a more precise type of an expression within a
program's code flow. Usually type narrowing is done by analyzing
conditional code flow and applying the narrowing to a block of code. The
conditional expression here is sometimes referred to as a "type guard".
conditional expression here is sometimes referred to as a "type predicate".
Sometimes it would be convenient to use a user-defined boolean function
as a type guard. Such a function should use ``TypeGuard[...]`` as its
return type to alert static type checkers to this intention.
as a type predicate. Such a function should use ``TypeGuard[...]`` or
``TypeIs[...]`` as its return type to alert static type checkers to
this intention. ``TypeGuard`` should be used over ``TypeIs`` when narrowing
from an incompatible type (e.g., ``list[object]`` to ``list[int]``) or when
the function does not return ``True`` for all instances of the narrowed type.
Using ``-> TypeGuard`` tells the static type checker that for a given
function:
Using ``-> TypeGuard[NarrowedType]`` tells the static type checker that
for a given function:
1. The return value is a boolean.
2. If the return value is ``True``, the type of its argument
is the type inside ``TypeGuard``.
is ``NarrowedType``.
For example::
@ -860,7 +864,7 @@ def TypeGuard(self, parameters):
type-unsafe results. The main reason is to allow for things like
narrowing ``list[object]`` to ``list[str]`` even though the latter is not
a subtype of the former, since ``list`` is invariant. The responsibility of
writing type-safe type guards is left to the user.
writing type-safe type predicates is left to the user.
``TypeGuard`` also works with type variables. For more information, see
PEP 647 (User-Defined Type Guards).
@ -869,6 +873,75 @@ def TypeGuard(self, parameters):
return _GenericAlias(self, (item,))
@_SpecialForm
def TypeIs(self, parameters):
"""Special typing construct for marking user-defined type predicate functions.
``TypeIs`` can be used to annotate the return type of a user-defined
type predicate function. ``TypeIs`` only accepts a single type argument.
At runtime, functions marked this way should return a boolean and accept
at least one argument.
``TypeIs`` aims to benefit *type narrowing* -- a technique used by static
type checkers to determine a more precise type of an expression within a
program's code flow. Usually type narrowing is done by analyzing
conditional code flow and applying the narrowing to a block of code. The
conditional expression here is sometimes referred to as a "type predicate".
Sometimes it would be convenient to use a user-defined boolean function
as a type predicate. Such a function should use ``TypeIs[...]`` or
``TypeGuard[...]`` as its return type to alert static type checkers to
this intention. ``TypeIs`` usually has more intuitive behavior than
``TypeGuard``, but it cannot be used when the input and output types
are incompatible (e.g., ``list[object]`` to ``list[int]``) or when the
function does not return ``True`` for all instances of the narrowed type.
Using ``-> TypeIs[NarrowedType]`` tells the static type checker that for
a given function:
1. The return value is a boolean.
2. If the return value is ``True``, the type of its argument
is the intersection of the argument's original type and
``NarrowedType``.
3. If the return value is ``False``, the type of its argument
is narrowed to exclude ``NarrowedType``.
For example::
from typing import assert_type, final, TypeIs
class Parent: pass
class Child(Parent): pass
@final
class Unrelated: pass
def is_parent(val: object) -> TypeIs[Parent]:
return isinstance(val, Parent)
def run(arg: Child | Unrelated):
if is_parent(arg):
# Type of ``arg`` is narrowed to the intersection
# of ``Parent`` and ``Child``, which is equivalent to
# ``Child``.
assert_type(arg, Child)
else:
# Type of ``arg`` is narrowed to exclude ``Parent``,
# so only ``Unrelated`` is left.
assert_type(arg, Unrelated)
The type inside ``TypeIs`` must be consistent with the type of the
function's argument; if it is not, static type checkers will raise
an error. An incorrectly written ``TypeIs`` function can lead to
unsound behavior in the type system; it is the user's responsibility
to write such functions in a type-safe manner.
``TypeIs`` also works with type variables. For more information, see
PEP 742 (Narrowing types with ``TypeIs``).
"""
item = _type_check(parameters, f'{self} accepts only single type.')
return _GenericAlias(self, (item,))
class ForwardRef(_Final, _root=True):
"""Internal wrapper to hold a forward reference."""
@ -1241,11 +1314,12 @@ class _GenericAlias(_BaseGenericAlias, _root=True):
# A = Callable[[], None] # _CallableGenericAlias
# B = Callable[[T], None] # _CallableGenericAlias
# C = B[int] # _CallableGenericAlias
# * Parameterized `Final`, `ClassVar` and `TypeGuard`:
# * Parameterized `Final`, `ClassVar`, `TypeGuard`, and `TypeIs`:
# # All _GenericAlias
# Final[int]
# ClassVar[float]
# TypeVar[bool]
# TypeGuard[bool]
# TypeIs[range]
def __init__(self, origin, args, *, inst=True, name=None):
super().__init__(origin, inst=inst, name=name)

View File

@ -0,0 +1 @@
Add :data:`typing.TypeIs`, implementing :pep:`742`. Patch by Jelle Zijlstra.