From 997f283c1dde2c5b036ac0f2b8fb751797d8e28f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Peron?= Date: Mon, 18 Mar 2019 14:52:39 -0600 Subject: [PATCH] add manager code, test and doc --- README.rst | 128 +++- docs/source/contents/simpy-events.rst | 10 +- simpy_events/manager.py | 873 +++++++++++++++++++++++ tests/test_cases.py | 140 ++++ tests/test_manager.py | 955 ++++++++++++++++++++++++++ 5 files changed, 2102 insertions(+), 4 deletions(-) create mode 100644 simpy_events/manager.py create mode 100755 tests/test_cases.py create mode 100755 tests/test_manager.py diff --git a/README.rst b/README.rst index e9e5e41..94bb21f 100644 --- a/README.rst +++ b/README.rst @@ -16,11 +16,133 @@ simpy-events .. |python version| image:: https://img.shields.io/pypi/pyversions/simpy-events.svg :target: https://pypi.python.org/pypi/simpy-events -event system with simpy to decouple simulation code and increase reusability +event system with `SimPy`_ to decouple simulation code and increase reusability -( **WORK IN PROGRESS** ) +( >>>>>>> **WORK IN PROGRESS** <<<<<<< ) + + + +A basic example +======================= + +.. seealso:: `SimPy`_ is a process-based discrete-event simulation framework based on standard Python. + ++ Our simplified scenario is composed of: + + - satellites emitting signals + - receivers receiving and processing signals + ++ basic imports and creating the root namespace :: + + from simpy_events.manager import RootNameSpace + import simpy + + root = RootNameSpace() + ++ implementing a satellite model :: + + sat = root.ns('satellite') + + class Satellite: + chunk = 4 + + def __init__(self, name, data): + self.signal = sat.event('signal', sat=name) + self.data = tuple(map(str, data)) + + def process(self, env): + signal = self.signal + data = self.data + chunk = self.chunk + # slice data in chunks + for chunk in [data[chunk*i:chunk*i+chunk] + for i in range(int(len(data) / chunk))]: + event = env.timeout(1, ','.join(chunk)) + yield signal(event) + ++ implementing a receiver model :: + + receiver = root.ns('receiver') + signals = receiver.topic('signals') + + @signals.after + def receive_signal(context, event): + env = event.env + metadata = context.event.metadata + header = str({key: val for key, val in metadata.items() + if key not in ('name', 'ns')}) + env.process(process_signal(env, header, event.value)) + + def process_signal(env, header, signal): + receive = receiver.event('process') + for data in signal.split(','): + yield receive(env.timeout(0, f'{header}: {data}')) + ++ creating code to analyse what's going on :: + + @root.enable('analyse') + def new_process(context, event): + metadata = context.event.metadata + context = {key: str(val) for key, val in metadata.items()} + print(f'new signal process: {context}') + + @root.after('analyse') + def signal(context, event): + metadata = context.event.metadata + ns = metadata['ns'] + print(f'signal: {ns.path}: {event.value}') + ++ setting up our simulation :: + + root.topic('receiver::signals').extend([ + '::satellite::signal', + ]) + root.topic('analyse').extend([ + '::satellite::signal', + '::receiver::process', + ]) + + def run(env): + # create some actors + s1 = Satellite('sat1', range(8)) + s2 = Satellite('sat2', range(100, 108)) + env.process(s1.process(env)) + env.process(s2.process(env)) + + # execute + root.enabled = True + env.run() + ++ running the simulation :: + + new signal process: {'ns': '::satellite', 'name': 'signal', 'sat': 'sat1'} + new signal process: {'ns': '::satellite', 'name': 'signal', 'sat': 'sat2'} + signal: ::satellite: 0,1,2,3 + new signal process: {'ns': '::receiver', 'name': 'process'} + signal: ::satellite: 100,101,102,103 + new signal process: {'ns': '::receiver', 'name': 'process'} + signal: ::receiver: {'sat': 'sat1'}: 0 + signal: ::receiver: {'sat': 'sat2'}: 100 + signal: ::receiver: {'sat': 'sat1'}: 1 + signal: ::receiver: {'sat': 'sat2'}: 101 + signal: ::receiver: {'sat': 'sat1'}: 2 + signal: ::receiver: {'sat': 'sat2'}: 102 + signal: ::receiver: {'sat': 'sat1'}: 3 + signal: ::receiver: {'sat': 'sat2'}: 103 + signal: ::satellite: 4,5,6,7 + new signal process: {'ns': '::receiver', 'name': 'process'} + signal: ::satellite: 104,105,106,107 + new signal process: {'ns': '::receiver', 'name': 'process'} + signal: ::receiver: {'sat': 'sat1'}: 4 + signal: ::receiver: {'sat': 'sat2'}: 104 + signal: ::receiver: {'sat': 'sat1'}: 5 + signal: ::receiver: {'sat': 'sat2'}: 105 + signal: ::receiver: {'sat': 'sat1'}: 6 + signal: ::receiver: {'sat': 'sat2'}: 106 + signal: ::receiver: {'sat': 'sat1'}: 7 + signal: ::receiver: {'sat': 'sat2'}: 107 install and test ======================= @@ -87,4 +209,4 @@ https://github.com/loicpw .. _Read The Docs: http://simpy-events.readthedocs.io/en/latest/ - +.. _SimPy: https://simpy.readthedocs.org diff --git a/docs/source/contents/simpy-events.rst b/docs/source/contents/simpy-events.rst index 24effdd..f57ef54 100644 --- a/docs/source/contents/simpy-events.rst +++ b/docs/source/contents/simpy-events.rst @@ -7,4 +7,12 @@ event :ignore-module-all: :members: :private-members: - :special-members: __init__, __getitem__, __setitem__, __delitem__, __len__, __call__, __enter__, __exit__ \ No newline at end of file + :special-members: __init__, __getitem__, __setitem__, __delitem__, __len__, __call__, __enter__, __exit__ + +manager +---------------------------------------- +.. automodule:: simpy_events.manager + :ignore-module-all: + :members: + :private-members: + :special-members: __init__, __getitem__, __setitem__, __delitem__, __len__, __call__, __enter__, __exit__ diff --git a/simpy_events/manager.py b/simpy_events/manager.py new file mode 100644 index 0000000..5989989 --- /dev/null +++ b/simpy_events/manager.py @@ -0,0 +1,873 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from .event import Event, EventDispatcher +import collections +from functools import partial + + +class Handlers(collections.MutableSequence): + """ Holds a sequence of handlers. + + `Handlers` is a sequence object which holds handlers for a + specific hook in a topic. + + .. seealso:: `simpy_events.event.Event` + + `Handlers` behave like a `list` expect it's also callable so it + can be used as a decorator to append handlers to it. + """ + def __init__(self, lst=None): + self._lst = [] if lst is None else lst + + def __getitem__(self, index): + return self._lst[index] + + def __setitem__(self, index, value): + self._lst[index] = value + + def __delitem__(self, index): + del self._lst[index] + + def __len__(self): + return len(self._lst) + + def insert(self, index, value): + self._lst.insert(index, value) + + def __call__(self, fct): + """ append `fct` to the sequence. + + `Handlers` object can be used as a decorator to append a + handler to it. + """ + self.append(fct) + return fct + + +class Topic(collections.MutableSequence): + """ Holds a mapping of handlers to link to specific events. + + `Topic` is a sequence that contains names of events to be linked + automatically when they are created or the name of existing + events is added. + + When events are created they're registered by event type + (`EventType`), identified by a name. If that name is contained + in a `Topic` then the topic will be added to the + `simpy_events.event.Event`'s `topcis` sequence and the handlers + it contains will be called when the event is dispatched. + + a `Topic` carries a `dict` containing sequences of handlers for + specific hooks ('before', 'after'...), and this `dict` is added + to `simpy_events.event.Event`'s topics. The topic's dict is + added to an event's topics sequence either when the + `simpy_events.event.Event` is created or when the corresponding + event's type (name) is added to the `Topic`. + + `Topic`'s `dict` contains key:value pairs where keys are hook + names ('before', 'after'...) and values are `Handlers` objects. + The handler functions added to the `Topic` are added to the + `Handlers` objects. + + The topic is removed automatically from an + `simpy_events.event.Event` if the corresponding event type + (name) is removed from the `Topic`. + + .. seealso:: `simpy_events.event.Event`, `NameSpace.topic`, + `NameSpace.event` + """ + def __init__(self, ns, name): + """ initializes a `Topic` attached to `ns` by its name `name`. + + .. seealso:: `Topic` are expected to be initialized + automatically, see also `NameSpace.topic`. + + `ns` is the `NameSpace` instance that created and holds the + `Topic`. + + `name` is the name of the `Topic` and under which it's + identified in its parent `ns`. + """ + self._ns = ns + self._name = name + self._events = [] + self._topic = {} + + @property + def topic(self): + """ (read only) The `dict` that is added to event's `topics` """ + return self._topic + + @property + def ns(self): + """ (read only) The `NameSpace` that holds the `Topic` """ + return self._ns + + @property + def name(self): + """ (read only) The name of the `Topic` """ + return self._name + + def __getitem__(self, index): + """ return an event name added to the `Topic` """ + return self._events[index] + + def __setitem__(self, index, event): + """ add an event name to the `Topic` + + this will take care of removing the topic from the events + identified by the current event name at the specified + `index` + + then the new event name will be added to the sequence and + the corresponding events will be linked if instances + exist. + + .. note:: cannot use a `slice` as `index`, this will raise + a `NotImplementedError`. + """ + if isinstance(index, slice): + raise NotImplementedError('slice is not supported, only integer') + + # remove current + self._remove(self._events[index]) + # add new + self._events[index] = event + self._add(event) + + def _add(self, event): + et = self._ns.event_type(event) + et.add_topic(self._topic) + + def _remove(self, event): + et = self._ns.event_type(event) + et.remove_topic(self._topic) + + def __delitem__(self, index): + """ remove an event name from the `Topic` + + this will remove the topic from the events identified by the + event name at the removed `index`. + + .. note:: cannot use a `slice` as `index`, this will raise + a `NotImplementedError`. + """ + if isinstance(index, slice): + raise NotImplementedError('slice is not supported, only integer') + + self._remove(self._events[index]) + del self._events[index] + + def __len__(self): + return len(self._events) + + def insert(self, index, event): + """ insert an event name into the `Topic` + + The new event name is added to the sequence at the specified + `index` and the corresponding events are linked if instances + exist. + """ + self._events.insert(index, event) + self._add(event) + + def get_handlers(self, hook): + """ eq. to `Topic.handlers` but doesnt create the `Handlers` + + return the `Handlers` sequence or `None`. + """ + return self._topic.get(hook) + + def handlers(self, hook): + """ return the `Handlers` sequence for the hook `hook`. + + the `Handlers` sequence for a given hook (i.e 'before', + 'after'...) is created in a lazy way by the `Topic`. + + .. seealso:: `simpy_events.event.Event` for details about + hooks. + + Since `Handlers` can be used as a decorator itself to add a + handler to it, this method can be used as a decorator to + register a handler, for ex :: + + @topic.handlers('before') + def handler(context, data): + pass + + .. seealso:: + + `Topic.get_handlers` + + `Topic.enable` + + `Topic.disable` + + `Topic.before` + + `Topic.callbacks` + + `Topic.after` + """ + try: + return self._topic[hook] + except KeyError: + handlers = Handlers() + self._topic[hook] = handlers + return handlers + + +# add a convenience registering method for each known hook +# created properties simply return a `partial` method using +# `Topic.handlers` +_hooks = ( + 'before', + 'callbacks', + 'after', + 'enable', + 'disable', +) + +for _ in _hooks: + @property + def p(self, hook=_): + f"(read only) equivalent to `Topic.handlers`('{_}')" + return self.handlers(hook) + + setattr(Topic, _, p) + + +class EventsProperty: + """ Set an attribue value for a hierarchy of parents/children + + `EventsProperty` is used internally to automatically set the + value of a specific attribute from a parent down to a + hierarchy given the following rules: + + + the value of the parent is set recursively to children until + a child contains a not `None` value. + + + if the value of a given node is set to `None` then the first + parent whose value is not `None` will be used to replace the + value recursively. + + In other words `EventsProperty` ensures the a hierarchically set + value that can be overriden by children nodes. + + .. seealso:: `EventsPropertiesMixin` + """ + def __init__(self, name, value, parent): + """ creates a new hierarchical attribute linked to `parent` + + for each event added to this node its `name` attribute will + be set every time the applicable value is updated (this + `EventsProperty`'s value or a parent value depending on + whether the value is `None` or not). + """ + self.parent = parent + if parent is not None: + parent.children.append(self) + self.children = [] + self._name = name + self._events = [] + self._value = value + + @property + def value(self): + """ return the current value of this node in the hierarchy """ + return self._value + + @value.setter + def value(self, value): + """ set the value for this node in the hierarchy + + if `value` is `None` then the first value that is not `None` + up in the hierarchy will be used to set the attribute of + the events of this node and all the children whose value is + `None` recursively down the hierarchy. + + if `value` is not `None` then all the events attached to + this particular node and the events of all the children + whose value is `None` recursively down the hierarchy will + be updated. + """ + self._value = value + if value is None: + assert self.parent is not None, 'value cannot be None' + value = self._get_value() + self._propagate(value) + + def _get_value(self): + # get the first not `None` value in parents + if self._value is None: + parent = self + while(parent._value) is None: + parent = parent.parent + return parent._value + return self._value + + def _propagate(self, value): + # set value on the events of this node + self._set_value(value, self._events) + # forward `value` down to the hierarchy + children = self.children + for child in children: + if child.value is None: + child._propagate(value) + + def _set_value(self, value, events): + # set the attribute value to `value` for each event in `events` + name = self._name + for event in events: + setattr(event, name, value) + + def add_event(self, event): + """ add an event to this node + + the corresponding attribute will be hierarchically set + starting from this node in the hierarchy for the added + event. + """ + self._events.append(event) + self._set_value(self._get_value(), (event,)) + + def remove_event(self, event): + """ remove an event from the hierarchy. + + This doesn't modify the corresponding attribute. + """ + self._events.remove(event) + # TODO ==> value ? + + +class EventsPropertiesMixin: + """ Internally used mixin class to add `EventsProperty` instances + + This class add an `EventsProperty` instance for each attribute + name in `EventsPropertiesMixin._props` : + + + "dispatcher" + + "enabled" + + This is used to ensure a hierarchically set value for the + corresponding attribute of `simpy_events.event.Event` instances. + + .. seealso:: `NameSpace`, `EventType` + + For each attribute: + + + a `property` is used to set / get the value + + + the `EventsProperty` object is stored in a private attribute + using the name '_{attr_name}' (ex: "_dispatcher") + + Then the `EventsPropertiesMixin._add_event_properties` and + `EventsPropertiesMixin.remove_event_properties` methods can be + used in subclasses to add / remove an event to / from the + `EventsProperty` instances. + """ + _props = ( + 'dispatcher', + 'enabled', + ) + + def __init__(self, parent, **values): + """ `parent` is either `None` or a `EventsPropertiesMixin`. + + `values` are optional extra keyword args to initialize the + value of the `EventsProperty` objects (ex: dispatcher=...). + + For each managed attribute, the `EventsProperty` object is + stored in a private attribute using the name '_{attr_name}' + (ex: "_dispatcher"). + """ + + for name in self._props: + setattr(self, f'_{name}', EventsProperty( + name, + values.get(name), + None if parent is None else getattr(parent, f'_{name}') + )) + + def _add_event_properties(self, event): + """ used in subclasses to add a `simpy_events.event.Event`. + + This add the event to each contained `EventsProperty` object, + so the corresponding attribute is hierarchically set for the + event. + """ + for name in self._props: + getattr(self, f'_{name}').add_event(event) + + def _remove_event_properties(self, event): + """ used in subclasses to remove a `simpy_events.event.Event`. + + This remove the event from each contained `EventsProperty` + object. + """ + for name in self._props: + getattr(self, f'_{name}').remove_event(event) + + +# create getter and setter for each property in EventsPropertiesMixin +for _ in EventsPropertiesMixin._props: + @property + def p(self, name=_): + return getattr(self, f'_{name}').value + + @p.setter + def p(self, value, name=_): + getattr(self, f'_{name}').value = value + + setattr(EventsPropertiesMixin, _, p) + + +class EventType(EventsPropertiesMixin): + """ Link a set of `simpy_events.event.Event` instances to a name. + + `EventType` allows to define an *event type* identified by + a name in a given `NameSpace`, and create + `simpy_events.event.Event` instances from it, which will allow + to manage those instances as a group and share common + properties: + + + `Topic` objects can be added to the `EventType` and + then automatically linked to the `simpy_events.event.Event` + instances. + + + the `simpy_events.event.Event` instances are managed through + the `NameSpace`/`EventType` hierarchy that allows to manage + the `simpy_events.event.Event.dispatcher` and + `simpy_events.event.Event.enabled` values either for a given + `NameSpace` or a given `EventType`. + + + the `NameSpace` instance and the name of the `EventType` will + be given as metadata to the created events (see + `EventType.create`). + + .. todo:: remove event instance ? + """ + def __init__(self, ns, name): + """ initializes an `EventType` attached to `ns` by name `name`. + + .. seealso:: `EventType` are expected to be initialized + automatically, see also `NameSpace.event_type`. + + `ns` is the `NameSpace` instance that created and holds the + `EventType`. + + `name` is the name of the `EventType` and under which it's + identified in its parent `ns`. + """ + super().__init__(parent=ns) + self._metadata = { + 'ns': ns, + 'name': name, + } + self._name = name + self._ns = ns + self._instances = [] + self._topics = [] + + @property + def name(self): + """ (read only) The name of the `EventType` """ + return self._name + + @property + def ns(self): + """ (read only) The `NameSpace` that holds the `EventType` """ + return self._ns + + @property + def instances(self): + """ iter on created `simpy_events.event.Event` instances """ + return iter(self._instances) + + @property + def topics(self): + """ iter on added `Topic` objects """ + return iter(self._topics) + + def create(self, **metadata): + """ create a `simpy_events.event.Event` instance + + `metadata` are optional keyword args that will be forwarded + as it is to initialize the event. + + by default two keyword args are given to the + `simpy_events.event.Event` class: + + + `ns`: the `NameSpace` instance (`EventType.ns`) + + `name`: the name of the `EventType` (`EventType.name`) + + those values will be overriden by custom values if + corresponding keyword are contained in `metadata`. + + Once the event has been created the `Topic` objects linked + to the `EventType` are linked to the + `simpy_events.event.Event` instance. + + Then `simpy_events.event.Event.enabled` and + `simpy_events.event.Event.dispatcher` values for the created + event are synchronized with the hierarchy (`NameSpace`/ + `EventType`). + """ + # create event + kw = self._metadata.copy() + kw.update(metadata) + event = Event(**kw) + self._instances.append(event) + + # link topics + event.topics.extend(self._topics) + + # synchronize dispatcher and enabled properties + self._add_event_properties(event) + + return event + + def add_topic(self, topic): + """ add a `Topic` object to this `EventType`. + + This will immediately link the `Topic` to the existing and + future created `simpy_events.event.Event` instances for this + `EventType`, + """ + self._topics.append(topic) + for evt in self._instances: + evt.topics.append(topic) + + def remove_topic(self, topic): + """ remove a `Topic` object from this `EventType`. + + The `Topic` will immediately be unlinked from the existing + `simpy_events.event.Event` instances for this `EventType`. + """ + # since topics are dict we must check the id to remove the + # correct instance ({} == {} is True) + for i, tp in enumerate(self._topics): + if tp is topic: + del self._topics[i] + break + + for evt in self._instances: + topics = evt.topics + for i, tp in enumerate(topics): + if tp is topic: + del topics[i] + break + + +class NameSpace(EventsPropertiesMixin): + """ Define a hierarchical name space to link events and handlers. + + `NameSpace` provides a central node to automatically link + `simpy_events.event.Event` objects and their handlers. + + `NameSpace` allows to define `EventType` objects and create + `simpy_events.event.Event` instances associated with those + event types. + + It also allows to define `Topic` objects and link them to event + types. Handlers can then be attached to the `Topic` objects, + which will automatically link them to the related + `simpy_events.event.Event` instances. + + Then, `NameSpace` and `EventType` also allow to set / override + `simpy_events.event.Event.enabled` and + `simpy_events.event.Event.dispatcher` attributes at a given + point in the hierarchy. + + .. seealso:: `RootNameSpace` + """ + separator = '::' + + def __init__(self, parent, name, root, **kwargs): + """ `NameSpace` are expected to be initialized automatically + + .. seealso:: `NameSpace.ns`, `RootNameSpace` + + + `parent` is the parent `NameSpace` that created it + + `name` is the name of the `NameSpace` + + `root` is the `RootNameSpace` for the hierarchy + + additional `kwargs` are forwarded to + `EventsPropertiesMixin` + """ + super().__init__(parent=parent, **kwargs) + self._root = root + self._name = name + self._parent = parent + self._events = {} + self._topics = {} + self._children = {} + + @property + def name(self): + """ (read only) the name of the `NameSpace` + + example:: + + root = RootNameSpace(dispatcher) + ns = root.ns('first::second::third') + assert ns.name == 'third' + """ + return self._name + + @property + def path(self): + """ (read only) return the absolute path of in the hierarchy + + example:: + + root = RootNameSpace(dispatcher) + ns = root.ns('first::second::third') + assert ns.path == '::first::second::third' + + .. note:: **str(ns)** will return **ns.path** + """ + parent = self._parent + if parent is self._root: + return f'{self.separator}{self._name}' + return f'{parent.path}{self.separator}{self._name}' + + def __str__(self): + """ return `NameSpace.path` """ + return self.path + + def ns(self, name): + """ return or create the child `NameSpace` for `name` + + There is a unique `name`:`NameSpace` pair from a given + `NameSpace` instance. It's automatically created when + accessing it if it doesn't exist. + + `name` is either a relative or absolute name. An absolute + name begins with '::'. + + If `name` is absolute the `NameSpace` is referenced from + the `RootNameSpace` in the hierarchy, ex:: + + ns = root.ns('one') + assert ns.ns('::one::two') is root.ns('one::two') + + On the other hand a relative name references a `NameSpace` + from the node on which ns is called, ex:: + + ns = root.ns('one') + assert ns.ns('one::two') is not root.ns('one::two') + assert ns.ns('one::two') is ns.ns('one').ns('two') + assert ns.ns('one::two') is root.ns('one::one::two') + + .. note:: `name` cannnot be empty (`ValueError`), and + redundant separators ('::'), as well as trailing + separators will be ignored, ex:: + + ns1 = ns.ns('::::one::::two::::::::three::') + assert ns1 is ns.ns('::one::two::three') + + .. note:: ':' will be processed as a normal character, + ex:: + + assert ns.ns(':one').name == ':one' + ns1 = ns.ns(':one::two:::::three:') + ns2 = ns.ns(':one').ns('two').ns(':').ns('three:') + assert ns1 is ns2 + + .. seealso:: `NameSpace.path` + """ + sp = self.separator + + # query root if is absolute + if name.startswith(sp): + return self._root.ns(name[len(sp):]) + + # ignore heading, trailing and repeated separators and + # turn name into a sequence, for ex: '::::a::::::b::::c::d:::::' + # will turns to 'a', 'b', 'c', 'd', ':' + itername = iter(filter(None, name.split(sp))) + + # find / create the NameSpace instance in the hierarchy + ns = self + for _name in itername: + # find or create child and forward if sub names + try: + child = ns._children[_name] + except KeyError: + child = NameSpace(parent=ns, name=_name, root=self._root) + ns._children[_name] = child + ns = child + + if ns is self: + raise ValueError('name cannot be empty') + return ns + + def _split_parent(self, name): + # extract (NameSpace, ) from `name`, where + # `name` is expected to follow one of these patterns: + # + '::' + # + '' + sp = self.separator + + if sp in name: + ns, name = name.rsplit(sp, maxsplit=1) + # return root if name == '::' + return (self.ns(ns), name) if ns else (self._root, name) + # no ns specified in `name` + return self, name + + def _get_object(self, name, mapping, obj_type): + # find or create the object identified by the key `name` + # in the mapping `mapping`. Uses `obj_type` to create + # the object if it doesn't exist, in this case `obj_type` is + # called with the following arguments: + # + `self`: the `NameSpace` instance + # + `name` + # a `ValueError` is raised if `name` is empty + if not name: + raise ValueError('name cannot be empty') + + # find or create obj in the specified mapping + try: + obj = mapping[name] + except KeyError: + obj = obj_type(self, name) + mapping[name] = obj + return obj + + def event_type(self, name): + """ find or create an `EventType` + + `name` is either relative or absolute (see `NameSpace.ns` + for details). + + .. note:: the `EventType` objects have their own mapping + within a given `NameSpace`, this means an `EventType` + and a child `NameSpace` can have the same name, ex:: + + ns.event_type('domain') + ns.ns('domain') + + will create the `EventType` instance if it doesn't exist. + """ + ns, name = self._split_parent(name) + return ns._get_object(name, ns._events, EventType) + + def topic(self, name): + """ find or create an `Topic` + + `name` is either relative or absolute (see `NameSpace.ns` + for details). + + .. note:: the `Topic` objects have their own mapping + within a given `NameSpace`, this means an `Topic` + and a child `NameSpace` can have the same name, ex:: + + ns.topic('domain') + ns.ns('domain') + + will create the `Topic` instance if it doesn't exist. + """ + ns, name = self._split_parent(name) + return ns._get_object(name, ns._topics, Topic) + + def event(self, name, *args, **kwargs): + """ create a `simpy_events.event.Event` instance + + `name` is the name of the event type to use, it is either + relative or absolute (see `NameSpace.event_type`). + + additional `args` and `kwargs` are forwarded to + `EventType.create`. + + `NameSpace.event` is a convenience method, the following :: + + ns.event('my event') + + is equivalent to :: + + ns.event_type('my event').create() + + """ + return self.event_type(name).create(*args, **kwargs) + + def handlers(self, name, hook): + """ return the handlers for the topic `name` and the hook `hook` + + This is a convenience method that returns the `Handlers` + sequence for a given `hook` in a given `Topic`. + + .. seealso:: `NameSpace.topic`, `Topic.handlers` + + Then the following :: + + ns.handlers('my topic', 'before') + + is equivalent to :: + + ns.topic('my topic').handlers('before') + + .. note:: this method can be used as a decorator to + register a handler, for ex :: + + @ns.handlers('my topic', 'before') + def handler(context, data): + pass + """ + return self.topic(name).handlers(hook) + + +# add a convenience registering method for each known hook +# a property is added to NameSpace for each hook, which returns +# a partial method using NameSpace.handlers +for _ in _hooks: + @property + def p(self, hook=_): + f"""(read only) equivalent to `NameSpace.handlers` with hook='{_}' :: + + NameSpace.{_}('my topic') + + is equivalent to :: + + NameSpace.handlers('my topic', {_}) + """ + return partial(self.handlers, hook=hook) + + setattr(NameSpace, _, p) + + +class RootNameSpace(NameSpace): + """ The root `NameSpace` object in the hierarchy. + + the `RootNameSpace` differs from `NameSpace` because it has no + parent, as a consequence: + + + `RootNameSpace.path` returns `None` + + + `RootNameSpace.name` returns `None` + + + `RootNameSpace.dispatcher` cannot be `None` (i.e unspecified) + + a value can be specified when creating the instance, otherwise + a `simpy_events.event.EventDispatcher` will be created + + + `RootNameSpace.enabled` cannot be `None` (i.e unspecified) + + the value can be specifiied at creation (`False` by default) + """ + def __init__(self, dispatcher=None, enabled=False): + """ init the root `NameSpace` in the hierarchy + + + `dispatcher`: used (unless overriden in children) to set + `simpy_events.event.Event.dispatcher` + + if the value is not provided then a + `simpy_events.event.EventDispatcher` is created + + + `enabled`: used (unless overriden in children) to set + `simpy_events.event.Event.enabled` + + Default value is `False` + """ + if dispatcher is None: + dispatcher = EventDispatcher() + super().__init__(root=self, parent=None, name=None, + dispatcher=dispatcher, enabled=enabled) + + @NameSpace.path.getter + def path(self): + return None diff --git a/tests/test_cases.py b/tests/test_cases.py new file mode 100755 index 0000000..9b8ac58 --- /dev/null +++ b/tests/test_cases.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python +import pytest +from simpy_events.manager import RootNameSpace +#from simpy_events.event import EventDispatcher +import simpy +import sys + + +@pytest.fixture +def env(): + yield simpy.Environment() + + +@pytest.fixture +def root(): +# class Dispatcher(EventDispatcher): +# def __init__(self): +# self.verbose = False +# +# def dispatch(self, event, hook, data): +# if self.verbose: +# metadata = {key: str(item) for key, item +# in event.metadata.items()} +# try: +# # data is a simpy event +# print('dispatching', metadata, hook, data.value) +# except AttributeError: +# # data is something else +# print('dispatching', metadata, hook, data) +# super().dispatch(event, hook, data) +# +# return RootNameSpace(dispatcher=Dispatcher()) + return RootNameSpace() + + +def test_example_1(root, env, capsys): + + # satellite ------------------------------------------------------ # + sat = root.ns('satellite') + + class Satellite: + chunk = 4 + + def __init__(self, name, data): + self.signal = sat.event('signal', sat=name) + self.data = tuple(map(str, data)) + + def process(self, env): + signal = self.signal + data = self.data + chunk = self.chunk + # slice data in chunks + for chunk in [data[chunk*i:chunk*i+chunk] + for i in range(int(len(data) / chunk))]: + event = env.timeout(1, ','.join(chunk)) + yield signal(event) + + # receiver ------------------------------------------------------- # + receiver = root.ns('receiver') + signals = receiver.topic('signals') + + @signals.after + def receive_signal(context, event): + env = event.env + metadata = context.event.metadata + header = str({key: val for key, val in metadata.items() + if key not in ('name', 'ns')}) + env.process(process_signal(env, header, event.value)) + + def process_signal(env, header, signal): + receive = receiver.event('process') + for data in signal.split(','): + yield receive(env.timeout(0, f'{header}: {data}')) + + # analyse -------------------------------------------------------- # + @root.enable('analyse') + def new_process(context, event): + metadata = context.event.metadata + context = {key: str(val) for key, val in metadata.items()} + print(f'new signal process: {context}') + + @root.after('analyse') + def signal(context, event): + metadata = context.event.metadata + ns = metadata['ns'] + print(f'signal: {ns.path}: {event.value}') + + # ---------------------------------------------------------------- # + root.topic('receiver::signals').extend([ + '::satellite::signal', + ]) + root.topic('analyse').extend([ + '::satellite::signal', + '::receiver::process', + ]) + + def run(env): + # create some actors + s1 = Satellite('sat1', range(8)) + s2 = Satellite('sat2', range(100, 108)) + env.process(s1.process(env)) + env.process(s2.process(env)) + + # execute + root.enabled = True + env.run() + # ---------------------------------------------------------------- # + + run(env) + captured = capsys.readouterr() + print('test_example_1:\n', file=sys.stderr) + print(captured.out, file=sys.stderr) + assert captured.out == """\ +new signal process: {'ns': '::satellite', 'name': 'signal', 'sat': 'sat1'} +new signal process: {'ns': '::satellite', 'name': 'signal', 'sat': 'sat2'} +signal: ::satellite: 0,1,2,3 +new signal process: {'ns': '::receiver', 'name': 'process'} +signal: ::satellite: 100,101,102,103 +new signal process: {'ns': '::receiver', 'name': 'process'} +signal: ::receiver: {'sat': 'sat1'}: 0 +signal: ::receiver: {'sat': 'sat2'}: 100 +signal: ::receiver: {'sat': 'sat1'}: 1 +signal: ::receiver: {'sat': 'sat2'}: 101 +signal: ::receiver: {'sat': 'sat1'}: 2 +signal: ::receiver: {'sat': 'sat2'}: 102 +signal: ::receiver: {'sat': 'sat1'}: 3 +signal: ::receiver: {'sat': 'sat2'}: 103 +signal: ::satellite: 4,5,6,7 +new signal process: {'ns': '::receiver', 'name': 'process'} +signal: ::satellite: 104,105,106,107 +new signal process: {'ns': '::receiver', 'name': 'process'} +signal: ::receiver: {'sat': 'sat1'}: 4 +signal: ::receiver: {'sat': 'sat2'}: 104 +signal: ::receiver: {'sat': 'sat1'}: 5 +signal: ::receiver: {'sat': 'sat2'}: 105 +signal: ::receiver: {'sat': 'sat1'}: 6 +signal: ::receiver: {'sat': 'sat2'}: 106 +signal: ::receiver: {'sat': 'sat1'}: 7 +signal: ::receiver: {'sat': 'sat2'}: 107 +""" diff --git a/tests/test_manager.py b/tests/test_manager.py new file mode 100755 index 0000000..0595a7f --- /dev/null +++ b/tests/test_manager.py @@ -0,0 +1,955 @@ +#!/usr/bin/env python +import pytest +from simpy_events.manager import (Handlers, NameSpace, RootNameSpace, + EventType, Topic, _hooks) +from simpy_events.event import EventDispatcher, Event +import simpy +import sys + + +@pytest.fixture +def env(): + yield simpy.Environment() + + +@pytest.fixture +def root(): + class Dispatcher(EventDispatcher): + def dispatch(self, event, hook, data): + metadata = {key: str(item) for key, item + in event.metadata.items()} + try: + # data is a simpy event + print('dispatching', metadata, hook, data.value) + except AttributeError: + # data is something else + print('dispatching', metadata, hook, data) + super().dispatch(event, hook, data) + + return RootNameSpace(dispatcher=Dispatcher()) + + +def test_handlers_add_handlers(): + lst = [] + handlers = Handlers(lst) + assert len(handlers) == 0 + + @handlers + def handler(*args, **kw): + pass + + assert handlers[0] is handler + assert lst == [handler] + assert len(handlers) == 1 + assert list(handlers) == lst + + handlers.append(handler) + assert len(handlers) == 2 + assert lst == [handler, handler] + + +def test_handlers_remove_handlers(): + + def handler1(*args, **kw): + pass + + def handler2(*args, **kw): + pass + + lst = [handler1, handler2] + handlers = Handlers(lst) + assert len(handlers) == 2 + + del handlers[0] + assert len(handlers) == 1 + assert lst == [handler2] + + assert handlers.pop() is handler2 + assert len(handlers) == 0 + assert lst == [] + + with pytest.raises(ValueError): + handlers.remove(handler1) + with pytest.raises(IndexError): + del handlers[0] + with pytest.raises(IndexError): + handlers[0] + + +def test_handlers_replace_handlers(): + + def handler1(*args, **kw): + pass + + def handler2(*args, **kw): + pass + + lst = [handler1, handler2] + handlers = Handlers(lst) + assert len(handlers) == 2 + + handlers[0] = handler2 + assert len(handlers) == 2 + lst = [handler2, handler2] + + handlers[:] = [handler1, handler1] + assert len(handlers) == 2 + lst = [handler1, handler1] + + +def test_namespace_root_properties(root): + assert root.name is None + assert root.path is None + + +def test_namespace_create(root): + ns = root.ns('my ns') + assert root.ns('my ns') is ns + assert ns.name == 'my ns' + assert ns.path == '::my ns' + + +def test_namespace_absolute_path(root): + ns = root.ns('::my ns') + assert root.ns('my ns') is ns + assert ns.ns('::my ns') is ns + assert ns.ns('my ns') is not ns + assert ns.name == 'my ns' + assert ns.path == '::my ns' + + +def test_namespace_sub_level(root): + ns = root.ns('my ns') + sub = ns.ns('sub level') + assert sub.name == 'sub level' + assert sub.path == '::my ns::sub level' + assert sub is root.ns('my ns::sub level') + assert sub is ns.ns('sub level') + + +def test_namespace_sub_level_depth(root): + ns = root.ns('my ns') + sub = ns.ns('sub level::sub level2') + assert sub.name == 'sub level2' + assert sub.path == '::my ns::sub level::sub level2' + assert sub is root.ns('my ns::sub level::sub level2') + assert sub is ns.ns('sub level::sub level2') + assert sub is ns.ns('sub level').ns('sub level2') + + +def test_namespace_ignore_redundant_separator(root): + assert root.ns('my ns::') is root.ns('my ns') + assert root.ns('my ns::::') is root.ns('my ns') + assert root.ns('::my ns') is root.ns('my ns') + assert root.ns('::::my ns') is root.ns('my ns') + assert root.ns('::my ns::') is root.ns('my ns') + assert root.ns('::::my ns::::') is root.ns('my ns') + + +def test_namespace_colon_name(root): + assert root.ns('my ns:') is not root.ns('my ns') + assert root.ns('my ns:::') is root.ns('my ns').ns(':') + + +def test_namespace_ignore_redundant_separator_sub_levels(root): + assert root.ns('my ns::sub::') is root.ns('my ns::sub') + assert root.ns('my ns::::sub') is root.ns('my ns::sub') + assert root.ns('my ns::sub::::') is root.ns('my ns::sub') + assert root.ns('my ns::::sub::::') is root.ns('my ns::sub') + assert root.ns('my ns::::sub1::::sub2') is root.ns('my ns::sub1::sub2') + assert root.ns('my ns::::sub1::::sub2::') is root.ns('my ns::sub1::sub2') + + +def test_namespace_empty(root): + with pytest.raises(ValueError): + root.ns('') + with pytest.raises(ValueError): + root.ns('::') + with pytest.raises(ValueError): + root.ns('::::') + + +def test_create_event_type_empty(root): + with pytest.raises(ValueError): + et = root.event_type('') + with pytest.raises(ValueError): + et = root.event_type('my ns::') + with pytest.raises(ValueError): + et = root.event_type('::my ns::') + with pytest.raises(ValueError): + et = root.event_type('::my ns::sub::') + + +def test_create_event_type(root): + et = root.event_type('my event') + assert isinstance(et, EventType) + assert et.name == 'my event' + assert et.ns is root + assert list(et.instances) == [] + assert list(et.topics) == [] + + +def test_create_event_type_root_absolute_path(root): + et = root.event_type('my event') + assert et is root.event_type('::my event') + + +def test_create_event_type_sub_level(root): + et = root.event_type('my app::sub1::my event') + assert et.name == 'my event' + ns1 = root.ns('my app') + ns2 = root.ns('my app::sub1') + assert ns2 is et.ns + assert root.event_type('my app::sub1::my event') is et + assert root.event_type('::my app::sub1::my event') is et + assert ns2.event_type('my event') is et + assert ns1.event_type('sub1::my event') is et + + +def test_event_type_create_event(root): + et = root.event_type('my app::my event') + + evt = et.create() + assert isinstance(evt, Event) + assert list(et.instances) == [evt] + evt2 = et.create() + assert list(et.instances) == [evt, evt2] + + +def test_event_type_create_event_check_metadata(root): + et = root.event_type('my app::my event') + evt = et.create() + assert evt.metadata == { + 'name': 'my event', + 'ns': root.ns('my app'), + } + + +def test_event_type_create_event_with_metadata(root): + et = root.event_type('my app::my event') + evt = et.create(context='test') + assert evt.metadata == { + 'name': 'my event', + 'ns': root.ns('my app'), + 'context': 'test', + } + + +def test_event_type_create_event_override_metadata(root): + et = root.event_type('my app::my event') + evt = et.create(context='test', name='funky') + assert evt.metadata == { + 'name': 'funky', + 'ns': root.ns('my app'), + 'context': 'test', + } + + +@pytest.fixture +def dispatcher(): + class MyDispatcher: + def __init__(self, name): + self.name = name + + def dispatch(self, event, hook, data): + metadata = {key: str(item) for key, item + in event.metadata.items()} + print(self.name, ':', metadata, hook, data) + + return MyDispatcher + + +def test_dispatcher_property(root, capsys, dispatcher): + root.dispatcher = dispatcher('root') + ns = root.ns('my ns') + sub = ns.ns('sub') + et = root.event_type('my ns::sub::my event') + et2 = root.event_type('my ns::sub::my event2') + + print('# create events') + evt1 = et.create(id='E1') + evt1.enabled = True + evt2 = et.create(id='E2') + evt2.enabled = True + evt3 = et2.create(id='E3') + evt3.enabled = True + + def dispatch(): + evt1.dispatch('test') + evt2.dispatch('test') + evt3.dispatch('test') + + print('# change root dispatcher') + root.dispatcher = dispatcher('root2') + dispatch() + + print('# set dispatcher on sub') + sub.dispatcher = dispatcher('sub') + dispatch() + + print('# set dispatcher on event type') + et.dispatcher = dispatcher('my event') + et2.dispatcher = dispatcher('my event2') + dispatch() + + print('# set dispatcher on my ns') + ns.dispatcher = dispatcher('my ns') + dispatch() + + print('# remove dispatcher on sub') + sub.dispatcher = None + dispatch() + + print('# remove dispatcher on event type') + et.dispatcher = None + et2.dispatcher = None + dispatch() + + print('# remove dispatcher on my ns') + ns.dispatcher = None + dispatch() + + captured = capsys.readouterr() + print('test_dispatcher_property:\n', file=sys.stderr) + print(captured.out, file=sys.stderr) + assert captured.out == """\ +# create events +root : {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E1'} enable None +root : {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E2'} enable None +root : {'ns': '::my ns::sub', 'name': 'my event2', 'id': 'E3'} enable None +# change root dispatcher +root2 : {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E1'} test None +root2 : {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E2'} test None +root2 : {'ns': '::my ns::sub', 'name': 'my event2', 'id': 'E3'} test None +# set dispatcher on sub +sub : {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E1'} test None +sub : {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E2'} test None +sub : {'ns': '::my ns::sub', 'name': 'my event2', 'id': 'E3'} test None +# set dispatcher on event type +my event : {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E1'} test None +my event : {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E2'} test None +my event2 : {'ns': '::my ns::sub', 'name': 'my event2', 'id': 'E3'} test None +# set dispatcher on my ns +my event : {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E1'} test None +my event : {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E2'} test None +my event2 : {'ns': '::my ns::sub', 'name': 'my event2', 'id': 'E3'} test None +# remove dispatcher on sub +my event : {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E1'} test None +my event : {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E2'} test None +my event2 : {'ns': '::my ns::sub', 'name': 'my event2', 'id': 'E3'} test None +# remove dispatcher on event type +my ns : {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E1'} test None +my ns : {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E2'} test None +my ns : {'ns': '::my ns::sub', 'name': 'my event2', 'id': 'E3'} test None +# remove dispatcher on my ns +root2 : {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E1'} test None +root2 : {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E2'} test None +root2 : {'ns': '::my ns::sub', 'name': 'my event2', 'id': 'E3'} test None +""" + + +def test_enabled_property(root, capsys): + ns = root.ns('my ns') + sub = ns.ns('sub') + et = root.event_type('my ns::sub::my event') + et2 = root.event_type('my ns::sub::my event2') + + print('# create events') + evt1 = et.create(id='E1') + evt2 = et.create(id='E2') + evt3 = et2.create(id='E3') + + def dispatch(): + evt1.dispatch('test') + evt2.dispatch('test') + evt3.dispatch('test') + + print('# change root enabled') + root.enabled = True + dispatch() + + print('# set enabled on sub') + sub.enabled = False + dispatch() + + print('# set enabled on event type') + et.enabled = True + et2.enabled = True + dispatch() + + print('# set enabled on my ns') + ns.enabled = False + dispatch() + + print('# remove enabled on sub') + sub.enabled = None + dispatch() + + print('# remove enabled on event type') + et.enabled = None + et2.enabled = None + dispatch() + + print('# remove enabled on my ns') + ns.enabled = None + dispatch() + + captured = capsys.readouterr() + print('test_enabled_property:\n', file=sys.stderr) + print(captured.out, file=sys.stderr) + assert captured.out == """\ +# create events +# change root enabled +dispatching {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E1'} enable None +dispatching {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E2'} enable None +dispatching {'ns': '::my ns::sub', 'name': 'my event2', 'id': 'E3'} enable None +dispatching {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E1'} test None +dispatching {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E2'} test None +dispatching {'ns': '::my ns::sub', 'name': 'my event2', 'id': 'E3'} test None +# set enabled on sub +dispatching {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E1'} disable None +dispatching {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E2'} disable None +dispatching {'ns': '::my ns::sub', 'name': 'my event2', 'id': 'E3'} disable None +# set enabled on event type +dispatching {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E1'} enable None +dispatching {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E2'} enable None +dispatching {'ns': '::my ns::sub', 'name': 'my event2', 'id': 'E3'} enable None +dispatching {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E1'} test None +dispatching {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E2'} test None +dispatching {'ns': '::my ns::sub', 'name': 'my event2', 'id': 'E3'} test None +# set enabled on my ns +dispatching {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E1'} test None +dispatching {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E2'} test None +dispatching {'ns': '::my ns::sub', 'name': 'my event2', 'id': 'E3'} test None +# remove enabled on sub +dispatching {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E1'} test None +dispatching {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E2'} test None +dispatching {'ns': '::my ns::sub', 'name': 'my event2', 'id': 'E3'} test None +# remove enabled on event type +dispatching {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E1'} disable None +dispatching {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E2'} disable None +dispatching {'ns': '::my ns::sub', 'name': 'my event2', 'id': 'E3'} disable None +# remove enabled on my ns +dispatching {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E1'} enable None +dispatching {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E2'} enable None +dispatching {'ns': '::my ns::sub', 'name': 'my event2', 'id': 'E3'} enable None +dispatching {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E1'} test None +dispatching {'ns': '::my ns::sub', 'name': 'my event', 'id': 'E2'} test None +dispatching {'ns': '::my ns::sub', 'name': 'my event2', 'id': 'E3'} test None +""" + + +def test_ns_create_event_empty(root): + with pytest.raises(ValueError): + et = root.event('') + with pytest.raises(ValueError): + et = root.event('my ns::') + with pytest.raises(ValueError): + et = root.event('::my ns::') + with pytest.raises(ValueError): + et = root.event('::my ns::sub::') + + +def test_ns_create_event(root): + evt = root.event('my app::my event') + assert isinstance(evt, Event) + et = root.event_type('my app::my event') + assert list(et.instances) == [evt] + + +def test_ns_create_event_absolute_path(root): + evt = root.event('::my app::my event') + assert isinstance(evt, Event) + et = root.event_type('my app::my event') + assert list(et.instances) == [evt] + + +def test_ns_create_event_sub_levels(root): + evt = root.event('my app::sub::my event') + assert isinstance(evt, Event) + et = root.event_type('my app::sub::my event') + assert list(et.instances) == [evt] + + +def test_ns_create_event_in_root_absolute_path(root): + evt = root.event('::my event') + assert isinstance(evt, Event) + et = root.event_type('my event') + assert list(et.instances) == [evt] + + +def test_create_topic_empty(root): + with pytest.raises(ValueError): + root.topic('') + with pytest.raises(ValueError): + root.topic('my ns::') + with pytest.raises(ValueError): + root.topic('::my ns::') + with pytest.raises(ValueError): + root.topic('::my ns::sub::') + + +def test_create_topic(root): + topic = root.topic('my topic') + assert isinstance(topic, Topic) + assert topic.name == 'my topic' + assert topic.ns is root + assert list(topic) == [] + + +def test_create_topic_root_absolute_path(root): + topic = root.topic('my topic') + assert topic is root.topic('::my topic') + + +def test_create_topic_sub_level(root): + topic = root.topic('my app::sub1::my topic') + assert topic.name == 'my topic' + ns1 = root.ns('my app') + ns2 = root.ns('my app::sub1') + assert ns2 is topic.ns + assert root.topic('my app::sub1::my topic') is topic + assert root.topic('::my app::sub1::my topic') is topic + assert ns2.topic('my topic') is topic + assert ns1.topic('sub1::my topic') is topic + + +def test_topic_add_events(root): + topic = root.topic('my topic') + assert len(topic) == 0 + + topic.append('my event') + assert len(topic) == 1 + assert 'my event' in topic + + topic.append('my event 2') + assert len(topic) == 2 + assert 'my event 2' in topic + + assert list(topic) == ['my event', 'my event 2'] + + +def test_topic_add_events_can_add_same(root): + topic = root.topic('my topic') + topic.append('my event') + topic.append('my event') + topic.append('my app::my event') + topic.append('my app::my event') + + assert list(topic) == ['my event', 'my event', + 'my app::my event', 'my app::my event'] + + +def test_topic_add_events_preserve_items(root): + topic = root.topic('my app::sub::my topic') + topic.append('my event') + topic.append('sub2::my event') + topic.append('::my app::sub::my event2') + topic.append('::my other app::my event') + topic.append(':::::my other app::::sub:::my event') + + assert list(topic) == [ + 'my event', + 'sub2::my event', + '::my app::sub::my event2', + '::my other app::my event', + ':::::my other app::::sub:::my event', + ] + + +def test_topic_remove_events(root): + topic = root.topic('my topic') + topic.append('my event') + topic.append('my event 2') + topic.append('my app::sub::my event') + assert len(topic) == 3 + + del topic[1] + assert len(topic) == 2 + assert 'my event' in topic + + topic.remove('my event') + assert len(topic) == 1 + + topic.remove('my app::sub::my event') + assert len(topic) == 0 + + +def test_topic_remove_events_not_in_topic(root): + topic = root.topic('my app::my topic') + topic.append('my event') + topic.append('my app::my event2') + + with pytest.raises(ValueError): + topic.remove('my event 2') + with pytest.raises(ValueError): + topic.remove('my app::my event') + with pytest.raises(ValueError): + topic.remove('my event2') + with pytest.raises(IndexError): + del topic[2] + + +def test_topic_get_event_not_in_topic(root): + topic = root.topic('my app::my topic') + topic.append('my event') + topic.append('my app::my event2') + + with pytest.raises(IndexError): + topic[2] + with pytest.raises(ValueError): + topic.index('my other event') + with pytest.raises(ValueError): + topic.index('my app::my event') + with pytest.raises(ValueError): + topic.index('my event2') + + +def test_topic_get_use_slice(root): + topic = root.topic('my topic') + lst = [ + 'my event', + 'my event 2', + 'my event 3', + 'my event 4', + ] + topic.extend(lst) + + assert topic[:] == lst + assert topic[1:3] == ['my event 2', 'my event 3'] + + +def test_topic_set_use_slice(root): + topic = root.topic('my topic') + lst = [ + 'my event', + 'my event 2', + 'my event 3', + 'my event 4', + ] + + with pytest.raises(NotImplementedError): + topic[:] = lst + + assert len(topic) == 0 + + +def test_topic_del_use_slice(root): + topic = root.topic('my topic') + lst = [ + 'my event', + 'my event 2', + 'my event 3', + 'my event 4', + ] + topic.extend(lst) + + with pytest.raises(NotImplementedError): + del topic[:] + + assert list(topic) == lst + + +def test_topic_set_invalid_index(root): + topic = root.topic('my topic') + lst = [ + 'my event', + 'my event 2', + 'my event 3', + 'my event 4', + ] + topic.extend(lst) + + with pytest.raises(IndexError): + topic[99] = 'my event 99' + + assert list(topic) == lst + + +def test_topic_replace_events(root): + topic = root.topic('my topic') + topic.append('my event') + topic.append('my event 2') + + topic[1] = 'my event 3' + assert list(topic) == ['my event', 'my event 3'] + + +@pytest.fixture +def event_samples(): + def met(root, *event_types): + """ creates an event type and two instances / event type for each """ + rv = [] + for evt in event_types: + rv.append(root.event_type(evt)) + # create two Event instances for each event type + root.event(evt) + root.event(evt) + return rv if len(rv) > 1 else rv[0] + + return met + + +def check_topic_in_events(topic, events, nbr=1): + """ check topic is attached 'nbr' times to all event instances in events """ + topic = topic._topic + for ev in events: + found = 0 + for tp in ev.topics: + if topic is tp: + found += 1 + assert found == nbr, (f'{topic} (id: {id(topic)}) found {found} times ' + f'in {ev}, expected {nbr} times; event topics: ' + f'{ev.topics} (ids: {map(id, ev.topics)})') + + +def test_link_topic_and_events_add_events_same_ns(root, + event_samples): + topic1 = root.topic('my app::my topic') + topic2 = root.topic('my app::my topic2') + et1, et2 = event_samples(root, 'my app::my event', 'my app::my event2') + topic2.append('my event') + topic1.append('my event') + check_topic_in_events(topic1, et1.instances) + check_topic_in_events(topic2, et1.instances) + topic1.append('my event2') + topic2.append('my event2') + check_topic_in_events(topic1, et2.instances) + check_topic_in_events(topic2, et2.instances) + + +def test_link_topic_and_events_add_events_with_ns(root, + event_samples): + topic1 = root.topic('my app::my topic') + et1, et2 = event_samples(root, 'my app::my event', 'my other app::my event') + topic1.append('::my app::my event') + topic1.append('::my other app::my event') + check_topic_in_events(topic1, et1.instances) + check_topic_in_events(topic1, et2.instances) + + +def test_link_topic_and_events_add_events_with_ns_sub_level(root, + event_samples): + topic1 = root.topic('my app::my topic') + topic2 = root.topic('my app::my topic2') + topic3 = root.topic('my app::my topic3') + et1, et2 = event_samples(root, 'my app::sub::my event', 'my other app::sub::my event') + topic1.append('sub::my event') + topic2.append('::my app::sub::my event') + topic3.append('::my other app::sub::my event') + check_topic_in_events(topic1, et1.instances) + check_topic_in_events(topic2, et1.instances) + check_topic_in_events(topic3, et2.instances) + + +def test_link_topic_and_events_add_events_before(root, + event_samples): + topic1 = root.topic('my app::my topic') + topic2 = root.topic('my app::my topic2') + topic1.append('my event') + topic1.append('::my app::my event2') + topic2.append('my event') + et1, et2 = event_samples(root, 'my app::my event', 'my app::my event2') + check_topic_in_events(topic1, et1.instances) + check_topic_in_events(topic2, et1.instances) + check_topic_in_events(topic1, et2.instances) + + +def test_link_topic_and_events_add_events_X(root, + event_samples): + topic1 = root.topic('my app::my topic') + et1 = event_samples(root, 'my app::my event') + topic1.append('my event') + topic1.append('::my app::my event') + topic1.append('my event') + topic1.append('::my app::my event') + check_topic_in_events(topic1, et1.instances, 4) + + +def test_link_topic_and_events_remove_events_same_ns(root, + event_samples): + topic1 = root.topic('my app::my topic') + topic2 = root.topic('my app::my topic2') + et1 = event_samples(root, 'my app::my event') + topic1.append('my event') + topic1.append('my event') + topic2.append('my event') + + topic1.remove('my event') + check_topic_in_events(topic1, et1.instances) + check_topic_in_events(topic2, et1.instances) + topic2.remove('my event') + check_topic_in_events(topic1, et1.instances) + check_topic_in_events(topic2, et1.instances, 0) + topic1.remove('my event') + check_topic_in_events(topic1, et1.instances, 0) + + +def test_link_topic_and_events_remove_events_with_ns(root, + event_samples): + topic1 = root.topic('my app::my topic') + et1, et2 = event_samples(root, 'my app::my event', 'my other app::my event') + topic1.append('my event') + topic1.append('::my app::my event') + topic1.append('::my other app::my event') + + topic1.remove('::my app::my event') + check_topic_in_events(topic1, et1.instances) + check_topic_in_events(topic1, et2.instances) + topic1.remove('my event') + check_topic_in_events(topic1, et1.instances, 0) + check_topic_in_events(topic1, et2.instances) + topic1.remove('::my other app::my event') + check_topic_in_events(topic1, et2.instances, 0) + + +def test_link_topic_and_events_remove_events_with_ns_sub_level(root, + event_samples): + topic1 = root.topic('my app::my topic') + et1, et2 = event_samples(root, 'my app::sub::my event', 'my other app::sub::my event') + topic1.append('sub::my event') + topic1.append('::my app::sub::my event') + topic1.append('::my other app::sub::my event') + + topic1.remove('sub::my event') + check_topic_in_events(topic1, et1.instances) + check_topic_in_events(topic1, et2.instances) + topic1.remove('::my app::sub::my event') + check_topic_in_events(topic1, et1.instances, 0) + check_topic_in_events(topic1, et2.instances) + topic1.remove('::my other app::sub::my event') + check_topic_in_events(topic1, et2.instances, 0) + + +def test_link_topic_and_events_replace_events_same_ns(root, + event_samples): + topic1 = root.topic('my app::my topic') + et1, et2, et3 = event_samples(root, + 'my app::my event', + 'my app::my event2', + 'my app::my event3') + lst = [ + 'my event', + 'my event2', + 'my event3', + ] + topic1.extend(lst) + + topic1[1] = 'my event3' + check_topic_in_events(topic1, et1.instances, 1) + check_topic_in_events(topic1, et2.instances, 0) + check_topic_in_events(topic1, et3.instances, 2) + + +def test_link_topic_and_events_replace_events_with_ns(root, + event_samples): + topic1 = root.topic('my app::my topic') + et1, et2 = event_samples(root, + 'my app::my event', + 'my other app::my event') + lst = [ + 'my event', + '::my app::my event', + '::my other app::my event', + ] + topic1.extend(lst) + + topic1[0] = '::my other app::my event' + check_topic_in_events(topic1, et1.instances, 1) + check_topic_in_events(topic1, et2.instances, 2) + + +def test_link_topic_and_events_replace_events_with_ns_sub_level(root, + event_samples): + topic1 = root.topic('my app::my topic') + et1, et2, et3 = event_samples(root, + 'my app::sub::my event', + 'my app::sub::my event2', + 'my app::sub::my event3') + lst = [ + 'sub::my event', + 'sub::my event2', + '::my app::sub::my event3', + ] + topic1.extend(lst) + + topic1[1] = 'sub::my event3' + topic1[2] = '::my app::sub::my event' + check_topic_in_events(topic1, et1.instances, 2) + check_topic_in_events(topic1, et2.instances, 0) + check_topic_in_events(topic1, et3.instances, 1) + + +def test_topic_handlers(root): + topic = root.topic('my app::my topic') + handlers = topic.handlers('before') + assert isinstance(handlers, Handlers) + assert topic.handlers('before') is handlers + assert topic.handlers('after') is not handlers + + +def test_topic_get_handlers(root): + topic = root.topic('my app::my topic') + assert topic.get_handlers('before') is None + handlers = topic.handlers('before') + assert topic.get_handlers('before') is handlers + + +@pytest.fixture +def hooks(): + return _hooks + + +def test_topic_hooks_property(root, hooks): + topic = root.topic('my app::my topic') + for hook in hooks: + assert getattr(topic, hook) is topic.handlers(hook) + + +def test_event_type_create_event_enabled_dispatched_topics(root, capsys): + """ check topics are already linked to the event when 'enable' is + dispatched when creating the event and the event is enabled. + """ + topic = root.topic('my app::topic') + topic.append('my event') + root.event_type('my app::my event').enabled = True + + @topic.enable + def on_enable(context, data): + print('on_enable:', context.hook, context.event.metadata['name']) + + root.event('my app::my event') + captured = capsys.readouterr() + assert captured.out == """\ +dispatching {'ns': '::my app', 'name': 'my event'} enable None +on_enable: enable my event +""" + + +def test_ns_handlers(root): + ns = root.ns('my ns') + topic = ns.topic('my topic') + handlers = topic.handlers('before') + assert ns.handlers('my topic', 'before') is handlers + + +def test_ns_handlers_other_ns(root): + ns = root.ns('my ns') + topic = root.topic('my other ns::my topic') + handlers = topic.handlers('before') + assert ns.handlers('::my other ns::my topic', 'before') is handlers + + +def test_ns_hooks_property(root, hooks): + ns = root.ns('my ns') + topic = root.topic('my ns::my topic') + for hook in hooks: + assert getattr(ns, hook)('my topic') is topic.handlers(hook) + + +def test_ns_hooks_property_other_ns(root, hooks): + ns = root.ns('my ns') + topic = root.topic('my other ns::my topic') + for hook in hooks: + assert (getattr(ns, hook)('::my other ns::my topic') + is topic.handlers(hook))