Coverage for src/dev_shell/api_objtree.py: 90%

219 statements  

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

1"""ApiObjTree - API client mode's read-only `obj[...]`, built entirely from 

2Home Assistant's standard websocket API (get_states, config/*_registry/list) 

3- no dev_shell_server component needed, works against any instance an admin 

4token can reach. Bound to the same name, `obj`, as the live tree (see 

5apirepl.py) so a snippet that only touches `obj` runs unchanged in either 

6mode. Entities only have what that API exposes: state + attributes from 

7get_states, and whatever the entity registry's own partial dict exposes (no 

8live component instance, so no calling methods on it, no fields an 

9integration keeps off the registry/state). 

10 

11Mirrors custom_components/dev_shell_server/objtree.py's shape - __getitem__, 

12.keys()/.values()/.items(), find(), show() all behave the same way modulo the 

13smaller attribute set - but lives in this package rather than that one (a 

14HACS-deployed component with its own packaging boundary), so the handful of 

15small, stable, HA-import-free pieces it needs (the ordered-view mixin, 

16parse_path, the None/empty-collection cleanup) are duplicated here rather than 

17imported across that boundary. Revisit if API client mode sticks and a shared 

18package becomes worth the packaging work. 

19 

20Unlike the live tree, this one is explicitly a *snapshot*: fetching is async 

21(it's a websocket call) but Mapping's `__getitem__`/`__iter__`/`__len__` are 

22not, so refreshing can't happen lazily on access without risking asyncio 

23reentrancy (this is normally used from inside an already-running event loop). 

24Instead the owning REPL loop decides when to call `await cache.refresh()` 

25(e.g. between prompts, when `cache.is_stale()`) - see apirepl.py. `reset_cache()` 

26just marks the cache stale; it does not fetch. 

27""" 

28 

29from __future__ import annotations 

30 

31import time 

32from collections.abc import ( 

33 ItemsView, 

34 Iterable, 

35 Iterator, 

36 KeysView, 

37 Mapping, 

38 Sequence, 

39 ValuesView, 

40) 

41from dataclasses import dataclass 

42from datetime import datetime 

43from typing import Any, NamedTuple, Protocol 

44 

45 

46class _ApiClient(Protocol): 

47 """What Cache actually needs from a client - just enough to let tests use 

48 a lightweight fake instead of a real websocket Client.""" 

49 

50 async def call(self, type_: str, **payload: Any) -> Any: ... 

51 

52 

53def parse_path(path: str) -> tuple[str, ...]: 

54 """Split "/integration[/domain[/object_id]]" into 1-3 parts. Duplicated 

55 from custom_components/dev_shell_server/paths.py - see the module 

56 docstring for why.""" 

57 parts = tuple(p for p in path.split("/") if p) 

58 if not parts or len(parts) > 3: 58 ↛ 59line 58 didn't jump to line 59 because the condition on line 58 was never true

59 raise ValueError(f"{path!r} - expected /integration/domain/object_id") 

60 return parts 

61 

62 

63def _as_set(value: str | list[str] | None) -> set[str]: 

64 if value is None: 

65 return set() 

66 return {value} if isinstance(value, str) else set(value) 

67 

68 

69def _require_str(value: Any, what: str = "path") -> str: 

70 """A clear TypeError at the boundary beats a confusing one from deep 

71 inside parse_path - duplicated from objtree.py's identical helper, see 

72 that module for the rationale.""" 

73 if not isinstance(value, str): 

74 raise TypeError(f"{what} must be a str, not {type(value).__name__}") 

75 return value 

76 

77 

78class _OrderedView(Iterable[Any]): 

79 """Positional access/slicing on top of a MappingView's existing order - 

80 duplicated from objtree.py's identical mixin, see that module for the 

81 rationale.""" 

82 

83 def __getitem__(self, index: int | slice) -> Any: 

84 return tuple(self)[index] 

85 

86 def __reversed__(self) -> Iterator[Any]: 

87 return reversed(tuple(self)) 

88 

89 

90class OrderedKeysView(_OrderedView, KeysView, Sequence): 

91 pass 

92 

93 

94class OrderedValuesView(_OrderedView, ValuesView, Sequence): 

95 pass 

96 

97 

98class OrderedItemsView(_OrderedView, ItemsView, Sequence): # type: ignore[misc] # ty: ignore[invalid-method-override] 

99 # Sequence.__contains__(value) vs ItemsView.__contains__(item: tuple) is a 

100 # real static Liskov mismatch, but fine at runtime - both just delegate to 

101 # tuple(self).__contains__ via _OrderedView/Iterable, deliberately getting 

102 # both Set and Sequence behavior at once (see the module docstring). 

103 pass 

104 

105 

106@dataclass(frozen=True) 

107class ApiEntity: 

108 """One entity's worth of API client mode data - only what get_states and 

109 the entity registry's partial dict expose, not the live component instance. 

110 

111 Named to match the real Entity's own properties where there's a direct 

112 equivalent - state, state_attributes, name - per 

113 https://developers.home-assistant.io/docs/core/entity/. `name` comes from 

114 get_states' `friendly_name` attribute: that's already HA's own fully 

115 resolved result of computing Entity.name (registry override, device name, 

116 has_entity_name, etc.), published into the state machine for exactly this 

117 kind of external consumer - cheaper and more faithful than recomputing it 

118 from the pieces ourselves. `state_attributes` is everything else in that 

119 attributes dict; broader than the live property of the same name (which 

120 excludes things like unit_of_measurement that come from separate Entity 

121 properties), since the state machine doesn't preserve that distinction. 

122 """ 

123 

124 entity_id: str 

125 platform: str 

126 domain: str 

127 object_id: str 

128 state: str | None 

129 name: str | None 

130 state_attributes: dict[str, Any] 

131 area_id: str | None 

132 labels: frozenset[str] 

133 registry: dict[str, Any] 

134 

135 

136class _Found(NamedTuple): 

137 """Internal only - never returned from find()/find_paths()/find_names(), 

138 just the shared (path, entity) pair each of them projects from 

139 (.entity, .path, and .entity_id respectively) so the filtering logic in 

140 _find() lives in exactly one place. Any attribute other than path/entity 

141 falls through to `entity`, so e.g. found.entity_id works without 

142 found.entity.entity_id.""" 

143 

144 path: str 

145 entity: ApiEntity 

146 

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

148 return getattr(self.entity, name) 

149 

150 

151# The two registry JSON fields that are Unix-timestamp floats rather than the 

152# ISO datetime strings get_states already returns - see show()/_clean(). 

153_TIMESTAMP_FIELDS = frozenset({"created_at", "modified_at"}) 

154 

155 

156class Cache: 

157 """Fetches and holds one snapshot of states + registries. Refresh is 

158 driven by the REPL loop between commands - see the module docstring.""" 

159 

160 def __init__(self, client: _ApiClient, ttl: float) -> None: 

161 self.client = client 

162 self.ttl = ttl 

163 self._fetched_at: float | None = None 

164 self.entities: dict[str, ApiEntity] = {} 

165 self.areas: dict[str, dict[str, Any]] = {} 

166 self.labels: dict[str, dict[str, Any]] = {} 

167 

168 def is_stale(self) -> bool: 

169 return ( 

170 self._fetched_at is None or (time.monotonic() - self._fetched_at) > self.ttl 

171 ) 

172 

173 def reset(self) -> None: 

174 """Mark the cache stale. Doesn't fetch - see the module docstring.""" 

175 self._fetched_at = None 

176 

177 async def refresh(self) -> None: 

178 states = {s["entity_id"]: s for s in await self.client.call("get_states")} 

179 registry_entries = await self.client.call("config/entity_registry/list") 

180 devices = { 

181 d["id"]: d for d in await self.client.call("config/device_registry/list") 

182 } 

183 self.areas = { 

184 a["area_id"]: a for a in await self.client.call("config/area_registry/list") 

185 } 

186 self.labels = { 

187 l["label_id"]: l 

188 for l in await self.client.call("config/label_registry/list") 

189 } 

190 

191 entities: dict[str, ApiEntity] = {} 

192 for entry in registry_entries: 

193 entity_id = entry["entity_id"] 

194 domain, object_id = entity_id.split(".", 1) 

195 state = states.get(entity_id, {}) 

196 area_id = entry.get("area_id") 

197 if area_id is None and entry.get("device_id"): 

198 device = devices.get(entry["device_id"]) 

199 if device is not None: 199 ↛ 205line 199 didn't jump to line 205 because the condition on line 199 was always true

200 area_id = device.get("area_id") 

201 # friendly_name is already HA's own fully-resolved Entity.name, 

202 # published for exactly this kind of external consumer - see 

203 # ApiEntity's docstring. Copy the dict before popping: it's 

204 # `states[entity_id]`'s own attributes dict, not ours to mutate. 

205 attributes = dict(state.get("attributes", {})) 

206 name = attributes.pop("friendly_name", None) 

207 entities[entity_id] = ApiEntity( 

208 entity_id=entity_id, 

209 platform=entry["platform"], 

210 domain=domain, 

211 object_id=object_id, 

212 state=state.get("state"), 

213 name=name, 

214 state_attributes=attributes, 

215 area_id=area_id, 

216 labels=frozenset(entry.get("labels", ())), 

217 registry=entry, 

218 ) 

219 self.entities = entities 

220 self._fetched_at = time.monotonic() 

221 

222 

223def _resolve_from_cache( 

224 values: str | list[str] | None, 

225 by_id: dict[str, dict[str, Any]], 

226 kind: str, 

227 id_key: str, 

228) -> set[str]: 

229 """Resolve each of `values` (an id or a display name) to its canonical id. 

230 

231 Raises KeyError on anything that matches neither - a typo'd area/label 

232 name should fail loudly, not just quietly match nothing in find(). 

233 """ 

234 ids: set[str] = set() 

235 for value in _as_set(values): 

236 if value in by_id: 

237 ids.add(value) 

238 continue 

239 match = next( 

240 ( 

241 k 

242 for k, v in by_id.items() 

243 if v.get("name", "").casefold() == value.casefold() 

244 ), 

245 None, 

246 ) 

247 if match is None: 

248 raise KeyError(f"no such {kind}: {value!r}") 

249 ids.add(match) 

250 return ids 

251 

252 

253def _normalize(value: Any) -> Any: 

254 if isinstance(value, dict): 

255 return _clean(value) 

256 return value 

257 

258 

259def _clean(data: dict[str, Any]) -> dict[str, Any]: 

260 """Recursively drop None/empty-collection values and render the known 

261 timestamp fields as local ISO 8601 strings. 

262 

263 Empty *lists* are dropped here too, not just empty sets: labels/aliases 

264 etc. arrive over the wire as JSON lists (JSON has no set type), so a list 

265 is this mode's wire-equivalent of the live tree's empty-set fields - and 

266 keeping both modes behaving the same way is the point. 

267 """ 

268 cleaned: dict[str, Any] = {} 

269 for key, value in data.items(): 

270 if key in _TIMESTAMP_FIELDS and isinstance(value, (int, float)): 

271 value = datetime.fromtimestamp(value).astimezone().isoformat() 

272 else: 

273 value = _normalize(value) 

274 if value is None or value == set() or value == []: 

275 continue 

276 cleaned[key] = value 

277 return cleaned 

278 

279 

280@dataclass(frozen=True) 

281class ApiObjTree(Mapping[str, "ApiEntity | ApiObjTree"]): 

282 """API client mode's view of the object tree - see the module docstring.""" 

283 

284 cache: Cache 

285 integration: str | None = None 

286 domain: str | None = None 

287 

288 def reset_cache(self) -> None: 

289 self.cache.reset() 

290 

291 def __getitem__(self, key: str) -> ApiEntity | ApiObjTree: 

292 _require_str(key, "key") 

293 if "/" not in key and self.integration is None: 

294 entity = self.cache.entities.get(key) 

295 if entity is None: 295 ↛ 296line 295 didn't jump to line 296 because the condition on line 295 was never true

296 raise KeyError(key) 

297 return entity 

298 try: 

299 parts = parse_path(key) 

300 except ValueError as err: 

301 raise KeyError(str(err)) from None 

302 scope = tuple(p for p in (self.integration, self.domain) if p is not None) 

303 full = scope + parts 

304 if len(full) > 3: 304 ↛ 305line 304 didn't jump to line 305 because the condition on line 304 was never true

305 raise KeyError(key) 

306 if len(full) < 3: 

307 subtree = ApiObjTree(self.cache, *full) 

308 if not subtree: 

309 raise KeyError(key) 

310 return subtree 

311 integration, domain, object_id = full 

312 entity = self.cache.entities.get(f"{domain}.{object_id}") 

313 if entity is None or entity.platform != integration: 313 ↛ 314line 313 didn't jump to line 314 because the condition on line 313 was never true

314 raise KeyError(key) 

315 return entity 

316 

317 def _entries(self, prefix: tuple[str, ...]) -> Iterator[ApiEntity]: 

318 for entity in self.cache.entities.values(): 

319 if len(prefix) >= 1 and entity.platform != prefix[0]: 

320 continue 

321 if len(prefix) >= 2 and entity.domain != prefix[1]: 

322 continue 

323 if len(prefix) >= 3 and entity.object_id != prefix[2]: 323 ↛ 324line 323 didn't jump to line 324 because the condition on line 323 was never true

324 continue 

325 yield entity 

326 

327 def __iter__(self) -> Iterator[str]: 

328 scope = tuple(p for p in (self.integration, self.domain) if p is not None) 

329 children = { 

330 (entity.platform, entity.domain, entity.object_id)[len(scope)] 

331 for entity in self._entries(scope) 

332 } 

333 for child in sorted(children): 

334 yield "/" + child 

335 

336 def __len__(self) -> int: 

337 return sum(1 for _ in self) 

338 

339 def keys(self) -> OrderedKeysView: 

340 return OrderedKeysView(self) 

341 

342 def values(self) -> OrderedValuesView: 

343 return OrderedValuesView(self) 

344 

345 def items(self) -> OrderedItemsView: 

346 return OrderedItemsView(self) 

347 

348 def _find( 

349 self, 

350 path: str, 

351 *, 

352 platform: str | list[str] | None, 

353 domain: str | list[str] | None, 

354 area: str | list[str] | None, 

355 label: str | list[str] | None, 

356 ) -> Iterator[_Found]: 

357 """The real search, shared by find()/find_paths()/find_names() - each 

358 just projects a different field from the (path, entity) pairs this 

359 yields. `domain` matches the HA domain (e.g. "light") across 

360 integrations, the same way `platform` does for the owning 

361 integration; `area`/`label` each take an id or a display name. Each 

362 of the four ORs within itself when given a list, and they AND 

363 together. No order guarantee; wrap in sorted(...) if you want one. 

364 """ 

365 _require_str(path) 

366 scope = tuple(p for p in (self.integration, self.domain) if p is not None) 

367 try: 

368 prefix = scope if path in ("", "/") else scope + parse_path(path) 

369 except ValueError as err: 

370 raise KeyError(str(err)) from None 

371 if len(prefix) > 3: 371 ↛ 372line 371 didn't jump to line 372 because the condition on line 371 was never true

372 raise KeyError(path) 

373 

374 platforms = _as_set(platform) 

375 domains = _as_set(domain) 

376 area_ids = _resolve_from_cache(area, self.cache.areas, "area", "area_id") 

377 label_ids = _resolve_from_cache(label, self.cache.labels, "label", "label_id") 

378 

379 def _matches() -> Iterator[_Found]: 

380 for entity in self._entries(prefix): 

381 if platforms and entity.platform not in platforms: 

382 continue 

383 if domains and entity.domain not in domains: 

384 continue 

385 if area_ids and entity.area_id not in area_ids: 

386 continue 

387 if label_ids and not (entity.labels & label_ids): 

388 continue 

389 full_path = "/" + "/".join( 

390 (entity.platform, entity.domain, entity.object_id)[len(scope) :] 

391 ) 

392 yield _Found(full_path, entity) 

393 

394 return _matches() 

395 

396 def find( 

397 self, 

398 path: str = "/", 

399 *, 

400 platform: str | list[str] | None = None, 

401 domain: str | list[str] | None = None, 

402 area: str | list[str] | None = None, 

403 label: str | list[str] | None = None, 

404 raw: bool = False, 

405 ) -> Iterator[ApiEntity] | Iterator[dict[str, Any]]: 

406 """Every entity (or raw dict, if raw=True) matching the filters under 

407 `path` (relative to this view, "/" meaning this view's whole 

408 subtree) - flat, skipping the directory-style one-level-at-a-time 

409 grouping .keys()/indexing give you. find_paths()/find_names() are the 

410 same search with just the tree-path or entity_id strings, if that's 

411 all you want - see _find() for the filters. 

412 

413 `raw=True` yields the object as received instead of the typed, 

414 renamed-to-match-Entity ApiEntity view - exactly what show(path) 

415 would return for that path (the merged, cleaned registry+state 

416 dict). The dict's own "entity_id" key identifies which entity it 

417 came from. 

418 """ 

419 matches = self._find( 

420 path, platform=platform, domain=domain, area=area, label=label 

421 ) 

422 if raw: 

423 return (self.show(found.path) for found in matches) 

424 return (found.entity for found in matches) 

425 

426 def find_paths( 

427 self, 

428 path: str = "/", 

429 *, 

430 platform: str | list[str] | None = None, 

431 domain: str | list[str] | None = None, 

432 area: str | list[str] | None = None, 

433 label: str | list[str] | None = None, 

434 ) -> Iterator[str]: 

435 """Just the tree-path strings from find() (e.g. 

436 "/demo/light/kitchen_lights") - see _find() for the filters.""" 

437 return ( 

438 found.path 

439 for found in self._find( 

440 path, platform=platform, domain=domain, area=area, label=label 

441 ) 

442 ) 

443 

444 def find_names( 

445 self, 

446 path: str = "/", 

447 *, 

448 platform: str | list[str] | None = None, 

449 domain: str | list[str] | None = None, 

450 area: str | list[str] | None = None, 

451 label: str | list[str] | None = None, 

452 ) -> Iterator[str]: 

453 """Just the HA entity_id strings from find() (e.g. 

454 "light.kitchen_lights") - see _find() for the filters. Not the same 

455 as find_paths(): this is the flat entity_id, not this tree's 

456 /integration/domain/object_id path.""" 

457 return ( 

458 found.entity_id 

459 for found in self._find( 

460 path, platform=platform, domain=domain, area=area, label=label 

461 ) 

462 ) 

463 

464 def show(self, path: str) -> dict[str, Any]: 

465 """Same shape as the live tree's show() - the registry entry's own 

466 fields plus `state`/`state_attributes`, cleaned the same way (None/ 

467 empty-collection dropped, timestamps as local ISO 8601).""" 

468 _require_str(path) 

469 scope = tuple(p for p in (self.integration, self.domain) if p is not None) 

470 try: 

471 full = scope + parse_path(path) 

472 except ValueError as err: 

473 raise KeyError(str(err)) from None 

474 if len(full) != 3: 474 ↛ 475line 474 didn't jump to line 475 because the condition on line 474 was never true

475 raise KeyError(f"{path!r} - show() needs a full entity path") 

476 integration, domain, object_id = full 

477 entity = self.cache.entities.get(f"{domain}.{object_id}") 

478 if entity is None or entity.platform != integration: 478 ↛ 479line 478 didn't jump to line 479 because the condition on line 478 was never true

479 raise KeyError(path) 

480 

481 data: dict[str, Any] = dict(entity.registry) 

482 data["name"] = entity.name 

483 data["state"] = entity.state 

484 data["state_attributes"] = entity.state_attributes 

485 return _clean(data) 

486 

487 def __repr__(self) -> str: 

488 # "(api)" is just a display hint for a human reading output - the 

489 # binding name and API are identical to the live tree's on purpose. 

490 scope = "/".join(p for p in (self.integration, self.domain) if p is not None) 

491 label = f"obj:/{scope}" if scope else "obj" 

492 kind = ( 

493 "entities" 

494 if self.domain is not None 

495 else "domains" 

496 if self.integration 

497 else "integrations" 

498 ) 

499 return f"<{label}: {len(self)} {kind} (api)>"