threadify-sdk 0.2.7__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.7/threadify_sdk.egg-info → threadify_sdk-0.2.9}/PKG-INFO +25 -2
- {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/README.md +24 -1
- {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/pyproject.toml +1 -1
- {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/tests/test_client.py +16 -9
- {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/tests/test_connection.py +100 -0
- {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/tests/test_models.py +4 -3
- {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/tests/test_step.py +2 -3
- {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/threadify/connection.py +45 -21
- {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/threadify/data_retriever.py +6 -2
- {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/threadify/models.py +1 -0
- {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/threadify/otel_exporter.py +16 -3
- {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/threadify/step.py +0 -12
- {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/threadify/thread.py +1 -0
- {threadify_sdk-0.2.7 → threadify_sdk-0.2.9/threadify_sdk.egg-info}/PKG-INFO +25 -2
- {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/CHANGELOG.md +0 -0
- {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/LICENSE +0 -0
- {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/MANIFEST.in +0 -0
- {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/setup.cfg +0 -0
- {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/tests/test_data_retriever.py +0 -0
- {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/tests/test_notification.py +0 -0
- {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/tests/test_thread.py +0 -0
- {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/threadify/__init__.py +0 -0
- {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/threadify/client.py +0 -0
- {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/threadify/notification.py +0 -0
- {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/threadify_sdk.egg-info/SOURCES.txt +0 -0
- {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/threadify_sdk.egg-info/dependency_links.txt +0 -0
- {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/threadify_sdk.egg-info/requires.txt +0 -0
- {threadify_sdk-0.2.7 → 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,
|
|
@@ -35,11 +37,11 @@ from threadify.models import (
|
|
|
35
37
|
FIELD_SERVICE_NAME,
|
|
36
38
|
FIELD_STATUS,
|
|
37
39
|
FIELD_STEP_NAME,
|
|
40
|
+
FIELD_TAGS,
|
|
38
41
|
FIELD_THREAD_ID,
|
|
39
42
|
FIELD_THREAD_ID_ACK,
|
|
40
43
|
FIELD_THREAD_TOKEN,
|
|
41
44
|
STATUS_SUCCESS,
|
|
42
|
-
DEFAULT_PROCESSED_MAX_SIZE,
|
|
43
45
|
RefQuery,
|
|
44
46
|
first_non_empty,
|
|
45
47
|
require_non_empty,
|
|
@@ -144,6 +146,8 @@ class Connection:
|
|
|
144
146
|
contract_name: str = "",
|
|
145
147
|
service_name: str = "",
|
|
146
148
|
refs: dict[str, Any] | None = None,
|
|
149
|
+
tags: list[str] | None = None,
|
|
150
|
+
role: str = "",
|
|
147
151
|
) -> ThreadInstance:
|
|
148
152
|
from threadify.thread import ThreadInstance
|
|
149
153
|
|
|
@@ -152,27 +156,36 @@ class Connection:
|
|
|
152
156
|
|
|
153
157
|
effective_service = first_non_empty(service_name, self._service_name)
|
|
154
158
|
|
|
155
|
-
# Prepare refs
|
|
156
159
|
message_refs = (refs or {}).copy()
|
|
160
|
+
label_value = label
|
|
161
|
+
|
|
157
162
|
message_refs[FIELD_SERVICE_NAME] = effective_service
|
|
158
|
-
if
|
|
159
|
-
message_refs["label"] =
|
|
163
|
+
if label_value:
|
|
164
|
+
message_refs["label"] = label_value
|
|
160
165
|
|
|
161
166
|
msg: dict[str, Any] = {
|
|
162
167
|
FIELD_ACTION: ACTION_START_THREAD,
|
|
163
168
|
FIELD_REFS: message_refs,
|
|
164
169
|
}
|
|
165
170
|
|
|
171
|
+
effective_role = role
|
|
166
172
|
if contract_name:
|
|
167
173
|
msg[FIELD_CONTRACT_NAME] = contract_name
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
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
|
|
182
|
+
|
|
183
|
+
# Validate and attach tags if provided
|
|
184
|
+
if tags:
|
|
185
|
+
for t in tags:
|
|
186
|
+
if not isinstance(t, str) or not t.strip():
|
|
187
|
+
raise ValueError("Each tag must be a non-empty string")
|
|
188
|
+
msg[FIELD_TAGS] = list(tags)
|
|
176
189
|
|
|
177
190
|
await self._send(msg)
|
|
178
191
|
|
|
@@ -182,7 +195,15 @@ class Connection:
|
|
|
182
195
|
raise RuntimeError(resp.get(FIELD_MESSAGE, "failed to start thread"))
|
|
183
196
|
|
|
184
197
|
thread_id = resp[FIELD_THREAD_ID]
|
|
185
|
-
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
|
+
)
|
|
206
|
+
thread.tags = list(tags) if tags else []
|
|
186
207
|
self._threads[thread_id] = thread
|
|
187
208
|
self._logger.debug(f"Thread started: {thread_id}")
|
|
188
209
|
return thread
|
|
@@ -209,16 +230,19 @@ class Connection:
|
|
|
209
230
|
msg[FIELD_THREAD_TOKEN] = token
|
|
210
231
|
elif thread_id is not None:
|
|
211
232
|
require_non_empty("thread_id", thread_id)
|
|
212
|
-
require_non_empty("role", role)
|
|
213
233
|
msg[FIELD_THREAD_ID] = thread_id
|
|
214
|
-
msg[FIELD_ROLE] = role
|
|
215
|
-
elif token_or_thread_id is not None:
|
|
216
|
-
require_non_empty("token_or_thread_id", token_or_thread_id)
|
|
217
234
|
if role:
|
|
218
|
-
msg[FIELD_THREAD_ID] = token_or_thread_id
|
|
219
235
|
msg[FIELD_ROLE] = role
|
|
220
|
-
|
|
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:
|
|
221
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
|
|
222
246
|
else:
|
|
223
247
|
raise ValueError("provide token, thread_id+role, or token_or_thread_id")
|
|
224
248
|
|
|
@@ -264,7 +288,7 @@ class Connection:
|
|
|
264
288
|
event: str,
|
|
265
289
|
step_name_or_handler: str | NotificationHandler | None = None,
|
|
266
290
|
handler: NotificationHandler | None = None,
|
|
267
|
-
) ->
|
|
291
|
+
) -> Connection:
|
|
268
292
|
"""Subscribe to notifications for a step or thread-level event.
|
|
269
293
|
|
|
270
294
|
Supports two signatures:
|
|
@@ -296,7 +320,7 @@ class Connection:
|
|
|
296
320
|
self._notification_handlers.setdefault(key, []).append(actual_handler)
|
|
297
321
|
return self
|
|
298
322
|
|
|
299
|
-
def unsubscribe(self, event: str, step_name: str = "") ->
|
|
323
|
+
def unsubscribe(self, event: str, step_name: str = "") -> Connection:
|
|
300
324
|
"""Unsubscribe from notifications.
|
|
301
325
|
|
|
302
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:
|
|
@@ -71,6 +71,7 @@ class ThreadInstance:
|
|
|
71
71
|
self.role = role
|
|
72
72
|
self.access_level = access_level
|
|
73
73
|
self.refs: dict[str, str] = refs or {}
|
|
74
|
+
self.tags: list[str] = [] # Tags applied at thread creation (immutable)
|
|
74
75
|
|
|
75
76
|
self._steps: dict[str, Any] = {}
|
|
76
77
|
self._pending_waits: dict[str, _PendingWait] = {}
|
|
@@ -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
|