Skip to content

Commit a39d985

Browse files
authored
Builder improvements (#246)
* Add better testing for builder code * Fixes for seq builder * Builder refactor
1 parent 00419a9 commit a39d985

File tree

8 files changed

+466
-106
lines changed

8 files changed

+466
-106
lines changed

expression/collections/seq.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ def delay(generator: Callable[[], Iterable[_TSource]]) -> Iterable[_TSource]:
142142
return delay(generator)
143143

144144
@staticmethod
145-
def empty() -> Seq[Any]:
145+
def empty() -> Seq[_TSource]:
146146
"""Returns empty sequence."""
147147
return Seq()
148148

@@ -363,7 +363,8 @@ def zip(self, other: Iterable[_TResult]) -> Iterable[tuple[_TSource, _TResult]]:
363363

364364
def __iter__(self) -> Iterator[_TSource]:
365365
"""Return iterator for sequence."""
366-
return builtins.iter(self._value)
366+
# Make sure we return a proper generator that can handle send, throw, and close
367+
return (x for x in self._value)
367368

368369
def __repr__(self) -> str:
369370
result = "["
@@ -397,7 +398,8 @@ def __init__(self, gen: Callable[[], Iterable[_TSource]]) -> None:
397398

398399
def __iter__(self) -> Iterator[_TSource]:
399400
xs = self.gen()
400-
return builtins.iter(xs)
401+
# Make sure we return a proper generator that can handle send, throw, and close
402+
return (x for x in xs)
401403

402404

403405
def append(

expression/core/builder.py

Lines changed: 58 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -8,147 +8,118 @@
88
from .error import EffectError
99

1010

11-
_TInner = TypeVar("_TInner")
12-
_TOuter = TypeVar("_TOuter")
11+
_T = TypeVar("_T") # for value type
12+
_M = TypeVar("_M") # for monadic type
1313
_P = ParamSpec("_P")
1414

1515

16-
class Builder(Generic[_TInner, _TOuter], ABC):
16+
class BuilderState(Generic[_T]):
17+
"""Encapsulates the state of a builder computation."""
18+
19+
def __init__(self):
20+
self.is_done = False
21+
22+
23+
class Builder(Generic[_T, _M], ABC): # Corrected Generic definition
1724
"""Effect builder."""
1825

19-
def bind(self, xs: _TOuter, fn: Callable[[Any], _TOuter]) -> _TOuter:
20-
raise NotImplementedError("Builder does not implement a bind method")
26+
# Required methods
27+
def bind(self, xs: _M, fn: Callable[[_T], _M]) -> _M: # Use concrete types for Callable input and output
28+
raise NotImplementedError("Builder does not implement a `bind` method")
2129

22-
def return_(self, x: _TInner) -> _TOuter:
23-
raise NotImplementedError("Builder does not implement a return method")
30+
def return_(self, x: _T) -> _M:
31+
raise NotImplementedError("Builder does not implement a `return` method")
2432

25-
def return_from(self, xs: _TOuter) -> _TOuter:
26-
raise NotImplementedError("Builder does not implement a return from method")
33+
def return_from(self, xs: _M) -> _M:
34+
raise NotImplementedError("Builder does not implement a `return` from method")
2735

28-
def combine(self, xs: _TOuter, ys: _TOuter) -> _TOuter:
36+
def combine(self, xs: _M, ys: _M) -> _M:
2937
"""Used for combining multiple statements in the effect."""
30-
raise NotImplementedError("Builder does not implement a combine method")
38+
raise NotImplementedError("Builder does not implement a `combine` method")
3139

32-
def zero(self) -> _TOuter:
40+
def zero(self) -> _M:
3341
"""Zero effect.
3442
3543
Called if the effect raises StopIteration without a value, i.e
3644
returns None.
3745
"""
38-
raise NotImplementedError("Builder does not implement a zero method")
46+
raise NotImplementedError("Builder does not implement a `zero` method")
3947

40-
def delay(self, fn: Callable[[], _TOuter]) -> _TOuter:
48+
# Optional methods for control flow
49+
def delay(self, fn: Callable[[], _M]) -> _M:
4150
"""Delay the computation.
4251
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+
Default implementation is to return the result of the function.
5253
"""
5354
return fn()
5455

55-
def run(self, computation: _TOuter) -> _TOuter:
56+
def run(self, computation: _M) -> _M:
5657
"""Run a computation.
5758
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
59+
Default implementation is to return the computation as is.
6760
"""
6861
return computation
6962

63+
# Internal implementation
7064
def _send(
7165
self,
7266
gen: Generator[Any, Any, Any],
73-
done: list[bool],
74-
value: _TInner | None = None,
75-
) -> _TOuter:
67+
state: BuilderState[_T], # Use BuilderState
68+
value: _T,
69+
) -> _M:
7670
try:
7771
yielded = gen.send(value)
7872
return self.return_(yielded)
7973
except EffectError as error:
80-
# Effect errors (Nothing, Error, etc) short circuits the processing so we
81-
# set `done` to `True` here.
82-
done.append(True)
83-
# get value from exception
84-
value = error.args[0]
85-
return self.return_from(cast("_TOuter", value))
74+
# Effect errors (Nothing, Error, etc) short circuits
75+
state.is_done = True
76+
return self.return_from(cast("_M", error.args[0]))
8677
except StopIteration as ex:
87-
done.append(True)
78+
state.is_done = True
79+
8880
# Return of a value in the generator produces StopIteration with a value
8981
if ex.value is not None:
9082
return self.return_(ex.value)
91-
raise
83+
84+
raise # Raise StopIteration with no value
85+
9286
except RuntimeError:
93-
done.append(True)
94-
raise StopIteration
87+
state.is_done = True
88+
return self.zero() # Return zero() to handle generator runtime errors instead of raising StopIteration
9589

9690
def __call__(
9791
self,
9892
fn: Callable[
9993
_P,
100-
Generator[_TInner | None, _TInner, _TInner | None] | Generator[_TInner | None, None, _TInner | None],
94+
Generator[_T | None, _T, _T | None] | Generator[_T | None, None, _T | None],
10195
],
102-
) -> Callable[_P, _TOuter]:
103-
"""Option builder.
104-
105-
Enables the use of computational expressions using coroutines.
106-
Thus inside the coroutine the keywords `yield` and `yield from`
107-
reassembles `yield` and `yield!` from F#.
108-
109-
Args:
110-
fn: A function that contains a computational expression and
111-
returns either a coroutine, generator or an option.
112-
113-
Returns:
114-
A `builder` function that can wrap coroutines into builders.
115-
"""
96+
) -> Callable[_P, _M]:
97+
"""The builder decorator."""
11698

11799
@wraps(fn)
118-
def wrapper(*args: _P.args, **kw: _P.kwargs) -> _TOuter:
100+
def wrapper(*args: _P.args, **kw: _P.kwargs) -> _M:
119101
gen = fn(*args, **kw)
120-
done: list[bool] = []
121-
122-
result: _TOuter | None = None
102+
state = BuilderState[_T]() # Initialize BuilderState
103+
result: _M = self.zero() # Initialize result
104+
value: _M
123105

124-
def binder(value: Any) -> _TOuter:
125-
ret = self._send(gen, done, value)
126-
127-
# Delay every result except the first
128-
if result is not None:
129-
return self.delay(lambda: ret)
130-
return ret
106+
def binder(value: Any) -> _M:
107+
ret = self._send(gen, state, value) # Pass state to _send
108+
return self.delay(lambda: ret) # Delay every bind call
131109

132110
try:
133-
result = self._send(gen, done)
111+
# Initialize co-routine with None to start the generator and get the
112+
# first value
113+
result = value = binder(None)
134114

135-
while not done:
136-
cont = self.bind(result, binder)
115+
while not state.is_done: # Loop until coroutine is exhausted
116+
value: _M = self.bind(value, binder) # Send value to coroutine
117+
result = self.combine(result, value) # Combine previous result with new value
137118

138-
# Combine every result except the first
139-
if result is None:
140-
result = cont
141-
else:
142-
result = self.combine(result, cont)
143119
except StopIteration:
120+
# This will happens if the generator exits by returning None
144121
pass
145122

146-
# If anything returns `None` (i.e raises StopIteration without a value) then
147-
# we expect the effect to have a zero method implemented.
148-
if result is None:
149-
result = self.zero()
150-
151-
# Run the computation at the end
152-
return self.run(result)
123+
return self.run(result) # Run the result
153124

154125
return wrapper

expression/effect/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
from .option import OptionBuilder as option
44
from .result import ResultBuilder as result
55
from .result import TryBuilder as try_
6-
from .seq import SeqBuilder as seq
6+
from .seq import SeqBuilder as seq_builder
7+
8+
9+
seq = seq_builder
710

811

912
__all__ = ["option", "result", "seq", "try_"]

expression/effect/result.py

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,21 +16,75 @@ class ResultBuilder(Builder[_TSource, Result[Any, _TError]]):
1616
def bind(
1717
self,
1818
xs: Result[_TSource, _TError],
19-
fn: Callable[[_TSource], Result[_TResult, _TError]],
19+
fn: Callable[[Any], Result[_TResult, _TError]],
2020
) -> Result[_TResult, _TError]:
21+
"""Bind a function to a result value.
22+
23+
In F# computation expressions, this corresponds to ``let!`` and enables
24+
sequencing of computations.
25+
26+
Args:
27+
xs: The result value to bind
28+
fn: The function to apply to the value if Ok
29+
30+
Returns:
31+
The result of applying fn to the value if Ok, otherwise Error
32+
"""
2133
return pipe(xs, result.bind(fn))
2234

23-
def return_(self, x: _TSource) -> Result[_TSource, _TError]:
35+
def return_(self, x: _TSource) -> Result[_TSource, _TError]: # Use Any for return_ type
36+
"""Wrap a value in a result.
37+
38+
In F# computation expressions, this corresponds to ``return`` and lifts
39+
a value into the result context.
40+
41+
Args:
42+
x: The value to wrap
43+
44+
Returns:
45+
Ok containing the value
46+
"""
2447
return Ok(x)
2548

2649
def return_from(self, xs: Result[_TSource, _TError]) -> Result[_TSource, _TError]:
50+
"""Return a result value directly.
51+
52+
In F# computation expressions, this corresponds to ``return!`` and allows
53+
returning an already wrapped value.
54+
55+
Args:
56+
xs: The result value to return
57+
58+
Returns:
59+
The result value unchanged
60+
"""
2761
return xs
2862

2963
def combine(self, xs: Result[_TSource, _TError], ys: Result[_TSource, _TError]) -> Result[_TSource, _TError]:
64+
"""Combine two result computations.
65+
66+
In F# computation expressions, this enables sequencing multiple
67+
expressions where we only care about the final result.
68+
69+
Args:
70+
xs: First result computation
71+
ys: Second result computation
72+
73+
Returns:
74+
The second computation if first is Ok, otherwise Error
75+
"""
3076
return xs.bind(lambda _: ys)
3177

32-
def zero(self) -> Result[_TSource, _TError]:
33-
raise NotImplementedError
78+
def zero(self) -> Result[Any, _TError]: # Use Any for zero return type
79+
"""Return the zero value for results.
80+
81+
In F# computation expressions, this is used when no value is returned,
82+
corresponding to Ok(()) in F#.
83+
84+
Returns:
85+
Ok(None)
86+
"""
87+
return Ok(None)
3488

3589
def __call__(
3690
self, # Ignored self parameter

0 commit comments

Comments
 (0)