Each listing below is self-contained, redeclaring the
Console and greet() it needs instead
of importing the chapter’s, so a solution keeps working when a
chapter listing changes.
Console that reads as well as printsAdd a
read()method to theConsoleprotocol inconsole_protocol.pyand writeask_and_greet(), an Effect that asks for a name and greets the result. Supply a scriptedConsolein a test and a real one in a demo, and confirmask_and_greet()stays unchanged between them.
Supplying
an Interface shows a Protocol standing in for a
base class, and need(Console) hands back whatever
object the supplier gave. Add read() to the
protocol, then write ask_and_greet() as a generator
that gets the Console with need() and
calls read() and print() on it. A
scripted class that returns a canned answer goes to
supply() in the test, and a class wrapping
input() goes to it in the demo.
If you pass scripted to supply()
without as_type(Console), the demo still prints
['Hello, Bob!'], but ty reports an
invalid-argument-type at each run()
call. The Effect reaching run() still carries the
Need[Console] that ask_and_greet()
requests, so the solution wraps each supplied object in
as_type(Console).
# test_ch46_ask_and_greet.py
from dataclasses import dataclass, field
from typing import Protocol, runtime_checkable
from stateless import (Depend, Need, as_type, need,
run, supply)
@runtime_checkable
class Console(Protocol):
def print(self, message: str) -> None: ...
def read(self, prompt: str) -> str: ...
class Terminal:
def print(self, message: str) -> None:
print(message)
def read(self, prompt: str) -> str:
return input(prompt)
@dataclass
class Scripted:
answer: str
printed: list[str] = field(default_factory=list)
def print(self, message: str) -> None:
self.printed.append(message)
def read(self, prompt: str) -> str:
return self.answer
def ask_and_greet() -> Depend[Need[Console], None]:
console = yield from need(Console)
name = console.read("What is your name? ")
console.print(f"Hello, {name}!")
def test_ask_and_greet_uses_the_answer_it_reads() -> None:
scripted = Scripted("Alice")
run(supply(as_type(Console)(scripted))(ask_and_greet)())
assert scripted.printed == ["Hello, Alice!"]
scripted = Scripted("Bob")
run(supply(as_type(Console)(scripted))(ask_and_greet)())
print(scripted.printed)
#: ['Hello, Bob!']Supplying a real Console is the same call with
Terminal() in place of Scripted(...),
and the session reads:
What is your name? Alice
Hello, Alice!
The demo in test_ch46_ask_and_greet.py uses
Scripted rather than Terminal for the
reason any book listing does: a call to input() has
no terminal from which to read. The substitution is the point
either way, and neither binding requires a change to
ask_and_greet(), which is character-for-character
the same function under both.
Request a capability, not a class. No
binding could have required a change.
ask_and_greet() names a capability in its return
type and calls two methods on whatever answers.
Terminal, Scripted, and any third
implementation are interchangeable because none of them appears
in the Effect. Adding read() to the protocol
changed which classes qualify, and supply() still
picks among them the same way.
Supply under the protocol’s type.
as_type(Console) does quiet work in both calls, and
the work is static. supply() reads the Ability from
the declared type of its argument, so
supply(scripted) alone builds a handler for
Need[Scripted] rather than the
Need[Console] that ask_and_greet()
requests. The wrapper turns that static type into
Console. At runtime the wrapper returns
scripted untouched, and supply() still
finds it, because Console is
@runtime_checkable and isinstance()
matches Scripted on shape.
Take
undeclared_need.py, remove the# type: ignore, and runty checkon it. Fix the error by changing only the annotation, then check whatgreet_all()’s callers must now declare.
Effects
Propagate, and the Type Checker Verifies It shows what
ty says when a function’s annotation hides a
request that greet() makes. Read the error’s yield
type, then change the return annotation of
greet_all() so it names the request that comes up
through yield from. Whoever calls
greet_all() inherits that request, so look at what
its callers declare next.
# The shape of exercise_2.py
from stateless import Depend, Need, need, run, supply
class Console:
def print(self, message: str) -> None:
...
def greet(name: str) -> Depend[Need[Console], None]:
...
def greet_all(names: list[str]) -> Depend[
Need[Console], None
]:
...If you fix the annotation but keep a caller that runs
greet_all() with no environment, as
run(greet_all(["Alice", "Bob"])) does,
ty reports an invalid-argument-type at
that run() call. The run raises a
MissingAbilityError before the first greeting
prints. The new annotation hands the requirement to every
caller, so the solution wraps greet_all in
supply(Console()) before calling
run().
Removing the # type: ignore from undeclared_need.py
produces:
error[invalid-yield]: Yield expression type does not match annotation
--> undeclared_need.py:7:20
|
5 | def greet_all(names: list[str]) -> Success[None]:
| ------------- Function annotated
| with yield type `Never` here
6 | for name in names:
7 | yield from greet(name)
| ^^^^^^^^^^^ expression of type `Need[Console]`,
| expected `Never`
Success[None] is
Effect[Never, Never, None]: an Effect that needs
nothing, fails with nothing, and returns nothing.
Never in the yield channel means no value of any
type may travel there, so a single Need[Console]
coming up from greet() contradicts it.
The fix is the annotation, and only the annotation:
# exercise_2.py
from stateless import Depend, Need, need, run, supply
class Console:
def print(self, message: str) -> None:
print(message)
def greet(name: str) -> Depend[Need[Console], None]:
console = yield from need(Console)
console.print(f"Hello, {name}!")
def greet_all(names: list[str]) -> Depend[
Need[Console], None
]:
for name in names:
yield from greet(name)
run(supply(Console())(greet_all)(["Alice", "Bob"]))
#: Hello, Alice!
#: Hello, Bob!Fix the signature, not the body. The body
stays as it is: greet_all() did the right thing all
along. Its signature described a different function.
What greet_all()’s callers must now declare is
the point of the exercise. Before, greet_all()
claimed to need nothing, so a caller could run it with no
environment: run(greet_all(names)). Now every
caller has two options, the same two greet()’s
callers have. Supply a Console, ending the
requirement, or declare Need[Console] in its own
return type and pass the requirement further up. No third option
exists, which makes the dependency visible. The requirement
appears in the signature of every function between the one that
uses the Console and the one that supplies it, and
the type checker refuses to let any of them stay silent.
Apply
reveal_type()tocatch(ValueError)(one_unhandled)and runty check. Explain why its result type differs fromall_handled()’s, given that both have handled every errorread_score()declares.
Turning
an Error Into a Value shows catch() moving a
declared error out of the failure channel. Compare the two
result types and ask what remains in each return type after
catch() has run. Then look at what a
match over the caught value does to that type, and
which function in the pair contains one that covers every
case.
# The shape of exercise_3.py
from typing import Final, assert_never, reveal_type
from stateless import Success, Try, catch, throws
RAW: Final[dict[str, str]] = {"Alice": "42", "Bob": "seven"}
@throws(KeyError, ValueError)
def read_score(name: str) -> int:
...
def all_handled(name: str) -> Success[str]:
...
def one_unhandled(name: str) -> Try[ValueError, str]:
...# exercise_3.py
from typing import Final, assert_never, reveal_type
from stateless import Success, Try, catch, throws
RAW: Final[dict[str, str]] = {"Alice": "42", "Bob": "seven"}
@throws(KeyError, ValueError)
def read_score(name: str) -> int:
text = RAW[name] # KeyError
return int(text) # ValueError
both = catch(KeyError, ValueError)(read_score)
one = catch(KeyError)(read_score)
def all_handled(name: str) -> Success[str]:
value: int | KeyError | ValueError = yield from both(
name)
match value:
case KeyError():
return f"{name}: unknown"
case ValueError():
return f"{name}: unreadable"
case int():
return f"{name}: {value}"
case _:
assert_never(value)
def one_unhandled(name: str) -> Try[ValueError, str]:
value: int | KeyError = yield from one(name)
match value:
case KeyError():
return f"{name}: unknown"
case int():
return f"{name}: {value}"
case _:
assert_never(value)
if __name__ == "__main__":
reveal_type(catch(ValueError)(one_unhandled))ty reveals:
info[revealed-type]: Revealed type
`(name: str) -> Generator[Never, Any, str | ValueError]`
all_handled() is Success[str],
which expands to Generator[Never, Any, str]. The
revealed type and all_handled()’s type both carry
Never in the yield channel, so both agree that
nothing can fail from here on. They differ in the return
channel: str for all_handled(),
str | ValueError for the wrapped
one_unhandled().
Consume every caught error. The difference
is where the error stops travelling. all_handled()
catches both errors, then consumes both in its
match, turning each into a sentence and returning a
str. No error remains, and the
assert_never() proves it: the match
covers every case inside the function.
Pass the remaining error up.
one_unhandled() catches only the
KeyError and consumes that one. The
ValueError stays declared, as
Try[ValueError, str] says, so it is still in the
yield channel when catch(ValueError) wraps
one_unhandled(). catch() does not
delete an error. It moves the error from the yield channel to
the return channel, as a value. So the ValueError
leaves the failure channel and reappears beside the
str, and a caller needing a bare str
has one case left to handle.
Both functions have handled every error
read_score() declares. Only
all_handled() has interpreted what it
caught. catch() turns a failure into a value, and a
match turns that value into a result. Skipping the
match leaves the caught error sitting in the return
type.
Log protocol, and a test that records bothRewrite
audit_log.pysoLogis aProtocolrather than a concrete class, then write a test that supplies a recordingLogand a recordingConsoleat once and asserts on both.
Retrofitting
an Effect builds Log as an Ability alongside
Console, and Supplying
an Interface shows how a Protocol replaces a
concrete class. Declare Log with a
write() method, and write a recording class for
each protocol. Pass both instances to supply() in
one call, run the Effect, and assert on each recorder’s
list.
If you declare Log as a Protocol
without @runtime_checkable, ty passes
the listing, but the run raises a TypeError at the
first request for a Log. supply()
matches each request with isinstance(), and
isinstance() raises a TypeError on a
Protocol without that decorator. The solution puts
the decorator on both protocols, as console_protocol.py does
on Console.
# test_ch46_audit_log.py
from dataclasses import dataclass, field
from typing import Protocol, runtime_checkable
from stateless import (Depend, Need, as_type, need,
run, supply)
@runtime_checkable
class Console(Protocol):
def print(self, message: str) -> None: ...
@runtime_checkable
class Log(Protocol):
def write(self, entry: str) -> None: ...
def greet(name: str) -> Depend[Need[Console], None]:
console = yield from need(Console)
console.print(f"Hello, {name}!")
def greet_logged(
name: str,
) -> Depend[Need[Console] | Need[Log], None]:
yield from greet(name)
log = yield from need(Log)
log.write(f"greeted {name}")
def greet_all(
names: list[str],
) -> Depend[Need[Console] | Need[Log], None]:
for name in names:
yield from greet_logged(name)
@dataclass
class Recorder:
printed: list[str] = field(default_factory=list)
entries: list[str] = field(default_factory=list)
def print(self, message: str) -> None:
self.printed.append(message)
def write(self, entry: str) -> None:
self.entries.append(entry)
def test_greeting_and_logging_are_both_recorded() -> None:
recorder = Recorder()
environment = supply(
as_type(Console)(recorder), as_type(Log)(recorder))
run(environment(greet_all)(["Alice", "Bob"]))
assert recorder.printed == ["Hello, Alice!",
"Hello, Bob!"]
assert recorder.entries == ["greeted Alice",
"greeted Bob"]
recorder = Recorder()
run(supply(as_type(Console)(recorder),
as_type(Log)(recorder))(greet_all)(["Cyd"]))
print(recorder.printed, recorder.entries)
#: ['Hello, Cyd!'] ['greeted Cyd']Fill both roles with one object. One object
satisfies both protocols. The concrete-class version could not
arrange that: Log is a dataclass
holding its own entries, so a test must construct one and read
log.entries afterward. As a Protocol,
Log is a shape, and a single Recorder
can have that shape and the Console shape at
once.
Supply one object under two types. The two
as_type() calls make one object answerable to two
requests. supply() reads the Ability from each
argument’s declared type, so
supply(recorder, recorder) builds a handler for
Need[Recorder], an Ability neither Effect requests.
Each wrapper names the role this instance fills. Supplying the
same object twice under two different types is the case for
which as_type() exists.
Check the greeting and the log together. Writing both assertions in one test is the payoff. A test holding the whole environment can check that the greeting reached the console and that the log recorded it, in one function, with no capture of stdout and no temporary file. Both Effects are requests before they are actions, so the test decides what performing them means.
Add a
Metalmaterial totest_nailer.pywith a strength that survives the robotic nailer, and add its two rows to the table. Then explain why the test function body needs no change.
One
Effect, Many Environments shows parametrize
running holds() against a table of materials and
nailers. Define a Metal value whose strength
exceeds the robotic nailer’s force, and add one row for each
nailer. Consider what holds() asks for by type, and
whether a new instance of that type changes the request.
# test_ch46_nailer.py
from typing import Final
import pytest
from record import record
from stateless import Depend, Need, need, run, supply
@record
class Material:
strength: int
@record
class Nailer:
force: int
def holds() -> Depend[Need[Material] | Need[Nailer], bool]:
material = yield from need(Material)
nailer = yield from need(Nailer)
return nailer.force < material.strength
WOOD: Final[Material] = Material(strength=5)
PLASTIC: Final[Material] = Material(strength=10)
METAL: Final[Material] = Material(strength=20)
HAND: Final[Nailer] = Nailer(force=4)
ROBOTIC: Final[Nailer] = Nailer(force=11)
@pytest.mark.parametrize("material, nailer, expected", [
(WOOD, HAND, True),
(PLASTIC, HAND, True),
(METAL, HAND, True),
(WOOD, ROBOTIC, False),
(PLASTIC, ROBOTIC, False),
(METAL, ROBOTIC, True),
])
def test_holds(
material: Material, nailer: Nailer, expected: bool
) -> None:
assert run(
supply(material, nailer)(holds)()) is expected
print(run(supply(METAL, ROBOTIC)(holds)()))
#: TruePick a strength above both forces.
METAL at strength 20 outlasts the
robotic nailer’s force of 11, so its two rows read
True and True. METAL is
the first material in the table that survives both nailers.
Build each environment from a row. The test
function body needs no change because it mentions no material or
nailer. It receives two objects and an expectation, builds an
environment from them with supply(), and asks
whether the answer matches. The parametrize table
decides which two objects those are, and adding a row adds a
case without touching the function body that runs it.
That split between the table and the body is the same one
running through the whole chapter, seen from the testing side.
holds() declares two requirements and names no
instance, so every combination of instances is a valid
environment for it. The parametrize table is a list of
environments, and the test body is the driver that runs the
Effect in each one. Six rows share one assertion. A version
constructing its own Material inside
holds() needs three copies of the function, one per
material, and a version that also constructs its own
Nailer needs all six.
This one looks ahead to
handle(), which Abilities Are Not Special covers.default_console.pydefaults by supplying an instance. Write the other kind of default, one that builds whatever the request names.handle()reads its handler’s parameter annotation to decide what it answers, so a function annotatedNeed[Console]and returningability.t()hands back a default-constructed instance of the requested class. Run it againstgreeter.py’sgreet(), whoseConsoleconstructs with no arguments, and confirm the greeting prints. Then declare a second Ability and request that one too, and report which requests your handler answered at runtime and which ones the type checker believes it answered. Account for the difference, usinghandle()’st = get_origin(t) or tas the evidence.
Layering
Handlers shows a default supplied under a more specific
handler, and Abilities
Are Not Special covers handle(). Write a
handler function that takes a Need[Console]
parameter and returns a new instance of the class the request
carries in ability.t. Wrap it with
handle(), then compare what runs against what the
annotation tells the type checker.
# The shape of exercise_6.py
from stateless import Depend, Need, handle, need, run
class Console:
def print(self, message: str) -> None:
...
class Clock:
def now(self) -> str:
...
def greet(name: str) -> Depend[Need[Console], None]:
...
def stamped(
name: str,
) -> Depend[Need[Console] | Need[Clock], None]:
...
def default(ability: Need[Console]) -> Console:
...# exercise_6.py
from stateless import Depend, Need, handle, need, run
class Console:
def print(self, message: str) -> None:
print(message)
class Clock:
def now(self) -> str:
return "noon"
def greet(name: str) -> Depend[Need[Console], None]:
console = yield from need(Console)
console.print(f"Hello, {name}!")
def stamped(
name: str,
) -> Depend[Need[Console] | Need[Clock], None]:
clock = yield from need(Clock)
console = yield from need(Console)
console.print(f"[{clock.now()}] Hello, {name}!")
def default(ability: Need[Console]) -> Console:
print(f"handler answered a request "
f"for {ability.t.__name__}")
return ability.t()
defaults = handle(default)
run(defaults(greet)("Alice"))
#: handler answered a request for Console
#: Hello, Alice!
run(defaults(stamped)("Bob")) # type: ignore
#: handler answered a request for Clock
#: handler answered a request for Console
#: [noon] Hello, Bob!Build the class the request names.
default() names no Console in its
body. It reads ability.t, the class the request
carries, and calls that class, so default() answers
a request by constructing the requested class. That is the other
kind of default: default_console.py
supplies one prepared instance, and default()
builds whatever the request names, on demand.
Run both Effects through one handler. At
runtime the handler answered three requests across the two
calls, two for Console and one for
Clock, although default() annotates
its parameter Need[Console]. The type checker
believes the handler answers Need[Console] and
leaves Need[Clock] open, so the second
run() needs a # type: ignore. Without
that pragma, the type checker reports a leftover
Need[Clock].
handle()’s t = get_origin(t) or t
is the evidence. handle() reads the annotation,
reduces Need[Console] to its origin,
Need, and installs the runtime check
isinstance(ability, Need). That check ignores the
type argument, so every Need[...] request matches.
The type checker reads the same annotation without that
reduction, subtracts Need[Console] from the
requirements, and leaves Need[Clock] in place.
Neither view is wrong about what it describes.
isinstance() cannot test a type argument:
Need[Clock] and Need[Console] are the
same runtime class, so no runtime check tells the two apart. The
annotation is the only place the distinction exists, and
handle() uses the annotation for matching but
cannot enforce the distinction. A handler like
default() genuinely handles more than its type
says, and one that assumes ability.t is a
Console receives a Clock with nothing
to stop it.
yield fromBreak
audit_log.pyby removing theyield fromin front ofgreet(name)ingreet_logged(). Runty check,ruff check, and the script, and record what each reports and what the program prints. Explain where the greetings went and why no tool objects. Then restore it, and instead remove theyield fromin front ofneed(Console)ingreeter.py’sgreet(). This timetyproduces two diagnostics. Explain what each one catches, and why the type checker catches assigning a dropped request but not discarding one.
Nothing
Runs Yet shows that calling a generator function builds an
Effect without running its body, and Why
yield from explains what the keyword does. Ask
whether a discarded generator breaks any rule ty or
ruff checks. For the second case, read both
diagnostics and ask what the function has become once it loses
its only yield from, and whether the next line uses
the dropped value.
Removing the yield from in front of
greet(name) in greet_logged():
def greet_logged(
name: str,
) -> Depend[Need[Console] | Need[Log], None]:
greet(name) # Was: yield from greet(name)
log = yield from need(Log)
log.write(f"greeted {name}")ty check reports nothing.
ruff check reports nothing. The script runs to
completion and prints:
['greeted Alice', 'greeted Bob']
No greeting prints. greet(name) calls a
generator function, so it builds an Effect and returns it.
Nothing then drives that Effect, so its body does not run and
makes no Need[Console] request. The log entries
still appear because the deletion touches only the greeting half
of the function. The surviving entries make the failure quieter
still: the program looks like it worked and produced most of its
output.
No tool objects because the code breaks no rule. Building a
value and discarding it is legal Python, and
greet(name)’s value is a generator like any other.
The declared Need[Console] in the return type still
holds, since a declaration says what the function may request,
not what it must. Those two deleted words are the chapter’s own
caveat about the limits of the guarantee.
Removing the yield from in front of
need(Console) in greet() draws two
diagnostics:
def greet(name: str) -> Depend[Need[Console], None]:
console = need(Console) # Was: yield from need(Console)
console.print(f"Hello, {name}!")error[invalid-return-type]: Function always implicitly returns `None`,
which is not assignable to return type
`Generator[Need[Console], Any, None]`
--> greeter.py:8:25
|
8 | def greet(name: str) -> Depend[Need[Console], None]:
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^
error[unresolved-attribute]: Object of type
`Generator[Need[Console], Any, Console]` has no attribute `print`
--> greeter.py:10:5
|
10 | console.print(f"Hello, {name}!")
| ^^^^^^^^^^^^^
The invalid-return-type error says the function
stopped being a generator. Removing the only
yield from in the body leaves no yield
anywhere, so greet() is an ordinary function
returning None. None is not the
Generator its annotation declares. The
unresolved-attribute error says the value in
console is the wrong kind of thing: a
Generator rather than a Console, and
generators have no print().
The difference between the two cases is whether anything
later uses the dropped value. Discarding
greet(name) is invisible because nothing afterward
depends on it, and a discarded expression has no type to
contradict. Assigning need(Console) binds a
generator to a name the next line then uses as a
Console, so the mistake reaches an operation the
type checker can evaluate. The lesson generalizes past this
library: a type checker verifies how a program uses its values,
so a value nobody uses is a value nobody checks.
retry() takes a
functionBuild a registry of Effects: a
dict[str, Success[None]]that maps each of two names tosupply(Console())(greet)(name). Run every entry, then run every entry a second time, and record what prints on each pass. Change the values to functions that build the Effect when called, and run both passes again. Explain which of the two shapesretry()requires, and why it takes a schedule and returns a decorator of typeCallable[P, Effect[...]] -> Callable[P, Effect[...]], rather than being an operation on an Effect.
An
Effect Runs Once shows a spent Effect returning
None when you run it again. Store the Effects in
one dictionary and functions that build them in another, then
run each twice. retry() starts the work over after
a failure, so consider what it needs to call each time.
# The shape of exercise_8.py
from collections.abc import Callable
from functools import partial
from typing import Final
from stateless import (Depend, Need, Success, need,
run, supply)
class Console:
def print(self, message: str) -> None:
...
def greet(name: str) -> Depend[Need[Console], None]:
...
NAMES: Final[list[str]] = ["Alice", "Bob"]
def make(name: str) -> Success[None]:
...# exercise_8.py
from collections.abc import Callable
from functools import partial
from typing import Final
from stateless import (Depend, Need, Success, need,
run, supply)
class Console:
def print(self, message: str) -> None:
print(message)
def greet(name: str) -> Depend[Need[Console], None]:
console = yield from need(Console)
console.print(f"Hello, {name}!")
NAMES: Final[list[str]] = ["Alice", "Bob"]
built: dict[str, Success[None]] = {
name: supply(Console())(greet)(name) for name in NAMES
}
for effect in built.values():
run(effect)
#: Hello, Alice!
#: Hello, Bob!
# The same objects, a second time
for effect in built.values():
run(effect)
def make(name: str) -> Success[None]:
return supply(Console())(greet)(name)
builders: dict[str, Callable[[], Success[None]]] = {
name: partial(make, name) for name in NAMES
}
for builder in builders.values():
run(builder())
#: Hello, Alice!
#: Hello, Bob!
# A fresh Effect each time
for builder in builders.values():
run(builder())
#: Hello, Alice!
#: Hello, Bob!Run each stored Effect twice. The first pass
over built greets both names. The second prints
nothing, and run() returns None for
each entry without raising an exception. An Effect is a
generator, and a generator runs once. Resuming a finished
generator raises StopIteration immediately, which
run() reads as “already returned, with no value.”
So a spent Effect looks the same as one that succeeded and
returned None, and nothing reports the
difference.
Build a fresh Effect per run. The dictionary
of builders behaves as a reader expects. Each pass calls each
entry, each call builds a new generator, and each generator runs
its body once. The stored value goes from a description
run() consumes once to a recipe a caller can follow
as often as it likes.
That difference is why retry() takes a schedule
and returns a decorator of type
Callable[P, Effect[...]] -> Callable[P, Effect[...]]
rather than one of type
Effect[...] -> Effect[...]. Retrying means
running the same work more than once, and an Effect cannot
supply the second run: by the time the first attempt fails, that
attempt has run the generator to its end, leaving nothing to
resume. retry() needs to build a fresh Effect per
attempt, and only the function that builds the Effect can do
that. So retry() decorates the function, calls it
once per attempt, and hands back a function that takes the same
arguments. The Effect the returned function builds has a wider
type: retry() adds the clock on which it sleeps and
replaces the error with a RetryError.
The same reasoning explains repeat() and
memoize(). It also explains why storing Effects in
a registry, a queue, or a cache is a mistake that looks fine
until something runs an entry twice. Store the function, and
apply the arguments where you need the Effect.
Write
report_all(), which callsstateless_coroutine.py’sreport()for three URLs withyield fromand returns the three results. Importing that module runs its own unguardedprint(run(...)), so expect one line of its output before yours. Work out what its annotation must be, and confirm it with the type checker. Then call it from inside anasync def, once withrun()and once withawait run_async(), and record what each one does. Explain why the type checker accepts both.
Waiting
on a Coroutine shows wait() putting an
Async request into an Effect, and Where to
Call run() covers running one from inside an
event loop. Loop over the URLs, delegate to
report() with yield from, and collect
the results in a list. The annotation carries the
Async request, and the two runners differ in
whether they start their own event loop.
# The shape of exercise_9.py
import asyncio
from exceptions import expect
from stateless import Async, Depend, run, run_async, wait
async def fetch(url: str) -> str:
...
def report(url: str) -> Depend[Async, str]:
...
def report_all(urls: list[str]) -> Depend[Async, list[str]]:
...
async def main() -> None:
...# exercise_9.py
import asyncio
from exceptions import expect
from stateless import Async, Depend, run, run_async, wait
async def fetch(url: str) -> str:
await asyncio.sleep(0.01)
return f"fetched {url}"
def report(url: str) -> Depend[Async, str]:
body = yield from wait(fetch(url))
return f"{body = }, {len(body) = }"
def report_all(urls: list[str]) -> Depend[Async, list[str]]:
reports: list[str] = []
for url in urls:
reports.append((yield from report(url)))
return reports
async def main() -> None:
expect(RuntimeError, run, report_all(["a"]))
for line in await run_async(
report_all(["a", "b", "c"])):
print(line)
asyncio.run(main())
#: [RuntimeError] asyncio.run() cannot be called from a
#: running event loop
#: body = 'fetched a', len(body) = 9
#: body = 'fetched b', len(body) = 9
#: body = 'fetched c', len(body) = 9Relay the request, collect the results. The
annotation is Depend[Async, list[str]].
report() needs Async, and
yield from passes that requirement straight up, so
report_all() needs it too. Three delegations to the
same Effect type add nothing new to the channel:
Need[Console] | Need[Log] grows because the two
requirements differ, and here they do not. Only the return type
changes, from one str to a list[str],
since report_all() collects the results rather than
relaying them.
Drive the Effect from a coroutine.
run() raises a RuntimeError, and
await run_async(...) works.
run(effect) is
asyncio.run(run_async(effect)).
asyncio.run() refuses to start an event loop inside
a running one, so calling run() from
main() fails. run_async() is the same
driver in coroutine form, so the running loop can await it.
The type checker accepts both calls because both are
correctly typed. run() takes an Effect and returns
its result. run_async() takes an Effect and returns
an awaitable of its result. report_all(["a"])
satisfies either signature, and nothing in the type system
records that this call site sits inside a coroutine. Whether an
event loop is running is a fact about the moment of the call,
not about the types involved, so calling run()
inside a coroutine is a mistake the type checker cannot report.
The rule is positional rather than type-based:
run() at the outermost edge of a synchronous
program, run_async() anywhere inside an
asynchronous one.
announce()declaresEffect[Need[Console], KeyError, None]. Give it a second failure: a helper that formats the score and raises aValueErroron a negative one, lifted with@throws(ValueError). Follow the type checker until the program builds, add a negative score toscores.py’sSCORESso the new failure can occur, then run it on a name that produces each failure and on one that succeeds, and say where each failure surfaced. Then deleteValueErrorfromannounce()’s annotation and record what the type checker reports and at which line.
Multiple
Errors shows an Effect declaring more than one failure, and
The Error
Channel covers @throws. Lift the helper with
@throws(ValueError), then widen
announce()’s failure type to a union and call the
helper with yield from. Run the three names and
note whether each failure comes out of run() as an
exception. Deleting ValueError from the annotation
makes the type checker flag the yield from of the
helper.
# The shape of exercise_10.py
from typing import Final
from exceptions import expected
from stateless import (Effect, Need, need, run, supply,
throws)
class Console:
def print(self, message: str) -> None:
...
SCORES: Final[dict[str, int]] = {
"Alice": 42, "Bob": 7, "Cyd": -3}
@throws(KeyError)
def score(name: str) -> int:
...
@throws(ValueError)
def format_score(name: str, value: int) -> str:
...
def announce(
name: str,
) -> Effect[Need[Console], KeyError | ValueError, None]:
...# exercise_10.py
from typing import Final
from exceptions import expected
from stateless import (Effect, Need, need, run, supply,
throws)
class Console:
def print(self, message: str) -> None:
print(message)
SCORES: Final[dict[str, int]] = {
"Alice": 42, "Bob": 7, "Cyd": -3}
@throws(KeyError)
def score(name: str) -> int:
return SCORES[name]
@throws(ValueError)
def format_score(name: str, value: int) -> str:
if value < 0:
raise ValueError(
f"negative score for {name}: {value}")
return f"{name}: {value}"
def announce(
name: str,
) -> Effect[Need[Console], KeyError | ValueError, None]:
value: int = yield from score(name)
line: str = yield from format_score(name, value)
console = yield from need(Console)
console.print(line)
bound = supply(Console())(announce)
for who in ("Alice", "Cyd", "Dana"):
with expected((KeyError, ValueError)):
run(bound(who))
#: Alice: 42
#: [ValueError] negative score for Cyd: -3
#: [KeyError] 'Dana'Lift the helper into an Effect.
@throws(ValueError) turns
format_score() from a function that raises an
exception into an Effect that declares one, so its failure
travels as a value in the yield channel instead of unwinding the
stack.
Declare the second failure. Following the
type checker until the program builds means one edit: widening
announce()’s error parameter from
KeyError to KeyError | ValueError.
line: str and value: int carry
annotations by choice, not by demand: yield from on
a @throws function produces the declared success
type, and naming that type keeps the type checker’s inference
pinned.
Surface each failure at run().
Each failure surfaces at run(), and nowhere
earlier. Cyd has a score, so the lookup succeeds
and format_score() fails. Dana has
none, so the lookup fails and format_score() does
not run. In both cases the error value travels up through the
yield from chain untouched, past
announce(), past supply(), to the
driver. run() raises it as an ordinary exception
because nothing along the way catches it. Declaring a failure is
not handling it. The declaration says the failure can arrive,
and catch() turns it into a value the program
handles.
Deleting ValueError from the annotation
gives:
error[invalid-yield]: Yield expression type does not match annotation
--> exercise_10.py:29:28
|
27 | ) -> Effect[Need[Console], KeyError, None]:
| ------------------------------------- Function annotated with
| yield type `Need[Console] | KeyError` here
28 | value: int = yield from score(name)
29 | line: str = yield from format_score(name, value)
| ^^^^^^^^^^^^^^^^^^^^^^^^^
| expression of type `ValueError`,
| expected `Need[Console] | KeyError`
The error appears on line 29, the yield from
that introduces the undeclared failure, not on the signature and
not at the call site. That line is the useful place for the
diagnostic. The diagnostic names both the failure that escaped
and the delegation through which it escaped, so the fix is
either to declare the failure or to catch it, right there.
ambiguous_supply.pypicks itsConsoleby argument order. Add a third implementation and predict, before running it, which of the six orderings send Alice’s greeting where. Then follow the advice in When Two Implementations Match: give the recording implementation a method name the screen one does not have, declare each as its ownProtocol, and show that handing the wrong implementation to an Effect is now a type error rather than a silent choice. Two implementations sharing one method name stay ambiguous under bothProtocols, so say what the technique does and does not prevent.
When
Two Implementations Match explains why supply()
takes the first argument that satisfies the request. Predict
from argument order, since the first match wins. If you give
each implementation its own Protocol with a
differently named method, the type checker rejects an
implementation that lacks the method the Effect requests. Then
check whether the type checker can tell two classes apart when
they share one method name.
# The shape of exercise_11.py
from dataclasses import dataclass, field
from typing import Protocol, runtime_checkable
from stateless import (Depend, Need, as_type, need,
run, supply)
@runtime_checkable
class Screen(Protocol):
def print(self, message: str) -> None: ...
@runtime_checkable
class Recorder(Protocol):
def record(self, message: str) -> None: ...
@dataclass
class Terminal:
def print(self, message: str) -> None:
...
@dataclass
class Capture:
messages: list[str] = field(default_factory=list)
def record(self, message: str) -> None:
...
def to_screen(name: str) -> Depend[Need[Screen], None]:
...
def to_log(name: str) -> Depend[Need[Recorder], None]:
...Three implementations have six orderings, and the prediction
is short. supply() scans its arguments and takes
the first that satisfies the request, so whichever
implementation comes first answers it. Two of the six orderings
put Terminal first and send the greeting to the
screen, and the remaining four split evenly between
Capture and the third implementation. No ordering
produces an error, and no ordering produces a warning.
The fix is to stop letting one structural check match three objects:
# exercise_11.py
from dataclasses import dataclass, field
from typing import Protocol, runtime_checkable
from stateless import (Depend, Need, as_type, need,
run, supply)
@runtime_checkable
class Screen(Protocol):
def print(self, message: str) -> None: ...
@runtime_checkable
class Recorder(Protocol):
def record(self, message: str) -> None: ...
@dataclass
class Terminal:
def print(self, message: str) -> None:
print(message)
@dataclass
class Capture:
messages: list[str] = field(default_factory=list)
def record(self, message: str) -> None:
self.messages.append(message)
def to_screen(name: str) -> Depend[Need[Screen], None]:
device = yield from need(Screen)
device.print(f"Hello, {name}!")
def to_log(name: str) -> Depend[Need[Recorder], None]:
device = yield from need(Recorder)
device.record(f"Hello, {name}!")
capture = Capture()
run(supply(as_type(Screen)(Terminal()))(to_screen)("Alice"))
#: Hello, Alice!
run(supply(as_type(Recorder)(capture))(to_log)("Bob"))
print(capture.messages)
#: ['Hello, Bob!']Give each role its own protocol. The fix
renames Capture.print() to record(),
gives each method its own Protocol,
Screen and Recorder, and splits
greet() into one Effect per Protocol.
The two Protocols no longer overlap, so neither
implementation satisfies both, and each Effect names the
Protocol it needs.
That change turns the coin flip into a diagnostic. If you add
one more line to the end of the listing, handing
to_log the object that prints instead of the one
that records, ty rejects it before the program
runs:
error[invalid-argument-type]: Argument is incorrect
--> exercise_11.py:40:30
|
40 | run(supply(as_type(Recorder)(Terminal()))(to_log)("Carol"))
| ^^^^^^^^^^ Expected `Recorder`, found `Terminal`
info: type `Terminal` is not assignable to protocol `Recorder`
info: └── protocol member `record` is not defined on type `Terminal`
In the second info line, the type checker names
the missing method rather than the missing type, and that is
what structural typing means: Terminal fails not
because of what it is but because of what it does not do.
One limit remains. Distinct method names remove the ambiguity
between abilities. They do nothing about two
implementations of the same ability. If you add a
second recorder, an Audit that also defines
record(), supply(capture, audit) is
ambiguous again by argument order, with no diagnostic. When
Two Implementations Match gives its advice in two halves for
that reason. No type can enforce the second half, “supply one
implementation per Ability.” Stateless resolves a request by
scanning its arguments at runtime, so a duplicate is a fact
about the call rather than about the types. ZIO’s compile-time
rejection of this case is the difference that section names.