From b6b6c97b62a2efc72b55054f18db321b45501995 Mon Sep 17 00:00:00 2001 From: MylesBartlett Date: Thu, 1 Aug 2024 23:03:25 +0100 Subject: [PATCH] Add text to README.md. Add docstrings to option.py. --- README.md | 35 ++++- serox/option.py | 333 +++++++++++++++++++++++++++++++++++++++++++++++- serox/vec.py | 24 ++++ 3 files changed, 384 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 79152f8..45e49df 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,34 @@ -# serox +# Serox: Serpentine Oxidation :fire: -Primitives for Rustifying Python +Rusty Abstractions for Python. + +`Serox` defines a emulates a suite of commonly-used Rust primitives. Namely: + +1. Iterator combinators allowing for the seamless chaining of operations. +2. A `Result` pseudo-`enum` comprising `Some` and `Null` pseudo-variants. We say 'pseudo' as the + Python analogue to Rust's tagged union is the union (`A | B`) type; since this type is not a data + structure, we cannot implement methods on it directly and instead have to resort to some + legerdemain. + +3. A `Option` pseudo-`enum`. The `T | None` pattern is ubiquitous in Python yet, frustratingly, is + not treated as a first-class citizen within the language; `Option` redresses this. + +4. The `qmark` decorator emulates the '?' (error/null short-circuiting) operator, allowing for + propagation of error and null values without interrupting the control flow. Without this, one has + to resort to awkward pattern-matching to perform common operations such as `unwrap_or` (setting + `Null` to a default value) or `map` (applying a function to the contained value if `Some`). + +```python +from serox import Option, qmark + +@qmark +def some_function(foo: Option[str]) -> Option[str]: + foo_bar: str = value.map(lambda x: x + "bar").q + return foo_bar + "_baz" +``` + +## Acknowledgements + +Credit to [result](https://github.com/rustedpy/result) and +[rustshed](https://github.com/pawelrubin/rustshed/) for laying the groundwork for the +[Result](https://github.com/rustedpy/result) and '?' implementations. diff --git a/serox/option.py b/serox/option.py index a48b2d7..7d7dcd5 100644 --- a/serox/option.py +++ b/serox/option.py @@ -6,7 +6,6 @@ Callable, Generator, Hashable, - Literal, NoReturn, Protocol, TypeGuard, @@ -58,7 +57,19 @@ class _Option[T]( Protocol, ): @override - def iter(self: Option[T]) -> Iter[T, Literal[False]]: + def iter(self: Option[T]) -> Iter[T, False_]: + """ + Returns an iterator over the possibly contained value. + + Examples + ======== + .. code-block:: python + x = Some(4) + assert x.iter().next() == 4 + + x: Option[int] = Null() + assert x.iter().next() == Null() + """ return Iter(self, par=False) def __next__(self: Option[T]) -> Option[T]: @@ -68,6 +79,12 @@ def __iter__(self: Option[T]) -> Generator[T, None, None]: yield from Iter(self, par=False) def or_(self: Option[T], optb: Option[T], /) -> Option[T]: + """ + Returns the option if it contains a value, otherwise returns `optb`. + + Arguments passed to or are eagerly evaluated; if you are passing the result of a function + call, it is recommended to use :meth:`or_else`, which is lazily evaluated. + """ return self.__or__(optb) def __or__(self: Option[T], optb: Option[T], /) -> Option[T]: @@ -78,6 +95,9 @@ def __or__(self: Option[T], optb: Option[T], /) -> Option[T]: return optb def or_else(self: Option[T], f: Fn0[Option[T]], /) -> Option[T]: + """ + Returns the option if it contains a value, otherwise calls `f` and returns the result. + """ match x := self: case Some(_): return x @@ -88,6 +108,17 @@ def or_else(self: Option[T], f: Fn0[Option[T]], /) -> Option[T]: # used in conjunction with [`serox::question_mark::qmark`]. @property def q(self: Option[T]) -> T: + """ + '?' operator for early exiting an `Option` returning function. + + Examples + ======== + .. code-block:: python + + @qmark + def some_function(value: Option[str]) -> Option[str]: + return Some(value.q + "_suffix") + """ match self: case Some(x): return x @@ -130,9 +161,15 @@ def into(self: Option[T]) -> T | None: return None def is_some(self: Option[T]) -> bool: + """ + Returns `True` if the option is a `Some` value. + """ return isinstance(self, Some) def is_null(self: Option[T]) -> bool: + """ + Returns `True` if the option is a `Null` value. + """ return not self.is_some() @overload @@ -140,6 +177,15 @@ def unwrap(self: Some[T]) -> T: ... @overload def unwrap(self: Null[T]) -> NoReturn: ... def unwrap(self: Option[T]) -> T | NoReturn: + """ + Returns the contained `Some` value, consuming the `self` value. + + Because this function may panic, its use is generally discouraged. + Instead, prefer to use pattern matching and handle the `Null` case explicitly, or call + :meth:`unwrap_or`, :meth:`unwrap_or_else`, or :meth:`unwrap_or_default`. + + raises :class:`UnwrapFailed`: if the `self` is value equals `Null` + """ match self: case Some(x): return x @@ -147,6 +193,20 @@ def unwrap(self: Option[T]) -> T | NoReturn: raise UnwrapFailed() def unwrap_or(self: Option[T], default: T, /) -> T: + """ + Returns the contained `Some` value or a provided `default`. + + Arguments passed to :meth:`unwrap_or` are eagerly evaluated; if you are passing the result + of a function call, it is recommended to use :meth:`unwrap_or_else`, which is lazily + evaluated. + + Examples + ======== + .. code-block:: python + + assert Some("car").unwrap_or("bike") == "car" + assert Null[str].unwrap_or("bike") == "bike" + """ match self: case Some(x): return x @@ -154,6 +214,17 @@ def unwrap_or(self: Option[T], default: T, /) -> T: return default def unwrap_or_else(self: Option[T], f: Callable[[], T], /) -> T: + """ + Returns the contained `Some` value or computes it from a closure. + + Examples + ======== + .. code-block:: python + + k = 10 + assert Some(4).unwrap_or_else(lambda: 2 * k) == 4 + assert Null[int]().unwrap_or_else(lambda: 2 * k) == 20 + """ match self: case Some(x): return x @@ -165,6 +236,21 @@ def map[U](self: Null[T], f: Fn1[T, U], /) -> Null[U]: ... @overload def map[U](self: Some[T], f: Fn1[T, U], /) -> Some[U]: ... def map[U](self: Option[T], f: Fn1[T, U], /) -> Option[U]: + """ + Maps an `Option[T]` to `Option[U]` by applying a function to a contained value (if `Some`) + or returns `Null` (if `Null`). + + Examples + ======== + .. code-block:: python + + maybe_some_string = Some("Hello, World!") + maybe_some_len = maybe_some_string.map(lambda s: len(s)) + assert maybe_some_len == Some(13) + + x: Option[str] = Null() + assert x.map(lambda s: len(s)) == Null() + """ match self: case Some(t): return Some(f(t)) @@ -172,6 +258,23 @@ def map[U](self: Option[T], f: Fn1[T, U], /) -> Option[U]: return Null[U]() def map_or[U](self: Option[T], default: U, f: Fn1[T, U], /) -> U: + """ + Returns the provided default result (if `Null`), or applies a function to the contained + value (if `Null`). + + Arguments passed to map_or are eagerly evaluated; if you are passing the result of a + function call, it is recommended to use map_or_else, which is lazily evaluated. + + Examples + ======== + .. code-block:: python + + x = Some("foo") + assert x.map_or(42, lambda v: len(v)) == 3 + + x: Option[str] = Null() + assert x.map_or(42, lambda v: len(v)) == 42 + """ match self: case Some(x): return f(x) @@ -179,17 +282,112 @@ def map_or[U](self: Option[T], default: U, f: Fn1[T, U], /) -> U: return default def map_or_else[U](self: Option[T], default: Fn0[U], f: Fn1[T, U], /) -> U: + """ + Computes a default function result (if `Null`), or applies a different function to the + contained value (if `Some`). + + Examples + ======== + .. code-block:: python + + k = 21 + x = Some("foo") + assert x.map_or_else(lambda: 2 * k, lambda v: len(v)) == 3 + + x: Option[str] = Null() + assert x.map_or_else(lambda: 2 * k, lambda v: len(v)) == 42 + """ match self: case Some(x): return f(x) case Null(): return default() + @overload + def and_[U](self: Null[T], optb: Option[U], /) -> Null[U]: ... + @overload + def and_[U](self: Some[T], optb: Some[U], /) -> Some[U]: ... + @overload + def and_[U](self: Some[T], optb: Null[U], /) -> Null[U]: ... + def and_[U](self: Option[T], optb: Option[U], /) -> Option[U]: + """ + Returns `Null` if the option is `Null`, otherwise returns `optb`. + + Arguments passed to and are eagerly evaluated; if you are passing the result of a function + call, it is recommended to use :meth:`and_then`, which is lazily evaluated. + + Examples + ======== + + .. code-block:: python + + x = Some(2) + y: Option[str] = Null() + assert x.and_(y) == Null() + + x: Option[int] = Null() + y = Some("foo") + assert x.and_(y) == Null() + + x = Some(2) + y = Some("foo") + assert x.and_(y) == Some("foo") + + x: Option[int] = Null() + y: Option[str] = Null() + assert x.and_(y) == Null() + """ + match self: + case Null(): + return Null[U]() + case Some(_): + return optb + + @overload + def __and__[U](self: Null[T], optb: Option[U], /) -> Null[U]: ... + @overload + def __and__[U](self: Some[T], optb: Some[U], /) -> Some[U]: ... + @overload + def __and__[U](self: Some[T], optb: Null[U], /) -> Null[U]: ... + def __and__[U](self: Option[T], optb: Option[U], /) -> Option[U]: + return self.and_(optb) + @overload def and_then[U](self: Null[T], f: Fn1[T, U], /) -> Null[U]: ... @overload def and_then[U](self: Some[T], f: Fn1[T, U], /) -> Option[U]: ... def and_then[U](self: Option[T], f: Callable[[T], Option[U]], /) -> Option[U]: + """ + Returns `Null` if the option is `Null` otherwise calls `f` with the wrapped value and returns + the result. + + Some languages call this operation flatmap. + + Examples + ======== + .. code-block:: python + + def sqrt_then_to_string(x: int) -> Option[str]: + if x < 0: + return Null() + return Some(str(x**0.5)) + + + assert Some(2).and_then(sq_then_to_string) == Some(str(4)) + assert Some(-1).and_then(sq_then_to_string) == Null() + assert Null[str]().and_then(sq_then_to_string) == Null() + + Often used to chain fallible operations that may return `Null`. + + .. code-block:: python + + arr_2d = Vec(Vec("A0", "A1"), Vec("B0", "B1")) + item_0_0 = arr_2d.first().and_then(lambda row: row.first()) + assert item_0_0 == Some("A1") + + item_2_0 = arr_2d.get(2).and_then(lambda row: row.get(0)) + assert item_2_0 == Null() + """ match self: case Some(x): return f(x) @@ -197,6 +395,23 @@ def and_then[U](self: Option[T], f: Callable[[T], Option[U]], /) -> Option[U]: return Null[U]() def ok_or[E: Exception](self: Option[T], err: E, /) -> Result[T, E]: + """ + Transforms the `Option[T]` into a `Result[T, E]`, mapping `Some(v)` to `Ok(v)` and `Null` + to `Err(err)`. + + Arguments passed to :meth:`ok_or` are eagerly evaluated; if you are passing the result of a + function call, it is recommended to use :meth:`ok_or_else`, which is lazily evaluated. + + Examples + ======== + .. code-block:: python + + x = Some("foo") + assert x.ok_or(0) == Ok("foo") + + x: Option[str] = Null() + assert x.ok_or(0) == Err(0) + """ match self: case Some(x): return Ok(x) @@ -204,6 +419,20 @@ def ok_or[E: Exception](self: Option[T], err: E, /) -> Result[T, E]: return Err(err) def ok_or_else[E: Exception](self: Option[T], err: Fn0[E], /) -> Result[T, E]: + """ + Transforms the `Option[T]` into a `Result[T, E]`, mapping `Some(v)` to `Ok(v)` and `Null` to + `Err(err())`. + + Examples + ======= + .. code-block:: python + + x = Some("foo") + assert x.ok_or_else(lambda: 0) == Ok("foo") + + x: Option[str] = Null() + assert x.ok_or_else(lambda: 0) == Err(0) + """ match self: case Some(x): return Ok(x) @@ -211,6 +440,18 @@ def ok_or_else[E: Exception](self: Option[T], err: Fn0[E], /) -> Result[T, E]: return Err(err()) def filter(self: Option[T], f: Fn1[T, bool]) -> Option[T]: + """ + Returns `Null` if the option is `Null`, otherwise calls predicate with the wrapped value + and returns: + + - `Some(t)` if predicate returns `True` (where `t` is the wrapped value), and + - `None` if predicate returns `False`. + + This function works similar to :meth:`Iterator.filter()`. + You can imagine the `Option[T]` being an iterator over one or zero elements. + `filter()` lets you decide which elements to keep. + """ + match self: case Some(x) if f(x): return Some(x) @@ -232,6 +473,9 @@ def clone(self: Option[T]) -> Option[T]: @override @classmethod def default(cls: type[Option[T]]) -> Option[T]: + """ + Returns `Null`. + """ return Null() def zip[U](self: Option[T], other: Option[U]) -> Option[tuple[T, U]]: @@ -240,6 +484,17 @@ def zip[U](self: Option[T], other: Option[U]) -> Option[tuple[T, U]]: If `self` is `Some(s)` and `other` is `Some(o)`, this method returns `Some((s, o))`. Otherwise, `Null` is returned. + + Examples + ======== + .. code-block:: python + + x = Some(1) + y = Some("hi") + z = Null[int] + + assert x.zip(y) == Some((1, "hi")) + assert x.zip(z) == Null() """ match (self, other): case (Some(a), Some(b)): @@ -248,6 +503,20 @@ def zip[U](self: Option[T], other: Option[U]) -> Option[tuple[T, U]]: return Null() def transpose[E](self: Option[Result[T, E]]) -> Result[Option[T], E]: + """ + Transposes an `Option` of a `Result` into a `Result` of an `Option`. + + `Null` will be mapped to `Ok(Null())`. `Some(Ok(_))` and `Some(Err(_))` will be mapped to + `Ok(Some(_))` and `Err(_)`. + + Examples + ======== + .. code-block:: python + + x = Result[Option[int], ValueError] = Ok(Some(5)) + y = Option[Result[int], ValueError] = Some(Ok(5)) + assert x == y.transpose() + """ from .result import Err, Ok match self: @@ -266,10 +535,14 @@ def transpose[E](self: Option[Result[T, E]]) -> Result[Option[T], E]: repr=True, ) class Null[T](_Option[T]): + """ + No value. + """ + __slots__ = () # Emulate [`NoneType`]. - def __bool__(self) -> Literal[False]: ... + def __bool__(self) -> False_: ... @final @@ -280,21 +553,69 @@ def __bool__(self) -> Literal[False]: ... frozen=True, ) class Some[T](_Option[T]): + """ + Some value of type `T`. + """ + __match_args__ = ("value",) value: T type Option[T] = Some[T] | Null[T] +""" +Type `Option` represents an optional value: every `Option` is either `Some` and contains a value, +or `Null`, and does not. `Option` types are very common in Rust code, as they have a number of uses: + +- Initial values +- Return values for functions that are not defined over their entire input range (partial functions) +- Return value for otherwise reporting simple errors, where `Null` is returned on error +- Optional class attributes +- Optional function arguments + +Options are commonly paired with pattern matching to query the presence of a value and take action, +always accounting for the `Null` case. + +.. code-block:: python + + def divide(numerator: float, denominator: float) -> Option[float]: + if denominator == 0.0: + return Null() + else: + return Some(numerator / denominator) + + # The return value of the function is an option + result = divide(2.0, 3.0) + + # Pattern match to retrieve the value + match result: + # The division was valid + case Some(x): + print(f"Result {x}") + # The division was invalid + case Null(): + print("Cannot divide by 0") +""" -# Applying [`TypeGuard`] to a `self` parameter is not permitted so -# we have to resort to defining external `is_some` and `is_null` functions -# in order to generate boolean checkers capable of type narrowing. def is_some[T](x: Option[T], /) -> TypeGuard[Some[T]]: + """ + Returns `True` if the option is a `Some` value. + + Since `TypeGuard` does not allow for type-narrowing of the `self` parameter, + this external function is needed to achieve that goal; :meth:`~Option.is_some` + on its own does not suffice. + """ return x.is_some() def is_null[T](x: Option[T], /) -> TypeGuard[Null[T]]: + """ + Returns `True` if the option is a `Null` value. + + Since `TypeGuard` does not allow for type-narrowing of the `self` parameter, + this external function is needed to achieve that goal; :meth:`~Option.is_null` + on its own does not suffice. + """ return x.is_null() diff --git a/serox/vec.py b/serox/vec.py index 011c7c8..68b5184 100644 --- a/serox/vec.py +++ b/serox/vec.py @@ -1,5 +1,6 @@ from __future__ import annotations # noqa: I001 from dataclasses import dataclass +from serox import Range from random import Random as Rng from .common import True_, False_ from typing import ( @@ -8,6 +9,7 @@ Generator, Iterable, Self, + overload, override, ) @@ -148,6 +150,28 @@ def __repr__(self) -> str: def __getitem__(self, index: int, /) -> T: return self.inner[index] + @overload + def get(self, index: Range[Any], /) -> Option[Vec[T]]: ... + @overload + def get(self, index: int, /) -> Option[T]: ... + def get(self, index: int | Range[Any], /) -> Option[T] | Option[Vec[T]]: + """ + Returns an element or sub-vector depending on the type of index. + + - If given a position, returns the element at that position or `Null` if out of bounds. + - If given a range, returns the sub-vector corresponding to that range, or `Null` if out of + bounds. + """ + match index: + case Range(): + if index.contains(self.len()): + return Some(Vec(*self.inner[index.start : index.end])) + return Null() + case int(): + if index >= self.len(): + return Null() + return Some(self.inner[index]) + @override def __len__(self) -> int: return len(self.inner)