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.
@@ -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 []