Q:

How to handle modal operator events in a single loop?

When writing a modal operator, handling each event runs the 'modal()' callback which handles a single event.
While this makes sense, its not always convenient compared to a simple loop that consumes events (local variables need to be stored in the operator and restored every time, and you can't use flow control (break/continue) - for example).
Is there a way to handle events for a modal operator in a Python loop?
psudo-code example:
def modal_loop():
    for event in modal_events():
        # handle event
        if event.type == 'ESC':
            return {'CANCELLED'}

        if other_test:
            break
    return {'FINISHED'}

Note, this question is a little contrived. Since it should be possible using some of Python's coroutines, posting here since its probably a useful reference for others too.

A:

Yes, Python's coroutines can be used make the operators callbacks interact with a generator.
Here's an example of a generic re-usable operator mix-in class, and an example usage.
See modal_iter function for an example of how (invoke, execute, modal, cancel) can be handled from within a single generator.
import bpy
from bpy.types import Operator

# -----------------------------
# Generic Modal Iterator Mix-In

class ModalIterOperator:
    """ Defines Operator callbacks (invoke, execute, modal, cancel).
        Sub-classes only need to define `modal_iter` generator.
    """

    def invoke(self, context, event):
        self._iter = iter(self.modal_iter(context))
        self._iter.send(None)
        result = self._iter.send(event)

        if 'RUNNING_MODAL' in result:
            wm = context.window_manager
            wm.modal_handler_add(self)
        return result

    def execute(self, context):
        return self.invoke(context, None)

    def modal(self, context, event):
        return self._iter.send(event)

    def cancel(self, context):
        self._iter.send(None)

# ----------------------------------
# Example of ModalIterOperator Usage

class MyIterTest(Operator, ModalIterOperator):
    bl_idname = "wm.my_modal_iter_operator"
    bl_label = "My Modal Iter Operator"

    # -------------------------------------------
    # Entire modal operator in a single function!

    def modal_iter(self, context):

        event = yield

        if event is not None:
            # -------------------------------------
            # Section typically handled by invoke()

            print("Operator.invoke(..., event=%r)" % event.type)

            # Check we would typically do inside invoke()
            # to see if we want to exit, or continue and run modal()
            if event.type == 'ESC':
                yield {'CANCELLED'}
                return
        else:
            # --------------------------------------
            # Section typically handled by execute()

            print("Operator.execute()")

            # We may want to cancel the operator here too.
            if 0:
                yield {'CANCELLED'}
                return

        # ------------------------------------
        # Section typically handled by modal()
        event = yield {'RUNNING_MODAL'}

        while event is not None:
            print("Operator.modal(..., event=%r)" % event.type)

            if event.type in {'RIGHTMOUSE', 'ESC'}:
                yield {'CANCELLED'}
                return

            # ------------------------------------
            # Main modal operator logic goes here!
            # ------------------------------------                

            event = yield {'RUNNING_MODAL'}

        # cancel may be initiated externally
        if event is None:
            # -------------------------------------
            # Section typically handled by cancel()
            print("Operator.cancel()")
            yield  # --> None, since we're closed externally
            return

        yield {'FINISHED'}

def register():
    bpy.utils.register_class(MyIterTest)

def unregister():
    bpy.utils.unregister_class(MyIterTest)

if __name__ == "__main__":
    register()

    # test call
    bpy.ops.wm.my_modal_iter_operator('INVOKE_DEFAULT')

Notes:

None of the return's in modal_iter are needed, since the way the generator is used these points will never be passed anyway. I've added them in so as to be clear whats happening and so any accidental iteration past yield {'CANCELLED'} will raise a StopIteration exception, which intentionally isn't handled.
This code example may seem overly verbose, this is done for completeness.In practice you may not need all of the callbacks (cancel or execute for eg).
This article was used in writing the example above.

