Automatic conversion between compatible protocol buffers
Project description
proto-converter
Automatic deep conversion between compatible protocol buffer types. Inspired by python-proto-converter but designed to require far less boilerplate.
The problem
You have parallel proto hierarchies — say an internal schema and a public API schema — with messages that are structurally compatible (same field names and types) but generated as different Python classes. Converting between them by hand is tedious and breaks every time a field is added.
The existing python-proto-converter
helps with this, but still requires an explicit mapping for every relevant proto.
Submessages are particularly cumbersome because they require their own converter
and special handling in the containing converter. This proto-converter library
does away with that requirement. As long as the submessages are also automatically
convertible, no converters need to be defined at all.
What this library does
Call proto_converter.convert(msg, TargetType) and it figures out the rest:
- Scalars, enums,
Struct,Any— copied when the name and type match. Enums with different types are compatible if every source value number exists in the destination (matching proto wire-format semantics). - Nested messages (singular, repeated, and map values) — if the field names match but the message types differ, a converter for the nested types is created automatically and applied recursively. This works to arbitrary depth.
- No registration needed when every source field has a compatible counterpart
in the destination. Just call
convert().
When the types aren't fully compatible (extra fields, renamed fields, fields that need transformation), you register a converter subclass — but only for the specific type pair that differs, not the whole tree.
Note the asymmetry: extra source fields with no destination counterpart raise
NotImplementedError (potential data loss). Extra destination fields are left
at their proto3 defaults (harmless).
Installation
pip install proto-converter
Quick start
import proto_converter
# Deep-convert an entire message tree with zero configuration — works as long
# as field names and types are compatible at every level.
api_msg = proto_converter.convert(internal_msg, api_pb2.MyMessage)
When the source has fields the destination doesn't (or vice versa), register a converter to tell the library what to do with them:
from proto_converter import ProtoConverter, convert_field
class PersonConverter(ProtoConverter[internal_pb2.Person, api_pb2.Person]):
# Fields that exist only in the source and can be dropped.
IGNORED_FIELDS = ["internal_id", "created_at"]
# Fields that need custom logic.
@convert_field(["secret_name"])
def convert_name(self, src, dest):
dest.display_name = src.secret_name.upper()
Just defining the class is enough — ProtoConverter.__init_subclass__ registers
it in a global registry. After that, proto_converter.convert() finds and uses
it automatically, including when it appears as a nested message inside a larger
conversion.
Any field that can't be auto-converted and isn't handled by IGNORED_FIELDS or
@convert_field raises NotImplementedError at converter construction time, not
during conversion — so missing fields are caught early.
Important: convert() auto-creates and caches converters for the entire message
tree on first call. If you define a ProtoConverter subclass for a type pair that
was already auto-created, registration will fail. Define all custom converter
subclasses before calling convert().
Custom type resolution
The recursive converter needs to map protobuf Descriptor objects back to Python
classes. By default it uses importlib, assuming the proto package maps directly
to a Python package. If your generated code lives under a different prefix, use
set_module_resolver to remap the import path:
import proto_converter
def resolver(module_path: str) -> str | None:
if module_path.startswith("ultravox."):
return f"ultravox_proto.{module_path}"
return None # use the original path
proto_converter.set_module_resolver(resolver)
For full control over type resolution (e.g. when you need to intercept at the
Descriptor level), use set_type_resolver instead — it receives a protobuf
Descriptor and returns a Python class directly.
Test recommendations
For most projects, it is worthwhile to have tests converting between top-level messages. The tests may exercise any custom conversions registered. If there are no custom conversions, it's sufficient to test conversion of a default instance. This will catch the introduction of any fields that cannot be automatically converted since convertability is checked during converter construction.
(Testing conversion explicitly is typically unhelpful given that the code using
a converter has its own tests that invoke convert at some point.)
Thread safety
Converters are cached in a global registry. Once a converter for a given type pair
has been created (typically at import time or on first use), convert() is a plain
dict lookup followed by a stateless conversion. It is thread-safe as long as any
custom field conversions on the path are themselves thread-safe.
However, converter construction (the first convert() call for a new type pair,
or defining a ProtoConverter subclass) is not thread-safe. The same applies to
set_module_resolver() and set_type_resolver(). If this is a concern, do all of
these during single-threaded startup rather than lazily from worker threads.
Proto2 notes
This library is designed for proto3 but works with proto2 in most cases. Known differences:
- Default values: auto-conversion uses
ListFields(), which skips fields set to their default value. In proto3 this is standard (defaults are always zero-values). In proto2, fields with explicit non-zero defaults that happen to be set to that default will be skipped. Use@convert_fieldfor any proto2 fields where preserving explicit defaults matters. - Required fields: not validated — a required source field at its default won't be copied, potentially producing an invalid destination message.
- Groups: not supported (groups are extremely rare in practice).
Development
just install # install deps + generate test protos
just # format, check, and test (the default)
just test # just tests
just check # lint + type check
just format # auto-format
just build-protos # regenerate test protos after changing .proto files
Releasing
- Update the version in
pyproject.toml. - Merge to
main. - Tag and push:
git tag v<version> && git push origin v<version> - Build and publish:
uv build && uv publish
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file proto_converter-1.0.0.tar.gz.
File metadata
- Download URL: proto_converter-1.0.0.tar.gz
- Upload date:
- Size: 10.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.17 {"installer":{"name":"uv","version":"0.9.17","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
77276bafd8741781fc0ae1c1c13adfacb2bd886273704fe850c954a38a78a6dc
|
|
| MD5 |
1a92156ecbc9db09b426ab526395951f
|
|
| BLAKE2b-256 |
ac288d6c7d743765c60dc460c653f92d1f2672f46021c700991c8a6177f39b60
|
File details
Details for the file proto_converter-1.0.0-py3-none-any.whl.
File metadata
- Download URL: proto_converter-1.0.0-py3-none-any.whl
- Upload date:
- Size: 11.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.17 {"installer":{"name":"uv","version":"0.9.17","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ad30c1e7d77a3e8adabae90127e088a22026e1dbd5af2e249bb594d109f20990
|
|
| MD5 |
61fbd8b48f6158a02d0fafdc0406eb2b
|
|
| BLAKE2b-256 |
d14f6459f73ec22322394901ac6dafe0cbb452c402f266982e8bc9200f3f1dcd
|