Coverage for core / src / sensorkit / core / impl / entity.py: 97%

121 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-02 00:03 +0000

1# SPDX-License-Identifier: Apache-2.0 

2from __future__ import annotations 

3 

4import asyncio 

5import contextlib 

6from _contextvars import ContextVar 

7from typing import TYPE_CHECKING, Any, Callable, ClassVar, Iterable, Literal, Self, final, override 

8 

9from loguru import logger 

10from pydantic import BaseModel, TypeAdapter 

11 

12from sensorkit.backend.base import Entity, KVError, SpecialProperty 

13from sensorkit.backend.event import Event 

14from sensorkit.backend.request import ExtendedHandlerFunc, HandlerFunc, Request 

15from sensorkit.common.keyword import Keyword, dump_keyword_json, get_keyword_info 

16from sensorkit.core.entity import EntityBase, EntityInfo, EntityInterface 

17from sensorkit.data.graph import DataGraph 

18 

19if TYPE_CHECKING: 

20 from sensorkit.core.client import SensorKit, ServiceContext 

21 

22 

23class EntityImpl(EntityBase, EntityInterface): 

24 """Object that exposes implementation-side functionality of an entity.""" 

25 

26 current: ClassVar[ContextVar[EntityImpl | None]] = ContextVar("current_entity", default=None) 

27 """The instance in the current execution context, if any.""" 

28 

29 @classmethod 

30 def for_service_context(cls, context: ServiceContext, entity: str) -> Self: 

31 """Return the entity in the current execution context.""" 

32 return cls( 

33 sensorkit=context.sensorkit(), 

34 entity=Entity.at(entity), 

35 task_group=context.task_group, 

36 ) 

37 

38 def __init__(self, sensorkit: SensorKit, entity: Entity, *, task_group: asyncio.TaskGroup): 

39 super().__init__(sensorkit, entity) 

40 self._data_graph: DataGraph | None = None 

41 self._task_group = task_group 

42 self._attach_hooks: list[Callable] = [] 

43 self._detach_hooks: list[Callable] = [] 

44 

45 async def init_impl(self): 

46 """Implements initialization of the entity. 

47 

48 This method is called after the backend is up and available, but before `attach` is run. 

49 Implementations will typically retrieve configuration, restore persisted state, and 

50 perform any other initialization to prepare resources required by user `on_attach` hooks. 

51 """ 

52 pass 

53 

54 def on_attach(self, func: Callable): 

55 """Register a callback to run when this entity is being attached.""" 

56 self._attach_hooks.append(func) 

57 return func 

58 

59 @final 

60 async def attach(self): 

61 """Attach the entity implementation to the running service context. 

62 

63 Runs all attach hooks and publishes entity info. An exception raised in any attach hook 

64 is propagated as fatal. 

65 """ 

66 await self._call_with_context(self._attach_hooks, error_policy="raise") 

67 

68 with self.enter_context(): 

69 await self.attach_impl() 

70 

71 await self.publish_entity_info() 

72 

73 async def attach_impl(self): 

74 """Implements "attach". Runs after the user attach hooks.""" 

75 pass 

76 

77 def on_detach(self, func: Callable): 

78 """Register a callback to run when this entity is being detached.""" 

79 self._detach_hooks.append(func) 

80 return func 

81 

82 @final 

83 async def detach(self): 

84 """Detach the entity implementation from the running service context. 

85 

86 Runs all detach hooks. An exception raised in the internal detach hook (`detach_impl`) is 

87 propagated as fatal, while exceptions from user detach hooks are logged. 

88 """ 

89 with self.enter_context(): 

90 await self.detach_impl() 

91 

92 for result in await self._call_with_context(self._detach_hooks, error_policy="ignore"): 

93 match result: 

94 case asyncio.CancelledError(): 

95 logger.warning(f"Cancelled during {self.entity} detach") 

96 case Exception(): 

97 logger.warning(f"Error during {self.entity} detach ({type(result).__name__})") 

98 logger.opt(exception=result).debug("detach hook raised") 

99 case BaseException(): 

100 raise result 

101 

102 async def detach_impl(self): 

103 """Implements "detach". Runs before the user detach hooks.""" 

104 pass 

105 

106 @contextlib.contextmanager 

107 def enter_context(self): 

108 """Store a reference to this entity in the current execution context.""" 

109 var = type(self).current 

110 token = var.set(self) 

111 

112 # Also set the base EntityImpl.current so sk.entity() resolves for all entity types. 

113 base_var = EntityImpl.current 

114 base_token = base_var.set(self) if var is not base_var else None 

115 

116 try: 

117 yield 

118 finally: 

119 var.reset(token) 

120 

121 if base_token is not None: 

122 base_var.reset(base_token) 

123 

124 @override 

125 @property 

126 def task_group(self): 

127 return self._task_group 

128 

129 @override 

130 async def emit_event(self, event: Event): 

131 """Serialize and publish the event to the entity's event stream subject.""" 

132 await self._stream.publish( 

133 SpecialProperty.EVENTS, 

134 event.model_dump_json().encode(), 

135 ) 

136 

137 @override 

138 async def publish(self, model: Keyword): 

139 """Serialize and publish a keyword model to the entity's data stream.""" 

140 if info := get_keyword_info(model): 

141 await self._stream.publish( 

142 info.key, 

143 dump_keyword_json(model), 

144 ) 

145 else: 

146 await self._stream.publish( 

147 model.__class__.__name__, 

148 model.model_dump_json().encode() 

149 if isinstance(model, BaseModel) 

150 else TypeAdapter(model).dump_json(model), 

151 ) 

152 

153 @override 

154 async def handle_request[P: BaseModel | None, R: BaseModel | None, V: BaseModel | None]( 

155 self, 

156 request: Request[P, R, V], 

157 func: HandlerFunc[P, R] | ExtendedHandlerFunc[P, R, V], 

158 ): 

159 """Register a handler for the given Request definition on the backend.""" 

160 await self._request.handle_request( 

161 request.name, 

162 request.create_handler(func, self._stream), 

163 ) 

164 

165 @override 

166 async def data_graph(self): 

167 """Return an AppSource if there is a DataGraph is associated with this entity.""" 

168 if not self._data_graph: 

169 with contextlib.suppress(KVError): 

170 dg = await self.kv_get_model(DataGraph) 

171 

172 # Make sure a concurrent call does not result in two graphs. 

173 if not self._data_graph: 

174 self._data_graph = dg 

175 self._data_graph.start(task_group=self.task_group) 

176 logger.info(f"Started DataGraph with {len(self._data_graph.nodes)} ops") 

177 

178 return self._data_graph 

179 

180 def entity_info(self) -> EntityInfo: 

181 """Build this entity's info record from its current state.""" 

182 return EntityInfo(entity_type="generic", details=None) 

183 

184 @override 

185 async def publish_entity_info(self) -> EntityInfo: 

186 """Publish this entity's EntityInfo entry to the KV store.""" 

187 info = self.entity_info() 

188 await self.kv_put_model(info) 

189 return info 

190 

191 async def _call_with_context( 

192 self, 

193 funcs: Iterable[Callable], 

194 args: tuple[Any, ...] = (), 

195 kwargs: dict[str, Any] | None = None, 

196 *, 

197 error_policy: Literal["raise", "log", "ignore"] = "log", 

198 ) -> list[Any]: 

199 """Invoke hooks concurrently within this entity's execution context.""" 

200 call_kwargs = kwargs if kwargs is not None else {} 

201 

202 async def invoke(func: Callable): 

203 aw = func(*args, **call_kwargs) 

204 return await aw if asyncio.iscoroutine(aw) else aw 

205 

206 with self.enter_context(): 

207 tasks = [asyncio.ensure_future(invoke(func)) for func in funcs] 

208 

209 if error_policy == "raise": 

210 # Fail fast: propagate the first hook exception immediately, cancelling any 

211 # still-running siblings so none are left orphaned on the loop. 

212 try: 

213 return await asyncio.gather(*tasks) 

214 finally: 

215 for task in tasks: 

216 task.cancel() 

217 await asyncio.gather(*tasks, return_exceptions=True) 

218 

219 results = await asyncio.gather(*tasks, return_exceptions=True) 

220 

221 if error_policy == "log": 

222 for result in results: 

223 match result: 

224 case Exception(): 

225 logger.opt(exception=result).debug("error in callback") 

226 case BaseException(): 

227 logger.debug(f"error in callback ({type(result).__name__})") 

228 

229 return results