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/timeline.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""The recording scope: the tape events land on, and how they find it.
|
|
2
|
+
|
|
3
|
+
Observed code calls its own methods normally; nothing threads a tape
|
|
4
|
+
through those calls. So when a wrapper fires it answers two questions
|
|
5
|
+
from ambient state: am I recording, and into what; and what call am I
|
|
6
|
+
nested inside. Both live in context variables, set when a timeline is
|
|
7
|
+
entered and restored on exit.
|
|
8
|
+
|
|
9
|
+
Context variables specifically, because a module-level global would be
|
|
10
|
+
shared by concurrent asyncio tasks, recording one task's calls as
|
|
11
|
+
children of another's, and a thread-local would fail the same way for
|
|
12
|
+
many tasks on one thread. Each task gets its own copy of the context,
|
|
13
|
+
so each records its own correctly nested tree.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import contextvars
|
|
19
|
+
import threading
|
|
20
|
+
from collections.abc import Iterable
|
|
21
|
+
from typing import Any, Protocol, runtime_checkable
|
|
22
|
+
|
|
23
|
+
from wrapt import MISSING
|
|
24
|
+
|
|
25
|
+
from .capture import NONE, REFERENCE, CapturePolicy, _capture_value, _level_of
|
|
26
|
+
from .eventlogs import EventLog
|
|
27
|
+
from .events import Event
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@runtime_checkable
|
|
31
|
+
class _Appliable(Protocol):
|
|
32
|
+
# Anything with the binding lifecycle: a Binding or a BindingGroup.
|
|
33
|
+
# Duck-typed rather than imported, because the bindings module is a
|
|
34
|
+
# consumer of this one and importing it here would be circular.
|
|
35
|
+
|
|
36
|
+
def apply(self) -> Any: ...
|
|
37
|
+
|
|
38
|
+
def remove(self) -> Any: ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Tape:
|
|
42
|
+
"""The ordered record of events for one timeline.
|
|
43
|
+
|
|
44
|
+
The tape assigns each event its sequence number as it is recorded,
|
|
45
|
+
so ordering assertions have a single authoritative order even when
|
|
46
|
+
events arrive from concurrently running tasks.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
# The capture levels this sink requires of bindings that follow the
|
|
50
|
+
# sink (design: the sink says what it needs). REFERENCE on both
|
|
51
|
+
# axes, because a test asserts within the scope, where references
|
|
52
|
+
# are accurate and cost nothing. A streaming sink would declare
|
|
53
|
+
# SUMMARY arguments and NONE results instead.
|
|
54
|
+
|
|
55
|
+
capture_args: CapturePolicy = REFERENCE
|
|
56
|
+
capture_result: CapturePolicy = REFERENCE
|
|
57
|
+
|
|
58
|
+
def __init__(self) -> None:
|
|
59
|
+
self._entries: list[Event] = []
|
|
60
|
+
self._lock = threading.Lock()
|
|
61
|
+
self._seq = 0
|
|
62
|
+
|
|
63
|
+
def record(self, event: Event) -> Event:
|
|
64
|
+
"""Assign the next sequence number to the event and append it.
|
|
65
|
+
|
|
66
|
+
Returns the event, which stays live: the recorder fills in the
|
|
67
|
+
outcome fields when the call completes.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
with self._lock:
|
|
71
|
+
self._seq += 1
|
|
72
|
+
event.seq = self._seq
|
|
73
|
+
self._entries.append(event)
|
|
74
|
+
|
|
75
|
+
return event
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def all(self) -> list[Event]:
|
|
79
|
+
"""Every recorded event, in sequence order."""
|
|
80
|
+
|
|
81
|
+
with self._lock:
|
|
82
|
+
return list(self._entries)
|
|
83
|
+
|
|
84
|
+
def for_binding(self, bnd: Any) -> EventLog:
|
|
85
|
+
"""A filterable view over this tape's events for one binding."""
|
|
86
|
+
|
|
87
|
+
with self._lock:
|
|
88
|
+
events = [event for event in self._entries if event.binding is bnd]
|
|
89
|
+
|
|
90
|
+
return EventLog(getattr(bnd, "label", repr(bnd)), events)
|
|
91
|
+
|
|
92
|
+
def roots(self) -> list[Event]:
|
|
93
|
+
"""The top-level events: those recorded with no observed caller."""
|
|
94
|
+
|
|
95
|
+
with self._lock:
|
|
96
|
+
return [event for event in self._entries if event.parent is None]
|
|
97
|
+
|
|
98
|
+
def tree(self) -> str:
|
|
99
|
+
"""The call graph as it actually ran, one event per line,
|
|
100
|
+
indented by nesting depth.
|
|
101
|
+
|
|
102
|
+
A completed event shows its result after `->`; one that raised
|
|
103
|
+
shows `!!` and the exception type; one still in progress shows
|
|
104
|
+
neither.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
lines: list[str] = []
|
|
108
|
+
|
|
109
|
+
def emit(event: Event) -> None:
|
|
110
|
+
injected = " (injected)" if event.injected else ""
|
|
111
|
+
|
|
112
|
+
if event.exception is not None:
|
|
113
|
+
marker = f" !! {type(event.exception).__name__}{injected}"
|
|
114
|
+
elif event.result is not MISSING:
|
|
115
|
+
marker = f" -> {event.result!r}{injected}"
|
|
116
|
+
else:
|
|
117
|
+
marker = ""
|
|
118
|
+
|
|
119
|
+
lines.append(" " * event.depth + str(event) + marker)
|
|
120
|
+
|
|
121
|
+
for child in event.children:
|
|
122
|
+
emit(child)
|
|
123
|
+
|
|
124
|
+
for root in self.roots():
|
|
125
|
+
emit(root)
|
|
126
|
+
|
|
127
|
+
return "\n".join(lines)
|
|
128
|
+
|
|
129
|
+
def assert_order(self, *bindings: Any) -> Tape:
|
|
130
|
+
"""Assert the bindings recorded events in the given order.
|
|
131
|
+
|
|
132
|
+
A subsequence check, not an exact match: other events may appear
|
|
133
|
+
before, between and after, and only the relative order of the
|
|
134
|
+
given bindings' events matters. Repeating a binding requires it
|
|
135
|
+
to have recorded that many times in order. Raises AssertionError
|
|
136
|
+
naming where the expectation stalled, with the actual timeline.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
with self._lock:
|
|
140
|
+
entries = list(self._entries)
|
|
141
|
+
|
|
142
|
+
position = 0
|
|
143
|
+
for event in entries:
|
|
144
|
+
if position < len(bindings) and event.binding is bindings[position]:
|
|
145
|
+
position += 1
|
|
146
|
+
|
|
147
|
+
if position != len(bindings):
|
|
148
|
+
stalled = getattr(bindings[position], "label", repr(bindings[position]))
|
|
149
|
+
actual = "\n".join(f" {event}" for event in entries) or " (no events)"
|
|
150
|
+
raise AssertionError(
|
|
151
|
+
f"expected order not satisfied; stalled waiting for"
|
|
152
|
+
f" {stalled} (position {position + 1} of {len(bindings)})\n"
|
|
153
|
+
f" actual timeline:\n{actual}"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return self
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# The ambient state. The tape variable doubles as the recording switch:
|
|
160
|
+
# None means no timeline is active and wrappers call straight through.
|
|
161
|
+
# The stack variable holds the events currently in progress, innermost
|
|
162
|
+
# last, and is the entire source of parent, depth and children.
|
|
163
|
+
|
|
164
|
+
_tape: contextvars.ContextVar[Tape | None] = contextvars.ContextVar(
|
|
165
|
+
"wrapture_tape", default=None
|
|
166
|
+
)
|
|
167
|
+
_stack: contextvars.ContextVar[tuple[Event, ...]] = contextvars.ContextVar(
|
|
168
|
+
"wrapture_stack", default=()
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# How many timelines are active process-wide, kept so a wrapper firing
|
|
172
|
+
# with no ambient tape can tell "nothing is recording anywhere" from
|
|
173
|
+
# "a timeline is running but this thread has no context". Threads start
|
|
174
|
+
# with a fresh context, so their calls otherwise vanish from the tape
|
|
175
|
+
# silently; see the known limitations page.
|
|
176
|
+
|
|
177
|
+
_active_lock = threading.Lock()
|
|
178
|
+
_active_count = 0
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _timeline_started() -> None:
|
|
182
|
+
global _active_count
|
|
183
|
+
|
|
184
|
+
with _active_lock:
|
|
185
|
+
_active_count += 1
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _timeline_finished() -> None:
|
|
189
|
+
global _active_count
|
|
190
|
+
|
|
191
|
+
with _active_lock:
|
|
192
|
+
_active_count -= 1
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _timelines_active() -> bool:
|
|
196
|
+
return _active_count > 0
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# The reentrancy guard. Set while the recording machinery itself runs, so
|
|
200
|
+
# an observed callable invoked from inside the recorder (rather than from
|
|
201
|
+
# the code under observation) does not record recursively without bound.
|
|
202
|
+
# Behaviour still applies on the guarded path: only recording is skipped,
|
|
203
|
+
# so a call the user stubbed out stays stubbed even when the recorder
|
|
204
|
+
# triggers it.
|
|
205
|
+
|
|
206
|
+
_in_recorder: contextvars.ContextVar[bool] = contextvars.ContextVar(
|
|
207
|
+
"wrapture_in_recorder", default=False
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _push(event: Event) -> contextvars.Token[tuple[Event, ...]]:
|
|
212
|
+
# Nest the event under whatever is currently in progress, then make
|
|
213
|
+
# it the innermost in-progress event. The returned token restores
|
|
214
|
+
# the previous stack in _pop, which must run in the same context.
|
|
215
|
+
|
|
216
|
+
stack = _stack.get()
|
|
217
|
+
parent = stack[-1] if stack else None
|
|
218
|
+
|
|
219
|
+
event.parent = parent
|
|
220
|
+
event.depth = len(stack)
|
|
221
|
+
if parent is not None:
|
|
222
|
+
parent.children.append(event)
|
|
223
|
+
|
|
224
|
+
return _stack.set(stack + (event,))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _pop(token: contextvars.Token[tuple[Event, ...]]) -> None:
|
|
228
|
+
_stack.reset(token)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _capture_result(event: Event, outcome: Any, policy: CapturePolicy) -> None:
|
|
232
|
+
# Result capture runs under the recorder guard: at SUMMARY and above
|
|
233
|
+
# it calls user code (repr, deepcopy), which must not record.
|
|
234
|
+
|
|
235
|
+
if _level_of(policy) <= NONE:
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
guard = _in_recorder.set(True)
|
|
239
|
+
try:
|
|
240
|
+
event.result = _capture_value(policy, None, outcome)
|
|
241
|
+
finally:
|
|
242
|
+
_in_recorder.reset(guard)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def current_event() -> Event | None:
|
|
246
|
+
"""The in-flight event, or None when nothing is being recorded.
|
|
247
|
+
|
|
248
|
+
The behaviour pipeline runs after its event is pushed, so this is
|
|
249
|
+
reachable from inside a decorates() handler, where it names the
|
|
250
|
+
event for the very call the handler is wrapping.
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
stack = _stack.get()
|
|
254
|
+
return stack[-1] if stack else None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def annotate(**data: Any) -> None:
|
|
258
|
+
"""Merge values into the in-flight event's data dict.
|
|
259
|
+
|
|
260
|
+
Annotation is targeted capture: the caller attaches what it knows a
|
|
261
|
+
generic policy cannot infer (a row count, a cache hit, an immutable
|
|
262
|
+
copy of a value that will be mutated). A silent no-op when nothing
|
|
263
|
+
is being recorded, so observed code can call it unconditionally.
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
event = current_event()
|
|
267
|
+
if event is not None:
|
|
268
|
+
event.data.update(data)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class Timeline:
|
|
272
|
+
"""A recording scope, created by timeline().
|
|
273
|
+
|
|
274
|
+
Entering sets the ambient tape and applies the bindings given at
|
|
275
|
+
creation, rolling back if any of them fails to apply. Exiting
|
|
276
|
+
removes them in reverse order and restores the previous ambient
|
|
277
|
+
state. The same timeline can be reused sequentially; its tape keeps
|
|
278
|
+
accumulating across uses.
|
|
279
|
+
"""
|
|
280
|
+
|
|
281
|
+
def __init__(self, appliables: list[_Appliable]) -> None:
|
|
282
|
+
self.tape = Tape()
|
|
283
|
+
|
|
284
|
+
self._appliables = appliables
|
|
285
|
+
self._applied: list[_Appliable] = []
|
|
286
|
+
self._tape_token: contextvars.Token[Tape | None] | None = None
|
|
287
|
+
self._stack_token: contextvars.Token[tuple[Event, ...]] | None = None
|
|
288
|
+
|
|
289
|
+
def __enter__(self) -> Tape:
|
|
290
|
+
if self._tape_token is not None:
|
|
291
|
+
raise RuntimeError("timeline is already active")
|
|
292
|
+
|
|
293
|
+
self._tape_token = _tape.set(self.tape)
|
|
294
|
+
self._stack_token = _stack.set(())
|
|
295
|
+
|
|
296
|
+
# Apply every binding, rolling the whole entry back if one
|
|
297
|
+
# fails, so a partially patched scope never survives.
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
for appliable in self._appliables:
|
|
301
|
+
appliable.apply()
|
|
302
|
+
self._applied.append(appliable)
|
|
303
|
+
except Exception:
|
|
304
|
+
for applied in reversed(self._applied):
|
|
305
|
+
applied.remove()
|
|
306
|
+
self._applied.clear()
|
|
307
|
+
|
|
308
|
+
self._restore()
|
|
309
|
+
raise
|
|
310
|
+
|
|
311
|
+
_timeline_started()
|
|
312
|
+
return self.tape
|
|
313
|
+
|
|
314
|
+
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
315
|
+
_timeline_finished()
|
|
316
|
+
|
|
317
|
+
for applied in reversed(self._applied):
|
|
318
|
+
applied.remove()
|
|
319
|
+
self._applied.clear()
|
|
320
|
+
|
|
321
|
+
self._restore()
|
|
322
|
+
|
|
323
|
+
# Verify declared expectations only when the block itself
|
|
324
|
+
# succeeded: raising here over an in-flight failure would bury
|
|
325
|
+
# the real cause.
|
|
326
|
+
|
|
327
|
+
if exc_type is None:
|
|
328
|
+
for appliable in self._appliables:
|
|
329
|
+
verify = getattr(appliable, "_verify", None)
|
|
330
|
+
if verify is not None:
|
|
331
|
+
verify(self.tape)
|
|
332
|
+
|
|
333
|
+
def _restore(self) -> None:
|
|
334
|
+
assert self._tape_token is not None
|
|
335
|
+
assert self._stack_token is not None
|
|
336
|
+
|
|
337
|
+
_tape.reset(self._tape_token)
|
|
338
|
+
_stack.reset(self._stack_token)
|
|
339
|
+
|
|
340
|
+
self._tape_token = None
|
|
341
|
+
self._stack_token = None
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def timeline(*bindings: _Appliable | Iterable[_Appliable]) -> Timeline:
|
|
345
|
+
"""Open a recording scope.
|
|
346
|
+
|
|
347
|
+
Bindings passed here are applied on entry and removed on exit: a
|
|
348
|
+
binding applied outside a timeline records nothing anyway, so the
|
|
349
|
+
recording scope and the useful patch lifetime are the same interval.
|
|
350
|
+
Accepts bindings, binding groups, or iterables of either; a group is
|
|
351
|
+
applied as a unit, keeping its own rollback behaviour. With no
|
|
352
|
+
arguments the scope only records, for bindings whose lifetime is
|
|
353
|
+
managed elsewhere.
|
|
354
|
+
"""
|
|
355
|
+
|
|
356
|
+
flattened: list[_Appliable] = []
|
|
357
|
+
|
|
358
|
+
for entry in bindings:
|
|
359
|
+
if isinstance(entry, _Appliable):
|
|
360
|
+
flattened.append(entry)
|
|
361
|
+
else:
|
|
362
|
+
flattened.extend(entry)
|
|
363
|
+
|
|
364
|
+
return Timeline(flattened)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wrapture
|
|
3
|
+
Version: 1.0.0.dev1
|
|
4
|
+
Summary: Library for attaching bindings to arbitrary Python call sites for monkey patching, testing, tracing and profiling.
|
|
5
|
+
Author-email: Graham Dumpleton <Graham.Dumpleton@gmail.com>
|
|
6
|
+
License-Expression: BSD-2-Clause
|
|
7
|
+
Project-URL: Homepage, https://github.com/GrahamDumpleton/wrapture
|
|
8
|
+
Project-URL: Documentation, https://wrapture.readthedocs.io
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/GrahamDumpleton/wrapture/issues/
|
|
10
|
+
Keywords: wrapper,monkey patching,tracing,testing,profiling
|
|
11
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: wrapt>=2.4.0rc4
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: mypy; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest; extra == "dev"
|
|
24
|
+
Requires-Dist: ruff; extra == "dev"
|
|
25
|
+
Requires-Dist: setuptools; extra == "dev"
|
|
26
|
+
Provides-Extra: docs
|
|
27
|
+
Requires-Dist: myst-parser; extra == "docs"
|
|
28
|
+
Requires-Dist: sphinx; extra == "docs"
|
|
29
|
+
Requires-Dist: sphinx-rtd-theme; extra == "docs"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# wrapture
|
|
33
|
+
|
|
34
|
+
**Trace assertions without instrumenting your code.**
|
|
35
|
+
|
|
36
|
+
[](https://github.com/GrahamDumpleton/wrapture/actions/workflows/build-test-release.yml)
|
|
37
|
+
[](https://wrapture.readthedocs.io)
|
|
38
|
+
|
|
39
|
+
wrapture (`wrapt` + `capture`) is a Python library for attaching bindings to
|
|
40
|
+
arbitrary call sites, without modifying the code being observed, and doing
|
|
41
|
+
something useful with what flows through them.
|
|
42
|
+
|
|
43
|
+
It is a sibling project to [wrapt](https://github.com/GrahamDumpleton/wrapt)
|
|
44
|
+
and [autowrapt](https://github.com/GrahamDumpleton/autowrapt), building on the
|
|
45
|
+
safe monkey-patching machinery wrapt provides.
|
|
46
|
+
|
|
47
|
+
> **Status: early development.** The monkey patching and unit testing layers
|
|
48
|
+
> are implemented; the tracing and profiling layers are designed but not
|
|
49
|
+
> built. Nothing is published to PyPI yet.
|
|
50
|
+
|
|
51
|
+
## Documentation
|
|
52
|
+
|
|
53
|
+
Full documentation is at [wrapture.readthedocs.io](https://wrapture.readthedocs.io).
|
|
54
|
+
Start with the [getting started](https://wrapture.readthedocs.io/en/latest/getting-started.html)
|
|
55
|
+
page: everything on it can be pasted into a Python interpreter. Coming
|
|
56
|
+
from `unittest.mock`? There is a
|
|
57
|
+
[comparison page](https://wrapture.readthedocs.io/en/latest/coming-from-mock.html)
|
|
58
|
+
mapping each mock idiom to its wrapture counterpart.
|
|
59
|
+
|
|
60
|
+
## Thirty seconds of it
|
|
61
|
+
|
|
62
|
+
None of the classes below import wrapture or know they are observed:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
place = wrapture.binding(OrderService, "place")
|
|
66
|
+
charge = wrapture.binding(Gateway, "charge")
|
|
67
|
+
record = wrapture.binding(Ledger, "record")
|
|
68
|
+
|
|
69
|
+
with wrapture.timeline(place, charge, record) as tape:
|
|
70
|
+
OrderService().place(500)
|
|
71
|
+
|
|
72
|
+
print(tape.tree())
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
OrderService.place(amount=500) -> {'id': 'ch_500', 'amount': 500}
|
|
77
|
+
Gateway.charge(amount=500, currency='USD') -> {'id': 'ch_500', 'amount': 500}
|
|
78
|
+
Ledger.record(entry={'id': 'ch_500', 'amount': 500}) -> 'led_ch_500'
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The same bindings intervene as well as observe: stub a result, inject a
|
|
82
|
+
failure, or transform one argument while the real code keeps running.
|
|
83
|
+
|
|
84
|
+
## What it does
|
|
85
|
+
|
|
86
|
+
One mechanism, four uses, in increasing order of machinery:
|
|
87
|
+
|
|
88
|
+
1. **Monkey patching.** A clean lifecycle and behaviour vocabulary over
|
|
89
|
+
wrapt's `wrap_object()`. Point at a method by name and stub it, fail it,
|
|
90
|
+
transform its arguments or result, or wrap it with a decorator, then
|
|
91
|
+
remove it again, with honest reporting if something else displaced the
|
|
92
|
+
patch in the meantime. Useful entirely on its own, with nothing else
|
|
93
|
+
switched on.
|
|
94
|
+
|
|
95
|
+
2. **Unit testing.** Observe and assert on how calls actually flowed through a
|
|
96
|
+
*real* call graph (nesting, ordering, arguments and return values) and
|
|
97
|
+
optionally intervene (stub, transform, fail-inject). Unlike a `Mock`,
|
|
98
|
+
which fabricates values and cannot see calls an object makes to itself,
|
|
99
|
+
wrapture watches the real code run. This makes it possible to test code
|
|
100
|
+
with no injectable seams at all, and to assert on what *didn't* happen on
|
|
101
|
+
an error path: inject a gateway timeout, then verify the ledger was not
|
|
102
|
+
written, the receipt was not sent, and the compensating refund was issued.
|
|
103
|
+
|
|
104
|
+
3. **Ad-hoc tracing.** Attach bindings to a running application, including
|
|
105
|
+
one you cannot modify or redeploy, and emit a structured, nested trace to
|
|
106
|
+
process or chart elsewhere. Name a handful of methods and a call tree
|
|
107
|
+
appears; no code changes required.
|
|
108
|
+
|
|
109
|
+
4. **Targeted profiling.** Use a binding as a *scope* within which CPython's
|
|
110
|
+
own profiling machinery is active, so you can profile one subsystem of a
|
|
111
|
+
live process instead of everything.
|
|
112
|
+
|
|
113
|
+
The distinction that matters: most tracing and profiling tools either need
|
|
114
|
+
the code to have been written with them in mind, or can only be switched on
|
|
115
|
+
for the whole program at once. wrapture needs neither: you point at a method
|
|
116
|
+
by name and a trace appears.
|
|
117
|
+
|
|
118
|
+
## Why
|
|
119
|
+
|
|
120
|
+
No single existing tool covers "point at arbitrary methods, get a structured
|
|
121
|
+
nested trace, assert on it or export it, in tests or in production":
|
|
122
|
+
|
|
123
|
+
- `unittest.mock` records a flat call list, with no nesting and no return
|
|
124
|
+
values, and a patched call returns a fabricated `MagicMock` rather than
|
|
125
|
+
running the real code.
|
|
126
|
+
- Span-assertion tools (`logfire.testing`, OpenTelemetry's
|
|
127
|
+
`InMemorySpanExporter`) require the code to already be instrumented.
|
|
128
|
+
- `sys.settrace` tools (`hunter`, `snoop`) give a firehose with no assertion
|
|
129
|
+
API.
|
|
130
|
+
- `cProfile` cannot scope to a subsystem in a live process, and APM agents
|
|
131
|
+
are all-or-nothing products rather than a toolkit.
|
|
132
|
+
|
|
133
|
+
wrapture fills that gap: a targeted call tree with normalized arguments and
|
|
134
|
+
return values, produced by naming the methods you care about, usable as a
|
|
135
|
+
testing assertion library, a tracing tool, or both at once.
|
|
136
|
+
|
|
137
|
+
## What it is not
|
|
138
|
+
|
|
139
|
+
- **Not a replacement for `unittest.mock`.** It complements mocking where
|
|
140
|
+
code has seams; it exists for the code that doesn't.
|
|
141
|
+
- **Not a sampling profiler.** `py-spy` and `austin` do that better and
|
|
142
|
+
without distortion.
|
|
143
|
+
- **Not a production APM.** It is a toolkit that APM-like things could be
|
|
144
|
+
built on.
|
|
145
|
+
- **Not an OpenTelemetry competitor.** It should emit to OTel, not replace
|
|
146
|
+
it.
|
|
147
|
+
|
|
148
|
+
## Requirements
|
|
149
|
+
|
|
150
|
+
- Python 3.12+
|
|
151
|
+
- [wrapt](https://github.com/GrahamDumpleton/wrapt) 2.4.0+
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
BSD 2-Clause. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
wrapture/__init__.py,sha256=wFXWftQjghULEMHW6UP5-ucFxl19fTdc1VQSxhUJ2y8,2168
|
|
2
|
+
wrapture/attributes.py,sha256=kcdKvsyXxPTlr8aiJRSuXlEyTxAoHBLS55iw2X731To,11622
|
|
3
|
+
wrapture/behaviours.py,sha256=I3iNUquVfcWVXKWvis9o7PUq1Zh-XCQdQoRG8AgsOjE,12894
|
|
4
|
+
wrapture/bindings.py,sha256=dk-W_Nl_b8m6O55L3LgS-jFyJ_LBNzb6edl3fMrcrpw,36595
|
|
5
|
+
wrapture/capture.py,sha256=JIgefS3ASrGos8s798Yr6FTss7rh6n01jPAMGm6iUWY,5689
|
|
6
|
+
wrapture/eventlogs.py,sha256=eTMtc5NizGPTwcsHfw4FrdzHXW2rHHMutJ4M7WXsFSU,7917
|
|
7
|
+
wrapture/events.py,sha256=borkvI-SdGGvhgOT9acRg2XkbnNga0Ikf5imfjkxFyY,6902
|
|
8
|
+
wrapture/exceptions.py,sha256=1WB2qLj3E-aXVOutipr9pMDWBm4_Qw0S8tkCABRaD5c,1415
|
|
9
|
+
wrapture/iterators.py,sha256=X5_WAJY9LsuTC1Bo5y2QYael8ZFZvLI8bgo5jJkTkaA,14021
|
|
10
|
+
wrapture/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
wrapture/pytest_plugin.py,sha256=yMDZHgFofNeGQP4USIPtjK4mKWxwVk5E0SSZ9pKIeTI,3859
|
|
12
|
+
wrapture/stacks.py,sha256=CcnCD4bTQbl-JDLms1Nx5FTJmjlkEJbmfGTVgO_Dxi0,2644
|
|
13
|
+
wrapture/timeline.py,sha256=DlCFhKx0qER1upqC5yvI0Y-7oBwHh3jQQ-qFp_bTZxs,12044
|
|
14
|
+
wrapture-1.0.0.dev1.dist-info/licenses/LICENSE,sha256=RPMyRnXwgrw9co5o6wY9G3DKlb-oBZ44tpiRnuYeec8,1299
|
|
15
|
+
wrapture-1.0.0.dev1.dist-info/METADATA,sha256=H_gsOD_sGyvAkr1QbrHke80vn1jBB8gv0m4TFdJ-U0Q,6699
|
|
16
|
+
wrapture-1.0.0.dev1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
17
|
+
wrapture-1.0.0.dev1.dist-info/top_level.txt,sha256=eUnd7dTkQgJ1f9rnewuv3zt-yCP-Qc36DMmRskb8AIU,9
|
|
18
|
+
wrapture-1.0.0.dev1.dist-info/RECORD,,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Copyright (c) 2026, Graham Dumpleton
|
|
2
|
+
All rights reserved.
|
|
3
|
+
|
|
4
|
+
Redistribution and use in source and binary forms, with or without
|
|
5
|
+
modification, are permitted provided that the following conditions are met:
|
|
6
|
+
|
|
7
|
+
* Redistributions of source code must retain the above copyright notice, this
|
|
8
|
+
list of conditions and the following disclaimer.
|
|
9
|
+
|
|
10
|
+
* Redistributions in binary form must reproduce the above copyright notice,
|
|
11
|
+
this list of conditions and the following disclaimer in the documentation
|
|
12
|
+
and/or other materials provided with the distribution.
|
|
13
|
+
|
|
14
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
15
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
16
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
17
|
+
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
|
18
|
+
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
19
|
+
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
20
|
+
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
21
|
+
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
22
|
+
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
23
|
+
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
24
|
+
POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
wrapture
|