From 3229b279a9f862014e2de2553ad7d33009eb6001 Mon Sep 17 00:00:00 2001 From: K <51281148+K-dash@users.noreply.github.com> Date: Thu, 1 Jan 2026 23:55:33 +0900 Subject: [PATCH] Refactor Result and Option types to remove error type parameters and enhance error handling --- README.md | 57 +++++++-- pyropust/__init__.pyi | 105 ++++++++++------ pyropust/catch.py | 17 ++- pyropust/do.py | 12 +- pyropust/pyropust_native.pyi | 95 ++++++++++----- src/py/blueprint.rs | 2 +- src/py/error.rs | 159 +++++++++++++++++++++++++ src/py/option.rs | 6 +- src/py/result.rs | 178 ++++++++++++++++++++++++++-- tests/option/test_conversion.py | 13 +- tests/option/test_utility.py | 22 ++-- tests/result/test_composition.py | 106 ++++++++--------- tests/result/test_conversion.py | 22 ++-- tests/result/test_extraction.py | 36 +++--- tests/result/test_query.py | 42 +++---- tests/result/test_transformation.py | 73 ++++++------ tests/result/test_utility.py | 44 +++---- tests/test_blueprint.py | 6 +- tests/test_examples.py | 4 +- tests/test_runtime.py | 124 ++++++++++++++++++- tests/typing/test_types.py | 59 ++++----- 21 files changed, 861 insertions(+), 321 deletions(-) diff --git a/README.md b/README.md index 5686f2c..c99e50d 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ You do not need to switch everything at once. A realistic path is: ### 1) Result and Option -Rust-style `Result[T, E]` and `Option[T]` as first-class values. +Rust-style `Result[T]` and `Option[T]` as first-class values. ```python from pyropust import Ok, Err, Some, None_ @@ -68,12 +68,12 @@ maybe = Some(42) empty = None_() ``` -Result is explicit about failures. You can return it from functions and branch on `is_ok / is_err` without exceptions. +Result is explicit about failures. All failures are represented as `RopustError`. You can return it from functions and branch on `is_ok / is_err` without exceptions. ```python from pyropust import Ok, Err, Result -def divide(a: int, b: int) -> Result[float, str]: +def divide(a: int, b: int) -> Result[float]: if b == 0: return Err("division by zero") return Ok(a / b) @@ -83,6 +83,7 @@ if res.is_ok(): value = res.unwrap() else: error = res.unwrap_err() + print(error.message) ``` Keep Option short and explicit: you must unwrap or provide defaults. @@ -111,8 +112,8 @@ from pyropust import Ok res = ( Ok("123") - .map(int) # Result[int, E] - .map(lambda x: x * 2) # Result[int, E] + .map(int) # Result[int] + .map(lambda x: x * 2) # Result[int] .and_then(lambda x: Ok(f"Value is {x}")) ) print(res.unwrap()) # "Value is 246" @@ -121,21 +122,58 @@ print(res.unwrap()) # "Value is 246" When to use: `map/and_then` is best for small, expression-style transforms where each step is a function. > [!TIP] -> **Type Hint for `and_then`**: When using `and_then` with a callback that may return `Err`, define the initial `Result` with an explicit return type annotation. This ensures the error type is correctly inferred. +> **Type Hint for `and_then`**: When using `and_then` with a callback that may return `Err`, define the initial `Result` with an explicit return type annotation. This ensures the Ok type is correctly inferred. > > ```python > from pyropust import Ok, Err, Result > -> def fetch_data() -> Result[int, str]: # Declare error type here +> def fetch_data() -> Result[int]: # Declare ok type here > return Ok(42) > -> def validate(x: int) -> Result[int, str]: +> def validate(x: int) -> Result[int]: > return Err("invalid") if x < 0 else Ok(x) > > # Error type flows correctly through the chain > result = fetch_data().and_then(validate) > ``` +#### Adding context and error codes + +In real applications, errors often need additional context as they move up the stack. +pyropust provides helpers inspired by Rust’s `context` and error mapping patterns. + +```python +from pyropust import Result, Err + +def load_config(path: str) -> Result[str]: + return Err("file not found") + +result = load_config("/etc/app.toml").context( + "failed to load application config", + code="config.load", +) +``` + +- `context(...)` adds human-readable context while preserving the original cause +- The original error is kept as a structured cause chain + +You can also modify error codes for classification and observability: + +```python +result = load_config("/etc/app.toml").with_code("config.not_found") + +result = load_config("/etc/app.toml").map_err_code("startup") +``` + +Error codes are stable, machine-facing identifiers. +Messages are for humans and may change; codes are for branching, testing, and observability. + +These helpers make it easy to: + +- Add meaning at higher layers +- Classify failures without losing detail +- Keep error handling explicit and testable + ### 2) Blueprint: typed pipelines A **Blueprint** is a declarative pipeline that describes what happens to data, not how it is wired together. @@ -223,12 +261,13 @@ The `@do` decorator enables linear, Rust-style propagation of `Result`. from pyropust import Ok, Result, do @do -def process(data: str) -> Result[str, object]: +def process(data: str) -> Result[str]: text = yield Ok(data) return Ok(text.upper()) ``` When to use: `@do` reads like imperative code and is better when you need intermediate variables, early returns, or mixed steps. +Prefer `context` for adding meaning instead of catching exceptions. This is not syntax sugar over exceptions — it is structured propagation of `Result` values. diff --git a/pyropust/__init__.pyi b/pyropust/__init__.pyi index bff9de6..b7449bd 100644 --- a/pyropust/__init__.pyi +++ b/pyropust/__init__.pyi @@ -4,40 +4,52 @@ from typing import Generic, Never, TypeVar, overload # Variance-sensitive type parameters require old-style TypeVar T_co = TypeVar("T_co", covariant=True) -E_co = TypeVar("E_co", covariant=True) In_contra = TypeVar("In_contra", contravariant=True) Out_co = TypeVar("Out_co", covariant=True) -class Result(Generic[T_co, E_co]): +class Result(Generic[T_co]): def is_ok(self) -> bool: ... def is_err(self) -> bool: ... def is_ok_and(self, predicate: Callable[[T_co], object]) -> bool: ... - def is_err_and(self, predicate: Callable[[E_co], object]) -> bool: ... + def is_err_and(self, predicate: Callable[[RopustError], object]) -> bool: ... def unwrap(self) -> T_co: ... - def unwrap_err(self) -> E_co: ... + def unwrap_err(self) -> RopustError: ... def expect(self, msg: str) -> T_co: ... - def expect_err(self, msg: str) -> E_co: ... + def expect_err(self, msg: str) -> RopustError: ... def unwrap_or[U](self, default: U) -> T_co | U: ... - def unwrap_or_else[U](self, f: Callable[[E_co], U]) -> T_co | U: ... + def unwrap_or_else[U](self, f: Callable[[RopustError], U]) -> T_co | U: ... def ok(self) -> Option[T_co]: ... - def err(self) -> Option[E_co]: ... - def map[U](self, f: Callable[[T_co], U]) -> Result[U, E_co]: ... - def map_err[U](self, f: Callable[[E_co], U]) -> Result[T_co, U]: ... + def err(self) -> Option[RopustError]: ... + def map[U](self, f: Callable[[T_co], U]) -> Result[U]: ... + def map_err(self, f: Callable[[RopustError], RopustError]) -> Result[T_co]: ... def map_or[U](self, default: U, f: Callable[[T_co], U]) -> U: ... - def map_or_else[U](self, default_f: Callable[[E_co], U], f: Callable[[T_co], U]) -> U: ... - def inspect(self, f: Callable[[T_co], object]) -> Result[T_co, E_co]: ... - def inspect_err(self, f: Callable[[E_co], object]) -> Result[T_co, E_co]: ... - def and_[U](self, other: Result[U, E_co]) -> Result[U, E_co]: ... - def or_[F](self, other: Result[T_co, F]) -> Result[T_co, F]: ... - def or_else[F](self, f: Callable[[E_co], Result[T_co, F]]) -> Result[T_co, F]: ... - def and_then[U](self, f: Callable[[T_co], Result[U, E_co]]) -> Result[U, E_co]: ... - def flatten[T, E](self: Result[Result[T, E], E]) -> Result[T, E]: ... - def transpose[T, E](self: Result[Option[T], E]) -> Option[Result[T, E]]: ... + def map_or_else[U]( + self, default_f: Callable[[RopustError], U], f: Callable[[T_co], U] + ) -> U: ... + def inspect(self, f: Callable[[T_co], object]) -> Result[T_co]: ... + def inspect_err(self, f: Callable[[RopustError], object]) -> Result[T_co]: ... + def context( + self, + message: str, + *, + code: str = "context", + metadata: Mapping[str, str] | None = None, + op: str | None = None, + path: list[str | int] | None = None, + expected: str | None = None, + got: str | None = None, + ) -> Result[T_co]: ... + def with_code(self, code: str) -> Result[T_co]: ... + def map_err_code(self, prefix: str) -> Result[T_co]: ... + def and_[U](self, other: Result[U]) -> Result[U]: ... + def or_(self, other: Result[T_co]) -> Result[T_co]: ... + def or_else(self, f: Callable[[RopustError], Result[T_co]]) -> Result[T_co]: ... + def and_then[U](self, f: Callable[[T_co], Result[U]]) -> Result[U]: ... + def flatten[T](self: Result[Result[T]]) -> Result[T]: ... + def transpose[T](self: Result[Option[T]]) -> Option[Result[T]]: ... def unwrap_or_raise(self, exc: BaseException) -> T_co: ... @classmethod - def attempt[T]( - cls, f: Callable[[], T], *exceptions: type[BaseException] - ) -> Result[T, RopustError]: ... + def attempt[T](cls, f: Callable[[], T], *exceptions: type[BaseException]) -> Result[T]: ... class Option(Generic[T_co]): def is_some(self) -> bool: ... @@ -59,11 +71,11 @@ class Option(Generic[T_co]): def or_else(self, f: Callable[[], Option[T_co]]) -> Option[T_co]: ... def xor(self, other: Option[T_co]) -> Option[T_co]: ... def flatten[T](self: Option[Option[T]]) -> Option[T]: ... - def transpose[T, E](self: Option[Result[T, E]]) -> Result[Option[T], E]: ... + def transpose[T](self: Option[Result[T]]) -> Result[Option[T]]: ... def zip[U](self, other: Option[U]) -> Option[tuple[T_co, U]]: ... def zip_with[U, R](self, other: Option[U], f: Callable[[T_co, U], R]) -> Option[R]: ... - def ok_or[E](self, error: E) -> Result[T_co, E]: ... - def ok_or_else[E](self, f: Callable[[], E]) -> Result[T_co, E]: ... + def ok_or(self, error: RopustError | BaseException | str) -> Result[T_co]: ... + def ok_or_else(self, f: Callable[[], RopustError | BaseException | str]) -> Result[T_co]: ... class ErrorKind: InvalidInput: ErrorKind @@ -92,6 +104,33 @@ class RopustError: def to_dict(self) -> dict[str, object]: ... @classmethod def from_dict(cls, data: dict[str, object]) -> RopustError: ... + @classmethod + def new( + cls, + code: str, + message: str, + *, + kind: ErrorKind | str | None = None, + op: str | None = None, + path: list[str | int] | None = None, + expected: str | None = None, + got: str | None = None, + metadata: Mapping[str, str] | None = None, + ) -> RopustError: ... + @classmethod + def wrap( + cls, + err: BaseException | RopustError, + *, + code: str, + message: str, + kind: ErrorKind | str | None = None, + op: str | None = None, + path: list[str | int] | None = None, + expected: str | None = None, + got: str | None = None, + metadata: Mapping[str, str] | None = None, + ) -> RopustError: ... class Operator(Generic[In_contra, Out_co]): ... @@ -229,17 +268,15 @@ class Op: def to_uppercase() -> Operator[str, str]: ... # END GENERATED OP -def Ok[T](value: T) -> Result[T, Never]: ... -def Err[E](error: E) -> Result[Never, E]: ... +def Ok[T](value: T) -> Result[T]: ... +def Err(error: RopustError | BaseException | str) -> Result[Never]: ... def Some[T](value: T) -> Option[T]: ... def None_() -> Option[Never]: ... -type Do[T, E, R] = Generator[Result[T, E], T, Result[R, E]] +type Do[T, R] = Generator[Result[T], T, Result[R]] -def do[**P, T, E, R](fn: Callable[P, Do[T, E, R]]) -> Callable[P, Result[R, E]]: ... -def run[OrigIn, Out]( - blueprint: Blueprint[OrigIn, Out], input: OrigIn -) -> Result[Out, RopustError]: ... +def do[**P, T, R](fn: Callable[P, Do[T, R]]) -> Callable[P, Result[R]]: ... +def run[OrigIn, Out](blueprint: Blueprint[OrigIn, Out], input: OrigIn) -> Result[Out]: ... def exception_to_ropust_error(exc: BaseException, code: str = "py_exception") -> RopustError: ... # Overload 1: Decorator with exception types (@catch() or @catch(ValueError, ...)) @@ -250,14 +287,14 @@ def catch[R]( # type: ignore[overload-overlap] exc: type[BaseException], /, *more_exceptions: type[BaseException], -) -> Callable[[Callable[..., R]], Callable[..., Result[R, RopustError]]]: ... +) -> Callable[[Callable[..., R]], Callable[..., Result[R]]]: ... # Overload 2: Decorator without arguments (@catch()) @overload -def catch[R]() -> Callable[[Callable[..., R]], Callable[..., Result[R, RopustError]]]: ... +def catch[R]() -> Callable[[Callable[..., R]], Callable[..., Result[R]]]: ... # Overload 3: Bare decorator usage (@catch) @overload -def catch[R](fn: Callable[..., R], /) -> Callable[..., Result[R, RopustError]]: ... +def catch[R](fn: Callable[..., R], /) -> Callable[..., Result[R]]: ... __all__: list[str] diff --git a/pyropust/catch.py b/pyropust/catch.py index cd7d096..551ab2c 100644 --- a/pyropust/catch.py +++ b/pyropust/catch.py @@ -4,7 +4,7 @@ from functools import wraps from typing import overload -from .pyropust_native import Result, RopustError +from .pyropust_native import Result def _is_exception_type(value: object) -> bool: @@ -14,9 +14,9 @@ def _is_exception_type(value: object) -> bool: def _decorate[**P, R]( fn: Callable[P, R], exc_types: tuple[type[BaseException], ...], -) -> Callable[P, Result[R, RopustError]]: +) -> Callable[P, Result[R]]: @wraps(fn) - def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[R, RopustError]: + def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[R]: return Result.attempt(lambda: fn(*args, **kwargs), *exc_types) return wrapper @@ -24,22 +24,19 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[R, RopustError]: # Overload 1: Bare decorator usage (@catch) @overload -def catch[**P, R](fn: Callable[P, R], /) -> Callable[P, Result[R, RopustError]]: ... +def catch[**P, R](fn: Callable[P, R], /) -> Callable[P, Result[R]]: ... # Overload 2: Decorator with exception types (@catch() or @catch(ValueError)) @overload def catch[**P, R]( *exc_types: type[BaseException], -) -> Callable[[Callable[P, R]], Callable[P, Result[R, RopustError]]]: ... +) -> Callable[[Callable[P, R]], Callable[P, Result[R]]]: ... def catch[**P, R]( *args: type[BaseException] | Callable[P, R], -) -> ( - Callable[P, Result[R, RopustError]] - | Callable[[Callable[P, R]], Callable[P, Result[R, RopustError]]] -): +) -> Callable[P, Result[R]] | Callable[[Callable[P, R]], Callable[P, Result[R]]]: """Convert exceptions into Result using RopustError. Can be used as @catch or @catch(ValueError, TypeError). @@ -59,7 +56,7 @@ def catch[**P, R]( if not exc_types: exc_types = [Exception] - def decorator(fn: Callable[P, R]) -> Callable[P, Result[R, RopustError]]: + def decorator(fn: Callable[P, R]) -> Callable[P, Result[R]]: return _decorate(fn, tuple(exc_types)) return decorator diff --git a/pyropust/do.py b/pyropust/do.py index ebedd32..da711b7 100644 --- a/pyropust/do.py +++ b/pyropust/do.py @@ -7,11 +7,11 @@ from .pyropust_native import Result -def do[**P, T, E, R]( - fn: Callable[P, Generator[Result[T, E], T, Result[R, E]]], -) -> Callable[P, Result[R, E]]: +def do[**P, T, R]( + fn: Callable[P, Generator[Result[T], T, Result[R]]], +) -> Callable[P, Result[R]]: @wraps(fn) - def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[R, E]: + def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[R]: gen = fn(*args, **kwargs) if not hasattr(gen, "send"): raise TypeError("@do function must return a generator") @@ -28,9 +28,9 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[R, E]: if not isinstance(current, Result): raise TypeError("yielded value must be Result") if current.is_err(): - # On error, the value type is irrelevant, so cast to Result[R, E] + # On error, the value type is irrelevant, so cast to Result[R] if TYPE_CHECKING: - return cast("Result[R, E]", current) + return cast("Result[R]", current) return current try: current = gen.send(current.unwrap()) diff --git a/pyropust/pyropust_native.pyi b/pyropust/pyropust_native.pyi index e9bf0b1..b4a599d 100644 --- a/pyropust/pyropust_native.pyi +++ b/pyropust/pyropust_native.pyi @@ -10,40 +10,52 @@ from typing import Generic, Never, TypeVar # Variance-sensitive type parameters require old-style TypeVar T_co = TypeVar("T_co", covariant=True) -E_co = TypeVar("E_co", covariant=True) In_contra = TypeVar("In_contra", contravariant=True) Out_co = TypeVar("Out_co", covariant=True) -class Result(Generic[T_co, E_co]): +class Result(Generic[T_co]): def is_ok(self) -> bool: ... def is_err(self) -> bool: ... def is_ok_and(self, predicate: Callable[[T_co], object]) -> bool: ... - def is_err_and(self, predicate: Callable[[E_co], object]) -> bool: ... + def is_err_and(self, predicate: Callable[[RopustError], object]) -> bool: ... def unwrap(self) -> T_co: ... - def unwrap_err(self) -> E_co: ... + def unwrap_err(self) -> RopustError: ... def expect(self, msg: str) -> T_co: ... - def expect_err(self, msg: str) -> E_co: ... + def expect_err(self, msg: str) -> RopustError: ... def unwrap_or[U](self, default: U) -> T_co | U: ... - def unwrap_or_else[U](self, f: Callable[[E_co], U]) -> T_co | U: ... + def unwrap_or_else[U](self, f: Callable[[RopustError], U]) -> T_co | U: ... def ok(self) -> Option[T_co]: ... - def err(self) -> Option[E_co]: ... - def map[U](self, f: Callable[[T_co], U]) -> Result[U, E_co]: ... - def map_err[U](self, f: Callable[[E_co], U]) -> Result[T_co, U]: ... + def err(self) -> Option[RopustError]: ... + def map[U](self, f: Callable[[T_co], U]) -> Result[U]: ... + def map_err(self, f: Callable[[RopustError], RopustError]) -> Result[T_co]: ... def map_or[U](self, default: U, f: Callable[[T_co], U]) -> U: ... - def map_or_else[U](self, default_f: Callable[[E_co], U], f: Callable[[T_co], U]) -> U: ... - def inspect(self, f: Callable[[T_co], object]) -> Result[T_co, E_co]: ... - def inspect_err(self, f: Callable[[E_co], object]) -> Result[T_co, E_co]: ... - def and_[U](self, other: Result[U, E_co]) -> Result[U, E_co]: ... - def or_[F](self, other: Result[T_co, F]) -> Result[T_co, F]: ... - def or_else[F](self, f: Callable[[E_co], Result[T_co, F]]) -> Result[T_co, F]: ... - def and_then[U](self, f: Callable[[T_co], Result[U, E_co]]) -> Result[U, E_co]: ... - def flatten[T, E](self: Result[Result[T, E], E]) -> Result[T, E]: ... - def transpose[T, E](self: Result[Option[T], E]) -> Option[Result[T, E]]: ... + def map_or_else[U]( + self, default_f: Callable[[RopustError], U], f: Callable[[T_co], U] + ) -> U: ... + def inspect(self, f: Callable[[T_co], object]) -> Result[T_co]: ... + def inspect_err(self, f: Callable[[RopustError], object]) -> Result[T_co]: ... + def context( + self, + message: str, + *, + code: str = "context", + metadata: Mapping[str, str] | None = None, + op: str | None = None, + path: list[str | int] | None = None, + expected: str | None = None, + got: str | None = None, + ) -> Result[T_co]: ... + def with_code(self, code: str) -> Result[T_co]: ... + def map_err_code(self, prefix: str) -> Result[T_co]: ... + def and_[U](self, other: Result[U]) -> Result[U]: ... + def or_(self, other: Result[T_co]) -> Result[T_co]: ... + def or_else(self, f: Callable[[RopustError], Result[T_co]]) -> Result[T_co]: ... + def and_then[U](self, f: Callable[[T_co], Result[U]]) -> Result[U]: ... + def flatten[T](self: Result[Result[T]]) -> Result[T]: ... + def transpose[T](self: Result[Option[T]]) -> Option[Result[T]]: ... def unwrap_or_raise(self, exc: BaseException) -> T_co: ... @classmethod - def attempt[T]( - cls, f: Callable[[], T], *exceptions: type[BaseException] - ) -> Result[T, RopustError]: ... + def attempt[T](cls, f: Callable[[], T], *exceptions: type[BaseException]) -> Result[T]: ... class Option(Generic[T_co]): def is_some(self) -> bool: ... @@ -65,11 +77,11 @@ class Option(Generic[T_co]): def or_else(self, f: Callable[[], Option[T_co]]) -> Option[T_co]: ... def xor(self, other: Option[T_co]) -> Option[T_co]: ... def flatten[T](self: Option[Option[T]]) -> Option[T]: ... - def transpose[T, E](self: Option[Result[T, E]]) -> Result[Option[T], E]: ... + def transpose[T](self: Option[Result[T]]) -> Result[Option[T]]: ... def zip[U](self, other: Option[U]) -> Option[tuple[T_co, U]]: ... def zip_with[U, R](self, other: Option[U], f: Callable[[T_co, U], R]) -> Option[R]: ... - def ok_or[E](self, error: E) -> Result[T_co, E]: ... - def ok_or_else[E](self, f: Callable[[], E]) -> Result[T_co, E]: ... + def ok_or(self, error: RopustError | BaseException | str) -> Result[T_co]: ... + def ok_or_else(self, f: Callable[[], RopustError | BaseException | str]) -> Result[T_co]: ... class ErrorKind: InvalidInput: ErrorKind @@ -98,6 +110,33 @@ class RopustError: def to_dict(self) -> dict[str, object]: ... @classmethod def from_dict(cls, data: dict[str, object]) -> RopustError: ... + @classmethod + def new( + cls, + code: str, + message: str, + *, + kind: ErrorKind | str | None = None, + op: str | None = None, + path: list[str | int] | None = None, + expected: str | None = None, + got: str | None = None, + metadata: Mapping[str, str] | None = None, + ) -> RopustError: ... + @classmethod + def wrap( + cls, + err: BaseException | RopustError, + *, + code: str, + message: str, + kind: ErrorKind | str | None = None, + op: str | None = None, + path: list[str | int] | None = None, + expected: str | None = None, + got: str | None = None, + metadata: Mapping[str, str] | None = None, + ) -> RopustError: ... class Operator(Generic[In_contra, Out_co]): ... @@ -235,11 +274,9 @@ class Op: def to_uppercase() -> Operator[str, str]: ... # END GENERATED OP -def Ok[T](value: T) -> Result[T, Never]: ... -def Err[E](error: E) -> Result[Never, E]: ... +def Ok[T](value: T) -> Result[T]: ... +def Err(error: RopustError | BaseException | str) -> Result[Never]: ... def Some[T](value: T) -> Option[T]: ... def None_() -> Option[Never]: ... -def run[OrigIn, Out]( - blueprint: Blueprint[OrigIn, Out], input: OrigIn -) -> Result[Out, RopustError]: ... +def run[OrigIn, Out](blueprint: Blueprint[OrigIn, Out], input: OrigIn) -> Result[Out]: ... def exception_to_ropust_error(exc: BaseException, code: str = "py_exception") -> RopustError: ... diff --git a/src/py/blueprint.rs b/src/py/blueprint.rs index d4b330e..cf3ef14 100644 --- a/src/py/blueprint.rs +++ b/src/py/blueprint.rs @@ -286,5 +286,5 @@ fn ropust_error( }, ) .expect("ropust error alloc"); - err(err_obj.into()) + err(py, err_obj.into()) } diff --git a/src/py/error.rs b/src/py/error.rs index e4296aa..df4d3bb 100644 --- a/src/py/error.rs +++ b/src/py/error.rs @@ -150,6 +150,104 @@ impl RopustError { self.__repr__() } + #[classmethod] + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = (code, message, *, kind = None, op = None, path = None, expected = None, got = None, metadata = None))] + fn new( + _cls: &Bound<'_, PyType>, + py: Python<'_>, + code: String, + message: String, + kind: Option>, + op: Option, + path: Option>, + expected: Option, + got: Option, + metadata: Option>, + ) -> PyResult { + let kind = extract_kind(py, kind, ErrorKind::InvalidInput)?; + let path = extract_path(py, path)?; + let metadata = extract_metadata(py, metadata)?; + + Ok(RopustError { + kind, + code, + message, + metadata, + op, + path, + expected, + got, + cause: None, + }) + } + + #[classmethod] + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = (err, *, code, message, kind = None, op = None, path = None, expected = None, got = None, metadata = None))] + fn wrap( + _cls: &Bound<'_, PyType>, + py: Python<'_>, + err: Py, + code: String, + message: String, + kind: Option>, + op: Option, + path: Option>, + expected: Option, + got: Option, + metadata: Option>, + ) -> PyResult { + let err_ref = err.bind(py); + if err_ref.is_none() { + return Err(PyTypeError::new_err( + "wrap expects an exception or RopustError", + )); + } + + let mut metadata = extract_metadata(py, metadata)?; + + let cause = if let Ok(cause_ref) = err_ref.extract::>() { + Some(cause_ref.__repr__()) + } else { + let base_exc = py.get_type::(); + if !err_ref.is_instance(base_exc.as_any())? { + return Err(PyTypeError::new_err( + "wrap expects an exception or RopustError", + )); + } + let py_err = PyErr::from_value(err_ref.clone()); + let cause_obj = build_ropust_error_from_pyerr(py, py_err, "py_exception"); + let cause_ref = cause_obj.bind(py).extract::>()?; + if !metadata.contains_key("cause_exception") { + if let Some(value) = cause_ref.metadata.get("exception") { + metadata.insert("cause_exception".to_string(), value.to_string()); + } + } + if !metadata.contains_key("cause_py_traceback") { + if let Some(value) = cause_ref.metadata.get("py_traceback") { + metadata.insert("cause_py_traceback".to_string(), value.to_string()); + } + } + Some(cause_ref.__repr__()) + }; + + let kind = extract_kind(py, kind, ErrorKind::Internal)?; + let path = extract_path(py, path)?; + + Ok(RopustError { + kind, + code, + message, + metadata, + op, + path, + expected, + got, + cause, + }) + } + fn to_dict(&self, py: Python<'_>) -> PyResult> { let dict = PyDict::new(py); dict.set_item("kind", self.kind.as_str())?; @@ -322,6 +420,67 @@ fn format_traceback(py: Python<'_>, py_err: &PyErr) -> Option { Some(formatted.concat()) } +fn extract_kind( + py: Python<'_>, + kind: Option>, + default_kind: ErrorKind, +) -> PyResult { + let Some(kind_value) = kind else { + return Ok(default_kind); + }; + let kind_value = kind_value.bind(py); + if let Ok(kind_obj) = kind_value.extract::>() { + return Ok(kind_obj.kind); + } + let kind_str = kind_value.extract::()?; + match kind_str.as_str() { + "InvalidInput" => Ok(ErrorKind::InvalidInput), + "NotFound" => Ok(ErrorKind::NotFound), + "Internal" => Ok(ErrorKind::Internal), + _ => Err(PyTypeError::new_err( + "invalid 'kind' field (expected ErrorKind or string)", + )), + } +} + +fn extract_path(py: Python<'_>, path: Option>) -> PyResult> { + let Some(path_value) = path else { + return Ok(Vec::new()); + }; + let path_value = path_value.bind(py); + let list = path_value.cast_exact::()?; + let mut path = Vec::new(); + for item in list.iter() { + if let Ok(key) = item.extract::() { + path.push(PathItem::Key(key)); + } else if let Ok(index) = item.extract::() { + path.push(PathItem::Index(index)); + } else { + return Err(PyTypeError::new_err( + "invalid path element (expected str or int)", + )); + } + } + Ok(path) +} + +fn extract_metadata( + py: Python<'_>, + metadata: Option>, +) -> PyResult> { + let mut data = HashMap::new(); + let Some(meta_value) = metadata else { + return Ok(data); + }; + let meta_dict = meta_value.bind(py).cast_exact::()?; + for (k, v) in meta_dict.iter() { + let key = k.extract::()?; + let value = v.extract::()?; + data.insert(key, value); + } + Ok(data) +} + fn get_optional_string(dict: &Bound<'_, PyDict>, key: &str) -> PyResult> { if let Some(value) = dict.get_item(key)? { value.extract::>() diff --git a/src/py/option.rs b/src/py/option.rs index 3bd2308..6968f8d 100644 --- a/src/py/option.rs +++ b/src/py/option.rs @@ -248,7 +248,7 @@ impl OptionObj { Ok(ok(py_option.into())) } else { let err_value = res_ref.err.as_ref().expect("err value").clone_ref(py); - Ok(err(err_value)) + Ok(err(py, err_value)) } } else { let option_obj = none_(); @@ -285,7 +285,7 @@ impl OptionObj { let value = self.value.as_ref().expect("some value").clone_ref(py); ok(value) } else { - err(error) + err(py, error) } } @@ -295,7 +295,7 @@ impl OptionObj { Ok(ok(value)) } else { let error = f.call0()?; - Ok(err(error.into())) + Ok(err(py, error.into())) } } } diff --git a/src/py/result.rs b/src/py/result.rs index 350de42..e673e54 100644 --- a/src/py/result.rs +++ b/src/py/result.rs @@ -1,9 +1,10 @@ use pyo3::exceptions::{PyBaseException, PyRuntimeError, PyTypeError}; use pyo3::prelude::*; -use pyo3::types::{PyAny, PyTuple, PyType}; +use pyo3::types::{PyAny, PyDict, PyList, PyTuple, PyType}; use pyo3::Bound; +use std::collections::HashMap; -use super::error::build_ropust_error_from_pyerr; +use super::error::{build_ropust_error_from_pyerr, ErrorKind, PathItem, RopustError}; use super::option::{none_, some, OptionObj}; #[pyclass(name = "Result")] @@ -95,7 +96,7 @@ impl ResultObj { let mapped = f.call1((value.clone_ref(py),))?; Ok(ok(mapped.into())) } else { - Ok(err(self.err.as_ref().expect("err value").clone_ref(py))) + Ok(err(py, self.err.as_ref().expect("err value").clone_ref(py))) } } @@ -105,7 +106,7 @@ impl ResultObj { } else { let value = self.err.as_ref().expect("err value"); let mapped = f.call1((value.clone_ref(py),))?; - Ok(err(mapped.into())) + Ok(err(py, mapped.into())) } } @@ -227,7 +228,7 @@ impl ResultObj { let out_ref: PyRef<'_, ResultObj> = out.extract()?; Ok(clone_result(py, &out_ref)) } else { - Ok(err(self.err.as_ref().expect("err value").clone_ref(py))) + Ok(err(py, self.err.as_ref().expect("err value").clone_ref(py))) } } @@ -263,7 +264,7 @@ impl ResultObj { let inner_ref: PyRef<'_, ResultObj> = value.extract(py)?; Ok(clone_result(py, &inner_ref)) } else { - Ok(err(self.err.as_ref().expect("err value").clone_ref(py))) + Ok(err(py, self.err.as_ref().expect("err value").clone_ref(py))) } } @@ -287,12 +288,82 @@ impl ResultObj { } } else { let err_value = self.err.as_ref().expect("err value").clone_ref(py); - let result_obj = err(err_value); + let result_obj = err(py, err_value); let py_result = Py::new(py, result_obj)?; Ok(some(py_result.into())) } } + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = (message, *, code = "context", metadata = None, op = None, path = None, expected = None, got = None))] + fn context( + &self, + py: Python<'_>, + message: &str, + code: &str, + metadata: Option>, + op: Option, + path: Option>, + expected: Option, + got: Option, + ) -> PyResult { + if self.is_ok { + return Ok(clone_result_value(py, self)); + } + + let err_value = self.err.as_ref().expect("err value").clone_ref(py); + let err_ref = err_value.bind(py).extract::>()?; + + let mut merged_metadata = err_ref.metadata.clone(); + let extra_metadata = extract_metadata(py, metadata)?; + merged_metadata.extend(extra_metadata); + + let path = match path { + Some(path_value) => extract_path(py, path_value)?, + None => err_ref.path.clone(), + }; + + let new_err = RopustError { + kind: err_ref.kind, + code: code.to_string(), + message: message.to_string(), + metadata: merged_metadata, + op: op.or_else(|| err_ref.op.clone()), + path, + expected: expected.or_else(|| err_ref.expected.clone()), + got: got.or_else(|| err_ref.got.clone()), + cause: Some(error_repr(&err_ref)), + }; + Ok(err(py, Py::new(py, new_err)?.into())) + } + + fn with_code(&self, py: Python<'_>, code: &str) -> PyResult { + if self.is_ok { + return Ok(clone_result_value(py, self)); + } + let err_value = self.err.as_ref().expect("err value").clone_ref(py); + let err_ref = err_value.bind(py).extract::>()?; + let mut new_err = err_ref.clone(); + new_err.code = code.to_string(); + Ok(err(py, Py::new(py, new_err)?.into())) + } + + fn map_err_code(&self, py: Python<'_>, prefix: &str) -> PyResult { + if self.is_ok { + return Ok(clone_result_value(py, self)); + } + let err_value = self.err.as_ref().expect("err value").clone_ref(py); + let err_ref = err_value.bind(py).extract::>()?; + let mut new_err = err_ref.clone(); + let prefix_dot = format!("{prefix}."); + if new_err.code.is_empty() { + new_err.code = prefix.to_string(); + } else if !new_err.code.starts_with(&prefix_dot) { + new_err.code = format!("{prefix}.{}", new_err.code); + } + Ok(err(py, Py::new(py, new_err)?.into())) + } + #[classmethod] #[pyo3(signature = (f, *exceptions))] fn attempt( @@ -344,8 +415,8 @@ pub fn py_ok(value: Py) -> ResultObj { } #[pyfunction(name = "Err")] -pub fn py_err(error: Py) -> ResultObj { - err(error) +pub fn py_err(py: Python<'_>, error: Py) -> ResultObj { + err(py, error) } // Internal constructor functions @@ -357,11 +428,12 @@ pub fn ok(value: Py) -> ResultObj { } } -pub fn err(error: Py) -> ResultObj { +pub fn err(py: Python<'_>, error: Py) -> ResultObj { + let normalized = normalize_error(py, error); ResultObj { is_ok: false, ok: None, - err: Some(error), + err: Some(normalized), } } @@ -373,6 +445,23 @@ fn clone_result(py: Python<'_>, out_ref: &PyRef<'_, ResultObj>) -> ResultObj { } } +fn clone_result_value(py: Python<'_>, out_ref: &ResultObj) -> ResultObj { + ResultObj { + is_ok: out_ref.is_ok, + ok: out_ref.ok.as_ref().map(|v| v.clone_ref(py)), + err: out_ref.err.as_ref().map(|v| v.clone_ref(py)), + } +} + +fn error_repr(err: &RopustError) -> String { + format!( + "RopustError(kind=ErrorKind.{}, code='{}', message='{}')", + err.kind.as_str(), + err.code, + err.message + ) +} + fn should_catch(py: Python<'_>, err: &PyErr, exceptions: &Bound<'_, PyTuple>) -> PyResult { if exceptions.is_empty() { let base_exc = py.get_type::(); @@ -388,5 +477,70 @@ fn should_catch(py: Python<'_>, err: &PyErr, exceptions: &Bound<'_, PyTuple>) -> fn ropust_error_from_exception(py: Python<'_>, py_err: PyErr) -> ResultObj { let err_obj = build_ropust_error_from_pyerr(py, py_err, "py_exception"); - err(err_obj.into()) + err(py, err_obj.into()) +} + +fn normalize_error(py: Python<'_>, error: Py) -> Py { + let error_ref = error.bind(py); + if error_ref.extract::>().is_ok() { + return error; + } + + let base_exc = py.get_type::(); + if error_ref.is_instance(base_exc.as_any()).unwrap_or(false) { + let py_err = PyErr::from_value(error_ref.clone()); + let err_obj = build_ropust_error_from_pyerr(py, py_err, "py_exception"); + return err_obj.into(); + } + + let message = error_ref + .extract::() + .unwrap_or_else(|_| "".to_string()); + let err_obj = RopustError { + kind: ErrorKind::InvalidInput, + code: "custom".to_string(), + message, + metadata: HashMap::new(), + op: None, + path: Vec::new(), + expected: None, + got: None, + cause: None, + }; + Py::new(py, err_obj).expect("ropust error alloc").into() +} + +fn extract_metadata( + py: Python<'_>, + metadata: Option>, +) -> PyResult> { + let mut data = HashMap::new(); + let Some(meta_value) = metadata else { + return Ok(data); + }; + let meta_dict = meta_value.bind(py).cast_exact::()?; + for (k, v) in meta_dict.iter() { + let key = k.extract::()?; + let value = v.extract::()?; + data.insert(key, value); + } + Ok(data) +} + +fn extract_path(py: Python<'_>, path: Py) -> PyResult> { + let path_value = path.bind(py); + let list = path_value.cast_exact::()?; + let mut path = Vec::new(); + for item in list.iter() { + if let Ok(key) = item.extract::() { + path.push(PathItem::Key(key)); + } else if let Ok(index) = item.extract::() { + path.push(PathItem::Index(index)); + } else { + return Err(PyTypeError::new_err( + "invalid path element (expected str or int)", + )); + } + } + Ok(path) } diff --git a/tests/option/test_conversion.py b/tests/option/test_conversion.py index f93b17c..b8c7cfe 100644 --- a/tests/option/test_conversion.py +++ b/tests/option/test_conversion.py @@ -29,7 +29,7 @@ def none_val() -> Option[int]: opt = none_val() result = opt.ok_or("error") assert result.is_err() - assert result.unwrap_err() == "error" + assert result.unwrap_err().message == "error" def test_ok_or_preserves_value_type(self) -> None: """Verify ok_or preserves value type.""" @@ -47,7 +47,7 @@ def none_val() -> Option[int]: error = ValueError("validation failed") result = opt.ok_or(error) assert result.is_err() - assert result.unwrap_err() == error + assert result.unwrap_err().message.endswith("validation failed") def test_ok_or_enables_result_chaining(self) -> None: """Use case: convert Option to Result for error handling.""" @@ -61,7 +61,7 @@ def none_val() -> Option[int]: opt_none = none_val() error = opt_none.ok_or("missing").unwrap_err() - assert error == "missing" + assert error.message == "missing" class TestOptionOkOrElse: @@ -83,7 +83,7 @@ def none_val() -> Option[int]: opt = none_val() result = opt.ok_or_else(lambda: "computed error") assert result.is_err() - assert result.unwrap_err() == "computed error" + assert result.unwrap_err().message == "computed error" def test_ok_or_else_function_not_called_on_some(self) -> None: """Verify error function is not called when Some.""" @@ -108,8 +108,7 @@ def none_val() -> Option[str]: result = opt.ok_or_else(lambda: ValueError("dynamically created error")) assert result.is_err() error = result.unwrap_err() - assert isinstance(error, ValueError) - assert str(error) == "dynamically created error" + assert error.message.endswith("dynamically created error") def test_ok_or_else_enables_lazy_error_creation(self) -> None: """Use case: avoid creating error unless needed.""" @@ -124,7 +123,7 @@ def none_val() -> Option[int]: opt_none = none_val() # Error is only created when needed error = opt_none.ok_or_else(lambda: "lazy error").unwrap_err() - assert error == "lazy error" + assert error.message == "lazy error" def expensive_error() -> str: diff --git a/tests/option/test_utility.py b/tests/option/test_utility.py index ff74396..2b65015 100644 --- a/tests/option/test_utility.py +++ b/tests/option/test_utility.py @@ -70,7 +70,7 @@ class TestOptionTranspose: def test_transpose_some_ok(self) -> None: """Transpose Some(Ok(value)) -> Ok(Some(value)).""" - def make_some_ok() -> Option[Result[int, str]]: + def make_some_ok() -> Option[Result[int]]: return Some(Ok(42)) opt = make_some_ok() @@ -83,17 +83,17 @@ def make_some_ok() -> Option[Result[int, str]]: def test_transpose_some_err(self) -> None: """Transpose Some(Err(error)) -> Err(error).""" - def make_err() -> Result[int, str]: + def make_err() -> Result[int]: return Err("error") - opt: Option[Result[int, str]] = Some(make_err()) + opt: Option[Result[int]] = Some(make_err()) result = opt.transpose() assert result.is_err() - assert result.unwrap_err() == "error" + assert result.unwrap_err().message == "error" def test_transpose_none(self) -> None: """Transpose None -> Ok(None).""" - opt: Option[Result[int, str]] = None_() + opt: Option[Result[int]] = None_() result = opt.transpose() assert result.is_ok() unwrapped = result.unwrap() @@ -108,15 +108,15 @@ def test_transpose_requires_result(self) -> None: def test_transpose_round_trip_some(self) -> None: """Verify transpose is reversible for Some(Ok(value)).""" - def make_option() -> Option[Result[int, str]]: + def make_option() -> Option[Result[int]]: return Some(Ok(42)) opt = make_option() - # Option[Result[T, E]] -> Result[Option[T], E] + # Option[Result[T]] -> Result[Option[T]] transposed = opt.transpose() assert transposed.is_ok() - # Result[Option[T], E] -> Option[Result[T, E]] + # Result[Option[T]] -> Option[Result[T]] back = transposed.transpose() assert back.is_some() inner_result = back.unwrap() @@ -126,15 +126,15 @@ def make_option() -> Option[Result[int, str]]: def test_transpose_round_trip_none(self) -> None: """Verify transpose is reversible for None.""" - def make_option() -> Option[Result[int, str]]: + def make_option() -> Option[Result[int]]: return None_() opt = make_option() - # Option[Result[T, E]] -> Result[Option[T], E] + # Option[Result[T]] -> Result[Option[T]] transposed = opt.transpose() assert transposed.is_ok() - # Result[Option[T], E] -> Option[Result[T, E]] + # Result[Option[T]] -> Option[Result[T]] back = transposed.transpose() assert back.is_none() diff --git a/tests/result/test_composition.py b/tests/result/test_composition.py index fc42d48..889d19e 100644 --- a/tests/result/test_composition.py +++ b/tests/result/test_composition.py @@ -1,7 +1,7 @@ """Tests for Result composition methods (and_then, and_, or_). Note: Type annotations are required when using Ok()/Err() constructors -because they have inferred types Result[T, Never] and Result[Never, E]. +because they have inferred types Result[T] and Result[Never]. This matches Rust's type system design. Use function return types or intermediate functions to satisfy strict type checking. """ @@ -20,20 +20,20 @@ def test_and_then_chains_ok_results(self) -> None: assert res.unwrap() == 20 def test_and_then_short_circuits_on_err(self) -> None: - res: Result[int, str] = Err("first error").and_then(lambda x: Ok(x * 2)) + res: Result[int] = Err("first error").and_then(lambda x: Ok(x * 2)) assert res.is_err() - assert res.unwrap_err() == "first error" + assert res.unwrap_err().message == "first error" def test_and_then_propagates_inner_err(self) -> None: - def get_ok() -> Result[int, str]: + def get_ok() -> Result[int]: return Ok(10) - def inner_err(_val: int) -> Result[int, str]: + def inner_err(_val: int) -> Result[int]: return Err("inner error") res = get_ok().and_then(inner_err) assert res.is_err() - assert res.unwrap_err() == "inner error" + assert res.unwrap_err().message == "inner error" def test_readme_example_functional_chaining(self) -> None: """Verify the README functional chaining example works.""" @@ -45,50 +45,50 @@ class TestResultAnd: """Test Result.and_() for combining Results.""" def test_and_returns_other_on_ok(self) -> None: - res1: Result[int, str] = Ok(10) - res2: Result[int, str] = Ok(20) + res1: Result[int] = Ok(10) + res2: Result[int] = Ok(20) result = res1.and_(res2) assert result.is_ok() assert result.unwrap() == 20 def test_and_returns_self_on_err(self) -> None: - res1: Result[int, str] = Err("error1") - res2: Result[int, str] = Ok(20) + res1: Result[int] = Err("error1") + res2: Result[int] = Ok(20) result = res1.and_(res2) assert result.is_err() - assert result.unwrap_err() == "error1" + assert result.unwrap_err().message == "error1" def test_and_ok_with_err_returns_err(self) -> None: # Type annotations are required because Ok() and Err() constructors - # have inferred types Result[T, Never] and Result[Never, E] respectively. + # have inferred types Result[T] and Result[Never] respectively. # This matches Rust's type system design. # Note: Using intermediate function calls to satisfy pyright's strict type checking - def ok_val() -> Result[int, str]: + def ok_val() -> Result[int]: return Ok(10) - def err_val() -> Result[int, str]: + def err_val() -> Result[int]: return Err("error2") res1 = ok_val() res2 = err_val() result = res1.and_(res2) assert result.is_err() - assert result.unwrap_err() == "error2" + assert result.unwrap_err().message == "error2" def test_and_both_err_returns_first_err(self) -> None: - res1: Result[int, str] = Err("error1") - res2: Result[int, str] = Err("error2") + res1: Result[int] = Err("error1") + res2: Result[int] = Err("error2") result = res1.and_(res2) assert result.is_err() - assert result.unwrap_err() == "error1" + assert result.unwrap_err().message == "error1" def test_and_enables_sequential_validation(self) -> None: """Use case: sequential validation where all must succeed.""" - def validate_positive(x: int) -> Result[int, str]: + def validate_positive(x: int) -> Result[int]: return Ok(x) if x > 0 else Err("must be positive") - def validate_range(x: int) -> Result[int, str]: + def validate_range(x: int) -> Result[int]: return Ok(x) if x < 100 else Err("must be less than 100") # Success case @@ -99,28 +99,28 @@ def validate_range(x: int) -> Result[int, str]: # First validation fails result = validate_positive(-5).and_(validate_range(50)) assert result.is_err() - assert result.unwrap_err() == "must be positive" + assert result.unwrap_err().message == "must be positive" class TestResultOr: """Test Result.or_() for fallback Results.""" def test_or_returns_self_on_ok(self) -> None: - res1: Result[int, str] = Ok(10) - res2: Result[int, str] = Ok(20) + res1: Result[int] = Ok(10) + res2: Result[int] = Ok(20) result = res1.or_(res2) assert result.is_ok() assert result.unwrap() == 10 def test_or_returns_other_on_err(self) -> None: # Type annotations are required because Ok() and Err() constructors - # have inferred types Result[T, Never] and Result[Never, E] respectively. + # have inferred types Result[T] and Result[Never] respectively. # This matches Rust's type system design. # Note: Using intermediate function calls to satisfy pyright's strict type checking - def err_val() -> Result[int, str]: + def err_val() -> Result[int]: return Err("error1") - def ok_val() -> Result[int, str]: + def ok_val() -> Result[int]: return Ok(20) res1 = err_val() @@ -130,29 +130,29 @@ def ok_val() -> Result[int, str]: assert result.unwrap() == 20 def test_or_both_ok_returns_first_ok(self) -> None: - res1: Result[int, str] = Ok(10) - res2: Result[int, str] = Ok(20) + res1: Result[int] = Ok(10) + res2: Result[int] = Ok(20) result = res1.or_(res2) assert result.is_ok() assert result.unwrap() == 10 def test_or_both_err_returns_second_err(self) -> None: - res1: Result[int, str] = Err("error1") - res2: Result[int, str] = Err("error2") + res1: Result[int] = Err("error1") + res2: Result[int] = Err("error2") result = res1.or_(res2) assert result.is_err() - assert result.unwrap_err() == "error2" + assert result.unwrap_err().message == "error2" def test_or_enables_fallback_chain(self) -> None: """Use case: try multiple sources until one succeeds.""" - def fetch_from_cache() -> Result[str, str]: + def fetch_from_cache() -> Result[str]: return Err("cache miss") - def fetch_from_database() -> Result[str, str]: + def fetch_from_database() -> Result[str]: return Err("db connection failed") - def fetch_from_default() -> Result[str, str]: + def fetch_from_default() -> Result[str]: return Ok("default value") # All fallbacks tried until one succeeds @@ -162,13 +162,13 @@ def fetch_from_default() -> Result[str, str]: def test_or_with_err_ok_returns_ok(self) -> None: # Type annotations are required because Ok() and Err() constructors - # have inferred types Result[T, Never] and Result[Never, E] respectively. + # have inferred types Result[T] and Result[Never] respectively. # This matches Rust's type system design. # Note: Using intermediate function calls to satisfy pyright's strict type checking - def err_val() -> Result[int, str]: + def err_val() -> Result[int]: return Err("error") - def ok_val() -> Result[int, str]: + def ok_val() -> Result[int]: return Ok(42) res1 = err_val() @@ -182,15 +182,15 @@ class TestResultOrElse: """Test Result.or_else() for fallback with error transformation.""" def test_or_else_returns_self_on_ok(self) -> None: - res: Result[int, str] = Ok(10) + res: Result[int] = Ok(10) result = res.or_else(lambda _e: Ok(20)) assert result.is_ok() assert result.unwrap() == 10 def test_or_else_calls_function_on_err(self) -> None: - res: Result[int, str] = Err("error") - # Pyright reports type error due to covariance: Ok(20) returns Result[int, Never], - # but or_else expects Result[T_co, F]. This is a type system limitation when using + res: Result[int] = Err("error") + # Pyright reports type error due to covariance: Ok(20) returns Result[int], + # but or_else expects Result[T_co]. This is a type system limitation when using # lambdas with inferred types. The runtime behavior is correct and matches Rust. result = res.or_else(lambda _e: Ok(20)) # pyright: ignore[reportArgumentType] assert result.is_ok() @@ -198,31 +198,31 @@ def test_or_else_calls_function_on_err(self) -> None: def test_or_else_receives_error_value(self) -> None: """Verify or_else function receives the actual error.""" - res: Result[int, int] = Err(404) - # Pyright reports type error due to covariance: Ok(code * 10) returns Result[int, Never], - # but or_else expects Result[T_co, F]. This is a type system limitation when using + res: Result[int] = Err("404") + # Pyright reports type error due to covariance: Ok(code * 10) returns Result[int], + # but or_else expects Result[T_co]. This is a type system limitation when using # lambdas with inferred types. The runtime behavior is correct and matches Rust. - result = res.or_else(lambda code: Ok(code * 10)) # pyright: ignore[reportArgumentType] + result = res.or_else(lambda err: Ok(int(err.message) * 10)) # pyright: ignore[reportArgumentType] assert result.is_ok() assert result.unwrap() == 4040 def test_or_else_can_return_new_error(self) -> None: """Verify or_else can transform error type.""" - res: Result[int, str] = Err("original") - result = res.or_else(lambda e: Err(f"transformed: {e}")) + res: Result[int] = Err("original") + result = res.or_else(lambda e: Err(f"transformed: {e.message}")) assert result.is_err() - assert result.unwrap_err() == "transformed: original" + assert result.unwrap_err().message == "transformed: original" def test_or_else_enables_error_recovery_chain(self) -> None: """Use case: try multiple recovery strategies.""" - def fetch_primary() -> Result[str, str]: + def fetch_primary() -> Result[str]: return Err("primary failed") - def try_secondary(_e: str) -> Result[str, str]: + def try_secondary(_e: object) -> Result[str]: return Err("secondary failed") - def use_default(_e: str) -> Result[str, str]: + def use_default(_e: object) -> Result[str]: return Ok("default value") # Chain multiple or_else calls until one succeeds @@ -234,12 +234,12 @@ def test_or_else_function_not_called_on_ok(self) -> None: """Verify short-circuit behavior - function shouldn't be called for Ok.""" called = False - def recovery(_e: str) -> Result[int, str]: + def recovery(_e: object) -> Result[int]: nonlocal called called = True return Ok(999) - res: Result[int, str] = Ok(10) + res: Result[int] = Ok(10) result = res.or_else(recovery) assert result.is_ok() assert result.unwrap() == 10 diff --git a/tests/result/test_conversion.py b/tests/result/test_conversion.py index 809d5f1..e44bb41 100644 --- a/tests/result/test_conversion.py +++ b/tests/result/test_conversion.py @@ -1,7 +1,7 @@ """Tests for Result conversion methods (ok, err). Note: Type annotations are required when using Ok()/Err() constructors -because they have inferred types Result[T, Never] and Result[Never, E]. +because they have inferred types Result[T] and Result[Never]. This matches Rust's type system design. Use function return types or intermediate functions to satisfy strict type checking. """ @@ -21,7 +21,7 @@ def test_ok_returns_some_on_ok(self) -> None: assert opt.unwrap() == 42 def test_ok_returns_none_on_err(self) -> None: - res: Result[int, str] = Err("error") + res: Result[int] = Err("error") opt = res.ok() assert opt.is_none() @@ -32,7 +32,7 @@ def test_ok_preserves_value_type(self) -> None: def test_ok_discards_error(self) -> None: """Verify that error information is lost after ok().""" - res: Result[int, str] = Err("important error") + res: Result[int] = Err("important error") opt = res.ok() # Error is discarded, we only know it's None assert opt.is_none() @@ -44,7 +44,7 @@ def test_ok_enables_option_chaining(self) -> None: result = res.ok().map(lambda x: x * 2).unwrap_or(0) assert result == 20 - res_err: Result[int, str] = Err("error") + res_err: Result[int] = Err("error") result_err = res_err.ok().map(lambda x: x * 2).unwrap_or(0) assert result_err == 0 @@ -53,10 +53,10 @@ class TestResultErr: """Test Result.err() for extracting error as Option.""" def test_err_returns_some_on_err(self) -> None: - res: Result[int, str] = Err("error message") + res: Result[int] = Err("error message") opt = res.err() assert opt.is_some() - assert opt.unwrap() == "error message" + assert opt.unwrap().message == "error message" def test_err_returns_none_on_ok(self) -> None: res = Ok(42) @@ -65,9 +65,9 @@ def test_err_returns_none_on_ok(self) -> None: def test_err_preserves_error_type(self) -> None: error = ValueError("validation failed") - res: Result[int, Exception] = Err(error) + res: Result[int] = Err(error) opt = res.err() - assert opt.unwrap() == error + assert opt.unwrap().message.endswith("validation failed") def test_err_discards_success_value(self) -> None: """Verify that success value is lost after err().""" @@ -79,12 +79,12 @@ def test_err_discards_success_value(self) -> None: def test_err_for_error_collection(self) -> None: """Use case: collect errors from multiple Results.""" results = [Ok(1), Err("error1"), Ok(3), Err("error2")] - errors = [r.err().unwrap() for r in results if r.is_err()] + errors = [r.err().unwrap().message for r in results if r.is_err()] assert errors == ["error1", "error2"] def test_err_with_option_methods(self) -> None: """Chain with Option methods for error processing.""" - res: Result[int, str] = Err("bad input") + res: Result[int] = Err("bad input") # Transform error using Option.map - formatted = res.err().map(lambda e: f"Error: {e}").unwrap_or("No error") + formatted = res.err().map(lambda e: f"Error: {e.message}").unwrap_or("No error") assert formatted == "Error: bad input" diff --git a/tests/result/test_extraction.py b/tests/result/test_extraction.py index 75aa6e4..84122b6 100644 --- a/tests/result/test_extraction.py +++ b/tests/result/test_extraction.py @@ -1,7 +1,7 @@ """Tests for Result extraction methods (expect, expect_err, unwrap_or, unwrap_or_else). Note: Type annotations are required when using Ok()/Err() constructors -because they have inferred types Result[T, Never] and Result[Never, E]. +because they have inferred types Result[T] and Result[Never]. This matches Rust's type system design. Use function return types or intermediate functions to satisfy strict type checking. """ @@ -21,7 +21,7 @@ def test_expect_returns_ok_value(self) -> None: assert res.expect("should not fail") == 42 def test_expect_raises_with_custom_message_on_err(self) -> None: - res: Result[int, str] = Err("error") + res: Result[int] = Err("error") with pytest.raises(RuntimeError, match="custom error message"): res.expect("custom error message") @@ -30,7 +30,7 @@ def test_expect_works_with_complex_types(self) -> None: assert res.expect("should work") == {"key": "value"} def test_expect_message_can_be_multiline(self) -> None: - res: Result[int, str] = Err("error") + res: Result[int] = Err("error") with pytest.raises(RuntimeError, match=r"Line 1\nLine 2\nLine 3"): res.expect("Line 1\nLine 2\nLine 3") @@ -39,8 +39,8 @@ class TestResultExpectErr: """Test Result.expect_err() for extracting Err value with custom error message.""" def test_expect_err_returns_err_value(self) -> None: - res: Result[int, str] = Err("error message") - assert res.expect_err("should not fail") == "error message" + res: Result[int] = Err("error message") + assert res.expect_err("should not fail").message == "error message" def test_expect_err_raises_with_custom_message_on_ok(self) -> None: res = Ok(42) @@ -49,8 +49,8 @@ def test_expect_err_raises_with_custom_message_on_ok(self) -> None: def test_expect_err_works_with_exception_objects(self) -> None: error = ValueError("validation failed") - res: Result[int, Exception] = Err(error) - assert res.expect_err("should work") == error + res: Result[int] = Err(error) + assert res.expect_err("should work").message.endswith("validation failed") class TestResultUnwrapOr: @@ -61,7 +61,7 @@ def test_unwrap_or_returns_ok_value(self) -> None: assert res.unwrap_or(999) == 10 def test_unwrap_or_returns_default_on_err(self) -> None: - res: Result[int, str] = Err("error") + res: Result[int] = Err("error") assert res.unwrap_or(999) == 999 def test_unwrap_or_works_with_different_types(self) -> None: @@ -70,15 +70,15 @@ def test_unwrap_or_works_with_different_types(self) -> None: assert res.unwrap_or("default") == "hello" # Err case with string - res_err: Result[str, str] = Err("error") + res_err: Result[str] = Err("error") assert res_err.unwrap_or("default") == "default" def test_unwrap_or_default_can_be_none(self) -> None: - res: Result[str, str] = Err("error") + res: Result[str] = Err("error") assert res.unwrap_or(None) is None def test_unwrap_or_with_complex_default(self) -> None: - res: Result[list[int], str] = Err("error") + res: Result[list[int]] = Err("error") default = [1, 2, 3] assert res.unwrap_or(default) == [1, 2, 3] @@ -91,20 +91,20 @@ def test_unwrap_or_else_returns_ok_value(self) -> None: assert res.unwrap_or_else(lambda _e: 999) == 10 def test_unwrap_or_else_computes_default_on_err(self) -> None: - res: Result[int, str] = Err("error") - assert res.unwrap_or_else(lambda e: len(e)) == 5 + res: Result[int] = Err("error") + assert res.unwrap_or_else(lambda e: len(e.message)) == 5 def test_unwrap_or_else_receives_err_value(self) -> None: - res: Result[int, str] = Err("custom error") + res: Result[int] = Err("custom error") # Function receives the actual error value - result = res.unwrap_or_else(lambda e: len(e) * 2) + result = res.unwrap_or_else(lambda e: len(e.message) * 2) assert result == 24 # len("custom error") * 2 def test_unwrap_or_else_not_called_on_ok(self) -> None: """Verify function is not called when Result is Ok.""" called = False - def compute_default(_e: str) -> int: + def compute_default(_e: object) -> int: nonlocal called called = True return 999 @@ -115,5 +115,5 @@ def compute_default(_e: str) -> int: def test_unwrap_or_else_with_type_conversion(self) -> None: # Error to default value type conversion - res: Result[str, int] = Err(404) - assert res.unwrap_or_else(lambda code: f"Error {code}") == "Error 404" + res: Result[str] = Err("404") + assert res.unwrap_or_else(lambda code: f"Error {code.message}") == "Error 404" diff --git a/tests/result/test_query.py b/tests/result/test_query.py index cf46db0..5a73735 100644 --- a/tests/result/test_query.py +++ b/tests/result/test_query.py @@ -1,7 +1,7 @@ """Tests for Result query methods (is_ok_and, is_err_and). Note: Type annotations are required when using Ok()/Err() constructors -because they have inferred types Result[T, Never] and Result[Never, E]. +because they have inferred types Result[T] and Result[Never]. This matches Rust's type system design. Use function return types or intermediate functions to satisfy strict type checking. """ @@ -23,7 +23,7 @@ def test_returns_false_when_ok_but_predicate_false(self) -> None: assert res.is_ok_and(lambda x: x > 5) is False def test_returns_false_when_err(self) -> None: - res: Result[int, str] = Err("error") + res: Result[int] = Err("error") assert res.is_ok_and(lambda x: x > 5) is False def test_accepts_truthy_values(self) -> None: @@ -52,7 +52,7 @@ def side_effect_predicate(_x: int) -> bool: called = True return True - res: Result[int, str] = Err("error") + res: Result[int] = Err("error") assert res.is_ok_and(side_effect_predicate) is False assert called is False @@ -61,47 +61,41 @@ class TestResultIsErrAnd: """Test Result.is_err_and() for conditional Err checking.""" def test_returns_true_when_err_and_predicate_true(self) -> None: - res: Result[int, str] = Err("error") - assert res.is_err_and(lambda e: "err" in e) is True + res: Result[int] = Err("error") + assert res.is_err_and(lambda e: "err" in e.message) is True def test_returns_false_when_err_but_predicate_false(self) -> None: - res: Result[int, str] = Err("success") - assert res.is_err_and(lambda e: "err" in e) is False + res: Result[int] = Err("success") + assert res.is_err_and(lambda e: "err" in e.message) is False def test_returns_false_when_ok(self) -> None: - res: Result[int, str] = Ok(10) - assert res.is_err_and(lambda e: "err" in e) is False + res: Result[int] = Ok(10) + assert res.is_err_and(lambda e: "err" in e.message) is False def test_accepts_truthy_values(self) -> None: """Verify Python truthiness protocol works.""" # Non-empty string is truthy - res_err: Result[int, str] = Err("error") - assert res_err.is_err_and(lambda e: e) is True + res_err: Result[int] = Err("error") + assert res_err.is_err_and(lambda e: e.message) is True # Empty string is falsy - res_empty: Result[int, str] = Err("") - assert res_empty.is_err_and(lambda e: e) is False + res_empty: Result[int] = Err("") + assert res_empty.is_err_and(lambda e: e.message) is False def test_predicate_receives_err_value(self) -> None: """Verify predicate gets the actual Err value.""" - - class CustomError: - def __init__(self, code: int) -> None: - self.code = code - - err_obj = CustomError(404) - res: Result[int, CustomError] = Err(err_obj) - assert res.is_err_and(lambda e: e.code == 404) is True - assert res.is_err_and(lambda e: e.code == 500) is False + res: Result[int] = Err("404") + assert res.is_err_and(lambda e: e.message == "404") is True + assert res.is_err_and(lambda e: e.message == "500") is False def test_predicate_not_called_when_ok(self) -> None: """Verify short-circuit behavior - predicate shouldn't be called for Ok.""" called = False - def side_effect_predicate(_e: str) -> bool: + def side_effect_predicate(_e: object) -> bool: nonlocal called called = True return True - res: Result[int, str] = Ok(10) + res: Result[int] = Ok(10) assert res.is_err_and(side_effect_predicate) is False assert called is False diff --git a/tests/result/test_transformation.py b/tests/result/test_transformation.py index 02f0255..251a074 100644 --- a/tests/result/test_transformation.py +++ b/tests/result/test_transformation.py @@ -3,14 +3,14 @@ Includes: map, map_err, map_or, map_or_else, inspect, inspect_err. Note: Type annotations are required when using Ok()/Err() constructors -because they have inferred types Result[T, Never] and Result[Never, E]. +because they have inferred types Result[T] and Result[Never]. This matches Rust's type system design. Use function return types or intermediate functions to satisfy strict type checking. """ from __future__ import annotations -from pyropust import Err, Ok, Result +from pyropust import Err, Ok, Result, RopustError class TestResultMap: @@ -27,21 +27,23 @@ def test_map_chains_multiple_transforms(self) -> None: assert res.unwrap() == 246 def test_map_skips_on_err(self) -> None: - res: Result[int, str] = Err("error").map(lambda x: x * 2) + res: Result[int] = Err("error").map(lambda x: x * 2) assert res.is_err() - assert res.unwrap_err() == "error" + assert res.unwrap_err().message == "error" class TestResultMapErr: """Test Result.map_err() for transforming Err values.""" def test_map_err_transforms_err_value(self) -> None: - res: Result[int, str] = Err("error").map_err(lambda e: e.upper()) + res: Result[int] = Err("error").map_err( + lambda e: RopustError.new(code=e.code, message=e.message.upper(), kind=e.kind) + ) assert res.is_err() - assert res.unwrap_err() == "ERROR" + assert res.unwrap_err().message == "ERROR" def test_map_err_skips_on_ok(self) -> None: - res = Ok(10).map_err(lambda e: f"Transformed: {e}") + res = Ok(10).map_err(lambda e: e) assert res.is_ok() assert res.unwrap() == 10 @@ -55,7 +57,7 @@ def test_map_or_applies_function_on_ok(self) -> None: assert result == 20 def test_map_or_returns_default_on_err(self) -> None: - res: Result[int, str] = Err("error") + res: Result[int] = Err("error") result = res.map_or(0, lambda x: x * 2) assert result == 0 @@ -66,7 +68,7 @@ def test_map_or_with_type_conversion(self) -> None: assert result == "Value: 42" # Use default on Err - res_err: Result[int, str] = Err("error") + res_err: Result[int] = Err("error") result_err = res_err.map_or("default", lambda x: f"Value: {x}") assert result_err == "default" @@ -79,7 +81,7 @@ def transform(_x: int) -> int: called = True return 999 - res: Result[int, str] = Err("error") + res: Result[int] = Err("error") result = res.map_or(0, transform) assert result == 0 assert called is False @@ -94,14 +96,14 @@ def test_map_or_else_applies_function_on_ok(self) -> None: assert result == 20 def test_map_or_else_computes_default_on_err(self) -> None: - res: Result[int, str] = Err("error") - result = res.map_or_else(lambda e: len(e), lambda x: x * 2) + res: Result[int] = Err("error") + result = res.map_or_else(lambda e: len(e.message), lambda x: x * 2) assert result == 5 def test_map_or_else_receives_error_value(self) -> None: """Verify default function receives the actual error.""" - res: Result[int, int] = Err(404) - result = res.map_or_else(lambda code: code * 10, lambda x: x * 2) + res: Result[int] = Err("404") + result = res.map_or_else(lambda code: int(code.message) * 10, lambda x: x * 2) assert result == 4040 def test_map_or_else_functions_not_both_called(self) -> None: @@ -114,7 +116,7 @@ def transform(_x: int) -> int: transform_called = True return 999 - def compute_default(_e: str) -> int: + def compute_default(_e: object) -> int: nonlocal default_called default_called = True return 0 @@ -127,7 +129,7 @@ def compute_default(_e: str) -> int: # Err case transform_called = False - res_err: Result[int, str] = Err("error") + res_err: Result[int] = Err("error") res_err.map_or_else(compute_default, transform) assert transform_called is False assert default_called is True @@ -156,11 +158,11 @@ def side_effect(_x: int) -> None: nonlocal called called = True - res: Result[int, str] = Err("error") + res: Result[int] = Err("error") result = res.inspect(side_effect) assert called is False assert result.is_err() - assert result.unwrap_err() == "error" + assert result.unwrap_err().message == "error" def test_inspect_enables_chaining(self) -> None: """Verify inspect returns Result for method chaining.""" @@ -178,10 +180,10 @@ def test_inspect_enables_chaining(self) -> None: def test_inspect_preserves_error(self) -> None: """Verify inspect doesn't affect Err.""" - res: Result[int, str] = Err("error") + res: Result[int] = Err("error") result = res.inspect(lambda _x: None).map(lambda x: x * 2) assert result.is_err() - assert result.unwrap_err() == "error" + assert result.unwrap_err().message == "error" def test_inspect_with_print_debugging(self) -> None: """Use case: debugging with side effects.""" @@ -206,20 +208,21 @@ class TestResultInspectErr: def test_inspect_err_calls_function_on_err(self) -> None: called_with = None - def side_effect(e: str) -> None: + def side_effect(e: RopustError) -> None: nonlocal called_with called_with = e - res: Result[int, str] = Err("error") + res: Result[int] = Err("error") result = res.inspect_err(side_effect) - assert called_with == "error" + assert called_with is not None + assert called_with.message == "error" assert result.is_err() - assert result.unwrap_err() == "error" + assert result.unwrap_err().message == "error" def test_inspect_err_not_called_on_ok(self) -> None: called = False - def side_effect(_e: str) -> None: + def side_effect(_e: object) -> None: nonlocal called called = True @@ -233,15 +236,17 @@ def test_inspect_err_enables_chaining(self) -> None: """Verify inspect_err returns Result for method chaining.""" log: list[str] = [] - res: Result[int, str] = ( + res: Result[int] = ( Err("error1") - .inspect_err(lambda e: log.append(e)) - .map_err(lambda e: f"{e}_mapped") - .inspect_err(lambda e: log.append(e)) + .inspect_err(lambda e: log.append(e.message)) + .map_err( + lambda e: RopustError.new(code=e.code, message=f"{e.message}_mapped", kind=e.kind) + ) + .inspect_err(lambda e: log.append(e.message)) ) assert log == ["error1", "error1_mapped"] - assert res.unwrap_err() == "error1_mapped" + assert res.unwrap_err().message == "error1_mapped" def test_inspect_err_preserves_ok(self) -> None: """Verify inspect_err doesn't affect Ok.""" @@ -256,10 +261,10 @@ def test_inspect_err_for_error_logging(self) -> None: result = ( Err("validation failed") - .inspect_err(lambda e: errors_logged.append(f"Error: {e}")) - .map_err(lambda e: e.upper()) - .inspect_err(lambda e: errors_logged.append(f"Transformed: {e}")) + .inspect_err(lambda e: errors_logged.append(f"Error: {e.message}")) + .map_err(lambda e: RopustError.new(code=e.code, message=e.message.upper(), kind=e.kind)) + .inspect_err(lambda e: errors_logged.append(f"Transformed: {e.message}")) ) assert errors_logged == ["Error: validation failed", "Transformed: VALIDATION FAILED"] - assert result.unwrap_err() == "VALIDATION FAILED" + assert result.unwrap_err().message == "VALIDATION FAILED" diff --git a/tests/result/test_utility.py b/tests/result/test_utility.py index 25aede7..35ee14e 100644 --- a/tests/result/test_utility.py +++ b/tests/result/test_utility.py @@ -1,7 +1,7 @@ """Tests for Result utility methods (flatten, transpose). Note: Type annotations are required when using Ok()/Err() constructors -because they have inferred types Result[T, Never] and Result[Never, E]. +because they have inferred types Result[T] and Result[Never]. This matches Rust's type system design. Use function return types or intermediate functions to satisfy strict type checking. """ @@ -19,7 +19,7 @@ class TestResultFlatten: def test_flatten_ok_ok(self) -> None: """Flatten Ok(Ok(value)) -> Ok(value).""" - def make_nested() -> Result[Result[int, str], str]: + def make_nested() -> Result[Result[int]]: return Ok(Ok(42)) nested = make_nested() @@ -30,36 +30,36 @@ def make_nested() -> Result[Result[int, str], str]: def test_flatten_ok_err(self) -> None: """Flatten Ok(Err(error)) -> Err(error).""" - def inner_err() -> Result[int, str]: + def inner_err() -> Result[int]: return Err("inner error") - nested: Result[Result[int, str], str] = Ok(inner_err()) + nested: Result[Result[int]] = Ok(inner_err()) result = nested.flatten() assert result.is_err() - assert result.unwrap_err() == "inner error" + assert result.unwrap_err().message == "inner error" def test_flatten_err(self) -> None: """Flatten Err(error) -> Err(error).""" - nested: Result[Result[int, str], str] = Err("outer error") + nested: Result[Result[int]] = Err("outer error") result = nested.flatten() assert result.is_err() - assert result.unwrap_err() == "outer error" + assert result.unwrap_err().message == "outer error" def test_flatten_requires_nested_result(self) -> None: """Verify flatten raises TypeError if Ok value is not a Result.""" - res: Result[int, str] = Ok(42) + res: Result[int] = Ok(42) with pytest.raises(TypeError, match="flatten requires Ok value to be a Result"): res.flatten() # type: ignore[misc] def test_flatten_multiple_levels(self) -> None: """Verify flatten only removes one level of nesting.""" - def triple_nested() -> Result[Result[Result[int, str], str], str]: + def triple_nested() -> Result[Result[Result[int]]]: return Ok(Ok(Ok(42))) nested = triple_nested() result = nested.flatten() - # After one flatten, we have Result[Result[int, str], str] + # After one flatten, we have Result[Result[int]] assert result.is_ok() inner = result.flatten() assert inner.is_ok() @@ -68,22 +68,22 @@ def triple_nested() -> Result[Result[Result[int, str], str], str]: def test_flatten_with_different_error_types_in_layers(self) -> None: """Verify flatten works when inner and outer error types match.""" - def make_nested() -> Result[Result[str, int], int]: - # Inner Err with int error type - return Ok(Err(404)) + def make_nested() -> Result[Result[str]]: + # Inner Err with int error type coerced to string message + return Ok(Err("404")) nested = make_nested() result = nested.flatten() assert result.is_err() - assert result.unwrap_err() == 404 + assert result.unwrap_err().message == "404" class TestResultTranspose: - """Test Result.transpose() for converting Result[Option[T], E] to Option[Result[T, E]].""" + """Test Result.transpose() for converting Result[Option[T]] to Option[Result[T]].""" def test_transpose_ok_some(self) -> None: """Transpose Ok(Some(value)) -> Some(Ok(value)).""" - res: Result[Option[int], str] = Ok(Some(42)) + res: Result[Option[int]] = Ok(Some(42)) opt = res.transpose() assert opt.is_some() inner = opt.unwrap() @@ -93,7 +93,7 @@ def test_transpose_ok_some(self) -> None: def test_transpose_ok_none(self) -> None: """Transpose Ok(None) -> None.""" - def make_none() -> Result[Option[int], str]: + def make_none() -> Result[Option[int]]: return Ok(None_()) res = make_none() @@ -102,16 +102,16 @@ def make_none() -> Result[Option[int], str]: def test_transpose_err(self) -> None: """Transpose Err(error) -> Some(Err(error)).""" - res: Result[Option[int], str] = Err("error") + res: Result[Option[int]] = Err("error") opt = res.transpose() assert opt.is_some() inner = opt.unwrap() assert inner.is_err() - assert inner.unwrap_err() == "error" + assert inner.unwrap_err().message == "error" def test_transpose_requires_option(self) -> None: """Verify transpose raises TypeError if Ok value is not an Option.""" - res: Result[int, str] = Ok(42) + res: Result[int] = Ok(42) with pytest.raises(TypeError, match="transpose requires Ok value to be an Option"): res.transpose() # type: ignore[misc] @@ -119,7 +119,7 @@ def test_transpose_round_trip_some(self) -> None: """Verify transpose is self-inverse for Some case.""" # Option.transpose() not implemented yet, so we manually verify structure # For now, just verify Result.transpose works correctly - res: Result[Option[int], str] = Ok(Some(42)) + res: Result[Option[int]] = Ok(Some(42)) transposed = res.transpose() assert transposed.is_some() inner = transposed.unwrap() @@ -129,7 +129,7 @@ def test_transpose_round_trip_some(self) -> None: def test_transpose_round_trip_none(self) -> None: """Verify transpose is self-inverse for None case.""" - def make_none() -> Result[Option[int], str]: + def make_none() -> Result[Option[int]]: return Ok(None_()) res = make_none() diff --git a/tests/test_blueprint.py b/tests/test_blueprint.py index 21c4c05..2243477 100644 --- a/tests/test_blueprint.py +++ b/tests/test_blueprint.py @@ -3,7 +3,7 @@ from collections.abc import Generator from datetime import UTC, datetime -from pyropust import Blueprint, ErrorKind, Ok, Op, Result, RopustError, do, run +from pyropust import Blueprint, ErrorKind, Ok, Op, Result, do, run def test_blueprint_execution() -> None: @@ -30,9 +30,7 @@ def test_do_with_blueprint() -> None: bp = Blueprint().pipe(Op.assert_str()).pipe(Op.split("@")).pipe(Op.index(1)) @do - def workflow( - raw: str, - ) -> Generator[Result[object, RopustError], object, Result[str, RopustError]]: + def workflow(raw: str) -> Generator[Result[object], object, Result[str]]: domain = yield run(bp, raw) return Ok(f"Processed: {domain}") diff --git a/tests/test_examples.py b/tests/test_examples.py index 092b92f..22c058b 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -11,7 +11,7 @@ class TestResultManualHandling: def test_readme_example_divide_function(self) -> None: """Verify the README divide example works.""" - def divide(a: int, b: int) -> Result[float, str]: + def divide(a: int, b: int) -> Result[float]: if b == 0: return Err("Division by zero") return Ok(a / b) @@ -24,4 +24,4 @@ def divide(a: int, b: int) -> Result[float, str]: # Error case res_err = divide(10, 0) assert res_err.is_err() - assert res_err.unwrap_err() == "Division by zero" + assert res_err.unwrap_err().message == "Division by zero" diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 41b6f37..9cb4794 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -4,7 +4,18 @@ import pytest -from pyropust import Err, None_, Ok, Result, RopustError, Some, catch, do, exception_to_ropust_error +from pyropust import ( + Err, + ErrorKind, + None_, + Ok, + Result, + RopustError, + Some, + catch, + do, + exception_to_ropust_error, +) def test_result_ok_err() -> None: @@ -17,7 +28,7 @@ def test_result_ok_err() -> None: assert err.is_ok() is False assert err.is_err() is True - assert err.unwrap_err() == "nope" + assert err.unwrap_err().message == "nope" def test_option_unwrap() -> None: @@ -38,7 +49,7 @@ def test_option_unwrap() -> None: def test_do_short_circuit() -> None: @do - def flow(value: str) -> Generator[Result[str, object], str, Result[str, object]]: + def flow(value: str) -> Generator[Result[str], str, Result[str]]: value = yield Ok(value) return Ok(value.upper()) @@ -117,3 +128,110 @@ def raise_value_error() -> None: def test_ropust_error_from_dict_missing_fields() -> None: with pytest.raises(TypeError, match="missing 'kind' field"): RopustError.from_dict({"code": "missing", "message": "oops"}) + + +def test_ropust_error_new_builds_fields() -> None: + err = RopustError.new( + code="user.age.not_number", + message="age must be a number", + kind=ErrorKind.InvalidInput, + op="ParseAge", + path=["user", 0], + expected="numeric string", + got="x", + metadata={"source": "input"}, + ) + + assert err.code == "user.age.not_number" + assert err.message == "age must be a number" + assert err.kind == ErrorKind.InvalidInput + assert err.op == "ParseAge" + assert err.path == ["user", 0] + assert err.expected == "numeric string" + assert err.got == "x" + assert err.metadata["source"] == "input" + assert err.cause is None + + +def test_ropust_error_new_accepts_string_kind() -> None: + err = RopustError.new(code="missing", message="oops", kind="NotFound") + assert err.kind == ErrorKind.NotFound + + +def test_ropust_error_wrap_with_ropust_error() -> None: + base = RopustError.new(code="user.age.not_number", message="age must be a number") + wrapped = RopustError.wrap( + base, + code="user.load.failed", + message="failed to load user", + metadata={"source": "payload"}, + ) + + assert wrapped.code == "user.load.failed" + assert wrapped.message == "failed to load user" + assert wrapped.cause is not None + assert "code='user.age.not_number'" in wrapped.cause + assert wrapped.metadata["source"] == "payload" + + +def test_ropust_error_wrap_with_exception() -> None: + def raise_value_error() -> None: + raise ValueError("boom") + + with pytest.raises(ValueError, match="boom") as excinfo: + raise_value_error() + + wrapped = RopustError.wrap( + excinfo.value, + code="json.decode.failed", + message="invalid json payload", + ) + + assert wrapped.code == "json.decode.failed" + assert wrapped.message == "invalid json payload" + assert wrapped.cause is not None + assert "code='py_exception'" in wrapped.cause + assert wrapped.metadata["cause_exception"] == "ValueError" + assert "cause_py_traceback" in wrapped.metadata + + +def test_ropust_error_wrap_rejects_none() -> None: + with pytest.raises(TypeError, match="wrap expects an exception or RopustError"): + RopustError.wrap(None, code="invalid", message="bad input") # type: ignore[arg-type] + + +def test_result_context_wraps_error() -> None: + err = Err("boom") + wrapped = err.context("failed to process", metadata={"step": "parse"}) + assert wrapped.is_err() + wrapped_err = wrapped.unwrap_err() + assert wrapped_err.code == "context" + assert wrapped_err.message == "failed to process" + assert wrapped_err.metadata["step"] == "parse" + assert wrapped_err.cause is not None + assert "message='boom'" in wrapped_err.cause + + +def test_result_context_ok_passthrough() -> None: + ok = Ok(123) + out = ok.context("ignored") + assert out.is_ok() + assert out.unwrap() == 123 + + +def test_result_with_code() -> None: + err = Err("boom") + coded = err.with_code("parse.error") + assert coded.is_err() + coded_err = coded.unwrap_err() + assert coded_err.code == "parse.error" + assert coded_err.message == "boom" + + +def test_result_map_err_code_prefixes_once() -> None: + err = Err("boom") + prefixed = err.map_err_code("pipeline") + assert prefixed.unwrap_err().code == "pipeline.custom" + + prefixed_again = prefixed.map_err_code("pipeline") + assert prefixed_again.unwrap_err().code == "pipeline.custom" diff --git a/tests/typing/test_types.py b/tests/typing/test_types.py index 8779994..4ffe583 100644 --- a/tests/typing/test_types.py +++ b/tests/typing/test_types.py @@ -36,21 +36,21 @@ # Result: Constructors # ========================================================================== - # Ok() returns Result[T, Never] - error type is Never (can unify with any E) - ok_int: Result[int, Never] = Ok(42) - assert_type(Ok(42), Result[int, Never]) - assert_type(Ok("hello"), Result[str, Never]) + # Ok() returns Result[T] + ok_int: Result[int] = Ok(42) + assert_type(Ok(42), Result[int]) + assert_type(Ok("hello"), Result[str]) - # Err() returns Result[Never, E] - ok type is Never (can unify with any T) - err_str: Result[Never, str] = Err("error") - assert_type(Err("error"), Result[Never, str]) - assert_type(Err(ValueError("oops")), Result[Never, ValueError]) + # Err() returns Result[Never] + err_str: Result[Never] = Err("error") + assert_type(Err("error"), Result[Never]) + assert_type(Err(ValueError("oops")), Result[Never]) # ========================================================================== # Result: Methods # ========================================================================== - def get_result() -> Result[int, str]: + def get_result() -> Result[int]: return Ok(10) res = get_result() @@ -63,46 +63,51 @@ def get_result() -> Result[int, str]: assert_type(res.unwrap(), int) # unwrap_err returns the Err value type - assert_type(res.unwrap_err(), str) + assert_type(res.unwrap_err(), RopustError) # unwrap_or_raise returns ok value type assert_type(res.unwrap_or_raise(RuntimeError("boom")), int) - # attempt returns Result[T, RopustError] + # attempt returns Result[T] attempt_ok = Result.attempt(lambda: 123) - assert_type(attempt_ok, Result[int, RopustError]) + assert_type(attempt_ok, Result[int]) - # map transforms the Ok value, preserves error type + # map transforms the Ok value mapped = res.map(lambda x: str(x)) - assert_type(mapped, Result[str, str]) + assert_type(mapped, Result[str]) # map with different output type mapped_float = res.map(lambda x: x * 2.5) - assert_type(mapped_float, Result[float, str]) + assert_type(mapped_float, Result[float]) # map_err transforms the Err value, preserves ok type - mapped_err = res.map_err(lambda e: ValueError(e)) - assert_type(mapped_err, Result[int, ValueError]) + mapped_err = res.map_err(lambda e: e) + assert_type(mapped_err, Result[int]) + + # context/with_code/map_err_code return Result[T] + assert_type(res.context("extra context"), Result[int]) + assert_type(res.with_code("parse.error"), Result[int]) + assert_type(res.map_err_code("pipeline"), Result[int]) # and_then chains Result-returning functions - def validate(x: int) -> Result[str, str]: + def validate(x: int) -> Result[str]: return Ok(str(x)) if x > 0 else Err("negative") chained = res.and_then(validate) - assert_type(chained, Result[str, str]) + assert_type(chained, Result[str]) # ========================================================================== # Result: Chaining (README example) # ========================================================================== # Functional chaining with explicit error type annotation - def fetch_value() -> Result[str, str]: + def fetch_value() -> Result[str]: return Ok("123") chain_result = ( fetch_value().map(int).map(lambda x: x * 2).and_then(lambda x: Ok(f"Value is {x}")) ) - assert_type(chain_result, Result[str, str]) + assert_type(chain_result, Result[str]) # ========================================================================== # Option: Constructors @@ -167,7 +172,7 @@ def parse_int(value: str) -> int: return int(value) parsed = parse_int("123") - assert_type(parsed, Result[int, RopustError]) + assert_type(parsed, Result[int]) # ========================================================================== # Operator: Flat API (backward compatible) @@ -302,7 +307,7 @@ def to_len(value: str) -> int: bp_for_run = Blueprint.for_type(str).pipe(Op.split("@")) result_from_run = run(bp_for_run, "a@b") - assert_type(result_from_run, Result[list[str], RopustError]) + assert_type(result_from_run, Result[list[str]]) # ========================================================================== # RopustError properties @@ -336,12 +341,10 @@ def get_ropust_error() -> RopustError: # ========================================================================== @do - def to_upper( - value: str, - ) -> Generator[Result[str, object], str, Result[str, object]]: + def to_upper(value: str) -> Generator[Result[str], str, Result[str]]: text = yield Ok(value) return Ok(text.upper()) # @do decorated function returns Result - do_result: Result[str, object] = to_upper("hello") - assert_type(to_upper("hello"), Result[str, object]) + do_result: Result[str] = to_upper("hello") + assert_type(to_upper("hello"), Result[str])