homeassistant-repl 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ """Command line client for Home Assistant REPL."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .api_objtree import ApiObjTree, Cache
6
+ from .client import Client, HaReplError, resolve_token, resolve_url
7
+
8
+ __all__ = ["ApiObjTree", "Cache", "Client", "HaReplError", "connect"]
9
+
10
+
11
+ async def connect(
12
+ url: str | None = None, token: str | None = None, *, ttl: float = 30.0
13
+ ) -> ApiObjTree:
14
+ """Connect to Home Assistant's websocket API and return a ready-to-use
15
+ `obj` tree - the one-liner version of API client mode (see
16
+ apirepl.py/api_objtree.py for the pieces this wires together), for
17
+ scripts and plain Python sessions rather than the `ha-repl` REPL.
18
+
19
+ `url`/`token` default to $HASS_SERVER/$HASS_TOKEN (and, inside a Home
20
+ Assistant add-on, the supervisor) the same way the CLI does - see
21
+ resolve_url()/resolve_token(). `ttl` is how long the snapshot is used
22
+ before it's considered stale; this helper doesn't auto-refresh, so call
23
+ `await obj.cache.refresh()` yourself when you want a newer one.
24
+
25
+ The websocket stays open for the life of the process - call
26
+ `await obj.cache.client.close()` when you're done with it, if that
27
+ matters for your script.
28
+ """
29
+ client = await Client(resolve_url(url), resolve_token(token)).open()
30
+ cache = Cache(client, ttl)
31
+ await cache.refresh()
32
+ return ApiObjTree(cache)
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -0,0 +1,499 @@
1
+ """ApiObjTree - API client mode's read-only `obj[...]`, built entirely from
2
+ Home Assistant's standard websocket API (get_states, config/*_registry/list)
3
+ - no ha_repl_server component needed, works against any instance an admin
4
+ token can reach. Bound to the same name, `obj`, as the live tree (see
5
+ apirepl.py) so a snippet that only touches `obj` runs unchanged in either
6
+ mode. Entities only have what that API exposes: state + attributes from
7
+ get_states, and whatever the entity registry's own partial dict exposes (no
8
+ live component instance, so no calling methods on it, no fields an
9
+ integration keeps off the registry/state).
10
+
11
+ Mirrors custom_components/ha_repl_server/objtree.py's shape - __getitem__,
12
+ .keys()/.values()/.items(), find(), show() all behave the same way modulo the
13
+ smaller attribute set - but lives in this package rather than that one (a
14
+ HACS-deployed component with its own packaging boundary), so the handful of
15
+ small, stable, HA-import-free pieces it needs (the ordered-view mixin,
16
+ parse_path, the None/empty-collection cleanup) are duplicated here rather than
17
+ imported across that boundary. Revisit if API client mode sticks and a shared
18
+ package becomes worth the packaging work.
19
+
20
+ Unlike 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
22
+ not, so refreshing can't happen lazily on access without risking asyncio
23
+ reentrancy (this is normally used from inside an already-running event loop).
24
+ Instead 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()`
26
+ just marks the cache stale; it does not fetch.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import time
32
+ from collections.abc import (
33
+ ItemsView,
34
+ Iterable,
35
+ Iterator,
36
+ KeysView,
37
+ Mapping,
38
+ Sequence,
39
+ ValuesView,
40
+ )
41
+ from dataclasses import dataclass
42
+ from datetime import datetime
43
+ from typing import Any, NamedTuple, Protocol
44
+
45
+
46
+ class _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
+
53
+ def parse_path(path: str) -> tuple[str, ...]:
54
+ """Split "/integration[/domain[/object_id]]" into 1-3 parts. Duplicated
55
+ from custom_components/ha_repl_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:
59
+ raise ValueError(f"{path!r} - expected /integration/domain/object_id")
60
+ return parts
61
+
62
+
63
+ def _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
+
69
+ def _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
+
78
+ class _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
+
90
+ class OrderedKeysView(_OrderedView, KeysView, Sequence):
91
+ pass
92
+
93
+
94
+ class OrderedValuesView(_OrderedView, ValuesView, Sequence):
95
+ pass
96
+
97
+
98
+ class 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)
107
+ class 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
+
136
+ class _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
+
156
+ class 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:
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
+
223
+ def _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
+
253
+ def _normalize(value: Any) -> Any:
254
+ if isinstance(value, dict):
255
+ return _clean(value)
256
+ return value
257
+
258
+
259
+ def _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)
281
+ class 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:
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:
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:
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]:
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:
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:
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:
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)>"
@@ -0,0 +1,71 @@
1
+ """API client mode's interactive shell: same local line editing/history as
2
+ repl.py, but code runs right here (LocalSession) - no `hass`, no
3
+ ha_repl_server/exec call, no HACS component required on the HA side at
4
+ all. `obj` is bound the same name as custom mode, just to an ApiObjTree
5
+ instead of the live ObjTree, so a snippet that only touches `obj` runs
6
+ unchanged in either mode.
7
+
8
+ The cache refresh happens here, between prompts, not inside ApiObjTree's own
9
+ Mapping methods - see api_objtree.py's module docstring for why (asyncio
10
+ reentrancy: this loop is the one safe place that's both async and knows when
11
+ "between commands" is).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import ast
17
+ import codeop
18
+ from pathlib import Path
19
+
20
+ from prompt_toolkit import PromptSession
21
+ from prompt_toolkit.history import FileHistory
22
+ from prompt_toolkit.lexers import PygmentsLexer
23
+ from pygments.lexers.python import PythonLexer
24
+
25
+ from .api_objtree import ApiObjTree, Cache
26
+ from .client import Client
27
+ from .local_session import LocalSession
28
+ from .repl import is_quit_call
29
+
30
+ HISTORY = Path.home() / ".ha_repl_api_history"
31
+
32
+
33
+ async def run_api_repl(client: Client, ttl: float) -> int:
34
+ cache = Cache(client, ttl)
35
+ await cache.refresh()
36
+ session = LocalSession({"obj": ApiObjTree(cache)})
37
+
38
+ compiler = codeop.CommandCompiler()
39
+ compiler.compiler.flags |= ast.PyCF_ALLOW_TOP_LEVEL_AWAIT
40
+ prompt: PromptSession[str] = PromptSession(
41
+ history=FileHistory(str(HISTORY)), lexer=PygmentsLexer(PythonLexer)
42
+ )
43
+ print(
44
+ f"Home Assistant REPL (API client mode) connected to {client.url}. "
45
+ "`obj` only, read-only - no `hass`. Ctrl-D to exit."
46
+ )
47
+ lines: list[str] = []
48
+ while True:
49
+ try:
50
+ line = await prompt.prompt_async("... " if lines else ">>> ")
51
+ except KeyboardInterrupt:
52
+ lines.clear()
53
+ continue
54
+ except EOFError:
55
+ return 0
56
+ lines.append(line)
57
+ source = "\n".join(lines)
58
+ try:
59
+ # None means incomplete input: keep reading continuation lines.
60
+ if compiler(source, "<ha_repl>", "single") is None:
61
+ continue
62
+ except SyntaxError, OverflowError, ValueError:
63
+ pass # run it anyway so LocalSession reports the error consistently
64
+ lines.clear()
65
+ if not source.strip():
66
+ continue
67
+ if is_quit_call(source):
68
+ return 0
69
+ if cache.is_stale():
70
+ await cache.refresh()
71
+ await session.run(source)