tbr-deal-finder 0.2.1__py3-none-any.whl → 0.3.1__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.
Files changed (32) hide show
  1. tbr_deal_finder/__init__.py +1 -5
  2. tbr_deal_finder/__main__.py +7 -0
  3. tbr_deal_finder/book.py +16 -8
  4. tbr_deal_finder/cli.py +13 -27
  5. tbr_deal_finder/config.py +2 -2
  6. tbr_deal_finder/desktop_updater.py +147 -0
  7. tbr_deal_finder/gui/__init__.py +0 -0
  8. tbr_deal_finder/gui/main.py +725 -0
  9. tbr_deal_finder/gui/pages/__init__.py +1 -0
  10. tbr_deal_finder/gui/pages/all_books.py +93 -0
  11. tbr_deal_finder/gui/pages/all_deals.py +63 -0
  12. tbr_deal_finder/gui/pages/base_book_page.py +291 -0
  13. tbr_deal_finder/gui/pages/book_details.py +604 -0
  14. tbr_deal_finder/gui/pages/latest_deals.py +370 -0
  15. tbr_deal_finder/gui/pages/settings.py +389 -0
  16. tbr_deal_finder/retailer/amazon.py +58 -7
  17. tbr_deal_finder/retailer/amazon_custom_auth.py +79 -0
  18. tbr_deal_finder/retailer/audible.py +2 -1
  19. tbr_deal_finder/retailer/chirp.py +55 -11
  20. tbr_deal_finder/retailer/kindle.py +31 -19
  21. tbr_deal_finder/retailer/librofm.py +53 -20
  22. tbr_deal_finder/retailer/models.py +31 -1
  23. tbr_deal_finder/retailer_deal.py +38 -14
  24. tbr_deal_finder/tracked_books.py +24 -18
  25. tbr_deal_finder/utils.py +64 -2
  26. tbr_deal_finder/version_check.py +40 -0
  27. {tbr_deal_finder-0.2.1.dist-info → tbr_deal_finder-0.3.1.dist-info}/METADATA +18 -87
  28. tbr_deal_finder-0.3.1.dist-info/RECORD +38 -0
  29. {tbr_deal_finder-0.2.1.dist-info → tbr_deal_finder-0.3.1.dist-info}/entry_points.txt +1 -0
  30. tbr_deal_finder-0.2.1.dist-info/RECORD +0 -25
  31. {tbr_deal_finder-0.2.1.dist-info → tbr_deal_finder-0.3.1.dist-info}/WHEEL +0 -0
  32. {tbr_deal_finder-0.2.1.dist-info → tbr_deal_finder-0.3.1.dist-info}/licenses/LICENSE +0 -0
@@ -1,6 +1,6 @@
1
1
  import asyncio
2
2
  import json
3
- import readline # type: ignore
3
+ from typing import Union
4
4
 
5
5
  from tbr_deal_finder.config import Config
6
6
  from tbr_deal_finder.retailer.amazon import Amazon, AUTH_PATH
@@ -20,6 +20,10 @@ class Kindle(Amazon):
20
20
  def format(self) -> BookFormat:
21
21
  return BookFormat.EBOOK
22
22
 
23
+ @property
24
+ def max_concurrency(self) -> int:
25
+ return 3
26
+
23
27
  def _get_base_url(self) -> str:
24
28
  return f"https://www.amazon.{self._auth.locale.domain}"
25
29
 
@@ -73,7 +77,7 @@ class Kindle(Amazon):
73
77
  self,
74
78
  target: Book,
75
79
  semaphore: asyncio.Semaphore
76
- ) -> Book:
80
+ ) -> Union[Book, None]:
77
81
  target.exists = False
78
82
 
79
83
  if not target.ebook_asin:
@@ -81,26 +85,34 @@ class Kindle(Amazon):
81
85
 
82
86
  asin = target.ebook_asin
83
87
  async with semaphore:
84
- match = await self._client.get(
85
- f"{self._get_base_url()}/api/bifrost/offers/batch/v1/{asin}?ref_=KindleDeepLinkOffers",
86
- headers={"x-client-id": "kindle-android-deeplink"},
87
- )
88
- products = match.get("resources", [])
89
- if not products:
90
- return target
88
+ for i in range(10):
89
+ match = await self._client.get(
90
+ f"{self._get_base_url()}/api/bifrost/offers/batch/v1/{asin}?ref_=KindleDeepLinkOffers",
91
+ headers={"x-client-id": "kindle-android-deeplink"},
92
+ )
93
+ products = match.get("resources", [])
94
+ if not products:
95
+ await asyncio.sleep(1)
96
+ continue
91
97
 
92
- actions = products[0].get("personalizedActionOutput", {}).get("personalizedActions", [])
93
- if not actions:
94
- return target
98
+ actions = products[0].get("personalizedActionOutput", {}).get("personalizedActions", [])
99
+ if not actions:
100
+ await asyncio.sleep(1)
101
+ continue
95
102
 
96
- for action in actions:
97
- if "printListPrice" in action["offer"]:
98
- target.list_price = action["offer"]["printListPrice"]["value"]
99
- target.current_price = action["offer"]["digitalPrice"]["value"]
100
- target.exists = True
101
- break
103
+ for action in actions:
104
+ if "printListPrice" in action["offer"]:
105
+ target.list_price = action["offer"]["printListPrice"]["value"]
106
+ target.current_price = action["offer"]["digitalPrice"]["value"]
107
+ target.exists = True
108
+ break
102
109
 
103
- return target
110
+ # The sleep is a pre-emptive backoff
111
+ # Concurrency is already low, but this endpoint loves to throttle
112
+ await asyncio.sleep(.25)
113
+ return target
114
+
115
+ return None
104
116
 
105
117
  async def get_wishlist(self, config: Config) -> list[Book]:
106
118
  """Not currently supported
@@ -3,12 +3,12 @@ import json
3
3
  import os
4
4
  import urllib.parse
5
5
  from datetime import datetime, timedelta
6
+ from typing import Union
6
7
 
7
8
  import click
8
9
 
9
- from tbr_deal_finder import TBR_DEALS_PATH
10
10
  from tbr_deal_finder.config import Config
11
- from tbr_deal_finder.retailer.models import AioHttpSession, Retailer
11
+ from tbr_deal_finder.retailer.models import AioHttpSession, Retailer, GuiAuthContext
12
12
  from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors, is_matching_authors, get_normalized_title
13
13
  from tbr_deal_finder.utils import currency_to_float, echo_err
14
14
 
@@ -57,16 +57,21 @@ class LibroFM(AioHttpSession, Retailer):
57
57
  else:
58
58
  return {}
59
59
 
60
- async def set_auth(self):
61
- auth_path = TBR_DEALS_PATH.joinpath("libro_fm.json")
62
- if os.path.exists(auth_path):
63
- with open(auth_path, "r") as f:
60
+ def user_is_authed(self) -> bool:
61
+ if os.path.exists(self.auth_path):
62
+ with open(self.auth_path, "r") as f:
64
63
  auth_info = json.load(f)
65
64
  token_created_at = datetime.fromtimestamp(auth_info["created_at"])
66
- max_token_age = datetime.now() - timedelta(days=5)
65
+ max_token_age = datetime.now() - timedelta(days=7)
67
66
  if token_created_at > max_token_age:
68
67
  self.auth_token = auth_info["access_token"]
69
- return
68
+ return True
69
+
70
+ return False
71
+
72
+ async def set_auth(self):
73
+ if self.user_is_authed():
74
+ return
70
75
 
71
76
  response = await self.make_request(
72
77
  "/oauth/token",
@@ -82,9 +87,37 @@ class LibroFM(AioHttpSession, Retailer):
82
87
  await self.set_auth()
83
88
 
84
89
  self.auth_token = response["access_token"]
85
- with open(auth_path, "w") as f:
90
+ with open(self.auth_path, "w") as f:
86
91
  json.dump(response, f)
87
92
 
93
+ @property
94
+ def gui_auth_context(self) -> GuiAuthContext:
95
+ return GuiAuthContext(
96
+ title="Login to Libro.FM",
97
+ fields=[
98
+ {"name": "username", "label": "Username", "type": "text"},
99
+ {"name": "password", "label": "Password", "type": "password"}
100
+ ]
101
+ )
102
+
103
+ async def gui_auth(self, form_data: dict) -> bool:
104
+ response = await self.make_request(
105
+ "/oauth/token",
106
+ "POST",
107
+ json={
108
+ "grant_type": "password",
109
+ "username": form_data["username"],
110
+ "password": form_data["password"],
111
+ }
112
+ )
113
+ if "access_token" not in response:
114
+ return False
115
+
116
+ self.auth_token = response["access_token"]
117
+ with open(self.auth_path, "w") as f:
118
+ json.dump(response, f)
119
+ return True
120
+
88
121
  async def get_book_isbn(self, book: Book, semaphore: asyncio.Semaphore) -> Book:
89
122
  # runtime isn't used but get_book_isbn must follow the get_book method signature.
90
123
 
@@ -115,24 +148,24 @@ class LibroFM(AioHttpSession, Retailer):
115
148
 
116
149
  async def get_book(
117
150
  self, target: Book, semaphore: asyncio.Semaphore
118
- ) -> Book:
151
+ ) -> Union[Book, None]:
119
152
  if not target.audiobook_isbn:
120
153
  target.exists = False
121
154
  return target
122
155
 
123
156
  async with semaphore:
124
- response = await self.make_request(
125
- f"api/v10/explore/audiobook_details/{target.audiobook_isbn}",
126
- "GET"
127
- )
157
+ for _ in range(10):
158
+ response = await self.make_request(
159
+ f"api/v10/explore/audiobook_details/{target.audiobook_isbn}",
160
+ "GET"
161
+ )
128
162
 
129
- if response:
130
- target.list_price = target.audiobook_list_price
131
- target.current_price = currency_to_float(response["data"]["purchase_info"]["price"])
132
- return target
163
+ if response:
164
+ target.list_price = target.audiobook_list_price
165
+ target.current_price = currency_to_float(response["data"]["purchase_info"]["price"])
166
+ return target
133
167
 
134
- target.exists = False
135
- return target
168
+ return None
136
169
 
137
170
  async def get_wishlist(self, config: Config) -> list[Book]:
138
171
  wishlist_books = []
@@ -1,10 +1,23 @@
1
1
  import abc
2
2
  import asyncio
3
+ import dataclasses
4
+ from pathlib import Path
5
+ from typing import Optional, Union
3
6
 
4
7
  import aiohttp
5
8
 
6
9
  from tbr_deal_finder.book import Book, BookFormat
7
10
  from tbr_deal_finder.config import Config
11
+ from tbr_deal_finder.utils import get_data_dir
12
+
13
+
14
+ @dataclasses.dataclass
15
+ class GuiAuthContext:
16
+ title: str
17
+ fields: list[dict]
18
+ message: Optional[str] = None
19
+ user_copy_context: Optional[str] = None
20
+ pop_up_type: Optional[str] = "form"
8
21
 
9
22
 
10
23
  class Retailer(abc.ABC):
@@ -26,12 +39,29 @@ class Retailer(abc.ABC):
26
39
  """
27
40
  raise NotImplementedError
28
41
 
42
+ @property
43
+ def auth_path(self) -> Path:
44
+ name = self.name.replace(".", "").lower()
45
+ return get_data_dir().joinpath(f"{name}.json")
46
+
47
+ @property
48
+ def gui_auth_context(self) -> GuiAuthContext:
49
+ raise NotImplementedError
50
+
51
+ @property
52
+ def max_concurrency(self) -> int:
53
+ # The max number of simultaneous requests to send to this retailer
54
+ return 10
55
+
29
56
  async def set_auth(self):
30
57
  raise NotImplementedError
31
58
 
59
+ async def gui_auth(self, form_data: dict) -> bool:
60
+ raise NotImplementedError
61
+
32
62
  async def get_book(
33
63
  self, target: Book, semaphore: asyncio.Semaphore
34
- ) -> Book:
64
+ ) -> Union[Book, None]:
35
65
  """Get book information from the retailer.
36
66
 
37
67
  - Uses Audible's product API to fetch book details
@@ -11,7 +11,7 @@ from tbr_deal_finder.config import Config
11
11
  from tbr_deal_finder.tracked_books import get_tbr_books, get_unknown_books, set_unknown_books
12
12
  from tbr_deal_finder.retailer import RETAILER_MAP
13
13
  from tbr_deal_finder.retailer.models import Retailer
14
- from tbr_deal_finder.utils import get_duckdb_conn, echo_warning, echo_info
14
+ from tbr_deal_finder.utils import get_duckdb_conn, echo_info, echo_err, is_gui_env
15
15
 
16
16
 
17
17
  def update_retailer_deal_table(config: Config, new_deals: list[Book]):
@@ -38,14 +38,6 @@ def update_retailer_deal_table(config: Config, new_deals: list[Book]):
38
38
  else:
39
39
  df_data.append(deal.dict())
40
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
- echo_warning(f"{str(deal)} is no longer active\n")
45
- deal.timepoint = config.run_time
46
- deal.deleted = True
47
- df_data.append(deal.dict())
48
-
49
41
  if df_data:
50
42
  df = pd.DataFrame(df_data)
51
43
 
@@ -75,7 +67,7 @@ async def _get_books(
75
67
  Returns:
76
68
  List of Book objects with updated pricing and availability
77
69
  """
78
- semaphore = asyncio.Semaphore(10)
70
+ semaphore = asyncio.Semaphore(retailer.max_concurrency)
79
71
  response = []
80
72
  unknown_books = []
81
73
  books = [copy.deepcopy(book) for book in books]
@@ -88,9 +80,21 @@ async def _get_books(
88
80
  for book in books
89
81
  if book.deal_id not in ignored_deal_ids
90
82
  ]
91
- results = await tqdm_asyncio.gather(*tasks, desc=f"Getting latest prices from {retailer.name}")
83
+
84
+ if is_gui_env():
85
+ results = await asyncio.gather(*tasks)
86
+ else:
87
+ results = await tqdm_asyncio.gather(*tasks, desc=f"Getting latest prices from {retailer.name}")
88
+
92
89
  for book in results:
93
- if book.exists:
90
+ if not book:
91
+ """Cases where we know the retailer has the book but it's not coming back.
92
+ We don't want to mark it as unknown it's more like we just got rate limited.
93
+
94
+ Kindle has been particularly bad about this.
95
+ """
96
+ continue
97
+ elif book.exists:
94
98
  response.append(book)
95
99
  elif not book.exists:
96
100
  unknown_books.append(book)
@@ -157,7 +161,7 @@ def _get_retailer_relevant_tbr_books(
157
161
  return response
158
162
 
159
163
 
160
- async def get_latest_deals(config: Config):
164
+ async def _get_latest_deals(config: Config):
161
165
  """
162
166
  Fetches the latest book deals from all tracked retailers for the user's TBR list.
163
167
 
@@ -206,8 +210,28 @@ async def get_latest_deals(config: Config):
206
210
  books = [
207
211
  book
208
212
  for book in books
209
- if book.current_price <= config.max_price and book.discount() >= config.min_discount
210
213
  ]
211
214
 
212
215
  update_retailer_deal_table(config, books)
213
216
  set_unknown_books(config, unknown_books)
217
+
218
+
219
+ async def get_latest_deals(config: Config) -> bool:
220
+ try:
221
+ await _get_latest_deals(config)
222
+ except Exception as e:
223
+ ran_successfully = False
224
+ details = f"Error getting deals: {e}"
225
+ echo_err(details)
226
+ else:
227
+ ran_successfully = True
228
+ details = ""
229
+
230
+ # Save execution results
231
+ db_conn = get_duckdb_conn()
232
+ db_conn.execute(
233
+ "INSERT INTO latest_deal_run_history (timepoint, ran_successfully, details) VALUES (?, ?, ?)",
234
+ [config.run_time, ran_successfully, details]
235
+ )
236
+
237
+ return ran_successfully
@@ -14,7 +14,7 @@ from tbr_deal_finder.owned_books import get_owned_books
14
14
  from tbr_deal_finder.retailer import Chirp, RETAILER_MAP, LibroFM, Kindle
15
15
  from tbr_deal_finder.config import Config
16
16
  from tbr_deal_finder.retailer.models import Retailer
17
- from tbr_deal_finder.utils import execute_query, get_duckdb_conn, get_query_by_name
17
+ from tbr_deal_finder.utils import execute_query, get_duckdb_conn, get_query_by_name, is_gui_env
18
18
 
19
19
 
20
20
  def _library_export_tbr_books(config: Config, tbr_book_map: dict[str: Book]):
@@ -139,16 +139,20 @@ async def _set_tbr_book_attr(
139
139
  tbr_books_map = {b.full_title_str: b for b in tbr_books}
140
140
  tbr_books_copy = copy.deepcopy(tbr_books)
141
141
  semaphore = asyncio.Semaphore(5)
142
- human_readable_name = target_attr.replace("_", " ").title()
143
142
 
144
143
  # Get books with the appropriate transform applied
145
144
  # Responsibility is on the callable here
146
- enriched_books = await tqdm_asyncio.gather(
147
- *[
148
- get_book_callable(book, semaphore) for book in tbr_books_copy
149
- ],
150
- desc=f"Getting required {human_readable_name} info"
151
- )
145
+ tasks = [
146
+ get_book_callable(book, semaphore) for book in tbr_books_copy
147
+ ]
148
+ if is_gui_env():
149
+ enriched_books = await asyncio.gather(*tasks)
150
+ else:
151
+ human_readable_name = target_attr.replace("_", " ").title()
152
+ enriched_books = await tqdm_asyncio.gather(
153
+ *tasks,
154
+ desc=f"Getting required {human_readable_name} info"
155
+ )
152
156
  for enriched_book in enriched_books:
153
157
  book = tbr_books_map[enriched_book.full_title_str]
154
158
  setattr(
@@ -241,20 +245,22 @@ def clear_unknown_books():
241
245
 
242
246
 
243
247
  def set_unknown_books(config: Config, unknown_books: list[Book]):
244
- if not unknown_books_requires_sync():
248
+ if (not unknown_books_requires_sync()) and (not unknown_books):
245
249
  return
246
250
 
247
251
  db_conn = get_duckdb_conn()
248
- db_conn.execute(
249
- "INSERT INTO unknown_book_run_history (timepoint, ran_successfully, details) VALUES (?, ?, ?)",
250
- [config.run_time, True, ""]
251
- )
252
252
 
253
- db_conn.execute(
254
- "DELETE FROM unknown_book"
255
- )
256
- if not unknown_books:
257
- return
253
+ if unknown_books_requires_sync():
254
+ db_conn.execute(
255
+ "INSERT INTO unknown_book_run_history (timepoint, ran_successfully, details) VALUES (?, ?, ?)",
256
+ [config.run_time, True, ""]
257
+ )
258
+
259
+ db_conn.execute(
260
+ "DELETE FROM unknown_book"
261
+ )
262
+ if not unknown_books:
263
+ return
258
264
 
259
265
  df = pd.DataFrame([book.unknown_book_dict() for book in unknown_books])
260
266
  db_conn = get_duckdb_conn()
tbr_deal_finder/utils.py CHANGED
@@ -1,12 +1,56 @@
1
+ import datetime
2
+ import functools
3
+ import os
1
4
  import re
5
+ import sys
6
+ from pathlib import Path
2
7
  from typing import Optional
3
8
 
4
9
  import click
5
10
  import duckdb
6
11
 
7
- from tbr_deal_finder import TBR_DEALS_PATH, QUERY_PATH
12
+ from tbr_deal_finder import QUERY_PATH
8
13
 
9
14
 
15
+ @functools.cache
16
+ def is_gui_env() -> bool:
17
+ return os.environ.get("ENTRYPOINT", "GUI") == "GUI"
18
+
19
+
20
+ @functools.cache
21
+ def get_data_dir() -> Path:
22
+ """
23
+ Get the appropriate user data directory for each platform
24
+ following OS conventions
25
+ """
26
+ app_author = "WillNye"
27
+ app_name = "TBR Deal Finder"
28
+
29
+ if custom_path := os.getenv("TBR_DEAL_FINDER_CUSTOM_PATH"):
30
+ path = Path(custom_path)
31
+
32
+ elif not is_gui_env():
33
+ path = Path.home() / ".tbr_deal_finder"
34
+
35
+ elif sys.platform == "win32":
36
+ # Windows: C:\Users\Username\AppData\Local\AppAuthor\AppName
37
+ base = os.environ.get("LOCALAPPDATA", os.path.expanduser("~\\AppData\\Local"))
38
+ path = Path(base) / app_author / app_name
39
+
40
+ elif sys.platform == "darwin":
41
+ # macOS: ~/Library/Application Support/AppName
42
+ path = Path.home() / "Library" / "Application Support" / app_name
43
+
44
+ else: # Linux and others
45
+ # Linux: ~/.local/share/appname (following XDG spec)
46
+ xdg_data_home = os.environ.get("XDG_DATA_HOME",
47
+ os.path.expanduser("~/.local/share"))
48
+ path = Path(xdg_data_home) / app_name.lower()
49
+
50
+ # Create directory if it doesn't exist
51
+ path.mkdir(parents=True, exist_ok=True)
52
+ return path
53
+
10
54
  def currency_to_float(price_str):
11
55
  """Parse various price formats to float."""
12
56
  if not price_str:
@@ -21,8 +65,13 @@ def currency_to_float(price_str):
21
65
  return 0.0
22
66
 
23
67
 
68
+ def float_to_currency(val: float) -> str:
69
+ from tbr_deal_finder.config import Config
70
+ return f"{Config.currency_symbol()}{val:.2f}"
71
+
72
+
24
73
  def get_duckdb_conn():
25
- return duckdb.connect(TBR_DEALS_PATH.joinpath("tbr_deal_finder.db"))
74
+ return duckdb.connect(get_data_dir().joinpath("tbr_deal_finder.db"))
26
75
 
27
76
 
28
77
  def execute_query(
@@ -37,6 +86,19 @@ def execute_query(
37
86
  return [dict(zip(column_names, row)) for row in rows]
38
87
 
39
88
 
89
+ def get_latest_deal_last_ran(
90
+ db_conn: duckdb.DuckDBPyConnection
91
+ ) -> Optional[datetime.datetime]:
92
+
93
+ results = execute_query(
94
+ db_conn,
95
+ QUERY_PATH.joinpath("latest_deal_last_ran_most_recent_success.sql").read_text(),
96
+ )
97
+ if not results:
98
+ return None
99
+ return results[0]["timepoint"]
100
+
101
+
40
102
  def get_query_by_name(file_name: str) -> str:
41
103
  return QUERY_PATH.joinpath(file_name).read_text()
42
104
 
@@ -0,0 +1,40 @@
1
+ import requests
2
+ from packaging import version
3
+ import warnings
4
+ from tbr_deal_finder import __VERSION__
5
+
6
+ _PACKAGE_NAME = "tbr-deal-finder"
7
+
8
+ def check_for_updates():
9
+ """Check if a newer version is available on PyPI."""
10
+ current_version = __VERSION__
11
+
12
+ try:
13
+ response = requests.get(
14
+ f"https://pypi.org/pypi/{_PACKAGE_NAME}/json",
15
+ timeout=2 # Don't hang if PyPI is slow
16
+ )
17
+ response.raise_for_status()
18
+
19
+ latest_version = response.json()["info"]["version"]
20
+
21
+ if version.parse(latest_version) > version.parse(current_version):
22
+ return latest_version
23
+ return None
24
+
25
+ except Exception:
26
+ # Silently fail - don't break user's code over version check
27
+ return None
28
+
29
+
30
+ def notify_if_outdated():
31
+ """Show a warning if package is outdated."""
32
+ latest = check_for_updates()
33
+ if latest:
34
+ warnings.warn(
35
+ f"A new version of {_PACKAGE_NAME} is available ({latest}). "
36
+ f"You have {__VERSION__}. Consider upgrading:\n"
37
+ f"pip install --upgrade {_PACKAGE_NAME}\nOr if you're running using uv:\ngit checkout main && git pull",
38
+ UserWarning,
39
+ stacklevel=2
40
+ )