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.
- {py_livewire-0.1.0 → py_livewire-0.2.0}/PKG-INFO +1 -1
- {py_livewire-0.1.0 → py_livewire-0.2.0}/py_livewire/__init__.py +1 -1
- {py_livewire-0.1.0 → py_livewire-0.2.0}/py_livewire/auth.py +23 -11
- {py_livewire-0.1.0 → py_livewire-0.2.0}/py_livewire/const.py +12 -0
- {py_livewire-0.1.0 → py_livewire-0.2.0}/py_livewire.egg-info/PKG-INFO +1 -1
- {py_livewire-0.1.0 → py_livewire-0.2.0}/tests/test_client.py +39 -1
- {py_livewire-0.1.0 → py_livewire-0.2.0}/LICENSE +0 -0
- {py_livewire-0.1.0 → py_livewire-0.2.0}/README.md +0 -0
- {py_livewire-0.1.0 → py_livewire-0.2.0}/py_livewire/__main__.py +0 -0
- {py_livewire-0.1.0 → py_livewire-0.2.0}/py_livewire/client.py +0 -0
- {py_livewire-0.1.0 → py_livewire-0.2.0}/py_livewire/exceptions.py +0 -0
- {py_livewire-0.1.0 → py_livewire-0.2.0}/py_livewire/models.py +0 -0
- {py_livewire-0.1.0 → py_livewire-0.2.0}/py_livewire/py.typed +0 -0
- {py_livewire-0.1.0 → py_livewire-0.2.0}/py_livewire/utils.py +0 -0
- {py_livewire-0.1.0 → py_livewire-0.2.0}/py_livewire.egg-info/SOURCES.txt +0 -0
- {py_livewire-0.1.0 → py_livewire-0.2.0}/py_livewire.egg-info/dependency_links.txt +0 -0
- {py_livewire-0.1.0 → py_livewire-0.2.0}/py_livewire.egg-info/entry_points.txt +0 -0
- {py_livewire-0.1.0 → py_livewire-0.2.0}/py_livewire.egg-info/requires.txt +0 -0
- {py_livewire-0.1.0 → py_livewire-0.2.0}/py_livewire.egg-info/top_level.txt +0 -0
- {py_livewire-0.1.0 → py_livewire-0.2.0}/pyproject.toml +0 -0
- {py_livewire-0.1.0 → py_livewire-0.2.0}/setup.cfg +0 -0
- {py_livewire-0.1.0 → py_livewire-0.2.0}/tests/test_models.py +0 -0
|
@@ -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
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|