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