amazon-orders 2.0.2__tar.gz → 3.0.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.
Files changed (37) hide show
  1. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/CHANGELOG.md +29 -1
  2. {amazon_orders-2.0.2/amazon_orders.egg-info → amazon_orders-3.0.0}/PKG-INFO +1 -1
  3. {amazon_orders-2.0.2 → amazon_orders-3.0.0/amazon_orders.egg-info}/PKG-INFO +1 -1
  4. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/amazonorders/__init__.py +1 -1
  5. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/amazonorders/cli.py +29 -20
  6. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/amazonorders/constants.py +16 -0
  7. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/amazonorders/entity/item.py +6 -5
  8. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/amazonorders/entity/order.py +8 -28
  9. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/amazonorders/entity/parsable.py +24 -0
  10. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/amazonorders/forms.py +12 -9
  11. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/amazonorders/selectors.py +30 -15
  12. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/amazonorders/session.py +2 -1
  13. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/amazonorders/util.py +3 -0
  14. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/tests/test_orders.py +110 -0
  15. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/tests/testcase.py +111 -27
  16. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/LICENSE +0 -0
  17. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/MANIFEST.in +0 -0
  18. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/README.md +0 -0
  19. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/amazon_orders.egg-info/SOURCES.txt +0 -0
  20. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/amazon_orders.egg-info/dependency_links.txt +0 -0
  21. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/amazon_orders.egg-info/entry_points.txt +0 -0
  22. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/amazon_orders.egg-info/requires.txt +0 -0
  23. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/amazon_orders.egg-info/top_level.txt +0 -0
  24. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/amazonorders/banner.txt +0 -0
  25. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/amazonorders/conf.py +0 -0
  26. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/amazonorders/entity/__init__.py +0 -0
  27. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/amazonorders/entity/recipient.py +0 -0
  28. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/amazonorders/entity/seller.py +0 -0
  29. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/amazonorders/entity/shipment.py +0 -0
  30. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/amazonorders/exception.py +0 -0
  31. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/amazonorders/orders.py +0 -0
  32. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/pyproject.toml +0 -0
  33. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/setup.cfg +0 -0
  34. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/tests/test_cli.py +0 -0
  35. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/tests/test_conf.py +0 -0
  36. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/tests/test_session.py +0 -0
  37. {amazon_orders-2.0.2 → amazon_orders-3.0.0}/tests/test_util.py +0 -0
@@ -4,7 +4,35 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## [Unreleased](https://github.com/alexdlaird/amazon-orders/compare/2.0.2...HEAD)
7
+ ## [Unreleased](https://github.com/alexdlaird/amazon-orders/compare/3.0.0...HEAD)
8
+
9
+ ## [3.0.0](https://github.com/alexdlaird/amazon-orders/compare/2.0.3...3.0.0) - 2024-11-03
10
+
11
+ ### Added
12
+
13
+ - Retry support to CLI when stale session fails to authenticate the first time.
14
+ - Improvements to exception messages on auth failures.
15
+ - Documentation improvements.
16
+
17
+ ### Fixed
18
+
19
+ - Several parsing issues with the implementation of Amazon's new `data-component` tag.
20
+
21
+ ### Removed
22
+
23
+ - `Order.order_shipped_date`, this cannot be consistently parsed from Amazon.
24
+ - `Order.refund_completed_date`, this cannot be consistently parsed from Amazon.
25
+
26
+ ## [2.0.3](https://github.com/alexdlaird/amazon-orders/compare/2.0.2...2.0.3) - 2024-11-01
27
+
28
+ ### Added
29
+
30
+ - Further support for Amazon's new `data-component` tag on order price, seller, and return eligibility, and fixing an issue with `Shipment` parsing.
31
+ - [`Parsable.to_date()`](https://amazon-orders.readthedocs.io/api.html#amazonorders.entity.parsable.Parsable.to_date) attempts multiple date formats.
32
+
33
+ ### Fixed
34
+
35
+ - An issue with `Shipment`s parsing with Amazon's new `data-component`.
8
36
 
9
37
  ## [2.0.2](https://github.com/alexdlaird/amazon-orders/compare/2.0.1...2.0.2) - 2024-10-30
10
38
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: amazon-orders
3
- Version: 2.0.2
3
+ Version: 3.0.0
4
4
  Summary: A CLI and library for interacting with Amazon order history.
5
5
  Maintainer-email: Alex Laird <contact@alexlaird.com>
6
6
  License: MIT License
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: amazon-orders
3
- Version: 2.0.2
3
+ Version: 3.0.0
4
4
  Summary: A CLI and library for interacting with Amazon order history.
5
5
  Maintainer-email: Alex Laird <contact@alexlaird.com>
6
6
  License: MIT License
@@ -1,3 +1,3 @@
1
1
  __copyright__ = "Copyright (c) 2024 Alex Laird"
2
2
  __license__ = "MIT"
3
- __version__ = "2.0.2"
3
+ __version__ = "3.0.0"
@@ -15,7 +15,7 @@ from click.core import Context
15
15
  from amazonorders import __version__, util
16
16
  from amazonorders.conf import AmazonOrdersConfig
17
17
  from amazonorders.entity.order import Order
18
- from amazonorders.exception import AmazonOrdersError
18
+ from amazonorders.exception import AmazonOrdersError, AmazonOrdersAuthError
19
19
  from amazonorders.orders import AmazonOrders
20
20
  from amazonorders.session import AmazonSession, IODefault
21
21
 
@@ -251,21 +251,34 @@ def _print_banner() -> None:
251
251
  click.echo(banner.format(version=__version__))
252
252
 
253
253
 
254
- def _authenticate(amazon_session: AmazonSession) -> None:
255
- if amazon_session.auth_cookies_stored():
256
- if amazon_session.username or amazon_session.password:
257
- click.echo(
258
- "Info: The --username and --password flags are ignored because a previous session "
259
- "still exists. If you would like to reauthenticate, call the `logout` command "
260
- "first.\n")
261
- else:
262
- if not amazon_session.username:
263
- amazon_session.username = click.prompt("Username")
264
- if not amazon_session.password:
265
- amazon_session.password = click.prompt("Password", hide_input=True)
266
- click.echo("")
267
-
268
- amazon_session.login()
254
+ def _authenticate(amazon_session: AmazonSession,
255
+ retries: int = 0) -> None:
256
+ try:
257
+ if amazon_session.auth_cookies_stored():
258
+ if amazon_session.username or amazon_session.password:
259
+ click.echo(
260
+ "Info: The --username and --password flags are ignored because a previous session "
261
+ "still exists. If you would like to reauthenticate, call the `logout` command "
262
+ "first.\n")
263
+ else:
264
+ if not amazon_session.username:
265
+ amazon_session.username = click.prompt("Username")
266
+ if not amazon_session.password:
267
+ amazon_session.password = click.prompt("Password", hide_input=True)
268
+ click.echo("")
269
+
270
+ amazon_session.login()
271
+ except AmazonOrdersAuthError as e:
272
+ if retries < 1:
273
+ if amazon_session.username:
274
+ click.echo(
275
+ f"Info: Authenticating '{amazon_session.username}' failed, retrying ...\n")
276
+
277
+ amazon_session.password = None
278
+
279
+ _authenticate(amazon_session, retries=retries + 1)
280
+ else:
281
+ raise e
269
282
 
270
283
 
271
284
  def _order_output(order: Order) -> str:
@@ -299,10 +312,6 @@ Order #{}
299
312
  order_str += f"\n Estimated Tax: ${order.estimated_tax:,.2f}"
300
313
  if order.refund_total:
301
314
  order_str += f"\n Refund Total: ${order.refund_total:,.2f}"
302
- if order.order_shipped_date:
303
- order_str += f"\n Order Shipped Date: {order.order_shipped_date}"
304
- if order.refund_completed_date:
305
- order_str += f"\n Refund Completed Date: {order.refund_completed_date}"
306
315
 
307
316
  order_str += "\n-----------------------------------------------------------------------"
308
317
 
@@ -5,6 +5,16 @@ import os
5
5
 
6
6
 
7
7
  class Constants:
8
+ """
9
+ A class containing useful constants. Extend and override with `constants_class` in the config:
10
+
11
+ .. code-block:: python
12
+
13
+ from amazonorders.conf import AmazonOrdersConfig
14
+
15
+ config = AmazonOrdersConfig(data={"constants_class": "my_module.MyConstants"})
16
+ """
17
+
8
18
  ##########################################################################
9
19
  # General URL
10
20
  ##########################################################################
@@ -53,3 +63,9 @@ class Constants:
53
63
  "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) "
54
64
  "Chrome/120.0.0.0 Safari/537.36",
55
65
  }
66
+
67
+ ##########################################################################
68
+ # Formats
69
+ ##########################################################################
70
+
71
+ VALID_DATE_FORMATS = ["%b %d, %Y", "%B %d, %Y"]
@@ -2,7 +2,7 @@ __copyright__ = "Copyright (c) 2024 Alex Laird"
2
2
  __license__ = "MIT"
3
3
 
4
4
  import logging
5
- from datetime import date, datetime
5
+ from datetime import date
6
6
  from typing import Optional, TypeVar
7
7
 
8
8
  from bs4 import Tag
@@ -35,11 +35,11 @@ class Item(Parsable):
35
35
  link=True, required=True)
36
36
  #: The Item price.
37
37
  self.price: Optional[float] = self.to_currency(
38
- self.safe_simple_parse(selector=self.config.selectors.FIELD_ITEM_TAG_ITERATOR_SELECTOR,
38
+ self.safe_simple_parse(selector=self.config.selectors.FIELD_ITEM_PRICE_SELECTOR,
39
39
  prefix_split="$"))
40
40
  #: The Item Seller.
41
41
  self.seller: Optional[Seller] = self.safe_simple_parse(
42
- selector=self.config.selectors.FIELD_ITEM_TAG_ITERATOR_SELECTOR,
42
+ selector=self.config.selectors.FIELD_ITEM_SELLER_SELECTOR,
43
43
  text_contains="Sold by:",
44
44
  wrap_tag=Seller)
45
45
  #: The Item condition.
@@ -69,7 +69,7 @@ class Item(Parsable):
69
69
  def _parse_return_eligible_date(self) -> Optional[date]:
70
70
  value = None
71
71
 
72
- for tag in util.select(self.parsed, self.config.selectors.FIELD_ITEM_TAG_ITERATOR_SELECTOR):
72
+ for tag in util.select(self.parsed, self.config.selectors.FIELD_ITEM_RETURN_SELECTOR):
73
73
  if "Return" in tag.text:
74
74
  tag_str = tag.text.strip()
75
75
  split_str = "through "
@@ -77,6 +77,7 @@ class Item(Parsable):
77
77
  split_str = "closed on "
78
78
  if split_str in tag_str:
79
79
  date_str = tag_str.split(split_str)[1]
80
- value = datetime.strptime(date_str, "%b %d, %Y").date()
80
+ value = self.to_date(date_str)
81
+ break
81
82
 
82
83
  return value
@@ -3,7 +3,7 @@ __license__ = "MIT"
3
3
 
4
4
  import json
5
5
  import logging
6
- from datetime import date, datetime
6
+ from datetime import date
7
7
  from typing import Any, List, Optional, TypeVar, Union
8
8
 
9
9
  from bs4 import BeautifulSoup, Tag
@@ -72,10 +72,6 @@ class Order(Parsable):
72
72
  self.estimated_tax: Optional[float] = self._if_full_details(self._parse_estimated_tax())
73
73
  #: The Order refund total. Only populated when ``full_details`` is ``True``.
74
74
  self.refund_total: Optional[float] = self._if_full_details(self._parse_refund_total())
75
- #: The Order shipped date. Only populated when ``full_details`` is ``True``.
76
- self.order_shipped_date: Optional[date] = self._if_full_details(self._parse_order_shipping_date())
77
- #: The Order refund total. Only populated when ``full_details`` is ``True``.
78
- self.refund_completed_date: Optional[date] = self._if_full_details(self._parse_refund_completed_date())
79
75
 
80
76
  def __repr__(self) -> str:
81
77
  return f"<Order #{self.order_number}: \"{self.items}\">"
@@ -137,19 +133,23 @@ class Order(Parsable):
137
133
  else:
138
134
  split_str = "Order placed"
139
135
 
140
- value = value.split(split_str)[1].strip()
141
- value = datetime.strptime(value, "%B %d, %Y").date()
136
+ date_str = value.split(split_str)[1].strip()
137
+ value = self.to_date(date_str)
142
138
 
143
139
  return value
144
140
 
145
141
  def _parse_recipient(self) -> Optional[Recipient]:
142
+ # At least for now, we don't populate Recipient data for digital orders
143
+ if util.select_one(self.parsed, self.config.selectors.FIELD_ORDER_GIFT_CARD_INSTANCE_SELECTOR):
144
+ return None
145
+
146
146
  value = util.select_one(self.parsed, self.config.selectors.FIELD_ORDER_ADDRESS_SELECTOR)
147
147
 
148
148
  if not value:
149
149
  value = util.select_one(self.parsed, self.config.selectors.FIELD_ORDER_ADDRESS_FALLBACK_1_SELECTOR)
150
150
 
151
151
  if value:
152
- data_popover = value.get("data-a-popover", {}) # type: ignore[arg-type]
152
+ data_popover = value.get("data-a-popover", {}) # type: ignore[arg-type, var-annotated]
153
153
  inline_content = data_popover.get("inlineContent") # type: ignore[union-attr]
154
154
  if inline_content:
155
155
  value = BeautifulSoup(json.loads(inline_content), "html.parser")
@@ -266,26 +266,6 @@ class Order(Parsable):
266
266
 
267
267
  return value
268
268
 
269
- def _parse_order_shipping_date(self) -> Optional[date]:
270
- value = self.simple_parse(self.config.selectors.FIELD_ORDER_SHIPPED_DATE_SELECTOR,
271
- prefix_split="Items shipped:")
272
-
273
- if value:
274
- date_str = value.split("-")[0].strip()
275
- value = datetime.strptime(date_str, "%B %d, %Y").date()
276
-
277
- return value
278
-
279
- def _parse_refund_completed_date(self) -> Optional[date]:
280
- value = self.simple_parse(self.config.selectors.FIELD_ORDER_REFUND_COMPLETED_DATE,
281
- prefix_split="Refund: Completed")
282
-
283
- if value:
284
- date_str = value.split("-")[0].strip()
285
- value = datetime.strptime(date_str, "%B %d, %Y").date()
286
-
287
- return value
288
-
289
269
  def _if_full_details(self,
290
270
  value: Any) -> Union[Any, None]:
291
271
  return value if self.full_details else None
@@ -2,6 +2,7 @@ __copyright__ = "Copyright (c) 2024 Alex Laird"
2
2
  __license__ = "MIT"
3
3
 
4
4
  import logging
5
+ from datetime import date, datetime
5
6
  from typing import Any, Callable, Optional, Type, Union, Dict
6
7
 
7
8
  from bs4 import Tag
@@ -169,3 +170,26 @@ class Parsable:
169
170
  return None
170
171
 
171
172
  return currency
173
+
174
+ def to_date(self,
175
+ date_str: str) -> Optional[date]:
176
+ """
177
+ Return the given date string as a date object.
178
+
179
+ :param date_str: The date string to parse to a date object.
180
+ :return: The parsed date.
181
+ """
182
+ value = None
183
+
184
+ for fmt in self.config.constants.VALID_DATE_FORMATS:
185
+ try:
186
+ value = datetime.strptime(date_str, fmt).date()
187
+ except ValueError:
188
+ pass
189
+
190
+ if value is None:
191
+ logger.warning(
192
+ f"ValueError: time data '{date_str}' does not match any format in "
193
+ f"{self.config.constants.VALID_DATE_FORMATS}")
194
+
195
+ return value
@@ -196,7 +196,8 @@ class SignInForm(AuthForm):
196
196
  super().fill_form()
197
197
  if not self.data:
198
198
  raise AmazonOrdersError(
199
- "AuthForm did not populate. Check if Amazon changed the expected HTML."
199
+ "SignInForm data did not populate, but it's required. "
200
+ "Check if Amazon changed their login flow."
200
201
  ) # pragma: no cover
201
202
 
202
203
  additional_attrs.update({self.solution_attr_key: self.amazon_session.username,
@@ -236,7 +237,8 @@ class MfaDeviceSelectForm(AuthForm):
236
237
  super().fill_form()
237
238
  if not self.data:
238
239
  raise AmazonOrdersError(
239
- "AuthForm did not populate. Check if Amazon changed the expected HTML."
240
+ "MfaDeviceSelectForm data did not populate, but it's required. "
241
+ "Check if Amazon changed their MFA flow."
240
242
  ) # pragma: no cover
241
243
 
242
244
  contexts = util.select(self.form, self.config.selectors.MFA_DEVICE_SELECT_INPUT_SELECTOR)
@@ -281,8 +283,8 @@ class MfaForm(AuthForm):
281
283
  super().fill_form()
282
284
  if not self.data:
283
285
  raise AmazonOrdersError(
284
- "AuthForm did not populate, but it's required. "
285
- "Check if Amazon changed the expected HTML."
286
+ "MfaForm data did not populate, but it's required. "
287
+ "Check if Amazon changed their MFA flow."
286
288
  ) # pragma: no cover
287
289
 
288
290
  otp = self.amazon_session.io.prompt("Enter the one-time passcode sent to your device")
@@ -320,22 +322,23 @@ class CaptchaForm(AuthForm):
320
322
  super().fill_form(additional_attrs)
321
323
  if not self.data:
322
324
  raise AmazonOrdersError(
323
- "AuthForm did not populate. Check if Amazon changed the expected HTML."
325
+ "CaptchaForm data did not populate, but it's required. "
326
+ "Check if Amazon changed their Captcha flow."
324
327
  ) # pragma: no cover
325
328
 
326
329
  # TODO: eliminate the use of find_parent() here
327
330
  form_parent = self.form.find_parent()
328
331
  if not form_parent:
329
332
  raise AmazonOrdersError(
330
- "AuthForm parent not found, but it's required. "
331
- "Check if Amazon changed the expected HTML."
333
+ "CaptchaForm parent not found, but it's required. "
334
+ "Check if Amazon changed their Captcha flow."
332
335
  ) # pragma: no cover
333
336
 
334
337
  img_tag = form_parent.select_one("img")
335
338
  if not img_tag:
336
339
  raise AmazonOrdersError(
337
- "AuthForm <img> tag not found, but it's required. "
338
- "Check if Amazon changed the expected HTML."
340
+ "CaptchaForm <img> tag not found, but it's required. "
341
+ "Check if Amazon changed their Captcha flow."
339
342
  ) # pragma: no cover
340
343
 
341
344
  img_url = str(img_tag["src"])
@@ -3,6 +3,16 @@ __license__ = "MIT"
3
3
 
4
4
 
5
5
  class Selectors:
6
+ """
7
+ A class containing CSS selectors. Extend and override with `selectors_class` in the config:
8
+
9
+ .. code-block:: python
10
+
11
+ from amazonorders.conf import AmazonOrdersConfig
12
+
13
+ config = AmazonOrdersConfig(data={"selectors_class": "my_module.MyConstants"})
14
+ """
15
+
6
16
  ##########################################################################
7
17
  # CSS selectors for AuthForms
8
18
  ##########################################################################
@@ -35,47 +45,52 @@ class Selectors:
35
45
  # is passed.
36
46
  ##########################################################################
37
47
 
38
- ORDER_HISTORY_ENTITY_SELECTOR = ["div.order", "div.order-card"]
39
- ORDER_DETAILS_ENTITY_SELECTOR = ["div#orderDetails", "div#ordersContainer", "[data-component='orderCard']"]
48
+ ORDER_HISTORY_ENTITY_SELECTOR = ["div.order-card", "div.order"]
49
+ ORDER_DETAILS_ENTITY_SELECTOR = ["div#orderDetails", "div#ordersContainer"]
40
50
  ITEM_ENTITY_SELECTOR = ["div:has(> div.yohtmlc-item)", ".item-box", "[data-component='purchasedItems']"]
41
- SHIPMENT_ENTITY_SELECTOR = ["div.shipment", "div.delivery-box", "[data-component='shipments']"]
51
+ SHIPMENT_ENTITY_SELECTOR = ["div.shipment", "div.delivery-box",
52
+ "[data-component='orderCard'] [data-component='shipments']"]
42
53
 
43
54
  #####################################
44
55
  # CSS selectors for Item fields
45
56
  #####################################
46
57
 
47
58
  FIELD_ITEM_IMG_LINK_SELECTOR = "a img"
48
- FIELD_ITEM_QUANTITY_SELECTOR = ["span.item-view-qty", "span.product-image__qty", "[data-component='itemQuantity']"]
49
- FIELD_ITEM_TITLE_SELECTOR = [".yohtmlc-item a", ".yohtmlc-product-title", "[data-component='itemTitle']"]
50
- FIELD_ITEM_LINK_SELECTOR = [".yohtmlc-item a", "a:has(> .yohtmlc-product-title)", "[data-component='itemTitle'] a"]
51
- FIELD_ITEM_TAG_ITERATOR_SELECTOR = [".yohtmlc-item div", "[data-component='purchasedItemsRightGrid']"]
59
+ FIELD_ITEM_QUANTITY_SELECTOR = [".od-item-view-qty", "span.item-view-qty", "span.product-image__qty"]
60
+ FIELD_ITEM_TITLE_SELECTOR = ["[data-component='itemTitle']", ".yohtmlc-item a", ".yohtmlc-product-title"]
61
+ FIELD_ITEM_LINK_SELECTOR = ["[data-component='itemTitle'] a", ".yohtmlc-item a", "a:has(> .yohtmlc-product-title)"]
62
+ FIELD_ITEM_TAG_ITERATOR_SELECTOR = [".yohtmlc-item div"]
63
+ FIELD_ITEM_PRICE_SELECTOR = ["[data-component='unitPrice']"] + FIELD_ITEM_TAG_ITERATOR_SELECTOR
64
+ FIELD_ITEM_SELLER_SELECTOR = ["[data-component='orderedMerchant']"] + FIELD_ITEM_TAG_ITERATOR_SELECTOR
65
+ FIELD_ITEM_RETURN_SELECTOR = ["[data-component='itemReturnEligibility']"] + FIELD_ITEM_TAG_ITERATOR_SELECTOR
52
66
 
53
67
  #####################################
54
68
  # CSS selectors for Order fields
55
69
  #####################################
56
70
 
57
71
  FIELD_ORDER_DETAILS_LINK_SELECTOR = "a.yohtmlc-order-details-link"
58
- FIELD_ORDER_NUMBER_SELECTOR = ["bdi[dir='ltr']", "span[dir='ltr']"]
72
+ FIELD_ORDER_NUMBER_SELECTOR = [".order-date-invoice-item bdi[dir='ltr']", "bdi[dir='ltr']", "span[dir='ltr']"]
59
73
  FIELD_ORDER_GRAND_TOTAL_SELECTOR = ["div.yohtmlc-order-total span.value", "div.order-header div.a-column.a-span2"]
60
- FIELD_ORDER_PLACED_DATE_SELECTOR = ["span.order-date-invoice-item",
61
- "div.a-span3"]
74
+ FIELD_ORDER_PLACED_DATE_SELECTOR = ["span.order-date-invoice-item", "div.a-span3"]
62
75
  FIELD_ORDER_PAYMENT_METHOD_SELECTOR = "img.pmts-payment-credit-card-instrument-logo"
63
76
  FIELD_ORDER_PAYMENT_METHOD_LAST_4_SELECTOR = "img.pmts-payment-credit-card-instrument-logo"
64
- FIELD_ORDER_SUBTOTALS_TAG_ITERATOR_SELECTOR = ["div#od-subtotals div.a-row", "[data-component='orderSubtotals']"]
77
+ FIELD_ORDER_SUBTOTALS_TAG_ITERATOR_SELECTOR = ["[data-component='orderSubtotals'] div.a-row",
78
+ "div#od-subtotals div.a-row"]
65
79
  FIELD_ORDER_SUBTOTALS_INNER_TAG_SELECTOR = "div.a-span-last"
66
80
  FIELD_ORDER_ADDRESS_SELECTOR = "div.displayAddressDiv"
67
81
  FIELD_ORDER_ADDRESS_FALLBACK_1_SELECTOR = "div.recipient span.a-declarative"
68
82
  FIELD_ORDER_ADDRESS_FALLBACK_2_SELECTOR = "script[id^='shipToData']"
69
- FIELD_ORDER_SHIPPED_DATE_SELECTOR = "#orderDetails div.a-box.a-last div div div.a-row:not(.a-color-success)"
70
- FIELD_ORDER_REFUND_COMPLETED_DATE = "#orderDetails div.a-box.a-last div div div.a-row.a-color-success"
83
+ FIELD_ORDER_GIFT_CARD_INSTANCE_SELECTOR = ".gift-card-instance"
71
84
 
72
85
  #####################################
73
86
  # CSS selectors for Shipment fields
74
87
  #####################################
75
88
 
76
- FIELD_SHIPMENT_TRACKING_LINK_SELECTOR = "span.track-package-button a"
89
+ FIELD_SHIPMENT_TRACKING_LINK_SELECTOR = ["span.track-package-button a", "a[href*='ship-track?itemId=']"]
77
90
  FIELD_SHIPMENT_DELIVERY_STATUS_SELECTOR = ["div.js-shipment-info-container div.a-row",
78
- "span.delivery-box__primary-text"]
91
+ "span.delivery-box__primary-text",
92
+ ".yohtmlc-shipment-status-primaryText",
93
+ ".od-status-message"]
79
94
 
80
95
  #####################################
81
96
  # CSS selectors for Recipient fields
@@ -98,7 +98,8 @@ class AmazonSession:
98
98
  self.io: IODefault = io
99
99
  #: The AmazonOrdersConfig to use.
100
100
  self.config: AmazonOrdersConfig = config
101
- #: The list of known form implementations to use with authentication.
101
+ #: The list of form implementations to use with authentication. If a value is passed for this when
102
+ #: instantiating an AmazonSession, ensure that list is populated with the default form implementations.
102
103
  self.auth_forms: List[AuthForm] = auth_forms
103
104
 
104
105
  #: The shared session to be used across all requests.
@@ -2,10 +2,13 @@ __copyright__ = "Copyright (c) 2024 Alex Laird"
2
2
  __license__ = "MIT"
3
3
 
4
4
  import importlib
5
+ import logging
5
6
  from typing import List, Union, Optional, Callable
6
7
 
7
8
  from bs4 import Tag
8
9
 
10
+ logger = logging.getLogger(__name__)
11
+
9
12
 
10
13
  def select(parsed: Tag, selector: Union[List[str], str]) -> List[Tag]:
11
14
  """
@@ -51,6 +51,32 @@ class TestOrders(UnitTestCase):
51
51
  self.assertEqual(1, resp1.call_count)
52
52
  self.assertEqual(1, resp2.call_count)
53
53
 
54
+ @responses.activate
55
+ def test_get_order_history_2024_data_component(self):
56
+ # GIVEN
57
+ self.amazon_session.is_authenticated = True
58
+ year = 2024
59
+ start_index = 0
60
+ resp1 = self.given_order_history_landing_exists()
61
+ resp2 = self.given_order_history_exists(year, start_index)
62
+
63
+ # WHEN
64
+ orders = self.amazon_orders.get_order_history(year=year, start_index=start_index)
65
+
66
+ # THEN
67
+ # Giving start_index=0 means we only got the first page, so just 10 results
68
+ self.assertEqual(10, len(orders))
69
+ # Regular order with new `data-component` fields
70
+ self.assert_order_112_5939971_8962610_data_component(orders[0], False)
71
+ # Gift card order
72
+ self.assert_order_112_4482432_2955442_gift_card(orders[2], False)
73
+ # Digital order (legacy)
74
+ self.assert_order_112_9087159_1657009_digital_order_legacy(orders[3], False)
75
+ # Subscription order
76
+ self.assert_order_114_8722141_6545058_data_component_subscription(orders[6], False)
77
+ self.assertEqual(1, resp1.call_count)
78
+ self.assertEqual(1, resp2.call_count)
79
+
54
80
  @responses.activate
55
81
  def test_get_order_history_paginated(self):
56
82
  # GIVEN
@@ -198,6 +224,90 @@ class TestOrders(UnitTestCase):
198
224
  self.assert_order_112_9685975_5907428_multiple_items_shipments_sellers(order, True)
199
225
  self.assertEqual(1, resp1.call_count)
200
226
 
227
+ @responses.activate
228
+ def test_get_order_2024_data_component(self):
229
+ # GIVEN
230
+ self.amazon_session.is_authenticated = True
231
+ order_id = "112-5939971-8962610"
232
+ with open(os.path.join(self.RESOURCES_DIR, f"order-details-{order_id}.html"), "r",
233
+ encoding="utf-8") as f:
234
+ resp1 = responses.add(
235
+ responses.GET,
236
+ f"{self.test_config.constants.ORDER_DETAILS_URL}?orderID={order_id}",
237
+ body=f.read(),
238
+ status=200,
239
+ )
240
+
241
+ # WHEN
242
+ order = self.amazon_orders.get_order(order_id)
243
+
244
+ # THEN
245
+ self.assert_order_112_5939971_8962610_data_component(order, True)
246
+ self.assertEqual(1, resp1.call_count)
247
+
248
+ @responses.activate
249
+ def test_get_order_2024_gift_card(self):
250
+ # GIVEN
251
+ self.amazon_session.is_authenticated = True
252
+ order_id = "112-4482432-2955442"
253
+ with open(os.path.join(self.RESOURCES_DIR, f"order-details-{order_id}.html"), "r",
254
+ encoding="utf-8") as f:
255
+ resp1 = responses.add(
256
+ responses.GET,
257
+ f"{self.test_config.constants.ORDER_DETAILS_URL}?orderID={order_id}",
258
+ body=f.read(),
259
+ status=200,
260
+ )
261
+
262
+ # WHEN
263
+ order = self.amazon_orders.get_order(order_id)
264
+
265
+ # THEN
266
+ self.assert_order_112_4482432_2955442_gift_card(order, True)
267
+ self.assertEqual(1, resp1.call_count)
268
+
269
+ @responses.activate
270
+ def test_get_order_2024_digital_order_legacy(self):
271
+ # GIVEN
272
+ self.amazon_session.is_authenticated = True
273
+ order_id = "112-9087159-1657009"
274
+ with open(os.path.join(self.RESOURCES_DIR, f"order-details-{order_id}.html"), "r",
275
+ encoding="utf-8") as f:
276
+ resp1 = responses.add(
277
+ responses.GET,
278
+ f"{self.test_config.constants.ORDER_DETAILS_URL}?orderID={order_id}",
279
+ body=f.read(),
280
+ status=200,
281
+ )
282
+
283
+ # WHEN
284
+ order = self.amazon_orders.get_order(order_id)
285
+
286
+ # THEN
287
+ self.assert_order_112_9087159_1657009_digital_order_legacy(order, True)
288
+ self.assertEqual(1, resp1.call_count)
289
+
290
+ @responses.activate
291
+ def test_get_order_2024_data_component_subscription(self):
292
+ # GIVEN
293
+ self.amazon_session.is_authenticated = True
294
+ order_id = "114-8722141-6545058"
295
+ with open(os.path.join(self.RESOURCES_DIR, f"order-details-{order_id}.html"), "r",
296
+ encoding="utf-8") as f:
297
+ resp1 = responses.add(
298
+ responses.GET,
299
+ f"{self.test_config.constants.ORDER_DETAILS_URL}?orderID={order_id}",
300
+ body=f.read(),
301
+ status=200,
302
+ )
303
+
304
+ # WHEN
305
+ order = self.amazon_orders.get_order(order_id)
306
+
307
+ # THEN
308
+ self.assert_order_114_8722141_6545058_data_component_subscription(order, True)
309
+ self.assertEqual(1, resp1.call_count)
310
+
201
311
  @unittest.skipIf(not os.path.exists(temp_order_history_file_path),
202
312
  reason="Skipped, to debug an order history page, "
203
313
  "place it at tests/output/temp-order-history.html")
@@ -36,9 +36,6 @@ class TestCase(unittest.TestCase):
36
36
  self.assertIsNone(order.subscription_discount)
37
37
  self.assertEqual(30.99, order.total_before_tax)
38
38
  self.assertEqual(3.02, order.estimated_tax)
39
- self.assertEqual(date(2018, 12, 28), order.order_shipped_date)
40
- # As of April 2024, this is no longer shown in Order History
41
- # self.assertEqual("New", order.items[0].condition)
42
39
  self.assertEqual(30.99, order.items[0].price)
43
40
  self.assertEqual("Amazon.com Services, Inc",
44
41
  order.items[0].seller.name)
@@ -73,9 +70,6 @@ class TestCase(unittest.TestCase):
73
70
  self.assertEqual(-5.83, order.subscription_discount)
74
71
  self.assertEqual(33.01, order.total_before_tax)
75
72
  self.assertEqual(2.89, order.estimated_tax)
76
- self.assertEqual(date(2020, 10, 28), order.order_shipped_date)
77
- # As of April 2024, this is no longer shown in Order History
78
- # self.assertEqual("New", order.items[0].condition)
79
73
  self.assertEqual(38.84, order.items[0].price)
80
74
  self.assertEqual("Amazon.com Services, Inc",
81
75
  order.items[0].seller.name)
@@ -111,10 +105,6 @@ class TestCase(unittest.TestCase):
111
105
  self.assertEqual(69.99, order.total_before_tax)
112
106
  self.assertEqual(6.12, order.estimated_tax)
113
107
  self.assertEqual(76.11, order.refund_total)
114
- self.assertEqual(date(2020, 10, 19), order.order_shipped_date)
115
- self.assertTrue(date(2020, 11, 2), order.refund_completed_date)
116
- # As of April 2024, this is no longer shown in Order History
117
- # self.assertEqual("New", order.items[0].condition)
118
108
  self.assertEqual(69.99, order.items[0].price)
119
109
  self.assertEqual("Amazon.com Services, Inc",
120
110
  order.items[0].seller.name)
@@ -184,12 +174,7 @@ class TestCase(unittest.TestCase):
184
174
  self.assertIsNone(order.subscription_discount)
185
175
  self.assertEqual(43.23, order.total_before_tax)
186
176
  self.assertEqual(3.38, order.estimated_tax)
187
- self.assertEqual(date(2023, 12, 7), order.order_shipped_date)
188
- # As of April 2024, this is no longer shown in Order History
189
- # self.assertEqual("New", order.items[0].condition)
190
177
  self.assertEqual(14.99, order.items[0].price)
191
- # As of April 2024, this is no longer shown in Order History
192
- # self.assertEqual("New", order.items[1].condition)
193
178
  self.assertEqual(28.24, order.items[1].price)
194
179
  found_cadeya = False
195
180
  found_amazon = False
@@ -266,12 +251,7 @@ class TestCase(unittest.TestCase):
266
251
  self.assertIsNone(order.subscription_discount)
267
252
  self.assertEqual(42.29, order.total_before_tax)
268
253
  self.assertEqual(2.96, order.estimated_tax)
269
- self.assertEqual(date(2024, 5, 16), order.order_shipped_date)
270
- # As of April 2024, this is no longer shown in Order History
271
- # self.assertEqual("New", order.items[0].condition)
272
254
  self.assertEqual(12.30, order.items[0].price)
273
- # As of April 2024, this is no longer shown in Order History
274
- # self.assertEqual("New", order.items[1].condition)
275
255
  self.assertEqual(29.99, order.items[1].price)
276
256
  found_kimoe = False
277
257
  found_amazon = False
@@ -330,22 +310,17 @@ class TestCase(unittest.TestCase):
330
310
  self.assertIsNone(order.subscription_discount)
331
311
  self.assertEqual(26.48, order.total_before_tax)
332
312
  self.assertEqual(2.32, order.estimated_tax)
333
- self.assertEqual(date(2020, 10, 27), order.order_shipped_date)
334
313
  found_aa = False
335
314
  found_aaa = False
336
315
  for item in order.items:
337
316
  if "AAA" in item.title:
338
317
  found_aa = True
339
- # As of April 2024, this is no longer shown in Order History
340
- # self.assertEqual("New", item.condition)
341
318
  self.assertEqual(10.99, item.price)
342
319
  self.assertEqual("Amazon.com Services, Inc",
343
320
  item.seller.name)
344
321
  self.assertIsNone(item.seller.link)
345
322
  else:
346
323
  found_aaa = True
347
- # As of April 2024, this is no longer shown in Order History
348
- # self.assertEqual("New", item.condition)
349
324
  self.assertEqual(15.49, item.price)
350
325
  self.assertEqual("Amazon.com Services, Inc",
351
326
  item.seller.name)
@@ -353,6 +328,117 @@ class TestCase(unittest.TestCase):
353
328
  self.assertTrue(found_aa)
354
329
  self.assertTrue(found_aaa)
355
330
 
331
+ def assert_order_112_5939971_8962610_data_component(self, order, full_details=False):
332
+ self.assertEqual("112-5939971-8962610", order.order_number)
333
+ self.assertEqual(28.50, order.grand_total)
334
+ self.assertIsNotNone(order.order_details_link)
335
+ self.assertEqual(date(2024, 11, 1), order.order_placed_date)
336
+ self.assertEqual("Alex Laird", order.recipient.name)
337
+ self.assertIn("555 My Road", order.recipient.address)
338
+ self.assertEqual(1, len(order.shipments))
339
+ self.assertEqual(str(order.items),
340
+ str(order.shipments[0].items))
341
+ self.assertIsNotNone(order.shipments[0].tracking_link)
342
+ self.assertEqual("Delivered November 2", order.shipments[0].delivery_status)
343
+ self.assertEqual(1, len(order.items))
344
+ self.assertEqual("2 Set Replacement Parts Roller Brushes Compatible for iRobot Roomba E I and J Series, "
345
+ "Brush Replacement for iRobot Roomba i3 i3+ i6 i6+ i7 i7+ i8 i8+Plus E5 E6 E7 j7 j7+ evo "
346
+ "Vacuum Cleaner Accessories",
347
+ order.items[0].title)
348
+ self.assertEqual(2, order.items[0].quantity)
349
+ self.assertIsNotNone(order.items[0].link)
350
+ self.assertIsNotNone(order.items[0].image_link)
351
+
352
+ self.assertEqual(order.full_details, full_details)
353
+
354
+ if full_details:
355
+ self.assertEqual(date(2025, 1, 31), order.items[0].return_eligible_date)
356
+ self.assertEqual("American Express", order.payment_method)
357
+ self.assertEqual('1234', order.payment_method_last_4)
358
+ self.assertEqual(27.98, order.subtotal)
359
+ self.assertEqual(0.00, order.shipping_total)
360
+ self.assertEqual(26.58, order.total_before_tax)
361
+ self.assertEqual(1.92, order.estimated_tax)
362
+ self.assertEqual(13.99, order.items[0].price)
363
+ self.assertEqual("xianbaikeshang",
364
+ order.items[0].seller.name)
365
+ self.assertIsNotNone(order.items[0].seller.link)
366
+
367
+ def assert_order_112_4482432_2955442_gift_card(self, order, full_details=False):
368
+ self.assertEqual("112-4482432-2955442", order.order_number)
369
+ self.assertEqual(10.00, order.grand_total)
370
+ self.assertIsNotNone(order.order_details_link)
371
+ self.assertEqual(date(2024, 10, 30), order.order_placed_date)
372
+ self.assertIsNone(order.recipient)
373
+ self.assertEqual(0, len(order.shipments))
374
+ self.assertEqual(1, len(order.items))
375
+ self.assertEqual("Amazon eGift Card - Amazon Logo (Animated)",
376
+ order.items[0].title)
377
+ self.assertIsNotNone(order.items[0].link)
378
+ self.assertIsNotNone(order.items[0].image_link)
379
+
380
+ self.assertEqual(order.full_details, full_details)
381
+
382
+ if full_details:
383
+ self.assertEqual("American Express", order.payment_method)
384
+ self.assertEqual('1234', order.payment_method_last_4)
385
+ self.assertEqual(10.00, order.subtotal)
386
+ self.assertEqual(10.00, order.total_before_tax)
387
+ self.assertEqual(0.00, order.estimated_tax)
388
+
389
+ def assert_order_112_9087159_1657009_digital_order_legacy(self, order, full_details=False):
390
+ if full_details:
391
+ self.assertEqual(order.order_number, "D01-8711688-7680252")
392
+ else:
393
+ self.assertEqual(order.order_number, "112-9087159-1657009")
394
+ self.assertEqual(10.00, order.grand_total)
395
+ self.assertIsNotNone(order.order_details_link)
396
+ self.assertEqual(date(2024, 10, 30), order.order_placed_date)
397
+ self.assertEqual(0, len(order.shipments))
398
+ self.assertEqual(1, len(order.items))
399
+ self.assertEqual("$10 -PlayStation Store Gift Card [Digital Code]",
400
+ order.items[0].title)
401
+ self.assertIsNotNone(order.items[0].link)
402
+ self.assertIsNotNone(order.items[0].image_link)
403
+
404
+ self.assertEqual(order.full_details, full_details)
405
+
406
+ # We cannot parse full details for digital orders, so nothing to assert
407
+
408
+ def assert_order_114_8722141_6545058_data_component_subscription(self, order, full_details=False):
409
+ self.assertEqual("114-8722141-6545058", order.order_number)
410
+ self.assertEqual(44.46, order.grand_total)
411
+ self.assertIsNotNone(order.order_details_link)
412
+ self.assertEqual(date(2024, 10, 23), order.order_placed_date)
413
+ self.assertEqual("Alex Laird", order.recipient.name)
414
+ self.assertIn("555 My Road", order.recipient.address)
415
+ self.assertEqual(1, len(order.shipments))
416
+ self.assertEqual(str(order.items),
417
+ str(order.shipments[0].items))
418
+ self.assertIsNotNone(order.shipments[0].tracking_link)
419
+ self.assertEqual("Delivered October 26", order.shipments[0].delivery_status)
420
+ self.assertEqual(1, len(order.items))
421
+ self.assertEqual("Bounty Paper Towels Quick Size, White, 16 Family Rolls = 40 Regular Rolls",
422
+ order.items[0].title)
423
+ self.assertIsNotNone(order.items[0].link)
424
+ self.assertIsNotNone(order.items[0].image_link)
425
+
426
+ self.assertEqual(order.full_details, full_details)
427
+
428
+ if full_details:
429
+ self.assertEqual(date(2024, 11, 25), order.items[0].return_eligible_date)
430
+ self.assertEqual("American Express", order.payment_method)
431
+ self.assertEqual('1234', order.payment_method_last_4)
432
+ self.assertEqual(43.49, order.subtotal)
433
+ self.assertEqual(0.00, order.shipping_total)
434
+ self.assertEqual(-2.17, order.subscription_discount)
435
+ self.assertEqual(41.32, order.total_before_tax)
436
+ self.assertEqual(3.14, order.estimated_tax)
437
+ self.assertEqual(43.49, order.items[0].price)
438
+ self.assertEqual("Amazon.com",
439
+ order.items[0].seller.name)
440
+ self.assertIsNone(order.items[0].seller.link)
441
+
356
442
  def assert_populated_generic(self, order, full_details):
357
443
  self.assertIsNotNone(order.order_number)
358
444
  self.assertIsNotNone(order.grand_total)
@@ -377,8 +463,6 @@ class TestCase(unittest.TestCase):
377
463
  self.assertIsNotNone(order.shipping_total)
378
464
  self.assertIsNotNone(order.total_before_tax)
379
465
  self.assertIsNotNone(order.estimated_tax)
380
- # As of April 2024, this is no longer shown in Order History
381
- # self.assertIsNotNone(order.items[0].condition)
382
466
  if order.recipient:
383
467
  self.assertIsNotNone(order.items[0].price)
384
468
  self.assertIsNotNone(order.items[0].seller.name)
File without changes
File without changes
File without changes
File without changes