├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── docs ├── conf.py ├── index.rst └── requirements.txt ├── pynats ├── __init__.py ├── commands.py ├── connection.py ├── message.py └── subscription.py ├── setup.py └── tests ├── test_connection.py └── test_connection_integration.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # Installer logs 26 | pip-log.txt 27 | pip-delete-this-directory.txt 28 | 29 | # Unit test / coverage reports 30 | htmlcov/ 31 | .tox/ 32 | .coverage 33 | .cache 34 | nosetests.xml 35 | coverage.xml 36 | 37 | # Translations 38 | *.mo 39 | 40 | # Mr Developer 41 | .mr.developer.cfg 42 | .project 43 | .pydevproject 44 | 45 | # Rope 46 | .ropeproject 47 | 48 | # Django stuff: 49 | *.log 50 | *.pot 51 | 52 | # Sphinx documentation 53 | docs/_build/ 54 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | python: 4 | - 2.7 5 | - 3.3 6 | - 3.4 7 | - pypy 8 | 9 | before_script: 10 | - gem -v 11 | - ruby -v 12 | - travis_retry gem install nats --pre 13 | - which nats-server 14 | - nats-server -d 15 | 16 | install: 17 | - travis_retry pip install --upgrade . 18 | 19 | script: py.test 20 | 21 | matrix: 22 | allow_failures: 23 | - python: 3.3 24 | - python: 3.4 25 | - python: pypy 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Máximo Cuadros 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | pynats [![Build Status](https://travis-ci.org/mcuadros/pynats.png?branch=master)](https://travis-ci.org/mcuadros/pynats) 2 | ====== 3 | 4 | A Python client for the [NATS messaging system](https://nats.io). 5 | 6 | > Note: pynats is under heavy development 7 | 8 | Requirements 9 | ------------ 10 | 11 | * python ~2.7 12 | * [gnatsd server](https://github.com/nats-io/gnatsd) 13 | 14 | 15 | Usage 16 | ----- 17 | ### Basic Usage 18 | 19 | ```python 20 | c = pynats.Connection(verbose=True) 21 | c.connect() 22 | 23 | # Simple Publisher 24 | c.publish('foo', 'Hello World!') 25 | 26 | # Simple Subscriber 27 | def callback(msg): 28 | print 'Received a message: %s' % msg.data 29 | 30 | c.subscribe('foo', callback) 31 | 32 | # Waiting for one msg 33 | c.wait(count=1) 34 | 35 | # Requests 36 | def request_callback(msg): 37 | print 'Got a response for help: %s' % msg.data 38 | 39 | c.request('help', request_callback) 40 | c.wait(count=1) 41 | 42 | # Unsubscribing 43 | subscription = c.subscribe('foo', callback) 44 | c.unsubscribe(subscription) 45 | 46 | # Close connection 47 | c.close() 48 | ``` 49 | 50 | Documentation 51 | ------------- 52 | 53 | ```sh 54 | cd docs 55 | sudo pip install -r requirements.txt 56 | sphinx-build -b html . build 57 | ``` 58 | 59 | After run this commands, the documention can be find at docs/build/index.html 60 | 61 | 62 | Tests 63 | ----- 64 | 65 | Tests are in the `tests` folder. 66 | To run them, you need `nosetests` or `py.test`. 67 | 68 | 69 | License 70 | ------- 71 | 72 | MIT, see [LICENSE](LICENSE) 73 | 74 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # pynats documentation build configuration file, created by 4 | # sphinx-quickstart on Sun Jan 6 09:55:26 2013. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os, platform 15 | import sphinx_rtd_theme 16 | from string import digits 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | version_string = sys.platform.rstrip(digits) + "-" + os.uname()[4] + "-" + ".".join(platform.python_version_tuple()[0:2]) 22 | sys.path.append(os.path.abspath('../pynats')) 23 | 24 | # -- General configuration ----------------------------------------------------- 25 | 26 | # If your documentation needs a minimal Sphinx version, state it here. 27 | #needs_sphinx = '1.0' 28 | 29 | # Add any Sphinx extension module names here, as strings. They can be extensions 30 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 31 | extensions = ['sphinx.ext.autodoc', 'sphinxcontrib.napoleon'] 32 | 33 | # Add any paths that contain templates here, relative to this directory. 34 | templates_path = ['_templates'] 35 | 36 | # The suffix of source filenames. 37 | source_suffix = '.rst' 38 | 39 | # The encoding of source files. 40 | #source_encoding = 'utf-8-sig' 41 | 42 | # The master toctree document. 43 | master_doc = 'index' 44 | 45 | # General information about the project. 46 | project = u'pynats' 47 | copyright = u'2014 Maximo Cuadros Ortiz' 48 | 49 | # The version info for the project you're documenting, acts as replacement for 50 | # |version| and |release|, also used in various other places throughout the 51 | # built documents. 52 | # 53 | # The short X.Y version. 54 | # version = '0.21' 55 | # The full version, including alpha/beta/rc tags. 56 | # release = '0.21.2' 57 | 58 | # The language for content autogenerated by Sphinx. Refer to documentation 59 | # for a list of supported languages. 60 | #language = None 61 | 62 | # There are two options for replacing |today|: either, you set today to some 63 | # non-false value, then it is used: 64 | #today = '' 65 | # Else, today_fmt is used as the format for a strftime call. 66 | #today_fmt = '%B %d, %Y' 67 | 68 | # List of patterns, relative to source directory, that match files and 69 | # directories to ignore when looking for source files. 70 | exclude_patterns = ['_build'] 71 | 72 | # The reST default role (used for this markup: `text`) to use for all documents. 73 | #default_role = None 74 | 75 | # If true, '()' will be appended to :func: etc. cross-reference text. 76 | #add_function_parentheses = True 77 | 78 | # If true, the current module name will be prepended to all description 79 | # unit titles (such as .. function::). 80 | #add_module_names = True 81 | 82 | # If true, sectionauthor and moduleauthor directives will be shown in the 83 | # output. They are ignored by default. 84 | #show_authors = False 85 | 86 | # The name of the Pygments (syntax highlighting) style to use. 87 | pygments_style = 'sphinx' 88 | 89 | # A list of ignored prefixes for module index sorting. 90 | #modindex_common_prefix = [] 91 | 92 | 93 | # -- Options for HTML output --------------------------------------------------- 94 | 95 | # The theme to use for HTML and HTML Help pages. See the documentation for 96 | # a list of builtin themes. 97 | html_theme = "sphinx_rtd_theme" 98 | 99 | # Theme options are theme-specific and customize the look and feel of a theme 100 | # further. For a list of options available for each theme, see the 101 | # documentation. 102 | #html_theme_options = {} 103 | 104 | # Add any paths that contain custom themes here, relative to this directory. 105 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 106 | 107 | # The name for this set of Sphinx documents. If None, it defaults to 108 | # " v documentation". 109 | #html_title = None 110 | 111 | # A shorter title for the navigation bar. Default is the same as html_title. 112 | #html_short_title = None 113 | 114 | # The name of an image file (relative to this directory) to place at the top 115 | # of the sidebar. 116 | #html_logo = None 117 | 118 | # The name of an image file (within the static path) to use as favicon of the 119 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 120 | # pixels large. 121 | #html_favicon = None 122 | 123 | # Add any paths that contain custom static files (such as style sheets) here, 124 | # relative to this directory. They are copied after the builtin static files, 125 | # so a file named "default.css" will overwrite the builtin "default.css". 126 | html_static_path = ['_static'] 127 | 128 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 129 | # using the given strftime format. 130 | #html_last_updated_fmt = '%b %d, %Y' 131 | 132 | # If true, SmartyPants will be used to convert quotes and dashes to 133 | # typographically correct entities. 134 | #html_use_smartypants = True 135 | 136 | # Custom sidebar templates, maps document names to template names. 137 | #html_sidebars = {} 138 | 139 | # Additional templates that should be rendered to pages, maps page names to 140 | # template names. 141 | #html_additional_pages = {} 142 | 143 | # If false, no module index is generated. 144 | #html_domain_indices = True 145 | 146 | # If false, no index is generated. 147 | #html_use_index = True 148 | 149 | # If true, the index is split into individual pages for each letter. 150 | #html_split_index = False 151 | 152 | # If true, links to the reST sources are added to the pages. 153 | #html_show_sourcelink = True 154 | 155 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 156 | #html_show_sphinx = True 157 | 158 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 159 | #html_show_copyright = True 160 | 161 | # If true, an OpenSearch description file will be output, and all pages will 162 | # contain a tag referring to it. The value of this option must be the 163 | # base URL from which the finished HTML is served. 164 | #html_use_opensearch = '' 165 | 166 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 167 | #html_file_suffix = None 168 | 169 | # Output file base name for HTML help builder. 170 | htmlhelp_basename = 'pynats_doc' 171 | 172 | 173 | # -- Options for LaTeX output -------------------------------------------------- 174 | 175 | latex_elements = { 176 | # The paper size ('letterpaper' or 'a4paper'). 177 | #'papersize': 'letterpaper', 178 | 179 | # The font size ('10pt', '11pt' or '12pt'). 180 | #'pointsize': '10pt', 181 | 182 | # Additional stuff for the LaTeX preamble. 183 | #'preamble': '', 184 | } 185 | 186 | # Grouping the document tree into LaTeX files. List of tuples 187 | # (source start file, target name, title, author, documentclass [howto/manual]). 188 | latex_documents = [ 189 | ('index', 'pynats.tex', u'pynats Documentation', 190 | u'Maximo Cuadros Ortiz', 'manual'), 191 | ] 192 | 193 | # The name of an image file (relative to this directory) to place at the top of 194 | # the title page. 195 | #latex_logo = None 196 | 197 | # For "manual" documents, if this is true, then toplevel headings are parts, 198 | # not chapters. 199 | #latex_use_parts = False 200 | 201 | # If true, show page references after internal links. 202 | #latex_show_pagerefs = False 203 | 204 | # If true, show URL addresses after external links. 205 | #latex_show_urls = False 206 | 207 | # Documents to append as an appendix to all manuals. 208 | #latex_appendices = [] 209 | 210 | # If false, no module index is generated. 211 | #latex_domain_indices = True 212 | 213 | 214 | # -- Options for manual page output -------------------------------------------- 215 | 216 | # One entry per manual page. List of tuples 217 | # (source start file, name, description, authors, manual section). 218 | man_pages = [ 219 | ('index', 'pynats', u'pynats Documentation', 220 | [u'Maximo Cuadros Ortiz'], 1) 221 | ] 222 | 223 | # If true, show URL addresses after external links. 224 | #man_show_urls = False 225 | 226 | 227 | # -- Options for Texinfo output ------------------------------------------------ 228 | 229 | # Grouping the document tree into Texinfo files. List of tuples 230 | # (source start file, target name, title, author, 231 | # dir menu entry, description, category) 232 | texinfo_documents = [ 233 | ( 234 | 'index', 'pynats', u'pynats Documentation', 235 | 'Maximo Cuadros Ortiz', 'pynats', 'A Python client for the NATS messaging system.', 236 | 'Miscellaneous' 237 | ) 238 | ] 239 | 240 | # Documents to append as an appendix to all manuals. 241 | #texinfo_appendices = [] 242 | 243 | # If false, no module index is generated. 244 | #texinfo_domain_indices = True 245 | 246 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 247 | #texinfo_show_urls = 'footnote' 248 | 249 | autodoc_member_order = 'bysource' 250 | 251 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ************************************* 2 | pynats - A Python client for the NATS 3 | ************************************* 4 | 5 | .. automodule:: pynats 6 | :members: Connection 7 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx==1.2.3 2 | sphinx-rtd-theme==0.1.6 3 | sphinxcontrib-napoleon==0.2.8 4 | -------------------------------------------------------------------------------- /pynats/__init__.py: -------------------------------------------------------------------------------- 1 | from pynats.subscription import * 2 | from pynats.connection import Connection 3 | 4 | __version__ = '0.1.0' 5 | -------------------------------------------------------------------------------- /pynats/commands.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | MSG = re.compile(b'^MSG\s+(?P[^\s\r\n]+)\s+(?P[^\s\r\n]+)\s+(?P([^\s\r\n]+)[^\S\r\n]+)?(?P\d+)\r\n') 4 | OK = re.compile(b'^\+OK\s*\r\n') 5 | ERR = re.compile(b'^-ERR\s+(\'.+\')?\r\n') 6 | PING = re.compile(b'^PING\r\n') 7 | PONG = re.compile(b'^PONG\r\n') 8 | INFO = re.compile(b'^INFO\s+([^\r\n]+)\r\n') 9 | 10 | commands = { 11 | 'MSG': MSG, '+OK': OK, '-ERR': ERR, 12 | 'PING': PING, 'PONG': PONG, 'INFO': INFO 13 | } 14 | -------------------------------------------------------------------------------- /pynats/connection.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import socket 3 | import json 4 | import time 5 | import random 6 | import string 7 | from pynats.commands import commands, MSG, INFO, PING, PONG, OK 8 | from pynats.subscription import Subscription 9 | from pynats.message import Message 10 | try: 11 | import urllib.parse as urlparse 12 | except: 13 | import urlparse 14 | 15 | DEFAULT_URI = 'nats://localhost:4222' 16 | 17 | 18 | class Connection(object): 19 | """ 20 | A Connection represents a bare connection to a nats-server. 21 | """ 22 | def __init__( 23 | self, 24 | url=DEFAULT_URI, 25 | name=None, 26 | ssl_required=False, 27 | verbose=False, 28 | pedantic=False, 29 | socket_keepalive=False 30 | ): 31 | self._connect_timeout = None 32 | self._socket_keepalive = socket_keepalive 33 | self._socket = None 34 | self._socket_file = None 35 | self._subscriptions = {} 36 | self._next_sid = 1 37 | self._options = { 38 | 'url': urlparse.urlsplit(url), 39 | 'name': name, 40 | 'ssl_required': ssl_required, 41 | 'verbose': verbose, 42 | 'pedantic': pedantic 43 | } 44 | 45 | def connect(self): 46 | """ 47 | Connect will attempt to connect to the NATS server. The url can 48 | contain username/password semantics. 49 | """ 50 | self._build_socket() 51 | self._connect_socket() 52 | self._build_file_socket() 53 | self._send_connect_msg() 54 | 55 | def _build_socket(self): 56 | self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 57 | self._socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) 58 | if self._socket_keepalive: 59 | self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) 60 | self._socket.settimeout(self._connect_timeout) 61 | 62 | def _connect_socket(self): 63 | SocketError.wrap(self._socket.connect, ( 64 | self._options['url'].hostname, 65 | self._options['url'].port 66 | )) 67 | 68 | def _send_connect_msg(self): 69 | self._send('CONNECT %s' % self._build_connect_config()) 70 | self._recv(INFO) 71 | 72 | def _build_connect_config(self): 73 | config = { 74 | 'verbose': self._options['verbose'], 75 | 'pedantic': self._options['pedantic'], 76 | 'ssl_required': self._options['ssl_required'], 77 | 'name': self._options['name'], 78 | } 79 | 80 | if self._options['url'].username is not None: 81 | config['user'] = self._options['url'].username 82 | config['pass'] = self._options['url'].password 83 | 84 | return json.dumps(config) 85 | 86 | def _build_file_socket(self): 87 | self._socket_file = self._socket.makefile('rb') 88 | 89 | def ping(self): 90 | self._send('PING') 91 | self._recv(PONG) 92 | 93 | def subscribe(self, subject, callback, queue=''): 94 | """ 95 | Subscribe will express interest in the given subject. The subject can 96 | have wildcards (partial:*, full:>). Messages will be delivered to the 97 | associated callback. 98 | 99 | Args: 100 | subject (string): a string with the subject 101 | callback (function): callback to be called 102 | """ 103 | s = Subscription( 104 | sid=self._next_sid, 105 | subject=subject, 106 | queue=queue, 107 | callback=callback, 108 | connetion=self 109 | ) 110 | 111 | self._subscriptions[s.sid] = s 112 | self._send('SUB %s %s %d' % (s.subject, s.queue, s.sid)) 113 | self._next_sid += 1 114 | 115 | return s 116 | 117 | def unsubscribe(self, subscription, max=None): 118 | """ 119 | Unsubscribe will remove interest in the given subject. If max is 120 | provided an automatic Unsubscribe that is processed by the server 121 | when max messages have been received 122 | 123 | Args: 124 | subscription (pynats.Subscription): a Subscription object 125 | max (int=None): number of messages 126 | """ 127 | if max is None: 128 | self._send('UNSUB %d' % subscription.sid) 129 | self._subscriptions.pop(subscription.sid) 130 | else: 131 | subscription.max = max 132 | self._send('UNSUB %d %s' % (subscription.sid, max)) 133 | 134 | def publish(self, subject, msg, reply=None): 135 | """ 136 | Publish publishes the data argument to the given subject. 137 | 138 | Args: 139 | subject (string): a string with the subject 140 | msg (string): payload string 141 | reply (string): subject used in the reply 142 | """ 143 | if msg is None: 144 | msg = '' 145 | 146 | if reply is None: 147 | command = 'PUB %s %d' % (subject, len(msg)) 148 | else: 149 | command = 'PUB %s %s %d' % (subject, reply, len(msg)) 150 | 151 | self._send(command) 152 | self._send(msg) 153 | 154 | def request(self, subject, callback, msg=None): 155 | """ 156 | ublish a message with an implicit inbox listener as the reply. 157 | Message is optional. 158 | 159 | Args: 160 | subject (string): a string with the subject 161 | callback (function): callback to be called 162 | msg (string=None): payload string 163 | """ 164 | inbox = self._build_inbox() 165 | s = self.subscribe(inbox, callback) 166 | self.unsubscribe(s, 1) 167 | self.publish(subject, msg, inbox) 168 | 169 | return s 170 | 171 | def _build_inbox(self): 172 | id = ''.join(random.choice(string.ascii_lowercase) for i in range(13)) 173 | return "_INBOX.%s" % id 174 | 175 | def wait(self, duration=None, count=0): 176 | """ 177 | Publish publishes the data argument to the given subject. 178 | 179 | Args: 180 | duration (float): will wait for the given number of seconds 181 | count (count): stop of wait after n messages from any subject 182 | """ 183 | start = time.time() 184 | total = 0 185 | while True: 186 | type, result = self._recv(MSG, PING, OK) 187 | if type is MSG: 188 | total += 1 189 | if self._handle_msg(result) is False: 190 | break 191 | 192 | if count and total >= count: 193 | break 194 | 195 | elif type is PING: 196 | self._handle_ping() 197 | 198 | if duration and time.time() - start > duration: 199 | break 200 | 201 | def _handle_msg(self, result): 202 | data = dict(result.groupdict()) 203 | sid = int(data['sid']) 204 | 205 | msg = Message( 206 | sid=sid, 207 | subject=data['subject'], 208 | size=int(data['size']), 209 | data=SocketError.wrap(self._readline).strip(), 210 | reply=data['reply'].strip() if data['reply'] is not None else None 211 | ) 212 | 213 | s = self._subscriptions.get(sid) 214 | s.received += 1 215 | 216 | # Check for auto-unsubscribe 217 | if s.max > 0 and s.received == s.max: 218 | self._subscriptions.pop(s.sid) 219 | 220 | return s.handle_msg(msg) 221 | 222 | def _handle_ping(self): 223 | self._send('PONG') 224 | 225 | def reconnect(self): 226 | """ 227 | Close the connection to the NATS server and open a new one 228 | """ 229 | self.close() 230 | self.connect() 231 | 232 | def close(self): 233 | """ 234 | Close will close the connection to the server. 235 | """ 236 | pass 237 | 238 | def _send(self, command): 239 | SocketError.wrap(self._socket.sendall, (command + '\r\n').encode('utf-8')) 240 | 241 | def _readline(self): 242 | lines = [] 243 | 244 | while True: 245 | line = self._socket_file.readline().decode('utf-8') 246 | lines.append(line) 247 | 248 | if line.endswith("\r\n"): 249 | break 250 | 251 | return "".join(lines) 252 | 253 | def _recv(self, *expected_commands): 254 | line = SocketError.wrap(self._readline) 255 | 256 | command = self._get_command(line) 257 | if command not in expected_commands: 258 | raise UnexpectedResponse(line) 259 | 260 | result = command.match(line.encode('utf-8')) 261 | if result is None: 262 | raise UnknownResponse(command.pattern, line) 263 | 264 | return command, result 265 | 266 | def _get_command(self, line): 267 | values = line.strip().split(' ', 1) 268 | 269 | return commands.get(values[0]) 270 | 271 | 272 | class UnknownResponse(Exception): 273 | pass 274 | 275 | 276 | class UnexpectedResponse(Exception): 277 | pass 278 | 279 | 280 | class SocketError(Exception): 281 | @staticmethod 282 | def wrap(wrapped_function, *args, **kwargs): 283 | try: 284 | return wrapped_function(*args, **kwargs) 285 | except socket.error as err: 286 | raise SocketError(err) 287 | -------------------------------------------------------------------------------- /pynats/message.py: -------------------------------------------------------------------------------- 1 | class Message(object): 2 | def __init__(self, sid, subject, size, data, reply=None): 3 | self.sid = sid 4 | self.subject = subject 5 | self.size = size 6 | self.data = data 7 | self.reply = reply 8 | -------------------------------------------------------------------------------- /pynats/subscription.py: -------------------------------------------------------------------------------- 1 | class Subscription(object): 2 | def __init__(self, sid, subject, queue, callback, connetion): 3 | self.sid = sid 4 | self.subject = subject 5 | self.queue = queue 6 | self.connetion = connetion 7 | self.callback = callback 8 | self.received = 0 9 | self.delivered = 0 10 | self.bytes = 0 11 | self.max = 0 12 | 13 | def handle_msg(self, msg): 14 | return self.callback(msg) 15 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from setuptools import setup 4 | 5 | 6 | def get_version_from_init(): 7 | file = open(os.path.join(os.path.dirname(__file__), 'pynats', '__init__.py')) 8 | 9 | regexp = re.compile(r".*__version__ = '(.*?)'", re.S) 10 | version = regexp.match(file.read()).group(1) 11 | file.close() 12 | 13 | return version 14 | 15 | 16 | setup( 17 | name='pynats', 18 | license='MIT', 19 | author='Maximo Cuadros', 20 | author_email='mcuadros@gmail.com', 21 | version=get_version_from_init(), 22 | url='https://github.com/mcuadros/pynats', 23 | packages=[ 24 | 'pynats' 25 | ], 26 | install_requires=[ 27 | 'mocket == 1.1.1' 28 | ] 29 | ) 30 | -------------------------------------------------------------------------------- /tests/test_connection.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import pynats 3 | import mocket.mocket as mocket 4 | 5 | 6 | class TestConnection(unittest.TestCase): 7 | def setUp(self): 8 | mocket.Mocket.enable() 9 | assertSocket( 10 | expected='CONNECT {"pedantic": false, "verbose": false, "ssl_required": false, "name": "foo"}\r\n', 11 | response='INFO {"foo": "bar"}\r\n' 12 | ) 13 | 14 | def test_connect(self): 15 | c = pynats.Connection('nats://localhost:4444', 'foo') 16 | c.connect() 17 | 18 | def test_socket_keepalive(self): 19 | c = pynats.Connection('nats://localhost:4444', 'foo', socket_keepalive=True) 20 | c.connect() 21 | 22 | def test_ping(self): 23 | c = pynats.Connection('nats://localhost:4444', 'foo') 24 | c.connect() 25 | 26 | assertSocket(expected='PING\r\n', response='PONG\r\n') 27 | c.ping() 28 | 29 | def test_subscribe_and_unsubscribe(self): 30 | c = pynats.Connection('nats://localhost:4444', 'foo') 31 | c.connect() 32 | 33 | def handler(msg): 34 | pass 35 | 36 | assertSocket(expected='SUB foo 1\r\n', response='') 37 | subscription = c.subscribe('foo', handler) 38 | 39 | self.assertEquals(c._next_sid, 2) 40 | self.assertIsInstance(subscription, pynats.Subscription) 41 | self.assertEquals(subscription.sid, 1) 42 | self.assertEquals(subscription.subject, 'foo') 43 | self.assertEquals(subscription.callback, handler) 44 | 45 | assertSocket(expected='UNSUB 1\r\n', response='') 46 | c.unsubscribe(subscription) 47 | self.assertEquals(c._subscriptions, {}) 48 | 49 | def test_publish(self): 50 | c = pynats.Connection('nats://localhost:4444', 'foo') 51 | c.connect() 52 | 53 | assertSocket(expected='PUB foo 3\r\n', response='') 54 | assertSocket(expected='msg\r\n', response='') 55 | c.publish('foo', 'msg') 56 | 57 | def test__build_inbox(self): 58 | c = pynats.Connection('nats://localhost:4444', 'foo') 59 | c.connect() 60 | 61 | inbox = c._build_inbox() 62 | self.assertEquals(20, len(inbox)) 63 | 64 | def test_request(self): 65 | c = pynats.Connection('nats://localhost:4444', 'foo') 66 | c.connect() 67 | 68 | inbox = '_INBOX.kykblzisxpeou' 69 | 70 | def mocked_inbox(): 71 | return inbox 72 | 73 | c._build_inbox = mocked_inbox 74 | 75 | assertSocket(expected='SUB %s 1\r\n' % inbox, response='') 76 | assertSocket(expected='UNSUB 1 1\r\n', response='') 77 | assertSocket(expected='PUB request %s 3\r\n' % inbox, response='') 78 | assertSocket(expected='msg\r\n', response='') 79 | 80 | s = c.request('request', 'callback', 'msg') 81 | self.assertEquals(s.subject, inbox) 82 | 83 | def test_request_without_msg(self): 84 | c = pynats.Connection('nats://localhost:4444', 'foo') 85 | c.connect() 86 | 87 | inbox = '_INBOX.kykblzisxpeou' 88 | 89 | def mocked_inbox(): 90 | return inbox 91 | 92 | c._build_inbox = mocked_inbox 93 | 94 | assertSocket(expected='SUB %s 1\r\n' % inbox, response='') 95 | assertSocket(expected='UNSUB 1 1\r\n', response='') 96 | assertSocket(expected='PUB request %s 0\r\n' % inbox, response='') 97 | assertSocket(expected='\r\n', response='') 98 | 99 | s = c.request('request', 'callback') 100 | self.assertEquals(s.subject, inbox) 101 | 102 | def test_publish_with_reply(self): 103 | c = pynats.Connection('nats://localhost:4444', 'foo') 104 | c.connect() 105 | 106 | assertSocket(expected='PUB foo reply 3\r\n', response='') 107 | assertSocket(expected='msg\r\n', response='') 108 | c.publish('foo', 'msg', 'reply') 109 | 110 | def test_wait_receive_ping(self): 111 | c = pynats.Connection('nats://localhost:4444', 'foo') 112 | c.connect() 113 | 114 | def monkey(self, *expected_commands): 115 | return pynats.commands.PING, None 116 | 117 | c._recv = monkey 118 | assertSocket(expected='PONG\r\n', response='') 119 | c.wait(duration=0.1) 120 | 121 | def test_wait_receive_msg(self): 122 | c = pynats.Connection('nats://localhost:4444', 'foo') 123 | c.connect() 124 | 125 | def handler(msg): 126 | self.assertEquals(msg.sid, 1) 127 | self.assertEquals(msg.subject, 'foo') 128 | self.assertEquals(msg.size, 10) 129 | self.assertEquals(msg.reply, 'reply') 130 | 131 | return False 132 | 133 | assertSocket(expected='SUB foo 1\r\n', response='') 134 | c.subscribe('foo', handler) 135 | 136 | def monkey(self, *expected_commands): 137 | line = 'MSG foo 1 reply 10\r\n' 138 | return pynats.commands.MSG, pynats.commands.MSG.match(line) 139 | 140 | c._recv = monkey 141 | assertSocket(expected='PONG\r\n', response='') 142 | c.wait(count=1) 143 | 144 | 145 | class assertSocket(object): 146 | def __init__(self, expected, response): 147 | self.location = ('localhost', 4444) 148 | mocket.Mocket.register(self) 149 | self.expected = expected 150 | self.response = response 151 | self.calls = 0 152 | 153 | def can_handle(self, data): 154 | return self.expected == data 155 | 156 | def collect(self, data): 157 | self.calls += 1 158 | 159 | def get_response(self): 160 | return self.response 161 | 162 | if __name__ == '__main__': 163 | unittest.main() 164 | -------------------------------------------------------------------------------- /tests/test_connection_integration.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import pynats 3 | import mocket.mocket as mocket 4 | import threading 5 | import time 6 | 7 | 8 | class TestConnectionIntegration(unittest.TestCase): 9 | def setUp(self): 10 | mocket.Mocket.disable() 11 | 12 | def test_wait_with_timeout(self): 13 | c = pynats.Connection(verbose=True) 14 | c.connect() 15 | 16 | def callback(msg): 17 | return True 18 | 19 | c.subscribe('foo', callback) 20 | self._send_message(0.1) 21 | c.wait(duration=0.1) 22 | 23 | def test_wait_with_limit(self): 24 | c = pynats.Connection(verbose=True) 25 | c.connect() 26 | 27 | def callback(msg): 28 | return True 29 | 30 | c.subscribe('foo', callback) 31 | self._send_message(0.1) 32 | self._send_message(0.1) 33 | self._send_message(0.1) 34 | c.wait(count=3) 35 | 36 | def test_wait_with_limit_foo(self): 37 | c = pynats.Connection(verbose=True) 38 | c.connect() 39 | 40 | def callback(msg): 41 | return True 42 | 43 | 44 | s = c.subscribe('foo', callback) 45 | c.unsubscribe(s, 3) 46 | self.assertEquals(1, len(c._subscriptions)) 47 | 48 | self._send_message(0.1) 49 | self._send_message(0.1) 50 | self._send_message(0.1) 51 | c.wait(count=3) 52 | 53 | self.assertEquals(0, len(c._subscriptions)) 54 | 55 | def test_wait_with_handler_return_false(self): 56 | c = pynats.Connection(verbose=True) 57 | c.connect() 58 | 59 | def callback(msg): 60 | return False 61 | 62 | c.subscribe('foo', callback) 63 | self._send_message(0.1) 64 | c.wait() 65 | 66 | def test_multiline_send(self): 67 | c = pynats.Connection(verbose=True) 68 | c.connect() 69 | 70 | payload = "123\n456" 71 | 72 | def callback(msg): 73 | self.assertEquals(msg.data, payload) 74 | return False 75 | 76 | c.subscribe('foo', callback) 77 | 78 | self._send_message(msg=payload) 79 | c.wait() 80 | 81 | 82 | def _send_message(self, sleep=0, msg="foo"): 83 | def send(): 84 | c = pynats.Connection(verbose=True) 85 | c.connect() 86 | c.publish('foo', msg) 87 | self._create_thread(send, sleep) 88 | 89 | def _create_thread(self, callback, sleep=0): 90 | time.sleep(sleep) 91 | th = threading.Thread(target=callback) 92 | th.start() 93 | th.join() 94 | 95 | if __name__ == '__main__': 96 | unittest.main() 97 | --------------------------------------------------------------------------------