|
4 | 4 | # pyright: reportPrivateUsage=false, reportUnknownVariableType=false |
5 | 5 | # pyright: reportGeneralTypeIssues=false |
6 | 6 |
|
7 | | -"""Path-segment validation tests for DeclarativeWorkflowState. |
| 7 | +"""Path-segment validation tests for workflow state. |
8 | 8 |
|
9 | | -Path segments handed to ``get``/``set``/``append`` and ``{Variable.Path}`` |
10 | | -placeholders in ``interpolate_string`` are subject to three distinct rules |
11 | | -that this module pins: |
| 9 | +For DeclarativeWorkflowState, path segments handed to ``get``/``set``/``append`` |
| 10 | +and ``{Variable.Path}`` placeholders in ``interpolate_string`` are subject to |
| 11 | +three distinct rules that this module pins: |
12 | 12 |
|
13 | 13 | - **Empty segments** (e.g. ``""``, ``"Local."``, ``"Local..foo"``) are rejected |
14 | 14 | by all of ``get``/``set``/``append`` and ``interpolate_string``. ``get`` and |
|
21 | 21 | - **Dict-keyed segments** — segments that resolve via dict lookup because the |
22 | 22 | parent is a ``dict`` — may use arbitrary non-empty string keys (e.g. UUIDs |
23 | 23 | or hyphenated identifiers like ``System.conversations.<uuid>.messages``). |
| 24 | +
|
| 25 | +Standalone WorkflowState shares the object-member rule, but retains its |
| 26 | +existing empty-dictionary-key behavior. |
24 | 27 | """ |
25 | 28 |
|
26 | 29 | import logging |
|
29 | 32 | from unittest.mock import MagicMock |
30 | 33 |
|
31 | 34 | import pytest |
| 35 | +from agent_framework._workflows._state import State |
32 | 36 |
|
| 37 | +from agent_framework_declarative import WorkflowState |
33 | 38 | from agent_framework_declarative._workflows import DeclarativeWorkflowState |
| 39 | +from agent_framework_declarative._workflows import _declarative_base as base |
34 | 40 |
|
35 | 41 | try: |
36 | 42 | import powerfx # noqa: F401 |
@@ -81,6 +87,126 @@ class _PlainObj: |
81 | 87 | text: str = "hi" |
82 | 88 |
|
83 | 89 |
|
| 90 | +@dataclass |
| 91 | +class _MemberRecord(_PlainObj): |
| 92 | + _private: str = "private-marker" |
| 93 | + |
| 94 | + |
| 95 | +class TestStateMemberAccess: |
| 96 | + """Both state implementations distinguish object members from dictionary keys.""" |
| 97 | + |
| 98 | + @pytest.fixture(params=["standalone", "modern"]) |
| 99 | + def member_state(self, request: pytest.FixtureRequest) -> WorkflowState | DeclarativeWorkflowState: |
| 100 | + if request.param == "standalone": |
| 101 | + return WorkflowState(inputs={"record": _MemberRecord()}) |
| 102 | + state = DeclarativeWorkflowState(State()) |
| 103 | + state.initialize({"record": _MemberRecord()}) |
| 104 | + return state |
| 105 | + |
| 106 | + @pytest.mark.parametrize("name", ["_private", "__marker__", "text-other", "1text", "text other", "text\n"]) |
| 107 | + def test_rejects_existing_invalid_members( |
| 108 | + self, member_state: WorkflowState | DeclarativeWorkflowState, name: str |
| 109 | + ) -> None: |
| 110 | + record = _PlainObj() |
| 111 | + setattr(record, name, "member-marker") |
| 112 | + member_state.set("Local.record", record) |
| 113 | + default = object() |
| 114 | + |
| 115 | + assert member_state.get(f"Local.record.{name}", default) is default |
| 116 | + |
| 117 | + def test_rejects_before_reading_attribute( |
| 118 | + self, member_state: WorkflowState | DeclarativeWorkflowState, caplog: pytest.LogCaptureFixture |
| 119 | + ) -> None: |
| 120 | + class Record: |
| 121 | + @property |
| 122 | + def _private(self) -> str: |
| 123 | + raise AssertionError("Rejected attribute was read") |
| 124 | + |
| 125 | + member_state.set("Local.record", Record()) |
| 126 | + default = object() |
| 127 | + with caplog.at_level(logging.WARNING): |
| 128 | + assert member_state.get("Local.record._private", default) is default |
| 129 | + |
| 130 | + assert any("rejecting attribute segment" in record.message for record in caplog.records) |
| 131 | + |
| 132 | + @pytest.mark.parametrize( |
| 133 | + "namespace", ["Workflow.Inputs", "Workflow.Outputs", "Local", "System", "Agent", "Conversation", "Custom"] |
| 134 | + ) |
| 135 | + def test_member_rule_applies_across_namespaces( |
| 136 | + self, member_state: WorkflowState | DeclarativeWorkflowState, namespace: str |
| 137 | + ) -> None: |
| 138 | + if namespace != "Workflow.Inputs": |
| 139 | + member_state.set(f"{namespace}.record", _MemberRecord()) |
| 140 | + |
| 141 | + assert member_state.get(f"{namespace}.record._private") is None |
| 142 | + assert member_state.get(f"{namespace}.record.text") == "hi" |
| 143 | + |
| 144 | + def test_mixed_dictionary_and_attribute_steps(self, member_state: WorkflowState | DeclarativeWorkflowState) -> None: |
| 145 | + class Record: |
| 146 | + class_value = "class-marker" |
| 147 | + _private = "private-marker" |
| 148 | + |
| 149 | + def __init__(self) -> None: |
| 150 | + self.public_value = "instance-marker" |
| 151 | + self.child = {"nested": _PlainObj()} |
| 152 | + |
| 153 | + member_state.set("Local.bag", {"record": Record()}) |
| 154 | + member_state.set("Local.nested", {"record": _MemberRecord(_private="nested-marker")}) |
| 155 | + |
| 156 | + assert member_state.get("Local.bag.record.class_value") == "class-marker" |
| 157 | + assert member_state.get("Local.bag.record.public_value") == "instance-marker" |
| 158 | + assert member_state.get("Local.bag.record.child.nested.text") == "hi" |
| 159 | + assert member_state.get("Local.bag.record._private") is None |
| 160 | + assert member_state.get("Local.nested.record._private") is None |
| 161 | + default = object() |
| 162 | + assert member_state.get("Local.bag.record.missing", default) is default |
| 163 | + |
| 164 | + @pytest.mark.parametrize("key", ["_private", "__marker__", "text-other", "123", "text other", "text\n"]) |
| 165 | + def test_dictionary_keys_are_not_member_identifiers( |
| 166 | + self, member_state: WorkflowState | DeclarativeWorkflowState, key: str |
| 167 | + ) -> None: |
| 168 | + member_state.set("Local.bag", {key: "dictionary-marker"}) |
| 169 | + assert member_state.get(f"Local.bag.{key}") == "dictionary-marker" |
| 170 | + |
| 171 | + @pytest.mark.parametrize("mode", ["absent", "error"]) |
| 172 | + def test_modern_engine_errors_do_not_fall_back(self, monkeypatch: pytest.MonkeyPatch, mode: str) -> None: |
| 173 | + state = DeclarativeWorkflowState(State()) |
| 174 | + state.initialize() |
| 175 | + state.set("Local.record", _PlainObj()) |
| 176 | + get = MagicMock(side_effect=AssertionError("Unexpected member lookup")) |
| 177 | + monkeypatch.setattr(state, "get", get) |
| 178 | + if mode == "absent": |
| 179 | + monkeypatch.setattr(base, "Engine", None) |
| 180 | + with pytest.raises(RuntimeError, match="PowerFx is not available"): |
| 181 | + state.eval("=Local.record.text") |
| 182 | + else: |
| 183 | + engine = MagicMock() |
| 184 | + engine.eval.side_effect = ValueError("synthetic engine error") |
| 185 | + monkeypatch.setattr(base, "Engine", MagicMock(return_value=engine)) |
| 186 | + with pytest.raises(ValueError, match="synthetic engine error"): |
| 187 | + state.eval("=Local.record.text") |
| 188 | + get.assert_not_called() |
| 189 | + |
| 190 | + @_requires_powerfx |
| 191 | + def test_modern_projection_preserves_instance_data(self) -> None: |
| 192 | + class Record: |
| 193 | + _class_value = "class-marker" |
| 194 | + |
| 195 | + def __init__(self) -> None: |
| 196 | + self._declared = "instance-marker" |
| 197 | + self.public_value = "public-marker" |
| 198 | + |
| 199 | + state = DeclarativeWorkflowState(State()) |
| 200 | + state.initialize() |
| 201 | + state.set("Local.record", Record()) |
| 202 | + |
| 203 | + assert state.get("Local.record._declared") is None |
| 204 | + assert state.get("Local.record._class_value") is None |
| 205 | + assert state.eval("=Local.record.public_value") == "public-marker" |
| 206 | + assert state.eval("=Local.record._declared") == "instance-marker" |
| 207 | + assert state.eval("=Local.record._class_value") is None |
| 208 | + |
| 209 | + |
84 | 210 | # --------------------------------------------------------------------------- |
85 | 211 | # get(): invalid paths return default |
86 | 212 | # --------------------------------------------------------------------------- |
|
0 commit comments