Skip to content

Commit 583b8ce

Browse files
authored
Merge branch 'main' into add-support-for-protobuf-5-x
2 parents 8b6bc48 + d96eb5c commit 583b8ce

File tree

8 files changed

+186
-71
lines changed

8 files changed

+186
-71
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@
44

55
[1]: https://pypi.org/project/google-api-core/#history
66

7+
## [2.19.0](https://github.com/googleapis/python-api-core/compare/v2.18.0...v2.19.0) (2024-04-29)
8+
9+
10+
### Features
11+
12+
* Add google.api_core.version_header ([#638](https://github.com/googleapis/python-api-core/issues/638)) ([a7b53e9](https://github.com/googleapis/python-api-core/commit/a7b53e9e9a7deb88baf92a2827958429e3677069))
13+
714
## [2.18.0](https://github.com/googleapis/python-api-core/compare/v2.17.1...v2.18.0) (2024-03-20)
815

916

google/api_core/gapic_v1/method.py

Lines changed: 32 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -42,24 +42,6 @@ class _MethodDefault(enum.Enum):
4242
so the default should be used."""
4343

4444

45-
def _is_not_none_or_false(value):
46-
return value is not None and value is not False
47-
48-
49-
def _apply_decorators(func, decorators):
50-
"""Apply a list of decorators to a given function.
51-
52-
``decorators`` may contain items that are ``None`` or ``False`` which will
53-
be ignored.
54-
"""
55-
filtered_decorators = filter(_is_not_none_or_false, reversed(decorators))
56-
57-
for decorator in filtered_decorators:
58-
func = decorator(func)
59-
60-
return func
61-
62-
6345
class _GapicCallable(object):
6446
"""Callable that applies retry, timeout, and metadata logic.
6547
@@ -91,6 +73,8 @@ def __init__(
9173
):
9274
self._target = target
9375
self._retry = retry
76+
if isinstance(timeout, (int, float)):
77+
timeout = TimeToDeadlineTimeout(timeout=timeout)
9478
self._timeout = timeout
9579
self._compression = compression
9680
self._metadata = metadata
@@ -100,35 +84,42 @@ def __call__(
10084
):
10185
"""Invoke the low-level RPC with retry, timeout, compression, and metadata."""
10286

103-
if retry is DEFAULT:
104-
retry = self._retry
105-
106-
if timeout is DEFAULT:
107-
timeout = self._timeout
108-
10987
if compression is DEFAULT:
11088
compression = self._compression
111-
112-
if isinstance(timeout, (int, float)):
113-
timeout = TimeToDeadlineTimeout(timeout=timeout)
114-
115-
# Apply all applicable decorators.
116-
wrapped_func = _apply_decorators(self._target, [retry, timeout])
89+
if compression is not None:
90+
kwargs["compression"] = compression
11791

11892
# Add the user agent metadata to the call.
11993
if self._metadata is not None:
120-
metadata = kwargs.get("metadata", [])
121-
# Due to the nature of invocation, None should be treated the same
122-
# as not specified.
123-
if metadata is None:
124-
metadata = []
125-
metadata = list(metadata)
126-
metadata.extend(self._metadata)
127-
kwargs["metadata"] = metadata
128-
if self._compression is not None:
129-
kwargs["compression"] = compression
94+
try:
95+
# attempt to concatenate default metadata with user-provided metadata
96+
kwargs["metadata"] = (*kwargs["metadata"], *self._metadata)
97+
except (KeyError, TypeError):
98+
# if metadata is not provided, use just the default metadata
99+
kwargs["metadata"] = self._metadata
100+
101+
call = self._build_wrapped_call(timeout, retry)
102+
return call(*args, **kwargs)
103+
104+
@functools.lru_cache(maxsize=4)
105+
def _build_wrapped_call(self, timeout, retry):
106+
"""
107+
Build a wrapped callable that applies retry, timeout, and metadata logic.
108+
"""
109+
wrapped_func = self._target
110+
if timeout is DEFAULT:
111+
timeout = self._timeout
112+
elif isinstance(timeout, (int, float)):
113+
timeout = TimeToDeadlineTimeout(timeout=timeout)
114+
if timeout is not None:
115+
wrapped_func = timeout(wrapped_func)
116+
117+
if retry is DEFAULT:
118+
retry = self._retry
119+
if retry is not None:
120+
wrapped_func = retry(wrapped_func)
130121

131-
return wrapped_func(*args, **kwargs)
122+
return wrapped_func
132123

133124

134125
def wrap_method(

google/api_core/grpc_helpers_async.py

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,6 @@ class _WrappedStreamStreamCall(
159159

160160
def _wrap_unary_errors(callable_):
161161
"""Map errors for Unary-Unary async callables."""
162-
grpc_helpers._patch_callable_name(callable_)
163162

164163
@functools.wraps(callable_)
165164
def error_remapped_callable(*args, **kwargs):
@@ -169,23 +168,13 @@ def error_remapped_callable(*args, **kwargs):
169168
return error_remapped_callable
170169

171170

172-
def _wrap_stream_errors(callable_):
171+
def _wrap_stream_errors(callable_, wrapper_type):
173172
"""Map errors for streaming RPC async callables."""
174-
grpc_helpers._patch_callable_name(callable_)
175173

176174
@functools.wraps(callable_)
177175
async def error_remapped_callable(*args, **kwargs):
178176
call = callable_(*args, **kwargs)
179-
180-
if isinstance(call, aio.UnaryStreamCall):
181-
call = _WrappedUnaryStreamCall().with_call(call)
182-
elif isinstance(call, aio.StreamUnaryCall):
183-
call = _WrappedStreamUnaryCall().with_call(call)
184-
elif isinstance(call, aio.StreamStreamCall):
185-
call = _WrappedStreamStreamCall().with_call(call)
186-
else:
187-
raise TypeError("Unexpected type of call %s" % type(call))
188-
177+
call = wrapper_type().with_call(call)
189178
await call.wait_for_connection()
190179
return call
191180

@@ -207,10 +196,17 @@ def wrap_errors(callable_):
207196
208197
Returns: Callable: The wrapped gRPC callable.
209198
"""
199+
grpc_helpers._patch_callable_name(callable_)
210200
if isinstance(callable_, aio.UnaryUnaryMultiCallable):
211201
return _wrap_unary_errors(callable_)
202+
elif isinstance(callable_, aio.UnaryStreamMultiCallable):
203+
return _wrap_stream_errors(callable_, _WrappedUnaryStreamCall)
204+
elif isinstance(callable_, aio.StreamUnaryMultiCallable):
205+
return _wrap_stream_errors(callable_, _WrappedStreamUnaryCall)
206+
elif isinstance(callable_, aio.StreamStreamMultiCallable):
207+
return _wrap_stream_errors(callable_, _WrappedStreamStreamCall)
212208
else:
213-
return _wrap_stream_errors(callable_)
209+
raise TypeError("Unexpected type of callable: {}".format(type(callable_)))
214210

215211

216212
def create_channel(

google/api_core/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
__version__ = "2.18.0"
15+
__version__ = "2.19.0"

google/api_core/version_header.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Copyright 2024 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+
API_VERSION_METADATA_KEY = "x-goog-api-version"
16+
17+
18+
def to_api_version_header(version_identifier):
19+
"""Returns data for the API Version header for the given `version_identifier`.
20+
21+
Args:
22+
version_identifier (str): The version identifier to be used in the
23+
tuple returned.
24+
25+
Returns:
26+
Tuple(str, str): A tuple containing the API Version metadata key and
27+
value.
28+
"""
29+
return (API_VERSION_METADATA_KEY, version_identifier)

tests/asyncio/test_grpc_helpers_async.py

Lines changed: 63 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,40 @@ async def test_common_methods_in_wrapped_call():
9797
assert mock_call.wait_for_connection.call_count == 1
9898

9999

100+
@pytest.mark.asyncio
101+
@pytest.mark.parametrize(
102+
"callable_type,expected_wrapper_type",
103+
[
104+
(grpc.aio.UnaryStreamMultiCallable, grpc_helpers_async._WrappedUnaryStreamCall),
105+
(grpc.aio.StreamUnaryMultiCallable, grpc_helpers_async._WrappedStreamUnaryCall),
106+
(
107+
grpc.aio.StreamStreamMultiCallable,
108+
grpc_helpers_async._WrappedStreamStreamCall,
109+
),
110+
],
111+
)
112+
async def test_wrap_errors_w_stream_type(callable_type, expected_wrapper_type):
113+
class ConcreteMulticallable(callable_type):
114+
def __call__(self, *args, **kwargs):
115+
raise NotImplementedError("Should not be called")
116+
117+
with mock.patch.object(
118+
grpc_helpers_async, "_wrap_stream_errors"
119+
) as wrap_stream_errors:
120+
callable_ = ConcreteMulticallable()
121+
grpc_helpers_async.wrap_errors(callable_)
122+
assert wrap_stream_errors.call_count == 1
123+
wrap_stream_errors.assert_called_once_with(callable_, expected_wrapper_type)
124+
125+
100126
@pytest.mark.asyncio
101127
async def test_wrap_stream_errors_unary_stream():
102128
mock_call = mock.Mock(aio.UnaryStreamCall, autospec=True)
103129
multicallable = mock.Mock(return_value=mock_call)
104130

105-
wrapped_callable = grpc_helpers_async._wrap_stream_errors(multicallable)
131+
wrapped_callable = grpc_helpers_async._wrap_stream_errors(
132+
multicallable, grpc_helpers_async._WrappedUnaryStreamCall
133+
)
106134

107135
await wrapped_callable(1, 2, three="four")
108136
multicallable.assert_called_once_with(1, 2, three="four")
@@ -114,7 +142,9 @@ async def test_wrap_stream_errors_stream_unary():
114142
mock_call = mock.Mock(aio.StreamUnaryCall, autospec=True)
115143
multicallable = mock.Mock(return_value=mock_call)
116144

117-
wrapped_callable = grpc_helpers_async._wrap_stream_errors(multicallable)
145+
wrapped_callable = grpc_helpers_async._wrap_stream_errors(
146+
multicallable, grpc_helpers_async._WrappedStreamUnaryCall
147+
)
118148

119149
await wrapped_callable(1, 2, three="four")
120150
multicallable.assert_called_once_with(1, 2, three="four")
@@ -126,22 +156,26 @@ async def test_wrap_stream_errors_stream_stream():
126156
mock_call = mock.Mock(aio.StreamStreamCall, autospec=True)
127157
multicallable = mock.Mock(return_value=mock_call)
128158

129-
wrapped_callable = grpc_helpers_async._wrap_stream_errors(multicallable)
159+
wrapped_callable = grpc_helpers_async._wrap_stream_errors(
160+
multicallable, grpc_helpers_async._WrappedStreamStreamCall
161+
)
130162

131163
await wrapped_callable(1, 2, three="four")
132164
multicallable.assert_called_once_with(1, 2, three="four")
133165
assert mock_call.wait_for_connection.call_count == 1
134166

135167

136168
@pytest.mark.asyncio
137-
async def test_wrap_stream_errors_type_error():
169+
async def test_wrap_errors_type_error():
170+
"""
171+
If wrap_errors is called with an unexpected type, it should raise a TypeError.
172+
"""
138173
mock_call = mock.Mock()
139174
multicallable = mock.Mock(return_value=mock_call)
140175

141-
wrapped_callable = grpc_helpers_async._wrap_stream_errors(multicallable)
142-
143-
with pytest.raises(TypeError):
144-
await wrapped_callable()
176+
with pytest.raises(TypeError) as exc:
177+
grpc_helpers_async.wrap_errors(multicallable)
178+
assert "Unexpected type" in str(exc.value)
145179

146180

147181
@pytest.mark.asyncio
@@ -151,7 +185,9 @@ async def test_wrap_stream_errors_raised():
151185
mock_call.wait_for_connection = mock.AsyncMock(side_effect=[grpc_error])
152186
multicallable = mock.Mock(return_value=mock_call)
153187

154-
wrapped_callable = grpc_helpers_async._wrap_stream_errors(multicallable)
188+
wrapped_callable = grpc_helpers_async._wrap_stream_errors(
189+
multicallable, grpc_helpers_async._WrappedStreamStreamCall
190+
)
155191

156192
with pytest.raises(exceptions.InvalidArgument):
157193
await wrapped_callable()
@@ -166,7 +202,9 @@ async def test_wrap_stream_errors_read():
166202
mock_call.read = mock.AsyncMock(side_effect=grpc_error)
167203
multicallable = mock.Mock(return_value=mock_call)
168204

169-
wrapped_callable = grpc_helpers_async._wrap_stream_errors(multicallable)
205+
wrapped_callable = grpc_helpers_async._wrap_stream_errors(
206+
multicallable, grpc_helpers_async._WrappedStreamStreamCall
207+
)
170208

171209
wrapped_call = await wrapped_callable(1, 2, three="four")
172210
multicallable.assert_called_once_with(1, 2, three="four")
@@ -189,7 +227,9 @@ async def test_wrap_stream_errors_aiter():
189227
mock_call.__aiter__ = mock.Mock(return_value=mocked_aiter)
190228
multicallable = mock.Mock(return_value=mock_call)
191229

192-
wrapped_callable = grpc_helpers_async._wrap_stream_errors(multicallable)
230+
wrapped_callable = grpc_helpers_async._wrap_stream_errors(
231+
multicallable, grpc_helpers_async._WrappedStreamStreamCall
232+
)
193233
wrapped_call = await wrapped_callable()
194234

195235
with pytest.raises(exceptions.InvalidArgument) as exc_info:
@@ -210,7 +250,9 @@ async def test_wrap_stream_errors_aiter_non_rpc_error():
210250
mock_call.__aiter__ = mock.Mock(return_value=mocked_aiter)
211251
multicallable = mock.Mock(return_value=mock_call)
212252

213-
wrapped_callable = grpc_helpers_async._wrap_stream_errors(multicallable)
253+
wrapped_callable = grpc_helpers_async._wrap_stream_errors(
254+
multicallable, grpc_helpers_async._WrappedStreamStreamCall
255+
)
214256
wrapped_call = await wrapped_callable()
215257

216258
with pytest.raises(TypeError) as exc_info:
@@ -224,7 +266,9 @@ async def test_wrap_stream_errors_aiter_called_multiple_times():
224266
mock_call = mock.Mock(aio.StreamStreamCall, autospec=True)
225267
multicallable = mock.Mock(return_value=mock_call)
226268

227-
wrapped_callable = grpc_helpers_async._wrap_stream_errors(multicallable)
269+
wrapped_callable = grpc_helpers_async._wrap_stream_errors(
270+
multicallable, grpc_helpers_async._WrappedStreamStreamCall
271+
)
228272
wrapped_call = await wrapped_callable()
229273

230274
assert wrapped_call.__aiter__() == wrapped_call.__aiter__()
@@ -239,7 +283,9 @@ async def test_wrap_stream_errors_write():
239283
mock_call.done_writing = mock.AsyncMock(side_effect=[None, grpc_error])
240284
multicallable = mock.Mock(return_value=mock_call)
241285

242-
wrapped_callable = grpc_helpers_async._wrap_stream_errors(multicallable)
286+
wrapped_callable = grpc_helpers_async._wrap_stream_errors(
287+
multicallable, grpc_helpers_async._WrappedStreamStreamCall
288+
)
243289

244290
wrapped_call = await wrapped_callable()
245291

@@ -295,7 +341,9 @@ def test_wrap_errors_streaming(wrap_stream_errors):
295341
result = grpc_helpers_async.wrap_errors(callable_)
296342

297343
assert result == wrap_stream_errors.return_value
298-
wrap_stream_errors.assert_called_once_with(callable_)
344+
wrap_stream_errors.assert_called_once_with(
345+
callable_, grpc_helpers_async._WrappedUnaryStreamCall
346+
)
299347

300348

301349
@pytest.mark.parametrize(

tests/unit/gapic/test_method.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,3 +222,24 @@ def test_wrap_method_with_call_not_supported():
222222
with pytest.raises(ValueError) as exc_info:
223223
google.api_core.gapic_v1.method.wrap_method(method, with_call=True)
224224
assert "with_call=True is only supported for unary calls" in str(exc_info.value)
225+
226+
227+
def test_benchmark_gapic_call():
228+
"""
229+
Ensure the __call__ method performance does not regress from expectations
230+
231+
__call__ builds a new wrapped function using passed-in timeout and retry, but
232+
subsequent calls are cached
233+
234+
Note: The threshold has been tuned for the CI workers. Test may flake on
235+
slower hardware
236+
"""
237+
from google.api_core.gapic_v1.method import _GapicCallable
238+
from google.api_core.retry import Retry
239+
from timeit import timeit
240+
241+
gapic_callable = _GapicCallable(
242+
lambda *a, **k: 1, retry=Retry(), timeout=1010, compression=False
243+
)
244+
avg_time = timeit(lambda: gapic_callable(), number=10_000)
245+
assert avg_time < 0.4

0 commit comments

Comments
 (0)