Coverage for src/taipanstack/core/result.py: 100%
97 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-10-13 20:52 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-10-13 20:52 +0000
1"""
2Result type utilities for functional error handling.
4Provides Rust-style Result types (Ok/Err) for explicit error handling,
5avoiding exceptions for expected failure cases. This promotes safer,
6more predictable code.
8Example:
9 >>> from taipanstack.core.result import safe, Ok, Err
10 >>> @safe
11 ... def divide(a: int, b: int) -> float:
12 ... if b == 0:
13 ... raise ValueError("division by zero")
14 ... return a / b
15 >>> result = divide(10, 0)
16 >>> if isinstance(result, Err):
17 ... print(f"Error: {result.err_value}")
18 ... else:
19 ... print(f"Result: {result.unwrap()}")
20 Error: division by zero
22"""
24import functools
25import inspect
26from collections.abc import Awaitable, Callable, Iterable
27from typing import ParamSpec, Protocol, TypeVar, cast, overload
29from result import Err, Ok, Result
31__all__ = [
32 "Err",
33 "Ok",
34 "Result",
35 "and_then_async",
36 "collect_results",
37 "map_async",
38 "safe",
39 "safe_from",
40]
42P = ParamSpec("P")
43T = TypeVar("T")
44E = TypeVar("E", bound=Exception)
45E_co = TypeVar("E_co", bound=Exception, covariant=True)
46U = TypeVar("U")
49def _safe_async_wrapper(
50 func: Callable[P, Awaitable[T]],
51) -> Callable[P, Awaitable[Result[T, Exception]]]:
52 ok_cls = Ok
53 err_cls = Err
55 @functools.wraps(func)
56 async def async_wrapper(
57 *args: P.args,
58 **kwargs: P.kwargs,
59 ) -> Result[T, Exception]:
60 try:
61 return ok_cls(await func(*args, **kwargs))
62 except Exception as e:
63 return err_cls(e)
65 return cast(Callable[P, Awaitable[Result[T, Exception]]], async_wrapper)
68def _safe_sync_wrapper(
69 func: Callable[P, T],
70) -> Callable[P, Result[T, Exception]]:
71 ok_cls = Ok
72 err_cls = Err
74 @functools.wraps(func)
75 def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[T, Exception]:
76 try:
77 return ok_cls(func(*args, **kwargs))
78 except Exception as e:
79 return err_cls(e)
81 return cast(Callable[P, Result[T, Exception]], wrapper)
84@overload
85def safe(
86 func: Callable[P, T],
87) -> Callable[P, Result[T, Exception]]: ...
90@overload
91def safe(
92 func: Callable[P, Awaitable[T]],
93) -> Callable[P, Awaitable[Result[T, Exception]]]: ...
96def safe(
97 func: Callable[P, T] | Callable[P, Awaitable[T]],
98) -> Callable[P, Result[T, Exception]] | Callable[P, Awaitable[Result[T, Exception]]]:
99 """Wrap a sync or async function to convert exceptions into Err results.
101 Detect whether *func* is a coroutine function and choose the
102 appropriate wrapper so that ``await``-able functions remain
103 ``await``-able and synchronous functions stay synchronous.
105 Args:
106 func: The sync or async function to wrap.
108 Returns:
109 A wrapped function that returns ``Result[T, Exception]``
110 (or a coroutine resolving to one).
112 Example:
113 >>> @safe
114 ... def parse_int(s: str) -> int:
115 ... return int(s)
116 >>> parse_int("42")
117 Ok(42)
118 >>> parse_int("invalid")
119 Err(ValueError("invalid literal for int()..."))
121 """
122 if inspect.iscoroutinefunction(func):
123 return _safe_async_wrapper(cast(Callable[P, Awaitable[T]], func))
124 return _safe_sync_wrapper(cast(Callable[P, T], func))
127class SafeFromDecorator(Protocol[E_co]):
128 """Protocol for safe_from decorator."""
130 @overload
131 def __call__(self, func: Callable[P, T]) -> Callable[P, Result[T, E_co]]: ...
133 @overload
134 def __call__(
135 self,
136 func: Callable[P, Awaitable[T]],
137 ) -> Callable[P, Awaitable[Result[T, E_co]]]: ...
140def _safe_from_async_wrapper(
141 func: Callable[P, Awaitable[T]],
142 exception_types: tuple[type[E], ...],
143) -> Callable[P, Awaitable[Result[T, E]]]:
145 @functools.wraps(func)
146 async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[T, E]:
147 try:
148 return Ok(await func(*args, **kwargs))
149 except exception_types as e:
150 return Err(e)
152 return cast(Callable[P, Awaitable[Result[T, E]]], async_wrapper)
155def _safe_from_sync_wrapper(
156 func: Callable[P, T],
157 exception_types: tuple[type[E], ...],
158) -> Callable[P, Result[T, E]]:
160 @functools.wraps(func)
161 def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[T, E]:
162 try:
163 return Ok(func(*args, **kwargs))
164 except exception_types as e:
165 return Err(e)
167 return cast(Callable[P, Result[T, E]], wrapper)
170def safe_from(
171 *exception_types: type[E],
172) -> SafeFromDecorator[E]:
173 """Decorator factory to catch specific exceptions as Err.
175 Only catches specified exception types; others propagate normally.
177 Args:
178 *exception_types: Exception types to convert to Err.
180 Returns:
181 Decorator that wraps function with selective error handling.
183 Example:
184 >>> @safe_from(ValueError, TypeError)
185 ... def process(data: str) -> int:
186 ... return int(data)
187 >>> process("abc")
188 Err(ValueError(...))
190 """
192 def decorator(
193 func: Callable[P, T] | Callable[P, Awaitable[T]],
194 ) -> Callable[P, Result[T, E]] | Callable[P, Awaitable[Result[T, E]]]:
195 if inspect.iscoroutinefunction(func):
196 return _safe_from_async_wrapper(
197 cast(Callable[P, Awaitable[T]], func), exception_types
198 )
199 return _safe_from_sync_wrapper(cast(Callable[P, T], func), exception_types)
201 return cast(SafeFromDecorator[E], decorator)
204def _extract_ok_values(
205 results: list[Result[T, E]] | tuple[Result[T, E], ...],
206) -> Result[list[T], E] | None:
207 try:
208 return Ok([cast(Ok[T], r).ok_value for r in results])
209 except AttributeError:
210 return None
213def _collect_list(
214 results: list[Result[T, E]] | tuple[Result[T, E], ...],
215) -> Result[list[T], E] | None:
216 # Explicitly verify types to avoid bypassing formal proof
217 # We fall back to the slow path if we aren't absolutely certain
218 # it's a structural match, to handle edge cases properly.
219 if not results:
220 return Ok([])
222 first = results[0]
223 # Fast path: homogeneous exact types
224 if type(first) is Ok:
225 return _extract_ok_values(results)
227 return None
230def _collect_iterable(
231 results: Iterable[Result[T, E]],
232) -> Result[list[T], E]:
233 """Collect an iterable of Results iteratively."""
234 ok_cls = Ok
235 err_cls = Err
236 values: list[T] = []
237 append = values.append
238 for result in results:
239 # We use explicit type checks (isinstance) but pre-cache the constructors.
240 # type(result) is ok_cls would be even faster but breaks subclassing.
241 if isinstance(result, ok_cls):
242 append(result.ok_value)
243 elif isinstance(result, err_cls):
244 return result
245 else:
246 raise TypeError(f"Expected Result, got {type(result)}")
247 return ok_cls(values)
250def collect_results(
251 results: Iterable[Result[T, E]],
252) -> Result[list[T], E]:
253 """Collect an iterable of Results into a single Result.
255 If all results are Ok, returns Ok with list of values.
256 If any result is Err, returns the first Err encountered.
258 Args:
259 results: Iterable of Result objects.
261 Returns:
262 Ok(list[T]) if all are Ok, otherwise first Err.
264 Example:
265 >>> collect_results([Ok(1), Ok(2), Ok(3)])
266 Ok([1, 2, 3])
267 >>> collect_results([Ok(1), Err("fail"), Ok(3)])
268 Err("fail")
270 """
271 if isinstance(results, (list, tuple)):
272 res_seq = cast(list[Result[T, E]] | tuple[Result[T, E], ...], results)
273 optimized_res = _collect_list(res_seq)
274 if optimized_res is not None:
275 return optimized_res
277 return _collect_iterable(results)
280@overload
281async def map_async(
282 result: Ok[T],
283 func: Callable[[T], Awaitable[U]],
284) -> Result[U, E]: ...
287@overload
288async def map_async(
289 result: Err[E],
290 func: Callable[[T], Awaitable[U]],
291) -> Err[E]: ...
294@overload
295async def map_async(
296 result: Result[T, E],
297 func: Callable[[T], Awaitable[U]],
298) -> Result[U, E]: ...
301async def map_async(
302 result: Result[T, E],
303 func: Callable[[T], Awaitable[U]],
304) -> Result[U, E]:
305 """Asynchronously apply a function to the value of an Ok result.
307 If the result is Err, returns it unchanged.
309 Args:
310 result: The Result to process.
311 func: Awaitable function to apply to the Ok value.
313 Returns:
314 New Result containing the processed value or original error.
316 Example:
317 >>> async def process(x: int) -> str:
318 ... return str(x * 2)
319 >>> await map_async(Ok(5), process)
320 Ok('10')
321 >>> await map_async(Err("fail"), process)
322 Err('fail')
324 """
325 if isinstance(result, Ok):
326 return Ok(await func(result.ok_value))
327 return result
330@overload
331async def and_then_async(
332 result: Ok[T],
333 func: Callable[[T], Awaitable[Result[U, E]]],
334) -> Result[U, E]: ...
337@overload
338async def and_then_async(
339 result: Err[E],
340 func: Callable[[T], Awaitable[Result[U, E]]],
341) -> Err[E]: ...
344@overload
345async def and_then_async(
346 result: Result[T, E],
347 func: Callable[[T], Awaitable[Result[U, E]]],
348) -> Result[U, E]: ...
351async def and_then_async(
352 result: Result[T, E],
353 func: Callable[[T], Awaitable[Result[U, E]]],
354) -> Result[U, E]:
355 """Asynchronously chain operations that return Results.
357 If the result is Ok, asynchronously applies `func` and returns its Result.
358 If the result is Err, returns it unchanged.
360 Args:
361 result: The Result to process.
362 func: Awaitable function taking the Ok value and returning a new Result.
364 Returns:
365 The new Result from `func` or the original error.
367 Example:
368 >>> async def fetch_user(uid: int) -> Result[str, ValueError]:
369 ... if uid == 1:
370 ... return Ok("Alice")
371 ... return Err(ValueError("User not found"))
372 >>> await and_then_async(Ok(1), fetch_user)
373 Ok('Alice')
374 >>> await and_then_async(Ok(2), fetch_user)
375 Err(ValueError('User not found'))
376 >>> await and_then_async(Err(ValueError("No DB")), fetch_user)
377 Err(ValueError('No DB'))
379 """
380 if isinstance(result, Ok):
381 return await func(result.ok_value)
382 return result