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.
@@ -0,0 +1,9 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ __VERSION__ = "0.1.0"
5
+
6
+ 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,116 @@
1
+ import dataclasses
2
+ import re
3
+ from datetime import datetime
4
+ from enum import Enum
5
+ from typing import Optional, Union
6
+
7
+ import click
8
+ from unidecode import unidecode
9
+
10
+ from tbr_deal_finder.config import Config
11
+ from tbr_deal_finder.utils import get_duckdb_conn, execute_query, get_query_by_name
12
+
13
+ _AUTHOR_RE = re.compile(r'[^a-zA-Z0-9]')
14
+
15
+ class BookFormat(Enum):
16
+ AUDIOBOOK = "Audiobook"
17
+ NA = "N/A" # When format does not matter
18
+
19
+
20
+ @dataclasses.dataclass
21
+ class Book:
22
+ retailer: str
23
+ title: str
24
+ authors: str
25
+ list_price: float
26
+ current_price: float
27
+ timepoint: datetime
28
+ format: Union[BookFormat, str]
29
+ audiobook_isbn: str = None
30
+
31
+ deleted: bool = False
32
+
33
+ deal_id: Optional[str] = None
34
+ exists: bool = True
35
+ normalized_authors: list[str] = None
36
+
37
+ def __post_init__(self):
38
+ if not self.deal_id:
39
+ self.deal_id = f"{self.title}__{self.normalized_authors}__{self.retailer}__{self.format}"
40
+
41
+ if isinstance(self.format, str):
42
+ self.format = BookFormat(self.format)
43
+
44
+ self.current_price = round(self.current_price, 2)
45
+ self.list_price = round(self.list_price, 2)
46
+ self.normalized_authors = get_normalized_authors(self.authors)
47
+
48
+ def discount(self) -> int:
49
+ return int((self.list_price/self.current_price - 1) * 100)
50
+
51
+ @staticmethod
52
+ def price_to_string(price: float) -> str:
53
+ return f"{Config.currency_symbol()}{price:.2f}"
54
+
55
+ @property
56
+ def title_id(self) -> str:
57
+ return f"{self.title}__{self.normalized_authors}__{self.format}"
58
+
59
+ def list_price_string(self):
60
+ return self.price_to_string(self.list_price)
61
+
62
+ def current_price_string(self):
63
+ return self.price_to_string(self.current_price)
64
+
65
+ def __str__(self) -> str:
66
+ price = self.current_price_string()
67
+ book_format = self.format.value
68
+ title = self.title
69
+ if len(self.title) > 75:
70
+ title = f"{title[:75]}..."
71
+ return f"{title} by {self.authors} - {price} - {self.discount()}% Off at {self.retailer} - {book_format}"
72
+
73
+ def dict(self):
74
+ response = dataclasses.asdict(self)
75
+ response["format"] = self.format.value
76
+ del response["audiobook_isbn"]
77
+ del response["exists"]
78
+ del response["normalized_authors"]
79
+
80
+ return response
81
+
82
+
83
+ def get_deals_found_at(timepoint: datetime) -> list[Book]:
84
+ db_conn = get_duckdb_conn()
85
+ query_response = execute_query(
86
+ db_conn,
87
+ get_query_by_name("get_deals_found_at.sql"),
88
+ {"timepoint": timepoint}
89
+ )
90
+ return [Book(**book) for book in query_response]
91
+
92
+
93
+ def get_active_deals() -> list[Book]:
94
+ db_conn = get_duckdb_conn()
95
+ query_response = execute_query(
96
+ db_conn,
97
+ get_query_by_name("get_active_deals.sql")
98
+ )
99
+ return [Book(**book) for book in query_response]
100
+
101
+
102
+ def print_books(books: list[Book]):
103
+ prior_title_id = books[0].title_id
104
+ for book in books:
105
+ if prior_title_id != book.title_id:
106
+ prior_title_id = book.title_id
107
+ click.echo()
108
+
109
+ click.echo(str(book))
110
+
111
+
112
+ def get_normalized_authors(authors: Union[str, list[str]]) -> list[str]:
113
+ if isinstance(authors, str):
114
+ authors = [i for i in authors.split(",")]
115
+
116
+ return sorted([_AUTHOR_RE.sub('', unidecode(author)).lower() for author in authors])
@@ -0,0 +1,104 @@
1
+ import configparser
2
+ from dataclasses import dataclass
3
+ from datetime import datetime
4
+ from typing import Union
5
+
6
+ from tbr_deal_finder import TBR_DEALS_PATH
7
+
8
+ _CONFIG_PATH = TBR_DEALS_PATH.joinpath("config.ini")
9
+
10
+ _LOCALE_CURRENCY_MAP = {
11
+ "us": "$",
12
+ "ca": "$",
13
+ "au": "$",
14
+ "uk": "£",
15
+ "fr": "€",
16
+ "de": "€",
17
+ "es": "€",
18
+ "it": "€",
19
+ "jp": "¥",
20
+ "in": "₹",
21
+ "br": "R$",
22
+ }
23
+
24
+ @dataclass
25
+ class Config:
26
+ library_export_paths: list[str]
27
+ tracked_retailers: list[str]
28
+ max_price: float = 8.0
29
+ min_discount: int = 35
30
+ run_time: datetime = datetime.now()
31
+
32
+ locale: str = "us" # This will be set as a class attribute below
33
+
34
+ def __post_init__(self):
35
+ if isinstance(self.library_export_paths, str):
36
+ self.set_library_export_paths(
37
+ self.library_export_paths.split(",")
38
+ )
39
+
40
+ if isinstance(self.tracked_retailers, str):
41
+ self.set_tracked_retailers(
42
+ self.tracked_retailers.split(",")
43
+ )
44
+
45
+ @classmethod
46
+ def currency_symbol(cls) -> str:
47
+ return _LOCALE_CURRENCY_MAP.get(cls.locale, "$")
48
+
49
+ @classmethod
50
+ def set_locale(cls, code: str):
51
+ cls.locale = code
52
+
53
+ @classmethod
54
+ def load(cls) -> "Config":
55
+ """Load configuration from file or return defaults."""
56
+ if not _CONFIG_PATH.exists():
57
+ raise FileNotFoundError(f"Config file not found at {_CONFIG_PATH}")
58
+
59
+ parser = configparser.ConfigParser()
60
+ parser.read(_CONFIG_PATH)
61
+ export_paths_str = parser.get('DEFAULT', 'library_export_paths')
62
+ tracked_retailers_str = parser.get('DEFAULT', 'tracked_retailers')
63
+ locale = parser.get('DEFAULT', 'locale', fallback="us")
64
+ cls.set_locale(locale)
65
+ return cls(
66
+ max_price=parser.getfloat('DEFAULT', 'max_price', fallback=8.0),
67
+ min_discount=parser.getint('DEFAULT', 'min_discount', fallback=35),
68
+ library_export_paths=[i.strip() for i in export_paths_str.split(",")],
69
+ tracked_retailers=[i.strip() for i in tracked_retailers_str.split(",")]
70
+ )
71
+
72
+ @property
73
+ def library_export_paths_str(self) -> str:
74
+ return ", ".join(self.library_export_paths)
75
+
76
+ @property
77
+ def tracked_retailers_str(self) -> str:
78
+ return ", ".join(self.tracked_retailers)
79
+
80
+ def set_library_export_paths(self, library_export_paths: Union[str, list[str]]):
81
+ if isinstance(library_export_paths, str):
82
+ self.library_export_paths = [i.strip() for i in library_export_paths.split(",")]
83
+ else:
84
+ self.library_export_paths = library_export_paths
85
+
86
+ def set_tracked_retailers(self, tracked_retailers: Union[str, list[str]]):
87
+ if isinstance(tracked_retailers, str):
88
+ self.tracked_retailers = [i.strip() for i in tracked_retailers.split(",")]
89
+ else:
90
+ self.tracked_retailers = tracked_retailers
91
+
92
+ def save(self):
93
+ """Save configuration to file."""
94
+ parser = configparser.ConfigParser()
95
+ parser['DEFAULT'] = {
96
+ 'max_price': str(self.max_price),
97
+ 'min_discount': str(self.min_discount),
98
+ 'locale': type(self).locale,
99
+ 'library_export_paths': self.library_export_paths_str,
100
+ 'tracked_retailers': self.tracked_retailers_str
101
+ }
102
+
103
+ with open(_CONFIG_PATH, 'w') as f:
104
+ parser.write(f)
@@ -0,0 +1,155 @@
1
+ import asyncio
2
+ import csv
3
+ import shutil
4
+ import tempfile
5
+
6
+ from tqdm.asyncio import tqdm_asyncio
7
+
8
+ from tbr_deal_finder.book import Book, BookFormat
9
+ from tbr_deal_finder.config import Config
10
+ from tbr_deal_finder.retailer.librofm import LibroFM
11
+
12
+
13
+ def _get_book_authors(book: dict) -> str:
14
+ if authors := book.get('Authors'):
15
+ return authors
16
+
17
+ authors = book['Author']
18
+ if additional_authors := book.get("Additional Authors"):
19
+ authors = f"{authors}, {additional_authors}"
20
+
21
+ return authors
22
+
23
+
24
+ def _get_book_title(book: dict) -> str:
25
+ title = book['Title']
26
+ return title.split("(")[0].strip()
27
+
28
+
29
+ def _is_tbr_book(book: dict) -> bool:
30
+ if "Read Status" in book:
31
+ return book["Read Status"] == "to-read"
32
+ elif "Bookshelves" in book:
33
+ return "to-read" in book["Bookshelves"]
34
+ else:
35
+ return True
36
+
37
+
38
+ def get_tbr_books(config: Config) -> list[Book]:
39
+ tbr_book_map: dict[str: Book] = {}
40
+ for library_export_path in config.library_export_paths:
41
+
42
+ with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
43
+ # Use csv.DictReader to get dictionaries with column headers
44
+ for book_dict in csv.DictReader(file):
45
+ if not _is_tbr_book(book_dict):
46
+ continue
47
+
48
+ title = _get_book_title(book_dict)
49
+ authors = _get_book_authors(book_dict)
50
+ key = f'{title}__{authors}'
51
+
52
+ if key in tbr_book_map:
53
+ continue
54
+
55
+ tbr_book_map[key] = Book(
56
+ retailer="N/A",
57
+ title=title,
58
+ authors=authors,
59
+ list_price=0,
60
+ current_price=0,
61
+ timepoint=config.run_time,
62
+ format=BookFormat.NA,
63
+ audiobook_isbn=book_dict["audiobook_isbn"],
64
+ )
65
+ return list(tbr_book_map.values())
66
+
67
+
68
+ async def maybe_set_library_export_audiobook_isbn(config: Config):
69
+ """To get the price from Libro.fm for a book you need its ISBN
70
+
71
+ As opposed to trying to get that every time latest-deals is run
72
+ we're just updating the export csv once to include the ISBN.
73
+ """
74
+
75
+ if "Libro.FM" not in config.tracked_retailers:
76
+ return
77
+
78
+ books_requiring_check_map = dict()
79
+ book_to_isbn_map = dict()
80
+
81
+
82
+ for library_export_path in config.library_export_paths:
83
+ with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
84
+ # Use csv.DictReader to get dictionaries with column headers
85
+ for book_dict in csv.DictReader(file):
86
+ if not _is_tbr_book(book_dict):
87
+ continue
88
+
89
+ title = _get_book_title(book_dict)
90
+ authors = _get_book_authors(book_dict)
91
+ key = f'{title}__{authors}'
92
+
93
+ if "audiobook_isbn" in book_dict:
94
+ book_to_isbn_map[key] = book_dict["audiobook_isbn"]
95
+ books_requiring_check_map.pop(key, None)
96
+ elif key not in book_to_isbn_map:
97
+ books_requiring_check_map[key] = Book(
98
+ retailer="N/A",
99
+ title=title,
100
+ authors=authors,
101
+ list_price=0,
102
+ current_price=0,
103
+ timepoint=config.run_time,
104
+ format=BookFormat.NA
105
+ )
106
+
107
+ if not books_requiring_check_map:
108
+ return
109
+
110
+ libro_fm = LibroFM()
111
+ # Setting it lower to be a good user of libro on their more expensive search call
112
+ semaphore = asyncio.Semaphore(3)
113
+
114
+ # Set the audiobook isbn for Book instances in books_requiring_check_map
115
+ await tqdm_asyncio.gather(
116
+ *[
117
+ libro_fm.get_book_isbn(book, semaphore) for book in books_requiring_check_map.values()
118
+ ],
119
+ desc="Getting required audiobook ISBN info"
120
+ )
121
+
122
+ # Go back and now add the audiobook_isbn
123
+ for library_export_path in config.library_export_paths:
124
+ with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
125
+ reader = csv.DictReader(file)
126
+ field_names = list(reader.fieldnames) + ["audiobook_isbn"]
127
+ file_content = [book_dict for book_dict in reader]
128
+ if not file_content or "audiobook_isbn" in file_content[0]:
129
+ continue
130
+
131
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, newline='') as temp_file:
132
+ temp_filename = temp_file.name
133
+ writer = csv.DictWriter(temp_file, fieldnames=field_names)
134
+ writer.writeheader()
135
+
136
+ for book_dict in file_content:
137
+ if _is_tbr_book(book_dict):
138
+ title = _get_book_title(book_dict)
139
+ authors = _get_book_authors(book_dict)
140
+ key = f'{title}__{authors}'
141
+
142
+ if key in book_to_isbn_map:
143
+ audiobook_isbn = book_to_isbn_map[key]
144
+ else:
145
+ book = books_requiring_check_map[key]
146
+ audiobook_isbn = book.audiobook_isbn
147
+
148
+ book_dict["audiobook_isbn"] = audiobook_isbn
149
+ else:
150
+ book_dict["audiobook_isbn"] = ""
151
+
152
+ writer.writerow(book_dict)
153
+
154
+ shutil.move(temp_filename, library_export_path)
155
+
@@ -0,0 +1,223 @@
1
+ import asyncio
2
+ import os
3
+ from datetime import timedelta
4
+ from textwrap import dedent
5
+ from typing import Union
6
+
7
+ import click
8
+ import questionary
9
+
10
+ from tbr_deal_finder.config import Config
11
+ from tbr_deal_finder.library_exports import maybe_set_library_export_audiobook_isbn
12
+ from tbr_deal_finder.migrations import make_migrations
13
+ from tbr_deal_finder.book import get_deals_found_at, print_books, get_active_deals
14
+ from tbr_deal_finder.retailer import RETAILER_MAP
15
+ from tbr_deal_finder.retailer_deal import get_latest_deals
16
+ from tbr_deal_finder.utils import get_duckdb_conn, get_query_by_name, execute_query
17
+
18
+
19
+ @click.group()
20
+ def cli():
21
+ make_migrations()
22
+
23
+ # Check that the config exists for all commands ran
24
+ try:
25
+ Config.load()
26
+ except FileNotFoundError:
27
+ _set_config()
28
+
29
+
30
+ def _add_path(existing_paths: list[str]) -> Union[str, None]:
31
+ try:
32
+ new_path = os.path.expanduser(click.prompt("What is the new path"))
33
+ if new_path in existing_paths:
34
+ click.echo(f"{new_path} is already being tracked.\n")
35
+ return None
36
+ elif os.path.exists(new_path):
37
+ return new_path
38
+ else:
39
+ click.echo(f"Could not find {new_path}. Please try again.\n")
40
+ return _add_path(existing_paths)
41
+ except (KeyError, KeyboardInterrupt, TypeError):
42
+ return None
43
+
44
+
45
+ def _remove_path(existing_paths: list[str]) -> Union[str, None]:
46
+ try:
47
+ return questionary.select(
48
+ "Which path would you like to remove?",
49
+ choices=existing_paths,
50
+ ).ask()
51
+ except (KeyError, KeyboardInterrupt, TypeError):
52
+ return None
53
+
54
+
55
+ def _set_library_export_paths(config: Config):
56
+ """
57
+ Interactively set the paths to the user's library export files.
58
+
59
+ Allows the user to add or remove paths to their StoryGraph, Goodreads, or custom CSV export files.
60
+ Ensures that only valid, unique paths are added. Updates the config in-place.
61
+ """
62
+ while True:
63
+ if config.library_export_paths:
64
+ if len(config.library_export_paths) > 1:
65
+ choices = ["Add new path", "Remove path", "Done"]
66
+ else:
67
+ choices = ["Add new path", "Done"]
68
+
69
+ try:
70
+ user_selection = questionary.select(
71
+ "What change would you like to make to your library export paths",
72
+ choices=choices,
73
+ ).ask()
74
+ except (KeyError, KeyboardInterrupt, TypeError):
75
+ return
76
+ else:
77
+ click.echo("Add your library export path.")
78
+ user_selection = "Add new path"
79
+
80
+ if user_selection == "Done":
81
+ return
82
+ elif user_selection == "Add new path":
83
+ if new_path := _add_path(config.library_export_paths):
84
+ config.library_export_paths.append(new_path)
85
+ else:
86
+ if remove_path := _remove_path(config.library_export_paths):
87
+ config.library_export_paths.remove(remove_path)
88
+
89
+
90
+ def _set_locale(config: Config):
91
+ locale_options = {
92
+ "US and all other countries not listed": "us",
93
+ "Canada": "ca",
94
+ "UK and Ireland": "uk",
95
+ "Australia and New Zealand": "au",
96
+ "France, Belgium, Switzerland": "fr",
97
+ "Germany, Austria, Switzerland": "de",
98
+ "Japan": "jp",
99
+ "Italy": "it",
100
+ "India": "in",
101
+ "Spain": "es",
102
+ "Brazil": "br"
103
+ }
104
+ default_locale = [k for k,v in locale_options.items() if v == config.locale][0]
105
+
106
+ try:
107
+ user_selection = questionary.select(
108
+ "What change would you like to make to your library export paths",
109
+ choices=list(locale_options.keys()),
110
+ default=default_locale
111
+ ).ask()
112
+ except (KeyError, KeyboardInterrupt, TypeError):
113
+ return
114
+
115
+ config.set_locale(locale_options[user_selection])
116
+
117
+
118
+ def _set_tracked_retailers(config: Config):
119
+ config.set_tracked_retailers(
120
+ questionary.checkbox(
121
+ "Select the retailers you want to check deals for. "
122
+ "Tip: Chirp doesn't have a subscription and can have good deals. I'd recommend checking it.",
123
+ choices=[
124
+ questionary.Choice(retailer, checked=retailer in config.tracked_retailers)
125
+ for retailer in RETAILER_MAP.keys()
126
+ ]).ask()
127
+ )
128
+
129
+
130
+ def _set_config() -> Config:
131
+ try:
132
+ config = Config.load()
133
+ except FileNotFoundError:
134
+ config = Config(library_export_paths=[], tracked_retailers=list(RETAILER_MAP.keys()))
135
+
136
+ # Setting these config values are a little more involved,
137
+ # so they're broken out into their own functions
138
+ _set_library_export_paths(config)
139
+ _set_tracked_retailers(config)
140
+ _set_locale(config)
141
+
142
+ config.max_price = click.prompt(
143
+ "Enter maximum price for deals",
144
+ type=float,
145
+ default=config.max_price
146
+ )
147
+ config.min_discount = click.prompt(
148
+ "Enter minimum discount percentage",
149
+ type=int,
150
+ default=config.min_discount
151
+ )
152
+
153
+ config.save()
154
+ click.echo("Configuration saved!")
155
+
156
+ return config
157
+
158
+
159
+ @cli.command()
160
+ def setup():
161
+ _set_config()
162
+
163
+
164
+ @cli.command()
165
+ def latest_deals():
166
+ """Find book deals from your Library export."""
167
+ config = Config.load()
168
+
169
+ asyncio.run(maybe_set_library_export_audiobook_isbn(config))
170
+
171
+ db_conn = get_duckdb_conn()
172
+ results = execute_query(
173
+ db_conn,
174
+ get_query_by_name("get_active_deals.sql")
175
+ )
176
+ last_ran = None if not results else results[0]["timepoint"]
177
+ min_age = config.run_time - timedelta(hours=8)
178
+
179
+ if not last_ran or last_ran < min_age:
180
+ try:
181
+ asyncio.run(get_latest_deals(config))
182
+ except Exception as e:
183
+ ran_successfully = False
184
+ details = f"Error getting deals: {e}"
185
+ click.echo(details)
186
+ else:
187
+ ran_successfully = True
188
+ details = ""
189
+
190
+ # Save execution results
191
+ db_conn.execute(
192
+ "INSERT INTO latest_deal_run_history (timepoint, ran_successfully, details) VALUES (?, ?, ?)",
193
+ [config.run_time, ran_successfully, details]
194
+ )
195
+
196
+ if not ran_successfully:
197
+ # Gracefully exit on Exception raised by get_latest_deals
198
+ return
199
+
200
+ else:
201
+ click.echo(dedent("""
202
+ To prevent abuse lastest deals can only be pulled every 8 hours.
203
+ Fetching most recent deal results.\n
204
+ """))
205
+ config.run_time = last_ran
206
+
207
+ if books := get_deals_found_at(config.run_time):
208
+ print_books(books)
209
+ else:
210
+ click.echo("No new deals found.")
211
+
212
+
213
+ @cli.command()
214
+ def active_deals():
215
+ """Get all active deals."""
216
+ if books := get_active_deals():
217
+ print_books(books)
218
+ else:
219
+ click.echo("No deals found.")
220
+
221
+
222
+ if __name__ == '__main__':
223
+ cli()