Skip to content

Commit 765f599

Browse files
authored
Cross package request messages are constructed specially (#317)
A cross package request is almost certainly not a proto-plus wrapped type, which puts certain restrictions on its construction and manipulation due to constraints in the Python protobuf API. It is bad practice but neither forbidden nor impossible to write a method whose request message definitionlives in a different package. A recurring example is IAM Policy Requests. This change detects when a method's request lives in a different package and constructs it either via keyword expansion (a dict was passed in) or with no ctor params. Also, in the above scenario, fields are not eligible for flattening if they point to non-primitive types. Generated unit tests for services that use out-of-package requests include from-dict construction. Adds the grpc-google-iam-v1 requirement as a special case in generated setup.py
1 parent 3f0fdbd commit 765f599

File tree

8 files changed

+107
-8
lines changed

8 files changed

+107
-8
lines changed

packages/gapic-generator/gapic/schema/api.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,13 @@ def subpackages(self) -> Mapping[str, 'API']:
279279
)
280280
return answer
281281

282+
def requires_package(self, pkg: Tuple[str, ...]) -> bool:
283+
return any(
284+
message.ident.package == pkg
285+
for proto in self.all_protos.values()
286+
for message in proto.all_messages.values()
287+
)
288+
282289

283290
class _ProtoBuilder:
284291
"""A "builder class" for Proto objects.

packages/gapic-generator/gapic/schema/wrappers.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -551,12 +551,26 @@ def field_headers(self) -> Sequence[str]:
551551
def flattened_fields(self) -> Mapping[str, Field]:
552552
"""Return the signature defined for this method."""
553553
signatures = self.options.Extensions[client_pb2.method_signature]
554+
cross_pkg_request = self.input.ident.package != self.ident.package
555+
556+
def filter_fields(sig):
557+
for f in sig.split(','):
558+
if not f:
559+
# Special case for an empty signature
560+
continue
561+
name = f.strip()
562+
field = self.input.get_field(*name.split('.'))
563+
if cross_pkg_request and not field.is_primitive:
564+
# This is not a proto-plus wrapped message type,
565+
# and setting a non-primitive field directly is verboten.
566+
continue
567+
568+
yield name, field
554569

555570
answer: Dict[str, Field] = collections.OrderedDict(
556-
(f.strip(), self.input.get_field(*f.strip().split('.')))
571+
name_and_field
557572
for sig in signatures
558-
# Special case for an empty signature check
559-
for f in sig.split(',') if f
573+
for name_and_field in filter_fields(sig)
560574
)
561575

562576
return answer

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

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,14 +188,30 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta):
188188
raise ValueError('If the `request` argument is set, then none of '
189189
'the individual field arguments should be set.')
190190

191-
# If we have keyword arguments corresponding to fields on the
192-
# request, apply these.
193191
{% endif -%}
192+
{% if method.input.ident.package != method.ident.package -%} {# request lives in a different package, so there is no proto wrapper #}
193+
# The request isn't a proto-plus wrapped type,
194+
# so it must be constructed via keyword expansion.
195+
if isinstance(request, dict):
196+
request = {{ method.input.ident }}(**request)
197+
elif not request:
198+
request = {{ method.input.ident }}()
199+
{%- else %}
194200
request = {{ method.input.ident }}(request)
195-
{%- for key, field in method.flattened_fields.items() %}
201+
{% endif %} {# different request package #}
202+
203+
# If we have keyword arguments corresponding to fields on the
204+
# request, apply these.
205+
{#- Vanilla python protobuf wrapper types cannot _set_ repeated fields #}
206+
{%- for key, field in method.flattened_fields.items() if not(field.repeated and method.input.ident.package != method.ident.package) %}
196207
if {{ field.name }} is not None:
197208
request.{{ key }} = {{ field.name }}
198209
{%- endfor %}
210+
{# They can be _extended_, however -#}
211+
{%- for key, field in method.flattened_fields.items() if (field.repeated and method.input.ident.package != method.ident.package) %}
212+
if {{ field.name }}:
213+
request.{{ key }}.extend({{ field.name }})
214+
{%- endfor %}
199215
{%- endif %}
200216

201217
# Wrap the RPC method; this adds retry and timeout information,

packages/gapic-generator/gapic/templates/setup.py.j2

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ setuptools.setup(
2020
'googleapis-common-protos >= 1.5.8',
2121
'grpcio >= 1.10.0',
2222
'proto-plus >= 0.4.0',
23+
{%- if api.requires_package(('google', 'iam', 'v1')) %}
24+
'grpc-google-iam-v1',
25+
{%- endif %}
2326
),
2427
python_requires='>={% if opts.lazy_import %}3.7{% else %}3.6{% endif %}',{# Lazy import requires module-level getattr #}
2528
setup_requires=[

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,35 @@ def test_{{ method.name|snake_case }}_field_headers():
165165
) in kw['metadata']
166166
{% endif %}
167167

168+
{% if method.ident.package != method.input.ident.package %}
169+
def test_{{ method.name|snake_case }}_from_dict():
170+
client = {{ service.client_name }}(
171+
credentials=credentials.AnonymousCredentials(),
172+
)
173+
# Mock the actual call within the gRPC stub, and fake the request.
174+
with mock.patch.object(
175+
type(client._transport.{{ method.name|snake_case }}),
176+
'__call__') as call:
177+
# Designate an appropriate return value for the call.
178+
{% if method.void -%}
179+
call.return_value = None
180+
{% elif method.lro -%}
181+
call.return_value = operations_pb2.Operation(name='operations/op')
182+
{% elif method.server_streaming -%}
183+
call.return_value = iter([{{ method.output.ident }}()])
184+
{% else -%}
185+
call.return_value = {{ method.output.ident }}()
186+
{% endif %}
187+
response = client.{{ method.name|snake_case }}(request={
188+
{%- for field in method.input.fields.values() %}
189+
'{{ field.name }}': {{ field.mock_value }},
190+
{%- endfor %}
191+
}
192+
)
193+
call.assert_called()
194+
195+
{% endif %}
196+
168197
{% if method.flattened_fields %}
169198
def test_{{ method.name|snake_case }}_flattened():
170199
client = {{ service.client_name }}(

packages/gapic-generator/tests/unit/schema/test_api.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ def test_api_build():
8787
imp.Import(package=('google', 'dep'), module='dep_pb2'),
8888
)
8989

90+
assert api_schema.requires_package(('google', 'example', 'v1'))
91+
assert not api_schema.requires_package(('elgoog', 'example', 'v1'))
92+
9093
# Establish that the subpackages work.
9194
assert 'common' in api_schema.subpackages
9295
sub = api_schema.subpackages['common']

packages/gapic-generator/tests/unit/schema/wrappers/test_method.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,9 +179,35 @@ def test_method_flattened_fields_empty_sig():
179179
assert len(method.flattened_fields) == 0
180180

181181

182+
def test_method_flattened_fields_different_package_non_primitive():
183+
# This test verifies that method flattening handles a special case where:
184+
# * the method's request message type lives in a different package and
185+
# * a field in the method_signature is a non-primitive.
186+
#
187+
# If the message is defined in a different package it is not guaranteed to
188+
# be a proto-plus wrapped type, which puts restrictions on assigning
189+
# directly to its fields, which complicates request construction.
190+
# The easiest solution in this case is to just prohibit these fields
191+
# in the method flattening.
192+
message = make_message('Mantle',
193+
package="mollusc.cephalopod.v1", module="squid")
194+
mantle = make_field('mantle', type=11, type_name='Mantle',
195+
message=message, meta=message.meta)
196+
arms_count = make_field('arms_count', type=5, meta=message.meta)
197+
input_message = make_message(
198+
'Squid', fields=(mantle, arms_count),
199+
package=".".join(message.meta.address.package),
200+
module=message.meta.address.module
201+
)
202+
method = make_method('PutSquid', input_message=input_message,
203+
package="remote.package.v1", module="module", signatures=("mantle,arms_count",))
204+
assert set(method.flattened_fields) == {'arms_count'}
205+
206+
182207
def test_method_include_flattened_message_fields():
183208
a = make_field('a', type=5)
184-
b = make_field('b', type=11, message=make_message('Eggs'))
209+
b = make_field('b', type=11, type_name='Eggs',
210+
message=make_message('Eggs'))
185211
input_msg = make_message('Z', fields=(a, b))
186212
method = make_method('F', input_message=input_msg, signatures=('a,b',))
187213
assert len(method.flattened_fields) == 2
@@ -274,7 +300,7 @@ def make_method(
274300
output=output_message,
275301
meta=metadata.Metadata(address=metadata.Address(
276302
name=name,
277-
package=package,
303+
package=tuple(package.split('.')),
278304
module=module,
279305
parent=(f'{name}Service',),
280306
)),

packages/gapic-generator/tests/unit/schema/wrappers/test_service.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ def get_method(name: str,
280280
input=input_,
281281
output=output,
282282
lro=lro,
283+
meta=input_.meta,
283284
)
284285

285286

0 commit comments

Comments
 (0)