aioamazondevices 6.4.5__py3-none-any.whl → 9.0.2__py3-none-any.whl

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.
@@ -0,0 +1,349 @@
1
+ """aioamazondevices HTTP wrapper module."""
2
+
3
+ import asyncio
4
+ import base64
5
+ import secrets
6
+ from collections.abc import Callable, Coroutine
7
+ from http import HTTPStatus
8
+ from http.cookies import Morsel
9
+ from typing import Any, cast
10
+
11
+ import orjson
12
+ from aiohttp import (
13
+ ClientConnectorError,
14
+ ClientResponse,
15
+ ClientSession,
16
+ ContentTypeError,
17
+ )
18
+ from bs4 import BeautifulSoup
19
+ from langcodes import Language, standardize_tag
20
+ from yarl import URL
21
+
22
+ from . import __version__
23
+ from .const.http import (
24
+ AMAZON_APP_BUNDLE_ID,
25
+ AMAZON_APP_ID,
26
+ AMAZON_APP_VERSION,
27
+ AMAZON_DEVICE_SOFTWARE_VERSION,
28
+ ARRAY_WRAPPER,
29
+ CSRF_COOKIE,
30
+ DEFAULT_HEADERS,
31
+ HTTP_ERROR_199,
32
+ HTTP_ERROR_299,
33
+ REQUEST_AGENT,
34
+ URI_SIGNIN,
35
+ )
36
+ from .exceptions import (
37
+ CannotAuthenticate,
38
+ CannotConnect,
39
+ CannotRetrieveData,
40
+ )
41
+ from .utils import _LOGGER, scrub_fields
42
+
43
+
44
+ class AmazonSessionStateData:
45
+ """Amazon session state data class."""
46
+
47
+ def __init__(
48
+ self,
49
+ domain: str,
50
+ login_email: str,
51
+ login_password: str,
52
+ login_data: dict[str, Any] | None = None,
53
+ ) -> None:
54
+ """Init state class."""
55
+ self._login_email: str = login_email
56
+ self._login_password: str = login_password
57
+ self._login_stored_data: dict[str, Any] = login_data or {}
58
+ self.country_specific_data(domain)
59
+
60
+ @property
61
+ def country_code(self) -> str:
62
+ """Return country code."""
63
+ return self._country_code
64
+
65
+ @property
66
+ def domain(self) -> str:
67
+ """Return domain."""
68
+ return self._domain
69
+
70
+ @property
71
+ def language(self) -> str:
72
+ """Return language."""
73
+ return self._language
74
+
75
+ @property
76
+ def login_email(self) -> str:
77
+ """Return login email."""
78
+ return self._login_email
79
+
80
+ @property
81
+ def login_password(self) -> str:
82
+ """Return login password."""
83
+ return self._login_password
84
+
85
+ @property
86
+ def login_stored_data(self) -> dict[str, Any]:
87
+ """Return login stored data."""
88
+ return self._login_stored_data
89
+
90
+ def country_specific_data(self, domain: str) -> None:
91
+ """Set country specific data."""
92
+ # Force lower case
93
+ domain = domain.replace("https://www.amazon.", "").lower()
94
+ country_code = domain.split(".")[-1] if domain != "com" else "us"
95
+
96
+ lang_object = Language.make(territory=country_code.upper())
97
+ lang_maximized = lang_object.maximize()
98
+
99
+ self._country_code: str = country_code
100
+ self._domain: str = domain
101
+ language = f"{lang_maximized.language}-{lang_maximized.territory}"
102
+ self._language: str = standardize_tag(language)
103
+
104
+ _LOGGER.debug(
105
+ "Initialize country <%s>: domain <amazon.%s>, language <%s>",
106
+ country_code.upper(),
107
+ self._domain,
108
+ self._language,
109
+ )
110
+
111
+ def load_login_stored_data(self, data: dict[str, Any]) -> dict[str, Any]:
112
+ """Load to Amazon using previously stored data."""
113
+ self._login_stored_data = data
114
+ return self._login_stored_data
115
+
116
+
117
+ class AmazonHttpWrapper:
118
+ """Amazon HTTP wrapper class."""
119
+
120
+ def __init__(
121
+ self,
122
+ client_session: ClientSession,
123
+ session_state_data: AmazonSessionStateData,
124
+ save_to_file: Callable[[str | dict, str, str], Coroutine[Any, Any, None]]
125
+ | None = None,
126
+ ) -> None:
127
+ """Initialize HTTP wrapper."""
128
+ self._session = client_session
129
+ self._session_state_data: AmazonSessionStateData = session_state_data
130
+ self._save_to_file = save_to_file
131
+
132
+ self._csrf_cookie: str | None = None
133
+ self._cookies: dict[str, str] = self._build_init_cookies()
134
+
135
+ @property
136
+ def cookies(self) -> dict[str, str]:
137
+ """Return the current cookies."""
138
+ return self._cookies
139
+
140
+ def _load_website_cookies(self, language: str) -> dict[str, str]:
141
+ """Get website cookies, if available."""
142
+ if not self._session_state_data.login_stored_data:
143
+ return {}
144
+
145
+ website_cookies: dict[str, Any] = self._session_state_data.login_stored_data[
146
+ "website_cookies"
147
+ ]
148
+ website_cookies.update(
149
+ {
150
+ "session-token": self._session_state_data.login_stored_data[
151
+ "store_authentication_cookie"
152
+ ]["cookie"]
153
+ }
154
+ )
155
+ website_cookies.update({"lc-acbit": language})
156
+
157
+ return website_cookies
158
+
159
+ def _build_init_cookies(self) -> dict[str, str]:
160
+ """Build initial cookies to prevent captcha in most cases."""
161
+ token_bytes = secrets.token_bytes(313)
162
+ frc = base64.b64encode(token_bytes).decode("ascii").rstrip("=")
163
+
164
+ map_md_dict = {
165
+ "device_user_dictionary": [],
166
+ "device_registration_data": {
167
+ "software_version": AMAZON_DEVICE_SOFTWARE_VERSION,
168
+ },
169
+ "app_identifier": {
170
+ "app_version": AMAZON_APP_VERSION,
171
+ "bundle_id": AMAZON_APP_BUNDLE_ID,
172
+ },
173
+ }
174
+ map_md_str = orjson.dumps(map_md_dict).decode("utf-8")
175
+ map_md = base64.b64encode(map_md_str.encode()).decode().rstrip("=")
176
+
177
+ return {"amzn-app-id": AMAZON_APP_ID, "frc": frc, "map-md": map_md}
178
+
179
+ async def _ignore_ap_signin_error(self, response: ClientResponse) -> bool:
180
+ """Return true if error is due to signin endpoint."""
181
+ # Endpoint URI_SIGNIN replies with error 404
182
+ # but reports the needed parameters anyway
183
+ if history := response.history:
184
+ return (
185
+ response.status == HTTPStatus.NOT_FOUND
186
+ and URI_SIGNIN in history[0].request_info.url.path
187
+ )
188
+ return False
189
+
190
+ async def set_session_state_data(
191
+ self, session_state_data: AmazonSessionStateData
192
+ ) -> None:
193
+ """Set the current session state data."""
194
+ self._session_state_data = session_state_data
195
+
196
+ async def http_phrase_error(self, error: int) -> str:
197
+ """Convert numeric error in human phrase."""
198
+ if error == HTTP_ERROR_199:
199
+ return "Miscellaneous Warning"
200
+
201
+ if error == HTTP_ERROR_299:
202
+ return "Miscellaneous Persistent Warning"
203
+
204
+ return HTTPStatus(error).phrase
205
+
206
+ async def session_request(
207
+ self,
208
+ method: str,
209
+ url: str,
210
+ input_data: dict[str, Any] | list[dict[str, Any]] | None = None,
211
+ json_data: bool = False,
212
+ agent: str = "Amazon",
213
+ ) -> tuple[BeautifulSoup, ClientResponse]:
214
+ """Return request response context data."""
215
+ _LOGGER.debug(
216
+ "%s request: %s with payload %s [json=%s]",
217
+ method,
218
+ url,
219
+ scrub_fields(input_data) if input_data else None,
220
+ json_data,
221
+ )
222
+
223
+ headers = DEFAULT_HEADERS.copy()
224
+ headers.update({"User-Agent": REQUEST_AGENT[agent]})
225
+ headers.update({"Accept-Language": self._session_state_data.language})
226
+ headers.update({"x-amzn-client": "github.com/chemelli74/aioamazondevices"})
227
+ headers.update({"x-amzn-build-version": __version__})
228
+
229
+ if self._csrf_cookie:
230
+ csrf = {CSRF_COOKIE: self._csrf_cookie}
231
+ _LOGGER.debug("Adding to headers: %s", csrf)
232
+ headers.update(csrf)
233
+
234
+ if json_data:
235
+ json_header = {"Content-Type": "application/json; charset=utf-8"}
236
+ _LOGGER.debug("Adding to headers: %s", json_header)
237
+ headers.update(json_header)
238
+
239
+ _cookies = (
240
+ self._load_website_cookies(self._session_state_data.language)
241
+ if self._session_state_data.login_stored_data
242
+ else self._cookies
243
+ )
244
+ self._session.cookie_jar.update_cookies(
245
+ _cookies, URL(f"amazon.{self._session_state_data.domain}")
246
+ )
247
+
248
+ resp: ClientResponse | None = None
249
+ for delay in [0, 1, 2, 5, 8, 12, 21]:
250
+ if delay:
251
+ _LOGGER.info(
252
+ "Sleeping for %s seconds before retrying API call to %s", delay, url
253
+ )
254
+ await asyncio.sleep(delay)
255
+
256
+ try:
257
+ resp = await self._session.request(
258
+ method,
259
+ URL(url, encoded=True),
260
+ data=input_data if not json_data else orjson.dumps(input_data),
261
+ headers=headers,
262
+ )
263
+
264
+ except (TimeoutError, ClientConnectorError) as exc:
265
+ _LOGGER.warning("Connection error to %s: %s", url, repr(exc))
266
+ raise CannotConnect(f"Connection error during {method}") from exc
267
+
268
+ # Retry with a delay only for specific HTTP status
269
+ # that can benefits of a back-off
270
+ if resp.status not in [
271
+ HTTPStatus.INTERNAL_SERVER_ERROR,
272
+ HTTPStatus.SERVICE_UNAVAILABLE,
273
+ HTTPStatus.TOO_MANY_REQUESTS,
274
+ ]:
275
+ break
276
+
277
+ if resp is None:
278
+ _LOGGER.error("No response received from %s", url)
279
+ raise CannotConnect(f"No response received from {url}")
280
+
281
+ if csrf := resp.cookies.get(CSRF_COOKIE, Morsel()).value:
282
+ self._csrf_cookie = csrf
283
+ _LOGGER.debug("CSRF cookie value: <%s> [%s]", self._csrf_cookie, url)
284
+
285
+ content_type: str = resp.headers.get("Content-Type", "")
286
+ _LOGGER.debug(
287
+ "Response for url %s :\nstatus : %s \
288
+ \ncontent type: %s ",
289
+ url,
290
+ resp.status,
291
+ content_type,
292
+ )
293
+
294
+ if resp.status != HTTPStatus.OK:
295
+ if resp.status in [
296
+ HTTPStatus.FORBIDDEN,
297
+ HTTPStatus.PROXY_AUTHENTICATION_REQUIRED,
298
+ HTTPStatus.UNAUTHORIZED,
299
+ ]:
300
+ raise CannotAuthenticate(await self.http_phrase_error(resp.status))
301
+ if not await self._ignore_ap_signin_error(resp):
302
+ raise CannotRetrieveData(
303
+ f"Request failed: {await self.http_phrase_error(resp.status)}"
304
+ )
305
+
306
+ raw_content = await resp.read()
307
+
308
+ if self._save_to_file:
309
+ await self._save_to_file(
310
+ raw_content.decode("utf-8"),
311
+ url,
312
+ content_type,
313
+ )
314
+
315
+ return BeautifulSoup(raw_content or "", "html.parser"), resp
316
+
317
+ async def response_to_json(
318
+ self, raw_resp: ClientResponse, description: str | None = None
319
+ ) -> dict[str, Any]:
320
+ """Convert response to JSON, if possible."""
321
+ try:
322
+ data = await raw_resp.json(loads=orjson.loads)
323
+ if not data:
324
+ _LOGGER.warning("Empty JSON data received")
325
+ data = {}
326
+ if isinstance(data, list):
327
+ # if anonymous array is returned wrap it inside
328
+ # generated key to convert list to dict
329
+ data = {ARRAY_WRAPPER: data}
330
+ if description:
331
+ _LOGGER.debug("JSON '%s' data: %s", description, scrub_fields(data))
332
+ return cast("dict[str, Any]", data)
333
+ except ContentTypeError as exc:
334
+ raise ValueError("Response not in JSON format") from exc
335
+ except orjson.JSONDecodeError as exc:
336
+ raise ValueError("Response with corrupted JSON format") from exc
337
+
338
+ async def clear_cookies(self) -> None:
339
+ """Clear session cookies."""
340
+ self._session.cookie_jar.clear()
341
+ await self.clear_csrf_cookie()
342
+
343
+ async def clear_csrf_cookie(self) -> None:
344
+ """Clear CSRF cookie."""
345
+ self._csrf_cookie = None
346
+
347
+ async def set_cookies(self, cookies: dict[str, str], domain_url: URL) -> None:
348
+ """Set session cookies."""
349
+ self._session.cookie_jar.update_cookies(cookies, domain_url)