You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@mesos.apache.org by kl...@apache.org on 2018/02/07 15:09:41 UTC
[4/4] mesos git commit: Added 'mesos.http' and 'mesos.exceptions' for
the python-based CLI.
Added 'mesos.http' and 'mesos.exceptions' for the python-based CLI.
Part of MESOS-7310, this patch adds the mesos.http and mesos.exceptions
modules, which provides a Resource class and its descendants for
abstracting away common operations over http connections with JSON
serialization.
Review: https://reviews.apache.org/r/61172/
Project: http://git-wip-us.apache.org/repos/asf/mesos/repo
Commit: http://git-wip-us.apache.org/repos/asf/mesos/commit/99421373
Tree: http://git-wip-us.apache.org/repos/asf/mesos/tree/99421373
Diff: http://git-wip-us.apache.org/repos/asf/mesos/diff/99421373
Branch: refs/heads/master
Commit: 994213739b1afc473bbd9d15ded7c3fd26eaa924
Parents: dea1b31
Author: Eric Chung <ci...@gmail.com>
Authored: Wed Feb 7 16:02:17 2018 +0100
Committer: Kevin Klues <kl...@gmail.com>
Committed: Wed Feb 7 16:08:47 2018 +0100
----------------------------------------------------------------------
src/python/lib/mesos/exceptions.py | 110 ++++++++
src/python/lib/mesos/http.py | 391 +++++++++++++++++++++++++++
src/python/lib/tests/conftest.py | 33 +++
src/python/lib/tests/test_exceptions.py | 63 +++++
src/python/lib/tests/test_http.py | 353 ++++++++++++++++++++++++
5 files changed, 950 insertions(+)
----------------------------------------------------------------------
http://git-wip-us.apache.org/repos/asf/mesos/blob/99421373/src/python/lib/mesos/exceptions.py
----------------------------------------------------------------------
diff --git a/src/python/lib/mesos/exceptions.py b/src/python/lib/mesos/exceptions.py
new file mode 100644
index 0000000..63c7aa6
--- /dev/null
+++ b/src/python/lib/mesos/exceptions.py
@@ -0,0 +1,110 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Exceptions Classes
+"""
+
+from __future__ import absolute_import
+
+
+class MesosException(Exception):
+ """
+ Exceptions class to be inherited by all Mesos exceptions.
+ """
+ def __init__(self, message=None, original_exception=None):
+ if original_exception is not None:
+ message = "{msg}: {exception}".format(
+ msg=message, exception=original_exception)
+ self.original_exception = original_exception
+ super(MesosException, self).__init__(message)
+
+
+class MesosHTTPException(MesosException):
+ """
+ A wrapper around Response objects for HTTP error codes.
+
+ :param response: requests Response object
+ :type response: Response
+ """
+ STATUS_CODE = None
+
+ def __init__(self, response):
+ super(MesosHTTPException, self).__init__()
+ self.response = response
+
+ def status(self):
+ """
+ Return status code from response.
+
+ :return: status code
+ :rtype: int
+ """
+ if self.STATUS_CODE is None:
+ return self.response.status_code
+ return self.STATUS_CODE
+
+ def __str__(self):
+ return "The url '{url}' returned HTTP {status_code}: {text}"\
+ .format(url=self.response.request.url,
+ status_code=self.response.status_code,
+ text=self.response.text)
+
+
+class MesosAuthenticationException(MesosHTTPException):
+ """
+ Indicates an authentication failure.
+ """
+ STATUS_CODE = 401
+
+
+class MesosUnprocessableException(MesosHTTPException):
+ """
+ The request was well-formed but was unable to be followed due to semantic
+ errors.
+ """
+ STATUS_CODE = 422
+
+
+class MesosAuthorizationException(MesosHTTPException):
+ """
+ The request was valid but the server is refusing action.
+ """
+ STATUS_CODE = 403
+
+
+class MesosBadRequestException(MesosHTTPException):
+ """
+ The server cannot or will not process the request due to an apparent client
+ error.
+ """
+ STATUS_CODE = 400
+
+
+class MesosServiceUnavailableException(MesosHTTPException):
+ """
+ The server is currently unavailable (because it is overloaded or down for
+ maintenance).
+ """
+ STATUS_CODE = 503
+
+
+class MesosInternalServerErrorException(MesosHTTPException):
+ """
+ An unexpected condition was encountered and no more specific
+ message is suitable.
+ """
+ STATUS_CODE = 500
http://git-wip-us.apache.org/repos/asf/mesos/blob/99421373/src/python/lib/mesos/http.py
----------------------------------------------------------------------
diff --git a/src/python/lib/mesos/http.py b/src/python/lib/mesos/http.py
new file mode 100644
index 0000000..073c159
--- /dev/null
+++ b/src/python/lib/mesos/http.py
@@ -0,0 +1,391 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# pylint: disable=too-many-arguments
+
+"""
+Classes and functions for interacting with the Mesos HTTP RESTful API
+"""
+
+from __future__ import absolute_import
+
+from urlparse import urlparse
+from copy import deepcopy
+
+import requests
+import tenacity
+import ujson
+
+from mesos.exceptions import MesosException
+from mesos.exceptions import MesosHTTPException
+from mesos.exceptions import MesosAuthenticationException
+from mesos.exceptions import MesosAuthorizationException
+from mesos.exceptions import MesosBadRequestException
+from mesos.exceptions import MesosInternalServerErrorException
+from mesos.exceptions import MesosServiceUnavailableException
+from mesos.exceptions import MesosUnprocessableException
+
+METHOD_HEAD = 'HEAD'
+METHOD_GET = 'GET'
+METHOD_POST = 'POST'
+METHOD_PUT = 'PUT'
+METHOD_PATCH = 'PATCH'
+METHOD_DELETE = 'DELETE'
+METHODS = {
+ METHOD_HEAD,
+ METHOD_GET,
+ METHOD_POST,
+ METHOD_PUT,
+ METHOD_PATCH,
+ METHOD_DELETE}
+
+REQUEST_JSON_HEADERS = {'Accept': 'application/json'}
+REQUEST_GZIP_HEADERS = {'Accept-Encoding': 'gzip'}
+
+BASE_HEADERS = {}
+DEFAULT_TIMEOUT = 30
+DEFAULT_AUTH = None
+DEFAULT_USE_GZIP_ENCODING = True
+DEFAULT_MAX_ATTEMPTS = 3
+
+
+def simple_urljoin(base, other):
+ """
+ Do a join by rstrip'ing / from base_url and lstrp'ing / from other.
+
+ This is needed since urlparse.urljoin tries to be too smart
+ and strips the subpath from base_url.
+
+ :type base: str
+ :type other: str
+ :rtype: str
+ """
+ return '/'.join([base.rstrip('/'), other.lstrip('/')])
+
+
+class Resource(object):
+ """
+ Encapsulate the context for an HTTP resource.
+
+ Context for an HTTP resource may include properties such as the URL,
+ default timeout for connections, default headers to be included in each
+ request, and auth.
+ """
+ SUCCESS_CODES = frozenset(xrange(200, 300))
+ ERROR_CODE_MAP = {c.STATUS_CODE: c for c in (
+ MesosBadRequestException,
+ MesosAuthenticationException,
+ MesosAuthorizationException,
+ MesosUnprocessableException,
+ MesosInternalServerErrorException,
+ MesosServiceUnavailableException)}
+
+ def __init__(self,
+ url,
+ default_headers=None,
+ default_timeout=DEFAULT_TIMEOUT,
+ default_auth=DEFAULT_AUTH,
+ default_use_gzip_encoding=DEFAULT_USE_GZIP_ENCODING,
+ default_max_attempts=DEFAULT_MAX_ATTEMPTS):
+ """
+ :param url: URL identifying the resource
+ :type url: str
+ :param default_headers: headers to attache to requests
+ :type default_headers: dict[str, str]
+ :param default_timeout: timeout in seconds
+ :type default_timeout: float
+ :param default_auth: auth scheme
+ :type default_auth: requests.auth.AuthBase
+ :param default_use_gzip_encoding: use gzip encoding by default or not
+ :type default_use_gzip_encoding: bool
+ :param default_max_attempts: max number of attempts when retrying
+ :type default_max_attempts: int
+ """
+ self.url = urlparse(url)
+ self.default_timeout = default_timeout
+ self.default_auth = default_auth
+ self.default_use_gzip_encoding = default_use_gzip_encoding
+ self.default_max_attempts = default_max_attempts
+
+ if default_headers is None:
+ self._default_headers = {}
+ else:
+ self._default_headers = deepcopy(default_headers)
+
+ def default_headers(self):
+ """
+ Return a copy of the default headers.
+
+ :rtype: dict[str, str]
+ """
+ return deepcopy(self._default_headers)
+
+ def subresource(self, subpath):
+ """
+ Return a new Resource object at a subpath of the current resource's URL.
+
+ :param subpath: subpath of the resource
+ :type subpath: str
+ :return: Resource at subpath
+ :rtype: Resource
+ """
+ return self.__class__(
+ url=simple_urljoin(self.url.geturl(), subpath),
+ default_headers=self.default_headers(),
+ default_timeout=self.default_timeout,
+ default_auth=self.default_auth,
+ default_use_gzip_encoding=self.default_use_gzip_encoding,
+ default_max_attempts=self.default_max_attempts,
+ )
+
+ def _request(self,
+ method,
+ additional_headers=None,
+ timeout=None,
+ auth=None,
+ use_gzip_encoding=None,
+ params=None,
+ **kwargs):
+ """
+ Make an HTTP request with given method and an optional timeout.
+
+ :param method: request method
+ :type method: str
+ :param additional_headers: additional headers to include in the request
+ :type additional_headers: dict[str, str]
+ :param timeout: timeout in seconds
+ :type timeout: float
+ :param auth: auth scheme for request
+ :type auth: requests.auth.AuthBase
+ :param use_gzip_encoding: boolean indicating whether to
+ pass gzip encoding in the request
+ headers or not
+ :type use_gzip_encoding: boolean
+ :param params: additional params to include in the request
+ :type params: str | dict[str, T]
+ :param kwargs: additional arguments to pass to requests.request
+ :type kwargs: dict[str, T]
+ :return: HTTP response
+ :rtype: requests.Response
+ """
+ headers = self.default_headers()
+ if additional_headers is not None:
+ headers.update(additional_headers)
+
+ if timeout is None:
+ timeout = self.default_timeout
+
+ if auth is None:
+ auth = self.default_auth
+
+ if use_gzip_encoding is None:
+ use_gzip_encoding = self.default_use_gzip_encoding
+
+ if headers and use_gzip_encoding:
+ headers.update(REQUEST_GZIP_HEADERS)
+
+ kwargs.update(dict(
+ url=self.url.geturl(),
+ method=method,
+ headers=headers,
+ timeout=timeout,
+ auth=auth,
+ params=params,
+ ))
+
+ # Here we call request without a try..except block since all exceptions
+ # raised here will be used to determine whether or not a retry is
+ # necessary in self.request.
+ response = requests.request(**kwargs)
+
+ if response.status_code in self.SUCCESS_CODES:
+ return response
+
+ known_exception = self.ERROR_CODE_MAP.get(response.status_code)
+ if known_exception:
+ raise known_exception(response)
+ else:
+ raise MesosHTTPException(response)
+
+ def request(self,
+ method,
+ additional_headers=None,
+ timeout=None,
+ auth=None,
+ use_gzip_encoding=None,
+ params=None,
+ max_attempts=None,
+ **kwargs):
+ """
+ Make an HTTP request by calling self._request with backoff retry.
+
+ :param method: request method
+ :type method: str
+ :param additional_headers: additional headers to include in the request
+ :type additional_headers: dict[str, str]
+ :param timeout: timeout in seconds, overrides default_timeout_secs
+ :type timeout: float
+ :param timeout: timeout in seconds
+ :type timeout: float
+ :param auth: auth scheme for the request
+ :type auth: requests.auth.AuthBase
+ :param use_gzip_encoding: boolean indicating whether to pass gzip
+ encoding in the request headers or not
+ :type use_gzip_encoding: boolean | None
+ :param params: additional params to include in the request
+ :type params: str | dict[str, T] | None
+ :param max_attempts: maximum number of attempts to try for any request
+ :type max_attempts: int
+ :param kwargs: additional arguments to pass to requests.request
+ :type kwargs: dict[str, T]
+ :return: HTTP response
+ :rtype: requests.Response
+ """
+ if max_attempts is None:
+ max_attempts = self.default_max_attempts
+
+ # We retry only when it makes sense: either due to a network partition
+ # (e.g. connection errors) or if the request failed due to a server
+ # error such as 500s, timeouts, and so on.
+ request_with_retry = tenacity.retry(
+ stop=tenacity.stop_after_attempt(max_attempts),
+ wait=tenacity.wait_exponential(),
+ retry=tenacity.retry_if_exception_type((
+ requests.exceptions.Timeout,
+ requests.exceptions.ConnectionError,
+ MesosServiceUnavailableException,
+ MesosInternalServerErrorException,
+ )),
+ reraise=True,
+ )(self._request)
+
+ try:
+ return request_with_retry(
+ method=method,
+ additional_headers=additional_headers,
+ timeout=timeout,
+ auth=auth,
+ use_gzip_encoding=use_gzip_encoding,
+ params=params,
+ **kwargs
+ )
+ # If the request itself failed, an exception subclassed from
+ # RequestException will be raised. Catch this and reraise as
+ # MesosException since we want the caller to be able to catch
+ # and handle this.
+ except requests.exceptions.RequestException as err:
+ raise MesosException('Request failed', err)
+
+ def request_json(self,
+ method,
+ timeout=None,
+ auth=None,
+ payload=None,
+ decoder=None,
+ params=None,
+ **kwargs):
+ """
+ Make an HTTP request and deserialize the response as JSON. Optionally
+ decode the deserialized json dict into a decoded object.
+
+ :param method: request method
+ :type method: str
+ :param timeout: timeout in seconds
+ :type timeout: float
+ :param auth: auth scheme for the request
+ :type auth: requests.auth.AuthBase
+ :param payload: json payload in the request
+ :type payload: dict[str, T] | str
+ :param decoder: decoder for json response
+ :type decoder: (dict) -> T
+ :param params: additional params to include in the request
+ :type params: str | dict[str, T]
+ :param kwargs: additional arguments to pass to requests.request
+ :type kwargs: dict[str, T]
+ :return: JSON response
+ :rtype: dict[str, T]
+ """
+ resp = self.request(method=method,
+ timeout=timeout,
+ auth=auth,
+ json=payload,
+ additional_headers=REQUEST_JSON_HEADERS,
+ params=params,
+ **kwargs)
+
+ try:
+ json_dict = ujson.loads(resp.text)
+ except ValueError as exception:
+ raise MesosException(
+ 'could not load JSON from "{data}"'.format(data=resp.text),
+ exception)
+
+ if decoder is not None:
+ return decoder(json_dict)
+
+ return json_dict
+
+ def get_json(self,
+ timeout=None,
+ auth=None,
+ decoder=None,
+ params=None):
+ """
+ Send a GET request.
+
+ :param timeout: timeout in seconds
+ :type timeout: float
+ :param auth: auth scheme for the request
+ :type auth: requests.auth.AuthBase
+ :param decoder: decoder for json response
+ :type decoder: (dict) -> T
+ :param params: additional params to include in the request
+ :type params: str | dict[str, U]
+ :rtype: dict[str, U]
+ """
+ return self.request_json(METHOD_GET,
+ timeout=timeout,
+ auth=auth,
+ decoder=decoder,
+ params=params)
+
+ def post_json(self,
+ timeout=None,
+ auth=None,
+ payload=None,
+ decoder=None,
+ params=None):
+ """
+ Sends a POST request.
+
+ :param timeout: timeout in seconds
+ :type timeout: float
+ :param auth: auth scheme for the request
+ :type auth: requests.auth.AuthBase
+ :param payload: post data
+ :type payload: dict[str, T] | str
+ :param decoder: decoder for json response
+ :type decoder: (dict) -> T
+ :param params: additional params to include in the request
+ :type params: str | dict[str, T]
+ :rtype: dict[str, T]
+ """
+ return self.request_json(METHOD_POST,
+ timeout=timeout,
+ auth=auth,
+ payload=payload,
+ decoder=decoder,
+ params=params)
http://git-wip-us.apache.org/repos/asf/mesos/blob/99421373/src/python/lib/tests/conftest.py
----------------------------------------------------------------------
diff --git a/src/python/lib/tests/conftest.py b/src/python/lib/tests/conftest.py
new file mode 100644
index 0000000..d4d6efc
--- /dev/null
+++ b/src/python/lib/tests/conftest.py
@@ -0,0 +1,33 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+PyTest configuration
+"""
+
+from __future__ import absolute_import
+
+import mock
+import pytest
+
+
+@pytest.yield_fixture()
+def mock_mesos_http_request():
+ """
+ PyTest fixture for monkey patching mesos.http.requests.request.
+ """
+ with mock.patch('mesos.http.requests.request') as mock_request:
+ yield mock_request
http://git-wip-us.apache.org/repos/asf/mesos/blob/99421373/src/python/lib/tests/test_exceptions.py
----------------------------------------------------------------------
diff --git a/src/python/lib/tests/test_exceptions.py b/src/python/lib/tests/test_exceptions.py
new file mode 100644
index 0000000..096eab8
--- /dev/null
+++ b/src/python/lib/tests/test_exceptions.py
@@ -0,0 +1,63 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# pylint: disable=redefined-outer-name,missing-docstring
+
+import mock
+import pytest
+
+from mesos.exceptions import MesosHTTPException
+from mesos.exceptions import MesosAuthenticationException
+from mesos.exceptions import MesosAuthorizationException
+from mesos.exceptions import MesosBadRequestException
+from mesos.exceptions import MesosUnprocessableException
+
+
+@pytest.mark.parametrize([
+ 'exception',
+ 'status_code',
+ 'expected_string'
+], [
+ (MesosHTTPException,
+ 400,
+ "The url 'http://some.url' returned HTTP 400: something bad happened"),
+ (MesosAuthenticationException,
+ 401,
+ "The url 'http://some.url' returned HTTP 401: something bad happened"),
+ (MesosAuthorizationException,
+ 403,
+ "The url 'http://some.url' returned HTTP 403: something bad happened"),
+ (MesosBadRequestException,
+ 400,
+ "The url 'http://some.url' returned HTTP 400: something bad happened"),
+ (MesosUnprocessableException,
+ 422,
+ "The url 'http://some.url' returned HTTP 422: something bad happened"),
+])
+def test_exceptions(exception, status_code, expected_string):
+ """
+ Test exceptions
+ """
+ mock_resp = mock.Mock()
+ mock_resp.status_code = status_code
+ mock_resp.reason = 'some_reason'
+ mock_resp.request.url = 'http://some.url'
+ mock_resp.text = 'something bad happened'
+
+ # Test MesosHTTPException
+ err = exception(mock_resp)
+ assert str(err) == expected_string
+ assert err.status() == status_code
http://git-wip-us.apache.org/repos/asf/mesos/blob/99421373/src/python/lib/tests/test_http.py
----------------------------------------------------------------------
diff --git a/src/python/lib/tests/test_http.py b/src/python/lib/tests/test_http.py
new file mode 100644
index 0000000..66dd6d7
--- /dev/null
+++ b/src/python/lib/tests/test_http.py
@@ -0,0 +1,353 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# pylint: disable=missing-docstring,protected-access,too-many-locals,too-many-arguments
+
+from __future__ import absolute_import
+
+from collections import namedtuple
+
+import mock
+import pytest
+import requests.exceptions
+import ujson
+
+from mesos import http
+
+
+def test_resource():
+ # test initialization
+ resource = http.Resource('http://some.domain/some/prefix')
+ assert resource.url.geturl() == 'http://some.domain/some/prefix'
+
+ # test subresource
+ subresource = resource.subresource('some/subpath')
+ assert subresource.url.geturl() == \
+ 'http://some.domain/some/prefix/some/subpath'
+
+
+@pytest.mark.parametrize(
+ ['default_tos',
+ 'override_tos',
+ 'expected_request_calls'],
+ [(None,
+ None,
+ [mock.call(auth=None,
+ headers={'Content-Type': 'applicatioin/json',
+ 'some-header-key': 'some-header-val'},
+ method='GET',
+ params=None,
+ somekey='someval',
+ timeout=None,
+ url='http://some.domain/some/prefix')]),
+ (10,
+ None,
+ [mock.call(auth=None,
+ headers={'Content-Type': 'applicatioin/json',
+ 'some-header-key': 'some-header-val'},
+ method='GET',
+ params=None,
+ somekey='someval',
+ timeout=10,
+ url='http://some.domain/some/prefix')]),
+ (10,
+ 100,
+ [mock.call(auth=None,
+ headers={'Content-Type': 'applicatioin/json',
+ 'some-header-key': 'some-header-val'},
+ method='GET',
+ params=None,
+ somekey='someval',
+ timeout=100,
+ url='http://some.domain/some/prefix')])])
+def test_resource_request(mock_mesos_http_request, default_tos, override_tos,
+ expected_request_calls):
+ # test request
+ mock_response = mock.Mock(status_code=200)
+ mock_mesos_http_request.return_value = mock_response
+ resource = http.Resource('http://some.domain/some/prefix',
+ default_timeout=default_tos,
+ default_headers={
+ 'Content-Type': 'applicatioin/json'})
+ ret = resource.request(http.METHOD_GET,
+ additional_headers={
+ 'some-header-key': 'some-header-val'
+ },
+ timeout=override_tos,
+ auth=None,
+ use_gzip_encoding=False,
+ max_attempts=1,
+ params=None,
+ somekey='someval')
+ assert ret == mock_response
+ assert mock_mesos_http_request.mock_calls == expected_request_calls
+
+
+SomeModel = namedtuple('SomeModel', ['some'])
+
+RequestJsonParams = namedtuple('RequestJsonParams',
+ [
+ 'default_tos',
+ 'override_tos',
+ 'json_payload',
+ 'obj_decoder',
+ 'request_exception',
+ 'resp_status',
+ 'json_exception',
+ 'expected_exception',
+ 'expected_additional_kwargs',
+ ])
+
+
+@mock.patch('mesos.http.ujson.loads')
+@pytest.mark.parametrize(
+ RequestJsonParams._fields,
+ [
+ RequestJsonParams(
+ default_tos=None,
+ override_tos=None,
+ json_payload={'some': 'payload'},
+ obj_decoder=None,
+ request_exception=None,
+ resp_status=200,
+ json_exception=None,
+ expected_exception=None,
+ expected_additional_kwargs=[{}]),
+ RequestJsonParams(
+ default_tos=None,
+ override_tos=None,
+ json_payload={'some': 'payload'},
+ obj_decoder=lambda d: SomeModel(**d),
+ request_exception=None,
+ resp_status=200,
+ json_exception=None,
+ expected_exception=None,
+ expected_additional_kwargs=[{}]),
+ RequestJsonParams(
+ default_tos=None,
+ override_tos=None,
+ json_payload={'some': 'payload'},
+ obj_decoder=None,
+ request_exception=requests.exceptions.SSLError,
+ resp_status=200,
+ json_exception=None,
+ expected_exception=http.MesosException,
+ expected_additional_kwargs=[{}]),
+ RequestJsonParams(
+ default_tos=None,
+ override_tos=None,
+ json_payload={'some': 'payload'},
+ obj_decoder=None,
+ request_exception=requests.exceptions.ConnectionError,
+ resp_status=200,
+ json_exception=None,
+ expected_exception=http.MesosException,
+ expected_additional_kwargs=[{}]),
+ RequestJsonParams(
+ default_tos=None,
+ override_tos=None,
+ json_payload={'some': 'payload'},
+ obj_decoder=None,
+ request_exception=requests.exceptions.Timeout,
+ resp_status=200,
+ json_exception=None,
+ expected_exception=http.MesosException,
+ expected_additional_kwargs=[{}]),
+ RequestJsonParams(
+ default_tos=None,
+ override_tos=None,
+ json_payload={'some': 'payload'},
+ obj_decoder=None,
+ request_exception=requests.exceptions.RequestException,
+ resp_status=200,
+ json_exception=None,
+ expected_exception=http.MesosException,
+ expected_additional_kwargs=[{}]),
+ RequestJsonParams(
+ default_tos=None,
+ override_tos=None,
+ json_payload={'some': 'payload'},
+ obj_decoder=None,
+ request_exception=None,
+ resp_status=299,
+ json_exception=None,
+ expected_exception=None,
+ expected_additional_kwargs=[{}]),
+ RequestJsonParams(
+ default_tos=None,
+ override_tos=None,
+ json_payload={'some': 'payload'},
+ obj_decoder=None,
+ request_exception=None,
+ resp_status=200,
+ json_exception=ValueError,
+ expected_exception=http.MesosException,
+ expected_additional_kwargs=[{}]),
+ RequestJsonParams(
+ default_tos=10,
+ override_tos=None,
+ json_payload={'some': 'payload'},
+ obj_decoder=None,
+ request_exception=None,
+ resp_status=200,
+ json_exception=None,
+ expected_exception=None,
+ expected_additional_kwargs=[dict(timeout=10)]),
+ RequestJsonParams(
+ default_tos=10,
+ override_tos=100,
+ json_payload={'some': 'payload'},
+ obj_decoder=None,
+ request_exception=None,
+ resp_status=200,
+ json_exception=None,
+ expected_exception=None,
+ expected_additional_kwargs=[dict(timeout=100)]),
+ RequestJsonParams(
+ default_tos=None,
+ override_tos=None,
+ json_payload={'some': 'payload'},
+ obj_decoder=None,
+ request_exception=None,
+ resp_status=400,
+ json_exception=None,
+ expected_exception=http.Resource.ERROR_CODE_MAP[400],
+ expected_additional_kwargs=[{}]),
+ RequestJsonParams(
+ default_tos=None,
+ override_tos=None,
+ json_payload={'some': 'payload'},
+ obj_decoder=None,
+ request_exception=None,
+ resp_status=999,
+ json_exception=None,
+ expected_exception=http.MesosException,
+ expected_additional_kwargs=[{}])])
+def test_resource_request_json(
+ mock_ujson_loads,
+ mock_mesos_http_request,
+ default_tos,
+ override_tos,
+ json_payload,
+ obj_decoder,
+ request_exception,
+ resp_status,
+ json_exception,
+ expected_exception,
+ expected_additional_kwargs):
+ call_kwargs = dict(method=http.METHOD_POST,
+ json={'some': 'payload'},
+ timeout=None,
+ url='http://some.domain/some/prefix',
+ auth=None,
+ headers={'Accept': 'application/json',
+ 'Accept-Encoding': 'gzip'},
+ params=None)
+ mock_calls = []
+ for kwargs in expected_additional_kwargs:
+ call_kwargs.update(kwargs)
+ mock_calls.append(mock.call(**call_kwargs))
+
+ def json_side_effect(_):
+ if json_exception is None:
+ return {'some': 'return_value'}
+ else:
+ raise json_exception
+
+ def request_side_effect(*_, **__):
+ if request_exception is None:
+ return mock.Mock(status_code=resp_status)
+ else:
+ raise request_exception
+
+ mock_mesos_http_request.side_effect = request_side_effect
+ mock_ujson_loads.side_effect = json_side_effect
+
+ resource = http.Resource('http://some.domain/some/prefix',
+ default_timeout=default_tos,
+ default_auth=None,
+ default_max_attempts=1,
+ default_use_gzip_encoding=True)
+
+ if expected_exception is None:
+ ret = resource.request_json(http.METHOD_POST,
+ timeout=override_tos,
+ payload=json_payload,
+ decoder=obj_decoder)
+ expected_ret = {'some': 'return_value'}
+ if obj_decoder is None:
+ assert ret == expected_ret
+ else:
+ assert ret == SomeModel(**expected_ret)
+ else:
+ with pytest.raises(expected_exception):
+ resource.request_json(http.METHOD_POST,
+ timeout=override_tos,
+ payload=json_payload)
+
+ assert mock_mesos_http_request.mock_calls == mock_calls
+
+
+def test_resource_get_json(mock_mesos_http_request):
+ mock_mesos_http_request.return_value = mock.Mock(
+ status_code=200,
+ text=ujson.dumps({'hello': 'world'}),
+ )
+ mock_auth = mock.Mock()
+ resource = http.Resource('http://some.domain/some/prefix',
+ default_timeout=100,
+ default_auth=mock_auth,
+ default_max_attempts=1,
+ default_use_gzip_encoding=True)
+ ret = resource.get_json()
+ assert mock_mesos_http_request.mock_calls == [
+ mock.call(
+ json=None,
+ method='GET',
+ url='http://some.domain/some/prefix',
+ auth=mock_auth,
+ headers={'Accept-Encoding': 'gzip',
+ 'Accept': 'application/json'},
+ params=None,
+ timeout=100,
+ )
+ ]
+ assert ret == {'hello': 'world'}
+
+
+def test_resource_post_json(mock_mesos_http_request):
+ mock_mesos_http_request.return_value = mock.Mock(
+ status_code=200,
+ text=ujson.dumps({'hello': 'world'}),
+ )
+ mock_auth = mock.Mock()
+ resource = http.Resource('http://some.domain/some/prefix',
+ default_timeout=100,
+ default_auth=mock_auth,
+ default_max_attempts=1,
+ default_use_gzip_encoding=True)
+ ret = resource.post_json(payload={'somekey': 'someval'})
+ assert mock_mesos_http_request.mock_calls == [
+ mock.call(json={'somekey': 'someval'},
+ method='POST',
+ url='http://some.domain/some/prefix',
+ auth=mock_auth,
+ headers={'Accept-Encoding': 'gzip',
+ 'Accept': 'application/json'},
+ params=None,
+ timeout=100)
+ ]
+ assert ret == {'hello': 'world'}