Skip to content

Commit 043785e

Browse files
partheaohmayr
andauthored
feat: implement async client for LROs (#707)
* feat: implement `AbstractOperationsAsyncClient` to support long running operations * remove coverage guards * address presubmit failures * fix coverage for cancel operation * tests cleanup * fix incorrect tests * file bugs * add auth import * address PR comments * address PR comments * fix unit tests and address more comments * disable retry parameter * add retry parameter * address PR comments --------- Co-authored-by: ohmayr <[email protected]> Co-authored-by: ohmayr <[email protected]>
1 parent 3c7e43e commit 043785e

File tree

11 files changed

+1593
-528
lines changed

11 files changed

+1593
-528
lines changed

google/api_core/operations_v1/__init__.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,7 @@
1414

1515
"""Package for interacting with the google.longrunning.operations meta-API."""
1616

17-
from google.api_core.operations_v1.abstract_operations_client import (
18-
AbstractOperationsClient,
19-
)
17+
from google.api_core.operations_v1.abstract_operations_client import AbstractOperationsClient
2018
from google.api_core.operations_v1.operations_async_client import OperationsAsyncClient
2119
from google.api_core.operations_v1.operations_client import OperationsClient
2220
from google.api_core.operations_v1.transports.rest import OperationsRestTransport
@@ -29,10 +27,14 @@
2927
]
3028

3129
try:
32-
from google.api_core.operations_v1.transports.rest_asyncio import OperationsRestAsyncTransport # noqa: F401
33-
__all__.append("OperationsRestAsyncTransport")
30+
from google.api_core.operations_v1.transports.rest_asyncio import (
31+
AsyncOperationsRestTransport,
32+
)
33+
from google.api_core.operations_v1.operations_rest_client_async import AsyncOperationsRestClient
34+
35+
__all__ += ["AsyncOperationsRestClient", "AsyncOperationsRestTransport"]
3436
except ImportError:
3537
# This import requires the `async_rest` extra.
36-
# Don't raise an exception if `OperationsRestAsyncTransport` cannot be imported
38+
# Don't raise an exception if `AsyncOperationsRestTransport` cannot be imported
3739
# as other transports are still available.
3840
pass
Lines changed: 370 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright 2024 Google LLC
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
from collections import OrderedDict
17+
import os
18+
import re
19+
from typing import Dict, Optional, Type, Union
20+
21+
from google.api_core import client_options as client_options_lib # type: ignore
22+
from google.api_core import gapic_v1 # type: ignore
23+
from google.api_core.operations_v1.transports.base import (
24+
DEFAULT_CLIENT_INFO,
25+
OperationsTransport,
26+
)
27+
from google.api_core.operations_v1.transports.rest import OperationsRestTransport
28+
29+
try:
30+
from google.api_core.operations_v1.transports.rest_asyncio import (
31+
AsyncOperationsRestTransport,
32+
)
33+
34+
HAS_ASYNC_REST_DEPENDENCIES = True
35+
except ImportError as e:
36+
HAS_ASYNC_REST_DEPENDENCIES = False
37+
ASYNC_REST_EXCEPTION = e
38+
39+
from google.auth import credentials as ga_credentials # type: ignore
40+
from google.auth.exceptions import MutualTLSChannelError # type: ignore
41+
from google.auth.transport import mtls # type: ignore
42+
43+
44+
class AbstractOperationsBaseClientMeta(type):
45+
"""Metaclass for the Operations Base client.
46+
47+
This provides base class-level methods for building and retrieving
48+
support objects (e.g. transport) without polluting the client instance
49+
objects.
50+
"""
51+
52+
_transport_registry = OrderedDict() # type: Dict[str, Type[OperationsTransport]]
53+
_transport_registry["rest"] = OperationsRestTransport
54+
if HAS_ASYNC_REST_DEPENDENCIES:
55+
_transport_registry["rest_asyncio"] = AsyncOperationsRestTransport
56+
57+
def get_transport_class(
58+
cls,
59+
label: Optional[str] = None,
60+
) -> Type[OperationsTransport]:
61+
"""Returns an appropriate transport class.
62+
63+
Args:
64+
label: The name of the desired transport. If none is
65+
provided, then the first transport in the registry is used.
66+
67+
Returns:
68+
The transport class to use.
69+
"""
70+
# If a specific transport is requested, return that one.
71+
if (
72+
label == "rest_asyncio" and not HAS_ASYNC_REST_DEPENDENCIES
73+
): # pragma: NO COVER
74+
raise ASYNC_REST_EXCEPTION
75+
76+
if label:
77+
return cls._transport_registry[label]
78+
79+
# No transport is requested; return the default (that is, the first one
80+
# in the dictionary).
81+
return next(iter(cls._transport_registry.values()))
82+
83+
84+
class AbstractOperationsBaseClient(metaclass=AbstractOperationsBaseClientMeta):
85+
"""Manages long-running operations with an API service.
86+
87+
When an API method normally takes long time to complete, it can be
88+
designed to return [Operation][google.api_core.operations_v1.Operation] to the
89+
client, and the client can use this interface to receive the real
90+
response asynchronously by polling the operation resource, or pass
91+
the operation resource to another API (such as Google Cloud Pub/Sub
92+
API) to receive the response. Any API service that returns
93+
long-running operations should implement the ``Operations``
94+
interface so developers can have a consistent client experience.
95+
"""
96+
97+
@staticmethod
98+
def _get_default_mtls_endpoint(api_endpoint):
99+
"""Converts api endpoint to mTLS endpoint.
100+
101+
Convert "*.sandbox.googleapis.com" and "*.googleapis.com" to
102+
"*.mtls.sandbox.googleapis.com" and "*.mtls.googleapis.com" respectively.
103+
Args:
104+
api_endpoint (Optional[str]): the api endpoint to convert.
105+
Returns:
106+
str: converted mTLS api endpoint.
107+
"""
108+
if not api_endpoint:
109+
return api_endpoint
110+
111+
mtls_endpoint_re = re.compile(
112+
r"(?P<name>[^.]+)(?P<mtls>\.mtls)?(?P<sandbox>\.sandbox)?(?P<googledomain>\.googleapis\.com)?"
113+
)
114+
115+
m = mtls_endpoint_re.match(api_endpoint)
116+
name, mtls, sandbox, googledomain = m.groups()
117+
if mtls or not googledomain:
118+
return api_endpoint
119+
120+
if sandbox:
121+
return api_endpoint.replace(
122+
"sandbox.googleapis.com", "mtls.sandbox.googleapis.com"
123+
)
124+
125+
return api_endpoint.replace(".googleapis.com", ".mtls.googleapis.com")
126+
127+
DEFAULT_ENDPOINT = "longrunning.googleapis.com"
128+
DEFAULT_MTLS_ENDPOINT = _get_default_mtls_endpoint.__func__( # type: ignore
129+
DEFAULT_ENDPOINT
130+
)
131+
132+
@classmethod
133+
def from_service_account_info(cls, info: dict, *args, **kwargs):
134+
"""
135+
This class method should be overridden by the subclasses.
136+
137+
Args:
138+
info (dict): The service account private key info.
139+
args: Additional arguments to pass to the constructor.
140+
kwargs: Additional arguments to pass to the constructor.
141+
142+
Raises:
143+
NotImplementedError: If the method is called on the base class.
144+
"""
145+
raise NotImplementedError("`from_service_account_info` is not implemented.")
146+
147+
@classmethod
148+
def from_service_account_file(cls, filename: str, *args, **kwargs):
149+
"""
150+
This class method should be overridden by the subclasses.
151+
152+
Args:
153+
filename (str): The path to the service account private key json
154+
file.
155+
args: Additional arguments to pass to the constructor.
156+
kwargs: Additional arguments to pass to the constructor.
157+
158+
Raises:
159+
NotImplementedError: If the method is called on the base class.
160+
"""
161+
raise NotImplementedError("`from_service_account_file` is not implemented.")
162+
163+
from_service_account_json = from_service_account_file
164+
165+
@property
166+
def transport(self) -> OperationsTransport:
167+
"""Returns the transport used by the client instance.
168+
169+
Returns:
170+
OperationsTransport: The transport used by the client
171+
instance.
172+
"""
173+
return self._transport
174+
175+
@staticmethod
176+
def common_billing_account_path(
177+
billing_account: str,
178+
) -> str:
179+
"""Returns a fully-qualified billing_account string."""
180+
return "billingAccounts/{billing_account}".format(
181+
billing_account=billing_account,
182+
)
183+
184+
@staticmethod
185+
def parse_common_billing_account_path(path: str) -> Dict[str, str]:
186+
"""Parse a billing_account path into its component segments."""
187+
m = re.match(r"^billingAccounts/(?P<billing_account>.+?)$", path)
188+
return m.groupdict() if m else {}
189+
190+
@staticmethod
191+
def common_folder_path(
192+
folder: str,
193+
) -> str:
194+
"""Returns a fully-qualified folder string."""
195+
return "folders/{folder}".format(
196+
folder=folder,
197+
)
198+
199+
@staticmethod
200+
def parse_common_folder_path(path: str) -> Dict[str, str]:
201+
"""Parse a folder path into its component segments."""
202+
m = re.match(r"^folders/(?P<folder>.+?)$", path)
203+
return m.groupdict() if m else {}
204+
205+
@staticmethod
206+
def common_organization_path(
207+
organization: str,
208+
) -> str:
209+
"""Returns a fully-qualified organization string."""
210+
return "organizations/{organization}".format(
211+
organization=organization,
212+
)
213+
214+
@staticmethod
215+
def parse_common_organization_path(path: str) -> Dict[str, str]:
216+
"""Parse a organization path into its component segments."""
217+
m = re.match(r"^organizations/(?P<organization>.+?)$", path)
218+
return m.groupdict() if m else {}
219+
220+
@staticmethod
221+
def common_project_path(
222+
project: str,
223+
) -> str:
224+
"""Returns a fully-qualified project string."""
225+
return "projects/{project}".format(
226+
project=project,
227+
)
228+
229+
@staticmethod
230+
def parse_common_project_path(path: str) -> Dict[str, str]:
231+
"""Parse a project path into its component segments."""
232+
m = re.match(r"^projects/(?P<project>.+?)$", path)
233+
return m.groupdict() if m else {}
234+
235+
@staticmethod
236+
def common_location_path(
237+
project: str,
238+
location: str,
239+
) -> str:
240+
"""Returns a fully-qualified location string."""
241+
return "projects/{project}/locations/{location}".format(
242+
project=project,
243+
location=location,
244+
)
245+
246+
@staticmethod
247+
def parse_common_location_path(path: str) -> Dict[str, str]:
248+
"""Parse a location path into its component segments."""
249+
m = re.match(r"^projects/(?P<project>.+?)/locations/(?P<location>.+?)$", path)
250+
return m.groupdict() if m else {}
251+
252+
def __init__(
253+
self,
254+
*,
255+
credentials: Optional[ga_credentials.Credentials] = None,
256+
transport: Union[str, OperationsTransport, None] = None,
257+
client_options: Optional[client_options_lib.ClientOptions] = None,
258+
client_info: gapic_v1.client_info.ClientInfo = DEFAULT_CLIENT_INFO,
259+
) -> None:
260+
"""Instantiates the operations client.
261+
262+
Args:
263+
credentials (Optional[google.auth.credentials.Credentials]): The
264+
authorization credentials to attach to requests. These
265+
credentials identify the application to the service; if none
266+
are specified, the client will attempt to ascertain the
267+
credentials from the environment.
268+
transport (Union[str, OperationsTransport]): The
269+
transport to use. If set to None, a transport is chosen
270+
automatically.
271+
client_options (google.api_core.client_options.ClientOptions): Custom options for the
272+
client. It won't take effect if a ``transport`` instance is provided.
273+
(1) The ``api_endpoint`` property can be used to override the
274+
default endpoint provided by the client. GOOGLE_API_USE_MTLS_ENDPOINT
275+
environment variable can also be used to override the endpoint:
276+
"always" (always use the default mTLS endpoint), "never" (always
277+
use the default regular endpoint) and "auto" (auto switch to the
278+
default mTLS endpoint if client certificate is present, this is
279+
the default value). However, the ``api_endpoint`` property takes
280+
precedence if provided.
281+
(2) If GOOGLE_API_USE_CLIENT_CERTIFICATE environment variable
282+
is "true", then the ``client_cert_source`` property can be used
283+
to provide client certificate for mutual TLS transport. If
284+
not provided, the default SSL client certificate will be used if
285+
present. If GOOGLE_API_USE_CLIENT_CERTIFICATE is "false" or not
286+
set, no client certificate will be used.
287+
client_info (google.api_core.gapic_v1.client_info.ClientInfo):
288+
The client info used to send a user-agent string along with
289+
API requests. If ``None``, then default info will be used.
290+
Generally, you only need to set this if you're developing
291+
your own client library.
292+
293+
Raises:
294+
google.auth.exceptions.MutualTLSChannelError: If mutual TLS transport
295+
creation failed for any reason.
296+
"""
297+
if isinstance(client_options, dict):
298+
client_options = client_options_lib.from_dict(client_options)
299+
if client_options is None:
300+
client_options = client_options_lib.ClientOptions()
301+
302+
# Create SSL credentials for mutual TLS if needed.
303+
use_client_cert = os.getenv(
304+
"GOOGLE_API_USE_CLIENT_CERTIFICATE", "false"
305+
).lower()
306+
if use_client_cert not in ("true", "false"):
307+
raise ValueError(
308+
"Environment variable `GOOGLE_API_USE_CLIENT_CERTIFICATE` must be either `true` or `false`"
309+
)
310+
client_cert_source_func = None
311+
is_mtls = False
312+
if use_client_cert == "true":
313+
if client_options.client_cert_source:
314+
is_mtls = True
315+
client_cert_source_func = client_options.client_cert_source
316+
else:
317+
is_mtls = mtls.has_default_client_cert_source()
318+
if is_mtls:
319+
client_cert_source_func = mtls.default_client_cert_source()
320+
else:
321+
client_cert_source_func = None
322+
323+
# Figure out which api endpoint to use.
324+
if client_options.api_endpoint is not None:
325+
api_endpoint = client_options.api_endpoint
326+
else:
327+
use_mtls_env = os.getenv("GOOGLE_API_USE_MTLS_ENDPOINT", "auto")
328+
if use_mtls_env == "never":
329+
api_endpoint = self.DEFAULT_ENDPOINT
330+
elif use_mtls_env == "always":
331+
api_endpoint = self.DEFAULT_MTLS_ENDPOINT
332+
elif use_mtls_env == "auto":
333+
if is_mtls:
334+
api_endpoint = self.DEFAULT_MTLS_ENDPOINT
335+
else:
336+
api_endpoint = self.DEFAULT_ENDPOINT
337+
else:
338+
raise MutualTLSChannelError(
339+
"Unsupported GOOGLE_API_USE_MTLS_ENDPOINT value. Accepted "
340+
"values: never, auto, always"
341+
)
342+
343+
# Save or instantiate the transport.
344+
# Ordinarily, we provide the transport, but allowing a custom transport
345+
# instance provides an extensibility point for unusual situations.
346+
if isinstance(transport, OperationsTransport):
347+
# transport is a OperationsTransport instance.
348+
if credentials or client_options.credentials_file:
349+
raise ValueError(
350+
"When providing a transport instance, "
351+
"provide its credentials directly."
352+
)
353+
if client_options.scopes:
354+
raise ValueError(
355+
"When providing a transport instance, provide its scopes "
356+
"directly."
357+
)
358+
self._transport = transport
359+
else:
360+
Transport = type(self).get_transport_class(transport)
361+
self._transport = Transport(
362+
credentials=credentials,
363+
credentials_file=client_options.credentials_file,
364+
host=api_endpoint,
365+
scopes=client_options.scopes,
366+
client_cert_source_for_mtls=client_cert_source_func,
367+
quota_project_id=client_options.quota_project_id,
368+
client_info=client_info,
369+
always_use_jwt_access=True,
370+
)

0 commit comments

Comments
 (0)