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