Skip to content

Commit

Permalink
Changed event topic registration to use a class decorator
Browse files Browse the repository at this point in the history
  • Loading branch information
agronholm committed Apr 14, 2016
1 parent 38793d4 commit 852f163
Show file tree
Hide file tree
Showing 4 changed files with 114 additions and 31 deletions.
11 changes: 4 additions & 7 deletions asphalt/core/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from typeguard import check_argument_types

from asphalt.core.event import EventSource, Event
from asphalt.core.event import EventSource, Event, register_topic
from asphalt.core.util import qualified_name

__all__ = ('Resource', 'ResourceEvent', 'ResourceConflict', 'ResourceNotFound',
Expand Down Expand Up @@ -104,6 +104,9 @@ def __init__(self, source: 'Context', topic: str, exception: Optional[BaseExcept
self.exception = exception


@register_topic('finished', ContextFinishEvent)
@register_topic('resource_published', ResourceEvent)
@register_topic('resource_removed', ResourceEvent)
class Context(EventSource):
"""
Contexts give request handlers and callbacks access to resources.
Expand All @@ -126,12 +129,6 @@ class Context(EventSource):
def __init__(self, parent: 'Context'=None, default_timeout: int=5):
assert check_argument_types()
super().__init__()
self._register_topics({
'finished': ContextFinishEvent,
'resource_published': ResourceEvent,
'resource_removed': ResourceEvent
})

self._parent = parent
self._resources = defaultdict(dict) # type: Dict[str, Dict[str, Resource]]
self._resource_creators = {} # type: Dict[str, Callable[[Context], Any]
Expand Down
73 changes: 53 additions & 20 deletions asphalt/core/event.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import re
from asyncio import ensure_future
from asyncio.queues import Queue
from collections import defaultdict
from inspect import isawaitable
from typing import Dict, Callable, Any, Sequence, Union, Iterable, Tuple

Expand All @@ -9,7 +11,7 @@

from asphalt.core.util import qualified_name

__all__ = ('Event', 'EventListener', 'EventSource')
__all__ = ('Event', 'EventListener', 'register_topic', 'EventSource')


class Event:
Expand Down Expand Up @@ -65,24 +67,56 @@ def __init__(self, event: Event, exceptions: Sequence[Tuple[EventListener, Excep
self.exceptions = exceptions


def register_topic(name: str, event_class: type = Event):
"""
Return a class decorator that registers an event topic on the given class.
A subclass may override an event topic by re-registering it with an event class that is a
subclass of the previously registered event class. Attempting to override the topic with an
incompatible class will raise a :exception:`TypeError`.
:param name: name of the topic (must consist of alphanumeric characters and ``_``)
:param event_class: the event class associated with this topic
"""
def wrapper(cls: type):
if not isinstance(cls, type) or not issubclass(cls, EventSource):
raise TypeError('cls must be a subclass of EventSource')

topics = cls.__dict__.get('_eventsource_topics')
if topics is None:
# Collect all the topics from superclasses
topics = cls._eventsource_topics = {}
for supercls in cls.__mro__[1:]:
supercls_topics = getattr(supercls, '_eventsource_topics', {})
topics.update(supercls_topics)

if name in topics and not issubclass(event_class, topics[name]):
existing_classname = qualified_name(topics[name])
new_classname = qualified_name(event_class)
raise TypeError('cannot override event class for topic "{}" -- event class {} is not '
'a subclass of {}'.format(name, new_classname, existing_classname))

topics[name] = event_class
return cls

assert check_argument_types()
assert re.match('[a-z0-9_]+', name), 'invalid characters in topic name'
assert issubclass(event_class, Event), 'event_class must be a subclass of Event'
return wrapper


class EventSource:
"""A mixin class that provides support for dispatching and listening to events."""

__slots__ = '_topics'
__slots__ = '_listeners'

# Provided in subclasses using @register_topic(...)
_eventsource_topics = {} # type: Dict[str, Any]

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._topics = {}

def _register_topics(self, topics: Dict[str, Any]) -> None:
"""
Register a number of supported topics and their respective event classes.
:param topics: a dictionary of topic -> event class
"""
for topic, event_class in topics.items():
self._topics[topic] = {'event_class': event_class, 'listeners': []}
self._listeners = defaultdict(list)

def add_listener(self, topics: Union[str, Iterable[str]], callback: Callable,
args: Sequence=(), kwargs: Dict[str, Any]=None) -> EventListener:
Expand All @@ -104,12 +138,12 @@ def add_listener(self, topics: Union[str, Iterable[str]], callback: Callable,
assert check_argument_types()
topics = (topics,) if isinstance(topics, str) else tuple(topics)
for topic in topics:
if topic not in self._topics:
if topic not in self._eventsource_topics:
raise LookupError('no such topic registered: {}'.format(topic))

handle = EventListener(self, topics, callback, args, kwargs or {})
for topic in topics:
self._topics[topic]['listeners'].append(handle)
self._listeners[topic].append(handle)

return handle

Expand All @@ -124,7 +158,7 @@ def remove_listener(self, handle: EventListener):
assert check_argument_types()
try:
for topic in handle.topics:
self._topics[topic]['listeners'].remove(handle)
self._listeners[topic].remove(handle)
except (KeyError, ValueError):
raise LookupError('listener not found') from None

Expand All @@ -149,19 +183,18 @@ async def dispatch(self, event: Union[str, Event], *args, **kwargs) -> None:
assert check_argument_types()
topic = event.topic if isinstance(event, Event) else event
try:
registration = self._topics[topic]
event_class = self._eventsource_topics[topic]
except KeyError:
raise LookupError('no such topic registered: {}'.format(topic)) from None

if isinstance(event, Event):
assert not args and not kwargs, 'passing extra arguments makes no sense here'
assert isinstance(event, registration['event_class']), 'event class mismatch'
assert isinstance(event, event_class), 'event class mismatch'
else:
event_class = registration['event_class']
event = event_class(self, topic, *args, **kwargs)

futures, exceptions = [], []
for listener in list(registration['listeners']):
for listener in list(self._listeners[topic]):
try:
retval = listener.callback(event, *listener.args, **listener.kwargs)
except Exception as e:
Expand Down
6 changes: 6 additions & 0 deletions docs/versionhistory.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
Version history
===============

This library adheres to `Semantic Versioning <http://semver.org/>`_.

**2.0.0**

- *BACKWARD INCOMPATIBLE* Dropped Python 3.4 support in order to make the code fully rely on the
Expand All @@ -11,14 +13,18 @@ Version history
As such, Asphalt components are no longer required to transparently work outside of the event
loop thread. Instead, use``asyncio_extras.threads.call_async()`` to call asynchronous code if
absolutely necessary.
- *BACKWARD INCOMPATIBLE* Removed the ``asphalt.command`` module from the public API
- *BACKWARD INCOMPATIBLE* Removed regular context manager support from the ``Context`` class
(asynchronous context manager support still remains)
- *BACKWARD INCOMPATIBLE* Modified event dispatch logic in ``EventSource`` to always run all
event listeners even if some listeners raise exceptions. A uniform exception is then raised
that contains all the exceptions and the listeners who raised them.
- *BACKWARD INCOMPATIBLE* Event topic registrations for ``EventSource`` subclasses are now done
using the ``@register_topic`` class decorator instead of the ``_register_topic()`` method
- Added the ability to listen to multiple topics in an EventSource with a single listener
- Added the ability to stream events from an EventSource
- Switched from argparse to click for the command line interface
- All classes and functions are now importable directly from ``asphalt.core``

**1.3.0**

Expand Down
55 changes: 51 additions & 4 deletions tests/test_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import pytest

from asphalt.core.event import EventSource, Event, EventListener, EventDispatchError, stream_events
from asphalt.core.event import (
EventSource, Event, EventListener, EventDispatchError, stream_events, register_topic)


class DummyEvent(Event):
Expand All @@ -12,11 +13,15 @@ def __init__(self, source, topic, *args, **kwargs):
self.kwargs = kwargs


@register_topic('event_a', DummyEvent)
@register_topic('event_b', DummyEvent)
class DummySource(EventSource):
pass


@pytest.fixture
def source():
event_source = EventSource()
event_source._register_topics({'event_a': DummyEvent, 'event_b': DummyEvent})
return event_source
return DummySource()


class TestEventListener:
Expand All @@ -30,6 +35,48 @@ def test_repr(self, handle):
"callback=test_event.TestEventListener.handle.<locals>.<lambda>, args=(), kwargs={})")


class TestRegisterTopic:
def test_incompatible_class(self):
"""
Test that attempting to use the @register_topic decorator on an incompatible class raises a
TypeError.
"""
target_class = type('TestType', (object,), {})
exc = pytest.raises(TypeError, register_topic('some_event', DummyEvent), target_class)
assert str(exc.value) == 'cls must be a subclass of EventSource'

def test_incompatible_override(self):
"""
Test that attempting to override an event topic with an incompatible Event subclass raises
a TypeError.
"""
target_class = type('TestType', (EventSource,), {})
register_topic('some_event', DummyEvent)(target_class)
exc = pytest.raises(TypeError, register_topic('some_event', Event), target_class)
assert str(exc.value) == ('cannot override event class for topic "some_event" -- event '
'class asphalt.core.event.Event is not a subclass of '
'test_event.DummyEvent')

@pytest.mark.asyncio
async def test_topic_override(self):
"""
Test that re-registering an event topic with a subclass of the original event class is
allowed.
"""
target_class = type('TestType', (EventSource,), {})
event_subclass = type('EventSubclass', (DummyEvent,), {})
register_topic('some_event', DummyEvent)(target_class)
register_topic('some_event', event_subclass)(target_class)
events = []
source = target_class()
source.add_listener('some_event', events.append)
await source.dispatch('some_event')
assert isinstance(events[0], event_subclass)


class TestEventSource:
def test_add_listener(self, source):
handle = source.add_listener('event_a', lambda: None)
Expand Down

0 comments on commit 852f163

Please sign in to comment.