diff --git a/CHANGELOG.md b/CHANGELOG.md index cbb855d..bda844d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Versions suffixed with `b*` are in `beta` and can be installed with `pip install --pre betterproto`. +## [2.0.0b7] - 2024-08-11 + +- **Breaking**: Support `Pydantic` v2 and dropping support for v1 [#588](https://github.com/danielgtaylor/python-betterproto/pull/588) +- **Breaking**: The attempting to access an unset `oneof` now raises an `AttributeError` + field. To see how to access `oneof` fields now, refer to [#558](https://github.com/danielgtaylor/python-betterproto/pull/558) + and [README.md](https://github.com/danielgtaylor/python-betterproto#one-of-support). +- **Breaking**: A custom `Enum` has been implemented to match the behaviour of being an open set. Any checks for `isinstance(enum_member, enum.Enum)` and `issubclass(EnumSubclass, enum.Enum)` will now return `False`. This change also has the side effect of + preventing any passthrough of `Enum` members (i.e. `Foo.RED.GREEN` doesn't work any more). See [#293](https://github.com/danielgtaylor/python-betterproto/pull/293) for more info, this fixed many bugs related to `Enum` handling. + +- Add support for `pickle` methods [#535](https://github.com/danielgtaylor/python-betterproto/pull/535) +- Add support for `Struct` and `Value` types [#551](https://github.com/danielgtaylor/python-betterproto/pull/551) +- Add support for [`Rich` package](https://rich.readthedocs.io/en/latest/index.html) for pretty printing [#508](https://github.com/danielgtaylor/python-betterproto/pull/508) +- Improve support for streaming messages [#518](https://github.com/danielgtaylor/python-betterproto/pull/518) [#529](https://github.com/danielgtaylor/python-betterproto/pull/529) +- Improve performance of serializing / de-serializing messages [#545](https://github.com/danielgtaylor/python-betterproto/pull/545) +- Improve the handling of message name collisions with typing by allowing the method / type of imports to be configured. + Refer to [#582](https://github.com/danielgtaylor/python-betterproto/pull/582) + and [README.md](https://github.com/danielgtaylor/python-betterproto#configuration-typing-imports). +- Fix roundtrip parsing of `datetime`s [#534](https://github.com/danielgtaylor/python-betterproto/pull/534) +- Fix accessing unset optional fields [#523](https://github.com/danielgtaylor/python-betterproto/pull/523) +- Fix `Message` equality comparison [#513](https://github.com/danielgtaylor/python-betterproto/pull/513) +- Fix behaviour with long comment messages [#532](https://github.com/danielgtaylor/python-betterproto/pull/532) +- Add a warning when calling a deprecated message [#596](https://github.com/danielgtaylor/python-betterproto/pull/596) + ## [2.0.0b6] - 2023-06-25 - **Breaking**: the minimum Python version has been bumped to `3.7` [#444](https://github.com/danielgtaylor/python-betterproto/pull/444) diff --git a/README.md b/README.md index b9ef624..4bbfc17 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Better Protobuf / gRPC Support for Python -![](https://github.com/danielgtaylor/python-betterproto/workflows/CI/badge.svg) +![](https://github.com/danielgtaylor/python-betterproto/actions/workflows/ci.yml/badge.svg) + > :octocat: If you're reading this on github, please be aware that it might mention unreleased features! See the latest released README on [pypi](https://pypi.org/project/betterproto/). 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 (e.g. Protobuf 2). The following are supported: diff --git a/pyproject.toml b/pyproject.toml index e31c82b..b8e9eb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "betterproto" -version = "2.0.0b6" +version = "2.0.0b7" description = "A better Protobuf / gRPC generator & library" authors = ["Daniel G. Taylor "] readme = "README.md" diff --git a/src/betterproto/__init__.py b/src/betterproto/__init__.py index 56a368c..8721434 100644 --- a/src/betterproto/__init__.py +++ b/src/betterproto/__init__.py @@ -62,6 +62,13 @@ if TYPE_CHECKING: SupportsWrite, ) +if sys.version_info >= (3, 10): + from types import UnionType as _types_UnionType +else: + + class _types_UnionType: + ... + # Proto 3 data types TYPE_ENUM = "enum" @@ -148,6 +155,7 @@ def datetime_default_gen() -> datetime: DATETIME_ZERO = datetime_default_gen() + # Special protobuf json doubles INFINITY = "Infinity" NEG_INFINITY = "-Infinity" @@ -1166,30 +1174,29 @@ class Message(ABC): def _get_field_default_gen(cls, field: dataclasses.Field) -> Any: t = cls._type_hint(field.name) - if hasattr(t, "__origin__"): - if t.__origin__ is dict: - # This is some kind of map (dict in Python). - return dict - elif t.__origin__ is list: - # This is some kind of list (repeated) field. - return list - elif t.__origin__ is Union and t.__args__[1] is type(None): + is_310_union = isinstance(t, _types_UnionType) + if hasattr(t, "__origin__") or is_310_union: + if is_310_union or t.__origin__ is Union: # This is an optional field (either wrapped, or using proto3 # field presence). For setting the default we really don't care # what kind of field it is. return type(None) - else: - return t - elif issubclass(t, Enum): + if t.__origin__ is list: + # This is some kind of list (repeated) field. + return list + if t.__origin__ is dict: + # This is some kind of map (dict in Python). + return dict + return t + if issubclass(t, Enum): # Enums always default to zero. return t.try_value - elif t is datetime: + if t is datetime: # Offsets are relative to 1970-01-01T00:00:00Z return datetime_default_gen - else: - # This is either a primitive scalar or another message type. Calling - # it should result in its zero value. - return t + # This is either a primitive scalar or another message type. Calling + # it should result in its zero value. + return t def _postprocess_single( self, wire_type: int, meta: FieldMetadata, field_name: str, value: Any diff --git a/src/betterproto/compile/importing.py b/src/betterproto/compile/importing.py index 4221122..b216dfc 100644 --- a/src/betterproto/compile/importing.py +++ b/src/betterproto/compile/importing.py @@ -1,6 +1,9 @@ +from __future__ import annotations + import os import re from typing import ( + TYPE_CHECKING, Dict, List, Set, @@ -13,6 +16,9 @@ from ..lib.google import protobuf as google_protobuf from .naming import pythonize_class_name +if TYPE_CHECKING: + from ..plugin.typing_compiler import TypingCompiler + WRAPPER_TYPES: Dict[str, Type] = { ".google.protobuf.DoubleValue": google_protobuf.DoubleValue, ".google.protobuf.FloatValue": google_protobuf.FloatValue, @@ -47,7 +53,7 @@ def get_type_reference( package: str, imports: set, source_type: str, - typing_compiler: "TypingCompiler", + typing_compiler: TypingCompiler, unwrap: bool = True, pydantic: bool = False, ) -> str: diff --git a/src/betterproto/plugin/models.py b/src/betterproto/plugin/models.py index cf14cad..03834a7 100644 --- a/src/betterproto/plugin/models.py +++ b/src/betterproto/plugin/models.py @@ -275,8 +275,21 @@ class OutputTemplate: @property def python_module_imports(self) -> Set[str]: imports = set() + + has_deprecated = False + if any(m.deprecated for m in self.messages): + has_deprecated = True if any(x for x in self.messages if any(x.deprecated_fields)): + has_deprecated = True + if any( + any(m.proto_obj.options.deprecated for m in s.methods) + for s in self.services + ): + has_deprecated = True + + if has_deprecated: imports.add("warnings") + if self.builtins_import: imports.add("builtins") return imports diff --git a/src/betterproto/plugin/typing_compiler.py b/src/betterproto/plugin/typing_compiler.py index 937c7bf..eca3691 100644 --- a/src/betterproto/plugin/typing_compiler.py +++ b/src/betterproto/plugin/typing_compiler.py @@ -139,29 +139,35 @@ class TypingImportTypingCompiler(TypingCompiler): class NoTyping310TypingCompiler(TypingCompiler): _imports: Dict[str, Set[str]] = field(default_factory=lambda: defaultdict(set)) + @staticmethod + def _fmt(type: str) -> str: # for now this is necessary till 3.14 + if type.startswith('"'): + return type[1:-1] + return type + def optional(self, type: str) -> str: - return f"{type} | None" + return f'"{self._fmt(type)} | None"' def list(self, type: str) -> str: - return f"list[{type}]" + return f'"list[{self._fmt(type)}]"' def dict(self, key: str, value: str) -> str: - return f"dict[{key}, {value}]" + return f'"dict[{key}, {self._fmt(value)}]"' def union(self, *types: str) -> str: - return " | ".join(types) + return f'"{" | ".join(map(self._fmt, types))}"' def iterable(self, type: str) -> str: - self._imports["typing"].add("Iterable") - return f"Iterable[{type}]" + self._imports["collections.abc"].add("Iterable") + return f'"Iterable[{type}]"' def async_iterable(self, type: str) -> str: - self._imports["typing"].add("AsyncIterable") - return f"AsyncIterable[{type}]" + self._imports["collections.abc"].add("AsyncIterable") + return f'"AsyncIterable[{type}]"' def async_iterator(self, type: str) -> str: - self._imports["typing"].add("AsyncIterator") - return f"AsyncIterator[{type}]" + self._imports["collections.abc"].add("AsyncIterator") + return f'"AsyncIterator[{type}]"' def imports(self) -> Dict[str, Optional[Set[str]]]: return {k: v if v else None for k, v in self._imports.items()} diff --git a/src/betterproto/templates/template.py.j2 b/src/betterproto/templates/template.py.j2 index 24c927c..c10793d 100644 --- a/src/betterproto/templates/template.py.j2 +++ b/src/betterproto/templates/template.py.j2 @@ -84,6 +84,10 @@ class {{ service.py_name }}Stub(betterproto.ServiceStub): {% if method.comment %} {{ method.comment }} + {% endif %} + {% if method.proto_obj.options.deprecated %} + warnings.warn("{{ service.py_name }}.{{ method.py_name }} is deprecated", DeprecationWarning) + {% endif %} {% if method.server_streaming %} {% if method.client_streaming %} diff --git a/tests/inputs/deprecated/deprecated.proto b/tests/inputs/deprecated/deprecated.proto index 81d69c0..f504d03 100644 --- a/tests/inputs/deprecated/deprecated.proto +++ b/tests/inputs/deprecated/deprecated.proto @@ -12,3 +12,10 @@ message Message { option deprecated = true; string value = 1; } + +message Empty {} + +service TestService { + rpc func(Empty) returns (Empty); + rpc deprecated_func(Empty) returns (Empty) { option deprecated = true; }; +} diff --git a/tests/test_deprecated.py b/tests/test_deprecated.py index 84e08bd..c1caead 100644 --- a/tests/test_deprecated.py +++ b/tests/test_deprecated.py @@ -2,9 +2,12 @@ import warnings import pytest +from tests.mocks import MockChannel from tests.output_betterproto.deprecated import ( + Empty, Message, Test, + TestServiceStub, ) @@ -43,3 +46,19 @@ def test_message_with_deprecated_field_not_set_default(message): _ = Test(value=10).message assert not record + + +@pytest.mark.asyncio +async def test_service_with_deprecated_method(): + stub = TestServiceStub(MockChannel([Empty(), Empty()])) + + with pytest.warns(DeprecationWarning) as record: + await stub.deprecated_func(Empty()) + + assert len(record) == 1 + assert str(record[0].message) == f"TestService.deprecated_func is deprecated" + + with pytest.warns(None) as record: + await stub.func(Empty()) + + assert not record diff --git a/tests/test_typing_compiler.py b/tests/test_typing_compiler.py index 3d1083c..ee17449 100644 --- a/tests/test_typing_compiler.py +++ b/tests/test_typing_compiler.py @@ -62,19 +62,17 @@ def test_typing_import_typing_compiler(): def test_no_typing_311_typing_compiler(): compiler = NoTyping310TypingCompiler() assert compiler.imports() == {} - assert compiler.optional("str") == "str | None" + assert compiler.optional("str") == '"str | None"' assert compiler.imports() == {} - assert compiler.list("str") == "list[str]" + assert compiler.list("str") == '"list[str]"' assert compiler.imports() == {} - assert compiler.dict("str", "int") == "dict[str, int]" + assert compiler.dict("str", "int") == '"dict[str, int]"' assert compiler.imports() == {} - assert compiler.union("str", "int") == "str | int" + assert compiler.union("str", "int") == '"str | int"' assert compiler.imports() == {} - assert compiler.iterable("str") == "Iterable[str]" - assert compiler.imports() == {"typing": {"Iterable"}} - assert compiler.async_iterable("str") == "AsyncIterable[str]" - assert compiler.imports() == {"typing": {"Iterable", "AsyncIterable"}} - assert compiler.async_iterator("str") == "AsyncIterator[str]" + assert compiler.iterable("str") == '"Iterable[str]"' + assert compiler.async_iterable("str") == '"AsyncIterable[str]"' + assert compiler.async_iterator("str") == '"AsyncIterator[str]"' assert compiler.imports() == { - "typing": {"Iterable", "AsyncIterable", "AsyncIterator"} + "collections.abc": {"Iterable", "AsyncIterable", "AsyncIterator"} }