AlexaPy 1.29.23__tar.gz → 1.29.25__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.
- {alexapy-1.29.23 → alexapy-1.29.25}/PKG-INFO +1 -1
- {alexapy-1.29.23 → alexapy-1.29.25}/alexapy/alexaapi.py +16 -2
- {alexapy-1.29.23 → alexapy-1.29.25}/alexapy/alexalogin.py +527 -172
- {alexapy-1.29.23 → alexapy-1.29.25}/alexapy/const.py +8 -0
- {alexapy-1.29.23 → alexapy-1.29.25}/pyproject.toml +1 -1
- {alexapy-1.29.23 → alexapy-1.29.25}/AUTHORS.md +0 -0
- {alexapy-1.29.23 → alexapy-1.29.25}/LICENSE +0 -0
- {alexapy-1.29.23 → alexapy-1.29.25}/README.md +0 -0
- {alexapy-1.29.23 → alexapy-1.29.25}/alexapy/__init__.py +0 -0
- {alexapy-1.29.23 → alexapy-1.29.25}/alexapy/alexahttp2.py +0 -0
- {alexapy-1.29.23 → alexapy-1.29.25}/alexapy/alexaproxy.py +0 -0
- {alexapy-1.29.23 → alexapy-1.29.25}/alexapy/alexawebsocket.py +0 -0
- {alexapy-1.29.23 → alexapy-1.29.25}/alexapy/errors.py +0 -0
- {alexapy-1.29.23 → alexapy-1.29.25}/alexapy/helpers.py +0 -0
|
@@ -1475,9 +1475,23 @@ class AlexaAPI:
|
|
|
1475
1475
|
"get", login, "/api/devices-v2/device", query=None
|
|
1476
1476
|
)
|
|
1477
1477
|
devices, *_ = await get_json_value(response, "devices", list)
|
|
1478
|
-
|
|
1479
|
-
|
|
1478
|
+
|
|
1479
|
+
if devices is not None:
|
|
1480
|
+
AlexaAPI.devices[login.email] = devices
|
|
1481
|
+
return devices
|
|
1482
|
+
|
|
1483
|
+
if login.email in AlexaAPI.devices:
|
|
1484
|
+
_LOGGER.debug(
|
|
1485
|
+
"%s: Using cached device list because device API returned no data",
|
|
1486
|
+
hide_email(login.email),
|
|
1487
|
+
)
|
|
1488
|
+
return AlexaAPI.devices[login.email]
|
|
1489
|
+
|
|
1490
|
+
_LOGGER.warning(
|
|
1491
|
+
"%s: Device API returned no data and no cached device list is available",
|
|
1492
|
+
hide_email(login.email),
|
|
1480
1493
|
)
|
|
1494
|
+
AlexaAPI.devices[login.email] = []
|
|
1481
1495
|
return AlexaAPI.devices[login.email]
|
|
1482
1496
|
|
|
1483
1497
|
@staticmethod
|
|
@@ -26,7 +26,7 @@ from binascii import Error
|
|
|
26
26
|
from collections import defaultdict
|
|
27
27
|
from collections.abc import Callable
|
|
28
28
|
from http.cookies import BaseCookie, Morsel, SimpleCookie
|
|
29
|
-
from json import JSONDecodeError, dumps
|
|
29
|
+
from json import JSONDecodeError, dumps, loads
|
|
30
30
|
from typing import Any
|
|
31
31
|
from urllib.parse import urlencode, urlparse
|
|
32
32
|
from uuid import uuid4
|
|
@@ -42,7 +42,15 @@ from requests.cookies import RequestsCookieJar
|
|
|
42
42
|
from simplejson import JSONDecodeError as SimpleJSONDecodeError
|
|
43
43
|
from yarl import URL
|
|
44
44
|
|
|
45
|
-
from .const import
|
|
45
|
+
from .const import (
|
|
46
|
+
APP_NAME,
|
|
47
|
+
CALL_VERSION,
|
|
48
|
+
COOKIE_SERIALIZATION_FORMAT,
|
|
49
|
+
COOKIE_SERIALIZATION_VERSION,
|
|
50
|
+
EXCEPTION_TEMPLATE,
|
|
51
|
+
LOCALE_KEY,
|
|
52
|
+
USER_AGENT,
|
|
53
|
+
)
|
|
46
54
|
from .errors import AlexapyPyotpInvalidKey
|
|
47
55
|
from .helpers import (
|
|
48
56
|
_catch_all_exceptions,
|
|
@@ -54,17 +62,16 @@ from .helpers import (
|
|
|
54
62
|
|
|
55
63
|
_LOGGER = logging.getLogger(__name__)
|
|
56
64
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
""
|
|
60
|
-
|
|
61
|
-
|
|
65
|
+
|
|
66
|
+
# Ensure http.cookies.Morsel recognizes the Partitioned cookie attribute.
|
|
67
|
+
# Older Python releases do not include the "partitioned" attribute.
|
|
68
|
+
# Newer releases (Python 3.14+) already support it, so this patch is only
|
|
69
|
+
# applied when necessary.
|
|
70
|
+
if "partitioned" not in Morsel._reserved:
|
|
71
|
+
_LOGGER.debug("Adding partitioned support to http.cookies.Morsel")
|
|
72
|
+
Morsel._reserved["partitioned"] = "Partitioned"
|
|
73
|
+
|
|
62
74
|
Morsel._flags.add("partitioned")
|
|
63
|
-
_LOGGER.debug(
|
|
64
|
-
"http.cookies patch: Morsel._reserved: %s; Morsel._flags: %s",
|
|
65
|
-
partitioned,
|
|
66
|
-
Morsel._flags,
|
|
67
|
-
)
|
|
68
75
|
|
|
69
76
|
|
|
70
77
|
def create_alexa_context() -> ssl.SSLContext:
|
|
@@ -78,6 +85,334 @@ def create_alexa_context() -> ssl.SSLContext:
|
|
|
78
85
|
_SSL_CONTEXT = create_alexa_context()
|
|
79
86
|
|
|
80
87
|
|
|
88
|
+
def _morsel_attr(morsel: Morsel, attr: str) -> str | bool:
|
|
89
|
+
"""Return a morsel attribute without assuming runtime support for newer attrs."""
|
|
90
|
+
try:
|
|
91
|
+
return morsel[attr]
|
|
92
|
+
except KeyError:
|
|
93
|
+
return ""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _cookie_url(domain: str, path: str = "/", secure: bool = True) -> URL:
|
|
97
|
+
"""Build a response URL suitable for aiohttp.CookieJar.update_cookies."""
|
|
98
|
+
clean_domain = (domain or "amazon.com").lstrip(".")
|
|
99
|
+
clean_path = path or "/"
|
|
100
|
+
if not clean_path.startswith("/"):
|
|
101
|
+
clean_path = f"/{clean_path}"
|
|
102
|
+
scheme = "https" if secure else "http"
|
|
103
|
+
return URL.build(scheme=scheme, host=clean_domain, path=clean_path)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _safe_bool(value: Any) -> bool:
|
|
107
|
+
"""Convert cookie flag values to bool."""
|
|
108
|
+
if isinstance(value, bool):
|
|
109
|
+
return value
|
|
110
|
+
return value not in ("", None, "False", "false", "0", 0)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _strip_wrapping_quotes(value: str) -> str:
|
|
114
|
+
"""Strip one pair of extra cookie quotes without altering embedded quotes."""
|
|
115
|
+
if len(value) >= 2 and value[0] == value[-1] == '"':
|
|
116
|
+
return value[1:-1]
|
|
117
|
+
return value
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _serialize_morsel(
|
|
121
|
+
name: str,
|
|
122
|
+
morsel: Morsel,
|
|
123
|
+
*,
|
|
124
|
+
fallback_domain: str = "",
|
|
125
|
+
fallback_path: str = "/",
|
|
126
|
+
host_only_cookies: set[tuple[str, str]] | None = None,
|
|
127
|
+
expirations: dict[tuple[str, str, str], Any] | None = None,
|
|
128
|
+
) -> dict[str, Any]:
|
|
129
|
+
"""Serialize one Morsel to a stable dict."""
|
|
130
|
+
domain = str(_morsel_attr(morsel, "domain") or fallback_domain or "")
|
|
131
|
+
path = str(_morsel_attr(morsel, "path") or fallback_path or "/")
|
|
132
|
+
host_only_cookies = host_only_cookies or set()
|
|
133
|
+
expirations = expirations or {}
|
|
134
|
+
return {
|
|
135
|
+
"name": str(name),
|
|
136
|
+
"value": str(morsel.value),
|
|
137
|
+
"domain": domain,
|
|
138
|
+
"path": path,
|
|
139
|
+
"expires": _morsel_attr(morsel, "expires") or "",
|
|
140
|
+
"max_age": _morsel_attr(morsel, "max-age") or "",
|
|
141
|
+
"secure": _safe_bool(_morsel_attr(morsel, "secure")),
|
|
142
|
+
"httponly": _safe_bool(_morsel_attr(morsel, "httponly")),
|
|
143
|
+
"samesite": _morsel_attr(morsel, "samesite") or "",
|
|
144
|
+
"partitioned": _safe_bool(_morsel_attr(morsel, "partitioned")),
|
|
145
|
+
"host_only": (domain, str(name)) in host_only_cookies,
|
|
146
|
+
"expiration": expirations.get((domain, path, str(name))),
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _serialize_cookie_jar(cookie_jar: aiohttp.CookieJar) -> dict[str, Any]:
|
|
151
|
+
"""Persist cookies using an alexapy-owned serialization format.
|
|
152
|
+
|
|
153
|
+
aiohttp.CookieJar.save/load serializes private implementation details,
|
|
154
|
+
which have changed across aiohttp/Python releases (for example the
|
|
155
|
+
addition of the "Partitioned" cookie attribute). Using an explicit
|
|
156
|
+
serialization format provides forward compatibility and allows schema
|
|
157
|
+
versioning.
|
|
158
|
+
"""
|
|
159
|
+
serialized: dict[str, Any] = {
|
|
160
|
+
"format": COOKIE_SERIALIZATION_FORMAT,
|
|
161
|
+
"version": COOKIE_SERIALIZATION_VERSION,
|
|
162
|
+
"cookies": [],
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
cookies: list[dict[str, Any]] = serialized["cookies"]
|
|
166
|
+
host_only_cookies = getattr(cookie_jar, "_host_only_cookies", set())
|
|
167
|
+
expirations = getattr(cookie_jar, "_expirations", {})
|
|
168
|
+
seen: set[tuple[str, str, str]] = set()
|
|
169
|
+
|
|
170
|
+
# Preserve aiohttp's domain/path buckets when available. Current aiohttp uses
|
|
171
|
+
# a mapping like {(domain, path): SimpleCookie(...)}; older/newer versions may
|
|
172
|
+
# vary, so this is deliberately defensive.
|
|
173
|
+
jar_cookies = getattr(cookie_jar, "_cookies", {})
|
|
174
|
+
if isinstance(jar_cookies, dict):
|
|
175
|
+
for key, simple_cookie in jar_cookies.items():
|
|
176
|
+
domain = ""
|
|
177
|
+
path = "/"
|
|
178
|
+
if isinstance(key, tuple):
|
|
179
|
+
domain = str(key[0] or "") if len(key) >= 1 else ""
|
|
180
|
+
path = str(key[1] or "/") if len(key) >= 2 else "/"
|
|
181
|
+
elif isinstance(key, str):
|
|
182
|
+
# Some private aiohttp pickles have been observed with a string
|
|
183
|
+
# bucket key. Treat it as a domain, not as a cookie name.
|
|
184
|
+
domain = key
|
|
185
|
+
|
|
186
|
+
if not isinstance(simple_cookie, BaseCookie):
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
for name, morsel in simple_cookie.items():
|
|
190
|
+
entry = _serialize_morsel(
|
|
191
|
+
str(name),
|
|
192
|
+
morsel,
|
|
193
|
+
fallback_domain=domain,
|
|
194
|
+
fallback_path=path,
|
|
195
|
+
host_only_cookies=host_only_cookies,
|
|
196
|
+
expirations=expirations,
|
|
197
|
+
)
|
|
198
|
+
cookie_key = (entry["domain"], entry["path"], entry["name"])
|
|
199
|
+
if cookie_key in seen:
|
|
200
|
+
continue
|
|
201
|
+
seen.add(cookie_key)
|
|
202
|
+
cookies.append(entry)
|
|
203
|
+
|
|
204
|
+
# Fallback for aiohttp versions whose internals differ from the above.
|
|
205
|
+
for morsel in cookie_jar:
|
|
206
|
+
entry = _serialize_morsel(morsel.key, morsel)
|
|
207
|
+
cookie_key = (entry["domain"], entry["path"], entry["name"])
|
|
208
|
+
if cookie_key in seen:
|
|
209
|
+
continue
|
|
210
|
+
seen.add(cookie_key)
|
|
211
|
+
cookies.append(entry)
|
|
212
|
+
|
|
213
|
+
return serialized
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _deserialize_cookie(
|
|
217
|
+
cookie_jar: aiohttp.CookieJar,
|
|
218
|
+
cookie: dict[str, Any],
|
|
219
|
+
*,
|
|
220
|
+
fallback_domain: str = "",
|
|
221
|
+
) -> tuple[str, str] | None:
|
|
222
|
+
"""Restore a serialized cookie into an aiohttp CookieJar."""
|
|
223
|
+
name = str(cookie.get("name") or "")
|
|
224
|
+
value = cookie.get("value")
|
|
225
|
+
domain = str(cookie.get("domain") or "")
|
|
226
|
+
path = str(cookie.get("path") or "/")
|
|
227
|
+
expires = str(cookie.get("expires") or "")
|
|
228
|
+
max_age = str(cookie.get("max_age") or "")
|
|
229
|
+
secure = _safe_bool(cookie.get("secure", True))
|
|
230
|
+
httponly = _safe_bool(cookie.get("httponly", False))
|
|
231
|
+
samesite = str(cookie.get("samesite") or "")
|
|
232
|
+
partitioned = _safe_bool(cookie.get("partitioned", False))
|
|
233
|
+
host_only = _safe_bool(cookie.get("host_only", False))
|
|
234
|
+
|
|
235
|
+
if not name or value is None:
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
if partitioned:
|
|
239
|
+
secure = True
|
|
240
|
+
|
|
241
|
+
cookie_value = _strip_wrapping_quotes(str(value))
|
|
242
|
+
raw_cookie = SimpleCookie()
|
|
243
|
+
raw_cookie[name] = cookie_value
|
|
244
|
+
morsel = raw_cookie[name]
|
|
245
|
+
|
|
246
|
+
clean_domain = domain.lstrip(".") or fallback_domain.lstrip(".")
|
|
247
|
+
clean_path = path
|
|
248
|
+
if not clean_path.startswith("/"):
|
|
249
|
+
clean_path = f"/{clean_path}"
|
|
250
|
+
|
|
251
|
+
# For host-only cookies, do not set the Domain attribute. The response URL
|
|
252
|
+
# supplies the host scope. Domain cookies keep their explicit Domain attr.
|
|
253
|
+
if clean_domain and not host_only:
|
|
254
|
+
with contextlib.suppress(KeyError):
|
|
255
|
+
morsel["domain"] = clean_domain
|
|
256
|
+
if clean_path:
|
|
257
|
+
with contextlib.suppress(KeyError):
|
|
258
|
+
morsel["path"] = clean_path
|
|
259
|
+
if expires:
|
|
260
|
+
with contextlib.suppress(KeyError):
|
|
261
|
+
morsel["expires"] = expires
|
|
262
|
+
if max_age:
|
|
263
|
+
with contextlib.suppress(KeyError):
|
|
264
|
+
morsel["max-age"] = max_age
|
|
265
|
+
if secure:
|
|
266
|
+
with contextlib.suppress(KeyError):
|
|
267
|
+
morsel["secure"] = True
|
|
268
|
+
if httponly:
|
|
269
|
+
with contextlib.suppress(KeyError):
|
|
270
|
+
morsel["httponly"] = True
|
|
271
|
+
if samesite:
|
|
272
|
+
with contextlib.suppress(KeyError):
|
|
273
|
+
morsel["samesite"] = samesite
|
|
274
|
+
if partitioned:
|
|
275
|
+
with contextlib.suppress(KeyError):
|
|
276
|
+
morsel["partitioned"] = True
|
|
277
|
+
|
|
278
|
+
cookie_jar.update_cookies(raw_cookie, _cookie_url(clean_domain, clean_path, secure))
|
|
279
|
+
return name, cookie_value
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _deserialize_cookie_jar(
|
|
283
|
+
cookie_jar: aiohttp.CookieJar,
|
|
284
|
+
serialized: dict[str, Any],
|
|
285
|
+
*,
|
|
286
|
+
fallback_domain: str = "",
|
|
287
|
+
) -> dict[str, str]:
|
|
288
|
+
"""Restore a serialized alexapy cookie jar into an aiohttp CookieJar."""
|
|
289
|
+
return_cookies: dict[str, str] = {}
|
|
290
|
+
if serialized.get("format") != COOKIE_SERIALIZATION_FORMAT:
|
|
291
|
+
return return_cookies
|
|
292
|
+
|
|
293
|
+
with contextlib.suppress(Exception):
|
|
294
|
+
cookie_jar.clear()
|
|
295
|
+
|
|
296
|
+
for cookie in serialized.get("cookies", []):
|
|
297
|
+
if not isinstance(cookie, dict):
|
|
298
|
+
continue
|
|
299
|
+
restored = _deserialize_cookie(
|
|
300
|
+
cookie_jar,
|
|
301
|
+
cookie,
|
|
302
|
+
fallback_domain=fallback_domain,
|
|
303
|
+
)
|
|
304
|
+
if restored:
|
|
305
|
+
return_cookies[restored[0]] = restored[1]
|
|
306
|
+
|
|
307
|
+
return return_cookies
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _legacy_cookie_value(value: Any) -> Any:
|
|
311
|
+
"""Extract a value from old aiohttp private-cookie records."""
|
|
312
|
+
if isinstance(value, Morsel):
|
|
313
|
+
return value.value
|
|
314
|
+
if isinstance(value, dict):
|
|
315
|
+
return value.get("value") or value.get("coded_value")
|
|
316
|
+
return value
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _restore_legacy_aiohttp_cookie_mapping(
|
|
320
|
+
cookie_jar: aiohttp.CookieJar,
|
|
321
|
+
cookies: dict[Any, Any],
|
|
322
|
+
*,
|
|
323
|
+
fallback_domain: str = "",
|
|
324
|
+
) -> dict[str, str]:
|
|
325
|
+
"""Restore old aiohttp.CookieJar.save() mappings.
|
|
326
|
+
|
|
327
|
+
This avoids treating bucket keys as cookie names.
|
|
328
|
+
"""
|
|
329
|
+
return_cookies: dict[str, str] = {}
|
|
330
|
+
restored_count = 0
|
|
331
|
+
|
|
332
|
+
for bucket_key, bucket in cookies.items():
|
|
333
|
+
domain = fallback_domain
|
|
334
|
+
path = "/"
|
|
335
|
+
if isinstance(bucket_key, tuple):
|
|
336
|
+
domain = str(bucket_key[0] or "") if len(bucket_key) >= 1 else ""
|
|
337
|
+
path = str(bucket_key[1] or "/") if len(bucket_key) >= 2 else "/"
|
|
338
|
+
elif isinstance(bucket_key, str):
|
|
339
|
+
# aiohttp private bucket key; do not turn this into a cookie name.
|
|
340
|
+
domain = bucket_key
|
|
341
|
+
|
|
342
|
+
if isinstance(bucket, BaseCookie | dict):
|
|
343
|
+
iterator = bucket.items()
|
|
344
|
+
else:
|
|
345
|
+
continue
|
|
346
|
+
|
|
347
|
+
for name, stored in iterator:
|
|
348
|
+
cookie_domain = domain
|
|
349
|
+
cookie_path = path
|
|
350
|
+
expires = ""
|
|
351
|
+
max_age = ""
|
|
352
|
+
secure = True
|
|
353
|
+
httponly = False
|
|
354
|
+
samesite = ""
|
|
355
|
+
partitioned = False
|
|
356
|
+
value = _legacy_cookie_value(stored)
|
|
357
|
+
|
|
358
|
+
if isinstance(stored, Morsel):
|
|
359
|
+
cookie_domain = str(_morsel_attr(stored, "domain") or domain)
|
|
360
|
+
cookie_path = str(_morsel_attr(stored, "path") or path or "/")
|
|
361
|
+
expires = str(_morsel_attr(stored, "expires") or "")
|
|
362
|
+
max_age = str(_morsel_attr(stored, "max-age") or "")
|
|
363
|
+
secure = _safe_bool(_morsel_attr(stored, "secure"))
|
|
364
|
+
httponly = _safe_bool(_morsel_attr(stored, "httponly"))
|
|
365
|
+
samesite = str(_morsel_attr(stored, "samesite") or "")
|
|
366
|
+
partitioned = _safe_bool(_morsel_attr(stored, "partitioned"))
|
|
367
|
+
elif isinstance(stored, dict):
|
|
368
|
+
cookie_domain = str(stored.get("domain") or domain)
|
|
369
|
+
cookie_path = str(stored.get("path") or path or "/")
|
|
370
|
+
expires = str(stored.get("expires") or "")
|
|
371
|
+
max_age = str(stored.get("max-age") or stored.get("max_age") or "")
|
|
372
|
+
secure = _safe_bool(stored.get("secure", True))
|
|
373
|
+
httponly = _safe_bool(stored.get("httponly", False))
|
|
374
|
+
samesite = str(stored.get("samesite") or "")
|
|
375
|
+
partitioned = _safe_bool(stored.get("partitioned", False))
|
|
376
|
+
|
|
377
|
+
restored = _deserialize_cookie(
|
|
378
|
+
cookie_jar,
|
|
379
|
+
{
|
|
380
|
+
"name": str(name),
|
|
381
|
+
"value": value,
|
|
382
|
+
"domain": cookie_domain,
|
|
383
|
+
"path": cookie_path,
|
|
384
|
+
"expires": expires,
|
|
385
|
+
"max_age": max_age,
|
|
386
|
+
"secure": secure,
|
|
387
|
+
"httponly": httponly,
|
|
388
|
+
"samesite": samesite,
|
|
389
|
+
"partitioned": partitioned,
|
|
390
|
+
},
|
|
391
|
+
fallback_domain=fallback_domain,
|
|
392
|
+
)
|
|
393
|
+
if restored:
|
|
394
|
+
restored_count += 1
|
|
395
|
+
return_cookies[restored[0]] = restored[1]
|
|
396
|
+
|
|
397
|
+
if restored_count:
|
|
398
|
+
_LOGGER.debug(
|
|
399
|
+
"Restored %s cookies from legacy aiohttp cookie mapping",
|
|
400
|
+
restored_count,
|
|
401
|
+
)
|
|
402
|
+
return return_cookies
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _is_flat_cookie_dict(cookies: dict[Any, Any]) -> bool:
|
|
406
|
+
"""Return True only for a legacy plain name/value cookie dict."""
|
|
407
|
+
return bool(cookies) and all(
|
|
408
|
+
isinstance(key, str)
|
|
409
|
+
and isinstance(value, str)
|
|
410
|
+
and not value.lstrip().startswith("{")
|
|
411
|
+
for key, value in cookies.items()
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
|
|
81
416
|
class AlexaLogin:
|
|
82
417
|
"""Class to handle login connection to Alexa. This class will not reconnect.
|
|
83
418
|
|
|
@@ -123,6 +458,7 @@ class AlexaLogin:
|
|
|
123
458
|
}
|
|
124
459
|
self._outputpath = outputpath
|
|
125
460
|
self._cookiefile: list[str] = [
|
|
461
|
+
self._outputpath(f".storage/{self._hass_domain}.{self.email}.cookies"),
|
|
126
462
|
self._outputpath(f".storage/{self._hass_domain}.{self.email}.pickle"),
|
|
127
463
|
self._outputpath(f"{self._hass_domain}.{self.email}.pickle"),
|
|
128
464
|
self._outputpath(f".storage/{self._hass_domain}.{self.email}.txt"),
|
|
@@ -317,153 +653,187 @@ class AlexaLogin:
|
|
|
317
653
|
return ""
|
|
318
654
|
|
|
319
655
|
async def load_cookie(self, cookies_txt: str = "") -> dict[str, str] | None: # noqa: PLR0915
|
|
320
|
-
"""Load
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
loaded
|
|
325
|
-
|
|
326
|
-
|
|
656
|
+
"""Load persisted cookies.
|
|
657
|
+
|
|
658
|
+
The preferred format is alexapy's versioned JSON .cookies file.
|
|
659
|
+
Legacy .pickle and .txt cookie files are still accepted for migration.
|
|
660
|
+
When a legacy file is successfully loaded, it is rewritten as .cookies
|
|
661
|
+
and the legacy files are removed.
|
|
662
|
+
"""
|
|
663
|
+
return_cookies: dict[str, str] = {}
|
|
664
|
+
if not self._cookiefile:
|
|
665
|
+
return return_cookies
|
|
666
|
+
|
|
667
|
+
if cookies_txt:
|
|
668
|
+
legacy_txt_cookiefile = self._cookiefile[-1]
|
|
669
|
+
_LOGGER.debug(
|
|
670
|
+
"Saving passed in cookie to %s\n%s",
|
|
671
|
+
legacy_txt_cookiefile.replace(self.email, hide_email(self.email)),
|
|
672
|
+
repr(cookies_txt),
|
|
673
|
+
)
|
|
674
|
+
try:
|
|
675
|
+
async with aiofiles.open(legacy_txt_cookiefile, mode="w") as localfile:
|
|
676
|
+
await localfile.write(cookies_txt)
|
|
677
|
+
except (OSError, EOFError, TypeError, AttributeError) as ex:
|
|
327
678
|
_LOGGER.debug(
|
|
328
|
-
"
|
|
329
|
-
|
|
330
|
-
|
|
679
|
+
"Error saving passed in cookie to %s: %s",
|
|
680
|
+
legacy_txt_cookiefile.replace(self.email, hide_email(self.email)),
|
|
681
|
+
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
|
331
682
|
)
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
683
|
+
|
|
684
|
+
for cookiefile in self._cookiefile:
|
|
685
|
+
_LOGGER.debug(
|
|
686
|
+
"Searching for cookies from %s",
|
|
687
|
+
cookiefile.replace(self.email, hide_email(self.email)),
|
|
688
|
+
)
|
|
689
|
+
if not await aioos.path.exists(cookiefile):
|
|
690
|
+
continue
|
|
691
|
+
|
|
692
|
+
_LOGGER.debug(
|
|
693
|
+
"Trying to load cookie from file %s",
|
|
694
|
+
cookiefile.replace(self.email, hide_email(self.email)),
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
cookies: (
|
|
698
|
+
dict[Any, Any]
|
|
699
|
+
| RequestsCookieJar
|
|
700
|
+
| http.cookiejar.MozillaCookieJar
|
|
701
|
+
| None
|
|
702
|
+
) = None
|
|
703
|
+
try:
|
|
704
|
+
async with aiofiles.open(cookiefile, "rb") as myfile:
|
|
705
|
+
raw_cookie_file = await myfile.read()
|
|
706
|
+
try:
|
|
707
|
+
cookies = loads(raw_cookie_file.decode())
|
|
708
|
+
if self._debug:
|
|
336
709
|
_LOGGER.debug(
|
|
337
|
-
"
|
|
338
|
-
self._cookiefile[0].replace(
|
|
339
|
-
self.email,
|
|
340
|
-
hide_email(self.email),
|
|
341
|
-
),
|
|
342
|
-
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
|
710
|
+
"JSON cookie loaded: %s %s", type(cookies), cookies
|
|
343
711
|
)
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
numcookies = 0
|
|
352
|
-
if not os.path.exists(cookiefile):
|
|
353
|
-
continue
|
|
354
|
-
if loaded and cookiefile != self._cookiefile[0]:
|
|
355
|
-
await delete_cookie(cookiefile)
|
|
356
|
-
_LOGGER.debug(
|
|
357
|
-
"Trying to load cookie from file %s",
|
|
358
|
-
cookiefile.replace(self.email, hide_email(self.email)),
|
|
359
|
-
)
|
|
712
|
+
except (UnicodeDecodeError, JSONDecodeError):
|
|
713
|
+
cookies = pickle.loads(raw_cookie_file)
|
|
714
|
+
if self._debug:
|
|
715
|
+
_LOGGER.debug(
|
|
716
|
+
"Pickled cookie loaded: %s %s", type(cookies), cookies
|
|
717
|
+
)
|
|
718
|
+
except pickle.UnpicklingError:
|
|
360
719
|
try:
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
_LOGGER.debug(
|
|
365
|
-
"Pickled cookie loaded: %s %s", type(cookies), cookies
|
|
366
|
-
)
|
|
367
|
-
except pickle.UnpicklingError:
|
|
368
|
-
try:
|
|
369
|
-
cookies = http.cookiejar.MozillaCookieJar(cookiefile)
|
|
370
|
-
cookies.load(ignore_discard=True, ignore_expires=True)
|
|
371
|
-
if self._debug:
|
|
372
|
-
_LOGGER.debug(
|
|
373
|
-
"Mozilla cookie loaded: %s %s", type(cookies), cookies
|
|
374
|
-
)
|
|
375
|
-
except (ValueError, http.cookiejar.LoadError) as ex:
|
|
720
|
+
cookies = http.cookiejar.MozillaCookieJar(cookiefile)
|
|
721
|
+
cookies.load(ignore_discard=True, ignore_expires=True)
|
|
722
|
+
if self._debug:
|
|
376
723
|
_LOGGER.debug(
|
|
377
|
-
"
|
|
378
|
-
cookiefile.replace(self.email, hide_email(self.email)),
|
|
379
|
-
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
|
724
|
+
"Mozilla cookie loaded: %s %s", type(cookies), cookies
|
|
380
725
|
)
|
|
381
|
-
|
|
382
|
-
except (OSError, EOFError) as ex:
|
|
726
|
+
except (ValueError, http.cookiejar.LoadError) as ex:
|
|
383
727
|
_LOGGER.debug(
|
|
384
|
-
"
|
|
728
|
+
"Cookie %s is truncated: %s",
|
|
385
729
|
cookiefile.replace(self.email, hide_email(self.email)),
|
|
386
730
|
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
|
387
731
|
)
|
|
388
732
|
continue
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
_LOGGER.debug(
|
|
435
|
-
"Processing cookie %s expires: %s",
|
|
436
|
-
cookie,
|
|
437
|
-
cookie.expires,
|
|
438
|
-
)
|
|
439
|
-
# escape extra quote marks from MozillaCookieJar cookie
|
|
440
|
-
return_cookies[cookie.name] = cookie.value.strip('"')
|
|
733
|
+
except (OSError, EOFError) as ex:
|
|
734
|
+
_LOGGER.debug(
|
|
735
|
+
"Error loading cookie from %s: %s",
|
|
736
|
+
cookiefile.replace(self.email, hide_email(self.email)),
|
|
737
|
+
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
|
738
|
+
)
|
|
739
|
+
continue
|
|
740
|
+
|
|
741
|
+
numcookies = 0
|
|
742
|
+
if (
|
|
743
|
+
isinstance(cookies, dict)
|
|
744
|
+
and cookies.get("format") == COOKIE_SERIALIZATION_FORMAT
|
|
745
|
+
):
|
|
746
|
+
_LOGGER.debug("Loading serialized alexapy cookie jar")
|
|
747
|
+
cookie_jar = self._session.cookie_jar
|
|
748
|
+
return_cookies = _deserialize_cookie_jar(
|
|
749
|
+
cookie_jar,
|
|
750
|
+
cookies,
|
|
751
|
+
fallback_domain=self.url,
|
|
752
|
+
)
|
|
753
|
+
numcookies = len(return_cookies)
|
|
754
|
+
elif isinstance(cookies, RequestsCookieJar):
|
|
755
|
+
_LOGGER.debug("Loading RequestsCookieJar")
|
|
756
|
+
requests_cookies = cookies.get_dict()
|
|
757
|
+
for key, value in requests_cookies.items():
|
|
758
|
+
if self._debug:
|
|
759
|
+
_LOGGER.debug('Key: "%s", Value: "%s"', key, value)
|
|
760
|
+
return_cookies[str(key)] = value.strip('"')
|
|
761
|
+
numcookies = len(return_cookies)
|
|
762
|
+
elif isinstance(cookies, defaultdict):
|
|
763
|
+
_LOGGER.debug("Loading legacy aiohttp cookie mapping")
|
|
764
|
+
cookie_jar = self._session.cookie_jar
|
|
765
|
+
return_cookies = _restore_legacy_aiohttp_cookie_mapping(
|
|
766
|
+
cookie_jar,
|
|
767
|
+
cookies,
|
|
768
|
+
fallback_domain=self.url,
|
|
769
|
+
)
|
|
770
|
+
numcookies = len(return_cookies)
|
|
771
|
+
elif isinstance(cookies, dict):
|
|
772
|
+
if _is_flat_cookie_dict(cookies):
|
|
773
|
+
_LOGGER.debug("Found legacy flat dict cookie")
|
|
774
|
+
return_cookies = {
|
|
775
|
+
str(key): str(value).strip('"')
|
|
776
|
+
for key, value in cookies.items()
|
|
777
|
+
}
|
|
441
778
|
numcookies = len(return_cookies)
|
|
442
779
|
else:
|
|
443
|
-
_LOGGER.debug("
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
780
|
+
_LOGGER.debug("Loading legacy aiohttp cookie dict mapping")
|
|
781
|
+
cookie_jar = self._session.cookie_jar
|
|
782
|
+
return_cookies = _restore_legacy_aiohttp_cookie_mapping(
|
|
783
|
+
cookie_jar,
|
|
784
|
+
cookies,
|
|
785
|
+
fallback_domain=self.url,
|
|
786
|
+
)
|
|
787
|
+
numcookies = len(return_cookies)
|
|
788
|
+
elif isinstance(cookies, http.cookiejar.MozillaCookieJar):
|
|
789
|
+
_LOGGER.debug("Found Mozillacookiejar")
|
|
790
|
+
for cookie in cookies:
|
|
791
|
+
if self._debug:
|
|
448
792
|
_LOGGER.debug(
|
|
449
|
-
"
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
hide_email(self.email),
|
|
453
|
-
),
|
|
793
|
+
"Processing cookie %s expires: %s",
|
|
794
|
+
cookie,
|
|
795
|
+
cookie.expires,
|
|
454
796
|
)
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
797
|
+
return_cookies[cookie.name] = cookie.value.strip('"')
|
|
798
|
+
numcookies = len(return_cookies)
|
|
799
|
+
else:
|
|
800
|
+
_LOGGER.debug("Ignoring unknown file %s", type(cookies))
|
|
801
|
+
|
|
802
|
+
if not numcookies:
|
|
803
|
+
continue
|
|
804
|
+
|
|
805
|
+
_LOGGER.debug("Loaded %s cookies", numcookies)
|
|
806
|
+
if cookiefile != self._cookiefile[0]:
|
|
807
|
+
_LOGGER.debug(
|
|
808
|
+
"Migrating legacy cookiefile to %s ",
|
|
809
|
+
self._cookiefile[0].replace(self.email, hide_email(self.email)),
|
|
810
|
+
)
|
|
811
|
+
try:
|
|
812
|
+
cookie_jar = self._session.cookie_jar
|
|
813
|
+
if not list(cookie_jar):
|
|
814
|
+
cookie_jar.update_cookies(
|
|
815
|
+
return_cookies, URL(f"https://{self.url}")
|
|
816
|
+
)
|
|
817
|
+
serialized_cookie_jar = _serialize_cookie_jar(cookie_jar)
|
|
818
|
+
async with aiofiles.open(
|
|
819
|
+
self._cookiefile[0], mode="w"
|
|
820
|
+
) as localfile:
|
|
821
|
+
await localfile.write(dumps(serialized_cookie_jar))
|
|
822
|
+
for legacy_cookiefile in self._cookiefile[1:]:
|
|
823
|
+
if await aioos.path.exists(legacy_cookiefile):
|
|
824
|
+
await delete_cookie(legacy_cookiefile)
|
|
825
|
+
except (OSError, EOFError, TypeError, AttributeError) as ex:
|
|
826
|
+
_LOGGER.debug(
|
|
827
|
+
"Error migrating cookie from %s to %s: %s",
|
|
828
|
+
cookiefile.replace(self.email, hide_email(self.email)),
|
|
829
|
+
self._cookiefile[0].replace(
|
|
830
|
+
self.email,
|
|
831
|
+
hide_email(self.email),
|
|
832
|
+
),
|
|
833
|
+
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
|
834
|
+
)
|
|
835
|
+
break
|
|
836
|
+
|
|
467
837
|
return return_cookies
|
|
468
838
|
|
|
469
839
|
async def close(self) -> None:
|
|
@@ -475,13 +845,9 @@ class AlexaLogin:
|
|
|
475
845
|
await self._session._connector.close()
|
|
476
846
|
self._session._connector = None
|
|
477
847
|
|
|
478
|
-
async def reset(self
|
|
848
|
+
async def reset(self) -> None:
|
|
479
849
|
"""Remove data related to existing login."""
|
|
480
|
-
_LOGGER.debug(
|
|
481
|
-
"Resetting Login for %s - %s",
|
|
482
|
-
hide_email(self.email),
|
|
483
|
-
self.url,
|
|
484
|
-
)
|
|
850
|
+
_LOGGER.debug("Resetting Login for %s - %s", hide_email(self.email), self.url)
|
|
485
851
|
await self.close()
|
|
486
852
|
self._session = None
|
|
487
853
|
self._data = None
|
|
@@ -493,15 +859,9 @@ class AlexaLogin:
|
|
|
493
859
|
self._create_session()
|
|
494
860
|
self._close_requested = False
|
|
495
861
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
hide_email(self.email),
|
|
500
|
-
self.url,
|
|
501
|
-
)
|
|
502
|
-
for cookiefile in self._cookiefile:
|
|
503
|
-
if cookiefile and os.path.exists(cookiefile):
|
|
504
|
-
await delete_cookie(cookiefile)
|
|
862
|
+
for cookiefile in self._cookiefile:
|
|
863
|
+
if (cookiefile) and os.path.exists(cookiefile):
|
|
864
|
+
await delete_cookie(cookiefile)
|
|
505
865
|
|
|
506
866
|
@classmethod
|
|
507
867
|
def get_inputs(cls, soup: BeautifulSoup, searchfield=None) -> dict[str, str]:
|
|
@@ -689,10 +1049,7 @@ class AlexaLogin:
|
|
|
689
1049
|
_LOGGER.debug("Using cookies to log in")
|
|
690
1050
|
if await self.test_loggedin(cookies):
|
|
691
1051
|
return
|
|
692
|
-
|
|
693
|
-
"Cookie login failed; resetting session without deleting cookies"
|
|
694
|
-
)
|
|
695
|
-
await self.reset(delete_cookies=False)
|
|
1052
|
+
await self.reset()
|
|
696
1053
|
_LOGGER.debug("Using credentials to log in")
|
|
697
1054
|
if not self._site:
|
|
698
1055
|
site: URL = self.start_url
|
|
@@ -844,6 +1201,7 @@ class AlexaLogin:
|
|
|
844
1201
|
async def save_cookiefile(self) -> None:
|
|
845
1202
|
"""Save login session cookies to file."""
|
|
846
1203
|
self._cookiefile = [
|
|
1204
|
+
self._outputpath(f".storage/{self._hass_domain}.{self.email}.cookies"),
|
|
847
1205
|
self._outputpath(f".storage/{self._hass_domain}.{self.email}.pickle"),
|
|
848
1206
|
self._outputpath(f"{self._hass_domain}.{self.email}.pickle"),
|
|
849
1207
|
self._outputpath(f".storage/{self._hass_domain}.{self.email}.txt"),
|
|
@@ -854,14 +1212,15 @@ class AlexaLogin:
|
|
|
854
1212
|
assert isinstance(cookie_jar, aiohttp.CookieJar)
|
|
855
1213
|
if self._debug:
|
|
856
1214
|
_LOGGER.debug("Saving cookie to %s", cookiefile)
|
|
857
|
-
loop = asyncio.get_event_loop()
|
|
858
1215
|
try:
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
1216
|
+
serialized_cookie_jar = _serialize_cookie_jar(cookie_jar)
|
|
1217
|
+
async with aiofiles.open(
|
|
1218
|
+
self._cookiefile[0], mode="w"
|
|
1219
|
+
) as localfile:
|
|
1220
|
+
await localfile.write(dumps(serialized_cookie_jar))
|
|
862
1221
|
except (OSError, EOFError, TypeError, AttributeError) as ex:
|
|
863
1222
|
_LOGGER.debug(
|
|
864
|
-
"Error saving
|
|
1223
|
+
"Error saving serialized cookie to %s: %s",
|
|
865
1224
|
self._cookiefile[0].replace(self.email, hide_email(self.email)),
|
|
866
1225
|
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
|
867
1226
|
)
|
|
@@ -877,6 +1236,7 @@ class AlexaLogin:
|
|
|
877
1236
|
async def delete_cookiefile(self) -> None:
|
|
878
1237
|
"""Delete cookiefile."""
|
|
879
1238
|
self._cookiefile = [
|
|
1239
|
+
self._outputpath(f".storage/{self._hass_domain}.{self.email}.cookies"),
|
|
880
1240
|
self._outputpath(f".storage/{self._hass_domain}.{self.email}.pickle"),
|
|
881
1241
|
self._outputpath(f"{self._hass_domain}.{self.email}.pickle"),
|
|
882
1242
|
self._outputpath(f".storage/{self._hass_domain}.{self.email}.txt"),
|
|
@@ -891,11 +1251,6 @@ class AlexaLogin:
|
|
|
891
1251
|
self._cookiefile[0].replace(self.email, hide_email(self.email)),
|
|
892
1252
|
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
|
893
1253
|
)
|
|
894
|
-
if self._debug:
|
|
895
|
-
_LOGGER.debug(
|
|
896
|
-
"Deleted:\n%s",
|
|
897
|
-
self._cookiefile.replace(self.email, hide_email(self.email))
|
|
898
|
-
)
|
|
899
1254
|
|
|
900
1255
|
async def get_tokens(self) -> bool:
|
|
901
1256
|
"""Get access and refresh tokens after registering device using cookies.
|
|
@@ -13,6 +13,14 @@ EXCEPTION_TEMPLATE = "An exception of type {0} occurred. Arguments:\n{1!r}"
|
|
|
13
13
|
CALL_VERSION = "2.2.556530.0"
|
|
14
14
|
APP_NAME = "Alexa Media Player"
|
|
15
15
|
|
|
16
|
+
# Cookie persistence format used by alexapy.
|
|
17
|
+
#
|
|
18
|
+
# This is intentionally owned by alexapy rather than relying on aiohttp
|
|
19
|
+
# CookieJar.save/load internals, which can change across aiohttp/Python
|
|
20
|
+
# versions and may not support newly introduced cookie attributes.
|
|
21
|
+
COOKIE_SERIALIZATION_FORMAT = "alexapy.cookies"
|
|
22
|
+
COOKIE_SERIALIZATION_VERSION = 1
|
|
23
|
+
|
|
16
24
|
# REST-style Alexa API hosts (for things like /api/notifications)
|
|
17
25
|
ALEXA_API_NA = "https://na-api-alexa.amazon.com"
|
|
18
26
|
ALEXA_API_EU = "https://eu-api-alexa.amazon.com"
|
|
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
|