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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: AlexaPy
3
- Version: 1.29.23
3
+ Version: 1.29.25
4
4
  Summary: Python API to control Amazon Echo Devices Programmatically.
5
5
  License: Apache-2.0
6
6
  License-File: AUTHORS.md
@@ -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
- AlexaAPI.devices[login.email] = (
1479
- devices if devices else AlexaAPI.devices[login.email]
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 APP_NAME, CALL_VERSION, EXCEPTION_TEMPLATE, LOCALE_KEY, USER_AGENT
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
- """Ensure cookies.Morsel contains "partitioned"
58
- See: https://github.com/python/cpython/issues/112713
59
- """
60
- partitioned = {"partitioned": "Partitioned"}
61
- Morsel._reserved.update(partitioned)
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 cookie from disk."""
321
- cookies: RequestsCookieJar | http.cookiejar.MozillaCookieJar | None = None
322
- return_cookies = {}
323
- numcookies: int = 0
324
- loaded: bool = False
325
- if self._cookiefile:
326
- if cookies_txt:
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
- "Saving passed in cookie to %s\n%s",
329
- self._cookiefile[0].replace(self.email, hide_email(self.email)),
330
- repr(cookies_txt),
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
- async with aiofiles.open(self._cookiefile[0], mode="w") as localfile:
333
- try:
334
- await localfile.write(cookies_txt)
335
- except (OSError, EOFError, TypeError, AttributeError) as ex:
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
- "Error saving passed in cookie to %s: %s",
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
- for cookiefile in self._cookiefile:
345
- _LOGGER.debug(
346
- "Searching for cookies from %s",
347
- cookiefile.replace(self.email, hide_email(self.email)),
348
- )
349
- if loaded:
350
- break
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
- async with aiofiles.open(cookiefile, "rb") as myfile:
362
- cookies = pickle.loads(await myfile.read())
363
- if self._debug:
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
- "Cookie %s is truncated: %s",
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
- continue
382
- except (OSError, EOFError) as ex:
726
+ except (ValueError, http.cookiejar.LoadError) as ex:
383
727
  _LOGGER.debug(
384
- "Error loading cookie from %s: %s",
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
- if isinstance(cookies, RequestsCookieJar):
390
- _LOGGER.debug("Loading RequestsCookieJar")
391
- cookies = cookies.get_dict()
392
- assert cookies is not None
393
- for key, value in cookies.items():
394
- if self._debug:
395
- _LOGGER.debug('Key: "%s", Value: "%s"', key, value)
396
- # skip "partitioned" key so python 3.12 http/cookies.py doesn't throw error # noqa: E501
397
- if key != "partitioned":
398
- # escape extra quote marks from Requests cookie
399
- return_cookies[str(key)] = value.strip('"')
400
- numcookies = len(return_cookies)
401
- elif isinstance(cookies, defaultdict):
402
- _LOGGER.debug("Trying to load aiohttpCookieJar to session")
403
- cookie_jar: aiohttp.CookieJar = self._session.cookie_jar
404
- loop = asyncio.get_event_loop()
405
- try:
406
- cookie_jar = await loop.run_in_executor(
407
- None, cookie_jar.load, cookiefile
408
- )
409
- return_cookies = self._get_cookies_from_session()
410
- numcookies = len(return_cookies)
411
- except (
412
- OSError,
413
- EOFError,
414
- TypeError,
415
- AttributeError,
416
- ValueError,
417
- ) as ex:
418
- _LOGGER.debug(
419
- "Error loading aiohttpcookie from %s: %s",
420
- cookiefile,
421
- EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
422
- )
423
- # a cookie_jar.load error can corrupt the session
424
- # so we must recreate it
425
- self._create_session(True)
426
- elif isinstance(cookies, dict):
427
- _LOGGER.debug("Found dict cookie")
428
- return_cookies = cookies
429
- numcookies = len(return_cookies)
430
- elif isinstance(cookies, http.cookiejar.MozillaCookieJar):
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('"')
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("Ignoring unknown file %s", type(cookies))
444
- if numcookies:
445
- _LOGGER.debug("Loaded %s cookies", numcookies)
446
- loaded = True
447
- if cookiefile != self._cookiefile[0]:
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
- "Migrating old cookiefile to %s ",
450
- self._cookiefile[0].replace(
451
- self.email,
452
- hide_email(self.email),
453
- ),
793
+ "Processing cookie %s expires: %s",
794
+ cookie,
795
+ cookie.expires,
454
796
  )
455
- try:
456
- await aioos.rename(cookiefile, self._cookiefile[0])
457
- except (OSError, EOFError, TypeError, AttributeError) as ex:
458
- _LOGGER.debug(
459
- "Error moving cookie from %s to %s: %s",
460
- cookiefile.replace(self.email, hide_email(self.email)),
461
- self._cookiefile[0].replace(
462
- self.email,
463
- hide_email(self.email),
464
- ),
465
- EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
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, *, delete_cookies: bool = True) -> None:
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
- if delete_cookies:
497
- _LOGGER.debug(
498
- "Deleting cookies for %s - %s",
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
- _LOGGER.debug(
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
- await loop.run_in_executor(
860
- None, cookie_jar.save, self._cookiefile[0]
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 pickled cookie to %s: %s",
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"
@@ -1,7 +1,7 @@
1
1
  # SPDX-License-Identifier: Apache-2.0
2
2
  [tool.poetry]
3
3
  name = "AlexaPy"
4
- version = "1.29.23"
4
+ version = "1.29.25"
5
5
  description = "Python API to control Amazon Echo Devices Programmatically."
6
6
  authors = [
7
7
  "Keaton Taylor <keatonstaylor@gmail.com>",
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes