Skip to content

Commit 97da2d9

Browse files
committed
feat: initial support for Extended Operations
Certain APIs with Long-Running Operations deviate from the semantics in https://google.aip.dev/151 and instead define custom operation messages, aka Extended Operations. This change adds a PollingFuture subclass designed to be used with Extended Operations. It is analogous and broadly similar to google.api_core.operation.Operation and subclasses google.api_core.future.polling.PollingFuture. The full description of Extended Operation semantics is beyond the scope of this change.
1 parent 4422cce commit 97da2d9

File tree

3 files changed

+387
-17
lines changed

3 files changed

+387
-17
lines changed

google/api_core/extended_operation.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
# Copyright 2022 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+
"""Futures for extended long-running operations returned from Google Cloud APIs.
16+
17+
These futures can be used to synchronously wait for the result of a
18+
lon-running operations using :meth:`ExtendedOperation.result`:
19+
20+
.. code-black:: python
21+
22+
extended_operation = my_api_client.long_running_method()
23+
result =
24+
25+
"""
26+
27+
import threading
28+
29+
from google.api_core import exceptions
30+
from google.api_core.future import polling
31+
32+
33+
class ExtendedOperation(polling.PollingFuture):
34+
"""An ExtendedOperation future for interacting with a Google API Long-Running Operation.
35+
36+
Args:
37+
extended_operation (proto.Message): The initial operation.
38+
refresh (Callable[[], type(extended_operation)]): A callable that returns
39+
the latest state of the operation.
40+
cancel (Callable[[], None]): A callable that tries to cancel the operation.
41+
retry: Optional(google.api_core.retry.Retry): The retry configuration used
42+
when polling. This can be used to control how often :meth:`done`
43+
is polled. Regardless of the retry's ``deadline``, it will be
44+
overridden by the ``timeout`` argument to :meth:`result`.
45+
46+
Note: Most long-running API methods use google.api_core.operation.Operation
47+
This class is a wrapper for a subset of methods that use alternative
48+
Long-Running Operation (LRO) semantics.
49+
"""
50+
51+
def __init__(
52+
self, extended_operation, refresh, cancel, retry=polling.DEFAULT_RETRY
53+
):
54+
super().__init__(retry=retry)
55+
# Note: there is not a concrete type the extended operation must be.
56+
# It MUST have fields that correspond to the following, POSSIBLY WITH DIFFERENT NAMES:
57+
# * name: str
58+
# * status: Union[str, bool, enum.Enum]
59+
# * error_code: int
60+
# * error_message: str
61+
self._extended_operation = extended_operation
62+
self._refresh = refresh
63+
self._cancel = cancel
64+
# Note: the extended operation does not give a good way to indicate cancellation.
65+
# We make do with manually tracking cancellation and checking for doneness.
66+
self._cancelled = False
67+
self._completion_lock = threading.Lock()
68+
# Invoke in case the operation came back already complete.
69+
self._handle_refreshed_operation()
70+
71+
# Note: the following four properties MUST be overridden in a subclass
72+
# if, and only if, the fields in the corresponding extended operation message
73+
# have different names.
74+
#
75+
# E.g. we have an extended operation class that looks like
76+
#
77+
# class MyOperation(proto.Message):
78+
# moniker = proto.Field(proto.STRING, number=1)
79+
# status_msg = proto.Field(proto.STRING, number=2)
80+
# optional http_error_code = proto.Field(proto.INT32, number=3)
81+
# optional http_error_msg = proto.Field(proto.STRING, number=4)
82+
#
83+
# the ExtendedOperation subclass would provide property overrrides that map
84+
# to these (poorly named) fields.
85+
@property
86+
def name(self):
87+
return self._extended_operation.name
88+
89+
@property
90+
def status(self):
91+
return self._extended_operation.status
92+
93+
@property
94+
def error_code(self):
95+
return self._extended_operation.error_code
96+
97+
@property
98+
def error_message(self):
99+
return self._extended_operation.error_message
100+
101+
def done(self, retry=polling.DEFAULT_RETRY):
102+
self._refresh_and_update(retry)
103+
return self._extended_operation.done
104+
105+
def cancel(self):
106+
if self.done():
107+
return False
108+
109+
self._cancel()
110+
self._cancelled = True
111+
return True
112+
113+
def cancelled(self):
114+
# TODO(dovs): there is not currently a good way to determine whether the
115+
# operation has been cancelled.
116+
# The best we can do is manually keep track of cancellation
117+
# and check for doneness.
118+
if not self._cancelled:
119+
return False
120+
121+
self._refresh_and_update()
122+
return self._extended_operation.done
123+
124+
def _refresh_and_update(self, retry=polling.DEFAULT_RETRY):
125+
if not self._extended_operation.done:
126+
self._extended_operation = self._refresh(retry=retry)
127+
self._handle_refreshed_operation()
128+
129+
def _handle_refreshed_operation(self):
130+
with self._completion_lock:
131+
if not self._extended_operation.done:
132+
return
133+
134+
if self.error_code and self.error_message:
135+
# TODO(dovs): handle this better.
136+
exception = exceptions.from_grpc_status(
137+
status_code=self.error_code,
138+
message=self.error_message,
139+
response=self._extended_operation,
140+
)
141+
self.set_exception(exception)
142+
elif self.error_code or self.error_message:
143+
exception = exceptions.GoogleAPICallError(
144+
f"Unexpected error {self.error_code}: {self.error_message}"
145+
)
146+
self.set_exception(exception)
147+
else:
148+
# Extended operations have no payload.
149+
self.set_result(None)
150+
151+
@classmethod
152+
def make(cls, refresh, cancel, extended_operation, **kwargs):
153+
# Note: it is the caller's responsibility to set up refresh and cancel
154+
# with their correct request argument.
155+
# The reason for this is that the services that use Extended Operations
156+
# have rpcs that look something like the following:
157+
# // service.proto
158+
# service MyLongService {
159+
# rpc StartLongTask(StartLongTaskRequest) returns (ExtendedOperation) {
160+
# option (google.cloud.operation_service) = "CustomOperationService";
161+
# }
162+
# }
163+
#
164+
# service CustomOperationService {
165+
# rpc Get(GetOperationRequest) returns (ExtendedOperation) {
166+
# option (google.cloud.operation_polling_method) = true;
167+
# }
168+
# }
169+
#
170+
# Any info needed for the poll, e.g. a name, path params, etc.
171+
# is held in the request, which the initial client method is in a much
172+
# better position to make made because the caller made the initial request.
173+
#
174+
# TL;DR: the caller sets up closures for refresh and cancel that carry
175+
# the properly configured requests.
176+
return cls(extended_operation, refresh, cancel, **kwargs)

noxfile.py

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ def default(session, install_grpc=True):
9292
)
9393

9494
# Install all test dependencies, then install this package in-place.
95-
session.install("mock", "pytest", "pytest-cov")
95+
session.install("dataclasses", "mock", "pytest", "pytest-cov", "pytest-xdist")
9696
if install_grpc:
9797
session.install("-e", ".[grpc]", "-c", constraints_path)
9898
else:
@@ -102,28 +102,36 @@ def default(session, install_grpc=True):
102102
"python",
103103
"-m",
104104
"py.test",
105-
"--quiet",
106-
"--cov=google.api_core",
107-
"--cov=tests.unit",
108-
"--cov-append",
109-
"--cov-config=.coveragerc",
110-
"--cov-report=",
111-
"--cov-fail-under=0",
112-
os.path.join("tests", "unit"),
105+
*(
106+
# Helpful for running a single test or testfile.
107+
session.posargs
108+
or [
109+
"--quiet",
110+
"--cov=google.api_core",
111+
"--cov=tests.unit",
112+
"--cov-append",
113+
"--cov-config=.coveragerc",
114+
"--cov-report=",
115+
"--cov-fail-under=0",
116+
# Running individual tests with parallelism enabled is usually not helpful.
117+
"-n=auto",
118+
os.path.join("tests", "unit"),
119+
]
120+
),
113121
]
114-
pytest_args.extend(session.posargs)
115122

116123
# Inject AsyncIO content and proto-plus, if version >= 3.6.
117124
# proto-plus is needed for a field mask test in test_protobuf_helpers.py
118125
if _greater_or_equal_than_36(session.python):
119126
session.install("asyncmock", "pytest-asyncio", "proto-plus")
120127

121-
pytest_args.append("--cov=tests.asyncio")
122-
pytest_args.append(os.path.join("tests", "asyncio"))
123-
session.run(*pytest_args)
124-
else:
125-
# Run py.test against the unit tests.
126-
session.run(*pytest_args)
128+
# Having positional arguments means the user wants to run specific tests.
129+
# Best not to add additional tests to that list.
130+
if not session.posargs:
131+
pytest_args.append("--cov=tests.asyncio")
132+
pytest_args.append(os.path.join("tests", "asyncio"))
133+
134+
session.run(*pytest_args)
127135

128136

129137
@nox.session(python=["3.6", "3.7", "3.8", "3.9", "3.10"])
@@ -171,7 +179,11 @@ def mypy(session):
171179
"""Run type-checking."""
172180
session.install(".[grpc, grpcgcp]", "mypy")
173181
session.install(
174-
"types-setuptools", "types-requests", "types-protobuf", "types-mock"
182+
"types-setuptools",
183+
"types-requests",
184+
"types-protobuf",
185+
"types-mock",
186+
"types-dataclasses",
175187
)
176188
session.run("mypy", "google", "tests")
177189

0 commit comments

Comments
 (0)