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.
Files changed (81) hide show
  1. {wrapture-1.0.0.dev7/src/wrapture.egg-info → wrapture-1.0.0.dev8}/PKG-INFO +7 -7
  2. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/README.md +6 -6
  3. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/__init__.py +11 -1
  4. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/attributes.py +343 -64
  5. wrapture-1.0.0.dev8/src/wrapture/behaviours.py +899 -0
  6. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/bindings.py +1134 -137
  7. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/eventlogs.py +23 -0
  8. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/events.py +14 -3
  9. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/exceptions.py +10 -0
  10. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/export.py +1 -0
  11. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/observed.py +19 -9
  12. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/sinks.py +2 -0
  13. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/timeline.py +111 -17
  14. wrapture-1.0.0.dev8/src/wrapture/values.py +163 -0
  15. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/windows.py +24 -2
  16. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8/src/wrapture.egg-info}/PKG-INFO +7 -7
  17. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture.egg-info/SOURCES.txt +7 -0
  18. wrapture-1.0.0.dev8/tests/test_async_stubs.py +450 -0
  19. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_attributes.py +4 -15
  20. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_bindings.py +66 -5
  21. wrapture-1.0.0.dev8/tests/test_mapping.py +383 -0
  22. wrapture-1.0.0.dev8/tests/test_module_attributes.py +389 -0
  23. wrapture-1.0.0.dev8/tests/test_phases.py +918 -0
  24. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_recording.py +7 -5
  25. wrapture-1.0.0.dev8/tests/test_strict.py +228 -0
  26. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_timeline.py +205 -3
  27. wrapture-1.0.0.dev8/tests/test_values.py +535 -0
  28. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_windows.py +76 -0
  29. wrapture-1.0.0.dev7/src/wrapture/behaviours.py +0 -428
  30. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/LICENSE +0 -0
  31. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/pyproject.toml +0 -0
  32. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/setup.cfg +0 -0
  33. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/__main__.py +0 -0
  34. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/asgi.py +0 -0
  35. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/bootstrap.py +0 -0
  36. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/capture.py +0 -0
  37. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/collectors.py +0 -0
  38. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/config.py +0 -0
  39. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/iterators.py +0 -0
  40. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/lifecycle.py +0 -0
  41. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/outputs.py +0 -0
  42. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/py.typed +0 -0
  43. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/pytest_plugin.py +0 -0
  44. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/scheduler.py +0 -0
  45. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/stacks.py +0 -0
  46. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/tools/__init__.py +0 -0
  47. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/tools/__main__.py +0 -0
  48. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/tools/convert.py +0 -0
  49. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture/wsgi.py +0 -0
  50. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture.egg-info/dependency_links.txt +0 -0
  51. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture.egg-info/entry_points.txt +0 -0
  52. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture.egg-info/not-zip-safe +0 -0
  53. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture.egg-info/requires.txt +0 -0
  54. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/src/wrapture.egg-info/top_level.txt +0 -0
  55. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_asgi.py +0 -0
  56. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_attribute_events.py +0 -0
  57. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_behaviours.py +0 -0
  58. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_bootstrap.py +0 -0
  59. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_capture.py +0 -0
  60. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_collectors.py +0 -0
  61. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_config.py +0 -0
  62. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_eventlogs.py +0 -0
  63. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_events.py +0 -0
  64. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_export.py +0 -0
  65. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_generator_events.py +0 -0
  66. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_iterators.py +0 -0
  67. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_jsonlines.py +0 -0
  68. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_observed.py +0 -0
  69. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_outputs.py +0 -0
  70. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_predicates.py +0 -0
  71. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_pytest_plugin.py +0 -0
  72. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_runner.py +0 -0
  73. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_sink_library.py +0 -0
  74. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_sinks.py +0 -0
  75. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_stacks.py +0 -0
  76. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_targets.py +0 -0
  77. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_testing_patterns.py +0 -0
  78. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_threads.py +0 -0
  79. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_tools.py +0 -0
  80. {wrapture-1.0.0.dev7 → wrapture-1.0.0.dev8}/tests/test_version.py +0 -0
  81. {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.dev7
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: early development.** The monkey patching, unit testing and
48
- > ad-hoc tracing layers are implemented, including WSGI and ASGI request
49
- > tracing. Development previews are published to PyPI; the API may still
50
- > shift before 1.0.0.
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 `Mock`,
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: early development.** The monkey patching, unit testing and
17
- > ad-hoc tracing layers are implemented, including WSGI and ASGI request
18
- > tracing. Development previews are published to PyPI; the API may still
19
- > shift before 1.0.0.
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 `Mock`,
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", "dev7")
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
- guard = _in_recorder.set(True)
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
- behaviour = binding._behaviour("get")
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
- if behaviour is None:
272
- return read()
273
- return behaviour(read, instance, (), {})
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
- behaviour = binding._behaviour("set")
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
- if behaviour is None:
294
- write(value)
295
- return None
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
- behaviour = binding._behaviour("delete")
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
- if behaviour is None:
317
- erase()
318
- return None
319
- return behaviour(erase, instance, (), {})
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
- _record(binding, "delete", instance, attribute, operate)
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
- raise NotImplementedYetError(
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)