Skip to content

Commit

Permalink
Merge e7a2c62 into 4e9e6ef
Browse files Browse the repository at this point in the history
  • Loading branch information
randomir committed Dec 21, 2020
2 parents 4e9e6ef + e7a2c62 commit 3a0d2fd
Show file tree
Hide file tree
Showing 13 changed files with 603 additions and 274 deletions.
1 change: 1 addition & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ def iad_add_directive_header(self, sig):
intersphinx_mapping = {
'python': ('https://docs.python.org/3', None),
'numpy': ('https://numpy.org/doc/stable/', None),
'urllib3': ('https://urllib3.readthedocs.io/en/stable/', None),
'oceandocs': ('https://docs.ocean.dwavesys.com/en/stable/', None),
'sysdocs_gettingstarted': ('https://docs.dwavesys.com/docs/latest/', None),
}
350 changes: 203 additions & 147 deletions dwave/cloud/client.py

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions dwave/cloud/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,20 @@ def parse_float(s, default=None):
return float(s)


def parse_int(s, default=None):
"""Parse value as returned by ConfigParse as int.
NB: we need this instead of ``ConfigParser.getint`` when we're parsing
values downstream.
"""

if s is None or s == '':
return default
if float(s) != int(s):
raise ValueError
return int(s)


def parse_boolean(s, default=None):
"""Parse value as returned by ConfigParse as bool.
Expand Down
37 changes: 31 additions & 6 deletions dwave/cloud/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,23 +22,48 @@ class ConfigFileParseError(ConfigFileError):
"""Invalid format of config file."""


class SolverError(Exception):
class SAPIError(Exception):
"""Generic SAPI error base class."""

def __init__(self, *args, **kwargs):
self.error_msg = kwargs.pop('error_msg', None)
self.error_code = kwargs.pop('error_code', None)
super(SAPIError, self).__init__(*args, **kwargs)

def __str__(self):
return super(SAPIError, self).__str__() or self.error_msg or ''

class ResourceAuthenticationError(SAPIError):
"""Access to resource not authorized."""

class ResourceNotFoundError(SAPIError):
"""Resource not found."""


class SolverError(SAPIError):
"""Generic base class for all solver-related errors."""

class ProblemError(SAPIError):
"""Generic base class for all problem-related errors."""


class SolverFailureError(SolverError):
"""An exception raised when there is a remote failure calling a solver."""

class SolverNotFoundError(SolverError):
class SolverNotFoundError(ResourceNotFoundError, SolverError):
"""Solver with matching feature set not found / not available."""

class ProblemNotFoundError(ResourceNotFoundError, ProblemError):
"""Problem not found."""

class SolverOfflineError(SolverError):
"""Action attempted on an offline solver."""

class SolverAuthenticationError(SolverError):
class SolverAuthenticationError(ResourceAuthenticationError, SolverError):
"""An exception raised when there is an authentication error."""

def __init__(self):
super(SolverAuthenticationError, self).__init__("Token not accepted for that action.")
def __init__(self, *args, **kwargs):
super(SolverAuthenticationError, self).__init__("Invalid token or access denied.", *args, **kwargs)

class UnsupportedSolverError(SolverError):
"""The solver we received from the API is not supported by the client."""
Expand All @@ -64,7 +89,7 @@ def __init__(self):
super(CanceledFutureError, self).__init__("An error occurred reading results from a canceled request")


class InvalidAPIResponseError(Exception):
class InvalidAPIResponseError(SAPIError):
"""Raised when an invalid/unexpected response from D-Wave Solver API is received."""


Expand Down
71 changes: 70 additions & 1 deletion dwave/cloud/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@

__all__ = ['evaluate_ising', 'uniform_iterator', 'uniform_get',
'default_text_input', 'click_info_switch', 'datetime_to_timestamp',
'datetime_to_timestamp', 'utcnow', 'epochnow', 'tictoc']
'datetime_to_timestamp', 'utcnow', 'epochnow', 'tictoc',
'hasinstance', 'exception_chain', 'is_caused_by']

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -303,6 +304,74 @@ def create_url(self, url):
return urljoin(self.base_url, url)


def hasinstance(iterable, class_or_tuple):
"""Extension of ``isinstance`` to iterables/sequences. Returns True iff the
sequence contains at least one object which is instance of ``class_or_tuple``.
"""

return any(isinstance(e, class_or_tuple) for e in iterable)


def exception_chain(exception):
"""Traverse the chain of embedded exceptions, yielding one at the time.
Args:
exception (:class:`Exception`): Chained exception.
Yields:
:class:`Exception`: The next exception in the input exception's chain.
Examples:
def f():
try:
1/0
except ZeroDivisionError:
raise ValueError
try:
f()
except Exception as e:
assert(hasinstance(exception_chain(e), ZeroDivisionError))
See: PEP-3134.
"""

while exception:
yield exception

# explicit exception chaining, i.e `raise .. from ..`
if exception.__cause__:
exception = exception.__cause__

# implicit exception chaining
elif exception.__context__:
exception = exception.__context__

else:
return


def is_caused_by(exception, exception_types):
"""Check if any of ``exception_types`` is causing the ``exception``.
Equivalently, check if any of ``exception_types`` is contained in the
exception chain rooted at ``exception``.
Args:
exception (:class:`Exception`):
Chained exception.
exception_types (:class:`Exception` or tuple of :class:`Exception`):
Exception type or a tuple of exception types to check for.
Returns:
bool:
True when ``exception`` is caused by any of the exceptions in
``exception_types``.
"""

return hasinstance(exception_chain(exception), exception_types)


def user_agent(name=None, version=None):
"""Return User-Agent ~ "name/version language/version interpreter/version os/version"."""

Expand Down
5 changes: 3 additions & 2 deletions tests/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mock
requests_mock
requests_mock>=1.8
coverage
coveralls
coveralls
parameterized
118 changes: 109 additions & 9 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,14 @@
from unittest import mock
from contextlib import contextmanager

import requests.exceptions
import requests
from plucky import merge

from dwave.cloud.client import Client
from dwave.cloud.solver import StructuredSolver, UnstructuredSolver
from dwave.cloud.exceptions import (
SolverAuthenticationError, SolverError, SolverNotFoundError)
from dwave.cloud.testing import iterable_mock_open
import dwave.cloud

from tests import config
Expand Down Expand Up @@ -147,29 +148,29 @@ def test_get_solver_no_defaults(self):
with Client(**conf) as base_client:

with qpu.Client(**conf) as client:
solvers = base_client.get_solvers(qpu=True)
solvers = {s.id for s in base_client.get_solvers(qpu=True)}
if solvers:
self.assertEqual(client.get_solver().id, solvers[0].id)
self.assertIn(client.get_solver().id, solvers)
else:
self.assertRaises(SolverError, client.get_solver)

with sw.Client(**conf) as client:
solvers = base_client.get_solvers(software=True)
solvers = {s.id for s in base_client.get_solvers(software=True)}
if solvers:
self.assertEqual(client.get_solver().id, solvers[0].id)
self.assertIn(client.get_solver().id, solvers)
else:
self.assertRaises(SolverError, client.get_solver)

with hybrid.Client(**conf) as client:
solvers = base_client.get_solvers(hybrid=True)
solvers = {s.id for s in base_client.get_solvers(hybrid=True)}
if solvers:
self.assertEqual(client.get_solver().id, solvers[0].id)
self.assertIn(client.get_solver().id, solvers)
else:
self.assertRaises(SolverError, client.get_solver)


class ClientFactory(unittest.TestCase):
"""Test Client.from_config() factory."""
class ClientConstruction(unittest.TestCase):
"""Test Client constructor and Client.from_config() factory."""

def test_default(self):
conf = {k: k for k in 'endpoint token'.split()}
Expand Down Expand Up @@ -458,6 +459,105 @@ def test_polling_params_from_kwargs(self):
self.assertEqual(client.poll_backoff_min, 0.5)
self.assertEqual(client.poll_backoff_max, 1.0)

def _verify_retry_config(self, retry, opts):
self.assertEqual(retry.total, opts['http_retry_total'])
self.assertEqual(retry.connect, opts['http_retry_connect'])
self.assertEqual(retry.read, opts['http_retry_read'])
self.assertEqual(retry.redirect, opts['http_retry_redirect'])
self.assertEqual(retry.status, opts['http_retry_status'])
self.assertEqual(retry.backoff_factor, opts['http_retry_backoff_factor'])
self.assertEqual(retry.BACKOFF_MAX, opts['http_retry_backoff_max'])

def test_http_retry_params_from_config(self):
retry_opts = {
"http_retry_total": 3,
"http_retry_connect": 2,
"http_retry_read": 2,
"http_retry_redirect": 0,
"http_retry_status": 2,
"http_retry_backoff_factor": 0.5,
"http_retry_backoff_max": 30,
}
retry_conf = {k: str(v) for k, v in retry_opts.items()}
conf = dict(token='token', **retry_conf)

# http retry params from config file propagated to client object
with mock.patch("dwave.cloud.client.load_config", lambda **kw: conf):
with dwave.cloud.Client.from_config() as client:
for opt, val in retry_opts.items():
self.assertEqual(getattr(client, opt), val,
"%s doesn't match" % opt)

# verify Retry object config
retry = client.session.get_adapter('https://').max_retries
self._verify_retry_config(retry, retry_opts)

# test defaults
conf = dict(token='token')
with mock.patch("dwave.cloud.client.load_config", lambda **kw: conf):
with dwave.cloud.Client.from_config() as client:
for param in retry_conf:
self.assertEqual(getattr(client, param), Client.DEFAULTS[param])

def test_http_retry_params_from_kwargs(self):
retry_kwargs = {
"http_retry_total": 3,
"http_retry_connect": 2,
"http_retry_read": None,
"http_retry_redirect": 0,
"http_retry_status": None,
"http_retry_backoff_factor": 0.5,
"http_retry_backoff_max": 30,
}
conf = dict(token='token')

with mock.patch("dwave.cloud.client.load_config", lambda **kw: conf):
with dwave.cloud.Client.from_config(**retry_kwargs) as client:
# verify client final config
for opt, val in retry_kwargs.items():
self.assertEqual(getattr(client, opt), val,
"%s doesn't match" % opt)

# verify Retry object config
retry = client.session.get_adapter('https://').max_retries
self._verify_retry_config(retry, retry_kwargs)


class ClientConfigIntegration(unittest.TestCase):

def test_custom_options(self):
"""Test custom options (request_timeout, polling_timeout, permissive_ssl)
are propagated to Client."""

request_timeout = 15
polling_timeout = 180

config_body = """
[custom]
token = 123
permissive_ssl = on
request_timeout = {}
polling_timeout = {}
""".format(request_timeout, polling_timeout)

with mock.patch("dwave.cloud.config.open", iterable_mock_open(config_body)):
with Client.from_config('config_file', profile='custom') as client:
# check permissive_ssl and timeouts custom params passed-thru
self.assertFalse(client.session.verify)
self.assertEqual(client.request_timeout, request_timeout)
self.assertEqual(client.polling_timeout, polling_timeout)

# verify client uses those properly
def mock_send(*args, **kwargs):
self.assertEqual(kwargs.get('timeout'), request_timeout)
response = requests.Response()
response.status_code = 200
response._content = b'{}'
return response

with mock.patch("requests.adapters.HTTPAdapter.send", mock_send):
client.get_solvers()


class FeatureBasedSolverSelection(unittest.TestCase):
"""Test Client.get_solvers(**filters)."""
Expand Down
18 changes: 16 additions & 2 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@
from dwave.cloud.testing import iterable_mock_open
from dwave.cloud.config import (
get_configfile_paths, load_config_from_files, load_config,
parse_float, parse_boolean)
parse_float, parse_int, parse_boolean)


class TestConfig(unittest.TestCase):
class TestConfigParsing(unittest.TestCase):

config_body = """
[defaults]
Expand Down Expand Up @@ -400,6 +400,20 @@ def test_parse_float(self):
self.assertEqual(parse_float(1.5), 1.5)
self.assertEqual(parse_float(1), 1.0)

def test_parse_int(self):
self.assertEqual(parse_int(None), None)
self.assertEqual(parse_int(''), None)
self.assertEqual(parse_int('', default=1), 1)

with self.assertRaises(ValueError):
parse_int('1.5')
with self.assertRaises(ValueError):
parse_int(1.5)

self.assertEqual(parse_int(123), 123)
self.assertEqual(parse_int(0), 0)
self.assertEqual(parse_int(-123), -123)

def test_parse_boolean(self):
self.assertEqual(parse_boolean(None), None)
self.assertEqual(parse_boolean(''), None)
Expand Down
2 changes: 1 addition & 1 deletion tests/test_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def test_solver_auth_error_msg(self):
try:
raise SolverAuthenticationError
except Exception as e:
self.assertEqual(str(e), "Token not accepted for that action.")
self.assertEqual(str(e), "Invalid token or access denied.")

def test_canceled_future_error_msg(self):
try:
Expand Down
Loading

0 comments on commit 3a0d2fd

Please sign in to comment.