Skip to content

Commit 00419a9

Browse files
authored
Add better testing for builder code (#245)
* Add better testing for builder code
1 parent aafe4f5 commit 00419a9

File tree

9 files changed

+709
-296
lines changed

9 files changed

+709
-296
lines changed

expression/core/builder.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,34 @@ def zero(self) -> _TOuter:
3838
raise NotImplementedError("Builder does not implement a zero method")
3939

4040
def delay(self, fn: Callable[[], _TOuter]) -> _TOuter:
41-
"""Default implementation evaluates the given function."""
41+
"""Delay the computation.
42+
43+
In F# computation expressions, delay wraps the entire computation to ensure
44+
it is not evaluated until run. This enables proper sequencing of effects
45+
and lazy evaluation.
46+
47+
Args:
48+
fn: The computation to delay
49+
50+
Returns:
51+
The delayed computation
52+
"""
4253
return fn()
4354

44-
def run(self, xs: _TOuter) -> _TOuter:
45-
"""Default implementation assumes the result is already evaluated."""
46-
return xs
55+
def run(self, computation: _TOuter) -> _TOuter:
56+
"""Run a computation.
57+
58+
Forces evaluation of a delayed computation. In F# computation expressions,
59+
run is called at the end to evaluate the entire computation that was
60+
wrapped in delay.
61+
62+
Args:
63+
computation: The computation to run
64+
65+
Returns:
66+
The evaluated result
67+
"""
68+
return computation
4769

4870
def _send(
4971
self,
@@ -126,6 +148,7 @@ def binder(value: Any) -> _TOuter:
126148
if result is None:
127149
result = self.zero()
128150

151+
# Run the computation at the end
129152
return self.run(result)
130153

131154
return wrapper

expression/core/option.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -177,8 +177,8 @@ def filter(self, predicate: Callable[[_TSourceOut], bool]) -> Option[_TSourceOut
177177
def to_list(self) -> list[_TSourceOut]:
178178
"""Convert option to list."""
179179
match self:
180-
case Option(tag="some", some=some):
181-
return [some]
180+
case Option(tag="some", some=value):
181+
return [value]
182182
case _:
183183
return []
184184

@@ -188,8 +188,8 @@ def to_seq(self) -> Seq[_TSourceOut]:
188188
from expression.collections.seq import Seq
189189

190190
match self:
191-
case Option(tag="some", some=some):
192-
return Seq[_TSourceOut].of(some)
191+
case Option(tag="some", some=value):
192+
return Seq[_TSourceOut].of(value)
193193
case _:
194194
return Seq()
195195

expression/core/typing.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from typing import Any, Protocol, TypeVar, get_origin
66

77

8-
_T = TypeVar("_T")
98
_T_co = TypeVar("_T_co", covariant=True)
109

1110
_Base = TypeVar("_Base")

expression/effect/option.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,88 @@
1313

1414
class OptionBuilder(Builder[_TSource, Option[Any]]):
1515
def bind(self, xs: Option[_TSource], fn: Callable[[_TSource], Option[_TResult]]) -> Option[_TResult]:
16+
"""Bind a function to an option value.
17+
18+
In F# computation expressions, this corresponds to let! and enables
19+
sequencing of computations.
20+
21+
Args:
22+
xs: The option value to bind
23+
fn: The function to apply to the value if Some
24+
25+
Returns:
26+
The result of applying fn to the value if Some, otherwise Nothing
27+
"""
1628
return option.bind(fn)(xs)
1729

1830
def return_(self, x: _TSource) -> Option[_TSource]:
31+
"""Wrap a value in an option.
32+
33+
In F# computation expressions, this corresponds to return and lifts
34+
a value into the option context.
35+
36+
Args:
37+
x: The value to wrap
38+
39+
Returns:
40+
Some containing the value
41+
"""
1942
return Some(x)
2043

2144
def return_from(self, xs: Option[_TSource]) -> Option[_TSource]:
45+
"""Return an option value directly.
46+
47+
In F# computation expressions, this corresponds to return! and allows
48+
returning an already wrapped value.
49+
50+
Args:
51+
xs: The option value to return
52+
53+
Returns:
54+
The option value unchanged
55+
"""
2256
return xs
2357

2458
def combine(self, xs: Option[_TSource], ys: Option[_TSource]) -> Option[_TSource]:
59+
"""Combine two option computations.
60+
61+
In F# computation expressions, this enables sequencing multiple
62+
expressions where we only care about the final result.
63+
64+
Args:
65+
xs: First option computation
66+
ys: Second option computation
67+
68+
Returns:
69+
The second computation if first is Some, otherwise Nothing
70+
"""
2571
return xs.bind(lambda _: ys)
2672

2773
def zero(self) -> Option[_TSource]:
74+
"""Return the zero value for options.
75+
76+
In F# computation expressions, this is used when no value is returned,
77+
corresponding to None in F#.
78+
79+
Returns:
80+
Nothing
81+
"""
2882
return Nothing
2983

84+
def delay(self, fn: Callable[[], Option[_TSource]]) -> Option[_TSource]:
85+
"""Delay an option computation.
86+
87+
In F# computation expressions, delay ensures proper sequencing of effects
88+
by controlling when computations are evaluated.
89+
90+
Args:
91+
fn: The computation to delay
92+
93+
Returns:
94+
The result of evaluating the computation
95+
"""
96+
return fn()
97+
3098
def __call__(
3199
self, # Ignored self parameter
32100
fn: Callable[

tests/test_option.py

Lines changed: 10 additions & 149 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
from collections.abc import Callable, Generator
1+
from collections.abc import Callable
22
from typing import Any, Annotated
33

4-
import pytest
54
from hypothesis import given # type: ignore
65
from hypothesis import strategies as st
76
from pydantic import BaseModel, Field, GetCoreSchemaHandler
@@ -14,14 +13,12 @@
1413
Option,
1514
Result,
1615
Some,
17-
effect,
1816
option,
1917
pipe,
2018
pipe2,
2119
)
2220
from expression.core.option import Nothing, Option, Some
2321
from expression.extra.option import pipeline
24-
from tests.utils import CustomException
2522

2623

2724
def test_option_some():
@@ -53,11 +50,19 @@ def test_option_some_match_fluent():
5350
case _:
5451
assert False
5552

53+
def test_option_some_iterate_to_list():
54+
xs = Some(42)
55+
56+
for x in xs.to_list():
57+
assert x == 42
58+
break
59+
else:
60+
assert False
5661

5762
def test_option_some_iterate():
5863
xs = Some(42)
5964

60-
for x in option.to_list(xs):
65+
for x in xs:
6166
assert x == 42
6267
break
6368
else:
@@ -425,150 +430,6 @@ def test_option_to_optional_nothing():
425430
assert xs is None
426431

427432

428-
def test_option_builder_zero():
429-
@effect.option[int]()
430-
def fn():
431-
while False:
432-
yield
433-
434-
xs = fn()
435-
assert xs is Nothing
436-
437-
438-
def test_option_builder_yield_value():
439-
@effect.option[int]()
440-
def fn():
441-
yield 42
442-
443-
xs = fn()
444-
match xs:
445-
case Option(tag="some", some=value):
446-
assert value == 42
447-
case _:
448-
assert False
449-
450-
451-
def test_option_builder_yield_value_async():
452-
@effect.option[int]()
453-
def fn():
454-
yield 42
455-
456-
xs = fn()
457-
match xs:
458-
case Option(tag="some", some=value):
459-
assert value == 42
460-
case _:
461-
assert False
462-
463-
464-
def test_option_builder_yield_some_wrapped():
465-
@effect.option[Option[int]]()
466-
def fn() -> Generator[Option[int], Option[int], Option[int]]:
467-
x: Option[int] = yield Some(42)
468-
return x
469-
470-
xs = fn()
471-
match xs:
472-
case Option(tag="some", some=value):
473-
assert value == Some(42)
474-
case _:
475-
assert False
476-
477-
478-
def test_option_builder_return_some():
479-
@effect.option[int]()
480-
def fn() -> Generator[int, int, int]:
481-
x: int = yield 42
482-
return x
483-
484-
xs = fn()
485-
match xs:
486-
case Option(tag="some", some=value):
487-
assert value == 42
488-
case _:
489-
assert False
490-
491-
492-
def test_option_builder_return_nothing_wrapped():
493-
@effect.option[Option[int]]()
494-
def fn():
495-
return Nothing
496-
yield
497-
498-
xs = fn()
499-
match xs:
500-
case Option(tag="some", some=value):
501-
assert value is Nothing
502-
case _:
503-
assert False
504-
505-
506-
def test_option_builder_yield_from_some():
507-
@effect.option[int]()
508-
def fn() -> Generator[int, int, int]:
509-
x = yield from Some(42)
510-
return x + 1
511-
512-
xs = fn()
513-
match xs:
514-
case Option(tag="some", some=value):
515-
assert value == 43
516-
case _:
517-
assert False
518-
519-
520-
def test_option_builder_yield_from_none():
521-
@effect.option[int]()
522-
def fn() -> Generator[int, int, int]:
523-
x: int
524-
x = yield from Nothing
525-
return x
526-
527-
xs = fn()
528-
assert xs is Nothing
529-
530-
531-
def test_option_builder_multiple_some():
532-
@effect.option[int]()
533-
def fn() -> Generator[int, int, int]:
534-
x: int = yield 42
535-
y = yield from Some(43)
536-
537-
return x + y
538-
539-
xs = fn()
540-
match xs:
541-
case Option(tag="some", some=value):
542-
assert value == 85
543-
case _:
544-
assert False
545-
546-
547-
def test_option_builder_none_short_circuits():
548-
@effect.option[int]()
549-
def fn() -> Generator[int, int, int]:
550-
x: int = yield from Nothing
551-
y = yield from Some(43)
552-
553-
return x + y
554-
555-
xs = fn()
556-
assert xs is Nothing
557-
558-
559-
def test_option_builder_throws():
560-
error = "do'h"
561-
562-
@effect.option()
563-
def fn():
564-
raise CustomException(error)
565-
yield
566-
567-
with pytest.raises(CustomException) as ex:
568-
fn()
569-
570-
assert ex.value.message == error
571-
572433

573434
def test_pipeline_none():
574435
hn = pipeline()

0 commit comments

Comments
 (0)