Skip to content

Commit 29fd85d

Browse files
authored
Tests and impl for resource path helper methods (#253)
Includes generated unit tests, plumbing code unit tests, and system tests for determining whether a message is a resource, providing an accessor for one and only one path if it is a resource, and a service accessor for all message fields that are resources (only one level deep at the moment).
1 parent 725a6aa commit 29fd85d

File tree

6 files changed

+216
-3
lines changed

6 files changed

+216
-3
lines changed

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from google.api import annotations_pb2 # type: ignore
3838
from google.api import client_pb2
3939
from google.api import field_behavior_pb2
40+
from google.api import resource_pb2
4041
from google.api_core import exceptions # type: ignore
4142
from google.protobuf import descriptor_pb2 # type: ignore
4243

@@ -212,6 +213,9 @@ class MessageType:
212213
def __getattr__(self, name):
213214
return getattr(self.message_pb, name)
214215

216+
def __hash__(self):
217+
return hash(self.name)
218+
215219
@utils.cached_property
216220
def field_types(self) -> Sequence[Union['MessageType', 'EnumType']]:
217221
"""Return all composite fields used in this proto's messages."""
@@ -231,6 +235,25 @@ def ident(self) -> metadata.Address:
231235
"""Return the identifier data to be used in templates."""
232236
return self.meta.address
233237

238+
@property
239+
def resource_path(self) -> Optional[str]:
240+
"""If this message describes a resource, return the path to the resource.
241+
If there are multiple paths, returns the first one."""
242+
return next(
243+
iter(self.options.Extensions[resource_pb2.resource].pattern),
244+
None
245+
)
246+
247+
@property
248+
def resource_type(self) -> Optional[str]:
249+
resource = self.options.Extensions[resource_pb2.resource]
250+
return resource.type[resource.type.find('/') + 1:] if resource else None
251+
252+
@property
253+
def resource_path_args(self) -> Sequence[str]:
254+
path_arg_re = re.compile(r'\{([a-zA-Z0-9_-]+)\}')
255+
return path_arg_re.findall(self.resource_path or '')
256+
234257
def get_field(self, *field_path: str,
235258
collisions: FrozenSet[str] = frozenset()) -> Field:
236259
"""Return a field arbitrarily deep in this message's structure.
@@ -732,6 +755,17 @@ def names(self) -> FrozenSet[str]:
732755
# Done; return the answer.
733756
return frozenset(answer)
734757

758+
@utils.cached_property
759+
def resource_messages(self) -> FrozenSet[MessageType]:
760+
"""Returns all the resource message types used in all
761+
request fields in the service."""
762+
return frozenset(
763+
field.message
764+
for method in self.methods.values()
765+
for field in method.input.fields.values()
766+
if field.message and field.message.resource_path
767+
)
768+
735769
def with_context(self, *, collisions: FrozenSet[str]) -> 'Service':
736770
"""Return a derivative of this service with the provided context.
737771

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,13 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta):
8181
from_service_account_json = from_service_account_file
8282

8383

84+
{% for message in service.resource_messages -%}
85+
@staticmethod
86+
def {{ message.resource_type|snake_case }}_path({% for arg in message.resource_path_args %}{{ arg }}: str,{% endfor %}) -> str:
87+
"""Return a fully-qualified {{ message.resource_type|snake_case }} string."""
88+
return "{{ message.resource_path }}".format({% for arg in message.resource_path_args %}{{ arg }}={{ arg }}, {% endfor %})
89+
{% endfor %}
90+
8491
def __init__(self, *,
8592
credentials: credentials.Credentials = None,
8693
transport: Union[str, {{ service.name }}Transport] = None,

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,4 +422,17 @@ def test_{{ service.name|snake_case }}_grpc_lro_client():
422422
assert transport.operations_client is transport.operations_client
423423

424424
{% endif -%}
425+
426+
{% for message in service.resource_messages -%}
427+
{% with molluscs = cycler("squid", "clam", "whelk", "octopus", "oyster", "nudibranch", "cuttlefish", "mussel", "winkle") -%}
428+
def test_{{ message.name|snake_case }}_path():
429+
{% for arg in message.resource_path_args -%}
430+
{{ arg }} = "{{ molluscs.next() }}"
431+
{% endfor %}
432+
expected = "{{ message.resource_path }}".format({% for arg in message.resource_path_args %}{{ arg }}={{ arg }}, {% endfor %})
433+
actual = {{ service.client_name }}.{{ message.name|snake_case }}_path({{message.resource_path_args|join(", ") }})
434+
assert expected == actual
435+
436+
{% endwith -%}
437+
{% endfor -%}
425438
{% endblock %}

packages/gapic-generator/tests/system/test_resource_crud.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,10 @@ def test_crud_positional(identity):
4343
assert identity.get_user(user.name).display_name == 'Monty Python'
4444
finally:
4545
identity.delete_user(user.name)
46+
47+
48+
def test_path_methods(identity):
49+
expected = "users/bdfl"
50+
actual = identity.user_path("bdfl")
51+
52+
assert expected == actual

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

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import pytest
1919

20+
from google.api import resource_pb2
2021
from google.protobuf import descriptor_pb2
2122

2223
from gapic.schema import metadata
@@ -124,6 +125,21 @@ def test_get_field_nonterminal_repeated_error():
124125
assert outer.get_field('inner', 'one') == inner_fields[1]
125126

126127

128+
def test_resource_path():
129+
options = descriptor_pb2.MessageOptions()
130+
resource = options.Extensions[resource_pb2.resource]
131+
resource.pattern.append(
132+
"kingdoms/{kingdom}/phyla/{phylum}/classes/{klass}")
133+
resource.pattern.append(
134+
"kingdoms/{kingdom}/divisions/{division}/classes/{klass}")
135+
resource.type = "taxonomy.biology.com/Class"
136+
message = make_message('Squid', options=options)
137+
138+
assert message.resource_path == "kingdoms/{kingdom}/phyla/{phylum}/classes/{klass}"
139+
assert message.resource_path_args == ["kingdom", "phylum", "klass"]
140+
assert message.resource_type == "Class"
141+
142+
127143
def test_field_map():
128144
# Create an Entry message.
129145
entry_msg = make_message(
@@ -140,8 +156,8 @@ def test_field_map():
140156

141157

142158
def make_message(name: str, package: str = 'foo.bar.v1', module: str = 'baz',
143-
fields: Sequence[wrappers.Field] = (), meta: metadata.Metadata = None,
144-
options: descriptor_pb2.MethodOptions = None,
159+
fields: Sequence[wrappers.Field] = (), meta: metadata.Metadata = None,
160+
options: descriptor_pb2.MethodOptions = None,
145161
) -> wrappers.MessageType:
146162
message_pb = descriptor_pb2.DescriptorProto(
147163
name=name,
@@ -183,7 +199,7 @@ def make_field(name: str, repeated: bool = False,
183199

184200

185201
def make_enum(name: str, package: str = 'foo.bar.v1', module: str = 'baz',
186-
values: Tuple[str, int] = (), meta: metadata.Metadata = None,
202+
values: Tuple[str, int] = (), meta: metadata.Metadata = None,
187203
) -> wrappers.EnumType:
188204
enum_value_pbs = [
189205
descriptor_pb2.EnumValueDescriptorProto(name=i[0], number=i[1])

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

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import collections
1516
import typing
1617

1718
from google.api import annotations_pb2
1819
from google.api import client_pb2
1920
from google.api import http_pb2
21+
from google.api import resource_pb2
2022
from google.protobuf import descriptor_pb2
2123

2224
from gapic.schema import imp
@@ -146,6 +148,52 @@ def test_module_name():
146148
assert service.module_name == 'my_service'
147149

148150

151+
def test_resource_messages():
152+
# Resources
153+
squid_options = descriptor_pb2.MessageOptions()
154+
squid_options.Extensions[resource_pb2.resource].pattern.append(
155+
"squid/{squid}")
156+
squid_message = make_message("Squid", options=squid_options)
157+
clam_options = descriptor_pb2.MessageOptions()
158+
clam_options.Extensions[resource_pb2.resource].pattern.append(
159+
"clam/{clam}")
160+
clam_message = make_message("Clam", options=clam_options)
161+
whelk_options = descriptor_pb2.MessageOptions()
162+
whelk_options.Extensions[resource_pb2.resource].pattern.append(
163+
"whelk/{whelk}")
164+
whelk_message = make_message("Whelk", options=whelk_options)
165+
166+
# Not resources
167+
octopus_message = make_message("Octopus")
168+
oyster_message = make_message("Oyster")
169+
nudibranch_message = make_message("Nudibranch")
170+
171+
service = make_service(
172+
'Molluscs',
173+
methods=(
174+
make_method(
175+
f"Get{message.name}",
176+
input_message=make_message(
177+
f"{message.name}Request",
178+
fields=[make_field(message.name, message=message)]
179+
)
180+
)
181+
for message in (
182+
squid_message,
183+
clam_message,
184+
whelk_message,
185+
octopus_message,
186+
oyster_message,
187+
nudibranch_message
188+
)
189+
)
190+
)
191+
192+
expected = {squid_message, clam_message, whelk_message}
193+
actual = service.resource_messages
194+
assert expected == actual
195+
196+
149197
def make_service(name: str = 'Placeholder', host: str = '',
150198
methods: typing.Tuple[wrappers.Method] = (),
151199
scopes: typing.Tuple[str] = ()) -> wrappers.Service:
@@ -248,6 +296,7 @@ def get_message(dot_path: str, *,
248296
# path is just google.protobuf.DescriptorProto).
249297
pieces = dot_path.split('.')
250298
pkg, module, name = pieces[:-2], pieces[-2], pieces[-1]
299+
251300
return wrappers.MessageType(
252301
fields={i.name: wrappers.Field(
253302
field_pb=i,
@@ -264,6 +313,93 @@ def get_message(dot_path: str, *,
264313
)
265314

266315

316+
def make_method(
317+
name: str, input_message: wrappers.MessageType = None,
318+
output_message: wrappers.MessageType = None,
319+
package: str = 'foo.bar.v1', module: str = 'baz',
320+
http_rule: http_pb2.HttpRule = None,
321+
signatures: typing.Sequence[str] = (),
322+
**kwargs) -> wrappers.Method:
323+
# Use default input and output messages if they are not provided.
324+
input_message = input_message or make_message('MethodInput')
325+
output_message = output_message or make_message('MethodOutput')
326+
327+
# Create the method pb2.
328+
method_pb = descriptor_pb2.MethodDescriptorProto(
329+
name=name,
330+
input_type=str(input_message.meta.address),
331+
output_type=str(output_message.meta.address),
332+
**kwargs
333+
)
334+
335+
# If there is an HTTP rule, process it.
336+
if http_rule:
337+
ext_key = annotations_pb2.http
338+
method_pb.options.Extensions[ext_key].MergeFrom(http_rule)
339+
340+
# If there are signatures, include them.
341+
for sig in signatures:
342+
ext_key = client_pb2.method_signature
343+
method_pb.options.Extensions[ext_key].append(sig)
344+
345+
# Instantiate the wrapper class.
346+
return wrappers.Method(
347+
method_pb=method_pb,
348+
input=input_message,
349+
output=output_message,
350+
meta=metadata.Metadata(address=metadata.Address(
351+
name=name,
352+
package=package,
353+
module=module,
354+
parent=(f'{name}Service',),
355+
)),
356+
)
357+
358+
359+
def make_field(name: str, repeated: bool = False,
360+
message: wrappers.MessageType = None,
361+
enum: wrappers.EnumType = None,
362+
meta: metadata.Metadata = None, **kwargs) -> wrappers.Method:
363+
if message:
364+
kwargs['type_name'] = str(message.meta.address)
365+
if enum:
366+
kwargs['type_name'] = str(enum.meta.address)
367+
field_pb = descriptor_pb2.FieldDescriptorProto(
368+
name=name,
369+
label=3 if repeated else 1,
370+
**kwargs
371+
)
372+
return wrappers.Field(
373+
enum=enum,
374+
field_pb=field_pb,
375+
message=message,
376+
meta=meta or metadata.Metadata(),
377+
)
378+
379+
380+
def make_message(name: str, package: str = 'foo.bar.v1', module: str = 'baz',
381+
fields: typing.Sequence[wrappers.Field] = (),
382+
meta: metadata.Metadata = None,
383+
options: descriptor_pb2.MethodOptions = None,
384+
) -> wrappers.MessageType:
385+
message_pb = descriptor_pb2.DescriptorProto(
386+
name=name,
387+
field=[i.field_pb for i in fields],
388+
options=options,
389+
)
390+
return wrappers.MessageType(
391+
message_pb=message_pb,
392+
fields=collections.OrderedDict((i.name, i) for i in fields),
393+
nested_messages={},
394+
nested_enums={},
395+
meta=meta or metadata.Metadata(address=metadata.Address(
396+
name=name,
397+
package=tuple(package.split('.')),
398+
module=module,
399+
)),
400+
)
401+
402+
267403
def get_enum(dot_path: str) -> wrappers.EnumType:
268404
pieces = dot_path.split('.')
269405
pkg, module, name = pieces[:-2], pieces[-2], pieces[-1]

0 commit comments

Comments
 (0)