├── 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 |
--------------------------------------------------------------------------------