Skip to content

Commit 9d7e35f

Browse files
committed
Phase 2: close the three P0 findings from the architecture review
P0-1 — the coordinator was constructed without config_entry. Deprecated since Home Assistant 2024.11 and scheduled to become mandatory, at which point setup raises and the integration stops loading. Passing it also ties the refresh task to the entry, so an unload cancels a poll that is still in flight. The minimum supported Home Assistant therefore moves from 2024.7 to 2024.11, in hacs.json and the README. The hygiene test guarding that minimum compared version strings, and "2024.11.0" sorts below "2024.7.0" as text — it would have rejected every release from 2024.10 onwards. It now compares numbers. P0-2 — the inventory endpoint serialised its JSON inside the request handler, blocking the event loop in proportion to cellar size: measured 3.3 ms at 200 bottles, 13.8 ms at 1,000 and 43.9 ms at 2,500 on a developer-class core, and several times that on the hardware Home Assistant usually runs on, once per dashboard load. The body is now rendered by the executor that already runs the parse, and the view hands over bytes it never touches. The payload is unchanged, so any dashboard or Lovelace card reading the endpoint keeps working. P0-3 — CellarTracker takes credentials as query parameters, so the password travels in the request URL, and aiohttp's ClientResponseError renders that URL in both str() and repr(). Nothing printed it, but only by accident: the error was re-raised as a bare CannotConnect and survived as __cause__, where nothing happened to format it. A single _LOGGER.exception, a diagnostics dump, or debug logging on aiohttp.client would have published it. Transport failures are now described by a string built here — the HTTP status, or the exception's type name — and raised after the handler has exited, so the original is attached as neither __cause__ nor __context__. `raise ... from None` was not enough: it clears __cause__ but leaves __context__, which a traceback would not print but a diagnostics dump walking the chain still could. 20 tests added, 210 passing. Each new suite was confirmed to fail against the unfixed code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LLPEGFSy3fLEuXNUPAPWR4
1 parent 8b27e39 commit 9d7e35f

9 files changed

Lines changed: 406 additions & 12 deletions

File tree

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,9 @@ The response contains all 66 columns CellarTracker returns; the table above is t
135135

136136
## Installation via HACS
137137

138-
**Requires Home Assistant 2024.7 or newer** — that is the release that added the static-path API
139-
the integration uses to serve its dashboard page.
138+
**Requires Home Assistant 2024.11 or newer** — 2024.7 added the static-path API the integration
139+
uses to serve its dashboard page, and 2024.11 added the `config_entry` argument its data
140+
coordinator now passes.
140141

141142
1. Open **HACS** in Home Assistant.
142143
2. Click the **** menu (top right) → **Custom repositories**.

custom_components/cellar_tracker/cellar_data.py

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from homeassistant.core import HomeAssistant
2626
from homeassistant.exceptions import ConfigEntryAuthFailed
2727
from homeassistant.helpers.aiohttp_client import async_get_clientsession
28+
from homeassistant.helpers.json import json_bytes
2829
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
2930

3031
from .const import (
@@ -109,13 +110,32 @@ async def async_fetch_inventory_payload(hass, username: str, password: str) -> s
109110
"Location": "1",
110111
}
111112

113+
# The password is a query parameter, so it travels in the request URL - and
114+
# aiohttp's ClientResponseError renders that URL in both str() and repr().
115+
# Nothing derived from the failed request may escape this function except a
116+
# description we build ourselves.
117+
failure: str | None = None
118+
112119
try:
113120
async with asyncio.timeout(REQUEST_TIMEOUT):
114121
async with session.get(BASE_URL, params=params) as response:
115122
response.raise_for_status()
116123
payload = await response.text()
124+
except aiohttp.ClientResponseError as err:
125+
# The status is the diagnostic part and carries nothing sensitive.
126+
failure = f"HTTP {err.status} from CellarTracker"
117127
except aiohttp.ClientError as err:
118-
raise CannotConnect from err
128+
# Connector and payload errors name the host rather than the query
129+
# string, but the same rule applies: name the failure, copy nothing.
130+
failure = type(err).__name__
131+
132+
if failure is not None:
133+
# Raised outside the handler deliberately. `raise ... from None` would
134+
# clear __cause__ but leave the original on __context__, where a
135+
# traceback would not print it but a diagnostics dump walking the chain
136+
# still could. Once the except block has exited the exception is no
137+
# longer being handled, so nothing is attached at all.
138+
raise CannotConnect(failure)
119139

120140
# An auth failure arrives as HTTP 200 with a marker in the body.
121141
if NOT_LOGGED_REPONSE in payload:
@@ -147,18 +167,38 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry):
147167
hass,
148168
_LOGGER,
149169
name=DOMAIN,
170+
# Mandatory from a future Home Assistant release, and deprecated
171+
# without it since 2024.11. It also ties the refresh task to the
172+
# entry, so unloading cancels a poll still in flight.
173+
config_entry=entry,
150174
update_interval=scan_interval,
151175
always_update=False,
152176
)
153177

154178
# Consecutive polls that reported an empty cellar after it held stock.
155179
self._suspicious_empty_polls = 0
156180

181+
# Replaced wholesale by each refresh, never mutated in place, and only
182+
# one refresh runs at a time - so the loop can read it without a lock.
183+
self._inventory_body: bytes = b"[]"
184+
157185
@property
158186
def currency(self) -> str:
159187
"""Return the configured currency symbol."""
160188
return self._currency
161189

190+
@property
191+
def inventory_body(self) -> bytes:
192+
"""The bottle list as a JSON body, rendered ahead of any request.
193+
194+
Serialising a large cellar costs real time - tens of milliseconds at a
195+
few thousand bottles, more on the hardware Home Assistant usually runs
196+
on - and doing it inside a request handler spends that time on the
197+
event loop. It is rendered in the executor that already runs the parse
198+
instead, so the view only ever hands over bytes.
199+
"""
200+
return self._inventory_body
201+
162202
def _process_inventory(self, inventory: list, previous: dict | None = None) -> dict:
163203
"""Process the raw inventory list into a structured dictionary.
164204
@@ -299,4 +339,7 @@ async def _async_update_data(self) -> dict:
299339
def _parse_and_process(self, payload: str, previous: dict | None) -> dict:
300340
"""Parse the tab-separated export, then summarise it. Runs in an executor."""
301341
rows = list(csv.DictReader(io.StringIO(payload), dialect="excel-tab"))
302-
return self._process_inventory(rows, previous=previous)
342+
result = self._process_inventory(rows, previous=previous)
343+
# Rendered here, on the executor thread, for the HTTP views to serve.
344+
self._inventory_body = json_bytes(result["bottles"])
345+
return result

custom_components/cellar_tracker/views.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,19 @@ class CellarTrackerInventoryView(_CellarTrackerView):
7777
name = "api:cellartracker:inventory"
7878

7979
async def get(self, request):
80-
"""Handle GET request for inventory."""
80+
"""Handle GET request for inventory.
81+
82+
The body was rendered by the coordinator when it last refreshed, so a
83+
thousand-bottle cellar costs this handler nothing: serialising it here
84+
would block the event loop for every dashboard load.
85+
"""
8186
coordinator = self._coordinator(request)
8287
if coordinator is None or not coordinator.data:
8388
return web.json_response([])
8489

85-
return web.json_response(coordinator.data.get("bottles", []))
90+
return web.Response(
91+
body=coordinator.inventory_body, content_type="application/json"
92+
)
8693

8794

8895
class CellarTrackerSettingsView(_CellarTrackerView):

hacs.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
22
"name": "CellarTracker",
33
"render_readme": true,
4-
"homeassistant": "2024.7.0"
4+
"homeassistant": "2024.11.0"
55
}

tests/conftest.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from __future__ import annotations
1414

1515
import asyncio
16+
import json as _json
1617
import pathlib
1718
import sys
1819
import types
@@ -45,13 +46,22 @@ class UpdateFailed(Exception):
4546

4647
# --- homeassistant.helpers.update_coordinator ---------------------------------
4748
class DataUpdateCoordinator:
48-
"""Minimal stand-in that records what the real base class would receive."""
49+
"""Minimal stand-in that records what the real base class would receive.
4950
50-
def __init__(self, hass, logger, *, name=None, update_interval=None, **kwargs):
51+
``config_entry`` is captured explicitly rather than absorbed into
52+
``**kwargs``: Home Assistant 2024.11 added it and is making it mandatory,
53+
so a test has to be able to see whether we passed it.
54+
"""
55+
56+
def __init__(
57+
self, hass, logger, *, name=None, config_entry=None, update_interval=None, **kwargs
58+
):
5159
self.hass = hass
5260
self.logger = logger
5361
self.name = name
62+
self.config_entry = config_entry
5463
self.update_interval = update_interval
64+
self.init_kwargs = kwargs
5565
self.data = None
5666

5767

@@ -174,6 +184,12 @@ class OptionsFlow(_FlowBase):
174184
EntityCategory=types.SimpleNamespace(DIAGNOSTIC="diagnostic"),
175185
)
176186
_module("homeassistant.helpers.entity_platform", AddEntitiesCallback=object)
187+
_module(
188+
"homeassistant.helpers.json",
189+
# The real helper is orjson-backed; stdlib json is equivalent for our
190+
# payload, which is only str/int/float.
191+
json_bytes=lambda data: _json.dumps(data).encode("utf-8"),
192+
)
177193

178194
class FakeRequest:
179195
"""Minimal aiohttp request exposing only the query string."""
@@ -231,19 +247,27 @@ def __init__(self, coordinator):
231247

232248

233249
class FakeCoordinator:
234-
"""Stands in for WineCellarData in view tests."""
250+
"""Stands in for WineCellarData in view tests.
251+
252+
Renders ``inventory_body`` the same way the real coordinator does: the
253+
views serve those bytes directly rather than encoding per request, so a
254+
double without them would not exercise the code path that runs in
255+
production.
256+
"""
235257

236258
def __init__(self, *, currency="USD", bottles=None, data=True):
237259
self.currency = currency
238260
if not data:
239261
self.data = None
262+
self.inventory_body = b"[]"
240263
else:
241264
bottles = bottles or []
242265
self.data = {
243266
"total_bottles": len(bottles),
244267
"total_value": 0.0,
245268
"bottles": bottles,
246269
}
270+
self.inventory_body = _json.dumps(bottles).encode("utf-8")
247271

248272

249273
class FakeHttp:
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""P0-1: the coordinator must be constructed with its config entry.
2+
3+
Home Assistant 2024.11 added ``config_entry`` to ``DataUpdateCoordinator``.
4+
Omitting it logs a deprecation warning today and is scheduled to become
5+
mandatory; when it does, setup raises and the integration stops loading.
6+
7+
Passing it is not only about silencing the warning. The coordinator registers
8+
its refresh task against the entry, so unloading the entry cancels a poll that
9+
is still in flight instead of leaving it running against a torn-down entry.
10+
11+
The entry is already the constructor's own argument, so nothing has to be
12+
threaded through to make this work.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
from cellar_tracker.cellar_data import WineCellarData
18+
from conftest import ConfigEntry, FakeHass, FakeSession
19+
20+
ENTRY_DATA = {"username": "alice", "password": "s3cret", "currency": "USD"}
21+
22+
23+
def build() -> tuple[WineCellarData, ConfigEntry]:
24+
hass = FakeHass()
25+
hass.session = FakeSession()
26+
entry = ConfigEntry(entry_id="entry-abc", data=ENTRY_DATA)
27+
return WineCellarData(hass, entry), entry
28+
29+
30+
def test_the_entry_reaches_the_base_coordinator():
31+
coordinator, entry = build()
32+
assert coordinator.config_entry is entry, (
33+
"DataUpdateCoordinator must receive config_entry= or Home Assistant "
34+
"will refuse to construct it once the deprecation lands"
35+
)
36+
37+
38+
def test_the_entry_is_not_smuggled_through_kwargs():
39+
"""It has to be the named parameter, not an extra the base class ignores."""
40+
coordinator, _ = build()
41+
assert "config_entry" not in coordinator.init_kwargs

0 commit comments

Comments
 (0)