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/__init__.py +119 -0
- wrapture/attributes.py +344 -0
- wrapture/behaviours.py +428 -0
- wrapture/bindings.py +1110 -0
- wrapture/capture.py +165 -0
- wrapture/eventlogs.py +232 -0
- wrapture/events.py +201 -0
- wrapture/exceptions.py +45 -0
- wrapture/iterators.py +451 -0
- wrapture/py.typed +0 -0
- wrapture/pytest_plugin.py +119 -0
- wrapture/stacks.py +93 -0
- wrapture/timeline.py +364 -0
- wrapture-1.0.0.dev1.dist-info/METADATA +155 -0
- wrapture-1.0.0.dev1.dist-info/RECORD +18 -0
- wrapture-1.0.0.dev1.dist-info/WHEEL +5 -0
- wrapture-1.0.0.dev1.dist-info/licenses/LICENSE +24 -0
- wrapture-1.0.0.dev1.dist-info/top_level.txt +1 -0
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))
|