amazon-orders-de 1.0.0__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.
- amazon_orders_de/__init__.py +11 -0
- amazon_orders_de/constants.py +21 -0
- amazon_orders_de/order.py +46 -0
- amazon_orders_de/selectors.py +30 -0
- amazon_orders_de/transactions.py +157 -0
- amazon_orders_de-1.0.0.dist-info/METADATA +78 -0
- amazon_orders_de-1.0.0.dist-info/RECORD +9 -0
- amazon_orders_de-1.0.0.dist-info/WHEEL +4 -0
- amazon_orders_de-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
An ``amazon-orders`` language package for amazon.de, which requests its pages in English. Enable it with
|
|
3
|
+
``language_package: amazon_orders_de`` and ``domain: amazon.de`` in the config.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from amazon_orders_de.constants import Constants
|
|
7
|
+
from amazon_orders_de.order import Order
|
|
8
|
+
from amazon_orders_de.selectors import Selectors
|
|
9
|
+
from amazon_orders_de.transactions import TransactionsPage
|
|
10
|
+
|
|
11
|
+
__all__ = ["Constants", "Order", "Selectors", "TransactionsPage"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from amazonorders import constants
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Constants(constants.Constants):
|
|
5
|
+
"""
|
|
6
|
+
Constants for amazon.de, requested in English.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
# Ask for the English version of the site, so its pages are parsed with the English words of ``amazon-orders``
|
|
10
|
+
BASE_HEADERS = {**constants.Constants.BASE_HEADERS, "Accept-Language": "en-GB,en;q=0.9"}
|
|
11
|
+
|
|
12
|
+
# amazon.de picks its language from this cookie rather than from ``Accept-Language``, and sets it again from the
|
|
13
|
+
# account's language on every response
|
|
14
|
+
REQUEST_COOKIES = {"lc-acbde": "en_GB"}
|
|
15
|
+
|
|
16
|
+
#: The internal API that loads further pages of Transactions.
|
|
17
|
+
TRANSACTION_HISTORY_API_ROUTE = "/payments-portal/data/iris/live/v1/data/manage/get-transactions"
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def TRANSACTION_HISTORY_API_URL(self) -> str:
|
|
21
|
+
return f"{self.BASE_URL}{self.TRANSACTION_HISTORY_API_ROUTE}"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from amazonorders import util
|
|
4
|
+
from amazonorders.entity import order, recipient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Recipient(recipient.Recipient):
|
|
8
|
+
"""
|
|
9
|
+
A Recipient whose address is rendered as an unclassed list, one or more lines per list item.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def _parse_address(self) -> str | None:
|
|
13
|
+
return self._parse_address_lines() or super()._parse_address()
|
|
14
|
+
|
|
15
|
+
def _parse_address_lines(self) -> str | None:
|
|
16
|
+
lines: list[str] = []
|
|
17
|
+
for tag in util.select(self.parsed, self.config.selectors.FIELD_RECIPIENT_ADDRESS_LINES_SELECTOR):
|
|
18
|
+
# A list item may hold several lines separated by <br> or, when Amazon renders the address inline,
|
|
19
|
+
# by commas
|
|
20
|
+
lines.extend(line.strip() for line in re.split(r"[\n,]", tag.get_text("\n")) if line.strip())
|
|
21
|
+
|
|
22
|
+
return "\n".join(lines) or None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Order(order.Order):
|
|
26
|
+
"""
|
|
27
|
+
An Order on amazon.de.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def _parse_payment_method(self) -> str | None:
|
|
31
|
+
# When a Gift Card was used alongside a card, prefer the card (the instrument with a number), like its last
|
|
32
|
+
# digits are
|
|
33
|
+
selectors = self.config.selectors
|
|
34
|
+
for instrument in util.select(self.parsed, selectors.FIELD_ORDER_PAYMENT_INSTRUMENT_SELECTOR):
|
|
35
|
+
name_tag = util.select_one(instrument, selectors.FIELD_ORDER_PAYMENT_INSTRUMENT_NAME_SELECTOR)
|
|
36
|
+
if name_tag and util.select_one(instrument, selectors.FIELD_ORDER_PAYMENT_INSTRUMENT_NUMBER_SELECTOR):
|
|
37
|
+
return name_tag.text.strip() or None
|
|
38
|
+
|
|
39
|
+
return super()._parse_payment_method()
|
|
40
|
+
|
|
41
|
+
def _parse_recipient(self) -> recipient.Recipient | None:
|
|
42
|
+
value = super()._parse_recipient()
|
|
43
|
+
if value is None:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
return Recipient(value.parsed, self.config)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from amazonorders import selectors
|
|
2
|
+
from amazonorders.selectors import Selector
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Selectors(selectors.Selectors):
|
|
6
|
+
"""
|
|
7
|
+
Selectors for amazon.de, requested in English, which uses British English labels.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
# Rendered as "Postage & Packing"
|
|
11
|
+
FIELD_ORDER_SHIPPING_TOTAL_LABELS = [*selectors.Selectors.FIELD_ORDER_SHIPPING_TOTAL_LABELS, "postage"]
|
|
12
|
+
# Rendered as "Estimated VAT"
|
|
13
|
+
FIELD_ORDER_ESTIMATED_TAX_LABELS = [*selectors.Selectors.FIELD_ORDER_ESTIMATED_TAX_LABELS, "estimated vat"]
|
|
14
|
+
|
|
15
|
+
ORDER_SKIP_TOTALS = [
|
|
16
|
+
*selectors.Selectors.ORDER_SKIP_TOTALS,
|
|
17
|
+
# Identifies an Order of a cancelled service (e.g. a cancelled installation) on the history page
|
|
18
|
+
Selector("div.yohtmlc-shipment-status-primaryText", "Service cancelled"),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
#: The payment instruments an Order was paid with, and each one's name and masked number.
|
|
22
|
+
FIELD_ORDER_PAYMENT_INSTRUMENT_SELECTOR = "[data-testid='payment-instrument']"
|
|
23
|
+
FIELD_ORDER_PAYMENT_INSTRUMENT_NAME_SELECTOR = "[data-testid='payment-instrument-name']"
|
|
24
|
+
FIELD_ORDER_PAYMENT_INSTRUMENT_NUMBER_SELECTOR = "[data-testid='payment-instrument-number']"
|
|
25
|
+
|
|
26
|
+
#: The address lines that follow the Recipient's name in an unclassed list.
|
|
27
|
+
FIELD_RECIPIENT_ADDRESS_LINES_SELECTOR = "ul > li:not(:first-child)"
|
|
28
|
+
|
|
29
|
+
#: The Transactions, embedded as JSON in the Next.js page.
|
|
30
|
+
TRANSACTION_HISTORY_NEXT_DATA_SELECTOR = "script#__NEXT_DATA__"
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from amazonorders import transactions, util
|
|
7
|
+
from amazonorders.entity.transaction import Transaction
|
|
8
|
+
from amazonorders.exception import AmazonOrdersEntityError, AmazonOrdersError
|
|
9
|
+
from amazonorders.session import AmazonSession
|
|
10
|
+
from bs4 import Tag
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
#: An Order number within a Transaction's Order description.
|
|
15
|
+
_ORDER_NUMBER_RE = re.compile(r"\b(\d{3}-\d{7}-\d{7}|D\d{2}-\d{7}-\d{7})\b")
|
|
16
|
+
|
|
17
|
+
_PageResult = tuple[list[Transaction], dict[str, Any] | None]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TransactionsPage(transactions.TransactionsPage):
|
|
21
|
+
"""
|
|
22
|
+
The Transactions page of amazon.de, a Next.js page that embeds its first page of Transactions as JSON and loads
|
|
23
|
+
further pages from an internal API.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def get_page(
|
|
27
|
+
self, amazon_session: AmazonSession, url: str, next_page_data: dict[str, Any] | None = None
|
|
28
|
+
) -> _PageResult:
|
|
29
|
+
if not next_page_data or "request" not in next_page_data:
|
|
30
|
+
return super().get_page(amazon_session, url, next_page_data)
|
|
31
|
+
|
|
32
|
+
page_response = amazon_session.post(
|
|
33
|
+
self.config.constants.TRANSACTION_HISTORY_API_URL,
|
|
34
|
+
json=next_page_data["request"],
|
|
35
|
+
headers={"x-amzn-upx-token": next_page_data["token"]},
|
|
36
|
+
)
|
|
37
|
+
amazon_session.check_response(page_response, meta=next_page_data)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
data = page_response.response.json()
|
|
41
|
+
except ValueError as e:
|
|
42
|
+
raise AmazonOrdersError(
|
|
43
|
+
"Could not parse Transaction history. Check if Amazon changed the API.", meta=next_page_data
|
|
44
|
+
) from e
|
|
45
|
+
|
|
46
|
+
return self._parse_api_response(data, next_page_data)
|
|
47
|
+
|
|
48
|
+
def parse_page(self, parsed: Tag) -> _PageResult:
|
|
49
|
+
next_data_tag = util.select_one(parsed, self.config.selectors.TRANSACTION_HISTORY_NEXT_DATA_SELECTOR)
|
|
50
|
+
if next_data_tag:
|
|
51
|
+
result = self._parse_next_data(next_data_tag)
|
|
52
|
+
if result is not None:
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
return super().parse_page(parsed)
|
|
56
|
+
|
|
57
|
+
def _parse_next_data(self, next_data_tag: Tag) -> _PageResult | None:
|
|
58
|
+
try:
|
|
59
|
+
page_props = json.loads(next_data_tag.text)["props"]["pageProps"]
|
|
60
|
+
state = page_props["state"]["transactionResponseState"]
|
|
61
|
+
transaction_list = state["visibleTransactionResponse"]["transactionList"]
|
|
62
|
+
except (ValueError, KeyError, TypeError):
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
loaded_transactions = self._parse_transactions_data(transaction_list)
|
|
66
|
+
|
|
67
|
+
# Further pages are loaded from an internal API, authorized by the token embedded in the page
|
|
68
|
+
next_page_data = None
|
|
69
|
+
if state.get("hasMore") and state.get("lastEvaluatedPageKey"):
|
|
70
|
+
try:
|
|
71
|
+
next_page_data = {
|
|
72
|
+
"token": page_props["token"],
|
|
73
|
+
"request": {
|
|
74
|
+
"type": "GetTransactions",
|
|
75
|
+
"locale": page_props["request"]["locale"],
|
|
76
|
+
"surfaceInfo": page_props["manageWalletRequest"]["requestContext"]["surfaceInfo"],
|
|
77
|
+
"transactionsViewRequest": {"filtersControls": {"includeFilters": True}},
|
|
78
|
+
"widgetName": "ViewTransactions",
|
|
79
|
+
"exclusiveStartKey": state["lastEvaluatedPageKey"],
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
except (KeyError, TypeError):
|
|
83
|
+
logger.warning(
|
|
84
|
+
"Only the first page of Transactions could be loaded, as the data to load further "
|
|
85
|
+
"pages was not found."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return loaded_transactions, next_page_data
|
|
89
|
+
|
|
90
|
+
def _parse_api_response(self, data: dict[str, Any], next_page_data: dict[str, Any]) -> _PageResult:
|
|
91
|
+
try:
|
|
92
|
+
display_response = data["displayResponse"]
|
|
93
|
+
transaction_list = display_response["transactionsList"]
|
|
94
|
+
except (KeyError, TypeError) as e:
|
|
95
|
+
raise AmazonOrdersError(
|
|
96
|
+
"Could not parse Transaction history. Check if Amazon changed the API.", meta=next_page_data
|
|
97
|
+
) from e
|
|
98
|
+
|
|
99
|
+
loaded_transactions = self._parse_transactions_data(transaction_list)
|
|
100
|
+
|
|
101
|
+
last_evaluated_key = display_response.get("lastEvaluatedKey")
|
|
102
|
+
if not last_evaluated_key:
|
|
103
|
+
return loaded_transactions, None
|
|
104
|
+
|
|
105
|
+
return loaded_transactions, {
|
|
106
|
+
"token": next_page_data["token"],
|
|
107
|
+
"request": dict(next_page_data["request"], exclusiveStartKey=last_evaluated_key),
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
def _parse_transactions_data(self, transaction_list: list[dict[str, Any]]) -> list[Transaction]:
|
|
111
|
+
config = self.config
|
|
112
|
+
loaded_transactions = []
|
|
113
|
+
for data in transaction_list:
|
|
114
|
+
date_str = data.get("formattedDate")
|
|
115
|
+
completed_date = config.constants.parse_date(date_str)
|
|
116
|
+
if completed_date is None:
|
|
117
|
+
err_msg = (
|
|
118
|
+
f"Transaction date {date_str!r} could not be parsed, but it's required. Check if "
|
|
119
|
+
f"Amazon changed the page"
|
|
120
|
+
)
|
|
121
|
+
if not config.warn_on_missing_required_field:
|
|
122
|
+
raise AmazonOrdersEntityError(f"{err_msg} or set warn_on_missing_required_field=True in config.")
|
|
123
|
+
|
|
124
|
+
logger.warning(f"{err_msg}.")
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
grand_total = config.constants.parse_currency(data.get("formattedAmount"))
|
|
128
|
+
if grand_total is None:
|
|
129
|
+
logger.warning(
|
|
130
|
+
f"Transaction amount {data.get('formattedAmount')!r} could not be parsed, so the "
|
|
131
|
+
f"Transaction was skipped."
|
|
132
|
+
)
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
payment_data = data.get("paymentMethodDisplayStringData") or {}
|
|
136
|
+
payment_method_name = payment_data.get("paymentMethodName")
|
|
137
|
+
last_digits = (payment_data.get("paymentMethodNumber") or {}).get("lastDigits")
|
|
138
|
+
# When a Transaction covers more than one Order, only the first one is used
|
|
139
|
+
order_data = (data.get("orderData") or [{}])[0]
|
|
140
|
+
order_number_match = _ORDER_NUMBER_RE.search(order_data.get("orderDisplayString") or "")
|
|
141
|
+
|
|
142
|
+
loaded_transactions.append(
|
|
143
|
+
config.transaction_cls.from_fields(
|
|
144
|
+
config,
|
|
145
|
+
completed_date,
|
|
146
|
+
grand_total,
|
|
147
|
+
payment_method=f"{payment_method_name} ****{last_digits}"
|
|
148
|
+
if payment_method_name and last_digits
|
|
149
|
+
else payment_method_name,
|
|
150
|
+
payment_method_last_4=last_digits,
|
|
151
|
+
order_number=order_number_match.group(1) if order_number_match else None,
|
|
152
|
+
order_details_link=order_data.get("orderDetailsUrl"),
|
|
153
|
+
seller=data.get("statementDescriptor"),
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
return loaded_transactions
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: amazon-orders-de
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: An amazon-orders language package for amazon.de, which parses its pages in English
|
|
5
|
+
Project-URL: Source, https://github.com/udondan/amazon-orders-de
|
|
6
|
+
Author: Daniel Schroeder
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Requires-Dist: amazon-orders<5,>=4.8.1
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# amazon-orders-de
|
|
16
|
+
|
|
17
|
+
A [language package](https://amazon-orders.readthedocs.io/language-packages.html) for
|
|
18
|
+
[amazon-orders](https://github.com/alexdlaird/amazon-orders) that adds support for amazon.de.
|
|
19
|
+
|
|
20
|
+
Rather than translating `amazon-orders` into German, it asks amazon.de for its pages in English and handles what's
|
|
21
|
+
different there:
|
|
22
|
+
|
|
23
|
+
- British English labels ("Postage & Packing", "Estimated VAT")
|
|
24
|
+
- Orders whose service was cancelled
|
|
25
|
+
- Recipient addresses rendered as a plain list
|
|
26
|
+
- The payment method of an Order paid with a Gift Card and a card
|
|
27
|
+
- The Transactions page, which embeds its first page as JSON and loads further pages from an API
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
pip install amazon-orders-de
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
Set `language_package` and `domain` in the `amazon-orders` config (`~/.config/amazonorders/config.yml`):
|
|
38
|
+
|
|
39
|
+
```yaml
|
|
40
|
+
language_package: amazon_orders_de
|
|
41
|
+
domain: amazon.de
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
or pass them in Python:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from amazonorders.conf import AmazonOrdersConfig
|
|
48
|
+
from amazonorders.orders import AmazonOrders
|
|
49
|
+
from amazonorders.session import AmazonSession
|
|
50
|
+
|
|
51
|
+
config = AmazonOrdersConfig(data={"language_package": "amazon_orders_de", "domain": "amazon.de"})
|
|
52
|
+
amazon_session = AmazonSession(config=config)
|
|
53
|
+
amazon_session.login()
|
|
54
|
+
|
|
55
|
+
orders = AmazonOrders(amazon_session).get_order_history(year=2026)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Everything else works as described in the [amazon-orders docs](https://amazon-orders.readthedocs.io).
|
|
59
|
+
|
|
60
|
+
## Development
|
|
61
|
+
|
|
62
|
+
With [mise](https://mise.jdx.dev/), which installs Python and uv:
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
mise run install # uv sync
|
|
66
|
+
mise run lint # ruff and mypy; `mise run lint-fix` fixes what it can
|
|
67
|
+
mise run test
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Commits follow [Conventional Commits](https://www.conventionalcommits.org/), from which release-please creates the
|
|
71
|
+
releases and the changelog.
|
|
72
|
+
|
|
73
|
+
The unit tests parse real amazon.de pages in `tests/resources`. Before a page is added there, it's anonymized with
|
|
74
|
+
`scripts/anonymize-test-resources.py`, which removes personal and product details. The raw pages and the rules file
|
|
75
|
+
with the values to replace must never be committed.
|
|
76
|
+
|
|
77
|
+
The integration tests in `tests/integration` log in to amazon.de with `AMAZON_USERNAME`, `AMAZON_PASSWORD`, and
|
|
78
|
+
`AMAZON_OTP_SECRET_KEY`, and run weekly in CI.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
amazon_orders_de/__init__.py,sha256=Rwo3_V20Q7-Db_lqypjMJwSxk4otMBoRO5OwVMCGSNM,459
|
|
2
|
+
amazon_orders_de/constants.py,sha256=AXOdeYhDFXXm_pjLBejVIuuJG-elwEfvy0PwKqmbi8Y,860
|
|
3
|
+
amazon_orders_de/order.py,sha256=Hw70lKVt-S0cjCEm5YZ9VcWHrudi3DxPBhHI_lpmYNI,1766
|
|
4
|
+
amazon_orders_de/selectors.py,sha256=ppMKmlB8KA-stYbNNMTb-J2QUOulUhMzkxDnHcFnDBY,1436
|
|
5
|
+
amazon_orders_de/transactions.py,sha256=kx3uM9M5nERqkegtnEa9x8GyrRo95dDsuAXuDHd4BWw,6881
|
|
6
|
+
amazon_orders_de-1.0.0.dist-info/METADATA,sha256=JqnrvQhsGDAP8NmejpG3X2I2dAB8vYS8JWWc_9SFtEQ,2692
|
|
7
|
+
amazon_orders_de-1.0.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
amazon_orders_de-1.0.0.dist-info/licenses/LICENSE,sha256=vB-LyFzAYUHN758HXTKXGZdIfJe0iiDEUHT_KA7Czh8,1073
|
|
9
|
+
amazon_orders_de-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel Schroeder
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|