wrapture 1.0.0.dev7__tar.gz → 1.0.0.dev8__tar.gz
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-1.0.0.dev7/src/wrapture.egg-info → wrapture-1.0.0.dev8}/PKG-INFO +7 -7
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/README.md +6 -6
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/__init__.py +11 -1
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/attributes.py +343 -64
- wrapture-1.0.0.dev8/src/wrapture/behaviours.py +899 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/bindings.py +1134 -137
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/eventlogs.py +23 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/events.py +14 -3
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/exceptions.py +10 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/export.py +1 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/observed.py +19 -9
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/sinks.py +2 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/timeline.py +111 -17
- wrapture-1.0.0.dev8/src/wrapture/values.py +163 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/windows.py +24 -2
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8/src/wrapture.egg-info}/PKG-INFO +7 -7
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture.egg-info/SOURCES.txt +7 -0
- wrapture-1.0.0.dev8/tests/test_async_stubs.py +450 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_attributes.py +4 -15
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_bindings.py +66 -5
- wrapture-1.0.0.dev8/tests/test_mapping.py +383 -0
- wrapture-1.0.0.dev8/tests/test_module_attributes.py +389 -0
- wrapture-1.0.0.dev8/tests/test_phases.py +918 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_recording.py +7 -5
- wrapture-1.0.0.dev8/tests/test_strict.py +228 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_timeline.py +205 -3
- wrapture-1.0.0.dev8/tests/test_values.py +535 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_windows.py +76 -0
- wrapture-1.0.0.dev7/src/wrapture/behaviours.py +0 -428
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/LICENSE +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/pyproject.toml +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/setup.cfg +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/__main__.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/asgi.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/bootstrap.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/capture.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/collectors.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/config.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/iterators.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/lifecycle.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/outputs.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/py.typed +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/pytest_plugin.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/scheduler.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/stacks.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/tools/__init__.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/tools/__main__.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/tools/convert.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/wsgi.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture.egg-info/dependency_links.txt +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture.egg-info/entry_points.txt +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture.egg-info/not-zip-safe +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture.egg-info/requires.txt +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture.egg-info/top_level.txt +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_asgi.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_attribute_events.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_behaviours.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_bootstrap.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_capture.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_collectors.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_config.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_eventlogs.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_events.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_export.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_generator_events.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_iterators.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_jsonlines.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_observed.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_outputs.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_predicates.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_pytest_plugin.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_runner.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_sink_library.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_sinks.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_stacks.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_targets.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_testing_patterns.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_threads.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_tools.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_version.py +0 -0
- {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_wsgi.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wrapture
|
|
3
|
-
Version: 1.0.0.
|
|
3
|
+
Version: 1.0.0.dev8
|
|
4
4
|
Summary: Monkey patch, test, and trace Python by attaching bindings to call sites, without modifying the code being observed. Built on wrapt.
|
|
5
5
|
Author-email: Graham Dumpleton <Graham.Dumpleton@gmail.com>
|
|
6
6
|
License-Expression: BSD-2-Clause
|
|
@@ -44,10 +44,10 @@ It is a sibling project to [wrapt](https://github.com/GrahamDumpleton/wrapt)
|
|
|
44
44
|
and [autowrapt](https://github.com/GrahamDumpleton/autowrapt), building on the
|
|
45
45
|
safe monkey-patching machinery wrapt provides.
|
|
46
46
|
|
|
47
|
-
> **Status:
|
|
48
|
-
>
|
|
49
|
-
>
|
|
50
|
-
>
|
|
47
|
+
> **Status: stabilising ahead of 1.0.0.** The feature set is complete for
|
|
48
|
+
> a first release and development previews are published to PyPI. The API
|
|
49
|
+
> is being exercised and tidied rather than extended, so small changes are
|
|
50
|
+
> still possible before 1.0.0.
|
|
51
51
|
|
|
52
52
|
## Installation
|
|
53
53
|
|
|
@@ -112,8 +112,8 @@ One mechanism, three uses, in increasing order of machinery:
|
|
|
112
112
|
|
|
113
113
|
2. **Unit testing.** Observe and assert on how calls actually flowed through a
|
|
114
114
|
*real* call graph (nesting, ordering, arguments and return values) and
|
|
115
|
-
optionally intervene (stub, transform, fail-inject). Unlike a
|
|
116
|
-
which fabricates values and cannot see calls an object makes to itself,
|
|
115
|
+
optionally intervene (stub, transform, fail-inject). Unlike a
|
|
116
|
+
`unittest.mock` `Mock`, which fabricates values and cannot see calls an object makes to itself,
|
|
117
117
|
wrapture watches the real code run. This makes it possible to test code
|
|
118
118
|
with no injectable seams at all, and to assert on what *didn't* happen on
|
|
119
119
|
an error path: inject a gateway timeout, then verify the ledger was not
|
|
@@ -13,10 +13,10 @@ It is a sibling project to [wrapt](https://github.com/GrahamDumpleton/wrapt)
|
|
|
13
13
|
and [autowrapt](https://github.com/GrahamDumpleton/autowrapt), building on the
|
|
14
14
|
safe monkey-patching machinery wrapt provides.
|
|
15
15
|
|
|
16
|
-
> **Status:
|
|
17
|
-
>
|
|
18
|
-
>
|
|
19
|
-
>
|
|
16
|
+
> **Status: stabilising ahead of 1.0.0.** The feature set is complete for
|
|
17
|
+
> a first release and development previews are published to PyPI. The API
|
|
18
|
+
> is being exercised and tidied rather than extended, so small changes are
|
|
19
|
+
> still possible before 1.0.0.
|
|
20
20
|
|
|
21
21
|
## Installation
|
|
22
22
|
|
|
@@ -81,8 +81,8 @@ One mechanism, three uses, in increasing order of machinery:
|
|
|
81
81
|
|
|
82
82
|
2. **Unit testing.** Observe and assert on how calls actually flowed through a
|
|
83
83
|
*real* call graph (nesting, ordering, arguments and return values) and
|
|
84
|
-
optionally intervene (stub, transform, fail-inject). Unlike a
|
|
85
|
-
which fabricates values and cannot see calls an object makes to itself,
|
|
84
|
+
optionally intervene (stub, transform, fail-inject). Unlike a
|
|
85
|
+
`unittest.mock` `Mock`, which fabricates values and cannot see calls an object makes to itself,
|
|
86
86
|
wrapture watches the real code run. This makes it possible to test code
|
|
87
87
|
with no injectable seams at all, and to assert on what *didn't* happen on
|
|
88
88
|
an error path: inject a gateway timeout, then verify the ledger was not
|
|
@@ -17,7 +17,7 @@ def _format_version(parts: tuple[str, ...]) -> str:
|
|
|
17
17
|
)
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
__version_info__ = ("1", "0", "0", "
|
|
20
|
+
__version_info__ = ("1", "0", "0", "dev8")
|
|
21
21
|
__version__ = _format_version(__version_info__)
|
|
22
22
|
|
|
23
23
|
from wrapt import MISSING, register_post_import_hook, when_imported
|
|
@@ -27,9 +27,13 @@ from .asgi import (
|
|
|
27
27
|
)
|
|
28
28
|
from .behaviours import (
|
|
29
29
|
CallBehaviour,
|
|
30
|
+
CallPhase,
|
|
30
31
|
DeleteBehaviour,
|
|
32
|
+
DeletePhase,
|
|
31
33
|
GetBehaviour,
|
|
34
|
+
GetPhase,
|
|
32
35
|
SetBehaviour,
|
|
36
|
+
SetPhase,
|
|
33
37
|
)
|
|
34
38
|
from .bindings import (
|
|
35
39
|
Binding,
|
|
@@ -69,6 +73,7 @@ from .exceptions import (
|
|
|
69
73
|
NeverAppliedError,
|
|
70
74
|
NotImplementedYetError,
|
|
71
75
|
RecordingGapWarning,
|
|
76
|
+
SequenceExhaustedError,
|
|
72
77
|
SinkErrorWarning,
|
|
73
78
|
WrongModeError,
|
|
74
79
|
)
|
|
@@ -144,12 +149,14 @@ __all__ = [
|
|
|
144
149
|
"Window",
|
|
145
150
|
"BindingGroup",
|
|
146
151
|
"CallBehaviour",
|
|
152
|
+
"CallPhase",
|
|
147
153
|
"Config",
|
|
148
154
|
"ConfigError",
|
|
149
155
|
"ConfigWarning",
|
|
150
156
|
"Counter",
|
|
151
157
|
"DeferredTargetError",
|
|
152
158
|
"DeleteBehaviour",
|
|
159
|
+
"DeletePhase",
|
|
153
160
|
"Depth",
|
|
154
161
|
"Event",
|
|
155
162
|
"EventLog",
|
|
@@ -157,6 +164,7 @@ __all__ = [
|
|
|
157
164
|
"Fanout",
|
|
158
165
|
"Filter",
|
|
159
166
|
"GetBehaviour",
|
|
167
|
+
"GetPhase",
|
|
160
168
|
"IteratorAbandonBehaviour",
|
|
161
169
|
"IteratorErrorBehaviour",
|
|
162
170
|
"IteratorFinishBehaviour",
|
|
@@ -171,7 +179,9 @@ __all__ = [
|
|
|
171
179
|
"Printer",
|
|
172
180
|
"RecordingGapWarning",
|
|
173
181
|
"Sample",
|
|
182
|
+
"SequenceExhaustedError",
|
|
174
183
|
"SetBehaviour",
|
|
184
|
+
"SetPhase",
|
|
175
185
|
"SetupEntry",
|
|
176
186
|
"Sink",
|
|
177
187
|
"SinkErrorWarning",
|
|
@@ -7,6 +7,13 @@ nothing was defined. Reads, writes and deletes hook the binding's
|
|
|
7
7
|
behaviour first and then perform the real operation, honouring a prior
|
|
8
8
|
descriptor's own logic beneath the interception.
|
|
9
9
|
|
|
10
|
+
A module owner works the same way one level up: the descriptor goes on
|
|
11
|
+
a per-module subclass of the module's type, assigned to the module's
|
|
12
|
+
`__class__` while any binding on the module is applied, and the value
|
|
13
|
+
itself stays in the module's `__dict__`. One such class serves every
|
|
14
|
+
binding on the module, and it is removed again when the last binding
|
|
15
|
+
is.
|
|
16
|
+
|
|
10
17
|
The descriptor derives from wrapt's BaseObjectProxy and wraps the prior
|
|
11
18
|
definition, so wrapt's unwrap_object() can traverse and splice the
|
|
12
19
|
wrapper chain, and two attribute bindings on one name compose rather
|
|
@@ -22,6 +29,7 @@ from __future__ import annotations
|
|
|
22
29
|
|
|
23
30
|
import inspect
|
|
24
31
|
import sys
|
|
32
|
+
import threading
|
|
25
33
|
import time
|
|
26
34
|
from collections.abc import Callable
|
|
27
35
|
from typing import TYPE_CHECKING, Any
|
|
@@ -29,9 +37,8 @@ from typing import TYPE_CHECKING, Any
|
|
|
29
37
|
import wrapt
|
|
30
38
|
from wrapt import MISSING, BaseObjectProxy, apply_patch
|
|
31
39
|
|
|
32
|
-
from .capture import _capture_value, _level_of
|
|
40
|
+
from .capture import REFERENCE, CapturePolicy, _capture_value, _level_of
|
|
33
41
|
from .events import Event, EventKind
|
|
34
|
-
from .exceptions import NotImplementedYetError
|
|
35
42
|
from .sinks import (
|
|
36
43
|
_active_sinks,
|
|
37
44
|
_in_recorder,
|
|
@@ -49,6 +56,7 @@ from .timeline import (
|
|
|
49
56
|
)
|
|
50
57
|
|
|
51
58
|
if TYPE_CHECKING:
|
|
59
|
+
from .behaviours import Phase
|
|
52
60
|
from .bindings import Binding
|
|
53
61
|
|
|
54
62
|
|
|
@@ -78,6 +86,19 @@ def _read(prior: Any, attribute: str, instance: Any, owner: Any) -> Any:
|
|
|
78
86
|
if prior is not MISSING:
|
|
79
87
|
return prior
|
|
80
88
|
|
|
89
|
+
# A module may supply names it does not define through a module-level
|
|
90
|
+
# __getattr__ (PEP 562); a missing_ok binding on such a name defers
|
|
91
|
+
# to it rather than hiding it.
|
|
92
|
+
|
|
93
|
+
if inspect.ismodule(instance):
|
|
94
|
+
fallback = instance.__dict__.get("__getattr__")
|
|
95
|
+
if fallback is not None:
|
|
96
|
+
return fallback(attribute)
|
|
97
|
+
|
|
98
|
+
raise AttributeError(
|
|
99
|
+
f"module {instance.__name__!r} has no attribute {attribute!r}"
|
|
100
|
+
)
|
|
101
|
+
|
|
81
102
|
raise AttributeError(
|
|
82
103
|
f"{type(instance).__name__!r} object has no attribute {attribute!r}"
|
|
83
104
|
)
|
|
@@ -112,13 +133,100 @@ def _delete(prior: Any, attribute: str, instance: Any) -> None:
|
|
|
112
133
|
) from None
|
|
113
134
|
|
|
114
135
|
|
|
136
|
+
def _build_event(
|
|
137
|
+
binding: Binding,
|
|
138
|
+
kind: EventKind,
|
|
139
|
+
instance: Any,
|
|
140
|
+
attribute: str,
|
|
141
|
+
policy: CapturePolicy,
|
|
142
|
+
value: Any,
|
|
143
|
+
phase: Phase | None,
|
|
144
|
+
) -> Event:
|
|
145
|
+
"""Construct the event for one attribute operation, under the
|
|
146
|
+
recorder guard so capture that runs user code does not record."""
|
|
147
|
+
|
|
148
|
+
guard = _in_recorder.set(True)
|
|
149
|
+
try:
|
|
150
|
+
event = Event(
|
|
151
|
+
kind,
|
|
152
|
+
binding._path,
|
|
153
|
+
label=binding._label,
|
|
154
|
+
instance=instance,
|
|
155
|
+
binding=binding,
|
|
156
|
+
capture=_level_of(policy),
|
|
157
|
+
injected=phase is not None and phase.injected,
|
|
158
|
+
phase=binding._phase_of(phase),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
if binding._stack_depth is not None:
|
|
162
|
+
event.stack = _capture_stack(binding._stack_depth)
|
|
163
|
+
|
|
164
|
+
if value is not MISSING:
|
|
165
|
+
event.value = _capture_value(policy, attribute, value)
|
|
166
|
+
|
|
167
|
+
# The prior value, when cheaply available: only what already
|
|
168
|
+
# sits in the instance dictionary. A prior held by a descriptor
|
|
169
|
+
# would take running user code to read, so it is not recorded.
|
|
170
|
+
|
|
171
|
+
if kind in ("set", "delete"):
|
|
172
|
+
previous = getattr(instance, "__dict__", {}).get(attribute, MISSING)
|
|
173
|
+
if previous is not MISSING:
|
|
174
|
+
event.previous = _capture_value(policy, attribute, previous)
|
|
175
|
+
|
|
176
|
+
finally:
|
|
177
|
+
_in_recorder.reset(guard)
|
|
178
|
+
|
|
179
|
+
return event
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _quiet(
|
|
183
|
+
binding: Binding,
|
|
184
|
+
kind: EventKind,
|
|
185
|
+
instance: Any,
|
|
186
|
+
attribute: str,
|
|
187
|
+
operate: Callable[[Event | None], Any],
|
|
188
|
+
value: Any,
|
|
189
|
+
slot: list[Phase | None],
|
|
190
|
+
) -> Any:
|
|
191
|
+
"""Run an unrecorded attribute operation, still showing it to a
|
|
192
|
+
phase with an until= exit through a private event, as the callable
|
|
193
|
+
path does."""
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
outcome = operate(None)
|
|
197
|
+
except BaseException as exc:
|
|
198
|
+
phase = slot[0]
|
|
199
|
+
|
|
200
|
+
if phase is not None and phase.watches:
|
|
201
|
+
event = _build_event(
|
|
202
|
+
binding, kind, instance, attribute, REFERENCE, value, phase
|
|
203
|
+
)
|
|
204
|
+
event.exception = exc
|
|
205
|
+
binding._completed(kind, phase, event)
|
|
206
|
+
|
|
207
|
+
raise
|
|
208
|
+
|
|
209
|
+
phase = slot[0]
|
|
210
|
+
|
|
211
|
+
if phase is not None and phase.watches:
|
|
212
|
+
event = _build_event(
|
|
213
|
+
binding, kind, instance, attribute, REFERENCE, value, phase
|
|
214
|
+
)
|
|
215
|
+
if kind == "get":
|
|
216
|
+
event.result = outcome
|
|
217
|
+
binding._completed(kind, phase, event)
|
|
218
|
+
|
|
219
|
+
return outcome
|
|
220
|
+
|
|
221
|
+
|
|
115
222
|
def _record(
|
|
116
223
|
binding: Binding,
|
|
117
224
|
kind: EventKind,
|
|
118
225
|
instance: Any,
|
|
119
226
|
attribute: str,
|
|
120
|
-
operate: Callable[[], Any],
|
|
227
|
+
operate: Callable[[Event | None], Any],
|
|
121
228
|
value: Any = MISSING,
|
|
229
|
+
slot: list[Phase | None] | None = None,
|
|
122
230
|
) -> Any:
|
|
123
231
|
"""Run one attribute operation, recording it onto the ambient tape.
|
|
124
232
|
|
|
@@ -126,20 +234,25 @@ def _record(
|
|
|
126
234
|
recorder's own reentrancy guard) means the operation just runs, and
|
|
127
235
|
otherwise an event is recorded around it, with the operation pushed
|
|
128
236
|
on the in-progress stack so anything it triggers nests under it.
|
|
237
|
+
`slot` holds the phase handling the operation, updated by dispatch
|
|
238
|
+
if a sequence hands over mid-operation.
|
|
129
239
|
"""
|
|
130
240
|
|
|
241
|
+
if slot is None:
|
|
242
|
+
slot = [None]
|
|
243
|
+
|
|
131
244
|
# when=False is a behaviour-only binding: it never records, counts
|
|
132
245
|
# nothing, and takes no part in gap detection.
|
|
133
246
|
|
|
134
247
|
if binding._when is False:
|
|
135
|
-
return operate
|
|
248
|
+
return _quiet(binding, kind, instance, attribute, operate, value, slot)
|
|
136
249
|
|
|
137
250
|
active = _active_sinks()
|
|
138
251
|
if not active or _in_recorder.get():
|
|
139
252
|
if not active and not _in_recorder.get() and _timelines_active():
|
|
140
253
|
binding._note_missed_call()
|
|
141
254
|
|
|
142
|
-
return operate
|
|
255
|
+
return _quiet(binding, kind, instance, attribute, operate, value, slot)
|
|
143
256
|
|
|
144
257
|
# The per-access predicate, mapped onto call shape the same way
|
|
145
258
|
# behaviour stages are: a set passes the written value as the one
|
|
@@ -156,45 +269,23 @@ def _record(
|
|
|
156
269
|
|
|
157
270
|
if not wanted:
|
|
158
271
|
binding._filtered_calls += 1
|
|
159
|
-
return operate
|
|
272
|
+
return _quiet(binding, kind, instance, attribute, operate, value, slot)
|
|
160
273
|
|
|
161
274
|
# The written value and the prior value are inbound data, so they
|
|
162
275
|
# capture on the arguments axis, under the attribute's name so a
|
|
163
|
-
# by-name policy such as redact() applies to writes too.
|
|
276
|
+
# by-name policy such as redact() applies to writes too. An until=
|
|
277
|
+
# predicate needs the value whatever the sinks asked for.
|
|
278
|
+
|
|
279
|
+
phase = slot[0]
|
|
280
|
+
watching = phase is not None and phase.watches
|
|
164
281
|
|
|
165
282
|
policy = binding._capture_args
|
|
166
283
|
if policy is None:
|
|
167
284
|
policy = _required_policy(active, "capture_args")
|
|
285
|
+
if watching and _level_of(policy) < REFERENCE:
|
|
286
|
+
policy = REFERENCE
|
|
168
287
|
|
|
169
|
-
|
|
170
|
-
try:
|
|
171
|
-
event = Event(
|
|
172
|
-
kind,
|
|
173
|
-
binding._path,
|
|
174
|
-
label=binding._label,
|
|
175
|
-
instance=instance,
|
|
176
|
-
binding=binding,
|
|
177
|
-
capture=_level_of(policy),
|
|
178
|
-
injected=binding._injects.get(kind, False),
|
|
179
|
-
)
|
|
180
|
-
|
|
181
|
-
if binding._stack_depth is not None:
|
|
182
|
-
event.stack = _capture_stack(binding._stack_depth)
|
|
183
|
-
|
|
184
|
-
if value is not MISSING:
|
|
185
|
-
event.value = _capture_value(policy, attribute, value)
|
|
186
|
-
|
|
187
|
-
# The prior value, when cheaply available: only what already
|
|
188
|
-
# sits in the instance dictionary. A prior held by a descriptor
|
|
189
|
-
# would take running user code to read, so it is not recorded.
|
|
190
|
-
|
|
191
|
-
if kind in ("set", "delete"):
|
|
192
|
-
previous = getattr(instance, "__dict__", {}).get(attribute, MISSING)
|
|
193
|
-
if previous is not MISSING:
|
|
194
|
-
event.previous = _capture_value(policy, attribute, previous)
|
|
195
|
-
|
|
196
|
-
finally:
|
|
197
|
-
_in_recorder.reset(guard)
|
|
288
|
+
event = _build_event(binding, kind, instance, attribute, policy, value, phase)
|
|
198
289
|
|
|
199
290
|
# Position before delivery: pushed first, so sinks hearing
|
|
200
291
|
# on_enter see the event's final depth and parent link. Timing
|
|
@@ -208,11 +299,12 @@ def _record(
|
|
|
208
299
|
event.started = started
|
|
209
300
|
|
|
210
301
|
try:
|
|
211
|
-
outcome = operate()
|
|
302
|
+
outcome = operate(event)
|
|
212
303
|
except BaseException as exc:
|
|
213
304
|
event.duration = time.perf_counter() - started
|
|
214
305
|
event.exception = exc
|
|
215
306
|
_notify_error(event, active)
|
|
307
|
+
binding._completed(kind, slot[0], event)
|
|
216
308
|
raise
|
|
217
309
|
finally:
|
|
218
310
|
_pop(token)
|
|
@@ -226,9 +318,12 @@ def _record(
|
|
|
226
318
|
result_policy = binding._capture_result
|
|
227
319
|
if result_policy is None:
|
|
228
320
|
result_policy = _required_policy(active, "capture_result")
|
|
321
|
+
if watching and _level_of(result_policy) < REFERENCE:
|
|
322
|
+
result_policy = REFERENCE
|
|
229
323
|
_capture_result(event, outcome, result_policy)
|
|
230
324
|
|
|
231
325
|
_notify_exit(event, active)
|
|
326
|
+
binding._completed(kind, slot[0], event)
|
|
232
327
|
return outcome
|
|
233
328
|
|
|
234
329
|
|
|
@@ -262,17 +357,18 @@ class AttributeDescriptor(BaseObjectProxy[Any]):
|
|
|
262
357
|
binding._suspended_calls += 1
|
|
263
358
|
return _read(prior, attribute, instance, owner)
|
|
264
359
|
|
|
265
|
-
|
|
360
|
+
phase = binding._select("get")
|
|
361
|
+
slot: list[Phase | None] = [phase]
|
|
266
362
|
|
|
267
363
|
def read() -> Any:
|
|
268
364
|
return _read(prior, attribute, instance, owner)
|
|
269
365
|
|
|
270
|
-
def operate() -> Any:
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
366
|
+
def operate(event: Event | None) -> Any:
|
|
367
|
+
return binding._invoke(
|
|
368
|
+
"get", phase, read, instance, (), {}, event, slot=slot
|
|
369
|
+
)
|
|
274
370
|
|
|
275
|
-
return _record(binding, "get", instance, attribute, operate)
|
|
371
|
+
return _record(binding, "get", instance, attribute, operate, slot=slot)
|
|
276
372
|
|
|
277
373
|
def __set__(self, instance: Any, value: Any) -> None:
|
|
278
374
|
binding = self._self_wrapture_binding
|
|
@@ -284,18 +380,18 @@ class AttributeDescriptor(BaseObjectProxy[Any]):
|
|
|
284
380
|
_write(prior, attribute, instance, value)
|
|
285
381
|
return
|
|
286
382
|
|
|
287
|
-
|
|
383
|
+
phase = binding._select("set")
|
|
384
|
+
slot: list[Phase | None] = [phase]
|
|
288
385
|
|
|
289
386
|
def write(new_value: Any) -> None:
|
|
290
387
|
_write(prior, attribute, instance, new_value)
|
|
291
388
|
|
|
292
|
-
def operate() -> Any:
|
|
293
|
-
|
|
294
|
-
write(value)
|
|
295
|
-
|
|
296
|
-
return behaviour(write, instance, (value,), {})
|
|
389
|
+
def operate(event: Event | None) -> Any:
|
|
390
|
+
return binding._invoke(
|
|
391
|
+
"set", phase, write, instance, (value,), {}, event, slot=slot
|
|
392
|
+
)
|
|
297
393
|
|
|
298
|
-
_record(binding, "set", instance, attribute, operate, value=value)
|
|
394
|
+
_record(binding, "set", instance, attribute, operate, value=value, slot=slot)
|
|
299
395
|
|
|
300
396
|
def __delete__(self, instance: Any) -> None:
|
|
301
397
|
binding = self._self_wrapture_binding
|
|
@@ -307,18 +403,103 @@ class AttributeDescriptor(BaseObjectProxy[Any]):
|
|
|
307
403
|
_delete(prior, attribute, instance)
|
|
308
404
|
return
|
|
309
405
|
|
|
310
|
-
|
|
406
|
+
phase = binding._select("delete")
|
|
407
|
+
slot: list[Phase | None] = [phase]
|
|
311
408
|
|
|
312
409
|
def erase() -> None:
|
|
313
410
|
_delete(prior, attribute, instance)
|
|
314
411
|
|
|
315
|
-
def operate() -> Any:
|
|
316
|
-
|
|
317
|
-
erase()
|
|
318
|
-
|
|
319
|
-
|
|
412
|
+
def operate(event: Event | None) -> Any:
|
|
413
|
+
return binding._invoke(
|
|
414
|
+
"delete", phase, erase, instance, (), {}, event, slot=slot
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
_record(binding, "delete", instance, attribute, operate, slot=slot)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
_MODULE_BASE = "_wrapture_module_base"
|
|
421
|
+
|
|
422
|
+
_module_lock = threading.Lock()
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _module_class(module: Any) -> type | None:
|
|
426
|
+
"""The per-module class wrapture assigned to a module, or None when
|
|
427
|
+
the module's type is not one of ours."""
|
|
428
|
+
|
|
429
|
+
cls = type(module)
|
|
430
|
+
if _MODULE_BASE in vars(cls):
|
|
431
|
+
return cls
|
|
432
|
+
|
|
433
|
+
return None
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _intercept_module(module: Any, binding: Binding) -> type:
|
|
437
|
+
"""Find or create the per-module class that carries descriptors for
|
|
438
|
+
a module, assigning it to the module's __class__ on creation.
|
|
439
|
+
|
|
440
|
+
The class derives from the module's current type rather than
|
|
441
|
+
ModuleType, so anything that already replaced the module's class is
|
|
442
|
+
kept working beneath the interception. It is named "module" so the
|
|
443
|
+
type's name, and messages built from it, read the same as before.
|
|
444
|
+
"""
|
|
445
|
+
|
|
446
|
+
existing = _module_class(module)
|
|
447
|
+
if existing is not None:
|
|
448
|
+
return existing
|
|
449
|
+
|
|
450
|
+
# From CPython 3.14 (python/cpython#103951, PR #126264, still the
|
|
451
|
+
# case on main) the LOAD_ATTR specialisation for modules is chosen on
|
|
452
|
+
# the type's getattro slot alone, anything sharing ModuleType's, and
|
|
453
|
+
# then reads the module dictionary directly, skipping data
|
|
454
|
+
# descriptors on the type. Before 3.14 the guard was an exact type
|
|
455
|
+
# check, so subclasses always took the general path and descriptors
|
|
456
|
+
# worked. The change was made for speed and neither the issue nor
|
|
457
|
+
# the PR mentions descriptors, so the loss looks unintended, an
|
|
458
|
+
# unreported regression rather than a decision. Defining
|
|
459
|
+
# __getattribute__ gives the class its own slot, so every access
|
|
460
|
+
# takes the general path and the descriptors are seen, on every
|
|
461
|
+
# version, whether or not upstream changes again. It delegates
|
|
462
|
+
# straight to the base, so messages and any module __getattr__
|
|
463
|
+
# behave as before.
|
|
464
|
+
|
|
465
|
+
base: Any = type(module)
|
|
466
|
+
|
|
467
|
+
def __getattribute__(self: Any, name: str) -> Any:
|
|
468
|
+
return base.__getattribute__(self, name)
|
|
469
|
+
|
|
470
|
+
namespace = {
|
|
471
|
+
_MODULE_BASE: base,
|
|
472
|
+
"__module__": base.__module__,
|
|
473
|
+
"__getattribute__": __getattribute__,
|
|
474
|
+
}
|
|
475
|
+
cls = type("module", (base,), namespace)
|
|
476
|
+
|
|
477
|
+
try:
|
|
478
|
+
module.__class__ = cls
|
|
479
|
+
except TypeError as exc:
|
|
480
|
+
raise TypeError(
|
|
481
|
+
f"{binding._label}: attribute bindings on module"
|
|
482
|
+
f" {module.__name__!r} are not possible because its type"
|
|
483
|
+
f" {base.__name__!r} does not allow __class__ assignment"
|
|
484
|
+
f" ({exc}). To hold a value in the attribute while applied,"
|
|
485
|
+
f" use attr={binding._name.rsplit('.', 1)[-1]!r} instead"
|
|
486
|
+
) from None
|
|
487
|
+
|
|
488
|
+
return cls
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _release_module(module: Any) -> None:
|
|
492
|
+
"""Put the module's original class back once no descriptor remains
|
|
493
|
+
on the per-module class and it is still what the module uses."""
|
|
494
|
+
|
|
495
|
+
cls = _module_class(module)
|
|
496
|
+
if cls is None:
|
|
497
|
+
return
|
|
498
|
+
|
|
499
|
+
if any(isinstance(value, AttributeDescriptor) for value in vars(cls).values()):
|
|
500
|
+
return
|
|
320
501
|
|
|
321
|
-
|
|
502
|
+
module.__class__ = vars(cls)[_MODULE_BASE]
|
|
322
503
|
|
|
323
504
|
|
|
324
505
|
def _resolve_parent(target: Any, name: str) -> tuple[Any, str]:
|
|
@@ -336,6 +517,36 @@ def _resolve_parent(target: Any, name: str) -> tuple[Any, str]:
|
|
|
336
517
|
return target, name
|
|
337
518
|
|
|
338
519
|
|
|
520
|
+
def _install_on_module(
|
|
521
|
+
binding: Binding, module: Any, attribute: str
|
|
522
|
+
) -> AttributeDescriptor:
|
|
523
|
+
"""Install the descriptor for a module attribute on the per-module
|
|
524
|
+
class, creating the class on first use."""
|
|
525
|
+
|
|
526
|
+
# An absent attribute is only bindable with missing_ok=True, as for
|
|
527
|
+
# a class owner. A name a module-level __getattr__ would supply
|
|
528
|
+
# counts as absent: it is not in the module's dictionary.
|
|
529
|
+
|
|
530
|
+
if attribute not in vars(module) and not binding._missing_ok:
|
|
531
|
+
raise AttributeError(
|
|
532
|
+
f"{binding._label}: attribute {attribute!r} is not defined on"
|
|
533
|
+
f" module {module.__name__!r}; pass missing_ok=True to bind a"
|
|
534
|
+
f" name that is assigned later"
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
with _module_lock:
|
|
538
|
+
cls = _intercept_module(module, binding)
|
|
539
|
+
|
|
540
|
+
prior: Any = vars(cls).get(attribute, MISSING)
|
|
541
|
+
created = prior is MISSING
|
|
542
|
+
|
|
543
|
+
descriptor = AttributeDescriptor(prior, attribute, binding)
|
|
544
|
+
descriptor.__self_setattr__("__wrapt_wrap_object_created_slot__", created)
|
|
545
|
+
apply_patch(cls, attribute, descriptor)
|
|
546
|
+
|
|
547
|
+
return descriptor
|
|
548
|
+
|
|
549
|
+
|
|
339
550
|
def install(binding: Binding, target: Any, name: str) -> AttributeDescriptor:
|
|
340
551
|
"""Install an AttributeDescriptor for a binding, returning the handle.
|
|
341
552
|
|
|
@@ -344,22 +555,21 @@ def install(binding: Binding, target: Any, name: str) -> AttributeDescriptor:
|
|
|
344
555
|
keeps working beneath the interception. Whether installation created
|
|
345
556
|
the attribute slot is recorded on the descriptor the same way
|
|
346
557
|
wrapt.wrap_object() records it, so wrapt.unwrap_object() removes the
|
|
347
|
-
slot rather than leaving a shadowing copy where appropriate.
|
|
558
|
+
slot rather than leaving a shadowing copy where appropriate. A
|
|
559
|
+
module owner installs on the per-module class instead.
|
|
348
560
|
"""
|
|
349
561
|
|
|
350
562
|
parent, attribute = _resolve_parent(target, name)
|
|
351
563
|
|
|
352
564
|
if inspect.ismodule(parent):
|
|
353
|
-
|
|
354
|
-
f"{binding._label}: attribute bindings on a module are not"
|
|
355
|
-
f" supported; module attribute access does not go through"
|
|
356
|
-
f" class descriptors"
|
|
357
|
-
)
|
|
565
|
+
return _install_on_module(binding, parent, attribute)
|
|
358
566
|
|
|
359
567
|
if not inspect.isclass(parent):
|
|
360
568
|
raise TypeError(
|
|
361
569
|
f"{binding._label}: an attribute binding installs a descriptor"
|
|
362
|
-
f" on the class, so the target must be a class, not an instance"
|
|
570
|
+
f" on the class, so the target must be a class, not an instance."
|
|
571
|
+
f" To observe reads on every instance, bind the class; to set"
|
|
572
|
+
f" the value on this one object, use attr={attribute!r}"
|
|
363
573
|
)
|
|
364
574
|
|
|
365
575
|
prior: Any = MISSING
|
|
@@ -386,3 +596,72 @@ def install(binding: Binding, target: Any, name: str) -> AttributeDescriptor:
|
|
|
386
596
|
apply_patch(parent, attribute, descriptor)
|
|
387
597
|
|
|
388
598
|
return descriptor
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _holder(target: Any, name: str, descriptor: AttributeDescriptor) -> tuple[Any, str]:
|
|
602
|
+
"""The object whose namespace holds a binding's descriptor, and the
|
|
603
|
+
attribute name there: for a module owner the per-module class in
|
|
604
|
+
its MRO that has the descriptor, otherwise the owner itself. Raises
|
|
605
|
+
when the owner no longer resolves or no class holds it."""
|
|
606
|
+
|
|
607
|
+
parent, attribute = _resolve_parent(target, name)
|
|
608
|
+
|
|
609
|
+
if not inspect.ismodule(parent):
|
|
610
|
+
return parent, attribute
|
|
611
|
+
|
|
612
|
+
# Something may have layered its own subclass above ours after the
|
|
613
|
+
# install, so search the MRO rather than insisting on type(module).
|
|
614
|
+
|
|
615
|
+
for cls in type(parent).__mro__:
|
|
616
|
+
if _MODULE_BASE not in vars(cls):
|
|
617
|
+
continue
|
|
618
|
+
current = vars(cls).get(attribute, MISSING)
|
|
619
|
+
if current is not MISSING and wrapt.is_wrapped_by(current, descriptor):
|
|
620
|
+
return cls, attribute
|
|
621
|
+
|
|
622
|
+
raise LookupError(f"module {parent.__name__!r} does not hold {attribute!r}")
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def is_installed(target: Any, name: str, descriptor: AttributeDescriptor) -> bool:
|
|
626
|
+
"""Whether a binding's descriptor is still in place at its location,
|
|
627
|
+
alone or within a chain of composed descriptors."""
|
|
628
|
+
|
|
629
|
+
try:
|
|
630
|
+
holder, attribute = _holder(target, name, descriptor)
|
|
631
|
+
current = wrapt.resolve_path(holder, attribute)[2]
|
|
632
|
+
except Exception:
|
|
633
|
+
return False
|
|
634
|
+
|
|
635
|
+
return bool(wrapt.is_wrapped_by(current, descriptor))
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def uninstall(
|
|
639
|
+
target: Any,
|
|
640
|
+
name: str,
|
|
641
|
+
descriptor: AttributeDescriptor,
|
|
642
|
+
*,
|
|
643
|
+
missing_ok: bool,
|
|
644
|
+
) -> None:
|
|
645
|
+
"""Remove a binding's descriptor, splicing it out of a composed
|
|
646
|
+
chain where needed, and for a module owner release the per-module
|
|
647
|
+
class once nothing else is installed on it."""
|
|
648
|
+
|
|
649
|
+
parent, attribute = _resolve_parent(target, name)
|
|
650
|
+
|
|
651
|
+
if not inspect.ismodule(parent):
|
|
652
|
+
wrapt.unwrap_object(parent, attribute, descriptor, missing_ok=missing_ok)
|
|
653
|
+
return
|
|
654
|
+
|
|
655
|
+
with _module_lock:
|
|
656
|
+
try:
|
|
657
|
+
cls, attribute = _holder(target, name, descriptor)
|
|
658
|
+
except LookupError:
|
|
659
|
+
if missing_ok:
|
|
660
|
+
return
|
|
661
|
+
raise ValueError(
|
|
662
|
+
f"module {parent.__name__!r} no longer holds the descriptor"
|
|
663
|
+
f" for {attribute!r}, so it cannot be removed"
|
|
664
|
+
) from None
|
|
665
|
+
|
|
666
|
+
wrapt.unwrap_object(cls, attribute, descriptor, missing_ok=missing_ok)
|
|
667
|
+
_release_module(parent)
|