Coverage for custom_components/ha_repl_server/objtree.py: 70%
263 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-10-05 13:50 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-10-05 13:50 +0000
1"""`obj[...]` - a live, read-only lookup over Home Assistant's entities.
3Entities only for now; the full integration/domain/area/label tree from the
4README's Object Browser design is a later step. Lookup forms:
6 obj["light.kitchen_lights"] # plain entity_id
7 obj["/demo/light/kitchen_lights"] # /integration/domain/object_id
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:
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
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.
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.
31`obj.mode("api")` switches that: entity lookups/find() return the same
32read-only, dict-shaped ApiEntity data API client mode's `obj` would give you
33(built locally from the live entity/registry, not a second round-trip
34through the API) instead of the live Entity object - e.g. to preview how a
35snippet will behave once ported to API client mode, or just to avoid live
36side effects. It's a session-wide switch, not a one-off: it persists on
37every view sharing this `obj`'s root until `obj.mode("live")` is called.
38`obj.mode()` with no argument returns the current mode.
40Only entities in the entity registry are reachable by the /integration/... path,
41since that's where the owning integration ("platform") is recorded; legacy YAML
42entities without a unique_id aren't registered there. Both lookup forms require a
43live Entity object backing the entity - true for everything set up the normal way
44through an EntityPlatform (YAML or config-entry), but not for a bare state set
45directly via hass.states.async_set() with no Entity subclass behind it (rare
46outside of tests/templates). See the README's Object Browser enhancements for the
47registry-less follow-up.
48"""
50from __future__ import annotations
52import re
53from collections.abc import (
54 Callable,
55 ItemsView,
56 Iterable,
57 Iterator,
58 KeysView,
59 Mapping,
60 Sequence,
61 ValuesView,
62)
63from dataclasses import dataclass, field
64from datetime import datetime
65from typing import Any, NamedTuple
67from homeassistant.core import HomeAssistant
68from homeassistant.helpers import area_registry as ar
69from homeassistant.helpers import device_registry as dr
70from homeassistant.helpers import entity_registry as er
71from homeassistant.helpers import label_registry as lr
72from homeassistant.helpers.entity import Entity
73from homeassistant.helpers.entity_platform import DATA_DOMAIN_ENTITIES
74from homeassistant.helpers.typing import UNDEFINED
75from homeassistant.util import dt as dt_util
77from .paths import parse_path
80def _get_entity(hass: HomeAssistant, entity_id: str) -> Entity | None:
81 """The live Entity instance behind entity_id, or None.
83 hass.data[DATA_DOMAIN_ENTITIES] is maintained by EntityPlatform itself (for
84 every platform, YAML or config-entry) as {domain: {entity_id: Entity}} - the
85 one lookup that's both centralized and independent of which integration or
86 setup style owns the entity. It's a private implementation detail of
87 homeassistant.helpers.entity_platform, not public API, so this is written
88 defensively (plain dict .get chains) rather than assuming its shape.
89 """
90 domain = entity_id.partition(".")[0]
91 return hass.data.get(DATA_DOMAIN_ENTITIES, {}).get(domain, {}).get(entity_id)
94def _as_set(value: str | list[str] | None) -> set[str]:
95 if value is None:
96 return set()
97 return {value} if isinstance(value, str) else set(value)
100# HA's own slugs (integration/domain/object_id) are always [a-z0-9_] - never any
101# of these - so a `find()` path containing one is unambiguously a regex, not a
102# literal path, with no risk of misreading a real path as a pattern.
103_REGEX_METACHARS = frozenset(".^$*+?{}[]|()\\")
106def _require_str(value: Any, what: str = "path") -> str:
107 """A clear TypeError at the boundary beats a confusing one from deep
108 inside parse_path (e.g. passing an Entity instead of its path - an easy
109 slip, since indexing returns one and it's natural to then pass it back
110 in somewhere a path string was wanted)."""
111 if not isinstance(value, str): 111 ↛ 112line 111 didn't jump to line 112 because the condition on line 111 was never true
112 raise TypeError(f"{what} must be a str, not {type(value).__name__}")
113 return value
116@dataclass(frozen=True)
117class ApiEntity:
118 """One entity's worth of `obj.mode("api")` data - the same read-only
119 subset of fields API client mode's own ApiEntity exposes (see
120 homeassistant_repl.api_objtree.ApiEntity), built here from the already-
121 live Entity/registry entry directly rather than a second round-trip
122 through the API. Not the live component instance: no calling methods on
123 it, no fields an integration keeps off the registry/state.
124 """
126 entity_id: str
127 platform: str
128 domain: str
129 object_id: str
130 state: str | None
131 name: str | None
132 state_attributes: dict[str, Any]
133 area_id: str | None
134 labels: frozenset[str]
135 registry: dict[str, Any]
138@dataclass
139class _Mode:
140 """Mutable, shared by reference across every ObjTree view derived from
141 the same root - a plain field on the (frozen, otherwise immutable)
142 ObjTree dataclass would only flip that one view, not "the rest of the
143 session" the way obj.mode(...) promises."""
145 api: bool = False
148class _Found(NamedTuple):
149 """Internal only - never returned from find()/find_paths()/find_names(),
150 just the shared (path, entity) pair each of them projects from
151 (.entity, .path, and .entity_id respectively) so the filtering logic in
152 _find() lives in exactly one place. Any attribute other than path/entity
153 falls through to `entity`, so e.g. found.entity_id works without
154 found.entity.entity_id."""
156 path: str
157 entity: Entity | ApiEntity
159 def __getattr__(self, name: str) -> Any:
160 return getattr(self.entity, name)
163def _resolve_ids(
164 values: str | list[str] | None,
165 kind: str,
166 by_id: Callable[[str], Any],
167 by_name: Callable[[str], Any],
168 id_attr: str,
169) -> set[str]:
170 """Resolve each of `values` (an id or a display name) to its canonical id.
172 Raises KeyError on anything that matches neither - a typo'd area/label
173 name should fail loudly, not just quietly match nothing in find().
174 """
175 ids: set[str] = set()
176 for value in _as_set(values): 176 ↛ 177line 176 didn't jump to line 177 because the loop on line 176 never started
177 found = by_id(value) or by_name(value)
178 if found is None:
179 raise KeyError(f"no such {kind}: {value!r}")
180 ids.add(getattr(found, id_attr))
181 return ids
184def _entity_area_id(entry: er.RegistryEntry, devices: dr.DeviceRegistry) -> str | None:
185 """The entry's own area, falling back to its device's - the same rule the
186 frontend uses to decide which area an entity "is in"."""
187 if entry.area_id is not None: 187 ↛ 188line 187 didn't jump to line 188 because the condition on line 187 was never true
188 return entry.area_id
189 if entry.device_id is not None: 189 ↛ 190line 189 didn't jump to line 190 because the condition on line 189 was never true
190 device = devices.async_get(entry.device_id)
191 if device is not None:
192 return device.area_id
193 return None
196def _public_attrs(obj: Any) -> dict[str, Any]:
197 """Every attrs-declared field on `obj`, minus _cache and anything else
198 marked private by the leading-underscore convention attrs itself uses for
199 fields like RegistryEntry._cache."""
200 return {
201 a.name: getattr(obj, a.name)
202 for a in getattr(obj, "__attrs_attrs__", ())
203 if not a.name.startswith("_")
204 }
207def _normalize(value: Any) -> Any:
208 if isinstance(value, datetime):
209 return dt_util.as_local(value).isoformat()
210 if isinstance(value, Mapping):
211 return _clean(dict(value))
212 return value
215def _clean(data: dict[str, Any]) -> dict[str, Any]:
216 """Recursively drop None/empty-set values and render datetimes as local
217 ISO 8601 strings - applied throughout, not just at the top level, since
218 things like state_attributes are themselves dicts that can hold either."""
219 cleaned = {}
220 for key, value in data.items():
221 value = _normalize(value)
222 if value is None or value == set():
223 continue
224 cleaned[key] = value
225 return cleaned
228class _OrderedView(Iterable[Any]):
229 """Adds positional access/slicing on top of a MappingView's existing order.
231 ObjTree.__iter__ already yields children in a meaningful (alphanumeric)
232 order, so the views built on it can afford to be real Sequences too - not
233 just the usual Collection (ValuesView) or Set (KeysView/ItemsView) - without
234 giving up set arithmetic or `isinstance(x, KeysView)` duck-typing: this is a
235 mixin, not a replacement, so a class using it keeps its MappingView base's
236 behaviour and only adds `__getitem__`/`__reversed__` on top.
238 Inherits Iterable (abstract, no `__iter__` of its own) only so type checkers
239 know `self` supports `tuple(self)` here - the concrete classes below supply
240 the real `__iter__` via their MappingView base, same as at runtime.
241 """
243 def __getitem__(self, index: int | slice) -> Any:
244 return tuple(self)[index]
246 def __reversed__(self) -> Iterator[Any]:
247 return reversed(tuple(self))
250class OrderedKeysView(_OrderedView, KeysView, Sequence):
251 pass
254class OrderedValuesView(_OrderedView, ValuesView, Sequence):
255 pass
258class OrderedItemsView(_OrderedView, ItemsView, Sequence): # type: ignore[misc] # ty: ignore[invalid-method-override]
259 # Sequence.__contains__(value) vs ItemsView.__contains__(item: tuple) is a
260 # real static Liskov mismatch, but fine at runtime - both just delegate to
261 # tuple(self).__contains__ via _OrderedView/Iterable, deliberately getting
262 # both Set and Sequence behavior at once.
263 pass
266@dataclass(frozen=True)
267class ObjTree(Mapping[str, "Entity | ApiEntity | ObjTree"]):
268 """A view of the object tree, optionally restricted to a subtree.
270 `integration` and/or `domain` pin this view to that part of the tree -
271 the way `obj["/alexa_devices"]` or `obj["/alexa_devices/media_player"]`
272 does. Indexing a restricted view only needs the remaining path segments,
273 given either as a single "a/b" string or one segment at a time.
275 Implementing collections.abc.Mapping (on top of __getitem__, __iter__ and
276 __len__) gets `in`, .get(), and real KeysView/ItemsView/ValuesView from
277 .keys()/.items()/.values() for free, consistent with any other dict-like
278 object - overridden below to also be Sequences, since every level in this
279 tree is naturally a list (see _OrderedView): `obj["/alexa_devices"].keys()[0]`
280 and slicing work, alongside the usual Set/Collection behaviour.
281 """
283 hass: HomeAssistant
284 integration: str | None = None
285 domain: str | None = None
286 # Shared by reference with every view derived from this one (see _Mode's
287 # docstring) - not compared/hashed, since it's a toggle, not identity.
288 _mode: _Mode = field(default_factory=_Mode, compare=False)
290 def mode(self, value: str | None = None) -> str | None:
291 """Switch between "live" (the default - indexing/find() return the
292 real, live Entity, exactly as component code sees it) and "api"
293 (the same read-only, dict-shaped ApiEntity data API client mode's
294 `obj` would give you) - see the module docstring. Persists on every
295 view sharing this obj's root until mode() is called again; with no
296 argument, returns the current mode instead of changing it.
297 """
298 if value is None:
299 return "api" if self._mode.api else "live"
300 if value not in ("live", "api"):
301 raise ValueError(f"mode must be 'live' or 'api', not {value!r}")
302 self._mode.api = value == "api"
303 return None
305 def __getitem__(self, key: str) -> Entity | ApiEntity | ObjTree:
306 _require_str(key, "key")
307 if (
308 "/" not in key
309 and "." in key
310 and self.integration is not None
311 and self.domain is None
312 ):
313 # Courtesy: a full entity_id ("domain.object_id") also works
314 # scoped to just an integration, not only at the root - translate
315 # to the equivalent "/"-path so the logic below (which already
316 # enforces the platform match via _entity_at/_api_entity_at)
317 # handles it the same way.
318 domain, _, object_id = key.partition(".")
319 key = f"{domain}/{object_id}"
320 if "/" not in key and self.integration is None:
321 if self._mode.api:
322 # No integration to check against here (that's the whole
323 # point of a bare entity_id) - just need entry+entity, not
324 # _api_entity_at's platform match.
325 entry = er.async_get(self.hass).async_get(key)
326 entity = _get_entity(self.hass, key) if entry is not None else None
327 if entry is None or entity is None: 327 ↛ 328line 327 didn't jump to line 328 because the condition on line 327 was never true
328 raise KeyError(key)
329 domain, _, object_id = key.partition(".")
330 return self._to_api_entity(entry, domain, object_id, entity)
331 entity = _get_entity(self.hass, key)
332 if entity is None: 332 ↛ 333line 332 didn't jump to line 333 because the condition on line 332 was never true
333 raise KeyError(key)
334 return entity
335 try:
336 parts = parse_path(key)
337 except ValueError as err:
338 raise KeyError(str(err)) from None
339 scope = tuple(p for p in (self.integration, self.domain) if p is not None)
340 full = scope + parts
341 if len(full) > 3: 341 ↛ 342line 341 didn't jump to line 342 because the condition on line 341 was never true
342 raise KeyError(key)
343 if len(full) < 3:
344 # Not `ObjTree(self.hass, *full, _mode=...)`: mypy can't tell a
345 # variable-length tuple unpack won't collide with a later keyword.
346 padded = full + (None,) * (2 - len(full))
347 subtree = ObjTree(self.hass, padded[0], padded[1], _mode=self._mode)
348 # Without this, `in`/.get() (Mapping's default __contains__ tries
349 # self[key]) would say yes to any made-up integration/domain name,
350 # disagreeing with .keys() - which only ever lists ones with entities.
351 if not subtree: 351 ↛ 352line 351 didn't jump to line 352 because the condition on line 351 was never true
352 raise KeyError(key)
353 return subtree
354 if self._mode.api:
355 api_entity = self._api_entity_at(*full)
356 if api_entity is None: 356 ↛ 357line 356 didn't jump to line 357 because the condition on line 356 was never true
357 raise KeyError(key)
358 return api_entity
359 entity = self._entity_at(*full)
360 if entity is None:
361 raise KeyError(key)
362 return entity
364 def _entity_at(
365 self, integration: str, domain: str, object_id: str
366 ) -> Entity | None:
367 entity_id = f"{domain}.{object_id}"
368 entry = er.async_get(self.hass).async_get(entity_id)
369 if entry is None or entry.platform != integration:
370 return None
371 return _get_entity(self.hass, entity_id)
373 def _api_entity_at(
374 self, integration: str, domain: str, object_id: str
375 ) -> ApiEntity | None:
376 entity_id = f"{domain}.{object_id}"
377 entry = er.async_get(self.hass).async_get(entity_id)
378 if entry is None or entry.platform != integration: 378 ↛ 379line 378 didn't jump to line 379 because the condition on line 378 was never true
379 return None
380 entity = _get_entity(self.hass, entity_id)
381 if entity is None: 381 ↛ 382line 381 didn't jump to line 382 because the condition on line 381 was never true
382 return None
383 return self._to_api_entity(entry, domain, object_id, entity)
385 def _to_api_entity(
386 self, entry: er.RegistryEntry, domain: str, object_id: str, entity: Entity
387 ) -> ApiEntity:
388 devices = dr.async_get(self.hass)
389 name = entity.name
390 return ApiEntity(
391 entity_id=entry.entity_id,
392 platform=entry.platform,
393 domain=domain,
394 object_id=object_id,
395 # Mirrors what /api/states actually publishes: both come back as
396 # plain strings there, not the live property's broader type
397 # (state: StateType, name: str | UndefinedType | None).
398 state=None if entity.state is None else str(entity.state),
399 name=None if name is UNDEFINED else name,
400 state_attributes=dict(entity.state_attributes or {}),
401 area_id=_entity_area_id(entry, devices),
402 labels=frozenset(entry.labels),
403 registry=_public_attrs(entry),
404 )
406 def _entries(
407 self, prefix: tuple[str, ...]
408 ) -> Iterator[tuple[er.RegistryEntry, str, str]]:
409 """Every (registry entry, domain, object_id) whose (platform, domain,
410 object_id) matches `prefix` position by position - `prefix` may have
411 0-3 elements, a narrower prefix just matching fewer positions. The
412 shared scan both __iter__ (one level, deduped) and find() (full depth,
413 flat) are built on.
414 """
415 registry = er.async_get(self.hass)
416 domain_entities = self.hass.data.get(DATA_DOMAIN_ENTITIES, {})
417 for domain, entities in domain_entities.items():
418 if len(prefix) >= 2 and domain != prefix[1]: 418 ↛ 419line 418 didn't jump to line 419 because the condition on line 418 was never true
419 continue
420 for entity_id in entities:
421 entry = registry.async_get(entity_id)
422 if entry is None: 422 ↛ 423line 422 didn't jump to line 423 because the condition on line 422 was never true
423 continue
424 if len(prefix) >= 1 and entry.platform != prefix[0]: 424 ↛ 425line 424 didn't jump to line 425 because the condition on line 424 was never true
425 continue
426 object_id = entity_id.split(".", 1)[1]
427 if len(prefix) >= 3 and object_id != prefix[2]: 427 ↛ 428line 427 didn't jump to line 428 because the condition on line 427 was never true
428 continue
429 yield entry, domain, object_id
431 def __iter__(self) -> Iterator[str]:
432 """The immediate child names one level down, alphanumeric order - a
433 directory listing, not a recursive flattening down to every entity
434 (that's find(), a different, explicitly flat view, not this one)."""
435 scope = tuple(p for p in (self.integration, self.domain) if p is not None)
436 children = {
437 (entry.platform, domain, object_id)[len(scope)]
438 for entry, domain, object_id in self._entries(scope)
439 }
440 yield from sorted(children)
442 def _find(
443 self,
444 path: str,
445 *,
446 platform: str | list[str] | None,
447 domain: str | list[str] | None,
448 area: str | list[str] | None,
449 label: str | list[str] | None,
450 ) -> Iterator[_Found]:
451 """The real search, shared by find()/find_paths()/find_names() - each
452 just projects a different field from the (path, entity) pairs this
453 yields. Skips anything with no live Entity object backing it - same
454 requirement as indexing - so an entity id from here always works if
455 passed back into this view.
457 `path` is normally an exact /integration/domain/object_id prefix, the
458 same as indexing - but a path containing a regex metacharacter (e.g.
459 "/mqtt/binary_sensor/barn.*") is matched as a regular expression
460 against each candidate's full path instead, anchored at the start
461 (so it behaves like a prefix match unless you anchor the end
462 yourself with `$`). That trades the usual early narrowing for a scan
463 of this view's whole subtree, filtered by the pattern.
465 `platform` matches the registry entry's platform (owning integration)
466 directly; `domain` matches the HA domain (e.g. "light"), letting you
467 search across integrations without fixing `path` to one; `area`/`label`
468 each take an id or a display name, and an entity's area falls back to
469 its device's when it has none of its own - the same rule the frontend
470 uses. Each of the four ORs within itself when given a list, and they
471 AND together. No order guarantee; wrap in sorted(...) if you want one.
472 """
473 _require_str(path)
474 scope = tuple(p for p in (self.integration, self.domain) if p is not None)
475 pattern = None
476 if path not in ("", "/") and _REGEX_METACHARS.intersection(path):
477 pattern = re.compile(path)
478 prefix = scope
479 else:
480 try:
481 prefix = scope if path in ("", "/") else scope + parse_path(path)
482 except ValueError as err:
483 raise KeyError(str(err)) from None
484 if len(prefix) > 3: 484 ↛ 485line 484 didn't jump to line 485 because the condition on line 484 was never true
485 raise KeyError(path)
487 platforms = _as_set(platform)
488 domains = _as_set(domain)
489 areas = ar.async_get(self.hass)
490 labels = lr.async_get(self.hass)
491 area_ids = _resolve_ids(
492 area, "area", areas.async_get_area, areas.async_get_area_by_name, "id"
493 )
494 label_ids = _resolve_ids(
495 label,
496 "label",
497 labels.async_get_label,
498 labels.async_get_label_by_name,
499 "label_id",
500 )
501 devices = dr.async_get(self.hass)
503 def _matches() -> Iterator[_Found]:
504 for entry, dom, object_id in self._entries(prefix):
505 if platforms and entry.platform not in platforms: 505 ↛ 506line 505 didn't jump to line 506 because the condition on line 505 was never true
506 continue
507 if domains and dom not in domains:
508 continue
509 if area_ids and _entity_area_id(entry, devices) not in area_ids: 509 ↛ 510line 509 didn't jump to line 510 because the condition on line 509 was never true
510 continue
511 if label_ids and not (entry.labels & label_ids): 511 ↛ 512line 511 didn't jump to line 512 because the condition on line 511 was never true
512 continue
513 entity = _get_entity(self.hass, entry.entity_id)
514 if entity is None: 514 ↛ 515line 514 didn't jump to line 515 because the condition on line 514 was never true
515 continue
516 full_path = "/" + "/".join(
517 (entry.platform, dom, object_id)[len(scope) :]
518 )
519 if pattern is not None and not pattern.match(full_path):
520 continue
521 found_entity = (
522 self._to_api_entity(entry, dom, object_id, entity)
523 if self._mode.api
524 else entity
525 )
526 yield _Found(full_path, found_entity)
528 return _matches()
530 def find(
531 self,
532 path: str = "/",
533 *,
534 platform: str | list[str] | None = None,
535 domain: str | list[str] | None = None,
536 area: str | list[str] | None = None,
537 label: str | list[str] | None = None,
538 raw: bool = False,
539 ) -> Iterator[Entity | ApiEntity] | Iterator[dict[str, Any]]:
540 """Every entity (or raw dict, if raw=True) matching the filters under
541 `path` (relative to this view, "/" meaning this view's whole
542 subtree) - flat, skipping the directory-style one-level-at-a-time
543 grouping .keys()/indexing give you. find_paths()/find_names() are the
544 same search with just the tree-path or entity_id strings, if that's
545 all you want - see _find() for the filters.
547 `raw=True` yields the object as received instead of the live Entity -
548 exactly what show(path) would return for that path (the registry
549 entry's fields plus state/state_attributes, cleaned the same way).
550 The dict's own "entity_id" key identifies which entity it came from.
551 """
552 matches = self._find(
553 path, platform=platform, domain=domain, area=area, label=label
554 )
555 if raw: 555 ↛ 556line 555 didn't jump to line 556 because the condition on line 555 was never true
556 return (self.show(found.path) for found in matches)
557 return (found.entity for found in matches)
559 def find_paths(
560 self,
561 path: str = "/",
562 *,
563 platform: str | list[str] | None = None,
564 domain: str | list[str] | None = None,
565 area: str | list[str] | None = None,
566 label: str | list[str] | None = None,
567 ) -> Iterator[str]:
568 """Just the tree-path strings from find() (e.g.
569 "/demo/light/kitchen_lights") - see _find() for the filters."""
570 return (
571 found.path
572 for found in self._find(
573 path, platform=platform, domain=domain, area=area, label=label
574 )
575 )
577 def find_names(
578 self,
579 path: str = "/",
580 *,
581 platform: str | list[str] | None = None,
582 domain: str | list[str] | None = None,
583 area: str | list[str] | None = None,
584 label: str | list[str] | None = None,
585 ) -> Iterator[str]:
586 """Just the HA entity_id strings from find() (e.g.
587 "light.kitchen_lights") - see _find() for the filters. Not the same
588 as find_paths(): this is the flat entity_id, not this tree's
589 /integration/domain/object_id path."""
590 return (
591 found.entity_id
592 for found in self._find(
593 path, platform=platform, domain=domain, area=area, label=label
594 )
595 )
597 def show(self, path: str) -> dict[str, Any]:
598 """The registry entry's own fields (minus _cache and other data this
599 hides by the leading-underscore convention) plus the live entity's
600 `state` and `state_attributes` - meant to be the trailing expression
601 at the REPL, so the usual Pretty-printed echo renders it; this doesn't
602 print anything itself. Datetimes come back as local ISO 8601 strings,
603 and None/empty-set values are dropped throughout, including inside
604 nested dicts like state_attributes.
605 """
606 _require_str(path)
607 scope = tuple(p for p in (self.integration, self.domain) if p is not None)
608 try:
609 full = scope + parse_path(path)
610 except ValueError as err:
611 raise KeyError(str(err)) from None
612 if len(full) != 3:
613 raise KeyError(f"{path!r} - show() needs a full entity path")
614 integration, domain, object_id = full
615 entity = self._entity_at(integration, domain, object_id)
616 if entity is None:
617 raise KeyError(path)
619 entry = er.async_get(self.hass).async_get(f"{domain}.{object_id}")
620 data: dict[str, Any] = _public_attrs(entry) if entry is not None else {}
621 # Overrides the registry's own (often-None, user-override-only) "name"
622 # field with the live, fully-resolved display name - the direct
623 # equivalent per https://developers.home-assistant.io/docs/core/entity/.
624 data["name"] = entity.name
625 data["state"] = entity.state
626 data["state_attributes"] = entity.state_attributes
627 return _clean(data)
629 def __len__(self) -> int:
630 return sum(1 for _ in self)
632 def keys(self) -> OrderedKeysView:
633 return OrderedKeysView(self)
635 def values(self) -> OrderedValuesView:
636 return OrderedValuesView(self)
638 def items(self) -> OrderedItemsView:
639 return OrderedItemsView(self)
641 def __repr__(self) -> str:
642 scope = "/".join(p for p in (self.integration, self.domain) if p is not None)
643 label = f"obj:/{scope}" if scope else "obj"
644 kind = (
645 "entities"
646 if self.domain is not None
647 else "domains"
648 if self.integration
649 else "integrations"
650 )
651 return f"<{label}: {len(self)} {kind}>"