From f20e323c6ff967f8850534e454efe12c0989356a Mon Sep 17 00:00:00 2001 From: Jannic Warken Date: Thu, 15 Aug 2024 08:45:49 +0200 Subject: [PATCH 1/3] Replace TypeGuard with TypeIs --- CHANGELOG.md | 3 + README.md | 21 ----- docs/README.md | 4 +- docs/result.md | 128 ++++++++++++++-------------- setup.cfg | 2 +- src/result/result.py | 14 +-- tests/type_checking/test_result.yml | 21 +++-- 7 files changed, 91 insertions(+), 102 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54555a2..73f5d08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ Possible log types: ## [Unreleased] +- `[changed]` Improve type narrowing for `is_ok` and `is_err` type guards by + replacing `typing.TypeGuard` with `typing.TypeIs` (#193) + ## [0.17.0] - 2024-06-02 - `[added]` Add `inspect()` and `inspect_err()` methods (#185) diff --git a/README.md b/README.md index e8cec62..a151482 100644 --- a/README.md +++ b/README.md @@ -156,27 +156,6 @@ False True ``` -The benefit of `isinstance` is better type checking that type guards currently -do not offer, - -```python -res1: Result[int, str] = some_result() -if isinstance(res1, Err): - print("Error...:", res1.err_value) # res1 is narrowed to an Err - return -res1.ok() - -res2: Result[int, str] = some_result() -if res1.is_err(): - print("Error...:", res2.err_value) # res1 is NOT narrowed to an Err here - return -res1.ok() -``` - -There is a proposed [PEP 724 – Stricter Type Guards](https://peps.python.org/pep-0724/) -which may allow the `is_ok` and `is_err` type guards to work as expected in -future versions of Python. - Convert a `Result` to the value or `None`: ``` python diff --git a/docs/README.md b/docs/README.md index 3b123fb..ce04bfc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,8 +19,8 @@ - [`result.as_result`](./result.md#function-as_result): Make a decorator to turn a function into one that returns a ``Result``. - [`result.do`](./result.md#function-do): Do notation for Result (syntactic sugar for sequence of `and_then()` calls). - [`result.do_async`](./result.md#function-do_async): Async version of do. Example: -- [`result.is_err`](./result.md#function-is_err): A typeguard to check if a result is an Err -- [`result.is_ok`](./result.md#function-is_ok): A typeguard to check if a result is an Ok +- [`result.is_err`](./result.md#function-is_err): A type guard to check if a result is an Err +- [`result.is_ok`](./result.md#function-is_ok): A type guard to check if a result is an Ok --- diff --git a/docs/result.md b/docs/result.md index 731c1f1..eb6d5ed 100644 --- a/docs/result.md +++ b/docs/result.md @@ -13,7 +13,7 @@ --- - + ## function `as_result` @@ -30,7 +30,7 @@ Regular return values are turned into ``Ok(return_value)``. Raised exceptions of --- - + ## function `as_async_result` @@ -45,15 +45,15 @@ Make a decorator to turn an async function into one that returns a ``Result``. R --- - + ## function `is_ok` ```python -is_ok(result: 'Result[T, E]') → TypeGuard[Ok[T]] +is_ok(result: 'Result[T, E]') → TypeIs[Ok[T]] ``` -A typeguard to check if a result is an Ok +A type guard to check if a result is an Ok Usage: @@ -68,15 +68,15 @@ elif is_err(r): --- - + ## function `is_err` ```python -is_err(result: 'Result[T, E]') → TypeGuard[Err[E]] +is_err(result: 'Result[T, E]') → TypeIs[Err[E]] ``` -A typeguard to check if a result is an Err +A type guard to check if a result is an Err Usage: @@ -91,7 +91,7 @@ elif is_err(r): --- - + ## function `do` @@ -128,7 +128,7 @@ NOTE: If you exclude the type annotation e.g. `Result[float, int]` your type che --- - + ## function `do_async` @@ -182,12 +182,12 @@ Furthermore, neither mypy nor pyright can infer that the second case is actually --- - + ## class `Ok` A value that indicates success and which stores arbitrary data for the return value. - + ### method `__init__` @@ -218,7 +218,7 @@ Return the inner value. --- - + ### method `and_then` @@ -230,7 +230,7 @@ The contained result is `Ok`, so return the result of `op` with the original val --- - + ### method `and_then_async` @@ -242,7 +242,7 @@ The contained result is `Ok`, so return the result of `op` with the original val --- - + ### method `err` @@ -254,7 +254,7 @@ Return `None`. --- - + ### method `expect` @@ -266,7 +266,7 @@ Return the value. --- - + ### method `expect_err` @@ -278,31 +278,31 @@ Raise an UnwrapError since this type is `Ok` --- - + ### method `inspect` ```python -inspect(op: 'Callable[[T], None]') → Result[T, E] +inspect(op: 'Callable[[T], Any]') → Result[T, E] ``` Calls a function with the contained value if `Ok`. Returns the original result. --- - + ### method `inspect_err` ```python -inspect_err(op: 'Callable[[E], None]') → Result[T, E] +inspect_err(op: 'Callable[[E], Any]') → Result[T, E] ``` Calls a function with the contained value if `Err`. Returns the original result. --- - + ### method `is_err` @@ -316,7 +316,7 @@ is_err() → Literal[False] --- - + ### method `is_ok` @@ -330,7 +330,7 @@ is_ok() → Literal[True] --- - + ### method `map` @@ -342,7 +342,7 @@ The contained result is `Ok`, so return `Ok` with original value mapped to a new --- - + ### method `map_async` @@ -354,7 +354,7 @@ The contained result is `Ok`, so return the result of `op` with the original val --- - + ### method `map_err` @@ -366,7 +366,7 @@ The contained result is `Ok`, so return `Ok` with the original value --- - + ### method `map_or` @@ -378,7 +378,7 @@ The contained result is `Ok`, so return the original value mapped to a new value --- - + ### method `map_or_else` @@ -390,7 +390,7 @@ The contained result is `Ok`, so return original value mapped to a new value usi --- - + ### method `ok` @@ -402,7 +402,7 @@ Return the value. --- - + ### method `or_else` @@ -414,7 +414,7 @@ The contained result is `Ok`, so return `Ok` with the original value --- - + ### method `unwrap` @@ -426,7 +426,7 @@ Return the value. --- - + ### method `unwrap_err` @@ -438,7 +438,7 @@ Raise an UnwrapError since this type is `Ok` --- - + ### method `unwrap_or` @@ -450,7 +450,7 @@ Return the value. --- - + ### method `unwrap_or_else` @@ -462,7 +462,7 @@ Return the value. --- - + ### method `unwrap_or_raise` @@ -475,12 +475,12 @@ Return the value. --- - + ## class `DoException` This is used to signal to `do()` that the result is an `Err`, which short-circuits the generator and returns that Err. Using this exception for control flow in `do()` allows us to simulate `and_then()` in the Err case: namely, we don't call `op`, we just return `self` (the Err). - + ### method `__init__` @@ -498,12 +498,12 @@ __init__(err: 'Err[E]') → None --- - + ## class `Err` A value that signifies failure and which stores arbitrary data for the error. - + ### method `__init__` @@ -534,7 +534,7 @@ Return the inner value. --- - + ### method `and_then` @@ -546,7 +546,7 @@ The contained result is `Err`, so return `Err` with the original value --- - + ### method `and_then_async` @@ -558,7 +558,7 @@ The contained result is `Err`, so return `Err` with the original value --- - + ### method `err` @@ -570,7 +570,7 @@ Return the error. --- - + ### method `expect` @@ -582,7 +582,7 @@ Raises an `UnwrapError`. --- - + ### method `expect_err` @@ -594,31 +594,31 @@ Return the inner value --- - + ### method `inspect` ```python -inspect(op: 'Callable[[T], None]') → Result[T, E] +inspect(op: 'Callable[[T], Any]') → Result[T, E] ``` Calls a function with the contained value if `Ok`. Returns the original result. --- - + ### method `inspect_err` ```python -inspect_err(op: 'Callable[[E], None]') → Result[T, E] +inspect_err(op: 'Callable[[E], Any]') → Result[T, E] ``` Calls a function with the contained value if `Err`. Returns the original result. --- - + ### method `is_err` @@ -632,7 +632,7 @@ is_err() → Literal[True] --- - + ### method `is_ok` @@ -646,7 +646,7 @@ is_ok() → Literal[False] --- - + ### method `map` @@ -658,7 +658,7 @@ Return `Err` with the same value --- - + ### method `map_async` @@ -670,7 +670,7 @@ The contained result is `Ok`, so return the result of `op` with the original val --- - + ### method `map_err` @@ -682,7 +682,7 @@ The contained result is `Err`, so return `Err` with original error mapped to a n --- - + ### method `map_or` @@ -694,7 +694,7 @@ Return the default value --- - + ### method `map_or_else` @@ -706,7 +706,7 @@ Return the result of the default operation --- - + ### method `ok` @@ -718,7 +718,7 @@ Return `None`. --- - + ### method `or_else` @@ -730,7 +730,7 @@ The contained result is `Err`, so return the result of `op` with the original va --- - + ### method `unwrap` @@ -742,7 +742,7 @@ Raises an `UnwrapError`. --- - + ### method `unwrap_err` @@ -754,7 +754,7 @@ Return the inner value --- - + ### method `unwrap_or` @@ -766,7 +766,7 @@ Return `default`. --- - + ### method `unwrap_or_else` @@ -778,7 +778,7 @@ The contained result is ``Err``, so return the result of applying ``op`` to the --- - + ### method `unwrap_or_raise` @@ -791,14 +791,14 @@ The contained result is ``Err``, so raise the exception with the value. --- - + ## class `UnwrapError` Exception raised from ``.unwrap_<...>`` and ``.expect_<...>`` calls. The original ``Result`` can be accessed via the ``.result`` attribute, but this is not intended for regular use, as type information is lost: ``UnwrapError`` doesn't know about both ``T`` and ``E``, since it's raised from ``Ok()`` or ``Err()`` which only knows about either ``T`` or ``E``, not both. - + ### method `__init__` diff --git a/setup.cfg b/setup.cfg index 03c5752..b45c8f3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -25,7 +25,7 @@ classifiers = [options] include_package_data = True install_requires = - typing_extensions;python_version<'3.10' + typing_extensions>=4.10.0;python_version<'3.13' package_dir = =src packages = find: diff --git a/src/result/result.py b/src/result/result.py index 30bf9a8..8551239 100644 --- a/src/result/result.py +++ b/src/result/result.py @@ -20,10 +20,12 @@ Union, ) +from typing_extensions import TypeIs + if sys.version_info >= (3, 10): - from typing import ParamSpec, TypeAlias, TypeGuard + from typing import ParamSpec, TypeAlias else: - from typing_extensions import ParamSpec, TypeAlias, TypeGuard + from typing_extensions import ParamSpec, TypeAlias T = TypeVar("T", covariant=True) # Success type @@ -527,8 +529,8 @@ async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[R, TBE]: return decorator -def is_ok(result: Result[T, E]) -> TypeGuard[Ok[T]]: - """A typeguard to check if a result is an Ok +def is_ok(result: Result[T, E]) -> TypeIs[Ok[T]]: + """A type guard to check if a result is an Ok Usage: @@ -544,8 +546,8 @@ def is_ok(result: Result[T, E]) -> TypeGuard[Ok[T]]: return result.is_ok() -def is_err(result: Result[T, E]) -> TypeGuard[Err[E]]: - """A typeguard to check if a result is an Err +def is_err(result: Result[T, E]) -> TypeIs[Err[E]]: + """A type guard to check if a result is an Err Usage: diff --git a/tests/type_checking/test_result.yml b/tests/type_checking/test_result.yml index 2322b15..838976a 100644 --- a/tests/type_checking/test_result.yml +++ b/tests/type_checking/test_result.yml @@ -85,11 +85,16 @@ - case: map_result disable_cache: false main: | - from result import Ok, Err, is_ok, is_err - - result = Ok(1) - err = Err("error") - if is_ok(result): - reveal_type(result) # N: Revealed type is "result.result.Ok[builtins.int]" - elif is_err(err): - reveal_type(err) # N: Revealed type is "result.result.Err[builtins.str]" + from result import Result, Ok, Err, is_ok, is_err + + res1: Result[int, str] = Ok(1) + if is_ok(res1): + reveal_type(res1) # N: Revealed type is "result.result.Ok[builtins.int]" + else: + reveal_type(res1) # N: Revealed type is "result.result.Err[builtins.str]" + + res2: Result[int, str] = Err("error") + if is_err(res2): + reveal_type(res2) # N: Revealed type is "result.result.Err[builtins.str]" + else: + reveal_type(res2) # N: Revealed type is "result.result.Ok[builtins.int]" From 430f07343f9b91cd19cda6bdbf55ceb5ff9352ac Mon Sep 17 00:00:00 2001 From: Jannic Warken Date: Sat, 17 Aug 2024 17:40:22 +0200 Subject: [PATCH 2/3] Proposal: Mention type guard functons before isinstance --- README.md | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index a151482..7a3b025 100644 --- a/README.md +++ b/README.md @@ -64,10 +64,10 @@ def get_user_by_email(email: str) -> Result[User, str]: return Ok(user) user_result = get_user_by_email(email) -if isinstance(user_result, Ok): # or `is_ok(user_result)` +if is_ok(user_result): # or `isinstance(user_result, Ok)` # type(user_result.ok_value) == User do_something(user_result.ok_value) -else: # or `elif is_err(user_result)` +else: # type(user_result.err_value) == str raise RuntimeError('Could not fetch user: %s' % user_result.err_value) ``` @@ -97,12 +97,9 @@ for a, b in values: Not all methods () have been -implemented, only the ones that make sense in the Python context. By -using `isinstance` to check for `Ok` or `Err` you get type safe access -to the contained value when using [MyPy](https://mypy.readthedocs.io/) -to typecheck your code. All of this in a package allowing easier -handling of values that can be OK or not, without resorting to custom -exceptions. +implemented, only the ones that make sense in the Python context. +All of this in a package allowing easier handling of values that can +be OK or not, without resorting to custom exceptions. ## API @@ -119,20 +116,19 @@ Creating an instance: Checking whether a result is `Ok` or `Err`. You can either use `is_ok` and `is_err` type guard **functions** or `isinstance`. This way you get -type safe access that can be checked with MyPy. The `is_ok()` or -`is_err()` **methods** can be used if you don't need the type safety -with MyPy: +type safe access that can be checked with MyPy (compared to the `is_ok` +and `is_err` **methods**). ``` python >>> res = Ok('yay') ->>> isinstance(res, Ok) -True >>> is_ok(res) True ->>> isinstance(res, Err) -False +>>> isinstance(res, Ok) +True >>> is_err(res) False +>>> isinstance(res, Err) +False >>> res.is_ok() True >>> res.is_err() From 2fb4116cbd60cc08b1e9cbb41a547dd1e70e556b Mon Sep 17 00:00:00 2001 From: Jannic Warken Date: Sun, 18 Aug 2024 09:40:24 +0200 Subject: [PATCH 3/3] Update README.md --- README.md | 60 +++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 7a3b025..089458a 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ def get_user_by_email(email: str) -> Result[User, str]: return Ok(user) user_result = get_user_by_email(email) -if is_ok(user_result): # or `isinstance(user_result, Ok)` +if is_ok(user_result): # type(user_result.ok_value) == User do_something(user_result.ok_value) else: @@ -114,25 +114,29 @@ Creating an instance: >>> res2 = Err('nay') ``` -Checking whether a result is `Ok` or `Err`. You can either use `is_ok` -and `is_err` type guard **functions** or `isinstance`. This way you get -type safe access that can be checked with MyPy (compared to the `is_ok` -and `is_err` **methods**). +Checking whether a result is `Ok` or `Err`: ``` python ->>> res = Ok('yay') ->>> is_ok(res) -True ->>> isinstance(res, Ok) -True ->>> is_err(res) -False ->>> isinstance(res, Err) -False ->>> res.is_ok() -True ->>> res.is_err() -False +if is_err(result): + raise RuntimeError(result.err_value) +do_something(result.ok_value) +``` +or +``` python +if is_ok(result): + do_something(result.ok_value) +else: + raise RuntimeError(result.err_value) +``` + +Alternatively, `isinstance` can be used (interchangeably to type guard functions +`is_ok` and `is_err`). However, relying on `isinstance` may result in code that +is slightly less readable and less concise: + +``` python +if isinstance(result, Err): + raise RuntimeError(result.err_value) +do_something(result.ok_value) ``` You can also check if an object is `Ok` or `Err` by using the `OkErr` @@ -333,7 +337,7 @@ x = third_party.do_something(...) # could raise; who knows? safe_do_something = as_result(Exception)(third_party.do_something) res = safe_do_something(...) # Ok(...) or Err(...) -if isinstance(res, Ok): +if is_ok(res): print(res.ok_value) ``` @@ -443,6 +447,24 @@ from the non-unix shell you're using on Windows. ## FAQ +- **Why should I use the `is_ok` (`is_err`) type guard function over the `is_ok` (`is_err`) method?** + +As you can see in the following example, MyPy can only narrow the type correctly +while using the type guard **functions**: +```python +result: Result[int, str] + +if is_ok(result): + reveal_type(result) # "result.result.Ok[builtins.int]" +else: + reveal_type(result) # "result.result.Err[builtins.str]" + +if result.is_ok(): + reveal_type(result) # "Union[result.result.Ok[builtins.int], result.result.Err[builtins.str]]" +else: + reveal_type(result) # "Union[result.result.Ok[builtins.int], result.result.Err[builtins.str]]" +``` + - **Why do I get the "Cannot infer type argument" error with MyPy?** There is [a bug in MyPy](https://github.com/python/mypy/issues/230)