Repository: eigenein/protobuf

Issue:
Skip None values when serialising
With the `dataclasses` interface it's not possible to omit values while serialising a message.

Discussion:

Patch:
diff --git a/README.md b/README.md
index 2f30b57..6f72ed6 100644
--- a/README.md
+++ b/README.md
@@ -101,12 +101,12 @@ class Message:
     foo: List[int32] = field(1, default_factory=list)
 ```
 
+It's also possible to wrap a field type with [`typing.Optional`](https://docs.python.org/3/library/typing.html#typing.Optional). If `None` is assigned to an `Optional` field, then the field will be skipped during serialization.
+
 ### Default values
 
 In `pure-protobuf` it's developer's responsibility to take care of default values. If encoded message does not contain a particular element, the corresponding field stays unassigned. It means that the standard `default` and `default_factory` parameters of the `field` function work as usual.
 
-It's allowed to wrap a field type with [`typing.Optional`](https://docs.python.org/3/library/typing.html#typing.Optional). It's discarded by `pure-protobuf` but allows you to hint a nullable field properly.
-
 ### Enumerations
 
 Subclasses of the standard [`IntEnum`](https://docs.python.org/3/library/enum.html#intenum) class are supported:
diff --git a/pure_protobuf/dataclasses_.py b/pure_protobuf/dataclasses_.py
index b51e930..2ce4e96 100644
--- a/pure_protobuf/dataclasses_.py
+++ b/pure_protobuf/dataclasses_.py
@@ -124,7 +124,7 @@ def make_field(number: int, name: str, type_: Any) -> Tuple[int, Field]:
     Figure out how to serialize and de-serialize the field.
     Returns the field number and a corresponding ``Field`` instance.
     """
-    type_ = remove_optional(type_)
+    is_optional, type_ = get_optional(type_)
     is_repeated, type_ = get_repeated(type_)
 
     if isinstance(type_, type) and issubclass(type_, Message):
@@ -144,7 +144,7 @@ def make_field(number: int, name: str, type_: Any) -> Tuple[int, Field]:
 
     if not is_repeated:
         # Non-repeated field.
-        return number, NonRepeatedField(number, name, serializer)
+        return number, NonRepeatedField(number, name, serializer, is_optional)
     elif serializer.wire_type != WireType.BYTES:
         # Repeated fields of scalar numeric types are packed by default.
         # See also: https://developers.google.com/protocol-buffers/docs/encoding#packed
@@ -154,9 +154,9 @@ def make_field(number: int, name: str, type_: Any) -> Tuple[int, Field]:
         return number, UnpackedRepeatedField(number, name, serializer)
 
 
-def remove_optional(type_: Any) -> Any:
+def get_optional(type_: Any) -> Tuple[bool, Any]:
     """
-    Removes ``Optional`` type annotation if present.
+    Extracts ``Optional`` type annotation if present.
     This may be useful if a user wants to annotate a field with ``Optional[...]`` and set default to ``None``.
     """
     if getattr(type_, '__origin__', None) is Union:
@@ -166,9 +166,9 @@ def remove_optional(type_: Any) -> Any:
         if len(args) == 2 and NoneType in args:
             # Extract inner type.
             type_, = args - {NoneType}
-            return type_
+            return True, type_
 
-    return type_
+    return False, type_
 
 
 def get_repeated(type_: Any) -> Tuple[bool, Any]:
diff --git a/pure_protobuf/fields.py b/pure_protobuf/fields.py
index a4976b9..0a8b4fb 100644
--- a/pure_protobuf/fields.py
+++ b/pure_protobuf/fields.py
@@ -61,8 +61,13 @@ class NonRepeatedField(Field):
     See also: https://developers.google.com/protocol-buffers/docs/encoding#optional
     """
 
+    def __init__(self, number: int, name: str, serializer: Serializer, is_optional: bool):
+        super().__init__(number, name, serializer)
+        self.is_optional = is_optional
+
     def validate(self, value: Any):
-        self.serializer.validate(value)
+        if value is not None or not self.is_optional:
+            self.serializer.validate(value)
 
     def dump(self, value: Any, io: IO):
         if value is not None:


Test patch:
diff --git a/tests/test_dataclasses.py b/tests/test_dataclasses.py
index 61af66b..25151d7 100644
--- a/tests/test_dataclasses.py
+++ b/tests/test_dataclasses.py
@@ -4,7 +4,7 @@
 
 # noinspection PyCompatibility
 from dataclasses import dataclass
-from typing import Any, List, Tuple
+from typing import Any, List, Optional, Tuple
 
 from pytest import mark, raises
 
@@ -17,6 +17,7 @@ from pure_protobuf.dataclasses_ import make_field, message
     (1, 'a', types.int32, 150, b'\x08\x96\x01'),
     (1, 'a', List[types.int32], [1, 150, 2], b'\x0A\x04\x01\x96\x01\x02'),
     (1, 'a', List[bytes], [b'\x42', b'\x43'], b'\x0A\x01\x42' b'\x0A\x01\x43'),
+    (1, 'a', Optional[bytes], None, b''),
     # TODO: repeated embedded message.
 ])
 def test_make_field(number: int, name: str, type_: Any, value: Any, expected: bytes):
diff --git a/tests/test_fields.py b/tests/test_fields.py
index feadd82..54f852d 100644
--- a/tests/test_fields.py
+++ b/tests/test_fields.py
@@ -4,7 +4,7 @@
 from io import BytesIO
 from typing import List, Optional
 
-from pytest import mark
+from pytest import mark, raises
 
 from pure_protobuf.enums import WireType
 from pure_protobuf.fields import NonRepeatedField, PackedRepeatedField, UnpackedRepeatedField
@@ -14,8 +14,8 @@ from pure_protobuf.serializers import BytesSerializer, UnsignedVarintSerializer
 @mark.parametrize('value, bytes_', [
     (b'testing', b'\x0A\x07testing'),
 ])
-def test_scalar_field(value: bytes, bytes_: bytes):
-    field = NonRepeatedField(1, 'a', BytesSerializer())
+def test_non_repeated_field(value: bytes, bytes_: bytes):
+    field = NonRepeatedField(1, 'a', BytesSerializer(), False)
     assert field.dumps(value) == bytes_
     with BytesIO(bytes_) as io:
         assert field.load(WireType(UnsignedVarintSerializer().load(io) & 0b111), io) == value
@@ -25,8 +25,16 @@ def test_scalar_field(value: bytes, bytes_: bytes):
     (1, b'\x08\x01'),
     (None, b''),
 ])
-def test_optional_scalar_field(value: Optional[int], expected: bytes):
-    assert NonRepeatedField(1, 'a', UnsignedVarintSerializer()).dumps(value) == expected
+def test_optional_non_repeated_field(value: Optional[int], expected: bytes):
+    assert NonRepeatedField(1, 'a', UnsignedVarintSerializer(), True).dumps(value) == expected
+
+
+@mark.parametrize('value', [
+    None,
+])
+def test_non_repeated_field_value_error(value: Optional[int]):
+    with raises(ValueError):
+        NonRepeatedField(1, 'a', UnsignedVarintSerializer(), False).validate(value)
 
 
 @mark.parametrize('value, bytes_', [