├── 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 | 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