Writing Plugins
Protoc plugins generate additional files from your Protobuf schema.
Running alongside protoc-gen-py, your plugin can generate RPC interfaces, database mappings, validation helpers, or any other code that builds on the message types protoc-gen-py produces.
Plugins are implemented as simple executables, named protoc-gen-<name> by convention.
They receive the schema via stdin and return generated files via stdout.
Hello world
Create a file named protoc-gen-hello.py:
#!/usr/bin/env python3
from protobuf.plugin import Schema, run
def generate(schema: Schema) -> None:
for desc in schema.files_to_generate:
f = schema.generate_file(desc, "_hello.py")
f.preamble(desc)
f.print('print(f"Hello! Here is your descriptor:\\n{', desc, '.proto.to_json()}")')
run("protoc-gen-hello", "0.1.0", generate)
Make it executable and confirm it responds to --version:
Add the plugin to your buf.gen.yaml:
version: v2
inputs:
- directory: proto
plugins:
- local: protoc-gen-py
out: src/gen
- local: protoc-gen-hello.py
out: src/gen
Run buf generate. For each .proto file, a _hello.py file appears alongside the _pb.py file:
src/gen/
└── example/
└── v1/
├── example_pb.py # generated by protoc-gen-py
└── example_hello.py # generated by protoc-gen-hello
example_hello.py contains the preamble header followed by your generated code:
# Generated from example/v1/example.proto. DO NOT EDIT.
# Generated by protoc-gen-hello v0.1.0 with parameter "".
# ruff: noqa: PGH004
# ruff: noqa
# fmt: off
from __future__ import annotations
from . import example_pb
print(f"Hello! Here is your descriptor:\n{example_pb.proto.to_json()}")
The framework automatically imports example_pb because desc was passed directly to f.print().
A complete example
Building on the hello world structure, here is a plugin that generates a typed Protocol for each service's unary RPCs.
Given a service like this:
// user/v1/user.proto
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse);
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
rpc ListUsers(ListUsersRequest) returns (stream ListUsersResponse);
}
A plugin that generates a typed Protocol for each unary RPC:
#!/usr/bin/env python3
from protobuf.plugin import Ident, Module, Schema, run
_PROTOCOL = Module("typing").ident("Protocol")
def generate(schema: Schema) -> None:
for desc in schema.files_to_generate:
f = schema.generate_file(desc, "_rpc.py")
f.preamble(desc)
for service in desc.services:
with f.scope("class ", f.ident(service.name), "(", _PROTOCOL, "):"):
for method in service.methods:
if method.method_kind != "unary":
continue # skip streaming RPCs
request = Ident.for_desc(method.input, type_only=True)
response = Ident.for_desc(method.output, type_only=True)
with f.scope("def ", method.name, "(self, request: ", request, ") -> ", response, ":"):
f.print("...")
run("protoc-gen-rpc", "0.1.0", generate)
Ident.for_desc() takes any message, enum, or extension descriptor and returns a reference to the corresponding symbol in the _pb file protoc-gen-py generates.
See Generating Files for details.
Add both plugins to buf.gen.yaml:
version: v2
inputs:
- directory: proto
plugins:
- local: protoc-gen-py # generates _pb files with message classes
out: src/gen
- local: protoc-gen-rpc.py # generates _rpc files referencing those classes
out: src/gen
Run buf generate.
Both plugins receive the same proto schema simultaneously.
For user/v1/user.proto this produces src/gen/user/v1/user_rpc.py:
# Generated from user/v1/user.proto. DO NOT EDIT.
# Generated by protoc-gen-rpc v0.1.0 with parameter "".
# ruff: noqa: PGH004
# ruff: noqa
# fmt: off
from __future__ import annotations
from typing import Protocol, TYPE_CHECKING
if TYPE_CHECKING:
from .user_pb import CreateUserRequest, CreateUserResponse, GetUserRequest, GetUserResponse
class UserService(Protocol):
def GetUser(self, request: GetUserRequest) -> GetUserResponse:
...
def CreateUser(self, request: CreateUserRequest) -> CreateUserResponse:
...
ListUsers is skipped because it is a streaming RPC.
Users of the generated file implement the Protocol and import message types from the _pb file normally:
from gen.user.v1.user_pb import GetUserRequest, GetUserResponse
from gen.user.v1.user_rpc import UserService
class InMemoryUserService(UserService):
def GetUser(self, request: GetUserRequest) -> GetUserResponse:
...
Note
method.name is PascalCase as written in the proto.
The examples/plugin/ directory shows a complete version of this plugin with snake_case conversion, docstrings, and forwarded proto comments.
Schema and descriptors
The Schema passed to your generate callback is the entry point:
schema.files_to_generate # Sequence[DescFile] — files buf was asked to generate
schema.all_files # Sequence[DescFile] — all files including transitive imports
schema.options # parsed plugin options (see Options)
Always iterate files_to_generate, not all_files; the latter includes imported dependencies that the user did not explicitly ask you to generate.
For the full descriptor API, see Descriptors.
Creating output files
schema.generate_file() has two forms:
# Derive the output path from a DescFile + suffix:
# "user/v1/user.proto" + "_rpc.py" → "user/v1/user_rpc.py"
f = schema.generate_file(desc, "_rpc.py")
# Arbitrary output path:
f = schema.generate_file("my_package/__init__.py")
Note
If generate_file() is never called for a given file, it will not appear in the output.
A File returned by generate_file() but with nothing printed will still be emitted.
Next steps
- Generating files: printing lines, imports, scopes, docstrings
- Options: plugin options, a complete example
- Descriptors: walking messages, fields, enums, and services