Coverage for src/taipanstack/core/result.py: 100%
83 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 15:01 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 15:01 +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")
49@overload
50def safe(
51 func: Callable[P, T],
52) -> Callable[P, Result[T, Exception]]: ...
55@overload
56def safe(
57 func: Callable[P, Awaitable[T]],
58) -> Callable[P, Awaitable[Result[T, Exception]]]: ...
61def safe(
62 func: Callable[P, T] | Callable[P, Awaitable[T]],
63) -> Callable[P, Result[T, Exception]] | Callable[P, Awaitable[Result[T, Exception]]]:
64 """Wrap a sync or async function to convert exceptions into Err results.
66 Detect whether *func* is a coroutine function and choose the
67 appropriate wrapper so that ``await``-able functions remain
68 ``await``-able and synchronous functions stay synchronous.
70 Args:
71 func: The sync or async function to wrap.
73 Returns:
74 A wrapped function that returns ``Result[T, Exception]``
75 (or a coroutine resolving to one).
77 Example:
78 >>> @safe
79 ... def parse_int(s: str) -> int:
80 ... return int(s)
81 >>> parse_int("42")
82 Ok(42)
83 >>> parse_int("invalid")
84 Err(ValueError("invalid literal for int()..."))
86 """
87 # Pre-cache constructors for minor speedup in tight loops
88 # (LOAD_DEREF is faster than LOAD_GLOBAL)
89 ok_cls = Ok
90 err_cls = Err
92 if inspect.iscoroutinefunction(func):
93 # Cast once here to satisfy mypy inside the closure
94 func_coro = cast(Callable[P, Awaitable[T]], func)
96 @functools.wraps(func)
97 async def async_wrapper(
98 *args: P.args,
99 **kwargs: P.kwargs,
100 ) -> Result[T, Exception]:
101 try:
102 return ok_cls(await func_coro(*args, **kwargs))
103 except Exception as e:
104 return err_cls(e)
106 return cast(Callable[P, Awaitable[Result[T, Exception]]], async_wrapper)
108 # Cast once here to satisfy mypy inside the closure
109 func_sync = cast(Callable[P, T], func)
111 @functools.wraps(func)
112 def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[T, Exception]:
113 try:
114 return ok_cls(func_sync(*args, **kwargs))
115 except Exception as e:
116 return err_cls(e)
118 return cast(Callable[P, Result[T, Exception]], wrapper)
121class SafeFromDecorator(Protocol[E_co]):
122 """Protocol for safe_from decorator."""
124 @overload
125 def __call__(self, func: Callable[P, T]) -> Callable[P, Result[T, E_co]]: ...
127 @overload
128 def __call__(
129 self,
130 func: Callable[P, Awaitable[T]],
131 ) -> Callable[P, Awaitable[Result[T, E_co]]]: ...
134def safe_from(
135 *exception_types: type[E],
136) -> SafeFromDecorator[E]:
137 """Decorator factory to catch specific exceptions as Err.
139 Only catches specified exception types; others propagate normally.
141 Args:
142 *exception_types: Exception types to convert to Err.
144 Returns:
145 Decorator that wraps function with selective error handling.
147 Example:
148 >>> @safe_from(ValueError, TypeError)
149 ... def process(data: str) -> int:
150 ... return int(data)
151 >>> process("abc")
152 Err(ValueError(...))
154 """
156 def decorator(
157 func: Callable[P, T] | Callable[P, Awaitable[T]],
158 ) -> Callable[P, Result[T, E]] | Callable[P, Awaitable[Result[T, E]]]:
159 if inspect.iscoroutinefunction(func):
160 func_coro = cast(Callable[P, Awaitable[T]], func)
162 @functools.wraps(func)
163 async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[T, E]:
164 try:
165 return Ok(await func_coro(*args, **kwargs))
166 except exception_types as e:
167 return Err(e)
169 return cast(Callable[P, Awaitable[Result[T, E]]], async_wrapper)
171 func_sync = cast(Callable[P, T], func)
173 @functools.wraps(func)
174 def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[T, E]:
175 try:
176 return Ok(func_sync(*args, **kwargs))
177 except exception_types as e:
178 return Err(e)
180 return cast(Callable[P, Result[T, E]], wrapper)
182 return cast(SafeFromDecorator[E], decorator)
185def _collect_list(
186 results: list[Result[T, E]] | tuple[Result[T, E], ...],
187) -> Result[list[T], E] | None:
188 try:
189 # We use a runtime # type: ignore to bypass mypy's strict check
190 # on the AttributeError strategy for extreme performance on the hot path
191 return Ok([r.ok_value for r in results]) # type: ignore[union-attr]
192 except AttributeError:
193 return None
196def _collect_iterable(
197 results: Iterable[Result[T, E]],
198) -> Result[list[T], E]:
199 """Collect an iterable of Results iteratively."""
200 ok_cls = Ok
201 err_cls = Err
202 values: list[T] = []
203 append = values.append
204 for result in results:
205 # We use explicit type checks (isinstance) but pre-cache the constructors.
206 # type(result) is ok_cls would be even faster but breaks subclassing.
207 if isinstance(result, ok_cls):
208 append(result.ok_value)
209 elif isinstance(result, err_cls):
210 return result
211 else:
212 # Fallback for structural compatibility
213 return result # type: ignore[unreachable]
214 return ok_cls(values)
217def collect_results(
218 results: Iterable[Result[T, E]],
219) -> Result[list[T], E]:
220 """Collect an iterable of Results into a single Result.
222 If all results are Ok, returns Ok with list of values.
223 If any result is Err, returns the first Err encountered.
225 Args:
226 results: Iterable of Result objects.
228 Returns:
229 Ok(list[T]) if all are Ok, otherwise first Err.
231 Example:
232 >>> collect_results([Ok(1), Ok(2), Ok(3)])
233 Ok([1, 2, 3])
234 >>> collect_results([Ok(1), Err("fail"), Ok(3)])
235 Err("fail")
237 """
238 if isinstance(results, (list, tuple)):
239 optimized_res = _collect_list(results)
240 if optimized_res is not None:
241 return optimized_res
243 return _collect_iterable(results)
246@overload
247async def map_async(
248 result: Ok[T],
249 func: Callable[[T], Awaitable[U]],
250) -> Result[U, E]: ...
253@overload
254async def map_async(
255 result: Err[E],
256 func: Callable[[T], Awaitable[U]],
257) -> Err[E]: ...
260@overload
261async def map_async(
262 result: Result[T, E],
263 func: Callable[[T], Awaitable[U]],
264) -> Result[U, E]: ...
267async def map_async(
268 result: Result[T, E],
269 func: Callable[[T], Awaitable[U]],
270) -> Result[U, E]:
271 """Asynchronously apply a function to the value of an Ok result.
273 If the result is Err, returns it unchanged.
275 Args:
276 result: The Result to process.
277 func: Awaitable function to apply to the Ok value.
279 Returns:
280 New Result containing the processed value or original error.
282 Example:
283 >>> async def process(x: int) -> str:
284 ... return str(x * 2)
285 >>> await map_async(Ok(5), process)
286 Ok('10')
287 >>> await map_async(Err("fail"), process)
288 Err('fail')
290 """
291 if isinstance(result, Ok):
292 return Ok(await func(result.ok_value))
293 return result
296@overload
297async def and_then_async(
298 result: Ok[T],
299 func: Callable[[T], Awaitable[Result[U, E]]],
300) -> Result[U, E]: ...
303@overload
304async def and_then_async(
305 result: Err[E],
306 func: Callable[[T], Awaitable[Result[U, E]]],
307) -> Err[E]: ...
310@overload
311async def and_then_async(
312 result: Result[T, E],
313 func: Callable[[T], Awaitable[Result[U, E]]],
314) -> Result[U, E]: ...
317async def and_then_async(
318 result: Result[T, E],
319 func: Callable[[T], Awaitable[Result[U, E]]],
320) -> Result[U, E]:
321 """Asynchronously chain operations that return Results.
323 If the result is Ok, asynchronously applies `func` and returns its Result.
324 If the result is Err, returns it unchanged.
326 Args:
327 result: The Result to process.
328 func: Awaitable function taking the Ok value and returning a new Result.
330 Returns:
331 The new Result from `func` or the original error.
333 Example:
334 >>> async def fetch_user(uid: int) -> Result[str, ValueError]:
335 ... if uid == 1:
336 ... return Ok("Alice")
337 ... return Err(ValueError("User not found"))
338 >>> await and_then_async(Ok(1), fetch_user)
339 Ok('Alice')
340 >>> await and_then_async(Ok(2), fetch_user)
341 Err(ValueError('User not found'))
342 >>> await and_then_async(Err(ValueError("No DB")), fetch_user)
343 Err(ValueError('No DB'))
345 """
346 if isinstance(result, Ok):
347 return await func(result.ok_value)
348 return result