Skip to content

Commit 177b577

Browse files
committed
feat: file_level and indirectly used resources generate helper methods
File level resources are defined as options for the proto file, not for a message type. Indirectly used resources are resources backed by a message type, but the message type is not a field type referenced by a service. E.g. message Squid { option (google.api.resource) = { type: "animalia.mollusca.com/Squid" pattern: "zones/{zone}/squids/{squid}" }; } message CreateSquidRequest{ string name = 1 [ (google.api.resource_reference) = { type: "animalia.mollusca.com/Squid" } ]; } message CreateSquidResponse{} Both file level and indirectly used resources generate helper methods in service clients that need them. Closes googleapis#637
1 parent 517118c commit 177b577

File tree

6 files changed

+239
-19
lines changed

6 files changed

+239
-19
lines changed

gapic/schema/api.py

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,15 @@
1919

2020
import collections
2121
import dataclasses
22+
import itertools
2223
import keyword
2324
import os
2425
import sys
2526
from typing import Callable, Container, Dict, FrozenSet, Mapping, Optional, Sequence, Set, Tuple
27+
from types import MappingProxyType
2628

2729
from google.api_core import exceptions # type: ignore
30+
from google.api import resource_pb2
2831
from google.longrunning import operations_pb2 # type: ignore
2932
from google.protobuf import descriptor_pb2
3033

@@ -58,11 +61,14 @@ def __getattr__(self, name: str):
5861

5962
@classmethod
6063
def build(
61-
cls, file_descriptor: descriptor_pb2.FileDescriptorProto,
62-
file_to_generate: bool, naming: api_naming.Naming,
63-
opts: Options = Options(),
64-
prior_protos: Mapping[str, 'Proto'] = None,
65-
load_services: bool = True
64+
cls,
65+
file_descriptor: descriptor_pb2.FileDescriptorProto,
66+
file_to_generate: bool,
67+
naming: api_naming.Naming,
68+
opts: Options = Options(),
69+
prior_protos: Mapping[str, 'Proto'] = None,
70+
load_services: bool = True,
71+
all_resources: Optional[Mapping[str, wrappers.CommonResource]] = None,
6672
) -> 'Proto':
6773
"""Build and return a Proto instance.
6874
@@ -85,7 +91,8 @@ def build(
8591
naming=naming,
8692
opts=opts,
8793
prior_protos=prior_protos or {},
88-
load_services=load_services
94+
load_services=load_services,
95+
all_resources=all_resources or {},
8996
).proto
9097

9198
@cached_property
@@ -104,6 +111,24 @@ def messages(self) -> Mapping[str, wrappers.MessageType]:
104111
if not v.meta.address.parent
105112
)
106113

114+
@cached_property
115+
def resource_messages(self) -> Mapping[str, wrappers.MessageType]:
116+
"""Return the file level resources of the proto."""
117+
file_resource_messages = (
118+
(res.type, wrappers.CommonResource.build(res).message_type)
119+
for res in self.file_pb2.options.Extensions[resource_pb2.resource_definition]
120+
)
121+
resource_messages = (
122+
(msg.options.Extensions[resource_pb2.resource].type, msg)
123+
for msg in self.messages.values()
124+
if msg.options.Extensions[resource_pb2.resource].type
125+
)
126+
return collections.OrderedDict(
127+
itertools.chain(
128+
file_resource_messages, resource_messages,
129+
)
130+
)
131+
107132
@property
108133
def module_name(self) -> str:
109134
"""Return the appropriate module name for this service.
@@ -264,6 +289,13 @@ def disambiguate_keyword_fname(
264289
load_services=False,
265290
)
266291

292+
# A file descriptor's file-level resources are NOT visible to any importers.
293+
# The only way to make referenced resources visible is to aggregate them at
294+
# the API level and then pass that around.
295+
all_file_resources = collections.ChainMap(
296+
*(proto.resource_messages for proto in pre_protos.values())
297+
)
298+
267299
# Second pass uses all the messages and enums defined in the entire API.
268300
# This allows LRO returning methods to see all the types in the API,
269301
# bypassing the above missing import problem.
@@ -274,6 +306,7 @@ def disambiguate_keyword_fname(
274306
naming=naming,
275307
opts=opts,
276308
prior_protos=pre_protos,
309+
all_resources=MappingProxyType(all_file_resources),
277310
)
278311
for name, proto in pre_protos.items()
279312
}
@@ -390,7 +423,8 @@ def __init__(
390423
naming: api_naming.Naming,
391424
opts: Options = Options(),
392425
prior_protos: Mapping[str, Proto] = None,
393-
load_services: bool = True
426+
load_services: bool = True,
427+
all_resources: Optional[Mapping[str, wrappers.CommonResource]] = None,
394428
):
395429
self.proto_messages: Dict[str, wrappers.MessageType] = {}
396430
self.proto_enums: Dict[str, wrappers.EnumType] = {}
@@ -432,9 +466,9 @@ def __init__(
432466
# below is because `repeated DescriptorProto message_type = 4;` in
433467
# descriptor.proto itself).
434468
self._load_children(file_descriptor.enum_type, self._load_enum,
435-
address=self.address, path=(5,))
469+
address=self.address, path=(5,), resources=all_resources)
436470
self._load_children(file_descriptor.message_type, self._load_message,
437-
address=self.address, path=(4,))
471+
address=self.address, path=(4,), resources=all_resources)
438472

439473
# Edge case: Protocol buffers is not particularly picky about
440474
# ordering, and it is possible that a message will have had a field
@@ -469,7 +503,7 @@ def __init__(
469503
# same files.
470504
if file_to_generate and load_services:
471505
self._load_children(file_descriptor.service, self._load_service,
472-
address=self.address, path=(6,))
506+
address=self.address, path=(6,), resources=all_resources)
473507
# TODO(lukesneeringer): oneofs are on path 7.
474508

475509
@property
@@ -528,7 +562,8 @@ def api_messages(self) -> Mapping[str, wrappers.MessageType]:
528562

529563
def _load_children(self,
530564
children: Sequence, loader: Callable, *,
531-
address: metadata.Address, path: Tuple[int, ...]) -> Mapping:
565+
address: metadata.Address, path: Tuple[int, ...],
566+
resources: Mapping[str, wrappers.CommonResource]) -> Mapping:
532567
"""Return wrapped versions of arbitrary children from a Descriptor.
533568
534569
Args:
@@ -554,7 +589,7 @@ def _load_children(self,
554589
# applicable loader function on each.
555590
answer = {}
556591
for child, i in zip(children, range(0, sys.maxsize)):
557-
wrapped = loader(child, address=address, path=path + (i,))
592+
wrapped = loader(child, address=address, path=path + (i,), resources=resources)
558593
answer[wrapped.name] = wrapped
559594
return answer
560595

@@ -794,6 +829,7 @@ def _load_message(self,
794829
message_pb: descriptor_pb2.DescriptorProto,
795830
address: metadata.Address,
796831
path: Tuple[int],
832+
resources: Mapping[str, wrappers.CommonResource],
797833
) -> wrappers.MessageType:
798834
"""Load message descriptions from DescriptorProtos."""
799835
address = address.child(message_pb.name, path)
@@ -810,12 +846,14 @@ def _load_message(self,
810846
address=address,
811847
loader=self._load_enum,
812848
path=path + (4,),
849+
resources=resources,
813850
)
814851
nested_messages = self._load_children(
815852
message_pb.nested_type,
816853
address=address,
817854
loader=self._load_message,
818855
path=path + (3,),
856+
resources=resources,
819857
)
820858

821859
oneofs = self._get_oneofs(
@@ -856,6 +894,7 @@ def _load_enum(self,
856894
enum: descriptor_pb2.EnumDescriptorProto,
857895
address: metadata.Address,
858896
path: Tuple[int],
897+
resources: Mapping[str, wrappers.CommonResource],
859898
) -> wrappers.EnumType:
860899
"""Load enum descriptions from EnumDescriptorProtos."""
861900
address = address.child(enum.name, path)
@@ -886,6 +925,7 @@ def _load_service(self,
886925
service: descriptor_pb2.ServiceDescriptorProto,
887926
address: metadata.Address,
888927
path: Tuple[int],
928+
resources: Mapping[str, wrappers.CommonResource],
889929
) -> wrappers.Service:
890930
"""Load comments for a service and its methods."""
891931
address = address.child(service.name, path)
@@ -905,6 +945,7 @@ def _load_service(self,
905945
),
906946
methods=methods,
907947
service_pb=service,
948+
visible_resources=resources,
908949
)
909950
return self.proto_services[address.proto]
910951

gapic/schema/wrappers.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
from itertools import chain
3434
from typing import (cast, Dict, FrozenSet, Iterable, List, Mapping,
3535
ClassVar, Optional, Sequence, Set, Tuple, Union)
36-
3736
from google.api import annotations_pb2 # type: ignore
3837
from google.api import client_pb2
3938
from google.api import field_behavior_pb2
@@ -62,6 +61,12 @@ class Field:
6261
def __getattr__(self, name):
6362
return getattr(self.field_pb, name)
6463

64+
def __hash__(self):
65+
# The only sense in which it is meaningful to say a field is equal to
66+
# another field is if they are the same, i.e. they live in the same
67+
# message type under the same moniker, i.e. they have the same id.
68+
return id(self)
69+
6570
@property
6671
def name(self) -> str:
6772
"""Used to prevent collisions with python keywords"""
@@ -305,6 +310,15 @@ def recursive_field_types(self) -> Sequence[
305310

306311
return tuple(types)
307312

313+
@utils.cached_property
314+
def recursive_fields(self) -> Sequence[Field]:
315+
return frozenset(chain(
316+
self.fields.values(),
317+
(field
318+
for t in self.recursive_field_types if isinstance(t, MessageType)
319+
for field in t.fields.values()),
320+
))
321+
308322
@property
309323
def map(self) -> bool:
310324
"""Return True if the given message is a map, False otherwise."""
@@ -860,6 +874,13 @@ class CommonResource:
860874
type_name: str
861875
pattern: str
862876

877+
@classmethod
878+
def build(cls, resource: resource_pb2.ResourceDescriptor):
879+
return cls(
880+
type_name=resource.type,
881+
pattern=next(iter(resource.pattern))
882+
)
883+
863884
@utils.cached_property
864885
def message_type(self):
865886
message_pb = descriptor_pb2.DescriptorProto()
@@ -880,6 +901,10 @@ class Service:
880901
"""Description of a service (defined with the ``service`` keyword)."""
881902
service_pb: descriptor_pb2.ServiceDescriptorProto
882903
methods: Mapping[str, Method]
904+
# N.B.: visible_resources is intended to be a read-only view
905+
# whose backing store is owned by the API.
906+
# This is represented by a types.MappingProxyType instance.
907+
visible_resources: Mapping[str, MessageType]
883908
meta: metadata.Metadata = dataclasses.field(
884909
default_factory=metadata.Metadata,
885910
)
@@ -1021,6 +1046,14 @@ def gen_resources(message):
10211046
if type_.resource_path:
10221047
yield type_
10231048

1049+
def gen_indirect_resources_used(message):
1050+
for field in message.recursive_fields:
1051+
resource = field.options.Extensions[
1052+
resource_pb2.resource_reference]
1053+
resource_type = resource.type or resource.child_type
1054+
if resource_type:
1055+
yield self.visible_resources[resource_type]
1056+
10241057
return frozenset(
10251058
msg
10261059
for method in self.methods.values()
@@ -1029,6 +1062,10 @@ def gen_resources(message):
10291062
gen_resources(
10301063
method.lro.response_type if method.lro else method.output
10311064
),
1065+
gen_indirect_resources_used(method.input),
1066+
gen_indirect_resources_used(
1067+
method.lro.response_type if method.lro else method.output
1068+
),
10321069
)
10331070
)
10341071

test_utils/test_utils.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,16 @@
2424
from google.protobuf import descriptor_pb2 as desc
2525

2626

27-
def make_service(name: str = 'Placeholder', host: str = '',
28-
methods: typing.Tuple[wrappers.Method] = (),
29-
scopes: typing.Tuple[str] = ()) -> wrappers.Service:
27+
def make_service(
28+
name: str = "Placeholder",
29+
host: str = "",
30+
methods: typing.Tuple[wrappers.Method] = (),
31+
scopes: typing.Tuple[str] = (),
32+
visible_resources: typing.Optional[
33+
typing.Mapping[str, wrappers.CommonResource]
34+
] = None,
35+
) -> wrappers.Service:
36+
visible_resources = visible_resources or {}
3037
# Define a service descriptor, and set a host and oauth scopes if
3138
# appropriate.
3239
service_pb = desc.ServiceDescriptorProto(name=name)
@@ -38,6 +45,7 @@ def make_service(name: str = 'Placeholder', host: str = '',
3845
return wrappers.Service(
3946
service_pb=service_pb,
4047
methods={m.name: m for m in methods},
48+
visible_resources=visible_resources,
4149
)
4250

4351

@@ -47,7 +55,8 @@ def make_service_with_method_options(
4755
*,
4856
http_rule: http_pb2.HttpRule = None,
4957
method_signature: str = '',
50-
in_fields: typing.Tuple[desc.FieldDescriptorProto] = ()
58+
in_fields: typing.Tuple[desc.FieldDescriptorProto] = (),
59+
visible_resources: typing.Optional[typing.Mapping[str, wrappers.CommonResource]] = None,
5160
) -> wrappers.Service:
5261
# Declare a method with options enabled for long-running operations and
5362
# field headers.
@@ -69,6 +78,7 @@ def make_service_with_method_options(
6978
return wrappers.Service(
7079
service_pb=service_pb,
7180
methods={method.name: method},
81+
visible_resources=visible_resources or {},
7282
)
7383

7484

tests/unit/generator/test_generator.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,7 @@ def test_get_filename_with_service():
262262
methods=[],
263263
service_pb=descriptor_pb2.ServiceDescriptorProto(
264264
name="Eggs"),
265+
visible_resources={},
265266
),
266267
},
267268
)

tests/unit/samplegen/test_integration.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@ def test_generate_sample_basic():
8080
"classify_target": DummyField(name="classify_target")
8181
}
8282
)
83-
}
83+
},
84+
visible_resources={},
8485
)
8586

8687
schema = DummyApiSchema(
@@ -216,7 +217,8 @@ def test_generate_sample_basic_unflattenable():
216217
input=input_type,
217218
output=message_factory("$resp.taxonomy"),
218219
)
219-
}
220+
},
221+
visible_resources={},
220222
)
221223

222224
schema = DummyApiSchema(

0 commit comments

Comments
 (0)