Repository: mbr/tinyrpc Branch: master Commit: d77f1218da49 Files: 60 Total size: 262.1 KB Directory structure: gitextract_i25jxbas/ ├── .github/ │ └── workflows/ │ └── python-tox.yml ├── .gitignore ├── .readthedocs.yaml ├── .style.yapf ├── .vscode/ │ └── settings.json ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs/ │ ├── Makefile │ ├── _static/ │ │ └── uml.xmi │ ├── client.rst │ ├── conf.py │ ├── dispatch.rst │ ├── examples.rst │ ├── exceptions.rst │ ├── index.rst │ ├── jsonrpc.rst │ ├── make.bat │ ├── msgpackrpc.rst │ ├── protocols.rst │ ├── server.rst │ ├── structure.rst │ └── transports.rst ├── examples/ │ ├── http_client_example.py │ ├── http_server_example.py │ ├── zmq_client_example.py │ └── zmq_server_example.py ├── optional_features.pip ├── pyproject.toml ├── requirements.txt ├── setup.py ├── tests/ │ ├── __init__.py │ ├── test_client.py │ ├── test_dispatch.py │ ├── test_jsonrpc.py │ ├── test_msgpackrpc.py │ ├── test_protocols.py │ ├── test_rabbitmq_transport.py │ ├── test_server.py │ ├── test_transport.py │ └── test_wsgi_transport.py ├── tinyrpc/ │ ├── __init__.py │ ├── client.py │ ├── dispatch/ │ │ └── __init__.py │ ├── exc.py │ ├── protocols/ │ │ ├── __init__.py │ │ ├── jsonrpc.py │ │ └── msgpackrpc.py │ ├── server/ │ │ ├── __init__.py │ │ └── gevent.py │ └── transports/ │ ├── __init__.py │ ├── callback.py │ ├── cgi.py │ ├── http.py │ ├── rabbitmq.py │ ├── websocket.py │ ├── websocketclient.py │ ├── wsgi.py │ └── zmq.py └── tox.ini ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/python-tox.yml ================================================ # This workflow will install tox and run tox for each version of Python defined in the matrix name: Unit tests on: push: branches: [ master ] pull_request: branches: [ master ] jobs: build: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Install tox run: | python -m pip install --upgrade pip python -m pip install tox - name: Run tox run: tox -e py ================================================ FILE: .gitignore ================================================ .cache .tox .idea __pycache__ *.pyc *egg-info docs/_build .coverage .pytest_cache _env build/ dist/ htmlcov/ ================================================ FILE: .readthedocs.yaml ================================================ # .readthedocs.yaml # Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details # Required version: 2 # Set the version of Python and other tools you might need build: os: ubuntu-22.04 tools: python: "3.11" # Build documentation in the docs/ directory with Sphinx sphinx: configuration: docs/conf.py # We recommend specifying your dependencies to enable reproducible builds: # https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html # python: # install: # - requirements: docs/requirements.txt ================================================ FILE: .style.yapf ================================================ [style] # Align closing bracket with visual indentation. align_closing_bracket_with_visual_indent=True # Allow dictionary keys to exist on multiple lines. For example: # # x = { # ('this is the first element of a tuple', # 'this is the second element of a tuple'): # value, # } allow_multiline_dictionary_keys=False # Allow lambdas to be formatted on more than one line. allow_multiline_lambdas=True # Allow splits before the dictionary value. allow_split_before_dict_value=True # Number of blank lines surrounding top-level function and class # definitions. blank_lines_around_top_level_definition=2 # Insert a blank line before a class-level docstring. blank_line_before_class_docstring=False # Insert a blank line before a 'def' or 'class' immediately nested # within another 'def' or 'class'. For example: # # class Foo: # # <------ this blank line # def method(): # ... blank_line_before_nested_class_or_def=False # Do not split consecutive brackets. Only relevant when # dedent_closing_brackets is set. For example: # # call_func_that_takes_a_dict( # { # 'key1': 'value1', # 'key2': 'value2', # } # ) # # would reformat to: # # call_func_that_takes_a_dict({ # 'key1': 'value1', # 'key2': 'value2', # }) coalesce_brackets=True # The column limit. column_limit=79 # The style for continuation alignment. Possible values are: # # - SPACE: Use spaces for continuation alignment. This is default behavior. # - FIXED: Use fixed number (CONTINUATION_INDENT_WIDTH) of columns # (ie: CONTINUATION_INDENT_WIDTH/INDENT_WIDTH tabs) for continuation # alignment. # - LESS: Slightly left if cannot vertically align continuation lines with # indent characters. # - VALIGN-RIGHT: Vertically align continuation lines with indent # characters. Slightly right (one more indent character) if cannot # vertically align continuation lines with indent characters. # # For options FIXED, and VALIGN-RIGHT are only available when USE_TABS is # enabled. continuation_align_style=SPACE # Indent width used for line continuations. continuation_indent_width=4 # Put closing brackets on a separate line, dedented, if the bracketed # expression can't fit in a single line. Applies to all kinds of brackets, # including function definitions and calls. For example: # # config = { # 'key1': 'value1', # 'key2': 'value2', # } # <--- this bracket is dedented and on a separate line # # time_series = self.remote_client.query_entity_counters( # entity='dev3246.region1', # key='dns.query_latency_tcp', # transform=Transformation.AVERAGE(window=timedelta(seconds=60)), # start_ts=now()-timedelta(days=3), # end_ts=now(), # ) # <--- this bracket is dedented and on a separate line dedent_closing_brackets=True # Place each dictionary entry onto its own line. each_dict_entry_on_separate_line=True # The regex for an i18n comment. The presence of this comment stops # reformatting of that line, because the comments are required to be # next to the string they translate. i18n_comment= # The i18n function call names. The presence of this function stops # reformattting on that line, because the string it has cannot be moved # away from the i18n comment. i18n_function_call= # Indent the dictionary value if it cannot fit on the same line as the # dictionary key. For example: # # config = { # 'key1': # 'value1', # 'key2': value1 + # value2, # } indent_dictionary_value=False # The number of columns to use for indentation. indent_width=4 # Join short lines into one line. E.g., single line 'if' statements. join_multiple_lines=True # Do not include spaces around selected binary operators. For example: # # 1 + 2 * 3 - 4 / 5 # # will be formatted as follows when configured with a value "*,/": # # 1 + 2*3 - 4/5 # no_spaces_around_selected_binary_operators=set() # Use spaces around default or named assigns. spaces_around_default_or_named_assign=False # Use spaces around the power operator. spaces_around_power_operator=False # The number of spaces required before a trailing comment. spaces_before_comment=2 # Insert a space between the ending comma and closing bracket of a list, # etc. space_between_ending_comma_and_closing_bracket=True # Split before arguments if the argument list is terminated by a # comma. split_arguments_when_comma_terminated=False # Set to True to prefer splitting before '&', '|' or '^' rather than # after. split_before_bitwise_operator=True # Split before the closing bracket if a list or dict literal doesn't fit on # a single line. split_before_closing_bracket=True # Split before a dictionary or set generator (comp_for). For example, note # the split before the 'for': # # foo = { # variable: 'Hello world, have a nice day!' # for variable in bar if variable != 42 # } split_before_dict_set_generator=True # Split after the opening paren which surrounds an expression if it doesn't # fit on a single line. split_before_expression_after_opening_paren=False # If an argument / parameter list is going to be split, then split before # the first argument. split_before_first_argument=True # Set to True to prefer splitting before 'and' or 'or' rather than # after. split_before_logical_operator=True # Split named assignments onto individual lines. split_before_named_assigns=True # Set to True to split list comprehensions and generators that have # non-trivial expressions and multiple clauses before each of these # clauses. For example: # # result = [ # a_long_var + 100 for a_long_var in xrange(1000) # if a_long_var % 10] # # would reformat to something like: # # result = [ # a_long_var + 100 # for a_long_var in xrange(1000) # if a_long_var % 10] split_complex_comprehension=True # The penalty for splitting right after the opening bracket. split_penalty_after_opening_bracket=30 # The penalty for splitting the line after a unary operator. split_penalty_after_unary_operator=10000 # The penalty for splitting right before an if expression. split_penalty_before_if_expr=0 # The penalty of splitting the line around the '&', '|', and '^' # operators. split_penalty_bitwise_operator=300 # The penalty for splitting a list comprehension or generator # expression. split_penalty_comprehension=80 # The penalty for characters over the column limit. split_penalty_excess_character=4500 # The penalty incurred by adding a line split to the unwrapped line. The # more line splits added the higher the penalty. split_penalty_for_added_line_split=30 # The penalty of splitting a list of "import as" names. For example: # # from a_very_long_or_indented_module_name_yada_yad import (long_argument_1, # long_argument_2, # long_argument_3) # # would reformat to something like: # # from a_very_long_or_indented_module_name_yada_yad import ( # long_argument_1, long_argument_2, long_argument_3) split_penalty_import_names=0 # The penalty of splitting the line around the 'and' and 'or' # operators. split_penalty_logical_operator=300 # Use the Tab character for indentation. use_tabs=False ================================================ FILE: .vscode/settings.json ================================================ { "restructuredtext.confPath": "${workspaceFolder}/docs" "files.watcherExclude": { "**/.git/objects/**": true, "**/.git/subtree-cache/**": true, "**/node_modules/*/**": true, ".tox/**": true } } ================================================ FILE: LICENSE ================================================ Copyright (c) 2013 Marc Brinkmann Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: MANIFEST.in ================================================ include README.rst include LICENSE ================================================ FILE: README.rst ================================================ tinyrpc: A small and modular way of handling web-related RPC ============================================================ .. image:: https://readthedocs.org/projects/tinyrpc/badge/?version=latest :target: https://tinyrpc.readthedocs.io/en/latest .. image:: https://github.com/mbr/tinyrpc/actions/workflows/python-tox.yml/badge.svg :target: https://github.com/mbr/tinyrpc/actions/workflows/python-tox.yml .. image:: https://badge.fury.io/py/tinyrpc.svg :target: https://pypi.org/project/tinyrpc/ Note ---- Tinyrpc has been revised. The current version will support Python3 only. Have a look at the 0.9.x version if you need Python2 support. Python2 support will be dropped completely when Python2 retires, somewhere in 2020. Motivation ---------- As of this writing (in Jan 2013) there are a few jsonrpc_ libraries already out there on PyPI_, most of them handling one specific use case (e.g. json via WSGI, using Twisted, or TCP-sockets). None of the libraries, however, makes it easy to reuse the jsonrpc_-parsing bits and substitute a different transport (i.e. going from json_ via TCP_ to an implementation using WebSockets_ or 0mq_). In the end, all these libraries have their own dispatching interfaces and a custom implementation of handling jsonrpc_. Today (march 2019) that hasn't changed. ``tinyrpc`` aims to do better by dividing the problem into cleanly interchangeable parts that allow easy addition of new transport methods, RPC protocols or dispatchers. Example: To create a server process receiving and handling JSONRPC requests do: .. code-block:: python import gevent import gevent.pywsgi import gevent.queue from tinyrpc.protocols.jsonrpc import JSONRPCProtocol from tinyrpc.transports.wsgi import WsgiServerTransport from tinyrpc.server.gevent import RPCServerGreenlets from tinyrpc.dispatch import RPCDispatcher dispatcher = RPCDispatcher() transport = WsgiServerTransport(queue_class=gevent.queue.Queue) # start wsgi server as a background-greenlet wsgi_server = gevent.pywsgi.WSGIServer(('127.0.0.1', 5000), transport.handle) gevent.spawn(wsgi_server.serve_forever) rpc_server = RPCServerGreenlets(transport, JSONRPCProtocol(), dispatcher) @dispatcher.public def reverse_string(s): return s[::-1] # in the main greenlet, run our rpc_server rpc_server.serve_forever() The corresponding client code looks like: .. code-block:: python from tinyrpc.protocols.jsonrpc import JSONRPCProtocol from tinyrpc.transports.http import HttpPostClientTransport from tinyrpc import RPCClient rpc_client = RPCClient( JSONRPCProtocol(), HttpPostClientTransport('http://127.0.0.1:5000/')) remote_server = rpc_client.get_proxy() # call a method called 'reverse_string' with a single string argument result = remote_server.reverse_string('Hello, World!') print("Server answered:", result) Documentation ------------- You'll quickly find that ``tinyrpc`` has more documentation and tests than core code, hence the name. See the documentation at for more details, especially the Structure-section to get a birds-eye view. Installation ------------ .. code-block:: sh pip install tinyrpc will install ``tinyrpc`` with its default dependencies. Optional dependencies --------------------- Depending on the protocols and transports you want to use additional dependencies are required. You can instruct pip to install these dependencies by specifying extras to the basic install command. .. code-block:: sh pip install tinyrpc[httpclient, wsgi] will install ``tinyrpc`` with dependencies for the httpclient and wsgi transports. Available extras are: +------------+-------------------------------------------------------+ | Option | Needed to use objects of class | +============+=======================================================+ | gevent | optional in RPCClient, required by RPCServerGreenlets | +------------+-------------------------------------------------------+ | httpclient | HttpPostClientTransport, HttpWebSocketClientTransport | +------------+-------------------------------------------------------+ | msgpack | implements MSGPACKRPCProtocol | +------------+-------------------------------------------------------+ | jsonext | optional in JSONRPCProtocol | +------------+-------------------------------------------------------+ | rabbitmq | RabbitMQServerTransport, RabbitMQClientTransport | +------------+-------------------------------------------------------+ | websocket | WSServerTransport | +------------+-------------------------------------------------------+ | wsgi | WsgiServerTransport | +------------+-------------------------------------------------------+ | zmq | ZmqServerTransport, ZmqClientTransport | +------------+-------------------------------------------------------+ New in version 1.1.0 -------------------- Tinyrpc supports RabbitMQ has transport medium. New in version 1.0.4 -------------------- Tinyrpc now supports the MSGPACK RPC protocol in addition to JSON-RPC. .. _jsonrpc: http://www.jsonrpc.org/ .. _PyPI: http://pypi.python.org .. _json: http://www.json.org/ .. _TCP: http://en.wikipedia.org/wiki/Transmission_Control_Protocol .. _WebSockets: http://en.wikipedia.org/wiki/WebSocket .. _0mq: http://www.zeromq.org/ ================================================ FILE: docs/Makefile ================================================ # Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = _build # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: -rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/tinyrpc.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/tinyrpc.qhc" devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/tinyrpc" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/tinyrpc" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." ================================================ FILE: docs/_static/uml.xmi ================================================ umbrello uml modeller http://umbrello.kde.org 1.6.9 UnicodeUTF8 ================================================ FILE: docs/client.rst ================================================ RPC Client ========== :py:class:`~tinyrpc.client.RPCClient` instances are high-level handlers for making remote procedure calls to servers. Other than :py:class:`~tinyrpc.client.RPCProxy` objects, they are what most user applications interact with. Clients needs to be instantiated with a protocol and a transport to function. Proxies are syntactic sugar for using clients. .. autoclass:: tinyrpc.client.RPCClient :members: :show-inheritance: :noindex: .. autoclass:: tinyrpc.client.RPCProxy :members: :show-inheritance: :noindex: .. automodule:: tinyrpc.client :members: RPCCall, RPCCallTo :show-inheritance: :noindex: ================================================ FILE: docs/conf.py ================================================ # -*- coding: utf-8 -*- # # tinyrpc documentation build configuration file, created by # sphinx-quickstart on Wed Jan 23 19:15:13 2013. # # This file is execfile()d with the current directory set to its containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import sys, os # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. #sys.path.insert(0, os.path.abspath('.')) sys.path.insert(0, os.path.abspath('..')) # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. #needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx'] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix of source filenames. source_suffix = '.rst' # The encoding of source files. #source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. project = u'tinyrpc' copyright = u'2013 - 2023, Marc Brinkmann, Leo Noordergraaf' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = '1.1' # The full version, including alpha/beta/rc tags. release = '1.1.7' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. #language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: #today = '' # Else, today_fmt is used as the format for a strftime call. #today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ['_build'] # The reST default role (used for this markup: `text`) to use for all documents. #default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. #add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). #add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. #show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] # -- Options for HTML output --------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. #html_theme = 'default' html_theme = 'sphinx_rtd_theme' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. #html_theme_options = {} html_theme_options = { 'prev_next_buttons_location': 'both', 'collapse_navigation': True, 'sticky_navigation': True, 'navigation_depth': 4 } # Add any paths that contain custom themes here, relative to this directory. #html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". #html_title = None # A shorter title for the navigation bar. Default is the same as html_title. #html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. #html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. #html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. #html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. #html_use_smartypants = True # Custom sidebar templates, maps document names to template names. #html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. #html_additional_pages = {} # If false, no module index is generated. #html_domain_indices = True # If false, no index is generated. #html_use_index = True # If true, the index is split into individual pages for each letter. #html_split_index = False # If true, links to the reST sources are added to the pages. #html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. #html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. #html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. #html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). #html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = 'tinyrpcdoc' # -- Options for LaTeX output -------------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). #'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). #'pointsize': '10pt', # Additional stuff for the LaTeX preamble. #'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ ('index', 'tinyrpc.tex', u'tinyrpc Documentation', u'Marc Brinkmann, Leo Noordergraaf', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. #latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. #latex_use_parts = False # If true, show page references after internal links. #latex_show_pagerefs = False # If true, show URL addresses after external links. #latex_show_urls = False # Documents to append as an appendix to all manuals. #latex_appendices = [] # If false, no module index is generated. #latex_domain_indices = True # -- Options for manual page output -------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ('index', 'tinyrpc', u'tinyrpc Documentation', [u'Marc Brinkmann, Leo Noordergraaf'], 1) ] # If true, show URL addresses after external links. #man_show_urls = False # -- Options for Texinfo output ------------------------------------------------ # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ('index', 'tinyrpc', u'tinyrpc Documentation', u'Marc Brinkmann, Leo Noordergraaf', 'tinyrpc', 'One line description of project.', 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. #texinfo_appendices = [] # If false, no module index is generated. #texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. #texinfo_show_urls = 'footnote' # Example configuration for intersphinx: refer to the Python standard library. intersphinx_mapping = { 'https://docs.python.org/3/': None, 'https://pyzmq.readthedocs.io/en/latest/': None, 'http://docs.python-requests.org/en/latest/': None, 'http://werkzeug.pocoo.org/docs/': None, 'http://www.gevent.org/': None, } autoclass_content = "both" autodoc_mock_imports = ["msgpack", "zmq", "werkzeug", "pika", "geventwebsocket"] ================================================ FILE: docs/dispatch.rst ================================================ Dispatching =========== Dispatching in ``tinyrpc`` is very similiar to url-routing in web frameworks. Functions are registered with a specific name and made public, i.e. callable, to remote clients. Examples -------- Exposing a few functions: ~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python from tinyrpc.dispatch import RPCDispatcher dispatch = RPCDispatcher() @dispatch.public def foo(): # ... @dispatch.public def bar(arg): # ... # later on, assuming we know we want to call foo(*args, **kwargs): f = dispatch.get_method('foo') f(*args, **kwargs) Using prefixes and instance registration: ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python from tinyrpc.dispatch import public class SomeWebsite(object): def __init__(self, ...): # note: this method will not be exposed def secret(self): # another unexposed method @public def get_user_info(self, user): # ... # using a different name @public('get_user_comment') def get_comment(self, comment_id): # ... The code above declares an RPC interface for ``SomeWebsite`` objects, consisting of two visible methods: ``get_user_info(user)`` and ``get_user_comment(comment_id)``. These can be used with a dispatcher now: .. code-block:: python def hello(): # ... website1 = SomeWebsite(...) website2 = SomeWebsite(...) from tinyrpc.dispatch import RPCDispatcher dispatcher = RPCDispatcher() # directly register version method @dispatcher.public def version(): # ... # add earlier defined method dispatcher.add_method(hello) # register the two website instances dispatcher.register_instance(website1, 'sitea.') dispatcher.register_instance(website2, 'siteb.') In the example above, the :py:class:`~tinyrpc.dispatch.RPCDispatcher` now knows a total of six registered methods: ``version``, ``hello``, ``sitea.get_user_info``, ``sitea.get_user_comment``, ``siteb.get_user_info``, ``siteb.get_user_comment``. Automatic dispatching ~~~~~~~~~~~~~~~~~~~~~ When writing a server application, a higher level dispatching method is available with :py:func:`~tinyrpc.dispatch.RPCDispatcher.dispatch`: .. code-block:: python from tinyrpc.dispatch import RPCDispatcher dispatcher = RPCDispatcher() # register methods like in the examples above # ... # now assumes that a valid RPCRequest has been obtained, as `request` response = dispatcher.dispatch(request) # response can be directly processed back to the client, all Exceptions have # been handled already Class, static and unbound method dispatching ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Although you will only rarely use these method types they *do* work and here we show you how. Class methods do not have `self` as the initial parameter but rather a reference to their class. You may want to use such methods to instantiate class instances. .. code-block:: python class ShowClassMethod: @classmethod @public def func(cls, a, b): return a-b Note the ordering of the decorators. Ordering them differently will not work. You call dispatch to the `func` method just as you would dispatch to any other method. Static methods have neither a class nor instance reference as first parameter: .. code-block:: python class ShowStaticMethod: @staticmethod @public def func(a, b): return a-b Again the ordering of the decorators is critical and you dispatch them as any other method. Finally it is possible to dispatch to unbound methods but I strongly advise against it. If you really want to do that see the tests to learn how. Everyone else should use static methods instead. API reference ------------- .. autoclass:: tinyrpc.dispatch.RPCDispatcher :members: :show-inheritance: :member-order: bysource Classes can be made to support an RPC interface without coupling it to a dispatcher using a decorator: .. autofunction:: tinyrpc.dispatch.public ================================================ FILE: docs/examples.rst ================================================ Quickstart examples =================== The source contains all of these examples in a working fashion in the examples subfolder. HTTP based ---------- A client making JSONRPC calls via HTTP (this requires :py:mod:`requests` to be installed): .. code-block:: python from tinyrpc import RPCClient from tinyrpc.protocols.jsonrpc import JSONRPCProtocol from tinyrpc.transports.http import HttpPostClientTransport rpc_client = RPCClient( JSONRPCProtocol(), HttpPostClientTransport('http://localhost') ) str_server = rpc_client.get_proxy() # ... # call a method called 'reverse_string' with a single string argument result = str_server.reverse_string('Simple is better.') print("Server answered:", result) This call can be answered by a server implemented as follows: .. code-block:: python import gevent import gevent.pywsgi import gevent.queue from tinyrpc.server.gevent import RPCServerGreenlets from tinyrpc.dispatch import RPCDispatcher from tinyrpc.protocols.jsonrpc import JSONRPCProtocol from tinyrpc.transports.wsgi import WsgiServerTransport dispatcher = RPCDispatcher() transport = WsgiServerTransport(queue_class=gevent.queue.Queue) # start wsgi server as a background-greenlet wsgi_server = gevent.pywsgi.WSGIServer(('127.0.0.1', 80), transport.handle) gevent.spawn(wsgi_server.serve_forever) rpc_server = RPCServerGreenlets( transport, JSONRPCProtocol(), dispatcher ) @dispatcher.public def reverse_string(s): return s[::-1] # in the main greenlet, run our rpc_server rpc_server.serve_forever() 0mq --- An example using :py:mod:`zmq` is very similiar, differing only in the instantiation of the transport: .. code-block:: python import zmq from tinyrpc import RPCClient from tinyrpc.protocols.jsonrpc import JSONRPCProtocol from tinyrpc.transports.zmq import ZmqClientTransport ctx = zmq.Context() rpc_client = RPCClient( JSONRPCProtocol(), ZmqClientTransport.create(ctx, 'tcp://127.0.0.1:5001') ) str_server = rpc_client.get_proxy() # call a method called 'reverse_string' with a single string argument result = str_server.reverse_string('Hello, World!') print("Server answered:", result) Matching server: .. code-block:: python import zmq from tinyrpc.server import RPCServer from tinyrpc.dispatch import RPCDispatcher from tinyrpc.protocols.jsonrpc import JSONRPCProtocol from tinyrpc.transports.zmq import ZmqServerTransport ctx = zmq.Context() dispatcher = RPCDispatcher() transport = ZmqServerTransport.create(ctx, 'tcp://127.0.0.1:5001') rpc_server = RPCServer( transport, JSONRPCProtocol(), dispatcher ) @dispatcher.public def reverse_string(s): return s[::-1] rpc_server.serve_forever() Further examples ---------------- In :doc:`protocols`, you can find client and server examples on how to use just the protocol parsing parts of ``tinyrpc``. The :py:class:`~tinyrpc.dispatch.RPCDispatcher` should be useful on its own (or at least easily replaced with one of your choosing), see :doc:`dispatch` for details. ================================================ FILE: docs/exceptions.rst ================================================ The Exceptions hierarchy ======================== All exceptions are rooted in the :py:class:`Exception` class. The :py:class:`~tinyrpc.exc.RPCError` class derives from it and forms the basis of all tinyrpc exceptions. Abstract exceptions ------------------- These exceptions, most of them will be overridden, define errors concerning the transport and structure of messages. .. autoclass:: tinyrpc.exc.RPCError :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.exc.BadRequestError :members: :show-inheritance: :member-order: bysource :noindex: .. autoclass:: tinyrpc.exc.BadReplyError :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.exc.InvalidRequestError :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.exc.InvalidReplyError :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.exc.MethodNotFoundError :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.exc.InvalidParamsError :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.exc.ServerError :members: :show-inheritance: :member-order: bysource Protocol exceptions ------------------- Each protocol provides its own concrete implementations of these exceptions. JSON-RPC ^^^^^^^^ .. autoclass:: tinyrpc.protocols.jsonrpc.JSONRPCParseError :members: :show-inheritance: :member-order: bysource :noindex: .. autoclass:: tinyrpc.protocols.jsonrpc.JSONRPCInvalidRequestError :members: :show-inheritance: :member-order: bysource :noindex: .. autoclass:: tinyrpc.protocols.jsonrpc.JSONRPCMethodNotFoundError :members: :show-inheritance: :member-order: bysource :noindex: .. autoclass:: tinyrpc.protocols.jsonrpc.JSONRPCInvalidParamsError :members: :show-inheritance: :member-order: bysource :noindex: .. autoclass:: tinyrpc.protocols.jsonrpc.JSONRPCInternalError :members: :show-inheritance: :member-order: bysource :noindex: .. autoclass:: tinyrpc.protocols.jsonrpc.JSONRPCServerError :members: :show-inheritance: :member-order: bysource :noindex: This last exception is a client side exception designed to represent the server side error in the client. .. autoclass:: tinyrpc.protocols.jsonrpc.JSONRPCError :members: :show-inheritance: :member-order: bysource :noindex: MSGPACK-RPC ^^^^^^^^^^^ .. autoclass:: tinyrpc.protocols.msgpackrpc.MSGPACKRPCParseError :members: :show-inheritance: :member-order: bysource :noindex: .. autoclass:: tinyrpc.protocols.msgpackrpc.MSGPACKRPCInvalidRequestError :members: :show-inheritance: :member-order: bysource :noindex: .. autoclass:: tinyrpc.protocols.msgpackrpc.MSGPACKRPCMethodNotFoundError :members: :show-inheritance: :member-order: bysource :noindex: .. autoclass:: tinyrpc.protocols.msgpackrpc.MSGPACKRPCInvalidParamsError :members: :show-inheritance: :member-order: bysource :noindex: .. autoclass:: tinyrpc.protocols.msgpackrpc.MSGPACKRPCInternalError :members: :show-inheritance: :member-order: bysource :noindex: .. autoclass:: tinyrpc.protocols.msgpackrpc.MSGPACKRPCServerError :members: :show-inheritance: :member-order: bysource :noindex: This last exception is a client side exception designed to represent the server side error in the client. .. autoclass:: tinyrpc.protocols.msgpackrpc.MSGPACKRPCError :members: :show-inheritance: :member-order: bysource :noindex: ================================================ FILE: docs/index.rst ================================================ tinyrpc: A modular RPC library ============================== ``tinyrpc`` is a framework for constructing remote procedure call (RPC) services in Python. In ``tinyrpc`` all components (transport, protocol and dispatcher) that together make an RPC service are independently replacable. Although its initial scope is handling jsonrpc_ it is easy to add further protocols or add additional transports (one such example is msgpackrpc_, which is now fully supported). If so desired it is even possible to replace the default method dispatcher. Table of contents ----------------- .. toctree:: :maxdepth: 2 examples structure dispatch protocols jsonrpc msgpackrpc transports client server exceptions Installation ------------ .. code-block:: sh pip install tinyrpc will install ``tinyrpc`` with its default dependencies. Optional dependencies +++++++++++++++++++++ Depending on the protocols and transports you want to use additional dependencies are required. You can instruct pip to install these dependencies by specifying extras to the basic install command. .. code-block:: sh pip install tinyrpc[httpclient, wsgi] will install ``tinyrpc`` with dependencies for the httpclient and wsgi transports. Available extras are: +------------+-------------------------------------------------------+ | Option | Needed to use objects of class | +============+=======================================================+ | gevent | optional in RPCClient, required by RPCServerGreenlets | +------------+-------------------------------------------------------+ | httpclient | HttpPostClientTransport, HttpWebSocketClientTransport | +------------+-------------------------------------------------------+ | jsonext | optional in JSONRPCProtocol | +------------+-------------------------------------------------------+ | msgpack | required by MSGPACKRPCProtocol | +------------+-------------------------------------------------------+ | rabbitmq | RabbitMQServerTransport, RabbitMQClientTransport | +------------+-------------------------------------------------------+ | websocket | WSServerTransport, HttpWebSocketClientTransport | +------------+-------------------------------------------------------+ | wsgi | WsgiServerTransport | +------------+-------------------------------------------------------+ | zmq | ZmqServerTransport, ZmqClientTransport | +------------+-------------------------------------------------------+ People ------ Creator +++++++ - Marc Brinkmann: https://github.com/mbr As of this writing (in Jan 2013) there are a few jsonrpc_ libraries already out there on PyPI_, most of them handling one specific use case (e.g. json via WSGI, using Twisted, or TCP-sockets). None of the libraries, however, made it easy to reuse the jsonrpc_-parsing bits and substitute a different transport (i.e. going from json_ via TCP_ to an implementation using WebSockets_ or 0mq_). In the end, all these libraries have their own dispatching interfaces and a custom implementation of handling jsonrpc_. ``tinyrpc`` aims to do better by dividing the problem into cleanly interchangeable parts that allow easy addition of new transport methods, RPC protocols or dispatchers. Maintainer ++++++++++ - Leo Noordergraaf: https://github.com/lnoor Looking for a Python jsonrpc_ library I found ``tinyrpc``. I was immediately taken by its modular concept and construction. After creating a couple transports and trying to get them integrated in tinyrpc, I learned that Marc got involved with other projects and that maintaining ``tinyrpc`` became too much a burden. I then volunteered to become its maintainer. .. _jsonrpc: http://jsonrpc.org .. _msgpackrpc: https://github.com/msgpack-rpc/msgpack-rpc/blob/master/spec.md .. _PyPI: http://pypi.python.org .. _json: http://www.json.org/ .. _TCP: http://en.wikipedia.org/wiki/Transmission_Control_Protocol .. _WebSockets: http://en.wikipedia.org/wiki/WebSocket .. _0mq: http://www.zeromq.org/ ================================================ FILE: docs/jsonrpc.rst ================================================ The JSON-RPC protocol ===================== Example ------- The following example shows how to use the :py:class:`~tinyrpc.protocols.jsonrpc.JSONRPCProtocol` class in a custom application, without using any other components: Server ++++++ .. code-block:: python from tinyrpc.protocols.jsonrpc import JSONRPCProtocol from tinyrpc import BadRequestError, RPCBatchRequest rpc = JSONRPCProtocol() # the code below is valid for all protocols, not just JSONRPC: def handle_incoming_message(self, data): try: request = rpc.parse_request(data) except BadRequestError as e: # request was invalid, directly create response response = e.error_respond(e) else: # we got a valid request # the handle_request function is user-defined # and returns some form of response if hasattr(request, create_batch_response): response = request.create_batch_response( handle_request(req) for req in request ) else: response = handle_request(request) # now send the response to the client if response != None: send_to_client(response.serialize()) def handle_request(request): try: # do magic with method, args, kwargs... return request.respond(result) except Exception as e: # for example, a method wasn't found return request.error_respond(e) Client ++++++ .. code-block:: python from tinyrpc.protocols.jsonrpc import JSONRPCProtocol rpc = JSONRPCProtocol() # again, code below is protocol-independent # assuming you want to call method(*args, **kwargs) request = rpc.create_request(method, args, kwargs) reply = send_to_server_and_get_reply(request) response = rpc.parse_reply(reply) if hasattr(response, 'error'): # error handling... else: # the return value is found in response.result do_something_with(response.result) Another example, this time using batch requests: .. code-block:: python # or using batch requests: requests = rpc.create_batch_request([ rpc.create_request(method_1, args_1, kwargs_1) rpc.create_request(method_2, args_2, kwargs_2) # ... ]) reply = send_to_server_and_get_reply(request) responses = rpc.parse_reply(reply) for responses in response: if hasattr(reponse, 'error'): # ... Finally, one-way requests are requests where the client does not expect an answer: .. code-block:: python request = rpc.create_request(method, args, kwargs, one_way=True) send_to_server(request) # done Protocol implementation ----------------------- API Reference +++++++++++++ .. autoclass:: tinyrpc.protocols.jsonrpc.JSONRPCProtocol :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.protocols.jsonrpc.JSONRPCRequest :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.protocols.jsonrpc.JSONRPCSuccessResponse :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.protocols.jsonrpc.JSONRPCErrorResponse :members: :show-inheritance: :member-order: bysource Batch protocol -------------- API Reference +++++++++++++ .. autoclass:: tinyrpc.protocols.jsonrpc.JSONRPCBatchRequest :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.protocols.jsonrpc.JSONRPCBatchResponse :members: :show-inheritance: :member-order: bysource Errors and error handling ------------------------- API Reference +++++++++++++ .. autoclass:: tinyrpc.protocols.jsonrpc.FixedErrorMessageMixin :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.protocols.jsonrpc.JSONRPCParseError :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.protocols.jsonrpc.JSONRPCInvalidRequestError :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.protocols.jsonrpc.JSONRPCMethodNotFoundError :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.protocols.jsonrpc.JSONRPCInvalidParamsError :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.protocols.jsonrpc.JSONRPCInternalError :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.protocols.jsonrpc.JSONRPCServerError :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.protocols.jsonrpc.JSONRPCError :members: :show-inheritance: :member-order: bysource Adding custom exceptions ------------------------ .. note:: As per the specification_ you should use error codes -32000 to -32099 when adding server specific error messages. Error codes outside the range -32768 to -32000 are available for application specific error codes. To add custom errors you need to combine an :py:class:`Exception` subclass with the :py:class:`~tinyrpc.protocols.jsonrpc.FixedErrorMessageMixin` class to create your exception object which you can raise. So a version of the reverse string example that dislikes palindromes could look like: .. code-block:: python from tinyrpc.protocols.jsonrpc import FixedErrorMessageMixin, JSONRPCProtocol from tinyrpc.dispatch import RPCDispatcher dispatcher = RPCDispatcher() class PalindromeError(FixedErrorMessageMixin, Exception): jsonrpc_error_code = 99 message = "Ah, that's cheating!" @dispatcher.public def reverse_string(s): r = s[::-1] if r == s: raise PalindromeError() return r Error with data --------------- The specification_ states that the ``error`` element of a reply may contain an optional ``data`` property. This property is now available for your use. There are two ways that you can use to pass additional data with an :py:class:`Exception`. It depends whether your application generates regular exceptions or exceptions derived from :py:class:`~tinyrpc.protocols.jsonrpc.FixedErrorMessageMixin`. When using ordinary exceptions you normally pass a single parameter (an error message) to the :py:class:`Exception` constructor. By passing two parameters, the second parameter is assumed to be the data element. .. code-block:: python @public def fn(): raise Exception('error message', {'msg': 'structured data', 'lst': [1, 2, 3]}) This will produce the reply message:: { "jsonrpc": "2.0", "id": , "error": { "code": -32000, "message": "error message", "data": {"msg": "structured data", "lst": [1, 2, 3]} } } When using :py:class:`~tinyrpc.protocols.jsonrpc.FixedErrorMessageMixin` based exceptions the data is passed using a keyword parameter. .. code-block:: python class MyException(FixedErrorMessageMixin, Exception): jsonrcp_error_code = 99 message = 'standard message' @public def fn(): raise MyException(data={'msg': 'structured data', 'lst': [1, 2, 3]}) This will produce the reply message:: { "jsonrpc": "2.0", "id": , "error": { "code": 99, "message": "standard message", "data": {"msg": "structured data", "lst": [1, 2, 3]} } } .. _specification: http://www.jsonrpc.org/specification#error_object ================================================ FILE: docs/make.bat ================================================ @ECHO OFF REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set BUILDDIR=_build set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . set I18NSPHINXOPTS=%SPHINXOPTS% . if NOT "%PAPER%" == "" ( set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% ) if "%1" == "" goto help if "%1" == "help" ( :help echo.Please use `make ^` where ^ is one of echo. html to make standalone HTML files echo. dirhtml to make HTML files named index.html in directories echo. singlehtml to make a single large HTML file echo. pickle to make pickle files echo. json to make JSON files echo. htmlhelp to make HTML files and a HTML help project echo. qthelp to make HTML files and a qthelp project echo. devhelp to make HTML files and a Devhelp project echo. epub to make an epub echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter echo. text to make text files echo. man to make manual pages echo. texinfo to make Texinfo files echo. gettext to make PO message catalogs echo. changes to make an overview over all changed/added/deprecated items echo. linkcheck to check all external links for integrity echo. doctest to run all doctests embedded in the documentation if enabled goto end ) if "%1" == "clean" ( for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i del /q /s %BUILDDIR%\* goto end ) if "%1" == "html" ( %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/html. goto end ) if "%1" == "dirhtml" ( %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. goto end ) if "%1" == "singlehtml" ( %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. goto end ) if "%1" == "pickle" ( %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the pickle files. goto end ) if "%1" == "json" ( %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the JSON files. goto end ) if "%1" == "htmlhelp" ( %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run HTML Help Workshop with the ^ .hhp project file in %BUILDDIR%/htmlhelp. goto end ) if "%1" == "qthelp" ( %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run "qcollectiongenerator" with the ^ .qhcp project file in %BUILDDIR%/qthelp, like this: echo.^> qcollectiongenerator %BUILDDIR%\qthelp\tinyrpc.qhcp echo.To view the help file: echo.^> assistant -collectionFile %BUILDDIR%\qthelp\tinyrpc.ghc goto end ) if "%1" == "devhelp" ( %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp if errorlevel 1 exit /b 1 echo. echo.Build finished. goto end ) if "%1" == "epub" ( %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub if errorlevel 1 exit /b 1 echo. echo.Build finished. The epub file is in %BUILDDIR%/epub. goto end ) if "%1" == "latex" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex if errorlevel 1 exit /b 1 echo. echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. goto end ) if "%1" == "text" ( %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text if errorlevel 1 exit /b 1 echo. echo.Build finished. The text files are in %BUILDDIR%/text. goto end ) if "%1" == "man" ( %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man if errorlevel 1 exit /b 1 echo. echo.Build finished. The manual pages are in %BUILDDIR%/man. goto end ) if "%1" == "texinfo" ( %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo if errorlevel 1 exit /b 1 echo. echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. goto end ) if "%1" == "gettext" ( %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale if errorlevel 1 exit /b 1 echo. echo.Build finished. The message catalogs are in %BUILDDIR%/locale. goto end ) if "%1" == "changes" ( %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes if errorlevel 1 exit /b 1 echo. echo.The overview file is in %BUILDDIR%/changes. goto end ) if "%1" == "linkcheck" ( %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck if errorlevel 1 exit /b 1 echo. echo.Link check complete; look for any errors in the above output ^ or in %BUILDDIR%/linkcheck/output.txt. goto end ) if "%1" == "doctest" ( %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest if errorlevel 1 exit /b 1 echo. echo.Testing of doctests in the sources finished, look at the ^ results in %BUILDDIR%/doctest/output.txt. goto end ) :end ================================================ FILE: docs/msgpackrpc.rst ================================================ The MSGPACK-RPC protocol ======================== Example ------- The following example shows how to use the :py:class:`~tinyrpc.protocols.msgpackrpc.MSGPACKRPCProtocol` class in a custom application, without using any other components: Server ++++++ .. code-block:: python from tinyrpc.protocols.msgpackrpc import MSGPACKRPCProtocol from tinyrpc import BadRequestError, RPCRequest rpc = MSGPACKRPCProtocol() # the code below is valid for all protocols, not just MSGPACKRPCProtocol, # as long as you don't need to handle batch RPC requests: def handle_incoming_message(self, data): try: request = rpc.parse_request(data) except BadRequestError as e: # request was invalid, directly create response response = e.error_respond(e) else: # we got a valid request # the handle_request function is user-defined # and returns some form of response response = handle_request(request) # now send the response to the client if response != None: send_to_client(response.serialize()) def handle_request(request): try: # do magic with method, args, kwargs... return request.respond(result) except Exception as e: # for example, a method wasn't found return request.error_respond(e) Client ++++++ .. code-block:: python from tinyrpc.protocols.msgpackrpc import MSGPACKRPCProtocol rpc = MSGPACKRPCProtocol() # again, code below is protocol-independent # assuming you want to call method(*args, **kwargs) request = rpc.create_request(method, args, kwargs) reply = send_to_server_and_get_reply(request) response = rpc.parse_reply(reply) if hasattr(response, 'error'): # error handling... else: # the return value is found in response.result do_something_with(response.result) Finally, one-way requests are requests where the client does not expect an answer: .. code-block:: python request = rpc.create_request(method, args, kwargs, one_way=True) send_to_server(request) # done Protocol implementation ----------------------- API Reference +++++++++++++ .. autoclass:: tinyrpc.protocols.msgpackrpc.MSGPACKRPCProtocol :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.protocols.msgpackrpc.MSGPACKRPCRequest :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.protocols.msgpackrpc.MSGPACKRPCSuccessResponse :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.protocols.msgpackrpc.MSGPACKRPCErrorResponse :members: :show-inheritance: :member-order: bysource Errors and error handling ------------------------- API Reference +++++++++++++ .. autoclass:: tinyrpc.protocols.msgpackrpc.FixedErrorMessageMixin :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.protocols.msgpackrpc.MSGPACKRPCParseError :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.protocols.msgpackrpc.MSGPACKRPCInvalidRequestError :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.protocols.msgpackrpc.MSGPACKRPCMethodNotFoundError :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.protocols.msgpackrpc.MSGPACKRPCInvalidParamsError :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.protocols.msgpackrpc.MSGPACKRPCInternalError :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.protocols.msgpackrpc.MSGPACKRPCServerError :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.protocols.msgpackrpc.MSGPACKRPCError :members: :show-inheritance: :member-order: bysource Adding custom exceptions ------------------------ .. note:: Unlike JSON-RPC, the MSGPACK-RPC specification does not specify how the error messages should look like; the protocol allows any arbitrary MSGPACK object as an error object. For sake of compatibility with JSON-RPC, this implementation uses MSGPACK lists of length 2 (consisting of a numeric error code and an error description) to represent errors in the serialized representation. These are transparently decoded into :py:class:`~tinyrpc.protocols.msgpackrpc.MSGPACKRPCError` instances as needed. The error codes for parsing errors, invalid requests, unknown RPC methods and so on match those from the `JSON-RPC specification`_. To add custom errors you need to combine an :py:class:`Exception` subclass with the :py:class:`~tinyrpc.protocols.msgpackrpc.FixedErrorMessageMixin` class to create your exception object which you can raise. So a version of the reverse string example that dislikes palindromes could look like: .. code-block:: python from tinyrpc.protocols.msgpackrpc import FixedErrorMessageMixin, MSGPACKRPCProtocol from tinyrpc.dispatch import RPCDispatcher dispatcher = RPCDispatcher() class PalindromeError(FixedErrorMessageMixin, Exception): msgpackrpc_error_code = 99 message = "Ah, that's cheating!" @dispatcher.public def reverse_string(s): r = s[::-1] if r == s: raise PalindromeError() return r .. _specification: https://github.com/msgpack-rpc/msgpack-rpc/blob/master/spec.md .. _JSON-RPC specification: http://www.jsonrpc.org/specification#error_object ================================================ FILE: docs/protocols.rst ================================================ The protocol layer ================== Interface definition -------------------- All protocols are implemented by deriving from :py:class:`~tinyrpc.protocols.RPCProtocol` and implementing all of its members. Every protocol deals with multiple kinds of structures: ``data`` arguments are always byte strings, either messages or replies, that are sent via or received from a transport. Protocol-specific subclasses of :py:class:`~tinyrpc.protocols.RPCRequest` and :py:class:`~tinyrpc.protocols.RPCResponse` represent well-formed requests and responses. Protocol specific subclasses of :py:class:`~tinyrpc.protocols.RPCErrorResponse` represent errors and error responses. Finally, if an error occurs during parsing of a request, a :py:class:`~tinyrpc.exc.BadRequestError` instance must be thrown. These need to be subclassed for each protocol as well, since they generate error replies. API Reference +++++++++++++ .. autoclass:: tinyrpc.protocols.RPCProtocol :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.protocols.RPCRequest :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.protocols.RPCResponse :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.protocols.RPCErrorResponse :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.exc.BadRequestError :members: :show-inheritance: :member-order: bysource Batch protocols --------------- Some protocols may support batch requests. In this case, they need to derive from :py:class:`~tinyrpc.protocols.RPCBatchProtocol`. Batch protocols differ in that their :py:func:`~tinyrpc.protocols.RPCProtocol.parse_request` method may return an instance of :py:class:`~tinyrpc.protocols.RPCBatchRequest`. They also possess an addional method in :py:func:`~tinyrpc.protocols.RPCBatchProtocol.create_batch_request`. Handling a batch request is slightly different, while it supports :py:func:`~tinyrpc.protocols.RPCBatchRequest.error_respond`, to make actual responses, :py:func:`~tinyrpc.protocols.RPCBatchRequest.create_batch_response` needs to be used. No assumptions are made whether or not it is okay for batch requests to be handled in parallel. This is up to the server/dispatch implementation, which must be chosen appropriately. API Reference +++++++++++++ .. autoclass:: tinyrpc.protocols.RPCBatchProtocol :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.protocols.RPCBatchRequest :members: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.protocols.RPCBatchResponse :members: :show-inheritance: :member-order: bysource ID Generators ------------- By default, the :py:class:`~tinyrpc.protocols.jsonrpc.JSONRPCProtocol` and :py:class:`~tinyrpc.protocols.msgpackrpc.MSGPACKRPCProtocol` classes generates ids as sequential integers starting at 1. If alternative id generation is needed, you may supply your own generator. Example ------- The following example shows how to use alternative id generators in a protocol that supports them. .. code-block:: python from tinyrpc.protocols.jsonrpc import JSONRPCProtocol def collatz_generator(): """A sample generator for demonstration purposes ONLY.""" n = 27 while True: if n % 2 != 0: n = 3*n + 1 else: n = n / 2 yield n rpc = JSONRPCProtocol(id_generator=collatz_generator()) Supported protocols ------------------- Any supported protocol is used by instantiating its class and calling the interface of :py:class:`~tinyrpc.protocols.RPCProtocol`. Note that constructors are not part of the interface, any protocol may have specific arguments for its instances. Protocols usually live in their own module because they may need to import optional modules that needn't be a dependency for all of ``tinyrpc``. .. _jsonrpc: http://jsonrpc.org ================================================ FILE: docs/server.rst ================================================ Server implementations ====================== Like :doc:`client`, servers are top-level instances that most user code should interact with. They provide runnable functions that are combined with transports, protocols and dispatchers to form a complete RPC system. .. automodule:: tinyrpc.server :members: :noindex: .. py:class:: tinyrpc.server.gevent.RPCServerGreenlets Asynchronous RPCServer. This implementation of :py:class:`~tinyrpc.server.RPCServer` uses :py:func:`gevent.spawn` to spawn new client handlers, result in asynchronous handling of clients using greenlets. ================================================ FILE: docs/structure.rst ================================================ Structure of tinyrpc ==================== Architecture ------------ ``tinyrpc`` is constructed around the :py:class:`~tinyrpc.server.RPCServer` and :py:class:`~tinyrpc.client.RPCClient` classes. They in turn depend on the :py:class:`~tinyrpc.dispatch.RPCDispatcher`, :py:class:`~tinyrpc.protocols.RPCProtocol`, :py:class:`~tinyrpc.transports.ServerTransport` and :py:class:`~tinyrpc.transports.ClientTransport` classes as visualized in the image below. .. image:: _static/uml.png Of these :py:class:`~tinyrpc.protocols.RPCProtocol`, :py:class:`~tinyrpc.transports.ServerTransport` and :py:class:`~tinyrpc.transports.ClientTransport` are abstract base classes. Each layer is useful "on its own" and can be used separately. If you just need to decode a jsonrpc_ message, without passing it on or sending it through a transport, the :py:class:`~tinyrpc.protocols.jsonrpc.JSONRPCProtocol`-class is completely usable on its own. Likewise the :py:class:`~tinyrpc.dispatch.RPCDispatcher` could be used to dispatch calls in a commandline REPL like application. Transport --------- The transport classes are responsible for receiving and sending messages. No assumptions are made about messages, except that they are of a fixed size. Messages are received and possibly passed on as Python :py:class:`bytes` objects. In an RPC context, messages coming in (containing requests) are simply called messages, a message sent in reply is called a reply. Replies are always serialized responses. Protocol -------- The protocol class(es) are responsible for two tasks: * they implement the protocol, defining how method names, method parameters and errors are represented in requests and responses. * they serialize the requests and responses into messages and deserialize messages back into requests and responses. Dispatcher ---------- :doc:`dispatch` performs the actual method calling determining with method to call and how to pass it the parameters. The result of the method call, or the exception if the call failed is assembled and made available to the protocol for serialization. Client and Server ----------------- The client and server classes tie all components together to provide the application interface. .. _jsonrpc: http://jsonrpc.org ================================================ FILE: docs/transports.rst ================================================ Transports ========== Transports are somewhat low level interface concerned with transporting messages across through different means. "Messages" in this case are simple strings. All transports need to support two different interfaces: .. autoclass:: tinyrpc.transports.ServerTransport :members: :noindex: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.transports.ClientTransport :members: :noindex: :show-inheritance: :member-order: bysource Note that these transports are of relevance when using ``tinyrpc``-built in facilities. They can be coopted for any other purpose, if you simply need reliable server-client message passing as well. Also note that the client transport interface is not designed for asynchronous use. For simple use cases (sending multiple concurrent requests) monkey patching with gevent may get the job done. Transport implementations ------------------------- A few transport implementations are included with ``tinyrpc``: 0mq ~~~ Based on :py:mod:`zmq`, supports 0mq based sockets. Highly recommended: .. autoclass:: tinyrpc.transports.zmq.ZmqServerTransport :members: :noindex: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.transports.zmq.ZmqClientTransport :members: :noindex: :show-inheritance: :member-order: bysource HTTP ~~~~ There is only an HTTP client, no server (use WSGI instead). .. autoclass:: tinyrpc.transports.http.HttpPostClientTransport :members: :noindex: :show-inheritance: :member-order: bysource .. note:: To set a timeout on your client transport provide a ``timeout`` keyword parameter like:: transport = HttpPostClientTransport(endpoint, timeout=0.1) It will result in a ``requests.exceptions.Timeout`` exception when a timeout occurs. WSGI ~~~~ .. autoclass:: tinyrpc.transports.wsgi.WsgiServerTransport :members: :noindex: :show-inheritance: :member-order: bysource CGI ~~~ .. autoclass:: tinyrpc.transports.cgi.CGIServerTransport :members: :noindex: :show-inheritance: :member-order: bysource Callback ~~~~~~~~ .. autoclass:: tinyrpc.transports.callback.CallbackServerTransport :members: :noindex: :show-inheritance: :member-order: bysource RabbitMQ ~~~~~~~~ .. autoclass:: tinyrpc.transports.rabbitmq.RabbitMQServerTransport :members: :noindex: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.transports.rabbitmq.RabbitMQClientTransport :members: :noindex: :show-inheritance: :member-order: bysource WebSocket ~~~~~~~~~ .. autoclass:: tinyrpc.transports.websocket.WSServerTransport :members: :noindex: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.transports.websocket.WSApplication :members: :noindex: :show-inheritance: :member-order: bysource .. autoclass:: tinyrpc.transports.websocketclient.HttpWebSocketClientTransport :members: :noindex: :show-inheritance: :member-order: bysource ================================================ FILE: examples/http_client_example.py ================================================ #!/usr/bin/env python3 from tinyrpc.protocols.jsonrpc import JSONRPCProtocol from tinyrpc.transports.http import HttpPostClientTransport from tinyrpc import RPCClient rpc_client = RPCClient( JSONRPCProtocol(), HttpPostClientTransport('http://127.0.0.1:5000/') ) remote_server = rpc_client.get_proxy() # call a method called 'reverse_string' with a single string argument result = remote_server.reverse_string('Hello, World!') print("Server answered:", result) ================================================ FILE: examples/http_server_example.py ================================================ #!/usr/bin/env python3 import gevent import gevent.wsgi import gevent.queue from tinyrpc.protocols.jsonrpc import JSONRPCProtocol from tinyrpc.transports.wsgi import WsgiServerTransport from tinyrpc.server.gevent import RPCServerGreenlets from tinyrpc.dispatch import RPCDispatcher dispatcher = RPCDispatcher() transport = WsgiServerTransport(queue_class=gevent.queue.Queue) # start wsgi server as a background-greenlet wsgi_server = gevent.wsgi.WSGIServer(('127.0.0.1', 5000), transport.handle) gevent.spawn(wsgi_server.serve_forever) rpc_server = RPCServerGreenlets( transport, JSONRPCProtocol(), dispatcher ) @dispatcher.public def reverse_string(s): return s[::-1] # in the main greenlet, run our rpc_server rpc_server.serve_forever() ================================================ FILE: examples/zmq_client_example.py ================================================ #!/usr/bin/env python3 import zmq from tinyrpc.protocols.jsonrpc import JSONRPCProtocol from tinyrpc.transports.zmq import ZmqClientTransport from tinyrpc import RPCClient ctx = zmq.Context() rpc_client = RPCClient( JSONRPCProtocol(), ZmqClientTransport.create(ctx, 'tcp://127.0.0.1:5001') ) remote_server = rpc_client.get_proxy() # call a method called 'reverse_string' with a single string argument result = remote_server.reverse_string('Hello, World!') print("Server answered:", result) ================================================ FILE: examples/zmq_server_example.py ================================================ #!/usr/bin/env python3 import zmq from tinyrpc.protocols.jsonrpc import JSONRPCProtocol from tinyrpc.transports.zmq import ZmqServerTransport from tinyrpc.server import RPCServer from tinyrpc.dispatch import RPCDispatcher ctx = zmq.Context() dispatcher = RPCDispatcher() transport = ZmqServerTransport.create(ctx, 'tcp://127.0.0.1:5001') rpc_server = RPCServer( transport, JSONRPCProtocol(), dispatcher ) @dispatcher.public def reverse_string(s): return s[::-1] rpc_server.serve_forever() ================================================ FILE: optional_features.pip ================================================ requests werkzeug gevent pyzmq websocket-client gevent-websocket pyzmq jsonext msgpack pika ================================================ FILE: pyproject.toml ================================================ [build-system] requires = ["setuptools", "wheel"] build-backend = "setuptools.build_meta" ================================================ FILE: requirements.txt ================================================ gevent==22.10.2 gevent-websocket==0.10.1 msgpack==1.0.2 pika==1.2.0 pytest==6.2.4 pytest-cov==2.11.1 pyzmq==23.2.1 requests==2.31.0 Werkzeug==2.2.3 ================================================ FILE: setup.py ================================================ import os from setuptools import setup, find_packages def read(fname): return open(os.path.join(os.path.dirname(__file__), fname)).read() setup( name='tinyrpc', version='1.1.7', description='A small, modular, transport and protocol neutral RPC ' 'library that, among other things, supports JSON-RPC and zmq.', long_description=read('README.rst'), long_description_content_type="text/x-rst", packages=find_packages(exclude=['examples']), keywords='json rpc json-rpc jsonrpc 0mq zmq zeromq', author='Marc Brinkmann', author_email='git@marcbrinkmann.de', maintainer='Leo Noordergraaf', maintainer_email='leo@noordergraaf.net', url='http://github.com/mbr/tinyrpc', license='MIT', extras_require={ 'gevent': ['gevent'], 'httpclient': ['requests', 'websocket-client', 'gevent-websocket'], 'msgpack': ['msgpack'], 'websocket': ['gevent-websocket'], 'wsgi': ['werkzeug'], 'zmq': ['pyzmq'], 'jsonext': ['jsonext'], 'rabbitmq': ['pika'] } ) ================================================ FILE: tests/__init__.py ================================================ ================================================ FILE: tests/test_client.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- import pytest from unittest.mock import Mock from tinyrpc.exc import RPCError from tinyrpc.client import RPCClient, RPCProxy from tinyrpc.protocols import RPCProtocol, RPCResponse, RPCErrorResponse, RPCRequest from tinyrpc.transports import ClientTransport @pytest.fixture(params=['test_method1', 'method2', 'CamelCasedMethod']) def method_name(request): return request.param @pytest.fixture(params=[(), ('foo', None, 42), (1, )]) def method_args(request): return request.param @pytest.fixture( params=[(), (('foo', 'bar'), ('x', None), ('y', 42)), (('q', 1), )] ) def method_kwargs(request): return dict(request.param or {}) @pytest.fixture(params=['', 'NoDot', 'dot.']) def prefix(request): return request.param @pytest.fixture(params=[True, False]) def one_way_setting(request): return request.param @pytest.fixture def mock_client(): return Mock(RPCClient) @pytest.fixture def mock_protocol(): mproto = Mock(RPCProtocol) foo = Mock(RPCResponse) foo.result = None mproto.parse_reply = Mock(return_value=foo) return mproto @pytest.fixture def mock_transport(): return Mock(ClientTransport) @pytest.fixture() def client(mock_protocol, mock_transport): return RPCClient(mock_protocol, mock_transport) @pytest.fixture def m_proxy(mock_client, prefix, one_way_setting): return RPCProxy(mock_client, prefix, one_way_setting) def test_proxy_calls_correct_method( m_proxy, mock_client, prefix, method_kwargs, method_args, method_name, one_way_setting ): getattr(m_proxy, method_name)(*method_args, **method_kwargs) mock_client.call.assert_called_with( prefix + method_name, method_args, method_kwargs, one_way=one_way_setting ) def test_client_uses_correct_protocol( client, mock_protocol, method_name, method_args, method_kwargs, one_way_setting ): client.call(method_name, method_args, method_kwargs, one_way_setting) assert mock_protocol.create_request.called def test_client_uses_correct_transport( client, mock_protocol, method_name, method_args, method_kwargs, one_way_setting, mock_transport ): client.call(method_name, method_args, method_kwargs, one_way_setting) assert mock_transport.send_message.called def test_client_passes_correct_reply( client, mock_protocol, method_name, method_args, method_kwargs, one_way_setting, mock_transport ): transport_return = '023hoisdfh' mock_transport.send_message = Mock(return_value=transport_return) client.call(method_name, method_args, method_kwargs, one_way_setting) if one_way_setting: mock_protocol.parse_reply.assert_not_called() else: mock_protocol.parse_reply.assert_called_with(transport_return) def test_client_raises_error_replies( client, mock_protocol, method_name, method_args, method_kwargs, one_way_setting ): error_response = RPCErrorResponse() error_response.error = 'foo' mock_protocol.parse_reply = Mock(return_value=error_response) if not one_way_setting: client.call(method_name, method_args, method_kwargs, one_way_setting) assert mock_protocol.raise_error.call_args is not None args, kwargs = mock_protocol.raise_error.call_args assert isinstance(args[0], RPCErrorResponse) assert args[0].error == 'foo' print(mock_protocol.mock_calls) mock_protocol.raise_error.assert_called_with(error_response) def test_client_raises_indirect_error_replies( client, mock_protocol, method_name, method_args, method_kwargs, one_way_setting ): class MockException(Exception): pass def raise_error(error): raise MockException(error) error_response = RPCErrorResponse() error_response.error = 'foo' mock_protocol.parse_reply = Mock(return_value=error_response) mock_protocol.raise_error = raise_error if not one_way_setting: with pytest.raises(MockException): client.call( method_name, method_args, method_kwargs, one_way_setting ) def test_client_produces_good_proxy(client, prefix, one_way_setting): proxy = client.get_proxy(prefix, one_way_setting) assert proxy.client == client assert proxy.prefix == prefix assert proxy.one_way == one_way_setting assert callable(proxy.foobar) @pytest.mark.skip( 'no longer performs automatic conversion, serialize() always returns bytes' ) def test_client_send_binary_message( client, mock_protocol, method_name, method_args, method_kwargs, one_way_setting, mock_transport ): req = Mock(RPCRequest) req.serialize.return_value = u'unicode not acceptable' mock_protocol.create_request.return_value = req client.call(method_name, method_args, method_kwargs, one_way_setting) assert mock_transport.send_message.called assert isinstance(mock_transport.send_message.call_args[0][0], bytes) ================================================ FILE: tests/test_dispatch.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- from unittest.mock import Mock import pytest import inspect from tinyrpc.dispatch import RPCDispatcher, public from tinyrpc import RPCRequest, RPCBatchRequest, RPCBatchResponse from tinyrpc.protocols.jsonrpc import JSONRPCProtocol, JSONRPCInvalidParamsError from tinyrpc.exc import * @pytest.fixture def dispatch(): return RPCDispatcher() @pytest.fixture() def subdispatch(): return RPCDispatcher() def mock_request(method='subtract', args=None, kwargs=None): mock_request = Mock(RPCRequest) mock_request.method = method mock_request.args = args or [4, 6] mock_request.kwargs = kwargs or {} return mock_request @pytest.fixture(name="mock_request") def mock_request_fixture(): return mock_request() def test_function_decorating_without_paramters(dispatch): @dispatch.public def foo(bar): pass assert dispatch.get_method('foo') == foo def test_function_decorating_with_empty_paramters(dispatch): @dispatch.public() def foo(bar): pass assert dispatch.get_method('foo') == foo def test_function_decorating_with_paramters(dispatch): @dispatch.public(name='baz') def foo(bar): pass with pytest.raises(MethodNotFoundError): dispatch.get_method('foo') assert dispatch.get_method('baz') == foo def test_subdispatchers(dispatch, subdispatch): @dispatch.public() def foo(bar): pass @subdispatch.public(name='foo') def subfoo(bar): pass dispatch.add_subdispatch(subdispatch, 'sub.') assert dispatch.get_method('foo') == foo assert dispatch.get_method('sub.foo') == subfoo def test_object_method_marking(): class Foo(object): def foo1(self): pass @public def foo2(self): pass @public(name='baz') def foo3(self): pass f = Foo() assert not hasattr(f.foo1, '_rpc_public_name') assert f.foo2._rpc_public_name == 'foo2' assert f.foo3._rpc_public_name == 'baz' def test_object_method_register(dispatch): class Foo(object): def foo1(self): pass @public def foo2(self): pass @public(name='baz') def foo3(self): pass f = Foo() dispatch.register_instance(f) with pytest.raises(MethodNotFoundError): assert dispatch.get_method('foo1') assert dispatch.get_method('foo2') == f.foo2 assert dispatch.get_method('baz') == f.foo3 def test_object_method_register_with_prefix(dispatch): class Foo(object): def foo1(self): pass @public def foo2(self): pass @public(name='baz') def foo3(self): pass f = Foo() dispatch.register_instance(f, 'myprefix') with pytest.raises(MethodNotFoundError): assert dispatch.get_method('foo1') with pytest.raises(MethodNotFoundError): assert dispatch.get_method('myprefixfoo1') with pytest.raises(MethodNotFoundError): assert dispatch.get_method('foo2') with pytest.raises(MethodNotFoundError): assert dispatch.get_method('foo3') assert dispatch.get_method('myprefixfoo2') == f.foo2 assert dispatch.get_method('myprefixbaz') == f.foo3 def test_dispatch_calls_method_and_responds(dispatch, mock_request): m = Mock() m.subtract = Mock(return_value=-2) dispatch.add_method(m.subtract, 'subtract') response = dispatch.dispatch(mock_request) assert m.subtract.called mock_request.respond.assert_called_with(-2) def test_dispatch_handles_in_function_exceptions(dispatch, mock_request): m = Mock() m.subtract = Mock(return_value=-2) class MockError(Exception): pass m.subtract.side_effect = MockError('mock error') dispatch.add_method(m.subtract, 'subtract') response = dispatch.dispatch(mock_request) assert m.subtract.called mock_request.error_respond.assert_called_with(m.subtract.side_effect) def test_batch_dispatch(dispatch): method1 = Mock(return_value='rv1') method2 = Mock(return_value=None) dispatch.add_method(method1, 'method1') dispatch.add_method(method2, 'method2') batch_request = RPCBatchRequest() batch_request.error_respond = Mock(return_value='ERROR') batch_request.append(mock_request('method1', args=[1,2])) batch_request.append(mock_request('non_existant_method', args=[5,6])) batch_request.append(mock_request('method2', args=[3,4])) batch_request.create_batch_response = lambda: RPCBatchResponse() assert batch_request.error_respond.call_count == 0 response = dispatch.dispatch(batch_request) # assert all methods are called method1.assert_called_with(1, 2) method2.assert_called_with(3, 4) # FIXME: could use better checking? def test_dispatch_raises_key_error(dispatch): with pytest.raises(MethodNotFoundError): dispatch.get_method('foo') @pytest.fixture(params=[ ('fn_a', [4, 6], {}, -2), ('fn_a', [4], {}, InvalidParamsError), # InvalidParamsError instead of JSONRPCInvalidParamsError due to mocking ('fn_a', [], {'a':4, 'b':6}, -2), ('fn_a', [4], {'b':6}, -2), ('fn_b', [4, 6], {}, -2), ('fn_b', [], {'a':4, 'b':6}, InvalidParamsError), ('fn_b', [4], {}, IndexError), # a[1] doesn't exist, can't be detected beforehand ('fn_c', [4, 6], {}, InvalidParamsError), ('fn_c', [], {'a':4, 'b':6}, -2), ('fn_c', [], {'a':4}, KeyError) # a['b'] doesn't exist, can't be detected beforehand ]) def invoke_with(request): return request.param def test_argument_error(dispatch, invoke_with): method, args, kwargs, result = invoke_with protocol = JSONRPCProtocol() @dispatch.public def fn_a(a, b): return a-b @dispatch.public def fn_b(*a): return a[0]-a[1] @dispatch.public def fn_c(**a): return a['a']-a['b'] mock_request = Mock(RPCRequest) mock_request.args = args mock_request.kwargs = kwargs mock_request.method = method dispatch._dispatch(mock_request, getattr(protocol, '_caller', None)) if inspect.isclass(result) and issubclass(result, Exception): assert type(mock_request.error_respond.call_args[0][0]) == result else: mock_request.respond.assert_called_with(result) def test_call_argument_validation(dispatch): def f(a,b): return a+b dispatch.validate_parameters(f, [1, 2], {}) with pytest.raises(InvalidParamsError): dispatch.validate_parameters(f, [1], {}) dispatch.validate_parameters(dir, [], {}) # should skip validation, will produce error otherwise def test_bound_method_argument_error(dispatch, invoke_with): method, args, kwargs, result = invoke_with protocol = JSONRPCProtocol() class Test: c = 0 @public def fn_a(self, a, b): return a-b+self.c @public def fn_b(self, *a): return a[0]-a[1]+self.c @public def fn_c(self, **a): return a['a']-a['b']+self.c test=Test() dispatch.register_instance(test) mock_request = Mock(RPCRequest) mock_request.args = args mock_request.kwargs = kwargs mock_request.method = method dispatch._dispatch(mock_request, getattr(protocol, '_caller', None)) if inspect.isclass(result) and issubclass(result, Exception): assert type(mock_request.error_respond.call_args[0][0]) == result else: mock_request.respond.assert_called_with(result) def test_bound_method_validation(dispatch): class Test: def f(self, a, b): return a+b inst = Test() dispatch.validate_parameters(inst.f, [1, 2], {}) with pytest.raises(InvalidParamsError): dispatch.validate_parameters(inst.f, [1], {}) def test_unbound_method_argument_error(dispatch, invoke_with): method, args, kwargs, result = invoke_with protocol = JSONRPCProtocol() class Test: c = 0 @public def fn_a(a, b): return a-b @public def fn_b(*a): return a[0]-a[1] @public def fn_c(**a): return a['a']-a['b'] dispatch.register_instance(Test) mock_request = Mock(RPCRequest) mock_request.args = args mock_request.kwargs = kwargs mock_request.method = method dispatch._dispatch(mock_request, getattr(protocol, '_caller', None)) if inspect.isclass(result) and issubclass(result, Exception): assert type(mock_request.error_respond.call_args[0][0]) == result else: mock_request.respond.assert_called_with(result) def test_unbound_method_validation(dispatch): class Test: def f(a, b): return a+b dispatch.validate_parameters(Test.f, [1, 2], {}) with pytest.raises(InvalidParamsError): dispatch.validate_parameters(Test.f, [1], {}) def test_static_method_argument_error(dispatch, invoke_with): method, args, kwargs, result = invoke_with protocol = JSONRPCProtocol() class Test: c = 0 @staticmethod @public def fn_a(a, b): return a-b @staticmethod @public def fn_b(*a): return a[0]-a[1] @staticmethod @public def fn_c(**a): return a['a']-a['b'] test=Test() dispatch.register_instance(test) mock_request = Mock(RPCRequest) mock_request.args = args mock_request.kwargs = kwargs mock_request.method = method dispatch._dispatch(mock_request, getattr(protocol, '_caller', None)) if inspect.isclass(result) and issubclass(result, Exception): assert type(mock_request.error_respond.call_args[0][0]) == result else: mock_request.respond.assert_called_with(result) def test_static_method_validation(dispatch): class Test: @staticmethod def f(a, b): return a+b inst = Test() dispatch.validate_parameters(inst.f, [1, 2], {}) with pytest.raises(InvalidParamsError): dispatch.validate_parameters(inst.f, [1], {}) def test_class_method_argument_error(dispatch, invoke_with): method, args, kwargs, result = invoke_with protocol = JSONRPCProtocol() class Test: c = 0 @classmethod @public def fn_a(cls, a, b): return a-b-cls.c @classmethod @public def fn_b(cls, *a): return a[0]-a[1]-cls.c @classmethod @public def fn_c(cls, **a): return a['a']-a['b']-cls.c test=Test() dispatch.register_instance(test) mock_request = Mock(RPCRequest) mock_request.args = args mock_request.kwargs = kwargs mock_request.method = method dispatch._dispatch(mock_request, getattr(protocol, '_caller', None)) if inspect.isclass(result) and issubclass(result, Exception): assert type(mock_request.error_respond.call_args[0][0]) == result else: mock_request.respond.assert_called_with(result) def test_class_method_validation(dispatch): class Test: @classmethod def f(cls, a, b): return a+b inst = Test() dispatch.validate_parameters(inst.f, [1, 2], {}) with pytest.raises(InvalidParamsError): dispatch.validate_parameters(inst.f, [1], {}) ================================================ FILE: tests/test_jsonrpc.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- import json import pytest from tinyrpc import MethodNotFoundError, InvalidRequestError, ServerError, \ RPCError, RPCResponse, InvalidReplyError from tinyrpc.protocols.jsonrpc import JSONRPCParseError, \ JSONRPCInvalidRequestError, \ JSONRPCMethodNotFoundError, \ JSONRPCInvalidParamsError, \ JSONRPCInternalError,\ JSONRPCErrorResponse def _json_equal(a, b): da = json.loads(a.decode() if isinstance(a, bytes) else a) db = json.loads(b.decode() if isinstance(b, bytes) else b) return da == db @pytest.fixture def prot(): from tinyrpc.protocols.jsonrpc import JSONRPCProtocol return JSONRPCProtocol() @pytest.mark.parametrize(('data', 'attrs'), [ # examples from the spec, parsing only ("""{"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1}""", {'method': 'subtract', 'args': [42, 23], 'unique_id': 1} ), ("""{"jsonrpc": "2.0", "method": "subtract", "params": [23, 42], "id": 2}""", {'method': 'subtract', 'args': [23, 42], 'unique_id': 2} ), ("""{"jsonrpc": "2.0", "method": "subtract", "params": {"subtrahend": 23, "minuend": 42}, "id": 3}""", {'method': 'subtract', 'kwargs': {'subtrahend': 23, 'minuend': 42}, 'unique_id': 3} ), ("""{"jsonrpc": "2.0", "method": "subtract", "params": {"minuend": 42, "subtrahend": 23}, "id": 4}""", {'method': 'subtract', 'kwargs': {'minuend': 42, 'subtrahend': 23}, 'unique_id': 4}, ), ("""{"jsonrpc": "2.0", "method": "update", "params": [1,2,3,4,5]}""", {'method': 'update', 'args': [1, 2, 3, 4, 5]} ), ("""{"jsonrpc": "2.0", "method": "foobar"}""", {'method': 'foobar'} ), ]) def test_parsing_good_request_samples(prot, data, attrs): req = prot.parse_request(data) for k, v in attrs.items(): assert getattr(req, k) == v @pytest.mark.parametrize('invalid_json', [ '{"jsonrpc": "2.0", "method": "foobar, "params": "bar", "baz]', 'garbage', ]) def test_parsing_invalid_json(prot, invalid_json): with pytest.raises(JSONRPCParseError): prot.parse_request(invalid_json) def test_parsing_invalid_arguments(prot): with pytest.raises(JSONRPCInvalidParamsError): prot.parse_request( """{"jsonrpc": "2.0", "method": "update", "params": 9}""" ) @pytest.mark.parametrize(('data', 'id', 'result'), [ ("""{"jsonrpc": "2.0", "result": 19, "id": 1}""", 1, 19, ), ("""{"jsonrpc": "2.0", "result": -19, "id": 2}""", 2, -19, ), ("""{"jsonrpc": "2.0", "result": 19, "id": 3}""", 3, 19, ), ("""{"jsonrpc": "2.0", "result": 19, "id": 4}""", 4, 19, ), ]) def test_good_reply_samples(prot, data, id, result): # assume the protocol is awaiting a response for # a request with `id` prot._pending_replies = [id] reply = prot.parse_reply(data) assert reply.unique_id == id assert reply.result == result @pytest.mark.parametrize(('data'), [ """{"jsonrpc": "2.0", "result": 19, "id": 9001}""" ]) def test_unsolicited_reply_raises_error(prot, data): prot._pending_replies = [4] with pytest.raises(InvalidReplyError): reply = prot.parse_reply(data) @pytest.mark.parametrize(('exc', 'code', 'message'), [ (JSONRPCParseError, -32700, 'Parse error'), (JSONRPCInvalidRequestError, -32600, 'Invalid Request'), (JSONRPCMethodNotFoundError, -32601, 'Method not found'), (JSONRPCInvalidParamsError, -32602, 'Invalid params'), (JSONRPCInternalError, -32603, 'Internal error'), # generic errors #(InvalidRequestError, -32600, 'Invalid Request'), #(MethodNotFoundError, -32601, 'Method not found'), #(ServerError, -32603, 'Internal error'), ]) def test_proper_construction_of_error_codes(prot, exc, code, message): request = prot.parse_request( """{"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"}""" ) reply = exc().error_respond().serialize() assert isinstance(reply, bytes) reply = reply.decode() err = json.loads(reply) assert err['error']['code'] == code assert err['error']['message'] == message def test_notification_yields_None_response(prot): data = """{"jsonrpc": "2.0", "method": "update", "params": [1,2,3,4,5]}""" req = prot.parse_request(data) assert req.one_way == True # updates should never cause retries assert req.respond(True) == None def test_batch_empty_array(prot): with pytest.raises(JSONRPCInvalidRequestError): prot.parse_request("""[]""") def test_batch_invalid_array(prot): assert isinstance(prot.parse_request("""[1]""")[0], JSONRPCInvalidRequestError) def test_batch_invalid_batch(prot): for r in prot.parse_request("""[1, 2, 3]"""): assert isinstance(r, JSONRPCInvalidRequestError) def test_batch_good_examples(prot): data = """ [ {"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"}, {"jsonrpc": "2.0", "method": "notify_hello", "params": [7]}, {"jsonrpc": "2.0", "method": "subtract", "params": [42,23], "id": "2"}, {"foo": "boo"}, {"jsonrpc": "2.0", "method": "foo.get", "params": {"name": "myself"}, "id": "5"}, {"jsonrpc": "2.0", "method": "get_data", "id": "9"} ] """ results = prot.parse_request(data) assert isinstance(results, list) assert results[0].method == 'sum' assert results[0].args == [1, 2, 4] assert results[0].unique_id == "1" assert results[1].method == 'notify_hello' assert results[1].args == [7] assert results[1].unique_id == None assert results[2].method == 'subtract' assert results[2].args == [42, 23] assert results[2].unique_id == "2" assert isinstance(results[3], JSONRPCInvalidRequestError) assert results[4].method == 'foo.get' assert results[4].kwargs == {'name': 'myself'} assert results[4].unique_id == "5" assert results[5].method == 'get_data' assert results[5].args == [] assert results[5].kwargs == {} assert results[5].unique_id == "9" def test_unique_ids(prot): req1 = prot.create_request('foo', [1, 2]) req2 = prot.create_request('foo', [1, 2]) assert req1.unique_id != req2.unique_id def test_out_of_order(prot): req = prot.create_request('foo', ['a', 'b'], None) rep = req.respond(1) assert req.unique_id == rep.unique_id def test_request_generation(prot): jdata = json.loads(prot.create_request('subtract', [42, 23]).serialize().decode()) assert jdata['method'] == 'subtract' assert jdata['params'] == [42, 23] assert jdata['id'] != None assert jdata['jsonrpc'] == '2.0' def test_jsonrpc_spec_v2_example1(prot): # reset id counter from tinyrpc.protocols import default_id_generator prot._id_generator = default_id_generator(1) request = prot.create_request('subtract', [42, 23]) assert _json_equal( """{"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1}""", request.serialize() ) reply = request.respond(19) assert _json_equal( """{"jsonrpc": "2.0", "result": 19, "id": 1}""", reply.serialize() ) request = prot.create_request('subtract', [23, 42]) assert _json_equal( """{"jsonrpc": "2.0", "method": "subtract", "params": [23, 42], "id": 2}""", request.serialize() ) reply = request.respond(-19) assert _json_equal( """{"jsonrpc": "2.0", "result": -19, "id": 2}""", reply.serialize() ) def test_jsonrpc_spec_v2_example2(prot): # reset id counter from tinyrpc.protocols import default_id_generator prot._id_generator = default_id_generator(3) request = prot.create_request('subtract', kwargs={'subtrahend': 23, 'minuend': 42}) assert _json_equal( """{"jsonrpc": "2.0", "method": "subtract", "params": {"subtrahend": 23, "minuend": 42}, "id": 3}""", request.serialize() ) reply = request.respond(19) assert _json_equal( """{"jsonrpc": "2.0", "result": 19, "id": 3}""", reply.serialize() ) request = prot.create_request('subtract', kwargs={'subtrahend': 23, 'minuend': 42}) assert _json_equal( """{"jsonrpc": "2.0", "method": "subtract", "params": {"minuend": 42, "subtrahend": 23}, "id": 4}""", request.serialize() ) reply = request.respond(-19) assert _json_equal( """{"jsonrpc": "2.0", "result": -19, "id": 4}""", reply.serialize() ) def test_jsonrpc_spec_v2_example3(prot): request = prot.create_request('update', [1, 2, 3, 4, 5], one_way=True) assert _json_equal( """{"jsonrpc": "2.0", "method": "update", "params": [1,2,3,4,5]}""", request.serialize() ) request = prot.create_request('foobar', one_way=True) assert _json_equal( """{"jsonrpc": "2.0", "method": "foobar"}""", request.serialize() ) def test_jsonrpc_spec_v2_example4(prot): request = prot.create_request('foobar') request.unique_id = str(1) assert _json_equal( """{"jsonrpc": "2.0", "method": "foobar", "id": "1"}""", request.serialize() ) response = request.error_respond(MethodNotFoundError('foobar')) assert _json_equal( """{"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": "1"}""", response.serialize() ) def test_jsonrpc_spec_v2_example5(prot): try: prot.parse_request( """{"jsonrpc": "2.0", "method": "foobar, "params": "bar", "baz]""") assert False # parsing must fail except JSONRPCParseError as error: e = error response = e.error_respond() assert _json_equal( """{"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": null}""", response.serialize() ) def test_jsonrpc_spec_v2_example6(prot): try: prot.parse_request( """{"jsonrpc": "2.0", "method": 1, "params": "bar"}""") assert False # parsing must fail except JSONRPCInvalidRequestError as error: e = error response = e.error_respond() assert _json_equal( """{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}""", response.serialize() ) def test_jsonrpc_spec_v2_example6_with_request_id(prot): try: prot.parse_request( """{"jsonrpc": "2.0", "id": 42, "method": 1, "params": "bar"}""") assert False # parsing must fail except JSONRPCInvalidRequestError as error: e = error response = e.error_respond() assert _json_equal( """{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": 42}""", response.serialize() ) def test_jsonrpc_spec_v2_example7(prot): try: prot.parse_request("""[ {"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"}, {"jsonrpc": "2.0", "method" ]""") assert False except JSONRPCParseError as error: e = error response = e.error_respond() assert _json_equal( """{"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": null}""", response.serialize() ) def test_jsonrpc_spec_v2_example8(prot): try: prot.parse_request("""[]""") assert False except JSONRPCInvalidRequestError as error: e = error response = e.error_respond() assert _json_equal("""{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}""", response.serialize()) def test_jsonrpc_spec_v2_example9(prot): requests = prot.parse_request("""[1]""") assert isinstance(requests[0], JSONRPCInvalidRequestError) responses = requests.create_batch_response() responses.append(requests[0].error_respond()) assert _json_equal("""[ {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null} ]""", responses.serialize()) def test_jsonrpc_spec_v2_example10(prot): requests = prot.parse_request("""[1, 2, 3]""") assert isinstance(requests[0], JSONRPCInvalidRequestError) assert isinstance(requests[1], JSONRPCInvalidRequestError) assert isinstance(requests[2], JSONRPCInvalidRequestError) responses = requests.create_batch_response() responses.append(requests[0].error_respond()) responses.append(requests[1].error_respond()) responses.append(requests[2].error_respond()) assert _json_equal("""[ {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}, {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}, {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null} ]""", responses.serialize()) def test_jsonrpc_spec_v2_example11(prot): requests = prot.parse_request("""[ {"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"}, {"jsonrpc": "2.0", "method": "notify_hello", "params": [7]}, {"jsonrpc": "2.0", "method": "subtract", "params": [42,23], "id": "2"}, {"foo": "boo"}, {"jsonrpc": "2.0", "method": "foo.get", "params": {"name": "myself"}, "id": "5"}, {"jsonrpc": "2.0", "method": "get_data", "id": "9"} ]""") assert isinstance(requests[3], JSONRPCInvalidRequestError) responses = requests.create_batch_response() responses.append(requests[0].respond(7)) responses.append(requests[2].respond(19)) responses.append(requests[3].error_respond()) responses.append(requests[4].error_respond(MethodNotFoundError('foo.get'))) responses.append(requests[5].respond(['hello', 5])) assert _json_equal("""[ {"jsonrpc": "2.0", "result": 7, "id": "1"}, {"jsonrpc": "2.0", "result": 19, "id": "2"}, {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}, {"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": "5"}, {"jsonrpc": "2.0", "result": ["hello", 5], "id": "9"} ]""", responses.serialize()) def test_jsonrpc_spec_v2_example12(prot): reqs = [] reqs.append(prot.create_request('notify_sum', [1, 2, 4], one_way=True)) reqs.append(prot.create_request('notify_hello', [7], one_way=True)) request = prot.create_batch_request(reqs) assert request.create_batch_response() == None def test_can_get_custom_error_messages_out(prot): request = prot.create_request('foo') custom_msg = 'join the army, they said. see the world, they said.' e = Exception(custom_msg) response = request.error_respond(e) jstr = response.serialize() assert isinstance(jstr, bytes) jstr = jstr.decode() data = json.loads(jstr) assert data['error']['message'] == custom_msg def test_accepts_empty_but_not_none_args_kwargs(prot): request = prot.create_request('foo', args=[], kwargs={}) def test_missing_jsonrpc_version_on_request(prot): with pytest.raises(JSONRPCInvalidRequestError): prot.parse_request('{"method": "sum", "params": [1,2,4], "id": "1"}') def test_missing_jsonrpc_version_on_reply(prot): with pytest.raises(InvalidReplyError): prot.parse_reply('{"result": 7, "id": "1"}') def test_pass_error_data_with_standard_exception(prot): request = prot.create_request('foo') custom_msg = 'join the army, they said. see the world, they said.' data = {'pi': 3.14, 'lst': ['a', 'b', 'c']} e = Exception(custom_msg, data) response = request.error_respond(e) jmsg = response.serialize() assert isinstance(jmsg, bytes) jmsg = jmsg.decode() decoded = json.loads(jmsg) print("decoded=", decoded) assert decoded['error']['code'] == -32000 assert decoded['error']['message'] == custom_msg assert decoded['error']['data'] == data # on the client side, when reply is parsed parsed_reply = prot.parse_reply(jmsg) serialized_reply = parsed_reply.serialize().decode("utf-8") decoded_reply = json.loads(serialized_reply) print("decoded_reply=", decoded_reply) assert isinstance(parsed_reply, JSONRPCErrorResponse) assert hasattr(parsed_reply, "data") assert serialized_reply == jmsg assert decoded_reply == decoded def test_pass_error_data_with_custom_exception(prot): # type: (JSONRPCProtocol) -> None request = prot.create_request('foo') data = {'pi': 3.14, 'lst': ['a', 'b', 'c']} e = JSONRPCParseError(data=data) response = request.error_respond(e) jmsg = response.serialize() assert isinstance(jmsg, bytes) jmsg = jmsg.decode() decoded = json.loads(jmsg) print("decoded=", decoded) assert decoded['error']['code'] == -32700 assert decoded['error']['message'] == JSONRPCParseError.message assert decoded['error']['data'] == data # on the client side, when reply is parsed parsed_reply = prot.parse_reply(jmsg) serialized_reply = parsed_reply.serialize().decode("utf-8") decoded_reply = json.loads(serialized_reply) print("decoded_reply=", decoded_reply) assert isinstance(parsed_reply, JSONRPCErrorResponse) assert hasattr(parsed_reply, "data") assert serialized_reply == jmsg assert decoded_reply == decoded ================================================ FILE: tests/test_msgpackrpc.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- import msgpack import pytest from tinyrpc import InvalidReplyError, MethodNotFoundError from tinyrpc.protocols.msgpackrpc import ( MSGPACKRPCParseError, MSGPACKRPCInvalidRequestError, MSGPACKRPCMethodNotFoundError, MSGPACKRPCInvalidParamsError, MSGPACKRPCInternalError, ) def _msgpack_equal(a, b): return msgpack.unpackb(a) == msgpack.unpackb(b) @pytest.fixture def prot(): from tinyrpc.protocols.msgpackrpc import MSGPACKRPCProtocol return MSGPACKRPCProtocol() @pytest.mark.parametrize( ("data", "attrs"), [ # examples from the JSON-RPC spec, translated to MSGPACK, parsing only ( b"\x94\x00\x01\xa8subtract\x92*\x17", {"method": "subtract", "args": [42, 23], "unique_id": 1}, ), ( b"\x94\x00\x02\xa8subtract\x92\x17*", {"method": "subtract", "args": [23, 42], "unique_id": 2}, ), ( b"\x93\x02\xa6update\x95\x01\x02\x03\x04\x05", {"method": "update", "args": [1, 2, 3, 4, 5]}, ), (b"\x93\x02\xa6foobar\x90", {"method": "foobar", "args": []}), ], ) def test_parsing_good_request_samples(prot, data, attrs): req = prot.parse_request(data) for k, v in attrs.items(): assert getattr(req, k) == v @pytest.mark.parametrize( "invalid_msgpack", [ b"\x81\xa3\x66\x6f\x6f\xa4\x62\x61\x72", b"\x94\x00\x01\x81\xa3aaa\xa3bb", b"garbage", ], ) def test_parsing_invalid_msgpack(prot, invalid_msgpack): with pytest.raises(MSGPACKRPCParseError): prot.parse_request(invalid_msgpack) @pytest.mark.parametrize( "data", [ b"\xc0", # None b"\x94\x00\xc0\xa3aaa\x90", # [0, None, "aaa", []] - request ID not int b"\x95\x00\x02\xa3aaa\x90\xc0", # [0, 2, "aaa", [], None] - too long b"\x94\x02\xa3aaa\x90\xc0", # [2, "aaa", [], None] - too long b"\x93\x02\x01\x90", # [2, 1, []] - method name not string ], ) def test_parsing_valid_msgpack_but_invalid_rpc_message(prot, data): with pytest.raises(MSGPACKRPCInvalidRequestError): prot.parse_request(data) @pytest.mark.parametrize( "invalid_args", [ b"\x94\x00\x03\xa6update\t", # [0, 3, "update", 9] b"\x94\x00\x03\xa6foobar\xc0", # [0, 3, "foobar", None] b"\x93\x02\xa3aaa\xc0", # [2, "aaa", None] ], ) def test_parsing_invalid_arguments(prot, invalid_args): with pytest.raises(MSGPACKRPCInvalidParamsError): prot.parse_request(invalid_args) @pytest.mark.parametrize( ("data", "id", "result"), [ (b"\x94\x01\x01\xc0\x13", 1, 19), # [1, 1, None, 19] (b"\x94\x01\x02\xc0\xed", 2, -19), # [1, 2, None, -19] (b"\x94\x01\x03\xc0\x13", 3, 19), # [1, 3, None, 19] (b"\x94\x01\x04\xc0\x13", 4, 19), # [1, 4, None, 19] ], ) def test_good_reply_samples(prot, data, id, result): reply = prot.parse_reply(data) assert reply.unique_id == id assert reply.result == result @pytest.mark.parametrize( ("data", "id", "code", "message"), [ # Neovim-style (b"\x94\x01\x05\x92\xcd\x04\xd2\xa5Error\xc0", 5, 1234, "Error"), # Ordinary error string (b"\x94\x01\x05\xa5Error\xc0", 5, None, "Error"), # Two-item list but the types don't match Neovim's style (b"\x94\x01\x05\x92\xa41234\xa5Error\xc0", 5, None, ["1234", "Error"]), ], ) def test_good_error_reply_samples(prot, data, id, code, message): reply = prot.parse_reply(data) assert reply.unique_id == id assert reply._msgpackrpc_error_code == code assert reply.error == message @pytest.mark.parametrize( ("exc", "code", "message"), [ (MSGPACKRPCParseError, -32700, "Parse error"), (MSGPACKRPCInvalidRequestError, -32600, "Invalid request"), (MSGPACKRPCMethodNotFoundError, -32601, "Method not found"), (MSGPACKRPCInvalidParamsError, -32602, "Invalid params"), (MSGPACKRPCInternalError, -32603, "Internal error"), ], ) def test_proper_construction_of_error_codes(prot, exc, code, message): reply = exc().error_respond().serialize() assert isinstance(reply, bytes) err = msgpack.unpackb(reply, raw=False) assert err[0] == 1 assert err[2] == [code, message] def test_notification_yields_None_response(prot): # [2, "update", [1,2,3,4,5]] data = b"\x93\x02\xa6update\x95\x01\x02\x03\x04\x05" req = prot.parse_request(data) assert req.one_way is True # updates should never cause retries assert req.respond(True) is None @pytest.mark.parametrize( "data", [ b"\x90", # \x90 = [] b"\x91\x01", # \x91\x01 = [1] b"\x93\x01\x02\x03", # \x93\x01\x02\x03 = [1, 2, 3] ( b"\x95\x94\x00\x01\xa3sum\x93\x01\x02\x04" b"\x93\x02\xacnotify_hello\x91\x07" b"\x94\x00\x02\xa8subtract\x92*\x17" b"\x94\x00\x05\xa7foo.get\x81\xa4name\xa6myself" b"\x94\x00\t\xa8get_data\xc0" ), ], ) def test_batch_examples(prot, data): with pytest.raises(MSGPACKRPCInvalidRequestError): prot.parse_request(data) def test_unique_ids(prot): req1 = prot.create_request("foo", [1, 2]) req2 = prot.create_request("foo", [1, 2]) assert req1.unique_id != req2.unique_id def test_out_of_order(prot): req = prot.create_request("foo", ["a", "b"], None) rep = req.respond(1) assert req.unique_id == rep.unique_id def test_request_generation(prot): data = msgpack.unpackb( prot.create_request("subtract", [42, 23]).serialize(), raw=False ) assert data[0] == 0 assert isinstance(data[1], int) assert data[2] == "subtract" assert data[3] == [42, 23] # The tests below are adapted from the JSON-RPC specification, hence their names def test_jsonrpc_spec_v2_example1(prot): # reset id counter from tinyrpc.protocols import default_id_generator prot._id_generator = default_id_generator(1) request = prot.create_request("subtract", [42, 23]) assert request.serialize() == b"\x94\x00\x01\xa8subtract\x92*\x17" reply = request.respond(19) assert reply.serialize() == b"\x94\x01\x01\xc0\x13" request = prot.create_request("subtract", [23, 42]) assert request.serialize() == b"\x94\x00\x02\xa8subtract\x92\x17*" reply = request.respond(-19) assert reply.serialize() == b"\x94\x01\x02\xc0\xed" def test_jsonrpc_spec_v2_example3(prot): request = prot.create_request("update", [1, 2, 3, 4, 5], one_way=True) assert request.serialize() == b"\x93\x02\xa6update\x95\x01\x02\x03\x04\x05" request = prot.create_request("foobar", one_way=True) assert request.serialize() == b"\x93\x02\xa6foobar\x90" def test_jsonrpc_spec_v2_example4(prot): request = prot.create_request("foobar") request.unique_id = 1 assert request.serialize() == b"\x94\x00\x01\xa6foobar\x90" response = request.error_respond(MethodNotFoundError("foobar")) assert _msgpack_equal( b"\x94\x01\x01\x92\xd1\x80\xa7\xb0Method not found\xc0", response.serialize() ) def test_jsonrpc_spec_v2_example5(prot): try: prot.parse_request(b"\x94\x00\x01\x81\xa3aaa\xa3bb") assert False # parsing must fail except MSGPACKRPCParseError as error: e = error response = e.error_respond() # TODO(ntamas): here we are sending None as the request ID because # obviously we could not parse it from a malformed request. We need to # decide whether this is valid MSGPACK or not. assert _msgpack_equal( b"\x94\x01\xc0\x92\xd1\x80D\xabParse error\xc0", response.serialize() ) def test_jsonrpc_spec_v2_example6(prot): try: prot.parse_request(b"\x94\x00\x01\x01\xa3bar") assert False # parsing must fail except MSGPACKRPCInvalidRequestError as error: e = error response = e.error_respond() assert _msgpack_equal( b"\x94\x01\x01\x92\xd1\x80\xa8\xafInvalid request\xc0", response.serialize() ) def test_jsonrpc_spec_v2_example8(prot): try: prot.parse_request(b"\x90") assert False except MSGPACKRPCInvalidRequestError as error: e = error response = e.error_respond() assert _msgpack_equal( b"\x94\x01\xc0\x92\xd1\x80\xa8\xafInvalid request\xc0", response.serialize() ) def test_jsonrpc_spec_v2_example9(prot): try: prot.parse_request(b"\x91\x01") assert False except MSGPACKRPCInvalidRequestError as error: e = error response = e.error_respond() assert _msgpack_equal( b"\x94\x01\xc0\x92\xd1\x80\xa8\xafInvalid request\xc0", response.serialize() ) def test_jsonrpc_spec_v2_example10(prot): try: prot.parse_request(b"\x93\x01\x02\x03") assert False except MSGPACKRPCInvalidRequestError as error: e = error response = e.error_respond() assert _msgpack_equal( b"\x94\x01\xc0\x92\xd1\x80\xa8\xafInvalid request\xc0", response.serialize() ) def test_jsonrpc_spec_v2_example11(prot): # Since MSGPACK does not support batched request, we test the requests # one by one requests = [] for data in [ b"\x94\x00\x01\xa3sum\x93\x01\x02\x04", # [0, 1, "sum", [1,2,4]] b"\x93\x02\xacnotify_hello\x91\x07", # [2, "notify_hello", [7] b"\x94\x00\x02\xa8subtract\x92*\x17", # [0, 2, "subtract", [42,23]] b"\x92\xa3foo\xa3boo", # ["foo", "boo"] b"\x94\x00\x05\xa7foo.get\x92\xa4name\xa6myself", # [0, 5, "foo.get", ["name", "myself"]] b"\x94\x00\t\xa8get_data\x90", # [0, 9, "get_data", []] ]: try: requests.append(prot.parse_request(data)) except Exception as ex: requests.append(ex) assert isinstance(requests[3], MSGPACKRPCInvalidRequestError) responses = [] responses.append(requests[0].respond(7)) responses.append(requests[1].error_respond(MethodNotFoundError("notify_hello"))) responses.append(requests[2].respond(19)) responses.append(requests[3].error_respond()) responses.append(requests[4].error_respond(MethodNotFoundError("foo.get"))) responses.append(requests[5].respond(["hello", 5])) responses = [ response.serialize() if response else response for response in responses ] assert responses[0] == b"\x94\x01\x01\xc0\x07" assert responses[1] is None assert responses[2] == b"\x94\x01\x02\xc0\x13" assert responses[3] == b"\x94\x01\xc0\x92\xd1\x80\xa8\xafInvalid request\xc0" assert responses[4] == b"\x94\x01\x05\x92\xd1\x80\xa7\xb0Method not found\xc0" assert responses[5] == b"\x94\x01\t\xc0\x92\xa5hello\x05" def test_can_get_custom_error_messages_out(prot): request = prot.create_request("foo") custom_msg = "join the army, they said. see the world, they said." e = Exception(custom_msg) response = request.error_respond(e) data = response.serialize() assert isinstance(data, bytes) decoded = msgpack.unpackb(data, raw=False) assert decoded[0] == 1 assert decoded[1] == request.unique_id assert isinstance(decoded[2], list) assert decoded[2][1] == custom_msg def test_accepts_empty_but_not_none_args(prot): prot.create_request("foo", args=[]) def test_rejects_nonempty_kwargs(prot): with pytest.raises(MSGPACKRPCInvalidRequestError): prot.create_request("foo", kwargs={"foo": "bar"}) def test_accepts_empty_kwargs(prot): prot.create_request("foo", kwargs={}) @pytest.mark.parametrize( "data", [ b"\x97\x01", # complete garbage b"\x93\x01\xc0\xa5hello", # too short b"\x94\x00\x01\xc0\xa5hello", # not a reply (message type is request) b"\x94\x01\xc0\xc0\xa5hello", # missing message ID in response b"\x94\x01\x01\xa5hello\xa5hello", # contains error _and_ result ], ) def test_invalid_replies(prot, data): with pytest.raises(InvalidReplyError): prot.parse_reply(data) ================================================ FILE: tests/test_protocols.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- import pytest from tinyrpc.protocols.jsonrpc import JSONRPCProtocol from tinyrpc import RPCErrorResponse @pytest.fixture(params=['jsonrpc']) def protocol(request): if 'jsonrpc': return JSONRPCProtocol() raise RuntimeError('Bad protocol name in test case') def test_protocol_returns_bytes(protocol): req = protocol.create_request('foo', ['bar']) assert isinstance(req.serialize(), bytes) def test_procotol_responds_bytes(protocol): req = protocol.create_request('foo', ['bar']) rep = req.respond(42) err_rep = req.error_respond(Exception('foo')) assert isinstance(rep.serialize(), bytes) assert isinstance(err_rep.serialize(), bytes) def test_one_way(protocol): req = protocol.create_request('foo', None, {'a': 'b'}, True) assert req.respond(None) == None def test_raises_on_args_and_kwargs(protocol): with pytest.raises(Exception): protocol.create_request('foo', ['arg1', 'arg2'], {'kw_key': 'kw_value'}) def test_supports_no_args(protocol): protocol.create_request('foo') def test_creates_error_response(protocol): req = protocol.create_request('foo', ['bar']) err_rep = req.error_respond(Exception('foo')) assert hasattr(err_rep, 'error') def test_parses_error_response(protocol): req = protocol.create_request('foo', ['bar']) err_rep = req.error_respond(Exception('foo')) parsed = protocol.parse_reply(err_rep.serialize()) assert hasattr(parsed, 'error') def test_default_id_generator(): from tinyrpc.protocols import default_id_generator g = default_id_generator(1) assert next(g) == 1 assert next(g) == 2 assert next(g) == 3 ================================================ FILE: tests/test_rabbitmq_transport.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- import pytest from unittest.mock import patch from tinyrpc.transports.rabbitmq import RabbitMQServerTransport, RabbitMQClientTransport FAKE_REQUEST_MSG = b'a fake request message' FAKE_RESPONSE_MSG = b'a fake response message' FAKE_MESSAGE_DATA = b'some fake message data' TEST_QUEUE = 'test_queue' TEST_ROUTE = 'test_route' class DummyBlockingConnection: class DummyChannel: class GenericObject(object): pass def __init__(self): self.properties = self.GenericObject() self.properties.reply_to = "reply_to" self.properties.correlation_id = "correlation_id" def queue_declare(self, *args, **kwargs): result = self.GenericObject() result.method = self.GenericObject() result.method.queue = "queue_id" return result def basic_consume(self, on_message_callback, *args, **kwargs): self.on_message_callback = on_message_callback def basic_publish(self, properties, *args, **kwargs): self.properties = properties def basic_ack(self, *args, **kwargs): pass def __init__(self, *args, **kwargs): pass def channel(self): self.channel = self.DummyChannel() return self.channel def process_data_events(self): fake_response = FAKE_MESSAGE_DATA method = self.DummyChannel.GenericObject() method.delivery_tag = "delivery_tag" self.channel.on_message_callback(self.channel, method, self.channel.properties, fake_response) @pytest.fixture def dummy_blockingconnection(): return DummyBlockingConnection() @pytest.fixture def rabbitmq_server(dummy_blockingconnection): return RabbitMQServerTransport(dummy_blockingconnection, TEST_QUEUE) @pytest.fixture def rabbitmq_client(dummy_blockingconnection): return RabbitMQClientTransport(dummy_blockingconnection, TEST_ROUTE) @patch('pika.BlockingConnection', DummyBlockingConnection) def test_can_create_rabbitmq_server(): RabbitMQServerTransport.create("localhost", TEST_QUEUE) @patch('pika.BlockingConnection', DummyBlockingConnection) def test_can_create_rabbitmq_client(): RabbitMQClientTransport.create("localhost", TEST_ROUTE) def test_server_can_receive_message(rabbitmq_server): context, message = rabbitmq_server.receive_message() assert context assert message == FAKE_MESSAGE_DATA def test_server_can_send_reply(rabbitmq_server): context, message = rabbitmq_server.receive_message() assert context assert message == FAKE_MESSAGE_DATA rabbitmq_server.send_reply(context, FAKE_RESPONSE_MSG) def test_client_can_send_message(rabbitmq_client): response = rabbitmq_client.send_message(FAKE_REQUEST_MSG, expect_reply=False) assert response is None def test_client_can_send_message_and_get_reply(rabbitmq_client): response = rabbitmq_client.send_message(FAKE_REQUEST_MSG, expect_reply=True) assert response == FAKE_MESSAGE_DATA ================================================ FILE: tests/test_server.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- import pytest from unittest.mock import Mock, call from tinyrpc.server import RPCServer from tinyrpc.transports import ServerTransport from tinyrpc.protocols import RPCProtocol, RPCResponse from tinyrpc.dispatch import RPCDispatcher CONTEXT='sapperdeflap' RECMSG='out of receive_message' PARMSG='out of parse_request' SERMSG='out of serialize' @pytest.fixture def transport(): transport = Mock(ServerTransport) transport.receive_message = Mock(return_value=(CONTEXT, RECMSG)) return transport @pytest.fixture def protocol(): protocol = Mock(RPCProtocol) protocol.parse_request = Mock(return_value=PARMSG) return protocol @pytest.fixture() def response(): response = Mock(RPCResponse) response.serialize = Mock(return_value=SERMSG) return response @pytest.fixture def dispatcher(response): dispatcher = Mock(RPCDispatcher) dispatcher.dispatch = Mock(return_value=response) return dispatcher def test_handle_message(transport, protocol, dispatcher): server = RPCServer(transport, protocol, dispatcher) server.receive_one_message() transport.receive_message.assert_called() protocol.parse_request.assert_called_with(RECMSG) dispatcher.dispatch.assert_called_with(PARMSG, None) dispatcher.dispatch().serialize.assert_called() transport.send_reply.assert_called_with(CONTEXT, SERMSG) def test_handle_message_callback(transport, protocol, dispatcher): server = RPCServer(transport, protocol, dispatcher) server.trace = Mock(return_value=None) server.receive_one_message() assert server.trace.call_args_list == [call('-->', CONTEXT, RECMSG), call('<--', CONTEXT, SERMSG)] server.trace.assert_called() ================================================ FILE: tests/test_transport.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- import pytest import zmq import zmq.green from tinyrpc.transports import ServerTransport, ClientTransport from tinyrpc.transports.zmq import ZmqServerTransport, ZmqClientTransport class DummyServerTransport(ServerTransport): def __init__(self): self.messages = [] self.clients = {} def receive_message(self): return self.messages.pop() def send_reply(self, context, message): if not isinstance(message, str): raise TypeError('Message must be str().') self.clients[context].messages.append(message) class DummyClientTransport(ClientTransport): def __init__(self, server): self.server = server self.id = id(self) self.server.clients[self.id] = self self.messages = [] def send_message(self, message): if not isinstance(message, str): raise TypeError('Message must be str().') self.server.messages.append((self.id, message)) def receive_reply(self): return self.messages.pop() ZMQ_ENDPOINT = 'inproc://example2' @pytest.fixture(scope='session') def zmq_context(request): ctx = zmq.Context() def fin(): request.addfinalizer(ctx.destroy()) return ctx @pytest.fixture(scope='session') def zmq_green_context(request): ctx = zmq.Context() def fin(): request.addfinalizer(ctx.destroy()) return ctx # zmq and zmq.green fail on python3 SERVERS=['dummy'] @pytest.fixture(params=SERVERS) def transport(request, zmq_context, zmq_green_context): if request.param == 'dummy': server = DummyServerTransport() client = DummyClientTransport(server) elif request.param in ('zmq', 'zmq.green'): ctx = zmq_context if request.param == 'zmq' else zmq_green_context server = ZmqServerTransport.create(ctx, ZMQ_ENDPOINT) client = ZmqClientTransport.create(ctx, ZMQ_ENDPOINT) def fin(): server.socket.close() client.socket.close() request.addfinalizer(fin) else: raise ValueError('Invalid transport.') return (client, server) SAMPLE_MESSAGES = ['asdf', 'loremipsum' * 1500, '', '\x00', 'b\x00a', '\r\n', '\n', '\u1234'.encode('utf8')] BAD_MESSAGES = [b'asdf', b'', 1234, 1.2, None, True, False, ('foo',)] @pytest.fixture(scope='session', params=SAMPLE_MESSAGES) def sample_msg(request): return request.param @pytest.fixture(scope='session', params=SAMPLE_MESSAGES) def sample_msg2(request): return request.param @pytest.fixture(scope='session', params=BAD_MESSAGES) def bad_msg(request): return request.param def test_transport_rejects_bad_values(transport, bad_msg): client, server = transport with pytest.raises(TypeError): client.send_message(bad_msg) # FIXME: these tests need to be rethought, as they no longer work properly with # the change to the interface of ClientTransport # FIXME: the actual client needs tests as well ================================================ FILE: tests/test_wsgi_transport.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- import pytest import gevent import gevent.queue import gevent.monkey from gevent.pywsgi import WSGIServer import requests from importlib import reload from tinyrpc.transports.wsgi import WsgiServerTransport from tinyrpc.transports.http import HttpPostClientTransport TEST_SERVER_ADDR = ('127.0.0.1', 49294) @pytest.fixture(scope='module', autouse=True) def monkey_patches(request): # ugh? ugh. ugh. ugh! import socket gevent.monkey.patch_all( socket=True, dns=False, time=False, select=False, thread=False, os=True, httplib=False, ssl=False, aggressive=False) def fin(): reload(socket) request.addfinalizer(fin) @pytest.fixture() def wsgi_server(request): app = WsgiServerTransport(queue_class=gevent.queue.Queue) server = WSGIServer(TEST_SERVER_ADDR, app.handle) def fin(): server.stop() server_greenlet.join() request.addfinalizer(fin) server_greenlet = gevent.spawn(server.serve_forever) gevent.sleep(0) # wait for server to come up return (app, 'http://%s:%d' % TEST_SERVER_ADDR) def test_server_supports_post_only(wsgi_server): transport, addr = wsgi_server r = requests.get(addr) # we expect a "not supported" response assert r.status_code == 405 r = requests.head(addr) # we expect a "not supported" response assert r.status_code == 405 @pytest.mark.parametrize(('msg',), [(b'foo',), (b'',), (b'bar',), (b'1234',), (b'{}',), (b'{',), (b'\x00\r\n',)]) def test_server_receives_messages(wsgi_server, msg): transport, addr = wsgi_server def consumer(): context, received_msg = transport.receive_message() assert received_msg == msg reply = b'reply:' + msg transport.send_reply(context, reply) gevent.spawn(consumer) r = requests.post(addr, data=msg) assert r.content == b'reply:' + msg @pytest.fixture def sessioned_client(): session = requests.Session() adapter = requests.adapters.HTTPAdapter(pool_maxsize=100) session.mount('http://', adapter) client = HttpPostClientTransport( 'http://%s:%d' % TEST_SERVER_ADDR, post_method=session.post ) return client @pytest.fixture def non_sessioned_client(): client = HttpPostClientTransport('http://%s:%d' % TEST_SERVER_ADDR) return client @pytest.mark.parametrize(('msg',), [(b'foo',), (b'',), (b'bar',), (b'1234',), (b'{}',), (b'{',), (b'\x00\r\n',)]) def test_sessioned_http_sessioned_client(wsgi_server, sessioned_client, msg): transport, addr = wsgi_server def consumer(): context, received_msg = transport.receive_message() assert received_msg == msg reply = b'reply:' + msg transport.send_reply(context, reply) gevent.spawn(consumer) result = sessioned_client.send_message(msg) assert result == b'reply:' + msg @pytest.mark.skip('somehow fails on travis') def test_exhaust_ports(wsgi_server, non_sessioned_client): """ This raises a > ConnectionError: HTTPConnectionPool(host='127.0.0.1', port=49294): > Max retries exceeded with url: / (Caused by > NewConnectionError(' object at 0x7f6f86246210>: Failed to establish a new connection: > [Errno 99] Cannot assign requested address',)) """ transport, addr = wsgi_server def consumer(): context, received_msg = transport.receive_message() reply = b'reply:' + received_msg transport.send_reply(context, reply) def send_and_receive(i): try: gevent.spawn(consumer) msg = b'msg_%s' % i result = non_sessioned_client.send_message(msg) return result == b'reply:' + msg except Exception as e: return e pool = gevent.pool.Pool(500) with pytest.raises(requests.ConnectionError): for result in pool.imap_unordered(send_and_receive, range(55000)): assert result if isinstance(result, Exception): raise result ================================================ FILE: tinyrpc/__init__.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- from .protocols import * from .exc import * from .client import * ================================================ FILE: tinyrpc/client.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- import sys from collections import namedtuple from typing import List, Any, Dict, Callable, Optional from .transports import ClientTransport from .exc import RPCError from .protocols import RPCErrorResponse, RPCProtocol, RPCRequest, RPCResponse, RPCBatchResponse RPCCall = namedtuple('RPCCall', 'method args kwargs') """Defines the elements of an RPC call. RPCCall is used with :py:meth:`~tinyrpc.client.RPCClient.call_all` to provide the list of requests to be processed. Each request contains the elements defined in this tuple. """ RPCCallTo = namedtuple('RPCCallTo', 'transport method args kwargs') """Defines the elements of a RPC call directed to multiple transports. RPCCallTo is used with :py:meth:`~tinyrpc.client.RPCClient.call_all` to provide the list of requests to be processed. """ class RPCClient(object): """Client for making RPC calls to connected servers. :param protocol: An :py:class:`~tinyrpc.RPCProtocol` instance. :type protocol: RPCProtocol :param transport: The data transport mechanism :type transport: ClientTransport """ def __init__( self, protocol: RPCProtocol, transport: ClientTransport ) -> None: self.protocol = protocol self.transport = transport def _send_and_handle_reply( self, req: RPCRequest, one_way: bool = False, transport: ClientTransport = None, no_exception: bool = False ) -> Optional[RPCResponse]: tport = self.transport if transport is None else transport # sends ... reply = tport.send_message(req.serialize(), expect_reply=(not one_way)) if one_way: # ... and be done return # ... or process the reply response = self.protocol.parse_reply(reply) if not no_exception and isinstance(response, RPCErrorResponse): if hasattr(self.protocol, 'raise_error') and callable( self.protocol.raise_error): response = self.protocol.raise_error(response) else: raise RPCError( 'Error calling remote procedure: %s' % response.error ) return response def call( self, method: str, args: List, kwargs: Dict, one_way: bool = False ) -> Any: """Calls the requested method and returns the result. If an error occurred, an :py:class:`~tinyrpc.exc.RPCError` instance is raised. :param str method: Name of the method to call. :param list args: Arguments to pass to the method. :param dict kwargs: Keyword arguments to pass to the method. :param bool one_way: Whether or not a reply is desired. :return: The result of the call :rtype: any """ req = self.protocol.create_request(method, args, kwargs, one_way) rep = self._send_and_handle_reply(req, one_way) if one_way: return return rep.result def call_all(self, requests: List[RPCCall]) -> List[Any]: """Calls the methods in the request in parallel. When the :py:mod:`gevent` module is already loaded it is assumed to be correctly initialized, including monkey patching if necessary. In that case the RPC calls defined by ``requests`` are performed in parallel otherwise the methods are called sequentially. :param requests: A list of either :py:class:`~tinyrpc.client.RPCCall` or :py:class:`~tinyrpc.client.RPCCallTo` elements. When RPCCallTo is used each element defines a transport. Otherwise the default transport set when RPCClient is created is used. :return: A list with replies matching the order of the requests. """ threads = [] if 'gevent' in sys.modules: # assume that gevent is available and functional, make calls in parallel import gevent for r in requests: req = self.protocol.create_request(r.method, r.args, r.kwargs) tr = r.transport.transport if len(r) == 4 else None threads.append( gevent.spawn( self._send_and_handle_reply, req, False, tr, True ) ) gevent.joinall(threads) return [t.value for t in threads] else: # call serially for r in requests: req = self.protocol.create_request(r.method, r.args, r.kwargs) tr = r.transport.transport if len(r) == 4 else None threads.append( self._send_and_handle_reply(req, False, tr, True) ) return threads def get_proxy(self, prefix: str = '', one_way: bool = False) -> 'RPCProxy': """Convenience method for creating a proxy. :param prefix: Passed on to :py:class:`~tinyrpc.client.RPCProxy`. :param one_way: Passed on to :py:class:`~tinyrpc.client.RPCProxy`. :return: :py:class:`~tinyrpc.client.RPCProxy` instance. """ return RPCProxy(self, prefix, one_way) def batch_call(self, calls: List[RPCCallTo]) -> RPCBatchResponse: """Experimental, use at your own peril.""" req = self.protocol.create_batch_request() for call_args in calls: req.append(self.protocol.create_request(*call_args)) return self._send_and_handle_reply(req) class RPCProxy(object): """Create a new remote proxy object. Proxies allow calling of methods through a simpler interface. See the documentation for an example. :param client: An :py:class:`~tinyrpc.client.RPCClient` instance. :param prefix: Prefix to prepend to every method name. :param one_way: Passed to every call of :py:func:`~tinyrpc.client.call`. """ def __init__( self, client: RPCClient, prefix: str = '', one_way: bool = False ) -> None: self.client = client self.prefix = prefix self.one_way = one_way def __getattr__(self, name: str) -> Callable: """Returns a proxy function that, when called, will call a function name ``name`` on the client associated with the proxy. """ proxy_func = lambda *args, **kwargs: self.client.call( self.prefix + name, args, kwargs, one_way=self.one_way ) return proxy_func ================================================ FILE: tinyrpc/dispatch/__init__.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- """ Dispatcher ========== Given an RPC request the dispatcher will try to locate a server function that implements the request and will call that function returning its return value to the caller. """ import inspect from typing import Callable, Any, Dict, List, Optional, TypeVar, Union, overload from tinyrpc import RPCRequest, RPCResponse, RPCBatchRequest, RPCBatchResponse from .. import exc T = TypeVar("T") @overload def public(name: Callable[..., T]) -> Callable[..., T]: ... @overload def public(name: Optional[str] = None) -> Callable[[Callable[..., T]], Callable[..., T]]: ... def public(name = None): # noinspection SpellCheckingInspection """Decorator. Mark a method as eligible for registration by a dispatcher. The dispatchers :py:func:`~tinyrpc.dispatch.RPCDispatcher.register_instance` function will do the actual registration of the marked method. The difference with :py:func:`~tinyrpc.dispatch.RPCDispatcher.public` is that this decorator does not register with a dispatcher, therefore binding the marked methods with a dispatcher is delayed until runtime. It also becomes possible to bind with multiple dispatchers. :param name: The name to register the function with. Example: .. code-block:: python def class Baz(object): def not_exposed(self); # ... @public('do_something') def visible_method(self, arg1): # ... baz = Baz() dispatch = RPCDispatcher() dispatch.register_instance(baz, 'bazzies`) # Baz.visible_method is now callable via RPC as bazzies.do_something('hello') ``@public`` is a shortcut for ``@public()``. """ if callable(name): f = name f._rpc_public_name = f.__name__ return f def _(f): f._rpc_public_name = name or f.__name__ return f return _ class RPCDispatcher(object): """Stores name-to-method mappings.""" def __init__(self) -> None: self.method_map = {} self.subdispatchers = {} @overload def public(self, name: Callable[..., T]) -> Callable[..., T]: ... @overload def public(self, name: Optional[str] = None) -> Callable[[Callable[..., T]], Callable[..., T]]: ... def public(self, name = None): """Convenient decorator. Allows easy registering of functions to this dispatcher. Example: .. code-block:: python dispatch = RPCDispatcher() @dispatch.public def foo(bar): # ... class Baz(object): def not_exposed(self): # ... @dispatch.public(name='do_something') def visible_method(arg1) # ... :param str name: Name to register callable with. """ if callable(name): self.add_method(name) return name def _(f): self.add_method(f, name=name) return f return _ def add_subdispatch(self, dispatcher: 'RPCDispatcher', prefix: str = ''): """Adds a subdispatcher, possibly in its own namespace. :param dispatcher: The dispatcher to add as a subdispatcher. :type dispatcher: RPCDispatcher :param str prefix: A prefix. All of the new subdispatchers methods will be available as prefix + their original name. """ self.subdispatchers.setdefault(prefix, []).append(dispatcher) def add_method(self, f: Callable, name: str = None) -> None: """Add a method to the dispatcher. :param f: Callable to be added. :type f: callable :param str name: Name to register it with. If ``None``, ``f.__name__`` will be used. :raises ~tinyrpc.exc.RPCError: When the `name` is already registered. """ assert callable(f), "method argument must be callable" # catches a few programming errors that are # commonly silently swallowed otherwise if not name: name = f.__name__ if name in self.method_map: raise exc.RPCError('Name \'{}\' already registered'.format(name)) self.method_map[name] = f def get_method(self, name: str) -> Callable: """Retrieve a previously registered method. Checks if a method matching ``name`` has been registered. If :py:func:`get_method` cannot find a method, every subdispatcher with a prefix matching the method name is checked as well. :param str name: Function to find. :returns: The callable implementing the function. :rtype: callable :raises: :py:exc:`~tinyrpc.exc.MethodNotFoundError` """ if name in self.method_map: return self.method_map[name] for prefix, subdispatchers in self.subdispatchers.items(): if name.startswith(prefix): for sd in subdispatchers: try: return sd.get_method(name[len(prefix):]) except exc.MethodNotFoundError: pass raise exc.MethodNotFoundError(name) def register_instance(self, obj: object, prefix: str = '') -> None: """Create new subdispatcher and register all public object methods on it. To be used in conjunction with the :py:func:`public` decorator (*not* :py:func:`RPCDispatcher.public`). :param obj: The object whose public methods should be made available. :type obj: object :param str prefix: A prefix for the new subdispatcher. """ dispatch = self.__class__() # type: 'RPCDispatcher' for name, f in inspect.getmembers( obj, lambda f: callable(f) and hasattr(f, '_rpc_public_name')): dispatch.add_method(f, f._rpc_public_name) # add to dispatchers self.add_subdispatch(dispatch, prefix) def dispatch( self, request: Union[RPCRequest, RPCBatchRequest], caller: Callable = None ) -> Union[RPCResponse, RPCBatchResponse]: """Fully handle request. The dispatch method determines which method to call, calls it and returns a response containing a result. No exceptions will be thrown, rather, every exception will be turned into a response using :py:func:`~tinyrpc.RPCRequest.error_respond`. If a method isn't found, a :py:exc:`~tinyrpc.exc.MethodNotFoundError` response will be returned. If any error occurs outside of the requested method, a :py:exc:`~tinyrpc.exc.ServerError` without any error information will be returned. If the method is found and called but throws an exception, the exception thrown is used as a response instead. This is the only case in which information from the exception is possibly propagated back to the client, as the exception is part of the requested method. :py:class:`~tinyrpc.RPCBatchRequest` instances are handled by handling all its children in order and collecting the results, then returning an :py:class:`~tinyrpc.RPCBatchResponse` with the results. :param request: The request containing the function to be called and its parameters. :type request: ~tinyrpc.protocols.RPCRequest or ~tinyrpc.protocols.RPCBatchRequest :param caller: An optional callable used to invoke the method. :type caller: callable :return: The result produced by calling the requested function. :rtype: ~tinyrpc.protocols.RPCResponse or ~tinyrpc.protocols.RPCBatchResponse :raises ~exc.MethodNotFoundError: If the requested function is not published. :raises ~exc.ServerError: If some other error occurred. .. Note:: The :py:exc:`~tinyrpc.exc.ServerError` is raised for any kind of exception not raised by the called function itself or :py:exc:`~tinyrpc.exc.MethodNotFoundError`. """ if hasattr(request, 'create_batch_response'): results = [self._dispatch(req, caller) for req in request] response = request.create_batch_response() if response is not None: response.extend(results) return response else: return self._dispatch(request, caller) def _dispatch(self, request, caller): try: method = self.get_method(request.method) except exc.MethodNotFoundError as e: return request.error_respond(e) except Exception: # unexpected error, do not let client know what happened return request.error_respond(exc.ServerError()) # we found the method try: if self.validator is not None: self.validator(method, request.args, request.kwargs) if caller is not None: result = caller(method, request.args, request.kwargs) else: result = method(*request.args, **request.kwargs) except Exception as e: # an error occurred within the method, return it return request.error_respond(e) # respond with result return request.respond(result) @staticmethod def validate_parameters( method: Callable, args: List[Any], kwargs: Dict[str, Any] ) -> None: """Verify that `*args` and `**kwargs` are appropriate parameters for `method`. .. Warning:: This function has changed to a static function. This will make it easier to replace it with a regular function instead of having to subclass only to replace it. :param method: A callable. :param args: List of positional arguments for `method` :param kwargs: Keyword arguments for `method` :raises ~tinyrpc.exc.InvalidParamsError: Raised when the provided arguments are not acceptable for `method`. """ if hasattr(method, '__code__'): try: inspect.getcallargs(method, *args, **kwargs) except TypeError: raise exc.InvalidParamsError() validator = validate_parameters """Dispatched function parameter validation. :type: callable By default this attribute is set to :py:func:`validate_parameters`. The value can be set to any callable implementing the same interface as :py:func:`validate_parameters` or to `None` to disable validation entirely. """ ================================================ FILE: tinyrpc/exc.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- from abc import ABC class RPCError(Exception, ABC): """Base class for all exceptions thrown by :py:mod:`tinyrpc`.""" def error_respond(self): """Converts the error to an error response object. :returns: An error response instance or ``None`` if the protocol decides to drop the error silently. :rtype: :py:class:`~tinyrpc.protocols.RPCErrorResponse` """ raise NotImplementedError() class BadRequestError(RPCError, ABC): """Base class for all errors that caused the processing of a request to abort before a request object could be instantiated.""" class BadReplyError(RPCError, ABC): """Base class for all errors that caused processing of a reply to abort before it could be turned in a response object.""" class InvalidRequestError(BadRequestError, ABC): """A request made was malformed (i.e. violated the specification) and could not be parsed.""" class InvalidReplyError(BadReplyError, ABC): """A reply received was malformed (i.e. violated the specification) and could not be parsed into a response.""" class UnexpectedIDError (InvalidReplyError, ABC): """A reply received contained an invalid unique identifier.""" class MethodNotFoundError(RPCError, ABC): """The desired method was not found.""" class InvalidParamsError(RPCError, ABC): """The provided parameters do not match those of the desired method.""" class ServerError(RPCError, ABC): """An internal error in the RPC system occurred.""" class TimeoutError(Exception): """No reply received within the timeout period.""" ================================================ FILE: tinyrpc/protocols/__init__.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- """Protocol definition. Defines the abstract base classes from which a protocol definition must be constructed. """ from abc import ABC from typing import Any, Generator, List, Dict, Union, Optional import itertools from tinyrpc import exc class RPCRequest(object): """Defines a generic RPC request.""" def __init__(self) -> None: self.unique_id = None """Correlation ID used to match request and response. :type: int or str or None Protocol specific, may or may not be set. This value should only be set by :py:func:`~tinyrpc.protocols.RPCProtocol.create_request`. When the protocol permits it this ID allows servers to respond to requests out of order and allows clients to relate a response to the corresponding request. Only supported if the protocol has its :py:attr:`~tinyrpc.protocols.RPCProtocol.supports_out_of_order` set to ``True``. Generated by the client, the server copies it from request to corresponding response. """ self.method = None """The name of the RPC function to be called. :type: str The :py:attr:`method` attribute uses the name of the function as it is known by the public. The :py:class:`~tinyrpc.dispatch.RPCDispatcher` allows the use of public aliases in the ``@public`` decorators. These are the names used in the :py:attr:`method` attribute. """ self.args = [] """The positional arguments of the method call. :type: list The contents of this list are the positional parameters for the :py:attr:`method` called. It is eventually called as ``method(*args)``. """ self.kwargs = {} """The keyword arguments of the method call. :type: dict The contents of this dict are the keyword parameters for the :py:attr:`method` called. It is eventually called as ``method(**kwargs)``. """ def error_respond(self, error: Union[Exception, str] ) -> Optional['RPCErrorResponse']: """Creates an error response. Create a response indicating that the request was parsed correctly, but an error has occurred trying to fulfill it. This is an abstract method that must be overridden in a derived class. :param error: An exception or a string describing the error. :type error: Exception or str :return: A response or ``None`` to indicate that no error should be sent out. :rtype: :py:class:`RPCErrorResponse` """ raise NotImplementedError() def respond(self, result: Any) -> Optional['RPCResponse']: """Create a response. Call this to return the result of a successful method invocation. This creates and returns an instance of a protocol-specific subclass of :py:class:`~tinyrpc.RPCResponse`. This is an abstract method that must be overridden in a derived class. :param result: Passed on to new response instance. :type result: Any type that can be serialized by the protocol. :return: A response or ``None`` to indicate this request does not expect a response. :rtype: :py:class:`RPCResponse` """ raise NotImplementedError() def serialize(self) -> bytes: """Returns a serialization of the request. Converts the request into a bytes object that can be passed to and by the transport layer. This is an abstract method that must be overridden in a derived class. :return: A bytes object to be passed on to a transport. :rtype: bytes """ raise NotImplementedError() class RPCBatchRequest(list): """Multiple requests batched together. Protocols that support multiple requests in a single message use this to group them together. Note that not all protocols may support batch requests. Handling a batch requests is done in any order, responses must be gathered in a batch response and be in the same order as their respective requests. Any item of a batch request is either an :py:class:`RPCRequest` or an :py:class:`~tinyrpc.exc.BadRequestError`, which indicates that there has been an error in parsing the request. """ def create_batch_response(self) -> Optional['RPCBatchResponse']: """Creates a response suitable for responding to this request. This is an abstract method that must be overridden in a derived class. :return: An :py:class:`RPCBatchResponse` or None if no response is expected. :rtype: :py:class:`RPCBatchResponse` """ raise NotImplementedError() def serialize(self) -> bytes: """Returns a serialization of the request. Converts the request into a bytes object that can be passed to and by the transport layer. This is an abstract method that must be overridden in a derived class. :return: A bytes object to be passed on to a transport. :rtype: bytes """ raise NotImplementedError() class RPCResponse(ABC): """Defines a generic RPC response. Base class for all responses. .. py:attribute:: id Correlation ID to match request and response :type: str or int .. py:attribute:: result When present this attribute contains the result of the RPC call. Otherwise the :py:attr:`error` attribute must be defined. :type: Any type that can be serialized by the protocol. .. py:attribute:: error When present the :py:attr:`result` attribute must be absent. Presence of this attribute indicates an error condition. :type: :py:class:`~tinyrpc.exc.RPCError` """ def __init__(self) -> None: self.unique_id = None """Correlation ID used to match request and response. :type: int or str or None """ def serialize(self) -> bytes: """Returns a serialization of the response. Converts the response into a bytes object that can be passed to and by the transport layer. This is an abstract method that must be overridden in a derived class. :return: The serialized encoded response object. :rtype: bytes """ raise NotImplementedError() class RPCErrorResponse(RPCResponse, ABC): """RPC error response class. Base class for all deriving responses. .. py:attribute:: error This attribute contains the fields ``message`` (str) and ``code`` (int) where at least ``message`` is required to contain a value. :type: dict """ error = None class RPCBatchResponse(list): """Multiple response from a batch request. See :py:class:`RPCBatchRequest` on how to handle. Items in a batch response need to be :py:class:`RPCResponse` instances or None, meaning no reply should generated for the request. """ def serialize(self) -> bytes: """Returns a serialization of the batch response. Converts the response into a bytes object that can be passed to and by the transport layer. This is an abstract method that must be overridden in a derived class. :return: A bytes object to be passed on to a transport. :rtype: bytes """ raise NotImplementedError() class RPCProtocol(ABC): """Abstract base class for all protocol implementations.""" supports_out_of_order = False """If true, this protocol can receive responses out of order correctly. Note that this usually depends on the generation of unique_ids, the generation of these may or may not be thread safe, depending on the protocol. Ideally, only one instance of RPCProtocol should be used per client. :type: bool """ raises_errors = True """If True, this protocol instance will raise an RPCError exception. On receipt of an RPCErrorResponse instance an RPCError exception is raised. When this flag is False the RPCErrorResponse object is returned to the caller which is then responsible for handling the error. :type: bool """ def create_request( self, method: str, args: List[Any] = None, kwargs: Dict[str, Any] = None, one_way: bool = False ) -> 'RPCRequest': """Creates a new :py:class:`RPCRequest` object. Called by the client when constructing a request. It is up to the implementing protocol whether or not ``args``, ``kwargs``, one of these, both at once or none of them are supported. :param str method: The method name to invoke. :param list args: The positional arguments to call the method with. :param dict kwargs: The keyword arguments to call the method with. :param bool one_way: The request is an update, i.e. it does not expect a reply. :return: A new request instance :rtype: :py:class:`RPCRequest` """ raise NotImplementedError() def parse_request(self, data: bytes) -> 'RPCRequest': """De-serializes and validates a request. Called by the server to reconstruct the serialized :py:class:`RPCRequest`. :param bytes data: The data stream received by the transport layer containing the serialized request. :return: A reconstructed request. :rtype: :py:class:`RPCRequest` """ raise NotImplementedError() def parse_reply(self, data: bytes) -> Union['RPCResponse', 'RPCBatchResponse']: """De-serializes and validates a response. Called by the client to reconstruct the serialized :py:class:`RPCResponse`. :param bytes data: The data stream received by the transport layer containing the serialized response. :return: A reconstructed response. :rtype: :py:class:`RPCResponse` """ raise NotImplementedError() def raise_error(self, error: 'RPCErrorResponse') -> exc.RPCError: """Raises the exception in the client. Called by the client to convert the :py:class:`RPCErrorResponse` into an Exception and raise or return it depending on the :py:attr:`raises_errors` attribute. :param error: The error response received from the server. :type error: :py:class:`RPCResponse` :rtype: :py:exc:`~tinyrpc.exc.RPCError` when :py:attr:`raises_errors` is False. :raises: :py:exc:`~tinyrpc.exc.RPCError` when :py:attr:`raises_errors` is True. """ ex = exc.RPCError( 'Error calling remote procedure: %s' % error.error['message'] ) if self.raises_errors: raise ex return ex class RPCBatchProtocol(RPCProtocol, ABC): """Abstract base class for all batch protocol implementations.""" def create_batch_request( self, requests: List['RPCRequest'] = None ) -> 'RPCBatchRequest': """Create a new :py:class:`RPCBatchRequest` object. Called by the client when constructing a request. :param requests: A list of requests. :type requests: :py:class:`list` or :py:class:`RPCRequest` :return: A new request instance. :rtype: :py:class:`RPCBatchRequest` """ raise NotImplementedError() def default_id_generator(start: int = 1) -> Generator[int, None, None]: """Generates sequential integers from `start`. e.g. 1, 2, 3, .. 9, 10, 11, ... :param start: The first value to start with.` :type start: int :return: A generator that yields a sequence of integers. :rtype: :py:class:`Generator[int, None, None]` """ return itertools.count(start) ================================================ FILE: tinyrpc/protocols/jsonrpc.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- """JSON RPC 2.0 Protocol implementation. This module can use the jsonext_ package to make it easier to convert to JSON. In order to use jsonext import it before importing tinyrpc. Tinyrpc will detect the presence of jsonext and use it automatically. .. _jsonext: https://pypi.org/project/jsonext """ import json import sys from tinyrpc.exc import UnexpectedIDError from typing import Dict, Any, Union, Optional, List, Tuple, Callable, Generator from . import default_id_generator from .. import ( RPCBatchProtocol, RPCRequest, RPCResponse, RPCErrorResponse, InvalidRequestError, MethodNotFoundError, InvalidReplyError, RPCError, RPCBatchRequest, RPCBatchResponse, InvalidParamsError, ) if 'jsonext' in sys.modules: # jsonext was imported before this file, assume the intent is that # it is used in place of the regular json encoder. import jsonext json_dumps = jsonext.dumps else: json_dumps = json.dumps class FixedErrorMessageMixin(object): """Combines JSON RPC exceptions with the generic RPC exceptions. Constructs the exception using the provided parameters as well as properties of the JSON RPC Exception. JSON RPC exceptions declare two attributes: .. py:attribute:: jsonrpc_error_code This is an error code conforming to the JSON RPC `error codes`_ convention. :type: :py:class:`int` .. py:attribute:: message This is a textual representation of the error code. :type: :py:class:`str` :param list args: Positional arguments for the constructor. When present it overrules the :py:attr:`message` attribute. :param dict kwargs: Keyword arguments for the constructor. If the ``data`` parameter is found in ``kwargs`` its contents are used as the *data* property of the JSON RPC Error object. :py:class:`FixedErrorMessageMixin` is the basis for adding your own exceptions to the predefined ones. Here is a version of the reverse string example that dislikes palindromes: .. code-block:: python class PalindromeError(FixedErrorMessageMixin, Exception) jsonrpc_error_code = 99 message = "Ah, that's cheating" @public def reverse_string(s): r = s[::-1] if r == s: raise PalindromeError(data=s) return r >>> client.reverse('rotator') Will return an error object to the client looking like: .. code-block:: json { "jsonrpc": "2.0", "id": 1, "error": { "code": 99, "message": "Ah, that's cheating", "data": "rotator" } } .. _error codes: https://www.jsonrpc.org/specification#error_object """ def __init__(self, *args, **kwargs) -> None: if not args: args = [self.message] self.request_id = kwargs.pop('request_id', None) if 'data' in kwargs: self.data = kwargs.pop('data') super(FixedErrorMessageMixin, self).__init__(*args, **kwargs) def error_respond(self) -> 'JSONRPCErrorResponse': """Converts the error to an error response object. :return: An error response object ready to be serialized and sent to the client. :rtype: :py:class:`JSONRPCErrorResponse` """ response = JSONRPCErrorResponse() response.error = self.message response.unique_id = self.request_id response._jsonrpc_error_code = self.jsonrpc_error_code if hasattr(self, 'data'): response.data = self.data return response class JSONRPCParseError(FixedErrorMessageMixin, InvalidRequestError): """The request cannot be decoded or is malformed.""" jsonrpc_error_code = -32700 message = 'Parse error' class JSONRPCInvalidRequestError(FixedErrorMessageMixin, InvalidRequestError): """The request contents are not valid for JSON RPC 2.0""" jsonrpc_error_code = -32600 message = 'Invalid Request' class JSONRPCMethodNotFoundError(FixedErrorMessageMixin, MethodNotFoundError): """The requested method name is not found in the registry.""" jsonrpc_error_code = -32601 message = 'Method not found' class JSONRPCInvalidParamsError(FixedErrorMessageMixin, InvalidRequestError): """The provided parameters are not appropriate for the function called.""" jsonrpc_error_code = -32602 message = 'Invalid params' class JSONRPCInternalError(FixedErrorMessageMixin, InvalidRequestError): """Unspecified error, not in the called function.""" jsonrpc_error_code = -32603 message = 'Internal error' class JSONRPCServerError(FixedErrorMessageMixin, InvalidRequestError): """Unspecified error, this message originates from the called function.""" jsonrpc_error_code = -32000 message = '' class JSONRPCError(FixedErrorMessageMixin, RPCError): """Reconstructs (to some extend) the server-side exception. The client creates this exception by providing it with the ``error`` attribute of the JSON error response object returned by the server. :param dict error: This dict contains the error specification: * code (int): the numeric error code. * message (str): the error description. * data (any): if present, the data attribute of the error """ def __init__( self, error: Union['JSONRPCErrorResponse', Dict[str, Any]] ) -> None: if isinstance(error, JSONRPCErrorResponse): super(JSONRPCError, self).__init__(error.error) self.message = error.error self._jsonrpc_error_code = error._jsonrpc_error_code if hasattr(error, 'data'): self.data = error.data else: super(JSONRPCError, self).__init__(error.message) self.message = error['message'] self._jsonrpc_error_code = error['code'] if 'data' in error: self.data = error['data'] class JSONRPCSuccessResponse(RPCResponse): """Collects the attributes of a successful response message. Contains the fields of a normal (i.e. a non-error) response message. .. py:attribute:: unique_id Correlation ID to match request and response. A JSON RPC response *must* have a defined matching id attribute. ``None`` is not a valid value for a successful response. :type: str or int .. py:attribute:: result Contains the result of the RPC call. :type: Any type that can be serialized by the protocol. """ def _to_dict(self): return { 'jsonrpc': JSONRPCProtocol.JSON_RPC_VERSION, 'id': self.unique_id, 'result': self.result } def serialize(self) -> bytes: """Returns a serialization of the response. Converts the response into a bytes object that can be passed to and by the transport layer. :return: The serialized encoded response object. :rtype: bytes """ return json_dumps(self._to_dict()).encode() class JSONRPCErrorResponse(RPCErrorResponse): """Collects the attributes of an error response message. Contains the fields of an error response message. .. py:attribute:: unique_id Correlation ID to match request and response. ``None`` is a valid ID when the error cannot be matched to a particular request. :type: str or int or None .. py:attribute:: error The error message. A string describing the error condition. :type: str .. py:attribute:: data This field may contain any JSON encodable datum that the server may want to return the client. It may contain additional information about the error condition, a partial result or whatever. Its presence and value are entirely optional. :type: Any type that can be serialized by the protocol. .. py:attribute:: _jsonrpc_error_code The numeric error code. The value is usually predefined by one of the JSON protocol exceptions. It can be set by the developer when defining application specific exceptions. See :py:class:`FixedErrorMessageMixin` for an example on how to do this. Note that the value of this field *must* comply with the defined values in the standard_. .. _standard: https://www.jsonrpc.org/specification#error_object """ def _to_dict(self): msg = { 'jsonrpc': JSONRPCProtocol.JSON_RPC_VERSION, 'id': self.unique_id, 'error': { 'message': str(self.error), 'code': self._jsonrpc_error_code } } if hasattr(self, 'data'): msg['error']['data'] = self.data return msg def serialize(self) -> bytes: """Returns a serialization of the error. Converts the response into a bytes object that can be passed to and by the transport layer. :return: The serialized encoded error object. :rtype: bytes """ return json_dumps(self._to_dict()).encode() def _get_code_message_and_data(error: Union[Exception, str] ) -> Tuple[int, str, Any]: assert isinstance(error, (Exception, str)) data = None if isinstance(error, Exception): if hasattr(error, 'jsonrpc_error_code'): code = error.jsonrpc_error_code msg = str(error) try: data = error.data except AttributeError: pass elif isinstance(error, InvalidRequestError): code = JSONRPCInvalidRequestError.jsonrpc_error_code msg = JSONRPCInvalidRequestError.message elif isinstance(error, MethodNotFoundError): code = JSONRPCMethodNotFoundError.jsonrpc_error_code msg = JSONRPCMethodNotFoundError.message elif isinstance(error, InvalidParamsError): code = JSONRPCInvalidParamsError.jsonrpc_error_code msg = JSONRPCInvalidParamsError.message else: # allow exception message to propagate code = JSONRPCServerError.jsonrpc_error_code if len(error.args) == 2: msg = str(error.args[0]) data = error.args[1] else: msg = str(error) else: code = -32000 msg = error return code, msg, data class JSONRPCRequest(RPCRequest): """Defines a JSON RPC request.""" def __init__(self): super().__init__() self.one_way = False """Request or Notification. :type: bool This flag indicates if the client expects to receive a reply (request: ``one_way = False``) or not (notification: ``one_way = True``). Note that according to the specification it is possible for the server to return an error response. For example if the request becomes unreadable and the server is not able to determine that it is in fact a notification an error should be returned. However, once the server had verified that the request is a notification no reply (not even an error) should be returned. """ self.unique_id = None """Correlation ID used to match request and response. :type: int or str Generated by the client, the server copies it from request to corresponding response. """ self.method = None """The name of the RPC function to be called. :type: str The :py:attr:`method` attribute uses the name of the function as it is known by the public. The :py:class:`~tinyrpc.dispatch.RPCDispatcher` allows the use of public aliases in the ``@public`` decorators. These are the names used in the :py:attr:`method` attribute. """ self.args = [] """The positional arguments of the method call. :type: list The contents of this list are the positional parameters for the :py:attr:`method` called. It is eventually called as ``method(*args)``. """ self.kwargs = {} """The keyword arguments of the method call. :type: dict The contents of this dict are the keyword parameters for the :py:attr:`method` called. It is eventually called as ``method(**kwargs)``. """ def error_respond(self, error: Union[Exception, str] ) -> Optional['JSONRPCErrorResponse']: """Create an error response to this request. When processing the request produces an error condition this method can be used to create the error response object. :param error: Specifies what error occurred. :type error: Exception or str :returns: An error response object that can be serialized and sent to the client. :rtype: ;py:class:`JSONRPCErrorResponse` """ if self.unique_id is None: return None response = JSONRPCErrorResponse() response.unique_id = None if self.one_way else self.unique_id code, msg, data = _get_code_message_and_data(error) response.error = msg response._jsonrpc_error_code = code if data: response.data = data return response def respond(self, result: Any) -> Optional['JSONRPCSuccessResponse']: """Create a response to this request. When processing the request completed successfully this method can be used to create a response object. :param result: The result of the invoked method. :type result: Anything that can be encoded by JSON. :returns: A response object that can be serialized and sent to the client. :rtype: :py:class:`JSONRPCSuccessResponse` """ if self.one_way or self.unique_id is None: return None response = JSONRPCSuccessResponse() response.result = result response.unique_id = self.unique_id return response def _to_dict(self): jdata = { 'jsonrpc': JSONRPCProtocol.JSON_RPC_VERSION, 'method': self.method, } if self.args: jdata['params'] = self.args if self.kwargs: jdata['params'] = self.kwargs if not self.one_way and hasattr( self, 'unique_id') and self.unique_id is not None: jdata['id'] = self.unique_id return jdata def serialize(self) -> bytes: """Returns a serialization of the request. Converts the request into a bytes object that can be sent to the server. :return: The serialized encoded request object. :rtype: bytes """ return json_dumps(self._to_dict()).encode() class JSONRPCBatchRequest(RPCBatchRequest): """Defines a JSON RPC batch request.""" def create_batch_response(self) -> Optional['JSONRPCBatchResponse']: """Produces a batch response object if a response is expected. :return: A batch response if needed :rtype: :py:class:`JSONRPCBatchResponse` """ if self._expects_response(): return JSONRPCBatchResponse() def _expects_response(self): for request in self: if isinstance(request, Exception): return True if not request.one_way and request.unique_id is not None: return True return False def serialize(self) -> bytes: """Returns a serialization of the request. Converts the request into a bytes object that can be passed to and by the transport layer. :return: A bytes object to be passed on to a transport. :rtype: bytes """ return json_dumps([req._to_dict() for req in self]).encode() class JSONRPCBatchResponse(RPCBatchResponse): """Multiple responses from a batch request. See :py:class:`JSONRPCBatchRequest` on how to handle. Items in a batch response need to be :py:class:`JSONRPCResponse` instances or None, meaning no reply should be generated for the request. """ def serialize(self) -> bytes: """Returns a serialization of the batch response. Converts the response into a bytes object that can be passed to and by the transport layer. :return: A bytes object to be passed on to a transport. :rtype: bytes """ return json_dumps([ resp._to_dict() for resp in self if resp is not None ]).encode() class JSONRPCProtocol(RPCBatchProtocol): """JSONRPC protocol implementation.""" JSON_RPC_VERSION = "2.0" """Currently, only version 2.0 is supported.""" _ALLOWED_REPLY_KEYS = sorted(['id', 'jsonrpc', 'error', 'result']) _ALLOWED_REQUEST_KEYS = sorted(['id', 'jsonrpc', 'method', 'params']) def __init__( self, id_generator: Optional[Generator[object, None, None]] = None, *args, **kwargs ) -> None: super(JSONRPCProtocol, self).__init__(*args, **kwargs) self._id_generator = id_generator or default_id_generator() self._pending_replies = [] def _get_unique_id(self) -> object: return next(self._id_generator) def request_factory(self) -> 'JSONRPCRequest': """Factory for request objects. Allows derived classes to use requests derived from :py:class:`JSONRPCRequest`. :rtype: :py:class:`JSONRPCRequest` """ return JSONRPCRequest() def create_batch_request( self, requests: Union['JSONRPCRequest', List['JSONRPCRequest']] = None ) -> 'JSONRPCBatchRequest': """Create a new :py:class:`JSONRPCBatchRequest` object. Called by the client when constructing a request. :param requests: A list of requests. :type requests: :py:class:`list` or :py:class:`JSONRPCRequest` :return: A new request instance. :rtype: :py:class:`JSONRPCBatchRequest` """ return JSONRPCBatchRequest(requests or []) def create_request( self, method: str, args: List[Any] = None, kwargs: Dict[str, Any] = None, one_way: bool = False ) -> 'JSONRPCRequest': """Creates a new :py:class:`JSONRPCRequest` object. Called by the client when constructing a request. JSON RPC allows either the ``args`` or ``kwargs`` argument to be set. :param str method: The method name to invoke. :param list args: The positional arguments to call the method with. :param dict kwargs: The keyword arguments to call the method with. :param bool one_way: The request is an update, i.e. it does not expect a reply. :return: A new request instance :rtype: :py:class:`JSONRPCRequest` :raises InvalidRequestError: when ``args`` and ``kwargs`` are both defined. """ if args and kwargs: raise InvalidRequestError( 'Does not support args and kwargs at ' 'the same time' ) request = self.request_factory() request.one_way = one_way if not one_way: request.unique_id = self._get_unique_id() self._pending_replies.append(request.unique_id) request.method = method if args is not None: request.args = args if kwargs is not None: request.kwargs = kwargs return request def parse_reply( self, data: bytes ) -> Union['JSONRPCSuccessResponse', 'JSONRPCErrorResponse', 'JSONRPCBatchResponse']: """De-serializes and validates a response. Called by the client to reconstruct the serialized :py:class:`JSONRPCResponse`. :param bytes data: The data stream received by the transport layer containing the serialized request. :return: A reconstructed response. :rtype: :py:class:`JSONRPCSuccessResponse` or :py:class:`JSONRPCErrorResponse` :raises InvalidReplyError: if the response is not valid JSON or does not conform to the standard. """ if isinstance(data, bytes): data = data.decode() try: rep = json.loads(data) except Exception as e: raise InvalidReplyError(e) if isinstance(rep, list): # batch request replies = JSONRPCBatchResponse() for subrep in rep: try: replies.append(self._parse_subreply(subrep)) except RPCError as e: replies.append(e) except Exception as e: replies.append(InvalidReplyError(e)) if not replies: raise InvalidReplyError("Empty batch response received.") return replies else: return self._parse_subreply(rep) def _parse_subreply(self, rep): for k in rep.keys(): if k not in self._ALLOWED_REPLY_KEYS: raise InvalidReplyError('Key not allowed: %s' % k) if 'jsonrpc' not in rep: raise InvalidReplyError('Missing jsonrpc (version) in response.') if rep['jsonrpc'] != self.JSON_RPC_VERSION: raise InvalidReplyError('Wrong JSONRPC version') if 'id' not in rep: raise InvalidReplyError('Missing id in response') if ('error' in rep) and ('result' in rep): raise InvalidReplyError( 'Reply must contain exactly one of result and error.' ) if 'error' in rep: response = JSONRPCErrorResponse() error = rep['error'] response.error = error["message"] response._jsonrpc_error_code = error["code"] if "data" in error: response.data = error["data"] else: response = JSONRPCSuccessResponse() response.result = rep.get('result', None) response.unique_id = rep['id'] if response.unique_id not in self._pending_replies: raise UnexpectedIDError( 'Reply id does not correspond to any sent requests.' ) else: self._pending_replies.remove(response.unique_id) return response def parse_request(self, data: bytes ) -> Union['JSONRPCRequest', 'JSONRPCBatchRequest']: """De-serializes and validates a request. Called by the server to reconstruct the serialized :py:class:`JSONRPCRequest`. :param bytes data: The data stream received by the transport layer containing the serialized request. :return: A reconstructed request. :rtype: :py:class:`JSONRPCRequest` :raises JSONRPCParseError: if the ``data`` cannot be parsed as valid JSON. :raises JSONRPCInvalidRequestError: if the request does not comply with the standard. """ if isinstance(data, bytes): data = data.decode() try: req = json.loads(data) except Exception as e: raise JSONRPCParseError() if isinstance(req, list): # batch request requests = JSONRPCBatchRequest() for subreq in req: try: requests.append(self._parse_subrequest(subreq)) except RPCError as e: requests.append(e) except Exception as e: requests.append(JSONRPCInvalidRequestError(request_id=subreq.get("id"))) if not requests: raise JSONRPCInvalidRequestError() return requests else: return self._parse_subrequest(req) def _parse_subrequest(self, req): if not isinstance(req, dict): raise JSONRPCInvalidRequestError() for k in req.keys(): if k not in self._ALLOWED_REQUEST_KEYS: raise JSONRPCInvalidRequestError(request_id=req.get("id")) if req.get('jsonrpc', None) != self.JSON_RPC_VERSION: raise JSONRPCInvalidRequestError(request_id=req.get("id")) if not isinstance(req['method'], str): raise JSONRPCInvalidRequestError(request_id=req.get("id")) request = self.request_factory() request.method = req['method'] request.one_way = 'id' not in req if not request.one_way: request.unique_id = req['id'] params = req.get('params', None) if params is not None: if isinstance(params, list): request.args = req['params'] elif isinstance(params, dict): request.kwargs = req['params'] else: raise JSONRPCInvalidParamsError(request_id=req.get("id")) return request def raise_error( self, error: Union['JSONRPCErrorResponse', Dict[str, Any]] ) -> 'JSONRPCError': """Recreates the exception. Creates a :py:class:`~tinyrpc.protocols.jsonrpc.JSONRPCError` instance and raises it. This allows the error, message and data attributes of the original exception to propagate into the client code. The :py:attr:`~tinyrpc.protocols.RPCProtocol.raises_error` flag controls if the exception object is raised or returned. :returns: the exception object if it is not allowed to raise it. :raises JSONRPCError: when the exception can be raised. The exception object will contain ``message``, ``code`` and optionally a ``data`` property. """ exc = JSONRPCError(error) if self.raises_errors: raise exc return exc def _caller( self, method: Callable, args: List[Any], kwargs: Dict[str, Any] ) -> Any: # Custom dispatcher called by RPCDispatcher._dispatch(). # Override this when you need to call the method with additional parameters for example. return method(*args, **kwargs) ================================================ FILE: tinyrpc/protocols/msgpackrpc.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- from . import default_id_generator from .. import ( RPCError, RPCErrorResponse, RPCProtocol, RPCRequest, RPCResponse, InvalidRequestError, MethodNotFoundError, InvalidReplyError, ) import msgpack from typing import Any, Dict, List, Optional, Tuple, Union, Generator class FixedErrorMessageMixin(object): def __init__(self, *args, **kwargs): if not args: args = [self.message] self.request_id = kwargs.pop("request_id", None) super(FixedErrorMessageMixin, self).__init__(*args, **kwargs) def error_respond(self): response = MSGPACKRPCErrorResponse() response.error = self.message response.unique_id = self.request_id response._msgpackrpc_error_code = self.msgpackrpc_error_code return response class MSGPACKRPCParseError(FixedErrorMessageMixin, InvalidRequestError): msgpackrpc_error_code = -32700 message = "Parse error" class MSGPACKRPCInvalidRequestError(FixedErrorMessageMixin, InvalidRequestError): msgpackrpc_error_code = -32600 message = "Invalid request" class MSGPACKRPCMethodNotFoundError(FixedErrorMessageMixin, MethodNotFoundError): msgpackrpc_error_code = -32601 message = "Method not found" class MSGPACKRPCInvalidParamsError(FixedErrorMessageMixin, InvalidRequestError): msgpackrpc_error_code = -32602 message = "Invalid params" class MSGPACKRPCInternalError(FixedErrorMessageMixin, InvalidRequestError): msgpackrpc_error_code = -32603 message = "Internal error" class MSGPACKRPCServerError(FixedErrorMessageMixin, InvalidRequestError): msgpackrpc_error_code = -32000 message = "" class MSGPACKRPCError(FixedErrorMessageMixin, RPCError): """Reconstructs (to some extend) the server-side exception. The client creates this exception by providing it with the ``error`` attribute of the MSGPACK error response object returned by the server. :param error: This tuple contains the error specification: the numeric error code and the error description. """ def __init__( self, error: Union["MSGPACKRPCErrorResponse", Tuple[int, str]] ) -> None: if isinstance(error, MSGPACKRPCErrorResponse): super().__init__(error.error) self._msgpackrpc_error_code = error._msgpackrpc_error_code else: super().__init__() self._msgpackrpc_error_code, self.message = error class MSGPACKRPCSuccessResponse(RPCResponse): def _to_list(self): return [1, self.unique_id, None, self.result] def serialize(self): return msgpack.packb(self._to_list(), use_bin_type=True) class MSGPACKRPCErrorResponse(RPCErrorResponse): def _to_list(self): return [1, self.unique_id, [self._msgpackrpc_error_code, str(self.error)], None] def serialize(self): return msgpack.packb(self._to_list(), use_bin_type=True) def _get_code_and_message(error): assert isinstance(error, (Exception, str)) if isinstance(error, Exception): if hasattr(error, "msgpackrpc_error_code"): code = error.msgpackrpc_error_code msg = str(error) elif isinstance(error, InvalidRequestError): code = MSGPACKRPCInvalidRequestError.msgpackrpc_error_code msg = MSGPACKRPCInvalidRequestError.message elif isinstance(error, MethodNotFoundError): code = MSGPACKRPCMethodNotFoundError.msgpackrpc_error_code msg = MSGPACKRPCMethodNotFoundError.message else: # allow exception message to propagate code = MSGPACKRPCServerError.msgpackrpc_error_code msg = str(error) else: code = -32000 msg = error return code, msg class MSGPACKRPCRequest(RPCRequest): """Defines a MSGPACK-RPC request.""" def __init__(self): super().__init__() self.one_way = False """Request or Notification. :type: bool This flag indicates if the client expects to receive a reply (request: ``one_way = False``) or not (notification: ``one_way = True``). Note that it is possible for the server to return an error response. For example if the request becomes unreadable and the server is not able to determine that it is in fact a notification an error should be returned. However, once the server had verified that the request is a notification no reply (not even an error) should be returned. """ self.unique_id = None """Correlation ID used to match request and response. :type: int Generated by the client, the server copies it from request to corresponding response. """ self.method = None """The name of the RPC function to be called. :type: str The :py:attr:`method` attribute uses the name of the function as it is known by the public. The :py:class:`~tinyrpc.dispatch.RPCDispatcher` allows the use of public aliases in the ``@public`` decorators. These are the names used in the :py:attr:`method` attribute. """ self.args = [] """The positional arguments of the method call. :type: list The contents of this list are the positional parameters for the :py:attr:`method` called. It is eventually called as ``method(*args)``. """ def error_respond( self, error: Union[Exception, str] ) -> Optional["MSGPACKRPCErrorResponse"]: """Create an error response to this request. When processing the request produces an error condition this method can be used to create the error response object. :param error: Specifies what error occurred. :type error: Exception or str :returns: An error response object that can be serialized and sent to the client. :rtype: ;py:class:`MSGPACKRPCErrorResponse` """ if not self.unique_id: return None response = MSGPACKRPCErrorResponse() response.unique_id = None if self.one_way else self.unique_id code, msg = _get_code_and_message(error) response.error = msg response._msgpackrpc_error_code = code return response def respond(self, result: Any) -> Optional["MSGPACKRPCSuccessResponse"]: """Create a response to this request. When processing the request completed successfully this method can be used to create a response object. :param result: The result of the invoked method. :type result: Anything that can be encoded by MSGPACK. :returns: A response object that can be serialized and sent to the client. :rtype: :py:class:`MSGPACKRPCSuccessResponse` """ if self.one_way or self.unique_id is None: return None response = MSGPACKRPCSuccessResponse() response.result = result response.unique_id = self.unique_id return response def _to_list(self): if self.one_way or self.unique_id is None: return [2, self.method, self.args if self.args is not None else []] else: return [ 0, self.unique_id, self.method, self.args if self.args is not None else [], ] def serialize(self) -> bytes: return msgpack.packb(self._to_list(), use_bin_type=True) class MSGPACKRPCProtocol(RPCProtocol): """MSGPACKRPC protocol implementation.""" def __init__( self, id_generator: Optional[Generator[object, None, None]] = None, *args, **kwargs ) -> None: super(MSGPACKRPCProtocol, self).__init__(*args, **kwargs) self._id_generator = id_generator or default_id_generator() def _get_unique_id(self): return next(self._id_generator) def request_factory(self) -> "MSGPACKRPCRequest": """Factory for request objects. Allows derived classes to use requests derived from :py:class:`MSGPACKRPCRequest`. :rtype: :py:class:`MSGPACKRPCRequest` """ return MSGPACKRPCRequest() def create_request( self, method: str, args: List[Any] = None, kwargs: Dict[str, Any] = None, one_way: bool = False, ) -> "MSGPACKRPCRequest": """Creates a new :py:class:`MSGPACKRPCRequest` object. Called by the client when constructing a request. MSGPACK-RPC allows only the ``args`` argument to be set; keyword arguments are not supported. :param str method: The method name to invoke. :param list args: The positional arguments to call the method with. :param dict kwargs: The keyword arguments to call the method with; must be ``None`` as the protocol does not support keyword arguments. :param bool one_way: The request is an update, i.e. it does not expect a reply. :return: A new request instance :rtype: :py:class:`MSGPACKRPCRequest` :raises InvalidRequestError: when ``kwargs`` is defined. """ if kwargs: raise MSGPACKRPCInvalidRequestError("Does not support kwargs") request = self.request_factory() request.one_way = one_way if not one_way: request.unique_id = self._get_unique_id() request.method = method request.args = list(args) if args is not None else [] request.kwargs = None return request def parse_reply( self, data: bytes ) -> Union["MSGPACKRPCSuccessResponse", "MSGPACKRPCErrorResponse"]: """De-serializes and validates a response. Called by the client to reconstruct the serialized :py:class:`MSGPACKRPCResponse`. :param bytes data: The data stream received by the transport layer containing the serialized response. :return: A reconstructed response. :rtype: :py:class:`MSGPACKRPCSuccessResponse` or :py:class:`MSGPACKRPCErrorResponse` :raises InvalidReplyError: if the response is not valid MSGPACK or does not conform to the standard. """ try: rep = msgpack.unpackb(data, raw=False) except Exception as e: raise InvalidReplyError(e) if len(rep) != 4: raise InvalidReplyError("MSGPACKRPC spec requires reply of length 4") if rep[0] != 1: raise InvalidReplyError("Invalid MSGPACK message type") if not isinstance(rep[1], int): raise InvalidReplyError("Invalid or missing message ID in response") if rep[2] is not None and rep[3] is not None: raise InvalidReplyError("Reply must contain only one of result and error.") if rep[2] is not None: response = MSGPACKRPCErrorResponse() if isinstance(rep[2], list) and len(rep[2]) == 2: code, message = rep[2] if isinstance(code, int) and isinstance(message, str): response.error = str(message) response._msgpackrpc_error_code = int(code) else: response.error = rep[2] response._msgpackrpc_error_code = None else: response.error = rep[2] response._msgpackrpc_error_code = None else: response = MSGPACKRPCSuccessResponse() response.result = rep[3] response.unique_id = rep[1] return response def parse_request(self, data: bytes) -> "MSGPACKRPCRequest": """De-serializes and validates a request. Called by the server to reconstruct the serialized :py:class:`MSGPACKRPCRequest`. :param bytes data: The data stream received by the transport layer containing the serialized request. :return: A reconstructed request. :rtype: :py:class:`MSGPACKRPCRequest` :raises MSGPACKRPCParseError: if the ``data`` cannot be parsed as valid MSGPACK. :raises MSGPACKRPCInvalidRequestError: if the request does not comply with the standard. """ try: req = msgpack.unpackb(data, raw=False) except Exception: raise MSGPACKRPCParseError() if not isinstance(req, list): raise MSGPACKRPCInvalidRequestError() if len(req) < 2: raise MSGPACKRPCInvalidRequestError() if req[0] == 0: # MSGPACK request request_id = req[1] if not isinstance(request_id, int): raise MSGPACKRPCInvalidRequestError() if len(req) == 4: return self._parse_request(req) else: raise MSGPACKRPCInvalidRequestError(request_id=request_id) elif req[0] == 2: # MSGPACK notification if len(req) == 3: return self._parse_notification(req) else: raise MSGPACKRPCInvalidRequestError() else: raise MSGPACKRPCInvalidRequestError() def _parse_notification(self, req): if not isinstance(req[1], str): raise MSGPACKRPCInvalidRequestError() request = MSGPACKRPCRequest() request.one_way = True request.method = req[1] params = req[2] # params should not be None according to the spec; if there are # no params, an empty array must be used if isinstance(params, list): request.args = params else: raise MSGPACKRPCInvalidParamsError(request_id=req[1]) return request def _parse_request(self, req): if not isinstance(req[2], str): raise MSGPACKRPCInvalidRequestError(request_id=req[1]) request = MSGPACKRPCRequest() request.one_way = False request.method = req[2] request.unique_id = req[1] params = req[3] # params should not be None according to the spec; if there are # no params, an empty array must be used if isinstance(params, list): request.args = params else: raise MSGPACKRPCInvalidParamsError(request_id=req[1]) return request def raise_error( self, error: Union["MSGPACKRPCErrorResponse", Dict[str, Any]] ) -> "MSGPACKRPCError": """Recreates the exception. Creates a :py:class:`~tinyrpc.protocols.msgpackrpc.MSGPACKRPCError` instance and raises it. This allows the error code and the message of the original exception to propagate into the client code. The :py:attr:`~tinyrpc.protocols.MSGPACKProtocol.raises_error` flag controls if the exception object is raised or returned. :returns: the exception object if it is not allowed to raise it. :raises MSGPACKRPCError: when the exception can be raised. The exception object will contain ``message`` and ``code``. """ exc = MSGPACKRPCError(error) if self.raises_errors: raise exc return exc ================================================ FILE: tinyrpc/server/__init__.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- """Server definition. Defines and implements a single-threaded, single-process, synchronous server. """ # FIXME: needs (more) unittests # FIXME: needs checks for out-of-order, concurrency, etc as attributes from typing import Any, Callable import tinyrpc.exc from tinyrpc import RPCProtocol from tinyrpc.dispatch import RPCDispatcher from tinyrpc.transports import ServerTransport class RPCServer(object): """High level RPC server. The server is completely generic only assuming some form of RPC communication is intended. Protocol, data transport and method dispatching are injected into the server object. :param transport: The data transport mechanism to use. :param protocol: The RPC protocol to use. :param dispatcher: The dispatching mechanism to use. :type transport: :py:class:`~tinyrpc.transports.ServerTransport` :type protocol: :py:class:`~tinyrpc.protocols.RPCProtocol` :type dispatcher: :py:class:`~tinyrpc.dispatch.RPCDispatcher` """ trace = None """Trace incoming and outgoing messages. When this attribute is set to a callable this callable will be called directly after a message has been received and immediately after a reply is sent. The callable should accept three positional parameters: :param str direction: Either '-->' for incoming or '<--' for outgoing data. :param any context: The context returned by :py:meth:`~tinyrpc.transports.ServerTransport.receive_message`. :param bytes message: The message itself. Example: .. code-block:: python def my_trace(direction, context, message): logger.debug('%s%s', direction, message) server = RPCServer(transport, protocol, dispatcher) server.trace = my_trace server.serve_forever() will log all incoming and outgoing traffic of the RPC service. Note that the ``message`` will be the data stream that is transported, not the interpreted meaning of that data. It is therefore possible that the binary stream is unreadable without further translation. """ def __init__( self, transport: ServerTransport, protocol: RPCProtocol, dispatcher: RPCDispatcher ): self.transport = transport self.protocol = protocol self.dispatcher = dispatcher self.trace = None def serve_forever(self) -> None: """Handle requests forever. Starts the server loop; continuously calling :py:meth:`receive_one_message` to process the next incoming request. """ while True: self.receive_one_message() def receive_one_message(self) -> None: """Handle a single request. Polls the transport for a new message. After a new message has arrived :py:meth:`_spawn` is called with a handler function and arguments to handle the request. The handler function will try to decode the message using the supplied protocol, if that fails, an error response will be sent. After decoding the message, the dispatcher will be asked to handle the resulting request and the return value (either an error or a result) will be sent back to the client using the transport. """ context, message = self.transport.receive_message() if callable(self.trace): self.trace('-->', context, message) # assuming protocol is thread-safe and dispatcher is thread-safe, as # long as its immutable def handle_message(context: Any, message: bytes) -> None: """Parse, process and reply a single request.""" try: request = self.protocol.parse_request(message) except tinyrpc.exc.RPCError as e: response = e.error_respond() else: response = self.dispatcher.dispatch( request, getattr(self.protocol, '_caller', None) ) # send reply if response is not None: result = response.serialize() if callable(self.trace): self.trace('<--', context, result) self.transport.send_reply(context, result) self._spawn(handle_message, context, message) def _spawn(self, func: Callable, *args, **kwargs): """Spawn a handler function. This function is overridden in subclasses to provide concurrency. In the base implementation, it simply calls the supplied function ``func`` with ``*args`` and ``**kwargs``. This results in a single-threaded, single-process, synchronous server. :param func: A callable to call. :param args: Arguments to ``func``. :param kwargs: Keyword arguments to ``func``. """ func(*args, **kwargs) ================================================ FILE: tinyrpc/server/gevent.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- """Server definition. Defines and implements a single-threaded, single-process, asynchronous server. """ from typing import Callable import gevent from . import RPCServer class RPCServerGreenlets(RPCServer): """Asynchronous RPCServer. This implementation of :py:class:`~tinyrpc.server.RPCServer` uses :py:func:`gevent.spawn` to spawn new client handlers, resulting in asynchronous handling of clients using greenlets. """ def _spawn(self, func: Callable, *args, **kwargs): """Spawn a handler function. Spawns the supplied ``func`` with ``*args`` and ``**kwargs`` as a gevent greenlet. :param callable func: A callable to call. :param list args: Arguments to ``func``. :param dict kwargs: Keyword arguments to ``func``. """ gevent.spawn(func, *args, **kwargs) def start(self): """ Create a Greenlet with serve_forever so you can do a gevent.joinall of several RPCServerGreenlets """ return gevent.spawn(self.serve_forever) ================================================ FILE: tinyrpc/transports/__init__.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- from typing import Any, Tuple class ServerTransport(object): """Abstract base class for all server transports. The server side implementation of the transport component. Requests and replies encoded by the protocol component are exchanged between client and server using the :py:class:`ServerTransport` and :py:class:`ClientTransport` classes. """ def receive_message(self) -> Tuple[Any, bytes]: """Receive a message from the transport. Blocks until a message has been received. May return an opaque context object to its caller that should be passed on to :py:func:`~tinyrpc.transport.ServerTransport.send_reply` to identify the transport or requester later on. Use and function of the context object are entirely controlled by the transport instance. The message must be treated as a binary entity as only the protocol level will know how to interpret the message. If the transport encodes the message in some way, the opposite end is responsible for decoding it before it is passed to either client or server. :return: A tuple consisting of ``(context, message)``. Where ``context`` can be any valid Python type and ``message`` must be a :py:class:`bytes` object. """ raise NotImplementedError() def send_reply(self, context: Any, reply: bytes) -> None: """Sends a reply to a client. The client is usually identified by passing ``context`` as returned from the original :py:meth:`receive_message` call. The reply must be a bytes object since only the protocol level will know how to construct the reply. :param any context: A context returned by :py:meth:`receive_message`. :param bytes reply: The reply to return to the client. """ raise NotImplementedError class ClientTransport(object): """Abstract base class for all client transports. The client side implementation of the transport component. Requests and replies encoded by the protocol component are exchanged between client and server using the :py:class:`ServerTransport` and :py:class:`ClientTransport` classes. """ def send_message(self, message: bytes, expect_reply: bool = True) -> bytes: """Send a message to the server and possibly receive a reply. Sends a message to the connected server. The message must be treated as a binary entity as only the protocol level will know how to interpret the message. If the transport encodes the message in some way, the opposite end is responsible for decoding it before it is passed to either client or server. This function will block until the reply has been received. :param bytes message: The request to send to the server. :param bool expect_reply: Some protocols allow notifications for which a reply is not expected. When this flag is ``False`` the transport may not wait for a response from the server. **Note** that it is still the responsibility of the transport layer how to implement this. It is still possible that the server sends some form of reply regardless the value of this flag. :return: The servers reply to the request. :rtype: bytes """ raise NotImplementedError ================================================ FILE: tinyrpc/transports/callback.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- from typing import Callable, Tuple, Any from . import ServerTransport class CallbackServerTransport(ServerTransport): """Callback server transport. The :py:class:`CallbackServerTransport` uses the provided callbacks to implement communication with the counterpart. Used when tinyrpc is part of a system where it cannot directly attach to a socket or stream. The methods :py:meth:`receive_message` and :py:meth:`send_reply` are implemented by callback functions that are set when constructed. :param callable reader: Called when the transport wants to receive a new request. :returns: The RPC request. :rtype: bytes :param callable writer(reply): Called to return the response to the client. :param bytes reply: The response to the request. """ def __init__( self, reader: Callable[[], bytes], writer: Callable[[bytes], None] ) -> None: super(CallbackServerTransport, self).__init__() self.reader = reader self.writer = writer def receive_message(self) -> Tuple[Any, bytes]: """Receive a message from the transport. Uses the callback function :py:attr:`reader` to obtain a :py:class:`bytes` ``message``. May return a context opaque to clients that should be passed on to :py:meth:`send_reply` to identify the client later on. :return: A tuple consisting of ``(context, message)``. """ return None, self.reader() def send_reply(self, context: Any, reply: bytes): """Sends a reply to a client. The client is usually identified by passing ``context`` as returned from the original :py:meth:`receive_message` call. Uses the callback function :py:attr:`writer` to forward the reply. :param any context: A context returned by :py:meth:`receive_message`. :param bytes reply: The reply. """ self.writer(reply) ================================================ FILE: tinyrpc/transports/cgi.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- import os import sys import urllib.parse as urlparse from typing import Any, Tuple from . import ServerTransport class CGIServerTransport(ServerTransport): """CGI transport. The CGIServerTransport adds CGI as a supported server protocol. It can be used with the regular HTTP client. Reading stdin is blocking but, given that we've been called, something is waiting. The transport accepts only POST requests. A POST request provides the entire JSON-RPC request in the body of the HTTP request. """ def receive_message(self) -> Tuple[Any, bytes]: """Receive a message from the transport. Blocks until a message has been received. May return a context opaque to clients that should be passed to :py:func:`send_reply` to identify the client later on. :return: A tuple consisting of ``(context, message)``. """ if not ('REQUEST_METHOD' in os.environ and os.environ['REQUEST_METHOD'] == 'POST'): print("Status: 405 Method not Allowed; only POST is accepted") exit(0) # POST content_length = int(os.environ['CONTENT_LENGTH']) request_json = sys.stdin.read(content_length) request_json = urlparse.unquote(request_json) # context isn't used with cgi return None, request_json def send_reply(self, context: Any, reply: bytes) -> None: """Sends a reply to a client. The client is usually identified by passing ``context`` as returned from the original :py:func:`receive_message` call. Messages must be bytes, it is up to the sender to convert the message beforehand. A non-bytes value raises a :py:exc:`TypeError`. :param any context: A context returned by :py:func:`receive_message`. :param bytes reply: A binary to send back as the reply. """ # context isn't used with cgi # Using sys.stdout.buffer.write() fails as stdout is on occasion monkey patched # to AsyncFile which doesn't support the buffer attribute. print("Status: 200 OK") print("Content-Type: application/json") print("Cache-Control: no-cache") print("Pragma: no-cache") print("Content-Length: %d" % len(reply)) print() print(reply.decode()) ================================================ FILE: tinyrpc/transports/http.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- from typing import Callable, Dict import requests from . import ClientTransport class HttpPostClientTransport(ClientTransport): """HTTP POST based client transport. Requires :py:mod:`requests`. Submits messages to a server using the body of an ``HTTP`` ``POST`` request. Replies are taken from the responses body. :param str endpoint: The URL to send ``POST`` data to. :param callable post_method: allows to replace `requests.post` with another method, e.g. the post method of a `requests.Session()` instance. :param dict kwargs: Additional parameters for :py:func:`requests.post`. """ def __init__( self, endpoint: str, post_method: Callable = None, **kwargs: Dict ): self.endpoint = endpoint self.request_kwargs = kwargs if post_method is None: self.post = requests.post else: self.post = post_method def send_message(self, message: bytes, expect_reply: bool = True): if not isinstance(message, bytes): raise TypeError('message must by of type bytes') r = self.post(self.endpoint, data=message, **self.request_kwargs) if expect_reply: # Note that this is not strictly conforming to the (JSON RPC) standard since # even notifications may, under certain conditions, return an # error message which is completely ignored by this implementation. return r.content ================================================ FILE: tinyrpc/transports/rabbitmq.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- from typing import Tuple, Any import pika from . import ServerTransport, ClientTransport class RabbitMQServerTransport(ServerTransport): """Server transport based on a :py:class:`pika.BlockingConnection`. The transport assumes a RabbitMQ topology has already been established. :param connection: A :py:class:`pika.BlockingConnection` instance. :param queue: The RabbitMQ queue to consume messages from. :param exchange: The RabbitMQ exchange to use. """ def __init__(self, connection: pika.BlockingConnection, queue: str, exchange: str = '') -> None: self.connection = connection self.queue = queue self.exchange = exchange self.channel = self.connection.channel() self.channel.queue_declare(queue=self.queue) self.channel.basic_consume(queue=self.queue, on_message_callback=self.on_receive) self.message_received = False def receive_message(self) -> Tuple[Any, bytes]: while not self.message_received: self.connection.process_data_events() return self.context, self.message def send_reply(self, context: Any, reply: bytes) -> None: ch, method, props = context ch.basic_publish(exchange=self.exchange, routing_key=props.reply_to, properties=pika.BasicProperties(correlation_id = props.correlation_id), body=reply) ch.basic_ack(delivery_tag=method.delivery_tag) self.message_received = False # message processed, reset status def on_receive(self, ch, method, props, body): self.context = (ch, method, props) self.message = body self.message_received = True @classmethod def create(cls, host: str, queue: str, exchange: str = '') -> 'RabbitMQServerTransport': """Create new server transport. Instead of creating the BlockingConnection yourself, you can call this function and pass in the host name, queue, and exchange. :param host: The host clients will connect to. :param queue: The RabbitMQ queue to consume messages from. :param exchange: The RabbitMQ exchange to use. """ connection = pika.BlockingConnection(pika.ConnectionParameters(host)) return cls(connection, queue, exchange) class RabbitMQClientTransport(ClientTransport): """Client transport based on a :py:class:`pika.BlockingConnection`. The transport assumes a RabbitMQ topology has already been established. :param connection: A :py:class:`pika.BlockingConnection` instance. :param routing_key: The RabbitMQ routing key to direct messages. :param exchange: The RabbitMQ exchange to use. """ def __init__(self, connection: pika.BlockingConnection, routing_key: str, exchange: str = '') -> None: self.connection = connection self.routing_key = routing_key self.exchange = exchange self._id_counter = 1000 self.channel = self.connection.channel() qd_result = self.channel.queue_declare(queue='', exclusive=True) self.callback_queue = qd_result.method.queue self.channel.basic_consume( queue=self.callback_queue, on_message_callback=self.on_response, auto_ack=True ) def _get_unique_id(self) -> int: self._id_counter += 1 return self._id_counter def send_message(self, message: bytes, expect_reply: bool = True) -> bytes: self.response_data = None self.corr_id = str(self._get_unique_id()) self.channel.basic_publish( exchange=self.exchange, routing_key=self.routing_key, properties=pika.BasicProperties( reply_to=self.callback_queue, correlation_id=self.corr_id, ), body=message) if expect_reply: while self.response_data is None: self.connection.process_data_events() return self.response_data def on_response(self, ch, method, props, body): if self.corr_id == props.correlation_id: self.response_data = body @classmethod def create(cls, host: str, routing_key: str, exchange: str = '') -> 'RabbitMQClientTransport': """Create new client transport. Instead of creating the BlockingConnection yourself, you can call this function and pass in the host name, routing key, and exchange. :param host: The host clients will connect to. :param routing_key: The RabbitMQ routing key to direct messages. :param exchange: The RabbitMQ exchange to use. """ connection = pika.BlockingConnection(pika.ConnectionParameters(host)) return cls(connection, routing_key, exchange) ================================================ FILE: tinyrpc/transports/websocket.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- import queue from typing import Callable, Tuple, Any from . import ServerTransport from geventwebsocket.resource import WebSocketApplication, Resource class WSServerTransport(ServerTransport): """ Requires :py:mod:`geventwebsocket`. Due to the nature of WS, this transport has a few peculiarities: It must be run in a thread, greenlet or some other form of concurrent execution primitive. This is due to :py:attr:`~tinyrpc.transports.websocket.WSServerTransport.handle` which is a :py:class:`geventwebsocket.resource.Resource` that joins a wsgi handler for the / and a WebSocket handler for the /ws path. These resource is used in combination with a :py:class:`geventwebsocket.server.WebSocketServer` that blocks while waiting for a call to :py:func:`~tinyrpc.transports.wsgi.WSServerTransport.send_reply`. The parameter ``queue_class`` must be used to supply a proper queue class for the chosen concurrency mechanism (i.e. when using :py:mod:`gevent`, set it to :py:class:`gevent.queue.Queue`). :param queue_class: The queue class to use. :param wsgi_handler: Can be used to change the standard response to a http request to the / """ def __init__( self, queue_class: queue.Queue = queue.Queue, wsgi_handler: Callable[[], str] = None ) -> None: self._queue_class = queue_class self.messages = queue_class() def static_wsgi_app(environ, start_response) -> str: start_response("200 OK", [("Content-Type", "text/html")]) return 'Ready for WebSocket connection in /ws' self.handle = Resource({ '/': static_wsgi_app if wsgi_handler is None else wsgi_handler, '/ws': WSApplicationFactory(self.messages, queue_class) }) def receive_message(self) -> Tuple[Any, bytes]: return self.messages.get() def send_reply(self, context: Any, reply: bytes) -> None: context.put(reply) class WSApplicationFactory(object): """ Creates WebSocketApplications with a messages queue and the queue_class needed for the communication with the WSServerTransport. """ def __init__(self, messages: queue.Queue, queue_class): self.messages = messages self._queue_class = queue_class def __call__(self, ws): """ The fake __init__ for the WSApplication """ app = WSApplication(ws) app.messages = self.messages app._queue_class = self._queue_class return app @classmethod def protocol(cls): return WebSocketApplication.protocol() class WSApplication(WebSocketApplication): """ This class is the bridge between the WSServerTransport and the WebSocket protocol implemented by :py:class:`geventwebsocket.resource.WebSocketApplication` """ def on_message(self, msg, *args, **kwargs): # create new context context = self._queue_class() self.messages.put((context, msg)) response = context.get() self.ws.send(response, *args, **kwargs) ================================================ FILE: tinyrpc/transports/websocketclient.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- from typing import Dict import geventwebsocket as websocket from . import ClientTransport class HttpWebSocketClientTransport(ClientTransport): """HTTP WebSocket based client transport. Requires :py:mod:`websocket-python`. Submits messages to a server using the body of an ``HTTP`` ``WebSocket`` message. Replies are taken from the response of the websocket. The connection is establish on the ``__init__`` because the protocol is connection oriented, you need to close the connection calling the close method. :param endpoint: The URL to connect the websocket. :param kwargs: Additional parameters for :py:func:`websocket.send`. """ def __init__(self, endpoint: str, **kwargs: Dict): self.endpoint = endpoint self.request_kwargs = kwargs self.ws = websocket.create_connection(self.endpoint, **kwargs) def send_message(self, message: bytes, expect_reply: bool = True) -> bytes: if not isinstance(message, bytes): raise TypeError('message must by of type bytes') self.ws.send(message) r = self.ws.recv() if expect_reply: return r def close(self) -> None: """Terminate the connection. Since WebSocket maintains an open connection over multiple calls it must be closed explicitly. """ if self.ws is not None: self.ws.close() ================================================ FILE: tinyrpc/transports/wsgi.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- import queue as Queue from typing import Tuple, Any from werkzeug.wrappers import Response, Request from . import ServerTransport class WsgiServerTransport(ServerTransport): """WSGI transport. Requires :py:mod:`werkzeug`. Due to the nature of WSGI, this transport has a few peculiarities: It must be run in a thread, greenlet or some other form of concurrent execution primitive. This is due to :py:func:`~tinyrpc.transports.wsgi.WsgiServerTransport.handle` blocking while waiting for a call to :py:func:`~tinyrpc.transports.wsgi.WsgiServerTransport.send_reply`. The parameter ``queue_class`` must be used to supply a proper queue class for the chosen concurrency mechanism (i.e. when using :py:mod:`gevent`, set it to :py:class:`gevent.queue.Queue`). :param max_content_length: The maximum request content size allowed. Should be set to a sane value to prevent DoS-Attacks. :param queue_class: The Queue class to use. :param allow_origin: The ``Access-Control-Allow-Origin`` header. Defaults to ``*`` (so change it if you need actual security). """ def __init__( self, max_content_length: int = 4096, queue_class: Queue.Queue = Queue.Queue, allow_origin: str = '*' ): self._queue_class = queue_class self.messages = queue_class() self.max_content_length = max_content_length self.allow_origin = allow_origin def receive_message(self) -> Tuple[Any, bytes]: return self.messages.get() def send_reply(self, context: Any, reply: bytes): context.put(reply) def handle(self, environ, start_response): """WSGI handler function. The transport will serve a request by reading the message and putting it into an internal buffer. It will then block until another concurrently running function sends a reply using :py:meth:`send_reply`. The reply will then be sent to the client being handled and handle will return. """ request = Request(environ) request.max_content_length = self.max_content_length access_control_headers = { 'Access-Control-Allow-Methods': 'POST', 'Access-Control-Allow-Origin': self.allow_origin, 'Access-Control-Allow-Headers': 'Content-Type, X-Requested-With, Accept, Origin' } post_headers = { 'Content-Type': 'application/json' } if request.method == 'OPTIONS': response = Response(headers=access_control_headers) elif request.method == 'POST': # message is encoded in POST, read it... msg = request.stream.read() # create new context context = self._queue_class() self.messages.put((context, msg)) # collect and combine all headers response_headers = dict(**access_control_headers, **post_headers) # ...and send the reply response = Response(context.get(), headers=response_headers) else: # nothing else supported at the moment response = Response('Only POST supported', 405) return response(environ, start_response) ================================================ FILE: tinyrpc/transports/zmq.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- from __future__ import absolute_import # needed for zmq import from typing import Tuple, Any, Dict import zmq from . import ServerTransport, ClientTransport from .. import exc class ZmqServerTransport(ServerTransport): """Server transport based on a :py:const:`zmq.ROUTER` socket. :param socket: A :py:const:`zmq.ROUTER` socket instance, bound to an endpoint. """ def __init__(self, socket: zmq.Socket) -> None: self.socket = socket def receive_message(self) -> Tuple[Any, bytes]: msg = self.socket.recv_multipart() return msg[:-1], msg[-1] def send_reply(self, context: Any, reply: bytes) -> None: self.socket.send_multipart(context + [reply]) @classmethod def create(cls, zmq_context: zmq.Context, endpoint: str) -> 'ZmqServerTransport': """Create new server transport. Instead of creating the socket yourself, you can call this function and merely pass the :py:class:`zmq.core.context.Context` instance. By passing a context imported from :py:mod:`zmq.green`, you can use green (gevent) 0mq sockets as well. :param zmq_context: A 0mq context. :param endpoint: The endpoint clients will connect to. """ socket = zmq_context.socket(zmq.ROUTER) socket.bind(endpoint) return cls(socket) class ZmqClientTransport(ClientTransport): """Client transport based on a :py:const:`zmq.REQ` socket. :param socket: A :py:const:`zmq.REQ` socket instance, connected to the server socket. :param timeout: An optional float. When set it defines the time period in seconds to wait for a reply. It will generate a :py:class:`exc.TimeoutError` exception if no reply was received in time. """ def __init__(self, socket: zmq.Socket, timeout: float = None) -> None: self.socket = socket self.timeout = timeout def send_message(self, message: bytes, expect_reply: bool = True) -> bytes: self.socket.send(message) # zmq contains a state machine preventing a new request # until the previous one is answered, so always receive if self.timeout is None: reply = self.socket.recv() else: poller = zmq.Poller() poller.register(self.socket, zmq.POLLIN) ready = dict(poller.poll(int(self.timeout * 1000))) if ready.get(self.socket) == zmq.POLLIN: reply = self.socket.recv() else: raise exc.TimeoutError() if expect_reply: return reply @classmethod def create(cls, zmq_context: zmq.Context, endpoint: str, timeout: float = None) -> 'ZmqClientTransport': """Create new client transport. Instead of creating the socket yourself, you can call this function and merely pass the :py:class:`zmq.core.context.Context` instance. By passing a context imported from :py:mod:`zmq.green`, you can use green (gevent) 0mq sockets as well. :param zmq_context: A 0mq context. :param endpoint: The endpoint the server is bound to. :param timeout: Optional period in seconds to wait for reply """ socket = zmq_context.socket(zmq.REQ) socket.connect(endpoint) return cls(socket, timeout) ================================================ FILE: tox.ini ================================================ [tox] #envlist = py38 envlist = py34, py35, py36, py37, py38, py39, py310, py311 [testenv] deps = -rrequirements.txt commands= pytest -rs pytest --cov=tinyrpc/ --cov-report=term --cov-report=html