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