threadify-sdk 0.2.8__tar.gz → 0.2.9__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {threadify_sdk-0.2.8/threadify_sdk.egg-info → threadify_sdk-0.2.9}/PKG-INFO +25 -2
- {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/README.md +24 -1
- {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/pyproject.toml +1 -1
- {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/tests/test_client.py +16 -9
- {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/tests/test_connection.py +100 -0
- {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/tests/test_models.py +4 -3
- {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/tests/test_step.py +2 -3
- {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/threadify/connection.py +35 -21
- {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/threadify/data_retriever.py +6 -2
- {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/threadify/otel_exporter.py +16 -3
- {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/threadify/step.py +0 -12
- {threadify_sdk-0.2.8 → threadify_sdk-0.2.9/threadify_sdk.egg-info}/PKG-INFO +25 -2
- {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/CHANGELOG.md +0 -0
- {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/LICENSE +0 -0
- {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/MANIFEST.in +0 -0
- {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/setup.cfg +0 -0
- {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/tests/test_data_retriever.py +0 -0
- {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/tests/test_notification.py +0 -0
- {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/tests/test_thread.py +0 -0
- {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/threadify/__init__.py +0 -0
- {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/threadify/client.py +0 -0
- {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/threadify/models.py +0 -0
- {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/threadify/notification.py +0 -0
- {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/threadify/thread.py +0 -0
- {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/threadify_sdk.egg-info/SOURCES.txt +0 -0
- {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/threadify_sdk.egg-info/dependency_links.txt +0 -0
- {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/threadify_sdk.egg-info/requires.txt +0 -0
- {threadify_sdk-0.2.8 → threadify_sdk-0.2.9}/threadify_sdk.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: threadify-sdk
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.9
|
|
4
4
|
Summary: Python SDK for Threadify — Service-delivery intelligence. Track every customer request from start to finish across every system, team, and partner. Visit: https://threadify.dev
|
|
5
5
|
Author-email: Threadify Team <team@threadify.dev>
|
|
6
6
|
License: MIT
|
|
@@ -63,7 +63,14 @@ async def main():
|
|
|
63
63
|
return
|
|
64
64
|
|
|
65
65
|
try:
|
|
66
|
-
thread = await conn.start(
|
|
66
|
+
thread = await conn.start(
|
|
67
|
+
contract_name="order_processing",
|
|
68
|
+
role="customer",
|
|
69
|
+
refs={"order_id": "ORD-123"},
|
|
70
|
+
tags=["priority"],
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
await thread.add_refs({"crm_id": "CRM-456"})
|
|
67
74
|
|
|
68
75
|
# Easy chaining!
|
|
69
76
|
await (
|
|
@@ -130,6 +137,22 @@ thread = await conn.join(
|
|
|
130
137
|
)
|
|
131
138
|
```
|
|
132
139
|
|
|
140
|
+
### Start options
|
|
141
|
+
|
|
142
|
+
`Connection.start(...)` supports named options for contract, role, refs, and tags, while preserving the older positional forms:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
thread = await conn.start(
|
|
146
|
+
contract_name="order_processing",
|
|
147
|
+
role="customer",
|
|
148
|
+
refs={"customer_id": "123"},
|
|
149
|
+
tags=["priority"],
|
|
150
|
+
)
|
|
151
|
+
thread = await conn.start("Order-123", "customer")
|
|
152
|
+
thread = await conn.start({"customer_id": "123"}, "customer")
|
|
153
|
+
thread = await conn.start("Order-123") # contract is optional
|
|
154
|
+
```
|
|
155
|
+
|
|
133
156
|
## Subscriptions
|
|
134
157
|
|
|
135
158
|
Preferred API:
|
|
@@ -36,7 +36,14 @@ async def main():
|
|
|
36
36
|
return
|
|
37
37
|
|
|
38
38
|
try:
|
|
39
|
-
thread = await conn.start(
|
|
39
|
+
thread = await conn.start(
|
|
40
|
+
contract_name="order_processing",
|
|
41
|
+
role="customer",
|
|
42
|
+
refs={"order_id": "ORD-123"},
|
|
43
|
+
tags=["priority"],
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
await thread.add_refs({"crm_id": "CRM-456"})
|
|
40
47
|
|
|
41
48
|
# Easy chaining!
|
|
42
49
|
await (
|
|
@@ -103,6 +110,22 @@ thread = await conn.join(
|
|
|
103
110
|
)
|
|
104
111
|
```
|
|
105
112
|
|
|
113
|
+
### Start options
|
|
114
|
+
|
|
115
|
+
`Connection.start(...)` supports named options for contract, role, refs, and tags, while preserving the older positional forms:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
thread = await conn.start(
|
|
119
|
+
contract_name="order_processing",
|
|
120
|
+
role="customer",
|
|
121
|
+
refs={"customer_id": "123"},
|
|
122
|
+
tags=["priority"],
|
|
123
|
+
)
|
|
124
|
+
thread = await conn.start("Order-123", "customer")
|
|
125
|
+
thread = await conn.start({"customer_id": "123"}, "customer")
|
|
126
|
+
thread = await conn.start("Order-123") # contract is optional
|
|
127
|
+
```
|
|
128
|
+
|
|
106
129
|
## Subscriptions
|
|
107
130
|
|
|
108
131
|
Preferred API:
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "threadify-sdk"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.9"
|
|
8
8
|
description = "Python SDK for Threadify — Service-delivery intelligence. Track every customer request from start to finish across every system, team, and partner. Visit: https://threadify.dev"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -9,14 +9,21 @@ from threadify.models import ConnectOptions
|
|
|
9
9
|
|
|
10
10
|
class TestThreadifyConnect:
|
|
11
11
|
@pytest.mark.asyncio
|
|
12
|
-
async def
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
)
|
|
12
|
+
async def test_connect_uses_default_ws_url_when_not_provided(self, monkeypatch):
|
|
13
|
+
ws = AsyncMock()
|
|
14
|
+
ws.send = AsyncMock()
|
|
15
|
+
ws.recv = AsyncMock(return_value=json.dumps({"action": "connect", "status": "success"}))
|
|
16
|
+
ws.close = AsyncMock()
|
|
17
|
+
|
|
18
|
+
ws_connect = AsyncMock(return_value=ws)
|
|
16
19
|
monkeypatch.setattr("threadify.client.websockets.connect", ws_connect)
|
|
17
20
|
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
conn = await Threadify.connect("test-api-key", "test-service")
|
|
22
|
+
|
|
23
|
+
ws_connect.assert_awaited_once_with("wss://eng.threadify.dev/threads")
|
|
24
|
+
sent_msg = json.loads(ws.send.call_args.args[0])
|
|
25
|
+
assert sent_msg["serviceName"] == "test-service"
|
|
26
|
+
assert conn.is_connected is True
|
|
20
27
|
|
|
21
28
|
@pytest.mark.asyncio
|
|
22
29
|
async def test_connect_with_explicit_ws_url(self, monkeypatch):
|
|
@@ -64,6 +71,6 @@ class TestThreadifyConnect:
|
|
|
64
71
|
|
|
65
72
|
|
|
66
73
|
class TestThreadifyFactory:
|
|
67
|
-
def
|
|
68
|
-
|
|
69
|
-
|
|
74
|
+
def test_create_uses_default_ws_url(self):
|
|
75
|
+
factory = Threadify.create("test-api-key")
|
|
76
|
+
assert factory._options.ws_url == "wss://eng.threadify.dev/threads"
|
|
@@ -36,6 +36,7 @@ def _make_connection():
|
|
|
36
36
|
conn._notification_handlers = {}
|
|
37
37
|
conn._active_subscriptions = {}
|
|
38
38
|
conn._processed_notifications = set()
|
|
39
|
+
conn._processed_notifications_max_size = 10_000
|
|
39
40
|
conn._recv_queue = asyncio.Queue()
|
|
40
41
|
conn._data_retriever = None
|
|
41
42
|
import logging
|
|
@@ -120,6 +121,105 @@ class TestConnectionProperties:
|
|
|
120
121
|
assert conn.is_connected is True
|
|
121
122
|
|
|
122
123
|
|
|
124
|
+
class TestStart:
|
|
125
|
+
@pytest.mark.asyncio
|
|
126
|
+
async def test_start_with_label_and_optional_contract(self):
|
|
127
|
+
conn = _make_connection()
|
|
128
|
+
conn._send = AsyncMock()
|
|
129
|
+
conn._wait_response = AsyncMock(
|
|
130
|
+
return_value={
|
|
131
|
+
"action": "startThread",
|
|
132
|
+
"status": "success",
|
|
133
|
+
"threadId": "thread-123",
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
thread = await conn.start("customer-123", "customer")
|
|
138
|
+
|
|
139
|
+
assert thread.thread_id == "thread-123"
|
|
140
|
+
assert thread.role == "test"
|
|
141
|
+
assert thread.refs["label"] == "customer-123"
|
|
142
|
+
assert thread.refs["serviceName"] == "test-service"
|
|
143
|
+
sent_msg = conn._send.call_args.args[0]
|
|
144
|
+
assert sent_msg["refs"]["label"] == "customer-123"
|
|
145
|
+
assert sent_msg["contractName"] == "customer"
|
|
146
|
+
assert sent_msg["role"] == "test"
|
|
147
|
+
|
|
148
|
+
@pytest.mark.asyncio
|
|
149
|
+
async def test_start_with_refs_and_contract(self):
|
|
150
|
+
conn = _make_connection()
|
|
151
|
+
conn._send = AsyncMock()
|
|
152
|
+
conn._wait_response = AsyncMock(
|
|
153
|
+
return_value={
|
|
154
|
+
"action": "startThread",
|
|
155
|
+
"status": "success",
|
|
156
|
+
"threadId": "thread-legacy-123",
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
thread = await conn.start(refs={"customer_id": "123"}, contract_name="customer")
|
|
161
|
+
|
|
162
|
+
assert thread.thread_id == "thread-legacy-123"
|
|
163
|
+
assert thread.refs["customer_id"] == "123"
|
|
164
|
+
assert thread.refs["serviceName"] == "test-service"
|
|
165
|
+
sent_msg = conn._send.call_args.args[0]
|
|
166
|
+
assert sent_msg["refs"]["customer_id"] == "123"
|
|
167
|
+
assert "label" not in sent_msg["refs"]
|
|
168
|
+
assert sent_msg["contractName"] == "customer"
|
|
169
|
+
|
|
170
|
+
@pytest.mark.asyncio
|
|
171
|
+
async def test_start_without_contract_still_works(self):
|
|
172
|
+
conn = _make_connection()
|
|
173
|
+
conn._send = AsyncMock()
|
|
174
|
+
conn._wait_response = AsyncMock(
|
|
175
|
+
return_value={
|
|
176
|
+
"action": "startThread",
|
|
177
|
+
"status": "success",
|
|
178
|
+
"threadId": "thread-no-contract-123",
|
|
179
|
+
}
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
thread = await conn.start("customer-123")
|
|
183
|
+
|
|
184
|
+
assert thread.thread_id == "thread-no-contract-123"
|
|
185
|
+
assert thread.refs["label"] == "customer-123"
|
|
186
|
+
assert thread.refs["serviceName"] == "test-service"
|
|
187
|
+
sent_msg = conn._send.call_args.args[0]
|
|
188
|
+
assert sent_msg["refs"]["label"] == "customer-123"
|
|
189
|
+
assert "contractName" not in sent_msg
|
|
190
|
+
assert "role" not in sent_msg
|
|
191
|
+
|
|
192
|
+
@pytest.mark.asyncio
|
|
193
|
+
async def test_start_accepts_keyword_config_options(self):
|
|
194
|
+
conn = _make_connection()
|
|
195
|
+
conn._send = AsyncMock()
|
|
196
|
+
conn._wait_response = AsyncMock(
|
|
197
|
+
return_value={
|
|
198
|
+
"action": "startThread",
|
|
199
|
+
"status": "success",
|
|
200
|
+
"threadId": "thread-options-123",
|
|
201
|
+
}
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
thread = await conn.start(
|
|
205
|
+
contract_name="order_processing",
|
|
206
|
+
role="merchant",
|
|
207
|
+
refs={"customer_id": "123"},
|
|
208
|
+
tags=["priority", "external"],
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
assert thread.thread_id == "thread-options-123"
|
|
212
|
+
assert thread.role == "merchant"
|
|
213
|
+
assert thread.tags == ["priority", "external"]
|
|
214
|
+
assert thread.refs["customer_id"] == "123"
|
|
215
|
+
assert thread.refs["serviceName"] == "test-service"
|
|
216
|
+
sent_msg = conn._send.call_args.args[0]
|
|
217
|
+
assert sent_msg["contractName"] == "order_processing"
|
|
218
|
+
assert sent_msg["role"] == "merchant"
|
|
219
|
+
assert sent_msg["refs"]["customer_id"] == "123"
|
|
220
|
+
assert sent_msg["tags"] == ["priority", "external"]
|
|
221
|
+
|
|
222
|
+
|
|
123
223
|
class TestJoin:
|
|
124
224
|
@pytest.mark.asyncio
|
|
125
225
|
async def test_join_with_token_keyword(self):
|
|
@@ -3,6 +3,7 @@ import pytest
|
|
|
3
3
|
from threadify.models import (
|
|
4
4
|
DEFAULT_CONNECT_TIMEOUT,
|
|
5
5
|
DEFAULT_MAX_IN_FLIGHT,
|
|
6
|
+
DEFAULT_WS_URL,
|
|
6
7
|
ConnectOptions,
|
|
7
8
|
StepResult,
|
|
8
9
|
StepStatus,
|
|
@@ -77,10 +78,10 @@ class TestConnectOptions:
|
|
|
77
78
|
def test_defaults(self):
|
|
78
79
|
opts = ConnectOptions()
|
|
79
80
|
opts.with_defaults()
|
|
80
|
-
assert opts.ws_url ==
|
|
81
|
+
assert opts.ws_url == DEFAULT_WS_URL
|
|
81
82
|
assert opts.max_in_flight == DEFAULT_MAX_IN_FLIGHT
|
|
82
83
|
assert opts.connect_timeout == DEFAULT_CONNECT_TIMEOUT
|
|
83
|
-
assert opts.graphql_url ==
|
|
84
|
+
assert opts.graphql_url == derive_graphql_url(DEFAULT_WS_URL)
|
|
84
85
|
|
|
85
86
|
def test_preserves_custom(self):
|
|
86
87
|
opts = ConnectOptions(
|
|
@@ -100,7 +101,7 @@ class TestConnectOptions:
|
|
|
100
101
|
opts2 = ConnectOptions(ws_url="wss://custom.com/threads", max_in_flight=100)
|
|
101
102
|
opts2.validate() # Should not raise.
|
|
102
103
|
|
|
103
|
-
def
|
|
104
|
+
def test_validate_requires_non_empty_ws_url(self):
|
|
104
105
|
opts = ConnectOptions(ws_url="")
|
|
105
106
|
with pytest.raises(ValueError, match="ws_url is required"):
|
|
106
107
|
opts.validate()
|
|
@@ -24,9 +24,7 @@ class TestThreadStepFluent:
|
|
|
24
24
|
def test_chaining(self):
|
|
25
25
|
thread = _make_thread()
|
|
26
26
|
step = ThreadStep("order_placed", thread, "test-svc")
|
|
27
|
-
result = step.add_context({"orderId": "ORD-1", "amount": "99.99"})
|
|
28
|
-
{"stripe": "pi_abc"}
|
|
29
|
-
)
|
|
27
|
+
result = step.add_context({"orderId": "ORD-1", "amount": "99.99"})
|
|
30
28
|
assert result is step # Fluent returns self.
|
|
31
29
|
assert step.context == {"orderId": "ORD-1", "amount": "99.99"}
|
|
32
30
|
|
|
@@ -278,3 +276,4 @@ class TestStepProperties:
|
|
|
278
276
|
assert data["action"] == "recordThreadEvent"
|
|
279
277
|
assert data["stepName"] == "order_placed"
|
|
280
278
|
assert data["threadId"] == "thread-001"
|
|
279
|
+
assert "refs" not in data
|
|
@@ -20,6 +20,8 @@ from threadify.models import (
|
|
|
20
20
|
ACTION_START_THREAD,
|
|
21
21
|
ACTION_SUBSCRIBE,
|
|
22
22
|
ACTION_UNSUBSCRIBE,
|
|
23
|
+
DEFAULT_PROCESSED_MAX_SIZE,
|
|
24
|
+
FIELD_ACCESS_LEVEL,
|
|
23
25
|
FIELD_ACK_TOKEN,
|
|
24
26
|
FIELD_ACTION,
|
|
25
27
|
FIELD_CONTRACT_NAME,
|
|
@@ -40,7 +42,6 @@ from threadify.models import (
|
|
|
40
42
|
FIELD_THREAD_ID_ACK,
|
|
41
43
|
FIELD_THREAD_TOKEN,
|
|
42
44
|
STATUS_SUCCESS,
|
|
43
|
-
DEFAULT_PROCESSED_MAX_SIZE,
|
|
44
45
|
RefQuery,
|
|
45
46
|
first_non_empty,
|
|
46
47
|
require_non_empty,
|
|
@@ -146,6 +147,7 @@ class Connection:
|
|
|
146
147
|
service_name: str = "",
|
|
147
148
|
refs: dict[str, Any] | None = None,
|
|
148
149
|
tags: list[str] | None = None,
|
|
150
|
+
role: str = "",
|
|
149
151
|
) -> ThreadInstance:
|
|
150
152
|
from threadify.thread import ThreadInstance
|
|
151
153
|
|
|
@@ -154,27 +156,29 @@ class Connection:
|
|
|
154
156
|
|
|
155
157
|
effective_service = first_non_empty(service_name, self._service_name)
|
|
156
158
|
|
|
157
|
-
# Prepare refs
|
|
158
159
|
message_refs = (refs or {}).copy()
|
|
160
|
+
label_value = label
|
|
161
|
+
|
|
159
162
|
message_refs[FIELD_SERVICE_NAME] = effective_service
|
|
160
|
-
if
|
|
161
|
-
message_refs["label"] =
|
|
163
|
+
if label_value:
|
|
164
|
+
message_refs["label"] = label_value
|
|
162
165
|
|
|
163
166
|
msg: dict[str, Any] = {
|
|
164
167
|
FIELD_ACTION: ACTION_START_THREAD,
|
|
165
168
|
FIELD_REFS: message_refs,
|
|
166
169
|
}
|
|
167
170
|
|
|
171
|
+
effective_role = role
|
|
168
172
|
if contract_name:
|
|
169
173
|
msg[FIELD_CONTRACT_NAME] = contract_name
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
msg[FIELD_ROLE] =
|
|
174
|
+
if not effective_role:
|
|
175
|
+
if effective_service:
|
|
176
|
+
effective_role = effective_service.removesuffix("-service")
|
|
177
|
+
else:
|
|
178
|
+
effective_role = "participant"
|
|
179
|
+
|
|
180
|
+
if effective_role:
|
|
181
|
+
msg[FIELD_ROLE] = effective_role
|
|
178
182
|
|
|
179
183
|
# Validate and attach tags if provided
|
|
180
184
|
if tags:
|
|
@@ -191,7 +195,14 @@ class Connection:
|
|
|
191
195
|
raise RuntimeError(resp.get(FIELD_MESSAGE, "failed to start thread"))
|
|
192
196
|
|
|
193
197
|
thread_id = resp[FIELD_THREAD_ID]
|
|
194
|
-
thread = ThreadInstance(
|
|
198
|
+
thread = ThreadInstance(
|
|
199
|
+
self,
|
|
200
|
+
thread_id,
|
|
201
|
+
contract_name,
|
|
202
|
+
effective_role,
|
|
203
|
+
resp.get(FIELD_ACCESS_LEVEL, ""),
|
|
204
|
+
message_refs.copy(),
|
|
205
|
+
)
|
|
195
206
|
thread.tags = list(tags) if tags else []
|
|
196
207
|
self._threads[thread_id] = thread
|
|
197
208
|
self._logger.debug(f"Thread started: {thread_id}")
|
|
@@ -219,16 +230,19 @@ class Connection:
|
|
|
219
230
|
msg[FIELD_THREAD_TOKEN] = token
|
|
220
231
|
elif thread_id is not None:
|
|
221
232
|
require_non_empty("thread_id", thread_id)
|
|
222
|
-
require_non_empty("role", role)
|
|
223
233
|
msg[FIELD_THREAD_ID] = thread_id
|
|
224
|
-
msg[FIELD_ROLE] = role
|
|
225
|
-
elif token_or_thread_id is not None:
|
|
226
|
-
require_non_empty("token_or_thread_id", token_or_thread_id)
|
|
227
234
|
if role:
|
|
228
|
-
msg[FIELD_THREAD_ID] = token_or_thread_id
|
|
229
235
|
msg[FIELD_ROLE] = role
|
|
230
|
-
|
|
236
|
+
elif token_or_thread_id is not None:
|
|
237
|
+
require_non_empty("token_or_thread_id", token_or_thread_id)
|
|
238
|
+
# If it has 3 parts (JWT) or is a long string, it's likely a token. Otherwise it's a thread ID.
|
|
239
|
+
is_token = len(token_or_thread_id.split('.')) == 3 or len(token_or_thread_id) > 50
|
|
240
|
+
if is_token and not role:
|
|
231
241
|
msg[FIELD_THREAD_TOKEN] = token_or_thread_id
|
|
242
|
+
else:
|
|
243
|
+
msg[FIELD_THREAD_ID] = token_or_thread_id
|
|
244
|
+
if role:
|
|
245
|
+
msg[FIELD_ROLE] = role
|
|
232
246
|
else:
|
|
233
247
|
raise ValueError("provide token, thread_id+role, or token_or_thread_id")
|
|
234
248
|
|
|
@@ -274,7 +288,7 @@ class Connection:
|
|
|
274
288
|
event: str,
|
|
275
289
|
step_name_or_handler: str | NotificationHandler | None = None,
|
|
276
290
|
handler: NotificationHandler | None = None,
|
|
277
|
-
) ->
|
|
291
|
+
) -> Connection:
|
|
278
292
|
"""Subscribe to notifications for a step or thread-level event.
|
|
279
293
|
|
|
280
294
|
Supports two signatures:
|
|
@@ -306,7 +320,7 @@ class Connection:
|
|
|
306
320
|
self._notification_handlers.setdefault(key, []).append(actual_handler)
|
|
307
321
|
return self
|
|
308
322
|
|
|
309
|
-
def unsubscribe(self, event: str, step_name: str = "") ->
|
|
323
|
+
def unsubscribe(self, event: str, step_name: str = "") -> Connection:
|
|
310
324
|
"""Unsubscribe from notifications.
|
|
311
325
|
|
|
312
326
|
Args:
|
|
@@ -183,8 +183,12 @@ class DataRetriever:
|
|
|
183
183
|
if q.started_before:
|
|
184
184
|
variables["startedBefore"] = q.started_before
|
|
185
185
|
data = await self._client.query(query, variables)
|
|
186
|
-
|
|
187
|
-
|
|
186
|
+
threads_by_ref = data.get("threadsByRef")
|
|
187
|
+
if isinstance(threads_by_ref, list):
|
|
188
|
+
threads_list = threads_by_ref
|
|
189
|
+
else:
|
|
190
|
+
connection = threads_by_ref or {}
|
|
191
|
+
threads_list = connection.get("threads") or []
|
|
188
192
|
return [ArchivedThread(t, self._client) for t in threads_list if isinstance(t, dict)]
|
|
189
193
|
|
|
190
194
|
async def get_validation_results(self, thread_id: str, step_name: str = "") -> list[dict[str, Any]]:
|
|
@@ -150,6 +150,7 @@ class ThreadifySpanExporter(_SpanExporterBase):
|
|
|
150
150
|
"threadify.step_name",
|
|
151
151
|
"threadify.role",
|
|
152
152
|
"threadify.service",
|
|
153
|
+
"threadify.tags",
|
|
153
154
|
}:
|
|
154
155
|
continue
|
|
155
156
|
|
|
@@ -169,7 +170,7 @@ class ThreadifySpanExporter(_SpanExporterBase):
|
|
|
169
170
|
if context:
|
|
170
171
|
step.add_context(context)
|
|
171
172
|
if refs:
|
|
172
|
-
|
|
173
|
+
await thread.add_refs(refs)
|
|
173
174
|
|
|
174
175
|
# Map timing (OTel uses nanoseconds since epoch)
|
|
175
176
|
start_time_ns = span.start_time
|
|
@@ -228,8 +229,6 @@ class ThreadifySpanExporter(_SpanExporterBase):
|
|
|
228
229
|
|
|
229
230
|
async def _get_or_start_thread(self, span: ReadableSpan, trace_id: str) -> Any:
|
|
230
231
|
"""Get or create a ThreadInstance for this trace."""
|
|
231
|
-
from threadify.thread import ThreadInstance
|
|
232
|
-
|
|
233
232
|
if trace_id not in self._trace_threads:
|
|
234
233
|
fut: asyncio.Future[Any] = asyncio.get_event_loop().create_future()
|
|
235
234
|
self._trace_threads[trace_id] = fut
|
|
@@ -264,10 +263,12 @@ class ThreadifySpanExporter(_SpanExporterBase):
|
|
|
264
263
|
except Exception:
|
|
265
264
|
pass
|
|
266
265
|
|
|
266
|
+
tags = self._span_attr_list(span, "threadify.tags")
|
|
267
267
|
thread = await self._connection.start(
|
|
268
268
|
label=label,
|
|
269
269
|
contract_name=contract_name or "",
|
|
270
270
|
service_name=service_name,
|
|
271
|
+
tags=tags,
|
|
271
272
|
)
|
|
272
273
|
fut.set_result(thread)
|
|
273
274
|
except Exception as exc:
|
|
@@ -298,6 +299,18 @@ class ThreadifySpanExporter(_SpanExporterBase):
|
|
|
298
299
|
value = span.attributes.get(key)
|
|
299
300
|
return str(value) if value is not None else None
|
|
300
301
|
|
|
302
|
+
@staticmethod
|
|
303
|
+
def _span_attr_list(span: ReadableSpan, key: str) -> list[str] | None:
|
|
304
|
+
value = span.attributes.get(key)
|
|
305
|
+
if value is None:
|
|
306
|
+
return None
|
|
307
|
+
if isinstance(value, list):
|
|
308
|
+
return [str(v) for v in value]
|
|
309
|
+
if isinstance(value, str):
|
|
310
|
+
# Allow comma-separated tags as a fallback
|
|
311
|
+
return [v.strip() for v in value.split(",") if v.strip()]
|
|
312
|
+
return None
|
|
313
|
+
|
|
301
314
|
@staticmethod
|
|
302
315
|
def _make_result(code: int, error: str | None = None) -> Any:
|
|
303
316
|
"""Build an OpenTelemetry ExportResult-compatible object."""
|
|
@@ -13,7 +13,6 @@ from threadify.models import (
|
|
|
13
13
|
FIELD_IDEMPOTENCY_KEY,
|
|
14
14
|
FIELD_IS_DUPLICATE,
|
|
15
15
|
FIELD_MESSAGE,
|
|
16
|
-
FIELD_REFS,
|
|
17
16
|
FIELD_SERVICE_NAME,
|
|
18
17
|
FIELD_STARTED_AT,
|
|
19
18
|
FIELD_STATUS,
|
|
@@ -41,7 +40,6 @@ class ThreadStep:
|
|
|
41
40
|
result = await (
|
|
42
41
|
step
|
|
43
42
|
.add_context({"orderId": "ORD-123", "amount": 99.99})
|
|
44
|
-
.add_refs({"stripe_id": "pi_abc"})
|
|
45
43
|
.success("Order placed!")
|
|
46
44
|
)
|
|
47
45
|
"""
|
|
@@ -59,7 +57,6 @@ class ThreadStep:
|
|
|
59
57
|
self._manual_idempotency_key: str = ""
|
|
60
58
|
self._sub_steps: list[SubStepData] = []
|
|
61
59
|
self._context: dict[str, str] = {}
|
|
62
|
-
self._refs: dict[str, str] = {}
|
|
63
60
|
self._metadata: dict[str, Any] | None = None
|
|
64
61
|
self._error: Exception | None = None
|
|
65
62
|
|
|
@@ -108,14 +105,6 @@ class ThreadStep:
|
|
|
108
105
|
self._context[f"private_{k}"] = s
|
|
109
106
|
return self
|
|
110
107
|
|
|
111
|
-
def add_refs(self, refs: dict[str, str] | None) -> ThreadStep:
|
|
112
|
-
"""Add external system references."""
|
|
113
|
-
if self._error is not None:
|
|
114
|
-
return self
|
|
115
|
-
if refs:
|
|
116
|
-
self._refs.update(refs)
|
|
117
|
-
return self
|
|
118
|
-
|
|
119
108
|
def sub_step(
|
|
120
109
|
self,
|
|
121
110
|
name: str,
|
|
@@ -181,7 +170,6 @@ class ThreadStep:
|
|
|
181
170
|
self._event[FIELD_FINISHED_AT] = now_iso()
|
|
182
171
|
self._event[FIELD_STATUS] = status
|
|
183
172
|
self._event[FIELD_CONTEXT] = self._context
|
|
184
|
-
self._event[FIELD_REFS] = self._refs
|
|
185
173
|
|
|
186
174
|
# Handle optional message/data.
|
|
187
175
|
if message_or_data is not None:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: threadify-sdk
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.9
|
|
4
4
|
Summary: Python SDK for Threadify — Service-delivery intelligence. Track every customer request from start to finish across every system, team, and partner. Visit: https://threadify.dev
|
|
5
5
|
Author-email: Threadify Team <team@threadify.dev>
|
|
6
6
|
License: MIT
|
|
@@ -63,7 +63,14 @@ async def main():
|
|
|
63
63
|
return
|
|
64
64
|
|
|
65
65
|
try:
|
|
66
|
-
thread = await conn.start(
|
|
66
|
+
thread = await conn.start(
|
|
67
|
+
contract_name="order_processing",
|
|
68
|
+
role="customer",
|
|
69
|
+
refs={"order_id": "ORD-123"},
|
|
70
|
+
tags=["priority"],
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
await thread.add_refs({"crm_id": "CRM-456"})
|
|
67
74
|
|
|
68
75
|
# Easy chaining!
|
|
69
76
|
await (
|
|
@@ -130,6 +137,22 @@ thread = await conn.join(
|
|
|
130
137
|
)
|
|
131
138
|
```
|
|
132
139
|
|
|
140
|
+
### Start options
|
|
141
|
+
|
|
142
|
+
`Connection.start(...)` supports named options for contract, role, refs, and tags, while preserving the older positional forms:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
thread = await conn.start(
|
|
146
|
+
contract_name="order_processing",
|
|
147
|
+
role="customer",
|
|
148
|
+
refs={"customer_id": "123"},
|
|
149
|
+
tags=["priority"],
|
|
150
|
+
)
|
|
151
|
+
thread = await conn.start("Order-123", "customer")
|
|
152
|
+
thread = await conn.start({"customer_id": "123"}, "customer")
|
|
153
|
+
thread = await conn.start("Order-123") # contract is optional
|
|
154
|
+
```
|
|
155
|
+
|
|
133
156
|
## Subscriptions
|
|
134
157
|
|
|
135
158
|
Preferred API:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|