Skip to content

Commit b711d1e

Browse files
Merge remote-tracking branch 'daniel/master' into pr/wrapper-as-output
2 parents 7e9ba08 + 5e2d9fe commit b711d1e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

64 files changed

+1087
-122
lines changed

.gitignore

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,9 @@
44
.pytest_cache
55
.python-version
66
build/
7-
betterproto/tests/*.bin
8-
betterproto/tests/*_pb2.py
9-
betterproto/tests/*.py
10-
!betterproto/tests/generate.py
11-
!betterproto/tests/test_*.py
7+
betterproto/tests/output_*
128
**/__pycache__
139
dist
1410
**/*.egg-info
1511
output
12+
.idea

Pipfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ flake8 = "*"
88
mypy = "*"
99
isort = "*"
1010
pytest = "*"
11+
pytest-asyncio = "*"
1112
rope = "*"
1213
v = {editable = true,version = "*"}
1314

Pipfile.lock

Lines changed: 464 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -311,18 +311,34 @@ $ pip install -e .
311311

312312
There are two types of tests:
313313

314-
1. Manually-written tests for some behavior of the library
315-
2. Proto files and JSON inputs for automated tests
314+
1. Standard tests
315+
2. Custom tests
316316

317-
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.
317+
#### Standard tests
318+
319+
Adding a standard test case is easy.
320+
321+
- Create a new directory `betterproto/tests/inputs/<name>`
322+
- add `<name>.proto` with a message called `Test`
323+
- add `<name>.json` with some test data
324+
325+
It will be picked up automatically when you run the tests.
326+
327+
- See also: [Standard Tests Development Guide](betterproto/tests/README.md)
328+
329+
#### Custom tests
330+
331+
Custom tests are found in `tests/test_*.py` and are run with pytest.
332+
333+
#### Running
318334

319335
Here's how to run the tests.
320336

321337
```sh
322338
# Generate assets from sample .proto files
323339
$ pipenv run generate
324340

325-
# Run the tests
341+
# Run all tests
326342
$ pipenv run test
327343
```
328344

@@ -340,6 +356,9 @@ $ pipenv run test
340356
- [x] Refs to nested types
341357
- [x] Imports in proto files
342358
- [x] Well-known Google types
359+
- [ ] Support as request input
360+
- [ ] Support as response output
361+
- [ ] Automatically wrap/unwrap responses
343362
- [x] OneOf support
344363
- [x] Basic support on the wire
345364
- [x] Check which was set from the group

betterproto/__init__.py

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@
1111
Any,
1212
AsyncGenerator,
1313
Callable,
14+
Collection,
1415
Dict,
1516
Generator,
1617
Iterable,
1718
List,
19+
Mapping,
1820
Optional,
1921
SupportsBytes,
2022
Tuple,
@@ -1000,32 +1002,80 @@ def _get_wrapper(proto_type: str) -> Type:
10001002
}[proto_type]
10011003

10021004

1005+
_Value = Union[str, bytes]
1006+
_MetadataLike = Union[Mapping[str, _Value], Collection[Tuple[str, _Value]]]
1007+
1008+
10031009
class ServiceStub(ABC):
10041010
"""
10051011
Base class for async gRPC service stubs.
10061012
"""
10071013

1008-
def __init__(self, channel: grpclib.client.Channel) -> None:
1014+
def __init__(
1015+
self,
1016+
channel: grpclib.client.Channel,
1017+
*,
1018+
timeout: Optional[float] = None,
1019+
deadline: Optional[grpclib.metadata.Deadline] = None,
1020+
metadata: Optional[_MetadataLike] = None,
1021+
) -> None:
10091022
self.channel = channel
1023+
self.timeout = timeout
1024+
self.deadline = deadline
1025+
self.metadata = metadata
1026+
1027+
def __resolve_request_kwargs(
1028+
self,
1029+
timeout: Optional[float],
1030+
deadline: Optional[grpclib.metadata.Deadline],
1031+
metadata: Optional[_MetadataLike],
1032+
):
1033+
return {
1034+
"timeout": self.timeout if timeout is None else timeout,
1035+
"deadline": self.deadline if deadline is None else deadline,
1036+
"metadata": self.metadata if metadata is None else metadata,
1037+
}
10101038

10111039
async def _unary_unary(
1012-
self, route: str, request: "IProtoMessage", response_type: Type[T]
1040+
self,
1041+
route: str,
1042+
request: "IProtoMessage",
1043+
response_type: Type[T],
1044+
*,
1045+
timeout: Optional[float] = None,
1046+
deadline: Optional[grpclib.metadata.Deadline] = None,
1047+
metadata: Optional[_MetadataLike] = None,
10131048
) -> T:
10141049
"""Make a unary request and return the response."""
10151050
async with self.channel.request(
1016-
route, grpclib.const.Cardinality.UNARY_UNARY, type(request), response_type
1051+
route,
1052+
grpclib.const.Cardinality.UNARY_UNARY,
1053+
type(request),
1054+
response_type,
1055+
**self.__resolve_request_kwargs(timeout, deadline, metadata),
10171056
) as stream:
10181057
await stream.send_message(request, end=True)
10191058
response = await stream.recv_message()
10201059
assert response is not None
10211060
return response
10221061

10231062
async def _unary_stream(
1024-
self, route: str, request: "IProtoMessage", response_type: Type[T]
1063+
self,
1064+
route: str,
1065+
request: "IProtoMessage",
1066+
response_type: Type[T],
1067+
*,
1068+
timeout: Optional[float] = None,
1069+
deadline: Optional[grpclib.metadata.Deadline] = None,
1070+
metadata: Optional[_MetadataLike] = None,
10251071
) -> AsyncGenerator[T, None]:
10261072
"""Make a unary request and return the stream response iterator."""
10271073
async with self.channel.request(
1028-
route, grpclib.const.Cardinality.UNARY_STREAM, type(request), response_type
1074+
route,
1075+
grpclib.const.Cardinality.UNARY_STREAM,
1076+
type(request),
1077+
response_type,
1078+
**self.__resolve_request_kwargs(timeout, deadline, metadata),
10291079
) as stream:
10301080
await stream.send_message(request, end=True)
10311081
async for message in stream:

betterproto/plugin.bat

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
@SET plugin_dir=%~dp0
2+
@python %plugin_dir%/plugin.py %*

betterproto/plugin.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ def get_py_zero(type_num: int) -> str:
138138

139139

140140
def traverse(proto_file):
141-
def _traverse(path, items, prefix = ''):
141+
def _traverse(path, items, prefix=""):
142142
for i, item in enumerate(items):
143143
# Adjust the name since we flatten the heirarchy.
144144
item.name = next_prefix = prefix + item.name

betterproto/tests/README.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Standard Tests Development Guide
2+
3+
Standard test cases are found in [betterproto/tests/inputs](inputs), where each subdirectory represents a testcase, that is verified in isolation.
4+
5+
```
6+
inputs/
7+
bool/
8+
double/
9+
int32/
10+
...
11+
```
12+
13+
## Test case directory structure
14+
15+
Each testcase has a `<name>.proto` file with a message called `Test`, a matching `.json` file and optionally a custom test file called `test_*.py`.
16+
17+
```bash
18+
bool/
19+
bool.proto
20+
bool.json
21+
test_bool.py # optional
22+
```
23+
24+
### proto
25+
26+
`<name>.proto` &mdash; *The protobuf message to test*
27+
28+
```protobuf
29+
syntax = "proto3";
30+
31+
message Test {
32+
bool value = 1;
33+
}
34+
```
35+
36+
You can add multiple `.proto` files to the test case, as long as one file matches the directory name.
37+
38+
### json
39+
40+
`<name>.json` &mdash; *Test-data to validate the message with*
41+
42+
```json
43+
{
44+
"value": true
45+
}
46+
```
47+
48+
### pytest
49+
50+
`test_<name>.py` &mdash; *Custom test to validate specific aspects of the generated class*
51+
52+
```python
53+
from betterproto.tests.output_betterproto.bool.bool import Test
54+
55+
def test_value():
56+
message = Test()
57+
assert not message.value, "Boolean is False by default"
58+
```
59+
60+
## Standard tests
61+
62+
The following tests are automatically executed for all cases:
63+
64+
- [x] Can the generated python code imported?
65+
- [x] Can the generated message class be instantiated?
66+
- [x] Is the generated code compatible with the Google's `grpc_tools.protoc` implementation?
67+
68+
## Running the tests
69+
70+
- `pipenv run generate`
71+
This generates
72+
- `betterproto/tests/output_betterproto` &mdash; *the plugin generated python classes*
73+
- `betterproto/tests/output_reference` &mdash; *reference implementation classes*
74+
- `pipenv run test`
75+

betterproto/tests/generate.py

Lines changed: 53 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,84 +1,74 @@
11
#!/usr/bin/env python
22
import os
3+
import sys
4+
from typing import Set
5+
6+
from betterproto.tests.util import (
7+
get_directories,
8+
inputs_path,
9+
output_path_betterproto,
10+
output_path_reference,
11+
protoc_plugin,
12+
protoc_reference,
13+
)
314

415
# Force pure-python implementation instead of C++, otherwise imports
516
# break things because we can't properly reset the symbol database.
617
os.environ["PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION"] = "python"
718

8-
import importlib
9-
import json
10-
import subprocess
11-
import sys
12-
from typing import Generator, Tuple
13-
14-
from google.protobuf import symbol_database
15-
from google.protobuf.descriptor_pool import DescriptorPool
16-
from google.protobuf.json_format import MessageToJson, Parse
17-
18-
19-
root = os.path.dirname(os.path.realpath(__file__))
2019

20+
def generate(whitelist: Set[str]):
21+
path_whitelist = {os.path.realpath(e) for e in whitelist if os.path.exists(e)}
22+
name_whitelist = {e for e in whitelist if not os.path.exists(e)}
2123

22-
def get_files(end: str) -> Generator[str, None, None]:
23-
for r, dirs, files in os.walk(root):
24-
for filename in [f for f in files if f.endswith(end)]:
25-
yield os.path.join(r, filename)
24+
test_case_names = set(get_directories(inputs_path))
2625

26+
for test_case_name in sorted(test_case_names):
27+
test_case_path = os.path.realpath(os.path.join(inputs_path, test_case_name))
2728

28-
def get_base(filename: str) -> str:
29-
return os.path.splitext(os.path.basename(filename))[0]
29+
if (
30+
whitelist
31+
and test_case_path not in path_whitelist
32+
and test_case_name not in name_whitelist
33+
):
34+
continue
3035

36+
case_output_dir_reference = os.path.join(output_path_reference, test_case_name)
37+
case_output_dir_betterproto = os.path.join(
38+
output_path_betterproto, test_case_name
39+
)
3140

32-
def ensure_ext(filename: str, ext: str) -> str:
33-
if not filename.endswith(ext):
34-
return filename + ext
35-
return filename
41+
print(f"Generating output for {test_case_name}")
42+
os.makedirs(case_output_dir_reference, exist_ok=True)
43+
os.makedirs(case_output_dir_betterproto, exist_ok=True)
3644

45+
protoc_reference(test_case_path, case_output_dir_reference)
46+
protoc_plugin(test_case_path, case_output_dir_betterproto)
3747

38-
if __name__ == "__main__":
39-
os.chdir(root)
40-
41-
if len(sys.argv) > 1:
42-
proto_files = [ensure_ext(f, ".proto") for f in sys.argv[1:]]
43-
bases = {get_base(f) for f in proto_files}
44-
json_files = [
45-
f for f in get_files(".json") if get_base(f).split("-")[0] in bases
46-
]
47-
else:
48-
proto_files = get_files(".proto")
49-
json_files = get_files(".json")
50-
51-
for filename in proto_files:
52-
print(f"Generating code for {os.path.basename(filename)}")
53-
subprocess.run(
54-
f"protoc --python_out=. {os.path.basename(filename)}", shell=True
55-
)
56-
subprocess.run(
57-
f"protoc --plugin=protoc-gen-custom=../plugin.py --custom_out=. {os.path.basename(filename)}",
58-
shell=True,
59-
)
6048

61-
for filename in json_files:
62-
# Reset the internal symbol database so we can import the `Test` message
63-
# multiple times. Ugh.
64-
sym = symbol_database.Default()
65-
sym.pool = DescriptorPool()
49+
HELP = "\n".join(
50+
[
51+
"Usage: python generate.py",
52+
" python generate.py [DIRECTORIES or NAMES]",
53+
"Generate python classes for standard tests.",
54+
"",
55+
"DIRECTORIES One or more relative or absolute directories of test-cases to generate classes for.",
56+
" python generate.py inputs/bool inputs/double inputs/enum",
57+
"",
58+
"NAMES One or more test-case names to generate classes for.",
59+
" python generate.py bool double enums",
60+
]
61+
)
6662

67-
parts = get_base(filename).split("-")
68-
out = filename.replace(".json", ".bin")
69-
print(f"Using {parts[0]}_pb2 to generate {os.path.basename(out)}")
7063

71-
imported = importlib.import_module(f"{parts[0]}_pb2")
72-
input_json = open(filename).read()
73-
parsed = Parse(input_json, imported.Test())
74-
serialized = parsed.SerializeToString()
75-
preserve = "casing" not in filename
76-
serialized_json = MessageToJson(parsed, preserving_proto_field_name=preserve)
64+
def main():
65+
if set(sys.argv).intersection({"-h", "--help"}):
66+
print(HELP)
67+
return
68+
whitelist = set(sys.argv[1:])
7769

78-
s_loaded = json.loads(serialized_json)
79-
in_loaded = json.loads(input_json)
70+
generate(whitelist)
8071

81-
if s_loaded != in_loaded:
82-
raise AssertionError("Expected JSON to be equal:", s_loaded, in_loaded)
8372

84-
open(out, "wb").write(serialized)
73+
if __name__ == "__main__":
74+
main()
File renamed without changes.

0 commit comments

Comments
 (0)