Skip to content

Commit b9a64f3

Browse files
authored
Merge branch 'DavidMStraub:master' into documentation-improvements
2 parents cc454cd + 6dbc8ed commit b9a64f3

10 files changed

Lines changed: 261 additions & 32 deletions

File tree

.github/workflows/ci.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: CI
2+
3+
on:
4+
- push
5+
- pull_request
6+
7+
env:
8+
DEFAULT_PYTHON: "3.12"
9+
10+
jobs:
11+
ruff:
12+
name: Check ruff
13+
runs-on: ubuntu-latest
14+
steps:
15+
- name: Check out code from GitHub
16+
uses: actions/checkout@v4
17+
18+
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
19+
uses: actions/setup-python@v5
20+
with:
21+
python-version: ${{ env.DEFAULT_PYTHON }}
22+
23+
- name: Upgrade pip
24+
run: |
25+
python -m pip install --upgrade pip
26+
pip --version
27+
28+
- name: Install ruff
29+
run: |
30+
pip install ruff
31+
32+
- name: Run ruff
33+
run: |
34+
ruff check homeconnect

homeconnect/api.py

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import json
22
import logging
33
import os
4-
import time
54
from threading import Thread
5+
import time
66
from typing import Callable, Dict, Optional, Union
77

88
from oauthlib.oauth2 import TokenExpiredError
99
from requests import Response
10+
from requests.adapters import HTTPAdapter, Retry
11+
from requests.exceptions import RetryError
1012
from requests_oauthlib import OAuth2Session
1113

1214
from .sseclient import SSEClient
@@ -16,6 +18,8 @@
1618
ENDPOINT_TOKEN = "/security/oauth/token"
1719
ENDPOINT_APPLIANCES = "/api/homeappliances"
1820
TIMEOUT_S = 120
21+
TOTAL_RETRIES = 1
22+
1923

2024
LOGGER = logging.getLogger("homeconnect")
2125

@@ -52,6 +56,9 @@ def __init__(
5256
token=token,
5357
token_updater=token_updater,
5458
)
59+
self.retry = Retry(TOTAL_RETRIES, status_forcelist=[429])
60+
self._oauth.mount("https://", HTTPAdapter(max_retries=self.retry))
61+
self._oauth.mount("http://", HTTPAdapter(max_retries=self.retry))
5562

5663
def refresh_tokens(self) -> Dict[str, Union[str, int]]:
5764
"""Refresh and return new tokens."""
@@ -77,6 +84,9 @@ def request(self, method: str, path: str, **kwargs) -> Response:
7784
self._oauth.token = self.refresh_tokens()
7885

7986
return getattr(self._oauth, method)(url, **kwargs)
87+
except RetryError as e:
88+
LOGGER.warning("Retry failed: %s", e)
89+
return e.response
8090

8191
def get(self, endpoint):
8292
"""Get data as dictionary from an endpoint."""
@@ -128,8 +138,7 @@ def delete(self, endpoint):
128138
return res
129139

130140
def get_appliances(self):
131-
"""Return a list of `HomeConnectAppliance` instances for all
132-
appliances."""
141+
"""Return a list of `HomeConnectAppliance` instances for all appliances."""
133142

134143
appliances = {}
135144

@@ -150,7 +159,7 @@ def get_appliances(self):
150159
def get_authurl(self):
151160
"""Get the URL needed for the authorization code grant flow."""
152161
authorization_url, _ = self._oauth.authorization_url(
153-
f"{self.host}/{ENDPOINT_AUTHORIZE}"
162+
f"{self.host}{ENDPOINT_AUTHORIZE}"
154163
)
155164
return authorization_url
156165

@@ -184,15 +193,16 @@ def handle_event(self, event, appliance):
184193
"""Handle a new event.
185194
186195
Updates the status with the event data and executes any callback
187-
function."""
196+
function.
197+
"""
188198
event_data = json.loads(event.data)
189199
items = event_data.get("items")
190200
if items is not None:
191201
data_dict = self.json2dict(items)
192202
else:
193203
data_dict = {event_data.pop("key"): event_data}
194204

195-
if event.event == "NOTIFY" or event.event == "STATUS":
205+
if event.event in ("NOTIFY", "STATUS", "EVENT"):
196206
appliance.status.update(data_dict)
197207

198208
elif event.event == "CONNECTED":
@@ -208,8 +218,11 @@ def handle_event(self, event, appliance):
208218

209219
@staticmethod
210220
def json2dict(lst):
211-
"""Turn a list of dictionaries where one key is called 'key'
212-
into a dictionary with the value of 'key' as key."""
221+
"""Convert JSON to dictionary.
222+
223+
Turn a list of dictionaries where one key is called 'key'
224+
into a dictionary with the value of 'key' as key.
225+
"""
213226
return {d.pop("key"): d for d in lst}
214227

215228

@@ -237,14 +250,23 @@ def token_dump(self, token):
237250
json.dump(token, f)
238251

239252
def token_load(self):
240-
"""Load the token from the cache if exists it and is not expired,
241-
otherwise return None."""
253+
"""Load the token from the cache if exists and not expired."""
242254
if not os.path.exists(self.token_cache):
243255
return None
244256
with open(self.token_cache, "r") as f:
245257
token = json.load(f)
246258
now = int(time.time())
247259
token["expires_in"] = token.get("expires_at", now - 1) - now
260+
self._oauth = OAuth2Session(
261+
client_id=self.client_id,
262+
redirect_uri=self.redirect_uri,
263+
auto_refresh_kwargs={
264+
"client_id": self.client_id,
265+
"client_secret": self.client_secret,
266+
},
267+
token=token,
268+
token_updater=self.token_updater,
269+
)
248270
return token
249271

250272
def token_expired(self, token):
@@ -253,11 +275,10 @@ def token_expired(self, token):
253275
return token["expires_at"] - now < 60
254276

255277
def get_token(self, authorization_response):
256-
"""Get the token given the redirect URL obtained from the
257-
authorization."""
278+
"""Get the token given the redirect URL obtained from the authorization."""
258279
LOGGER.info("Fetching token ...")
259280
token = self._oauth.fetch_token(
260-
f"{self.host}/{ENDPOINT_TOKEN}",
281+
f"{self.host}{ENDPOINT_TOKEN}",
261282
authorization_response=authorization_response,
262283
client_secret=self.client_secret,
263284
)
@@ -291,7 +312,10 @@ def __init__(
291312
self.event_callback = None
292313

293314
def __repr__(self):
294-
return "HomeConnectAppliance(hc, haId='{}', vib='{}', brand='{}', type='{}', name='{}', enumber='{}', connected={})".format(
315+
return (
316+
"HomeConnectAppliance(hc, haId='{}', vib='{}', brand='{}'"
317+
", type='{}', name='{}', enumber='{}', connected={})"
318+
).format(
295319
self.haId,
296320
self.vib,
297321
self.brand,
@@ -302,16 +326,19 @@ def __repr__(self):
302326
)
303327

304328
def listen_events(self, callback=None):
305-
"""Register event callback method"""
329+
"""Register event callback method."""
306330
self.event_callback = callback
307331

308332
if not self.hc.listening_events:
309333
self.hc.listen_events()
310334

311335
@staticmethod
312336
def json2dict(lst):
313-
"""Turn a list of dictionaries where one key is called 'key'
314-
into a dictionary with the value of 'key' as key."""
337+
"""Convert JSON to dictionary.
338+
339+
Turn a list of dictionaries where one key is called 'key'
340+
into a dictionary with the value of 'key' as key.
341+
"""
315342
return {d.pop("key"): d for d in lst}
316343

317344
def get(self, endpoint):

homeconnect/sseclient.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
1-
"""Taken from https://github.com/btubbs/sseclient"""
1+
"""Taken from https://github.com/btubbs/sseclient."""
22

33
import codecs
44
import logging
55
import re
66
import time
77
import warnings
88

9-
import requests
10-
import six
119
from oauthlib.oauth2 import TokenExpiredError
10+
import requests
1211
from requests.exceptions import HTTPError
1312

1413
# Technically, we should support streams that mix line endings. This regex,
@@ -42,7 +41,7 @@ def __init__(
4241
self.requests_kwargs["headers"]["Accept"] = "text/event-stream"
4342

4443
# Keep data here as it streams in
45-
self.buf = u""
44+
self.buf = ""
4645

4746
self._connect()
4847

@@ -56,6 +55,8 @@ def _connect(self):
5655
self.resp = requester.get(self.url, stream=True, **self.requests_kwargs)
5756
self.resp_iterator = self.resp.iter_content(chunk_size=self.chunk_size)
5857

58+
self.resp.encoding = "UTF-8"
59+
5960
# TODO: Ensure we're handling redirects. Might also stick the 'origin'
6061
# attribute on Events like the Javascript spec requires.
6162
try:
@@ -84,7 +85,8 @@ def __next__(self):
8485
raise EOFError()
8586
self.buf += decoder.decode(next_chunk)
8687

87-
# except (StopIteration, requests.RequestException, EOFError, http.client.IncompleteRead, ValueError) as e:
88+
# except (StopIteration, requests.RequestException, EOFError,
89+
# http.client.IncompleteRead, ValueError) as e:
8890
except Exception as e:
8991
LOGGER.warning("Exception while reading event: ", exc_info=True)
9092
time.sleep(self.retry / 1000.0)
@@ -113,9 +115,6 @@ def __next__(self):
113115

114116
return msg
115117

116-
if six.PY2:
117-
next = __next__
118-
119118

120119
class Event(object):
121120

@@ -144,7 +143,8 @@ def dump(self):
144143

145144
@classmethod
146145
def parse(cls, raw):
147-
"""
146+
"""Parse event message.
147+
148148
Given a possibly-multiline string representing an SSE message, parse it
149149
and return a Event object.
150150
"""
@@ -154,7 +154,7 @@ def parse(cls, raw):
154154
if m is None:
155155
# Malformed line. Discard but warn.
156156
warnings.warn('Invalid SSE line: "%s"' % line, SyntaxWarning)
157-
LOGGER.warn('Invalid SSE line: "%s"', line)
157+
LOGGER.warning('Invalid SSE line: "%s"', line)
158158
continue
159159

160160
name = m.group("name")

pyproject.toml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
[tool.ruff.lint]
2+
select = [
3+
"D", # docstrings
4+
"E", # pycodestyle
5+
"G", # flake8-logging-format
6+
"I", # isort
7+
"W", # pycodestyle
8+
]
9+
10+
ignore = [
11+
"D100", # Missing docstring in public module
12+
"D101", # Missing docstring in public class
13+
"D102", # Missing docstring in public method
14+
"D104", # Missing docstring in public package
15+
"D105", # Missing docstring in magic method
16+
"D107", # Missing docstring in `__init__`
17+
"D202", # No blank lines allowed aftee function docstring
18+
"D203", # 1 blank line required before class docstring
19+
"D213", # Multi-line docstring summary should start at the second line
20+
"D401", # First line should be in imperative mood
21+
"E722", # Do not use bare `except`
22+
]
23+
24+
[tool.ruff.lint.isort]
25+
force-sort-within-sections = true
26+
combine-as-imports = true
27+
split-on-trailing-comma = false

requirements_test.txt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
certifi==2024.7.4
2+
charset-normalizer==3.3.2
3+
coverage==7.5.4
4+
execnet==2.1.1
5+
idna==3.7
6+
iniconfig==2.0.0
7+
oauthlib==3.2.2
8+
packaging==24.1
9+
pluggy==1.5.0
10+
pytest==8.2.2
11+
pytest-cov==5.0.0
12+
pytest-xdist==3.6.1
13+
PyYAML==6.0.1
14+
requests==2.32.3
15+
requests-mock==1.12.1
16+
requests-oauthlib==2.0.0
17+
responses==0.25.3
18+
typing_extensions==4.12.2
19+
urllib3==2.2.2

setup.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
1-
from setuptools import setup, find_packages
2-
1+
from setuptools import find_packages, setup
32

43
with open("README.md") as f:
54
LONG_DESCRIPTION = f.read()
65

76
setup(
87
name="homeconnect",
9-
version="0.7.2",
8+
version="0.8.0",
109
author="David M. Straub",
11-
author_email="david.straub@tum.de",
10+
author_email="straub@protonmail.com",
1211
url="https://github.com/DavidMStraub/homeconnect",
1312
description="Python client for the BSH Home Connect REST API",
1413
long_description=LONG_DESCRIPTION,
1514
long_description_content_type="text/markdown",
1615
license="MIT",
1716
packages=find_packages(),
1817
install_requires=["requests", "requests_oauthlib"],
19-
extras_require={"testing": ["nose",],},
18+
extras_require={
19+
"testing": [
20+
"nose",
21+
],
22+
},
2023
)

test.sh

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
#!/usr/bin/env bash
2+
3+
set -x
4+
5+
python3 -m venv .venv
6+
7+
. .venv/bin/activate
8+
9+
pip3 install -r requirements_test.txt
10+
11+
pytest --cov=homeconnect/ --cov-report term-missing -vv ./tests/homeconnect
12+
13+
rm -rf .venv

tests/__init__.py

Whitespace-only changes.

tests/homeconnect/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)