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.
- tbr_deal_finder/__init__.py +1 -5
- tbr_deal_finder/__main__.py +7 -0
- tbr_deal_finder/book.py +16 -8
- tbr_deal_finder/cli.py +13 -27
- tbr_deal_finder/config.py +2 -2
- tbr_deal_finder/desktop_updater.py +147 -0
- tbr_deal_finder/gui/__init__.py +0 -0
- tbr_deal_finder/gui/main.py +725 -0
- tbr_deal_finder/gui/pages/__init__.py +1 -0
- tbr_deal_finder/gui/pages/all_books.py +93 -0
- tbr_deal_finder/gui/pages/all_deals.py +63 -0
- tbr_deal_finder/gui/pages/base_book_page.py +291 -0
- tbr_deal_finder/gui/pages/book_details.py +604 -0
- tbr_deal_finder/gui/pages/latest_deals.py +370 -0
- tbr_deal_finder/gui/pages/settings.py +389 -0
- tbr_deal_finder/retailer/amazon.py +58 -7
- tbr_deal_finder/retailer/amazon_custom_auth.py +79 -0
- tbr_deal_finder/retailer/audible.py +2 -1
- tbr_deal_finder/retailer/chirp.py +55 -11
- tbr_deal_finder/retailer/kindle.py +31 -19
- tbr_deal_finder/retailer/librofm.py +53 -20
- tbr_deal_finder/retailer/models.py +31 -1
- tbr_deal_finder/retailer_deal.py +38 -14
- tbr_deal_finder/tracked_books.py +24 -18
- tbr_deal_finder/utils.py +64 -2
- tbr_deal_finder/version_check.py +40 -0
- {tbr_deal_finder-0.2.1.dist-info → tbr_deal_finder-0.3.1.dist-info}/METADATA +18 -87
- tbr_deal_finder-0.3.1.dist-info/RECORD +38 -0
- {tbr_deal_finder-0.2.1.dist-info → tbr_deal_finder-0.3.1.dist-info}/entry_points.txt +1 -0
- tbr_deal_finder-0.2.1.dist-info/RECORD +0 -25
- {tbr_deal_finder-0.2.1.dist-info → tbr_deal_finder-0.3.1.dist-info}/WHEEL +0 -0
- {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
|
-
|
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
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
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
|
-
|
93
|
-
|
94
|
-
|
98
|
+
actions = products[0].get("personalizedActionOutput", {}).get("personalizedActions", [])
|
99
|
+
if not actions:
|
100
|
+
await asyncio.sleep(1)
|
101
|
+
continue
|
95
102
|
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
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
|
-
|
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
|
-
|
61
|
-
|
62
|
-
|
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=
|
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
|
-
|
125
|
-
|
126
|
-
|
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
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
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
|
-
|
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
|
tbr_deal_finder/retailer_deal.py
CHANGED
@@ -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,
|
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(
|
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
|
-
|
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
|
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
|
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
|
tbr_deal_finder/tracked_books.py
CHANGED
@@ -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
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
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
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
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
|
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(
|
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
|
+
)
|