Skip to content

Commit 0318550

Browse files
author
Jon Wayne Parrott
authored
Add final set of protobuf helpers to api_core (#4259)
1 parent e053eb8 commit 0318550

File tree

2 files changed

+340
-4
lines changed

2 files changed

+340
-4
lines changed

google/api_core/protobuf_helpers.py

Lines changed: 175 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919

2020
from google.protobuf.message import Message
2121

22+
_SENTINEL = object()
23+
2224

2325
def from_any_pb(pb_type, any_pb):
2426
"""Converts an ``Any`` protobuf to the specified message type.
@@ -44,11 +46,13 @@ def from_any_pb(pb_type, any_pb):
4446

4547

4648
def check_oneof(**kwargs):
47-
"""Raise ValueError if more than one keyword argument is not none.
49+
"""Raise ValueError if more than one keyword argument is not ``None``.
50+
4851
Args:
4952
kwargs (dict): The keyword arguments sent to the function.
53+
5054
Raises:
51-
ValueError: If more than one entry in kwargs is not none.
55+
ValueError: If more than one entry in ``kwargs`` is not ``None``.
5256
"""
5357
# Sanity check: If no keyword arguments were sent, this is fine.
5458
if not kwargs:
@@ -62,10 +66,12 @@ def check_oneof(**kwargs):
6266

6367

6468
def get_messages(module):
65-
"""Return a dictionary of message names and objects.
69+
"""Discovers all protobuf Message classes in a given import module.
70+
6671
Args:
67-
module (module): A Python module; dir() will be run against this
72+
module (module): A Python module; :func:`dir` will be run against this
6873
module to find Message subclasses.
74+
6975
Returns:
7076
dict[str, Message]: A dictionary with the Message class names as
7177
keys, and the Message subclasses themselves as values.
@@ -76,3 +82,168 @@ def get_messages(module):
7682
if inspect.isclass(candidate) and issubclass(candidate, Message):
7783
answer[name] = candidate
7884
return answer
85+
86+
87+
def _resolve_subkeys(key, separator='.'):
88+
"""Resolve a potentially nested key.
89+
90+
If the key contains the ``separator`` (e.g. ``.``) then the key will be
91+
split on the first instance of the subkey::
92+
93+
>>> _resolve_subkeys('a.b.c')
94+
('a', 'b.c')
95+
>>> _resolve_subkeys('d|e|f', separator='|')
96+
('d', 'e|f')
97+
98+
If not, the subkey will be :data:`None`::
99+
100+
>>> _resolve_subkeys('foo')
101+
('foo', None)
102+
103+
Args:
104+
key (str): A string that may or may not contain the separator.
105+
separator (str): The namespace separator. Defaults to `.`.
106+
107+
Returns:
108+
Tuple[str, str]: The key and subkey(s).
109+
"""
110+
parts = key.split(separator, 1)
111+
112+
if len(parts) > 1:
113+
return parts
114+
else:
115+
return parts[0], None
116+
117+
118+
def get(msg_or_dict, key, default=_SENTINEL):
119+
"""Retrieve a key's value from a protobuf Message or dictionary.
120+
121+
Args:
122+
mdg_or_dict (Union[~google.protobuf.message.Message, Mapping]): the
123+
object.
124+
key (str): The key to retrieve from the object.
125+
default (Any): If the key is not present on the object, and a default
126+
is set, returns that default instead. A type-appropriate falsy
127+
default is generally recommended, as protobuf messages almost
128+
always have default values for unset values and it is not always
129+
possible to tell the difference between a falsy value and an
130+
unset one. If no default is set then :class:`KeyError` will be
131+
raised if the key is not present in the object.
132+
133+
Returns:
134+
Any: The return value from the underlying Message or dict.
135+
136+
Raises:
137+
KeyError: If the key is not found. Note that, for unset values,
138+
messages and dictionaries may not have consistent behavior.
139+
TypeError: If ``msg_or_dict`` is not a Message or Mapping.
140+
"""
141+
# We may need to get a nested key. Resolve this.
142+
key, subkey = _resolve_subkeys(key)
143+
144+
# Attempt to get the value from the two types of objects we know about.
145+
# If we get something else, complain.
146+
if isinstance(msg_or_dict, Message):
147+
answer = getattr(msg_or_dict, key, default)
148+
elif isinstance(msg_or_dict, collections.Mapping):
149+
answer = msg_or_dict.get(key, default)
150+
else:
151+
raise TypeError(
152+
'get() expected a dict or protobuf message, got {!r}.'.format(
153+
type(msg_or_dict)))
154+
155+
# If the object we got back is our sentinel, raise KeyError; this is
156+
# a "not found" case.
157+
if answer is _SENTINEL:
158+
raise KeyError(key)
159+
160+
# If a subkey exists, call this method recursively against the answer.
161+
if subkey is not None and answer is not default:
162+
return get(answer, subkey, default=default)
163+
164+
return answer
165+
166+
167+
def _set_field_on_message(msg, key, value):
168+
"""Set helper for protobuf Messages."""
169+
# Attempt to set the value on the types of objects we know how to deal
170+
# with.
171+
if isinstance(value, (collections.MutableSequence, tuple)):
172+
# Clear the existing repeated protobuf message of any elements
173+
# currently inside it.
174+
while getattr(msg, key):
175+
getattr(msg, key).pop()
176+
177+
# Write our new elements to the repeated field.
178+
for item in value:
179+
if isinstance(item, collections.Mapping):
180+
getattr(msg, key).add(**item)
181+
else:
182+
# protobuf's RepeatedCompositeContainer doesn't support
183+
# append.
184+
getattr(msg, key).extend([item])
185+
elif isinstance(value, collections.Mapping):
186+
# Assign the dictionary values to the protobuf message.
187+
for item_key, item_value in value.items():
188+
set(getattr(msg, key), item_key, item_value)
189+
elif isinstance(value, Message):
190+
getattr(msg, key).CopyFrom(value)
191+
else:
192+
setattr(msg, key, value)
193+
194+
195+
def set(msg_or_dict, key, value):
196+
"""Set a key's value on a protobuf Message or dictionary.
197+
198+
Args:
199+
msg_or_dict (Union[~google.protobuf.message.Message, Mapping]): the
200+
object.
201+
key (str): The key to set.
202+
value (Any): The value to set.
203+
204+
Raises:
205+
TypeError: If ``msg_or_dict`` is not a Message or dictionary.
206+
"""
207+
# Sanity check: Is our target object valid?
208+
if not isinstance(msg_or_dict, (collections.MutableMapping, Message)):
209+
raise TypeError(
210+
'set() expected a dict or protobuf message, got {!r}.'.format(
211+
type(msg_or_dict)))
212+
213+
# We may be setting a nested key. Resolve this.
214+
basekey, subkey = _resolve_subkeys(key)
215+
216+
# If a subkey exists, then get that object and call this method
217+
# recursively against it using the subkey.
218+
if subkey is not None:
219+
if isinstance(msg_or_dict, collections.MutableMapping):
220+
msg_or_dict.setdefault(basekey, {})
221+
set(get(msg_or_dict, basekey), subkey, value)
222+
return
223+
224+
if isinstance(msg_or_dict, collections.MutableMapping):
225+
msg_or_dict[key] = value
226+
else:
227+
_set_field_on_message(msg_or_dict, key, value)
228+
229+
230+
def setdefault(msg_or_dict, key, value):
231+
"""Set the key on a protobuf Message or dictionary to a given value if the
232+
current value is falsy.
233+
234+
Because protobuf Messages do not distinguish between unset values and
235+
falsy ones particularly well (by design), this method treats any falsy
236+
value (e.g. 0, empty list) as a target to be overwritten, on both Messages
237+
and dictionaries.
238+
239+
Args:
240+
msg_or_dict (Union[~google.protobuf.message.Message, Mapping]): the
241+
object.
242+
key (str): The key on the object in question.
243+
value (Any): The value to set.
244+
245+
Raises:
246+
TypeError: If ``msg_or_dict`` is not a Message or dictionary.
247+
"""
248+
if not get(msg_or_dict, key, default=None):
249+
set(msg_or_dict, key, value)

tests/unit/test_protobuf_helpers.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,11 @@
1414

1515
import pytest
1616

17+
from google.api import http_pb2
1718
from google.api_core import protobuf_helpers
19+
from google.longrunning import operations_pb2
1820
from google.protobuf import any_pb2
21+
from google.protobuf import timestamp_pb2
1922
from google.protobuf.message import Message
2023
from google.type import date_pb2
2124
from google.type import timeofday_pb2
@@ -65,3 +68,165 @@ def test_get_messages():
6568
# Ensure that no non-Message objects were exported.
6669
for value in answer.values():
6770
assert issubclass(value, Message)
71+
72+
73+
def test_get_dict_absent():
74+
with pytest.raises(KeyError):
75+
assert protobuf_helpers.get({}, 'foo')
76+
77+
78+
def test_get_dict_present():
79+
assert protobuf_helpers.get({'foo': 'bar'}, 'foo') == 'bar'
80+
81+
82+
def test_get_dict_default():
83+
assert protobuf_helpers.get({}, 'foo', default='bar') == 'bar'
84+
85+
86+
def test_get_dict_nested():
87+
assert protobuf_helpers.get({'foo': {'bar': 'baz'}}, 'foo.bar') == 'baz'
88+
89+
90+
def test_get_dict_nested_default():
91+
assert protobuf_helpers.get({}, 'foo.baz', default='bacon') == 'bacon'
92+
assert (
93+
protobuf_helpers.get({'foo': {}}, 'foo.baz', default='bacon') ==
94+
'bacon')
95+
96+
97+
def test_get_msg_sentinel():
98+
msg = timestamp_pb2.Timestamp()
99+
with pytest.raises(KeyError):
100+
assert protobuf_helpers.get(msg, 'foo')
101+
102+
103+
def test_get_msg_present():
104+
msg = timestamp_pb2.Timestamp(seconds=42)
105+
assert protobuf_helpers.get(msg, 'seconds') == 42
106+
107+
108+
def test_get_msg_default():
109+
msg = timestamp_pb2.Timestamp()
110+
assert protobuf_helpers.get(msg, 'foo', default='bar') == 'bar'
111+
112+
113+
def test_invalid_object():
114+
with pytest.raises(TypeError):
115+
protobuf_helpers.get(object(), 'foo', 'bar')
116+
117+
118+
def test_set_dict():
119+
mapping = {}
120+
protobuf_helpers.set(mapping, 'foo', 'bar')
121+
assert mapping == {'foo': 'bar'}
122+
123+
124+
def test_set_msg():
125+
msg = timestamp_pb2.Timestamp()
126+
protobuf_helpers.set(msg, 'seconds', 42)
127+
assert msg.seconds == 42
128+
129+
130+
def test_set_dict_nested():
131+
mapping = {}
132+
protobuf_helpers.set(mapping, 'foo.bar', 'baz')
133+
assert mapping == {'foo': {'bar': 'baz'}}
134+
135+
136+
def test_set_invalid_object():
137+
with pytest.raises(TypeError):
138+
protobuf_helpers.set(object(), 'foo', 'bar')
139+
140+
141+
def test_set_list():
142+
list_ops_response = operations_pb2.ListOperationsResponse()
143+
144+
protobuf_helpers.set(list_ops_response, 'operations', [
145+
{'name': 'foo'},
146+
operations_pb2.Operation(name='bar'),
147+
])
148+
149+
assert len(list_ops_response.operations) == 2
150+
151+
for operation in list_ops_response.operations:
152+
assert isinstance(operation, operations_pb2.Operation)
153+
154+
assert list_ops_response.operations[0].name == 'foo'
155+
assert list_ops_response.operations[1].name == 'bar'
156+
157+
158+
def test_set_list_clear_existing():
159+
list_ops_response = operations_pb2.ListOperationsResponse(
160+
operations=[{'name': 'baz'}],
161+
)
162+
163+
protobuf_helpers.set(list_ops_response, 'operations', [
164+
{'name': 'foo'},
165+
operations_pb2.Operation(name='bar'),
166+
])
167+
168+
assert len(list_ops_response.operations) == 2
169+
for operation in list_ops_response.operations:
170+
assert isinstance(operation, operations_pb2.Operation)
171+
assert list_ops_response.operations[0].name == 'foo'
172+
assert list_ops_response.operations[1].name == 'bar'
173+
174+
175+
def test_set_msg_with_msg_field():
176+
rule = http_pb2.HttpRule()
177+
pattern = http_pb2.CustomHttpPattern(kind='foo', path='bar')
178+
179+
protobuf_helpers.set(rule, 'custom', pattern)
180+
181+
assert rule.custom.kind == 'foo'
182+
assert rule.custom.path == 'bar'
183+
184+
185+
def test_set_msg_with_dict_field():
186+
rule = http_pb2.HttpRule()
187+
pattern = {'kind': 'foo', 'path': 'bar'}
188+
189+
protobuf_helpers.set(rule, 'custom', pattern)
190+
191+
assert rule.custom.kind == 'foo'
192+
assert rule.custom.path == 'bar'
193+
194+
195+
def test_set_msg_nested_key():
196+
rule = http_pb2.HttpRule(
197+
custom=http_pb2.CustomHttpPattern(kind='foo', path='bar'))
198+
199+
protobuf_helpers.set(rule, 'custom.kind', 'baz')
200+
201+
assert rule.custom.kind == 'baz'
202+
assert rule.custom.path == 'bar'
203+
204+
205+
def test_setdefault_dict_unset():
206+
mapping = {}
207+
protobuf_helpers.setdefault(mapping, 'foo', 'bar')
208+
assert mapping == {'foo': 'bar'}
209+
210+
211+
def test_setdefault_dict_falsy():
212+
mapping = {'foo': None}
213+
protobuf_helpers.setdefault(mapping, 'foo', 'bar')
214+
assert mapping == {'foo': 'bar'}
215+
216+
217+
def test_setdefault_dict_truthy():
218+
mapping = {'foo': 'bar'}
219+
protobuf_helpers.setdefault(mapping, 'foo', 'baz')
220+
assert mapping == {'foo': 'bar'}
221+
222+
223+
def test_setdefault_pb2_falsy():
224+
operation = operations_pb2.Operation()
225+
protobuf_helpers.setdefault(operation, 'name', 'foo')
226+
assert operation.name == 'foo'
227+
228+
229+
def test_setdefault_pb2_truthy():
230+
operation = operations_pb2.Operation(name='bar')
231+
protobuf_helpers.setdefault(operation, 'name', 'foo')
232+
assert operation.name == 'bar'

0 commit comments

Comments
 (0)