Contracts (code first)¶
In code-first mode, the Pydantic models and the services are the contract. fastgrpc3 turns
them into .proto files, compiles them with protoruf when the server starts, and can write
them for other languages (fastgrpc3 export).
Models¶
A message is a Pydantic model. ProtoModel is recommended: it forbids unknown fields,
accepts field names and aliases, checks oneof groups and takes the class arguments below.
A plain BaseModel works too.
from fastgrpc3 import ProtoModel, field
class Order(ProtoModel, package="shop.v1"):
"""A customer order.""" # becomes a comment in the .proto
number: str = field(1, min_length=1, description="Unique order number.")
note: str | None = field(2, default=None)
| Class argument | Effect |
|---|---|
proto="shop.v1.Order" |
full proto name of the message |
package="shop.v1" |
package only; the message keeps the class name |
file="shop/v1/orders.proto" |
the .proto file it is rendered into (default: one file per package, shop/v1/shop.proto) |
reserved=(4, (10, 20)), reserved_names=("old",) |
numbers (single or inclusive ranges) and names no field may use |
Without proto= or package=, a message goes into the package of the service that uses
it, and the messages it contains follow. A model used by services of two packages must say
where it belongs; two classes with the same full name are refused.
Field numbers¶
Every field needs a number on the wire. Give it explicitly:
from typing import Annotated
from fastgrpc3 import ProtoField, field, key
class Item(ProtoModel):
sku: str = field(1) # field(number, **pydantic.Field arguments)
qty: int = key(2, default=1) # fastgrpc2's spelling, same thing
label: Annotated[str, ProtoField(3)] = ""
Or leave it out and let proto.lock hold it. The first fastgrpc3 lock gives each new
field the smallest number never used in the message, wherever the field is declared, and
writes it down; the number never changes afterwards. A removed field's number and name are
reserved for good:
# proto.lock — generated by `fastgrpc3 lock`, commit it
version = 1
[messages."shop.v1.Item"]
reserved = [4]
reserved_names = ["legacy_code"]
[messages."shop.v1.Item".fields]
sku = 1
qty = 2
label = 3
fastgrpc3 lock myapp.main:app # number new fields, update proto.lock (commit it)
fastgrpc3 check myapp.main:app # in CI: fail when proto.lock is out of date
The server reads the lock when it is given one, GrpcApp(..., lock_file="proto.lock"),
and logs a warning for any field it has to number at startup; unlocked="error" refuses
to start instead. Without a lock, numbers follow the declaration order, as in fastgrpc2:
inserting a field then changes the wire, so the server warns at startup about every field
numbered that way (give it field(n), or use a lock).
fastgrpc3 breaking compares the contract with an earlier one and lists what would break
existing peers: a field removed without reserving its number, a type or cardinality
changed, a method removed… (see Command line). In CI:
fastgrpc3 breaking myapp.main:app --against origin/main
Numbers go from 1 to 2²⁹−1, except 19000–19999 (reserved by protobuf).
Names¶
The proto field name is the Python name. When Python cannot use it (from, json…):
class Trip(ProtoModel):
from_: str = field(1, default="", proto_name="from")
Pydantic aliases are independent of the wire: encoding and decoding go by field name.
Types¶
int is an int64, float a double, str, bytes, bool the obvious ones. Other
proto types come from markers in fastgrpc3.types, which also check the bounds:
from fastgrpc3.types import Float32, Int32, UInt32, UInt64
class Sizes(ProtoModel):
count: UInt32 = field(1, default=0) # uint32, 0..2³²-1
offset: Int32 = field(2, default=0) # int32
ratio: Float32 = field(3, default=0.0) # float
X | None is an optional field, list[X] a repeated one, dict[K, V] a map;
datetime, timedelta and date become the standard Timestamp, Duration and
google.type.Date. The full table is in Type mapping.
A type with no proto equivalent (Decimal, UUID, unions, list[list[int]]…) travels as a
JSON carrier: a string field holding the value serialized by Pydantic, marked
// json in the .proto. It works between Python peers; other languages see a string.
Presence and default values¶
Proto3 rules apply: a field without presence is not sent when it holds its zero value
(0, "", False, empty list), and is read back as that zero value. So a Pydantic default
(qty: int = 1) only applies when you build the model in Python; on the wire, 0 stays
0. Use X | None when "absent" must differ from the zero value.
Enums¶
from fastgrpc3 import ProtoEnum
class Status(ProtoEnum, proto="shop.v1.Status"):
UNSPECIFIED = 0
PAID = 1
- The values get the
ENUM_NAME_prefix in the.proto(STATUS_PAID);prefix="..."changes it,prefix=""keeps the names as written. - A value numbered 0 is required, and is written first.
ProtoEnumis open: an unknown number sent by a newer peer decodes to a pseudo-member (Status(7), namedUNRECOGNIZED_7) instead of failing, and is sent back unchanged. A plainIntEnumworks too, but refuses unknown numbers when validated.- An
Enumwhose values are not ints needs numbers:@proto_enum(NEW=0, PAID=1).
oneof¶
class Payment(ProtoModel):
card: Card | None = field(1, default=None, oneof="method")
iban: str | None = field(2, default=None, oneof="method")
Members are ordinary T | None = None fields of a ProtoModel; at most one may be set
(validation error otherwise). When decoding, the last member on the wire wins.
Exporting¶
fastgrpc3 export myapp.main:app -o proto/
writes one .proto per package (or per file=), with imports between them. It is exactly
the text the server compiles at startup. Docstrings and description= become comments.