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/capture.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Capture policies: how much of a call's values recording stores.
|
|
2
|
+
|
|
3
|
+
Recording by reference is free but can lie retroactively: mutate a list
|
|
4
|
+
after the call and the tape shows the mutated list. Copying everything
|
|
5
|
+
is safe but costly, and calling repr() on arbitrary values can itself
|
|
6
|
+
have side effects. So capture is a policy, chosen per binding or
|
|
7
|
+
declared by the sink consuming the events.
|
|
8
|
+
|
|
9
|
+
A policy is either one of the levels below, ordered by cost, or a
|
|
10
|
+
callable fn(name, value) -> stored applied to each captured value:
|
|
11
|
+
|
|
12
|
+
NONE no arguments, no result; skips signature binding
|
|
13
|
+
TYPES type names only; never calls user code
|
|
14
|
+
REFERENCE store references (what unittest.mock does); the default
|
|
15
|
+
SUMMARY bounded repr; survives locks and sockets, retains nothing
|
|
16
|
+
SNAPSHOT deepcopy; highest fidelity, falls back where it raises
|
|
17
|
+
|
|
18
|
+
The safe levels are the ones that call no user code: NONE, TYPES and
|
|
19
|
+
REFERENCE cannot trigger anything, while SUMMARY and SNAPSHOT execute
|
|
20
|
+
methods on the values being captured, which may be slow, may raise, and
|
|
21
|
+
may have effects. That is why REFERENCE is the default.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import copy
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
NONE = 0
|
|
31
|
+
TYPES = 1
|
|
32
|
+
REFERENCE = 2
|
|
33
|
+
SUMMARY = 3
|
|
34
|
+
SNAPSHOT = 4
|
|
35
|
+
|
|
36
|
+
CapturePolicy = int | Callable[[str | None, Any], Any]
|
|
37
|
+
|
|
38
|
+
_ATOMIC = (bool, int, float, complex, type(None))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def type_name(value: Any) -> str:
|
|
42
|
+
"""The safest possible capture: a type name, calling no user code.
|
|
43
|
+
|
|
44
|
+
repr() can have side effects (a lazy ORM object may issue a query),
|
|
45
|
+
so tracing with SUMMARY can cause the very behaviour being traced.
|
|
46
|
+
TYPES is immune because it touches nothing but the class.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
return f"<{type(value).__name__}>"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def summarize(value: Any, *, limit: int = 200, items: int = 10) -> Any:
|
|
53
|
+
"""Bounded repr. Never raises, never retains the original.
|
|
54
|
+
|
|
55
|
+
Type-aware for the common containers, because a naive
|
|
56
|
+
repr(value)[:limit] materialises the whole repr before truncating:
|
|
57
|
+
a 5MB repr costs 5MB and milliseconds even though the output is 200
|
|
58
|
+
bytes. Handling str, bytes and the container types structurally
|
|
59
|
+
bounds the work as well as the result.
|
|
60
|
+
|
|
61
|
+
Unavoidably, the fallback for unknown types calls repr(), which is
|
|
62
|
+
user code: it may be slow and may have side effects. Use TYPES where
|
|
63
|
+
that matters.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
if isinstance(value, _ATOMIC):
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
if isinstance(value, (str, bytes)):
|
|
70
|
+
if len(value) > limit:
|
|
71
|
+
if isinstance(value, bytes):
|
|
72
|
+
return value[:limit] + b"..."
|
|
73
|
+
return value[:limit] + f"...+{len(value) - limit}"
|
|
74
|
+
return value
|
|
75
|
+
|
|
76
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
77
|
+
kind = type(value).__name__
|
|
78
|
+
shown = [
|
|
79
|
+
summarize(v, limit=limit // 4, items=items) for v in list(value)[:items]
|
|
80
|
+
]
|
|
81
|
+
more = f", +{len(value) - items}" if len(value) > items else ""
|
|
82
|
+
return f"<{kind} {shown!r}{more}>"
|
|
83
|
+
|
|
84
|
+
if isinstance(value, dict):
|
|
85
|
+
shown_items = {
|
|
86
|
+
k: summarize(v, limit=limit // 4, items=items)
|
|
87
|
+
for k, v in list(value.items())[:items]
|
|
88
|
+
}
|
|
89
|
+
more = f", +{len(value) - items}" if len(value) > items else ""
|
|
90
|
+
return f"<dict {shown_items!r}{more}>"
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
text = repr(value)
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
return f"<unreprable {type(value).__name__}: {type(exc).__name__}>"
|
|
96
|
+
|
|
97
|
+
if len(text) > limit:
|
|
98
|
+
return text[:limit] + f"...+{len(text) - limit}"
|
|
99
|
+
return text
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def redact(
|
|
103
|
+
*names: str, level: CapturePolicy = REFERENCE, marker: str = "<redacted>"
|
|
104
|
+
) -> CapturePolicy:
|
|
105
|
+
"""A capture policy that replaces named parameters with a marker.
|
|
106
|
+
|
|
107
|
+
binding(Gateway, "charge", capture_args=redact("card_number"))
|
|
108
|
+
|
|
109
|
+
Matching is by parameter name against the signature-normalized
|
|
110
|
+
arguments, so it works whether the caller passed the value
|
|
111
|
+
positionally or by keyword. Everything not named is captured at
|
|
112
|
+
`level`.
|
|
113
|
+
|
|
114
|
+
Two limits: results have no parameter name, so a bare redact() does
|
|
115
|
+
not touch them (pair it with capture_result=NONE when the secret
|
|
116
|
+
comes back out); and names are top-level parameters only, so a
|
|
117
|
+
secret nested inside a dict argument is not found. Any custom
|
|
118
|
+
fn(name, value) callable handles those cases.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
wanted = set(names)
|
|
122
|
+
|
|
123
|
+
def policy(name: str | None, value: Any) -> Any:
|
|
124
|
+
if name in wanted:
|
|
125
|
+
return marker
|
|
126
|
+
return _capture_value(level, name, value)
|
|
127
|
+
|
|
128
|
+
policy.level = _level_of(level) # type: ignore[attr-defined]
|
|
129
|
+
return policy
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _level_of(policy: CapturePolicy) -> int:
|
|
133
|
+
# A policy is an int level or a callable carrying one; a callable
|
|
134
|
+
# without a declared level is assumed to want normalized arguments.
|
|
135
|
+
|
|
136
|
+
if callable(policy):
|
|
137
|
+
return int(getattr(policy, "level", REFERENCE))
|
|
138
|
+
return policy
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _capture_value(policy: CapturePolicy, name: str | None, value: Any) -> Any:
|
|
142
|
+
if callable(policy):
|
|
143
|
+
return policy(name, value)
|
|
144
|
+
return _apply_capture(policy, value)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _apply_capture(level: int, value: Any) -> Any:
|
|
148
|
+
if level == TYPES:
|
|
149
|
+
return type_name(value)
|
|
150
|
+
|
|
151
|
+
if level >= SNAPSHOT:
|
|
152
|
+
# deepcopy fails on locks, sockets, file handles and connections,
|
|
153
|
+
# which are common arguments. Failing the call under test because
|
|
154
|
+
# the recorder could not copy an argument would be indefensible,
|
|
155
|
+
# so fall back to the bounded repr instead.
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
return copy.deepcopy(value)
|
|
159
|
+
except Exception:
|
|
160
|
+
return summarize(value)
|
|
161
|
+
|
|
162
|
+
if level == SUMMARY:
|
|
163
|
+
return summarize(value)
|
|
164
|
+
|
|
165
|
+
return value
|
wrapture/eventlogs.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""The filterable view over recorded events.
|
|
2
|
+
|
|
3
|
+
One naming rule, without exception, on every object in this package: a
|
|
4
|
+
method whose name starts with assert_ raises on failure; everything else
|
|
5
|
+
returns data. The filters here narrow and return a new log, never raise,
|
|
6
|
+
so a mismatched filter yields an empty log rather than an error. Each
|
|
7
|
+
narrowed log remembers what it was filtered from, so failure output can
|
|
8
|
+
show the events a filter discarded instead of a bare empty result.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable, Iterator
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from wrapt import MISSING
|
|
17
|
+
|
|
18
|
+
from .events import Event
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EventLog:
|
|
22
|
+
"""An immutable, filterable view over recorded events.
|
|
23
|
+
|
|
24
|
+
Filters return a narrowed EventLog and never raise. The data
|
|
25
|
+
accessors make a log usable directly in a bare assert: an empty log
|
|
26
|
+
is falsey, and repr() prints the events it holds.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
label: str,
|
|
32
|
+
events: list[Event],
|
|
33
|
+
*,
|
|
34
|
+
filtered_from: EventLog | None = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
self._label = label
|
|
37
|
+
self._events = list(events)
|
|
38
|
+
self._filtered_from = filtered_from
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def label(self) -> str:
|
|
42
|
+
"""The log's provenance: the binding label plus one bracketed
|
|
43
|
+
segment per filter applied."""
|
|
44
|
+
|
|
45
|
+
return self._label
|
|
46
|
+
|
|
47
|
+
def _narrow(self, suffix: str, keep: Callable[[Event], bool]) -> EventLog:
|
|
48
|
+
kept = [event for event in self._events if keep(event)]
|
|
49
|
+
return EventLog(f"{self._label}{suffix}", kept, filtered_from=self)
|
|
50
|
+
|
|
51
|
+
# -- filters: return a narrowed log, never raise -------------------------
|
|
52
|
+
|
|
53
|
+
def of_kind(self, *kinds: str) -> EventLog:
|
|
54
|
+
"""Events of the given kind or kinds: "call", "get", "set" or
|
|
55
|
+
"delete"."""
|
|
56
|
+
|
|
57
|
+
wanted = set(kinds)
|
|
58
|
+
return self._narrow(f"[{','.join(kinds)}]", lambda event: event.kind in wanted)
|
|
59
|
+
|
|
60
|
+
def matching(self, predicate: Callable[[Event], bool]) -> EventLog:
|
|
61
|
+
"""Events for which the predicate returns true."""
|
|
62
|
+
|
|
63
|
+
name = getattr(predicate, "__name__", "predicate")
|
|
64
|
+
return self._narrow(f"[matching={name}]", lambda event: bool(predicate(event)))
|
|
65
|
+
|
|
66
|
+
def raising(self, *exceptions: type[BaseException]) -> EventLog:
|
|
67
|
+
"""Events that raised one of the given exception types, or, with
|
|
68
|
+
no arguments, events that raised anything at all."""
|
|
69
|
+
|
|
70
|
+
if not exceptions:
|
|
71
|
+
return self._narrow("[raising]", lambda event: event.exception is not None)
|
|
72
|
+
|
|
73
|
+
names = ",".join(exception.__name__ for exception in exceptions)
|
|
74
|
+
return self._narrow(
|
|
75
|
+
f"[raising={names}]",
|
|
76
|
+
lambda event: isinstance(event.exception, exceptions),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def with_args(self, **arguments: Any) -> EventLog:
|
|
80
|
+
"""Call events whose normalized arguments include every given
|
|
81
|
+
name and value.
|
|
82
|
+
|
|
83
|
+
Matching is by parameter name against the signature-normalized
|
|
84
|
+
arguments with defaults applied, so with_args(currency="USD")
|
|
85
|
+
matches a call that never spelled the default out. Events with
|
|
86
|
+
no normalized arguments, attribute events included, never match.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
suffix = "[" + ", ".join(f"{k}={v!r}" for k, v in arguments.items()) + "]"
|
|
90
|
+
|
|
91
|
+
def keep(event: Event) -> bool:
|
|
92
|
+
if event.arguments is None:
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
return all(
|
|
96
|
+
name in event.arguments and event.arguments[name] == value
|
|
97
|
+
for name, value in arguments.items()
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return self._narrow(suffix, keep)
|
|
101
|
+
|
|
102
|
+
def returning(self, value: Any) -> EventLog:
|
|
103
|
+
"""Events whose recorded outcome equals the value: the return
|
|
104
|
+
value of a call, or the value a read produced. Events that
|
|
105
|
+
raised have no outcome and never match."""
|
|
106
|
+
|
|
107
|
+
return self._narrow(
|
|
108
|
+
f"[returning={value!r}]",
|
|
109
|
+
lambda event: event.result is not MISSING and event.result == value,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def with_value(self, value: Any) -> EventLog:
|
|
113
|
+
"""Set events that wrote the value: the write-side counterpart
|
|
114
|
+
to with_args()."""
|
|
115
|
+
|
|
116
|
+
return self._narrow(
|
|
117
|
+
f"[value={value!r}]",
|
|
118
|
+
lambda event: event.value is not MISSING and event.value == value,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def injected(self, want: bool = True) -> EventLog:
|
|
122
|
+
"""Events whose outcome was supplied by returns(), raises() or
|
|
123
|
+
rejects(); with want=False, events whose outcome was real."""
|
|
124
|
+
|
|
125
|
+
suffix = "[injected]" if want else "[injected=False]"
|
|
126
|
+
return self._narrow(suffix, lambda event: event.injected is want)
|
|
127
|
+
|
|
128
|
+
# -- assertions: raise on failure, return self so they chain -------------
|
|
129
|
+
|
|
130
|
+
def assert_never(self) -> EventLog:
|
|
131
|
+
"""Assert the log holds no events."""
|
|
132
|
+
|
|
133
|
+
if self._events:
|
|
134
|
+
raise AssertionError(self._failure("expected no events"))
|
|
135
|
+
return self
|
|
136
|
+
|
|
137
|
+
def assert_any(self) -> EventLog:
|
|
138
|
+
"""Assert the log holds at least one event."""
|
|
139
|
+
|
|
140
|
+
if not self._events:
|
|
141
|
+
raise AssertionError(self._failure("expected at least 1 event(s)"))
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
def assert_once(self) -> EventLog:
|
|
145
|
+
"""Assert the log holds exactly one event."""
|
|
146
|
+
|
|
147
|
+
return self.assert_times(1)
|
|
148
|
+
|
|
149
|
+
def assert_times(self, count: int) -> EventLog:
|
|
150
|
+
"""Assert the log holds exactly `count` events."""
|
|
151
|
+
|
|
152
|
+
if len(self._events) != count:
|
|
153
|
+
raise AssertionError(self._failure(f"expected exactly {count} event(s)"))
|
|
154
|
+
return self
|
|
155
|
+
|
|
156
|
+
def assert_at_least(self, count: int) -> EventLog:
|
|
157
|
+
"""Assert the log holds at least `count` events."""
|
|
158
|
+
|
|
159
|
+
if len(self._events) < count:
|
|
160
|
+
raise AssertionError(self._failure(f"expected at least {count} event(s)"))
|
|
161
|
+
return self
|
|
162
|
+
|
|
163
|
+
def assert_at_most(self, count: int) -> EventLog:
|
|
164
|
+
"""Assert the log holds at most `count` events."""
|
|
165
|
+
|
|
166
|
+
if len(self._events) > count:
|
|
167
|
+
raise AssertionError(self._failure(f"expected at most {count} event(s)"))
|
|
168
|
+
return self
|
|
169
|
+
|
|
170
|
+
def _describe(self) -> list[str]:
|
|
171
|
+
# The log's events, line by line. When the log is empty because
|
|
172
|
+
# a filter discarded everything, fall back to the nearest
|
|
173
|
+
# non-empty log in the filter chain, so filtering the wrong
|
|
174
|
+
# thing is visible rather than mysterious.
|
|
175
|
+
|
|
176
|
+
lines = repr(self).splitlines()
|
|
177
|
+
|
|
178
|
+
if not self._events:
|
|
179
|
+
ancestor = self._filtered_from
|
|
180
|
+
while ancestor is not None and not ancestor._events:
|
|
181
|
+
ancestor = ancestor._filtered_from
|
|
182
|
+
|
|
183
|
+
if ancestor is not None:
|
|
184
|
+
lines.append(" filtered from:")
|
|
185
|
+
lines.extend(f" {line}" for line in repr(ancestor).splitlines())
|
|
186
|
+
|
|
187
|
+
return lines
|
|
188
|
+
|
|
189
|
+
def _failure(self, expectation: str) -> str:
|
|
190
|
+
return "\n".join([f"{expectation}, got {len(self._events)}", *self._describe()])
|
|
191
|
+
|
|
192
|
+
# -- data ----------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def count(self) -> int:
|
|
196
|
+
"""How many events the log holds."""
|
|
197
|
+
|
|
198
|
+
return len(self._events)
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def first(self) -> Event:
|
|
202
|
+
"""The earliest event in the log."""
|
|
203
|
+
|
|
204
|
+
return self._events[0]
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def last(self) -> Event:
|
|
208
|
+
"""The latest event in the log."""
|
|
209
|
+
|
|
210
|
+
return self._events[-1]
|
|
211
|
+
|
|
212
|
+
def __len__(self) -> int:
|
|
213
|
+
return len(self._events)
|
|
214
|
+
|
|
215
|
+
def __bool__(self) -> bool:
|
|
216
|
+
return bool(self._events)
|
|
217
|
+
|
|
218
|
+
def __iter__(self) -> Iterator[Event]:
|
|
219
|
+
return iter(self._events)
|
|
220
|
+
|
|
221
|
+
def __getitem__(self, index: int) -> Event:
|
|
222
|
+
return self._events[index]
|
|
223
|
+
|
|
224
|
+
def __repr__(self) -> str:
|
|
225
|
+
lines = [f"<EventLog {self._label}: {len(self._events)} event(s)>"]
|
|
226
|
+
|
|
227
|
+
if self._events:
|
|
228
|
+
lines.extend(f" {event}" for event in self._events)
|
|
229
|
+
else:
|
|
230
|
+
lines.append(" (no events)")
|
|
231
|
+
|
|
232
|
+
return "\n".join(lines)
|
wrapture/events.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""The event record produced when a binding observes something happen.
|
|
2
|
+
|
|
3
|
+
One record type covers all four kinds of observation: a call to a wrapped
|
|
4
|
+
callable, and a read, write or delete of a wrapped attribute. The kinds
|
|
5
|
+
share the fields that describe where and when the event happened and how
|
|
6
|
+
events nest; each kind then populates the fields that make sense for it.
|
|
7
|
+
|
|
8
|
+
Nothing in this module records anything by itself. Events are created by
|
|
9
|
+
the recording machinery when a binding fires inside a timeline, and are
|
|
10
|
+
consumed through the event log and tape interfaces built on top of them.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import inspect
|
|
16
|
+
import weakref
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any, Literal
|
|
19
|
+
|
|
20
|
+
from wrapt import MISSING
|
|
21
|
+
|
|
22
|
+
from .capture import REFERENCE
|
|
23
|
+
|
|
24
|
+
EventKind = Literal["call", "get", "set", "delete"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(eq=False)
|
|
28
|
+
class Event:
|
|
29
|
+
"""One recorded occurrence at a binding.
|
|
30
|
+
|
|
31
|
+
The `kind` field says what happened: "call" for an invocation of a
|
|
32
|
+
wrapped callable, and "get", "set" or "delete" for attribute access.
|
|
33
|
+
Fields that were not observed hold the MISSING sentinel (for values,
|
|
34
|
+
so that a recorded None stays distinguishable) or None (for the
|
|
35
|
+
optional descriptive fields). Events compare by identity: two events
|
|
36
|
+
with identical fields are still two distinct occurrences.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
kind: EventKind
|
|
40
|
+
|
|
41
|
+
# Where it happened: path is the fully qualified location of what
|
|
42
|
+
# was bound, in module:path form with both halves dotted, so it
|
|
43
|
+
# stays meaningful after events leave the process. label is the
|
|
44
|
+
# friendly display name, from the binding's label.
|
|
45
|
+
|
|
46
|
+
path: str
|
|
47
|
+
label: str | None = None
|
|
48
|
+
instance: Any = None
|
|
49
|
+
|
|
50
|
+
# The Binding that recorded this event. Typed loosely because the
|
|
51
|
+
# bindings module builds on this one; excluded from repr() because
|
|
52
|
+
# the path already identifies the event.
|
|
53
|
+
|
|
54
|
+
binding: Any = field(default=None, repr=False)
|
|
55
|
+
|
|
56
|
+
# Position on the tape: allocation order, nesting depth, and the
|
|
57
|
+
# enclosing and enclosed events. Excluded from repr() because parent
|
|
58
|
+
# and children reference each other cyclically.
|
|
59
|
+
|
|
60
|
+
seq: int = 0
|
|
61
|
+
depth: int = 0
|
|
62
|
+
parent: Event | None = field(default=None, repr=False)
|
|
63
|
+
children: list[Event] = field(default_factory=list, repr=False)
|
|
64
|
+
|
|
65
|
+
started: float | None = None
|
|
66
|
+
duration: float | None = None
|
|
67
|
+
|
|
68
|
+
# Iteration, for a call that produced a generator: how many items it
|
|
69
|
+
# has yielded so far, and the accumulated time its body ran across
|
|
70
|
+
# resumptions. duration is then wall time from creation to close,
|
|
71
|
+
# which includes all the consumer's time between yields, so the two
|
|
72
|
+
# answer different questions.
|
|
73
|
+
|
|
74
|
+
items: int | None = None
|
|
75
|
+
body_duration: float | None = None
|
|
76
|
+
|
|
77
|
+
# Outcome. For a call this is the return value or the exception it
|
|
78
|
+
# raised; for a get it is the value read, so the same accessors and
|
|
79
|
+
# filters work across calls and reads.
|
|
80
|
+
|
|
81
|
+
result: Any = MISSING
|
|
82
|
+
exception: BaseException | None = None
|
|
83
|
+
|
|
84
|
+
# kind == "call": the arguments as sent, the signature-normalized
|
|
85
|
+
# form with defaults applied, and the (args, kwargs) actually passed
|
|
86
|
+
# on when behaviour transformed them.
|
|
87
|
+
|
|
88
|
+
args: tuple[Any, ...] | None = None
|
|
89
|
+
kwargs: dict[str, Any] | None = None
|
|
90
|
+
arguments: dict[str, Any] | None = None
|
|
91
|
+
forwarded: tuple[tuple[Any, ...], dict[str, Any]] | None = None
|
|
92
|
+
|
|
93
|
+
# kind == "set": the value written, and the prior value where it was
|
|
94
|
+
# cheaply available.
|
|
95
|
+
|
|
96
|
+
value: Any = MISSING
|
|
97
|
+
previous: Any = MISSING
|
|
98
|
+
|
|
99
|
+
# Recording provenance: the capture level values were recorded at,
|
|
100
|
+
# whether the outcome was supplied by returns()/raises() rather than
|
|
101
|
+
# produced by the real operation, the interned id of the captured
|
|
102
|
+
# call stack when the binding asked for one, and caller-supplied
|
|
103
|
+
# annotations merged in with annotate().
|
|
104
|
+
|
|
105
|
+
capture: int = REFERENCE
|
|
106
|
+
injected: bool = False
|
|
107
|
+
stack: int | None = None
|
|
108
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
109
|
+
|
|
110
|
+
def __str__(self) -> str:
|
|
111
|
+
# Display favours the friendly label; the path is there when no
|
|
112
|
+
# label was recorded, and for anything that needs the location
|
|
113
|
+
# rather than the name.
|
|
114
|
+
|
|
115
|
+
where = self.label or self.path
|
|
116
|
+
|
|
117
|
+
if self.kind == "call":
|
|
118
|
+
return f"{where}({self._format_arguments()})"
|
|
119
|
+
|
|
120
|
+
if self.kind == "get":
|
|
121
|
+
if self.result is not MISSING:
|
|
122
|
+
return f"get {where} -> {self.result!r}"
|
|
123
|
+
return f"get {where}"
|
|
124
|
+
|
|
125
|
+
if self.kind == "set":
|
|
126
|
+
return f"set {where} = {self.value!r}"
|
|
127
|
+
|
|
128
|
+
return f"delete {where}"
|
|
129
|
+
|
|
130
|
+
def _format_arguments(self) -> str:
|
|
131
|
+
# Prefer the normalized form so the display matches what filters
|
|
132
|
+
# and assertions compare against; fall back to the raw call shape
|
|
133
|
+
# when no signature was available.
|
|
134
|
+
|
|
135
|
+
if self.arguments is not None:
|
|
136
|
+
return ", ".join(f"{k}={v!r}" for k, v in self.arguments.items())
|
|
137
|
+
|
|
138
|
+
positional = [repr(a) for a in (self.args or ())]
|
|
139
|
+
keyword = [f"{k}={v!r}" for k, v in (self.kwargs or {}).items()]
|
|
140
|
+
return ", ".join(positional + keyword)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# Signature lookup is cached because inspect.signature costs microseconds
|
|
144
|
+
# per call and would dominate the recording path. The cache is keyed on
|
|
145
|
+
# the function itself via weak references, never on id(): ids are reused
|
|
146
|
+
# after garbage collection, and a collision would silently bind one
|
|
147
|
+
# function's arguments against another's signature.
|
|
148
|
+
|
|
149
|
+
_signature_cache: weakref.WeakKeyDictionary[Any, inspect.Signature | None] = (
|
|
150
|
+
weakref.WeakKeyDictionary()
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _signature(func: Any) -> inspect.Signature | None:
|
|
155
|
+
try:
|
|
156
|
+
return _signature_cache[func]
|
|
157
|
+
except KeyError:
|
|
158
|
+
pass
|
|
159
|
+
except TypeError:
|
|
160
|
+
# Unhashable or not weak-referenceable: compute every time.
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
return inspect.signature(func)
|
|
164
|
+
except (TypeError, ValueError):
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
signature = inspect.signature(func)
|
|
169
|
+
except (TypeError, ValueError):
|
|
170
|
+
signature = None
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
_signature_cache[func] = signature
|
|
174
|
+
except TypeError:
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
return signature
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def normalized_arguments(
|
|
181
|
+
func: Any, args: tuple[Any, ...], kwargs: dict[str, Any]
|
|
182
|
+
) -> dict[str, Any] | None:
|
|
183
|
+
"""Bind args and kwargs against func's signature, defaults applied.
|
|
184
|
+
|
|
185
|
+
Returns a dict mapping parameter names to values, so that f(1, 2)
|
|
186
|
+
and f(1, b=2) produce the same recorded arguments. Returns None when
|
|
187
|
+
no signature is available or the arguments do not fit it, in which
|
|
188
|
+
case the caller falls back to the raw call shape.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
signature = _signature(func)
|
|
192
|
+
if signature is None:
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
bound = signature.bind(*args, **kwargs)
|
|
197
|
+
except TypeError:
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
bound.apply_defaults()
|
|
201
|
+
return dict(bound.arguments)
|
wrapture/exceptions.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Exception types raised by wrapture."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AlreadyAppliedError(RuntimeError):
|
|
5
|
+
"""apply() was called on a binding whose wrapper is already applied."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class WrongModeError(AttributeError):
|
|
9
|
+
"""A behaviour namespace was accessed that does not apply to this
|
|
10
|
+
binding's mode.
|
|
11
|
+
|
|
12
|
+
Derives from AttributeError so that hasattr() can be used to probe
|
|
13
|
+
which namespaces a binding supports.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ExpectationNotMetError(AssertionError):
|
|
18
|
+
"""A declared expect_* expectation was not met when the timeline
|
|
19
|
+
verified it at exit.
|
|
20
|
+
|
|
21
|
+
Derives from AssertionError so test frameworks report it as a test
|
|
22
|
+
failure rather than an error.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RecordingGapWarning(RuntimeWarning):
|
|
27
|
+
"""An observed operation ran on a thread with no recording context
|
|
28
|
+
while a timeline was active elsewhere, so it is missing from that
|
|
29
|
+
timeline's tape. Behaviour still applied; only recording was lost.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NeverAppliedError(RuntimeError):
|
|
34
|
+
"""events was read on a binding that was never applied, so nothing
|
|
35
|
+
could possibly have been recorded for it."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class NotImplementedYetError(NotImplementedError):
|
|
39
|
+
"""A feature whose API shape is settled is not implemented yet."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DeferredTargetError(ValueError):
|
|
43
|
+
"""A string target used wrapt's trailing `?` deferred-patching syntax,
|
|
44
|
+
which is not supported. Import the module first and bind against it.
|
|
45
|
+
"""
|