93

After reading Eli Bendersky's article on implementing state machines via Python coroutines I wanted to...

  • see his example run under Python3
  • and also add the appropriate type annotations for the generators

I succeeded in doing the first part (but without using async defs or yield froms, I basically just ported the code - so any improvements there are most welcome).

But I need some help with the type annotations of the coroutines:

#!/usr/bin/env python3

from typing import Callable, Generator

def unwrap_protocol(header: int=0x61,
                    footer: int=0x62,
                    dle: int=0xAB,
                    after_dle_func: Callable[[int], int]=lambda x: x,
                    target: Generator=None) -> Generator:
    """ Simplified protocol unwrapping co-routine."""
    #
    # Outer loop looking for a frame header
    #
    while True:
        byte = (yield)
        frame = []  # type: List[int]

        if byte == header:
            #
            # Capture the full frame
            #
            while True:
                byte = (yield)
                if byte == footer:
                    target.send(frame)
                    break
                elif byte == dle:
                    byte = (yield)
                    frame.append(after_dle_func(byte))
                else:
                    frame.append(byte)


def frame_receiver() -> Generator:
    """ A simple co-routine "sink" for receiving full frames."""
    while True:
        frame = (yield)
        print('Got frame:', ''.join('%02x' % x for x in frame))

bytestream = bytes(
    bytearray((0x70, 0x24,
               0x61, 0x99, 0xAF, 0xD1, 0x62,
               0x56, 0x62,
               0x61, 0xAB, 0xAB, 0x14, 0x62,
               0x7)))

frame_consumer = frame_receiver()
next(frame_consumer)  # Get to the yield

unwrapper = unwrap_protocol(target=frame_consumer)
next(unwrapper)  # Get to the yield

for byte in bytestream:
    unwrapper.send(byte)

This runs properly...

$ ./decoder.py 
Got frame: 99afd1
Got frame: ab14

...and also typechecks:

$ mypy --disallow-untyped-defs decoder.py 
$

But I am pretty sure I can do better than just use the Generator base class in the type specs (just as I did for the Callable). I know it takes 3 type parameters (Generator[A,B,C]), but I am not sure how exactly they'd be specified here.

Any help most welcome.

ttsiodras
  • 10,602
  • 6
  • 55
  • 71

3 Answers3

96

I figured out the answer on my own.

I searched, but found no documentation for the 3 type parameters of Generator in the official typing documentation for Python 3.5.2 - beyond a truly cryptic mention of...

class typing.Generator(Iterator[T_co], Generic[T_co, T_contra, V_co])

Luckily, the original PEP484 (that started all this) was far more helpful:

"The return type of generator functions can be annotated by the generic type Generator[yield_type, send_type, return_type] provided by typing.py module:

def echo_round() -> Generator[int, float, str]:
    res = yield
    while res:
        res = yield round(res)
    return 'OK'

Based on this, I was able to annotate my Generators, and saw mypy confirm my assignments:

from typing import Callable, Generator

# A protocol decoder:
#
# - yields Nothing
# - expects ints to be `send` in his yield waits
# - and doesn't return anything.
ProtocolDecodingCoroutine = Generator[None, int, None]

# A frame consumer (passed as an argument to a protocol decoder):
#
# - yields Nothing
# - expects List[int] to be `send` in his waiting yields
# - and doesn't return anything.
FrameConsumerCoroutine = Generator[None, List[int], None]


def unwrap_protocol(header: int=0x61,
                    footer: int=0x62,
                    dle :int=0xAB,
                    after_dle_func: Callable[[int], int]=lambda x: x,
                    target: FrameConsumerCoroutine=None) -> ProtocolDecodingCoroutine:
    ...

def frame_receiver() -> FrameConsumerCoroutine:
    ...

I tested my assignments by e.g. swaping the order of the types - and then as expected, mypy complained and asked for the proper ones (as seen above).

The complete code is accessible from here.

I will leave the question open for a couple of days, in case anyone wants to chime in - especially in terms of using the new coroutine styles of Python 3.5 (async def, etc) - I would appreciate a hint on exactly how they'd be used here.

ttsiodras
  • 10,602
  • 6
  • 55
  • 71
  • 4
    Regarding `async def` and friends -- they currently aren't supported by mypy, but are under active development/should be ready in the near future! See https://github.com/python/mypy/pull/1808 and https://github.com/python/mypy/issues/1886 for more details. – Michael0x2a Jul 18 '16 at 17:09
  • Just as an FYI, mypy 0.4.4 (which has experimental support for async/await) [was just released](http://mypy-lang.blogspot.com/2016/08/mypy-044-released.html). You can find more information on typing coroutines and async/await stuff in general in the [mypy docs](http://mypy.readthedocs.io/en/latest/kinds_of_types.html#typing-async-await). Currently, I don't think the docs for the typing module itself mention stuff related to async/await, but that'll probably be fixed sometime in the next few days. – Michael0x2a Aug 25 '16 at 21:01
  • Thanks, Michael - will check it out. – ttsiodras Aug 27 '16 at 08:40
  • 3
    Python 3.8.3 doc is more descriptive: "A generator can be annotated by the generic type Generator[YieldType, SendType, ReturnType]." https://docs.python.org/3/library/typing.html#typing.Generator – weakish Jun 14 '20 at 07:14
67

If you have a simple function using yield then you can use the Iterator type to annotate its result, rather than using Generator:

from collections.abc import Iterator  # Python >=3.9

def count_up() -> Iterator[int]:
    for x in range(10):
        yield x

If the function is async you need to use AsyncIterator instead:

from collections.abc import AsyncIterator  # Python >=3.5

async def count_up() -> AsyncIterator[int]:
    for x in range(10):
        yield x

In Python <3.9 you must import Iterator differently:

from typing import Iterator  # Python <3.9
David Foster
  • 6,931
  • 4
  • 41
  • 42
  • 7
    This is [applicable](https://docs.python.org/3/library/typing.html#typing.Generator) if the generator only yields values and neither receives nor returns values. – Peter Bašista Apr 26 '21 at 10:31
  • [Starting with Python 3.7](https://www.python.org/dev/peps/pep-0585/), `from typing import Iterator` is not needed anymore, and `collections.abc.Iterator` replaces `Iterator`. – Wok Dec 20 '21 at 13:48
  • One has to `import collections.abc` though. – Wok Dec 20 '21 at 13:58
10

As time of writing, the Python documentation explicitly mentions what to do with the async case as well (the non-async examples were already mentioned in the accepted answer).

Citing from there:

async def echo_round() -> AsyncGenerator[int, float]:
    sent = yield 0
    while sent >= 0.0:
        rounded = await round(sent)
        sent = yield rounded

(first parameter is yield-type, second is send-type) or for simple cases (where send-type is None)

async def infinite_stream(start: int) -> AsyncIterator[int]:
    while True:
        yield start
        start = await increment(start)
phispi
  • 604
  • 7
  • 15