threadify-sdk 0.2.0__py3-none-any.whl
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/__init__.py +51 -0
- threadify/client.py +191 -0
- threadify/connection.py +505 -0
- threadify/data_retriever.py +476 -0
- threadify/models.py +285 -0
- threadify/notification.py +164 -0
- threadify/otel_exporter.py +318 -0
- threadify/step.py +312 -0
- threadify/thread.py +323 -0
- threadify_sdk-0.2.0.dist-info/METADATA +181 -0
- threadify_sdk-0.2.0.dist-info/RECORD +14 -0
- threadify_sdk-0.2.0.dist-info/WHEEL +5 -0
- threadify_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
- threadify_sdk-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from threadify.models import (
|
|
9
|
+
CompleteDataOptions,
|
|
10
|
+
HistoryQueryOptions,
|
|
11
|
+
RefQuery,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
THREAD_FIELDS = """
|
|
15
|
+
id
|
|
16
|
+
contractId
|
|
17
|
+
contractName
|
|
18
|
+
contractVersion
|
|
19
|
+
ownerId
|
|
20
|
+
companyId
|
|
21
|
+
status
|
|
22
|
+
lastHash
|
|
23
|
+
startedAt
|
|
24
|
+
completedAt
|
|
25
|
+
error
|
|
26
|
+
refs
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
STEP_FIELDS = """
|
|
30
|
+
threadId
|
|
31
|
+
stepName
|
|
32
|
+
idempotencyKey
|
|
33
|
+
status
|
|
34
|
+
retryCount
|
|
35
|
+
firstSeenAt
|
|
36
|
+
lastUpdatedAt
|
|
37
|
+
latestStepID
|
|
38
|
+
previousStep
|
|
39
|
+
verified
|
|
40
|
+
verificationError
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
STEP_HISTORY_FIELDS = """
|
|
44
|
+
attempt
|
|
45
|
+
timestamp
|
|
46
|
+
status
|
|
47
|
+
context
|
|
48
|
+
duration
|
|
49
|
+
error
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
SUB_STEP_FIELDS = """
|
|
53
|
+
id
|
|
54
|
+
threadId
|
|
55
|
+
stepId
|
|
56
|
+
name
|
|
57
|
+
status
|
|
58
|
+
payload
|
|
59
|
+
recordedAt
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
VALIDATION_RESULT_FIELDS = """
|
|
63
|
+
validationId
|
|
64
|
+
threadId
|
|
65
|
+
stepId
|
|
66
|
+
stepName
|
|
67
|
+
idempotencyKey
|
|
68
|
+
timestamp
|
|
69
|
+
validations {
|
|
70
|
+
type
|
|
71
|
+
message
|
|
72
|
+
field
|
|
73
|
+
expected
|
|
74
|
+
actual
|
|
75
|
+
rule
|
|
76
|
+
}
|
|
77
|
+
overallStatus
|
|
78
|
+
hasCriticalViolation
|
|
79
|
+
criticalCount
|
|
80
|
+
warningCount
|
|
81
|
+
infoCount
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class GraphQLClient:
|
|
86
|
+
"""Performs authenticated GraphQL requests."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, url: str, api_key: str):
|
|
89
|
+
self._url = url
|
|
90
|
+
self._api_key = api_key
|
|
91
|
+
self._client = httpx.AsyncClient()
|
|
92
|
+
|
|
93
|
+
async def query(
|
|
94
|
+
self, gql_query: str, variables: dict[str, Any] | None = None
|
|
95
|
+
) -> dict[str, Any]:
|
|
96
|
+
"""Execute a GraphQL query and return the data portion."""
|
|
97
|
+
body = {"query": gql_query, "variables": variables or {}}
|
|
98
|
+
|
|
99
|
+
resp = await self._client.post(
|
|
100
|
+
self._url,
|
|
101
|
+
json=body,
|
|
102
|
+
headers={
|
|
103
|
+
"Content-Type": "application/json",
|
|
104
|
+
"X-API-Key": self._api_key,
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if resp.status_code != 200:
|
|
109
|
+
raise RuntimeError(f"GraphQL request failed: {resp.status_code} {resp.text}")
|
|
110
|
+
|
|
111
|
+
result = resp.json()
|
|
112
|
+
|
|
113
|
+
if result.get("errors"):
|
|
114
|
+
raise RuntimeError(
|
|
115
|
+
f"GraphQL errors: {result['errors'][0].get('message', 'Unknown error')}"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return result.get("data", {})
|
|
119
|
+
|
|
120
|
+
async def close(self) -> None:
|
|
121
|
+
await self._client.aclose()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class DataRetriever:
|
|
125
|
+
"""Read access to archived thread data via GraphQL."""
|
|
126
|
+
|
|
127
|
+
def __init__(self, graphql_url: str, api_key: str):
|
|
128
|
+
self._client = GraphQLClient(graphql_url, api_key)
|
|
129
|
+
|
|
130
|
+
async def get_thread(self, thread_id: str) -> ArchivedThread:
|
|
131
|
+
"""Retrieve an archived thread by ID."""
|
|
132
|
+
query = f"""
|
|
133
|
+
query GetThread($id: ID!) {{
|
|
134
|
+
thread(id: $id) {{
|
|
135
|
+
{THREAD_FIELDS}
|
|
136
|
+
}}
|
|
137
|
+
}}
|
|
138
|
+
"""
|
|
139
|
+
data = await self._client.query(query, {"id": thread_id})
|
|
140
|
+
thread_data = data.get("thread")
|
|
141
|
+
if not thread_data:
|
|
142
|
+
raise RuntimeError(f"Thread not found: {thread_id}")
|
|
143
|
+
return ArchivedThread(thread_data, self._client)
|
|
144
|
+
|
|
145
|
+
async def get_threads_by_ref(self, q: RefQuery) -> list[ArchivedThread]:
|
|
146
|
+
"""Retrieve threads by reference key-value pair."""
|
|
147
|
+
query = f"""
|
|
148
|
+
query GetThreadsByRef(
|
|
149
|
+
$refKey: String!
|
|
150
|
+
$refValue: String!
|
|
151
|
+
$status: String
|
|
152
|
+
$startedAfter: String
|
|
153
|
+
$startedBefore: String
|
|
154
|
+
$limit: Int
|
|
155
|
+
$offset: Int
|
|
156
|
+
) {{
|
|
157
|
+
threadsByRef(
|
|
158
|
+
refKey: $refKey
|
|
159
|
+
refValue: $refValue
|
|
160
|
+
status: $status
|
|
161
|
+
startedAfter: $startedAfter
|
|
162
|
+
startedBefore: $startedBefore
|
|
163
|
+
limit: $limit
|
|
164
|
+
offset: $offset
|
|
165
|
+
) {{
|
|
166
|
+
{THREAD_FIELDS}
|
|
167
|
+
}}
|
|
168
|
+
}}
|
|
169
|
+
"""
|
|
170
|
+
variables: dict[str, Any] = {
|
|
171
|
+
"refKey": q.ref_key,
|
|
172
|
+
"refValue": q.ref_value,
|
|
173
|
+
"limit": q.limit or 50,
|
|
174
|
+
"offset": q.offset or 0,
|
|
175
|
+
}
|
|
176
|
+
if q.status:
|
|
177
|
+
variables["status"] = q.status
|
|
178
|
+
if q.started_after:
|
|
179
|
+
variables["startedAfter"] = q.started_after
|
|
180
|
+
if q.started_before:
|
|
181
|
+
variables["startedBefore"] = q.started_before
|
|
182
|
+
data = await self._client.query(query, variables)
|
|
183
|
+
threads_list = data.get("threadsByRef") or []
|
|
184
|
+
return [ArchivedThread(t, self._client) for t in threads_list if isinstance(t, dict)]
|
|
185
|
+
|
|
186
|
+
async def get_validation_results(self, thread_id: str, step_name: str = "") -> list[dict[str, Any]]:
|
|
187
|
+
"""Retrieve validation results for a thread, optionally filtered by step."""
|
|
188
|
+
query = f"""
|
|
189
|
+
query GetThreadValidations($threadId: ID!, $options: ValidationQueryOptions) {{
|
|
190
|
+
thread(id: $threadId) {{
|
|
191
|
+
validationResults(options: $options) {{
|
|
192
|
+
{VALIDATION_RESULT_FIELDS}
|
|
193
|
+
}}
|
|
194
|
+
}}
|
|
195
|
+
}}
|
|
196
|
+
"""
|
|
197
|
+
options: dict[str, Any] = {"limit": 50}
|
|
198
|
+
if step_name:
|
|
199
|
+
options["stepName"] = step_name
|
|
200
|
+
data = await self._client.query(query, {"threadId": thread_id, "options": options})
|
|
201
|
+
thread_data = data.get("thread") or {}
|
|
202
|
+
return thread_data.get("validationResults") or []
|
|
203
|
+
|
|
204
|
+
async def get_thread_chain(self, root_id: str, max_depth: int = 3) -> list[ArchivedThread]:
|
|
205
|
+
"""Retrieve a thread chain from the root."""
|
|
206
|
+
if not root_id:
|
|
207
|
+
raise RuntimeError("root_id is required")
|
|
208
|
+
if max_depth <= 0:
|
|
209
|
+
max_depth = 3
|
|
210
|
+
|
|
211
|
+
query = f"""
|
|
212
|
+
query GetThreadChain($rootId: ID!, $maxDepth: Int) {{
|
|
213
|
+
threadChain(rootId: $rootId, maxDepth: $maxDepth) {{
|
|
214
|
+
{THREAD_FIELDS}
|
|
215
|
+
}}
|
|
216
|
+
}}
|
|
217
|
+
"""
|
|
218
|
+
data = await self._client.query(
|
|
219
|
+
query,
|
|
220
|
+
{
|
|
221
|
+
"rootId": root_id,
|
|
222
|
+
"maxDepth": max_depth,
|
|
223
|
+
},
|
|
224
|
+
)
|
|
225
|
+
chain_list = data.get("threadChain") or []
|
|
226
|
+
return [ArchivedThread(t, self._client) for t in chain_list if isinstance(t, dict)]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class ArchivedThread:
|
|
230
|
+
"""Historical thread with read-only access."""
|
|
231
|
+
|
|
232
|
+
def __init__(self, data: dict[str, Any], client: GraphQLClient):
|
|
233
|
+
self.id: str = data.get("id", "")
|
|
234
|
+
self.contract_id: str = data.get("contractId", "")
|
|
235
|
+
self.contract_name: str = data.get("contractName", "")
|
|
236
|
+
self.contract_version: str = data.get("contractVersion", "")
|
|
237
|
+
self.owner_id: str = data.get("ownerId", "")
|
|
238
|
+
self.company_id: str = data.get("companyId", "")
|
|
239
|
+
self.status: str = data.get("status", "")
|
|
240
|
+
self.last_hash: str = data.get("lastHash", "")
|
|
241
|
+
self.started_at: str = data.get("startedAt", "")
|
|
242
|
+
self.completed_at: str = data.get("completedAt", "")
|
|
243
|
+
self.error: str = data.get("error", "")
|
|
244
|
+
|
|
245
|
+
# Parse refs from JSON string.
|
|
246
|
+
refs_raw = data.get("refs", "")
|
|
247
|
+
if isinstance(refs_raw, str) and refs_raw:
|
|
248
|
+
try:
|
|
249
|
+
self.refs: dict[str, Any] = json.loads(refs_raw)
|
|
250
|
+
except (json.JSONDecodeError, TypeError):
|
|
251
|
+
self.refs = {}
|
|
252
|
+
elif isinstance(refs_raw, dict):
|
|
253
|
+
self.refs = refs_raw
|
|
254
|
+
else:
|
|
255
|
+
self.refs = {}
|
|
256
|
+
|
|
257
|
+
self._client = client
|
|
258
|
+
|
|
259
|
+
async def steps(
|
|
260
|
+
self,
|
|
261
|
+
step_name: str = "",
|
|
262
|
+
idempotency_key: str = "",
|
|
263
|
+
status: str = "",
|
|
264
|
+
) -> list[ArchivedStep]:
|
|
265
|
+
"""Retrieve steps for this thread, optionally filtered."""
|
|
266
|
+
if not self.id:
|
|
267
|
+
raise RuntimeError("thread ID is required")
|
|
268
|
+
|
|
269
|
+
query = f"""
|
|
270
|
+
query GetThreadSteps(
|
|
271
|
+
$threadId: ID!
|
|
272
|
+
$stepName: String
|
|
273
|
+
$idempotencyKey: String
|
|
274
|
+
$status: String
|
|
275
|
+
) {{
|
|
276
|
+
thread(id: $threadId) {{
|
|
277
|
+
steps(stepName: $stepName, idempotencyKey: $idempotencyKey, status: $status) {{
|
|
278
|
+
{STEP_FIELDS}
|
|
279
|
+
history(limit: 1) {{
|
|
280
|
+
{STEP_HISTORY_FIELDS}
|
|
281
|
+
}}
|
|
282
|
+
}}
|
|
283
|
+
}}
|
|
284
|
+
}}
|
|
285
|
+
"""
|
|
286
|
+
variables: dict[str, Any] = {"threadId": self.id}
|
|
287
|
+
if step_name:
|
|
288
|
+
variables["stepName"] = step_name
|
|
289
|
+
if idempotency_key:
|
|
290
|
+
variables["idempotencyKey"] = idempotency_key
|
|
291
|
+
if status:
|
|
292
|
+
variables["status"] = status
|
|
293
|
+
|
|
294
|
+
data = await self._client.query(query, variables)
|
|
295
|
+
thread_data = data.get("thread") or {}
|
|
296
|
+
steps_list = thread_data.get("steps") or []
|
|
297
|
+
return [ArchivedStep(s, self._client) for s in steps_list if isinstance(s, dict)]
|
|
298
|
+
|
|
299
|
+
async def validation_results(self, limit: int = 10) -> list[dict[str, Any]]:
|
|
300
|
+
"""Retrieve validation results for this thread."""
|
|
301
|
+
if limit <= 0:
|
|
302
|
+
limit = 10
|
|
303
|
+
|
|
304
|
+
query = f"""
|
|
305
|
+
query GetThreadValidations($threadId: ID!, $options: ValidationQueryOptions) {{
|
|
306
|
+
thread(id: $threadId) {{
|
|
307
|
+
validationResults(options: $options) {{
|
|
308
|
+
{VALIDATION_RESULT_FIELDS}
|
|
309
|
+
}}
|
|
310
|
+
}}
|
|
311
|
+
}}
|
|
312
|
+
"""
|
|
313
|
+
data = await self._client.query(
|
|
314
|
+
query,
|
|
315
|
+
{
|
|
316
|
+
"threadId": self.id,
|
|
317
|
+
"options": {"limit": limit},
|
|
318
|
+
},
|
|
319
|
+
)
|
|
320
|
+
thread_data = data.get("thread") or {}
|
|
321
|
+
return thread_data.get("validationResults") or []
|
|
322
|
+
|
|
323
|
+
async def get_complete_data(self, options: CompleteDataOptions | None = None) -> dict[str, Any]:
|
|
324
|
+
"""Retrieve complete thread data (steps + history + validations) in one query."""
|
|
325
|
+
opts = options or CompleteDataOptions()
|
|
326
|
+
step_history_limit = opts.step_history_limit if opts.step_history_limit > 0 else 50
|
|
327
|
+
validation_limit = opts.validation_limit if opts.validation_limit > 0 else 10
|
|
328
|
+
|
|
329
|
+
query = f"""
|
|
330
|
+
query GetCompleteThread(
|
|
331
|
+
$id: ID!
|
|
332
|
+
$stepName: String
|
|
333
|
+
$idempotencyKey: String
|
|
334
|
+
$status: String
|
|
335
|
+
$stepHistoryLimit: Int
|
|
336
|
+
$validationLimit: Int
|
|
337
|
+
) {{
|
|
338
|
+
thread(id: $id) {{
|
|
339
|
+
{THREAD_FIELDS}
|
|
340
|
+
steps(stepName: $stepName, idempotencyKey: $idempotencyKey, status: $status) {{
|
|
341
|
+
{STEP_FIELDS}
|
|
342
|
+
history(limit: $stepHistoryLimit) {{
|
|
343
|
+
{STEP_HISTORY_FIELDS}
|
|
344
|
+
}}
|
|
345
|
+
}}
|
|
346
|
+
validationResults(options: {{limit: $validationLimit}}) {{
|
|
347
|
+
{VALIDATION_RESULT_FIELDS}
|
|
348
|
+
}}
|
|
349
|
+
}}
|
|
350
|
+
}}
|
|
351
|
+
"""
|
|
352
|
+
variables: dict[str, Any] = {
|
|
353
|
+
"id": self.id,
|
|
354
|
+
"stepHistoryLimit": step_history_limit,
|
|
355
|
+
"validationLimit": validation_limit,
|
|
356
|
+
}
|
|
357
|
+
if opts.step_name:
|
|
358
|
+
variables["stepName"] = opts.step_name
|
|
359
|
+
if opts.idempotency_key:
|
|
360
|
+
variables["idempotencyKey"] = opts.idempotency_key
|
|
361
|
+
if opts.status:
|
|
362
|
+
variables["status"] = opts.status
|
|
363
|
+
|
|
364
|
+
data = await self._client.query(query, variables)
|
|
365
|
+
thread_data = data.get("thread")
|
|
366
|
+
if not thread_data:
|
|
367
|
+
raise RuntimeError(f"Thread not found: {self.id}")
|
|
368
|
+
return thread_data
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class ArchivedStep:
|
|
372
|
+
"""Historical step with read-only access."""
|
|
373
|
+
|
|
374
|
+
def __init__(self, data: dict[str, Any], client: GraphQLClient):
|
|
375
|
+
self.thread_id: str = data.get("threadId", "")
|
|
376
|
+
self.step_name: str = data.get("stepName", "")
|
|
377
|
+
self.idempotency_key: str = data.get("idempotencyKey", "")
|
|
378
|
+
self.status: str = data.get("status", "")
|
|
379
|
+
self.retry_count: int = int(data.get("retryCount", 0))
|
|
380
|
+
self.first_seen_at: str = data.get("firstSeenAt", "")
|
|
381
|
+
self.last_updated_at: str = data.get("lastUpdatedAt", "")
|
|
382
|
+
self.latest_step_id: str = data.get("latestStepID", "")
|
|
383
|
+
self.previous_step: str = data.get("previousStep", "")
|
|
384
|
+
self.verified: bool = data.get("verified", False)
|
|
385
|
+
self.verification_error: str = data.get("verificationError", "")
|
|
386
|
+
|
|
387
|
+
# Last execution from history.
|
|
388
|
+
history = data.get("history") or []
|
|
389
|
+
self.last_execution: dict[str, Any] | None = history[0] if history else None
|
|
390
|
+
|
|
391
|
+
self._client = client
|
|
392
|
+
|
|
393
|
+
async def history(self, options: HistoryQueryOptions | None = None) -> list[dict[str, Any]]:
|
|
394
|
+
"""Retrieve execution history for this step."""
|
|
395
|
+
opts = options or HistoryQueryOptions()
|
|
396
|
+
limit = opts.limit if opts.limit > 0 else 100
|
|
397
|
+
|
|
398
|
+
query = f"""
|
|
399
|
+
query GetStepHistory(
|
|
400
|
+
$threadId: String!
|
|
401
|
+
$stepName: String!
|
|
402
|
+
$idempotencyKey: String
|
|
403
|
+
$limit: Int
|
|
404
|
+
$offset: Int
|
|
405
|
+
$startAt: String
|
|
406
|
+
$endAt: String
|
|
407
|
+
$activityType: String
|
|
408
|
+
$actor: String
|
|
409
|
+
) {{
|
|
410
|
+
stepHistory(
|
|
411
|
+
threadId: $threadId
|
|
412
|
+
stepName: $stepName
|
|
413
|
+
idempotencyKey: $idempotencyKey
|
|
414
|
+
limit: $limit
|
|
415
|
+
offset: $offset
|
|
416
|
+
startAt: $startAt
|
|
417
|
+
endAt: $endAt
|
|
418
|
+
activityType: $activityType
|
|
419
|
+
actor: $actor
|
|
420
|
+
) {{
|
|
421
|
+
{STEP_HISTORY_FIELDS}
|
|
422
|
+
}}
|
|
423
|
+
}}
|
|
424
|
+
"""
|
|
425
|
+
variables: dict[str, Any] = {
|
|
426
|
+
"threadId": self.thread_id,
|
|
427
|
+
"stepName": self.step_name,
|
|
428
|
+
"limit": limit,
|
|
429
|
+
}
|
|
430
|
+
if self.idempotency_key:
|
|
431
|
+
variables["idempotencyKey"] = self.idempotency_key
|
|
432
|
+
if opts.offset:
|
|
433
|
+
variables["offset"] = opts.offset
|
|
434
|
+
if opts.start_at:
|
|
435
|
+
variables["startAt"] = opts.start_at
|
|
436
|
+
if opts.end_at:
|
|
437
|
+
variables["endAt"] = opts.end_at
|
|
438
|
+
if opts.activity_type:
|
|
439
|
+
variables["activityType"] = opts.activity_type
|
|
440
|
+
if opts.actor:
|
|
441
|
+
variables["actor"] = opts.actor
|
|
442
|
+
|
|
443
|
+
data = await self._client.query(query, variables)
|
|
444
|
+
return data.get("stepHistory") or []
|
|
445
|
+
|
|
446
|
+
async def sub_steps(self) -> list[dict[str, Any]]:
|
|
447
|
+
"""Retrieve sub-steps for this step."""
|
|
448
|
+
query = f"""
|
|
449
|
+
query GetStepSubSteps(
|
|
450
|
+
$threadId: String!
|
|
451
|
+
$stepName: String!
|
|
452
|
+
$idempotencyKey: String
|
|
453
|
+
) {{
|
|
454
|
+
thread(id: $threadId) {{
|
|
455
|
+
steps(stepName: $stepName, idempotencyKey: $idempotencyKey) {{
|
|
456
|
+
subSteps {{
|
|
457
|
+
{SUB_STEP_FIELDS}
|
|
458
|
+
}}
|
|
459
|
+
}}
|
|
460
|
+
}}
|
|
461
|
+
}}
|
|
462
|
+
"""
|
|
463
|
+
variables: dict[str, Any] = {
|
|
464
|
+
"threadId": self.thread_id,
|
|
465
|
+
"stepName": self.step_name,
|
|
466
|
+
}
|
|
467
|
+
if self.idempotency_key:
|
|
468
|
+
variables["idempotencyKey"] = self.idempotency_key
|
|
469
|
+
|
|
470
|
+
data = await self._client.query(query, variables)
|
|
471
|
+
thread_data = data.get("thread") or {}
|
|
472
|
+
steps_list = thread_data.get("steps") or []
|
|
473
|
+
if not steps_list:
|
|
474
|
+
return []
|
|
475
|
+
first_step = steps_list[0] if isinstance(steps_list[0], dict) else {}
|
|
476
|
+
return first_step.get("subSteps") or []
|