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,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
 
@@ -60,10 +60,6 @@ class Book:
60
60
 
61
61
  return int((1 - self.current_price/self.list_price) * 100)
62
62
 
63
- @staticmethod
64
- def price_to_string(price: float) -> str:
65
- return f"{Config.currency_symbol()}{price:.2f}"
66
-
67
63
  @property
68
64
  def deal_id(self) -> str:
69
65
  return f"{self.title}__{self.normalized_authors}__{self.format}__{self.retailer}"
@@ -93,10 +89,10 @@ class Book:
93
89
  self._list_price = round(price, 2)
94
90
 
95
91
  def list_price_string(self):
96
- return self.price_to_string(self.list_price)
92
+ return float_to_currency(self.list_price)
97
93
 
98
94
  def current_price_string(self):
99
- return self.price_to_string(self.current_price)
95
+ return float_to_currency(self.current_price)
100
96
 
101
97
  def __str__(self) -> str:
102
98
  price = self.current_price_string()
@@ -159,7 +155,11 @@ def get_active_deals() -> list[Book]:
159
155
  return [Book(**book) for book in query_response]
160
156
 
161
157
 
162
- 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]):
163
163
  audiobooks = [book for book in books if book.format == BookFormat.AUDIOBOOK]
164
164
  audiobooks = sorted(audiobooks, key=lambda book: book.deal_id)
165
165
 
@@ -171,10 +171,16 @@ def print_books(books: list[Book]):
171
171
  continue
172
172
 
173
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
+
174
177
  echo_info(f"\n\n{init_book.format.value} Deals:")
175
178
 
176
179
  prior_title_id = init_book.title_id
177
180
  for book in books_in_format:
181
+ if not is_qualifying_deal(config, book):
182
+ continue
183
+
178
184
  if prior_title_id != book.title_id:
179
185
  prior_title_id = book.title_id
180
186
  click.echo()
@@ -183,10 +189,12 @@ def print_books(books: list[Book]):
183
189
 
184
190
 
185
191
  def get_full_title_str(title: str, authors: Union[list, str]) -> str:
192
+ title = get_normalized_title(title)
186
193
  return f"{title}__{get_normalized_authors(authors)}"
187
194
 
188
195
 
189
196
  def get_title_id(title: str, authors: Union[list, str], book_format: BookFormat) -> str:
197
+ title = get_normalized_title(title)
190
198
  return f"{title}__{get_normalized_authors(authors)}__{book_format.value}"
191
199
 
192
200
 
tbr_deal_finder/cli.py CHANGED
@@ -18,14 +18,15 @@ 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
 
@@ -195,34 +196,13 @@ def latest_deals():
195
196
  config = _set_config()
196
197
 
197
198
  db_conn = get_duckdb_conn()
198
- results = execute_query(
199
- db_conn,
200
- get_query_by_name("get_active_deals.sql")
201
- )
202
- last_ran = None if not results else results[0]["timepoint"]
199
+ last_ran = get_latest_deal_last_ran(db_conn)
203
200
  min_age = config.run_time - timedelta(hours=8)
204
201
 
205
202
  if not last_ran or last_ran < min_age:
206
- try:
207
- asyncio.run(get_latest_deals(config))
208
- except Exception as e:
209
- ran_successfully = False
210
- details = f"Error getting deals: {e}"
211
- echo_err(details)
212
- else:
213
- ran_successfully = True
214
- details = ""
215
-
216
- # Save execution results
217
- db_conn.execute(
218
- "INSERT INTO latest_deal_run_history (timepoint, ran_successfully, details) VALUES (?, ?, ?)",
219
- [config.run_time, ran_successfully, details]
220
- )
221
-
203
+ ran_successfully = asyncio.run(get_latest_deals(config))
222
204
  if not ran_successfully:
223
- # Gracefully exit on Exception raised by get_latest_deals
224
205
  return
225
-
226
206
  else:
227
207
  echo_info(dedent("""
228
208
  To prevent abuse lastest deals can only be pulled every 8 hours.
@@ -231,7 +211,7 @@ def latest_deals():
231
211
  config.run_time = last_ran
232
212
 
233
213
  if books := get_deals_found_at(config.run_time):
234
- print_books(books)
214
+ print_books(config, books)
235
215
  else:
236
216
  echo_info("No new deals found.")
237
217
 
@@ -239,11 +219,17 @@ def latest_deals():
239
219
  @cli.command()
240
220
  def active_deals():
241
221
  """Get all active deals."""
222
+ try:
223
+ config = Config.load()
224
+ except FileNotFoundError:
225
+ config = _set_config()
226
+
242
227
  if books := get_active_deals():
243
- print_books(books)
228
+ print_books(config, books)
244
229
  else:
245
230
  echo_info("No deals found.")
246
231
 
247
232
 
248
233
  if __name__ == '__main__':
234
+ os.environ.setdefault("ENTRYPOINT", "CLI")
249
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": "$",
@@ -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