Coverage for src / beautyspot / hooks.py: 83%

29 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-03-18 18:20 +0900

1# src/beautyspot/hooks.py 

2 

3import inspect 

4import functools 

5import threading 

6from collections.abc import Callable 

7from typing import Any 

8 

9from beautyspot.types import PreExecuteContext, CacheHitContext, CacheMissContext 

10 

11 

12class HookBase: 

13 """ 

14 beautyspotのタスク実行ライフサイクルに介入するためのベースクラス。 

15 ユーザーはこのクラスを継承し、必要なメソッドのみをオーバーライドして使用します。 

16 

17 Note: 

18 このクラスはスレッドセーフではありません。複数のスレッドから同時に 

19 同じフックインスタンスが呼ばれる可能性がある場合は、 

20 ``ThreadSafeHookBase`` を使用してください。 

21 """ 

22 

23 def pre_execute(self, context: PreExecuteContext) -> None: 

24 """関数実行(およびキャッシュ確認)の直前に呼び出されます。""" 

25 

26 def on_cache_hit(self, context: CacheHitContext) -> None: 

27 """キャッシュから値が正常に取得され、元の関数実行がスキップされた直後に呼び出されます。""" 

28 

29 def on_cache_miss(self, context: CacheMissContext) -> None: 

30 """元の関数が実行され結果が得られた直後に呼び出されます。 

31 

32 .. note:: 

33 このフックは関数実行の直後、キャッシュへの**永続化完了前**に呼ばれます。 

34 ``save_sync=False`` の場合、フック呼び出し後にバックグラウンドで 

35 保存が失敗する可能性があります。保存の成否を確認したい場合は 

36 ``on_save_error`` コールバックを使用してください。 

37 """ 

38 

39 

40def _wrap_with_lock(fn: Callable[..., Any]) -> Callable[..., Any]: 

41 """インスタンスの ``_lock`` で ``fn`` を保護するラッパーを返す。""" 

42 

43 @functools.wraps(fn) 

44 def wrapper(self: Any, context: Any) -> None: 

45 with self._lock: 

46 fn(self, context) 

47 

48 return wrapper 

49 

50 

51class ThreadSafeHookBase(HookBase): 

52 """スレッドセーフなフックベースクラス。 

53 

54 内部で ``threading.RLock`` を使用し、各コールバックの排他制御を行います。 

55 ``HookBase`` と同じメソッド名 (``pre_execute``, ``on_cache_hit``, 

56 ``on_cache_miss``) をオーバーライドするだけで使用できます。 

57 

58 .. note:: 

59 再入可能ロック (``RLock``) を使用しているため、 

60 サブクラスが ``super()`` 経由で親の同名メソッドを呼び出しても 

61 デッドロックしません。 

62 

63 Example:: 

64 

65 class MyHook(ThreadSafeHookBase): 

66 def __init__(self): 

67 super().__init__() 

68 self.count = 0 

69 

70 def pre_execute(self, context): 

71 self.count += 1 # ロックは自動適用される 

72 """ 

73 

74 _HOOK_METHODS: frozenset[str] = frozenset( 

75 name 

76 for name, _ in inspect.getmembers(HookBase, predicate=inspect.isfunction) 

77 if not name.startswith("__") 

78 ) 

79 

80 def __init_subclass__(cls, **kwargs: object) -> None: 

81 super().__init_subclass__(**kwargs) 

82 for name in ThreadSafeHookBase._HOOK_METHODS: 

83 if name in cls.__dict__: 83 ↛ 82line 83 didn't jump to line 82 because the condition on line 83 was always true

84 setattr(cls, name, _wrap_with_lock(cls.__dict__[name])) 

85 

86 def __init__(self) -> None: 

87 # Bug Fix: Lock → RLock 

88 # サブクラスが super() 経由で同名のラップ済みメソッドを呼び出すと、 

89 # 同一スレッドが同じロックを再取得しようとしてデッドロックする。 

90 # RLock (再入可能ロック) を使用することでこれを防ぐ。 

91 self._lock = threading.RLock() 

92 

93 def __getattr__(self, name: str) -> Any: 

94 if name == "_lock": 

95 raise AttributeError( 

96 f"'{type(self).__name__}._lock' is not initialized. " 

97 f"Did you forget to call super().__init__() in your __init__ method?" 

98 ) 

99 raise AttributeError( 

100 f"'{type(self).__name__}' object has no attribute '{name}'" 

101 )