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/iterators.py
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""Iterator proxies: behaviour applied to the items and lifecycle of an
|
|
2
|
+
iterator.
|
|
3
|
+
|
|
4
|
+
A binding's behaviour runs when the target is called, but a callable that
|
|
5
|
+
returns a generator or iterator produces its values later, one item at a
|
|
6
|
+
time, as the caller iterates. iterator() creates a factory holding
|
|
7
|
+
behaviour; calling the factory with an iterator returns a wrapped
|
|
8
|
+
iterator that applies the behaviour as the iteration runs. Nothing is
|
|
9
|
+
wrapped automatically: the factory is applied from a binding's
|
|
10
|
+
decorates(), transforms_result() or transforms_args() stage, or called
|
|
11
|
+
directly.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import inspect
|
|
17
|
+
from collections.abc import (
|
|
18
|
+
AsyncGenerator,
|
|
19
|
+
AsyncIterable,
|
|
20
|
+
AsyncIterator,
|
|
21
|
+
Callable,
|
|
22
|
+
Generator,
|
|
23
|
+
Iterable,
|
|
24
|
+
Iterator,
|
|
25
|
+
)
|
|
26
|
+
from typing import Any, NamedTuple, Self
|
|
27
|
+
|
|
28
|
+
ItemFunction = Callable[[Any], Any]
|
|
29
|
+
FinishFunction = Callable[[Any], Any]
|
|
30
|
+
ErrorFunction = Callable[[BaseException], Any]
|
|
31
|
+
AbandonFunction = Callable[[], Any]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _Hooks(NamedTuple):
|
|
35
|
+
"""The behaviour snapshot a wrapped iterator runs with."""
|
|
36
|
+
|
|
37
|
+
stages: tuple[ItemFunction, ...]
|
|
38
|
+
finish: tuple[FinishFunction, ...]
|
|
39
|
+
error: tuple[ErrorFunction, ...]
|
|
40
|
+
abandon: tuple[AbandonFunction, ...]
|
|
41
|
+
|
|
42
|
+
def item(self, item: Any) -> Any:
|
|
43
|
+
for stage in self.stages:
|
|
44
|
+
item = stage(item)
|
|
45
|
+
return item
|
|
46
|
+
|
|
47
|
+
def finished(self, value: Any) -> None:
|
|
48
|
+
for check in self.finish:
|
|
49
|
+
check(value)
|
|
50
|
+
|
|
51
|
+
def failed(self, exc: BaseException) -> None:
|
|
52
|
+
for fn in self.error:
|
|
53
|
+
fn(exc)
|
|
54
|
+
|
|
55
|
+
def abandoned(self) -> None:
|
|
56
|
+
for fn in self.abandon:
|
|
57
|
+
fn()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _relay(
|
|
61
|
+
generator: Generator[Any, Any, Any], hooks: _Hooks
|
|
62
|
+
) -> Generator[Any, Any, Any]:
|
|
63
|
+
"""A generator around a generator, applying hooks as it runs.
|
|
64
|
+
|
|
65
|
+
Preserves the full generator protocol: values from send() are
|
|
66
|
+
forwarded in, throw() is forwarded so the wrapped generator can
|
|
67
|
+
handle the exception, close() closes the wrapped generator, and the
|
|
68
|
+
wrapped generator's return value is returned. If an item stage
|
|
69
|
+
raises, the wrapped generator is closed before the exception
|
|
70
|
+
propagates.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
operation: tuple[str, Any] = ("send", None)
|
|
74
|
+
|
|
75
|
+
while True:
|
|
76
|
+
# Drive the wrapped generator with whatever the consumer last did
|
|
77
|
+
# to this one: a plain next()/send() or a throw().
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
if operation[0] == "send":
|
|
81
|
+
item = generator.send(operation[1])
|
|
82
|
+
else:
|
|
83
|
+
item = generator.throw(operation[1])
|
|
84
|
+
except StopIteration as stop:
|
|
85
|
+
hooks.finished(stop.value)
|
|
86
|
+
return stop.value
|
|
87
|
+
except BaseException as exc:
|
|
88
|
+
hooks.failed(exc)
|
|
89
|
+
raise
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
item = hooks.item(item)
|
|
93
|
+
except BaseException as exc:
|
|
94
|
+
generator.close()
|
|
95
|
+
hooks.failed(exc)
|
|
96
|
+
raise
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
operation = ("send", (yield item))
|
|
100
|
+
except GeneratorExit:
|
|
101
|
+
generator.close()
|
|
102
|
+
hooks.abandoned()
|
|
103
|
+
raise
|
|
104
|
+
except BaseException as exc:
|
|
105
|
+
operation = ("throw", exc)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def _relay_async(
|
|
109
|
+
generator: AsyncGenerator[Any, Any], hooks: _Hooks
|
|
110
|
+
) -> AsyncGenerator[Any, Any]:
|
|
111
|
+
"""The async twin of _relay, for async generators.
|
|
112
|
+
|
|
113
|
+
Forwards asend() and athrow(), and aclose() closes the wrapped
|
|
114
|
+
generator. Async generators have no return value, so finish hooks
|
|
115
|
+
receive None.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
operation: tuple[str, Any] = ("send", None)
|
|
119
|
+
|
|
120
|
+
while True:
|
|
121
|
+
try:
|
|
122
|
+
if operation[0] == "send":
|
|
123
|
+
item = await generator.asend(operation[1])
|
|
124
|
+
else:
|
|
125
|
+
item = await generator.athrow(operation[1])
|
|
126
|
+
except StopAsyncIteration:
|
|
127
|
+
hooks.finished(None)
|
|
128
|
+
return
|
|
129
|
+
except BaseException as exc:
|
|
130
|
+
hooks.failed(exc)
|
|
131
|
+
raise
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
item = hooks.item(item)
|
|
135
|
+
except BaseException as exc:
|
|
136
|
+
await generator.aclose()
|
|
137
|
+
hooks.failed(exc)
|
|
138
|
+
raise
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
operation = ("send", (yield item))
|
|
142
|
+
except GeneratorExit:
|
|
143
|
+
await generator.aclose()
|
|
144
|
+
hooks.abandoned()
|
|
145
|
+
raise
|
|
146
|
+
except BaseException as exc:
|
|
147
|
+
operation = ("throw", exc)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class _ItemIterator:
|
|
151
|
+
"""A plain iterator around a plain iterator, applying hooks.
|
|
152
|
+
|
|
153
|
+
Used for iterators that are not generators, which have no send(),
|
|
154
|
+
throw() or close() to forward. With no close() there is also no
|
|
155
|
+
abandonment to observe: abandon hooks never fire for these.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
__slots__ = ("_finished", "_hooks", "_iterator")
|
|
159
|
+
|
|
160
|
+
def __init__(self, iterator: Iterator[Any], hooks: _Hooks):
|
|
161
|
+
self._iterator = iterator
|
|
162
|
+
self._hooks = hooks
|
|
163
|
+
self._finished = False
|
|
164
|
+
|
|
165
|
+
def __iter__(self) -> Iterator[Any]:
|
|
166
|
+
return self
|
|
167
|
+
|
|
168
|
+
def __next__(self) -> Any:
|
|
169
|
+
try:
|
|
170
|
+
item = next(self._iterator)
|
|
171
|
+
except StopIteration as stop:
|
|
172
|
+
# Finish hooks run once, on the first exhaustion; iterating a
|
|
173
|
+
# spent iterator again raises without re-running them.
|
|
174
|
+
if not self._finished:
|
|
175
|
+
self._finished = True
|
|
176
|
+
self._hooks.finished(stop.value)
|
|
177
|
+
raise
|
|
178
|
+
except BaseException as exc:
|
|
179
|
+
self._hooks.failed(exc)
|
|
180
|
+
raise
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
return self._hooks.item(item)
|
|
184
|
+
except BaseException as exc:
|
|
185
|
+
self._hooks.failed(exc)
|
|
186
|
+
raise
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class _AsyncItemIterator:
|
|
190
|
+
"""The async twin of _ItemIterator, for plain async iterators."""
|
|
191
|
+
|
|
192
|
+
__slots__ = ("_finished", "_hooks", "_iterator")
|
|
193
|
+
|
|
194
|
+
def __init__(self, iterator: AsyncIterator[Any], hooks: _Hooks):
|
|
195
|
+
self._iterator = iterator
|
|
196
|
+
self._hooks = hooks
|
|
197
|
+
self._finished = False
|
|
198
|
+
|
|
199
|
+
def __aiter__(self) -> AsyncIterator[Any]:
|
|
200
|
+
return self
|
|
201
|
+
|
|
202
|
+
async def __anext__(self) -> Any:
|
|
203
|
+
try:
|
|
204
|
+
item = await self._iterator.__anext__()
|
|
205
|
+
except StopAsyncIteration:
|
|
206
|
+
if not self._finished:
|
|
207
|
+
self._finished = True
|
|
208
|
+
self._hooks.finished(None)
|
|
209
|
+
raise
|
|
210
|
+
except BaseException as exc:
|
|
211
|
+
self._hooks.failed(exc)
|
|
212
|
+
raise
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
return self._hooks.item(item)
|
|
216
|
+
except BaseException as exc:
|
|
217
|
+
self._hooks.failed(exc)
|
|
218
|
+
raise
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class _IteratorBehaviour:
|
|
222
|
+
"""Base for the iterator factory's behaviour namespaces."""
|
|
223
|
+
|
|
224
|
+
__slots__ = ("_factory",)
|
|
225
|
+
|
|
226
|
+
def __init__(self, factory: IteratorProxy) -> None:
|
|
227
|
+
self._factory = factory
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class ItemBehaviour(_IteratorBehaviour):
|
|
231
|
+
"""`iterator().on_item`: behaviour applied to each item.
|
|
232
|
+
|
|
233
|
+
Mirrors the composing half of a binding's on_call: stages accumulate
|
|
234
|
+
in the order added and each item passes through all of them.
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
__slots__ = ()
|
|
238
|
+
|
|
239
|
+
def transforms_item(self, fn: ItemFunction) -> IteratorProxy:
|
|
240
|
+
"""fn(item) -> item, rewriting each item as it passes through."""
|
|
241
|
+
|
|
242
|
+
return self._factory._add(self._factory._item_stages, fn)
|
|
243
|
+
|
|
244
|
+
def validates_item(self, check: Callable[[Any], Any]) -> IteratorProxy:
|
|
245
|
+
"""check(item); each item passes through unchanged.
|
|
246
|
+
|
|
247
|
+
The check fails the iteration only by raising; its return value
|
|
248
|
+
is ignored.
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
def stage(item: Any) -> Any:
|
|
252
|
+
check(item)
|
|
253
|
+
return item
|
|
254
|
+
|
|
255
|
+
return self._factory._add(self._factory._item_stages, stage)
|
|
256
|
+
|
|
257
|
+
def passes_through(self) -> IteratorProxy:
|
|
258
|
+
"""Drop all configured item behaviour."""
|
|
259
|
+
|
|
260
|
+
return self._factory._clear(self._factory._item_stages)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class FinishBehaviour(_IteratorBehaviour):
|
|
264
|
+
"""`iterator().on_finish`: behaviour for normal exhaustion.
|
|
265
|
+
|
|
266
|
+
Checks receive the wrapped generator's return value, or None for
|
|
267
|
+
iterator kinds that have no return value.
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
__slots__ = ()
|
|
271
|
+
|
|
272
|
+
def validates(self, check: FinishFunction) -> IteratorProxy:
|
|
273
|
+
"""check(value); completion stands unless check raises."""
|
|
274
|
+
|
|
275
|
+
return self._factory._add(self._factory._finish_checks, check)
|
|
276
|
+
|
|
277
|
+
def passes_through(self) -> IteratorProxy:
|
|
278
|
+
"""Drop all configured finish behaviour."""
|
|
279
|
+
|
|
280
|
+
return self._factory._clear(self._factory._finish_checks)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class ErrorBehaviour(_IteratorBehaviour):
|
|
284
|
+
"""`iterator().on_error`: behaviour for a failed iteration.
|
|
285
|
+
|
|
286
|
+
Hooks receive the exception about to reach the consumer, whether it
|
|
287
|
+
came from the wrapped iterator's body, from an unhandled throw(), or
|
|
288
|
+
from an item stage. The exception propagates afterwards; a hook that
|
|
289
|
+
itself raises replaces it.
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
__slots__ = ()
|
|
293
|
+
|
|
294
|
+
def notifies(self, fn: ErrorFunction) -> IteratorProxy:
|
|
295
|
+
"""fn(exc), called before the exception propagates."""
|
|
296
|
+
|
|
297
|
+
return self._factory._add(self._factory._error_hooks, fn)
|
|
298
|
+
|
|
299
|
+
def passes_through(self) -> IteratorProxy:
|
|
300
|
+
"""Drop all configured error behaviour."""
|
|
301
|
+
|
|
302
|
+
return self._factory._clear(self._factory._error_hooks)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class AbandonBehaviour(_IteratorBehaviour):
|
|
306
|
+
"""`iterator().on_abandon`: behaviour for an abandoned iteration.
|
|
307
|
+
|
|
308
|
+
Hooks fire when a started, unexhausted wrapped generator is closed,
|
|
309
|
+
whether explicitly via close() or by garbage collection. A wrapper
|
|
310
|
+
closed before its first item is silent, and plain iterators have no
|
|
311
|
+
close protocol so never report abandonment.
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
__slots__ = ()
|
|
315
|
+
|
|
316
|
+
def notifies(self, fn: AbandonFunction) -> IteratorProxy:
|
|
317
|
+
"""fn(), called after the wrapped generator has been closed."""
|
|
318
|
+
|
|
319
|
+
return self._factory._add(self._factory._abandon_hooks, fn)
|
|
320
|
+
|
|
321
|
+
def passes_through(self) -> IteratorProxy:
|
|
322
|
+
"""Drop all configured abandon behaviour."""
|
|
323
|
+
|
|
324
|
+
return self._factory._clear(self._factory._abandon_hooks)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class IteratorProxy:
|
|
328
|
+
"""A factory for iterators that apply configured behaviour.
|
|
329
|
+
|
|
330
|
+
Created by iterator(). Unlike a Binding there is no target: one
|
|
331
|
+
factory can be applied to any number of iterators by calling it, and
|
|
332
|
+
each call returns a new wrapped iterator around the one given.
|
|
333
|
+
|
|
334
|
+
The behaviour applied by a wrapped iterator is the behaviour
|
|
335
|
+
configured at the moment the factory was called on it. Reconfiguring
|
|
336
|
+
the factory affects only iterators wrapped afterwards.
|
|
337
|
+
"""
|
|
338
|
+
|
|
339
|
+
def __init__(self) -> None:
|
|
340
|
+
self._item_stages: list[ItemFunction] = []
|
|
341
|
+
self._finish_checks: list[FinishFunction] = []
|
|
342
|
+
self._error_hooks: list[ErrorFunction] = []
|
|
343
|
+
self._abandon_hooks: list[AbandonFunction] = []
|
|
344
|
+
|
|
345
|
+
@property
|
|
346
|
+
def on_item(self) -> ItemBehaviour:
|
|
347
|
+
"""The behaviour namespace for items."""
|
|
348
|
+
|
|
349
|
+
return ItemBehaviour(self)
|
|
350
|
+
|
|
351
|
+
@property
|
|
352
|
+
def on_finish(self) -> FinishBehaviour:
|
|
353
|
+
"""The behaviour namespace for normal exhaustion."""
|
|
354
|
+
|
|
355
|
+
return FinishBehaviour(self)
|
|
356
|
+
|
|
357
|
+
@property
|
|
358
|
+
def on_error(self) -> ErrorBehaviour:
|
|
359
|
+
"""The behaviour namespace for a failed iteration."""
|
|
360
|
+
|
|
361
|
+
return ErrorBehaviour(self)
|
|
362
|
+
|
|
363
|
+
@property
|
|
364
|
+
def on_abandon(self) -> AbandonBehaviour:
|
|
365
|
+
"""The behaviour namespace for an abandoned iteration."""
|
|
366
|
+
|
|
367
|
+
return AbandonBehaviour(self)
|
|
368
|
+
|
|
369
|
+
def _add(self, hooks: list[Any], fn: Any) -> Self:
|
|
370
|
+
hooks.append(fn)
|
|
371
|
+
return self
|
|
372
|
+
|
|
373
|
+
def _clear(self, hooks: list[Any]) -> Self:
|
|
374
|
+
hooks.clear()
|
|
375
|
+
return self
|
|
376
|
+
|
|
377
|
+
def _snapshot(self) -> _Hooks:
|
|
378
|
+
return _Hooks(
|
|
379
|
+
tuple(self._item_stages),
|
|
380
|
+
tuple(self._finish_checks),
|
|
381
|
+
tuple(self._error_hooks),
|
|
382
|
+
tuple(self._abandon_hooks),
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
def __repr__(self) -> str:
|
|
386
|
+
count = (
|
|
387
|
+
len(self._item_stages)
|
|
388
|
+
+ len(self._finish_checks)
|
|
389
|
+
+ len(self._error_hooks)
|
|
390
|
+
+ len(self._abandon_hooks)
|
|
391
|
+
)
|
|
392
|
+
return f"<IteratorProxy {count} behaviour(s)>"
|
|
393
|
+
|
|
394
|
+
def __call__(self, iterable: Any) -> Any:
|
|
395
|
+
"""Wrap an iterator so configured behaviour applies as it runs.
|
|
396
|
+
|
|
397
|
+
Accepts sync and async generators, which keep their full protocol
|
|
398
|
+
through the wrapper, and plain sync and async iterators. With no
|
|
399
|
+
behaviour configured the iterator is returned unwrapped.
|
|
400
|
+
|
|
401
|
+
An iterable that is not an iterator, such as a list, is refused:
|
|
402
|
+
wrapping it would silently replace it with an iterator of a
|
|
403
|
+
different type. Call iter() on it first if that is intended.
|
|
404
|
+
"""
|
|
405
|
+
|
|
406
|
+
# Classify before the no-behaviour shortcut, so an unsupported
|
|
407
|
+
# value is refused consistently however the factory is configured.
|
|
408
|
+
|
|
409
|
+
wrap: Callable[[Any, _Hooks], Any]
|
|
410
|
+
|
|
411
|
+
if inspect.isgenerator(iterable):
|
|
412
|
+
wrap = _relay
|
|
413
|
+
elif inspect.isasyncgen(iterable):
|
|
414
|
+
wrap = _relay_async
|
|
415
|
+
elif isinstance(iterable, Iterator):
|
|
416
|
+
wrap = _ItemIterator
|
|
417
|
+
elif isinstance(iterable, AsyncIterator):
|
|
418
|
+
wrap = _AsyncItemIterator
|
|
419
|
+
elif isinstance(iterable, Iterable | AsyncIterable):
|
|
420
|
+
raise TypeError(
|
|
421
|
+
f"{iterable!r} is iterable but is not an iterator; wrapping"
|
|
422
|
+
f" it would silently replace it with an iterator of a"
|
|
423
|
+
f" different type. Call iter() on it first if that is"
|
|
424
|
+
f" intended."
|
|
425
|
+
)
|
|
426
|
+
else:
|
|
427
|
+
raise TypeError(f"{iterable!r} is not an iterator")
|
|
428
|
+
|
|
429
|
+
hooks = self._snapshot()
|
|
430
|
+
|
|
431
|
+
if not any(hooks):
|
|
432
|
+
return iterable
|
|
433
|
+
|
|
434
|
+
return wrap(iterable, hooks)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def iterator() -> IteratorProxy:
|
|
438
|
+
"""Create an iterator proxy factory.
|
|
439
|
+
|
|
440
|
+
The factory holds behaviour configured through its namespaces, and is
|
|
441
|
+
applied by calling it with an iterator; each call returns a new
|
|
442
|
+
wrapped iterator applying the behaviour configured at that moment:
|
|
443
|
+
|
|
444
|
+
doubles = wrapture.iterator()
|
|
445
|
+
doubles.on_item.transforms_item(lambda item: 2 * item)
|
|
446
|
+
|
|
447
|
+
rows = wrapture.binding(Repo, "rows")
|
|
448
|
+
rows.on_call.transforms_result(doubles)
|
|
449
|
+
"""
|
|
450
|
+
|
|
451
|
+
return IteratorProxy()
|
wrapture/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""An opt-in pytest plugin for suites that use wrapture.
|
|
2
|
+
|
|
3
|
+
Deliberately not auto-loaded: activate it from a conftest.py
|
|
4
|
+
|
|
5
|
+
pytest_plugins = ["wrapture.pytest_plugin"]
|
|
6
|
+
|
|
7
|
+
or on the command line with `-p wrapture.pytest_plugin`. Activated, it
|
|
8
|
+
provides:
|
|
9
|
+
|
|
10
|
+
- A sweep after every test for bindings the test applied and did not
|
|
11
|
+
remove. Leaked bindings are removed, so later tests are not poisoned,
|
|
12
|
+
and the test fails naming them. Bindings applied by wider-scoped
|
|
13
|
+
fixtures before the test began are not the test's leaks and are left
|
|
14
|
+
alone.
|
|
15
|
+
- A `tape` fixture: a recording scope spanning the test, yielding the
|
|
16
|
+
Tape. When a test that used it fails, the tape's call tree is
|
|
17
|
+
attached to the failure report.
|
|
18
|
+
- Assertion output for comparisons involving an EventLog, printing the
|
|
19
|
+
events, with the discarded events of an over-narrowed filter chain
|
|
20
|
+
shown the way the assert_* methods show them.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from collections.abc import Generator, Iterator
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
import pytest
|
|
29
|
+
|
|
30
|
+
from .bindings import _applied_bindings
|
|
31
|
+
from .eventlogs import EventLog
|
|
32
|
+
from .timeline import Tape, timeline
|
|
33
|
+
|
|
34
|
+
_TAPE_KEY = pytest.StashKey[Tape]()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.fixture
|
|
38
|
+
def tape(request: pytest.FixtureRequest) -> Iterator[Tape]:
|
|
39
|
+
"""A recording scope spanning the whole test.
|
|
40
|
+
|
|
41
|
+
Bindings the test applies, however it applies them, record onto
|
|
42
|
+
it. Because this timeline is given no bindings, it applies none and
|
|
43
|
+
verifies no declared expectations; use timeline(...) inside the
|
|
44
|
+
test when those are wanted. When a test that used this fixture
|
|
45
|
+
fails, the tape's tree is attached to the failure report.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
with timeline() as recording:
|
|
49
|
+
request.node.stash[_TAPE_KEY] = recording
|
|
50
|
+
yield recording
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.fixture(autouse=True)
|
|
54
|
+
def _wrapture_leak_sweep() -> Iterator[None]:
|
|
55
|
+
# Snapshot what was already applied before the test's own fixtures
|
|
56
|
+
# and body ran: those belong to wider scopes and are not this
|
|
57
|
+
# test's leaks. This fixture is autouse and function scoped, so its
|
|
58
|
+
# teardown runs after the test's own fixtures have torn down.
|
|
59
|
+
|
|
60
|
+
before = {bnd for bnd in _applied_bindings if bnd.applied}
|
|
61
|
+
|
|
62
|
+
yield
|
|
63
|
+
|
|
64
|
+
leaked = [bnd for bnd in _applied_bindings if bnd.applied and bnd not in before]
|
|
65
|
+
|
|
66
|
+
if leaked:
|
|
67
|
+
labels = ", ".join(sorted(bnd.label for bnd in leaked))
|
|
68
|
+
|
|
69
|
+
for bnd in leaked:
|
|
70
|
+
bnd.remove()
|
|
71
|
+
|
|
72
|
+
pytest.fail(
|
|
73
|
+
f"wrapture: bindings left applied after the test: {labels}"
|
|
74
|
+
f" (removed now, so later tests are unaffected)",
|
|
75
|
+
pytrace=False,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.hookimpl(wrapper=True)
|
|
80
|
+
def pytest_runtest_makereport(
|
|
81
|
+
item: pytest.Item, call: pytest.CallInfo[None]
|
|
82
|
+
) -> Generator[None, Any, Any]:
|
|
83
|
+
report = yield
|
|
84
|
+
|
|
85
|
+
# Attach the tape's tree to a failing test that used the tape
|
|
86
|
+
# fixture, so the failure output shows what actually ran.
|
|
87
|
+
|
|
88
|
+
if (
|
|
89
|
+
isinstance(report, pytest.TestReport)
|
|
90
|
+
and report.when == "call"
|
|
91
|
+
and report.failed
|
|
92
|
+
):
|
|
93
|
+
recording = item.stash.get(_TAPE_KEY, None)
|
|
94
|
+
if recording is not None and recording.all:
|
|
95
|
+
report.sections.append(("wrapture tape", recording.tree()))
|
|
96
|
+
|
|
97
|
+
return report
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def pytest_assertrepr_compare(
|
|
101
|
+
config: pytest.Config, op: str, left: Any, right: Any
|
|
102
|
+
) -> list[str] | None:
|
|
103
|
+
"""Explain comparisons involving an EventLog with the events shown."""
|
|
104
|
+
|
|
105
|
+
if not isinstance(left, EventLog) and not isinstance(right, EventLog):
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
def shorthand(value: Any) -> str:
|
|
109
|
+
if isinstance(value, EventLog):
|
|
110
|
+
return f"<EventLog {value.label}: {value.count} event(s)>"
|
|
111
|
+
return repr(value)
|
|
112
|
+
|
|
113
|
+
lines = [f"{shorthand(left)} {op} {shorthand(right)}"]
|
|
114
|
+
|
|
115
|
+
for side in (left, right):
|
|
116
|
+
if isinstance(side, EventLog):
|
|
117
|
+
lines.extend(side._describe())
|
|
118
|
+
|
|
119
|
+
return lines
|
wrapture/stacks.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Stack capture: recording how control physically reached a call.
|
|
2
|
+
|
|
3
|
+
The tape's parent and children links give the logical path between
|
|
4
|
+
observed points; they say nothing about the unobserved frames in
|
|
5
|
+
between. Stack capture answers "how did control actually get here",
|
|
6
|
+
priced per binding: no capture by default, just the immediate caller
|
|
7
|
+
for a few hundred nanoseconds, a fixed number of frames, or the full
|
|
8
|
+
stack when the whole route matters.
|
|
9
|
+
|
|
10
|
+
Captured stacks are interned. Stacks repeat almost perfectly (a
|
|
11
|
+
thousand captures at one call site typically produce one unique stack),
|
|
12
|
+
so each event stores a small integer id and the unique frame tuples
|
|
13
|
+
live in one side table. Frames are extracted to plain tuples
|
|
14
|
+
immediately and frame objects are never retained, since a held frame
|
|
15
|
+
keeps every local variable in it alive.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
import threading
|
|
23
|
+
import types
|
|
24
|
+
from typing import Final, NamedTuple
|
|
25
|
+
|
|
26
|
+
import wrapt
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class StackFrame(NamedTuple):
|
|
30
|
+
"""One captured frame: where in the source control was."""
|
|
31
|
+
|
|
32
|
+
filename: str
|
|
33
|
+
lineno: int
|
|
34
|
+
function: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# The stack= argument to binding(): capture just the calling frame, or
|
|
38
|
+
# every frame. Any other positive integer captures that many frames.
|
|
39
|
+
|
|
40
|
+
caller: Final[int] = 1
|
|
41
|
+
full: Final[int] = sys.maxsize
|
|
42
|
+
|
|
43
|
+
# Frames of the observation machinery itself are elided, so a captured
|
|
44
|
+
# stack starts at the code under observation.
|
|
45
|
+
|
|
46
|
+
_ELIDED = (
|
|
47
|
+
os.path.dirname(os.path.abspath(__file__)),
|
|
48
|
+
os.path.dirname(os.path.abspath(wrapt.__file__)),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
_lock = threading.Lock()
|
|
52
|
+
_ids: dict[tuple[StackFrame, ...], int] = {}
|
|
53
|
+
_stacks: list[tuple[StackFrame, ...]] = []
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def stack_frames(stack_id: int) -> tuple[StackFrame, ...]:
|
|
57
|
+
"""The frames behind an event's interned stack id, innermost first."""
|
|
58
|
+
|
|
59
|
+
with _lock:
|
|
60
|
+
return _stacks[stack_id]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _intern(frames: tuple[StackFrame, ...]) -> int:
|
|
64
|
+
with _lock:
|
|
65
|
+
found = _ids.get(frames)
|
|
66
|
+
|
|
67
|
+
if found is None:
|
|
68
|
+
found = len(_stacks)
|
|
69
|
+
_ids[frames] = found
|
|
70
|
+
_stacks.append(frames)
|
|
71
|
+
|
|
72
|
+
return found
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _capture(depth: int) -> int:
|
|
76
|
+
frames: list[StackFrame] = []
|
|
77
|
+
frame: types.FrameType | None = sys._getframe(1)
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
while frame is not None and len(frames) < depth:
|
|
81
|
+
code = frame.f_code
|
|
82
|
+
filename = code.co_filename
|
|
83
|
+
|
|
84
|
+
if not filename.startswith(_ELIDED):
|
|
85
|
+
frames.append(StackFrame(filename, frame.f_lineno, code.co_qualname))
|
|
86
|
+
|
|
87
|
+
frame = frame.f_back
|
|
88
|
+
finally:
|
|
89
|
+
# Never retain a frame object past the walk.
|
|
90
|
+
|
|
91
|
+
del frame
|
|
92
|
+
|
|
93
|
+
return _intern(tuple(frames))
|