Skip to content

Commit 97a5e7e

Browse files
Add search attribute client parity
1 parent 2cf4dd3 commit 97a5e7e

6 files changed

Lines changed: 219 additions & 0 deletions

File tree

src/durable_workflow/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
ScheduleList,
2727
ScheduleSpec,
2828
ScheduleTriggerResult,
29+
SearchAttributeList,
2930
TaskQueueAdmission,
3031
TaskQueueDescription,
3132
TaskQueueList,
@@ -163,6 +164,7 @@
163164
"ScheduleNotFound",
164165
"ScheduleSpec",
165166
"ScheduleTriggerResult",
167+
"SearchAttributeList",
166168
"StartChildWorkflow",
167169
"TaskQueueAdmission",
168170
"TaskQueueDescription",

src/durable_workflow/client.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ def _route_for_metrics(path: str) -> str:
7070
parts[3] = "{run_id}"
7171
elif parts[0] == "schedules" and len(parts) >= 2:
7272
parts[1] = "{schedule_id}"
73+
elif parts[0] == "search-attributes" and len(parts) >= 2:
74+
parts[1] = "{name}"
7375
elif parts[:2] == ["bridge-adapters", "webhook"] and len(parts) >= 3:
7476
parts[2] = "{adapter}"
7577
elif (
@@ -206,6 +208,24 @@ class WorkflowRunList:
206208
runs: list[WorkflowRun]
207209

208210

211+
@dataclass
212+
class SearchAttributeList:
213+
"""Search attribute definitions available in the current namespace."""
214+
215+
system_attributes: dict[str, str]
216+
custom_attributes: dict[str, str]
217+
218+
@classmethod
219+
def from_dict(cls, data: dict[str, Any]) -> SearchAttributeList:
220+
system = data.get("system_attributes")
221+
custom = data.get("custom_attributes")
222+
223+
return cls(
224+
system_attributes=dict(system) if isinstance(system, dict) else {},
225+
custom_attributes=dict(custom) if isinstance(custom, dict) else {},
226+
)
227+
228+
209229
@dataclass
210230
class TaskQueueTaskAdmission:
211231
"""Workflow/activity admission state for one task queue."""
@@ -998,6 +1018,51 @@ async def describe_task_queue(self, name: str) -> TaskQueueDescription:
9981018
)
9991019
return TaskQueueDescription.from_dict(data)
10001020

1021+
# ── Search attributes ─────────────────────────────────────────────
1022+
async def list_search_attributes(self) -> SearchAttributeList:
1023+
"""List system and custom search attribute definitions for this namespace."""
1024+
data = await self._request("GET", "/search-attributes")
1025+
if not isinstance(data, dict):
1026+
raise ServerError(
1027+
200,
1028+
{
1029+
"reason": "invalid_search_attribute_response",
1030+
"message": f"expected JSON object, got {type(data).__name__}",
1031+
},
1032+
)
1033+
return SearchAttributeList.from_dict(data)
1034+
1035+
async def create_search_attribute(self, name: str, attribute_type: str) -> dict[str, Any]:
1036+
"""Register a custom search attribute and return the server response."""
1037+
data = await self._request(
1038+
"POST",
1039+
"/search-attributes",
1040+
json={"name": name, "type": attribute_type},
1041+
context=name,
1042+
)
1043+
if not isinstance(data, dict):
1044+
raise ServerError(
1045+
200,
1046+
{
1047+
"reason": "invalid_search_attribute_response",
1048+
"message": f"expected JSON object, got {type(data).__name__}",
1049+
},
1050+
)
1051+
return data
1052+
1053+
async def delete_search_attribute(self, name: str) -> dict[str, Any]:
1054+
"""Remove a custom search attribute and return the server response."""
1055+
data = await self._request("DELETE", f"/search-attributes/{quote(name, safe='')}", context=name)
1056+
if not isinstance(data, dict):
1057+
raise ServerError(
1058+
200,
1059+
{
1060+
"reason": "invalid_search_attribute_response",
1061+
"message": f"expected JSON object, got {type(data).__name__}",
1062+
},
1063+
)
1064+
return data
1065+
10011066
# ── Workflows ──────────────────────────────────────────────────────
10021067
async def start_workflow(
10031068
self,
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"schema": "durable-workflow.polyglot.control-plane-request-fixture",
3+
"version": 1,
4+
"operation": "search_attribute.create",
5+
"request": {
6+
"method": "POST",
7+
"path": "/search-attributes",
8+
"body": {
9+
"name": "OrderStatus",
10+
"type": "keyword"
11+
}
12+
},
13+
"semantic_body": {
14+
"name": "OrderStatus",
15+
"type": "keyword"
16+
},
17+
"response_body": {
18+
"name": "OrderStatus",
19+
"type": "keyword",
20+
"outcome": "created"
21+
},
22+
"cli": {
23+
"argv": {
24+
"name": "OrderStatus",
25+
"type": "keyword",
26+
"--json": true
27+
}
28+
},
29+
"sdk_python": {
30+
"method": "create_search_attribute",
31+
"args": {
32+
"name": "OrderStatus",
33+
"attribute_type": "keyword"
34+
}
35+
}
36+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"schema": "durable-workflow.polyglot.control-plane-request-fixture",
3+
"version": 1,
4+
"operation": "search_attribute.delete",
5+
"request": {
6+
"method": "DELETE",
7+
"path": "/search-attributes/OrderStatus"
8+
},
9+
"semantic_body": {
10+
"name": "OrderStatus",
11+
"outcome": "deleted"
12+
},
13+
"response_body": {
14+
"name": "OrderStatus",
15+
"outcome": "deleted"
16+
},
17+
"cli": {
18+
"argv": {
19+
"name": "OrderStatus",
20+
"--json": true
21+
}
22+
},
23+
"sdk_python": {
24+
"method": "delete_search_attribute",
25+
"args": {
26+
"name": "OrderStatus"
27+
}
28+
}
29+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"schema": "durable-workflow.polyglot.control-plane-request-fixture",
3+
"version": 1,
4+
"operation": "search_attribute.list",
5+
"request": {
6+
"method": "GET",
7+
"path": "/search-attributes"
8+
},
9+
"semantic_body": {
10+
"system_attributes": {
11+
"WorkflowType": "keyword",
12+
"StartTime": "datetime"
13+
},
14+
"custom_attributes": {
15+
"OrderStatus": "keyword",
16+
"Priority": "int"
17+
}
18+
},
19+
"response_body": {
20+
"system_attributes": {
21+
"WorkflowType": "keyword",
22+
"StartTime": "datetime"
23+
},
24+
"custom_attributes": {
25+
"OrderStatus": "keyword",
26+
"Priority": "int"
27+
}
28+
},
29+
"cli": {
30+
"argv": {
31+
"--json": true
32+
}
33+
},
34+
"sdk_python": {
35+
"method": "list_search_attributes",
36+
"args": {}
37+
}
38+
}

tests/test_client.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1074,6 +1074,55 @@ async def test_describe_task_queue_rejects_non_object_response(self, client: Cli
10741074
await client.describe_task_queue("orders")
10751075

10761076

1077+
class TestSearchAttributes:
1078+
@pytest.mark.asyncio
1079+
async def test_list_search_attributes_matches_polyglot_fixture(self, client: Client) -> None:
1080+
fixture_path = Path(__file__).parent / "fixtures" / "control-plane" / "search-attribute-list-parity.json"
1081+
fixture = json.loads(fixture_path.read_text())
1082+
resp = _mock_response(200, fixture["response_body"])
1083+
1084+
with patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp) as mock:
1085+
result = await client.list_search_attributes(**fixture["sdk_python"]["args"])
1086+
1087+
assert mock.call_args.args[:2] == (fixture["request"]["method"], f"/api{fixture['request']['path']}")
1088+
assert result.system_attributes == fixture["semantic_body"]["system_attributes"]
1089+
assert result.custom_attributes == fixture["semantic_body"]["custom_attributes"]
1090+
1091+
@pytest.mark.asyncio
1092+
async def test_create_search_attribute_matches_polyglot_fixture(self, client: Client) -> None:
1093+
fixture_path = Path(__file__).parent / "fixtures" / "control-plane" / "search-attribute-create-parity.json"
1094+
fixture = json.loads(fixture_path.read_text())
1095+
resp = _mock_response(200, fixture["response_body"])
1096+
1097+
with patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp) as mock:
1098+
result = await client.create_search_attribute(**fixture["sdk_python"]["args"])
1099+
1100+
assert mock.call_args.args[:2] == (fixture["request"]["method"], f"/api{fixture['request']['path']}")
1101+
assert mock.call_args.kwargs["json"] == fixture["request"]["body"]
1102+
assert result == fixture["response_body"]
1103+
1104+
@pytest.mark.asyncio
1105+
async def test_delete_search_attribute_matches_polyglot_fixture(self, client: Client) -> None:
1106+
fixture_path = Path(__file__).parent / "fixtures" / "control-plane" / "search-attribute-delete-parity.json"
1107+
fixture = json.loads(fixture_path.read_text())
1108+
resp = _mock_response(200, fixture["response_body"])
1109+
1110+
with patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp) as mock:
1111+
result = await client.delete_search_attribute(**fixture["sdk_python"]["args"])
1112+
1113+
assert mock.call_args.args[:2] == (fixture["request"]["method"], f"/api{fixture['request']['path']}")
1114+
assert result == fixture["response_body"]
1115+
1116+
@pytest.mark.asyncio
1117+
async def test_search_attribute_name_is_url_encoded(self, client: Client) -> None:
1118+
resp = _mock_response(200, {"name": "Customer Status", "outcome": "deleted"})
1119+
1120+
with patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp) as mock:
1121+
await client.delete_search_attribute("Customer Status")
1122+
1123+
assert mock.call_args.args[:2] == ("DELETE", "/api/search-attributes/Customer%20Status")
1124+
1125+
10771126
class TestSchedules:
10781127
@pytest.mark.asyncio
10791128
async def test_create_schedule_matches_polyglot_fixture(self, client: Client) -> None:

0 commit comments

Comments
 (0)