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
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())
|