Conversion functions¤
Conversion functions translate low-level protobuf messages into high-level Python wrappers. They keep generated types out of public wrapper modules. They also retain values that a newer server sends or that an invalid message contains. This page explains how to write one; the conversion functions this library provides follow the same rules.
Keep generated code in conversion packages¤
Put a conversion function named *_from_proto or *_to_proto in
proto/<namespace>/, where <namespace> identifies a protobuf API version such
as v1alpha8. Add a sibling directory when you support a new protobuf API
version. Public type modules must not import generated protobuf code. They
define the wrapper types that conversion functions use. See
Organizing a wrapper package for the package
layout.
Keep the protobuf type in the conversion function signature and export the
function from its versioned proto package. Public wrapper types and their
ordinary constructors must not depend on generated message classes.
Delegate enum conversion¤
An enum conversion function calls
enum_from_proto. The helper
returns a known wrapper enum member. It returns an unrecognized number as
int. Use allow_invalid=False only when your code cannot accept an
unrecognized value. This is how Enums keeps an enum usable with
newer protobuf APIs.
from frequenz.client.common.metrics import Metric
from frequenz.client.common.proto import enum_from_proto
assert enum_from_proto(Metric.AC_POWER_ACTIVE.value, Metric) is Metric.AC_POWER_ACTIVE
assert enum_from_proto(999, Metric) == 999
Do not copy this logic into each enum conversion function. Using the helper keeps unrecognized values consistent across wrapper packages. The one exception is a protobuf API version that numbers a value differently from the wrapper enum, which Enums covers: those conversion functions translate the numbers themselves.
The matching *_to_proto function needs no helper. The wrapper member value is
the protobuf number, as Enums requires, so the function wraps that
number in the generated ValueType:
# Excerpt from metrics/proto/v1alpha8/_metric.py, without its docstring.
def metric_to_proto(metric: Metric) -> metrics_pb2.Metric.ValueType:
return metrics_pb2.Metric.ValueType(metric.value)
Type the parameter as the wrapper enum, not Metric | int. A caller that
received an unrecognized number decides what to send back; the conversion
function does not choose for it.
Return validity in the type¤
When a protobuf message breaks a rule for the wrapper type, return
X | InvalidX. The valid subclass gives callers the normal behavior. The
invalid subclass keeps the received fields for diagnosis, recovery, or later
interpretation. For example,
Bounds and
InvalidBounds represent valid
and invalid ranges, while
DeliveryArea and
InvalidDeliveryArea do the
same for delivery-area data. See Validity in the type
for how to model these types.
Normal construction must enforce the valid type's rules and reject invalid
values. A conversion function, however, must keep invalid protobuf data by
creating the matching invalid subclass. This gives callers a reliable valid type
without discarding a message the client received. Annotate the return type as
the explicit union, not as the shared Base* class, so callers must separate
the two cases; see Validity in the type.
When working with this result, use a safe get_*() accessor if the wrapper has
one. Otherwise, handle every member of the union with match and
assert_never:
from typing import assert_never
from frequenz.client.common.metrics import Bounds, InvalidBounds
def describe(bounds: Bounds | InvalidBounds) -> str:
match bounds:
case Bounds():
return "valid"
case InvalidBounds():
return "invalid"
case unexpected:
assert_never(unexpected)
assert describe(Bounds(lower=0, upper=1)) == "valid"
assert describe(InvalidBounds(lower=1, upper=0)) == "invalid"
Check unset fields and alternatives¤
Generated scalar defaults do not tell you whether a field was sent. Use
HasField() for an optional or
message field. Use WhichOneof()
to check the active option in a oneof before you read it. Base the conversion
on those results, not on a default value that could mean the field is absent or
set.
When a message contains descriptor-known content that the current wrapper type
does not model, retain it in a JSON-compatible mapping for inspection. When
creating that mapping with
MessageToDict(), pass
preserving_proto_field_name=True so its keys use the original protobuf field
names.
This mapping is not a lossless protobuf representation. It does not preserve unknown wire fields, all field-presence information, or the exact protobuf representation of each value. Do not use it when the wrapper must write the original content back unchanged; preserve a lossless representation for that use case instead.