Skip to content

Commit 6d6d872

Browse files
committed
fix: try something
1 parent abc3ac7 commit 6d6d872

9 files changed

Lines changed: 153 additions & 82 deletions

File tree

.vscode/settings.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"[python]": {
3+
"editor.formatOnSave": true,
4+
"editor.defaultFormatter": "charliermarsh.ruff",
5+
"editor.codeActionsOnSave": {
6+
"source.fixAll.ruff": "explicit",
7+
"source.organizeImports": "explicit"
8+
}
9+
}
10+
}
Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
"""Api package for EAU par Agur."""
22

33
from .agur_api_client import AgurApiClient
4-
from .exceptions import AgurApiConnectionError, AgurApiError, AgurApiUnauthorizedError
4+
from .exceptions import AgurApiConnectionError, AgurApiError, AgurApiInvalidSessionError, AgurApiUnauthorizedError
55

6-
__all__ = ["AgurApiClient", "AgurApiConnectionError", "AgurApiError", "AgurApiUnauthorizedError"]
6+
__all__ = [
7+
"AgurApiClient",
8+
"AgurApiConnectionError",
9+
"AgurApiError",
10+
"AgurApiUnauthorizedError",
11+
"AgurApiInvalidSessionError",
12+
]

custom_components/eau_agur/api/agur_api_client.py

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
LOGGER,
2727
LOGIN_PATH,
2828
)
29-
from .exceptions import AgurApiConnectionError, AgurApiError, AgurApiUnauthorizedError
29+
from .exceptions import AgurApiConnectionError, AgurApiError, AgurApiInvalidSessionError, AgurApiUnauthorizedError
3030

3131

3232
class AgurApiClient:
@@ -41,7 +41,7 @@ def __init__(
4141
client_id: str = CLIENT_ID,
4242
access_key: str = ACCESS_KEY,
4343
session: aiohttp.ClientSession | None = None,
44-
) -> AgurApiClient:
44+
) -> None:
4545
"""Initialize connection with the Agur API."""
4646

4747
if base_path is None:
@@ -78,7 +78,7 @@ async def request(
7878

7979
url = URL.build(scheme="https", host=self._host, path=self._base_path).join(URL(uri))
8080

81-
LOGGER.warning("URL: %s", url)
81+
LOGGER.debug("URL: %s", url)
8282

8383
if headers is None:
8484
headers: dict[str, Any] = {}
@@ -87,7 +87,6 @@ async def request(
8787
headers["Conversationid"] = self._conversation_id
8888

8989
if self._token is not None:
90-
LOGGER.warning("Token: %s", self._token[:18]) # Only take the first 18 characters
9190
headers["Token"] = self._token
9291

9392
if self._session is None:
@@ -119,20 +118,9 @@ async def request(
119118
raise AgurApiError(response.status, {"message": contents.decode("utf8")})
120119

121120
if "application/json" in content_type:
122-
json_data = await response.json()
123-
LOGGER.warning("Response JSON: %s", json_data)
124-
return json_data
121+
return await response.json()
125122

126-
text = await response.text()
127-
LOGGER.warning("Response Text: %s", text)
128-
return {"message": text}
129-
130-
def is_token_expired(self) -> bool:
131-
"""Check if the token is expired."""
132-
LOGGER.warning("Token expires at: %s", self._token_expires_at)
133-
if self._token_expires_at is None:
134-
return True
135-
return datetime.now(timezone.utc) > self._token_expires_at
123+
return {"message": await response.text()}
136124

137125
async def generate_temporary_token(self) -> None:
138126
"""Generate a temporary token."""
@@ -155,7 +143,7 @@ async def generate_temporary_token(self) -> None:
155143
except AgurApiError as exception:
156144
raise AgurApiError("Error occurred while generating temporary token.") from exception
157145

158-
async def login(self, email: str, password: str) -> bool:
146+
async def login(self, email: str, password: str) -> None:
159147
"""Login to Agur API."""
160148
try:
161149
response = await self.request(
@@ -173,6 +161,9 @@ async def login(self, email: str, password: str) -> bool:
173161
if exception.args[0] == 401:
174162
raise AgurApiUnauthorizedError("Invalid credentials.") from exception
175163

164+
if exception.args[0] == 400:
165+
raise AgurApiInvalidSessionError("Invalid session.") from exception
166+
176167
raise AgurApiError("Error occurred while logging in.") from exception
177168

178169
async def get_default_contract(self) -> str:

custom_components/eau_agur/api/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,7 @@ class AgurApiConnectionError(AgurApiError):
1111

1212
class AgurApiUnauthorizedError(AgurApiError):
1313
"""Agur API unauthorized exception."""
14+
15+
16+
class AgurApiInvalidSessionError(AgurApiError):
17+
"""Agur API invalid session exception."""

custom_components/eau_agur/config_flow.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@
88
from homeassistant.helpers import selector
99
from homeassistant.helpers.aiohttp_client import async_create_clientsession
1010

11-
from .api import AgurApiClient, AgurApiConnectionError, AgurApiError, AgurApiUnauthorizedError
11+
from .api import (
12+
AgurApiClient,
13+
AgurApiConnectionError,
14+
AgurApiError,
15+
AgurApiInvalidSessionError,
16+
AgurApiUnauthorizedError,
17+
)
1218
from .const import CONF_CONTRACT_NUMBER, CONF_PROVIDER, DOMAIN, LOGGER, PROVIDERS
1319

1420

@@ -18,7 +24,7 @@ class EauAgurFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
1824
VERSION = 1
1925
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
2026

21-
async def async_step_user(self, user_input=None) -> config_entries.FlowResult:
27+
async def async_step_user(self, user_input=None) -> config_entries.ConfigFlowResult:
2228
"""Handle a flow initialized by the user."""
2329
_errors: dict[str, str] = {}
2430
if user_input is not None:
@@ -39,10 +45,23 @@ async def async_step_user(self, user_input=None) -> config_entries.FlowResult:
3945

4046
await api_client.generate_temporary_token()
4147

42-
await api_client.login(
43-
user_input[CONF_EMAIL],
44-
user_input[CONF_PASSWORD],
45-
)
48+
# Retry login up to 4 times if we get an invalid session error
49+
login_attempts = 0
50+
max_login_attempts = 4
51+
52+
while login_attempts < max_login_attempts:
53+
try:
54+
await api_client.login(
55+
user_input[CONF_EMAIL],
56+
user_input[CONF_PASSWORD],
57+
)
58+
break # Login successful, exit retry loop
59+
except AgurApiInvalidSessionError as err:
60+
login_attempts += 1
61+
if login_attempts >= max_login_attempts:
62+
LOGGER.error(f"Login failed after {max_login_attempts} attempts due to invalid session")
63+
raise AgurApiError("Login failed after maximum retry attempts") from err
64+
LOGGER.warning(f"Login attempt {login_attempts} failed due to invalid session, retrying...")
4665

4766
default_contract_id = await api_client.get_default_contract()
4867

custom_components/eau_agur/coordinator.py

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,24 @@
77
from homeassistant.exceptions import ConfigEntryAuthFailed
88
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
99

10-
from .api import AgurApiClient, AgurApiConnectionError, AgurApiUnauthorizedError
10+
from .api import AgurApiClient, AgurApiConnectionError, AgurApiInvalidSessionError, AgurApiUnauthorizedError
1111
from .const import CONF_CONTRACT_NUMBER, DOMAIN, LOGGER, SCAN_INTERVAL_IN_MINUTES
1212

1313

1414
class EauAgurDataUpdateCoordinator(DataUpdateCoordinator):
1515
"""Data returned by the coordinator."""
1616

17+
_api_client: AgurApiClient
18+
_email: str | None
19+
_password: str | None
20+
_contract_number: str | None
21+
1722
def __init__(self, hass: HomeAssistant, api_client: AgurApiClient, entry: ConfigEntry):
1823
"""Initialize the coordinator."""
1924

2025
self._api_client = api_client
2126
self._email = entry.data.get(CONF_EMAIL)
2227
self._password = entry.data.get(CONF_PASSWORD)
23-
self._password = entry.data.get(CONF_PASSWORD)
2428
self._contract_number = entry.data.get(CONF_CONTRACT_NUMBER)
2529

2630
super().__init__(
@@ -35,14 +39,26 @@ async def _async_update_data(self) -> dict[str, Any]:
3539

3640
try:
3741
LOGGER.debug("Updating data from API")
38-
39-
# Refresh the token and login if needed
40-
if self._api_client.is_token_expired():
41-
await self._api_client.generate_temporary_token()
42-
else:
43-
LOGGER.debug("Token is not expired, skipping token generation")
44-
45-
await self._api_client.login(self._email, self._password)
42+
await self._api_client.generate_temporary_token()
43+
44+
# Validate that we have the required configuration
45+
if not self._email or not self._password or not self._contract_number:
46+
raise ConfigEntryAuthFailed("Missing required configuration: email, password, or contract number")
47+
48+
# Retry login up to 4 times if we get an invalid session error
49+
login_attempts = 0
50+
max_login_attempts = 4
51+
52+
while login_attempts < max_login_attempts:
53+
try:
54+
await self._api_client.login(self._email, self._password)
55+
break # Login successful, exit retry loop
56+
except AgurApiInvalidSessionError as err:
57+
login_attempts += 1
58+
if login_attempts >= max_login_attempts:
59+
LOGGER.error(f"Login failed after {max_login_attempts} attempts due to invalid session")
60+
raise ConfigEntryAuthFailed("Login failed after maximum retry attempts") from err
61+
LOGGER.warning(f"Login attempt {login_attempts} failed due to invalid session, retrying...")
4662

4763
# Get the consumption data
4864
result = {
@@ -59,14 +75,22 @@ async def _async_update_data(self) -> dict[str, Any]:
5975

6076
async def async_get_consumption(self) -> float | None:
6177
"""Return the consumption data."""
78+
if not self._contract_number:
79+
LOGGER.error("Contract number not available")
80+
return None
81+
6282
try:
6383
return await self._api_client.get_consumption(self._contract_number)
6484
except AgurApiConnectionError as err:
6585
LOGGER.error(f"Error communicating with API: {err}")
6686
return None
6787

68-
async def async_get_last_invoice(self) -> dict[str, Any] | None:
88+
async def async_get_last_invoice(self) -> float | None:
6989
"""Return the last invoice data."""
90+
if not self._contract_number:
91+
LOGGER.error("Contract number not available")
92+
return None
93+
7094
try:
7195
return await self._api_client.get_last_invoice(self._contract_number)
7296
except AgurApiConnectionError as err:

mise.lock

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,6 @@ backend = "core:python"
99
[tools.uv]
1010
version = "0.7.18"
1111
backend = "aqua:astral-sh/uv"
12+
13+
[tools.uv.checksums]
14+
"uv-x86_64-unknown-linux-musl.tar.gz" = "sha256:c8b51ed978b5f95a7c34dfe39e1dce966f7497fc12179f27507d507d9f3ff40b"

mise.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ run = ["uv run ruff check", "uv run ruff format --check --diff"]
3232

3333
[tasks."project:lint-fix"]
3434
description = "Run linting and fix"
35-
run = ["uv run ruff check --fix", "uv run ruff format --fix"]
35+
run = ["uv run ruff check --fix", "uv run ruff format"]
3636

3737
[tasks."precommit:install"]
3838
description = "Install pre-commit hooks"

tests/api/test_agur_api_client.py

Lines changed: 58 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
import asyncio
2-
from datetime import datetime, timezone
2+
import json
33
from unittest.mock import patch
44

55
import aiohttp
66
import pytest
77
from aresponses import ResponsesMockServer
88

99
from custom_components.eau_agur.api import AgurApiClient
10-
from custom_components.eau_agur.api.exceptions import AgurApiConnectionError, AgurApiError
10+
from custom_components.eau_agur.api.exceptions import (
11+
AgurApiConnectionError,
12+
AgurApiError,
13+
AgurApiInvalidSessionError,
14+
AgurApiUnauthorizedError,
15+
)
1116

1217
HOST_PATTERN = "example.com"
1318

@@ -52,7 +57,7 @@ async def test_http_error400(aresponses):
5257
HOST_PATTERN,
5358
"/",
5459
"GET",
55-
aresponses.Response(text="Bad request!", status=404),
60+
aresponses.Response(text="Bad request!", status=400),
5661
)
5762

5863
async with aiohttp.ClientSession() as session:
@@ -119,6 +124,56 @@ async def test_post_login(aresponses: ResponsesMockServer):
119124
await client.login("dupond.toto@mycompany.com", "myP@ssw0rd!")
120125

121126

127+
@pytest.mark.asyncio
128+
async def test_post_login_invalid_session(aresponses: ResponsesMockServer):
129+
"""Test requesting consumption data."""
130+
aresponses.add(
131+
host_pattern=HOST_PATTERN,
132+
path_pattern="/webapi/Utilisateur/authentification",
133+
method_pattern="POST",
134+
response=aresponses.Response(
135+
status=400,
136+
body=json.dumps(
137+
{
138+
"severity": "Security",
139+
"message": "Session Inconnue. Veuillez rafraîchir votre page et vous reconnecter.",
140+
"according": "W/FRONT",
141+
"refLog": "LogTicket-250705-0745-c8692f2c-485a-48c3-9daa-ded89ad5d246",
142+
}
143+
),
144+
),
145+
)
146+
async with aiohttp.ClientSession() as session:
147+
client = AgurApiClient(HOST_PATTERN, session=session)
148+
with pytest.raises(AgurApiInvalidSessionError):
149+
await client.login("dupond.toto@mycompany.com", "myP@ssw0rd!")
150+
151+
152+
@pytest.mark.asyncio
153+
async def test_post_login_invalid_credentials(aresponses: ResponsesMockServer):
154+
"""Test requesting consumption data."""
155+
aresponses.add(
156+
host_pattern=HOST_PATTERN,
157+
path_pattern="/webapi/Utilisateur/authentification",
158+
method_pattern="POST",
159+
response=aresponses.Response(
160+
status=401,
161+
body=json.dumps(
162+
{
163+
"severity": "Security",
164+
"message": "Par sécurité au bout de 5 essais infructueux votre compte sera bloqué. Il vous reste 5 essais.",
165+
"according": "W/FRONT",
166+
"refLog": "LogTicket-250705-0923-98d60ea9-2882-42fe-ab06-43475363c192",
167+
}
168+
),
169+
),
170+
)
171+
async with aiohttp.ClientSession() as session:
172+
client = AgurApiClient(HOST_PATTERN, session=session)
173+
with pytest.raises(AgurApiUnauthorizedError):
174+
await client.login("dupond.toto@mycompany.com", "myP@ssw0rd!")
175+
176+
122177
@pytest.mark.asyncio
123178
async def test_post_generate_temporary_token(aresponses: ResponsesMockServer):
124179
"""Test requesting generation of a temporary token."""
@@ -177,44 +232,3 @@ async def test_get_last_invoice(aresponses: ResponsesMockServer):
177232
client = AgurApiClient(HOST_PATTERN, session=session)
178233
value = await client.get_last_invoice("12345")
179234
assert value == 30.0
180-
181-
182-
@pytest.mark.asyncio
183-
async def test_is_token_expired_none():
184-
"""Test is_token_expired when token_expires_at is None."""
185-
async with aiohttp.ClientSession() as session:
186-
client = AgurApiClient(HOST_PATTERN, session=session)
187-
# By default, _token_expires_at is None
188-
assert client.is_token_expired() is True
189-
190-
191-
@pytest.mark.asyncio
192-
async def test_is_token_expired_past():
193-
"""Test is_token_expired when token is expired."""
194-
async with aiohttp.ClientSession() as session:
195-
client = AgurApiClient(HOST_PATTERN, session=session)
196-
# Set token expiration to a past datetime
197-
past_time = datetime(2023, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
198-
client._token_expires_at = past_time
199-
200-
# Mock current time to be after expiration
201-
current_time = datetime(2023, 1, 1, 13, 0, 0, tzinfo=timezone.utc)
202-
with patch("custom_components.eau_agur.api.agur_api_client.datetime") as mock_datetime:
203-
mock_datetime.now.return_value = current_time
204-
assert client.is_token_expired() is True
205-
206-
207-
@pytest.mark.asyncio
208-
async def test_is_token_expired_future():
209-
"""Test is_token_expired when token is not expired."""
210-
async with aiohttp.ClientSession() as session:
211-
client = AgurApiClient(HOST_PATTERN, session=session)
212-
# Set token expiration to a future datetime
213-
future_time = datetime(2023, 1, 1, 14, 0, 0, tzinfo=timezone.utc)
214-
client._token_expires_at = future_time
215-
216-
# Mock current time to be before expiration
217-
current_time = datetime(2023, 1, 1, 13, 0, 0, tzinfo=timezone.utc)
218-
with patch("custom_components.eau_agur.api.agur_api_client.datetime") as mock_datetime:
219-
mock_datetime.now.return_value = current_time
220-
assert client.is_token_expired() is False

0 commit comments

Comments
 (0)