AlexaPy 1.29.23__tar.gz → 1.29.24__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.24}/PKG-INFO +1 -1
- {alexapy-1.29.23 → alexapy-1.29.24}/alexapy/alexalogin.py +497 -169
- {alexapy-1.29.23 → alexapy-1.29.24}/alexapy/const.py +8 -0
- {alexapy-1.29.23 → alexapy-1.29.24}/pyproject.toml +1 -1
- {alexapy-1.29.23 → alexapy-1.29.24}/AUTHORS.md +0 -0
- {alexapy-1.29.23 → alexapy-1.29.24}/LICENSE +0 -0
- {alexapy-1.29.23 → alexapy-1.29.24}/README.md +0 -0
- {alexapy-1.29.23 → alexapy-1.29.24}/alexapy/__init__.py +0 -0
- {alexapy-1.29.23 → alexapy-1.29.24}/alexapy/alexaapi.py +0 -0
- {alexapy-1.29.23 → alexapy-1.29.24}/alexapy/alexahttp2.py +0 -0
- {alexapy-1.29.23 → alexapy-1.29.24}/alexapy/alexaproxy.py +0 -0
- {alexapy-1.29.23 → alexapy-1.29.24}/alexapy/alexawebsocket.py +0 -0
- {alexapy-1.29.23 → alexapy-1.29.24}/alexapy/errors.py +0 -0
- {alexapy-1.29.23 → alexapy-1.29.24}/alexapy/helpers.py +0 -0
|
@@ -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,18 +62,6 @@ from .helpers import (
|
|
|
54
62
|
|
|
55
63
|
_LOGGER = logging.getLogger(__name__)
|
|
56
64
|
|
|
57
|
-
"""Ensure cookies.Morsel contains "partitioned"
|
|
58
|
-
See: https://github.com/python/cpython/issues/112713
|
|
59
|
-
"""
|
|
60
|
-
partitioned = {"partitioned": "Partitioned"}
|
|
61
|
-
Morsel._reserved.update(partitioned)
|
|
62
|
-
Morsel._flags.add("partitioned")
|
|
63
|
-
_LOGGER.debug(
|
|
64
|
-
"http.cookies patch: Morsel._reserved: %s; Morsel._flags: %s",
|
|
65
|
-
partitioned,
|
|
66
|
-
Morsel._flags,
|
|
67
|
-
)
|
|
68
|
-
|
|
69
65
|
|
|
70
66
|
def create_alexa_context() -> ssl.SSLContext:
|
|
71
67
|
"""Create an SSL context for Alexa."""
|
|
@@ -78,6 +74,318 @@ def create_alexa_context() -> ssl.SSLContext:
|
|
|
78
74
|
_SSL_CONTEXT = create_alexa_context()
|
|
79
75
|
|
|
80
76
|
|
|
77
|
+
def _morsel_attr(morsel: Morsel, attr: str) -> str | bool:
|
|
78
|
+
"""Return a morsel attribute without assuming runtime support for newer attrs."""
|
|
79
|
+
try:
|
|
80
|
+
return morsel[attr]
|
|
81
|
+
except KeyError:
|
|
82
|
+
return ""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _cookie_url(domain: str, path: str = "/", secure: bool = True) -> URL:
|
|
86
|
+
"""Build a response URL suitable for aiohttp.CookieJar.update_cookies."""
|
|
87
|
+
clean_domain = (domain or "amazon.com").lstrip(".")
|
|
88
|
+
clean_path = path or "/"
|
|
89
|
+
if not clean_path.startswith("/"):
|
|
90
|
+
clean_path = f"/{clean_path}"
|
|
91
|
+
scheme = "https" if secure else "http"
|
|
92
|
+
return URL.build(scheme=scheme, host=clean_domain, path=clean_path)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _safe_bool(value: Any) -> bool:
|
|
96
|
+
"""Convert cookie flag values to bool."""
|
|
97
|
+
if isinstance(value, bool):
|
|
98
|
+
return value
|
|
99
|
+
return value not in ("", None, "False", "false", "0", 0)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _strip_wrapping_quotes(value: str) -> str:
|
|
103
|
+
"""Strip one pair of extra cookie quotes without altering embedded quotes."""
|
|
104
|
+
if len(value) >= 2 and value[0] == value[-1] == '"':
|
|
105
|
+
return value[1:-1]
|
|
106
|
+
return value
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _serialize_morsel(
|
|
110
|
+
name: str,
|
|
111
|
+
morsel: Morsel,
|
|
112
|
+
*,
|
|
113
|
+
fallback_domain: str = "",
|
|
114
|
+
fallback_path: str = "/",
|
|
115
|
+
host_only_cookies: set[tuple[str, str]] | None = None,
|
|
116
|
+
expirations: dict[tuple[str, str, str], Any] | None = None,
|
|
117
|
+
) -> dict[str, Any]:
|
|
118
|
+
"""Serialize one Morsel to a stable dict."""
|
|
119
|
+
domain = str(_morsel_attr(morsel, "domain") or fallback_domain or "")
|
|
120
|
+
path = str(_morsel_attr(morsel, "path") or fallback_path or "/")
|
|
121
|
+
host_only_cookies = host_only_cookies or set()
|
|
122
|
+
expirations = expirations or {}
|
|
123
|
+
return {
|
|
124
|
+
"name": str(name),
|
|
125
|
+
"value": str(morsel.value),
|
|
126
|
+
"domain": domain,
|
|
127
|
+
"path": path,
|
|
128
|
+
"expires": _morsel_attr(morsel, "expires") or "",
|
|
129
|
+
"max_age": _morsel_attr(morsel, "max-age") or "",
|
|
130
|
+
"secure": _safe_bool(_morsel_attr(morsel, "secure")),
|
|
131
|
+
"httponly": _safe_bool(_morsel_attr(morsel, "httponly")),
|
|
132
|
+
"samesite": _morsel_attr(morsel, "samesite") or "",
|
|
133
|
+
"partitioned": _safe_bool(_morsel_attr(morsel, "partitioned")),
|
|
134
|
+
"host_only": (domain, str(name)) in host_only_cookies,
|
|
135
|
+
"expiration": expirations.get((domain, path, str(name))),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _serialize_cookie_jar(cookie_jar: aiohttp.CookieJar) -> dict[str, Any]:
|
|
140
|
+
"""Persist cookies using an alexapy-owned serialization format.
|
|
141
|
+
|
|
142
|
+
aiohttp.CookieJar.save/load serializes private implementation details,
|
|
143
|
+
which have changed across aiohttp/Python releases (for example the
|
|
144
|
+
addition of the "Partitioned" cookie attribute). Using an explicit
|
|
145
|
+
serialization format provides forward compatibility and allows schema
|
|
146
|
+
versioning.
|
|
147
|
+
"""
|
|
148
|
+
serialized: dict[str, Any] = {
|
|
149
|
+
"format": COOKIE_SERIALIZATION_FORMAT,
|
|
150
|
+
"version": COOKIE_SERIALIZATION_VERSION,
|
|
151
|
+
"cookies": [],
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
cookies: list[dict[str, Any]] = serialized["cookies"]
|
|
155
|
+
host_only_cookies = getattr(cookie_jar, "_host_only_cookies", set())
|
|
156
|
+
expirations = getattr(cookie_jar, "_expirations", {})
|
|
157
|
+
seen: set[tuple[str, str, str]] = set()
|
|
158
|
+
|
|
159
|
+
# Preserve aiohttp's domain/path buckets when available. Current aiohttp uses
|
|
160
|
+
# a mapping like {(domain, path): SimpleCookie(...)}; older/newer versions may
|
|
161
|
+
# vary, so this is deliberately defensive.
|
|
162
|
+
jar_cookies = getattr(cookie_jar, "_cookies", {})
|
|
163
|
+
if isinstance(jar_cookies, dict):
|
|
164
|
+
for key, simple_cookie in jar_cookies.items():
|
|
165
|
+
domain = ""
|
|
166
|
+
path = "/"
|
|
167
|
+
if isinstance(key, tuple):
|
|
168
|
+
domain = str(key[0] or "") if len(key) >= 1 else ""
|
|
169
|
+
path = str(key[1] or "/") if len(key) >= 2 else "/"
|
|
170
|
+
elif isinstance(key, str):
|
|
171
|
+
# Some private aiohttp pickles have been observed with a string
|
|
172
|
+
# bucket key. Treat it as a domain, not as a cookie name.
|
|
173
|
+
domain = key
|
|
174
|
+
|
|
175
|
+
if not isinstance(simple_cookie, BaseCookie):
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
for name, morsel in simple_cookie.items():
|
|
179
|
+
entry = _serialize_morsel(
|
|
180
|
+
str(name),
|
|
181
|
+
morsel,
|
|
182
|
+
fallback_domain=domain,
|
|
183
|
+
fallback_path=path,
|
|
184
|
+
host_only_cookies=host_only_cookies,
|
|
185
|
+
expirations=expirations,
|
|
186
|
+
)
|
|
187
|
+
cookie_key = (entry["domain"], entry["path"], entry["name"])
|
|
188
|
+
if cookie_key in seen:
|
|
189
|
+
continue
|
|
190
|
+
seen.add(cookie_key)
|
|
191
|
+
cookies.append(entry)
|
|
192
|
+
|
|
193
|
+
# Fallback for aiohttp versions whose internals differ from the above.
|
|
194
|
+
for morsel in cookie_jar:
|
|
195
|
+
entry = _serialize_morsel(morsel.key, morsel)
|
|
196
|
+
cookie_key = (entry["domain"], entry["path"], entry["name"])
|
|
197
|
+
if cookie_key in seen:
|
|
198
|
+
continue
|
|
199
|
+
seen.add(cookie_key)
|
|
200
|
+
cookies.append(entry)
|
|
201
|
+
|
|
202
|
+
return serialized
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _deserialize_cookie(
|
|
206
|
+
cookie_jar: aiohttp.CookieJar, cookie: dict[str, Any]
|
|
207
|
+
) -> tuple[str, str] | None:
|
|
208
|
+
"""Restore one cookie entry to an aiohttp CookieJar."""
|
|
209
|
+
name = str(cookie.get("name") or "")
|
|
210
|
+
value = cookie.get("value")
|
|
211
|
+
domain = str(cookie.get("domain") or "")
|
|
212
|
+
path = str(cookie.get("path") or "/")
|
|
213
|
+
expires = str(cookie.get("expires") or "")
|
|
214
|
+
max_age = str(cookie.get("max_age") or "")
|
|
215
|
+
secure = _safe_bool(cookie.get("secure", True))
|
|
216
|
+
httponly = _safe_bool(cookie.get("httponly", False))
|
|
217
|
+
samesite = str(cookie.get("samesite") or "")
|
|
218
|
+
partitioned = _safe_bool(cookie.get("partitioned", False))
|
|
219
|
+
host_only = _safe_bool(cookie.get("host_only", False))
|
|
220
|
+
|
|
221
|
+
if not name or value is None:
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
cookie_value = _strip_wrapping_quotes(str(value))
|
|
225
|
+
raw_cookie = SimpleCookie()
|
|
226
|
+
raw_cookie[name] = cookie_value
|
|
227
|
+
morsel = raw_cookie[name]
|
|
228
|
+
|
|
229
|
+
clean_domain = domain.lstrip(".")
|
|
230
|
+
clean_path = path
|
|
231
|
+
if not clean_path.startswith("/"):
|
|
232
|
+
clean_path = f"/{clean_path}"
|
|
233
|
+
|
|
234
|
+
# For host-only cookies, do not set the Domain attribute. The response URL
|
|
235
|
+
# supplies the host scope. Domain cookies keep their explicit Domain attr.
|
|
236
|
+
if clean_domain and not host_only:
|
|
237
|
+
with contextlib.suppress(KeyError):
|
|
238
|
+
morsel["domain"] = clean_domain
|
|
239
|
+
if clean_path:
|
|
240
|
+
with contextlib.suppress(KeyError):
|
|
241
|
+
morsel["path"] = clean_path
|
|
242
|
+
if expires:
|
|
243
|
+
with contextlib.suppress(KeyError):
|
|
244
|
+
morsel["expires"] = expires
|
|
245
|
+
if max_age:
|
|
246
|
+
with contextlib.suppress(KeyError):
|
|
247
|
+
morsel["max-age"] = max_age
|
|
248
|
+
if secure:
|
|
249
|
+
with contextlib.suppress(KeyError):
|
|
250
|
+
morsel["secure"] = True
|
|
251
|
+
if httponly:
|
|
252
|
+
with contextlib.suppress(KeyError):
|
|
253
|
+
morsel["httponly"] = True
|
|
254
|
+
if samesite:
|
|
255
|
+
with contextlib.suppress(KeyError):
|
|
256
|
+
morsel["samesite"] = samesite
|
|
257
|
+
if partitioned:
|
|
258
|
+
# Only Python/aiohttp combinations that know the attr should keep it.
|
|
259
|
+
with contextlib.suppress(KeyError):
|
|
260
|
+
morsel["partitioned"] = True
|
|
261
|
+
|
|
262
|
+
cookie_jar.update_cookies(raw_cookie, _cookie_url(clean_domain, clean_path, secure))
|
|
263
|
+
return name, cookie_value
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _deserialize_cookie_jar(
|
|
267
|
+
cookie_jar: aiohttp.CookieJar, serialized: dict[str, Any]
|
|
268
|
+
) -> dict[str, str]:
|
|
269
|
+
"""Restore a serialized alexapy cookie jar into an aiohttp CookieJar."""
|
|
270
|
+
return_cookies: dict[str, str] = {}
|
|
271
|
+
if serialized.get("format") != COOKIE_SERIALIZATION_FORMAT:
|
|
272
|
+
return return_cookies
|
|
273
|
+
|
|
274
|
+
with contextlib.suppress(Exception):
|
|
275
|
+
cookie_jar.clear()
|
|
276
|
+
|
|
277
|
+
for cookie in serialized.get("cookies", []):
|
|
278
|
+
if not isinstance(cookie, dict):
|
|
279
|
+
continue
|
|
280
|
+
restored = _deserialize_cookie(cookie_jar, cookie)
|
|
281
|
+
if restored:
|
|
282
|
+
return_cookies[restored[0]] = restored[1]
|
|
283
|
+
|
|
284
|
+
return return_cookies
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _legacy_cookie_value(value: Any) -> Any:
|
|
288
|
+
"""Extract a value from old aiohttp private-cookie records."""
|
|
289
|
+
if isinstance(value, Morsel):
|
|
290
|
+
return value.value
|
|
291
|
+
if isinstance(value, dict):
|
|
292
|
+
return value.get("value") or value.get("coded_value")
|
|
293
|
+
return value
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _restore_legacy_aiohttp_cookie_mapping(
|
|
297
|
+
cookie_jar: aiohttp.CookieJar, cookies: dict[Any, Any]
|
|
298
|
+
) -> dict[str, str]:
|
|
299
|
+
"""Restore old aiohttp.CookieJar.save() mappings.
|
|
300
|
+
|
|
301
|
+
This avoids treating bucket keys as cookie names.
|
|
302
|
+
"""
|
|
303
|
+
return_cookies: dict[str, str] = {}
|
|
304
|
+
restored_count = 0
|
|
305
|
+
|
|
306
|
+
for bucket_key, bucket in cookies.items():
|
|
307
|
+
domain = ""
|
|
308
|
+
path = "/"
|
|
309
|
+
if isinstance(bucket_key, tuple):
|
|
310
|
+
domain = str(bucket_key[0] or "") if len(bucket_key) >= 1 else ""
|
|
311
|
+
path = str(bucket_key[1] or "/") if len(bucket_key) >= 2 else "/"
|
|
312
|
+
elif isinstance(bucket_key, str):
|
|
313
|
+
# aiohttp private bucket key; do not turn this into a cookie name.
|
|
314
|
+
domain = bucket_key
|
|
315
|
+
|
|
316
|
+
if isinstance(bucket, BaseCookie | dict):
|
|
317
|
+
iterator = bucket.items()
|
|
318
|
+
else:
|
|
319
|
+
continue
|
|
320
|
+
|
|
321
|
+
for name, stored in iterator:
|
|
322
|
+
cookie_domain = domain
|
|
323
|
+
cookie_path = path
|
|
324
|
+
expires = ""
|
|
325
|
+
max_age = ""
|
|
326
|
+
secure = True
|
|
327
|
+
httponly = False
|
|
328
|
+
samesite = ""
|
|
329
|
+
partitioned = False
|
|
330
|
+
value = _legacy_cookie_value(stored)
|
|
331
|
+
|
|
332
|
+
if isinstance(stored, Morsel):
|
|
333
|
+
cookie_domain = str(_morsel_attr(stored, "domain") or domain)
|
|
334
|
+
cookie_path = str(_morsel_attr(stored, "path") or path or "/")
|
|
335
|
+
expires = str(_morsel_attr(stored, "expires") or "")
|
|
336
|
+
max_age = str(_morsel_attr(stored, "max-age") or "")
|
|
337
|
+
secure = _safe_bool(_morsel_attr(stored, "secure"))
|
|
338
|
+
httponly = _safe_bool(_morsel_attr(stored, "httponly"))
|
|
339
|
+
samesite = str(_morsel_attr(stored, "samesite") or "")
|
|
340
|
+
partitioned = _safe_bool(_morsel_attr(stored, "partitioned"))
|
|
341
|
+
elif isinstance(stored, dict):
|
|
342
|
+
cookie_domain = str(stored.get("domain") or domain)
|
|
343
|
+
cookie_path = str(stored.get("path") or path or "/")
|
|
344
|
+
expires = str(stored.get("expires") or "")
|
|
345
|
+
max_age = str(stored.get("max-age") or stored.get("max_age") or "")
|
|
346
|
+
secure = _safe_bool(stored.get("secure", True))
|
|
347
|
+
httponly = _safe_bool(stored.get("httponly", False))
|
|
348
|
+
samesite = str(stored.get("samesite") or "")
|
|
349
|
+
partitioned = _safe_bool(stored.get("partitioned", False))
|
|
350
|
+
|
|
351
|
+
restored = _deserialize_cookie(
|
|
352
|
+
cookie_jar,
|
|
353
|
+
{
|
|
354
|
+
"name": str(name),
|
|
355
|
+
"value": value,
|
|
356
|
+
"domain": cookie_domain,
|
|
357
|
+
"path": cookie_path,
|
|
358
|
+
"expires": expires,
|
|
359
|
+
"max_age": max_age,
|
|
360
|
+
"secure": secure,
|
|
361
|
+
"httponly": httponly,
|
|
362
|
+
"samesite": samesite,
|
|
363
|
+
"partitioned": partitioned,
|
|
364
|
+
},
|
|
365
|
+
)
|
|
366
|
+
if restored:
|
|
367
|
+
restored_count += 1
|
|
368
|
+
return_cookies[restored[0]] = restored[1]
|
|
369
|
+
|
|
370
|
+
if restored_count:
|
|
371
|
+
_LOGGER.debug(
|
|
372
|
+
"Restored %s cookies from legacy aiohttp cookie mapping",
|
|
373
|
+
restored_count,
|
|
374
|
+
)
|
|
375
|
+
return return_cookies
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _is_flat_cookie_dict(cookies: dict[Any, Any]) -> bool:
|
|
379
|
+
"""Return True only for a legacy plain name/value cookie dict."""
|
|
380
|
+
return bool(cookies) and all(
|
|
381
|
+
isinstance(key, str)
|
|
382
|
+
and isinstance(value, str)
|
|
383
|
+
and not value.lstrip().startswith("{")
|
|
384
|
+
for key, value in cookies.items()
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
|
|
81
389
|
class AlexaLogin:
|
|
82
390
|
"""Class to handle login connection to Alexa. This class will not reconnect.
|
|
83
391
|
|
|
@@ -123,6 +431,7 @@ class AlexaLogin:
|
|
|
123
431
|
}
|
|
124
432
|
self._outputpath = outputpath
|
|
125
433
|
self._cookiefile: list[str] = [
|
|
434
|
+
self._outputpath(f".storage/{self._hass_domain}.{self.email}.cookies"),
|
|
126
435
|
self._outputpath(f".storage/{self._hass_domain}.{self.email}.pickle"),
|
|
127
436
|
self._outputpath(f"{self._hass_domain}.{self.email}.pickle"),
|
|
128
437
|
self._outputpath(f".storage/{self._hass_domain}.{self.email}.txt"),
|
|
@@ -317,153 +626,182 @@ class AlexaLogin:
|
|
|
317
626
|
return ""
|
|
318
627
|
|
|
319
628
|
async def load_cookie(self, cookies_txt: str = "") -> dict[str, str] | None: # noqa: PLR0915
|
|
320
|
-
"""Load
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
loaded
|
|
325
|
-
|
|
326
|
-
|
|
629
|
+
"""Load persisted cookies.
|
|
630
|
+
|
|
631
|
+
The preferred format is alexapy's versioned JSON .cookies file.
|
|
632
|
+
Legacy .pickle and .txt cookie files are still accepted for migration.
|
|
633
|
+
When a legacy file is successfully loaded, it is rewritten as .cookies
|
|
634
|
+
and the legacy files are removed.
|
|
635
|
+
"""
|
|
636
|
+
return_cookies: dict[str, str] = {}
|
|
637
|
+
if not self._cookiefile:
|
|
638
|
+
return return_cookies
|
|
639
|
+
|
|
640
|
+
if cookies_txt:
|
|
641
|
+
legacy_txt_cookiefile = self._cookiefile[-1]
|
|
642
|
+
_LOGGER.debug(
|
|
643
|
+
"Saving passed in cookie to %s\n%s",
|
|
644
|
+
legacy_txt_cookiefile.replace(self.email, hide_email(self.email)),
|
|
645
|
+
repr(cookies_txt),
|
|
646
|
+
)
|
|
647
|
+
try:
|
|
648
|
+
async with aiofiles.open(legacy_txt_cookiefile, mode="w") as localfile:
|
|
649
|
+
await localfile.write(cookies_txt)
|
|
650
|
+
except (OSError, EOFError, TypeError, AttributeError) as ex:
|
|
327
651
|
_LOGGER.debug(
|
|
328
|
-
"
|
|
329
|
-
|
|
330
|
-
|
|
652
|
+
"Error saving passed in cookie to %s: %s",
|
|
653
|
+
legacy_txt_cookiefile.replace(self.email, hide_email(self.email)),
|
|
654
|
+
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
|
331
655
|
)
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
656
|
+
|
|
657
|
+
for cookiefile in self._cookiefile:
|
|
658
|
+
_LOGGER.debug(
|
|
659
|
+
"Searching for cookies from %s",
|
|
660
|
+
cookiefile.replace(self.email, hide_email(self.email)),
|
|
661
|
+
)
|
|
662
|
+
if not await aioos.path.exists(cookiefile):
|
|
663
|
+
continue
|
|
664
|
+
|
|
665
|
+
_LOGGER.debug(
|
|
666
|
+
"Trying to load cookie from file %s",
|
|
667
|
+
cookiefile.replace(self.email, hide_email(self.email)),
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
cookies: (
|
|
671
|
+
dict[Any, Any]
|
|
672
|
+
| RequestsCookieJar
|
|
673
|
+
| http.cookiejar.MozillaCookieJar
|
|
674
|
+
| None
|
|
675
|
+
) = None
|
|
676
|
+
try:
|
|
677
|
+
async with aiofiles.open(cookiefile, "rb") as myfile:
|
|
678
|
+
raw_cookie_file = await myfile.read()
|
|
679
|
+
try:
|
|
680
|
+
cookies = loads(raw_cookie_file.decode())
|
|
681
|
+
if self._debug:
|
|
336
682
|
_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),
|
|
683
|
+
"JSON cookie loaded: %s %s", type(cookies), cookies
|
|
343
684
|
)
|
|
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
|
-
)
|
|
685
|
+
except (UnicodeDecodeError, JSONDecodeError):
|
|
686
|
+
cookies = pickle.loads(raw_cookie_file)
|
|
687
|
+
if self._debug:
|
|
688
|
+
_LOGGER.debug(
|
|
689
|
+
"Pickled cookie loaded: %s %s", type(cookies), cookies
|
|
690
|
+
)
|
|
691
|
+
except pickle.UnpicklingError:
|
|
360
692
|
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:
|
|
693
|
+
cookies = http.cookiejar.MozillaCookieJar(cookiefile)
|
|
694
|
+
cookies.load(ignore_discard=True, ignore_expires=True)
|
|
695
|
+
if self._debug:
|
|
376
696
|
_LOGGER.debug(
|
|
377
|
-
"
|
|
378
|
-
cookiefile.replace(self.email, hide_email(self.email)),
|
|
379
|
-
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
|
697
|
+
"Mozilla cookie loaded: %s %s", type(cookies), cookies
|
|
380
698
|
)
|
|
381
|
-
|
|
382
|
-
except (OSError, EOFError) as ex:
|
|
699
|
+
except (ValueError, http.cookiejar.LoadError) as ex:
|
|
383
700
|
_LOGGER.debug(
|
|
384
|
-
"
|
|
701
|
+
"Cookie %s is truncated: %s",
|
|
385
702
|
cookiefile.replace(self.email, hide_email(self.email)),
|
|
386
703
|
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
|
387
704
|
)
|
|
388
705
|
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
|
-
_LOGGER.debug("Found Mozillacookiejar")
|
|
432
|
-
for cookie in cookies:
|
|
433
|
-
if self._debug:
|
|
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('"')
|
|
706
|
+
except (OSError, EOFError) as ex:
|
|
707
|
+
_LOGGER.debug(
|
|
708
|
+
"Error loading cookie from %s: %s",
|
|
709
|
+
cookiefile.replace(self.email, hide_email(self.email)),
|
|
710
|
+
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
|
711
|
+
)
|
|
712
|
+
continue
|
|
713
|
+
|
|
714
|
+
numcookies = 0
|
|
715
|
+
if (
|
|
716
|
+
isinstance(cookies, dict)
|
|
717
|
+
and cookies.get("format") == COOKIE_SERIALIZATION_FORMAT
|
|
718
|
+
):
|
|
719
|
+
_LOGGER.debug("Loading serialized alexapy cookie jar")
|
|
720
|
+
cookie_jar = self._session.cookie_jar
|
|
721
|
+
return_cookies = _deserialize_cookie_jar(cookie_jar, cookies)
|
|
722
|
+
numcookies = len(return_cookies)
|
|
723
|
+
elif isinstance(cookies, RequestsCookieJar):
|
|
724
|
+
_LOGGER.debug("Loading RequestsCookieJar")
|
|
725
|
+
requests_cookies = cookies.get_dict()
|
|
726
|
+
for key, value in requests_cookies.items():
|
|
727
|
+
if self._debug:
|
|
728
|
+
_LOGGER.debug('Key: "%s", Value: "%s"', key, value)
|
|
729
|
+
# Skip "partitioned" so older Python http.cookies does not fail.
|
|
730
|
+
if key != "partitioned":
|
|
731
|
+
return_cookies[str(key)] = value.strip('"')
|
|
732
|
+
numcookies = len(return_cookies)
|
|
733
|
+
elif isinstance(cookies, defaultdict):
|
|
734
|
+
_LOGGER.debug("Loading legacy aiohttp cookie mapping")
|
|
735
|
+
cookie_jar = self._session.cookie_jar
|
|
736
|
+
return_cookies = _restore_legacy_aiohttp_cookie_mapping(
|
|
737
|
+
cookie_jar, cookies
|
|
738
|
+
)
|
|
739
|
+
numcookies = len(return_cookies)
|
|
740
|
+
elif isinstance(cookies, dict):
|
|
741
|
+
if _is_flat_cookie_dict(cookies):
|
|
742
|
+
_LOGGER.debug("Found legacy flat dict cookie")
|
|
743
|
+
return_cookies = {
|
|
744
|
+
str(key): str(value).strip('"')
|
|
745
|
+
for key, value in cookies.items()
|
|
746
|
+
if key != "partitioned"
|
|
747
|
+
}
|
|
441
748
|
numcookies = len(return_cookies)
|
|
442
749
|
else:
|
|
443
|
-
_LOGGER.debug("
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
750
|
+
_LOGGER.debug("Loading legacy aiohttp cookie dict mapping")
|
|
751
|
+
cookie_jar = self._session.cookie_jar
|
|
752
|
+
return_cookies = _restore_legacy_aiohttp_cookie_mapping(
|
|
753
|
+
cookie_jar, cookies
|
|
754
|
+
)
|
|
755
|
+
numcookies = len(return_cookies)
|
|
756
|
+
elif isinstance(cookies, http.cookiejar.MozillaCookieJar):
|
|
757
|
+
_LOGGER.debug("Found Mozillacookiejar")
|
|
758
|
+
for cookie in cookies:
|
|
759
|
+
if self._debug:
|
|
448
760
|
_LOGGER.debug(
|
|
449
|
-
"
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
hide_email(self.email),
|
|
453
|
-
),
|
|
761
|
+
"Processing cookie %s expires: %s",
|
|
762
|
+
cookie,
|
|
763
|
+
cookie.expires,
|
|
454
764
|
)
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
765
|
+
return_cookies[cookie.name] = cookie.value.strip('"')
|
|
766
|
+
numcookies = len(return_cookies)
|
|
767
|
+
else:
|
|
768
|
+
_LOGGER.debug("Ignoring unknown file %s", type(cookies))
|
|
769
|
+
|
|
770
|
+
if not numcookies:
|
|
771
|
+
continue
|
|
772
|
+
|
|
773
|
+
_LOGGER.debug("Loaded %s cookies", numcookies)
|
|
774
|
+
if cookiefile != self._cookiefile[0]:
|
|
775
|
+
_LOGGER.debug(
|
|
776
|
+
"Migrating legacy cookiefile to %s ",
|
|
777
|
+
self._cookiefile[0].replace(self.email, hide_email(self.email)),
|
|
778
|
+
)
|
|
779
|
+
try:
|
|
780
|
+
cookie_jar = self._session.cookie_jar
|
|
781
|
+
if not list(cookie_jar):
|
|
782
|
+
cookie_jar.update_cookies(
|
|
783
|
+
return_cookies, URL(f"https://{self.url}")
|
|
784
|
+
)
|
|
785
|
+
serialized_cookie_jar = _serialize_cookie_jar(cookie_jar)
|
|
786
|
+
async with aiofiles.open(
|
|
787
|
+
self._cookiefile[0], mode="w"
|
|
788
|
+
) as localfile:
|
|
789
|
+
await localfile.write(dumps(serialized_cookie_jar))
|
|
790
|
+
for legacy_cookiefile in self._cookiefile[1:]:
|
|
791
|
+
if await aioos.path.exists(legacy_cookiefile):
|
|
792
|
+
await delete_cookie(legacy_cookiefile)
|
|
793
|
+
except (OSError, EOFError, TypeError, AttributeError) as ex:
|
|
794
|
+
_LOGGER.debug(
|
|
795
|
+
"Error migrating cookie from %s to %s: %s",
|
|
796
|
+
cookiefile.replace(self.email, hide_email(self.email)),
|
|
797
|
+
self._cookiefile[0].replace(
|
|
798
|
+
self.email,
|
|
799
|
+
hide_email(self.email),
|
|
800
|
+
),
|
|
801
|
+
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
|
802
|
+
)
|
|
803
|
+
break
|
|
804
|
+
|
|
467
805
|
return return_cookies
|
|
468
806
|
|
|
469
807
|
async def close(self) -> None:
|
|
@@ -475,13 +813,9 @@ class AlexaLogin:
|
|
|
475
813
|
await self._session._connector.close()
|
|
476
814
|
self._session._connector = None
|
|
477
815
|
|
|
478
|
-
async def reset(self
|
|
816
|
+
async def reset(self) -> None:
|
|
479
817
|
"""Remove data related to existing login."""
|
|
480
|
-
_LOGGER.debug(
|
|
481
|
-
"Resetting Login for %s - %s",
|
|
482
|
-
hide_email(self.email),
|
|
483
|
-
self.url,
|
|
484
|
-
)
|
|
818
|
+
_LOGGER.debug("Resetting Login for %s - %s", hide_email(self.email), self.url)
|
|
485
819
|
await self.close()
|
|
486
820
|
self._session = None
|
|
487
821
|
self._data = None
|
|
@@ -493,15 +827,9 @@ class AlexaLogin:
|
|
|
493
827
|
self._create_session()
|
|
494
828
|
self._close_requested = False
|
|
495
829
|
|
|
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)
|
|
830
|
+
for cookiefile in self._cookiefile:
|
|
831
|
+
if (cookiefile) and os.path.exists(cookiefile):
|
|
832
|
+
await delete_cookie(cookiefile)
|
|
505
833
|
|
|
506
834
|
@classmethod
|
|
507
835
|
def get_inputs(cls, soup: BeautifulSoup, searchfield=None) -> dict[str, str]:
|
|
@@ -689,10 +1017,7 @@ class AlexaLogin:
|
|
|
689
1017
|
_LOGGER.debug("Using cookies to log in")
|
|
690
1018
|
if await self.test_loggedin(cookies):
|
|
691
1019
|
return
|
|
692
|
-
|
|
693
|
-
"Cookie login failed; resetting session without deleting cookies"
|
|
694
|
-
)
|
|
695
|
-
await self.reset(delete_cookies=False)
|
|
1020
|
+
await self.reset()
|
|
696
1021
|
_LOGGER.debug("Using credentials to log in")
|
|
697
1022
|
if not self._site:
|
|
698
1023
|
site: URL = self.start_url
|
|
@@ -844,6 +1169,7 @@ class AlexaLogin:
|
|
|
844
1169
|
async def save_cookiefile(self) -> None:
|
|
845
1170
|
"""Save login session cookies to file."""
|
|
846
1171
|
self._cookiefile = [
|
|
1172
|
+
self._outputpath(f".storage/{self._hass_domain}.{self.email}.cookies"),
|
|
847
1173
|
self._outputpath(f".storage/{self._hass_domain}.{self.email}.pickle"),
|
|
848
1174
|
self._outputpath(f"{self._hass_domain}.{self.email}.pickle"),
|
|
849
1175
|
self._outputpath(f".storage/{self._hass_domain}.{self.email}.txt"),
|
|
@@ -854,14 +1180,15 @@ class AlexaLogin:
|
|
|
854
1180
|
assert isinstance(cookie_jar, aiohttp.CookieJar)
|
|
855
1181
|
if self._debug:
|
|
856
1182
|
_LOGGER.debug("Saving cookie to %s", cookiefile)
|
|
857
|
-
loop = asyncio.get_event_loop()
|
|
858
1183
|
try:
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
1184
|
+
serialized_cookie_jar = _serialize_cookie_jar(cookie_jar)
|
|
1185
|
+
async with aiofiles.open(
|
|
1186
|
+
self._cookiefile[0], mode="w"
|
|
1187
|
+
) as localfile:
|
|
1188
|
+
await localfile.write(dumps(serialized_cookie_jar))
|
|
862
1189
|
except (OSError, EOFError, TypeError, AttributeError) as ex:
|
|
863
1190
|
_LOGGER.debug(
|
|
864
|
-
"Error saving
|
|
1191
|
+
"Error saving serialized cookie to %s: %s",
|
|
865
1192
|
self._cookiefile[0].replace(self.email, hide_email(self.email)),
|
|
866
1193
|
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
|
867
1194
|
)
|
|
@@ -877,6 +1204,7 @@ class AlexaLogin:
|
|
|
877
1204
|
async def delete_cookiefile(self) -> None:
|
|
878
1205
|
"""Delete cookiefile."""
|
|
879
1206
|
self._cookiefile = [
|
|
1207
|
+
self._outputpath(f".storage/{self._hass_domain}.{self.email}.cookies"),
|
|
880
1208
|
self._outputpath(f".storage/{self._hass_domain}.{self.email}.pickle"),
|
|
881
1209
|
self._outputpath(f"{self._hass_domain}.{self.email}.pickle"),
|
|
882
1210
|
self._outputpath(f".storage/{self._hass_domain}.{self.email}.txt"),
|
|
@@ -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
|
|
File without changes
|