Doc updates, refactor code layout, python package

This commit is contained in:
Daniel G. Taylor 2019-10-18 21:07:12 -07:00
parent 811b54cabb
commit 7fe64ad8fe
No known key found for this signature in database
GPG Key ID: 7BD6DC99C9A87E22
9 changed files with 270 additions and 22 deletions

3
.gitignore vendored
View File

@ -8,3 +8,6 @@ betterproto/tests/*.py
!betterproto/tests/generate.py !betterproto/tests/generate.py
!betterproto/tests/test_*.py !betterproto/tests/test_*.py
**/__pycache__ **/__pycache__
dist
**/*.egg-info
output

2
MANIFEST.in Normal file
View File

@ -0,0 +1,2 @@
recursive-exclude tests *
exclude output

View File

@ -19,6 +19,6 @@ grpclib = "*"
python_version = "3.7" python_version = "3.7"
[scripts] [scripts]
plugin = "protoc --plugin=protoc-gen-custom=protoc-gen-betterpy.py --custom_out=output" plugin = "protoc --plugin=protoc-gen-custom=betterproto/plugin.py --custom_out=output"
generate = "python betterproto/tests/generate.py" generate = "python betterproto/tests/generate.py"
test = "pytest ./betterproto/tests" test = "pytest ./betterproto/tests"

216
README.md
View File

@ -1,10 +1,10 @@
# Better Protobuf / gRPC Support for Python # Better Protobuf / gRPC Support for Python
This project aims to provide an improved experience when using Protobuf / gRPC in a modern Python environment by making use of modern language features and generating readable, understandable code. It will not support legacy features or environments. The following are supported: This project aims to provide an improved experience when using Protobuf / gRPC in a modern Python environment by making use of modern language features and generating readable, understandable, idiomatic Python code. It will not support legacy features or environments. The following are supported:
- Protobuf 3 & gRPC code generation - Protobuf 3 & gRPC code generation
- Both binary & JSON serialization is built-in - Both binary & JSON serialization is built-in
- Python 3.7+ - Python 3.7+ making use of:
- Enums - Enums
- Dataclasses - Dataclasses
- `async`/`await` - `async`/`await`
@ -17,7 +17,198 @@ This project is heavily inspired by, and borrows functionality from:
- https://github.com/eigenein/protobuf/ - https://github.com/eigenein/protobuf/
- https://github.com/vmagamedov/grpclib - https://github.com/vmagamedov/grpclib
## TODO ## Motivation
This project exists because I am unhappy with the state of the official Google protoc plugin for Python.
- No `async` support (requires additional `grpclib` plugin)
- No typing support or code completion/intelligence (requires additional `mypy` plugin)
- No `__init__.py` module files get generated
- Output is not importable
- Import paths break in Python 3 unless you mess with `sys.path`
- Bugs when names clash (e.g. `codecs` package)
- Generated code is not idiomatic
- Completely unreadable runtime code-generation
- Much code looks like C++ or Java ported 1:1 to Python
- Capitalized function names like `HasField()` and `SerializeToString()`
- Uses `SerializeToString()` rather than the built-in `__bytes__()`
This project is a reimplementation from the ground up focused on idiomatic modern Python to help fix some of the above. While it may not be a 1:1 drop-in replacement due to changed method names and call patterns, the wire format is identical.
## Installation & Getting Started
First, install the package:
```sh
$ pip install betterproto
```
Now, given a proto file, e.g `example.proto`:
```protobuf
syntax = "proto3";
package hello;
// Greeting represents a message you can tell a user.
message Greeting {
string message = 1;
}
```
You can run the following:
```sh
$ protoc -I . --python_betterproto_out=. example.proto
```
This will generate `hello.py` which looks like:
```py
# Generated by the protocol buffer compiler. DO NOT EDIT!
# sources: hello.proto
# plugin: python-betterproto
from dataclasses import dataclass
import betterproto
@dataclass
class Hello(betterproto.Message):
"""Greeting represents a message you can tell a user."""
message: str = betterproto.string_field(1)
```
Now you can use it!
```py
>>> from hello import Hello
>>> test = Hello()
>>> test
Hello(message='')
>>> test.message = "Hey!"
>>> test
Hello(message="Hey!")
>>> serialized = bytes(test)
>>> serialized
b'\n\x04Hey!'
>>> another = Hello().parse(serialized)
>>> another
Hello(message="Hey!")
>>> another.to_dict()
{"message": "Hey!"}
>>> another.to_json(indent=2)
'{\n "message": "Hey!"\n}'
```
### Async gRPC Support
The generated Protobuf `Message` classes are compatible with [grpclib](https://github.com/vmagamedov/grpclib) so you are free to use it if you like. That said, this project also includes support for async gRPC stub generation with better static type checking and code completion support. It is enabled by default.
Given an example like:
```protobuf
syntax = "proto3";
package echo;
message EchoRequest {
string value = 1;
// Number of extra times to echo
uint32 extra_times = 2;
}
message EchoResponse {
repeated string values = 1;
}
message EchoStreamResponse {
string value = 1;
}
service Echo {
rpc Echo(EchoRequest) returns (EchoResponse);
rpc EchoStream(EchoRequest) returns (stream EchoStreamResponse);
}
```
You can use it like so (enable async in the interactive shell first):
```py
>>> import echo
>>> from grpclib.client import Channel
>>> channel = Channel(host="127.0.0.1", port=1234)
>>> service = echo.EchoStub(channel)
>>> await service.echo(value="hello", extra_times=1)
EchoResponse(values=["hello", "hello"])
>>> async for response in service.echo_stream(value="hello", extra_times=1)
print(response)
EchoStreamResponse(value="hello")
EchoStreamResponse(value="hello")
```
### JSON
Both serializing and parsing are supported to/from JSON and Python dictionaries using the following methods:
- Dicts: `Message().to_dict()`, `Message().from_dict(...)`
- JSON: `Message().to_json()`, `Message().from_json(...)`
### Determining if a message was sent
Sometimes it is useful to be able to determine whether a message has been sent on the wire. This is how the Google wrapper types work to let you know whether a value is unset, set as the default (zero value), or set as something else, for example.
Use `Message().serialized_on_wire` to determine if it was sent. This is a little bit different from the official Google generated Python code:
```py
# Old way
>>> mymessage.HasField('myfield')
# New way
>>> mymessage.myfield.serialized_on_wire
```
## Development
First, make sure you have Python 3.7+ and `pipenv` installed:
```sh
# Get set up with the virtual env & dependencies
$ pipenv install --dev
# Link the local package
$ pipenv shell
$ pip install -e .
```
### Tests
There are two types of tests:
1. Manually-written tests for some behavior of the library
2. Proto files and JSON inputs for automated tests
For #2, you can add a new `*.proto` file into the `betterproto/tests` directory along with a sample `*.json` input and it will get automatically picked up.
Here's how to run the tests.
```sh
# Generate assets from sample .proto files
$ pipenv run generate
# Run the tests
$ pipenv run tests
```
### TODO
- [x] Fixed length fields - [x] Fixed length fields
- [x] Packed fixed-length - [x] Packed fixed-length
@ -30,11 +221,26 @@ This project is heavily inspired by, and borrows functionality from:
- [ ] Support passthrough of unknown fields - [ ] Support passthrough of unknown fields
- [x] Refs to nested types - [x] Refs to nested types
- [x] Imports in proto files - [x] Imports in proto files
- [ ] Well-known Google types - [x] Well-known Google types
- [ ] JSON that isn't completely naive. - [ ] JSON that isn't completely naive.
- [x] 64-bit ints as strings
- [x] Maps
- [x] Lists
- [ ] Bytes as base64
- [ ] Any support
- [ ] Well known types support (timestamp, duration, wrappers)
- [ ] Async service stubs - [ ] Async service stubs
- [x] Unary-unary - [x] Unary-unary
- [x] Server streaming response - [x] Server streaming response
- [ ] Client streaming request - [ ] Client streaming request
- [ ] Python package - [ ] Renaming messages and fields to conform to Python name standards
- [ ] Renaming clashes with language keywords and standard library top-level packages
- [x] Python package
- [ ] Automate running tests
- [ ] Cleanup! - [ ] Cleanup!
## License
Copyright © 2019 Daniel G. Taylor
http://dgt.mit-license.org/

View File

@ -633,9 +633,9 @@ class Message(ABC):
setattr(self, field.name, v) setattr(self, field.name, v)
return self return self
def to_json(self) -> str: def to_json(self, indent: Union[None, int, str] = None) -> str:
"""Returns the encoded JSON representation of this message instance.""" """Returns the encoded JSON representation of this message instance."""
return json.dumps(self.to_dict()) return json.dumps(self.to_dict(), indent=indent)
def from_json(self: T, value: Union[str, bytes]) -> T: def from_json(self: T, value: Union[str, bytes]) -> T:
""" """

View File

@ -8,7 +8,7 @@ import sys
import textwrap import textwrap
from typing import Any, List, Tuple from typing import Any, List, Tuple
from jinja2 import Environment, PackageLoader import jinja2
from google.protobuf.compiler import plugin_pb2 as plugin from google.protobuf.compiler import plugin_pb2 as plugin
from google.protobuf.descriptor_pb2 import ( from google.protobuf.descriptor_pb2 import (
@ -130,12 +130,12 @@ def get_comment(proto_file, path: List[int]) -> str:
def generate_code(request, response): def generate_code(request, response):
env = Environment( env = jinja2.Environment(
trim_blocks=True, trim_blocks=True,
lstrip_blocks=True, lstrip_blocks=True,
loader=PackageLoader("betterproto", "templates"), loader=jinja2.FileSystemLoader("%s/templates/" % os.path.dirname(__file__)),
) )
template = env.get_template("main.py") template = env.get_template("template.py")
output_map = {} output_map = {}
for proto_file in request.proto_file: for proto_file in request.proto_file:
@ -157,6 +157,7 @@ def generate_code(request, response):
"package": package, "package": package,
"files": [f.name for f in options["files"]], "files": [f.name for f in options["files"]],
"imports": set(), "imports": set(),
"typing_imports": set(),
"messages": [], "messages": [],
"enums": [], "enums": [],
"services": [], "services": [],
@ -229,12 +230,14 @@ def generate_code(request, response):
f.Type.Name(nested.field[0].type), f.Type.Name(nested.field[0].type),
f.Type.Name(nested.field[1].type), f.Type.Name(nested.field[1].type),
) )
output["typing_imports"].add("Dict")
if f.label == 3 and field_type != "map": if f.label == 3 and field_type != "map":
# Repeated field # Repeated field
repeated = True repeated = True
t = f"List[{t}]" t = f"List[{t}]"
zero = "[]" zero = "[]"
output["typing_imports"].add("List")
if f.type in [1, 2, 3, 4, 5, 6, 7, 8, 13, 15, 16, 17, 18]: if f.type in [1, 2, 3, 4, 5, 6, 7, 8, 13, 15, 16, 17, 18]:
packed = True packed = True
@ -292,6 +295,9 @@ def generate_code(request, response):
for msg in output["messages"]: for msg in output["messages"]:
if msg["name"] == input_type: if msg["name"] == input_type:
input_message = msg input_message = msg
for field in msg["properties"]:
if field["zero"] == "None":
output["typing_imports"].add("Optional")
break break
data["methods"].append( data["methods"].append(
@ -311,9 +317,13 @@ def generate_code(request, response):
} }
) )
if method.server_streaming:
output["typing_imports"].add("AsyncGenerator")
output["services"].append(data) output["services"].append(data)
output["imports"] = sorted(output["imports"]) output["imports"] = sorted(output["imports"])
output["typing_imports"] = sorted(output["typing_imports"])
# Fill response # Fill response
f = response.file.add() f = response.file.add()
@ -341,7 +351,8 @@ def generate_code(request, response):
init.content = b"" init.content = b""
if __name__ == "__main__": def main():
"""The plugin's main entry point."""
# Read request message from stdin # Read request message from stdin
data = sys.stdin.buffer.read() data = sys.stdin.buffer.read()
@ -360,3 +371,7 @@ if __name__ == "__main__":
# Write to stdout # Write to stdout
sys.stdout.buffer.write(output) sys.stdout.buffer.write(output)
if __name__ == "__main__":
main()

View File

@ -4,7 +4,10 @@
{% if description.enums %}import enum {% if description.enums %}import enum
{% endif %} {% endif %}
from dataclasses import dataclass from dataclasses import dataclass
from typing import AsyncGenerator, Dict, List, Optional {% if description.typing_imports %}
from typing import {% for i in description.typing_imports %}{{ i }}{% if not loop.last %}, {% endif %}{% endfor %}
{% endif %}
import betterproto import betterproto
{% if description.services %} {% if description.services %}

View File

@ -1,7 +1,12 @@
#!/usr/bin/env python #!/usr/bin/env python
import os
# Force pure-python implementation instead of C++, otherwise imports
# break things because we can't properly reset the symbol database.
os.environ["PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION"] = "python"
import importlib import importlib
import json import json
import os # isort: skip
import subprocess import subprocess
import sys import sys
from typing import Generator, Tuple from typing import Generator, Tuple
@ -10,12 +15,6 @@ from google.protobuf import symbol_database
from google.protobuf.descriptor_pool import DescriptorPool from google.protobuf.descriptor_pool import DescriptorPool
from google.protobuf.json_format import MessageToJson, Parse from google.protobuf.json_format import MessageToJson, Parse
# Force pure-python implementation instead of C++, otherwise imports
# break things because we can't properly reset the symbol database.
os.environ["PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION"] = "python"
root = os.path.dirname(os.path.realpath(__file__)) root = os.path.dirname(os.path.realpath(__file__))
@ -55,7 +54,7 @@ if __name__ == "__main__":
f"protoc --python_out=. {os.path.basename(filename)}", shell=True f"protoc --python_out=. {os.path.basename(filename)}", shell=True
) )
subprocess.run( subprocess.run(
f"protoc --plugin=protoc-gen-custom=../../protoc-gen-betterpy.py --custom_out=. {os.path.basename(filename)}", f"protoc --plugin=protoc-gen-custom=../plugin.py --custom_out=. {os.path.basename(filename)}",
shell=True, shell=True,
) )

20
setup.py Normal file
View File

@ -0,0 +1,20 @@
from setuptools import setup, find_packages
setup(
name="betterproto",
version="1.0",
description="A better Protobuf / gRPC generator & library",
url="http://github.com/danielgtaylor/python-betterproto",
author="Daniel G. Taylor",
author_email="danielgtaylor@gmail.com",
license="MIT",
entry_points={
"console_scripts": ["protoc-gen-python_betterproto=betterproto.plugin:main"]
},
packages=find_packages(
exclude=["tests", "*.tests", "*.tests.*", "output", "output.*"]
),
package_data={"betterproto": ["py.typed", "templates"]},
install_requires=["grpclib", "protobuf"],
zip_safe=False,
)