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.
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ .env
9
+ .pytest_cache/
10
+ config.yaml
11
+ logs/
@@ -0,0 +1,4 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-library-observer
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.10
@@ -0,0 +1,11 @@
1
+ @echo off
2
+ cd /d %~dp0
3
+
4
+ if not exist .venv (
5
+ python -m venv .venv
6
+ )
7
+
8
+ call .venv\Scripts\activate.bat
9
+ python -m pip install -e .
10
+
11
+ python example.py
@@ -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,9 @@
1
+ from .bus import ObserverBus
2
+ from .context import ObserverContext
3
+ from .deractor import observe_methods
4
+
5
+ __all__ = [
6
+ "ObserverBus",
7
+ "ObserverContext",
8
+ "observe_methods",
9
+ ]
@@ -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"]
@@ -0,0 +1,10 @@
1
+ @echo off
2
+ cd /d %~dp0
3
+
4
+ if not exist .venv (
5
+ python -m venv .venv
6
+ )
7
+
8
+ call .venv\Scripts\activate.bat
9
+ python -m pip install -e .
10
+ python -m unittest discover -s tests -p "test_*.py"
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()