1# Copyright 2015 Google LLC
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"""Base classes for client used to interact with Google Cloud APIs."""
16
17import io
18import json
19import os
20from pickle import PicklingError
21from typing import Tuple
22from typing import Union
23
24import google.api_core.client_options
25import google.api_core.exceptions
26import google.auth
27from google.auth import environment_vars
28import google.auth.credentials
29import google.auth.transport.requests
30from google.cloud._helpers import _determine_default_project
31from google.oauth2 import service_account
32
33try:
34 import google.auth.api_key
35
36 HAS_GOOGLE_AUTH_API_KEY = True
37except ImportError: # pragma: NO COVER
38 HAS_GOOGLE_AUTH_API_KEY = False # pragma: NO COVER
39 # TODO: Investigate adding a test for google.auth.api_key ImportError (https://github.com/googleapis/python-cloud-core/issues/334)
40
41
42_GOOGLE_AUTH_CREDENTIALS_HELP = (
43 "This library only supports credentials from google-auth-library-python. "
44 "See https://google-auth.readthedocs.io/en/latest/ "
45 "for help on authentication with this library."
46)
47
48# Default timeout for auth requests.
49_CREDENTIALS_REFRESH_TIMEOUT = 300
50
51
52class _ClientFactoryMixin(object):
53 """Mixin to allow factories that create credentials.
54
55 .. note::
56
57 This class is virtual.
58 """
59
60 _SET_PROJECT = False
61
62 @classmethod
63 def from_service_account_info(cls, info, *args, **kwargs):
64 """Factory to retrieve JSON credentials while creating client.
65
66 :type info: dict
67 :param info:
68 The JSON object with a private key and other credentials
69 information (downloaded from the Google APIs console).
70
71 :type args: tuple
72 :param args: Remaining positional arguments to pass to constructor.
73
74 :param kwargs: Remaining keyword arguments to pass to constructor.
75
76 :rtype: :class:`_ClientFactoryMixin`
77 :returns: The client created with the retrieved JSON credentials.
78 :raises TypeError: if there is a conflict with the kwargs
79 and the credentials created by the factory.
80 """
81 if "credentials" in kwargs:
82 raise TypeError("credentials must not be in keyword arguments")
83
84 credentials = service_account.Credentials.from_service_account_info(info)
85 if cls._SET_PROJECT:
86 if "project" not in kwargs:
87 kwargs["project"] = info.get("project_id")
88
89 kwargs["credentials"] = credentials
90 return cls(*args, **kwargs)
91
92 @classmethod
93 def from_service_account_json(cls, json_credentials_path, *args, **kwargs):
94 """Factory to retrieve JSON credentials while creating client.
95
96 :type json_credentials_path: str
97 :param json_credentials_path: The path to a private key file (this file
98 was given to you when you created the
99 service account). This file must contain
100 a JSON object with a private key and
101 other credentials information (downloaded
102 from the Google APIs console).
103
104 :type args: tuple
105 :param args: Remaining positional arguments to pass to constructor.
106
107 :param kwargs: Remaining keyword arguments to pass to constructor.
108
109 :rtype: :class:`_ClientFactoryMixin`
110 :returns: The client created with the retrieved JSON credentials.
111 :raises TypeError: if there is a conflict with the kwargs
112 and the credentials created by the factory.
113 """
114 with io.open(json_credentials_path, "r", encoding="utf-8") as json_fi:
115 credentials_info = json.load(json_fi)
116
117 return cls.from_service_account_info(credentials_info, *args, **kwargs)
118
119
120class Client(_ClientFactoryMixin):
121 """Client to bundle configuration needed for API requests.
122
123 Stores ``credentials`` and an HTTP object so that subclasses
124 can pass them along to a connection class.
125
126 If no value is passed in for ``_http``, a :class:`requests.Session` object
127 will be created and authorized with the ``credentials``. If not, the
128 ``credentials`` and ``_http`` need not be related.
129
130 Callers and subclasses may seek to use the private key from
131 ``credentials`` to sign data.
132
133 Args:
134 credentials (google.auth.credentials.Credentials):
135 (Optional) The OAuth2 Credentials to use for this client. If not
136 passed (and if no ``_http`` object is passed), falls back to the
137 default inferred from the environment.
138 client_options (google.api_core.client_options.ClientOptions):
139 (Optional) Custom options for the client.
140 _http (requests.Session):
141 (Optional) HTTP object to make requests. Can be any object that
142 defines ``request()`` with the same interface as
143 :meth:`requests.Session.request`. If not passed, an ``_http``
144 object is created that is bound to the ``credentials`` for the
145 current object.
146 This parameter should be considered private, and could change in
147 the future.
148
149 Raises:
150 google.auth.exceptions.DefaultCredentialsError:
151 Raised if ``credentials`` is not specified and the library fails
152 to acquire default credentials.
153 """
154
155 SCOPE: Union[Tuple[str, ...], None] = None
156 """The scopes required for authenticating with a service.
157
158 Needs to be set by subclasses.
159 """
160
161 def __init__(self, credentials=None, _http=None, client_options=None):
162 if isinstance(client_options, dict):
163 client_options = google.api_core.client_options.from_dict(client_options)
164 if client_options is None:
165 client_options = google.api_core.client_options.ClientOptions()
166
167 if credentials and client_options.credentials_file:
168 raise google.api_core.exceptions.DuplicateCredentialArgs(
169 "'credentials' and 'client_options.credentials_file' are mutually exclusive."
170 )
171
172 if (
173 HAS_GOOGLE_AUTH_API_KEY
174 and client_options.api_key
175 and (credentials or client_options.credentials_file)
176 ):
177 raise google.api_core.exceptions.DuplicateCredentialArgs(
178 "'client_options.api_key' is mutually exclusive with 'credentials' and 'client_options.credentials_file'."
179 )
180
181 if credentials and not isinstance(
182 credentials, google.auth.credentials.Credentials
183 ):
184 raise ValueError(_GOOGLE_AUTH_CREDENTIALS_HELP)
185
186 scopes = client_options.scopes or self.SCOPE
187
188 # if no http is provided, credentials must exist
189 if not _http and credentials is None:
190 if client_options.credentials_file:
191 credentials, _ = google.auth.load_credentials_from_file(
192 client_options.credentials_file, scopes=scopes
193 )
194 elif HAS_GOOGLE_AUTH_API_KEY and client_options.api_key is not None:
195 credentials = google.auth.api_key.Credentials(client_options.api_key)
196 else:
197 credentials, _ = google.auth.default(scopes=scopes)
198
199 self._credentials = google.auth.credentials.with_scopes_if_required(
200 credentials, scopes=scopes
201 )
202
203 if client_options.quota_project_id:
204 self._credentials = self._credentials.with_quota_project(
205 client_options.quota_project_id
206 )
207
208 self._http_internal = _http
209 self._client_cert_source = client_options.client_cert_source
210
211 def __getstate__(self):
212 """Explicitly state that clients are not pickleable."""
213 raise PicklingError(
214 "\n".join(
215 [
216 "Pickling client objects is explicitly not supported.",
217 "Clients have non-trivial state that is local and unpickleable.",
218 ]
219 )
220 )
221
222 @property
223 def _http(self):
224 """Getter for object used for HTTP transport.
225
226 :rtype: :class:`~requests.Session`
227 :returns: An HTTP object.
228 """
229 if self._http_internal is None:
230 self._http_internal = google.auth.transport.requests.AuthorizedSession(
231 self._credentials,
232 refresh_timeout=_CREDENTIALS_REFRESH_TIMEOUT,
233 )
234 self._http_internal.configure_mtls_channel(self._client_cert_source)
235 return self._http_internal
236
237 def close(self):
238 """Clean up transport, if set.
239
240 Suggested use:
241
242 .. code-block:: python
243
244 import contextlib
245
246 with contextlib.closing(client): # closes on exit
247 do_something_with(client)
248 """
249 if self._http_internal is not None:
250 self._http_internal.close()
251
252
253class _ClientProjectMixin(object):
254 """Mixin to allow setting the project on the client.
255
256 :type project: str
257 :param project:
258 (Optional) the project which the client acts on behalf of. If not
259 passed, falls back to the default inferred from the environment.
260
261 :type credentials: :class:`google.auth.credentials.Credentials`
262 :param credentials:
263 (Optional) credentials used to discover a project, if not passed.
264
265 :raises: :class:`EnvironmentError` if the project is neither passed in nor
266 set on the credentials or in the environment. :class:`ValueError`
267 if the project value is invalid.
268 """
269
270 def __init__(self, project=None, credentials=None):
271 # This test duplicates the one from `google.auth.default`, but earlier,
272 # for backward compatibility: we want the environment variable to
273 # override any project set on the credentials. See:
274 # https://github.com/googleapis/python-cloud-core/issues/27
275 if project is None:
276 project = os.getenv(
277 environment_vars.PROJECT,
278 os.getenv(environment_vars.LEGACY_PROJECT),
279 )
280
281 # Project set on explicit credentials overrides discovery from
282 # SDK / GAE / GCE.
283 if project is None and credentials is not None:
284 project = getattr(credentials, "project_id", None)
285
286 if project is None:
287 project = self._determine_default(project)
288
289 if project is None:
290 raise EnvironmentError(
291 "Project was not passed and could not be "
292 "determined from the environment."
293 )
294
295 if isinstance(project, bytes):
296 project = project.decode("utf-8")
297
298 if not isinstance(project, str):
299 raise ValueError("Project must be a string.")
300
301 self.project = project
302
303 @staticmethod
304 def _determine_default(project):
305 """Helper: use default project detection."""
306 return _determine_default_project(project)
307
308
309class ClientWithProject(Client, _ClientProjectMixin):
310 """Client that also stores a project.
311
312 :type project: str
313 :param project: the project which the client acts on behalf of. If not
314 passed falls back to the default inferred from the
315 environment.
316
317 :type credentials: :class:`~google.auth.credentials.Credentials`
318 :param credentials: (Optional) The OAuth2 Credentials to use for this
319 client. If not passed (and if no ``_http`` object is
320 passed), falls back to the default inferred from the
321 environment.
322
323 :type _http: :class:`~requests.Session`
324 :param _http: (Optional) HTTP object to make requests. Can be any object
325 that defines ``request()`` with the same interface as
326 :meth:`~requests.Session.request`. If not passed, an
327 ``_http`` object is created that is bound to the
328 ``credentials`` for the current object.
329 This parameter should be considered private, and could
330 change in the future.
331
332 :raises: :class:`ValueError` if the project is neither passed in nor
333 set in the environment.
334 """
335
336 _SET_PROJECT = True # Used by from_service_account_json()
337
338 def __init__(self, project=None, credentials=None, client_options=None, _http=None):
339 _ClientProjectMixin.__init__(self, project=project, credentials=credentials)
340 Client.__init__(
341 self, credentials=credentials, client_options=client_options, _http=_http
342 )