diff --git a/.github/sync-repo-settings.yaml b/.github/sync-repo-settings.yaml index a1cc91e1..d5b332af 100644 --- a/.github/sync-repo-settings.yaml +++ b/.github/sync-repo-settings.yaml @@ -7,7 +7,13 @@ branchProtectionRules: requiredStatusCheckContexts: - 'style-check' - 'docs' - - 'unit' + - 'unit (3.6)' + - 'unit (3.6, cpp)' + - 'unit (3.7)' + - 'unit (3.7, cpp)' + - 'unit (3.8)' + # - 'unit (3.9, cpp)' # Don't have binary wheels for 3.9 cpp protobuf yet + - 'unit (3.9)' - 'cla/google' requiredApprovingReviewCount: 1 requiresCodeOwnerReviews: true diff --git a/.github/workflows/pypi-upload.yaml b/.github/workflows/pypi-upload.yaml new file mode 100644 index 00000000..05545359 --- /dev/null +++ b/.github/workflows/pypi-upload.yaml @@ -0,0 +1,24 @@ +name: Upload Python Package to PyPI + +on: + release: + types: [created] + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' + - name: Install dependencies + run: python -m pip install twine wheel + - name: Package and upload modulee + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python setup.py sdist bdist_wheel + twine upload dist/* diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3be96ea0..93d7308a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,6 +13,10 @@ jobs: style-check: runs-on: ubuntu-latest steps: + - name: Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.9.0 + with: + access_token: ${{ github.token }} - uses: actions/checkout@v2 - name: Set up Python 3.8 uses: actions/setup-python@v2 @@ -25,6 +29,10 @@ jobs: docs: runs-on: ubuntu-latest steps: + - name: Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.9.0 + with: + access_token: ${{ github.token }} - uses: actions/checkout@v2 - name: Set up Python 3.8 uses: actions/setup-python@v2 @@ -40,11 +48,11 @@ jobs: matrix: python: [3.6, 3.7, 3.8, 3.9] variant: ['', cpp] - # Note: as of 2021-02-09, there are no 3.9 python wheels for protobuf/grpc - exclude: - - python: 3.9 - variant: cpp steps: + - name: Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.9.0 + with: + access_token: ${{ github.token }} - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 00a9e65d..eaf77f1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,81 @@ # Changelog +## [1.19.0](https://www.github.com/googleapis/proto-plus-python/compare/v1.18.1...v1.19.0) (2021-06-29) + + +### Features + +* pass 'including_default_value_fields' through to 'Message.to_dict' method ([#232](https://www.github.com/googleapis/proto-plus-python/issues/232)) ([15c2f47](https://www.github.com/googleapis/proto-plus-python/commit/15c2f479f81f0f80d451ca9b043e42cecfe7184e)) + +### [1.18.1](https://www.github.com/googleapis/proto-plus-python/compare/v1.18.0...v1.18.1) (2021-03-19) + + +### Bug Fixes + +* Add arm64 support for PY3.6 ([#219](https://www.github.com/googleapis/proto-plus-python/issues/219)) ([c9667c2](https://www.github.com/googleapis/proto-plus-python/commit/c9667c22d0b8f6026dbf69d502eb8eb972279891)) + +## [1.18.0](https://www.github.com/googleapis/proto-plus-python/compare/v1.17.0...v1.18.0) (2021-03-16) + + +### Features + +* add copy_from method for field assignment ([#215](https://www.github.com/googleapis/proto-plus-python/issues/215)) ([11c3e58](https://www.github.com/googleapis/proto-plus-python/commit/11c3e58a9ba59f0d7d808a26597dab735ca982ba)) + +## [1.17.0](https://www.github.com/googleapis/proto-plus-python/compare/v1.16.0...v1.17.0) (2021-03-12) + + +### Features + +* add preserving_proto_field_name to to_json ([#213](https://www.github.com/googleapis/proto-plus-python/issues/213)) ([b2c245b](https://www.github.com/googleapis/proto-plus-python/commit/b2c245bf044b964897f4e7423ff4944ae915e469)) + +## [1.16.0](https://www.github.com/googleapis/proto-plus-python/compare/v1.15.0...v1.16.0) (2021-03-12) + + +### Features + +* add preserving_proto_field_name passthrough in MessageMeta.to_dict ([#211](https://www.github.com/googleapis/proto-plus-python/issues/211)) ([7675a0c](https://www.github.com/googleapis/proto-plus-python/commit/7675a0c8d1004f2727d64100527f2b208d305017)) + +## [1.15.0](https://www.github.com/googleapis/proto-plus-python/compare/v1.14.3...v1.15.0) (2021-03-10) + + +### Features + +* allow_alias for enums ([#207](https://www.github.com/googleapis/proto-plus-python/issues/207)) ([6d4d713](https://www.github.com/googleapis/proto-plus-python/commit/6d4d71399f494b9f3bd47b6f3ef0b6d3c0c547b5)) + +### [1.14.3](https://www.github.com/googleapis/proto-plus-python/compare/v1.14.2...v1.14.3) (2021-03-04) + + +### Bug Fixes + +* adding enums to a repeated field does not raise a TypeError ([#202](https://www.github.com/googleapis/proto-plus-python/issues/202)) ([2a10bbe](https://www.github.com/googleapis/proto-plus-python/commit/2a10bbecaf8955c7bf1956086aef42630112788b)) + +### [1.14.2](https://www.github.com/googleapis/proto-plus-python/compare/v1.14.1...v1.14.2) (2021-02-26) + + +### Bug Fixes + +* use the correct environment for uploading to pypi ([#199](https://www.github.com/googleapis/proto-plus-python/issues/199)) ([babdc5c](https://www.github.com/googleapis/proto-plus-python/commit/babdc5cddf08235cac3cda66200babab44204688)) + +### [1.14.1](https://www.github.com/googleapis/proto-plus-python/compare/v1.14.0...v1.14.1) (2021-02-26) + + +### Bug Fixes + +* install the wheel dependency ([#197](https://www.github.com/googleapis/proto-plus-python/issues/197)) ([923ab31](https://www.github.com/googleapis/proto-plus-python/commit/923ab31e4685b47acae793198be55335e5eeae38)) + +## [1.14.0](https://www.github.com/googleapis/proto-plus-python/compare/v1.13.1...v1.14.0) (2021-02-24) + + +### Features + +* Pypi publish ghub actions ([#189](https://www.github.com/googleapis/proto-plus-python/issues/189)) ([4c967b0](https://www.github.com/googleapis/proto-plus-python/commit/4c967b0bb2ead29156bcc53c1f3b227b3afb2e8b)) + + +### Bug Fixes + +* proper __setitem__ and insert for RepeatedComposite ([#178](https://www.github.com/googleapis/proto-plus-python/issues/178)) ([1157a76](https://www.github.com/googleapis/proto-plus-python/commit/1157a76bb608d72389f46dc4d8e9aa00cc14ccc6)) +* proper native marshal for repeated enumeration fields ([#180](https://www.github.com/googleapis/proto-plus-python/issues/180)) ([30265d6](https://www.github.com/googleapis/proto-plus-python/commit/30265d654d7f3589cbd0994d2ac564db1fd44265)) + ### [1.13.1](https://www.github.com/googleapis/proto-plus-python/compare/v1.13.0...v1.13.1) (2021-02-09) diff --git a/README.rst b/README.rst index 140248c0..08ec2bd9 100644 --- a/README.rst +++ b/README.rst @@ -1,7 +1,7 @@ Proto Plus for Python ===================== -|pypi| |release level| |ci| |docs| |codecov| +|pypi| |release level| |docs| |codecov| Beautiful, Pythonic protocol buffers. @@ -26,7 +26,5 @@ Documentation :target: https://cloud.google.com/terms/launch-stages .. |docs| image:: https://readthedocs.org/projects/proto-plus-python/badge/?version=latest :target: https://proto-plus-python.readthedocs.io/en/latest/ -.. |ci| image:: https://circleci.com/gh/googleapis/proto-plus-python.svg?style=shield - :target: https://circleci.com/gh/googleapis/proto-plus-python .. |codecov| image:: https://codecov.io/gh/googleapis/proto-plus-python/graph/badge.svg :target: https://codecov.io/gh/googleapis/proto-plus-python diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..8b58ae9c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,7 @@ +# Security Policy + +To report a security issue, please use [g.co/vulnz](https://g.co/vulnz). + +The Google Security Team will respond within 5 working days of your report on g.co/vulnz. + +We use g.co/vulnz for our intake, and do coordination and disclosure here using GitHub Security Advisory to privately discuss and fix the issue. diff --git a/docs/messages.rst b/docs/messages.rst index af23ea9d..e7861b04 100644 --- a/docs/messages.rst +++ b/docs/messages.rst @@ -80,6 +80,74 @@ Instantiate messages using either keyword arguments or a :class:`dict` >>> song.title 'Canon in D' + +Assigning to Fields +------------------- + +One of the goals of proto-plus is to make protobufs feel as much like regular python +objects as possible. It is possible to update a message's field by assigning to it, +just as if it were a regular python object. + +.. code-block:: python + + song = Song() + song.composer = Composer(given_name="Johann", family_name="Bach") + + # Can also assign from a dictionary as a convenience. + song.composer = {"given_name": "Claude", "family_name": "Debussy"} + + # Repeated fields can also be assigned + class Album(proto.Message): + songs = proto.RepeatedField(Song, number=1) + + a = Album() + songs = [Song(title="Canon in D"), Song(title="Little Fugue")] + a.songs = songs + +.. note:: + + Assigning to a proto-plus message field works by making copies, not by updating references. + This is necessary because of memory layout requirements of protocol buffers. + These memory constraints are maintained by the protocol buffers runtime. + This behavior can be surprising under certain circumstances, e.g. trying to save + an alias to a nested field. + + :class:`proto.Message` defines a helper message, :meth:`~.Message.copy_from` to + help make the distinction clear when reading code. + The semantics of :meth:`~.Message.copy_from` are identical to the field assignment behavior described above. + + .. code-block:: python + + composer = Composer(given_name="Johann", family_name="Bach") + song = Song(title="Tocatta and Fugue in D Minor", composer=composer) + composer.given_name = "Wilhelm" + + # 'composer' is NOT a reference to song.composer + assert song.composer.given_name == "Johann" + + # We CAN update the song's composer by assignment. + song.composer = composer + composer.given_name = "Carl" + + # 'composer' is STILL not a referene to song.composer. + assert song.composer.given_name == "Wilhelm" + + # It does work in reverse, though, + # if we want a reference we can access then update. + composer = song.composer + composer.given_name = "Gottfried" + + assert song.composer.given_name == "Gottfried" + + # We can use 'copy_from' if we're concerned that the code + # implies that assignment involves references. + composer = Composer(given_name="Elisabeth", family_name="Bach") + # We could also do Message.copy_from(song.composer, composer) instead. + Composer.copy_from(song.composer, composer) + + assert song.composer.given_name == "Elisabeth" + + Enums ----- diff --git a/docs/reference/message.rst b/docs/reference/message.rst index 34da8376..436b0c81 100644 --- a/docs/reference/message.rst +++ b/docs/reference/message.rst @@ -11,7 +11,7 @@ Message and Field .. automethod:: to_json .. automethod:: from_json .. automethod:: to_dict - + .. automethod:: copy_from .. automodule:: proto.fields :members: diff --git a/noxfile.py b/noxfile.py index 0ed6caa7..1ca07cec 100644 --- a/noxfile.py +++ b/noxfile.py @@ -54,7 +54,7 @@ def unit(session, proto="python"): # Check if protobuf has released wheels for new python versions # https://pypi.org/project/protobuf/#files # This list will generally be shorter than 'unit' -@nox.session(python=["3.6", "3.7", "3.8"]) +@nox.session(python=["3.6", "3.7", "3.8", "3.9"]) def unitcpp(session): return unit(session, proto="cpp") diff --git a/proto/enums.py b/proto/enums.py index 22e8f42d..1fe8746f 100644 --- a/proto/enums.py +++ b/proto/enums.py @@ -47,6 +47,21 @@ def __new__(mcls, name, bases, attrs): filename = _file_info._FileInfo.proto_file_name( attrs.get("__module__", name.lower()) ) + + # Retrieve any enum options. + # We expect something that looks like an EnumOptions message, + # either an actual instance or a dict-like representation. + pb_options = "_pb_options" + opts = attrs.pop(pb_options, {}) + # This is the only portable way to remove the _pb_options name + # from the enum attrs. + # In 3.7 onwards, we can define an _ignore_ attribute and do some + # mucking around with that. + if pb_options in attrs._member_names: + idx = attrs._member_names.index(pb_options) + attrs._member_names.pop(idx) + + # Make the descriptor. enum_desc = descriptor_pb2.EnumDescriptorProto( name=name, # Note: the superclass ctor removes the variants, so get them now. @@ -60,6 +75,7 @@ def __new__(mcls, name, bases, attrs): ), key=lambda v: v.number, ), + options=opts, ) file_info = _file_info._FileInfo.maybe_add_descriptor(filename, package) diff --git a/proto/marshal/collections/repeated.py b/proto/marshal/collections/repeated.py index 30fa68d0..01b5d2fd 100644 --- a/proto/marshal/collections/repeated.py +++ b/proto/marshal/collections/repeated.py @@ -174,5 +174,5 @@ def __setitem__(self, key, value): def insert(self, index: int, value): """Insert ``value`` in the sequence before ``index``.""" - pb_value = self._marshal.to_proto(self._pb_type, value, strict=True) + pb_value = self._marshal.to_proto(self._pb_type, value) self.pb.insert(index, pb_value) diff --git a/proto/message.py b/proto/message.py index abb8f635..00ec4cc7 100644 --- a/proto/message.py +++ b/proto/message.py @@ -332,7 +332,8 @@ def to_json( instance, *, use_integers_for_enums=True, - including_default_value_fields=True + including_default_value_fields=True, + preserving_proto_field_name=False, ) -> str: """Given a message instance, serialize it to json @@ -342,6 +343,9 @@ def to_json( use_integers_for_enums (Optional(bool)): An option that determines whether enum values should be represented by strings (False) or integers (True). Default is True. + preserving_proto_field_name (Optional(bool)): An option that + determines whether field name representations preserve + proto case (snake_case) or use lowerCamelCase. Default is False. Returns: str: The json string representation of the protocol buffer. @@ -350,6 +354,7 @@ def to_json( cls.pb(instance), use_integers_for_enums=use_integers_for_enums, including_default_value_fields=including_default_value_fields, + preserving_proto_field_name=preserving_proto_field_name, ) def from_json(cls, payload, *, ignore_unknown_fields=False) -> "Message": @@ -369,7 +374,14 @@ def from_json(cls, payload, *, ignore_unknown_fields=False) -> "Message": Parse(payload, instance._pb, ignore_unknown_fields=ignore_unknown_fields) return instance - def to_dict(cls, instance, *, use_integers_for_enums=True) -> "Message": + def to_dict( + cls, + instance, + *, + use_integers_for_enums=True, + preserving_proto_field_name=True, + including_default_value_fields=True, + ) -> "Message": """Given a message instance, return its representation as a python dict. Args: @@ -378,6 +390,12 @@ def to_dict(cls, instance, *, use_integers_for_enums=True) -> "Message": use_integers_for_enums (Optional(bool)): An option that determines whether enum values should be represented by strings (False) or integers (True). Default is True. + preserving_proto_field_name (Optional(bool)): An option that + determines whether field name representations preserve + proto case (snake_case) or use lowerCamelCase. Default is True. + including_default_value_fields (Optional(bool)): An option that + determines whether the default field values should be included in the results. + Default is True. Returns: dict: A representation of the protocol buffer using pythonic data structures. @@ -386,11 +404,41 @@ def to_dict(cls, instance, *, use_integers_for_enums=True) -> "Message": """ return MessageToDict( cls.pb(instance), - including_default_value_fields=True, - preserving_proto_field_name=True, + including_default_value_fields=including_default_value_fields, + preserving_proto_field_name=preserving_proto_field_name, use_integers_for_enums=use_integers_for_enums, ) + def copy_from(cls, instance, other): + """Equivalent for protobuf.Message.CopyFrom + + Args: + instance: An instance of this message type + other: (Union[dict, ~.Message): + A dictionary or message to reinitialize the values for this message. + """ + if isinstance(other, cls): + # Just want the underlying proto. + other = Message.pb(other) + elif isinstance(other, cls.pb()): + # Don't need to do anything. + pass + elif isinstance(other, collections.abc.Mapping): + # Coerce into a proto + other = cls._meta.pb(**other) + else: + raise TypeError( + "invalid argument type to copy to {}: {}".format( + cls.__name__, other.__class__.__name__ + ) + ) + + # Note: we can't just run self.__init__ because this may be a message field + # for a higher order proto; the memory layout for protos is NOT LIKE the + # python memory model. We cannot rely on just setting things by reference. + # Non-trivial complexity is (partially) hidden by the protobuf runtime. + cls.pb(instance).CopyFrom(other) + class Message(metaclass=MessageMeta): """The abstract base class for a message. @@ -426,7 +474,7 @@ def __init__(self, mapping=None, *, ignore_unknown_fields=False, **kwargs): # # The `wrap` method on the metaclass is the public API for taking # ownership of the passed in protobuf objet. - mapping = copy.copy(mapping) + mapping = copy.deepcopy(mapping) if kwargs: mapping.MergeFrom(self._meta.pb(**kwargs)) @@ -615,7 +663,7 @@ def __init__( package: str, full_name: str, marshal: Marshal, - options: descriptor_pb2.MessageOptions + options: descriptor_pb2.MessageOptions, ) -> None: self.package = package self.full_name = full_name diff --git a/setup.py b/setup.py index fd2a81a1..b6bc503a 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ from setuptools import find_packages, setup -version = "1.13.1" +version = "1.19.0" PACKAGE_ROOT = os.path.abspath(os.path.dirname(__file__)) diff --git a/testing/constraints-3.6.txt b/testing/constraints-3.6.txt index f7365f65..31111ae4 100644 --- a/testing/constraints-3.6.txt +++ b/testing/constraints-3.6.txt @@ -5,5 +5,5 @@ # # e.g., if setup.py has "foo >= 1.14.0, < 2.0.0dev", # Then this file should have foo==1.14.0 -protobuf==3.12.0 -google-api-core==1.22.2 \ No newline at end of file +protobuf==3.15.6 +google-api-core==1.22.2 diff --git a/tests/test_fields_enum.py b/tests/test_fields_enum.py index eeeab325..e5d5b324 100644 --- a/tests/test_fields_enum.py +++ b/tests/test_fields_enum.py @@ -12,7 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. +import os import proto +import pytest import sys @@ -353,3 +355,40 @@ class Task(proto.Message): t = Task(weekday="TUESDAY") t2 = Task.deserialize(Task.serialize(t)) assert t == t2 + + +if os.environ.get("PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION", "python") == "cpp": + # This test only works, and is only relevant, with the cpp runtime. + # Python just doesn't give a care and lets it work anyway. + def test_enum_alias_bad(): + # Certain enums may shadow the different enum monikers with the same value. + # This is generally discouraged, and protobuf will object by default, + # but will explicitly allow this behavior if the enum is defined with + # the `allow_alias` option set. + with pytest.raises(TypeError): + + # The wrapper message is a hack to avoid manifest wrangling to + # define the enum. + class BadMessage(proto.Message): + class BadEnum(proto.Enum): + UNKNOWN = 0 + DEFAULT = 0 + + bad_dup_enum = proto.Field(proto.ENUM, number=1, enum=BadEnum) + + +def test_enum_alias_good(): + # Have to split good and bad enum alias into two tests so that the generated + # file descriptor is properly created. + # For the python runtime, aliases are allowed by default, but we want to + # make sure that the options don't cause problems. + # For the cpp runtime, we need to verify that we can in fact define aliases. + class GoodMessage(proto.Message): + class GoodEnum(proto.Enum): + _pb_options = {"allow_alias": True} + UNKNOWN = 0 + DEFAULT = 0 + + good_dup_enum = proto.Field(proto.ENUM, number=1, enum=GoodEnum) + + assert GoodMessage.GoodEnum.UNKNOWN == GoodMessage.GoodEnum.DEFAULT == 0 diff --git a/tests/test_json.py b/tests/test_json.py index 4b7be8be..d1cbc1fb 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -136,3 +136,15 @@ class Octopus(proto.Message): # Don't permit unknown fields by default with pytest.raises(ParseError): o = Octopus.from_json(json_str) + + +def test_json_snake_case(): + class Squid(proto.Message): + mass_kg = proto.Field(proto.INT32, number=1) + + json_str = '{\n "mass_kg": 20\n}' + s = Squid.from_json(json_str) + + assert s.mass_kg == 20 + + assert Squid.to_json(s, preserving_proto_field_name=True) == json_str diff --git a/tests/test_marshal_strict.py b/tests/test_marshal_strict.py new file mode 100644 index 00000000..75e2f05d --- /dev/null +++ b/tests/test_marshal_strict.py @@ -0,0 +1,24 @@ +# Copyright (C) 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import proto +from proto.marshal.marshal import BaseMarshal +import pytest + + +def test_strict_to_proto(): + m = BaseMarshal() + + with pytest.raises(TypeError): + m.to_proto(dict, None, strict=True) diff --git a/tests/test_marshal_types_enum.py b/tests/test_marshal_types_enum.py index 1d302053..6cd348c3 100644 --- a/tests/test_marshal_types_enum.py +++ b/tests/test_marshal_types_enum.py @@ -58,3 +58,35 @@ class Foo(proto.Enum): with mock.patch.object(warnings, "warn") as warn: assert enum_rule.to_python(4) == 4 warn.assert_called_once_with("Unrecognized Foo enum value: 4") + + +def test_enum_append(): + class Bivalve(proto.Enum): + CLAM = 0 + OYSTER = 1 + + class MolluscContainer(proto.Message): + bivalves = proto.RepeatedField(proto.ENUM, number=1, enum=Bivalve,) + + mc = MolluscContainer() + clam = Bivalve.CLAM + mc.bivalves.append(clam) + mc.bivalves.append(1) + + assert mc.bivalves == [clam, Bivalve.OYSTER] + + +def test_enum_map_insert(): + class Bivalve(proto.Enum): + CLAM = 0 + OYSTER = 1 + + class MolluscContainer(proto.Message): + bivalves = proto.MapField(proto.STRING, proto.ENUM, number=1, enum=Bivalve,) + + mc = MolluscContainer() + clam = Bivalve.CLAM + mc.bivalves["clam"] = clam + mc.bivalves["oyster"] = 1 + + assert mc.bivalves == {"clam": clam, "oyster": Bivalve.OYSTER} diff --git a/tests/test_message.py b/tests/test_message.py index b723fcde..5351dbd7 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -262,6 +262,11 @@ class Color(proto.Enum): s_dict = Squid.to_dict(s, use_integers_for_enums=False) assert s_dict["chromatophores"][0]["color"] == "RED" + s_new_2 = Squid(mass_kg=20) + s_dict_2 = Squid.to_dict(s_new_2, including_default_value_fields=False) + expected_dict = {"mass_kg": 20} + assert s_dict_2 == expected_dict + new_s = Squid(s_dict) assert new_s == s @@ -317,3 +322,27 @@ class Squid(proto.Message): s = Squid({"mass_kg": 20, "length_cm": 100}, ignore_unknown_fields=True) assert not hasattr(s, "length_cm") + + +def test_copy_from(): + class Mollusc(proto.Message): + class Squid(proto.Message): + mass_kg = proto.Field(proto.INT32, number=1) + + squid = proto.Field(Squid, number=1) + + m = Mollusc() + s = Mollusc.Squid(mass_kg=20) + Mollusc.Squid.copy_from(m.squid, s) + assert m.squid is not s + assert m.squid == s + + s.mass_kg = 30 + Mollusc.Squid.copy_from(m.squid, Mollusc.Squid.pb(s)) + assert m.squid == s + + Mollusc.Squid.copy_from(m.squid, {"mass_kg": 10}) + assert m.squid.mass_kg == 10 + + with pytest.raises(TypeError): + Mollusc.Squid.copy_from(m.squid, (("mass_kg", 20)))