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/*.protoThis command outputs the following files
simple_pb2_grpc.pysimple_pb2.pyWithin simple_pb2_grpc.py we see this import
import simple_pb2 as simple__pb2This 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/*.protothen in src/server.py do the following imports
import simple_pb2import simple_pb2_grpcWe 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.pyNow the import is
from gen.protos import simple_pb2 as simple__pb2and 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/*.protoIf we add these imports
# Import the generated code from gen/protosfrom gen.protos import simple_pb2, simple_pb2_grpcto a src/server.py file then run it
python -m src.serverwe 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 1However, 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__pb2It runs.
❯ export PYTHONPATH=gen/protos❯ python -m src.serverServer started, listening on port 50051Generating 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/*.protoThis 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.pyTo 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 50051The 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...