diff --git a/.travis.yml b/.travis.yml index da28bbe98..d638f15ec 100644 --- a/.travis.yml +++ b/.travis.yml @@ -81,7 +81,6 @@ stage_osx: &stage_osx stages: - lint and pure python test - test - - old api - slow tests # Define the job matrix explicitly, as matrix expansion causes issues when @@ -96,6 +95,13 @@ jobs: if: type != cron script: make style && make lint + # Type checking using mypy + - stage: lint and pure python test + name: mypy type checking + <<: *stage_linux + if: type != cron + script: make mypy + # Run the tests against without compilation (GNU/Linux, Python 3.5) - stage: lint and pure python test <<: *stage_linux @@ -146,24 +152,6 @@ jobs: - MPLBACKEND=ps - PYTHON_VERSION=3.7.2 - # "old api" stage - ########################################################################### - # GNU/Linux, Python 3.6, against Qconsole v1 - - stage: old api - name: Python 3.6 API v1 QConsole - <<: *stage_linux - if: branch = master and repo = Qiskit/qiskit-ibmq-provider and type = push - python: 3.6 - env: USE_ALTERNATE_ENV_CREDENTIALS=True - - # GNU/Linux, Python 3.6, against QE v1 - - stage: old api - name: Python 3.6 API v1 QE - <<: *stage_linux - if: branch = master and repo = Qiskit/qiskit-ibmq-provider and type = push - python: 3.6 - env: QE_URL="https://quantumexperience.ng.bluemix.net/api" - # "slow tests" stage ########################################################################### # GNU/Linux, Python 3.5 @@ -176,6 +164,7 @@ jobs: # effectively increases timeout to 2h. - travis_wait 120 make test - if: tag IS present + language: python python: "3.6" env: - TWINE_USERNAME=qiskit diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eb5eef9a..7ff795761 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,63 @@ The format is based on [Keep a Changelog]. > - **Security**: in case of vulnerabilities. +## [0.4.0] - 2019-11-12 + +### Added + +- A new `IBMQJobManager` class that takes a list of circuits or pulse schedules + as input, splits them into one or more jobs, and submits the jobs. + (\#389, \#400, \#407) +- New features to `provider.backends`: + - it contains the available backends as attributes. A user can now use + `provider.backends.` to see a list of backend names, and make + use of the attributes as regular `IBMQBackend` instances. (\#303) + - the methods `provider.backends.jobs()` and + `provider.backends.retrieve_job()` can be used for retrieving + provider-wide jobs. (\#354) + +### Changed + +- `IBMQBackend.run()` now accepts an optional `job_name` parameter. If + specified, the `job_name` is assigned to the job, which can also be used + as a filter in `IBMQBackend.jobs()`. (\#300, \#384) +- `IBMQBackend.run()` now accepts an optional `job_share_level` + parameter. If specified, the job could be shared with other users at the + global, hub, group, project, or none level. (\#414) +- The signature of `IBMQBackend.jobs()` is changed. `db_filter`, which was the + 4th parameter, is now the 5th parameter. (\#300) +- The `backend.properties()` function now accepts an optional `datetime` + parameter. If specified, the function returns the backend properties closest + to, but older than, the specified datetime filter. (\#277) +- The `WebsocketClient.get_job_status()` method now accepts two optional + parameters: `retries` and `backoff_factor`. (\#341) +- The `IBMQJob` class has been refactored: + - The `IBMQJob` attributes are now automatically populated based on the + information contained in the remote API. As a result, the `IBMQJob` + constructor signature has changed. (\#329) + - `IBMQJob.submit()` can no longer be called directly, and jobs are expected + to be submitted via `IBMQBackend.run()`. (\#329) + - `IBMQJob.error_message()` now gives more information on why a job failed. + (\#375) + - `IBMQJob.queue_position()` now accepts an optional `refresh` parameter that + indicates whether it should query the API for the latest queue position. + (\#387) + - The `IBMQJob.result()` function now accepts an optional `partial` parameter. + If specified, `IBMQJob.result()` will return partial results for jobs with + experiments that failed. (\#399) +- The Exception hierarchy has been refined with more specialized classes, and + exception chaining is used in some cases - please inspect the complete + traceback for more informative failures. (\#395, \#396) +- Some `warnings` have been toned down to `logger.warning` messages. (\#379) + +### Removed + +- Support for the legacy Quantum Experience and QConsole is fully deprecated. + Only credentials from the new Quantum Experience can be used. (\#344) +- The circuits functionality has been temporarily removed from the provider. It + will be reintroduced in future versions. (\#429) + + ## [0.3.3] - 2019-09-30 ### Fixed @@ -157,7 +214,8 @@ The format is based on [Keep a Changelog]. - Support for non-qobj format has been removed. (\#26, \#28) -[UNRELEASED]: https://github.com/Qiskit/qiskit-ibmq-provider/compare/0.3.3...HEAD +[UNRELEASED]: https://github.com/Qiskit/qiskit-ibmq-provider/compare/0.4.0...HEAD +[0.4.0]: https://github.com/Qiskit/qiskit-ibmq-provider/compare/0.3.3...0.4.0 [0.3.3]: https://github.com/Qiskit/qiskit-ibmq-provider/compare/0.3.2...0.3.3 [0.3.2]: https://github.com/Qiskit/qiskit-ibmq-provider/compare/0.3.1...0.3.2 [0.3.1]: https://github.com/Qiskit/qiskit-ibmq-provider/compare/0.3.0...0.3.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b79da7f4f..bdfd549c6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -238,10 +238,13 @@ The order of precedence in the options is right to left. For example, ### Style guide Please submit clean code and please make effort to follow existing -conventions in order to keep it as readable as possible. We use -[Pylint](https://www.pylint.org) and [PEP -8](https://www.python.org/dev/peps/pep-0008) style guide: to ensure your -changes respect the style guidelines, run the next commands: +conventions in order to keep it as readable as possible. We use: +* [Pylint](https://www.pylint.org) linter +* [PEP 8](https://www.python.org/dev/peps/pep-0008) style +* [mypy](http://mypy-lang.org/) type hinting + +To ensure your changes respect the style guidelines, you can run the following +commands: All platforms: @@ -249,6 +252,7 @@ All platforms: $> cd out out$> make lint out$> make style +out$> make mypy ``` Development cycle diff --git a/MANIFEST.in b/MANIFEST.in index 6fba00dce..15e2a2866 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,3 @@ include LICENSE.txt README.md include qiskit/providers/ibmq/VERSION.txt +recursive-include test *.py diff --git a/Makefile b/Makefile index e44e7b778..51c305f96 100644 --- a/Makefile +++ b/Makefile @@ -11,13 +11,16 @@ # that they have been altered from the originals. -.PHONY: lint style test +.PHONY: lint style test mypy lint: pylint -rn qiskit/providers/ibmq test +mypy: + mypy --module qiskit.providers.ibmq + style: - pycodestyle --max-line-length=100 qiskit test + pycodestyle qiskit test test: python -m unittest -v diff --git a/README.md b/README.md index 7864571f7..6fb53085f 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,7 @@ [![License](https://img.shields.io/github/license/Qiskit/qiskit-ibmq-provider.svg?style=popout-square)](https://opensource.org/licenses/Apache-2.0)[![Build Status](https://img.shields.io/travis/com/Qiskit/qiskit-ibmq-provider/master.svg?style=popout-square)](https://travis-ci.com/Qiskit/qiskit-ibmq-provider)[![](https://img.shields.io/github/release/Qiskit/qiskit-ibmq-provider.svg?style=popout-square)](https://github.com/Qiskit/qiskit-ibmq-provider/releases)[![](https://img.shields.io/pypi/dm/qiskit-ibmq-provider.svg?style=popout-square)](https://pypi.org/project/qiskit-ibmq-provider/) -Qiskit is an open-source framework for working with noisy intermediate-scale -quantum computers (NISQ) at the level of pulses, circuits, and algorithms. +**Qiskit** is an open-source framework for working with noisy quantum computers at the level of pulses, circuits, and algorithms. This module contains a provider that allows accessing the **[IBM Q]** quantum devices and simulators. @@ -27,11 +26,11 @@ To install from source, follow the instructions in the Once the package is installed, you can access the provider from Qiskit. -> **Note**: Since July 2019 (and with version `0.3` of this -> `qiskit-ibmq-provider` package / version `0.11` of the `qiskit` package), -> using the new IBM Q Experience (v2) is the default behavior. If you have -> been using an account for the legacy Quantum Experience or QConsole (v1), -> please check the [update instructions](#updating-to-the-new-IBM-Q-Experience). +> **Note**: Since November 2019 (and with version `0.4` of this +> `qiskit-ibmq-provider` package / version `0.14` of the `qiskit` package) +> legacy Quantum Experience or QConsole (v1) accounts are no longer supported. +> If you are still using a v1 account, please follow the steps described in +> [update instructions](#updating-to-the-new-IBM-Q-Experience) to update your account. ### Configure your IBMQ credentials @@ -58,7 +57,7 @@ in your program simply via: from qiskit import IBMQ provider = IBMQ.load_account() -provider.get_backend('ibmq_qasm_simulator') +backend = provider.get_backend('ibmq_qasm_simulator') ``` Alternatively, if you do not want to save your credentials to disk and only @@ -68,7 +67,7 @@ intend to use them during the current session, you can use: from qiskit import IBMQ provider = IBMQ.enable_account('MY_API_TOKEN') -provider.get_backend('ibmq_qasm_simulator') +backend = provider.get_backend('ibmq_qasm_simulator') ``` By default, all IBM Q accounts have access to the same, open project @@ -82,17 +81,17 @@ provider_2 = IBMQ.get_provider(hub='MY_HUB', group='MY_GROUP', project='MY_PROJE ## Updating to the new IBM Q Experience -Since July 2019 (and with version `0.3` of this `qiskit-ibmq-provider` package), -the IBMQProvider defaults to using the new [IBM Q Experience], which supersedes -the legacy Quantum Experience and Qconsole. The new IBM Q Experience is also -referred as `v2`, whereas the legacy one and Qconsole as `v1`. +Since November 2019 (and with version `0.4` of this `qiskit-ibmq-provider` +package), the IBMQProvider only supports the new [IBM Q Experience], dropping +support for the legacy Quantum Experience and Qconsole accounts. The new IBM Q +Experience is also referred as `v2`, whereas the legacy one and Qconsole as `v1`. This section includes instructions for updating your accounts and programs. Please note that: * the IBM Q Experience `v1` credentials and the programs written for pre-0.3 - versions will still be working during the `0.3.x` series. It is not - mandatory to update your accounts and programs, but recommended in order - to take advantage of the new features. + versions will still be working during the `0.3.x` series. From 0.4 onwards, + only `v2` credentials are supported, and it is recommended to upgrade + in order to take advantage of the new features. * updating your credentials to the IBM Q Experience `v2` implies that you will need to update your programs. The sections below contain instructions on how to perform the transition. @@ -135,9 +134,8 @@ in the [IBM Q Experience account page]. ### Updating your programs -With the introduction of support for the new IBM Q Experience support, a more -structured approach for accessing backends has been introduced. Previously, -access to all backends was centralized through: +The new IBM Q Experience support also introduces a more structured approach for accessing backends. +Previously, access to all backends was centralized through: ```python IBMQ.backends() diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 000000000..dce383c93 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,28 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2018. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/apidocs/ibmq.rst b/docs/apidocs/ibmq.rst new file mode 100644 index 000000000..6d3f23b07 --- /dev/null +++ b/docs/apidocs/ibmq.rst @@ -0,0 +1,25 @@ +.. _qiskit-providers-ibmq: + +************************* +qiskit.providers.ibmq +************************* + +.. currentmodule:: qiskit.providers.ibmq + +.. autofunction:: least_busy + +.. automodapi:: qiskit.providers + :no-heading: + :no-inheritance-diagram: + :no-inherited-members: + + +Submodules +========== + +.. toctree:: + :maxdepth: 1 + + ibmqfactory + ibmqprovider + ibmqbackend diff --git a/docs/apidocs/ibmqbackend.rst b/docs/apidocs/ibmqbackend.rst new file mode 100644 index 000000000..80833e21b --- /dev/null +++ b/docs/apidocs/ibmqbackend.rst @@ -0,0 +1,10 @@ +.. _qiskit-providers-ibmq-ibmqbackend: + +*********************************** +qiskit.providers.ibmq.ibmqbackend +*********************************** + +.. currentmodule:: qiskit.providers.ibmq.ibmqbackend + +.. autoclass:: IBMQBackend + :members: diff --git a/docs/apidocs/ibmqfactory.rst b/docs/apidocs/ibmqfactory.rst new file mode 100644 index 000000000..a9961d683 --- /dev/null +++ b/docs/apidocs/ibmqfactory.rst @@ -0,0 +1,10 @@ +.. _qiskit-providers-ibmq-ibmqfactory: + +********************************* +qiskit.providers.ibmq.ibmqfactory +********************************* + +.. currentmodule:: qiskit.providers.ibmq.ibmqfactory + +.. autoclass:: IBMQFactory + :members: diff --git a/docs/apidocs/ibmqprovider.rst b/docs/apidocs/ibmqprovider.rst new file mode 100644 index 000000000..cf7180597 --- /dev/null +++ b/docs/apidocs/ibmqprovider.rst @@ -0,0 +1,10 @@ +.. _qiskit-providers-ibmq-ibmqprovider: + +*********************************** +qiskit.providers.ibmq.ibmqprovider +*********************************** + +.. currentmodule:: qiskit.providers.ibmq.ibmqprovider + +.. autoclass:: IBMQProvider + :members: diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 000000000..da803b1da --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- + +# This code is part of Qiskit. +# +# (C) Copyright IBM 2018. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +# pylint: disable=invalid-name +# Configuration file for the Sphinx documentation builder. +# +# This file does only contain a selection of the most common options. For a +# full list see the documentation: +# http://www.sphinx-doc.org/en/master/config + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) + +""" +Sphinx documentation builder +""" + +# -- Project information ----------------------------------------------------- +project = 'Qiskit' +copyright = '2019, Qiskit Development Team' # pylint: disable=redefined-builtin +author = 'Qiskit Development Team' + +# The short X.Y version +version = '' +# The full version, including alpha/beta/rc tags +release = '0.12.0' + +# -- General configuration --------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.mathjax', + 'sphinx.ext.napoleon', + 'sphinx.ext.viewcode', + 'sphinx.ext.extlinks', + 'sphinx_tabs.tabs', + 'sphinx_automodapi.automodapi', + 'IPython.sphinxext.ipython_console_highlighting', + 'IPython.sphinxext.ipython_directive' +] + + +# If true, figures, tables and code-blocks are automatically numbered if they +# have a caption. +numfig = True + +# A dictionary mapping 'figure', 'table', 'code-block' and 'section' to +# strings that are used for format of figure numbers. As a special character, +# %s will be replaced to figure number. +numfig_format = { + 'table': 'Table %s' +} +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = [] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = None + +# A boolean that decides whether module names are prepended to all object names +# (for object types where a “module” of some kind is defined), e.g. for +# py:function directives. +add_module_names = False + +# A list of prefixes that are ignored for sorting the Python module index +# (e.g., if this is set to ['foo.'], then foo.bar is shown under B, not F). +# This can be handy if you document a project that consists of a single +# package. Works only for the HTML builder currently. +modindex_common_prefix = ['qiskit.'] + +# -- Configuration for extlinks extension ------------------------------------ +# Refer to https://www.sphinx-doc.org/en/master/usage/extensions/extlinks.html + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'sphinx_rtd_theme' # use the theme in subdir 'theme' + +html_sidebars = {'**': ['globaltoc.html']} +html_last_updated_fmt = '%Y/%m/%d' diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 000000000..4e27016af --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,14 @@ +################################## +Qiskit IBM Q Account documentation +################################## + +.. toctree:: + :maxdepth: 2 + :hidden: + + API References + +.. Hiding - Indices and tables + :ref:`genindex` + :ref:`modindex` + :ref:`search` diff --git a/qiskit/providers/ibmq/VERSION.txt b/qiskit/providers/ibmq/VERSION.txt index 1c09c74e2..1d0ba9ea1 100644 --- a/qiskit/providers/ibmq/VERSION.txt +++ b/qiskit/providers/ibmq/VERSION.txt @@ -1 +1 @@ -0.3.3 +0.4.0 diff --git a/qiskit/providers/ibmq/__init__.py b/qiskit/providers/ibmq/__init__.py index 95bac3035..344d9cc5a 100644 --- a/qiskit/providers/ibmq/__init__.py +++ b/qiskit/providers/ibmq/__init__.py @@ -14,11 +14,13 @@ """Backends provided by IBM Quantum Experience.""" +from typing import List + from qiskit.exceptions import QiskitError from .ibmqfactory import IBMQFactory -from .ibmqprovider import IBMQProvider -from .ibmqbackend import IBMQBackend +from .ibmqbackend import IBMQBackend, BaseBackend from .job import IBMQJob +from .managed import IBMQJobManager from .version import __version__ @@ -26,7 +28,7 @@ IBMQ = IBMQFactory() -def least_busy(backends): +def least_busy(backends: List[BaseBackend]) -> BaseBackend: """Return the least busy backend from a list. Return the least busy available backend for those that @@ -34,10 +36,10 @@ def least_busy(backends): local backends that do not have this are not considered. Args: - backends (list[BaseBackend]): backends to choose from + backends: backends to choose from Returns: - BaseBackend: the the least busy backend + the the least busy backend Raises: QiskitError: if passing a list of backend names that is diff --git a/qiskit/providers/ibmq/accountprovider.py b/qiskit/providers/ibmq/accountprovider.py index 9936fa775..ec305bbeb 100644 --- a/qiskit/providers/ibmq/accountprovider.py +++ b/qiskit/providers/ibmq/accountprovider.py @@ -15,17 +15,19 @@ """Provider for a single IBM Quantum Experience account.""" import logging +from typing import Dict, List, Optional, Any from collections import OrderedDict -from qiskit.providers import BaseProvider +from qiskit.providers import BaseProvider # type: ignore[attr-defined] from qiskit.providers.models import (QasmBackendConfiguration, PulseBackendConfiguration) -from qiskit.providers.providerutils import filter_backends from qiskit.validation.exceptions import ModelValidationError -from .api_v2.clients import AccountClient -from .circuits import CircuitsManager +from .api.clients import AccountClient from .ibmqbackend import IBMQBackend, IBMQSimulator +from .credentials import Credentials +from .ibmqbackendservice import IBMQBackendService + logger = logging.getLogger(__name__) @@ -33,12 +35,17 @@ class AccountProvider(BaseProvider): """Provider for a single IBM Quantum Experience account.""" - def __init__(self, credentials, access_token): + def __init__(self, credentials: Credentials, access_token: str) -> None: """Return a new AccountProvider. + The ``provider_backends`` attribute can be used to autocomplete + backend names, by pressing ``tab`` after + ``AccountProvider.provider_backends.``. Note that this feature may + not be available if an error occurs during backend discovery. + Args: - credentials (Credentials): IBM Q Experience credentials. - access_token (str): access token for IBM Q Experience. + credentials: IBM Q Experience credentials. + access_token: access token for IBM Q Experience. """ super().__init__() @@ -48,53 +55,31 @@ def __init__(self, credentials, access_token): self._api = AccountClient(access_token, credentials.url, credentials.websockets_url, + use_websockets=(not credentials.proxies), **credentials.connection_parameters()) - self.circuits = CircuitsManager(self._api) - - # Initialize the internal list of backends, lazy-loading it on first - # access. - self._backends = None - - def backends(self, name=None, filters=None, **kwargs): - """Return all backends accessible via this provider, subject to optional filtering. - - Args: - name (str): backend name to filter by - filters (callable): more complex filters, such as lambda functions - e.g. AccountProvider.backends( - filters=lambda b: b.configuration['n_qubits'] > 5) - kwargs: simple filters specifying a true/false criteria in the - backend configuration or backend status or provider credentials - e.g. AccountProvider.backends(n_qubits=5, operational=True) - Returns: - list[IBMQBackend]: list of backends available that match the filter - """ - # pylint: disable=arguments-differ - if self._backends is None: - self._backends = self._discover_remote_backends() - - backends = self._backends.values() + # Initialize the internal list of backends. + self._backends = self._discover_remote_backends() + self.backends = IBMQBackendService(self) # type: ignore[assignment] - # Special handling of the `name` parameter, to support alias - # resolution. - if name: - aliases = self._aliased_backend_names() - aliases.update(self._deprecated_backend_names()) - name = aliases.get(name, name) - kwargs['backend_name'] = name + def backends(self, name: Optional[str] = None, **kwargs: Any) -> List[IBMQBackend]: + # pylint: disable=method-hidden + # This method is only for faking the subclassing of `BaseProvider`, as + # `.backends()` is an abstract method. Upon initialization, it is + # replaced by a `IBMQBackendService` instance. + pass - return filter_backends(backends, filters=filters, **kwargs) - - def _discover_remote_backends(self): + def _discover_remote_backends(self, timeout: Optional[float] = None) -> Dict[str, IBMQBackend]: """Return the remote backends available. + Args: + timeout: number of seconds to wait for the discovery. + Returns: - dict[str:IBMQBackend]: a dict of the remote backend instances, - keyed by backend name. + a dict of the remote backend instances, keyed by backend name. """ - ret = OrderedDict() - configs_list = self._api.available_backends() + ret = OrderedDict() # type: ignore[var-annotated] + configs_list = self._api.list_backends(timeout=timeout) for raw_config in configs_list: # Make sure the raw_config is of proper type if not isinstance(raw_config, dict): @@ -123,29 +108,13 @@ def _discover_remote_backends(self): return ret - @staticmethod - def _deprecated_backend_names(): - """Returns deprecated backend names.""" - return { - 'ibmqx_qasm_simulator': 'ibmq_qasm_simulator', - 'ibmqx_hpc_qasm_simulator': 'ibmq_qasm_simulator', - 'real': 'ibmqx1' - } - - @staticmethod - def _aliased_backend_names(): - """Returns aliased backend names.""" - return { - 'ibmq_5_yorktown': 'ibmqx2', - 'ibmq_5_tenerife': 'ibmqx4', - 'ibmq_16_rueschlikon': 'ibmqx5', - 'ibmq_20_austin': 'QS1_1' - } - - def __eq__(self, other): + def __eq__( # type: ignore[overide] + self, + other: 'AccountProvider' + ) -> bool: return self.credentials == other.credentials - def __repr__(self): + def __repr__(self) -> str: credentials_info = "hub='{}', group='{}', project='{}'".format( self.credentials.hub, self.credentials.group, self.credentials.project) diff --git a/qiskit/providers/ibmq/api/__init__.py b/qiskit/providers/ibmq/api/__init__.py index 305dbb4e5..255d2b5db 100644 --- a/qiskit/providers/ibmq/api/__init__.py +++ b/qiskit/providers/ibmq/api/__init__.py @@ -12,7 +12,4 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. -"""IBM Q API connector.""" - -from .exceptions import ApiError, BadBackendError, RegisterSizeError -from .ibmqconnector import IBMQConnector +"""IBM Q Experience API connector and utilities.""" diff --git a/qiskit/providers/ibmq/api_v2/clients/__init__.py b/qiskit/providers/ibmq/api/clients/__init__.py similarity index 94% rename from qiskit/providers/ibmq/api_v2/clients/__init__.py rename to qiskit/providers/ibmq/api/clients/__init__.py index dc829aacb..db1bc3058 100644 --- a/qiskit/providers/ibmq/api_v2/clients/__init__.py +++ b/qiskit/providers/ibmq/api/clients/__init__.py @@ -12,7 +12,7 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. -"""IBM Q Experience v2 API clients.""" +"""IBM Q Experience API clients.""" from .base import BaseClient from .account import AccountClient diff --git a/qiskit/providers/ibmq/api/clients/account.py b/qiskit/providers/ibmq/api/clients/account.py new file mode 100644 index 000000000..fd04a69a0 --- /dev/null +++ b/qiskit/providers/ibmq/api/clients/account.py @@ -0,0 +1,480 @@ +# -*- coding: utf-8 -*- + +# This code is part of Qiskit. +# +# (C) Copyright IBM 2018, 2019. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Client for accessing an individual IBM Q Experience account.""" + +import asyncio +import logging +import time + +from typing import List, Dict, Any, Optional +# Disabled unused-import because datetime is used only for type hints. +from datetime import datetime # pylint: disable=unused-import + +from qiskit.providers.ibmq.apiconstants import (API_JOB_FINAL_STATES, ApiJobStatus, + ApiJobShareLevel) + +from ..exceptions import (RequestsApiError, WebsocketError, + WebsocketTimeoutError, UserTimeoutExceededError) +from ..rest import Api +from ..session import RetrySession +from .base import BaseClient +from .websocket import WebsocketClient + +logger = logging.getLogger(__name__) + + +class AccountClient(BaseClient): + """Client for accessing an individual IBM Q Experience account. + + This client provides access to an individual IBM Q hub/group/project. + """ + + def __init__( + self, + access_token: str, + project_url: str, + websockets_url: str, + use_websockets: bool, + **request_kwargs: Any + ) -> None: + """AccountClient constructor. + + Args: + access_token: IBM Q Experience access token. + project_url: IBM Q Experience URL for a specific h/g/p. + websockets_url: URL for the websockets server. + use_websockets: whether to use webscokets + **request_kwargs: arguments for the `requests` Session. + """ + self.client_api = Api(RetrySession(project_url, access_token, + **request_kwargs)) + self.client_ws = WebsocketClient(websockets_url, access_token) + self._use_websockets = use_websockets + + # Backend-related public functions. + + def list_backends(self, timeout: Optional[float] = None) -> List[Dict[str, Any]]: + """Return the list of backends. + + Args: + timeout: number of seconds to wait for the request. + + Returns: + a list of backends. + """ + return self.client_api.backends(timeout=timeout) + + def backend_status(self, backend_name: str) -> Dict[str, Any]: + """Return the status of a backend. + + Args: + backend_name: the name of the backend. + + Returns: + backend status. + """ + return self.client_api.backend(backend_name).status() + + def backend_properties( + self, + backend_name: str, + datetime: Optional[datetime] = None + ) -> Dict[str, Any]: + """Return the properties of a backend. + + Args: + backend_name: the name of the backend. + datetime: datetime for additional filtering of backend properties. + + Returns: + backend properties. + """ + # pylint: disable=redefined-outer-name + return self.client_api.backend(backend_name).properties(datetime=datetime) + + def backend_pulse_defaults(self, backend_name: str) -> Dict: + """Return the pulse defaults of a backend. + + Args: + backend_name: the name of the backend. + + Returns: + backend pulse defaults. + """ + return self.client_api.backend(backend_name).pulse_defaults() + + # Jobs-related public functions. + + def list_jobs_statuses( + self, + limit: int = 10, + skip: int = 0, + extra_filter: Optional[Dict[str, Any]] = None + ) -> List[Dict[str, Any]]: + """Return a list of statuses of jobs, with filtering and pagination. + + Args: + limit: maximum number of items to return. + skip: offset for the items to return. + extra_filter: additional filtering passed to the query. + + Returns: + a list of job statuses. + """ + return self.client_api.jobs(limit=limit, skip=skip, + extra_filter=extra_filter) + + def job_submit( + self, + backend_name: str, + qobj_dict: Dict[str, Any], + use_object_storage: bool, + job_name: Optional[str] = None, + job_share_level: Optional[ApiJobShareLevel] = None + ) -> Dict[str, Any]: + """Submit a Qobj to a device. + + Args: + backend_name: the name of the backend. + qobj_dict: the Qobj to be executed, as a dictionary. + use_object_storage: `True` if object storage should be used. + job_name: custom name to be assigned to the job. + job_share_level: level the job should be shared at. + + Returns: + job status. + """ + submit_info = None + if use_object_storage: + # Attempt to use object storage. + try: + submit_info = self._job_submit_object_storage( + backend_name=backend_name, + qobj_dict=qobj_dict, + job_name=job_name, + job_share_level=job_share_level) + except Exception: # pylint: disable=broad-except + # Fall back to submitting the Qobj via POST if object storage + # failed. + logger.info('Submitting the job via object storage failed: ' + 'retrying via regular POST upload.') + + if not submit_info: + # Submit Qobj via HTTP. + submit_info = self._job_submit_post(backend_name, qobj_dict, job_name, job_share_level) + + return submit_info + + def _job_submit_post( + self, + backend_name: str, + qobj_dict: Dict[str, Any], + job_name: Optional[str] = None, + job_share_level: Optional[ApiJobShareLevel] = None + ) -> Dict[str, Any]: + """Submit a Qobj to a device using HTTP POST. + + Args: + backend_name: the name of the backend. + qobj_dict: the Qobj to be executed, as a dictionary. + job_name: custom name to be assigned to the job. + job_share_level: level the job should be shared at. + + Returns: + job status. + """ + # Check for the job share level. + _job_share_level = job_share_level.value if job_share_level else None + + return self.client_api.job_submit( + backend_name, + qobj_dict, + job_name, + job_share_level=_job_share_level) + + def _job_submit_object_storage( + self, + backend_name: str, + qobj_dict: Dict[str, Any], + job_name: Optional[str] = None, + job_share_level: Optional[ApiJobShareLevel] = None + ) -> Dict: + """Submit a Qobj to a device using object storage. + + Args: + backend_name: the name of the backend. + qobj_dict: the Qobj to be executed, as a dictionary. + job_name: custom name to be assigned to the job. + job_share_level: level the job should be shared at. + + Returns: + job status. + """ + # Check for the job share level. + _job_share_level = job_share_level.value if job_share_level else None + + # Get the job via object storage. + job_info = self.client_api.submit_job_object_storage( + backend_name, + job_name=job_name, + job_share_level=_job_share_level) + + # Get the upload URL. + job_id = job_info['id'] + job_api = self.client_api.job(job_id) + upload_url = job_api.upload_url()['url'] + + # Upload the Qobj to object storage. + _ = job_api.put_object_storage(upload_url, qobj_dict) + + # Notify the API via the callback. + response = job_api.callback_upload() + + return response['job'] + + def job_download_qobj(self, job_id: str, use_object_storage: bool) -> Dict: + """Retrieve and return a Qobj. + + Args: + job_id: the id of the job. + use_object_storage: `True` if object storage should be used. + + Returns: + Qobj, in dict form. + """ + if use_object_storage: + return self._job_download_qobj_object_storage(job_id) + else: + return self.job_get(job_id).get('qObject', {}) + + def _job_download_qobj_object_storage(self, job_id: str) -> Dict: + """Retrieve and return a Qobj using object storage. + + Args: + job_id: the id of the job. + + Returns: + Qobj, in dict form. + """ + job_api = self.client_api.job(job_id) + + # Get the download URL. + download_url = job_api.download_url()['url'] + + # Download the result from object storage. + return job_api.get_object_storage(download_url) + + def job_result(self, job_id: str, use_object_storage: bool) -> Dict: + """Retrieve and return a job result. + + Args: + job_id: the id of the job. + use_object_storage: `True` if object storage should be used. + + Returns: + job information. + """ + if use_object_storage: + return self._job_result_object_storage(job_id) + + return self.job_get(job_id)['qObjectResult'] + + def _job_result_object_storage(self, job_id: str) -> Dict: + """Retrieve and return a result using object storage. + + Args: + job_id: the id of the job. + + Returns: + job information. + """ + job_api = self.client_api.job(job_id) + + # Get the download URL. + download_url = job_api.result_url()['url'] + + # Download the result from object storage. + result_response = job_api.get_object_storage(download_url) + + # Notify the API via the callback + try: + _ = job_api.callback_download() + except (RequestsApiError, ValueError) as ex: + logger.warning("An error occurred while sending download completion acknowledgement: " + "%s", ex) + return result_response + + def job_get( + self, + job_id: str + ) -> Dict[str, Any]: + """Return information about a job. + + Args: + job_id: the id of the job. + + Returns: + job information. + """ + return self.client_api.job(job_id).get() + + def job_status(self, job_id: str) -> Dict[str, Any]: + """Return the status of a job. + + Args: + job_id: the id of the job. + + Returns: + job status. + + Raises: + ApiIBMQProtocolError: if an unexpected result is received from the server. + """ + return self.client_api.job(job_id).status() + + def job_final_status( + self, + job_id: str, + timeout: Optional[float] = None, + wait: float = 5 + ) -> Dict[str, Any]: + """Wait until the job progress to a final state. + + Args: + job_id: the id of the job + timeout: seconds to wait for job. If None, wait indefinitely. + wait: seconds between queries. + + Returns: + job status. + + Raises: + UserTimeoutExceededError: if the job does not return results + before a specified timeout. + ApiIBMQProtocolError: if an unexpected result is received from the server. + """ + status_response = None + # Attempt to use websocket if available. + if self._use_websockets: + start_time = time.time() + try: + status_response = self._job_final_status_websocket(job_id, timeout) + except WebsocketTimeoutError as ex: + logger.warning('Timeout checking job status using websocket, ' + 'retrying using HTTP') + logger.debug(ex) + except (RuntimeError, WebsocketError) as ex: + logger.warning('Error checking job status using websocket, ' + 'retrying using HTTP.') + logger.debug(ex) + + # Adjust timeout for HTTP retry. + if timeout is not None: + timeout -= (time.time() - start_time) + + if not status_response: + # Use traditional http requests if websocket not available or failed. + status_response = self._job_final_status_polling(job_id, timeout, wait) + + return status_response + + def _job_final_status_websocket( + self, + job_id: str, + timeout: Optional[float] = None + ) -> Dict[str, Any]: + """Return the final status of a job via websocket. + + Args: + job_id: the id of the job. + timeout: seconds to wait for job. If None, wait indefinitely. + + Returns: + job status. + + Raises: + RuntimeError: if an unexpected error occurred while getting the event loop. + WebsocketError: if the websocket connection ended unexpectedly. + WebsocketTimeoutError: if the timeout has been reached. + """ + # As mentioned in `websocket.py`, in jupyter we need to use + # `nest_asyncio` to allow nested event loops. + try: + loop = asyncio.get_event_loop() + except RuntimeError as ex: + # Event loop may not be set in a child thread. + if 'There is no current event loop' in str(ex): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + else: + raise + return loop.run_until_complete( + self.client_ws.get_job_status(job_id, timeout=timeout)) + + def _job_final_status_polling( + self, + job_id: str, + timeout: Optional[float] = None, + wait: float = 5 + ) -> Dict[str, Any]: + """Return the final status of a job via polling. + + Args: + job_id: the id of the job. + timeout: seconds to wait for job. If None, wait indefinitely. + wait: seconds between queries. + + Returns: + job status. + + Raises: + UserTimeoutExceededError: if the user specified timeout has been exceeded. + """ + start_time = time.time() + status_response = self.job_status(job_id) + while ApiJobStatus(status_response['status']) not in API_JOB_FINAL_STATES: + elapsed_time = time.time() - start_time + if timeout is not None and elapsed_time >= timeout: + raise UserTimeoutExceededError( + 'Timeout while waiting for job {}'.format(job_id)) + + logger.info('API job status = %s (%d seconds)', + status_response['status'], elapsed_time) + time.sleep(wait) + status_response = self.job_status(job_id) + + return status_response + + def job_properties(self, job_id: str) -> Dict: + """Return the backend properties of a job. + + Args: + job_id: the id of the job. + + Returns: + backend properties. + """ + return self.client_api.job(job_id).properties() + + def job_cancel(self, job_id: str) -> Dict[str, Any]: + """Submit a request for cancelling a job. + + Args: + job_id: the id of the job. + + Returns: + job cancellation response. + """ + return self.client_api.job(job_id).cancel() diff --git a/qiskit/providers/ibmq/api_v2/clients/auth.py b/qiskit/providers/ibmq/api/clients/auth.py similarity index 62% rename from qiskit/providers/ibmq/api_v2/clients/auth.py rename to qiskit/providers/ibmq/api/clients/auth.py index 78a187035..4051113ed 100644 --- a/qiskit/providers/ibmq/api_v2/clients/auth.py +++ b/qiskit/providers/ibmq/api/clients/auth.py @@ -13,6 +13,8 @@ # that they have been altered from the originals. """Client for accessing authentication features of IBM Q Experience.""" +from typing import Dict, List, Optional, Any +from requests.exceptions import RequestException from ..exceptions import AuthenticationLicenseError, RequestsApiError from ..rest import Api, Auth @@ -24,29 +26,29 @@ class AuthClient(BaseClient): """Client for accessing authentication features of IBM Q Experience.""" - def __init__(self, api_token, auth_url, **request_kwargs): + def __init__(self, api_token: str, auth_url: str, **request_kwargs: Any) -> None: """AuthClient constructor. Args: - api_token (str): IBM Q Experience API token. - auth_url (str): URL for the authentication service. - **request_kwargs (dict): arguments for the `requests` Session. + api_token: IBM Q Experience API token. + auth_url: URL for the authentication service. + **request_kwargs: arguments for the `requests` Session. """ self.api_token = api_token self.auth_url = auth_url - self._service_urls = {} + self._service_urls = {} # type: ignore[var-annotated] self.client_auth = Auth(RetrySession(auth_url, **request_kwargs)) self.client_api = self._init_service_clients(**request_kwargs) - def _init_service_clients(self, **request_kwargs): + def _init_service_clients(self, **request_kwargs: Any) -> Api: """Initialize the clients used for communicating with the API and ws. Args: - **request_kwargs (dict): arguments for the `requests` Session. + **request_kwargs: arguments for the `requests` Session. Returns: - Api: client for the api server. + client for the api server. """ # Request an access token. access_token = self._request_access_token() @@ -60,11 +62,11 @@ def _init_service_clients(self, **request_kwargs): return client_api - def _request_access_token(self): + def _request_access_token(self) -> str: """Request a new access token from the API authentication server. Returns: - str: access token. + access token. Raises: AuthenticationLicenseError: if the user hasn't accepted the license agreement. @@ -74,45 +76,50 @@ def _request_access_token(self): response = self.client_auth.login(self.api_token) return response['id'] except RequestsApiError as ex: - response = ex.original_exception.response - if response is not None and response.status_code == 401: - try: - error_code = response.json()['error']['name'] - if error_code == 'ACCEPT_LICENSE_REQUIRED': - message = response.json()['error']['message'] - raise AuthenticationLicenseError(message) - except (ValueError, KeyError): - # the response did not contain the expected json. - pass + # Get the original exception that raised. + original_exception = ex.__cause__ + + if isinstance(original_exception, RequestException): + # Get the response from the original request exception. + error_response = original_exception.response # pylint: disable=no-member + if error_response is not None and error_response.status_code == 401: + try: + error_code = error_response.json()['error']['name'] + if error_code == 'ACCEPT_LICENSE_REQUIRED': + message = error_response.json()['error']['message'] + raise AuthenticationLicenseError(message) + except (ValueError, KeyError): + # the response did not contain the expected json. + pass raise # User account-related public functions. - def user_urls(self): + def user_urls(self) -> Dict[str, str]: """Retrieve the API URLs from the authentication server. Returns: - dict: a dict with the base URLs for the services. Currently + a dict with the base URLs for the services. Currently supported keys: - * ``http``: the API URL for http communication. - * ``ws``: the API URL for websocket communication. + * ``http``: the API URL for http communication. + * ``ws``: the API URL for websocket communication. """ response = self.client_auth.user_info() return response['urls'] - def user_hubs(self): + def user_hubs(self) -> List[Dict[str, str]]: """Retrieve the hubs available to the user. The first entry in the list will be the default one, as indicated by the API (by having `isDefault` in all hub, group, project fields). Returns: - list[dict]: a list of dicts with the hubs, which contains the keys + a list of dicts with the hubs, which contains the keys `hub`, `group`, `project`. """ response = self.client_api.hubs() - hubs = [] + hubs = [] # type: ignore[var-annotated] for hub in response: hub_name = hub['name'] for group_name, group in hub['groups'].items(): @@ -131,27 +138,27 @@ def user_hubs(self): # Miscellaneous public functions. - def api_version(self): + def api_version(self) -> Dict[str, str]: """Return the version of the API. Returns: - dict: versions of the API components. + versions of the API components. """ return self.client_api.version() - def current_access_token(self): + def current_access_token(self) -> Optional[str]: """Return the current access token. Returns: - str: the access token in use. + the access token in use. """ return self.client_auth.session.access_token - def current_service_urls(self): + def current_service_urls(self) -> Dict[str, str]: """Return the current service URLs. Returns: - dict: a dict with the base URLs for the services, in the same + a dict with the base URLs for the services, in the same format as `.user_urls()`. """ return self._service_urls diff --git a/qiskit/providers/ibmq/api_v2/clients/base.py b/qiskit/providers/ibmq/api/clients/base.py similarity index 100% rename from qiskit/providers/ibmq/api_v2/clients/base.py rename to qiskit/providers/ibmq/api/clients/base.py diff --git a/qiskit/providers/ibmq/api_v2/clients/version.py b/qiskit/providers/ibmq/api/clients/version.py similarity index 80% rename from qiskit/providers/ibmq/api_v2/clients/version.py rename to qiskit/providers/ibmq/api/clients/version.py index 45f959498..f657122ca 100644 --- a/qiskit/providers/ibmq/api_v2/clients/version.py +++ b/qiskit/providers/ibmq/api/clients/version.py @@ -14,6 +14,8 @@ """Client for determining the version of an IBM Q Experience service.""" +from typing import Dict, Union, Any + from ..session import RetrySession from ..rest.version_finder import VersionFinder @@ -23,21 +25,21 @@ class VersionClient(BaseClient): """Client for determining the version of an IBM Q Experience service.""" - def __init__(self, url, **request_kwargs): + def __init__(self, url: str, **request_kwargs: Any) -> None: """VersionClient constructor. Args: - url (str): URL for the service. - **request_kwargs (dict): arguments for the `requests` Session. + url: URL for the service. + **request_kwargs: arguments for the `requests` Session. """ self.client_version_finder = VersionFinder( RetrySession(url, **request_kwargs)) - def version(self): + def version(self) -> Dict[str, Union[bool, str]]: """Return the version info. Returns: - dict: a dict with information about the API version, + a dict with information about the API version, with the following keys: * `new_api` (bool): whether the new API is being used And the following optional keys: diff --git a/qiskit/providers/ibmq/api/clients/websocket.py b/qiskit/providers/ibmq/api/clients/websocket.py new file mode 100644 index 000000000..c5b12e96f --- /dev/null +++ b/qiskit/providers/ibmq/api/clients/websocket.py @@ -0,0 +1,329 @@ +# -*- coding: utf-8 -*- + +# This code is part of Qiskit. +# +# (C) Copyright IBM 2018, 2019. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Client for websocket communication with the IBM Q Experience API.""" + +import asyncio +import json +import logging +import time +from abc import ABC, abstractmethod +from typing import Dict, Union, Generator, Optional, Any +from concurrent import futures +from ssl import SSLError +import warnings + +import nest_asyncio +from websockets import connect, ConnectionClosed +from websockets.client import WebSocketClientProtocol +from websockets.exceptions import InvalidURI + +from qiskit.providers.ibmq.apiconstants import ApiJobStatus, API_JOB_FINAL_STATES +from ..exceptions import (WebsocketError, WebsocketTimeoutError, + WebsocketIBMQProtocolError, + WebsocketAuthenticationError) + +from .base import BaseClient + + +logger = logging.getLogger(__name__) + +# `asyncio` by design does not allow event loops to be nested. Jupyter (really +# tornado) has its own event loop already so we need to patch it. +# Patch asyncio to allow nested use of `loop.run_until_complete()`. +nest_asyncio.apply() + + +class WebsocketMessage(ABC): + """Container for a message sent or received via websockets. + + Args: + type_: message type. + """ + def __init__(self, type_: str) -> None: + self.type_ = type_ + + @abstractmethod + def get_data(self) -> Union[str, Dict[str, str]]: + """Getter for "abstract" attribute subclasses define, `data`.""" + pass + + def as_json(self) -> str: + """Return a json representation of the message.""" + return json.dumps({'type': self.type_, 'data': self.get_data()}) + + +class WebsocketAuthenticationMessage(WebsocketMessage): + """Container for an authentication message sent via websockets. + + Args: + type_: message type. + data: data type. + """ + def __init__(self, type_: str, data: str) -> None: + super().__init__(type_) + self.data = data + + def get_data(self) -> str: + return self.data + + +class WebsocketResponseMethod(WebsocketMessage): + """Container for a message received via websockets. + + Args: + type_: message type. + data: data type. + """ + def __init__(self, type_: str, data: Dict[str, str]) -> None: + super().__init__(type_) + self.data = data + + def get_data(self) -> Dict[str, str]: + return self.data + + @classmethod + def from_bytes(cls, json_string: bytes) -> 'WebsocketResponseMethod': + """Instantiate a message from a bytes response.""" + try: + parsed_dict = json.loads(json_string.decode('utf8')) + except (ValueError, AttributeError) as ex: + raise WebsocketIBMQProtocolError('Unable to parse message') from ex + + return cls(parsed_dict['type'], parsed_dict.get('data', None)) + + +class WebsocketClient(BaseClient): + """Client for websocket communication with the IBM Q Experience API. + + Args: + websocket_url: URL for websocket communication with IBM Q. + access_token: access token for IBM Q. + """ + BACKOFF_MAX = 8 # Maximum time to wait between retries. + + def __init__(self, websocket_url: str, access_token: str) -> None: + self.websocket_url = websocket_url.rstrip('/') + self.access_token = access_token + + @asyncio.coroutine + def _connect(self, url: str) -> Generator[Any, None, WebSocketClientProtocol]: + """Authenticate against the websocket server, returning the connection. + + Returns: + an open websocket connection. + + Raises: + WebsocketError: if the connection to the websocket server could + not be established. + WebsocketAuthenticationError: if the connection to the websocket + was established, but the authentication failed. + WebsocketIBMQProtocolError: if the connection to the websocket + server was established, but the answer was unexpected. + """ + try: + logger.debug('Starting new websocket connection: %s', url) + with warnings.catch_warnings(): + # Suppress websockets deprecation warnings until the fix is available + warnings.filterwarnings("ignore", category=DeprecationWarning) + websocket = yield from connect(url) + + # Isolate specific exceptions, so they are not retried in `get_job_status`. + except (SSLError, InvalidURI) as ex: + raise ex + + # pylint: disable=broad-except + except Exception as ex: + raise WebsocketError('Could not connect to server') from ex + + try: + # Authenticate against the server. + auth_request = self._authentication_message() + with warnings.catch_warnings(): + # Suppress websockets deprecation warnings until the fix is available + warnings.filterwarnings("ignore", category=DeprecationWarning) + yield from websocket.send(auth_request.as_json()) + + # Verify that the server acknowledged our authentication. + auth_response_raw = yield from websocket.recv() + + auth_response = WebsocketResponseMethod.from_bytes(auth_response_raw) + + if auth_response.type_ != 'authenticated': + raise WebsocketIBMQProtocolError(auth_response.as_json()) + except ConnectionClosed as ex: + yield from websocket.close() + raise WebsocketAuthenticationError( + 'Error during websocket authentication') from ex + + return websocket + + @asyncio.coroutine + def get_job_status( + self, + job_id: str, + timeout: Optional[float] = None, + retries: int = 5, + backoff_factor: float = 0.5 + ) -> Generator[Any, None, Dict[str, str]]: + """Return the status of a job. + + Reads status messages from the API, which are issued at regular + intervals. When a final state is reached, the server + closes the socket. If the websocket connection is closed without + a reason, the exponential backoff algorithm is used as a basis to + reestablish connections. The algorithm takes effect when a + connection closes, it is given by: + + 1. When a connection closes, sleep for a calculated backoff + time. + 2. Try to retrieve another socket and increment a retry + counter. + 3. Attempt to get the job status. + - If the connection is closed, go back to step 1. + - If the job status is read successfully, reset the retry + counter. + 4. Continue until the job status is complete or the maximum + number of retries is met. + + Args: + job_id: id of the job. + timeout: timeout, in seconds. + retries: max number of retries. + backoff_factor: backoff factor used to calculate the + time to wait between retries. + + Returns: + the API response for the status of a job, as a dict that + contains at least the keys ``status`` and ``id``. + + Raises: + WebsocketError: if the websocket connection ended unexpectedly. + WebsocketTimeoutError: if the timeout has been reached. + """ + url = '{}/jobs/{}/status'.format(self.websocket_url, job_id) + + original_timeout = timeout + start_time = time.time() + attempt_retry = True # By default, attempt to retry if the websocket connection closes. + current_retry_attempt = 0 + last_status = None + websocket = None + + while current_retry_attempt <= retries: + try: + websocket = yield from self._connect(url) + # Read messages from the server until the connection is closed or + # a timeout has been reached. + while True: + try: + with warnings.catch_warnings(): + # Suppress websockets deprecation warnings until the fix is available + warnings.filterwarnings("ignore", category=DeprecationWarning) + if timeout: + response_raw = yield from asyncio.wait_for( + websocket.recv(), timeout=timeout) + + # Decrease the timeout. + timeout = original_timeout - (time.time() - start_time) + else: + response_raw = yield from websocket.recv() + logger.debug('Received message from websocket: %s', + response_raw) + + response = WebsocketResponseMethod.from_bytes(response_raw) + last_status = response.data + + # Successfully received and parsed a message, reset retry counter. + current_retry_attempt = 0 + + job_status = response.data.get('status') + if (job_status and + ApiJobStatus(job_status) in API_JOB_FINAL_STATES): + return last_status + + if timeout and timeout <= 0: + raise WebsocketTimeoutError('Timeout reached') + + except futures.TimeoutError: + # Timeout during our wait. + raise WebsocketTimeoutError('Timeout reached') from None + except ConnectionClosed as ex: + # From the API: + # 4001: closed due to an internal errors + # 4002: closed on purpose (no more updates to send) + # 4003: closed due to job not found. + message = 'Unexpected error' + if ex.code == 4001: + message = 'Internal server error' + elif ex.code == 4002: + return last_status # type: ignore[return-value] + elif ex.code == 4003: + attempt_retry = False # No point in retrying. + message = 'Job id not found' + + raise WebsocketError('Connection with websocket closed ' + 'unexpectedly: {}(status_code={})' + .format(message, ex.code)) from ex + + except WebsocketError as ex: + logger.warning('%s', ex) + + # Specific `WebsocketError` exceptions that are not worth retrying. + if isinstance(ex, (WebsocketTimeoutError, WebsocketIBMQProtocolError)): + raise ex + + current_retry_attempt = current_retry_attempt + 1 + if (current_retry_attempt > retries) or (not attempt_retry): + raise ex + + # Sleep, and then `continue` with retrying. + backoff_time = self._backoff_time(backoff_factor, current_retry_attempt) + logger.warning('Retrying get_job_status after %s seconds: ' + 'Attempt #%s.', backoff_time, current_retry_attempt) + yield from asyncio.sleep(backoff_time) # Block asyncio loop for given backoff time. + + continue # Continues next iteration after `finally` block. + + finally: + with warnings.catch_warnings(): + # Suppress websockets deprecation warnings until the fix is available + warnings.filterwarnings("ignore", category=DeprecationWarning) + if websocket is not None: + yield from websocket.close() + + # Execution should not reach here, sanity check. + raise WebsocketError('Failed to establish a websocket ' + 'connection after {} retries.'.format(retries)) + + def _backoff_time(self, backoff_factor: float, current_retry_attempt: int) -> float: + """Calculate the backoff time to sleep for. + + Exponential backoff time formula: + {backoff_factor} * (2 ** (current_retry_attempt - 1)) + + Args: + backoff_factor: backoff factor, in seconds. + current_retry_attempt: current number of retry attempts. + + Returns: + The number of seconds to sleep for, before a retry attempt is made. + """ + backoff_time = backoff_factor * (2 ** (current_retry_attempt - 1)) + return min(self.BACKOFF_MAX, backoff_time) + + def _authentication_message(self) -> 'WebsocketAuthenticationMessage': + """Return the message used for authenticating against the server.""" + return WebsocketAuthenticationMessage(type_='authentication', + data=self.access_token) diff --git a/qiskit/providers/ibmq/api/exceptions.py b/qiskit/providers/ibmq/api/exceptions.py index 70b7e9662..36f9f5787 100644 --- a/qiskit/providers/ibmq/api/exceptions.py +++ b/qiskit/providers/ibmq/api/exceptions.py @@ -12,53 +12,51 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. -"""Exceptions for IBMQ Connector.""" +"""Exceptions related to the IBM Q Experience API.""" from ..exceptions import IBMQError class ApiError(IBMQError): - """IBMQConnector API error handling base class.""" + """Generic IBM Q API error.""" + pass + - def __init__(self, usr_msg=None, dev_msg=None): - """ApiError. +class RequestsApiError(ApiError): + """Exception re-raising a RequestException.""" + pass - Args: - usr_msg (str): Short user facing message describing error. - dev_msg (str or None): More detailed message to assist - developer with resolving issue. - """ - super().__init__(usr_msg) - self.usr_msg = usr_msg - self.dev_msg = dev_msg - def __repr__(self): - return repr(self.dev_msg) +class WebsocketError(ApiError): + """Exceptions related to websockets.""" + pass - def __str__(self): - return str(self.usr_msg) +class WebsocketIBMQProtocolError(WebsocketError): + """Exceptions related to IBM Q protocol error.""" + pass -class BadBackendError(ApiError): - """Unavailable backend error.""" - def __init__(self, backend): - """BadBackendError. +class WebsocketAuthenticationError(WebsocketError): + """Exception caused during websocket authentication.""" + pass - Args: - backend (str): name of backend. - """ - usr_msg = 'Could not find backend "{0}" available.'.format(backend) - dev_msg = ('Backend "{0}" does not exist. Please use ' - 'available_backends to see options').format(backend) - super().__init__(usr_msg, dev_msg) + +class WebsocketTimeoutError(WebsocketError): + """Timeout during websocket communication.""" + pass + + +class AuthenticationLicenseError(ApiError): + """Exception due to user not accepting latest license agreement via web.""" + pass -class CredentialsError(ApiError): - """Exception associated with bad server credentials.""" +class ApiIBMQProtocolError(ApiError): + """Exception related to IBM Q API protocol error.""" pass -class RegisterSizeError(ApiError): - """Exception due to exceeding the maximum number of allowed qubits.""" +class UserTimeoutExceededError(ApiError): + """Exceptions related to exceeding user defined timeout.""" pass diff --git a/qiskit/providers/ibmq/api/ibmqconnector.py b/qiskit/providers/ibmq/api/ibmqconnector.py deleted file mode 100644 index e67b8fca6..000000000 --- a/qiskit/providers/ibmq/api/ibmqconnector.py +++ /dev/null @@ -1,486 +0,0 @@ -# -*- coding: utf-8 -*- - -# This code is part of Qiskit. -# -# (C) Copyright IBM 2017, 2018. -# -# This code is licensed under the Apache License, Version 2.0. You may -# obtain a copy of this license in the LICENSE.txt file in the root directory -# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. -# -# Any modifications or derivative works of this code must retain this -# copyright notice, and modified files need to carry a notice indicating -# that they have been altered from the originals. - -"""IBM Q API connector.""" - -import json -import logging -import re - -from ..apiconstants import ApiJobStatus -from .exceptions import CredentialsError, BadBackendError -from .utils import Request - -logger = logging.getLogger(__name__) - - -def get_job_url(config): - """Return the URL for a job.""" - hub = config.get('hub', None) - group = config.get('group', None) - project = config.get('project', None) - - if hub and group and project: - return '/Network/{}/Groups/{}/Projects/{}/jobs'.format(hub, group, - project) - return '/Jobs' - - -def get_backend_properties_url(config, backend_type): - """Return the URL for a backend's properties.""" - hub = config.get('hub', None) - - if hub: - return '/Network/{}/devices/{}/properties'.format(hub, backend_type) - return '/Backends/{}/properties'.format(backend_type) - - -def get_backend_defaults_url(config, backend_type): - """Return the URL for a backend's pulse defaults.""" - hub = config.get('hub', None) - group = config.get('group', None) - project = config.get('project', None) - - if hub and group and project: - return '/Network/{}/Groups/{}/Projects/{}/devices/{}/defaults'.format( - hub, group, project, backend_type) - - return '/Backends/{}/defaults'.format(backend_type) - - -def get_backends_url(config): - """Return the URL for a backend.""" - hub = config.get('hub', None) - group = config.get('group', None) - project = config.get('project', None) - - if hub and group and project: - return '/Network/{}/Groups/{}/Projects/{}/devices/v/1'.format(hub, group, - project) - return '/Backends/v/1' - - -class IBMQConnector: - """Connector class that handles the requests to the IBMQ platform. - - This class exposes a Python API for making requests to the IBMQ platform. - """ - - def __init__(self, token=None, config=None, verify=True): - """ If verify is set to false, ignore SSL certificate errors """ - self.config = config - - if self.config and ('url' in self.config): - url_parsed = re.compile(r'(?= 0. - if 'lengthQueue' in status: - ret['pending_jobs'] = max(status['lengthQueue'], 0) - else: - ret['pending_jobs'] = 0 - - ret['backend_name'] = backend_type - ret['backend_version'] = status.get('backend_version', '0.0.0') - ret['status_msg'] = status.get('status', '') - ret['operational'] = bool(status.get('state', False)) - - # Not part of the schema. - if 'busy' in status: - ret['dedicated'] = status['busy'] - - return ret - - def backend_properties(self, backend): - """Get the properties of a backend.""" - if not self.check_credentials(): - raise CredentialsError('credentials invalid') - - backend_type = self._check_backend(backend) - - if not backend_type: - raise BadBackendError(backend) - - url = get_backend_properties_url(self.config, backend_type) - - ret = self.req.get(url, params="&version=1") - if not bool(ret): - ret = {} - else: - ret["backend_name"] = backend_type - return ret - - def backend_defaults(self, backend): - """Get the pulse defaults of a backend.""" - if not self.check_credentials(): - raise CredentialsError('credentials invalid') - - backend_name = self._check_backend(backend) - - if not backend_name: - raise BadBackendError(backend) - - url = get_backend_defaults_url(self.config, backend_name) - - ret = self.req.get(url) - if not bool(ret): - ret = {} - return ret - - def available_backends(self): - """Get the backends available to use in the IBMQ Platform.""" - if not self.check_credentials(): - raise CredentialsError('credentials invalid') - - url = get_backends_url(self.config) - - response = self.req.get(url) - if (response is not None) and (isinstance(response, dict)): - return [] - - return response - - def circuit_run(self, name, **kwargs): - """Execute a Circuit. - - Args: - name (str): name of the Circuit. - **kwargs (dict): arguments for the Circuit. - - Returns: - dict: json response. - - Raises: - CredentialsError: if the user was not authenticated. - """ - if not self.check_credentials(): - raise CredentialsError('credentials invalid') - - url = '/QCircuitApiModels' - - payload = { - 'name': name, - 'params': kwargs - } - - response = self.req.post(url, data=json.dumps(payload)) - - return response - - def circuit_job_get(self, job_id): - """Return information about a Circuit job. - - Args: - job_id (str): the id of the job. - - Returns: - dict: job information. - """ - if not self.check_credentials(): - return {'status': 'Error', - 'error': 'Not credentials valid'} - if not job_id: - return {'status': 'Error', - 'error': 'Job ID not specified'} - - # TODO: by API constraints, always use the URL without h/g/p. - url = '/Jobs/{}'.format(job_id) - - job = self.req.get(url) - - if 'calibration' in job: - job['properties'] = job.pop('calibration') - - if 'qObjectResult' in job: - # If the job is using Qobj, return the qObjectResult directly, - # which should contain a valid Result. - return job - elif 'qasms' in job: - # Fallback for pre-Qobj jobs. - for qasm in job['qasms']: - if ('result' in qasm) and ('data' in qasm['result']): - qasm['data'] = qasm['result']['data'] - del qasm['result']['data'] - for key in qasm['result']: - qasm['data'][key] = qasm['result'][key] - del qasm['result'] - - return job - - def circuit_job_status(self, job_id): - """Return the status of a Circuits job. - - Args: - job_id (str): the id of the job. - - Returns: - dict: job status. - """ - if not self.check_credentials(): - return {'status': 'Error', - 'error': 'Not credentials valid'} - if not job_id: - return {'status': 'Error', - 'error': 'Job ID not specified'} - - # TODO: by API constraints, always use the URL without h/g/p. - url = '/Jobs/{}/status'.format(job_id) - - status = self.req.get(url) - - return status - - def api_version(self): - """Get the API Version of the QX Platform.""" - response = self.req.get('/version') - - # Parse the response, making sure a dict is returned in all cases. - if isinstance(response, str): - response = {'new_api': False, - 'api': response} - elif isinstance(response, dict): - response['new_api'] = True - - return response diff --git a/qiskit/providers/ibmq/api_v2/rest/__init__.py b/qiskit/providers/ibmq/api/rest/__init__.py similarity index 91% rename from qiskit/providers/ibmq/api_v2/rest/__init__.py rename to qiskit/providers/ibmq/api/rest/__init__.py index b90e932d8..e77d164f6 100644 --- a/qiskit/providers/ibmq/api_v2/rest/__init__.py +++ b/qiskit/providers/ibmq/api/rest/__init__.py @@ -12,7 +12,7 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. -"""REST adaptors for the IBM Q Experience v2 API.""" +"""REST adaptors for the IBM Q Experience API.""" from .auth import Auth from .root import Api diff --git a/qiskit/providers/ibmq/api_v2/rest/auth.py b/qiskit/providers/ibmq/api/rest/auth.py similarity index 80% rename from qiskit/providers/ibmq/api_v2/rest/auth.py rename to qiskit/providers/ibmq/api/rest/auth.py index 48cf20446..190296e2f 100644 --- a/qiskit/providers/ibmq/api_v2/rest/auth.py +++ b/qiskit/providers/ibmq/api/rest/auth.py @@ -12,8 +12,9 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. -"""Authentication REST adapter for the IBM Q Experience v2 API.""" +"""Authentication REST adapter for the IBM Q Experience API.""" +from typing import Dict, Any from .base import RestAdapterBase @@ -25,19 +26,19 @@ class Auth(RestAdapterBase): 'user_info': '/users/me', } - def login(self, api_token): + def login(self, api_token: str) -> Dict[str, Any]: """Login with token. Args: - api_token (str): API token. + api_token: API token. Returns: - dict: json response. + json response. """ url = self.get_url('login') return self.session.post(url, json={'apiToken': api_token}).json() - def user_info(self): + def user_info(self) -> Dict[str, Any]: """Return user information.""" url = self.get_url('user_info') response = self.session.get(url).json() diff --git a/qiskit/providers/ibmq/api_v2/rest/backend.py b/qiskit/providers/ibmq/api/rest/backend.py similarity index 63% rename from qiskit/providers/ibmq/api_v2/rest/backend.py rename to qiskit/providers/ibmq/api/rest/backend.py index c43f5911b..60a0a211b 100644 --- a/qiskit/providers/ibmq/api_v2/rest/backend.py +++ b/qiskit/providers/ibmq/api/rest/backend.py @@ -12,9 +12,13 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. -"""Backend REST adapter for the IBM Q Experience v2 API.""" +"""Backend REST adapter for the IBM Q Experience API.""" +import json +from typing import Dict, Optional, Any +from datetime import datetime # pylint: disable=unused-import from .base import RestAdapterBase +from ..session import RetrySession class Backend(RestAdapterBase): @@ -26,20 +30,39 @@ class Backend(RestAdapterBase): 'status': '/queue/status', } - def __init__(self, session, backend_name): + def __init__(self, session: RetrySession, backend_name: str) -> None: """Backend constructor. Args: - session (Session): session to be used in the adaptor. - backend_name (str): name of the backend. + session: session to be used in the adaptor. + backend_name: name of the backend. """ self.backend_name = backend_name super().__init__(session, '/devices/{}'.format(backend_name)) - def properties(self): - """Return backend properties.""" + def properties(self, datetime: Optional[datetime] = None) -> Dict[str, Any]: + """Return backend properties. + + Args: + datetime: datetime used for additional filtering passed to the query. + + Returns: + json response of backend properties. + """ + # pylint: disable=redefined-outer-name url = self.get_url('properties') - response = self.session.get(url, params={'version': 1}).json() + + params = { + 'version': 1 + } + + query = {} + if datetime: + extra_filter = {'last_update_date': {'lt': datetime.isoformat()}} + query['where'] = extra_filter + params['filter'] = json.dumps(query) # type: ignore[assignment] + + response = self.session.get(url, params=params).json() # Adjust name of the backend. if response: @@ -47,12 +70,12 @@ def properties(self): return response - def pulse_defaults(self): + def pulse_defaults(self) -> Dict[str, Any]: """Return backend pulse defaults.""" url = self.get_url('pulse_defaults') return self.session.get(url).json() - def status(self): + def status(self) -> Dict[str, Any]: """Return backend status.""" url = self.get_url('status') response = self.session.get(url).json() diff --git a/qiskit/providers/ibmq/api_v2/rest/base.py b/qiskit/providers/ibmq/api/rest/base.py similarity index 65% rename from qiskit/providers/ibmq/api_v2/rest/base.py rename to qiskit/providers/ibmq/api/rest/base.py index b251fa69a..c8ce3159b 100644 --- a/qiskit/providers/ibmq/api_v2/rest/base.py +++ b/qiskit/providers/ibmq/api/rest/base.py @@ -12,33 +12,34 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. -"""REST clients for accessing the IBM Q Experience v2 API.""" +"""REST clients for accessing the IBM Q Experience API.""" + +from ..session import RetrySession class RestAdapterBase: """Base class for REST adaptors.""" - URL_MAP = {} + URL_MAP = {} # type: ignore[var-annotated] """Mapping between the internal name of an endpoint and the actual URL""" - def __init__(self, session, prefix_url=''): + def __init__(self, session: RetrySession, prefix_url: str = '') -> None: """RestAdapterBase constructor. Args: - session (Session): session to be used in the adaptor. - prefix_url (str): string to be prefixed to all urls. + session: session to be used in the adaptor. + prefix_url: string to be prefixed to all urls. """ self.session = session self.prefix_url = prefix_url - def get_url(self, identifier): + def get_url(self, identifier: str) -> str: """Return the resolved URL for the specified identifier. Args: - identifier (str): internal identifier of the endpoint. + identifier: internal identifier of the endpoint. Returns: - str: the resolved URL of the endpoint (relative to the session - base url). + the resolved URL of the endpoint (relative to the session base url). """ return '{}{}'.format(self.prefix_url, self.URL_MAP[identifier]) diff --git a/qiskit/providers/ibmq/api_v2/rest/job.py b/qiskit/providers/ibmq/api/rest/job.py similarity index 50% rename from qiskit/providers/ibmq/api_v2/rest/job.py rename to qiskit/providers/ibmq/api/rest/job.py index 12f3aee38..a8b70c8ba 100644 --- a/qiskit/providers/ibmq/api_v2/rest/job.py +++ b/qiskit/providers/ibmq/api/rest/job.py @@ -12,11 +12,17 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. -"""Job REST adapter for the IBM Q Experience v2 API.""" +"""Job REST adapter for the IBM Q Experience API.""" -import json +import pprint + +from typing import Dict, Any +from marshmallow.exceptions import ValidationError from .base import RestAdapterBase +from .validation import StatusResponseSchema +from ..session import RetrySession +from ..exceptions import ApiIBMQProtocolError class Job(RestAdapterBase): @@ -24,6 +30,7 @@ class Job(RestAdapterBase): URL_MAP = { 'callback_upload': '/jobDataUploaded', + 'callback_download': '/resultDownloaded', 'cancel': '/cancel', 'download_url': '/jobDownloadUrl', 'self': '', @@ -33,132 +40,105 @@ class Job(RestAdapterBase): 'upload_url': '/jobUploadUrl' } - def __init__(self, session, job_id): + def __init__(self, session: RetrySession, job_id: str) -> None: """Job constructor. Args: - session (Session): session to be used in the adaptor. - job_id (str): id of the job. + session: session to be used in the adaptor. + job_id: id of the job. """ self.job_id = job_id super().__init__(session, '/Jobs/{}'.format(job_id)) - def get(self, excluded_fields, included_fields): + def get(self) -> Dict[str, Any]: """Return a job. - Args: - excluded_fields (list[str]): names of the fields to explicitly - exclude from the result. - included_fields (list[str]): names of the fields to explicitly - include in the result. - Returns: - dict: json response. + json response. """ url = self.get_url('self') - query = build_url_filter(excluded_fields, included_fields) - response = self.session.get( - url, params={'filter': json.dumps(query) if query else None}).json() + response = self.session.get(url).json() if 'calibration' in response: response['properties'] = response.pop('calibration') return response - def callback_upload(self): + def callback_upload(self) -> Dict[str, Any]: """Notify the API after uploading a Qobj via object storage.""" url = self.get_url('callback_upload') return self.session.post(url).json() - def cancel(self): + def callback_download(self) -> Dict[str, Any]: + """Notify the API after downloading a Qobj via object storage.""" + url = self.get_url('callback_download') + return self.session.post(url).json() + + def cancel(self) -> Dict[str, Any]: """Cancel a job.""" url = self.get_url('cancel') return self.session.post(url).json() - def download_url(self): + def download_url(self) -> Dict[str, Any]: """Return an object storage URL for downloading the Qobj.""" url = self.get_url('download_url') return self.session.get(url).json() - def properties(self): + def properties(self) -> Dict[str, Any]: """Return the backend properties of a job.""" url = self.get_url('properties') return self.session.get(url).json() - def result_url(self): + def result_url(self) -> Dict[str, Any]: """Return an object storage URL for downloading results.""" url = self.get_url('result_url') return self.session.get(url).json() - def status(self): - """Return the status of a job.""" - url = self.get_url('status') - return self.session.get(url).json() + def status(self) -> Dict[str, Any]: + """Return the status of a job. - def upload_url(self): + Returns: + status of a job + + Raises: + ApiIBMQProtocolError: if an unexpected result is received from the server. + """ + url = self.get_url('status') + api_response = self.session.get(url).json() + try: + # Validate the response. + StatusResponseSchema().validate(api_response) + except ValidationError as err: + raise ApiIBMQProtocolError('Unrecognized answer from server: \n{}'.format( + pprint.pformat(api_response))) from err + return api_response + + def upload_url(self) -> Dict[str, Any]: """Return an object storage URL for uploading the Qobj.""" url = self.get_url('upload_url') return self.session.get(url).json() - def put_object_storage(self, url, qobj_dict): + def put_object_storage(self, url: str, qobj_dict: Dict[str, Any]) -> str: """Upload a Qobj via object storage. Args: - url (str): object storage URL. - qobj_dict (dict): the qobj to be uploaded, in dict form. + url: object storage URL. + qobj_dict: the qobj to be uploaded, in dict form. Returns: - str: text response, that will be empty if the request was - successful. + text response, that will be empty if the request was successful. """ response = self.session.put(url, json=qobj_dict, bare=True) return response.text - def get_object_storage(self, url): + def get_object_storage(self, url: str) -> Dict[str, Any]: """Get via object_storage. Args: - url (str): object storage URL. + url: object storage URL. Returns: - dict: json response. + json response. """ return self.session.get(url, bare=True).json() - - -def build_url_filter(excluded_fields, included_fields): - """Return a URL filter based on included and excluded fields. - - If a field appears in both excluded_fields and included_fields, it - is ultimately included. - - Args: - excluded_fields (list[str]): names of the fields to explicitly - exclude from the result. - included_fields (list[str]): names of the fields to explicitly - include in the result. - - Returns: - dict: the query, as a dict in the format for the API. - """ - excluded_fields = excluded_fields or [] - included_fields = included_fields or [] - field_flags = {} - ret = {} - - # Build a map of fields to bool. - for field_ in excluded_fields: - field_flags[field_] = False - for field_ in included_fields: - # Set the included fields. If a field_ here was also in - # excluded_fields, it is overwritten here. - field_flags[field_] = True - - if 'properties' in field_flags: - field_flags['calibration'] = field_flags.pop('properties') - - if field_flags: - ret = {'fields': field_flags} - - return ret diff --git a/qiskit/providers/ibmq/api_v2/rest/root.py b/qiskit/providers/ibmq/api/rest/root.py similarity index 53% rename from qiskit/providers/ibmq/api_v2/rest/root.py rename to qiskit/providers/ibmq/api/rest/root.py index 21f953917..b6c5cfbc1 100644 --- a/qiskit/providers/ibmq/api_v2/rest/root.py +++ b/qiskit/providers/ibmq/api/rest/root.py @@ -12,10 +12,12 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. -"""Root REST adapter for the IBM Q Experience v2 API.""" +"""Root REST adapter for the IBM Q Experience API.""" import json +from typing import Dict, List, Optional, Any + from .base import RestAdapterBase from .backend import Backend from .job import Job @@ -33,48 +35,60 @@ class Api(RestAdapterBase): 'version': '/version' } - def backend(self, backend_name): + def backend(self, backend_name: str) -> Backend: """Return a adapter for a specific backend. Args: - backend_name (str): name of the backend. + backend_name: name of the backend. Returns: - Backend: the backend adapter. + the backend adapter. """ return Backend(self.session, backend_name) - def job(self, job_id): + def job(self, job_id: str) -> Job: """Return a adapter for a specific job. Args: - job_id (str): id of the job. + job_id: id of the job. Returns: - Job: the backend adapter. + the backend adapter. """ return Job(self.session, job_id) - def backends(self): - """Return the list of backends.""" + def backends(self, timeout: Optional[float] = None) -> List[Dict[str, Any]]: + """Return the list of backends. + + Args: + timeout: number of seconds to wait for the request. + + Returns: + json response. + """ url = self.get_url('backends') - return self.session.get(url).json() + return self.session.get(url, timeout=timeout).json() - def hubs(self): + def hubs(self) -> List[Dict[str, Any]]: """Return the list of hubs available to the user.""" url = self.get_url('hubs') return self.session.get(url).json() - def jobs(self, limit=10, skip=0, extra_filter=None): + def jobs( + self, + limit: int = 10, + skip: int = 0, + extra_filter: Dict[str, Any] = None + ) -> List[Dict[str, Any]]: """Return a list of jobs statuses. Args: - limit (int): maximum number of items to return. - skip (int): offset for the items to return. - extra_filter (dict): additional filtering passed to the query. + limit: maximum number of items to return. + skip: offset for the items to return. + extra_filter: additional filtering passed to the query. Returns: - list[dict]: json response. + json response. """ url = self.get_url('jobs_status') @@ -89,15 +103,23 @@ def jobs(self, limit=10, skip=0, extra_filter=None): return self.session.get( url, params={'filter': json.dumps(query)}).json() - def submit_job(self, backend_name, qobj_dict): + def job_submit( + self, + backend_name: str, + qobj_dict: Dict[str, Any], + job_name: Optional[str] = None, + job_share_level: Optional[str] = None + ) -> Dict[str, Any]: """Submit a job for executing. Args: - backend_name (str): the name of the backend. - qobj_dict (dict): the Qobj to be executed, as a dictionary. + backend_name: the name of the backend. + qobj_dict: the Qobj to be executed, as a dictionary. + job_name: custom name to be assigned to the job. + job_share_level: level the job should be shared at. Returns: - dict: json response. + json response. """ url = self.get_url('jobs') @@ -107,17 +129,31 @@ def submit_job(self, backend_name, qobj_dict): 'shots': qobj_dict.get('config', {}).get('shots', 1) } + if job_name: + payload['name'] = job_name + + if job_share_level: + payload['shareLevel'] = job_share_level + return self.session.post(url, json=payload).json() - def submit_job_object_storage(self, backend_name, shots=1): + def submit_job_object_storage( + self, + backend_name: str, + shots: int = 1, + job_name: Optional[str] = None, + job_share_level: Optional[str] = None + ) -> Dict[str, Any]: """Submit a job for executing, using object storage. Args: - backend_name (str): the name of the backend. - shots (int): number of shots. + backend_name: the name of the backend. + shots: number of shots. + job_name: custom name to be assigned to the job. + job_share_level: level the job should be shared at. Returns: - dict: json response. + json response. """ url = self.get_url('jobs') @@ -128,17 +164,23 @@ def submit_job_object_storage(self, backend_name, shots=1): 'allowObjectStorage': True } + if job_name: + payload['name'] = job_name + + if job_share_level: + payload['shareLevel'] = job_share_level + return self.session.post(url, json=payload).json() - def circuit(self, name, **kwargs): + def circuit(self, name: str, **kwargs: Any) -> Dict[str, Any]: """Execute a Circuit. Args: - name (str): name of the Circuit. - **kwargs (dict): arguments for the Circuit. + name: name of the Circuit. + **kwargs: arguments for the Circuit. Returns: - dict: json response. + json response. """ url = self.get_url('circuit') @@ -149,7 +191,7 @@ def circuit(self, name, **kwargs): return self.session.post(url, json=payload).json() - def version(self): + def version(self) -> Dict[str, Any]: """Return the API versions.""" url = self.get_url('version') return self.session.get(url).json() diff --git a/qiskit/providers/ibmq/api_v2/__init__.py b/qiskit/providers/ibmq/api/rest/schemas/__init__.py similarity index 84% rename from qiskit/providers/ibmq/api_v2/__init__.py rename to qiskit/providers/ibmq/api/rest/schemas/__init__.py index f1fb885d9..c2f6371bc 100644 --- a/qiskit/providers/ibmq/api_v2/__init__.py +++ b/qiskit/providers/ibmq/api/rest/schemas/__init__.py @@ -2,7 +2,7 @@ # This code is part of Qiskit. # -# (C) Copyright IBM 2018, 2019. +# (C) Copyright IBM 2017, 2019. # # This code is licensed under the Apache License, Version 2.0. You may # obtain a copy of this license in the LICENSE.txt file in the root directory @@ -12,4 +12,4 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. -"""IBM Q Experience v2 API connector and utilities.""" +"""Qiskit IBMQ Provider schema-conformant objects""" diff --git a/qiskit/providers/ibmq/api/rest/schemas/auth.py b/qiskit/providers/ibmq/api/rest/schemas/auth.py new file mode 100644 index 000000000..b4afd4aa6 --- /dev/null +++ b/qiskit/providers/ibmq/api/rest/schemas/auth.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- + +# This code is part of Qiskit. +# +# (C) Copyright IBM 2017, 2019. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Schemas for authentication.""" + +from qiskit.validation import BaseSchema +from qiskit.validation.fields import String, Url, Nested + + +# Helper schemas. + +class UserApiUrlResponseSchema(BaseSchema): + """Nested schema for UserInfoResponse""" + # pylint: disable=invalid-name + + # Required properties. + http = Url(required=True, description='the API URL for http communication.') + ws = String(required=True, description='the API URL for websocket communication.') + + +# Endpoint schemas. + +class LoginRequestSchema(BaseSchema): + """Schema for LoginRequest""" + + # Required properties + apiToken = String(required=True, description='API token.') + + +class LoginResponseSchema(BaseSchema): + """Schema for LoginResponse.""" + # pylint: disable=invalid-name + + # Required properties. + id = String(required=True, description='access token.') + + +class UserInfoResponseSchema(BaseSchema): + """Schema for UserInfoResponse.""" + + # Required properties. + urls = Nested(UserApiUrlResponseSchema, required=True, + description='base URLs for the services. Currently supported keys: ' + 'http and ws') + + +class VersionResponseSchema(BaseSchema): + """Schema for VersionResponse""" + + # Required properties. + api_auth = String(load_from='api-auth', required=True, + description='the versions of auth API component') diff --git a/qiskit/providers/ibmq/api/rest/schemas/job.py b/qiskit/providers/ibmq/api/rest/schemas/job.py new file mode 100644 index 000000000..f4ca46e28 --- /dev/null +++ b/qiskit/providers/ibmq/api/rest/schemas/job.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- + +# This code is part of Qiskit. +# +# (C) Copyright IBM 2019. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Schemas for job.""" +from marshmallow.fields import Bool +from marshmallow.validate import OneOf +from qiskit.providers.ibmq.apiconstants import ApiJobStatus, ApiJobKind +from qiskit.validation import BaseSchema +from qiskit.validation.fields import Dict, String, Url, Nested, Integer + + +# Helper schemas. + +class FieldsFilterRequestSchema(BaseSchema): + """Nested schema for SelfFilterQueryParamRequestSchema""" + + # Required properties + fields = Dict(keys=String, values=Bool) + + +class InfoQueueResponseSchema(BaseSchema): + """Nested schema for StatusResponseSchema""" + + # Optional properties + position = Integer(required=False, missing=0) + status = String(required=False) + + +class JobResponseSchema(BaseSchema): + """Nested schema for CallbackUploadResponseSchema""" + # pylint: disable=invalid-name + + # Optional properties + error = String(required=False) + + # Required properties + id = String(required=True) + kind = String(required=True) + creationDate = String(required=True, description="when the job was run") + + +# Endpoint schemas. + +class SelfFilterQueryParamRequestSchema(BaseSchema): + """Schema for SelfFilterQueryParamRequest""" + + # Required properties + filter = Nested(FieldsFilterRequestSchema, required=True) + + +class SelfResponseSchema(BaseSchema): + """Schema for SelfResponseSchema""" + # pylint: disable=invalid-name + + # Optional properties + error = String(required=False) + + id = String(required=True) + kind = String(required=True, validate=OneOf([kind.value for kind in ApiJobKind])) + status = String(required=True, validate=OneOf([status.value for status in ApiJobStatus])) + creationDate = String(required=True, description="when the job was run") + + +class PropertiesResponseSchema(BaseSchema): + """Schema for PropertiesResponse""" + pass + + +class StatusResponseSchema(BaseSchema): + """Schema for StatusResponse""" + + # Optional properties + infoQueue = Nested(InfoQueueResponseSchema, required=False) + + # Required properties + status = String(required=True, validate=OneOf([status.value for status in ApiJobStatus])) + + +class CancelResponseSchema(BaseSchema): + """Schema for CancelResponse""" + + # Optional properties + error = String(required=False) + + +class UploadUrlResponseSchema(BaseSchema): + """Schema for UploadUrlResponse""" + + # Required properties + url = Url(required=True, description="upload object storage URL.") + + +class DownloadUrlResponseSchema(BaseSchema): + """Schema for DownloadUrlResponse""" + + # Required properties + url = Url(required=True, description="download object storage URL.") + + +class ResultUrlResponseSchema(BaseSchema): + """Schema for ResultUrlResponse""" + + # Required properties + url = Url(required=True, description="object storage URL.") + + +class CallbackUploadResponseSchema(BaseSchema): + """Schema for CallbackUploadResponse""" + + # Required properties + job = Nested(JobResponseSchema, required=True) + + +class CallbackDownloadResponseSchema(BaseSchema): + """Schema for CallbackDownloadResponse""" + pass diff --git a/qiskit/providers/ibmq/api/rest/schemas/root.py b/qiskit/providers/ibmq/api/rest/schemas/root.py new file mode 100644 index 000000000..11742f8fa --- /dev/null +++ b/qiskit/providers/ibmq/api/rest/schemas/root.py @@ -0,0 +1,166 @@ +# -*- coding: utf-8 -*- + +# This code is part of Qiskit. +# +# (C) Copyright IBM 2017, 2019. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Schemas for root.""" + +from marshmallow.validate import OneOf +from qiskit.providers import JobStatus +from qiskit.providers.models.backendconfiguration import BackendConfigurationSchema +from qiskit.providers.ibmq.apiconstants import ApiJobKind +from qiskit.validation import BaseSchema +from qiskit.validation.fields import String, Dict, Nested, Boolean, List, Number + + +# Helper schemas. + +class ProjectResponseSchema(BaseSchema): + """Nested schema for ProjectsResponseSchema""" + + # Required properties. + isDefault = Boolean(required=True) + + +class ProjectsResponseSchema(BaseSchema): + """Nested schema for GroupResponseSchema""" + + # Required properties. + project_name = String(required=True) + project = Nested(ProjectResponseSchema, required=True) + + +class GroupResponseSchema(BaseSchema): + """Nested schema for GroupsResponseSchema""" + + # Required properties. + projects = Dict(Nested(ProjectsResponseSchema), required=True) + + +class GroupsResponseSchema(BaseSchema): + """Nested schema for HubsResponseSchema""" + + # Required properties. + group_name = String(required=True) + group = Nested(GroupResponseSchema, required=True) + + +class CircuitErrorResponseSchema(BaseSchema): + """Nested schema for CircuitResponseSchema""" + + # Required properties + code = String(required=True, validate=OneOf(['GENERIC_ERROR', 'HUB_NOT_FOUND'])) + + +class BackendRequestSchema(BaseSchema): + """Nested schema for JobsRequestSchema""" + + # Required properties + name = String(required=True, description="the name of the backend.") + + +class JobsStatusFilterQueryParamRequestSchema(BaseSchema): + """Nested schema for JobsStatusRequestSchema""" + + # Optional properties + where = Dict(attribute="extra_filter", required=False, + description="additional filtering passed to the query.") + + # Required properties + order = String(required=True, default="creationDate DESC") + limit = Number(required=True, description="maximum number of items to return.") + skip = Number(required=True, description="offset for the items to return.") + + +# Endpoint schemas. + +class HubsResponseSchema(BaseSchema): + """Schema for HubsResponse""" + pass + + # pylint: disable=pointless-string-statement + """ Commented out until https://github.com/Qiskit/qiskit-terra/issues/3021 is addressed + # Required properties. + name = String(required=True) + groups = Dict(Nested(GroupsResponseSchema), required=True) + """ + + +class CircuitRequestSchema(BaseSchema): + """Schema for CircuitRequest""" + + # Required properties + name = String(required=True, description="name of the Circuit.") + params = Dict(required=True, description="arguments for the Circuit.") + + +class CircuitResponseSchema(BaseSchema): + """Schema for CircuitResponse""" + # pylint: disable=invalid-name + + # Optional properties + error = Dict(Nested(CircuitErrorResponseSchema), required=False) + + # Required properties + id = String(required=True, description="the job ID of an already submitted job.") + creationDate = String(required=True, description="when the job was run.") + status = String(required=True, description="`status` field directly from the API response.") + + +class BackendsResponseSchema(BaseSchema): + """Schema for BackendResponse""" + + # Required properties + backends = List(Nested(BackendConfigurationSchema, required=True)) + + +class JobsRequestSchema(BaseSchema): + """Schema for JobsRequest""" + + # Optional properties + name = String(required=False, description="custom name to be assigned to the job.") + + # Required properties + qObject = Dict(required=True, description="the Qobj to be executed, as a dictionary.") + backend = Nested(BackendRequestSchema, required=True) + shots = Number(required=True) + + +class JobsResponseSchema(BaseSchema): + """Schema for JobsResponse""" + # pylint: disable=invalid-name + + # Optional properties + error = String(required=False) + + # Required properties + id = String(required=True) + status = String(required=True, validate=OneOf([status.name for status in JobStatus])) + creationDate = String(required=True) + + +class JobsStatusRequestSchema(BaseSchema): + """Schema for JobsStatusRequest""" + + # Required properties + filter = Nested(JobsStatusFilterQueryParamRequestSchema, required=True) + + +class JobsStatusResponseSchema(BaseSchema): + """Schema for JobsStatusResponse""" + # pylint: disable=invalid-name + + # Required properties + id = String(required=True, description="the job ID of an already submitted job.") + kind = String(required=True, validate=OneOf([kind.name for kind in ApiJobKind])) + creationDate = String(required=True, description="when the job was run.") + status = String(required=True) diff --git a/qiskit/providers/ibmq/api/rest/validation.py b/qiskit/providers/ibmq/api/rest/validation.py new file mode 100644 index 000000000..e65606d96 --- /dev/null +++ b/qiskit/providers/ibmq/api/rest/validation.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- + +# This code is part of Qiskit. +# +# (C) Copyright IBM 2019. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Schemas for validation.""" +# TODO The schemas defined here should be merged with others under rest/schemas +# when they are ready +from marshmallow.validate import OneOf +from qiskit.providers.ibmq.apiconstants import ApiJobStatus +from qiskit.validation import BaseSchema +from qiskit.validation.fields import String, Nested, Integer + + +# Helper schemas. + +class InfoQueueResponseSchema(BaseSchema): + """Nested schema for StatusResponseSchema""" + + # Optional properties + position = Integer(required=False, missing=0) + status = String(required=False) + + +# Endpoint schemas. + +class StatusResponseSchema(BaseSchema): + """Schema for StatusResponse""" + + # Optional properties + infoQueue = Nested(InfoQueueResponseSchema, required=False) + + # Required properties + status = String(required=True, validate=OneOf([status.value for status in ApiJobStatus])) diff --git a/qiskit/providers/ibmq/api_v2/rest/version_finder.py b/qiskit/providers/ibmq/api/rest/version_finder.py similarity index 87% rename from qiskit/providers/ibmq/api_v2/rest/version_finder.py rename to qiskit/providers/ibmq/api/rest/version_finder.py index e83817a19..7c4a41175 100644 --- a/qiskit/providers/ibmq/api_v2/rest/version_finder.py +++ b/qiskit/providers/ibmq/api/rest/version_finder.py @@ -12,9 +12,10 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. -"""Version finder for the IBM Q Experience v2 API.""" +"""Version finder for the IBM Q Experience API.""" from json import JSONDecodeError +from typing import Dict, Union from .base import RestAdapterBase @@ -26,11 +27,11 @@ class VersionFinder(RestAdapterBase): 'version': '/version' } - def version(self): + def version(self) -> Dict[str, Union[str, bool]]: """Return the version info. Returns: - dict: a dict with information about the API version, + a dict with information about the API version, with the following keys: * `new_api` (bool): whether the new API is being used And the following optional keys: diff --git a/qiskit/providers/ibmq/api_v2/session.py b/qiskit/providers/ibmq/api/session.py similarity index 57% rename from qiskit/providers/ibmq/api_v2/session.py rename to qiskit/providers/ibmq/api/session.py index 9fd5bf57e..ae808554c 100644 --- a/qiskit/providers/ibmq/api_v2/session.py +++ b/qiskit/providers/ibmq/api/session.py @@ -15,8 +15,10 @@ """Session customized for IBM Q Experience access.""" import os -from requests import Session, RequestException +from typing import Dict, Optional, Any, Tuple, Union +from requests import Session, RequestException, Response from requests.adapters import HTTPAdapter +from requests.auth import AuthBase from urllib3.util.retry import Retry from .exceptions import RequestsApiError @@ -39,19 +41,31 @@ class RetrySession(Session): ``requests.Session``. """ - def __init__(self, base_url, access_token=None, - retries=5, backoff_factor=0.5, - verify=True, proxies=None, auth=None): + def __init__( + self, + base_url: str, + access_token: Optional[str] = None, + retries_total: int = 5, + retries_connect: int = 3, + backoff_factor: float = 0.5, + verify: bool = True, + proxies: Optional[Dict[str, str]] = None, + auth: Optional[AuthBase] = None, + timeout: Tuple[float, Union[float, None]] = (5.0, None) + ) -> None: """RetrySession constructor. Args: - base_url (str): base URL for the session's requests. - access_token (str): access token. - retries (int): number of retries for the requests. - backoff_factor (float): backoff factor between retry attempts. - verify (bool): enable SSL verification. - proxies (dict): proxy URLs mapped by protocol. - auth (AuthBase): authentication handler. + base_url: base URL for the session's requests. + access_token: access token. + retries_total: number of total retries for the requests. + retries_connect: number of connect retries for the requests. + backoff_factor: backoff factor between retry attempts. + verify: enable SSL verification. + proxies: proxy URLs mapped by protocol. + auth: authentication handler. + timeout: timeout for the requests, in the form (connection_timeout, + total_timeout). """ super().__init__() @@ -59,36 +73,44 @@ def __init__(self, base_url, access_token=None, self._access_token = access_token self.access_token = access_token - self._initialize_retry(retries, backoff_factor) + self._initialize_retry(retries_total, retries_connect, backoff_factor) self._initialize_session_parameters(verify, proxies or {}, auth) + self._timeout = timeout - def __del__(self): + def __del__(self) -> None: """RetrySession destructor. Closes the session.""" self.close() @property - def access_token(self): + def access_token(self) -> Optional[str]: """Return the session access token.""" return self._access_token @access_token.setter - def access_token(self, value): + def access_token(self, value: Optional[str]) -> None: """Set the session access token.""" self._access_token = value if value: - self.params.update({'access_token': value}) + self.params.update({'access_token': value}) # type: ignore[attr-defined] else: - self.params.pop('access_token', None) - - def _initialize_retry(self, retries, backoff_factor): + self.params.pop('access_token', None) # type: ignore[attr-defined] + + def _initialize_retry( + self, + retries_total: int, + retries_connect: int, + backoff_factor: float + ) -> None: """Set the Session retry policy. Args: - retries (int): number of retries for the requests. - backoff_factor (float): backoff factor between retry attempts. + retries_total: number of total retries for the requests. + retries_connect: number of connect retries for the requests. + backoff_factor: backoff factor between retry attempts. """ retry = Retry( - total=retries, + total=retries_total, + connect=retries_connect, backoff_factor=backoff_factor, status_forcelist=STATUS_FORCELIST, ) @@ -97,13 +119,18 @@ def _initialize_retry(self, retries, backoff_factor): self.mount('http://', retry_adapter) self.mount('https://', retry_adapter) - def _initialize_session_parameters(self, verify, proxies, auth): + def _initialize_session_parameters( + self, + verify: bool, + proxies: Dict[str, str], + auth: Optional[AuthBase] = None + ) -> None: """Set the Session parameters and attributes. Args: - verify (bool): enable SSL verification. - proxies (dict): proxy URLs mapped by protocol. - auth (AuthBase): authentication handler. + verify: enable SSL verification. + proxies: proxy URLs mapped by protocol. + auth: authentication handler. """ client_app_header = CLIENT_APPLICATION @@ -118,18 +145,24 @@ def _initialize_session_parameters(self, verify, proxies, auth): self.proxies = proxies or {} self.verify = verify - def request(self, method, url, bare=False, **kwargs): + def request( # type: ignore[override] + self, + method: str, + url: str, + bare: bool = False, + **kwargs: Any + ) -> Response: """Constructs a Request, prepending the base url. Args: - method (string): method for the new `Request` object. - url (string): URL for the new `Request` object. - bare (bool): if `True`, do not send IBM Q specific information + method: method for the new `Request` object. + url: URL for the new `Request` object. + bare: if `True`, do not send IBM Q specific information (access token) in the request or modify the `url`. - kwargs (dict): additional arguments for the request. + kwargs: additional arguments for the request. Returns: - Request: Request object. + Response object. Raises: RequestsApiError: if the request failed. @@ -144,6 +177,10 @@ def request(self, method, url, bare=False, **kwargs): else: final_url = self.base_url + url + # Add a timeout to the connection for non-proxy connections. + if not self.proxies: + kwargs.update({'timeout': self._timeout}) + try: response = super().request(method, final_url, **kwargs) response.raise_for_status() @@ -162,7 +199,9 @@ def request(self, method, url, bare=False, **kwargs): if self.access_token: message = message.replace(self.access_token, '...') + # Replace the original message on the `RequestException` as well. + ex.args = (message,) - raise RequestsApiError(ex, message) from None + raise RequestsApiError(message) from ex return response diff --git a/qiskit/providers/ibmq/api/utils.py b/qiskit/providers/ibmq/api/utils.py deleted file mode 100644 index 45fb9f93c..000000000 --- a/qiskit/providers/ibmq/api/utils.py +++ /dev/null @@ -1,420 +0,0 @@ -# -*- coding: utf-8 -*- - -# This code is part of Qiskit. -# -# (C) Copyright IBM 2017, 2018. -# -# This code is licensed under the Apache License, Version 2.0. You may -# obtain a copy of this license in the LICENSE.txt file in the root directory -# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. -# -# Any modifications or derivative works of this code must retain this -# copyright notice, and modified files need to carry a notice indicating -# that they have been altered from the originals. - -"""Utilities for IBM Q API connector.""" - -import json -import logging -import re -import time -from urllib import parse - -import requests -from requests_ntlm import HttpNtlmAuth - -from .exceptions import (ApiError, CredentialsError, RegisterSizeError) - - -logger = logging.getLogger(__name__) - -CLIENT_APPLICATION = 'qiskit-api-py' - - -class Credentials: - """Credentials class that manages the tokens.""" - - config_base = {'url': 'https://quantumexperience.ng.bluemix.net/api'} - - def __init__(self, token, config=None, verify=True, proxy_urls=None, - ntlm_credentials=None): - self.token_unique = token - self.verify = verify - self.config = config - self.proxy_urls = proxy_urls - self.ntlm_credentials = ntlm_credentials - - # Set the extra arguments to requests (proxy and auth). - self.extra_args = {} - if self.proxy_urls: - self.extra_args['proxies'] = self.proxy_urls - if self.ntlm_credentials: - self.extra_args['auth'] = HttpNtlmAuth( - self.ntlm_credentials['username'], - self.ntlm_credentials['password']) - - if not verify: - # pylint: disable=import-error - import requests.packages.urllib3 as urllib3 - urllib3.disable_warnings() - print('-- Ignoring SSL errors. This is not recommended --') - if self.config and ("url" not in self.config): - self.config["url"] = self.config_base["url"] - elif not self.config: - self.config = self.config_base - - self.data_credentials = {} - if token: - self.obtain_token(config=self.config) - else: - access_token = self.config.get('access_token', None) - if access_token: - user_id = self.config.get('user_id', None) - if access_token: - self.set_token(access_token) - if user_id: - self.set_user_id(user_id) - else: - self.obtain_token(config=self.config) - - def obtain_token(self, config=None): - """Obtain the token to access to QX Platform. - - Raises: - CredentialsError: when token is invalid or the user has not - accepted the license. - ApiError: when the response from the server couldn't be parsed. - """ - client_application = CLIENT_APPLICATION - if self.config and ("client_application" in self.config): - client_application += ':' + self.config["client_application"] - headers = {'x-qx-client-application': client_application} - - if self.token_unique: - try: - response = requests.post(str(self.config.get('url') + - "/users/loginWithToken"), - data={'apiToken': self.token_unique}, - verify=self.verify, - headers=headers, - **self.extra_args) - except requests.RequestException as ex: - raise ApiError('error during login: %s' % str(ex)) - elif config and ("email" in config) and ("password" in config): - email = config.get('email', None) - password = config.get('password', None) - credentials = { - 'email': email, - 'password': password - } - try: - response = requests.post(str(self.config.get('url') + - "/users/login"), - data=credentials, - verify=self.verify, - headers=headers, - **self.extra_args) - except requests.RequestException as ex: - raise ApiError('error during login: %s' % str(ex)) - else: - raise CredentialsError('invalid token') - - if response.status_code == 401: - error_message = None - try: - # For 401: ACCEPT_LICENSE_REQUIRED, a detailed message is - # present in the response and passed to the exception. - error_message = response.json()['error']['message'] - except Exception: # pylint: disable=broad-except - pass - - if error_message: - raise CredentialsError('error during login: %s' % error_message) - raise CredentialsError('invalid token') - try: - response.raise_for_status() - self.data_credentials = response.json() - except (requests.HTTPError, ValueError) as ex: - raise ApiError('error during login: %s' % str(ex)) - - if self.get_token() is None: - raise CredentialsError('invalid token') - - def get_token(self): - """Return the Authenticated Token to connect with QX Platform.""" - return self.data_credentials.get('id', None) - - def get_user_id(self): - """Return the user id in QX platform.""" - return self.data_credentials.get('userId', None) - - def get_config(self): - """Return the configuration that was set for this Credentials.""" - return self.config - - def set_token(self, access_token): - """Set the Access Token to connect with QX Platform API.""" - self.data_credentials['id'] = access_token - - def set_user_id(self, user_id): - """Set the user id to connect with QX Platform API.""" - self.data_credentials['userId'] = user_id - - -class Request: - """Request class that performs the HTTP calls. - - Note: - Set the proxy information, if present, from the configuration, - with the following format:: - - config = { - 'proxies': { - # If using 'urls', assume basic auth or no auth. - 'urls': { - 'http': 'http://user:password@1.2.3.4:5678', - 'https': 'http://user:password@1.2.3.4:5678', - } - # If using 'ntlm', assume NTLM authentication. - 'username_ntlm': 'domain\\username', - 'password_ntlm': 'password' - } - } - """ - - def __init__(self, token, config=None, verify=True, retries=5, - timeout_interval=1.0): - self.verify = verify - self.client_application = CLIENT_APPLICATION - self.config = config - self.errors_not_retry = [401, 403, 413] - - # Set the basic proxy settings, if present. - self.proxy_urls = None - self.ntlm_credentials = None - if config and 'proxies' in config: - if 'urls' in config['proxies']: - self.proxy_urls = self.config['proxies']['urls'] - if 'username_ntlm' and 'password_ntlm' in config['proxies']: - self.ntlm_credentials = { - 'username': self.config['proxies']['username_ntlm'], - 'password': self.config['proxies']['password_ntlm'] - } - - # Set the extra arguments to requests (proxy and auth). - self.extra_args = {} - if self.proxy_urls: - self.extra_args['proxies'] = self.proxy_urls - if self.ntlm_credentials: - self.extra_args['auth'] = HttpNtlmAuth( - self.ntlm_credentials['username'], - self.ntlm_credentials['password']) - - if self.config and ("client_application" in self.config): - self.client_application += ':' + self.config["client_application"] - self.credential = Credentials(token, self.config, verify, - proxy_urls=self.proxy_urls, - ntlm_credentials=self.ntlm_credentials) - - if not isinstance(retries, int): - raise TypeError('post retries must be positive integer') - self.retries = retries - self.timeout_interval = timeout_interval - self.result = None - self._max_qubit_error_re = re.compile( - r".*registers exceed the number of qubits, " - r"it can\'t be greater than (\d+).*") - - def check_token(self, response): - """Check is the user's token is valid.""" - if response.status_code == 401: - self.credential.obtain_token(config=self.config) - return False - return True - - def post(self, path, params='', data=None): - """POST Method Wrapper of the REST API.""" - self.result = None - data = data or {} - headers = {'Content-Type': 'application/json', - 'x-qx-client-application': self.client_application} - url = str(self.credential.config['url'] + path + '?access_token=' + - self.credential.get_token() + params) - retries = self.retries - while retries > 0: - response = requests.post(url, data=data, headers=headers, - verify=self.verify, **self.extra_args) - if not self.check_token(response): - response = requests.post(url, data=data, headers=headers, - verify=self.verify, - **self.extra_args) - - if self._response_good(response): - if self.result: - return self.result - elif retries < 2: - return response.json() - else: - retries -= 1 - else: - retries -= 1 - time.sleep(self.timeout_interval) - - # timed out - raise ApiError(usr_msg='Failed to get proper ' + - 'response from backend.') - - def put(self, path, params='', data=None): - """PUT Method Wrapper of the REST API.""" - self.result = None - data = data or {} - headers = {'Content-Type': 'application/json', - 'x-qx-client-application': self.client_application} - url = str(self.credential.config['url'] + path + '?access_token=' + - self.credential.get_token() + params) - retries = self.retries - while retries > 0: - response = requests.put(url, data=data, headers=headers, - verify=self.verify, **self.extra_args) - if not self.check_token(response): - response = requests.put(url, data=data, headers=headers, - verify=self.verify, - **self.extra_args) - if self._response_good(response): - if self.result: - return self.result - elif retries < 2: - return response.json() - else: - retries -= 1 - else: - retries -= 1 - time.sleep(self.timeout_interval) - # timed out - raise ApiError(usr_msg='Failed to get proper ' + - 'response from backend.') - - def get(self, path, params='', with_token=True): - """GET Method Wrapper of the REST API.""" - self.result = None - access_token = '' - if with_token: - access_token = self.credential.get_token() or '' - if access_token: - access_token = '?access_token=' + str(access_token) - url = self.credential.config['url'] + path + access_token + params - retries = self.retries - headers = {'x-qx-client-application': self.client_application} - while retries > 0: # Repeat until no error - response = requests.get(url, verify=self.verify, headers=headers, - **self.extra_args) - if not self.check_token(response): - response = requests.get(url, verify=self.verify, - headers=headers, **self.extra_args) - if self._response_good(response): - if self.result: - return self.result - elif retries < 2: - return response.json() - else: - retries -= 1 - else: - retries -= 1 - time.sleep(self.timeout_interval) - # timed out - raise ApiError(usr_msg='Failed to get proper ' + - 'response from backend.') - - def _sanitize_url(self, url): - """Strip any tokens or actual paths from url. - - Args: - url (str): The url to sanitize - - Returns: - str: The sanitized url - """ - return parse.urlparse(url).path - - def _response_good(self, response): - """check response. - - Args: - response (requests.Response): HTTP response. - - Returns: - bool: True if the response is good, else False. - - Raises: - ApiError: response isn't formatted properly. - """ - - url = self._sanitize_url(response.url) - - if response.status_code != requests.codes.ok: - if 'QCircuitApiModels' in url: - # Reduce verbosity for Circuits invocation. - # TODO: reenable once the API is more stable. - logger.debug('Got a %s code response to %s: %s', - response.status_code, - url, - response.text) - else: - logger.warning('Got a %s code response to %s: %s', - response.status_code, - url, - response.text) - if response.status_code in self.errors_not_retry: - raise ApiError(usr_msg='Got a {} code response to {}: {}'.format( - response.status_code, - url, - response.text)) - return self._parse_response(response) - try: - if str(response.headers['content-type']).startswith("text/html;"): - self.result = response.text - return True - else: - self.result = response.json() - except (json.JSONDecodeError, ValueError): - usr_msg = 'device server returned unexpected http response' - dev_msg = usr_msg + ': ' + response.text - raise ApiError(usr_msg=usr_msg, dev_msg=dev_msg) - if not isinstance(self.result, (list, dict)): - msg = ('JSON not a list or dict: url: {0},' - 'status: {1}, reason: {2}, text: {3}') - raise ApiError( - usr_msg=msg.format(url, - response.status_code, - response.reason, response.text)) - if ('error' not in self.result or - ('status' not in self.result['error'] or - self.result['error']['status'] != 400)): - return True - - logger.warning("Got a 400 code JSON response to %s", url) - return False - - def _parse_response(self, response): - """parse text of response for HTTP errors. - - This parses the text of the response to decide whether to - retry request or raise exception. At the moment this only - detects an exception condition. - - Args: - response (Response): requests.Response object - - Returns: - bool: False if the request should be retried, True - if not. - - Raises: - RegisterSizeError: if invalid device register size. - """ - # convert error messages into exceptions - mobj = self._max_qubit_error_re.match(response.text) - if mobj: - raise RegisterSizeError( - 'device register size must be <= {}'.format(mobj.group(1))) - return True diff --git a/qiskit/providers/ibmq/api_v2/clients/account.py b/qiskit/providers/ibmq/api_v2/clients/account.py deleted file mode 100644 index 574776b98..000000000 --- a/qiskit/providers/ibmq/api_v2/clients/account.py +++ /dev/null @@ -1,329 +0,0 @@ -# -*- coding: utf-8 -*- - -# This code is part of Qiskit. -# -# (C) Copyright IBM 2018, 2019. -# -# This code is licensed under the Apache License, Version 2.0. You may -# obtain a copy of this license in the LICENSE.txt file in the root directory -# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. -# -# Any modifications or derivative works of this code must retain this -# copyright notice, and modified files need to carry a notice indicating -# that they have been altered from the originals. - -"""Client for accessing an individual IBM Q Experience account.""" - -import asyncio - -from ..rest import Api -from ..session import RetrySession - -from .base import BaseClient -from .websocket import WebsocketClient - - -class AccountClient(BaseClient): - """Client for accessing an individual IBM Q Experience account. - - This client provides access to an individual IBM Q hub/group/project. - """ - - def __init__(self, access_token, project_url, websockets_url, **request_kwargs): - """AccountClient constructor. - - Args: - access_token (str): IBM Q Experience access token. - project_url (str): IBM Q Experience URL for a specific h/g/p. - websockets_url (str): URL for the websockets server. - **request_kwargs (dict): arguments for the `requests` Session. - """ - self.client_api = Api(RetrySession(project_url, access_token, - **request_kwargs)) - self.client_ws = WebsocketClient(websockets_url, access_token) - - # Backend-related public functions. - - def list_backends(self): - """Return the list of backends. - - Returns: - list[dict]: a list of backends. - """ - return self.client_api.backends() - - def backend_status(self, backend_name): - """Return the status of a backend. - - Args: - backend_name (str): the name of the backend. - - Returns: - dict: backend status. - """ - return self.client_api.backend(backend_name).status() - - def backend_properties(self, backend_name): - """Return the properties of a backend. - - Args: - backend_name (str): the name of the backend. - - Returns: - dict: backend properties. - """ - return self.client_api.backend(backend_name).properties() - - def backend_pulse_defaults(self, backend_name): - """Return the pulse defaults of a backend. - - Args: - backend_name (str): the name of the backend. - - Returns: - dict: backend pulse defaults. - """ - return self.client_api.backend(backend_name).pulse_defaults() - - # Jobs-related public functions. - - def list_jobs_statuses(self, limit=10, skip=0, extra_filter=None): - """Return a list of statuses of jobs, with filtering and pagination. - - Args: - limit (int): maximum number of items to return. - skip (int): offset for the items to return. - extra_filter (dict): additional filtering passed to the query. - - Returns: - list[dict]: a list of job statuses. - """ - return self.client_api.jobs(limit=limit, skip=skip, - extra_filter=extra_filter) - - def job_submit(self, backend_name, qobj_dict): - """Submit a Qobj to a device. - - Args: - backend_name (str): the name of the backend. - qobj_dict (dict): the Qobj to be executed, as a dictionary. - - Returns: - dict: job status. - """ - return self.client_api.submit_job(backend_name, qobj_dict) - - def job_submit_object_storage(self, backend_name, qobj_dict): - """Submit a Qobj to a device using object storage. - - Args: - backend_name (str): the name of the backend. - qobj_dict (dict): the Qobj to be executed, as a dictionary. - - Returns: - dict: job status. - """ - # Get the job via object storage. - job_info = self.client_api.submit_job_object_storage(backend_name) - - # Get the upload URL. - job_id = job_info['id'] - job_api = self.client_api.job(job_id) - upload_url = job_api.upload_url()['url'] - - # Upload the Qobj to object storage. - _ = job_api.put_object_storage(upload_url, qobj_dict) - - # Notify the API via the callback. - response = job_api.callback_upload() - - return response['job'] - - def job_download_qobj_object_storage(self, job_id): - """Retrieve and return a Qobj using object storage. - - Args: - job_id (str): the id of the job. - - Returns: - dict: Qobj, in dict form. - """ - job_api = self.client_api.job(job_id) - - # Get the download URL. - download_url = job_api.download_url()['url'] - - # Download the result from object storage. - return job_api.get_object_storage(download_url) - - def job_result_object_storage(self, job_id): - """Retrieve and return a result using object storage. - - Args: - job_id (str): the id of the job. - - Returns: - dict: job information. - """ - job_api = self.client_api.job(job_id) - - # Get the download URL. - download_url = job_api.result_url()['url'] - - # Download the result from object storage. - return job_api.get_object_storage(download_url) - - def job_get(self, job_id, excluded_fields=None, included_fields=None): - """Return information about a job. - - Args: - job_id (str): the id of the job. - excluded_fields (list[str]): names of the fields to explicitly - exclude from the result. - included_fields (list[str]): names of the fields, if present, to explicitly - include in the result. All the other fields will not be included in the result. - - Returns: - dict: job information. - """ - return self.client_api.job(job_id).get(excluded_fields, - included_fields) - - def job_status(self, job_id): - """Return the status of a job. - - Args: - job_id (str): the id of the job. - - Returns: - dict: job status. - """ - return self.client_api.job(job_id).status() - - def job_final_status_websocket(self, job_id, timeout=None): - """Return the final status of a job via websocket. - - Args: - job_id (str): the id of the job. - timeout (float or None): seconds to wait for job. If None, wait - indefinitely. - - Returns: - dict: job status. - - Raises: - RuntimeError: if an unexpected error occurred while getting the event loop. - """ - # As mentioned in `websocket.py`, in jupyter we need to use - # `nest_asyncio` to allow nested event loops. - try: - loop = asyncio.get_event_loop() - except RuntimeError as ex: - # Event loop may not be set in a child thread. - if 'There is no current event loop' in str(ex): - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - else: - raise - return loop.run_until_complete( - self.client_ws.get_job_status(job_id, timeout=timeout)) - - def job_properties(self, job_id): - """Return the backend properties of a job. - - Args: - job_id (str): the id of the job. - - Returns: - dict: backend properties. - """ - return self.client_api.job(job_id).properties() - - def job_cancel(self, job_id): - """Submit a request for cancelling a job. - - Args: - job_id (str): the id of the job. - - Returns: - dict: job cancellation response. - """ - return self.client_api.job(job_id).cancel() - - # Circuits-related public functions. - - def circuit_run(self, name, **kwargs): - """Execute a Circuit. - - Args: - name (str): name of the Circuit. - **kwargs (dict): arguments for the Circuit. - - Returns: - dict: json response. - """ - return self.client_api.circuit(name, **kwargs) - - def circuit_job_get(self, job_id): - """Return information about a Circuit job. - - Args: - job_id (str): the id of the job. - - Returns: - dict: job information. - """ - return self.client_api.job(job_id).get([], []) - - def circuit_job_status(self, job_id): - """Return the status of a Circuits job. - - Args: - job_id (str): the id of the job. - - Returns: - dict: job status. - """ - return self.job_status(job_id) - - # Endpoints for compatibility with classic IBMQConnector. These functions - # are meant to facilitate the transition, and should be removed moving - # forward. - - def get_status_job(self, id_job): - # pylint: disable=missing-docstring - return self.job_status(id_job) - - def submit_job(self, qobj_dict, backend_name): - # pylint: disable=missing-docstring - return self.job_submit(backend_name, qobj_dict) - - def get_jobs(self, limit=10, skip=0, backend=None, only_completed=False, - filter=None): - # pylint: disable=missing-docstring,redefined-builtin - # TODO: this function seems to be unused currently in IBMQConnector. - raise NotImplementedError - - def get_status_jobs(self, limit=10, skip=0, backend=None, filter=None): - # pylint: disable=missing-docstring,redefined-builtin - if backend: - filter = filter or {} - filter.update({'backend.name': backend}) - - return self.list_jobs_statuses(limit, skip, filter) - - def cancel_job(self, id_job): - # pylint: disable=missing-docstring - return self.job_cancel(id_job) - - def backend_defaults(self, backend): - # pylint: disable=missing-docstring - return self.backend_pulse_defaults(backend) - - def available_backends(self): - # pylint: disable=missing-docstring - return self.list_backends() - - def get_job(self, id_job, exclude_fields=None, include_fields=None): - # pylint: disable=missing-docstring - return self.job_get(id_job, exclude_fields, include_fields) diff --git a/qiskit/providers/ibmq/api_v2/clients/websocket.py b/qiskit/providers/ibmq/api_v2/clients/websocket.py deleted file mode 100644 index 52095beac..000000000 --- a/qiskit/providers/ibmq/api_v2/clients/websocket.py +++ /dev/null @@ -1,215 +0,0 @@ -# -*- coding: utf-8 -*- - -# This code is part of Qiskit. -# -# (C) Copyright IBM 2018, 2019. -# -# This code is licensed under the Apache License, Version 2.0. You may -# obtain a copy of this license in the LICENSE.txt file in the root directory -# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. -# -# Any modifications or derivative works of this code must retain this -# copyright notice, and modified files need to carry a notice indicating -# that they have been altered from the originals. - -"""Client for websocket communication with the IBM Q Experience API.""" - -import asyncio -import json -import logging -import time -from concurrent import futures -import warnings - -import nest_asyncio -from websockets import connect, ConnectionClosed - -from qiskit.providers.ibmq.apiconstants import ApiJobStatus, API_JOB_FINAL_STATES -from ..exceptions import (WebsocketError, WebsocketTimeoutError, - WebsocketIBMQProtocolError, - WebsocketAuthenticationError) - -from .base import BaseClient - - -logger = logging.getLogger(__name__) - -# `asyncio` by design does not allow event loops to be nested. Jupyter (really -# tornado) has its own event loop already so we need to patch it. -# Patch asyncio to allow nested use of `loop.run_until_complete()`. -nest_asyncio.apply() - - -class WebsocketMessage: - """Container for a message sent or received via websockets. - - Attributes: - type_ (str): message type. - data (dict): message data. - """ - def __init__(self, type_, data=None): - self.type_ = type_ - self.data = data - - def as_json(self): - """Return a json representation of the message.""" - parsed_dict = {'type': self.type_} - if self.data: - parsed_dict['data'] = self.data - return json.dumps(parsed_dict) - - @classmethod - def from_bytes(cls, json_string): - """Instantiate a message from a bytes response.""" - try: - parsed_dict = json.loads(json_string.decode('utf8')) - except (ValueError, AttributeError) as ex: - raise WebsocketIBMQProtocolError('Unable to parse message') from ex - - return cls(parsed_dict['type'], parsed_dict.get('data', None)) - - -class WebsocketClient(BaseClient): - """Client for websocket communication with the IBM Q Experience API. - - Attributes: - websocket_url (str): URL for websocket communication with IBM Q. - access_token (str): access token for IBM Q. - """ - - def __init__(self, websocket_url, access_token): - self.websocket_url = websocket_url.rstrip('/') - self.access_token = access_token - - @asyncio.coroutine - def _connect(self, url): - """Authenticate against the websocket server, returning the connection. - - Returns: - Connect: an open websocket connection. - - Raises: - WebsocketError: if the connection to the websocket server could - not be established. - WebsocketAuthenticationError: if the connection to the websocket - was established, but the authentication failed. - WebsocketIBMQProtocolError: if the connection to the websocket - server was established, but the answer was unexpected. - """ - try: - logger.debug('Starting new websocket connection: %s', url) - with warnings.catch_warnings(): - # Suppress websockets deprecation warnings until the fix is available - warnings.filterwarnings("ignore", category=DeprecationWarning) - websocket = yield from connect(url) - - # pylint: disable=broad-except - except Exception as ex: - raise WebsocketError('Could not connect to server') from ex - - try: - # Authenticate against the server. - auth_request = self._authentication_message() - with warnings.catch_warnings(): - # Suppress websockets deprecation warnings until the fix is available - warnings.filterwarnings("ignore", category=DeprecationWarning) - yield from websocket.send(auth_request.as_json()) - - # Verify that the server acknowledged our authentication. - auth_response_raw = yield from websocket.recv() - - auth_response = WebsocketMessage.from_bytes(auth_response_raw) - - if auth_response.type_ != 'authenticated': - raise WebsocketIBMQProtocolError(auth_response.as_json()) - except ConnectionClosed as ex: - yield from websocket.close() - raise WebsocketAuthenticationError( - 'Error during websocket authentication') from ex - - return websocket - - @asyncio.coroutine - def get_job_status(self, job_id, timeout=None): - """Return the status of a job. - - Reads status messages from the API, which are issued at regular - intervals (20 seconds). When a final state is reached, the server - closes the socket. - - Args: - job_id (str): id of the job. - timeout (int): timeout, in seconds. - - Returns: - dict: the API response for the status of a job, as a dict that - contains at least the keys ``status`` and ``id``. - - Raises: - WebsocketError: if the websocket connection ended unexpectedly. - WebsocketTimeoutError: if the timeout has been reached. - """ - url = '{}/jobs/{}/status'.format(self.websocket_url, job_id) - websocket = yield from self._connect(url) - - original_timeout = timeout - start_time = time.time() - last_status = None - - try: - # Read messages from the server until the connection is closed or - # a timeout has been reached. - while True: - try: - with warnings.catch_warnings(): - # Suppress websockets deprecation warnings until the fix is available - warnings.filterwarnings("ignore", category=DeprecationWarning) - if timeout: - response_raw = yield from asyncio.wait_for( - websocket.recv(), timeout=timeout) - - # Decrease the timeout, with a 5-second grace period. - elapsed_time = time.time() - start_time - timeout = max(5, int(original_timeout - elapsed_time)) - else: - response_raw = yield from websocket.recv() - logger.debug('Received message from websocket: %s', - response_raw) - - response = WebsocketMessage.from_bytes(response_raw) - last_status = response.data - - job_status = response.data.get('status') - if (job_status and - ApiJobStatus(job_status) in API_JOB_FINAL_STATES): - break - - except futures.TimeoutError: - # Timeout during our wait. - raise WebsocketTimeoutError('Timeout reached') from None - except ConnectionClosed as ex: - # From the API: - # 4001: closed due to an internal errors - # 4002: closed on purpose (no more updates to send) - # 4003: closed due to job not found. - message = 'Unexpected error' - if ex.code == 4001: - message = 'Internal server error' - elif ex.code == 4002: - break - elif ex.code == 4003: - message = 'Job id not found' - raise WebsocketError('Connection with websocket closed ' - 'unexpectedly: {}'.format(message)) from ex - finally: - with warnings.catch_warnings(): - # Suppress websockets deprecation warnings until the fix is available - warnings.filterwarnings("ignore", category=DeprecationWarning) - yield from websocket.close() - - return last_status - - def _authentication_message(self): - """Return the message used for authenticating against the server.""" - return WebsocketMessage(type_='authentication', - data=self.access_token) diff --git a/qiskit/providers/ibmq/api_v2/exceptions.py b/qiskit/providers/ibmq/api_v2/exceptions.py deleted file mode 100644 index 1e0ba881b..000000000 --- a/qiskit/providers/ibmq/api_v2/exceptions.py +++ /dev/null @@ -1,54 +0,0 @@ -# -*- coding: utf-8 -*- - -# This code is part of Qiskit. -# -# (C) Copyright IBM 2018, 2019. -# -# This code is licensed under the Apache License, Version 2.0. You may -# obtain a copy of this license in the LICENSE.txt file in the root directory -# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. -# -# Any modifications or derivative works of this code must retain this -# copyright notice, and modified files need to carry a notice indicating -# that they have been altered from the originals. - -"""Exceptions related to the IBM Q Experience API.""" - -from ..api import ApiError as ApiErrorV1 - - -class ApiError(ApiErrorV1): - """Generic IBM Q API error.""" - pass - - -class RequestsApiError(ApiError): - """Exception re-raising a RequestException.""" - def __init__(self, original_exception, *args, **kwargs): - self.original_exception = original_exception - super().__init__(*args, **kwargs) - - -class WebsocketError(ApiError): - """Exceptions related to websockets.""" - pass - - -class WebsocketIBMQProtocolError(WebsocketError): - """Exceptions related to IBM Q protocol error.""" - pass - - -class WebsocketAuthenticationError(WebsocketError): - """Exception caused during websocket authentication.""" - pass - - -class WebsocketTimeoutError(WebsocketError): - """Timeout during websocket communication.""" - pass - - -class AuthenticationLicenseError(ApiError): - """Exception due to user not accepting latest license agreement via web.""" - pass diff --git a/qiskit/providers/ibmq/apiconstants.py b/qiskit/providers/ibmq/apiconstants.py index 3578b67c2..e8b9fe472 100644 --- a/qiskit/providers/ibmq/apiconstants.py +++ b/qiskit/providers/ibmq/apiconstants.py @@ -54,3 +54,12 @@ class ApiJobKind(enum.Enum): QOBJECT = 'q-object' QOBJECT_STORAGE = 'q-object-external-storage' CIRCUIT = 'q-circuit' + + +class ApiJobShareLevel(enum.Enum): + """Possible values used by the API for job share levels.""" + GLOBAL = 'global' + HUB = 'hub' + GROUP = 'group' + PROJECT = 'project' + NONE = 'none' diff --git a/qiskit/providers/ibmq/circuits/exceptions.py b/qiskit/providers/ibmq/circuits/exceptions.py deleted file mode 100644 index 376fb6ad6..000000000 --- a/qiskit/providers/ibmq/circuits/exceptions.py +++ /dev/null @@ -1,48 +0,0 @@ -# -*- coding: utf-8 -*- - -# This code is part of Qiskit. -# -# (C) Copyright IBM 2018, 2019. -# -# This code is licensed under the Apache License, Version 2.0. You may -# obtain a copy of this license in the LICENSE.txt file in the root directory -# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. -# -# Any modifications or derivative works of this code must retain this -# copyright notice, and modified files need to carry a notice indicating -# that they have been altered from the originals. - -"""Exceptions related to Circuits.""" - -from ..exceptions import IBMQError - - -CIRCUIT_NOT_ALLOWED = 'Circuit support is not available yet in this account' -CIRCUIT_SUBMIT_ERROR = 'Circuit could not be submitted: {}' -CIRCUIT_RESULT_ERROR = 'Circuit result could not be returned: {}' - - -class CircuitError(IBMQError): - """Generic Circuit exception.""" - pass - - -class CircuitAvailabilityError(CircuitError): - """Error while accessing a Circuit.""" - - def __init__(self, message=''): - super().__init__(message or CIRCUIT_NOT_ALLOWED) - - -class CircuitSubmitError(CircuitError): - """Error while submitting a Circuit.""" - - def __init__(self, message): - super().__init__(CIRCUIT_SUBMIT_ERROR.format(message)) - - -class CircuitResultError(CircuitError): - """Error during the results of a Circuit.""" - - def __init__(self, message): - super().__init__(CIRCUIT_RESULT_ERROR.format(message)) diff --git a/qiskit/providers/ibmq/circuits/manager.py b/qiskit/providers/ibmq/circuits/manager.py deleted file mode 100644 index 21792fb77..000000000 --- a/qiskit/providers/ibmq/circuits/manager.py +++ /dev/null @@ -1,186 +0,0 @@ -# -*- coding: utf-8 -*- - -# This code is part of Qiskit. -# -# (C) Copyright IBM 2018, 2019. -# -# This code is licensed under the Apache License, Version 2.0. You may -# obtain a copy of this license in the LICENSE.txt file in the root directory -# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. -# -# Any modifications or derivative works of this code must retain this -# copyright notice, and modified files need to carry a notice indicating -# that they have been altered from the originals. - -"""Manager for interacting with Circuits.""" - -from qiskit.providers import JobStatus - -from ..api_v2.exceptions import RequestsApiError -from ..job.circuitjob import CircuitJob -from .exceptions import (CircuitError, - CircuitAvailabilityError, CircuitResultError, - CircuitSubmitError) - - -GRAPH_STATE = 'graph_state' -HARDWARE_EFFICIENT = 'hardware_efficient' -RANDOM_UNIFORM = 'random_uniform' - - -class CircuitsManager: - """Class that provides access to the different Circuits.""" - - def __init__(self, client): - self.client = client - - def _call_circuit(self, name, **kwargs): - """Execute a Circuit. - - Args: - name (str): name of the Circuit. - **kwargs: parameters passed to the Circuit. - - Returns: - Result: the result of executing the circuit. - - Raises: - CircuitAvailabilityError: if Circuits are not available. - CircuitSubmitError: if there was an error submitting the Circuit. - CircuitResultError: if the result of the Circuit could not be - returned. - """ - try: - response = self.client.circuit_run(name=name, **kwargs) - except RequestsApiError as ex: - # Revise the original requests exception to intercept. - error_response = ex.original_exception.response - - # Check for errors related to the submission. - try: - body = error_response.json() - except ValueError: - body = {} - - # Generic authorization or unavailable endpoint error. - if error_response.status_code in (401, 404): - raise CircuitAvailabilityError() from None - - if error_response.status_code == 400: - # Hub permission error. - if body.get('error', {}).get('code') == 'HUB_NOT_FOUND': - raise CircuitAvailabilityError() from None - - # Generic error. - if body.get('error', {}).get('code') == 'GENERIC_ERROR': - raise CircuitAvailabilityError() from None - - # Handle the rest of the exceptions as unexpected. - raise CircuitSubmitError(str(ex)) - except Exception as ex: - # Handle non-requests exception as unexpected. - raise CircuitSubmitError(str(ex)) - - # Create a Job for the circuit. - try: - job = CircuitJob(backend=None, - job_id=response['id'], - api=self.client, - creation_date=response['creationDate'], - api_status=response['status'], - use_websockets=True) - except Exception as ex: - raise CircuitResultError(str(ex)) - - # Wait for the job to complete, explicitly checking for errors. - job._wait_for_completion() - if job.status() is JobStatus.ERROR: - raise CircuitResultError( - 'Job {} finished with an error'.format(job.job_id())) - - return job.result() - - def graph_state(self, number_of_qubits, adjacency_matrix, angles): - """Execute the graph state Circuit. - - This circuit implements graph state circuits that are measured in a - product basis. Measurement angles can be chosen to measure graph state - stabilizers (for validation/characterization) or to measure in a basis - such that the circuit family may be hard to classically simulate. - - Args: - number_of_qubits (int): number of qubits to use, in the 2-20 range. - adjacency_matrix (list[list]): square matrix of elements whose - values are 0 or 1. The matrix size is `number_of_qubits` by - `number_of_qubits` and is expected to be symmetric and have - zeros on the diagonal. - angles (list[float]): list of phase angles, each in the interval - `[0, 2*pi)` radians. There should be 3 * number_of_qubits - elements in the array. The first three elements are the - theta, phi, and lambda angles, respectively, of a u3 gate - acting on the first qubit. Each of the number_of_qubits triples - is interpreted accordingly as the parameters of a u3 gate - acting on subsequent qubits. - - Returns: - Result: the result of executing the circuit. - - Raises: - CircuitError: if the parameters are not valid. - """ - if not 2 <= number_of_qubits <= 20: - raise CircuitError('Invalid number_of_qubits') - if len(angles) != number_of_qubits*3: - raise CircuitError('Invalid angles length') - - return self._call_circuit(name=GRAPH_STATE, - number_of_qubits=number_of_qubits, - adjacency_matrix=adjacency_matrix, - angles=angles) - - def hardware_efficient(self, number_of_qubits, angles): - """Execute the hardware efficient Circuit. - - This circuit implements the random lattice circuit across a user - specified number of qubits and phase angles. - - Args: - number_of_qubits (int): number of qubits to use, in the 2-20 range. - angles (list): array of three phase angles (x/y/z) each from - 0 to 2*Pi, one set for each qubit of each layer of the lattice. - There should be 3 * number_of_qubits * desired lattice depth - entries in the array. - - Returns: - Result: the result of executing the circuit. - - Raises: - CircuitError: if the parameters are not valid. - """ - if not 2 <= number_of_qubits <= 20: - raise CircuitError('Invalid number_of_qubits') - if len(angles) % 3*number_of_qubits != 0: - raise CircuitError('Invalid angles length') - - return self._call_circuit(name=HARDWARE_EFFICIENT, - number_of_qubits=number_of_qubits, - angles=angles) - - def random_uniform(self, number_of_qubits=None): - """Execute the random uniform Circuit. - - This circuit implements hadamard gates across all available qubits on - the device. - - Args: - number_of_qubits (int) : optional argument for number of qubits to - use. If not specified will use all qubits on device. - - Returns: - Result: the result of executing the circuit. - """ - kwargs = {} - if number_of_qubits is not None: - kwargs['number_of_qubits'] = number_of_qubits - - return self._call_circuit(name=RANDOM_UNIFORM, **kwargs) diff --git a/qiskit/providers/ibmq/credentials/__init__.py b/qiskit/providers/ibmq/credentials/__init__.py index 3aa2bb85f..d4d615f13 100644 --- a/qiskit/providers/ibmq/credentials/__init__.py +++ b/qiskit/providers/ibmq/credentials/__init__.py @@ -15,9 +15,10 @@ """Utilities for working with credentials for the IBMQ package.""" from collections import OrderedDict +from typing import Dict, Optional import logging -from .credentials import Credentials +from .credentials import Credentials, HubGroupProject from .exceptions import CredentialsError from .configrc import read_credentials_from_qiskitrc, store_credentials from .environ import read_credentials_from_environ @@ -26,7 +27,9 @@ logger = logging.getLogger(__name__) -def discover_credentials(qiskitrc_filename=None): +def discover_credentials( + qiskitrc_filename: Optional[str] = None +) -> Dict[HubGroupProject, Credentials]: """Automatically discover credentials for IBM Q. This method looks for credentials in the following locations, in order, @@ -37,16 +40,16 @@ def discover_credentials(qiskitrc_filename=None): 3. in the `qiskitrc` configuration file Args: - qiskitrc_filename (str): location for the `qiskitrc` configuration + qiskitrc_filename: location for the `qiskitrc` configuration file. If `None`, defaults to `{HOME}/.qiskitrc/qiskitrc`. Returns: - dict: dictionary with the contents of the configuration file, with + dictionary with the contents of the configuration file, with the form:: {credentials_unique_id: Credentials} """ - credentials = OrderedDict() + credentials = OrderedDict() # type: ignore[var-annotated] # dict[str:function] that defines the different locations for looking for # credentials, and their precedence order. @@ -60,7 +63,7 @@ def discover_credentials(qiskitrc_filename=None): # Attempt to read the credentials from the different sources. for display_name, (reader_function, kwargs) in readers.items(): try: - credentials = reader_function(**kwargs) + credentials = reader_function(**kwargs) # type: ignore[arg-type] logger.info('Using credentials from %s', display_name) if credentials: break diff --git a/qiskit/providers/ibmq/credentials/configrc.py b/qiskit/providers/ibmq/credentials/configrc.py index 03ba2fe15..0b3e86914 100644 --- a/qiskit/providers/ibmq/credentials/configrc.py +++ b/qiskit/providers/ibmq/credentials/configrc.py @@ -14,28 +14,33 @@ """Utilities for reading and writing credentials from and to config files.""" -import warnings +import logging import os from ast import literal_eval from collections import OrderedDict from configparser import ConfigParser, ParsingError +from typing import Dict, Optional, Any -from .credentials import Credentials +from .credentials import Credentials, HubGroupProject from .exceptions import CredentialsError +logger = logging.getLogger(__name__) + DEFAULT_QISKITRC_FILE = os.path.join(os.path.expanduser("~"), '.qiskit', 'qiskitrc') -def read_credentials_from_qiskitrc(filename=None): +def read_credentials_from_qiskitrc( + filename: Optional[str] = None +) -> Dict[HubGroupProject, Credentials]: """Read a configuration file and return a dict with its sections. Args: - filename (str): full path to the qiskitrc file. If `None`, the default + filename: full path to the qiskitrc file. If `None`, the default location is used (`HOME/.qiskit/qiskitrc`). Returns: - dict: dictionary with the contents of the configuration file, with + dictionary with the contents of the configuration file, with the form:: {credential_unique_id: Credentials} @@ -53,7 +58,7 @@ def read_credentials_from_qiskitrc(filename=None): raise CredentialsError(str(ex)) # Build the credentials dictionary. - credentials_dict = OrderedDict() + credentials_dict = OrderedDict() # type: ignore[var-annotated] for name in config_parser.sections(): single_credentials = dict(config_parser.items(name)) # Individually convert keys to their right types. @@ -63,30 +68,34 @@ def read_credentials_from_qiskitrc(filename=None): single_credentials['proxies'] = literal_eval( single_credentials['proxies']) if 'verify' in single_credentials.keys(): - single_credentials['verify'] = bool(single_credentials['verify']) - new_credentials = Credentials(**single_credentials) + single_credentials['verify'] = bool( # type: ignore[assignment] + single_credentials['verify']) + new_credentials = Credentials(**single_credentials) # type: ignore[arg-type] credentials_dict[new_credentials.unique_id()] = new_credentials return credentials_dict -def write_qiskit_rc(credentials, filename=None): +def write_qiskit_rc( + credentials: Dict[HubGroupProject, Credentials], + filename: Optional[str] = None +) -> None: """Write credentials to the configuration file. Args: - credentials (dict): dictionary with the credentials, with the form:: + credentials: dictionary with the credentials, with the form:: {credentials_unique_id: Credentials} - filename (str): full path to the qiskitrc file. If `None`, the default + filename: full path to the qiskitrc file. If `None`, the default location is used (`HOME/.qiskit/qiskitrc`). """ - def _credentials_object_to_dict(obj): + def _credentials_object_to_dict(obj: Credentials) -> Dict[str, Any]: return {key: getattr(obj, key) for key in ['token', 'url', 'proxies', 'verify'] if getattr(obj, key)} - def _section_name(credentials_): + def _section_name(credentials_: Credentials) -> str: """Return a string suitable for use as a unique section name.""" base_name = 'ibmq' if credentials_.is_ibmq(): @@ -111,13 +120,17 @@ def _section_name(credentials_): config_parser.write(config_file) -def store_credentials(credentials, overwrite=False, filename=None): +def store_credentials( + credentials: Credentials, + overwrite: bool = False, + filename: Optional[str] = None +) -> None: """Store the credentials for a single account in the configuration file. Args: - credentials (Credentials): credentials instance. - overwrite (bool): overwrite existing credentials. - filename (str): full path to the qiskitrc file. If `None`, the default + credentials: credentials instance. + overwrite: overwrite existing credentials. + filename: full path to the qiskitrc file. If `None`, the default location is used (`HOME/.qiskit/qiskitrc`). """ # Read the current providers stored in the configuration file. @@ -127,8 +140,8 @@ def store_credentials(credentials, overwrite=False, filename=None): # Check if duplicated credentials are already stored. By convention, # we assume (hub, group, project) is always unique. if credentials.unique_id() in stored_credentials and not overwrite: - warnings.warn('Credentials already present. ' - 'Set overwrite=True to overwrite.') + logger.warning('Credentials already present. ' + 'Set overwrite=True to overwrite.') return # Append and write the credentials to file. @@ -136,12 +149,15 @@ def store_credentials(credentials, overwrite=False, filename=None): write_qiskit_rc(stored_credentials, filename) -def remove_credentials(credentials, filename=None): +def remove_credentials( + credentials: Credentials, + filename: Optional[str] = None +) -> None: """Remove credentials from qiskitrc. Args: - credentials (Credentials): credentials. - filename (str): full path to the qiskitrc file. If `None`, the default + credentials: credentials. + filename: full path to the qiskitrc file. If `None`, the default location is used (`HOME/.qiskit/qiskitrc`). Raises: diff --git a/qiskit/providers/ibmq/credentials/credentials.py b/qiskit/providers/ibmq/credentials/credentials.py index 4c263cd6c..cc6882472 100644 --- a/qiskit/providers/ibmq/credentials/credentials.py +++ b/qiskit/providers/ibmq/credentials/credentials.py @@ -16,6 +16,7 @@ import re +from typing import Dict, Tuple, Optional, Any from requests_ntlm import HttpNtlmAuth from .hubgroupproject import HubGroupProject @@ -39,20 +40,28 @@ class Credentials: The `unique_id()` returns the unique identifier. """ - def __init__(self, token, url, websockets_url=None, - hub=None, group=None, project=None, - proxies=None, verify=True): + def __init__( + self, + token: str, + url: str, + websockets_url: Optional[str] = None, + hub: Optional[str] = None, + group: Optional[str] = None, + project: Optional[str] = None, + proxies: Optional[Dict] = None, + verify: bool = True + ) -> None: """Return new set of credentials. Args: - token (str): Quantum Experience or IBMQ API token. - url (str): URL for Quantum Experience or IBMQ. - websockets_url (str): URL for websocket server. - hub (str): the hub used for IBMQ. - group (str): the group used for IBMQ. - project (str): the project used for IBMQ. - proxies (dict): proxy configuration for the API. - verify (bool): if False, ignores SSL certificates errors + token: Quantum Experience or IBMQ API token. + url: URL for Quantum Experience or IBMQ. + websockets_url: URL for websocket server. + hub: the hub used for IBMQ. + group: the group used for IBMQ. + project: the project used for IBMQ. + proxies: proxy configuration for the API. + verify: if False, ignores SSL certificates errors Note: `hub`, `group` and `project` are stored as attributes for @@ -69,28 +78,28 @@ def __init__(self, token, url, websockets_url=None, self.proxies = proxies or {} self.verify = verify - def is_ibmq(self): + def is_ibmq(self) -> bool: """Return whether the credentials represent a IBMQ account.""" return all([self.hub, self.group, self.project]) - def __eq__(self, other): + def __eq__(self, other: object) -> bool: return self.__dict__ == other.__dict__ - def unique_id(self): + def unique_id(self) -> HubGroupProject: """Return a value that uniquely identifies these credentials. By convention, we assume (hub, group, project) is always unique. Returns: - HubGroupProject: the (hub, group, project) tuple. + the (hub, group, project) tuple. """ return HubGroupProject(self.hub, self.group, self.project) - def connection_parameters(self): + def connection_parameters(self) -> Dict[str, Any]: """Return a dict of kwargs in the format expected by `requests`. Returns: - dict: a dict with connection-related arguments in the format + a dict with connection-related arguments in the format expected by `requests`. The following keys can be present: `proxies`, `verify`, `auth`. """ @@ -111,23 +120,28 @@ def connection_parameters(self): return request_kwargs -def _unify_ibmq_url(url, hub=None, group=None, project=None): +def _unify_ibmq_url( + url: str, + hub: Optional[str] = None, + group: Optional[str] = None, + project: Optional[str] = None +) -> Tuple[str, str, Optional[str], Optional[str], Optional[str]]: """Return a new-style set of credential values (url and hub parameters). Args: - url (str): URL for Quantum Experience or IBM Q. - hub (str): the hub used for IBM Q. - group (str): the group used for IBM Q. - project (str): the project used for IBM Q. + url: URL for Quantum Experience or IBM Q. + hub: the hub used for IBM Q. + group: the group used for IBM Q. + project: the project used for IBM Q. Returns: tuple[url, base_url, hub, group, token]: - * url (str): new-style Quantum Experience or IBM Q URL (the hub, + * url: new-style Quantum Experience or IBM Q URL (the hub, group and project included in the URL). - * base_url (str): base URL for the API, without hub/group/project. - * hub (str): the hub used for IBM Q. - * group (str): the group used for IBM Q. - * project (str): the project used for IBM Q. + * base_url: base URL for the API, without hub/group/project. + * hub: the hub used for IBM Q. + * group: the group used for IBM Q. + * project: the project used for IBM Q. """ # Check if the URL is "new style", and retrieve embedded parameters from it. regex_match = re.match(REGEX_IBMQ_HUBS, url, re.IGNORECASE) diff --git a/qiskit/providers/ibmq/credentials/environ.py b/qiskit/providers/ibmq/credentials/environ.py index 9173a6a37..049db8389 100644 --- a/qiskit/providers/ibmq/credentials/environ.py +++ b/qiskit/providers/ibmq/credentials/environ.py @@ -16,8 +16,9 @@ import os from collections import OrderedDict +from typing import Dict -from .credentials import Credentials +from .credentials import Credentials, HubGroupProject # Dictionary that maps `ENV_VARIABLE_NAME` to credential parameter. VARIABLES_MAP = { @@ -29,11 +30,11 @@ } -def read_credentials_from_environ(): +def read_credentials_from_environ() -> Dict[HubGroupProject, Credentials]: """Read the environment variables and return its credentials. Returns: - dict: dictionary with the credentials, in the form:: + dictionary with the credentials, in the form:: {credentials_unique_id: Credentials} """ @@ -42,10 +43,10 @@ def read_credentials_from_environ(): return OrderedDict() # Build the credentials based on environment variables. - credentials = {} + credentials_dict = {} for envar_name, credential_key in VARIABLES_MAP.items(): if os.getenv(envar_name): - credentials[credential_key] = os.getenv(envar_name) + credentials_dict[credential_key] = os.getenv(envar_name) - credentials = Credentials(**credentials) + credentials = Credentials(**credentials_dict) # type: ignore[arg-type] return OrderedDict({credentials.unique_id(): credentials}) diff --git a/qiskit/providers/ibmq/credentials/qconfig.py b/qiskit/providers/ibmq/credentials/qconfig.py index c2dc5fea5..d72fee2ea 100644 --- a/qiskit/providers/ibmq/credentials/qconfig.py +++ b/qiskit/providers/ibmq/credentials/qconfig.py @@ -16,20 +16,21 @@ import os from collections import OrderedDict +from typing import Dict from importlib.util import module_from_spec, spec_from_file_location -from .credentials import Credentials +from .credentials import Credentials, HubGroupProject from .exceptions import CredentialsError DEFAULT_QCONFIG_FILE = 'Qconfig.py' QE_URL = 'https://quantumexperience.ng.bluemix.net/api' -def read_credentials_from_qconfig(): +def read_credentials_from_qconfig() -> Dict[HubGroupProject, Credentials]: """Read a `QConfig.py` file and return its credentials. Returns: - dict: dictionary with the credentials, in the form:: + dictionary with the credentials, in the form:: {credentials_unique_id: Credentials} @@ -53,13 +54,13 @@ def read_credentials_from_qconfig(): try: spec = spec_from_file_location('Qconfig', DEFAULT_QCONFIG_FILE) q_config = module_from_spec(spec) - spec.loader.exec_module(q_config) + spec.loader.exec_module(q_config) # type: ignore[attr-defined] if hasattr(q_config, 'config'): - credentials = q_config.config.copy() + credentials = q_config.config.copy() # type: ignore[attr-defined] else: credentials = {} - credentials['token'] = q_config.APItoken + credentials['token'] = q_config.APItoken # type: ignore[attr-defined] credentials['url'] = credentials.get('url', QE_URL) except Exception as ex: # pylint: disable=broad-except raise CredentialsError('Error loading Qconfig.py: %s' % str(ex)) diff --git a/qiskit/providers/ibmq/credentials/updater.py b/qiskit/providers/ibmq/credentials/updater.py index 190136ff7..9ab6f026c 100644 --- a/qiskit/providers/ibmq/credentials/updater.py +++ b/qiskit/providers/ibmq/credentials/updater.py @@ -14,6 +14,7 @@ """Helper for updating credentials from IBM Q Experience v1 to v2.""" +from typing import Optional from .credentials import Credentials from .configrc import (read_credentials_from_qiskitrc, remove_credentials, @@ -29,7 +30,7 @@ QE2_AUTH_URL = 'https://auth.quantum-computing.ibm.com/api' -def update_credentials(force=False): +def update_credentials(force: bool = False) -> Optional[Credentials]: """Update or provide information about updating stored credentials. This function is an interactive helper to update credentials stored in @@ -45,11 +46,11 @@ def update_credentials(force=False): the configuration from the instructions at the IBM Q Experience site. Args: - force (bool): if `True`, disable interactive prompts and perform the + force: if `True`, disable interactive prompts and perform the changes. Returns: - Credentials: if the updating is possible, credentials for IBM Q + if the updating is possible, credentials for IBM Q Experience version 2; and `None` otherwise. """ # Get the list of stored credentials. @@ -146,7 +147,7 @@ def update_credentials(force=False): return final_credentials -def is_directly_updatable(credentials): +def is_directly_updatable(credentials: Credentials) -> bool: """Returns `True` if credentials can be updated directly.""" if credentials.base_url == QE_URL: return True diff --git a/qiskit/providers/ibmq/exceptions.py b/qiskit/providers/ibmq/exceptions.py index a6d4f89b6..5f75ab081 100644 --- a/qiskit/providers/ibmq/exceptions.py +++ b/qiskit/providers/ibmq/exceptions.py @@ -2,7 +2,7 @@ # This code is part of Qiskit. # -# (C) Copyright IBM 2018. +# (C) Copyright IBM 2018, 2019. # # This code is licensed under the Apache License, Version 2.0. You may # obtain a copy of this license in the LICENSE.txt file in the root directory @@ -40,6 +40,6 @@ class IBMQBackendError(IBMQError): pass -class IBMQBackendValueError(IBMQError, ValueError): +class IBMQBackendValueError(IBMQBackendError, ValueError): """Value errors thrown within IBMQBackend.""" pass diff --git a/qiskit/providers/ibmq/ibmqbackend.py b/qiskit/providers/ibmq/ibmqbackend.py index 8cc37ffe3..26d7be86a 100644 --- a/qiskit/providers/ibmq/ibmqbackend.py +++ b/qiskit/providers/ibmq/ibmqbackend.py @@ -2,7 +2,7 @@ # This code is part of Qiskit. # -# (C) Copyright IBM 2017, 2018. +# (C) Copyright IBM 2017, 2019. # # This code is licensed under the Apache License, Version 2.0. You may # obtain a copy of this license in the LICENSE.txt file in the root directory @@ -17,15 +17,22 @@ import logging import warnings +from typing import Dict, List, Union, Optional, Any +from datetime import datetime as python_datetime from marshmallow import ValidationError -from qiskit.providers import BaseBackend, JobStatus +from qiskit.qobj import Qobj, validate_qobj_against_schema +from qiskit.providers import BaseBackend, JobStatus # type: ignore[attr-defined] from qiskit.providers.models import (BackendStatus, BackendProperties, - PulseDefaults) - -from .api import ApiError -from .api_v2.clients import BaseClient -from .apiconstants import ApiJobStatus, ApiJobKind + PulseDefaults, BackendConfiguration, GateConfig) +from qiskit.validation.exceptions import ModelValidationError +from qiskit.tools.events.pubsub import Publisher +from qiskit.providers.ibmq import accountprovider # pylint: disable=unused-import +from qiskit.providers.ibmq.apiconstants import ApiJobShareLevel + +from .api.clients import AccountClient +from .api.exceptions import ApiError +from .credentials import Credentials from .exceptions import IBMQBackendError, IBMQBackendValueError from .job import IBMQJob from .utils import update_qobj_config @@ -36,15 +43,20 @@ class IBMQBackend(BaseBackend): """Backend class interfacing with an IBMQ backend.""" - def __init__(self, configuration, provider, credentials, api): + def __init__( + self, + configuration: BackendConfiguration, + provider: 'accountprovider.AccountProvider', + credentials: Credentials, + api: AccountClient + ) -> None: """Initialize remote backend for IBM Quantum Experience. Args: - configuration (BackendConfiguration): configuration of backend. - provider (IBMQProvider): provider. - credentials (Credentials): credentials. - api (IBMQConnector): - api for communicating with the Quantum Experience. + configuration: configuration of backend. + provider: provider. + credentials: credentials. + api: api for communicating with the Quantum Experience. """ super().__init__(provider=provider, configuration=configuration) @@ -58,52 +70,143 @@ def __init__(self, configuration, provider, credentials, api): self._properties = None self._defaults = None - def run(self, qobj): + def run( + self, + qobj: Qobj, + job_name: Optional[str] = None, + job_share_level: Optional[str] = None + ) -> IBMQJob: """Run a Qobj asynchronously. Args: - qobj (Qobj): description of job + qobj: description of job. + job_name: custom name to be assigned to the job. This job + name can subsequently be used as a filter in the + ``jobs()`` function call. Job names do not need to be unique. + job_share_level: allows sharing a job at the hub/group/project and + global level. The possible job share levels are: "global", "hub", + "group", "project", and "none". + * global: the job is public to any user. + * hub: the job is shared between the users in the same hub. + * group: the job is shared between the users in the same group. + * project: the job is shared between the users in the same project. + * none: the job is not shared at any level. + If the job share level is not specified, then the job is not shared at any level. Returns: - IBMQJob: an instance derived from BaseJob + an instance derived from BaseJob + + Raises: + SchemaValidationError: If the job validation fails. + IBMQBackendError: If an unexpected error occurred while submitting + the job. + IBMQBackendValueError: If the specified job share level is not valid. """ # pylint: disable=arguments-differ - kwargs = {} - if isinstance(self._api, BaseClient): - # Default to using object storage and websockets for new API. - kwargs = {'use_object_storage': True, - 'use_websockets': True} - if self._credentials.proxies: - # Disable using websockets through proxies. - kwargs['use_websockets'] = False - - job = IBMQJob(self, None, self._api, qobj=qobj, **kwargs) - job.submit() + api_job_share_level = None + if job_share_level: + try: + api_job_share_level = ApiJobShareLevel(job_share_level) + except ValueError: + raise IBMQBackendValueError( + '"{}" is not a valid job share level. ' + 'Valid job share levels are: {}' + .format(job_share_level, ', '.join(level.value for level in ApiJobShareLevel))) + + validate_qobj_against_schema(qobj) + return self._submit_job(qobj, job_name, api_job_share_level) + + def _submit_job( + self, + qobj: Qobj, + job_name: Optional[str] = None, + job_share_level: Optional[ApiJobShareLevel] = None + ) -> IBMQJob: + """Submit qobj job to IBM-Q. + Args: + qobj: description of job. + job_name: custom name to be assigned to the job. This job + name can subsequently be used as a filter in the + ``jobs()`` function call. Job names do not need to be unique. + job_share_level: level the job should be shared at. + + Returns: + an instance derived from BaseJob + Events: + ibmq.job.start: The job has started. + + Raises: + IBMQBackendError: If an unexpected error occurred while submitting + the job. + """ + try: + qobj_dict = qobj.to_dict() + submit_info = self._api.job_submit( + backend_name=self.name(), + qobj_dict=qobj_dict, + use_object_storage=getattr(self.configuration(), 'allow_object_storage', False), + job_name=job_name, + job_share_level=job_share_level) + except ApiError as ex: + raise IBMQBackendError('Error submitting job: {}'.format(str(ex))) + + # Error in the job after submission: + # Transition to the `ERROR` final state. + if 'error' in submit_info: + raise IBMQBackendError('Error submitting job: {}'.format(str(submit_info['error']))) + + # Submission success. + submit_info.update({ + '_backend': self, + 'api': self._api, + 'qObject': qobj_dict + }) + try: + job = IBMQJob.from_dict(submit_info) + except ModelValidationError as err: + raise IBMQBackendError('Unexpected return value from the server when ' + 'submitting job: {}'.format(str(err))) + Publisher().publish("ibmq.job.start", job) return job - def properties(self, refresh=False): - """Return the online backend properties. + def properties( + self, + refresh: bool = False, + datetime: Optional[python_datetime] = None + ) -> Optional[BackendProperties]: + """Return the online backend properties with optional filtering. Args: - refresh (bool): if True, the return is via a QX API call. + refresh: if True, the return is via a QX API call. Otherwise, a cached version is returned. + datetime: by specifying a datetime, + this function returns an instance of the BackendProperties whose + timestamp is closest to, but older than, the specified datetime. Returns: - BackendProperties: The properties of the backend. + The properties of the backend. If the backend has no properties to + display, it returns ``None``. """ # pylint: disable=arguments-differ + if datetime: + # Do not use cache for specific datetime properties. + api_properties = self._api.backend_properties(self.name(), datetime=datetime) + if not api_properties: + return None + return BackendProperties.from_dict(api_properties) + if refresh or self._properties is None: api_properties = self._api.backend_properties(self.name()) self._properties = BackendProperties.from_dict(api_properties) return self._properties - def status(self): + def status(self) -> BackendStatus: """Return the online backend status. Returns: - BackendStatus: The status of the backend. + The status of the backend. Raises: LookupError: If status for the backend can't be found. @@ -117,22 +220,22 @@ def status(self): raise LookupError( "Couldn't get backend status: {0}".format(ex)) - def defaults(self, refresh=False): + def defaults(self, refresh: bool = False) -> Optional[PulseDefaults]: """Return the pulse defaults for the backend. Args: - refresh (bool): if True, the return is via a QX API call. + refresh: if True, the return is via a QX API call. Otherwise, a cached version is returned. Returns: - PulseDefaults: the pulse defaults for the backend. If the backend - does not support defaults, it returns ``None``. + the pulse defaults for the backend. If the backend does not support + defaults, it returns ``None``. """ if not self.configuration().open_pulse: return None if refresh or self._defaults is None: - api_defaults = self._api.backend_defaults(self.name()) + api_defaults = self._api.backend_pulse_defaults(self.name()) if api_defaults: self._defaults = PulseDefaults.from_dict(api_defaults) else: @@ -140,7 +243,14 @@ def defaults(self, refresh=False): return self._defaults - def jobs(self, limit=10, skip=0, status=None, db_filter=None): + def jobs( + self, + limit: int = 10, + skip: int = 0, + status: Optional[Union[JobStatus, str]] = None, + job_name: Optional[str] = None, + db_filter: Optional[Dict[str, Any]] = None + ) -> List[IBMQJob]: """Return the jobs submitted to this backend. Return the jobs submitted to this backend, with optional filtering and @@ -154,12 +264,16 @@ def jobs(self, limit=10, skip=0, status=None, db_filter=None): in the returned list. Args: - limit (int): number of jobs to retrieve. - skip (int): starting index for the job retrieval. - status (None or qiskit.providers.JobStatus or str): only get jobs + limit: number of jobs to retrieve. + skip: starting index for the job retrieval. + status: only get jobs with this status, where status is e.g. `JobStatus.RUNNING` or `'RUNNING'` - db_filter (dict): `loopback-based filter + job_name: filter by job name. The `job_name` is matched partially + and `regular expressions + + `_ can be used. + db_filter: `loopback-based filter `_. This is an interface to a database ``where`` filter. Some examples of its usage are: @@ -183,149 +297,41 @@ def jobs(self, limit=10, skip=0, status=None, db_filter=None): job_list = backend.jobs(limit=5, db_filter=date_filter) Returns: - list(IBMQJob): list of IBMQJob instances + list of IBMQJob instances Raises: IBMQBackendValueError: status keyword value unrecognized """ - # Build the filter for the query. - backend_name = self.name() - api_filter = {'backend.name': backend_name} - if status: - if isinstance(status, str): - status = JobStatus[status] - if status == JobStatus.RUNNING: - this_filter = {'status': ApiJobStatus.RUNNING.value, - 'infoQueue': {'exists': False}} - elif status == JobStatus.QUEUED: - this_filter = {'status': ApiJobStatus.RUNNING.value, - 'infoQueue.status': 'PENDING_IN_QUEUE'} - elif status == JobStatus.CANCELLED: - this_filter = {'status': ApiJobStatus.CANCELLED.value} - elif status == JobStatus.DONE: - this_filter = {'status': ApiJobStatus.COMPLETED.value} - elif status == JobStatus.ERROR: - this_filter = {'status': {'regexp': '^ERROR'}} - else: - raise IBMQBackendValueError('unrecognized value for "status" keyword ' - 'in job filter') - api_filter.update(this_filter) - if db_filter: - # status takes precedence over db_filter for same keys - api_filter = {**db_filter, **api_filter} - - # Retrieve the requested number of jobs, using pagination. The API - # might limit the number of jobs per request. - job_responses = [] - current_page_limit = limit - - while True: - job_page = self._api.get_status_jobs(limit=current_page_limit, - skip=skip, filter=api_filter) - job_responses += job_page - skip = skip + len(job_page) - - if not job_page: - # Stop if there are no more jobs returned by the API. - break - - if limit: - if len(job_responses) >= limit: - # Stop if we have reached the limit. - break - current_page_limit = limit - len(job_responses) - else: - current_page_limit = 0 + return self._provider.backends.jobs( + limit, skip, self.name(), status, job_name, db_filter) - job_list = [] - for job_info in job_responses: - kwargs = {} - try: - job_kind = ApiJobKind(job_info.get('kind', None)) - except ValueError: - # Discard pre-qobj jobs. - break - - if isinstance(self._api, BaseClient): - # Default to using websockets for new API. - kwargs['use_websockets'] = True - if self._credentials.proxies: - # Disable using websockets through proxies. - kwargs['use_websockets'] = False - if job_kind == ApiJobKind.QOBJECT_STORAGE: - kwargs['use_object_storage'] = True - - job = IBMQJob(self, job_info.get('id'), self._api, - creation_date=job_info.get('creationDate'), - api_status=job_info.get('status'), - **kwargs) - job_list.append(job) - - return job_list - - def retrieve_job(self, job_id): + def retrieve_job(self, job_id: str) -> IBMQJob: """Return a job submitted to this backend. Args: - job_id (str): the job id of the job to retrieve + job_id: the job id of the job to retrieve Returns: - IBMQJob: class instance + class instance Raises: IBMQBackendError: if retrieval failed """ - try: - job_info = self._api.get_job(job_id) - - # Check for generic errors. - if 'error' in job_info: - raise IBMQBackendError('Failed to get job "{}": {}' - .format(job_id, job_info['error'])) - - # Check for jobs from a different backend. - job_backend_name = job_info['backend']['name'] - if job_backend_name != self.name(): - warnings.warn('Job "{}" belongs to another backend than the one queried. ' - 'The query was made on backend "{}", ' - 'but the job actually belongs to backend "{}".' - .format(job_id, self.name(), job_backend_name)) - raise IBMQBackendError('Failed to get job "{}": ' - 'job does not belong to backend "{}".' - .format(job_id, self.name())) - - # Check for pre-qobj jobs. - kwargs = {} - try: - job_kind = ApiJobKind(job_info.get('kind', None)) - - if isinstance(self._api, BaseClient): - # Default to using websockets for new API. - kwargs['use_websockets'] = True - if self._credentials.proxies: - # Disable using websockets through proxies. - kwargs['use_websockets'] = False - if job_kind == ApiJobKind.QOBJECT_STORAGE: - kwargs['use_object_storage'] = True - - except ValueError: - warnings.warn('The result of job {} is in a no longer supported format. ' - 'Please send the job using Qiskit 0.8+.'.format(job_id), - DeprecationWarning) - raise IBMQBackendError('Failed to get job "{}": {}' - .format(job_id, 'job in pre-qobj format')) - except ApiError as ex: - raise IBMQBackendError('Failed to get job "{}": {}' - .format(job_id, str(ex))) + job = self._provider.backends.retrieve_job(job_id) + job_backend = job.backend() - job = IBMQJob(self, job_info.get('id'), self._api, - creation_date=job_info.get('creationDate'), - api_status=job_info.get('status'), - **kwargs) + if self.name() != job_backend.name(): + warnings.warn('Job "{}" belongs to another backend than the one queried. ' + 'The query was made on backend "{}", ' + 'but the job actually belongs to backend "{}".' + .format(job_id, self.name(), job_backend.name())) + raise IBMQBackendError('Failed to get job "{}": ' + 'job does not belong to backend "{}".' + .format(job_id, self.name())) - return job + return self._provider.backends.retrieve_job(job_id) - def __repr__(self): + def __repr__(self) -> str: credentials_info = '' if self.hub: credentials_info = "hub='{}', group='{}', project='{}'".format( @@ -337,7 +343,11 @@ def __repr__(self): class IBMQSimulator(IBMQBackend): """Backend class interfacing with an IBMQ simulator.""" - def properties(self, refresh=False): + def properties( + self, + refresh: bool = False, + datetime: Optional[python_datetime] = None + ) -> None: """Return the online backend properties. Returns: @@ -345,17 +355,104 @@ def properties(self, refresh=False): """ return None - def run(self, qobj, backend_options=None, noise_model=None): + def run( + self, + qobj: Qobj, + job_name: Optional[str] = None, + job_share_level: Optional[str] = None, + backend_options: Optional[Dict] = None, + noise_model: Any = None, + ) -> IBMQJob: """Run qobj asynchronously. Args: - qobj (Qobj): description of job - backend_options (dict): backend options - noise_model (NoiseModel): noise model + qobj: description of job + backend_options: backend options + noise_model: noise model + job_name: custom name to be assigned to the job + job_share_level: allows sharing a job at the hub/group/project and + global level (see `IBMQBackend.run()` for more details). Returns: - IBMQJob: an instance derived from BaseJob + an instance derived from BaseJob """ # pylint: disable=arguments-differ qobj = update_qobj_config(qobj, backend_options, noise_model) - return super(IBMQSimulator, self).run(qobj) + return super(IBMQSimulator, self).run(qobj, job_name, job_share_level) + + +class IBMQRetiredBackend(IBMQBackend): + """Backend class interfacing with an IBMQ device that is no longer available.""" + + def __init__( + self, + configuration: BackendConfiguration, + provider: 'accountprovider.AccountProvider', + credentials: Credentials, + api: AccountClient + ) -> None: + """Initialize remote backend for IBM Quantum Experience. + + Args: + configuration: configuration of backend. + provider: provider. + credentials: credentials. + api: api for communicating with the Quantum Experience. + """ + super().__init__(configuration, provider, credentials, api) + self._status = BackendStatus( + backend_name=self.name(), + backend_version=self.configuration().backend_version, + operational=False, + pending_jobs=0, + status_msg='This backend is no longer available.') + + def properties( + self, + refresh: bool = False, + datetime: Optional[python_datetime] = None + ) -> None: + """Return the online backend properties.""" + return None + + def defaults(self, refresh: bool = False) -> None: + """Return the pulse defaults for the backend.""" + return None + + def status(self) -> BackendStatus: + """Return the online backend status.""" + return self._status + + def run( + self, + qobj: Qobj, + job_name: Optional[str] = None, + job_share_level: Optional[str] = None + ) -> None: + """Run a Qobj.""" + raise IBMQBackendError('This backend is no longer available.') + + @classmethod + def from_name( + cls, + backend_name: str, + provider: 'accountprovider.AccountProvider', + credentials: Credentials, + api: AccountClient + ) -> 'IBMQRetiredBackend': + """Return a retired backend from its name.""" + configuration = BackendConfiguration( + backend_name=backend_name, + backend_version='0.0.0', + n_qubits=1, + basis_gates=[], + simulator=False, + local=False, + conditional=False, + open_pulse=False, + memory=False, + max_shots=1, + gates=[GateConfig(name='TODO', parameters=[], qasm_def='TODO')], + coupling_map=[[0, 1]], + ) + return cls(configuration, provider, credentials, api) diff --git a/qiskit/providers/ibmq/ibmqbackendservice.py b/qiskit/providers/ibmq/ibmqbackendservice.py new file mode 100644 index 000000000..12ee9ee4d --- /dev/null +++ b/qiskit/providers/ibmq/ibmqbackendservice.py @@ -0,0 +1,294 @@ +# -*- coding: utf-8 -*- + +# This code is part of Qiskit. +# +# (C) Copyright IBM 2019. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Backend namespace for an IBM Quantum Experience account provider.""" + +import logging +from typing import Dict, List, Callable, Optional, Any, Union +from types import SimpleNamespace + +from qiskit.providers import JobStatus, QiskitBackendNotFoundError # type: ignore[attr-defined] +from qiskit.providers.providerutils import filter_backends +from qiskit.validation.exceptions import ModelValidationError +from qiskit.providers.ibmq import accountprovider # pylint: disable=unused-import + +from .api.exceptions import ApiError +from .apiconstants import ApiJobStatus +from .exceptions import IBMQBackendError, IBMQBackendValueError +from .ibmqbackend import IBMQBackend, IBMQRetiredBackend +from .job import IBMQJob +from .utils import to_python_identifier + +logger = logging.getLogger(__name__) + + +class IBMQBackendService(SimpleNamespace): + """Backend namespace for an IBM Quantum Experience account provider.""" + + def __init__(self, provider: 'accountprovider.AccountProvider') -> None: + """Creates a new IBMQBackendService instance. + + Args: + provider: IBM Q Experience account provider + """ + super().__init__() + + self._provider = provider + self._discover_backends() + + def _discover_backends(self) -> None: + """Discovers the remote backends if not already known.""" + for backend in self._provider._backends.values(): + backend_name = to_python_identifier(backend.name()) + + # Append _ if duplicate + while backend_name in self.__dict__: + backend_name += '_' + + setattr(self, backend_name, backend) + + def __call__( + self, + name: Optional[str] = None, + filters: Optional[Callable[[List[IBMQBackend]], bool]] = None, + timeout: Optional[float] = None, + **kwargs: Any + ) -> List[IBMQBackend]: + """Return all backends accessible via this provider, subject to optional filtering. + + Args: + name: backend name to filter by + filters: more complex filters, such as lambda functions + e.g. AccountProvider.backends( + filters=lambda b: b.configuration['n_qubits'] > 5) + timeout: number of seconds to wait for backend discovery. + kwargs: simple filters specifying a true/false criteria in the + backend configuration or backend status or provider credentials + e.g. AccountProvider.backends(n_qubits=5, operational=True) + + Returns: + list of backends available that match the filter + """ + backends = self._provider._backends.values() + + # Special handling of the `name` parameter, to support alias + # resolution. + if name: + aliases = self._aliased_backend_names() + aliases.update(self._deprecated_backend_names()) + name = aliases.get(name, name) + kwargs['backend_name'] = name + + return filter_backends(backends, filters=filters, **kwargs) + + def jobs( + self, + limit: int = 10, + skip: int = 0, + backend_name: Optional[str] = None, + status: Optional[Union[JobStatus, str]] = None, + job_name: Optional[str] = None, + db_filter: Optional[Dict[str, Any]] = None + ) -> List[IBMQJob]: + """Return a list of jobs from the API. + + Return a list of jobs, with optional filtering and pagination. Note + that the API has a limit for the number of jobs returned in a single + call, and this function might involve making several calls to the API. + See also the `skip` parameter for more control over pagination. + + Note that jobs submitted with earlier versions of Qiskit + (in particular, those that predate the Qobj format) are not included + in the returned list. + + Args: + limit: number of jobs to retrieve. + skip: starting index for the job retrieval. + backend_name: name of the backend. + status: only get jobs with this status, where status is e.g. + `JobStatus.RUNNING` or `'RUNNING'` + job_name: filter by job name. The `job_name` is matched partially + and `regular expressions + + `_ can be used. + db_filter: `loopback-based filter + `_. + This is an interface to a database ``where`` filter. Some + examples of its usage are: + + Filter last five jobs with errors:: + + job_list = backend.jobs(limit=5, status=JobStatus.ERROR) + + Filter last five jobs with counts=1024, and counts for + states ``00`` and ``11`` each exceeding 400:: + + cnts_filter = {'shots': 1024, + 'qasms.result.data.counts.00': {'gt': 400}, + 'qasms.result.data.counts.11': {'gt': 400}} + job_list = backend.jobs(limit=5, db_filter=cnts_filter) + + Filter last five jobs from 30 days ago:: + + past_date = datetime.datetime.now() - datetime.timedelta(days=30) + date_filter = {'creationDate': {'lt': past_date.isoformat()}} + job_list = backend.jobs(limit=5, db_filter=date_filter) + + Returns: + list of IBMQJob instances + + Raises: + IBMQBackendValueError: status keyword value unrecognized + """ + # Build the filter for the query. + api_filter = {} # type: Dict[str, Any] + + if backend_name: + api_filter['backend.name'] = backend_name + + if status: + if isinstance(status, str): + status = JobStatus[status] + if status == JobStatus.RUNNING: + this_filter = {'status': ApiJobStatus.RUNNING.value, + 'infoQueue': {'exists': False}} + elif status == JobStatus.QUEUED: + this_filter = {'status': ApiJobStatus.RUNNING.value, + 'infoQueue.status': 'PENDING_IN_QUEUE'} + elif status == JobStatus.CANCELLED: + this_filter = {'status': ApiJobStatus.CANCELLED.value} + elif status == JobStatus.DONE: + this_filter = {'status': ApiJobStatus.COMPLETED.value} + elif status == JobStatus.ERROR: + this_filter = {'status': {'regexp': '^ERROR'}} + else: + raise IBMQBackendValueError('unrecognized value for "status" keyword ' + 'in job filter') + api_filter.update(this_filter) + + if job_name: + api_filter['name'] = {"regexp": job_name} + + if db_filter: + # status takes precedence over db_filter for same keys + api_filter = {**db_filter, **api_filter} + + # Retrieve the requested number of jobs, using pagination. The API + # might limit the number of jobs per request. + job_responses = [] # type: List[Dict[str, Any]] + current_page_limit = limit + + while True: + job_page = self._provider._api.list_jobs_statuses( + limit=current_page_limit, skip=skip, extra_filter=api_filter) + job_responses += job_page + skip = skip + len(job_page) + + if not job_page: + # Stop if there are no more jobs returned by the API. + break + + if limit: + if len(job_responses) >= limit: + # Stop if we have reached the limit. + break + current_page_limit = limit - len(job_responses) + else: + current_page_limit = 0 + + job_list = [] + for job_info in job_responses: + job_id = job_info.get('id', "") + # Recreate the backend used for this job. + backend_name = job_info.get('backend', {}).get('name', 'unknown') + try: + backend = self._provider.get_backend(backend_name) + except QiskitBackendNotFoundError: + backend = IBMQRetiredBackend.from_name(backend_name, + self._provider, + self._provider.credentials, + self._provider._api) + + job_info.update({ + '_backend': backend, + 'api': self._provider._api, + }) + try: + job = IBMQJob.from_dict(job_info) + except ModelValidationError: + logger.warning('Discarding job "%s" because it contains invalid data.', job_id) + continue + + job_list.append(job) + + return job_list + + def retrieve_job(self, job_id: str) -> IBMQJob: + """Return a single job from the API. + + Args: + job_id: the job id of the job to retrieve + + Returns: + class instance + + Raises: + IBMQBackendError: if retrieval failed + """ + try: + job_info = self._provider._api.job_get(job_id) + except ApiError as ex: + raise IBMQBackendError('Failed to get job "{}": {}' + .format(job_id, str(ex))) + + # Recreate the backend used for this job. + backend_name = job_info.get('backend', {}).get('name', 'unknown') + try: + backend = self._provider.get_backend(backend_name) + except QiskitBackendNotFoundError: + backend = IBMQRetiredBackend.from_name(backend_name, + self._provider, + self._provider.credentials, + self._provider._api) + + job_info.update({ + '_backend': backend, + 'api': self._provider._api + }) + try: + job = IBMQJob.from_dict(job_info) + except ModelValidationError as ex: + raise IBMQBackendError('Failed to get job "{}". Invalid job data received: {}' + .format(job_id, str(ex))) + + return job + + @staticmethod + def _deprecated_backend_names() -> Dict[str, str]: + """Returns deprecated backend names.""" + return { + 'ibmqx_qasm_simulator': 'ibmq_qasm_simulator', + 'ibmqx_hpc_qasm_simulator': 'ibmq_qasm_simulator', + 'real': 'ibmqx1' + } + + @staticmethod + def _aliased_backend_names() -> Dict[str, str]: + """Returns aliased backend names.""" + return { + 'ibmq_5_yorktown': 'ibmqx2', + 'ibmq_5_tenerife': 'ibmqx4', + 'ibmq_16_rueschlikon': 'ibmqx5', + 'ibmq_20_austin': 'QS1_1' + } diff --git a/qiskit/providers/ibmq/ibmqfactory.py b/qiskit/providers/ibmq/ibmqfactory.py index e8a72c0a3..23314ddb3 100644 --- a/qiskit/providers/ibmq/ibmqfactory.py +++ b/qiskit/providers/ibmq/ibmqfactory.py @@ -15,141 +15,111 @@ """Factory and credentials manager for IBM Q Experience.""" import logging -import warnings +from typing import Dict, List, Union, Optional, Any from collections import OrderedDict -from qiskit.providers.exceptions import QiskitBackendNotFoundError - from .accountprovider import AccountProvider -from .api_v2.clients import AuthClient, VersionClient -from .credentials import Credentials, discover_credentials +from .api.clients import AuthClient, VersionClient +from .credentials import Credentials, HubGroupProject, discover_credentials from .credentials.configrc import (read_credentials_from_qiskitrc, remove_credentials, store_credentials) from .credentials.updater import update_credentials from .exceptions import IBMQAccountError, IBMQApiUrlError, IBMQProviderError -from .ibmqprovider import IBMQProvider -from .utils.deprecation import deprecated, UPDATE_ACCOUNT_TEXT - logger = logging.getLogger(__name__) QX_AUTH_URL = 'https://auth.quantum-computing.ibm.com/api' +UPDATE_ACCOUNT_TEXT = "Please update your accounts and programs by following the " \ + "instructions here: https://github.com/Qiskit/qiskit-ibmq-provider#" \ + "updating-to-the-new-ibm-q-experience " class IBMQFactory: """Factory and credentials manager for IBM Q Experience.""" - def __init__(self): - self._credentials = None - self._providers = OrderedDict() - self._v1_provider = IBMQProvider() + def __init__(self) -> None: + self._credentials = None # type: Optional[Credentials] + self._providers = OrderedDict() # type: Dict[HubGroupProject, AccountProvider] # Account management functions. - def enable_account(self, token, url=QX_AUTH_URL, **kwargs): + def enable_account( + self, + token: str, + url: str = QX_AUTH_URL, + **kwargs: Any + ) -> Optional[AccountProvider]: """Authenticate against IBM Q Experience for use during this session. - Note: with version 0.3 of this qiskit-ibmq-provider package, use of + Note: with version 0.4 of this qiskit-ibmq-provider package, use of the legacy Quantum Experience and Qconsole (also known - as the IBM Q Experience v1) credentials is deprecated. The new - default is to use the IBM Q Experience v2 credentials. + as the IBM Q Experience v1) credentials is fully deprecated. Args: - token (str): IBM Q Experience API token. - url (str): URL for the IBM Q Experience authentication server. - **kwargs (dict): additional settings for the connection: + token: IBM Q Experience API token. + url: URL for the IBM Q Experience authentication server. + **kwargs: additional settings for the connection: * proxies (dict): proxy configuration. * verify (bool): verify the server's TLS certificate. Returns: - AccountProvider: the provider for the default open access project. + the provider for the default open access project. Raises: - IBMQAccountError: if an IBM Q Experience v2 account is already in - use, or if attempting using both IBM Q Experience v1 and v2 - accounts. - IBMQApiUrlError: if the input token and url are for an IBM Q - Experience v2 account, but the url is not a valid + IBMQAccountError: if an IBM Q Experience account is already in + use. + IBMQApiUrlError: if the URL is not a valid IBM Q Experience authentication URL. """ - # Check if an IBM Q Experience 2 account is already in use. + # Check if an IBM Q Experience account is already in use. if self._credentials: - raise IBMQAccountError('An IBM Q Experience v2 account is already ' + raise IBMQAccountError('An IBM Q Experience account is already ' 'enabled.') # Check the version used by these credentials. credentials = Credentials(token, url, **kwargs) version_info = self._check_api_version(credentials) - # For API 1, delegate onto the IBMQProvider. - if not version_info['new_api']: - warnings.warn( - 'Using IBM Q Experience v1 credentials is being deprecated. ' - 'Please use IBM Q Experience v2 credentials instead. ' - 'You can find the instructions to make the updates here:\n' - 'https://github.com/Qiskit/qiskit-ibmq-provider#' - 'updating-to-the-new-ibm-q-experience', - DeprecationWarning) - self._v1_provider.enable_account(token, url, **kwargs) - return self._v1_provider - - # Prevent using credentials not from the auth server. - if 'api-auth' not in version_info: + # Check the URL is a valid authentication URL. + if not version_info['new_api'] or 'api-auth' not in version_info: raise IBMQApiUrlError( 'The URL specified ({}) is not an IBM Q Experience ' 'authentication URL'.format(credentials.url)) - # Prevent mixing API 1 and API 2 credentials. - if self._v1_provider.active_accounts(): - raise IBMQAccountError('An IBM Q Experience v1 account is ' - 'already enabled.') - - # Initialize the API 2 providers. + # Initialize the providers. self._initialize_providers(credentials) # Prevent edge case where no hubs are available. providers = self.providers() if not providers: - warnings.warn('No Hub/Group/Projects could be found for this ' - 'account.') + logger.warning('No Hub/Group/Projects could be found for this ' + 'account.') return None return providers[0] - def disable_account(self): + def disable_account(self) -> None: """Disable the account in the current session. Raises: - IBMQAccountError: if IBM Q Experience API v1 credentials are found, - or if no account is in use in the session. + IBMQAccountError: if no account is in use in the session. """ - if self._v1_provider.active_accounts(): - raise IBMQAccountError( - 'IBM Q Experience v1 accounts are enabled. Please use ' - 'IBMQ.disable_accounts() to disable them.') - if not self._credentials: raise IBMQAccountError('No account is in use for this session.') self._credentials = None self._providers = OrderedDict() - def load_account(self): + def load_account(self) -> Optional[AccountProvider]: """Authenticate against IBM Q Experience from stored credentials. Returns: - AccountProvider: the provider for the default open access project. + the provider for the default open access project. Raises: - IBMQAccountError: if an IBM Q Experience v1 account is already in - use, or no IBM Q Experience v2 accounts can be found. + IBMQAccountError: if no IBM Q Experience credentials can be found. """ - # Prevent mixing API 1 and API 2 credentials. - if self._v1_provider.active_accounts(): - raise IBMQAccountError('An IBM Q Experience v1 account is ' - 'already enabled.') - # Check for valid credentials. credentials_list = list(discover_credentials().values()) @@ -158,28 +128,24 @@ def load_account(self): 'No IBM Q Experience credentials found on disk.') if len(credentials_list) > 1: - raise IBMQAccountError('Multiple IBM Q Experience credentials ' - 'found. ' + UPDATE_ACCOUNT_TEXT) + raise IBMQAccountError('Multiple IBM Q Experience credentials found. ' + + UPDATE_ACCOUNT_TEXT) credentials = credentials_list[0] - # Explicitly check via an API call, to allow environment auth URLs. + # Explicitly check via an API call, to allow environment auth URLs # contain API 2 URL (but not auth) slipping through. version_info = self._check_api_version(credentials) - # For API 1, delegate onto the IBMQProvider. - if not version_info['new_api']: - raise IBMQAccountError('IBM Q Experience v1 credentials found. ' + + # Check the URL is a valid authentication URL. + if not version_info['new_api'] or 'api-auth' not in version_info: + raise IBMQAccountError('Invalid IBM Q Experience credentials found. ' + UPDATE_ACCOUNT_TEXT) - if 'api-auth' not in version_info: - raise IBMQAccountError('Invalid IBM Q Experience v2 credentials ' - 'found. ' + UPDATE_ACCOUNT_TEXT) - - # Initialize the API 2 providers. + # Initialize the providers. if self._credentials: # For convention, emit a warning instead of raising. - warnings.warn('Credentials are already in use. The existing ' - 'account in the session will be replaced.') + logger.warning('Credentials are already in use. The existing ' + 'account in the session will be replaced.') self.disable_account() self._initialize_providers(credentials) @@ -187,72 +153,75 @@ def load_account(self): # Prevent edge case where no hubs are available. providers = self.providers() if not providers: - warnings.warn('No Hub/Group/Projects could be found for this ' - 'account.') + logger.warning('No Hub/Group/Projects could be found for this ' + 'account.') return None return providers[0] @staticmethod - def save_account(token, url=QX_AUTH_URL, overwrite=False, **kwargs): + def save_account( + token: str, + url: str = QX_AUTH_URL, + overwrite: bool = False, + **kwargs: Any + ) -> None: """Save the account to disk for future use. - Note: IBM Q Experience v1 credentials are being deprecated. Please - use IBM Q Experience v2 credentials instead. - Args: - token (str): IBM Q Experience API token. - url (str): URL for the IBM Q Experience authentication server. - overwrite (bool): overwrite existing credentials. - **kwargs (dict): + token: IBM Q Experience API token. + url: URL for the IBM Q Experience authentication server. + overwrite: overwrite existing credentials. + **kwargs: * proxies (dict): Proxy configuration for the API. * verify (bool): If False, ignores SSL certificates errors + + Raises: + IBMQApiUrlError: if the URL is not a valid IBM Q Experience + authentication URL. """ if url != QX_AUTH_URL: - warnings.warn( - 'IBM Q Experience v1 credentials are being deprecated. Please ' - 'use IBM Q Experience v2 credentials instead. ' - 'You can find the instructions to make the updates here:\n' - 'https://github.com/Qiskit/qiskit-ibmq-provider#' - 'updating-to-the-new-ibm-q-experience', - DeprecationWarning) + raise IBMQApiUrlError( + 'Invalid IBM Q Experience credentials found. ' + UPDATE_ACCOUNT_TEXT) + + if not token or not isinstance(token, str): + raise IBMQApiUrlError('Invalid token found: "%s" %s' % (token, type(token))) credentials = Credentials(token, url, **kwargs) store_credentials(credentials, overwrite=overwrite) @staticmethod - def delete_account(): + def delete_account() -> None: """Delete the saved account from disk. Raises: - IBMQAccountError: if no valid IBM Q Experience v2 credentials found. + IBMQAccountError: if no valid IBM Q Experience credentials found. """ stored_credentials = read_credentials_from_qiskitrc() if not stored_credentials: raise IBMQAccountError('No credentials found.') if len(stored_credentials) != 1: - raise IBMQAccountError('Multiple credentials found. ' + - UPDATE_ACCOUNT_TEXT) + raise IBMQAccountError('Multiple credentials found. ' + UPDATE_ACCOUNT_TEXT) credentials = list(stored_credentials.values())[0] if credentials.url != QX_AUTH_URL: - raise IBMQAccountError('IBM Q Experience v1 credentials found. ' + - UPDATE_ACCOUNT_TEXT) + raise IBMQAccountError( + 'Invalid IBM Q Experience credentials found. ' + UPDATE_ACCOUNT_TEXT) remove_credentials(credentials) @staticmethod - def stored_account(): + def stored_account() -> Dict[str, str]: """List the account stored on disk. Returns: - dict: dictionary with information about the account stored on disk. + dictionary with information about the account stored on disk. Raises: - IBMQAccountError: if no valid IBM Q Experience v2 credentials found. + IBMQAccountError: if no valid IBM Q Experience credentials found. """ stored_credentials = read_credentials_from_qiskitrc() if not stored_credentials: @@ -260,8 +229,8 @@ def stored_account(): if (len(stored_credentials) > 1 or list(stored_credentials.values())[0].url != QX_AUTH_URL): - raise IBMQAccountError('IBM Q Experience v1 credentials found. ' + - UPDATE_ACCOUNT_TEXT) + raise IBMQAccountError( + 'Invalid IBM Q Experience credentials found. ' + UPDATE_ACCOUNT_TEXT) credentials = list(stored_credentials.values())[0] return { @@ -269,24 +238,15 @@ def stored_account(): 'url': credentials.url } - def active_account(self): - """List the IBM Q Experience v2 account currently in the session. + def active_account(self) -> Optional[Dict[str, str]]: + """List the IBM Q Experience account currently in the session. Returns: - dict: information about the account currently in the session. - - Raises: - IBMQAccountError: if an IBM Q Experience v1 account is already in - use. + information about the account currently in the session. """ - if self._v1_provider.active_accounts(): - raise IBMQAccountError( - 'IBM Q Experience v1 accounts are enabled. Please use ' - 'IBMQ.active_accounts() to retrieve information about them.') - if not self._credentials: - # Return None instead of raising, for compatibility with the - # previous active_accounts() behavior. + # Return None instead of raising, maintaining the same behavior + # of the classic active_accounts() method. return None return { @@ -295,32 +255,35 @@ def active_account(self): } @staticmethod - def update_account(force=False): + def update_account(force: bool = False) -> Optional[Credentials]: """Interactive helper from migrating stored credentials to IBM Q Experience v2. Args: - force (bool): if `True`, disable interactive prompts and perform - the changes. + force: if `True`, disable interactive prompts and perform the changes. Returns: - Credentials: if the updating is possible, credentials for the API + if the updating is possible, credentials for the API version 2; and `None` otherwise. """ return update_credentials(force) # Provider management functions. - def providers(self, hub=None, group=None, project=None): + def providers( + self, + hub: Optional[str] = None, + group: Optional[str] = None, + project: Optional[str] = None + ) -> List[AccountProvider]: """Return a list of providers with optional filtering. Args: - hub (str): name of the hub. - group (str): name of the group. - project (str): name of the project. + hub: name of the hub. + group: name of the group. + project: name of the project. Returns: - list[AccountProvider]: list of providers that match the specified - criteria. + list of providers that match the specified criteria. """ filters = [] @@ -332,15 +295,20 @@ def providers(self, hub=None, group=None, project=None): filters.append(lambda hgp: hgp.project == project) providers = [provider for key, provider in self._providers.items() - if all(f(key) for f in filters)] + if all(f(key) for f in filters)] # type: ignore[arg-type] return providers - def get_provider(self, hub=None, group=None, project=None): + def get_provider( + self, + hub: Optional[str] = None, + group: Optional[str] = None, + project: Optional[str] = None + ) -> AccountProvider: """Return a provider for a single hub/group/project combination. Returns: - AccountProvider: provider that match the specified criteria. + provider that match the specified criteria. Raises: IBMQProviderError: if no provider matches the specified criteria, @@ -359,21 +327,21 @@ def get_provider(self, hub=None, group=None, project=None): # Private functions. @staticmethod - def _check_api_version(credentials): + def _check_api_version(credentials: Credentials) -> Dict[str, Union[bool, str]]: """Check the version of the API in a set of credentials. Returns: - dict: dictionary with version information. + dictionary with version information. """ version_finder = VersionClient(credentials.base_url, **credentials.connection_parameters()) return version_finder.version() - def _initialize_providers(self, credentials): + def _initialize_providers(self, credentials: Credentials) -> None: """Authenticate against IBM Q Experience and populate the providers. Args: - credentials (Credentials): credentials for IBM Q Experience. + credentials: credentials for IBM Q Experience. Raises: IBMQApiUrlError: if the credentials do not belong to a IBM Q @@ -394,7 +362,7 @@ def _initialize_providers(self, credentials): websockets_url=service_urls['ws'], proxies=credentials.proxies, verify=credentials.verify, - **hub_info,) + **hub_info, ) # Build the provider. try: @@ -405,203 +373,3 @@ def _initialize_providers(self, credentials): # Catch-all for errors instantiating the provider. logger.warning('Unable to instantiate provider for %s: %s', hub_info, ex) - - # Deprecated account management functions for backward compatibility. - - @deprecated - def active_accounts(self): - """List all IBM Q Experience v1 accounts currently in the session. - - Note: this method is being deprecated, and is only available when using - v1 accounts. - - Returns: - list[dict]: a list with information about the accounts currently - in the session. - - Raises: - IBMQAccountError: if the method is used with an IBM Q Experience - v2 account. - """ - return self._v1_provider.active_accounts() - - @deprecated - def disable_accounts(self, **kwargs): - """Disable IBM Q Experience v1 accounts in the current session. - - Note: this method is being deprecated, and only available when using - v1 accounts. - - The filter kwargs can be `token`, `url`, `hub`, `group`, `project`. - If no filter is passed, all accounts in the current session will be disabled. - - Raises: - IBMQAccountError: if the method is used with an IBM Q Experience v2 - account, or if no account matched the filter. - """ - self._v1_provider.disable_accounts(**kwargs) - - @deprecated - def load_accounts(self, **kwargs): - """Load IBM Q Experience v1 accounts found in the system into current session. - - Will also load v2 accounts for backward compatibility, but can lead to - issues if mixing v1 and v2 credentials in other method calls. - - Note: this method is being deprecated, and only available when using - v1 accounts. - - Automatically load the accounts found in the system. This method - looks for credentials in the following locations, in order, and - returns as soon as credentials are found: - - 1. in the `Qconfig.py` file in the current working directory. - 2. in the environment variables. - 3. in the `qiskitrc` configuration file - - Raises: - IBMQAccountError: If mixing v1 and v2 account credentials - """ - version_counter = 0 - for credentials in discover_credentials().values(): - # Explicitly check via an API call, to prevent credentials that - # contain API 2 URL (but not auth) slipping through. - version_info = self._check_api_version(credentials) - version_counter += int(version_info['new_api']) - - # Check if mixing v1 and v2 credentials - if version_counter != 0 and version_counter < len(discover_credentials().values()): - raise IBMQAccountError('Can not mix API v1 and v2' - 'credentials.') - - # If calling using API v2 credentials - if version_counter: - warnings.warn( - 'Calling IBMQ.load_accounts() with v2 credentials. ' - 'This is provided for backwards compatibility ' - 'and may lead to unexpected behaviour when mixing ' - 'v1 and v2 account credentials.', DeprecationWarning) - self.load_account() - else: - # Old API v1 call - self._v1_provider.load_accounts(**kwargs) - - @deprecated - def delete_accounts(self, **kwargs): - """Delete saved IBM Q Experience v1 accounts from disk, subject to optional filtering. - - Note: this method is being deprecated, and only available when using - v1 accounts. - - The filter kwargs can be `token`, `url`, `hub`, `group`, `project`. - If no filter is passed, all accounts will be deleted from disk. - - Raises: - IBMQAccountError: if the method is used with an IBM Q Experience v2 - account, or if no account matched the filter. - """ - self._v1_provider.delete_accounts(**kwargs) - - @deprecated - def stored_accounts(self): - """List all IBM Q Experience v1 accounts stored to disk. - - Note: this method is being deprecated, and only available when using - v1 accounts. - - Returns: - list[dict]: a list with information about the accounts stored - on disk. - - Raises: - IBMQAccountError: if the method is used with an IBM Q Experience v2 account. - """ - return self._v1_provider.stored_accounts() - - # Deprecated backend-related functionality. - - def backends(self, name=None, filters=None, **kwargs): - """Return all backends accessible via IBMQ provider, subject to optional filtering. - - Note: this method is being deprecated. Please use an IBM Q Experience v2 - account, and:: - - provider = IBMQ.get_provider(...) - provider.backends() - - instead. - - Args: - name (str): backend name to filter by - filters (callable): more complex filters, such as lambda functions - e.g. IBMQ.backends(filters=lambda b: b.configuration['n_qubits'] > 5) - kwargs: simple filters specifying a true/false criteria in the - backend configuration or backend status or provider credentials - e.g. IBMQ.backends(n_qubits=5, operational=True, hub='internal') - - Returns: - list[IBMQBackend]: list of backends available that match the filter - """ - warnings.warn('IBMQ.backends() is being deprecated. ' - 'Please use IBMQ.get_provider() to retrieve a provider ' - 'and AccountProvider.backends() to find its backends.', - DeprecationWarning) - - if self._credentials: - hgp_filter = {} - - # First filter providers by h/g/p - for key in ['hub', 'group', 'project']: - if key in kwargs: - hgp_filter[key] = kwargs.pop(key) - providers = self.providers(**hgp_filter) - - # Aggregate the list of filtered backends. - backends = [] - for provider in providers: - backends = backends + provider.backends( - name=name, filters=filters, **kwargs) - - return backends - else: - return self._v1_provider.backends(name, filters, **kwargs) - - def get_backend(self, name=None, **kwargs): - """Return a single backend matching the specified filtering. - - Note: this method is being deprecated. Please use an IBM Q Experience v2 - account, and:: - - provider = IBMQ.get_provider(...) - provider.get_backend('name') - - instead. - - Args: - name (str): name of the backend. - **kwargs (dict): dict used for filtering. - - Returns: - BaseBackend: a backend matching the filtering. - - Raises: - QiskitBackendNotFoundError: if no backend could be found or - more than one backend matches the filtering criteria. - """ - warnings.warn('IBMQ.get_backend() is being deprecated. ' - 'Please use IBMQ.get_provider() to retrieve a provider ' - 'and AccountProvider.get_backend("name") to retrieve a ' - 'backend.', - DeprecationWarning) - - # Don't issue duplicate warnings - with warnings.catch_warnings(): - warnings.filterwarnings('ignore', category=DeprecationWarning) - backends = self.backends(name, **kwargs) - - if len(backends) > 1: - raise QiskitBackendNotFoundError('More than one backend matches the criteria') - if not backends: - raise QiskitBackendNotFoundError('No backend matches the criteria') - - return backends[0] diff --git a/qiskit/providers/ibmq/ibmqprovider.py b/qiskit/providers/ibmq/ibmqprovider.py deleted file mode 100644 index 81c30217f..000000000 --- a/qiskit/providers/ibmq/ibmqprovider.py +++ /dev/null @@ -1,279 +0,0 @@ -# -*- coding: utf-8 -*- - -# This code is part of Qiskit. -# -# (C) Copyright IBM 2017, 2018. -# -# This code is licensed under the Apache License, Version 2.0. You may -# obtain a copy of this license in the LICENSE.txt file in the root directory -# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. -# -# Any modifications or derivative works of this code must retain this -# copyright notice, and modified files need to carry a notice indicating -# that they have been altered from the originals. - -"""Provider for remote IBMQ backends with admin features.""" - -import warnings -from collections import OrderedDict - -from qiskit.providers import BaseProvider - -from .credentials.configrc import remove_credentials -from .credentials import (Credentials, read_credentials_from_qiskitrc, - store_credentials, discover_credentials) -from .exceptions import IBMQAccountError -from .ibmqsingleprovider import IBMQSingleProvider - - -QE_URL = 'https://quantumexperience.ng.bluemix.net/api' - - -class IBMQProvider(BaseProvider): - """Provider for remote IBMQ backends with admin features. - - This class is the entry point for handling backends from IBMQ, allowing - using different accounts. - """ - def __init__(self): - super().__init__() - - # dict[credentials_unique_id: IBMQSingleProvider] - # This attribute stores a reference to the different accounts. The - # keys are tuples (hub, group, project), as the convention is that - # that tuple uniquely identifies a set of credentials. - self._accounts = OrderedDict() - - def backends(self, name=None, filters=None, **kwargs): - """Return all backends accessible via IBMQ provider, subject to optional filtering. - - Args: - name (str): backend name to filter by - filters (callable): more complex filters, such as lambda functions - e.g. IBMQ.backends(filters=lambda b: b.configuration['n_qubits'] > 5) - kwargs: simple filters specifying a true/false criteria in the - backend configuration or backend status or provider credentials - e.g. IBMQ.backends(n_qubits=5, operational=True, hub='internal') - - Returns: - list[IBMQBackend]: list of backends available that match the filter - """ - # pylint: disable=arguments-differ - - # Special handling of the credentials filters: match and prune from kwargs - credentials_filter = {} - for key in ['token', 'url', 'hub', 'group', 'project', 'proxies', 'verify']: - if key in kwargs: - credentials_filter[key] = kwargs.pop(key) - providers = [provider for provider in self._accounts.values() if - self._credentials_match_filter(provider.credentials, - credentials_filter)] - - # Special handling of the `name` parameter, to support alias resolution. - if name: - aliases = self._aliased_backend_names() - aliases.update(self._deprecated_backend_names()) - name = aliases.get(name, name) - - # Aggregate the list of filtered backends. - backends = [] - for provider in providers: - backends = backends + provider.backends( - name=name, filters=filters, **kwargs) - - return backends - - @staticmethod - def _deprecated_backend_names(): - """Returns deprecated backend names.""" - return { - 'ibmqx_qasm_simulator': 'ibmq_qasm_simulator', - 'ibmqx_hpc_qasm_simulator': 'ibmq_qasm_simulator', - 'real': 'ibmqx1' - } - - @staticmethod - def _aliased_backend_names(): - """Returns aliased backend names.""" - return { - 'ibmq_5_yorktown': 'ibmqx2', - 'ibmq_5_tenerife': 'ibmqx4', - 'ibmq_16_rueschlikon': 'ibmqx5', - 'ibmq_20_austin': 'QS1_1' - } - - def enable_account(self, token, url=QE_URL, **kwargs): - """Authenticate a new IBMQ account and add for use during this session. - - Login into Quantum Experience or IBMQ using the provided credentials, - adding the account to the current session. The account is not stored - in disk. - - Args: - token (str): Quantum Experience or IBM Q API token. - url (str): URL for Quantum Experience or IBM Q (for IBM Q, - including the hub, group and project in the URL). - **kwargs (dict): - * proxies (dict): Proxy configuration for the API. - * verify (bool): If False, ignores SSL certificates errors - """ - credentials = Credentials(token, url, **kwargs) - - self._append_account(credentials) - - def save_account(self, token, url=QE_URL, overwrite=False, **kwargs): - """Save the account to disk for future use. - - Login into Quantum Experience or IBMQ using the provided credentials, - adding the account to the current session. The account is stored in - disk for future use. - - Args: - token (str): Quantum Experience or IBM Q API token. - url (str): URL for Quantum Experience or IBM Q (for IBM Q, - including the hub, group and project in the URL). - overwrite (bool): overwrite existing credentials. - **kwargs (dict): - * proxies (dict): Proxy configuration for the API. - * verify (bool): If False, ignores SSL certificates errors - """ - credentials = Credentials(token, url, **kwargs) - store_credentials(credentials, overwrite=overwrite) - - def active_accounts(self): - """List all accounts currently in the session. - - Returns: - list[dict]: a list with information about the accounts currently - in the session. - """ - information = [] - for provider in self._accounts.values(): - information.append({ - 'token': provider.credentials.token, - 'url': provider.credentials.url, - }) - - return information - - def stored_accounts(self): - """List all accounts stored to disk. - - Returns: - list[dict]: a list with information about the accounts stored - on disk. - """ - information = [] - stored_creds = read_credentials_from_qiskitrc() - for creds in stored_creds: - information.append({ - 'token': stored_creds[creds].token, - 'url': stored_creds[creds].url - }) - - return information - - def load_accounts(self, **kwargs): - """Load IBMQ accounts found in the system into current session, - subject to optional filtering. - - Automatically load the accounts found in the system. This method - looks for credentials in the following locations, in order, and - returns as soon as credentials are found: - - 1. in the `Qconfig.py` file in the current working directory. - 2. in the environment variables. - 3. in the `qiskitrc` configuration file - - Raises: - IBMQAccountError: if no credentials are found. - """ - for credentials in discover_credentials().values(): - if self._credentials_match_filter(credentials, kwargs): - self._append_account(credentials) - - if not self._accounts: - raise IBMQAccountError('No IBMQ credentials found on disk.') - - def disable_accounts(self, **kwargs): - """Disable accounts in the current session, subject to optional filtering. - - The filter kwargs can be `token`, `url`, `hub`, `group`, `project`. - If no filter is passed, all accounts in the current session will be disabled. - - Raises: - IBMQAccountError: if no account matched the filter. - """ - disabled = False - - # Try to remove from session. - current_creds = self._accounts.copy() - for creds in current_creds: - credentials = Credentials(current_creds[creds].credentials.token, - current_creds[creds].credentials.url) - if self._credentials_match_filter(credentials, kwargs): - del self._accounts[credentials.unique_id()] - disabled = True - - if not disabled: - raise IBMQAccountError('No matching account to disable in current session.') - - def delete_accounts(self, **kwargs): - """Delete saved accounts from disk, subject to optional filtering. - - The filter kwargs can be `token`, `url`, `hub`, `group`, `project`. - If no filter is passed, all accounts will be deleted from disk. - - Raises: - IBMQAccountError: if no account matched the filter. - """ - deleted = False - - # Try to delete from disk. - stored_creds = read_credentials_from_qiskitrc() - for creds in stored_creds: - credentials = Credentials(stored_creds[creds].token, - stored_creds[creds].url) - if self._credentials_match_filter(credentials, kwargs): - remove_credentials(credentials) - deleted = True - - if not deleted: - raise IBMQAccountError('No matching account to delete from disk.') - - def _append_account(self, credentials): - """Append an account with the specified credentials to the session. - - Args: - credentials (Credentials): set of credentials. - - Returns: - IBMQSingleProvider: new single-account provider. - """ - # Check if duplicated credentials are already in use. By convention, - # we assume (hub, group, project) is always unique. - if credentials.unique_id() in self._accounts.keys(): - warnings.warn('Credentials are already in use.') - - single_provider = IBMQSingleProvider(credentials, self) - - self._accounts[credentials.unique_id()] = single_provider - - return single_provider - - def _credentials_match_filter(self, credentials, filter_dict): - """Return True if the credentials match a filter. - - These filters apply on properties of a Credentials object: - token, url, hub, group, project, proxies, verify - Any other filter has no effect. - - Args: - credentials (Credentials): IBMQ credentials object - filter_dict (dict): dictionary of filter conditions - - Returns: - bool: True if the credentials meet all the filter conditions - """ - return all(getattr(credentials, key_, None) == value_ for - key_, value_ in filter_dict.items()) diff --git a/qiskit/providers/ibmq/ibmqsingleprovider.py b/qiskit/providers/ibmq/ibmqsingleprovider.py deleted file mode 100644 index ae0caf2b8..000000000 --- a/qiskit/providers/ibmq/ibmqsingleprovider.py +++ /dev/null @@ -1,133 +0,0 @@ -# -*- coding: utf-8 -*- - -# This code is part of Qiskit. -# -# (C) Copyright IBM 2017, 2018. -# -# This code is licensed under the Apache License, Version 2.0. You may -# obtain a copy of this license in the LICENSE.txt file in the root directory -# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. -# -# Any modifications or derivative works of this code must retain this -# copyright notice, and modified files need to carry a notice indicating -# that they have been altered from the originals. - -"""Provider for a single IBMQ account.""" - -import logging -from collections import OrderedDict - -from qiskit.providers import BaseProvider -from qiskit.providers.models import (QasmBackendConfiguration, - PulseBackendConfiguration) -from qiskit.providers.providerutils import filter_backends -from qiskit.validation.exceptions import ModelValidationError - -from .api import IBMQConnector -from .ibmqbackend import IBMQBackend, IBMQSimulator - - -logger = logging.getLogger(__name__) - - -class IBMQSingleProvider(BaseProvider): - """Provider for single IBMQ accounts. - - Note: this class is not part of the public API and is not guaranteed to be - present in future releases. - """ - - def __init__(self, credentials, ibmq_provider): - """Return a new IBMQSingleProvider. - - Args: - credentials (Credentials): Quantum Experience or IBMQ credentials. - ibmq_provider (IBMQProvider): IBMQ main provider. - """ - super().__init__() - - # Get a connection to IBMQ. - self.credentials = credentials - self._api = self._authenticate(self.credentials) - self._ibm_provider = ibmq_provider - - # Populate the list of remote backends. - self._backends = self._discover_remote_backends() - - def backends(self, name=None, filters=None, **kwargs): - # pylint: disable=arguments-differ - backends = self._backends.values() - - if name: - kwargs['backend_name'] = name - - return filter_backends(backends, filters=filters, **kwargs) - - @classmethod - def _authenticate(cls, credentials): - """Authenticate against the IBMQ API. - - Args: - credentials (Credentials): Quantum Experience or IBMQ credentials. - - Returns: - IBMQConnector: instance of the IBMQConnector. - Raises: - ConnectionError: if the authentication resulted in error. - """ - try: - config_dict = { - 'url': credentials.url, - } - if credentials.proxies: - config_dict['proxies'] = credentials.proxies - return IBMQConnector(credentials.token, config_dict, - credentials.verify) - except Exception as ex: - root_exception = ex - if 'License required' in str(ex): - # For the 401 License required exception from the API, be - # less verbose with the exceptions. - root_exception = None - raise ConnectionError("Couldn't connect to IBMQ server: {0}" - .format(ex)) from root_exception - - def _discover_remote_backends(self): - """Return the remote backends available. - - Returns: - dict[str:IBMQBackend]: a dict of the remote backend instances, - keyed by backend name. - """ - ret = OrderedDict() - configs_list = self._api.available_backends() - for raw_config in configs_list: - try: - # Make sure the raw_config is of proper type - if not isinstance(raw_config, dict): - logger.warning("An error occurred when retrieving backend " - "information. Some backends might not be available.") - continue - - if raw_config.get('open_pulse', False): - config = PulseBackendConfiguration.from_dict(raw_config) - else: - config = QasmBackendConfiguration.from_dict(raw_config) - backend_cls = IBMQSimulator if config.simulator else IBMQBackend - ret[config.backend_name] = backend_cls( - configuration=config, - provider=self._ibm_provider, - credentials=self.credentials, - api=self._api) - except ModelValidationError as ex: - logger.warning( - 'Remote backend "%s" could not be instantiated due to an ' - 'invalid config: %s', - raw_config.get('backend_name', - raw_config.get('name', 'unknown')), - ex) - - return ret - - def __eq__(self, other): - return self.credentials == other.credentials diff --git a/qiskit/providers/ibmq/job/__init__.py b/qiskit/providers/ibmq/job/__init__.py index 937b3f444..8c90c0eac 100644 --- a/qiskit/providers/ibmq/job/__init__.py +++ b/qiskit/providers/ibmq/job/__init__.py @@ -14,5 +14,4 @@ """Module representing Jobs communicating with IBM Q.""" -from .circuitjob import CircuitJob from .ibmqjob import IBMQJob diff --git a/qiskit/providers/ibmq/job/circuitjob.py b/qiskit/providers/ibmq/job/circuitjob.py deleted file mode 100644 index acfe0af52..000000000 --- a/qiskit/providers/ibmq/job/circuitjob.py +++ /dev/null @@ -1,61 +0,0 @@ -# -*- coding: utf-8 -*- - -# This code is part of Qiskit. -# -# (C) Copyright IBM 2018, 2019. -# -# This code is licensed under the Apache License, Version 2.0. You may -# obtain a copy of this license in the LICENSE.txt file in the root directory -# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. -# -# Any modifications or derivative works of this code must retain this -# copyright notice, and modified files need to carry a notice indicating -# that they have been altered from the originals. - -"""Job specific for Circuits.""" - -from qiskit.providers import JobError -from qiskit.providers.jobstatus import JOB_FINAL_STATES - -from .ibmqjob import IBMQJob - - -class CircuitJob(IBMQJob): - """Job specific for use with Circuits. - - Note: this class is experimental, and currently only supports the - customizations needed for using it with the manager (which implies - initializing with a job_id: - - * _wait_for_completion() - * status() - * result() - - In general, the changes involve using a different `self._api.foo()` method - for adjusting to the Circuits particularities. - """ - - def status(self): - # Implies self._job_id is None - if self._future_captured_exception is not None: - raise JobError(str(self._future_captured_exception)) - - if self._job_id is None or self._status in JOB_FINAL_STATES: - return self._status - - try: - # TODO: See result values - api_response = self._api.circuit_job_status(self._job_id) - self._update_status(api_response) - # pylint: disable=broad-except - except Exception as err: - raise JobError(str(err)) - - return self._status - - def _get_job(self): - if self._cancelled: - raise JobError( - 'Job result impossible to retrieve. The job was cancelled.') - - return self._api.circuit_job_get(self._job_id) diff --git a/qiskit/providers/ibmq/job/exceptions.py b/qiskit/providers/ibmq/job/exceptions.py new file mode 100644 index 000000000..bcc3e2178 --- /dev/null +++ b/qiskit/providers/ibmq/job/exceptions.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- + +# This code is part of Qiskit. +# +# (C) Copyright IBM 2019. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Exceptions related to IBMQJob.""" + +from qiskit.providers.exceptions import JobError + + +class IBMQJobApiError(JobError): + """Error that occurs unexpectedly when querying the API.""" + pass + + +class IBMQJobFailureError(JobError): + """Error that occurs because the job failed.""" + pass + + +class IBMQJobInvalidStateError(JobError): + """Error that occurs because a job is not in a state for the operation.""" + pass diff --git a/qiskit/providers/ibmq/job/ibmqjob.py b/qiskit/providers/ibmq/job/ibmqjob.py index 8fc71eeda..c5901bdfc 100644 --- a/qiskit/providers/ibmq/job/ibmqjob.py +++ b/qiskit/providers/ibmq/job/ibmqjob.py @@ -2,7 +2,7 @@ # This code is part of Qiskit. # -# (C) Copyright IBM 2017, 2018. +# (C) Copyright IBM 2017, 2019. # # This code is licensed under the Apache License, Version 2.0. You may # obtain a copy of this license in the LICENSE.txt file in the root directory @@ -14,214 +14,180 @@ """IBMQJob module -This module is used for creating asynchronous job objects for the -IBM Q Experience. +This module is used for creating a job objects for the IBM Q Experience. """ import logging -import pprint -import time -from concurrent import futures +from typing import Dict, Optional, Tuple, Any +import warnings +from datetime import datetime -from qiskit.providers import BaseJob, JobError, JobTimeoutError +from marshmallow import ValidationError + +from qiskit.providers import (BaseJob, # type: ignore[attr-defined] + JobTimeoutError, BaseBackend) from qiskit.providers.jobstatus import JOB_FINAL_STATES, JobStatus from qiskit.providers.models import BackendProperties -from qiskit.qobj import Qobj, validate_qobj_against_schema +from qiskit.qobj import Qobj from qiskit.result import Result -from qiskit.tools.events.pubsub import Publisher - -from ..api import ApiError -from ..apiconstants import ApiJobStatus -from ..api_v2.exceptions import WebsocketTimeoutError, WebsocketError +from qiskit.validation import BaseModel, ModelValidationError, bind_schema -from .utils import current_utc_time, build_error_report, is_job_queued +from ..apiconstants import ApiJobStatus, ApiJobKind +from ..api.clients import AccountClient +from ..api.exceptions import ApiError, UserTimeoutExceededError +from ..job.exceptions import (IBMQJobApiError, IBMQJobFailureError, + IBMQJobInvalidStateError) +from .schema import JobResponseSchema +from .utils import (build_error_report, is_job_queued, + api_status_to_job_status, api_to_job_error) logger = logging.getLogger(__name__) -class IBMQJob(BaseJob): +@bind_schema(JobResponseSchema) +class IBMQJob(BaseModel, BaseJob): """Representation of a job that will be execute on a IBMQ backend. - Represent the jobs that will be executed on IBM-Q simulators and real - devices. Jobs are intended to be created calling ``run()`` on a particular - backend. - - Creating a ``Job`` instance does not imply running it. You need to do it in - separate steps:: + Represent a job that is or has been executed on an IBMQ simulator or real + device. New jobs are intended to be created by calling ``run()`` on a + particular backend. - job = IBMQJob(...) - job.submit() # It won't block. - - An error while submitting a job will cause the next call to ``status()`` to - raise. If submitting the job successes, you can inspect the job's status by + If the job was successfully submitted, you can inspect the job's status by using ``status()``. Status can be one of ``JobStatus`` members:: - from qiskit.backends.jobstatus import JobStatus + from qiskit.providers.jobstatus import JobStatus - job = IBMQJob(...) - job.submit() + job = IBMQBackend.run(...) try: - job_status = job.status() # It won't block. It will query the backend API. + job_status = job.status() # It will query the backend API. if job_status is JobStatus.RUNNING: print('The job is still running') - except JobError as ex: + except IBMQJobApiError as ex: print("Something wrong happened!: {}".format(ex)) - A call to ``status()`` can raise if something happens at the API level that - prevents Qiskit from determining the status of the job. An example of this - is a temporary connection lose or a network failure. + A call to ``status()`` can raise if something happens at the server level + that prevents Qiskit from determining the status of the job. An example of + this is a temporary connection lose or a network failure. - The ``submit()`` and ``status()`` methods are examples of non-blocking API. - ``Job`` instances also have `id()` and ``result()`` methods which will - block:: + The ``status()`` method is an example of non-blocking API. + The ``result()`` method is an example of blocking API: - job = IBMQJob(...) - job.submit() + job = IBMQBackend.run(...) try: - job_id = job.id() # It will block until completing submission. - print('The job {} was successfully submitted'.format(job_id)) - job_result = job.result() # It will block until finishing. print('The job finished with result {}'.format(job_result)) except JobError as ex: print("Something wrong happened!: {}".format(ex)) - Both methods can raise if something ath the API level happens that prevent - Qiskit from determining the status of the job. + Many of the ``IBMQJob`` methods can raise ``IBMQJobApiError`` if unexpected + failures happened at the server level. + + Job information retrieved from the API server is attached to the ``IBMQJob`` + instance as attributes. Given that Qiskit and the API server can be updated + independently, some of these attributes might be deprecated or experimental. + Supported attributes can be retrieved via methods. For example, you + can use ``IBMQJob.creation_date()`` to retrieve the job creation date, + which is a supported attribute. Note: - When querying the API for getting the status, two kinds of errors are - possible. The most severe is the one preventing Qiskit from getting a - response from the backend. This can be caused by a network failure or a - temporary system break. In these cases, calling ``status()`` will raise. + When querying the server for getting the job information, two kinds + of errors are possible. The most severe is the one preventing Qiskit + from getting a response from the server. This can be caused by a + network failure or a temporary system break. In these cases, the job + method will raise. If Qiskit successfully retrieves the status of a job, it could be it finished with errors. In that case, ``status()`` will simply return ``JobStatus.ERROR`` and you can call ``error_message()`` to get more info. - - Attributes: - _executor (futures.Executor): executor to handle asynchronous jobs """ - _executor = futures.ThreadPoolExecutor() - def __init__(self, backend, job_id, api, qobj=None, - creation_date=None, api_status=None, - use_object_storage=False, use_websockets=False): + def __init__(self, + _backend: BaseBackend, + api: AccountClient, + _job_id: str, + _creation_date: datetime, + kind: ApiJobKind, + _api_status: ApiJobStatus, + **kwargs: Any) -> None: """IBMQJob init function. - We can instantiate jobs from two sources: A QObj, and an already submitted job returned by - the API servers. - Args: - backend (BaseBackend): The backend instance used to run this job. - job_id (str or None): The job ID of an already submitted job. - Pass `None` if you are creating a new job. - api (IBMQConnector or BaseClient): object for connecting to the API. - qobj (Qobj): The Quantum Object. See notes below - creation_date (str): When the job was run. - api_status (str): `status` field directly from the API response. - use_object_storage (bool): if `True`, signals that the Job will - _attempt_ to use object storage for submitting jobs and - retrieving results. - use_websockets (bool): if `True`, signals that the Job will - _attempt_ to use websockets when pooling for final status. - - Notes: - It is mandatory to pass either ``qobj`` or ``job_id``. Passing a ``qobj`` - will ignore ``job_id`` and will create an instance to be submitted to the - API server for job creation. Passing only a `job_id` will create an instance - representing an already-created job retrieved from the API server. + _backend: the backend instance used to run this job. + api: object for connecting to the API. + _job_id: job ID of this job. + _creation_date: job creation date. + kind: job kind. + _api_status: API job status. + kwargs: additional job attributes, that will be added as + instance members. """ - # pylint: disable=unused-argument - super().__init__(backend, job_id) + # pylint: disable=redefined-builtin + BaseModel.__init__(self, _backend=_backend, _job_id=_job_id, + _creation_date=_creation_date, kind=kind, + _api_status=_api_status, **kwargs) + BaseJob.__init__(self, self.backend(), self.job_id()) - # Properties common to all Jobs. + # Model attributes. self._api = api - self._backend = backend - self._creation_date = creation_date or current_utc_time() - self._future = None - self._future_captured_exception = None + self._use_object_storage = (self.kind == ApiJobKind.QOBJECT_STORAGE) + self._queue_position = None + self._update_status_position(_api_status, kwargs.pop('infoQueue', None)) # Properties used for caching. self._cancelled = False - self._api_error_msg = None - self._result = None - self._queue_position = None - - # Properties used for deciding the underlying API features to use. - self._use_object_storage = use_object_storage - self._use_websockets = use_websockets - - if qobj: - validate_qobj_against_schema(qobj) - self._qobj_payload = qobj.to_dict() - self._status = JobStatus.INITIALIZING - else: - # In case of not providing a `qobj`, it is assumed the job already - # exists in the API (with `job_id`). - self._qobj_payload = {} - - # Some API calls (`get_status_jobs`, `get_status_job`) provide - # enough information to recreate the `Job`. If that is the case, try - # to make use of that information during instantiation, as - # `self.status()` involves an extra call to the API. - if api_status == ApiJobStatus.VALIDATING.value: - self._status = JobStatus.VALIDATING - elif api_status == ApiJobStatus.COMPLETED.value: - self._status = JobStatus.DONE - elif api_status == ApiJobStatus.CANCELLED.value: - self._status = JobStatus.CANCELLED - self._cancelled = True - elif api_status in (ApiJobStatus.ERROR_CREATING_JOB.value, - ApiJobStatus.ERROR_VALIDATING_JOB.value, - ApiJobStatus.ERROR_RUNNING_JOB.value): - self._status = JobStatus.ERROR - else: - self._status = JobStatus.INITIALIZING - self.status() + self._job_error_msg = self._error.message if self._error else None - def qobj(self): - """Return the Qobj submitted for this job. + def qobj(self) -> Qobj: + """Return the Qobj for this job. Note that this method might involve querying the API for results if the Job has been created in a previous Qiskit session. Returns: - Qobj: the Qobj submitted for this job. + the Qobj for this job. + + Raises: + IBMQJobApiError: if there was some unexpected failure in the server. """ - if not self._qobj_payload: - # Populate self._qobj_payload by retrieving the results. - self._wait_for_job() + # pylint: disable=access-member-before-definition,attribute-defined-outside-init + if not self._qobj: # type: ignore[has-type] + self._wait_for_completion() + with api_to_job_error(): + qobj = self._api.job_download_qobj( + self.job_id(), self._use_object_storage) + self._qobj = Qobj.from_dict(qobj) - return Qobj.from_dict(self._qobj_payload) + return self._qobj - def properties(self): + def properties(self) -> Optional[BackendProperties]: """Return the backend properties for this job. - The properties might not be available if the job hasn't completed, - in which case None is returned. - Returns: - BackendProperties: the backend properties used for this job, or None if + the backend properties used for this job, or None if properties are not available. - """ - self._wait_for_submission() - properties = self._api.job_properties(job_id=self.job_id()) + Raises: + IBMQJobApiError: if there was some unexpected failure in the server. + """ + with api_to_job_error(): + properties = self._api.job_properties(job_id=self.job_id()) - # Backend properties of a job might not be available if the job hasn't - # completed. This is to ensure the properties returned are up to date. if not properties: return None + return BackendProperties.from_dict(properties) - # pylint: disable=arguments-differ - def result(self, timeout=None, wait=5): + def result( + self, + timeout: Optional[float] = None, + wait: float = 5, + partial: bool = False + ) -> Result: """Return the result of the job. Note: @@ -236,128 +202,107 @@ def result(self, timeout=None, wait=5): results again in another instance or session might fail due to the job having been consumed. + When `partial=True`, the result method returns a `Result` object + containing partial results. If partial results are returned, precaution + should be taken when accessing individual experiments, as doing so might + cause an exception. Verifying whether some experiments of a job failed can + be done by checking the boolean attribute `Result.success`. + + For example: + If there is a job with two experiments (where one fails), getting + the counts of the unsuccessful experiment would raise an exception + since there are no counts to return for it: + i.e. + try: + counts = result.get_counts("failed_experiment") + except QiskitError: + print("Experiment failed!") + Args: - timeout (float): number of seconds to wait for job - wait (int): time between queries to IBM Q server + timeout: number of seconds to wait for job + wait: time between queries to IBM Q server + partial: if true attempts to return partial results for the job. Returns: - qiskit.Result: Result object + Result object. Raises: - JobError: if attempted to recover a result on a failed job. + IBMQJobInvalidStateError: if the job was cancelled. + IBMQJobFailureError: If the job failed. + IBMQJobApiError: If there was some unexpected failure in the server. """ - self._wait_for_completion(timeout=timeout, wait=wait) - - status = self.status() - if status is not JobStatus.DONE: - raise JobError('Invalid job state. The job should be DONE but ' - 'it is {}'.format(str(status))) - - if not self._result: - if self._use_object_storage: - # Retrieve the results via object storage. - result_response = self._api.job_result_object_storage( - self._job_id) - self._result = Result.from_dict(result_response) - else: - job_response = self._get_job() - self._result = Result.from_dict(job_response['qObjectResult']) + # pylint: disable=arguments-differ + # pylint: disable=access-member-before-definition,attribute-defined-outside-init - return self._result + if not self._wait_for_completion(timeout=timeout, wait=wait, + required_status=(JobStatus.DONE,)): + if self._status is JobStatus.CANCELLED: + raise IBMQJobInvalidStateError('Unable to retrieve job result. Job was cancelled.') - def cancel(self): - """Attempt to cancel a job. + if self._status is JobStatus.ERROR and not partial: + raise IBMQJobFailureError('Unable to retrieve job result. Job has failed. ' + 'Use job.error_message() to get more details.') - Note: - This function waits for a job ID to become available if the job - has been submitted but not yet queued. + return self._retrieve_result() + + def cancel(self) -> bool: + """Attempt to cancel a job. Returns: - bool: True if job can be cancelled, else False. Note this operation + True if job can be cancelled, else False. Note this operation might not be possible depending on the environment. Raises: - JobError: if there was some unexpected failure in the server. + IBMQJobApiError: if there was some unexpected failure in the server. """ - # Wait for the job ID to become available. - self._wait_for_submission() - try: - response = self._api.cancel_job(self._job_id) + response = self._api.job_cancel(self.job_id()) self._cancelled = 'error' not in response return self._cancelled except ApiError as error: self._cancelled = False - raise JobError('Error cancelling job: %s' % error.usr_msg) + raise IBMQJobApiError('Error cancelling job: %s' % error) - def status(self): + def status(self) -> JobStatus: """Query the API to update the status. Returns: - qiskit.providers.JobStatus: The status of the job, once updated. + The status of the job, once updated. Raises: - JobError: if there was an exception in the future being executed - or the server sent an unknown answer. + IBMQJobApiError: if there was some unexpected failure in the server. """ - # Implies self._job_id is None - if self._future_captured_exception is not None: - raise JobError(str(self._future_captured_exception)) - - if self._job_id is None or self._status in JOB_FINAL_STATES: + if self._status in JOB_FINAL_STATES: return self._status - try: - # TODO: See result values - api_response = self._api.get_status_job(self._job_id) - self._update_status(api_response) - # pylint: disable=broad-except - except Exception as err: - raise JobError(str(err)) + with api_to_job_error(): + api_response = self._api.job_status(self.job_id()) + self._update_status_position(ApiJobStatus(api_response['status']), + api_response.get('infoQueue', None)) + + # Get all job attributes if the job is done. + if self._status in JOB_FINAL_STATES: + self.refresh() return self._status - def _update_status(self, api_response): - """Update the job status from an API status. + def _update_status_position(self, status: ApiJobStatus, info_queue: Optional[Dict]) -> None: + """Update the job status and potentially queue position from an API response. Args: - api_response (dict): API response for a status query. - - Raises: - JobError: if the API response could not be parsed. + status: job status from the API response. + info_queue: job queue information from the API response. """ - if 'status' not in api_response: - raise JobError('Unrecognized answer from server: \n{}'.format( - pprint.pformat(api_response))) - - try: - api_status = ApiJobStatus(api_response['status']) - except ValueError: - raise JobError('Unrecognized status from server: {}'.format( - api_response['status'])) - - if api_status is ApiJobStatus.VALIDATING: - self._status = JobStatus.VALIDATING - - elif api_status is ApiJobStatus.RUNNING: - self._status = JobStatus.RUNNING - queued, self._queue_position = is_job_queued(api_response) + self._status = api_status_to_job_status(status) + if status is ApiJobStatus.RUNNING: + queued, self._queue_position = is_job_queued(info_queue) # type: ignore[assignment] if queued: self._status = JobStatus.QUEUED - elif api_status is ApiJobStatus.COMPLETED: - self._status = JobStatus.DONE - - elif api_status is ApiJobStatus.CANCELLED: - self._status = JobStatus.CANCELLED - self._cancelled = True - - elif api_status in (ApiJobStatus.ERROR_CREATING_JOB, - ApiJobStatus.ERROR_VALIDATING_JOB, - ApiJobStatus.ERROR_RUNNING_JOB): - self._status = JobStatus.ERROR + if self._status is not JobStatus.QUEUED: + self._queue_position = None - def error_message(self): + def error_message(self) -> Optional[str]: """Provide details about the reason of failure. Note: @@ -365,267 +310,219 @@ def error_message(self): query the API for the job will fail, as the job is "consumed". The first call to this method in an ``IBMQJob`` instance will query - the API and consume the job if it errored at some point (otherwise + the API and consume the job if it failed at some point (otherwise it will return ``None``). Subsequent calls to that instance's method will also return the failure details, since they are cached. However, attempting to retrieve the error details again in another instance or session might fail due to the job having been consumed. Returns: - str: An error report if the job errored or ``None`` otherwise. + An error report if the job failed or ``None`` otherwise. """ - self._wait_for_completion() - if self.status() is not JobStatus.ERROR: + # pylint: disable=attribute-defined-outside-init + if not self._wait_for_completion(required_status=(JobStatus.ERROR,)): return None - if not self._api_error_msg: - job_response = self._get_job() - if 'qObjectResult' in job_response: - results = job_response['qObjectResult']['results'] - self._api_error_msg = build_error_report(results) - elif 'qasms' in job_response: - qasm_statuses = [qasm['status'] for qasm in job_response['qasms']] - self._api_error_msg = 'Job resulted in the following QASM status(es): ' \ - '{}.'.format(', '.join(qasm_statuses)) + if not self._job_error_msg: + # First try getting error messages from the result. + try: + self._retrieve_result() + except IBMQJobFailureError: + pass + + if not self._job_error_msg: + # Then try refreshing the job + if not self._error: + self.refresh() + if self._error: + self._job_error_msg = self._error.message + elif self._api_status: + # TODO this can be removed once API provides detailed error + self._job_error_msg = self._api_status.value else: - self._api_error_msg = job_response.get('status', 'An unknown error occurred.') + self._job_error_msg = "Unknown error." - return self._api_error_msg + return self._job_error_msg - def queue_position(self): + def queue_position(self, refresh: bool = False) -> Optional[int]: """Return the position in the server queue. + Args: + refresh (bool): if True, query the API and return the latest value. + Otherwise return the cached value. + Returns: - Number: Position in the queue. + Position in the queue or ``None`` if position is unknown or not applicable. """ + if refresh: + # Get latest position + self.status() return self._queue_position - def creation_date(self): - """Return creation date.""" - return self._creation_date - - def job_id(self, timeout=60): - """Return the job ID assigned by the API. + def creation_date(self) -> str: + """Return creation date. - If the job ID is not set because the job is still initializing, this - call will block until a job ID is available or the timeout is reached. + Returns: + Job creation date. + """ + return str(self._creation_date) - Args: - timeout (float): number of seconds to wait for the job ID. + def job_id(self) -> str: + """Return the job ID assigned by the API. Returns: - str: the job ID. + the job ID. """ - self._wait_for_submission(timeout) return self._job_id - def submit(self): - """Submit job to IBM-Q. - - Events: - ibmq.job.start: The job has started. + def name(self) -> Optional[str]: + """Return the name assigned to this job. - Raises: - JobError: If we have already submitted the job. + Returns: + the job name or ``None`` if no name was assigned to the job. """ - # TODO: Validation against the schema should be done here and not - # during initialization. Once done, we should document that the method - # can raise QobjValidationError. - if self._future is not None or self._job_id is not None: - raise JobError("We have already submitted the job!") - self._future = self._executor.submit(self._submit_callback) - Publisher().publish("ibmq.job.start", self) + return self._name - def _submit_callback(self): - """Submit qobj job to IBM-Q. + def time_per_step(self) -> Optional[Dict]: + """Return the date and time information on each step of the job processing. Returns: - dict: A dictionary with the response of the submitted job + a dictionary containing the date and time information on each + step of the job processing. The keys of the dictionary are the + names of the steps, and the values are the date and time + information. ``None`` is returned if the information is not + yet available. """ - backend_name = self.backend().name() + if not self._time_per_step or self._status not in JOB_FINAL_STATES: + self.refresh() + return self._time_per_step - submit_info = None - if self._use_object_storage: - # Attempt to use object storage. - try: - submit_info = self._api.job_submit_object_storage( - backend_name=backend_name, - qobj_dict=self._qobj_payload) - except Exception as err: # pylint: disable=broad-except - # Fall back to submitting the Qobj via POST if object storage - # failed. - logger.info('Submitting the job via object storage failed: ' - 'retrying via regular POST upload.') - # Disable object storage for this job. - self._use_object_storage = False - - if not submit_info: - try: - submit_info = self._api.submit_job( - backend_name=backend_name, - qobj_dict=self._qobj_payload) - except Exception as err: # pylint: disable=broad-except - # Undefined error during submission: - # Capture and keep it for raising it when calling status(). - self._future_captured_exception = err - return None - - # Error in the job after submission: - # Transition to the `ERROR` final state. - if 'error' in submit_info: - self._status = JobStatus.ERROR - self._api_error_msg = str(submit_info['error']) - return submit_info - - # Submission success. - self._creation_date = submit_info.get('creationDate') - self._status = JobStatus.QUEUED - self._job_id = submit_info.get('id') - return submit_info - - def _wait_for_job(self, timeout=None, wait=5): - """Blocks until the job is complete and returns the job content from the - API, consuming it. + def submit(self) -> None: + """Submit job to IBM-Q. - Args: - timeout (float): number of seconds to wait for job. - wait (int): time between queries to IBM Q server. + Note: + This function is deprecated, please use ``IBMQBackend.run()`` to + submit a job. - Return: - dict: a dictionary with the contents of the job. + Events: + The job has started. Raises: - JobError: if there is an error while requesting the results. + IBMQJobApiError: if there was some unexpected failure in the server. + IBMQJobInvalidStateError: If the job has already been submitted. """ - self._wait_for_completion(timeout, wait) - - try: - job_response = self._get_job() - if not self._qobj_payload: - if self._use_object_storage: - # Attempt to use object storage. - self._qobj_payload = self._api.job_download_qobj_object_storage( - self._job_id) - else: - self._qobj_payload = job_response.get('qObject', {}) - except ApiError as api_err: - raise JobError(str(api_err)) - - return job_response - - def _get_job(self): - """Query the API for retrieving the job complete state, consuming it. - - Returns: - dict: a dictionary with the contents of the result. + if self.job_id() is not None: + raise IBMQJobInvalidStateError("We have already submitted the job!") - Raises: - JobTimeoutError: if the job does not return results before a - specified timeout. - JobError: if something wrong happened in some of the server API - calls. - """ - if self._cancelled: - raise JobError( - 'Job result impossible to retrieve. The job was cancelled.') + warnings.warn("job.submit() is deprecated. Please use " + "IBMQBackend.run() to submit a job.", DeprecationWarning, stacklevel=2) - return self._api.get_job(self._job_id) + def refresh(self) -> None: + """Obtain the latest job information from the API.""" + with api_to_job_error(): + api_response = self._api.job_get(self.job_id()) - def _wait_for_completion(self, timeout=None, wait=5): + saved_model_cls = JobResponseSchema.model_cls + try: + # Load response into a dictionary + JobResponseSchema.model_cls = dict + data = self.schema.load(api_response) + BaseModel.__init__(self, **data) + + # Model attributes. + self._use_object_storage = (self.kind == ApiJobKind.QOBJECT_STORAGE) + self._update_status_position(data.pop('_api_status'), + data.pop('infoQueue', None)) + except ValidationError as ex: + raise IBMQJobApiError("Unexpected return value received from the server.") from ex + finally: + JobResponseSchema.model_cls = saved_model_cls + + def to_dict(self) -> None: + """Serialize the model into a Python dict of simple types.""" + warnings.warn("IBMQJob.to_dict() is not supported and may not work properly.", + stacklevel=2) + return BaseModel.to_dict(self) + + def _wait_for_completion( + self, + timeout: Optional[float] = None, + wait: float = 5, + required_status: Tuple[JobStatus] = JOB_FINAL_STATES + ) -> bool: """Wait until the job progress to a final state such as DONE or ERROR. Args: - timeout (float or None): seconds to wait for job. If None, wait - indefinitely. - wait (float): seconds between queries. + timeout: seconds to wait for job. If None, wait indefinitely. + wait: seconds between queries. + required_status: the final job status required. + + Returns: + True if the final job status matches one of the required states. Raises: JobTimeoutError: if the job does not return results before a specified timeout. """ - self._wait_for_submission(timeout) + if self._status in JOB_FINAL_STATES: + return self._status in required_status - # Attempt to use websocket if available. - if self._use_websockets: - start_time = time.time() + with api_to_job_error(): try: - self._wait_for_final_status_websocket(timeout) - return - except WebsocketError as ex: - logger.warning('Error checking job status using websocket, ' - 'retrying using HTTP.') - logger.debug(ex) - except JobTimeoutError as ex: - logger.warning('Timeout checking job status using websocket, ' - 'retrying using HTTP') - logger.debug(ex) - - # Adjust timeout for HTTP retry. - if timeout is not None: - timeout -= (time.time() - start_time) - - # Use traditional http requests if websocket not available or failed. - self._wait_for_final_status(timeout, wait) - - def _wait_for_submission(self, timeout=60): - """Waits for the request to return a job ID""" - if self._job_id is None: - if self._future is None: - raise JobError("You have to submit the job before doing a job related operation!") - try: - submit_info = self._future.result(timeout=timeout) - if self._future_captured_exception is not None: - raise self._future_captured_exception - except TimeoutError as ex: + status_response = self._api.job_final_status( + self.job_id(), timeout=timeout, wait=wait) + except UserTimeoutExceededError: raise JobTimeoutError( - "Timeout waiting for the job being submitted: {}".format(ex) - ) - if 'error' in submit_info: - self._status = JobStatus.ERROR - self._api_error_msg = str(submit_info['error']) - raise JobError(str(submit_info['error'])) + 'Timeout while waiting for job {}'.format(self._job_id)) + self._update_status_position(ApiJobStatus(status_response['status']), + status_response.get('infoQueue', None)) + # Get all job attributes if the job is done. + if self._status in JOB_FINAL_STATES: + self.refresh() - def _wait_for_final_status(self, timeout=None, wait=5): - """Wait until the job progress to a final state. + return self._status in required_status - Args: - timeout (float or None): seconds to wait for job. If None, wait - indefinitely. - wait (float): seconds between queries. + def _retrieve_result(self) -> Result: + """Retrieve the job result response. + + Returns: + The job result. Raises: - JobTimeoutError: if the job does not return results before a - specified timeout. + IBMQJobApiError: If there was some unexpected failure in the server. + IBMQJobFailureError: If the job failed and partial result could not + be retrieved. """ - start_time = time.time() - while self.status() not in JOB_FINAL_STATES: - elapsed_time = time.time() - start_time - if timeout is not None and elapsed_time >= timeout: - raise JobTimeoutError( - 'Timeout while waiting for job {}'.format(self._job_id)) + # pylint: disable=access-member-before-definition,attribute-defined-outside-init + result_response = None + if not self._result: # type: ignore[has-type] + try: + result_response = self._api.job_result(self.job_id(), self._use_object_storage) + self._result = Result.from_dict(result_response) + except (ModelValidationError, ApiError) as err: + if self._status is JobStatus.ERROR: + raise IBMQJobFailureError('Unable to retrieve job result. Job has failed. ' + 'Use job.error_message() to get more details.') + raise IBMQJobApiError(str(err)) + finally: + # In case partial results are returned or job failure, an error message is cached. + if result_response: + self._check_for_error_message(result_response) + + if self._status is JobStatus.ERROR and not self._result.results: + raise IBMQJobFailureError('Unable to retrieve job result. Job has failed. ' + 'Use job.error_message() to get more details.') - logger.info('status = %s (%d seconds)', self._status, elapsed_time) - time.sleep(wait) + return self._result - def _wait_for_final_status_websocket(self, timeout=None): - """Wait until the job progress to a final state using websockets. + def _check_for_error_message(self, result_response: Dict[str, Any]) -> None: + """Retrieves the error message from the result response. Args: - timeout (float or None): seconds to wait for job. If None, wait - indefinitely. - - Raises: - JobTimeoutError: if the job does not return results before a - specified timeout. + result_response: Dictionary of the result response. """ - # Avoid the websocket invocation if already in a final state. - if self._status in JOB_FINAL_STATES: - return - - try: - status_response = self._api.job_final_status_websocket( - self._job_id, timeout=timeout) - self._update_status(status_response) - except WebsocketTimeoutError: - raise JobTimeoutError( - 'Timeout while waiting for job {}'.format(self._job_id)) + if result_response and result_response['results']: + # If individual errors given + self._job_error_msg = build_error_report(result_response['results']) + elif 'error' in result_response: + self._job_error_msg = result_response['error']['message'] diff --git a/qiskit/providers/ibmq/job/schema.py b/qiskit/providers/ibmq/job/schema.py new file mode 100644 index 000000000..728466a95 --- /dev/null +++ b/qiskit/providers/ibmq/job/schema.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- + +# This code is part of Qiskit. +# +# (C) Copyright IBM 2019. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Schemas for job.""" + +from marshmallow import pre_load +from marshmallow.validate import Range + +from qiskit.validation import BaseSchema +from qiskit.validation.fields import Dict, String, Nested, Integer, Boolean, DateTime +from qiskit.qobj.qobj import QobjSchema +from qiskit.result.models import ResultSchema + +from qiskit.providers.ibmq.utils import to_python_identifier +from qiskit.providers.ibmq.apiconstants import ApiJobKind, ApiJobStatus + +from ..utils.fields import Enum + + +# Mapping between 'API job field': 'IBMQJob attribute', for solving name +# clashes. +FIELDS_MAP = { + 'id': '_job_id', + 'status': '_api_status', + 'backend': '_backend_info', + 'creationDate': '_creation_date', + 'qObject': '_qobj', + 'qObjectResult': '_result', + 'error': '_error', + 'name': '_name', + 'timePerStep': '_time_per_step' +} + + +# Helper schemas. + +class JobResponseBackendSchema(BaseSchema): + """Nested schema for the backend field in JobResponseSchema.""" + + # Required properties + name = String(required=True) + + +class JobResponseErrorSchema(BaseSchema): + """Nested schema for the error field in JobResponseSchema.""" + + # Required properties + code = Integer(required=True) + message = String(required=True) + + +# Endpoint schemas. + +class JobResponseSchema(BaseSchema): + """Schema for IBMQJob. + + Schema for an `IBMQJob`. The following conventions are in use in order to + provide enough flexibility in regards to attributes: + + * the "Required properties" reflect attributes that will always be present + in the model. + * the "Optional properties with a default value" reflect attributes that + are always present in the model, but might contain uninitialized values + depending on the state of the job. + * some properties are prepended by underscore due to name clashes and extra + constraints in the IBMQJob class (for example, existing IBMQJob methods + that have the same name as a response field). + + The schema is used for GET Jobs, GET Jobs/{id}, and POST Jobs responses. + """ + # pylint: disable=invalid-name + + # Required properties. + _creation_date = DateTime(required=True) + kind = Enum(required=True, enum_cls=ApiJobKind) + _job_id = String(required=True) + _api_status = Enum(required=True, enum_cls=ApiJobStatus) + + # Optional properties with a default value. + _name = String(missing=None) + shots = Integer(validate=Range(min=0), missing=None) + _time_per_step = Dict(keys=String, values=String, missing=None) + _result = Nested(ResultSchema, missing=None) + _qobj = Nested(QobjSchema, missing=None) + _error = Nested(JobResponseErrorSchema, missing=None) + + # Optional properties + _backend_info = Nested(JobResponseBackendSchema) + allow_object_storage = Boolean() + error = String() + + @pre_load + def preprocess_field_names(self, data, **_): # type: ignore + """Pre-process the job response fields. + + Rename selected fields of the job response due to name clashes, and + convert from camel-case the rest of the fields. + + TODO: when updating to terra 0.10, check if changes related to + marshmallow 3 allow to use directly `data_key`, as in 0.9 terra + duplicates the unknown keys. + """ + rename_map = {} + for field_name in data: + if field_name in FIELDS_MAP: + rename_map[field_name] = FIELDS_MAP[field_name] + else: + rename_map[field_name] = to_python_identifier(field_name) + + for old_name, new_name in rename_map.items(): + data[new_name] = data.pop(old_name) + + return data diff --git a/qiskit/providers/ibmq/job/utils.py b/qiskit/providers/ibmq/job/utils.py index 627793941..f8fd04d11 100644 --- a/qiskit/providers/ibmq/job/utils.py +++ b/qiskit/providers/ibmq/job/utils.py @@ -15,45 +15,67 @@ """Utilities for working with IBM Q Jobs.""" from datetime import datetime, timezone +from typing import Dict, List, Tuple, Generator, Optional, Any +from contextlib import contextmanager +from qiskit.providers.jobstatus import JobStatus +from qiskit.providers.ibmq.job.exceptions import IBMQJobApiError -def current_utc_time(): +from ..apiconstants import ApiJobStatus +from ..api.exceptions import ApiError + + +API_TO_JOB_STATUS = { + ApiJobStatus.CREATING: JobStatus.INITIALIZING, + ApiJobStatus.CREATED: JobStatus.INITIALIZING, + ApiJobStatus.VALIDATING: JobStatus.VALIDATING, + ApiJobStatus.VALIDATED: JobStatus.QUEUED, + ApiJobStatus.RUNNING: JobStatus.RUNNING, + ApiJobStatus.COMPLETED: JobStatus.DONE, + ApiJobStatus.CANCELLED: JobStatus.CANCELLED, + ApiJobStatus.ERROR_CREATING_JOB: JobStatus.ERROR, + ApiJobStatus.ERROR_VALIDATING_JOB: JobStatus.ERROR, + ApiJobStatus.ERROR_RUNNING_JOB: JobStatus.ERROR +} + + +def current_utc_time() -> str: """Gets the current time in UTC format. Returns: - str: current time in UTC format. + current time in UTC format. """ - datetime.utcnow().replace(tzinfo=timezone.utc).isoformat() + return datetime.utcnow().replace(tzinfo=timezone.utc).isoformat() -def is_job_queued(api_job_status_response): +def is_job_queued(info_queue: Optional[Dict] = None) -> Tuple[bool, int]: """Checks whether a job has been queued or not. Args: - api_job_status_response (dict): status response of the job. + info_queue: queue information from the API response. Returns: - Pair[boolean, int]: a pair indicating if the job is queued and in which + a pair indicating if the job is queued and in which position. """ is_queued, position = False, 0 - if 'infoQueue' in api_job_status_response: - if 'status' in api_job_status_response['infoQueue']: - queue_status = api_job_status_response['infoQueue']['status'] + if info_queue: + if 'status' in info_queue: + queue_status = info_queue['status'] is_queued = queue_status == 'PENDING_IN_QUEUE' - if 'position' in api_job_status_response['infoQueue']: - position = api_job_status_response['infoQueue']['position'] + if 'position' in info_queue: + position = info_queue['position'] return is_queued, position -def build_error_report(results): +def build_error_report(results: List[Dict[str, Any]]) -> str: """Build an user-friendly error report for a failed job. Args: - results (dict): result section of the job response. + results: result section of the job response. Returns: - str: the error report. + the error report. """ error_list = [] for index, result in enumerate(results): @@ -62,3 +84,24 @@ def build_error_report(results): error_report = 'The following experiments failed:\n{}'.format('\n'.join(error_list)) return error_report + + +def api_status_to_job_status(api_status: ApiJobStatus) -> JobStatus: + """Return the corresponding job status for the input API job status. + + Args: + api_status: API job status + + Returns: + job status + """ + return API_TO_JOB_STATUS[api_status] + + +@contextmanager +def api_to_job_error() -> Generator[None, None, None]: + """Convert an ApiError to an IBMQJobApiError.""" + try: + yield + except ApiError as api_err: + raise IBMQJobApiError(str(api_err)) diff --git a/qiskit/providers/ibmq/circuits/__init__.py b/qiskit/providers/ibmq/managed/__init__.py similarity index 78% rename from qiskit/providers/ibmq/circuits/__init__.py rename to qiskit/providers/ibmq/managed/__init__.py index 96dae257d..fbf6cc1d1 100644 --- a/qiskit/providers/ibmq/circuits/__init__.py +++ b/qiskit/providers/ibmq/managed/__init__.py @@ -12,6 +12,7 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. -"""Module for interacting with Circuits.""" +"""Module representing Jobs communicating with IBM Q.""" -from .manager import CircuitsManager +from .ibmqjobmanager import IBMQJobManager +from .managedjobset import ManagedJobSet diff --git a/qiskit/providers/ibmq/managed/exceptions.py b/qiskit/providers/ibmq/managed/exceptions.py new file mode 100644 index 000000000..2255133ee --- /dev/null +++ b/qiskit/providers/ibmq/managed/exceptions.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- + +# This code is part of Qiskit. +# +# (C) Copyright IBM 2018, 2019. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Exception for the job manager modules.""" + +from ..exceptions import IBMQError + + +class IBMQJobManagerError(IBMQError): + """Base class for errors raise by job manager.""" + pass + + +class IBMQJobManagerInvalidStateError(IBMQJobManagerError): + """Errors raised when an operation is invoked in an invalid state.""" + pass + + +class IBMQJobManagerTimeoutError(IBMQJobManagerError): + """Errors raised when a job manager operation times out.""" + pass + + +class IBMQJobManagerJobNotFound(IBMQJobManagerError): + """Errors raised when a job cannot be found.""" + pass + + +class IBMQManagedResultDataNotAvailable(IBMQJobManagerError): + """Errors raised when result data is not available.""" + pass diff --git a/qiskit/providers/ibmq/managed/ibmqjobmanager.py b/qiskit/providers/ibmq/managed/ibmqjobmanager.py new file mode 100644 index 000000000..192e27c84 --- /dev/null +++ b/qiskit/providers/ibmq/managed/ibmqjobmanager.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- + +# This code is part of Qiskit. +# +# (C) Copyright IBM 2019. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Job manager used to manage jobs for IBM Q Experience.""" + +import logging +from typing import List, Optional, Union, Any +from concurrent import futures + +from qiskit.circuit import QuantumCircuit +from qiskit.pulse import Schedule + +from .exceptions import IBMQJobManagerInvalidStateError +from .utils import format_job_details, format_status_counts +from .managedjobset import ManagedJobSet +from ..ibmqbackend import IBMQBackend + +logger = logging.getLogger(__name__) + + +class IBMQJobManager: + """Job manager for IBM Q Experience.""" + + def __init__(self) -> None: + """Creates a new IBMQJobManager instance.""" + self._job_sets = [] # type: List[ManagedJobSet] + self._executor = futures.ThreadPoolExecutor() + + def run( + self, + experiments: Union[List[QuantumCircuit], List[Schedule]], + backend: IBMQBackend, + name: Optional[str] = None, + max_experiments_per_job: Optional[int] = None, + **run_config: Any + ) -> ManagedJobSet: + """Execute a set of circuits or pulse schedules on a backend. + + The circuits or schedules will be split into multiple jobs. Circuits + or schedules in a job will be executed together in each shot. + + A name can be assigned to this job set. Each job in this set will have + a job name consists of the set name followed by an underscore (_) + followed by the job index and another underscore. For example, a job + for set ``foo`` can have a job name of ``foo_1_``. The name can then + be used to retrieve the jobs later. If no name is given, the job + submission datetime will be used. + + Args: + experiments: Circuit(s) or pulse schedule(s) to execute. + backend: Backend to execute the experiments on. + name: Name for this set of jobs. Default: current datetime. + max_experiments_per_job: Maximum number of experiments to run in each job. + If not specified, the default is to use the maximum allowed by + the backend. + If the specified value is greater the maximum allowed by the + backend, the default is used. + run_config: Configuration of the runtime environment. Some + examples of these configuration parameters include: + ``qobj_id``, ``qobj_header``, ``shots``, ``memory``, + ``seed_simulator``, ``qubit_lo_freq``, ``meas_lo_freq``, + ``qubit_lo_range``, ``meas_lo_range``, ``schedule_los``, + ``meas_level``, ``meas_return``, ``meas_map``, + ``memory_slot_size``, ``rep_time``, and ``parameter_binds``. + + Refer to the documentation on ``qiskit.compiler.assemble()`` + for details on these arguments. + + Returns: + Managed job set. + + Raises: + IBMQJobManagerInvalidStateError: If the backend does not support + the experiment type. + """ + if (any(isinstance(exp, Schedule) for exp in experiments) and + not backend.configuration().open_pulse): + raise IBMQJobManagerInvalidStateError("The backend does not support pulse schedules.") + + experiment_list = self._split_experiments( + experiments, backend=backend, max_experiments_per_job=max_experiments_per_job) + + job_set = ManagedJobSet(name=name) + job_set.run(experiment_list, backend=backend, executor=self._executor, **run_config) + self._job_sets.append(job_set) + + return job_set + + def _split_experiments( + self, + experiments: Union[List[QuantumCircuit], List[Schedule]], + backend: IBMQBackend, + max_experiments_per_job: Optional[int] = None + ) -> List[Union[List[QuantumCircuit], List[Schedule]]]: + """Split a list of experiments into sublists. + + Args: + experiments: Experiments to be split. + backend: Backend to execute the experiments on. + max_experiments_per_job: Maximum number of experiments to run in each job. + + Returns: + A list of sublists of experiments. + """ + if hasattr(backend.configuration(), 'max_experiments'): + backend_max = backend.configuration().max_experiments + chunk_size = backend_max if max_experiments_per_job is None \ + else min(backend_max, max_experiments_per_job) + elif max_experiments_per_job: + chunk_size = max_experiments_per_job + else: + return [experiments] + + return [experiments[x:x + chunk_size] for x in range(0, len(experiments), chunk_size)] + + def report(self, detailed: bool = True) -> str: + """Return a report on the statuses of all jobs managed by this manager. + + Args: + detailed: True if a detailed report is be returned. False + if a summary report is to be returned. + + Returns: + A report on job statuses. + """ + job_set_statuses = [job_set.statuses() for job_set in self._job_sets] + flat_status_list = [stat for stat_list in job_set_statuses for stat in stat_list] + + report = ["Summary report:"] + report.extend(format_status_counts(flat_status_list)) + + if detailed: + report.append("\nDetail report:") + for i, job_set in enumerate(self._job_sets): + report.append((" Job set {}:".format(job_set.name()))) + report.extend(format_job_details( + job_set_statuses[i], job_set.managed_jobs())) + + return '\n'.join(report) + + def job_sets(self, name: Optional[str] = None) -> List[ManagedJobSet]: + """Returns a list of managed job sets matching the specified filtering. + + Args: + name: Name of the managed job sets. + + Returns: + A list of managed job sets. + """ + if name: + return [job_set for job_set in self._job_sets if job_set.name() == name] + + return self._job_sets diff --git a/qiskit/providers/ibmq/managed/managedjob.py b/qiskit/providers/ibmq/managed/managedjob.py new file mode 100644 index 000000000..1ca66aae7 --- /dev/null +++ b/qiskit/providers/ibmq/managed/managedjob.py @@ -0,0 +1,186 @@ +# -*- coding: utf-8 -*- + +# This code is part of Qiskit. +# +# (C) Copyright IBM 2019. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Experiments managed by the job manager.""" + +import warnings +import logging +from typing import List, Optional, Union +from concurrent.futures import ThreadPoolExecutor + +from qiskit.circuit import QuantumCircuit +from qiskit.providers.ibmq import IBMQBackend +from qiskit.pulse import Schedule +from qiskit.qobj import Qobj +from qiskit.result import Result +from qiskit.providers.jobstatus import JobStatus +from qiskit.providers.exceptions import JobError, JobTimeoutError + +from ..job.ibmqjob import IBMQJob + +logger = logging.getLogger(__name__) + + +class ManagedJob: + """Job managed by job manager.""" + + def __init__( + self, + experiments: Union[List[QuantumCircuit], List[Schedule]], + start_index: int, + qobj: Qobj, + job_name: str, + backend: IBMQBackend, + executor: ThreadPoolExecutor + ): + """Creates a new ManagedJob instance. + + Args: + experiments: Experiments for the job. + start_index: Starting index of the experiment set. + qobj: Qobj to run. + job_name: Name of the job. + backend: Backend to execute the experiments on. + executor: The thread pool to use. + """ + self.experiments = experiments + self.start_index = start_index + self.end_index = start_index + len(experiments) - 1 + + # Properties that are populated by the future. + self.job = None # type: Optional[IBMQJob] + self.submit_error = None # type: Optional[Exception] + + # Submit the job in its own future. + self.future = executor.submit( + self._async_submit, qobj=qobj, job_name=job_name, backend=backend) + + def _async_submit( + self, + qobj: Qobj, + job_name: str, + backend: IBMQBackend, + ) -> None: + """Run a Qobj asynchronously and populate instance attributes. + + Args: + qobj: Qobj to run. + job_name: Name of the job. + backend: Backend to execute the experiments on. + + Returns: + IBMQJob instance for the job. + """ + try: + self.job = backend.run(qobj=qobj, job_name=job_name) + except Exception as err: # pylint: disable=broad-except + warnings.warn("Unable to submit job for experiments {}-{}: {}".format( + self.start_index, self.end_index, err)) + self.submit_error = err + + def status(self) -> Optional[JobStatus]: + """Query the API for job status. + + Returns: + Current job status, or ``None`` if an error occurred. + """ + if self.submit_error is not None: + return None + + if self.job is None: + # Job not yet submitted + return JobStatus.INITIALIZING + + try: + return self.job.status() + except JobError as err: + warnings.warn( + "Unable to retrieve job status for experiments {}-{}, job ID={}: {} ".format( + self.start_index, self.end_index, self.job.job_id(), err)) + + return None + + def result( + self, + timeout: Optional[float] = None, + partial: bool = False + ) -> Optional[Result]: + """Return the result of the job. + + Args: + timeout: number of seconds to wait for job + partial: If true, attempt to retrieve partial job results. + + Returns: + Result object or ``None`` if result could not be retrieved. + + Raises: + JobTimeoutError: if the job does not return results before a + specified timeout. + """ + result = None + if self.job is not None: + try: + result = self.job.result(timeout=timeout, partial=partial) + except JobTimeoutError: + raise + except JobError as err: + warnings.warn( + "Unable to retrieve job result for experiments {}-{}, job ID={}: {} ".format( + self.start_index, self.end_index, self.job.job_id(), err)) + + return result + + def error_message(self) -> Optional[str]: + """Provide details about the reason of failure. + + Returns: + An error report if the job failed or ``None`` otherwise. + """ + if self.job is None: + return None + try: + return self.job.error_message() + except JobError: + return "Unknown error." + + def cancel(self) -> None: + """Attempt to cancel a job.""" + cancelled = False + cancel_error = "Unknown error" + try: + cancelled = self.job.cancel() + except JobError as err: + cancel_error = str(err) + + if not cancelled: + logger.warning("Unable to cancel job %s for experiments %d-%d: %s", + self.job.job_id(), self.start_index, self.end_index, cancel_error) + + def qobj(self) -> Optional[Qobj]: + """Return the Qobj for this job. + + Returns: + The Qobj for this job or ``None`` if the Qobj could not be retrieved. + """ + if self.job is None: + return None + try: + return self.job.qobj() + except JobError as err: + warnings.warn( + "Unable to retrieve qobj for experiments {}-{}, job ID={}: {} ".format( + self.start_index, self.end_index, self.job.job_id(), err)) + + return None diff --git a/qiskit/providers/ibmq/managed/managedjobset.py b/qiskit/providers/ibmq/managed/managedjobset.py new file mode 100644 index 000000000..c3a091f08 --- /dev/null +++ b/qiskit/providers/ibmq/managed/managedjobset.py @@ -0,0 +1,308 @@ +# -*- coding: utf-8 -*- + +# This code is part of Qiskit. +# +# (C) Copyright IBM 2019. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""A set of jobs being managed by the IBMQJobManager.""" + +from datetime import datetime +from typing import List, Optional, Union, Any, Tuple +from concurrent.futures import ThreadPoolExecutor +import time +import logging + +from qiskit.circuit import QuantumCircuit +from qiskit.pulse import Schedule +from qiskit.compiler import assemble +from qiskit.qobj import Qobj +from qiskit.providers.jobstatus import JobStatus +from qiskit.providers.exceptions import JobTimeoutError + +from .managedjob import ManagedJob +from .managedresults import ManagedResults +from .utils import requires_submit, format_status_counts, format_job_details +from .exceptions import (IBMQJobManagerInvalidStateError, IBMQJobManagerTimeoutError, + IBMQJobManagerJobNotFound) +from ..job import IBMQJob +from ..ibmqbackend import IBMQBackend + +logger = logging.getLogger(__name__) + + +class ManagedJobSet: + """A set of managed jobs.""" + + def __init__(self, name: Optional[str] = None) -> None: + """Creates a new ManagedJobSet instance.""" + self._managed_jobs = [] # type: List[ManagedJob] + self._name = name or datetime.utcnow().isoformat() + self._backend = None # type: Optional[IBMQBackend] + + # Used for caching + self._managed_results = None # type: Optional[ManagedResults] + self._error_msg = None # type: Optional[str] + + def run( + self, + experiment_list: Union[List[List[QuantumCircuit]], List[List[Schedule]]], + backend: IBMQBackend, + executor: ThreadPoolExecutor, + **assemble_config: Any + ) -> None: + """Execute a list of circuits or pulse schedules on a backend. + + Args: + experiment_list : Circuit(s) or pulse schedule(s) to execute. + backend: Backend to execute the experiments on. + executor: The thread pool to use. + assemble_config: Additional arguments used to configure the Qobj + assembly. Refer to the ``qiskit.compiler.assemble`` documentation + for details on these arguments. + + Raises: + IBMQJobManagerInvalidStateError: If the jobs were already submitted. + """ + if self._managed_jobs: + raise IBMQJobManagerInvalidStateError("Jobs were already submitted.") + + self._backend = backend + exp_index = 0 + for i, experiments in enumerate(experiment_list): + qobj = assemble(experiments, backend=backend, **assemble_config) + job_name = "{}_{}_".format(self._name, i) + self._managed_jobs.append( + ManagedJob(experiments, start_index=exp_index, + qobj=qobj, job_name=job_name, backend=backend, + executor=executor) + ) + exp_index += len(experiments) + + def statuses(self) -> List[Union[JobStatus, None]]: + """Return the status of each job. + + Returns: + A list of job statuses. The entry is ``None`` if the job status + cannot be retrieved due to server error. + """ + return [mjob.status() for mjob in self._managed_jobs] + + def report(self, detailed: bool = True) -> str: + """Return a report on current job statuses. + + Args: + detailed: True if a detailed report is be returned. False + if a summary report is to be returned. + + Returns: + A report on job statuses. + """ + statuses = self.statuses() + report = ["Job set {}:".format(self.name()), + "Summary report:"] + report.extend(format_status_counts(statuses)) + + if detailed: + report.append("\nDetail report:") + report.extend(format_job_details(statuses, self._managed_jobs)) + + return '\n'.join(report) + + @requires_submit + def results( + self, + timeout: Optional[float] = None, + partial: bool = False + ) -> ManagedResults: + """Return the results of the jobs. + + This call will block until all job results become available or + the timeout is reached. + + Note: + Some IBMQ job results can be read only once. A second attempt to + query the API for the job will fail, as the job is "consumed". + + The first call to this method in a ``ManagedJobSet`` instance will query + the API and consume any available job results. Subsequent calls to + that instance's method will also return the results, since they are + cached. However, attempting to retrieve the results again in + another instance or session might fail due to the job results + having been consumed. + + When `partial=True`, this method will attempt to retrieve partial + results of failed jobs if possible. In this case, precaution should + be taken when accessing individual experiments, as doing so might + cause an exception. The ``success`` attribute of a + ``ManagedResults`` instance can be used to verify whether it contains + partial results. + + For example: + If one of the experiments failed, trying to get the counts of + the unsuccessful experiment would raise an exception since + there are no counts to return for it: + i.e. + try: + counts = managed_results.get_counts("failed_experiment") + except QiskitError: + print("Experiment failed!") + + Args: + timeout: Number of seconds to wait for job results. + partial: If true, attempt to retrieve partial job results. + + Returns: + A ``ManagedResults`` instance that can be used to retrieve results + for individual experiments. + + Raises: + IBMQJobManagerTimeoutError: if unable to retrieve all job results before the + specified timeout. + """ + if self._managed_results is not None: + return self._managed_results + + start_time = time.time() + original_timeout = timeout + success = True + + # TODO We can potentially make this multithreaded + for mjob in self._managed_jobs: + try: + result = mjob.result(timeout=timeout, partial=partial) + if result is None or not result.success: + success = False + except JobTimeoutError: + raise IBMQJobManagerTimeoutError( + "Timeout waiting for results for experiments {}-{}.".format( + mjob.start_index, self._managed_jobs[-1].end_index)) + + if timeout: + timeout = original_timeout - (time.time() - start_time) + if timeout <= 0: + raise IBMQJobManagerTimeoutError( + "Timeout waiting for results for experiments {}-{}.".format( + mjob.start_index, self._managed_jobs[-1].end_index)) + + self._managed_results = ManagedResults(self, self._backend.name(), success) + + return self._managed_results + + @requires_submit + def error_messages(self) -> Optional[str]: + """Provide details about job failures. + + This call will block until all job results become available. + + Returns: + An error report if one or more jobs failed or ``None`` otherwise. + """ + if self._error_msg: + return self._error_msg + + report = [] # type: List[str] + for i, mjob in enumerate(self._managed_jobs): + msg_list = mjob.error_message() + if not msg_list: + continue + report.append("Experiments {}-{}, job index={}, job ID={}:".format( + mjob.start_index, mjob.end_index, i, mjob.job.job_id())) + for msg in msg_list.split('\n'): + report.append(msg.rjust(len(msg)+2)) + + if not report: + return None + return '\n'.join(report) + + @requires_submit + def cancel(self) -> None: + """Cancel all managed jobs.""" + for mjob in self._managed_jobs: + mjob.cancel() + + @requires_submit + def jobs(self) -> List[Union[IBMQJob, None]]: + """Return a list of submitted jobs. + + Returns: + A list of IBMQJob instances that represents the submitted jobs. The + entry is ``None`` if the job submit failed. + """ + return [mjob.job for mjob in self._managed_jobs] + + @requires_submit + def job( + self, + experiment: Union[str, QuantumCircuit, Schedule, int] + ) -> Tuple[Optional[IBMQJob], int]: + """Returns the job used to submit the experiment and the experiment index. + + For example, if ``IBMQJobManager`` is used to submit 1000 experiments, + and ``IBMQJobManager`` divides them into 2 jobs: job 1 + has experiments 0-499, and job 2 has experiments 500-999. In this + case ``job_set.job(501)`` will return (job2, 1). + + Args: + experiment: the index of the experiment. Several types are + accepted for convenience:: + * str: the name of the experiment. + * QuantumCircuit: the name of the circuit instance will be used. + * Schedule: the name of the schedule instance will be used. + * int: the position of the experiment. + + Returns: + A tuple of the job used to submit the experiment, or ``None`` if + the job submit failed, and the experiment index. + + Raises: + IBMQJobManagerJobNotFound: If the job for the experiment could not + be found. + """ + if isinstance(experiment, int): + for mjob in self._managed_jobs: + if mjob.end_index >= experiment >= mjob.start_index: + return mjob.job, experiment - mjob.start_index + else: + if isinstance(experiment, (QuantumCircuit, Schedule)): + experiment = experiment.name + for mjob in self._managed_jobs: + for i, exp in enumerate(mjob.experiments): + if exp.name == experiment: + return mjob.job, i + + raise IBMQJobManagerJobNotFound("Unable to find the job for experiment {}".format( + experiment)) + + @requires_submit + def qobjs(self) -> List[Qobj]: + """Return the Qobj for the jobs. + + Returns: + A list of Qobj for the jobs. The entry is ``None`` if the Qobj + could not be retrieved. + """ + return [mjob.qobj() for mjob in self._managed_jobs] + + def name(self) -> str: + """Return the name of this set of jobs. + + Returns: + Name of this set of jobs. + """ + return self._name + + def managed_jobs(self) -> List[ManagedJob]: + """Return a list of managed jobs. + + Returns: + A list of managed jobs. + """ + return self._managed_jobs diff --git a/qiskit/providers/ibmq/managed/managedresults.py b/qiskit/providers/ibmq/managed/managedresults.py new file mode 100644 index 000000000..863a17643 --- /dev/null +++ b/qiskit/providers/ibmq/managed/managedresults.py @@ -0,0 +1,192 @@ +# -*- coding: utf-8 -*- + +# This code is part of Qiskit. +# +# (C) Copyright IBM 2019. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Results managed by the job manager.""" + +from typing import List, Optional, Union, Tuple, Dict + +from qiskit.result import Result +from qiskit.circuit import QuantumCircuit +from qiskit.pulse import Schedule + +from .exceptions import IBMQManagedResultDataNotAvailable +from ..job.exceptions import JobError + + +class ManagedResults: + """Results managed by job manager. + + This class is a wrapper around the `Result` class. It provides the same + methods as the `Result` class. Please refer to the `Result` class for + more information on the methods. + """ + + def __init__( + self, + job_set: 'ManagedJobSet', # type: ignore[name-defined] + backend_name: str, + success: bool + ): + """Creates a new ManagedResults instance. + + Args: + job_set: Managed job set for these results. + backend_name: Name of the backend used to run the experiments. + success: True if all experiments were successful and results + available. False otherwise. + """ + self._job_set = job_set + self.backend_name = backend_name + self.success = success + + def data(self, experiment: Union[str, QuantumCircuit, Schedule, int]) -> Dict: + """Get the raw data for an experiment. + + Args: + experiment: the index of the experiment. Several types are + accepted for convenience:: + * str: the name of the experiment. + * QuantumCircuit: the name of the circuit instance will be used. + * Schedule: the name of the schedule instance will be used. + * int: the position of the experiment. + + Returns: + Refer to the ``Result.data()`` documentation for return information. + + Raises: + IBMQManagedResultDataNotAvailable: If data for the experiment could not be retrieved. + IBMQJobManagerJobNotFound: If the job for the experiment could not + be found. + """ + result, exp_index = self._get_result(experiment) + return result.data(exp_index) + + def get_memory( + self, + experiment: Union[str, QuantumCircuit, Schedule, int] + ) -> Union[list, 'numpy.ndarray']: # type: ignore[name-defined] + """Get the sequence of memory states (readouts) for each shot. + The data from the experiment is a list of format + ['00000', '01000', '10100', '10100', '11101', '11100', '00101', ..., '01010'] + + Args: + experiment: the index of the experiment, as specified by ``data()``. + + Returns: + Refer to the ``Result.get_memory()`` documentation for return information. + + Raises: + IBMQManagedResultDataNotAvailable: If data for the experiment could not be retrieved. + IBMQJobManagerJobNotFound: If the job for the experiment could not + be found. + """ + result, exp_index = self._get_result(experiment) + return result.get_memory(exp_index) + + def get_counts( + self, + experiment: Union[str, QuantumCircuit, Schedule, int] + ) -> Dict[str, int]: + """Get the histogram data of an experiment. + + Args: + experiment: the index of the experiment, as specified by ``data()``. + + Returns: + Refer to the ``Result.get_counts()`` documentation for return information. + + Raises: + IBMQManagedResultDataNotAvailable: If data for the experiment could not be retrieved. + IBMQJobManagerJobNotFound: If the job for the experiment could not + be found. + """ + result, exp_index = self._get_result(experiment) + return result.get_counts(exp_index) + + def get_statevector( + self, + experiment: Union[str, QuantumCircuit, Schedule, int], + decimals: Optional[int] = None + ) -> List[complex]: + """Get the final statevector of an experiment. + + Args: + experiment: the index of the experiment, as specified by ``data()``. + decimals: the number of decimals in the statevector. + If None, does not round. + + Returns: + Refer to the ``Result.get_statevector()`` documentation for return information. + + Raises: + IBMQManagedResultDataNotAvailable: If data for the experiment could not be retrieved. + IBMQJobManagerJobNotFound: If the job for the experiment could not + be found. + """ + result, exp_index = self._get_result(experiment) + return result.get_statevector(experiment=exp_index, decimals=decimals) + + def get_unitary( + self, + experiment: Union[str, QuantumCircuit, Schedule, int], + decimals: Optional[int] = None + ) -> List[List[complex]]: + """Get the final unitary of an experiment. + + Args: + experiment: the index of the experiment, as specified by ``data()``. + decimals: the number of decimals in the unitary. + If None, does not round. + + Returns: + Refer to the ``Result.get_unitary()`` documentation for return information. + + Raises: + IBMQManagedResultDataNotAvailable: If data for the experiment could not be retrieved. + IBMQJobManagerJobNotFound: If the job for the experiment could not + be found. + """ + result, exp_index = self._get_result(experiment) + return result.get_unitary(experiment=exp_index, decimals=decimals) + + def _get_result( + self, + experiment: Union[str, QuantumCircuit, Schedule, int] + ) -> Tuple[Result, int]: + """Get the result of the job used to submit the experiment. + + Args: + experiment: the index of the experiment, as specified by ``data()``. + + Returns: + A tuple of the result of the job used to submit the experiment and + the experiment index within the job. + + Raises: + IBMQManagedResultDataNotAvailable: If data for the experiment could not be retrieved. + IBMQJobManagerJobNotFound: If the job for the experiment could not + be found. + """ + + (job, exp_index) = self._job_set.job(experiment) + if job is None: + raise IBMQManagedResultDataNotAvailable( + "Job for experiment {} was not successfully submitted.".format(experiment)) + + try: + result = job.result() + return result, exp_index + except JobError as err: + raise IBMQManagedResultDataNotAvailable( + "Result data for experiment {} is not available.".format(experiment)) from err diff --git a/qiskit/providers/ibmq/managed/utils.py b/qiskit/providers/ibmq/managed/utils.py new file mode 100644 index 000000000..0dd09f402 --- /dev/null +++ b/qiskit/providers/ibmq/managed/utils.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- + +# This code is part of Qiskit. +# +# (C) Copyright IBM 2019. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Utility functions for IBMQJobManager.""" + +from typing import Callable, Any, List, Union +from functools import wraps +from collections import Counter +from concurrent.futures import wait + +from qiskit.providers.jobstatus import JobStatus + +from .managedjob import ManagedJob + + +def requires_submit(func: Callable) -> Callable: + """Decorator used by ManagedJobSet to wait for all jobs to be submitted. + + Args: + func (callable): function to be decorated. + + Returns: + callable: the decorated function. + + Raises: + IBMQJobManagerInvalidStateError: If jobs have not been submitted. + """ + @wraps(func) + def _wrapper( + job_set: 'ManagedJobSet', # type: ignore[name-defined] + *args: Any, + **kwargs: Any + ) -> Any: + """Wrapper function. + + Args: + job_set: ManagedJobSet instance used to manage a set of jobs. + args: arguments to be passed to the decorated function. + kwargs: keyword arguments to be passed to the decorated function. + + Returns: + return value of the decorated function. + """ + futures = [managed_job.future for managed_job in job_set._managed_jobs] + wait(futures) + return func(job_set, *args, **kwargs) + + return _wrapper + + +def format_status_counts(statuses: List[Union[JobStatus, None]]) -> List[str]: + """Format summary report on job statuses. + + Args: + statuses: Statuses of the jobs. + + Returns: + Formatted job status report. + """ + counts = Counter(statuses) # type: Counter + report = [ + " Total jobs: {}".format(len(statuses)), + " Successful jobs: {}".format(counts[JobStatus.DONE]), + " Failed jobs: {}".format(counts[JobStatus.ERROR]), + " Cancelled jobs: {}".format(counts[JobStatus.CANCELLED]), + " Running jobs: {}".format(counts[JobStatus.RUNNING]), + " Pending jobs: {}".format(counts[JobStatus.INITIALIZING] + + counts[JobStatus.VALIDATING] + + counts[JobStatus.QUEUED]) + ] + + return report + + +def format_job_details( + statuses: List[Union[JobStatus, None]], + managed_jobs: List[ManagedJob] +) -> List[str]: + """Format detailed report for jobs. + + Args: + statuses: Statuses of the jobs. + managed_jobs: Jobs being managed. + + Returns: + Formatted job details. + """ + report = [] + for i, mjob in enumerate(managed_jobs): + report.append(" experiments: {}-{}".format(mjob.start_index, mjob.end_index)) + report.append(" job index: {}".format(i)) + if not mjob.future.done(): + report.append(" status: {}".format(JobStatus.INITIALIZING.value)) + continue + if mjob.submit_error is not None: + report.append(" status: job submit failed: {}".format( + str(mjob.submit_error))) + continue + + job = mjob.job + report.append(" job ID: {}".format(job.job_id())) + report.append(" name: {}".format(job.name())) + status_txt = statuses[i].value if statuses[i] else "Unknown" + report.append(" status: {}".format(status_txt)) + + if statuses[i] is JobStatus.QUEUED: + report.append(" queue position: {}".format(job.queue_position())) + elif statuses[i] is JobStatus.ERROR: + report.append(" error_message:") + msg_list = job.error_message().split('\n') + for msg in msg_list: + report.append(msg.rjust(len(msg)+6)) + + return report diff --git a/qiskit/providers/ibmq/utils/__init__.py b/qiskit/providers/ibmq/utils/__init__.py index 0fa5f3ec8..ce4366db5 100644 --- a/qiskit/providers/ibmq/utils/__init__.py +++ b/qiskit/providers/ibmq/utils/__init__.py @@ -14,5 +14,5 @@ """Utilities related to the IBMQ Provider.""" -from .deprecation import deprecated from .qobj_utils import update_qobj_config +from .utils import to_python_identifier diff --git a/qiskit/providers/ibmq/utils/deprecation.py b/qiskit/providers/ibmq/utils/deprecation.py deleted file mode 100644 index 0eb668934..000000000 --- a/qiskit/providers/ibmq/utils/deprecation.py +++ /dev/null @@ -1,56 +0,0 @@ -# -*- coding: utf-8 -*- - -# This code is part of Qiskit. -# -# (C) Copyright IBM 2017, 2019. -# -# This code is licensed under the Apache License, Version 2.0. You may -# obtain a copy of this license in the LICENSE.txt file in the root directory -# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. -# -# Any modifications or derivative works of this code must retain this -# copyright notice, and modified files need to carry a notice indicating -# that they have been altered from the originals. - -"""Utilities for transitioning from IBM Q Experience v1 to v2.""" - -import warnings -from functools import wraps - -from qiskit.providers.ibmq.exceptions import IBMQAccountError - -UPDATE_ACCOUNT_TEXT = ( - 'Please update your accounts and programs by following the instructions here:\n' - 'https://github.com/Qiskit/qiskit-ibmq-provider#updating-to-the-new-ibm-q-experience') - - -def deprecated(func): - """Decorator that signals that the function has been deprecated. - - Args: - func (callable): function to be decorated. - - Returns: - callable: the decorated function. - """ - - @wraps(func) - def _wrapper(self, *args, **kwargs): - # The special case of load_accounts is here for backward - # compatibility when using v2 credentials. - if self._credentials and func.__name__ != 'load_accounts': - raise IBMQAccountError( - 'IBMQ.{}() is not available when using an IBM Q Experience ' - 'v2 account. Please use IBMQ.{}() (note the singular form) ' - 'instead.'.format(func.__name__, func.__name__[:-1])) - - warnings.warn( - 'IBMQ.{}() is being deprecated. Please use IBM Q Experience v2 ' - 'credentials and IBMQ.{}() (note the singular form) instead. You can ' - 'find the instructions to make the updates here: \n' - 'https://github.com/Qiskit/qiskit-ibmq-provider#updating-to-the-new-ibm-q-experience' - .format(func.__name__, func.__name__[:-1]), - DeprecationWarning) - return func(self, *args, **kwargs) - - return _wrapper diff --git a/qiskit/providers/ibmq/utils/fields.py b/qiskit/providers/ibmq/utils/fields.py new file mode 100644 index 000000000..17c4afe90 --- /dev/null +++ b/qiskit/providers/ibmq/utils/fields.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- + +# This code is part of Qiskit. +# +# (C) Copyright IBM 2019. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Custom fields for validation.""" + +import enum +from typing import Dict, Any + +from qiskit.validation import ModelTypeValidator, BaseModel + + +class Enum(ModelTypeValidator): + """Field for enums.""" + + default_error_messages = { + 'invalid': '"{input}" cannot be parsed as a {enum_cls}.', + 'format': '"{input}" cannot be formatted as a {enum_cls}.', + } + + def __init__(self, enum_cls: enum.EnumMeta, *args: Any, **kwargs: Any) -> None: + self.valid_types = (enum_cls,) + self.valid_strs = [elem.value for elem in enum_cls] # type: ignore[var-annotated] + self.enum_cls = enum_cls + + super().__init__(*args, **kwargs) + + def _serialize( # type: ignore[return] + self, + value: Any, + attr: str, + obj: BaseModel, + **_: Any + ) -> str: + try: + return value.value + except AttributeError: + # TODO: change to self.make_error_serialize after #3228 + self.make_error('format', input=value, enum_cls=self.enum_cls) + + def _deserialize( # type: ignore[return] + self, + value: Any, + attr: str, + data: Dict[str, Any], + **_: Any + ) -> enum.EnumMeta: + try: + return self.enum_cls(value) + except ValueError: + self.fail('invalid', input=value, enum_cls=self.enum_cls) diff --git a/qiskit/providers/ibmq/utils/qobj_utils.py b/qiskit/providers/ibmq/utils/qobj_utils.py index 691e4149f..05f451d22 100644 --- a/qiskit/providers/ibmq/utils/qobj_utils.py +++ b/qiskit/providers/ibmq/utils/qobj_utils.py @@ -14,18 +14,20 @@ """Utilities related to Qobj.""" -from qiskit.qobj import QobjHeader +from typing import Dict, Any, Optional +from qiskit.qobj import QobjHeader, Qobj -def _serialize_noise_model(config): + +def _serialize_noise_model(config: Dict[str, Any]) -> Dict[str, Any]: """Traverse the dictionary looking for noise_model keys and apply a transformation so it can be serialized. Args: - config (dict): The dictionary to traverse + config: The dictionary to traverse Returns: - dict: The transformed dictionary + The transformed dictionary """ for k, v in config.items(): if isinstance(config[k], dict): @@ -42,16 +44,20 @@ def _serialize_noise_model(config): return config -def update_qobj_config(qobj, backend_options=None, noise_model=None): +def update_qobj_config( + qobj: Qobj, + backend_options: Optional[Dict] = None, + noise_model: Any = None +) -> Qobj: """Update a Qobj configuration from options and noise model. Args: - qobj (Qobj): description of job - backend_options (dict): backend options - noise_model (NoiseModel): noise model + qobj: description of job + backend_options: backend options + noise_model: noise model Returns: - Qobj: qobj. + qobj. """ config = qobj.config.to_dict() diff --git a/qiskit/providers/ibmq/utils/utils.py b/qiskit/providers/ibmq/utils/utils.py new file mode 100644 index 000000000..4e296fca1 --- /dev/null +++ b/qiskit/providers/ibmq/utils/utils.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- + +# This code is part of Qiskit. +# +# (C) Copyright IBM 2019. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""General utility functions.""" + +import re +import keyword + + +def to_python_identifier(name: str) -> str: + """Convert a name to a valid Python identifier. + + Args: + name: Name to be converted. + + Returns: + Name that is a valid Python identifier. + """ + # Python identifiers can only contain alphanumeric characters + # and underscores and cannot start with a digit. + pattern = re.compile(r"\W|^(?=\d)", re.ASCII) + if not name.isidentifier(): + name = re.sub(pattern, '_', name) + + # Convert to snake case + name = re.sub('((?<=[a-z0-9])[A-Z]|(?!^)(?=2.3,<2.4 pylintfileheader>=0.0.2 vcrpy -pproxy==1.2.2 +pproxy==1.2.2; python_version <= '3.5' +pproxy==2.1.8; python_version > '3.5' +Sphinx>=1.8.3 +sphinx-rtd-theme>=0.4.0 +sphinx-tabs>=1.1.11 +sphinx-automodapi \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index c91b1e82f..d14388aa2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -nest-asyncio==1.0.0 -qiskit-terra>=0.8 +nest-asyncio>=1.0.0,!=1.1.0 +qiskit-terra>=0.10 requests>=2.19 requests-ntlm>=1.1.0 websockets>=7,<8 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 000000000..b07dad277 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,15 @@ +[pycodestyle] +max-line-length = 100 + +[mypy] +python_version = 3.5 +namespace_packages = True +ignore_missing_imports = True +warn_redundant_casts = True +warn_unreachable = True +strict_equality = True +disallow_untyped_calls = True +disallow_untyped_defs = True +disallow_incomplete_defs = True +strict_optional = False +show_none_errors = False diff --git a/setup.py b/setup.py index be343b2be..524527435 100644 --- a/setup.py +++ b/setup.py @@ -17,8 +17,8 @@ from setuptools import setup requirements = [ - "nest-asyncio==1.0.0", - "qiskit-terra>=0.8", + "nest-asyncio>=1.0.0,!=1.1.0", + "qiskit-terra>=0.10", "requests>=2.19", "requests-ntlm>=1.1.0", "websockets>=7,<8" @@ -64,14 +64,14 @@ keywords="qiskit sdk quantum api ibmq", packages=['qiskit.providers.ibmq', 'qiskit.providers.ibmq.api', - 'qiskit.providers.ibmq.api_v2', - 'qiskit.providers.ibmq.api_v2.clients', - 'qiskit.providers.ibmq.api_v2.rest', - 'qiskit.providers.ibmq.circuits', + 'qiskit.providers.ibmq.api.clients', + 'qiskit.providers.ibmq.api.rest', 'qiskit.providers.ibmq.credentials', 'qiskit.providers.ibmq.job', + 'qiskit.providers.ibmq.managed', 'qiskit.providers.ibmq.utils'], install_requires=requirements, include_package_data=True, - python_requires=">=3.5" + python_requires=">=3.5", + zip_safe=False ) diff --git a/test/decorators.py b/test/decorators.py index 52e259b68..4bf818fc6 100644 --- a/test/decorators.py +++ b/test/decorators.py @@ -19,81 +19,85 @@ from unittest import SkipTest from qiskit.test.testing_options import get_test_options +from qiskit.providers.ibmq import least_busy from qiskit.providers.ibmq.ibmqfactory import IBMQFactory from qiskit.providers.ibmq.credentials import (Credentials, discover_credentials) -def requires_new_api_auth(func): - """Decorator that signals that the test requires new API auth credentials. +def requires_qe_access(func): + """Decorator that signals that the test uses the online API. - Note: this decorator is meant to be used *after* ``requires_qe_access``, as - it depends on the ``qe_url`` parameter. + It involves: + * determines if the test should be skipped by checking environment + variables. + * if the `USE_ALTERNATE_ENV_CREDENTIALS` environment variable is + set, it reads the credentials from an alternative set of environment + variables. + * if the test is not skipped, it reads `qe_token` and `qe_url` from + `Qconfig.py`, environment variables or qiskitrc. + * if the test is not skipped, it appends `qe_token` and `qe_url` as + arguments to the test function. Args: func (callable): test function to be decorated. Returns: callable: the decorated function. - - Raises: - SkipTest: if no new API auth credentials were found. """ @wraps(func) - def _wrapper(*args, **kwargs): - qe_url = kwargs.get('qe_url') - # TODO: provide a way to check it in a more robust way. - if not ('quantum-computing.ibm.com/api' in qe_url and - 'auth' in qe_url): - raise SkipTest( - 'Skipping test that requires new API auth credentials') + def _wrapper(obj, *args, **kwargs): + if get_test_options()['skip_online']: + raise SkipTest('Skipping online tests') - return func(*args, **kwargs) + credentials = _get_credentials() + obj.using_ibmq_credentials = credentials.is_ibmq() + kwargs.update({'qe_token': credentials.token, + 'qe_url': credentials.url}) + + return func(obj, *args, **kwargs) return _wrapper -def requires_classic_api(func): - """Decorator that signals that the test requires classic API credentials. +def requires_provider(func): + """Decorator that signals the test uses the online API, via a provider. - Note: this decorator is meant to be used *after* ``requires_qe_access``, as - it depends on the ``qe_url`` parameter. + This decorator delegates into the `requires_qe_access` decorator, but + instead of the credentials it appends a `provider` argument to the decorated + function. Args: func (callable): test function to be decorated. Returns: callable: the decorated function. - - Raises: - SkipTest: if no classic API credentials were found. """ @wraps(func) + @requires_qe_access def _wrapper(*args, **kwargs): - qe_url = kwargs.get('qe_url') - # TODO: provide a way to check it in a more robust way. - if 'quantum-computing.ibm.com/api' in qe_url: - raise SkipTest( - 'Skipping test that requires classic API auth credentials') + ibmq_factory = IBMQFactory() + qe_token = kwargs.pop('qe_token') + qe_url = kwargs.pop('qe_url') + provider = ibmq_factory.enable_account(qe_token, qe_url) + kwargs.update({'provider': provider}) return func(*args, **kwargs) return _wrapper -def requires_qe_access(func): - """Decorator that signals that the test uses the online API. +def requires_device(func): + """Decorator that retrieves the appropriate backend to use for testing. + + This decorator delegates into the `requires_provider` decorator, but instead of the + provider it appends a `backend` argument to the decorated function. It involves: - * determines if the test should be skipped by checking environment - variables. - * if the `USE_ALTERNATE_ENV_CREDENTIALS` environment variable is - set, it reads the credentials from an alternative set of environment - variables. - * if the test is not skipped, it reads `qe_token` and `qe_url` from - `Qconfig.py`, environment variables or qiskitrc. - * if the test is not skipped, it appends `qe_token` and `qe_url` as - arguments to the test function. + * If the `QE_DEVICE` environment variable is set, the test is to be + run against the backend specified by `QE_DEVICE`. + * If the `QE_DEVICE` environment variable is not set, the test is to + be run against least busy device. Args: func (callable): test function to be decorated. @@ -102,26 +106,39 @@ def requires_qe_access(func): callable: the decorated function. """ @wraps(func) - def _wrapper(obj, *args, **kwargs): - if get_test_options()['skip_online']: - raise SkipTest('Skipping online tests') + @requires_provider + def _wrapper(*args, **kwargs): + provider = kwargs.pop('provider') - credentials = _get_credentials() - obj.using_ibmq_credentials = credentials.is_ibmq() - kwargs.update({'qe_token': credentials.token, - 'qe_url': credentials.url}) + _backend = None + if os.getenv('QE_DEVICE'): + backend_name = os.getenv('QE_DEVICE') + _backend = provider.get_backend(backend_name) + else: + _backend = least_busy(provider.backends(simulator=False)) - return func(obj, *args, **kwargs) + kwargs.update({'backend': _backend}) + + return func(*args, **kwargs) return _wrapper -def requires_provider(func): - """Decorator that signals the test uses the online API, via a provider. +def run_on_device(func): + """Decorator that signals that the test should run on a real or semi-real device. - This decorator delegates into the `requires_qe_access` decorator, but - instead of the credentials it appends a `provider` argument to the - decorated function. + It involves: + * skips the test if online tests are to be skipped. + * if the `USE_STAGING_CREDENTIALS` environment variable is set, then enable + the staging account using credentials specified by the + `QE_STAGING_TOKEN` and `QE_STAGING_URL` environment variables. + Backend name specified by `QE_STAGING_DEVICE`, if set, will + also be used. + * else skips the test if slow tests are to be skipped. + * else enable the account using credentials returned by `_get_credentials()` + and use the backend specified by `QE_DEVICE`, if set. + * if backend value is not already set, use the least busy backend. + * appends arguments `provider` and `backend` to the decorated function. Args: func (callable): test function to be decorated. @@ -130,15 +147,29 @@ def requires_provider(func): callable: the decorated function. """ @wraps(func) - @requires_qe_access - def _wrapper(*args, **kwargs): + def _wrapper(obj, *args, **kwargs): + + if get_test_options()['skip_online']: + raise SkipTest('Skipping online tests') + + if os.getenv('USE_STAGING_CREDENTIALS', ''): + credentials = Credentials(os.getenv('QE_STAGING_TOKEN'), os.getenv('QE_STAGING_URL')) + backend_name = os.getenv('QE_STAGING_DEVICE', None) + else: + if not get_test_options()['run_slow']: + raise SkipTest('Skipping slow tests') + credentials = _get_credentials() + backend_name = os.getenv('QE_DEVICE', None) + + obj.using_ibmq_credentials = credentials.is_ibmq() ibmq_factory = IBMQFactory() - qe_token = kwargs.pop('qe_token') - qe_url = kwargs.pop('qe_url') - provider = ibmq_factory.enable_account(qe_token, qe_url) + provider = ibmq_factory.enable_account(credentials.token, credentials.url) kwargs.update({'provider': provider}) + _backend = provider.get_backend(backend_name) if backend_name else \ + least_busy(provider.backends(simulator=False)) + kwargs.update({'backend': _backend}) - return func(*args, **kwargs) + return func(obj, *args, **kwargs) return _wrapper diff --git a/test/fake_account_client.py b/test/fake_account_client.py new file mode 100644 index 000000000..3fc20d66d --- /dev/null +++ b/test/fake_account_client.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- + +# This code is part of Qiskit. +# +# (C) Copyright IBM 2019. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Fake AccountClient.""" + +# TODO This can probably be merged with the one in test_ibmq_job_states +import time +import copy +from random import randrange + + +VALID_JOB_RESPONSE = { + 'id': 'TEST_ID', + 'kind': 'q-object', + 'status': 'CREATING', + 'creationDate': '2019-01-01T13:15:58.425972' +} + +VALID_STATUS_RESPONSE = { + 'status': 'COMPLETED' +} + +VALID_RESULT_RESPONSE = { + 'backend_name': 'ibmqx2', + 'backend_version': '1.1.1', + 'job_id': 'XC1323XG2', + 'qobj_id': 'Experiment1', + 'success': True, + 'results': [ + { + 'header': { + 'name': 'Bell state', + 'memory_slots': 2, + 'creg_sizes': [['c', 2]], + 'clbit_labels': [['c', 0], ['c', 1]], + 'qubit_labels': [['q', 0], ['q', 1]] + }, + 'shots': 1024, + 'status': 'DONE', + 'success': True, + 'data': { + 'counts': { + '0x0': 484, '0x3': 540 + } + } + } + ] +} + + +class BaseFakeAccountClient: + """Base class for faking the AccountClient.""" + + def __init__(self): + self._jobs = {} + self._result_retrieved = [] + + def list_jobs_statuses(self, *_args, **_kwargs): + """Return a list of statuses of jobs.""" + raise NotImplementedError + + def job_submit(self, *_args, **_kwargs): + """Submit a Qobj to a device.""" + new_job_id = str(time.time()).replace('.', '') + while new_job_id in self._jobs: + new_job_id += "_" + response = copy.deepcopy(VALID_JOB_RESPONSE) + response['id'] = new_job_id + self._jobs[new_job_id] = response + return response + + def job_download_qobj(self, *_args, **_kwargs): + """Retrieve and return a Qobj.""" + raise NotImplementedError + + def job_result(self, job_id, *_args, **_kwargs): + """Return a random job result.""" + if job_id in self._result_retrieved: + raise ValueError("Result already retrieved for job {}!".format(job_id)) + self._result_retrieved.append(job_id) + result = copy.deepcopy(VALID_RESULT_RESPONSE) + result['results'][0]['data']['counts'] = { + '0x0': randrange(1024), '0x3': randrange(1024)} + return result + + def job_get(self, job_id, *_args, **_kwargs): + """Return information about a job.""" + job = self._jobs[job_id] + job['status'] = 'COMPLETED' + return job + + def job_status(self, *_args, **_kwargs): + """Return the status of a job.""" + return VALID_STATUS_RESPONSE + + def job_final_status(self, *_args, **_kwargs): + """Wait until the job progress to a final state.""" + return VALID_STATUS_RESPONSE + + def job_properties(self, *_args, **_kwargs): + """Return the backend properties of a job.""" + raise NotImplementedError + + def job_cancel(self, *_args, **_kwargs): + """Submit a request for cancelling a job.""" + raise NotImplementedError diff --git a/test/ibmq/test_account_client_v2.py b/test/ibmq/test_account_client.py similarity index 65% rename from test/ibmq/test_account_client_v2.py rename to test/ibmq/test_account_client.py index c23009d8a..11cf0c6b2 100644 --- a/test/ibmq/test_account_client_v2.py +++ b/test/ibmq/test_account_client.py @@ -12,19 +12,22 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. -"""Tests for the AccountClient for IBM Q Experience v2.""" +"""Tests for the AccountClient for IBM Q Experience.""" +from unittest import mock import re -from unittest import skip + +from requests.exceptions import RequestException from qiskit.circuit import ClassicalRegister, QuantumCircuit, QuantumRegister from qiskit.compiler import assemble, transpile -from qiskit.providers.ibmq.api_v2.clients import AccountClient, AuthClient -from qiskit.providers.ibmq.api_v2.exceptions import ApiError, RequestsApiError +from qiskit.providers.ibmq.api.clients import AccountClient, AuthClient +from qiskit.providers.ibmq.api.exceptions import ApiError, RequestsApiError from qiskit.providers.ibmq.ibmqfactory import IBMQFactory +from qiskit.providers.jobstatus import JobStatus from ..ibmqtestcase import IBMQTestCase -from ..decorators import requires_new_api_auth, requires_qe_access +from ..decorators import requires_qe_access, requires_device from ..contextmanagers import custom_envs, no_envs @@ -52,7 +55,6 @@ def setUpClass(cls): @classmethod @requires_qe_access - @requires_new_api_auth def _get_provider(cls, qe_token=None, qe_url=None): """Helper for getting account credentials.""" ibmq_factory = IBMQFactory() @@ -63,7 +65,8 @@ def _get_client(self): """Helper for instantiating an AccountClient.""" return AccountClient(self.access_token, self.provider.credentials.url, - self.provider.credentials.websockets_url) + self.provider.credentials.websockets_url, + use_websockets=True) def test_job_submit(self): """Test job_submit, running a job against a simulator.""" @@ -75,7 +78,7 @@ def test_job_submit(self): # Run the job through the AccountClient directly. api = backend._api - job = api.job_submit(backend_name, qobj.to_dict()) + job = api.job_submit(backend_name, qobj.to_dict(), use_object_storage=False) self.assertIn('status', job) self.assertIsNotNone(job['status']) @@ -92,63 +95,93 @@ def test_job_submit_object_storage(self): api = backend._api try: - job = api.job_submit_object_storage(backend_name, qobj.to_dict()) + job = api._job_submit_object_storage(backend_name, qobj.to_dict()) except RequestsApiError as ex: - response = ex.original_exception.response - if response.status_code == 400: - try: - api_code = response.json()['error']['code'] - - # If we reach that point, it means the backend does not - # support qobject storage. - self.assertEqual(api_code, - 'Q_OBJECT_STORAGE_IS_NOT_ALLOWED') - return - except (ValueError, KeyError): - pass + # Get the original connection that was raised. + original_exception = ex.__cause__ + + if isinstance(original_exception, RequestException): + # Get the response from the original request exception. + error_response = original_exception.response # pylint: disable=no-member + if error_response is not None and error_response.status_code == 400: + try: + api_code = error_response.json()['error']['code'] + + # If we reach that point, it means the backend does not + # support qobject storage. + self.assertEqual(api_code, 'Q_OBJECT_STORAGE_IS_NOT_ALLOWED') + return + except (ValueError, KeyError): + pass raise job_id = job['id'] self.assertEqual(job['kind'], 'q-object-external-storage') # Wait for completion. - api.job_final_status_websocket(job_id) + api.job_final_status(job_id) # Fetch results and qobj via object storage. - result = api.job_result_object_storage(job_id) - qobj_downloaded = api.job_download_qobj_object_storage(job_id) + result = api._job_result_object_storage(job_id) + qobj_downloaded = api._job_download_qobj_object_storage(job_id) self.assertEqual(qobj_downloaded, qobj.to_dict()) self.assertEqual(result['status'], 'COMPLETED') - def test_get_status_jobs(self): + def test_job_submit_object_storage_fallback(self): + """Test job_submit fallback when object storage fails.""" + # Create a Qobj. + backend_name = 'ibmq_qasm_simulator' + backend = self.provider.get_backend(backend_name) + circuit = transpile(self.qc1, backend, seed_transpiler=self.seed) + qobj = assemble(circuit, backend, shots=1) + + # Run via the AccountClient, making object storage fail. + api = backend._api + with mock.patch.object(api, '_job_submit_object_storage', + side_effect=Exception()), \ + mock.patch.object(api, '_job_submit_post') as mocked_post: + _ = api.job_submit(backend_name, qobj.to_dict(), use_object_storage=True) + + # Assert the POST has been called. + self.assertEqual(mocked_post.called, True) + + def test_list_jobs_statuses(self): """Check get status jobs by user authenticated.""" api = self._get_client() - jobs = api.get_status_jobs(limit=2) + jobs = api.list_jobs_statuses(limit=2) self.assertEqual(len(jobs), 2) - def test_backend_status(self): + @requires_device + def test_backend_status(self, backend): """Check the status of a real chip.""" - backend_name = ('ibmq_20_tokyo' - if self.using_ibmq_credentials else 'ibmqx2') api = self._get_client() - is_available = api.backend_status(backend_name) + is_available = api.backend_status(backend.name()) self.assertIsNotNone(is_available['operational']) - def test_backend_properties(self): + @requires_device + def test_backend_properties(self, backend): """Check the properties of calibration of a real chip.""" - backend_name = ('ibmq_20_tokyo' - if self.using_ibmq_credentials else 'ibmqx2') api = self._get_client() - properties = api.backend_properties(backend_name) + properties = api.backend_properties(backend.name()) self.assertIsNotNone(properties) - def test_available_backends(self): - """Check the backends available.""" + def test_backend_pulse_defaults(self): + """Check the backend pulse defaults of each backend.""" api = self._get_client() - backends = api.available_backends() - self.assertGreaterEqual(len(backends), 1) + api_backends = api.list_backends() + + for backend_info in api_backends: + backend_name = backend_info['backend_name'] + with self.subTest(backend_name=backend_name): + defaults = api.backend_pulse_defaults(backend_name=backend_name) + is_open_pulse = backend_info['open_pulse'] + + if is_open_pulse: + self.assertTrue(defaults) + else: + self.assertFalse(defaults) def test_exception_message(self): """Check exception has proper message.""" @@ -158,7 +191,7 @@ def test_exception_message(self): api.job_status('foo') raised_exception = exception_context.exception - original_error = raised_exception.original_exception.response.json()['error'] + original_error = raised_exception.__cause__.response.json()['error'] self.assertIn(original_error['message'], raised_exception.message, "Original error message not in raised exception") self.assertIn(original_error['code'], raised_exception.message, @@ -188,7 +221,6 @@ def test_list_backends(self): self.assertEqual(provider_backends, api_backends) - @skip('TODO: reenable after api changes') def test_job_cancel(self): """Test canceling a job.""" backend_name = 'ibmq_qasm_simulator' @@ -200,12 +232,21 @@ def test_job_cancel(self): job = backend.run(qobj) job_id = job.job_id() - try: - api.job_cancel(job_id) - except RequestsApiError as ex: - # TODO: rewrite using assert - if all(err not in str(ex) for err in ['JOB_NOT_RUNNING', 'JOB_NOT_CANCELLED']): - raise + max_retry = 2 + for _ in range(max_retry): + try: + api.job_cancel(job_id) + self.assertEqual(job.status(), JobStatus.CANCELLED) + break + except RequestsApiError as ex: + if 'JOB_NOT_RUNNING' in str(ex): + self.assertEqual(job.status(), JobStatus.DONE) + break + else: + # We may hit the JOB_NOT_CANCELLED error if the job is + # in a temporary, noncancellable state. In this case we'll + # just retry. + self.assertIn('JOB_NOT_CANCELLED', str(ex)) class TestAccountClientJobs(IBMQTestCase): @@ -223,13 +264,13 @@ def setUpClass(cls): backend_name = 'ibmq_qasm_simulator' backend = cls.provider.get_backend(backend_name) cls.client = backend._api - cls.job = cls.client.submit_job(cls._get_qobj(backend).to_dict(), - backend_name) + cls.job = cls.client.job_submit( + backend_name, cls._get_qobj(backend).to_dict(), + use_object_storage=backend.configuration().allow_object_storage) cls.job_id = cls.job['id'] @classmethod @requires_qe_access - @requires_new_api_auth def _get_provider(cls, qe_token=None, qe_url=None): """Helper for getting account credentials.""" ibmq_factory = IBMQFactory() @@ -257,54 +298,6 @@ def test_job_get(self): response = self.client.job_get(self.job_id) self.assertIn('status', response) - @skip('TODO: reenable after api changes') - def test_job_get_includes(self): - """Check the include fields parameter for job_get.""" - # Get the job, including some fields. - self.assertIn('backend', self.job) - self.assertIn('shots', self.job) - job_included = self.client.job_get(self.job_id, - included_fields=['backend', 'shots']) - - # Ensure the response has only the included fields - self.assertEqual({'backend', 'shots'}, set(job_included.keys())) - - @skip('TODO: reenable after api changes') - def test_job_get_excludes(self): - """Check the exclude fields parameter for job_get.""" - # Get the job, excluding a field. - self.assertIn('shots', self.job) - self.assertIn('backend', self.job) - job_excluded = self.client.job_get(self.job_id, excluded_fields=['backend']) - - # Ensure the response only excludes the specified field - self.assertNotIn('backend', job_excluded) - self.assertIn('shots', self.job) - - @skip('TODO: reenable after api changes') - def test_job_get_includes_nonexistent(self): - """Check job_get including nonexistent fields.""" - # Get the job, including an nonexistent field. - self.assertNotIn('dummy_include', self.job) - job_included = self.client.job_get(self.job_id, - included_fields=['dummy_include']) - - # Ensure the response is empty, since no existing fields are included - self.assertFalse(job_included) - - @skip('TODO: reenable after api changes') - def test_job_get_excludes_nonexistent(self): - """Check job_get excluding nonexistent fields.""" - # Get the job, excluding an non-existent field. - self.assertNotIn('dummy_exclude', self.job) - self.assertIn('shots', self.job) - job_excluded = self.client.job_get(self.job_id, - excluded_fields=['dummy_exclude']) - - # Ensure the response only excludes the specified field. We can't do a direct - # comparison against the original job because some fields might have changed. - self.assertIn('shots', job_excluded) - def test_job_status(self): """Test getting job status.""" response = self.client.job_status(self.job_id) @@ -312,13 +305,13 @@ def test_job_status(self): def test_job_final_status_websocket(self): """Test getting a job's final status via websocket.""" - response = self.client.job_final_status_websocket(self.job_id) + response = self.client._job_final_status_websocket(self.job_id) self.assertIn('status', response) def test_job_properties(self): """Test getting job properties.""" # Force the job to finish. - _ = self.client.job_final_status_websocket(self.job_id) + _ = self.client._job_final_status_websocket(self.job_id) response = self.client.job_properties(self.job_id) # Since the job is against a simulator, it will have no properties. @@ -349,14 +342,12 @@ class TestAuthClient(IBMQTestCase): """Tests for the AuthClient.""" @requires_qe_access - @requires_new_api_auth def test_valid_login(self, qe_token, qe_url): """Test valid authenticating against IBM Q.""" client = AuthClient(qe_token, qe_url) self.assertTrue(client.client_api.session.access_token) @requires_qe_access - @requires_new_api_auth def test_url_404(self, qe_token, qe_url): """Test login against a 404 URL""" url_404 = re.sub(r'/api.*$', '/api/TEST_404', qe_url) @@ -364,7 +355,6 @@ def test_url_404(self, qe_token, qe_url): _ = AuthClient(qe_token, url_404) @requires_qe_access - @requires_new_api_auth def test_invalid_token(self, qe_token, qe_url): """Test login using invalid token.""" qe_token = 'INVALID_TOKEN' @@ -372,7 +362,6 @@ def test_invalid_token(self, qe_token, qe_url): _ = AuthClient(qe_token, qe_url) @requires_qe_access - @requires_new_api_auth def test_url_unreachable(self, qe_token, qe_url): """Test login against an invalid (malformed) URL.""" qe_url = 'INVALID_URL' @@ -380,9 +369,28 @@ def test_url_unreachable(self, qe_token, qe_url): _ = AuthClient(qe_token, qe_url) @requires_qe_access - @requires_new_api_auth def test_api_version(self, qe_token, qe_url): """Check the version of the QX API.""" api = AuthClient(qe_token, qe_url) version = api.api_version() self.assertIsNotNone(version) + + @requires_qe_access + def test_user_urls(self, qe_token, qe_url): + """Check the user urls of the QX API.""" + api = AuthClient(qe_token, qe_url) + user_urls = api.user_urls() + self.assertIsNotNone(user_urls) + self.assertTrue('http' in user_urls and 'ws' in user_urls) + + @requires_qe_access + def test_user_hubs(self, qe_token, qe_url): + """Check the user hubs of the QX API.""" + api = AuthClient(qe_token, qe_url) + user_hubs = api.user_hubs() + self.assertIsNotNone(user_hubs) + for user_hub in user_hubs: + with self.subTest(user_hub=user_hub): + self.assertTrue('hub' in user_hub + and 'group' in user_hub + and 'project' in user_hub) diff --git a/test/ibmq/test_circuits.py b/test/ibmq/test_circuits.py deleted file mode 100644 index 1508c24d7..000000000 --- a/test/ibmq/test_circuits.py +++ /dev/null @@ -1,43 +0,0 @@ -# -*- coding: utf-8 -*- - -# This code is part of Qiskit. -# -# (C) Copyright IBM 2018, 2019. -# -# This code is licensed under the Apache License, Version 2.0. You may -# obtain a copy of this license in the LICENSE.txt file in the root directory -# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. -# -# Any modifications or derivative works of this code must retain this -# copyright notice, and modified files need to carry a notice indicating -# that they have been altered from the originals. - -"""Tests for Circuits.""" - -import os - -from qiskit.providers.ibmq.ibmqfactory import IBMQFactory -from qiskit.result import Result - -from ..decorators import requires_new_api_auth, requires_qe_access -from ..ibmqtestcase import IBMQTestCase - - -class TestCircuits(IBMQTestCase): - """Tests IBM Q Circuits.""" - - def setUp(self): - super().setUp() - - if not os.getenv('CIRCUITS_TESTS'): - self.skipTest('Circut tests disable') - - @requires_qe_access - @requires_new_api_auth - def test_circuit_random_uniform(self, qe_token, qe_url): - """Test random_uniform circuit.""" - ibmq_factory = IBMQFactory() - provider = ibmq_factory.enable_account(qe_token, qe_url) - results = provider.circuits.random_uniform(number_of_qubits=4) - - self.assertIsInstance(results, Result) diff --git a/test/ibmq/test_ibmq_backends.py b/test/ibmq/test_ibmq_backends.py index cde57257c..b792dfa0d 100644 --- a/test/ibmq/test_ibmq_backends.py +++ b/test/ibmq/test_ibmq_backends.py @@ -53,6 +53,8 @@ def test_one_qubit_no_operation(self): result_local = self._local_backend.run(qobj).result() for remote_backend in self._remote_backends: + if not remote_backend.status().operational: + continue with self.subTest(backend=remote_backend): result_remote = remote_backend.run(qobj).result() self.assertDictAlmostEqual(result_remote.get_counts(circuit), diff --git a/test/ibmq/test_ibmq_connector.py b/test/ibmq/test_ibmq_connector.py deleted file mode 100644 index 78e64ab5e..000000000 --- a/test/ibmq/test_ibmq_connector.py +++ /dev/null @@ -1,179 +0,0 @@ -# -*- coding: utf-8 -*- - -# This code is part of Qiskit. -# -# (C) Copyright IBM 2017, 2018. -# -# This code is licensed under the Apache License, Version 2.0. You may -# obtain a copy of this license in the LICENSE.txt file in the root directory -# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. -# -# Any modifications or derivative works of this code must retain this -# copyright notice, and modified files need to carry a notice indicating -# that they have been altered from the originals. - -"""Test IBMQConnector.""" - -import re - -from qiskit.circuit import ClassicalRegister, QuantumCircuit, QuantumRegister -from qiskit.compiler import assemble, transpile -from qiskit.providers.ibmq import IBMQ -from qiskit.providers.ibmq.api import (ApiError, BadBackendError, IBMQConnector) - -from ..ibmqtestcase import IBMQTestCase -from ..decorators import requires_classic_api, requires_qe_access - - -class TestIBMQConnector(IBMQTestCase): - """Tests for IBMQConnector.""" - - def setUp(self): - qr = QuantumRegister(2) - cr = ClassicalRegister(2) - self.qc1 = QuantumCircuit(qr, cr, name='qc1') - self.qc2 = QuantumCircuit(qr, cr, name='qc2') - self.qc1.h(qr) - self.qc2.h(qr[0]) - self.qc2.cx(qr[0], qr[1]) - self.qc1.measure(qr[0], cr[0]) - self.qc1.measure(qr[1], cr[1]) - self.qc2.measure(qr[0], cr[0]) - self.qc2.measure(qr[1], cr[1]) - self.seed = 73846087 - - @staticmethod - def _get_api(qe_token, qe_url): - """Helper for instantating an IBMQConnector.""" - return IBMQConnector(qe_token, config={'url': qe_url}) - - @requires_qe_access - @requires_classic_api - def test_api_auth_token(self, qe_token, qe_url): - """Authentication with IBMQ Platform.""" - api = self._get_api(qe_token, qe_url) - credential = api.check_credentials() - self.assertTrue(credential) - - def test_api_auth_token_fail(self): - """Invalid authentication with IBQM Platform.""" - self.assertRaises(ApiError, - IBMQConnector, 'fail') - - @requires_qe_access - @requires_classic_api - def test_api_run_job(self, qe_token, qe_url): - """Test running a job against a simulator.""" - IBMQ.enable_account(qe_token, qe_url) - - backend_name = 'ibmq_qasm_simulator' - backend = IBMQ.get_backend(backend_name) - qobj = assemble(transpile(self.qc1, backend=backend, seed_transpiler=self.seed), - backend=backend, shots=1) - - api = backend._api - job = api.submit_job(qobj.to_dict(), backend_name) - check_status = None - if 'status' in job: - check_status = job['status'] - self.assertIsNotNone(check_status) - - @requires_qe_access - @requires_classic_api - def test_api_run_job_fail_backend(self, qe_token, qe_url): - """Test running a job against an invalid backend.""" - IBMQ.enable_account(qe_token, qe_url) - - backend_name = 'ibmq_qasm_simulator' - backend = IBMQ.get_backend(backend_name) - qobj = assemble(transpile(self.qc1, backend=backend, seed_transpiler=self.seed), - backend=backend, shots=1) - - api = backend._api - self.assertRaises(BadBackendError, api.submit_job, qobj.to_dict(), - 'INVALID_BACKEND_NAME') - - @requires_qe_access - @requires_classic_api - def test_api_get_jobs(self, qe_token, qe_url): - """Check get jobs by user authenticated.""" - api = self._get_api(qe_token, qe_url) - jobs = api.get_jobs(2) - self.assertEqual(len(jobs), 2) - - @requires_qe_access - @requires_classic_api - def test_api_get_status_jobs(self, qe_token, qe_url): - """Check get status jobs by user authenticated.""" - api = self._get_api(qe_token, qe_url) - jobs = api.get_status_jobs(1) - self.assertEqual(len(jobs), 1) - - @requires_qe_access - @requires_classic_api - def test_api_backend_status(self, qe_token, qe_url): - """Check the status of a real chip.""" - backend_name = ('ibmq_20_tokyo' - if self.using_ibmq_credentials else 'ibmqx2') - api = self._get_api(qe_token, qe_url) - is_available = api.backend_status(backend_name) - self.assertIsNotNone(is_available['operational']) - - @requires_qe_access - @requires_classic_api - def test_api_backend_properties(self, qe_token, qe_url): - """Check the properties of calibration of a real chip.""" - backend_name = ('ibmq_20_tokyo' - if self.using_ibmq_credentials else 'ibmqx2') - api = self._get_api(qe_token, qe_url) - - properties = api.backend_properties(backend_name) - self.assertIsNotNone(properties) - - @requires_qe_access - @requires_classic_api - def test_api_backends_available(self, qe_token, qe_url): - """Check the backends available.""" - api = self._get_api(qe_token, qe_url) - backends = api.available_backends() - self.assertGreaterEqual(len(backends), 1) - - @requires_qe_access - @requires_classic_api - def test_qx_api_version(self, qe_token, qe_url): - """Check the version of the QX API.""" - api = self._get_api(qe_token, qe_url) - version = api.api_version() - self.assertIn('new_api', version) - - -class TestAuthentication(IBMQTestCase): - """Tests for the authentication features. - - These tests are in a separate TestCase as they need to control the - instantiation of `IBMQConnector` directly. - """ - @requires_qe_access - @requires_classic_api - def test_url_404(self, qe_token, qe_url): - """Test accessing a 404 URL""" - url_404 = re.sub(r'/api.*$', '/api/TEST_404', qe_url) - with self.assertRaises(ApiError): - _ = IBMQConnector(qe_token, - config={'url': url_404}) - - @requires_qe_access - @requires_classic_api - def test_invalid_token(self, qe_token, qe_url): - """Test using an invalid token""" - qe_token = 'INVALID_TOKEN' - with self.assertRaises(ApiError): - _ = IBMQConnector(qe_token, config={'url': qe_url}) - - @requires_qe_access - @requires_classic_api - def test_url_unreachable(self, qe_token, qe_url): - """Test accessing an invalid URL""" - qe_url = 'INVALID_URL' - with self.assertRaises(ApiError): - _ = IBMQConnector(qe_token, config={'url': qe_url}) diff --git a/test/ibmq/test_ibmq_factory.py b/test/ibmq/test_ibmq_factory.py index 45ed983de..6b4e9da1b 100644 --- a/test/ibmq/test_ibmq_factory.py +++ b/test/ibmq/test_ibmq_factory.py @@ -15,51 +15,48 @@ """Tests for the IBMQFactory.""" import os -import warnings from unittest import skipIf from qiskit.providers.ibmq.accountprovider import AccountProvider -from qiskit.providers.ibmq.api_v2.exceptions import RequestsApiError +from qiskit.providers.ibmq.api.exceptions import RequestsApiError from qiskit.providers.ibmq.exceptions import IBMQAccountError, IBMQApiUrlError from qiskit.providers.ibmq.ibmqfactory import IBMQFactory, QX_AUTH_URL -from qiskit.providers.ibmq.ibmqprovider import IBMQProvider from ..ibmqtestcase import IBMQTestCase -from ..decorators import (requires_qe_access, - requires_new_api_auth, - requires_classic_api) +from ..decorators import requires_qe_access from ..contextmanagers import (custom_qiskitrc, no_file, no_envs, CREDENTIAL_ENV_VARS) - -API1_URL = 'https://quantumexperience.ng.bluemix.net/api' -API2_URL = 'https://api.quantum-computing.ibm.com/api' +API_URL = 'https://api.quantum-computing.ibm.com/api' AUTH_URL = 'https://auth.quantum-computing.ibm.com/api' +API1_URL = 'https://quantumexperience.ng.bluemix.net/api' class TestIBMQFactoryEnableAccount(IBMQTestCase): """Tests for IBMQFactory `enable_account()`.""" @requires_qe_access - @requires_new_api_auth def test_auth_url(self, qe_token, qe_url): - """Test login into an API 2 auth account.""" + """Test login into an auth account.""" ibmq = IBMQFactory() provider = ibmq.enable_account(qe_token, qe_url) self.assertIsInstance(provider, AccountProvider) - @requires_qe_access - @requires_classic_api - def test_api1_url(self, qe_token, qe_url): - """Test login into an API 1 auth account.""" - ibmq = IBMQFactory() - provider = ibmq.enable_account(qe_token, qe_url) - self.assertIsInstance(provider, IBMQProvider) + def test_old_api_url(self): + """Test login into an API v1 auth account.""" + qe_token = 'invalid' + qe_url = API1_URL + + with self.assertRaises(IBMQApiUrlError) as context_manager: + ibmq = IBMQFactory() + ibmq.enable_account(qe_token, qe_url) + + self.assertIn('authentication URL', str(context_manager.exception)) def test_non_auth_url(self): - """Test login into an API 2 non-auth account.""" + """Test login into a non-auth account.""" qe_token = 'invalid' - qe_url = API2_URL + qe_url = API_URL with self.assertRaises(IBMQApiUrlError) as context_manager: ibmq = IBMQFactory() @@ -68,9 +65,9 @@ def test_non_auth_url(self): self.assertIn('authentication URL', str(context_manager.exception)) def test_non_auth_url_with_hub(self): - """Test login into an API 2 non-auth account with h/g/p.""" + """Test login into a non-auth account with h/g/p.""" qe_token = 'invalid' - qe_url = API2_URL + '/Hubs/X/Groups/Y/Projects/Z' + qe_url = API_URL + '/Hubs/X/Groups/Y/Projects/Z' with self.assertRaises(IBMQApiUrlError) as context_manager: ibmq = IBMQFactory() @@ -79,8 +76,7 @@ def test_non_auth_url_with_hub(self): self.assertIn('authentication URL', str(context_manager.exception)) @requires_qe_access - @requires_new_api_auth - def test_api2_after_api2(self, qe_token, qe_url): + def test_enable_twice(self, qe_token, qe_url): """Test login into an already logged-in account.""" ibmq = IBMQFactory() ibmq.enable_account(qe_token, qe_url) @@ -91,9 +87,8 @@ def test_api2_after_api2(self, qe_token, qe_url): self.assertIn('already', str(context_manager.exception)) @requires_qe_access - @requires_new_api_auth - def test_api1_after_api2(self, qe_token, qe_url): - """Test login into API 1 during an already logged-in API 2 account.""" + def test_enable_twice_invalid(self, qe_token, qe_url): + """Test login into an invalid account during an already logged-in account.""" ibmq = IBMQFactory() ibmq.enable_account(qe_token, qe_url) @@ -105,21 +100,6 @@ def test_api1_after_api2(self, qe_token, qe_url): self.assertIn('already', str(context_manager.exception)) @requires_qe_access - @requires_classic_api - def test_api2_after_api1(self, qe_token, qe_url): - """Test login into API 2 during an already logged-in API 1 account.""" - ibmq = IBMQFactory() - ibmq.enable_account(qe_token, qe_url) - - with self.assertRaises(IBMQAccountError) as context_manager: - qe_token_api2 = 'invalid' - qe_url_api2 = AUTH_URL - ibmq.enable_account(qe_token_api2, qe_url_api2) - - self.assertIn('already', str(context_manager.exception)) - - @requires_qe_access - @requires_new_api_auth def test_pass_unreachable_proxy(self, qe_token, qe_url): """Test using an unreachable proxy while enabling an account.""" proxies = { @@ -134,155 +114,43 @@ def test_pass_unreachable_proxy(self, qe_token, qe_url): self.assertIn('ProxyError', str(context_manager.exception)) -class TestIBMQFactoryDeprecation(IBMQTestCase): - """Tests for IBMQFactory deprecated methods.""" - - @classmethod - def setUpClass(cls): - # Cause all warnings to always be triggered. - warnings.simplefilter("always") - - @requires_qe_access - @requires_classic_api - def test_api1_disable_accounts(self, qe_token, qe_url): - """Test backward compatibility for API 1 disable_accounts().""" - ibmq = IBMQFactory() - ibmq.enable_account(qe_token, qe_url) - - with warnings.catch_warnings(record=True) as warnings_list: - accounts = ibmq.active_accounts()[0] - self.assertEqual(accounts['token'], qe_token) - self.assertEqual(accounts['url'], qe_url) - ibmq.disable_accounts() - number_of_accounts = len(ibmq.active_accounts()) - - self.assertEqual(number_of_accounts, 0) - self.assertEqual(len(warnings_list), 3) - for warn in warnings_list: - self.assertTrue(issubclass(warn.category, DeprecationWarning)) - - @skipIf(os.name == 'nt', 'Test not supported in Windows') - @requires_qe_access - @requires_classic_api - def test_api1_load_accounts(self, qe_token, qe_url): - """Test backward compatibility for API 1 load_accounts().""" - ibmq_factory = IBMQFactory() - - with no_file('Qconfig.py'), custom_qiskitrc(), no_envs(CREDENTIAL_ENV_VARS): - with self.assertWarns(DeprecationWarning): - ibmq_factory.save_account(qe_token, qe_url) - - with self.assertWarns(DeprecationWarning): - ibmq_factory.load_accounts() - - self.assertEqual( - list(ibmq_factory._v1_provider._accounts.values())[0].credentials.token, - qe_token) - - @skipIf(os.name == 'nt', 'Test not supported in Windows') - def test_api1_delete_accounts(self): - """Test backward compatibility for API 1 delete_accounts().""" - ibmq_provider = IBMQProvider() - ibmq_factory = IBMQFactory() - - with custom_qiskitrc(): - ibmq_provider.save_account('QISKITRC_TOKEN', url=API1_URL) - - with self.assertWarns(DeprecationWarning): - ibmq_factory.delete_accounts() - with self.assertWarns(DeprecationWarning): - stored_accounts = ibmq_factory.stored_accounts() - - self.assertEqual(len(stored_accounts), 0) - - @requires_qe_access - @requires_classic_api - def test_api1_backends(self, qe_token, qe_url): - """Test backward compatibility for API 1 backends().""" - ibmq = IBMQFactory() - ibmq.enable_account(qe_token, qe_url) - - ibmq_provider = IBMQProvider() - ibmq_provider.enable_account(qe_token, qe_url) - ibmq_provider_backend_names = [b.name() for b in ibmq_provider.backends()] - - with self.assertWarns(DeprecationWarning): - ibmq_backend_names = [b.name() for b in ibmq.backends()] - - self.assertEqual(set(ibmq_backend_names), - set(ibmq_provider_backend_names)) - - @requires_qe_access - @requires_classic_api - def test_api1_get_backend(self, qe_token, qe_url): - """Test backward compatibility for API 1 get_backend().""" - ibmq = IBMQFactory() - ibmq.enable_account(qe_token, qe_url) - - ibmq_provider = IBMQProvider() - ibmq_provider.enable_account(qe_token, qe_url) - backend = ibmq_provider.backends()[0] - - with self.assertWarns(DeprecationWarning): - ibmq_backend = ibmq.get_backend(backend.name()) - - self.assertEqual(backend.name(), ibmq_backend.name()) - - @skipIf(os.name == 'nt', 'Test not supported in Windows') class TestIBMQFactoryAccounts(IBMQTestCase): """Tests for the IBMQ account handling.""" @classmethod def setUpClass(cls): - cls.v2_token = 'API2_TOKEN' - cls.v1_token = 'API1_TOKEN' + cls.token = 'API_TOKEN' def setUp(self): super().setUp() # Reference for saving accounts. self.factory = IBMQFactory() - self.provider = IBMQProvider() - def test_save_account_v2(self): - """Test saving an API 2 account.""" + def test_save_account(self): + """Test saving an account.""" with custom_qiskitrc(): - self.factory.save_account(self.v2_token, url=AUTH_URL) + self.factory.save_account(self.token, url=AUTH_URL) stored_cred = self.factory.stored_account() - self.assertEqual(stored_cred['token'], self.v2_token) + self.assertEqual(stored_cred['token'], self.token) self.assertEqual(stored_cred['url'], AUTH_URL) - def test_stored_account_v1(self): - """Test listing a stored API 1 account.""" + def test_delete_account(self): + """Test deleting an account.""" with custom_qiskitrc(): - self.provider.save_account(self.v1_token, url=API1_URL) - with self.assertRaises(IBMQAccountError): - self.factory.stored_account() - - def test_delete_account_v2(self): - """Test deleting an API 2 account.""" - with custom_qiskitrc(): - self.factory.save_account(self.v2_token, url=AUTH_URL) + self.factory.save_account(self.token, url=AUTH_URL) self.factory.delete_account() stored_cred = self.factory.stored_account() self.assertEqual(len(stored_cred), 0) - def test_delete_account_v1(self): - """Test deleting an API 1 account.""" - with custom_qiskitrc(): - self.provider.save_account(self.v1_token, url=API1_URL) - with self.assertRaises(IBMQAccountError): - self.factory.delete_account() - @requires_qe_access - @requires_new_api_auth - def test_load_account_v2(self, qe_token, qe_url): - """Test loading an API 2 account.""" + def test_load_account(self, qe_token, qe_url): + """Test loading an account.""" if qe_url != QX_AUTH_URL: - # .save_account() expects an auth 2 production URL. + # .save_account() expects an auth production URL. self.skipTest('Test requires production auth URL') with no_file('Qconfig.py'), custom_qiskitrc(), no_envs(CREDENTIAL_ENV_VARS): @@ -291,35 +159,17 @@ def test_load_account_v2(self, qe_token, qe_url): self.assertEqual(self.factory._credentials.token, qe_token) self.assertEqual(self.factory._credentials.url, qe_url) - self.assertEqual(self.factory._v1_provider._accounts, {}) - - def test_load_account_v1(self): - """Test loading an API 1 account.""" - with no_file('Qconfig.py'), custom_qiskitrc(), no_envs(CREDENTIAL_ENV_VARS): - self.provider.save_account(self.v1_token, url=API1_URL) - with self.assertRaises(IBMQAccountError): - self.factory.load_account() @requires_qe_access - @requires_new_api_auth - def test_disable_account_v2(self, qe_token, qe_url): - """Test disabling an API 2 account """ + def test_disable_account(self, qe_token, qe_url): + """Test disabling an account """ self.factory.enable_account(qe_token, qe_url) self.factory.disable_account() self.assertIsNone(self.factory._credentials) @requires_qe_access - @requires_classic_api - def test_disable_account_v1(self, qe_token, qe_url): - """Test disabling an API 1 account """ - self.factory.enable_account(qe_token, qe_url) - with self.assertRaises(IBMQAccountError): - self.factory.disable_account() - - @requires_qe_access - @requires_new_api_auth - def test_active_account_v2(self, qe_token, qe_url): - """Test active_account for an API 2 account """ + def test_active_account(self, qe_token, qe_url): + """Test active_account for an account """ self.assertIsNone(self.factory.active_account()) self.factory.enable_account(qe_token, qe_url) @@ -328,20 +178,29 @@ def test_active_account_v2(self, qe_token, qe_url): self.assertEqual(active_account['token'], qe_token) self.assertEqual(active_account['url'], qe_url) - @requires_qe_access - @requires_classic_api - def test_active_account_v1(self, qe_token, qe_url): - """Test active_account for an API 1 account """ - self.factory.enable_account(qe_token, qe_url) - with self.assertRaises(IBMQAccountError): - self.factory.active_account() + def test_save_none_token(self): + """Test saving an account with token=None. See #391""" + with self.assertRaises(IBMQApiUrlError) as context_manager: + self.factory.save_account(None) + self.assertIn('Invalid token found', str(context_manager.exception)) + + def test_save_empty_token(self): + """Test saving an account with token=''. See #391""" + with self.assertRaises(IBMQApiUrlError) as context_manager: + self.factory.save_account('') + self.assertIn('Invalid token found', str(context_manager.exception)) + + def test_save_zero_token(self): + """Test saving an account with token=0. See #391""" + with self.assertRaises(IBMQApiUrlError) as context_manager: + self.factory.save_account(0) + self.assertIn('Invalid token found', str(context_manager.exception)) class TestIBMQFactoryProvider(IBMQTestCase): """Tests for IBMQFactory provider related methods.""" @requires_qe_access - @requires_new_api_auth def _get_provider(self, qe_token=None, qe_url=None): return self.ibmq.enable_account(qe_token, qe_url) diff --git a/test/ibmq/test_ibmq_integration.py b/test/ibmq/test_ibmq_integration.py index 8e7254810..615a08a4c 100644 --- a/test/ibmq/test_ibmq_integration.py +++ b/test/ibmq/test_ibmq_integration.py @@ -15,13 +15,12 @@ """IBMQ provider integration tests (compile and run).""" from qiskit import ClassicalRegister, QuantumCircuit, QuantumRegister -from qiskit.providers.ibmq import least_busy from qiskit.result import Result from qiskit.execute import execute from qiskit.compiler import assemble, transpile from ..ibmqtestcase import IBMQTestCase -from ..decorators import requires_provider +from ..decorators import requires_provider, requires_device class TestIBMQIntegration(IBMQTestCase): @@ -48,11 +47,9 @@ def test_ibmq_result_fields(self, provider): self.assertEqual(remote_result.status, 'COMPLETED') self.assertEqual(remote_result.results[0].status, 'DONE') - @requires_provider - def test_compile_remote(self, provider): + @requires_device + def test_compile_remote(self, backend): """Test Compiler remote.""" - backend = least_busy(provider.backends()) - qubit_reg = QuantumRegister(2, name='q') clbit_reg = ClassicalRegister(2, name='c') qc = QuantumCircuit(qubit_reg, clbit_reg, name="bell") @@ -63,11 +60,9 @@ def test_compile_remote(self, provider): circuits = transpile(qc, backend=backend) self.assertIsInstance(circuits, QuantumCircuit) - @requires_provider - def test_compile_two_remote(self, provider): + @requires_device + def test_compile_two_remote(self, backend): """Test Compiler remote on two circuits.""" - backend = least_busy(provider.backends()) - qubit_reg = QuantumRegister(2, name='q') clbit_reg = ClassicalRegister(2, name='c') qc = QuantumCircuit(qubit_reg, clbit_reg, name="bell") diff --git a/test/ibmq/test_ibmq_job.py b/test/ibmq/test_ibmq_job.py index c07d1d6d2..0516ba45c 100644 --- a/test/ibmq/test_ibmq_job.py +++ b/test/ibmq/test_ibmq_job.py @@ -2,7 +2,7 @@ # This code is part of Qiskit. # -# (C) Copyright IBM 2017, 2018. +# (C) Copyright IBM 2017, 2019. # # This code is licensed under the Apache License, Version 2.0. You may # obtain a copy of this license in the LICENSE.txt file in the root directory @@ -17,21 +17,28 @@ import time import warnings from concurrent import futures +from datetime import datetime +from unittest import skip import numpy from scipy.stats import chi2_contingency from qiskit import ClassicalRegister, QuantumCircuit, QuantumRegister -from qiskit.providers import JobError, JobStatus +from qiskit.providers import JobStatus from qiskit.providers.ibmq import least_busy +from qiskit.providers.ibmq.ibmqbackend import IBMQRetiredBackend from qiskit.providers.ibmq.exceptions import IBMQBackendError from qiskit.providers.ibmq.ibmqfactory import IBMQFactory from qiskit.providers.ibmq.job.ibmqjob import IBMQJob +from qiskit.providers.ibmq.job.exceptions import (IBMQJobFailureError, + IBMQJobInvalidStateError) from qiskit.test import slow_test from qiskit.compiler import assemble, transpile +from qiskit.result import Result from ..jobtestcase import JobTestCase -from ..decorators import requires_provider, requires_qe_access +from ..decorators import (requires_provider, requires_qe_access, + run_on_device, requires_device) class TestIBMQJob(JobTestCase): @@ -78,11 +85,9 @@ def test_run_simulator(self, provider): self.assertGreater(contingency2[1], 0.01) @slow_test - @requires_provider - def test_run_device(self, provider): + @requires_device + def test_run_device(self, backend): """Test running in a real device.""" - backend = least_busy(provider.backends(simulator=False)) - qobj = assemble(transpile(self._qc, backend=backend), backend=backend) shots = qobj.config.shots job = backend.run(qobj) @@ -98,7 +103,6 @@ def test_run_device(self, provider): # guaranteed to have them. _ = job.properties() - @slow_test @requires_provider def test_run_async_simulator(self, provider): """Test running in a simulator asynchronously.""" @@ -151,13 +155,9 @@ def test_run_async_simulator(self, provider): job_ids = [job.job_id() for job in job_array] self.assertEqual(sorted(job_ids), sorted(list(set(job_ids)))) - @slow_test - @requires_provider - def test_run_async_device(self, provider): + @run_on_device + def test_run_async_device(self, provider, backend): # pylint: disable=unused-argument """Test running in a real device asynchronously.""" - backends = provider.backends(simulator=False) - backend = least_busy(backends) - self.log.info('submitting to backend %s', backend.name()) num_qubits = 5 qr = QuantumRegister(num_qubits, 'qr') @@ -191,7 +191,7 @@ def test_run_async_device(self, provider): self.assertTrue(num_jobs - num_error - num_done > 0) # Wait for all the results. - result_array = [job.result() for job in job_array] + result_array = [job.result(timeout=180) for job in job_array] # Ensure all jobs have finished. self.assertTrue( @@ -202,13 +202,12 @@ def test_run_async_device(self, provider): job_ids = [job.job_id() for job in job_array] self.assertEqual(sorted(job_ids), sorted(list(set(job_ids)))) - @slow_test @requires_provider def test_cancel(self, provider): - """Test job cancelation.""" - backend_name = ('ibmq_20_tokyo' - if self.using_ibmq_credentials else 'ibmqx2') - backend = provider.get_backend(backend_name) + """Test job cancellation.""" + # Find the most busy backend + backend = max([b for b in provider.backends() if b.status().operational], + key=lambda b: b.status().pending_jobs) qobj = assemble(transpile(self._qc, backend=backend), backend=backend) job = backend.run(qobj) @@ -217,61 +216,53 @@ def test_cancel(self, provider): self.assertTrue(can_cancel) self.assertTrue(job.status() is JobStatus.CANCELLED) - @requires_provider - def test_job_id(self, provider): - """Test getting a job id.""" - backend = provider.get_backend('ibmq_qasm_simulator') + @requires_device + def test_get_jobs_from_backend(self, backend): + """Test retrieving jobs from a backend.""" + job_list = backend.jobs(limit=5, skip=0) + for job in job_list: + self.assertTrue(isinstance(job.job_id(), str)) - qobj = assemble(transpile(self._qc, backend=backend), backend=backend) - job = backend.run(qobj) - self.log.info('job_id: %s', job.job_id()) - self.assertTrue(job.job_id() is not None) + @requires_device + @requires_provider + def test_get_jobs_from_backend_service(self, backend, provider): + """Test retrieving jobs from backend service.""" + job_list = provider.backends.jobs(backend_name=backend.name(), limit=5, skip=0) + for job in job_list: + self.assertTrue(isinstance(job.job_id(), str)) @requires_provider - def test_get_backend_name(self, provider): - """Test getting a backend name.""" + def test_retrieve_job_backend(self, provider): + """Test retrieving a single job from a backend.""" backend = provider.get_backend('ibmq_qasm_simulator') - qobj = assemble(transpile(self._qc, backend=backend), backend=backend) job = backend.run(qobj) - self.assertTrue(job.backend().name() == backend.name()) - - @requires_provider - def test_get_jobs_from_backend(self, provider): - """Test retrieving jobs from a backend.""" - backend = least_busy(provider.backends()) - start_time = time.time() - job_list = backend.jobs(limit=5, skip=0) - self.log.info('time to get jobs: %0.3f s', time.time() - start_time) - self.log.info('found %s jobs on backend %s', - len(job_list), backend.name()) - for job in job_list: - self.log.info('status: %s', job.status()) - self.assertTrue(isinstance(job.job_id(), str)) - self.log.info('time to get job statuses: %0.3f s', - time.time() - start_time) + retrieved_job = backend.retrieve_job(job.job_id()) + self.assertEqual(job.job_id(), retrieved_job.job_id()) + self.assertEqual(job.result().get_counts(), retrieved_job.result().get_counts()) + self.assertEqual(job.qobj().to_dict(), qobj.to_dict()) @requires_provider - def test_retrieve_job(self, provider): - """Test retrieving a single job.""" + def test_retrieve_job_backend_service(self, provider): + """Test retrieving a single job from backend service.""" backend = provider.get_backend('ibmq_qasm_simulator') qobj = assemble(transpile(self._qc, backend=backend), backend=backend) job = backend.run(qobj) - rjob = backend.retrieve_job(job.job_id()) - self.assertEqual(job.job_id(), rjob.job_id()) - self.assertEqual(job.result().get_counts(), rjob.result().get_counts()) + retrieved_job = provider.backends.retrieve_job(job.job_id()) + self.assertEqual(job.job_id(), retrieved_job.job_id()) + self.assertEqual(job.result().get_counts(), retrieved_job.result().get_counts()) self.assertEqual(job.qobj().to_dict(), qobj.to_dict()) @slow_test + @requires_device @requires_provider - def test_retrieve_job_uses_appropriate_backend(self, provider): + def test_retrieve_job_uses_appropriate_backend(self, backend, provider): """Test that retrieved jobs come from their appropriate backend.""" simulator_backend = provider.get_backend('ibmq_qasm_simulator') - backends = provider.backends(simulator=False) - real_backend = least_busy(backends) + real_backend = backend qobj_sim = assemble( transpile(self._qc, backend=simulator_backend), backend=simulator_backend) @@ -297,33 +288,60 @@ def test_retrieve_job_uses_appropriate_backend(self, provider): real_backend.retrieve_job, job_sim.job_id()) self.assertIn('belongs to', str(context_manager.warning)) - @requires_provider - def test_retrieve_job_error(self, provider): - """Test retrieving an invalid job.""" - backends = provider.backends(simulator=False) - backend = least_busy(backends) - + @requires_device + def test_retrieve_job_error_backend(self, backend): + """Test retrieving an invalid job from a backend.""" self.assertRaises(IBMQBackendError, backend.retrieve_job, 'BAD_JOB_ID') @requires_provider - def test_get_jobs_filter_job_status(self, provider): + def test_retrieve_job_error_backend_service(self, provider): + """Test retrieving an invalid job from backend service.""" + self.assertRaises(IBMQBackendError, provider.backends.retrieve_job, 'BAD_JOB_ID') + + @requires_device + def test_get_jobs_filter_job_status_backend(self, backend): """Test retrieving jobs from a backend filtered by status.""" - backends = provider.backends(simulator=False) - backend = least_busy(backends) + job_list = backend.jobs(limit=5, skip=0, status=JobStatus.DONE) + for job in job_list: + self.assertTrue(job.status() is JobStatus.DONE) + + @requires_device + @requires_provider + def test_get_jobs_filter_job_status_backend_service(self, backend, provider): + """Test retrieving jobs from backend service filtered by status.""" + job_list = provider.backends.jobs(backend_name=backend.name(), + limit=5, skip=0, status=JobStatus.DONE) + for job in job_list: + self.assertTrue(job.status() is JobStatus.DONE) + + @requires_provider + def test_get_jobs_filter_counts_backend(self, provider): + """Test retrieving jobs from a backend filtered by counts.""" + # TODO: consider generalizing backend name + # TODO: this tests depends on the previous executions of the user + backend = provider.get_backend('ibmq_qasm_simulator') + my_filter = {'backend.name': 'ibmq_qasm_simulator', + 'shots': 1024, + 'qasms.result.data.counts.00': {'lt': 500}} + self.log.info('searching for at most 5 jobs with 1024 shots, a count ' + 'for "00" of < 500, on the ibmq_qasm_simulator backend') with warnings.catch_warnings(): # Disable warnings from pre-qobj jobs. warnings.filterwarnings('ignore', category=DeprecationWarning, module='qiskit.providers.ibmq.ibmqbackend') - job_list = backend.jobs(limit=5, skip=0, status=JobStatus.DONE) + job_list = backend.jobs(limit=5, skip=0, db_filter=my_filter) - for job in job_list: - self.assertTrue(job.status() is JobStatus.DONE) + for i, job in enumerate(job_list): + self.log.info('match #%d', i) + result = job.result() + self.assertTrue(any(cresult.data.counts.to_dict()['0x0'] < 500 + for cresult in result.results)) @requires_provider - def test_get_jobs_filter_counts(self, provider): - """Test retrieving jobs from a backend filtered by counts.""" + def test_get_jobs_filter_counts_backend_service(self, provider): + """Test retrieving jobs from backend service filtered by counts.""" # TODO: consider generalizing backend name # TODO: this tests depends on the previous executions of the user backend = provider.get_backend('ibmq_qasm_simulator') @@ -339,7 +357,8 @@ def test_get_jobs_filter_counts(self, provider): warnings.filterwarnings('ignore', category=DeprecationWarning, module='qiskit.providers.ibmq.ibmqbackend') - job_list = backend.jobs(limit=5, skip=0, db_filter=my_filter) + job_list = provider.backends.jobs(backend_name=backend.name(), + limit=5, skip=0, db_filter=my_filter) for i, job in enumerate(job_list): self.log.info('match #%d', i) @@ -347,18 +366,33 @@ def test_get_jobs_filter_counts(self, provider): self.assertTrue(any(cresult.data.counts.to_dict()['0x0'] < 500 for cresult in result.results)) - @requires_provider - def test_get_jobs_filter_date(self, provider): + @requires_device + def test_get_jobs_filter_date_backend(self, backend): """Test retrieving jobs from a backend filtered by date.""" - backends = provider.backends(simulator=False) - backend = least_busy(backends) - - my_filter = {'creationDate': {'lt': '2017-01-01T00:00:00.00'}} + date_today = datetime.now().isoformat() + my_filter = {'creationDate': {'lt': date_today}} job_list = backend.jobs(limit=5, db_filter=my_filter) + + self.assertTrue(job_list) self.log.info('found %s matching jobs', len(job_list)) for i, job in enumerate(job_list): - self.log.info('match #%d: %s', i, job.creation_date) - self.assertTrue(job.creation_date < '2017-01-01T00:00:00.00') + self.log.info('match #%d: %s', i, job.creation_date()) + self.assertTrue(job.creation_date() < date_today) + + @requires_device + @requires_provider + def test_get_jobs_filter_date_backend_service(self, backend, provider): + """Test retrieving jobs from backend service filtered by date.""" + date_today = datetime.now().isoformat() + my_filter = {'creationDate': {'lt': date_today}} + job_list = provider.backends.jobs(backend_name=backend.name(), + limit=5, db_filter=my_filter) + + self.assertTrue(job_list) + self.log.info('found %s matching jobs', len(job_list)) + for i, job in enumerate(job_list): + self.log.info('match #%d: %s', i, job.creation_date()) + self.assertTrue(job.creation_date() < date_today) @requires_provider def test_double_submit_fails(self, provider): @@ -368,39 +402,56 @@ def test_double_submit_fails(self, provider): qobj = assemble(transpile(self._qc, backend=backend), backend=backend) # backend.run() will automatically call job.submit() job = backend.run(qobj) - with self.assertRaises(JobError): + with self.assertRaises(IBMQJobInvalidStateError): job.submit() @requires_provider - def test_error_message_qasm(self, provider): - """Test retrieving job error messages including QASM status(es).""" + def test_retrieve_failed_job_simulator(self, provider): + """Test retrieving job error messages from a simulator backend.""" backend = provider.get_backend('ibmq_qasm_simulator') - qr = QuantumRegister(5) # 5 is sufficient for this test - cr = ClassicalRegister(2) - qc = QuantumCircuit(qr, cr) - qc.cx(qr[0], qr[1]) - qc_new = transpile(qc, backend) + qc_new = transpile(self._qc, backend) + qobj = assemble([qc_new, qc_new], backend=backend) + qobj.experiments[1].instructions[1].name = 'bad_instruction' - qobj = assemble(qc_new, shots=1000) - qobj.experiments[0].instructions[0].name = 'test_name' + job = backend.run(qobj) + with self.assertRaises(IBMQJobFailureError): + job.result() - job_sim = backend.run(qobj) - with self.assertRaises(JobError): - job_sim.result() + new_job = provider.backends.retrieve_job(job.job_id()) + message = new_job.error_message() + self.assertIn('Experiment 1: ERROR', message) - message = job_sim.error_message() - self.assertTrue(message) + @run_on_device + def test_retrieve_failed_job_device(self, provider, backend): + """Test retrieving a failed job from a device backend.""" + qc_new = transpile(self._qc, backend) + qobj = assemble([qc_new, qc_new], backend=backend) + qobj.experiments[1].instructions[1].name = 'bad_instruction' - @slow_test + job = backend.run(qobj) + with self.assertRaises(IBMQJobFailureError): + job.result(timeout=180) + + new_job = provider.backends.retrieve_job(job.job_id()) + self.assertTrue(new_job.error_message()) + + @skip('Remove skip once simulator returns schema complaint partial results.') @requires_provider - def test_running_job_properties(self, provider): - """Test fetching properties of a running job.""" - backend = least_busy(provider.backends(simulator=False)) + def test_retrieve_failed_job_simulator_partial(self, provider): + """Test retrieving partial results from a simulator backend.""" + backend = provider.get_backend('ibmq_qasm_simulator') + + qc_new = transpile(self._qc, backend) + qobj = assemble([qc_new, qc_new], backend=backend) + qobj.experiments[1].instructions[1].name = 'bad_instruction' - qobj = assemble(transpile(self._qc, backend=backend), backend=backend) job = backend.run(qobj) - _ = job.properties() + result = job.result(partial=True) + + self.assertIsInstance(result, Result) + self.assertTrue(result.results[0].success) + self.assertFalse(result.results[1].success) @slow_test @requires_qe_access @@ -433,6 +484,22 @@ def test_pulse_job(self, qe_token, qe_url): job = backend.run(qobj) _ = job.result() + @requires_provider + def test_retrieve_from_retired_backend(self, provider): + """Test retrieving a job from a retired backend.""" + backend = provider.get_backend('ibmq_qasm_simulator') + qobj = assemble(transpile(self._qc, backend=backend), backend=backend) + job = backend.run(qobj) + + del provider._backends['ibmq_qasm_simulator'] + new_job = provider.backends.retrieve_job(job.job_id()) + self.assertTrue(isinstance(new_job.backend(), IBMQRetiredBackend)) + self.assertNotEqual(new_job.backend().name(), 'unknown') + + new_job2 = provider.backends.jobs(db_filter={'id': job.job_id()})[0] + self.assertTrue(isinstance(new_job2.backend(), IBMQRetiredBackend)) + self.assertNotEqual(new_job2.backend().name(), 'unknown') + def _bell_circuit(): qr = QuantumRegister(2, 'q') diff --git a/test/ibmq/test_ibmq_job_attributes.py b/test/ibmq/test_ibmq_job_attributes.py new file mode 100644 index 000000000..693ef2f91 --- /dev/null +++ b/test/ibmq/test_ibmq_job_attributes.py @@ -0,0 +1,287 @@ +# -*- coding: utf-8 -*- + +# This code is part of Qiskit. +# +# (C) Copyright IBM 2019. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""IBMQJob Test.""" + +import time +from unittest import mock + +from qiskit import ClassicalRegister, QuantumCircuit, QuantumRegister +from qiskit.providers import JobStatus +from qiskit.providers.ibmq.job.exceptions import IBMQJobFailureError, JobError +from qiskit.providers.ibmq.api.clients.account import AccountClient +from qiskit.providers.ibmq.exceptions import IBMQBackendValueError +from qiskit.compiler import assemble, transpile + +from ..jobtestcase import JobTestCase +from ..decorators import requires_provider, run_on_device + + +class TestIBMQJobAttributes(JobTestCase): + """Test ibmqjob module.""" + + def setUp(self): + super().setUp() + self._qc = _bell_circuit() + + @requires_provider + def test_job_id(self, provider): + """Test getting a job id.""" + backend = provider.get_backend('ibmq_qasm_simulator') + + qobj = assemble(transpile(self._qc, backend=backend), backend=backend) + job = backend.run(qobj) + self.log.info('job_id: %s', job.job_id()) + self.assertTrue(job.job_id() is not None) + + @requires_provider + def test_get_backend_name(self, provider): + """Test getting a backend name.""" + backend = provider.get_backend('ibmq_qasm_simulator') + + qobj = assemble(transpile(self._qc, backend=backend), backend=backend) + job = backend.run(qobj) + self.assertTrue(job.backend().name() == backend.name()) + + @run_on_device + def test_running_job_properties(self, provider, backend): # pylint: disable=unused-argument + """Test fetching properties of a running job.""" + qobj = assemble(transpile(self._qc, backend=backend), backend=backend) + job = backend.run(qobj) + _ = job.properties() + + @requires_provider + def test_job_name_backend(self, provider): + """Test using job names on a backend.""" + backend = provider.get_backend('ibmq_qasm_simulator') + qobj = assemble(transpile(self._qc, backend=backend), backend=backend) + + # Use a unique job name + job_name = str(time.time()).replace('.', '') + job_id = backend.run(qobj, job_name=job_name).job_id() + job = backend.retrieve_job(job_id) + self.assertEqual(job.name(), job_name) + + # Check using partial matching. + job_name_partial = job_name[8:] + retrieved_jobs = backend.jobs(job_name=job_name_partial) + self.assertGreaterEqual(len(retrieved_jobs), 1) + retrieved_job_ids = {job.job_id() for job in retrieved_jobs} + self.assertIn(job_id, retrieved_job_ids) + + # Check using regular expressions. + job_name_regex = '^{}$'.format(job_name) + retrieved_jobs = backend.jobs(job_name=job_name_regex) + self.assertEqual(len(retrieved_jobs), 1) + self.assertEqual(job_id, retrieved_jobs[0].job_id()) + + @requires_provider + def test_job_name_backend_service(self, provider): + """Test using job names on backend service.""" + backend = provider.get_backend('ibmq_qasm_simulator') + qobj = assemble(transpile(self._qc, backend=backend), backend=backend) + + # Use a unique job name + job_name = str(time.time()).replace('.', '') + job_id = backend.run(qobj, job_name=job_name).job_id() + job = provider.backends.retrieve_job(job_id) + self.assertEqual(job.name(), job_name) + + # Check using partial matching. + job_name_partial = job_name[8:] + retrieved_jobs = provider.backends.jobs(backend_name=backend.name(), + job_name=job_name_partial) + self.assertGreaterEqual(len(retrieved_jobs), 1) + retrieved_job_ids = {job.job_id() for job in retrieved_jobs} + self.assertIn(job_id, retrieved_job_ids) + + # Check using regular expressions. + job_name_regex = '^{}$'.format(job_name) + retrieved_jobs = provider.backends.jobs(backend_name=backend.name(), + job_name=job_name_regex) + self.assertEqual(len(retrieved_jobs), 1) + self.assertEqual(job_id, retrieved_jobs[0].job_id()) + + @requires_provider + def test_duplicate_job_name_backend(self, provider): + """Test multiple jobs with the same custom job name using a backend.""" + backend = provider.get_backend('ibmq_qasm_simulator') + qobj = assemble(transpile(self._qc, backend=backend), backend=backend) + + # Use a unique job name + job_name = str(time.time()).replace('.', '') + job_ids = set() + for _ in range(2): + job_ids.add(backend.run(qobj, job_name=job_name).job_id()) + + retrieved_jobs = backend.jobs(job_name=job_name) + + self.assertEqual(len(retrieved_jobs), 2) + retrieved_job_ids = {job.job_id() for job in retrieved_jobs} + self.assertEqual(job_ids, retrieved_job_ids) + for job in retrieved_jobs: + self.assertEqual(job.name(), job_name) + + @requires_provider + def test_duplicate_job_name_backend_service(self, provider): + """Test multiple jobs with the same custom job name using backend service.""" + backend = provider.get_backend('ibmq_qasm_simulator') + qobj = assemble(transpile(self._qc, backend=backend), backend=backend) + + # Use a unique job name + job_name = str(time.time()).replace('.', '') + job_ids = set() + for _ in range(2): + job_ids.add(backend.run(qobj, job_name=job_name).job_id()) + + retrieved_jobs = provider.backends.jobs(backend_name=backend.name(), + job_name=job_name) + self.assertEqual(len(retrieved_jobs), 2) + retrieved_job_ids = {job.job_id() for job in retrieved_jobs} + self.assertEqual(job_ids, retrieved_job_ids) + for job in retrieved_jobs: + self.assertEqual(job.name(), job_name) + + @run_on_device + def test_error_message_device(self, provider, backend): # pylint: disable=unused-argument + """Test retrieving job error messages from a device backend.""" + qc_new = transpile(self._qc, backend) + qobj = assemble([qc_new, qc_new], backend=backend) + qobj.experiments[1].instructions[1].name = 'bad_instruction' + + job = backend.run(qobj) + with self.assertRaises(IBMQJobFailureError): + job.result(timeout=300, partial=True) + + message = job.error_message() + self.assertTrue(message) + + @requires_provider + def test_error_message_simulator(self, provider): + """Test retrieving job error messages from a simulator backend.""" + backend = provider.get_backend('ibmq_qasm_simulator') + + qc_new = transpile(self._qc, backend) + qobj = assemble([qc_new, qc_new], backend=backend) + qobj.experiments[1].instructions[1].name = 'bad_instruction' + + job = backend.run(qobj) + with self.assertRaises(IBMQJobFailureError): + job.result() + + message = job.error_message() + self.assertIn('Experiment 1: ERROR', message) + + @requires_provider + def test_error_message_validation(self, provider): + """Test retrieving job error message for a validation error.""" + backend = provider.get_backend('ibmq_qasm_simulator') + qobj = assemble(transpile(self._qc, backend), shots=10000) + job = backend.run(qobj) + with self.assertRaises(IBMQJobFailureError): + job.result() + + message = job.error_message() + self.assertNotIn("Unknown", message) + + @requires_provider + def test_refresh(self, provider): + """Test refreshing job data.""" + backend = provider.get_backend('ibmq_qasm_simulator') + qobj = assemble(transpile(self._qc, backend=backend), backend=backend) + job = backend.run(qobj) + job._wait_for_completion() + + rjob = provider.backends.jobs(db_filter={'id': job.job_id()})[0] + self.assertFalse(rjob._time_per_step) + rjob.refresh() + self.assertEqual(rjob._time_per_step, job._time_per_step) + + @requires_provider + def test_time_per_step(self, provider): + """Test retrieving time per step.""" + backend = provider.get_backend('ibmq_qasm_simulator') + qobj = assemble(transpile(self._qc, backend=backend), backend=backend) + job = backend.run(qobj) + job.result() + self.assertTrue(job.time_per_step()) + + rjob = provider.backends.jobs(db_filter={'id': job.job_id()})[0] + self.assertTrue(rjob.time_per_step()) + + @requires_provider + def test_new_job_attributes(self, provider): + """Test job with new attributes.""" + def _mocked__api_job_submit(*args, **kwargs): + submit_info = original_submit(*args, **kwargs) + submit_info.update({'batman': 'bruce'}) + return submit_info + + backend = provider.get_backend('ibmq_qasm_simulator') + qobj = assemble(transpile(self._qc, backend=backend), backend=backend) + original_submit = backend._api.job_submit + with mock.patch.object(AccountClient, 'job_submit', + side_effect=_mocked__api_job_submit): + job = backend.run(qobj) + + self.assertEqual(job.batman, 'bruce') + + @requires_provider + def test_queue_position(self, provider): + """Test retrieving queue position.""" + # Find the most busy backend. + backend = max([b for b in provider.backends() if b.status().operational], + key=lambda b: b.status().pending_jobs) + qobj = assemble(transpile(self._qc, backend=backend), backend=backend) + job = backend.run(qobj) + status = job.status() + if status is JobStatus.QUEUED: + self.assertIsNotNone(job.queue_position()) + else: + self.assertIsNone(job.queue_position()) + + # Cancel job so it doesn't consume more resources. + try: + job.cancel() + except JobError: + pass + + @requires_provider + def test_invalid_job_share_level(self, provider): + """Test setting a non existent share level for a job.""" + backend = provider.get_backend('ibmq_qasm_simulator') + qobj = assemble(transpile(self._qc, backend=backend), backend=backend) + with self.assertRaises(IBMQBackendValueError) as context_manager: + backend.run(qobj, job_share_level='invalid_job_share_level') + self.assertIn('not a valid job share', context_manager.exception.message) + + @requires_provider + def test_share_job_in_project(self, provider): + """Test successfully sharing a job within a shareable project.""" + backend = provider.get_backend('ibmq_qasm_simulator') + qobj = assemble(transpile(self._qc, backend=backend), backend=backend) + job = backend.run(qobj, job_share_level='project') + + retrieved_job = backend.retrieve_job(job.job_id()) + self.assertEqual(getattr(retrieved_job, 'share_level'), 'project') + + +def _bell_circuit(): + qr = QuantumRegister(2, 'q') + cr = ClassicalRegister(2, 'c') + qc = QuantumCircuit(qr, cr) + qc.h(qr[0]) + qc.cx(qr[0], qr[1]) + qc.measure(qr, cr) + return qc diff --git a/test/ibmq/test_ibmq_job_model.py b/test/ibmq/test_ibmq_job_model.py new file mode 100644 index 000000000..11d2e4d51 --- /dev/null +++ b/test/ibmq/test_ibmq_job_model.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- + +# This code is part of Qiskit. +# +# (C) Copyright IBM 2019. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""IBMQJob model tests.""" + +from qiskit import ClassicalRegister, QuantumCircuit, QuantumRegister +from qiskit.compiler import assemble, transpile +from qiskit.validation import ModelValidationError +from qiskit.validation.jsonschema import SchemaValidationError + +from qiskit.providers.ibmq import IBMQJob + +from ..jobtestcase import JobTestCase +from ..decorators import requires_provider + +VALID_JOB_RESPONSE = { + # Attributes needed by the constructor. + 'api': None, + '_backend': None, + + # Attributes required by the schema. + 'id': 'TEST_ID', + 'kind': 'q-object', + 'status': 'CREATING', + 'creationDate': '2019-01-01T13:15:58.425972' +} + + +class TestIBMQJobModel(JobTestCase): + """Test model-related functionality of IBMQJob.""" + + def test_bad_job_schema(self): + """Test creating a job with bad job schema.""" + bad_job_info = {'id': 'TEST_ID'} + with self.assertRaises(ModelValidationError): + IBMQJob.from_dict(bad_job_info) + + @requires_provider + def test_invalid_qobj(self, provider): + """Test submitting an invalid qobj.""" + backend = provider.get_backend('ibmq_qasm_simulator') + qobj = assemble(transpile(_bell_circuit(), backend=backend), + backend=backend) + + delattr(qobj, 'qobj_id') + with self.assertRaises(SchemaValidationError): + backend.run(qobj) + + def test_valid_job(self): + """Test the model can be created from a response.""" + job = IBMQJob.from_dict(VALID_JOB_RESPONSE) + + # Check for a required attribute with correct name. + self.assertNotIn('creationDate', job) + self.assertIn('_creation_date', job) + + def test_auto_undefined_fields(self): + """Test undefined response fields appear in the model.""" + response = VALID_JOB_RESPONSE.copy() + response['newField'] = {'foo': 2} + job = IBMQJob.from_dict(response) + + # Check the field appears as an attribute in the model. + self.assertIn('new_field', job) + self.assertEqual(job.new_field, {'foo': 2}) + + def test_invalid_enum(self): + """Test creating a model with an invalid value for an Enum field.""" + response = VALID_JOB_RESPONSE.copy() + response['kind'] = 'invalid' + with self.assertRaises(ModelValidationError): + IBMQJob.from_dict(response) + + +def _bell_circuit(): + qr = QuantumRegister(2, 'q') + cr = ClassicalRegister(2, 'c') + qc = QuantumCircuit(qr, cr) + qc.h(qr[0]) + qc.cx(qr[0], qr[1]) + qc.measure(qr, cr) + return qc diff --git a/test/ibmq/test_ibmq_job_states.py b/test/ibmq/test_ibmq_job_states.py index 6cb6b7469..3cc840d45 100644 --- a/test/ibmq/test_ibmq_job_states.py +++ b/test/ibmq/test_ibmq_job_states.py @@ -18,13 +18,17 @@ import time from contextlib import suppress +from unittest import mock from qiskit.providers.ibmq.apiconstants import API_JOB_FINAL_STATES, ApiJobStatus -from qiskit.test.mock import new_fake_qobj, FakeRueschlikon -from qiskit.providers import JobError, JobTimeoutError -from qiskit.providers.ibmq.api import ApiError -from qiskit.providers.ibmq.job.ibmqjob import IBMQJob +from qiskit.test.mock import FakeQobj +from qiskit.providers import JobTimeoutError +from qiskit.providers.ibmq.job.exceptions import IBMQJobApiError, IBMQJobInvalidStateError +from qiskit.providers.ibmq.api.exceptions import (ApiError, UserTimeoutExceededError, + ApiIBMQProtocolError) +from qiskit.providers.ibmq.exceptions import IBMQBackendError from qiskit.providers.jobstatus import JobStatus +from qiskit.providers.ibmq.ibmqbackend import IBMQBackend from ..jobtestcase import JobTestCase @@ -50,6 +54,9 @@ VALID_QOBJ_RESPONSE = { 'status': 'COMPLETED', + 'kind': 'q-object', + 'creationDate': '2019-01-01T12:57:15.052Z', + 'id': '0123456789', 'qObjectResult': { 'backend_name': 'ibmqx2', 'backend_version': '1.1.1', @@ -97,6 +104,14 @@ } +VALID_JOB_RESPONSE = { + 'id': 'TEST_ID', + 'kind': 'q-object', + 'status': 'CREATING', + 'creationDate': '2019-01-01T13:15:58.425972' +} + + class TestIBMQJobStates(JobTestCase): """Test the states of an IBMQJob.""" @@ -106,7 +121,7 @@ def setUp(self): def test_unrecognized_status(self): job = self.run_with_api(UnknownStatusAPI()) - with self.assertRaises(JobError): + with self.assertRaises(IBMQJobApiError): self.wait_for_initialization(job) def test_validating_job(self): @@ -185,38 +200,25 @@ def test_status_flow_for_errored_cancellation(self): self.assertEqual(job.status(), JobStatus.RUNNING) def test_status_flow_for_unable_to_run_valid_qobj(self): - """Contrary to other tests, this one is expected to fail even for a - non-job-related issue. If the API fails while sending a job, we don't - get an id so we can not query for the job status.""" - job = self.run_with_api(UnavailableRunAPI()) - - with self.assertRaises(JobError): - self.wait_for_initialization(job) - - with self.assertRaises(JobError): - job.status() + with self.assertRaises(IBMQBackendError): + self.run_with_api(UnavailableRunAPI()) def test_api_throws_temporarily_but_job_is_finished(self): job = self.run_with_api(ThrowingNonJobRelatedErrorAPI(errors_before_success=2)) # First time we query the server... - with self.assertRaises(JobError): + with self.assertRaises(IBMQJobApiError): # The error happens inside wait_for_initialization, the first time # it calls to status() after INITIALIZING. self.wait_for_initialization(job) # Also an explicit second time... - with self.assertRaises(JobError): + with self.assertRaises(IBMQJobApiError): job.status() # Now the API gets fixed and doesn't throw anymore. self.assertEqual(job.status(), JobStatus.DONE) - def test_status_flow_for_unable_to_run_invalid_qobj(self): - job = self.run_with_api(RejectingJobAPI()) - self.wait_for_initialization(job) - self.assertEqual(job.status(), JobStatus.ERROR) - def test_error_while_running_job(self): job = self.run_with_api(ErrorWhileRunningAPI()) @@ -234,14 +236,14 @@ def test_cancelled_result(self): self.wait_for_initialization(job) job.cancel() self._current_api.progress() - with self.assertRaises(JobError): + with self.assertRaises(IBMQJobInvalidStateError): _ = job.result() self.assertEqual(job.status(), JobStatus.CANCELLED) def test_errored_result(self): job = self.run_with_api(ThrowingGetJobAPI()) self.wait_for_initialization(job) - with self.assertRaises(ApiError): + with self.assertRaises(IBMQJobApiError): job.result() def test_completed_result(self): @@ -270,7 +272,7 @@ def test_block_on_result_waiting_until_cancelled(self): with ThreadPoolExecutor() as executor: executor.submit(_auto_progress_api, self._current_api) - with self.assertRaises(JobError): + with self.assertRaises(IBMQJobInvalidStateError): job.result() self.assertEqual(job.status(), JobStatus.CANCELLED) @@ -282,7 +284,7 @@ def test_block_on_result_waiting_until_exception(self): with ThreadPoolExecutor() as executor: executor.submit(_auto_progress_api, self._current_api) - with self.assertRaises(JobError): + with self.assertRaises(IBMQJobApiError): job.result() def test_never_complete_result_with_timeout(self): @@ -293,8 +295,6 @@ def test_never_complete_result_with_timeout(self): job.result(timeout=0.2) def test_only_final_states_cause_detailed_request(self): - from unittest import mock - # The state ERROR_CREATING_JOB is only handled when running the job, # and not while checking the status, so it is not tested. all_state_apis = {'COMPLETED': NonQueuedAPI, @@ -310,25 +310,25 @@ def test_only_final_states_cause_detailed_request(self): with suppress(BaseFakeAPI.NoMoreStatesError): self._current_api.progress() - with mock.patch.object(self._current_api, 'get_job', - wraps=self._current_api.get_job): + with mock.patch.object(self._current_api, 'job_get', + wraps=self._current_api.job_get): job.status() if ApiJobStatus(status) in API_JOB_FINAL_STATES: - self.assertTrue(self._current_api.get_job.called) + self.assertTrue(self._current_api.job_get.called) else: - self.assertFalse(self._current_api.get_job.called) + self.assertFalse(self._current_api.job_get.called) def run_with_api(self, api): """Creates a new ``IBMQJob`` running with the provided API object.""" - backend = FakeRueschlikon() + backend = IBMQBackend(mock.Mock(), mock.Mock(), mock.Mock(), api=api) self._current_api = api - self._current_qjob = IBMQJob(backend, None, api, qobj=new_fake_qobj()) - self._current_qjob.submit() + self._current_qjob = backend.run(qobj=FakeQobj()) + self._current_qjob.refresh = mock.Mock() return self._current_qjob def _auto_progress_api(api, interval=0.2): - """Progress a `BaseFakeAPI` instacn every `interval` seconds until reaching + """Progress a `BaseFakeAPI` instance every `interval` seconds until reaching the final state. """ with suppress(BaseFakeAPI.NoMoreStatesError): @@ -357,27 +357,47 @@ def __init__(self): 'project': 'test-project' }) - def get_job(self, job_id): + def job_get(self, job_id): if not job_id: return {'status': 'Error', 'error': 'Job ID not specified'} return self._job_status[self._state] - def get_status_job(self, job_id): + def job_status(self, job_id): summary_fields = ['status', 'error', 'infoQueue'] - complete_response = self.get_job(job_id) + complete_response = self.job_get(job_id) + try: + ApiJobStatus(complete_response['status']) + except ValueError: + raise ApiIBMQProtocolError return {key: value for key, value in complete_response.items() if key in summary_fields} - def submit_job(self, *_args, **_kwargs): + def job_submit(self, *_args, **_kwargs): time.sleep(0.2) - return {'id': 'TEST_ID'} + return VALID_JOB_RESPONSE - def cancel_job(self, job_id, *_args, **_kwargs): + def job_cancel(self, job_id, *_args, **_kwargs): if not job_id: return {'status': 'Error', 'error': 'Job ID not specified'} return {} if self._can_cancel else { 'error': 'testing fake API can not cancel'} + def job_final_status(self, job_id, *_args, **_kwargs): + start_time = time.time() + status_response = self.job_status(job_id) + while ApiJobStatus(status_response['status']) not in API_JOB_FINAL_STATES: + elapsed_time = time.time() - start_time + timeout = _kwargs.get('timeout', None) + if timeout is not None and elapsed_time >= timeout: + raise UserTimeoutExceededError( + 'Timeout while waiting for job {}'.format(job_id)) + time.sleep(5) + status_response = self.job_status(job_id) + return status_response + + def job_result(self, job_id, *_args, **_kwargs): + return self.job_get(job_id)['qObjectResult'] + def progress(self): if self._state == len(self._job_status) - 1: raise self.NoMoreStatesError() @@ -451,14 +471,14 @@ class QueuedAPI(BaseFakeAPI): class RejectingJobAPI(BaseFakeAPI): """Class for emulating an API unable of initializing.""" - def submit_job(self, *_args, **_kwargs): + def job_submit(self, *_args, **_kwargs): return {'error': 'invalid qobj'} class UnavailableRunAPI(BaseFakeAPI): """Class for emulating an API throwing before even initializing.""" - def submit_job(self, *_args, **_kwargs): + def job_submit(self, *_args, **_kwargs): time.sleep(0.2) raise ApiError() @@ -470,7 +490,7 @@ class ThrowingAPI(BaseFakeAPI): {'status': 'RUNNING'} ] - def get_job(self, job_id): + def job_get(self, job_id): raise ApiError() @@ -487,27 +507,27 @@ def __init__(self, errors_before_success=2): super().__init__() self._number_of_exceptions_to_throw = errors_before_success - def get_job(self, job_id): + def job_get(self, job_id): if self._number_of_exceptions_to_throw != 0: self._number_of_exceptions_to_throw -= 1 raise ApiError() - return super().get_job(job_id) + return super().job_get(job_id) class ThrowingGetJobAPI(BaseFakeAPI): """Class for emulating an API throwing in the middle of execution. But not in - get_status_job() , just in get_job(). + get_status_job(), just in job_get(). """ _job_status = [ {'status': 'COMPLETED'} ] - def get_status_job(self, job_id): + def job_status(self, job_id): return self._job_status[self._state] - def get_job(self, job_id): + def job_get(self, job_id): raise ApiError('Unexpected error') @@ -545,5 +565,5 @@ class ErroredCancellationAPI(BaseFakeAPI): _can_cancel = True - def cancel_job(self, job_id, *_args, **_kwargs): + def job_cancel(self, job_id, *_args, **_kwargs): return {'status': 'Error', 'error': 'test-error-while-cancelling'} diff --git a/test/ibmq/test_ibmq_jobmanager.py b/test/ibmq/test_ibmq_jobmanager.py new file mode 100644 index 000000000..073bf835d --- /dev/null +++ b/test/ibmq/test_ibmq_jobmanager.py @@ -0,0 +1,292 @@ +# -*- coding: utf-8 -*- + +# This code is part of Qiskit. +# +# (C) Copyright IBM 2019. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Tests for the IBMQJobManager.""" +import copy +from unittest import mock +import time + +from qiskit import QuantumCircuit +from qiskit.providers.ibmq.managed.ibmqjobmanager import IBMQJobManager +from qiskit.providers.ibmq.managed.exceptions import (IBMQJobManagerJobNotFound, + IBMQManagedResultDataNotAvailable) +from qiskit.providers.jobstatus import JobStatus +from qiskit.providers import JobError +from qiskit.providers.ibmq.ibmqbackend import IBMQBackend +from qiskit.providers.ibmq.exceptions import IBMQBackendError +from qiskit.compiler import transpile, assemble + +from ..ibmqtestcase import IBMQTestCase +from ..decorators import requires_provider +from ..fake_account_client import BaseFakeAccountClient + + +class TestIBMQJobManager(IBMQTestCase): + """Tests for IBMQJobManager.""" + + def setUp(self): + self._qc = _bell_circuit() + self._jm = IBMQJobManager() + + @requires_provider + def test_split_circuits(self, provider): + """Test having circuits split into multiple jobs.""" + backend = provider.get_backend('ibmq_qasm_simulator') + max_circs = backend.configuration().max_experiments + backend._api = BaseFakeAccountClient() + + circs = [] + for _ in range(max_circs+2): + circs.append(self._qc) + job_set = self._jm.run(circs, backend=backend) + job_set.results() + statuses = job_set.statuses() + + self.assertEqual(len(statuses), 2) + self.assertTrue(all(s is JobStatus.DONE for s in statuses)) + self.assertTrue(len(job_set.jobs()), 2) + + @requires_provider + def test_no_split_circuits(self, provider): + """Test running all circuits in a single job.""" + backend = provider.get_backend('ibmq_qasm_simulator') + max_circs = backend.configuration().max_experiments + backend._api = BaseFakeAccountClient() + + circs = [] + for _ in range(int(max_circs/2)): + circs.append(self._qc) + job_set = self._jm.run(circs, backend=backend) + self.assertTrue(len(job_set.jobs()), 1) + + @requires_provider + def test_custom_split_circuits(self, provider): + """Test having circuits split with custom slices.""" + backend = provider.get_backend('ibmq_qasm_simulator') + backend._api = BaseFakeAccountClient() + + circs = [] + for _ in range(2): + circs.append(self._qc) + job_set = self._jm.run(circs, backend=backend, max_experiments_per_job=1) + self.assertTrue(len(job_set.jobs()), 2) + + @requires_provider + def test_job_report(self, provider): + """Test job report.""" + backend = provider.get_backend('ibmq_qasm_simulator') + backend._api = BaseFakeAccountClient() + + circs = [] + for _ in range(2): + circs.append(self._qc) + job_set = self._jm.run(circs, backend=backend, max_experiments_per_job=1) + jobs = job_set.jobs() + report = self._jm.report() + for job in jobs: + self.assertIn(job.job_id(), report) + + @requires_provider + def test_skipped_status(self, provider): + """Test one of jobs has no status.""" + backend = provider.get_backend('ibmq_qasm_simulator') + + circs = [] + for _ in range(2): + circs.append(self._qc) + job_set = self._jm.run(circs, backend=backend, max_experiments_per_job=1) + jobs = job_set.jobs() + jobs[1]._job_id = 'BAD_ID' + statuses = job_set.statuses() + self.assertIsNone(statuses[1]) + + @requires_provider + def test_job_qobjs(self, provider): + """Test retrieving qobjs for the jobs.""" + backend = provider.get_backend('ibmq_qasm_simulator') + + circs = [] + for _ in range(2): + circs.append(self._qc) + job_set = self._jm.run(circs, backend=backend, max_experiments_per_job=1) + jobs = job_set.jobs() + job_set.results() + for i, qobj in enumerate(job_set.qobjs()): + rjob = provider.backends.retrieve_job(jobs[i].job_id()) + self.assertDictEqual(qobj.__dict__, rjob.qobj().__dict__) + + @requires_provider + def test_error_message(self, provider): + """Test error message report.""" + backend = provider.get_backend('ibmq_qasm_simulator') + + # Create a bad job. + qc_new = transpile(self._qc, backend) + qobj = assemble([qc_new, qc_new], backend=backend) + qobj.experiments[1].instructions[1].name = 'bad_instruction' + job = backend.run(qobj) + + circs = [] + for _ in range(4): + circs.append(self._qc) + job_set = self._jm.run(circs, backend=backend, max_experiments_per_job=2) + job_set.results() + job_set.managed_jobs()[1].job = job + + error_report = job_set.error_messages() + self.assertIsNotNone(error_report) + self.assertIn(job.job_id(), error_report) + + @requires_provider + def test_async_submit_exception(self, provider): + """Test asynchronous job submit failed.""" + backend = provider.get_backend('ibmq_qasm_simulator') + backend._api = BaseFakeAccountClient() + + circs = [] + for _ in range(2): + circs.append(self._qc) + with mock.patch.object(IBMQBackend, 'run', + side_effect=[IBMQBackendError("Kaboom!"), mock.DEFAULT]): + job_set = self._jm.run(circs, backend=backend, max_experiments_per_job=1) + self.assertIsNone(job_set.jobs()[0]) + self.assertIsNotNone(job_set.jobs()[1]) + + # Make sure results() and statuses() don't fail + job_set.results() + job_set.statuses() + + @requires_provider + def test_multiple_job_sets(self, provider): + """Test submitting multiple sets of jobs.""" + backend = provider.get_backend('ibmq_qasm_simulator') + backend._api = BaseFakeAccountClient() + + qc2 = QuantumCircuit(1, 1) + qc2.h(0) + qc2.measure([0], [0]) + + job_set1 = self._jm.run([self._qc, self._qc], backend=backend, max_experiments_per_job=1) + job_set2 = self._jm.run([qc2], backend=backend, max_experiments_per_job=1) + + id1 = {job.job_id() for job in job_set1.jobs()} + id2 = {job.job_id() for job in job_set2.jobs()} + self.assertTrue(id1.isdisjoint(id2)) + + @requires_provider + def test_retrieve_job_sets(self, provider): + """Test retrieving a set of jobs.""" + backend = provider.get_backend('ibmq_qasm_simulator') + backend._api = BaseFakeAccountClient() + name = str(time.time()).replace('.', '') + + self._jm.run([self._qc], backend=backend, max_experiments_per_job=1) + job_set = self._jm.run([self._qc, self._qc], backend=backend, + name=name, max_experiments_per_job=1) + rjob_set = self._jm.job_sets(name=name)[0] + self.assertEqual(job_set, rjob_set) + + +class TestResultManager(IBMQTestCase): + """Tests for ResultManager.""" + + def setUp(self): + self._qc = _bell_circuit() + self._jm = IBMQJobManager() + + @requires_provider + def test_index_by_number(self, provider): + """Test indexing results by number.""" + backend = provider.get_backend('ibmq_qasm_simulator') + max_per_job = 5 + circs = [] + for _ in range(max_per_job*2): + circs.append(self._qc) + job_set = self._jm.run(circs, backend=backend, max_experiments_per_job=max_per_job) + result_manager = job_set.results() + jobs = job_set.jobs() + + for i in [0, max_per_job-1, max_per_job+1]: + with self.subTest(i=i): + job_index = int(i / max_per_job) + exp_index = i % max_per_job + self.assertEqual(result_manager.get_counts(i), + jobs[job_index].result().get_counts(exp_index)) + + @requires_provider + def test_index_by_name(self, provider): + """Test indexing results by name.""" + backend = provider.get_backend('ibmq_qasm_simulator') + max_per_job = 5 + circs = [] + for i in range(max_per_job*2+1): + new_qc = copy.deepcopy(self._qc) + new_qc.name = "test_qc_{}".format(i) + circs.append(new_qc) + job_set = self._jm.run(circs, backend=backend, max_experiments_per_job=max_per_job) + result_manager = job_set.results() + jobs = job_set.jobs() + + for i in [1, max_per_job, len(circs)-1]: + with self.subTest(i=i): + job_index = int(i / max_per_job) + exp_index = i % max_per_job + self.assertEqual(result_manager.get_counts(circs[i].name), + jobs[job_index].result().get_counts(exp_index)) + + @requires_provider + def test_index_out_of_range(self, provider): + """Test result index out of range.""" + backend = provider.get_backend('ibmq_qasm_simulator') + job_set = self._jm.run([self._qc], backend=backend) + result_manager = job_set.results() + with self.assertRaises(IBMQJobManagerJobNotFound): + result_manager.get_counts(1) + + @requires_provider + def test_skipped_result(self, provider): + """Test one of jobs has no result.""" + backend = provider.get_backend('ibmq_qasm_simulator') + max_circs = backend.configuration().max_experiments + + circs = [] + for _ in range(max_circs+2): + circs.append(self._qc) + job_set = self._jm.run(circs, backend=backend) + jobs = job_set.jobs() + cjob = jobs[1] + cancelled = False + for _ in range(2): + # Try twice in case job is not in a cancellable state + try: + cancelled = cjob.cancel() + if cancelled: + break + except JobError: + pass + + result_manager = job_set.results() + if cancelled: + with self.assertRaises(IBMQManagedResultDataNotAvailable): + result_manager.get_counts(max_circs) + else: + self.log.warning("Unable to cancel job %s", cjob.job_id()) + + +def _bell_circuit(): + qc = QuantumCircuit(2, 2) + qc.h(0) + qc.cx(0, 1) + qc.measure([0, 1], [0, 1]) + return qc diff --git a/test/ibmq/test_ibmq_provider.py b/test/ibmq/test_ibmq_provider.py index 3db2ccaa0..1d795e951 100644 --- a/test/ibmq/test_ibmq_provider.py +++ b/test/ibmq/test_ibmq_provider.py @@ -15,26 +15,26 @@ """Tests for all IBMQ backends.""" +from datetime import datetime + from qiskit import ClassicalRegister, QuantumCircuit, QuantumRegister -from qiskit.providers.ibmq import IBMQProvider from qiskit.providers.exceptions import QiskitBackendNotFoundError from qiskit.providers.ibmq.accountprovider import AccountProvider from qiskit.providers.ibmq.ibmqfactory import IBMQFactory -from qiskit.providers.ibmq.ibmqbackend import IBMQSimulator +from qiskit.providers.ibmq.ibmqbackend import IBMQSimulator, IBMQBackend from qiskit.qobj import QobjHeader from qiskit.test import slow_test, providers from qiskit.compiler import assemble, transpile +from qiskit.providers.models.backendproperties import BackendProperties -from ..decorators import (requires_qe_access, - requires_classic_api, - requires_new_api_auth) +from ..decorators import requires_qe_access from ..ibmqtestcase import IBMQTestCase -class TestIBMQProvider(IBMQTestCase, providers.ProviderTestCase): - """Tests for all the IBMQ backends through the classic API.""" +class TestAccountProvider(IBMQTestCase, providers.ProviderTestCase): + """Tests for all the IBMQ backends through the new API.""" - provider_cls = IBMQProvider + provider_cls = AccountProvider backend_name = 'ibmq_qasm_simulator' def setUp(self): @@ -47,13 +47,11 @@ def setUp(self): self.qc1.measure(qr, cr) @requires_qe_access - @requires_classic_api def _get_provider(self, qe_token, qe_url): """Return an instance of a Provider.""" # pylint: disable=arguments-differ - provider = self.provider_cls() - provider.enable_account(qe_token, qe_url) - return provider + ibmq = IBMQFactory() + return ibmq.enable_account(qe_token, qe_url) def test_remote_backends_exist_real_device(self): """Test if there are remote backends that are devices.""" @@ -91,7 +89,7 @@ def test_remote_backend_properties(self): if backend.configuration().simulator: self.assertEqual(properties, None) - def test_remote_backend_defaults(self): + def test_remote_backend_pulse_defaults(self): """Test backend pulse defaults.""" remotes = self.provider.backends(simulator=False) for backend in remotes: @@ -145,7 +143,7 @@ def test_qobj_headers_in_result_devices(self): def test_aliases(self): """Test that display names of devices map the regular names.""" - aliased_names = self.provider._aliased_backend_names() + aliased_names = self.provider.backends._aliased_backend_names() for display_name, backend_name in aliased_names.items(): with self.subTest(display_name=display_name, @@ -162,16 +160,23 @@ def test_aliases(self): self.assertEqual( backend_by_display_name.name(), backend_name) + def test_remote_backend_properties_filter_date(self): + """Test backend properties filtered by date.""" + backends = self.provider.backends(simulator=False) -class TestAccountProvider(TestIBMQProvider): - """Tests for all the IBMQ backends through the new API.""" - - provider_cls = AccountProvider - - @requires_qe_access - @requires_new_api_auth - def _get_provider(self, qe_token, qe_url): - """Return an instance of a Provider.""" - # pylint: disable=arguments-differ - ibmq = IBMQFactory() - return ibmq.enable_account(qe_token, qe_url) + datetime_filter = datetime(2019, 2, 1).replace(tzinfo=None) + for backend in backends: + with self.subTest(backend=backend): + properties = backend.properties(datetime=datetime_filter) + if isinstance(properties, BackendProperties): + last_update_date = properties.last_update_date.replace(tzinfo=None) + self.assertLessEqual(last_update_date, datetime_filter) + else: + self.assertEqual(properties, None) + + def test_provider_backends(self): + """Test provider_backends have correct attributes.""" + provider_backends = {back for back in dir(self.provider.backends) + if isinstance(getattr(self.provider.backends, back), IBMQBackend)} + backends = {back.name() for back in self.provider._backends.values()} + self.assertEqual(provider_backends, backends) diff --git a/test/ibmq/test_proxies.py b/test/ibmq/test_proxies.py index 8b99d0fe3..ea3b41712 100644 --- a/test/ibmq/test_proxies.py +++ b/test/ibmq/test_proxies.py @@ -23,12 +23,17 @@ from requests.exceptions import ProxyError from qiskit.providers.ibmq import IBMQFactory -from qiskit.providers.ibmq.api_v2.clients import (AuthClient, - VersionClient) -from qiskit.providers.ibmq.api_v2.exceptions import RequestsApiError +from qiskit.providers.ibmq.api.clients import (AuthClient, + VersionClient) +from qiskit.providers.ibmq.api.exceptions import RequestsApiError from ..ibmqtestcase import IBMQTestCase -from ..decorators import requires_qe_access, requires_new_api_auth +from ..decorators import requires_qe_access +# Fallback mechanism. Version variable is stored under __doc__ in new pproxy versions +try: + from pproxy.__doc__ import __version__ as pproxy_version +except ImportError: + from pproxy import __version__ as pproxy_version ADDRESS = '127.0.0.1' PORT = 8080 @@ -37,15 +42,17 @@ INVALID_ADDRESS_PROXIES = {'https': '{}:{}'.format('invalid', PORT)} -@skipIf(sys.version_info >= (3, 7), 'pproxy version not supported in 3.7') +@skipIf((sys.version_info > (3, 5) and pproxy_version == '1.2.2') or + (sys.version_info == (3, 5) and pproxy_version > '1.2.2'), + 'pproxy version is not supported') class TestProxies(IBMQTestCase): """Tests for proxy capabilities.""" def setUp(self): super().setUp() - + listen_flag = '-l' if pproxy_version >= '1.7.2' else '-i' # launch a mock server. - command = ['pproxy', '-v', '-i', 'http://{}:{}'.format(ADDRESS, PORT)] + command = ['pproxy', '-v', listen_flag, 'http://{}:{}'.format(ADDRESS, PORT)] self.proxy_process = subprocess.Popen(command, stdout=subprocess.PIPE) def tearDown(self): @@ -78,7 +85,6 @@ def test_proxies_factory(self, qe_token, qe_url): self.assertIn(api_line, proxy_output) @requires_qe_access - @requires_new_api_auth def test_proxies_authclient(self, qe_token, qe_url): """Should reach the proxy using AuthClient.""" pproxy_desired_access_log_line_ = pproxy_desired_access_log_line(qe_url) @@ -91,7 +97,6 @@ def test_proxies_authclient(self, qe_token, qe_url): # pylint: disable=unused-argument @requires_qe_access - @requires_new_api_auth def test_proxies_versionclient(self, qe_token, qe_url): """Should reach the proxy using IBMQVersionFinder.""" pproxy_desired_access_log_line_ = pproxy_desired_access_log_line(qe_url) @@ -104,39 +109,32 @@ def test_proxies_versionclient(self, qe_token, qe_url): self.proxy_process.stdout.read().decode('utf-8')) @requires_qe_access - @requires_new_api_auth def test_invalid_proxy_port_authclient(self, qe_token, qe_url): """Should raise RequestApiError with ProxyError using AuthClient.""" with self.assertRaises(RequestsApiError) as context_manager: _ = AuthClient(qe_token, qe_url, proxies=INVALID_PORT_PROXIES) - self.assertIsInstance(context_manager.exception.original_exception, - ProxyError) + self.assertIsInstance(context_manager.exception.__cause__, ProxyError) # pylint: disable=unused-argument @requires_qe_access - @requires_new_api_auth def test_invalid_proxy_port_versionclient(self, qe_token, qe_url): """Should raise RequestApiError with ProxyError using VersionClient.""" with self.assertRaises(RequestsApiError) as context_manager: version_finder = VersionClient(qe_url, proxies=INVALID_PORT_PROXIES) version_finder.version() - self.assertIsInstance(context_manager.exception.original_exception, - ProxyError) + self.assertIsInstance(context_manager.exception.__cause__, ProxyError) @requires_qe_access - @requires_new_api_auth def test_invalid_proxy_address_authclient(self, qe_token, qe_url): """Should raise RequestApiError with ProxyError using AuthClient.""" with self.assertRaises(RequestsApiError) as context_manager: _ = AuthClient(qe_token, qe_url, proxies=INVALID_ADDRESS_PROXIES) - self.assertIsInstance(context_manager.exception.original_exception, - ProxyError) + self.assertIsInstance(context_manager.exception.__cause__, ProxyError) @requires_qe_access - @requires_new_api_auth def test_invalid_proxy_address_versionclient(self, qe_token, qe_url): """Should raise RequestApiError with ProxyError using VersionClient.""" # pylint: disable=unused-argument @@ -145,12 +143,11 @@ def test_invalid_proxy_address_versionclient(self, qe_token, qe_url): proxies=INVALID_ADDRESS_PROXIES) version_finder.version() - self.assertIsInstance(context_manager.exception.original_exception, - ProxyError) + self.assertIsInstance(context_manager.exception.__cause__, ProxyError) def pproxy_desired_access_log_line(url): """Return a desired pproxy log entry given a url.""" qe_url_parts = urllib.parse.urlparse(url) protocol_port = '443' if qe_url_parts.scheme == 'https' else '80' - return 'http {}:{}'.format(qe_url_parts.hostname, protocol_port) + return '{}:{}'.format(qe_url_parts.hostname, protocol_port) diff --git a/test/ibmq/test_registration.py b/test/ibmq/test_registration.py index 677cee668..6a66cf4f1 100644 --- a/test/ibmq/test_registration.py +++ b/test/ibmq/test_registration.py @@ -14,6 +14,7 @@ """Test the registration and credentials features of the IBMQ module.""" +import logging import os import warnings from io import StringIO @@ -23,14 +24,13 @@ from unittest.mock import patch from requests_ntlm import HttpNtlmAuth -from qiskit.providers.ibmq import IBMQ +from qiskit.providers.ibmq import IBMQ, IBMQFactory from qiskit.providers.ibmq.credentials import ( Credentials, discover_credentials, qconfig, read_credentials_from_qiskitrc, store_credentials) -from qiskit.providers.ibmq.credentials.updater import update_credentials, QE2_AUTH_URL, QE2_URL +from qiskit.providers.ibmq.credentials.updater import ( + update_credentials, QE2_AUTH_URL, QE2_URL, QE_URL) from qiskit.providers.ibmq.exceptions import IBMQAccountError -from qiskit.providers.ibmq.ibmqprovider import QE_URL, IBMQProvider -from qiskit.providers.ibmq.ibmqsingleprovider import IBMQSingleProvider from ..ibmqtestcase import IBMQTestCase from ..contextmanagers import custom_envs, no_envs, custom_qiskitrc, no_file, CREDENTIAL_ENV_VARS @@ -46,137 +46,6 @@ } -# TODO: NamedTemporaryFiles do not support name in Windows -@skipIf(os.name == 'nt', 'Test not supported in Windows') -class TestIBMQProviderAccounts(IBMQTestCase): - """Tests for the IBMQProvider account handling.""" - - def setUp(self): - super().setUp() - - # Use an IBMQProvider instead of a Factory. - self.provider = IBMQProvider() - - def test_enable_account(self): - """Test enabling one account.""" - with custom_qiskitrc(), mock_ibmq_provider(): - self.provider.enable_account('QISKITRC_TOKEN', url='someurl', - proxies=PROXIES) - - # Compare the session accounts with the ones stored in file. - loaded_accounts = read_credentials_from_qiskitrc() - _, provider = list(self.provider._accounts.items())[0] - - self.assertEqual(loaded_accounts, {}) - self.assertEqual('QISKITRC_TOKEN', provider.credentials.token) - self.assertEqual('someurl', provider.credentials.url) - self.assertEqual(PROXIES, provider.credentials.proxies) - - def test_enable_multiple_accounts(self): - """Test enabling multiple accounts, combining QX and IBMQ.""" - with custom_qiskitrc(), mock_ibmq_provider(): - self.provider.enable_account('QISKITRC_TOKEN') - self.provider.enable_account('QISKITRC_TOKEN', - url=IBMQ_TEMPLATE.format('a', 'b', 'c')) - self.provider.enable_account('QISKITRC_TOKEN', - url=IBMQ_TEMPLATE.format('a', 'b', 'X')) - - # Compare the session accounts with the ones stored in file. - loaded_accounts = read_credentials_from_qiskitrc() - self.assertEqual(loaded_accounts, {}) - self.assertEqual(len(self.provider._accounts), 3) - - def test_enable_duplicate_accounts(self): - """Test enabling the same credentials twice.""" - with custom_qiskitrc(), mock_ibmq_provider(): - self.provider.enable_account('QISKITRC_TOKEN') - - self.assertEqual(len(self.provider._accounts), 1) - - def test_save_account(self): - """Test saving one account.""" - with custom_qiskitrc(), mock_ibmq_provider(): - self.provider.save_account('QISKITRC_TOKEN', url=QE_URL, - proxies=PROXIES) - - # Compare the session accounts with the ones stored in file. - stored_accounts = read_credentials_from_qiskitrc() - self.assertEqual(len(stored_accounts.keys()), 1) - - def test_save_multiple_accounts(self): - """Test saving several accounts, combining QX and IBMQ""" - with custom_qiskitrc(), mock_ibmq_provider(): - self.provider.save_account('QISKITRC_TOKEN') - self.provider.save_account('QISKITRC_TOKEN', - url=IBMQ_TEMPLATE.format('a', 'b', 'c')) - self.provider.save_account('QISKITRC_TOKEN', - IBMQ_TEMPLATE.format('a', 'b', 'X')) - - # Compare the session accounts with the ones stored in file. - stored_accounts = read_credentials_from_qiskitrc() - self.assertEqual(len(stored_accounts), 3) - for account_name, provider in self.provider._accounts.items(): - self.assertEqual(provider.credentials, - stored_accounts[account_name]) - - def test_save_duplicate_accounts(self): - """Test saving the same credentials twice.""" - with custom_qiskitrc(), mock_ibmq_provider(): - self.provider.save_account('QISKITRC_TOKEN') - with self.assertWarns(UserWarning) as context_manager: - self.provider.save_account('QISKITRC_TOKEN') - - self.assertIn('Set overwrite', str(context_manager.warning)) - # Compare the session accounts with the ones stored in file. - stored_accounts = read_credentials_from_qiskitrc() - self.assertEqual(len(stored_accounts), 1) - - def test_disable_accounts(self): - """Test disabling an account in a session.""" - with custom_qiskitrc(), mock_ibmq_provider(): - self.provider.enable_account('QISKITRC_TOKEN') - self.provider.disable_accounts(token='QISKITRC_TOKEN') - - self.assertEqual(len(self.provider._accounts), 0) - - def test_delete_accounts(self): - """Test deleting an account from disk.""" - with custom_qiskitrc(), mock_ibmq_provider(): - self.provider.save_account('QISKITRC_TOKEN') - self.assertEqual(len(read_credentials_from_qiskitrc()), 1) - - self.provider._accounts.clear() - self.provider.delete_accounts(token='QISKITRC_TOKEN') - self.assertEqual(len(read_credentials_from_qiskitrc()), 0) - - def test_disable_all_accounts(self): - """Test disabling all accounts from session.""" - with custom_qiskitrc(), mock_ibmq_provider(): - self.provider.enable_account('QISKITRC_TOKEN') - self.provider.enable_account('QISKITRC_TOKEN', - url=IBMQ_TEMPLATE.format('a', 'b', 'c')) - self.provider.disable_accounts() - self.assertEqual(len(self.provider._accounts), 0) - - def test_delete_all_accounts(self): - """Test deleting all accounts from disk.""" - with custom_qiskitrc(), mock_ibmq_provider(): - self.provider.save_account('QISKITRC_TOKEN') - self.provider.save_account('QISKITRC_TOKEN', - url=IBMQ_TEMPLATE.format('a', 'b', 'c')) - self.assertEqual(len(read_credentials_from_qiskitrc()), 2) - self.provider.delete_accounts() - self.assertEqual(len(self.provider._accounts), 0) - self.assertEqual(len(read_credentials_from_qiskitrc()), 0) - - def test_pass_bad_proxy(self): - """Test proxy pass through.""" - with self.assertRaises(ConnectionError) as context_manager: - self.provider.enable_account('dummy_token', 'https://dummy_url', - proxies=PROXIES) - self.assertIn('ProxyError', str(context_manager.exception)) - - # TODO: NamedTemporaryFiles do not support name in Windows @skipIf(os.name == 'nt', 'Test not supported in Windows') class TestCredentials(IBMQTestCase): @@ -186,41 +55,41 @@ def test_autoregister_no_credentials(self): """Test register() with no credentials available.""" with no_file('Qconfig.py'), custom_qiskitrc(), no_envs(CREDENTIAL_ENV_VARS): with self.assertRaises(IBMQAccountError) as context_manager: - IBMQ.load_accounts() + IBMQ.load_account() - self.assertIn('No IBMQ credentials found', str(context_manager.exception)) + self.assertIn('No IBM Q Experience credentials found', str(context_manager.exception)) def test_store_credentials_overwrite(self): """Test overwriting qiskitrc credentials.""" - credentials = Credentials('QISKITRC_TOKEN', url=QE_URL, hub='HUB') - credentials2 = Credentials('QISKITRC_TOKEN_2', url=QE_URL) + credentials = Credentials('QISKITRC_TOKEN', url=QE2_AUTH_URL) + credentials2 = Credentials('QISKITRC_TOKEN_2', url=QE2_AUTH_URL) - # Use an IBMQProvider instead of a Factory. - provider = IBMQProvider() + factory = IBMQFactory() with custom_qiskitrc(): store_credentials(credentials) # Cause all warnings to always be triggered. warnings.simplefilter("always") + + # Get the logger for `store_credentials`. + config_rc_logger = logging.getLogger(store_credentials.__module__) + # Attempt overwriting. - with warnings.catch_warnings(record=True) as w: + with self.assertLogs(logger=config_rc_logger, level='WARNING') as log_records: store_credentials(credentials) - self.assertIn('already present', str(w[0])) + self.assertIn('already present', log_records.output[0]) with no_file('Qconfig.py'), no_envs(CREDENTIAL_ENV_VARS), mock_ibmq_provider(): # Attempt overwriting. store_credentials(credentials2, overwrite=True) - provider.load_accounts() + factory.load_account() - # Ensure that the credentials are the overwritten ones - note that the - # 'hub' parameter was removed. - self.assertEqual(len(provider._accounts), 1) - self.assertEqual(list(provider._accounts.values())[0].credentials.token, - 'QISKITRC_TOKEN_2') + # Ensure that the credentials are the overwritten ones. + self.assertEqual(factory._credentials, credentials2) def test_environ_over_qiskitrc(self): """Test order, without qconfig""" - credentials = Credentials('QISKITRC_TOKEN', url=QE_URL) + credentials = Credentials('QISKITRC_TOKEN', url=QE2_AUTH_URL) with custom_qiskitrc(): # Prepare the credentials: both env and qiskitrc present @@ -234,7 +103,7 @@ def test_environ_over_qiskitrc(self): def test_qconfig_over_all(self): """Test order, with qconfig""" - credentials = Credentials('QISKITRC_TOKEN', url=QE_URL) + credentials = Credentials('QISKITRC_TOKEN', url=QE2_AUTH_URL) with custom_qiskitrc(): # Prepare the credentials: qconfig, env and qiskitrc present @@ -339,9 +208,6 @@ class TestIBMQAccountUpdater(IBMQTestCase): def setUp(self): super().setUp() - # Reference for saving accounts. - self.ibmq = IBMQProvider() - # Avoid stdout output during tests. self.patcher = patch('sys.stdout', new=StringIO()) self.patcher.start() @@ -366,7 +232,7 @@ def assertCorrectApi2Credentials(self, token, credentials_dict): def test_qe_credentials(self): """Test converting QE credentials.""" with custom_qiskitrc(): - self.ibmq.save_account('A', url=QE_URL) + store_credentials(Credentials('A', url=QE_URL)) _ = update_credentials(force=True) # Assert over the stored (updated) credentials. @@ -376,8 +242,8 @@ def test_qe_credentials(self): def test_qconsole_credentials(self): """Test converting Qconsole credentials.""" with custom_qiskitrc(): - self.ibmq.save_account('A', - url=IBMQ_TEMPLATE.format('a', 'b', 'c')) + store_credentials(Credentials('A', + url=IBMQ_TEMPLATE.format('a', 'b', 'c'))) _ = update_credentials(force=True) # Assert over the stored (updated) credentials. @@ -387,9 +253,9 @@ def test_qconsole_credentials(self): def test_proxy_credentials(self): """Test converting credentials with proxy values.""" with custom_qiskitrc(): - self.ibmq.save_account('A', - url=IBMQ_TEMPLATE.format('a', 'b', 'c'), - proxies=PROXIES) + store_credentials(Credentials('A', + url=IBMQ_TEMPLATE.format('a', 'b', 'c'), + proxies=PROXIES)) _ = update_credentials(force=True) # Assert over the stored (updated) credentials. @@ -403,12 +269,11 @@ def test_proxy_credentials(self): def test_multiple_credentials(self): """Test converting multiple credentials.""" with custom_qiskitrc(): - self.ibmq.save_account('A', url=QE_URL) - self.ibmq.save_account('B', - url=IBMQ_TEMPLATE.format('a', 'b', 'c')) - self.ibmq.save_account('C', - url=IBMQ_TEMPLATE.format('d', 'e', 'f')) - + store_credentials(Credentials('A', url=QE2_AUTH_URL)) + store_credentials(Credentials('B', + url=IBMQ_TEMPLATE.format('a', 'b', 'c'))) + store_credentials(Credentials('C', + url=IBMQ_TEMPLATE.format('d', 'e', 'f'))) _ = update_credentials(force=True) # Assert over the stored (updated) credentials. @@ -420,7 +285,7 @@ def test_multiple_credentials(self): def test_api2_non_auth_credentials(self): """Test converting api 2 non auth credentials.""" with custom_qiskitrc(): - self.ibmq.save_account('A', url=QE2_URL) + store_credentials(Credentials('A', url=QE2_URL)) _ = update_credentials(force=True) # Assert over the stored (updated) credentials. @@ -430,7 +295,7 @@ def test_api2_non_auth_credentials(self): def test_auth2_credentials(self): """Test converting already API 2 auth credentials.""" with custom_qiskitrc(): - self.ibmq.save_account('A', url=QE2_AUTH_URL) + store_credentials(Credentials('A', url=QE2_AUTH_URL)) credentials = update_credentials(force=True) # No credentials should be returned. @@ -439,7 +304,7 @@ def test_auth2_credentials(self): def test_unknown_credentials(self): """Test converting credentials with an unknown URL.""" with custom_qiskitrc(): - self.ibmq.save_account('A', url='UNKNOWN_URL') + store_credentials(Credentials('A', url='UNKNOWN_URL')) credentials = update_credentials(force=True) # No credentials should be returned nor updated. @@ -470,11 +335,19 @@ def custom_qconfig(contents=b''): qconfig.DEFAULT_QCONFIG_FILE = default_qconfig_file_original +def _mocked_initialize_provider(self, credentials: Credentials): + """Mock `_initialize_provider()`, just storing the credentials.""" + self._credentials = credentials + + @contextmanager def mock_ibmq_provider(): - """Mock the initialization of IBMQSingleProvider, so it does not query the api.""" - patcher = patch.object(IBMQSingleProvider, '_authenticate', return_value=None) - patcher2 = patch.object(IBMQSingleProvider, '_discover_remote_backends', return_value={}) + """Mock the initialization of IBMQFactory, so it does not query the api.""" + patcher = patch.object(IBMQFactory, '_initialize_providers', + side_effect=_mocked_initialize_provider, + autospec=True) + patcher2 = patch.object(IBMQFactory, '_check_api_version', + return_value={'new_api': True, 'api-auth': '0.1'}) patcher.start() patcher2.start() yield diff --git a/test/ibmq/websocket/test_websocket.py b/test/ibmq/websocket/test_websocket.py index 59582500c..4c9fd59ae 100644 --- a/test/ibmq/websocket/test_websocket.py +++ b/test/ibmq/websocket/test_websocket.py @@ -20,16 +20,18 @@ import websockets -from qiskit.providers.ibmq.api_v2.exceptions import ( +from qiskit.providers.ibmq.api.exceptions import ( WebsocketError, WebsocketTimeoutError, WebsocketIBMQProtocolError) -from qiskit.providers.ibmq.api_v2.clients.websocket import WebsocketClient +from qiskit.providers.ibmq.api.clients.websocket import WebsocketClient from ...ibmqtestcase import IBMQTestCase from .websocket_server import ( TOKEN_JOB_COMPLETED, TOKEN_JOB_TRANSITION, TOKEN_WRONG_FORMAT, - TOKEN_TIMEOUT, websocket_handler) + TOKEN_TIMEOUT, TOKEN_WEBSOCKET_RETRY_SUCCESS, + TOKEN_WEBSOCKET_RETRY_FAILURE, TOKEN_WEBSOCKET_JOB_NOT_FOUND, + websocket_handler) TEST_IP_ADDRESS = '127.0.0.1' INVALID_PORT = 9876 @@ -111,3 +113,29 @@ def test_invalid_response(self): with self.assertRaises(WebsocketIBMQProtocolError): _ = asyncio.get_event_loop().run_until_complete( client.get_job_status('job_id')) + + def test_websocket_retry_success(self): + """Test retrieving a job status during a retry attempt.""" + client = WebsocketClient('ws://{}:{}'.format( + TEST_IP_ADDRESS, VALID_PORT), TOKEN_WEBSOCKET_RETRY_SUCCESS) + response = asyncio.get_event_loop().run_until_complete( + client.get_job_status('job_id')) + self.assertIsInstance(response, dict) + self.assertIn('status', response) + self.assertEqual(response['status'], 'COMPLETED') + + def test_websocket_retry_failure(self): + """Test exceeding the retry limit for retrieving a job status.""" + client = WebsocketClient('ws://{}:{}'.format( + TEST_IP_ADDRESS, VALID_PORT), TOKEN_WEBSOCKET_RETRY_FAILURE) + with self.assertRaises(WebsocketError): + _ = asyncio.get_event_loop().run_until_complete( + client.get_job_status('job_id')) + + def test_websocket_job_not_found(self): + """Test retrieving a job status for an non existent id.""" + client = WebsocketClient('ws://{}:{}'.format( + TEST_IP_ADDRESS, VALID_PORT), TOKEN_WEBSOCKET_JOB_NOT_FOUND) + with self.assertRaises(WebsocketError): + _ = asyncio.get_event_loop().run_until_complete( + client.get_job_status('job_id')) diff --git a/test/ibmq/websocket/test_websocket_integration.py b/test/ibmq/websocket/test_websocket_integration.py index a61aeebab..ecbee5dfd 100644 --- a/test/ibmq/websocket/test_websocket_integration.py +++ b/test/ibmq/websocket/test_websocket_integration.py @@ -14,22 +14,21 @@ """Test for the Websocket client integration.""" -from unittest import mock, skip +from unittest import mock from threading import Thread from queue import Queue from qiskit import ClassicalRegister, QuantumCircuit, QuantumRegister from qiskit.compiler import assemble, transpile from qiskit.providers import JobTimeoutError -from qiskit.providers.ibmq import least_busy -from qiskit.providers.ibmq.api_v2.clients.websocket import WebsocketClient, WebsocketMessage +from qiskit.providers.ibmq.api.clients.websocket import ( + WebsocketClient, WebsocketAuthenticationMessage) +from qiskit.providers.ibmq.api.clients import AccountClient from qiskit.providers.ibmq.ibmqfactory import IBMQFactory -from qiskit.providers.ibmq.job.ibmqjob import IBMQJob from qiskit.providers.jobstatus import JobStatus -from qiskit.test import slow_test from ...ibmqtestcase import IBMQTestCase -from ...decorators import requires_qe_access, requires_new_api_auth +from ...decorators import requires_qe_access, run_on_device class TestWebsocketIntegration(IBMQTestCase): @@ -37,7 +36,6 @@ class TestWebsocketIntegration(IBMQTestCase): @classmethod @requires_qe_access - @requires_new_api_auth def _get_provider(cls, qe_token=None, qe_url=None): """Helper for getting account credentials.""" ibmq_factory = IBMQFactory() @@ -63,38 +61,38 @@ def test_websockets_simulator(self): job = self.sim_backend.run(self.qobj) # Manually disable the non-websocket polling. - job._wait_for_final_status = None + job._api._job_final_status_polling = None result = job.result() self.assertEqual(result.status, 'COMPLETED') - @slow_test - def test_websockets_device(self): + @run_on_device + def test_websockets_device(self, provider, backend): # pylint: disable=unused-argument """Test checking status of a job via websockets for a device.""" - backend = least_busy(self.provider.backends(simulator=False)) - qc = transpile(self.qc1, backend=backend) qobj = assemble(qc, backend=backend) job = backend.run(qobj) # Manually disable the non-websocket polling. - job._wait_for_final_status = None - result = job.result() + job._api._job_final_status_polling = None + result = job.result(timeout=180) - self.assertEqual(result.status, 'COMPLETED') + self.assertTrue(result.success) - @skip('TODO: reenable after api changes') def test_websockets_job_final_state(self): """Test checking status of a job in a final state via websockets.""" job = self.sim_backend.run(self.qobj) - # Manually disable the non-websocket polling. - job._wait_for_final_status = None - # Cancel the job to put it in a final (cancelled) state. - job.cancel() job._wait_for_completion() - self.assertIs(job._status, JobStatus.CANCELLED) + # Manually disable the non-websocket polling. + job._api._job_final_status_polling = None + + # Pretend we haven't seen the final status + job._status = JobStatus.RUNNING + + job._wait_for_completion() + self.assertIs(job._status, JobStatus.DONE) def test_websockets_retry_bad_url(self): """Test http retry after websocket error due to an invalid URL.""" @@ -114,50 +112,47 @@ def test_websockets_retry_bad_url(self): self.assertIs(job._status, JobStatus.DONE) @mock.patch.object(WebsocketClient, '_authentication_message', - return_value=WebsocketMessage(type_='authentication', data='phantom_token')) + return_value=WebsocketAuthenticationMessage( + type_='authentication', data='phantom_token')) def test_websockets_retry_bad_auth(self, _): """Test http retry after websocket error due to a failed authentication.""" job = self.sim_backend.run(self.qobj) - with mock.patch.object(IBMQJob, '_wait_for_final_status', - side_effect=job._wait_for_final_status) as mocked_wait: + with mock.patch.object(AccountClient, 'job_status', + side_effect=job._api.job_status) as mocked_wait: job._wait_for_completion() self.assertIs(job._status, JobStatus.DONE) - mocked_wait.assert_called_with(mock.ANY, mock.ANY) + mocked_wait.assert_called_with(job.job_id()) def test_websockets_retry_connection_closed(self): """Test http retry after websocket error due to closed connection.""" - def _final_status_side_effect(*args, **kwargs): + def _job_status_side_effect(*args, **kwargs): """Side effect function to restore job ID""" + # pylint: disable=unused-argument job._job_id = saved_job_id - return saved_wait_for_final_status(*args, **kwargs) + return saved_job_status(saved_job_id) job = self.sim_backend.run(self.qobj) - job._wait_for_submission() # Save the originals. saved_job_id = job._job_id - saved_wait_for_final_status = job._wait_for_final_status + saved_job_status = job._api.job_status # Use bad job ID to fail the status retrieval. job._job_id = '12345' # job.result() should retry with http successfully after getting websockets error. - with mock.patch.object(IBMQJob, '_wait_for_final_status', - side_effect=_final_status_side_effect): + with mock.patch.object(AccountClient, 'job_status', + side_effect=_job_status_side_effect): job._wait_for_completion() self.assertIs(job._status, JobStatus.DONE) - @slow_test def test_websockets_timeout(self): """Test timeout checking status of a job via websockets.""" - backend = least_busy(self.provider.backends(simulator=False)) - - qc = transpile(self.qc1, backend=backend) - qobj = assemble(qc, backend=backend) - job = backend.run(qobj) + qc = transpile(self.qc1, backend=self.sim_backend) + qobj = assemble(qc, backend=self.sim_backend, shots=2048) + job = self.sim_backend.run(qobj) - job._wait_for_submission() with self.assertRaises(JobTimeoutError): job.result(timeout=0.1) @@ -167,7 +162,7 @@ def test_websockets_multi_job(self): def _run_job_get_result(q): job = self.sim_backend.run(self.qobj) # Manually disable the non-websocket polling. - job._wait_for_final_status = None + job._api._job_final_status_polling = None job._wait_for_completion() if job._status is not JobStatus.DONE: q.put(False) diff --git a/test/ibmq/websocket/websocket_server.py b/test/ibmq/websocket/websocket_server.py index 24ac4163a..cc4e1547d 100644 --- a/test/ibmq/websocket/websocket_server.py +++ b/test/ibmq/websocket/websocket_server.py @@ -17,13 +17,16 @@ import asyncio import json -from qiskit.providers.ibmq.api_v2.clients.websocket import WebsocketMessage +from qiskit.providers.ibmq.api.clients.websocket import WebsocketResponseMethod TOKEN_JOB_COMPLETED = 'token_job_completed' TOKEN_JOB_TRANSITION = 'token_job_transition' TOKEN_TIMEOUT = 'token_timeout' TOKEN_WRONG_FORMAT = 'token_wrong_format' +TOKEN_WEBSOCKET_RETRY_SUCCESS = 'token_websocket_retry_success' +TOKEN_WEBSOCKET_RETRY_FAILURE = 'token_websocket_retry_failure' +TOKEN_WEBSOCKET_JOB_NOT_FOUND = 'token_websocket_job_not_found' @asyncio.coroutine @@ -39,7 +42,10 @@ def websocket_handler(websocket, path): if token in (TOKEN_JOB_COMPLETED, TOKEN_JOB_TRANSITION, TOKEN_TIMEOUT, - TOKEN_WRONG_FORMAT): + TOKEN_WRONG_FORMAT, + TOKEN_WEBSOCKET_RETRY_SUCCESS, + TOKEN_WEBSOCKET_RETRY_FAILURE, + TOKEN_WEBSOCKET_JOB_NOT_FOUND): msg_out = json.dumps({'type': 'authenticated'}) yield from websocket.send(msg_out.encode('utf8')) else: @@ -55,13 +61,19 @@ def websocket_handler(websocket, path): yield from handle_token_timeout(websocket) elif token == TOKEN_WRONG_FORMAT: yield from handle_token_wrong_format(websocket) + elif token == TOKEN_WEBSOCKET_RETRY_SUCCESS: + yield from handle_token_retry_success(websocket) + elif token == TOKEN_WEBSOCKET_RETRY_FAILURE: + yield from handle_token_retry_failure(websocket) + elif token == TOKEN_WEBSOCKET_JOB_NOT_FOUND: + yield from handle_token_job_not_found(websocket) @asyncio.coroutine def handle_token_job_completed(websocket): """Return a final job status, and close with 4002.""" - msg_out = WebsocketMessage(type_='job-status', - data={'status': 'COMPLETED'}) + msg_out = WebsocketResponseMethod(type_='job-status', + data={'status': 'COMPLETED'}) yield from websocket.send(msg_out.as_json().encode('utf8')) yield from websocket.close(code=4002) @@ -70,13 +82,13 @@ def handle_token_job_completed(websocket): @asyncio.coroutine def handle_token_job_transition(websocket): """Send several job status, and close with 4002.""" - msg_out = WebsocketMessage(type_='job-status', - data={'status': 'RUNNING'}) + msg_out = WebsocketResponseMethod(type_='job-status', + data={'status': 'RUNNING'}) yield from websocket.send(msg_out.as_json().encode('utf8')) yield from asyncio.sleep(1) - msg_out = WebsocketMessage(type_='job-status', - data={'status': 'COMPLETED'}) + msg_out = WebsocketResponseMethod(type_='job-status', + data={'status': 'COMPLETED'}) yield from websocket.send(msg_out.as_json().encode('utf8')) yield from websocket.close(code=4002) @@ -94,3 +106,25 @@ def handle_token_wrong_format(websocket): """Return a status in an invalid format.""" yield from websocket.send('INVALID'.encode('utf8')) yield from websocket.close() + + +@asyncio.coroutine +def handle_token_retry_success(websocket): + """Close the socket once and force a retry.""" + if not hasattr(handle_token_retry_success, 'retry_attempt'): + setattr(handle_token_retry_success, 'retry_attempt', True) + yield from handle_token_retry_failure(websocket) + else: + yield from handle_token_job_completed(websocket) + + +@asyncio.coroutine +def handle_token_retry_failure(websocket): + """Continually close the socket, until both the first attempt and retry fail.""" + yield from websocket.close() + + +@asyncio.coroutine +def handle_token_job_not_found(websocket): + """Close the socket, specifying code for job not found.""" + yield from websocket.close(code=4003) diff --git a/test/ibmqtestcase.py b/test/ibmqtestcase.py index c2d55b0cc..c37dbc4c8 100644 --- a/test/ibmqtestcase.py +++ b/test/ibmqtestcase.py @@ -24,7 +24,6 @@ def tearDown(self): # Reset the default providers, as in practice they acts as a singleton # due to importing the wrapper from qiskit. from qiskit.providers.ibmq import IBMQ - IBMQ._v1_provider._accounts.clear() IBMQ._providers.clear() IBMQ._credentials = None