diff --git a/README.md b/README.md index 385d1ea..8e9f4cd 100644 --- a/README.md +++ b/README.md @@ -312,7 +312,7 @@ $ pip install -e . This project enforces [black](https://github.com/psf/black) python code formatting. -Before commiting changes run: +Before committing changes run: ```bash pipenv run black . @@ -333,7 +333,7 @@ Adding a standard test case is easy. - Create a new directory `betterproto/tests/inputs/` - add `.proto` with a message called `Test` - - add `.json` with some test data + - add `.json` with some test data (optional) It will be picked up automatically when you run the tests. diff --git a/betterproto/tests/README.md b/betterproto/tests/README.md index 1892cea..51cd8ec 100644 --- a/betterproto/tests/README.md +++ b/betterproto/tests/README.md @@ -12,12 +12,12 @@ inputs/ ## Test case directory structure -Each testcase has a `.proto` file with a message called `Test`, a matching `.json` file and optionally a custom test file called `test_*.py`. +Each testcase has a `.proto` file with a message called `Test`, and optionally a matching `.json` file and a custom test called `test_*.py`. ```bash bool/ bool.proto - bool.json + bool.json # optional test_bool.py # optional ``` @@ -61,21 +61,22 @@ def test_value(): The following tests are automatically executed for all cases: -- [x] Can the generated python code imported? +- [x] Can the generated python code be imported? - [x] Can the generated message class be instantiated? - [x] Is the generated code compatible with the Google's `grpc_tools.protoc` implementation? + - _when `.json` is present_ ## Running the tests -- `pipenv run generate` - This generates +- `pipenv run generate` + This generates: - `betterproto/tests/output_betterproto` — *the plugin generated python classes* - `betterproto/tests/output_reference` — *reference implementation classes* - `pipenv run test` ## Intentionally Failing tests -The standard test suite includes tests that fail by intention. These tests document known bugs and missing features that are intended to be corrented in the future. +The standard test suite includes tests that fail by intention. These tests document known bugs and missing features that are intended to be corrected in the future. When running `pytest`, they show up as `x` or `X` in the test results. diff --git a/betterproto/tests/inputs/config.py b/betterproto/tests/inputs/config.py index 7b52cd5..4e416e0 100644 --- a/betterproto/tests/inputs/config.py +++ b/betterproto/tests/inputs/config.py @@ -1,6 +1,6 @@ # Test cases that are expected to fail, e.g. unimplemented features or bug-fixes. # Remove from list when fixed. -tests = { +xfail = { "import_circular_dependency", "oneof_enum", # 63 "casing_message_field_uppercase", # 11 @@ -10,19 +10,6 @@ tests = { "googletypes_value", # 9 } - -# Defines where the main package for this test resides. -# Needed to test relative package imports. -packages = { - "import_root_package_from_child": ".child", - "import_parent_package_from_child": ".parent.child", - "import_root_package_from_nested_child": ".nested.child", - "import_cousin_package": ".test.subpackage", - "import_cousin_package_same_name": ".test.subpackage", - "repeatedmessage": ".repeatedmessage", - "service": ".service", -} - services = { "googletypes_response", "googletypes_response_embedded", diff --git a/betterproto/tests/test_inputs.py b/betterproto/tests/test_inputs.py index 6778425..ede31d6 100644 --- a/betterproto/tests/test_inputs.py +++ b/betterproto/tests/test_inputs.py @@ -3,6 +3,7 @@ import json import os import sys from collections import namedtuple +from types import ModuleType from typing import Set import pytest @@ -10,7 +11,12 @@ import pytest import betterproto from betterproto.tests.inputs import config as test_input_config from betterproto.tests.mocks import MockChannel -from betterproto.tests.util import get_directories, get_test_case_json_data, inputs_path +from betterproto.tests.util import ( + find_module, + get_directories, + get_test_case_json_data, + inputs_path, +) # Force pure-python implementation instead of C++, otherwise imports # break things because we can't properly reset the symbol database. @@ -50,16 +56,17 @@ class TestCases: test_cases = TestCases( path=inputs_path, services=test_input_config.services, - xfail=test_input_config.tests, + xfail=test_input_config.xfail, ) plugin_output_package = "betterproto.tests.output_betterproto" reference_output_package = "betterproto.tests.output_reference" +TestData = namedtuple("TestData", ["plugin_module", "reference_module", "json_data"]) -TestData = namedtuple( - "TestData", ["plugin_module", "reference_module", "json_data", "entry_point"] -) + +def module_has_entry_point(module: ModuleType): + return any(hasattr(module, attr) for attr in ["Test", "TestStub"]) @pytest.fixture @@ -77,18 +84,23 @@ def test_data(request): sys.path.append(reference_module_root) - test_package = test_case_name + test_input_config.packages.get(test_case_name, "") + plugin_module = importlib.import_module(f"{plugin_output_package}.{test_case_name}") + + plugin_module_entry_point = find_module(plugin_module, module_has_entry_point) + + if not plugin_module_entry_point: + raise Exception( + f"Test case {repr(test_case_name)} has no entry point. " + + "Please add a proto message or service called Test and recompile." + ) yield ( TestData( - plugin_module=importlib.import_module( - f"{plugin_output_package}.{test_package}" - ), + plugin_module=plugin_module_entry_point, reference_module=lambda: importlib.import_module( f"{reference_output_package}.{test_case_name}.{test_case_name}_pb2" ), json_data=get_test_case_json_data(test_case_name), - entry_point=test_package, ) ) @@ -111,7 +123,7 @@ def test_message_equality(test_data: TestData) -> None: @pytest.mark.parametrize("test_data", test_cases.messages_with_json, indirect=True) def test_message_json(repeat, test_data: TestData) -> None: - plugin_module, _, json_data, entry_point = test_data + plugin_module, _, json_data = test_data for _ in range(repeat): message: betterproto.Message = plugin_module.Test() @@ -124,13 +136,13 @@ def test_message_json(repeat, test_data: TestData) -> None: @pytest.mark.parametrize("test_data", test_cases.services, indirect=True) def test_service_can_be_instantiated(test_data: TestData) -> None: - plugin_module, _, json_data, entry_point = test_data + plugin_module, _, json_data = test_data plugin_module.TestStub(MockChannel()) @pytest.mark.parametrize("test_data", test_cases.messages_with_json, indirect=True) def test_binary_compatibility(repeat, test_data: TestData) -> None: - plugin_module, reference_module, json_data, entry_point = test_data + plugin_module, reference_module, json_data = test_data reference_instance = Parse(json_data, reference_module().Test()) reference_binary_output = reference_instance.SerializeToString() diff --git a/betterproto/tests/util.py b/betterproto/tests/util.py index a7cff7a..40b10c6 100644 --- a/betterproto/tests/util.py +++ b/betterproto/tests/util.py @@ -1,6 +1,9 @@ +import importlib import os +import pathlib import subprocess -from typing import Generator +from types import ModuleType +from typing import Callable, Generator, Optional os.environ["PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION"] = "python" @@ -60,3 +63,25 @@ def get_test_case_json_data(test_case_name, json_file_name=None): with open(test_data_file_path) as fh: return fh.read() + + +def find_module( + module: ModuleType, predicate: Callable[[ModuleType], bool] +) -> Optional[ModuleType]: + if predicate(module): + return module + + module_path = pathlib.Path(*module.__path__) + + for sub in list(module_path.glob("**/")): + if not sub.is_dir() or sub == module_path: + continue + sub_module_path = sub.relative_to(module_path) + sub_module_name = ".".join(sub_module_path.parts) + + sub_module = importlib.import_module(f".{sub_module_name}", module.__name__) + + if predicate(sub_module): + return sub_module + + return None