Advanced Features¶
This guide covers advanced usage patterns including Pydantic integration, performance optimization, and production best practices.
Pydantic Integration¶
protoruf works seamlessly with Pydantic models for type-safe data handling.
Define Your Models¶
from pydantic import BaseModel, Field
from protoruf import compile_proto, json_to_protobuf, protobuf_to_json
# Define Pydantic models matching your proto schema
class Metadata(BaseModel):
author: str = ""
created_at: int = 0
attributes: dict[str, str] = {}
class Message(BaseModel):
id: str = ""
content: str = ""
priority: int = 0
tags: list[str] = []
metadata: Metadata | None = None
Convert via JSON Serialization¶
# Compile proto
descriptor = compile_proto("message.proto")
# Create message with Pydantic
msg = Message(
id="123",
content="Hello, World!",
priority=1,
tags=["greeting", "example"],
metadata=Metadata(author="Alice", created_at=1234567890)
)
# Convert to Protobuf via JSON
json_str = msg.model_dump_json()
protobuf_bytes = json_to_protobuf(
json_str,
descriptor,
message_type="message.Message"
)
# Convert back and reconstruct model
result_json = protobuf_to_json(
protobuf_bytes,
descriptor,
message_type="message.Message"
)
result_msg = Message.model_validate_json(result_json)
Benefits of Pydantic Integration¶
- ✅ Validation: Automatic data validation before serialization
- ✅ Type Safety: Catch errors at model creation time
- ✅ Defaults: Handle default values consistently
- ✅ IDE Support: Autocomplete and type checking
Performance Optimization¶
1. Use DescriptorCache for Hot Loops¶
This is the single most impactful optimization. The free functions
(json_to_protobuf / protobuf_to_json) re-decode the descriptor set on every
call, which dominates the cost when converting many messages.
DescriptorCache decodes the descriptor
pool once and reuses it (plus the resolved message descriptors) across every
conversion — roughly a 7–14× speedup. Build it once, reuse it everywhere.
from protoruf import compile_proto, DescriptorCache
descriptor = compile_proto("schema.proto")
# Decode the pool a single time
cache = DescriptorCache(descriptor)
for json_data in json_stream:
# No descriptor argument — the pool is already decoded
protobuf_bytes = cache.json_to_protobuf(json_data, "message.Message")
process(protobuf_bytes)
# Round-trip back to JSON
restored = cache.protobuf_to_json(protobuf_bytes, "message.Message")
A single cache instance handles every message type in the descriptor and is safe to share across threads. The output format is identical to the free functions.
Prefer the cache
Reach for the free functions only for one-off conversions. Any loop or
long-lived service should hold a DescriptorCache.
2. Cache Descriptors¶
If you do use the free functions, still compile descriptors once and reuse them:
from functools import lru_cache
from protoruf import compile_proto
@lru_cache(maxsize=32)
def get_descriptor(proto_file: str) -> bytes:
return compile_proto(proto_file)
# First call compiles, subsequent calls return cached
descriptor = get_descriptor("schema.proto")
3. Use Descriptor Files¶
Avoid recompiling in production:
# Build step
compile_proto("schema.proto", output_path="schema.desc")
# Runtime (faster)
from protoruf import load_descriptor
descriptor = load_descriptor("schema.desc")
# Pair it with a DescriptorCache for maximum throughput
from protoruf import DescriptorCache
cache = DescriptorCache(descriptor)
4. Pretty Printing Overhead¶
Skip pretty printing in production:
# Faster (default)
json_str = protobuf_to_json(protobuf_bytes, descriptor, "message.Message")
# Slower (use only for debugging/logs)
json_str = protobuf_to_json(protobuf_bytes, descriptor, "message.Message", pretty=True)
Error Handling Best Practices¶
Validate Early¶
Catch errors as close to input as possible:
def convert_safely(json_str: str, descriptor: bytes, msg_type: str) -> bytes:
try:
# Validate JSON first
import json
json.loads(json_str) # Raises ValueError if invalid
return json_to_protobuf(json_str, descriptor, msg_type)
except ValueError as e:
print(f"Invalid input: {e}")
raise
except RuntimeError as e:
print(f"Conversion failed: {e}")
raise
Handle Missing Message Types¶
def convert_with_fallback(json_str: str, descriptor: bytes, msg_type: str) -> bytes | None:
try:
return json_to_protobuf(json_str, descriptor, msg_type)
except ValueError as e:
if "not found" in str(e):
print(f"Message type '{msg_type}' not in descriptor")
else:
print(f"Invalid JSON: {e}")
return None
Working with Multiple Message Types¶
Descriptor with Multiple Messages¶
A single .proto file can define multiple messages:
syntax = "proto3";
package ecommerce;
message Product {
string id = 1;
string name = 2;
double price = 3;
}
message Order {
string order_id = 1;
repeated Product items = 2;
double total = 3;
}
Use different message types with the same descriptor:
descriptor = compile_proto("ecommerce.proto")
# Convert Product
product_json = '{"id": "p1", "name": "Widget", "price": 9.99}'
product_pb = json_to_protobuf(product_json, descriptor, "ecommerce.Product")
# Convert Order
order_json = '{"order_id": "o1", "items": [{"id": "p1", "name": "Widget", "price": 9.99}], "total": 9.99}'
order_pb = json_to_protobuf(order_json, descriptor, "ecommerce.Order")
Production Patterns¶
Service Wrapper¶
Create a service class for clean APIs:
from protoruf import compile_proto, DescriptorCache
class ProtoService:
def __init__(self, proto_file: str):
# Decode the descriptor pool once for the lifetime of the service
self.cache = DescriptorCache(compile_proto(proto_file))
def encode(self, json_str: str, message_type: str) -> bytes:
return self.cache.json_to_protobuf(json_str, message_type)
def decode(self, protobuf_bytes: bytes, message_type: str, pretty: bool = False) -> str:
return self.cache.protobuf_to_json(protobuf_bytes, message_type, pretty=pretty)
# Usage
service = ProtoService("schema.proto")
encoded = service.encode('{"id": "123"}', "message.Message")
decoded = service.decode(encoded, "message.Message", pretty=True)
Descriptor Registry¶
Manage multiple proto schemas:
class DescriptorRegistry:
def __init__(self):
self._descriptors: dict[str, bytes] = {}
def register(self, name: str, proto_file: str):
from protoruf import compile_proto
self._descriptors[name] = compile_proto(proto_file)
def get(self, name: str) -> bytes:
if name not in self._descriptors:
raise KeyError(f"Descriptor '{name}' not registered")
return self._descriptors[name]
# Usage
registry = DescriptorRegistry()
registry.register("user", "user.proto")
registry.register("order", "order.proto")
user_desc = registry.get("user")
Debugging Tips¶
Inspect Descriptor Contents¶
Print the descriptor to understand its structure:
Test Round-Trips¶
Verify conversions preserve data:
original = '{"id": "123", "name": "Test"}'
encoded = json_to_protobuf(original, descriptor, "message.Message")
decoded = protobuf_to_json(encoded, descriptor, "message.Message")
assert original == decoded, "Round-trip failed!"
Next Steps¶
- Review the complete API Reference
- Learn about contributing to protoruf