From 79f3668453554e7db059578a4276097080a78259 Mon Sep 17 00:00:00 2001 From: Heba Alazzeh Date: Thu, 16 Jul 2026 16:17:52 +0000 Subject: [PATCH 01/14] feat(api-core): move request-id auto-population logic to gapic_v1 public helpers Introduces method_helpers module containing setup_request_id helper. Exposes the module as public in gapic_v1. --- .../google/api_core/gapic_v1/__init__.py | 4 +- .../api_core/gapic_v1/method_helpers.py | 59 +++++++++ .../tests/unit/gapic/test_method_helpers.py | 118 ++++++++++++++++++ 3 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 packages/google-api-core/google/api_core/gapic_v1/method_helpers.py create mode 100644 packages/google-api-core/tests/unit/gapic/test_method_helpers.py diff --git a/packages/google-api-core/google/api_core/gapic_v1/__init__.py b/packages/google-api-core/google/api_core/gapic_v1/__init__.py index 48a27ec21d24..b112d6fc5cc0 100644 --- a/packages/google-api-core/google/api_core/gapic_v1/__init__.py +++ b/packages/google-api-core/google/api_core/gapic_v1/__init__.py @@ -25,9 +25,10 @@ # Older Python versions safely ignore this variable. __lazy_modules__: Set[str] = { "google.api_core.gapic_v1.client_info", + "google.api_core.gapic_v1.method_helpers", "google.api_core.gapic_v1.routing_header", } -__all__ = ["client_info", "routing_header"] +__all__ = ["client_info", "method_helpers", "routing_header"] if _has_grpc: __lazy_modules__.update( @@ -41,6 +42,7 @@ from google.api_core.gapic_v1 import ( # noqa: E402 client_info, + method_helpers, routing_header, ) diff --git a/packages/google-api-core/google/api_core/gapic_v1/method_helpers.py b/packages/google-api-core/google/api_core/gapic_v1/method_helpers.py new file mode 100644 index 000000000000..a79d23bf6b19 --- /dev/null +++ b/packages/google-api-core/google/api_core/gapic_v1/method_helpers.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +"""Helpers for method requests.""" + +import uuid +from typing import Union +import google.protobuf.message + + +def setup_request_id( + request: Union[google.protobuf.message.Message, dict, None], + field_name: str, + is_proto3_optional: bool, +) -> None: + """Populate a UUID4 field in the request if it is not already set. + + Args: + request (Union[google.protobuf.message.Message, dict]): The + request object. + field_name (str): The name of the field to populate. + is_proto3_optional (bool): Whether the field is proto3 optional. + """ + if request is None: + return + + if isinstance(request, dict): + if is_proto3_optional: + if field_name not in request: + request[field_name] = str(uuid.uuid4()) + elif not request.get(field_name): + request[field_name] = str(uuid.uuid4()) + return + + if is_proto3_optional: + try: + # Pure protobuf messages + if not request.HasField(field_name): + setattr(request, field_name, str(uuid.uuid4())) + except (AttributeError, ValueError): + # Proto-plus messages or other objects + if not getattr(request, field_name, None): + setattr(request, field_name, str(uuid.uuid4())) + else: + if not getattr(request, field_name, None): + setattr(request, field_name, str(uuid.uuid4())) diff --git a/packages/google-api-core/tests/unit/gapic/test_method_helpers.py b/packages/google-api-core/tests/unit/gapic/test_method_helpers.py new file mode 100644 index 000000000000..61189a72a965 --- /dev/null +++ b/packages/google-api-core/tests/unit/gapic/test_method_helpers.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import re +from google.api_core.gapic_v1.method_helpers import setup_request_id + + +def test_setup_request_id(): + class MockRequest: + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + def __contains__(self, key): + return hasattr(self, key) + + class MockProtoRequest: + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + def HasField(self, key): + return hasattr(self, key) + + # Test with proto3 optional field not in request + request = MockRequest() + setup_request_id(request, "request_id", True) + assert re.match( + r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", + request.request_id, + ) + + # Test with proto3 optional field already in request + request = MockRequest(request_id="already_set") + setup_request_id(request, "request_id", True) + assert request.request_id == "already_set" + + # Test with non-proto3 optional field empty + request = MockRequest(request_id="") + setup_request_id(request, "request_id", False) + assert re.match( + r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", + request.request_id, + ) + + # Test with non-proto3 optional field already set + request = MockRequest(request_id="already_set") + setup_request_id(request, "request_id", False) + assert request.request_id == "already_set" + + # Test with proto3 optional field not in request (MockProtoRequest) + request = MockProtoRequest() + setup_request_id(request, "request_id", True) + assert re.match( + r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", + request.request_id, + ) + + # Test with proto3 optional field already in request (MockProtoRequest) + request = MockProtoRequest(request_id="already_set") + setup_request_id(request, "request_id", True) + assert request.request_id == "already_set" + + # Test with ValueError + class MockValueErrorRequest: + def HasField(self, key): + raise ValueError("Mismatched field") + + def __contains__(self, key): + return hasattr(self, key) + + request = MockValueErrorRequest() + setup_request_id(request, "request_id", True) + assert re.match( + r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", + request.request_id, + ) + + # Test with dict and proto3 optional field not in request + request = {} + setup_request_id(request, "request_id", True) + assert re.match( + r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", + request["request_id"], + ) + + # Test with dict and proto3 optional field already in request + request = {"request_id": "already_set"} + setup_request_id(request, "request_id", True) + assert request["request_id"] == "already_set" + + # Test with dict and non-proto3 optional field empty + request = {"request_id": ""} + setup_request_id(request, "request_id", False) + assert re.match( + r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", + request["request_id"], + ) + + # Test with dict and non-proto3 optional field already set + request = {"request_id": "already_set"} + setup_request_id(request, "request_id", False) + assert request["request_id"] == "already_set" + + # Test with None request (should handle gracefully without raising exception) + setup_request_id(None, "request_id", True) From 62fbcc87261077bb2dd6e04508b0a694c2f62f56 Mon Sep 17 00:00:00 2001 From: Heba Alazzeh Date: Thu, 16 Jul 2026 18:14:03 +0000 Subject: [PATCH 02/14] refactor(api-core): rename gapic_v1.method_helpers to gapic_v1.request and improve docstring --- .../google-api-core/google/api_core/gapic_v1/__init__.py | 6 +++--- .../api_core/gapic_v1/{method_helpers.py => request.py} | 8 +++++++- .../gapic/{test_method_helpers.py => test_request.py} | 3 ++- 3 files changed, 12 insertions(+), 5 deletions(-) rename packages/google-api-core/google/api_core/gapic_v1/{method_helpers.py => request.py} (89%) rename packages/google-api-core/tests/unit/gapic/{test_method_helpers.py => test_request.py} (98%) diff --git a/packages/google-api-core/google/api_core/gapic_v1/__init__.py b/packages/google-api-core/google/api_core/gapic_v1/__init__.py index b112d6fc5cc0..010ff6ed3ef7 100644 --- a/packages/google-api-core/google/api_core/gapic_v1/__init__.py +++ b/packages/google-api-core/google/api_core/gapic_v1/__init__.py @@ -25,10 +25,10 @@ # Older Python versions safely ignore this variable. __lazy_modules__: Set[str] = { "google.api_core.gapic_v1.client_info", - "google.api_core.gapic_v1.method_helpers", + "google.api_core.gapic_v1.request", "google.api_core.gapic_v1.routing_header", } -__all__ = ["client_info", "method_helpers", "routing_header"] +__all__ = ["client_info", "request", "routing_header"] if _has_grpc: __lazy_modules__.update( @@ -42,7 +42,7 @@ from google.api_core.gapic_v1 import ( # noqa: E402 client_info, - method_helpers, + request, routing_header, ) diff --git a/packages/google-api-core/google/api_core/gapic_v1/method_helpers.py b/packages/google-api-core/google/api_core/gapic_v1/request.py similarity index 89% rename from packages/google-api-core/google/api_core/gapic_v1/method_helpers.py rename to packages/google-api-core/google/api_core/gapic_v1/request.py index a79d23bf6b19..75672aeb7853 100644 --- a/packages/google-api-core/google/api_core/gapic_v1/method_helpers.py +++ b/packages/google-api-core/google/api_core/gapic_v1/request.py @@ -14,10 +14,16 @@ # limitations under the License. # -"""Helpers for method requests.""" +"""Helpers for preparing and structuring API requests. + +This module provides utilities to preprocess request parameters and objects +before invoking API methods, such as automatically generating request IDs +if they are not already set. +""" import uuid from typing import Union + import google.protobuf.message diff --git a/packages/google-api-core/tests/unit/gapic/test_method_helpers.py b/packages/google-api-core/tests/unit/gapic/test_request.py similarity index 98% rename from packages/google-api-core/tests/unit/gapic/test_method_helpers.py rename to packages/google-api-core/tests/unit/gapic/test_request.py index 61189a72a965..5cdb66ce05e4 100644 --- a/packages/google-api-core/tests/unit/gapic/test_method_helpers.py +++ b/packages/google-api-core/tests/unit/gapic/test_request.py @@ -14,7 +14,8 @@ # limitations under the License. import re -from google.api_core.gapic_v1.method_helpers import setup_request_id + +from google.api_core.gapic_v1.request import setup_request_id def test_setup_request_id(): From 20adea6b498f00f84def5e9f8378938cb5d7c2cd Mon Sep 17 00:00:00 2001 From: Heba Alazzeh Date: Thu, 16 Jul 2026 21:15:24 +0000 Subject: [PATCH 03/14] chore: address PR review comments for gapic centralization request ID --- .../google/api_core/gapic_v1/request.py | 7 + .../tests/unit/gapic/test_request.py | 187 +++++++++--------- 2 files changed, 97 insertions(+), 97 deletions(-) diff --git a/packages/google-api-core/google/api_core/gapic_v1/request.py b/packages/google-api-core/google/api_core/gapic_v1/request.py index 75672aeb7853..90c135d00f8c 100644 --- a/packages/google-api-core/google/api_core/gapic_v1/request.py +++ b/packages/google-api-core/google/api_core/gapic_v1/request.py @@ -34,6 +34,13 @@ def setup_request_id( ) -> None: """Populate a UUID4 field in the request if it is not already set. + This helper is used to ensure request idempotency by automatically + generating a unique identifier (such as `request_id`) for requests + that support it. If a request is retried, the same identifier can be + sent on subsequent retries, allowing the server to recognize the retried + request and prevent duplicate processing (e.g., creating duplicate + resources). + Args: request (Union[google.protobuf.message.Message, dict]): The request object. diff --git a/packages/google-api-core/tests/unit/gapic/test_request.py b/packages/google-api-core/tests/unit/gapic/test_request.py index 5cdb66ce05e4..8fc4d2f110de 100644 --- a/packages/google-api-core/tests/unit/gapic/test_request.py +++ b/packages/google-api-core/tests/unit/gapic/test_request.py @@ -15,105 +15,98 @@ import re -from google.api_core.gapic_v1.request import setup_request_id - - -def test_setup_request_id(): - class MockRequest: - def __init__(self, **kwargs): - for k, v in kwargs.items(): - setattr(self, k, v) - - def __contains__(self, key): - return hasattr(self, key) - - class MockProtoRequest: - def __init__(self, **kwargs): - for k, v in kwargs.items(): - setattr(self, k, v) - - def HasField(self, key): - return hasattr(self, key) - - # Test with proto3 optional field not in request - request = MockRequest() - setup_request_id(request, "request_id", True) - assert re.match( - r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", - request.request_id, - ) - - # Test with proto3 optional field already in request - request = MockRequest(request_id="already_set") - setup_request_id(request, "request_id", True) - assert request.request_id == "already_set" - - # Test with non-proto3 optional field empty - request = MockRequest(request_id="") - setup_request_id(request, "request_id", False) - assert re.match( - r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", - request.request_id, - ) +import pytest - # Test with non-proto3 optional field already set - request = MockRequest(request_id="already_set") - setup_request_id(request, "request_id", False) - assert request.request_id == "already_set" - - # Test with proto3 optional field not in request (MockProtoRequest) - request = MockProtoRequest() - setup_request_id(request, "request_id", True) - assert re.match( - r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", - request.request_id, - ) - - # Test with proto3 optional field already in request (MockProtoRequest) - request = MockProtoRequest(request_id="already_set") - setup_request_id(request, "request_id", True) - assert request.request_id == "already_set" - - # Test with ValueError - class MockValueErrorRequest: - def HasField(self, key): - raise ValueError("Mismatched field") - - def __contains__(self, key): - return hasattr(self, key) - - request = MockValueErrorRequest() - setup_request_id(request, "request_id", True) - assert re.match( - r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", - request.request_id, - ) +from google.api_core.gapic_v1.request import setup_request_id - # Test with dict and proto3 optional field not in request - request = {} - setup_request_id(request, "request_id", True) - assert re.match( - r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", - request["request_id"], - ) - # Test with dict and proto3 optional field already in request - request = {"request_id": "already_set"} - setup_request_id(request, "request_id", True) - assert request["request_id"] == "already_set" - - # Test with dict and non-proto3 optional field empty - request = {"request_id": ""} - setup_request_id(request, "request_id", False) - assert re.match( - r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", - request["request_id"], +# --- Mock Request Helper Classes --- + + +class MockRequest: + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + def __contains__(self, key): + return hasattr(self, key) + + +class MockProtoRequest: + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + def HasField(self, key): + return hasattr(self, key) + + +class MockValueErrorRequest: + def HasField(self, key): + raise ValueError("Mismatched field") + + def __contains__(self, key): + return hasattr(self, key) + + +# --- Parameterized Test --- + +UUID_REGEX = r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}" + + +@pytest.mark.parametrize( + "request_obj, is_proto3_optional, expected", + [ + # MockRequest cases + (MockRequest(), True, "uuid"), + (MockRequest(request_id="already_set"), True, "already_set"), + (MockRequest(request_id=""), False, "uuid"), + (MockRequest(request_id="already_set"), False, "already_set"), + # MockProtoRequest cases + (MockProtoRequest(), True, "uuid"), + (MockProtoRequest(request_id="already_set"), True, "already_set"), + # ValueError case + (MockValueErrorRequest(), True, "uuid"), + # Dict cases + ({}, True, "uuid"), + ({"request_id": "already_set"}, True, "already_set"), + ({"request_id": ""}, False, "uuid"), + ({"request_id": "already_set"}, False, "already_set"), + # None case + (None, True, "none"), + ], + ids=[ + "proto3_optional_not_in_request", + "proto3_optional_already_in_request", + "non_proto3_optional_empty", + "non_proto3_optional_already_set", + "proto3_optional_not_in_request_proto", + "proto3_optional_already_in_request_proto", + "value_error_fallback", + "dict_proto3_optional_not_in_request", + "dict_proto3_optional_already_in_request", + "dict_non_proto3_optional_empty", + "dict_non_proto3_optional_already_set", + "none_request", + ], +) +def test_setup_request_id(request_obj, is_proto3_optional, expected): + # Act + setup_request_id(request_obj, "request_id", is_proto3_optional) + + # Assert + if expected == "none": + assert request_obj is None + return + + # Extract the resulting value depending on container type + value = ( + request_obj["request_id"] + if isinstance(request_obj, dict) + else request_obj.request_id ) - # Test with dict and non-proto3 optional field already set - request = {"request_id": "already_set"} - setup_request_id(request, "request_id", False) - assert request["request_id"] == "already_set" - - # Test with None request (should handle gracefully without raising exception) - setup_request_id(None, "request_id", True) + if expected == "uuid": + assert re.match(UUID_REGEX, value) + else: + assert value == expected From f03ac7c519da0d0504a0334ec09d32f1e7a8328e Mon Sep 17 00:00:00 2001 From: Heba Alazzeh Date: Thu, 16 Jul 2026 16:17:52 +0000 Subject: [PATCH 04/14] feat(api-core): move request-id auto-population logic to gapic_v1 public helpers Introduces method_helpers module containing setup_request_id helper. Exposes the module as public in gapic_v1. --- .../google/api_core/gapic_v1/__init__.py | 4 +- .../api_core/gapic_v1/method_helpers.py | 59 +++++++++ .../tests/unit/gapic/test_method_helpers.py | 118 ++++++++++++++++++ 3 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 packages/google-api-core/google/api_core/gapic_v1/method_helpers.py create mode 100644 packages/google-api-core/tests/unit/gapic/test_method_helpers.py diff --git a/packages/google-api-core/google/api_core/gapic_v1/__init__.py b/packages/google-api-core/google/api_core/gapic_v1/__init__.py index 48a27ec21d24..b112d6fc5cc0 100644 --- a/packages/google-api-core/google/api_core/gapic_v1/__init__.py +++ b/packages/google-api-core/google/api_core/gapic_v1/__init__.py @@ -25,9 +25,10 @@ # Older Python versions safely ignore this variable. __lazy_modules__: Set[str] = { "google.api_core.gapic_v1.client_info", + "google.api_core.gapic_v1.method_helpers", "google.api_core.gapic_v1.routing_header", } -__all__ = ["client_info", "routing_header"] +__all__ = ["client_info", "method_helpers", "routing_header"] if _has_grpc: __lazy_modules__.update( @@ -41,6 +42,7 @@ from google.api_core.gapic_v1 import ( # noqa: E402 client_info, + method_helpers, routing_header, ) diff --git a/packages/google-api-core/google/api_core/gapic_v1/method_helpers.py b/packages/google-api-core/google/api_core/gapic_v1/method_helpers.py new file mode 100644 index 000000000000..a79d23bf6b19 --- /dev/null +++ b/packages/google-api-core/google/api_core/gapic_v1/method_helpers.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +"""Helpers for method requests.""" + +import uuid +from typing import Union +import google.protobuf.message + + +def setup_request_id( + request: Union[google.protobuf.message.Message, dict, None], + field_name: str, + is_proto3_optional: bool, +) -> None: + """Populate a UUID4 field in the request if it is not already set. + + Args: + request (Union[google.protobuf.message.Message, dict]): The + request object. + field_name (str): The name of the field to populate. + is_proto3_optional (bool): Whether the field is proto3 optional. + """ + if request is None: + return + + if isinstance(request, dict): + if is_proto3_optional: + if field_name not in request: + request[field_name] = str(uuid.uuid4()) + elif not request.get(field_name): + request[field_name] = str(uuid.uuid4()) + return + + if is_proto3_optional: + try: + # Pure protobuf messages + if not request.HasField(field_name): + setattr(request, field_name, str(uuid.uuid4())) + except (AttributeError, ValueError): + # Proto-plus messages or other objects + if not getattr(request, field_name, None): + setattr(request, field_name, str(uuid.uuid4())) + else: + if not getattr(request, field_name, None): + setattr(request, field_name, str(uuid.uuid4())) diff --git a/packages/google-api-core/tests/unit/gapic/test_method_helpers.py b/packages/google-api-core/tests/unit/gapic/test_method_helpers.py new file mode 100644 index 000000000000..61189a72a965 --- /dev/null +++ b/packages/google-api-core/tests/unit/gapic/test_method_helpers.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import re +from google.api_core.gapic_v1.method_helpers import setup_request_id + + +def test_setup_request_id(): + class MockRequest: + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + def __contains__(self, key): + return hasattr(self, key) + + class MockProtoRequest: + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + def HasField(self, key): + return hasattr(self, key) + + # Test with proto3 optional field not in request + request = MockRequest() + setup_request_id(request, "request_id", True) + assert re.match( + r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", + request.request_id, + ) + + # Test with proto3 optional field already in request + request = MockRequest(request_id="already_set") + setup_request_id(request, "request_id", True) + assert request.request_id == "already_set" + + # Test with non-proto3 optional field empty + request = MockRequest(request_id="") + setup_request_id(request, "request_id", False) + assert re.match( + r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", + request.request_id, + ) + + # Test with non-proto3 optional field already set + request = MockRequest(request_id="already_set") + setup_request_id(request, "request_id", False) + assert request.request_id == "already_set" + + # Test with proto3 optional field not in request (MockProtoRequest) + request = MockProtoRequest() + setup_request_id(request, "request_id", True) + assert re.match( + r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", + request.request_id, + ) + + # Test with proto3 optional field already in request (MockProtoRequest) + request = MockProtoRequest(request_id="already_set") + setup_request_id(request, "request_id", True) + assert request.request_id == "already_set" + + # Test with ValueError + class MockValueErrorRequest: + def HasField(self, key): + raise ValueError("Mismatched field") + + def __contains__(self, key): + return hasattr(self, key) + + request = MockValueErrorRequest() + setup_request_id(request, "request_id", True) + assert re.match( + r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", + request.request_id, + ) + + # Test with dict and proto3 optional field not in request + request = {} + setup_request_id(request, "request_id", True) + assert re.match( + r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", + request["request_id"], + ) + + # Test with dict and proto3 optional field already in request + request = {"request_id": "already_set"} + setup_request_id(request, "request_id", True) + assert request["request_id"] == "already_set" + + # Test with dict and non-proto3 optional field empty + request = {"request_id": ""} + setup_request_id(request, "request_id", False) + assert re.match( + r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", + request["request_id"], + ) + + # Test with dict and non-proto3 optional field already set + request = {"request_id": "already_set"} + setup_request_id(request, "request_id", False) + assert request["request_id"] == "already_set" + + # Test with None request (should handle gracefully without raising exception) + setup_request_id(None, "request_id", True) From 2cc27b5b2803acfe090a31810fecbac8d2bb01fa Mon Sep 17 00:00:00 2001 From: Heba Alazzeh Date: Thu, 16 Jul 2026 16:20:12 +0000 Subject: [PATCH 05/14] feat: delegate request-id setup to public method_helpers Updates generator templates and goldens to import public method_helpers from google-api-core gapic_v1 and call setup_request_id helper. Removes duplicate setup_request_id test logic from generated client unit tests. --- .../%sub/services/%service/async_client.py.j2 | 3 - .../%sub/services/%service/client.py.j2 | 37 +++----- .../%name_%version/%sub/test_%service.py.j2 | 90 +++---------------- .../storage_batch_operations/client.py | 22 +---- .../test_storage_batch_operations.py | 76 ---------------- 5 files changed, 25 insertions(+), 203 deletions(-) diff --git a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/async_client.py.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/async_client.py.j2 index 5fe416902b86..84c6cdd2bd3e 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/async_client.py.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/async_client.py.j2 @@ -8,9 +8,6 @@ import logging as std_logging from collections import OrderedDict import re from typing import Dict, Callable, Mapping, MutableMapping, MutableSequence, Optional, {% if service.any_server_streaming %}AsyncIterable, Awaitable, {% endif %}{% if service.any_client_streaming %}AsyncIterator, {% endif %}Sequence, Tuple, Type, Union -{% if api.all_method_settings.values()|map(attribute="auto_populated_fields", default=[])|select|list %} -import uuid -{% endif %} {% if service.any_deprecated %} import warnings {% endif %} diff --git a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/client.py.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/client.py.j2 index e2e3edb24967..530361055a32 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/client.py.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/client.py.j2 @@ -6,6 +6,14 @@ {% import "%namespace/%name_%version/%sub/services/%service/_client_macros.j2" as macros %} {% import "%namespace/%name_%version/%sub/services/%service/_shared_macros.j2" as shared_macros %} +{% set has_auto_populated_fields = namespace(value=false) %} +{% for method in service.methods.values() %} + {% set method_settings = api.all_method_settings.get(method.meta.address.proto) %} + {% if method_settings and method_settings.auto_populated_fields %} + {% set has_auto_populated_fields.value = true %} + {% endif %} +{% endfor %} + from collections import OrderedDict {% if service.any_extended_operations_methods %} import functools @@ -16,9 +24,6 @@ import logging as std_logging import os import re from typing import Dict, Callable, Mapping, MutableMapping, MutableSequence, Optional, {% if service.any_server_streaming %}Iterable, {% endif %}{% if service.any_client_streaming %}Iterator, {% endif %}Sequence, Tuple, Type, Union, cast -{% if api.all_method_settings.values()|map(attribute="auto_populated_fields", default=[])|select|list %} -import uuid -{% endif %} import warnings {% set package_path = api.naming.module_namespace|join('.') + "." + api.naming.versioned_module_name %} @@ -30,6 +35,9 @@ from google.api_core import exceptions as core_exceptions from google.api_core import extended_operation {% endif %} from google.api_core import gapic_v1 +{% if has_auto_populated_fields.value %} +from google.api_core.gapic_v1 import method_helpers +{% endif %} from google.api_core import retry as retries from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport import mtls # type: ignore @@ -453,7 +461,7 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta): # NOTE (b/349488459): universe validation is disabled until further notice. return True - {% if api.all_method_settings.values()|map(attribute="auto_populated_fields", default=[])|select|list %} + {% if has_auto_populated_fields.value %} @staticmethod def _setup_request_id(request, field_name: str, is_proto3_optional: bool): """Populate a UUID4 field in the request if it is not already set. @@ -463,26 +471,7 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta): field_name (str): The name of the field to populate. is_proto3_optional (bool): Whether the field is proto3 optional. """ - if isinstance(request, dict): - if is_proto3_optional: - if field_name not in request: - request[field_name] = str(uuid.uuid4()) - elif not request.get(field_name): - request[field_name] = str(uuid.uuid4()) - return - - if is_proto3_optional: - try: - # Pure protobuf messages - if not request.HasField(field_name): - setattr(request, field_name, str(uuid.uuid4())) - except (AttributeError, ValueError): - # Proto-plus messages or other objects - if field_name not in request: - setattr(request, field_name, str(uuid.uuid4())) - else: - if not getattr(request, field_name): - setattr(request, field_name, str(uuid.uuid4())) + method_helpers.setup_request_id(request, field_name, is_proto3_optional) {% endif %} def _add_cred_info_for_auth_errors( diff --git a/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_%service.py.j2 b/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_%service.py.j2 index c68b390b7c84..aa7c48173f5c 100644 --- a/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_%service.py.j2 +++ b/packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_%service.py.j2 @@ -6,9 +6,17 @@ {% import "tests/unit/gapic/%name_%version/%sub/test_macros.j2" as test_macros %} {% import "%namespace/%name_%version/%sub/services/%service/_shared_macros.j2" as shared_macros %} +{% set has_auto_populated_fields = namespace(value=false) %} +{% for method in service.methods.values() %} + {% set method_settings = api.all_method_settings.get(method.meta.address.proto) %} + {% if method_settings and method_settings.auto_populated_fields %} + {% set has_auto_populated_fields.value = true %} + {% endif %} +{% endfor %} + import os import asyncio -{% if api.all_method_settings.values()|map(attribute="auto_populated_fields", default=[])|select|list %} +{% if has_auto_populated_fields.value %} import re {% endif %} from unittest import mock @@ -107,7 +115,7 @@ CRED_INFO_JSON = { "principal": "service-account@example.com", } CRED_INFO_STRING = json.dumps(CRED_INFO_JSON) -{% if api.all_method_settings.values()|map(attribute="auto_populated_fields", default=[])|select|list %} +{% if has_auto_populated_fields.value %} _UUID4_RE = re.compile(r"{{ uuid4_re }}") {% endif %} @@ -434,84 +442,6 @@ def test__add_cred_info_for_auth_errors_no_get_cred_info(error_code): client._add_cred_info_for_auth_errors(error) assert error.details == [] -{% if api.all_method_settings.values()|map(attribute="auto_populated_fields", default=[])|select|list %} -def test__setup_request_id(): - class MockRequest: - def __init__(self, **kwargs): - for k, v in kwargs.items(): - setattr(self, k, v) - def __contains__(self, key): - return hasattr(self, key) - - class MockProtoRequest: - def __init__(self, **kwargs): - for k, v in kwargs.items(): - setattr(self, k, v) - def HasField(self, key): - return hasattr(self, key) - - # Test with proto3 optional field not in request - request = MockRequest() - {{ service.client_name }}._setup_request_id(request, "request_id", True) - assert re.match(r"{{ test_macros.get_uuid4_re() }}", request.request_id) - - # Test with proto3 optional field already in request - request = MockRequest(request_id="already_set") - {{ service.client_name }}._setup_request_id(request, "request_id", True) - assert request.request_id == "already_set" - - # Test with non-proto3 optional field empty - request = MockRequest(request_id="") - {{ service.client_name }}._setup_request_id(request, "request_id", False) - assert re.match(r"{{ test_macros.get_uuid4_re() }}", request.request_id) - - # Test with non-proto3 optional field already set - request = MockRequest(request_id="already_set") - {{ service.client_name }}._setup_request_id(request, "request_id", False) - assert request.request_id == "already_set" - - # Test with proto3 optional field not in request (MockProtoRequest) - request = MockProtoRequest() - {{ service.client_name }}._setup_request_id(request, "request_id", True) - assert re.match(r"{{ test_macros.get_uuid4_re() }}", request.request_id) - - # Test with proto3 optional field already in request (MockProtoRequest) - request = MockProtoRequest(request_id="already_set") - {{ service.client_name }}._setup_request_id(request, "request_id", True) - assert request.request_id == "already_set" - - # Test with ValueError - class MockValueErrorRequest: - def HasField(self, key): - raise ValueError("Mismatched field") - def __contains__(self, key): - return hasattr(self, key) - - request = MockValueErrorRequest() - {{ service.client_name }}._setup_request_id(request, "request_id", True) - assert re.match(r"{{ test_macros.get_uuid4_re() }}", request.request_id) - - # Test with dict and proto3 optional field not in request - request = {} - {{ service.client_name }}._setup_request_id(request, "request_id", True) - assert re.match(r"{{ test_macros.get_uuid4_re() }}", request["request_id"]) - - # Test with dict and proto3 optional field already in request - request = {"request_id": "already_set"} - {{ service.client_name }}._setup_request_id(request, "request_id", True) - assert request["request_id"] == "already_set" - - # Test with dict and non-proto3 optional field empty - request = {"request_id": ""} - {{ service.client_name }}._setup_request_id(request, "request_id", False) - assert re.match(r"{{ test_macros.get_uuid4_re() }}", request["request_id"]) - - # Test with dict and non-proto3 optional field already set - request = {"request_id": "already_set"} - {{ service.client_name }}._setup_request_id(request, "request_id", False) - assert request["request_id"] == "already_set" - -{% endif %} @pytest.mark.parametrize("client_class,transport_name", [ {% if 'grpc' in opts.transport %} ({{ service.client_name }}, "grpc"), diff --git a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/client.py b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/client.py index 5f79cf8e016a..3afb80ac1d10 100755 --- a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/client.py +++ b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/client.py @@ -28,6 +28,7 @@ from google.api_core import client_options as client_options_lib from google.api_core import exceptions as core_exceptions from google.api_core import gapic_v1 +from google.api_core.gapic_v1 import method_helpers from google.api_core import retry as retries from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport import mtls # type: ignore @@ -480,26 +481,7 @@ def _setup_request_id(request, field_name: str, is_proto3_optional: bool): field_name (str): The name of the field to populate. is_proto3_optional (bool): Whether the field is proto3 optional. """ - if isinstance(request, dict): - if is_proto3_optional: - if field_name not in request: - request[field_name] = str(uuid.uuid4()) - elif not request.get(field_name): - request[field_name] = str(uuid.uuid4()) - return - - if is_proto3_optional: - try: - # Pure protobuf messages - if not request.HasField(field_name): - setattr(request, field_name, str(uuid.uuid4())) - except (AttributeError, ValueError): - # Proto-plus messages or other objects - if field_name not in request: - setattr(request, field_name, str(uuid.uuid4())) - else: - if not getattr(request, field_name): - setattr(request, field_name, str(uuid.uuid4())) + method_helpers.setup_request_id(request, field_name, is_proto3_optional) def _add_cred_info_for_auth_errors( self, diff --git a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/tests/unit/gapic/storagebatchoperations_v1/test_storage_batch_operations.py b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/tests/unit/gapic/storagebatchoperations_v1/test_storage_batch_operations.py index 5c53e97f8d12..3697bd9a4839 100755 --- a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/tests/unit/gapic/storagebatchoperations_v1/test_storage_batch_operations.py +++ b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/tests/unit/gapic/storagebatchoperations_v1/test_storage_batch_operations.py @@ -351,82 +351,6 @@ def test__add_cred_info_for_auth_errors_no_get_cred_info(error_code): client._add_cred_info_for_auth_errors(error) assert error.details == [] -def test__setup_request_id(): - class MockRequest: - def __init__(self, **kwargs): - for k, v in kwargs.items(): - setattr(self, k, v) - def __contains__(self, key): - return hasattr(self, key) - - class MockProtoRequest: - def __init__(self, **kwargs): - for k, v in kwargs.items(): - setattr(self, k, v) - def HasField(self, key): - return hasattr(self, key) - - # Test with proto3 optional field not in request - request = MockRequest() - StorageBatchOperationsClient._setup_request_id(request, "request_id", True) - assert re.match(r"[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12}", request.request_id) - - # Test with proto3 optional field already in request - request = MockRequest(request_id="already_set") - StorageBatchOperationsClient._setup_request_id(request, "request_id", True) - assert request.request_id == "already_set" - - # Test with non-proto3 optional field empty - request = MockRequest(request_id="") - StorageBatchOperationsClient._setup_request_id(request, "request_id", False) - assert re.match(r"[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12}", request.request_id) - - # Test with non-proto3 optional field already set - request = MockRequest(request_id="already_set") - StorageBatchOperationsClient._setup_request_id(request, "request_id", False) - assert request.request_id == "already_set" - - # Test with proto3 optional field not in request (MockProtoRequest) - request = MockProtoRequest() - StorageBatchOperationsClient._setup_request_id(request, "request_id", True) - assert re.match(r"[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12}", request.request_id) - - # Test with proto3 optional field already in request (MockProtoRequest) - request = MockProtoRequest(request_id="already_set") - StorageBatchOperationsClient._setup_request_id(request, "request_id", True) - assert request.request_id == "already_set" - - # Test with ValueError - class MockValueErrorRequest: - def HasField(self, key): - raise ValueError("Mismatched field") - def __contains__(self, key): - return hasattr(self, key) - - request = MockValueErrorRequest() - StorageBatchOperationsClient._setup_request_id(request, "request_id", True) - assert re.match(r"[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12}", request.request_id) - - # Test with dict and proto3 optional field not in request - request = {} - StorageBatchOperationsClient._setup_request_id(request, "request_id", True) - assert re.match(r"[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12}", request["request_id"]) - - # Test with dict and proto3 optional field already in request - request = {"request_id": "already_set"} - StorageBatchOperationsClient._setup_request_id(request, "request_id", True) - assert request["request_id"] == "already_set" - - # Test with dict and non-proto3 optional field empty - request = {"request_id": ""} - StorageBatchOperationsClient._setup_request_id(request, "request_id", False) - assert re.match(r"[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12}", request["request_id"]) - - # Test with dict and non-proto3 optional field already set - request = {"request_id": "already_set"} - StorageBatchOperationsClient._setup_request_id(request, "request_id", False) - assert request["request_id"] == "already_set" - @pytest.mark.parametrize("client_class,transport_name", [ (StorageBatchOperationsClient, "grpc"), (StorageBatchOperationsAsyncClient, "grpc_asyncio"), From 9c2121cb78d74db4b418a6cfb5522a84cf275d8b Mon Sep 17 00:00:00 2001 From: Heba Alazzeh Date: Thu, 16 Jul 2026 18:19:45 +0000 Subject: [PATCH 06/14] refactor: use request module instead of method_helpers in gapic-generator --- .../%name_%version/%sub/services/%service/client.py.j2 | 4 ++-- .../services/storage_batch_operations/client.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/client.py.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/client.py.j2 index 530361055a32..d54885bdcf98 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/client.py.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/client.py.j2 @@ -36,7 +36,7 @@ from google.api_core import extended_operation {% endif %} from google.api_core import gapic_v1 {% if has_auto_populated_fields.value %} -from google.api_core.gapic_v1 import method_helpers +from google.api_core.gapic_v1 import request {% endif %} from google.api_core import retry as retries from google.auth import credentials as ga_credentials # type: ignore @@ -471,7 +471,7 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta): field_name (str): The name of the field to populate. is_proto3_optional (bool): Whether the field is proto3 optional. """ - method_helpers.setup_request_id(request, field_name, is_proto3_optional) + request.setup_request_id(request, field_name, is_proto3_optional) {% endif %} def _add_cred_info_for_auth_errors( diff --git a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/client.py b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/client.py index 3afb80ac1d10..95ad1da16ea1 100755 --- a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/client.py +++ b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/client.py @@ -28,7 +28,7 @@ from google.api_core import client_options as client_options_lib from google.api_core import exceptions as core_exceptions from google.api_core import gapic_v1 -from google.api_core.gapic_v1 import method_helpers +from google.api_core.gapic_v1 import request from google.api_core import retry as retries from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport import mtls # type: ignore @@ -481,7 +481,7 @@ def _setup_request_id(request, field_name: str, is_proto3_optional: bool): field_name (str): The name of the field to populate. is_proto3_optional (bool): Whether the field is proto3 optional. """ - method_helpers.setup_request_id(request, field_name, is_proto3_optional) + request.setup_request_id(request, field_name, is_proto3_optional) def _add_cred_info_for_auth_errors( self, From dbc73075618f9417c9435b2d0f4dcb4208318911 Mon Sep 17 00:00:00 2001 From: Heba Alazzeh Date: Thu, 16 Jul 2026 18:14:03 +0000 Subject: [PATCH 07/14] refactor(api-core): rename gapic_v1.method_helpers to gapic_v1.request and improve docstring --- .../google-api-core/google/api_core/gapic_v1/__init__.py | 6 +++--- .../api_core/gapic_v1/{method_helpers.py => request.py} | 8 +++++++- .../gapic/{test_method_helpers.py => test_request.py} | 3 ++- 3 files changed, 12 insertions(+), 5 deletions(-) rename packages/google-api-core/google/api_core/gapic_v1/{method_helpers.py => request.py} (89%) rename packages/google-api-core/tests/unit/gapic/{test_method_helpers.py => test_request.py} (98%) diff --git a/packages/google-api-core/google/api_core/gapic_v1/__init__.py b/packages/google-api-core/google/api_core/gapic_v1/__init__.py index b112d6fc5cc0..010ff6ed3ef7 100644 --- a/packages/google-api-core/google/api_core/gapic_v1/__init__.py +++ b/packages/google-api-core/google/api_core/gapic_v1/__init__.py @@ -25,10 +25,10 @@ # Older Python versions safely ignore this variable. __lazy_modules__: Set[str] = { "google.api_core.gapic_v1.client_info", - "google.api_core.gapic_v1.method_helpers", + "google.api_core.gapic_v1.request", "google.api_core.gapic_v1.routing_header", } -__all__ = ["client_info", "method_helpers", "routing_header"] +__all__ = ["client_info", "request", "routing_header"] if _has_grpc: __lazy_modules__.update( @@ -42,7 +42,7 @@ from google.api_core.gapic_v1 import ( # noqa: E402 client_info, - method_helpers, + request, routing_header, ) diff --git a/packages/google-api-core/google/api_core/gapic_v1/method_helpers.py b/packages/google-api-core/google/api_core/gapic_v1/request.py similarity index 89% rename from packages/google-api-core/google/api_core/gapic_v1/method_helpers.py rename to packages/google-api-core/google/api_core/gapic_v1/request.py index a79d23bf6b19..75672aeb7853 100644 --- a/packages/google-api-core/google/api_core/gapic_v1/method_helpers.py +++ b/packages/google-api-core/google/api_core/gapic_v1/request.py @@ -14,10 +14,16 @@ # limitations under the License. # -"""Helpers for method requests.""" +"""Helpers for preparing and structuring API requests. + +This module provides utilities to preprocess request parameters and objects +before invoking API methods, such as automatically generating request IDs +if they are not already set. +""" import uuid from typing import Union + import google.protobuf.message diff --git a/packages/google-api-core/tests/unit/gapic/test_method_helpers.py b/packages/google-api-core/tests/unit/gapic/test_request.py similarity index 98% rename from packages/google-api-core/tests/unit/gapic/test_method_helpers.py rename to packages/google-api-core/tests/unit/gapic/test_request.py index 61189a72a965..5cdb66ce05e4 100644 --- a/packages/google-api-core/tests/unit/gapic/test_method_helpers.py +++ b/packages/google-api-core/tests/unit/gapic/test_request.py @@ -14,7 +14,8 @@ # limitations under the License. import re -from google.api_core.gapic_v1.method_helpers import setup_request_id + +from google.api_core.gapic_v1.request import setup_request_id def test_setup_request_id(): From 5f1b7214efc097f86d61970ffa7c286b174ee7c5 Mon Sep 17 00:00:00 2001 From: Heba Alazzeh Date: Thu, 16 Jul 2026 21:15:24 +0000 Subject: [PATCH 08/14] chore: address PR review comments for gapic centralization request ID --- .../google/api_core/gapic_v1/request.py | 7 + .../tests/unit/gapic/test_request.py | 187 +++++++++--------- 2 files changed, 97 insertions(+), 97 deletions(-) diff --git a/packages/google-api-core/google/api_core/gapic_v1/request.py b/packages/google-api-core/google/api_core/gapic_v1/request.py index 75672aeb7853..90c135d00f8c 100644 --- a/packages/google-api-core/google/api_core/gapic_v1/request.py +++ b/packages/google-api-core/google/api_core/gapic_v1/request.py @@ -34,6 +34,13 @@ def setup_request_id( ) -> None: """Populate a UUID4 field in the request if it is not already set. + This helper is used to ensure request idempotency by automatically + generating a unique identifier (such as `request_id`) for requests + that support it. If a request is retried, the same identifier can be + sent on subsequent retries, allowing the server to recognize the retried + request and prevent duplicate processing (e.g., creating duplicate + resources). + Args: request (Union[google.protobuf.message.Message, dict]): The request object. diff --git a/packages/google-api-core/tests/unit/gapic/test_request.py b/packages/google-api-core/tests/unit/gapic/test_request.py index 5cdb66ce05e4..8fc4d2f110de 100644 --- a/packages/google-api-core/tests/unit/gapic/test_request.py +++ b/packages/google-api-core/tests/unit/gapic/test_request.py @@ -15,105 +15,98 @@ import re -from google.api_core.gapic_v1.request import setup_request_id - - -def test_setup_request_id(): - class MockRequest: - def __init__(self, **kwargs): - for k, v in kwargs.items(): - setattr(self, k, v) - - def __contains__(self, key): - return hasattr(self, key) - - class MockProtoRequest: - def __init__(self, **kwargs): - for k, v in kwargs.items(): - setattr(self, k, v) - - def HasField(self, key): - return hasattr(self, key) - - # Test with proto3 optional field not in request - request = MockRequest() - setup_request_id(request, "request_id", True) - assert re.match( - r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", - request.request_id, - ) - - # Test with proto3 optional field already in request - request = MockRequest(request_id="already_set") - setup_request_id(request, "request_id", True) - assert request.request_id == "already_set" - - # Test with non-proto3 optional field empty - request = MockRequest(request_id="") - setup_request_id(request, "request_id", False) - assert re.match( - r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", - request.request_id, - ) +import pytest - # Test with non-proto3 optional field already set - request = MockRequest(request_id="already_set") - setup_request_id(request, "request_id", False) - assert request.request_id == "already_set" - - # Test with proto3 optional field not in request (MockProtoRequest) - request = MockProtoRequest() - setup_request_id(request, "request_id", True) - assert re.match( - r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", - request.request_id, - ) - - # Test with proto3 optional field already in request (MockProtoRequest) - request = MockProtoRequest(request_id="already_set") - setup_request_id(request, "request_id", True) - assert request.request_id == "already_set" - - # Test with ValueError - class MockValueErrorRequest: - def HasField(self, key): - raise ValueError("Mismatched field") - - def __contains__(self, key): - return hasattr(self, key) - - request = MockValueErrorRequest() - setup_request_id(request, "request_id", True) - assert re.match( - r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", - request.request_id, - ) +from google.api_core.gapic_v1.request import setup_request_id - # Test with dict and proto3 optional field not in request - request = {} - setup_request_id(request, "request_id", True) - assert re.match( - r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", - request["request_id"], - ) - # Test with dict and proto3 optional field already in request - request = {"request_id": "already_set"} - setup_request_id(request, "request_id", True) - assert request["request_id"] == "already_set" - - # Test with dict and non-proto3 optional field empty - request = {"request_id": ""} - setup_request_id(request, "request_id", False) - assert re.match( - r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", - request["request_id"], +# --- Mock Request Helper Classes --- + + +class MockRequest: + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + def __contains__(self, key): + return hasattr(self, key) + + +class MockProtoRequest: + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + def HasField(self, key): + return hasattr(self, key) + + +class MockValueErrorRequest: + def HasField(self, key): + raise ValueError("Mismatched field") + + def __contains__(self, key): + return hasattr(self, key) + + +# --- Parameterized Test --- + +UUID_REGEX = r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}" + + +@pytest.mark.parametrize( + "request_obj, is_proto3_optional, expected", + [ + # MockRequest cases + (MockRequest(), True, "uuid"), + (MockRequest(request_id="already_set"), True, "already_set"), + (MockRequest(request_id=""), False, "uuid"), + (MockRequest(request_id="already_set"), False, "already_set"), + # MockProtoRequest cases + (MockProtoRequest(), True, "uuid"), + (MockProtoRequest(request_id="already_set"), True, "already_set"), + # ValueError case + (MockValueErrorRequest(), True, "uuid"), + # Dict cases + ({}, True, "uuid"), + ({"request_id": "already_set"}, True, "already_set"), + ({"request_id": ""}, False, "uuid"), + ({"request_id": "already_set"}, False, "already_set"), + # None case + (None, True, "none"), + ], + ids=[ + "proto3_optional_not_in_request", + "proto3_optional_already_in_request", + "non_proto3_optional_empty", + "non_proto3_optional_already_set", + "proto3_optional_not_in_request_proto", + "proto3_optional_already_in_request_proto", + "value_error_fallback", + "dict_proto3_optional_not_in_request", + "dict_proto3_optional_already_in_request", + "dict_non_proto3_optional_empty", + "dict_non_proto3_optional_already_set", + "none_request", + ], +) +def test_setup_request_id(request_obj, is_proto3_optional, expected): + # Act + setup_request_id(request_obj, "request_id", is_proto3_optional) + + # Assert + if expected == "none": + assert request_obj is None + return + + # Extract the resulting value depending on container type + value = ( + request_obj["request_id"] + if isinstance(request_obj, dict) + else request_obj.request_id ) - # Test with dict and non-proto3 optional field already set - request = {"request_id": "already_set"} - setup_request_id(request, "request_id", False) - assert request["request_id"] == "already_set" - - # Test with None request (should handle gracefully without raising exception) - setup_request_id(None, "request_id", True) + if expected == "uuid": + assert re.match(UUID_REGEX, value) + else: + assert value == expected From 99cee8ea0545215dd8c1d3aa75a5fcdd99aab6dd Mon Sep 17 00:00:00 2001 From: Heba Alazzeh Date: Thu, 16 Jul 2026 21:22:45 +0000 Subject: [PATCH 09/14] chore: remove private _setup_request_id and call setup_request_id helper directly --- .../%sub/services/%service/_shared_macros.j2 | 6 +----- .../%sub/services/%service/async_client.py.j2 | 11 +++++++++++ .../%sub/services/%service/client.py.j2 | 13 +------------ 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/_shared_macros.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/_shared_macros.j2 index 755e4530e7ba..5f1b236f1780 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/_shared_macros.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/_shared_macros.j2 @@ -28,11 +28,7 @@ {% if method_settings is not none %} {% for auto_populated_field in method_settings.auto_populated_fields %} {% set is_proto3_optional = method.input.fields[auto_populated_field].proto3_optional %} - {% if is_async %} - self._client._setup_request_id(request, '{{ auto_populated_field }}', {{ is_proto3_optional }}) - {% else %} - self._setup_request_id(request, '{{ auto_populated_field }}', {{ is_proto3_optional }}) - {% endif %} + setup_request_id(request, '{{ auto_populated_field }}', {{ is_proto3_optional }}) {% endfor %} {% endif %}{# if method_settings is not none #} {% endwith %}{# method_settings #} diff --git a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/async_client.py.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/async_client.py.j2 index 84c6cdd2bd3e..19a39aa53f3c 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/async_client.py.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/async_client.py.j2 @@ -4,6 +4,14 @@ {% import "%namespace/%name_%version/%sub/services/%service/_client_macros.j2" as macros %} {% import "%namespace/%name_%version/%sub/services/%service/_shared_macros.j2" as shared_macros %} +{% set has_auto_populated_fields = namespace(value=false) %} +{% for method in service.methods.values() %} + {% set method_settings = api.all_method_settings.get(method.meta.address.proto) %} + {% if method_settings and method_settings.auto_populated_fields %} + {% set has_auto_populated_fields.value = true %} + {% endif %} +{% endfor %} + import logging as std_logging from collections import OrderedDict import re @@ -18,6 +26,9 @@ from {{package_path}} import gapic_version as package_version from google.api_core.client_options import ClientOptions from google.api_core import exceptions as core_exceptions from google.api_core import gapic_v1 +{% if has_auto_populated_fields.value %} +from google.api_core.gapic_v1.request import setup_request_id +{% endif %} from google.api_core import retry_async as retries from google.auth import credentials as ga_credentials # type: ignore from google.oauth2 import service_account # type: ignore diff --git a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/client.py.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/client.py.j2 index d54885bdcf98..a29896142286 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/client.py.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/client.py.j2 @@ -36,7 +36,7 @@ from google.api_core import extended_operation {% endif %} from google.api_core import gapic_v1 {% if has_auto_populated_fields.value %} -from google.api_core.gapic_v1 import request +from google.api_core.gapic_v1.request import setup_request_id {% endif %} from google.api_core import retry as retries from google.auth import credentials as ga_credentials # type: ignore @@ -461,18 +461,7 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta): # NOTE (b/349488459): universe validation is disabled until further notice. return True - {% if has_auto_populated_fields.value %} - @staticmethod - def _setup_request_id(request, field_name: str, is_proto3_optional: bool): - """Populate a UUID4 field in the request if it is not already set. - Args: - request (Union[google.protobuf.message.Message, dict]): The request object. - field_name (str): The name of the field to populate. - is_proto3_optional (bool): Whether the field is proto3 optional. - """ - request.setup_request_id(request, field_name, is_proto3_optional) - {% endif %} def _add_cred_info_for_auth_errors( self, From 4ff1910b7e648a6683edac8aa85f7398b7bd3e02 Mon Sep 17 00:00:00 2001 From: Heba Alazzeh Date: Thu, 16 Jul 2026 21:35:56 +0000 Subject: [PATCH 10/14] test(api-core): update UUID regex in test_request.py --- packages/google-api-core/tests/unit/gapic/test_request.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/google-api-core/tests/unit/gapic/test_request.py b/packages/google-api-core/tests/unit/gapic/test_request.py index 8fc4d2f110de..14ec9bb5dd24 100644 --- a/packages/google-api-core/tests/unit/gapic/test_request.py +++ b/packages/google-api-core/tests/unit/gapic/test_request.py @@ -51,7 +51,9 @@ def __contains__(self, key): # --- Parameterized Test --- -UUID_REGEX = r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}" +UUID_REGEX = ( + r"[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12}" +) @pytest.mark.parametrize( From 77f97c8044e4a13e397fec541d80e937333ceb8f Mon Sep 17 00:00:00 2001 From: Heba Alazzeh Date: Fri, 17 Jul 2026 14:01:55 +0000 Subject: [PATCH 11/14] refactor(api-core): rename request.py to requests.py for consistency --- .../google-api-core/google/api_core/gapic_v1/__init__.py | 6 +++--- .../google/api_core/gapic_v1/{request.py => requests.py} | 0 .../tests/unit/gapic/{test_request.py => test_requests.py} | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) rename packages/google-api-core/google/api_core/gapic_v1/{request.py => requests.py} (100%) rename packages/google-api-core/tests/unit/gapic/{test_request.py => test_requests.py} (98%) diff --git a/packages/google-api-core/google/api_core/gapic_v1/__init__.py b/packages/google-api-core/google/api_core/gapic_v1/__init__.py index 010ff6ed3ef7..78937c032670 100644 --- a/packages/google-api-core/google/api_core/gapic_v1/__init__.py +++ b/packages/google-api-core/google/api_core/gapic_v1/__init__.py @@ -25,10 +25,10 @@ # Older Python versions safely ignore this variable. __lazy_modules__: Set[str] = { "google.api_core.gapic_v1.client_info", - "google.api_core.gapic_v1.request", + "google.api_core.gapic_v1.requests", "google.api_core.gapic_v1.routing_header", } -__all__ = ["client_info", "request", "routing_header"] +__all__ = ["client_info", "requests", "routing_header"] if _has_grpc: __lazy_modules__.update( @@ -42,7 +42,7 @@ from google.api_core.gapic_v1 import ( # noqa: E402 client_info, - request, + requests, routing_header, ) diff --git a/packages/google-api-core/google/api_core/gapic_v1/request.py b/packages/google-api-core/google/api_core/gapic_v1/requests.py similarity index 100% rename from packages/google-api-core/google/api_core/gapic_v1/request.py rename to packages/google-api-core/google/api_core/gapic_v1/requests.py diff --git a/packages/google-api-core/tests/unit/gapic/test_request.py b/packages/google-api-core/tests/unit/gapic/test_requests.py similarity index 98% rename from packages/google-api-core/tests/unit/gapic/test_request.py rename to packages/google-api-core/tests/unit/gapic/test_requests.py index 8fc4d2f110de..603241ba4e3e 100644 --- a/packages/google-api-core/tests/unit/gapic/test_request.py +++ b/packages/google-api-core/tests/unit/gapic/test_requests.py @@ -17,7 +17,7 @@ import pytest -from google.api_core.gapic_v1.request import setup_request_id +from google.api_core.gapic_v1.requests import setup_request_id # --- Mock Request Helper Classes --- From 37ad06f3818609a6cb85279f78aff75a03715ecf Mon Sep 17 00:00:00 2001 From: Heba Alazzeh Date: Fri, 17 Jul 2026 14:03:04 +0000 Subject: [PATCH 12/14] refactor(generator): update templates and goldens to use requests instead of request module for setup_request_id --- .../%name_%version/%sub/services/%service/async_client.py.j2 | 2 +- .../%name_%version/%sub/services/%service/client.py.j2 | 2 +- .../services/storage_batch_operations/client.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/async_client.py.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/async_client.py.j2 index 19a39aa53f3c..2dc1f7350258 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/async_client.py.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/async_client.py.j2 @@ -27,7 +27,7 @@ from google.api_core.client_options import ClientOptions from google.api_core import exceptions as core_exceptions from google.api_core import gapic_v1 {% if has_auto_populated_fields.value %} -from google.api_core.gapic_v1.request import setup_request_id +from google.api_core.gapic_v1.requests import setup_request_id {% endif %} from google.api_core import retry_async as retries from google.auth import credentials as ga_credentials # type: ignore diff --git a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/client.py.j2 b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/client.py.j2 index a29896142286..fff3a08b0013 100644 --- a/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/client.py.j2 +++ b/packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/client.py.j2 @@ -36,7 +36,7 @@ from google.api_core import extended_operation {% endif %} from google.api_core import gapic_v1 {% if has_auto_populated_fields.value %} -from google.api_core.gapic_v1.request import setup_request_id +from google.api_core.gapic_v1.requests import setup_request_id {% endif %} from google.api_core import retry as retries from google.auth import credentials as ga_credentials # type: ignore diff --git a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/client.py b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/client.py index 95ad1da16ea1..27c7188f4a3e 100755 --- a/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/client.py +++ b/packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/services/storage_batch_operations/client.py @@ -28,7 +28,7 @@ from google.api_core import client_options as client_options_lib from google.api_core import exceptions as core_exceptions from google.api_core import gapic_v1 -from google.api_core.gapic_v1 import request +from google.api_core.gapic_v1.requests import setup_request_id from google.api_core import retry as retries from google.auth import credentials as ga_credentials # type: ignore from google.auth.transport import mtls # type: ignore @@ -481,7 +481,7 @@ def _setup_request_id(request, field_name: str, is_proto3_optional: bool): field_name (str): The name of the field to populate. is_proto3_optional (bool): Whether the field is proto3 optional. """ - request.setup_request_id(request, field_name, is_proto3_optional) + setup_request_id(request, field_name, is_proto3_optional) def _add_cred_info_for_auth_errors( self, From b9e2c6200a8c4c5636e767295af644f120b52297 Mon Sep 17 00:00:00 2001 From: Heba Alazzeh Date: Fri, 17 Jul 2026 14:07:40 +0000 Subject: [PATCH 13/14] fix(api-core): handle dictionary requests with None value in setup_request_id --- packages/google-api-core/google/api_core/gapic_v1/requests.py | 2 +- packages/google-api-core/tests/unit/gapic/test_requests.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/google-api-core/google/api_core/gapic_v1/requests.py b/packages/google-api-core/google/api_core/gapic_v1/requests.py index 90c135d00f8c..8ce97c9cffa7 100644 --- a/packages/google-api-core/google/api_core/gapic_v1/requests.py +++ b/packages/google-api-core/google/api_core/gapic_v1/requests.py @@ -52,7 +52,7 @@ def setup_request_id( if isinstance(request, dict): if is_proto3_optional: - if field_name not in request: + if field_name not in request or request[field_name] is None: request[field_name] = str(uuid.uuid4()) elif not request.get(field_name): request[field_name] = str(uuid.uuid4()) diff --git a/packages/google-api-core/tests/unit/gapic/test_requests.py b/packages/google-api-core/tests/unit/gapic/test_requests.py index 603241ba4e3e..1e921955d043 100644 --- a/packages/google-api-core/tests/unit/gapic/test_requests.py +++ b/packages/google-api-core/tests/unit/gapic/test_requests.py @@ -69,8 +69,10 @@ def __contains__(self, key): (MockValueErrorRequest(), True, "uuid"), # Dict cases ({}, True, "uuid"), + ({"request_id": None}, True, "uuid"), ({"request_id": "already_set"}, True, "already_set"), ({"request_id": ""}, False, "uuid"), + ({"request_id": None}, False, "uuid"), ({"request_id": "already_set"}, False, "already_set"), # None case (None, True, "none"), @@ -84,8 +86,10 @@ def __contains__(self, key): "proto3_optional_already_in_request_proto", "value_error_fallback", "dict_proto3_optional_not_in_request", + "dict_proto3_optional_value_none", "dict_proto3_optional_already_in_request", "dict_non_proto3_optional_empty", + "dict_non_proto3_optional_value_none", "dict_non_proto3_optional_already_set", "none_request", ], From 0482ae6ede02ec94732ee1e7c99fd53c0099c7bb Mon Sep 17 00:00:00 2001 From: Heba Alazzeh Date: Fri, 17 Jul 2026 14:13:46 +0000 Subject: [PATCH 14/14] fix(api-core): remove redundant request.py and test_request.py, sort imports in requests.py --- .../google/api_core/gapic_v1/request.py | 72 ----------- .../google/api_core/gapic_v1/requests.py | 2 +- .../tests/unit/gapic/test_request.py | 114 ------------------ 3 files changed, 1 insertion(+), 187 deletions(-) delete mode 100644 packages/google-api-core/google/api_core/gapic_v1/request.py delete mode 100644 packages/google-api-core/tests/unit/gapic/test_request.py diff --git a/packages/google-api-core/google/api_core/gapic_v1/request.py b/packages/google-api-core/google/api_core/gapic_v1/request.py deleted file mode 100644 index 90c135d00f8c..000000000000 --- a/packages/google-api-core/google/api_core/gapic_v1/request.py +++ /dev/null @@ -1,72 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -"""Helpers for preparing and structuring API requests. - -This module provides utilities to preprocess request parameters and objects -before invoking API methods, such as automatically generating request IDs -if they are not already set. -""" - -import uuid -from typing import Union - -import google.protobuf.message - - -def setup_request_id( - request: Union[google.protobuf.message.Message, dict, None], - field_name: str, - is_proto3_optional: bool, -) -> None: - """Populate a UUID4 field in the request if it is not already set. - - This helper is used to ensure request idempotency by automatically - generating a unique identifier (such as `request_id`) for requests - that support it. If a request is retried, the same identifier can be - sent on subsequent retries, allowing the server to recognize the retried - request and prevent duplicate processing (e.g., creating duplicate - resources). - - Args: - request (Union[google.protobuf.message.Message, dict]): The - request object. - field_name (str): The name of the field to populate. - is_proto3_optional (bool): Whether the field is proto3 optional. - """ - if request is None: - return - - if isinstance(request, dict): - if is_proto3_optional: - if field_name not in request: - request[field_name] = str(uuid.uuid4()) - elif not request.get(field_name): - request[field_name] = str(uuid.uuid4()) - return - - if is_proto3_optional: - try: - # Pure protobuf messages - if not request.HasField(field_name): - setattr(request, field_name, str(uuid.uuid4())) - except (AttributeError, ValueError): - # Proto-plus messages or other objects - if not getattr(request, field_name, None): - setattr(request, field_name, str(uuid.uuid4())) - else: - if not getattr(request, field_name, None): - setattr(request, field_name, str(uuid.uuid4())) diff --git a/packages/google-api-core/google/api_core/gapic_v1/requests.py b/packages/google-api-core/google/api_core/gapic_v1/requests.py index 8ce97c9cffa7..f440ac69126c 100644 --- a/packages/google-api-core/google/api_core/gapic_v1/requests.py +++ b/packages/google-api-core/google/api_core/gapic_v1/requests.py @@ -21,8 +21,8 @@ if they are not already set. """ -import uuid from typing import Union +import uuid import google.protobuf.message diff --git a/packages/google-api-core/tests/unit/gapic/test_request.py b/packages/google-api-core/tests/unit/gapic/test_request.py deleted file mode 100644 index 14ec9bb5dd24..000000000000 --- a/packages/google-api-core/tests/unit/gapic/test_request.py +++ /dev/null @@ -1,114 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import re - -import pytest - -from google.api_core.gapic_v1.request import setup_request_id - - -# --- Mock Request Helper Classes --- - - -class MockRequest: - def __init__(self, **kwargs): - for k, v in kwargs.items(): - setattr(self, k, v) - - def __contains__(self, key): - return hasattr(self, key) - - -class MockProtoRequest: - def __init__(self, **kwargs): - for k, v in kwargs.items(): - setattr(self, k, v) - - def HasField(self, key): - return hasattr(self, key) - - -class MockValueErrorRequest: - def HasField(self, key): - raise ValueError("Mismatched field") - - def __contains__(self, key): - return hasattr(self, key) - - -# --- Parameterized Test --- - -UUID_REGEX = ( - r"[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12}" -) - - -@pytest.mark.parametrize( - "request_obj, is_proto3_optional, expected", - [ - # MockRequest cases - (MockRequest(), True, "uuid"), - (MockRequest(request_id="already_set"), True, "already_set"), - (MockRequest(request_id=""), False, "uuid"), - (MockRequest(request_id="already_set"), False, "already_set"), - # MockProtoRequest cases - (MockProtoRequest(), True, "uuid"), - (MockProtoRequest(request_id="already_set"), True, "already_set"), - # ValueError case - (MockValueErrorRequest(), True, "uuid"), - # Dict cases - ({}, True, "uuid"), - ({"request_id": "already_set"}, True, "already_set"), - ({"request_id": ""}, False, "uuid"), - ({"request_id": "already_set"}, False, "already_set"), - # None case - (None, True, "none"), - ], - ids=[ - "proto3_optional_not_in_request", - "proto3_optional_already_in_request", - "non_proto3_optional_empty", - "non_proto3_optional_already_set", - "proto3_optional_not_in_request_proto", - "proto3_optional_already_in_request_proto", - "value_error_fallback", - "dict_proto3_optional_not_in_request", - "dict_proto3_optional_already_in_request", - "dict_non_proto3_optional_empty", - "dict_non_proto3_optional_already_set", - "none_request", - ], -) -def test_setup_request_id(request_obj, is_proto3_optional, expected): - # Act - setup_request_id(request_obj, "request_id", is_proto3_optional) - - # Assert - if expected == "none": - assert request_obj is None - return - - # Extract the resulting value depending on container type - value = ( - request_obj["request_id"] - if isinstance(request_obj, dict) - else request_obj.request_id - ) - - if expected == "uuid": - assert re.match(UUID_REGEX, value) - else: - assert value == expected