Skip to content

Commit 88efc9d

Browse files
authored
chore(generator): move setup_request_id into compat layer (#17739)
Moves `setup_request_id` defined in the client template into `_compat.py.j2` and the related test cases from `test_%service.py.j2` to `test_compat.py.j2` Associated / Dependent API Core PR: #17738 Towards: #17883
1 parent 788208b commit 88efc9d

12 files changed

Lines changed: 293 additions & 232 deletions

File tree

packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/_compat.py.j2

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
{% block content %}
44

55
"""A compatibility module for older versions of google-api-core."""
6+
{% set has_auto_populated_fields = api.all_method_settings.values()|map(attribute="auto_populated_fields", default=[])|select|list %}
67
{# TODO(https://github.com/googleapis/google-cloud-python/issues/17813):
78
Clean up this file/functions when the minimum supported version of
89
google-api-core has the functions in `_compat.py.j2`. #}
@@ -12,4 +13,55 @@ falling back to the local implementation if not present. #}
1213
{# TODO(https://github.com/googleapis/google-cloud-python/issues/17883):
1314
Backfill compatibility functions being removed from the client layer. #}
1415

16+
{% if has_auto_populated_fields %}
17+
from typing import Union
18+
import uuid
19+
20+
import google.protobuf.message
21+
22+
23+
def setup_request_id(
24+
request: Union[google.protobuf.message.Message, dict, None],
25+
field_name: str,
26+
is_proto3_optional: bool,
27+
) -> None:
28+
"""Populate a UUID4 field in the request if it is not already set.
29+
30+
This helper is used to ensure request idempotency by automatically
31+
generating a unique identifier (such as `request_id`) for requests
32+
that support it. If a request is retried, the same identifier can be
33+
sent on subsequent retries, allowing the server to recognize the retried
34+
request and prevent duplicate processing (e.g., creating duplicate
35+
resources).
36+
37+
Args:
38+
request (Union[google.protobuf.message.Message, dict]): The
39+
request object.
40+
field_name (str): The name of the field to populate.
41+
is_proto3_optional (bool): Whether the field is proto3 optional.
42+
"""
43+
if request is None:
44+
return
45+
46+
if isinstance(request, dict):
47+
if is_proto3_optional:
48+
if field_name not in request or request[field_name] is None:
49+
request[field_name] = str(uuid.uuid4())
50+
elif not request.get(field_name):
51+
request[field_name] = str(uuid.uuid4())
52+
return
53+
54+
if is_proto3_optional:
55+
try:
56+
# Pure protobuf messages
57+
if not request.HasField(field_name):
58+
setattr(request, field_name, str(uuid.uuid4()))
59+
except (AttributeError, ValueError):
60+
# Proto-plus messages or other objects
61+
if not getattr(request, field_name, None):
62+
setattr(request, field_name, str(uuid.uuid4()))
63+
else:
64+
if not getattr(request, field_name, None):
65+
setattr(request, field_name, str(uuid.uuid4()))
66+
{% endif %}
1567
{% endblock %}

packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/_shared_macros.j2

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,7 @@
2828
{% if method_settings is not none %}
2929
{% for auto_populated_field in method_settings.auto_populated_fields %}
3030
{% set is_proto3_optional = method.input.fields[auto_populated_field].proto3_optional %}
31-
{% if is_async %}
32-
self._client._setup_request_id(request, '{{ auto_populated_field }}', {{ is_proto3_optional }})
33-
{% else %}
34-
self._setup_request_id(request, '{{ auto_populated_field }}', {{ is_proto3_optional }})
35-
{% endif %}
31+
setup_request_id(request, '{{ auto_populated_field }}', {{ is_proto3_optional }})
3632
{% endfor %}
3733
{% endif %}{# if method_settings is not none #}
3834
{% endwith %}{# method_settings #}

packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/async_client.py.j2

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ from {{package_path}} import gapic_version as package_version
2121
from google.api_core.client_options import ClientOptions
2222
from google.api_core import exceptions as core_exceptions
2323
from google.api_core import gapic_v1
24+
{% if has_auto_populated_fields %}
25+
from {{package_path}}._compat import setup_request_id
26+
{% endif %}
2427
from google.api_core import retry_async as retries
2528
from google.auth import credentials as ga_credentials # type: ignore
2629
from google.oauth2 import service_account # type: ignore

packages/gapic-generator/gapic/templates/%namespace/%name_%version/%sub/services/%service/client.py.j2

Lines changed: 3 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ from google.api_core import exceptions as core_exceptions
3030
from google.api_core import extended_operation
3131
{% endif %}
3232
from google.api_core import gapic_v1
33+
{% if has_auto_populated_fields %}
34+
from {{package_path}}._compat import setup_request_id
35+
{% endif %}
3336
from google.api_core import retry as retries
3437
from google.auth import credentials as ga_credentials # type: ignore
3538
from google.auth.transport import mtls # type: ignore
@@ -453,39 +456,6 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta):
453456
# NOTE (b/349488459): universe validation is disabled until further notice.
454457
return True
455458

456-
{% if has_auto_populated_fields %}
457-
458-
@staticmethod
459-
def _setup_request_id(request, field_name: str, is_proto3_optional: bool):
460-
"""Populate a UUID4 field in the request if it is not already set.
461-
462-
Args:
463-
request (Union[google.protobuf.message.Message, dict]): The request object.
464-
field_name (str): The name of the field to populate.
465-
is_proto3_optional (bool): Whether the field is proto3 optional.
466-
"""
467-
if isinstance(request, dict):
468-
if is_proto3_optional:
469-
if field_name not in request:
470-
request[field_name] = str(uuid.uuid4())
471-
elif not request.get(field_name):
472-
request[field_name] = str(uuid.uuid4())
473-
return
474-
475-
if is_proto3_optional:
476-
try:
477-
# Pure protobuf messages
478-
if not request.HasField(field_name):
479-
setattr(request, field_name, str(uuid.uuid4()))
480-
except (AttributeError, ValueError):
481-
# Proto-plus messages or other objects
482-
if field_name not in request:
483-
setattr(request, field_name, str(uuid.uuid4()))
484-
else:
485-
if not getattr(request, field_name):
486-
setattr(request, field_name, str(uuid.uuid4()))
487-
{% endif %}
488-
489459
def _add_cred_info_for_auth_errors(
490460
self,
491461
error: core_exceptions.GoogleAPICallError

packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_%service.py.j2

Lines changed: 0 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -434,84 +434,6 @@ def test__add_cred_info_for_auth_errors_no_get_cred_info(error_code):
434434
client._add_cred_info_for_auth_errors(error)
435435
assert error.details == []
436436

437-
{% if has_auto_populated_fields %}
438-
def test__setup_request_id():
439-
class MockRequest:
440-
def __init__(self, **kwargs):
441-
for k, v in kwargs.items():
442-
setattr(self, k, v)
443-
def __contains__(self, key):
444-
return hasattr(self, key)
445-
446-
class MockProtoRequest:
447-
def __init__(self, **kwargs):
448-
for k, v in kwargs.items():
449-
setattr(self, k, v)
450-
def HasField(self, key):
451-
return hasattr(self, key)
452-
453-
# Test with proto3 optional field not in request
454-
request = MockRequest()
455-
{{ service.client_name }}._setup_request_id(request, "request_id", True)
456-
assert re.match(r"{{ test_macros.get_uuid4_re() }}", request.request_id)
457-
458-
# Test with proto3 optional field already in request
459-
request = MockRequest(request_id="already_set")
460-
{{ service.client_name }}._setup_request_id(request, "request_id", True)
461-
assert request.request_id == "already_set"
462-
463-
# Test with non-proto3 optional field empty
464-
request = MockRequest(request_id="")
465-
{{ service.client_name }}._setup_request_id(request, "request_id", False)
466-
assert re.match(r"{{ test_macros.get_uuid4_re() }}", request.request_id)
467-
468-
# Test with non-proto3 optional field already set
469-
request = MockRequest(request_id="already_set")
470-
{{ service.client_name }}._setup_request_id(request, "request_id", False)
471-
assert request.request_id == "already_set"
472-
473-
# Test with proto3 optional field not in request (MockProtoRequest)
474-
request = MockProtoRequest()
475-
{{ service.client_name }}._setup_request_id(request, "request_id", True)
476-
assert re.match(r"{{ test_macros.get_uuid4_re() }}", request.request_id)
477-
478-
# Test with proto3 optional field already in request (MockProtoRequest)
479-
request = MockProtoRequest(request_id="already_set")
480-
{{ service.client_name }}._setup_request_id(request, "request_id", True)
481-
assert request.request_id == "already_set"
482-
483-
# Test with ValueError
484-
class MockValueErrorRequest:
485-
def HasField(self, key):
486-
raise ValueError("Mismatched field")
487-
def __contains__(self, key):
488-
return hasattr(self, key)
489-
490-
request = MockValueErrorRequest()
491-
{{ service.client_name }}._setup_request_id(request, "request_id", True)
492-
assert re.match(r"{{ test_macros.get_uuid4_re() }}", request.request_id)
493-
494-
# Test with dict and proto3 optional field not in request
495-
request = {}
496-
{{ service.client_name }}._setup_request_id(request, "request_id", True)
497-
assert re.match(r"{{ test_macros.get_uuid4_re() }}", request["request_id"])
498-
499-
# Test with dict and proto3 optional field already in request
500-
request = {"request_id": "already_set"}
501-
{{ service.client_name }}._setup_request_id(request, "request_id", True)
502-
assert request["request_id"] == "already_set"
503-
504-
# Test with dict and non-proto3 optional field empty
505-
request = {"request_id": ""}
506-
{{ service.client_name }}._setup_request_id(request, "request_id", False)
507-
assert re.match(r"{{ test_macros.get_uuid4_re() }}", request["request_id"])
508-
509-
# Test with dict and non-proto3 optional field already set
510-
request = {"request_id": "already_set"}
511-
{{ service.client_name }}._setup_request_id(request, "request_id", False)
512-
assert request["request_id"] == "already_set"
513-
514-
{% endif %}
515437
@pytest.mark.parametrize("client_class,transport_name", [
516438
{% if 'grpc' in opts.transport %}
517439
({{ service.client_name }}, "grpc"),

packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_compat.py.j2

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,101 @@
22

33
{% block content %}
44

5+
{% set has_auto_populated_fields = api.all_method_settings.values()|map(attribute="auto_populated_fields", default=[])|select|list %}
56
"""Tests for the compatibility module for older versions of google-api-core."""
67
{# TODO(https://github.com/googleapis/google-cloud-python/issues/17813):
78
Clean up this file/tests when the minimum supported version of
89
google-api-core has the functions in `_compat.py.j2`. #}
910
{# TODO(https://github.com/googleapis/google-cloud-python/issues/17883):
1011
Backfill compatibility functions tests being removed from the client layer. #}
1112

13+
14+
{% if has_auto_populated_fields %}
15+
import re
16+
import pytest
17+
18+
{% set package_path = api.naming.module_namespace|join('.') + "." + api.naming.versioned_module_name %}
19+
from {{package_path}}._compat import setup_request_id
20+
21+
class MockRequest:
22+
def __init__(self, **kwargs):
23+
for k, v in kwargs.items():
24+
setattr(self, k, v)
25+
26+
def __contains__(self, key):
27+
return hasattr(self, key)
28+
29+
30+
class MockProtoRequest:
31+
def __init__(self, **kwargs):
32+
for k, v in kwargs.items():
33+
setattr(self, k, v)
34+
35+
def HasField(self, key):
36+
return hasattr(self, key)
37+
38+
39+
class MockValueErrorRequest:
40+
def HasField(self, key):
41+
raise ValueError("Mismatched field")
42+
43+
def __contains__(self, key):
44+
return hasattr(self, key)
45+
46+
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}"
47+
48+
@pytest.mark.parametrize(
49+
"request_obj, is_proto3_optional, expected",
50+
[
51+
(MockRequest(), True, "uuid"),
52+
(MockRequest(request_id="already_set"), True, "already_set"),
53+
(MockRequest(request_id=""), False, "uuid"),
54+
(MockRequest(request_id="already_set"), False, "already_set"),
55+
(MockProtoRequest(), True, "uuid"),
56+
(MockProtoRequest(request_id="already_set"), True, "already_set"),
57+
(MockValueErrorRequest(), True, "uuid"),
58+
({}, True, "uuid"),
59+
({"request_id": None}, True, "uuid"),
60+
({"request_id": "already_set"}, True, "already_set"),
61+
({"request_id": ""}, False, "uuid"),
62+
({"request_id": None}, False, "uuid"),
63+
({"request_id": "already_set"}, False, "already_set"),
64+
(None, True, "none"),
65+
],
66+
ids=[
67+
"proto3_optional_not_in_request",
68+
"proto3_optional_already_in_request",
69+
"non_proto3_optional_empty",
70+
"non_proto3_optional_already_set",
71+
"proto3_optional_not_in_request_proto",
72+
"proto3_optional_already_in_request_proto",
73+
"value_error_fallback",
74+
"dict_proto3_optional_not_in_request",
75+
"dict_proto3_optional_value_none",
76+
"dict_proto3_optional_already_in_request",
77+
"dict_non_proto3_optional_empty",
78+
"dict_non_proto3_optional_value_none",
79+
"dict_non_proto3_optional_already_set",
80+
"none_request",
81+
],
82+
)
83+
def test_setup_request_id(request_obj, is_proto3_optional, expected):
84+
setup_request_id(request_obj, "request_id", is_proto3_optional)
85+
86+
if expected == "none":
87+
assert request_obj is None
88+
return
89+
90+
value = (
91+
request_obj["request_id"]
92+
if isinstance(request_obj, dict)
93+
else request_obj.request_id
94+
)
95+
96+
if expected == "uuid":
97+
assert re.match(UUID_REGEX, value)
98+
else:
99+
assert value == expected
100+
101+
{% endif %}
12102
{% endblock %}

packages/gapic-generator/gapic/templates/tests/unit/gapic/%name_%version/%sub/test_macros.j2

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2215,10 +2215,6 @@ def test_initialize_client_w_{{transport_name}}():
22152215
{% endfor %}{# method in service.methods.values() #}
22162216
{% endmacro %}{# empty_call_test #}
22172217

2218-
{% macro get_uuid4_re() -%}
2219-
{{ uuid4_re }}
2220-
{%- endmacro %}{# uuid_re #}
2221-
22222218
{% macro routing_parameter_test(service, api, transport, is_async) %}
22232219
{% for method in service.methods.values() %}{# method #}
22242220
{# See existing proposal b/330610501 to add support for explicit routing in BIDI/client side streaming #}

packages/gapic-generator/tests/integration/goldens/storagebatchoperations/google/cloud/storagebatchoperations_v1/_compat.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,53 @@
1414
# limitations under the License.
1515
#
1616
"""A compatibility module for older versions of google-api-core."""
17+
18+
from typing import Union
19+
import uuid
20+
21+
import google.protobuf.message
22+
23+
24+
def setup_request_id(
25+
request: Union[google.protobuf.message.Message, dict, None],
26+
field_name: str,
27+
is_proto3_optional: bool,
28+
) -> None:
29+
"""Populate a UUID4 field in the request if it is not already set.
30+
31+
This helper is used to ensure request idempotency by automatically
32+
generating a unique identifier (such as `request_id`) for requests
33+
that support it. If a request is retried, the same identifier can be
34+
sent on subsequent retries, allowing the server to recognize the retried
35+
request and prevent duplicate processing (e.g., creating duplicate
36+
resources).
37+
38+
Args:
39+
request (Union[google.protobuf.message.Message, dict]): The
40+
request object.
41+
field_name (str): The name of the field to populate.
42+
is_proto3_optional (bool): Whether the field is proto3 optional.
43+
"""
44+
if request is None:
45+
return
46+
47+
if isinstance(request, dict):
48+
if is_proto3_optional:
49+
if field_name not in request or request[field_name] is None:
50+
request[field_name] = str(uuid.uuid4())
51+
elif not request.get(field_name):
52+
request[field_name] = str(uuid.uuid4())
53+
return
54+
55+
if is_proto3_optional:
56+
try:
57+
# Pure protobuf messages
58+
if not request.HasField(field_name):
59+
setattr(request, field_name, str(uuid.uuid4()))
60+
except (AttributeError, ValueError):
61+
# Proto-plus messages or other objects
62+
if not getattr(request, field_name, None):
63+
setattr(request, field_name, str(uuid.uuid4()))
64+
else:
65+
if not getattr(request, field_name, None):
66+
setattr(request, field_name, str(uuid.uuid4()))

0 commit comments

Comments
 (0)