diff --git a/.github/release-please.yml b/.github/release-please.yml index 4507ad05..466597e5 100644 --- a/.github/release-please.yml +++ b/.github/release-please.yml @@ -1 +1,2 @@ releaseType: python +handleGHRelease: true diff --git a/.github/release-trigger.yml b/.github/release-trigger.yml new file mode 100644 index 00000000..1e9cfcd3 --- /dev/null +++ b/.github/release-trigger.yml @@ -0,0 +1,2 @@ +enabled: true + diff --git a/.github/sync-repo-settings.yaml b/.github/sync-repo-settings.yaml index d5b332af..7e74b00b 100644 --- a/.github/sync-repo-settings.yaml +++ b/.github/sync-repo-settings.yaml @@ -2,7 +2,7 @@ rebaseMergeAllowed: false squashMergeAllowed: true mergeCommitAllowed: false branchProtectionRules: -- pattern: master +- pattern: main isAdminEnforced: true requiredStatusCheckContexts: - 'style-check' diff --git a/.github/workflows/pypi-upload.yaml b/.github/workflows/pypi-upload.yaml index 05545359..b4151b68 100644 --- a/.github/workflows/pypi-upload.yaml +++ b/.github/workflows/pypi-upload.yaml @@ -10,7 +10,7 @@ jobs: steps: - uses: actions/checkout@v2 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: '3.x' - name: Install dependencies diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 93d7308a..026e15c2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,28 +14,28 @@ jobs: runs-on: ubuntu-latest steps: - name: Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.9.0 + uses: styfle/cancel-workflow-action@0.9.1 with: access_token: ${{ github.token }} - uses: actions/checkout@v2 - name: Set up Python 3.8 - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: 3.8 - name: Install black - run: pip install black==19.10b0 + run: pip install black==22.3.0 - name: Check diff run: black --diff --check . docs: runs-on: ubuntu-latest steps: - name: Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.9.0 + uses: styfle/cancel-workflow-action@0.9.1 with: access_token: ${{ github.token }} - uses: actions/checkout@v2 - name: Set up Python 3.8 - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: 3.8 - name: Install nox. @@ -46,16 +46,16 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python: [3.6, 3.7, 3.8, 3.9] + python: ['3.6', '3.7', '3.8', '3.9', '3.10'] variant: ['', cpp] steps: - name: Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.9.0 + uses: styfle/cancel-workflow-action@0.9.1 with: access_token: ${{ github.token }} - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: ${{ matrix.python }} - name: Install nox and codecov. diff --git a/CHANGELOG.md b/CHANGELOG.md index eaf77f1a..28b1d33e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,101 @@ # Changelog +### [1.20.3](https://github.com/googleapis/proto-plus-python/compare/v1.20.2...v1.20.3) (2022-02-18) + + +### Bug Fixes + +* additional logic to mitigate collisions with reserved terms ([#301](https://github.com/googleapis/proto-plus-python/issues/301)) ([c9a77df](https://github.com/googleapis/proto-plus-python/commit/c9a77df58e93a87952470d809538a08103644364)) + +### [1.20.2](https://github.com/googleapis/proto-plus-python/compare/v1.20.1...v1.20.2) (2022-02-17) + + +### Bug Fixes + +* dir(proto.Message) does not raise ([#302](https://github.com/googleapis/proto-plus-python/issues/302)) ([80dcce9](https://github.com/googleapis/proto-plus-python/commit/80dcce9099e630a6217792b6b3a14213add690e6)) + +### [1.20.1](https://github.com/googleapis/proto-plus-python/compare/v1.20.0...v1.20.1) (2022-02-14) + + +### Bug Fixes + +* mitigate collisions in field names ([#295](https://github.com/googleapis/proto-plus-python/issues/295)) ([158ae99](https://github.com/googleapis/proto-plus-python/commit/158ae995aa4fdf6239c864a41f5df5575a3c30b3)) + +## [1.20.0](https://github.com/googleapis/proto-plus-python/compare/v1.19.9...v1.20.0) (2022-02-07) + + +### Features + +* add custom __dir__ for messages and message classes ([#289](https://github.com/googleapis/proto-plus-python/issues/289)) ([35e019e](https://github.com/googleapis/proto-plus-python/commit/35e019eb8155c1e4067b326804e3e7e86f85b6a8)) + + +### Bug Fixes + +* workaround for buggy pytest ([#291](https://github.com/googleapis/proto-plus-python/issues/291)) ([28aa3b2](https://github.com/googleapis/proto-plus-python/commit/28aa3b2b325d2ba262f35cfc8d20e1f5fbdcf883)) + +### [1.19.9](https://github.com/googleapis/proto-plus-python/compare/v1.19.8...v1.19.9) (2022-01-25) + + +### Bug Fixes + +* add pickling support to proto messages ([#280](https://github.com/googleapis/proto-plus-python/issues/280)) ([2b7be35](https://github.com/googleapis/proto-plus-python/commit/2b7be3563f9fc2a4649a5e14d7653b85020c566f)) + +### [1.19.8](https://www.github.com/googleapis/proto-plus-python/compare/v1.19.7...v1.19.8) (2021-11-09) + + +### Documentation + +* fix typos ([#277](https://www.github.com/googleapis/proto-plus-python/issues/277)) ([e3b71e8](https://www.github.com/googleapis/proto-plus-python/commit/e3b71e8b2a81a5abb5af666c9625facb1814a609)) + +### [1.19.7](https://www.github.com/googleapis/proto-plus-python/compare/v1.19.6...v1.19.7) (2021-10-27) + + +### Bug Fixes + +* restore allowing None as value for stringy ints ([#272](https://www.github.com/googleapis/proto-plus-python/issues/272)) ([a8991d7](https://www.github.com/googleapis/proto-plus-python/commit/a8991d71ff455093fbfef142f9140d3f2928195f)) + +### [1.19.6](https://www.github.com/googleapis/proto-plus-python/compare/v1.19.5...v1.19.6) (2021-10-25) + + +### Bug Fixes + +* setting 64bit fields from strings supported ([#267](https://www.github.com/googleapis/proto-plus-python/issues/267)) ([ea7b911](https://www.github.com/googleapis/proto-plus-python/commit/ea7b91100114f5c3d40d41320b045568ac9a68f9)) + +### [1.19.5](https://www.github.com/googleapis/proto-plus-python/compare/v1.19.4...v1.19.5) (2021-10-11) + + +### Documentation + +* Clarify semantics of multiple oneof variants passed to msg ctor ([#263](https://www.github.com/googleapis/proto-plus-python/issues/263)) ([6f8a5b2](https://www.github.com/googleapis/proto-plus-python/commit/6f8a5b2098e4f6748945c53bda3d5821e62e5a0a)) + +### [1.19.4](https://www.github.com/googleapis/proto-plus-python/compare/v1.19.3...v1.19.4) (2021-10-08) + + +### Documentation + +* clarify that proto plus messages are not pickleable ([#260](https://www.github.com/googleapis/proto-plus-python/issues/260)) ([6e691dc](https://www.github.com/googleapis/proto-plus-python/commit/6e691dc27b1e540ef0661597fd89ece8f0155c97)) + +### [1.19.3](https://www.github.com/googleapis/proto-plus-python/compare/v1.19.2...v1.19.3) (2021-10-07) + + +### Bug Fixes + +* setting bytes field from python string base64 decodes before assignment ([#255](https://www.github.com/googleapis/proto-plus-python/issues/255)) ([b6f3eb6](https://www.github.com/googleapis/proto-plus-python/commit/b6f3eb6575484748126170997b8c98512763ea66)) + +### [1.19.2](https://www.github.com/googleapis/proto-plus-python/compare/v1.19.1...v1.19.2) (2021-09-29) + + +### Bug Fixes + +* ensure enums are hashable ([#252](https://www.github.com/googleapis/proto-plus-python/issues/252)) ([232341b](https://www.github.com/googleapis/proto-plus-python/commit/232341b4f4902fba1b3597bb1e1618b8f320374b)) + +### [1.19.1](https://www.github.com/googleapis/proto-plus-python/compare/v1.19.0...v1.19.1) (2021-09-29) + + +### Bug Fixes + +* ensure enums are incomparable w other enum types ([#248](https://www.github.com/googleapis/proto-plus-python/issues/248)) ([5927c14](https://www.github.com/googleapis/proto-plus-python/commit/5927c1400f400b3213c9b92e7a37c3c3a1abd681)) + ## [1.19.0](https://www.github.com/googleapis/proto-plus-python/compare/v1.18.1...v1.19.0) (2021-06-29) diff --git a/docs/fields.rst b/docs/fields.rst index 07e70fbf..b930c61a 100644 --- a/docs/fields.rst +++ b/docs/fields.rst @@ -151,6 +151,33 @@ a string (which should match for all fields within the oneof): have consecutive field numbers, but they must be declared in consecutive order. +.. warning:: + + If a message is constructed with multiple variants of a single ``oneof`` passed + to its constructor, the **last** keyword/value pair passed will be the final + value set. + + This is consistent with PEP-468_, which specifies the order that keyword args + are seen by called functions, and with the regular protocol buffers runtime, + which exhibits the same behavior. + + Example: + + .. code-block:: python + + import proto + + class Song(proto.Message): + name = proto.Field(proto.STRING, number=1, oneof="identifier") + database_id = proto.Field(proto.STRING, number=2, oneof="identifier") + + s = Song(name="Canon in D minor", database_id="b5a37aad3") + assert "database_id" in s and "name" not in s + + s = Song(database_id="e6aa708c7e", name="Little Fugue") + assert "name" in s and "database_id" not in s + + Optional fields --------------- @@ -199,5 +226,4 @@ information. .. _documentation: https://github.com/protocolbuffers/protobuf/blob/v3.12.0/docs/field_presence.md - - +.. _PEP-468: https://www.python.org/dev/peps/pep-0468/ diff --git a/docs/marshal.rst b/docs/marshal.rst index 369d823d..ee61cb2e 100644 --- a/docs/marshal.rst +++ b/docs/marshal.rst @@ -44,7 +44,35 @@ Protocol buffer type Python type Nullable If you *write* a timestamp field using a Python ``datetime`` value, any existing nanosecond precision will be overwritten. +.. note:: + + Setting a ``bytes`` field from a string value will first base64 decode the string. + This is necessary to preserve the original protobuf semantics when converting between + Python dicts and proto messages. + Converting a message containing a bytes field to a dict will + base64 encode the bytes field and yield a value of type str. + +.. code-block:: python + + import proto + from google.protobuf.json_format import ParseDict + + class MyMessage(proto.Message): + data = proto.Field(proto.BYTES, number=1) + + msg = MyMessage(data=b"this is a message") + msg_dict = MyMessage.to_dict(msg) + + # Note: the value is the base64 encoded string of the bytes field. + # It has a type of str, NOT bytes. + assert type(msg_dict['data']) == str + + msg_pb = ParseDict(msg_dict, MyMessage.pb()) + msg_two = MyMessage(msg_dict) + assert msg == msg_pb == msg_two + + Wrapper types ------------- diff --git a/docs/messages.rst b/docs/messages.rst index e7861b04..e033473c 100644 --- a/docs/messages.rst +++ b/docs/messages.rst @@ -129,7 +129,7 @@ just as if it were a regular python object. song.composer = composer composer.given_name = "Carl" - # 'composer' is STILL not a referene to song.composer. + # 'composer' is STILL not a reference to song.composer. assert song.composer.given_name == "Wilhelm" # It does work in reverse, though, @@ -243,3 +243,35 @@ already allows construction from mapping types. song_dict = Song.to_dict(song) new_song = Song(song_dict) + +.. note:: + + Although Python's pickling protocol has known issues when used with + untrusted collaborators, some frameworks do use it for communication + between trusted hosts. To support such frameworks, protobuf messages + **can** be pickled and unpickled, although the preferred mechanism for + serializing proto messages is :meth:`~.Message.serialize`. + + Multiprocessing example: + + .. code-block:: python + + import proto + from multiprocessing import Pool + + class Composer(proto.Message): + name = proto.Field(proto.STRING, number=1) + genre = proto.Field(proto.STRING, number=2) + + composers = [Composer(name=n) for n in ["Bach", "Mozart", "Brahms", "Strauss"]] + + with multiprocessing.Pool(2) as p: + def add_genre(comp_bytes): + composer = Composer.deserialize(comp_bytes) + composer.genre = "classical" + return Composer.serialize(composer) + + updated_composers = [ + Composer.deserialize(comp_bytes) + for comp_bytes in p.map(add_genre, (Composer.serialize(comp) for comp in composers)) + ] diff --git a/noxfile.py b/noxfile.py index 1ca07cec..0e737e73 100644 --- a/noxfile.py +++ b/noxfile.py @@ -22,7 +22,16 @@ CURRENT_DIRECTORY = pathlib.Path(__file__).parent.absolute() -@nox.session(python=["3.6", "3.7", "3.8", "3.9"]) +PYTHON_VERSIONS = [ + "3.6", + "3.7", + "3.8", + "3.9", + "3.10", +] + + +@nox.session(python=PYTHON_VERSIONS) def unit(session, proto="python"): """Run the unit test suite.""" @@ -54,12 +63,13 @@ 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", "3.9"]) +@nox.session(python=PYTHON_VERSIONS) def unitcpp(session): return unit(session, proto="cpp") -@nox.session(python="3.7") +# Just use the most recent version for docs +@nox.session(python=PYTHON_VERSIONS[-1]) def docs(session): """Build the docs.""" diff --git a/proto/_file_info.py b/proto/_file_info.py index 34ec79c5..537eeaf4 100644 --- a/proto/_file_info.py +++ b/proto/_file_info.py @@ -40,7 +40,9 @@ def maybe_add_descriptor(cls, filename, package): if not descriptor: descriptor = cls.registry[filename] = cls( descriptor=descriptor_pb2.FileDescriptorProto( - name=filename, package=package, syntax="proto3", + name=filename, + package=package, + syntax="proto3", ), enums=collections.OrderedDict(), messages=collections.OrderedDict(), diff --git a/proto/datetime_helpers.py b/proto/datetime_helpers.py index 66732ba5..7a3aafec 100644 --- a/proto/datetime_helpers.py +++ b/proto/datetime_helpers.py @@ -63,7 +63,7 @@ def _to_rfc3339(value, ignore_zone=True): datetime object is ignored and the datetime is treated as UTC. Returns: - str: The RFC3339 formated string representing the datetime. + str: The RFC3339 formatted string representing the datetime. """ if not ignore_zone and value.tzinfo is not None: # Convert to UTC and remove the time zone info. @@ -97,7 +97,7 @@ def replace(self, *args, **kw): new values by whichever keyword arguments are specified. For example, if d == date(2002, 12, 31), then d.replace(day=26) == date(2002, 12, 26). - NOTE: nanosecond and microsecond are mutually exclusive arguemnts. + NOTE: nanosecond and microsecond are mutually exclusive arguments. """ ms_provided = "microsecond" in kw @@ -171,7 +171,7 @@ def from_rfc3339(cls, stamp): nanos = 0 else: scale = 9 - len(fraction) - nanos = int(fraction) * (10 ** scale) + nanos = int(fraction) * (10**scale) return cls( bare.year, bare.month, diff --git a/proto/enums.py b/proto/enums.py index 1fe8746f..6f13d32e 100644 --- a/proto/enums.py +++ b/proto/enums.py @@ -108,7 +108,48 @@ def __new__(mcls, name, bases, attrs): class Enum(enum.IntEnum, metaclass=ProtoEnumMeta): """A enum object that also builds a protobuf enum descriptor.""" - pass + def _comparable(self, other): + # Avoid 'isinstance' to prevent other IntEnums from matching + return type(other) in (type(self), int) + + def __hash__(self): + return hash(self.value) + + def __eq__(self, other): + if not self._comparable(other): + return NotImplemented + + return self.value == int(other) + + def __ne__(self, other): + if not self._comparable(other): + return NotImplemented + + return self.value != int(other) + + def __lt__(self, other): + if not self._comparable(other): + return NotImplemented + + return self.value < int(other) + + def __le__(self, other): + if not self._comparable(other): + return NotImplemented + + return self.value <= int(other) + + def __ge__(self, other): + if not self._comparable(other): + return NotImplemented + + return self.value >= int(other) + + def __gt__(self, other): + if not self._comparable(other): + return NotImplemented + + return self.value > int(other) class _EnumInfo: diff --git a/proto/fields.py b/proto/fields.py index cc98e8b0..6f5b6452 100644 --- a/proto/fields.py +++ b/proto/fields.py @@ -78,7 +78,8 @@ def descriptor(self): if isinstance(self.message, str): if not self.message.startswith(self.package): self.message = "{package}.{name}".format( - package=self.package, name=self.message, + package=self.package, + name=self.message, ) type_name = self.message elif self.message: @@ -90,7 +91,8 @@ def descriptor(self): elif isinstance(self.enum, str): if not self.enum.startswith(self.package): self.enum = "{package}.{name}".format( - package=self.package, name=self.enum, + package=self.package, + name=self.enum, ) type_name = self.enum elif self.enum: @@ -126,14 +128,15 @@ def package(self) -> str: @property def pb_type(self): - """Return the composite type of the field, or None for primitives.""" + """Return the composite type of the field, or the primitive type if a primitive.""" # For enums, return the Python enum. if self.enum: return self.enum - # For non-enum primitives, return None. + # For primitive fields, we still want to know + # what the type is. if not self.message: - return None + return self.proto_type # Return the internal protobuf message. if hasattr(self.message, "_meta"): diff --git a/proto/marshal/marshal.py b/proto/marshal/marshal.py index c8224ce2..e7f8d4d9 100644 --- a/proto/marshal/marshal.py +++ b/proto/marshal/marshal.py @@ -25,9 +25,12 @@ from proto.marshal.collections import MapComposite from proto.marshal.collections import Repeated from proto.marshal.collections import RepeatedComposite +from proto.marshal.rules import bytes as pb_bytes +from proto.marshal.rules import stringy_numbers from proto.marshal.rules import dates from proto.marshal.rules import struct from proto.marshal.rules import wrappers +from proto.primitives import ProtoType class Rule(abc.ABC): @@ -85,14 +88,6 @@ class TimestampRule: proto_type (type): A protocol buffer message type. rule: A marshal object """ - # Sanity check: Do not register anything to a class that is not - # a protocol buffer message. - if not issubclass(proto_type, (message.Message, enum.IntEnum)): - raise TypeError( - "Only enums and protocol buffer messages may be " - "registered to the marshal." - ) - # If a rule was provided, register it and be done. if rule: # Ensure the rule implements Rule. @@ -150,6 +145,14 @@ def reset(self): self.register(struct_pb2.ListValue, struct.ListValueRule(marshal=self)) self.register(struct_pb2.Struct, struct.StructRule(marshal=self)) + # Special case for bytes to allow base64 encode/decode + self.register(ProtoType.BYTES, pb_bytes.BytesRule()) + + # Special case for int64 from strings because of dict round trip. + # See https://github.com/protocolbuffers/protobuf/issues/2679 + for rule_class in stringy_numbers.STRINGY_NUMBER_RULES: + self.register(rule_class._proto_type, rule_class()) + def to_python(self, proto_type, value, *, absent: bool = None): # Internal protobuf has its own special type for lists of values. # Return a view around it that implements MutableSequence. @@ -212,7 +215,8 @@ def to_proto(self, proto_type, value, *, strict: bool = False): raise TypeError( "Parameter must be instance of the same class; " "expected {expected}, got {got}".format( - expected=proto_type.__name__, got=pb_value.__class__.__name__, + expected=proto_type.__name__, + got=pb_value.__class__.__name__, ), ) diff --git a/proto/marshal/rules/bytes.py b/proto/marshal/rules/bytes.py new file mode 100644 index 00000000..080b0a03 --- /dev/null +++ b/proto/marshal/rules/bytes.py @@ -0,0 +1,44 @@ +# 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 base64 + + +class BytesRule: + """A marshal between Python strings and protobuf bytes. + + Note: this conversion is asymmetric because Python does have a bytes type. + It is sometimes necessary to convert proto bytes fields to strings, e.g. for + JSON encoding, marshalling a message to a dict. Because bytes fields can + represent arbitrary data, bytes fields are base64 encoded when they need to + be represented as strings. + + It is necessary to have the conversion be bidirectional, i.e. + my_message == MyMessage(MyMessage.to_dict(my_message)) + + To accomplish this, we need to intercept assignments from strings and + base64 decode them back into bytes. + """ + + def to_python(self, value, *, absent: bool = None): + return value + + def to_proto(self, value): + if isinstance(value, str): + value = value.encode("utf-8") + value += b"=" * (4 - len(value) % 4) # padding + value = base64.urlsafe_b64decode(value) + + return value diff --git a/proto/marshal/rules/dates.py b/proto/marshal/rules/dates.py index 1e0695ec..5145bcf8 100644 --- a/proto/marshal/rules/dates.py +++ b/proto/marshal/rules/dates.py @@ -44,7 +44,8 @@ def to_proto(self, value) -> timestamp_pb2.Timestamp: return value.timestamp_pb() if isinstance(value, datetime): return timestamp_pb2.Timestamp( - seconds=int(value.timestamp()), nanos=value.microsecond * 1000, + seconds=int(value.timestamp()), + nanos=value.microsecond * 1000, ) return value diff --git a/proto/marshal/rules/enums.py b/proto/marshal/rules/enums.py index d965666c..9cfc3127 100644 --- a/proto/marshal/rules/enums.py +++ b/proto/marshal/rules/enums.py @@ -36,7 +36,8 @@ def to_python(self, value, *, absent: bool = None): # the user realizes that an unexpected value came along. warnings.warn( "Unrecognized {name} enum value: {value}".format( - name=self._enum.__name__, value=value, + name=self._enum.__name__, + value=value, ) ) return value diff --git a/proto/marshal/rules/message.py b/proto/marshal/rules/message.py index e5ecf17b..c865b99d 100644 --- a/proto/marshal/rules/message.py +++ b/proto/marshal/rules/message.py @@ -29,7 +29,16 @@ def to_proto(self, value): if isinstance(value, self._wrapper): return self._wrapper.pb(value) if isinstance(value, dict) and not self.is_map: - return self._descriptor(**value) + # We need to use the wrapper's marshaling to handle + # potentially problematic nested messages. + try: + # Try the fast path first. + return self._descriptor(**value) + except TypeError as ex: + # If we have a type error, + # try the slow path in case the error + # was an int64/string issue + return self._wrapper(value)._pb return value @property diff --git a/proto/marshal/rules/stringy_numbers.py b/proto/marshal/rules/stringy_numbers.py new file mode 100644 index 00000000..dae69e9c --- /dev/null +++ b/proto/marshal/rules/stringy_numbers.py @@ -0,0 +1,71 @@ +# 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. + +from proto.primitives import ProtoType + + +class StringyNumberRule: + """A marshal between certain numeric types and strings + + This is a necessary hack to allow round trip conversion + from messages to dicts back to messages. + + See https://github.com/protocolbuffers/protobuf/issues/2679 + and + https://developers.google.com/protocol-buffers/docs/proto3#json + for more details. + """ + + def to_python(self, value, *, absent: bool = None): + return value + + def to_proto(self, value): + if value is not None: + return self._python_type(value) + + return None + + +class Int64Rule(StringyNumberRule): + _python_type = int + _proto_type = ProtoType.INT64 + + +class UInt64Rule(StringyNumberRule): + _python_type = int + _proto_type = ProtoType.UINT64 + + +class SInt64Rule(StringyNumberRule): + _python_type = int + _proto_type = ProtoType.SINT64 + + +class Fixed64Rule(StringyNumberRule): + _python_type = int + _proto_type = ProtoType.FIXED64 + + +class SFixed64Rule(StringyNumberRule): + _python_type = int + _proto_type = ProtoType.SFIXED64 + + +STRINGY_NUMBER_RULES = [ + Int64Rule, + UInt64Rule, + SInt64Rule, + Fixed64Rule, + SFixed64Rule, +] diff --git a/proto/marshal/rules/struct.py b/proto/marshal/rules/struct.py index 27dfaf56..0e34587b 100644 --- a/proto/marshal/rules/struct.py +++ b/proto/marshal/rules/struct.py @@ -47,11 +47,15 @@ def to_python(self, value, *, absent: bool = None): return str(value.string_value) if kind == "struct_value": return self._marshal.to_python( - struct_pb2.Struct, value.struct_value, absent=False, + struct_pb2.Struct, + value.struct_value, + absent=False, ) if kind == "list_value": return self._marshal.to_python( - struct_pb2.ListValue, value.list_value, absent=False, + struct_pb2.ListValue, + value.list_value, + absent=False, ) # If more variants are ever added, we want to fail loudly # instead of tacitly returning None. @@ -126,7 +130,9 @@ def to_proto(self, value) -> struct_pb2.Struct: if isinstance(value, struct_pb2.Struct): return value if isinstance(value, maps.MapComposite): - return struct_pb2.Struct(fields={k: v for k, v in value.pb.items()},) + return struct_pb2.Struct( + fields={k: v for k, v in value.pb.items()}, + ) # We got a dict (or something dict-like); convert it. answer = struct_pb2.Struct( diff --git a/proto/message.py b/proto/message.py index 00ec4cc7..b157f728 100644 --- a/proto/message.py +++ b/proto/message.py @@ -67,7 +67,9 @@ def __new__(mcls, name, bases, attrs): # Determine the name of the entry message. msg_name = "{pascal_key}Entry".format( pascal_key=re.sub( - r"_\w", lambda m: m.group()[1:].upper(), key, + r"_\w", + lambda m: m.group()[1:].upper(), + key, ).replace(key[0], key[0].upper(), 1), ) @@ -83,20 +85,26 @@ def __new__(mcls, name, bases, attrs): { "__module__": attrs.get("__module__", None), "__qualname__": "{prefix}.{name}".format( - prefix=attrs.get("__qualname__", name), name=msg_name, + prefix=attrs.get("__qualname__", name), + name=msg_name, ), "_pb_options": {"map_entry": True}, } ) entry_attrs["key"] = Field(field.map_key_type, number=1) entry_attrs["value"] = Field( - field.proto_type, number=2, enum=field.enum, message=field.message, + field.proto_type, + number=2, + enum=field.enum, + message=field.message, ) map_fields[msg_name] = MessageMeta(msg_name, (Message,), entry_attrs) # Create the repeated field for the entry message. map_fields[key] = RepeatedField( - ProtoType.MESSAGE, number=field.number, message=map_fields[msg_name], + ProtoType.MESSAGE, + number=field.number, + message=map_fields[msg_name], ) # Add the new entries to the attrs @@ -273,6 +281,30 @@ def __prepare__(mcls, name, bases, **kwargs): def meta(cls): return cls._meta + def __dir__(self): + try: + names = set(dir(type)) + names.update( + ( + "meta", + "pb", + "wrap", + "serialize", + "deserialize", + "to_json", + "from_json", + "to_dict", + "copy_from", + ) + ) + desc = self.pb().DESCRIPTOR + names.update(t.name for t in desc.nested_types) + names.update(e.name for e in desc.enum_types) + + return names + except AttributeError: + return dir(type) + def pb(cls, obj=None, *, coerce: bool = False): """Return the underlying protobuf Message class or instance. @@ -288,7 +320,13 @@ def pb(cls, obj=None, *, coerce: bool = False): if coerce: obj = cls(obj) else: - raise TypeError("%r is not an instance of %s" % (obj, cls.__name__,)) + raise TypeError( + "%r is not an instance of %s" + % ( + obj, + cls.__name__, + ) + ) return obj._pb def wrap(cls, pb): @@ -394,7 +432,7 @@ def to_dict( 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. + determines whether the default field values should be included in the results. Default is True. Returns: @@ -453,7 +491,13 @@ class Message(metaclass=MessageMeta): message. """ - def __init__(self, mapping=None, *, ignore_unknown_fields=False, **kwargs): + def __init__( + self, + mapping=None, + *, + ignore_unknown_fields=False, + **kwargs, + ): # We accept several things for `mapping`: # * An instance of this class. # * An instance of the underlying protobuf descriptor class. @@ -473,7 +517,7 @@ def __init__(self, mapping=None, *, ignore_unknown_fields=False, **kwargs): # passed in. # # The `wrap` method on the metaclass is the public API for taking - # ownership of the passed in protobuf objet. + # ownership of the passed in protobuf object. mapping = copy.deepcopy(mapping) if kwargs: mapping.MergeFrom(self._meta.pb(**kwargs)) @@ -493,7 +537,10 @@ def __init__(self, mapping=None, *, ignore_unknown_fields=False, **kwargs): # Sanity check: Did we get something not a map? Error if so. raise TypeError( "Invalid constructor input for %s: %r" - % (self.__class__.__name__, mapping,) + % ( + self.__class__.__name__, + mapping, + ) ) params = {} @@ -501,9 +548,8 @@ def __init__(self, mapping=None, *, ignore_unknown_fields=False, **kwargs): # coerced. marshal = self._meta.marshal for key, value in mapping.items(): - try: - pb_type = self._meta.fields[key].pb_type - except KeyError: + (key, pb_type) = self._get_pb_type_from_key(key) + if pb_type is None: if ignore_unknown_fields: continue @@ -511,13 +557,86 @@ def __init__(self, mapping=None, *, ignore_unknown_fields=False, **kwargs): "Unknown field for {}: {}".format(self.__class__.__name__, key) ) - pb_value = marshal.to_proto(pb_type, value) + try: + pb_value = marshal.to_proto(pb_type, value) + except ValueError: + # Underscores may be appended to field names + # that collide with python or proto-plus keywords. + # In case a key only exists with a `_` suffix, coerce the key + # to include the `_` suffix. It's not possible to + # natively define the same field with a trailing underscore in protobuf. + # See related issue + # https://github.com/googleapis/python-api-core/issues/227 + if isinstance(value, dict): + keys_to_update = [ + item + for item in value + if not hasattr(pb_type, item) and hasattr(pb_type, f"{item}_") + ] + for item in keys_to_update: + value[f"{item}_"] = value.pop(item) + + pb_value = marshal.to_proto(pb_type, value) + if pb_value is not None: params[key] = pb_value # Create the internal protocol buffer. super().__setattr__("_pb", self._meta.pb(**params)) + def _get_pb_type_from_key(self, key): + """Given a key, return the corresponding pb_type. + + Args: + key(str): The name of the field. + + Returns: + A tuple containing a key and pb_type. The pb_type will be + the composite type of the field, or the primitive type if a primitive. + If no corresponding field exists, return None. + """ + + pb_type = None + + try: + pb_type = self._meta.fields[key].pb_type + except KeyError: + # Underscores may be appended to field names + # that collide with python or proto-plus keywords. + # In case a key only exists with a `_` suffix, coerce the key + # to include the `_` suffix. It's not possible to + # natively define the same field with a trailing underscore in protobuf. + # See related issue + # https://github.com/googleapis/python-api-core/issues/227 + if f"{key}_" in self._meta.fields: + key = f"{key}_" + pb_type = self._meta.fields[key].pb_type + + return (key, pb_type) + + def __dir__(self): + desc = type(self).pb().DESCRIPTOR + names = {f_name for f_name in self._meta.fields.keys()} + names.update(m.name for m in desc.nested_types) + names.update(e.name for e in desc.enum_types) + names.update(dir(object())) + # Can't think of a better way of determining + # the special methods than manually listing them. + names.update( + ( + "__bool__", + "__contains__", + "__dict__", + "__getattr__", + "__getstate__", + "__module__", + "__setstate__", + "__weakref__", + ) + ) + + return names + def __bool__(self): """Return True if any field is truthy, False otherwise.""" return any(k in self and getattr(self, k) for k in self._meta.fields.keys()) @@ -538,7 +657,7 @@ def __contains__(self, key): to get a boolean that distinguishes between ``False`` and ``None`` (or the same for a string, int, etc.). This library transparently handles that case for you, but this method remains available to - accomodate cases not automatically covered. + accommodate cases not automatically covered. Args: key (str): The name of the field. @@ -604,13 +723,14 @@ def __getattr__(self, key): their Python equivalents. See the ``marshal`` module for more details. """ - try: - pb_type = self._meta.fields[key].pb_type - pb_value = getattr(self._pb, key) - marshal = self._meta.marshal - return marshal.to_python(pb_type, pb_value, absent=key not in self) - except KeyError as ex: - raise AttributeError(str(ex)) + (key, pb_type) = self._get_pb_type_from_key(key) + if pb_type is None: + raise AttributeError( + "Unknown field for {}: {}".format(self.__class__.__name__, key) + ) + pb_value = getattr(self._pb, key) + marshal = self._meta.marshal + return marshal.to_python(pb_type, pb_value, absent=key not in self) def __ne__(self, other): """Return True if the messages are unequal, False otherwise.""" @@ -628,7 +748,12 @@ def __setattr__(self, key, value): if key[0] == "_": return super().__setattr__(key, value) marshal = self._meta.marshal - pb_type = self._meta.fields[key].pb_type + (key, pb_type) = self._get_pb_type_from_key(key) + if pb_type is None: + raise AttributeError( + "Unknown field for {}: {}".format(self.__class__.__name__, key) + ) + pb_value = marshal.to_proto(pb_type, value) # Clear the existing field. @@ -640,6 +765,15 @@ def __setattr__(self, key, value): if pb_value is not None: self._pb.MergeFrom(self._meta.pb(**{key: pb_value})) + def __getstate__(self): + """Serialize for pickling.""" + return self._pb.SerializeToString() + + def __setstate__(self, value): + """Deserialization for pickling.""" + new_pb = self._meta.pb().FromString(value) + super().__setattr__("_pb", new_pb) + class _MessageInfo: """Metadata about a message. diff --git a/proto/modules.py b/proto/modules.py index 9d2e519e..45864a93 100644 --- a/proto/modules.py +++ b/proto/modules.py @@ -17,7 +17,8 @@ _ProtoModule = collections.namedtuple( - "ProtoModule", ["package", "marshal", "manifest"], + "ProtoModule", + ["package", "marshal", "manifest"], ) @@ -39,7 +40,11 @@ def define_module( """ if not marshal: marshal = package - return _ProtoModule(package=package, marshal=marshal, manifest=frozenset(manifest),) + return _ProtoModule( + package=package, + marshal=marshal, + manifest=frozenset(manifest), + ) __all__ = ("define_module",) diff --git a/setup.py b/setup.py index b6bc503a..012a7b7b 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ from setuptools import find_packages, setup -version = "1.19.0" +version = "1.20.3" PACKAGE_ROOT = os.path.abspath(os.path.dirname(__file__)) @@ -36,8 +36,12 @@ long_description=README, platforms="Posix; MacOS X", include_package_data=True, - install_requires=("protobuf >= 3.12.0",), - extras_require={"testing": ["google-api-core[grpc] >= 1.22.2",],}, + install_requires=("protobuf >= 3.19.0",), + extras_require={ + "testing": [ + "google-api-core[grpc] >= 1.22.2", + ], + }, python_requires=">=3.6", classifiers=[ "Development Status :: 5 - Production/Stable", @@ -50,6 +54,7 @@ "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", "Topic :: Software Development :: Code Generators", "Topic :: Software Development :: Libraries :: Python Modules", ], diff --git a/testing/constraints-3.6.txt b/testing/constraints-3.6.txt index 31111ae4..852feeef 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.15.6 google-api-core==1.22.2 +protobuf==3.19.0 diff --git a/tests/clam.py b/tests/clam.py index a4255fe8..4946ebe1 100644 --- a/tests/clam.py +++ b/tests/clam.py @@ -14,7 +14,13 @@ import proto -__protobuf__ = proto.module(package="ocean.clam.v1", manifest={"Clam", "Species",},) +__protobuf__ = proto.module( + package="ocean.clam.v1", + manifest={ + "Clam", + "Species", + }, +) class Species(proto.Enum): diff --git a/tests/conftest.py b/tests/conftest.py index b60b91d2..f1f8a096 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -92,7 +92,9 @@ def _register_messages(scope, iterable, sym_db): """Create and register messages from the file descriptor.""" for name, descriptor in iterable.items(): new_msg = reflection.GeneratedProtocolMessageType( - name, (message.Message,), {"DESCRIPTOR": descriptor, "__module__": None}, + name, + (message.Message,), + {"DESCRIPTOR": descriptor, "__module__": None}, ) sym_db.RegisterMessage(new_msg) setattr(scope, name, new_msg) diff --git a/tests/enums_test.py b/tests/enums_test.py new file mode 100644 index 00000000..59c5e671 --- /dev/null +++ b/tests/enums_test.py @@ -0,0 +1,33 @@ +# 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 + +__protobuf__ = proto.module( + package="test.proto", + manifest={ + "Enums", + }, +) + + +class OneEnum(proto.Enum): + UNSPECIFIED = 0 + SOME_VALUE = 1 + + +class OtherEnum(proto.Enum): + UNSPECIFIED = 0 + APPLE = 1 + BANANA = 2 diff --git a/tests/mollusc.py b/tests/mollusc.py index d12e4cb9..c92f161a 100644 --- a/tests/mollusc.py +++ b/tests/mollusc.py @@ -15,7 +15,12 @@ import proto import zone -__protobuf__ = proto.module(package="ocean.mollusc.v1", manifest={"Mollusc",},) +__protobuf__ = proto.module( + package="ocean.mollusc.v1", + manifest={ + "Mollusc", + }, +) class Mollusc(proto.Message): diff --git a/tests/test_datetime_helpers.py b/tests/test_datetime_helpers.py index 84001f92..264b5296 100644 --- a/tests/test_datetime_helpers.py +++ b/tests/test_datetime_helpers.py @@ -173,7 +173,6 @@ def test_from_rfc3339_w_full_precision(): assert stamp == expected -@staticmethod @pytest.mark.parametrize( "fractional, nanos", [ @@ -281,7 +280,7 @@ def _to_seconds(value): """Convert a datetime to seconds since the unix epoch. Args: - value (datetime.datetime): The datetime to covert. + value (datetime.datetime): The datetime to convert. Returns: int: Microseconds since the unix epoch. diff --git a/tests/test_enum_total_ordering.py b/tests/test_enum_total_ordering.py new file mode 100644 index 00000000..ad7a3691 --- /dev/null +++ b/tests/test_enum_total_ordering.py @@ -0,0 +1,93 @@ +# Copyright 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 pytest + +import enums_test + + +def test_total_ordering_w_same_enum_type(): + to_compare = enums_test.OneEnum.SOME_VALUE + + for item in enums_test.OneEnum: + if item.value < to_compare.value: + assert not to_compare == item + assert to_compare != item + assert not to_compare < item + assert not to_compare <= item + assert to_compare > item + assert to_compare >= item + elif item.value > to_compare.value: + assert not to_compare == item + assert to_compare != item + assert to_compare < item + assert to_compare <= item + assert not to_compare > item + assert not to_compare >= item + else: # item.value == to_compare.value: + assert to_compare == item + assert not to_compare != item + assert not to_compare < item + assert to_compare <= item + assert not to_compare > item + assert to_compare >= item + + +def test_total_ordering_w_other_enum_type(): + to_compare = enums_test.OneEnum.SOME_VALUE + + for item in enums_test.OtherEnum: + assert not to_compare == item + assert to_compare.SOME_VALUE != item + with pytest.raises(TypeError): + assert not to_compare < item + with pytest.raises(TypeError): + assert not to_compare <= item + with pytest.raises(TypeError): + assert not to_compare > item + with pytest.raises(TypeError): + assert not to_compare >= item + + +@pytest.mark.parametrize("int_val", range(-1, 3)) +def test_total_ordering_w_int(int_val): + to_compare = enums_test.OneEnum.SOME_VALUE + + if int_val < to_compare.value: + assert not to_compare == int_val + assert to_compare != int_val + assert not to_compare < int_val + assert not to_compare <= int_val + assert to_compare > int_val + assert to_compare >= int_val + elif int_val > to_compare.value: + assert not to_compare == int_val + assert to_compare != int_val + assert to_compare < int_val + assert to_compare <= int_val + assert not to_compare > int_val + assert not to_compare >= int_val + else: # int_val == to_compare.value: + assert to_compare == int_val + assert not to_compare != int_val + assert not to_compare < int_val + assert to_compare <= int_val + assert not to_compare > int_val + assert to_compare >= int_val + + +def test_hashing(): + to_hash = enums_test.OneEnum.SOME_VALUE + + {to_hash: "testing"} # no raise diff --git a/tests/test_fields_bytes.py b/tests/test_fields_bytes.py index 3e595d7a..625dcd91 100644 --- a/tests/test_fields_bytes.py +++ b/tests/test_fields_bytes.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import base64 import pytest import proto @@ -71,5 +72,25 @@ class Foo(proto.Message): # for strings (but not vice versa). foo.bar = b"anything" assert foo.bar == "anything" - with pytest.raises(TypeError): - foo.baz = "anything" + + # We need to permit setting bytes fields from strings, + # but the marshalling needs to base64 decode the result. + # This is a requirement for interop with the vanilla protobuf runtime: + # converting a proto message to a dict base64 encodes the bytes + # because it may be sent over the network via a protocol like HTTP. + encoded_swallow: str = base64.urlsafe_b64encode(b"unladen swallow").decode("utf-8") + assert type(encoded_swallow) == str + foo.baz = encoded_swallow + assert foo.baz == b"unladen swallow" + + +def test_bytes_to_dict_bidi(): + class Foo(proto.Message): + bar = proto.Field(proto.BYTES, number=1) + + foo = Foo(bar=b"spam") + + foo_dict = Foo.to_dict(foo) + foo_two = Foo(foo_dict) + + assert foo == foo_two diff --git a/tests/test_fields_int.py b/tests/test_fields_int.py index c3a979c0..81e15e4e 100644 --- a/tests/test_fields_int.py +++ b/tests/test_fields_int.py @@ -65,12 +65,12 @@ class Foo(proto.Message): big = proto.Field(proto.INT64, number=2) foo = Foo() - foo.big = 2 ** 40 - assert foo.big == 2 ** 40 + foo.big = 2**40 + assert foo.big == 2**40 with pytest.raises(ValueError): - foo.small = 2 ** 40 + foo.small = 2**40 with pytest.raises(ValueError): - Foo(small=2 ** 40) + Foo(small=2**40) def test_int_unsigned(): @@ -93,3 +93,46 @@ class Foo(proto.Message): bar_field = Foo.meta.fields["bar"] assert bar_field.descriptor is bar_field.descriptor + + +def test_int64_dict_round_trip(): + # When converting a message to other types, protobuf turns int64 fields + # into decimal coded strings. + # This is not a problem for round trip JSON, but it is a problem + # when doing a round trip conversion from a message to a dict to a message. + # See https://github.com/protocolbuffers/protobuf/issues/2679 + # and + # https://developers.google.com/protocol-buffers/docs/proto3#json + # for more details. + class Squid(proto.Message): + mass_kg = proto.Field(proto.INT64, number=1) + length_cm = proto.Field(proto.UINT64, number=2) + age_s = proto.Field(proto.FIXED64, number=3) + depth_m = proto.Field(proto.SFIXED64, number=4) + serial_num = proto.Field(proto.SINT64, number=5) + + s = Squid(mass_kg=10, length_cm=20, age_s=30, depth_m=40, serial_num=50) + + s_dict = Squid.to_dict(s) + + s2 = Squid(s_dict) + + assert s == s2 + + # Double check that the conversion works with deeply nested messages. + class Clam(proto.Message): + class Shell(proto.Message): + class Pearl(proto.Message): + mass_kg = proto.Field(proto.INT64, number=1) + + pearl = proto.Field(Pearl, number=1) + + shell = proto.Field(Shell, number=1) + + c = Clam(shell=Clam.Shell(pearl=Clam.Shell.Pearl(mass_kg=10))) + + c_dict = Clam.to_dict(c) + + c2 = Clam(c_dict) + + assert c == c2 diff --git a/tests/test_fields_map_composite.py b/tests/test_fields_map_composite.py index 13bf0c5f..3b2d8fd9 100644 --- a/tests/test_fields_map_composite.py +++ b/tests/test_fields_map_composite.py @@ -22,7 +22,12 @@ class Foo(proto.Message): bar = proto.Field(proto.INT32, number=1) class Baz(proto.Message): - foos = proto.MapField(proto.STRING, proto.MESSAGE, number=1, message=Foo,) + foos = proto.MapField( + proto.STRING, + proto.MESSAGE, + number=1, + message=Foo, + ) baz = Baz(foos={"i": Foo(bar=42), "j": Foo(bar=24)}) assert len(baz.foos) == 2 @@ -36,7 +41,12 @@ class Foo(proto.Message): bar = proto.Field(proto.INT32, number=1) class Baz(proto.Message): - foos = proto.MapField(proto.STRING, proto.MESSAGE, number=1, message=Foo,) + foos = proto.MapField( + proto.STRING, + proto.MESSAGE, + number=1, + message=Foo, + ) baz = Baz(foos={"i": {"bar": 42}, "j": {"bar": 24}}) assert len(baz.foos) == 2 @@ -52,7 +62,12 @@ class Foo(proto.Message): bar = proto.Field(proto.INT32, number=1) class Baz(proto.Message): - foos = proto.MapField(proto.STRING, proto.MESSAGE, number=1, message=Foo,) + foos = proto.MapField( + proto.STRING, + proto.MESSAGE, + number=1, + message=Foo, + ) baz = Baz() baz.foos["i"] = Foo(bar=42) @@ -68,7 +83,12 @@ class Foo(proto.Message): bar = proto.Field(proto.INT32, number=1) class Baz(proto.Message): - foos = proto.MapField(proto.STRING, proto.MESSAGE, number=1, message=Foo,) + foos = proto.MapField( + proto.STRING, + proto.MESSAGE, + number=1, + message=Foo, + ) baz = Baz() baz.foos["i"] = Foo() @@ -82,7 +102,12 @@ class Foo(proto.Message): bar = proto.Field(proto.INT32, number=1) class Baz(proto.Message): - foos = proto.MapField(proto.STRING, proto.MESSAGE, number=1, message=Foo,) + foos = proto.MapField( + proto.STRING, + proto.MESSAGE, + number=1, + message=Foo, + ) baz = Baz() baz.foos["i"] = Foo(bar=42) diff --git a/tests/test_fields_mitigate_collision.py b/tests/test_fields_mitigate_collision.py new file mode 100644 index 00000000..117af48a --- /dev/null +++ b/tests/test_fields_mitigate_collision.py @@ -0,0 +1,81 @@ +# Copyright 2022 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 +# +# https://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 +import pytest + +# Underscores may be appended to field names +# that collide with python or proto-plus keywords. +# In case a key only exists with a `_` suffix, coerce the key +# to include the `_` suffix. It's not possible to +# natively define the same field with a trailing underscore in protobuf. +# See related issue +# https://github.com/googleapis/python-api-core/issues/227 +def test_fields_mitigate_collision(): + class TestMessage(proto.Message): + spam_ = proto.Field(proto.STRING, number=1) + eggs = proto.Field(proto.STRING, number=2) + + class TextStream(proto.Message): + text_stream = proto.Field(TestMessage, number=1) + + obj = TestMessage(spam_="has_spam") + obj.eggs = "has_eggs" + assert obj.spam_ == "has_spam" + + # Test that `spam` is coerced to `spam_` + modified_obj = TestMessage({"spam": "has_spam", "eggs": "has_eggs"}) + assert modified_obj.spam_ == "has_spam" + + # Test get and set + modified_obj.spam = "no_spam" + assert modified_obj.spam == "no_spam" + + modified_obj.spam_ = "yes_spam" + assert modified_obj.spam_ == "yes_spam" + + modified_obj.spam = "maybe_spam" + assert modified_obj.spam_ == "maybe_spam" + + modified_obj.spam_ = "maybe_not_spam" + assert modified_obj.spam == "maybe_not_spam" + + # Try nested values + modified_obj = TextStream( + text_stream=TestMessage({"spam": "has_spam", "eggs": "has_eggs"}) + ) + assert modified_obj.text_stream.spam_ == "has_spam" + + # Test get and set for nested values + modified_obj.text_stream.spam = "no_spam" + assert modified_obj.text_stream.spam == "no_spam" + + modified_obj.text_stream.spam_ = "yes_spam" + assert modified_obj.text_stream.spam_ == "yes_spam" + + modified_obj.text_stream.spam = "maybe_spam" + assert modified_obj.text_stream.spam_ == "maybe_spam" + + modified_obj.text_stream.spam_ = "maybe_not_spam" + assert modified_obj.text_stream.spam == "maybe_not_spam" + + with pytest.raises(AttributeError): + assert modified_obj.text_stream.attribute_does_not_exist == "n/a" + + with pytest.raises(AttributeError): + modified_obj.text_stream.attribute_does_not_exist = "n/a" + + # Try using dict + modified_obj = TextStream(text_stream={"spam": "has_spam", "eggs": "has_eggs"}) + assert modified_obj.text_stream.spam_ == "has_spam" diff --git a/tests/test_fields_repeated_composite.py b/tests/test_fields_repeated_composite.py index 2ce691ef..29c7e9fe 100644 --- a/tests/test_fields_repeated_composite.py +++ b/tests/test_fields_repeated_composite.py @@ -75,7 +75,9 @@ class Baz(proto.Message): def test_repeated_composite_marshaled(): class Foo(proto.Message): timestamps = proto.RepeatedField( - proto.MESSAGE, message=timestamp_pb2.Timestamp, number=1, + proto.MESSAGE, + message=timestamp_pb2.Timestamp, + number=1, ) foo = Foo( diff --git a/tests/test_file_info_salting.py b/tests/test_file_info_salting.py index c7a91d93..4fce9105 100644 --- a/tests/test_file_info_salting.py +++ b/tests/test_file_info_salting.py @@ -34,7 +34,9 @@ def sample_file_info(name): filename, _file_info._FileInfo( descriptor=descriptor_pb2.FileDescriptorProto( - name=filename, package=package, syntax="proto3", + name=filename, + package=package, + syntax="proto3", ), enums=collections.OrderedDict(), messages=collections.OrderedDict(), diff --git a/tests/test_file_info_salting_with_manifest.py b/tests/test_file_info_salting_with_manifest.py index 1fc50462..2d8f75eb 100644 --- a/tests/test_file_info_salting_with_manifest.py +++ b/tests/test_file_info_salting_with_manifest.py @@ -21,7 +21,10 @@ from proto import _file_info, _package_info PACKAGE = "a.test.package.salting.with.manifest" -__protobuf__ = proto.module(package=PACKAGE, manifest={"This", "That"},) +__protobuf__ = proto.module( + package=PACKAGE, + manifest={"This", "That"}, +) class This(proto.Message): @@ -49,7 +52,9 @@ def sample_file_info(name): filename, _file_info._FileInfo( descriptor=descriptor_pb2.FileDescriptorProto( - name=filename, package=package, syntax="proto3", + name=filename, + package=package, + syntax="proto3", ), enums=collections.OrderedDict(), messages=collections.OrderedDict(), diff --git a/tests/test_json.py b/tests/test_json.py index d1cbc1fb..8faa96d4 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -148,3 +148,17 @@ class Squid(proto.Message): assert s.mass_kg == 20 assert Squid.to_json(s, preserving_proto_field_name=True) == json_str + + +def test_json_name(): + class Squid(proto.Message): + massKg = proto.Field(proto.INT32, number=1, json_name="mass_in_kilograms") + + s = Squid(massKg=20) + j = Squid.to_json(s) + + assert "mass_in_kilograms" in j + + s_two = Squid.from_json(j) + + assert s == s_two diff --git a/tests/test_marshal_register.py b/tests/test_marshal_register.py index 8a9597f3..3ca1a2a8 100644 --- a/tests/test_marshal_register.py +++ b/tests/test_marshal_register.py @@ -33,19 +33,6 @@ def to_python(self, value, *, absent=None): assert isinstance(marshal._rules[empty_pb2.Empty], Rule) -def test_invalid_target_registration(): - marshal = BaseMarshal() - with pytest.raises(TypeError): - - @marshal.register(object) - class Rule: - def to_proto(self, value): - return value - - def to_python(self, value, *, absent=None): - return value - - def test_invalid_marshal_class(): marshal = BaseMarshal() with pytest.raises(TypeError): diff --git a/tests/test_marshal_stringy_numbers.py b/tests/test_marshal_stringy_numbers.py new file mode 100644 index 00000000..f3f40831 --- /dev/null +++ b/tests/test_marshal_stringy_numbers.py @@ -0,0 +1,50 @@ +# Copyright 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 +# +# https://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 pytest + +from proto.marshal.marshal import BaseMarshal +from proto.primitives import ProtoType + +INT_32BIT_PLUS_ONE = 0xFFFFFFFF + 1 + + +@pytest.mark.parametrize( + "pb_type,value,expected", + [ + (ProtoType.INT64, 0, 0), + (ProtoType.INT64, INT_32BIT_PLUS_ONE, INT_32BIT_PLUS_ONE), + (ProtoType.SINT64, -INT_32BIT_PLUS_ONE, -INT_32BIT_PLUS_ONE), + (ProtoType.INT64, None, None), + (ProtoType.UINT64, 0, 0), + (ProtoType.UINT64, INT_32BIT_PLUS_ONE, INT_32BIT_PLUS_ONE), + (ProtoType.UINT64, None, None), + (ProtoType.SINT64, 0, 0), + (ProtoType.SINT64, INT_32BIT_PLUS_ONE, INT_32BIT_PLUS_ONE), + (ProtoType.SINT64, -INT_32BIT_PLUS_ONE, -INT_32BIT_PLUS_ONE), + (ProtoType.SINT64, None, None), + (ProtoType.FIXED64, 0, 0), + (ProtoType.FIXED64, INT_32BIT_PLUS_ONE, INT_32BIT_PLUS_ONE), + (ProtoType.FIXED64, -INT_32BIT_PLUS_ONE, -INT_32BIT_PLUS_ONE), + (ProtoType.FIXED64, None, None), + (ProtoType.SFIXED64, 0, 0), + (ProtoType.SFIXED64, INT_32BIT_PLUS_ONE, INT_32BIT_PLUS_ONE), + (ProtoType.SFIXED64, -INT_32BIT_PLUS_ONE, -INT_32BIT_PLUS_ONE), + (ProtoType.SFIXED64, None, None), + ], +) +def test_marshal_to_proto_stringy_numbers(pb_type, value, expected): + + marshal = BaseMarshal() + assert marshal.to_proto(pb_type, value) == expected diff --git a/tests/test_marshal_types_dates.py b/tests/test_marshal_types_dates.py index 63185893..5fe09ab5 100644 --- a/tests/test_marshal_types_dates.py +++ b/tests/test_marshal_types_dates.py @@ -28,7 +28,9 @@ def test_timestamp_read(): class Foo(proto.Message): event_time = proto.Field( - proto.MESSAGE, number=1, message=timestamp_pb2.Timestamp, + proto.MESSAGE, + number=1, + message=timestamp_pb2.Timestamp, ) foo = Foo(event_time=timestamp_pb2.Timestamp(seconds=1335020400)) @@ -45,7 +47,9 @@ class Foo(proto.Message): def test_timestamp_write_init(): class Foo(proto.Message): event_time = proto.Field( - proto.MESSAGE, number=1, message=timestamp_pb2.Timestamp, + proto.MESSAGE, + number=1, + message=timestamp_pb2.Timestamp, ) foo = Foo(event_time=DatetimeWithNanoseconds(2012, 4, 21, 15, tzinfo=timezone.utc)) @@ -60,7 +64,9 @@ class Foo(proto.Message): def test_timestamp_write(): class Foo(proto.Message): event_time = proto.Field( - proto.MESSAGE, number=1, message=timestamp_pb2.Timestamp, + proto.MESSAGE, + number=1, + message=timestamp_pb2.Timestamp, ) foo = Foo() @@ -77,7 +83,9 @@ class Foo(proto.Message): def test_timestamp_write_pb2(): class Foo(proto.Message): event_time = proto.Field( - proto.MESSAGE, number=1, message=timestamp_pb2.Timestamp, + proto.MESSAGE, + number=1, + message=timestamp_pb2.Timestamp, ) foo = Foo() @@ -93,7 +101,9 @@ class Foo(proto.Message): def test_timestamp_rmw_nanos(): class Foo(proto.Message): event_time = proto.Field( - proto.MESSAGE, number=1, message=timestamp_pb2.Timestamp, + proto.MESSAGE, + number=1, + message=timestamp_pb2.Timestamp, ) foo = Foo() @@ -110,7 +120,9 @@ class Foo(proto.Message): def test_timestamp_absence(): class Foo(proto.Message): event_time = proto.Field( - proto.MESSAGE, number=1, message=timestamp_pb2.Timestamp, + proto.MESSAGE, + number=1, + message=timestamp_pb2.Timestamp, ) foo = Foo() @@ -120,7 +132,9 @@ class Foo(proto.Message): def test_timestamp_del(): class Foo(proto.Message): event_time = proto.Field( - proto.MESSAGE, number=1, message=timestamp_pb2.Timestamp, + proto.MESSAGE, + number=1, + message=timestamp_pb2.Timestamp, ) foo = Foo(event_time=DatetimeWithNanoseconds(2012, 4, 21, 15, tzinfo=timezone.utc)) @@ -130,7 +144,11 @@ class Foo(proto.Message): def test_duration_read(): class Foo(proto.Message): - ttl = proto.Field(proto.MESSAGE, number=1, message=duration_pb2.Duration,) + ttl = proto.Field( + proto.MESSAGE, + number=1, + message=duration_pb2.Duration, + ) foo = Foo(ttl=duration_pb2.Duration(seconds=60, nanos=1000)) assert isinstance(foo.ttl, timedelta) @@ -142,7 +160,11 @@ class Foo(proto.Message): def test_duration_write_init(): class Foo(proto.Message): - ttl = proto.Field(proto.MESSAGE, number=1, message=duration_pb2.Duration,) + ttl = proto.Field( + proto.MESSAGE, + number=1, + message=duration_pb2.Duration, + ) foo = Foo(ttl=timedelta(days=2)) assert isinstance(foo.ttl, timedelta) @@ -155,7 +177,11 @@ class Foo(proto.Message): def test_duration_write(): class Foo(proto.Message): - ttl = proto.Field(proto.MESSAGE, number=1, message=duration_pb2.Duration,) + ttl = proto.Field( + proto.MESSAGE, + number=1, + message=duration_pb2.Duration, + ) foo = Foo() foo.ttl = timedelta(seconds=120) @@ -167,7 +193,11 @@ class Foo(proto.Message): def test_duration_write_pb2(): class Foo(proto.Message): - ttl = proto.Field(proto.MESSAGE, number=1, message=duration_pb2.Duration,) + ttl = proto.Field( + proto.MESSAGE, + number=1, + message=duration_pb2.Duration, + ) foo = Foo() foo.ttl = duration_pb2.Duration(seconds=120) @@ -179,7 +209,11 @@ class Foo(proto.Message): def test_duration_del(): class Foo(proto.Message): - ttl = proto.Field(proto.MESSAGE, number=1, message=duration_pb2.Duration,) + ttl = proto.Field( + proto.MESSAGE, + number=1, + message=duration_pb2.Duration, + ) foo = Foo(ttl=timedelta(seconds=900)) del foo.ttl @@ -191,7 +225,11 @@ class Foo(proto.Message): def test_duration_nanos_rmw(): class Foo(proto.Message): - ttl = proto.Field(proto.MESSAGE, number=1, message=duration_pb2.Duration,) + ttl = proto.Field( + proto.MESSAGE, + number=1, + message=duration_pb2.Duration, + ) foo = Foo(ttl=timedelta(microseconds=50)) assert foo.ttl.microseconds == 50 diff --git a/tests/test_marshal_types_enum.py b/tests/test_marshal_types_enum.py index 6cd348c3..02626a8d 100644 --- a/tests/test_marshal_types_enum.py +++ b/tests/test_marshal_types_enum.py @@ -66,7 +66,11 @@ class Bivalve(proto.Enum): OYSTER = 1 class MolluscContainer(proto.Message): - bivalves = proto.RepeatedField(proto.ENUM, number=1, enum=Bivalve,) + bivalves = proto.RepeatedField( + proto.ENUM, + number=1, + enum=Bivalve, + ) mc = MolluscContainer() clam = Bivalve.CLAM @@ -82,7 +86,12 @@ class Bivalve(proto.Enum): OYSTER = 1 class MolluscContainer(proto.Message): - bivalves = proto.MapField(proto.STRING, proto.ENUM, number=1, enum=Bivalve,) + bivalves = proto.MapField( + proto.STRING, + proto.ENUM, + number=1, + enum=Bivalve, + ) mc = MolluscContainer() clam = Bivalve.CLAM diff --git a/tests/test_marshal_types_wrappers_bool.py b/tests/test_marshal_types_wrappers_bool.py index ffe206a6..e27caf99 100644 --- a/tests/test_marshal_types_wrappers_bool.py +++ b/tests/test_marshal_types_wrappers_bool.py @@ -20,7 +20,11 @@ def test_bool_value_init(): class Foo(proto.Message): - bar = proto.Field(proto.MESSAGE, message=wrappers_pb2.BoolValue, number=1,) + bar = proto.Field( + proto.MESSAGE, + message=wrappers_pb2.BoolValue, + number=1, + ) assert Foo(bar=True).bar is True assert Foo(bar=False).bar is False @@ -29,7 +33,11 @@ class Foo(proto.Message): def test_bool_value_init_dict(): class Foo(proto.Message): - bar = proto.Field(proto.MESSAGE, message=wrappers_pb2.BoolValue, number=1,) + bar = proto.Field( + proto.MESSAGE, + message=wrappers_pb2.BoolValue, + number=1, + ) assert Foo({"bar": True}).bar is True assert Foo({"bar": False}).bar is False @@ -38,7 +46,11 @@ class Foo(proto.Message): def test_bool_value_distinction_from_bool(): class Foo(proto.Message): - bar = proto.Field(proto.MESSAGE, message=wrappers_pb2.BoolValue, number=1,) + bar = proto.Field( + proto.MESSAGE, + message=wrappers_pb2.BoolValue, + number=1, + ) baz = proto.Field(proto.BOOL, number=2) assert Foo().bar is None @@ -63,7 +75,11 @@ class Foo(proto.Message): def test_bool_value_write_bool_value(): class Foo(proto.Message): - bar = proto.Field(proto.MESSAGE, message=wrappers_pb2.BoolValue, number=1,) + bar = proto.Field( + proto.MESSAGE, + message=wrappers_pb2.BoolValue, + number=1, + ) foo = Foo(bar=True) foo.bar = wrappers_pb2.BoolValue() @@ -72,7 +88,11 @@ class Foo(proto.Message): def test_bool_value_del(): class Foo(proto.Message): - bar = proto.Field(proto.MESSAGE, message=wrappers_pb2.BoolValue, number=1,) + bar = proto.Field( + proto.MESSAGE, + message=wrappers_pb2.BoolValue, + number=1, + ) foo = Foo(bar=False) assert foo.bar is False diff --git a/tests/test_message.py b/tests/test_message.py index 5351dbd7..3146f0bb 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -346,3 +346,76 @@ class Squid(proto.Message): with pytest.raises(TypeError): Mollusc.Squid.copy_from(m.squid, (("mass_kg", 20))) + + +def test_dir(): + class Mollusc(proto.Message): + class Class(proto.Enum): + UNKNOWN = 0 + GASTROPOD = 1 + BIVALVE = 2 + CEPHALOPOD = 3 + + class Arm(proto.Message): + length_cm = proto.Field(proto.INT32, number=1) + + mass_kg = proto.Field(proto.INT32, number=1) + class_ = proto.Field(Class, number=2) + arms = proto.RepeatedField(Arm, number=3) + + expected = ( + { + # Fields and nested message and enum types + "arms", + "class_", + "mass_kg", + "Arm", + "Class", + } + | { + # Other methods and attributes + "__bool__", + "__contains__", + "__dict__", + "__getattr__", + "__getstate__", + "__module__", + "__setstate__", + "__weakref__", + } + | set(dir(object)) + ) # Gets the long tail of dunder methods and attributes. + + actual = set(dir(Mollusc())) + + # Check instance names + assert actual == expected + + # Check type names + expected = ( + set(dir(type)) + | { + # Class methods from the MessageMeta metaclass + "copy_from", + "deserialize", + "from_json", + "meta", + "pb", + "serialize", + "to_dict", + "to_json", + "wrap", + } + | { + # Nested message and enum types + "Arm", + "Class", + } + ) + + actual = set(dir(Mollusc)) + assert actual == expected + + +def test_dir_message_base(): + assert set(dir(proto.Message)) == set(dir(type)) diff --git a/tests/test_message_filename_with_and_without_manifest.py b/tests/test_message_filename_with_and_without_manifest.py index 32c2bc15..e67e8472 100644 --- a/tests/test_message_filename_with_and_without_manifest.py +++ b/tests/test_message_filename_with_and_without_manifest.py @@ -16,7 +16,10 @@ PACKAGE = "a.test.package.with.and.without.manifest" -__protobuf__ = proto.module(package=PACKAGE, manifest={"This", "That"},) +__protobuf__ = proto.module( + package=PACKAGE, + manifest={"This", "That"}, +) class This(proto.Message): diff --git a/tests/test_message_filename_with_manifest.py b/tests/test_message_filename_with_manifest.py index e12e5a51..aa96028e 100644 --- a/tests/test_message_filename_with_manifest.py +++ b/tests/test_message_filename_with_manifest.py @@ -15,7 +15,10 @@ import proto PACKAGE = "a.test.package.with.manifest" -__protobuf__ = proto.module(package=PACKAGE, manifest={"ThisFoo", "ThisBar"},) +__protobuf__ = proto.module( + package=PACKAGE, + manifest={"ThisFoo", "ThisBar"}, +) class ThisFoo(proto.Message): diff --git a/tests/test_message_nested.py b/tests/test_message_nested.py index 89dfad68..41af9507 100644 --- a/tests/test_message_nested.py +++ b/tests/test_message_nested.py @@ -56,7 +56,8 @@ class Bacon(proto.Message): baz = proto.Field(proto.MESSAGE, number=2, message=Baz) foo = Foo( - bar={"spam": "xyz", "eggs": False}, baz=Foo.Baz(bacon=Foo.Baz.Bacon(value=42)), + bar={"spam": "xyz", "eggs": False}, + baz=Foo.Baz(bacon=Foo.Baz.Bacon(value=42)), ) assert foo.bar.spam == "xyz" assert not foo.bar.eggs diff --git a/tests/test_message_pickling.py b/tests/test_message_pickling.py new file mode 100644 index 00000000..dd97403c --- /dev/null +++ b/tests/test_message_pickling.py @@ -0,0 +1,51 @@ +# Copyright 2018 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 +# +# https://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 itertools +import pickle + +import pytest + +import proto + + +class Squid(proto.Message): + # Test primitives, enums, and repeated fields. + class Chromatophore(proto.Message): + class Color(proto.Enum): + UNKNOWN = 0 + RED = 1 + BROWN = 2 + WHITE = 3 + BLUE = 4 + + color = proto.Field(Color, number=1) + + mass_kg = proto.Field(proto.INT32, number=1) + chromatophores = proto.RepeatedField(Chromatophore, number=2) + + +def test_pickling(): + + s = Squid(mass_kg=20) + colors = ["RED", "BROWN", "WHITE", "BLUE"] + s.chromatophores = [ + {"color": c} for c in itertools.islice(itertools.cycle(colors), 10) + ] + + pickled = pickle.dumps(s) + + unpickled = pickle.loads(pickled) + + assert unpickled == s diff --git a/tests/test_modules.py b/tests/test_modules.py index 897ce46c..79a99da1 100644 --- a/tests/test_modules.py +++ b/tests/test_modules.py @@ -38,7 +38,8 @@ class Foo(proto.Message): def test_module_package_explicit_marshal(): sys.modules[__name__].__protobuf__ = proto.module( - package="spam.eggs.v1", marshal="foo", + package="spam.eggs.v1", + marshal="foo", ) try: @@ -54,7 +55,10 @@ class Foo(proto.Message): def test_module_manifest(): - __protobuf__ = proto.module(manifest={"Foo", "Bar", "Baz"}, package="spam.eggs.v1",) + __protobuf__ = proto.module( + manifest={"Foo", "Bar", "Baz"}, + package="spam.eggs.v1", + ) # We want to fake a module, but modules have attribute access, and # `frame.f_locals` is a dictionary. Since we only actually care about diff --git a/tests/zone.py b/tests/zone.py index 4e7ef402..90bea6a8 100644 --- a/tests/zone.py +++ b/tests/zone.py @@ -16,7 +16,12 @@ import proto -__protobuf__ = proto.module(package="ocean.zone.v1", manifest={"Zone",},) +__protobuf__ = proto.module( + package="ocean.zone.v1", + manifest={ + "Zone", + }, +) class Zone(proto.Enum):