bdo-toolkit 1.0.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.
- bdo_toolkit/__init__.py +87 -0
- bdo_toolkit/_async_sessions.py +651 -0
- bdo_toolkit/_capture_backend.py +194 -0
- bdo_toolkit/_capture_options.py +68 -0
- bdo_toolkit/_capture_runtime.py +626 -0
- bdo_toolkit/_deposit_origin.py +1599 -0
- bdo_toolkit/_engine.py +327 -0
- bdo_toolkit/_framing.py +904 -0
- bdo_toolkit/_profile_runtime.py +157 -0
- bdo_toolkit/_protocol.py +386 -0
- bdo_toolkit/_reassembly.py +654 -0
- bdo_toolkit/_specs.py +285 -0
- bdo_toolkit/_storage_destination_validation.py +167 -0
- bdo_toolkit/_storage_hydration.py +241 -0
- bdo_toolkit/_version.py +3 -0
- bdo_toolkit/calibration.py +3223 -0
- bdo_toolkit/capture.py +1713 -0
- bdo_toolkit/character_state.py +3506 -0
- bdo_toolkit/cli.py +948 -0
- bdo_toolkit/diagnostics.py +51 -0
- bdo_toolkit/events.py +214 -0
- bdo_toolkit/filters.py +105 -0
- bdo_toolkit/item_state.py +48 -0
- bdo_toolkit/origin_learning.py +779 -0
- bdo_toolkit/profiles.py +370 -0
- bdo_toolkit/py.typed +1 -0
- bdo_toolkit/remote_profiles.py +358 -0
- bdo_toolkit/solare/__init__.py +50 -0
- bdo_toolkit/solare/_constants.py +94 -0
- bdo_toolkit/solare/_detail_learning.py +1437 -0
- bdo_toolkit/solare/_details.py +796 -0
- bdo_toolkit/solare/_discovery.py +1212 -0
- bdo_toolkit/solare/_live_tracker.py +472 -0
- bdo_toolkit/solare/_replay_capture.py +182 -0
- bdo_toolkit/solare/_result.py +441 -0
- bdo_toolkit/solare/_scanner.py +203 -0
- bdo_toolkit/solare/_validation.py +11 -0
- bdo_toolkit/solare/async_session.py +444 -0
- bdo_toolkit/solare/models.py +806 -0
- bdo_toolkit/solare/replay.py +62 -0
- bdo_toolkit/solare/session.py +1051 -0
- bdo_toolkit/writers.py +30 -0
- bdo_toolkit-1.0.0.dist-info/METADATA +143 -0
- bdo_toolkit-1.0.0.dist-info/RECORD +48 -0
- bdo_toolkit-1.0.0.dist-info/WHEEL +5 -0
- bdo_toolkit-1.0.0.dist-info/entry_points.txt +2 -0
- bdo_toolkit-1.0.0.dist-info/licenses/LICENSE +21 -0
- bdo_toolkit-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"""Asyncio facade for the synchronous Solare live session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections import deque
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
8
|
+
from functools import partial
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from types import TracebackType
|
|
11
|
+
from typing import AsyncIterator, Callable, Optional, TypeVar
|
|
12
|
+
|
|
13
|
+
from bdo_toolkit._capture_options import PacketCaptureOptions
|
|
14
|
+
from bdo_toolkit._capture_runtime import _attach_cleanup_owner
|
|
15
|
+
from ._constants import LIVE_CAPTURE_BUFFER_BYTES
|
|
16
|
+
from .models import (
|
|
17
|
+
SolareCaptureEndpoint,
|
|
18
|
+
SolareCaptureResult,
|
|
19
|
+
SolareUpdate,
|
|
20
|
+
SolareUpdateKind,
|
|
21
|
+
)
|
|
22
|
+
from .session import LiveSolareSession, _validate_timeout
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
T = TypeVar("T")
|
|
26
|
+
|
|
27
|
+
# Stay above the 15.625 ms monotonic-clock quantum used by Windows CPython
|
|
28
|
+
# 3.10 so terminal cleanup polling yields instead of busy-spinning.
|
|
29
|
+
_TERMINAL_SHUTDOWN_POLL_SECONDS = 0.05
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def _settle(future: asyncio.Future[T]) -> T:
|
|
33
|
+
while not future.done():
|
|
34
|
+
try:
|
|
35
|
+
await asyncio.wait((future,))
|
|
36
|
+
except asyncio.CancelledError:
|
|
37
|
+
continue
|
|
38
|
+
return future.result()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def _await_preserving_future(future: asyncio.Future[T]) -> T:
|
|
42
|
+
"""Await a worker future without cancelling it or creating a shield."""
|
|
43
|
+
|
|
44
|
+
await asyncio.wait((future,))
|
|
45
|
+
return future.result()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AsyncLiveSolareSession:
|
|
49
|
+
"""Awaitable single-consumer facade over :class:`LiveSolareSession`."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
*,
|
|
54
|
+
capture_options: Optional[PacketCaptureOptions] = None,
|
|
55
|
+
save_pcap: str | Path | None = None,
|
|
56
|
+
stop_on_complete: bool = True,
|
|
57
|
+
retain_raw_extensions: bool = False,
|
|
58
|
+
on_update: Optional[Callable[[SolareUpdate], None]] = None,
|
|
59
|
+
capture_buffer_bytes: int = LIVE_CAPTURE_BUFFER_BYTES,
|
|
60
|
+
) -> None:
|
|
61
|
+
self._session = LiveSolareSession(
|
|
62
|
+
capture_options=capture_options,
|
|
63
|
+
save_pcap=save_pcap,
|
|
64
|
+
stop_on_complete=stop_on_complete,
|
|
65
|
+
retain_raw_extensions=retain_raw_extensions,
|
|
66
|
+
on_update=on_update,
|
|
67
|
+
capture_buffer_bytes=capture_buffer_bytes,
|
|
68
|
+
)
|
|
69
|
+
self._executor = ThreadPoolExecutor(
|
|
70
|
+
# One progress poll and one completion wait may coexist. Keep a
|
|
71
|
+
# third worker reserved so stop/cancellation can always wake both.
|
|
72
|
+
max_workers=3,
|
|
73
|
+
thread_name_prefix="bdo-toolkit-solare-async",
|
|
74
|
+
)
|
|
75
|
+
self._closed = False
|
|
76
|
+
self._started = False
|
|
77
|
+
self._start_active = False
|
|
78
|
+
self._poll_active = False
|
|
79
|
+
self._wait_active = False
|
|
80
|
+
self._pending_updates: deque[SolareUpdate] = deque()
|
|
81
|
+
self._stop_future: Optional[asyncio.Future[SolareCaptureResult]] = None
|
|
82
|
+
self._terminal_shutdown_task: Optional[asyncio.Task[None]] = None
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def running(self) -> bool:
|
|
86
|
+
return self._session.running
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def stopped(self) -> bool:
|
|
90
|
+
return self._session.stopped
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def cleanup_incomplete(self) -> bool:
|
|
94
|
+
"""Whether native capture cleanup remains owned for another attempt."""
|
|
95
|
+
|
|
96
|
+
return self._session.cleanup_incomplete
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def result(self) -> Optional[SolareCaptureResult]:
|
|
100
|
+
return self._session.result
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def error(self) -> Optional[BaseException]:
|
|
104
|
+
return self._session.error
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def stop_reason(self) -> Optional[str]:
|
|
108
|
+
return self._session.stop_reason
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def endpoint(self) -> Optional[SolareCaptureEndpoint]:
|
|
112
|
+
return self._session.endpoint
|
|
113
|
+
|
|
114
|
+
def raise_if_failed(self) -> None:
|
|
115
|
+
self._session.raise_if_failed()
|
|
116
|
+
|
|
117
|
+
def _submit(self, function: Callable[[], T]) -> asyncio.Future[T]:
|
|
118
|
+
if self._closed:
|
|
119
|
+
raise RuntimeError("async live Solare session is already closed")
|
|
120
|
+
return asyncio.get_running_loop().run_in_executor(self._executor, function)
|
|
121
|
+
|
|
122
|
+
def _shutdown(self) -> None:
|
|
123
|
+
if not self._closed:
|
|
124
|
+
self._executor.shutdown(wait=False, cancel_futures=False)
|
|
125
|
+
self._closed = True
|
|
126
|
+
|
|
127
|
+
def _schedule_terminal_shutdown(self) -> None:
|
|
128
|
+
task = self._terminal_shutdown_task
|
|
129
|
+
if task is not None and not task.done():
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
async def close_after_worker_and_calls_settle() -> None:
|
|
133
|
+
while not self._session.stopped and not self._closed:
|
|
134
|
+
await asyncio.sleep(_TERMINAL_SHUTDOWN_POLL_SECONDS)
|
|
135
|
+
while not self._closed and (
|
|
136
|
+
self._start_active
|
|
137
|
+
or self._poll_active
|
|
138
|
+
or self._wait_active
|
|
139
|
+
or (
|
|
140
|
+
self._stop_future is not None
|
|
141
|
+
and not self._stop_future.done()
|
|
142
|
+
)
|
|
143
|
+
):
|
|
144
|
+
await asyncio.sleep(_TERMINAL_SHUTDOWN_POLL_SECONDS)
|
|
145
|
+
self._shutdown()
|
|
146
|
+
|
|
147
|
+
self._terminal_shutdown_task = asyncio.create_task(
|
|
148
|
+
close_after_worker_and_calls_settle()
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def _observe_polled_update(
|
|
152
|
+
self,
|
|
153
|
+
update: Optional[SolareUpdate],
|
|
154
|
+
) -> Optional[SolareUpdate]:
|
|
155
|
+
if update is not None and update.kind is SolareUpdateKind.FINISHED:
|
|
156
|
+
self._schedule_terminal_shutdown()
|
|
157
|
+
elif self._session.stopped:
|
|
158
|
+
self._shutdown()
|
|
159
|
+
return update
|
|
160
|
+
|
|
161
|
+
async def start(self) -> None:
|
|
162
|
+
"""Start capture without blocking the event-loop thread."""
|
|
163
|
+
|
|
164
|
+
if self._started or self._start_active:
|
|
165
|
+
raise RuntimeError("live Solare session was already started")
|
|
166
|
+
self._start_active = True
|
|
167
|
+
try:
|
|
168
|
+
try:
|
|
169
|
+
future = self._submit(self._session.start)
|
|
170
|
+
await _await_preserving_future(future)
|
|
171
|
+
except asyncio.CancelledError as exc:
|
|
172
|
+
started = False
|
|
173
|
+
stop_future: asyncio.Future[SolareCaptureResult] | None = None
|
|
174
|
+
try:
|
|
175
|
+
await _settle(future)
|
|
176
|
+
started = True
|
|
177
|
+
except BaseException:
|
|
178
|
+
pass
|
|
179
|
+
if started or self._session.cleanup_incomplete:
|
|
180
|
+
self._started = True
|
|
181
|
+
try:
|
|
182
|
+
stop_future = self._ensure_stop_future()
|
|
183
|
+
await _settle(stop_future)
|
|
184
|
+
except BaseException:
|
|
185
|
+
pass
|
|
186
|
+
if self._session.cleanup_incomplete:
|
|
187
|
+
if (
|
|
188
|
+
stop_future is not None
|
|
189
|
+
and self._stop_future is stop_future
|
|
190
|
+
):
|
|
191
|
+
self._stop_future = None
|
|
192
|
+
_attach_cleanup_owner(
|
|
193
|
+
exc,
|
|
194
|
+
self,
|
|
195
|
+
context="async live Solare startup",
|
|
196
|
+
)
|
|
197
|
+
else:
|
|
198
|
+
self._shutdown()
|
|
199
|
+
raise
|
|
200
|
+
except BaseException as exc:
|
|
201
|
+
if self._session.cleanup_incomplete:
|
|
202
|
+
# The synchronous session deliberately retains the native
|
|
203
|
+
# callback owners so a later stop() can finish cleanup.
|
|
204
|
+
self._started = True
|
|
205
|
+
_attach_cleanup_owner(
|
|
206
|
+
exc,
|
|
207
|
+
self,
|
|
208
|
+
context="async live Solare startup",
|
|
209
|
+
)
|
|
210
|
+
else:
|
|
211
|
+
self._shutdown()
|
|
212
|
+
raise
|
|
213
|
+
self._started = True
|
|
214
|
+
finally:
|
|
215
|
+
self._start_active = False
|
|
216
|
+
|
|
217
|
+
def _ensure_stop_future(self) -> asyncio.Future[SolareCaptureResult]:
|
|
218
|
+
if self._stop_future is None:
|
|
219
|
+
self._stop_future = self._submit(self._session.stop)
|
|
220
|
+
return self._stop_future
|
|
221
|
+
|
|
222
|
+
def _inside_update_callback(self) -> bool:
|
|
223
|
+
predicate = getattr(self._session, "_inside_update_callback", None)
|
|
224
|
+
return bool(predicate is not None and predicate())
|
|
225
|
+
|
|
226
|
+
async def stop(self) -> SolareCaptureResult:
|
|
227
|
+
"""Stop capture and return the structurally classified result."""
|
|
228
|
+
|
|
229
|
+
if self._inside_update_callback():
|
|
230
|
+
raise RuntimeError(
|
|
231
|
+
"stop() cannot block inside on_update; use "
|
|
232
|
+
"request_stop() instead"
|
|
233
|
+
)
|
|
234
|
+
if not self._started:
|
|
235
|
+
raise RuntimeError("live Solare session was not started")
|
|
236
|
+
if self._session.stopped:
|
|
237
|
+
try:
|
|
238
|
+
self._session.raise_if_failed()
|
|
239
|
+
result = self._session.result
|
|
240
|
+
if result is None:
|
|
241
|
+
raise RuntimeError(
|
|
242
|
+
"live Solare session stopped without a result"
|
|
243
|
+
)
|
|
244
|
+
return result
|
|
245
|
+
finally:
|
|
246
|
+
self._shutdown()
|
|
247
|
+
|
|
248
|
+
future = self._ensure_stop_future()
|
|
249
|
+
try:
|
|
250
|
+
result = await _await_preserving_future(future)
|
|
251
|
+
except asyncio.CancelledError:
|
|
252
|
+
try:
|
|
253
|
+
await _settle(future)
|
|
254
|
+
except BaseException:
|
|
255
|
+
pass
|
|
256
|
+
if self._session.cleanup_incomplete:
|
|
257
|
+
if self._stop_future is future:
|
|
258
|
+
self._stop_future = None
|
|
259
|
+
else:
|
|
260
|
+
self._shutdown()
|
|
261
|
+
raise
|
|
262
|
+
except BaseException:
|
|
263
|
+
if self._session.cleanup_incomplete:
|
|
264
|
+
if self._stop_future is future:
|
|
265
|
+
self._stop_future = None
|
|
266
|
+
else:
|
|
267
|
+
self._shutdown()
|
|
268
|
+
raise
|
|
269
|
+
self._shutdown()
|
|
270
|
+
return result
|
|
271
|
+
|
|
272
|
+
def request_stop(self) -> None:
|
|
273
|
+
"""Request orderly shutdown without blocking the event loop."""
|
|
274
|
+
|
|
275
|
+
ready_callback_active = self._start_active and self._session.running
|
|
276
|
+
if not (self._started or ready_callback_active):
|
|
277
|
+
raise RuntimeError("live Solare session was not started")
|
|
278
|
+
self._session.request_stop()
|
|
279
|
+
|
|
280
|
+
async def wait(
|
|
281
|
+
self, timeout: Optional[float] = None
|
|
282
|
+
) -> Optional[SolareCaptureResult]:
|
|
283
|
+
"""Await automatic completion or a timeout.
|
|
284
|
+
|
|
285
|
+
After completed stop, ``timeout`` is ignored and terminal state is
|
|
286
|
+
returned immediately.
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
if self._inside_update_callback():
|
|
290
|
+
raise RuntimeError(
|
|
291
|
+
"wait() cannot block inside on_update; use request_stop() "
|
|
292
|
+
"and wait after the callback returns"
|
|
293
|
+
)
|
|
294
|
+
if not self._started:
|
|
295
|
+
raise RuntimeError("live Solare session was not started")
|
|
296
|
+
if self._wait_active:
|
|
297
|
+
raise RuntimeError("async live Solare session supports one waiter")
|
|
298
|
+
self._wait_active = True
|
|
299
|
+
try:
|
|
300
|
+
if self._session.stopped:
|
|
301
|
+
self._shutdown()
|
|
302
|
+
return self._session.wait(timeout=0)
|
|
303
|
+
future = self._submit(partial(self._session.wait, timeout))
|
|
304
|
+
try:
|
|
305
|
+
result = await _await_preserving_future(future)
|
|
306
|
+
except asyncio.CancelledError:
|
|
307
|
+
stop_future: asyncio.Future[SolareCaptureResult] | None = None
|
|
308
|
+
try:
|
|
309
|
+
stop_future = self._ensure_stop_future()
|
|
310
|
+
await _settle(stop_future)
|
|
311
|
+
except BaseException:
|
|
312
|
+
pass
|
|
313
|
+
try:
|
|
314
|
+
await _settle(future)
|
|
315
|
+
except BaseException:
|
|
316
|
+
pass
|
|
317
|
+
if self._session.cleanup_incomplete:
|
|
318
|
+
if (
|
|
319
|
+
stop_future is not None
|
|
320
|
+
and self._stop_future is stop_future
|
|
321
|
+
):
|
|
322
|
+
self._stop_future = None
|
|
323
|
+
else:
|
|
324
|
+
self._shutdown()
|
|
325
|
+
raise
|
|
326
|
+
except BaseException:
|
|
327
|
+
if (
|
|
328
|
+
not self._session.cleanup_incomplete
|
|
329
|
+
and (self._session.stopped or self._session.error is not None)
|
|
330
|
+
):
|
|
331
|
+
self._shutdown()
|
|
332
|
+
raise
|
|
333
|
+
if result is not None or self._session.stopped:
|
|
334
|
+
self._shutdown()
|
|
335
|
+
return result
|
|
336
|
+
finally:
|
|
337
|
+
self._wait_active = False
|
|
338
|
+
|
|
339
|
+
async def poll(self, timeout: Optional[float] = None) -> Optional[SolareUpdate]:
|
|
340
|
+
"""Await one structured progress update.
|
|
341
|
+
|
|
342
|
+
After completed stop, ``timeout`` is ignored while progress drains.
|
|
343
|
+
"""
|
|
344
|
+
|
|
345
|
+
if self._inside_update_callback():
|
|
346
|
+
raise RuntimeError(
|
|
347
|
+
"poll() cannot consume updates inside on_update; consume "
|
|
348
|
+
"them outside the callback"
|
|
349
|
+
)
|
|
350
|
+
if not self._started:
|
|
351
|
+
raise RuntimeError("live Solare session was not started")
|
|
352
|
+
if self._poll_active:
|
|
353
|
+
raise RuntimeError("async live Solare session supports one consumer")
|
|
354
|
+
stopped_at_entry = self._session.stopped
|
|
355
|
+
if not stopped_at_entry:
|
|
356
|
+
_validate_timeout(timeout, name="timeout")
|
|
357
|
+
self._poll_active = True
|
|
358
|
+
try:
|
|
359
|
+
if self._pending_updates:
|
|
360
|
+
return self._observe_polled_update(
|
|
361
|
+
self._pending_updates.popleft()
|
|
362
|
+
)
|
|
363
|
+
if self._session.stopped:
|
|
364
|
+
self._shutdown()
|
|
365
|
+
return self._session.poll(timeout=0)
|
|
366
|
+
future = self._submit(partial(self._session.poll, timeout))
|
|
367
|
+
try:
|
|
368
|
+
update = await _await_preserving_future(future)
|
|
369
|
+
except asyncio.CancelledError:
|
|
370
|
+
stop_future: asyncio.Future[SolareCaptureResult] | None = None
|
|
371
|
+
try:
|
|
372
|
+
stop_future = self._ensure_stop_future()
|
|
373
|
+
await _settle(stop_future)
|
|
374
|
+
except BaseException:
|
|
375
|
+
pass
|
|
376
|
+
try:
|
|
377
|
+
settled_update = await _settle(future)
|
|
378
|
+
except BaseException:
|
|
379
|
+
pass
|
|
380
|
+
else:
|
|
381
|
+
if settled_update is not None:
|
|
382
|
+
self._pending_updates.append(settled_update)
|
|
383
|
+
if self._session.cleanup_incomplete:
|
|
384
|
+
if (
|
|
385
|
+
stop_future is not None
|
|
386
|
+
and self._stop_future is stop_future
|
|
387
|
+
):
|
|
388
|
+
self._stop_future = None
|
|
389
|
+
else:
|
|
390
|
+
self._shutdown()
|
|
391
|
+
raise
|
|
392
|
+
except BaseException:
|
|
393
|
+
if (
|
|
394
|
+
not self._session.cleanup_incomplete
|
|
395
|
+
and (self._session.stopped or self._session.error is not None)
|
|
396
|
+
):
|
|
397
|
+
self._shutdown()
|
|
398
|
+
raise
|
|
399
|
+
return self._observe_polled_update(update)
|
|
400
|
+
finally:
|
|
401
|
+
self._poll_active = False
|
|
402
|
+
|
|
403
|
+
async def updates(self) -> AsyncIterator[SolareUpdate]:
|
|
404
|
+
while True:
|
|
405
|
+
update = await self.poll()
|
|
406
|
+
if update is None:
|
|
407
|
+
return
|
|
408
|
+
yield update
|
|
409
|
+
|
|
410
|
+
def __aiter__(self) -> AsyncIterator[SolareUpdate]:
|
|
411
|
+
return self.updates()
|
|
412
|
+
|
|
413
|
+
async def __aenter__(self) -> "AsyncLiveSolareSession":
|
|
414
|
+
await self.start()
|
|
415
|
+
return self
|
|
416
|
+
|
|
417
|
+
async def __aexit__(
|
|
418
|
+
self,
|
|
419
|
+
exc_type: type[BaseException] | None,
|
|
420
|
+
exc_value: BaseException | None,
|
|
421
|
+
traceback: TracebackType | None,
|
|
422
|
+
) -> None:
|
|
423
|
+
try:
|
|
424
|
+
if exc_value is None:
|
|
425
|
+
await self.stop()
|
|
426
|
+
else:
|
|
427
|
+
try:
|
|
428
|
+
await self.stop()
|
|
429
|
+
except BaseException as cleanup_error:
|
|
430
|
+
# Preserve the exception already escaping the async block.
|
|
431
|
+
if self.cleanup_incomplete:
|
|
432
|
+
_attach_cleanup_owner(
|
|
433
|
+
exc_value,
|
|
434
|
+
self,
|
|
435
|
+
context="async live Solare context",
|
|
436
|
+
)
|
|
437
|
+
if hasattr(exc_value, "add_note"):
|
|
438
|
+
exc_value.add_note(
|
|
439
|
+
"async live Solare context cleanup also failed: "
|
|
440
|
+
f"{cleanup_error!r}"
|
|
441
|
+
)
|
|
442
|
+
finally:
|
|
443
|
+
if not self._session.cleanup_incomplete:
|
|
444
|
+
self._shutdown()
|