Skip to content

Commit ccf903e

Browse files
authored
feat: provide a to_dict method (googleapis#154)
Parallels the `to_json` method. Also adds options to ignore unknown fields during deserializtion. Closes googleapis#153 Closes googleapis#151
1 parent cd9003c commit ccf903e

6 files changed

Lines changed: 146 additions & 15 deletions

File tree

docs/messages.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,13 @@ via the :meth:`~.Message.to_json` and :meth:`~.Message.from_json` methods.
165165
166166
new_song = Song.from_json(json)
167167
168+
Similarly, messages can be converted into dictionaries via the
169+
:meth:`~.Message.to_dict` helper method.
170+
There is no :meth:`~.Message.from_dict` method because the Message constructor
171+
already allows construction from mapping types.
172+
173+
.. code-block:: python
174+
175+
song_dict = Song.to_dict(song)
176+
177+
new_song = Song(song_dict)

docs/reference/message.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Message and Field
1010
.. automethod:: deserialize
1111
.. automethod:: to_json
1212
.. automethod:: from_json
13+
.. automethod:: to_dict
1314

1415

1516
.. automodule:: proto.fields

noxfile.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ def unitcpp(session):
4848
return unit(session, proto="cpp")
4949

5050

51-
@nox.session(python="3.6")
51+
@nox.session(python="3.7")
5252
def docs(session):
5353
"""Build the docs."""
5454

proto/message.py

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
from google.protobuf import descriptor_pb2
2222
from google.protobuf import message
23-
from google.protobuf.json_format import MessageToJson, Parse
23+
from google.protobuf.json_format import MessageToDict, MessageToJson, Parse
2424

2525
from proto import _file_info
2626
from proto import _package_info
@@ -347,39 +347,65 @@ def to_json(cls, instance, *, use_integers_for_enums=True) -> str:
347347
including_default_value_fields=True,
348348
)
349349

350-
def from_json(cls, payload) -> "Message":
350+
def from_json(cls, payload, *, ignore_unknown_fields=False) -> "Message":
351351
"""Given a json string representing an instance,
352352
parse it into a message.
353353
354354
Args:
355355
paylod: A json string representing a message.
356+
ignore_unknown_fields (Optional(bool)): If True, do not raise errors
357+
for unknown fields.
356358
357359
Returns:
358360
~.Message: An instance of the message class against which this
359361
method was called.
360362
"""
361363
instance = cls()
362-
Parse(payload, instance._pb)
364+
Parse(payload, instance._pb, ignore_unknown_fields=ignore_unknown_fields)
363365
return instance
364366

367+
def to_dict(cls, instance, *, use_integers_for_enums=True) -> "Message":
368+
"""Given a message instance, return its representation as a python dict.
369+
370+
Args:
371+
instance: An instance of this message type, or something
372+
compatible (accepted by the type's constructor).
373+
use_integers_for_enums (Optional(bool)): An option that determines whether enum
374+
values should be represented by strings (False) or integers (True).
375+
Default is True.
376+
377+
Returns:
378+
dict: A representation of the protocol buffer using pythonic data structures.
379+
Messages and map fields are represented as dicts,
380+
repeated fields are represented as lists.
381+
"""
382+
return MessageToDict(
383+
cls.pb(instance),
384+
including_default_value_fields=True,
385+
preserving_proto_field_name=True,
386+
use_integers_for_enums=use_integers_for_enums,
387+
)
388+
365389

366390
class Message(metaclass=MessageMeta):
367391
"""The abstract base class for a message.
368392
369393
Args:
370394
mapping (Union[dict, ~.Message]): A dictionary or message to be
371395
used to determine the values for this message.
396+
ignore_unknown_fields (Optional(bool)): If True, do not raise errors for
397+
unknown fields. Only applied if `mapping` is a mapping type or there
398+
are keyword parameters.
372399
kwargs (dict): Keys and values corresponding to the fields of the
373400
message.
374401
"""
375402

376-
def __init__(self, mapping=None, **kwargs):
403+
def __init__(self, mapping=None, *, ignore_unknown_fields=False, **kwargs):
377404
# We accept several things for `mapping`:
378405
# * An instance of this class.
379406
# * An instance of the underlying protobuf descriptor class.
380407
# * A dict
381408
# * Nothing (keyword arguments only).
382-
383409
if mapping is None:
384410
if not kwargs:
385411
# Special fast path for empty construction.
@@ -405,24 +431,33 @@ def __init__(self, mapping=None, **kwargs):
405431
# Just use the above logic on mapping's underlying pb.
406432
self.__init__(mapping=mapping._pb, **kwargs)
407433
return
408-
elif not isinstance(mapping, collections.abc.Mapping):
434+
elif isinstance(mapping, collections.abc.Mapping):
435+
# Can't have side effects on mapping.
436+
mapping = copy.copy(mapping)
437+
# kwargs entries take priority for duplicate keys.
438+
mapping.update(kwargs)
439+
else:
409440
# Sanity check: Did we get something not a map? Error if so.
410441
raise TypeError(
411442
"Invalid constructor input for %s: %r"
412443
% (self.__class__.__name__, mapping,)
413444
)
414-
else:
415-
# Can't have side effects on mapping.
416-
mapping = copy.copy(mapping)
417-
# kwargs entries take priority for duplicate keys.
418-
mapping.update(kwargs)
419445

420446
params = {}
421447
# Update the mapping to address any values that need to be
422448
# coerced.
423449
marshal = self._meta.marshal
424450
for key, value in mapping.items():
425-
pb_type = self._meta.fields[key].pb_type
451+
try:
452+
pb_type = self._meta.fields[key].pb_type
453+
except KeyError:
454+
if ignore_unknown_fields:
455+
continue
456+
457+
raise ValueError(
458+
"Unknown field for {}: {}".format(self.__class__.__name__, key)
459+
)
460+
426461
pb_value = marshal.to_proto(pb_type, value)
427462
if pb_value is not None:
428463
params[key] = pb_value

tests/test_json.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import pytest
1616

1717
import proto
18-
from google.protobuf.json_format import MessageToJson, Parse
18+
from google.protobuf.json_format import MessageToJson, Parse, ParseError
1919

2020

2121
def test_message_to_json():
@@ -34,7 +34,7 @@ class Squid(proto.Message):
3434

3535
json = """{
3636
"massKg": 100
37-
}
37+
}
3838
"""
3939

4040
s = Squid.from_json(json)
@@ -95,3 +95,21 @@ class Zone(proto.Enum):
9595
.replace("\n", "")
9696
)
9797
assert json2 == '{"zone":"EPIPELAGIC"}'
98+
99+
100+
def test_json_unknown_field():
101+
# Note that 'lengthCm' is unknown in the local definition.
102+
# This could happen if the client is using an older proto definition
103+
# than the server.
104+
json_str = '{\n "massKg": 20,\n "lengthCm": 100\n}'
105+
106+
class Octopus(proto.Message):
107+
mass_kg = proto.Field(proto.INT32, number=1)
108+
109+
o = Octopus.from_json(json_str, ignore_unknown_fields=True)
110+
assert not hasattr(o, "length_cm")
111+
assert not hasattr(o, "lengthCm")
112+
113+
# Don't permit unknown fields by default
114+
with pytest.raises(ParseError):
115+
o = Octopus.from_json(json_str)

tests/test_message.py

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

15+
import itertools
1516
import pytest
1617

1718
import proto
@@ -228,3 +229,69 @@ class Squid(proto.Message):
228229
s1._pb = s2._pb
229230

230231
assert s1.mass_kg == 20
232+
233+
234+
def test_serialize_to_dict():
235+
class Squid(proto.Message):
236+
# Test primitives, enums, and repeated fields.
237+
class Chromatophore(proto.Message):
238+
class Color(proto.Enum):
239+
UNKNOWN = 0
240+
RED = 1
241+
BROWN = 2
242+
WHITE = 3
243+
BLUE = 4
244+
245+
color = proto.Field(Color, number=1)
246+
247+
mass_kg = proto.Field(proto.INT32, number=1)
248+
chromatophores = proto.RepeatedField(Chromatophore, number=2)
249+
250+
s = Squid(mass_kg=20)
251+
colors = ["RED", "BROWN", "WHITE", "BLUE"]
252+
s.chromatophores = [
253+
{"color": c} for c in itertools.islice(itertools.cycle(colors), 10)
254+
]
255+
256+
s_dict = Squid.to_dict(s)
257+
assert s_dict["chromatophores"][0]["color"] == 1
258+
259+
new_s = Squid(s_dict)
260+
assert new_s == s
261+
262+
s_dict = Squid.to_dict(s, use_integers_for_enums=False)
263+
assert s_dict["chromatophores"][0]["color"] == "RED"
264+
265+
new_s = Squid(s_dict)
266+
assert new_s == s
267+
268+
269+
def test_unknown_field_deserialize():
270+
# This is a somewhat common setup: a client uses an older proto definition,
271+
# while the server sends the newer definition. The client still needs to be
272+
# able to interact with the protos it receives from the server.
273+
274+
class Octopus_Old(proto.Message):
275+
mass_kg = proto.Field(proto.INT32, number=1)
276+
277+
class Octopus_New(proto.Message):
278+
mass_kg = proto.Field(proto.INT32, number=1)
279+
length_cm = proto.Field(proto.INT32, number=2)
280+
281+
o_new = Octopus_New(mass_kg=20, length_cm=100)
282+
o_ser = Octopus_New.serialize(o_new)
283+
284+
o_old = Octopus_Old.deserialize(o_ser)
285+
assert not hasattr(o_old, "length_cm")
286+
287+
288+
def test_unknown_field_from_dict():
289+
class Squid(proto.Message):
290+
mass_kg = proto.Field(proto.INT32, number=1)
291+
292+
# By default we don't permit unknown fields
293+
with pytest.raises(ValueError):
294+
s = Squid({"mass_kg": 20, "length_cm": 100})
295+
296+
s = Squid({"mass_kg": 20, "length_cm": 100}, ignore_unknown_fields=True)
297+
assert not hasattr(s, "length_cm")

0 commit comments

Comments
 (0)