Skip to content

Commit 27d5e7d

Browse files
authored
feat(storage): Introduce base abstract class for storage client (#1655)
This PR introduces an abstract class to support the upcoming async client for the Python SDK. This refactor defines the public interface and enables code sharing between sync and async client, without introducing new logic.
1 parent b992294 commit 27d5e7d

File tree

3 files changed

+390
-249
lines changed

3 files changed

+390
-249
lines changed

google/cloud/storage/abstracts/__init__.py

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

0 commit comments

Comments
 (0)