Skip to content

Commit 7fe64ad

Browse files
committed
Doc updates, refactor code layout, python package
1 parent 811b54c commit 7fe64ad

File tree

9 files changed

+270
-22
lines changed

9 files changed

+270
-22
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,6 @@ betterproto/tests/*.py
88
!betterproto/tests/generate.py
99
!betterproto/tests/test_*.py
1010
**/__pycache__
11+
dist
12+
**/*.egg-info
13+
output

MANIFEST.in

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
recursive-exclude tests *
2+
exclude output

Pipfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,6 @@ grpclib = "*"
1919
python_version = "3.7"
2020

2121
[scripts]
22-
plugin = "protoc --plugin=protoc-gen-custom=protoc-gen-betterpy.py --custom_out=output"
22+
plugin = "protoc --plugin=protoc-gen-custom=betterproto/plugin.py --custom_out=output"
2323
generate = "python betterproto/tests/generate.py"
2424
test = "pytest ./betterproto/tests"

README.md

Lines changed: 211 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
# Better Protobuf / gRPC Support for Python
22

3-
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:
3+
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:
44

55
- Protobuf 3 & gRPC code generation
66
- Both binary & JSON serialization is built-in
7-
- Python 3.7+
7+
- Python 3.7+ making use of:
88
- Enums
99
- Dataclasses
1010
- `async`/`await`
@@ -17,7 +17,198 @@ This project is heavily inspired by, and borrows functionality from:
1717
- https://github.com/eigenein/protobuf/
1818
- https://github.com/vmagamedov/grpclib
1919

20-
## TODO
20+
## Motivation
21+
22+
This project exists because I am unhappy with the state of the official Google protoc plugin for Python.
23+
24+
- No `async` support (requires additional `grpclib` plugin)
25+
- No typing support or code completion/intelligence (requires additional `mypy` plugin)
26+
- No `__init__.py` module files get generated
27+
- Output is not importable
28+
- Import paths break in Python 3 unless you mess with `sys.path`
29+
- Bugs when names clash (e.g. `codecs` package)
30+
- Generated code is not idiomatic
31+
- Completely unreadable runtime code-generation
32+
- Much code looks like C++ or Java ported 1:1 to Python
33+
- Capitalized function names like `HasField()` and `SerializeToString()`
34+
- Uses `SerializeToString()` rather than the built-in `__bytes__()`
35+
36+
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.
37+
38+
## Installation & Getting Started
39+
40+
First, install the package:
41+
42+
```sh
43+
$ pip install betterproto
44+
```
45+
46+
Now, given a proto file, e.g `example.proto`:
47+
48+
```protobuf
49+
syntax = "proto3";
50+
51+
package hello;
52+
53+
// Greeting represents a message you can tell a user.
54+
message Greeting {
55+
string message = 1;
56+
}
57+
```
58+
59+
You can run the following:
60+
61+
```sh
62+
$ protoc -I . --python_betterproto_out=. example.proto
63+
```
64+
65+
This will generate `hello.py` which looks like:
66+
67+
```py
68+
# Generated by the protocol buffer compiler. DO NOT EDIT!
69+
# sources: hello.proto
70+
# plugin: python-betterproto
71+
from dataclasses import dataclass
72+
73+
import betterproto
74+
75+
76+
@dataclass
77+
class Hello(betterproto.Message):
78+
"""Greeting represents a message you can tell a user."""
79+
80+
message: str = betterproto.string_field(1)
81+
```
82+
83+
Now you can use it!
84+
85+
```py
86+
>>> from hello import Hello
87+
>>> test = Hello()
88+
>>> test
89+
Hello(message='')
90+
91+
>>> test.message = "Hey!"
92+
>>> test
93+
Hello(message="Hey!")
94+
95+
>>> serialized = bytes(test)
96+
>>> serialized
97+
b'\n\x04Hey!'
98+
99+
>>> another = Hello().parse(serialized)
100+
>>> another
101+
Hello(message="Hey!")
102+
103+
>>> another.to_dict()
104+
{"message": "Hey!"}
105+
>>> another.to_json(indent=2)
106+
'{\n "message": "Hey!"\n}'
107+
```
108+
109+
### Async gRPC Support
110+
111+
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.
112+
113+
Given an example like:
114+
115+
```protobuf
116+
syntax = "proto3";
117+
118+
package echo;
119+
120+
message EchoRequest {
121+
string value = 1;
122+
// Number of extra times to echo
123+
uint32 extra_times = 2;
124+
}
125+
126+
message EchoResponse {
127+
repeated string values = 1;
128+
}
129+
130+
message EchoStreamResponse {
131+
string value = 1;
132+
}
133+
134+
service Echo {
135+
rpc Echo(EchoRequest) returns (EchoResponse);
136+
rpc EchoStream(EchoRequest) returns (stream EchoStreamResponse);
137+
}
138+
```
139+
140+
You can use it like so (enable async in the interactive shell first):
141+
142+
```py
143+
>>> import echo
144+
>>> from grpclib.client import Channel
145+
146+
>>> channel = Channel(host="127.0.0.1", port=1234)
147+
>>> service = echo.EchoStub(channel)
148+
>>> await service.echo(value="hello", extra_times=1)
149+
EchoResponse(values=["hello", "hello"])
150+
151+
>>> async for response in service.echo_stream(value="hello", extra_times=1)
152+
print(response)
153+
154+
EchoStreamResponse(value="hello")
155+
EchoStreamResponse(value="hello")
156+
```
157+
158+
### JSON
159+
160+
Both serializing and parsing are supported to/from JSON and Python dictionaries using the following methods:
161+
162+
- Dicts: `Message().to_dict()`, `Message().from_dict(...)`
163+
- JSON: `Message().to_json()`, `Message().from_json(...)`
164+
165+
### Determining if a message was sent
166+
167+
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.
168+
169+
Use `Message().serialized_on_wire` to determine if it was sent. This is a little bit different from the official Google generated Python code:
170+
171+
```py
172+
# Old way
173+
>>> mymessage.HasField('myfield')
174+
175+
# New way
176+
>>> mymessage.myfield.serialized_on_wire
177+
```
178+
179+
## Development
180+
181+
First, make sure you have Python 3.7+ and `pipenv` installed:
182+
183+
```sh
184+
# Get set up with the virtual env & dependencies
185+
$ pipenv install --dev
186+
187+
# Link the local package
188+
$ pipenv shell
189+
$ pip install -e .
190+
```
191+
192+
### Tests
193+
194+
There are two types of tests:
195+
196+
1. Manually-written tests for some behavior of the library
197+
2. Proto files and JSON inputs for automated tests
198+
199+
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.
200+
201+
Here's how to run the tests.
202+
203+
```sh
204+
# Generate assets from sample .proto files
205+
$ pipenv run generate
206+
207+
# Run the tests
208+
$ pipenv run tests
209+
```
210+
211+
### TODO
21212

22213
- [x] Fixed length fields
23214
- [x] Packed fixed-length
@@ -30,11 +221,26 @@ This project is heavily inspired by, and borrows functionality from:
30221
- [ ] Support passthrough of unknown fields
31222
- [x] Refs to nested types
32223
- [x] Imports in proto files
33-
- [ ] Well-known Google types
224+
- [x] Well-known Google types
34225
- [ ] JSON that isn't completely naive.
226+
- [x] 64-bit ints as strings
227+
- [x] Maps
228+
- [x] Lists
229+
- [ ] Bytes as base64
230+
- [ ] Any support
231+
- [ ] Well known types support (timestamp, duration, wrappers)
35232
- [ ] Async service stubs
36233
- [x] Unary-unary
37234
- [x] Server streaming response
38235
- [ ] Client streaming request
39-
- [ ] Python package
236+
- [ ] Renaming messages and fields to conform to Python name standards
237+
- [ ] Renaming clashes with language keywords and standard library top-level packages
238+
- [x] Python package
239+
- [ ] Automate running tests
40240
- [ ] Cleanup!
241+
242+
## License
243+
244+
Copyright © 2019 Daniel G. Taylor
245+
246+
http://dgt.mit-license.org/

betterproto/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -633,9 +633,9 @@ def from_dict(self: T, value: dict) -> T:
633633
setattr(self, field.name, v)
634634
return self
635635

636-
def to_json(self) -> str:
636+
def to_json(self, indent: Union[None, int, str] = None) -> str:
637637
"""Returns the encoded JSON representation of this message instance."""
638-
return json.dumps(self.to_dict())
638+
return json.dumps(self.to_dict(), indent=indent)
639639

640640
def from_json(self: T, value: Union[str, bytes]) -> T:
641641
"""

protoc-gen-betterpy.py renamed to betterproto/plugin.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import textwrap
99
from typing import Any, List, Tuple
1010

11-
from jinja2 import Environment, PackageLoader
11+
import jinja2
1212

1313
from google.protobuf.compiler import plugin_pb2 as plugin
1414
from google.protobuf.descriptor_pb2 import (
@@ -130,12 +130,12 @@ def get_comment(proto_file, path: List[int]) -> str:
130130

131131

132132
def generate_code(request, response):
133-
env = Environment(
133+
env = jinja2.Environment(
134134
trim_blocks=True,
135135
lstrip_blocks=True,
136-
loader=PackageLoader("betterproto", "templates"),
136+
loader=jinja2.FileSystemLoader("%s/templates/" % os.path.dirname(__file__)),
137137
)
138-
template = env.get_template("main.py")
138+
template = env.get_template("template.py")
139139

140140
output_map = {}
141141
for proto_file in request.proto_file:
@@ -157,6 +157,7 @@ def generate_code(request, response):
157157
"package": package,
158158
"files": [f.name for f in options["files"]],
159159
"imports": set(),
160+
"typing_imports": set(),
160161
"messages": [],
161162
"enums": [],
162163
"services": [],
@@ -229,12 +230,14 @@ def generate_code(request, response):
229230
f.Type.Name(nested.field[0].type),
230231
f.Type.Name(nested.field[1].type),
231232
)
233+
output["typing_imports"].add("Dict")
232234

233235
if f.label == 3 and field_type != "map":
234236
# Repeated field
235237
repeated = True
236238
t = f"List[{t}]"
237239
zero = "[]"
240+
output["typing_imports"].add("List")
238241

239242
if f.type in [1, 2, 3, 4, 5, 6, 7, 8, 13, 15, 16, 17, 18]:
240243
packed = True
@@ -292,6 +295,9 @@ def generate_code(request, response):
292295
for msg in output["messages"]:
293296
if msg["name"] == input_type:
294297
input_message = msg
298+
for field in msg["properties"]:
299+
if field["zero"] == "None":
300+
output["typing_imports"].add("Optional")
295301
break
296302

297303
data["methods"].append(
@@ -311,9 +317,13 @@ def generate_code(request, response):
311317
}
312318
)
313319

320+
if method.server_streaming:
321+
output["typing_imports"].add("AsyncGenerator")
322+
314323
output["services"].append(data)
315324

316325
output["imports"] = sorted(output["imports"])
326+
output["typing_imports"] = sorted(output["typing_imports"])
317327

318328
# Fill response
319329
f = response.file.add()
@@ -341,7 +351,8 @@ def generate_code(request, response):
341351
init.content = b""
342352

343353

344-
if __name__ == "__main__":
354+
def main():
355+
"""The plugin's main entry point."""
345356
# Read request message from stdin
346357
data = sys.stdin.buffer.read()
347358

@@ -360,3 +371,7 @@ def generate_code(request, response):
360371

361372
# Write to stdout
362373
sys.stdout.buffer.write(output)
374+
375+
376+
if __name__ == "__main__":
377+
main()

betterproto/templates/main.py renamed to betterproto/templates/template.py

Lines changed: 4 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)