Merge branch '21834-stdout-timestamps'
[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 import collections
13 import httplib2
14 import json
15 import logging
16 import os
17 import pathlib
18 import re
19 import socket
20 import ssl
21 import sys
22 import threading
23 import time
24 import types
25
26 from typing import (
27     Any,
28     Dict,
29     List,
30     Mapping,
31     Optional,
32 )
33
34 import apiclient
35 import apiclient.http
36 from apiclient import discovery as apiclient_discovery
37 from apiclient import errors as apiclient_errors
38 from . import config
39 from . import errors
40 from . import retry
41 from . import util
42 from . import cache
43 from .logging import GoogleHTTPClientFilter, log_handler
44
45 _logger = logging.getLogger('arvados.api')
46 _googleapiclient_log_lock = threading.Lock()
47
48 MAX_IDLE_CONNECTION_DURATION = 30
49 """
50 Number of seconds that API client HTTP connections should be allowed to idle
51 in keepalive state before they are forced closed. Client code can adjust this
52 constant, and it will be used for all Arvados API clients constructed after
53 that point.
54 """
55
56 # An unused HTTP 5xx status code to request a retry internally.
57 # See _intercept_http_request. This should not be user-visible.
58 _RETRY_4XX_STATUS = 545
59
60 if sys.version_info >= (3,):
61     httplib2.SSLHandshakeError = None
62
63 _orig_retry_request = apiclient.http._retry_request
64 def _retry_request(http, num_retries, *args, **kwargs):
65     try:
66         num_retries = max(num_retries, http.num_retries)
67     except AttributeError:
68         # `http` client object does not have a `num_retries` attribute.
69         # It apparently hasn't gone through _patch_http_request, possibly
70         # because this isn't an Arvados API client. Pass through to
71         # avoid interfering with other Google API clients.
72         return _orig_retry_request(http, num_retries, *args, **kwargs)
73     response, body = _orig_retry_request(http, num_retries, *args, **kwargs)
74     # If _intercept_http_request ran out of retries for a 4xx response,
75     # restore the original status code.
76     if response.status == _RETRY_4XX_STATUS:
77         response.status = int(response['status'])
78     return (response, body)
79 apiclient.http._retry_request = _retry_request
80
81 def _intercept_http_request(self, uri, method="GET", headers={}, **kwargs):
82     if not headers.get('X-Request-Id'):
83         headers['X-Request-Id'] = self._request_id()
84     try:
85         if (self.max_request_size and
86             kwargs.get('body') and
87             self.max_request_size < len(kwargs['body'])):
88             raise apiclient_errors.MediaUploadSizeError("Request size %i bytes exceeds published limit of %i bytes" % (len(kwargs['body']), self.max_request_size))
89
90         headers['Authorization'] = 'OAuth2 %s' % self.arvados_api_token
91
92         if (time.time() - self._last_request_time) > self._max_keepalive_idle:
93             # High probability of failure due to connection atrophy. Make
94             # sure this request [re]opens a new connection by closing and
95             # forgetting all cached connections first.
96             for conn in self.connections.values():
97                 conn.close()
98             self.connections.clear()
99
100         self._last_request_time = time.time()
101         try:
102             response, body = self.orig_http_request(uri, method, headers=headers, **kwargs)
103         except ssl.SSLCertVerificationError as e:
104             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
105         # googleapiclient only retries 403, 429, and 5xx status codes.
106         # If we got another 4xx status that we want to retry, convert it into
107         # 5xx so googleapiclient handles it the way we want.
108         if response.status in retry._HTTP_CAN_RETRY and response.status < 500:
109             response.status = _RETRY_4XX_STATUS
110         return (response, body)
111     except Exception as e:
112         # Prepend "[request_id] " to the error message, which we
113         # assume is the first string argument passed to the exception
114         # constructor.
115         for i in range(len(e.args or ())):
116             if type(e.args[i]) == type(""):
117                 e.args = e.args[:i] + ("[{}] {}".format(headers['X-Request-Id'], e.args[i]),) + e.args[i+1:]
118                 raise type(e)(*e.args)
119         raise
120
121 def _patch_http_request(http, api_token, num_retries):
122     http.arvados_api_token = api_token
123     http.max_request_size = 0
124     http.num_retries = num_retries
125     http.orig_http_request = http.request
126     http.request = types.MethodType(_intercept_http_request, http)
127     http._last_request_time = 0
128     http._max_keepalive_idle = MAX_IDLE_CONNECTION_DURATION
129     http._request_id = util.new_request_id
130     return http
131
132 def _close_connections(self):
133     for conn in self._http.connections.values():
134         conn.close()
135
136 # Monkey patch discovery._cast() so objects and arrays get serialized
137 # with json.dumps() instead of str().
138 _cast_orig = apiclient_discovery._cast
139 def _cast_objects_too(value, schema_type):
140     global _cast_orig
141     if (type(value) != type('') and
142         type(value) != type(b'') and
143         (schema_type == 'object' or schema_type == 'array')):
144         return json.dumps(value)
145     else:
146         return _cast_orig(value, schema_type)
147 apiclient_discovery._cast = _cast_objects_too
148
149 # Convert apiclient's HttpErrors into our own API error subclass for better
150 # error reporting.
151 # Reassigning apiclient_errors.HttpError is not sufficient because most of the
152 # apiclient submodules import the class into their own namespace.
153 def _new_http_error(cls, *args, **kwargs):
154     return super(apiclient_errors.HttpError, cls).__new__(
155         errors.ApiError, *args, **kwargs)
156 apiclient_errors.HttpError.__new__ = staticmethod(_new_http_error)
157
158 def http_cache(data_type: str) -> cache.SafeHTTPCache:
159     """Set up an HTTP file cache
160
161     This function constructs and returns an `arvados.cache.SafeHTTPCache`
162     backed by the filesystem under a cache directory from the environment, or
163     `None` if the directory cannot be set up. The return value can be passed to
164     `httplib2.Http` as the `cache` argument.
165
166     Arguments:
167
168     * data_type: str --- The name of the subdirectory
169       where data is cached.
170     """
171     try:
172         path = util._BaseDirectories('CACHE').storage_path(data_type)
173     except (OSError, RuntimeError):
174         return None
175     else:
176         return cache.SafeHTTPCache(str(path), max_age=60*60*24*2)
177
178 def api_client(
179         version: str,
180         discoveryServiceUrl: str,
181         token: str,
182         *,
183         cache: bool=True,
184         http: Optional[httplib2.Http]=None,
185         insecure: bool=False,
186         num_retries: int=10,
187         request_id: Optional[str]=None,
188         timeout: int=5*60,
189         **kwargs: Any,
190 ) -> apiclient_discovery.Resource:
191     """Build an Arvados API client
192
193     This function returns a `googleapiclient.discovery.Resource` object
194     constructed from the given arguments. This is a relatively low-level
195     interface that requires all the necessary inputs as arguments. Most
196     users will prefer to use `api` which can accept more flexible inputs.
197
198     Arguments:
199
200     * version: str --- A string naming the version of the Arvados API to use.
201
202     * discoveryServiceUrl: str --- The URL used to discover APIs passed
203       directly to `googleapiclient.discovery.build`.
204
205     * token: str --- The authentication token to send with each API call.
206
207     Keyword-only arguments:
208
209     * cache: bool --- If true, loads the API discovery document from, or
210       saves it to, a cache on disk.
211
212     * http: httplib2.Http | None --- The HTTP client object the API client
213       object will use to make requests.  If not provided, this function will
214       build its own to use. Either way, the object will be patched as part
215       of the build process.
216
217     * insecure: bool --- If true, ignore SSL certificate validation
218       errors. Default `False`.
219
220     * num_retries: int --- The number of times to retry each API request if
221       it encounters a temporary failure. Default 10.
222
223     * request_id: str | None --- Default `X-Request-Id` header value for
224       outgoing requests that don't already provide one. If `None` or
225       omitted, generate a random ID. When retrying failed requests, the same
226       ID is used on all attempts.
227
228     * timeout: int --- A timeout value for HTTP requests in seconds. Default
229       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, num_retries)
243
244     # The first time a client is instantiated, temporarily route
245     # googleapiclient.http retry logs if they're not already. These are
246     # important because temporary problems fetching the discovery document
247     # can cause clients to appear to hang early. This can be removed after
248     # we have a more general story for handling googleapiclient logs (#20521).
249     client_logger = logging.getLogger('googleapiclient.http')
250     # "first time a client is instantiated" = thread that acquires this lock
251     # It is never released.
252     # googleapiclient sets up its own NullHandler so we detect if logging is
253     # configured by looking for a real handler anywhere in the hierarchy.
254     client_logger_unconfigured = _googleapiclient_log_lock.acquire(blocking=False) and all(
255         isinstance(handler, logging.NullHandler)
256         for logger_name in ['', 'googleapiclient', 'googleapiclient.http']
257         for handler in logging.getLogger(logger_name).handlers
258     )
259     if client_logger_unconfigured:
260         client_level = client_logger.level
261         client_filter = GoogleHTTPClientFilter()
262         client_logger.addFilter(client_filter)
263         client_logger.addHandler(log_handler)
264         if logging.NOTSET < client_level < client_filter.retry_levelno:
265             client_logger.setLevel(client_level)
266         else:
267             client_logger.setLevel(client_filter.retry_levelno)
268     try:
269         svc = apiclient_discovery.build(
270             'arvados', version,
271             cache_discovery=False,
272             discoveryServiceUrl=discoveryServiceUrl,
273             http=http,
274             num_retries=num_retries,
275             **kwargs,
276         )
277     finally:
278         if client_logger_unconfigured:
279             client_logger.removeHandler(log_handler)
280             client_logger.removeFilter(client_filter)
281             client_logger.setLevel(client_level)
282     svc.api_token = token
283     svc.insecure = insecure
284     svc.request_id = request_id
285     svc.config = lambda: util.get_config_once(svc)
286     svc.vocabulary = lambda: util.get_vocabulary_once(svc)
287     svc.close_connections = types.MethodType(_close_connections, svc)
288     http.max_request_size = svc._rootDesc.get('maxRequestSize', 0)
289     http.cache = None
290     http._request_id = lambda: svc.request_id or util.new_request_id()
291     return svc
292
293 def normalize_api_kwargs(
294         version: Optional[str]=None,
295         discoveryServiceUrl: Optional[str]=None,
296         host: Optional[str]=None,
297         token: Optional[str]=None,
298         **kwargs: Any,
299 ) -> Dict[str, Any]:
300     """Validate kwargs from `api` and build kwargs for `api_client`
301
302     This method takes high-level keyword arguments passed to the `api`
303     constructor and normalizes them into a new dictionary that can be passed
304     as keyword arguments to `api_client`. It raises `ValueError` if required
305     arguments are missing or conflict.
306
307     Arguments:
308
309     * version: str | None --- A string naming the version of the Arvados API
310       to use. If not specified, the code will log a warning and fall back to
311       'v1'.
312
313     * discoveryServiceUrl: str | None --- The URL used to discover APIs
314       passed directly to `googleapiclient.discovery.build`. It is an error
315       to pass both `discoveryServiceUrl` and `host`.
316
317     * host: str | None --- The hostname and optional port number of the
318       Arvados API server. Used to build `discoveryServiceUrl`. It is an
319       error to pass both `discoveryServiceUrl` and `host`.
320
321     * token: str --- The authentication token to send with each API call.
322
323     Additional keyword arguments will be included in the return value.
324     """
325     if discoveryServiceUrl and host:
326         raise ValueError("both discoveryServiceUrl and host provided")
327     elif discoveryServiceUrl:
328         url_src = "discoveryServiceUrl"
329     elif host:
330         url_src = "host argument"
331         discoveryServiceUrl = 'https://%s/discovery/v1/apis/{api}/{apiVersion}/rest' % (host,)
332     elif token:
333         # This specific error message gets priority for backwards compatibility.
334         raise ValueError("token argument provided, but host missing.")
335     else:
336         raise ValueError("neither discoveryServiceUrl nor host provided")
337     if not token:
338         raise ValueError("%s provided, but token missing" % (url_src,))
339     if not version:
340         version = 'v1'
341         _logger.info(
342             "Using default API version. Call arvados.api(%r) instead.",
343             version,
344         )
345     return {
346         'discoveryServiceUrl': discoveryServiceUrl,
347         'token': token,
348         'version': version,
349         **kwargs,
350     }
351
352 def api_kwargs_from_config(
353         version: Optional[str]=None,
354         apiconfig: Optional[Mapping[str, str]]=None,
355         **kwargs: Any
356 ) -> Dict[str, Any]:
357     """Build `api_client` keyword arguments from configuration
358
359     This function accepts a mapping with Arvados configuration settings like
360     `ARVADOS_API_HOST` and converts them into a mapping of keyword arguments
361     that can be passed to `api_client`. If `ARVADOS_API_HOST` or
362     `ARVADOS_API_TOKEN` are not configured, it raises `ValueError`.
363
364     Arguments:
365
366     * version: str | None --- A string naming the version of the Arvados API
367       to use. If not specified, the code will log a warning and fall back to
368       'v1'.
369
370     * apiconfig: Mapping[str, str] | None --- A mapping with entries for
371       `ARVADOS_API_HOST`, `ARVADOS_API_TOKEN`, and optionally
372       `ARVADOS_API_HOST_INSECURE`. If not provided, calls
373       `arvados.config.settings` to get these parameters from user
374       configuration.
375
376     Additional keyword arguments will be included in the return value.
377     """
378     if apiconfig is None:
379         apiconfig = config.settings()
380     missing = " and ".join(
381         key
382         for key in ['ARVADOS_API_HOST', 'ARVADOS_API_TOKEN']
383         if key not in apiconfig
384     )
385     if missing:
386         raise ValueError(
387             "%s not set.\nPlease set in %s or export environment variable." %
388             (missing, config.default_config_file),
389         )
390     return normalize_api_kwargs(
391         version,
392         None,
393         apiconfig['ARVADOS_API_HOST'],
394         apiconfig['ARVADOS_API_TOKEN'],
395         insecure=config.flag_is_true('ARVADOS_API_HOST_INSECURE', apiconfig),
396         **kwargs,
397     )
398
399 def api(
400         version: Optional[str]=None,
401         cache: bool=True,
402         host: Optional[str]=None,
403         token: Optional[str]=None,
404         insecure: bool=False,
405         request_id: Optional[str]=None,
406         timeout: int=5*60,
407         *,
408         discoveryServiceUrl: Optional[str]=None,
409         **kwargs: Any,
410 ) -> 'arvados.safeapi.ThreadSafeApiCache':
411     """Dynamically build an Arvados API client
412
413     This function provides a high-level "do what I mean" interface to build an
414     Arvados API client object. You can call it with no arguments to build a
415     client from user configuration; pass `host` and `token` arguments just
416     like you would write in user configuration; or pass additional arguments
417     for lower-level control over the client.
418
419     This function returns a `arvados.safeapi.ThreadSafeApiCache`, an
420     API-compatible wrapper around `googleapiclient.discovery.Resource`. If
421     you're handling concurrency yourself and/or your application is very
422     performance-sensitive, consider calling `api_client` directly.
423
424     Arguments:
425
426     * version: str | None --- A string naming the version of the Arvados API
427       to use. If not specified, the code will log a warning and fall back to
428       'v1'.
429
430     * host: str | None --- The hostname and optional port number of the
431       Arvados API server.
432
433     * token: str | None --- The authentication token to send with each API
434       call.
435
436     * discoveryServiceUrl: str | None --- The URL used to discover APIs
437       passed directly to `googleapiclient.discovery.build`.
438
439     If `host`, `token`, and `discoveryServiceUrl` are all omitted, `host` and
440     `token` will be loaded from the user's configuration. Otherwise, you must
441     pass `token` and one of `host` or `discoveryServiceUrl`. It is an error to
442     pass both `host` and `discoveryServiceUrl`.
443
444     Other arguments are passed directly to `api_client`. See that function's
445     docstring for more information about their meaning.
446     """
447     kwargs.update(
448         cache=cache,
449         insecure=insecure,
450         request_id=request_id,
451         timeout=timeout,
452     )
453     if discoveryServiceUrl or host or token:
454         kwargs.update(normalize_api_kwargs(version, discoveryServiceUrl, host, token))
455     else:
456         kwargs.update(api_kwargs_from_config(version))
457     version = kwargs.pop('version')
458     # We do the import here to avoid a circular import at the top level.
459     from .safeapi import ThreadSafeApiCache
460     return ThreadSafeApiCache({}, {}, kwargs, version)
461
462 def api_from_config(
463         version: Optional[str]=None,
464         apiconfig: Optional[Mapping[str, str]]=None,
465         **kwargs: Any
466 ) -> 'arvados.safeapi.ThreadSafeApiCache':
467     """Build an Arvados API client from a configuration mapping
468
469     This function builds an Arvados API client from a mapping with user
470     configuration. It accepts that mapping as an argument, so you can use a
471     configuration that's different from what the user has set up.
472
473     This function returns a `arvados.safeapi.ThreadSafeApiCache`, an
474     API-compatible wrapper around `googleapiclient.discovery.Resource`. If
475     you're handling concurrency yourself and/or your application is very
476     performance-sensitive, consider calling `api_client` directly.
477
478     Arguments:
479
480     * version: str | None --- A string naming the version of the Arvados API
481       to use. If not specified, the code will log a warning and fall back to
482       'v1'.
483
484     * apiconfig: Mapping[str, str] | None --- A mapping with entries for
485       `ARVADOS_API_HOST`, `ARVADOS_API_TOKEN`, and optionally
486       `ARVADOS_API_HOST_INSECURE`. If not provided, calls
487       `arvados.config.settings` to get these parameters from user
488       configuration.
489
490     Other arguments are passed directly to `api_client`. See that function's
491     docstring for more information about their meaning.
492     """
493     return api(**api_kwargs_from_config(version, apiconfig, **kwargs))
494
495 class OrderedJsonModel(apiclient.model.JsonModel):
496     """Model class for JSON that preserves the contents' order
497
498     .. WARNING:: Deprecated
499        This model is redundant now that Python dictionaries preserve insertion
500        ordering. Code that passes this model to API constructors can remove it.
501
502     In Python versions before 3.6, API clients that cared about preserving the
503     order of fields in API server responses could use this model to do so.
504     Typical usage looked like:
505
506         from arvados.api import OrderedJsonModel
507         client = arvados.api('v1', ..., model=OrderedJsonModel())
508     """
509     @util._deprecated(preferred="the default model and rely on Python's built-in dictionary ordering")
510     def __init__(self, data_wrapper=False):
511         return super().__init__(data_wrapper)
512
513
514 RETRY_DELAY_INITIAL = 0
515 """
516 .. WARNING:: Deprecated
517    This constant was used by retry code in previous versions of the Arvados SDK.
518    Changing the value has no effect anymore.
519    Prefer passing `num_retries` to an API client constructor instead.
520    Refer to the constructor docstrings for details.
521 """
522
523 RETRY_DELAY_BACKOFF = 0
524 """
525 .. WARNING:: Deprecated
526    This constant was used by retry code in previous versions of the Arvados SDK.
527    Changing the value has no effect anymore.
528    Prefer passing `num_retries` to an API client constructor instead.
529    Refer to the constructor docstrings for details.
530 """
531
532 RETRY_COUNT = 0
533 """
534 .. WARNING:: Deprecated
535    This constant was used by retry code in previous versions of the Arvados SDK.
536    Changing the value has no effect anymore.
537    Prefer passing `num_retries` to an API client constructor instead.
538    Refer to the constructor docstrings for details.
539 """