├── pyproject.toml ├── src └── pyproto │ ├── __init__.py │ └── converter.py ├── CONTRIBUTING.md ├── example ├── example_proto.proto └── converter_example.py ├── setup.py ├── LICENSE └── README.md /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=42", 4 | "protobuf>=3.6.0", 5 | "wheel" 6 | ] 7 | build-backend = "setuptools.build_meta" 8 | -------------------------------------------------------------------------------- /src/pyproto/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 The SeqIO Authors. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement (CLA). You (or your employer) retain the copyright to your 10 | contribution; this simply gives us permission to use and redistribute your 11 | contributions as part of the project. Head over to 12 | to see your current agreements on file or 13 | to sign a new one. 14 | 15 | You generally only need to submit a CLA once, so if you've already submitted one 16 | (even if it was for a different project), you probably don't need to do it 17 | again. 18 | 19 | ## Code Reviews 20 | 21 | All submissions, including submissions by project members, require review. We 22 | use GitHub pull requests for this purpose. Consult 23 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 24 | information on using pull requests. 25 | 26 | ## Community Guidelines 27 | 28 | This project follows 29 | [Google's Open Source Community Guidelines](https://opensource.google/conduct/). 30 | -------------------------------------------------------------------------------- /example/example_proto.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Google LLC 2 | 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | syntax = "proto3"; 16 | 17 | package pyproto.test; 18 | 19 | import "google/protobuf/any.proto"; 20 | 21 | message Topping { 22 | string name = 1; 23 | } 24 | 25 | message MatchaMilkTea { 26 | string name = 1; 27 | float price = 2; 28 | string seller = 3; 29 | google.protobuf.Any topping1 = 4; 30 | google.protobuf.Any topping2 = 5; 31 | Topping topping3 = 6; 32 | } 33 | 34 | message GreenTeaMilkTea { 35 | string name = 1; 36 | int64 price = 2; 37 | string seller = 3; 38 | Topping topping1 = 4; 39 | google.protobuf.Any topping2 = 5; 40 | google.protobuf.Any topping3 = 6; 41 | } 42 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # 3 | # Copyright 2020 Google LLC 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import setuptools 18 | 19 | with open("README.md", "r") as fh: 20 | long_description = fh.read() 21 | 22 | setuptools.setup( 23 | name="python-proto-converter", 24 | version="1.0", 25 | author="Huayu Yang", 26 | author_email="huayumochi@google.com", 27 | description="Library to converter between protos", 28 | long_description=long_description, 29 | long_description_content_type="text/markdown", 30 | url="https://github.com/google/python-proto-converter'", 31 | package_dir={"": "src"}, 32 | packages=setuptools.find_packages(where="src"), 33 | python_requires=">=3.6", 34 | classifiers=[ 35 | "Programming Language :: Python :: 3", 36 | "License :: OSI Approved :: Apache Software License", 37 | "Operating System :: OS Independent", 38 | "Topic :: Software Development :: Libraries", 39 | "Topic :: Software Development :: Libraries :: Python Modules", 40 | ], 41 | install_requires=[ 42 | "protobuf>=3.6.0", 43 | ], 44 | ) 45 | -------------------------------------------------------------------------------- /example/converter_example.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import example_proto_pb2 16 | 17 | from pyproto import converter 18 | from google.protobuf import any_pb2 19 | 20 | 21 | class MatchaToGreenTeaConverter(converter.ProtoConverter): 22 | def __init__(self): 23 | super(MatchaToGreenTeaConverter, self).__init__( 24 | pb_class_from=example_proto_pb2.MatchaMilkTea, 25 | pb_class_to=example_proto_pb2.GreenTeaMilkTea) 26 | 27 | @converter.convert_field(field_names=["name", "price"]) 28 | def price_name_convert_function(self, src_proto, dest_proto): 29 | dest_proto.price = int(src_proto.price) 30 | 31 | @converter.convert_field(field_names=["topping1"]) 32 | def topping_convert_function(self, src_proto, dest_proto): 33 | src_proto.topping1.Unpack(dest_proto.topping1) 34 | 35 | def _pack_to_any_proto(proto): 36 | any_proto = any_pb2.Any() 37 | any_proto.Pack(proto) 38 | return any_proto 39 | 40 | def example(): 41 | src_milk_tea = example_proto_pb2.MatchaMilkTea( 42 | name="matcha_milk_tea", price=10, seller="sellerA", 43 | topping1=_pack_to_any_proto(example_proto_pb2.Topping(name="jelly")), 44 | topping2=_pack_to_any_proto(example_proto_pb2.Topping(name="taro")), 45 | topping3=example_proto_pb2.Topping(name="chips")) 46 | 47 | proto_converter = MatchaToGreenTeaConverter() 48 | 49 | result_proto = proto_converter.convert(src_milk_tea) 50 | 51 | print(result_proto) 52 | 53 | 54 | if __name__ == '__main__': 55 | example() -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /src/pyproto/converter.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """ProtoConverter. 16 | 17 | This module provides a class to convert between different protos of 18 | different types. All fields with the same name and type will be converted 19 | automatically. Handler functions can be provided for custom conversions. Fields 20 | can also be ignored. 21 | 22 | Typical usage example: 23 | 24 | converter = ProtoConverter( 25 | pb_class_from=proto1_pb2.Proto1, 26 | pb_class_to=proto2_pb2.Proto2, 27 | field_names_to_ignore=["field1", "field2"]) 28 | proto2 = converter.convert(proto1) 29 | """ 30 | 31 | import functools 32 | from typing import Any, Callable, List, Optional, Type 33 | 34 | from google.protobuf import any_pb2 35 | from google.protobuf import descriptor 36 | from google.protobuf import symbol_database 37 | 38 | # We would like to annotate FROM and TO as subclasses of message.Message but not 39 | # message.Message itself. There currently exists no way to express such a thing, 40 | # and using Message would lead to unwanted type errors, so Any is the best we 41 | # can do. 42 | FROM = Any 43 | TO = Any 44 | 45 | 46 | class ProtoConverter(object): 47 | """A converter to convert Protos in Python.""" 48 | 49 | def __init__(self, 50 | pb_class_from: Type[FROM], 51 | pb_class_to: Type[TO], 52 | field_names_to_ignore: Optional[List[str]] = None, 53 | raise_exception_on_unhandled_destination_fields: bool = False): 54 | """Constructor for the ProtoConverter. 55 | 56 | Args: 57 | pb_class_from: the init method for the proto to convert from. 58 | pb_class_to: the init method for the proto to convert to. 59 | field_names_to_ignore: the fields from the source proto that will be 60 | ignored by the converter. 61 | raise_exception_on_unhandled_destination_fields: raise exception if 62 | there are fields from the pb_class_to that are not mapped from the 63 | pb_class_from proto. By default the unmapped fields in the pb_class_to 64 | will be set to empty during conversion. 65 | 66 | Returns: 67 | ProtoConverter 68 | 69 | Raise: 70 | NotImplementedError: When creating the proto converter if there are 71 | fields not handled or ignored. 72 | 73 | """ 74 | 75 | if field_names_to_ignore is None: 76 | field_names_to_ignore = [] 77 | 78 | self._pb_class_from = pb_class_from 79 | self._pb_class_to = pb_class_to 80 | self._field_names_to_ignore = field_names_to_ignore 81 | self._raise_exception_on_unhandled_destination_fields = raise_exception_on_unhandled_destination_fields 82 | self._function_convert_field_names = [] # type: List[str] 83 | self._convert_functions = [] # type: List[Callable] 84 | 85 | self._assert_all_fields_are_handled() 86 | 87 | def _assert_all_fields_are_handled(self): 88 | """Asserts all unhandled fields has been handled by user functions.""" 89 | 90 | for entry in dir(self.__class__): 91 | function = getattr(self.__class__, entry) 92 | if not callable(function): 93 | continue 94 | 95 | if hasattr(function, "convert_field_names"): 96 | self._convert_functions.append(function) 97 | self._function_convert_field_names.extend(function.convert_field_names) 98 | 99 | src_proto_fields = self._pb_class_from.DESCRIPTOR.fields 100 | dest_proto_fields_by_name = self._pb_class_to.DESCRIPTOR.fields_by_name 101 | 102 | self._unconverted_fields = _get_unhandled_fields( 103 | src_proto_fields, dest_proto_fields_by_name, 104 | self._field_names_to_ignore) 105 | 106 | if self._pb_class_from.DESCRIPTOR.oneofs: 107 | _validate_oneof_field_multi_mapping(self._pb_class_from, 108 | self._pb_class_to, 109 | self._field_names_to_ignore) 110 | if self._pb_class_to.DESCRIPTOR.oneofs: 111 | _validate_oneof_field_multi_mapping(self._pb_class_to, 112 | self._pb_class_from, 113 | self._field_names_to_ignore) 114 | 115 | unconverted_fields = ( 116 | set(self._unconverted_fields) - set(self._function_convert_field_names)) 117 | 118 | if unconverted_fields: 119 | raise NotImplementedError( 120 | "Fields can't be automatically converted, must either be explicitly " 121 | "handled or explicitly ignored. Unhandled fields: {}.".format( 122 | unconverted_fields)) 123 | 124 | if self._raise_exception_on_unhandled_destination_fields: 125 | unhandled_dest_fields = set(dest_proto_fields_by_name) - set( 126 | self._pb_class_from.DESCRIPTOR.fields_by_name) - set( 127 | self._function_convert_field_names) 128 | if unhandled_dest_fields: 129 | raise NotImplementedError( 130 | "Destination proto contains fields that don't exist in " 131 | "the Source proto. Unhandled fields: {}".format( 132 | unhandled_dest_fields)) 133 | 134 | def convert(self, src_proto: FROM) -> TO: 135 | """Converts the src_proto(pb_class_from) to the converter's pb_class_to.""" 136 | 137 | src_type = src_proto.DESCRIPTOR.full_name 138 | expected_src_type = self._pb_class_from.DESCRIPTOR.full_name 139 | if src_type != expected_src_type: 140 | raise TypeError( 141 | f"Provided src_proto type [{src_type}] doesn't match the converter's " 142 | f"src_proto type [{expected_src_type}].") 143 | 144 | dest_proto = self._pb_class_to() 145 | 146 | self._auto_convert(src_proto, dest_proto) 147 | for user_func in self._convert_functions: 148 | user_func(self, src_proto, dest_proto) 149 | 150 | return dest_proto 151 | 152 | def _auto_convert(self, src_proto, dest_proto): 153 | """Auto-converts fields from src_proto to dest_proto.""" 154 | 155 | for src_field_descriptor, src_field in src_proto.ListFields(): 156 | if (src_field_descriptor.name in self._field_names_to_ignore or 157 | src_field_descriptor.name in self._unconverted_fields or 158 | src_field_descriptor.is_extension): 159 | continue 160 | 161 | dest_field_descriptor = dest_proto.DESCRIPTOR.fields_by_name[ 162 | src_field_descriptor.name] 163 | dest_field = getattr(dest_proto, src_field_descriptor.name) 164 | 165 | # Map Case 166 | if _is_map_field(src_field_descriptor): 167 | src_map_value_field_descriptor = ( 168 | src_field_descriptor.message_type.fields_by_name["value"]) 169 | dest_map_value_field_descriptor = ( 170 | dest_field_descriptor.message_type.fields_by_name["value"]) 171 | # Map -> Map 172 | if (_is_any_field(dest_map_value_field_descriptor) and 173 | not _is_any_field(src_map_value_field_descriptor)): 174 | for key, value in src_field.items(): 175 | dest_field[key].Pack(value) 176 | 177 | # Map -> Map and Map -> Map 178 | else: 179 | dest_field.MergeFrom(src_field) 180 | 181 | # Array Case 182 | elif src_field_descriptor.is_repeated: 183 | # Any[] -> Any[], MergeFrom doesn't work for Any[] 184 | # Any[] -> Proto[] shouldn't happen 185 | if _is_any_field(src_field_descriptor): 186 | factory = symbol_database.Default() 187 | for field in src_field: 188 | type_name = field.TypeName() 189 | proto_descriptor = factory.pool.FindMessageTypeByName(type_name) 190 | proto_class = factory.GetPrototype(proto_descriptor) 191 | proto_object = proto_class() 192 | field.Unpack(proto_object) 193 | dest_field.add().Pack(proto_object) 194 | # Proto [] -> Any[] 195 | elif _is_any_field(dest_field_descriptor): 196 | for field in src_field: 197 | any_proto = any_pb2.Any() 198 | any_proto.Pack(field) 199 | dest_field.append(any_proto) 200 | else: 201 | dest_field.MergeFrom(src_field) 202 | 203 | # Proto Case 204 | elif src_field_descriptor.type == descriptor.FieldDescriptor.TYPE_MESSAGE: 205 | if _is_any_field( 206 | dest_field_descriptor) and not _is_any_field(src_field_descriptor): 207 | dest_field.Pack(src_field) 208 | else: 209 | dest_field.CopyFrom(src_field) 210 | 211 | # Other Case 212 | else: 213 | setattr(dest_proto, src_field_descriptor.name, src_field) 214 | 215 | 216 | def _get_unhandled_fields(src_proto_fields, dest_proto_fields_by_name, 217 | field_names_to_ignore): 218 | """Gets a list of unconverted fields from src to dest.""" 219 | 220 | unhandled_field_names = [] 221 | for field in src_proto_fields: 222 | if field.name in field_names_to_ignore: 223 | continue 224 | 225 | if not _is_src_field_auto_convertible(field, dest_proto_fields_by_name): 226 | unhandled_field_names.append(field.name) 227 | 228 | return unhandled_field_names 229 | 230 | 231 | def _is_src_field_auto_convertible(src_field, 232 | dest_proto_fields_by_name) -> bool: 233 | """Checks if the src_field can be auto-converted. 234 | 235 | There must be a field in dest_proto with same name and type as the src_field 236 | to auto convert src_field. 237 | 238 | Args: 239 | src_field: the field to check if it's auto-convertible. 240 | dest_proto_fields_by_name: field name to field dict for dest_proto. 241 | 242 | Returns: 243 | bool: True if the src_field is auto-convertible. 244 | """ 245 | 246 | if src_field.name not in dest_proto_fields_by_name: 247 | return False 248 | 249 | dest_field = dest_proto_fields_by_name[src_field.name] 250 | 251 | # Check field type and repeated label matching. 252 | if dest_field.is_repeated != src_field.is_repeated or src_field.type != dest_field.type: 253 | return False 254 | 255 | if _is_map_field(src_field): 256 | # Check that map field key and value are auto-convertible. 257 | src_fields_by_name = src_field.message_type.fields_by_name 258 | dest_fields_by_name = dest_field.message_type.fields_by_name 259 | if (not _is_src_field_auto_convertible(src_fields_by_name["key"], 260 | dest_fields_by_name) or 261 | not _is_src_field_auto_convertible(src_fields_by_name["value"], 262 | dest_fields_by_name)): 263 | return False 264 | elif src_field.type == descriptor.FieldDescriptor.TYPE_MESSAGE: 265 | # Any -> Any will always be valid 266 | if _is_any_field(src_field) and _is_any_field(dest_field): 267 | return True 268 | 269 | # Disable Any -> Proto convert since we can't check 270 | # whether it's convertible until runtime. 271 | if _is_any_field(src_field): 272 | return False 273 | 274 | # Proto -> Any will always be convertible as long as the field_name matches 275 | if _is_any_field(dest_field): 276 | return True 277 | 278 | if src_field.message_type != dest_field.message_type: 279 | return False 280 | elif src_field.type == descriptor.FieldDescriptor.TYPE_ENUM: 281 | return src_field.enum_type.name == dest_field.enum_type.name 282 | 283 | return True 284 | 285 | 286 | def _validate_oneof_field_multi_mapping(src_pb, dest_pb, ignored_fields): 287 | """Validates if the oneof field on src_pb maps to multiple fields. 288 | 289 | Args: 290 | src_pb: the proto to check oneof from. 291 | dest_pb: the proto to check oneof against. 292 | ignored_fields: fields that skip the check. 293 | Exception: Raises NotImplementedError if any oneof field in src_pb maps to 294 | multiple fields from dest_pb. 295 | """ 296 | 297 | ignored_fields_set = set(ignored_fields) 298 | src_oneof_names_dict = src_pb.DESCRIPTOR.oneofs_by_name 299 | 300 | dest_oneof_dict = _get_fields_to_oneof_dict(dest_pb.DESCRIPTOR.oneofs_by_name) 301 | dest_field_names = set(dest_pb.DESCRIPTOR.fields_by_name.keys()) 302 | 303 | for src_oneof_name, src_oneof_field in src_oneof_names_dict.items(): 304 | mapped_field = set() 305 | for src_field in src_oneof_field.fields: 306 | src_field_name = src_field.name 307 | if src_field_name in ignored_fields_set: 308 | continue 309 | 310 | if src_field_name in dest_oneof_dict: 311 | mapped_field.add(dest_oneof_dict[src_field_name]) 312 | elif src_field_name in dest_field_names: 313 | mapped_field.add(src_field_name) 314 | 315 | if len(mapped_field) > 1: 316 | raise NotImplementedError( 317 | "Oneof field {} in proto {} maps to more than one field, all fields in the " 318 | "oneof must be explicitly handled or ignored.".format( 319 | src_oneof_name, src_pb.DESCRIPTOR.name)) 320 | 321 | 322 | def _get_fields_to_oneof_dict(oneof_by_name): 323 | result_dict = {} 324 | for name, oneof_field in oneof_by_name.items(): 325 | for field in oneof_field.fields: 326 | result_dict[field.name] = name 327 | 328 | return result_dict 329 | 330 | 331 | def convert_field(field_names: Optional[List[str]] = None): 332 | """Decorator that converts proto fields. 333 | 334 | Args: 335 | field_names: list of field names from src proto this function handles. 336 | 337 | Returns: 338 | convert_field_decorator 339 | 340 | Typical usage example: 341 | 342 | @converter.convert_field(field_names=["hello"]) 343 | def hello_convert_function(self, src_proto, dest_proto): 344 | ... 345 | """ 346 | 347 | if field_names is None: 348 | field_names = [] 349 | 350 | def convert_field_decorator(convert_method): 351 | convert_method.convert_field_names = field_names 352 | 353 | @functools.wraps(convert_method) 354 | def convert_field_wrapper(self, src_proto, dest_proto): 355 | convert_method(self, src_proto, dest_proto) 356 | 357 | return convert_field_wrapper 358 | 359 | return convert_field_decorator 360 | 361 | 362 | def _is_any_field(field_descriptor) -> bool: 363 | return (field_descriptor.message_type == 364 | any_pb2.DESCRIPTOR.message_types_by_name["Any"]) 365 | 366 | 367 | def _is_map_field(field_descriptor) -> bool: 368 | return (field_descriptor.is_repeated 369 | and 370 | field_descriptor.type == descriptor.FieldDescriptor.TYPE_MESSAGE and 371 | field_descriptor.message_type.has_options and 372 | field_descriptor.message_type.GetOptions().map_entry) 373 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Python Proto Converter 3 | The Python Proto Converter converts between protos in Python. Proto conversion is often needed when converting between Database Access Object (DAO) and API proto. 4 | 5 | ### Install 6 | pip install python-proto-converter 7 | 8 | ### Run the example 9 | 1. Build the proto (assuming in exmaple/ directory) 10 | protoc -I=. --python_out=. ./example_proto.proto 11 | 12 | 2. execute 13 | python3 ./converter_example.py 14 | 15 | ### Features 16 | 17 | * A base class that auto-converts fields with the same name and type. 18 | * Custom convert functions can be implemented to handle fields conversion. 19 | * Fields can be disabled during auto-converting. 20 | * Unhandled fields assertion during class instantiation. 21 | 22 | ### Example 23 | 24 | #### Basic usage 25 | 26 | Let's start with a simple example, suppose you want to convert from one similar 27 | proto to another. For this example, these are the MatchaMilkTea to 28 | GreenTeaMilkTea protos. 29 | 30 | ```proto 31 | message MatchaMilkTea { 32 | string name = 1; 33 | float price = 2; 34 | string seller = 3; 35 | } 36 | ``` 37 | 38 | ```proto 39 | message GreenTeaMilkTea { 40 | string name = 1; 41 | int64 price = 2; 42 | string seller = 3; 43 | } 44 | ``` 45 | 46 | The `name` and `seller` fields can be auto-converted, since the type and the 47 | field name are identical. However, we probably don't want to copy the name of 48 | MatchaMilkTea to GreenTeaMilkTea. To disable auto-convert on the `name` field, 49 | we mark it ignored and provide our custom function for the `name` field. 50 | 51 | The `price` field has different types (float vs int64), therefore it can't be 52 | auto-converted. Leaving it unhandled will trigger an exception when creating the 53 | proto converter. Similar to the `name` field, we can create a custom method to 54 | convert the `price` field. 55 | 56 | ```python 57 | from google3.alkali.contrib.certified.python.proto import converter 58 | 59 | ... 60 | 61 | class MatchaToGreenTeaConverter(converter.ProtoConverter): 62 | def __init__(self): 63 | super(MatchaToGreenTeaConverter, self).__init__( 64 | pb_class_from=matcha_milk_tea_pb2.MatchaMilkTea, 65 | pb_class_to=green_tea_milk_tea_pb2.GreenTeaMilkTea, 66 | field_names_to_ignore=["name"]) 67 | 68 | @converter.convert_field(field_names=["price"]) 69 | def price_convert_function(self, src_proto, dest_proto): 70 | dest_proto.price = int(src_proto.price) 71 | 72 | @converter.convert_field(field_names=["name"]) 73 | def name_convert_function(self, src_proto, dest_proto): 74 | dest_proto.name = "GreenTeaMilkTea" 75 | ``` 76 | 77 | Or you can combine them in the same method since these fields are simple: 78 | 79 | ```python 80 | @converter.convert_field(field_names=["price", "name"]) 81 | def price_name_convert_function(self, src_proto, dest_proto): 82 | dest_proto.price = int(src_proto.price) 83 | dest_proto.name = "GreenTeaMilkTea" 84 | ``` 85 | 86 | Now you can create the converter in code and use it: 87 | 88 | ```python 89 | ... 90 | matcha_to_green_tea_converter = MatchaToGreenTeaConverter() 91 | green_tea_milk_tea_proto = matcha_to_green_tea_converter.convert(matcha_milk_tea_proto) 92 | ... 93 | ``` 94 | 95 | #### Nested protos 96 | 97 | Let's make this example a bit more complicated by adding some fields. 98 | 99 | ```proto 100 | enum Flavor { 101 | GREEN_TEA = 0; 102 | MATCHA = 1; 103 | BERRY = 2; 104 | SPICY = 3; 105 | } 106 | 107 | message MilkTea { 108 | string name = 1; 109 | float price = 2; 110 | Flavor flavor = 3; 111 | } 112 | ``` 113 | 114 | ```proto 115 | message MatchaMilkTea { 116 | MilkTea milk_tea = 1; 117 | int64 sugar = 2; 118 | repeated string shops = 3; 119 | string matcha_provider = 4; 120 | map ingredients = 5; 121 | map ingredients_calorie_map = 6; 122 | repeated string cup_sizes = 7; 123 | } 124 | ``` 125 | 126 | ```proto 127 | message GreenTeaMilkTea { 128 | MilkTea milk_tea = 1; 129 | float sugar = 2; 130 | repeated string shops = 3; 131 | string green_tea_provider = 4; 132 | map ingredients = 5; 133 | map ingredients_calorie_map = 6; 134 | repeated int64 cup_sizes = 7; 135 | } 136 | ``` 137 | 138 | Most of the fields are identical and can be auto-converted, except: 139 | 140 | * float sugar and int64 sugar; 141 | * string green_tea_provider; 142 | * string matcha_provider; 143 | * ingredients_calorie_map; 144 | * cup_sizes; 145 | 146 | You can create a new MatchaToGreenTeaConverter class that inherits ProtoConverter 147 | to convert from MatchaMilkTea to GreenTeaMilkTea: 148 | 149 | ```python 150 | from google3.alkali.contrib.certified.python.proto import converter 151 | 152 | ... 153 | 154 | class MatchaToGreenTeaConverter(converter.ProtoConverter): 155 | def __init__(self): 156 | super(MatchaToGreenTeaConverter, self).__init__( 157 | pb_class_from=matcha_milk_tea_pb2.MatchaMilkTea, 158 | pb_class_to=green_tea_milk_tea_pb2.GreenTeaMilkTea, 159 | field_names_to_ignore=["ingredients_calorie_map", "cup_sizes"]) 160 | 161 | @converter.convert_field(field_names=["sugar"]) 162 | def sugar_convert_function(self, src_proto, dest_proto): 163 | dest_proto.sugar = int(src_proto.sugar) 164 | 165 | @converter.convert_field(field_names=["matcha_provider"]) 166 | def provider_convert_function(self, src_proto, dest_proto): 167 | dest_proto.green_tea_provider = src_proto.matcha_provider 168 | ``` 169 | 170 | * `pb_class_from` and `pb_class_to` are the constructors of the protos. 171 | * pb_class_from.Fields in `field_names_to_ignore` will be ignored during 172 | auto-conversion and when validating that all fields have been handled. In 173 | the example, `ingredients_calorie_map` and `cup_sizes` are ignored during 174 | conversion. 175 | * `@converter.convert_field` decorates a custom conversion function. In this 176 | example, we have two functions to convert the `sugar` field and the 177 | `matcha_provider` field. 178 | * All fields that can't be auto-converted from the source proto must either be 179 | handled by custom conversion functions or listed in `field_names_to_ignore`. 180 | 181 | #### Oneof fields 182 | 183 | Oneof fields can be tricky and error-prone, therefore it is required to 184 | explicitly handle or ignore all the fields in oneofs. 185 | 186 | ```proto 187 | message MochiFlavor { 188 | string flavor = 1; 189 | } 190 | 191 | message Mochi { 192 | oneof price { 193 | string price_str = 1; 194 | float price_float = 2; 195 | } 196 | oneof flavor { 197 | Flavor flavor_enum = 3; 198 | MochiFlavor flavor_proto = 4; 199 | } 200 | int64 calorie = 5; 201 | } 202 | ``` 203 | 204 | ```proto 205 | message TaroMochi { 206 | float price_float = 1; 207 | MochiFlavor flavor_proto = 2; 208 | int64 calorie = 3; 209 | } 210 | ``` 211 | 212 | ```python 213 | proto_converter = converter.ProtoConverter( 214 | pb_class_from=mochi_pb2.Mochi, 215 | pb_class_to=mochi_pb2.Taromochi, 216 | field_names_to_ignore=["flavor_enum", "price_str"]) 217 | src_proto = mochi_pb2.Mochi( 218 | price_float=3.14, 219 | flavor_proto=mochi_pb2.MochiFlavor(flavor="taro"), 220 | calorie=100) 221 | 222 | dest_proto = proto_converter.convert(src_proto=src_proto) 223 | ``` 224 | 225 | In the above example, even though `flavor_enum` and `price_str` fields are not 226 | used, ProtoConverter will still raise an exception if these fields are not 227 | ignored. 228 | 229 | #### Any fields 230 | 231 | `Proto` to `Any` and `Any` to `Any` are converted automatically as long as the 232 | field name matches. 233 | 234 | ```proto 235 | message AnyMochiBox { 236 | string name = 1; 237 | google.protobuf.Any mochi = 2; 238 | } 239 | 240 | message TaroMochiBox { 241 | string name = 1; 242 | TaroMochi mochi = 2; 243 | } 244 | ``` 245 | 246 | In the example below, ProtoConverter auto-converts a TaroMochi field to a Any 247 | field. 248 | 249 | ```python 250 | taro_mochi = mochi_pb2.TaroMochi(price_float=3.14, 251 | flavor_proto=mochi_pb2.MochiFlavor(flavor="taro"), calorie=100) 252 | proto_converter = converter.ProtoConverter( 253 | pb_class_from=mochi_pb2.TaroMochiBox, pb_class_to=mochi_pb2.AnyMochiBox) 254 | 255 | src_proto = mochi_pb2.TaroMochiBox(name="TaroMochiBox", mochi=taro_mochi) 256 | 257 | dest_proto = proto_converter.convert(src_proto=src_proto) 258 | ``` 259 | 260 | Similarily, ProtoConverter auto-converts Proto Any field to Any field. 261 | 262 | ```python 263 | taro_mochi = mochi_pb2.TaroMochi( 264 | price_float=3.14, 265 | flavor_proto=mochi_pb2.MochiFlavor(flavor="taro"), 266 | calorie=100) 267 | taromochi_any_proto = any_pb2.Any() 268 | taromochi_any_proto.Pack(taro_mochi) 269 | proto_converter = converter.ProtoConverter( 270 | pb_class_from=mochi_pb2.AnyMochiBox, pb_class_to=mochi_pb2.AnyMochiBox) 271 | src_proto = mochi_pb2.AnyMochiBox( 272 | name="TaroMochiBox", mochi=taromochi_any_proto) 273 | 274 | dest_proto = proto_converter.convert(src_proto=src_proto) 275 | ``` 276 | 277 | Repeated `Any` field and Map `Any` field are also supported. 278 | 279 | ```proto 280 | message AnyMochiBoxes { 281 | string name = 1; 282 | repeated google.protobuf.Any mochi = 2; 283 | } 284 | 285 | message TaroMochiBoxes { 286 | string name = 1; 287 | repeated TaroMochi mochi = 2; 288 | } 289 | 290 | message MochiGiftPackage { 291 | string name = 1; 292 | map mochi = 2; 293 | } 294 | 295 | message TaroMochiGiftPackage { 296 | string name = 1; 297 | map mochi = 2; 298 | } 299 | ``` 300 | 301 | The examples below demonstrate the auto-conversion for repeated fields and Map 302 | fields with Any proto. 303 | 304 | ```python 305 | proto_converter = converter.ProtoConverter( 306 | pb_class_from=mochi_pb2.TaroMochiBoxes, 307 | pb_class_to=mochi_pb2.AnyMochiBoxes) 308 | src_proto = mochi_pb2.TaroMochiBoxes(name="TaroMochiBoxes", 309 | mochi=[taro_mochi, taro_mochi]) 310 | dest_proto = proto_converter.convert(src_proto=src_proto) 311 | ``` 312 | 313 | ```python 314 | proto_converter = converter.ProtoConverter( 315 | pb_class_from=mochi_pb2.TaroMochiGiftPackage, 316 | pb_class_to=mochi_pb2.AnyMochiGiftPackage) 317 | src_proto = mochi_pb2.TaroMochiGiftPackage( 318 | name="TaroMochiBoxes", 319 | mochi={"taro_mochi": taro_mochi}) 320 | dest_proto = proto_converter.convert(src_proto=src_proto) 321 | ``` 322 | 323 | We decided not to support `Any` field to `Proto` field auto conversion to make 324 | it less error-pone, since the `Any` field can contain any type and cause runtime 325 | failures. However, it is very easy to add a custom method to handle `Any` field. 326 | 327 | ```python 328 | class MochiConverter(converter.ProtoConverter): 329 | 330 | @converter.convert_field(field_names=["mochi"]) 331 | def mochi_field_convert_function(self, src_proto, dest_proto): 332 | src_proto.mochi.Unpack(dest_proto.mochi) 333 | 334 | ... 335 | 336 | taro_mochi = mochi_pb2.TaroMochi( 337 | price_float=3.14, 338 | flavor_proto=mochi_pb2.MochiFlavor(flavor="taro"), 339 | calorie=100) 340 | taromochi_any_proto = any_pb2.Any() 341 | taromochi_any_proto.Pack(taro_mochi) 342 | proto_converter = MochiConverter(pb_class_from=mochi_pb2.AnyMochiBox, 343 | pb_class_to=mochi_pb2.TaroMochiBox) 344 | src_proto = mochi_pb2.AnyMochiBox( 345 | name="TaroMochiBox", mochi=_pack_to_any_proto(taro_mochi)) 346 | 347 | dest_proto = proto_converter.convert(src_proto=src_proto) 348 | ``` 349 | 350 | Repeated `Any` field to repeated `Proto` field 351 | 352 | ```python 353 | class RepeatedMochiConverter(converter.ProtoConverter): 354 | 355 | @converter.convert_field(field_names=["mochi"]) 356 | def mochi_field_convert_function(self, src_proto, dest_proto): 357 | for field in src_proto.mochi: 358 | proto_object = mochi_pb2.TaroMochi() 359 | field.Unpack(proto_object) 360 | dest_proto.mochi.append(proto_object) 361 | ``` 362 | 363 | Map `Any` field to Map `Proto` field 364 | 365 | ```python 366 | class MapMochiConverter(converter.ProtoConverter): 367 | 368 | @converter.convert_field(field_names=["mochi"]) 369 | def mochi_field_convert_function(self, src_proto, dest_proto): 370 | for key, field in src_proto.mochi.items(): 371 | proto_object = mochi_pb2.TaroMochi() 372 | field.Unpack(proto_object) 373 | dest_proto.mochi[key].CopyFrom(proto_object) 374 | ``` 375 | 376 | #### Nested conversion 377 | 378 | Nested conversion is supported if the source proto and destination proto 379 | contains the same proto type (like the above example), 380 | while auto-conversion won't work if the nested protos are of different type. 381 | 382 | However, it's very easy to support this case with a custom method. We think it's 383 | cleaner to create separate converters as you will see in the below example. 384 | 385 | ```proto 386 | message TaroMochi { 387 | float price_float = 1; 388 | MochiFlavor flavor_proto = 2; 389 | int64 calorie = 3; 390 | } 391 | 392 | message CocoMochi { 393 | float price_float = 1; 394 | MochiFlavor flavor_proto = 2; 395 | int64 calorie = 3; 396 | } 397 | 398 | message TaroMochiBox { 399 | string name = 1; 400 | TaroMochi mochi = 2; 401 | } 402 | 403 | message CocoMochiBox { 404 | string name = 1; 405 | CocoMochi mochi = 2; 406 | } 407 | ``` 408 | 409 | ```python 410 | class NestedMochiBoxConverter(converter.ProtoConverter): 411 | taro_to_coco_converter: converter.ProtoConverter = None 412 | 413 | def __init__(self): 414 | super(RecursiveMochiBoxConverter, self).__init__( 415 | pb_class_from=mochi_pb2.TaroMochiBox, 416 | pb_class_to=mochi_pb2.CocoMochiBox 417 | ) 418 | self.taro_to_coco_converter = converter.ProtoConverter( 419 | pb_class_from=mochi_pb2.TaroMochi, pb_class_to=mochi_pb2.CocoMochi) 420 | 421 | @converter.convert_field(field_names=["mochi"]) 422 | def mochi_field_convert_function(self, src_proto, dest_proto): 423 | dest_proto.mochi.CopyFrom( 424 | self.taro_to_coco_converter.convert(src_proto.mochi)) 425 | 426 | ... 427 | 428 | proto_converter = NestedMochiBoxConverter() 429 | dest_proto = proto_converter.convert(src_proto) 430 | ``` 431 | 432 | With the additional ProtoConverter between TaroMochi and CocoMochi, it's very 433 | easy to update the conversion once the TaroMochi or CocoMochi proto changes. 434 | 435 | For nested array protos, we need to iterate through each element and append the 436 | conversion result to the destination proto: 437 | 438 | ```proto 439 | message CocoMochiBoxes { 440 | string name = 1; 441 | repeated CocoMochi mochi = 2; 442 | } 443 | 444 | message TaroMochiBoxes { 445 | string name = 1; 446 | repeated TaroMochi mochi = 2; 447 | } 448 | ``` 449 | 450 | ```python 451 | @converter.convert_field(field_names=["mochi"]) 452 | def mochi_field_convert_function(self, src_proto, dest_proto): 453 | for mochi in src_proto.mochi: 454 | dest_proto.mochi.append(self.taro_to_coco_converter.convert(mochi)) 455 | ``` 456 | 457 | ## Contributing 458 | 459 | See [`CONTRIBUTING.md`](CONTRIBUTING.md) for details. 460 | 461 | ## License 462 | 463 | Apache 2.0; see [`LICENSE`](LICENSE) for details. 464 | 465 | ## Disclaimer 466 | 467 | This project is not an official Google project. It is not supported by 468 | Google and Google specifically disclaims all warranties as to its quality, 469 | merchantability, or fitness for a particular purpose. 470 | --------------------------------------------------------------------------------