python-siseli 0.1.0__tar.gz → 0.1.2__tar.gz

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.
Files changed (37) hide show
  1. {python_siseli-0.1.0 → python_siseli-0.1.2}/PKG-INFO +1 -1
  2. {python_siseli-0.1.0 → python_siseli-0.1.2}/pyproject.toml +1 -1
  3. {python_siseli-0.1.0 → python_siseli-0.1.2}/python_siseli.egg-info/PKG-INFO +1 -1
  4. {python_siseli-0.1.0 → python_siseli-0.1.2}/python_siseli.egg-info/SOURCES.txt +1 -0
  5. python_siseli-0.1.2/siseli/auth.py +169 -0
  6. {python_siseli-0.1.0 → python_siseli-0.1.2}/siseli/exceptions.py +20 -1
  7. python_siseli-0.1.2/tests/test_auth.py +297 -0
  8. python_siseli-0.1.0/siseli/auth.py +0 -96
  9. {python_siseli-0.1.0 → python_siseli-0.1.2}/README.md +0 -0
  10. {python_siseli-0.1.0 → python_siseli-0.1.2}/python_siseli.egg-info/dependency_links.txt +0 -0
  11. {python_siseli-0.1.0 → python_siseli-0.1.2}/python_siseli.egg-info/requires.txt +0 -0
  12. {python_siseli-0.1.0 → python_siseli-0.1.2}/python_siseli.egg-info/top_level.txt +0 -0
  13. {python_siseli-0.1.0 → python_siseli-0.1.2}/setup.cfg +0 -0
  14. {python_siseli-0.1.0 → python_siseli-0.1.2}/siseli/__init__.py +0 -0
  15. {python_siseli-0.1.0 → python_siseli-0.1.2}/siseli/_utils.py +0 -0
  16. {python_siseli-0.1.0 → python_siseli-0.1.2}/siseli/alarms.py +0 -0
  17. {python_siseli-0.1.0 → python_siseli-0.1.2}/siseli/client.py +0 -0
  18. {python_siseli-0.1.0 → python_siseli-0.1.2}/siseli/config.py +0 -0
  19. {python_siseli-0.1.0 → python_siseli-0.1.2}/siseli/const.py +0 -0
  20. {python_siseli-0.1.0 → python_siseli-0.1.2}/siseli/dashboard.py +0 -0
  21. {python_siseli-0.1.0 → python_siseli-0.1.2}/siseli/device.py +0 -0
  22. {python_siseli-0.1.0 → python_siseli-0.1.2}/siseli/dictionary.py +0 -0
  23. {python_siseli-0.1.0 → python_siseli-0.1.2}/siseli/history.py +0 -0
  24. {python_siseli-0.1.0 → python_siseli-0.1.2}/siseli/models/__init__.py +0 -0
  25. {python_siseli-0.1.0 → python_siseli-0.1.2}/siseli/models/alarm.py +0 -0
  26. {python_siseli-0.1.0 → python_siseli-0.1.2}/siseli/models/auth.py +0 -0
  27. {python_siseli-0.1.0 → python_siseli-0.1.2}/siseli/models/common.py +0 -0
  28. {python_siseli-0.1.0 → python_siseli-0.1.2}/siseli/models/config.py +0 -0
  29. {python_siseli-0.1.0 → python_siseli-0.1.2}/siseli/models/dashboard.py +0 -0
  30. {python_siseli-0.1.0 → python_siseli-0.1.2}/siseli/models/device.py +0 -0
  31. {python_siseli-0.1.0 → python_siseli-0.1.2}/siseli/models/dictionary.py +0 -0
  32. {python_siseli-0.1.0 → python_siseli-0.1.2}/siseli/models/history.py +0 -0
  33. {python_siseli-0.1.0 → python_siseli-0.1.2}/siseli/models/state.py +0 -0
  34. {python_siseli-0.1.0 → python_siseli-0.1.2}/siseli/models/station.py +0 -0
  35. {python_siseli-0.1.0 → python_siseli-0.1.2}/siseli/state.py +0 -0
  36. {python_siseli-0.1.0 → python_siseli-0.1.2}/siseli/station.py +0 -0
  37. {python_siseli-0.1.0 → python_siseli-0.1.2}/tests/test_phase3.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-siseli
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Python SDK for Siseli Cloud
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-siseli"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "Python SDK for Siseli Cloud"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-siseli
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Python SDK for Siseli Cloud
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -30,4 +30,5 @@ siseli/models/dictionary.py
30
30
  siseli/models/history.py
31
31
  siseli/models/state.py
32
32
  siseli/models/station.py
33
+ tests/test_auth.py
33
34
  tests/test_phase3.py
@@ -0,0 +1,169 @@
1
+ """Authentication: login and token lifecycle management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import logging
7
+ from datetime import UTC, datetime
8
+
9
+ import httpx
10
+
11
+ from .exceptions import AuthenticationError, NetworkError
12
+ from .models.auth import TokenInfo
13
+
14
+ _LOGGER = logging.getLogger(__name__)
15
+
16
+
17
+ def _md5(text: str) -> str:
18
+ # The Siseli Cloud API requires the MD5 hash of the plaintext password.
19
+ # This is a protocol constraint imposed by the server, not a local
20
+ # password-storage decision. The hash is sent over HTTPS and is never
21
+ # stored on disk.
22
+ return hashlib.md5(text.encode()).hexdigest() # noqa: S324
23
+
24
+
25
+ def _parse_dt(value: str | None) -> datetime | None:
26
+ if not value:
27
+ return None
28
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
29
+
30
+
31
+ def _extract_api_message(response: httpx.Response) -> str | None:
32
+ """Extract a safe, short error message from an HTTP error response.
33
+
34
+ Tries to parse the body as JSON and returns the value of the first
35
+ recognised error field (``error``, ``message``, ``detail``). Falls back
36
+ to the first 200 characters of the raw text body so there is always
37
+ *something* useful in logs. Never returns passwords, tokens, or auth
38
+ headers — those are never present in response bodies.
39
+ """
40
+ try:
41
+ body = response.json()
42
+ if isinstance(body, dict):
43
+ for key in ("error", "message", "detail"):
44
+ value = body.get(key)
45
+ if value and isinstance(value, str):
46
+ return value[:200]
47
+ except Exception: # noqa: BLE001
48
+ pass
49
+ text = (response.text or "").strip()
50
+ return text[:200] if text else None
51
+
52
+
53
+ class Auth:
54
+ """Manages credentials and the current access token.
55
+
56
+ The password is hashed with MD5 before being sent to the API, which
57
+ matches the behaviour observed in the browser client.
58
+ """
59
+
60
+ def __init__(self, account: str, password: str) -> None:
61
+ self._account = account
62
+ # The web client sends the MD5 hash of the plaintext password.
63
+ self._password_hash = _md5(password)
64
+ self._token_info: TokenInfo | None = None
65
+
66
+ async def login(self, http: httpx.AsyncClient) -> TokenInfo:
67
+ """Authenticate and store the returned tokens.
68
+
69
+ Raises :exc:`~siseli.exceptions.AuthenticationError` on HTTP-level
70
+ auth failures (4xx). Raises :exc:`~siseli.exceptions.NetworkError`
71
+ on network / timeout errors so callers can distinguish the two cases.
72
+ """
73
+ endpoint = "/apis/login/account"
74
+ _LOGGER.debug("Siseli auth: POST %s (account=%r)", endpoint, self._account)
75
+
76
+ try:
77
+ response = await http.post(
78
+ endpoint,
79
+ json={"account": self._account, "password": self._password_hash},
80
+ )
81
+ except httpx.TimeoutException as exc:
82
+ _LOGGER.warning("Siseli auth: request timed out for account=%r", self._account)
83
+ raise NetworkError(f"Login request timed out: {exc}") from exc
84
+ except httpx.HTTPError as exc:
85
+ _LOGGER.warning(
86
+ "Siseli auth: network error for account=%r: %s", self._account, exc
87
+ )
88
+ raise NetworkError(f"Login request failed: {exc}") from exc
89
+
90
+ http_status = response.status_code
91
+ _LOGGER.debug(
92
+ "Siseli auth: response status=%d for account=%r", http_status, self._account
93
+ )
94
+
95
+ # For HTTP error responses, extract a safe diagnostic message and raise
96
+ # AuthenticationError (4xx) or NetworkError (5xx).
97
+ if not response.is_success:
98
+ api_message = _extract_api_message(response)
99
+ _LOGGER.warning(
100
+ "Siseli auth: failed for account=%r — HTTP %d%s",
101
+ self._account,
102
+ http_status,
103
+ f", api_message={api_message!r}" if api_message else "",
104
+ )
105
+ diag = f"HTTP {http_status}"
106
+ if api_message:
107
+ diag = f"{diag}: {api_message}"
108
+ if http_status >= 500:
109
+ raise NetworkError(f"Login request failed: {diag}")
110
+ raise AuthenticationError(
111
+ f"Login failed: {diag}",
112
+ http_status=http_status,
113
+ api_message=api_message,
114
+ )
115
+
116
+ body = response.json()
117
+ if body.get("code") != 0:
118
+ api_message = body.get("message") or body.get("error") or body.get("detail")
119
+ if api_message:
120
+ api_message = str(api_message)
121
+ _LOGGER.warning(
122
+ "Siseli auth: API returned non-zero code=%r for account=%r, message=%r",
123
+ body.get("code"),
124
+ self._account,
125
+ api_message,
126
+ )
127
+ raise AuthenticationError(
128
+ f"Login failed (code {body.get('code')}): {api_message}",
129
+ http_status=http_status,
130
+ api_message=api_message,
131
+ )
132
+
133
+ data = body["data"]
134
+ self._token_info = TokenInfo(
135
+ access_token=data["accessToken"],
136
+ refresh_token=data["refreshToken"],
137
+ access_token_expires_at=_parse_dt(data.get("accessTokenWillExpiredAt")),
138
+ refresh_token_expires_at=_parse_dt(data.get("refreshTokenWillExpiredAt")),
139
+ auth_id=data.get("authId", ""),
140
+ account=data.get("account", self._account),
141
+ user_id=data.get("userId", ""),
142
+ )
143
+ _LOGGER.debug("Siseli auth: login successful for account=%r", self._account)
144
+ return self._token_info
145
+
146
+ @property
147
+ def access_token(self) -> str:
148
+ """Return the current access token.
149
+
150
+ Raises :exc:`~siseli.exceptions.AuthenticationError` if not yet
151
+ authenticated.
152
+ """
153
+ if self._token_info is None:
154
+ raise AuthenticationError("Not authenticated — call authenticate() first")
155
+ return self._token_info.access_token
156
+
157
+ @property
158
+ def token_info(self) -> TokenInfo | None:
159
+ """Return token metadata, or *None* if not authenticated."""
160
+ return self._token_info
161
+
162
+ def is_authenticated(self) -> bool:
163
+ """Return *True* when there is a valid, non-expired access token."""
164
+ if self._token_info is None:
165
+ return False
166
+ expires_at = self._token_info.access_token_expires_at
167
+ if expires_at is None:
168
+ return True
169
+ return datetime.now(UTC) < expires_at
@@ -8,7 +8,26 @@ class SiseliError(Exception):
8
8
 
9
9
 
10
10
  class AuthenticationError(SiseliError):
11
- """Raised when authentication fails."""
11
+ """Raised when authentication fails.
12
+
13
+ Attributes:
14
+ http_status: HTTP status code returned by the server, or *None* when
15
+ the failure occurred before a response was received.
16
+ api_message: Short error message extracted from the API response body
17
+ (e.g. the ``message`` or ``error`` field), or *None* when
18
+ unavailable. Never contains passwords, tokens, or other secrets.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ message: str,
24
+ *,
25
+ http_status: int | None = None,
26
+ api_message: str | None = None,
27
+ ) -> None:
28
+ self.http_status = http_status
29
+ self.api_message = api_message
30
+ super().__init__(message)
12
31
 
13
32
 
14
33
  class TokenExpiredError(AuthenticationError):
@@ -0,0 +1,297 @@
1
+ """Tests for diagnostic logging and error handling in Auth.login()."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+ import httpx
7
+
8
+ from siseli.auth import Auth, _extract_api_message
9
+ from siseli.exceptions import AuthenticationError, NetworkError
10
+
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Helpers
14
+ # ---------------------------------------------------------------------------
15
+
16
+ _GOOD_BODY = {
17
+ "code": 0,
18
+ "data": {
19
+ "accessToken": "tok-access",
20
+ "refreshToken": "tok-refresh",
21
+ "accessTokenWillExpiredAt": None,
22
+ "refreshTokenWillExpiredAt": None,
23
+ "authId": "auth-1",
24
+ "account": "user@example.com",
25
+ "userId": "uid-1",
26
+ },
27
+ }
28
+
29
+
30
+ def _make_response(status_code: int, json_body: object | None = None, text: str = "") -> httpx.Response:
31
+ """Build a minimal fake httpx.Response."""
32
+ import json as _json
33
+
34
+ if json_body is not None:
35
+ content = _json.dumps(json_body).encode()
36
+ headers = {"content-type": "application/json"}
37
+ else:
38
+ content = text.encode()
39
+ headers = {"content-type": "text/plain"}
40
+
41
+ return httpx.Response(
42
+ status_code=status_code,
43
+ content=content,
44
+ headers=headers,
45
+ request=httpx.Request("POST", "https://example.com/apis/login/account"),
46
+ )
47
+
48
+
49
+ class _FakeAsyncClient:
50
+ """Minimal async HTTP client stub."""
51
+
52
+ def __init__(self, response: httpx.Response | Exception) -> None:
53
+ self._response = response
54
+ self.last_request_json: dict | None = None
55
+
56
+ async def post(self, url: str, *, json: dict | None = None, **_: object) -> httpx.Response:
57
+ self.last_request_json = json
58
+ if isinstance(self._response, Exception):
59
+ raise self._response
60
+ return self._response
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # _extract_api_message
65
+ # ---------------------------------------------------------------------------
66
+
67
+
68
+ def test_extract_api_message_json_error_field() -> None:
69
+ resp = _make_response(401, json_body={"error": "invalid_credentials"})
70
+ assert _extract_api_message(resp) == "invalid_credentials"
71
+
72
+
73
+ def test_extract_api_message_json_message_field() -> None:
74
+ resp = _make_response(401, json_body={"message": "Bad password"})
75
+ assert _extract_api_message(resp) == "Bad password"
76
+
77
+
78
+ def test_extract_api_message_json_detail_field() -> None:
79
+ resp = _make_response(403, json_body={"detail": "Account locked"})
80
+ assert _extract_api_message(resp) == "Account locked"
81
+
82
+
83
+ def test_extract_api_message_prefers_error_over_message() -> None:
84
+ resp = _make_response(401, json_body={"error": "first", "message": "second"})
85
+ assert _extract_api_message(resp) == "first"
86
+
87
+
88
+ def test_extract_api_message_plain_text_fallback() -> None:
89
+ resp = _make_response(500, text="Internal Server Error")
90
+ assert _extract_api_message(resp) == "Internal Server Error"
91
+
92
+
93
+ def test_extract_api_message_truncates_long_text() -> None:
94
+ long_text = "x" * 300
95
+ resp = _make_response(500, text=long_text)
96
+ result = _extract_api_message(resp)
97
+ assert result is not None
98
+ assert len(result) == 200
99
+
100
+
101
+ def test_extract_api_message_empty_body() -> None:
102
+ resp = _make_response(401, text="")
103
+ assert _extract_api_message(resp) is None
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Auth.login — HTTP error status codes
108
+ # ---------------------------------------------------------------------------
109
+
110
+
111
+ @pytest.mark.asyncio
112
+ async def test_login_401_raises_authentication_error_with_status() -> None:
113
+ response = _make_response(401, json_body={"error": "invalid_credentials"})
114
+ client = _FakeAsyncClient(response)
115
+
116
+ auth = Auth("user@example.com", "secret")
117
+ with pytest.raises(AuthenticationError) as exc_info:
118
+ await auth.login(client)
119
+
120
+ err = exc_info.value
121
+ assert err.http_status == 401
122
+ assert err.api_message == "invalid_credentials"
123
+ assert "401" in str(err)
124
+ assert "invalid_credentials" in str(err)
125
+
126
+
127
+ @pytest.mark.asyncio
128
+ async def test_login_403_raises_authentication_error() -> None:
129
+ response = _make_response(403, json_body={"message": "Account disabled"})
130
+ client = _FakeAsyncClient(response)
131
+
132
+ auth = Auth("user@example.com", "secret")
133
+ with pytest.raises(AuthenticationError) as exc_info:
134
+ await auth.login(client)
135
+
136
+ err = exc_info.value
137
+ assert err.http_status == 403
138
+ assert err.api_message == "Account disabled"
139
+ assert "403" in str(err)
140
+
141
+
142
+ @pytest.mark.asyncio
143
+ async def test_login_429_raises_authentication_error() -> None:
144
+ response = _make_response(429, json_body={"message": "Too Many Requests"})
145
+ client = _FakeAsyncClient(response)
146
+
147
+ auth = Auth("user@example.com", "secret")
148
+ with pytest.raises(AuthenticationError) as exc_info:
149
+ await auth.login(client)
150
+
151
+ err = exc_info.value
152
+ assert err.http_status == 429
153
+ assert err.api_message == "Too Many Requests"
154
+
155
+
156
+ @pytest.mark.asyncio
157
+ async def test_login_5xx_raises_network_error() -> None:
158
+ response = _make_response(503, json_body={"error": "Service unavailable"})
159
+ client = _FakeAsyncClient(response)
160
+
161
+ auth = Auth("user@example.com", "secret")
162
+ with pytest.raises(NetworkError) as exc_info:
163
+ await auth.login(client)
164
+
165
+ assert "503" in str(exc_info.value)
166
+
167
+
168
+ @pytest.mark.asyncio
169
+ async def test_login_500_raises_network_error_not_auth_error() -> None:
170
+ response = _make_response(500, text="Internal Server Error")
171
+ client = _FakeAsyncClient(response)
172
+
173
+ auth = Auth("user@example.com", "secret")
174
+ with pytest.raises(NetworkError):
175
+ await auth.login(client)
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # Auth.login — network / timeout errors
180
+ # ---------------------------------------------------------------------------
181
+
182
+
183
+ @pytest.mark.asyncio
184
+ async def test_login_timeout_raises_network_error() -> None:
185
+ client = _FakeAsyncClient(httpx.TimeoutException("timed out"))
186
+
187
+ auth = Auth("user@example.com", "secret")
188
+ with pytest.raises(NetworkError) as exc_info:
189
+ await auth.login(client)
190
+
191
+ assert "timed out" in str(exc_info.value).lower() or "timeout" in str(exc_info.value).lower()
192
+
193
+
194
+ @pytest.mark.asyncio
195
+ async def test_login_connection_error_raises_network_error() -> None:
196
+ client = _FakeAsyncClient(httpx.ConnectError("connection refused"))
197
+
198
+ auth = Auth("user@example.com", "secret")
199
+ with pytest.raises(NetworkError):
200
+ await auth.login(client)
201
+
202
+
203
+ # ---------------------------------------------------------------------------
204
+ # Auth.login — non-zero API code (HTTP 200 but code != 0)
205
+ # ---------------------------------------------------------------------------
206
+
207
+
208
+ @pytest.mark.asyncio
209
+ async def test_login_api_nonzero_code_raises_auth_error() -> None:
210
+ response = _make_response(200, json_body={"code": 10001, "message": "Wrong credentials"})
211
+ client = _FakeAsyncClient(response)
212
+
213
+ auth = Auth("user@example.com", "secret")
214
+ with pytest.raises(AuthenticationError) as exc_info:
215
+ await auth.login(client)
216
+
217
+ err = exc_info.value
218
+ assert err.http_status == 200
219
+ assert err.api_message == "Wrong credentials"
220
+ assert "10001" in str(err)
221
+ assert "Wrong credentials" in str(err)
222
+
223
+
224
+ # ---------------------------------------------------------------------------
225
+ # Auth.login — no secrets in error messages
226
+ # ---------------------------------------------------------------------------
227
+
228
+
229
+ @pytest.mark.asyncio
230
+ async def test_login_error_message_does_not_contain_password() -> None:
231
+ response = _make_response(401, json_body={"error": "invalid_credentials"})
232
+ client = _FakeAsyncClient(response)
233
+
234
+ password = "s3cr3tpassword"
235
+ auth = Auth("user@example.com", password)
236
+ with pytest.raises(AuthenticationError) as exc_info:
237
+ await auth.login(client)
238
+
239
+ assert password not in str(exc_info.value)
240
+ # MD5 hash should not appear either (it's the sent value, not in response)
241
+ import hashlib
242
+ md5_hash = hashlib.md5(password.encode()).hexdigest() # noqa: S324
243
+ assert md5_hash not in str(exc_info.value)
244
+
245
+
246
+ @pytest.mark.asyncio
247
+ async def test_login_request_does_not_send_plaintext_password() -> None:
248
+ """Verify the login request sends MD5 hash, not the plaintext password."""
249
+ response = _make_response(200, json_body=_GOOD_BODY)
250
+ client = _FakeAsyncClient(response)
251
+
252
+ password = "plaintext_password"
253
+ auth = Auth("user@example.com", password)
254
+ await auth.login(client)
255
+
256
+ assert client.last_request_json is not None
257
+ assert client.last_request_json.get("password") != password
258
+
259
+
260
+ # ---------------------------------------------------------------------------
261
+ # Auth.login — successful login
262
+ # ---------------------------------------------------------------------------
263
+
264
+
265
+ @pytest.mark.asyncio
266
+ async def test_login_success_returns_token_info() -> None:
267
+ response = _make_response(200, json_body=_GOOD_BODY)
268
+ client = _FakeAsyncClient(response)
269
+
270
+ auth = Auth("user@example.com", "secret")
271
+ token_info = await auth.login(client)
272
+
273
+ assert token_info.access_token == "tok-access"
274
+ assert token_info.refresh_token == "tok-refresh"
275
+ assert auth.is_authenticated()
276
+
277
+
278
+ # ---------------------------------------------------------------------------
279
+ # AuthenticationError attributes
280
+ # ---------------------------------------------------------------------------
281
+
282
+
283
+ def test_authentication_error_attributes() -> None:
284
+ err = AuthenticationError(
285
+ "Login failed: HTTP 401: invalid_credentials",
286
+ http_status=401,
287
+ api_message="invalid_credentials",
288
+ )
289
+ assert err.http_status == 401
290
+ assert err.api_message == "invalid_credentials"
291
+ assert "401" in str(err)
292
+
293
+
294
+ def test_authentication_error_defaults() -> None:
295
+ err = AuthenticationError("Not authenticated")
296
+ assert err.http_status is None
297
+ assert err.api_message is None
@@ -1,96 +0,0 @@
1
- """Authentication: login and token lifecycle management."""
2
-
3
- from __future__ import annotations
4
-
5
- import hashlib
6
- from datetime import UTC, datetime
7
-
8
- import httpx
9
-
10
- from .exceptions import AuthenticationError
11
- from .models.auth import TokenInfo
12
-
13
-
14
- def _md5(text: str) -> str:
15
- # The Siseli Cloud API requires the MD5 hash of the plaintext password.
16
- # This is a protocol constraint imposed by the server, not a local
17
- # password-storage decision. The hash is sent over HTTPS and is never
18
- # stored on disk.
19
- return hashlib.md5(text.encode()).hexdigest() # noqa: S324
20
-
21
-
22
- def _parse_dt(value: str | None) -> datetime | None:
23
- if not value:
24
- return None
25
- return datetime.fromisoformat(value.replace("Z", "+00:00"))
26
-
27
-
28
- class Auth:
29
- """Manages credentials and the current access token.
30
-
31
- The password is hashed with MD5 before being sent to the API, which
32
- matches the behaviour observed in the browser client.
33
- """
34
-
35
- def __init__(self, account: str, password: str) -> None:
36
- self._account = account
37
- # The web client sends the MD5 hash of the plaintext password.
38
- self._password_hash = _md5(password)
39
- self._token_info: TokenInfo | None = None
40
-
41
- async def login(self, http: httpx.AsyncClient) -> TokenInfo:
42
- """Authenticate and store the returned tokens.
43
-
44
- Raises :exc:`~siseli.exceptions.AuthenticationError` on failure.
45
- """
46
- try:
47
- response = await http.post(
48
- "/apis/login/account",
49
- json={"account": self._account, "password": self._password_hash},
50
- )
51
- response.raise_for_status()
52
- except httpx.HTTPError as exc:
53
- raise AuthenticationError(f"Login request failed: {exc}") from exc
54
-
55
- body = response.json()
56
- if body.get("code") != 0:
57
- raise AuthenticationError(
58
- f"Login failed (code {body.get('code')}): {body.get('message')}"
59
- )
60
-
61
- data = body["data"]
62
- self._token_info = TokenInfo(
63
- access_token=data["accessToken"],
64
- refresh_token=data["refreshToken"],
65
- access_token_expires_at=_parse_dt(data.get("accessTokenWillExpiredAt")),
66
- refresh_token_expires_at=_parse_dt(data.get("refreshTokenWillExpiredAt")),
67
- auth_id=data.get("authId", ""),
68
- account=data.get("account", self._account),
69
- user_id=data.get("userId", ""),
70
- )
71
- return self._token_info
72
-
73
- @property
74
- def access_token(self) -> str:
75
- """Return the current access token.
76
-
77
- Raises :exc:`~siseli.exceptions.AuthenticationError` if not yet
78
- authenticated.
79
- """
80
- if self._token_info is None:
81
- raise AuthenticationError("Not authenticated — call authenticate() first")
82
- return self._token_info.access_token
83
-
84
- @property
85
- def token_info(self) -> TokenInfo | None:
86
- """Return token metadata, or *None* if not authenticated."""
87
- return self._token_info
88
-
89
- def is_authenticated(self) -> bool:
90
- """Return *True* when there is a valid, non-expired access token."""
91
- if self._token_info is None:
92
- return False
93
- expires_at = self._token_info.access_token_expires_at
94
- if expires_at is None:
95
- return True
96
- return datetime.now(UTC) < expires_at
File without changes
File without changes