Skip to content

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
# 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.
  • ProtoEnum is open: an unknown number sent by a newer peer decodes to a pseudo-member (Status(7), named UNRECOGNIZED_7) instead of failing, and is sent back unchanged. A plain IntEnum works too, but refuses unknown numbers when validated.
  • An Enum whose 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.