AlexaPy 1.29.25__tar.gz → 1.30.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: AlexaPy
3
- Version: 1.29.25
3
+ Version: 1.30.0
4
4
  Summary: Python API to control Amazon Echo Devices Programmatically.
5
5
  License: Apache-2.0
6
6
  License-File: AUTHORS.md
@@ -137,9 +137,7 @@ class AlexaAPI:
137
137
  cached = getattr(login, "_alexa_api_url", None)
138
138
  if cached:
139
139
  _LOGGER.debug(
140
- "%s: Returning cached url: %s",
141
- hide_email(login.email),
142
- cached
140
+ "%s: Returning cached url: %s", hide_email(login.email), cached
143
141
  )
144
142
  return cached
145
143
 
@@ -159,9 +157,7 @@ class AlexaAPI:
159
157
 
160
158
  try:
161
159
  resp = await session.get(
162
- endpoints_url,
163
- headers=local_headers,
164
- ssl=login._ssl
160
+ endpoints_url, headers=local_headers, ssl=login._ssl
165
161
  )
166
162
  text = await resp.text()
167
163
  _LOGGER.debug(
@@ -176,10 +172,7 @@ class AlexaAPI:
176
172
  if resp.status == 200:
177
173
  try:
178
174
  data = json.loads(text)
179
- api_url = (
180
- data.get("websiteApiUrl", "")
181
- .rstrip("/")
182
- )
175
+ api_url = data.get("websiteApiUrl", "").rstrip("/")
183
176
  if api_url:
184
177
  login._alexa_api_url = api_url
185
178
  _LOGGER.debug(
@@ -191,7 +184,7 @@ class AlexaAPI:
191
184
  else:
192
185
  _LOGGER.debug(
193
186
  "%s: Unable to extract websiteApiUrl from /api/endpoints",
194
- hide_email(login.email)
187
+ hide_email(login.email),
195
188
  )
196
189
  except ValueError:
197
190
  _LOGGER.debug(
@@ -516,7 +509,7 @@ class AlexaAPI:
516
509
  for marker in (
517
510
  "Rate exceeded",
518
511
  "ThrottlingException",
519
- "Too many requests"
512
+ "Too many requests",
520
513
  )
521
514
  )
522
515
  and not getattr(login, "_rate_retry_inflight", False)
@@ -1494,6 +1487,177 @@ class AlexaAPI:
1494
1487
  AlexaAPI.devices[login.email] = []
1495
1488
  return AlexaAPI.devices[login.email]
1496
1489
 
1490
+ @staticmethod
1491
+ @_catch_all_exceptions
1492
+ async def get_child_mode(
1493
+ login: AlexaLogin, serial: str, device_type: str
1494
+ ) -> bool | None:
1495
+ """Return whether Amazon Kids (child mode) is active for a device.
1496
+
1497
+ Amazon models the Amazon Kids/child state per device as the boolean
1498
+ ``isChildDirectedDevice``.
1499
+
1500
+ Args:
1501
+ login (AlexaLogin): Successfully logged in AlexaLogin
1502
+ serial (str): The device serial number
1503
+ device_type (str): The device type
1504
+
1505
+ Returns:
1506
+ Optional[bool]: True if Amazon Kids is active, False if not,
1507
+ None if the state could not be determined.
1508
+
1509
+ """
1510
+ response = await AlexaAPI._static_request(
1511
+ "get",
1512
+ login,
1513
+ "/api/device/op-mode",
1514
+ query={"deviceType": device_type, "deviceSerialNumber": serial},
1515
+ )
1516
+ value, _valid = await get_json_value(response, "isChildDirectedDevice", bool)
1517
+ return value
1518
+
1519
+ @staticmethod
1520
+ @_catch_all_exceptions
1521
+ async def disable_child_mode(
1522
+ login: AlexaLogin, serial: str, device_type: str
1523
+ ) -> None:
1524
+ """Disable Amazon Kids (child mode) for a device.
1525
+
1526
+ Unassigns the device from any child profile, which turns Amazon Kids
1527
+ off (``isChildDirectedDevice`` becomes False).
1528
+
1529
+ Args:
1530
+ login (AlexaLogin): Successfully logged in AlexaLogin
1531
+ serial (str): The device serial number
1532
+ device_type (str): The device type
1533
+
1534
+ """
1535
+ await AlexaAPI._static_request(
1536
+ "post",
1537
+ login,
1538
+ f"/api/unassign-device-from-child/{serial}/{device_type}",
1539
+ )
1540
+
1541
+ @staticmethod
1542
+ def _parent_dashboard_subdomain(login: AlexaLogin) -> str:
1543
+ """Return the region subdomain of the Amazon Kids parent dashboard.
1544
+
1545
+ Amazon serves the parental-controls dashboard from a localized
1546
+ subdomain (e.g. ``eltern.amazon.de`` in Germany, ``parents.amazon.com``
1547
+ elsewhere).
1548
+ """
1549
+ return {"amazon.de": "eltern"}.get(login.url, "parents")
1550
+
1551
+ @staticmethod
1552
+ @_catch_all_exceptions
1553
+ async def get_child_profiles(login: AlexaLogin) -> list[dict[str, Any]] | None:
1554
+ """Return the child profiles of the Amazon household.
1555
+
1556
+ Each entry contains at least ``firstName``, ``age`` and ``directedId``;
1557
+ the ``directedId`` is required to enable Amazon Kids on a device.
1558
+
1559
+ Args:
1560
+ login (AlexaLogin): Successfully logged in AlexaLogin
1561
+
1562
+ Returns:
1563
+ Optional[list[dict]]: The CHILD household members, or None on error.
1564
+
1565
+ """
1566
+ response = await AlexaAPI._static_request(
1567
+ "get",
1568
+ login,
1569
+ "/ajax/get-household-with-age",
1570
+ sub_domain=AlexaAPI._parent_dashboard_subdomain(login),
1571
+ )
1572
+ members, valid = await get_json_value(response, "members", list)
1573
+ if not valid or not members:
1574
+ return []
1575
+ return [member for member in members if member.get("role") == "CHILD"]
1576
+
1577
+ @staticmethod
1578
+ @_catch_all_exceptions
1579
+ async def get_device_child(
1580
+ login: AlexaLogin, serial: str, device_type: str
1581
+ ) -> str | None:
1582
+ """Return the directedId of the child a device is assigned to.
1583
+
1584
+ Args:
1585
+ login (AlexaLogin): Successfully logged in AlexaLogin
1586
+ serial (str): The device serial number
1587
+ device_type (str): The device type
1588
+
1589
+ Returns:
1590
+ Optional[str]: The child directedId, or None if unassigned/unknown.
1591
+
1592
+ """
1593
+ response = await AlexaAPI._static_request(
1594
+ "get",
1595
+ login,
1596
+ "/ajax/get-oobe-device-data",
1597
+ query={"deviceId": serial, "deviceTypeId": device_type},
1598
+ sub_domain=AlexaAPI._parent_dashboard_subdomain(login),
1599
+ )
1600
+ value, _valid = await get_json_value(response, "childDirectedId", str)
1601
+ return value or None
1602
+
1603
+ @staticmethod
1604
+ @_catch_all_exceptions
1605
+ async def enable_child_mode(
1606
+ login: AlexaLogin, serial: str, device_type: str, child_directed_id: str
1607
+ ) -> None:
1608
+ """Enable Amazon Kids by assigning a device to a child profile.
1609
+
1610
+ The parent dashboard uses its own CSRF token (``ft-panda-csrf-token``),
1611
+ which is seeded by loading the device onboarding page first; the token
1612
+ is then echoed back in the ``x-amzn-csrf`` header.
1613
+
1614
+ Args:
1615
+ login (AlexaLogin): Successfully logged in AlexaLogin
1616
+ serial (str): The device serial number
1617
+ device_type (str): The device type
1618
+ child_directed_id (str): directedId of the child (see get_child_profiles)
1619
+
1620
+ """
1621
+ sub_domain = AlexaAPI._parent_dashboard_subdomain(login)
1622
+ # Seed the parent-dashboard CSRF cookie (ft-panda-csrf-token).
1623
+ await AlexaAPI._static_request(
1624
+ "get",
1625
+ login,
1626
+ f"/oobe/brownie/start/{device_type}/{serial}",
1627
+ sub_domain=sub_domain,
1628
+ )
1629
+ additional_headers = {
1630
+ "Referer": (
1631
+ f"https://{sub_domain}.{login.url}"
1632
+ f"/oobe/brownie/start/{device_type}/{serial}"
1633
+ ),
1634
+ "Origin": f"https://{sub_domain}.{login.url}",
1635
+ }
1636
+ try:
1637
+ # The ft-panda-csrf-token cookie is scoped to the parent-dashboard
1638
+ # host, so it must be read for that host (not the default one).
1639
+ additional_headers["x-amzn-csrf"] = login._get_cookies_from_session(
1640
+ f"{sub_domain}.{login.url}"
1641
+ )["ft-panda-csrf-token"].value
1642
+ except (KeyError, AttributeError):
1643
+ _LOGGER.debug(
1644
+ "%s: ft-panda-csrf-token not found after bootstrap; "
1645
+ "assign may be rejected",
1646
+ hide_email(login.email),
1647
+ )
1648
+ await AlexaAPI._static_request(
1649
+ "post",
1650
+ login,
1651
+ "/ajax/assign-device-to-child",
1652
+ data={
1653
+ "deviceId": serial,
1654
+ "deviceTypeId": device_type,
1655
+ "childDirectedId": child_directed_id,
1656
+ },
1657
+ additional_headers=additional_headers,
1658
+ sub_domain=sub_domain,
1659
+ )
1660
+
1497
1661
  @staticmethod
1498
1662
  @_catch_all_exceptions
1499
1663
  async def get_wake_words(login: AlexaLogin) -> list[dict[str, Any]] | None:
@@ -1633,9 +1797,7 @@ class AlexaAPI:
1633
1797
 
1634
1798
  record_key = (record.get("recordKey") or "").split("#")
1635
1799
  o["deviceType"] = (
1636
- record_key[2]
1637
- if len(record_key) > 2 and record_key[2]
1638
- else None
1800
+ record_key[2] if len(record_key) > 2 and record_key[2] else None
1639
1801
  )
1640
1802
  o["creationTimestamp"] = record.get("timestamp")
1641
1803
  o["deviceSerialNumber"] = record_key[3] if len(record_key) > 3 else None
@@ -2072,8 +2234,10 @@ class AlexaAPI:
2072
2234
  # 🔹 Use discovered base instead of hard-coded NA
2073
2235
  api_base = await AlexaAPI._get_alexa_api_base(login)
2074
2236
  ts = int(time.time() * 1000)
2075
- url = URL(api_base).with_path("/api/notifications").update_query(
2076
- {"cached": "true", "_": str(ts)}
2237
+ url = (
2238
+ URL(api_base)
2239
+ .with_path("/api/notifications")
2240
+ .update_query({"cached": "true", "_": str(ts)})
2077
2241
  )
2078
2242
 
2079
2243
  headers = AlexaAPI._build_notifications_headers(login)
@@ -55,9 +55,34 @@ class HTTP2EchoClient:
55
55
  close_callback: Callable[[], Coroutine[Any, Any, None]],
56
56
  error_callback: Callable[[str], Coroutine[Any, Any, None]],
57
57
  loop: asyncio.AbstractEventLoop | None = None,
58
+ *,
59
+ read_timeout: float | None = 300,
58
60
  ) -> None:
59
- """Init for threading and HTTP2 Push Connection."""
61
+ """Init for threading and HTTP2 Push Connection.
62
+
63
+ Args:
64
+ login: Authenticated AlexaLogin whose session opens the
65
+ directives stream.
66
+ msg_callback: Coroutine invoked with each directive message
67
+ parsed from the stream.
68
+ open_callback: Coroutine invoked once the HTTP2 stream has been
69
+ accepted and opened.
70
+ close_callback: Coroutine invoked when the stream is closed.
71
+ error_callback: Coroutine invoked with an error message when the
72
+ connection fails.
73
+ loop: Event loop to schedule tasks on; defaults to the running
74
+ loop when omitted.
75
+ read_timeout: Maximum seconds to wait between chunks of data on
76
+ the directives stream before treating the connection as stale
77
+ and closing it. A healthy stream carries multipart keepalive
78
+ traffic well inside this window; a stream that has gone silent
79
+ without a transport-level close delivers nothing and would
80
+ otherwise hang forever without any error. Pass None to disable
81
+ the check (previous behavior).
82
+ """
60
83
  assert login.session is not None
84
+ if read_timeout is not None and read_timeout <= 0:
85
+ raise ValueError("read_timeout must be positive or None")
61
86
  self._options = {
62
87
  "method": "GET",
63
88
  "path": "/v20160207/directives",
@@ -81,10 +106,21 @@ class HTTP2EchoClient:
81
106
  loop if loop else asyncio.get_event_loop()
82
107
  )
83
108
  self._last_ping = datetime.datetime(1, 1, 1)
109
+ self._last_activity: datetime.datetime | None = None
110
+ self._read_timeout = read_timeout
84
111
  self._tasks = set()
85
112
  self._opened: asyncio.Event = asyncio.Event()
86
113
  self._closing: bool = False
87
114
 
115
+ @property
116
+ def last_activity(self) -> datetime.datetime | None:
117
+ """Return when data last arrived on the directives stream.
118
+
119
+ Any traffic counts, including multipart keepalive boundaries, not
120
+ only parsed directives. None until the first chunk arrives.
121
+ """
122
+ return self._last_activity
123
+
88
124
  async def async_run(self) -> None:
89
125
  """Start Async WebSocket Listener.
90
126
 
@@ -133,7 +169,11 @@ class HTTP2EchoClient:
133
169
  headers={
134
170
  "authorization": self._options["authorization"],
135
171
  },
136
- timeout=httpx.Timeout(None),
172
+ # Never time out the overall stream, but bound the gap between
173
+ # chunks: the server sends keepalive boundaries continuously,
174
+ # so a long silent gap means the connection is dead even if
175
+ # the transport still looks open.
176
+ timeout=httpx.Timeout(None, read=self._read_timeout),
137
177
  ) as response:
138
178
  # Validate the stream was accepted before reporting "open" upstream.
139
179
  if response.status_code in (401, 403):
@@ -164,6 +204,24 @@ class HTTP2EchoClient:
164
204
 
165
205
  # Otherwise, normal disconnect.
166
206
  return
207
+ except httpx.ReadTimeout:
208
+ # No data (not even keepalives) inside the read window: the
209
+ # stream has gone silent without a transport-level close. Handle
210
+ # it so the consumer reconnects instead of hanging forever.
211
+ _LOGGER.debug(
212
+ "HTTP2 stream silent for %ss; closing stale connection",
213
+ self._read_timeout,
214
+ )
215
+
216
+ # If we never reached "open", propagate so the caller waiting on
217
+ # open sees a failed connect. The shared finally below still
218
+ # notifies close once, as it does for the other error paths.
219
+ if not self._opened.is_set():
220
+ raise
221
+
222
+ # Otherwise, treat like a normal disconnect so reconnect runs.
223
+ self.on_close(f"HTTP2 stream silent for {self._read_timeout}s")
224
+ return
167
225
  except Exception as exception_:
168
226
  # Surface unexpected failures so callers waiting for open can fail fast.
169
227
  _LOGGER.debug("HTTP2 exception: %s", exception_)
@@ -178,6 +236,7 @@ class HTTP2EchoClient:
178
236
  """Handle New Message."""
179
237
  reauth_required = "Unable to authenticate the request. Please provide a valid authorization token." # noqa: E501
180
238
  _LOGGER.debug("Received raw message: %s", message)
239
+ self._last_activity = datetime.datetime.now(datetime.UTC)
181
240
  for line in message.splitlines():
182
241
  if line.startswith("------"):
183
242
  if not self.boundary: # set boundary character
@@ -703,6 +703,12 @@ class AlexaLogin:
703
703
  try:
704
704
  async with aiofiles.open(cookiefile, "rb") as myfile:
705
705
  raw_cookie_file = await myfile.read()
706
+ if not raw_cookie_file:
707
+ _LOGGER.debug(
708
+ "Cookie file %s is empty; ignoring it",
709
+ cookiefile.replace(self.email, hide_email(self.email)),
710
+ )
711
+ continue
706
712
  try:
707
713
  cookies = loads(raw_cookie_file.decode())
708
714
  if self._debug:
@@ -880,95 +886,114 @@ class AlexaLogin:
880
886
  pass
881
887
  return data
882
888
 
883
- async def test_loggedin(self, cookies: dict[str, str] | None = None) -> bool:
884
- """Function that will test the connection is logged in.
889
+ async def test_loggedin(
890
+ self,
891
+ cookies: dict[str, str] | None = None,
892
+ *,
893
+ rebuild_session: bool = True,
894
+ ) -> bool:
895
+ """Test whether the connection is logged in.
896
+
897
+ Attempts to retrieve the authenticated customer and compare the returned
898
+ email address with the expected login email.
885
899
 
886
- Tests:
887
- - Attempts to get authentication and compares to expected login email
888
- Returns false if unsuccessful getting json or the emails don't match
889
- Returns false if no csrf found; necessary to issue commands
900
+ Returns False if the response cannot be decoded, the email does not match,
901
+ or no usable authenticated session can be confirmed.
890
902
  """
891
903
  if self._debug:
892
- _LOGGER.debug("Testing whether logged in to alexa.%s", self._url)
904
+ _LOGGER.debug(
905
+ "Testing whether logged in to alexa.%s (rebuild_session=%s)",
906
+ self._url,
907
+ rebuild_session
908
+ )
893
909
  _LOGGER.debug("Cookies: %s", cookies)
894
910
  _LOGGER.debug("Session Cookies:\n%s", self._print_session_cookies())
895
911
  _LOGGER.debug("Header: %s", dumps(self._headers))
912
+
896
913
  if not self._session:
897
914
  self._create_session()
898
- await self.get_tokens()
899
- await self.register_capabilities()
900
- await self.exchange_token_for_cookies()
901
- await self.get_csrf()
902
- path = (
903
- self._prefix
904
- + "amazon.com"
905
- + f"/api/users/me?platform=ios&version={CALL_VERSION}"
906
- )
907
- self._log_cookies_for_url(path)
908
- get_resp = await self._session.get(
909
- path,
910
- cookies=cookies,
911
- ssl=self._ssl,
912
- )
913
- email = None
914
- json = None
915
- await self._process_resp(get_resp)
916
- try:
917
- json = await get_resp.json()
918
- email = json.get("email")
919
- except (JSONDecodeError, SimpleJSONDecodeError, ContentTypeError) as ex:
920
- _LOGGER.debug(
921
- "Not logged in: %s",
922
- EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
923
- )
924
- if self.url.lower() == "amazon.com":
925
- return False
926
- # Convert from amazon.com domain to native domain
927
- if self.url.lower() != "amazon.com":
928
- self._headers["authority"] = f"www.{self._url}"
915
+
916
+ if rebuild_session:
917
+ await self.get_tokens()
918
+ await self.register_capabilities()
919
+ await self.exchange_token_for_cookies()
920
+ await self.get_csrf()
921
+
922
+ domains = [self._url]
923
+ if self._url.lower() != "amazon.com":
924
+ domains.append("amazon.com")
925
+
926
+ customer = None
927
+
928
+ for domain in domains:
929
+ self._headers["authority"] = f"www.{domain}"
929
930
  path = (
930
931
  self._prefix
931
- + self._url
932
+ + domain
932
933
  + f"/api/users/me?platform=ios&version={CALL_VERSION}"
933
934
  )
935
+
934
936
  self._log_cookies_for_url(path)
935
- get_resp = await self._session.get(path)
937
+ get_resp = await self._session.get(
938
+ path,
939
+ cookies=cookies,
940
+ ssl=self._ssl,
941
+ )
936
942
  await self._process_resp(get_resp)
943
+
937
944
  try:
938
- json = await get_resp.json()
939
- email = json.get("email")
940
- except (JSONDecodeError, SimpleJSONDecodeError, ContentTypeError) as ex:
945
+ customer = await get_resp.json()
946
+ except (
947
+ JSONDecodeError,
948
+ SimpleJSONDecodeError,
949
+ ContentTypeError,
950
+ ) as ex:
941
951
  _LOGGER.debug(
942
- "Not logged in: %s",
952
+ "Not logged in to %s: %s",
953
+ domain,
943
954
  EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
944
955
  )
945
- return False
946
- self.customer_id = json.get("id")
947
- if (email and email.lower() == self.email.lower()) or "@" not in self.email:
948
- if "@" in self.email:
949
- _LOGGER.debug(
950
- "Logged in as %s to %s with id: %s",
951
- hide_email(email),
952
- self.url,
953
- self.customer_id,
954
- )
955
- else:
956
- _LOGGER.debug(
957
- "Logged in as to %s mobile account %s with %s",
958
- hide_email(email),
959
- self.url,
960
- self.customer_id,
961
- )
962
- self.stats["login_timestamp"] = datetime.datetime.now()
963
- self.stats["api_calls"] = 0
964
- await self.check_domain()
965
- await self.finalize_login()
966
- return True
967
- _LOGGER.debug(
968
- "Not logged in due to email mismatch to stored %s", hide_email(email)
969
- )
970
- await self.reset()
971
- return False
956
+ continue
957
+
958
+ email = customer.get("email")
959
+ if (email and email.lower() == self.email.lower()) or "@" not in self.email:
960
+ break
961
+
962
+ _LOGGER.debug(
963
+ "Login email from %s did not match stored email: %s",
964
+ domain,
965
+ hide_email(email),
966
+ )
967
+ customer = None
968
+
969
+ if customer is None:
970
+ _LOGGER.debug("Not logged in to any attempted Alexa domain")
971
+ await self.reset()
972
+ return False
973
+
974
+ email = customer.get("email")
975
+ self.customer_id = customer.get("id")
976
+
977
+ if "@" in self.email:
978
+ _LOGGER.debug(
979
+ "Logged in as %s to %s with id: %s",
980
+ hide_email(email),
981
+ self.url,
982
+ self.customer_id,
983
+ )
984
+ else:
985
+ _LOGGER.debug(
986
+ "Logged in as %s to mobile account %s with %s",
987
+ hide_email(email),
988
+ self.url,
989
+ self.customer_id,
990
+ )
991
+
992
+ self.stats["login_timestamp"] = datetime.datetime.now()
993
+ self.stats["api_calls"] = 0
994
+ await self.check_domain()
995
+ await self.finalize_login()
996
+ return True
972
997
 
973
998
  async def get_csrf_token(self) -> str | None:
974
999
  """Get an anti-CSRF token from an Amazon webpage."""
@@ -1045,11 +1070,44 @@ class AlexaLogin:
1045
1070
  ) -> None:
1046
1071
  """Login to Amazon."""
1047
1072
  data = data or {}
1073
+
1074
+ # Prefer the persisted OAuth device registration whenever a refresh token
1075
+ # is available. Access tokens and website cookies are short-lived runtime
1076
+ # credentials and can be rebuilt silently from the refresh token without
1077
+ # device registration, capability registration, or stored credentials.
1078
+ if self.refresh_token:
1079
+ _LOGGER.debug("Attempting login using the stored OAuth refresh token")
1080
+ if not self._session:
1081
+ self._create_session()
1082
+ recovered = (
1083
+ await self.refresh_access_token()
1084
+ and await self.exchange_token_for_cookies()
1085
+ and await self.get_csrf()
1086
+ and await self.test_loggedin(rebuild_session=False)
1087
+ )
1088
+ if recovered:
1089
+ _LOGGER.info(
1090
+ "Logged in %s using the stored OAuth refresh token",
1091
+ hide_email(self.email),
1092
+ )
1093
+ return
1094
+ _LOGGER.debug(
1095
+ "OAuth refresh-token login failed; falling back to stored cookies"
1096
+ )
1097
+ await self.reset()
1098
+
1099
+ # Retain persisted-cookie login as a compatibility fallback for accounts
1100
+ # without a usable OAuth refresh token.
1048
1101
  if cookies:
1049
- _LOGGER.debug("Using cookies to log in")
1050
- if await self.test_loggedin(cookies):
1102
+ _LOGGER.debug("Using stored cookies to log in")
1103
+ if await self.test_loggedin(
1104
+ cookies,
1105
+ rebuild_session=False,
1106
+ ):
1051
1107
  return
1108
+ _LOGGER.debug("Stored-cookie login failed; falling back to credentials")
1052
1109
  await self.reset()
1110
+
1053
1111
  _LOGGER.debug("Using credentials to log in")
1054
1112
  if not self._site:
1055
1113
  site: URL = self.start_url
@@ -1212,18 +1270,31 @@ class AlexaLogin:
1212
1270
  assert isinstance(cookie_jar, aiohttp.CookieJar)
1213
1271
  if self._debug:
1214
1272
  _LOGGER.debug("Saving cookie to %s", cookiefile)
1273
+ temp_cookiefile = f"{self._cookiefile[0]}.{uuid4().hex}.tmp"
1215
1274
  try:
1216
1275
  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))
1276
+ serialized_cookies = dumps(serialized_cookie_jar)
1277
+ async with aiofiles.open(temp_cookiefile, mode="w") as localfile:
1278
+ await localfile.write(serialized_cookies)
1279
+ await localfile.flush()
1280
+
1281
+ # The temporary file is created beside the destination so
1282
+ # os.replace() is atomic on the same filesystem. The live
1283
+ # cookie file therefore remains valid until the replacement
1284
+ # is complete, even if shutdown interrupts the write.
1285
+ await asyncio.to_thread(
1286
+ os.replace, temp_cookiefile, self._cookiefile[0]
1287
+ )
1221
1288
  except (OSError, EOFError, TypeError, AttributeError) as ex:
1222
1289
  _LOGGER.debug(
1223
1290
  "Error saving serialized cookie to %s: %s",
1224
1291
  self._cookiefile[0].replace(self.email, hide_email(self.email)),
1225
1292
  EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
1226
1293
  )
1294
+ finally:
1295
+ with contextlib.suppress(OSError):
1296
+ if os.path.exists(temp_cookiefile):
1297
+ await aioos.remove(temp_cookiefile)
1227
1298
  elif (cookiefile) and os.path.exists(cookiefile):
1228
1299
  _LOGGER.debug(
1229
1300
  "Removing outdated cookiefile %s",
@@ -1,7 +1,7 @@
1
1
  # SPDX-License-Identifier: Apache-2.0
2
2
  [tool.poetry]
3
3
  name = "AlexaPy"
4
- version = "1.29.25"
4
+ version = "1.30.0"
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