diff --git a/owl-bot-staging/proto-plus/proto-plus/proto-plus.txt b/owl-bot-staging/proto-plus/proto-plus/proto-plus.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/proto-plus/.coveragerc b/packages/proto-plus/.coveragerc new file mode 100644 index 000000000000..12a43d1c07bb --- /dev/null +++ b/packages/proto-plus/.coveragerc @@ -0,0 +1,12 @@ +[run] +branch = True + +[report] +show_missing = True +omit = + proto/marshal/compat.py +exclude_lines = + # Re-enable the standard pragma + pragma: NO COVER + # Ignore debug-only repr + def __repr__ diff --git a/packages/proto-plus/.flake8 b/packages/proto-plus/.flake8 new file mode 100644 index 000000000000..fe63398b556d --- /dev/null +++ b/packages/proto-plus/.flake8 @@ -0,0 +1,8 @@ +[flake8] +ignore = + # Closing bracket mismatches opening bracket's line. + # This works poorly with type annotations in method declarations. + E123, E124 + # Line over-indented for visual indent. + # This works poorly with type annotations in method declarations. + E128, E131 diff --git a/packages/proto-plus/.gitignore b/packages/proto-plus/.gitignore new file mode 100644 index 000000000000..f2185a4d2b5a --- /dev/null +++ b/packages/proto-plus/.gitignore @@ -0,0 +1,61 @@ +*.py[cod] +*.sw[op] + +# C extensions +*.so + +# Packages +*.egg +*.egg-info +dist +build +eggs +parts +bin +var +sdist +develop-eggs +.installed.cfg +lib +lib64 +__pycache__ + +# Installer logs +pip-log.txt + +# Unit test / coverage reports +.coverage +.nox +.tox +.cache +.pytest_cache +htmlcov + +# Translations +*.mo + +# Mac +.DS_Store + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# JetBrains +.idea + +# Built documentation +docs/_build +docs/_build_doc2dash + +# Virtual environment +env/ +coverage.xml + +# System test environment variables. +system_tests/local_test_setup + +# Make sure a generated file isn't accidentally committed. +pylintrc +pylintrc.test diff --git a/packages/proto-plus/.librarian/state.yaml b/packages/proto-plus/.librarian/state.yaml new file mode 100644 index 000000000000..7705ed2d30f0 --- /dev/null +++ b/packages/proto-plus/.librarian/state.yaml @@ -0,0 +1,10 @@ +image: us-central1-docker.pkg.dev/cloud-sdk-librarian-prod/images-prod/python-librarian-generator@sha256:8e2c32496077054105bd06c54a59d6a6694287bc053588e24debe6da6920ad91 +libraries: + - id: proto-plus + version: 1.26.1 + apis: [] + source_roots: + - . + preserve_regex: [] + remove_regex: [] + tag_format: v{version} diff --git a/packages/proto-plus/.readthedocs.yml b/packages/proto-plus/.readthedocs.yml new file mode 100644 index 000000000000..309b5e76a26c --- /dev/null +++ b/packages/proto-plus/.readthedocs.yml @@ -0,0 +1,6 @@ +--- +build: + image: latest +python: + pip_install: true + version: 3.9 diff --git a/packages/proto-plus/.repo-metadata.json b/packages/proto-plus/.repo-metadata.json new file mode 100644 index 000000000000..cbe8a78e01b2 --- /dev/null +++ b/packages/proto-plus/.repo-metadata.json @@ -0,0 +1,12 @@ +{ + "name": "proto-plus", + "name_pretty": "Proto Plus", + "client_documentation": "https://googleapis.dev/python/proto-plus/latest", + "release_level": "stable", + "language": "python", + "library_type": "CORE", + "repo": "googleapis/proto-plus-python", + "distribution_name": "proto-plus", + "default_version": "", + "codeowner_team": "" +} diff --git a/packages/proto-plus/CHANGELOG.md b/packages/proto-plus/CHANGELOG.md new file mode 100644 index 000000000000..0bc4e9acd440 --- /dev/null +++ b/packages/proto-plus/CHANGELOG.md @@ -0,0 +1,529 @@ +# Changelog + +[PyPI History][1] + +[1]: https://pypi.org/project/proto-plus/#history + +## [1.26.1](https://github.com/googleapis/proto-plus-python/compare/v1.26.0...v1.26.1) (2025-03-05) + + +### Bug Fixes + +* **deps:** Allow protobuf 6.x ([#536](https://github.com/googleapis/proto-plus-python/issues/536)) ([51ba025](https://github.com/googleapis/proto-plus-python/commit/51ba02513c4fa12fe94db74c4a23fed7af972ea9)) + +## [1.26.0](https://github.com/googleapis/proto-plus-python/compare/v1.25.0...v1.26.0) (2025-01-22) + + +### Features + +* Migrate to pyproject.toml ([#496](https://github.com/googleapis/proto-plus-python/issues/496)) ([82ed3b9](https://github.com/googleapis/proto-plus-python/commit/82ed3b91ae0cebd6f89ce6661590a1bc6b7fce31)) + + +### Bug Fixes + +* Construct messages with nested duration in protobuf 5.28+ ([#519](https://github.com/googleapis/proto-plus-python/issues/519)) ([197ddf8](https://github.com/googleapis/proto-plus-python/commit/197ddf8a3ae9ab21b0136f27692d0f1ecd727d5b)) +* Fix enums initialization in PyPy ([#507](https://github.com/googleapis/proto-plus-python/issues/507)) ([b8b68f2](https://github.com/googleapis/proto-plus-python/commit/b8b68f207a00129e91551ef6725f5021f821e0a9)) +* Incorrect return type annotation for Message.to_dict ([#516](https://github.com/googleapis/proto-plus-python/issues/516)) ([72990f3](https://github.com/googleapis/proto-plus-python/commit/72990f3859d77732d40db5b82c310da265e72cac)) +* Use include rather than exclude to find_namespace_packages in setup.py ([#502](https://github.com/googleapis/proto-plus-python/issues/502)) ([77e252e](https://github.com/googleapis/proto-plus-python/commit/77e252e614f6434c2c47ab6168d08f87e004be43)) + + +### Documentation + +* Update docs link in README ([#524](https://github.com/googleapis/proto-plus-python/issues/524)) ([a85be75](https://github.com/googleapis/proto-plus-python/commit/a85be75f8fb811f5345cea2786b9b7a688085a7e)) + +## [1.25.0](https://github.com/googleapis/proto-plus-python/compare/v1.24.0...v1.25.0) (2024-10-15) + + +### Features + +* Add support for Python 3.13 ([#493](https://github.com/googleapis/proto-plus-python/issues/493)) ([e9643a1](https://github.com/googleapis/proto-plus-python/commit/e9643a1f6135267d4389c77722120e6c98342a74)) + + +### Bug Fixes + +* Construct messages with nested struct ([#479](https://github.com/googleapis/proto-plus-python/issues/479)) ([aa4aa61](https://github.com/googleapis/proto-plus-python/commit/aa4aa61b8c7ac0cc34d2d5797999bb434de88737)) +* Fix 'Couldn't build proto file' when using Python 3.13 ([#492](https://github.com/googleapis/proto-plus-python/issues/492)) ([a48c39f](https://github.com/googleapis/proto-plus-python/commit/a48c39ff2212261bc932d10132086a6c55be22e9)) +* Fix conda compatibility issue ([#475](https://github.com/googleapis/proto-plus-python/issues/475)) ([e2f9c9d](https://github.com/googleapis/proto-plus-python/commit/e2f9c9d1c87230d0e8d9ccacdfd0872792d54f1b)) +* Fix issue with equality comparison of repeated field with None ([#477](https://github.com/googleapis/proto-plus-python/issues/477)) ([3476348](https://github.com/googleapis/proto-plus-python/commit/3476348c995af2ce5dfcbcc688e9ddf98fa36360)) +* Remove check for Protobuf version ([#474](https://github.com/googleapis/proto-plus-python/issues/474)) ([a1748a3](https://github.com/googleapis/proto-plus-python/commit/a1748a315b6b50128b0d9927b2fee353ec55975f)) + + +### Documentation + +* Fix typos in proto/message.py ([#463](https://github.com/googleapis/proto-plus-python/issues/463)) ([4d8ee65](https://github.com/googleapis/proto-plus-python/commit/4d8ee656e008ec2b22f347e5da539b6285ec4b1b)) +* Update message.py spelling error `paylod` → `payload` ([e59fc9a](https://github.com/googleapis/proto-plus-python/commit/e59fc9a4f8dd9fcb0804b77347662ae29d1a31a1)) + +## [1.24.0](https://github.com/googleapis/proto-plus-python/compare/v1.23.0...v1.24.0) (2024-06-11) + + +### Features + +* Add `always_print_fields_with_no_presence` fields to `to_json` and `to_dict` ([0f89372](https://github.com/googleapis/proto-plus-python/commit/0f893724cabe513a5a9f9c8428dbd31f1b4f1d52)) + + +### Bug Fixes + +* Add compatibility with protobuf==5.x ([0f89372](https://github.com/googleapis/proto-plus-python/commit/0f893724cabe513a5a9f9c8428dbd31f1b4f1d52)) +* AttributeError module 'google._upb._message' has no attribute 'MessageMapContainer' ([0f89372](https://github.com/googleapis/proto-plus-python/commit/0f893724cabe513a5a9f9c8428dbd31f1b4f1d52)) +* **deps:** Allow protobuf 5.x ([#457](https://github.com/googleapis/proto-plus-python/issues/457)) ([62d74e3](https://github.com/googleapis/proto-plus-python/commit/62d74e3275476b82fed48f2119fc761fe2371292)) +* Drop python 3.6 ([#456](https://github.com/googleapis/proto-plus-python/issues/456)) ([5a7666c](https://github.com/googleapis/proto-plus-python/commit/5a7666c15002aee0fab44a9aa6d3279aab3f1f69)) + + +### Documentation + +* Deprecate field `including_default_value_fields` in `to_json` and `to_dict` ([0f89372](https://github.com/googleapis/proto-plus-python/commit/0f893724cabe513a5a9f9c8428dbd31f1b4f1d52)) + +## [1.23.0](https://github.com/googleapis/proto-plus-python/compare/v1.22.3...v1.23.0) (2023-12-01) + + +### Features + +* Add additional parameters to `to_json()` and `to_dict()` methods ([#384](https://github.com/googleapis/proto-plus-python/issues/384)) ([8f13a46](https://github.com/googleapis/proto-plus-python/commit/8f13a46514e1d7653426c0db3c1021f9c794451a)) +* Add support for proto.__version__ ([#393](https://github.com/googleapis/proto-plus-python/issues/393)) ([48cd63f](https://github.com/googleapis/proto-plus-python/commit/48cd63f2d0a7c62c40d2724f46ac564c9884675b)) +* Add support for python 3.12 ([#400](https://github.com/googleapis/proto-plus-python/issues/400)) ([1b3a96f](https://github.com/googleapis/proto-plus-python/commit/1b3a96fae7a21bf0120a79ba6bf57aacfd2a0db4)) + + +### Bug Fixes + +* Use setuptools.find_namespace_packages ([#412](https://github.com/googleapis/proto-plus-python/issues/412)) ([30a6864](https://github.com/googleapis/proto-plus-python/commit/30a6864739eb8fb116caa5873044d3999f37f578)) + + +### Documentation + +* Add documentation on how to query the current oneof in a given message ([#408](https://github.com/googleapis/proto-plus-python/issues/408)) ([d89d811](https://github.com/googleapis/proto-plus-python/commit/d89d81112885f3b3ca4e1342fd2034ee6797fcf6)) +* Add example for __protobuf__ module level attribute ([#409](https://github.com/googleapis/proto-plus-python/issues/409)) ([6755884](https://github.com/googleapis/proto-plus-python/commit/675588450acc4636b2d82b2bc0860a314064c4a4)) + +## [1.22.3](https://github.com/googleapis/proto-plus-python/compare/v1.22.2...v1.22.3) (2023-06-22) + + +### Bug Fixes + +* Resolve issue where marshal fails with cross api dependency ([#348](https://github.com/googleapis/proto-plus-python/issues/348)) ([0dcea18](https://github.com/googleapis/proto-plus-python/commit/0dcea18898cdc2170a945f3d96216bae6a37e60f)) + +## [1.22.2](https://github.com/googleapis/proto-plus-python/compare/v1.22.1...v1.22.2) (2023-01-05) + + +### Bug Fixes + +* Add support for Python 3.11 ([#329](https://github.com/googleapis/proto-plus-python/issues/329)) ([5cff3a0](https://github.com/googleapis/proto-plus-python/commit/5cff3a0d703fc90f757f1d150adc0dfd62aa3d2e)) + + +### Documentation + +* Fix typo in index.rst ([#342](https://github.com/googleapis/proto-plus-python/issues/342)) ([a66a378](https://github.com/googleapis/proto-plus-python/commit/a66a3782802a1088c775a6b29adb15fa0235e1f1)) + +## [1.22.1](https://github.com/googleapis/proto-plus-python/compare/v1.22.0...v1.22.1) (2022-08-29) + + +### Bug Fixes + +* Add no-pretty print option ([#336](https://github.com/googleapis/proto-plus-python/issues/336)) ([1bb228a](https://github.com/googleapis/proto-plus-python/commit/1bb228ac93543d28871645a22e5ac7fb20a0a55c)) + +## [1.22.0](https://github.com/googleapis/proto-plus-python/compare/v1.21.0...v1.22.0) (2022-08-10) + +### Features + +* Add support for protobuf v4 ([#327](https://github.com/googleapis/proto-plus-python/issues/327)) ([ed353aa](https://github.com/googleapis/proto-plus-python/commit/ed353aaf8bd5a659535eb493221320e449f3f637)) + +### Bug Fixes + +* Fix Timestamp, Duration and FieldMask marshaling in REST transport ([a2e7300](https://github.com/googleapis/proto-plus-python/commit/a2e7300368625ceec39dd2c2dfb96291ad8cf1f1)) +* fixes bug in the test. ([#332](https://github.com/googleapis/proto-plus-python/issues/332)) ([f85f470](https://github.com/googleapis/proto-plus-python/commit/f85f470c880a7bff7f3c813d33d15e657e7b5123)) + +## [1.20.6](https://github.com/googleapis/proto-plus-python/compare/v1.20.5...v1.20.6) (2022-06-13) + + +### Bug Fixes + +* **deps:** allow protobuf < 5.0.0 ([#324](https://github.com/googleapis/proto-plus-python/issues/324)) ([af4f56e](https://github.com/googleapis/proto-plus-python/commit/af4f56edb0bfbfa808f7def37e19b24bd27d4b40)) + + +### Documentation + +* fix changelog header to consistent size ([#319](https://github.com/googleapis/proto-plus-python/issues/319)) ([27d2003](https://github.com/googleapis/proto-plus-python/commit/27d2003571342e24aa74d29a45fa49d2328ff76d)) + +## [1.20.5](https://github.com/googleapis/proto-plus-python/compare/v1.20.4...v1.20.5) (2022-05-26) + + +### Bug Fixes + +* **deps:** require google-api-core[grpc] >= 1.31.5 ([1d13c41](https://github.com/googleapis/proto-plus-python/commit/1d13c415df457a87153a6fca202003fa83e56093)) +* **deps:** require protobuf>= 3.15.0, <4.0.0dev ([#315](https://github.com/googleapis/proto-plus-python/issues/315)) ([1d13c41](https://github.com/googleapis/proto-plus-python/commit/1d13c415df457a87153a6fca202003fa83e56093)) + +## [1.20.4](https://github.com/googleapis/proto-plus-python/compare/v1.20.3...v1.20.4) (2022-05-02) + + +### Bug Fixes + +* default proto package name is the module name, not "" ([#309](https://github.com/googleapis/proto-plus-python/issues/309)) ([3148a1c](https://github.com/googleapis/proto-plus-python/commit/3148a1c287eb69b397c940119cd44e5067357e17)) +* lookup attribute instead of performing a deepcopy ([#226](https://github.com/googleapis/proto-plus-python/issues/226)) ([e469059](https://github.com/googleapis/proto-plus-python/commit/e469059d70bab4c2b0a38dd52c4451b3c61bf470)) + +## [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) + + +### 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) + + +### Bug Fixes + +* update docstring to match type hint ([#172](https://www.github.com/googleapis/proto-plus-python/issues/172)) ([14dad5b](https://www.github.com/googleapis/proto-plus-python/commit/14dad5bf6c5967a720e9d3095d621dbfe208b614)) + +## [1.13.0](https://www.github.com/googleapis/proto-plus-python/compare/v1.12.0...v1.13.0) (2020-12-04) + + +### Features + +* add 3.9 support and drop 3.5 ([#167](https://www.github.com/googleapis/proto-plus-python/issues/167)) ([6d17195](https://www.github.com/googleapis/proto-plus-python/commit/6d171956e14b398aece931b9dde1013be9644b74)) + +## [1.12.0](https://www.github.com/googleapis/proto-plus-python/compare/v1.11.0...v1.12.0) (2020-11-20) + + +### Features + +* add default values parameter to to_json ([#164](https://www.github.com/googleapis/proto-plus-python/issues/164)) ([691f1b2](https://www.github.com/googleapis/proto-plus-python/commit/691f1b24454502c4ac49a88a09d1c9fbc287b2bd)) + +## [1.11.0](https://www.github.com/googleapis/proto-plus-python/compare/v1.10.2...v1.11.0) (2020-10-19) + + +### Features + +* provide a to_dict method ([#154](https://www.github.com/googleapis/proto-plus-python/issues/154)) ([ccf903e](https://www.github.com/googleapis/proto-plus-python/commit/ccf903e3cddfcb1ff539e853594b4342914b7d61)), closes [#153](https://www.github.com/googleapis/proto-plus-python/issues/153) [#151](https://www.github.com/googleapis/proto-plus-python/issues/151) + +## [1.10.2](https://www.github.com/googleapis/proto-plus-python/compare/v1.10.1...v1.10.2) (2020-10-14) + + +### Documentation + +* explain how to use repeated struct.Value ([#148](https://www.github.com/googleapis/proto-plus-python/issues/148)) ([9634ea8](https://www.github.com/googleapis/proto-plus-python/commit/9634ea8fa464c0d34f13469016f23cc2e986d973)), closes [#104](https://www.github.com/googleapis/proto-plus-python/issues/104) + +## [1.10.1](https://www.github.com/googleapis/proto-plus-python/compare/v1.10.0...v1.10.1) (2020-10-08) + + +### Bug Fixes + +* accessing an unset struct_pb2.Value field does not raise ([#140](https://www.github.com/googleapis/proto-plus-python/issues/140)) ([d045cbf](https://www.github.com/googleapis/proto-plus-python/commit/d045cbf058cbb8f4ca98dd06741270fcaee865be)) +* add LICENSE and tests to package ([#146](https://www.github.com/googleapis/proto-plus-python/issues/146)) ([815c943](https://www.github.com/googleapis/proto-plus-python/commit/815c9439a1dadb2d4111784eb18ba673ce6e6cc2)) + +## [1.10.0](https://www.github.com/googleapis/proto-plus-python/compare/v1.9.1...v1.10.0) (2020-09-24) + + +### Bug Fixes + +* loosen tag match for publish_package ([#123](https://www.github.com/googleapis/proto-plus-python/issues/123)) ([67441c9](https://www.github.com/googleapis/proto-plus-python/commit/67441c931b5f00b2e1084ce2539784ae9d9c31e6)) +* third party enums don't break first class enums ([#118](https://www.github.com/googleapis/proto-plus-python/issues/118)) ([50b87af](https://www.github.com/googleapis/proto-plus-python/commit/50b87af481bb1f19d10d64e88dc9ee39c2d5b6f8)), closes [#103](https://www.github.com/googleapis/proto-plus-python/issues/103) + + +## [1.10.0-dev2](https://www.github.com/googleapis/proto-plus-python/compare/v1.9.1...v1.10.0-dev2) (2020-09-21) + + +### Bug Fixes + +* loosen tag match for publish_package ([#123](https://www.github.com/googleapis/proto-plus-python/issues/123)) ([67441c9](https://www.github.com/googleapis/proto-plus-python/commit/67441c931b5f00b2e1084ce2539784ae9d9c31e6)) +* third party enums don't break first class enums ([#118](https://www.github.com/googleapis/proto-plus-python/issues/118)) ([50b87af](https://www.github.com/googleapis/proto-plus-python/commit/50b87af481bb1f19d10d64e88dc9ee39c2d5b6f8)), closes [#103](https://www.github.com/googleapis/proto-plus-python/issues/103) + + +## [1.9.1](https://www.github.com/googleapis/proto-plus-python/compare/v1.9.0...v1.9.1) (2020-09-08) + + +### Reverts + +* Revert "feat: json serialization and deserialization support stringy enums (#112)" (#116) ([91c6d7b](https://www.github.com/googleapis/proto-plus-python/commit/91c6d7bb27d198439bb323d2454fb94e197bf3dd)), closes [#112](https://www.github.com/googleapis/proto-plus-python/issues/112) [#116](https://www.github.com/googleapis/proto-plus-python/issues/116) + + +### Documentation + +* update README ([#120](https://www.github.com/googleapis/proto-plus-python/issues/120)) ([2077390](https://www.github.com/googleapis/proto-plus-python/commit/2077390d614acb278ab94077f131a895d7184881)) + +## [1.9.0](https://www.github.com/googleapis/proto-plus-python/compare/v1.8.1...v1.9.0) (2020-09-02) + + +### Features + +* json serialization and deserialization support stringy enums ([#112](https://www.github.com/googleapis/proto-plus-python/issues/112)) ([8d2e3a3](https://www.github.com/googleapis/proto-plus-python/commit/8d2e3a3439650dab9ca7c6ff49ed067838a02a45)), closes [#107](https://www.github.com/googleapis/proto-plus-python/issues/107) + +## [1.8.1](https://www.github.com/googleapis/proto-plus-python/compare/v1.8.0...v1.8.1) (2020-08-28) + + +### Bug Fixes + +* revert "feat: allow enum strings in json serialization and deserialization" ([#110](https://www.github.com/googleapis/proto-plus-python/issues/110)) ([bd3d50e](https://www.github.com/googleapis/proto-plus-python/commit/bd3d50e6b4d4574a21592f51adf7b248ededd545)), closes [#107](https://www.github.com/googleapis/proto-plus-python/issues/107) + +## [1.8.0](https://www.github.com/googleapis/proto-plus-python/compare/v1.7.1...v1.8.0) (2020-08-28) + + +### Features + +* allow enum strings in json serialization and deserialization ([#107](https://www.github.com/googleapis/proto-plus-python/issues/107)) ([a082f85](https://www.github.com/googleapis/proto-plus-python/commit/a082f85ffcb72e2c53c0e33e40e6df2927a41259)) + +## [1.7.1](https://www.github.com/googleapis/proto-plus-python/compare/v1.7.0...v1.7.1) (2020-08-17) + + +### Bug Fixes + +* revert algorithm for RepeatedComposite insertion. ([#101](https://www.github.com/googleapis/proto-plus-python/issues/101)) ([ae946aa](https://www.github.com/googleapis/proto-plus-python/commit/ae946aa2a3b394fa31590224fcf50593bde0ccaa)) + +## [1.7.0](https://www.github.com/googleapis/proto-plus-python/compare/v1.6.0...v1.7.0) (2020-08-07) + + +### Features + +* optimize insert for class RepeatedComposite. ([#95](https://www.github.com/googleapis/proto-plus-python/issues/95)) ([86790e3](https://www.github.com/googleapis/proto-plus-python/commit/86790e3f7d891e13835699a4e1f50aec6140fa6e)) + +## [1.6.0](https://www.github.com/googleapis/proto-plus-python/compare/v1.5.3...v1.6.0) (2020-08-05) + + +### Features + +* more performance optimizations ([#92](https://www.github.com/googleapis/proto-plus-python/issues/92)) ([19b1519](https://www.github.com/googleapis/proto-plus-python/commit/19b151960de7c83ac82e670b06cb47d6e885f627)) + +## [1.5.3](https://www.github.com/googleapis/proto-plus-python/compare/v1.5.2...v1.5.3) (2020-08-04) + + +### Bug Fixes + +* yet more perf tweaks ([#90](https://www.github.com/googleapis/proto-plus-python/issues/90)) ([eb7891c](https://www.github.com/googleapis/proto-plus-python/commit/eb7891cf05124803352b2f4fd719937356bf9167)) + +## [1.5.2](https://www.github.com/googleapis/proto-plus-python/compare/v1.5.1...v1.5.2) (2020-08-03) + + +### Bug Fixes + +* tweak to_python ([#88](https://www.github.com/googleapis/proto-plus-python/issues/88)) ([5459ede](https://www.github.com/googleapis/proto-plus-python/commit/5459ede75597b06df5a211b0e317fb2c1f4b034e)) + +## [1.5.1](https://www.github.com/googleapis/proto-plus-python/compare/v1.5.0...v1.5.1) (2020-07-30) + + +### Bug Fixes + +* numerous small performance tweaks ([#85](https://www.github.com/googleapis/proto-plus-python/issues/85)) ([7b5faf2](https://www.github.com/googleapis/proto-plus-python/commit/7b5faf2e2c20c8022c83d6a99656505aa669200b)) + +## [1.5.0](https://www.github.com/googleapis/proto-plus-python/compare/v1.4.2...v1.5.0) (2020-07-29) + + +### Features + +* support fixed filename salt to allow proto-plus use with schema registry tools ([#61](https://www.github.com/googleapis/proto-plus-python/issues/61)) ([ea86eb9](https://www.github.com/googleapis/proto-plus-python/commit/ea86eb9ac694ed1f0e711698429449f41ecfedfc)) + +## [1.4.2](https://www.github.com/googleapis/proto-plus-python/compare/v1.4.1...v1.4.2) (2020-07-23) + + +### Bug Fixes + +* getattr on an invalid field raises AttributeError ([#73](https://www.github.com/googleapis/proto-plus-python/issues/73)) ([74ea8f0](https://www.github.com/googleapis/proto-plus-python/commit/74ea8f0cd9083939e53d1de2450b649500281b9a)), closes [#31](https://www.github.com/googleapis/proto-plus-python/issues/31) + +## [1.4.1](https://www.github.com/googleapis/proto-plus-python/compare/v1.4.0...v1.4.1) (2020-07-23) + + +### Bug Fixes + +* tweak publish ci task ([#65](https://www.github.com/googleapis/proto-plus-python/issues/65)) ([983189c](https://www.github.com/googleapis/proto-plus-python/commit/983189c5effa25fb9365eb63caddb425d3cfb2b5)) + +## [1.4.0](https://www.github.com/googleapis/proto-plus-python/compare/v1.3.2...v1.4.0) (2020-07-23) + + +### Features + +* prevent unnecessary copies when deserializing proto ([#63](https://www.github.com/googleapis/proto-plus-python/issues/63)) ([5e1c061](https://www.github.com/googleapis/proto-plus-python/commit/5e1c0619b5f4c5d2a6a75ae6d45a53fef2e58823)) + +## [1.3.2](https://www.github.com/googleapis/proto-plus-python/compare/v1.3.1...v1.3.2) (2020-07-22) + + +### Bug Fixes + +* correctly handle passed in vanilla datetime.datetime ([#57](https://www.github.com/googleapis/proto-plus-python/issues/57)) ([a770816](https://www.github.com/googleapis/proto-plus-python/commit/a770816197cbce60ee023bd5b6ee6bd2d970ded8)), closes [googleapis/gapic-generator-python#544](https://www.github.com/googleapis/gapic-generator-python/issues/544) +* update encrypted pypi passwd ([#58](https://www.github.com/googleapis/proto-plus-python/issues/58)) ([d985233](https://www.github.com/googleapis/proto-plus-python/commit/d9852336d83717cb9ff24b6bec3ef1463239fea1)) + +## [1.3.1](https://www.github.com/googleapis/proto-plus-python/compare/v1.3.0...v1.3.1) (2020-07-21) + + +### Bug Fixes + +* tweak pypi circleci task ([#54](https://www.github.com/googleapis/proto-plus-python/issues/54)) ([89c49d7](https://www.github.com/googleapis/proto-plus-python/commit/89c49d700d4b6e9a434fbfced8ca39d430dd22f9)) + + +### Documentation + +* linkify pypi badge ([#50](https://www.github.com/googleapis/proto-plus-python/issues/50)) ([8ff08e2](https://www.github.com/googleapis/proto-plus-python/commit/8ff08e21e75570aad71c5e62f4c78b43139b5df2)) + +## [1.3.0](https://www.github.com/googleapis/proto-plus-python/compare/1.2.0...v1.3.0) (2020-07-16) + + +### Features + +* add convenience methods to convert to/from json ([#39](https://www.github.com/googleapis/proto-plus-python/issues/39)) ([2868946](https://www.github.com/googleapis/proto-plus-python/commit/286894609843f568c9ff367ab79542783642b801)) +* add DatetimeWithNanoseconds class to maintain Timestamp pb precision. ([#40](https://www.github.com/googleapis/proto-plus-python/issues/40)) ([a17ccd5](https://www.github.com/googleapis/proto-plus-python/commit/a17ccd52c7fa3609ce79fde84b931c0693f53171)), closes [#38](https://www.github.com/googleapis/proto-plus-python/issues/38) +* add support for proto3 optional fields ([#35](https://www.github.com/googleapis/proto-plus-python/issues/35)) ([0eb5762](https://www.github.com/googleapis/proto-plus-python/commit/0eb5762681e315635db1dffd583d91a4f32cba43)) + + +### Bug Fixes + +* Modify setup.py to indicate this is google maintained ([#45](https://www.github.com/googleapis/proto-plus-python/issues/45)) ([96b3b00](https://www.github.com/googleapis/proto-plus-python/commit/96b3b00dd6712fe44e71dedf8080b20544e95416)) diff --git a/packages/proto-plus/CODE_OF_CONDUCT.md b/packages/proto-plus/CODE_OF_CONDUCT.md new file mode 100644 index 000000000000..46b2a08ea6d1 --- /dev/null +++ b/packages/proto-plus/CODE_OF_CONDUCT.md @@ -0,0 +1,43 @@ +# Contributor Code of Conduct + +As contributors and maintainers of this project, +and in the interest of fostering an open and welcoming community, +we pledge to respect all people who contribute through reporting issues, +posting feature requests, updating documentation, +submitting pull requests or patches, and other activities. + +We are committed to making participation in this project +a harassment-free experience for everyone, +regardless of level of experience, gender, gender identity and expression, +sexual orientation, disability, personal appearance, +body size, race, ethnicity, age, religion, or nationality. + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery +* Personal attacks +* Trolling or insulting/derogatory comments +* Public or private harassment +* Publishing other's private information, +such as physical or electronic +addresses, without explicit permission +* Other unethical or unprofessional conduct. + +Project maintainers have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct. +By adopting this Code of Conduct, +project maintainers commit themselves to fairly and consistently +applying these principles to every aspect of managing this project. +Project maintainers who do not follow or enforce the Code of Conduct +may be permanently removed from the project team. + +This code of conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. + +Instances of abusive, harassing, or otherwise unacceptable behavior +may be reported by opening an issue +or contacting one or more of the project maintainers. + +This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.2.0, +available at [http://contributor-covenant.org/version/1/2/0/](http://contributor-covenant.org/version/1/2/0/) diff --git a/packages/proto-plus/CONTRIBUTING.md b/packages/proto-plus/CONTRIBUTING.md new file mode 100644 index 000000000000..6272489dae31 --- /dev/null +++ b/packages/proto-plus/CONTRIBUTING.md @@ -0,0 +1,28 @@ +# How to Contribute + +We'd love to accept your patches and contributions to this project. There are +just a few small guidelines you need to follow. + +## Contributor License Agreement + +Contributions to this project must be accompanied by a Contributor License +Agreement. You (or your employer) retain the copyright to your contribution; +this simply gives us permission to use and redistribute your contributions as +part of the project. Head over to to see +your current agreements on file or to sign a new one. + +You generally only need to submit a CLA once, so if you've already submitted one +(even if it was for a different project), you probably don't need to do it +again. + +## Code Reviews + +All submissions, including submissions by project members, require review. We +use GitHub pull requests for this purpose. Consult +[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more +information on using pull requests. + +## Community Guidelines + +This project follows [Google's Open Source Community +Guidelines](https://opensource.google/conduct/). diff --git a/packages/proto-plus/CONTRIBUTING.rst b/packages/proto-plus/CONTRIBUTING.rst new file mode 100644 index 000000000000..2e0d8efd6790 --- /dev/null +++ b/packages/proto-plus/CONTRIBUTING.rst @@ -0,0 +1,22 @@ +Contributing +============ + +We are thrilled that you are interested in contributing to this project. +Please open an issue or pull request with your ideas. + + +Contributor License Agreements +------------------------------ + +Before we can accept your pull requests, you will need to sign a Contributor +License Agreement (CLA): + +- **If you are an individual writing original source code** and **you own the + intellectual property**, then you'll need to sign an + `individual CLA `__. +- **If you work for a company that wants to allow you to contribute your work**, + then you'll need to sign a + `corporate CLA `__. + +You can sign these electronically (just scroll to the bottom). After that, +we'll be able to accept your pull requests. diff --git a/packages/proto-plus/LICENSE b/packages/proto-plus/LICENSE new file mode 100644 index 000000000000..d64569567334 --- /dev/null +++ b/packages/proto-plus/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/packages/proto-plus/MANIFEST.in b/packages/proto-plus/MANIFEST.in new file mode 100644 index 000000000000..e6c966606293 --- /dev/null +++ b/packages/proto-plus/MANIFEST.in @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2020 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. + +include README.rst LICENSE +recursive-include tests * +global-exclude *.py[co] +global-exclude __pycache__ \ No newline at end of file diff --git a/packages/proto-plus/README.rst b/packages/proto-plus/README.rst new file mode 100644 index 000000000000..0fcef70f8734 --- /dev/null +++ b/packages/proto-plus/README.rst @@ -0,0 +1,26 @@ +Proto Plus for Python +===================== + +|pypi| |release level| + + Beautiful, Pythonic protocol buffers. + +This is a wrapper around `protocol buffers`_. Protocol buffers is a +specification format for APIs, such as those inside Google. +This library provides protocol buffer message classes and objects that +largely behave like native Python types. + +.. _protocol buffers: https://developers.google.com/protocol-buffers/ + + +Documentation +------------- + +`API Documentation`_ is available on Read the Docs. + +.. _API Documentation: https://googleapis.dev/python/proto-plus/latest/ + +.. |pypi| image:: https://img.shields.io/pypi/v/proto-plus.svg + :target: https://pypi.org/project/proto-plus +.. |release level| image:: https://img.shields.io/badge/release%20level-ga-gold.svg?style=flat + :target: https://cloud.google.com/terms/launch-stages diff --git a/packages/proto-plus/SECURITY.md b/packages/proto-plus/SECURITY.md new file mode 100644 index 000000000000..8b58ae9c01ae --- /dev/null +++ b/packages/proto-plus/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/packages/proto-plus/docs/Makefile b/packages/proto-plus/docs/Makefile new file mode 100644 index 000000000000..f9f9a54ca7dc --- /dev/null +++ b/packages/proto-plus/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +SPHINXPROJ = APIClientGeneratorforPython +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/packages/proto-plus/docs/conf.py b/packages/proto-plus/docs/conf.py new file mode 100644 index 000000000000..ecc295d25a44 --- /dev/null +++ b/packages/proto-plus/docs/conf.py @@ -0,0 +1,182 @@ +# -*- coding: utf-8 -*- +# +# Configuration file for the Sphinx documentation builder. +# +# This file does only contain a selection of the most common options. For a +# full list see the documentation: +# http://www.sphinx-doc.org/en/stable/config + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import os +import sys + +import proto + + +sys.path.insert(0, os.path.abspath("..")) + + +# -- Project information ----------------------------------------------------- + +project = "Proto Plus for Python" +copyright = "2018, Google LLC" +author = "Google LLC" + +version = proto.version.__version__ + + +# -- General configuration --------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx.ext.viewcode", + "sphinx.ext.githubpages", + "sphinx.ext.napoleon", +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = ".rst" + +# The master toctree document. +master_doc = "index" + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = "en" + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path . +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = "sphinx" + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = "sphinx_rtd_theme" + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +# html_theme_options = {} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = [] + +# Custom sidebar templates, must be a dictionary that maps document names +# to template names. +# +# The default sidebars (for documents that don't match any pattern) are +# defined by theme itself. Builtin themes are using these templates by +# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', +# 'searchbox.html']``. +# +# html_sidebars = {} + + +# -- Options for HTMLHelp output --------------------------------------------- + +# Output file base name for HTML help builder. +htmlhelp_basename = "proto-plus-python" + + +# -- Options for LaTeX output ------------------------------------------------ + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + # Latex figure (float) alignmentAPIClientGeneratorforPython + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + ( + master_doc, + "proto-plus-python.tex", + "Proto Plus for Python Documentation", + "Luke Sneeringer", + "manual", + ), +] + + +# -- Options for manual page output ------------------------------------------ + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ( + master_doc, + "proto-plus-python", + "Proto Plus for Python Documentation", + [author], + 1, + ) +] + + +# -- Options for Texinfo output ---------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ( + master_doc, + "proto-plus-python", + "Proto Plus for Python Documentation", + author, + "proto-plus-python", + "One line description of project.", + "Miscellaneous", + ), +] + + +# -- Extension configuration ------------------------------------------------- + +# -- Options for intersphinx extension --------------------------------------- + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = {"python": ("https://docs.python.org/3/", None)} diff --git a/packages/proto-plus/docs/fields.rst b/packages/proto-plus/docs/fields.rst new file mode 100644 index 000000000000..1961cff07407 --- /dev/null +++ b/packages/proto-plus/docs/fields.rst @@ -0,0 +1,246 @@ +Fields +====== + +Fields are assigned using the :class:`~.Field` class, instantiated within a +:class:`~.Message` declaration. + +Fields always have a type (either a primitive, a message, or an enum) and a +``number``. + +.. code-block:: python + + import proto + + class Composer(proto.Message): + given_name = proto.Field(proto.STRING, number=1) + family_name = proto.Field(proto.STRING, number=2) + + class Song(proto.Message): + composer = proto.Field(Composer, number=1) + title = proto.Field(proto.STRING, number=2) + lyrics = proto.Field(proto.STRING, number=3) + year = proto.Field(proto.INT32, number=4) + + + +For messages and enums, assign the message or enum class directly (as shown +in the example above). + +.. note:: + + For messages declared in the same module, it is also possible to use a + string with the message class' name *if* the class is not + yet declared, which allows for declaring messages out of order or with + circular references. + +Repeated fields +--------------- + +Some fields are actually repeated fields. In protocol buffers, repeated fields +are generally equivalent to typed lists. In protocol buffers, these are +declared using the **repeated** keyword: + +.. code-block:: protobuf + + message Album { + repeated Song songs = 1; + string publisher = 2; + } + +Declare them in Python using the :class:`~.RepeatedField` class: + +.. code-block:: python + + class Album(proto.Message): + songs = proto.RepeatedField(Song, number=1) + publisher = proto.Field(proto.STRING, number=2) + + +.. note:: + + Elements **must** be appended individually for repeated fields of `struct.Value`. + + .. code-block:: python + + class Row(proto.Message): + values = proto.RepeatedField(proto.MESSAGE, number=1, message=struct.Value,) + + >>> row = Row() + >>> values = [struct_pb2.Value(string_value="hello")] + >>> for v in values: + >>> row.values.append(v) + + Direct assignment will result in an error. + + .. code-block:: python + + class Row(proto.Message): + values = proto.RepeatedField(proto.MESSAGE, number=1, message=struct.Value,) + + >>> row = Row() + >>> row.values = [struct_pb2.Value(string_value="hello")] + Traceback (most recent call last): + File "", line 1, in + File "/usr/local/google/home/busunkim/github/python-automl/.nox/unit-3-8/lib/python3.8/site-packages/proto/message.py", line 543, in __setattr__ + self._pb.MergeFrom(self._meta.pb(**{key: pb_value})) + TypeError: Value must be iterable + + +Map fields +---------- + +Similarly, some fields are map fields. In protocol buffers, map fields are +equivalent to typed dictionaries, where the keys are either strings or +integers, and the values can be any type. In protocol buffers, these use +a special ``map`` syntax: + +.. code-block:: protobuf + + message Album { + map track_list = 1; + string publisher = 2; + } + +Declare them in Python using the :class:`~.MapField` class: + +.. code-block:: python + + class Album(proto.Message): + track_list = proto.MapField(proto.UINT32, Song, number=1) + publisher = proto.Field(proto.STRING, number=2) + + +Oneofs (mutually-exclusive fields) +---------------------------------- + +Protocol buffers allows certain fields to be declared as mutually exclusive. +This is done by wrapping fields in a ``oneof`` syntax: + +.. code-block:: protobuf + + import "google/type/postal_address.proto"; + + message AlbumPurchase { + Album album = 1; + oneof delivery { + google.type.PostalAddress postal_address = 2; + string download_uri = 3; + } + } + +When using this syntax, protocol buffers will enforce that only one of the +given fields is set on the message, and setting a field within the oneof +will clear any others. + +Declare this in Python using the ``oneof`` keyword-argument, which takes +a string (which should match for all fields within the oneof): + +.. code-block:: python + + from google.type.postal_address import PostalAddress + + class AlbumPurchase(proto.Message): + album = proto.Field(Album, number=1) + postal_address = proto.Field(PostalAddress, number=2, oneof='delivery') + download_uri = proto.Field(proto.STRING, number=3, oneof='delivery') + +.. warning:: + + ``oneof`` fields **must** be declared consecutively, otherwise the C + implementation of protocol buffers will reject the message. They need not + 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 + + To query which ``oneof`` is present in a given message, use ``proto.Message._pb("oneof")``. + + 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") + assert s._pb.WhichOneof("identifier") == "name" + + s = Song(database_id="e6aa708c7e") + assert s._pb.WhichOneof("identifier") == "database_id" + + +Optional fields +--------------- + +All fields in protocol buffers are optional, but it is often necessary to +check for field presence. Sometimes legitimate values for fields can be falsy, +so checking for truthiness is not sufficient. Proto3 v3.12.0 added the +``optional`` keyword to field descriptions, which enables a mechanism for +checking field presence. + +In proto plus, fields can be marked as optional by passing ``optional=True`` +in the constructor. The message *class* then gains a field of the same name +that can be used to detect whether the field is present in message *instances*. + +.. code-block:: python + + class Song(proto.Message): + composer = proto.Field(Composer, number=1) + title = proto.Field(proto.STRING, number=2) + lyrics = proto.Field(proto.STRING, number=3) + year = proto.Field(proto.INT32, number=4) + performer = proto.Field(proto.STRING, number=5, optional=True) + + >>> s = Song( + ... composer={'given_name': 'Johann', 'family_name': 'Pachelbel'}, + ... title='Canon in D', + ... year=1680, + ... genre=Genre.CLASSICAL, + ... ) + >>> Song.performer in s + False + >>> s.performer = 'Brahms' + >>> Song.performer in s + True + >>> del s.performer + >>> Song.performer in s + False + >>> s.performer = "" # The mysterious, unnamed composer + >>> Song.performer in s + True + + +Under the hood, fields marked as optional are implemented via a synthetic +one-variant ``oneof``. See the protocolbuffers documentation_ for more +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/packages/proto-plus/docs/index.rst b/packages/proto-plus/docs/index.rst new file mode 100644 index 000000000000..d94608dd5c97 --- /dev/null +++ b/packages/proto-plus/docs/index.rst @@ -0,0 +1,39 @@ +Proto Plus for Python +===================== + + Beautiful, Pythonic protocol buffers. + +This library provides a clean, readable, straightforward pattern for +declaring messages in `protocol buffers`_. It provides a wrapper around +the official implementation, so that using messages feels natural while +retaining the power and flexibility of protocol buffers. + +.. _protocol buffers: https://developers.google.com/protocol-buffers/ + + +Installing +---------- + +Install this library using ``pip``: + +.. code-block:: shell + + $ pip install proto-plus + +This library carries a dependency on the official implementation +(protobuf_), which may install a C component. + +.. _protobuf: https://pypi.org/project/protobuf/ + + +Table of Contents +----------------- + +.. toctree:: + :maxdepth: 2 + + messages + fields + marshal + status + reference/index diff --git a/packages/proto-plus/docs/marshal.rst b/packages/proto-plus/docs/marshal.rst new file mode 100644 index 000000000000..ee61cb2e6846 --- /dev/null +++ b/packages/proto-plus/docs/marshal.rst @@ -0,0 +1,84 @@ +Type Marshaling +=============== + +Proto Plus provides a service that converts between protocol buffer objects +and native Python types (or the wrapper types provided by this library). + +This allows native Python objects to be used in place of protocol buffer +messages where appropriate. In all cases, we return the native type, and are +liberal on what we accept. + +Well-known types +---------------- + +The following types are currently handled by Proto Plus: + +=================================== ======================= ======== +Protocol buffer type Python type Nullable +=================================== ======================= ======== +``google.protobuf.BoolValue`` ``bool`` Yes +``google.protobuf.BytesValue`` ``bytes`` Yes +``google.protobuf.DoubleValue`` ``float`` Yes +``google.protobuf.Duration`` ``datetime.timedelta`` – +``google.protobuf.FloatValue`` ``float`` Yes +``google.protobuf.Int32Value`` ``int`` Yes +``google.protobuf.Int64Value`` ``int`` Yes +``google.protobuf.ListValue`` ``MutableSequence`` Yes +``google.protobuf.StringValue`` ``str`` Yes +``google.protobuf.Struct`` ``MutableMapping`` Yes +``google.protobuf.Timestamp`` ``datetime.datetime`` Yes +``google.protobuf.UInt32Value`` ``int`` Yes +``google.protobuf.UInt64Value`` ``int`` Yes +``google.protobuf.Value`` JSON-encodable values Yes +=================================== ======================= ======== + +.. note:: + + Protocol buffers include well-known types for ``Timestamp`` and + ``Duration``, both of which have nanosecond precision. However, the + Python ``datetime`` and ``timedelta`` objects have only microsecond + precision. This library converts timestamps to an implementation of + ``datetime.datetime``, DatetimeWithNanoseconds, that includes nanosecond + precision. + + 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 +------------- + +Additionally, every :class:`~.Message` subclass is a wrapper class. The +creation of the class also creates the underlying protocol buffer class, and +this is registered to the marshal. + +The underlying protocol buffer message class is accessible with the +:meth:`~.Message.pb` class method. diff --git a/packages/proto-plus/docs/messages.rst b/packages/proto-plus/docs/messages.rst new file mode 100644 index 000000000000..68d7d63d4d7f --- /dev/null +++ b/packages/proto-plus/docs/messages.rst @@ -0,0 +1,304 @@ +Messages +======== + +The fundamental building block in protocol buffers are `messages`_. +Messages are essentially permissive, strongly-typed structs (dictionaries), +which have zero or more fields that may themselves contain primitives or +other messages. + +.. code-block:: protobuf + + syntax = "proto3"; + + message Song { + Composer composer = 1; + string title = 2; + string lyrics = 3; + int32 year = 4; + } + + message Composer { + string given_name = 1; + string family_name = 2; + } + +The most common use case for protocol buffers is to write a ``.proto`` file, +and then use the protocol buffer compiler to generate code for it. + + +Declaring messages +------------------ + +However, it is possible to declare messages directly. +This is the equivalent message declaration in Python, using this library: + +.. code-block:: python + + import proto + + class Composer(proto.Message): + given_name = proto.Field(proto.STRING, number=1) + family_name = proto.Field(proto.STRING, number=2) + + class Song(proto.Message): + composer = proto.Field(Composer, number=1) + title = proto.Field(proto.STRING, number=2) + lyrics = proto.Field(proto.STRING, number=3) + year = proto.Field(proto.INT32, number=4) + +A few things to note: + +* This library only handles proto3. +* The ``number`` is really a field ID. It is *not* a value of any kind. +* All fields are optional (as is always the case in proto3). + The only general way to determine whether a field was explicitly set to its + falsy value or not set all is to mark it ``optional``. +* Because all fields are optional, it is the responsibility of application logic + to determine whether a necessary field has been set. +* You can optionally define a `__protobuf__` attribute in your module which will be used + to differentiate messages which have the same name but exist in different modules. + +.. code-block:: python + + # file a.py + import proto + + __protobuf__ = proto.module(package="a") + + class A(proto.Message): + name = proto.Field(proto.STRING, number=1) + + # file b.py + import proto + + __protobuf__ = proto.module(package="b") + + class A(proto.Message): + name = proto.Field(proto.STRING, number=1) + + # file main.py + import a + import b + + _a = a.A(name="Hello, A!") + _b = b.A(name="Hello, B!") + +.. _messages: https://developers.google.com/protocol-buffers/docs/proto3#simple + +Messages are fundamentally made up of :doc:`fields`. Most messages are nothing +more than a name and their set of fields. + + +Usage +----- + +Instantiate messages using either keyword arguments or a :class:`dict` +(and mix and matching is acceptable): + +.. code-block:: python + + >>> song = Song( + ... composer={'given_name': 'Johann', 'family_name': 'Pachelbel'}, + ... title='Canon in D', + ... year=1680, + ... ) + >>> song.composer.family_name + 'Pachelbel' + >>> 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 reference 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 +----- + +Enums are also supported: + +.. code-block:: python + + import proto + + class Genre(proto.Enum): + GENRE_UNSPECIFIED = 0 + CLASSICAL = 1 + JAZZ = 2 + ROCK = 3 + + class Composer(proto.Message): + given_name = proto.Field(proto.STRING, number=1) + family_name = proto.Field(proto.STRING, number=2) + + class Song(proto.Message): + composer = proto.Field(Composer, number=1) + title = proto.Field(proto.STRING, number=2) + lyrics = proto.Field(proto.STRING, number=3) + year = proto.Field(proto.INT32, number=4) + genre = proto.Field(Genre, number=5) + +All enums **must** begin with a ``0`` value, which is always the default in +proto3 (and, as above, indistuiguishable from unset). + +Enums utilize Python :class:`enum.IntEnum` under the hood: + +.. code-block:: python + + >>> song = Song( + ... composer={'given_name': 'Johann', 'family_name': 'Pachelbel'}, + ... title='Canon in D', + ... year=1680, + ... genre=Genre.CLASSICAL, + ... ) + >>> song.genre + + >>> song.genre.name + 'CLASSICAL' + >>> song.genre.value + 1 + +Additionally, it is possible to provide strings or plain integers: + +.. code-block:: python + + >>> song.genre = 2 + >>> song.genre + + >>> song.genre = 'CLASSICAL' + + +Serialization +------------- + +Serialization and deserialization is available through the +:meth:`~.Message.serialize` and :meth:`~.Message.deserialize` class methods. + +The :meth:`~.Message.serialize` method is available on the message *classes* +only, and accepts an instance: + +.. code-block:: python + + serialized_song = Song.serialize(song) + +The :meth:`~.Message.deserialize` method accepts a :class:`bytes`, and +returns an instance of the message: + +.. code-block:: python + + song = Song.deserialize(serialized_song) + +JSON serialization and deserialization are also available from message *classes* +via the :meth:`~.Message.to_json` and :meth:`~.Message.from_json` methods. + +.. code-block:: python + + json = Song.to_json(song) + + new_song = Song.from_json(json) + +Similarly, messages can be converted into dictionaries via the +:meth:`~.Message.to_dict` helper method. +There is no :meth:`~.Message.from_dict` method because the Message constructor +already allows construction from mapping types. + +.. code-block:: python + + 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/packages/proto-plus/docs/reference/datetime_helpers.rst b/packages/proto-plus/docs/reference/datetime_helpers.rst new file mode 100644 index 000000000000..e45ad1fe4c39 --- /dev/null +++ b/packages/proto-plus/docs/reference/datetime_helpers.rst @@ -0,0 +1,5 @@ +Datetime Helpers +---------------- + +.. automodule:: proto.datetime_helpers + :members: diff --git a/packages/proto-plus/docs/reference/index.rst b/packages/proto-plus/docs/reference/index.rst new file mode 100644 index 000000000000..35b01946e51f --- /dev/null +++ b/packages/proto-plus/docs/reference/index.rst @@ -0,0 +1,19 @@ +Reference +--------- + +Below is a reference for the major classes and functions within this +module. + +- The :doc:`message` section (which uses the ``message`` and ``fields`` + modules) handles constructing messages. +- The :doc:`marshal` module handles translating between internal protocol + buffer instances and idiomatic equivalents. +- The :doc:`datetime_helpers` has datetime related helpers to maintain + nanosecond precision. + +.. toctree:: + :maxdepth: 2 + + message + marshal + datetime_helpers diff --git a/packages/proto-plus/docs/reference/marshal.rst b/packages/proto-plus/docs/reference/marshal.rst new file mode 100644 index 000000000000..73caec4d4c0e --- /dev/null +++ b/packages/proto-plus/docs/reference/marshal.rst @@ -0,0 +1,5 @@ +Marshal +------- + +.. automodule:: proto.marshal + :members: diff --git a/packages/proto-plus/docs/reference/message.rst b/packages/proto-plus/docs/reference/message.rst new file mode 100644 index 000000000000..436b0c819176 --- /dev/null +++ b/packages/proto-plus/docs/reference/message.rst @@ -0,0 +1,21 @@ +Message and Field +----------------- + +.. autoclass:: proto.message.Message + :members: + + .. automethod:: pb + .. automethod:: wrap + .. automethod:: serialize + .. automethod:: deserialize + .. automethod:: to_json + .. automethod:: from_json + .. automethod:: to_dict + .. automethod:: copy_from + +.. automodule:: proto.fields + :members: + + +.. automodule:: proto.enums + :members: diff --git a/packages/proto-plus/docs/status.rst b/packages/proto-plus/docs/status.rst new file mode 100644 index 000000000000..6004b127ed76 --- /dev/null +++ b/packages/proto-plus/docs/status.rst @@ -0,0 +1,17 @@ +Status +====== + +Features and Limitations +------------------------ + +Nice things this library does: + +- Idiomatic protocol buffer message representation and usage. +- Wraps the official protocol buffers implementation, and exposes its objects + in the public API so that they are available where needed. + + +Upcoming work +------------- + +- Specialized behavior for ``google.protobuf.FieldMask`` objects. diff --git a/packages/proto-plus/noxfile.py b/packages/proto-plus/noxfile.py new file mode 100644 index 000000000000..1e43d8264ac8 --- /dev/null +++ b/packages/proto-plus/noxfile.py @@ -0,0 +1,172 @@ +# Copyright 2017, 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 __future__ import absolute_import + +import nox +import pathlib + + +CURRENT_DIRECTORY = pathlib.Path(__file__).parent.absolute() + + +PYTHON_VERSIONS = [ + "3.7", + "3.8", + "3.9", + "3.10", + "pypy3.10", + "3.11", + "3.12", + "3.13", + "3.14", +] + +# Error if a python version is missing +nox.options.error_on_missing_interpreters = True + + +@nox.session(python=PYTHON_VERSIONS) +@nox.parametrize("implementation", ["cpp", "upb", "python"]) +def unit(session, implementation): + """Run the unit test suite.""" + + constraints_path = str( + CURRENT_DIRECTORY / "testing" / f"constraints-{session.python}.txt" + ) + + session.env["PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION"] = implementation + session.install("coverage", "pytest", "pytest-cov", "pytz") + session.install("-e", ".[testing]", "-c", constraints_path) + # TODO(https://github.com/googleapis/proto-plus-python/issues/389): + # Remove the 'cpp' implementation once support for Protobuf 3.x is dropped. + # The 'cpp' implementation requires Protobuf<4. + if implementation == "cpp": + session.install("protobuf<4") + + # TODO(https://github.com/googleapis/proto-plus-python/issues/403): re-enable `-W=error` + # The warnings-as-errors flag `-W=error` was removed in + # https://github.com/googleapis/proto-plus-python/pull/400. + # It should be re-added once issue + # https://github.com/protocolbuffers/protobuf/issues/15077 is fixed. + session.run( + "pytest", + "--quiet", + *( + session.posargs # Coverage info when running individual tests is annoying. + or [ + "--cov=proto", + "--cov-config=.coveragerc", + "--cov-report=term", + "--cov-report=html", + "tests", + ] + ), + ) + + +# Only test upb and python implementation backends. +# As of protobuf 4.x, the "ccp" implementation is not available in the PyPI package as per +# https://github.com/protocolbuffers/protobuf/tree/main/python#implementation-backends +@nox.session(python=PYTHON_VERSIONS[-1]) +@nox.parametrize("implementation", ["python", "upb"]) +def prerelease_deps(session, implementation): + """Run the unit test suite against pre-release versions of dependencies.""" + + session.env["PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION"] = implementation + + # Install test environment dependencies + session.install("coverage", "pytest", "pytest-cov", "pytz") + + # Install the package without dependencies + session.install("-e", ".", "--no-deps") + + prerel_deps = [ + "google-api-core", + # dependency of google-api-core + "googleapis-common-protos", + ] + + for dep in prerel_deps: + session.install("--pre", "--no-deps", "--upgrade", dep) + + session.install("--pre", "--upgrade", "protobuf") + # Print out prerelease package versions + session.run( + "python", "-c", "import google.protobuf; print(google.protobuf.__version__)" + ) + session.run( + "python", "-c", "import google.api_core; print(google.api_core.__version__)" + ) + + # TODO(https://github.com/googleapis/proto-plus-python/issues/403): re-enable `-W=error` + # The warnings-as-errors flag `-W=error` was removed in + # https://github.com/googleapis/proto-plus-python/pull/400. + # It should be re-added once issue + # https://github.com/protocolbuffers/protobuf/issues/15077 is fixed. + session.run( + "pytest", + "--quiet", + *( + session.posargs # Coverage info when running individual tests is annoying. + or [ + "--cov=proto", + "--cov-config=.coveragerc", + "--cov-report=term", + "--cov-report=html", + "tests", + ] + ), + ) + + +@nox.session(python="3.10") +def docs(session): + """Build the docs.""" + + session.install( + # We need to pin to specific versions of the `sphinxcontrib-*` packages + # which still support sphinx 4.x. + # See https://github.com/googleapis/sphinx-docfx-yaml/issues/344 + # and https://github.com/googleapis/sphinx-docfx-yaml/issues/345. + "sphinxcontrib-applehelp==1.0.4", + "sphinxcontrib-devhelp==1.0.2", + "sphinxcontrib-htmlhelp==2.0.1", + "sphinxcontrib-qthelp==1.0.3", + "sphinxcontrib-serializinghtml==1.1.5", + "sphinx==4.5.0", + "sphinx_rtd_theme", + ) + session.install(".") + + # Build the docs! + session.run("rm", "-rf", "docs/_build/") + session.run( + "sphinx-build", + "-W", + "-b", + "html", + "-d", + "docs/_build/doctrees", + "docs/", + "docs/_build/html/", + ) + + +@nox.session(python="3.10") +def lint_setup_py(session): + """Verify that setup.py is valid (including RST check).""" + + session.install("docutils", "Pygments") + session.run("python", "setup.py", "check", "--restructuredtext", "--strict") diff --git a/packages/proto-plus/proto/__init__.py b/packages/proto-plus/proto/__init__.py new file mode 100644 index 000000000000..8780991c0426 --- /dev/null +++ b/packages/proto-plus/proto/__init__.py @@ -0,0 +1,72 @@ +# 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. + +from .enums import Enum +from .fields import Field +from .fields import MapField +from .fields import RepeatedField +from .marshal import Marshal +from .message import Message +from .modules import define_module as module +from .primitives import ProtoType +from .version import __version__ + + +DOUBLE = ProtoType.DOUBLE +FLOAT = ProtoType.FLOAT +INT64 = ProtoType.INT64 +UINT64 = ProtoType.UINT64 +INT32 = ProtoType.INT32 +FIXED64 = ProtoType.FIXED64 +FIXED32 = ProtoType.FIXED32 +BOOL = ProtoType.BOOL +STRING = ProtoType.STRING +MESSAGE = ProtoType.MESSAGE +BYTES = ProtoType.BYTES +UINT32 = ProtoType.UINT32 +ENUM = ProtoType.ENUM +SFIXED32 = ProtoType.SFIXED32 +SFIXED64 = ProtoType.SFIXED64 +SINT32 = ProtoType.SINT32 +SINT64 = ProtoType.SINT64 + + +__all__ = ( + "__version__", + "Enum", + "Field", + "MapField", + "RepeatedField", + "Marshal", + "Message", + "module", + # Expose the types directly. + "DOUBLE", + "FLOAT", + "INT64", + "UINT64", + "INT32", + "FIXED64", + "FIXED32", + "BOOL", + "STRING", + "MESSAGE", + "BYTES", + "UINT32", + "ENUM", + "SFIXED32", + "SFIXED64", + "SINT32", + "SINT64", +) diff --git a/packages/proto-plus/proto/_file_info.py b/packages/proto-plus/proto/_file_info.py new file mode 100644 index 000000000000..537eeaf4556a --- /dev/null +++ b/packages/proto-plus/proto/_file_info.py @@ -0,0 +1,196 @@ +# 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 collections +import inspect +import logging + +from google.protobuf import descriptor_pb2 +from google.protobuf import descriptor_pool +from google.protobuf import message +from google.protobuf import reflection + +from proto.marshal.rules.message import MessageRule + +log = logging.getLogger("_FileInfo") + + +class _FileInfo( + collections.namedtuple( + "_FileInfo", + ["descriptor", "messages", "enums", "name", "nested", "nested_enum"], + ) +): + registry = {} # Mapping[str, '_FileInfo'] + + @classmethod + def maybe_add_descriptor(cls, filename, package): + descriptor = cls.registry.get(filename) + if not descriptor: + descriptor = cls.registry[filename] = cls( + descriptor=descriptor_pb2.FileDescriptorProto( + name=filename, + package=package, + syntax="proto3", + ), + enums=collections.OrderedDict(), + messages=collections.OrderedDict(), + name=filename, + nested={}, + nested_enum={}, + ) + + return descriptor + + @staticmethod + def proto_file_name(name): + return "{0}.proto".format(name.replace(".", "/")) + + def _get_manifest(self, new_class): + module = inspect.getmodule(new_class) + if hasattr(module, "__protobuf__"): + return frozenset(module.__protobuf__.manifest) + + return frozenset() + + def _get_remaining_manifest(self, new_class): + return self._get_manifest(new_class) - {new_class.__name__} + + def _calculate_salt(self, new_class, fallback): + manifest = self._get_manifest(new_class) + if manifest and new_class.__name__ not in manifest: + log.warning( + "proto-plus module {module} has a declared manifest but {class_name} is not in it".format( + module=inspect.getmodule(new_class).__name__, + class_name=new_class.__name__, + ) + ) + + return "" if new_class.__name__ in manifest else (fallback or "").lower() + + def generate_file_pb(self, new_class, fallback_salt=""): + """Generate the descriptors for all protos in the file. + + This method takes the file descriptor attached to the parent + message and generates the immutable descriptors for all of the + messages in the file descriptor. (This must be done in one fell + swoop for immutability and to resolve proto cross-referencing.) + + This is run automatically when the last proto in the file is + generated, as determined by the module's __all__ tuple. + """ + pool = descriptor_pool.Default() + + # Salt the filename in the descriptor. + # This allows re-use of the filename by other proto messages if + # needed (e.g. if __all__ is not used). + salt = self._calculate_salt(new_class, fallback_salt) + self.descriptor.name = "{name}.proto".format( + name="_".join([self.descriptor.name[:-6], salt]).rstrip("_"), + ) + + # Add the file descriptor. + pool.Add(self.descriptor) + + # Adding the file descriptor to the pool created a descriptor for + # each message; go back through our wrapper messages and associate + # them with the internal protobuf version. + for full_name, proto_plus_message in self.messages.items(): + # Get the descriptor from the pool, and create the protobuf + # message based on it. + descriptor = pool.FindMessageTypeByName(full_name) + pb_message = reflection.GeneratedProtocolMessageType( + descriptor.name, + (message.Message,), + {"DESCRIPTOR": descriptor, "__module__": None}, + ) + + # Register the message with the marshal so it is wrapped + # appropriately. + # + # We do this here (rather than at class creation) because it + # is not until this point that we have an actual protobuf + # message subclass, which is what we need to use. + proto_plus_message._meta._pb = pb_message + proto_plus_message._meta.marshal.register( + pb_message, MessageRule(pb_message, proto_plus_message) + ) + + # Iterate over any fields on the message and, if their type + # is a message still referenced as a string, resolve the reference. + for field in proto_plus_message._meta.fields.values(): + if field.message and isinstance(field.message, str): + field.message = self.messages[field.message] + elif field.enum and isinstance(field.enum, str): + field.enum = self.enums[field.enum] + + # Same thing for enums + for full_name, proto_plus_enum in self.enums.items(): + descriptor = pool.FindEnumTypeByName(full_name) + proto_plus_enum._meta.pb = descriptor + + # We no longer need to track this file's info; remove it from + # the module's registry and from this object. + self.registry.pop(self.name) + + def ready(self, new_class): + """Return True if a file descriptor may added, False otherwise. + + This determine if all the messages that we plan to create have been + created, as best as we are able. + + Since messages depend on one another, we create descriptor protos + (which reference each other using strings) and wait until we have + built everything that is going to be in the module, and then + use the descriptor protos to instantiate the actual descriptors in + one fell swoop. + + Args: + new_class (~.MessageMeta): The new class currently undergoing + creation. + """ + # If there are any nested descriptors that have not been assigned to + # the descriptors that should contain them, then we are not ready. + if len(self.nested) or len(self.nested_enum): + return False + + # If there are any unresolved fields (fields with a composite message + # declared as a string), ensure that the corresponding message is + # declared. + for field in self.unresolved_fields: + if (field.message and field.message not in self.messages) or ( + field.enum and field.enum not in self.enums + ): + return False + + # If the module in which this class is defined provides a + # __protobuf__ property, it may have a manifest. + # + # Do not generate the file descriptor until every member of the + # manifest has been populated. + module = inspect.getmodule(new_class) + manifest = self._get_remaining_manifest(new_class) + + # We are ready if all members have been populated. + return all(hasattr(module, i) for i in manifest) + + @property + def unresolved_fields(self): + """Return fields with referencing message types as strings.""" + for proto_plus_message in self.messages.values(): + for field in proto_plus_message._meta.fields.values(): + if (field.message and isinstance(field.message, str)) or ( + field.enum and isinstance(field.enum, str) + ): + yield field diff --git a/packages/proto-plus/proto/_package_info.py b/packages/proto-plus/proto/_package_info.py new file mode 100644 index 000000000000..75e89ebaac18 --- /dev/null +++ b/packages/proto-plus/proto/_package_info.py @@ -0,0 +1,50 @@ +# 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 sys + +from proto.marshal import Marshal + + +def compile(name, attrs): + """Return the package and marshal to use. + + Args: + name (str): The name of the new class, as sent to ``type.__new__``. + attrs (Mapping[str, Any]): The attrs for a new class, as sent + to ``type.__new__`` + + Returns: + Tuple[str, ~.Marshal]: + - The proto package, if any (empty string otherwise). + - The marshal object to use. + """ + # Pull a reference to the module where this class is being + # declared. + module = sys.modules.get(attrs.get("__module__")) + module_name = module.__name__ if hasattr(module, __name__) else "" + proto_module = getattr(module, "__protobuf__", object()) + + # A package should be present; get the marshal from there. + # TODO: Revert to empty string as a package value after protobuf fix. + # When package is empty, upb based protobuf fails with an + # "TypeError: Couldn't build proto file into descriptor pool: invalid name: empty part ()' means" + # during an attempt to add to descriptor pool. + package = getattr( + proto_module, "package", module_name if module_name else "_default_package" + ) + marshal = Marshal(name=getattr(proto_module, "marshal", package)) + + # Done; return the data. + return (package, marshal) diff --git a/packages/proto-plus/proto/datetime_helpers.py b/packages/proto-plus/proto/datetime_helpers.py new file mode 100644 index 000000000000..ffac4f47d95a --- /dev/null +++ b/packages/proto-plus/proto/datetime_helpers.py @@ -0,0 +1,225 @@ +# Copyright 2017 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. + +"""Helpers for :mod:`datetime`.""" + +import calendar +import datetime +import re + +from google.protobuf import timestamp_pb2 + + +_UTC_EPOCH = datetime.datetime.fromtimestamp(0, datetime.timezone.utc) + +_RFC3339_MICROS = "%Y-%m-%dT%H:%M:%S.%fZ" +_RFC3339_NO_FRACTION = "%Y-%m-%dT%H:%M:%S" +# datetime.strptime cannot handle nanosecond precision: parse w/ regex +_RFC3339_NANOS = re.compile( + r""" + (?P + \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2} # YYYY-MM-DDTHH:MM:SS + ) + ( # Optional decimal part + \. # decimal point + (?P\d{1,9}) # nanoseconds, maybe truncated + )? + Z # Zulu +""", + re.VERBOSE, +) + + +def _from_microseconds(value): + """Convert timestamp in microseconds since the unix epoch to datetime. + + Args: + value (float): The timestamp to convert, in microseconds. + + Returns: + datetime.datetime: The datetime object equivalent to the timestamp in + UTC. + """ + return _UTC_EPOCH + datetime.timedelta(microseconds=value) + + +def _to_rfc3339(value, ignore_zone=True): + """Convert a datetime to an RFC3339 timestamp string. + + Args: + value (datetime.datetime): + The datetime object to be converted to a string. + ignore_zone (bool): If True, then the timezone (if any) of the + datetime object is ignored and the datetime is treated as UTC. + + Returns: + 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. + value = value.replace(tzinfo=None) - value.utcoffset() + + return value.strftime(_RFC3339_MICROS) + + +class DatetimeWithNanoseconds(datetime.datetime): + """Track nanosecond in addition to normal datetime attrs. + + Nanosecond can be passed only as a keyword argument. + """ + + __slots__ = ("_nanosecond",) + + # pylint: disable=arguments-differ + def __new__(cls, *args, **kw): + nanos = kw.pop("nanosecond", 0) + if nanos > 0: + if "microsecond" in kw: + raise TypeError("Specify only one of 'microsecond' or 'nanosecond'") + kw["microsecond"] = nanos // 1000 + inst = datetime.datetime.__new__(cls, *args, **kw) + inst._nanosecond = nanos or 0 + return inst + + # pylint: disable=arguments-differ + def replace(self, *args, **kw): + """Return a date with the same value, except for those parameters given + 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 arguments. + """ + + ms_provided = "microsecond" in kw + ns_provided = "nanosecond" in kw + provided_ns = kw.pop("nanosecond", 0) + + prev_nanos = self.nanosecond + + if ms_provided and ns_provided: + raise TypeError("Specify only one of 'microsecond' or 'nanosecond'") + + if ns_provided: + # if nanos were provided, manipulate microsecond kw arg to super + kw["microsecond"] = provided_ns // 1000 + inst = super().replace(*args, **kw) + + if ms_provided: + # ms were provided, nanos are invalid, build from ms + inst._nanosecond = inst.microsecond * 1000 + elif ns_provided: + # ns were provided, replace nanoseconds to match after calling super + inst._nanosecond = provided_ns + else: + # if neither ms or ns were provided, passthru previous nanos. + inst._nanosecond = prev_nanos + + return inst + + @property + def nanosecond(self): + """Read-only: nanosecond precision.""" + return self._nanosecond or self.microsecond * 1000 + + def rfc3339(self): + """Return an RFC3339-compliant timestamp. + + Returns: + (str): Timestamp string according to RFC3339 spec. + """ + if self._nanosecond == 0: + return _to_rfc3339(self) + nanos = str(self._nanosecond).rjust(9, "0").rstrip("0") + return "{}.{}Z".format(self.strftime(_RFC3339_NO_FRACTION), nanos) + + @classmethod + def from_rfc3339(cls, stamp): + """Parse RFC3339-compliant timestamp, preserving nanoseconds. + + Args: + stamp (str): RFC3339 stamp, with up to nanosecond precision + + Returns: + :class:`DatetimeWithNanoseconds`: + an instance matching the timestamp string + + Raises: + ValueError: if `stamp` does not match the expected format + """ + with_nanos = _RFC3339_NANOS.match(stamp) + if with_nanos is None: + raise ValueError( + "Timestamp: {}, does not match pattern: {}".format( + stamp, _RFC3339_NANOS.pattern + ) + ) + bare = datetime.datetime.strptime( + with_nanos.group("no_fraction"), _RFC3339_NO_FRACTION + ) + fraction = with_nanos.group("nanos") + if fraction is None: + nanos = 0 + else: + scale = 9 - len(fraction) + nanos = int(fraction) * (10**scale) + return cls( + bare.year, + bare.month, + bare.day, + bare.hour, + bare.minute, + bare.second, + nanosecond=nanos, + tzinfo=datetime.timezone.utc, + ) + + def timestamp_pb(self): + """Return a timestamp message. + + Returns: + (:class:`~google.protobuf.timestamp_pb2.Timestamp`): Timestamp message + """ + inst = ( + self + if self.tzinfo is not None + else self.replace(tzinfo=datetime.timezone.utc) + ) + delta = inst - _UTC_EPOCH + seconds = int(delta.total_seconds()) + nanos = self._nanosecond or self.microsecond * 1000 + return timestamp_pb2.Timestamp(seconds=seconds, nanos=nanos) + + @classmethod + def from_timestamp_pb(cls, stamp): + """Parse RFC3339-compliant timestamp, preserving nanoseconds. + + Args: + stamp (:class:`~google.protobuf.timestamp_pb2.Timestamp`): timestamp message + + Returns: + :class:`DatetimeWithNanoseconds`: + an instance matching the timestamp message + """ + microseconds = int(stamp.seconds * 1e6) + bare = _from_microseconds(microseconds) + return cls( + bare.year, + bare.month, + bare.day, + bare.hour, + bare.minute, + bare.second, + nanosecond=stamp.nanos, + tzinfo=datetime.timezone.utc, + ) diff --git a/packages/proto-plus/proto/enums.py b/packages/proto-plus/proto/enums.py new file mode 100644 index 000000000000..d3ab6b792023 --- /dev/null +++ b/packages/proto-plus/proto/enums.py @@ -0,0 +1,165 @@ +# Copyright 2019 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 enum + +from google.protobuf import descriptor_pb2 + +from proto import _file_info +from proto import _package_info +from proto.marshal.rules.enums import EnumRule + + +class ProtoEnumMeta(enum.EnumMeta): + """A metaclass for building and registering protobuf enums.""" + + def __new__(mcls, name, bases, attrs): + # Do not do any special behavior for `proto.Enum` itself. + if bases[0] == enum.IntEnum: + return super().__new__(mcls, name, bases, attrs) + + # Get the essential information about the proto package, and where + # this component belongs within the file. + package, marshal = _package_info.compile(name, attrs) + + # Determine the local path of this proto component within the file. + local_path = tuple(attrs.get("__qualname__", name).split(".")) + + # Sanity check: We get the wrong full name if a class is declared + # inside a function local scope; correct this. + if "" in local_path: + ix = local_path.index("") + local_path = local_path[: ix - 1] + local_path[ix + 1 :] + + # Determine the full name in protocol buffers. + full_name = ".".join((package,) + local_path).lstrip(".") + 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: + if isinstance(attrs._member_names, list): + idx = attrs._member_names.index(pb_options) + attrs._member_names.pop(idx) + elif isinstance(attrs._member_names, set): # PyPy + attrs._member_names.discard(pb_options) + else: # Python 3.11.0b3 + del attrs._member_names[pb_options] + + # Make the descriptor. + enum_desc = descriptor_pb2.EnumDescriptorProto( + name=name, + # Note: the superclass ctor removes the variants, so get them now. + # Note: proto3 requires that the first variant value be zero. + value=sorted( + ( + descriptor_pb2.EnumValueDescriptorProto(name=name, number=number) + # Minor hack to get all the enum variants out. + # Use the `_member_names` property to get only the enum members + # See https://github.com/googleapis/proto-plus-python/issues/490 + for name, number in attrs.items() + if name in attrs._member_names and isinstance(number, int) + ), + key=lambda v: v.number, + ), + options=opts, + ) + + file_info = _file_info._FileInfo.maybe_add_descriptor(filename, package) + if len(local_path) == 1: + file_info.descriptor.enum_type.add().MergeFrom(enum_desc) + else: + file_info.nested_enum[local_path] = enum_desc + + # Run the superclass constructor. + cls = super().__new__(mcls, name, bases, attrs) + + # We can't just add a "_meta" element to attrs because the Enum + # machinery doesn't know what to do with a non-int value. + # The pb is set later, in generate_file_pb + cls._meta = _EnumInfo(full_name=full_name, pb=None) + + file_info.enums[full_name] = cls + + # Register the enum with the marshal. + marshal.register(cls, EnumRule(cls)) + + # Generate the descriptor for the file if it is ready. + if file_info.ready(new_class=cls): + file_info.generate_file_pb(new_class=cls, fallback_salt=full_name) + + # Done; return the class. + return cls + + +class Enum(enum.IntEnum, metaclass=ProtoEnumMeta): + """A enum object that also builds a protobuf enum descriptor.""" + + 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: + def __init__(self, *, full_name: str, pb): + self.full_name = full_name + self.pb = pb diff --git a/packages/proto-plus/proto/fields.py b/packages/proto-plus/proto/fields.py new file mode 100644 index 000000000000..6f5b64521619 --- /dev/null +++ b/packages/proto-plus/proto/fields.py @@ -0,0 +1,165 @@ +# 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. + +from enum import EnumMeta + +from google.protobuf import descriptor_pb2 +from google.protobuf.internal.enum_type_wrapper import EnumTypeWrapper + +from proto.primitives import ProtoType + + +class Field: + """A representation of a type of field in protocol buffers.""" + + # Fields are NOT repeated nor maps. + # The RepeatedField overrides this values. + repeated = False + + def __init__( + self, + proto_type, + *, + number: int, + message=None, + enum=None, + oneof: str = None, + json_name: str = None, + optional: bool = False + ): + # This class is not intended to stand entirely alone; + # data is augmented by the metaclass for Message. + self.mcls_data = None + self.parent = None + + # If the proto type sent is an object or a string, it is really + # a message or enum. + if not isinstance(proto_type, int): + # Note: We only support the "shortcut syntax" for enums + # when receiving the actual class. + if isinstance(proto_type, (EnumMeta, EnumTypeWrapper)): + enum = proto_type + proto_type = ProtoType.ENUM + else: + message = proto_type + proto_type = ProtoType.MESSAGE + + # Save the direct arguments. + self.number = number + self.proto_type = proto_type + self.message = message + self.enum = enum + self.json_name = json_name + self.optional = optional + self.oneof = oneof + + # Once the descriptor is accessed the first time, cache it. + # This is important because in rare cases the message or enum + # types are written later. + self._descriptor = None + + @property + def descriptor(self): + """Return the descriptor for the field.""" + if not self._descriptor: + # Resolve the message type, if any, to a string. + type_name = None + if isinstance(self.message, str): + if not self.message.startswith(self.package): + self.message = "{package}.{name}".format( + package=self.package, + name=self.message, + ) + type_name = self.message + elif self.message: + type_name = ( + self.message.DESCRIPTOR.full_name + if hasattr(self.message, "DESCRIPTOR") + else self.message._meta.full_name + ) + elif isinstance(self.enum, str): + if not self.enum.startswith(self.package): + self.enum = "{package}.{name}".format( + package=self.package, + name=self.enum, + ) + type_name = self.enum + elif self.enum: + type_name = ( + self.enum.DESCRIPTOR.full_name + if hasattr(self.enum, "DESCRIPTOR") + else self.enum._meta.full_name + ) + + # Set the descriptor. + self._descriptor = descriptor_pb2.FieldDescriptorProto( + name=self.name, + number=self.number, + label=3 if self.repeated else 1, + type=self.proto_type, + type_name=type_name, + json_name=self.json_name, + proto3_optional=self.optional, + ) + + # Return the descriptor. + return self._descriptor + + @property + def name(self) -> str: + """Return the name of the field.""" + return self.mcls_data["name"] + + @property + def package(self) -> str: + """Return the package of the field.""" + return self.mcls_data["package"] + + @property + def pb_type(self): + """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 primitive fields, we still want to know + # what the type is. + if not self.message: + return self.proto_type + + # Return the internal protobuf message. + if hasattr(self.message, "_meta"): + return self.message.pb() + return self.message + + +class RepeatedField(Field): + """A representation of a repeated field in protocol buffers.""" + + repeated = True + + +class MapField(Field): + """A representation of a map field in protocol buffers.""" + + def __init__(self, key_type, value_type, *, number: int, message=None, enum=None): + super().__init__(value_type, number=number, message=message, enum=enum) + self.map_key_type = key_type + + +__all__ = ( + "Field", + "MapField", + "RepeatedField", +) diff --git a/packages/proto-plus/proto/marshal/__init__.py b/packages/proto-plus/proto/marshal/__init__.py new file mode 100644 index 000000000000..621ea3695f93 --- /dev/null +++ b/packages/proto-plus/proto/marshal/__init__.py @@ -0,0 +1,18 @@ +# 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. + +from .marshal import Marshal + + +__all__ = ("Marshal",) diff --git a/packages/proto-plus/proto/marshal/collections/__init__.py b/packages/proto-plus/proto/marshal/collections/__init__.py new file mode 100644 index 000000000000..4b80a546c26a --- /dev/null +++ b/packages/proto-plus/proto/marshal/collections/__init__.py @@ -0,0 +1,24 @@ +# 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. + +from .maps import MapComposite +from .repeated import Repeated +from .repeated import RepeatedComposite + + +__all__ = ( + "MapComposite", + "Repeated", + "RepeatedComposite", +) diff --git a/packages/proto-plus/proto/marshal/collections/maps.py b/packages/proto-plus/proto/marshal/collections/maps.py new file mode 100644 index 000000000000..3c4857161458 --- /dev/null +++ b/packages/proto-plus/proto/marshal/collections/maps.py @@ -0,0 +1,82 @@ +# 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 collections + +from proto.utils import cached_property +from google.protobuf.message import Message + + +class MapComposite(collections.abc.MutableMapping): + """A view around a mutable sequence in protocol buffers. + + This implements the full Python MutableMapping interface, but all methods + modify the underlying field container directly. + """ + + @cached_property + def _pb_type(self): + """Return the protocol buffer type for this sequence.""" + # Huzzah, another hack. Still less bad than RepeatedComposite. + return type(self.pb.GetEntryClass()().value) + + def __init__(self, sequence, *, marshal): + """Initialize a wrapper around a protobuf map. + + Args: + sequence: A protocol buffers map. + marshal (~.MarshalRegistry): An instantiated marshal, used to + convert values going to and from this map. + """ + self._pb = sequence + self._marshal = marshal + + def __contains__(self, key): + # Protocol buffers is so permissive that querying for the existence + # of a key will in of itself create it. + # + # By taking a tuple of the keys and querying that, we avoid sending + # the lookup to protocol buffers and therefore avoid creating the key. + return key in tuple(self.keys()) + + def __getitem__(self, key): + # We handle raising KeyError ourselves, because otherwise protocol + # buffers will create the key if it does not exist. + if key not in self: + raise KeyError(key) + return self._marshal.to_python(self._pb_type, self.pb[key]) + + def __setitem__(self, key, value): + pb_value = self._marshal.to_proto(self._pb_type, value, strict=True) + # Directly setting a key is not allowed; however, protocol buffers + # is so permissive that querying for the existence of a key will in + # of itself create it. + # + # Therefore, we create a key that way (clearing any fields that may + # be set) and then merge in our values. + self.pb[key].Clear() + self.pb[key].MergeFrom(pb_value) + + def __delitem__(self, key): + self.pb.pop(key) + + def __len__(self): + return len(self.pb) + + def __iter__(self): + return iter(self.pb) + + @property + def pb(self): + return self._pb diff --git a/packages/proto-plus/proto/marshal/collections/repeated.py b/packages/proto-plus/proto/marshal/collections/repeated.py new file mode 100644 index 000000000000..a6560411cd71 --- /dev/null +++ b/packages/proto-plus/proto/marshal/collections/repeated.py @@ -0,0 +1,189 @@ +# 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 collections +import copy +from typing import Iterable + +from proto.utils import cached_property + + +class Repeated(collections.abc.MutableSequence): + """A view around a mutable sequence in protocol buffers. + + This implements the full Python MutableSequence interface, but all methods + modify the underlying field container directly. + """ + + def __init__(self, sequence, *, marshal, proto_type=None): + """Initialize a wrapper around a protobuf repeated field. + + Args: + sequence: A protocol buffers repeated field. + marshal (~.MarshalRegistry): An instantiated marshal, used to + convert values going to and from this map. + """ + self._pb = sequence + self._marshal = marshal + self._proto_type = proto_type + + def __copy__(self): + """Copy this object and return the copy.""" + return type(self)(self.pb[:], marshal=self._marshal) + + def __delitem__(self, key): + """Delete the given item.""" + del self.pb[key] + + def __eq__(self, other): + if hasattr(other, "pb"): + return tuple(self.pb) == tuple(other.pb) + return tuple(self.pb) == tuple(other) if isinstance(other, Iterable) else False + + def __getitem__(self, key): + """Return the given item.""" + return self.pb[key] + + def __len__(self): + """Return the length of the sequence.""" + return len(self.pb) + + def __ne__(self, other): + return not self == other + + def __repr__(self): + return repr([*self]) + + def __setitem__(self, key, value): + self.pb[key] = value + + def insert(self, index: int, value): + """Insert ``value`` in the sequence before ``index``.""" + self.pb.insert(index, value) + + def sort(self, *, key: str = None, reverse: bool = False): + """Stable sort *IN PLACE*.""" + self.pb.sort(key=key, reverse=reverse) + + @property + def pb(self): + return self._pb + + +class RepeatedComposite(Repeated): + """A view around a mutable sequence of messages in protocol buffers. + + This implements the full Python MutableSequence interface, but all methods + modify the underlying field container directly. + """ + + @cached_property + def _pb_type(self): + """Return the protocol buffer type for this sequence.""" + # Provide the marshal-given proto_type, if any. + # Used for RepeatedComposite of Enum. + if self._proto_type is not None: + return self._proto_type + + # There is no public-interface mechanism to determine the type + # of what should go in the list (and the C implementation seems to + # have no exposed mechanism at all). + # + # If the list has members, use the existing list members to + # determine the type. + if len(self.pb) > 0: + return type(self.pb[0]) + + # We have no members in the list, so we get the type from the attributes. + if hasattr(self.pb, "_message_descriptor") and hasattr( + self.pb._message_descriptor, "_concrete_class" + ): + return self.pb._message_descriptor._concrete_class + + # Fallback logic in case attributes are not available + # In order to get the type, we create a throw-away copy and add a + # blank member to it. + canary = copy.deepcopy(self.pb).add() + return type(canary) + + def __eq__(self, other): + if super().__eq__(other): + return True + return ( + tuple([i for i in self]) == tuple(other) + if isinstance(other, Iterable) + else False + ) + + def __getitem__(self, key): + return self._marshal.to_python(self._pb_type, self.pb[key]) + + def __setitem__(self, key, value): + # The underlying protocol buffer does not define __setitem__, so we + # have to implement all the operations on our own. + + # If ``key`` is an integer, as in list[index] = value: + if isinstance(key, int): + if -len(self) <= key < len(self): + self.pop(key) # Delete the old item. + self.insert(key, value) # Insert the new item in its place. + else: + raise IndexError("list assignment index out of range") + + # If ``key`` is a slice object, as in list[start:stop:step] = [values]: + elif isinstance(key, slice): + start, stop, step = key.indices(len(self)) + + if not isinstance(value, collections.abc.Iterable): + raise TypeError("can only assign an iterable") + + if step == 1: # Is not an extended slice. + # Assign all the new values to the sliced part, replacing the + # old values, if any, and unconditionally inserting those + # values whose indices already exceed the slice length. + for index, item in enumerate(value): + if start + index < stop: + self.pop(start + index) + self.insert(start + index, item) + + # If there are less values than the length of the slice, remove + # the remaining elements so that the slice adapts to the + # newly provided values. + for _ in range(stop - start - len(value)): + self.pop(start + len(value)) + + else: # Is an extended slice. + indices = range(start, stop, step) + + if len(value) != len(indices): # XXX: Use PEP 572 on 3.8+ + raise ValueError( + f"attempt to assign sequence of size " + f"{len(value)} to extended slice of size " + f"{len(indices)}" + ) + + # Assign each value to its index, calling this function again + # with individual integer indexes that get processed above. + for index, item in zip(indices, value): + self[index] = item + + else: + raise TypeError( + f"list indices must be integers or slices, not {type(key).__name__}" + ) + + def insert(self, index: int, value): + """Insert ``value`` in the sequence before ``index``.""" + pb_value = self._marshal.to_proto(self._pb_type, value) + self.pb.insert(index, pb_value) diff --git a/packages/proto-plus/proto/marshal/compat.py b/packages/proto-plus/proto/marshal/compat.py new file mode 100644 index 000000000000..b393acf5a447 --- /dev/null +++ b/packages/proto-plus/proto/marshal/compat.py @@ -0,0 +1,64 @@ +# 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. + +# This file pulls in the container types from internal protocol buffers, +# and exports the types available. +# +# If the C extensions were not installed, then their container types will +# not be included. + +from google.protobuf.internal import containers + +# Import all message types to ensure that pyext types are recognized +# when upb types exist. Conda's protobuf defaults to pyext despite upb existing. +# See https://github.com/googleapis/proto-plus-python/issues/470 +try: + from google._upb import _message as _message_upb +except ImportError: + _message_upb = None + +try: + from google.protobuf.pyext import _message as _message_pyext +except ImportError: + _message_pyext = None + + +repeated_composite_types = (containers.RepeatedCompositeFieldContainer,) +repeated_scalar_types = (containers.RepeatedScalarFieldContainer,) +map_composite_types = (containers.MessageMap,) + +# In `proto/marshal.py`, for compatibility with protobuf 5.x, +# we'll use `map_composite_type_names` to check whether +# the name of the class of a protobuf type is +# `MessageMapContainer`, and, if `True`, return a MapComposite. +# See https://github.com/protocolbuffers/protobuf/issues/16596 +map_composite_type_names = ("MessageMapContainer",) + +for message in [_message_upb, _message_pyext]: + if message: + repeated_composite_types += (message.RepeatedCompositeContainer,) + repeated_scalar_types += (message.RepeatedScalarContainer,) + + try: + map_composite_types += (message.MessageMapContainer,) + except AttributeError: + # The `MessageMapContainer` attribute is not available in Protobuf 5.x+ + pass + +__all__ = ( + "repeated_composite_types", + "repeated_scalar_types", + "map_composite_types", + "map_composite_type_names", +) diff --git a/packages/proto-plus/proto/marshal/marshal.py b/packages/proto-plus/proto/marshal/marshal.py new file mode 100644 index 000000000000..d278421a5797 --- /dev/null +++ b/packages/proto-plus/proto/marshal/marshal.py @@ -0,0 +1,297 @@ +# 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 abc +import enum + +from google.protobuf import message +from google.protobuf import duration_pb2 +from google.protobuf import timestamp_pb2 +from google.protobuf import field_mask_pb2 +from google.protobuf import struct_pb2 +from google.protobuf import wrappers_pb2 + +from proto.marshal import compat +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.marshal.rules import field_mask +from proto.primitives import ProtoType + + +class Rule(abc.ABC): + """Abstract class definition for marshal rules.""" + + @classmethod + def __subclasshook__(cls, C): + if hasattr(C, "to_python") and hasattr(C, "to_proto"): + return True + return NotImplemented + + +class BaseMarshal: + """The base class to translate between protobuf and Python classes. + + Protocol buffers defines many common types (e.g. Timestamp, Duration) + which also exist in the Python standard library. The marshal essentially + translates between these: it keeps a registry of common protocol buffers + and their Python representations, and translates back and forth. + + The protocol buffer class is always the "key" in this relationship; when + presenting a message, the declared field types are used to determine + whether a value should be transformed into another class. Similarly, + when accepting a Python value (when setting a field, for example), + the declared field type is still used. This means that, if appropriate, + multiple protocol buffer types may use the same Python type. + + The primary implementation of this is :class:`Marshal`, which should + usually be used instead of this class directly. + """ + + def __init__(self): + self._rules = {} + self._noop = NoopRule() + self.reset() + + def register(self, proto_type: type, rule: Rule = None): + """Register a rule against the given ``proto_type``. + + This function expects a ``proto_type`` (the descriptor class) and + a ``rule``; an object with a ``to_python`` and ``to_proto`` method. + Each method should return the appropriate Python or protocol buffer + type, and be idempotent (e.g. accept either type as input). + + This function can also be used as a decorator:: + + @marshal.register(timestamp_pb2.Timestamp) + class TimestampRule: + ... + + In this case, the class will be initialized for you with zero + arguments. + + Args: + proto_type (type): A protocol buffer message type. + rule: A marshal object + """ + # If a rule was provided, register it and be done. + if rule: + # Ensure the rule implements Rule. + if not isinstance(rule, Rule): + raise TypeError( + "Marshal rule instances must implement " + "`to_proto` and `to_python` methods." + ) + + # Register the rule. + self._rules[proto_type] = rule + return + + # Create an inner function that will register an instance of the + # marshal class to this object's registry, and return it. + def register_rule_class(rule_class: type): + # Ensure the rule class is a valid rule. + if not issubclass(rule_class, Rule): + raise TypeError( + "Marshal rule subclasses must implement " + "`to_proto` and `to_python` methods." + ) + + # Register the rule class. + self._rules[proto_type] = rule_class() + return rule_class + + return register_rule_class + + def reset(self): + """Reset the registry to its initial state.""" + self._rules.clear() + + # Register date and time wrappers. + self.register(timestamp_pb2.Timestamp, dates.TimestampRule()) + self.register(duration_pb2.Duration, dates.DurationRule()) + + # Register FieldMask wrappers. + self.register(field_mask_pb2.FieldMask, field_mask.FieldMaskRule()) + + # Register nullable primitive wrappers. + self.register(wrappers_pb2.BoolValue, wrappers.BoolValueRule()) + self.register(wrappers_pb2.BytesValue, wrappers.BytesValueRule()) + self.register(wrappers_pb2.DoubleValue, wrappers.DoubleValueRule()) + self.register(wrappers_pb2.FloatValue, wrappers.FloatValueRule()) + self.register(wrappers_pb2.Int32Value, wrappers.Int32ValueRule()) + self.register(wrappers_pb2.Int64Value, wrappers.Int64ValueRule()) + self.register(wrappers_pb2.StringValue, wrappers.StringValueRule()) + self.register(wrappers_pb2.UInt32Value, wrappers.UInt32ValueRule()) + self.register(wrappers_pb2.UInt64Value, wrappers.UInt64ValueRule()) + + # Register the google.protobuf.Struct wrappers. + # + # These are aware of the marshal that created them, because they + # create RepeatedComposite and MapComposite instances directly and + # need to pass the marshal to them. + self.register(struct_pb2.Value, struct.ValueRule(marshal=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 get_rule(self, proto_type): + # Rules are needed to convert values between proto-plus and pb. + # Retrieve the rule for the specified proto type. + # The NoopRule will be used when a rule is not found. + rule = self._rules.get(proto_type, self._noop) + + # If we don't find a rule, also check under `_instances` + # in case there is a rule in another package. + # See https://github.com/googleapis/proto-plus-python/issues/349 + if rule == self._noop and hasattr(self, "_instances"): + for _, instance in self._instances.items(): + rule = instance._rules.get(proto_type, self._noop) + if rule != self._noop: + break + return rule + + 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. + value_type = type(value) # Minor performance boost over isinstance + if value_type in compat.repeated_composite_types: + return RepeatedComposite(value, marshal=self) + if value_type in compat.repeated_scalar_types: + if isinstance(proto_type, type): + return RepeatedComposite(value, marshal=self, proto_type=proto_type) + else: + return Repeated(value, marshal=self) + + # Same thing for maps of messages. + # See https://github.com/protocolbuffers/protobuf/issues/16596 + # We need to look up the name of the type in compat.map_composite_type_names + # as class `MessageMapContainer` is no longer exposed + # This is done to avoid taking a breaking change in proto-plus. + if ( + value_type in compat.map_composite_types + or value_type.__name__ in compat.map_composite_type_names + ): + return MapComposite(value, marshal=self) + return self.get_rule(proto_type=proto_type).to_python(value, absent=absent) + + def to_proto(self, proto_type, value, *, strict: bool = False): + # The protos in google/protobuf/struct.proto are exceptional cases, + # because they can and should represent themselves as lists and dicts. + # These cases are handled in their rule classes. + if proto_type not in ( + struct_pb2.Value, + struct_pb2.ListValue, + struct_pb2.Struct, + ): + # For our repeated and map view objects, simply return the + # underlying pb. + if isinstance(value, (Repeated, MapComposite)): + return value.pb + + # Convert lists and tuples recursively. + if isinstance(value, (list, tuple)): + return type(value)(self.to_proto(proto_type, i) for i in value) + + # Convert dictionaries recursively when the proto type is a map. + # This is slightly more complicated than converting a list or tuple + # because we have to step through the magic that protocol buffers does. + # + # Essentially, a type of map will show up here as + # a FoosEntry with a `key` field, `value` field, and a `map_entry` + # annotation. We need to do the conversion based on the `value` + # field's type. + if isinstance(value, dict) and ( + proto_type.DESCRIPTOR.has_options + and proto_type.DESCRIPTOR.GetOptions().map_entry + ): + recursive_type = type(proto_type().value) + return {k: self.to_proto(recursive_type, v) for k, v in value.items()} + + pb_value = self.get_rule(proto_type=proto_type).to_proto(value) + + # Sanity check: If we are in strict mode, did we get the value we want? + if strict and not isinstance(pb_value, proto_type): + raise TypeError( + "Parameter must be instance of the same class; " + "expected {expected}, got {got}".format( + expected=proto_type.__name__, + got=pb_value.__class__.__name__, + ), + ) + # Return the final value. + return pb_value + + +class Marshal(BaseMarshal): + """The translator between protocol buffer and Python instances. + + The bulk of the implementation is in :class:`BaseMarshal`. This class + adds identity tracking: multiple instantiations of :class:`Marshal` with + the same name will provide the same instance. + """ + + _instances = {} + + def __new__(cls, *, name: str): + """Create a marshal instance. + + Args: + name (str): The name of the marshal. Instantiating multiple + marshals with the same ``name`` argument will provide the + same marshal each time. + """ + klass = cls._instances.get(name) + if klass is None: + klass = cls._instances[name] = super().__new__(cls) + + return klass + + def __init__(self, *, name: str): + """Instantiate a marshal. + + Args: + name (str): The name of the marshal. Instantiating multiple + marshals with the same ``name`` argument will provide the + same marshal each time. + """ + self._name = name + if not hasattr(self, "_rules"): + super().__init__() + + +class NoopRule: + """A catch-all rule that does nothing.""" + + def to_python(self, pb_value, *, absent: bool = None): + return pb_value + + def to_proto(self, value): + return value + + +__all__ = ("Marshal",) diff --git a/packages/proto-plus/proto/marshal/rules/__init__.py b/packages/proto-plus/proto/marshal/rules/__init__.py new file mode 100644 index 000000000000..b0c7da3d7725 --- /dev/null +++ b/packages/proto-plus/proto/marshal/rules/__init__.py @@ -0,0 +1,13 @@ +# 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. diff --git a/packages/proto-plus/proto/marshal/rules/bytes.py b/packages/proto-plus/proto/marshal/rules/bytes.py new file mode 100644 index 000000000000..080b0a03d4a0 --- /dev/null +++ b/packages/proto-plus/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/packages/proto-plus/proto/marshal/rules/dates.py b/packages/proto-plus/proto/marshal/rules/dates.py new file mode 100644 index 000000000000..33d12829b3cc --- /dev/null +++ b/packages/proto-plus/proto/marshal/rules/dates.py @@ -0,0 +1,85 @@ +# 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. + +from datetime import datetime +from datetime import timedelta +from datetime import timezone + +from google.protobuf import duration_pb2 +from google.protobuf import timestamp_pb2 +from proto import datetime_helpers, utils + + +class TimestampRule: + """A marshal between Python datetimes and protobuf timestamps. + + Note: Python datetimes are less precise than protobuf datetimes + (microsecond vs. nanosecond level precision). If nanosecond-level + precision matters, it is recommended to interact with the internal + proto directly. + """ + + def to_python( + self, value, *, absent: bool = None + ) -> datetime_helpers.DatetimeWithNanoseconds: + if isinstance(value, timestamp_pb2.Timestamp): + if absent: + return None + return datetime_helpers.DatetimeWithNanoseconds.from_timestamp_pb(value) + return value + + def to_proto(self, value) -> timestamp_pb2.Timestamp: + if isinstance(value, datetime_helpers.DatetimeWithNanoseconds): + return value.timestamp_pb() + if isinstance(value, datetime): + return timestamp_pb2.Timestamp( + seconds=int(value.timestamp()), + nanos=value.microsecond * 1000, + ) + if isinstance(value, str): + timestamp_value = timestamp_pb2.Timestamp() + timestamp_value.FromJsonString(value=value) + return timestamp_value + return value + + +class DurationRule: + """A marshal between Python timedeltas and protobuf durations. + + Note: Python timedeltas are less precise than protobuf durations + (microsecond vs. nanosecond level precision). If nanosecond-level + precision matters, it is recommended to interact with the internal + proto directly. + """ + + def to_python(self, value, *, absent: bool = None) -> timedelta: + if isinstance(value, duration_pb2.Duration): + return timedelta( + days=value.seconds // 86400, + seconds=value.seconds % 86400, + microseconds=value.nanos // 1000, + ) + return value + + def to_proto(self, value) -> duration_pb2.Duration: + if isinstance(value, timedelta): + return duration_pb2.Duration( + seconds=value.days * 86400 + value.seconds, + nanos=value.microseconds * 1000, + ) + if isinstance(value, str): + duration_value = duration_pb2.Duration() + duration_value.FromJsonString(value=value) + return duration_value + return value diff --git a/packages/proto-plus/proto/marshal/rules/enums.py b/packages/proto-plus/proto/marshal/rules/enums.py new file mode 100644 index 000000000000..9cfc312764b6 --- /dev/null +++ b/packages/proto-plus/proto/marshal/rules/enums.py @@ -0,0 +1,59 @@ +# Copyright 2019 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. + +from typing import Type +import enum +import warnings + + +class EnumRule: + """A marshal for converting between integer values and enum values.""" + + def __init__(self, enum_class: Type[enum.IntEnum]): + self._enum = enum_class + + def to_python(self, value, *, absent: bool = None): + if isinstance(value, int) and not isinstance(value, self._enum): + try: + # Coerce the int on the wire to the enum value. + return self._enum(value) + except ValueError: + # Since it is possible to add values to enums, we do + # not want to flatly error on this. + # + # However, it is useful to make some noise about it so + # the user realizes that an unexpected value came along. + warnings.warn( + "Unrecognized {name} enum value: {value}".format( + name=self._enum.__name__, + value=value, + ) + ) + return value + + def to_proto(self, value): + # Accept enum values and coerce to the pure integer. + # This is not strictly necessary (protocol buffers can take these + # objects as they subclass int) but nevertheless seems like the + # right thing to do. + if isinstance(value, self._enum): + return value.value + + # If a string is provided that matches an enum value, coerce it + # to the enum value. + if isinstance(value, str): + return self._enum[value].value + + # We got a pure integer; pass it on. + return value diff --git a/packages/proto-plus/proto/marshal/rules/field_mask.py b/packages/proto-plus/proto/marshal/rules/field_mask.py new file mode 100644 index 000000000000..348e7e3995d4 --- /dev/null +++ b/packages/proto-plus/proto/marshal/rules/field_mask.py @@ -0,0 +1,36 @@ +# 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. + +from google.protobuf import field_mask_pb2 + + +class FieldMaskRule: + """A marshal between FieldMask and strings. + + See https://github.com/googleapis/proto-plus-python/issues/333 + 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 isinstance(value, str): + field_mask_value = field_mask_pb2.FieldMask() + field_mask_value.FromJsonString(value=value) + return field_mask_value + + return value diff --git a/packages/proto-plus/proto/marshal/rules/message.py b/packages/proto-plus/proto/marshal/rules/message.py new file mode 100644 index 000000000000..c191180102fa --- /dev/null +++ b/packages/proto-plus/proto/marshal/rules/message.py @@ -0,0 +1,53 @@ +# 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. + + +class MessageRule: + """A marshal for converting between a descriptor and proto.Message.""" + + def __init__(self, descriptor: type, wrapper: type): + self._descriptor = descriptor + self._wrapper = wrapper + + def to_python(self, value, *, absent: bool = None): + if isinstance(value, self._descriptor): + return self._wrapper.wrap(value) + return value + + def to_proto(self, value): + if isinstance(value, self._wrapper): + return self._wrapper.pb(value) + if isinstance(value, dict) and not self.is_map: + # 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, ValueError, AttributeError) as ex: + # If we have a TypeError, ValueError or AttributeError, + # try the slow path in case the error + # was: + # - an int64/string issue. + # - a missing key issue in case a key only exists with a `_` suffix. + # See related issue: https://github.com/googleapis/python-api-core/issues/227. + # - a missing key issue due to nested struct. See: https://github.com/googleapis/proto-plus-python/issues/424. + # - a missing key issue due to nested duration. See: https://github.com/googleapis/google-cloud-python/issues/13350. + return self._wrapper(value)._pb + return value + + @property + def is_map(self): + """Return True if the descriptor is a map entry, False otherwise.""" + desc = self._descriptor.DESCRIPTOR + return desc.has_options and desc.GetOptions().map_entry diff --git a/packages/proto-plus/proto/marshal/rules/stringy_numbers.py b/packages/proto-plus/proto/marshal/rules/stringy_numbers.py new file mode 100644 index 000000000000..dae69e9c9121 --- /dev/null +++ b/packages/proto-plus/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/packages/proto-plus/proto/marshal/rules/struct.py b/packages/proto-plus/proto/marshal/rules/struct.py new file mode 100644 index 000000000000..0e34587b26b1 --- /dev/null +++ b/packages/proto-plus/proto/marshal/rules/struct.py @@ -0,0 +1,143 @@ +# 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 collections.abc + +from google.protobuf import struct_pb2 + +from proto.marshal.collections import maps +from proto.marshal.collections import repeated + + +class ValueRule: + """A rule to marshal between google.protobuf.Value and Python values.""" + + def __init__(self, *, marshal): + self._marshal = marshal + + def to_python(self, value, *, absent: bool = None): + """Coerce the given value to the appropriate Python type. + + Note that both NullValue and absent fields return None. + In order to disambiguate between these two options, + use containment check, + E.g. + "value" in foo + which is True for NullValue and False for an absent value. + """ + kind = value.WhichOneof("kind") + if kind == "null_value" or absent: + return None + if kind == "bool_value": + return bool(value.bool_value) + if kind == "number_value": + return float(value.number_value) + if kind == "string_value": + return str(value.string_value) + if kind == "struct_value": + return self._marshal.to_python( + 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, + ) + # If more variants are ever added, we want to fail loudly + # instead of tacitly returning None. + raise ValueError("Unexpected kind: %s" % kind) # pragma: NO COVER + + def to_proto(self, value) -> struct_pb2.Value: + """Return a protobuf Value object representing this value.""" + if isinstance(value, struct_pb2.Value): + return value + if value is None: + return struct_pb2.Value(null_value=0) + if isinstance(value, bool): + return struct_pb2.Value(bool_value=value) + if isinstance(value, (int, float)): + return struct_pb2.Value(number_value=float(value)) + if isinstance(value, str): + return struct_pb2.Value(string_value=value) + if isinstance(value, collections.abc.Sequence): + return struct_pb2.Value( + list_value=self._marshal.to_proto(struct_pb2.ListValue, value), + ) + if isinstance(value, collections.abc.Mapping): + return struct_pb2.Value( + struct_value=self._marshal.to_proto(struct_pb2.Struct, value), + ) + raise ValueError("Unable to coerce value: %r" % value) + + +class ListValueRule: + """A rule translating google.protobuf.ListValue and list-like objects.""" + + def __init__(self, *, marshal): + self._marshal = marshal + + def to_python(self, value, *, absent: bool = None): + """Coerce the given value to a Python sequence.""" + return ( + None + if absent + else repeated.RepeatedComposite(value.values, marshal=self._marshal) + ) + + def to_proto(self, value) -> struct_pb2.ListValue: + # We got a proto, or else something we sent originally. + # Preserve the instance we have. + if isinstance(value, struct_pb2.ListValue): + return value + if isinstance(value, repeated.RepeatedComposite): + return struct_pb2.ListValue(values=[v for v in value.pb]) + + # We got a list (or something list-like); convert it. + return struct_pb2.ListValue( + values=[self._marshal.to_proto(struct_pb2.Value, v) for v in value] + ) + + +class StructRule: + """A rule translating google.protobuf.Struct and dict-like objects.""" + + def __init__(self, *, marshal): + self._marshal = marshal + + def to_python(self, value, *, absent: bool = None): + """Coerce the given value to a Python mapping.""" + return ( + None if absent else maps.MapComposite(value.fields, marshal=self._marshal) + ) + + def to_proto(self, value) -> struct_pb2.Struct: + # We got a proto, or else something we sent originally. + # Preserve the instance we have. + 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()}, + ) + + # We got a dict (or something dict-like); convert it. + answer = struct_pb2.Struct( + fields={ + k: self._marshal.to_proto(struct_pb2.Value, v) for k, v in value.items() + } + ) + return answer diff --git a/packages/proto-plus/proto/marshal/rules/wrappers.py b/packages/proto-plus/proto/marshal/rules/wrappers.py new file mode 100644 index 000000000000..5bc89e595851 --- /dev/null +++ b/packages/proto-plus/proto/marshal/rules/wrappers.py @@ -0,0 +1,84 @@ +# 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. + +from google.protobuf import wrappers_pb2 + + +class WrapperRule: + """A marshal for converting the protobuf wrapper classes to Python. + + This class converts between ``google.protobuf.BoolValue``, + ``google.protobuf.StringValue``, and their siblings to the appropriate + Python equivalents. + + These are effectively similar to the protobuf primitives except + that None becomes a possible value. + """ + + def to_python(self, value, *, absent: bool = None): + if isinstance(value, self._proto_type): + if absent: + return None + return value.value + return value + + def to_proto(self, value): + if isinstance(value, self._python_type): + return self._proto_type(value=value) + return value + + +class DoubleValueRule(WrapperRule): + _proto_type = wrappers_pb2.DoubleValue + _python_type = float + + +class FloatValueRule(WrapperRule): + _proto_type = wrappers_pb2.FloatValue + _python_type = float + + +class Int64ValueRule(WrapperRule): + _proto_type = wrappers_pb2.Int64Value + _python_type = int + + +class UInt64ValueRule(WrapperRule): + _proto_type = wrappers_pb2.UInt64Value + _python_type = int + + +class Int32ValueRule(WrapperRule): + _proto_type = wrappers_pb2.Int32Value + _python_type = int + + +class UInt32ValueRule(WrapperRule): + _proto_type = wrappers_pb2.UInt32Value + _python_type = int + + +class BoolValueRule(WrapperRule): + _proto_type = wrappers_pb2.BoolValue + _python_type = bool + + +class StringValueRule(WrapperRule): + _proto_type = wrappers_pb2.StringValue + _python_type = str + + +class BytesValueRule(WrapperRule): + _proto_type = wrappers_pb2.BytesValue + _python_type = bytes diff --git a/packages/proto-plus/proto/message.py b/packages/proto-plus/proto/message.py new file mode 100644 index 000000000000..10a6f42d7612 --- /dev/null +++ b/packages/proto-plus/proto/message.py @@ -0,0 +1,969 @@ +# 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 collections +import collections.abc +import copy +import re +from typing import Any, Dict, List, Optional, Type +import warnings + +import google.protobuf +from google.protobuf import descriptor_pb2 +from google.protobuf import message +from google.protobuf.json_format import MessageToDict, MessageToJson, Parse + +from proto import _file_info +from proto import _package_info +from proto.fields import Field +from proto.fields import MapField +from proto.fields import RepeatedField +from proto.marshal import Marshal +from proto.primitives import ProtoType +from proto.utils import has_upb + + +PROTOBUF_VERSION = google.protobuf.__version__ + +_upb = has_upb() # Important to cache result here. + + +class MessageMeta(type): + """A metaclass for building and registering Message subclasses.""" + + def __new__(mcls, name, bases, attrs): + # Do not do any special behavior for Message itself. + if not bases: + return super().__new__(mcls, name, bases, attrs) + + # Get the essential information about the proto package, and where + # this component belongs within the file. + package, marshal = _package_info.compile(name, attrs) + + # Determine the local path of this proto component within the file. + local_path = tuple(attrs.get("__qualname__", name).split(".")) + + # Sanity check: We get the wrong full name if a class is declared + # inside a function local scope; correct this. + if "" in local_path: + ix = local_path.index("") + local_path = local_path[: ix - 1] + local_path[ix + 1 :] + + # Determine the full name in protocol buffers. + full_name = ".".join((package,) + local_path).lstrip(".") + + # Special case: Maps. Map fields are special; they are essentially + # shorthand for a nested message and a repeated field of that message. + # Decompose each map into its constituent form. + # https://developers.google.com/protocol-buffers/docs/proto3#maps + map_fields = {} + for key, field in attrs.items(): + if not isinstance(field, MapField): + continue + + # 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, + ).replace(key[0], key[0].upper(), 1), + ) + + # Create the "entry" message (with the key and value fields). + # + # Note: We instantiate an ordered dictionary here and then + # attach key and value in order to ensure that the fields are + # iterated in the correct order when the class is created. + # This is only an issue in Python 3.5, where the order is + # random (and the wrong order causes the pool to refuse to add + # the descriptor because reasons). + entry_attrs = collections.OrderedDict( + { + "__module__": attrs.get("__module__", None), + "__qualname__": "{prefix}.{name}".format( + 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, + ) + 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], + ) + + # Add the new entries to the attrs + attrs.update(map_fields) + + # Okay, now we deal with all the rest of the fields. + # Iterate over all the attributes and separate the fields into + # their own sequence. + fields = [] + new_attrs = {} + oneofs = collections.OrderedDict() + proto_imports = set() + index = 0 + for key, field in attrs.items(): + # Sanity check: If this is not a field, do nothing. + if not isinstance(field, Field): + # The field objects themselves should not be direct attributes. + new_attrs[key] = field + continue + + # Add data that the field requires that we do not take in the + # constructor because we can derive it from the metaclass. + # (The goal is to make the declaration syntax as nice as possible.) + field.mcls_data = { + "name": key, + "parent_name": full_name, + "index": index, + "package": package, + } + + # Add the field to the list of fields. + fields.append(field) + # If this field is part of a "oneof", ensure the oneof itself + # is represented. + if field.oneof: + # Keep a running tally of the index of each oneof, and assign + # that index to the field's descriptor. + oneofs.setdefault(field.oneof, len(oneofs)) + field.descriptor.oneof_index = oneofs[field.oneof] + + # If this field references a message, it may be from another + # proto file; ensure we know about the import (to faithfully + # construct our file descriptor proto). + if field.message and not isinstance(field.message, str): + field_msg = field.message + if hasattr(field_msg, "pb") and callable(field_msg.pb): + field_msg = field_msg.pb() + # Sanity check: The field's message may not yet be defined if + # it was a Message defined in the same file, and the file + # descriptor proto has not yet been generated. + # + # We do nothing in this situation; everything will be handled + # correctly when the file descriptor is created later. + if field_msg: + proto_imports.add(field_msg.DESCRIPTOR.file.name) + + # Same thing, but for enums. + elif field.enum and not isinstance(field.enum, str): + field_enum = ( + field.enum._meta.pb + if hasattr(field.enum, "_meta") + else field.enum.DESCRIPTOR + ) + + if field_enum: + proto_imports.add(field_enum.file.name) + + # Increment the field index counter. + index += 1 + + # As per descriptor.proto, all synthetic oneofs must be ordered after + # 'real' oneofs. + opt_attrs = {} + for field in fields: + if field.optional: + field.oneof = "_{}".format(field.name) + field.descriptor.oneof_index = oneofs[field.oneof] = len(oneofs) + opt_attrs[field.name] = field.name + + # Generating a metaclass dynamically provides class attributes that + # instances can't see. This provides idiomatically named constants + # that enable the following pattern to check for field presence: + # + # class MyMessage(proto.Message): + # field = proto.Field(proto.INT32, number=1, optional=True) + # + # m = MyMessage() + # MyMessage.field in m + if opt_attrs: + mcls = type("AttrsMeta", (mcls,), opt_attrs) + + # Determine the filename. + # We determine an appropriate proto filename based on the + # Python module. + filename = _file_info._FileInfo.proto_file_name( + new_attrs.get("__module__", name.lower()) + ) + + # Get or create the information about the file, including the + # descriptor to which the new message descriptor shall be added. + file_info = _file_info._FileInfo.maybe_add_descriptor(filename, package) + + # Ensure any imports that would be necessary are assigned to the file + # descriptor proto being created. + for proto_import in proto_imports: + if proto_import not in file_info.descriptor.dependency: + file_info.descriptor.dependency.append(proto_import) + + # Retrieve any message options. + opts = descriptor_pb2.MessageOptions(**new_attrs.pop("_pb_options", {})) + + # Create the underlying proto descriptor. + desc = descriptor_pb2.DescriptorProto( + name=name, + field=[i.descriptor for i in fields], + oneof_decl=[ + descriptor_pb2.OneofDescriptorProto(name=i) for i in oneofs.keys() + ], + options=opts, + ) + + # If any descriptors were nested under this one, they need to be + # attached as nested types here. + child_paths = [p for p in file_info.nested.keys() if local_path == p[:-1]] + for child_path in child_paths: + desc.nested_type.add().MergeFrom(file_info.nested.pop(child_path)) + + # Same thing, but for enums + child_paths = [p for p in file_info.nested_enum.keys() if local_path == p[:-1]] + for child_path in child_paths: + desc.enum_type.add().MergeFrom(file_info.nested_enum.pop(child_path)) + + # Add the descriptor to the file if it is a top-level descriptor, + # or to a "holding area" for nested messages otherwise. + if len(local_path) == 1: + file_info.descriptor.message_type.add().MergeFrom(desc) + else: + file_info.nested[local_path] = desc + + # Create the MessageInfo instance to be attached to this message. + new_attrs["_meta"] = _MessageInfo( + fields=fields, + full_name=full_name, + marshal=marshal, + options=opts, + package=package, + ) + + # Run the superclass constructor. + cls = super().__new__(mcls, name, bases, new_attrs) + + # The info class and fields need a reference to the class just created. + cls._meta.parent = cls + for field in cls._meta.fields.values(): + field.parent = cls + + # Add this message to the _FileInfo instance; this allows us to + # associate the descriptor with the message once the descriptor + # is generated. + file_info.messages[full_name] = cls + + # Generate the descriptor for the file if it is ready. + if file_info.ready(new_class=cls): + file_info.generate_file_pb(new_class=cls, fallback_salt=full_name) + + # Done; return the class. + return cls + + @classmethod + def __prepare__(mcls, name, bases, **kwargs): + return collections.OrderedDict() + + @property + 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. + + Args: + obj: If provided, and an instance of ``cls``, return the + underlying protobuf instance. + coerce (bool): If provided, will attempt to coerce ``obj`` to + ``cls`` if it is not already an instance. + """ + if obj is None: + return cls.meta.pb + if not isinstance(obj, cls): + if coerce: + obj = cls(obj) + else: + raise TypeError( + "%r is not an instance of %s" + % ( + obj, + cls.__name__, + ) + ) + return obj._pb + + def wrap(cls, pb): + """Return a Message object that shallowly wraps the descriptor. + + Args: + pb: A protocol buffer object, such as would be returned by + :meth:`pb`. + """ + # Optimized fast path. + instance = cls.__new__(cls) + super(cls, instance).__setattr__("_pb", pb) + return instance + + def serialize(cls, instance) -> bytes: + """Return the serialized proto. + + Args: + instance: An instance of this message type, or something + compatible (accepted by the type's constructor). + + Returns: + bytes: The serialized representation of the protocol buffer. + """ + return cls.pb(instance, coerce=True).SerializeToString() + + def deserialize(cls, payload: bytes) -> "Message": + """Given a serialized proto, deserialize it into a Message instance. + + Args: + payload (bytes): The serialized proto. + + Returns: + ~.Message: An instance of the message class against which this + method was called. + """ + return cls.wrap(cls.pb().FromString(payload)) + + def _warn_if_including_default_value_fields_is_used_protobuf_5( + cls, including_default_value_fields: Optional[bool] + ) -> None: + """ + Warn Protobuf 5.x+ users that `including_default_value_fields` is deprecated if it is set. + + Args: + including_default_value_fields (Optional(bool)): The value of `including_default_value_fields` set by the user. + """ + if ( + PROTOBUF_VERSION[0] not in ("3", "4") + and including_default_value_fields is not None + ): + warnings.warn( + """The argument `including_default_value_fields` has been removed from + Protobuf 5.x. Please use `always_print_fields_with_no_presence` instead. + """, + DeprecationWarning, + ) + + def _raise_if_print_fields_values_are_set_and_differ( + cls, + always_print_fields_with_no_presence: Optional[bool], + including_default_value_fields: Optional[bool], + ) -> None: + """ + Raise Exception if both `always_print_fields_with_no_presence` and `including_default_value_fields` are set + and the values differ. + + Args: + always_print_fields_with_no_presence (Optional(bool)): The value of `always_print_fields_with_no_presence` set by the user. + including_default_value_fields (Optional(bool)): The value of `including_default_value_fields` set by the user. + Returns: + None + Raises: + ValueError: if both `always_print_fields_with_no_presence` and `including_default_value_fields` are set and + the values differ. + """ + if ( + always_print_fields_with_no_presence is not None + and including_default_value_fields is not None + and always_print_fields_with_no_presence != including_default_value_fields + ): + raise ValueError( + "Arguments `always_print_fields_with_no_presence` and `including_default_value_fields` must match" + ) + + def _normalize_print_fields_without_presence( + cls, + always_print_fields_with_no_presence: Optional[bool], + including_default_value_fields: Optional[bool], + ) -> bool: + """ + Return true if fields with no presence should be included in the results. + By default, fields with no presence will be included in the results + when both `always_print_fields_with_no_presence` and + `including_default_value_fields` are not set + + Args: + always_print_fields_with_no_presence (Optional(bool)): The value of `always_print_fields_with_no_presence` set by the user. + including_default_value_fields (Optional(bool)): The value of `including_default_value_fields` set by the user. + Returns: + None + Raises: + ValueError: if both `always_print_fields_with_no_presence` and `including_default_value_fields` are set and + the values differ. + """ + + cls._warn_if_including_default_value_fields_is_used_protobuf_5( + including_default_value_fields + ) + cls._raise_if_print_fields_values_are_set_and_differ( + always_print_fields_with_no_presence, including_default_value_fields + ) + # Default to True if neither `always_print_fields_with_no_presence` or `including_default_value_fields` is set + return ( + ( + always_print_fields_with_no_presence is None + and including_default_value_fields is None + ) + or always_print_fields_with_no_presence + or including_default_value_fields + ) + + def to_json( + cls, + instance, + *, + use_integers_for_enums=True, + including_default_value_fields=None, + preserving_proto_field_name=False, + sort_keys=False, + indent=2, + float_precision=None, + always_print_fields_with_no_presence=None, + ) -> str: + """Given a message instance, serialize it to json + + Args: + instance: An instance of this message type, or something + compatible (accepted by the type's constructor). + 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. + including_default_value_fields (Optional(bool)): Deprecated. Use argument + `always_print_fields_with_no_presence` instead. An option that + determines whether the default field values should be included in the results. + This value must match `always_print_fields_with_no_presence`, + if both arguments are explicitly set. + 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. + sort_keys (Optional(bool)): If True, then the output will be sorted by field names. + Default is False. + indent (Optional(int)): The JSON object will be pretty-printed with this indent level. + An indent level of 0 or negative will only insert newlines. + Pass None for the most compact representation without newlines. + float_precision (Optional(int)): If set, use this to specify float field valid digits. + Default is None. + always_print_fields_with_no_presence (Optional(bool)): If True, fields without + presence (implicit presence scalars, repeated fields, and map fields) will + always be serialized. Any field that supports presence is not affected by + this option (including singular message fields and oneof fields). + This value must match `including_default_value_fields`, + if both arguments are explicitly set. + Returns: + str: The json string representation of the protocol buffer. + """ + + print_fields = cls._normalize_print_fields_without_presence( + always_print_fields_with_no_presence, including_default_value_fields + ) + + if PROTOBUF_VERSION[0] in ("3", "4"): + return MessageToJson( + cls.pb(instance), + use_integers_for_enums=use_integers_for_enums, + including_default_value_fields=print_fields, + preserving_proto_field_name=preserving_proto_field_name, + sort_keys=sort_keys, + indent=indent, + float_precision=float_precision, + ) + else: + # The `including_default_value_fields` argument was removed from protobuf 5.x + # and replaced with `always_print_fields_with_no_presence` which very similar but has + # handles optional fields consistently by not affecting them. + # The old flag accidentally had inconsistent behavior between proto2 + # optional and proto3 optional fields. + return MessageToJson( + cls.pb(instance), + use_integers_for_enums=use_integers_for_enums, + always_print_fields_with_no_presence=print_fields, + preserving_proto_field_name=preserving_proto_field_name, + sort_keys=sort_keys, + indent=indent, + float_precision=float_precision, + ) + + def from_json(cls, payload, *, ignore_unknown_fields=False) -> "Message": + """Given a json string representing an instance, + parse it into a message. + + Args: + payload: A json string representing a message. + ignore_unknown_fields (Optional(bool)): If True, do not raise errors + for unknown fields. + + Returns: + ~.Message: An instance of the message class against which this + method was called. + """ + instance = cls() + Parse(payload, instance._pb, ignore_unknown_fields=ignore_unknown_fields) + return instance + + def to_dict( + cls, + instance, + *, + use_integers_for_enums=True, + preserving_proto_field_name=True, + including_default_value_fields=None, + float_precision=None, + always_print_fields_with_no_presence=None, + ) -> Dict[str, Any]: + """Given a message instance, return its representation as a python dict. + + Args: + instance: An instance of this message type, or something + compatible (accepted by the type's constructor). + 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)): Deprecated. Use argument + `always_print_fields_with_no_presence` instead. An option that + determines whether the default field values should be included in the results. + This value must match `always_print_fields_with_no_presence`, + if both arguments are explicitly set. + float_precision (Optional(int)): If set, use this to specify float field valid digits. + Default is None. + always_print_fields_with_no_presence (Optional(bool)): If True, fields without + presence (implicit presence scalars, repeated fields, and map fields) will + always be serialized. Any field that supports presence is not affected by + this option (including singular message fields and oneof fields). This value + must match `including_default_value_fields`, if both arguments are explicitly set. + + Returns: + dict: A representation of the protocol buffer using pythonic data structures. + Messages and map fields are represented as dicts, + repeated fields are represented as lists. + """ + + print_fields = cls._normalize_print_fields_without_presence( + always_print_fields_with_no_presence, including_default_value_fields + ) + + if PROTOBUF_VERSION[0] in ("3", "4"): + return MessageToDict( + cls.pb(instance), + including_default_value_fields=print_fields, + preserving_proto_field_name=preserving_proto_field_name, + use_integers_for_enums=use_integers_for_enums, + float_precision=float_precision, + ) + else: + # The `including_default_value_fields` argument was removed from protobuf 5.x + # and replaced with `always_print_fields_with_no_presence` which very similar but has + # handles optional fields consistently by not affecting them. + # The old flag accidentally had inconsistent behavior between proto2 + # optional and proto3 optional fields. + return MessageToDict( + cls.pb(instance), + always_print_fields_with_no_presence=print_fields, + preserving_proto_field_name=preserving_proto_field_name, + use_integers_for_enums=use_integers_for_enums, + float_precision=float_precision, + ) + + 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. + + Args: + mapping (Union[dict, ~.Message]): A dictionary or message to be + used to determine the values for this message. + ignore_unknown_fields (Optional(bool)): If True, do not raise errors for + unknown fields. Only applied if `mapping` is a mapping type or there + are keyword parameters. + kwargs (dict): Keys and values corresponding to the fields of the + message. + """ + + 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. + # * A dict + # * Nothing (keyword arguments only). + if mapping is None: + if not kwargs: + # Special fast path for empty construction. + super().__setattr__("_pb", self._meta.pb()) + return + + mapping = kwargs + elif isinstance(mapping, self._meta.pb): + # Make a copy of the mapping. + # This is a constructor for a new object, so users will assume + # that it will not have side effects on the arguments being + # passed in. + # + # The `wrap` method on the metaclass is the public API for taking + # ownership of the passed in protobuf object. + mapping = copy.deepcopy(mapping) + if kwargs: + mapping.MergeFrom(self._meta.pb(**kwargs)) + + super().__setattr__("_pb", mapping) + return + elif isinstance(mapping, type(self)): + # Just use the above logic on mapping's underlying pb. + self.__init__(mapping=mapping._pb, **kwargs) + return + elif isinstance(mapping, collections.abc.Mapping): + # Can't have side effects on mapping. + mapping = copy.copy(mapping) + # kwargs entries take priority for duplicate keys. + mapping.update(kwargs) + else: + # Sanity check: Did we get something not a map? Error if so. + raise TypeError( + "Invalid constructor input for %s: %r" + % ( + self.__class__.__name__, + mapping, + ) + ) + + params = {} + # Update the mapping to address any values that need to be + # coerced. + marshal = self._meta.marshal + for key, value in mapping.items(): + (key, pb_type) = self._get_pb_type_from_key(key) + if pb_type is None: + if ignore_unknown_fields: + continue + + raise ValueError( + "Unknown field for {}: {}".format(self.__class__.__name__, key) + ) + + 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()) + + def __contains__(self, key): + """Return True if this field was set to something non-zero on the wire. + + In most cases, this method will return True when ``__getattr__`` + would return a truthy value and False when it would return a falsy + value, so explicitly calling this is not useful. + + The exception case is empty messages explicitly set on the wire, + which are falsy from ``__getattr__``. This method allows to + distinguish between an explicitly provided empty message and the + absence of that message, which is useful in some edge cases. + + The most common edge case is the use of ``google.protobuf.BoolValue`` + 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 + accommodate cases not automatically covered. + + Args: + key (str): The name of the field. + + Returns: + bool: Whether the field's value corresponds to a non-empty + wire serialization. + """ + pb_value = getattr(self._pb, key) + try: + # Protocol buffers "HasField" is unfriendly; it only works + # against composite, non-repeated fields, and raises ValueError + # against any repeated field or primitive. + # + # There is no good way to test whether it is valid to provide + # a field to this method, so sadly we are stuck with a + # somewhat inefficient try/except. + return self._pb.HasField(key) + except ValueError: + return bool(pb_value) + + def __delattr__(self, key): + """Delete the value on the given field. + + This is generally equivalent to setting a falsy value. + """ + self._pb.ClearField(key) + + def __eq__(self, other): + """Return True if the messages are equal, False otherwise.""" + # If these are the same type, use internal protobuf's equality check. + if isinstance(other, type(self)): + return self._pb == other._pb + + # If the other type is the target protobuf object, honor that also. + if isinstance(other, self._meta.pb): + return self._pb == other + + # Ask the other object. + return NotImplemented + + def __getattr__(self, key): + """Retrieve the given field's value. + + In protocol buffers, the presence of a field on a message is + sufficient for it to always be "present". + + For primitives, a value of the correct type will always be returned + (the "falsy" values in protocol buffers consistently match those + in Python). For repeated fields, the falsy value is always an empty + sequence. + + For messages, protocol buffers does distinguish between an empty + message and absence, but this distinction is subtle and rarely + relevant. Therefore, this method always returns an empty message + (following the official implementation). To check for message + presence, use ``key in self`` (in other words, ``__contains__``). + + .. note:: + + Some well-known protocol buffer types + (e.g. ``google.protobuf.Timestamp``) will be converted to + their Python equivalents. See the ``marshal`` module for + more details. + """ + (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.""" + return not self == other + + def __repr__(self): + return repr(self._pb) + + def __setattr__(self, key, value): + """Set the value on the given field. + + For well-known protocol buffer types which are marshalled, either + the protocol buffer object or the Python equivalent is accepted. + """ + if key[0] == "_": + return super().__setattr__(key, value) + marshal = self._meta.marshal + (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. + # This is the only way to successfully write nested falsy values, + # because otherwise MergeFrom will no-op on them. + self._pb.ClearField(key) + + # Merge in the value being set. + 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. + + Args: + fields (Tuple[~.fields.Field]): The fields declared on the message. + package (str): The proto package. + full_name (str): The full name of the message. + file_info (~._FileInfo): The file descriptor and messages for the + file containing this message. + marshal (~.Marshal): The marshal instance to which this message was + automatically registered. + options (~.descriptor_pb2.MessageOptions): Any options that were + set on the message. + """ + + def __init__( + self, + *, + fields: List[Field], + package: str, + full_name: str, + marshal: Marshal, + options: descriptor_pb2.MessageOptions, + ) -> None: + self.package = package + self.full_name = full_name + self.options = options + self.fields = collections.OrderedDict((i.name, i) for i in fields) + self.fields_by_number = collections.OrderedDict((i.number, i) for i in fields) + self.marshal = marshal + self._pb = None + + @property + def pb(self) -> Type[message.Message]: + """Return the protobuf message type for this descriptor. + + If a field on the message references another message which has not + loaded, then this method returns None. + """ + return self._pb + + +__all__ = ("Message",) diff --git a/packages/proto-plus/proto/modules.py b/packages/proto-plus/proto/modules.py new file mode 100644 index 000000000000..45864a937c58 --- /dev/null +++ b/packages/proto-plus/proto/modules.py @@ -0,0 +1,50 @@ +# Copyright 2019 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. + +from typing import Set +import collections + + +_ProtoModule = collections.namedtuple( + "ProtoModule", + ["package", "marshal", "manifest"], +) + + +def define_module( + *, package: str, marshal: str = None, manifest: Set[str] = frozenset() +) -> _ProtoModule: + """Define a protocol buffers module. + + The settings defined here are used for all protobuf messages + declared in the module of the given name. + + Args: + package (str): The proto package name. + marshal (str): The name of the marshal to use. It is recommended + to use one marshal per Python library (e.g. package on PyPI). + manifest (Set[str]): A set of messages and enums to be created. Setting + this adds a slight efficiency in piecing together proto + descriptors under the hood. + """ + if not marshal: + marshal = package + return _ProtoModule( + package=package, + marshal=marshal, + manifest=frozenset(manifest), + ) + + +__all__ = ("define_module",) diff --git a/packages/proto-plus/proto/primitives.py b/packages/proto-plus/proto/primitives.py new file mode 100644 index 000000000000..cff2094c678b --- /dev/null +++ b/packages/proto-plus/proto/primitives.py @@ -0,0 +1,38 @@ +# 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 enum + + +class ProtoType(enum.IntEnum): + """The set of basic types in protocol buffers.""" + + # These values come from google/protobuf/descriptor.proto + DOUBLE = 1 + FLOAT = 2 + INT64 = 3 + UINT64 = 4 + INT32 = 5 + FIXED64 = 6 + FIXED32 = 7 + BOOL = 8 + STRING = 9 + MESSAGE = 11 + BYTES = 12 + UINT32 = 13 + ENUM = 14 + SFIXED32 = 15 + SFIXED64 = 16 + SINT32 = 17 + SINT64 = 18 diff --git a/packages/proto-plus/proto/utils.py b/packages/proto-plus/proto/utils.py new file mode 100644 index 000000000000..ac3c471a2e8b --- /dev/null +++ b/packages/proto-plus/proto/utils.py @@ -0,0 +1,58 @@ +# 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 functools + + +def has_upb(): + try: + from google._upb import _message # pylint: disable=unused-import + + has_upb = True + except ImportError: + has_upb = False + return has_upb + + +def cached_property(fx): + """Make the callable into a cached property. + + Similar to @property, but the function will only be called once per + object. + + Args: + fx (Callable[]): The property function. + + Returns: + Callable[]: The wrapped function. + """ + + @functools.wraps(fx) + def inner(self): + # Sanity check: If there is no cache at all, create an empty cache. + if not hasattr(self, "_cached_values"): + object.__setattr__(self, "_cached_values", {}) + + # If and only if the function's result is not in the cache, + # run the function. + if fx.__name__ not in self._cached_values: + self._cached_values[fx.__name__] = fx(self) + + # Return the value from cache. + return self._cached_values[fx.__name__] + + return property(inner) + + +__all__ = ("cached_property",) diff --git a/packages/proto-plus/proto/version.py b/packages/proto-plus/proto/version.py new file mode 100644 index 000000000000..46d6b35ed597 --- /dev/null +++ b/packages/proto-plus/proto/version.py @@ -0,0 +1,15 @@ +# Copyright 2023 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. +# +__version__ = "1.26.1" diff --git a/packages/proto-plus/pyproject.toml b/packages/proto-plus/pyproject.toml new file mode 100644 index 000000000000..3a031d6640da --- /dev/null +++ b/packages/proto-plus/pyproject.toml @@ -0,0 +1,58 @@ +# Copyright 2024 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. + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "proto-plus" +authors = [{ name = "Google LLC", email = "googleapis-packages@google.com" }] +license = { text = "Apache 2.0" } +requires-python = ">=3.7" +description = "Beautiful, Pythonic protocol buffers" +readme = "README.rst" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Operating System :: OS Independent", + "Topic :: Software Development :: Code Generators", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dependencies = ["protobuf >=3.19.0, < 7.0.0"] +dynamic = ["version"] + +[project.urls] +Documentation = "https://googleapis.dev/python/proto-plus/latest/" +Repository = "https://github.com/googleapis/proto-plus-python" + +[project.optional-dependencies] +testing = ["google-api-core >= 1.31.5"] + +[tool.setuptools.dynamic] +version = { attr = "proto.version.__version__" } + +[tool.setuptools.packages.find] +# Only include packages under the 'proto' namespace. Do not include build, docs, tests +include = ["proto*"] diff --git a/packages/proto-plus/pytest.ini b/packages/proto-plus/pytest.ini new file mode 100644 index 000000000000..4f8fe4d2a91b --- /dev/null +++ b/packages/proto-plus/pytest.ini @@ -0,0 +1,11 @@ +[pytest] +filterwarnings = + # treat all warnings as errors + error + # Remove once https://github.com/protocolbuffers/protobuf/issues/12186 is fixed + ignore:.*custom tp_new.*in Python 3.14:DeprecationWarning + # Remove once deprecated field `including_default_value_fields` is removed + ignore:.*The argument `including_default_value_fields` has been removed.*:DeprecationWarning + # Remove once deprecated field `float_precision` is removed + # See https://github.com/googleapis/proto-plus-python/issues/547 + ignore:float_precision option is deprecated for json_format:UserWarning diff --git a/packages/proto-plus/renovate.json b/packages/proto-plus/renovate.json new file mode 100644 index 000000000000..a35fc4d36c4e --- /dev/null +++ b/packages/proto-plus/renovate.json @@ -0,0 +1,11 @@ +{ + "extends": [ + "config:base", + "group:all", + ":disableDependencyDashboard", + "schedule:weekly" + ], + "ignorePaths": [ + ".kokoro/requirements.txt" + ] +} diff --git a/packages/proto-plus/setup.py b/packages/proto-plus/setup.py new file mode 100644 index 000000000000..12e9ed26dcdf --- /dev/null +++ b/packages/proto-plus/setup.py @@ -0,0 +1,19 @@ +# 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. + + +from setuptools import setup + + +setup() diff --git a/packages/proto-plus/testing/constraints-3.10.txt b/packages/proto-plus/testing/constraints-3.10.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/proto-plus/testing/constraints-3.11.txt b/packages/proto-plus/testing/constraints-3.11.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/proto-plus/testing/constraints-3.12.txt b/packages/proto-plus/testing/constraints-3.12.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/proto-plus/testing/constraints-3.13.txt b/packages/proto-plus/testing/constraints-3.13.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/proto-plus/testing/constraints-3.14.txt b/packages/proto-plus/testing/constraints-3.14.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/proto-plus/testing/constraints-3.7.txt b/packages/proto-plus/testing/constraints-3.7.txt new file mode 100644 index 000000000000..849c8e7cb244 --- /dev/null +++ b/packages/proto-plus/testing/constraints-3.7.txt @@ -0,0 +1,9 @@ +# This constraints file is used to check that lower bounds +# are correct in setup.py +# List *all* library dependencies and extras in this file. +# Pin the version to the lower bound. +# +# e.g., if setup.py has "foo >= 1.14.0, < 2.0.0dev", +# Then this file should have foo==1.14.0 +google-api-core==1.31.5 +protobuf==3.19.0 diff --git a/packages/proto-plus/testing/constraints-3.8.txt b/packages/proto-plus/testing/constraints-3.8.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/proto-plus/testing/constraints-3.9.txt b/packages/proto-plus/testing/constraints-3.9.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/proto-plus/testing/constraints-pypy3.10.txt b/packages/proto-plus/testing/constraints-pypy3.10.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/proto-plus/tests/clam.py b/packages/proto-plus/tests/clam.py new file mode 100644 index 000000000000..2f9dde13bd73 --- /dev/null +++ b/packages/proto-plus/tests/clam.py @@ -0,0 +1,44 @@ +# Copyright (C) 2020 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="ocean.clam.v1", + manifest={ + "Clam", + "Species", + "Color", + }, +) + + +class Species(proto.Enum): + UNKNOWN = 0 + SQUAMOSA = 1 + DURASA = 2 + GIGAS = 3 + + +class Color(proto.Enum): + COLOR_UNKNOWN = 0 + BLUE = 1 + ORANGE = 2 + GREEN = 3 + + +class Clam(proto.Message): + species = proto.Field(proto.ENUM, number=1, enum="Species") + mass_kg = proto.Field(proto.DOUBLE, number=2) + color = proto.Field(proto.ENUM, number=3, enum="Color") diff --git a/packages/proto-plus/tests/conftest.py b/packages/proto-plus/tests/conftest.py new file mode 100644 index 000000000000..9f2f5c9540ad --- /dev/null +++ b/packages/proto-plus/tests/conftest.py @@ -0,0 +1,116 @@ +# 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 importlib +from unittest import mock + +from google.protobuf import descriptor_pool +from google.protobuf import message +from google.protobuf import reflection +from google.protobuf import symbol_database + +from proto._file_info import _FileInfo +from proto.marshal import Marshal +from proto.marshal import rules +from proto.utils import has_upb + + +def pytest_runtest_setup(item): + _FileInfo.registry.clear() + + # Replace the descriptor pool and symbol database to avoid tests + # polluting one another. + pool = type(descriptor_pool.Default())() + sym_db = symbol_database.SymbolDatabase(pool=pool) + item._mocks = [ + mock.patch.object(descriptor_pool, "Default", return_value=pool), + mock.patch.object(symbol_database, "Default", return_value=sym_db), + ] + if descriptor_pool._USE_C_DESCRIPTORS: + + item._mocks.append( + mock.patch( + ( + "google._upb._message.default_pool" + if has_upb() + else "google.protobuf.pyext._message.default_pool" + ), + pool, + ) + ) + + [i.start() for i in item._mocks] + + # Importing a pb2 module registers those messages with the pool. + # However, our test harness is subbing out the default pool above, + # which means that all the dependencies that messages may depend on + # are now absent from the pool. + # + # Add any pb2 modules that may have been imported by the test's module to + # the descriptor pool and symbol database. + # + # This is exceptionally tricky in the C implementation because there is + # no way to add an existing descriptor to a pool; the only acceptable + # approach is to add a file descriptor proto, which then creates *new* + # descriptors. We therefore do that and then plop the replacement classes + # onto the pb2 modules. + reloaded = set() + for name in dir(item.module): + if name.endswith("_pb2") and not name.startswith("test_"): + module = getattr(item.module, name) + + # Exclude `google.protobuf.descriptor_pb2` which causes error + # `RecursionError: maximum recursion depth exceeded while calling a Python object` + # when running the test suite and is not required for tests. + # See https://github.com/googleapis/proto-plus-python/issues/425 + if module.__package__ == "google.protobuf" and name == "descriptor_pb2": + continue + + pool.AddSerializedFile(module.DESCRIPTOR.serialized_pb) + fd = pool.FindFileByName(module.DESCRIPTOR.name) + + # Register all the messages to the symbol database and the + # module. Do this recursively if there are nested messages. + _register_messages(module, fd.message_types_by_name, sym_db) + + # Track which modules had new message classes loaded. + # This is used below to wire the new classes into the marshal. + reloaded.add(name) + + # If the marshal had previously registered the old message classes, + # then reload the appropriate modules so the marshal is using the new ones. + if "wrappers_pb2" in reloaded: + importlib.reload(rules.wrappers) + if "struct_pb2" in reloaded: + importlib.reload(rules.struct) + if reloaded.intersection({"timestamp_pb2", "duration_pb2"}): + importlib.reload(rules.dates) + + +def pytest_runtest_teardown(item): + Marshal._instances.clear() + [i.stop() for i in item._mocks] + + +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}, + ) + sym_db.RegisterMessage(new_msg) + setattr(scope, name, new_msg) + _register_messages(new_msg, descriptor.nested_types_by_name, sym_db) diff --git a/packages/proto-plus/tests/enums_test.py b/packages/proto-plus/tests/enums_test.py new file mode 100644 index 000000000000..59c5e67116b0 --- /dev/null +++ b/packages/proto-plus/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/packages/proto-plus/tests/mollusc.py b/packages/proto-plus/tests/mollusc.py new file mode 100644 index 000000000000..c92f161a406d --- /dev/null +++ b/packages/proto-plus/tests/mollusc.py @@ -0,0 +1,27 @@ +# Copyright (C) 2020 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 +import zone + +__protobuf__ = proto.module( + package="ocean.mollusc.v1", + manifest={ + "Mollusc", + }, +) + + +class Mollusc(proto.Message): + zone = proto.Field(zone.Zone, number=1) diff --git a/packages/proto-plus/tests/test_datetime_helpers.py b/packages/proto-plus/tests/test_datetime_helpers.py new file mode 100644 index 000000000000..264b5296980a --- /dev/null +++ b/packages/proto-plus/tests/test_datetime_helpers.py @@ -0,0 +1,289 @@ +# Copyright 2017, 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 calendar +import datetime + +import pytest +import pytz + +from proto import datetime_helpers +from google.protobuf import timestamp_pb2 + + +ONE_MINUTE_IN_MICROSECONDS = 60 * 1e6 + + +def test_from_microseconds(): + five_mins_from_epoch_in_microseconds = 5 * ONE_MINUTE_IN_MICROSECONDS + five_mins_from_epoch_datetime = datetime.datetime( + 1970, 1, 1, 0, 5, 0, tzinfo=datetime.timezone.utc + ) + + result = datetime_helpers._from_microseconds(five_mins_from_epoch_in_microseconds) + + assert result == five_mins_from_epoch_datetime + + +def test_to_rfc3339(): + value = datetime.datetime(2016, 4, 5, 13, 30, 0) + expected = "2016-04-05T13:30:00.000000Z" + assert datetime_helpers._to_rfc3339(value) == expected + + +def test_to_rfc3339_with_utc(): + value = datetime.datetime(2016, 4, 5, 13, 30, 0, tzinfo=datetime.timezone.utc) + expected = "2016-04-05T13:30:00.000000Z" + assert datetime_helpers._to_rfc3339(value, ignore_zone=False) == expected + + +def test_to_rfc3339_with_non_utc(): + zone = pytz.FixedOffset(-60) + value = datetime.datetime(2016, 4, 5, 13, 30, 0, tzinfo=zone) + expected = "2016-04-05T14:30:00.000000Z" + assert datetime_helpers._to_rfc3339(value, ignore_zone=False) == expected + + +def test_to_rfc3339_with_non_utc_ignore_zone(): + zone = pytz.FixedOffset(-60) + value = datetime.datetime(2016, 4, 5, 13, 30, 0, tzinfo=zone) + expected = "2016-04-05T13:30:00.000000Z" + assert datetime_helpers._to_rfc3339(value, ignore_zone=True) == expected + + +def test_ctor_wo_nanos(): + stamp = datetime_helpers.DatetimeWithNanoseconds(2016, 12, 20, 21, 13, 47, 123456) + assert stamp.year == 2016 + assert stamp.month == 12 + assert stamp.day == 20 + assert stamp.hour == 21 + assert stamp.minute == 13 + assert stamp.second == 47 + assert stamp.microsecond == 123456 + assert stamp.nanosecond == 123456000 + + +def test_ctor_w_nanos(): + stamp = datetime_helpers.DatetimeWithNanoseconds( + 2016, 12, 20, 21, 13, 47, nanosecond=123456789 + ) + assert stamp.year == 2016 + assert stamp.month == 12 + assert stamp.day == 20 + assert stamp.hour == 21 + assert stamp.minute == 13 + assert stamp.second == 47 + assert stamp.microsecond == 123456 + assert stamp.nanosecond == 123456789 + + +def test_ctor_w_micros_positional_and_nanos(): + with pytest.raises(TypeError): + datetime_helpers.DatetimeWithNanoseconds( + 2016, 12, 20, 21, 13, 47, 123456, nanosecond=123456789 + ) + + +def test_ctor_w_micros_keyword_and_nanos(): + with pytest.raises(TypeError): + datetime_helpers.DatetimeWithNanoseconds( + 2016, 12, 20, 21, 13, 47, microsecond=123456, nanosecond=123456789 + ) + + +def test_rfc3339_wo_nanos(): + stamp = datetime_helpers.DatetimeWithNanoseconds(2016, 12, 20, 21, 13, 47, 123456) + assert stamp.rfc3339() == "2016-12-20T21:13:47.123456Z" + + +def test_rfc3339_wo_nanos_w_leading_zero(): + stamp = datetime_helpers.DatetimeWithNanoseconds(2016, 12, 20, 21, 13, 47, 1234) + assert stamp.rfc3339() == "2016-12-20T21:13:47.001234Z" + + +def test_rfc3339_w_nanos(): + stamp = datetime_helpers.DatetimeWithNanoseconds( + 2016, 12, 20, 21, 13, 47, nanosecond=123456789 + ) + assert stamp.rfc3339() == "2016-12-20T21:13:47.123456789Z" + + +def test_rfc3339_w_nanos_w_leading_zero(): + stamp = datetime_helpers.DatetimeWithNanoseconds( + 2016, 12, 20, 21, 13, 47, nanosecond=1234567 + ) + assert stamp.rfc3339() == "2016-12-20T21:13:47.001234567Z" + + +def test_rfc3339_w_nanos_no_trailing_zeroes(): + stamp = datetime_helpers.DatetimeWithNanoseconds( + 2016, 12, 20, 21, 13, 47, nanosecond=100000000 + ) + assert stamp.rfc3339() == "2016-12-20T21:13:47.1Z" + + +def test_rfc3339_w_nanos_w_leading_zero_and_no_trailing_zeros(): + stamp = datetime_helpers.DatetimeWithNanoseconds( + 2016, 12, 20, 21, 13, 47, nanosecond=1234500 + ) + assert stamp.rfc3339() == "2016-12-20T21:13:47.0012345Z" + + +def test_from_rfc3339_w_invalid(): + stamp = "2016-12-20T21:13:47" + with pytest.raises(ValueError): + datetime_helpers.DatetimeWithNanoseconds.from_rfc3339(stamp) + + +def test_from_rfc3339_wo_fraction(): + timestamp = "2016-12-20T21:13:47Z" + expected = datetime_helpers.DatetimeWithNanoseconds( + 2016, 12, 20, 21, 13, 47, tzinfo=datetime.timezone.utc + ) + stamp = datetime_helpers.DatetimeWithNanoseconds.from_rfc3339(timestamp) + assert stamp == expected + + +def test_from_rfc3339_w_partial_precision(): + timestamp = "2016-12-20T21:13:47.1Z" + expected = datetime_helpers.DatetimeWithNanoseconds( + 2016, 12, 20, 21, 13, 47, microsecond=100000, tzinfo=datetime.timezone.utc + ) + stamp = datetime_helpers.DatetimeWithNanoseconds.from_rfc3339(timestamp) + assert stamp == expected + + +def test_from_rfc3339_w_full_precision(): + timestamp = "2016-12-20T21:13:47.123456789Z" + expected = datetime_helpers.DatetimeWithNanoseconds( + 2016, 12, 20, 21, 13, 47, nanosecond=123456789, tzinfo=datetime.timezone.utc + ) + stamp = datetime_helpers.DatetimeWithNanoseconds.from_rfc3339(timestamp) + assert stamp == expected + + +@pytest.mark.parametrize( + "fractional, nanos", + [ + ("12345678", 123456780), + ("1234567", 123456700), + ("123456", 123456000), + ("12345", 123450000), + ("1234", 123400000), + ("123", 123000000), + ("12", 120000000), + ("1", 100000000), + ], +) +def test_from_rfc3339_test_nanoseconds(fractional, nanos): + value = "2009-12-17T12:44:32.{}Z".format(fractional) + assert ( + datetime_helpers.DatetimeWithNanoseconds.from_rfc3339(value).nanosecond == nanos + ) + + +def test_timestamp_pb_wo_nanos_naive(): + stamp = datetime_helpers.DatetimeWithNanoseconds(2016, 12, 20, 21, 13, 47, 123456) + delta = stamp.replace(tzinfo=datetime.timezone.utc) - datetime_helpers._UTC_EPOCH + seconds = int(delta.total_seconds()) + nanos = 123456000 + timestamp = timestamp_pb2.Timestamp(seconds=seconds, nanos=nanos) + assert stamp.timestamp_pb() == timestamp + + +def test_timestamp_pb_w_nanos(): + stamp = datetime_helpers.DatetimeWithNanoseconds( + 2016, 12, 20, 21, 13, 47, nanosecond=123456789, tzinfo=datetime.timezone.utc + ) + delta = stamp - datetime_helpers._UTC_EPOCH + timestamp = timestamp_pb2.Timestamp( + seconds=int(delta.total_seconds()), nanos=123456789 + ) + assert stamp.timestamp_pb() == timestamp + + +def test_from_timestamp_pb_wo_nanos(): + when = datetime.datetime( + 2016, 12, 20, 21, 13, 47, 123456, tzinfo=datetime.timezone.utc + ) + delta = when - datetime_helpers._UTC_EPOCH + seconds = int(delta.total_seconds()) + timestamp = timestamp_pb2.Timestamp(seconds=seconds) + + stamp = datetime_helpers.DatetimeWithNanoseconds.from_timestamp_pb(timestamp) + + assert _to_seconds(when) == _to_seconds(stamp) + assert stamp.microsecond == 0 + assert stamp.nanosecond == 0 + assert stamp.tzinfo == datetime.timezone.utc + + +def test_replace(): + stamp = datetime_helpers.DatetimeWithNanoseconds( + 2016, 12, 20, 21, 13, 47, 123456, tzinfo=datetime.timezone.utc + ) + + # ns and ms provided raises + with pytest.raises(TypeError): + stamp.replace(microsecond=1, nanosecond=0) + + # No Nanoseconds or Microseconds + new_stamp = stamp.replace(year=2015) + assert new_stamp.year == 2015 + assert new_stamp.microsecond == 123456 + assert new_stamp.nanosecond == 123456000 + + # Nanos + new_stamp = stamp.replace(nanosecond=789123) + assert new_stamp.microsecond == 789 + assert new_stamp.nanosecond == 789123 + + # Micros + new_stamp = stamp.replace(microsecond=456) + assert new_stamp.microsecond == 456 + assert new_stamp.nanosecond == 456000 + + # assert _to_seconds(when) == _to_seconds(stamp) + # assert stamp.microsecond == 0 + # assert stamp.nanosecond == 0 + # assert stamp.tzinfo == datetime.timezone.utc + + +def test_from_timestamp_pb_w_nanos(): + when = datetime.datetime( + 2016, 12, 20, 21, 13, 47, 123456, tzinfo=datetime.timezone.utc + ) + delta = when - datetime_helpers._UTC_EPOCH + seconds = int(delta.total_seconds()) + timestamp = timestamp_pb2.Timestamp(seconds=seconds, nanos=123456789) + + stamp = datetime_helpers.DatetimeWithNanoseconds.from_timestamp_pb(timestamp) + + assert _to_seconds(when) == _to_seconds(stamp) + assert stamp.microsecond == 123456 + assert stamp.nanosecond == 123456789 + assert stamp.tzinfo == datetime.timezone.utc + + +def _to_seconds(value): + """Convert a datetime to seconds since the unix epoch. + + Args: + value (datetime.datetime): The datetime to convert. + + Returns: + int: Microseconds since the unix epoch. + """ + assert value.tzinfo is datetime.timezone.utc + return calendar.timegm(value.timetuple()) diff --git a/packages/proto-plus/tests/test_enum_total_ordering.py b/packages/proto-plus/tests/test_enum_total_ordering.py new file mode 100644 index 000000000000..584a1831c58a --- /dev/null +++ b/packages/proto-plus/tests/test_enum_total_ordering.py @@ -0,0 +1,97 @@ +# 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 type(to_compare).SOME_VALUE != item + try: + assert to_compare.SOME_VALUE != item + except AttributeError: # Python 3.11.0b3 + pass + 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/packages/proto-plus/tests/test_fields_bytes.py b/packages/proto-plus/tests/test_fields_bytes.py new file mode 100644 index 000000000000..625dcd91c404 --- /dev/null +++ b/packages/proto-plus/tests/test_fields_bytes.py @@ -0,0 +1,96 @@ +# 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 base64 +import pytest + +import proto + + +def test_bytes_init(): + class Foo(proto.Message): + bar = proto.Field(proto.BYTES, number=1) + baz = proto.Field(proto.BYTES, number=2) + + foo = Foo(bar=b"spam") + assert foo.bar == b"spam" + assert foo.baz == b"" + assert not foo.baz + assert Foo.pb(foo).bar == b"spam" + assert Foo.pb(foo).baz == b"" + + +def test_bytes_rmw(): + class Foo(proto.Message): + spam = proto.Field(proto.BYTES, number=1) + eggs = proto.Field(proto.BYTES, number=2) + + foo = Foo(spam=b"bar") + foo.eggs = b"baz" + assert foo.spam == b"bar" + assert foo.eggs == b"baz" + assert Foo.pb(foo).spam == b"bar" + assert Foo.pb(foo).eggs == b"baz" + foo.spam = b"bacon" + assert foo.spam == b"bacon" + assert foo.eggs == b"baz" + assert Foo.pb(foo).spam == b"bacon" + assert Foo.pb(foo).eggs == b"baz" + + +def test_bytes_del(): + class Foo(proto.Message): + bar = proto.Field(proto.BYTES, number=1) + + foo = Foo(bar=b"spam") + assert foo.bar == b"spam" + del foo.bar + assert foo.bar == b"" + assert not foo.bar + + +def test_bytes_string_distinct(): + class Foo(proto.Message): + bar = proto.Field(proto.STRING, number=1) + baz = proto.Field(proto.BYTES, number=2) + + foo = Foo() + assert foo.bar != foo.baz + + # Since protobuf was written against Python 2, it accepts bytes objects + # for strings (but not vice versa). + foo.bar = b"anything" + assert foo.bar == "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/packages/proto-plus/tests/test_fields_composite.py b/packages/proto-plus/tests/test_fields_composite.py new file mode 100644 index 000000000000..60d29e122b12 --- /dev/null +++ b/packages/proto-plus/tests/test_fields_composite.py @@ -0,0 +1,93 @@ +# 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 proto + + +def test_composite_init(): + class Foo(proto.Message): + bar = proto.Field(proto.STRING, number=1) + baz = proto.Field(proto.INT64, number=2) + + class Spam(proto.Message): + foo = proto.Field(proto.MESSAGE, number=1, message=Foo) + eggs = proto.Field(proto.BOOL, number=2) + + spam = Spam(foo=Foo(bar="str", baz=42)) + assert spam.foo.bar == "str" + assert spam.foo.baz == 42 + assert spam.eggs is False + + +def test_composite_inner_rmw(): + class Foo(proto.Message): + bar = proto.Field(proto.STRING, number=1) + + class Spam(proto.Message): + foo = proto.Field(proto.MESSAGE, number=1, message=Foo) + + spam = Spam(foo=Foo(bar="str")) + spam.foo.bar = "other str" + assert spam.foo.bar == "other str" + assert Spam.pb(spam).foo.bar == "other str" + + +def test_composite_empty_inner_rmw(): + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + + class Spam(proto.Message): + foo = proto.Field(proto.MESSAGE, number=1, message=Foo) + + spam = Spam() + spam.foo.bar = 42 + assert spam.foo.bar == 42 + + +def test_composite_outer_rmw(): + class Foo(proto.Message): + bar = proto.Field(proto.FLOAT, number=1) + + class Spam(proto.Message): + foo = proto.Field(proto.MESSAGE, number=1, message=Foo) + + spam = Spam(foo=Foo(bar=3.14159)) + spam.foo = Foo(bar=2.71828) + assert abs(spam.foo.bar - 2.71828) < 1e-7 + + +def test_composite_dict_write(): + class Foo(proto.Message): + bar = proto.Field(proto.FLOAT, number=1) + + class Spam(proto.Message): + foo = proto.Field(proto.MESSAGE, number=1, message=Foo) + + spam = Spam() + spam.foo = {"bar": 2.71828} + assert abs(spam.foo.bar - 2.71828) < 1e-7 + + +def test_composite_del(): + class Foo(proto.Message): + bar = proto.Field(proto.STRING, number=1) + + class Spam(proto.Message): + foo = proto.Field(proto.MESSAGE, number=1, message=Foo) + + spam = Spam(foo=Foo(bar="str")) + del spam.foo + assert not spam.foo + assert isinstance(spam.foo, Foo) + assert spam.foo.bar == "" diff --git a/packages/proto-plus/tests/test_fields_composite_string_ref.py b/packages/proto-plus/tests/test_fields_composite_string_ref.py new file mode 100644 index 000000000000..b6c6faaa0a47 --- /dev/null +++ b/packages/proto-plus/tests/test_fields_composite_string_ref.py @@ -0,0 +1,106 @@ +# 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 sys + +import proto + + +def test_composite_forward_ref(): + class Spam(proto.Message): + foo = proto.Field(proto.MESSAGE, number=1, message="Foo") + eggs = proto.Field(proto.BOOL, number=2) + + class Foo(proto.Message): + bar = proto.Field(proto.STRING, number=1) + baz = proto.Field(proto.INT64, number=2) + + spam = Spam(foo=Foo(bar="str", baz=42)) + assert spam.foo.bar == "str" + assert spam.foo.baz == 42 + assert spam.eggs is False + + +def test_composite_forward_ref_with_package(): + sys.modules[__name__].__protobuf__ = proto.module(package="abc.def") + try: + + class Spam(proto.Message): + foo = proto.Field("Foo", number=1) + + class Eggs(proto.Message): + foo = proto.Field("abc.def.Foo", number=1) + + class Foo(proto.Message): + bar = proto.Field(proto.STRING, number=1) + baz = proto.Field(proto.INT64, number=2) + + finally: + del sys.modules[__name__].__protobuf__ + + spam = Spam(foo=Foo(bar="str", baz=42)) + eggs = Eggs(foo=Foo(bar="rts", baz=24)) + assert spam.foo.bar == "str" + assert spam.foo.baz == 42 + assert eggs.foo.bar == "rts" + assert eggs.foo.baz == 24 + + +def test_composite_backward_ref(): + class Foo(proto.Message): + bar = proto.Field(proto.STRING, number=1) + baz = proto.Field(proto.INT64, number=2) + + class Spam(proto.Message): + foo = proto.Field(proto.MESSAGE, number=1, message=Foo) + eggs = proto.Field(proto.BOOL, number=2) + + spam = Spam(foo=Foo(bar="str", baz=42)) + assert spam.foo.bar == "str" + assert spam.foo.baz == 42 + assert spam.eggs is False + + +def test_composite_multi_ref(): + class Spam(proto.Message): + foo = proto.Field(proto.MESSAGE, number=1, message="Foo") + eggs = proto.Field(proto.BOOL, number=2) + + class Foo(proto.Message): + bar = proto.Field(proto.STRING, number=1) + baz = proto.Field(proto.INT64, number=2) + + class Bacon(proto.Message): + foo = proto.Field(proto.MESSAGE, number=1, message=Foo) + + spam = Spam(foo=Foo(bar="str", baz=42)) + bacon = Bacon(foo=spam.foo) + assert spam.foo.bar == "str" + assert spam.foo.baz == 42 + assert spam.eggs is False + assert bacon.foo == spam.foo + + +def test_composite_self_ref(): + class Spam(proto.Message): + spam = proto.Field(proto.MESSAGE, number=1, message="Spam") + eggs = proto.Field(proto.BOOL, number=2) + + spam = Spam(spam=Spam(eggs=True)) + assert spam.eggs is False + assert spam.spam.eggs is True + assert type(spam) is type(spam.spam) # noqa: E0721 + assert not spam.spam.spam + assert spam.spam.spam.eggs is False + assert not spam.spam.spam.spam.spam.spam.spam.spam diff --git a/packages/proto-plus/tests/test_fields_enum.py b/packages/proto-plus/tests/test_fields_enum.py new file mode 100644 index 000000000000..e5d5b324a285 --- /dev/null +++ b/packages/proto-plus/tests/test_fields_enum.py @@ -0,0 +1,394 @@ +# 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 os +import proto +import pytest +import sys + + +def test_outer_enum_init(): + class Foo(proto.Message): + color = proto.Field(proto.ENUM, number=1, enum="Color") + + class Color(proto.Enum): + COLOR_UNSPECIFIED = 0 + RED = 1 + GREEN = 2 + BLUE = 3 + + foo = Foo(color=Color.RED) + assert foo.color == Color.RED + assert foo.color == 1 + assert foo.color + assert isinstance(foo.color, Color) + assert Foo.pb(foo).color == 1 + + +def test_outer_enum_init_int(): + class Foo(proto.Message): + color = proto.Field(proto.ENUM, number=1, enum="Color") + + class Color(proto.Enum): + COLOR_UNSPECIFIED = 0 + RED = 1 + GREEN = 2 + BLUE = 3 + + foo = Foo(color=1) + assert foo.color == Color.RED + assert foo.color == 1 + assert foo.color + assert isinstance(foo.color, Color) + assert Foo.pb(foo).color == 1 + + +def test_outer_enum_init_str(): + class Foo(proto.Message): + color = proto.Field(proto.ENUM, number=1, enum="Color") + + class Color(proto.Enum): + COLOR_UNSPECIFIED = 0 + RED = 1 + GREEN = 2 + BLUE = 3 + + foo = Foo(color="RED") + assert foo.color == Color.RED + assert foo.color == 1 + assert foo.color + assert isinstance(foo.color, Color) + assert Foo.pb(foo).color == 1 + + +def test_outer_enum_init_dict(): + class Foo(proto.Message): + color = proto.Field(proto.ENUM, number=1, enum="Color") + + class Color(proto.Enum): + COLOR_UNSPECIFIED = 0 + RED = 1 + GREEN = 2 + BLUE = 3 + + foo = Foo({"color": 1}) + assert foo.color == Color.RED + assert foo.color == 1 + assert foo.color + assert isinstance(foo.color, Color) + assert Foo.pb(foo).color == 1 + + +def test_outer_enum_init_dict_str(): + class Foo(proto.Message): + color = proto.Field(proto.ENUM, number=1, enum="Color") + + class Color(proto.Enum): + COLOR_UNSPECIFIED = 0 + RED = 1 + GREEN = 2 + BLUE = 3 + + foo = Foo({"color": "BLUE"}) + assert foo.color == Color.BLUE + assert foo.color == 3 + assert foo.color + assert isinstance(foo.color, Color) + assert Foo.pb(foo).color == 3 + + +def test_outer_enum_init_pb2(): + class Foo(proto.Message): + color = proto.Field(proto.ENUM, number=1, enum="Color") + + class Color(proto.Enum): + COLOR_UNSPECIFIED = 0 + RED = 1 + GREEN = 2 + BLUE = 3 + + foo = Foo(Foo.pb()(color=Color.RED)) + assert foo.color == Color.RED + assert foo.color == 1 + assert foo.color + assert isinstance(foo.color, Color) + assert Foo.pb(foo).color == 1 + + +def test_outer_enum_unset(): + class Foo(proto.Message): + color = proto.Field(proto.ENUM, number=1, enum="Color") + + class Color(proto.Enum): + COLOR_UNSPECIFIED = 0 + RED = 1 + GREEN = 2 + BLUE = 3 + + foo = Foo() + assert foo.color == Color.COLOR_UNSPECIFIED + assert foo.color == 0 + assert "color" not in foo + assert not foo.color + assert Foo.pb(foo).color == 0 + assert Foo.serialize(foo) == b"" + + +def test_outer_enum_write(): + class Foo(proto.Message): + color = proto.Field(proto.ENUM, number=1, enum="Color") + + class Color(proto.Enum): + COLOR_UNSPECIFIED = 0 + RED = 1 + GREEN = 2 + BLUE = 3 + + foo = Foo() + foo.color = Color.GREEN + assert foo.color == Color.GREEN + assert foo.color == 2 + assert Foo.pb(foo).color == 2 + assert foo.color + + +def test_outer_enum_write_int(): + class Foo(proto.Message): + color = proto.Field(proto.ENUM, number=1, enum="Color") + + class Color(proto.Enum): + COLOR_UNSPECIFIED = 0 + RED = 1 + GREEN = 2 + BLUE = 3 + + foo = Foo() + foo.color = 3 + assert foo.color == Color.BLUE + assert foo.color == 3 + assert isinstance(foo.color, Color) + assert Foo.pb(foo).color == 3 + assert foo.color + + +def test_outer_enum_write_str(): + class Foo(proto.Message): + color = proto.Field(proto.ENUM, number=1, enum="Color") + + class Color(proto.Enum): + COLOR_UNSPECIFIED = 0 + RED = 1 + GREEN = 2 + BLUE = 3 + + foo = Foo() + foo.color = "BLUE" + assert foo.color == Color.BLUE + assert foo.color == 3 + assert isinstance(foo.color, Color) + assert Foo.pb(foo).color == 3 + assert foo.color + + +def test_inner_enum_init(): + class Foo(proto.Message): + class Color(proto.Enum): + COLOR_UNSPECIFIED = 0 + RED = 1 + GREEN = 2 + BLUE = 3 + + color = proto.Field(Color, number=1) + + foo = Foo(color=Foo.Color.RED) + assert foo.color == Foo.Color.RED + assert foo.color == 1 + assert foo.color + assert Foo.pb(foo).color == 1 + + +def test_inner_enum_write(): + class Foo(proto.Message): + class Color(proto.Enum): + COLOR_UNSPECIFIED = 0 + RED = 1 + GREEN = 2 + BLUE = 3 + + color = proto.Field(Color, number=1) + + foo = Foo() + foo.color = Foo.Color.GREEN + assert foo.color == Foo.Color.GREEN + assert foo.color == 2 + assert isinstance(foo.color, Foo.Color) + assert Foo.pb(foo).color == 2 + assert foo.color + + +def test_enum_del(): + class Foo(proto.Message): + color = proto.Field(proto.ENUM, number=1, enum="Color") + + class Color(proto.Enum): + COLOR_UNSPECIFIED = 0 + RED = 1 + GREEN = 2 + BLUE = 3 + + foo = Foo(color=Color.BLUE) + del foo.color + assert foo.color == Color.COLOR_UNSPECIFIED + assert foo.color == 0 + assert isinstance(foo.color, Color) + assert "color" not in foo + assert not foo.color + assert Foo.pb(foo).color == 0 + + +def test_nested_enum_from_string(): + class Trawl(proto.Message): + # Note: this indirection with the nested field + # is necessary to trigger the exception for testing. + # Setting the field in an existing message accepts strings AND + # checks for valid variants. + # Similarly, constructing a message directly with a top level + # enum field kwarg passed as a string is also handled correctly, i.e. + # s = Squid(zone="ABYSSOPELAGIC") + # does NOT raise an exception. + squids = proto.RepeatedField("Squid", number=1) + + class Squid(proto.Message): + zone = proto.Field(proto.ENUM, number=1, enum="Zone") + + class Zone(proto.Enum): + EPIPELAGIC = 0 + MESOPELAGIC = 1 + BATHYPELAGIC = 2 + ABYSSOPELAGIC = 3 + + t = Trawl(squids=[{"zone": "MESOPELAGIC"}]) + assert t.squids[0] == Squid(zone=Zone.MESOPELAGIC) + + +def test_enum_field_by_string(): + class Squid(proto.Message): + zone = proto.Field(proto.ENUM, number=1, enum="Zone") + + class Zone(proto.Enum): + EPIPELAGIC = 0 + MESOPELAGIC = 1 + BATHYPELAGIC = 2 + ABYSSOPELAGIC = 3 + + s = Squid(zone=Zone.BATHYPELAGIC) + assert s.zone == Zone.BATHYPELAGIC + + +def test_enum_field_by_string_with_package(): + sys.modules[__name__].__protobuf__ = proto.module(package="mollusca.cephalopoda") + try: + + class Octopus(proto.Message): + zone = proto.Field(proto.ENUM, number=1, enum="mollusca.cephalopoda.Zone") + + class Zone(proto.Enum): + EPIPELAGIC = 0 + MESOPELAGIC = 1 + BATHYPELAGIC = 2 + ABYSSOPELAGIC = 3 + + finally: + del sys.modules[__name__].__protobuf__ + + o = Octopus(zone="MESOPELAGIC") + assert o.zone == Zone.MESOPELAGIC + + +def test_enums_in_different_files(): + import mollusc + import zone + + m = mollusc.Mollusc(zone="BATHYPELAGIC") + + assert m.zone == zone.Zone.BATHYPELAGIC + + +def test_enums_in_one_file(): + import clam + + c = clam.Clam(species=clam.Species.DURASA) + assert c.species == clam.Species.DURASA + + +def test_unwrapped_enum_fields(): + # The dayofweek_pb2 module apparently does some things that are deprecated + # in the protobuf API. + # There's nothing we can do about that, so ignore it. + import warnings + + warnings.filterwarnings("ignore", category=DeprecationWarning) + + from google.type import dayofweek_pb2 as dayofweek + + class Event(proto.Message): + weekday = proto.Field(proto.ENUM, number=1, enum=dayofweek.DayOfWeek) + + e = Event(weekday="WEDNESDAY") + e2 = Event.deserialize(Event.serialize(e)) + assert e == e2 + + class Task(proto.Message): + weekday = proto.Field(dayofweek.DayOfWeek, number=1) + + 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/packages/proto-plus/tests/test_fields_int.py b/packages/proto-plus/tests/test_fields_int.py new file mode 100644 index 000000000000..81e15e4e73ca --- /dev/null +++ b/packages/proto-plus/tests/test_fields_int.py @@ -0,0 +1,138 @@ +# 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 pytest + +import proto + + +def test_int_init(): + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + baz = proto.Field(proto.INT32, number=2) + + foo = Foo(bar=42) + assert foo.bar == 42 + assert foo.baz == 0 + assert not foo.baz + assert Foo.pb(foo).bar == 42 + assert Foo.pb(foo).baz == 0 + + +def test_int_rmw(): + class Foo(proto.Message): + spam = proto.Field(proto.INT32, number=1) + eggs = proto.Field(proto.INT32, number=2) + + foo = Foo(spam=42) + foo.eggs = 76 # trombones led the big parade... + assert foo.spam == 42 + assert foo.eggs == 76 + assert Foo.pb(foo).spam == 42 + assert Foo.pb(foo).eggs == 76 + foo.spam = 144 + assert foo.spam == 144 + assert foo.eggs == 76 + assert Foo.pb(foo).spam == 144 + assert Foo.pb(foo).eggs == 76 + + +def test_int_del(): + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + + foo = Foo(bar=42) + assert foo.bar == 42 + del foo.bar + assert foo.bar == 0 + assert not foo.bar + + +def test_int_size(): + class Foo(proto.Message): + small = proto.Field(proto.INT32, number=1) + big = proto.Field(proto.INT64, number=2) + + foo = Foo() + foo.big = 2**40 + assert foo.big == 2**40 + with pytest.raises(ValueError): + foo.small = 2**40 + with pytest.raises(ValueError): + Foo(small=2**40) + + +def test_int_unsigned(): + class Foo(proto.Message): + signed = proto.Field(proto.INT32, number=1) + unsigned = proto.Field(proto.UINT32, number=2) + + foo = Foo() + foo.signed = -10 + assert foo.signed == -10 + with pytest.raises(ValueError): + foo.unsigned = -10 + with pytest.raises(ValueError): + Foo(unsigned=-10) + + +def test_field_descriptor_idempotent(): + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + + 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/packages/proto-plus/tests/test_fields_map_composite.py b/packages/proto-plus/tests/test_fields_map_composite.py new file mode 100644 index 000000000000..3b2d8fd9acd6 --- /dev/null +++ b/packages/proto-plus/tests/test_fields_map_composite.py @@ -0,0 +1,117 @@ +# 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 pytest + +import proto + + +def test_composite_map(): + 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, + ) + + baz = Baz(foos={"i": Foo(bar=42), "j": Foo(bar=24)}) + assert len(baz.foos) == 2 + assert baz.foos["i"].bar == 42 + assert baz.foos["j"].bar == 24 + assert "k" not in baz.foos + + +def test_composite_map_dict(): + 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, + ) + + baz = Baz(foos={"i": {"bar": 42}, "j": {"bar": 24}}) + assert len(baz.foos) == 2 + assert baz.foos["i"].bar == 42 + assert baz.foos["j"].bar == 24 + assert "k" not in baz.foos + with pytest.raises(KeyError): + baz.foos["k"] + + +def test_composite_map_set(): + 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, + ) + + baz = Baz() + baz.foos["i"] = Foo(bar=42) + baz.foos["j"] = Foo(bar=24) + assert len(baz.foos) == 2 + assert baz.foos["i"].bar == 42 + assert baz.foos["j"].bar == 24 + assert "k" not in baz.foos + + +def test_composite_map_deep_set(): + 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, + ) + + baz = Baz() + baz.foos["i"] = Foo() + baz.foos["i"].bar = 42 + assert len(baz.foos) == 1 + assert baz.foos["i"].bar == 42 + + +def test_composite_map_del(): + 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, + ) + + baz = Baz() + baz.foos["i"] = Foo(bar=42) + assert len(baz.foos) == 1 + del baz.foos["i"] + assert len(baz.foos) == 0 + assert "i" not in baz.foos diff --git a/packages/proto-plus/tests/test_fields_map_scalar.py b/packages/proto-plus/tests/test_fields_map_scalar.py new file mode 100644 index 000000000000..25ce495629b4 --- /dev/null +++ b/packages/proto-plus/tests/test_fields_map_scalar.py @@ -0,0 +1,58 @@ +# 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 proto + + +def test_basic_map(): + class Foo(proto.Message): + tags = proto.MapField(proto.STRING, proto.STRING, number=1) + + foo = Foo(tags={"a": "foo", "b": "bar"}) + assert foo.tags["a"] == "foo" + assert foo.tags["b"] == "bar" + assert "c" not in foo.tags + + +def test_basic_map_with_underscore_field_name(): + class Foo(proto.Message): + tag_labels = proto.MapField(proto.STRING, proto.STRING, number=1) + + foo = Foo(tag_labels={"a": "foo", "b": "bar"}) + assert foo.tag_labels["a"] == "foo" + assert foo.tag_labels["b"] == "bar" + assert "c" not in foo.tag_labels + + +def test_basic_map_assignment(): + class Foo(proto.Message): + tags = proto.MapField(proto.STRING, proto.STRING, number=1) + + foo = Foo(tags={"a": "foo"}) + foo.tags["b"] = "bar" + assert len(foo.tags) == 2 + assert foo.tags["a"] == "foo" + assert foo.tags["b"] == "bar" + assert "c" not in foo.tags + + +def test_basic_map_deletion(): + class Foo(proto.Message): + tags = proto.MapField(proto.STRING, proto.STRING, number=1) + + foo = Foo(tags={"a": "foo", "b": "bar"}) + del foo.tags["b"] + assert len(foo.tags) == 1 + assert foo.tags["a"] == "foo" + assert "b" not in foo.tags diff --git a/packages/proto-plus/tests/test_fields_mitigate_collision.py b/packages/proto-plus/tests/test_fields_mitigate_collision.py new file mode 100644 index 000000000000..117af48ac241 --- /dev/null +++ b/packages/proto-plus/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/packages/proto-plus/tests/test_fields_oneof.py b/packages/proto-plus/tests/test_fields_oneof.py new file mode 100644 index 000000000000..63a82764e79e --- /dev/null +++ b/packages/proto-plus/tests/test_fields_oneof.py @@ -0,0 +1,49 @@ +# 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 proto + + +def test_oneof(): + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1, oneof="bacon") + baz = proto.Field(proto.STRING, number=2, oneof="bacon") + + foo = Foo(bar=42) + assert foo.bar == 42 + assert not foo.baz + foo.baz = "the answer" + assert not foo.bar + assert foo.baz == "the answer" + + +def test_multiple_oneofs(): + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1, oneof="spam") + baz = proto.Field(proto.STRING, number=2, oneof="spam") + bacon = proto.Field(proto.FLOAT, number=3, oneof="eggs") + ham = proto.Field(proto.STRING, number=4, oneof="eggs") + + foo = Foo() + foo.bar = 42 + foo.bacon = 42.0 + assert foo.bar == 42 + assert foo.bacon == 42.0 + assert not foo.baz + assert not foo.ham + foo.ham = "this one gets assigned" + assert not foo.bacon + assert foo.ham == "this one gets assigned" + assert foo.bar == 42 + assert not foo.baz diff --git a/packages/proto-plus/tests/test_fields_optional.py b/packages/proto-plus/tests/test_fields_optional.py new file mode 100644 index 000000000000..a15904c1a59d --- /dev/null +++ b/packages/proto-plus/tests/test_fields_optional.py @@ -0,0 +1,87 @@ +# Copyright 2020 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 + +import proto + + +def test_optional_init(): + class Squid(proto.Message): + mass_kg = proto.Field(proto.INT32, number=1, optional=True) + + squid_1 = Squid(mass_kg=20) + squid_2 = Squid() + + assert Squid.mass_kg in squid_1 + assert squid_1.mass_kg == 20 + assert not Squid.mass_kg in squid_2 + + squid_2.mass_kg = 30 + assert squid_2.mass_kg == 30 + assert Squid.mass_kg in squid_2 + + del squid_1.mass_kg + assert not Squid.mass_kg in squid_1 + + with pytest.raises(AttributeError): + Squid.shell + + +def test_optional_and_oneof(): + # This test is a defensive check that synthetic oneofs + # don't interfere with user defined oneofs. + + # Oneof defined before an optional + class Squid(proto.Message): + mass_kg = proto.Field(proto.INT32, number=1, oneof="mass") + mass_lbs = proto.Field(proto.INT32, number=2, oneof="mass") + + iridiphore_num = proto.Field(proto.INT32, number=3, optional=True) + + s = Squid(mass_kg=20) + assert s.mass_kg == 20 + assert not s.mass_lbs + assert not Squid.iridiphore_num in s + + s.iridiphore_num = 600 + assert s.mass_kg == 20 + assert not s.mass_lbs + assert Squid.iridiphore_num in s + + s = Squid(mass_lbs=40, iridiphore_num=600) + assert not s.mass_kg + assert s.mass_lbs == 40 + assert s.iridiphore_num == 600 + + # Oneof defined after an optional + class Clam(proto.Message): + flute_radius = proto.Field(proto.INT32, number=1, optional=True) + + mass_kg = proto.Field(proto.INT32, number=2, oneof="mass") + mass_lbs = proto.Field(proto.INT32, number=3, oneof="mass") + + c = Clam(mass_kg=20) + + assert c.mass_kg == 20 + assert not c.mass_lbs + assert not Clam.flute_radius in c + c.flute_radius = 30 + assert c.mass_kg == 20 + assert not c.mass_lbs + + c = Clam(mass_lbs=40, flute_radius=30) + assert c.mass_lbs == 40 + assert not c.mass_kg + assert c.flute_radius == 30 diff --git a/packages/proto-plus/tests/test_fields_repeated_composite.py b/packages/proto-plus/tests/test_fields_repeated_composite.py new file mode 100644 index 000000000000..7c9f73e4415e --- /dev/null +++ b/packages/proto-plus/tests/test_fields_repeated_composite.py @@ -0,0 +1,289 @@ +# 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. + +from datetime import datetime +from datetime import timezone +from enum import Enum + +import pytest + +from google.protobuf import timestamp_pb2 + +import proto +from proto.datetime_helpers import DatetimeWithNanoseconds + + +def test_repeated_composite_init(): + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + + class Baz(proto.Message): + foos = proto.RepeatedField(proto.MESSAGE, message=Foo, number=1) + + baz = Baz(foos=[Foo(bar=42)]) + assert len(baz.foos) == 1 + assert baz.foos == baz.foos + assert baz.foos[0].bar == 42 + assert isinstance(baz.foos[0], Foo) + + +def test_repeated_composite_equality(): + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + + class Baz(proto.Message): + foos = proto.RepeatedField(proto.MESSAGE, message=Foo, number=1) + + baz = Baz(foos=[Foo(bar=42)]) + assert baz.foos == baz.foos + assert baz.foos != None + + +def test_repeated_composite_init_struct(): + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + + class Baz(proto.Message): + foos = proto.RepeatedField(proto.MESSAGE, message=Foo, number=1) + + baz = Baz(foos=[{"bar": 42}]) + assert len(baz.foos) == 1 + assert baz.foos[0].bar == 42 + + +def test_repeated_composite_falsy_behavior(): + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + + class Baz(proto.Message): + foos = proto.RepeatedField(proto.MESSAGE, message=Foo, number=1) + + baz = Baz() + assert not baz.foos + assert len(baz.foos) == 0 + + +def test_repeated_composite_marshaled(): + class Foo(proto.Message): + timestamps = proto.RepeatedField( + proto.MESSAGE, + message=timestamp_pb2.Timestamp, + number=1, + ) + + foo = Foo( + timestamps=[DatetimeWithNanoseconds(2012, 4, 21, 15, tzinfo=timezone.utc)] + ) + foo.timestamps.append(timestamp_pb2.Timestamp(seconds=86400 * 365)) + foo.timestamps.append(DatetimeWithNanoseconds(2017, 10, 14, tzinfo=timezone.utc)) + assert all([isinstance(i, DatetimeWithNanoseconds) for i in foo.timestamps]) + assert all([isinstance(i, timestamp_pb2.Timestamp) for i in Foo.pb(foo).timestamps]) + assert foo.timestamps[0].year == 2012 + assert foo.timestamps[0].month == 4 + assert foo.timestamps[0].hour == 15 + assert foo.timestamps[1].year == 1971 + assert foo.timestamps[1].month == 1 + assert foo.timestamps[1].hour == 0 + assert foo.timestamps[2].year == 2017 + assert foo.timestamps[2].month == 10 + assert foo.timestamps[2].hour == 0 + + +def test_repeated_composite_enum(): + class Foo(proto.Message): + class Bar(proto.Enum): + BAZ = 0 + + bars = proto.RepeatedField(Bar, number=1) + + foo = Foo(bars=[Foo.Bar.BAZ]) + assert isinstance(foo.bars[0], Enum) + + +def test_repeated_composite_outer_write(): + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + + class Baz(proto.Message): + foos = proto.RepeatedField(proto.MESSAGE, message=Foo, number=1) + + baz = Baz() + baz.foos = [Foo(bar=96), Foo(bar=48)] + assert len(baz.foos) == 2 + assert baz.foos[0].bar == 96 + assert baz.foos[1].bar == 48 + + +def test_repeated_composite_append(): + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + + class Baz(proto.Message): + foos = proto.RepeatedField(proto.MESSAGE, message=Foo, number=1) + + baz = Baz() + baz.foos.append(Foo(bar=96)) + baz.foos.append({"bar": 72}) + assert len(baz.foos) == 2 + assert baz.foos[0].bar == 96 + assert baz.foos[1].bar == 72 + + +def test_repeated_composite_insert(): + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + + class Baz(proto.Message): + foos = proto.RepeatedField(proto.MESSAGE, message=Foo, number=1) + + baz = Baz() + baz.foos.insert(0, {"bar": 72}) + baz.foos.insert(0, Foo(bar=96)) + assert len(baz.foos) == 2 + assert baz.foos[0].bar == 96 + assert baz.foos[1].bar == 72 + + +def test_repeated_composite_iadd(): + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + + class Baz(proto.Message): + foos = proto.RepeatedField(proto.MESSAGE, message=Foo, number=1) + + baz = Baz() + baz.foos += [Foo(bar=96), Foo(bar=48)] + assert len(baz.foos) == 2 + assert baz.foos[0].bar == 96 + assert baz.foos[1].bar == 48 + + +def test_repeated_composite_set_index(): + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + + class Baz(proto.Message): + foos = proto.RepeatedField(proto.MESSAGE, message=Foo, number=1) + + baz = Baz(foos=[{"bar": 96}, {"bar": 48}]) + baz.foos[1] = Foo(bar=55) + assert baz.foos[0].bar == 96 + assert baz.foos[1].bar == 55 + assert len(baz.foos) == 2 + + +def test_repeated_composite_set_index_error(): + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + + class Baz(proto.Message): + foos = proto.RepeatedField(proto.MESSAGE, message=Foo, number=1) + + baz = Baz(foos=[]) + with pytest.raises(IndexError): + baz.foos[0] = Foo(bar=55) + + +def test_repeated_composite_set_slice_less(): + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + + class Baz(proto.Message): + foos = proto.RepeatedField(proto.MESSAGE, message=Foo, number=1) + + baz = Baz(foos=[{"bar": 96}, {"bar": 48}, {"bar": 24}]) + baz.foos[:2] = [{"bar": 12}] + assert baz.foos[0].bar == 12 + assert baz.foos[1].bar == 24 + assert len(baz.foos) == 2 + + +def test_repeated_composite_set_slice_more(): + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + + class Baz(proto.Message): + foos = proto.RepeatedField(proto.MESSAGE, message=Foo, number=1) + + baz = Baz(foos=[{"bar": 12}]) + baz.foos[:2] = [{"bar": 96}, {"bar": 48}, {"bar": 24}] + assert baz.foos[0].bar == 96 + assert baz.foos[1].bar == 48 + assert baz.foos[2].bar == 24 + assert len(baz.foos) == 3 + + +def test_repeated_composite_set_slice_not_iterable(): + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + + class Baz(proto.Message): + foos = proto.RepeatedField(proto.MESSAGE, message=Foo, number=1) + + baz = Baz(foos=[]) + with pytest.raises(TypeError): + baz.foos[:1] = None + + +def test_repeated_composite_set_extended_slice(): + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + + class Baz(proto.Message): + foos = proto.RepeatedField(proto.MESSAGE, message=Foo, number=1) + + baz = Baz(foos=[{"bar": 96}, {"bar": 48}]) + baz.foos[::-1] = [{"bar": 96}, {"bar": 48}] + assert baz.foos[0].bar == 48 + assert baz.foos[1].bar == 96 + assert len(baz.foos) == 2 + + +def test_repeated_composite_set_extended_slice_wrong_length(): + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + + class Baz(proto.Message): + foos = proto.RepeatedField(proto.MESSAGE, message=Foo, number=1) + + baz = Baz(foos=[{"bar": 96}]) + with pytest.raises(ValueError): + baz.foos[::-1] = [] + + +def test_repeated_composite_set_wrong_key_type(): + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + + class Baz(proto.Message): + foos = proto.RepeatedField(proto.MESSAGE, message=Foo, number=1) + + baz = Baz(foos=[]) + with pytest.raises(TypeError): + baz.foos[None] = Foo(bar=55) + + +def test_repeated_composite_set_wrong_value_type(): + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + + class NotFoo(proto.Message): + eggs = proto.Field(proto.INT32, number=1) + + class Baz(proto.Message): + foos = proto.RepeatedField(proto.MESSAGE, message=Foo, number=1) + + baz = Baz() + with pytest.raises(TypeError): + baz.foos.append(NotFoo(eggs=42)) diff --git a/packages/proto-plus/tests/test_fields_repeated_scalar.py b/packages/proto-plus/tests/test_fields_repeated_scalar.py new file mode 100644 index 000000000000..d63831322e11 --- /dev/null +++ b/packages/proto-plus/tests/test_fields_repeated_scalar.py @@ -0,0 +1,115 @@ +# 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 copy + +import pytest + +import proto + + +def test_repeated_scalar_init(): + class Foo(proto.Message): + bar = proto.RepeatedField(proto.INT32, number=1) + + foo = Foo(bar=[1, 1, 2, 3, 5, 8, 13]) + assert foo.bar == [1, 1, 2, 3, 5, 8, 13] + + +def test_repeated_scalar_append(): + class Foo(proto.Message): + bar = proto.RepeatedField(proto.INT32, number=1) + + foo = Foo(bar=[1, 1, 2, 3, 5, 8, 13]) + foo.bar.append(21) + foo.bar.append(34) + assert foo.bar == [1, 1, 2, 3, 5, 8, 13, 21, 34] + + +def test_repeated_scalar_iadd(): + class Foo(proto.Message): + bar = proto.RepeatedField(proto.INT32, number=1) + + foo = Foo(bar=[1, 1, 2, 3, 5, 8, 13]) + foo.bar += [21, 34] + assert foo.bar == [1, 1, 2, 3, 5, 8, 13, 21, 34] + + +def test_repeated_scalar_setitem(): + class Foo(proto.Message): + bar = proto.RepeatedField(proto.INT32, number=1) + + foo = Foo(bar=[1, 1, 2, 3, 5, 8, 13]) + foo.bar[4] = 99 + assert foo.bar == [1, 1, 2, 3, 99, 8, 13] + assert foo.bar[4] == 99 + + +def test_repeated_scalar_overwrite(): + class Foo(proto.Message): + bar = proto.RepeatedField(proto.INT32, number=1) + + foo = Foo(bar=[1, 1, 2, 3, 5, 8, 13]) + foo.bar = [1, 2, 4, 8, 16] + assert foo.bar == [1, 2, 4, 8, 16] + + +def test_repeated_scalar_eq_ne(): + class Foo(proto.Message): + bar = proto.RepeatedField(proto.INT32, number=1) + + foo = Foo(bar=[1, 1, 2, 3, 5, 8, 13]) + assert foo.bar == copy.copy(foo.bar) + assert foo.bar != [1, 2, 4, 8, 16] + assert foo.bar != None + + +def test_repeated_scalar_del(): + class Foo(proto.Message): + bar = proto.RepeatedField(proto.INT32, number=1) + + foo = Foo(bar=[1, 1, 2, 3, 5, 8, 13]) + del foo.bar + assert foo.bar == [] + assert not foo.bar + + +def test_repeated_scalar_delitem(): + class Foo(proto.Message): + bar = proto.RepeatedField(proto.INT32, number=1) + + foo = Foo(bar=[1, 1, 2, 3, 5, 8, 13]) + del foo.bar[5] + del foo.bar[3] + assert foo.bar == [1, 1, 2, 5, 13] + + +def test_repeated_scalar_sort(): + class Foo(proto.Message): + bar = proto.RepeatedField(proto.INT32, number=1) + + foo = Foo(bar=[8, 1, 2, 1, 21, 3, 13, 5]) + foo.bar.sort() + assert foo.bar == [1, 1, 2, 3, 5, 8, 13, 21] + + +def test_repeated_scalar_wrong_type(): + class Foo(proto.Message): + bar = proto.RepeatedField(proto.INT32, number=1) + + foo = Foo(bar=[1, 1, 2, 3, 5, 8, 13]) + with pytest.raises(TypeError): + foo.bar.append(21.0) + with pytest.raises(TypeError): + foo.bar.append("21") diff --git a/packages/proto-plus/tests/test_fields_string.py b/packages/proto-plus/tests/test_fields_string.py new file mode 100644 index 000000000000..6fd15c7311da --- /dev/null +++ b/packages/proto-plus/tests/test_fields_string.py @@ -0,0 +1,57 @@ +# 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 proto + + +def test_string_init(): + class Foo(proto.Message): + bar = proto.Field(proto.STRING, number=1) + baz = proto.Field(proto.STRING, number=2) + + foo = Foo(bar="spam") + assert foo.bar == "spam" + assert foo.baz == "" + assert not foo.baz + assert Foo.pb(foo).bar == "spam" + assert Foo.pb(foo).baz == "" + + +def test_string_rmw(): + class Foo(proto.Message): + spam = proto.Field(proto.STRING, number=1) + eggs = proto.Field(proto.STRING, number=2) + + foo = Foo(spam="bar") + foo.eggs = "baz" + assert foo.spam == "bar" + assert foo.eggs == "baz" + assert Foo.pb(foo).spam == "bar" + assert Foo.pb(foo).eggs == "baz" + foo.spam = "bacon" + assert foo.spam == "bacon" + assert foo.eggs == "baz" + assert Foo.pb(foo).spam == "bacon" + assert Foo.pb(foo).eggs == "baz" + + +def test_string_del(): + class Foo(proto.Message): + bar = proto.Field(proto.STRING, number=1) + + foo = Foo(bar="spam") + assert foo.bar == "spam" + del foo.bar + assert foo.bar == "" + assert not foo.bar diff --git a/packages/proto-plus/tests/test_file_info_salting.py b/packages/proto-plus/tests/test_file_info_salting.py new file mode 100644 index 000000000000..4fce9105270e --- /dev/null +++ b/packages/proto-plus/tests/test_file_info_salting.py @@ -0,0 +1,80 @@ +# 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 collections + +import proto +from google.protobuf import descriptor_pb2 + +from proto import _file_info, _package_info + + +def sample_file_info(name): + filename = name + ".proto" + + # Get the essential information about the proto package, and where + # this component belongs within the file. + package, marshal = _package_info.compile(name, {}) + + # Get or create the information about the file, including the + # descriptor to which the new message descriptor shall be added. + return _file_info._FileInfo.registry.setdefault( + filename, + _file_info._FileInfo( + descriptor=descriptor_pb2.FileDescriptorProto( + name=filename, + package=package, + syntax="proto3", + ), + enums=collections.OrderedDict(), + messages=collections.OrderedDict(), + name=filename, + nested={}, + nested_enum={}, + ), + ) + + +def test_fallback_salt_is_appended_to_filename(): + # given + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + + name = "my-fileinfo" + fallback_salt = "my-fallback_salt" + file_info = sample_file_info(name) + + # when + file_info.generate_file_pb(new_class=Foo, fallback_salt=fallback_salt) + + # then + assert file_info.descriptor.name == name + "_" + fallback_salt + ".proto" + + +def test_none_fallback_salt_is_appended_to_filename_as_empty(): + # given + + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + + name = "my-fileinfo" + none_fallback_salt = None + file_info = sample_file_info(name) + + # when + file_info.generate_file_pb(new_class=Foo, fallback_salt=none_fallback_salt) + + # then + assert file_info.descriptor.name == name + ".proto" diff --git a/packages/proto-plus/tests/test_file_info_salting_with_manifest.py b/packages/proto-plus/tests/test_file_info_salting_with_manifest.py new file mode 100644 index 000000000000..2d8f75eb48c7 --- /dev/null +++ b/packages/proto-plus/tests/test_file_info_salting_with_manifest.py @@ -0,0 +1,94 @@ +# 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 collections + +import proto +from google.protobuf import descriptor_pb2 + +from proto import _file_info, _package_info + +PACKAGE = "a.test.package.salting.with.manifest" +__protobuf__ = proto.module( + package=PACKAGE, + manifest={"This", "That"}, +) + + +class This(proto.Message): + this = proto.Field(proto.INT32, number=1) + + +class That(proto.Message): + that = proto.Field(proto.INT32, number=1) + + +class NotInManifest(proto.Message): + them = proto.Field(proto.INT32, number=1) + + +def sample_file_info(name): + filename = name + ".proto" + + # Get the essential information about the proto package, and where + # this component belongs within the file. + package, marshal = _package_info.compile(name, {}) + + # Get or create the information about the file, including the + # descriptor to which the new message descriptor shall be added. + return _file_info._FileInfo.registry.setdefault( + filename, + _file_info._FileInfo( + descriptor=descriptor_pb2.FileDescriptorProto( + name=filename, + package=package, + syntax="proto3", + ), + enums=collections.OrderedDict(), + messages=collections.OrderedDict(), + name=filename, + nested={}, + nested_enum={}, + ), + ) + + +def test_no_salt_is_appended_to_filename_with_manifest(): + # given + name = "my-filename" + fallback_salt = "my-fallback_salt" + file_info = sample_file_info(name) + + # when + file_info.generate_file_pb(new_class=This, fallback_salt=fallback_salt) + + # then + assert file_info.descriptor.name == name + ".proto" + + +def test_none_fallback_salt_is_appended_to_filename_as_empty(): + # given + + name = "my-fileinfo" + none_fallback_salt = None + file_info = sample_file_info(name) + + # when + file_info.generate_file_pb( + new_class=NotInManifest, fallback_salt=none_fallback_salt + ) + + # then + assert file_info.descriptor.name == name + ".proto" diff --git a/packages/proto-plus/tests/test_json.py b/packages/proto-plus/tests/test_json.py new file mode 100644 index 000000000000..ae3cf59eeb2e --- /dev/null +++ b/packages/proto-plus/tests/test_json.py @@ -0,0 +1,265 @@ +# Copyright (C) 2020 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 re + +import proto +from google.protobuf.json_format import MessageToJson, Parse, ParseError + + +def test_message_to_json(): + class Squid(proto.Message): + mass_kg = proto.Field(proto.INT32, number=1) + + s = Squid(mass_kg=100) + json = Squid.to_json(s) + json = json.replace(" ", "").replace("\n", "") + assert json == '{"massKg":100}' + + +def test_message_to_json_no_indent(): + class Squid(proto.Message): + mass_kg = proto.Field(proto.INT32, number=1) + name = proto.Field(proto.STRING, number=2) + + s = Squid(mass_kg=100, name="no_new_lines_squid") + json = Squid.to_json(s, indent=None) + assert json == '{"massKg": 100, "name": "no_new_lines_squid"}' + + +def test_message_from_json(): + class Squid(proto.Message): + mass_kg = proto.Field(proto.INT32, number=1) + + json = """{ + "massKg": 100 + } + """ + + s = Squid.from_json(json) + assert s == Squid(mass_kg=100) + + +def test_message_json_round_trip(): + class Squid(proto.Message): + mass_kg = proto.Field(proto.INT32, number=1) + + s = Squid(mass_kg=100) + json = Squid.to_json(s) + s2 = Squid.from_json(json) + + assert s == s2 + + +def test_json_stringy_enums(): + class Squid(proto.Message): + zone = proto.Field(proto.ENUM, number=1, enum="Zone") + + class Zone(proto.Enum): + EPIPELAGIC = 0 + MESOPELAGIC = 1 + BATHYPELAGIC = 2 + ABYSSOPELAGIC = 3 + + s1 = Squid(zone=Zone.MESOPELAGIC) + json = ( + Squid.to_json(s1, use_integers_for_enums=False) + .replace(" ", "") + .replace("\n", "") + ) + assert json == '{"zone":"MESOPELAGIC"}' + + s2 = Squid.from_json(json) + assert s2.zone == s1.zone + + +def test_json_default_enums(): + class Squid(proto.Message): + zone = proto.Field(proto.ENUM, number=1, enum="Zone") + + class Zone(proto.Enum): + EPIPELAGIC = 0 + MESOPELAGIC = 1 + BATHYPELAGIC = 2 + ABYSSOPELAGIC = 3 + + s = Squid() + assert s.zone == Zone.EPIPELAGIC + json1 = Squid.to_json(s).replace(" ", "").replace("\n", "") + assert json1 == '{"zone":0}' + + json2 = ( + Squid.to_json(s, use_integers_for_enums=False) + .replace(" ", "") + .replace("\n", "") + ) + assert json2 == '{"zone":"EPIPELAGIC"}' + + +def test_json_default_values(): + class Squid(proto.Message): + mass_kg = proto.Field(proto.INT32, number=1) + name = proto.Field(proto.STRING, number=2) + + s = Squid(name="Steve") + json1 = ( + Squid.to_json(s, including_default_value_fields=False) + .replace(" ", "") + .replace("\n", "") + ) + assert json1 == '{"name":"Steve"}' + + json1 = ( + Squid.to_json(s, always_print_fields_with_no_presence=False) + .replace(" ", "") + .replace("\n", "") + ) + assert json1 == '{"name":"Steve"}' + + json1 = ( + Squid.to_json( + s, + including_default_value_fields=False, + always_print_fields_with_no_presence=False, + ) + .replace(" ", "") + .replace("\n", "") + ) + assert json1 == '{"name":"Steve"}' + + with pytest.raises( + ValueError, + match="Arguments.*always_print_fields_with_no_presence.*including_default_value_fields.*must match", + ): + Squid.to_json( + s, + including_default_value_fields=True, + always_print_fields_with_no_presence=False, + ).replace(" ", "").replace("\n", "") + + with pytest.raises( + ValueError, + match="Arguments.*always_print_fields_with_no_presence.*including_default_value_fields.*must match", + ): + Squid.to_json( + s, + including_default_value_fields=False, + always_print_fields_with_no_presence=True, + ).replace(" ", "").replace("\n", "") + + json2 = ( + Squid.to_json( + s, + including_default_value_fields=True, + always_print_fields_with_no_presence=True, + ) + .replace(" ", "") + .replace("\n", "") + ) + assert json2 == '{"name":"Steve","massKg":0}' + + json2 = ( + Squid.to_json( + s, + including_default_value_fields=True, + ) + .replace(" ", "") + .replace("\n", "") + ) + assert json2 == '{"name":"Steve","massKg":0}' + + json2 = ( + Squid.to_json( + s, + always_print_fields_with_no_presence=True, + ) + .replace(" ", "") + .replace("\n", "") + ) + assert json2 == '{"name":"Steve","massKg":0}' + + json2 = Squid.to_json(s).replace(" ", "").replace("\n", "") + assert json2 == '{"name":"Steve","massKg":0}' + + s1 = Squid.from_json(json1) + s2 = Squid.from_json(json2) + assert s == s1 == s2 + + +def test_json_unknown_field(): + # Note that 'lengthCm' is unknown in the local definition. + # This could happen if the client is using an older proto definition + # than the server. + json_str = '{\n "massKg": 20,\n "lengthCm": 100\n}' + + class Octopus(proto.Message): + mass_kg = proto.Field(proto.INT32, number=1) + + o = Octopus.from_json(json_str, ignore_unknown_fields=True) + assert not hasattr(o, "length_cm") + assert not hasattr(o, "lengthCm") + + # 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 + + +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 + + +def test_json_sort_keys(): + class Squid(proto.Message): + name = proto.Field(proto.STRING, number=1) + mass_kg = proto.Field(proto.INT32, number=2) + + s = Squid(name="Steve", mass_kg=20) + j = Squid.to_json(s, sort_keys=True, indent=None) + + assert re.search(r"massKg.*name", j) + + +# TODO: https://github.com/googleapis/proto-plus-python/issues/390 +def test_json_float_precision(): + class Squid(proto.Message): + name = proto.Field(proto.STRING, number=1) + mass_kg = proto.Field(proto.FLOAT, number=2) + + s = Squid(name="Steve", mass_kg=3.14159265) + j = Squid.to_json(s, float_precision=3, indent=None) + + assert j == '{"name": "Steve", "massKg": 3.14}' diff --git a/packages/proto-plus/tests/test_marshal_field_mask.py b/packages/proto-plus/tests/test_marshal_field_mask.py new file mode 100644 index 000000000000..ffb36a2ff7a6 --- /dev/null +++ b/packages/proto-plus/tests/test_marshal_field_mask.py @@ -0,0 +1,100 @@ +# 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. + +from google.protobuf import field_mask_pb2 + +import proto +from proto.marshal.marshal import BaseMarshal + + +def test_field_mask_read(): + class Foo(proto.Message): + mask = proto.Field( + proto.MESSAGE, + number=1, + message=field_mask_pb2.FieldMask, + ) + + foo = Foo(mask=field_mask_pb2.FieldMask(paths=["f.b.d", "f.c"])) + + assert isinstance(foo.mask, field_mask_pb2.FieldMask) + assert foo.mask.paths == ["f.b.d", "f.c"] + + +def test_field_mask_write_string(): + class Foo(proto.Message): + mask = proto.Field( + proto.MESSAGE, + number=1, + message=field_mask_pb2.FieldMask, + ) + + foo = Foo() + foo.mask = "f.b.d,f.c" + + assert isinstance(foo.mask, field_mask_pb2.FieldMask) + assert foo.mask.paths == ["f.b.d", "f.c"] + + +def test_field_mask_write_pb2(): + class Foo(proto.Message): + mask = proto.Field( + proto.MESSAGE, + number=1, + message=field_mask_pb2.FieldMask, + ) + + foo = Foo() + foo.mask = field_mask_pb2.FieldMask(paths=["f.b.d", "f.c"]) + + assert isinstance(foo.mask, field_mask_pb2.FieldMask) + assert foo.mask.paths == ["f.b.d", "f.c"] + + +def test_field_mask_absence(): + class Foo(proto.Message): + mask = proto.Field( + proto.MESSAGE, + number=1, + message=field_mask_pb2.FieldMask, + ) + + foo = Foo() + assert not foo.mask.paths + + +def test_timestamp_del(): + class Foo(proto.Message): + mask = proto.Field( + proto.MESSAGE, + number=1, + message=field_mask_pb2.FieldMask, + ) + + foo = Foo() + foo.mask = field_mask_pb2.FieldMask(paths=["f.b.d", "f.c"]) + + del foo.mask + assert not foo.mask.paths + + +def test_timestamp_to_python_idempotent(): + # This path can never run in the current configuration because proto + # values are the only thing ever saved, and `to_python` is a read method. + # + # However, we test idempotency for consistency with `to_proto` and + # general resiliency. + marshal = BaseMarshal() + py_value = "f.b.d,f.c" + assert marshal.to_python(field_mask_pb2.FieldMask, py_value) is py_value diff --git a/packages/proto-plus/tests/test_marshal_register.py b/packages/proto-plus/tests/test_marshal_register.py new file mode 100644 index 000000000000..3ca1a2a88d73 --- /dev/null +++ b/packages/proto-plus/tests/test_marshal_register.py @@ -0,0 +1,48 @@ +# 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 pytest + +from google.protobuf import empty_pb2 + +from proto.marshal.marshal import BaseMarshal + + +def test_registration(): + marshal = BaseMarshal() + + @marshal.register(empty_pb2.Empty) + class Rule: + def to_proto(self, value): + return value + + def to_python(self, value, *, absent=None): + return value + + assert isinstance(marshal._rules[empty_pb2.Empty], Rule) + + +def test_invalid_marshal_class(): + marshal = BaseMarshal() + with pytest.raises(TypeError): + + @marshal.register(empty_pb2.Empty) + class Marshal: + pass + + +def test_invalid_marshal_rule(): + marshal = BaseMarshal() + with pytest.raises(TypeError): + marshal.register(empty_pb2.Empty, rule=object()) diff --git a/packages/proto-plus/tests/test_marshal_strict.py b/packages/proto-plus/tests/test_marshal_strict.py new file mode 100644 index 000000000000..75e2f05de90b --- /dev/null +++ b/packages/proto-plus/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/packages/proto-plus/tests/test_marshal_stringy_numbers.py b/packages/proto-plus/tests/test_marshal_stringy_numbers.py new file mode 100644 index 000000000000..f3f40831dae6 --- /dev/null +++ b/packages/proto-plus/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/packages/proto-plus/tests/test_marshal_types_dates.py b/packages/proto-plus/tests/test_marshal_types_dates.py new file mode 100644 index 000000000000..44c65ebc778d --- /dev/null +++ b/packages/proto-plus/tests/test_marshal_types_dates.py @@ -0,0 +1,327 @@ +# 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. + +from datetime import datetime +from datetime import timedelta +from datetime import timezone + +from google.protobuf import duration_pb2 +from google.protobuf import timestamp_pb2 + +import proto +from proto.marshal.marshal import BaseMarshal +from proto import datetime_helpers +from proto.datetime_helpers import DatetimeWithNanoseconds + + +def test_timestamp_read(): + class Foo(proto.Message): + event_time = proto.Field( + proto.MESSAGE, + number=1, + message=timestamp_pb2.Timestamp, + ) + + foo = Foo(event_time=timestamp_pb2.Timestamp(seconds=1335020400)) + + assert isinstance(foo.event_time, DatetimeWithNanoseconds) + assert foo.event_time.year == 2012 + assert foo.event_time.month == 4 + assert foo.event_time.day == 21 + assert foo.event_time.hour == 15 + assert foo.event_time.minute == 0 + assert foo.event_time.tzinfo == timezone.utc + + +def test_timestamp_write_init(): + class Foo(proto.Message): + event_time = proto.Field( + proto.MESSAGE, + number=1, + message=timestamp_pb2.Timestamp, + ) + + foo = Foo(event_time=DatetimeWithNanoseconds(2012, 4, 21, 15, tzinfo=timezone.utc)) + assert isinstance(foo.event_time, DatetimeWithNanoseconds) + assert isinstance(Foo.pb(foo).event_time, timestamp_pb2.Timestamp) + assert foo.event_time.year == 2012 + assert foo.event_time.month == 4 + assert foo.event_time.hour == 15 + assert Foo.pb(foo).event_time.seconds == 1335020400 + + +def test_timestamp_write(): + class Foo(proto.Message): + event_time = proto.Field( + proto.MESSAGE, + number=1, + message=timestamp_pb2.Timestamp, + ) + + foo = Foo() + dns = DatetimeWithNanoseconds(2012, 4, 21, 15, tzinfo=timezone.utc) + foo.event_time = dns + assert isinstance(foo.event_time, DatetimeWithNanoseconds) + assert isinstance(Foo.pb(foo).event_time, timestamp_pb2.Timestamp) + assert foo.event_time.year == 2012 + assert foo.event_time.month == 4 + assert foo.event_time.hour == 15 + assert Foo.pb(foo).event_time.seconds == 1335020400 + + +def test_timestamp_write_pb2(): + class Foo(proto.Message): + event_time = proto.Field( + proto.MESSAGE, + number=1, + message=timestamp_pb2.Timestamp, + ) + + foo = Foo() + foo.event_time = timestamp_pb2.Timestamp(seconds=1335020400) + assert isinstance(foo.event_time, DatetimeWithNanoseconds) + assert isinstance(Foo.pb(foo).event_time, timestamp_pb2.Timestamp) + assert foo.event_time.year == 2012 + assert foo.event_time.month == 4 + assert foo.event_time.hour == 15 + assert Foo.pb(foo).event_time.seconds == 1335020400 + + +def test_timestamp_write_string(): + class Foo(proto.Message): + event_time = proto.Field( + proto.MESSAGE, + number=1, + message=timestamp_pb2.Timestamp, + ) + + foo = Foo() + foo.event_time = "2012-04-21T15:00:00Z" + assert isinstance(foo.event_time, DatetimeWithNanoseconds) + assert isinstance(Foo.pb(foo).event_time, timestamp_pb2.Timestamp) + assert foo.event_time.year == 2012 + assert foo.event_time.month == 4 + assert foo.event_time.hour == 15 + assert Foo.pb(foo).event_time.seconds == 1335020400 + + +def test_timestamp_rmw_nanos(): + class Foo(proto.Message): + event_time = proto.Field( + proto.MESSAGE, + number=1, + message=timestamp_pb2.Timestamp, + ) + + foo = Foo() + foo.event_time = DatetimeWithNanoseconds( + 2012, 4, 21, 15, 0, 0, 1, tzinfo=timezone.utc + ) + assert foo.event_time.microsecond == 1 + assert Foo.pb(foo).event_time.nanos == 1000 + foo.event_time = foo.event_time.replace(microsecond=2) + assert foo.event_time.microsecond == 2 + assert Foo.pb(foo).event_time.nanos == 2000 + + +def test_timestamp_absence(): + class Foo(proto.Message): + event_time = proto.Field( + proto.MESSAGE, + number=1, + message=timestamp_pb2.Timestamp, + ) + + foo = Foo() + assert foo.event_time is None + + +def test_timestamp_del(): + class Foo(proto.Message): + event_time = proto.Field( + proto.MESSAGE, + number=1, + message=timestamp_pb2.Timestamp, + ) + + foo = Foo(event_time=DatetimeWithNanoseconds(2012, 4, 21, 15, tzinfo=timezone.utc)) + del foo.event_time + assert foo.event_time is None + + +def test_duration_read(): + class Foo(proto.Message): + 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) + assert isinstance(Foo.pb(foo).ttl, duration_pb2.Duration) + assert foo.ttl.days == 0 + assert foo.ttl.seconds == 60 + assert foo.ttl.microseconds == 1 + + +def test_duration_write_init(): + class Foo(proto.Message): + ttl = proto.Field( + proto.MESSAGE, + number=1, + message=duration_pb2.Duration, + ) + + foo = Foo(ttl=timedelta(days=2)) + assert isinstance(foo.ttl, timedelta) + assert isinstance(Foo.pb(foo).ttl, duration_pb2.Duration) + assert foo.ttl.days == 2 + assert foo.ttl.seconds == 0 + assert foo.ttl.microseconds == 0 + assert Foo.pb(foo).ttl.seconds == 86400 * 2 + + +def test_duration_write(): + class Foo(proto.Message): + ttl = proto.Field( + proto.MESSAGE, + number=1, + message=duration_pb2.Duration, + ) + + foo = Foo() + foo.ttl = timedelta(seconds=120) + assert isinstance(foo.ttl, timedelta) + assert isinstance(Foo.pb(foo).ttl, duration_pb2.Duration) + assert foo.ttl.seconds == 120 + assert Foo.pb(foo).ttl.seconds == 120 + + +def test_duration_write_pb2(): + class Foo(proto.Message): + ttl = proto.Field( + proto.MESSAGE, + number=1, + message=duration_pb2.Duration, + ) + + foo = Foo() + foo.ttl = duration_pb2.Duration(seconds=120) + assert isinstance(foo.ttl, timedelta) + assert isinstance(Foo.pb(foo).ttl, duration_pb2.Duration) + assert foo.ttl.seconds == 120 + assert Foo.pb(foo).ttl.seconds == 120 + + +def test_duration_write_string(): + class Foo(proto.Message): + ttl = proto.Field( + proto.MESSAGE, + number=1, + message=duration_pb2.Duration, + ) + + foo = Foo() + foo.ttl = "120s" + assert isinstance(foo.ttl, timedelta) + assert isinstance(Foo.pb(foo).ttl, duration_pb2.Duration) + assert foo.ttl.seconds == 120 + assert Foo.pb(foo).ttl.seconds == 120 + + +def test_duration_write_string_nested(): + class Foo(proto.Message): + foo_field: duration_pb2.Duration = proto.Field( + proto.MESSAGE, + number=1, + message=duration_pb2.Duration, + ) + + Foo(foo_field="300s") + + class Bar(proto.Message): + bar_field = proto.Field(proto.MESSAGE, number=1, message=Foo) + + bar = Bar({"bar_field": {"foo_field": "300s"}}) + assert isinstance(bar.bar_field.foo_field, timedelta) + assert isinstance(Bar.pb(bar).bar_field.foo_field, duration_pb2.Duration) + assert bar.bar_field.foo_field.seconds == 300 + assert Bar.pb(bar).bar_field.foo_field.seconds == 300 + + +def test_duration_del(): + class Foo(proto.Message): + ttl = proto.Field( + proto.MESSAGE, + number=1, + message=duration_pb2.Duration, + ) + + foo = Foo(ttl=timedelta(seconds=900)) + del foo.ttl + assert isinstance(foo.ttl, timedelta) + assert foo.ttl.days == 0 + assert foo.ttl.seconds == 0 + assert foo.ttl.microseconds == 0 + + +def test_duration_nanos_rmw(): + class Foo(proto.Message): + ttl = proto.Field( + proto.MESSAGE, + number=1, + message=duration_pb2.Duration, + ) + + foo = Foo(ttl=timedelta(microseconds=50)) + assert foo.ttl.microseconds == 50 + assert Foo.pb(foo).ttl.nanos == 50000 + foo.ttl = timedelta(microseconds=25) + assert Foo.pb(foo).ttl.nanos == 25000 + assert foo.ttl.microseconds == 25 + + +def test_timestamp_to_python_idempotent(): + # This path can never run in the current configuration because proto + # values are the only thing ever saved, and `to_python` is a read method. + # + # However, we test idempotency for consistency with `to_proto` and + # general resiliency. + marshal = BaseMarshal() + py_value = DatetimeWithNanoseconds(2012, 4, 21, 15, tzinfo=timezone.utc) + assert marshal.to_python(timestamp_pb2.Timestamp, py_value) is py_value + + +def test_duration_to_python_idempotent(): + # This path can never run in the current configuration because proto + # values are the only thing ever saved, and `to_python` is a read method. + # + # However, we test idempotency for consistency with `to_proto` and + # general resiliency. + marshal = BaseMarshal() + py_value = timedelta(seconds=240) + assert marshal.to_python(duration_pb2.Duration, py_value) is py_value + + +def test_vanilla_datetime_construction(): + # 99% of users are going to want to pass in regular datetime objects. + # Make sure that this interoperates well with nanosecond precision. + class User(proto.Message): + birthday = proto.Field(timestamp_pb2.Timestamp, number=1) + + # Our user WAs born yesterday. + bday = datetime.now(tz=timezone.utc) + timedelta(days=-1) + u = User(birthday=bday) + assert u.birthday == bday diff --git a/packages/proto-plus/tests/test_marshal_types_enum.py b/packages/proto-plus/tests/test_marshal_types_enum.py new file mode 100644 index 000000000000..acb554618dfa --- /dev/null +++ b/packages/proto-plus/tests/test_marshal_types_enum.py @@ -0,0 +1,100 @@ +# Copyright 2019 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. + +from unittest import mock +import warnings + +import proto +from proto.marshal.rules.enums import EnumRule + +__protobuf__ = proto.module(package="test.marshal.enum") + + +def test_to_proto(): + class Foo(proto.Enum): + FOO_UNSPECIFIED = 0 + BAR = 1 + BAZ = 2 + + enum_rule = EnumRule(Foo) + foo_a = enum_rule.to_proto(Foo.BAR) + foo_b = enum_rule.to_proto(1) + foo_c = enum_rule.to_proto("BAR") + # We want to distinguish literal `1` from `Foo.BAR` here + # (they are equivalent but not identical). + assert foo_a is foo_b is foo_c is 1 # noqa: F632 + + +def test_to_python(): + class Foo(proto.Enum): + FOO_UNSPECIFIED = 0 + BAR = 1 + BAZ = 2 + + enum_rule = EnumRule(Foo) + foo_a = enum_rule.to_python(1) + foo_b = enum_rule.to_python(Foo.BAR) + assert foo_a is foo_b is Foo.BAR + + +def test_to_python_unknown_value(): + class Foo(proto.Enum): + FOO_UNSPECIFIED = 0 + BAR = 1 + BAZ = 2 + + enum_rule = EnumRule(Foo) + 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 dict(mc.bivalves) == {"clam": clam, "oyster": Bivalve.OYSTER} diff --git a/packages/proto-plus/tests/test_marshal_types_message.py b/packages/proto-plus/tests/test_marshal_types_message.py new file mode 100644 index 000000000000..b96ec32285d8 --- /dev/null +++ b/packages/proto-plus/tests/test_marshal_types_message.py @@ -0,0 +1,37 @@ +# 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 proto +from proto.marshal.rules.message import MessageRule + + +def test_to_proto(): + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + + message_rule = MessageRule(Foo.pb(), Foo) + foo_pb2_a = message_rule.to_proto(Foo(bar=42)) + foo_pb2_b = message_rule.to_proto(Foo.pb()(bar=42)) + foo_pb2_c = message_rule.to_proto({"bar": 42}) + assert foo_pb2_a == foo_pb2_b == foo_pb2_c + + +def test_to_python(): + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + + message_rule = MessageRule(Foo.pb(), Foo) + foo_a = message_rule.to_python(Foo(bar=42)) + foo_b = message_rule.to_python(Foo.pb()(bar=42)) + assert foo_a == foo_b diff --git a/packages/proto-plus/tests/test_marshal_types_struct.py b/packages/proto-plus/tests/test_marshal_types_struct.py new file mode 100644 index 000000000000..8ca2cde8a3c1 --- /dev/null +++ b/packages/proto-plus/tests/test_marshal_types_struct.py @@ -0,0 +1,268 @@ +# 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 pytest + +from google.protobuf import struct_pb2 + +import proto + + +def test_value_primitives_read(): + class Foo(proto.Message): + value = proto.Field(struct_pb2.Value, number=1) + + assert Foo(value=3).value == 3.0 + assert Foo(value="3").value == "3" + assert Foo(value=None).value is None + assert Foo(value=False).value is False + assert Foo(value=True).value is True + + +def test_value_absent(): + class Foo(proto.Message): + value = proto.Field(struct_pb2.Value, number=1) + + assert Foo().value is None + + +def test_value_primitives_rmw(): + class Foo(proto.Message): + value = proto.Field(struct_pb2.Value, number=1) + + foo = Foo() + foo.value = 3 + assert isinstance(foo.value, float) + assert abs(Foo.pb(foo).value.number_value - 3.0) < 1e-7 + foo.value = False + assert not foo.value + assert foo.value is False + assert Foo.pb(foo).value.WhichOneof("kind") == "bool_value" + foo.value = None + assert not foo.value + assert foo.value is None + assert Foo.pb(foo).value.WhichOneof("kind") == "null_value" + + +def test_value_write_pb(): + class Foo(proto.Message): + value = proto.Field(struct_pb2.Value, number=1) + + foo = Foo(value=struct_pb2.Value(string_value="stringy")) + assert foo.value == "stringy" + + +def test_value_lists_read(): + class Foo(proto.Message): + value = proto.Field(struct_pb2.Value, number=1) + + foo = Foo(value=["3", None, "foo", True]) + assert foo.value == ["3", None, "foo", True] + + +def test_value_lists_null(): + class Foo(proto.Message): + value = proto.Field(struct_pb2.ListValue, number=1) + + foo = Foo() + assert foo.value is None + + +def test_value_struct_null(): + class Foo(proto.Message): + value = proto.Field(struct_pb2.Struct, number=1) + + foo = Foo() + assert foo.value is None + + +def test_value_lists_rmw(): + class Foo(proto.Message): + value = proto.Field(struct_pb2.Value, number=1) + + foo = Foo(value=["3", None, "foo", True]) + foo.value.append("bar") + foo.value.pop(1) + assert foo.value == ["3", "foo", True, "bar"] + + +def test_value_lists_nested(): + class Foo(proto.Message): + value = proto.Field(struct_pb2.Value, number=1) + + foo = Foo(value=[[True, False], [True, False]]) + foo.value.append([False, True]) + assert foo.value == [[True, False], [True, False], [False, True]] + + +def test_value_lists_struct(): + class Foo(proto.Message): + value = proto.Field(struct_pb2.Value, number=1) + + foo = Foo(value=[{"foo": "bar", "spam": "eggs"}]) + assert foo.value == [{"foo": "bar", "spam": "eggs"}] + + +def test_value_lists_detachment(): + class Foo(proto.Message): + value = proto.Field(struct_pb2.Value, number=1) + + foo = Foo(value=["foo", "bar"]) + detached_list = foo.value + detached_list.append("baz") + assert foo.value == ["foo", "bar", "baz"] + + +def test_value_structs_read(): + class Foo(proto.Message): + value = proto.Field(struct_pb2.Value, number=1) + + foo = Foo(value={"foo": True, "bar": False}) + assert foo.value == {"foo": True, "bar": False} + + +def test_value_structs_rmw(): + class Foo(proto.Message): + value = proto.Field(struct_pb2.Value, number=1) + + foo = Foo(value={"foo": True, "bar": False}) + foo.value["baz"] = "a string" + assert foo.value == {"foo": True, "bar": False, "baz": "a string"} + + +def test_value_structs_nested(): + class Foo(proto.Message): + value = proto.Field(struct_pb2.Value, number=1) + + foo = Foo(value={"foo": True, "bar": {"spam": "eggs"}}) + assert foo.value == {"foo": True, "bar": {"spam": "eggs"}} + sv = Foo.pb(foo).value.struct_value + assert sv["foo"] is True + assert sv["bar"].fields["spam"].string_value == "eggs" + + +def test_value_invalid_value(): + class Foo(proto.Message): + value = proto.Field(struct_pb2.Value, number=1) + + with pytest.raises(ValueError): + Foo(value=object()) + + +def test_value_unset(): + class Foo(proto.Message): + value = proto.Field(struct_pb2.Value, number=1) + + foo = Foo() + assert "value" not in foo + + +def test_list_value_read(): + class Foo(proto.Message): + value = proto.Field(struct_pb2.ListValue, number=1) + + foo = Foo(value=["foo", "bar", True, {"spam": "eggs"}]) + assert foo.value == ["foo", "bar", True, {"spam": "eggs"}] + + +def test_list_value_pb(): + class Foo(proto.Message): + value = proto.Field(struct_pb2.ListValue, number=1) + + foo = Foo( + value=struct_pb2.ListValue( + values=[ + struct_pb2.Value(string_value="foo"), + struct_pb2.Value(string_value="bar"), + struct_pb2.Value(bool_value=True), + ] + ) + ) + assert foo.value == ["foo", "bar", True] + + +def test_list_value_reassignment(): + class Foo(proto.Message): + value = proto.Field(struct_pb2.ListValue, number=1) + + foo = Foo(value=["foo", "bar"]) + detached = foo.value + detached.append(True) + foo.value = detached + assert foo.value == ["foo", "bar", True] + + +def test_list_value_invalid(): + class Foo(proto.Message): + value = proto.Field(struct_pb2.ListValue, number=1) + + with pytest.raises(TypeError): + Foo(value=3) + + +def test_struct_read(): + class Foo(proto.Message): + value = proto.Field(struct_pb2.Struct, number=1) + + foo = Foo(value={"foo": "bar", "bacon": True}) + assert foo.value == {"foo": "bar", "bacon": True} + + +def test_struct_pb(): + class Foo(proto.Message): + value = proto.Field(struct_pb2.Struct, number=1) + + foo = Foo( + value=struct_pb2.Struct( + fields={ + "foo": struct_pb2.Value(string_value="bar"), + "bacon": struct_pb2.Value(bool_value=True), + } + ) + ) + assert foo.value == {"foo": "bar", "bacon": True} + + +def test_struct_reassignment(): + class Foo(proto.Message): + value = proto.Field(struct_pb2.Struct, number=1) + + foo = Foo(value={"foo": "bar"}) + detached = foo.value + detached["bacon"] = True + foo.value = detached + assert foo.value == {"foo": "bar", "bacon": True} + + +def test_struct_nested(): + class Foo(proto.Message): + struct_field: struct_pb2.Struct = proto.Field( + proto.MESSAGE, + number=1, + message=struct_pb2.Struct, + ) + + class Bar(proto.Message): + foo_field: Foo = proto.Field( + proto.MESSAGE, + number=1, + message=Foo, + ) + + foo = Foo({"struct_field": {"foo": "bagel"}}) + assert foo.struct_field == {"foo": "bagel"} + + bar = Bar({"foo_field": {"struct_field": {"foo": "cheese"}}}) + assert bar.foo_field == Foo({"struct_field": {"foo": "cheese"}}) + assert bar.foo_field.struct_field == {"foo": "cheese"} diff --git a/packages/proto-plus/tests/test_marshal_types_wrappers_bool.py b/packages/proto-plus/tests/test_marshal_types_wrappers_bool.py new file mode 100644 index 000000000000..e27caf990dcb --- /dev/null +++ b/packages/proto-plus/tests/test_marshal_types_wrappers_bool.py @@ -0,0 +1,122 @@ +# 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. + +from google.protobuf import wrappers_pb2 + +import proto +from proto.marshal.marshal import BaseMarshal + + +def test_bool_value_init(): + class Foo(proto.Message): + 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 + assert Foo().bar is None + + +def test_bool_value_init_dict(): + class Foo(proto.Message): + 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 + assert Foo({"bar": None}).bar is None + + +def test_bool_value_distinction_from_bool(): + class Foo(proto.Message): + bar = proto.Field( + proto.MESSAGE, + message=wrappers_pb2.BoolValue, + number=1, + ) + baz = proto.Field(proto.BOOL, number=2) + + assert Foo().bar is None + assert Foo().baz is False + + +def test_bool_value_rmw(): + class Foo(proto.Message): + bar = proto.Field(wrappers_pb2.BoolValue, number=1) + baz = proto.Field(wrappers_pb2.BoolValue, number=2) + + foo = Foo(bar=False) + assert foo.bar is False + assert foo.baz is None + foo.baz = True + assert foo.baz is True + assert Foo.pb(foo).baz.value is True + foo.bar = None + assert foo.bar is None + assert not Foo.pb(foo).HasField("bar") + + +def test_bool_value_write_bool_value(): + class Foo(proto.Message): + bar = proto.Field( + proto.MESSAGE, + message=wrappers_pb2.BoolValue, + number=1, + ) + + foo = Foo(bar=True) + foo.bar = wrappers_pb2.BoolValue() + assert foo.bar is False + + +def test_bool_value_del(): + class Foo(proto.Message): + bar = proto.Field( + proto.MESSAGE, + message=wrappers_pb2.BoolValue, + number=1, + ) + + foo = Foo(bar=False) + assert foo.bar is False + del foo.bar + assert foo.bar is None + + +def test_multiple_types(): + class Foo(proto.Message): + bar = proto.Field(wrappers_pb2.BoolValue, number=1) + baz = proto.Field(wrappers_pb2.Int32Value, number=2) + + foo = Foo(bar=True, baz=42) + assert foo.bar is True + assert foo.baz == 42 + + +def test_bool_value_to_python(): + # This path can never run in the current configuration because proto + # values are the only thing ever saved, and `to_python` is a read method. + # + # However, we test idempotency for consistency with `to_proto` and + # general resiliency. + marshal = BaseMarshal() + assert marshal.to_python(wrappers_pb2.BoolValue, True) is True + assert marshal.to_python(wrappers_pb2.BoolValue, False) is False + assert marshal.to_python(wrappers_pb2.BoolValue, None) is None diff --git a/packages/proto-plus/tests/test_message.py b/packages/proto-plus/tests/test_message.py new file mode 100644 index 000000000000..720995a90787 --- /dev/null +++ b/packages/proto-plus/tests/test_message.py @@ -0,0 +1,487 @@ +# 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 pytest + +import proto + + +def test_message_constructor_instance(): + class Foo(proto.Message): + bar = proto.Field(proto.INT64, number=1) + + foo_original = Foo(bar=42) + foo_copy = Foo(foo_original) + assert foo_original.bar == foo_copy.bar == 42 + assert foo_original == foo_copy + assert foo_original is not foo_copy + assert isinstance(foo_original, Foo) + assert isinstance(foo_copy, Foo) + assert isinstance(Foo.pb(foo_copy), Foo.pb()) + + +def test_message_constructor_underlying_pb2(): + class Foo(proto.Message): + bar = proto.Field(proto.INT64, number=1) + + foo_pb2 = Foo.pb()(bar=42) + foo = Foo(foo_pb2) + assert foo.bar == Foo.pb(foo).bar == foo_pb2.bar == 42 + assert foo == foo_pb2 # Not communitive. Nothing we can do about that. + assert foo_pb2 == Foo.pb(foo) + assert foo_pb2 is not Foo.pb(foo) + assert isinstance(foo, Foo) + assert isinstance(Foo.pb(foo), Foo.pb()) + assert isinstance(foo_pb2, Foo.pb()) + + +def test_message_constructor_underlying_pb2_and_kwargs(): + class Foo(proto.Message): + bar = proto.Field(proto.INT64, number=1) + + foo_pb2 = Foo.pb()(bar=42) + foo = Foo(foo_pb2, bar=99) + assert foo.bar == Foo.pb(foo).bar == 99 + assert foo_pb2.bar == 42 + assert isinstance(foo, Foo) + assert isinstance(Foo.pb(foo), Foo.pb()) + assert isinstance(foo_pb2, Foo.pb()) + + +def test_message_constructor_dict(): + class Foo(proto.Message): + bar = proto.Field(proto.INT64, number=1) + + foo = Foo({"bar": 42}) + assert foo.bar == Foo.pb(foo).bar == 42 + assert foo != {"bar": 42} + assert isinstance(foo, Foo) + assert isinstance(Foo.pb(foo), Foo.pb()) + + +def test_message_constructor_kwargs(): + class Foo(proto.Message): + bar = proto.Field(proto.INT64, number=1) + + foo = Foo(bar=42) + assert foo.bar == Foo.pb(foo).bar == 42 + assert isinstance(foo, Foo) + assert isinstance(Foo.pb(foo), Foo.pb()) + + +def test_message_constructor_invalid(): + class Foo(proto.Message): + bar = proto.Field(proto.INT64, number=1) + + with pytest.raises(TypeError): + Foo(object()) + + +def test_message_constructor_explicit_qualname(): + class Foo(proto.Message): + __qualname__ = "Foo" + bar = proto.Field(proto.INT64, number=1) + + foo_original = Foo(bar=42) + foo_copy = Foo(foo_original) + assert foo_original.bar == foo_copy.bar == 42 + assert foo_original == foo_copy + assert foo_original is not foo_copy + assert isinstance(foo_original, Foo) + assert isinstance(foo_copy, Foo) + assert isinstance(Foo.pb(foo_copy), Foo.pb()) + + +def test_message_contains_primitive(): + class Foo(proto.Message): + bar = proto.Field(proto.INT64, number=1) + + assert "bar" in Foo(bar=42) + assert "bar" not in Foo(bar=0) + assert "bar" not in Foo() + + +def test_message_contains_composite(): + class Foo(proto.Message): + bar = proto.Field(proto.INT64, number=1) + + class Baz(proto.Message): + foo = proto.Field(proto.MESSAGE, number=1, message=Foo) + + assert "foo" in Baz(foo=Foo(bar=42)) + assert "foo" in Baz(foo=Foo()) + assert "foo" not in Baz() + + +def test_message_contains_repeated_primitive(): + class Foo(proto.Message): + bar = proto.RepeatedField(proto.INT64, number=1) + + assert "bar" in Foo(bar=[1, 1, 2, 3, 5]) + assert "bar" in Foo(bar=[0]) + assert "bar" not in Foo(bar=[]) + assert "bar" not in Foo() + + +def test_message_contains_repeated_composite(): + class Foo(proto.Message): + bar = proto.Field(proto.INT64, number=1) + + class Baz(proto.Message): + foo = proto.RepeatedField(proto.MESSAGE, number=1, message=Foo) + + assert "foo" in Baz(foo=[Foo(bar=42)]) + assert "foo" in Baz(foo=[Foo()]) + assert "foo" not in Baz(foo=[]) + assert "foo" not in Baz() + + +def test_message_eq_primitives(): + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + baz = proto.Field(proto.STRING, number=2) + bacon = proto.Field(proto.BOOL, number=3) + + assert Foo() == Foo() + assert Foo(bar=42, baz="42") == Foo(bar=42, baz="42") + assert Foo(bar=42, baz="42") != Foo(baz="42") + assert Foo(bar=42, bacon=True) == Foo(bar=42, bacon=True) + assert Foo(bar=42, bacon=True) != Foo(bar=42) + assert Foo(bar=42, baz="42", bacon=True) != Foo(bar=42, bacon=True) + assert Foo(bacon=False) == Foo() + assert Foo(bacon=True) != Foo(bacon=False) + assert Foo(bar=21 * 2) == Foo(bar=42) + assert Foo() == Foo(bar=0) + assert Foo() == Foo(bar=0, baz="", bacon=False) + assert Foo() != Foo(bar=0, baz="0", bacon=False) + + +def test_message_serialize(): + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + baz = proto.Field(proto.STRING, number=2) + bacon = proto.Field(proto.BOOL, number=3) + + foo = Foo(bar=42, bacon=True) + assert Foo.serialize(foo) == Foo.pb(foo).SerializeToString() + + +def test_message_dict_serialize(): + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + baz = proto.Field(proto.STRING, number=2) + bacon = proto.Field(proto.BOOL, number=3) + + foo = {"bar": 42, "bacon": True} + assert Foo.serialize(foo) == Foo.pb(foo, coerce=True).SerializeToString() + + +def test_message_deserialize(): + class OldFoo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + + class NewFoo(proto.Message): + bar = proto.Field(proto.INT64, number=1) + + serialized = OldFoo.serialize(OldFoo(bar=42)) + new_foo = NewFoo.deserialize(serialized) + assert isinstance(new_foo, NewFoo) + assert new_foo.bar == 42 + + +def test_message_pb(): + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + + assert isinstance(Foo.pb(Foo()), Foo.pb()) + with pytest.raises(TypeError): + Foo.pb(object()) + + +def test_invalid_field_access(): + class Squid(proto.Message): + mass_kg = proto.Field(proto.INT32, number=1) + + s = Squid() + with pytest.raises(AttributeError): + getattr(s, "shell") + + +def test_setattr(): + class Squid(proto.Message): + mass_kg = proto.Field(proto.INT32, number=1) + + s1 = Squid() + s2 = Squid(mass_kg=20) + + s1._pb = s2._pb + + assert s1.mass_kg == 20 + + +def test_serialize_to_dict(): + 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) + + s = Squid(mass_kg=20) + colors = ["RED", "BROWN", "WHITE", "BLUE"] + s.chromatophores = [ + {"color": c} for c in itertools.islice(itertools.cycle(colors), 10) + ] + + s_dict = Squid.to_dict(s) + assert s_dict["chromatophores"][0]["color"] == 1 + + new_s = Squid(s_dict) + assert new_s == s + + 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 + + s_dict_2 = Squid.to_dict(s_new_2, always_print_fields_with_no_presence=False) + expected_dict = {"mass_kg": 20} + assert s_dict_2 == expected_dict + + s_dict_2 = Squid.to_dict( + s_new_2, + including_default_value_fields=False, + always_print_fields_with_no_presence=False, + ) + expected_dict = {"mass_kg": 20} + assert s_dict_2 == expected_dict + + s_dict_2 = Squid.to_dict( + s_new_2, + including_default_value_fields=True, + ) + expected_dict = {"mass_kg": 20, "chromatophores": []} + assert s_dict_2 == expected_dict + + s_dict_2 = Squid.to_dict( + s_new_2, + always_print_fields_with_no_presence=True, + ) + expected_dict = {"mass_kg": 20, "chromatophores": []} + assert s_dict_2 == expected_dict + + s_dict_2 = Squid.to_dict( + s_new_2, + including_default_value_fields=True, + always_print_fields_with_no_presence=True, + ) + expected_dict = {"mass_kg": 20, "chromatophores": []} + assert s_dict_2 == expected_dict + + s_dict_2 = Squid.to_dict(s_new_2) + expected_dict = {"mass_kg": 20, "chromatophores": []} + assert s_dict_2 == expected_dict + + with pytest.raises( + ValueError, + match="Arguments.*always_print_fields_with_no_presence.*including_default_value_fields.*must match", + ): + s_dict_2 = Squid.to_dict( + s_new_2, + including_default_value_fields=True, + always_print_fields_with_no_presence=False, + ) + + with pytest.raises( + ValueError, + match="Arguments.*always_print_fields_with_no_presence.*including_default_value_fields.*must match", + ): + s_dict_2 = Squid.to_dict( + s_new_2, + including_default_value_fields=False, + always_print_fields_with_no_presence=True, + ) + + +# TODO: https://github.com/googleapis/proto-plus-python/issues/390 +def test_serialize_to_dict_float_precision(): + class Squid(proto.Message): + mass_kg = proto.Field(proto.FLOAT, number=1) + + s = Squid(mass_kg=3.14159265) + + s_dict = Squid.to_dict(s, float_precision=3) + assert s_dict["mass_kg"] == 3.14 + + +def test_unknown_field_deserialize(): + # This is a somewhat common setup: a client uses an older proto definition, + # while the server sends the newer definition. The client still needs to be + # able to interact with the protos it receives from the server. + + class Octopus_Old(proto.Message): + mass_kg = proto.Field(proto.INT32, number=1) + + class Octopus_New(proto.Message): + mass_kg = proto.Field(proto.INT32, number=1) + length_cm = proto.Field(proto.INT32, number=2) + + o_new = Octopus_New(mass_kg=20, length_cm=100) + o_ser = Octopus_New.serialize(o_new) + + o_old = Octopus_Old.deserialize(o_ser) + assert not hasattr(o_old, "length_cm") + + +def test_unknown_field_deserialize_keep_fields(): + # This is a somewhat common setup: a client uses an older proto definition, + # while the server sends the newer definition. The client still needs to be + # able to interact with the protos it receives from the server. + + class Octopus_Old(proto.Message): + mass_kg = proto.Field(proto.INT32, number=1) + + class Octopus_New(proto.Message): + mass_kg = proto.Field(proto.INT32, number=1) + length_cm = proto.Field(proto.INT32, number=2) + + o_new = Octopus_New(mass_kg=20, length_cm=100) + o_ser = Octopus_New.serialize(o_new) + + o_old = Octopus_Old.deserialize(o_ser) + assert not hasattr(o_old, "length_cm") + + o_new = Octopus_New.deserialize(Octopus_Old.serialize(o_old)) + assert o_new.length_cm == 100 + + +def test_unknown_field_from_dict(): + class Squid(proto.Message): + mass_kg = proto.Field(proto.INT32, number=1) + + # By default we don't permit unknown fields + with pytest.raises(ValueError): + s = Squid({"mass_kg": 20, "length_cm": 100}) + + 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))) + + +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/packages/proto-plus/tests/test_message_filename.py b/packages/proto-plus/tests/test_message_filename.py new file mode 100644 index 000000000000..dcb2ebfa0bc9 --- /dev/null +++ b/packages/proto-plus/tests/test_message_filename.py @@ -0,0 +1,26 @@ +# 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 proto + + +def test_filename_includes_classname_salt(): + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + + assert ( + Foo.pb(Foo()).DESCRIPTOR.file.name + == "test_message_filename__default_package.foo.proto" + ) diff --git a/packages/proto-plus/tests/test_message_filename_with_and_without_manifest.py b/packages/proto-plus/tests/test_message_filename_with_and_without_manifest.py new file mode 100644 index 000000000000..e67e8472b830 --- /dev/null +++ b/packages/proto-plus/tests/test_message_filename_with_and_without_manifest.py @@ -0,0 +1,53 @@ +# 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 proto + + +PACKAGE = "a.test.package.with.and.without.manifest" +__protobuf__ = proto.module( + package=PACKAGE, + manifest={"This", "That"}, +) + + +class This(proto.Message): + this = proto.Field(proto.INT32, number=1) + + +class That(proto.Message): + that = proto.Field(proto.INT32, number=1) + + +class NotInManifest(proto.Message): + them = proto.Field(proto.INT32, number=1) + + +def test_manifest_causes_exclusion_of_classname_salt(): + + assert ( + This.pb(This()).DESCRIPTOR.file.name + == "test_message_filename_with_and_without_manifest.proto" + ) + assert ( + That.pb(That()).DESCRIPTOR.file.name + == "test_message_filename_with_and_without_manifest.proto" + ) + + assert ( + NotInManifest.pb(NotInManifest()).DESCRIPTOR.file.name + == "test_message_filename_with_and_without_manifest_" + + PACKAGE + + ".notinmanifest.proto" + ) diff --git a/packages/proto-plus/tests/test_message_filename_with_manifest.py b/packages/proto-plus/tests/test_message_filename_with_manifest.py new file mode 100644 index 000000000000..aa96028ef792 --- /dev/null +++ b/packages/proto-plus/tests/test_message_filename_with_manifest.py @@ -0,0 +1,40 @@ +# 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 proto + +PACKAGE = "a.test.package.with.manifest" +__protobuf__ = proto.module( + package=PACKAGE, + manifest={"ThisFoo", "ThisBar"}, +) + + +class ThisFoo(proto.Message): + foo = proto.Field(proto.INT32, number=1) + + +class ThisBar(proto.Message): + bar = proto.Field(proto.INT32, number=2) + + +def test_manifest_causes_exclusion_of_classname_salt(): + assert ( + ThisFoo.pb(ThisFoo()).DESCRIPTOR.file.name + == "test_message_filename_with_manifest.proto" + ) + assert ( + ThisBar.pb(ThisBar()).DESCRIPTOR.file.name + == "test_message_filename_with_manifest.proto" + ) diff --git a/packages/proto-plus/tests/test_message_nested.py b/packages/proto-plus/tests/test_message_nested.py new file mode 100644 index 000000000000..41af9507f22e --- /dev/null +++ b/packages/proto-plus/tests/test_message_nested.py @@ -0,0 +1,64 @@ +# 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 proto + + +def test_singly_nested_message(): + class Foo(proto.Message): + class Bar(proto.Message): + value = proto.Field(proto.INT32, number=1) + + bar = proto.Field(proto.MESSAGE, number=1, message=Bar) + + foo = Foo(bar=Foo.Bar(value=42)) + assert foo.bar.value == 42 + + +def test_multiply_nested_message(): + class Foo(proto.Message): + class Bar(proto.Message): + class Baz(proto.Message): + value = proto.Field(proto.INT32, number=1) + + baz = proto.Field(proto.MESSAGE, number=1, message=Baz) + + bar = proto.Field(proto.MESSAGE, number=1, message=Bar) + + foo = Foo(bar=Foo.Bar(baz=Foo.Bar.Baz(value=42))) + assert foo.bar.baz.value == 42 + + +def test_forking_nested_messages(): + class Foo(proto.Message): + class Bar(proto.Message): + spam = proto.Field(proto.STRING, number=1) + eggs = proto.Field(proto.BOOL, number=2) + + class Baz(proto.Message): + class Bacon(proto.Message): + value = proto.Field(proto.INT32, number=1) + + bacon = proto.Field(proto.MESSAGE, number=1, message=Bacon) + + bar = proto.Field(proto.MESSAGE, number=1, message=Bar) + 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)), + ) + assert foo.bar.spam == "xyz" + assert not foo.bar.eggs + assert foo.baz.bacon.value == 42 diff --git a/packages/proto-plus/tests/test_message_pickling.py b/packages/proto-plus/tests/test_message_pickling.py new file mode 100644 index 000000000000..dd97403c1a79 --- /dev/null +++ b/packages/proto-plus/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/packages/proto-plus/tests/test_modules.py b/packages/proto-plus/tests/test_modules.py new file mode 100644 index 000000000000..7ab0e88741af --- /dev/null +++ b/packages/proto-plus/tests/test_modules.py @@ -0,0 +1,135 @@ +# 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. + +from unittest import mock +import inspect +import sys + +from google.protobuf import wrappers_pb2 + +import proto + + +def test_module_package(): + sys.modules[__name__].__protobuf__ = proto.module(package="spam.eggs.v1") + try: + + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + + marshal = proto.Marshal(name="spam.eggs.v1") + + assert Foo.meta.package == "spam.eggs.v1" + assert Foo.pb() in marshal._rules + finally: + del sys.modules[__name__].__protobuf__ + + +def test_module_package_cross_api(): + sys.modules[__name__].__protobuf__ = proto.module(package="spam.eggs.v1") + try: + + class Baz(proto.Message): + foo = proto.RepeatedField(proto.INT64, number=1) + + marshal = proto.Marshal(name="spam.eggs.v1") + + assert Baz.meta.package == "spam.eggs.v1" + assert Baz.pb() in marshal._rules + + sys.modules[__name__].__protobuf__ = proto.module(package="ham.pancakes.v1") + + class AnotherMessage(proto.Message): + qux = proto.Field(proto.MESSAGE, number=1, message=Baz) + + marshal = proto.Marshal(name="ham.pancakes.v1") + + assert AnotherMessage.meta.package == "ham.pancakes.v1" + assert AnotherMessage.pb() in marshal._rules + # Confirm that Baz.pb() is no longer present in marshal._rules + assert Baz.pb() not in marshal._rules + + # Test using multiple packages together + # See https://github.com/googleapis/proto-plus-python/issues/349. + msg = AnotherMessage(qux=Baz()) + assert type(msg) == AnotherMessage + finally: + del sys.modules[__name__].__protobuf__ + + +def test_module_package_explicit_marshal(): + sys.modules[__name__].__protobuf__ = proto.module( + package="spam.eggs.v1", + marshal="foo", + ) + try: + + class Foo(proto.Message): + bar = proto.Field(proto.INT32, number=1) + + marshal = proto.Marshal(name="foo") + + assert Foo.meta.package == "spam.eggs.v1" + assert Foo.pb() in marshal._rules + finally: + del sys.modules[__name__].__protobuf__ + + +def test_module_manifest(): + __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 + # getattr, this is reasonably easy to shim over. + frame = inspect.currentframe() + with mock.patch.object(inspect, "getmodule") as getmodule: + getmodule.side_effect = lambda *a: View(frame.f_locals) + + class Foo(proto.Message): + a = proto.Field(wrappers_pb2.Int32Value, number=1) + + class Bar(proto.Message): + b = proto.Field(proto.MESSAGE, number=1, message=Foo) + + assert not Foo.pb() + assert not Bar.pb() + + class Baz(proto.Message): + c = proto.Field(wrappers_pb2.BoolValue, number=1) + + assert Foo.pb() + assert Bar.pb() + assert Baz.pb() + + foo = Foo(a=12) + bar = Bar(b=Foo(a=24)) + baz = Baz(c=False) + assert foo.a == 12 + assert bar.b.a == 24 + assert baz.c is False + + +class View: + """A view around a mapping, for attribute-like access.""" + + def __init__(self, mapping): + self._mapping = mapping + + def __getattr__(self, name): + if name not in self._mapping: + raise AttributeError + return self._mapping[name] diff --git a/packages/proto-plus/tests/zone.py b/packages/proto-plus/tests/zone.py new file mode 100644 index 000000000000..90bea6a878c0 --- /dev/null +++ b/packages/proto-plus/tests/zone.py @@ -0,0 +1,31 @@ +# Copyright (C) 2020 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="ocean.zone.v1", + manifest={ + "Zone", + }, +) + + +class Zone(proto.Enum): + EPIPELAGIC = 0 + MESOPELAGIC = 1 + BATHYPELAGIC = 2 + ABYSSOPELAGIC = 3