Skip to content

Commit 7f01cf2

Browse files
committed
feat: define python client search attribute
Signed-off-by: Tim Li <ltim@uber.com>
1 parent 09fd459 commit 7f01cf2

File tree

3 files changed

+284
-0
lines changed

3 files changed

+284
-0
lines changed

cadence/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,23 @@
88
from .client import Client
99
from .worker import Registry
1010
from . import workflow
11+
from .search_attributes import (
12+
SearchAttributeConverter,
13+
SearchAttributeValue,
14+
CADENCE_CHANGE_VERSION,
15+
validate_search_attributes,
16+
)
1117

1218
__version__ = "0.1.0"
1319

1420
__all__ = [
21+
# Core
1522
"Client",
1623
"Registry",
1724
"workflow",
25+
# Search Attributes
26+
"SearchAttributeConverter",
27+
"SearchAttributeValue",
28+
"CADENCE_CHANGE_VERSION",
29+
"validate_search_attributes",
1830
]

cadence/search_attributes.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""
2+
Search Attributes support for Cadence workflows.
3+
4+
Search attributes are custom indexed fields attached to workflow executions
5+
that enable advanced visibility queries using SQL-like syntax.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
from datetime import datetime
12+
from typing import Any
13+
14+
from cadence.api.v1.common_pb2 import Payload, SearchAttributes
15+
16+
# Supported Python types for search attribute values (any JSON-serializable type)
17+
SearchAttributeValue = Any
18+
19+
# CadenceChangeVersion is used as search attributes key to find workflows with specific change version.
20+
CADENCE_CHANGE_VERSION = "CadenceChangeVersion"
21+
22+
class SearchAttributeConverter:
23+
"""
24+
Converts between Python dictionaries and protobuf SearchAttributes.
25+
"""
26+
27+
def encode(self, attrs: dict[str, SearchAttributeValue]) -> SearchAttributes:
28+
"""
29+
Encode a Python dictionary to a protobuf SearchAttributes message.
30+
31+
Args:
32+
attrs: Dictionary mapping attribute names to Python values
33+
34+
Returns:
35+
SearchAttributes protobuf message
36+
37+
Raises:
38+
ValueError: If attrs is empty or contains unsupported types
39+
"""
40+
if not attrs:
41+
raise ValueError("search attributes is empty")
42+
43+
result = SearchAttributes()
44+
45+
for key, value in attrs.items():
46+
try:
47+
encoded_bytes = self._encode_value(value)
48+
result.indexed_fields[key].CopyFrom(Payload(data=encoded_bytes))
49+
except (TypeError, ValueError) as e:
50+
raise ValueError(f"encode search attribute [{key}] error: {e}") from e
51+
52+
return result
53+
54+
def decode(
55+
self,
56+
attrs: SearchAttributes,
57+
type_hints: dict[str, type] | None = None,
58+
) -> dict[str, Any]:
59+
"""
60+
Decode a protobuf SearchAttributes message to a Python dictionary.
61+
62+
Args:
63+
attrs: SearchAttributes protobuf message
64+
type_hints: Optional type hints for datetime conversion
65+
66+
Returns:
67+
Dictionary mapping attribute names to decoded values
68+
"""
69+
if not attrs.indexed_fields:
70+
return {}
71+
72+
type_hints = type_hints or {}
73+
result: dict[str, Any] = {}
74+
75+
for key, payload in attrs.indexed_fields.items():
76+
hint = type_hints.get(key)
77+
result[key] = self._decode_value(payload.data, hint)
78+
79+
return result
80+
81+
def _encode_value(self, value: SearchAttributeValue) -> bytes:
82+
"""Encode a single value to JSON bytes."""
83+
if isinstance(value, datetime):
84+
return json.dumps(value.isoformat()).encode("utf-8")
85+
else:
86+
return json.dumps(value).encode("utf-8")
87+
88+
def _decode_value(self, data: bytes, type_hint: type | None = None) -> Any:
89+
"""Decode JSON bytes to a Python value."""
90+
if not data:
91+
return None
92+
93+
raw_value = json.loads(data.decode("utf-8"))
94+
95+
if type_hint is datetime and isinstance(raw_value, str):
96+
return datetime.fromisoformat(raw_value)
97+
98+
return raw_value
99+
100+
101+
def validate_search_attributes(
102+
attrs: dict[str, SearchAttributeValue],
103+
allow_reserved_keys: bool = False,
104+
) -> None:
105+
"""
106+
Validate search attributes before encoding.
107+
108+
Raises:
109+
ValueError: If empty or uses reserved keys
110+
"""
111+
if not attrs:
112+
raise ValueError("search attributes is empty")
113+
114+
if not allow_reserved_keys and CADENCE_CHANGE_VERSION in attrs:
115+
raise ValueError(
116+
f"{CADENCE_CHANGE_VERSION} is a reserved key that cannot be set, "
117+
"please use other key"
118+
)
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""Tests for the search_attributes module."""
2+
3+
import json
4+
from datetime import datetime, timezone
5+
6+
import pytest
7+
8+
from cadence.api.v1.common_pb2 import Payload, SearchAttributes
9+
from cadence.search_attributes import (
10+
CADENCE_CHANGE_VERSION,
11+
SearchAttributeConverter,
12+
validate_search_attributes,
13+
)
14+
15+
16+
@pytest.fixture
17+
def converter() -> SearchAttributeConverter:
18+
return SearchAttributeConverter()
19+
20+
21+
def make_search_attrs(attrs: dict[str, bytes]) -> SearchAttributes:
22+
result = SearchAttributes()
23+
for key, data in attrs.items():
24+
result.indexed_fields[key].CopyFrom(Payload(data=data))
25+
return result
26+
27+
28+
class TestEncode:
29+
def test_string(self, converter: SearchAttributeConverter) -> None:
30+
result = converter.encode({"Status": "RUNNING"})
31+
assert result.indexed_fields["Status"].data == b'"RUNNING"'
32+
33+
def test_int(self, converter: SearchAttributeConverter) -> None:
34+
result = converter.encode({"Count": 42})
35+
assert result.indexed_fields["Count"].data == b"42"
36+
37+
def test_float(self, converter: SearchAttributeConverter) -> None:
38+
result = converter.encode({"Price": 99.99})
39+
assert result.indexed_fields["Price"].data == b"99.99"
40+
41+
def test_bool(self, converter: SearchAttributeConverter) -> None:
42+
result = converter.encode({"IsTrue": True, "IsFalse": False})
43+
assert result.indexed_fields["IsTrue"].data == b"true"
44+
assert result.indexed_fields["IsFalse"].data == b"false"
45+
46+
def test_datetime(self, converter: SearchAttributeConverter) -> None:
47+
dt = datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
48+
result = converter.encode({"CreatedAt": dt})
49+
assert json.loads(result.indexed_fields["CreatedAt"].data) == "2024-01-15T10:30:00+00:00"
50+
51+
def test_list_of_strings(self, converter: SearchAttributeConverter) -> None:
52+
result = converter.encode({"Tags": ["urgent", "customer"]})
53+
assert json.loads(result.indexed_fields["Tags"].data) == ["urgent", "customer"]
54+
55+
def test_multiple_attributes(self, converter: SearchAttributeConverter) -> None:
56+
result = converter.encode({"Status": "RUNNING", "Count": 42})
57+
assert len(result.indexed_fields) == 2
58+
59+
def test_empty_raises(self, converter: SearchAttributeConverter) -> None:
60+
with pytest.raises(ValueError, match="search attributes is empty"):
61+
converter.encode({})
62+
63+
def test_list_of_ints(self, converter: SearchAttributeConverter) -> None:
64+
result = converter.encode({"Counts": [1, 2, 3]})
65+
assert json.loads(result.indexed_fields["Counts"].data) == [1, 2, 3]
66+
67+
def test_nested_dict(self, converter: SearchAttributeConverter) -> None:
68+
result = converter.encode({"Meta": {"key": "value"}})
69+
assert json.loads(result.indexed_fields["Meta"].data) == {"key": "value"}
70+
71+
72+
class TestDecode:
73+
def test_string(self, converter: SearchAttributeConverter) -> None:
74+
proto = make_search_attrs({"Status": b'"RUNNING"'})
75+
assert converter.decode(proto) == {"Status": "RUNNING"}
76+
77+
def test_int(self, converter: SearchAttributeConverter) -> None:
78+
proto = make_search_attrs({"Count": b"42"})
79+
assert converter.decode(proto) == {"Count": 42}
80+
81+
def test_float(self, converter: SearchAttributeConverter) -> None:
82+
proto = make_search_attrs({"Price": b"99.99"})
83+
assert converter.decode(proto) == {"Price": 99.99}
84+
85+
def test_bool(self, converter: SearchAttributeConverter) -> None:
86+
proto = make_search_attrs({"IsTrue": b"true", "IsFalse": b"false"})
87+
assert converter.decode(proto) == {"IsTrue": True, "IsFalse": False}
88+
89+
def test_list(self, converter: SearchAttributeConverter) -> None:
90+
proto = make_search_attrs({"Tags": b'["a", "b"]'})
91+
assert converter.decode(proto) == {"Tags": ["a", "b"]}
92+
93+
def test_datetime_without_hint(self, converter: SearchAttributeConverter) -> None:
94+
proto = make_search_attrs({"CreatedAt": b'"2024-01-15T10:30:00"'})
95+
assert converter.decode(proto) == {"CreatedAt": "2024-01-15T10:30:00"}
96+
97+
def test_datetime_with_hint(self, converter: SearchAttributeConverter) -> None:
98+
proto = make_search_attrs({"CreatedAt": b'"2024-01-15T10:30:00+00:00"'})
99+
result = converter.decode(proto, type_hints={"CreatedAt": datetime})
100+
assert result["CreatedAt"] == datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
101+
102+
def test_empty_search_attributes(self, converter: SearchAttributeConverter) -> None:
103+
assert converter.decode(SearchAttributes()) == {}
104+
105+
def test_empty_payload(self, converter: SearchAttributeConverter) -> None:
106+
proto = make_search_attrs({"Empty": b""})
107+
assert converter.decode(proto) == {"Empty": None}
108+
109+
110+
class TestRoundTrip:
111+
@pytest.mark.parametrize(
112+
"attrs,type_hints",
113+
[
114+
({"Status": "RUNNING"}, None),
115+
({"Count": 42}, None),
116+
({"Price": 99.99}, None),
117+
({"IsPriority": True}, None),
118+
({"Tags": ["a", "b"]}, None),
119+
({"Counts": [1, 2, 3]}, None),
120+
(
121+
{"CreatedAt": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)},
122+
{"CreatedAt": datetime},
123+
),
124+
],
125+
)
126+
def test_roundtrip(
127+
self,
128+
converter: SearchAttributeConverter,
129+
attrs: dict[str, type],
130+
type_hints: dict[str, type] | None,
131+
) -> None:
132+
encoded = converter.encode(attrs)
133+
decoded = converter.decode(encoded, type_hints=type_hints)
134+
assert decoded == attrs
135+
136+
137+
class TestValidate:
138+
def test_valid(self) -> None:
139+
validate_search_attributes({"Status": "RUNNING", "Count": 42})
140+
141+
def test_empty_raises(self) -> None:
142+
with pytest.raises(ValueError, match="search attributes is empty"):
143+
validate_search_attributes({})
144+
145+
def test_reserved_key_raises(self) -> None:
146+
with pytest.raises(ValueError, match="reserved key"):
147+
validate_search_attributes({CADENCE_CHANGE_VERSION: ["v1"]})
148+
149+
def test_reserved_key_allowed(self) -> None:
150+
validate_search_attributes({CADENCE_CHANGE_VERSION: ["v1"]}, allow_reserved_keys=True)
151+
152+
153+
def test_cadence_change_version_constant() -> None:
154+
assert CADENCE_CHANGE_VERSION == "CadenceChangeVersion"

0 commit comments

Comments
 (0)