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.
Files changed (48) hide show
  1. bdo_toolkit/__init__.py +87 -0
  2. bdo_toolkit/_async_sessions.py +651 -0
  3. bdo_toolkit/_capture_backend.py +194 -0
  4. bdo_toolkit/_capture_options.py +68 -0
  5. bdo_toolkit/_capture_runtime.py +626 -0
  6. bdo_toolkit/_deposit_origin.py +1599 -0
  7. bdo_toolkit/_engine.py +327 -0
  8. bdo_toolkit/_framing.py +904 -0
  9. bdo_toolkit/_profile_runtime.py +157 -0
  10. bdo_toolkit/_protocol.py +386 -0
  11. bdo_toolkit/_reassembly.py +654 -0
  12. bdo_toolkit/_specs.py +285 -0
  13. bdo_toolkit/_storage_destination_validation.py +167 -0
  14. bdo_toolkit/_storage_hydration.py +241 -0
  15. bdo_toolkit/_version.py +3 -0
  16. bdo_toolkit/calibration.py +3223 -0
  17. bdo_toolkit/capture.py +1713 -0
  18. bdo_toolkit/character_state.py +3506 -0
  19. bdo_toolkit/cli.py +948 -0
  20. bdo_toolkit/diagnostics.py +51 -0
  21. bdo_toolkit/events.py +214 -0
  22. bdo_toolkit/filters.py +105 -0
  23. bdo_toolkit/item_state.py +48 -0
  24. bdo_toolkit/origin_learning.py +779 -0
  25. bdo_toolkit/profiles.py +370 -0
  26. bdo_toolkit/py.typed +1 -0
  27. bdo_toolkit/remote_profiles.py +358 -0
  28. bdo_toolkit/solare/__init__.py +50 -0
  29. bdo_toolkit/solare/_constants.py +94 -0
  30. bdo_toolkit/solare/_detail_learning.py +1437 -0
  31. bdo_toolkit/solare/_details.py +796 -0
  32. bdo_toolkit/solare/_discovery.py +1212 -0
  33. bdo_toolkit/solare/_live_tracker.py +472 -0
  34. bdo_toolkit/solare/_replay_capture.py +182 -0
  35. bdo_toolkit/solare/_result.py +441 -0
  36. bdo_toolkit/solare/_scanner.py +203 -0
  37. bdo_toolkit/solare/_validation.py +11 -0
  38. bdo_toolkit/solare/async_session.py +444 -0
  39. bdo_toolkit/solare/models.py +806 -0
  40. bdo_toolkit/solare/replay.py +62 -0
  41. bdo_toolkit/solare/session.py +1051 -0
  42. bdo_toolkit/writers.py +30 -0
  43. bdo_toolkit-1.0.0.dist-info/METADATA +143 -0
  44. bdo_toolkit-1.0.0.dist-info/RECORD +48 -0
  45. bdo_toolkit-1.0.0.dist-info/WHEEL +5 -0
  46. bdo_toolkit-1.0.0.dist-info/entry_points.txt +2 -0
  47. bdo_toolkit-1.0.0.dist-info/licenses/LICENSE +21 -0
  48. bdo_toolkit-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,651 @@
1
+ """Asyncio facades for the synchronous live session APIs.
2
+
3
+ The packet capture, decoding, buffering, calibration, and shutdown logic stays
4
+ in :class:`LiveCaptureSession` and :class:`CalibrationSession`. These wrappers
5
+ only move their blocking lifecycle operations off the asyncio event-loop
6
+ thread and make cancellation deterministic.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from collections import deque
13
+ from concurrent.futures import ThreadPoolExecutor
14
+ from functools import partial
15
+ from pathlib import Path
16
+ from types import TracebackType
17
+ from typing import AsyncIterator, Callable, Optional, TypeVar
18
+
19
+ from ._capture_runtime import CaptureEndpoint, _attach_cleanup_owner
20
+ from ._capture_options import LiveCaptureOptions, PacketCaptureOptions
21
+ from .calibration import (
22
+ DEFAULT_CALIBRATION_MAX_RETAINED_BYTES,
23
+ DEFAULT_CALIBRATION_MAX_RETAINED_FRAMES,
24
+ CalibrationResult,
25
+ CalibrationRetention,
26
+ CalibrationSession,
27
+ )
28
+ from .capture import LiveCaptureHealth, LiveCaptureSession, _validate_poll_timeout
29
+ from .diagnostics import DecoderDiagnostic, DecoderHealth
30
+ from .events import BDOEvent
31
+ from .filters import EventFilter
32
+ from .origin_learning import CompanionObservation
33
+ from .profiles import OpcodeProfile
34
+
35
+
36
+ T = TypeVar("T")
37
+
38
+
39
+ async def _wait_ignoring_cancellation(future: asyncio.Future[T]) -> T:
40
+ """Wait for an already-started operation, even after caller cancellation."""
41
+
42
+ while not future.done():
43
+ try:
44
+ # asyncio.wait() never propagates cancellation into ``future`` and
45
+ # does not create a cancelled shield wrapper that may later log an
46
+ # otherwise-retrieved worker exception on Python 3.14.
47
+ await asyncio.wait((future,))
48
+ except asyncio.CancelledError:
49
+ # Cleanup must settle before the original cancellation escapes.
50
+ continue
51
+ return future.result()
52
+
53
+
54
+ async def _await_preserving_future(future: asyncio.Future[T]) -> T:
55
+ """Await without cancelling or wrapping the submitted worker future."""
56
+
57
+ await asyncio.wait((future,))
58
+ return future.result()
59
+
60
+
61
+ def _thread_task(function: Callable[[], T]) -> asyncio.Task[T]:
62
+ return asyncio.create_task(asyncio.to_thread(function))
63
+
64
+
65
+ class AsyncLiveCaptureSession:
66
+ """Awaitable facade over :class:`LiveCaptureSession`.
67
+
68
+ The underlying synchronous session remains the sole owner of capture,
69
+ event ordering, bounded buffering, finalization, and background errors.
70
+ A session is single-use and supports one event consumer.
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ *,
76
+ opcode_profile: str | Path | OpcodeProfile,
77
+ live_options: Optional[LiveCaptureOptions] = None,
78
+ event_filter: Optional[EventFilter] = None,
79
+ origin_observer: Optional[Callable[[CompanionObservation], object]] = None,
80
+ on_diagnostic: Optional[Callable[[DecoderDiagnostic], object]] = None,
81
+ ) -> None:
82
+ self._session = LiveCaptureSession(
83
+ opcode_profile=opcode_profile,
84
+ live_options=live_options,
85
+ event_filter=event_filter,
86
+ origin_observer=origin_observer,
87
+ on_diagnostic=on_diagnostic,
88
+ )
89
+ # A pending blocking poll needs a second worker so stop() can wake it,
90
+ # even when the host app configured a one-thread default executor.
91
+ self._executor = ThreadPoolExecutor(
92
+ max_workers=2,
93
+ thread_name_prefix="bdo-toolkit-live",
94
+ )
95
+ self._executor_closed = False
96
+ self._stop_future: asyncio.Future[None] | None = None
97
+ self._poll_active = False
98
+ # A cancelled worker poll may already have removed one event before it
99
+ # settles. Keep that event session-owned so the next consumer can
100
+ # retrieve it. Single-consumer enforcement bounds this deque to one.
101
+ self._pending_events: deque[BDOEvent] = deque()
102
+ self._start_attempted = False
103
+ self._start_complete = False
104
+
105
+ @property
106
+ def running(self) -> bool:
107
+ return self._session.running
108
+
109
+ @property
110
+ def stopped(self) -> bool:
111
+ return self._session.stopped
112
+
113
+ @property
114
+ def cleanup_incomplete(self) -> bool:
115
+ """Whether synchronous capture cleanup remains retryable."""
116
+
117
+ return self._session.cleanup_incomplete
118
+
119
+ @property
120
+ def stop_reason(self) -> Optional[str]:
121
+ return self._session.stop_reason
122
+
123
+ @property
124
+ def error(self) -> Optional[BaseException]:
125
+ return self._session.error
126
+
127
+ @property
128
+ def endpoint(self) -> Optional[CaptureEndpoint]:
129
+ return self._session.endpoint
130
+
131
+ @property
132
+ def health(self) -> LiveCaptureHealth:
133
+ """Return the underlying observational health snapshot synchronously."""
134
+
135
+ return self._session.health
136
+
137
+ @property
138
+ def decoder_health(self) -> DecoderHealth:
139
+ return self._session.decoder_health
140
+
141
+ def raise_if_failed(self) -> None:
142
+ """Re-raise a retained background capture failure."""
143
+
144
+ self._session.raise_if_failed()
145
+
146
+ def _submit(self, function: Callable[[], T]) -> asyncio.Future[T]:
147
+ if self._executor_closed:
148
+ raise RuntimeError("async live capture session is already closed")
149
+ loop = asyncio.get_running_loop()
150
+ return loop.run_in_executor(self._executor, function)
151
+
152
+ def _shutdown_executor(self) -> None:
153
+ if self._executor_closed:
154
+ return
155
+ self._executor.shutdown(wait=False, cancel_futures=False)
156
+ self._executor_closed = True
157
+
158
+ def _ensure_stop_future(self) -> asyncio.Future[None]:
159
+ if self._stop_future is None:
160
+ self._stop_future = self._submit(self._session.stop)
161
+ return self._stop_future
162
+
163
+ def _inside_origin_observer(self) -> bool:
164
+ predicate = getattr(self._session, "_inside_origin_observer", None)
165
+ return bool(predicate is not None and predicate())
166
+
167
+ async def start(self) -> None:
168
+ """Start capture without blocking the asyncio event loop."""
169
+
170
+ # Reject repeat/concurrent starts before submitting work. In
171
+ # particular, a rejected second start must not close the executor that
172
+ # still owns a running first session and its eventual cleanup.
173
+ if self._start_attempted:
174
+ raise RuntimeError("async live capture session was already started")
175
+ self._start_attempted = True
176
+ start_future = self._submit(self._session.start)
177
+ try:
178
+ await _await_preserving_future(start_future)
179
+ except asyncio.CancelledError as exc:
180
+ started = False
181
+ stop_future: asyncio.Future[None] | None = None
182
+ try:
183
+ await _wait_ignoring_cancellation(start_future)
184
+ started = True
185
+ except BaseException:
186
+ # Cancellation remains the caller-visible outcome.
187
+ pass
188
+ if started or self._session.cleanup_incomplete:
189
+ self._start_complete = True
190
+ try:
191
+ stop_future = self._ensure_stop_future()
192
+ await _wait_ignoring_cancellation(stop_future)
193
+ except BaseException:
194
+ pass
195
+ if not self._session.cleanup_incomplete:
196
+ self._shutdown_executor()
197
+ else:
198
+ if stop_future is not None and self._stop_future is stop_future:
199
+ self._stop_future = None
200
+ _attach_cleanup_owner(
201
+ exc,
202
+ self,
203
+ context="async live item capture startup",
204
+ )
205
+ raise
206
+ except BaseException as exc:
207
+ # A failed startup never enters an async context, so close the
208
+ # wrapper-owned executor unless the sync session retained a live
209
+ # backend specifically so cleanup can be retried.
210
+ if self._session.cleanup_incomplete:
211
+ self._start_complete = True
212
+ _attach_cleanup_owner(
213
+ exc,
214
+ self,
215
+ context="async live item capture startup",
216
+ )
217
+ else:
218
+ self._shutdown_executor()
219
+ raise
220
+ else:
221
+ self._start_complete = True
222
+
223
+ async def stop(self) -> None:
224
+ """Gracefully stop capture and finish decoder/origin state."""
225
+
226
+ if self._inside_origin_observer():
227
+ raise RuntimeError(
228
+ "stop() cannot block inside the live decoder or origin "
229
+ "observer; use request_stop() instead"
230
+ )
231
+ if self._session.stopped:
232
+ self._shutdown_executor()
233
+ return
234
+ if not self._start_complete:
235
+ raise RuntimeError("live capture session was not started")
236
+
237
+ stop_future = self._ensure_stop_future()
238
+ try:
239
+ await _await_preserving_future(stop_future)
240
+ except asyncio.CancelledError:
241
+ try:
242
+ try:
243
+ await _wait_ignoring_cancellation(stop_future)
244
+ except BaseException:
245
+ # Cancellation remains the caller-visible outcome.
246
+ pass
247
+ finally:
248
+ if not self._session.cleanup_incomplete:
249
+ self._shutdown_executor()
250
+ else:
251
+ if self._stop_future is stop_future:
252
+ self._stop_future = None
253
+ raise
254
+ except BaseException:
255
+ if self._session.stopped:
256
+ self._shutdown_executor()
257
+ else:
258
+ if self._stop_future is stop_future:
259
+ self._stop_future = None
260
+ raise
261
+ else:
262
+ self._shutdown_executor()
263
+
264
+ def request_stop(self) -> None:
265
+ """Request callback-safe shutdown without blocking its worker thread."""
266
+
267
+ self._session.request_stop()
268
+
269
+ async def poll(self, timeout: Optional[float] = None) -> Optional[BDOEvent]:
270
+ """Await one event, a timeout, or the fully drained stopped session.
271
+
272
+ Cancelling a pending poll is terminal for this single-consumer session:
273
+ capture is stopped before ``CancelledError`` is re-raised.
274
+ After completed stop, ``timeout`` is ignored while buffered data drains.
275
+ """
276
+
277
+ if self._inside_origin_observer():
278
+ raise RuntimeError(
279
+ "poll() cannot consume events inside origin_observer; "
280
+ "consume them after the callback returns"
281
+ )
282
+ if self._poll_active:
283
+ raise RuntimeError("async live capture session supports one consumer")
284
+ stopped_at_entry = self._session.stopped
285
+ if not self._start_complete and not stopped_at_entry:
286
+ raise RuntimeError("live capture session was not started")
287
+ # A cancellation-preserved event must not bypass the active timeout
288
+ # contract. Preserve the historical stopped fast path: it cannot wait
289
+ # and ignores the caller's timeout while draining.
290
+ if not stopped_at_entry:
291
+ _validate_poll_timeout(timeout)
292
+
293
+ self._poll_active = True
294
+ try:
295
+ if self._session.stopped:
296
+ self._shutdown_executor()
297
+ if self._pending_events:
298
+ return self._pending_events.popleft()
299
+
300
+ if self._session.stopped:
301
+ # After stop(), finalization is complete and a zero-time poll
302
+ # only drains already-buffered events; it cannot block.
303
+ return self._session.poll(timeout=0)
304
+
305
+ poll_future = self._submit(partial(self._session.poll, timeout))
306
+ try:
307
+ event = await _await_preserving_future(poll_future)
308
+ except asyncio.CancelledError:
309
+ stop_future: asyncio.Future[None] | None = None
310
+ try:
311
+ stop_future = self._ensure_stop_future()
312
+ await _wait_ignoring_cancellation(stop_future)
313
+ except BaseException:
314
+ pass
315
+ try:
316
+ event = await _wait_ignoring_cancellation(poll_future)
317
+ except BaseException:
318
+ pass
319
+ else:
320
+ if event is not None:
321
+ self._pending_events.append(event)
322
+ if not self._session.cleanup_incomplete:
323
+ self._shutdown_executor()
324
+ else:
325
+ if (
326
+ stop_future is not None
327
+ and self._stop_future is stop_future
328
+ ):
329
+ self._stop_future = None
330
+ raise
331
+ except BaseException:
332
+ if self._session.stopped:
333
+ self._shutdown_executor()
334
+ raise
335
+
336
+ if self._session.stopped:
337
+ self._shutdown_executor()
338
+ return event
339
+ finally:
340
+ self._poll_active = False
341
+
342
+ async def events(self) -> AsyncIterator[BDOEvent]:
343
+ """Yield events in order until capture stops and final events drain."""
344
+
345
+ if self._inside_origin_observer():
346
+ raise RuntimeError(
347
+ "events() cannot consume events inside origin_observer; "
348
+ "consume them after the callback returns"
349
+ )
350
+ while True:
351
+ event = await self.poll(timeout=None)
352
+ if event is None:
353
+ return
354
+ yield event
355
+
356
+ def __aiter__(self) -> AsyncIterator[BDOEvent]:
357
+ return self.events()
358
+
359
+ async def __aenter__(self) -> "AsyncLiveCaptureSession":
360
+ await self.start()
361
+ return self
362
+
363
+ async def __aexit__(
364
+ self,
365
+ exc_type: type[BaseException] | None,
366
+ exc_value: BaseException | None,
367
+ traceback: TracebackType | None,
368
+ ) -> None:
369
+ try:
370
+ if not self._session.stopped:
371
+ await self.stop()
372
+ else:
373
+ self._shutdown_executor()
374
+ except BaseException as cleanup_error:
375
+ if exc_value is None:
376
+ raise
377
+ if self.cleanup_incomplete:
378
+ _attach_cleanup_owner(
379
+ exc_value,
380
+ self,
381
+ context="async live item capture context",
382
+ )
383
+ if hasattr(exc_value, "add_note"):
384
+ exc_value.add_note(
385
+ "async live item capture context cleanup also failed: "
386
+ f"{cleanup_error!r}"
387
+ )
388
+
389
+
390
+ class AsyncCalibrationSession:
391
+ """Awaitable facade over :class:`CalibrationSession`.
392
+
393
+ ``stop()`` returns the same :class:`CalibrationResult` as the synchronous
394
+ session. Exiting the async context before ``stop()`` calls ``abort()`` and
395
+ discards the unfinished calibration, matching the synchronous context
396
+ manager's safety behavior.
397
+ """
398
+
399
+ def __init__(
400
+ self,
401
+ *,
402
+ item_id: int,
403
+ quantity: Optional[int] = None,
404
+ action: str = "auto",
405
+ capture_options: Optional[PacketCaptureOptions] = None,
406
+ context_frames: int = 5,
407
+ min_confidence: float = 0.80,
408
+ max_retained_frames: int = DEFAULT_CALIBRATION_MAX_RETAINED_FRAMES,
409
+ max_retained_bytes: int = DEFAULT_CALIBRATION_MAX_RETAINED_BYTES,
410
+ ) -> None:
411
+ self._session = CalibrationSession(
412
+ item_id=item_id,
413
+ quantity=quantity,
414
+ action=action,
415
+ capture_options=capture_options,
416
+ context_frames=context_frames,
417
+ min_confidence=min_confidence,
418
+ max_retained_frames=max_retained_frames,
419
+ max_retained_bytes=max_retained_bytes,
420
+ )
421
+ self._active = False
422
+ self._terminal_action: str | None = None
423
+ self._stop_task: asyncio.Task[CalibrationResult] | None = None
424
+ self._abort_task: asyncio.Task[None] | None = None
425
+ self._result: CalibrationResult | None = None
426
+
427
+ @property
428
+ def running(self) -> bool:
429
+ return self._session.running
430
+
431
+ @property
432
+ def cleanup_incomplete(self) -> bool:
433
+ """Whether calibration cleanup remains owned for another attempt."""
434
+
435
+ return self._session.cleanup_incomplete
436
+
437
+ @property
438
+ def frames_collected(self) -> int:
439
+ return self._session.frames_collected
440
+
441
+ @property
442
+ def frames_observed(self) -> int:
443
+ return self._session.frames_observed
444
+
445
+ @property
446
+ def frames_retained(self) -> int:
447
+ return self._session.frames_retained
448
+
449
+ @property
450
+ def frames_discarded(self) -> int:
451
+ return self._session.frames_discarded
452
+
453
+ @property
454
+ def bytes_observed(self) -> int:
455
+ return self._session.bytes_observed
456
+
457
+ @property
458
+ def bytes_retained(self) -> int:
459
+ return self._session.bytes_retained
460
+
461
+ @property
462
+ def bytes_discarded(self) -> int:
463
+ return self._session.bytes_discarded
464
+
465
+ @property
466
+ def retention_truncated(self) -> bool:
467
+ return self._session.retention_truncated
468
+
469
+ @property
470
+ def retention(self) -> CalibrationRetention:
471
+ return self._session.retention
472
+
473
+ @property
474
+ def result(self) -> CalibrationResult | None:
475
+ """Completed result, including one preserved across cancellation."""
476
+
477
+ return self._result
478
+
479
+ async def start(self) -> None:
480
+ """Begin calibration capture without blocking the event loop."""
481
+
482
+ if self._active:
483
+ raise RuntimeError("calibration session is already running")
484
+
485
+ start_task = _thread_task(self._session.start)
486
+ try:
487
+ await _await_preserving_future(start_task)
488
+ except asyncio.CancelledError as exc:
489
+ started = False
490
+ try:
491
+ await _wait_ignoring_cancellation(start_task)
492
+ started = True
493
+ except BaseException:
494
+ pass
495
+ if started or self._session.cleanup_incomplete:
496
+ # A late successful start belongs to a new calibration run.
497
+ # Clear every terminal artifact from the prior run before
498
+ # attempting cancellation cleanup, so an exposed cleanup
499
+ # owner cannot return a stale result or reuse an old task.
500
+ self._terminal_action = None
501
+ self._stop_task = None
502
+ self._abort_task = None
503
+ self._result = None
504
+ abort_task = _thread_task(
505
+ partial(self._session.__exit__, None, None, None)
506
+ )
507
+ try:
508
+ await _wait_ignoring_cancellation(abort_task)
509
+ except BaseException:
510
+ pass
511
+ if self._session.cleanup_incomplete:
512
+ self._active = True
513
+ _attach_cleanup_owner(
514
+ exc,
515
+ self,
516
+ context="async live calibration startup",
517
+ )
518
+ raise
519
+ except BaseException as exc:
520
+ if self._session.cleanup_incomplete:
521
+ self._active = True
522
+ self._terminal_action = None
523
+ self._stop_task = None
524
+ self._abort_task = None
525
+ self._result = None
526
+ _attach_cleanup_owner(
527
+ exc,
528
+ self,
529
+ context="async live calibration startup",
530
+ )
531
+ raise
532
+
533
+ self._active = True
534
+ self._terminal_action = None
535
+ self._stop_task = None
536
+ self._abort_task = None
537
+ self._result = None
538
+
539
+ async def _finish(self) -> CalibrationResult:
540
+ try:
541
+ result = await asyncio.to_thread(self._session.stop)
542
+ except BaseException:
543
+ self._active = self._session.cleanup_incomplete
544
+ raise
545
+ self._result = result
546
+ self._active = False
547
+ return result
548
+
549
+ async def stop(self) -> CalibrationResult:
550
+ """Stop capture, run calibration, and return its result."""
551
+
552
+ if self._terminal_action == "abort":
553
+ raise RuntimeError("calibration session was aborted")
554
+ if not self._active and self._stop_task is None:
555
+ raise RuntimeError("calibration session was not started")
556
+
557
+ if self._stop_task is None:
558
+ self._terminal_action = "stop"
559
+ self._stop_task = asyncio.create_task(self._finish())
560
+ stop_task = self._stop_task
561
+
562
+ try:
563
+ return await _await_preserving_future(stop_task)
564
+ except asyncio.CancelledError:
565
+ try:
566
+ await _wait_ignoring_cancellation(stop_task)
567
+ except BaseException:
568
+ pass
569
+ if self._session.cleanup_incomplete:
570
+ if self._stop_task is stop_task:
571
+ self._stop_task = None
572
+ self._active = True
573
+ raise
574
+ except BaseException:
575
+ if self._session.cleanup_incomplete:
576
+ if self._stop_task is stop_task:
577
+ self._stop_task = None
578
+ self._active = True
579
+ raise
580
+
581
+ async def _discard(self) -> None:
582
+ try:
583
+ await asyncio.to_thread(self._session.__exit__, None, None, None)
584
+ except BaseException:
585
+ self._active = self._session.cleanup_incomplete
586
+ raise
587
+ else:
588
+ self._active = False
589
+
590
+ async def abort(self) -> None:
591
+ """Stop capture and discard it without running calibration."""
592
+
593
+ if self._terminal_action == "stop":
594
+ await self.stop()
595
+ return
596
+ if not self._active and self._abort_task is None:
597
+ return
598
+
599
+ if self._abort_task is None:
600
+ self._terminal_action = "abort"
601
+ self._abort_task = asyncio.create_task(self._discard())
602
+ abort_task = self._abort_task
603
+
604
+ try:
605
+ await _await_preserving_future(abort_task)
606
+ except asyncio.CancelledError:
607
+ try:
608
+ await _wait_ignoring_cancellation(abort_task)
609
+ except BaseException:
610
+ pass
611
+ if self._session.cleanup_incomplete:
612
+ if self._abort_task is abort_task:
613
+ self._abort_task = None
614
+ self._active = True
615
+ raise
616
+ except BaseException:
617
+ if self._session.cleanup_incomplete:
618
+ if self._abort_task is abort_task:
619
+ self._abort_task = None
620
+ self._active = True
621
+ raise
622
+
623
+ async def __aenter__(self) -> "AsyncCalibrationSession":
624
+ await self.start()
625
+ return self
626
+
627
+ async def __aexit__(
628
+ self,
629
+ exc_type: type[BaseException] | None,
630
+ exc_value: BaseException | None,
631
+ traceback: TracebackType | None,
632
+ ) -> None:
633
+ try:
634
+ if self._terminal_action == "stop":
635
+ await self.stop()
636
+ elif self._active:
637
+ await self.abort()
638
+ except BaseException as cleanup_error:
639
+ if exc_value is None:
640
+ raise
641
+ if self.cleanup_incomplete:
642
+ _attach_cleanup_owner(
643
+ exc_value,
644
+ self,
645
+ context="async live calibration context",
646
+ )
647
+ if hasattr(exc_value, "add_note"):
648
+ exc_value.add_note(
649
+ "async live calibration context cleanup also failed: "
650
+ f"{cleanup_error!r}"
651
+ )