Skip to content

← Laziness, Iteration and Pipelines step 10 of 14

Medium Primitives

Iterator, Iterable, Generator, Sequence — and what the annotation promises

An annotation on a parameter is a promise about what you will do to the argument, not a description of what the caller happens to pass.

Annotation What you are promising
Iterable[T] I will iterate it — possibly zero times, at most once
Iterator[T] I will call next() on it; I know it is single-shot and I own its position
Sequence[T] I may take len(), index it, and traverse it more than once
Generator[Y, S, R] I may send() into it and I care about its return value

Consuming an Iterable[T] twice works for a list argument and silently returns nothing on the second pass for a generator. No exception, no warning — the second consumer just sees an empty stream. Annotating Iterable while traversing twice is how that bug ships.

Generator[Yield, Send, Return] takes three parameters; AsyncGenerator[Yield, Send] takes two. Since 3.13 (PEP 696) the trailing parameters default to None, so Generator[int] means Generator[int, None, None] — but a library that still supports 3.12 must write the full three-parameter form. And prefer collections.abc.Iterator over the deprecated typing.Iterator alias.

What to write

Only resolve_all. Everything else — the source builders, the instrumentation, the driver — is provided.

async def resolve_all[T](
    sources: Sequence[Awaitable[T] | Callable[[], Awaitable[T]] | T],
) -> list[T]

Normalise a heterogeneous sequence into a list of plain values, preserving order:

  • a bare T passes straight through,
  • an Awaitable[T] (a coroutine object, a Task, a Future) is awaited,
  • a zero-argument Callable[[], Awaitable[T]] is invoked and its result awaited.

The parameter is Sequence, not Iterable, and that is a design decision, not a formality: the contract is “one result per input, in input order”, which is a claim you can only honestly make about something with a length.

Where the type system earns its keep

Two narrowing steps, and the order matters:

  1. callable(source) peels off the factory arm. Check it first — a coroutine object is not callable, and neither is a Task, so nothing that is already awaitable can be misrouted into the factory branch.
  2. isinstance(pending, Awaitable) peels off the awaitable arm. collections.abc.Awaitable is genuinely runtime-checkable: its __subclasshook__ looks for __await__.

No # type: ignore, no typing.cast, no Any anywhere in the signature. Getting mypy to accept this without an escape hatch is the exercise.

What the report proves

  • values — results in input order. Completion order is deliberately not input order in several cases.
  • started — the values whose _produce body actually began, in order. This is the payoff. A coroutine object does nothing until awaited; a Task is already scheduled and runs the moment the loop gets control; a factory has not even built its coroutine yet.
  • calls — the values whose factory was invoked, in order.
  • pending — outstanding tasks at the end. Must be 0.

    Loading visualization…