diff --git a/pyropust/__init__.pyi b/pyropust/__init__.pyi index ad76139..34d44de 100644 --- a/pyropust/__init__.pyi +++ b/pyropust/__init__.pyi @@ -42,9 +42,22 @@ class Result(Generic[T_co, E_co]): class Option(Generic[T_co]): def is_some(self) -> bool: ... def is_none(self) -> bool: ... + def is_some_and(self, predicate: Callable[[T_co], object]) -> bool: ... + def is_none_or(self, predicate: Callable[[T_co], object]) -> bool: ... def unwrap(self) -> T_co: ... - def map[U](self, f: Callable[[T_co], U]) -> Option[U]: ... + def expect(self, msg: str) -> T_co: ... def unwrap_or[U](self, default: U) -> T_co | U: ... + def unwrap_or_else[U](self, f: Callable[[], U]) -> T_co | U: ... + def map[U](self, f: Callable[[T_co], U]) -> Option[U]: ... + def map_or[U](self, default: U, f: Callable[[T_co], U]) -> U: ... + def map_or_else[U](self, default_f: Callable[[], U], f: Callable[[T_co], U]) -> U: ... + def inspect(self, f: Callable[[T_co], object]) -> Option[T_co]: ... + def filter(self, predicate: Callable[[T_co], object]) -> Option[T_co]: ... + def and_[U](self, other: Option[U]) -> Option[U]: ... + def and_then[U](self, f: Callable[[T_co], Option[U]]) -> Option[U]: ... + 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]: ... class ErrorKind: InvalidInput: ErrorKind diff --git a/pyropust/pyropust_native.pyi b/pyropust/pyropust_native.pyi index b8b041c..0d97f55 100644 --- a/pyropust/pyropust_native.pyi +++ b/pyropust/pyropust_native.pyi @@ -48,9 +48,22 @@ class Result(Generic[T_co, E_co]): class Option(Generic[T_co]): def is_some(self) -> bool: ... def is_none(self) -> bool: ... + def is_some_and(self, predicate: Callable[[T_co], object]) -> bool: ... + def is_none_or(self, predicate: Callable[[T_co], object]) -> bool: ... def unwrap(self) -> T_co: ... - def map[U](self, f: Callable[[T_co], U]) -> Option[U]: ... + def expect(self, msg: str) -> T_co: ... def unwrap_or[U](self, default: U) -> T_co | U: ... + def unwrap_or_else[U](self, f: Callable[[], U]) -> T_co | U: ... + def map[U](self, f: Callable[[T_co], U]) -> Option[U]: ... + def map_or[U](self, default: U, f: Callable[[T_co], U]) -> U: ... + def map_or_else[U](self, default_f: Callable[[], U], f: Callable[[T_co], U]) -> U: ... + def inspect(self, f: Callable[[T_co], object]) -> Option[T_co]: ... + def filter(self, predicate: Callable[[T_co], object]) -> Option[T_co]: ... + def and_[U](self, other: Option[U]) -> Option[U]: ... + def and_then[U](self, f: Callable[[T_co], Option[U]]) -> Option[U]: ... + 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]: ... class ErrorKind: InvalidInput: ErrorKind diff --git a/src/py/option.rs b/src/py/option.rs index 25a5659..db38a7a 100644 --- a/src/py/option.rs +++ b/src/py/option.rs @@ -1,4 +1,4 @@ -use pyo3::exceptions::PyRuntimeError; +use pyo3::exceptions::{PyRuntimeError, PyTypeError}; use pyo3::prelude::*; #[pyclass(name = "Option")] @@ -42,6 +42,174 @@ impl OptionObj { Ok(default.clone_ref(py)) } } + + // Query methods + fn is_some_and(&self, py: Python<'_>, predicate: Bound<'_, PyAny>) -> PyResult { + if self.is_some { + let value = self.value.as_ref().expect("some value"); + let result = predicate.call1((value.clone_ref(py),))?; + result.is_truthy() + } else { + Ok(false) + } + } + + fn is_none_or(&self, py: Python<'_>, predicate: Bound<'_, PyAny>) -> PyResult { + if self.is_some { + let value = self.value.as_ref().expect("some value"); + let result = predicate.call1((value.clone_ref(py),))?; + result.is_truthy() + } else { + Ok(true) + } + } + + // Extraction methods + fn expect(&self, py: Python<'_>, msg: &str) -> PyResult> { + if self.is_some { + Ok(self.value.as_ref().expect("some value").clone_ref(py)) + } else { + Err(PyRuntimeError::new_err(msg.to_string())) + } + } + + fn unwrap_or_else(&self, py: Python<'_>, f: Bound<'_, PyAny>) -> PyResult> { + if self.is_some { + Ok(self.value.as_ref().expect("some value").clone_ref(py)) + } else { + let result = f.call0()?; + Ok(result.into()) + } + } + + // Transformation methods + fn map_or( + &self, + py: Python<'_>, + default: Py, + f: Bound<'_, PyAny>, + ) -> PyResult> { + if self.is_some { + let value = self.value.as_ref().expect("some value"); + let result = f.call1((value.clone_ref(py),))?; + Ok(result.into()) + } else { + Ok(default) + } + } + + fn map_or_else( + &self, + py: Python<'_>, + default_f: Bound<'_, PyAny>, + f: Bound<'_, PyAny>, + ) -> PyResult> { + if self.is_some { + let value = self.value.as_ref().expect("some value"); + let result = f.call1((value.clone_ref(py),))?; + Ok(result.into()) + } else { + let result = default_f.call0()?; + Ok(result.into()) + } + } + + fn inspect(&self, py: Python<'_>, f: Bound<'_, PyAny>) -> PyResult { + if self.is_some { + let value = self.value.as_ref().expect("some value"); + f.call1((value.clone_ref(py),))?; + } + Ok(OptionObj { + is_some: self.is_some, + value: self.value.as_ref().map(|v| v.clone_ref(py)), + }) + } + + fn filter(&self, py: Python<'_>, predicate: Bound<'_, PyAny>) -> PyResult { + if self.is_some { + let value = self.value.as_ref().expect("some value"); + let result = predicate.call1((value.clone_ref(py),))?; + if result.is_truthy()? { + Ok(some(value.clone_ref(py))) + } else { + Ok(none_()) + } + } else { + Ok(none_()) + } + } + + // Composition methods + fn and_(&self, py: Python<'_>, other: &Self) -> Self { + if self.is_some { + OptionObj { + is_some: other.is_some, + value: other.value.as_ref().map(|v| v.clone_ref(py)), + } + } else { + none_() + } + } + + fn and_then(&self, py: Python<'_>, f: Bound<'_, PyAny>) -> PyResult { + if self.is_some { + let value = self.value.as_ref().expect("some value"); + let out = f.call1((value.clone_ref(py),))?; + let option_type = py.get_type::(); + if !out.is_instance(option_type.as_any())? { + return Err(PyTypeError::new_err("and_then callback must return Option")); + } + let out_ref: PyRef<'_, OptionObj> = out.extract()?; + Ok(clone_option(py, &out_ref)) + } else { + Ok(none_()) + } + } + + fn or_(&self, py: Python<'_>, other: &Self) -> Self { + if self.is_some { + OptionObj { + is_some: self.is_some, + value: self.value.as_ref().map(|v| v.clone_ref(py)), + } + } else { + OptionObj { + is_some: other.is_some, + value: other.value.as_ref().map(|v| v.clone_ref(py)), + } + } + } + + fn or_else(&self, py: Python<'_>, f: Bound<'_, PyAny>) -> PyResult { + if self.is_some { + Ok(OptionObj { + is_some: self.is_some, + value: self.value.as_ref().map(|v| v.clone_ref(py)), + }) + } else { + let out = f.call0()?; + let option_type = py.get_type::(); + if !out.is_instance(option_type.as_any())? { + return Err(PyTypeError::new_err("or_else callback must return Option")); + } + let out_ref: PyRef<'_, OptionObj> = out.extract()?; + Ok(clone_option(py, &out_ref)) + } + } + + fn xor(&self, py: Python<'_>, other: &Self) -> Self { + match (self.is_some, other.is_some) { + (true, false) => OptionObj { + is_some: true, + value: self.value.as_ref().map(|v| v.clone_ref(py)), + }, + (false, true) => OptionObj { + is_some: true, + value: other.value.as_ref().map(|v| v.clone_ref(py)), + }, + _ => none_(), + } + } } // Python-facing constructor functions @@ -69,3 +237,10 @@ pub fn none_() -> OptionObj { value: None, } } + +fn clone_option(py: Python<'_>, out_ref: &PyRef<'_, OptionObj>) -> OptionObj { + OptionObj { + is_some: out_ref.is_some, + value: out_ref.value.as_ref().map(|v| v.clone_ref(py)), + } +} diff --git a/tests/option/test_composition.py b/tests/option/test_composition.py new file mode 100644 index 0000000..a355b3e --- /dev/null +++ b/tests/option/test_composition.py @@ -0,0 +1,266 @@ +"""Tests for Option composition methods (and_then, and_, or_). + +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 TestOptionAndThen: + """Test Option.and_then() for chaining operations that return Option.""" + + def test_and_then_chains_some_options(self) -> None: + opt = Some(10).and_then(lambda x: Some(x * 2)) + assert opt.is_some() + assert opt.unwrap() == 20 + + def test_and_then_short_circuits_on_none(self) -> None: + opt: Option[int] = None_().and_then(lambda x: Some(x * 2)) + assert opt.is_none() + + def test_and_then_propagates_inner_none(self) -> None: + def get_some() -> Option[int]: + return Some(10) + + def inner_none(_val: int) -> Option[int]: + return None_() + + opt = get_some().and_then(inner_none) + assert opt.is_none() + + def test_and_then_chains_with_type_change(self) -> None: + opt = Some(123).and_then(lambda x: Some(str(x))) + assert opt.is_some() + assert opt.unwrap() == "123" + + +class TestOptionAnd: + """Test Option.and_() for combining Options.""" + + def test_and_returns_other_on_some(self) -> None: + opt1: Option[int] = Some(10) + opt2: Option[str] = Some("value") + result = opt1.and_(opt2) + assert result.is_some() + assert result.unwrap() == "value" + + def test_and_returns_none_when_first_is_none(self) -> None: + opt1: Option[int] = None_() + opt2: Option[str] = Some("value") + result = opt1.and_(opt2) + assert result.is_none() + + def test_and_returns_none_when_second_is_none(self) -> None: + opt1: Option[int] = Some(10) + opt2: Option[str] = None_() + result = opt1.and_(opt2) + assert result.is_none() + + def test_and_both_none_returns_none(self) -> None: + opt1: Option[int] = None_() + opt2: Option[str] = None_() + result = opt1.and_(opt2) + assert result.is_none() + + def test_and_enables_sequential_validation(self) -> None: + """Test and_ for sequential validation where both must be Some.""" + + def validate_positive(n: int) -> Option[int]: + return Some(n) if n > 0 else None_() + + def validate_even(n: int) -> Option[int]: + return Some(n) if n % 2 == 0 else None_() + + # Both validations pass + opt1 = validate_positive(10) + opt2 = validate_even(10) + result = opt1.and_(opt2) + assert result.is_some() + + # First validation fails + opt1_fail = validate_positive(-5) + opt2_pass = validate_even(10) + result_fail = opt1_fail.and_(opt2_pass) + assert result_fail.is_none() + + +class TestOptionOr: + """Test Option.or_() for providing fallback Options.""" + + def test_or_returns_self_on_some(self) -> None: + opt1: Option[int] = Some(10) + opt2: Option[int] = Some(20) + result = opt1.or_(opt2) + assert result.is_some() + assert result.unwrap() == 10 + + def test_or_returns_other_on_none(self) -> None: + def none_val() -> Option[int]: + return None_() + + def some_val() -> Option[int]: + return Some(20) + + opt1 = none_val() + opt2 = some_val() + result = opt1.or_(opt2) + assert result.is_some() + assert result.unwrap() == 20 + + def test_or_both_some_returns_first(self) -> None: + opt1: Option[int] = Some(10) + opt2: Option[int] = Some(20) + result = opt1.or_(opt2) + assert result.is_some() + assert result.unwrap() == 10 + + def test_or_both_none_returns_none(self) -> None: + def none_val1() -> Option[int]: + return None_() + + def none_val2() -> Option[int]: + return None_() + + opt1 = none_val1() + opt2 = none_val2() + result = opt1.or_(opt2) + assert result.is_none() + + def test_or_enables_fallback_chain(self) -> None: + """Test or_ for trying multiple fallback sources.""" + + def primary_source() -> Option[str]: + return None_() + + def secondary_source() -> Option[str]: + return None_() + + def tertiary_source() -> Option[str]: + return Some("fallback value") + + result = primary_source().or_(secondary_source()).or_(tertiary_source()) + assert result.is_some() + assert result.unwrap() == "fallback value" + + +class TestOptionOrElse: + """Test Option.or_else() for fallback with computation.""" + + def test_or_else_returns_self_on_some(self) -> None: + opt: Option[int] = Some(10) + result = opt.or_else(lambda: Some(20)) + assert result.is_some() + assert result.unwrap() == 10 + + def test_or_else_calls_function_on_none(self) -> None: + def none_val() -> Option[int]: + return None_() + + def fallback() -> Option[int]: + return Some(20) + + opt = none_val() + result = opt.or_else(fallback) + assert result.is_some() + assert result.unwrap() == 20 + + def test_or_else_can_return_none(self) -> None: + def none_val() -> Option[int]: + return None_() + + def fallback() -> Option[int]: + return None_() + + opt = none_val() + result = opt.or_else(fallback) + assert result.is_none() + + def test_or_else_enables_fallback_chain(self) -> None: + """Test or_else for trying multiple fallback computations.""" + + def primary() -> Option[str]: + return None_() + + def secondary() -> Option[str]: + return None_() + + def tertiary() -> Option[str]: + return Some("fallback value") + + result = primary().or_else(secondary).or_else(tertiary) + assert result.is_some() + assert result.unwrap() == "fallback value" + + def test_or_else_function_not_called_on_some(self) -> None: + """Verify or_else function is not called when Some.""" + called: list[bool] = [] + + def fallback() -> Option[int]: + called.append(True) + return Some(99) + + opt: Option[int] = Some(10) + result = opt.or_else(fallback) + assert called == [] + assert result.is_some() + assert result.unwrap() == 10 + + +class TestOptionXor: + """Test Option.xor() for exclusive or operation.""" + + def test_xor_some_none_returns_some(self) -> None: + opt1: Option[int] = Some(10) + opt2: Option[int] = None_() + result = opt1.xor(opt2) + assert result.is_some() + assert result.unwrap() == 10 + + def test_xor_none_some_returns_some(self) -> None: + def none_val() -> Option[int]: + return None_() + + def some_val() -> Option[int]: + return Some(20) + + opt1 = none_val() + opt2 = some_val() + result = opt1.xor(opt2) + assert result.is_some() + assert result.unwrap() == 20 + + def test_xor_some_some_returns_none(self) -> None: + opt1: Option[int] = Some(10) + opt2: Option[int] = Some(20) + result = opt1.xor(opt2) + assert result.is_none() + + def test_xor_none_none_returns_none(self) -> None: + def none_val1() -> Option[int]: + return None_() + + def none_val2() -> Option[int]: + return None_() + + opt1 = none_val1() + opt2 = none_val2() + result = opt1.xor(opt2) + assert result.is_none() + + def test_xor_enables_exclusive_choice(self) -> None: + """Test xor for choosing when exactly one is Some.""" + + def from_cache() -> Option[str]: + return None_() + + def from_default() -> Option[str]: + return Some("default") + + # Exactly one source has a value + result = from_cache().xor(from_default()) + assert result.is_some() + assert result.unwrap() == "default" diff --git a/tests/option/test_methods.py b/tests/option/test_extraction.py similarity index 51% rename from tests/option/test_methods.py rename to tests/option/test_extraction.py index 23d7039..1366430 100644 --- a/tests/option/test_methods.py +++ b/tests/option/test_extraction.py @@ -1,21 +1,10 @@ -"""Tests for Option methods (map, unwrap_or).""" +"""Tests for Option extraction methods.""" from __future__ import annotations -from pyropust import None_, Option, Some - - -class TestOptionMap: - """Test Option.map() for transforming Some values.""" - - def test_map_transforms_some_value(self) -> None: - opt = Some(10).map(lambda x: x * 2) - assert opt.is_some() - assert opt.unwrap() == 20 +import pytest - def test_map_skips_on_none(self) -> None: - opt: Option[int] = None_().map(lambda x: x * 2) - assert opt.is_none() +from pyropust import None_, Option, Some class TestOptionUnwrapOr: @@ -44,3 +33,28 @@ def find_user(user_id: int) -> Option[str]: name_opt2 = find_user(999) name2 = name_opt2.unwrap_or("Guest") assert name2 == "Guest" + + +class TestOptionExpect: + """Test Option.expect() for extracting values with custom error messages.""" + + def test_expect_returns_value_on_some(self) -> None: + opt = Some("value") + assert opt.expect("should have value") == "value" + + def test_expect_raises_on_none(self) -> None: + opt: Option[str] = None_() + with pytest.raises(RuntimeError, match="custom error"): + opt.expect("custom error") + + +class TestOptionUnwrapOrElse: + """Test Option.unwrap_or_else() for computing default values.""" + + def test_unwrap_or_else_returns_value_on_some(self) -> None: + opt = Some(10) + assert opt.unwrap_or_else(lambda: 42) == 10 + + def test_unwrap_or_else_computes_default_on_none(self) -> None: + opt: Option[int] = None_() + assert opt.unwrap_or_else(lambda: 42) == 42 diff --git a/tests/option/test_query.py b/tests/option/test_query.py new file mode 100644 index 0000000..7e5dc90 --- /dev/null +++ b/tests/option/test_query.py @@ -0,0 +1,33 @@ +"""Tests for Option query methods.""" + +from __future__ import annotations + +from pyropust import None_, Option, Some + + +class TestOptionQuery: + """Test Option query methods.""" + + def test_is_some_and_returns_true_when_predicate_matches(self) -> None: + opt = Some(10) + assert opt.is_some_and(lambda x: x > 5) + + def test_is_some_and_returns_false_when_predicate_fails(self) -> None: + opt = Some(3) + assert not opt.is_some_and(lambda x: x > 5) + + def test_is_some_and_returns_false_on_none(self) -> None: + opt: Option[int] = None_() + assert not opt.is_some_and(lambda x: x > 5) + + def test_is_none_or_returns_true_on_none(self) -> None: + opt: Option[int] = None_() + assert opt.is_none_or(lambda x: x > 5) + + def test_is_none_or_returns_true_when_predicate_matches(self) -> None: + opt = Some(10) + assert opt.is_none_or(lambda x: x > 5) + + def test_is_none_or_returns_false_when_predicate_fails(self) -> None: + opt = Some(3) + assert not opt.is_none_or(lambda x: x > 5) diff --git a/tests/option/test_transformation.py b/tests/option/test_transformation.py new file mode 100644 index 0000000..9738a20 --- /dev/null +++ b/tests/option/test_transformation.py @@ -0,0 +1,85 @@ +"""Tests for Option transformation methods.""" + +from __future__ import annotations + +from pyropust import None_, Option, Some + + +class TestOptionMap: + """Test Option.map() for transforming Some values.""" + + def test_map_transforms_some_value(self) -> None: + opt = Some(10).map(lambda x: x * 2) + assert opt.is_some() + assert opt.unwrap() == 20 + + def test_map_skips_on_none(self) -> None: + opt: Option[int] = None_().map(lambda x: x * 2) + assert opt.is_none() + + +class TestOptionMapOr: + """Test Option.map_or() for transforming with default values.""" + + def test_map_or_applies_function_on_some(self) -> None: + opt = Some(5) + result = opt.map_or(0, lambda x: x * 2) + assert result == 10 + + def test_map_or_returns_default_on_none(self) -> None: + opt: Option[int] = None_() + result = opt.map_or(0, lambda x: x * 2) + assert result == 0 + + +class TestOptionMapOrElse: + """Test Option.map_or_else() for transforming with computed defaults.""" + + def test_map_or_else_applies_function_on_some(self) -> None: + opt = Some(5) + result = opt.map_or_else(lambda: 0, lambda x: x * 2) + assert result == 10 + + def test_map_or_else_computes_default_on_none(self) -> None: + opt: Option[int] = None_() + result = opt.map_or_else(lambda: 42, lambda x: x * 2) + assert result == 42 + + +class TestOptionInspect: + """Test Option.inspect() for side effects.""" + + def test_inspect_calls_function_on_some(self) -> None: + called: list[int] = [] + opt = Some(10) + result = opt.inspect(lambda x: called.append(x)) + assert called == [10] + assert result.is_some() + assert result.unwrap() == 10 + + def test_inspect_does_not_call_on_none(self) -> None: + called: list[int] = [] + opt: Option[int] = None_() + result = opt.inspect(lambda x: called.append(x)) + assert called == [] + assert result.is_none() + + +class TestOptionFilter: + """Test Option.filter() for conditional filtering.""" + + def test_filter_keeps_value_when_predicate_matches(self) -> None: + opt = Some(10) + result = opt.filter(lambda x: x > 5) + assert result.is_some() + assert result.unwrap() == 10 + + def test_filter_returns_none_when_predicate_fails(self) -> None: + opt = Some(3) + result = opt.filter(lambda x: x > 5) + assert result.is_none() + + def test_filter_returns_none_on_none(self) -> None: + opt: Option[int] = None_() + result = opt.filter(lambda x: x > 5) + assert result.is_none()