Skip to main content

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:

  1. 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).
  2. 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.
  3. 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_field for 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

  1. Update the version in pyproject.toml.
  2. Merge to main.
  3. Tag and push: git tag v<version> && git push origin v<version>
  4. Build and publish: uv build && uv publish

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

proto_converter-1.0.0.tar.gz (10.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

proto_converter-1.0.0-py3-none-any.whl (11.5 kB view details)

Uploaded Python 3

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

Hashes for proto_converter-1.0.0.tar.gz
Algorithm Hash digest
SHA256 77276bafd8741781fc0ae1c1c13adfacb2bd886273704fe850c954a38a78a6dc
MD5 1a92156ecbc9db09b426ab526395951f
BLAKE2b-256 ac288d6c7d743765c60dc460c653f92d1f2672f46021c700991c8a6177f39b60

See more details on using hashes here.

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

Hashes for proto_converter-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 ad30c1e7d77a3e8adabae90127e088a22026e1dbd5af2e249bb594d109f20990
MD5 61fbd8b48f6158a02d0fafdc0406eb2b
BLAKE2b-256 d14f6459f73ec22322394901ac6dafe0cbb452c402f266982e8bc9200f3f1dcd

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page