python-siseli 0.1.0__py3-none-any.whl → 0.1.2__py3-none-any.whl

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.
@@ -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
@@ -1,14 +1,14 @@
1
1
  siseli/__init__.py,sha256=_sHO0M0medXnJz_ZTtHKyy8DP6e40wuf5ubEGLC1kBs,1035
2
2
  siseli/_utils.py,sha256=QT-ooemKKAIEHICNI2QErPvRkFIkUGo6ncUFCJM1kGc,727
3
3
  siseli/alarms.py,sha256=Mc_CV8kUBFTXu8c48NstX8Lc6Q0bPTnhjwehx9sbCkQ,4853
4
- siseli/auth.py,sha256=L-aT_R9CQDrnwx6uiGR4aYaEdb3ky2DFQgqkW0x5cEM,3356
4
+ siseli/auth.py,sha256=I8WwJgy1go1MGxjowkVx-lvCtneh5rRtbs9DV9yO4T8,6470
5
5
  siseli/client.py,sha256=Eap7MTrnctdskt6kOzbns7SqUt27ZDjMlIKVshfcfN8,14200
6
6
  siseli/config.py,sha256=J8raq7Yf4gCyjO9ISNoTrk_EYNEGrESV56O-aHfDvBU,3278
7
7
  siseli/const.py,sha256=M8bBrc8cH0OyEGb9158_emux_dfknvikGQqm09iQ8CA,174
8
8
  siseli/dashboard.py,sha256=JQHfi8orKs5uWsGGfoTvA14A9M_R9yIAYuGCxb4zTsM,3325
9
9
  siseli/device.py,sha256=crix14pYakrDaFmomPC3Uwgdq-1boZ-ZaEg2HgzXibs,2762
10
10
  siseli/dictionary.py,sha256=VSO83uXPaGLPzKGRmpROuWQKivXN7DNOmXN8QQJ-Y-w,1120
11
- siseli/exceptions.py,sha256=Y34NlvH8Lx1Hf1tQY8rlmbtEElR9TCq-RmH5zrj9b3A,757
11
+ siseli/exceptions.py,sha256=y9ws9c2vr5Q3bZxLwkXybUqQooq57YEEXAN0wO3_fxk,1420
12
12
  siseli/history.py,sha256=CgBvDfZBIrrewu7PeRv08-Zr4boEVOY8Xevruu-ge-I,3869
13
13
  siseli/state.py,sha256=d7Exlf7FzF8O_CcF-NbAt6kq8qaR4fFhjGW8028kbrM,5800
14
14
  siseli/station.py,sha256=e1OK4OIry9WUjT8KggQI5b4Acypm9D31mWAMTjDGFew,5531
@@ -23,7 +23,7 @@ siseli/models/dictionary.py,sha256=d6KCu77UMlPcIDON8G3sjuytkSfbI5go1y3I_jAGTX8,4
23
23
  siseli/models/history.py,sha256=dx26VWLJosucMvwL36vojvAguvkMiv5GEKTllUtZV0Y,791
24
24
  siseli/models/state.py,sha256=VK5CNqMLRkY1w9HrcUhvaO_6J-rjqP7VwvbppCFpucI,2393
25
25
  siseli/models/station.py,sha256=02OLINAVblnKkF73ZwKxYvPtUFROsu4-g544oDg8ApA,1783
26
- python_siseli-0.1.0.dist-info/METADATA,sha256=6ie6JBHG1D8xEGY9afztyw-LaAVSM9Zn2y0LK8BZPRg,1407
27
- python_siseli-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
28
- python_siseli-0.1.0.dist-info/top_level.txt,sha256=Dofr9c7hGvRRV6ij6aoPuAZqxEjkgtsBeN4KtFHbGgo,7
29
- python_siseli-0.1.0.dist-info/RECORD,,
26
+ python_siseli-0.1.2.dist-info/METADATA,sha256=xnZJLpywI8nzDQEqwUgUsN9Pbx5iL7bnSqV3v326MFs,1407
27
+ python_siseli-0.1.2.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
28
+ python_siseli-0.1.2.dist-info/top_level.txt,sha256=Dofr9c7hGvRRV6ij6aoPuAZqxEjkgtsBeN4KtFHbGgo,7
29
+ python_siseli-0.1.2.dist-info/RECORD,,
siseli/auth.py CHANGED
@@ -3,13 +3,16 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import hashlib
6
+ import logging
6
7
  from datetime import UTC, datetime
7
8
 
8
9
  import httpx
9
10
 
10
- from .exceptions import AuthenticationError
11
+ from .exceptions import AuthenticationError, NetworkError
11
12
  from .models.auth import TokenInfo
12
13
 
14
+ _LOGGER = logging.getLogger(__name__)
15
+
13
16
 
14
17
  def _md5(text: str) -> str:
15
18
  # The Siseli Cloud API requires the MD5 hash of the plaintext password.
@@ -25,6 +28,28 @@ def _parse_dt(value: str | None) -> datetime | None:
25
28
  return datetime.fromisoformat(value.replace("Z", "+00:00"))
26
29
 
27
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
+
28
53
  class Auth:
29
54
  """Manages credentials and the current access token.
30
55
 
@@ -41,21 +66,68 @@ class Auth:
41
66
  async def login(self, http: httpx.AsyncClient) -> TokenInfo:
42
67
  """Authenticate and store the returned tokens.
43
68
 
44
- Raises :exc:`~siseli.exceptions.AuthenticationError` on failure.
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.
45
72
  """
73
+ endpoint = "/apis/login/account"
74
+ _LOGGER.debug("Siseli auth: POST %s (account=%r)", endpoint, self._account)
75
+
46
76
  try:
47
77
  response = await http.post(
48
- "/apis/login/account",
78
+ endpoint,
49
79
  json={"account": self._account, "password": self._password_hash},
50
80
  )
51
- response.raise_for_status()
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
52
84
  except httpx.HTTPError as exc:
53
- raise AuthenticationError(f"Login request failed: {exc}") from 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
+ )
54
115
 
55
116
  body = response.json()
56
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
+ )
57
127
  raise AuthenticationError(
58
- f"Login failed (code {body.get('code')}): {body.get('message')}"
128
+ f"Login failed (code {body.get('code')}): {api_message}",
129
+ http_status=http_status,
130
+ api_message=api_message,
59
131
  )
60
132
 
61
133
  data = body["data"]
@@ -68,6 +140,7 @@ class Auth:
68
140
  account=data.get("account", self._account),
69
141
  user_id=data.get("userId", ""),
70
142
  )
143
+ _LOGGER.debug("Siseli auth: login successful for account=%r", self._account)
71
144
  return self._token_info
72
145
 
73
146
  @property
siseli/exceptions.py CHANGED
@@ -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):