Protobuf Zip Imports in Python
In Python, the most straightforward path to implementing a gRPC server for a Protobuf service is to use protoc
to generate code that can be imported in a server, which then defines the service logic.
Let’s take a simple example Protobuf service:
syntax = "proto3";
package simple;
message HelloRequest { string name = 1;}
message HelloResponse { string message = 1;}
service Greeter { rpc SayHello (HelloRequest) returns (HelloResponse);}
Next, we run some variant of python -m grpc_tools.protoc
to generate code (assuming we’ve installed grpcio
and grpcio-tools
).
Here’s an example for .proto
files in a protos
folder:
python -m grpc_tools.protoc --python_out=. --grpc_python_out=. --proto_path=protos protos/*.proto
This command outputs the following files
simple_pb2_grpc.pysimple_pb2.py
Within simple_pb2_grpc.py
we see this import
import simple_pb2 as simple__pb2
This import can be problematic because it assumes that the generated code exists at the root of the project.
If you want to keep your project structure organized, you probably want to put the generated code into a subfolder and gitignore it.
The protoc
tool doesn’t seem to support any options for Python code that will write these import statements differently.
This limitation leaves us with only a few options:
- As first mentioned, generate the code at the root of the project and deal with the suboptimal structure
python -m grpc_tools.protoc --python_out=. --grpc_python_out=. --proto_path=protos protos/*.proto
then in src/server.py
do the following imports
import simple_pb2import simple_pb2_grpc
We now have simple_pb2_grpc.py
and simple_pb2.py
in the project root and the server runs
❯ python -m src.serverServer started, listening on port 50051
- Re-write the generated code to fix the imports for the package structure we want
❯ sed -i '' 's/import simple_pb2 as simple__pb2/from gen.protos import simple_pb2 as simple__pb2/' gen/protos/simple_pb2_grpc.py
Now the import is
from gen.protos import simple_pb2 as simple__pb2
and we can run the server
❯ python -m src.serverServer started, listening on port 50051
- Augment the PYTHONPATH to add the target folder of the generated code to allow the generated imports to work
mkdir -p gen/protospython -m grpc_tools.protoc --python_out=gen/protos --grpc_python_out=gen/protos --proto_path=protos protos/*.proto
If we add these imports
# Import the generated code from gen/protosfrom gen.protos import simple_pb2, simple_pb2_grpc
to a src/server.py
file then run it
python -m src.server
we still an error like this
python -m src.serverTraceback (most recent call last): File "<frozen runpy>", line 198, in _run_module_as_main File "<frozen runpy>", line 88, in _run_code File "/Users/danielcorin/dev/toys/protobuf-zip-imports/src/server.py", line 8, in <module> from gen.protos import simple_pb2, simple_pb2_grpc File "/Users/danielcorin/dev/toys/protobuf-zip-imports/gen/protos/simple_pb2_grpc.py", line 6, in <module> import simple_pb2 as simple__pb2ModuleNotFoundError: No module named 'simple_pb2'make: *** [serve] Error 1
However, we can get this to work if we augment PYTHONPATH. This approach allows the import in the generated code and the package import in the server to both work.
# from src/server.pyfrom gen.protos import simple_pb2, simple_pb2_grpc
# from gen/protos/simple_pb2_grpc.pyimport simple_pb2 as simple__pb2
It runs.
❯ export PYTHONPATH=gen/protos❯ python -m src.serverServer started, listening on port 50051
Generating code to a zip archive
All of the aforementioned approaches require some degree of compromise in package structure or environment setup. This approach is no different, but I like it best, because it does not require modifying the generated code, the PYTHONPATH or creating what can feel like a mess in the project root, especially when you have many different protobuf services.
The approach is to create a zip archive of the Python generated code — something protoc
supports out of the box.
python -m grpc_tools.protoc -I./protos --python_out=./gen.zip --grpc_python_out=./gen.zip protos/*.proto
This command creates gen.zip
at the root of the project.
When unarchived, we can see it contains these files:
❯ unzip gen.zipArchive: gen.zip extracting: simple_pb2.py extracting: simple_pb2_grpc.py
To make the imports work in our server, we can use zipimport
import zipimport
# Import the generated code from gen.zipimporter = zipimport.zipimporter("gen.zip")simple_pb2 = importer.load_module("simple_pb2")simple_pb2_grpc = importer.load_module("simple_pb2_grpc")
and our server runs
python -m src.serverServer started, listening on port 50051
The final src/server.py
code looks like this.
import socketimport sysfrom concurrent import futuresimport grpcimport zipimport
# Import the generated code from gen.zipimporter = zipimport.zipimporter("gen.zip")simple_pb2 = importer.load_module("simple_pb2")simple_pb2_grpc = importer.load_module("simple_pb2_grpc")
class Greeter(simple_pb2_grpc.GreeterServicer): def SayHello(self, request, context): return simple_pb2.HelloResponse(message=f"Hello, {request.name}!")
def serve(port=50051): try: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("localhost", port)) except socket.error: print(f"Error: Port {port} is already in use") sys.exit(1)
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) simple_pb2_grpc.add_GreeterServicer_to_server(Greeter(), server) server.add_insecure_port(f"[::]:{port}") server.start() print(f"Server started, listening on port {port}") server.wait_for_termination()
if __name__ == "__main__": serve()
You can also find this approach in project form here.
Recommended
Importing Activities for a Temporal Workflow in Python
A spot where I slipped up in trying to adopt Temporal in an existing Python project and then again in starting a new Python project was in defining a...
Sandboxed Python Environment
Disclaimer: I am not a security expert or a security professional.
Python Fabric
To help facilitate my blogging workflow, I wanted to go from written to published post quickly. My general workflow for writing a post for [this...