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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: AlexaPy
3
- Version: 1.29.23
3
+ Version: 1.29.24
4
4
  Summary: Python API to control Amazon Echo Devices Programmatically.
5
5
  License: Apache-2.0
6
6
  License-File: AUTHORS.md
@@ -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,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 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:
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
- "Saving passed in cookie to %s\n%s",
329
- self._cookiefile[0].replace(self.email, hide_email(self.email)),
330
- repr(cookies_txt),
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
- 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:
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
- "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),
683
+ "JSON cookie loaded: %s %s", type(cookies), cookies
343
684
  )
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
- )
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
- 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:
693
+ cookies = http.cookiejar.MozillaCookieJar(cookiefile)
694
+ cookies.load(ignore_discard=True, ignore_expires=True)
695
+ if self._debug:
376
696
  _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),
697
+ "Mozilla cookie loaded: %s %s", type(cookies), cookies
380
698
  )
381
- continue
382
- except (OSError, EOFError) as ex:
699
+ except (ValueError, http.cookiejar.LoadError) as ex:
383
700
  _LOGGER.debug(
384
- "Error loading cookie from %s: %s",
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
- 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('"')
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("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]:
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
- "Migrating old cookiefile to %s ",
450
- self._cookiefile[0].replace(
451
- self.email,
452
- hide_email(self.email),
453
- ),
761
+ "Processing cookie %s expires: %s",
762
+ cookie,
763
+ cookie.expires,
454
764
  )
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
- )
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, *, delete_cookies: bool = True) -> None:
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
- 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)
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
- _LOGGER.debug(
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
- await loop.run_in_executor(
860
- None, cookie_jar.save, self._cookiefile[0]
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 pickled cookie to %s: %s",
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"
@@ -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.24"
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
File without changes