Skip to content

Commit fd95812

Browse files
committed
Add decorator to turn regular functions in Result returning ones
Add a as_result() helper to make a decorator to turn a function into one that returns a Result: Regular return values are turned into Ok(return_value). Raised exceptions of the specified exception type(s) are turned into Err(exc). The decorator is signature-preserving, except for wrapping the return type into a Result, of course. For type annotations, this depends on typing.ParamSpec which requires Python 3.10+ (or use typing_extensions); see PEP612 (https://www.python.org/dev/peps/pep-0612/). This is currently not fully supported by Mypy; see python/mypy#8645 Calling decorated functions works without errors from Mypy, but will not be type-safe, i.e. it will behave as if it is calling a function like f(*args: Any, **kwargs: Any) Fixes rustedpy#33.
1 parent 63c528c commit fd95812

File tree

6 files changed

+179
-10
lines changed

6 files changed

+179
-10
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ Possible log types:
1313

1414
## [Unreleased]
1515

16+
- `[added]` `as_result` decorator to turn regular functions into
17+
`Result` returning ones (#33, 71)
1618
- `[removed]` Drop support for Python 3.6 (#49)
1719
- `[added]` Implement `unwrap_or_else` (#74)
1820

README.rst

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
======
12
Result
23
======
34

@@ -90,7 +91,7 @@ be OK or not, without resorting to custom exceptions.
9091

9192

9293
API
93-
---
94+
===
9495

9596
Creating an instance:
9697

@@ -199,7 +200,6 @@ returns the error value if ``Err``, otherwise it raises an ``UnwrapError``:
199200
>>>res2.unwrap_err()
200201
'nay'
201202

202-
203203
A custom error message can be displayed instead by using ``expect`` and ``expect_err``:
204204

205205
.. sourcecode:: python
@@ -261,10 +261,45 @@ To save memory, both the ``Ok`` and ``Err`` classes are ‘slotted’,
261261
i.e. they define ``__slots__``. This means assigning arbitrary
262262
attributes to instances will raise ``AttributeError``.
263263

264+
The ``as_result()`` decorator can be used to quickly turn ‘normal’
265+
functions into ``Result`` returning ones by specifying one or more
266+
exception types:
264267

265-
FAQ
266-
-------
268+
.. sourcecode:: python
269+
270+
@as_result(ValueError, IndexError)
271+
def f(value: int) -> int:
272+
if value < 0:
273+
raise ValueError
274+
else:
275+
return value
267276

277+
res = f(12) # Ok[12]
278+
res = f(-1) # Err[ValueError(-1)]
279+
280+
``Exception`` (or even ``BaseException``) can be specified to create a
281+
‘catch all’ ``Result`` return type. This is effectively the same as
282+
``try`` followed by ``except Exception``, which is not considered good
283+
practice in most scenarios, and hence this requires explicit opt-in.
284+
285+
Since ``as_result`` is a regular decorator, it can be used to wrap
286+
existing functions (also from other libraries), albeit with a slightly
287+
unconventional syntax (without the usual ``@``):
288+
289+
.. sourcecode:: python
290+
291+
import third_party
292+
293+
x = third_party.do_something(...) # could raise; who knows?
294+
295+
safe_do_something = as_result(Exception)(third_party.do_something)
296+
297+
res = safe_do_something(...) # Ok(...) or Err(...)
298+
if isinstance(res, Ok):
299+
print(res.value)
300+
301+
FAQ
302+
===
268303

269304
- **Why do I get the "Cannot infer type argument" error with MyPy?**
270305

@@ -274,9 +309,7 @@ Using ``if isinstance(res, Ok)`` instead of ``if res.is_ok()`` will help in some
274309
Otherwise using `one of these workarounds
275310
<https://github.com/python/mypy/issues/3889#issuecomment-325997911>`_ can help.
276311

277-
278-
279312
License
280-
-------
313+
=======
281314

282315
MIT License

setup.cfg

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ classifiers =
2222

2323
[options]
2424
include_package_data = True
25+
install_requires =
26+
typing_extensions;python_version<'3.10'
2527
package_dir =
2628
=src
2729
packages = find:

src/result/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
from .result import Err, Ok, OkErr, Result, UnwrapError
1+
from .result import Err, Ok, OkErr, Result, UnwrapError, as_result
22

33
__all__ = [
44
"Err",
55
"Ok",
66
"OkErr",
77
"Result",
88
"UnwrapError",
9+
"as_result",
910
]
1011
__version__ = "0.7.0"

src/result/result.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,33 @@
11
from __future__ import annotations
22

3-
from typing import Any, Callable, Generic, NoReturn, TypeVar, Union, cast, overload
3+
import functools
4+
import inspect
5+
import sys
6+
from typing import (
7+
Any,
8+
Callable,
9+
Generic,
10+
NoReturn,
11+
Type,
12+
TypeVar,
13+
Union,
14+
cast,
15+
overload,
16+
)
17+
18+
if sys.version_info[:2] >= (3, 10):
19+
from typing import ParamSpec
20+
else:
21+
from typing_extensions import ParamSpec
22+
423

524
T = TypeVar("T", covariant=True) # Success type
625
E = TypeVar("E", covariant=True) # Error type
726
U = TypeVar("U")
827
F = TypeVar("F")
28+
P = ParamSpec("P")
29+
R = TypeVar("R")
30+
TBE = TypeVar("TBE", bound=BaseException)
931

1032

1133
class Ok(Generic[T]):
@@ -287,3 +309,43 @@ def result(self) -> Result[Any, Any]:
287309
Returns the original result.
288310
"""
289311
return self._result
312+
313+
314+
def as_result(
315+
*exceptions: Type[TBE],
316+
) -> Callable[[Callable[P, R]], Callable[P, Result[R, TBE]]]:
317+
"""
318+
Make a decorator to turn a function into one that returns a ``Result``.
319+
320+
Regular return values are turned into ``Ok(return_value)``. Raised
321+
exceptions of the specified exception type(s) are turned into ``Err(exc)``.
322+
"""
323+
# Note: type annotations for signature-preserving decorators via ParamSpec
324+
# are currently not fully supported by Mypy 0.930; see
325+
# https://github.com/python/mypy/issues/8645
326+
#
327+
# The ‘type: ignore’ comments below are for our own linting purposes.
328+
# Calling code works without errors from Mypy, but will also not be
329+
# type-safe, i.e. it will behave as if it is calling a function like
330+
# f(*args: Any, **kwargs: Any)
331+
if not exceptions or not all(
332+
inspect.isclass(exception) and issubclass(exception, BaseException)
333+
for exception in exceptions
334+
):
335+
raise TypeError("as_result() requires one or more exception types")
336+
337+
def decorator(f: Callable[P, R]) -> Callable[P, Result[R, TBE]]:
338+
"""
339+
Decorator to turn a function into one that returns a ``Result``.
340+
"""
341+
342+
@functools.wraps(f)
343+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[R, TBE]:
344+
try:
345+
return Ok(f(*args, **kwargs))
346+
except exceptions as exc:
347+
return Err(exc)
348+
349+
return wrapper
350+
351+
return decorator

tests/test_result.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import pytest
44

5-
from result import Err, Ok, OkErr, Result, UnwrapError
5+
from result import Err, Ok, OkErr, Result, UnwrapError, as_result
66

77

88
def test_ok_factories() -> None:
@@ -197,3 +197,72 @@ def test_slots() -> None:
197197
o.some_arbitrary_attribute = 1 # type: ignore[attr-defined]
198198
with pytest.raises(AttributeError):
199199
n.some_arbitrary_attribute = 1 # type: ignore[attr-defined]
200+
201+
202+
def test_as_result() -> None:
203+
"""
204+
``as_result()`` turns functions into ones that return a ``Result``.
205+
"""
206+
207+
@as_result(ValueError)
208+
def good(value: int) -> int:
209+
return value
210+
211+
@as_result(IndexError, ValueError)
212+
def bad(value: int) -> int:
213+
raise ValueError
214+
215+
good_result = good(123)
216+
bad_result = bad(123)
217+
218+
assert isinstance(good_result, Ok)
219+
assert good_result.unwrap() == 123
220+
assert isinstance(bad_result, Err)
221+
assert isinstance(bad_result.unwrap_err(), ValueError)
222+
223+
224+
def test_as_result_other_exception() -> None:
225+
"""
226+
``as_result()`` only catches the specified exceptions.
227+
"""
228+
229+
@as_result(ValueError)
230+
def f() -> int:
231+
raise IndexError
232+
233+
with pytest.raises(IndexError):
234+
f()
235+
236+
237+
def test_as_result_invalid_usage() -> None:
238+
"""
239+
Invalid use of ``as_result()`` raises reasonable errors.
240+
"""
241+
message = "requires one or more exception types"
242+
243+
with pytest.raises(TypeError, match=message):
244+
245+
@as_result() # No exception types specified
246+
def f() -> int:
247+
return 1
248+
249+
with pytest.raises(TypeError, match=message):
250+
251+
@as_result("not an exception type") # type: ignore[arg-type]
252+
def g() -> int:
253+
return 1
254+
255+
256+
def test_as_result_type_checking() -> None:
257+
"""
258+
The ``as_result()`` is a signature-preserving decorator.
259+
"""
260+
261+
@as_result(ValueError)
262+
def f(a: int) -> int:
263+
return a
264+
265+
expected = {"a": "int", "return": "int"}
266+
assert f.__annotations__ == expected
267+
res = f(123)
268+
assert res.ok() == 123

0 commit comments

Comments
 (0)