Skip to content

Commit 9d16ecf

Browse files
authored
Test and impl for resource path parsing methods in generated clients (#391)
Given a fully qualified resource path, it is sometimes desirable to parse out the component segments. This change adds generated methods to do this parsing to gapic client classes, accompanying generated unit tests, logic in the wrapper schema to support this feature, and generator unit tests for the schema logic.
1 parent b66aaf4 commit 9d16ecf

File tree

6 files changed

+117
-10
lines changed

6 files changed

+117
-10
lines changed

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta):
120120
"""Return a fully-qualified {{ message.resource_type|snake_case }} string."""
121121
return "{{ message.resource_path }}".format({% for arg in message.resource_path_args %}{{ arg }}={{ arg }}, {% endfor %})
122122

123+
124+
@staticmethod
125+
def parse_{{ message.resource_type|snake_case }}_path(path: str) -> Dict[str,str]:
126+
"""Parse a {{ message.resource_type|snake_case }} path into its component segments."""
127+
m = re.match(r"{{ message.path_regex_str }}", path)
128+
return m.groupdict() if m else {}
123129
{% endfor %}
124130

125131
def __init__(self, *,

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -693,7 +693,7 @@ def test_{{ service.name|snake_case }}_grpc_lro_client():
693693
{% endif -%}
694694

695695
{% for message in service.resource_messages -%}
696-
{% with molluscs = cycler("squid", "clam", "whelk", "octopus", "oyster", "nudibranch", "cuttlefish", "mussel", "winkle") -%}
696+
{% with molluscs = cycler("squid", "clam", "whelk", "octopus", "oyster", "nudibranch", "cuttlefish", "mussel", "winkle", "nautilus", "scallop", "abalone") -%}
697697
def test_{{ message.resource_type|snake_case }}_path():
698698
{% for arg in message.resource_path_args -%}
699699
{{ arg }} = "{{ molluscs.next() }}"
@@ -702,6 +702,19 @@ def test_{{ message.resource_type|snake_case }}_path():
702702
actual = {{ service.client_name }}.{{ message.resource_type|snake_case }}_path({{message.resource_path_args|join(", ") }})
703703
assert expected == actual
704704

705+
706+
def test_parse_{{ message.resource_type|snake_case }}_path():
707+
expected = {
708+
{% for arg in message.resource_path_args -%}
709+
"{{ arg }}": "{{ molluscs.next() }}",
710+
{% endfor %}
711+
}
712+
path = {{ service.client_name }}.{{ message.resource_type|snake_case }}_path(**expected)
713+
714+
# Check that the path construction is reversible.
715+
actual = {{ service.client_name }}.parse_{{ message.resource_type|snake_case }}_path(path)
716+
assert expected == actual
717+
705718
{% endwith -%}
706719
{% endfor -%}
707720

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

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,10 @@ def with_context(self, *, collisions: FrozenSet[str]) -> 'Field':
209209
@dataclasses.dataclass(frozen=True)
210210
class MessageType:
211211
"""Description of a message (defined with the ``message`` keyword)."""
212+
# Class attributes
213+
PATH_ARG_RE = re.compile(r'\{([a-zA-Z0-9_-]+)\}')
214+
215+
# Instance attributes
212216
message_pb: descriptor_pb2.DescriptorProto
213217
fields: Mapping[str, Field]
214218
nested_enums: Mapping[str, 'EnumType']
@@ -278,8 +282,32 @@ def resource_type(self) -> Optional[str]:
278282

279283
@property
280284
def resource_path_args(self) -> Sequence[str]:
281-
path_arg_re = re.compile(r'\{([a-zA-Z0-9_-]+)\}')
282-
return path_arg_re.findall(self.resource_path or '')
285+
return self.PATH_ARG_RE.findall(self.resource_path or '')
286+
287+
@utils.cached_property
288+
def path_regex_str(self) -> str:
289+
# The indirection here is a little confusing:
290+
# we're using the resource path template as the base of a regex,
291+
# with each resource ID segment being captured by a regex.
292+
# E.g., the path schema
293+
# kingdoms/{kingdom}/phyla/{phylum}
294+
# becomes the regex
295+
# ^kingdoms/(?P<kingdom>.+?)/phyla/(?P<phylum>.+?)$
296+
parsing_regex_str = (
297+
"^" +
298+
self.PATH_ARG_RE.sub(
299+
# We can't just use (?P<name>[^/]+) because segments may be
300+
# separated by delimiters other than '/'.
301+
# Multiple delimiter characters within one schema are allowed,
302+
# e.g.
303+
# as/{a}-{b}/cs/{c}%{d}_{e}
304+
# This is discouraged but permitted by AIP4231
305+
lambda m: "(?P<{name}>.+?)".format(name=m.groups()[0]),
306+
self.resource_path or ''
307+
) +
308+
"$"
309+
)
310+
return parsing_regex_str
283311

284312
def get_field(self, *field_path: str,
285313
collisions: FrozenSet[str] = frozenset()) -> Field:

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta):
120120
"""Return a fully-qualified {{ message.resource_type|snake_case }} string."""
121121
return "{{ message.resource_path }}".format({% for arg in message.resource_path_args %}{{ arg }}={{ arg }}, {% endfor %})
122122

123+
124+
@staticmethod
125+
def parse_{{ message.resource_type|snake_case }}_path(path: str) -> Dict[str,str]:
126+
"""Parse a {{ message.resource_type|snake_case }} path into its component segments."""
127+
m = re.match(r"{{ message.path_regex_str }}", path)
128+
return m.groupdict() if m else {}
123129
{% endfor %}
124130

125131
def __init__(self, *,

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

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -692,14 +692,27 @@ def test_{{ service.name|snake_case }}_grpc_lro_client():
692692
{% endif -%}
693693

694694
{% for message in service.resource_messages -%}
695-
{% with molluscs = cycler("squid", "clam", "whelk", "octopus", "oyster", "nudibranch", "cuttlefish", "mussel", "winkle") -%}
695+
{% with molluscs = cycler("squid", "clam", "whelk", "octopus", "oyster", "nudibranch", "cuttlefish", "mussel", "winkle", "nautilus", "scallop", "abalone") -%}
696696
def test_{{ message.resource_type|snake_case }}_path():
697-
{% for arg in message.resource_path_args -%}
698-
{{ arg }} = "{{ molluscs.next() }}"
699-
{% endfor %}
700-
expected = "{{ message.resource_path }}".format({% for arg in message.resource_path_args %}{{ arg }}={{ arg }}, {% endfor %})
701-
actual = {{ service.client_name }}.{{ message.resource_type|snake_case }}_path({{message.resource_path_args|join(", ") }})
702-
assert expected == actual
697+
{% for arg in message.resource_path_args -%}
698+
{{ arg }} = "{{ molluscs.next() }}"
699+
{% endfor %}
700+
expected = "{{ message.resource_path }}".format({% for arg in message.resource_path_args %}{{ arg }}={{ arg }}, {% endfor %})
701+
actual = {{ service.client_name }}.{{ message.resource_type|snake_case }}_path({{message.resource_path_args|join(", ") }})
702+
assert expected == actual
703+
704+
705+
def test_parse_{{ message.resource_type|snake_case }}_path():
706+
expected = {
707+
{% for arg in message.resource_path_args -%}
708+
"{{ arg }}": "{{ molluscs.next() }}",
709+
{% endfor %}
710+
}
711+
path = {{ service.client_name }}.{{ message.resource_type|snake_case }}_path(**expected)
712+
713+
# Check that the path construction is reversible.
714+
actual = {{ service.client_name }}.parse_{{ message.resource_type|snake_case }}_path(path)
715+
assert expected == actual
703716

704717
{% endwith -%}
705718
{% endfor -%}

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# limitations under the License.
1414

1515
import collections
16+
import re
1617
from typing import Sequence, Tuple
1718

1819
import pytest
@@ -181,6 +182,46 @@ def test_resource_path():
181182
assert message.resource_type == "Class"
182183

183184

185+
def test_parse_resource_path():
186+
options = descriptor_pb2.MessageOptions()
187+
resource = options.Extensions[resource_pb2.resource]
188+
resource.pattern.append(
189+
"kingdoms/{kingdom}/phyla/{phylum}/classes/{klass}"
190+
)
191+
resource.type = "taxonomy.biology.com/Klass"
192+
message = make_message('Klass', options=options)
193+
194+
# Plausible resource ID path
195+
path = "kingdoms/animalia/phyla/mollusca/classes/cephalopoda"
196+
expected = {
197+
'kingdom': 'animalia',
198+
'phylum': 'mollusca',
199+
'klass': 'cephalopoda',
200+
}
201+
actual = re.match(message.path_regex_str, path).groupdict()
202+
203+
assert expected == actual
204+
205+
options2 = descriptor_pb2.MessageOptions()
206+
resource2 = options2.Extensions[resource_pb2.resource]
207+
resource2.pattern.append(
208+
"kingdoms-{kingdom}_{phylum}#classes%{klass}"
209+
)
210+
resource2.type = "taxonomy.biology.com/Klass"
211+
message2 = make_message('Klass', options=options2)
212+
213+
# Plausible resource ID path from a non-standard schema
214+
path2 = "kingdoms-Animalia/_Mollusca~#classes%Cephalopoda"
215+
expected2 = {
216+
'kingdom': 'Animalia/',
217+
'phylum': 'Mollusca~',
218+
'klass': 'Cephalopoda',
219+
}
220+
actual2 = re.match(message2.path_regex_str, path2).groupdict()
221+
222+
assert expected2 == actual2
223+
224+
184225
def test_field_map():
185226
# Create an Entry message.
186227
entry_msg = make_message(

0 commit comments

Comments
 (0)