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