├── requirements.txt ├── tox.ini ├── pubsub ├── __init__.py ├── local.py └── net.py ├── MANIFEST.in ├── .gitignore ├── tests ├── test_local.py └── test_net.py ├── setup.py ├── README.rst └── LICENSE /requirements.txt: -------------------------------------------------------------------------------- 1 | Twisted==15.3.0 2 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist=py27,py34 3 | 4 | [testenv] 5 | commands=py.test 6 | deps = 7 | -r{toxinidir}/requirements.txt 8 | pytest 9 | mock 10 | -------------------------------------------------------------------------------- /pubsub/__init__.py: -------------------------------------------------------------------------------- 1 | """pubsub - A simple PubSub server implementation in Python.""" 2 | 3 | __version__ = '0.1.0' 4 | __author__ = 'Matt Rasmus ' 5 | __all__ = [] 6 | 7 | from .net import PubSub 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | # Informational files 2 | include README.rst 3 | include LICENSE 4 | 5 | # Exclude any compile Python files (most likely grafted by tests/ directory). 6 | global-exclude *.pyc 7 | 8 | # Setup-related things 9 | include requirements.txt 10 | include setup.py 11 | include tox.ini 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | __pycache__ 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | 30 | # Translations 31 | *.mo 32 | 33 | # Mr Developer 34 | .mr.developer.cfg 35 | .project 36 | .pydevproject 37 | -------------------------------------------------------------------------------- /tests/test_local.py: -------------------------------------------------------------------------------- 1 | # tests/test_pubsub.py 2 | 3 | from pubsub.local import PubSub 4 | 5 | 6 | def test_local_pubsub(): 7 | """Verify simple usage of local PubSub.""" 8 | pubsub = PubSub() 9 | results = {'a': 0, 'b': 0} 10 | expected = {'a', 'b'} 11 | 12 | def sub_func_a(add=0, **kwargs): 13 | results['a'] += add 14 | 15 | def sub_func_b(add=0, **kwargs): 16 | results['b'] += add 17 | 18 | pubsub.subscribe("test", sub_func_a) 19 | pubsub.publish("test", add=3) 20 | 21 | assert results == {'a': 3, 'b': 0} 22 | 23 | pubsub.subscribe("test", sub_func_b) 24 | pubsub.publish("test", add=2) 25 | 26 | assert results == {'a': 5, 'b': 2} 27 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open('requirements.txt') as f: 4 | requires = [line.strip() for line in f.readlines()] 5 | 6 | setuptools.setup( 7 | name="pubsub", 8 | version="0.1.0", 9 | url="https://github.com/mttr/pubsub", 10 | 11 | author="Matt Rasmus", 12 | author_email="mattr@zzntd.com", 13 | 14 | description="A simple PubSub server implementation in Python.", 15 | long_description=open('README.rst').read(), 16 | 17 | packages=setuptools.find_packages(), 18 | 19 | install_requires=requires, 20 | 21 | classifiers=[ 22 | 'Development Status :: 2 - Pre-Alpha', 23 | 'Programming Language :: Python', 24 | 'Programming Language :: Python :: 2', 25 | 'Programming Language :: Python :: 2.7', 26 | 'Programming Language :: Python :: 3', 27 | 'Programming Language :: Python :: 3.4', 28 | ], 29 | 30 | entry_points={ 31 | 'console_scripts': [ 32 | 'pubsubserve = pubsub.net:main', 33 | ] 34 | } 35 | ) 36 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | pubsub 2 | ====== 3 | 4 | A simple PubSub server implementation in Python. Built with 5 | `Twisted `. 6 | 7 | 8 | Installation 9 | ------------ 10 | 11 | In a virtualenv, run ``python setup.py install``. 12 | 13 | 14 | Example Usage 15 | ------------- 16 | 17 | 18 | Run the PubSub server with ``pubsubserve`` (by default, the server listens 19 | on ``localhost:3000``). 20 | 21 | In an interactive session (I'm using IPython):: 22 | 23 | In [1]: from pubsub import PubSub 24 | 25 | In [2]: def test(**kwargs): 26 | ...: print("HELLO %s" % kwargs) 27 | ...: 28 | 29 | In [3]: ps = PubSub() 30 | 31 | In [4]: ps.subscribe("hello", test) 32 | 33 | In [5]: ps.run() 34 | 35 | While this is waiting, in another interactive session:: 36 | 37 | In [1]: from pubsub import PubSub 38 | 39 | In [2]: ps = PubSub() 40 | 41 | In [3]: ps.publish("hello", world="WORLD", other="UNIVERSE") 42 | 43 | In [4]: ps.run() 44 | 45 | In the listener session, ``HELLO {u'world': u'WORLD', u'other': u'UNIVERSE'}`` 46 | should be visible. 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Matt Rasmus 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pubsub/local.py: -------------------------------------------------------------------------------- 1 | # pubsub/local.py 2 | 3 | """ 4 | A local PubSub_ implementation. 5 | 6 | .. _PubSub: https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern 7 | 8 | Example usage:: 9 | 10 | In [1]: from pubsub.local import PubSub 11 | 12 | In [2]: def subscriber(data=None, **kwargs): 13 | ...: print(data) 14 | ...: 15 | 16 | In [3]: pubsub = PubSub() 17 | 18 | In [4]: pubsub.subscribe("Hello", subscriber) 19 | 20 | In [5]: pubsub.publish("Hello", data="World") 21 | World 22 | """ 23 | 24 | from collections import defaultdict 25 | 26 | 27 | class PubSub(object): 28 | 29 | def __init__(self): 30 | self.topics = defaultdict(set) 31 | 32 | def subscribe(self, topic, f): 33 | """ 34 | Subscribe the function/method ``f`` to ``topic``. 35 | """ 36 | self.topics[topic].add(f) 37 | 38 | def publish(self, topic, **kwargs): 39 | """ 40 | Publish ``**kwargs`` to ``topic``, calling all functions/methods 41 | subscribed to ``topic`` with the arguments specified in ``**kwargs``. 42 | """ 43 | for f in self.topics[topic]: 44 | f(**kwargs) 45 | -------------------------------------------------------------------------------- /tests/test_net.py: -------------------------------------------------------------------------------- 1 | # tests/test_net.py 2 | 3 | import json 4 | import pytest 5 | 6 | from mock import MagicMock 7 | from twisted.test import proto_helpers 8 | 9 | from pubsub.net import PubSubFactory, PublisherProtocol, SubscriberProtocol 10 | 11 | 12 | def make_publish_request(topic, **kwargs): 13 | return json.dumps({ 14 | 'command': 'publish', 15 | 'topic': topic, 16 | 'data': kwargs, 17 | }) 18 | 19 | 20 | def make_subscribe_request(topic): 21 | return json.dumps({ 22 | 'command': 'subscribe', 23 | 'topic': topic, 24 | }) 25 | 26 | 27 | class TestPubSubServer: 28 | 29 | @pytest.fixture(autouse=True) 30 | def setup(self): 31 | self.ps_factory = PubSubFactory() 32 | 33 | def test_dataReceived_subscribe(self): 34 | sub_proto = self.ps_factory.buildProtocol(('127.0.0.1', 0)) 35 | tr = proto_helpers.StringTransport() 36 | sub_proto.makeConnection(tr) 37 | 38 | sub_proto.dataReceived(make_subscribe_request('test')) 39 | 40 | assert sub_proto in self.ps_factory.topics['test'] 41 | 42 | def test_dataReceived_publish(self): 43 | # Using a mock here since otherwise StringTransport will complain 44 | # about being passed unicode when data is being sent to subscribers. 45 | 46 | mock_sub = MagicMock() 47 | self.ps_factory.topics['test'].add(mock_sub) 48 | 49 | pub_proto = self.ps_factory.buildProtocol(('127.0.0.1', 0)) 50 | pub_tr = proto_helpers.StringTransport() 51 | pub_proto.makeConnection(pub_tr) 52 | 53 | pub_proto.dataReceived(make_publish_request('test', testdata=5)) 54 | mock_sub.transport.write.assert_called_with( 55 | json.dumps({'testdata': 5})) 56 | 57 | def test_connectionLost(self): 58 | sub_proto = self.ps_factory.buildProtocol(('127.0.0.1', 0)) 59 | tr = proto_helpers.StringTransport() 60 | sub_proto.makeConnection(tr) 61 | 62 | sub_proto.dataReceived(make_subscribe_request('test')) 63 | 64 | assert sub_proto in self.ps_factory.topics['test'] 65 | 66 | sub_proto.connectionLost(None) 67 | 68 | assert sub_proto not in self.ps_factory.topics['test'] 69 | 70 | 71 | class TestPubSubClientProtocols: 72 | 73 | def test_publisher(self): 74 | proto = PublisherProtocol('test', testdata=5) 75 | proto.makeConnection(MagicMock()) 76 | 77 | proto.connectionMade() 78 | 79 | proto.transport.write.assert_called_with( 80 | make_publish_request('test', testdata=5)) 81 | 82 | proto.transport.loseConnection.assert_called_with() 83 | 84 | def test_subscriber_connection(self): 85 | proto = SubscriberProtocol('test', lambda _: None) 86 | proto.makeConnection(MagicMock()) 87 | 88 | proto.connectionMade() 89 | 90 | proto.transport.write.assert_called_with( 91 | make_subscribe_request('test')) 92 | 93 | def test_subscriber_received(self): 94 | callmock = MagicMock() 95 | proto = SubscriberProtocol('test', callmock) 96 | proto.makeConnection(MagicMock()) 97 | 98 | proto.dataReceived(json.dumps({'testdata': 5})) 99 | 100 | callmock.assert_called_with(testdata=5) 101 | -------------------------------------------------------------------------------- /pubsub/net.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # pubsub/net.py 3 | 4 | """ 5 | A networking implementation of PubSub using Twisted. 6 | 7 | ============= 8 | PubSub Server 9 | ============= 10 | 11 | A PubSub server listens for subscription requests and publish commands, and, 12 | when published to, sends data to subscribers. All incoming and outgoing 13 | requests are encoded in JSON. 14 | 15 | A Subscribe request looks like this:: 16 | { 17 | "command": "subscribe", 18 | "topic": "hello" 19 | } 20 | 21 | A Publish request looks like this:: 22 | { 23 | "command": "publish", 24 | "topic": "hello", 25 | "data": { 26 | "world": "WORLD" 27 | } 28 | } 29 | 30 | When the server receive's a Publish request, it will send the ``data`` object 31 | to all subscribers of ``topic``. 32 | """ 33 | 34 | import argparse 35 | import json 36 | import logging 37 | 38 | from collections import defaultdict 39 | 40 | from twisted.internet import reactor 41 | from twisted.internet.endpoints import TCP4ClientEndpoint, connectProtocol 42 | from twisted.internet.protocol import Protocol, Factory, ClientFactory 43 | 44 | 45 | class PubSubProtocol(Protocol): 46 | 47 | def __init__(self, topics): 48 | self.topics = topics 49 | self.subscribed_topic = None 50 | 51 | def connectionLost(self, reason): 52 | if self.subscribed_topic: 53 | self.topics[self.subscribed_topic].remove(self) 54 | 55 | def dataReceived(self, data): 56 | try: 57 | request = json.loads(data) 58 | except ValueError: 59 | # FIXME It would probably be courteous of us to let the sender 60 | # know that something went wrong. 61 | logging.debug("ValueError on decoding incoming data. Data: '%s'" 62 | % data, exc_info=True) 63 | self.transport.loseConnection() 64 | return 65 | 66 | if request['command'] == 'subscribe': 67 | self.handle_subscribe(request['topic']) 68 | elif request['command'] == 'publish': 69 | self.handle_publish(request['topic'], request['data']) 70 | 71 | def handle_subscribe(self, topic): 72 | self.topics[topic].add(self) 73 | self.subscribed_topic = topic 74 | 75 | def handle_publish(self, topic, data): 76 | request = json.dumps(data) 77 | 78 | for protocol in self.topics[topic]: 79 | protocol.transport.write(request) 80 | 81 | 82 | class PubSubFactory(Factory): 83 | 84 | def __init__(self): 85 | self.topics = defaultdict(set) 86 | 87 | def buildProtocol(self, addr): 88 | return PubSubProtocol(self.topics) 89 | 90 | 91 | class PublisherProtocol(Protocol): 92 | 93 | def __init__(self, topic, **kwargs): 94 | self.topic = topic 95 | self.kwargs = kwargs 96 | 97 | def connectionMade(self): 98 | request = json.dumps({ 99 | 'command': 'publish', 100 | 'topic': self.topic, 101 | 'data': self.kwargs, 102 | }) 103 | 104 | self.transport.write(request) 105 | self.transport.loseConnection() 106 | 107 | 108 | class SubscriberProtocol(Protocol): 109 | 110 | def __init__(self, topic, callback): 111 | self.topic = topic 112 | self.callback = callback 113 | 114 | def connectionMade(self): 115 | request = json.dumps({ 116 | 'command': 'subscribe', 117 | 'topic': self.topic, 118 | }) 119 | 120 | self.transport.write(request) 121 | 122 | def dataReceived(self, data): 123 | kwargs = json.loads(data) 124 | 125 | self.callback(**kwargs) 126 | 127 | 128 | class PubSub(object): 129 | 130 | def __init__(self, host='localhost', port=3000): 131 | self.host = host 132 | self.port = port 133 | self.reactor = reactor 134 | 135 | def _make_connection(self, protocol): 136 | endpoint = TCP4ClientEndpoint(reactor, self.host, self.port) 137 | connection = connectProtocol(endpoint, protocol) 138 | 139 | def subscribe(self, topic, callback): 140 | """ 141 | Subscribe ``callback`` callable to ``topic``. 142 | """ 143 | sub = SubscriberProtocol(topic, callback) 144 | self._make_connection(sub) 145 | 146 | def publish(self, topic, **kwargs): 147 | """ 148 | Publish ``**kwargs`` to ``topic``, calling all callables 149 | subscribed to ``topic`` with the arguments specified in ``**kwargs``. 150 | """ 151 | pub = PublisherProtocol(topic, **kwargs) 152 | self._make_connection(pub) 153 | 154 | def run(self): 155 | """ 156 | Convenience method to start the Twisted event loop. 157 | """ 158 | self.reactor.run() 159 | 160 | 161 | def main(): 162 | parser = argparse.ArgumentParser(description="Run a PubSub server") 163 | parser.add_argument('address', type=str, nargs='?', 164 | default='localhost:3000') 165 | parser.add_argument('-d', '--debug', action='store_true') 166 | 167 | args = parser.parse_args() 168 | 169 | if args.debug: 170 | logging.basicConfig(level=logging.DEBUG) 171 | 172 | host, port = args.address.split(':') 173 | port = int(port) 174 | 175 | reactor.listenTCP(port, PubSubFactory(), interface=host) 176 | reactor.run() 177 | 178 | 179 | if __name__ == '__main__': 180 | main() 181 | --------------------------------------------------------------------------------