diff --git a/.bumpversion.cfg b/.bumpversion.cfg index b1070fb..32d1f49 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -4,6 +4,11 @@ message = Bump version to {new_version} commit = True tag = True + +[bumpversion:file:docs/installation.rst] +search = {current_version} +replace = {new_version} + [bumpversion:file:setup.py] search = version="{current_version}" replace = version="{new_version}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 28d5e0d..35bef91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* Added examples and more detailed documentation for installation and usage. + ### Changed * Fixed bug failing to get a `str` representation of `Message` on `ipy` diff --git a/docs/api.rst b/docs/api.rst index e014675..6f07e8b 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -6,3 +6,5 @@ API Reference :maxdepth: 1 api/compas_eve + api/compas_eve.mqtt + api/compas_eve.memory diff --git a/docs/api/compas_eve.memory.rst b/docs/api/compas_eve.memory.rst new file mode 100644 index 0000000..6820523 --- /dev/null +++ b/docs/api/compas_eve.memory.rst @@ -0,0 +1,2 @@ + +.. automodule:: compas_eve.memory diff --git a/docs/api/compas_eve.mqtt.rst b/docs/api/compas_eve.mqtt.rst new file mode 100644 index 0000000..d8a3532 --- /dev/null +++ b/docs/api/compas_eve.mqtt.rst @@ -0,0 +1,2 @@ + +.. automodule:: compas_eve.mqtt diff --git a/docs/api/compas_eve.rst b/docs/api/compas_eve.rst index 8eb8ded..8ce0ba8 100644 --- a/docs/api/compas_eve.rst +++ b/docs/api/compas_eve.rst @@ -1,2 +1,5 @@ +******************************************************************************** +compas_eve +******************************************************************************** .. automodule:: compas_eve diff --git a/docs/conf.py b/docs/conf.py index 6c3a3e1..1bc0cd4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -89,6 +89,18 @@ def setup(app): # autosummary options autosummary_generate = True +autosummary_mock_imports = [ + "System", + "clr", + "Eto", + "Rhino", + "Grasshopper", + "scriptcontext", + "rhinoscriptsyntax", + "bpy", + "bmesh", + "mathutils", +] # napoleon options @@ -161,14 +173,17 @@ def linkcode_resolve(domain, info): obj = getattr(module, obj_name) attr = getattr(obj, attr_name) if inspect.isfunction(attr): - filename = inspect.getmodule(obj).__name__.replace(".", "/") - lineno = inspect.getsourcelines(attr)[1] + try: + filename = inspect.getmodule(obj).__name__.replace(".", "/") + lineno = inspect.getsourcelines(attr)[1] + except IOError: + return None else: return None else: return None - return f"https://github.com/compas-dev/compas_eve/blob/master/src/{filename}.py#L{lineno}" + return f"https://github.com/gramaziokohler/compas_eve/blob/main/src/{filename}.py#L{lineno}" # extlinks @@ -185,9 +200,9 @@ def linkcode_resolve(domain, info): "package_title": project, "package_version": release, "package_author": "Gonzalo Casas", - "package_docs": "https://compas-dev.github.io/compas_eve/", - "package_repo": "https://github.com/compas-dev/compas_eve", - "package_old_versions_txt": "https://compas-dev.github.io/compas_eve/doc_versions.txt", + "package_docs": "https://gramaziokohler.github.io/compas_eve/", + "package_repo": "https://github.com/gramaziokohler/compas_eve", + "package_old_versions_txt": "https://gramaziokohler.github.io/compas_eve/doc_versions.txt", } html_context = {} diff --git a/docs/examples.rst b/docs/examples.rst index 49e8715..04940a9 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -2,9 +2,81 @@ Examples ******************************************************************************** -.. toctree:: - :maxdepth: 1 - :titlesonly: - :glob: +.. currentmodule:: compas_eve - examples/* +The main feature of ``compas_eve`` is to allow communication between different +parts of a program using messages. These messages are sent around using a +publisher/subscriber model, or pub/sub for short. In pub/sub communication, +messages are not sent directly from a sender to a receiver, instead, they are +sent to a :class:`Topic`. A topic is like a mailbox, the :class:`Publisher` +sends messages to the topic without the need for a subcriber to be +actively listening for messages, and also the :class:`Subscriber` can start +listening for messages on a topic without the need for any publisher to be +currently sending anything. + +This creates a highly decoupled communication model that facilitates the creation +of complex software with simple code. + +An additional benefit of pub/sub is that it is not limited to 1-to-1 communication: +on a single topic, there can be multiple publishers and multiple subscribers all +communicating at the same time without additional coordination. + +Hello World +----------- + +Let's see a **Hello World** example of this type of communication using ``compas_eve``. +This example is very contrived because both the publisher and the subscriber are defined +in the same script and the same thread. + +.. literalinclude :: examples/01_hello_world.py + :language: python + +This example is the simplest possible, and it only shows the main concepts needed +to communicate. In particular, ``compas_eve`` uses by default an **in-memory transport** +for the messages, this means that messages are can only be received within the same program. + +Hello Threaded World +-------------------- + +Let's try to extend this first example and add multiple threads to illustrate +multi-threaded communication: + +.. literalinclude :: examples/02_hello_threaded_world.py + :language: python + +This get more interesting! Now the publisher and subscriber are in separate threads. However, +the in-memory transport is limited to *same-process*. This means that if we launch this +script twice, the messages will not jump from one process to the other. +In other words, if we want to communicate with a subscriber on a different process on the machine, +or even on a completely separate machine, we need to take an extra step. + +Hello Distributed World +----------------------- + +Fortunately, it is very easy to extend our example and enable communication across processes, machines, +networks, continents, anything that is connected to the Internet! + +The only difference is that we are going to configure a different :class:`Transport` implementation for +our messages. In this case, we will use the MQTT transport method. `MQTT `_ +is a network protocol very popular for IoT applications because of its lightweight. + +We are going to split the code and create one script for sending messages with a publisher and a different +one for receiving. This will enable us to start the two examples at the same time from different windows, or +potentially from different machines! + +First the publisher example: + +.. literalinclude :: examples/03_hello_distributed_world_pub.py + :language: python + +And now the subscriber example: + +.. literalinclude :: examples/03_hello_distributed_world_sub.py + :language: python + +You can start both programs in two completely different terminal windows, +or even completely different computers and they will be able to communicate! + +And since pub/sub allows any number of publishers and any number of +subscriber per topic, you can start the same scripts more than once and the +messages will be received and send multiple times! diff --git a/docs/examples/01_hello_world.py b/docs/examples/01_hello_world.py new file mode 100644 index 0000000..d46f5a2 --- /dev/null +++ b/docs/examples/01_hello_world.py @@ -0,0 +1,19 @@ +import time + +from compas_eve import Message +from compas_eve import Publisher +from compas_eve import Subscriber +from compas_eve import Topic + + +topic = Topic("/compas_eve/hello_world/", Message) + +publisher = Publisher(topic) +subcriber = Subscriber(topic, callback=lambda msg: print(f"Received message: {msg.text}")) +subcriber.subscribe() + +for i in range(20): + msg = Message(text=f"Hello world #{i}") + print(f"Publishing message: {msg.text}") + publisher.publish(msg) + time.sleep(1) diff --git a/docs/examples/02_hello_threaded_world.py b/docs/examples/02_hello_threaded_world.py new file mode 100644 index 0000000..ba986e3 --- /dev/null +++ b/docs/examples/02_hello_threaded_world.py @@ -0,0 +1,37 @@ +import time +from threading import Thread + +from compas_eve import Message +from compas_eve import Publisher +from compas_eve import Subscriber +from compas_eve import Topic + +topic = Topic("/compas_eve/hello_world/", Message) + + +def start_publisher(): + publisher = Publisher(topic) + + for i in range(20): + msg = Message(text=f"Hello world #{i}") + print(f"Publishing message: {msg.text}") + publisher.publish(msg) + time.sleep(1) + + +def start_subscriber(): + subcriber = Subscriber(topic, callback=lambda msg: print(f"Received message: {msg.text}")) + subcriber.subscribe() + + +# Define one thread for each +t1 = Thread(target=start_subscriber) +t2 = Thread(target=start_publisher) + +# Start both threads +t1.start() +t2.start() + +# Wait until both threads complete +t1.join() +t2.join() diff --git a/docs/examples/03_hello_distributed_world_pub.py b/docs/examples/03_hello_distributed_world_pub.py new file mode 100644 index 0000000..b977ba4 --- /dev/null +++ b/docs/examples/03_hello_distributed_world_pub.py @@ -0,0 +1,17 @@ +import time + +from compas_eve import Message +from compas_eve import Publisher +from compas_eve import Topic +from compas_eve.mqtt import MqttTransport + +topic = Topic("/compas_eve/hello_world/", Message) +tx = MqttTransport("broker.hivemq.com") + +publisher = Publisher(topic, transport=tx) + +for i in range(20): + msg = Message(text=f"Hello world #{i}") + print(f"Publishing message: {msg.text}") + publisher.publish(msg) + time.sleep(1) diff --git a/docs/examples/03_hello_distributed_world_sub.py b/docs/examples/03_hello_distributed_world_sub.py new file mode 100644 index 0000000..8b924d6 --- /dev/null +++ b/docs/examples/03_hello_distributed_world_sub.py @@ -0,0 +1,16 @@ +import time + +from compas_eve import Message +from compas_eve import Subscriber +from compas_eve import Topic +from compas_eve.mqtt import MqttTransport + +topic = Topic("/compas_eve/hello_world/", Message) +tx = MqttTransport("broker.hivemq.com") + +subcriber = Subscriber(topic, callback=lambda msg: print(f"Received message: {msg.text}"), transport=tx) +subcriber.subscribe() + +print("Waiting for messages, press CTRL+C to cancel") +while True: + time.sleep(1) diff --git a/docs/examples/PLACEHOLDER b/docs/examples/PLACEHOLDER deleted file mode 100644 index e69de29..0000000 diff --git a/docs/index.rst b/docs/index.rst index 44db3af..64366c4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,10 +1,10 @@ ******************************************************************************** -compas_eve +Event Extensions for COMPAS ******************************************************************************** .. rst-class:: lead -COMPAS Event Extensions: adds event-based communication infrastructure to the COMPAS framework. +``compas_eve`` adds event-based communication infrastructure to the COMPAS framework. .. .. figure:: /_images/ :figclass: figure @@ -15,12 +15,11 @@ Table of Contents ================= .. toctree:: - :maxdepth: 3 + :maxdepth: 2 :titlesonly: Introduction installation - tutorial examples api license diff --git a/docs/installation.rst b/docs/installation.rst index a2d3ef9..2c3be84 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -1,3 +1,117 @@ ******************************************************************************** Installation ******************************************************************************** + +.. highlight:: bash + +**COMPAS EVE** can be easily installed on multiple platforms, +using popular package managers such as conda or pip. + +Install with conda +================== + +The recommended way to install **COMPAS EVE** is with `conda `_. +For example, create an environment named ``project_name`` and install ``compas_eve``. + +:: + + conda create -n project_name -c conda-forge compas_eve + +Afterwards, simply activate the environment and run the following +command to check if the installation process was successful. + +.. code-block:: bash + + conda activate project_name + python -m compas_eve + +.. code-block:: none + + COMPAS EVE v0.2.1 is installed! + +You are ready to use **COMPAS EVE**! + +Installation options +-------------------- + +Install COMPAS EVE in an environment with a specific version of Python. + +.. code-block:: bash + + conda create -n project_name python=3.8 compas_eve + +Install COMPAS EVE in an existing environment. + +.. code-block:: bash + + conda install -n project_name compas_eve + +Install with pip +================ + +Install COMPAS EVE using ``pip`` from the Python Package Index. + +.. code-block:: bash + + pip install compas_eve + +Install an editable version from local source. + +.. code-block:: bash + + cd path/to/compas_eve + pip install -e . + +Note that installation with ``pip`` is also possible within a ``conda`` environment. + +.. code-block:: bash + + conda activate project_name + pip install -e . + + +Update with conda +================= + +To update COMPAS EVE to the latest version with ``conda`` + +.. code-block:: bash + + conda update compas_eve + +To switch to a specific version + +.. code-block:: bash + + conda install compas_eve=0.2.1 + + +Update with pip +=============== + +If you installed COMPAS EVE with ``pip`` the update command is the following + +.. code-block:: bash + + pip install --upgrade compas_eve + +Or to switch to a specific version + +.. code-block:: bash + + pip install compas_eve==0.2.1 + + +Working in Rhino +================ + +To make **COMPAS EVE** available inside Rhino, open the *command prompt*, +activate the appropriate environment, and type the following: + +:: + + python -m compas_rhino.install + +Open Rhino, start the Python script editor, type ``import compas_eve`` and +run it to verify that your installation is working. + diff --git a/docs/tutorial.rst b/docs/tutorial.rst deleted file mode 100644 index 34e1184..0000000 --- a/docs/tutorial.rst +++ /dev/null @@ -1,3 +0,0 @@ -******************************************************************************** -Tutorial -******************************************************************************** diff --git a/src/compas_eve/__init__.py b/src/compas_eve/__init__.py index 3bb6463..4848e7d 100644 --- a/src/compas_eve/__init__.py +++ b/src/compas_eve/__init__.py @@ -6,9 +6,21 @@ .. currentmodule:: compas_eve -.. toctree:: - :maxdepth: 1 - +Classes +======= + +.. autosummary:: + :toctree: generated/ + :nosignatures: + + Message + Topic + Publisher + Subscriber + Transport + InMemoryTransport + get_default_transport + set_default_transport """ diff --git a/src/compas_eve/__main__.py b/src/compas_eve/__main__.py index 244a421..0348317 100644 --- a/src/compas_eve/__main__.py +++ b/src/compas_eve/__main__.py @@ -1,2 +1,4 @@ +import compas_eve + if __name__ == "__main__": - pass + print("COMPAS EVE v{} is installed!".format(compas_eve.__version__)) diff --git a/src/compas_eve/core.py b/src/compas_eve/core.py index 0c80545..bd7c51a 100644 --- a/src/compas_eve/core.py +++ b/src/compas_eve/core.py @@ -8,10 +8,26 @@ def get_default_transport(): + """Retrieve the default transport implementation to be used system-wide. + + Returns + ------- + :class:`~compas_eve.Transport` + Instance of a transport class. By default, ``compas_eve`` uses + :class:`~compas_eve.memory.InMemoryTransport`. + """ return DEFAULT_TRANSPORT def set_default_transport(transport): + """Assign a default transport implementation to be used system-wide. + + Parameters + ---------- + transport : :class:`~compas_eve.Transport` + Instance of a transport class. By default, ``compas_eve`` uses + :class:`~compas_eve.memory.InMemoryTransport`. + """ global DEFAULT_TRANSPORT DEFAULT_TRANSPORT = transport @@ -64,7 +80,23 @@ def parse(cls, value): class Topic(object): - """Describes a topic""" + """A topic is like a mailbox where messages can be sent and received. + + Topics are described by a name, a type of message they accept, and a + set of options. + + Attributes + ---------- + name : str + Name of the topic. + message_type : type + Class defining the message structure. Use :class:`Message` for + a generic, non-typed checked message implementation. + options : dict + A dictionary of options. + """ + # TODO: Add documentation/examples of possible options + def __init__(self, name, message_type, **options): self.name = name diff --git a/src/compas_eve/memory/__init__.py b/src/compas_eve/memory/__init__.py index 2d7dca7..0804f09 100644 --- a/src/compas_eve/memory/__init__.py +++ b/src/compas_eve/memory/__init__.py @@ -1,6 +1,26 @@ +""" +******************************************************************************** +compas_eve.memory +******************************************************************************** + +.. currentmodule:: compas_eve.memory + + +Classes +======= + +.. autosummary:: + :toctree: generated/ + :nosignatures: + + InMemoryTransport + +""" from compas_eve.event_emitter import EventEmitterMixin from compas_eve.core import Transport +__all__ = ["InMemoryTransport"] + class InMemoryTransport(Transport, EventEmitterMixin): """In-Memory transport is ideal for simple single-process apps and testing. @@ -16,7 +36,15 @@ def on_ready(self, callback): callback() def publish(self, topic, message): - """Publish a message to a topic.""" + """Publish a message to a topic. + + Parameters + ---------- + topic : :class:`Topic` + Instance of the topic to publish to. + message : :class:`Message` + Instance of the message to publish. + """ event_key = "event:{}".format(topic.name) def _callback(**kwargs): @@ -25,7 +53,23 @@ def _callback(**kwargs): self.on_ready(_callback) def subscribe(self, topic, callback): - """Subscribe to be notified of messages on a given topic.""" + """Subscribe to a topic. + + Every time a new message is received on the topic, the callback will be invoked. + + Parameters + ---------- + topic : :class:`Topic` + Instance of the topic to subscribe to. + callback : function + Callback to invoke whenever a new message arrives. The callback should + receive only one `msg` argument, e.g. ``lambda msg: print(msg)``. + + Returns + ------- + str + Returns an identifier of the subscription. + """ event_key = "event:{}".format(topic.name) subscribe_id = "{}:{}".format(event_key, id(callback)) @@ -61,7 +105,7 @@ def advertise(self, topic): return advertise_id def unadvertise(self, topic): - """Announce this code will stop publishing messages to the specified topic. + """Announce that this code will stop publishing messages to the specified topic. This call has no effect on the in-memory transport.""" pass diff --git a/src/compas_eve/mqtt/__init__.py b/src/compas_eve/mqtt/__init__.py index b8b5e49..e7d7744 100644 --- a/src/compas_eve/mqtt/__init__.py +++ b/src/compas_eve/mqtt/__init__.py @@ -1,3 +1,21 @@ +""" +******************************************************************************** +compas_eve.mqtt +******************************************************************************** + +.. currentmodule:: compas_eve.mqtt + + +Classes +======= + +.. autosummary:: + :toctree: generated/ + :nosignatures: + + MqttTransport + +""" import sys if sys.platform == "cli": diff --git a/src/compas_eve/mqtt/mqtt_cli.py b/src/compas_eve/mqtt/mqtt_cli.py index f8a8e8c..783d3b5 100644 --- a/src/compas_eve/mqtt/mqtt_cli.py +++ b/src/compas_eve/mqtt/mqtt_cli.py @@ -32,6 +32,16 @@ class MqttTransport(Transport, EventEmitterMixin): + """MQTT transport allows sending and receiving messages using an MQTT broker. + + Parameters + ---------- + host : str + Host name for the MQTT broker, e.g. ``broker.hivemq.com`` or ``localhost`` if + you are running a local broker on your machine. + port : int + MQTT broker port, defaults to ``1883``. + """ def __init__(self, host, port=1883, *args, **kwargs): super(MqttTransport, self).__init__(*args, **kwargs) self.host = host diff --git a/src/compas_eve/mqtt/mqtt_paho.py b/src/compas_eve/mqtt/mqtt_paho.py index 2c109ee..70ff06e 100644 --- a/src/compas_eve/mqtt/mqtt_paho.py +++ b/src/compas_eve/mqtt/mqtt_paho.py @@ -8,6 +8,17 @@ class MqttTransport(Transport, EventEmitterMixin): + """MQTT transport allows sending and receiving messages using an MQTT broker. + + Parameters + ---------- + host : str + Host name for the MQTT broker, e.g. ``broker.hivemq.com`` or ``localhost`` if + you are running a local broker on your machine. + port : int + MQTT broker port, defaults to ``1883``. + """ + def __init__(self, host, port=1883, *args, **kwargs): super(MqttTransport, self).__init__(*args, **kwargs) self.host = host @@ -20,6 +31,7 @@ def __init__(self, host, port=1883, *args, **kwargs): self.client.loop_start() def close(self): + """Close the connection to the MQTT broker.""" self.client.loop_stop() def _on_connect(self, client, userdata, flags, rc): @@ -27,12 +39,29 @@ def _on_connect(self, client, userdata, flags, rc): self.emit("ready") def on_ready(self, callback): + """Allows to hook-up to the event triggered when the connection to MQTT broker is ready. + + Parameters + ---------- + callback : function + Function to invoke when the connection is established. + """ if self._is_connected: callback() else: self.once("ready", callback) def publish(self, topic, message): + """Publish a message to a topic. + + Parameters + ---------- + topic : :class:`Topic` + Instance of the topic to publish to. + message : :class:`Message` + Instance of the message to publish. + """ + def _callback(**kwargs): # TODO: can we avoid the additional cast to dict? json_message = json_dumps(dict(message)) @@ -41,6 +70,23 @@ def _callback(**kwargs): self.on_ready(_callback) def subscribe(self, topic, callback): + """Subscribe to a topic. + + Every time a new message is received on the topic, the callback will be invoked. + + Parameters + ---------- + topic : :class:`Topic` + Instance of the topic to subscribe to. + callback : function + Callback to invoke whenever a new message arrives. The callback should + receive only one `msg` argument, e.g. ``lambda msg: print(msg)``. + + Returns + ------- + str + Returns an identifier of the subscription. + """ event_key = "event:{}".format(topic.name) subscribe_id = "{}:{}".format(event_key, id(callback)) @@ -68,14 +114,45 @@ def _on_message(self, client, userdata, msg): self.emit(event_key, msg) def advertise(self, topic): + """Announce this code will publish messages to the specified topic. + + This call has no effect on this transport implementation. + + Parameters + ---------- + topic : :class:`Topic` + Instance of the topic to advertise. + + Returns + ------- + str + Advertising identifier. + """ + advertise_id = "advertise:{}:{}".format(topic.name, self.id_counter) # mqtt does not need anything here return advertise_id def unadvertise(self, topic): + """Announce that this code will stop publishing messages to the specified topic. + + This call has no effect on this transport implementation. + + Parameters + ---------- + topic : :class:`Topic` + Instance of the topic to stop publishing messages to. + """ pass def unsubscribe_by_id(self, subscribe_id): + """Unsubscribe from the specified topic based on the subscription id. + + Parameters + ---------- + subscribe_id : str + Identifier of the subscription. + """ ev_type, topic_name, _callback_id = subscribe_id.split(":") event_key = "{}:{}".format(ev_type, topic_name) @@ -86,4 +163,11 @@ def unsubscribe_by_id(self, subscribe_id): del self._local_callbacks[subscribe_id] def unsubscribe(self, topic): + """Unsubscribe from the specified topic. + + Parameters + ---------- + topic : :class:`Topic` + Instance of the topic to unsubscribe from. + """ self.client.unsubscribe(topic.name) diff --git a/tasks.py b/tasks.py index b1bfa56..4d01b1e 100644 --- a/tasks.py +++ b/tasks.py @@ -26,6 +26,7 @@ { "base_folder": os.path.dirname(__file__), "ghuser": { + "prefix": "EVE", "source_dir": "src/compas_eve/ghpython/components", "target_dir": "src/compas_eve/ghpython/components/ghuser", },