From 3b1983e67efd832e885c1af861d476e8dfccce66 Mon Sep 17 00:00:00 2001 From: Michael H Date: Mon, 5 Aug 2024 13:00:23 -0400 Subject: [PATCH] retooled, moved to guides --- docs/guides/type_narrowing.rst | 78 +++++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/docs/guides/type_narrowing.rst b/docs/guides/type_narrowing.rst index f900775c..5e8ca445 100644 --- a/docs/guides/type_narrowing.rst +++ b/docs/guides/type_narrowing.rst @@ -195,15 +195,91 @@ This behavior can be seen in the following example:: else: reveal_type(x) # Unrelated +There are also cases beyond just mutability. In some cases, it may not be +possible to narrow a type fully from information available to the ``TypeIs`` +function. In such cases, raising an error is the only possible option, as you +have neither enough information to confirm or deny a type narrowing operation. +(Note: returning false (denying) results in unsafe negative narrowing in this +case) This is most likely to occur with narrowing of generics. + +To see why, we can look at the following example:: + + from typing_extensions import TypeVar, TypeIs + from typing import Generic + + X = TypeVar("X", str, int, str | int, covariant=True, default=str | int) + + class A(Generic[X]): + def __init__(self, i: X, /): + self._i: X = i + + @property + def i(self) -> X: + return self._i + + + class B(A[X], Generic[X]): + def __init__(self, i: X, j: X, /): + super().__init__(i) + self._j: X = j + + @property + def j(self) -> X: + return self._j + + def possible_problem(x: A) -> TypeIs[A[int]]: + return isinstance(x.i, int) + + def possible_correction(x: A) -> TypeIs[A[int]]: + if type(x) is A: + # only narrow cases we know about + return isinstance(x.i, int) + raise TypeError( + f"Refusing to narrow Genenric type {type(x)!r}" + f"from function that only knows about {A!r}" + ) + +Because it is possible to attempt to narrow B, +but A does not have appropriate information about B +(or any other unknown subclass of A!) it's not possible to safely narrow +in either direction. The general rule for generics is that if you do not know +all the places a generic class is generic and do not check enough of them to be +absolutely certain, you cannot return True, and if you do not have a definitive +counter example to the type to be narrowed to you cannot return False. +In practice, if soundness is prioritized over an unsafe narrowing, +not knowing what you don't know is solvable by either +erroring out when neither return option is safe, or by making the class to be +narrowed final to avoid such a situation. Safety and soundness -------------------- While type narrowing is important for typing real-world Python code, many -forms of type narrowing are unsafe in the presence of mutability. Type checkers +forms of type narrowing are unsafe. Type checkers attempt to limit type narrowing in a way that minimizes unsafety while remaining useful, but not all safety violations can be detected. +One example of this tradeoff building off of TypeIs + +If you trust that users implementing the Sequence Protocol are doing so in a +way that is safe to iterate over, and will not be mutated for the duration +you are relying on it; then while the following function can never be fully +sound, full soundness is not necessarily easier or better for your use:: + + def useful_unsoundness(s: Sequence[object]) -> TypeIs[Sequence[int]]: + return all(isinstance(i, int) for i in s) + +However, many cases of this sort can be extracted for safe use with an +alternative construction if soundness is of a high priority, +and the cost of a copy is acceptable:: + + def safer(s: Sequence[object]) -> Sequence[int]: + ret = tuple(i for i in s if isinstance(i, int)) + if len(ret) != len(s): + raise TypeError + return ret + + ``isinstance()`` and ``issubclass()`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~