Skip to content

Commit

Permalink
Add ok_or and ok_or_else methods.
Browse files Browse the repository at this point in the history
  • Loading branch information
chuckwondo committed Aug 18, 2024
1 parent 87a13c6 commit afbe69e
Show file tree
Hide file tree
Showing 8 changed files with 234 additions and 78 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ jobs:

# Install library
- name: Install maybe
run: pip install --root-user-action=ignore --editable .
run: pip install --root-user-action=ignore --editable .[result]

# Tests
- name: Run tests (excluding pattern matching)
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ PYTHON_PRE_310 := $(shell python -c "import sys; print(sys.version_info < (3, 10
install: phony
@echo Installing dependencies...
python -m pip install --require-virtualenv -r requirements-dev.txt
python -m pip install --require-virtualenv -e .
python -m pip install --require-virtualenv -e .[result]

lint: phony lint-flake lint-mypy

Expand All @@ -23,7 +23,7 @@ lint-mypy: phony
mypy

test: phony
pytest
pytest -vv

docs: phony
lazydocs \
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,22 @@ assert o.unwrap_or_else(str.upper) == 'yay'
assert n.unwrap_or_else(lambda: 'default') == 'default'
```

There are some methods that support conversion from a `Maybe` to a `Result` type
in the `result` package. If you wish to leverage these methods, you must add
`result` to your dependency list:

```python
from maybe import Nothing, Some
from result import Ok, Err

o = Some('yay')
n = Nothing()
assert o.ok_or('error') == Ok('yay')
assert o.ok_or_else(lambda: 'error') == Ok('yay')
assert n.ok_or('error') == Err('error')
assert n.ok_or_else(lambda: 'error') == Err('error')
```

## Contributing

These steps should work on any Unix-based system (Linux, macOS, etc) with Python
Expand Down
126 changes: 91 additions & 35 deletions docs/maybe.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ disallow_untyped_defs = true
ignore_missing_imports = true
no_implicit_optional = true
no_implicit_reexport = true
pretty = true
show_column_numbers = true
show_error_codes = true
show_error_context = true
Expand All @@ -44,4 +45,3 @@ addopts = [
# By default, ignore tests that only run on Python 3.10+
"--ignore=tests/test_pattern_matching.py",
]
testpaths = ["tests"]
3 changes: 3 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ where = src
[options.package_data]
maybe = py.typed

[options.extras_require]
result = result

[flake8]
# flake8 does not (yet?) support pyproject.toml; see
# https://github.com/PyCQA/flake8/issues/234
Expand Down
67 changes: 59 additions & 8 deletions src/maybe/maybe.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# mypy: disable-error-code="import-not-found"

from __future__ import annotations

import sys
Expand All @@ -19,9 +21,17 @@
from typing_extensions import ParamSpec, TypeAlias, TypeGuard


try:
import result # pyright: ignore[reportMissingImports]

_RESULT_INSTALLED = True
except ImportError: # pragma: no cover
_RESULT_INSTALLED = False


T = TypeVar("T", covariant=True) # Success type
U = TypeVar("U")
F = TypeVar("F")
E = TypeVar("E")
P = ParamSpec("P")
R = TypeVar("R")
TBE = TypeVar("TBE", bound=BaseException)
Expand Down Expand Up @@ -81,7 +91,7 @@ def unwrap(self) -> T:
"""
return self._value

def unwrap_or(self, _default: U) -> T:
def unwrap_or(self, _default: object) -> T:
"""
Return the value.
"""
Expand Down Expand Up @@ -133,6 +143,26 @@ def or_else(self, _op: object) -> Some[T]:
"""
return self

if _RESULT_INSTALLED:

def ok_or(self, _error: object) -> result.Ok[T]:
"""
Return a `result.Ok` with the inner value.
**NOTE**: This method is available only if the `result` package is
installed.
"""
return result.Ok(self._value)

def ok_or_else(self, _op: object) -> result.Ok[T]:
"""
Return a `result.Ok` with the inner value.
**NOTE**: This method is available only if the `result` package is
installed.
"""
return result.Ok(self._value)


class Nothing:
"""
Expand Down Expand Up @@ -239,21 +269,42 @@ def or_else(self, op: Callable[[], Maybe[T]]) -> Maybe[T]:
"""
return op()

if _RESULT_INSTALLED:

# define Maybe as a generic type alias for use
# in type annotations
def ok_or(self, error: E) -> result.Err[E]:
"""
There is no contained value, so return a `result.Err` with the given
error value.
**NOTE**: This method is available only if the `result` package is
installed.
"""
return result.Err(error)

def ok_or_else(self, op: Callable[[], E]) -> result.Err[E]:
"""
There is no contained value, so return a `result.Err` with the
result of `op`.
**NOTE**: This method is available only if the `result` package is
installed.
"""
return result.Err(op())


# Define Maybe as a generic type alias for use in type annotations
Maybe: TypeAlias = Union[Some[T], Nothing]
"""
A simple `Maybe` type inspired by Rust.
Not all methods (https://doc.rust-lang.org/std/option/enum.Option.html)
have been implemented, only the ones that make sense in the Python context.
"""
Maybe: TypeAlias = Union[Some[T], Nothing]

SomeNothing: Final = (Some, Nothing)
"""
A type to use in `isinstance` checks.
This is purely for convenience sake, as you could also just write `isinstance(res, (Some, Nothing))
A type to use in `isinstance` checks. This is purely for convenience sake, as you could
also just write `isinstance(res, (Some, Nothing))
"""
SomeNothing: Final = (Some, Nothing)


class UnwrapError(Exception):
Expand Down
92 changes: 61 additions & 31 deletions tests/test_maybe.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@

from maybe import Some, SomeNothing, Maybe, Nothing, UnwrapError, is_nothing, is_some

try:
import result # pyright: ignore[reportMissingImports]

_RESULT_INSTALLED = True
except ImportError:
_RESULT_INSTALLED = False


def test_some_factories() -> None:
instance = Some(1)
Expand Down Expand Up @@ -50,15 +57,15 @@ def test_repr() -> None:


def test_some_value() -> None:
res = Some('haha')
assert res.some_value == 'haha'
res = Some("haha")
assert res.some_value == "haha"


def test_some() -> None:
res = Some('haha')
res = Some("haha")
assert res.is_some() is True
assert res.is_nothing() is False
assert res.some_value == 'haha'
assert res.some_value == "haha"


def test_some_guard() -> None:
Expand All @@ -76,71 +83,75 @@ def test_nothing() -> None:


def test_some_method() -> None:
o = Some('yay')
o = Some("yay")
n = Nothing()
assert o.some() == 'yay'
# TODO(francium): Can this type ignore directive be removed? mypy may fail?
assert o.some() == "yay"

# Unfortunately, it seems the mypy team made a very deliberate and highly contested
# decision to mark using the return value from a function known to only return None
# as an error, so we are forced to ignore the check here.
# See https://github.com/python/mypy/issues/6549
assert n.some() is None # type: ignore[func-returns-value]


def test_expect() -> None:
o = Some('yay')
o = Some("yay")
n = Nothing()
assert o.expect('failure') == 'yay'
assert o.expect("failure") == "yay"
with pytest.raises(UnwrapError):
n.expect('failure')
n.expect("failure")


def test_unwrap() -> None:
o = Some('yay')
o = Some("yay")
n = Nothing()
assert o.unwrap() == 'yay'
assert o.unwrap() == "yay"
with pytest.raises(UnwrapError):
n.unwrap()


def test_unwrap_or() -> None:
o = Some('yay')
o = Some("yay")
n = Nothing()
assert o.unwrap_or('some_default') == 'yay'
assert n.unwrap_or('another_default') == 'another_default'
assert o.unwrap_or("some_default") == "yay"
assert n.unwrap_or("another_default") == "another_default"


def test_unwrap_or_else() -> None:
o = Some('yay')
o = Some("yay")
n = Nothing()
assert o.unwrap_or_else(str.upper) == 'yay'
assert n.unwrap_or_else(lambda: 'default') == 'default'
assert o.unwrap_or_else(str.upper) == "yay"
assert n.unwrap_or_else(lambda: "default") == "default"


def test_unwrap_or_raise() -> None:
o = Some('yay')
o = Some("yay")
n = Nothing()
assert o.unwrap_or_raise(ValueError) == 'yay'
assert o.unwrap_or_raise(ValueError) == "yay"
with pytest.raises(ValueError) as exc_info:
n.unwrap_or_raise(ValueError)
assert exc_info.value.args == ()


def test_map() -> None:
o = Some('yay')
o = Some("yay")
n = Nothing()
assert o.map(str.upper).some() == 'YAY'
assert o.map(str.upper).some() == "YAY"
assert n.map(str.upper).is_nothing()


def test_map_or() -> None:
o = Some('yay')
o = Some("yay")
n = Nothing()
assert o.map_or('hay', str.upper) == 'YAY'
assert n.map_or('hay', str.upper) == 'hay'
assert o.map_or("hay", str.upper) == "YAY"
assert n.map_or("hay", str.upper) == "hay"


def test_map_or_else() -> None:
o = Some('yay')
o = Some("yay")
n = Nothing()
assert o.map_or_else(lambda: 'hay', str.upper) == 'YAY'
assert n.map_or_else(lambda: 'hay', str.upper) == 'hay'
assert o.map_or_else(lambda: "hay", str.upper) == "YAY"
assert n.map_or_else(lambda: "hay", str.upper) == "hay"


def test_and_then() -> None:
Expand All @@ -159,14 +170,19 @@ def test_or_else() -> None:
assert Some(2).or_else(sq).or_else(sq).some() == 2
assert Some(2).or_else(to_nothing).or_else(sq).some() == 2
assert Nothing().or_else(lambda: sq(3)).or_else(lambda: to_nothing(2)).some() == 9
assert Nothing().or_else(lambda: to_nothing(2)).or_else(lambda: to_nothing(2)).is_nothing()
assert (
Nothing()
.or_else(lambda: to_nothing(2))
.or_else(lambda: to_nothing(2))
.is_nothing()
)

assert Some(2).or_else(sq_lambda).or_else(sq).some() == 2
assert Some(2).or_else(to_nothing_lambda).or_else(sq_lambda).some() == 2


def test_isinstance_result_type() -> None:
o = Some('yay')
o = Some("yay")
n = Nothing()
assert isinstance(o, SomeNothing)
assert isinstance(n, SomeNothing)
Expand All @@ -185,7 +201,7 @@ def test_slots() -> None:
"""
Some and Nothing have slots, so assigning arbitrary attributes fails.
"""
o = Some('yay')
o = Some("yay")
n = Nothing()
with pytest.raises(AttributeError):
o.some_arbitrary_attribute = 1 # type: ignore[attr-defined]
Expand All @@ -204,3 +220,17 @@ def to_nothing(_: int) -> Maybe[int]:
# Lambda versions of the same functions, just for test/type coverage
sq_lambda: Callable[[int], Maybe[int]] = lambda i: Some(i * i)
to_nothing_lambda: Callable[[int], Maybe[int]] = lambda _: Nothing()


if _RESULT_INSTALLED:
def test_some_ok_or() -> None:
assert Some(1).ok_or("error") == result.Ok(1)

def test_some_ok_or_else() -> None:
assert Some(1).ok_or_else("error") == result.Ok(1)

def test_nothing_ok_or() -> None:
assert Nothing().ok_or("error") == result.Err("error")

def test_nothing_ok_or_else() -> None:
assert Nothing().ok_or_else(lambda: "error") == result.Err("error")

0 comments on commit afbe69e

Please sign in to comment.