py-livewire 0.1.0__tar.gz → 0.2.0__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 (22) hide show
  1. {py_livewire-0.1.0 → py_livewire-0.2.0}/PKG-INFO +1 -1
  2. {py_livewire-0.1.0 → py_livewire-0.2.0}/py_livewire/__init__.py +1 -1
  3. {py_livewire-0.1.0 → py_livewire-0.2.0}/py_livewire/auth.py +23 -11
  4. {py_livewire-0.1.0 → py_livewire-0.2.0}/py_livewire/const.py +12 -0
  5. {py_livewire-0.1.0 → py_livewire-0.2.0}/py_livewire.egg-info/PKG-INFO +1 -1
  6. {py_livewire-0.1.0 → py_livewire-0.2.0}/tests/test_client.py +39 -1
  7. {py_livewire-0.1.0 → py_livewire-0.2.0}/LICENSE +0 -0
  8. {py_livewire-0.1.0 → py_livewire-0.2.0}/README.md +0 -0
  9. {py_livewire-0.1.0 → py_livewire-0.2.0}/py_livewire/__main__.py +0 -0
  10. {py_livewire-0.1.0 → py_livewire-0.2.0}/py_livewire/client.py +0 -0
  11. {py_livewire-0.1.0 → py_livewire-0.2.0}/py_livewire/exceptions.py +0 -0
  12. {py_livewire-0.1.0 → py_livewire-0.2.0}/py_livewire/models.py +0 -0
  13. {py_livewire-0.1.0 → py_livewire-0.2.0}/py_livewire/py.typed +0 -0
  14. {py_livewire-0.1.0 → py_livewire-0.2.0}/py_livewire/utils.py +0 -0
  15. {py_livewire-0.1.0 → py_livewire-0.2.0}/py_livewire.egg-info/SOURCES.txt +0 -0
  16. {py_livewire-0.1.0 → py_livewire-0.2.0}/py_livewire.egg-info/dependency_links.txt +0 -0
  17. {py_livewire-0.1.0 → py_livewire-0.2.0}/py_livewire.egg-info/entry_points.txt +0 -0
  18. {py_livewire-0.1.0 → py_livewire-0.2.0}/py_livewire.egg-info/requires.txt +0 -0
  19. {py_livewire-0.1.0 → py_livewire-0.2.0}/py_livewire.egg-info/top_level.txt +0 -0
  20. {py_livewire-0.1.0 → py_livewire-0.2.0}/pyproject.toml +0 -0
  21. {py_livewire-0.1.0 → py_livewire-0.2.0}/setup.cfg +0 -0
  22. {py_livewire-0.1.0 → py_livewire-0.2.0}/tests/test_models.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py-livewire
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Async Python client for the LiveWire electric motorcycle cloud API
5
5
  Author-email: Igor Novgorodov <igor@novg.net>
6
6
  License-Expression: Apache-2.0
@@ -19,7 +19,7 @@ from .models import (
19
19
  )
20
20
  from .utils import uuid_from_android_id
21
21
 
22
- __version__ = "0.1.0"
22
+ __version__ = "0.2.0"
23
23
 
24
24
  __all__ = [
25
25
  "ApiError",
@@ -6,7 +6,7 @@ from typing import Any
6
6
  import aiohttp
7
7
 
8
8
  from . import const
9
- from .exceptions import AuthenticationError
9
+ from .exceptions import ApiError, AuthenticationError
10
10
 
11
11
 
12
12
  def jwt_subject(token: str) -> str | None:
@@ -25,7 +25,10 @@ class Authenticator:
25
25
  """Logs in to Gigya and exchanges the account UID for a LiveWire session token.
26
26
 
27
27
  The token carries no usable expiry and there is no refresh flow, so it is used until
28
- the backend rejects it, which triggers a full login again.
28
+ the backend rejects it. The account UID is cached after the first login, so renewing
29
+ a rejected token only re-requests a session token and the Gigya password login runs
30
+ just once. This avoids hammering Gigya when the token expires: the password is only
31
+ needed to obtain the UID, not to mint the token.
29
32
  """
30
33
 
31
34
  def __init__(
@@ -46,6 +49,7 @@ class Authenticator:
46
49
  self._api_url = api_url
47
50
 
48
51
  self._token: str | None = None
52
+ self._uid: str | None = None
49
53
  self._lock = asyncio.Lock()
50
54
  self.terms_accepted: bool | None = None
51
55
 
@@ -67,8 +71,11 @@ class Authenticator:
67
71
  return self._token
68
72
 
69
73
  async def _login(self, session: aiohttp.ClientSession) -> str:
70
- uid = await self._gigya_uid(session)
71
- return await self._session_token(session, uid)
74
+ # The UID is stable, so the Gigya password login only runs to obtain it the
75
+ # first time; renewing an expired token re-requests a session token with it.
76
+ if self._uid is None:
77
+ self._uid = await self._gigya_uid(session)
78
+ return await self._session_token(session, self._uid)
72
79
 
73
80
  async def _gigya_uid(self, session: aiohttp.ClientSession) -> str:
74
81
  # Gigya expects a form and reports errors in a body with HTTP 200
@@ -84,13 +91,16 @@ class Authenticator:
84
91
  ) as resp:
85
92
  res: dict[str, Any] = await resp.json(content_type=None)
86
93
 
87
- if res.get("errorCode", 0) != 0:
88
- raise AuthenticationError(
89
- f"Gigya login failed: {res.get('errorMessage')} "
90
- f"({res.get('errorCode')})"
91
- )
94
+ error_code = res.get("errorCode", 0)
95
+ if error_code != 0:
96
+ message = f"Gigya login failed: {res.get('errorMessage')} ({error_code})"
97
+ if error_code in const.GIGYA_CREDENTIAL_ERRORS:
98
+ raise AuthenticationError(message)
99
+ # A transient Gigya problem (rate limit, captcha, server error): retryable,
100
+ # re-entering the credentials would not help.
101
+ raise ApiError(message, code=str(error_code))
92
102
  if not res.get("UID"):
93
- raise AuthenticationError("Gigya login returned no UID")
103
+ raise ApiError("Gigya login returned no UID")
94
104
 
95
105
  return str(res["UID"])
96
106
 
@@ -112,7 +122,9 @@ class Authenticator:
112
122
  res = {}
113
123
  if not res.get("jwt"):
114
124
  detail = (res.get("error") or {}).get("description") or f"HTTP {status}"
115
- raise AuthenticationError(f"session request failed: {detail}")
125
+ # The session endpoint does not use the password, so a failure here is a
126
+ # backend or transient problem, not a credential one: retryable, not reauth.
127
+ raise ApiError(f"session request failed: {detail}", status=status)
116
128
 
117
129
  self.terms_accepted = res.get("termsAccepted")
118
130
  return str(res["jwt"])
@@ -19,6 +19,18 @@ LANGUAGE = "en-US"
19
19
 
20
20
  KM_PER_MILE = 1.609344
21
21
 
22
+ # Gigya accounts.login errorCodes that mean the credentials are the problem, so
23
+ # that re-entering them could help: a wrong loginID or password, or an account the
24
+ # user still has to finish setting up. Any other Gigya error (rate limit, captcha,
25
+ # server error) is transient and should be retried, not reported as a login failure.
26
+ GIGYA_CREDENTIAL_ERRORS = frozenset(
27
+ {
28
+ 403042, # invalid loginID or password
29
+ 206001, # account pending registration
30
+ 206002, # account pending verification
31
+ }
32
+ )
33
+
22
34
  # Values of error.code in the response envelope
23
35
  CODE_OK = "0"
24
36
  CODE_PARTIAL = "-300" # the app still shows the charging data that comes with it
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py-livewire
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Async Python client for the LiveWire electric motorcycle cloud API
5
5
  Author-email: Igor Novgorodov <igor@novg.net>
6
6
  License-Expression: Apache-2.0
@@ -39,6 +39,9 @@ class Backend:
39
39
  self.handlers: dict[tuple[str, str], Handler] = {}
40
40
  self.next_token = make_jwt("user-1", 1)
41
41
  self.valid_token = self.next_token
42
+ self.gigya_logins = 0
43
+ self.gigya_error: dict | None = None
44
+ self.session_error: dict | None = None
42
45
 
43
46
  def route(self, method: str, path: str, data=None, status: int = 200) -> None:
44
47
  async def handler(request: web.Request) -> web.StreamResponse:
@@ -56,8 +59,11 @@ class Backend:
56
59
  return app
57
60
 
58
61
  async def gigya(self, request: web.Request) -> web.Response:
62
+ self.gigya_logins += 1
59
63
  form = await request.post()
60
64
  assert form["apiKey"] == "4_6aX8cf8RFVt6F3JQQZaF3A"
65
+ if self.gigya_error is not None:
66
+ return web.json_response(self.gigya_error)
61
67
  if form["password"] != "secret":
62
68
  return web.json_response(
63
69
  {"errorCode": 403042, "errorMessage": "Invalid LoginID"}
@@ -66,6 +72,8 @@ class Backend:
66
72
 
67
73
  async def session(self, request: web.Request) -> web.Response:
68
74
  self.sessions.append(await request.json())
75
+ if self.session_error is not None:
76
+ return web.json_response(self.session_error, status=500)
69
77
  self.valid_token = self.next_token
70
78
  return web.json_response({"jwt": self.valid_token, "termsAccepted": True})
71
79
 
@@ -181,14 +189,44 @@ def test_relogin_on_401():
181
189
 
182
190
  async def test(client: Client):
183
191
  await client.login()
184
- # the backend expires the token, the next login yields a new one
192
+ # the backend expires the token, the next request yields a new one
185
193
  backend.valid_token = None
186
194
  backend.next_token = make_jwt("user-1", 2)
187
195
 
188
196
  assert len(await client.get_bikes()) == 1
189
197
 
190
198
  run(test, backend)
199
+ # The expired token is renewed from a new session token, reusing the cached UID:
200
+ # a second session request, but no second Gigya password login.
191
201
  assert len(backend.sessions) == 2
202
+ assert backend.gigya_logins == 1
203
+
204
+
205
+ def test_transient_gigya_error_is_retryable():
206
+ backend = Backend()
207
+ backend.gigya_error = {"errorCode": 500001, "errorMessage": "General Server Error"}
208
+
209
+ async def test(client: Client):
210
+ with pytest.raises(ApiError) as err:
211
+ await client.login()
212
+ assert not isinstance(err.value, AuthenticationError)
213
+ assert err.value.code == "500001"
214
+
215
+ run(test, backend)
216
+
217
+
218
+ def test_session_failure_is_retryable():
219
+ # The session endpoint does not use the password, so its failure must not look like
220
+ # an authentication failure (which would trigger a pointless reauth).
221
+ backend = Backend()
222
+ backend.session_error = {"error": {"description": "backend down"}}
223
+
224
+ async def test(client: Client):
225
+ with pytest.raises(ApiError) as err:
226
+ await client.login()
227
+ assert not isinstance(err.value, AuthenticationError)
228
+
229
+ run(test, backend)
192
230
 
193
231
 
194
232
  def test_pair():
File without changes
File without changes
File without changes
File without changes