├── docs ├── __init__.py ├── examples │ ├── __init__.py │ └── services.py ├── assets │ └── wampy-internal-architecture.jpg ├── wampy.errors.rst ├── wampy.session.rst ├── wampy.constants.rst ├── wampy.mixins.rst ├── wampy.roles.callee.rst ├── wampy.roles.caller.rst ├── wampy.messages.call.rst ├── wampy.messages.hello.rst ├── wampy.peers.clients.rst ├── wampy.peers.routers.rst ├── wampy.messages.yield.rst ├── wampy.messages.goodbye.rst ├── wampy.messages.publish.rst ├── wampy.roles.publisher.rst ├── wampy.messages.register.rst ├── wampy.roles.subscriber.rst ├── wampy.messages.subscribe.rst ├── templates │ ├── sidebarlinks.html │ └── relations.html ├── what_is_wampy.rst ├── subscribing_to_a_topic.rst ├── publishing_to_a_topic.rst ├── tls.rst ├── exception_handling.rst ├── remote_procedure_calls.rst ├── what_is_wamp.rst ├── a_wampy_application.rst ├── message_handler.rst ├── index.rst ├── a_wampy_client.rst ├── testing.rst ├── authentication.rst ├── Makefile ├── make.bat └── conf.py ├── test ├── unit │ ├── __init__.py │ ├── test_configure.py │ ├── test_authentication.py │ └── test_backends.py ├── integration │ ├── __init__.py │ ├── test_really_long_strings.py │ ├── test_message_handler.py │ ├── test_unicode.py │ ├── test_app_runner.py │ ├── test_exception_handling.py │ ├── transports │ │ ├── test_transports.py │ │ └── test_websockets.py │ ├── roles │ │ ├── test_publishing.py │ │ └── test_callers.py │ ├── test_clients.py │ └── test_authentication.py ├── __init__.py └── conftest.py ├── wampy ├── config │ ├── __init__.py │ └── defaults.py ├── messages │ ├── base.py │ ├── registered.py │ ├── welcome.py │ ├── abort.py │ ├── subscribed.py │ ├── authenticate.py │ ├── goodbye.py │ ├── subscribe.py │ ├── challenge.py │ ├── cancel.py │ ├── publish.py │ ├── hello.py │ ├── register.py │ ├── invocation.py │ ├── result.py │ ├── __init__.py │ ├── call.py │ ├── event.py │ ├── yield_.py │ └── error.py ├── testing │ ├── configs │ │ ├── README.rst │ │ ├── crossbar.ipv6.json │ │ ├── crossbar.timeout.json │ │ ├── crossbar.tls.json │ │ ├── crossbar.json │ │ └── crossbar.static.auth.json │ ├── keys │ │ ├── dhparam.pem │ │ ├── server_cert.pem │ │ └── server_key.pem │ ├── __init__.py │ ├── helpers.py │ └── pytest_plugin.py ├── cli │ ├── __init__.py │ ├── main.py │ └── run.py ├── roles │ ├── __init__.py │ ├── subscriber.py │ ├── publisher.py │ ├── callee.py │ └── caller.py ├── transports │ ├── __init__.py │ └── websocket │ │ └── __init__.py ├── peers │ ├── __init__.py │ ├── routers.py │ └── clients.py ├── backends │ ├── errors.py │ ├── __init__.py │ ├── eventlet_.py │ └── gevent_.py ├── errors.py ├── serializers.py ├── auth.py ├── interfaces.py ├── __init__.py ├── constants.py ├── mixins.py ├── message_handler.py └── session.py ├── rtd_requirements.txt ├── .coveragerc ├── CONTRIBUTORS.txt ├── .travis.yml ├── Makefile ├── coverage.svg ├── .gitignore ├── CONTRIBUTING.md └── setup.py /docs/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/examples/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/integration/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wampy/config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wampy/messages/base.py: -------------------------------------------------------------------------------- 1 | 2 | class Message(object): 3 | 4 | def __str__(self): 5 | return str(self.message) 6 | -------------------------------------------------------------------------------- /docs/assets/wampy-internal-architecture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noisyboiler/wampy/HEAD/docs/assets/wampy-internal-architecture.jpg -------------------------------------------------------------------------------- /docs/wampy.errors.rst: -------------------------------------------------------------------------------- 1 | wampy.errors module 2 | =================== 3 | 4 | .. automodule:: errors 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/wampy.session.rst: -------------------------------------------------------------------------------- 1 | wampy.session module 2 | ==================== 3 | 4 | .. automodule:: session 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /rtd_requirements.txt: -------------------------------------------------------------------------------- 1 | # ReadTheDocs doesn't support installation of requirements via extras_require 2 | # See https://github.com/rtfd/readthedocs.org/issues/173 3 | 4 | -e .[docs] -------------------------------------------------------------------------------- /docs/wampy.constants.rst: -------------------------------------------------------------------------------- 1 | wampy.constants module 2 | ====================== 3 | 4 | .. automodule:: constants 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/wampy.mixins.rst: -------------------------------------------------------------------------------- 1 | wampy.roles.callee module 2 | ========================= 3 | 4 | .. automodule:: mixins 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/wampy.roles.callee.rst: -------------------------------------------------------------------------------- 1 | wampy.roles.callee module 2 | ========================= 3 | 4 | .. automodule:: callee 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/wampy.roles.caller.rst: -------------------------------------------------------------------------------- 1 | wampy.roles.caller module 2 | ========================= 3 | 4 | .. automodule:: caller 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/wampy.messages.call.rst: -------------------------------------------------------------------------------- 1 | wampy.messages.call module 2 | ========================== 3 | 4 | .. automodule:: call 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/wampy.messages.hello.rst: -------------------------------------------------------------------------------- 1 | wampy.messages.hello module 2 | =========================== 3 | 4 | .. automodule:: hello 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/wampy.peers.clients.rst: -------------------------------------------------------------------------------- 1 | wampy.peers.clients module 2 | ========================== 3 | 4 | .. automodule:: clients 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/wampy.peers.routers.rst: -------------------------------------------------------------------------------- 1 | wampy.peers.routers module 2 | ========================== 3 | 4 | .. automodule:: routers 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = False 3 | source = 4 | wampy 5 | omit = 6 | test/* 7 | 8 | [report] 9 | exclude_lines = 10 | pragma: no cover 11 | 12 | show_missing = True 13 | -------------------------------------------------------------------------------- /docs/wampy.messages.yield.rst: -------------------------------------------------------------------------------- 1 | wampy.messages.yield module 2 | =========================== 3 | 4 | .. automodule:: yield_ 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/wampy.messages.goodbye.rst: -------------------------------------------------------------------------------- 1 | wampy.messages.goodbye module 2 | ============================= 3 | 4 | .. automodule:: goodbye 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/wampy.messages.publish.rst: -------------------------------------------------------------------------------- 1 | wampy.messages.publish module 2 | ============================= 3 | 4 | .. automodule:: publish 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/wampy.roles.publisher.rst: -------------------------------------------------------------------------------- 1 | wampy.roles.publisher module 2 | ============================ 3 | 4 | .. automodule:: publisher 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/wampy.messages.register.rst: -------------------------------------------------------------------------------- 1 | wampy.messages.register module 2 | ============================== 3 | 4 | .. automodule:: register 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/wampy.roles.subscriber.rst: -------------------------------------------------------------------------------- 1 | wampy.roles.subscriber module 2 | ============================= 3 | 4 | .. automodule:: subscriber 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /wampy/testing/configs/README.rst: -------------------------------------------------------------------------------- 1 | Crossbar Config Examples 2 | ======================== 3 | 4 | Note that the default IP version is 4. All configs specify IPv4 unless ipv6 is included in the file name. 5 | -------------------------------------------------------------------------------- /docs/wampy.messages.subscribe.rst: -------------------------------------------------------------------------------- 1 | wampy.messages.subscribe module 2 | =============================== 3 | 4 | .. automodule:: subscribe 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | -------------------------------------------------------------------------------- /wampy/cli/__init__.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | -------------------------------------------------------------------------------- /wampy/roles/__init__.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | -------------------------------------------------------------------------------- /docs/templates/sidebarlinks.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wampy/testing/keys/dhparam.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN DH PARAMETERS----- 2 | MIGHAoGBALOSAAEXXAZLxNbZOw92qfwJtToq1eiVr8vF7jhVESvZXviE/nwHVYw3 3 | IkbjFJDK+6kwH+KGpMGf4DYDraRpiArLOoveL8swh3WouKAEl0o5yoLwUi072W8j 4 | SSPEGqRh+19r2HNo25/mgxF9ddMKFalGzURM2oTl4y+ZXaIt6GsTAgEC 5 | -----END DH PARAMETERS----- 6 | -------------------------------------------------------------------------------- /CONTRIBUTORS.txt: -------------------------------------------------------------------------------- 1 | Contributions have been made by: 2 | 3 | @abulte Alexandre Bulté 4 | @titilambert Thibault Cohen 5 | @jpinsonault Joe Pinsonault 6 | @fnordian Marcus Hunger 7 | @jwg4 8 | @FoxMaSk 9 | @chadrik Chad Dombrova 10 | @tortoisedoc 11 | @keiser1080 12 | @wodCZ 13 | @goranpetrovikj 14 | -------------------------------------------------------------------------------- /wampy/transports/__init__.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | from . websocket import WebSocket, SecureWebSocket # noqa 6 | -------------------------------------------------------------------------------- /wampy/transports/websocket/__init__.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | from . connection import WebSocket, SecureWebSocket # noqa 6 | -------------------------------------------------------------------------------- /wampy/peers/__init__.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | from . clients import Client # noqa 6 | from . routers import Crossbar # noqa 7 | -------------------------------------------------------------------------------- /wampy/backends/errors.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | import eventlet 6 | import gevent 7 | 8 | 9 | class WampyTimeOut(gevent.Timeout, eventlet.Timeout): 10 | pass 11 | -------------------------------------------------------------------------------- /test/integration/test_really_long_strings.py: -------------------------------------------------------------------------------- 1 | from wampy.peers.clients import Client 2 | 3 | 4 | def test_send_really_long_string(router, echo_service): 5 | really_long_string = "a" * 1000 6 | 7 | caller = Client(url=router.url) 8 | with caller: 9 | response = caller.rpc.echo(message=really_long_string) 10 | 11 | assert response['message'] == really_long_string 12 | -------------------------------------------------------------------------------- /wampy/testing/__init__.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | from . helpers import wait_for_subscriptions # NOQA 6 | from . helpers import wait_for_registrations # NOQA 7 | from . helpers import wait_for_session # NOQA 8 | -------------------------------------------------------------------------------- /test/integration/test_message_handler.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | 6 | class TestMessageHandler: 7 | # yep, agreed, this looks bad. 8 | # but all APIs are covered by end-to-end tests. 9 | # however, this is embarrassing. 10 | pass 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | sudo: false 4 | 5 | python: 6 | - "3.7" 7 | - "3.7.3" 8 | - "3.8" 9 | - "3.9" 10 | 11 | install: 12 | - sudo apt-get install libsnappy-dev # this is for twisted's numerous dependencies 13 | - pip3 install --upgrade setuptools coverage 14 | - pip3 install --upgrade --editable .[dev] 15 | - pip3 install pytest-cov 16 | - pip3 install coveralls 17 | 18 | script: 19 | - pytest -s -vv --cov=./wampy 20 | 21 | after_success: 22 | - coveralls 23 | -------------------------------------------------------------------------------- /docs/templates/relations.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/what_is_wampy.rst: -------------------------------------------------------------------------------- 1 | What is wampy? 2 | ============== 3 | 4 | This is a Python implementation of **WAMP** not requiring Twisted or asyncio, enabling use within classic blocking Python applications. It is a light-weight alternative to `autobahn`_. 5 | 6 | With **wampy** you can quickly and easily create your own **WAMP** clients, whether this is in a web app, a microservice, a script or just in a Python shell. 7 | 8 | **wampy** tries to provide an intuitive API for your **WAMP** messaging. 9 | 10 | .. _autobahn: http://autobahn.ws/python/ 11 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | install: 2 | pip3 install --editable .[dev] 3 | 4 | docs: 5 | pip3 install --editable .[doc] 6 | 7 | tests: 8 | pip3 install --editable .[dev] 9 | pip3 install coverage 10 | pip3 install pytest-cov 11 | py.test ./test -vs 12 | 13 | unit-tests: 14 | py.test ./test/unit -vs 15 | 16 | lint: 17 | flake8 . 18 | 19 | coverage: 20 | coverage run --source ./wampy -m py.test ./test/ && coverage report 21 | 22 | crossbar: 23 | crossbar start --config ./wampy/testing/configs/crossbar.json 24 | 25 | deploy: 26 | pip install -U twine wheel setuptools 27 | python setup.py bdist_wheel --universal 28 | twine upload dist/* 29 | -------------------------------------------------------------------------------- /docs/subscribing_to_a_topic.rst: -------------------------------------------------------------------------------- 1 | Subscribing to a Topic 2 | ====================== 3 | 4 | You need a long running wampy application process for this. 5 | 6 | :: 7 | 8 | from wampy.peers.clients import Client 9 | from wampy.roles.subscriber import subscribe 10 | 11 | 12 | class WampyApp(Client): 13 | 14 | @subscribe(topic="topic-name") 15 | def weather_events(self, topic_data): 16 | # do something with the ``topic_data`` here 17 | pass 18 | 19 | 20 | See `runnning a wampy application`_ for executing the process. 21 | 22 | 23 | .. _runnning a wampy application: a_wampy_application.html#running-the-application 24 | -------------------------------------------------------------------------------- /docs/publishing_to_a_topic.rst: -------------------------------------------------------------------------------- 1 | Publishing to a Topic 2 | ===================== 3 | 4 | To publish to a topic you simply call the ``publish`` API on any wampy client with the topic name and message to deliver. 5 | 6 | :: 7 | 8 | from wampy.peers.clients import Client 9 | from wampy.peers.routers import Crossbar 10 | 11 | with Client(router=Crossbar()) as client: 12 | client.publish(topic="foo", message={'foo': 'bar'}) 13 | 14 | 15 | The message can be whatever JSON serializable object you choose. 16 | 17 | Note that the Crossbar router does require a path to an expected ``config.yaml``, but here a default value is used. The default for Crossbar is ``"./crossbar/config.json"``. 18 | -------------------------------------------------------------------------------- /test/integration/test_unicode.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | from wampy.peers.clients import Client 8 | from wampy.testing.helpers import wait_for_session 9 | 10 | 11 | def test_client_sending_unicode_does_not_raise(router, echo_service): 12 | class MyClient(Client): 13 | pass 14 | 15 | client = MyClient(url=router.url) 16 | 17 | client.start() 18 | wait_for_session(client) 19 | 20 | response = client.rpc.echo(weird_text="100éfa") 21 | 22 | assert response is not None 23 | -------------------------------------------------------------------------------- /docs/tls.rst: -------------------------------------------------------------------------------- 1 | TLS/wss Support 2 | =============== 3 | 4 | Your Router must be configured to use TLS. For an example see the `config`_ used by the test runner along with the `TLS Router`_ setup. 5 | 6 | To connect a Client over TLS you must provide a connection URL using the ``wss`` protocol and your **Router** probably will require you to provide a certificate for authorisation. 7 | 8 | :: 9 | 10 | In [1]: from wampy.peers import Client 11 | 12 | In [2]: client = Client(url="wss://...", cert_path="./...") 13 | 14 | .. _config: https://github.com/noisyboiler/wampy/blob/master/wampy/testing/configs/crossbar.config.ipv4.tls.json 15 | .. _TLS Router: https://github.com/noisyboiler/wampy/blob/master/test/test_transports.py#L71 16 | -------------------------------------------------------------------------------- /wampy/errors.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | 6 | class ConnectionError(Exception): 7 | pass 8 | 9 | 10 | class IncompleteFrameError(Exception): 11 | def __init__(self, required_bytes): 12 | self.required_bytes = required_bytes 13 | 14 | 15 | class WampProtocolError(Exception): 16 | pass 17 | 18 | 19 | class WebsocktProtocolError(Exception): 20 | pass 21 | 22 | 23 | class WampyError(Exception): 24 | pass 25 | 26 | 27 | class WampyTimeOutError(Exception): 28 | pass 29 | 30 | 31 | class NoFrameReturnedError(Exception): 32 | pass 33 | -------------------------------------------------------------------------------- /docs/exception_handling.rst: -------------------------------------------------------------------------------- 1 | Exception Handling 2 | ================== 3 | 4 | When calling a remote procedure an ``Exception`` might be raised by the remote application. It this happens the *Callee's* ``Exception`` will be wrapped in a wampy ``RemoteError`` and will contain the name of the remote procedure that raised the error, the ``request_id``, the exception type and any message. 5 | 6 | :: 7 | 8 | from wampy.errors import RemoteError 9 | from wampy.peers.clients import Client 10 | 11 | 12 | with Client() as client: 13 | 14 | try: 15 | response = client.rpc.some_unreliable_procedure() 16 | except RemoteError as rmt_err: 17 | # do stuff here to recover from the error or 18 | # fail gracefully 19 | -------------------------------------------------------------------------------- /wampy/messages/registered.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | 6 | class Registered(object): 7 | """ [REGISTERED, REGISTER.Request|id, Registration|id] 8 | """ 9 | WAMP_CODE = 65 10 | name = "registered" 11 | 12 | def __init__(self, request_id, registration_id): 13 | 14 | super(Registered, self).__init__() 15 | 16 | self.request_id = request_id 17 | self.registration_id = registration_id 18 | 19 | @property 20 | def message(self): 21 | return [ 22 | self.WAMP_CODE, self.request_id, self.registration_id, 23 | ] 24 | -------------------------------------------------------------------------------- /test/unit/test_configure.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from mock import patch 4 | 5 | 6 | def test_configure_logging(caplog): 7 | from wampy import configure_logging 8 | caplog.set_level(logging.INFO) 9 | configure_logging() 10 | 11 | assert 'logging configured' in caplog.text 12 | 13 | 14 | def test_configure_gevent_async(caplog): 15 | from wampy import configure_async 16 | caplog.set_level(logging.INFO) 17 | configure_async() 18 | 19 | assert 'gevent monkey-patched your environment' in caplog.text 20 | 21 | 22 | def test_configure_eventlet_async(caplog): 23 | from wampy import configure_async 24 | with patch('wampy.async_name', 'eventlet'): 25 | configure_async() 26 | 27 | assert 'eventlet monkey-patched your environment' in caplog.text 28 | -------------------------------------------------------------------------------- /wampy/serializers.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | import simplejson as json 6 | from wampy.errors import WampProtocolError 7 | 8 | 9 | def json_serialize(message): 10 | # WAMP serialization insists on UTF-8 encoded Unicode 11 | try: 12 | data = json.dumps( 13 | message, separators=(',', ':'), ensure_ascii=False, 14 | encoding='utf-8', 15 | ) 16 | except TypeError as exc: 17 | raise WampProtocolError( 18 | "Message not serialized: {} - {}".format( 19 | message, str(exc) 20 | ) 21 | ) 22 | 23 | return data 24 | -------------------------------------------------------------------------------- /test/unit/test_authentication.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | from wampy.auth import compute_wcs 6 | 7 | 8 | def test_compute_wamp_challenge_response(): 9 | secret = 'prq7+YkJ1/KlW1X0YczMHw==' 10 | challenge_data = { 11 | "authid": "peter", 12 | "authrole": "wampy", 13 | "authmethod": "wampcra", 14 | "authprovider": "static", 15 | "session": 3071302313344522, 16 | "nonce": "acL4f0gAqPijddsa6ko555z5nTLF3pjWp0lO0okYDvCC4GhXt8NbTooRaeYjNwTu", # noqa 17 | "timestamp": "2019-01-17T12:09:52.508Z", 18 | } 19 | 20 | assert compute_wcs(secret, str(challenge_data)) 21 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | import pytest 6 | 7 | from wampy.peers.clients import Client 8 | from wampy.roles.callee import callee 9 | from wampy.testing.helpers import wait_for_registrations, wait_for_session 10 | 11 | 12 | @pytest.fixture 13 | def config_path(): 14 | return './wampy/testing/configs/crossbar.json' 15 | 16 | 17 | class EchoService(Client): 18 | 19 | @callee 20 | def echo(self, **kwargs): 21 | return kwargs 22 | 23 | 24 | @pytest.yield_fixture 25 | def echo_service(router): 26 | with EchoService(url=router.url) as svc: 27 | wait_for_session(svc) 28 | wait_for_registrations(svc, 1) 29 | yield 30 | -------------------------------------------------------------------------------- /wampy/backends/__init__.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | from wampy.config.defaults import async_name 6 | from wampy.constants import EVENTLET, GEVENT 7 | from wampy.errors import WampyError 8 | 9 | 10 | def get_async_adapter(): 11 | if async_name == GEVENT: 12 | from . gevent_ import Gevent 13 | _adapter = Gevent() 14 | return _adapter 15 | 16 | if async_name == EVENTLET: 17 | from . eventlet_ import Eventlet 18 | _adapter = Eventlet() 19 | return _adapter 20 | 21 | raise WampyError( 22 | 'only gevent and eventlet are supported, sorry. help out??' 23 | ) 24 | 25 | 26 | async_adapter = get_async_adapter() 27 | -------------------------------------------------------------------------------- /wampy/messages/welcome.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | import logging 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class Welcome(object): 11 | """ A _Router_ completes the opening of a WAMP session by sending a 12 | "WELCOME" reply message to the _Client_. 13 | 14 | [WELCOME, Session|id, Details|dict] 15 | 16 | """ 17 | WAMP_CODE = 2 18 | name = "welcome" 19 | 20 | def __init__(self, session_id, details_dict): 21 | self.session_id = session_id 22 | self.details = details_dict 23 | 24 | @property 25 | def message(self): 26 | return [ 27 | self.WAMP_CODE, self.session_id, self.details, 28 | ] 29 | -------------------------------------------------------------------------------- /docs/remote_procedure_calls.rst: -------------------------------------------------------------------------------- 1 | Remote Procedure Calls 2 | ====================== 3 | 4 | Classic 5 | ------- 6 | 7 | Conventional remote procedure calling over Crossbar.io. 8 | 9 | :: 10 | 11 | from wampy.peers import Client 12 | from wampy.peers.routers import Crossbar 13 | 14 | with Client(router=Crossbar()) as client: 15 | result = client.call("example.app.com.endpoint", *args, **kwargs) 16 | 17 | 18 | Microservices 19 | ------------- 20 | 21 | Inspired by the `nameko`_ project. 22 | 23 | :: 24 | 25 | from wampy.peers import Client 26 | from wampy.peers.routers import Crossbar 27 | 28 | with Client(router=Crossbar()) as client: 29 | result = client.rpc.endpoint(**kwargs) 30 | 31 | See `nameko_wamp`_ for usage. 32 | 33 | .. _nameko: https://github.com/nameko/nameko 34 | .. _nameko_wamp: https://github.com/noisyboiler/nameko-wamp 35 | -------------------------------------------------------------------------------- /wampy/messages/abort.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | 6 | class Abort(object): 7 | WAMP_CODE = 3 8 | name = "abort" 9 | 10 | def __init__(self, details=None, uri=None): 11 | """ Sent by a Peer*to abort the opening of a WAMP session. 12 | 13 | No response is expected. 14 | 15 | :Parameters: 16 | details : dict 17 | uri : string 18 | 19 | [ABORT, Details|dict, Reason|uri] 20 | 21 | """ 22 | super(Abort, self).__init__() 23 | 24 | self.details = details 25 | self.uri = uri 26 | 27 | @property 28 | def message(self): 29 | return [ 30 | self.WAMP_CODE, self.details, self.uri 31 | ] 32 | -------------------------------------------------------------------------------- /wampy/messages/subscribed.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | 6 | class Subscribed(object): 7 | """ If the _Broker_ is able to fulfill and allow the subscription, it 8 | answers by sending a "SUBSCRIBED" message to the _Subscriber_ 9 | 10 | [SUBSCRIBED, SUBSCRIBE.Request|id, Subscription|id] 11 | 12 | """ 13 | WAMP_CODE = 33 14 | name = "subscribed" 15 | 16 | def __init__(self, request_id, subscription_id): 17 | super(Subscribed, self).__init__() 18 | 19 | self.request_id = request_id 20 | self.subscription_id = subscription_id 21 | 22 | @property 23 | def message(self): 24 | return [ 25 | self.WAMP_CODE, self.request_id, self.subscription_id, 26 | ] 27 | -------------------------------------------------------------------------------- /wampy/auth.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | import binascii 6 | import hmac 7 | import hashlib 8 | 9 | 10 | def compute_wcs(key, challenge): 11 | """ 12 | Compute an WAMP-CRA authentication signature from an authentication 13 | challenge and a (derived) key. 14 | 15 | :param key: The key derived (via PBKDF2) from the secret. 16 | :type key: str/bytes 17 | :param challenge: The authentication challenge to sign. 18 | :type challenge: str/bytes 19 | 20 | :return: The authentication signature. 21 | :rtype: bytes 22 | """ 23 | key = key.encode('utf8') 24 | challenge = challenge.encode('utf8') 25 | sig = hmac.new(key, challenge, hashlib.sha256).digest() 26 | return binascii.b2a_base64(sig).strip() 27 | -------------------------------------------------------------------------------- /wampy/cli/main.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | import argparse 5 | import logging 6 | 7 | from . import run 8 | 9 | logger = logging.getLogger("wampy") 10 | 11 | 12 | def setup_parser(): 13 | parser = argparse.ArgumentParser() 14 | subparsers = parser.add_subparsers() 15 | 16 | for module in [run, ]: 17 | name = module.__name__.split('.')[-1] 18 | module_parser = subparsers.add_parser( 19 | name, description=module.__doc__ 20 | ) 21 | module.init_parser(module_parser) 22 | module_parser.set_defaults(main=module.main) 23 | 24 | return parser 25 | 26 | 27 | def main(): 28 | parser = setup_parser() 29 | args = parser.parse_args() 30 | logger.info("starting CLI with args: %s", args) 31 | args.main(args) 32 | -------------------------------------------------------------------------------- /wampy/messages/authenticate.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | 6 | class Authenticate(object): 7 | WAMP_CODE = 5 8 | name = "authenticate" 9 | 10 | def __init__(self, signature, kwargs_dict=None): 11 | """ The "AUTHENTICATE" message is used with certain Authentication 12 | Methods. A *Client* having received a challenge is expected to 13 | respond by sending a signature or token. 14 | 15 | [AUTHENTICATE, Signature|string, Extra|dict] 16 | 17 | """ 18 | super(Authenticate, self).__init__() 19 | 20 | self.signature = signature 21 | self.kwargs_dict = kwargs_dict or {} 22 | 23 | @property 24 | def message(self): 25 | return [ 26 | self.WAMP_CODE, self.signature, self.kwargs_dict 27 | ] 28 | -------------------------------------------------------------------------------- /wampy/roles/subscriber.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | import logging 6 | 7 | from wampy.errors import WampyError 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class RegisterSubscriptionDecorator(object): 13 | 14 | def __init__(self, **kwargs): 15 | if "topic" not in kwargs: 16 | raise WampyError( 17 | "subscriber missing ``topic`` keyword argument" 18 | ) 19 | 20 | self.topic = kwargs['topic'] 21 | 22 | def __call__(self, f): 23 | def wrapped_f(*args, **kwargs): 24 | f(*args, **kwargs) 25 | 26 | wrapped_f.subscriber = True 27 | wrapped_f.topic = self.topic 28 | wrapped_f.handler = f 29 | return wrapped_f 30 | 31 | 32 | subscribe = RegisterSubscriptionDecorator 33 | -------------------------------------------------------------------------------- /wampy/messages/goodbye.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | 6 | class Goodbye(object): 7 | """ Send a GOODBYE message to the Router. 8 | 9 | Message is of the format ``[GOODBYE, Details|dict, Reason|uri]``, e.g. :: 10 | 11 | [ 12 | GOODBYE, {}, "wamp.close.normal" 13 | ] 14 | 15 | """ 16 | WAMP_CODE = 6 17 | DEFAULT_REASON = "wamp.error.close_realm" 18 | 19 | name = "goodbye" 20 | 21 | def __init__( 22 | self, details=None, reason=DEFAULT_REASON, 23 | ): 24 | 25 | super(Goodbye, self).__init__() 26 | 27 | self.details = details or {} 28 | self.reason = reason 29 | 30 | @property 31 | def message(self): 32 | return [ 33 | self.WAMP_CODE, self.details, self.reason 34 | ] 35 | -------------------------------------------------------------------------------- /coverage.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | coverage 17 | coverage 18 | 86% 19 | 86% 20 | 21 | 22 | -------------------------------------------------------------------------------- /wampy/messages/subscribe.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | import random 6 | 7 | 8 | class Subscribe(object): 9 | """ Send a SUBSCRIBE message to the Router. 10 | 11 | Message is of the format ``[SUBSCRIBE, Request|id, Options|dict, 12 | Topic|uri]``, e.g. :: 13 | 14 | [ 15 | 32, 713845233, {}, "com.myapp.mytopic1" 16 | ] 17 | 18 | """ 19 | WAMP_CODE = 32 20 | name = "subscribe" 21 | 22 | def __init__(self, topic, options=None): 23 | super(Subscribe, self).__init__() 24 | 25 | self.topic = topic 26 | self.options = options or {} 27 | self.request_id = random.getrandbits(32) 28 | 29 | @property 30 | def message(self): 31 | return [ 32 | self.WAMP_CODE, self.request_id, self.options, self.topic 33 | ] 34 | -------------------------------------------------------------------------------- /wampy/messages/challenge.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | 6 | class Challenge(object): 7 | WAMP_CODE = 4 8 | name = "challenge" 9 | 10 | def __init__(self, auth_method, kwargs_dict): 11 | """ The "CHALLENGE" message is used with certain Authentication 12 | Methods. During authenticated session establishment, a *Router* 13 | sends a challenge message. 14 | 15 | [CHALLENGE, AuthMethod|string, Extra|dict] 16 | 17 | """ 18 | super(Challenge, self).__init__() 19 | 20 | self.auth_method = auth_method 21 | self.kwargs_dict = kwargs_dict 22 | 23 | @property 24 | def message(self): 25 | return [ 26 | self.WAMP_CODE, self.auth_method, self.kwargs_dict 27 | ] 28 | 29 | @property 30 | def value(self): 31 | return self.kwargs_dict 32 | 33 | @property 34 | def challenge(self): 35 | return self.value['challenge'] 36 | -------------------------------------------------------------------------------- /wampy/interfaces.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | import abc 6 | 7 | import six 8 | 9 | 10 | @six.add_metaclass(abc.ABCMeta) 11 | class Transport(object): 12 | 13 | @abc.abstractmethod 14 | def connect(self): 15 | """ should return ``self`` as the "connection" object """ 16 | 17 | @abc.abstractmethod 18 | def disconnect(self): 19 | pass 20 | 21 | @abc.abstractmethod 22 | def send(self, message): 23 | pass 24 | 25 | @abc.abstractmethod 26 | def receive(self): 27 | pass 28 | 29 | 30 | @six.add_metaclass(abc.ABCMeta) 31 | class Async(object): 32 | 33 | @abc.abstractmethod 34 | def Timeout(self, timeout): 35 | pass 36 | 37 | @abc.abstractmethod 38 | def receive_message(self, timeout): 39 | pass 40 | 41 | @abc.abstractmethod 42 | def spawn(self, *args, **kwargs): 43 | pass 44 | 45 | @abc.abstractmethod 46 | def sleep(self, time): 47 | pass 48 | -------------------------------------------------------------------------------- /wampy/messages/cancel.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | 6 | class Cancel(object): 7 | """ When a Caller wishes to cancel a remote procedure, it sends a "CANCEL" 8 | message to the Dealer. 9 | 10 | Message is of the format 11 | ``[CANCEL, CALL.Request|id, Options|dict]``, e.g. :: 12 | 13 | [ 14 | CANCEL, 10001, {}, 15 | ] 16 | 17 | "Request" is the ID used to make the Call being cancelled. 18 | 19 | "Options" is a dictionary that allows to provide additional 20 | cancellation request details in a extensible way. 21 | 22 | """ 23 | WAMP_CODE = 49 24 | name = "cancel" 25 | 26 | def __init__(self, request_id, options=None): 27 | super(Cancel, self).__init__() 28 | 29 | self.request_id = request_id 30 | self.options = options or {} 31 | 32 | @property 33 | def message(self): 34 | return [ 35 | self.WAMP_CODE, self.request_id, self.options, 36 | ] 37 | -------------------------------------------------------------------------------- /wampy/messages/publish.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | import random 6 | 7 | 8 | class Publish(object): 9 | """ Send a PUBLISH message to the Router. 10 | 11 | Message is of the format ``[PUBLISH, Request|id, Options|dict, 12 | Topic|uri, Arguments|list, ArgumentsKw|dict]``, e.g. :: 13 | 14 | [ 15 | 16, 239714735, {}, "com.myapp.mytopic1", [], 16 | {"color": "orange", "sizes": [23, 42, 7]} 17 | ] 18 | 19 | """ 20 | WAMP_CODE = 16 21 | name = "publish" 22 | 23 | def __init__(self, topic, options, *args, **kwargs): 24 | super(Publish, self).__init__() 25 | 26 | self.topic = topic 27 | self.options = options 28 | self.request_id = random.getrandbits(32) 29 | self.args = args 30 | self.kwargs = kwargs 31 | 32 | @property 33 | def message(self): 34 | return [ 35 | self.WAMP_CODE, self.request_id, self.options, self.topic, 36 | self.args, self.kwargs 37 | ] 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | .hypothesis/ 46 | .pytest_cache 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | #Ipython Notebook 62 | .ipynb_checkpoints 63 | 64 | #Crossbar 65 | .crossbar 66 | node.pid 67 | node.key 68 | 69 | #Mac 70 | .DS_Store 71 | 72 | #wampy 73 | config.yaml 74 | key.* 75 | test-runner-log 76 | -------------------------------------------------------------------------------- /wampy/roles/publisher.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | import logging 6 | 7 | from wampy.errors import WampyError 8 | from wampy.messages.publish import Publish 9 | 10 | 11 | logger = logging.getLogger('wampy.publishing') 12 | 13 | 14 | class PublishProxy: 15 | 16 | def __init__(self, client): 17 | self.client = client 18 | 19 | def __call__(self, *unsupported_args, **kwargs): 20 | if len(unsupported_args) != 0: 21 | raise WampyError( 22 | "wampy only supports publishing keyword arguments " 23 | "to a Topic." 24 | ) 25 | 26 | topic = kwargs.pop("topic") 27 | if not kwargs: 28 | raise WampyError( 29 | "wampy requires at least one message to publish to a topic" 30 | ) 31 | 32 | if "options" not in kwargs: 33 | kwargs["options"] = {} 34 | message = Publish(topic=topic, **kwargs) 35 | logger.info('publishing message: "%s"', message.message) 36 | 37 | self.client.send_message(message) 38 | -------------------------------------------------------------------------------- /wampy/testing/keys/server_cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDNjCCAh4CCQCP5LnGD/PkTDANBgkqhkiG9w0BAQUFADBdMQswCQYDVQQGEwJE 3 | RTEQMA4GA1UECAwHQmF2YXJpYTERMA8GA1UEBwwIRXJsYW5nZW4xFTATBgNVBAoM 4 | DFRhdmVuZG8gR21iSDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTE0MTEwNjIxMjIx 5 | MloXDTI0MTEwMzIxMjIxMlowXTELMAkGA1UEBhMCREUxEDAOBgNVBAgMB0JhdmFy 6 | aWExETAPBgNVBAcMCEVybGFuZ2VuMRUwEwYDVQQKDAxUYXZlbmRvIEdtYkgxEjAQ 7 | BgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB 8 | AK54iBXPWPyCGlsrGhBV1xkrfubttugoqADvfYP9eptzqOCzcAqtlWLFYf1tDGbT 9 | mkwNsgV/lJKVzGMyosSAuRBmolRTQeyBcypsX9QJt6s/PJ2vh3v2QQ7DWlokZGhx 10 | sCX6p6GEUbcBo61Yc+AvhAmJ+olYyLqp8nCIJFy0F+P7qvxx1B6pg08J/d75/m9A 11 | BiGgZ0WjYLrDj0yERWX3WljuYIT4e34WMPofaBeiMTQEpqdgTbuSgpXdNdlzuWaq 12 | gn8ITIHMnf5mlnCeU1rsHMJwJAUzRR9S40Km4WNxIP4/j+uVwMQLcJg73ni43n5V 13 | xza6fgCQGfe8HQLxItwoU0MCAwEAATANBgkqhkiG9w0BAQUFAAOCAQEAEsiNnmRU 14 | O4UW2AjI5EIr1Na7Xae8NZyIGrH+ONJUxaQxrpj1Q/bHmN7ZPFIDkZ2NX0rZBWwC 15 | gbLSG0HIK2CdA+ghW0RaOnQgzLoJKJCERMSYDL26AXBJ4L0h5U43rsgXTD/zaWid 16 | 9B90pxSCXvSnA+yw1U67Lgm1x9LEkgpGWpqPlHQBMvwi4gkkdUsPxBLCie4TfX+p 17 | gB51+Rjh8HaLl9tc0Z1JcjhK7ih1kz6A6XF7GaMvW0ipZYX/xIkUQNMTKhfdrrms 18 | qr2GdKmJpFKJhrvYpbx560R0ZT2Sxic8EOLjfWfvxD56xTU6ssosw1oYdRB2W9i7 19 | hsK8dBzPGj1ZcQ== 20 | -----END CERTIFICATE----- 21 | -------------------------------------------------------------------------------- /wampy/roles/callee.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | import logging 6 | import types 7 | from functools import partial 8 | 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class RegisterProcedureDecorator(object): 14 | 15 | def __init__(self, *args, **kwargs): 16 | self.invocation_policy = kwargs.get("invocation_policy", "single") 17 | 18 | @classmethod 19 | def decorator(cls, *args, **kwargs): 20 | 21 | def registering_decorator(fn, args, kwargs): 22 | invocation_policy = kwargs.get("invocation_policy", "single") 23 | fn.callee = True 24 | fn.invocation_policy = invocation_policy 25 | return fn 26 | 27 | if len(args) == 1 and isinstance(args[0], types.FunctionType): 28 | # usage without arguments to the decorator: 29 | return registering_decorator(args[0], args=(), kwargs={}) 30 | else: 31 | # usage with arguments to the decorator: 32 | return partial(registering_decorator, args=args, kwargs=kwargs) 33 | 34 | 35 | callee = RegisterProcedureDecorator.decorator 36 | -------------------------------------------------------------------------------- /wampy/__init__.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | # Set default logging handler to avoid "No handler found" warnings. 6 | import logging 7 | from logging import NullHandler 8 | 9 | from wampy.config.defaults import async_name 10 | from wampy.constants import EVENTLET, GEVENT 11 | 12 | logger = logging.getLogger("wampy") 13 | 14 | 15 | def configure_logging(): 16 | FORMAT = '%(asctime)-15s %(levelname)s:%(message)s' 17 | logging.basicConfig(format=FORMAT, level=logging.INFO) 18 | 19 | root = logging.getLogger() 20 | root.addHandler(NullHandler()) 21 | 22 | logger.info("logging configured") 23 | 24 | 25 | def configure_async(): 26 | if async_name == GEVENT: 27 | import gevent.monkey 28 | gevent.monkey.patch_all() 29 | logger.warning('gevent monkey-patched your environment') 30 | 31 | if async_name == EVENTLET: 32 | import eventlet 33 | eventlet.monkey_patch() 34 | logger.warning('eventlet monkey-patched your environment') 35 | 36 | 37 | configure_async() 38 | configure_logging() 39 | 40 | logger.info('wampy starting up with event loop: %s', async_name) 41 | -------------------------------------------------------------------------------- /test/integration/test_app_runner.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | 5 | from wampy.cli.run import run 6 | from wampy.peers.clients import Client 7 | 8 | 9 | @pytest.mark.skip( 10 | reason="no way of currently stopping the app runner from here" 11 | ) 12 | class TestAppRunner(object): 13 | 14 | @pytest.fixture 15 | def config_path(self): 16 | return './wampy/testing/configs/crossbar.json' 17 | 18 | def test_app_runner(self, router, config_path): 19 | apps = [ 20 | 'docs.examples.services:DateService', 21 | 'docs.examples.services:BinaryNumberService', 22 | 'docs.examples.services:FooService', 23 | ] 24 | 25 | runner = run(apps, config_path=config_path) 26 | 27 | # now check we can call these wonderful services 28 | client = Client(url=router.url) 29 | with client: 30 | date = client.rpc.get_todays_date() 31 | binary_number = client.rpc.get_binary_number(46) 32 | foo = client.rpc.get_foo() 33 | 34 | print("stopping all services gracefully") 35 | runner.stop() 36 | print("services have stopped") 37 | 38 | assert date == datetime.date.today().isoformat() 39 | assert binary_number == '0b101110' 40 | assert foo == 'foo' 41 | -------------------------------------------------------------------------------- /wampy/messages/hello.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | 6 | class Hello(object): 7 | """ Send a HELLO message to the Router. 8 | 9 | By default the *wampy* Client announces support for all four Client 10 | Roles: Subscriber, Publisher, Callee and Caller. 11 | 12 | Message is of the format ``[HELLO, Realm|uri, Details|dict]``, e.g. :: 13 | 14 | [ 15 | HELLO, "realm", { 16 | "roles": { 17 | "subscriber": { 18 | 'features': {...}, 19 | }, 20 | "publisher": {...}, 21 | ... 22 | }, 23 | "authmethods": ["wampcra"], 24 | "authid": "peter" 25 | } 26 | ] 27 | 28 | """ 29 | WAMP_CODE = 1 30 | name = "hello" 31 | 32 | def __init__(self, realm, details): 33 | super(Hello, self).__init__() 34 | 35 | self.realm = realm 36 | self.details = details 37 | 38 | @property 39 | def message(self): 40 | return [ 41 | self.WAMP_CODE, self.realm, self.details 42 | ] 43 | -------------------------------------------------------------------------------- /test/integration/test_exception_handling.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | import pytest 6 | 7 | from wampy.errors import WampyError 8 | from wampy.peers.clients import Client 9 | from wampy.roles.callee import callee 10 | 11 | 12 | class UnreliableCallee(Client): 13 | 14 | @callee 15 | def get_foo(self, *args, **kwargs): 16 | raise ValueError( 17 | "i do not like any of your values: {}, {}".format( 18 | args, kwargs) 19 | ) 20 | 21 | 22 | @pytest.yield_fixture 23 | def unreliable_callee(router, config_path): 24 | with UnreliableCallee(url=router.url): 25 | yield 26 | 27 | 28 | def test_handle_value_error(unreliable_callee, router): 29 | with Client(url=router.url, name="caller") as client: 30 | 31 | with pytest.raises(WampyError) as exc_info: 32 | client.rpc.get_foo(1, 2, three=3) 33 | 34 | exception = exc_info.value 35 | assert type(exception) is WampyError 36 | 37 | message = str(exception) 38 | 39 | assert "i do not like any of your values" in message 40 | assert "(1, 2)" in message 41 | assert "three" in message 42 | -------------------------------------------------------------------------------- /wampy/config/defaults.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | # a temporary solution to configuration. wampy needs to decide how 6 | # users can configure him, e.g. config.yaml 7 | # for now, env varialbes are the way to override defaults. 8 | 9 | import logging 10 | import os 11 | 12 | from wampy.constants import ( 13 | DEFAULT_HEARTBEAT_SECONDS, DEFAULT_HEARTBEAT_TIMEOUT_SECONDS, 14 | GEVENT, EVENT_LOOP_BACKENDS 15 | ) 16 | from wampy.errors import WampyError 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | async_name = os.environ.get('WAMPY_ASYNC_NAME', GEVENT) 22 | logger.info('asycn name is "%s"', async_name) 23 | if async_name not in EVENT_LOOP_BACKENDS: 24 | logger.error('unsupported event loop for wampy! "%s"', async_name) 25 | raise WampyError( 26 | 'export your WAMPY_ASYNC_NAME os environ value to be one of "{}" ' 27 | 'or just remove and use the default gevent'.format( 28 | EVENT_LOOP_BACKENDS 29 | ), 30 | ) 31 | 32 | heartbeat = os.environ.get( 33 | 'WEBSOCKET_HEARTBEAT', DEFAULT_HEARTBEAT_SECONDS, 34 | ) 35 | 36 | heartbeat_timeout = os.environ.get( 37 | 'HEARTBEAT_TIMEOUT_SECONDS', DEFAULT_HEARTBEAT_TIMEOUT_SECONDS, 38 | ) 39 | -------------------------------------------------------------------------------- /wampy/messages/register.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | import random 6 | 7 | 8 | class Register(object): 9 | """ A Callee announces the availability of an endpoint implementing 10 | a procedure with a Dealer by sending a "REGISTER" message. 11 | 12 | Message is of the format 13 | ``[REGISTER, Request|id, Options|dict, Procedure|uri]``, e.g. :: 14 | 15 | [ 16 | REGISTER, 25349185, {}, "com.myapp.myprocedure1" 17 | ] 18 | 19 | "Request" is a random, ephemeral ID chosen by the Callee and 20 | used to correlate the Dealer's response with the request. 21 | 22 | "Options" is a dictionary that allows to provide additional 23 | registration request details in a extensible way. 24 | 25 | """ 26 | WAMP_CODE = 64 27 | name = "register" 28 | 29 | def __init__(self, procedure, options=None): 30 | super(Register, self).__init__() 31 | 32 | self.procedure = procedure 33 | self.options = options or {} 34 | self.request_id = random.getrandbits(32) 35 | 36 | @property 37 | def message(self): 38 | return [ 39 | self.WAMP_CODE, self.request_id, self.options, 40 | self.procedure 41 | ] 42 | -------------------------------------------------------------------------------- /wampy/messages/invocation.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | from . base import Message 6 | 7 | 8 | class Invocation(Message): 9 | """Actual invocation of an endpoint sent by Dealer to a Callee. 10 | 11 | [INVOCATION, Request|id, REGISTERED.Registration|id, 12 | Details|dict] 13 | 14 | [INVOCATION, Request|id, REGISTERED.Registration|id, 15 | Details|dict, C* Arguments|list] 16 | 17 | [INVOCATION, Request|id, REGISTERED.Registration|id, 18 | Details|dict, CALL.Arguments|list, CALL.ArgumentsKw|dict] 19 | 20 | """ 21 | WAMP_CODE = 68 22 | name = "invocation" 23 | 24 | def __init__( 25 | self, request_id, registration_id, details, 26 | call_args=None, call_kwargs=None, 27 | ): 28 | 29 | super(Invocation, self).__init__() 30 | 31 | self.request_id = request_id 32 | self.registration_id = registration_id 33 | self.details = details 34 | self.call_args = call_args or tuple() 35 | self.call_kwargs = call_kwargs or {} 36 | 37 | @property 38 | def message(self): 39 | return [ 40 | self.WAMP_CODE, self.request_id, self.registration_id, 41 | self.details, self.call_args, self.call_kwargs, 42 | ] 43 | -------------------------------------------------------------------------------- /wampy/messages/result.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | from . base import Message 6 | 7 | 8 | class Result(Message): 9 | """ The Dealer sends a "RESULT" message to the original 10 | Caller :: 11 | 12 | [RESULT, CALL.Request|id, Details|dict] 13 | 14 | or 15 | 16 | [RESULT, CALL.Request|id, Details|dict, YIELD.Arguments|list] 17 | 18 | or 19 | 20 | [RESULT, CALL.Request|id, Details|dict, YIELD.Arguments|list, 21 | YIELD.ArgumentsKw|dict] 22 | 23 | """ 24 | WAMP_CODE = 50 25 | name = "result" 26 | 27 | def __init__( 28 | self, request_id, details_dict, yield_args=None, 29 | yield_kwargs=None 30 | ): 31 | 32 | super(Result, self).__init__() 33 | 34 | self.request_id = request_id 35 | self.details = details_dict 36 | self.yield_args = yield_args 37 | self.yield_kwargs = yield_kwargs 38 | 39 | @property 40 | def message(self): 41 | return [ 42 | self.WAMP_CODE, self.request_id, self.details, self.yield_args, 43 | self.yield_kwargs 44 | ] 45 | 46 | @property 47 | def value(self): 48 | if self.yield_kwargs: 49 | return self.yield_kwargs['message'] 50 | return self.yield_args[0] 51 | -------------------------------------------------------------------------------- /wampy/messages/__init__.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | from . abort import Abort 6 | from . authenticate import Authenticate 7 | from . call import Call 8 | from . cancel import Cancel 9 | from . challenge import Challenge 10 | from . error import Error 11 | from . event import Event 12 | from . hello import Hello 13 | from . invocation import Invocation 14 | from . goodbye import Goodbye 15 | from . publish import Publish 16 | from . register import Register 17 | from . registered import Registered 18 | from . result import Result 19 | from . subscribe import Subscribe 20 | from . subscribed import Subscribed 21 | from . yield_ import Yield 22 | from . welcome import Welcome 23 | 24 | 25 | __all__ = [ 26 | Abort, Authenticate, Call, Challenge, Error, Event, Goodbye, Hello, 27 | Invocation, Publish, Register, Registered, Result, Subscribe, 28 | Subscribed, Welcome, Yield 29 | ] 30 | 31 | 32 | MESSAGE_TYPE_MAP = { 33 | 1: Hello, 34 | 2: Welcome, 35 | 3: Abort, 36 | 4: Challenge, 37 | 5: Authenticate, 38 | 6: Goodbye, 39 | 8: Error, 40 | 16: Publish, 41 | 32: Subscribe, 42 | 33: Subscribed, 43 | 36: Event, 44 | 48: Call, 45 | 49: Cancel, 46 | 50: Result, 47 | 64: Register, 48 | 65: Registered, 49 | 68: Invocation, 50 | 70: Yield, 51 | } 52 | -------------------------------------------------------------------------------- /wampy/messages/call.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | import random 6 | 7 | 8 | class Call(object): 9 | """ When a Caller wishes to call a remote procedure, it sends a "CALL" 10 | message to a Dealer. 11 | 12 | Message is of the format 13 | ``[CALL, Request|id, Options|dict, Procedure|uri, Arguments|list, 14 | ArgumentsKw|dict]``, e.g. :: 15 | 16 | [ 17 | CALL, 10001, {}, "com.myapp.myprocedure1", [], {} 18 | ] 19 | 20 | "Request" is a random, ephemeral ID chosen by the Callee and 21 | used to correlate the Dealer's response with the request. 22 | 23 | "Options" is a dictionary that allows to provide additional 24 | registration request details in a extensible way. 25 | 26 | """ 27 | WAMP_CODE = 48 28 | name = "call" 29 | 30 | def __init__(self, procedure, options=None, args=None, kwargs=None): 31 | super(Call, self).__init__() 32 | 33 | self.procedure = procedure 34 | self.options = options or {} 35 | self.args = args or [] 36 | self.kwargs = kwargs or {} 37 | self.request_id = random.getrandbits(32) 38 | 39 | @property 40 | def message(self): 41 | return [ 42 | self.WAMP_CODE, self.request_id, self.options, self.procedure, 43 | self.args, self.kwargs 44 | ] 45 | -------------------------------------------------------------------------------- /wampy/testing/configs/crossbar.ipv6.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "controller": { 4 | }, 5 | "workers": [ 6 | { 7 | "type": "router", 8 | "realms": [ 9 | { 10 | "name": "realm1", 11 | "roles": [ 12 | { 13 | "name": "anonymous", 14 | "permissions": [ 15 | { 16 | "uri": "", 17 | "match": "prefix", 18 | "allow": { 19 | "call": true, 20 | "register": true, 21 | "publish": true, 22 | "subscribe": true 23 | }, 24 | "disclose": { 25 | "caller": false, 26 | "publisher": false 27 | }, 28 | "cache": true 29 | } 30 | ] 31 | } 32 | ] 33 | } 34 | ], 35 | "transports": [ 36 | { 37 | "type": "websocket", 38 | "endpoint": { 39 | "type": "tcp", 40 | "port": 8080, 41 | "version": 6, 42 | "interface": "::1" 43 | } 44 | } 45 | ] 46 | } 47 | ] 48 | } -------------------------------------------------------------------------------- /wampy/messages/event.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | 6 | class Event(object): 7 | """ When a Subscriber_is deemed to be a receiver, the Broker sends 8 | the Subscriber an "EVENT" message: 9 | 10 | [EVENT, SUBSCRIBED.Subscription|id, PUBLISHED.Publication|id, 11 | Details|dict] 12 | 13 | or 14 | 15 | [EVENT, SUBSCRIBED.Subscription|id, PUBLISHED.Publication|id, 16 | Details|dict, PUBLISH.Arguments|list] 17 | 18 | or 19 | 20 | [EVENT, SUBSCRIBED.Subscription|id, PUBLISHED.Publication|id, 21 | Details|dict, PUBLISH.Arguments|list, PUBLISH.ArgumentKw|dict] 22 | 23 | """ 24 | WAMP_CODE = 36 25 | name = "event" 26 | 27 | def __init__( 28 | self, subscription_id, publication_id, details_dict, 29 | publish_args=None, publish_kwargs=None, 30 | ): 31 | 32 | super(Event, self).__init__() 33 | 34 | self.subscription_id = subscription_id 35 | self.publication_id = publication_id 36 | self.details = details_dict 37 | self.publish_args = publish_args or [] 38 | self.publish_kwargs = publish_kwargs or {} 39 | 40 | @property 41 | def message(self): 42 | return [ 43 | self.WAMP_CODE, self.subscription_id, self.publication_id, 44 | self.details, self.publish_args, self.publish_kwargs, 45 | ] 46 | -------------------------------------------------------------------------------- /docs/examples/services.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from wampy.peers.clients import Client 4 | from wampy.roles.callee import callee 5 | from wampy.roles.subscriber import subscribe 6 | 7 | 8 | class DateService(Client): 9 | """ A service that returns the current date. 10 | 11 | usage :: 12 | 13 | $ wampy run docs.examples.services:DateService --router http://example.com:port 14 | 15 | e.g. :: 16 | 17 | $ crossbar start --config ./wampy/testing/configs/crossbar.config.json 18 | $ wampy run docs.examples.services:SubscribingService --router http://localhost:8080 19 | 20 | """ # NOQA 21 | @callee 22 | def get_todays_date(self): 23 | return datetime.date.today().isoformat() 24 | 25 | 26 | class SubscribingService(Client): 27 | """ A service that prints out "foo" topic messages 28 | 29 | usage :: 30 | 31 | $ wampy run docs.examples.services:SubscribingService --router http://example.com:port 32 | 33 | e.g. :: 34 | 35 | $ crossbar start --config ./wampy/testing/configs/crossbar.config.json 36 | $ wampy run docs.examples.services:SubscribingService --router http://localhost:8080 37 | 38 | """ # NOQA 39 | @subscribe(topic="foo") 40 | def foo_handler(self, **kwargs): 41 | print("foo message received: {}".format(kwargs)) 42 | 43 | 44 | class BinaryNumberService(Client): 45 | 46 | @callee 47 | def get_binary_number(self, number): 48 | return bin(number) 49 | 50 | 51 | class FooService(Client): 52 | 53 | @callee 54 | def get_foo(self): 55 | return 'foo' 56 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing to wampy 2 | ---------------------- 3 | 4 | Contributions are very welcome! 5 | 6 | ### wampy style conventions 7 | 8 | wampy follows the standard [PEP8 style guide for Python](http://www.python.org/dev/peps/pep-0008/) and the [PEP257](http://www.python.org/dev/peps/pep-0257/) for docstrings. 9 | 10 | All source code is linted using the default configuration of [flake8](https://pypi.python.org/pypi/flake8). 11 | 12 | wampy prefers standard [guidlines](https://www.python.org/dev/peps/pep-0008/#programming-recommendations). 13 | 14 | For example, wampy loves trailing commas and doesn't really like backslashes. 15 | 16 | ### Imports 17 | 18 | Please follow the following convention: 19 | 20 | # standard lib, straight `imports` first please 21 | import os 22 | import sys 23 | from itertools import cycle, repeat # imports should be in alphabetical order 24 | 25 | # 3rd party imports 26 | import sqlalchemy 27 | from eventlet.green import urllib2 28 | 29 | # wampy imports 30 | from wampy.constants import ( 31 | DEFAULT_ROUTER_URL, DEFAULT_ROLES, DEFAULT_REALM, # trailing commas are appreciated 32 | ) 33 | from wampy.session import Session 34 | from wampy.messages import Abort, Challenge 35 | 36 | 37 | ### Line Length 38 | 39 | wampy especially enjoys line lengths <= 79 chars and longer lines crafted with appropriate lines breaks. 40 | 41 | For example, above, wampy always drops import lines longer than 79 chars on to a new line and never uses backslashes - parenthesis are always preferred. 42 | 43 | Please never use backslashes. 44 | -------------------------------------------------------------------------------- /docs/what_is_wamp.rst: -------------------------------------------------------------------------------- 1 | What is WAMP? 2 | ============= 3 | 4 | The `WAMP Protocol`_ is a powerful tool for your web applications and microservices - else just for your free time, fun and games! 5 | 6 | **WAMP** facilitates communication between independent applications over a common "router". An actor in this process is called a **Peer**, and a **Peer** is either a **Client** or the **Router**. 7 | 8 | **WAMP** messaging occurs between **Clients** over the **Router** via **Remote Procedure Call (RPC)** or the **Publish/Subscribe** pattern. As long as your **Client** knows how to connect to a **Router** it does not then need to know anything further about other connected **Peers** beyond a shared string name for an endpoint or **Topic**, i.e. it does not care where another **Client** application is, how many of them there might be, how they might be written or how to identify them. This is more simple than other messaging protocols, such as AMQP for example, where you also need to consider exchanges and queues in order to explicitly connect to other actors from your applications. 9 | 10 | **WAMP** is most commonly a WebSocket subprotocol (runs on top of WebSocket) that uses JSON as message serialization format. However, the protocol can also run with MsgPack as serialization, run over raw TCP or in fact any message based, bidirectional, reliable transport - but **wampy** (currently) runs over websockets only. 11 | 12 | For further reading please see some of the popular blog posts on WAMP such as http://tavendo.com/blog/post/is-crossbar-the-future-of-python-web-apps/. 13 | 14 | .. _WAMP Protocol: http://wamp-proto.org/ 15 | -------------------------------------------------------------------------------- /wampy/constants.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | DEFAULT_HOST = 'localhost' 6 | DEFAULT_PORT = 8080 7 | 8 | DEFAULT_ROUTER_URL = "ws://{}/{}".format(DEFAULT_HOST, DEFAULT_PORT) 9 | 10 | WEBSOCKET_VERSION = 13 11 | WEBSOCKET_SUBPROTOCOLS = 'wamp.2.json' 12 | WEBSOCKET_SUCCESS_STATUS = 101 13 | 14 | CALLEE = 'CALLEE' 15 | CALLER = 'CALLER' 16 | DEALER = 'DEALER' 17 | 18 | ROLES = [ 19 | CALLER, CALLEE, DEALER 20 | ] 21 | 22 | # Basic Profile 23 | DEFAULT_REALM = "realm1" 24 | # TODO: this encapsulates more than just "roles", while "features" 25 | # should really be passed in here too, e.g. `call_timeout`. This was 26 | # not fully understood when first desigining the API. 27 | DEFAULT_ROLES = { 28 | 'roles': { 29 | 'subscriber': {}, 30 | 'publisher': {}, 31 | 'callee': { 32 | 'shared_registration': True, 33 | }, 34 | 'caller': { 35 | 'features': { 36 | 'call_timeout': True, 37 | } 38 | }, 39 | }, 40 | 'authmethods': ['anonymous'] 41 | } 42 | DEFAULT_TIMEOUT = 10 # seconds 43 | 44 | # disabled by default. override with OS env variables. 45 | DEFAULT_HEARTBEAT_SECONDS = 0 46 | DEFAULT_HEARTBEAT_TIMEOUT_SECONDS = 2 47 | 48 | SUBSCRIBER = "subscriber" 49 | 50 | # WAMP URIs 51 | NOT_AUTHORISED = 'wamp.error.not_authorized' 52 | 53 | GEVENT = 'gevent' 54 | EVENTLET = 'eventlet' 55 | EVENT_LOOP_BACKENDS = [EVENTLET, GEVENT] 56 | -------------------------------------------------------------------------------- /wampy/backends/eventlet_.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | import eventlet 6 | 7 | from wampy.errors import WampyTimeOutError 8 | from wampy.interfaces import Async 9 | 10 | 11 | class Eventlet(Async): 12 | 13 | def __init__(self, message_queue=eventlet.queue.Queue()): 14 | self.message_queue = message_queue 15 | 16 | def __str__(self): 17 | return 'EventletAsyncAdapter' 18 | 19 | def queue(self): 20 | return eventlet.queue.Queue() 21 | 22 | @property 23 | def QueueEmpty(self): 24 | return eventlet.queue.Empty 25 | 26 | def Timeout(self, timeout, raise_after=True): 27 | return eventlet.Timeout(timeout, raise_after) 28 | 29 | def receive_message(self, timeout): 30 | try: 31 | message = self._wait_for_message(timeout) 32 | except eventlet.Timeout: 33 | raise WampyTimeOutError( 34 | "no message returned (timed-out in {})".format(timeout) 35 | ) 36 | return message 37 | 38 | def spawn(self, *args, **kwargs): 39 | gthread = eventlet.spawn(*args, **kwargs) 40 | return gthread 41 | 42 | def sleep(self, time=0): 43 | eventlet.sleep(time) 44 | 45 | def _wait_for_message(self, timeout): 46 | q = self.message_queue 47 | 48 | with eventlet.Timeout(timeout): 49 | while q.qsize() == 0: 50 | eventlet.sleep() 51 | 52 | message = q.get() 53 | return message 54 | -------------------------------------------------------------------------------- /docs/a_wampy_application.rst: -------------------------------------------------------------------------------- 1 | A wampy Application 2 | =================== 3 | 4 | This is a fully fledged example of a wampy application that implements all 4 WAMP Roles: caller, callee, publisher and subscriber. 5 | 6 | :: 7 | 8 | from wampy.peers.clients import Client 9 | from wampy.roles import callee 10 | from wampy.roles import subscriber 11 | 12 | 13 | class WampyApp(Client): 14 | 15 | @callee 16 | def get_weather(self, *args, **kwargs): 17 | weather = self.call("another.example.app.endpoint") 18 | return weather 19 | 20 | @subscriber(topic="global-weather") 21 | def weather_events(self, weather_data): 22 | # process weather data here 23 | self.publish(topic="wampy-weather", message=weather_data) 24 | 25 | 26 | Here the method decorated by @callee is a callable remote procedure. In this example, it also acts as a Caller, by calling another remote procedure and then returning the result. 27 | 28 | And the method decorated by @subscribe implements the Subscriber Role, and when it receives an Event it then acts as a Publisher, and publishes a new message to a topic. 29 | 30 | Note that the ``call`` and ``publish`` APIs are provided by the super class, ``Client``. 31 | 32 | Running The Application 33 | ----------------------- 34 | 35 | **wampy** provides a command line interface tool to start the application. 36 | 37 | :: 38 | 39 | $ wampy run path.to.your.module.including.module_name:WampyApp 40 | 41 | 42 | For example, running one of the **wampy** example applications. 43 | 44 | :: 45 | 46 | $ wampy run docs.examples.services:BinaryNumberService --config './wampy/testing/configs/crossbar.config.ipv4.json' 47 | -------------------------------------------------------------------------------- /docs/message_handler.rst: -------------------------------------------------------------------------------- 1 | The MessageHandler Class 2 | ============================ 3 | 4 | Every wampy ``Client`` requires a ``MessageHandler``. This is a class with a list of ``Messages`` it will accept and a "handle" method for each. 5 | 6 | The default ``MessageHandler`` contains everything you need to use WAMP in your microservices, but you may want to add more behaviour such as logging messages, encrypting messages, appending meta data or custom authorisation. 7 | 8 | If you want to define your own ``MessageHandler`` then you must subclass the default and override the "handle" methods for each ``Message`` customisation you need. 9 | 10 | Note that whenever the ``Session`` receives a ``Message`` it calls ``handle_message`` on the ``MessageHandler``. You can override this if you want to add global behaviour changes. ``handle_message`` will delegate to specific handlers, e.g. ``handle_invocation``. 11 | 12 | For example. 13 | 14 | :: 15 | 16 | from wampy.message_handler import MessageHandler 17 | 18 | 19 | class CustomHandler(MessageHandler): 20 | 21 | def handle_welcome(self, message_obj): 22 | # maybe do some auth stuff here 23 | super(CustomHandler, self).handle_welcome(message_obj) 24 | # and maybe then some other stuff now like alerting 25 | 26 | 27 | There may be no need to even do what wampy does if your application already has patterns for handling WAMP messages! In which case override but don't call ``super`` - just do your own thing. 28 | 29 | Then your Client should be initialised with an *instance* of the custom handler. 30 | 31 | :: 32 | 33 | from wampy.peers.clients import Client 34 | 35 | client = Client(message_handler=CustomHandler()) 36 | -------------------------------------------------------------------------------- /wampy/messages/yield_.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | 6 | class Yield(object): 7 | """ When the Callee is able to successfully process and finish the 8 | execution of the call, it answers by sending a "YIELD" message to the 9 | Dealer. 10 | 11 | Message is of the format :: 12 | 13 | [ 14 | YIELD, INVOCATION.Request|id, Options|dict, Arguments|list, 15 | ArgumentsKw|dict 16 | ] 17 | 18 | "INVOCATION.Request" is the ID from the original invocation 19 | request. 20 | 21 | "Options"is a dictionary that allows to provide additional options. 22 | 23 | "Arguments" is a list of positional result elements (each of 24 | arbitrary type). The list may be of zero length. 25 | 26 | "ArgumentsKw" is a dictionary of keyword result elements (each of 27 | arbitrary type). The dictionary may be empty. 28 | 29 | """ 30 | WAMP_CODE = 70 31 | name = "yield" 32 | 33 | def __init__( 34 | self, invocation_request_id, options=None, result_args=None, 35 | result_kwargs=None 36 | ): 37 | 38 | super(Yield, self).__init__() 39 | 40 | self.invocation_request_id = invocation_request_id 41 | self.options = options or {} 42 | self.result_args = result_args or [] 43 | self.result_kwargs = result_kwargs or {} 44 | 45 | @property 46 | def message(self): 47 | return [ 48 | self.WAMP_CODE, self.invocation_request_id, self.options, 49 | self.result_args, self.result_kwargs 50 | ] 51 | -------------------------------------------------------------------------------- /wampy/backends/gevent_.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | import gevent 6 | import gevent.queue 7 | 8 | from wampy.errors import WampyTimeOutError 9 | from wampy.interfaces import Async 10 | 11 | 12 | class Gevent(Async): 13 | 14 | def __init__(self, message_queue=gevent.queue.Queue()): 15 | self.message_queue = message_queue 16 | 17 | def __str__(self): 18 | return 'GeventAsyncAdapter' 19 | 20 | def queue(self): 21 | # TODO: why? 22 | return gevent.queue.Queue() 23 | 24 | def Timeout(self, timeout, raise_after=True): 25 | return gevent.Timeout(timeout, raise_after) 26 | 27 | @property 28 | def QueueEmpty(self): 29 | return gevent.queue.Empty 30 | 31 | def receive_message(self, timeout): 32 | try: 33 | message = self._wait_for_message(timeout) 34 | except gevent.Timeout: 35 | raise WampyTimeOutError( 36 | "no message returned (timed-out in {})".format(timeout) 37 | ) 38 | return message 39 | 40 | def spawn(self, *args, **kwargs): 41 | gthread = gevent.spawn(*args, **kwargs) 42 | return gthread 43 | 44 | def sleep(self, time=0): 45 | gevent.sleep(time) 46 | 47 | def _wait_for_message(self, timeout): 48 | # executed every time a Client expects to recieve a Message 49 | q = self.message_queue 50 | 51 | with gevent.Timeout(timeout): 52 | while q.qsize() == 0: 53 | gevent.sleep() 54 | 55 | message = q.get() 56 | return message 57 | -------------------------------------------------------------------------------- /wampy/testing/configs/crossbar.timeout.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "controller": { 4 | }, 5 | "workers": [ 6 | { 7 | "type": "router", 8 | "realms": [ 9 | { 10 | "name": "realm1", 11 | "roles": [ 12 | { 13 | "name": "anonymous", 14 | "permissions": [ 15 | { 16 | "uri": "", 17 | "match": "prefix", 18 | "allow": { 19 | "call": true, 20 | "register": true, 21 | "publish": true, 22 | "subscribe": true 23 | }, 24 | "disclose": { 25 | "caller": false, 26 | "publisher": false 27 | }, 28 | "cache": true 29 | } 30 | ] 31 | } 32 | ] 33 | } 34 | ], 35 | "transports": [ 36 | { 37 | "type": "websocket", 38 | "endpoint": { 39 | "type": "tcp", 40 | "port": 8080, 41 | "version": 4, 42 | "interface": "localhost" 43 | }, 44 | "options": { 45 | "fail_by_drop": true, 46 | "auto_ping_interval": 2000, 47 | "auto_ping_timeout": 1000, 48 | "auto_ping_size": 4 49 | } 50 | } 51 | ] 52 | } 53 | ] 54 | } -------------------------------------------------------------------------------- /wampy/testing/keys/server_key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEArniIFc9Y/IIaWysaEFXXGSt+5u226CioAO99g/16m3Oo4LNw 3 | Cq2VYsVh/W0MZtOaTA2yBX+UkpXMYzKixIC5EGaiVFNB7IFzKmxf1Am3qz88na+H 4 | e/ZBDsNaWiRkaHGwJfqnoYRRtwGjrVhz4C+ECYn6iVjIuqnycIgkXLQX4/uq/HHU 5 | HqmDTwn93vn+b0AGIaBnRaNgusOPTIRFZfdaWO5ghPh7fhYw+h9oF6IxNASmp2BN 6 | u5KCld012XO5ZqqCfwhMgcyd/maWcJ5TWuwcwnAkBTNFH1LjQqbhY3Eg/j+P65XA 7 | xAtwmDveeLjeflXHNrp+AJAZ97wdAvEi3ChTQwIDAQABAoIBAD0R+7CGr8NTVx5q 8 | a+kj4vLwgq8arld9Q7FwIyM8atpXFdnxdVqHgC7eoHow3ZJwpyXP9WxvR/Y3cR+X 9 | 7dmSpTTUeCXELuM2PLWw0apK7HuI2xLnCimd/Q/J2aqL6omUoe/pWRv0URYaAM0A 10 | lS738uPT5FqHNVwBeNdjEDdS4rnuHei1wYwWKlZ5aTRaO21g0Ya67fY7AKRFuKEn 11 | TxGmgA/cKT+RCoztBSys1vY4vuBBhsZXBzSWjkQE/nh5FiZ/rDvmtPeBn2hjqKK2 12 | IE3k6LOvkpwSRfzbR26lQv3PdEL0SFbGiqwfCm2Et77scL73uXS5Ov8cnsKtlBXs 13 | xEWfrWECgYEA1gwOqsx3FAU+gwC/2fS9oA2niq2wCPkfQzeiqiExeUCbOnHG6pdH 14 | gv6GD7HPmu9KfY7gqsyku/x0+hMZszCg/98+s0lNL4+N5Khmg+ORso6FpOqLk0du 15 | 5f1E+f/WDEjh0vKHXOLoaJ8tzj3JpZxwKL8/E2wG1dO5dWwAkUvEyq8CgYEA0Kq1 16 | /BUAUti6p6pGSVqijOsUFVDUxzne5aZEvtWmqHerYtShRZM0bmYsKhLjW9NuVMDa 17 | jBU1bs/UUcPbW0Bfc/Fl3fvCzXH7RxIEzMmnB5ckxLLzYMpDBVQ9PNZBBcW+B9d0 18 | xiScVExFIgAN+0nPNLa/wrEyvIml0i4TOlvlFa0CgYEA0F4QcSh1yyGHxxOVr+FW 19 | L1bbgF6wfSu2yUKBsUh61uSTuANGdtwpm1WWv/SCevry8uOBxgNNYkrSvRaW8B8o 20 | u61hZjq3TtNad/uPQFjqXn3rj61bjlX9mRpCaXQptO/GFgpOx5eEU0SR3LG9eOCf 21 | NqtmBcwlo0Zmxe4LZ2Xw/rUCgYAI6+OP/Y3f/OguFveeV0Ov5rUbHDOcuPqwsuUp 22 | i5Tuiv9G4HRstxh8x92Hhvs1h9qlwQEXECkSrcwUGt2cDyqFmIKUdRklE4R8y2Zt 23 | IwoDJxEpX8VMFBm9dpaPrVFmX8f6KdoSRqpwaDpkc8AlSEiVpmKYfl7+9JukWtfz 24 | nM40mQKBgQCIhntxpd46R3B4yn2nRm6VIduEIxkxYwJahKIiGi51GM4n3flJAoEV 25 | jTfzy5UEGz8WMhgr5PpvIyxaABIgU+ijbnQcHNN9WfcVULlM6WOyuRYRbqmaQOh7 26 | ohnuqq4w8Y0tK2UKK/Vf3ku16SCck2t/LpURwAZp/hKmxvZ46Th5wQ== 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /wampy/testing/configs/crossbar.tls.json: -------------------------------------------------------------------------------- 1 | { 2 | "version":2, 3 | "workers":[ 4 | { 5 | "type":"router", 6 | "realms":[ 7 | { 8 | "name":"realm1", 9 | "roles":[ 10 | { 11 | "name":"anonymous", 12 | "permissions":[ 13 | { 14 | "uri":"", 15 | "match":"prefix", 16 | "allow":{ 17 | "call":true, 18 | "register":true, 19 | "publish":true, 20 | "subscribe":true 21 | }, 22 | "disclose":{ 23 | "caller":false, 24 | "publisher":false 25 | }, 26 | "cache":true 27 | } 28 | ] 29 | } 30 | ] 31 | } 32 | ], 33 | "transports":[ 34 | { 35 | "type":"websocket", 36 | "endpoint": { 37 | "type": "tcp", 38 | "port": 9443, 39 | "version": 4, 40 | "interface": "localhost", 41 | "tls":{ 42 | "key":"./wampy/testing/keys/server_key.pem", 43 | "certificate":"./wampy/testing/keys/server_cert.pem", 44 | "dhparam":"./wampy/testing/keys/dhparam.pem", 45 | "ciphers":"ECDH+AESGCM:DH+AESGCM:ECDH+AES256:DH+AES256:ECDH+AES128:DH+AES:ECDH+3DES:DH+3DES:RSA+AES:RSA+3DES:!ADH:!AECDH:!MD5:!DSS" 46 | } 47 | } 48 | } 49 | ] 50 | } 51 | ] 52 | } 53 | -------------------------------------------------------------------------------- /wampy/mixins.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | try: 6 | from urlparse import urlsplit 7 | except ImportError: 8 | from urllib.parse import urlsplit 9 | 10 | 11 | class ParseUrlMixin(object): 12 | def parse_url(self): 13 | """ Parses a URL of the form: 14 | 15 | - ws://host[:port][path] 16 | - wss://host[:port][path] 17 | - ws+unix:///path/to/my.socket 18 | 19 | """ 20 | self.scheme = None 21 | self.resource = None 22 | self.host = None 23 | self.port = None 24 | 25 | if self.url is None: 26 | return 27 | 28 | scheme, url = self.url.split(":", 1) 29 | parsed = urlsplit(url, scheme="http") 30 | if parsed.hostname: 31 | self.host = parsed.hostname 32 | elif '+unix' in scheme: 33 | self.host = 'localhost' 34 | else: 35 | raise ValueError("Invalid hostname from: %s", self.url) 36 | 37 | if parsed.port: 38 | self.port = parsed.port 39 | 40 | if scheme == "ws": 41 | if not self.port: 42 | self.port = 8080 43 | elif scheme == "wss": 44 | if not self.port: 45 | self.port = 443 46 | elif scheme in ('ws+unix', 'wss+unix'): 47 | pass 48 | else: 49 | raise ValueError("Invalid scheme: %s" % scheme) 50 | 51 | if parsed.path: 52 | resource = parsed.path 53 | else: 54 | resource = "/" 55 | 56 | if '+unix' in scheme: 57 | self.unix_socket_path = resource 58 | resource = '/' 59 | 60 | if parsed.query: 61 | resource += "?" + parsed.query 62 | 63 | self.scheme = scheme 64 | self.resource = resource 65 | -------------------------------------------------------------------------------- /wampy/messages/error.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | from wampy.errors import WampyError 6 | 7 | 8 | class Error(object): 9 | WAMP_CODE = 8 10 | name = "error" 11 | 12 | def __init__( 13 | self, request_type, request_id, 14 | details=None, error="", args_list=None, kwargs_dict=None 15 | ): 16 | """ Error reply sent by a Peer as an error response to 17 | different kinds of requests. 18 | 19 | :Parameters: 20 | 21 | :request_type: 22 | The WAMP message type code for the original request. 23 | :type request_type: int 24 | 25 | :request_id: 26 | The WAMP request ID of the original request 27 | (`Call`, `Subscribe`, ...) this error occurred for. 28 | :type request: int 29 | 30 | :args_list: 31 | Args to pass into an Application defined Exception 32 | 33 | :kwargs_list: 34 | Kwargs to pass into an Application defined Exception 35 | 36 | [ERROR, REQUEST.Type|int, REQUEST.Request|id, Details|dict, 37 | Error|uri, Arguments|list, ArgumentsKw|dict] 38 | 39 | """ 40 | super(Error, self).__init__() 41 | 42 | self.request_type = request_type 43 | self.request_id = request_id 44 | self.error = error 45 | self.args_list = args_list or [] 46 | self.kwargs_dict = kwargs_dict or {} 47 | 48 | # wampy is not implementing ``details`` which appears to be an 49 | # alternative to args and kwargs 50 | if details: 51 | raise WampyError( 52 | "Not Implemented: must use ``args_list`` and '" 53 | "``kwargs_dict, not ``details``" 54 | ) 55 | self.details = {} 56 | 57 | @property 58 | def message(self): 59 | return [ 60 | self.WAMP_CODE, self.request_type, self.request_id, 61 | self.details, self.error, self.args_list, self.kwargs_dict, 62 | ] 63 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | from setuptools import setup, find_packages 6 | from os import path 7 | 8 | 9 | here = path.abspath(path.dirname(__file__)) 10 | 11 | 12 | # Get the long description from the README file 13 | with open(path.join(here, 'README.md')) as f: 14 | long_description = f.read() 15 | 16 | 17 | setup( 18 | name='wampy', 19 | version='1.1.0', 20 | description='WAMP RPC and Pub/Sub for python interactive shells, scripts, apps and microservices', # noqa 21 | long_description=long_description, 22 | long_description_content_type='text/markdown', 23 | url='https://github.com/noisyboiler/wampy', 24 | author='Simon Harrison', 25 | author_email='noisyboiler@googlemail.com', 26 | license='Mozilla Public License 2.0', 27 | classifiers=[ 28 | 'Development Status :: 4 - Beta', 29 | 'License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)', 30 | 'Programming Language :: Python :: 3.7', 31 | 'Programming Language :: Python :: 3.8', 32 | 'Programming Language :: Python :: 3.9', 33 | ], 34 | keywords='WAMP RPC', 35 | packages=find_packages(), 36 | install_requires=[ 37 | "attrs==20.3.0", 38 | "eventlet>=0.24.1", 39 | "six==1.13.0", 40 | "simplejson>=3.11.1", 41 | "gevent>=21.1.2", 42 | ], 43 | extras_require={ 44 | 'dev': [ 45 | "colorlog>=3.1.4", 46 | "coverage>=3.7.1", 47 | "crossbar==21.3.1", 48 | "flake8>=3.5.0", 49 | "gevent-websocket>=0.10.1", 50 | "pytest>=4.0.2", 51 | "mock>=1.3.0", 52 | ], 53 | 'docs': [ 54 | "Sphinx==1.4.5", 55 | "guzzle_sphinx_theme", 56 | ], 57 | }, 58 | entry_points={ 59 | 'console_scripts': [ 60 | 'wampy=wampy.cli.main:main', 61 | ], 62 | # pytest looks up the pytest11 entrypoint to discover its plugins 63 | 'pytest11': [ 64 | 'pytest_wampy=wampy.testing.pytest_plugin', 65 | ] 66 | }, 67 | ) 68 | -------------------------------------------------------------------------------- /wampy/testing/helpers.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | import json 5 | 6 | from wampy.backends import async_adapter 7 | from wampy.message_handler import MessageHandler 8 | 9 | TIMEOUT = 5 10 | 11 | 12 | def assert_stops_raising( 13 | fn, exception_type=Exception, timeout=5, interval=0.1): 14 | 15 | with async_adapter.Timeout(timeout): 16 | while True: 17 | try: 18 | fn() 19 | except exception_type: 20 | pass 21 | else: 22 | return 23 | async_adapter.sleep(interval) 24 | 25 | 26 | def wait_for_subscriptions(client, number_of_subscriptions): 27 | with async_adapter.Timeout(TIMEOUT): 28 | while ( 29 | len(client._session.subscription_map.keys()) 30 | < number_of_subscriptions 31 | ): 32 | async_adapter.sleep(0.01) 33 | 34 | 35 | def wait_for_registrations(client, number_of_registrations): 36 | with async_adapter.Timeout(TIMEOUT): 37 | while ( 38 | len(client._session.registration_map.keys()) 39 | < number_of_registrations 40 | ): 41 | async_adapter.sleep(0.01) 42 | 43 | 44 | def wait_for_session(client): 45 | with async_adapter.Timeout(TIMEOUT): 46 | while client._session.id is None: 47 | async_adapter.sleep(0.01) 48 | 49 | 50 | def wait_for_messages(client, number_of_messages): 51 | messages_received = ( 52 | client._session.message_handler.messages_received) 53 | 54 | with async_adapter.Timeout(TIMEOUT): 55 | while len(messages_received) < number_of_messages: 56 | async_adapter.sleep(0.01) 57 | 58 | return messages_received 59 | 60 | 61 | class CollectingMessageHandler(MessageHandler): 62 | 63 | def __init__(self, client): 64 | super(CollectingMessageHandler, self).__init__(client) 65 | self.messages_received = [] 66 | 67 | def handle_message(self, message): 68 | self.messages_received.append(json.loads(message)) 69 | super(CollectingMessageHandler, self).handle_message( 70 | message, 71 | ) 72 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | wampy 2 | ===== 3 | 4 | .. pull-quote :: 5 | 6 | WAMP RPC and Pub/Sub for your Python apps and microservices 7 | 8 | This is a Python implementation of **WAMP** not requiring Twisted or asyncio, enabling use within classic blocking Python applications. It is a light-weight alternative to `autobahn`_. 9 | 10 | With **wampy** you can quickly and easily create your own **WAMP** clients, whether this is in a web app, a microservice, a script or just in a Python shell. 11 | 12 | **wampy** tries to provide an intuitive API for your **WAMP** messaging. 13 | 14 | WAMP 15 | ---- 16 | 17 | Background to the Web Application Messaging Protocol of which wampy implements. 18 | 19 | .. toctree:: 20 | :maxdepth: 2 21 | 22 | what_is_wamp 23 | 24 | 25 | User Guide 26 | ---------- 27 | 28 | Running a wampy application or interacting with any other WAMP application 29 | 30 | .. toctree:: 31 | :maxdepth: 2 32 | 33 | what_is_wampy 34 | a_wampy_application 35 | a_wampy_client 36 | publishing_to_a_topic 37 | subscribing_to_a_topic 38 | remote_procedure_calls 39 | exception_handling 40 | authentication 41 | message_handler 42 | testing 43 | tls 44 | 45 | modules 46 | ------- 47 | 48 | .. toctree:: 49 | 50 | wampy.constants 51 | wampy.errors 52 | wampy.mixins 53 | wampy.session 54 | wampy.messages.call 55 | wampy.messages.hello 56 | wampy.messages.goodbye 57 | wampy.messages.subscribe 58 | wampy.messages.publish 59 | wampy.messages.yield 60 | wampy.messages.register 61 | wampy.peers.clients 62 | wampy.peers.routers 63 | wampy.roles.callee 64 | wampy.roles.caller 65 | wampy.roles.publisher 66 | wampy.roles.subscriber 67 | 68 | 69 | .. automodule:: constants 70 | :noindex: 71 | 72 | .. automodule:: errors 73 | :noindex: 74 | 75 | .. automodule:: mixins 76 | :noindex: 77 | 78 | .. automodule:: session 79 | :noindex: 80 | 81 | .. automodule:: clients 82 | :noindex: 83 | 84 | .. automodule:: routers 85 | :noindex: 86 | 87 | .. automodule:: callee 88 | :noindex: 89 | 90 | .. automodule:: caller 91 | :noindex: 92 | 93 | .. automodule:: publisher 94 | :noindex: 95 | 96 | .. automodule:: subscriber 97 | :noindex: 98 | 99 | .. automodule:: subscribe 100 | :noindex: 101 | 102 | .. automodule:: publish 103 | :noindex: 104 | 105 | .. automodule:: yield_ 106 | :noindex: 107 | 108 | 109 | Indices and tables 110 | ================== 111 | 112 | * :ref:`genindex` 113 | * :ref:`modindex` 114 | * :ref:`search` 115 | 116 | .. _autobahn: http://autobahn.ws/python/ 117 | -------------------------------------------------------------------------------- /wampy/testing/configs/crossbar.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "controller": { 4 | }, 5 | "workers": [ 6 | { 7 | "type": "router", 8 | "realms": [ 9 | { 10 | "name": "realm1", 11 | "roles": [ 12 | { 13 | "name": "anonymous", 14 | "permissions": [ 15 | { 16 | "uri": "", 17 | "match": "prefix", 18 | "allow": { 19 | "call": true, 20 | "register": true, 21 | "publish": true, 22 | "subscribe": true 23 | }, 24 | "disclose": { 25 | "caller": false, 26 | "publisher": false 27 | }, 28 | "cache": true 29 | } 30 | ], 31 | "features": { 32 | "call_timeout": true 33 | } 34 | }, 35 | { 36 | "name": "dealer", 37 | "permissions": [ 38 | { 39 | "uri": "", 40 | "match": "prefix", 41 | "allow": { 42 | "call": true, 43 | "register": true, 44 | "publish": true, 45 | "subscribe": true 46 | }, 47 | "disclose": { 48 | "caller": false, 49 | "publisher": false 50 | }, 51 | "cache": true 52 | } 53 | ], 54 | "features": { 55 | "call_timeout": true 56 | } 57 | } 58 | ] 59 | } 60 | ], 61 | "transports": [ 62 | { 63 | "type": "websocket", 64 | "endpoint": { 65 | "type": "tcp", 66 | "port": 8080, 67 | "version": 4, 68 | "interface": "localhost" 69 | }, 70 | "options": { 71 | "auto_ping_interval": 10000, 72 | "auto_ping_timeout": 5000 73 | } 74 | } 75 | ] 76 | } 77 | ] 78 | } -------------------------------------------------------------------------------- /docs/a_wampy_client.rst: -------------------------------------------------------------------------------- 1 | A wampy Client 2 | ============== 3 | 4 | If you're working from a Python shell or script you can connect to a Router as follows. 5 | 6 | 1. Router is running on localhost, port 8080, start and stop manually. 7 | 8 | :: 9 | 10 | from wampy.peers import Client 11 | 12 | client = Client() 13 | client.start() # connects to the Router & establishes a WAMP Session 14 | # send some WAMP messages here 15 | client.stop() # ends Session, disconnects from Router 16 | 17 | 18 | 2. Router is running on localhost, port 8080, context-manage the Session 19 | 20 | :: 21 | 22 | from wampy.peers import Client 23 | 24 | with Client() as client: 25 | # send some WAMP messages here 26 | 27 | # on exit, the Session and connection are gracefully closed 28 | 29 | 3. Router is on example.com, port 8080, context-managed client again 30 | 31 | :: 32 | 33 | from wampy.peers import Client 34 | 35 | with Client(url="ws://example.com:8080") as client: 36 | # send some WAMP messages here 37 | 38 | # exits as normal 39 | 40 | Under the hood wampy creates an instance of a Router representaion because a Session is a managed conversation between two Peers - a Client and a Router. Because wampy treats a Session like this, there is actually also a *fourth* method of connection, as you can create the Router instance yourself and pass this into a Client directly. This is bascically only useful for test and CI environments, or local setups during development, or for fun. See the wampy tests for examples and the wampy wrapper around the Crossbar.io Router. 41 | 42 | Sending a Message 43 | ================= 44 | 45 | When a **wampy** client starts up it will send the **HELLO** message for you and begin a **Session**. Once you have the **Session** you can construct and send a **WAMP** message yourself, if you so choose. But **wampy** has the ``publish`` and ``rpc`` APIs so you don't have to. 46 | 47 | But if you did want to do it yourself, here's an example how to... 48 | 49 | Given a **Crossbar.io** server running on localhost on port 8080, a **realm** of "realm1", and a remote procedure "foobar", send a **CALL** message with **wampy** as follows: 50 | 51 | :: 52 | 53 | In [1]: from wampy.peers.clients import Client 54 | 55 | In [2]: from wampy.messages.call import Call 56 | 57 | In [3]: client = Client() 58 | 59 | In [4]: message = Call(procedure="foobar", args=(), kwargs={}) 60 | 61 | In [5]: with client: 62 | client.send_message(message) 63 | 64 | This example assumes a Router running on localhost and a second Peer attached over the same realm who hjas registered the callee "foobar" 65 | 66 | Note that in the example, as you leave the context managed function call, the client will send a **GOODBYE** message and your **Session** will end. 67 | 68 | wampy does not want you to waste time constructing messages by hand, so the above can be replaced with: 69 | 70 | :: 71 | 72 | In [1]: from wampy.peers.clients import Client 73 | 74 | In [2]: client = Client() 75 | 76 | In [5]: with client: 77 | client.rpc.foobar(*args, **kwargs) 78 | 79 | Under the hood, **wampy** has the ``RpcProxy`` object that implements the ``rpc`` API. 80 | -------------------------------------------------------------------------------- /test/integration/transports/test_transports.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | import datetime 6 | import ssl 7 | from datetime import date 8 | 9 | import pytest 10 | 11 | from wampy.peers.clients import Client 12 | from wampy.roles.callee import callee 13 | from wampy.testing.helpers import wait_for_session, wait_for_registrations 14 | 15 | 16 | class DateService(Client): 17 | 18 | @callee 19 | def get_todays_date(self): 20 | return datetime.date.today().isoformat() 21 | 22 | 23 | class TestIP4(object): 24 | 25 | @pytest.fixture 26 | def config_path(self): 27 | return './wampy/testing/configs/crossbar.json' 28 | 29 | def test_ipv4_websocket_connection(self, config_path, router): 30 | service = DateService(url=router.url) 31 | with service: 32 | wait_for_registrations(service, 1) 33 | 34 | client = Client(url=router.url) 35 | 36 | with client: 37 | result = client.rpc.get_todays_date() 38 | 39 | today = date.today() 40 | 41 | assert result == today.isoformat() 42 | 43 | 44 | class TestIP6(object): 45 | 46 | @pytest.fixture 47 | def config_path(self): 48 | return './wampy/testing/configs/crossbar.ipv6.json' 49 | 50 | @pytest.mark.skip(reason="Travis errors wheh swapping between IPV 4 & 6") 51 | def test_ipv6_websocket_connection(self, config_path, router): 52 | service = DateService(url=router.url) 53 | with service: 54 | wait_for_registrations(service, 1) 55 | 56 | client = Client(url=router.url) 57 | 58 | with client: 59 | result = client.rpc.get_todays_date() 60 | 61 | today = date.today() 62 | 63 | assert result == today.isoformat() 64 | 65 | 66 | class TestSecureWebSocket(object): 67 | 68 | @pytest.fixture 69 | def config_path(self): 70 | return './wampy/testing/configs/crossbar.tls.json' 71 | 72 | @pytest.fixture 73 | def url(self): 74 | return 'wss://localhost:9443' 75 | 76 | def test_ipv4_secure_websocket_connection_by_router_url( 77 | self, config_path, router 78 | ): 79 | assert router.url == "wss://localhost:9443" 80 | 81 | try: 82 | ssl.PROTOCOL_TLSv1_2 83 | except AttributeError: 84 | pytest.skip('Python Environment does not support TLS') 85 | 86 | with DateService( 87 | url="wss://localhost:9443", 88 | cert_path="./wampy/testing/keys/server_cert.pem", 89 | ) as service: 90 | wait_for_registrations(service, 1) 91 | 92 | client = Client( 93 | url="wss://localhost:9443", 94 | cert_path="./wampy/testing/keys/server_cert.pem", 95 | ) 96 | with client: 97 | wait_for_session(client) 98 | result = client.rpc.get_todays_date() 99 | 100 | today = date.today() 101 | 102 | assert result == today.isoformat() 103 | -------------------------------------------------------------------------------- /wampy/cli/run.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | """ usage 6 | 7 | wampy run module:app 8 | 9 | Largely experimental for now.... sorry. 10 | 11 | """ 12 | import errno 13 | import logging 14 | import os 15 | import sys 16 | 17 | import gevent 18 | 19 | from wampy.peers.routers import Crossbar 20 | 21 | logger = logging.getLogger("cli") 22 | 23 | 24 | class CommandError(Exception): 25 | pass 26 | 27 | 28 | def import_module(module_name): 29 | 30 | try: 31 | __import__(module_name) 32 | except ImportError: 33 | if module_name.endswith(".py") and os.path.exists(module_name): 34 | raise CommandError( 35 | "Failed to find module, did you mean '{}'?".format( 36 | module_name[:-3].replace('/', '.') 37 | ) 38 | ) 39 | 40 | raise 41 | 42 | module = sys.modules[module_name] 43 | return module 44 | 45 | 46 | class AppRunner(object): 47 | 48 | def __init__(self): 49 | self.apps = [] 50 | 51 | def add_app(self, app): 52 | self.apps.append(app) 53 | 54 | def start(self): 55 | for app in self.apps: 56 | print("starting up app: %s" % app.name) 57 | app.start() 58 | print("{} is now starting up.".format(app.name)) 59 | 60 | def stop(self): 61 | for app in self.apps: 62 | app.stop() 63 | print('runner stopped') 64 | sys.exit(0) 65 | 66 | 67 | def run(apps, config_path=None): 68 | router = Crossbar(config_path=config_path) 69 | router_url = router.url 70 | 71 | print("starting up services...") 72 | runner = AppRunner() 73 | 74 | for app in apps: 75 | module_name, app_name = app.split(':') 76 | mod = import_module(module_name) 77 | app_class = getattr(mod, app_name) 78 | app = app_class(url=router_url) 79 | runner.add_app(app) 80 | 81 | runner.start() 82 | 83 | while True: 84 | try: 85 | gevent.sleep() 86 | except OSError as exc: 87 | if exc.errno == errno.EINTR: 88 | continue 89 | raise 90 | except KeyboardInterrupt: 91 | try: 92 | runner.stop() 93 | except KeyboardInterrupt: 94 | runner.stop() 95 | except Exception: 96 | logger.exception("cannot start runner") 97 | break 98 | 99 | 100 | def main(args): 101 | if '.' not in sys.path: 102 | sys.path.insert(0, '.') 103 | 104 | app = args.application 105 | config_path = args.config 106 | run(app, config_path) 107 | 108 | 109 | def init_parser(parser): 110 | parser.add_argument( 111 | 'application', nargs='+', 112 | metavar='module[:application class]', 113 | help='python path to one wampy application class to run') 114 | 115 | parser.add_argument( 116 | '--config', default='./crossbar/config.json', 117 | help='Crossbar config file path') 118 | 119 | return parser 120 | -------------------------------------------------------------------------------- /test/unit/test_backends.py: -------------------------------------------------------------------------------- 1 | import eventlet 2 | import gevent 3 | import pytest 4 | from mock import Mock, patch 5 | 6 | from wampy.backends import get_async_adapter 7 | from wampy.backends.eventlet_ import Eventlet as EventletAdapter 8 | from wampy.backends.gevent_ import Gevent as GeventAdapter 9 | 10 | from wampy.errors import WampyError, WampyTimeOutError 11 | 12 | 13 | def test_get_unknown_backend(): 14 | with patch('wampy.backends.async_name', 'foobar'): 15 | with pytest.raises(WampyError): 16 | get_async_adapter() 17 | 18 | 19 | class TestGeventadapter: 20 | 21 | def test_get_backend(self): 22 | adapter = get_async_adapter() 23 | assert str(adapter) == 'GeventAsyncAdapter' 24 | 25 | def test_interface(self): 26 | adapter = get_async_adapter() 27 | 28 | assert isinstance(adapter.queue(), gevent.queue.Queue) 29 | assert adapter.QueueEmpty is gevent.queue.Empty 30 | 31 | timeout = adapter.Timeout(timeout=10) 32 | assert isinstance(timeout, gevent.Timeout) 33 | 34 | def test_receive_message_g(self): 35 | mock_queue = Mock() 36 | mock_queue.qsize.side_effect = [3, 2, 1] 37 | mock_queue.get.side_effect = [1, 2, 3] 38 | 39 | adapter = GeventAdapter(message_queue=mock_queue) 40 | 41 | message = adapter.receive_message(timeout=1) 42 | assert message == 1 43 | 44 | message = adapter.receive_message(timeout=1) 45 | assert message == 2 46 | 47 | message = adapter.receive_message(timeout=1) 48 | assert message == 3 49 | 50 | def test_receive_message_timeout(self): 51 | mock_queue = Mock() 52 | mock_queue.qsize.return_value = 0 53 | 54 | adapter = GeventAdapter(message_queue=mock_queue) 55 | 56 | with pytest.raises(WampyTimeOutError): 57 | adapter.receive_message(timeout=1) 58 | 59 | 60 | class TestEventletadapter: 61 | 62 | def test_get_eventlet_backend(self): 63 | with patch('wampy.backends.async_name', 'eventlet'): 64 | adapter = get_async_adapter() 65 | 66 | assert str(adapter) == 'EventletAsyncAdapter' 67 | 68 | def test_interface(self): 69 | with patch('wampy.backends.async_name', 'eventlet'): 70 | adapter = get_async_adapter() 71 | 72 | assert isinstance(adapter.queue(), eventlet.queue.Queue) 73 | assert adapter.QueueEmpty is eventlet.queue.Empty 74 | 75 | timeout = adapter.Timeout(timeout=10) 76 | assert isinstance(timeout, eventlet.Timeout) 77 | 78 | def test_receive_message(self): 79 | mock_queue = Mock() 80 | mock_queue.qsize.side_effect = [3, 2, 1] 81 | mock_queue.get.side_effect = [1, 2, 3] 82 | 83 | adapter = EventletAdapter(message_queue=mock_queue) 84 | 85 | message = adapter.receive_message(timeout=1) 86 | assert message == 1 87 | 88 | message = adapter.receive_message(timeout=1) 89 | assert message == 2 90 | 91 | message = adapter.receive_message(timeout=1) 92 | assert message == 3 93 | 94 | def test_receive_message_timeout(self): 95 | mock_queue = Mock() 96 | mock_queue.qsize.return_value = 0 97 | 98 | adapter = EventletAdapter(message_queue=mock_queue) 99 | 100 | with pytest.raises(WampyTimeOutError): 101 | adapter.receive_message(timeout=1) 102 | -------------------------------------------------------------------------------- /docs/testing.rst: -------------------------------------------------------------------------------- 1 | Testing wampy apps 2 | ================== 3 | 4 | To test any WAMP application you are going to need a Peer acting as a Router. 5 | 6 | **wampy** provides a ``pytest`` fixture for this: ``router`` which must be installed via ``$ pip install --editable .[dev]``. Usage is then simple. 7 | 8 | For example 9 | 10 | :: 11 | 12 | def test_my_wampy_applications(router): 13 | # do stuff here 14 | 15 | 16 | The router is **Crossbar.io** and will be started and shutdown between each test. 17 | 18 | It has a default configuration which you can override in your tests by creating a ``config_path`` fixture in your own ``conftest`` or test module. 19 | 20 | For example 21 | 22 | :: 23 | 24 | import pytest 25 | 26 | 27 | @pytest.fixture 28 | def config_path(): 29 | return './path/to/my/preferred/crossbar.json' 30 | 31 | 32 | Now any test using ``router`` will be a Crossbar.io server configured yourself. 33 | 34 | For example 35 | 36 | :: 37 | 38 | def test_my_app(router): 39 | # this router's configuration has been overridden! 40 | 41 | 42 | If you require even more control you can import the router itself from ``wampy.peers.routers`` and setup your tests however you need to. 43 | 44 | **wampy** also provides a ``pytest`` fixture for a WAMP client. 45 | 46 | Here is an example testing a wampy ``HelloService`` application. 47 | 48 | :: 49 | 50 | import pytest 51 | 52 | from wampy.roles.callee import callee 53 | from wampy.peers.clients import Client 54 | from wampy.testing import wait_for_registrations 55 | 56 | class HelloService(Client): 57 | 58 | @callee 59 | def say_hello(self, name): 60 | message = "Hello {}".format(name) 61 | return message 62 | 63 | 64 | @pytest.yield_fixture 65 | def hello_service(router): 66 | with HelloService(router=router) as service: 67 | wait_for_registrations(service, 1) 68 | yield 69 | 70 | 71 | def test_get_hello_message(hello_service, router, client): 72 | response = client.rpc.say_hello(name="wampy") 73 | 74 | assert response == "Hello wampy" 75 | 76 | 77 | Notice the use of ``wait_for_registrations``. All wampy actions are asynchronous, and so it's easy to get confused when setting up tests wondering why an application hasn't registered Callees or subscribed to Topics, or a Session even opened yet. 78 | 79 | So to help you setup your tests and avoid race conditions there are some helpers that you can execute to wait for async certain actions to perform before you start actually running test cases. These are: 80 | 81 | :: 82 | 83 | # execute with the client you're waiting for as the only argument 84 | from wampy.testing import wait_for_session 85 | # e.g. ```wait_for_session(client)``` 86 | 87 | # wait for a specific number of registrations on a client 88 | from wampy.testing import wait_for_registrations 89 | # e.g. ``wait_for_registrations(client, number_of_registrations=5) 90 | 91 | # wait for a specific number of subscriptions on a client 92 | from wampy.testing import wait_for_subscriptions 93 | # e.g. ``wait_for_subscriptions(client, number_of_subscriptions=7) 94 | 95 | # provied a function that raises until the test passes 96 | from test.helpers import assert_stops_raising 97 | # e.g. assert_stops_raising(my_func_that_raises_until_condition_met) 98 | 99 | For far more examples, see the wampy test suite. 100 | -------------------------------------------------------------------------------- /wampy/testing/configs/crossbar.static.auth.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "workers": [ 4 | { 5 | "type": "router", 6 | "options": { 7 | "pythonpath": [ 8 | ".." 9 | ] 10 | }, 11 | "realms": [ 12 | { 13 | "name": "realm1", 14 | "roles": [ 15 | { 16 | "name": "wampy", 17 | "permissions": [ 18 | { 19 | "uri": "get_foo", 20 | "match": "exact", 21 | "allow": { 22 | "call": false, 23 | "register": true, 24 | "publish": false, 25 | "subscribe": false 26 | }, 27 | "disclose": { 28 | "caller": false, 29 | "publisher": false 30 | }, 31 | "cache": true 32 | } 33 | ] 34 | } 35 | ] 36 | } 37 | ], 38 | "transports": [ 39 | { 40 | "type": "websocket", 41 | "endpoint": { 42 | "type": "tcp", 43 | "port": 8080, 44 | "version": 4, 45 | "interface": "localhost" 46 | }, 47 | "auth": { 48 | "wampcra": { 49 | "type": "static", 50 | "role": "wampy", 51 | "users": { 52 | "foo-service": { 53 | "secret": "secret1", 54 | "role": "wampy" 55 | }, 56 | "joe": { 57 | "secret": "secret2", 58 | "role": "wampy" 59 | }, 60 | "peter": { 61 | "secret": "prq7+YkJ1/KlW1X0YczMHw==", 62 | "role": "wampy", 63 | "salt": "salt123", 64 | "iterations": 100, 65 | "keylen": 16 66 | } 67 | } 68 | }, 69 | "ticket": { 70 | "type": "static", 71 | "role": "wampy", 72 | "principals": { 73 | "martin": { 74 | "ticket": "wEx9TPFtHdRr2Zg7rtRE", 75 | "role": "wampy" 76 | } 77 | } 78 | }, 79 | "anonymous": { 80 | "type": "static", 81 | "role": "wampy" 82 | } 83 | } 84 | } 85 | ] 86 | } 87 | ] 88 | } 89 | -------------------------------------------------------------------------------- /wampy/roles/caller.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | import logging 6 | 7 | from wampy.constants import NOT_AUTHORISED 8 | from wampy.errors import WampyError, WampProtocolError 9 | from wampy.messages import Error, Result 10 | from wampy.messages import MESSAGE_TYPE_MAP 11 | from wampy.messages.call import Call 12 | 13 | logger = logging.getLogger('wampy.rpc') 14 | 15 | 16 | class CallProxy: 17 | """ Proxy wrapper of a `wampy` client for WAMP application RPCs. 18 | 19 | Applictions and their endpoints are identified by dot delimented 20 | strings, e.g. :: 21 | 22 | "com.example.endpoints" 23 | 24 | and a `CallProxy` object will call such and endpoint, passing in 25 | any `args` or `kwargs` necessary. 26 | 27 | """ 28 | def __init__(self, client): 29 | self.client = client 30 | 31 | def __call__(self, procedure, *args, **kwargs): 32 | message = Call(procedure=procedure, args=args, kwargs=kwargs) 33 | response = self.client._make_rpc(message) 34 | wamp_code = response.WAMP_CODE 35 | 36 | if wamp_code == Error.WAMP_CODE: 37 | logger.error("call returned an error: %s", response) 38 | return response 39 | elif wamp_code == Result.WAMP_CODE: 40 | return response.value 41 | 42 | raise WampProtocolError("unexpected response: %s", response) 43 | 44 | 45 | class RpcProxy: 46 | """ Proxy wrapper of a `wampy` client for WAMP application RPCs 47 | where the endpoint is a non-delimted single string name, such as 48 | a function name, e.g. :: 49 | 50 | "get_data" 51 | 52 | The typical use case of this proxy class is for microservices 53 | where endpoints are class methods. 54 | 55 | """ 56 | def __init__(self, client): 57 | self.client = client 58 | 59 | def __getattr__(self, name): 60 | 61 | def wrapper(*args, **kwargs): 62 | # timeout is currently handled by wampy whilst 63 | # https://github.com/crossbario/crossbar/issues/299 64 | # is addressed, but we pass in the value regardless, waiting 65 | # for the feature on CrossBar. 66 | # WAMP Call Message requires milliseconds... 67 | options = { 68 | 'timeout': int(self.client.call_timeout * 1000), 69 | } 70 | message = Call( 71 | procedure=name, options=options, args=args, kwargs=kwargs, 72 | ) 73 | response = self.client._make_rpc(message) 74 | 75 | wamp_code = response.WAMP_CODE 76 | if wamp_code == Error.WAMP_CODE: 77 | _, _, request_id, _, endpoint, exc_args, exc_kwargs = ( 78 | response.message) 79 | 80 | if endpoint == NOT_AUTHORISED: 81 | raise WampyError( 82 | "NOT_AUTHORISED: {} - {}".format( 83 | self.client.name, exc_args[0] 84 | ) 85 | ) 86 | 87 | raise WampyError( 88 | 'oops! wampy has failed, sorry: {}'.format( 89 | response.message 90 | ) 91 | ) 92 | 93 | if wamp_code != Result.WAMP_CODE: 94 | raise WampProtocolError( 95 | 'unexpected message code: "%s (%s) %s"', 96 | wamp_code, MESSAGE_TYPE_MAP[wamp_code], 97 | response[5] 98 | ) 99 | 100 | result = response.value 101 | logger.debug("RpcProxy got result: %s", result) 102 | return result 103 | 104 | return wrapper 105 | -------------------------------------------------------------------------------- /test/integration/roles/test_publishing.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | import logging 6 | import pytest 7 | from mock import ANY 8 | 9 | from wampy.errors import WampyError 10 | from wampy.peers.clients import Client 11 | from wampy.roles.subscriber import subscribe 12 | from wampy.testing.helpers import assert_stops_raising 13 | 14 | 15 | logger = logging.getLogger('wampy.testing') 16 | 17 | 18 | class SubscribingClient(Client): 19 | 20 | call_count = 0 21 | 22 | @subscribe(topic="foo") 23 | def foo_topic_handler(self, *args, **kwargs): 24 | self.call_count += 1 25 | 26 | 27 | @pytest.yield_fixture 28 | def foo_subscriber(router): 29 | client = SubscribingClient(url=router.url) 30 | with client: 31 | yield client 32 | 33 | 34 | def test_cannot_publish_nothing_to_topic(foo_subscriber, router): 35 | assert foo_subscriber.call_count == 0 36 | 37 | client = Client(url=router.url) 38 | 39 | with client: 40 | with pytest.raises(WampyError): 41 | client.publish(topic="foo") 42 | 43 | assert foo_subscriber.call_count == 0 44 | 45 | 46 | def test_cannot_publish_args_to_topic(foo_subscriber, router): 47 | assert foo_subscriber.call_count == 0 48 | 49 | client = Client(url=router.url) 50 | 51 | with client: 52 | 53 | with pytest.raises(WampyError): 54 | client.publish("foo",) 55 | 56 | assert foo_subscriber.call_count == 0 57 | 58 | with pytest.raises(WampyError): 59 | client.publish("foo", "foobar") 60 | 61 | assert foo_subscriber.call_count == 0 62 | 63 | even_more_args = range(100) 64 | 65 | with pytest.raises(WampyError): 66 | client.publish(even_more_args) 67 | 68 | assert foo_subscriber.call_count == 0 69 | 70 | 71 | def test_publish_kwargs_to_topic(foo_subscriber, router): 72 | assert foo_subscriber.call_count == 0 73 | 74 | client = Client(url=router.url) 75 | 76 | client.start() 77 | client.publish(topic="foo", message="foobar") 78 | 79 | def check_call_count(): 80 | assert foo_subscriber.call_count == 1 81 | 82 | assert_stops_raising(check_call_count) 83 | 84 | client.publish(topic="foo", message="foobar") 85 | client.publish(topic="foo", message="spam") 86 | client.publish(topic="foo", message="ham") 87 | 88 | def check_call_count(): 89 | assert foo_subscriber.call_count == 4 90 | 91 | assert_stops_raising(check_call_count) 92 | 93 | client.stop() 94 | 95 | 96 | def test_kwargs_are_received(router): 97 | 98 | class SubscribingClient(Client): 99 | received_kwargs = None 100 | 101 | @subscribe(topic="foo") 102 | def foo_topic_handler(self, **kwargs): 103 | SubscribingClient.received_kwargs = kwargs 104 | 105 | reader = SubscribingClient(url=router.url) 106 | 107 | assert SubscribingClient.received_kwargs is None 108 | 109 | with reader: 110 | assert SubscribingClient.received_kwargs is None 111 | 112 | publisher = Client(url=router.url) 113 | 114 | assert SubscribingClient.received_kwargs is None 115 | 116 | with publisher: 117 | publisher.publish( 118 | topic="foo", message="foobar", spam="eggs") 119 | 120 | def check_kwargs(): 121 | assert SubscribingClient.received_kwargs == { 122 | 'message': 'foobar', 123 | 'spam': 'eggs', 124 | 'meta': { 125 | 'topic': 'foo', 126 | 'subscription_id': ANY, 127 | }, 128 | } 129 | 130 | assert_stops_raising(check_kwargs) 131 | -------------------------------------------------------------------------------- /docs/authentication.rst: -------------------------------------------------------------------------------- 1 | Authentication Methods 2 | ====================== 3 | 4 | The Realm is a WAMP routing and administrative domain, optionally protected by authentication and authorization. 5 | 6 | In the WAMP Basic Profile without session authentication the Router will reply with a "WELCOME" or "ABORT" message. 7 | 8 | :: 9 | 10 | ,------. ,------. 11 | |Client| |Router| 12 | `--+---' `--+---' 13 | | HELLO | 14 | | ----------------> 15 | | | 16 | | WELCOME | 17 | | <---------------- 18 | ,--+---. ,--+---. 19 | |Client| |Router| 20 | `------' `------' 21 | 22 | The Advanced router Profile provides some authentication options at the WAMP level - although your app may choose to use transport level auth (e.g. cookies or TLS certificates) or implement its own system (e.g. on the remote procedure). 23 | 24 | :: 25 | 26 | ,------. ,------. 27 | |Client| |Router| 28 | `--+---' `--+---' 29 | | HELLO | 30 | | ----------------> 31 | | | 32 | | CHALLENGE | 33 | | <---------------- 34 | | | 35 | | AUTHENTICATE | 36 | | ----------------> 37 | | | 38 | | WELCOME or ABORT| 39 | | <---------------- 40 | ,--+---. ,--+---. 41 | |Client| |Router| 42 | `------' `------' 43 | 44 | 45 | Challenge Response Authentication 46 | --------------------------------- 47 | 48 | WAMP Challenge-Response ("WAMP-CRA") authentication is a simple, secure authentication mechanism using a shared secret. The client and the server share a secret. The secret never travels the wire, hence WAMP-CRA can be used via non-TLS connections. 49 | 50 | wampy needs the secret to be set as an environment variable against the key ``WAMPYSECRET`` on deployment or in the test environment (if testing auth) otherwise a ``WampyError`` will be raised. In future a ``Client`` could take configuration on startup. 51 | 52 | The Router must also be configured to expect Users and by what auth method. 53 | 54 | For the Client you can instantiate the ``Client`` with ``roles`` which can take ``authmethods`` and ``authid``. 55 | 56 | :: 57 | 58 | roles = { 59 | 'roles': { 60 | 'subscriber': {}, 61 | 'publisher': {}, 62 | 'callee': { 63 | 'shared_registration': True, 64 | }, 65 | 'caller': {}, 66 | }, 67 | 'authmethods': ['wampcra'] # where "anonymous" is the default 68 | 'authid': 'your-username-or-identifier' 69 | } 70 | 71 | client = Client(roles=roles) 72 | 73 | And the Router would include something like... 74 | 75 | :: 76 | 77 | "auth": { 78 | "wampcra": { 79 | "type": "static", 80 | "role": "wampy", 81 | "users": { 82 | "your-username-or-identifier": { 83 | "secret": "prq7+YkJ1/KlW1X0YczMHw==", 84 | "role": "wampy", 85 | "salt": "salt123", 86 | "iterations": 100, 87 | "keylen": 16, 88 | }, 89 | "someone-else": { 90 | "secret": "secret2", 91 | "role": "wampy", 92 | ... 93 | }, 94 | ... 95 | } 96 | }, 97 | "anonymous": { 98 | "type": "static", 99 | "role": "wampy" 100 | } 101 | } 102 | 103 | with permissions for RPC and subscriptions optionally defined. See the included testing `config`_ for a more complete example. 104 | 105 | .. _config: https://github.com/noisyboiler/wampy/master/wampy/testing/config.static.auth.json 106 | -------------------------------------------------------------------------------- /test/integration/roles/test_callers.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | import datetime 6 | from datetime import date 7 | 8 | import pytest 9 | 10 | from wampy.backends import get_async_adapter 11 | from wampy.errors import WampyTimeOutError 12 | from wampy.peers.clients import Client 13 | from wampy.roles.callee import callee 14 | from wampy.testing import wait_for_registrations 15 | 16 | 17 | class DateService(Client): 18 | 19 | @callee 20 | def get_todays_date(self): 21 | return datetime.date.today().isoformat() 22 | 23 | 24 | class HelloService(Client): 25 | 26 | @callee 27 | def say_hello(self, name): 28 | message = "Hello {}".format(name) 29 | return message 30 | 31 | @callee 32 | def say_greeting(self, name, greeting="hola"): 33 | message = "{greeting} to {name}".format( 34 | greeting=greeting, name=name) 35 | return message 36 | 37 | 38 | class BinaryNumberService(Client): 39 | 40 | @callee 41 | def get_binary(self, integer): 42 | result = bin(integer) 43 | return result 44 | 45 | 46 | class ReallySlowService(Client): 47 | 48 | @callee 49 | def requires_patience(self, wait_in_seconds): 50 | async_ = get_async_adapter() 51 | async_.sleep(wait_in_seconds) 52 | reward_for_waiting = "$$$$" 53 | return reward_for_waiting 54 | 55 | 56 | @pytest.fixture 57 | def date_service(router): 58 | with DateService(url=router.url) as serv: 59 | wait_for_registrations(serv, 1) 60 | yield 61 | 62 | 63 | @pytest.fixture 64 | def hello_service(router): 65 | with HelloService(url=router.url): 66 | yield 67 | 68 | 69 | @pytest.fixture 70 | def binary_number_service(router): 71 | with BinaryNumberService(url=router.url): 72 | yield 73 | 74 | 75 | @pytest.fixture 76 | def really_slow_service(router): 77 | with ReallySlowService(url=router.url): 78 | yield 79 | 80 | 81 | class TestClientCall: 82 | 83 | def test_call_with_no_args_or_kwargs(self, date_service, router): 84 | client = Client(url=router.url) 85 | with client: 86 | response = client.call("get_todays_date") 87 | 88 | today = date.today() 89 | 90 | assert response == today.isoformat() 91 | 92 | def test_call_with_args_but_no_kwargs(self, hello_service, router): 93 | caller = Client(url=router.url) 94 | with caller: 95 | response = caller.call("say_hello", "Simon") 96 | 97 | assert response == "Hello Simon" 98 | 99 | def test_call_with_args_and_kwargs(self, hello_service, router): 100 | caller = Client(url=router.url) 101 | with caller: 102 | response = caller.call("say_greeting", "Simon", greeting="watcha") 103 | 104 | assert response == "watcha to Simon" 105 | 106 | 107 | class TestClientRpc: 108 | 109 | def test_rpc_with_no_args_but_a_default_kwarg(self, hello_service, router): 110 | caller = Client(url=router.url) 111 | with caller: 112 | response = caller.rpc.say_greeting("Simon") 113 | 114 | assert response == "hola to Simon" 115 | 116 | def test_rpc_with_args_but_no_kwargs(self, hello_service, router): 117 | caller = Client(url=router.url) 118 | with caller: 119 | response = caller.rpc.say_hello("Simon") 120 | 121 | assert response == "Hello Simon" 122 | 123 | def test_rpc_with_no_args_but_a_kwarg(self, hello_service, router): 124 | caller = Client(url=router.url) 125 | with caller: 126 | response = caller.rpc.say_greeting("Simon", greeting="goodbye") 127 | 128 | assert response == "goodbye to Simon" 129 | 130 | 131 | class TestCallerTimeout: 132 | @pytest.mark.parametrize("call_timeout, wait, reward", [ 133 | (1, 2, None), 134 | (2, 1, "$$$$"), 135 | (0.9, 1.1, None), 136 | (1, 3, None), 137 | ]) 138 | def test_timeout_values( 139 | self, call_timeout, wait, reward, router, really_slow_service, 140 | ): 141 | with Client(url=router.url, call_timeout=call_timeout) as client: 142 | try: 143 | resp = client.rpc.requires_patience(wait_in_seconds=wait) 144 | except WampyTimeOutError: 145 | resp = None 146 | 147 | assert resp == reward 148 | -------------------------------------------------------------------------------- /test/integration/test_clients.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | import datetime 6 | from time import sleep 7 | 8 | import pytest 9 | 10 | from wampy.peers.clients import Client 11 | from wampy.roles.callee import callee 12 | 13 | from wampy.testing.helpers import assert_stops_raising, wait_for_session 14 | from wampy.testing.helpers import ( 15 | CollectingMessageHandler, wait_for_messages, 16 | ) 17 | 18 | 19 | class DateService(Client): 20 | 21 | @callee 22 | def get_todays_date(self): 23 | return datetime.date.today().isoformat() 24 | 25 | 26 | class HelloService(Client): 27 | 28 | @callee 29 | def say_hello(self, name): 30 | message = "Hello {}".format(name) 31 | return message 32 | 33 | @callee 34 | def say_greeting(self, name, greeting="hola"): 35 | message = "{greeting} to {name}".format( 36 | greeting=greeting, name=name) 37 | return message 38 | 39 | 40 | class BinaryNumberService(Client): 41 | 42 | @callee 43 | def get_binary(self, integer): 44 | """ Return the binary format for a given base ten integer. 45 | """ 46 | result = bin(integer) 47 | return result 48 | 49 | 50 | @pytest.yield_fixture 51 | def date_service(router): 52 | with DateService(url=router.url): 53 | yield 54 | 55 | 56 | @pytest.yield_fixture 57 | def hello_service(router): 58 | with HelloService(url=router.url): 59 | yield 60 | 61 | 62 | @pytest.yield_fixture 63 | def binary_number_service(router): 64 | with BinaryNumberService(url=router.url): 65 | yield 66 | 67 | 68 | @pytest.fixture 69 | def client_cls(): 70 | class MyClient(Client): 71 | pass 72 | 73 | return MyClient 74 | 75 | 76 | def test_client_connects_to_router_by_url(router): 77 | class MyClient(Client): 78 | pass 79 | 80 | client = MyClient(url=router.url) 81 | 82 | assert client.session is None 83 | 84 | client.start() 85 | wait_for_session(client) 86 | 87 | session = client.session 88 | assert session and session.id is not None 89 | 90 | client.stop() 91 | 92 | def assert_session_closed(): 93 | assert client.session is None 94 | 95 | assert_stops_raising(assert_session_closed, timeout=2) 96 | 97 | 98 | def test_url_without_protocol(router, client_cls): 99 | with pytest.raises(ValueError): 100 | with client_cls(url="localhost:8080"): 101 | # we must start a Session to test this 102 | pass 103 | 104 | 105 | def test_url_without_port_uses_default(router, client_cls): 106 | client = client_cls(url="ws://localhost") 107 | 108 | # should not raise 109 | client.start() 110 | wait_for_session(client) 111 | client.stop() 112 | 113 | 114 | def test_client_connects_to_router_by_instance(router): 115 | class MyClient(Client): 116 | pass 117 | 118 | client = MyClient(url=router.url) 119 | 120 | assert client.session is None 121 | 122 | client.start() 123 | wait_for_session(client) 124 | 125 | session = client.session 126 | assert session and session.id is not None 127 | 128 | client.stop() 129 | 130 | def assert_session_closed(): 131 | assert client.session is None 132 | 133 | assert_stops_raising(assert_session_closed, timeout=2) 134 | 135 | 136 | def test_can_start_two_clients(router): 137 | 138 | class MyClient(Client): 139 | pass 140 | 141 | app_one = MyClient(url=router.url) 142 | app_one.start() 143 | wait_for_session(app_one) 144 | 145 | assert app_one.session.id 146 | 147 | app_two = MyClient(url=router.url) 148 | app_two.start() 149 | wait_for_session(app_two) 150 | 151 | assert app_two.session.id 152 | 153 | app_one.stop() 154 | app_two.stop() 155 | 156 | def assert_session_closed(): 157 | assert app_one.session is None 158 | assert app_two.session is None 159 | 160 | assert_stops_raising(assert_session_closed, timeout=2) 161 | 162 | 163 | def test_client_stays_alive(router): 164 | client = BinaryNumberService( 165 | url=router.url, 166 | message_handler_cls=CollectingMessageHandler, 167 | name="foobar", 168 | ) 169 | 170 | client.start() 171 | wait_for_session(client) 172 | 173 | sleep(5) 174 | 175 | result = client.rpc.get_binary(100) 176 | wait_for_messages(client, 4) 177 | assert result == '0b1100100' 178 | 179 | client.stop() 180 | -------------------------------------------------------------------------------- /wampy/peers/routers.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | import json 6 | import logging 7 | import os 8 | import socket 9 | import subprocess 10 | from socket import error as socket_error 11 | from time import time as now 12 | 13 | from wampy.backends import async_adapter 14 | from wampy.errors import ConnectionError, WampyError 15 | from wampy.mixins import ParseUrlMixin 16 | 17 | logger = logging.getLogger('wampy.peers.routers') 18 | 19 | 20 | class Crossbar(ParseUrlMixin): 21 | 22 | def __init__( 23 | self, 24 | url="ws://localhost:8080", 25 | config_path="./crossbar/config.json", 26 | crossbar_directory=None, 27 | ): 28 | """ A wrapper around a Crossbar Server. Wampy uses this when 29 | executing its test suite. 30 | 31 | Typically used in test cases, local dev and scripts rather than 32 | with production applications. For Production, just deploy and 33 | connect to as you would any other server. 34 | 35 | """ 36 | try: 37 | with open(config_path) as data_file: 38 | config_data = json.load(data_file) 39 | except FileNotFoundError: 40 | raise FileNotFoundError(f'{config_path} not found') 41 | 42 | self.config = config_data 43 | self.config_path = config_path 44 | config = self.config['workers'][0] 45 | 46 | self.realm = config['realms'][0] 47 | self.roles = self.realm['roles'] 48 | 49 | if len(config['transports']) > 1: 50 | raise WampyError( 51 | "Only a single websocket transport is supported by Wampy, " 52 | "sorry" 53 | ) 54 | 55 | self.transport = config['transports'][0] 56 | self.url = url 57 | 58 | self.ipv = self.transport['endpoint'].get("version", None) 59 | if self.ipv is None: 60 | logger.warning( 61 | "defaulting to IPV 4 because neither was specified." 62 | ) 63 | self.ipv = 4 64 | 65 | self.parse_url() 66 | 67 | self.websocket_location = self.resource 68 | 69 | self.crossbar_directory = crossbar_directory 70 | 71 | try: 72 | self.certificate = self.transport['endpoint']['tls']['certificate'] 73 | except KeyError: 74 | self.certificate = None 75 | 76 | self.proc = None 77 | self.started = False 78 | 79 | @property 80 | def can_use_tls(self): 81 | return bool(self.certificate) 82 | 83 | def __enter__(self): 84 | self.start() 85 | return self 86 | 87 | def __exit__(self, exception_type, exception_value, traceback): 88 | self.stop() 89 | 90 | def _wait_until_ready(self, timeout=5, raise_if_not_ready=True): 91 | # we're only ready when it's possible to connect to the CrossBar 92 | # over TCP - so let's just try it. 93 | end = now() + timeout 94 | ready = False 95 | 96 | while not ready: 97 | timeout = end - now() 98 | if timeout < 0: 99 | if raise_if_not_ready: 100 | raise ConnectionError( 101 | 'Failed to connect to CrossBar over {}: {}:{}'.format( 102 | self.ipv, self.host, self.port) 103 | ) 104 | else: 105 | return ready 106 | 107 | try: 108 | self.try_connection() 109 | except ConnectionError: 110 | pass 111 | else: 112 | ready = True 113 | 114 | return ready 115 | 116 | def start(self): 117 | """ Start Crossbar.io in a subprocess. 118 | """ 119 | if self.started is True: 120 | raise WampyError("Router already started") 121 | 122 | # will attempt to connect or start up the CrossBar 123 | crossbar_config_path = self.config_path 124 | cbdir = self.crossbar_directory 125 | 126 | # starts the process from the root of the test namespace 127 | cmd = [ 128 | 'crossbar', 'start', 129 | '--cbdir', cbdir, 130 | '--config', crossbar_config_path, 131 | ] 132 | 133 | self.proc = subprocess.Popen(cmd, preexec_fn=os.setsid) 134 | 135 | self._wait_until_ready() 136 | logger.info( 137 | "Crosbar.io is ready for connections on %s (IPV%s)", 138 | self.url, self.ipv 139 | ) 140 | 141 | self.started = True 142 | 143 | def stop(self): 144 | logger.info("stopping crossbar") 145 | 146 | # handles gracefully a user already terminated server, the auto 147 | # termination failing and killing the process to ensure has died. 148 | 149 | try: 150 | self.proc.terminate() 151 | except OSError as exc: 152 | if "no such process" in str(exc).lower(): 153 | logger.warning("process died already: %s", self.proc) 154 | return 155 | logger.warning("process %s did not terminate", self.proc) 156 | else: 157 | # wait for a graceful shutdown 158 | logger.info("sleeping while Crossbar shuts down") 159 | async_adapter.sleep(2) 160 | 161 | self.started = False 162 | 163 | def try_connection(self): 164 | if self.ipv == 4: 165 | _socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 166 | 167 | try: 168 | _socket.connect((self.host, self.port)) 169 | except socket_error: 170 | raise ConnectionError("Could not connect") 171 | 172 | elif self.ipv == 6: 173 | _socket = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) 174 | 175 | try: 176 | _socket.connect(("::", self.port)) 177 | except socket_error: 178 | raise ConnectionError("Could not connect") 179 | 180 | else: 181 | raise WampyError( 182 | "unknown IPV: {}".format(self.ipv) 183 | ) 184 | 185 | _socket.shutdown(socket.SHUT_RDWR) 186 | _socket.close() 187 | -------------------------------------------------------------------------------- /test/integration/transports/test_websockets.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections import OrderedDict 3 | 4 | import pytest 5 | import gevent 6 | from gevent import Greenlet 7 | from geventwebsocket import ( 8 | WebSocketApplication, WebSocketServer, Resource, 9 | ) 10 | from mock import ANY 11 | from mock import call, patch 12 | 13 | from wampy.backends import async_adapter 14 | from wampy.config.defaults import async_name 15 | from wampy.constants import GEVENT 16 | from wampy.peers.clients import Client 17 | from wampy.testing.helpers import wait_for_session 18 | from wampy.transports.websocket.connection import WebSocket 19 | from wampy.transports.websocket.frames import Close, Ping 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | gevent_only = pytest.mark.skipif( 24 | async_name != GEVENT, 25 | reason="requires a Greenlet WebSocket server and you're using eventlet" 26 | ) 27 | 28 | 29 | class WsApplication(WebSocketApplication): 30 | pass 31 | 32 | 33 | @pytest.fixture 34 | def server(): 35 | s = WebSocketServer( 36 | ('0.0.0.0', 8001), 37 | Resource(OrderedDict([('/', WsApplication)])) 38 | ) 39 | s.start() 40 | thread = Greenlet.spawn(s.serve_forever) 41 | yield s 42 | s.stop() 43 | thread.kill() 44 | 45 | 46 | def test_wampy_webscoket_headers(router): 47 | expected_method_and_path = 'GET / HTTP/1.1' 48 | expected_headers = { 49 | 'Host': 'localhost:8080', 50 | 'Upgrade': 'websocket', 51 | 'Connection': 'Upgrade', 52 | 'Sec-WebSocket-Key': ANY, 53 | 'Origin': 'ws://localhost:8080', 54 | 'Sec-WebSocket-Version': '13', 55 | 'Sec-WebSocket-Protocol': 'wamp.2.json', 56 | } 57 | 58 | ws = WebSocket(server_url=router.url) 59 | header_list = ws._get_handshake_headers(upgrade=True) 60 | 61 | method_and_path = header_list[0] 62 | actual_headers = { 63 | header.split(':', 1)[0]: header.split(':', 1)[1].strip() 64 | for header in header_list[1:] 65 | } 66 | 67 | assert expected_method_and_path == method_and_path 68 | assert expected_headers == actual_headers 69 | 70 | 71 | @gevent_only 72 | def test_send_ping(server): 73 | websocket = WebSocket(server_url='ws://0.0.0.0:8001') 74 | with patch.object(websocket, 'handle_ping') as mock_handle: 75 | assert websocket.connected is False 76 | 77 | websocket.connect(upgrade=False) 78 | 79 | def connection_handler(): 80 | while True: 81 | try: 82 | message = websocket.receive() 83 | except Exception: 84 | logger.exception('connection handler exploded') 85 | raise 86 | if message: 87 | logger.info('got message: %s', message) 88 | 89 | assert websocket.connected is True 90 | 91 | # the first bytes sent down the connection are the response bytes 92 | # to the TCP connection and upgrade. we receieve in this thread 93 | # because it will block all execution 94 | Greenlet.spawn(connection_handler) 95 | gevent.sleep(0.01) # enough for the upgrade to happen 96 | 97 | clients = server.clients 98 | assert len(clients) == 1 99 | 100 | client_handler = list(clients.values())[0] 101 | socket = client_handler.ws 102 | 103 | ping_frame = Ping() 104 | socket.send(ping_frame.frame) 105 | 106 | with gevent.Timeout(5): 107 | while mock_handle.call_count != 1: 108 | gevent.sleep(0.01) 109 | 110 | assert mock_handle.call_count == 1 111 | assert mock_handle.call_args == call(ping_frame=ANY) 112 | 113 | call_param = mock_handle.call_args[1]['ping_frame'] 114 | assert isinstance(call_param, Ping) 115 | 116 | 117 | @pytest.fixture(scope="function") 118 | def config_path(): 119 | return './wampy/testing/configs/crossbar.timeout.json' 120 | 121 | 122 | def test_respond_to_ping_with_pong(config_path, router): 123 | # This test shows proper handling of ping/pong keep-alives 124 | # by connecting to a pong-demanding server (crossbar.timeout.json) 125 | # and keeping the connection open for longer than the server's timeout. 126 | # Failure would be an exception being thrown because of the server 127 | # closing the connection. 128 | 129 | class MyClient(Client): 130 | pass 131 | 132 | exceptionless = True 133 | 134 | try: 135 | client = MyClient(url=router.url) 136 | client.start() 137 | wait_for_session(client) 138 | 139 | async_adapter.sleep(5) 140 | 141 | # this is purely to demonstrate we can make calls while sending 142 | # pongs 143 | client.publish(topic="test", message="test") 144 | client.stop() 145 | except Exception as e: 146 | print(e) 147 | exceptionless = False 148 | 149 | assert exceptionless 150 | 151 | 152 | @gevent_only 153 | def test_server_closess(server): 154 | websocket = WebSocket(server_url='ws://0.0.0.0:8001') 155 | with patch.object(websocket, 'handle_close') as mock_handle: 156 | websocket.connect(upgrade=False) 157 | 158 | def connection_handler(): 159 | while True: 160 | try: 161 | message = websocket.receive() 162 | except Exception: 163 | logger.exception('connection handler exploded') 164 | raise 165 | if message: 166 | logger.info('got message: %s', message) 167 | 168 | Greenlet.spawn(connection_handler) 169 | gevent.sleep(0.01) # enough for the upgrade to happen 170 | 171 | clients = server.clients 172 | client_handler = list(clients.values())[0] 173 | socket = client_handler.ws 174 | Greenlet.spawn(socket.close) 175 | 176 | with gevent.Timeout(1): 177 | while mock_handle.call_count != 1: 178 | gevent.sleep(0.01) 179 | 180 | assert mock_handle.call_count == 1 181 | assert mock_handle.call_args == call(close_frame=ANY) 182 | 183 | call_param = mock_handle.call_args[1]['close_frame'] 184 | assert isinstance(call_param, Close) 185 | 186 | 187 | def test_pinging(router): 188 | with patch('wampy.transports.websocket.connection.heartbeat', 1): 189 | with patch( 190 | 'wampy.transports.websocket.connection.heartbeat_timeout', 2 191 | ): 192 | client = Client(router.url) 193 | client.start() 194 | wait_for_session(client) 195 | 196 | assert client.is_pinging 197 | 198 | ws = client.session.connection 199 | assert ws.missed_pongs == 0 200 | 201 | async_adapter.sleep(10) 202 | 203 | assert ws.missed_pongs == 0 204 | 205 | client.stop() 206 | -------------------------------------------------------------------------------- /wampy/testing/pytest_plugin.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | import atexit 6 | import logging 7 | import os 8 | import psutil 9 | import signal 10 | import sys 11 | import subprocess 12 | from time import sleep 13 | 14 | import colorlog 15 | import pytest 16 | 17 | from wampy.constants import DEFAULT_HOST, DEFAULT_PORT 18 | from wampy.errors import ConnectionError 19 | from wampy.peers.clients import Client 20 | from wampy.peers.routers import Crossbar 21 | from wampy.session import Session 22 | from wampy.transports import WebSocket 23 | 24 | 25 | logger = logging.getLogger('wampy.testing') 26 | 27 | logging_level_map = { 28 | 'DEBUG': logging.DEBUG, 29 | 'INFO': logging.INFO, 30 | } 31 | 32 | 33 | class PytestConfigurationError(Exception): 34 | pass 35 | 36 | 37 | def pytest_addoption(parser): 38 | parser.addoption( 39 | '--logging-level', 40 | type=str, 41 | action='store', 42 | dest='logging_level', 43 | help='configure the logging level', 44 | ) 45 | 46 | parser.addoption( 47 | '--file-logging', 48 | type=bool, 49 | action='store', 50 | dest='file_logging', 51 | help='optionally log to file', 52 | default=False, 53 | ) 54 | 55 | 56 | def add_file_logging(): 57 | root = logging.getLogger() 58 | fhandler = logging.FileHandler(filename='test-runner-log.log', mode='a') 59 | formatter = logging.Formatter( 60 | '%(asctime)s - %(name)s - %(levelname)s - %(message)s' 61 | ) 62 | fhandler.setFormatter(formatter) 63 | root.addHandler(fhandler) 64 | root.setLevel(logging.DEBUG) 65 | 66 | 67 | def pytest_configure(config): 68 | if config.option.logging_level is None: 69 | logging_level = logging.INFO 70 | else: 71 | logging_level = config.option.logging_level 72 | if logging_level not in logging_level_map: 73 | raise PytestConfigurationError( 74 | '{} not a recognised logging level'.format(logging_level) 75 | ) 76 | logging_level = logging_level_map[logging_level] 77 | 78 | sh = colorlog.StreamHandler() 79 | sh.setLevel(logging_level) 80 | formatter = colorlog.ColoredFormatter( 81 | "%(white)s%(name)s %(reset)s %(log_color)s%" 82 | "(levelname)-8s%(reset)s %(blue)s%(message)s", 83 | datefmt=None, 84 | reset=True, 85 | log_colors={ 86 | 'DEBUG': 'cyan', 87 | 'INFO': 'green', 88 | 'WARNING': 'yellow', 89 | 'ERROR': 'red', 90 | 'CRITICAL': 'red,bg_white', 91 | }, 92 | secondary_log_colors={}, 93 | style='%' 94 | ) 95 | 96 | sh.setFormatter(formatter) 97 | root = logging.getLogger() 98 | # remove the default streamhandler 99 | handler = next( 100 | ( 101 | handler for handler in root.handlers if 102 | isinstance(handler, logging.StreamHandler) 103 | ), None 104 | ) 105 | if handler: 106 | index = root.handlers.index(handler) 107 | root.handlers.pop(index) 108 | # and add our fancy coloured one 109 | root.addHandler(sh) 110 | 111 | if config.option.file_logging is True: 112 | add_file_logging() 113 | 114 | 115 | def find_processes(process_name): 116 | ps = subprocess.Popen( 117 | "ps -eaf | pgrep " + process_name, shell=True, stdout=subprocess.PIPE) 118 | output = ps.stdout.read() 119 | ps.stdout.close() 120 | ps.wait() 121 | 122 | return output 123 | 124 | 125 | def get_process_ids(): 126 | pids = [] 127 | pids.extend([ 128 | o for o in 129 | find_processes("crossbar-controller").decode().split('\n') if o 130 | ]) 131 | pids.extend([ 132 | o for o in 133 | find_processes("crossbar-worker").decode().split('\n') if o 134 | ]) 135 | 136 | return pids 137 | 138 | 139 | def assert_not_running(crossbar): 140 | try: 141 | crossbar.try_connection() 142 | except ConnectionError: 143 | pass 144 | else: 145 | sys.exit( 146 | "Crossbar is already running with unknown configuration, " 147 | "meaning the tests cannot reliably run - aborting!" 148 | ) 149 | 150 | 151 | def kill(pid): 152 | process = psutil.Process(pid) 153 | for proc in process.children(recursive=True): 154 | proc.kill() 155 | process.kill() 156 | 157 | 158 | def kill_crossbar(try_again=True): 159 | pids = get_process_ids() 160 | if pids and try_again is True: 161 | logger.warning( 162 | "Crossbar.io did not stop when sig term issued! " 163 | "Will try again." 164 | ) 165 | 166 | for pid_as_str in pids: 167 | try: 168 | pid = os.getpgid(int(pid_as_str)) 169 | except OSError: 170 | continue 171 | 172 | logger.warning("OS sending SIGTERM to crossbar pid: %s", pid) 173 | 174 | try: 175 | os.kill(pid, signal.SIGTERM) 176 | except Exception: # anything Twisted raises 177 | logger.exception( 178 | "Failed to terminate router process: %s", pid 179 | ) 180 | 181 | try: 182 | os.waitpid(pid, options=os.WNOHANG) 183 | except OSError: 184 | pass 185 | 186 | try: 187 | os.kill(pid, signal.SIGKILL) 188 | except Exception as exc: 189 | if "No such process" in str(exc): 190 | continue 191 | logger.exception( 192 | "Failed again to terminate router process: %s", pid) 193 | 194 | pids = get_process_ids() 195 | if pids and try_again is True: 196 | logger.warning('try one more time to shutdown Crossbar') 197 | sleep(5) 198 | kill_crossbar(try_again=False) 199 | elif pids and try_again is False: 200 | logger.error("Failed to shutdown all router processes") 201 | 202 | 203 | class ConfigurationError(Exception): 204 | pass 205 | 206 | 207 | @pytest.yield_fixture 208 | def url(): 209 | return "ws://localhost:8080" 210 | 211 | 212 | @pytest.yield_fixture 213 | def router(config_path, url): 214 | logger.info('config for test run: %s', config_path) 215 | logger.info('url for test run: %s', url) 216 | 217 | crossbar = Crossbar( 218 | url=url, 219 | config_path=config_path, 220 | crossbar_directory='./', 221 | ) 222 | 223 | assert_not_running(crossbar) 224 | crossbar.start() 225 | 226 | yield crossbar 227 | 228 | crossbar.stop() 229 | kill_crossbar() 230 | 231 | 232 | @pytest.fixture 233 | def connection(router): 234 | connection = WebSocket(host=DEFAULT_HOST, port=DEFAULT_PORT) 235 | connection.connect() 236 | 237 | assert connection.status == 101 # websocket success status 238 | assert connection.headers['upgrade'] == 'websocket' 239 | 240 | return connection 241 | 242 | 243 | @pytest.fixture 244 | def session_maker(router, connection): 245 | 246 | def maker(client, transport=connection): 247 | return Session( 248 | client=client, router=router, transport=transport, 249 | ) 250 | 251 | return maker 252 | 253 | 254 | @pytest.yield_fixture 255 | def client(router): 256 | with Client(router=router) as client: 257 | yield client 258 | 259 | 260 | atexit.register(kill_crossbar) 261 | -------------------------------------------------------------------------------- /wampy/message_handler.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | import json 5 | import logging 6 | 7 | from wampy.messages import MESSAGE_TYPE_MAP 8 | from wampy.messages import Error, Yield 9 | 10 | logger = logging.getLogger('wampy.messagehandler') 11 | 12 | 13 | class MessageHandler(object): 14 | """ Responsible for processing incoming WAMP messages. 15 | 16 | The ``Session`` object receives Messages on behalf of a 17 | ``Client`` and passes them into a ``MessageHandler``. 18 | 19 | The ``MessageHandler`` is designed to be extensible and be 20 | configured so that a wampy client can be used as part of 21 | larger applications. To do this subclass ``MessageHandler`` 22 | and override the ``handle_`` methods you wish to customise, 23 | then instantiate your ``Client`` with your ``MessageHandler`` 24 | instance. 25 | 26 | .. warning :: 27 | When subclassing ``MessageHandler`` avoid raising Exceptions 28 | since messages are handled in a background "green" thread 29 | and unless you're very careful, you won't see your error 30 | and you'll lose your background worker too. 31 | 32 | """ 33 | def __init__(self, client): 34 | self.client = client 35 | 36 | @property 37 | def session(self): 38 | return self.client._session 39 | 40 | def handle_message(self, message): 41 | # all WAMP paylods on a websocket frame are JSON 42 | message = json.loads(message) 43 | wamp_code = message[0] 44 | 45 | if wamp_code not in MESSAGE_TYPE_MAP: 46 | logger.warning('unexpected WAMP code: %s', wamp_code) 47 | return 48 | 49 | message_class = MESSAGE_TYPE_MAP[wamp_code] 50 | # instantiate our Message obj using the incoming payload - but slicing 51 | # off the WAMP code, which we already know 52 | message_obj = message_class(*message[1:]) 53 | 54 | handler_name = "handle_{}".format(message_obj.name) 55 | logger.info("handling %s", message_class) 56 | handler = getattr(self, handler_name) 57 | handler(message_obj) 58 | 59 | def handle_abort(self, message_obj): 60 | logger.warning( 61 | "The Router has Aborted the handshake: %s", 62 | message_obj.message, 63 | ) 64 | # we can't raise from inside a green thread (it'll not be seen) and 65 | # we can't gracefully disconnect and kill other remaining gthreads 66 | # from here, either. 67 | self.session._message_queue.put(message_obj) 68 | 69 | def handle_authenticate(self, message_obj): 70 | self.session._message_queue.put(message_obj) 71 | 72 | def handle_challenge(self, message_obj): 73 | self.session._message_queue.put(message_obj) 74 | 75 | def handle_close(self, close_frame): 76 | self.session.connection.stop_pinging() 77 | self.session.connection.disconnect() 78 | self.session.session_id = None 79 | logger.warning( 80 | 'server closed connection: %s', close_frame.payload, 81 | ) 82 | 83 | def handle_error(self, message_obj): 84 | logger.error("received error: %s", message_obj.message) 85 | self.session._message_queue.put(message_obj) 86 | 87 | def handle_event(self, message_obj): 88 | session = self.session 89 | 90 | payload_list = message_obj.publish_args 91 | payload_dict = message_obj.publish_kwargs 92 | 93 | func, topic = session.subscription_map[message_obj.subscription_id] 94 | 95 | payload_dict['meta'] = {} 96 | payload_dict['meta']['topic'] = topic 97 | payload_dict['meta']['subscription_id'] = message_obj.subscription_id 98 | 99 | func(*payload_list, **payload_dict) 100 | 101 | def handle_goodbye(self, message_obj): 102 | self.session._message_queue.put(message_obj) 103 | 104 | def handle_subscribed(self, message_obj): 105 | session = self.session 106 | 107 | original_message, handler = session.request_ids[ 108 | message_obj.request_id] 109 | topic = original_message.topic 110 | 111 | session.subscription_map[message_obj.subscription_id] = handler, topic 112 | 113 | def handle_invocation(self, message_obj): 114 | session = self.session 115 | 116 | args = message_obj.call_args 117 | kwargs = message_obj.call_kwargs 118 | 119 | procedure_name = session.registration_map[message_obj.registration_id] 120 | procedure = getattr(self.client, procedure_name) 121 | 122 | try: 123 | result = procedure(*args, **kwargs) 124 | except Exception as exc: 125 | logger.exception("error calling: %s", procedure_name) 126 | result = None 127 | error = exc 128 | else: 129 | error = None 130 | 131 | self.process_result(message_obj, result, exc=error) 132 | 133 | def handle_registered(self, message_obj): 134 | session = self.session 135 | procedure_name = session.request_ids[message_obj.request_id] 136 | session.registration_map[message_obj.registration_id] = procedure_name 137 | logger.info("registrated %s for %s", procedure_name, self.client.name) 138 | 139 | def handle_result(self, message_obj): 140 | # result of RPC needs to be passed back to the Client app 141 | self.session._message_queue.put(message_obj) 142 | 143 | def handle_welcome(self, message_obj): 144 | self.session.session_id = message_obj.session_id 145 | logger.info("Welcomed %s", self.client) 146 | self.session._message_queue.put(message_obj) 147 | # this may look to be more appropriate on the Client following starting 148 | # a Session, but a Session is not guaranteed - and this is the only 149 | # place that it is. 150 | self.client._register_roles() 151 | 152 | def process_result(self, message_obj, result, exc=None): 153 | if self.session.session_id is None: 154 | logger.error( 155 | 'wampy has already ended the WAMP session. not processing %s', 156 | message_obj 157 | ) 158 | return 159 | 160 | procedure_name = self.session.registration_map[ 161 | message_obj.registration_id 162 | ] 163 | 164 | if exc: 165 | error_message = Error( 166 | request_type=68, # the failing message wamp code 167 | request_id=message_obj.request_id, 168 | error=procedure_name, 169 | kwargs_dict={ 170 | 'exc_type': exc.__class__.__name__, 171 | 'message': str(exc), 172 | 'call_args': message_obj.call_args, 173 | 'call_kwargs': message_obj.call_kwargs, 174 | }, 175 | ) 176 | logger.error("returning with Error: %s", error_message) 177 | self.session.send_message(error_message) 178 | 179 | result_kwargs = {} 180 | result_kwargs['message'] = result 181 | result_kwargs['meta'] = {} 182 | result_kwargs['meta']['procedure_name'] = procedure_name 183 | result_kwargs['meta']['session_id'] = self.session.id 184 | result_args = [result] 185 | 186 | yield_message = Yield( 187 | message_obj.request_id, 188 | result_args=result_args, 189 | result_kwargs=result_kwargs, 190 | ) 191 | 192 | self.session.send_message(yield_message) 193 | -------------------------------------------------------------------------------- /wampy/peers/clients.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | import inspect 6 | import logging 7 | 8 | from wampy.constants import ( 9 | DEFAULT_ROUTER_URL, DEFAULT_TIMEOUT, DEFAULT_ROLES, DEFAULT_REALM, 10 | ) 11 | from wampy.session import Session 12 | from wampy.message_handler import MessageHandler 13 | from wampy.roles.caller import CallProxy, RpcProxy 14 | from wampy.roles.publisher import PublishProxy 15 | 16 | logger = logging.getLogger("wampy.clients") 17 | 18 | 19 | class Client(object): 20 | """ A WAMP Client for use in Python applications, scripts and shells. 21 | """ 22 | 23 | def __init__( 24 | self, url=DEFAULT_ROUTER_URL, cert_path=None, ipv=4, name=None, 25 | realm=DEFAULT_REALM, roles=DEFAULT_ROLES, call_timeout=DEFAULT_TIMEOUT, 26 | message_handler_cls=None, 27 | ): 28 | """ A WAMP Client "Peer". 29 | 30 | :Parameters: 31 | url : string 32 | The URL of the Router Peer. 33 | This must include protocol, host and port and an optional path, 34 | e.g. "ws://example.com:8080" or "wss://example.com:8080/ws". 35 | Note though that "ws" protocol defaults to port 8080, an "wss" 36 | to 443. 37 | cert_path : str 38 | If using ``wss`` protocol, a certificate might be required by 39 | the Router. If so, provide the path to the certificate here 40 | which will be used when connecting the Secure WebSocket. 41 | ipv : int 42 | The Internet Protocol version. Defaults to 4. 43 | realm : str 44 | The routing namespace to construct the ``Session`` over. 45 | Defaults to ``realm1``. 46 | roles : dictionary 47 | Description of the Roles implemented by the ``Client``. 48 | Defaults to ``wampy.constants.DEFAULT_ROLES``. 49 | message_handler_cls : Class 50 | A ``wampy.message_handler.MessageHandler`` class, or 51 | a subclass of. This implements and provides the required 52 | actions for the the WAMP messages. 53 | name : string 54 | Optional name for your ``Client``. Useful for when testing 55 | your app or for logging. 56 | call_timeout : integer 57 | A Caller might want to issue a call and provide a timeout after 58 | which the call will finish. 59 | The value should be in seconds. 60 | 61 | """ 62 | # the endpoint of a WAMP Router 63 | self.url = url 64 | # when using Secure WebSockets 65 | self.cert_path = cert_path 66 | self.ipv = ipv 67 | 68 | # the ``realm`` is the administrive domain to route messages over. 69 | self.realm = realm 70 | # the ``roles`` define what Roles (features) the Client can act, 71 | # but also configure behaviour such as auth 72 | self.roles = roles 73 | 74 | # wampy uses a decoupled "messge handler" to process incoming messages. 75 | # wampy also provides a very adequate default. 76 | # the Client instance is passed into the handler because Callee's and 77 | # Subscribers are declared on subclasses of the Client class. 78 | self.message_handler = ( 79 | message_handler_cls(client=self) if message_handler_cls 80 | else MessageHandler(client=self) 81 | ) 82 | 83 | # generally ``name`` is used for debugging and logging only 84 | self.name = name or self.__class__.__name__ 85 | 86 | # we cannot rely on Routers to implement WAMP call timeout yet. 87 | # although wampy will send the appropriate instructions in the Call 88 | # message, we still implement our own cuttoff 89 | self.call_timeout = call_timeout 90 | 91 | # create a Session between ourselves and the Router. 92 | # the ``MessageHandler`` will process incoming messages 93 | # and pass back any messages that the client needs, such 94 | # as RPC responses and Subscriptions. 95 | self._session = Session( 96 | router_url=self.url, 97 | message_handler=self.message_handler, 98 | ipv=self.ipv, 99 | cert_path=self.cert_path, 100 | call_timeout=self.call_timeout, 101 | realm=self.realm, 102 | roles=self.roles, 103 | client_name=self.name, 104 | ) 105 | 106 | def __enter__(self): 107 | self.start() 108 | return self 109 | 110 | def __exit__(self, exception_type, exception_value, traceback): 111 | self.stop() 112 | 113 | @property 114 | def session(self): 115 | if self._session.session_id: 116 | return self._session 117 | return None 118 | 119 | @property 120 | def subscription_map(self): 121 | return self.session.subscription_map 122 | 123 | @property 124 | def registration_map(self): 125 | return self.session.registration_map 126 | 127 | @property 128 | def request_ids(self): 129 | return self.session.request_ids 130 | 131 | @property 132 | def is_pinging(self): 133 | return self._session.connection.is_pinging 134 | 135 | @property 136 | def call(self): 137 | return CallProxy(client=self) 138 | 139 | @property 140 | def rpc(self): 141 | return RpcProxy(client=self) 142 | 143 | @property 144 | def publish(self): 145 | return PublishProxy(client=self) 146 | 147 | def start(self): 148 | self._session.begin() 149 | 150 | def stop(self): 151 | if self.session: 152 | self.session.end(goodbye_from=self.name) 153 | 154 | def send_message(self, message): 155 | self.session.send_message(message) 156 | 157 | def recv_message(self, source_request_id=None): 158 | return self.session.recv_message(source_request_id=source_request_id) 159 | 160 | def _make_rpc(self, message): 161 | # _make_rpc should not be called directly, rather by a Proxy object 162 | self.send_message(message) 163 | response = self.recv_message( 164 | source_request_id=message.request_id, 165 | ) 166 | return response 167 | 168 | def _register_roles(self): 169 | # over-ride this if you want to customise how your client registers 170 | # its Roles 171 | logger.info("registering roles for: %s", self.name) 172 | 173 | maybe_roles = [] 174 | bases = [b for b in inspect.getmro(self.__class__) if b is not object] 175 | 176 | for base in bases: 177 | maybe_roles.extend( 178 | v for v in base.__dict__.values() if 179 | inspect.isclass(base) and callable(v) 180 | ) 181 | 182 | for maybe_role in maybe_roles: 183 | 184 | if hasattr(maybe_role, 'callee'): 185 | procedure_name = maybe_role.__name__ 186 | invocation_policy = maybe_role.invocation_policy 187 | self.session._register_procedure( 188 | procedure_name, invocation_policy 189 | ) 190 | 191 | if hasattr(maybe_role, 'subscriber'): 192 | topic = maybe_role.topic 193 | handler_name = maybe_role.handler.__name__ 194 | handler = getattr(self, handler_name) 195 | self.session._subscribe_to_topic( 196 | handler, topic, 197 | ) 198 | 199 | logger.info("waiting for registration of roles for: %s", self.name) 200 | -------------------------------------------------------------------------------- /test/integration/test_authentication.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | import os 6 | 7 | import pytest 8 | 9 | from wampy.errors import WampyError 10 | from wampy.messages import Abort, Challenge, Error, Welcome, Goodbye 11 | from wampy.peers.clients import Client 12 | from wampy.roles.callee import callee 13 | 14 | from wampy.testing.helpers import ( 15 | CollectingMessageHandler, wait_for_messages, 16 | ) 17 | 18 | 19 | @pytest.fixture(scope="function") 20 | def config_path(): 21 | # this config has some static user creds defined 22 | # for "peter" 23 | return './wampy/testing/configs/crossbar.static.auth.json' 24 | 25 | 26 | class FooService(Client): 27 | 28 | @callee 29 | def get_foo(self, *args, **kwargs): 30 | return "foo" 31 | 32 | 33 | @pytest.yield_fixture 34 | def foo_service(router, config_path): 35 | os.environ['WAMPYSECRET'] = "prq7+YkJ1/KlW1X0YczMHw==" 36 | roles = { 37 | 'roles': { 38 | 'subscriber': {}, 39 | 'publisher': {}, 40 | 'callee': {}, 41 | 'caller': {}, 42 | }, 43 | 'authmethods': ['anonymous', 'wampcra'], 44 | 'authid': 'foo-service', 45 | } 46 | 47 | with FooService(url=router.url, roles=roles): 48 | yield 49 | 50 | 51 | def test_connection_is_aborted_when_not_authorised(router): 52 | roles = { 53 | 'roles': { 54 | 'subscriber': {}, 55 | 'publisher': {}, 56 | 'callee': {}, 57 | 'caller': {}, 58 | }, 59 | 'authmethods': ['wampcra'], 60 | 'authid': 'not-an-expected-user', 61 | } 62 | 63 | client = Client( 64 | url=router.url, roles=roles, name="unauthenticated-client-one", 65 | message_handler_cls=CollectingMessageHandler 66 | ) 67 | 68 | with pytest.raises(WampyError) as exc_info: 69 | client.start() 70 | wait_for_messages(client, 1) 71 | 72 | client.stop() 73 | 74 | exception = exc_info.value 75 | 76 | message = str(exception) 77 | 78 | assert ( 79 | "no principal with authid \"not-an-expected-user\" exists" 80 | in message 81 | ) 82 | assert "wamp.error.not_authorized" in message 83 | 84 | 85 | def test_connection_exits_if_missing_client_secret(router): 86 | roles = { 87 | 'roles': { 88 | 'subscriber': {}, 89 | 'publisher': {}, 90 | 'callee': {}, 91 | 'caller': {}, 92 | }, 93 | 'authmethods': ['wampcra'], 94 | 'authid': 'peter', 95 | } 96 | 97 | client = Client( 98 | url=router.url, roles=roles, name="unauthenticated-client-two") 99 | 100 | with pytest.raises(WampyError) as exc_info: 101 | client.start() 102 | 103 | exception = exc_info.value 104 | 105 | message = str(exception) 106 | assert "WAMPYSECRET" in message 107 | 108 | 109 | def test_connection_is_challenged(router): 110 | os.environ['WAMPYSECRET'] = "prq7+YkJ1/KlW1X0YczMHw==" 111 | roles = { 112 | 'roles': { 113 | 'subscriber': {}, 114 | 'publisher': {}, 115 | 'callee': {}, 116 | 'caller': {}, 117 | }, 118 | 'authmethods': ['wampcra'], 119 | 'authid': 'peter', 120 | } 121 | 122 | client = Client( 123 | url=router.url, 124 | roles=roles, 125 | message_handler_cls=CollectingMessageHandler, 126 | name="unauthenticated-client" 127 | ) 128 | 129 | client.start() 130 | messages = wait_for_messages(client, 2) 131 | 132 | # expect a Challenge and Welcome message 133 | assert messages[0][0] == Challenge.WAMP_CODE 134 | assert messages[1][0] == Welcome.WAMP_CODE 135 | 136 | client.stop() 137 | messages = wait_for_messages(client, 3) 138 | 139 | # now also expect a Goodbye message 140 | assert len(messages) == 3 141 | assert messages[0][0] == Challenge.WAMP_CODE 142 | assert messages[1][0] == Welcome.WAMP_CODE 143 | assert messages[2][0] == Goodbye.WAMP_CODE 144 | 145 | 146 | def test_connection_is_ticket_challenged(router): 147 | os.environ['WAMPYSECRET'] = "wEx9TPFtHdRr2Zg7rtRE" 148 | roles = { 149 | 'roles': { 150 | 'subscriber': {}, 151 | 'publisher': {}, 152 | 'callee': {}, 153 | 'caller': {}, 154 | }, 155 | 'authmethods': ['ticket'], 156 | 'authid': 'martin', 157 | } 158 | 159 | client = Client( 160 | url=router.url, 161 | roles=roles, 162 | message_handler_cls=CollectingMessageHandler, 163 | name="unauthenticated-client" 164 | ) 165 | 166 | client.start() 167 | messages = wait_for_messages(client, 2) 168 | 169 | # expect a Challenge and Welcome message 170 | assert messages[0][0] == Challenge.WAMP_CODE 171 | assert messages[1][0] == Welcome.WAMP_CODE 172 | 173 | client.stop() 174 | messages = wait_for_messages(client, 3) 175 | 176 | # now also expect a Goodbye message 177 | assert len(messages) == 3 178 | assert messages[0][0] == Challenge.WAMP_CODE 179 | assert messages[1][0] == Welcome.WAMP_CODE 180 | assert messages[2][0] == Goodbye.WAMP_CODE 181 | 182 | 183 | def test_incorrect_secret(router): 184 | os.environ['WAMPYSECRET'] = "incorrect-password" 185 | roles = { 186 | 'roles': { 187 | 'subscriber': {}, 188 | 'publisher': {}, 189 | 'callee': {}, 190 | 'caller': {}, 191 | }, 192 | 'authmethods': ['wampcra'], 193 | 'authid': 'peter', 194 | } 195 | 196 | client = Client( 197 | url=router.url, 198 | roles=roles, 199 | name="bad-client" 200 | ) 201 | 202 | with pytest.raises(WampyError) as exc_info: 203 | client.start() 204 | 205 | exception = exc_info.value 206 | 207 | message = str(exception) 208 | 209 | assert ( 210 | "WAMP-CRA signature is invalid" 211 | in message 212 | ) 213 | assert "wamp.error.not_authorized" in message 214 | 215 | 216 | def test_incorrect_ticket(router): 217 | os.environ['WAMPYSECRET'] = "incorrect-ticket" 218 | roles = { 219 | 'roles': { 220 | 'subscriber': {}, 221 | 'publisher': {}, 222 | 'callee': {}, 223 | 'caller': {}, 224 | }, 225 | 'authmethods': ['ticket'], 226 | 'authid': 'martin', 227 | } 228 | 229 | client = Client( 230 | url=router.url, 231 | roles=roles, 232 | name="bad-client", 233 | message_handler_cls=CollectingMessageHandler, 234 | ) 235 | 236 | with pytest.raises(WampyError) as exc_info: 237 | client.start() 238 | 239 | messages = wait_for_messages(client, 2) 240 | assert messages[0][0] == Challenge.WAMP_CODE 241 | assert messages[1][0] == Abort.WAMP_CODE 242 | 243 | exception = exc_info.value 244 | message = str(exception) 245 | 246 | assert ( 247 | "ticket in static WAMP-Ticket authentication is invalid" 248 | in message 249 | ) 250 | assert "wamp.error.not_authorized" in message 251 | 252 | 253 | def test_peter_cannot_call_get_foo(router, foo_service): 254 | # `get_foo` can be registered but not called over the wampy role 255 | os.environ['WAMPYSECRET'] = "prq7+YkJ1/KlW1X0YczMHw==" 256 | roles = { 257 | 'roles': { 258 | 'subscriber': {}, 259 | 'publisher': {}, 260 | 'callee': {}, 261 | 'caller': {}, 262 | }, 263 | 'authmethods': ['wampcra'], 264 | 'authid': 'peter', 265 | } 266 | 267 | client = Client( 268 | url=router.url, 269 | roles=roles, 270 | message_handler_cls=CollectingMessageHandler, 271 | name="unauthenticated-client-three", 272 | ) 273 | 274 | client.start() 275 | 276 | with pytest.raises(WampyError): 277 | client.rpc.get_foo() 278 | messages = wait_for_messages(client, 4) 279 | # now also expect a Goodbye message 280 | assert len(messages) == 4 281 | assert messages[0][0] == Challenge.WAMP_CODE 282 | assert messages[1][0] == Welcome.WAMP_CODE 283 | assert messages[2][0] == Error.WAMP_CODE 284 | 285 | client.stop() 286 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help 18 | help: 19 | @echo "Please use \`make ' where is one of" 20 | @echo " html to make standalone HTML files" 21 | @echo " dirhtml to make HTML files named index.html in directories" 22 | @echo " singlehtml to make a single large HTML file" 23 | @echo " pickle to make pickle files" 24 | @echo " json to make JSON files" 25 | @echo " htmlhelp to make HTML files and a HTML help project" 26 | @echo " qthelp to make HTML files and a qthelp project" 27 | @echo " applehelp to make an Apple Help Book" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " epub3 to make an epub3" 31 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 32 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 33 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 34 | @echo " text to make text files" 35 | @echo " man to make manual pages" 36 | @echo " texinfo to make Texinfo files" 37 | @echo " info to make Texinfo files and run them through makeinfo" 38 | @echo " gettext to make PO message catalogs" 39 | @echo " changes to make an overview of all changed/added/deprecated items" 40 | @echo " xml to make Docutils-native XML files" 41 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 42 | @echo " linkcheck to check all external links for integrity" 43 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 44 | @echo " coverage to run coverage check of the documentation (if enabled)" 45 | @echo " dummy to check syntax errors of document sources" 46 | 47 | .PHONY: clean 48 | clean: 49 | rm -rf $(BUILDDIR)/* 50 | 51 | .PHONY: html 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | .PHONY: dirhtml 58 | dirhtml: 59 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 60 | @echo 61 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 62 | 63 | .PHONY: singlehtml 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | .PHONY: pickle 70 | pickle: 71 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 72 | @echo 73 | @echo "Build finished; now you can process the pickle files." 74 | 75 | .PHONY: json 76 | json: 77 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 78 | @echo 79 | @echo "Build finished; now you can process the JSON files." 80 | 81 | .PHONY: htmlhelp 82 | htmlhelp: 83 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 84 | @echo 85 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 86 | ".hhp project file in $(BUILDDIR)/htmlhelp." 87 | 88 | .PHONY: qthelp 89 | qthelp: 90 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 91 | @echo 92 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 93 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 94 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/wampy.qhcp" 95 | @echo "To view the help file:" 96 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/wampy.qhc" 97 | 98 | .PHONY: applehelp 99 | applehelp: 100 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 101 | @echo 102 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 103 | @echo "N.B. You won't be able to view it unless you put it in" \ 104 | "~/Library/Documentation/Help or install it in your application" \ 105 | "bundle." 106 | 107 | .PHONY: devhelp 108 | devhelp: 109 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 110 | @echo 111 | @echo "Build finished." 112 | @echo "To view the help file:" 113 | @echo "# mkdir -p $$HOME/.local/share/devhelp/wampy" 114 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/wampy" 115 | @echo "# devhelp" 116 | 117 | .PHONY: epub 118 | epub: 119 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 120 | @echo 121 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 122 | 123 | .PHONY: epub3 124 | epub3: 125 | $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 126 | @echo 127 | @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." 128 | 129 | .PHONY: latex 130 | latex: 131 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 132 | @echo 133 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 134 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 135 | "(use \`make latexpdf' here to do that automatically)." 136 | 137 | .PHONY: latexpdf 138 | latexpdf: 139 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 140 | @echo "Running LaTeX files through pdflatex..." 141 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 142 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 143 | 144 | .PHONY: latexpdfja 145 | latexpdfja: 146 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 147 | @echo "Running LaTeX files through platex and dvipdfmx..." 148 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 149 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 150 | 151 | .PHONY: text 152 | text: 153 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 154 | @echo 155 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 156 | 157 | .PHONY: man 158 | man: 159 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 160 | @echo 161 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 162 | 163 | .PHONY: texinfo 164 | texinfo: 165 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 166 | @echo 167 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 168 | @echo "Run \`make' in that directory to run these through makeinfo" \ 169 | "(use \`make info' here to do that automatically)." 170 | 171 | .PHONY: info 172 | info: 173 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 174 | @echo "Running Texinfo files through makeinfo..." 175 | make -C $(BUILDDIR)/texinfo info 176 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 177 | 178 | .PHONY: gettext 179 | gettext: 180 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 181 | @echo 182 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 183 | 184 | .PHONY: changes 185 | changes: 186 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 187 | @echo 188 | @echo "The overview file is in $(BUILDDIR)/changes." 189 | 190 | .PHONY: linkcheck 191 | linkcheck: 192 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 193 | @echo 194 | @echo "Link check complete; look for any errors in the above output " \ 195 | "or in $(BUILDDIR)/linkcheck/output.txt." 196 | 197 | .PHONY: doctest 198 | doctest: 199 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 200 | @echo "Testing of doctests in the sources finished, look at the " \ 201 | "results in $(BUILDDIR)/doctest/output.txt." 202 | 203 | .PHONY: coverage 204 | coverage: 205 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 206 | @echo "Testing of coverage in the sources finished, look at the " \ 207 | "results in $(BUILDDIR)/coverage/python.txt." 208 | 209 | .PHONY: xml 210 | xml: 211 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 212 | @echo 213 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 214 | 215 | .PHONY: pseudoxml 216 | pseudoxml: 217 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 218 | @echo 219 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 220 | 221 | .PHONY: dummy 222 | dummy: 223 | $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy 224 | @echo 225 | @echo "Build finished. Dummy builder generates no files." 226 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. epub3 to make an epub3 31 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 32 | echo. text to make text files 33 | echo. man to make manual pages 34 | echo. texinfo to make Texinfo files 35 | echo. gettext to make PO message catalogs 36 | echo. changes to make an overview over all changed/added/deprecated items 37 | echo. xml to make Docutils-native XML files 38 | echo. pseudoxml to make pseudoxml-XML files for display purposes 39 | echo. linkcheck to check all external links for integrity 40 | echo. doctest to run all doctests embedded in the documentation if enabled 41 | echo. coverage to run coverage check of the documentation if enabled 42 | echo. dummy to check syntax errors of document sources 43 | goto end 44 | ) 45 | 46 | if "%1" == "clean" ( 47 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 48 | del /q /s %BUILDDIR%\* 49 | goto end 50 | ) 51 | 52 | 53 | REM Check if sphinx-build is available and fallback to Python version if any 54 | %SPHINXBUILD% 1>NUL 2>NUL 55 | if errorlevel 9009 goto sphinx_python 56 | goto sphinx_ok 57 | 58 | :sphinx_python 59 | 60 | set SPHINXBUILD=python -m sphinx.__init__ 61 | %SPHINXBUILD% 2> nul 62 | if errorlevel 9009 ( 63 | echo. 64 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 65 | echo.installed, then set the SPHINXBUILD environment variable to point 66 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 67 | echo.may add the Sphinx directory to PATH. 68 | echo. 69 | echo.If you don't have Sphinx installed, grab it from 70 | echo.http://sphinx-doc.org/ 71 | exit /b 1 72 | ) 73 | 74 | :sphinx_ok 75 | 76 | 77 | if "%1" == "html" ( 78 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 79 | if errorlevel 1 exit /b 1 80 | echo. 81 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 82 | goto end 83 | ) 84 | 85 | if "%1" == "dirhtml" ( 86 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 87 | if errorlevel 1 exit /b 1 88 | echo. 89 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 90 | goto end 91 | ) 92 | 93 | if "%1" == "singlehtml" ( 94 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 95 | if errorlevel 1 exit /b 1 96 | echo. 97 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 98 | goto end 99 | ) 100 | 101 | if "%1" == "pickle" ( 102 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 103 | if errorlevel 1 exit /b 1 104 | echo. 105 | echo.Build finished; now you can process the pickle files. 106 | goto end 107 | ) 108 | 109 | if "%1" == "json" ( 110 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 111 | if errorlevel 1 exit /b 1 112 | echo. 113 | echo.Build finished; now you can process the JSON files. 114 | goto end 115 | ) 116 | 117 | if "%1" == "htmlhelp" ( 118 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 119 | if errorlevel 1 exit /b 1 120 | echo. 121 | echo.Build finished; now you can run HTML Help Workshop with the ^ 122 | .hhp project file in %BUILDDIR%/htmlhelp. 123 | goto end 124 | ) 125 | 126 | if "%1" == "qthelp" ( 127 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 128 | if errorlevel 1 exit /b 1 129 | echo. 130 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 131 | .qhcp project file in %BUILDDIR%/qthelp, like this: 132 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\wampy.qhcp 133 | echo.To view the help file: 134 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\wampy.ghc 135 | goto end 136 | ) 137 | 138 | if "%1" == "devhelp" ( 139 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 140 | if errorlevel 1 exit /b 1 141 | echo. 142 | echo.Build finished. 143 | goto end 144 | ) 145 | 146 | if "%1" == "epub" ( 147 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 148 | if errorlevel 1 exit /b 1 149 | echo. 150 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 151 | goto end 152 | ) 153 | 154 | if "%1" == "epub3" ( 155 | %SPHINXBUILD% -b epub3 %ALLSPHINXOPTS% %BUILDDIR%/epub3 156 | if errorlevel 1 exit /b 1 157 | echo. 158 | echo.Build finished. The epub3 file is in %BUILDDIR%/epub3. 159 | goto end 160 | ) 161 | 162 | if "%1" == "latex" ( 163 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 164 | if errorlevel 1 exit /b 1 165 | echo. 166 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 167 | goto end 168 | ) 169 | 170 | if "%1" == "latexpdf" ( 171 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 172 | cd %BUILDDIR%/latex 173 | make all-pdf 174 | cd %~dp0 175 | echo. 176 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 177 | goto end 178 | ) 179 | 180 | if "%1" == "latexpdfja" ( 181 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 182 | cd %BUILDDIR%/latex 183 | make all-pdf-ja 184 | cd %~dp0 185 | echo. 186 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 187 | goto end 188 | ) 189 | 190 | if "%1" == "text" ( 191 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 192 | if errorlevel 1 exit /b 1 193 | echo. 194 | echo.Build finished. The text files are in %BUILDDIR%/text. 195 | goto end 196 | ) 197 | 198 | if "%1" == "man" ( 199 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 200 | if errorlevel 1 exit /b 1 201 | echo. 202 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 203 | goto end 204 | ) 205 | 206 | if "%1" == "texinfo" ( 207 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 208 | if errorlevel 1 exit /b 1 209 | echo. 210 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 211 | goto end 212 | ) 213 | 214 | if "%1" == "gettext" ( 215 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 216 | if errorlevel 1 exit /b 1 217 | echo. 218 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 219 | goto end 220 | ) 221 | 222 | if "%1" == "changes" ( 223 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 224 | if errorlevel 1 exit /b 1 225 | echo. 226 | echo.The overview file is in %BUILDDIR%/changes. 227 | goto end 228 | ) 229 | 230 | if "%1" == "linkcheck" ( 231 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 232 | if errorlevel 1 exit /b 1 233 | echo. 234 | echo.Link check complete; look for any errors in the above output ^ 235 | or in %BUILDDIR%/linkcheck/output.txt. 236 | goto end 237 | ) 238 | 239 | if "%1" == "doctest" ( 240 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 241 | if errorlevel 1 exit /b 1 242 | echo. 243 | echo.Testing of doctests in the sources finished, look at the ^ 244 | results in %BUILDDIR%/doctest/output.txt. 245 | goto end 246 | ) 247 | 248 | if "%1" == "coverage" ( 249 | %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage 250 | if errorlevel 1 exit /b 1 251 | echo. 252 | echo.Testing of coverage in the sources finished, look at the ^ 253 | results in %BUILDDIR%/coverage/python.txt. 254 | goto end 255 | ) 256 | 257 | if "%1" == "xml" ( 258 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 259 | if errorlevel 1 exit /b 1 260 | echo. 261 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 262 | goto end 263 | ) 264 | 265 | if "%1" == "pseudoxml" ( 266 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 267 | if errorlevel 1 exit /b 1 268 | echo. 269 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 270 | goto end 271 | ) 272 | 273 | if "%1" == "dummy" ( 274 | %SPHINXBUILD% -b dummy %ALLSPHINXOPTS% %BUILDDIR%/dummy 275 | if errorlevel 1 exit /b 1 276 | echo. 277 | echo.Build finished. Dummy builder generates no files. 278 | goto end 279 | ) 280 | 281 | :end 282 | -------------------------------------------------------------------------------- /wampy/session.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | import logging 6 | import os 7 | 8 | from wampy.auth import compute_wcs 9 | from wampy.backends import async_adapter 10 | from wampy.errors import ( 11 | NoFrameReturnedError, WampyError, WampyTimeOutError, 12 | WampProtocolError, 13 | ) 14 | from wampy.messages import ( 15 | Abort, Authenticate, Cancel, Challenge, Hello, Goodbye, Register, 16 | Subscribe, Welcome, 17 | ) 18 | 19 | from wampy.mixins import ParseUrlMixin 20 | from wampy.transports import WebSocket, SecureWebSocket 21 | 22 | logger = logging.getLogger('wampy.session') 23 | 24 | 25 | class Session(ParseUrlMixin): 26 | """ A transient conversation between two Peers attached to a 27 | Realm and running over a Transport. 28 | 29 | WAMP Sessions are established over a WAMP Connection which is 30 | the responsibility of the ``Transport`` object. 31 | 32 | Each wampy ``Session`` manages its own WAMP connection via the 33 | ``Transport``. 34 | 35 | Once the connection is established, the Session is begun when 36 | the Realm is joined. This is achieved by sending the HELLO message. 37 | 38 | .. note:: 39 | Routing occurs only between WAMP Sessions that have joined the 40 | same Realm. 41 | 42 | """ 43 | 44 | def __init__( 45 | self, router_url, message_handler, ipv, cert_path, 46 | call_timeout, realm, roles, client_name, 47 | ): 48 | """ A Session between a Client and a Router. 49 | 50 | The WAMP layer of the internal architecture. 51 | 52 | :Parameters: 53 | router_url : string 54 | The URL of the Router Peer. 55 | message_handler : instance 56 | An instance of ``wampy.message_handler.MessageHandler``, 57 | or a subclass of it. Handles incoming WAMP Messages. 58 | ipv : int 59 | The Internet Protocol version for the Transport to use 60 | 61 | """ 62 | self.url = router_url 63 | # decomposes the url, adding new Session instance variables for 64 | # them, so that the Session can decide on the Transport it needs 65 | # to use to connect to the Router 66 | self.parse_url() 67 | 68 | self.message_handler = message_handler 69 | self.ipv = ipv 70 | self.cert_path = cert_path 71 | self.call_timeout = call_timeout 72 | self.realm = realm 73 | self.roles = roles 74 | self.client_name = client_name 75 | 76 | if self.scheme == "ws": 77 | self.transport = WebSocket( 78 | server_url=self.url, 79 | ipv=self.ipv, 80 | ) 81 | elif self.scheme == "wss": 82 | self.transport = SecureWebSocket( 83 | server_url=self.url, 84 | ipv=self.ipv, 85 | certificate_path=self.cert_path, 86 | ) 87 | else: 88 | raise WampyError( 89 | 'wampy only suppoers network protocol "ws" or "wss"' 90 | ) 91 | 92 | self.connection = self.transport.connect(upgrade=True) 93 | 94 | self.request_ids = {} 95 | self.subscription_map = {} 96 | self.registration_map = {} 97 | 98 | self.session_id = None 99 | # spawn a green thread to listen for incoming messages over 100 | # a connection and put them on a queue to be processed 101 | self._managed_thread = None 102 | # the MessageHandler is responsible for putting messages on 103 | # to this queue which are then returned to the Client. The 104 | # queue is shared between the green threads. 105 | self._message_queue = async_adapter.message_queue 106 | self._listen() 107 | 108 | @property 109 | def id(self): 110 | return self.session_id 111 | 112 | # TODO wrap in HELLO 113 | def begin(self): 114 | self._say_hello() 115 | # wait for the Welcome Message which the MessageHandler returns to 116 | # the Client via its private message queue 117 | logger.info("Session requested") 118 | 119 | # TODO wrap in END 120 | def end(self, goodbye_from): 121 | self._say_goodbye(goodbye_from=goodbye_from) 122 | self.connection.disconnect() 123 | self._managed_thread.kill() 124 | self.session_id = None 125 | 126 | def send_message(self, message_obj): 127 | message = message_obj.message 128 | self.connection.send(message) 129 | 130 | # TODO: move this to the Client to remove another layer of abstraction? 131 | def recv_message(self, source_request_id=None, timeout=None): 132 | # Messages are passed from the MessageHandler to a queue on the 133 | # Client. 134 | try: 135 | message = async_adapter.receive_message( 136 | timeout=timeout or self.call_timeout, 137 | ) 138 | except WampProtocolError as wamp_err: 139 | logger.error(wamp_err) 140 | raise 141 | except WampyTimeOutError: 142 | if source_request_id: 143 | logger.warning( 144 | 'cancelling Call after wampy timed the Call out' 145 | ) 146 | cancelation = Cancel(request_id=source_request_id) 147 | self.send_message(cancelation) 148 | raise 149 | except Exception as exc: 150 | logger.warning("rpc failed!!") 151 | logger.exception(str(exc)) 152 | raise 153 | 154 | return message 155 | 156 | def _say_hello(self): 157 | details = self.roles 158 | for role, features in details['roles'].items(): 159 | features.setdefault('features', {}) 160 | features['features'].setdefault('call_timeout', True) 161 | 162 | message = Hello(realm=self.realm, details=details) 163 | self.send_message(message) 164 | 165 | message_obj = self.recv_message() 166 | # raise if Router aborts handshake or we cannot respond to a 167 | # Challenge. 168 | if message_obj.WAMP_CODE == Abort.WAMP_CODE: 169 | # gracefully shut down the Client and raise 170 | self.connection.disconnect() 171 | self._managed_thread.kill() 172 | raise WampyError(message_obj.message) 173 | 174 | if message_obj.WAMP_CODE == Challenge.WAMP_CODE: 175 | if 'WAMPYSECRET' not in os.environ: 176 | raise WampyError( 177 | "Wampy requires a client's secret to be " 178 | "in the environment as ``WAMPYSECRET``" 179 | ) 180 | 181 | secret = os.environ['WAMPYSECRET'] 182 | if message_obj.auth_method == 'ticket': 183 | logger.info("proceeding with ticket authentication method") 184 | message = Authenticate(secret) 185 | else: 186 | logger.info("assuming wampcra authentication method") 187 | challenge_data = message_obj.challenge 188 | signature = compute_wcs(secret, str(challenge_data)) 189 | message = Authenticate(signature.decode("utf-8")) 190 | 191 | self.send_message(message) 192 | message_obj = self.recv_message() 193 | 194 | # raise if Router aborts handshake or we cannot respond to a 195 | # Challenge. 196 | if message_obj.WAMP_CODE == Abort.WAMP_CODE: 197 | # gracefully shut down the Client and raise 198 | self.connection.disconnect() 199 | self._managed_thread.kill() 200 | raise WampyError(message_obj.message) 201 | elif message_obj.WAMP_CODE == Welcome.WAMP_CODE: 202 | logger.info( 203 | "%s has been Authenticated and Welcomed", self.client_name, 204 | ) 205 | 206 | def _say_goodbye(self, goodbye_from): 207 | logger.info("%s is saying GoodBye", goodbye_from) 208 | message = Goodbye() 209 | self.send_message(message) 210 | 211 | message_obj = self.recv_message() 212 | if message_obj.WAMP_CODE != Goodbye.WAMP_CODE: 213 | raise WampyError( 214 | "Expecting a Goodbye from the Router: got a " 215 | f"{message_obj.WAMP_CODE} instead", 216 | ) 217 | 218 | def _listen(self): 219 | # listens on the TCP socket connection to Crossbar. 220 | # Full Frames are always WAMP messages, which are passed to 221 | # a MessageHandler. 222 | connection = self.connection 223 | 224 | def connection_handler(): 225 | while True: 226 | if not self._managed_thread.ready(): 227 | async_adapter.sleep() 228 | 229 | if ( 230 | not hasattr(connection, 'closed') or 231 | connection.socket.closed 232 | ): 233 | try: 234 | frame = connection.receive() 235 | if frame: 236 | message = frame.payload 237 | logger.info("handling %s", message) 238 | self.message_handler.handle_message(message) 239 | except (SystemExit, KeyboardInterrupt): 240 | logger.warning("system manually exited") 241 | break 242 | except NoFrameReturnedError: 243 | break 244 | 245 | else: 246 | # this is likely the parent gthread closing it deliberately 247 | logger.warning("connection gthread has closed") 248 | break 249 | 250 | gthread = async_adapter.spawn(connection_handler) 251 | self._managed_thread = gthread 252 | 253 | def _subscribe_to_topic(self, handler, topic): 254 | message = Subscribe(topic=topic) 255 | request_id = message.request_id 256 | 257 | try: 258 | self.send_message(message) 259 | except Exception as exc: 260 | raise WampProtocolError( 261 | "failed to subscribe to {}: \"{}\"".format( 262 | topic, exc) 263 | ) 264 | 265 | self.request_ids[request_id] = message, handler 266 | 267 | def _register_procedure(self, procedure_name, invocation_policy="single"): 268 | """ Register a "procedure" on a Client as callable over the Router. 269 | The REGISTERED Message is handled by the MessageHandler. 270 | """ 271 | options = {"invoke": invocation_policy} 272 | message = Register(procedure=procedure_name, options=options) 273 | request_id = message.request_id 274 | 275 | try: 276 | self.send_message(message) 277 | except ValueError: 278 | raise WampProtocolError( 279 | "failed to register callee: %s", procedure_name 280 | ) 281 | 282 | self.request_ids[request_id] = procedure_name 283 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # wampy documentation build configuration file, created by 4 | # sphinx-quickstart on Wed Aug 10 17:25:53 2016. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | import os 20 | import pkg_resources 21 | import sys 22 | 23 | import guzzle_sphinx_theme 24 | 25 | 26 | sys.path.insert(0, os.path.abspath('..')) 27 | sys.path.insert(0, os.path.abspath('../wampy')) 28 | sys.path.insert(0, os.path.abspath('../wampy/roles')) 29 | sys.path.insert(0, os.path.abspath('../wampy/messages')) 30 | sys.path.insert(0, os.path.abspath('../wampy/peers')) 31 | 32 | # -- General configuration ------------------------------------------------ 33 | 34 | # If your documentation needs a minimal Sphinx version, state it here. 35 | # 36 | # needs_sphinx = '1.0' 37 | 38 | # Add any Sphinx extension module names here, as strings. They can be 39 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 40 | # ones. 41 | extensions = [ 42 | 'sphinx.ext.autodoc', 43 | 'sphinx.ext.todo', 44 | 'sphinx.ext.coverage', 45 | 'sphinx.ext.viewcode', 46 | 'sphinx.ext.intersphinx', 47 | 'guzzle_sphinx_theme', 48 | ] 49 | 50 | # Add any paths that contain templates here, relative to this directory. 51 | templates_path = ['templates'] 52 | 53 | # The suffix(es) of source filenames. 54 | # You can specify multiple suffix as a list of string: 55 | # 56 | # source_suffix = ['.rst', '.md'] 57 | source_suffix = '.rst' 58 | 59 | # The encoding of source files. 60 | # 61 | # source_encoding = 'utf-8-sig' 62 | 63 | # The master toctree document. 64 | master_doc = 'index' 65 | 66 | # General information about the project. 67 | project = u'wampy' 68 | copyright = u'2016, simon harrison' 69 | author = u'simon harrison' 70 | 71 | # The version info for the project you're documenting, acts as replacement for 72 | # |version| and |release|, also used in various other places throughout the 73 | # built documents. 74 | # 75 | # The short X.Y version. 76 | version = pkg_resources.get_distribution('wampy').version 77 | # The full version, including alpha/beta/rc tags. 78 | release = version 79 | 80 | # The language for content autogenerated by Sphinx. Refer to documentation 81 | # for a list of supported languages. 82 | # 83 | # This is also used if you do content translation via gettext catalogs. 84 | # Usually you set "language" from the command line for these cases. 85 | language = None 86 | 87 | # There are two options for replacing |today|: either, you set today to some 88 | # non-false value, then it is used: 89 | # 90 | # today = '' 91 | # 92 | # Else, today_fmt is used as the format for a strftime call. 93 | # 94 | # today_fmt = '%B %d, %Y' 95 | 96 | # List of patterns, relative to source directory, that match files and 97 | # directories to ignore when looking for source files. 98 | # This patterns also effect to html_static_path and html_extra_path 99 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 100 | 101 | # The reST default role (used for this markup: `text`) to use for all 102 | # documents. 103 | # 104 | # default_role = None 105 | 106 | # If true, '()' will be appended to :func: etc. cross-reference text. 107 | # 108 | # add_function_parentheses = True 109 | 110 | # If true, the current module name will be prepended to all description 111 | # unit titles (such as .. function::). 112 | # 113 | # add_module_names = True 114 | 115 | # If true, sectionauthor and moduleauthor directives will be shown in the 116 | # output. They are ignored by default. 117 | # 118 | # show_authors = False 119 | 120 | # The name of the Pygments (syntax highlighting) style to use. 121 | pygments_style = 'sphinx' 122 | 123 | # A list of ignored prefixes for module index sorting. 124 | modindex_common_prefix = ['wampy'] 125 | 126 | # If true, keep warnings as "system message" paragraphs in the built documents. 127 | # keep_warnings = False 128 | 129 | # If true, `todo` and `todoList` produce output, else they produce nothing. 130 | todo_include_todos = False 131 | 132 | 133 | # -- Options for HTML output ---------------------------------------------- 134 | 135 | html_theme_path = guzzle_sphinx_theme.html_theme_path() 136 | html_theme = 'guzzle_sphinx_theme' 137 | 138 | # Theme options are theme-specific and customize the look and feel of a theme 139 | # further. For a list of options available for each theme, see the 140 | # documentation. 141 | # 142 | # html_theme_options = {} 143 | 144 | # Add any paths that contain custom themes here, relative to this directory. 145 | # html_theme_path = [] 146 | 147 | # The name for this set of Sphinx documents. 148 | # " v documentation" by default. 149 | # 150 | # html_title = u'wampy v0.3.0' 151 | 152 | # A shorter title for the navigation bar. Default is the same as html_title. 153 | # 154 | # html_short_title = None 155 | 156 | # The name of an image file (relative to this directory) to place at the top 157 | # of the sidebar. 158 | # 159 | # html_logo = None 160 | 161 | # The name of an image file (relative to this directory) to use as a favicon of 162 | # the docs. This file should be a Windows icon file (.ico) being 16x16 or 163 | # 32x32 pixels large. 164 | # 165 | # html_favicon = None 166 | 167 | # Add any paths that contain custom static files (such as style sheets) here, 168 | # relative to this directory. They are copied after the builtin static files, 169 | # so a file named "default.css" will overwrite the builtin "default.css". 170 | html_static_path = ['_static'] 171 | 172 | # Add any extra paths that contain custom files (such as robots.txt or 173 | # .htaccess) here, relative to this directory. These files are copied 174 | # directly to the root of the documentation. 175 | # 176 | # html_extra_path = [] 177 | 178 | # If not None, a 'Last updated on:' timestamp is inserted at every page 179 | # bottom, using the given strftime format. 180 | # The empty string is equivalent to '%b %d, %Y'. 181 | # 182 | # html_last_updated_fmt = None 183 | 184 | # If true, SmartyPants will be used to convert quotes and dashes to 185 | # typographically correct entities. 186 | # 187 | # html_use_smartypants = True 188 | 189 | # Custom sidebar templates, maps document names to template names. 190 | # 191 | html_sidebars = { 192 | '**': ['localtoc.html', 'relations.html', 193 | 'sidebarlinks.html', 'searchbox.html'] 194 | } 195 | 196 | # Additional templates that should be rendered to pages, maps page names to 197 | # template names. 198 | # 199 | # html_additional_pages = {} 200 | 201 | # If false, no module index is generated. 202 | # 203 | # html_domain_indices = True 204 | 205 | # If false, no index is generated. 206 | # 207 | # html_use_index = True 208 | 209 | # If true, the index is split into individual pages for each letter. 210 | # 211 | # html_split_index = False 212 | 213 | # If true, links to the reST sources are added to the pages. 214 | # 215 | # html_show_sourcelink = True 216 | 217 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 218 | # 219 | # html_show_sphinx = True 220 | 221 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 222 | # 223 | # html_show_copyright = True 224 | 225 | # If true, an OpenSearch description file will be output, and all pages will 226 | # contain a tag referring to it. The value of this option must be the 227 | # base URL from which the finished HTML is served. 228 | # 229 | # html_use_opensearch = '' 230 | 231 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 232 | # html_file_suffix = None 233 | 234 | # Language to be used for generating the HTML full-text search index. 235 | # Sphinx supports the following languages: 236 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' 237 | # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr', 'zh' 238 | # 239 | # html_search_language = 'en' 240 | 241 | # A dictionary with options for the search language support, empty by default. 242 | # 'ja' uses this config value. 243 | # 'zh' user can custom change `jieba` dictionary path. 244 | # 245 | # html_search_options = {'type': 'default'} 246 | 247 | # The name of a javascript file (relative to the configuration directory) that 248 | # implements a search results scorer. If empty, the default will be used. 249 | # 250 | # html_search_scorer = 'scorer.js' 251 | 252 | # Output file base name for HTML help builder. 253 | htmlhelp_basename = 'wampydoc' 254 | 255 | # -- Options for LaTeX output --------------------------------------------- 256 | 257 | latex_elements = { 258 | # The paper size ('letterpaper' or 'a4paper'). 259 | # 260 | # 'papersize': 'letterpaper', 261 | 262 | # The font size ('10pt', '11pt' or '12pt'). 263 | # 264 | # 'pointsize': '10pt', 265 | 266 | # Additional stuff for the LaTeX preamble. 267 | # 268 | # 'preamble': '', 269 | 270 | # Latex figure (float) alignment 271 | # 272 | # 'figure_align': 'htbp', 273 | } 274 | 275 | # Grouping the document tree into LaTeX files. List of tuples 276 | # (source start file, target name, title, 277 | # author, documentclass [howto, manual, or own class]). 278 | latex_documents = [ 279 | (master_doc, 'wampy.tex', u'wampy Documentation', 280 | u'simon harrison', 'manual'), 281 | ] 282 | 283 | # The name of an image file (relative to this directory) to place at the top of 284 | # the title page. 285 | # 286 | # latex_logo = None 287 | 288 | # For "manual" documents, if this is true, then toplevel headings are parts, 289 | # not chapters. 290 | # 291 | # latex_use_parts = False 292 | 293 | # If true, show page references after internal links. 294 | # 295 | # latex_show_pagerefs = False 296 | 297 | # If true, show URL addresses after external links. 298 | # 299 | # latex_show_urls = False 300 | 301 | # Documents to append as an appendix to all manuals. 302 | # 303 | # latex_appendices = [] 304 | 305 | # It false, will not define \strong, \code, itleref, \crossref ... but only 306 | # \sphinxstrong, ..., \sphinxtitleref, ... To help avoid clash with user added 307 | # packages. 308 | # 309 | # latex_keep_old_macro_names = True 310 | 311 | # If false, no module index is generated. 312 | # 313 | # latex_domain_indices = True 314 | 315 | 316 | # -- Options for manual page output --------------------------------------- 317 | 318 | # One entry per manual page. List of tuples 319 | # (source start file, name, description, authors, manual section). 320 | man_pages = [ 321 | (master_doc, 'wampy', u'wampy Documentation', 322 | [author], 1) 323 | ] 324 | 325 | # If true, show URL addresses after external links. 326 | # 327 | # man_show_urls = False 328 | 329 | 330 | # -- Options for Texinfo output ------------------------------------------- 331 | 332 | # Grouping the document tree into Texinfo files. List of tuples 333 | # (source start file, target name, title, author, 334 | # dir menu entry, description, category) 335 | texinfo_documents = [ 336 | (master_doc, 'wampy', u'wampy Documentation', 337 | author, 'wampy', 'One line description of project.', 338 | 'Miscellaneous'), 339 | ] 340 | 341 | # Documents to append as an appendix to all manuals. 342 | # 343 | # texinfo_appendices = [] 344 | 345 | # If false, no module index is generated. 346 | # 347 | # texinfo_domain_indices = True 348 | 349 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 350 | # 351 | # texinfo_show_urls = 'footnote' 352 | 353 | # If true, do not generate a @detailmenu in the "Top" node's menu. 354 | # 355 | # texinfo_no_detailmenu = False 356 | 357 | 358 | # Example configuration for intersphinx: refer to the Python standard library. 359 | intersphinx_mapping = {'https://docs.python.org/': None} 360 | --------------------------------------------------------------------------------