Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions pyropust/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions pyropust/pyropust_native.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
88 changes: 88 additions & 0 deletions src/py/option.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -210,6 +213,91 @@ impl OptionObj {
_ => none_(),
}
}

// Utility methods
fn flatten(&self, py: Python<'_>) -> PyResult<Self> {
if self.is_some {
let value = self.value.as_ref().expect("some value");
let option_type = py.get_type::<OptionObj>();
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<ResultObj> {
if self.is_some {
let value = self.value.as_ref().expect("some value");
let result_type = py.get_type::<ResultObj>();
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<Self> {
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<Self> {
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<PyAny>) -> 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<ResultObj> {
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
Expand Down
132 changes: 132 additions & 0 deletions tests/option/test_conversion.py
Original file line number Diff line number Diff line change
@@ -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"
Loading