Repository: jcrist/msgspec (language: python)

Issue:
Struct `__post_init__` is not called when converting to Struct with `from_attributes=True`
### Description

When using `msgspec.convert()` to convert to a Struct with `from_attributes=True`, the Struct's `__post_init__` is not called. It works as expected with dataclasses.

```python
from dataclasses import dataclass
import msgspec

@dataclass
class DataclassEgg:
	grade: str
	
	def __post_init__(self):
		self.grade = f"dataclass({self.grade})"

class StructEgg(msgspec.Struct):
	grade: str

	def __post_init__(self):
		self.grade = f"Struct({self.grade})"
```

```python
>>> egg = {"grade": "A"}

>>> struct_egg = msgspec.convert(egg, StructEgg)
>>> struct_egg
StructEgg(grade='Struct(A)')

>>> dataclass_egg = msgspec.convert(egg, DataclassEgg)
>>> dataclass_egg
DataclassEgg(grade='dataclass(A)')

>>> msgspec.convert(struct_egg, DataclassEgg, from_attributes=True)  # DataclassEgg post init is called
DataclassEgg(grade='dataclass(Struct(A))')

>>> msgspec.convert(dataclass_egg, StructEgg, from_attributes=True)  # StructEgg post init is not called
StructEgg(grade='dataclass(A)')
```
The last one should be:
```python
StructEgg(grade='Struct(dataclass(A))')
```


Discussion / hints:
@NCRonB I thought the same, but I found out the `__post_init__()` is indeed being called. However, if there are any new fields defined in it (as in your example), these are silently ignored. To fix it, you have to create your `StructEgg` with the configuration option `dict=True`.

If the `__post_init__()` only contains validations or similar, there's probably no need for the `dict=True` option.
@jankotuc-photoneo I'm not defining any new fields — only setting the one field `grade` to a different value. If you change the `__post_init__()` to simply print() something, you'll see that it doesn't print.

I tried what you suggested, and it still doesn't work. If you have an example where it does, please share.

Fix patch:
diff --git a/msgspec/_core.c b/msgspec/_core.c
index caf60279..b8cfcf02 100644
--- a/msgspec/_core.c
+++ b/msgspec/_core.c
@@ -21363,6 +21363,9 @@ convert_object_to_struct(
             should_untrack = !MS_MAYBE_TRACKED(val);
         }
     }
+
+    if (Struct_decode_post_init(struct_type, out, path) < 0) goto error;
+
     Py_LeaveRecursiveCall();
     if (is_gc && !should_untrack)
         PyObject_GC_Track(out);


Test patch:
diff --git a/tests/test_convert.py b/tests/test_convert.py
index 95d496e3..0bb8cdc0 100644
--- a/tests/test_convert.py
+++ b/tests/test_convert.py
@@ -2239,18 +2239,18 @@ class Test2(Struct, Generic[T], tag=True, array_like=array_like):
 
 
 class TestStructPostInit:
-    @pytest.mark.parametrize("array_like", [False, True])
     @pytest.mark.parametrize("union", [False, True])
-    def test_struct_post_init(self, array_like, union):
-        count = 0
+    @mapcls_from_attributes_and_array_like
+    def test_struct_post_init(self, union, mapcls, from_attributes, array_like):
+        called = False
         singleton = object()
 
         class Ex(Struct, array_like=array_like, tag=union):
             x: int
 
             def __post_init__(self):
-                nonlocal count
-                count += 1
+                nonlocal called
+                called = True
                 return singleton
 
         if union:
@@ -2262,25 +2262,23 @@ class Ex2(Struct, array_like=array_like, tag=True):
         else:
             typ = Ex
 
-        msg = Ex(1)
-        buf = to_builtins(msg)
-        res = convert(buf, type=typ)
-        assert res == msg
-        assert count == 2  # 1 for Ex(), 1 for decode
+        msg = mapcls(type="Ex", x=1) if union else mapcls(x=1)
+        res = convert(msg, type=typ, from_attributes=from_attributes)
+        assert type(res) is Ex
+        assert called
         assert sys.getrefcount(singleton) == 2  # 1 for ref, 1 for call
 
-    @pytest.mark.parametrize("array_like", [False, True])
     @pytest.mark.parametrize("union", [False, True])
     @pytest.mark.parametrize("exc_class", [ValueError, TypeError, OSError])
-    def test_struct_post_init_errors(self, array_like, union, exc_class):
-        error = False
-
+    @mapcls_from_attributes_and_array_like
+    def test_struct_post_init_errors(
+        self, union, exc_class, mapcls, from_attributes, array_like
+    ):
         class Ex(Struct, array_like=array_like, tag=union):
             x: int
 
             def __post_init__(self):
-                if error:
-                    raise exc_class("Oh no!")
+                raise exc_class("Oh no!")
 
         if union:
 
@@ -2291,8 +2289,7 @@ class Ex2(Struct, array_like=array_like, tag=True):
         else:
             typ = Ex
 
-        msg = to_builtins([Ex(1)])
-        error = True
+        msg = [mapcls(type="Ex", x=1) if union else mapcls(x=1)]
 
         if exc_class in (ValueError, TypeError):
             expected = ValidationError
@@ -2300,7 +2297,7 @@ class Ex2(Struct, array_like=array_like, tag=True):
             expected = exc_class
 
         with pytest.raises(expected, match="Oh no!") as rec:
-            convert(msg, type=List[typ])
+            convert(msg, type=List[typ], from_attributes=from_attributes)
 
         if expected is ValidationError:
             assert "- at `$[0]`" in str(rec.value)