8000 feat: add copy_from method for field assignment (#215) · renovate-bot/proto-plus-python@11c3e58 · GitHub
[go: up one dir, main page]

Skip to content

Commit 11c3e58

Browse files
authored
feat: add copy_from method for field assignment (googleapis#215)
Because of implementation detatils of the underlying protocol buffers runtime, assigning to a proto-plus message field is achieved by copying, not by updating references. This can lead to surprising and unintuitive behavior for developers who are expecting python object behavior, e.g. reference aliases. This PR adds a 'Message.copy_from' method that is semantically identical to regular assignment. This can be used at the discretion of the developer to clarify expected behavior.
1 parent 521f33d commit 11c3e58

File tree

4 files changed

+124
-2
lines changed

4 files changed

+124
-2
lines changed

docs/messages.rst

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,74 @@ Instantiate messages using either keyword arguments or a :class:`dict`
8080
>>> song.title
8181
'Canon in D'
8282
83+
84+
Assigning to Fields
85+
-------------------
86+
87+
One of the goals of proto-plus is to make protobufs feel as much like regular python
88+
objects as possible. It is possible to update a message's field by assigning to it,
89+
just as if it were a regular python object.
90+
91+
.. code-block:: python
92+
93+
song = Song()
94+
song.composer = Composer(given_name="Johann", family_name="Bach")
95+
96+
# Can also assign from a dictionary as a convenience.
97+
song.composer = {"given_name": "Claude", "family_name": "Debussy"}
98+
99+
# Repeated fields can also be assigned
100+
class Album(proto.Message):
101+
songs = proto.RepeatedField(Song, number=1)
102+
103+
a = Album()
104+
songs = [Song(title="Canon in D"), Song(title="Little Fugue")]
105+
a.songs = songs
106+
107+
.. note::
108+
109+
Assigning to a proto-plus message field works by making copies, not by updating references.
110+
This is necessary because of memory layout requirements of protocol buffers.
111+
These memory constraints are maintained by the protocol buffers runtime.
112+
This behavior can be surprising under certain circumstances, e.g. trying to save
113+
an alias to a nested field.
114+
115+
:class:`proto.Message` defines a helper message, :meth:`~.Message.copy_from` to
116+
help make the distinction clear when reading code.
117+
The semantics of :meth:`~.Message.copy_from` are identical to the field assignment behavior described above.
118+
119+
.. code-block:: python
120+
121+
composer = Composer(given_name="Johann", family_name="Bach")
122+
song = Song(title="Tocatta and Fugue in D Minor", composer=composer)
123+
composer.given_name = "Wilhelm"
124+
125+
# 'composer' is NOT a reference to song.composer
126+
assert song.composer.given_name == "Johann"
127+
128+
# We CAN update the song's composer by assignment.
129+
song.composer = composer
130+
composer.given_name = "Carl"
131+
132+
# 'composer' is STILL not a referene to song.composer.
133+
assert song.composer.given_name == "Wilhelm"
134+
135+
# It does work in reverse, though,
136+
# if we want a reference we can access then update.
137+
composer = song.composer
138+
composer.given_name = "Gottfried"
139+
140+
assert song.composer.given_name == "Gottfried"
141+
142+
# We can use 'copy_from' if we're concerned that the code
143+
# implies that assignment involves references.
144+
composer = Composer(given_name="Elisabeth", family_name="Bach")
145+
# We could also do Message.copy_from(song.composer, composer) instead.
146+
Composer.copy_from(song.composer, composer)
147+
148+
assert song.composer.given_name == "Elisabeth"
149+
150+
83151
Enums
84152
-----
85153

docs/reference/message.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Message and Field
1111
.. automethod:: to_json
1212
.. automethod:: from_json
1313
.. automethod:: to_dict
14-
14+
.. automethod:: copy_from
1515

1616
.. automodule:: proto.fields
1717
:members:

proto/message.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,36 @@ def to_dict(
401401
use_integers_for_enums=use_integers_for_enums,
402402
)
403403

404+
def copy_from(cls, instance, other):
405+
"""Equivalent for protobuf.Message.CopyFrom
406+
407+
Args:
408+
instance: An instance of this message type
409+
other: (Union[dict, ~.Message):
410+
A dictionary or message to reinitialize the values for this message.
411+
"""
412+
if isinstance(other, cls):
413+
# Just want the underlying proto.
414+
other = Message.pb(other)
415+
elif isinstance(other, cls.pb()):
< A3D4 code>416+
# Don't need to do anything.
417+
pass
418+
elif isinstance(other, collections.abc.Mapping):
419+
# Coerce into a proto
420+
other = cls._meta.pb(**other)
421+
else:
422+
raise TypeError(
423+
"invalid argument type to copy to {}: {}".format(
424+
cls.__name__, other.__class__.__name__
425+
)
426+
)
427+
428+
# Note: we can't just run self.__init__ because this may be a message field
429+
# for a higher order proto; the memory layout for protos is NOT LIKE the
430+
# python memory model. We cannot rely on just setting things by reference.
431+
# Non-trivial complexity is (partially) hidden by the protobuf runtime.
432+
cls.pb(instance).CopyFrom(other)
433+
404434

405435
class Message(metaclass=MessageMeta):
406436
"""The abstract base class for a message.
@@ -436,7 +466,7 @@ def __init__(self, mapping=None, *, ignore_unknown_fields=False, **kwargs):
436466
#
437467
# The `wrap` method on the metaclass is the public API for taking
438468
# ownership of the passed in protobuf objet.
439-
mapping = copy.copy(mapping)
469+
mapping = copy.deepcopy(mapping)
440470
if kwargs:
441471
mapping.MergeFrom(self._meta.pb(**kwargs))
442472

tests/test_message.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,3 +317,27 @@ class Squid(proto.Message):
317317

318318
s = Squid({"mass_kg": 20, "length_cm": 100}, ignore_unknown_fields=True)
319319
assert not hasattr(s, "length_cm")
320+
321+
322+
def test_copy_from():
323+
class Mollusc(proto.Message):
324+
class Squid(proto.Message):
325+
mass_kg = proto.Field(proto.INT32, number=1)
326+
327+
squid = proto.Field(Squid, number=1)
328+
329+
m = Mollusc()
330+
s = Mollusc.Squid(mass_kg=20)
331+
Mollusc.Squid.copy_from(m.squid, s)
332+
assert m.squid is not s
333+
assert m.squid == s
334+
335+
s.mass_kg = 30
336+
Mollusc.Squid.copy_from(m.squid, Mollusc.Squid.pb(s))
337+
assert m.squid == s
338+
339+
Mollusc.Squid.copy_from(m.squid, {"mass_kg": 10})
340+
assert m.squid.mass_kg == 10
341+
342+
with pytest.raises(TypeError):
343+
Mollusc.Squid.copy_from(m.squid, (("mass_kg", 20)))

0 commit comments

Comments
 (0)
0