Skip to content

Commit 7276b34

Browse files
committed
Release 0.1.1
1 parent bab1b42 commit 7276b34

6 files changed

Lines changed: 299 additions & 57 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,16 @@ The format is based on Keep a Changelog, and this project follows Semantic Versi
66

77
## [Unreleased]
88

9-
### Added
9+
## [0.1.1] - 2026-03-17
10+
11+
### Changed
12+
13+
- Reduced log noise and shortened identifiers in websocket and client logging.
14+
- Improved error classification so auth failures surface as auth errors in more SDK methods.
15+
16+
### Fixed
1017

11-
- Initial packaging, tests, CI, and release setup.
18+
- Malformed successful API responses now raise SDK response errors instead of raw Python exceptions.
1219

1320
## [0.1.0] - 2026-03-16
1421

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "python-homely"
7-
version = "0.1.0"
7+
version = "0.1.1"
88
description = "Async Python client for the Homely cloud API, built for Home Assistant but usable anywhere."
99
readme = "README.md"
1010
requires-python = ">=3.12"

src/homely/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Reusable Homely client package extracted from the integration."""
22

3-
__version__ = "0.1.0"
3+
__version__ = "0.1.1"
44

55
from .client import (
66
BASE_URL,

src/homely/client.py

Lines changed: 177 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,17 @@
1919
REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=20)
2020

2121

22+
def _log_identifier(value: str | int | None) -> str | None:
23+
"""Return a shortened identifier suitable for debug logs."""
24+
if value is None:
25+
return None
26+
27+
text = str(value)
28+
if len(text) <= 8:
29+
return text
30+
return f"{text[:8]}..."
31+
32+
2233
def auth_header_value(token: str | None) -> str:
2334
"""Return normalized Authorization header value."""
2435
normalized = (token or "").strip()
@@ -27,6 +38,14 @@ def auth_header_value(token: str | None) -> str:
2738
return f"Bearer {normalized}"
2839

2940

41+
def _response_preview(payload: Any) -> str:
42+
"""Return a short safe preview of a response payload for exceptions."""
43+
text = repr(payload)
44+
if len(text) <= 200:
45+
return text
46+
return f"{text[:200]}..."
47+
48+
3049
class HomelyClient:
3150
"""Small reusable async client for the Homely cloud API."""
3251

@@ -61,19 +80,31 @@ async def authenticate(
6180
6281
Raises a typed SDK exception on failure.
6382
"""
64-
response, reason = await self.fetch_token_with_reason(username, password)
65-
if response:
66-
return TokenResponse.from_dict(response)
67-
if reason == "invalid_auth":
83+
response, status = await self._fetch_token_payload(username, password)
84+
if response is not None:
85+
try:
86+
return TokenResponse.from_dict(response)
87+
except (KeyError, TypeError, ValueError) as err:
88+
raise HomelyResponseError(
89+
"Homely authentication response missing required fields",
90+
status=status,
91+
body=_response_preview(response),
92+
) from err
93+
if status in (400, 401, 403):
6894
raise HomelyAuthError("Invalid Homely username or password")
95+
if status in (200, 201):
96+
raise HomelyResponseError(
97+
"Homely authentication response could not be parsed",
98+
status=status,
99+
)
69100
raise HomelyConnectionError("Could not connect to Homely")
70101

71-
async def fetch_token_with_reason(
102+
async def _fetch_token_payload(
72103
self,
73104
username: str,
74105
password: str,
75-
) -> tuple[dict[str, Any] | None, str | None]:
76-
"""Fetch access token and return optional reason key on failure."""
106+
) -> tuple[dict[str, Any] | None, int | None]:
107+
"""Fetch access token payload and include HTTP status when available."""
77108
url = f"{self._base_url}oauth/token"
78109
payload = {
79110
"username": username,
@@ -83,18 +114,43 @@ async def fetch_token_with_reason(
83114
try:
84115
async with self._session.post(url, json=payload, timeout=self._timeout) as response:
85116
if response.status in (200, 201):
117+
try:
118+
parsed = await response.json()
119+
except (aiohttp.ContentTypeError, TypeError, ValueError) as err:
120+
_LOGGER.debug(
121+
"Token fetch returned invalid JSON status=%s: %s",
122+
response.status,
123+
err,
124+
)
125+
return None, response.status
126+
if not isinstance(parsed, dict):
127+
_LOGGER.debug(
128+
"Token fetch returned unexpected payload type status=%s payload_type=%s",
129+
response.status,
130+
type(parsed).__name__,
131+
)
132+
return None, response.status
86133
_LOGGER.debug("Token fetch successful")
87-
return await response.json(), None
88-
89-
if response.status in (400, 401, 403):
90-
_LOGGER.debug("Token fetch rejected with status=%s", response.status)
91-
return None, "invalid_auth"
134+
return parsed, response.status
92135

93-
_LOGGER.warning("Token fetch failed with status=%s", response.status)
94-
return None, "cannot_connect"
136+
_LOGGER.debug("Token fetch failed with status=%s", response.status)
137+
return None, response.status
95138
except (aiohttp.ClientError, TimeoutError) as err:
96-
_LOGGER.warning("Token fetch network error: %s", err)
97-
return None, "cannot_connect"
139+
_LOGGER.debug("Token fetch network error: %s", err)
140+
return None, None
141+
142+
async def fetch_token_with_reason(
143+
self,
144+
username: str,
145+
password: str,
146+
) -> tuple[dict[str, Any] | None, str | None]:
147+
"""Fetch access token and return optional reason key on failure."""
148+
response, status = await self._fetch_token_payload(username, password)
149+
if response is not None:
150+
return response, None
151+
if status in (400, 401, 403):
152+
return None, "invalid_auth"
153+
return None, "cannot_connect"
98154

99155
async def fetch_token(
100156
self,
@@ -105,8 +161,11 @@ async def fetch_token(
105161
response, _reason = await self.fetch_token_with_reason(username, password)
106162
return response
107163

108-
async def fetch_refresh_token(self, refresh_token: str) -> dict[str, Any] | None:
109-
"""Refresh access token using refresh token."""
164+
async def _fetch_refresh_token_payload(
165+
self,
166+
refresh_token: str,
167+
) -> tuple[dict[str, Any] | None, int | None]:
168+
"""Refresh access token payload and include HTTP status when available."""
110169
url = f"{self._base_url}oauth/refresh-token"
111170
payload = {
112171
"refresh_token": refresh_token,
@@ -115,42 +174,108 @@ async def fetch_refresh_token(self, refresh_token: str) -> dict[str, Any] | None
115174
try:
116175
async with self._session.post(url, json=payload, timeout=self._timeout) as response:
117176
if response.status in (200, 201):
177+
try:
178+
parsed = await response.json()
179+
except (aiohttp.ContentTypeError, TypeError, ValueError) as err:
180+
_LOGGER.debug(
181+
"Token refresh returned invalid JSON status=%s: %s",
182+
response.status,
183+
err,
184+
)
185+
return None, response.status
186+
if not isinstance(parsed, dict):
187+
_LOGGER.debug(
188+
"Token refresh returned unexpected payload type status=%s payload_type=%s",
189+
response.status,
190+
type(parsed).__name__,
191+
)
192+
return None, response.status
118193
_LOGGER.debug("Token refresh successful")
119-
return await response.json()
194+
return parsed, response.status
120195
_LOGGER.debug("Token refresh failed with status=%s", response.status)
121-
return None
196+
return None, response.status
122197
except (aiohttp.ClientError, TimeoutError) as err:
123198
_LOGGER.debug("Token refresh network error: %s", err)
124-
return None
199+
return None, None
200+
201+
async def fetch_refresh_token(self, refresh_token: str) -> dict[str, Any] | None:
202+
"""Refresh access token using refresh token."""
203+
response, _status = await self._fetch_refresh_token_payload(refresh_token)
204+
return response
125205

126206
async def refresh_access_token(self, refresh_token: str) -> TokenResponse:
127207
"""Refresh access token and return a typed token response."""
128-
response = await self.fetch_refresh_token(refresh_token)
129-
if response:
130-
return TokenResponse.from_dict(response)
208+
response, status = await self._fetch_refresh_token_payload(refresh_token)
209+
if response is not None:
210+
try:
211+
return TokenResponse.from_dict(response)
212+
except (KeyError, TypeError, ValueError) as err:
213+
raise HomelyResponseError(
214+
"Homely refresh response missing required fields",
215+
status=status,
216+
body=_response_preview(response),
217+
) from err
218+
if status in (400, 401, 403):
219+
raise HomelyAuthError("Homely rejected the supplied refresh token")
220+
if status in (200, 201):
221+
raise HomelyResponseError(
222+
"Homely refresh response could not be parsed",
223+
status=status,
224+
)
131225
raise HomelyConnectionError("Could not refresh Homely access token")
132226

133-
async def get_locations(self, token: str) -> list[dict[str, Any]] | None:
134-
"""Get locations from API."""
227+
async def _get_locations_payload(
228+
self,
229+
token: str,
230+
) -> tuple[list[dict[str, Any]] | None, int | None]:
231+
"""Get locations payload and include HTTP status when available."""
135232
url = f"{self._base_url}locations"
136233
headers = {"Authorization": auth_header_value(token)}
137234

138235
try:
139236
async with self._session.get(url, headers=headers, timeout=self._timeout) as response:
140237
if response.status == 200:
238+
try:
239+
parsed = await response.json()
240+
except (aiohttp.ContentTypeError, TypeError, ValueError) as err:
241+
_LOGGER.debug(
242+
"Locations fetch returned invalid JSON status=%s: %s",
243+
response.status,
244+
err,
245+
)
246+
return None, response.status
247+
if not isinstance(parsed, list):
248+
_LOGGER.debug(
249+
"Locations fetch returned unexpected payload type status=%s payload_type=%s",
250+
response.status,
251+
type(parsed).__name__,
252+
)
253+
return None, response.status
141254
_LOGGER.debug("Locations fetch successful")
142-
return await response.json()
255+
return parsed, response.status
143256
_LOGGER.debug("Locations fetch failed with status=%s", response.status)
144-
return None
257+
return None, response.status
145258
except (aiohttp.ClientError, TimeoutError) as err:
146259
_LOGGER.debug("Locations fetch network error: %s", err)
147-
return None
260+
return None, None
261+
262+
async def get_locations(self, token: str) -> list[dict[str, Any]] | None:
263+
"""Get locations from API."""
264+
locations, _status = await self._get_locations_payload(token)
265+
return locations
148266

149267
async def get_locations_or_raise(self, token: str) -> list[dict[str, Any]]:
150268
"""Get locations from API or raise a typed exception."""
151-
locations = await self.get_locations(token)
269+
locations, status = await self._get_locations_payload(token)
152270
if locations is not None:
153271
return locations
272+
if status in (401, 403):
273+
raise HomelyAuthError("Homely rejected the supplied access token")
274+
if status == 200:
275+
raise HomelyResponseError(
276+
"Homely locations response could not be parsed",
277+
status=status,
278+
)
154279
raise HomelyConnectionError("Could not fetch Homely locations")
155280

156281
async def get_home_data(
@@ -174,20 +299,35 @@ async def get_home_data_with_status(
174299
try:
175300
async with self._session.get(url, headers=headers, timeout=self._timeout) as response:
176301
if response.status == 200:
177-
return await response.json(), response.status
178-
body = await response.text()
179-
body_preview = body.replace("\n", " ")[:200]
302+
try:
303+
parsed = await response.json()
304+
except (aiohttp.ContentTypeError, TypeError, ValueError) as err:
305+
_LOGGER.debug(
306+
"Location data fetch returned invalid JSON status=%s location_id=%s: %s",
307+
response.status,
308+
_log_identifier(location_id),
309+
err,
310+
)
311+
return None, response.status
312+
if not isinstance(parsed, dict):
313+
_LOGGER.debug(
314+
"Location data fetch returned unexpected payload type status=%s location_id=%s payload_type=%s",
315+
response.status,
316+
_log_identifier(location_id),
317+
type(parsed).__name__,
318+
)
319+
return None, response.status
320+
return parsed, response.status
180321
_LOGGER.debug(
181-
"Location data fetch failed with status=%s location_id=%s body_preview=%r",
322+
"Location data fetch failed with status=%s location_id=%s",
182323
response.status,
183-
location_id,
184-
body_preview,
324+
_log_identifier(location_id),
185325
)
186326
return None, response.status
187327
except (aiohttp.ClientError, TimeoutError) as err:
188328
_LOGGER.debug(
189329
"Location data fetch network error location_id=%s: %s",
190-
location_id,
330+
_log_identifier(location_id),
191331
err,
192332
)
193333
return None, None

0 commit comments

Comments
 (0)