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/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))