Skip to content

Commit a30450e

Browse files
committed
feat(storage): add abstract BaseClient for Python storage clients
1 parent 4c6d549 commit a30450e

File tree

3 files changed

+381
-248
lines changed

3 files changed

+381
-248
lines changed

google/cloud/storage/abstracts/__init__.py

Whitespace-only changes.
Lines changed: 372 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,372 @@
1+
# Copyright 2025 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+
"""The abstract for python-storage Client."""
15+
16+
from google.cloud.storage._helpers import _get_api_endpoint_override
17+
from google.cloud.storage._helpers import _get_environ_project
18+
from google.cloud.storage._helpers import _get_storage_emulator_override
19+
from google.cloud.storage._helpers import _use_client_cert
20+
from google.cloud.storage._helpers import _DEFAULT_SCHEME
21+
from google.cloud.storage._helpers import _STORAGE_HOST_TEMPLATE
22+
from google.auth.credentials import AnonymousCredentials
23+
from google.cloud.client import ClientWithProject
24+
from google.cloud.storage._helpers import _DEFAULT_UNIVERSE_DOMAIN
25+
from google.cloud._helpers import _LocalStack
26+
from abc import ABC, abstractmethod
27+
28+
import google.api_core
29+
30+
marker = object()
31+
32+
class BaseClient(ClientWithProject, ABC):
33+
"""Abstract class for python-storage Client"""
34+
35+
SCOPE = (
36+
"https://www.googleapis.com/auth/devstorage.full_control",
37+
"https://www.googleapis.com/auth/devstorage.read_only",
38+
"https://www.googleapis.com/auth/devstorage.read_write",
39+
)
40+
"""The scopes required for authenticating as a Cloud Storage consumer."""
41+
42+
def __init__(
43+
self,
44+
project=marker,
45+
credentials=None,
46+
_http=None,
47+
client_info=None,
48+
client_options=None,
49+
use_auth_w_custom_endpoint=True,
50+
extra_headers={},
51+
*,
52+
api_key=None,
53+
):
54+
self._base_connection = None
55+
56+
if project is None:
57+
no_project = True
58+
project = "<none>"
59+
else:
60+
no_project = False
61+
62+
if project is marker:
63+
project = None
64+
65+
# Save the initial value of constructor arguments before they
66+
# are passed along, for use in __reduce__ defined elsewhere.
67+
self._initial_client_info = client_info
68+
self._initial_client_options = client_options
69+
self._extra_headers = extra_headers
70+
71+
connection_kw_args = {"client_info": client_info}
72+
73+
# api_key should set client_options.api_key. Set it here whether
74+
# client_options was specified as a dict, as a ClientOptions object, or
75+
# None.
76+
if api_key:
77+
if client_options and not isinstance(client_options, dict):
78+
client_options.api_key = api_key
79+
else:
80+
if not client_options:
81+
client_options = {}
82+
client_options["api_key"] = api_key
83+
84+
if client_options:
85+
if isinstance(client_options, dict):
86+
client_options = google.api_core.client_options.from_dict(
87+
client_options
88+
)
89+
90+
if client_options and client_options.universe_domain:
91+
self._universe_domain = client_options.universe_domain
92+
else:
93+
self._universe_domain = None
94+
95+
storage_emulator_override = _get_storage_emulator_override()
96+
api_endpoint_override = _get_api_endpoint_override()
97+
98+
# Determine the api endpoint. The rules are as follows:
99+
100+
# 1. If the `api_endpoint` is set in `client_options`, use that as the
101+
# endpoint.
102+
if client_options and client_options.api_endpoint:
103+
api_endpoint = client_options.api_endpoint
104+
105+
# 2. Elif the "STORAGE_EMULATOR_HOST" env var is set, then use that as the
106+
# endpoint.
107+
elif storage_emulator_override:
108+
api_endpoint = storage_emulator_override
109+
110+
# 3. Elif the "API_ENDPOINT_OVERRIDE" env var is set, then use that as the
111+
# endpoint.
112+
elif api_endpoint_override:
113+
api_endpoint = api_endpoint_override
114+
115+
# 4. Elif the `universe_domain` is set in `client_options`,
116+
# create the endpoint using that as the default.
117+
#
118+
# Mutual TLS is not compatible with a non-default universe domain
119+
# at this time. If such settings are enabled along with the
120+
# "GOOGLE_API_USE_CLIENT_CERTIFICATE" env variable, a ValueError will
121+
# be raised.
122+
123+
elif self._universe_domain:
124+
# The final decision of whether to use mTLS takes place in
125+
# google-auth-library-python. We peek at the environment variable
126+
# here only to issue an exception in case of a conflict.
127+
if _use_client_cert():
128+
raise ValueError(
129+
'The "GOOGLE_API_USE_CLIENT_CERTIFICATE" env variable is '
130+
'set to "true" and a non-default universe domain is '
131+
"configured. mTLS is not supported in any universe other than"
132+
"googleapis.com."
133+
)
134+
api_endpoint = _DEFAULT_SCHEME + _STORAGE_HOST_TEMPLATE.format(
135+
universe_domain=self._universe_domain
136+
)
137+
138+
# 5. Else, use the default, which is to use the default
139+
# universe domain of "googleapis.com" and create the endpoint
140+
# "storage.googleapis.com" from that.
141+
else:
142+
api_endpoint = None
143+
144+
connection_kw_args["api_endpoint"] = api_endpoint
145+
self._is_emulator_set = True if storage_emulator_override else False
146+
147+
# If a custom endpoint is set, the client checks for credentials
148+
# or finds the default credentials based on the current environment.
149+
# Authentication may be bypassed under certain conditions:
150+
# (1) STORAGE_EMULATOR_HOST is set (for backwards compatibility), OR
151+
# (2) use_auth_w_custom_endpoint is set to False.
152+
if connection_kw_args["api_endpoint"] is not None:
153+
if self._is_emulator_set or not use_auth_w_custom_endpoint:
154+
if credentials is None:
155+
credentials = AnonymousCredentials()
156+
if project is None:
157+
project = _get_environ_project()
158+
if project is None:
159+
no_project = True
160+
project = "<none>"
161+
162+
super(BaseClient, self).__init__(
163+
project=project,
164+
credentials=credentials,
165+
client_options=client_options,
166+
_http=_http,
167+
)
168+
169+
# Validate that the universe domain of the credentials matches the
170+
# universe domain of the client.
171+
if self._credentials.universe_domain != self.universe_domain:
172+
raise ValueError(
173+
"The configured universe domain ({client_ud}) does not match "
174+
"the universe domain found in the credentials ({cred_ud}). If "
175+
"you haven't configured the universe domain explicitly, "
176+
"`googleapis.com` is the default.".format(
177+
client_ud=self.universe_domain,
178+
cred_ud=self._credentials.universe_domain,
179+
)
180+
)
181+
182+
if no_project:
183+
self.project = None
184+
185+
self.connection_kw_args = connection_kw_args
186+
self._batch_stack = _LocalStack()
187+
188+
@property
189+
def universe_domain(self):
190+
return self._universe_domain or _DEFAULT_UNIVERSE_DOMAIN
191+
192+
@classmethod
193+
def create_anonymous_client(cls):
194+
"""Factory: return client with anonymous credentials.
195+
196+
.. note::
197+
198+
Such a client has only limited access to "public" buckets:
199+
listing their contents and downloading their blobs.
200+
201+
:rtype: :class:`google.cloud.storage.client.Client`
202+
:returns: Instance w/ anonymous credentials and no project.
203+
"""
204+
client = cls(project="<none>", credentials=AnonymousCredentials())
205+
client.project = None
206+
return client
207+
208+
@property
209+
def api_endpoint(self):
210+
"""Returns the API_BASE_URL from connection"""
211+
return self._connection.API_BASE_URL
212+
213+
def update_user_agent(self, user_agent):
214+
"""Update the user-agent string for this client.
215+
216+
:type user_agent: str
217+
:param user_agent: The string to add to the user-agent.
218+
"""
219+
existing_user_agent = self._connection._client_info.user_agent
220+
if existing_user_agent is None:
221+
self._connection.user_agent = user_agent
222+
else:
223+
self._connection.user_agent = f"{user_agent} {existing_user_agent}"
224+
225+
@property
226+
def _connection(self):
227+
"""Get connection or batch on the client.
228+
229+
:rtype: :class:`google.cloud.storage._http.Connection`
230+
:returns: The connection set on the client, or the batch
231+
if one is set.
232+
"""
233+
if self.current_batch is not None:
234+
return self.current_batch
235+
else:
236+
return self._base_connection
237+
238+
@_connection.setter
239+
def _connection(self, value):
240+
"""Set connection on the client.
241+
242+
Intended to be used by constructor (since the base class calls)
243+
self._connection = connection
244+
Will raise if the connection is set more than once.
245+
246+
:type value: :class:`google.cloud.storage._http.Connection`
247+
:param value: The connection set on the client.
248+
249+
:raises: :class:`ValueError` if connection has already been set.
250+
"""
251+
if self._base_connection is not None:
252+
raise ValueError("Connection already set on client")
253+
self._base_connection = value
254+
255+
def _push_batch(self, batch):
256+
"""Push a batch onto our stack.
257+
258+
"Protected", intended for use by batch context mgrs.
259+
260+
:type batch: :class:`google.cloud.storage.batch.Batch`
261+
:param batch: newly-active batch
262+
"""
263+
self._batch_stack.push(batch)
264+
265+
def _pop_batch(self):
266+
"""Pop a batch from our stack.
267+
268+
"Protected", intended for use by batch context mgrs.
269+
270+
:raises: IndexError if the stack is empty.
271+
:rtype: :class:`google.cloud.storage.batch.Batch`
272+
:returns: the top-most batch/transaction, after removing it.
273+
"""
274+
return self._batch_stack.pop()
275+
276+
@property
277+
def current_batch(self):
278+
"""Currently-active batch.
279+
280+
:rtype: :class:`google.cloud.storage.batch.Batch` or ``NoneType`` (if
281+
no batch is active).
282+
:returns: The batch at the top of the batch stack.
283+
"""
284+
return self._batch_stack.top
285+
286+
@abstractmethod
287+
def bucket(self, bucket_name, user_project=None, generation=None):
288+
raise NotImplementedError("This method needs to be implemented.")
289+
290+
@abstractmethod
291+
def _get_resource(
292+
self,
293+
path,
294+
query_params=None,
295+
headers=None,
296+
timeout=None,
297+
retry=None,
298+
_target_object=None,
299+
):
300+
"""Helper for bucket / blob methods making API 'GET' calls."""
301+
raise NotImplementedError("This should be implemented via the child class")
302+
303+
@abstractmethod
304+
def _list_resource(
305+
self,
306+
path,
307+
item_to_value,
308+
page_token=None,
309+
max_results=None,
310+
extra_params=None,
311+
page_start=None,
312+
page_size=None,
313+
timeout=None,
314+
retry=None,
315+
):
316+
"""Helper for bucket / blob methods making API 'GET' calls."""
317+
raise NotImplementedError("This should be implemented via the child class")
318+
319+
@abstractmethod
320+
def _patch_resource(
321+
self,
322+
path,
323+
data,
324+
query_params=None,
325+
headers=None,
326+
timeout=None,
327+
retry=None,
328+
_target_object=None,
329+
):
330+
"""Helper for bucket / blob methods making API 'PATCH' calls."""
331+
raise NotImplementedError("This should be implemented via the child class")
332+
333+
@abstractmethod
334+
def _put_resource(
335+
self,
336+
path,
337+
data,
338+
query_params=None,
339+
headers=None,
340+
timeout=None,
341+
retry=None,
342+
_target_object=None,
343+
):
344+
"""Helper for bucket / blob methods making API 'PUT' calls."""
345+
raise NotImplementedError("This should be implemented via the child class")
346+
347+
@abstractmethod
348+
def _post_resource(
349+
self,
350+
path,
351+
data,
352+
query_params=None,
353+
headers=None,
354+
timeout=None,
355+
retry=None,
356+
_target_object=None,
357+
):
358+
"""Helper for bucket / blob methods making API 'POST' calls."""
359+
raise NotImplementedError("This should be implemented via the child class")
360+
361+
@abstractmethod
362+
def _delete_resource(
363+
self,
364+
path,
365+
query_params=None,
366+
headers=None,
367+
timeout=None,
368+
retry=None,
369+
_target_object=None,
370+
):
371+
"""Helper for bucket / blob methods making API 'DELETE' calls."""
372+
raise NotImplementedError("This should be implemented via the child class")

0 commit comments

Comments
 (0)