Repository: eigenein/protobuf

Issue:
Decoding of oneof fields only works for the last specified field
I am finding that only the last specified field can be successfully decoded for a `oneof` field.

For example, the assertion in this round-trip test fails:

```python
#!/usr/bin/env python3
from pure_protobuf.annotations import Field
from pure_protobuf.message import BaseMessage
from pure_protobuf.one_of import OneOf
from typing_extensions import Annotated
from dataclasses import dataclass
from typing import ClassVar, Optional


@dataclass
class Message(BaseMessage):
    payload: ClassVar[OneOf] = OneOf()
    which_payload = payload.which_one_of_getter()

    foo: Annotated[Optional[int], Field(1, one_of=payload)] = None
    bar: Annotated[Optional[bool], Field(2, one_of=payload)] = None


message = Message()
message.foo = 3

message = Message.loads(message.dumps())
print(message)
assert message.foo == 3
```

The output from the `print` statement is:
```
Message(foo=None, bar=None)
```

If the example is modified so it sets and asserts against the `bar` field, then it works fine.

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/eigenein/protobuf/pull/174?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pavel+Perestoronin) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 98.08%. Comparing base [(`2bb35d3`)](https://app.codecov.io/gh/eigenein/protobuf/commit/2bb35d3490851cfcea7dfdd7d8f4b23d2fa09924?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pavel+Perestoronin) to head [(`67126d0`)](https://app.codecov.io/gh/eigenein/protobuf/commit/67126d0a320301e774f4396f11b4a50e80a491f7?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pavel+Perestoronin).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master     #174      +/-   ##
==========================================
+ Coverage   97.95%   98.08%   +0.12%     
==========================================
  Files          30       30              
  Lines         784      783       -1     
==========================================
  Hits          768      768              
+ Misses         16       15       -1     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/eigenein/protobuf/pull/174?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pavel+Perestoronin).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pavel+Perestoronin).


Patch:
diff --git a/Makefile b/Makefile
index 27b8642..b67a61d 100644
--- a/Makefile
+++ b/Makefile
@@ -49,3 +49,7 @@ build:
 .PHONY: docs
 docs:
 	poetry run mkdocs build --site-dir _site
+
+.PHONY: docs/serve
+docs/serve:
+	poetry run mkdocs serve
diff --git a/docs/annotating_fields.md b/docs/annotating_fields.md
index 054a10c..3a5ce7f 100644
--- a/docs/annotating_fields.md
+++ b/docs/annotating_fields.md
@@ -1,6 +1,11 @@
 # Annotating fields
 
-Field types are specified via [`#!python Annotated`](https://docs.python.org/3/library/typing.html#typing.Annotated) [type hints](https://www.python.org/dev/peps/pep-0484/). Each field may include a `#!python pure_protobuf.annotations.Field` annotation, otherwise it gets ignored by `#!python BaseMessage`. For older Python versions one can use `#!python typing_extensions.Annotated`.
+Field types are specified via [`#!python Annotated`](https://docs.python.org/3/library/typing.html#typing.Annotated) [type hints](https://www.python.org/dev/peps/pep-0484/). Each field may include a [`#!python Field`][pure_protobuf.annotations.Field] annotation, otherwise it gets ignored by `#!python BaseMessage`. For older Python versions one can use `#!python typing_extensions.Annotated`.
+
+::: pure_protobuf.annotations.Field
+    options:
+      show_root_heading: true
+      heading_level: 2
 
 ## Supported types
 
@@ -255,3 +260,7 @@ assert message.which_one() == "bar"
     - When assigning a one-of member, `#!python BaseMessage` resets the other fields to `#!python None`, **regardless** of any defaults defined by, for example, `#!python dataclasses.field`.
     - The `#!python OneOf` descriptor simply iterates over its members in order to return an assigned `Oneof` value, so it takes [linear time](https://en.wikipedia.org/wiki/Time_complexity#Linear_time).
     - It's impossible to set a value via a `OneOf` descriptor, one needs to assign the value to a specific attribute.
+
+::: pure_protobuf.one_of.OneOf
+    options:
+      heading_level: 3
diff --git a/mkdocs.yml b/mkdocs.yml
index 164af52..7d504e4 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -21,22 +21,28 @@ theme:
     - navigation.footer
     - navigation.indexes
     - navigation.instant
+    - navigation.instant.progress
     - navigation.sections
     # - navigation.tabs
     - navigation.top
     - navigation.tracking
     - search.suggest
+    - search.highlight
   palette:
+    - media: "(prefers-color-scheme)"
+      toggle:
+        icon: material/brightness-auto
+        name: System theme
     - media: "(prefers-color-scheme: light)"
       scheme: default
       toggle:
         icon: material/brightness-7
-        name: Switch to dark mode
+        name: Dark mode
     - media: "(prefers-color-scheme: dark)"
       scheme: slate
       toggle:
         icon: material/brightness-4
-        name: Switch to light mode
+        name: Light mode
 
 plugins:
   - git-revision-date-localized:
@@ -87,7 +93,7 @@ extra:
     provider: google
     property: G-CPNSYW2HX7
 
-copyright: Copyright &copy; 2011-2023 Pavel Perestoronin
+copyright: Copyright &copy; 2011-2024 Pavel Perestoronin
 
 site_url: "https://eigenein.github.io/protobuf"
 
diff --git a/pure_protobuf/message.py b/pure_protobuf/message.py
index a333035..fc2f8b8 100644
--- a/pure_protobuf/message.py
+++ b/pure_protobuf/message.py
@@ -126,8 +126,7 @@ class BaseMessage(ABC):
     def __setattr__(self, name: str, value: Any) -> None:  # noqa: D105
         super().__setattr__(name, value)
         descriptor = self.__PROTOBUF_FIELDS_BY_NAME__[name]
-        one_of = descriptor.one_of
-        if one_of is not None:
+        if (one_of := descriptor.one_of) is not None and value is not None:
             one_of._keep_attribute(self, descriptor.number)
 
     @classmethod
diff --git a/pure_protobuf/one_of.py b/pure_protobuf/one_of.py
index 9876d80..cff29a5 100644
--- a/pure_protobuf/one_of.py
+++ b/pure_protobuf/one_of.py
@@ -16,6 +16,12 @@ class OneOf(Generic[OneOfT]):
     __slots__ = ("_fields",)
 
     def __init__(self) -> None:
+        """
+        Define a one-of group of fields.
+
+        A [`Field`][pure_protobuf.annotations.Field] then should be assigned to the group
+        via the [`one_of`][pure_protobuf.annotations.Field.one_of] parameter.
+        """
         self._fields: List[Tuple[int, str]] = []
 
     def _add_field(self, number: int, name: str) -> None:


Test patch:
diff --git a/tests/test_message.py b/tests/test_message.py
new file mode 100644
index 0000000..9e0ab44
--- /dev/null
+++ b/tests/test_message.py
@@ -0,0 +1,21 @@
+from dataclasses import dataclass
+from typing import ClassVar, Optional
+
+from typing_extensions import Annotated
+
+from pure_protobuf.annotations import Field
+from pure_protobuf.message import BaseMessage
+from pure_protobuf.one_of import OneOf
+
+
+def test_initialize_dataclass_with_one_of() -> None:
+    """Verify the fix for https://github.com/eigenein/protobuf/issues/171."""
+
+    @dataclass
+    class Message(BaseMessage):
+        payload: ClassVar[OneOf] = OneOf()
+
+        foo: Annotated[Optional[int], Field(1, one_of=payload)] = None
+        bar: Annotated[Optional[bool], Field(2, one_of=payload)] = None
+
+    assert Message(foo=3).foo == 3