wrapture 1.0.0.dev1__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.
wrapture/bindings.py ADDED
@@ -0,0 +1,1110 @@
1
+ """Bindings: the association of a target attribute with a wrapper.
2
+
3
+ A binding is created with binding() and names one attribute of a module,
4
+ class or instance. Applying it installs a wrapt wrapper on the target;
5
+ behaviour configured through the binding's namespaces then applies to every
6
+ call until the binding is removed, suspended or reconfigured.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import inspect
12
+ import time
13
+ import types
14
+ import warnings
15
+ import weakref
16
+ from collections.abc import AsyncGenerator, Generator
17
+ from typing import Any, Self, TypeVar
18
+
19
+ import wrapt
20
+ from wrapt import MISSING, is_wrapped_by, unwrap_object
21
+
22
+ from .attributes import install as install_attribute
23
+ from .behaviours import (
24
+ CallBehaviour,
25
+ DeleteBehaviour,
26
+ GetBehaviour,
27
+ SetBehaviour,
28
+ StageFunction,
29
+ WrappedFunction,
30
+ WrapperFunction,
31
+ _Behaviour,
32
+ _compose,
33
+ )
34
+ from .capture import (
35
+ NONE,
36
+ REFERENCE,
37
+ CapturePolicy,
38
+ _capture_value,
39
+ _level_of,
40
+ )
41
+ from .eventlogs import EventLog
42
+ from .events import Event, normalized_arguments
43
+ from .exceptions import (
44
+ AlreadyAppliedError,
45
+ DeferredTargetError,
46
+ ExpectationNotMetError,
47
+ NeverAppliedError,
48
+ RecordingGapWarning,
49
+ WrongModeError,
50
+ )
51
+ from .stacks import _capture as _capture_stack
52
+ from .timeline import (
53
+ Tape,
54
+ _capture_result,
55
+ _in_recorder,
56
+ _pop,
57
+ _push,
58
+ _stack,
59
+ _tape,
60
+ _timelines_active,
61
+ )
62
+
63
+ _BehaviourT = TypeVar("_BehaviourT", bound=_Behaviour)
64
+
65
+ # Every currently applied binding, so tooling such as the pytest plugin
66
+ # can sweep for patches left behind. Weak references, so the registry
67
+ # never keeps a binding alive.
68
+
69
+ _applied_bindings: weakref.WeakSet[Binding] = weakref.WeakSet()
70
+
71
+
72
+ def _reject_deferred(target: Any) -> None:
73
+ """Reject wrapt's `?` deferred-patching syntax.
74
+
75
+ Only a trailing `?` on a string target is rejected; that is the only
76
+ position where wrapt gives it meaning. A `?` in `name` is an ordinary
77
+ character that fails to resolve like any other typo.
78
+ """
79
+
80
+ if isinstance(target, str) and target.endswith("?"):
81
+ raise DeferredTargetError(
82
+ f"deferred patching is not supported: target {target!r} uses"
83
+ f" wrapt's trailing `?` syntax. A deferred wrap registers a"
84
+ f" post-import hook and returns no handle, so a binding would"
85
+ f" have nothing to remove, suspend or query. Import the module"
86
+ f" first and bind against it."
87
+ )
88
+
89
+
90
+ def _detect_mode(target: Any, name: str, missing_ok: bool = False) -> str:
91
+ """Decide whether this binding wraps a call or an attribute access.
92
+
93
+ Classified from whatever `resolve_path` finds at the target:
94
+
95
+ function / lambda / staticmethod / classmethod -> "callable"
96
+ property / member_descriptor / plain data -> "attribute"
97
+ other callable stored as data -> "callable"
98
+ absent from the class -> error unless
99
+ missing_ok=True
100
+
101
+ A callable stored as data is ambiguous; override with mode= if the
102
+ guess is wrong. An absent attribute raises rather than being inferred,
103
+ because it is indistinguishable from a typo.
104
+ """
105
+
106
+ try:
107
+ value = wrapt.resolve_path(target, name)[2]
108
+ except Exception:
109
+ if missing_ok:
110
+ return "attribute"
111
+ raise
112
+
113
+ # Classify what was found. The routine checks must come before the
114
+ # descriptor check: functions are themselves descriptors, so testing
115
+ # for __get__ first would classify every method as an attribute.
116
+
117
+ if isinstance(value, staticmethod | classmethod):
118
+ return "callable"
119
+ if inspect.isroutine(value):
120
+ return "callable"
121
+
122
+ if hasattr(type(value), "__get__"):
123
+ return "attribute"
124
+
125
+ if callable(value):
126
+ return "callable"
127
+
128
+ return "attribute"
129
+
130
+
131
+ def _derive_path(target: Any, name: str) -> str:
132
+ """The fully qualified location of the bound attribute.
133
+
134
+ Format is module:path, both halves dotted, as used by setuptools
135
+ entry points: everything before the colon is the module to import,
136
+ everything after is the attribute path within it. Derived from the
137
+ target, so the same attribute yields the same path however the
138
+ target was expressed, and unaffected by any label override.
139
+ """
140
+
141
+ if isinstance(target, str):
142
+ return f"{target}:{name}"
143
+
144
+ if isinstance(target, types.ModuleType):
145
+ return f"{target.__name__}:{name}"
146
+
147
+ # An instance target is located via its type: the events it records
148
+ # carry the instance itself for anything the path cannot say.
149
+
150
+ owner = target if isinstance(target, type) else type(target)
151
+ return f"{owner.__module__}:{owner.__qualname__}.{name}"
152
+
153
+
154
+ def _forwarder(wrapped: WrappedFunction, event: Event) -> WrappedFunction:
155
+ """The `wrapped` handed to behaviour, recording what the original
156
+ actually received, which may differ from what the caller sent."""
157
+
158
+ def forward(*args: Any, **kwargs: Any) -> Any:
159
+ event.forwarded = (args, kwargs)
160
+ return wrapped(*args, **kwargs)
161
+
162
+ return forward
163
+
164
+
165
+ async def _record_awaited(
166
+ awaitable: Any,
167
+ event: Event,
168
+ stack: tuple[Event, ...],
169
+ policy: CapturePolicy = REFERENCE,
170
+ ) -> Any:
171
+ """Record around the await, so the event reflects the real outcome.
172
+
173
+ Re-establishes the in-progress stack for the duration, so calls made
174
+ inside the coroutine body nest under this event. The stack is set
175
+ raw rather than pushed, because the event was already linked to its
176
+ parent when the call was recorded.
177
+ """
178
+
179
+ token = _stack.set(stack + (event,))
180
+ try:
181
+ result = await awaitable
182
+ except BaseException as exc:
183
+ event.exception = exc
184
+ raise
185
+ finally:
186
+ _stack.reset(token)
187
+
188
+ _capture_result(event, result, policy)
189
+ return result
190
+
191
+
192
+ def _close_iteration(
193
+ event: Event,
194
+ started: float,
195
+ body: float,
196
+ items: int,
197
+ policy: CapturePolicy,
198
+ result: Any = MISSING,
199
+ exception: BaseException | None = None,
200
+ ) -> None:
201
+ # Close a generator's event: durations and the final item count
202
+ # always, then the outcome. An abandoned generator supplies neither
203
+ # a result nor an exception, so its event closes with no outcome and
204
+ # stays visibly unfinished on the tape.
205
+
206
+ event.duration = time.perf_counter() - started
207
+ event.body_duration = body
208
+ event.items = items
209
+
210
+ if exception is not None:
211
+ event.exception = exception
212
+ elif result is not MISSING:
213
+ _capture_result(event, result, policy)
214
+
215
+
216
+ def _record_generator(
217
+ generator: Generator[Any, Any, Any],
218
+ event: Event,
219
+ stack: tuple[Event, ...],
220
+ policy: CapturePolicy,
221
+ ) -> Generator[Any, Any, Any]:
222
+ """A generator around a generator, recording as it runs.
223
+
224
+ One event, already on the tape, covers the whole iteration. The
225
+ in-progress stack is re-established around each resumption only, so
226
+ calls made inside the body nest under the event while the consumer's
227
+ own work between yields does not. Preserves the full generator
228
+ protocol: send() and throw() are forwarded, close() closes the
229
+ wrapped generator, and the return value is returned.
230
+ """
231
+
232
+ started = time.perf_counter()
233
+ body = 0.0
234
+ items = 0
235
+ event.items = 0
236
+
237
+ operation: tuple[str, Any] = ("send", None)
238
+
239
+ while True:
240
+ # Drive the wrapped generator with whatever the consumer last
241
+ # did, timing the resumption: the body only runs inside send()
242
+ # and throw().
243
+
244
+ token = _stack.set(stack + (event,))
245
+ resumed = time.perf_counter()
246
+
247
+ try:
248
+ if operation[0] == "send":
249
+ item = generator.send(operation[1])
250
+ else:
251
+ item = generator.throw(operation[1])
252
+ except StopIteration as stop:
253
+ body += time.perf_counter() - resumed
254
+ _stack.reset(token)
255
+ _close_iteration(event, started, body, items, policy, result=stop.value)
256
+ return stop.value
257
+ except BaseException as exc:
258
+ body += time.perf_counter() - resumed
259
+ _stack.reset(token)
260
+ _close_iteration(event, started, body, items, policy, exception=exc)
261
+ raise
262
+ else:
263
+ body += time.perf_counter() - resumed
264
+ _stack.reset(token)
265
+
266
+ items += 1
267
+ event.items = items
268
+
269
+ try:
270
+ operation = ("send", (yield item))
271
+ except GeneratorExit:
272
+ generator.close()
273
+ _close_iteration(event, started, body, items, policy)
274
+ raise
275
+ except BaseException as exc:
276
+ operation = ("throw", exc)
277
+
278
+
279
+ async def _record_async_generator(
280
+ generator: AsyncGenerator[Any, Any],
281
+ event: Event,
282
+ stack: tuple[Event, ...],
283
+ policy: CapturePolicy,
284
+ ) -> AsyncGenerator[Any, Any]:
285
+ """The async twin of _record_generator, for async generators.
286
+
287
+ Async generators have no return value, so exhaustion records a
288
+ result of None, which is what keeps a finished iteration
289
+ distinguishable from an abandoned one.
290
+ """
291
+
292
+ started = time.perf_counter()
293
+ body = 0.0
294
+ items = 0
295
+ event.items = 0
296
+
297
+ operation: tuple[str, Any] = ("send", None)
298
+
299
+ while True:
300
+ token = _stack.set(stack + (event,))
301
+ resumed = time.perf_counter()
302
+
303
+ try:
304
+ if operation[0] == "send":
305
+ item = await generator.asend(operation[1])
306
+ else:
307
+ item = await generator.athrow(operation[1])
308
+ except StopAsyncIteration:
309
+ body += time.perf_counter() - resumed
310
+ _stack.reset(token)
311
+ _close_iteration(event, started, body, items, policy, result=None)
312
+ return
313
+ except BaseException as exc:
314
+ body += time.perf_counter() - resumed
315
+ _stack.reset(token)
316
+ _close_iteration(event, started, body, items, policy, exception=exc)
317
+ raise
318
+ else:
319
+ body += time.perf_counter() - resumed
320
+ _stack.reset(token)
321
+
322
+ items += 1
323
+ event.items = items
324
+
325
+ try:
326
+ operation = ("send", (yield item))
327
+ except GeneratorExit:
328
+ await generator.aclose()
329
+ _close_iteration(event, started, body, items, policy)
330
+ raise
331
+ except BaseException as exc:
332
+ operation = ("throw", exc)
333
+
334
+
335
+ class Binding:
336
+ """The association of a target attribute with a wrapper and behaviour.
337
+
338
+ Created by binding(); the wrapper is installed by apply() or by
339
+ entering the binding as a context manager. Mixing the two lifecycle
340
+ styles is an error.
341
+
342
+ Two independent axes:
343
+
344
+ apply() / remove() whether the wrapper is applied to the target
345
+ suspend() / resume() whether an applied wrapper does anything
346
+
347
+ Behaviour can be configured or reconfigured at any time, before or
348
+ after apply().
349
+ """
350
+
351
+ def __init__(
352
+ self,
353
+ target: Any,
354
+ name: str,
355
+ *,
356
+ label: str | None = None,
357
+ mode: str | None = None,
358
+ missing_ok: bool = False,
359
+ capture: CapturePolicy | None = None,
360
+ capture_args: CapturePolicy | None = None,
361
+ capture_result: CapturePolicy | None = None,
362
+ stack: int | None = None,
363
+ ) -> None:
364
+ # Validate the target and settle the mode before anything is
365
+ # stored, so a bad binding fails on the line that created it.
366
+
367
+ _reject_deferred(target)
368
+
369
+ if stack is not None and stack < 1:
370
+ raise ValueError(
371
+ f"stack must be None, wrapture.caller, wrapture.full or a"
372
+ f" positive frame count, got {stack!r}"
373
+ )
374
+
375
+ if mode is None:
376
+ mode = _detect_mode(target, name, missing_ok=missing_ok)
377
+ elif mode not in ("callable", "attribute"):
378
+ raise ValueError(f"mode must be 'callable' or 'attribute', got {mode!r}")
379
+
380
+ # What this binding is bound to.
381
+
382
+ self._mode = mode
383
+ self._target = target
384
+ self._name = name
385
+ self._path = _derive_path(target, name)
386
+ self._label = label or self._default_label(target, name)
387
+ self._missing_ok = missing_ok
388
+
389
+ # Capture policy overrides. None means follow whatever the sink
390
+ # consuming the events declares; capture= is shorthand for both
391
+ # axes, with the specific parameters winning.
392
+
393
+ self._capture_args = capture_args if capture_args is not None else capture
394
+ self._capture_result = capture_result if capture_result is not None else capture
395
+ self._stack_depth = stack
396
+
397
+ # The behaviour pipelines, keyed by operation ("call", "get",
398
+ # "set" or "delete"): composing stages around one terminal, with
399
+ # the composed form cached until either changes.
400
+
401
+ self._pipelines: dict[str, list[StageFunction]] = {}
402
+ self._terminals: dict[str, WrapperFunction] = {}
403
+ self._composed: dict[str, WrapperFunction] = {}
404
+
405
+ # Lifecycle state, populated by apply() and cleared by remove().
406
+ # The apply count survives remove(): it distinguishes a binding
407
+ # that recorded nothing from one that was never applied at all.
408
+
409
+ self._wrapper: Any = None
410
+ self._suspended = False
411
+ self._suspended_calls = 0
412
+ self._apply_count = 0
413
+ self._missed_calls = 0
414
+ self._gap_warned = False
415
+
416
+ # Declared expectations, verified by the enclosing timeline at
417
+ # exit. Like behaviour, they persist across apply/remove cycles.
418
+
419
+ self._expectations: list[tuple[str, int]] = []
420
+
421
+ # Which operations currently have an injecting terminal
422
+ # (returns / raises / rejects), so their events can be marked.
423
+
424
+ self._injects: dict[str, bool] = {}
425
+
426
+ @staticmethod
427
+ def _default_label(target: Any, name: str) -> str:
428
+ owner = getattr(target, "__name__", None) or repr(target)
429
+ return f"{owner}.{name}"
430
+
431
+ # -- identity ----------------------------------------------------------
432
+
433
+ @property
434
+ def mode(self) -> str:
435
+ """'callable' or 'attribute'. Detected at creation.
436
+
437
+ Names what is bound, not the operation: a 'callable' binding
438
+ exposes on_call, an 'attribute' binding exposes on_get / on_set /
439
+ on_delete.
440
+ """
441
+
442
+ return self._mode
443
+
444
+ @property
445
+ def path(self) -> str:
446
+ """Fully qualified location of the bound attribute.
447
+
448
+ Format is module:path, both halves dotted. Derived from the
449
+ target and never affected by a label override, so events remain
450
+ self-describing wherever they end up.
451
+ """
452
+
453
+ return self._path
454
+
455
+ @property
456
+ def label(self) -> str:
457
+ return self._label
458
+
459
+ @property
460
+ def target(self) -> Any:
461
+ return self._target
462
+
463
+ @property
464
+ def name(self) -> str:
465
+ return self._name
466
+
467
+ @property
468
+ def wrapper(self) -> Any:
469
+ """The underlying wrapt handle, or None while unapplied.
470
+
471
+ Escape hatch to core wrapt: anything this class does not expose
472
+ remains reachable through it, e.g.
473
+ wrapt.unwrap_object(bnd.target, bnd.name, bnd.wrapper).
474
+ """
475
+
476
+ return self._wrapper
477
+
478
+ def __repr__(self) -> str:
479
+ if self._wrapper is None:
480
+ state = "unapplied"
481
+ else:
482
+ state = "active" if self.active else "displaced"
483
+
484
+ if self._suspended:
485
+ state += " suspended"
486
+
487
+ return f"<Binding {self._label!r} {self._mode} {state}>"
488
+
489
+ # -- behaviour namespaces ----------------------------------------------
490
+
491
+ def _namespace(
492
+ self, name: str, wanted: str, factory: type[_BehaviourT]
493
+ ) -> _BehaviourT:
494
+ if self._mode != wanted:
495
+ other = "on_get, on_set or on_delete" if wanted == "callable" else "on_call"
496
+ article = "an" if self._mode == "attribute" else "a"
497
+ raise WrongModeError(
498
+ f"{name} is not available: {self._label} is {article}"
499
+ f" {self._mode!r} binding; use {other}"
500
+ )
501
+
502
+ return factory(self)
503
+
504
+ @property
505
+ def on_call(self) -> CallBehaviour:
506
+ """The behaviour namespace for calls. Callable mode only."""
507
+
508
+ return self._namespace("on_call", "callable", CallBehaviour)
509
+
510
+ @property
511
+ def on_get(self) -> GetBehaviour:
512
+ """The behaviour namespace for attribute reads. Attribute mode only."""
513
+
514
+ return self._namespace("on_get", "attribute", GetBehaviour)
515
+
516
+ @property
517
+ def on_set(self) -> SetBehaviour:
518
+ """The behaviour namespace for attribute writes. Attribute mode only."""
519
+
520
+ return self._namespace("on_set", "attribute", SetBehaviour)
521
+
522
+ @property
523
+ def on_delete(self) -> DeleteBehaviour:
524
+ """The behaviour namespace for attribute deletes. Attribute mode only."""
525
+
526
+ return self._namespace("on_delete", "attribute", DeleteBehaviour)
527
+
528
+ # -- lifecycle ---------------------------------------------------------
529
+
530
+ @property
531
+ def applied(self) -> bool:
532
+ """Whether apply() installed a wrapper that has not been removed."""
533
+
534
+ return self._wrapper is not None
535
+
536
+ @property
537
+ def suspended(self) -> bool:
538
+ """Whether an applied wrapper is currently inert.
539
+
540
+ Orthogonal to `active`: a suspended binding is still applied, so it
541
+ reports active=True, suspended=True.
542
+ """
543
+
544
+ return self._suspended
545
+
546
+ @property
547
+ def active(self) -> bool:
548
+ """Whether the wrapper is still installed on the target.
549
+
550
+ Queried, not cached, so removal or replacement behind this
551
+ object's back is reported honestly. Three states: unapplied /
552
+ active / displaced.
553
+ """
554
+
555
+ if self._wrapper is None:
556
+ return False
557
+
558
+ # Resolve what is at the target right now; if the path no longer
559
+ # resolves at all, the wrapper is certainly not installed.
560
+
561
+ try:
562
+ current = wrapt.resolve_path(self._target, self._name)[2]
563
+ except Exception:
564
+ return False
565
+
566
+ return bool(is_wrapped_by(current, self._wrapper))
567
+
568
+ def apply(self, *, suspended: bool = False) -> Self:
569
+ """Apply the wrapper to the target. Returns self, so it chains.
570
+
571
+ With suspended=True the wrapper is installed but inert until
572
+ resume() is called.
573
+ """
574
+
575
+ if self._wrapper is not None:
576
+ raise AlreadyAppliedError(
577
+ f"{self._label} is already applied. Use either"
578
+ f" `with binding(...)` or apply()/remove() explicitly,"
579
+ f" not both."
580
+ )
581
+
582
+ # A fresh apply may warn about missed thread calls again.
583
+
584
+ self._gap_warned = False
585
+
586
+ if self._mode == "attribute":
587
+ self._wrapper = install_attribute(self, self._target, self._name)
588
+ self._suspended = suspended
589
+ self._apply_count += 1
590
+ _applied_bindings.add(self)
591
+ return self
592
+
593
+ # `enabled` must be supplied at construction: wrapt's _self_enabled
594
+ # is not writable afterwards. When it returns False wrapt bypasses
595
+ # the wrapper entirely.
596
+
597
+ def factory(wrapped: WrappedFunction, *args: Any, **kwargs: Any) -> Any:
598
+ return wrapt.FunctionWrapper(wrapped, self._make_wrapper(), self._enabled)
599
+
600
+ self._wrapper = wrapt.wrap_object(self._target, self._name, factory)
601
+ self._suspended = suspended
602
+ self._apply_count += 1
603
+ _applied_bindings.add(self)
604
+ return self
605
+
606
+ def _enabled(self) -> bool:
607
+ """Read by wrapt on every call; False bypasses the wrapper."""
608
+
609
+ if self._suspended:
610
+ self._suspended_calls += 1
611
+ return False
612
+
613
+ return True
614
+
615
+ def suspend(self) -> Self:
616
+ """Make an applied wrapper inert without removing it.
617
+
618
+ The wrapper stays in the chain, so nothing structural changes and
619
+ reconfiguration is atomic from a caller's point of view.
620
+ """
621
+
622
+ self._suspended = True
623
+ return self
624
+
625
+ def resume(self) -> Self:
626
+ """Reactivate a suspended wrapper."""
627
+
628
+ self._suspended = False
629
+ return self
630
+
631
+ @property
632
+ def suspended_calls(self) -> int:
633
+ """Calls that reached this binding while it was suspended."""
634
+
635
+ return self._suspended_calls
636
+
637
+ @property
638
+ def missed_calls(self) -> int:
639
+ """Operations that ran with no recording context while a
640
+ timeline was active elsewhere, typically on a thread, and are
641
+ therefore missing from that timeline's tape."""
642
+
643
+ return self._missed_calls
644
+
645
+ def _note_missed_call(self) -> None:
646
+ # Count every miss, but warn only once per apply cycle: a
647
+ # worker thread in a loop must not emit thousands of warnings.
648
+
649
+ self._missed_calls += 1
650
+
651
+ if not self._gap_warned:
652
+ self._gap_warned = True
653
+ warnings.warn(
654
+ f"{self._label}: an observed operation ran on a thread"
655
+ f" with no recording context while a timeline was active"
656
+ f" elsewhere, so it was not recorded (behaviour still"
657
+ f" applied). To record work on this thread, hand it a"
658
+ f" copied context: contextvars.copy_context().run(...)."
659
+ f" Misses are counted on Binding.missed_calls.",
660
+ RecordingGapWarning,
661
+ stacklevel=2,
662
+ )
663
+
664
+ @property
665
+ def events(self) -> EventLog:
666
+ """This binding's events from the enclosing timeline, as a
667
+ filterable EventLog.
668
+
669
+ One canonical name across both modes: a callable binding records
670
+ "call" events, an attribute binding records "get", "set" and
671
+ "delete"; narrow with .of_kind() where a mode has several.
672
+
673
+ Raises rather than returning an empty log when no events could
674
+ possibly exist, so "recorded nothing" can never be mistaken for
675
+ "not recording": NeverAppliedError if the binding was never
676
+ applied, and RuntimeError outside a timeline.
677
+ """
678
+
679
+ if self._apply_count == 0:
680
+ raise NeverAppliedError(
681
+ f"{self._label} was never applied; call apply() or use it"
682
+ f" as a context manager"
683
+ )
684
+
685
+ tape = _tape.get()
686
+ if tape is None:
687
+ raise RuntimeError(
688
+ f"{self._label}: events are only recorded inside a timeline()"
689
+ )
690
+
691
+ return tape.for_binding(self)
692
+
693
+ def remove(self, *, missing_ok: bool = True) -> Self:
694
+ """Remove the wrapper. Idempotent. The binding can be applied
695
+ again afterwards, starting unsuspended."""
696
+
697
+ if self._wrapper is None:
698
+ return self
699
+
700
+ unwrap_object(self._target, self._name, self._wrapper, missing_ok=missing_ok)
701
+
702
+ self._wrapper = None
703
+ self._suspended = False
704
+ _applied_bindings.discard(self)
705
+ return self
706
+
707
+ def __enter__(self) -> Self:
708
+ return self.apply()
709
+
710
+ def __exit__(self, *exc: object) -> None:
711
+ self.remove()
712
+
713
+ # -- declared expectations ---------------------------------------------
714
+
715
+ def expect_times(self, count: int) -> Self:
716
+ """Declare that this binding records exactly `count` events.
717
+
718
+ Verified when the enclosing timeline exits, so verification
719
+ cannot be forgotten; a mismatch raises ExpectationNotMetError.
720
+ """
721
+
722
+ self._expectations.append(("times", count))
723
+ return self
724
+
725
+ def expect_once(self) -> Self:
726
+ """Declare that this binding records exactly one event."""
727
+
728
+ return self.expect_times(1)
729
+
730
+ def expect_never(self) -> Self:
731
+ """Declare that this binding records no events."""
732
+
733
+ self._expectations.append(("never", 0))
734
+ return self
735
+
736
+ def expect_at_least(self, count: int) -> Self:
737
+ """Declare that this binding records at least `count` events."""
738
+
739
+ self._expectations.append(("at_least", count))
740
+ return self
741
+
742
+ def _verify(self, tape: Any) -> None:
743
+ # Called by the timeline at exit. Reuses the assertion methods
744
+ # so failure output matches theirs, re-raised under the declared
745
+ # expectation's own exception type.
746
+
747
+ if not self._expectations:
748
+ return
749
+
750
+ log = tape.for_binding(self)
751
+
752
+ for kind, count in self._expectations:
753
+ try:
754
+ if kind == "times":
755
+ log.assert_times(count)
756
+ elif kind == "never":
757
+ log.assert_never()
758
+ else:
759
+ log.assert_at_least(count)
760
+ except AssertionError as exc:
761
+ raise ExpectationNotMetError(
762
+ f"declared expectation on {self._label} not met: {exc}"
763
+ ) from None
764
+
765
+ # -- wrapper -----------------------------------------------------------
766
+
767
+ def _make_wrapper(self) -> WrapperFunction:
768
+ bnd = self
769
+
770
+ def wrapper(
771
+ wrapped: WrappedFunction,
772
+ instance: Any,
773
+ args: tuple[Any, ...],
774
+ kwargs: dict[str, Any],
775
+ ) -> Any:
776
+ behaviour = bnd._behaviour("call")
777
+ tape = _tape.get()
778
+
779
+ # Not recording: no timeline is active, or this call was
780
+ # triggered by the recording machinery itself rather than by
781
+ # the code under observation. Behaviour still applies; the
782
+ # call is just not recorded. A call with no context while a
783
+ # timeline runs elsewhere is a recording gap, typically a
784
+ # thread, and is counted and warned about rather than lost
785
+ # silently.
786
+
787
+ if tape is None or _in_recorder.get():
788
+ if tape is None and not _in_recorder.get() and _timelines_active():
789
+ bnd._note_missed_call()
790
+
791
+ if behaviour is None:
792
+ return wrapped(*args, **kwargs)
793
+ return behaviour(wrapped, instance, args, kwargs)
794
+
795
+ # Create and record the event under the recorder guard, so
796
+ # anything the bookkeeping calls that is itself observed
797
+ # passes through instead of recording recursively.
798
+
799
+ guard = _in_recorder.set(True)
800
+ try:
801
+ event = bnd._record_call(tape, wrapped, instance, args, kwargs)
802
+ finally:
803
+ _in_recorder.reset(guard)
804
+
805
+ # Run the call with the event on the in-progress stack, so
806
+ # calls made inside the body nest under it.
807
+
808
+ base = _stack.get()
809
+ token = _push(event)
810
+ try:
811
+ if behaviour is None:
812
+ outcome = wrapped(*args, **kwargs)
813
+ else:
814
+ outcome = behaviour(
815
+ _forwarder(wrapped, event), instance, args, kwargs
816
+ )
817
+ except BaseException as exc:
818
+ event.exception = exc
819
+ raise
820
+ finally:
821
+ _pop(token)
822
+
823
+ # A generator or coroutine outcome has not run yet: calling
824
+ # the target only constructed it, and the body executes when
825
+ # the consumer iterates or awaits. So the scope above
826
+ # covered construction only, and the outcome is recorded
827
+ # around the iteration or await instead. All tested on the
828
+ # result, not the target: a plain def can return either.
829
+
830
+ result_policy = bnd._capture_result
831
+ if result_policy is None:
832
+ result_policy = getattr(tape, "capture_result", REFERENCE)
833
+
834
+ if inspect.isgenerator(outcome):
835
+ return _record_generator(outcome, event, base, result_policy)
836
+
837
+ if inspect.isasyncgen(outcome):
838
+ return _record_async_generator(outcome, event, base, result_policy)
839
+
840
+ if inspect.isawaitable(outcome):
841
+ return _record_awaited(outcome, event, base, result_policy)
842
+
843
+ _capture_result(event, outcome, result_policy)
844
+ return outcome
845
+
846
+ return wrapper
847
+
848
+ def _record_call(
849
+ self,
850
+ tape: Tape,
851
+ wrapped: WrappedFunction,
852
+ instance: Any,
853
+ args: tuple[Any, ...],
854
+ kwargs: dict[str, Any],
855
+ ) -> Event:
856
+ # Resolve the argument capture policy: the binding's override,
857
+ # else what the sink consuming the events declares.
858
+
859
+ policy = self._capture_args
860
+ if policy is None:
861
+ policy = getattr(tape, "capture_args", REFERENCE)
862
+ level = _level_of(policy)
863
+
864
+ event = Event(
865
+ "call",
866
+ self._path,
867
+ label=self._label,
868
+ instance=instance,
869
+ binding=self,
870
+ capture=level,
871
+ injected=self._injects.get("call", False),
872
+ )
873
+
874
+ if self._stack_depth is not None:
875
+ event.stack = _capture_stack(self._stack_depth)
876
+
877
+ # NONE skips signature binding entirely, the dominant cost of
878
+ # recording: the call stays visible, its values do not.
879
+
880
+ if level > NONE:
881
+ arguments = normalized_arguments(wrapped, args, kwargs)
882
+
883
+ if not callable(policy) and level == REFERENCE:
884
+ event.args = args
885
+ event.kwargs = kwargs
886
+ event.arguments = arguments
887
+ elif arguments is not None:
888
+ # Above REFERENCE, capture through the normalized form
889
+ # only: keeping the raw call shape too would duplicate
890
+ # every value, and a by-name policy such as redact()
891
+ # cannot see names in a raw args tuple.
892
+
893
+ event.arguments = {
894
+ name: _capture_value(policy, name, value)
895
+ for name, value in arguments.items()
896
+ }
897
+ else:
898
+ # No signature to normalize against: capture the raw
899
+ # call shape instead, positionals under no name.
900
+
901
+ event.args = tuple(
902
+ _capture_value(policy, None, value) for value in args
903
+ )
904
+ event.kwargs = {
905
+ name: _capture_value(policy, name, value)
906
+ for name, value in kwargs.items()
907
+ }
908
+
909
+ return tape.record(event)
910
+
911
+ # -- behaviour pipelines -------------------------------------------------
912
+
913
+ def _set_terminal(
914
+ self, operation: str, fn: WrapperFunction, *, injected: bool = False
915
+ ) -> None:
916
+ self._terminals[operation] = fn
917
+ self._injects[operation] = injected
918
+ self._composed.pop(operation, None)
919
+
920
+ def _add_stage(self, operation: str, fn: StageFunction) -> None:
921
+ self._pipelines.setdefault(operation, []).append(fn)
922
+ self._composed.pop(operation, None)
923
+
924
+ def _clear_behaviour(self, operation: str) -> None:
925
+ self._pipelines.pop(operation, None)
926
+ self._terminals.pop(operation, None)
927
+ self._injects.pop(operation, None)
928
+ self._composed.pop(operation, None)
929
+
930
+ def _behaviour(self, operation: str) -> WrapperFunction | None:
931
+ """The composed pipeline for one operation, or None when nothing
932
+ is configured for it."""
933
+
934
+ pipeline = self._pipelines.get(operation)
935
+ terminal = self._terminals.get(operation)
936
+
937
+ if not pipeline and terminal is None:
938
+ return None
939
+
940
+ composed = self._composed.get(operation)
941
+
942
+ if composed is None:
943
+ composed = _compose(pipeline or (), terminal)
944
+ self._composed[operation] = composed
945
+
946
+ return composed
947
+
948
+
949
+ class BindingGroup:
950
+ """Several bindings applied and removed as a unit.
951
+
952
+ Bindings are reachable by attribute or item access using the names
953
+ they were given. apply() rolls back on partial failure; remove()
954
+ removes in reverse order of application.
955
+ """
956
+
957
+ def __init__(self, points: dict[str, tuple[Any, str]]) -> None:
958
+ self._bindings = {
959
+ key: Binding(target, name, label=key)
960
+ for key, (target, name) in points.items()
961
+ }
962
+
963
+ def __getitem__(self, key: str) -> Binding:
964
+ return self._bindings[key]
965
+
966
+ def __iter__(self) -> Any:
967
+ return iter(self._bindings.values())
968
+
969
+ def __len__(self) -> int:
970
+ return len(self._bindings)
971
+
972
+ def __getattr__(self, key: str) -> Binding:
973
+ try:
974
+ return self._bindings[key]
975
+ except KeyError:
976
+ raise AttributeError(key) from None
977
+
978
+ def __repr__(self) -> str:
979
+ return f"<BindingGroup {list(self._bindings)}>"
980
+
981
+ @property
982
+ def active(self) -> bool:
983
+ """Whether every binding in the group is applied and active."""
984
+
985
+ return all(b.active for b in self._bindings.values())
986
+
987
+ @property
988
+ def suspended(self) -> bool:
989
+ """Whether every binding in the group is suspended."""
990
+
991
+ return all(b.suspended for b in self._bindings.values())
992
+
993
+ def apply(self, *, suspended: bool = False) -> Self:
994
+ """Apply every binding, in declaration order. Returns self.
995
+
996
+ If any member fails to apply, the members already applied are
997
+ removed again, so the group never half-applies.
998
+ """
999
+
1000
+ applied: list[Binding] = []
1001
+
1002
+ try:
1003
+ for bnd in self._bindings.values():
1004
+ applied.append(bnd.apply(suspended=suspended))
1005
+ except Exception:
1006
+ for bnd in reversed(applied):
1007
+ bnd.remove()
1008
+ raise
1009
+
1010
+ return self
1011
+
1012
+ def suspend(self) -> Self:
1013
+ """Suspend every binding in the group. Returns self."""
1014
+
1015
+ for bnd in self._bindings.values():
1016
+ bnd.suspend()
1017
+ return self
1018
+
1019
+ def resume(self) -> Self:
1020
+ """Resume every binding in the group. Returns self."""
1021
+
1022
+ for bnd in self._bindings.values():
1023
+ bnd.resume()
1024
+ return self
1025
+
1026
+ def remove(self) -> Self:
1027
+ """Remove every binding, in reverse order of application. Returns
1028
+ self. Idempotent, like Binding.remove()."""
1029
+
1030
+ for bnd in reversed(list(self._bindings.values())):
1031
+ bnd.remove()
1032
+ return self
1033
+
1034
+ def _verify(self, tape: Any) -> None:
1035
+ # Called by the timeline at exit: verify every member's declared
1036
+ # expectations.
1037
+
1038
+ for bnd in self._bindings.values():
1039
+ bnd._verify(tape)
1040
+
1041
+ def __enter__(self) -> Self:
1042
+ return self.apply()
1043
+
1044
+ def __exit__(self, *exc: object) -> None:
1045
+ self.remove()
1046
+
1047
+
1048
+ def binding(
1049
+ target: Any,
1050
+ name: str,
1051
+ *,
1052
+ label: str | None = None,
1053
+ mode: str | None = None,
1054
+ missing_ok: bool = False,
1055
+ capture: CapturePolicy | None = None,
1056
+ capture_args: CapturePolicy | None = None,
1057
+ capture_result: CapturePolicy | None = None,
1058
+ stack: int | None = None,
1059
+ ) -> Binding:
1060
+ """Create a binding for one target attribute.
1061
+
1062
+ `target` is a module, class, instance, or a string naming a module.
1063
+ `name` is a dotted path to the attribute.
1064
+
1065
+ The mode, 'callable' or 'attribute', is detected from whatever is at
1066
+ the target and selects which behaviour namespaces exist. Pass `mode=`
1067
+ to override for the ambiguous case of a callable stored as data.
1068
+
1069
+ `missing_ok=True` permits binding a name that is not on the class,
1070
+ typically one assigned in __init__. Without it such a name raises
1071
+ AttributeError, because it is indistinguishable from a typo.
1072
+
1073
+ `capture=` overrides how much of the recorded values this binding
1074
+ stores (a level such as SUMMARY, or a fn(name, value) callable),
1075
+ with `capture_args=` and `capture_result=` controlling the two axes
1076
+ separately and winning over the shorthand. Left unset, the binding
1077
+ follows what the sink consuming the events declares.
1078
+
1079
+ `stack=` captures how control reached each recorded event:
1080
+ wrapture.caller for just the calling frame, a frame count, or
1081
+ wrapture.full for the whole stack. The default None captures
1082
+ nothing and costs nothing.
1083
+
1084
+ Does NOT apply the wrapper; call apply() or use the binding as a
1085
+ context manager.
1086
+ """
1087
+
1088
+ return Binding(
1089
+ target,
1090
+ name,
1091
+ label=label,
1092
+ mode=mode,
1093
+ missing_ok=missing_ok,
1094
+ capture=capture,
1095
+ capture_args=capture_args,
1096
+ capture_result=capture_result,
1097
+ stack=stack,
1098
+ )
1099
+
1100
+
1101
+ def bindings(**points: tuple[Any, str]) -> BindingGroup:
1102
+ """Create several bindings at once, named by keyword.
1103
+
1104
+ with bindings(charge=(Gateway, "charge"),
1105
+ ledger=(Ledger, "record")) as group:
1106
+ ...
1107
+ group.charge.suspend()
1108
+ """
1109
+
1110
+ return BindingGroup(dict(points))