19686: api constructor returns ThreadSafeApiCache
[arvados.git] / sdk / python / arvados / api.py
1 # Copyright (C) The Arvados Authors. All rights reserved.
2 #
3 # SPDX-License-Identifier: Apache-2.0
4 """Arvados API client
5
6 The code in this module builds Arvados API client objects you can use to submit
7 Arvados API requests. This includes extending the underlying HTTP client with
8 niceties such as caching, X-Request-Id header for tracking, and more. The main
9 client constructors are `api` and `api_from_config`.
10 """
11
12 from __future__ import absolute_import
13 from future import standard_library
14 standard_library.install_aliases()
15 from builtins import range
16 import collections
17 import http.client
18 import httplib2
19 import json
20 import logging
21 import os
22 import re
23 import socket
24 import ssl
25 import sys
26 import time
27 import types
28
29 import apiclient
30 from apiclient import discovery as apiclient_discovery
31 from apiclient import errors as apiclient_errors
32 from . import config
33 from . import errors
34 from . import util
35 from . import cache
36
37 _logger = logging.getLogger('arvados.api')
38
39 MAX_IDLE_CONNECTION_DURATION = 30
40 RETRY_DELAY_INITIAL = 2
41 RETRY_DELAY_BACKOFF = 2
42 RETRY_COUNT = 2
43
44 if sys.version_info >= (3,):
45     httplib2.SSLHandshakeError = None
46
47 class OrderedJsonModel(apiclient.model.JsonModel):
48     """Model class for JSON that preserves the contents' order.
49
50     API clients that care about preserving the order of fields in API
51     server responses can use this model to do so, like this:
52
53         from arvados.api import OrderedJsonModel
54         client = arvados.api('v1', ..., model=OrderedJsonModel())
55     """
56
57     def deserialize(self, content):
58         # This is a very slightly modified version of the parent class'
59         # implementation.  Copyright (c) 2010 Google.
60         content = content.decode('utf-8')
61         body = json.loads(content, object_pairs_hook=collections.OrderedDict)
62         if self._data_wrapper and isinstance(body, dict) and 'data' in body:
63             body = body['data']
64         return body
65
66
67 def _intercept_http_request(self, uri, method="GET", headers={}, **kwargs):
68     if not headers.get('X-Request-Id'):
69         headers['X-Request-Id'] = self._request_id()
70     try:
71         if (self.max_request_size and
72             kwargs.get('body') and
73             self.max_request_size < len(kwargs['body'])):
74             raise apiclient_errors.MediaUploadSizeError("Request size %i bytes exceeds published limit of %i bytes" % (len(kwargs['body']), self.max_request_size))
75
76         headers['Authorization'] = 'OAuth2 %s' % self.arvados_api_token
77
78         retryable = method in [
79             'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT']
80         retry_count = self._retry_count if retryable else 0
81
82         if (not retryable and
83             time.time() - self._last_request_time > self._max_keepalive_idle):
84             # High probability of failure due to connection atrophy. Make
85             # sure this request [re]opens a new connection by closing and
86             # forgetting all cached connections first.
87             for conn in self.connections.values():
88                 conn.close()
89             self.connections.clear()
90
91         delay = self._retry_delay_initial
92         for _ in range(retry_count):
93             self._last_request_time = time.time()
94             try:
95                 return self.orig_http_request(uri, method, headers=headers, **kwargs)
96             except http.client.HTTPException:
97                 _logger.debug("[%s] Retrying API request in %d s after HTTP error",
98                               headers['X-Request-Id'], delay, exc_info=True)
99             except ssl.SSLCertVerificationError as e:
100                 raise ssl.SSLCertVerificationError(e.args[0], "Could not connect to %s\n%s\nPossible causes: remote SSL/TLS certificate expired, or was issued by an untrusted certificate authority." % (uri, e)) from None
101             except socket.error:
102                 # This is the one case where httplib2 doesn't close the
103                 # underlying connection first.  Close all open
104                 # connections, expecting this object only has the one
105                 # connection to the API server.  This is safe because
106                 # httplib2 reopens connections when needed.
107                 _logger.debug("[%s] Retrying API request in %d s after socket error",
108                               headers['X-Request-Id'], delay, exc_info=True)
109                 for conn in self.connections.values():
110                     conn.close()
111
112             time.sleep(delay)
113             delay = delay * self._retry_delay_backoff
114
115         self._last_request_time = time.time()
116         return self.orig_http_request(uri, method, headers=headers, **kwargs)
117     except Exception as e:
118         # Prepend "[request_id] " to the error message, which we
119         # assume is the first string argument passed to the exception
120         # constructor.
121         for i in range(len(e.args or ())):
122             if type(e.args[i]) == type(""):
123                 e.args = e.args[:i] + ("[{}] {}".format(headers['X-Request-Id'], e.args[i]),) + e.args[i+1:]
124                 raise type(e)(*e.args)
125         raise
126
127 def _patch_http_request(http, api_token):
128     http.arvados_api_token = api_token
129     http.max_request_size = 0
130     http.orig_http_request = http.request
131     http.request = types.MethodType(_intercept_http_request, http)
132     http._last_request_time = 0
133     http._max_keepalive_idle = MAX_IDLE_CONNECTION_DURATION
134     http._retry_delay_initial = RETRY_DELAY_INITIAL
135     http._retry_delay_backoff = RETRY_DELAY_BACKOFF
136     http._retry_count = RETRY_COUNT
137     http._request_id = util.new_request_id
138     return http
139
140 def _close_connections(self):
141     for conn in self._http.connections.values():
142         conn.close()
143
144 # Monkey patch discovery._cast() so objects and arrays get serialized
145 # with json.dumps() instead of str().
146 _cast_orig = apiclient_discovery._cast
147 def _cast_objects_too(value, schema_type):
148     global _cast_orig
149     if (type(value) != type('') and
150         type(value) != type(b'') and
151         (schema_type == 'object' or schema_type == 'array')):
152         return json.dumps(value)
153     else:
154         return _cast_orig(value, schema_type)
155 apiclient_discovery._cast = _cast_objects_too
156
157 # Convert apiclient's HttpErrors into our own API error subclass for better
158 # error reporting.
159 # Reassigning apiclient_errors.HttpError is not sufficient because most of the
160 # apiclient submodules import the class into their own namespace.
161 def _new_http_error(cls, *args, **kwargs):
162     return super(apiclient_errors.HttpError, cls).__new__(
163         errors.ApiError, *args, **kwargs)
164 apiclient_errors.HttpError.__new__ = staticmethod(_new_http_error)
165
166 def http_cache(data_type):
167     homedir = os.environ.get('HOME')
168     if not homedir or len(homedir) == 0:
169         return None
170     path = homedir + '/.cache/arvados/' + data_type
171     try:
172         util.mkdir_dash_p(path)
173     except OSError:
174         return None
175     return cache.SafeHTTPCache(path, max_age=60*60*24*2)
176
177 def api_client(
178         version,
179         discoveryServiceUrl,
180         token,
181         *,
182         cache=True,
183         http=None,
184         insecure=False,
185         request_id=None,
186         timeout=5*60,
187         **kwargs,
188 ):
189     """Build an Arvados API client
190
191     This function returns a `googleapiclient.discovery.Resource` object
192     constructed from the given arguments. This is a relatively low-level
193     interface that requires all the necessary inputs as arguments. Most
194     users will prefer to use `api` which can accept more flexible inputs.
195
196     Arguments:
197
198     version: str
199     : A string naming the version of the Arvados API to use.
200
201     discoveryServiceUrl: str
202     : The URL used to discover APIs passed directly to
203       `googleapiclient.discovery.build`.
204
205     token: str
206     : The authentication token to send with each API call.
207
208     Keyword-only arguments:
209
210     cache: bool
211     : If true, loads the API discovery document from, or saves it to, a cache
212       on disk (located at `~/.cache/arvados/discovery`).
213
214     http: httplib2.Http | None
215     : The HTTP client object the API client object will use to make requests.
216       If not provided, this function will build its own to use. Either way, the
217       object will be patched as part of the build process.
218
219     insecure: bool
220     : If true, ignore SSL certificate validation errors. Default `False`.
221
222     request_id: str | None
223     : Default `X-Request-Id` header value for outgoing requests that
224       don't already provide one. If `None` or omitted, generate a random
225       ID. When retrying failed requests, the same ID is used on all
226       attempts.
227
228     timeout: int
229     : A timeout value for HTTP requests in seconds. Default 300 (5 minutes).
230
231     Additional keyword arguments will be passed directly to
232     `googleapiclient.discovery.build`.
233     """
234     if http is None:
235         http = httplib2.Http(
236             ca_certs=util.ca_certs_path(),
237             cache=http_cache('discovery') if cache else None,
238             disable_ssl_certificate_validation=bool(insecure),
239         )
240     if http.timeout is None:
241         http.timeout = timeout
242     http = _patch_http_request(http, token)
243
244     svc = apiclient_discovery.build(
245         'arvados', version,
246         cache_discovery=False,
247         discoveryServiceUrl=discoveryServiceUrl,
248         http=http,
249         **kwargs,
250     )
251     svc.api_token = token
252     svc.insecure = insecure
253     svc.request_id = request_id
254     svc.config = lambda: util.get_config_once(svc)
255     svc.vocabulary = lambda: util.get_vocabulary_once(svc)
256     svc.close_connections = types.MethodType(_close_connections, svc)
257     http.max_request_size = svc._rootDesc.get('maxRequestSize', 0)
258     http.cache = None
259     http._request_id = lambda: svc.request_id or util.new_request_id()
260     return svc
261
262 def normalize_api_kwargs(
263         version=None,
264         discoveryServiceUrl=None,
265         host=None,
266         token=None,
267         **kwargs,
268 ):
269     """Validate kwargs from `api` and build kwargs for `api_client`
270
271     This method takes high-level keyword arguments passed to the `api`
272     constructor and normalizes them into a new dictionary that can be passed
273     as keyword arguments to `api_client`. It raises `ValueError` if required
274     arguments are missing or conflict.
275
276     Arguments:
277
278     version: str | None
279     : A string naming the version of the Arvados API to use. If not specified,
280       the code will log a warning and fall back to 'v1'.
281
282     discoveryServiceUrl: str | None
283     : The URL used to discover APIs passed directly to
284       `googleapiclient.discovery.build`. It is an error to pass both
285       `discoveryServiceUrl` and `host`.
286
287     host: str | None
288     : The hostname and optional port number of the Arvados API server. Used to
289       build `discoveryServiceUrl`. It is an error to pass both
290       `discoveryServiceUrl` and `host`.
291
292     token: str
293     : The authentication token to send with each API call.
294
295     Additional keyword arguments will be included in the return value.
296     """
297     if discoveryServiceUrl and host:
298         raise ValueError("both discoveryServiceUrl and host provided")
299     elif discoveryServiceUrl:
300         url_src = "discoveryServiceUrl"
301     elif host:
302         url_src = "host argument"
303         discoveryServiceUrl = 'https://%s/discovery/v1/apis/{api}/{apiVersion}/rest' % (host,)
304     elif token:
305         # This specific error message gets priority for backwards compatibility.
306         raise ValueError("token argument provided, but host missing.")
307     else:
308         raise ValueError("neither discoveryServiceUrl nor host provided")
309     if not token:
310         raise ValueError("%s provided, but token missing" % (url_src,))
311     if not version:
312         version = 'v1'
313         _logger.info(
314             "Using default API version. Call arvados.api(%r) instead.",
315             version,
316         )
317     return {
318         'discoveryServiceUrl': discoveryServiceUrl,
319         'token': token,
320         'version': version,
321         **kwargs,
322     }
323
324 def api_kwargs_from_config(version=None, apiconfig=None, **kwargs):
325     """Build `api_client` keyword arguments from configuration
326
327     This function accepts a mapping with Arvados configuration settings like
328     `ARVADOS_API_HOST` and converts them into a mapping of keyword arguments
329     that can be passed to `api_client`. If `ARVADOS_API_HOST` or
330     `ARVADOS_API_TOKEN` are not configured, it raises `ValueError`.
331
332     Arguments:
333
334     version: str | None
335     : A string naming the version of the Arvados API to use. If not specified,
336       the code will log a warning and fall back to 'v1'.
337
338     apiconfig: Mapping[str, str] | None
339     : A mapping with entries for `ARVADOS_API_HOST`, `ARVADOS_API_TOKEN`, and
340       optionally `ARVADOS_API_HOST_INSECURE`. If not provided, calls
341       `arvados.config.settings` to get these parameters from user configuration.
342
343     Additional keyword arguments will be included in the return value.
344     """
345     if apiconfig is None:
346         apiconfig = config.settings()
347     missing = " and ".join(
348         key
349         for key in ['ARVADOS_API_HOST', 'ARVADOS_API_TOKEN']
350         if key not in apiconfig
351     )
352     if missing:
353         raise ValueError(
354             "%s not set.\nPlease set in %s or export environment variable." %
355             (missing, config.default_config_file),
356         )
357     return normalize_api_kwargs(
358         version,
359         None,
360         apiconfig['ARVADOS_API_HOST'],
361         apiconfig['ARVADOS_API_TOKEN'],
362         insecure=config.flag_is_true('ARVADOS_API_HOST_INSECURE', apiconfig),
363         **kwargs,
364     )
365
366 def api(version=None, cache=True, host=None, token=None, insecure=False,
367         request_id=None, timeout=5*60, *,
368         discoveryServiceUrl=None, **kwargs):
369     """Dynamically build an Arvados API client
370
371     This function provides a high-level "do what I mean" interface to build an
372     Arvados API client object. You can call it with no arguments to build a
373     client from user configuration; pass `host` and `token` arguments just
374     like you would write in user configuration; or pass additional arguments
375     for lower-level control over the client.
376
377     This function returns a `arvados.safeapi.ThreadSafeApiCache`, an
378     API-compatible wrapper around `googleapiclient.discovery.Resource`. If
379     you're handling concurrency yourself and/or your application is very
380     performance-sensitive, consider calling `api_client` directly.
381
382     Arguments:
383
384     version: str | None
385     : A string naming the version of the Arvados API to use. If not specified,
386       the code will log a warning and fall back to 'v1'.
387
388     host: str | None
389     : The hostname and optional port number of the Arvados API server.
390
391     token: str | None
392     : The authentication token to send with each API call.
393
394     discoveryServiceUrl: str | None
395     : The URL used to discover APIs passed directly to
396       `googleapiclient.discovery.build`.
397
398     If `host`, `token`, and `discoveryServiceUrl` are all omitted, `host` and
399     `token` will be loaded from the user's configuration. Otherwise, you must
400     pass `token` and one of `host` or `discoveryServiceUrl`. It is an error to
401     pass both `host` and `discoveryServiceUrl`.
402
403     Other arguments are passed directly to `api_client`. See that function's
404     docstring for more information about their meaning.
405     """
406     kwargs.update(
407         cache=cache,
408         insecure=insecure,
409         request_id=request_id,
410         timeout=timeout,
411     )
412     if discoveryServiceUrl or host or token:
413         kwargs.update(normalize_api_kwargs(version, discoveryServiceUrl, host, token))
414     else:
415         kwargs.update(api_kwargs_from_config(version))
416     version = kwargs.pop('version')
417     # We do the import here to avoid a circular import at the top level.
418     from .safeapi import ThreadSafeApiCache
419     return ThreadSafeApiCache({}, {}, kwargs, version)
420
421 def api_from_config(version=None, apiconfig=None, **kwargs):
422     """Build an Arvados API client from a configuration mapping
423
424     This function builds an Arvados API client from a mapping with user
425     configuration. It accepts that mapping as an argument, so you can use a
426     configuration that's different from what the user has set up.
427
428     This function returns a `arvados.safeapi.ThreadSafeApiCache`, an
429     API-compatible wrapper around `googleapiclient.discovery.Resource`. If
430     you're handling concurrency yourself and/or your application is very
431     performance-sensitive, consider calling `api_client` directly.
432
433     Arguments:
434
435     version: str | None
436     : A string naming the version of the Arvados API to use. If not specified,
437       the code will log a warning and fall back to 'v1'.
438
439     apiconfig: Mapping[str, str] | None
440     : A mapping with entries for `ARVADOS_API_HOST`, `ARVADOS_API_TOKEN`, and
441       optionally `ARVADOS_API_HOST_INSECURE`. If not provided, calls
442       `arvados.config.settings` to get these parameters from user configuration.
443
444     Other arguments are passed directly to `api_client`. See that function's
445     docstring for more information about their meaning.
446     """
447     return api(**api_kwargs_from_config(version, apiconfig, **kwargs))