tbr-deal-finder 0.1.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.
- tbr_deal_finder/__init__.py +9 -0
- tbr_deal_finder/book.py +116 -0
- tbr_deal_finder/config.py +104 -0
- tbr_deal_finder/library_exports.py +155 -0
- tbr_deal_finder/main.py +223 -0
- tbr_deal_finder/migrations.py +125 -0
- tbr_deal_finder/queries/get_active_deals.sql +4 -0
- tbr_deal_finder/queries/get_deals_found_at.sql +4 -0
- tbr_deal_finder/queries/latest_deal_last_ran_most_recent_success.sql +5 -0
- tbr_deal_finder/retailer/__init__.py +9 -0
- tbr_deal_finder/retailer/audible.py +115 -0
- tbr_deal_finder/retailer/chirp.py +79 -0
- tbr_deal_finder/retailer/librofm.py +136 -0
- tbr_deal_finder/retailer/models.py +37 -0
- tbr_deal_finder/retailer_deal.py +184 -0
- tbr_deal_finder/utils.py +40 -0
- tbr_deal_finder-0.1.0.dist-info/METADATA +167 -0
- tbr_deal_finder-0.1.0.dist-info/RECORD +21 -0
- tbr_deal_finder-0.1.0.dist-info/WHEEL +4 -0
- tbr_deal_finder-0.1.0.dist-info/entry_points.txt +2 -0
- tbr_deal_finder-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,184 @@
|
|
1
|
+
import asyncio
|
2
|
+
import copy
|
3
|
+
from collections import defaultdict
|
4
|
+
|
5
|
+
import click
|
6
|
+
import pandas as pd
|
7
|
+
from tqdm.asyncio import tqdm_asyncio
|
8
|
+
|
9
|
+
from tbr_deal_finder.book import Book, get_active_deals
|
10
|
+
from tbr_deal_finder.config import Config
|
11
|
+
from tbr_deal_finder.library_exports import get_tbr_books
|
12
|
+
from tbr_deal_finder.retailer import RETAILER_MAP
|
13
|
+
from tbr_deal_finder.retailer.models import Retailer
|
14
|
+
from tbr_deal_finder.utils import get_duckdb_conn
|
15
|
+
|
16
|
+
|
17
|
+
def update_retailer_deal_table(config: Config, new_deals: list[Book]):
|
18
|
+
"""Adds new deals to the database and marks old deals as deleted
|
19
|
+
|
20
|
+
:param config:
|
21
|
+
:param new_deals:
|
22
|
+
"""
|
23
|
+
|
24
|
+
# This could be done using a temp table for the new deals, but that feels like overkill
|
25
|
+
# I can't imagine there's ever going to be more than 5,000 books in someone's TBR
|
26
|
+
# If it were any larger we'd have bigger problems.
|
27
|
+
active_deal_map = {deal.deal_id: deal for deal in get_active_deals()}
|
28
|
+
# Dirty trick to ensure uniqueness in request
|
29
|
+
new_deals = list({nd.deal_id: nd for nd in new_deals}.values())
|
30
|
+
df_data = []
|
31
|
+
|
32
|
+
for deal in new_deals:
|
33
|
+
if deal.deal_id in active_deal_map:
|
34
|
+
if deal.current_price != active_deal_map[deal.deal_id].current_price:
|
35
|
+
df_data.append(deal.dict())
|
36
|
+
|
37
|
+
active_deal_map.pop(deal.deal_id)
|
38
|
+
else:
|
39
|
+
df_data.append(deal.dict())
|
40
|
+
|
41
|
+
# Any remaining values in active_deal_map mean that
|
42
|
+
# it wasn't found and should be marked for deletion
|
43
|
+
for deal in active_deal_map.values():
|
44
|
+
click.echo(f"{str(deal)} is no longer active")
|
45
|
+
deal.timepoint = config.run_time
|
46
|
+
deal.deleted = True
|
47
|
+
df_data.append(deal.dict())
|
48
|
+
|
49
|
+
if df_data:
|
50
|
+
df = pd.DataFrame(df_data)
|
51
|
+
|
52
|
+
db_conn = get_duckdb_conn()
|
53
|
+
db_conn.register("_df", df)
|
54
|
+
db_conn.execute("INSERT INTO retailer_deal SELECT * FROM _df;")
|
55
|
+
db_conn.unregister("_df")
|
56
|
+
|
57
|
+
|
58
|
+
def _retry_books(found_books: list[Book], all_books: list[Book]) -> list[Book]:
|
59
|
+
response = []
|
60
|
+
found_book_set = {f'{b.title} - {b.authors}' for b in found_books}
|
61
|
+
for book in all_books:
|
62
|
+
if ":" not in book.title:
|
63
|
+
continue
|
64
|
+
|
65
|
+
if f'{book.title} - {book.authors}' not in found_book_set:
|
66
|
+
alt_book = copy.deepcopy(book)
|
67
|
+
alt_book.title = alt_book.title.split(":")[0]
|
68
|
+
response.append(alt_book)
|
69
|
+
|
70
|
+
return response
|
71
|
+
|
72
|
+
|
73
|
+
async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book]:
|
74
|
+
"""Get Books with limited concurrency.
|
75
|
+
|
76
|
+
- Creates a semaphore to limit concurrent requests.
|
77
|
+
- Creates a list to store the response.
|
78
|
+
- Creates a list to store unresolved books.
|
79
|
+
|
80
|
+
Args:
|
81
|
+
config: Application configuration
|
82
|
+
retailer: Retailer instance to fetch data from
|
83
|
+
books: List of Book objects to look up
|
84
|
+
|
85
|
+
Returns:
|
86
|
+
List of Book objects with updated pricing and availability
|
87
|
+
"""
|
88
|
+
semaphore = asyncio.Semaphore(10)
|
89
|
+
response = []
|
90
|
+
unresolved_books = []
|
91
|
+
|
92
|
+
tasks = [
|
93
|
+
retailer.get_book(copy.deepcopy(book), config.run_time, semaphore)
|
94
|
+
for book in books
|
95
|
+
]
|
96
|
+
results = await tqdm_asyncio.gather(*tasks, desc=f"Getting latest prices from {retailer.name}")
|
97
|
+
for book in results:
|
98
|
+
if book.exists:
|
99
|
+
response.append(book)
|
100
|
+
elif not book.exists:
|
101
|
+
unresolved_books.append(book)
|
102
|
+
|
103
|
+
if retry_books := _retry_books(response, books):
|
104
|
+
click.echo("Attempting to find missing books with alternate title")
|
105
|
+
response.extend(await _get_books(config, retailer, retry_books))
|
106
|
+
elif unresolved_books:
|
107
|
+
click.echo()
|
108
|
+
for book in unresolved_books:
|
109
|
+
click.echo(f"{book.title} by {book.authors} not found")
|
110
|
+
|
111
|
+
return response
|
112
|
+
|
113
|
+
|
114
|
+
def _apply_proper_list_prices(books: list[Book]):
|
115
|
+
"""
|
116
|
+
Applies the lowest list price found across all retailers to each book.
|
117
|
+
|
118
|
+
This function:
|
119
|
+
- Creates a mapping of book titles and authors to their list prices.
|
120
|
+
- For each book, it checks if the list price is higher than the current value.
|
121
|
+
- If the list price is higher, it updates the book's list price.
|
122
|
+
"""
|
123
|
+
|
124
|
+
book_pricing_map = defaultdict(dict)
|
125
|
+
for book in books:
|
126
|
+
relevant_book_map = book_pricing_map[book.title_id]
|
127
|
+
|
128
|
+
if book.list_price > 0 and (
|
129
|
+
"list_price" not in relevant_book_map
|
130
|
+
or relevant_book_map["list_price"] > book.list_price
|
131
|
+
):
|
132
|
+
relevant_book_map["list_price"] = book.list_price
|
133
|
+
|
134
|
+
if "retailers" not in relevant_book_map:
|
135
|
+
relevant_book_map["retailers"] = []
|
136
|
+
|
137
|
+
relevant_book_map["retailers"].append(book)
|
138
|
+
|
139
|
+
# Apply the lowest list price to all
|
140
|
+
for book_info in book_pricing_map.values():
|
141
|
+
list_price = book_info.get("list_price", 0)
|
142
|
+
for book in book_info["retailers"]:
|
143
|
+
# Using current_price if list_price couldn't be determined,
|
144
|
+
# This is an issue with Libro.fm where it doesn't return list price
|
145
|
+
book.list_price = max(book.current_price, list_price)
|
146
|
+
|
147
|
+
|
148
|
+
async def get_latest_deals(config: Config):
|
149
|
+
"""
|
150
|
+
Fetches the latest book deals from all tracked retailers for the user's TBR list.
|
151
|
+
|
152
|
+
This function:
|
153
|
+
- Retrieves the user's TBR books based on the provided config.
|
154
|
+
- Iterates through each retailer specified in the config.
|
155
|
+
- For each retailer, fetches the latest deals for the TBR books, handling authentication as needed.
|
156
|
+
- Applies the lowest list price found across all retailers to each book.
|
157
|
+
- Filters books to those that meet the user's max price and minimum discount requirements.
|
158
|
+
- Updates the retailer deal table with the filtered deals.
|
159
|
+
|
160
|
+
Args:
|
161
|
+
config (Config): The user's configuration object.
|
162
|
+
|
163
|
+
"""
|
164
|
+
|
165
|
+
books: list[Book] = []
|
166
|
+
tbr_books = get_tbr_books(config)
|
167
|
+
for retailer_str in config.tracked_retailers:
|
168
|
+
retailer = RETAILER_MAP[retailer_str]()
|
169
|
+
await retailer.set_auth()
|
170
|
+
|
171
|
+
click.echo(f"Getting deals from {retailer.name}")
|
172
|
+
click.echo("\n---------------")
|
173
|
+
books.extend(await _get_books(config, retailer, tbr_books))
|
174
|
+
click.echo("---------------\n")
|
175
|
+
|
176
|
+
_apply_proper_list_prices(books)
|
177
|
+
|
178
|
+
books = [
|
179
|
+
book
|
180
|
+
for book in books
|
181
|
+
if book.current_price <= config.max_price and book.discount() >= config.min_discount
|
182
|
+
]
|
183
|
+
|
184
|
+
update_retailer_deal_table(config, books)
|
tbr_deal_finder/utils.py
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
import re
|
2
|
+
from typing import Optional
|
3
|
+
|
4
|
+
import duckdb
|
5
|
+
|
6
|
+
from tbr_deal_finder import TBR_DEALS_PATH, QUERY_PATH
|
7
|
+
|
8
|
+
|
9
|
+
def currency_to_float(price_str):
|
10
|
+
"""Parse various price formats to float."""
|
11
|
+
if not price_str:
|
12
|
+
return 0.0
|
13
|
+
|
14
|
+
# Remove currency symbols, commas, and whitespace
|
15
|
+
cleaned = re.sub(r'[^\d.]', '', str(price_str))
|
16
|
+
|
17
|
+
try:
|
18
|
+
return float(cleaned) if cleaned else 0.0
|
19
|
+
except ValueError:
|
20
|
+
return 0.0
|
21
|
+
|
22
|
+
|
23
|
+
def get_duckdb_conn():
|
24
|
+
return duckdb.connect(TBR_DEALS_PATH.joinpath("tbr_deal_finder.db"))
|
25
|
+
|
26
|
+
|
27
|
+
def execute_query(
|
28
|
+
db_conn: duckdb.DuckDBPyConnection,
|
29
|
+
query: str,
|
30
|
+
query_params: Optional[dict] = None,
|
31
|
+
) -> list[dict]:
|
32
|
+
q = db_conn.execute(query, query_params if query_params is not None else {})
|
33
|
+
rows = q.fetchall()
|
34
|
+
assert q.description
|
35
|
+
column_names = [desc[0] for desc in q.description]
|
36
|
+
return [dict(zip(column_names, row)) for row in rows]
|
37
|
+
|
38
|
+
|
39
|
+
def get_query_by_name(file_name: str) -> str:
|
40
|
+
return QUERY_PATH.joinpath(file_name).read_text()
|
@@ -0,0 +1,167 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: tbr-deal-finder
|
3
|
+
Version: 0.1.0
|
4
|
+
Summary: Track price drops and find deals on books in your TBR list across audiobook and ebook formats.
|
5
|
+
License: MIT
|
6
|
+
License-File: LICENSE
|
7
|
+
Requires-Python: >=3.13
|
8
|
+
Requires-Dist: aiohttp>=3.12.14
|
9
|
+
Requires-Dist: audible>=0.10.0
|
10
|
+
Requires-Dist: click>=8.2.1
|
11
|
+
Requires-Dist: duckdb>=1.3.2
|
12
|
+
Requires-Dist: pandas>=2.3.1
|
13
|
+
Requires-Dist: questionary>=2.1.0
|
14
|
+
Requires-Dist: tqdm>=4.67.1
|
15
|
+
Requires-Dist: unidecode>=1.4.0
|
16
|
+
Description-Content-Type: text/markdown
|
17
|
+
|
18
|
+
# tbr-deal-finder
|
19
|
+
|
20
|
+
Track price drops and find deals on books in your TBR (To Be Read) list across audiobook and ebook formats.
|
21
|
+
|
22
|
+
## Features
|
23
|
+
- Uses your StoryGraph exports, Goodreads exports, and custom csvs (spreadsheet) to track book deals
|
24
|
+
- Supports multiple of the library exports above
|
25
|
+
- Supports multiple locales and currencies
|
26
|
+
- Finds the latest and active deals from supported sellers
|
27
|
+
- Simple CLI interface for setup and usage
|
28
|
+
- Only get notified for new deals or view all active deals
|
29
|
+
|
30
|
+
## Support
|
31
|
+
|
32
|
+
### Audiobooks
|
33
|
+
* Audible
|
34
|
+
* Chirp
|
35
|
+
* Libro.fm (Work in progress)
|
36
|
+
|
37
|
+
### Locales
|
38
|
+
* US
|
39
|
+
* CA
|
40
|
+
* UK
|
41
|
+
* AU
|
42
|
+
* FR
|
43
|
+
* DE
|
44
|
+
* JP
|
45
|
+
* IT
|
46
|
+
* IN
|
47
|
+
* ES
|
48
|
+
* BR
|
49
|
+
|
50
|
+
## Installation Guide
|
51
|
+
|
52
|
+
### Python (Recommended)
|
53
|
+
1. If it's not already on your computer, download Python https://www.python.org/downloads/
|
54
|
+
1. tbr-deal-finder requires Python3.13 or higher
|
55
|
+
2. Optional: Install and use virtualenv
|
56
|
+
3. Open your Terminal/Commmand Prompt
|
57
|
+
4. Run `pip3.13 install tbr-deal-finder`
|
58
|
+
|
59
|
+
### UV
|
60
|
+
1. Clone the repository:
|
61
|
+
```sh
|
62
|
+
git clone https://github.com/yourusername/tbr-deal-finder.git
|
63
|
+
cd tbr-deal-finder
|
64
|
+
```
|
65
|
+
2. Install uv:
|
66
|
+
https://docs.astral.sh/uv/getting-started/installation/
|
67
|
+
|
68
|
+
## Configuration
|
69
|
+
This tool relies on the csv generated by the app you use to track your TBRs.
|
70
|
+
Here are the steps to get your export.
|
71
|
+
|
72
|
+
### StoryGraph
|
73
|
+
* Open https://app.thestorygraph.com/ in the browser of your choice
|
74
|
+
* Click on your profile icon in the top right corner
|
75
|
+
* Select "Manage Account"
|
76
|
+
* Scroll down to "Manage Your Data"
|
77
|
+
* Click the button "Export StoryGraph Library"
|
78
|
+
* You will be navigated to https://app.thestorygraph.com/user-export
|
79
|
+
* Click "Generate export"
|
80
|
+
* Wait a few minutes and refresh the page
|
81
|
+
* A new item will appear that says "Your export from ... - Download" will appear
|
82
|
+
* Click "Download"
|
83
|
+
|
84
|
+
### Goodreads
|
85
|
+
* Open https://www.goodreads.com/review/import in the browser of your choice
|
86
|
+
* At the top of the page click the button "Export Library"
|
87
|
+
* Wait a few minutes and refresh the page
|
88
|
+
* A new item will appear that says "Your export from ..." will appear
|
89
|
+
* Click it to download the csv
|
90
|
+
|
91
|
+
### Custom csv
|
92
|
+
If you've got your own CSV you're using to track your TBRs all you need are the following columns for it to be in a valid format
|
93
|
+
* `Title`
|
94
|
+
* `Authors`
|
95
|
+
* `Read Status`* (See below)
|
96
|
+
|
97
|
+
Optionally, you can add the `Read Status` column. Set `to-read` for all books you want to be tracked.
|
98
|
+
If you don't add this column the deal finder will run on ALL books in the CSV.
|
99
|
+
|
100
|
+
### tbr-deal-finder setup
|
101
|
+
|
102
|
+
#### Python
|
103
|
+
```sh
|
104
|
+
tbr-deal-finder setup
|
105
|
+
```
|
106
|
+
|
107
|
+
#### UV
|
108
|
+
```sh
|
109
|
+
uv run -m tbr_deal_finder.main setup
|
110
|
+
```
|
111
|
+
|
112
|
+
You will be prompted to:
|
113
|
+
- Enter the path(s) to your StoryGraph export CSV file(s)
|
114
|
+
- Select your locale (country/region)
|
115
|
+
- Set your maximum price for deals
|
116
|
+
- Set your minimum discount percentage
|
117
|
+
|
118
|
+
The configuration will be saved for future runs.
|
119
|
+
|
120
|
+
## Usage
|
121
|
+
All commands are available via the CLI:
|
122
|
+
|
123
|
+
- `setup` – Set up or update your configuration interactively.
|
124
|
+
- `latest-deals` – Find and print the latest book deals based on your config.
|
125
|
+
- `active-deals` – Show all currently active deals.
|
126
|
+
|
127
|
+
#### Python
|
128
|
+
```sh
|
129
|
+
tbr-deal-finder [COMMAND]
|
130
|
+
```
|
131
|
+
|
132
|
+
#### UV
|
133
|
+
```sh
|
134
|
+
uv run -m tbr_deal_finder.main [COMMAND]
|
135
|
+
```
|
136
|
+
|
137
|
+
Example:
|
138
|
+
```sh
|
139
|
+
tbr-deal-finder latest-deals
|
140
|
+
|
141
|
+
# or
|
142
|
+
|
143
|
+
uv run -m tbr_deal_finder.main latest-deals
|
144
|
+
```
|
145
|
+
|
146
|
+
## Updating your TBR
|
147
|
+
To update tbr-deal-finder as your TBR changes, regenerate and download your library export.
|
148
|
+
See [Configuration](#Configuration) for steps.
|
149
|
+
|
150
|
+
|
151
|
+
## Updating the tbr-deal-finder
|
152
|
+
|
153
|
+
### Python
|
154
|
+
```sh
|
155
|
+
pip3.13 install tbr-deal-finder --upgrade
|
156
|
+
```
|
157
|
+
|
158
|
+
### UV
|
159
|
+
```sh
|
160
|
+
# From the repo directory
|
161
|
+
git checkout main && git fetch
|
162
|
+
```
|
163
|
+
|
164
|
+
|
165
|
+
---
|
166
|
+
|
167
|
+
Happy deal hunting!
|
@@ -0,0 +1,21 @@
|
|
1
|
+
tbr_deal_finder/__init__.py,sha256=WCoj0GZrRiCQlrpkLTw1VUeJmX-RtBLdLqnFYn1Es_4,208
|
2
|
+
tbr_deal_finder/book.py,sha256=2MQirkxDIVKdQQ07U56zwOw45rC8KH-5aC932_X9dhE,3333
|
3
|
+
tbr_deal_finder/config.py,sha256=3fgN92sVsQbVqRBc58QK9w5t35zoPX6pP3k4nnJ_YTg,3441
|
4
|
+
tbr_deal_finder/library_exports.py,sha256=FJXBQV_9H5AI_cwNuGjvGQxgQ1RLlpldmDBwc_PsiK4,5474
|
5
|
+
tbr_deal_finder/main.py,sha256=8riqTK5QVPKyP3aUQ3hHjNbIuZ0ibF_WLiNFMBqddT4,6830
|
6
|
+
tbr_deal_finder/migrations.py,sha256=6_WV55bm71UCFrcFrfJXlEX5uDrgnNTWZPq6vZTg18o,3733
|
7
|
+
tbr_deal_finder/retailer_deal.py,sha256=J3dGceB84wDQUV0FsW_0I0PLA5nU6LStzAx6sMNTo2c,6408
|
8
|
+
tbr_deal_finder/utils.py,sha256=c_AfIpfE1IAewUBiaRhPHjBM2o-fvuVVcWfM7jPEOvk,1021
|
9
|
+
tbr_deal_finder/queries/get_active_deals.sql,sha256=jILZK5UVNPLbbKWgqMW0brEZyCb9XBdQZJLHRULoQC4,195
|
10
|
+
tbr_deal_finder/queries/get_deals_found_at.sql,sha256=1vAE8PsAvfFi0SbvoUw8pvLwRN9VGYTJ7AVI3rmxXEI,122
|
11
|
+
tbr_deal_finder/queries/latest_deal_last_ran_most_recent_success.sql,sha256=W4cNMAHtcW2DzQyPL8SHHFcbVZQKVK2VfTzazxC3LJU,107
|
12
|
+
tbr_deal_finder/retailer/__init__.py,sha256=WePMSN7vi4EL_uPiAH6ogNNE-kRQe4OHT4CYGTKvBSk,243
|
13
|
+
tbr_deal_finder/retailer/audible.py,sha256=7QYkaZOlImYOiBUo4zqhqwMEQVLMR895sk4rK7qpO3g,3478
|
14
|
+
tbr_deal_finder/retailer/chirp.py,sha256=mi2uIhiXHxMnlRgtd1BZULEZbpF_K2HzxWubV4v_vvc,3540
|
15
|
+
tbr_deal_finder/retailer/librofm.py,sha256=M4WvGh3Gf3LVUE3KOCVtNKJB8koQasgybUFhKBvqBe0,4476
|
16
|
+
tbr_deal_finder/retailer/models.py,sha256=zEwyM_0ildB8p38sxfpE6p2dIvtwAjeaul-GkJlk2Fo,1012
|
17
|
+
tbr_deal_finder-0.1.0.dist-info/METADATA,sha256=4-KnI5bGPGpv0KInTJON81buPsxfekMIV8q98Gk0Nco,4161
|
18
|
+
tbr_deal_finder-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
19
|
+
tbr_deal_finder-0.1.0.dist-info/entry_points.txt,sha256=3QvgMcIaHUHBs725doFts_fMGIWYzi1BWtxA8e1wut8,40
|
20
|
+
tbr_deal_finder-0.1.0.dist-info/licenses/LICENSE,sha256=rNc0wNPn4d4HHu6ZheJzeUaz_FbJ4rj2Dr2FjAivkNg,1064
|
21
|
+
tbr_deal_finder-0.1.0.dist-info/RECORD,,
|
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2025 WillNye
|
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.
|