diff --git a/docs/source/api.rst b/docs/source/api.rst index 882b57b0..92aed85f 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -18,6 +18,8 @@ Structs .. autofunction:: msgspec.structs.astuple +.. autofunction:: msgspec.structs.force_setattr + .. autofunction:: msgspec.structs.fields .. autoclass:: msgspec.structs.FieldInfo diff --git a/msgspec/_core.c b/msgspec/_core.c index f2510352..b1b8fa80 100644 --- a/msgspec/_core.c +++ b/msgspec/_core.c @@ -7568,6 +7568,45 @@ struct_astuple(PyObject *self, PyObject *const *args, Py_ssize_t nargs) return NULL; } +PyDoc_STRVAR(struct_force_setattr__doc__, +"force_setattr(struct, name, value)\n" +"--\n" +"\n" +"Set an attribute on a struct, even if the struct is frozen.\n" +"\n" +"The main use case for this is modifying a frozen struct in a ``__post_init__``\n" +"method before returning.\n" +"\n" +".. warning::\n\n" +" This function violates the guarantees of a frozen struct, and is potentially\n" +" unsafe. Only use it if you know what you're doing!\n" +"\n" +"Parameters\n" +"----------\n" +"struct: Struct\n" +" The struct instance.\n" +"name: str\n" +" The attribute name.\n" +"value: Any\n" +" The attribute value." +); +static PyObject* +struct_force_setattr(PyObject *self, PyObject *const *args, Py_ssize_t nargs) +{ + if (!check_positional_nargs(nargs, 3, 3)) return NULL; + PyObject *obj = args[0]; + PyObject *name = args[1]; + PyObject *value = args[2]; + if (Py_TYPE(Py_TYPE(obj)) != &StructMetaType) { + PyErr_SetString(PyExc_TypeError, "`struct` must be a `msgspec.Struct`"); + return NULL; + } + if (PyObject_GenericSetAttr(obj, name, value) < 0) { + return NULL; + } + Py_RETURN_NONE; +} + static PyObject * Struct_reduce(PyObject *self, PyObject *args) { @@ -20886,6 +20925,10 @@ static struct PyMethodDef msgspec_methods[] = { "defstruct", (PyCFunction) msgspec_defstruct, METH_VARARGS | METH_KEYWORDS, msgspec_defstruct__doc__, }, + { + "force_setattr", (PyCFunction) struct_force_setattr, METH_FASTCALL, + struct_force_setattr__doc__, + }, { "msgpack_encode", (PyCFunction) msgspec_msgpack_encode, METH_FASTCALL | METH_KEYWORDS, msgspec_msgpack_encode__doc__, diff --git a/msgspec/structs.py b/msgspec/structs.py index 57d5fb24..76f2fdfe 100644 --- a/msgspec/structs.py +++ b/msgspec/structs.py @@ -9,6 +9,7 @@ asdict, astuple, replace, + force_setattr, ) from ._utils import get_class_annotations as _get_class_annotations @@ -18,6 +19,7 @@ "asdict", "astuple", "fields", + "force_setattr", "replace", ) diff --git a/msgspec/structs.pyi b/msgspec/structs.pyi index 5352082d..58432b2d 100644 --- a/msgspec/structs.pyi +++ b/msgspec/structs.pyi @@ -7,6 +7,7 @@ S = TypeVar("S", bound=Struct) def replace(struct: S, /, **changes: Any) -> S: ... def asdict(struct: Struct) -> dict[str, Any]: ... def astuple(struct: Struct) -> tuple[Any, ...]: ... +def force_setattr(struct: Struct, name: str, value: Any) -> None: ... class StructConfig: frozen: bool diff --git a/tests/basic_typing_examples.py b/tests/basic_typing_examples.py index c279b62a..6826ae65 100644 --- a/tests/basic_typing_examples.py +++ b/tests/basic_typing_examples.py @@ -463,6 +463,15 @@ class Test(msgspec.Struct): reveal_type(o[0]) # assert "Any" in typ +def check_force_setattr() -> None: + class Point(msgspec.Struct, frozen=True): + x: int + y: int + + obj = Point(1, 2) + msgspec.structs.force_setattr(obj, "x", 3) + + def check_fields() -> None: class Test(msgspec.Struct): x: int diff --git a/tests/test_struct.py b/tests/test_struct.py index 3c4197b5..f3c64076 100644 --- a/tests/test_struct.py +++ b/tests/test_struct.py @@ -1600,6 +1600,22 @@ class Test(Base, gc=has_gc): t.x = [1] assert gc.is_tracked(t) + def test_force_setattr(self): + class Ex(Struct, frozen=True): + x: Any + + obj = Ex(1) + + res = msgspec.structs.force_setattr(obj, "x", 2) + assert res is None + assert obj.x == 2 + + with pytest.raises(AttributeError): + msgspec.structs.force_setattr(obj, "oops", 3) + + with pytest.raises(TypeError): + msgspec.structs.force_setattr(1, "oops", 3) + class TestOrderAndEq: @staticmethod