Merge branch '2800-python-global-state' into 2800-pgs
[arvados.git] / sdk / python / arvados / api.py
1 import httplib2
2 import json
3 import logging
4 import os
5 import re
6 import types
7 import hashlib
8
9 import apiclient
10 import apiclient.discovery
11 import apiclient.errors
12 import config
13 import errors
14 import util
15
16 _logger = logging.getLogger('arvados.api')
17 conncache = {}
18
19 class CredentialsFromToken(object):
20     def __init__(self, api_token):
21         self.api_token = api_token
22
23     @staticmethod
24     def http_request(self, uri, **kwargs):
25         from httplib import BadStatusLine
26         if 'headers' not in kwargs:
27             kwargs['headers'] = {}
28
29         if config.get("ARVADOS_EXTERNAL_CLIENT", "") == "true":
30             kwargs['headers']['X-External-Client'] = '1'
31
32         kwargs['headers']['Authorization'] = 'OAuth2 %s' % self.arvados_api_token
33         try:
34             return self.orig_http_request(uri, **kwargs)
35         except BadStatusLine:
36             # This is how httplib tells us that it tried to reuse an
37             # existing connection but it was already closed by the
38             # server. In that case, yes, we would like to retry.
39             # Unfortunately, we are not absolutely certain that the
40             # previous call did not succeed, so this is slightly
41             # risky.
42             return self.orig_http_request(uri, **kwargs)
43     def authorize(self, http):
44         http.arvados_api_token = self.api_token
45         http.orig_http_request = http.request
46         http.request = types.MethodType(self.http_request, http)
47         return http
48
49 # Monkey patch discovery._cast() so objects and arrays get serialized
50 # with json.dumps() instead of str().
51 _cast_orig = apiclient.discovery._cast
52 def _cast_objects_too(value, schema_type):
53     global _cast_orig
54     if (type(value) != type('') and
55         (schema_type == 'object' or schema_type == 'array')):
56         return json.dumps(value)
57     else:
58         return _cast_orig(value, schema_type)
59 apiclient.discovery._cast = _cast_objects_too
60
61 # Convert apiclient's HttpErrors into our own API error subclass for better
62 # error reporting.
63 # Reassigning apiclient.errors.HttpError is not sufficient because most of the
64 # apiclient submodules import the class into their own namespace.
65 def _new_http_error(cls, *args, **kwargs):
66     return super(apiclient.errors.HttpError, cls).__new__(
67         errors.ApiError, *args, **kwargs)
68 apiclient.errors.HttpError.__new__ = staticmethod(_new_http_error)
69
70 def http_cache(data_type):
71     path = os.environ['HOME'] + '/.cache/arvados/' + data_type
72     try:
73         util.mkdir_dash_p(path)
74     except OSError:
75         path = None
76     return path
77
78 def api(version=None, cache=True, host=None, token=None, insecure=False, **kwargs):
79     """Return an apiclient Resources object for an Arvados instance.
80
81     Arguments:
82     * version: A string naming the version of the Arvados API to use (for
83       example, 'v1').
84     * cache: If True (default), return an existing Resources object if
85       one already exists with the same endpoint and credentials. If
86       False, create a new one, and do not keep it in the cache (i.e.,
87       do not return it from subsequent api(cache=True) calls with
88       matching endpoint and credentials).
89     * host: The Arvados API server host (and optional :port) to connect to.
90     * token: The authentication token to send with each API call.
91     * insecure: If True, ignore SSL certificate validation errors.
92
93     Additional keyword arguments will be passed directly to
94     `apiclient.discovery.build` if a new Resource object is created.
95     If the `discoveryServiceUrl` or `http` keyword arguments are
96     missing, this function will set default values for them, based on
97     the current Arvados configuration settings.
98
99     """
100
101     if not version:
102         version = 'v1'
103         logging.info("Using default API version. " +
104                      "Call arvados.api('%s') instead." %
105                      version)
106     if host and token:
107         apiinsecure = insecure
108     elif not host and not token:
109         # Load from user configuration or environment
110         for x in ['ARVADOS_API_HOST', 'ARVADOS_API_TOKEN']:
111             if x not in config.settings():
112                 raise Exception("%s is not set. Aborting." % x)
113         host = config.get('ARVADOS_API_HOST')
114         token = config.get('ARVADOS_API_TOKEN')
115         apiinsecure = (config.get('ARVADOS_API_HOST_INSECURE', '').lower() in
116                        ('yes', 'true', '1'))
117     else:
118         # Caller provided one but not the other
119         if not host:
120             raise Exception("token argument provided, but host missing.")
121         else:
122             raise Exception("host argument provided, but token missing.")
123
124     if cache:
125         connprofile = hashlib.sha1(' '.join([
126             version, host, token, ('y' if apiinsecure else 'n')
127         ])).hexdigest()
128         svc = conncache.get(connprofile)
129         if svc:
130             return svc
131
132     if 'http' not in kwargs:
133         http_kwargs = {}
134         # Prefer system's CA certificates (if available) over httplib2's.
135         certs_path = '/etc/ssl/certs/ca-certificates.crt'
136         if os.path.exists(certs_path):
137             http_kwargs['ca_certs'] = certs_path
138         if cache:
139             http_kwargs['cache'] = http_cache('discovery')
140         if apiinsecure:
141             http_kwargs['disable_ssl_certificate_validation'] = True
142         kwargs['http'] = httplib2.Http(**http_kwargs)
143
144     credentials = CredentialsFromToken(api_token=token)
145     kwargs['http'] = credentials.authorize(kwargs['http'])
146
147     if 'discoveryServiceUrl' not in kwargs:
148         kwargs['discoveryServiceUrl'] = (
149             'https://%s/discovery/v1/apis/{api}/{apiVersion}/rest' % (host,))
150
151     svc = apiclient.discovery.build('arvados', version, **kwargs)
152     kwargs['http'].cache = None
153     if cache:
154         conncache[connprofile] = svc
155     return svc
156
157 def unload_connection_cache():
158     for connprofile in conncache:
159         del conncache[connprofile]