tbr-deal-finder 0.2.0__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 (34) hide show
  1. tbr_deal_finder/__init__.py +1 -5
  2. tbr_deal_finder/__main__.py +7 -0
  3. tbr_deal_finder/book.py +28 -8
  4. tbr_deal_finder/cli.py +15 -28
  5. tbr_deal_finder/config.py +3 -3
  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/migrations.py +26 -0
  17. tbr_deal_finder/queries/latest_unknown_book_sync.sql +5 -0
  18. tbr_deal_finder/retailer/amazon.py +60 -9
  19. tbr_deal_finder/retailer/amazon_custom_auth.py +79 -0
  20. tbr_deal_finder/retailer/audible.py +2 -1
  21. tbr_deal_finder/retailer/chirp.py +55 -11
  22. tbr_deal_finder/retailer/kindle.py +68 -44
  23. tbr_deal_finder/retailer/librofm.py +58 -21
  24. tbr_deal_finder/retailer/models.py +31 -1
  25. tbr_deal_finder/retailer_deal.py +62 -21
  26. tbr_deal_finder/tracked_books.py +76 -8
  27. tbr_deal_finder/utils.py +64 -2
  28. tbr_deal_finder/version_check.py +40 -0
  29. {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.1.dist-info}/METADATA +19 -88
  30. tbr_deal_finder-0.3.1.dist-info/RECORD +38 -0
  31. {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.1.dist-info}/entry_points.txt +1 -0
  32. tbr_deal_finder-0.2.0.dist-info/RECORD +0 -24
  33. {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.1.dist-info}/WHEEL +0 -0
  34. {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.1.dist-info}/licenses/LICENSE +0 -0
@@ -1,9 +1,5 @@
1
- import os
2
1
  from pathlib import Path
3
2
 
4
- __VERSION__ = "0.1.0"
3
+ __VERSION__ = "0.3.1"
5
4
 
6
5
  QUERY_PATH = Path(__file__).parent.joinpath("queries")
7
-
8
- TBR_DEALS_PATH = Path.home() / ".tbr_deal_finder"
9
- os.makedirs(TBR_DEALS_PATH, exist_ok=True)
@@ -0,0 +1,7 @@
1
+ """
2
+ Entry point for the TBR Deal Finder package when run in the flet app distro.
3
+ """
4
+ from tbr_deal_finder.gui.main import main
5
+
6
+ if __name__ == "__main__":
7
+ main()
tbr_deal_finder/book.py CHANGED
@@ -8,7 +8,7 @@ from Levenshtein import ratio
8
8
  from unidecode import unidecode
9
9
 
10
10
  from tbr_deal_finder.config import Config
11
- from tbr_deal_finder.utils import get_duckdb_conn, execute_query, get_query_by_name, echo_info
11
+ from tbr_deal_finder.utils import get_duckdb_conn, execute_query, get_query_by_name, echo_info, float_to_currency
12
12
 
13
13
  _AUTHOR_RE = re.compile(r'[^a-zA-Z0-9]')
14
14
 
@@ -55,11 +55,10 @@ class Book:
55
55
  self.format = format
56
56
 
57
57
  def discount(self) -> int:
58
- return int((self.list_price/self.current_price - 1) * 100)
58
+ if not self.current_price:
59
+ return 100
59
60
 
60
- @staticmethod
61
- def price_to_string(price: float) -> str:
62
- return f"{Config.currency_symbol()}{price:.2f}"
61
+ return int((1 - self.current_price/self.list_price) * 100)
63
62
 
64
63
  @property
65
64
  def deal_id(self) -> str:
@@ -90,10 +89,10 @@ class Book:
90
89
  self._list_price = round(price, 2)
91
90
 
92
91
  def list_price_string(self):
93
- return self.price_to_string(self.list_price)
92
+ return float_to_currency(self.list_price)
94
93
 
95
94
  def current_price_string(self):
96
- return self.price_to_string(self.current_price)
95
+ return float_to_currency(self.current_price)
97
96
 
98
97
  def __str__(self) -> str:
99
98
  price = self.current_price_string()
@@ -127,6 +126,15 @@ class Book:
127
126
  "book_id": self.title_id,
128
127
  }
129
128
 
129
+ def unknown_book_dict(self):
130
+ return {
131
+ "retailer": self.retailer,
132
+ "title": self.title,
133
+ "authors": self.authors,
134
+ "format": self.format.value,
135
+ "book_id": self.deal_id,
136
+ }
137
+
130
138
 
131
139
  def get_deals_found_at(timepoint: datetime) -> list[Book]:
132
140
  db_conn = get_duckdb_conn()
@@ -147,7 +155,11 @@ def get_active_deals() -> list[Book]:
147
155
  return [Book(**book) for book in query_response]
148
156
 
149
157
 
150
- def print_books(books: list[Book]):
158
+ def is_qualifying_deal(config: Config, book: Book) -> bool:
159
+ return book.current_price <= config.max_price and book.discount() >= config.min_discount
160
+
161
+
162
+ def print_books(config: Config, books: list[Book]):
151
163
  audiobooks = [book for book in books if book.format == BookFormat.AUDIOBOOK]
152
164
  audiobooks = sorted(audiobooks, key=lambda book: book.deal_id)
153
165
 
@@ -159,10 +171,16 @@ def print_books(books: list[Book]):
159
171
  continue
160
172
 
161
173
  init_book = books_in_format[0]
174
+ if not any(is_qualifying_deal(config, book) for book in books_in_format):
175
+ continue
176
+
162
177
  echo_info(f"\n\n{init_book.format.value} Deals:")
163
178
 
164
179
  prior_title_id = init_book.title_id
165
180
  for book in books_in_format:
181
+ if not is_qualifying_deal(config, book):
182
+ continue
183
+
166
184
  if prior_title_id != book.title_id:
167
185
  prior_title_id = book.title_id
168
186
  click.echo()
@@ -171,10 +189,12 @@ def print_books(books: list[Book]):
171
189
 
172
190
 
173
191
  def get_full_title_str(title: str, authors: Union[list, str]) -> str:
192
+ title = get_normalized_title(title)
174
193
  return f"{title}__{get_normalized_authors(authors)}"
175
194
 
176
195
 
177
196
  def get_title_id(title: str, authors: Union[list, str], book_format: BookFormat) -> str:
197
+ title = get_normalized_title(title)
178
198
  return f"{title}__{get_normalized_authors(authors)}__{book_format.value}"
179
199
 
180
200
 
tbr_deal_finder/cli.py CHANGED
@@ -13,19 +13,20 @@ from tbr_deal_finder.migrations import make_migrations
13
13
  from tbr_deal_finder.book import get_deals_found_at, print_books, get_active_deals
14
14
  from tbr_deal_finder.retailer import RETAILER_MAP
15
15
  from tbr_deal_finder.retailer_deal import get_latest_deals
16
- from tbr_deal_finder.tracked_books import reprocess_incomplete_tbr_books
16
+ from tbr_deal_finder.tracked_books import reprocess_incomplete_tbr_books, clear_unknown_books
17
17
  from tbr_deal_finder.utils import (
18
18
  echo_err,
19
19
  echo_info,
20
20
  echo_success,
21
- execute_query,
22
21
  get_duckdb_conn,
23
- get_query_by_name
22
+ get_latest_deal_last_ran
24
23
  )
24
+ from tbr_deal_finder.version_check import notify_if_outdated
25
25
 
26
26
 
27
27
  @click.group()
28
28
  def cli():
29
+ notify_if_outdated()
29
30
  make_migrations()
30
31
 
31
32
 
@@ -183,6 +184,7 @@ def setup():
183
184
  # Retailers may have changed causing some books to need reprocessing
184
185
  config = Config.load()
185
186
  reprocess_incomplete_tbr_books(config)
187
+ clear_unknown_books()
186
188
 
187
189
 
188
190
  @cli.command()
@@ -194,34 +196,13 @@ def latest_deals():
194
196
  config = _set_config()
195
197
 
196
198
  db_conn = get_duckdb_conn()
197
- results = execute_query(
198
- db_conn,
199
- get_query_by_name("get_active_deals.sql")
200
- )
201
- last_ran = None if not results else results[0]["timepoint"]
199
+ last_ran = get_latest_deal_last_ran(db_conn)
202
200
  min_age = config.run_time - timedelta(hours=8)
203
201
 
204
202
  if not last_ran or last_ran < min_age:
205
- try:
206
- asyncio.run(get_latest_deals(config))
207
- except Exception as e:
208
- ran_successfully = False
209
- details = f"Error getting deals: {e}"
210
- echo_err(details)
211
- else:
212
- ran_successfully = True
213
- details = ""
214
-
215
- # Save execution results
216
- db_conn.execute(
217
- "INSERT INTO latest_deal_run_history (timepoint, ran_successfully, details) VALUES (?, ?, ?)",
218
- [config.run_time, ran_successfully, details]
219
- )
220
-
203
+ ran_successfully = asyncio.run(get_latest_deals(config))
221
204
  if not ran_successfully:
222
- # Gracefully exit on Exception raised by get_latest_deals
223
205
  return
224
-
225
206
  else:
226
207
  echo_info(dedent("""
227
208
  To prevent abuse lastest deals can only be pulled every 8 hours.
@@ -230,7 +211,7 @@ def latest_deals():
230
211
  config.run_time = last_ran
231
212
 
232
213
  if books := get_deals_found_at(config.run_time):
233
- print_books(books)
214
+ print_books(config, books)
234
215
  else:
235
216
  echo_info("No new deals found.")
236
217
 
@@ -238,11 +219,17 @@ def latest_deals():
238
219
  @cli.command()
239
220
  def active_deals():
240
221
  """Get all active deals."""
222
+ try:
223
+ config = Config.load()
224
+ except FileNotFoundError:
225
+ config = _set_config()
226
+
241
227
  if books := get_active_deals():
242
- print_books(books)
228
+ print_books(config, books)
243
229
  else:
244
230
  echo_info("No deals found.")
245
231
 
246
232
 
247
233
  if __name__ == '__main__':
234
+ os.environ.setdefault("ENTRYPOINT", "CLI")
248
235
  cli()
tbr_deal_finder/config.py CHANGED
@@ -3,9 +3,9 @@ from dataclasses import dataclass
3
3
  from datetime import datetime
4
4
  from typing import Union
5
5
 
6
- from tbr_deal_finder import TBR_DEALS_PATH
6
+ from tbr_deal_finder.utils import get_data_dir
7
7
 
8
- _CONFIG_PATH = TBR_DEALS_PATH.joinpath("config.ini")
8
+ _CONFIG_PATH = get_data_dir().joinpath("config.ini")
9
9
 
10
10
  _LOCALE_CURRENCY_MAP = {
11
11
  "us": "$",
@@ -26,7 +26,7 @@ class Config:
26
26
  library_export_paths: list[str]
27
27
  tracked_retailers: list[str]
28
28
  max_price: float = 8.0
29
- min_discount: int = 35
29
+ min_discount: int = 30
30
30
  run_time: datetime = datetime.now()
31
31
 
32
32
  locale: str = "us" # This will be set as a class attribute below
@@ -0,0 +1,147 @@
1
+ """
2
+ Desktop application update checker and handler.
3
+ For packaged desktop applications (.dmg/.exe).
4
+ """
5
+ import json
6
+ import logging
7
+ import os
8
+ import platform
9
+ import subprocess
10
+ import tempfile
11
+ import webbrowser
12
+ from pathlib import Path
13
+ from typing import Optional, Dict, Any
14
+
15
+ import requests
16
+ from packaging import version
17
+
18
+ from tbr_deal_finder import __VERSION__
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ class DesktopUpdater:
23
+ """Handle updates for packaged desktop applications."""
24
+
25
+ def __init__(self, github_repo: str = "WillNye/tbr-deal-finder"):
26
+ self.github_repo = github_repo
27
+ self.current_version = __VERSION__
28
+ self.platform = platform.system().lower()
29
+
30
+ def check_for_updates(self) -> Optional[Dict[str, Any]]:
31
+ """
32
+ Check GitHub releases for newer versions.
33
+ Returns dict with update info or None if no update available.
34
+ """
35
+ try:
36
+ # Check GitHub releases API
37
+ response = requests.get(
38
+ f"https://api.github.com/repos/{self.github_repo}/releases/latest",
39
+ timeout=5
40
+ )
41
+ response.raise_for_status()
42
+
43
+ release_data = response.json()
44
+ latest_version = release_data["tag_name"].lstrip("v")
45
+ if version.parse(latest_version) > version.parse(self.current_version):
46
+ release_url = release_data["html_url"]
47
+ return {
48
+ "version": latest_version,
49
+ "download_url": release_url,
50
+ "release_notes": release_data.get("body", ""),
51
+ "release_url": release_url
52
+ }
53
+
54
+ return None
55
+
56
+ except Exception as e:
57
+ logger.error(f"Failed to check updates for {self.github_repo}: {e}")
58
+ return None
59
+
60
+ def download_update(self, download_url: str, progress_callback=None) -> Optional[Path]:
61
+ """
62
+ Download the update file.
63
+ Returns path to downloaded file or None if failed.
64
+ """
65
+ try:
66
+ response = requests.get(download_url, stream=True, timeout=30)
67
+ response.raise_for_status()
68
+
69
+ # Determine file extension
70
+ filename = download_url.split("/")[-1]
71
+ temp_file = Path(tempfile.gettempdir()) / filename
72
+
73
+ total_size = int(response.headers.get('content-length', 0))
74
+ downloaded = 0
75
+
76
+ with open(temp_file, 'wb') as f:
77
+ for chunk in response.iter_content(chunk_size=8192):
78
+ if chunk:
79
+ f.write(chunk)
80
+ downloaded += len(chunk)
81
+
82
+ if progress_callback and total_size > 0:
83
+ progress = (downloaded / total_size) * 100
84
+ progress_callback(progress)
85
+
86
+ return temp_file
87
+
88
+ except Exception as e:
89
+ logger.error(f"Failed to download update: {e}")
90
+ return None
91
+
92
+ def install_update(self, update_file: Path) -> bool:
93
+ """
94
+ Install the downloaded update.
95
+ Platform-specific installation logic.
96
+ """
97
+ if self.platform == "darwin":
98
+ return self._install_macos_update(update_file)
99
+ elif self.platform == "windows":
100
+ return self._install_windows_update(update_file)
101
+ elif self.platform == "linux":
102
+ return self._install_linux_update(update_file)
103
+ else:
104
+ return False
105
+
106
+ def _install_macos_update(self, dmg_file: Path) -> bool:
107
+ """Install .dmg update on macOS."""
108
+ try:
109
+ # Open the DMG file - user will need to drag to Applications
110
+ subprocess.run(["open", str(dmg_file)], check=True)
111
+ return True
112
+ except subprocess.CalledProcessError:
113
+ return False
114
+
115
+ def _install_windows_update(self, exe_file: Path) -> bool:
116
+ """Install .exe update on Windows."""
117
+ try:
118
+ # Run the installer
119
+ subprocess.run([str(exe_file)], check=True)
120
+ return True
121
+ except subprocess.CalledProcessError:
122
+ return False
123
+
124
+ def _install_linux_update(self, appimage_file: Path) -> bool:
125
+ """Install AppImage update on Linux."""
126
+ try:
127
+ # Make executable and offer to replace current installation
128
+ os.chmod(appimage_file, 0o755)
129
+
130
+ # For AppImage, we'd typically replace the current file
131
+ # This is more complex and might require user permission
132
+ return True
133
+ except Exception:
134
+ return False
135
+
136
+ def open_download_page(self, release_url: str):
137
+ """Open the GitHub release page in browser."""
138
+ webbrowser.open(release_url)
139
+
140
+
141
+ # Global instance
142
+ desktop_updater = DesktopUpdater()
143
+
144
+
145
+ def check_for_desktop_updates() -> Optional[Dict[str, Any]]:
146
+ """Convenience function to check for updates."""
147
+ return desktop_updater.check_for_updates()
File without changes