Source code for globus_sdk.base

from __future__ import unicode_literals

import json
import logging

import requests
import six
from six.moves.urllib.parse import quote

from globus_sdk import config, exc
from globus_sdk.response import GlobusHTTPResponse
from globus_sdk.version import __version__


class ClientLogAdapter(logging.LoggerAdapter):
    """
    Stuff in the memory location of the client to make log records unambiguous.
    """

    def process(self, msg, kwargs):
        return "[instance:{}] {}".format(id(self.extra["client"]), msg), kwargs

    def warn(self, *args, **kwargs):
        """
        NOTE: although Logger.warn() is deprecated in python, we've now made
        it part of the interface for clients. We should only remove this
        carefully, as someone may be leveraging something like

        >>> TransferClient.logger.warn(...)

        even though that would be a bad idea. At the very least, removing it
        should be a considered move, not just thrown in with warn() ->
        warning() cleanup.
        """
        return self.warning(*args, **kwargs)


[docs]class BaseClient(object): r""" Simple client with error handling for Globus REST APIs. Implemented as a wrapper around a ``requests.Session`` object, with a simplified interface that does not directly expose anything from requests. You should *never* try to directly instantiate a ``BaseClient``. :param authorizer: A ``GlobusAuthorizer`` which will generate Authorization headers :type authorizer: :class:`GlobusAuthorizer\ <globus_sdk.authorizers.base.GlobusAuthorizer>` :param app_name: Optional "nice name" for the application. Has no bearing on the semantics of client actions. It is just passed as part of the User-Agent string, and may be useful when debugging issues with the Globus Team :type app_name: str :param http_timeout: Number of seconds to wait on HTTP connections. Default is 60. A value of -1 indicates that no timeout should be used (requests can hang indefinitely). :type http_timeout: float All other parameters are for internal use and should be ignored. """ # Can be overridden by subclasses, but must be a subclass of GlobusError error_class = exc.GlobusAPIError default_response_class = GlobusHTTPResponse # a collection of authorizer types, or None to indicate "any" allowed_authorizer_types = None BASE_USER_AGENT = "globus-sdk-py-{0}".format(__version__) def __init__( self, service, environment=None, base_url=None, base_path=None, authorizer=None, app_name=None, http_timeout=None, *args, **kwargs ): self._init_logger_adapter() self.logger.info( 'Creating client of type {} for service "{}"'.format(type(self), service) ) # if restrictions have been placed by a child class on the allowed # authorizer types, make sure we are not in violation of those # constraints if self.allowed_authorizer_types is not None and ( authorizer is not None and type(authorizer) not in self.allowed_authorizer_types ): self.logger.error( "{} doesn't support authorizer={}".format(type(self), type(authorizer)) ) raise exc.GlobusSDKUsageError( ( "{0} can only take authorizers from {1}, " "but you have provided {2}" ).format(type(self), self.allowed_authorizer_types, type(authorizer)) ) # if an environment was passed, it will be used, but otherwise lookup # the env var -- and in the special case of `production` translate to # `default`, regardless of the source of that value # logs the environment when it isn't `default` self.environment = config.get_globus_environ(inputenv=environment) self.authorizer = authorizer if base_url is None: self.base_url = config.get_service_url(self.environment, service) else: self.base_url = base_url self.base_url = slash_join(self.base_url, base_path) # setup the basics for wrapping a Requests Session # including basics for internal header dict self._session = requests.Session() self._headers = { "Accept": "application/json", "User-Agent": self.BASE_USER_AGENT, } # verify SSL? Usually true self._verify = config.get_ssl_verify(self.environment) # HTTP connection timeout # this is passed verbatim to `requests`, and we therefore technically # support a tuple for connect/read timeouts, but we don't need to # advertise that... Just declare it as an float value if http_timeout is None: http_timeout = config.get_http_timeout(self.environment) self._http_timeout = http_timeout # handle -1 by passing None to requests if self._http_timeout == -1: self._http_timeout = None # set application name if given self.app_name = None if app_name is not None: self.set_app_name(app_name) def _init_logger_adapter(self): """ Create & assign the self.logger LoggerAdapter Used when initializing a new client, or unpickling. Technically, this could result in a state change across a pickle/unpickle -- file handles and other handlers could be detached, etc. However, that's better than a hard-fail on pickle.dump(s) calls. Also, so long as loggers are attached at the module level -- as they really ought to be -- everything will be fine. """ # get the fully qualified name of the client class, so that it's a # child of globus_sdk self.logger = ClientLogAdapter( logging.getLogger(self.__module__ + "." + self.__class__.__name__), {"client": self}, ) def __getstate__(self): """ Render to a serializable dict for pickle.dump(s) """ self.logger.info("__getstate__() called; client being pickled") d = dict(self.__dict__) # copy del d["logger"] return d def __setstate__(self, d): """ Load from a serialized format, as in pickle.load(s) """ self._init_logger_adapter() self.__dict__.update(d) self.logger.info("__setstate__() finished; client unpickled")
[docs] def set_app_name(self, app_name): """ Set an application name to send to Globus services as part of the User Agent. Application developers are encouraged to set an app name as a courtesy to the Globus Team, and to potentially speed resolution of issues when interacting with Globus Support. """ self.app_name = app_name self._headers["User-Agent"] = "{0}/{1}".format(self.BASE_USER_AGENT, app_name)
def qjoin_path(self, *parts): return "/" + "/".join(quote(part) for part in parts)
[docs] def get(self, path, params=None, headers=None, response_class=None, retry_401=True): """ Make a GET request to the specified path. :param path: Path for the request, with or without leading slash :type path: str :param params: Parameters to be encoded as a query string :type params: dict :param headers: HTTP headers to add to the request :type headers: dict :param response_class: Class for response object, overrides the client's ``default_response_class`` :type response_class: class :param retry_401: Retry on 401 responses with fresh Authorization if ``self.authorizer`` supports it :type retry_401: bool :return: :class:`GlobusHTTPResponse \ <globus_sdk.response.GlobusHTTPResponse>` object """ self.logger.debug("GET to {} with params {}".format(path, params)) return self._request( "GET", path, params=params, headers=headers, response_class=response_class, retry_401=retry_401, )
[docs] def post( self, path, json_body=None, params=None, headers=None, text_body=None, response_class=None, retry_401=True, ): """ Make a POST request to the specified path. :param path: Path for the request, with or without leading slash :type path: str :param params: Parameters to be encoded as a query string :type params: dict :param headers: HTTP headers to add to the request :type headers: dict :param json_body: Data which will be JSON encoded as the body of the request :type json_body: dict :param text_body: Either a raw string that will serve as the request body, or a dict which will be HTTP Form encoded :type text_body: str or dict :param response_class: Class for response object, overrides the client's ``default_response_class`` :type response_class: class :param retry_401: Retry on 401 responses with fresh Authorization if ``self.authorizer`` supports it :type retry_401: bool :return: :class:`GlobusHTTPResponse \ <globus_sdk.response.GlobusHTTPResponse>` object """ self.logger.debug("POST to {} with params {}".format(path, params)) return self._request( "POST", path, json_body=json_body, params=params, headers=headers, text_body=text_body, response_class=response_class, retry_401=retry_401, )
[docs] def delete( self, path, params=None, headers=None, response_class=None, retry_401=True ): """ Make a DELETE request to the specified path. :param path: Path for the request, with or without leading slash :type path: str :param params: Parameters to be encoded as a query string :type params: dict :param headers: HTTP headers to add to the request :type headers: dict :param response_class: Class for response object, overrides the client's ``default_response_class`` :type response_class: class :param retry_401: Retry on 401 responses with fresh Authorization if ``self.authorizer`` supports it :type retry_401: bool :return: :class:`GlobusHTTPResponse \ <globus_sdk.response.GlobusHTTPResponse>` object """ self.logger.debug("DELETE to {} with params {}".format(path, params)) return self._request( "DELETE", path, params=params, headers=headers, response_class=response_class, retry_401=retry_401, )
[docs] def put( self, path, json_body=None, params=None, headers=None, text_body=None, response_class=None, retry_401=True, ): """ Make a PUT request to the specified path. :param path: Path for the request, with or without leading slash :type path: str :param params: Parameters to be encoded as a query string :type params: dict :param headers: HTTP headers to add to the request :type headers: dict :param json_body: Data which will be JSON encoded as the body of the request :type json_body: dict :param text_body: Either a raw string that will serve as the request body, or a dict which will be HTTP Form encoded :type text_body: str or dict :param response_class: Class for response object, overrides the client's ``default_response_class`` :type response_class: class :param retry_401: Retry on 401 responses with fresh Authorization if ``self.authorizer`` supports it :type retry_401: bool :return: :class:`GlobusHTTPResponse \ <globus_sdk.response.GlobusHTTPResponse>` object """ self.logger.debug("PUT to {} with params {}".format(path, params)) return self._request( "PUT", path, json_body=json_body, params=params, headers=headers, text_body=text_body, response_class=response_class, retry_401=retry_401, )
def patch( self, path, json_body=None, params=None, headers=None, text_body=None, response_class=None, retry_401=True, ): """ Make a PATCH request to the specified path. :param path: Path for the request, with or without leading slash :type path: str :param params: Parameters to be encoded as a query string :type params: dict :param headers: HTTP headers to add to the request :type headers: dict :param json_body: Data which will be JSON encoded as the body of the request :type json_body: dict :param text_body: Either a raw string that will serve as the request body, or a dict which will be HTTP Form encoded :type text_body: str or dict :param response_class: Class for response object, overrides the client's ``default_response_class`` :type response_class: class :param retry_401: Retry on 401 responses with fresh Authorization if ``self.authorizer`` supports it :type retry_401: bool :return: :class:`GlobusHTTPResponse \ <globus_sdk.response.GlobusHTTPResponse>` object """ self.logger.debug("PATCH to {} with params {}".format(path, params)) return self._request( "PATCH", path, json_body=json_body, params=params, headers=headers, text_body=text_body, response_class=response_class, retry_401=retry_401, ) def _request( self, method, path, params=None, headers=None, json_body=None, text_body=None, response_class=None, retry_401=True, ): """ Send an HTTP request :param method: HTTP request method, as an all caps string :type method: str :param path: Path for the request, with or without leading slash :type path: str :param params: Parameters to be encoded as a query string :type params: dict :param headers: HTTP headers to add to the request :type headers: dict :param json_body: Data which will be JSON encoded as the body of the request :type json_body: dict :param text_body: Either a raw string that will serve as the request body, or a dict which will be HTTP Form encoded :type text_body: str or dict :param response_class: Class for response object, overrides the client's ``default_response_class`` :type response_class: class :param retry_401: Retry on 401 responses with fresh Authorization if ``self.authorizer`` supports it :type retry_401: bool :return: :class:`GlobusHTTPResponse \ <globus_sdk.response.GlobusHTTPResponse>` object """ # copy rheaders = dict(self._headers) # expand if headers is not None: rheaders.update(headers) if json_body is not None: assert text_body is None text_body = json.dumps(json_body) # set appropriate content-type header rheaders.update({"Content-Type": "application/json"}) # add Authorization header, or (if it's a NullAuthorizer) possibly # explicitly remove the Authorization header if self.authorizer is not None: self.logger.debug( "request will have authorization of type {}".format( type(self.authorizer) ) ) self.authorizer.set_authorization_header(rheaders) url = slash_join(self.base_url, path) self.logger.debug("request will hit URL:{}".format(url)) # because a 401 can trigger retry, we need to wrap the retry-able thing # in a method def send_request(): try: return self._session.request( method=method, url=url, headers=rheaders, params=params, data=text_body, verify=self._verify, timeout=self._http_timeout, ) except requests.RequestException as e: self.logger.error("NetworkError on request") raise exc.convert_request_exception(e) # initial request r = send_request() self.logger.debug("Request made to URL: {}".format(r.url)) # potential 401 retry handling if r.status_code == 401 and retry_401 and self.authorizer is not None: self.logger.debug("request got 401, checking retry-capability") # note that although handle_missing_authorization returns a T/F # value, it may actually mutate the state of the authorizer and # therefore change the value set by the `set_authorization_header` # method if self.authorizer.handle_missing_authorization(): self.logger.debug("request can be retried") self.authorizer.set_authorization_header(rheaders) r = send_request() if 200 <= r.status_code < 400: self.logger.debug( "request completed with response code: {}".format(r.status_code) ) if response_class is None: return self.default_response_class(r, client=self) else: return response_class(r, client=self) self.logger.debug( "request completed with (error) response code: {}".format(r.status_code) ) raise self.error_class(r)
def slash_join(a, b): """ Join a and b with a single slash, regardless of whether they already contain a trailing/leading slash or neither. """ if not b: # "" or None, don't append a slash return a if a.endswith("/"): if b.startswith("/"): return a[:-1] + b return a + b if b.startswith("/"): return a + b return a + "/" + b def merge_params(base_params, **more_params): """ Merge additional keyword arguments into a base dictionary of keyword arguments. Only inserts additional kwargs which are not None. This way, we can accept a bunch of named kwargs, a collector of additional kwargs, and then put them together sensibly as arguments to another function (typically BaseClient.get() or a variant thereof). For example: >>> def ep_search(self, filter_scope=None, filter_fulltext=None, **params): >>> # Yes, this is a side-effecting function, it doesn't return a new >>> # dict because it's way simpler to update in place >>> merge_params( >>> params, filter_scope=filter_scope, >>> filter_fulltext=filter_fulltext) >>> return self.get('endpoint_search', params=params) this is a whole lot cleaner than the alternative form: >>> def ep_search(self, filter_scope=None, filter_fulltext=None, **params): >>> if filter_scope is not None: >>> params['filter_scope'] = filter_scope >>> if filter_fulltext is not None: >>> params['filter_scope'] = filter_scope >>> return self.get('endpoint_search', params=params) the second form exposes a couple of dangers that are obviated in the first regarding correctness, like the possibility of doing >>> if filter_scope: >>> params['filter_scope'] = filter_scope which is wrong (!) because filter_scope='' is a theoretically valid, real argument we want to pass. The first form will also prove shorter and easier to write for the most part. """ for param in more_params: if more_params[param] is not None: base_params[param] = more_params[param] def safe_stringify(value): """ Converts incoming value to a unicode string. Convert bytes by decoding, anything else has __str__ called, then is converted to bytes and then to unicode to deal with python 2 and 3 differing in definitions of string """ if isinstance(value, six.text_type): return value if isinstance(value, bytes): return value.decode("utf-8") else: return six.b(str(value)).decode("utf-8")