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/models.py ADDED
@@ -0,0 +1,285 @@
1
+ import logging
2
+ from dataclasses import dataclass, field
3
+ from datetime import datetime, timezone
4
+ from enum import Enum
5
+ from typing import Any
6
+
7
+ # --- Constants ---
8
+
9
+ DEFAULT_CONNECT_TIMEOUT = 10.0 # seconds
10
+ DEFAULT_REQUEST_TIMEOUT = 10.0
11
+ DEFAULT_WAIT_TIMEOUT = 5.0
12
+ DEFAULT_MAX_IN_FLIGHT = 10
13
+ MIN_MAX_IN_FLIGHT = 1
14
+ MAX_MAX_IN_FLIGHT = 100
15
+ DEFAULT_PROCESSED_MAX_SIZE = 10_000
16
+
17
+ # Protocol actions
18
+ ACTION_CONNECT = "connect"
19
+ ACTION_START_THREAD = "startThread"
20
+ ACTION_JOIN_THREAD = "joinThread"
21
+ ACTION_RECORD_THREAD_EVENT = "recordThreadEvent"
22
+ ACTION_INVITE_PARTY = "inviteParty"
23
+ ACTION_ADD_REFS = "addRefs"
24
+ ACTION_THREAD_END = "threadEnd"
25
+ ACTION_CLOSE_THREAD = "closeThread"
26
+ ACTION_SUBSCRIBE = "subscribe"
27
+ ACTION_UNSUBSCRIBE = "unsubscribe"
28
+ ACTION_NOTIFICATION = "notification"
29
+ ACTION_NOTIFICATION_BATCH = "notification_batch"
30
+ ACTION_CLOSE_CONNECTION = "closeConnection"
31
+ ACTION_ACK_NOTIFICATION = "ack_notification"
32
+
33
+ # Protocol fields
34
+ FIELD_ACTION = "action"
35
+ FIELD_STATUS = "status"
36
+ FIELD_MESSAGE = "message"
37
+ FIELD_API_KEY = "apiKey"
38
+ FIELD_SERVICE_NAME = "serviceName"
39
+ FIELD_MAX_IN_FLIGHT = "maxInFlight"
40
+ FIELD_THREAD_ID = "threadId"
41
+ FIELD_STEP_NAME = "stepName"
42
+ FIELD_ROLE = "role"
43
+ FIELD_REFS = "refs"
44
+ FIELD_CONTRACT_NAME = "contractName"
45
+ FIELD_EVENT_TYPES = "eventTypes"
46
+ FIELD_NOTIFICATION = "notification"
47
+ FIELD_NOTIFICATIONS = "notifications"
48
+ FIELD_ACK_TOKEN = "ackToken"
49
+ FIELD_THREAD_TOKEN = "threadToken"
50
+ FIELD_NOTIFICATION_ID = "notificationId"
51
+ FIELD_NOTIFICATION_ID_ACK = "notification_id"
52
+ FIELD_THREAD_ID_ACK = "thread_id"
53
+ FIELD_PROCESSED = "processed"
54
+ FIELD_STEP_STATUS = "stepStatus"
55
+ FIELD_NOTIFICATION_TYPE = "notificationType"
56
+ FIELD_SOURCE = "source"
57
+ FIELD_CONTEXT = "context"
58
+ FIELD_FINISHED_AT = "finishedAt"
59
+ FIELD_STARTED_AT = "startedAt"
60
+ FIELD_IDEMPOTENCY_KEY = "idempotencyKey"
61
+ FIELD_IS_DUPLICATE = "isDuplicate"
62
+ FIELD_SUB_STEPS = "subSteps"
63
+ FIELD_THREADIFY_METADATA = "threadify_metadata"
64
+ FIELD_ACCESS_LEVEL = "accessLevel"
65
+ FIELD_EXPIRES_IN = "expiresIn"
66
+ FIELD_EXPIRES_AT = "expiresAt"
67
+ FIELD_REASON = "reason"
68
+ FIELD_THREAD_STATUS = "threadStatus"
69
+ FIELD_CLOSED_AT = "closedAt"
70
+ FIELD_COMPLETED_AT = "completedAt"
71
+ FIELD_CANCELLED_AT = "cancelledAt"
72
+ FIELD_DETAILS = "details"
73
+ FIELD_OWNER_ID = "ownerId"
74
+ FIELD_SEVERITY = "severity"
75
+ FIELD_TIMESTAMP = "timestamp"
76
+ FIELD_VIOLATION_TYPE = "violationType"
77
+
78
+ # Protocol status values
79
+ STATUS_SUCCESS = "success"
80
+ STATUS_FAILED = "failed"
81
+ STATUS_ERROR = "error"
82
+ STATUS_IN_PROGRESS = "in_progress"
83
+ STATUS_PASSED = "passed"
84
+ STATUS_VIOLATED = "violated"
85
+ STATUS_CANCELLED = "cancelled"
86
+ STATUS_COMPLETED = "completed"
87
+
88
+ # Protocol severity values
89
+ SEVERITY_INFO = "info"
90
+ SEVERITY_WARNING = "warning"
91
+ SEVERITY_CRITICAL = "critical"
92
+
93
+
94
+ # --- Enums ---
95
+
96
+
97
+ class StepStatus(str, Enum):
98
+ IN_PROGRESS = "in_progress"
99
+ SUCCESS = "success"
100
+ FAILED = "failed"
101
+ ERROR = "error"
102
+ SKIPPED = "skipped"
103
+
104
+
105
+ class ValidationSeverity(str, Enum):
106
+ INFO = "info"
107
+ WARNING = "warning"
108
+ CRITICAL = "critical"
109
+
110
+
111
+ @dataclass
112
+ class ConnectOptions:
113
+ """Configuration for connecting to the Threadify Engine."""
114
+
115
+ service_name: str = ""
116
+ ws_url: str = ""
117
+ graphql_url: str = ""
118
+ debug: bool = False
119
+ max_in_flight: int = DEFAULT_MAX_IN_FLIGHT
120
+ connect_timeout: float = DEFAULT_CONNECT_TIMEOUT
121
+ logger: logging.Logger | None = None
122
+
123
+ def with_defaults(self) -> "ConnectOptions":
124
+ if not self.graphql_url and self.ws_url:
125
+ self.graphql_url = derive_graphql_url(self.ws_url)
126
+ if self.max_in_flight == 0:
127
+ self.max_in_flight = DEFAULT_MAX_IN_FLIGHT
128
+ if self.connect_timeout == 0:
129
+ self.connect_timeout = DEFAULT_CONNECT_TIMEOUT
130
+ return self
131
+
132
+ def validate(self) -> None:
133
+ if not self.ws_url or not self.ws_url.strip():
134
+ raise ValueError("ws_url is required")
135
+ if not (MIN_MAX_IN_FLIGHT <= self.max_in_flight <= MAX_MAX_IN_FLIGHT):
136
+ raise ValueError(
137
+ f"max_in_flight must be between {MIN_MAX_IN_FLIGHT} and {MAX_MAX_IN_FLIGHT}"
138
+ )
139
+
140
+
141
+ @dataclass
142
+ class StepResult:
143
+ """Result of recording a step event."""
144
+
145
+ step_name: str
146
+ thread_id: str
147
+ status: str
148
+ idempotency_key: str
149
+ timestamp: str
150
+ duplicate: bool = False
151
+
152
+
153
+ @dataclass
154
+ class SubStepData:
155
+ """Data for a sub-step within a parent step."""
156
+
157
+ name: str
158
+ status: str = "success"
159
+ payload: dict[str, Any] | None = None
160
+ recorded_at: str = ""
161
+
162
+ def __post_init__(self):
163
+ if not self.recorded_at:
164
+ self.recorded_at = now_iso()
165
+
166
+
167
+ @dataclass
168
+ class InviteOptions:
169
+ """Options for inviting a party to join a thread."""
170
+
171
+ role: str = ""
172
+ access_level: str = "external"
173
+ expires_in: str = "24h"
174
+
175
+
176
+ @dataclass
177
+ class InviteResponse:
178
+ """Response from creating a party invitation."""
179
+
180
+ token: str
181
+ thread_id: str
182
+ role: str
183
+ access_level: str
184
+ expires_at: str
185
+
186
+
187
+ @dataclass
188
+ class ThreadEndResponse:
189
+ """Response from ending a thread."""
190
+
191
+ thread_id: str
192
+ status: str
193
+ ended_at: str
194
+ message: str = ""
195
+
196
+
197
+ @dataclass
198
+ class WaitOptions:
199
+ """Options for waiting on a specific step notification."""
200
+
201
+ timeout: float = DEFAULT_WAIT_TIMEOUT
202
+ statuses: list[str] = field(default_factory=list)
203
+
204
+
205
+ @dataclass
206
+ class NotificationData:
207
+ """Raw notification data from the server."""
208
+
209
+ notification_id: str = ""
210
+ thread_id: str = ""
211
+ step_id: str = ""
212
+ step_name: str = ""
213
+ contract_name: str = ""
214
+ status: str = ""
215
+ step_status: str = ""
216
+ severity: str = ""
217
+ message: str = ""
218
+ details: dict[str, Any] = field(default_factory=dict)
219
+ timestamp: str = ""
220
+ violation_type: str = ""
221
+ owner_id: str = ""
222
+ source: str = ""
223
+ notification_type: str = ""
224
+
225
+
226
+ @dataclass
227
+ class RefQuery:
228
+ """Query parameters for fetching threads by reference."""
229
+
230
+ ref_key: str = ""
231
+ ref_value: str = ""
232
+ status: str = ""
233
+ started_after: str = ""
234
+ started_before: str = ""
235
+ limit: int = 50
236
+ offset: int = 0
237
+
238
+
239
+ @dataclass
240
+ class CompleteDataOptions:
241
+ """Options for ArchivedThread.get_complete_data."""
242
+
243
+ step_history_limit: int = 50
244
+ validation_limit: int = 10
245
+ step_name: str = ""
246
+ idempotency_key: str = ""
247
+ status: str = ""
248
+
249
+
250
+ @dataclass
251
+ class HistoryQueryOptions:
252
+ """Filter options for step history queries."""
253
+
254
+ limit: int = 100
255
+ offset: int = 0
256
+ start_at: str = ""
257
+ end_at: str = ""
258
+ activity_type: str = ""
259
+ actor: str = ""
260
+
261
+
262
+ def derive_graphql_url(ws_url: str) -> str:
263
+ """Convert a WebSocket URL to its corresponding GraphQL URL."""
264
+ out = ws_url.replace("ws://", "http://", 1)
265
+ out = out.replace("wss://", "https://", 1)
266
+ return out.replace("/threads", "/graphql", 1)
267
+
268
+
269
+ def require_non_empty(name: str, value: str) -> None:
270
+ """Raise ValueError if value is empty or whitespace."""
271
+ if not value or not value.strip():
272
+ raise ValueError(f"{name} must be a non-empty string")
273
+
274
+
275
+ def first_non_empty(*values: str) -> str:
276
+ """Return the first non-empty, non-whitespace string."""
277
+ for v in values:
278
+ if v and v.strip():
279
+ return v
280
+ return ""
281
+
282
+
283
+ def now_iso() -> str:
284
+ """Return the current UTC time in ISO 8601 format."""
285
+ return datetime.now(timezone.utc).isoformat()
@@ -0,0 +1,164 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime, timezone
5
+ from typing import Any
6
+
7
+ from threadify.models import (
8
+ FIELD_CONTRACT_NAME,
9
+ FIELD_DETAILS,
10
+ FIELD_MESSAGE,
11
+ FIELD_NOTIFICATION_ID,
12
+ FIELD_NOTIFICATION_TYPE,
13
+ FIELD_OWNER_ID,
14
+ FIELD_SEVERITY,
15
+ FIELD_SOURCE,
16
+ FIELD_STATUS,
17
+ FIELD_STEP_NAME,
18
+ FIELD_STEP_STATUS,
19
+ FIELD_THREAD_ID,
20
+ FIELD_TIMESTAMP,
21
+ FIELD_VIOLATION_TYPE,
22
+ SEVERITY_CRITICAL,
23
+ SEVERITY_INFO,
24
+ SEVERITY_WARNING,
25
+ STATUS_ERROR,
26
+ STATUS_FAILED,
27
+ STATUS_PASSED,
28
+ STATUS_SUCCESS,
29
+ STATUS_VIOLATED,
30
+ )
31
+
32
+
33
+ class Notification:
34
+ """Wraps a real-time notification from the Threadify Engine.
35
+
36
+ Provides helper methods to inspect severity and status,
37
+ and an :meth:`ack` method to acknowledge receipt.
38
+
39
+ Usage::
40
+
41
+ def handler(notif):
42
+ if notif.is_violated and notif.is_critical:
43
+ print(f"Critical: {notif.message}")
44
+ notif.ack()
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ data: dict[str, Any],
50
+ connection: Any,
51
+ ack_token: str = "",
52
+ ):
53
+ self.notification_id: str = data.get(FIELD_NOTIFICATION_ID, "")
54
+ self.thread_id: str = data.get(FIELD_THREAD_ID, "")
55
+ self.step_id: str = data.get("stepId", "")
56
+ self.step_name: str = data.get(FIELD_STEP_NAME, "")
57
+ self.contract_name: str = data.get(FIELD_CONTRACT_NAME, "")
58
+ self.status: str = data.get(FIELD_STATUS, "")
59
+ self.step_status: str = data.get(FIELD_STEP_STATUS, "")
60
+ self.severity: str = data.get(FIELD_SEVERITY, "")
61
+ self.message: str = data.get(FIELD_MESSAGE, "")
62
+ self.details: dict[str, Any] = data.get(FIELD_DETAILS) or {}
63
+ self.violation_type: str = data.get(FIELD_VIOLATION_TYPE, "")
64
+ self.owner_id: str = data.get(FIELD_OWNER_ID, "")
65
+ self.source: str = data.get(FIELD_SOURCE, "")
66
+ self.notification_type: str = data.get(FIELD_NOTIFICATION_TYPE, "")
67
+
68
+ ts_str = data.get(FIELD_TIMESTAMP, "")
69
+ try:
70
+ self.timestamp = (
71
+ datetime.fromisoformat(ts_str) if ts_str else datetime.now(timezone.utc)
72
+ )
73
+ except (ValueError, TypeError):
74
+ self.timestamp = datetime.now(timezone.utc)
75
+
76
+ self._ack_token = ack_token
77
+ self._connection = connection
78
+ self._acknowledged = False
79
+
80
+ def ack(self) -> None:
81
+ """Acknowledge this notification so it won't be redelivered.
82
+
83
+ ACK is idempotent — safe to call multiple times.
84
+
85
+ Raises:
86
+ RuntimeError: If ack_token is missing.
87
+ """
88
+ if self._acknowledged:
89
+ return
90
+
91
+ if not self._ack_token:
92
+ raise RuntimeError(
93
+ f"Cannot ACK notification {self.notification_id}: ackToken is required"
94
+ )
95
+
96
+ self._acknowledged = True
97
+ self._connection._send_ack(self.notification_id, self.thread_id, self._ack_token)
98
+
99
+ # --- Status helpers ---
100
+
101
+ @property
102
+ def is_acknowledged(self) -> bool:
103
+ return self._acknowledged
104
+
105
+ @property
106
+ def is_violated(self) -> bool:
107
+ return self.status == STATUS_VIOLATED
108
+
109
+ @property
110
+ def is_passed(self) -> bool:
111
+ return self.status == STATUS_PASSED
112
+
113
+ @property
114
+ def is_critical(self) -> bool:
115
+ return self.severity == SEVERITY_CRITICAL
116
+
117
+ @property
118
+ def is_warning(self) -> bool:
119
+ return self.severity == SEVERITY_WARNING
120
+
121
+ @property
122
+ def is_info(self) -> bool:
123
+ return self.severity == SEVERITY_INFO
124
+
125
+ @property
126
+ def is_success(self) -> bool:
127
+ return self.step_status == STATUS_SUCCESS
128
+
129
+ @property
130
+ def is_failed(self) -> bool:
131
+ return self.step_status == STATUS_FAILED
132
+
133
+ @property
134
+ def is_error(self) -> bool:
135
+ return self.step_status == STATUS_ERROR
136
+
137
+ # --- Serialisation ---
138
+
139
+ def __str__(self) -> str:
140
+ sev = self.severity or "unknown"
141
+ return f"[{sev}] {self.step_name}: {self.message}"
142
+
143
+ def to_dict(self) -> dict[str, Any]:
144
+ """Return a plain dict representation for logging/serialisation."""
145
+ return {
146
+ "notificationId": self.notification_id,
147
+ "threadId": self.thread_id,
148
+ "stepId": self.step_id,
149
+ "stepName": self.step_name,
150
+ "contractName": self.contract_name,
151
+ "status": self.status,
152
+ "stepStatus": self.step_status,
153
+ "severity": self.severity,
154
+ "message": self.message,
155
+ "details": self.details,
156
+ "timestamp": self.timestamp.isoformat(),
157
+ "violationType": self.violation_type,
158
+ "ownerId": self.owner_id,
159
+ "acknowledged": self._acknowledged,
160
+ }
161
+
162
+ def to_json(self) -> str:
163
+ """Return JSON string representation."""
164
+ return json.dumps(self.to_dict())