Repository: jcrist/msgspec
Pull request #330 (2023-02-24 17:54:56)

Add `msgspec.structs.fields` utility
This mirrors the functionality in `dataclasses.fields`, returning a tuple of `FieldInfo` objects providing information about the individual struct fields.

Supersedes #282.

jcrist: When coupled with #328, this now exposes all information about a `Struct` type (or instance) in a public and documented way.

Patch:
diff --git a/docs/source/api.rst b/docs/source/api.rst
index c77816c..e553ea6 100644
--- a/docs/source/api.rst
+++ b/docs/source/api.rst
@@ -18,8 +18,14 @@ Structs
 
 .. autofunction:: msgspec.structs.astuple
 
+.. autofunction:: msgspec.structs.fields
+
+.. autoclass:: msgspec.structs.FieldInfo
+
 .. autoclass:: msgspec.structs.StructConfig
 
+.. autodata:: UNSET
+
 Meta
 ----
 
@@ -157,7 +163,6 @@ Inspect
 .. autoclass:: NamedTupleType
 .. autoclass:: DataclassType
 .. autoclass:: StructType
-.. autodata:: UNSET
 
 
 Exceptions
diff --git a/msgspec/structs.py b/msgspec/structs.py
index 3710d50..0643807 100644
--- a/msgspec/structs.py
+++ b/msgspec/structs.py
@@ -1,1 +1,103 @@
-from ._core import StructConfig, asdict, astuple, replace
+from __future__ import annotations
+
+from typing import Any
+
+from . import UNSET, Struct
+from ._core import (  # noqa
+    Factory as _Factory,
+    StructConfig,
+    asdict,
+    astuple,
+    nodefault as _nodefault,
+    replace,
+)
+from ._utils import _get_type_hints
+
+__all__ = (
+    "FieldInfo",
+    "StructConfig",
+    "asdict",
+    "astuple",
+    "fields",
+    "replace",
+)
+
+
+def __dir__():
+    return __all__
+
+
+class FieldInfo(Struct):
+    """A record describing a field in a struct type.
+
+    Parameters
+    ----------
+    name: str
+        The field name as seen by Python code (e.g. ``field_one``).
+    encode_name: str
+        The name used when encoding/decoding the field. This may differ if
+        the field is renamed (e.g. ``fieldOne``).
+    type: Any
+        The full field type annotation.
+    default: Any, optional
+        A default value for the field. Will be `UNSET` if no default value is set.
+    default_factory: Any, optional
+        A callable that creates a default value for the field. Will be
+        `UNSET` if no ``default_factory`` is set.
+    """
+
+    name: str
+    encode_name: str
+    type: Any
+    default: Any = UNSET
+    default_factory: Any = UNSET
+
+    @property
+    def required(self) -> bool:
+        """A helper for checking whether a field is required"""
+        return self.default is UNSET and self.default_factory is UNSET
+
+
+def fields(type_or_instance: Struct | type[Struct]) -> tuple[FieldInfo]:
+    """Get information about the fields in a Struct.
+
+    Parameters
+    ----------
+    type_or_instance:
+        A struct type or instance.
+
+    Returns
+    -------
+    tuple[FieldInfo]
+    """
+    if isinstance(type_or_instance, Struct):
+        cls = type(type_or_instance)
+    elif isinstance(type_or_instance, type) and issubclass(type_or_instance, Struct):
+        cls = type_or_instance
+    else:
+        raise TypeError("Must be called with a struct type or instance")
+
+    hints = _get_type_hints(cls)
+    npos = len(cls.__struct_fields__) - len(cls.__struct_defaults__)
+    fields = []
+    for name, encode_name, default_obj in zip(
+        cls.__struct_fields__,
+        cls.__struct_encode_fields__,
+        (_nodefault,) * npos + cls.__struct_defaults__,
+    ):
+        default = default_factory = UNSET
+        if isinstance(default_obj, _Factory):
+            default_factory = default_obj.factory
+        elif default_obj is not _nodefault:
+            default = default_obj
+
+        field = FieldInfo(
+            name=name,
+            encode_name=encode_name,
+            type=hints[name],
+            default=default,
+            default_factory=default_factory,
+        )
+        fields.append(field)
+
+    return tuple(fields)
diff --git a/msgspec/structs.pyi b/msgspec/structs.pyi
index f1d62e4..f9f277d 100644
--- a/msgspec/structs.pyi
+++ b/msgspec/structs.pyi
@@ -1,6 +1,6 @@
 from typing import Any, TypeVar, Union
 
-from . import Struct
+from . import UNSET, Struct
 
 S = TypeVar("S", bound=Struct, covariant=True)
 
@@ -20,3 +20,15 @@ class StructConfig:
     weakref: bool
     tag: Union[str, int, None]
     tag_field: Union[str, None]
+
+class FieldInfo(Struct):
+    name: str
+    encode_name: str
+    type: Any
+    default: Any = UNSET
+    default_factory: Any = UNSET
+
+    @property
+    def required(self) -> bool: ...
+
+def fields(type_or_instance: Struct | type[Struct]) -> tuple[FieldInfo]: ...
diff --git a/setup.cfg b/setup.cfg
index d304c63..4a1cdac 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -6,7 +6,6 @@ exclude =
     basic_typing_examples.py,
     json.py,
     msgpack.py,
-    structs.py,
     test_JSONTestSuite.py,
     conf.py
 ignore =


Test patch:
diff --git a/tests/basic_typing_examples.py b/tests/basic_typing_examples.py
index d0e3b6b..2e0cb4c 100644
--- a/tests/basic_typing_examples.py
+++ b/tests/basic_typing_examples.py
@@ -397,6 +397,23 @@ def check_astuple() -> None:
     reveal_type(msgspec.structs.astuple(x))  # assert "tuple" in typ
 
 
+def check_fields() -> None:
+    class Test(msgspec.Struct):
+        x: int
+        y: int
+
+    x = Test(1, 2)
+    res1 = msgspec.structs.fields(x)
+    reveal_type(res1)  # assert "tuple" in typ.lower() and "FieldInfo" in typ
+    res2 = msgspec.structs.fields(Test)
+    reveal_type(res2)  # assert "tuple" in typ.lower() and "FieldInfo" in typ
+
+    for field in res1:
+        reveal_type(field)  # assert "FieldInfo" in typ
+        reveal_type(field.required)  # assert "bool" in typ
+        reveal_type(field.name)  # assert "str" in typ
+
+
 ##########################################################
 # Meta                                                   #
 ##########################################################
diff --git a/tests/test_struct.py b/tests/test_struct.py
index 04c177a..b2af8bd 100644
--- a/tests/test_struct.py
+++ b/tests/test_struct.py
@@ -1844,7 +1844,7 @@ class TestReplace:
         del t2
 
 
-class TestUtils:
+class TestAsDictAndAsTuple:
     def test_asdict(self):
         x = Point(1, 2)
         assert msgspec.structs.asdict(x) == {"x": 1, "y": 2}
@@ -1854,7 +1854,7 @@ class TestUtils:
         assert msgspec.structs.astuple(x) == (1, 2)
 
     @pytest.mark.parametrize("func", [msgspec.structs.asdict, msgspec.structs.astuple])
-    def test_asdict_errors(self, func):
+    def test_errors(self, func):
         with pytest.raises(TypeError):
             func(1)
 
@@ -1865,6 +1865,68 @@ class TestUtils:
             func(x)
 
 
+class TestInspectFields:
+    def test_fields_bad_arg(self):
+        with pytest.raises(TypeError, match="struct type or instance"):
+            msgspec.structs.fields(1)
+
+    def test_fields_no_fields(self):
+        assert msgspec.structs.fields(msgspec.Struct) == ()
+
+    @pytest.mark.parametrize("instance", [False, True])
+    def test_fields(self, instance):
+        def factory():
+            return 1
+
+        class Example(msgspec.Struct):
+            x: int
+            y: int = 0
+            z: int = msgspec.field(default_factory=factory)
+
+        arg = Example(1, 2, 3) if instance else Example
+        fields = msgspec.structs.fields(arg)
+        x_field, y_field, z_field = fields
+
+        assert x_field.required
+        assert x_field.default is msgspec.UNSET
+        assert x_field.default_factory is msgspec.UNSET
+
+        assert not y_field.required
+        assert y_field.default == 0
+        assert y_field.default_factory is msgspec.UNSET
+
+        assert not z_field.required
+        assert z_field.default is msgspec.UNSET
+        assert z_field.default_factory is factory
+
+    def test_fields_keyword_only(self):
+        class Example(msgspec.Struct, kw_only=True):
+            a: int
+            b: int = 1
+            c: int
+            d: int = 2
+
+        sol = (
+            msgspec.structs.FieldInfo("a", "a", int),
+            msgspec.structs.FieldInfo("b", "b", int, default=1),
+            msgspec.structs.FieldInfo("c", "c", int),
+            msgspec.structs.FieldInfo("d", "d", int, default=2),
+        )
+        assert msgspec.structs.fields(Example) == sol
+
+    def test_fields_encode_name(self):
+        class Example(msgspec.Struct, rename="camel"):
+            field_one: int
+            field_two: int
+
+        sol = (
+            msgspec.structs.FieldInfo("field_one", "fieldOne", int),
+            msgspec.structs.FieldInfo("field_two", "fieldTwo", int),
+        )
+
+        assert msgspec.structs.fields(Example) == sol
+
+
 class TestClassVar:
     def case1(self):
         return """