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/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
+ """