From 17899a17cb22ea01a6420dc2f5cf16c03ceaf8b3 Mon Sep 17 00:00:00 2001 From: Will Chen Date: Fri, 19 Jul 2024 15:22:36 -0700 Subject: [PATCH] Prototype: safe-guard against mutable default values --- mesop/dataclass_utils/dataclass_utils.py | 19 +++++++-- mesop/dataclass_utils/dataclass_utils_test.py | 41 +++++++++++++++++++ mesop/examples/__init__.py | 1 + mesop/examples/mutable_state.py | 41 +++++++++++++++++++ 4 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 mesop/examples/mutable_state.py diff --git a/mesop/dataclass_utils/dataclass_utils.py b/mesop/dataclass_utils/dataclass_utils.py index d7aee06cf..09dc69138 100644 --- a/mesop/dataclass_utils/dataclass_utils.py +++ b/mesop/dataclass_utils/dataclass_utils.py @@ -1,6 +1,6 @@ import base64 import json -from dataclasses import asdict, dataclass, field, is_dataclass +from dataclasses import Field, asdict, dataclass, field, is_dataclass from datetime import datetime from io import StringIO from typing import Any, Type, TypeVar, cast, get_origin, get_type_hints @@ -32,15 +32,28 @@ def _check_has_pandas(): _has_pandas = _check_has_pandas() +def is_primitive(obj: Any): + return isinstance(obj, (int, float, bool, str, type(None), complex, bytes)) + + def dataclass_with_defaults(cls: Type[C]) -> Type[C]: """ Provides defaults for every attribute in a dataclass (recursively) so Mesop developers don't need to manually set default values """ - pass + for name in cls.__dict__: + if name.startswith("__") and name.endswith("__"): + continue + # If default is already set, make sure it's a primitive value + if not isinstance(cls.__dict__[name], Field) and not is_primitive( + cls.__dict__[name] + ): + raise Exception( + "Detected potentially mutable type | name", name, "type", type.__name__ + ) annotations = get_type_hints(cls) for name, type_hint in annotations.items(): - if name not in cls.__dict__: # Skip if default already set + if name not in cls.__dict__: if type_hint == int: setattr(cls, name, field(default=0)) elif type_hint == float: diff --git a/mesop/dataclass_utils/dataclass_utils_test.py b/mesop/dataclass_utils/dataclass_utils_test.py index f6efd8385..5aae2b6b6 100644 --- a/mesop/dataclass_utils/dataclass_utils_test.py +++ b/mesop/dataclass_utils/dataclass_utils_test.py @@ -5,6 +5,7 @@ import pandas as pd import pytest +import mesop.protos.ui_pb2 as pb from mesop.dataclass_utils.dataclass_utils import ( dataclass_with_defaults, serialize_dataclass, @@ -286,5 +287,45 @@ def test_serialize_deserialize_state_with_list_dict(): assert new_state == state +def test_raises_exception_for_mutable_default_value_default_list(): + with pytest.raises(Exception) as exc_info: + + @dataclass_with_defaults + class StateWithMutableDefaultValue: + mutable_list: list[str] = [] + + assert "Detected potentially mutable type" in str(exc_info.value) + + +def test_raises_exception_for_mutable_default_value_default_list_no_type_annotation(): + with pytest.raises(Exception) as exc_info: + + @dataclass_with_defaults + class StateWithMutableDefaultValue: + mutable_list = [] + + assert "Detected potentially mutable type" in str(exc_info.value) + + +def test_raises_exception_for_mutable_default_value_proto(): + with pytest.raises(Exception) as exc_info: + + @dataclass_with_defaults + class StateWithMutableProto: + proto: pb.Style = pb.Style() + + assert "Detected potentially mutable type" in str(exc_info.value) + + +def test_proto_works(): + with pytest.raises(Exception) as exc_info: + + @dataclass_with_defaults + class StateWithMutableProto: + proto: pb.Style + + assert "FAIL" in str(exc_info.value) + + if __name__ == "__main__": raise SystemExit(pytest.main([__file__])) diff --git a/mesop/examples/__init__.py b/mesop/examples/__init__.py index 7a790785b..40f2be616 100644 --- a/mesop/examples/__init__.py +++ b/mesop/examples/__init__.py @@ -19,6 +19,7 @@ from mesop.examples import index as index from mesop.examples import integrations as integrations from mesop.examples import many_checkboxes as many_checkboxes +from mesop.examples import mutable_state as mutable_state from mesop.examples import navigate_advanced as navigate_advanced from mesop.examples import nested as nested from mesop.examples import on_load as on_load diff --git a/mesop/examples/mutable_state.py b/mesop/examples/mutable_state.py new file mode 100644 index 000000000..70af23103 --- /dev/null +++ b/mesop/examples/mutable_state.py @@ -0,0 +1,41 @@ +import mesop as me +import mesop.components.button.button_pb2 as button_pb + + +class Bar: + val: str + pass + + +class Foo: + val: str + + +b = button_pb.ButtonType() +b.color = "abc" +print("~ b", b) +c = button_pb.ButtonType() +print("~ c", c) + + +@me.stateclass +class State: + button: button_pb.ButtonType + a: str + b: Bar + + +s = State() +s.button.color = "foo" +print("s.button (1)", s.button) +s.b.val = "hi" +print(s) +print("---") +b = State() +print("b.button (2)", b.button) +print(b) + + +@me.page() +def page(): + me.text("mutable state")