Merge branch 'master' into 8099-babysit-all-srun
[arvados.git] / sdk / python / arvados / api.py
1 import collections
2 import httplib
3 import httplib2
4 import json
5 import logging
6 import os
7 import re
8 import socket
9 import time
10 import types
11
12 import apiclient
13 from apiclient import discovery as apiclient_discovery
14 from apiclient import errors as apiclient_errors
15 import config
16 import errors
17 import util
18
19 _logger = logging.getLogger('arvados.api')
20
21 MAX_IDLE_CONNECTION_DURATION = 30
22 RETRY_DELAY_INITIAL = 2
23 RETRY_DELAY_BACKOFF = 2
24 RETRY_COUNT = 2
25
26 class OrderedJsonModel(apiclient.model.JsonModel):
27     """Model class for JSON that preserves the contents' order.
28
29     API clients that care about preserving the order of fields in API
30     server responses can use this model to do so, like this::
31
32         from arvados.api import OrderedJsonModel
33         client = arvados.api('v1', ..., model=OrderedJsonModel())
34     """
35
36     def deserialize(self, content):
37         # This is a very slightly modified version of the parent class'
38         # implementation.  Copyright (c) 2010 Google.
39         content = content.decode('utf-8')
40         body = json.loads(content, object_pairs_hook=collections.OrderedDict)
41         if self._data_wrapper and isinstance(body, dict) and 'data' in body:
42             body = body['data']
43         return body
44
45
46 def _intercept_http_request(self, uri, **kwargs):
47     if (self.max_request_size and
48         kwargs.get('body') and
49         self.max_request_size < len(kwargs['body'])):
50         raise apiclient_errors.MediaUploadSizeError("Request size %i bytes exceeds published limit of %i bytes" % (len(kwargs['body']), self.max_request_size))
51
52     if 'headers' not in kwargs:
53         kwargs['headers'] = {}
54
55     if config.get("ARVADOS_EXTERNAL_CLIENT", "") == "true":
56         kwargs['headers']['X-External-Client'] = '1'
57
58     kwargs['headers']['Authorization'] = 'OAuth2 %s' % self.arvados_api_token
59
60     retryable = kwargs.get('method', 'GET') in [
61         'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT']
62     retry_count = self._retry_count if retryable else 0
63
64     if (not retryable and
65         time.time() - self._last_request_time > self._max_keepalive_idle):
66         # High probability of failure due to connection atrophy. Make
67         # sure this request [re]opens a new connection by closing and
68         # forgetting all cached connections first.
69         for conn in self.connections.itervalues():
70             conn.close()
71         self.connections.clear()
72
73     delay = self._retry_delay_initial
74     for _ in range(retry_count):
75         self._last_request_time = time.time()
76         try:
77             return self.orig_http_request(uri, **kwargs)
78         except httplib.HTTPException:
79             _logger.debug("Retrying API request in %d s after HTTP error",
80                           delay, exc_info=True)
81         except socket.error:
82             # This is the one case where httplib2 doesn't close the
83             # underlying connection first.  Close all open
84             # connections, expecting this object only has the one
85             # connection to the API server.  This is safe because
86             # httplib2 reopens connections when needed.
87             _logger.debug("Retrying API request in %d s after socket error",
88                           delay, exc_info=True)
89             for conn in self.connections.itervalues():
90                 conn.close()
91         time.sleep(delay)
92         delay = delay * self._retry_delay_backoff
93
94     self._last_request_time = time.time()
95     return self.orig_http_request(uri, **kwargs)
96
97 def _patch_http_request(http, api_token):
98     http.arvados_api_token = api_token
99     http.max_request_size = 0
100     http.orig_http_request = http.request
101     http.request = types.MethodType(_intercept_http_request, http)
102     http._last_request_time = 0
103     http._max_keepalive_idle = MAX_IDLE_CONNECTION_DURATION
104     http._retry_delay_initial = RETRY_DELAY_INITIAL
105     http._retry_delay_backoff = RETRY_DELAY_BACKOFF
106     http._retry_count = RETRY_COUNT
107     return http
108
109 # Monkey patch discovery._cast() so objects and arrays get serialized
110 # with json.dumps() instead of str().
111 _cast_orig = apiclient_discovery._cast
112 def _cast_objects_too(value, schema_type):
113     global _cast_orig
114     if (type(value) != type('') and
115         (schema_type == 'object' or schema_type == 'array')):
116         return json.dumps(value)
117     else:
118         return _cast_orig(value, schema_type)
119 apiclient_discovery._cast = _cast_objects_too
120
121 # Convert apiclient's HttpErrors into our own API error subclass for better
122 # error reporting.
123 # Reassigning apiclient_errors.HttpError is not sufficient because most of the
124 # apiclient submodules import the class into their own namespace.
125 def _new_http_error(cls, *args, **kwargs):
126     return super(apiclient_errors.HttpError, cls).__new__(
127         errors.ApiError, *args, **kwargs)
128 apiclient_errors.HttpError.__new__ = staticmethod(_new_http_error)
129
130 def http_cache(data_type):
131     homedir = os.environ.get('HOME')
132     if not homedir or len(homedir) == 0:
133         return None
134     path = homedir + '/.cache/arvados/' + data_type
135     try:
136         util.mkdir_dash_p(path)
137     except OSError:
138         path = None
139     return path
140
141 def api(version=None, cache=True, host=None, token=None, insecure=False, **kwargs):
142     """Return an apiclient Resources object for an Arvados instance.
143
144     :version:
145       A string naming the version of the Arvados API to use (for
146       example, 'v1').
147
148     :cache:
149       Use a cache (~/.cache/arvados/discovery) for the discovery
150       document.
151
152     :host:
153       The Arvados API server host (and optional :port) to connect to.
154
155     :token:
156       The authentication token to send with each API call.
157
158     :insecure:
159       If True, ignore SSL certificate validation errors.
160
161     Additional keyword arguments will be passed directly to
162     `apiclient_discovery.build` if a new Resource object is created.
163     If the `discoveryServiceUrl` or `http` keyword arguments are
164     missing, this function will set default values for them, based on
165     the current Arvados configuration settings.
166
167     """
168
169     if not version:
170         version = 'v1'
171         _logger.info("Using default API version. " +
172                      "Call arvados.api('%s') instead." %
173                      version)
174     if 'discoveryServiceUrl' in kwargs:
175         if host:
176             raise ValueError("both discoveryServiceUrl and host provided")
177         # Here we can't use a token from environment, config file,
178         # etc. Those probably have nothing to do with the host
179         # provided by the caller.
180         if not token:
181             raise ValueError("discoveryServiceUrl provided, but token missing")
182     elif host and token:
183         pass
184     elif not host and not token:
185         return api_from_config(version=version, cache=cache, **kwargs)
186     else:
187         # Caller provided one but not the other
188         if not host:
189             raise ValueError("token argument provided, but host missing.")
190         else:
191             raise ValueError("host argument provided, but token missing.")
192
193     if host:
194         # Caller wants us to build the discoveryServiceUrl
195         kwargs['discoveryServiceUrl'] = (
196             'https://%s/discovery/v1/apis/{api}/{apiVersion}/rest' % (host,))
197
198     if 'http' not in kwargs:
199         http_kwargs = {'ca_certs': util.ca_certs_path()}
200         if cache:
201             http_kwargs['cache'] = http_cache('discovery')
202         if insecure:
203             http_kwargs['disable_ssl_certificate_validation'] = True
204         kwargs['http'] = httplib2.Http(**http_kwargs)
205
206     kwargs['http'] = _patch_http_request(kwargs['http'], token)
207
208     svc = apiclient_discovery.build('arvados', version, **kwargs)
209     svc.api_token = token
210     svc.insecure = insecure
211     kwargs['http'].max_request_size = svc._rootDesc.get('maxRequestSize', 0)
212     kwargs['http'].cache = None
213     return svc
214
215 def api_from_config(version=None, apiconfig=None, **kwargs):
216     """Return an apiclient Resources object enabling access to an Arvados server
217     instance.
218
219     :version:
220       A string naming the version of the Arvados REST API to use (for
221       example, 'v1').
222
223     :apiconfig:
224       If provided, this should be a dict-like object (must support the get()
225       method) with entries for ARVADOS_API_HOST, ARVADOS_API_TOKEN, and
226       optionally ARVADOS_API_HOST_INSECURE.  If not provided, use
227       arvados.config (which gets these parameters from the environment by
228       default.)
229
230     Other keyword arguments such as `cache` will be passed along `api()`
231
232     """
233     # Load from user configuration or environment
234     if apiconfig is None:
235         apiconfig = config.settings()
236
237     for x in ['ARVADOS_API_HOST', 'ARVADOS_API_TOKEN']:
238         if x not in apiconfig:
239             raise ValueError("%s is not set. Aborting." % x)
240     host = apiconfig.get('ARVADOS_API_HOST')
241     token = apiconfig.get('ARVADOS_API_TOKEN')
242     insecure = config.flag_is_true('ARVADOS_API_HOST_INSECURE', apiconfig)
243
244     return api(version=version, host=host, token=token, insecure=insecure, **kwargs)