Repository: run-llama/llama_index
Pull request: 15275 feature request make all events accessible like mappings
# Description

- Fixing dict interface for `StartEvent` (maximum recursion error with  `__getattr__` and `__setattr__`).
- Updates `Event` type to take on the dict-like interface that was priorly only set for `StartEvent` -- `StartEvent` is now a light subclass of `Event`.
- ~~Also, adds `DictLikeEvent` (naming?) as an event type that can be used generally with a dict-like interface.~~

Closes #15275 #15348 

## New Package?

Did I fill in the `tool.llamahub` section in the `pyproject.toml` and provide a detailed README.md for my new integration or package?

- [x] No

## Version Bump?

Did I bump the version in the `pyproject.toml` file of the package I am updating? (Except for the `llama-index-core` package)

- [x] NA

## Type of Change

Please delete options that are not relevant.

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration

- [ ] Added new unit/integration tests
- [ ] Added new notebook (that tests end-to-end)
- [x] I stared at the code and made sure it makes sense



Problem statement:
[Feature Request]: Make all events accessible like mappings
### Feature Description

The dict-emulating functions in `StartEvent` should likely be available to any event class.

### Reason

Accessing an event like a mapping is very handy, and it shouldn't be limited to `StartEvent`

### Value of Feature

Better consistency and reuse.

Hints from issue discussion:

Commit message: cr

Patch to review:
diff --git a/llama-index-core/llama_index/core/workflow/events.py b/llama-index-core/llama_index/core/workflow/events.py
index 72916f24b1..3935d5ca08 100644
--- a/llama-index-core/llama_index/core/workflow/events.py
+++ b/llama-index-core/llama_index/core/workflow/events.py
@@ -4,34 +4,87 @@
 
 
 class Event(BaseModel):
-    """Base class for event types."""
+    """Base class for event types that mimics dict interface.
 
-    class Config:
-        arbitrary_types_allowed = True
+    PrivateAttr:
+        _data (Dict[str, Any]): Underlying Python dict.
 
+    Examples:
+        Basic example usage
+        ```python
+        from llama_index.core.workflows.events import Event
 
-class StartEvent(Event):
-    """StartEvent is implicitly sent when a workflow runs. Mimics the interface of a dict."""
+        evt = Event(a=1, b=2)
+
+        # can use dot access to get values of `a` and `b`
+        print((evt.a, evt.b))
+
+        # can also set the attrs
+        evt.a = 2
+        ```
+
+        Custom event with additional Fields/PrivateAttr
+        ```python
+        from llama_index.core.workflows.events import Event
+        from llama_index.core.bridge.pydantic import Field, PrivateAttr
+
+        class CustomEvent(Event):
+            field_1: int = Field(description="my custom field")
+            _private_attr_1: int = PrivateAttr()
+
+        evt = CustomEvent(a=1, b=2, field_1=3, _private_attr_1=4)
+
+        # `field_1` and `_private_attr_1` get set as they do with Pydantic BaseModel
+        print(evt.field_1)
+        print(evt._private_attr_1)
+
+        # `a` and `b` get set in the underliying dict, namely `evt._data`
+        print((evt.a, evt.b))
+        ```
+    """
 
     _data: Dict[str, Any] = PrivateAttr(default_factory=dict)
 
-    def __init__(self, **data: Any):
-        super().__init__()
+    class Config:
+        arbitrary_types_allowed = True
+
+    def __init__(self, **params: Any):
+        """__init__.
+
+        NOTE: fields and private_attrs are pulled from params by name.
+        """
+        # extract and set fields, private attrs and remaining shove in _data
+        fields = {}
+        private_attrs = {}
+        data = {}
+        for k, v in params.items():
+            if k in self.__fields__:
+                fields[k] = v
+            elif k in self.__private_attributes__:
+                private_attrs[k] = v
+            else:
+                data[k] = v
+        super().__init__(**fields)
+        for private_attr, value in private_attrs.items():
+            super().__setattr__(private_attr, value)
         self._data = data
 
     def __getattr__(self, __name: str) -> Any:
-        if __name in self._data:
-            return self._data[__name]
+        if __name in self.__private_attributes__ or __name in self.__fields__:
+            return super().__getattr__(__name)
         else:
-            raise AttributeError(
-                f"'{self.__class__.__name__}' object has no attribute '{__name}'"
-            )
-
-    def __setattr__(self, __name: str, value: Any) -> None:
-        if __name in self._data:
-            self._data[__name] = value
+            try:
+                return self._data[__name]
+            except KeyError:
+                raise AttributeError(
+                    f"'{self.__class__.__name__}' object has no attribute '{__name}'"
+                )
+
+    def __setattr__(self, name, value) -> None:
+        if name in self.__private_attributes__ or name in self.__fields__:
+            super().__setattr__(name, value)
         else:
-            super().__setattr__(__name, value)
+            self._data.__setitem__(name, value)
 
     def __getitem__(self, key: str) -> Any:
         return self._data[key]
@@ -64,6 +117,10 @@ def dict(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
         return self._data
 
 
+class StartEvent(Event):
+    """StartEvent is implicitly sent when a workflow runs."""
+
+
 class StopEvent(Event):
     """EndEvent signals the workflow to stop."""
 
diff --git a/llama-index-core/tests/workflow/test_event.py b/llama-index-core/tests/workflow/test_event.py
new file mode 100644
index 0000000000..00407437b8
--- /dev/null
+++ b/llama-index-core/tests/workflow/test_event.py
@@ -0,0 +1,58 @@
+from llama_index.core.workflow.events import Event
+from llama_index.core.bridge.pydantic import PrivateAttr
+from typing import Any
+
+
+class TestEvent(Event):
+    param: str
+    _private_param_1: str = PrivateAttr()
+    _private_param_2: str = PrivateAttr(default_factory=str)
+
+
+class TestEvent2(Event):
+    """Custom Test Event.
+
+    Private Attrs:
+        _private_param: doesn't get modified during construction
+        _modified_private_param: gets processed before being set
+    """
+
+    _private_param: int = PrivateAttr()
+    _modified_private_param: int = PrivateAttr()
+
+    def __init__(self, _modified_private_param: int, **params: Any):
+        super().__init__(**params)
+        self._modified_private_param = _modified_private_param * 2
+
+
+def test_event_init_basic():
+    evt = Event(a=1, b=2, c="c")
+
+    assert evt.a == 1
+    assert evt.b == 2
+    assert evt.c == "c"
+    assert evt["a"] == evt.a
+    assert evt["b"] == evt.b
+    assert evt["c"] == evt.c
+    assert evt.keys() == {"a": 1, "b": 2, "c": "c"}.keys()
+
+
+def test_custom_event_with_fields_and_private_params():
+    evt = TestEvent(a=1, param="test_param", _private_param_1="test_private_param_1")
+
+    assert evt.a == 1
+    assert evt["a"] == evt.a
+    assert evt.param == "test_param"
+    assert evt._data == {"a": 1}
+    assert evt._private_param_1 == "test_private_param_1"
+    assert evt._private_param_2 == ""
+
+
+def test_custom_event_override_init():
+    evt = TestEvent2(a=1, b=2, _private_param=2, _modified_private_param=2)
+
+    assert evt.a == 1
+    assert evt.b == 2
+    assert evt._data == {"a": 1, "b": 2}
+    assert evt._private_param == 2
+    assert evt._modified_private_param == 4


Code review comments:
diff_hunk: @@ -0,0 +1,58 @@
+from llama_index.core.workflow.events import Event
+from llama_index.core.bridge.pydantic import PrivateAttr
+from typing import Any
+
+
+class TestEvent(Event):
path: llama-index-core/tests/workflow/test_event.py
text: @user1:
nit: pytest will try to run any class named `TestSomething` it finds like a test case and emit a warning. I usually prefix an underscore like `_TestEvent`

@author:
ohhhh my bad, I should've known -- merci!