Repository: danielgtaylor/python-betterproto

Issue:
Struct, Value well know type not supported
https://developers.google.com/protocol-buffers/docs/reference/csharp/class/google/protobuf/well-known-types/struct

These should map to `dict[str, any]` and `any`

Discussion:
woodruffw: NB: I haven't made any documentation changes, since I believe this behavior was intended (in terms of Protobuf's specified behavior) but just buggy before. However, if there's somewhere I should note this (like the CHANGELOG?) let me know and I'm more than happy to add it 🙂 

Similarly, in terms of semver, I believe this is considered a nonbreaking change (since it's a bugfix).
Gobot1234: Honestly I think just putting this behaviour on the type itself would be the best solution. The generated file has been pretty stable for a long time so I don't really have a problem just tacking this on at this point.
woodruffw: > Thanks for this! Honestly I think just putting this behaviour on the type itself would be the best solution. The generated file has been pretty stable for a long time so I don't really have a problem just tacking this on at this point.

No problem! Making sure I understand: by "on the type itself," you mean specialize `to_dict()` and `from_dict()` on `Struct` itself?

If so, makes sense -- I can do that in a moment.
Gobot1234: > > Thanks for this! Honestly I think just putting this behaviour on the type itself would be the best solution. The generated file has been pretty stable for a long time so I don't really have a problem just tacking this on at this point.
> 
> No problem! Making sure I understand: by "on the type itself," you mean specialize `to_dict()` and `from_dict()` on `Struct` itself?
> 
> If so, makes sense -- I can do that in a moment.

Yep exactly
woodruffw: Alright, moved the specialization to `Struct` itself!
woodruffw: Gentle ping; please let me know if there's anything else I can do here!
Gobot1234: Test failures seem unrelated

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e59aac8..8f8b545 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -16,12 +16,12 @@ jobs:
       fail-fast: false
       matrix:
         os: [Ubuntu, MacOS, Windows]
-        python-version: ['3.7', '3.8', '3.9', '3.10', '3.11']
+        python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12']
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
 
       - name: Set up Python ${{ matrix.python-version }}
-        uses: actions/setup-python@v4
+        uses: actions/setup-python@v5
         with:
           python-version: ${{ matrix.python-version }}
 
diff --git a/src/betterproto/lib/google/protobuf/__init__.py b/src/betterproto/lib/google/protobuf/__init__.py
index f59e4a1..9976bda 100644
--- a/src/betterproto/lib/google/protobuf/__init__.py
+++ b/src/betterproto/lib/google/protobuf/__init__.py
@@ -2,14 +2,19 @@
 # sources: google/protobuf/any.proto, google/protobuf/api.proto, google/protobuf/descriptor.proto, google/protobuf/duration.proto, google/protobuf/empty.proto, google/protobuf/field_mask.proto, google/protobuf/source_context.proto, google/protobuf/struct.proto, google/protobuf/timestamp.proto, google/protobuf/type.proto, google/protobuf/wrappers.proto
 # plugin: python-betterproto
 # This file has been @generated
+
 import warnings
 from dataclasses import dataclass
 from typing import (
     Dict,
     List,
+    Mapping,
 )
 
+from typing_extensions import Self
+
 import betterproto
+from betterproto.utils import hybridmethod
 
 
 class Syntax(betterproto.Enum):
@@ -1458,6 +1463,32 @@ class Struct(betterproto.Message):
     )
     """Unordered map of dynamically typed values."""
 
+    @hybridmethod
+    def from_dict(cls: "type[Self]", value: Mapping[str, Any]) -> Self:  # type: ignore
+        self = cls()
+        return self.from_dict(value)
+
+    @from_dict.instancemethod
+    def from_dict(self, value: Mapping[str, Any]) -> Self:
+        fields = {**value}
+        for k in fields:
+            if hasattr(fields[k], "from_dict"):
+                fields[k] = fields[k].from_dict()
+
+        self.fields = fields
+        return self
+
+    def to_dict(
+        self,
+        casing: betterproto.Casing = betterproto.Casing.CAMEL,
+        include_default_values: bool = False,
+    ) -> Dict[str, Any]:
+        output = {**self.fields}
+        for k in self.fields:
+            if hasattr(self.fields[k], "to_dict"):
+                output[k] = self.fields[k].to_dict(casing, include_default_values)
+        return output
+
 
 @dataclass(eq=False, repr=False)
 class Value(betterproto.Message):


Test patch:
diff --git a/tests/test_struct.py b/tests/test_struct.py
new file mode 100644
index 0000000..f266bc8
--- /dev/null
+++ b/tests/test_struct.py
@@ -0,0 +1,24 @@
+import json
+
+from betterproto.lib.google.protobuf import Struct
+
+
+def test_struct_roundtrip():
+    data = {
+        "foo": "bar",
+        "baz": None,
+        "quux": 123,
+        "zap": [1, {"two": 3}, "four"],
+    }
+    data_json = json.dumps(data)
+
+    struct_from_dict = Struct().from_dict(data)
+    assert struct_from_dict.fields == data
+    assert struct_from_dict.to_dict() == data
+    assert struct_from_dict.to_json() == data_json
+
+    struct_from_json = Struct().from_json(data_json)
+    assert struct_from_json.fields == data
+    assert struct_from_json.to_dict() == data
+    assert struct_from_json == struct_from_dict
+    assert struct_from_json.to_json() == data_json