diff --git a/pyropust/__init__.pyi b/pyropust/__init__.pyi index 34d44de..bff9de6 100644 --- a/pyropust/__init__.pyi +++ b/pyropust/__init__.pyi @@ -58,6 +58,12 @@ class Option(Generic[T_co]): def or_(self, other: Option[T_co]) -> Option[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 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]: ... class ErrorKind: InvalidInput: ErrorKind diff --git a/pyropust/pyropust_native.pyi b/pyropust/pyropust_native.pyi index 0d97f55..e9bf0b1 100644 --- a/pyropust/pyropust_native.pyi +++ b/pyropust/pyropust_native.pyi @@ -64,6 +64,12 @@ class Option(Generic[T_co]): def or_(self, other: Option[T_co]) -> Option[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 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]: ... class ErrorKind: InvalidInput: ErrorKind diff --git a/src/py/option.rs b/src/py/option.rs index db38a7a..3bd2308 100644 --- a/src/py/option.rs +++ b/src/py/option.rs @@ -1,5 +1,8 @@ use pyo3::exceptions::{PyRuntimeError, PyTypeError}; use pyo3::prelude::*; +use pyo3::types::PyTuple; + +use super::result::{err, ok, ResultObj}; #[pyclass(name = "Option")] pub struct OptionObj { @@ -210,6 +213,91 @@ impl OptionObj { _ => none_(), } } + + // Utility methods + fn flatten(&self, py: Python<'_>) -> PyResult { + if self.is_some { + let value = self.value.as_ref().expect("some value"); + let option_type = py.get_type::(); + if !value.bind(py).is_instance(option_type.as_any())? { + return Err(PyTypeError::new_err( + "flatten requires Some value to be an Option", + )); + } + let inner_ref: PyRef<'_, OptionObj> = value.extract(py)?; + Ok(clone_option(py, &inner_ref)) + } else { + Ok(none_()) + } + } + + fn transpose(&self, py: Python<'_>) -> PyResult { + if self.is_some { + let value = self.value.as_ref().expect("some value"); + let result_type = py.get_type::(); + if !value.bind(py).is_instance(result_type.as_any())? { + return Err(PyTypeError::new_err( + "transpose requires Some value to be a Result", + )); + } + let res_ref: PyRef<'_, ResultObj> = value.extract(py)?; + if res_ref.is_ok { + let inner_value = res_ref.ok.as_ref().expect("ok value").clone_ref(py); + let option_obj = some(inner_value); + let py_option = Py::new(py, option_obj)?; + Ok(ok(py_option.into())) + } else { + let err_value = res_ref.err.as_ref().expect("err value").clone_ref(py); + Ok(err(err_value)) + } + } else { + let option_obj = none_(); + let py_option = Py::new(py, option_obj)?; + Ok(ok(py_option.into())) + } + } + + fn zip(&self, py: Python<'_>, other: &Self) -> PyResult { + if self.is_some && other.is_some { + let value1 = self.value.as_ref().expect("some value"); + let value2 = other.value.as_ref().expect("some value"); + let tuple = PyTuple::new(py, &[value1.clone_ref(py), value2.clone_ref(py)])?; + Ok(some(tuple.into())) + } else { + Ok(none_()) + } + } + + fn zip_with(&self, py: Python<'_>, other: &Self, f: Bound<'_, PyAny>) -> PyResult { + if self.is_some && other.is_some { + let value1 = self.value.as_ref().expect("some value"); + let value2 = other.value.as_ref().expect("some value"); + let result = f.call1((value1.clone_ref(py), value2.clone_ref(py)))?; + Ok(some(result.into())) + } else { + Ok(none_()) + } + } + + // Result conversion methods + fn ok_or(&self, py: Python<'_>, error: Py) -> ResultObj { + if self.is_some { + let value = self.value.as_ref().expect("some value").clone_ref(py); + ok(value) + } else { + err(error) + } + } + + fn ok_or_else(&self, py: Python<'_>, f: Bound<'_, PyAny>) -> PyResult { + if self.is_some { + let value = self.value.as_ref().expect("some value").clone_ref(py); + Ok(ok(value)) + } else { + let error = f.call0()?; + Ok(err(error.into())) + } + } } // Python-facing constructor functions diff --git a/tests/option/test_conversion.py b/tests/option/test_conversion.py new file mode 100644 index 0000000..f93b17c --- /dev/null +++ b/tests/option/test_conversion.py @@ -0,0 +1,132 @@ +"""Tests for Option conversion methods (ok_or, ok_or_else). + +Note: Type annotations are required when using Some()/None_() constructors +because they have inferred types. 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 None_, Option, Some + + +class TestOptionOkOr: + """Test Option.ok_or() for converting Option to Result with a default error.""" + + def test_ok_or_returns_ok_on_some(self) -> None: + """ok_or converts Some to Ok.""" + opt = Some(42) + result = opt.ok_or("error") + assert result.is_ok() + assert result.unwrap() == 42 + + def test_ok_or_returns_err_on_none(self) -> None: + """ok_or converts None to Err with provided error.""" + + def none_val() -> Option[int]: + return None_() + + opt = none_val() + result = opt.ok_or("error") + assert result.is_err() + assert result.unwrap_err() == "error" + + def test_ok_or_preserves_value_type(self) -> None: + """Verify ok_or preserves value type.""" + opt = Some({"key": "value"}) + result = opt.ok_or("error") + assert result.unwrap() == {"key": "value"} + + def test_ok_or_with_complex_error(self) -> None: + """ok_or works with complex error types.""" + + def none_val() -> Option[int]: + return None_() + + opt = none_val() + error = ValueError("validation failed") + result = opt.ok_or(error) + assert result.is_err() + assert result.unwrap_err() == error + + def test_ok_or_enables_result_chaining(self) -> None: + """Use case: convert Option to Result for error handling.""" + opt = Some(10) + # Chain with Result methods + value = opt.ok_or("missing").map(lambda x: x * 2).unwrap() + assert value == 20 + + def none_val() -> Option[int]: + return None_() + + opt_none = none_val() + error = opt_none.ok_or("missing").unwrap_err() + assert error == "missing" + + +class TestOptionOkOrElse: + """Test Option.ok_or_else() for converting Option to Result with computed error.""" + + def test_ok_or_else_returns_ok_on_some(self) -> None: + """ok_or_else converts Some to Ok.""" + opt = Some(42) + result = opt.ok_or_else(lambda: "error") + assert result.is_ok() + assert result.unwrap() == 42 + + def test_ok_or_else_computes_error_on_none(self) -> None: + """ok_or_else calls function to compute error on None.""" + + def none_val() -> Option[int]: + return None_() + + opt = none_val() + result = opt.ok_or_else(lambda: "computed error") + assert result.is_err() + assert result.unwrap_err() == "computed error" + + def test_ok_or_else_function_not_called_on_some(self) -> None: + """Verify error function is not called when Some.""" + called: list[bool] = [] + opt = Some(42) + + def make_error() -> str: + called.append(True) + return "error" + + result = opt.ok_or_else(make_error) + assert result.is_ok() + assert called == [] + + def test_ok_or_else_with_complex_error_computation(self) -> None: + """ok_or_else can compute complex errors.""" + + def none_val() -> Option[str]: + return None_() + + opt = none_val() + 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" + + def test_ok_or_else_enables_lazy_error_creation(self) -> None: + """Use case: avoid creating error unless needed.""" + opt = Some(10) + # Error is never created for Some + value = opt.ok_or_else(lambda: expensive_error()).unwrap() + assert value == 10 + + def none_val() -> Option[int]: + return None_() + + 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" + + +def expensive_error() -> str: + """Simulate an expensive error creation.""" + return "expensive error" diff --git a/tests/option/test_utility.py b/tests/option/test_utility.py new file mode 100644 index 0000000..ff74396 --- /dev/null +++ b/tests/option/test_utility.py @@ -0,0 +1,258 @@ +"""Tests for Option utility methods (flatten, transpose, zip, zip_with). + +Note: Type annotations are required when using Some()/None_() constructors +because they have inferred types. This matches Rust's type system design. +Use function return types or intermediate functions to satisfy strict type checking. +""" + +from __future__ import annotations + +import pytest + +from pyropust import Err, None_, Ok, Option, Result, Some + + +class TestOptionFlatten: + """Test Option.flatten() for flattening nested Options.""" + + def test_flatten_some_some(self) -> None: + """Flatten Some(Some(value)) -> Some(value).""" + + def make_nested() -> Option[Option[int]]: + return Some(Some(42)) + + nested = make_nested() + result = nested.flatten() + assert result.is_some() + assert result.unwrap() == 42 + + def test_flatten_some_none(self) -> None: + """Flatten Some(None) -> None.""" + + def inner_none() -> Option[int]: + return None_() + + nested: Option[Option[int]] = Some(inner_none()) + result = nested.flatten() + assert result.is_none() + + def test_flatten_none(self) -> None: + """Flatten None -> None.""" + nested: Option[Option[int]] = None_() + result = nested.flatten() + assert result.is_none() + + def test_flatten_requires_nested_option(self) -> None: + """Verify flatten raises TypeError if Some value is not an Option.""" + opt: Option[int] = Some(42) + with pytest.raises(TypeError, match="flatten requires Some value to be an Option"): + opt.flatten() # type: ignore[misc] + + def test_flatten_multiple_levels(self) -> None: + """Verify flatten only removes one level of nesting.""" + + def triple_nested() -> Option[Option[Option[int]]]: + return Some(Some(Some(42))) + + nested = triple_nested() + once_flattened = nested.flatten() + assert once_flattened.is_some() + + # Still nested, need another flatten + twice_flattened = once_flattened.flatten() + assert twice_flattened.is_some() + assert twice_flattened.unwrap() == 42 + + +class TestOptionTranspose: + """Test Option.transpose() for converting Option[Result] to Result[Option].""" + + def test_transpose_some_ok(self) -> None: + """Transpose Some(Ok(value)) -> Ok(Some(value)).""" + + def make_some_ok() -> Option[Result[int, str]]: + return Some(Ok(42)) + + opt = make_some_ok() + result = opt.transpose() + assert result.is_ok() + unwrapped = result.unwrap() + assert unwrapped.is_some() + assert unwrapped.unwrap() == 42 + + def test_transpose_some_err(self) -> None: + """Transpose Some(Err(error)) -> Err(error).""" + + def make_err() -> Result[int, str]: + return Err("error") + + opt: Option[Result[int, str]] = Some(make_err()) + result = opt.transpose() + assert result.is_err() + assert result.unwrap_err() == "error" + + def test_transpose_none(self) -> None: + """Transpose None -> Ok(None).""" + opt: Option[Result[int, str]] = None_() + result = opt.transpose() + assert result.is_ok() + unwrapped = result.unwrap() + assert unwrapped.is_none() + + def test_transpose_requires_result(self) -> None: + """Verify transpose raises TypeError if Some value is not a Result.""" + opt: Option[int] = Some(42) + with pytest.raises(TypeError, match="transpose requires Some value to be a Result"): + opt.transpose() # type: ignore[misc] + + def test_transpose_round_trip_some(self) -> None: + """Verify transpose is reversible for Some(Ok(value)).""" + + def make_option() -> Option[Result[int, str]]: + return Some(Ok(42)) + + opt = make_option() + # Option[Result[T, E]] -> Result[Option[T], E] + transposed = opt.transpose() + assert transposed.is_ok() + + # Result[Option[T], E] -> Option[Result[T, E]] + back = transposed.transpose() + assert back.is_some() + inner_result = back.unwrap() + assert inner_result.is_ok() + assert inner_result.unwrap() == 42 + + def test_transpose_round_trip_none(self) -> None: + """Verify transpose is reversible for None.""" + + def make_option() -> Option[Result[int, str]]: + return None_() + + opt = make_option() + # Option[Result[T, E]] -> Result[Option[T], E] + transposed = opt.transpose() + assert transposed.is_ok() + + # Result[Option[T], E] -> Option[Result[T, E]] + back = transposed.transpose() + assert back.is_none() + + +class TestOptionZip: + """Test Option.zip() for combining two Options into a tuple.""" + + def test_zip_some_some(self) -> None: + """Zip Some(a) with Some(b) -> Some((a, b)).""" + opt1: Option[int] = Some(10) + opt2: Option[str] = Some("hello") + result = opt1.zip(opt2) + assert result.is_some() + value = result.unwrap() + assert value == (10, "hello") + + def test_zip_some_none(self) -> None: + """Zip Some(a) with None -> None.""" + opt1: Option[int] = Some(10) + opt2: Option[str] = None_() + result = opt1.zip(opt2) + assert result.is_none() + + def test_zip_none_some(self) -> None: + """Zip None with Some(b) -> None.""" + + def none_val() -> Option[int]: + return None_() + + def some_val() -> Option[str]: + return Some("hello") + + opt1 = none_val() + opt2 = some_val() + result = opt1.zip(opt2) + assert result.is_none() + + def test_zip_none_none(self) -> None: + """Zip None with None -> None.""" + + def none_val1() -> Option[int]: + return None_() + + def none_val2() -> Option[str]: + return None_() + + opt1 = none_val1() + opt2 = none_val2() + result = opt1.zip(opt2) + assert result.is_none() + + def test_zip_different_types(self) -> None: + """Verify zip works with different types.""" + opt1: Option[int] = Some(42) + opt2: Option[list[str]] = Some(["a", "b", "c"]) + result = opt1.zip(opt2) + assert result.is_some() + assert result.unwrap() == (42, ["a", "b", "c"]) + + +class TestOptionZipWith: + """Test Option.zip_with() for combining Options with a function.""" + + def test_zip_with_some_some(self) -> None: + """Zip Some(a) with Some(b) using function -> Some(f(a, b)).""" + opt1: Option[int] = Some(10) + opt2: Option[int] = Some(20) + result = opt1.zip_with(opt2, lambda x, y: x + y) + assert result.is_some() + assert result.unwrap() == 30 + + def test_zip_with_some_none(self) -> None: + """Zip Some(a) with None using function -> None.""" + opt1: Option[int] = Some(10) + opt2: Option[int] = None_() + result = opt1.zip_with(opt2, lambda x, y: x + y) + assert result.is_none() + + def test_zip_with_none_some(self) -> None: + """Zip None with Some(b) using function -> None.""" + + def none_val() -> Option[int]: + return None_() + + def some_val() -> Option[int]: + return Some(20) + + opt1 = none_val() + opt2 = some_val() + result = opt1.zip_with(opt2, lambda x, y: x + y) + assert result.is_none() + + def test_zip_with_none_none(self) -> None: + """Zip None with None using function -> None.""" + + def none_val1() -> Option[int]: + return None_() + + def none_val2() -> Option[int]: + return None_() + + opt1 = none_val1() + opt2 = none_val2() + result = opt1.zip_with(opt2, lambda x, y: x + y) + assert result.is_none() + + def test_zip_with_string_concatenation(self) -> None: + """Verify zip_with with string concatenation.""" + opt1: Option[str] = Some("Hello") + opt2: Option[str] = Some(" World") + result = opt1.zip_with(opt2, lambda x, y: x + y) + assert result.is_some() + assert result.unwrap() == "Hello World" + + def test_zip_with_different_types(self) -> None: + """Verify zip_with works with different input types.""" + opt1: Option[int] = Some(42) + opt2: Option[str] = Some("items") + result = opt1.zip_with(opt2, lambda count, item: f"{count} {item}") + assert result.is_some() + assert result.unwrap() == "42 items"