python-library-observer 0.1.0__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.
- python_library_observer-0.1.0/.gitignore +11 -0
- python_library_observer-0.1.0/PKG-INFO +4 -0
- python_library_observer-0.1.0/example.bat +11 -0
- python_library_observer-0.1.0/example.py +57 -0
- python_library_observer-0.1.0/observer/__init__.py +9 -0
- python_library_observer-0.1.0/observer/bus.py +88 -0
- python_library_observer-0.1.0/observer/context.py +29 -0
- python_library_observer-0.1.0/observer/deractor.py +353 -0
- python_library_observer-0.1.0/pyproject.toml +12 -0
- python_library_observer-0.1.0/test.bat +10 -0
- python_library_observer-0.1.0/tests/__init__.py +0 -0
- python_library_observer-0.1.0/tests/test_observer.py +562 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from observer import ObserverBus, ObserverContext
|
|
2
|
+
|
|
3
|
+
bus = ObserverBus()
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@bus.callback()
|
|
7
|
+
def log_all(ctx: ObserverContext) -> None:
|
|
8
|
+
print(
|
|
9
|
+
f"[ALL] {ctx.cls_name}.{ctx.method_name} "
|
|
10
|
+
f"phase={ctx.phase} args={ctx.args} kwargs={ctx.kwargs}"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@bus.callback(cls_name="Demo", phase="after")
|
|
15
|
+
def log_demo_after(ctx: ObserverContext) -> None:
|
|
16
|
+
print(
|
|
17
|
+
f"[DEMO AFTER] {ctx.cls_name}.{ctx.method_name} "
|
|
18
|
+
f"result={ctx.result} elapsed={ctx.elapsed:.6f}s"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@bus.callback(method_name="ping", phase="after")
|
|
23
|
+
def log_ping(ctx: ObserverContext) -> None:
|
|
24
|
+
print(f"[PING] result={ctx.result}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@bus.observe()
|
|
28
|
+
class Demo:
|
|
29
|
+
def add(self, a: int, b: int) -> int:
|
|
30
|
+
return a + b
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def build(cls, name: str) -> "Demo":
|
|
34
|
+
return cls()
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def ping(msg: str) -> str:
|
|
38
|
+
return f"pong:{msg}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ChildDemo(Demo):
|
|
42
|
+
def sub(self, a: int, b: int) -> int:
|
|
43
|
+
return a - b
|
|
44
|
+
|
|
45
|
+
def add(self, a: int, b: int) -> int:
|
|
46
|
+
return a + b + 100
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
d = Demo()
|
|
50
|
+
print(d.add(1, 2))
|
|
51
|
+
print(Demo.build("x"))
|
|
52
|
+
print(Demo.ping("hello"))
|
|
53
|
+
|
|
54
|
+
child = ChildDemo()
|
|
55
|
+
print(child.add(1, 2))
|
|
56
|
+
print(child.sub(5, 3))
|
|
57
|
+
print(ChildDemo.ping("world"))
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from threading import RLock
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .context import ObserverContext
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
ObserverCallback = Callable[[ObserverContext], None]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ObserverBus:
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
self._callbacks: list[tuple[ObserverCallback, dict[str, Any]]] = []
|
|
16
|
+
self._lock = RLock()
|
|
17
|
+
|
|
18
|
+
def subscribe(
|
|
19
|
+
self,
|
|
20
|
+
callback: ObserverCallback,
|
|
21
|
+
**filters: Any,
|
|
22
|
+
) -> ObserverCallback:
|
|
23
|
+
with self._lock:
|
|
24
|
+
exists = any(
|
|
25
|
+
registered_callback is callback and registered_filters == filters
|
|
26
|
+
for registered_callback, registered_filters in self._callbacks
|
|
27
|
+
)
|
|
28
|
+
if not exists:
|
|
29
|
+
self._callbacks.append((callback, dict(filters)))
|
|
30
|
+
return callback
|
|
31
|
+
|
|
32
|
+
def unsubscribe(self, callback: ObserverCallback) -> None:
|
|
33
|
+
with self._lock:
|
|
34
|
+
self._callbacks = [
|
|
35
|
+
(registered_callback, registered_filters)
|
|
36
|
+
for registered_callback, registered_filters in self._callbacks
|
|
37
|
+
if registered_callback is not callback
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
def callback(self, **filters: Any):
|
|
41
|
+
self._validate_filters(filters)
|
|
42
|
+
|
|
43
|
+
def decorator(fn: ObserverCallback) -> ObserverCallback:
|
|
44
|
+
return self.subscribe(fn, **filters)
|
|
45
|
+
|
|
46
|
+
return decorator
|
|
47
|
+
|
|
48
|
+
def emit(self, ctx: ObserverContext) -> None:
|
|
49
|
+
with self._lock:
|
|
50
|
+
callbacks = tuple(self._callbacks)
|
|
51
|
+
|
|
52
|
+
for callback, filters in callbacks:
|
|
53
|
+
if not self._match_filters(ctx, filters):
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
callback(ctx)
|
|
58
|
+
except Exception:
|
|
59
|
+
# 不要让监听器异常影响原始业务调用
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
def observe(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
include_private: bool = False,
|
|
66
|
+
emit_before: bool = True,
|
|
67
|
+
):
|
|
68
|
+
from .deractor import observe_methods
|
|
69
|
+
|
|
70
|
+
return observe_methods(
|
|
71
|
+
self,
|
|
72
|
+
include_private=include_private,
|
|
73
|
+
emit_before=emit_before,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def _match_filters(self, ctx: ObserverContext, filters: dict[str, Any]) -> bool:
|
|
77
|
+
for key, expected in filters.items():
|
|
78
|
+
if getattr(ctx, key) != expected:
|
|
79
|
+
return False
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
def _validate_filters(self, filters: dict[str, Any]) -> None:
|
|
83
|
+
valid_keys = set(ObserverContext.__dataclass_fields__.keys())
|
|
84
|
+
invalid_keys = [key for key in filters if key not in valid_keys]
|
|
85
|
+
if invalid_keys:
|
|
86
|
+
raise ValueError(
|
|
87
|
+
f"invalid callback filters: {', '.join(sorted(invalid_keys))}"
|
|
88
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
ObserverPhase = Literal["before", "after", "error"]
|
|
8
|
+
ObserverKind = Literal["instance", "class", "static"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True)
|
|
12
|
+
class ObserverContext:
|
|
13
|
+
call_id: str
|
|
14
|
+
instance: Any | None
|
|
15
|
+
owner: Any | None
|
|
16
|
+
cls: type
|
|
17
|
+
cls_name: str
|
|
18
|
+
method_name: str
|
|
19
|
+
qualname: str
|
|
20
|
+
method_kind: ObserverKind
|
|
21
|
+
args: tuple[Any, ...]
|
|
22
|
+
kwargs: dict[str, Any]
|
|
23
|
+
result: Any = None
|
|
24
|
+
error: BaseException | None = None
|
|
25
|
+
phase: ObserverPhase = "after"
|
|
26
|
+
is_async: bool = False
|
|
27
|
+
started_at: float = 0.0
|
|
28
|
+
ended_at: float = 0.0
|
|
29
|
+
elapsed: float = 0.0
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import inspect
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .bus import ObserverBus
|
|
11
|
+
from .context import ObserverContext, ObserverKind, ObserverPhase
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_WRAPPED_FLAG = "__observer_bus_wrapped__"
|
|
15
|
+
_SUBCLASS_HOOK_FLAG = "__observer_bus_subclass_hook_installed__"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def observe_methods(
|
|
19
|
+
bus: ObserverBus,
|
|
20
|
+
*,
|
|
21
|
+
include_private: bool = False,
|
|
22
|
+
emit_before: bool = True,
|
|
23
|
+
):
|
|
24
|
+
def decorator(cls: type) -> type:
|
|
25
|
+
_observe_class(
|
|
26
|
+
cls,
|
|
27
|
+
bus=bus,
|
|
28
|
+
include_private=include_private,
|
|
29
|
+
emit_before=emit_before,
|
|
30
|
+
)
|
|
31
|
+
_install_subclass_hook(
|
|
32
|
+
cls,
|
|
33
|
+
bus=bus,
|
|
34
|
+
include_private=include_private,
|
|
35
|
+
emit_before=emit_before,
|
|
36
|
+
)
|
|
37
|
+
return cls
|
|
38
|
+
|
|
39
|
+
return decorator
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _install_subclass_hook(
|
|
43
|
+
cls: type,
|
|
44
|
+
*,
|
|
45
|
+
bus: ObserverBus,
|
|
46
|
+
include_private: bool,
|
|
47
|
+
emit_before: bool,
|
|
48
|
+
) -> None:
|
|
49
|
+
if cls.__dict__.get(_SUBCLASS_HOOK_FLAG, False):
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
original_init_subclass = cls.__dict__.get("__init_subclass__")
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def __init_subclass__(subcls, **kwargs):
|
|
56
|
+
if original_init_subclass is not None:
|
|
57
|
+
original_init_subclass.__get__(subcls, subcls)(**kwargs)
|
|
58
|
+
else:
|
|
59
|
+
super(cls, subcls).__init_subclass__(**kwargs)
|
|
60
|
+
|
|
61
|
+
_observe_class(
|
|
62
|
+
subcls,
|
|
63
|
+
bus=bus,
|
|
64
|
+
include_private=include_private,
|
|
65
|
+
emit_before=emit_before,
|
|
66
|
+
)
|
|
67
|
+
_install_subclass_hook(
|
|
68
|
+
subcls,
|
|
69
|
+
bus=bus,
|
|
70
|
+
include_private=include_private,
|
|
71
|
+
emit_before=emit_before,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
setattr(cls, "__init_subclass__", __init_subclass__)
|
|
75
|
+
setattr(cls, _SUBCLASS_HOOK_FLAG, True)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _observe_class(
|
|
79
|
+
cls: type,
|
|
80
|
+
*,
|
|
81
|
+
bus: ObserverBus,
|
|
82
|
+
include_private: bool,
|
|
83
|
+
emit_before: bool,
|
|
84
|
+
) -> None:
|
|
85
|
+
for name, obj in list(cls.__dict__.items()):
|
|
86
|
+
if not include_private and name.startswith("_"):
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
if isinstance(obj, property):
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
if isinstance(obj, staticmethod):
|
|
93
|
+
func = obj.__func__
|
|
94
|
+
if getattr(func, _WRAPPED_FLAG, False):
|
|
95
|
+
continue
|
|
96
|
+
wrapped_func = _wrap_function(
|
|
97
|
+
owner_cls=cls,
|
|
98
|
+
method_name=name,
|
|
99
|
+
func=func,
|
|
100
|
+
bus=bus,
|
|
101
|
+
method_kind="static",
|
|
102
|
+
emit_before=emit_before,
|
|
103
|
+
)
|
|
104
|
+
setattr(cls, name, staticmethod(wrapped_func))
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
if isinstance(obj, classmethod):
|
|
108
|
+
func = obj.__func__
|
|
109
|
+
if getattr(func, _WRAPPED_FLAG, False):
|
|
110
|
+
continue
|
|
111
|
+
wrapped_func = _wrap_function(
|
|
112
|
+
owner_cls=cls,
|
|
113
|
+
method_name=name,
|
|
114
|
+
func=func,
|
|
115
|
+
bus=bus,
|
|
116
|
+
method_kind="class",
|
|
117
|
+
emit_before=emit_before,
|
|
118
|
+
)
|
|
119
|
+
setattr(cls, name, classmethod(wrapped_func))
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
if callable(obj):
|
|
123
|
+
if getattr(obj, _WRAPPED_FLAG, False):
|
|
124
|
+
continue
|
|
125
|
+
wrapped = _wrap_function(
|
|
126
|
+
owner_cls=cls,
|
|
127
|
+
method_name=name,
|
|
128
|
+
func=obj,
|
|
129
|
+
bus=bus,
|
|
130
|
+
method_kind="instance",
|
|
131
|
+
emit_before=emit_before,
|
|
132
|
+
)
|
|
133
|
+
setattr(cls, name, wrapped)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _wrap_function(
|
|
137
|
+
owner_cls: type,
|
|
138
|
+
method_name: str,
|
|
139
|
+
func: Callable[..., Any],
|
|
140
|
+
bus: ObserverBus,
|
|
141
|
+
method_kind: ObserverKind,
|
|
142
|
+
emit_before: bool,
|
|
143
|
+
):
|
|
144
|
+
if inspect.iscoroutinefunction(func):
|
|
145
|
+
@functools.wraps(func)
|
|
146
|
+
async def async_wrapper(*args, **kwargs):
|
|
147
|
+
call_id = uuid.uuid4().hex
|
|
148
|
+
started = time.perf_counter()
|
|
149
|
+
|
|
150
|
+
if emit_before:
|
|
151
|
+
bus.emit(
|
|
152
|
+
_build_context(
|
|
153
|
+
owner_cls=owner_cls,
|
|
154
|
+
method_name=method_name,
|
|
155
|
+
func=func,
|
|
156
|
+
method_kind=method_kind,
|
|
157
|
+
args=args,
|
|
158
|
+
kwargs=kwargs,
|
|
159
|
+
phase="before",
|
|
160
|
+
is_async=True,
|
|
161
|
+
started_at=started,
|
|
162
|
+
call_id=call_id,
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
result = await func(*args, **kwargs)
|
|
168
|
+
except Exception as error:
|
|
169
|
+
ended = time.perf_counter()
|
|
170
|
+
bus.emit(
|
|
171
|
+
_build_context(
|
|
172
|
+
owner_cls=owner_cls,
|
|
173
|
+
method_name=method_name,
|
|
174
|
+
func=func,
|
|
175
|
+
method_kind=method_kind,
|
|
176
|
+
args=args,
|
|
177
|
+
kwargs=kwargs,
|
|
178
|
+
phase="error",
|
|
179
|
+
is_async=True,
|
|
180
|
+
started_at=started,
|
|
181
|
+
ended_at=ended,
|
|
182
|
+
error=error,
|
|
183
|
+
call_id=call_id,
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
raise
|
|
187
|
+
|
|
188
|
+
ended = time.perf_counter()
|
|
189
|
+
bus.emit(
|
|
190
|
+
_build_context(
|
|
191
|
+
owner_cls=owner_cls,
|
|
192
|
+
method_name=method_name,
|
|
193
|
+
func=func,
|
|
194
|
+
method_kind=method_kind,
|
|
195
|
+
args=args,
|
|
196
|
+
kwargs=kwargs,
|
|
197
|
+
phase="after",
|
|
198
|
+
is_async=True,
|
|
199
|
+
started_at=started,
|
|
200
|
+
ended_at=ended,
|
|
201
|
+
result=result,
|
|
202
|
+
call_id=call_id,
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
return result
|
|
206
|
+
|
|
207
|
+
setattr(async_wrapper, _WRAPPED_FLAG, True)
|
|
208
|
+
return async_wrapper
|
|
209
|
+
|
|
210
|
+
@functools.wraps(func)
|
|
211
|
+
def sync_wrapper(*args, **kwargs):
|
|
212
|
+
call_id = uuid.uuid4().hex
|
|
213
|
+
started = time.perf_counter()
|
|
214
|
+
|
|
215
|
+
if emit_before:
|
|
216
|
+
bus.emit(
|
|
217
|
+
_build_context(
|
|
218
|
+
owner_cls=owner_cls,
|
|
219
|
+
method_name=method_name,
|
|
220
|
+
func=func,
|
|
221
|
+
method_kind=method_kind,
|
|
222
|
+
args=args,
|
|
223
|
+
kwargs=kwargs,
|
|
224
|
+
phase="before",
|
|
225
|
+
is_async=False,
|
|
226
|
+
started_at=started,
|
|
227
|
+
call_id=call_id,
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
result = func(*args, **kwargs)
|
|
233
|
+
except Exception as error:
|
|
234
|
+
ended = time.perf_counter()
|
|
235
|
+
bus.emit(
|
|
236
|
+
_build_context(
|
|
237
|
+
owner_cls=owner_cls,
|
|
238
|
+
method_name=method_name,
|
|
239
|
+
func=func,
|
|
240
|
+
method_kind=method_kind,
|
|
241
|
+
args=args,
|
|
242
|
+
kwargs=kwargs,
|
|
243
|
+
phase="error",
|
|
244
|
+
is_async=False,
|
|
245
|
+
started_at=started,
|
|
246
|
+
ended_at=ended,
|
|
247
|
+
error=error,
|
|
248
|
+
call_id=call_id,
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
raise
|
|
252
|
+
|
|
253
|
+
ended = time.perf_counter()
|
|
254
|
+
bus.emit(
|
|
255
|
+
_build_context(
|
|
256
|
+
owner_cls=owner_cls,
|
|
257
|
+
method_name=method_name,
|
|
258
|
+
func=func,
|
|
259
|
+
method_kind=method_kind,
|
|
260
|
+
args=args,
|
|
261
|
+
kwargs=kwargs,
|
|
262
|
+
phase="after",
|
|
263
|
+
is_async=False,
|
|
264
|
+
started_at=started,
|
|
265
|
+
ended_at=ended,
|
|
266
|
+
result=result,
|
|
267
|
+
call_id=call_id,
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
return result
|
|
271
|
+
|
|
272
|
+
setattr(sync_wrapper, _WRAPPED_FLAG, True)
|
|
273
|
+
return sync_wrapper
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _build_context(
|
|
277
|
+
*,
|
|
278
|
+
owner_cls: type,
|
|
279
|
+
method_name: str,
|
|
280
|
+
func: Callable[..., Any],
|
|
281
|
+
method_kind: ObserverKind,
|
|
282
|
+
args: tuple[Any, ...],
|
|
283
|
+
kwargs: dict[str, Any],
|
|
284
|
+
phase: ObserverPhase,
|
|
285
|
+
is_async: bool,
|
|
286
|
+
started_at: float,
|
|
287
|
+
call_id: str,
|
|
288
|
+
ended_at: float | None = None,
|
|
289
|
+
result: Any = None,
|
|
290
|
+
error: BaseException | None = None,
|
|
291
|
+
) -> ObserverContext:
|
|
292
|
+
instance, owner, pure_args = _split_receiver(method_kind, args)
|
|
293
|
+
actual_cls = _resolve_context_class(
|
|
294
|
+
owner_cls=owner_cls,
|
|
295
|
+
method_kind=method_kind,
|
|
296
|
+
instance=instance,
|
|
297
|
+
owner=owner,
|
|
298
|
+
)
|
|
299
|
+
final_ended_at = ended_at if ended_at is not None else 0.0
|
|
300
|
+
elapsed = final_ended_at - started_at if ended_at is not None else 0.0
|
|
301
|
+
|
|
302
|
+
return ObserverContext(
|
|
303
|
+
call_id=call_id,
|
|
304
|
+
instance=instance,
|
|
305
|
+
owner=owner,
|
|
306
|
+
cls=actual_cls,
|
|
307
|
+
cls_name=actual_cls.__name__,
|
|
308
|
+
method_name=method_name,
|
|
309
|
+
qualname=func.__qualname__,
|
|
310
|
+
method_kind=method_kind,
|
|
311
|
+
args=pure_args,
|
|
312
|
+
kwargs=dict(kwargs),
|
|
313
|
+
result=result,
|
|
314
|
+
error=error,
|
|
315
|
+
phase=phase,
|
|
316
|
+
is_async=is_async,
|
|
317
|
+
started_at=started_at,
|
|
318
|
+
ended_at=final_ended_at,
|
|
319
|
+
elapsed=elapsed,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _split_receiver(
|
|
324
|
+
method_kind: ObserverKind,
|
|
325
|
+
args: tuple[Any, ...],
|
|
326
|
+
) -> tuple[Any | None, Any | None, tuple[Any, ...]]:
|
|
327
|
+
if method_kind == "instance":
|
|
328
|
+
instance = args[0] if args else None
|
|
329
|
+
pure_args = args[1:] if args else ()
|
|
330
|
+
return instance, instance, pure_args
|
|
331
|
+
|
|
332
|
+
if method_kind == "class":
|
|
333
|
+
owner = args[0] if args else None
|
|
334
|
+
pure_args = args[1:] if args else ()
|
|
335
|
+
return None, owner, pure_args
|
|
336
|
+
|
|
337
|
+
return None, None, args
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _resolve_context_class(
|
|
341
|
+
*,
|
|
342
|
+
owner_cls: type,
|
|
343
|
+
method_kind: ObserverKind,
|
|
344
|
+
instance: Any | None,
|
|
345
|
+
owner: Any | None,
|
|
346
|
+
) -> type:
|
|
347
|
+
if method_kind == "instance" and instance is not None:
|
|
348
|
+
return type(instance)
|
|
349
|
+
|
|
350
|
+
if method_kind == "class" and isinstance(owner, type):
|
|
351
|
+
return owner
|
|
352
|
+
|
|
353
|
+
return owner_cls
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "python-library-observer"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
dependencies = []
|
|
10
|
+
|
|
11
|
+
[tool.hatch.build.targets.wheel]
|
|
12
|
+
packages = ["observer"]
|
|
File without changes
|
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import unittest
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from observer import ObserverBus, ObserverContext, observe_methods
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ObserverBusTests(unittest.TestCase):
|
|
11
|
+
def test_subscribe_invokes_on_emit(self) -> None:
|
|
12
|
+
bus = ObserverBus()
|
|
13
|
+
seen: list[str] = []
|
|
14
|
+
|
|
15
|
+
def cb(ctx: ObserverContext) -> None:
|
|
16
|
+
seen.append(ctx.phase)
|
|
17
|
+
|
|
18
|
+
bus.subscribe(cb)
|
|
19
|
+
ctx = ObserverContext(
|
|
20
|
+
call_id="1",
|
|
21
|
+
instance=None,
|
|
22
|
+
owner=None,
|
|
23
|
+
cls=object,
|
|
24
|
+
cls_name="object",
|
|
25
|
+
method_name="m",
|
|
26
|
+
qualname="m",
|
|
27
|
+
method_kind="static",
|
|
28
|
+
args=(),
|
|
29
|
+
kwargs={},
|
|
30
|
+
phase="after",
|
|
31
|
+
)
|
|
32
|
+
bus.emit(ctx)
|
|
33
|
+
self.assertEqual(seen, ["after"])
|
|
34
|
+
|
|
35
|
+
def test_subscribe_returns_callback(self) -> None:
|
|
36
|
+
bus = ObserverBus()
|
|
37
|
+
|
|
38
|
+
def cb(ctx: ObserverContext) -> None:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
self.assertIs(bus.subscribe(cb), cb)
|
|
42
|
+
|
|
43
|
+
def test_duplicate_subscribe_same_filters_is_idempotent(self) -> None:
|
|
44
|
+
bus = ObserverBus()
|
|
45
|
+
count = 0
|
|
46
|
+
|
|
47
|
+
def cb(ctx: ObserverContext) -> None:
|
|
48
|
+
nonlocal count
|
|
49
|
+
count += 1
|
|
50
|
+
|
|
51
|
+
bus.subscribe(cb, phase="after")
|
|
52
|
+
bus.subscribe(cb, phase="after")
|
|
53
|
+
bus.emit(
|
|
54
|
+
ObserverContext(
|
|
55
|
+
call_id="1",
|
|
56
|
+
instance=None,
|
|
57
|
+
owner=None,
|
|
58
|
+
cls=object,
|
|
59
|
+
cls_name="object",
|
|
60
|
+
method_name="m",
|
|
61
|
+
qualname="m",
|
|
62
|
+
method_kind="static",
|
|
63
|
+
args=(),
|
|
64
|
+
kwargs={},
|
|
65
|
+
phase="after",
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
self.assertEqual(count, 1)
|
|
69
|
+
|
|
70
|
+
def test_same_callback_different_filters_both_fire(self) -> None:
|
|
71
|
+
bus = ObserverBus()
|
|
72
|
+
phases: list[str] = []
|
|
73
|
+
|
|
74
|
+
def cb(ctx: ObserverContext) -> None:
|
|
75
|
+
phases.append(ctx.phase)
|
|
76
|
+
|
|
77
|
+
bus.subscribe(cb, phase="before")
|
|
78
|
+
bus.subscribe(cb, phase="after")
|
|
79
|
+
bus.emit(
|
|
80
|
+
ObserverContext(
|
|
81
|
+
call_id="1",
|
|
82
|
+
instance=None,
|
|
83
|
+
owner=None,
|
|
84
|
+
cls=object,
|
|
85
|
+
cls_name="object",
|
|
86
|
+
method_name="m",
|
|
87
|
+
qualname="m",
|
|
88
|
+
method_kind="static",
|
|
89
|
+
args=(),
|
|
90
|
+
kwargs={},
|
|
91
|
+
phase="before",
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
bus.emit(
|
|
95
|
+
ObserverContext(
|
|
96
|
+
call_id="2",
|
|
97
|
+
instance=None,
|
|
98
|
+
owner=None,
|
|
99
|
+
cls=object,
|
|
100
|
+
cls_name="object",
|
|
101
|
+
method_name="m",
|
|
102
|
+
qualname="m",
|
|
103
|
+
method_kind="static",
|
|
104
|
+
args=(),
|
|
105
|
+
kwargs={},
|
|
106
|
+
phase="after",
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
self.assertEqual(phases, ["before", "after"])
|
|
110
|
+
|
|
111
|
+
def test_unsubscribe_removes_callback(self) -> None:
|
|
112
|
+
bus = ObserverBus()
|
|
113
|
+
seen: list[int] = []
|
|
114
|
+
|
|
115
|
+
def cb(ctx: ObserverContext) -> None:
|
|
116
|
+
seen.append(1)
|
|
117
|
+
|
|
118
|
+
bus.subscribe(cb)
|
|
119
|
+
bus.unsubscribe(cb)
|
|
120
|
+
bus.emit(
|
|
121
|
+
ObserverContext(
|
|
122
|
+
call_id="1",
|
|
123
|
+
instance=None,
|
|
124
|
+
owner=None,
|
|
125
|
+
cls=object,
|
|
126
|
+
cls_name="object",
|
|
127
|
+
method_name="m",
|
|
128
|
+
qualname="m",
|
|
129
|
+
method_kind="static",
|
|
130
|
+
args=(),
|
|
131
|
+
kwargs={},
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
self.assertEqual(seen, [])
|
|
135
|
+
|
|
136
|
+
def test_callback_decorator_registers(self) -> None:
|
|
137
|
+
bus = ObserverBus()
|
|
138
|
+
seen: list[str] = []
|
|
139
|
+
|
|
140
|
+
@bus.callback(phase="after", cls_name="Svc")
|
|
141
|
+
def cb(ctx: ObserverContext) -> None:
|
|
142
|
+
seen.append(ctx.method_name)
|
|
143
|
+
|
|
144
|
+
bus.emit(
|
|
145
|
+
ObserverContext(
|
|
146
|
+
call_id="1",
|
|
147
|
+
instance=None,
|
|
148
|
+
owner=None,
|
|
149
|
+
cls=type("Svc", (), {}),
|
|
150
|
+
cls_name="Svc",
|
|
151
|
+
method_name="run",
|
|
152
|
+
qualname="Svc.run",
|
|
153
|
+
method_kind="instance",
|
|
154
|
+
args=(),
|
|
155
|
+
kwargs={},
|
|
156
|
+
phase="after",
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
self.assertEqual(seen, ["run"])
|
|
160
|
+
|
|
161
|
+
def test_callback_multiple_filters_all_must_match(self) -> None:
|
|
162
|
+
bus = ObserverBus()
|
|
163
|
+
seen: list[int] = []
|
|
164
|
+
|
|
165
|
+
@bus.callback(phase="after", method_name="run")
|
|
166
|
+
def cb(_: ObserverContext) -> None:
|
|
167
|
+
seen.append(1)
|
|
168
|
+
|
|
169
|
+
bus.emit(
|
|
170
|
+
ObserverContext(
|
|
171
|
+
call_id="1",
|
|
172
|
+
instance=None,
|
|
173
|
+
owner=None,
|
|
174
|
+
cls=object,
|
|
175
|
+
cls_name="object",
|
|
176
|
+
method_name="other",
|
|
177
|
+
qualname="object.other",
|
|
178
|
+
method_kind="static",
|
|
179
|
+
args=(),
|
|
180
|
+
kwargs={},
|
|
181
|
+
phase="after",
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
self.assertEqual(seen, [])
|
|
185
|
+
|
|
186
|
+
bus.emit(
|
|
187
|
+
ObserverContext(
|
|
188
|
+
call_id="2",
|
|
189
|
+
instance=None,
|
|
190
|
+
owner=None,
|
|
191
|
+
cls=object,
|
|
192
|
+
cls_name="object",
|
|
193
|
+
method_name="run",
|
|
194
|
+
qualname="object.run",
|
|
195
|
+
method_kind="static",
|
|
196
|
+
args=(),
|
|
197
|
+
kwargs={},
|
|
198
|
+
phase="after",
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
self.assertEqual(seen, [1])
|
|
202
|
+
|
|
203
|
+
def test_callback_decorator_rejects_invalid_filter_keys(self) -> None:
|
|
204
|
+
bus = ObserverBus()
|
|
205
|
+
|
|
206
|
+
with self.assertRaises(ValueError) as ar:
|
|
207
|
+
@bus.callback(not_a_context_field=1)
|
|
208
|
+
def cb(ctx: ObserverContext) -> None:
|
|
209
|
+
pass
|
|
210
|
+
|
|
211
|
+
self.assertIn("invalid callback filters", str(ar.exception))
|
|
212
|
+
|
|
213
|
+
def test_emit_listener_exception_does_not_propagate(self) -> None:
|
|
214
|
+
bus = ObserverBus()
|
|
215
|
+
|
|
216
|
+
def bad(_: ObserverContext) -> None:
|
|
217
|
+
raise RuntimeError("boom")
|
|
218
|
+
|
|
219
|
+
bus.subscribe(bad)
|
|
220
|
+
|
|
221
|
+
ctx = ObserverContext(
|
|
222
|
+
call_id="1",
|
|
223
|
+
instance=None,
|
|
224
|
+
owner=None,
|
|
225
|
+
cls=object,
|
|
226
|
+
cls_name="object",
|
|
227
|
+
method_name="m",
|
|
228
|
+
qualname="m",
|
|
229
|
+
method_kind="static",
|
|
230
|
+
args=(),
|
|
231
|
+
kwargs={},
|
|
232
|
+
)
|
|
233
|
+
bus.emit(ctx)
|
|
234
|
+
|
|
235
|
+
def test_emit_order_matches_subscription_order(self) -> None:
|
|
236
|
+
bus = ObserverBus()
|
|
237
|
+
order: list[int] = []
|
|
238
|
+
|
|
239
|
+
def first(_: ObserverContext) -> None:
|
|
240
|
+
order.append(1)
|
|
241
|
+
|
|
242
|
+
def second(_: ObserverContext) -> None:
|
|
243
|
+
order.append(2)
|
|
244
|
+
|
|
245
|
+
bus.subscribe(first)
|
|
246
|
+
bus.subscribe(second)
|
|
247
|
+
bus.emit(
|
|
248
|
+
ObserverContext(
|
|
249
|
+
call_id="1",
|
|
250
|
+
instance=None,
|
|
251
|
+
owner=None,
|
|
252
|
+
cls=object,
|
|
253
|
+
cls_name="object",
|
|
254
|
+
method_name="m",
|
|
255
|
+
qualname="m",
|
|
256
|
+
method_kind="static",
|
|
257
|
+
args=(),
|
|
258
|
+
kwargs={},
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
self.assertEqual(order, [1, 2])
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class ObserveMethodsTests(unittest.TestCase):
|
|
265
|
+
def test_instance_method_emits_before_and_after(self) -> None:
|
|
266
|
+
bus = ObserverBus()
|
|
267
|
+
phases: list[str] = []
|
|
268
|
+
|
|
269
|
+
bus.subscribe(lambda ctx: phases.append(ctx.phase))
|
|
270
|
+
|
|
271
|
+
@observe_methods(bus)
|
|
272
|
+
class Svc:
|
|
273
|
+
def add(self, a: int, b: int) -> int:
|
|
274
|
+
return a + b
|
|
275
|
+
|
|
276
|
+
self.assertEqual(Svc().add(1, 2), 3)
|
|
277
|
+
self.assertEqual(phases, ["before", "after"])
|
|
278
|
+
|
|
279
|
+
def test_emit_before_false_skips_before_phase(self) -> None:
|
|
280
|
+
bus = ObserverBus()
|
|
281
|
+
phases: list[str] = []
|
|
282
|
+
|
|
283
|
+
bus.subscribe(lambda ctx: phases.append(ctx.phase))
|
|
284
|
+
|
|
285
|
+
@observe_methods(bus, emit_before=False)
|
|
286
|
+
class Svc:
|
|
287
|
+
def add(self, a: int, b: int) -> int:
|
|
288
|
+
return a + b
|
|
289
|
+
|
|
290
|
+
self.assertEqual(Svc().add(1, 2), 3)
|
|
291
|
+
self.assertEqual(phases, ["after"])
|
|
292
|
+
|
|
293
|
+
def test_same_call_id_across_phases_for_sync_call(self) -> None:
|
|
294
|
+
bus = ObserverBus()
|
|
295
|
+
ids: list[str] = []
|
|
296
|
+
|
|
297
|
+
bus.subscribe(lambda ctx: ids.append(ctx.call_id))
|
|
298
|
+
|
|
299
|
+
@observe_methods(bus)
|
|
300
|
+
class Svc:
|
|
301
|
+
def add(self, a: int, b: int) -> int:
|
|
302
|
+
return a + b
|
|
303
|
+
|
|
304
|
+
Svc().add(1, 2)
|
|
305
|
+
self.assertEqual(len(ids), 2)
|
|
306
|
+
self.assertEqual(ids[0], ids[1])
|
|
307
|
+
|
|
308
|
+
def test_error_phase_then_exception_reraised(self) -> None:
|
|
309
|
+
bus = ObserverBus()
|
|
310
|
+
phases: list[str] = []
|
|
311
|
+
|
|
312
|
+
bus.subscribe(lambda ctx: phases.append(ctx.phase))
|
|
313
|
+
|
|
314
|
+
@observe_methods(bus)
|
|
315
|
+
class Svc:
|
|
316
|
+
def boom(self) -> None:
|
|
317
|
+
raise ValueError("x")
|
|
318
|
+
|
|
319
|
+
with self.assertRaises(ValueError):
|
|
320
|
+
Svc().boom()
|
|
321
|
+
|
|
322
|
+
self.assertEqual(phases, ["before", "error"])
|
|
323
|
+
|
|
324
|
+
def test_classmethod_context_owner_and_pure_args(self) -> None:
|
|
325
|
+
bus = ObserverBus()
|
|
326
|
+
snapshots: list[tuple[Any, Any, tuple[Any, ...]]] = []
|
|
327
|
+
|
|
328
|
+
def capture(ctx: ObserverContext) -> None:
|
|
329
|
+
if ctx.phase == "after":
|
|
330
|
+
snapshots.append((ctx.instance, ctx.owner, ctx.args))
|
|
331
|
+
|
|
332
|
+
bus.subscribe(capture)
|
|
333
|
+
|
|
334
|
+
@observe_methods(bus)
|
|
335
|
+
class Svc:
|
|
336
|
+
@classmethod
|
|
337
|
+
def build(cls, name: str) -> Svc:
|
|
338
|
+
return cls()
|
|
339
|
+
|
|
340
|
+
inst = Svc.build("n")
|
|
341
|
+
self.assertIsInstance(inst, Svc)
|
|
342
|
+
self.assertEqual(len(snapshots), 1)
|
|
343
|
+
instance, owner, args = snapshots[0]
|
|
344
|
+
self.assertIsNone(instance)
|
|
345
|
+
self.assertIs(owner, Svc)
|
|
346
|
+
self.assertEqual(args, ("n",))
|
|
347
|
+
|
|
348
|
+
def test_staticmethod_context(self) -> None:
|
|
349
|
+
bus = ObserverBus()
|
|
350
|
+
kinds: list[str] = []
|
|
351
|
+
owners: list[Any] = []
|
|
352
|
+
|
|
353
|
+
def capture(ctx: ObserverContext) -> None:
|
|
354
|
+
if ctx.phase == "after":
|
|
355
|
+
kinds.append(ctx.method_kind)
|
|
356
|
+
owners.append(ctx.owner)
|
|
357
|
+
|
|
358
|
+
bus.subscribe(capture)
|
|
359
|
+
|
|
360
|
+
@observe_methods(bus)
|
|
361
|
+
class Svc:
|
|
362
|
+
@staticmethod
|
|
363
|
+
def ping(msg: str) -> str:
|
|
364
|
+
return msg
|
|
365
|
+
|
|
366
|
+
self.assertEqual(Svc.ping("hi"), "hi")
|
|
367
|
+
self.assertEqual(kinds, ["static"])
|
|
368
|
+
self.assertIsNone(owners[0])
|
|
369
|
+
|
|
370
|
+
def test_property_not_wrapped(self) -> None:
|
|
371
|
+
bus = ObserverBus()
|
|
372
|
+
|
|
373
|
+
@observe_methods(bus)
|
|
374
|
+
class Svc:
|
|
375
|
+
@property
|
|
376
|
+
def x(self) -> int:
|
|
377
|
+
return 1
|
|
378
|
+
|
|
379
|
+
self.assertIsInstance(Svc.x, property)
|
|
380
|
+
|
|
381
|
+
def test_private_method_skipped_by_default(self) -> None:
|
|
382
|
+
bus = ObserverBus()
|
|
383
|
+
method_names: list[str] = []
|
|
384
|
+
|
|
385
|
+
bus.subscribe(lambda ctx: method_names.append(ctx.method_name))
|
|
386
|
+
|
|
387
|
+
@observe_methods(bus)
|
|
388
|
+
class Svc:
|
|
389
|
+
def _hidden(self) -> int:
|
|
390
|
+
return 2
|
|
391
|
+
|
|
392
|
+
def ok(self) -> int:
|
|
393
|
+
return self._hidden()
|
|
394
|
+
|
|
395
|
+
self.assertEqual(Svc().ok(), 2)
|
|
396
|
+
self.assertEqual(method_names, ["ok", "ok"])
|
|
397
|
+
self.assertNotIn("_hidden", method_names)
|
|
398
|
+
|
|
399
|
+
def test_include_private_wraps_dunder_named_callable(self) -> None:
|
|
400
|
+
bus = ObserverBus()
|
|
401
|
+
method_names: list[str] = []
|
|
402
|
+
|
|
403
|
+
bus.subscribe(lambda ctx: method_names.append(ctx.method_name))
|
|
404
|
+
|
|
405
|
+
@observe_methods(bus, include_private=True)
|
|
406
|
+
class Svc:
|
|
407
|
+
def _hidden(self) -> int:
|
|
408
|
+
return 7
|
|
409
|
+
|
|
410
|
+
self.assertEqual(Svc()._hidden(), 7)
|
|
411
|
+
self.assertIn("_hidden", method_names)
|
|
412
|
+
|
|
413
|
+
def test_subclass_methods_observed(self) -> None:
|
|
414
|
+
bus = ObserverBus()
|
|
415
|
+
names: list[str] = []
|
|
416
|
+
|
|
417
|
+
bus.subscribe(lambda ctx: names.append(ctx.method_name) if ctx.phase == "after" else None)
|
|
418
|
+
|
|
419
|
+
@observe_methods(bus)
|
|
420
|
+
class Base:
|
|
421
|
+
def a(self) -> int:
|
|
422
|
+
return 1
|
|
423
|
+
|
|
424
|
+
class Child(Base):
|
|
425
|
+
def b(self) -> int:
|
|
426
|
+
return 2
|
|
427
|
+
|
|
428
|
+
self.assertEqual(Child().a(), 1)
|
|
429
|
+
self.assertEqual(Child().b(), 2)
|
|
430
|
+
self.assertIn("a", names)
|
|
431
|
+
self.assertIn("b", names)
|
|
432
|
+
|
|
433
|
+
def test_instance_context_cls_is_runtime_type(self) -> None:
|
|
434
|
+
bus = ObserverBus()
|
|
435
|
+
cls_names: list[str] = []
|
|
436
|
+
|
|
437
|
+
bus.subscribe(
|
|
438
|
+
lambda ctx: cls_names.append(ctx.cls_name) if ctx.phase == "after" else None
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
@observe_methods(bus)
|
|
442
|
+
class Base:
|
|
443
|
+
def tag(self) -> str:
|
|
444
|
+
return "base"
|
|
445
|
+
|
|
446
|
+
class Child(Base):
|
|
447
|
+
def tag(self) -> str:
|
|
448
|
+
return "child"
|
|
449
|
+
|
|
450
|
+
self.assertEqual(Base().tag(), "base")
|
|
451
|
+
self.assertEqual(Child().tag(), "child")
|
|
452
|
+
self.assertEqual(cls_names, ["Base", "Child"])
|
|
453
|
+
|
|
454
|
+
def test_double_decorate_does_not_double_wrap(self) -> None:
|
|
455
|
+
bus = ObserverBus()
|
|
456
|
+
phases: list[str] = []
|
|
457
|
+
|
|
458
|
+
bus.subscribe(lambda ctx: phases.append(ctx.phase))
|
|
459
|
+
|
|
460
|
+
@observe_methods(bus)
|
|
461
|
+
@observe_methods(bus)
|
|
462
|
+
class Svc:
|
|
463
|
+
def run(self) -> int:
|
|
464
|
+
return 1
|
|
465
|
+
|
|
466
|
+
self.assertEqual(Svc().run(), 1)
|
|
467
|
+
self.assertEqual(phases, ["before", "after"])
|
|
468
|
+
|
|
469
|
+
def test_bus_observe_delegates_to_observe_methods(self) -> None:
|
|
470
|
+
bus = ObserverBus()
|
|
471
|
+
phases: list[str] = []
|
|
472
|
+
|
|
473
|
+
bus.subscribe(lambda ctx: phases.append(ctx.phase))
|
|
474
|
+
|
|
475
|
+
@bus.observe()
|
|
476
|
+
class Svc:
|
|
477
|
+
def run(self) -> int:
|
|
478
|
+
return 5
|
|
479
|
+
|
|
480
|
+
self.assertEqual(Svc().run(), 5)
|
|
481
|
+
self.assertEqual(phases, ["before", "after"])
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
class AsyncObserveMethodsTests(unittest.TestCase):
|
|
485
|
+
def test_async_instance_method_phases(self) -> None:
|
|
486
|
+
bus = ObserverBus()
|
|
487
|
+
phases: list[str] = []
|
|
488
|
+
|
|
489
|
+
bus.subscribe(lambda ctx: phases.append(ctx.phase))
|
|
490
|
+
|
|
491
|
+
@observe_methods(bus)
|
|
492
|
+
class Svc:
|
|
493
|
+
async def fetch(self, n: int) -> int:
|
|
494
|
+
await asyncio.sleep(0)
|
|
495
|
+
return n * 2
|
|
496
|
+
|
|
497
|
+
async def main() -> int:
|
|
498
|
+
return await Svc().fetch(3)
|
|
499
|
+
|
|
500
|
+
self.assertEqual(asyncio.run(main()), 6)
|
|
501
|
+
self.assertEqual(phases, ["before", "after"])
|
|
502
|
+
|
|
503
|
+
def test_async_error_phase(self) -> None:
|
|
504
|
+
bus = ObserverBus()
|
|
505
|
+
phases: list[str] = []
|
|
506
|
+
|
|
507
|
+
bus.subscribe(lambda ctx: phases.append(ctx.phase))
|
|
508
|
+
|
|
509
|
+
@observe_methods(bus)
|
|
510
|
+
class Svc:
|
|
511
|
+
async def boom(self) -> None:
|
|
512
|
+
raise OSError("async")
|
|
513
|
+
|
|
514
|
+
async def main() -> None:
|
|
515
|
+
await Svc().boom()
|
|
516
|
+
|
|
517
|
+
with self.assertRaises(OSError):
|
|
518
|
+
asyncio.run(main())
|
|
519
|
+
|
|
520
|
+
self.assertEqual(phases, ["before", "error"])
|
|
521
|
+
|
|
522
|
+
def test_async_emit_before_false(self) -> None:
|
|
523
|
+
bus = ObserverBus()
|
|
524
|
+
phases: list[str] = []
|
|
525
|
+
|
|
526
|
+
bus.subscribe(lambda ctx: phases.append(ctx.phase))
|
|
527
|
+
|
|
528
|
+
@observe_methods(bus, emit_before=False)
|
|
529
|
+
class Svc:
|
|
530
|
+
async def fetch(self) -> int:
|
|
531
|
+
return 9
|
|
532
|
+
|
|
533
|
+
async def main() -> int:
|
|
534
|
+
return await Svc().fetch()
|
|
535
|
+
|
|
536
|
+
self.assertEqual(asyncio.run(main()), 9)
|
|
537
|
+
self.assertEqual(phases, ["after"])
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
class InitSubclassChainTests(unittest.TestCase):
|
|
541
|
+
def test_preserves_existing_init_subclass(self) -> None:
|
|
542
|
+
bus = ObserverBus()
|
|
543
|
+
log: list[str] = []
|
|
544
|
+
|
|
545
|
+
class Base:
|
|
546
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
547
|
+
super().__init_subclass__(**kwargs)
|
|
548
|
+
log.append("base_hook")
|
|
549
|
+
|
|
550
|
+
@observe_methods(bus)
|
|
551
|
+
class Observed(Base):
|
|
552
|
+
def run(self) -> None:
|
|
553
|
+
pass
|
|
554
|
+
|
|
555
|
+
class Child(Observed):
|
|
556
|
+
pass
|
|
557
|
+
|
|
558
|
+
self.assertIn("base_hook", log)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
if __name__ == "__main__":
|
|
562
|
+
unittest.main()
|