Coverage for custom_components/dev_shell_server/objtree.py: 20%

201 statements  

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

1"""`obj[...]` - a live, read-only lookup over Home Assistant's entities. 

2 

3Entities only for now; the full integration/domain/area/label tree from the 

4README's Object Browser design is a later step. Lookup forms: 

5 

6 obj["light.kitchen_lights"] # plain entity_id 

7 obj["/demo/light/kitchen_lights"] # /integration/domain/object_id 

8 

9A path that doesn't reach all the way to an object_id instead returns an 

10ObjTree restricted to that part of the tree, so the object browser's 

11directories can be navigated a level at a time: 

12 

13 alexa = obj["/alexa_devices"] # restricted to that integration 

14 alexa["media_player"] # -> restricted to that domain too 

15 alexa["media_player/kitchen_show"] # -> the live entity 

16 

17`.keys()`/iteration mirror that: `alexa.keys()` lists just the domains directly 

18under `/alexa_devices` (one level, like `ls` on a directory), not every entity 

19underneath it recursively. `obj.find()` is the flat alternative - every 

20(path, entity) pair under a subtree, optionally filtered by 

21platform/domain/area/label; `obj.find_paths()` is the same search with just 

22the tree path strings (e.g. "/demo/light/kitchen_lights"), and 

23`obj.find_names()` with just the HA entity_id strings (e.g. 

24"light.kitchen_lights") instead. `obj.show(path)` renders one entity's 

25registry entry plus its live state. 

26 

27Both return the live Entity object - the actual LightEntity/SensorEntity/etc. 

28instance a component wrote, not the hass.states.get() State snapshot - since this 

29is strict mode: you use it exactly as you would from component code. 

30 

31Only entities in the entity registry are reachable by the /integration/... path, 

32since that's where the owning integration ("platform") is recorded; legacy YAML 

33entities without a unique_id aren't registered there. Both lookup forms require a 

34live Entity object backing the entity - true for everything set up the normal way 

35through an EntityPlatform (YAML or config-entry), but not for a bare state set 

36directly via hass.states.async_set() with no Entity subclass behind it (rare 

37outside of tests/templates). See the README's Object Browser enhancements for the 

38registry-less follow-up. 

39""" 

40 

41from __future__ import annotations 

42 

43from collections.abc import ( 

44 Callable, 

45 ItemsView, 

46 Iterable, 

47 Iterator, 

48 KeysView, 

49 Mapping, 

50 Sequence, 

51 ValuesView, 

52) 

53from dataclasses import dataclass 

54from datetime import datetime 

55from typing import Any, NamedTuple 

56 

57from homeassistant.core import HomeAssistant 

58from homeassistant.helpers import area_registry as ar 

59from homeassistant.helpers import device_registry as dr 

60from homeassistant.helpers import entity_registry as er 

61from homeassistant.helpers import label_registry as lr 

62from homeassistant.helpers.entity import Entity 

63from homeassistant.helpers.entity_platform import DATA_DOMAIN_ENTITIES 

64from homeassistant.util import dt as dt_util 

65 

66from .paths import parse_path 

67 

68 

69def _get_entity(hass: HomeAssistant, entity_id: str) -> Entity | None: 

70 """The live Entity instance behind entity_id, or None. 

71 

72 hass.data[DATA_DOMAIN_ENTITIES] is maintained by EntityPlatform itself (for 

73 every platform, YAML or config-entry) as {domain: {entity_id: Entity}} - the 

74 one lookup that's both centralized and independent of which integration or 

75 setup style owns the entity. It's a private implementation detail of 

76 homeassistant.helpers.entity_platform, not public API, so this is written 

77 defensively (plain dict .get chains) rather than assuming its shape. 

78 """ 

79 domain = entity_id.partition(".")[0] 

80 return hass.data.get(DATA_DOMAIN_ENTITIES, {}).get(domain, {}).get(entity_id) 

81 

82 

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

84 if value is None: 

85 return set() 

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

87 

88 

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

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

91 inside parse_path (e.g. passing an Entity instead of its path - an easy 

92 slip, since indexing returns one and it's natural to then pass it back 

93 in somewhere a path string was wanted).""" 

94 if not isinstance(value, str): 

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

96 return value 

97 

98 

99class _Found(NamedTuple): 

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

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

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

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

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

105 found.entity.entity_id.""" 

106 

107 path: str 

108 entity: Entity 

109 

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

111 return getattr(self.entity, name) 

112 

113 

114def _resolve_ids( 

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

116 kind: str, 

117 by_id: Callable[[str], Any], 

118 by_name: Callable[[str], Any], 

119 id_attr: str, 

120) -> set[str]: 

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

122 

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

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

125 """ 

126 ids: set[str] = set() 

127 for value in _as_set(values): 

128 found = by_id(value) or by_name(value) 

129 if found is None: 

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

131 ids.add(getattr(found, id_attr)) 

132 return ids 

133 

134 

135def _entity_area_id(entry: er.RegistryEntry, devices: dr.DeviceRegistry) -> str | None: 

136 """The entry's own area, falling back to its device's - the same rule the 

137 frontend uses to decide which area an entity "is in".""" 

138 if entry.area_id is not None: 

139 return entry.area_id 

140 if entry.device_id is not None: 

141 device = devices.async_get(entry.device_id) 

142 if device is not None: 

143 return device.area_id 

144 return None 

145 

146 

147def _public_attrs(obj: Any) -> dict[str, Any]: 

148 """Every attrs-declared field on `obj`, minus _cache and anything else 

149 marked private by the leading-underscore convention attrs itself uses for 

150 fields like RegistryEntry._cache.""" 

151 return { 

152 a.name: getattr(obj, a.name) 

153 for a in getattr(obj, "__attrs_attrs__", ()) 

154 if not a.name.startswith("_") 

155 } 

156 

157 

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

159 if isinstance(value, datetime): 

160 return dt_util.as_local(value).isoformat() 

161 if isinstance(value, Mapping): 

162 return _clean(dict(value)) 

163 return value 

164 

165 

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

167 """Recursively drop None/empty-set values and render datetimes as local 

168 ISO 8601 strings - applied throughout, not just at the top level, since 

169 things like state_attributes are themselves dicts that can hold either.""" 

170 cleaned = {} 

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

172 value = _normalize(value) 

173 if value is None or value == set(): 

174 continue 

175 cleaned[key] = value 

176 return cleaned 

177 

178 

179class _OrderedView(Iterable[Any]): 

180 """Adds positional access/slicing on top of a MappingView's existing order. 

181 

182 ObjTree.__iter__ already yields children in a meaningful (alphanumeric) 

183 order, so the views built on it can afford to be real Sequences too - not 

184 just the usual Collection (ValuesView) or Set (KeysView/ItemsView) - without 

185 giving up set arithmetic or `isinstance(x, KeysView)` duck-typing: this is a 

186 mixin, not a replacement, so a class using it keeps its MappingView base's 

187 behaviour and only adds `__getitem__`/`__reversed__` on top. 

188 

189 Inherits Iterable (abstract, no `__iter__` of its own) only so type checkers 

190 know `self` supports `tuple(self)` here - the concrete classes below supply 

191 the real `__iter__` via their MappingView base, same as at runtime. 

192 """ 

193 

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

195 return tuple(self)[index] 

196 

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

198 return reversed(tuple(self)) 

199 

200 

201class OrderedKeysView(_OrderedView, KeysView, Sequence): 

202 pass 

203 

204 

205class OrderedValuesView(_OrderedView, ValuesView, Sequence): 

206 pass 

207 

208 

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

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

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

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

213 # both Set and Sequence behavior at once. 

214 pass 

215 

216 

217@dataclass(frozen=True) 

218class ObjTree(Mapping[str, "Entity | ObjTree"]): 

219 """A view of the object tree, optionally restricted to a subtree. 

220 

221 `integration` and/or `domain` pin this view to that part of the tree - 

222 the way `obj["/alexa_devices"]` or `obj["/alexa_devices/media_player"]` 

223 does. Indexing a restricted view only needs the remaining path segments, 

224 given either as a single "a/b" string or one segment at a time. 

225 

226 Implementing collections.abc.Mapping (on top of __getitem__, __iter__ and 

227 __len__) gets `in`, .get(), and real KeysView/ItemsView/ValuesView from 

228 .keys()/.items()/.values() for free, consistent with any other dict-like 

229 object - overridden below to also be Sequences, since every level in this 

230 tree is naturally a list (see _OrderedView): `obj["/alexa_devices"].keys()[0]` 

231 and slicing work, alongside the usual Set/Collection behaviour. 

232 """ 

233 

234 hass: HomeAssistant 

235 integration: str | None = None 

236 domain: str | None = None 

237 

238 def __getitem__(self, key: str) -> Entity | ObjTree: 

239 _require_str(key, "key") 

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

241 entity = _get_entity(self.hass, key) 

242 if entity is None: 

243 raise KeyError(key) 

244 return entity 

245 try: 

246 parts = parse_path(key) 

247 except ValueError as err: 

248 raise KeyError(str(err)) from None 

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

250 full = scope + parts 

251 if len(full) > 3: 

252 raise KeyError(key) 

253 if len(full) < 3: 

254 subtree = ObjTree(self.hass, *full) 

255 # Without this, `in`/.get() (Mapping's default __contains__ tries 

256 # self[key]) would say yes to any made-up integration/domain name, 

257 # disagreeing with .keys() - which only ever lists ones with entities. 

258 if not subtree: 

259 raise KeyError(key) 

260 return subtree 

261 entity = self._entity_at(*full) 

262 if entity is None: 

263 raise KeyError(key) 

264 return entity 

265 

266 def _entity_at( 

267 self, integration: str, domain: str, object_id: str 

268 ) -> Entity | None: 

269 entity_id = f"{domain}.{object_id}" 

270 entry = er.async_get(self.hass).async_get(entity_id) 

271 if entry is None or entry.platform != integration: 

272 return None 

273 return _get_entity(self.hass, entity_id) 

274 

275 def _entries( 

276 self, prefix: tuple[str, ...] 

277 ) -> Iterator[tuple[er.RegistryEntry, str, str]]: 

278 """Every (registry entry, domain, object_id) whose (platform, domain, 

279 object_id) matches `prefix` position by position - `prefix` may have 

280 0-3 elements, a narrower prefix just matching fewer positions. The 

281 shared scan both __iter__ (one level, deduped) and find() (full depth, 

282 flat) are built on. 

283 """ 

284 registry = er.async_get(self.hass) 

285 domain_entities = self.hass.data.get(DATA_DOMAIN_ENTITIES, {}) 

286 for domain, entities in domain_entities.items(): 

287 if len(prefix) >= 2 and domain != prefix[1]: 

288 continue 

289 for entity_id in entities: 

290 entry = registry.async_get(entity_id) 

291 if entry is None: 

292 continue 

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

294 continue 

295 object_id = entity_id.split(".", 1)[1] 

296 if len(prefix) >= 3 and object_id != prefix[2]: 

297 continue 

298 yield entry, domain, object_id 

299 

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

301 """The immediate child names one level down, alphanumeric order - a 

302 directory listing, not a recursive flattening down to every entity 

303 (that's find(), a different, explicitly flat view, not this one).""" 

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

305 children = { 

306 (entry.platform, domain, object_id)[len(scope)] 

307 for entry, domain, object_id in self._entries(scope) 

308 } 

309 yield from sorted(children) 

310 

311 def _find( 

312 self, 

313 path: str, 

314 *, 

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

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

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

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

319 ) -> Iterator[_Found]: 

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

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

322 yields. Skips anything with no live Entity object backing it - same 

323 requirement as indexing - so an entity id from here always works if 

324 passed back into this view. 

325 

326 `platform` matches the registry entry's platform (owning integration) 

327 directly; `domain` matches the HA domain (e.g. "light"), letting you 

328 search across integrations without fixing `path` to one; `area`/`label` 

329 each take an id or a display name, and an entity's area falls back to 

330 its device's when it has none of its own - the same rule the frontend 

331 uses. Each of the four ORs within itself when given a list, and they 

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

333 """ 

334 _require_str(path) 

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

336 try: 

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

338 except ValueError as err: 

339 raise KeyError(str(err)) from None 

340 if len(prefix) > 3: 

341 raise KeyError(path) 

342 

343 platforms = _as_set(platform) 

344 domains = _as_set(domain) 

345 areas = ar.async_get(self.hass) 

346 labels = lr.async_get(self.hass) 

347 area_ids = _resolve_ids( 

348 area, "area", areas.async_get_area, areas.async_get_area_by_name, "id" 

349 ) 

350 label_ids = _resolve_ids( 

351 label, 

352 "label", 

353 labels.async_get_label, 

354 labels.async_get_label_by_name, 

355 "label_id", 

356 ) 

357 devices = dr.async_get(self.hass) 

358 

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

360 for entry, dom, object_id in self._entries(prefix): 

361 if platforms and entry.platform not in platforms: 

362 continue 

363 if domains and dom not in domains: 

364 continue 

365 if area_ids and _entity_area_id(entry, devices) not in area_ids: 

366 continue 

367 if label_ids and not (entry.labels & label_ids): 

368 continue 

369 entity = _get_entity(self.hass, entry.entity_id) 

370 if entity is None: 

371 continue 

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

373 (entry.platform, dom, object_id)[len(scope) :] 

374 ) 

375 yield _Found(full_path, entity) 

376 

377 return _matches() 

378 

379 def find( 

380 self, 

381 path: str = "/", 

382 *, 

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

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

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

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

387 raw: bool = False, 

388 ) -> Iterator[Entity] | Iterator[dict[str, Any]]: 

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

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

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

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

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

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

395 

396 `raw=True` yields the object as received instead of the live Entity - 

397 exactly what show(path) would return for that path (the registry 

398 entry's fields plus state/state_attributes, cleaned the same way). 

399 The dict's own "entity_id" key identifies which entity it came from. 

400 """ 

401 matches = self._find( 

402 path, platform=platform, domain=domain, area=area, label=label 

403 ) 

404 if raw: 

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

406 return (found.entity for found in matches) 

407 

408 def find_paths( 

409 self, 

410 path: str = "/", 

411 *, 

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

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

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

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

416 ) -> Iterator[str]: 

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

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

419 return ( 

420 found.path 

421 for found in self._find( 

422 path, platform=platform, domain=domain, area=area, label=label 

423 ) 

424 ) 

425 

426 def find_names( 

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 HA entity_id strings from find() (e.g. 

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

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

438 /integration/domain/object_id path.""" 

439 return ( 

440 found.entity_id 

441 for found in self._find( 

442 path, platform=platform, domain=domain, area=area, label=label 

443 ) 

444 ) 

445 

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

447 """The registry entry's own fields (minus _cache and other data this 

448 hides by the leading-underscore convention) plus the live entity's 

449 `state` and `state_attributes` - meant to be the trailing expression 

450 at the REPL, so the usual Pretty-printed echo renders it; this doesn't 

451 print anything itself. Datetimes come back as local ISO 8601 strings, 

452 and None/empty-set values are dropped throughout, including inside 

453 nested dicts like state_attributes. 

454 """ 

455 _require_str(path) 

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

457 try: 

458 full = scope + parse_path(path) 

459 except ValueError as err: 

460 raise KeyError(str(err)) from None 

461 if len(full) != 3: 

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

463 integration, domain, object_id = full 

464 entity = self._entity_at(integration, domain, object_id) 

465 if entity is None: 

466 raise KeyError(path) 

467 

468 entry = er.async_get(self.hass).async_get(f"{domain}.{object_id}") 

469 data: dict[str, Any] = _public_attrs(entry) if entry is not None else {} 

470 # Overrides the registry's own (often-None, user-override-only) "name" 

471 # field with the live, fully-resolved display name - the direct 

472 # equivalent per https://developers.home-assistant.io/docs/core/entity/. 

473 data["name"] = entity.name 

474 data["state"] = entity.state 

475 data["state_attributes"] = entity.state_attributes 

476 return _clean(data) 

477 

478 def __len__(self) -> int: 

479 return sum(1 for _ in self) 

480 

481 def keys(self) -> OrderedKeysView: 

482 return OrderedKeysView(self) 

483 

484 def values(self) -> OrderedValuesView: 

485 return OrderedValuesView(self) 

486 

487 def items(self) -> OrderedItemsView: 

488 return OrderedItemsView(self) 

489 

490 def __repr__(self) -> str: 

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

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

493 kind = ( 

494 "entities" 

495 if self.domain is not None 

496 else "domains" 

497 if self.integration 

498 else "integrations" 

499 ) 

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