Add in some doctests

This commit is contained in:
Emi Simpson 2023-02-10 16:33:54 -05:00
parent a5446551c2
commit 1c3558828d
Signed by: Emi
GPG Key ID: A12F2C2FFDC3D847
1 changed files with 306 additions and 18 deletions

View File

@ -21,6 +21,14 @@ def c(f2: Callable[[B], C], f1: Callable[P, B]) -> Callable[P, C]:
This can also be thought of as mapping the output of a function using the first This can also be thought of as mapping the output of a function using the first
parameter as a mapper function. parameter as a mapper function.
>>> double = lambda x: x + x
>>> succ = lambda x: x + 1
>>> c(double, succ)(1)
4
>>> c(succ, double)(1)
3
""" """
@wraps(f1) @wraps(f1)
def inner(*args: P.args, **kwargs: P.kwargs) -> C: def inner(*args: P.args, **kwargs: P.kwargs) -> C:
@ -34,6 +42,13 @@ def flip(f: Callable[P1, Callable[P2, C]]) -> Callable[P2, Callable[P1, C]]:
This only works with curried functions, so apply `cur2` or `cur3` before applying This only works with curried functions, so apply `cur2` or `cur3` before applying
`flip` if the arguments you want to flip are not curried. `flip` if the arguments you want to flip are not curried.
>>> pair = lambda x: lambda y: (x, y)
>>> pair(1)(2)
(1, 2)
>>> flip(pair)(1)(2)
(2, 1)
""" """
@wraps(f) @wraps(f)
def inner1(*args2: P2.args, **kwargs2: P2.kwargs) -> Callable[P1, C]: def inner1(*args2: P2.args, **kwargs2: P2.kwargs) -> Callable[P1, C]:
@ -45,7 +60,15 @@ def flip(f: Callable[P1, Callable[P2, C]]) -> Callable[P2, Callable[P1, C]]:
# Identity function! # Identity function!
def ident(x: A) -> A: def ident(x: A) -> A:
"The identity function. Output is identical to input." """
The identity function. Output is identical to input.
>>> ident(3)
3
>>> ident(('hello', 8))
('hello', 8)
"""
return x return x
def replace(replace_with: A) -> Callable[..., A]: def replace(replace_with: A) -> Callable[..., A]:
@ -55,6 +78,16 @@ def replace(replace_with: A) -> Callable[..., A]:
The argument `replace_with` is the value the the returned function should always The argument `replace_with` is the value the the returned function should always
return. The returned function can be used as if having any arity, and will always return. The returned function can be used as if having any arity, and will always
return the same value originally passed to `replace`. return the same value originally passed to `replace`.
>>> always_seven = replace(7)
>>> always_seven(2)
7
>>> always_seven('hello', 'world!')
7
>>> replace('uwu')('NYA!')
'uwu'
""" """
def constant(*args: Any, **kwargs: Any) -> A: def constant(*args: Any, **kwargs: Any) -> A:
"Always return a constant value, typically the one passed to `replace`" "Always return a constant value, typically the one passed to `replace`"
@ -77,6 +110,21 @@ def cur2(f: Callable[Concatenate[A, P], C]) -> Callable[[A], Callable[P, C]]:
becomes the function A -> (B, C, kw=D) -> E after being curried using this function. becomes the function A -> (B, C, kw=D) -> E after being curried using this function.
Can also be used as an annotation. Can also be used as an annotation.
>>> @cur2
... def pair(x, y):
... return (x, y)
...
>>> pair(1)(2)
(1, 2)
>>> alternate_pair = lambda x, y: (x, y)
>>> cur2(alternate_pair)(1)(2)
(1, 2)
>>> threeple = lambda x, y, z: (x, y, z)
>>> cur2(threeple)(1)(2, 3)
(1, 2, 3)
""" """
return p(p, f) #type:ignore return p(p, f) #type:ignore
def cur3(f: Callable[Concatenate[A, B, P], D]) -> Callable[[A], Callable[[B], Callable[P, D]]]: def cur3(f: Callable[Concatenate[A, B, P], D]) -> Callable[[A], Callable[[B], Callable[P, D]]]:
@ -84,9 +132,14 @@ def cur3(f: Callable[Concatenate[A, B, P], D]) -> Callable[[A], Callable[[B], Ca
Perform three-argument currying. Perform three-argument currying.
See `cur2` for an explaination of how this works. See `cur2` for an explaination of how this works.
>>> threeple = lambda x, y, z: (x, y, z)
>>> cur3(threeple)(1)(2)(3)
(1, 2, 3)
""" """
return p(p, p, f) #type:ignore return p(p, p, f) #type:ignore
# Curried versions of map & filter with stricter types # Curried versions of map & filter with stricter types
def p_map(f: Callable[[A], B]) -> Callable[[Sequence[A]], Sequence[B]]: def p_map(f: Callable[[A], B]) -> Callable[[Sequence[A]], Sequence[B]]:
"A curried version of the built in `map` function" "A curried version of the built in `map` function"
@ -99,7 +152,16 @@ def p_filter(f: Callable[[A], bool]) -> Callable[[Sequence[A]], Sequence[A]]:
# Normal Accessors # Normal Accessors
@cur2 @cur2
def indx(i: int, s: Sequence[A]) -> A: def indx(i: int, s: Sequence[A]) -> A:
"A curried version of the getitem function" """
A curried version of the getitem function
>>> get_second = indx(1)
>>> get_second(('a', 'b'))
'b'
>>> get_second([1, 2, 3, 4])
2
"""
return s[i] return s[i]
fst = indx(0) fst = indx(0)
@ -121,8 +183,17 @@ class SemEdComb:
ugly. This class abuses python's ability to override the property accessor (.) in ugly. This class abuses python's ability to override the property accessor (.) in
order to approximate semantic editor combinators. order to approximate semantic editor combinators.
Using this class, you can write `result.first.pmap(reverse)(myList)` to perform an >>> my_func = lambda x: ('abc' + x, 'def')
effect equivalent to `result . first reverse myList` in haskell. >>> my_func('hi')
('abchi', 'def')
>>> altered_func = result.first.map(str.upper, my_func)
>>> altered_func('hi')
('ABCHI', 'def')
>>> other_altered_func = arg.map(str.upper, my_func)
>>> other_altered_func('hello')
('abcHELLO', 'def')
Unfortunately, due to limitations of Python's type system, this class is largely Unfortunately, due to limitations of Python's type system, this class is largely
untyped. untyped.
@ -185,35 +256,135 @@ class SemEdComb:
@property @property
def result(self) -> 'SemEdComb': def result(self) -> 'SemEdComb':
"Map the result of a function" """
Map the result of a function
>>> my_func = lambda s: s + ' backwards is ' + s[::-1]
>>> my_func('hello')
'hello backwards is olleh'
>>> altered_func = result.map(str.upper, my_func)
>>> altered_func('hello')
'HELLO BACKWARDS IS OLLEH'
Can be chained in order to work with curried functions as well. That is, the
result of a two argument curried function is the result of the result of that
function.
>>> curried_pair = lambda x: lambda y: (x, y)
>>> altered_pair = result.result.second.map(str.upper, curried_pair)
>>> altered_pair('hello')('world')
('hello', 'WORLD')
"""
return self._c(SemEdComb.RESULT, '.result') return self._c(SemEdComb.RESULT, '.result')
@property @property
def arg(self) -> 'SemEdComb': def arg(self) -> 'SemEdComb':
"Map the argument of a function" """
Map the argument of a function
>>> my_func = lambda s: s + ' backwards is ' + s[::-1]
>>> my_func('hello')
'hello backwards is olleh'
>>> altered_func = arg.map(str.upper, my_func)
>>> altered_func('hello')
'HELLO backwards is OLLEH'
Can be combined with `.result` to work with curried functions.
>>> curried_pair = lambda x: lambda y: (x, y)
>>> altered_pair = result.arg.map(str.upper, curried_pair)
>>> altered_pair('hello')('world')
('hello', 'WORLD')
"""
return self._c(SemEdComb.ARG, '.arg') return self._c(SemEdComb.ARG, '.arg')
@property @property
def all(self) -> 'SemEdComb': def all(self) -> 'SemEdComb':
"Map every element of a list" """
Map every element of a sequence
To use this as the base of a chain of SECs, write "all_", since "all" by itself
refers to the builtin python function, which is different.
Note that this returns an iterator, not a sequence, even if the thing being mapped
was a sequence or a list.
>>> list(all_.map(lambda x: x + x, [1, 2, 3]))
[2, 4, 6]
>>> my_func = lambda s: [s] * s
>>> my_func(3)
[3, 3, 3]
>>> altered_func = result.all.map(lambda x: x + x, my_func)
>>> list(altered_func(3))
[6, 6, 6]
"""
return self._c(SemEdComb.ALL, '.all') return self._c(SemEdComb.ALL, '.all')
def index(self, i) -> 'SemEdComb': def index(self, i) -> 'SemEdComb':
"Map the ith element of a mutable sequence" """
Map the ith element of a mutable sequence
>>> index(1).map(lambda x: x + x, [1, 2, 3])
[1, 4, 3]
>>> my_func = lambda s: [s] * s
>>> my_func(3)
[3, 3, 3]
>>> altered_func = result.index(1).map(lambda x: x + x, my_func)
>>> list(altered_func(3))
[3, 6, 3]
"""
return self._c(SemEdComb.INDEX(i), f'.index({i})') return self._c(SemEdComb.INDEX(i), f'.index({i})')
def index_tup(self, i) -> 'SemEdComb': def index_tup(self, i) -> 'SemEdComb':
"Map the ith element of an immutable sequence" """
Map the ith element of an immutable sequence.
>>> index_tup(2).map(lambda x: x + x, (1, 2, 3, 4))
(1, 2, 6, 4)
See Also: `index`
For a more optimized version of this method specialized to two-tuples, see `first`
and `second`
"""
return self._c(SemEdComb.INDEX_TUP(i), f'.index_tup({i})') return self._c(SemEdComb.INDEX_TUP(i), f'.index_tup({i})')
@property @property
def first(self) -> 'SemEdComb': def first(self) -> 'SemEdComb':
"Map the first element of a two-tuple" """
Map the first element of a two-tuple
>>> first.map(lambda x: x+x, (1, 2))
(2, 2)
Doesn't work for threeples and fourples. If this is the behaviour you need, try
`index_tup`
>>> first.map(lambda x: x+x, (1, 2, 3))
(2, 2)
"""
return self._c(SemEdComb.FIRST, f'.first') return self._c(SemEdComb.FIRST, f'.first')
@property @property
def second(self) -> 'SemEdComb': def second(self) -> 'SemEdComb':
"Map the second element of a two-tuple" """
Map the second element of a two-tuple
>>> second.map(lambda x: x+x, (1, 2))
(1, 4)
As with `first`, this doesn't work with threeples, fourples, and moreples.
>>> second.map(lambda x: x+x, (1, 2, 3))
(1, 4)
"""
return self._c(SemEdComb.SECOND, f'.second') return self._c(SemEdComb.SECOND, f'.second')
def __repr__(self): def __repr__(self):
@ -225,6 +396,15 @@ class SemEdComb:
The name is short for partial map. The name is short for partial map.
>>> my_func = lambda s1: lambda s2: f"You entered {s1} and the pair {s2}"
>>> my_func(1)(('hello', 'world'))
"You entered 1 and the pair ('hello', 'world')"
>>> mapper = result.arg.first.pmap(str.upper)
>>> altered_func = mapper(my_func)
>>> altered_func(1)(('hello', 'world'))
"You entered 1 and the pair ('HELLO', 'world')"
See also: `map` See also: `map`
""" """
return SemEdComb.Inner(self.f(mapper), self.name) return SemEdComb.Inner(self.f(mapper), self.name)
@ -277,6 +457,15 @@ def tco_rec(f: Callable[P, Recur[P] | Return[B]]) -> Callable[P, B]:
instance of `Return`. instance of `Return`.
The function will be transformed by `tco_rec` to look as if it is a normal function. The function will be transformed by `tco_rec` to look as if it is a normal function.
>>> @tco_rec
... def factorial(n, coefficient = 1):
... if n > 1:
... return Recur(n - 1, coefficient * n)
... else:
... return Return(coefficient)
>>> factorial(4)
24
""" """
@wraps(f) @wraps(f)
def tco_loop(*args: P.args, **kwargs: P.kwargs) -> B: def tco_loop(*args: P.args, **kwargs: P.kwargs) -> B:
@ -297,6 +486,8 @@ class Some(Generic[A]):
Component of `Option` and counterpart of `None` Component of `Option` and counterpart of `None`
""" """
val: A val: A
def __repr__(self) -> str:
return f'Some({self.val!r})'
Option = Some[A] | None Option = Some[A] | None
"An Option datatype, aka Maybe" "An Option datatype, aka Maybe"
@ -304,6 +495,12 @@ Option = Some[A] | None
def map_opt(f: Callable[[A], B], o: Option[A]) -> Option[B]: def map_opt(f: Callable[[A], B], o: Option[A]) -> Option[B]:
""" """
Map the contents of an optional data type. Has no effect on `None` Map the contents of an optional data type. Has no effect on `None`
>>> map_opt(str.upper, Some('hello'))
Some('HELLO')
>>> map_opt(str.upper, None) is None
True
""" """
match o: match o:
case Some(val): case Some(val):
@ -311,14 +508,37 @@ def map_opt(f: Callable[[A], B], o: Option[A]) -> Option[B]:
case none: case none:
return none return none
def bind_opt(f: Callable[[A], Option[B]], o: Option[A]) -> Option[B]: def bind_opt(f: Callable[[A], Option[B]], o: Option[A]) -> Option[B]:
"wow! monads! (aka 'and_then')" """
wow! monads! (aka 'and_then')
>>> halve = lambda n: Some(n//2) if n % 2 == 0 else None
>>> [halve(2), halve(3)]
[Some(1), None]
>>> bind_opt(halve, Some(4))
Some(2)
>>> bind_opt(halve, Some(5)) is None
True
>>> bind_opt(halve, None) is None
True
"""
match o: match o:
case Some(val): case Some(val):
return f(val) return f(val)
case none: case none:
return none return none
def note(e: B, o: Option[A]) -> 'Result[A, B]': def note(e: B, o: Option[A]) -> 'Result[A, B]':
"Convert an `Option` to a `Result` by attaching an error to the `None` variants" """
Convert an `Option` to a `Result` by attaching an error to the `None` variants
>>> note('woops!', Some(1))
Ok(1)
>>> note('woops!', None)
Err('woops!')
"""
match o: match o:
case Some(val): case Some(val):
return Ok(val) return Ok(val)
@ -334,6 +554,8 @@ class Ok(Generic[A]):
Component of `Result` and counterpart of `Err` Component of `Result` and counterpart of `Err`
""" """
val: A val: A
def __repr__(self) -> str:
return f'Ok({self.val!r})'
@dataclass(frozen=True) @dataclass(frozen=True)
class Err(Generic[B]): class Err(Generic[B]):
""" """
@ -342,38 +564,80 @@ class Err(Generic[B]):
Component of `Result` and counterpart of `Ok` Component of `Result` and counterpart of `Ok`
""" """
err: B err: B
def __repr__(self) -> str:
return f'Err({self.err!r})'
def __bool__(self): def __bool__(self):
return False return False
Result = Ok[A] | Err[B] Result = Ok[A] | Err[B]
"A Result datatype, aka Either" "A Result datatype, aka Either"
def map_res(f: Callable[[A], C], r: Result[A, B]) -> Result[C, B]: def map_res(f: Callable[[A], C], r: Result[A, B]) -> Result[C, B]:
"Map the success value of a result" """
Map the success value of a result
>>> map_res(str.upper, Ok('hai!'))
Ok('HAI!')
>>> map_res(str.upper, Err('oh noes'))
Err('oh noes')
"""
match r: match r:
case Ok(val): case Ok(val):
return Ok(f(val)) return Ok(f(val))
case not_okay: case not_okay:
return not_okay return not_okay
def bind_res(f: Callable[[A], Result[C, B]], r: Result[A, B]) -> Result[C, B]: def bind_res(f: Callable[[A], Result[C, B]], r: Result[A, B]) -> Result[C, B]:
"Perform an fallible operation for successful results." """
Perform an fallible operation for successful results.
>>> halve = lambda n: Ok(n//2) if n % 2 == 0 else Err(f'{n} is not divisible by 2')
>>> [halve(2), halve(3)]
[Ok(1), Err('3 is not divisible by 2')]
>>> bind_res(halve, Ok(4))
Ok(2)
>>> bind_res(halve, Ok(5))
Err('5 is not divisible by 2')
>>> bind_res(halve, Err('not okay in the 1st place'))
Err('not okay in the 1st place')
"""
match r: match r:
case Ok(val): case Ok(val):
return f(val) return f(val)
case not_okay: case not_okay:
return not_okay return not_okay
def map_err(f: Callable[[B], C], r: Result[A, B]) -> Result[A, C]: def map_err(f: Callable[[B], C], r: Result[A, B]) -> Result[A, C]:
"Map the error value of a result" """
Map the error value of a result
>>> map_err(str.upper, Ok('hai!'))
Ok('hai!')
>>> map_err(str.upper, Err('oh noes'))
Err('OH NOES')
"""
match r: match r:
case Err(e): case Err(e):
return Err(f(e)) return Err(f(e))
case oki_doke: case oki_doke:
return oki_doke return oki_doke
def hush(r: Result[A, Any]) -> Option[A]: def hush(r: Result[A, Any]) -> Option[A]:
"Convert a `Result` to an `Option` by converting any errors to `None`" """
Convert a `Result` to an `Option` by converting any errors to `None`
>>> hush(Ok('hai!'))
Some('hai!')
>>> hush(Err('oh noes')) is None
True
"""
match r: match r:
case Ok(val): case Ok(val):
return Some(val) return Some(val)
case not_okay: case not_okay:
return None return None
def try_(handle: Callable[[Exception], B], f: Callable[P, A], *args: P.args, **kwargs: P.kwargs) -> Result[A, B]: def try_(handle: Callable[[Exception], B], f: Callable[P, A], *args: P.args, **kwargs: P.kwargs) -> Result[A, B]:
""" """
Try-catch in a function! Attempt to perform and operation, and `Err` on failure Try-catch in a function! Attempt to perform and operation, and `Err` on failure
@ -387,11 +651,18 @@ def try_(handle: Callable[[Exception], B], f: Callable[P, A], *args: P.args, **k
will be passed to `handle`. will be passed to `handle`.
args - Will be passed to `f` when it is called. args - Will be passed to `f` when it is called.
kwargs - Will be passed to `f` when it is called. kwargs - Will be passed to `f` when it is called.
>>> try_(ident, int, '3')
Ok(3)
>>> try_(ident, int, 'three')
Err(ValueError("invalid literal for int() with base 10: 'three'"))
""" """
try: try:
return Ok(f(*args, **kwargs)) return Ok(f(*args, **kwargs))
except Exception as e: except Exception as e:
return Err(handle(e)) return Err(handle(e))
def unwrap_r(r: Result[A, Any]) -> A: def unwrap_r(r: Result[A, Any]) -> A:
""" """
Assert that a `Result` is `Ok` and return it's value. Assert that a `Result` is `Ok` and return it's value.
@ -400,6 +671,13 @@ def unwrap_r(r: Result[A, Any]) -> A:
`AssertionError` - The result was NOT okay. The `AssertionError` will have two `AssertionError` - The result was NOT okay. The `AssertionError` will have two
arguments: The first is a string to make it more obvious what happened. The arguments: The first is a string to make it more obvious what happened. The
second is the error that was stored in the `Err`. second is the error that was stored in the `Err`.
>>> unwrap_r(Ok('hai!'))
'hai!'
>>> unwrap_r(Err('oh noes')) is None #doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
AssertionError: ('Tried to unwrap an error: ', 'oh noes')
""" """
match r: match r:
case Ok(val): case Ok(val):
@ -414,10 +692,20 @@ def sequence(s: Sequence[Result[A, B]]) -> Result[Iterator[A], B]:
and contains a list of all the unwrapped values of the `Ok`s. If there are any and contains a list of all the unwrapped values of the `Ok`s. If there are any
errors, proccessing of the sequence is immediately stopped, and the first error errors, proccessing of the sequence is immediately stopped, and the first error
encountered is returned. encountered is returned.
>>> map_res(list, sequence([Ok(1), Ok(2), Ok(3)]))
Ok([1, 2, 3])
>>> sequence([Ok(1), Err('Oops!'), Err('Aw man!')])
Err('Oops!')
""" """
if all(s): if all(s):
return Ok(map(unwrap_r, s)) return Ok(map(unwrap_r, s))
else: else:
o = next(filter(not_, s)) o = next(filter(not_, s))
assert isinstance(o, Err) assert isinstance(o, Err)
return o return o
if __name__ == '__main__':
import doctest
doctest.testmod()