diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..335a7fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,181 @@ + +# Created by https://www.gitignore.io/api/python,osx,pycharm + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env + +# virtualenv +.venv/ +venv/ +ENV/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject + + +### OSX ### +*.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon +# Thumbnails +._* +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + + +### PyCharm ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff: +.idea/ +.idea/workspace.xml +.idea/tasks.xml + +# Sensitive or high-churn files: +.idea/dataSources/ +.idea/dataSources.ids +.idea/dataSources.xml +.idea/dataSources.local.xml +.idea/sqlDataSources.xml +.idea/dynamic.xml +.idea/uiDesigner.xml + +# Gradle: +.idea/gradle.xml +.idea/libraries + +# Mongo Explorer plugin: +.idea/mongoSettings.xml + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +/out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +### PyCharm Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +### Antlr ### +# Antlr generated files that we don't need +*.interp + +### MyPi ### +.mypy_cache diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7a4a3ea --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..01f84eb --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include LICENSE +include README.md +include requirements.txt +include VERSION.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..6e16e04 --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +rpcq +==== + +[![Build Status](https://semaphoreci.com/api/v1/projects/05f5d83c-3639-4160-bebb-014b98d30cf0/2275647/badge.svg)](https://semaphoreci.com/rigetti/rpcq) + +The asynchronous RPC client-server framework and message specification for +[Rigetti Quantum Cloud Services (QCS)](https://www.rigetti.com/). + +Implements an efficient transport protocol by using [ZeroMQ](http://zeromq.org/) (ZMQ) sockets and +[MessagePack](https://msgpack.org/index.html) (`msgpack`) serialization. + +Not intended to be a full-featured replacement for other frameworks like +[gRPC](https://grpc.io/) or [Apache Thrift](https://thrift.apache.org/). + +Installation +------------ + +To install directly from the source, run `pip install -e .` from within the top-level +directory of the `rpcq` repository. To additionally install the requirements for testing, +make sure to run `pip install -r requirements.txt`. + +To instead install the latest released verson of `rpcq` from the Python package manager PyPi, +run `pip install rpcq`. + +**NOTE**: We strongly encourage users of `rpcq` to install the software within a (Python) +virtual environment (read up on [`virtualenv`](https://github.com/pypa/virtualenv), +[`pyenv`](https://github.com/pyenv/pyenv), or [`conda`](https://github.com/conda/conda) +for more info). + +Using the Client-Server Framework +--------------------------------- + +First, create a server, add a test handler, and spin it up. + +```python +from rpcq import Server + +server = Server() + +@server.rpc_handler +def test(): + return 'test' + +server.run('tcp://*:5555') +``` + +In another window, create a client that points to the same socket, and call the test method. + +```python +from rpcq import Client + +client = Client('tcp://localhost:5555') + +client.call('test') +``` + +This will return the string `'test'`. + +Using the Message Spec +---------------------- + +The message spec as defined in `src/messages.lisp` (which in turn produces `rpcq/messages.py`) +is meant to be used with the [Rigetti QCS](https://www.rigetti.com/qcs) platform. Therefore, +these messages are used in [`pyquil`](https://github.com/rigetticomputing/pyquil), in order +to allow users to communicate with the Rigetti Quil compiler and quantum processing units (QPUs). +PyQuil provides utilities for users to interact with the QCS API and write programs in +[Quil](https://arxiv.org/abs/1608.03355), the quantum instruction language developed at Rigetti. + +Thus, most users will not interact with `rpcq.messages` directly. However, for those interested +in building their own implementation of the QCS API utilities in pyQuil, becoming acquainted +with the client-server framework, the available messages in the message spec, and how they +are used in the `pyquil.api` module would be a good place to start! + +Updating the Python Message Bindings +------------------------------------ + +Currently only Python bindings are available for the message spec, but more language bindings +are in the works. To update the Python message bindings after editing `src/messages.lisp`, +open `rlwrap sbcl` and run: + +```lisp +(ql:quickload :rpcq) +(with-open-file (f "rpcq/messages.py" :direction :output :if-exists :supersede) + (rpcq:python-message-spec f)) +``` + +**NOTE**: Requires pre-installed +[`sbcl`](http://www.sbcl.org/), +[`quicklisp`](https://www.quicklisp.org/beta/), and +(optionally) [`rlwrap`](https://github.com/hanslub42/rlwrap). + +Running the Unit Tests +---------------------- + +The `rpcq` repository is configured with SemaphoreCI to automatically run the Python unit tests. +This can be done locally by running `pytest` from the top-level directory of the repository +(assuming you have installed the test requirements). + +There is additionally a very small suite of Lisp tests for `rpcq`. These are not run by +SemaphoreCI, but can be run locally by doing the following from within `rlwrap sbcl`: + +```lisp +(ql:quickload :rpcq) +(asdf:test-system :rpcq) +``` + +There may be some instances of `SYTLE-WARNING`, but if the test run successfully, +there should be something near the bottom of the output that looks like: + +``` +RPCQ-TESTS (Suite) + TEST-DEFMESSAGE [ OK ] +``` + +Authors +------- + +Developed at [Rigetti Computing](https://github.com/rigetticomputing) by +[Nikolas Tezak](https://github.com/ntezak), +[Steven Heidel](https://github.com/stevenheidel), +[Peter Karalekas](https://github.com/karalekas), +[Eric Peterson](https://github.com/ecp-rigetti), and +[Robert Smith](https://github.com/tarballs-are-good). diff --git a/VERSION.txt b/VERSION.txt new file mode 100644 index 0000000..227cea2 --- /dev/null +++ b/VERSION.txt @@ -0,0 +1 @@ +2.0.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d8e7864 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +future +msgpack>=0.5.2 +python-rapidjson +pyzmq>=17 +ruamel.yaml +typing + +# testing +pytest +pytest-asyncio +pytest-cov diff --git a/rpcq-tests.asd b/rpcq-tests.asd new file mode 100644 index 0000000..bc77008 --- /dev/null +++ b/rpcq-tests.asd @@ -0,0 +1,37 @@ +;;;; rpcq-tests.asd +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;; Copyright 2018 Rigetti Computing +;;;; +;;;; Licensed under the Apache License, Version 2.0 (the "License"); +;;;; you may not use this file except in compliance with the License. +;;;; You may obtain a copy of the License at +;;;; +;;;; http://www.apache.org/licenses/LICENSE-2.0 +;;;; +;;;; Unless required by applicable law or agreed to in writing, software +;;;; distributed under the License is distributed on an "AS IS" BASIS, +;;;; WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +;;;; See the License for the specific language governing permissions and +;;;; limitations under the License. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(asdf:defsystem #:rpcq-tests + :description "Regression tests for rpcq." + :author "Rigetti Computing " + :depends-on ( + #:rpcq + #:uiop + #:fiasco + ) + :perform (asdf:test-op (o s) + (uiop:symbol-call ':rpcq-tests + '#:run-rpcq-tests)) + :pathname "src-tests/" + :serial t + :components ((:file "package") + (:file "suite") + + ) + + ) + diff --git a/rpcq.asd b/rpcq.asd new file mode 100644 index 0000000..2a69b83 --- /dev/null +++ b/rpcq.asd @@ -0,0 +1,32 @@ +;;;; rpcq.asd +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;; Copyright 2018 Rigetti Computing +;;;; +;;;; Licensed under the Apache License, Version 2.0 (the "License"); +;;;; you may not use this file except in compliance with the License. +;;;; You may obtain a copy of the License at +;;;; +;;;; http://www.apache.org/licenses/LICENSE-2.0 +;;;; +;;;; Unless required by applicable law or agreed to in writing, software +;;;; distributed under the License is distributed on an "AS IS" BASIS, +;;;; WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +;;;; See the License for the specific language governing permissions and +;;;; limitations under the License. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(asdf:defsystem #:rpcq + :description "Message and RPC specifications for Rigetti Quantum Cloud Services." + :author "Rigetti Computing " + :version (:read-file-line "VERSION.txt") + :depends-on (#:alexandria ; Utilities + #:parse-float ; Float parsing + #:yason ; JSON generation + ) + :in-order-to ((asdf:test-op (asdf:test-op #:rpcq-tests))) + :pathname "src/" + :serial t + :components ((:file "package") + (:file "rpcq") + (:file "messages"))) + diff --git a/rpcq/__init__.py b/rpcq/__init__.py new file mode 100644 index 0000000..9781258 --- /dev/null +++ b/rpcq/__init__.py @@ -0,0 +1,3 @@ +from rpcq._client import Client +from rpcq._server import Server +from rpcq.version import __version__ diff --git a/rpcq/_base.py b/rpcq/_base.py new file mode 100644 index 0000000..ebf6ccf --- /dev/null +++ b/rpcq/_base.py @@ -0,0 +1,204 @@ +############################################################################## +# Copyright 2018 Rigetti Computing +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +############################################################################## +import rapidjson +from copy import deepcopy +from ruamel import yaml + +import msgpack + +REPR_LIST_TRUNCATION = 10 +"Number of list elements to print when calling repr on a Message with a list field." + + +def repr_value(value): + """ + Represent a value in human readable form. For long list's this truncates the printed + representation. + + :param value: The value to represent. + :return: A string representation. + :rtype: basestring + """ + if isinstance(value, list) and len(value) > REPR_LIST_TRUNCATION: + return "[{},...]".format(", ".join(map(repr, value[:REPR_LIST_TRUNCATION]))) + else: + return repr(value) + + +class UnknownMessageType(Exception): + """Raised when trying to decode an unknown message type.""" + + +class Message(object): + """ + Base class for messages. + """ + + def __repr__(self): + return "{}({})".format( + self.__class__.__name__, + ", ".join("{}={}".format(k, repr_value(v)) + for k, v in sorted(self.asdict().items(), key=lambda kv: kv[0]))) + + def __getitem__(self, item): + return self.asdict()[item] + + def items(self): + return self.asdict().items() + + def get(self, key, default): + return self.asdict().get(key, default) + + def __eq__(self, other): + return type(self) == type(other) and self.astuple() == other.astuple() + + def __copy__(self): + return self.__class__(**self.asdict()) + + def __deepcopy__(self, memo): + ret = self.__class__(**deepcopy(self.asdict(), memo=memo)) + memo[id(self)] = ret + return ret + + def copy(self, **kwargs): + """ + Create a copy with (optionally) substituted values. + + :param kwargs: Any fields that should be substituted. + :return: A new message object of the same class. + """ + values = self.asdict() + values.update(kwargs) + return self.__class__(**values) + + def asdict(self): + """ + Create a dictionary ``{fieldname1: fieldvalue1, ...}`` of the Message object. + + :return: A dictionary representation of the message. + :rtype: Dict[str,Any] + """ + raise NotImplementedError(self.__class__.__name__) + + def astuple(self): + """ + Create a tuple ``{fieldvalue1, ...}`` of the Message object. + + :return: A tuple representation of the message. + :rtype: Tuple[Any] + """ + raise NotImplementedError(self.__class__.__name__) + + def __hash__(self): + return hash((self.__class__, self.astuple())) + + def replace(self, **kwargs): + """ + Return a copy of the message object where the fields given in kwargs are + replaced. + + :param kwargs: The replaced fields. + :return: A copy of self. + """ + d = self.asdict() + d.update(kwargs) + return type(self)(**d) + + _types = None + + @staticmethod + def types(): + """ + Return a mapping ``{type_name: message_type}`` for all defined Message's. + + :return: A dictionary of ``Message`` types. + :rtype: Dict[str,type] + """ + if Message._types is None: + Message._types = {c.__name__: c for c in Message.__subclasses__()} + return Message._types + + +def _default(obj): + if isinstance(obj, Message): + d = obj.asdict() + d["_type"] = obj.__class__.__name__ + return d + else: + raise TypeError('Object of type {} is not JSON serializable'.format(obj.__class__.__name__)) + + +def _object_hook(obj): + if '_type' in obj: + try: + msg_type = Message.types()[obj["_type"]] + except KeyError: # pragma no coverage + raise UnknownMessageType("The message type {} is unknown".format(obj["_type"])) + + itms = {k: v for k, v in obj.items() if k != "_type"} + return msg_type(**itms) + else: + return obj + + +def to_msgpack(obj): + """ + Convert Python objects (including rpcq objects) to a msgpack byte array + :rtype: bytes + """ + # Docs for `use_bin_type` parameter are somewhat hard to find so they're copied here: + # Use bin type introduced in msgpack spec 2.0 for bytes. + # It also enables str8 type for unicode. + return msgpack.dumps(obj, default=_default, use_bin_type=True) + + +def from_msgpack(b): + """ + Convert a msgpack byte array into Python objects (including rpcq objects) + """ + # Docs for raw parameter are somewhat hard to find so they're copied here: + # If true, unpack msgpack raw to Python bytes (default). + # Otherwise, unpack to Python str (or unicode on Python 2) by decoding with UTF-8 encoding (recommended). + return msgpack.loads(b, object_hook=_object_hook, raw=False) + + +def to_json(obj): + """ + Convert Python objects (including rpcq objects) to a JSON string. + :rtype: str + """ + return rapidjson.dumps(obj, default=_default) + + +def from_json(s): + """ + Convert a JSON string into Python objects (including rpcq objects). + """ + return rapidjson.loads(s, object_hook=_object_hook) + + +def to_yaml_file(obj, f): + """ + Convert Python objects (including rpcq messages) to yaml and write it to `f`. + """ + yaml.dump(rapidjson.loads(to_json(obj)), f) + + +def from_yaml_file(f): + """ + Read a yaml file and convert to Python objects (including rpcq messages). + """ + return from_json(to_json(yaml.load(f, Loader=yaml.Loader))) diff --git a/rpcq/_client.py b/rpcq/_client.py new file mode 100644 index 0000000..28c1f78 --- /dev/null +++ b/rpcq/_client.py @@ -0,0 +1,186 @@ +############################################################################## +# Copyright 2018 Rigetti Computing +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +############################################################################## +import asyncio +import logging +import time +from typing import Dict, Union + +import zmq +import zmq.asyncio + +from rpcq._base import to_msgpack, from_msgpack +from rpcq._utils import rpc_request, RPCErrorError +from rpcq.messages import RPCError, RPCReply + +_log = logging.getLogger(__name__) + + +class Client: + """ + Client that executes methods on a remote server by sending JSON RPC requests to a socket. + """ + def __init__(self, endpoint: str, timeout: float = None): + """ + Create a client that connects to a server at . + + :param str endpoint: Socket endpoint, e.g. "tcp://localhost:1234" + :param float timeout: Timeout in seconds for Server response, set to None to disable the timeout + """ + self.timeout = timeout + self.endpoint = endpoint + + self._socket = self._connect_to_socket(zmq.Context(), endpoint) + # The async socket can't be created yet because it's possible that the current event loop during Client creation + # is different to the one used later to call a method, so we need to create the socket after the first call and + # then cache it + self._async_socket_cache = None + + # Mapping from request id to an event used to wake up the call that's waiting on that request. + # This is necessary to support parallel, asynchronous calls where we don't know which + # receive task will receive which reply. + self._events: Dict[str, asyncio.Event] = {} + + # Cache of replies so that different tasks can share results with each other + self._replies: Dict[str, Union[RPCReply, RPCError]] = {} + + async def call_async(self, method_name: str, *args, **kwargs): + """ + Send JSON RPC request to a backend socket and receive reply (asynchronously) + + :param method_name: Method name + :param args: Args that will be passed to the remote function + :param kwargs: Keyword args that will be passed to the remote function + """ + if self.timeout: + # Implementation note: this simply wraps the call in a timeout and converts to the built-in TimeoutError + try: + return await asyncio.wait_for(self._call_async(method_name, *args, **kwargs), timeout=self.timeout) + except asyncio.TimeoutError: + raise TimeoutError(f"Timeout on client {self.endpoint}, method name {method_name}, class info: {self}") + else: + return await self._call_async(method_name, *args, **kwargs) + + async def _call_async(self, method_name: str, *args, **kwargs): + """ + Sends a request to the socket and then wait for the reply. + + To deal with multiple, asynchronous requests we do not expect that the receive reply task + scheduled from this call is the one that receives this call's reply and instead rely on + Events to signal across multiple _async_call/_recv_reply tasks. + """ + request = rpc_request(method_name, *args, **kwargs) + _log.debug("Sending request: %s", request) + + # setup an event to notify us when the reply is received (potentially by a task scheduled by + # another call to _async_call). we do this before we send the request to catch the case + # where the reply comes back before we re-enter this thread + self._events[request.id] = asyncio.Event() + + # schedule a task to receive the reply to ensure we have a task to receive the reply + asyncio.ensure_future(self._recv_reply()) + + await self._async_socket.send_multipart([to_msgpack(request)]) + await self._events[request.id].wait() + + reply = self._replies.pop(request.id) + if isinstance(reply, RPCError): + raise RPCErrorError(reply.error) + else: + return reply.result + + async def _recv_reply(self): + """ + Helper task to recieve a reply store the result and trigger the associated event. + """ + raw_reply, = await self._async_socket.recv_multipart() + reply = from_msgpack(raw_reply) + _log.debug("Received reply: %s", reply) + self._replies[reply.id] = reply + self._events.pop(reply.id).set() + + def call(self, method_name: str, *args, **kwargs): + """ + Send JSON RPC request to a backend socket and receive reply + Note that this uses the default event loop to run in a blocking manner. If you would rather run in an async + fashion or provide your own event loop then use .async_call instead + + :param method_name: Method name + :param args: Args that will be passed to the remote function + :param kwargs: Keyword args that will be passed to the remote function + """ + request = rpc_request(method_name, *args, **kwargs) + _log.debug("Sending request: %s", request) + + self._socket.send_multipart([to_msgpack(request)]) + + start_time = time.time() + while True: + # Need to keep track of timeout manually in case this loop runs more than once. We subtract off already + # elapsed time from the timeout. The call to max is to make sure we don't send a negative value + # which would throw an error. + timeout = max((start_time + self.timeout - time.time()) * 1000, 0) if self.timeout is not None else None + if self._socket.poll(timeout) == 0: + raise TimeoutError(f"Timeout on client {self.endpoint}, method name {method_name}, class info: {self}") + + raw_reply, = self._socket.recv_multipart() + reply = from_msgpack(raw_reply) + _log.debug("Received reply: %s", reply) + + # there's a possibility that the socket will have some leftover replies from a previous + # request on it if that .call() was cancelled or timed out. Therefore, we need to discard replies that + # don't match the request just like in the call_async case. + if reply.id == request.id: + break + else: + _log.debug('Discarding reply: %s', reply) + + if isinstance(reply, RPCError): + raise RPCErrorError(reply.error) + else: + return reply.result + + def close(self): + """ + Close the sockets + """ + self._socket.close() + if self._async_socket_cache: + self._async_socket_cache.close() + self._async_socket_cache = None + + def _connect_to_socket(self, context: zmq.Context, endpoint: str): + """ + Connect to a DEALER socket at endpoint and turn off lingering. + + :param context: ZMQ Context to use (potentially async) + :param endpoint: Endpoint + :return: Connected socket + """ + socket = context.socket(zmq.DEALER) + socket.connect(endpoint) + socket.setsockopt(zmq.LINGER, 0) + _log.debug("Client connected to endpoint %s", self.endpoint) + return socket + + @property + def _async_socket(self): + """ + Creates a new async socket if one doesn't already exist for this Client + """ + if not self._async_socket_cache: + self._async_socket_cache = self._connect_to_socket(zmq.asyncio.Context(), self.endpoint) + + return self._async_socket_cache diff --git a/rpcq/_server.py b/rpcq/_server.py new file mode 100644 index 0000000..3294de1 --- /dev/null +++ b/rpcq/_server.py @@ -0,0 +1,148 @@ +############################################################################## +# Copyright 2018 Rigetti Computing +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +############################################################################## +""" +Server that accepts JSON RPC requests and returns JSON RPC replies/errors. +""" +import asyncio +import logging +from asyncio import AbstractEventLoop +from typing import Callable + +import zmq.asyncio + +from rpcq._base import to_msgpack, from_msgpack +from rpcq._spec import RPCSpec +from rpcq.messages import RPCRequest + +_log = logging.getLogger(__name__) + + +class Server: + """ + Server that accepts JSON RPC calls through a socket. + """ + def __init__(self, rpc_spec: RPCSpec = None): + """ + Create a server that will be linked to a socket + + :param rpc_spec: JSON RPC spec + """ + self.rpc_spec = rpc_spec if rpc_spec else RPCSpec() + self._exit_handlers = [] + + self._socket = None + + def rpc_handler(self, f: Callable): + """ + Add a function to the server. It will respond to JSON RPC requests with the corresponding method name. + This can be used as both a side-effecting function or as a decorator. + + :param f: Function to add + :return: Function wrapper (so it can be used as a decorator) + """ + return self.rpc_spec.add_handler(f) + + def exit_handler(self, f: Callable): + """ + Add an exit handler - a function which will be called when the server shuts down. + + :param f: Function to add + """ + self._exit_handlers.append(f) + + async def run_async(self, endpoint: str): + """ + Run server main task (asynchronously). + + :param endpoint: Socket endpoint to listen to, e.g. "tcp://*:1234" + """ + self._connect(endpoint) + + while True: + try: + # empty_frame may either be: + # 1. a single null frame if the client is a REQ socket + # 2. an empty list (ie. no frames) if the client is a DEALER socket + identity, *empty_frame, msg = await self._socket.recv_multipart() + request = from_msgpack(msg) + + asyncio.ensure_future(self._process_request(identity, empty_frame, request)) + except Exception: + _log.exception('Exception thrown in Server run loop') + + def run(self, endpoint: str, loop: AbstractEventLoop = None): + """ + Run server main task. + + :param endpoint: Socket endpoint to listen to, e.g. "tcp://*:1234" + :param loop: Event loop to run server in (alternatively just use run_async method) + """ + if not loop: + loop = asyncio.get_event_loop() + + try: + loop.run_until_complete(self.run_async(endpoint)) + except KeyboardInterrupt: + self._shutdown() + + def stop(self): + """ + DEPRECATED + """ + pass + + def _shutdown(self): + """ + Shut down the server. + """ + for exit_handler in self._exit_handlers: + exit_handler() + + if self._socket: + self._socket.close() + self._socket = None + + def _connect(self, endpoint: str): + """ + Connect the server to an endpoint. Creates a ZMQ ROUTER socket for the given endpoint. + + :param endpoint: Socket endpoint, e.g. "tcp://*:1234" + """ + if self._socket: + raise RuntimeError('Cannot run multiple Servers on the same socket') + + context = zmq.asyncio.Context() + self._socket = context.socket(zmq.ROUTER) + self._socket.bind(endpoint) + + _log.info("Starting server, listening on endpoint {}".format(endpoint)) + + async def _process_request(self, identity: bytes, empty_frame: list, request: RPCRequest): + """ + Executes the method specified in a JSON RPC request and then sends the reply to the socket. + + :param identity: Client identity provided by ZeroMQ + :param empty_frame: Either an empty list or a single null frame depending on the client type + :param request: JSON RPC request + """ + try: + _log.debug("Client %s sent request: %s", identity, request) + reply = await self.rpc_spec.run_handler(request) + + _log.debug("Sending client %s reply: %s", identity, reply) + await self._socket.send_multipart([identity, *empty_frame, to_msgpack(reply)]) + except Exception: + _log.exception('Exception thrown in _process_request') diff --git a/rpcq/_spec.py b/rpcq/_spec.py new file mode 100644 index 0000000..8753ca0 --- /dev/null +++ b/rpcq/_spec.py @@ -0,0 +1,122 @@ +############################################################################## +# Copyright 2018 Rigetti Computing +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +############################################################################## +""" +Class with json_rpc_call decorator for asynchronous JSON RPC calls +""" +import asyncio +import logging +import traceback +from typing import Union + +from rpcq._utils import rpc_reply, rpc_error, RPCMethodError, get_input +from rpcq.messages import RPCRequest, RPCReply, RPCError + +_log = logging.getLogger(__name__) + + +class RPCSpec(object): + """ + Class for keeping track of class methods that are exposed to the JSON RPC interface + """ + def __init__(self, *, provide_tracebacks: bool = True): + """ + Create a JsonRpcSpec object. + + Usage: + jr = JsonRpcSpec() + + class MyClass(object): + def __init__(self): + self.num = 5 + + @jr.add_method + def add(obj, *args): + return sum(args) + obj.num + + obj = MyClass() + + request = { + "jsonrpc": "2.0", + "id": "0", + "method": "add", + "params": [1, 2] + } + + reply = jr.call(request, obj) + + :param provide_tracebacks: If set to True, unhandled exceptions which occur during RPC call + implementations will have their tracebacks forwarded to the calling client as part of + the generated RPCError reply objject. If set to False, the generated RPCError reply will + omit this information (but the traceback will still get written to the logfile). + """ + self._json_rpc_methods = {} + self.provide_tracebacks = provide_tracebacks + + def add_handler(self, f): + """ + Adds the function f to a dictionary of JSON RPC methods. + + :param callable f: Method to be exposed + :return: + """ + self._json_rpc_methods[f.__name__] = f + return f + + def get_handler(self, request): + """ + Get callable from JSON RPC request + + :param RPCRequest request: JSON RPC request + :return: Method + :rtype: callable + """ + try: + f = self._json_rpc_methods[request.method] + + except (AttributeError, KeyError): # pragma no coverage + raise RPCMethodError("Received invalid method '{}'".format(request.method)) + + return f + + async def run_handler(self, request: RPCRequest) -> Union[RPCReply, RPCError]: + """ + Process a JSON RPC request + + :param RPCRequest request: JSON RPC request + :return: JSON RPC reply + """ + try: + rpc_handler = self.get_handler(request) + except RPCMethodError as e: + return rpc_error(request.id, str(e)) + + try: + # Run RPC and get result + args, kwargs = get_input(request.params) + result = rpc_handler(*args, **kwargs) + + if asyncio.iscoroutine(result): + result = await result + + except Exception as e: + _traceback = traceback.format_exc() + _log.error(_traceback) + if self.provide_tracebacks: + return rpc_error(request.id, "{}\n{}".format(str(e), _traceback)) + else: + return rpc_error(request.id, str(e)) + + return rpc_reply(request.id, result) diff --git a/rpcq/_utils.py b/rpcq/_utils.py new file mode 100644 index 0000000..6b73244 --- /dev/null +++ b/rpcq/_utils.py @@ -0,0 +1,99 @@ +############################################################################## +# Copyright 2018 Rigetti Computing +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +############################################################################## +"""Utils for message passing""" +import uuid +from typing import Optional, Tuple, Union + +from rpcq.messages import RPCRequest, RPCReply, RPCError + + +def rpc_request(method_name: str, *args, **kwargs) -> RPCRequest: + """ + Create RPC request + + :param method_name: Method name + :param args: Positional arguments + :param kwargs: Keyword arguments + :return: JSON RPC formatted dict + """ + if args: + kwargs['*args'] = args + + return RPCRequest( + jsonrpc='2.0', + id=str(uuid.uuid4()), + method=method_name, + params=kwargs + ) + + +def rpc_reply(id: Union[str, int], result: Optional[object]) -> RPCReply: + """ + Create RPC reply + + :param str|int id: Request ID + :param result: Result + :return: JSON RPC formatted dict + """ + return RPCReply( + jsonrpc='2.0', + id=id, + result=result + ) + + +def rpc_error(id: Union[str, int], error_msg: str) -> RPCError: + """ + Create RPC error + + :param id: Request ID + :param error_msg: Error message + :return: JSON RPC formatted dict + """ + return RPCError( + jsonrpc='2.0', + id=id, + error=error_msg) + + +def get_input(params: Union[dict, list]) -> Tuple[list, dict]: + """ + Get positional or keyword arguments from JSON RPC params + + :param params: Parameters passed through JSON RPC + :return: args, kwargs + """ + # Backwards compatibility for old clients that send params as a list + if isinstance(params, list): + args = params + kwargs = {} + elif isinstance(params, dict): + args = params.pop('*args', []) + kwargs = params + else: # pragma no coverage + raise TypeError( + 'Unknown type {} of params, must be list or dict'.format(type(params))) + + return args, kwargs + + +class RPCErrorError(IOError): + """JSON RPC error that is raised by a Client when it receives an RPCError message""" + + +class RPCMethodError(AttributeError): + """JSON RPC error that is raised by JSON RPC spec for nonexistent methods""" + diff --git a/rpcq/messages.py b/rpcq/messages.py new file mode 100644 index 0000000..ddcd5cc --- /dev/null +++ b/rpcq/messages.py @@ -0,0 +1,1206 @@ +#!/usr/bin/env python + +""" +WARNING: This file is auto-generated, do not edit by hand. See README.md. +""" + +import warnings +from rpcq._base import Message +from typing import List, Dict, Optional + +# Python 2/3 str/unicode compatibility +from past.builtins import basestring + + +class CoreMessages(object): + """ + WARNING: This class is auto-generated, do not edit by hand. See README.md. + This class is also DEPRECATED. + """ + + +class _deprecated_property(object): + + def __init__(self, prop): + self.prop = prop + + def __get__(self, *args): + warnings.warn( + "'CoreMessages.{0}' is deprecated. Please access '{0}' directly at the module level.".format( + self.prop.__name__), + UserWarning) + return self.prop + + +class ParameterSpec(Message): + """Specification of a dynamic parameter type and array-length.""" + + # fix slots + __slots__ = ( + 'type', + 'length', + ) + + def asdict(self): + """Generate dictionary representation of self.""" + return { + 'type': self.type, + 'length': self.length + } + + def astuple(self): + """Generate tuple representation of self.""" + return ( + self.type, + self.length + ) + + def __init__(self, + type=None, + length=1, + **kwargs): + # type: (str, int) -> None + + if kwargs: + warnings.warn(("Message {} ignoring unexpected keyword arguments: " + "{}.").format(self.__class__.__name__, ", ".join(kwargs.keys()))) + + # check presence of required fields + if type is None: + raise ValueError("The field 'type' cannot be None") + if length is None: + raise ValueError("The field 'length' cannot be None") + + # verify types + if not isinstance(type, basestring): + raise TypeError("Parameter type must be of type basestring, " + + "but object of type {} given".format(type(type))) + if not isinstance(length, int): + raise TypeError("Parameter length must be of type int, " + + "but object of type {} given".format(type(length))) + + self.type = type # type: str + """The parameter type, e.g., one of 'INTEGER', or 'FLOAT'.""" + + self.length = length # type: int + """If this is not 1, the parameter is an array of this length.""" + +CoreMessages.ParameterSpec = _deprecated_property(ParameterSpec) + +class ParameterAref(Message): + """A parametric expression.""" + + # fix slots + __slots__ = ( + 'name', + 'index', + ) + + def asdict(self): + """Generate dictionary representation of self.""" + return { + 'name': self.name, + 'index': self.index + } + + def astuple(self): + """Generate tuple representation of self.""" + return ( + self.name, + self.index + ) + + def __init__(self, + name, + index, + **kwargs): + # type: (str, int) -> None + + if kwargs: + warnings.warn(("Message {} ignoring unexpected keyword arguments: " + "{}.").format(self.__class__.__name__, ", ".join(kwargs.keys()))) + + # check presence of required fields + if name is None: + raise ValueError("The field 'name' cannot be None") + if index is None: + raise ValueError("The field 'index' cannot be None") + + # verify types + if not isinstance(name, basestring): + raise TypeError("Parameter name must be of type basestring, " + + "but object of type {} given".format(type(name))) + if not isinstance(index, int): + raise TypeError("Parameter index must be of type int, " + + "but object of type {} given".format(type(index))) + + self.name = name # type: str + """The parameter name""" + + self.index = index # type: int + """The array index.""" + +CoreMessages.ParameterAref = _deprecated_property(ParameterAref) + +class PatchTarget(Message): + """Patchable memory location descriptor.""" + + # fix slots + __slots__ = ( + 'patch_type', + 'patch_offset', + ) + + def asdict(self): + """Generate dictionary representation of self.""" + return { + 'patch_type': self.patch_type, + 'patch_offset': self.patch_offset + } + + def astuple(self): + """Generate tuple representation of self.""" + return ( + self.patch_type, + self.patch_offset + ) + + def __init__(self, + patch_type, + patch_offset, + **kwargs): + # type: (ParameterSpec, int) -> None + + if kwargs: + warnings.warn(("Message {} ignoring unexpected keyword arguments: " + "{}.").format(self.__class__.__name__, ", ".join(kwargs.keys()))) + + # check presence of required fields + if patch_type is None: + raise ValueError("The field 'patch_type' cannot be None") + if patch_offset is None: + raise ValueError("The field 'patch_offset' cannot be None") + + # verify types + if not isinstance(patch_type, ParameterSpec): + raise TypeError("Parameter patch_type must be of type ParameterSpec, " + + "but object of type {} given".format(type(patch_type))) + if not isinstance(patch_offset, int): + raise TypeError("Parameter patch_offset must be of type int, " + + "but object of type {} given".format(type(patch_offset))) + + self.patch_type = patch_type # type: ParameterSpec + """Data type at this address.""" + + self.patch_offset = patch_offset # type: int + """Memory address of the patch.""" + +CoreMessages.PatchTarget = _deprecated_property(PatchTarget) + +class RPCRequest(Message): + """A single request object according to the JSONRPC standard.""" + + # fix slots + __slots__ = ( + 'jsonrpc', + 'method', + 'params', + 'id', + ) + + def asdict(self): + """Generate dictionary representation of self.""" + return { + 'jsonrpc': self.jsonrpc, + 'method': self.method, + 'params': self.params, + 'id': self.id + } + + def astuple(self): + """Generate tuple representation of self.""" + return ( + self.jsonrpc, + self.method, + self.params, + self.id + ) + + def __init__(self, + method, + params, + id, + jsonrpc="2.0", + **kwargs): + # type: (str, object, str, str) -> None + + if kwargs: + warnings.warn(("Message {} ignoring unexpected keyword arguments: " + "{}.").format(self.__class__.__name__, ", ".join(kwargs.keys()))) + + # check presence of required fields + if jsonrpc is None: + raise ValueError("The field 'jsonrpc' cannot be None") + if method is None: + raise ValueError("The field 'method' cannot be None") + if params is None: + raise ValueError("The field 'params' cannot be None") + if id is None: + raise ValueError("The field 'id' cannot be None") + + # verify types + if not isinstance(jsonrpc, basestring): + raise TypeError("Parameter jsonrpc must be of type basestring, " + + "but object of type {} given".format(type(jsonrpc))) + if not isinstance(method, basestring): + raise TypeError("Parameter method must be of type basestring, " + + "but object of type {} given".format(type(method))) + if not isinstance(params, object): + raise TypeError("Parameter params must be of type object, " + + "but object of type {} given".format(type(params))) + if not isinstance(id, basestring): + raise TypeError("Parameter id must be of type basestring, " + + "but object of type {} given".format(type(id))) + + self.jsonrpc = jsonrpc # type: str + """The JSONRPC version.""" + + self.method = method # type: str + """The RPC function name.""" + + self.params = params # type: object + """The RPC function arguments.""" + + self.id = id # type: str + """RPC request id (used to verify that request and response belong together).""" + +CoreMessages.RPCRequest = _deprecated_property(RPCRequest) + +class RPCReply(Message): + """The reply for a JSONRPC request.""" + + # fix slots + __slots__ = ( + 'jsonrpc', + 'result', + 'id', + ) + + def asdict(self): + """Generate dictionary representation of self.""" + return { + 'jsonrpc': self.jsonrpc, + 'result': self.result, + 'id': self.id + } + + def astuple(self): + """Generate tuple representation of self.""" + return ( + self.jsonrpc, + self.result, + self.id + ) + + def __init__(self, + id, + jsonrpc="2.0", + result=None, + **kwargs): + # type: (str, str, Optional[object]) -> None + + if kwargs: + warnings.warn(("Message {} ignoring unexpected keyword arguments: " + "{}.").format(self.__class__.__name__, ", ".join(kwargs.keys()))) + + # check presence of required fields + if jsonrpc is None: + raise ValueError("The field 'jsonrpc' cannot be None") + if id is None: + raise ValueError("The field 'id' cannot be None") + + # verify types + if not isinstance(jsonrpc, basestring): + raise TypeError("Parameter jsonrpc must be of type basestring, " + + "but object of type {} given".format(type(jsonrpc))) + if not (result is None or isinstance(result, object)): + raise TypeError("Parameter result must be of type object, " + + "but object of type {} given".format(type(result))) + if not isinstance(id, basestring): + raise TypeError("Parameter id must be of type basestring, " + + "but object of type {} given".format(type(id))) + + self.jsonrpc = jsonrpc # type: str + """The JSONRPC version.""" + + self.result = result # type: Optional[object] + """The RPC result.""" + + self.id = id # type: str + """The RPC request id.""" + +CoreMessages.RPCReply = _deprecated_property(RPCReply) + +class RPCError(Message): + """A error message for JSONRPC requests.""" + + # fix slots + __slots__ = ( + 'jsonrpc', + 'error', + 'id', + ) + + def asdict(self): + """Generate dictionary representation of self.""" + return { + 'jsonrpc': self.jsonrpc, + 'error': self.error, + 'id': self.id + } + + def astuple(self): + """Generate tuple representation of self.""" + return ( + self.jsonrpc, + self.error, + self.id + ) + + def __init__(self, + error, + id, + jsonrpc="2.0", + **kwargs): + # type: (str, str, str) -> None + + if kwargs: + warnings.warn(("Message {} ignoring unexpected keyword arguments: " + "{}.").format(self.__class__.__name__, ", ".join(kwargs.keys()))) + + # check presence of required fields + if jsonrpc is None: + raise ValueError("The field 'jsonrpc' cannot be None") + if error is None: + raise ValueError("The field 'error' cannot be None") + if id is None: + raise ValueError("The field 'id' cannot be None") + + # verify types + if not isinstance(jsonrpc, basestring): + raise TypeError("Parameter jsonrpc must be of type basestring, " + + "but object of type {} given".format(type(jsonrpc))) + if not isinstance(error, basestring): + raise TypeError("Parameter error must be of type basestring, " + + "but object of type {} given".format(type(error))) + if not isinstance(id, basestring): + raise TypeError("Parameter id must be of type basestring, " + + "but object of type {} given".format(type(id))) + + self.jsonrpc = jsonrpc # type: str + """The JSONRPC version.""" + + self.error = error # type: str + """The error message.""" + + self.id = id # type: str + """The RPC request id.""" + +CoreMessages.RPCError = _deprecated_property(RPCError) + +class TargetDevice(Message): + """ISA and specs for a particular device.""" + + # fix slots + __slots__ = ( + 'isa', + 'specs', + ) + + def asdict(self): + """Generate dictionary representation of self.""" + return { + 'isa': self.isa, + 'specs': self.specs + } + + def astuple(self): + """Generate tuple representation of self.""" + return ( + self.isa, + self.specs + ) + + def __init__(self, + isa, + specs, + **kwargs): + # type: (Dict[str,dict], Dict[str,dict]) -> None + + if kwargs: + warnings.warn(("Message {} ignoring unexpected keyword arguments: " + "{}.").format(self.__class__.__name__, ", ".join(kwargs.keys()))) + + # check presence of required fields + if isa is None: + raise ValueError("The field 'isa' cannot be None") + if specs is None: + raise ValueError("The field 'specs' cannot be None") + + # verify types + if not isinstance(isa, dict): + raise TypeError("Parameter isa must be of type dict, " + + "but object of type {} given".format(type(isa))) + if not isinstance(specs, dict): + raise TypeError("Parameter specs must be of type dict, " + + "but object of type {} given".format(type(specs))) + + self.isa = isa # type: Dict[str,dict] + """Instruction-set architecture for this device.""" + + self.specs = specs # type: Dict[str,dict] + """Fidelities and coherence times for this device.""" + +CoreMessages.TargetDevice = _deprecated_property(TargetDevice) + +class RandomizedBenchmarkingRequest(Message): + """RPC request payload for generating a randomized benchmarking sequence.""" + + # fix slots + __slots__ = ( + 'depth', + 'qubits', + 'gateset', + 'seed', + ) + + def asdict(self): + """Generate dictionary representation of self.""" + return { + 'depth': self.depth, + 'qubits': self.qubits, + 'gateset': self.gateset, + 'seed': self.seed + } + + def astuple(self): + """Generate tuple representation of self.""" + return ( + self.depth, + self.qubits, + self.gateset, + self.seed + ) + + def __init__(self, + depth, + qubits, + gateset, + seed=None, + **kwargs): + # type: (int, int, List[str], Optional[int]) -> None + + if kwargs: + warnings.warn(("Message {} ignoring unexpected keyword arguments: " + "{}.").format(self.__class__.__name__, ", ".join(kwargs.keys()))) + + # check presence of required fields + if depth is None: + raise ValueError("The field 'depth' cannot be None") + if qubits is None: + raise ValueError("The field 'qubits' cannot be None") + if gateset is None: + raise ValueError("The field 'gateset' cannot be None") + + # verify types + if not isinstance(depth, int): + raise TypeError("Parameter depth must be of type int, " + + "but object of type {} given".format(type(depth))) + if not isinstance(qubits, int): + raise TypeError("Parameter qubits must be of type int, " + + "but object of type {} given".format(type(qubits))) + if not isinstance(gateset, list): + raise TypeError("Parameter gateset must be of type list, " + + "but object of type {} given".format(type(gateset))) + if not (seed is None or isinstance(seed, int)): + raise TypeError("Parameter seed must be of type int, " + + "but object of type {} given".format(type(seed))) + + self.depth = depth # type: int + """Depth of the benchmarking sequence.""" + + self.qubits = qubits # type: int + """Number of qubits involved in the benchmarking sequence.""" + + self.gateset = gateset # type: List[str] + """List of Quil programs, each describing a Clifford.""" + + self.seed = seed # type: Optional[int] + """PRNG seed. Set this to guarantee repeatable results.""" + +CoreMessages.RandomizedBenchmarkingRequest = _deprecated_property(RandomizedBenchmarkingRequest) + +class RandomizedBenchmarkingResponse(Message): + """RPC reply payload for a randomly generated benchmarking sequence.""" + + # fix slots + __slots__ = ( + 'sequence', + ) + + def asdict(self): + """Generate dictionary representation of self.""" + return { + 'sequence': self.sequence + } + + def astuple(self): + """Generate tuple representation of self.""" + return ( + self.sequence + ) + + def __init__(self, + sequence, + **kwargs): + # type: (List[List[int]]) -> None + + if kwargs: + warnings.warn(("Message {} ignoring unexpected keyword arguments: " + "{}.").format(self.__class__.__name__, ", ".join(kwargs.keys()))) + + # check presence of required fields + if sequence is None: + raise ValueError("The field 'sequence' cannot be None") + + # verify types + if not isinstance(sequence, list): + raise TypeError("Parameter sequence must be of type list, " + + "but object of type {} given".format(type(sequence))) + + self.sequence = sequence # type: List[List[int]] + """List of Cliffords, each expressed as a list of generator indices.""" + +CoreMessages.RandomizedBenchmarkingResponse = _deprecated_property(RandomizedBenchmarkingResponse) + +class PauliTerm(Message): + """Specification of a single Pauli term as a tensor product of Pauli factors.""" + + # fix slots + __slots__ = ( + 'indices', + 'symbols', + ) + + def asdict(self): + """Generate dictionary representation of self.""" + return { + 'indices': self.indices, + 'symbols': self.symbols + } + + def astuple(self): + """Generate tuple representation of self.""" + return ( + self.indices, + self.symbols + ) + + def __init__(self, + indices, + symbols, + **kwargs): + # type: (List[int], List[str]) -> None + + if kwargs: + warnings.warn(("Message {} ignoring unexpected keyword arguments: " + "{}.").format(self.__class__.__name__, ", ".join(kwargs.keys()))) + + # check presence of required fields + if indices is None: + raise ValueError("The field 'indices' cannot be None") + if symbols is None: + raise ValueError("The field 'symbols' cannot be None") + + # verify types + if not isinstance(indices, list): + raise TypeError("Parameter indices must be of type list, " + + "but object of type {} given".format(type(indices))) + if not isinstance(symbols, list): + raise TypeError("Parameter symbols must be of type list, " + + "but object of type {} given".format(type(symbols))) + + self.indices = indices # type: List[int] + """Qubit indices onto which the factors of a Pauli term are applied.""" + + self.symbols = symbols # type: List[str] + """Ordered factors of a Pauli term.""" + +CoreMessages.PauliTerm = _deprecated_property(PauliTerm) + +class ConjugateByCliffordRequest(Message): + """RPC request payload for conjugating a Pauli element by a Clifford element.""" + + # fix slots + __slots__ = ( + 'pauli', + 'clifford', + ) + + def asdict(self): + """Generate dictionary representation of self.""" + return { + 'pauli': self.pauli, + 'clifford': self.clifford + } + + def astuple(self): + """Generate tuple representation of self.""" + return ( + self.pauli, + self.clifford + ) + + def __init__(self, + pauli, + clifford, + **kwargs): + # type: (PauliTerm, str) -> None + + if kwargs: + warnings.warn(("Message {} ignoring unexpected keyword arguments: " + "{}.").format(self.__class__.__name__, ", ".join(kwargs.keys()))) + + # check presence of required fields + if pauli is None: + raise ValueError("The field 'pauli' cannot be None") + if clifford is None: + raise ValueError("The field 'clifford' cannot be None") + + # verify types + if not isinstance(pauli, PauliTerm): + raise TypeError("Parameter pauli must be of type PauliTerm, " + + "but object of type {} given".format(type(pauli))) + if not isinstance(clifford, basestring): + raise TypeError("Parameter clifford must be of type basestring, " + + "but object of type {} given".format(type(clifford))) + + self.pauli = pauli # type: PauliTerm + """Specification of a Pauli element.""" + + self.clifford = clifford # type: str + """Specification of a Clifford element.""" + +CoreMessages.ConjugateByCliffordRequest = _deprecated_property(ConjugateByCliffordRequest) + +class ConjugateByCliffordResponse(Message): + """RPC reply payload for a Pauli element as conjugated by a Clifford element.""" + + # fix slots + __slots__ = ( + 'phase', + 'pauli', + ) + + def asdict(self): + """Generate dictionary representation of self.""" + return { + 'phase': self.phase, + 'pauli': self.pauli + } + + def astuple(self): + """Generate tuple representation of self.""" + return ( + self.phase, + self.pauli + ) + + def __init__(self, + phase, + pauli, + **kwargs): + # type: (int, str) -> None + + if kwargs: + warnings.warn(("Message {} ignoring unexpected keyword arguments: " + "{}.").format(self.__class__.__name__, ", ".join(kwargs.keys()))) + + # check presence of required fields + if phase is None: + raise ValueError("The field 'phase' cannot be None") + if pauli is None: + raise ValueError("The field 'pauli' cannot be None") + + # verify types + if not isinstance(phase, int): + raise TypeError("Parameter phase must be of type int, " + + "but object of type {} given".format(type(phase))) + if not isinstance(pauli, basestring): + raise TypeError("Parameter pauli must be of type basestring, " + + "but object of type {} given".format(type(pauli))) + + self.phase = phase # type: int + """Encoded global phase factor on the emitted Pauli.""" + + self.pauli = pauli # type: str + """Description of the encoded Pauli.""" + +CoreMessages.ConjugateByCliffordResponse = _deprecated_property(ConjugateByCliffordResponse) + +class NativeQuilRequest(Message): + """Quil and the device metadata necessary for quilc.""" + + # fix slots + __slots__ = ( + 'quil', + 'target_device', + ) + + def asdict(self): + """Generate dictionary representation of self.""" + return { + 'quil': self.quil, + 'target_device': self.target_device + } + + def astuple(self): + """Generate tuple representation of self.""" + return ( + self.quil, + self.target_device + ) + + def __init__(self, + quil, + target_device, + **kwargs): + # type: (str, TargetDevice) -> None + + if kwargs: + warnings.warn(("Message {} ignoring unexpected keyword arguments: " + "{}.").format(self.__class__.__name__, ", ".join(kwargs.keys()))) + + # check presence of required fields + if quil is None: + raise ValueError("The field 'quil' cannot be None") + if target_device is None: + raise ValueError("The field 'target_device' cannot be None") + + # verify types + if not isinstance(quil, basestring): + raise TypeError("Parameter quil must be of type basestring, " + + "but object of type {} given".format(type(quil))) + if not isinstance(target_device, TargetDevice): + raise TypeError("Parameter target_device must be of type TargetDevice, " + + "but object of type {} given".format(type(target_device))) + + self.quil = quil # type: str + """Arbitrary Quil to be sent to quilc.""" + + self.target_device = target_device # type: TargetDevice + """Specifications for the device to target with quilc.""" + +CoreMessages.NativeQuilRequest = _deprecated_property(NativeQuilRequest) + +class NativeQuilMetadata(Message): + """Metadata for a native quil program.""" + + # fix slots + __slots__ = ( + 'final_rewiring', + 'gate_depth', + 'gate_volume', + 'multiqubit_gate_depth', + 'program_duration', + 'program_fidelity', + 'topological_swaps', + ) + + def asdict(self): + """Generate dictionary representation of self.""" + return { + 'final_rewiring': self.final_rewiring, + 'gate_depth': self.gate_depth, + 'gate_volume': self.gate_volume, + 'multiqubit_gate_depth': self.multiqubit_gate_depth, + 'program_duration': self.program_duration, + 'program_fidelity': self.program_fidelity, + 'topological_swaps': self.topological_swaps + } + + def astuple(self): + """Generate tuple representation of self.""" + return ( + self.final_rewiring, + self.gate_depth, + self.gate_volume, + self.multiqubit_gate_depth, + self.program_duration, + self.program_fidelity, + self.topological_swaps + ) + + def __init__(self, + final_rewiring=None, + gate_depth=None, + gate_volume=None, + multiqubit_gate_depth=None, + program_duration=None, + program_fidelity=None, + topological_swaps=None, + **kwargs): + # type: (List[int], Optional[int], Optional[int], Optional[int], Optional[float], Optional[float], Optional[int]) -> None + + if kwargs: + warnings.warn(("Message {} ignoring unexpected keyword arguments: " + "{}.").format(self.__class__.__name__, ", ".join(kwargs.keys()))) + + # initialize default values of collections + if final_rewiring is None: + final_rewiring = [] + + # verify types + if not (final_rewiring is None or isinstance(final_rewiring, list)): + raise TypeError("Parameter final_rewiring must be of type list, " + + "but object of type {} given".format(type(final_rewiring))) + if not (gate_depth is None or isinstance(gate_depth, int)): + raise TypeError("Parameter gate_depth must be of type int, " + + "but object of type {} given".format(type(gate_depth))) + if not (gate_volume is None or isinstance(gate_volume, int)): + raise TypeError("Parameter gate_volume must be of type int, " + + "but object of type {} given".format(type(gate_volume))) + if not (multiqubit_gate_depth is None or isinstance(multiqubit_gate_depth, int)): + raise TypeError("Parameter multiqubit_gate_depth must be of type int, " + + "but object of type {} given".format(type(multiqubit_gate_depth))) + if not (program_duration is None or isinstance(program_duration, float)): + raise TypeError("Parameter program_duration must be of type float, " + + "but object of type {} given".format(type(program_duration))) + if not (program_fidelity is None or isinstance(program_fidelity, float)): + raise TypeError("Parameter program_fidelity must be of type float, " + + "but object of type {} given".format(type(program_fidelity))) + if not (topological_swaps is None or isinstance(topological_swaps, int)): + raise TypeError("Parameter topological_swaps must be of type int, " + + "but object of type {} given".format(type(topological_swaps))) + + self.final_rewiring = final_rewiring # type: List[int] + """Output qubit index relabeling due to SWAP insertion.""" + + self.gate_depth = gate_depth # type: Optional[int] + """Maximum number of successive gates in the native quil program.""" + + self.gate_volume = gate_volume # type: Optional[int] + """Total number of gates in the native quil program.""" + + self.multiqubit_gate_depth = multiqubit_gate_depth # type: Optional[int] + """Maximum number of successive two-qubit gates in the native quil program.""" + + self.program_duration = program_duration # type: Optional[float] + """Rough estimate of native quil program length in nanoseconds.""" + + self.program_fidelity = program_fidelity # type: Optional[float] + """Rough estimate of the fidelity of the full native quil program, uses specs.""" + + self.topological_swaps = topological_swaps # type: Optional[int] + """Total number of SWAPs in the native quil program.""" + +CoreMessages.NativeQuilMetadata = _deprecated_property(NativeQuilMetadata) + +class NativeQuilResponse(Message): + """Native Quil and associated metadata returned from quilc.""" + + # fix slots + __slots__ = ( + 'quil', + 'metadata', + ) + + def asdict(self): + """Generate dictionary representation of self.""" + return { + 'quil': self.quil, + 'metadata': self.metadata + } + + def astuple(self): + """Generate tuple representation of self.""" + return ( + self.quil, + self.metadata + ) + + def __init__(self, + quil, + metadata=None, + **kwargs): + # type: (str, Optional[NativeQuilMetadata]) -> None + + if kwargs: + warnings.warn(("Message {} ignoring unexpected keyword arguments: " + "{}.").format(self.__class__.__name__, ", ".join(kwargs.keys()))) + + # check presence of required fields + if quil is None: + raise ValueError("The field 'quil' cannot be None") + + # verify types + if not isinstance(quil, basestring): + raise TypeError("Parameter quil must be of type basestring, " + + "but object of type {} given".format(type(quil))) + if not (metadata is None or isinstance(metadata, NativeQuilMetadata)): + raise TypeError("Parameter metadata must be of type NativeQuilMetadata, " + + "but object of type {} given".format(type(metadata))) + + self.quil = quil # type: str + """Native Quil returned from quilc.""" + + self.metadata = metadata # type: Optional[NativeQuilMetadata] + """Metadata for the returned Native Quil.""" + +CoreMessages.NativeQuilResponse = _deprecated_property(NativeQuilResponse) + +class BinaryExecutableRequest(Message): + """Native Quil and the information needed to create binary executables.""" + + # fix slots + __slots__ = ( + 'quil', + 'num_shots', + ) + + def asdict(self): + """Generate dictionary representation of self.""" + return { + 'quil': self.quil, + 'num_shots': self.num_shots + } + + def astuple(self): + """Generate tuple representation of self.""" + return ( + self.quil, + self.num_shots + ) + + def __init__(self, + quil, + num_shots, + **kwargs): + # type: (str, int) -> None + + if kwargs: + warnings.warn(("Message {} ignoring unexpected keyword arguments: " + "{}.").format(self.__class__.__name__, ", ".join(kwargs.keys()))) + + # check presence of required fields + if quil is None: + raise ValueError("The field 'quil' cannot be None") + if num_shots is None: + raise ValueError("The field 'num_shots' cannot be None") + + # verify types + if not isinstance(quil, basestring): + raise TypeError("Parameter quil must be of type basestring, " + + "but object of type {} given".format(type(quil))) + if not isinstance(num_shots, int): + raise TypeError("Parameter num_shots must be of type int, " + + "but object of type {} given".format(type(num_shots))) + + self.quil = quil # type: str + """Native Quil to be translated into an executable program.""" + + self.num_shots = num_shots # type: int + """The number of times to repeat the program.""" + +CoreMessages.BinaryExecutableRequest = _deprecated_property(BinaryExecutableRequest) + +class BinaryExecutableResponse(Message): + """Program to run on the QPU.""" + + # fix slots + __slots__ = ( + 'program', + 'memory_descriptors', + 'ro_sources', + ) + + def asdict(self): + """Generate dictionary representation of self.""" + return { + 'program': self.program, + 'memory_descriptors': self.memory_descriptors, + 'ro_sources': self.ro_sources + } + + def astuple(self): + """Generate tuple representation of self.""" + return ( + self.program, + self.memory_descriptors, + self.ro_sources + ) + + def __init__(self, + program, + memory_descriptors=None, + ro_sources=None, + **kwargs): + # type: (str, Dict[str,ParameterSpec], List[object]) -> None + + if kwargs: + warnings.warn(("Message {} ignoring unexpected keyword arguments: " + "{}.").format(self.__class__.__name__, ", ".join(kwargs.keys()))) + + # initialize default values of collections + if memory_descriptors is None: + memory_descriptors = {} + if ro_sources is None: + ro_sources = [] + + # check presence of required fields + if program is None: + raise ValueError("The field 'program' cannot be None") + + # verify types + if not isinstance(program, basestring): + raise TypeError("Parameter program must be of type basestring, " + + "but object of type {} given".format(type(program))) + if not (memory_descriptors is None or isinstance(memory_descriptors, dict)): + raise TypeError("Parameter memory_descriptors must be of type dict, " + + "but object of type {} given".format(type(memory_descriptors))) + if not (ro_sources is None or isinstance(ro_sources, list)): + raise TypeError("Parameter ro_sources must be of type list, " + + "but object of type {} given".format(type(ro_sources))) + + self.program = program # type: str + """Execution settings and sequencer binaries.""" + + self.memory_descriptors = memory_descriptors # type: Dict[str,ParameterSpec] + """Internal field for constructing patch tables.""" + + self.ro_sources = ro_sources # type: List[object] + """Internal field for reshaping returned buffers.""" + +CoreMessages.BinaryExecutableResponse = _deprecated_property(BinaryExecutableResponse) + +class PyQuilExecutableResponse(Message): + """Pidgin-serializable form of a pyQuil Program object.""" + + # fix slots + __slots__ = ( + 'program', + 'attributes', + ) + + def asdict(self): + """Generate dictionary representation of self.""" + return { + 'program': self.program, + 'attributes': self.attributes + } + + def astuple(self): + """Generate tuple representation of self.""" + return ( + self.program, + self.attributes + ) + + def __init__(self, + program, + attributes, + **kwargs): + # type: (str, Dict[str,object]) -> None + + if kwargs: + warnings.warn(("Message {} ignoring unexpected keyword arguments: " + "{}.").format(self.__class__.__name__, ", ".join(kwargs.keys()))) + + # check presence of required fields + if program is None: + raise ValueError("The field 'program' cannot be None") + if attributes is None: + raise ValueError("The field 'attributes' cannot be None") + + # verify types + if not isinstance(program, basestring): + raise TypeError("Parameter program must be of type basestring, " + + "but object of type {} given".format(type(program))) + if not isinstance(attributes, dict): + raise TypeError("Parameter attributes must be of type dict, " + + "but object of type {} given".format(type(attributes))) + + self.program = program # type: str + """String representation of a Quil program.""" + + self.attributes = attributes # type: Dict[str,object] + """Miscellaneous attributes to be unpacked onto the pyQuil Program object.""" + +CoreMessages.PyQuilExecutableResponse = _deprecated_property(PyQuilExecutableResponse) + +class QPURequest(Message): + """Program and patch values to send to the QPU for execution.""" + + # fix slots + __slots__ = ( + 'program', + 'patch_values', + 'id', + ) + + def asdict(self): + """Generate dictionary representation of self.""" + return { + 'program': self.program, + 'patch_values': self.patch_values, + 'id': self.id + } + + def astuple(self): + """Generate tuple representation of self.""" + return ( + self.program, + self.patch_values, + self.id + ) + + def __init__(self, + program, + patch_values, + id, + **kwargs): + # type: (object, Dict[str,List[object]], str) -> None + + if kwargs: + warnings.warn(("Message {} ignoring unexpected keyword arguments: " + "{}.").format(self.__class__.__name__, ", ".join(kwargs.keys()))) + + # check presence of required fields + if program is None: + raise ValueError("The field 'program' cannot be None") + if patch_values is None: + raise ValueError("The field 'patch_values' cannot be None") + if id is None: + raise ValueError("The field 'id' cannot be None") + + # verify types + if not isinstance(program, object): + raise TypeError("Parameter program must be of type object, " + + "but object of type {} given".format(type(program))) + if not isinstance(patch_values, dict): + raise TypeError("Parameter patch_values must be of type dict, " + + "but object of type {} given".format(type(patch_values))) + if not isinstance(id, basestring): + raise TypeError("Parameter id must be of type basestring, " + + "but object of type {} given".format(type(id))) + + self.program = program # type: object + """Execution settings and sequencer binaries.""" + + self.patch_values = patch_values # type: Dict[str,List[object]] + """Dictionary mapping data names to data values for patching the binary.""" + + self.id = id # type: str + """QPU request ID.""" + +CoreMessages.QPURequest = _deprecated_property(QPURequest) diff --git a/rpcq/test/__init__.py b/rpcq/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rpcq/test/test_base.py b/rpcq/test/test_base.py new file mode 100644 index 0000000..863774e --- /dev/null +++ b/rpcq/test/test_base.py @@ -0,0 +1,103 @@ +############################################################################## +# Copyright 2018 Rigetti Computing +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +############################################################################## +from __future__ import print_function +import logging + +import pytest +from rpcq._base import (Message) + +log = logging.getLogger(__file__) + + +def test_messages(): + + class TestRPCError(Message): + """A error message for JSONRPC requests.""" + + # fix slots + __slots__ = ( + 'jsonrpc', + 'error', + 'id', + ) + + def asdict(self): + """Generate dictionary representation of self.""" + return { + 'jsonrpc': self.jsonrpc, + 'error': self.error, + 'id': self.id + } + + def astuple(self): + """Generate tuple representation of self.""" + return ( + self.jsonrpc, + self.error, + self.id + ) + + def __init__(self, + error, + id, + jsonrpc="2.0"): + # type: (str, str, str) -> None + + # check presence of required fields + if jsonrpc is None: + raise ValueError("The field 'jsonrpc' cannot be None") + if error is None: + raise ValueError("The field 'error' cannot be None") + if id is None: + raise ValueError("The field 'id' cannot be None") + + # verify types + if not isinstance(jsonrpc, str): + raise TypeError("Parameter jsonrpc must be of type str, " + + "but object of type {} given".format(type(jsonrpc))) + if not isinstance(error, str): + raise TypeError("Parameter error must be of type str, " + + "but object of type {} given".format(type(error))) + if not isinstance(id, str): + raise TypeError("Parameter id must be of type str, " + + "but object of type {} given".format(type(id))) + + self.jsonrpc = jsonrpc # type: str + """The JSONRPC version.""" + + self.error = error # type: str + """The error message.""" + + self.id = id # type: str + """The RPC request id.""" + + e = "Error" + i = "asefa32423" + m = TestRPCError(e, id=i) + assert m.error == e + assert m.id == i + assert m.jsonrpc == "2.0" + + assert m['error'] == e + assert m.get('error', 1) == e + + assert m.asdict() == {"error": e, + "id": i, + "jsonrpc": "2.0"} + assert m.astuple() == ("2.0", e, i) + + with pytest.raises(TypeError): + TestRPCError(bad_field=1) diff --git a/rpcq/test/test_rpc.py b/rpcq/test/test_rpc.py new file mode 100644 index 0000000..ba7b343 --- /dev/null +++ b/rpcq/test/test_rpc.py @@ -0,0 +1,227 @@ +############################################################################## +# Copyright 2018 Rigetti Computing +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +############################################################################## +import asyncio +import logging +import os +import signal +import time +from multiprocessing import Process + +import pytest +import zmq + +from rpcq._base import to_msgpack, from_msgpack +from rpcq._server import Server +from rpcq._client import Client +from rpcq._utils import rpc_request, RPCErrorError + +# Set up logging for easier debugging of test failures, but disable logging any exceptions thrown in the mock server +# since those are expected +logging.basicConfig(format='%(asctime)s - %(name)s %(levelname)s : %(message)s', level=logging.DEBUG) +logging.getLogger('rpcq._spec').setLevel(logging.CRITICAL) + +mock = Server() + + +@mock.rpc_handler +async def add(a, b): + return a + b + + +@mock.rpc_handler +def foo(): + return 'bar' + + +@mock.rpc_handler +async def sleep(n: int): + await asyncio.sleep(n) + return n + + +@mock.rpc_handler +def raise_error(): + do_oops() + + +# Some functions that will eventually raise an error. +def do_oops(): + oops() + + +def oops(): + raise ValueError("Oops.") + + +@pytest.fixture +def m_endpoints(): + return "tcp://localhost:5557", "tcp://*:5557" + + +def run_mock(_, endpoint): + # Need a new event loop for a new process + mock.run(endpoint, loop=asyncio.new_event_loop()) + + +def test_server(request, m_endpoints): + context = zmq.Context() + backend = context.socket(zmq.DEALER) + backend.connect(m_endpoints[0]) + request.addfinalizer(backend.close) + + proc = Process(target=run_mock, args=m_endpoints) + proc.start() + + request = rpc_request("add", 1, 2) + backend.send(to_msgpack(request)) + reply = from_msgpack(backend.recv()) + assert reply.result == 3 + + os.kill(proc.pid, signal.SIGINT) + + +@pytest.fixture +def server(request, m_endpoints): + proc = Process(target=run_mock, args=m_endpoints) + proc.start() + yield proc + os.kill(proc.pid, signal.SIGINT) + + +@pytest.fixture +def client(request, m_endpoints): + client = Client(m_endpoints[0]) + request.addfinalizer(client.close) + return client + + +def test_client(server, client): + assert client.call('add', 1, 1) == 2 + assert client.call('foo') == 'bar' + with pytest.raises(RPCErrorError): + client.call('non_existent_method') + try: + client.call('raise_error') + except RPCErrorError as e: + # Get the full traceback and make sure it gets propagated correctly. Remove line numbers. + full_traceback = ''.join([i for i in str(e) if not i.isdigit()]) + assert 'Oops.\nTraceback (most recent call last):\n ' in full_traceback + assert 'ValueError: Oops.' in full_traceback + + +def test_client_timeout(server, client): + client.timeout = 0.05 + with pytest.raises(TimeoutError): + client.call('sleep', 0.1) + + +# regression test for buildup of requests to Server until Client is closed and reopened +def test_client_backlog(server, client): + # Test 1: The call to 'add' will actually receive the response to 'sleep' so we need to make sure it will discard it + # This should fail if you remove the while loop from Client.call + client.timeout = 0.1 + with pytest.raises(TimeoutError): + client.call('sleep', 0.2) + time.sleep(0.4) + assert client.call('add', 1, 1) == 2 + + # Test 2: Keep track of timeouts correctly even when the client has received a response for a different request + # This should fail if you remove the manual elapsed time tracking from Client.call + with pytest.raises(TimeoutError): + client.call('sleep', 0.18) + with pytest.raises(TimeoutError): + client.call('sleep', 0.12) + + +@pytest.mark.asyncio +async def test_async_client(server, client): + reply = await client.call_async('foo') + with pytest.raises(RPCErrorError): + await client.call_async('non_existent_method') + try: + await client.call_async('raise_error') + except RPCErrorError as e: + # Get the full traceback and make sure it gets propagated correctly. Remove line numbers. + full_traceback = ''.join([i for i in str(e) if not i.isdigit()]) + assert 'Oops.\nTraceback (most recent call last):\n ' in full_traceback + assert 'ValueError: Oops.' in full_traceback + + assert reply == "bar" + + +@pytest.mark.asyncio +async def test_async_client_timeout(server, client): + client.timeout = 0.05 + with pytest.raises(TimeoutError): + await client.call_async('sleep', 0.1) + + +@pytest.mark.asyncio +async def test_parallel_calls(server, client): + # Add a sleep to the first call to force the server to return the replies out of order + a = asyncio.ensure_future(client.call_async('sleep', 0.5)) + b = asyncio.ensure_future(client.call_async('sleep', 0)) + + assert await a == 0.5 + assert await b == 0 + + +@pytest.mark.asyncio +async def test_cancelling(server, client): + # This is a very deliberate test. It tests the case where one client has sent a request but was cancelled prior + # to receiving anything from the server. This means another client will receive an orphaned message and needs to + # store it but continue asking for messages. + a = asyncio.ensure_future(client.call_async('sleep', 0.3)) + b = asyncio.ensure_future(client.call_async('sleep', 0.6)) + + # Add a small sleep here to release the event loop and allow both of the above calls to be sent + await asyncio.sleep(0.1) + a.cancel() + assert await b == 0.6 + + +@pytest.mark.asyncio +async def test_three_cancelling(server, client): + # Same as above but with three async calls + a = asyncio.ensure_future(client.call_async('sleep', 0.3)) + b = asyncio.ensure_future(client.call_async('sleep', 0.6)) + c = asyncio.ensure_future(client.call_async('sleep', 0.6)) + + await asyncio.sleep(0.1) + a.cancel() + assert await b == 0.6 + assert await c == 0.6 + + +@pytest.mark.asyncio +async def test_two_clients(server, request, m_endpoints): + # ZeroMQ should be able to handle two independent client sockets by giving them each separate identities + client1 = Client(m_endpoints[0]) + request.addfinalizer(client1.close) + + client2 = Client(m_endpoints[0]) + request.addfinalizer(client2.close) + + a = asyncio.ensure_future(client1.call_async('sleep', 0.3)) + b = asyncio.ensure_future(client1.call_async('sleep', 0.1)) + + c = asyncio.ensure_future(client2.call_async('sleep', 0.2)) + d = asyncio.ensure_future(client2.call_async('sleep', 0.4)) + + assert await a == 0.3 + assert await b == 0.1 + assert await c == 0.2 + assert await d == 0.4 diff --git a/rpcq/test/test_spec.py b/rpcq/test/test_spec.py new file mode 100644 index 0000000..638466c --- /dev/null +++ b/rpcq/test/test_spec.py @@ -0,0 +1,125 @@ +############################################################################## +# Copyright 2018 Rigetti Computing +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +############################################################################## +import asyncio +import time + +import pytest + +from rpcq.messages import RPCError +from rpcq._spec import RPCSpec +from rpcq._utils import rpc_request + + +class MyClass(object): + classvar = 4 + + def __init__(self, num): + self.num = num + + async def add(self, *args): + asyncio.sleep(0.1) + return sum(args) + self.num + + def blocking_add(self, *args): + time.sleep(0.1) + return sum(args) + self.num + + @classmethod + def add_to_classvar(cls, num): + return cls.classvar + num + + +def foo(): + return 'bar' + + +def two_params(a: int, b: int): + return a * b + + +obj = MyClass(5) + +json_rpc_spec = RPCSpec() +json_rpc_spec.add_handler(obj.add) +json_rpc_spec.add_handler(obj.blocking_add) +json_rpc_spec.add_handler(obj.add_to_classvar) +json_rpc_spec.add_handler(foo) +json_rpc_spec.add_handler(two_params) + + +@pytest.mark.asyncio +async def test_json_rpc_call(): + request = rpc_request("add", 1, 2) + + # Execute method on object + reply = await json_rpc_spec.run_handler(request) + + assert reply.result == 8 + assert reply.id == request.id + + request2 = rpc_request("add", 1) + + # Execute method on object + reply2 = await json_rpc_spec.run_handler(request2) + + assert reply2.result == 6 + assert reply2.id == request2.id + + request3 = rpc_request("bad_name", 1) + + # Execute method on object + reply3 = await json_rpc_spec.run_handler(request3) + assert isinstance(reply3, RPCError) + + +@pytest.mark.asyncio +async def test_blocking_json_rpc_call(): + request = rpc_request("blocking_add", 1, 2) + reply = await json_rpc_spec.run_handler(request) + + assert reply.result == 8 + assert reply.id == request.id + + +@pytest.mark.asyncio +async def test_json_rpc_function(): + request = rpc_request("foo") + reply = await json_rpc_spec.run_handler(request) + + assert reply["result"] == "bar" + + +@pytest.mark.asyncio +async def test_json_rpc_classmethod_call(): + request = rpc_request("add_to_classvar", 1) + reply = await json_rpc_spec.run_handler(request) + + assert reply["result"] == 5 + + +@pytest.mark.asyncio +async def test_mixed_args_kwargs(): + request = rpc_request('two_params', 2, 3) + reply = await json_rpc_spec.run_handler(request) + assert reply["result"] == 6 + + request = rpc_request('two_params', 2, b=3) + reply = await json_rpc_spec.run_handler(request) + assert reply["result"] == 6 + + request = rpc_request('two_params', a=2, b=3) + reply = await json_rpc_spec.run_handler(request) + assert reply["result"] == 6 diff --git a/rpcq/version.py b/rpcq/version.py new file mode 100644 index 0000000..0b4b43a --- /dev/null +++ b/rpcq/version.py @@ -0,0 +1,24 @@ +############################################################################## +# Copyright 2018 Rigetti Computing +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +############################################################################## +""" +NOTE: This file will be overwritten by the packaging process. +""" +import os + +directory_of_this_file = os.path.dirname(os.path.abspath(__file__)) + +with open(f'{directory_of_this_file}/../VERSION.txt', 'r') as f: + __version__ = f.read().strip() diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..d6d72bc --- /dev/null +++ b/setup.py @@ -0,0 +1,51 @@ +############################################################################## +# Copyright 2018 Rigetti Computing +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +############################################################################## + +from setuptools import setup + +with open('VERSION.txt', 'r') as f: + __version__ = f.read().strip() + +with open('README.md', 'r') as f: + long_description = f.read() + +# overwrite version.py in the source distribution +with open('rpcq/version.py', 'w') as f: + f.write(f'__version__ = \'{__version__}\'\n') + +setup( + name='rpcq', + version=__version__, + author='Rigetti Computing', + author_email='info@rigetti.com', + license='Apache-2.0', + packages=[ + 'rpcq', + ], + url='https://github.com/rigetticomputing/rpcq.git', + description='''The RPC framework and message specification for Rigetti QCS.''', + long_description=long_description, + install_requires=[ + 'future', + 'msgpack>=0.5.2', + 'python-rapidjson', + 'pyzmq>=17', + 'ruamel.yaml', + 'typing' + ], + keywords='quantum rpc qcs', + python_requires='>=3.5', +) diff --git a/src-tests/package.lisp b/src-tests/package.lisp new file mode 100644 index 0000000..da5de82 --- /dev/null +++ b/src-tests/package.lisp @@ -0,0 +1,23 @@ +;;;; src-tests/package.lisp +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;; Copyright 2018 Rigetti Computing +;;;; +;;;; Licensed under the Apache License, Version 2.0 (the "License"); +;;;; you may not use this file except in compliance with the License. +;;;; You may obtain a copy of the License at +;;;; +;;;; http://www.apache.org/licenses/LICENSE-2.0 +;;;; +;;;; Unless required by applicable law or agreed to in writing, software +;;;; distributed under the License is distributed on an "AS IS" BASIS, +;;;; WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +;;;; See the License for the specific language governing permissions and +;;;; limitations under the License. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(fiasco:define-test-package #:rpcq-tests + (:use #:rpcq) + + ;; suite.lisp + (:export + #:run-rpcq-tests)) diff --git a/src-tests/suite.lisp b/src-tests/suite.lisp new file mode 100644 index 0000000..551a614 --- /dev/null +++ b/src-tests/suite.lisp @@ -0,0 +1,71 @@ +;;;; suite.lisp +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;; Copyright 2018 Rigetti Computing +;;;; +;;;; Licensed under the Apache License, Version 2.0 (the "License"); +;;;; you may not use this file except in compliance with the License. +;;;; You may obtain a copy of the License at +;;;; +;;;; http://www.apache.org/licenses/LICENSE-2.0 +;;;; +;;;; Unless required by applicable law or agreed to in writing, software +;;;; distributed under the License is distributed on an "AS IS" BASIS, +;;;; WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +;;;; See the License for the specific language governing permissions and +;;;; limitations under the License. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(in-package :rpcq-tests) + +(defun run-rpcq-tests (&key (verbose nil) (headless nil)) + "Run all rpcq tests. If VERBOSE is T, print out lots of test info. If HEADLESS is T, disable interactive debugging and quit on completion." + (setf fiasco::*test-run-standard-output* (make-broadcast-stream *standard-output*)) + (cond + ((null headless) + (run-package-tests :package ':rpcq-tests + :verbose verbose + :describe-failures t + :interactive t)) + (t + (let ((successp (run-package-tests :package ':rpcq-tests + :verbose t + :describe-failures t + :interactive nil))) + (uiop:quit (if successp 0 1)))))) + + +(deftest test-defmessage () + (rpcq::defmessage my-msg + ((required-int + :type :integer + :required t + :documentation "Required and no default") + (optional-map + :type (:map :string -> :integer) + :required nil + :documentation "Optional mapping" + :default (:yo "working")) + (str + :type :string + :required t + :default "a string" + :documentation "String docs." + ) + (flt + :type :float + :required t + :default 0.0 + :documentation "A float." + ) + ) + :documentation "Test message" + ) + (let ((m (make-instance 'my-msg :required-int 5))) + (is (= (my-msg-required-int m)) 5) + (is (= (hash-table-count (my-msg-optional-map m)) 1)) + (is (string= (gethash "yo" (my-msg-optional-map m)) "working")) + (is (length rpcq::*messages*) 1) + (is (typep (my-msg-flt m) 'double-float)) + (is (string= (my-msg-str m) "a string")) + ) + ) diff --git a/src/messages.lisp b/src/messages.lisp new file mode 100644 index 0000000..bf885b1 --- /dev/null +++ b/src/messages.lisp @@ -0,0 +1,332 @@ +;;;; messages.lisp +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;; Copyright 2018 Rigetti Computing +;;;; +;;;; Licensed under the Apache License, Version 2.0 (the "License"); +;;;; you may not use this file except in compliance with the License. +;;;; You may obtain a copy of the License at +;;;; +;;;; http://www.apache.org/licenses/LICENSE-2.0 +;;;; +;;;; Unless required by applicable law or agreed to in writing, software +;;;; distributed under the License is distributed on an "AS IS" BASIS, +;;;; WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +;;;; See the License for the specific language governing permissions and +;;;; limitations under the License. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(in-package #:rpcq) + + +(defmessage |ParameterSpec| + ( + (|type| + :documentation "The parameter type, e.g., one of 'INTEGER', or 'FLOAT'." + :type :string + :required t + :default nil) + + (|length| + :documentation "If this is not 1, the parameter is an array of this length." + :type :integer + :required t + :default 1) + ) + :documentation "Specification of a dynamic parameter type and array-length.") + +(defmessage |ParameterAref| + ( + (|name| + :documentation "The parameter name" + :type :string + :required t) + + (|index| + :documentation "The array index." + :type :integer + :required t) + ) + :documentation "A parametric expression.") + +(defmessage |PatchTarget| + ( + (|patch_type| + :documentation "Data type at this address." + :type |ParameterSpec| + :required t) + + (|patch_offset| + :documentation "Memory address of the patch." + :type :integer + :required t) + ) + + :documentation "Patchable memory location descriptor.") + +(defmessage |RPCRequest| + ( + (|jsonrpc| + :documentation "The JSONRPC version." + :type :string + :required t + :default "2.0") + + (|method| + :documentation "The RPC function name." + :type :string + :required t) + + (|params| + :documentation "The RPC function arguments." + :type :any + :required t) + + (|id| + :documentation "RPC request id (used to verify that request and response belong together)." + :type :string + :required t) + ) + :documentation "A single request object according to the JSONRPC standard.") + +(defmessage |RPCReply| + ( + (|jsonrpc| + :documentation "The JSONRPC version." + :type :string + :required t + :default "2.0") + + (|result| + :documentation "The RPC result." + :type :any + :default nil) + + (|id| + :documentation "The RPC request id." + :type :string + :required t) + ) + :documentation "The reply for a JSONRPC request.") + +(defmessage |RPCError| + ( + (|jsonrpc| + :documentation "The JSONRPC version." + :type :string + :required t + :default "2.0") + + (|error| + :documentation "The error message." + :type :string + :required t) + + (|id| + :documentation "The RPC request id." + :type :string + :required t) + ) + :documentation "A error message for JSONRPC requests.") + +(defmessage |TargetDevice| + ((|isa| + :documentation "Instruction-set architecture for this device." + :type (:map :string -> :map) + :required t) + + (|specs| + :documentation "Fidelities and coherence times for this device." + :type (:map :string -> :map) + :required t)) + :documentation "ISA and specs for a particular device.") + +(defmessage |RandomizedBenchmarkingRequest| + ((|depth| + :documentation "Depth of the benchmarking sequence." + :type :integer + :required t) + + (|qubits| + :documentation "Number of qubits involved in the benchmarking sequence." + :type :integer + :required t) + + (|gateset| + :documentation "List of Quil programs, each describing a Clifford." + :type (:list :string) + :required t) + + (|seed| + :documentation "PRNG seed. Set this to guarantee repeatable results." + :type :integer + :required nil)) + + :documentation "RPC request payload for generating a randomized benchmarking sequence.") + +(defmessage |RandomizedBenchmarkingResponse| + ((|sequence| + :documentation "List of Cliffords, each expressed as a list of generator indices." + :type (:list (:list :integer)) + :required t)) + + :documentation "RPC reply payload for a randomly generated benchmarking sequence.") + +(defmessage |PauliTerm| + ((|indices| + :documentation "Qubit indices onto which the factors of a Pauli term are applied." + :type (:list :integer) + :required t) + + (|symbols| + :documentation "Ordered factors of a Pauli term." + :type (:list :string) + :required t)) + + :documentation "Specification of a single Pauli term as a tensor product of Pauli factors.") + +(defmessage |ConjugateByCliffordRequest| + ((|pauli| + :documentation "Specification of a Pauli element." + :type |PauliTerm| + :required t) + + (|clifford| + :documentation "Specification of a Clifford element." + :type :string + :required t)) + + :documentation "RPC request payload for conjugating a Pauli element by a Clifford element.") + +(defmessage |ConjugateByCliffordResponse| + ((|phase| + :documentation "Encoded global phase factor on the emitted Pauli." + :type :integer + :required t) + + (|pauli| + :documentation "Description of the encoded Pauli." + :type :string + :required t)) + + :documentation "RPC reply payload for a Pauli element as conjugated by a Clifford element.") + +(defmessage |NativeQuilRequest| + ((|quil| + :documentation "Arbitrary Quil to be sent to quilc." + :type :string + :required t) + + (|target_device| + :documentation "Specifications for the device to target with quilc." + :type |TargetDevice| + :required t)) + :documentation "Quil and the device metadata necessary for quilc.") + +(defmessage |NativeQuilMetadata| + ((|final_rewiring| + :documentation "Output qubit index relabeling due to SWAP insertion." + :type (:list :integer) + :required nil) + + (|gate_depth| + :documentation "Maximum number of successive gates in the native quil program." + :type :integer + :required nil) + + (|gate_volume| + :documentation "Total number of gates in the native quil program." + :type :integer + :required nil) + + (|multiqubit_gate_depth| + :documentation "Maximum number of successive two-qubit gates in the native quil program." + :type :integer + :required nil) + + (|program_duration| + :documentation "Rough estimate of native quil program length in nanoseconds." + :type :float + :required nil) + + (|program_fidelity| + :documentation "Rough estimate of the fidelity of the full native quil program, uses specs." + :type :float + :required nil) + + (|topological_swaps| + :documentation "Total number of SWAPs in the native quil program." + :type :integer + :required nil)) + :documentation "Metadata for a native quil program.") + +(defmessage |NativeQuilResponse| + ((|quil| + :documentation "Native Quil returned from quilc." + :type :string + :required t) + + (|metadata| + :documentation "Metadata for the returned Native Quil." + :type |NativeQuilMetadata| + :required nil)) + :documentation "Native Quil and associated metadata returned from quilc.") + +(defmessage |BinaryExecutableRequest| + ((|quil| + :documentation "Native Quil to be translated into an executable program." + :type :string + :required t) + + (|num_shots| + :documentation "The number of times to repeat the program." + :type :integer + :required t)) + :documentation "Native Quil and the information needed to create binary executables.") + +(defmessage |BinaryExecutableResponse| + ((|program| + :documentation "Execution settings and sequencer binaries." + :type :string + :required t) + + (|memory_descriptors| + :documentation "Internal field for constructing patch tables." + :type (:map :string -> |ParameterSpec|) + :required nil + :default nil) + + (|ro_sources| + :documentation "Internal field for reshaping returned buffers." + :type (:list :any) + :required nil + :default nil)) + :documentation "Program to run on the QPU.") + +(defmessage |PyQuilExecutableResponse| + ((|program| + :documentation "String representation of a Quil program." + :type :string + :required t) + + (|attributes| + :documentation "Miscellaneous attributes to be unpacked onto the pyQuil Program object." + :type (:map :string -> :any) + :required t)) + :documentation "rpcQ-serializable form of a pyQuil Program object.") + +(defmessage |QPURequest| + ((|program| + :documentation "Execution settings and sequencer binaries." + :type :any + :required t) + + (|patch_values| + :documentation "Dictionary mapping data names to data values for patching the binary." + :type (:map :string -> (:list :any)) + :required t) + + (|id| + :documentation "QPU request ID." + :type :string + :required t)) + :documentation "Program and patch values to send to the QPU for execution.") diff --git a/src/package.lisp b/src/package.lisp new file mode 100644 index 0000000..42dd6b6 --- /dev/null +++ b/src/package.lisp @@ -0,0 +1,25 @@ +;;;; src/package.lisp +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;; Copyright 2018 Rigetti Computing +;;;; +;;;; Licensed under the Apache License, Version 2.0 (the "License"); +;;;; you may not use this file except in compliance with the License. +;;;; You may obtain a copy of the License at +;;;; +;;;; http://www.apache.org/licenses/LICENSE-2.0 +;;;; +;;;; Unless required by applicable law or agreed to in writing, software +;;;; distributed under the License is distributed on an "AS IS" BASIS, +;;;; WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +;;;; See the License for the specific language governing permissions and +;;;; limitations under the License. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defpackage #:rpcq + (:use #:cl #:alexandria #:yason) + (:export #:defmessage + #:serialize + #:deserialize + #:clear-messages + #:python-message-spec)) + diff --git a/src/rpcq.lisp b/src/rpcq.lisp new file mode 100644 index 0000000..8888371 --- /dev/null +++ b/src/rpcq.lisp @@ -0,0 +1,592 @@ +;;;; rpcq.lisp +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;; Copyright 2018 Rigetti Computing +;;;; +;;;; Licensed under the Apache License, Version 2.0 (the "License"); +;;;; you may not use this file except in compliance with the License. +;;;; You may obtain a copy of the License at +;;;; +;;;; http://www.apache.org/licenses/LICENSE-2.0 +;;;; +;;;; Unless required by applicable law or agreed to in writing, software +;;;; distributed under the License is distributed on an "AS IS" BASIS, +;;;; WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +;;;; See the License for the specific language governing permissions and +;;;; limitations under the License. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(in-package #:rpcq) + +;;; contains tools for manipulating and (de-)serializing messages +;;; that are passed around the Rigetti core stack. + + +;; store all messages defined thus far +(defvar *messages* nil) + +(defun clear-messages () + "Clear the stored message definitions." + (setf *messages* nil)) + + +(deftype atom-type () + '(member :string :bytes :bool :float :integer :any)) + + +(defparameter *python-types* + '(:string "str" + :bytes "bytes" + :float "float" + :integer "int" + :bool "bool" + :map "dict" + :list "list" + :any "object")) + +(defparameter *python-instance-check-types* + '(:string "basestring" + :bytes "bytes" + :float "float" + :integer "int" + :bool "bool" + :map "dict" + :list "list" + :any "object")) + +(defun format-documentation-string (string) + "Format a documentation string STRING into its final stored representation. + +The input strings are assumed to be FORMAT-compatible, so sequences like ~ are allowed." + (check-type string string) + (format nil string)) + +(defun python-instance-check-type (field-type) + (let ((*python-types* *python-instance-check-types*)) + (python-type field-type))) + +(defun to-octets (string) + "Convert a string S to a vector of 8-bit unsigned bytes" + (map 'simple-vector 'char-code string)) + +(defun to-string (octets) + "Convert a vector of octets to a string" + (map 'string 'code-char octets)) + +(defun python-argspec-default (field-type default) + "Translate DEFAULT values for immutable objects of a given +FIELD-TYPE to python." + (case field-type + ((:string) + (if default + (format nil "~S" default) + "None")) + ((:bytes) + (if default + (format nil "b~S" (to-string default)) + "None")) + ((:bool) + (if default + "True" + "False")) + ((:integer) + (if default + (format nil "~d" default) + "None")) + ((:float) + (if default + (format nil "~e" default) + "None")) + (otherwise + "None"))) + +(defun python-type (field-type) + "Always return a basic python type not List[...] or Dict[...] for +instance checks." + (python-typing-type + (if (listp field-type) + (car field-type) + field-type))) + +(defun python-typing-type (field-type) + "Return the python typing-module compliant field type" + (cond + ((keywordp field-type) + (assert (member field-type *python-types*) + (field-type) + "Unknown field-type ~S" field-type) + (getf *python-types* field-type)) + ((symbolp field-type) + ;; field-type is assumed to be message object + (format nil "~a" field-type)) + ((eq (car field-type) :list) + (format nil "List[~a]" (python-typing-type (cadr field-type)))) + ((eq (car field-type) :map) + (assert (string= (symbol-name (caddr field-type)) "->") + (field-type) + "Bad mapping spec.") + (format nil + "Dict[~a,~b]" + (python-typing-type (cadr field-type)) + (python-typing-type (cadddr field-type)))))) + +(defun python-maybe-optional-typing-type (field-type required) + "Rerturn the python type string for FIELD-TYPE while +accounting for whether the field is REQUIRED. +" + (let ((b (python-typing-type field-type))) + (if (or required + (listp field-type)) + b + (format nil "Optional[~a]" b)))) + +(defun python-collections-initform (field-type default) + "Translate a DEFAULT value of type FIELD-TYPE to a python initform." + (check-type field-type list) + (cond + ;; handle lists + ((eq :list (car field-type)) + (if (null default) + "[]" + (with-output-to-string (s) + (yason:encode default s)))) + + ;; handle mappings + ((eq :map (car field-type)) + (if (null default) + "{}" + (with-output-to-string (s) + (yason:encode (%plist-to-string-hash-table default) s)))) + (t + (error "Unrecognized field-type ~A" field-type)))) + + +(defun python-message-spec (&optional (stream nil)) + "print an importable python file with the message definitions" + (format stream "#!/usr/bin/env python + +\"\"\" +WARNING: This file is auto-generated, do not edit by hand. See README.md. +\"\"\" + +import warnings +from rpcq._base import Message +from typing import List, Dict, Optional + +# Python 2/3 str/unicode compatibility +from past.builtins import basestring + + +class CoreMessages(object): + \"\"\" + WARNING: This class is auto-generated, do not edit by hand. See README.md. + This class is also DEPRECATED. + \"\"\" + + +class _deprecated_property(object): + + def __init__(self, prop): + self.prop = prop + + def __get__(self, *args): + warnings.warn( + \"'CoreMessages.{0}' is deprecated. Please access '{0}' directly at the module level.\".format( + self.prop.__name__), + UserWarning) + return self.prop + +") + ;; for each message we need: + ;; - the message name and documentation + ;; - the message field names to define the + ;; slots as well as the asdict and astuple methods + ;; - the constructor definition argument specification + ;; (includes default values for primitive types) + ;; - the constructor type signature + ;; - the default values for lists and dicts + ;; - the not-None checks for all required arguments + ;; - the type checks for all arguments (accounting for required args) + ;; - the instance attribute assignment plus attribute docstring + (loop :for (msg-name field-specs documentation) :in *messages* + :collect + (let + (slot-names + args-no-default + args-with-default + required-args + field-instantiations + type-checks + collections-defaults) + (loop :for (name . params) :in (reverse field-specs) + :for required := (getf params :required) + :for type := (getf params :type) + :for typing-type := (python-maybe-optional-typing-type type required) + :for basic-type := (python-type type) + :for instance-check-type := (python-instance-check-type type) + :for defaultp := (member :default params) + :for default := (getf params :default) + :for collectionp := (listp type) + :for documentation := (getf params :documentation + (format nil "Field ~a of type ~a" name type)) + :do + ;; slot names + (push name slot-names) + + ;; constructor arguments + (if (and required + (not defaultp)) + + ;; regular positional argument + (push (list name typing-type) args-no-default) + + ;; keyword argument + (push (list + (format nil "~a=~a" name + (python-argspec-default + type + default)) + typing-type) + args-with-default)) + + ;; required arguments that are not collections with + ;; default values + (when (and required + (or + (not defaultp) + (not collectionp))) + (push name required-args)) + + ;; instance attribute initializations + (push (list name typing-type documentation) field-instantiations) + + ;; type checking + (push + (list required name instance-check-type name instance-check-type name) + type-checks) + + ;; Initialize default values for collections + (when (and collectionp + (or (not required) + defaultp)) + (push (list + name + (python-collections-initform type default)) + collections-defaults))) + (let + ;; concatenate positional and keyword args + ((init-arg-spec + (concatenate 'list + (mapcar #'car args-no-default) + (mapcar #'car args-with-default))) + + ;; constructor argument type signature + (typing-types-arg-spec + (concatenate 'list + (mapcar #'cadr args-no-default) + (mapcar #'cadr args-with-default)))) + + ;; Add class header, documentation and implementations of + ;; asdict and astuple + (format stream + "~& +class ~A(Message): + \"\"\"~a\"\"\" + + # fix slots + __slots__ = ( + ~{'~a'~^, + ~}, + ) + + def asdict(self): + \"\"\"Generate dictionary representation of self.\"\"\" + return { + ~:*~{'~a~:*': self.~a~^, + ~} + } + + def astuple(self): + \"\"\"Generate tuple representation of self.\"\"\" + return ( + ~:*~{self.~a~^, + ~} + )~%" + (symbol-name msg-name) + (or documentation (symbol-name msg-name)) + slot-names) + + ;; Add constructor signature and type hint + (format stream " + def __init__(self, + ~{~a~^, + ~}, + **kwargs): + # type: (~{~a~^, ~}) -> None~% + if kwargs: + warnings.warn((\"Message {} ignoring unexpected keyword arguments: \" + \"{}.\").format(self.__class__.__name__, \", \".join(kwargs.keys()))) +" + init-arg-spec + typing-types-arg-spec) + + ;; Initialize collections (list+dict) that either have + ;; default values or are optional to empty containers if + ;; they are None + (when collections-defaults + (format stream " + ~:[~;# initialize default values of collections~]~:* + ~{if ~a is None: + ~:*~a = ~a~^ + ~}~%" + (apply #'append collections-defaults))) + + ;; Check that required fields are not None (skip + ;; collections with default values as those are + ;; automatically converted to empty containers above + (when required-args + (format stream " + ~:[~;# check presence of required fields~]~:* + ~{if ~a~:* is None: + raise ValueError(\"The field '~a' cannot be None\")~^ + ~}~%" + required-args)) + + ;; Verify field types. For non-required fields a value of + ;; None is permitted + (when type-checks + (format stream " + ~:[~;# verify types~]~:* + ~{if not ~:[(~a is None or isinstance(~:*~a, ~a))~;isinstance(~a, ~a)~]: + raise TypeError(\"Parameter ~a must be of type ~a, \" + + \"but object of type {} given\".format(type(~a)))~^ + ~}~%" + (apply #'append type-checks))) + + ;; Actually set the instance attributes and add type hint + ;; and docstring + (format stream " + ~{self.~a~:* = ~a # type: ~a + \"\"\"~a\"\"\"~^ + + ~}~%" + (apply #'append field-instantiations)) + (format stream " +CoreMessages.~A = _deprecated_property(~:*~A) +" (symbol-name msg-name)))))) + +(defgeneric serialize (obj stream) + (:documentation "Serialize OBJ and append its representation to STREAM")) + +(defmethod serialize (obj stream) + (yason:encode obj stream)) + +(defgeneric %deserialize (payload) + (:documentation "Reconstruct objects that have already been converted to Lisp objects.")) + +(defgeneric %deserialize-struct (type payload)) + +(defmethod %deserialize ((payload cons)) + (loop :for elt :in payload :collect (%deserialize elt))) + +(defmethod %deserialize ((payload hash-table)) + (let ((type (gethash "_type" payload))) + (if type + (%deserialize-struct (intern type :rpcq) payload) + (let ((result (make-hash-table :test 'equal))) + (loop :for k :being :the :hash-keys :of payload + :using (hash-value v) + :do (setf (gethash k result) (%deserialize v))) + result)))) + +(defmethod %deserialize (payload) + payload) + +(defun deserialize (payload) + "Deserialize the object(s) encoded in PAYLOAD (string or stream)." + (%deserialize (let ((*read-default-float-format* 'double-float)) + (yason:parse payload :json-arrays-as-vectors t)))) + +(defun slot-type-and-initform (field-type required default) + "Translate a FIELD-TYPE to a Lisp type and initform taking into account +whether the field is REQUIRED and a specified DEFAULT value. + +The primitive field types must be specified as one of + + :string :bytes :integer :float :bool + +Message field types are specified by their class name, e.g. for +a Calibration message the field type is + + |Calibration| + +List and mapping field types are specified as + + (:list x) (:map :string -> x) + +where x is one of +{:string, :bytes, :any, :integer, :float, :bool, :list, :mapping} or a +message field type. We currently only support :string keys for +mappings as JSON does not support other kinds of keys but we +nonetheless explicitly include the key type for better readability + +If a field is non-required, it may be None on the python side or NIL +in Lisp. + +We distinguish between the following options for any field type: +1. Required with no default + - invalid not to provide a value for this field + - invalid to pass None/null/NIL for this field +2. Required with a default + - if a value is not provided, then a fallback value will be used + - invalid to pass None/null/NIL for this field +3. Optional with no default (equivalent to optional with a default of None in python, + null in JSON, NIL in Lisp) + - valid to either not provide a value or to pass None for this field +4. Optional with a default + - if a value is not provided, then a fallback value is used + - you can explicitly pass None/null/NIL for this field +" + (cond + + ;; handle :string :integer :float :bool :bytes + ((keywordp field-type) + (check-type field-type atom-type) + (let* + ((basic-type (getf '(:string (simple-array character) + :bytes (simple-array (unsigned-byte 8)) + :integer fixnum + :float double-float + :bool boolean + :any t) + field-type)) + ;; make sure the default value (if defined) is coerced + ;; to correct type + (coerced-default (when default + (if (and (eq :bytes field-type) (typep default 'string)) + ;; accept a string as the default value for a bytes object + (to-octets default) + (coerce default basic-type))))) + + (if required + (values basic-type coerced-default) + + ;; else also allow NIL + (values `(or null ,basic-type) coerced-default)))) + + ;; handle defined message types + ((symbolp field-type) + (if required + (values `(or null ,field-type) default) + (values field-type default))) + + ;; handle lists + ((eq ':list (car field-type)) + ;; Need not check if REQUIRED as NIL is still of type list + ;; We use 'simple-vector rather than 'list as this maps better to + ;; the JSON distinction betweel null and [] + (values 'simple-vector (coerce default 'simple-vector))) + + ;; handle mappings + ((eq ':map (car field-type)) + (let ((initform + (if default + + ;; default should be specified as plist + `(%plist-to-string-hash-table ',default) + '(make-hash-table :test 'equalp)))) + (if required + (values 'hash-table initform) + (values '(or null hash-table) initform)))) + (t + (error "Unrecognized field-type ~A" field-type)))) + + +(defun map-plist (f plist) + "Iterate over a PLIST and call F with key and value as parameters." + (loop :for (k v) :on plist :by #'cddr + :do (funcall f k v))) + +(defun %plist-to-string-hash-table (plist) + "Generate a hash-table from a PLIST while simultaneously converting the keys to strings." + (let ((tbl (make-hash-table :test 'equalp))) + (map-plist (lambda (k v) + (setf (gethash (symbol-name k) tbl) v)) + plist) + tbl)) + + +(defmacro defmessage (class-name field-specs &key (documentation nil)) + "Create a (de-)serializable message definition with name CLASS-NAME and slots (SLOT-SPECS)." + (check-type class-name symbol) + (setf *messages* (nconc *messages* `((,class-name ,field-specs ,documentation)))) + (flet ((accessor (slot-name) + (alexandria:symbolicate (symbol-name class-name) + "-" + (symbol-name slot-name)))) + (flet ((make-slot-spec (field-spec) + (let* + ((slot-name (car field-spec)) + (field-settings (cdr field-spec)) + (field-type (getf field-settings :type)) + (required (getf field-settings :required)) + (documentation (getf field-settings :documentation)) + (defaultp (member :default field-settings)) + (default (getf field-settings :default))) + + (multiple-value-bind (slot-type initform) + (slot-type-and-initform field-type required default) + `(,slot-name :initarg ,(intern (symbol-name slot-name) :keyword) + :reader ,(accessor slot-name) + :type ,slot-type + + ;; only add documentation if present + ,@(when documentation + (list :documentation (format-documentation-string + documentation))) + + ;; if no default value given + ;; but field is required raise error + ,@(if (and required (not defaultp)) + `(:initform (error + (concatenate + 'string + "Missing value for field " + ,(symbol-name slot-name)))) + ;; else initialize to + ;; initform generated by TRANSLATE-FIELD-TYPE + `(:initform ,initform)))))) + (init-spec (json) + (lambda (slot-name) + `( ,(intern (symbol-name slot-name) :keyword) + (%deserialize (gethash ,(symbol-name slot-name) ,json)))))) + (alexandria:with-gensyms (obj) + (let ((slot-names (mapcar #'car field-specs))) + `(progn + (defclass ,class-name () + ,(mapcar #'make-slot-spec field-specs) + ,@(when documentation + `((:documentation ,(format-documentation-string documentation))))) + + (defmethod yason:encode ((,obj ,class-name) &optional (stream *standard-output*)) + (with-slots ,slot-names ,obj + (yason:with-output (stream) + (yason:with-object () + (yason:encode-object-element "_type" ,(symbol-name class-name)) + ,@(loop :for slot :in slot-names + :collect `(yason:encode-object-element ,(string-downcase (symbol-name slot)) + ,slot)))))) + + (defmethod %deserialize-struct ((type (eql ',class-name)) (payload hash-table)) + (assert (string= (gethash "_type" payload) ,(symbol-name class-name))) + (make-instance ',class-name + ,@(mapcan (init-spec 'payload) slot-names))) + + + (defmethod print-object ((,obj ,class-name) stream) + (print-unreadable-object (,obj stream :type t) + (pprint-indent :block 2) + ,@(loop :for slot :in slot-names + :collect `(format stream + "~& ~A -> ~S" + ,(symbol-name slot) + (,(accessor slot) ,obj)))))))))))