cs2tracker 2.1.13__py3-none-any.whl → 2.1.15__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.

Potentially problematic release.


This version of cs2tracker might be problematic. Click here for more details.

@@ -0,0 +1,61 @@
1
+ from tkinter import ttk
2
+ from typing import cast
3
+
4
+ import matplotlib.pyplot as plt
5
+ from matplotlib.axes import Axes
6
+ from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk
7
+ from matplotlib.dates import DateFormatter
8
+
9
+ from cs2tracker.config import get_config
10
+ from cs2tracker.logs import PriceLogs
11
+ from cs2tracker.scraper.parser import Parser
12
+
13
+ config = get_config()
14
+
15
+
16
+ class PriceHistoryFrame(ttk.Frame):
17
+ # pylint: disable=attribute-defined-outside-init
18
+ def __init__(self, parent):
19
+ """Initialize the price history frame."""
20
+ super().__init__(parent)
21
+
22
+ self._add_widgets()
23
+
24
+ def _add_widgets(self):
25
+ """Add widgets to the frame."""
26
+ self._configure_canvas()
27
+ self.canvas.get_tk_widget().pack(fill="both", expand=True)
28
+
29
+ self.toolbar = NavigationToolbar2Tk(self.canvas, self)
30
+ self.toolbar.update()
31
+ self.toolbar.pack()
32
+
33
+ def _configure_canvas(self):
34
+ """Configure the canvas on which the price history chart is drawn."""
35
+ self._draw_plot()
36
+ plt.close(self.fig)
37
+
38
+ self.canvas = FigureCanvasTkAgg(self.fig, master=self)
39
+ self.canvas.draw()
40
+
41
+ def _draw_plot(self):
42
+ """Draw a chart of the price history."""
43
+
44
+ self.fig, ax_raw = plt.subplots(dpi=100)
45
+ self.fig.autofmt_xdate()
46
+ ax = cast(Axes, ax_raw)
47
+
48
+ dates, totals = PriceLogs.read()
49
+ for price_source in Parser.SOURCES:
50
+ usd_prices = totals[price_source]["USD"]
51
+ converted_prices = totals[price_source][config.conversion_currency]
52
+ ax.plot(dates, usd_prices, label=f"{price_source.name.title()}: USD")
53
+ ax.plot(
54
+ dates,
55
+ converted_prices,
56
+ label=f"{price_source.name.title()}: {config.conversion_currency}",
57
+ )
58
+
59
+ ax.legend(loc="upper left", fontsize="small")
60
+ date_formatter = DateFormatter("%Y-%m-%d")
61
+ ax.xaxis.set_major_formatter(date_formatter)
@@ -4,6 +4,10 @@ from tkinter.filedialog import asksaveasfilename
4
4
 
5
5
  from tksheet import Sheet
6
6
 
7
+ from cs2tracker.scraper.parser import Parser
8
+ from cs2tracker.scraper.scraper import ParsingError, SheetNotFoundError
9
+ from cs2tracker.util.tkinter import centered
10
+
7
11
 
8
12
  class ScraperFrame(ttk.Frame):
9
13
  def __init__(self, parent, scraper, sheet_size, dark_theme):
@@ -43,18 +47,38 @@ class ScraperFrame(ttk.Frame):
43
47
  height=self.sheet_height,
44
48
  width=self.sheet_width,
45
49
  auto_resize_columns=150,
50
+ default_column_width=150,
46
51
  sticky="nsew",
47
52
  )
48
53
  self.sheet.enable_bindings()
54
+
55
+ source_titles = []
56
+ for price_source in Parser.SOURCES:
57
+ source_titles += [
58
+ f"{price_source.name.title()} (USD)",
59
+ f"{price_source.name.title()} Owned (USD)",
60
+ ]
49
61
  self.sheet.insert_row(
50
- ["Item Name", "Item Owned", "Steam Market Price (USD)", "Total Value Owned (USD)"]
62
+ [
63
+ "Item Name",
64
+ "Item Owned",
65
+ ]
66
+ + source_titles
51
67
  )
52
- self.sheet.column_width(0, 220)
53
- self.sheet.column_width(1, 20)
54
68
  self.sheet.align_rows([0], "c")
55
- self.sheet.align_columns([1, 2, 3], "c")
56
- self.sheet.popup_menu_add_command("Save Sheet", self._save_sheet)
57
69
 
70
+ price_columns = list(range(2 * len(Parser.SOURCES)))
71
+ price_columns = [1] + [column_index + 2 for column_index in price_columns]
72
+ self.sheet.align_columns(price_columns, "c")
73
+ self.sheet.column_width(0, 220)
74
+
75
+ required_window_width = 220 + 150 * len(price_columns)
76
+ if int(self.sheet_width) < required_window_width:
77
+ self.parent.geometry(
78
+ centered(self.parent, f"{required_window_width}x{self.sheet_height}")
79
+ )
80
+
81
+ self.sheet.popup_menu_add_command("Save Sheet", self._save_sheet)
58
82
  self.parent.bind("<Configure>", self._readjust_sheet_size_with_window_size)
59
83
 
60
84
  def _save_sheet(self):
@@ -84,10 +108,9 @@ class ScraperFrame(ttk.Frame):
84
108
 
85
109
  self.scraper.scrape_prices(update_sheet_callback)
86
110
 
87
- row_heights = self.sheet.get_row_heights()
88
- last_row_index = len(row_heights) - 1
89
- self.sheet.align_rows(last_row_index, "c")
90
-
91
- if self.scraper.error_stack:
111
+ if self.scraper.error_stack and not isinstance(
112
+ self.scraper.error_stack[-1], SheetNotFoundError
113
+ ):
92
114
  last_error = self.scraper.error_stack[-1]
93
- messagebox.showerror("An Error Occurred", f"{last_error.message}")
115
+ if not isinstance(last_error, ParsingError):
116
+ messagebox.showerror("An Error Occurred", f"{last_error.message}", parent=self)
cs2tracker/config.py ADDED
@@ -0,0 +1,263 @@
1
+ import json
2
+ import re
3
+ from configparser import ConfigParser, ParsingError
4
+ from urllib.parse import quote, unquote
5
+
6
+ from cs2tracker.constants import CAPSULE_PAGES, CONFIG_FILE, INVENTORY_IMPORT_FILE
7
+ from cs2tracker.util.padded_console import get_console
8
+
9
+ STEAM_MARKET_LISTING_BASEURL_CS2 = "https://steamcommunity.com/market/listings/730/"
10
+ STEAM_MARKET_LISTING_REGEX = r"^https://steamcommunity.com/market/listings/\d+/.+$"
11
+
12
+ console = get_console()
13
+
14
+
15
+ class ValidatedConfig(ConfigParser):
16
+ def __init__(self):
17
+ """Initialize the ValidatedConfig class."""
18
+ super().__init__(delimiters=("~"), interpolation=None)
19
+ self.optionxform = str # type: ignore
20
+
21
+ self.valid = False
22
+ self.last_error = None
23
+ try:
24
+ self.load_from_file()
25
+ except (FileNotFoundError, ParsingError) as error:
26
+ console.error(f"Config error: {error}")
27
+ self.last_error = error
28
+
29
+ def delete_display_sections(self):
30
+ """
31
+ Delete all sections that are displayed to the user from the config.
32
+
33
+ (This excludes the internal App Settings section)
34
+ """
35
+ use_proxy = self.getboolean("App Settings", "use_proxy", fallback=False)
36
+ discord_notifications = self.getboolean(
37
+ "App Settings", "discord_notifications", fallback=False
38
+ )
39
+ conversion_currency = self.get("App Settings", "conversion_currency", fallback="EUR")
40
+
41
+ self.clear()
42
+ self.add_section("App Settings")
43
+ self.set("App Settings", "use_proxy", str(use_proxy))
44
+ self.set("App Settings", "discord_notifications", str(discord_notifications))
45
+ self.set("App Settings", "conversion_currency", conversion_currency)
46
+
47
+ def _validate_config_sections(self):
48
+ """Validate that the configuration file has all required sections."""
49
+ if not self.has_section("User Settings"):
50
+ raise ValueError("Missing 'User Settings' section in the configuration file.")
51
+ if not self.has_section("App Settings"):
52
+ raise ValueError("Missing 'App Settings' section in the configuration file.")
53
+ if not self.has_section("Stickers"):
54
+ raise ValueError("Missing 'Stickers' section in the configuration file.")
55
+ if not self.has_section("Cases"):
56
+ raise ValueError("Missing 'Cases' section in the configuration file.")
57
+ if not self.has_section("Skins"):
58
+ raise ValueError("Missing 'Skins' section in the configuration file.")
59
+ for capsule_section in CAPSULE_PAGES:
60
+ if not self.has_section(capsule_section):
61
+ raise ValueError(f"Missing '{capsule_section}' section in the configuration file.")
62
+
63
+ def _validate_config_values(self):
64
+ # pylint: disable=too-many-branches
65
+ """Validate that the configuration file has valid values for all sections."""
66
+ try:
67
+ for section in self.sections():
68
+ if section == "App Settings":
69
+ for option in ("use_proxy", "discord_notifications", "conversion_currency"):
70
+ if not self.has_option(section, option):
71
+ raise ValueError(f"Reason: Missing '{option}' in '{section}' section.")
72
+ if option in ("use_proxy", "discord_notifications") and self.get(
73
+ section, option, fallback=False
74
+ ) not in ("True", "False"):
75
+ raise ValueError(
76
+ f"Reason: Invalid value for '{option}' in '{section}' section."
77
+ )
78
+ elif section == "User Settings":
79
+ for option in ("proxy_api_key", "discord_webhook_url"):
80
+ if not self.has_option(section, option):
81
+ raise ValueError(f"Reason: Missing '{option}' in '{section}' section.")
82
+ else:
83
+ for item_href, item_owned in self.items(section):
84
+ if not re.match(STEAM_MARKET_LISTING_REGEX, item_href):
85
+ raise ValueError("Reason: Invalid Steam market listing URL.")
86
+ if int(item_owned) < 0:
87
+ raise ValueError("Reason: Negative values are not allowed.")
88
+ if int(item_owned) > 1000000:
89
+ raise ValueError("Reason: Value exceeds maximum limit of 1,000,000.")
90
+ except ValueError as error:
91
+ # Re-raise the error if it contains "Reason: " to maintain the original message
92
+ # and raise a ValueError if the conversion of a value to an integer fails.
93
+ if "Reason: " in str(error):
94
+ raise
95
+ raise ValueError("Reason: Invalid value type. All values must be integers.") from error
96
+
97
+ def _validate_config(self):
98
+ """
99
+ Validate the configuration file to ensure all required sections exist with the
100
+ right values.
101
+
102
+ :raises ValueError: If any required section is missing or if any value is
103
+ invalid.
104
+ """
105
+ try:
106
+ self._validate_config_sections()
107
+ self._validate_config_values()
108
+ self.valid = True
109
+ except ValueError as error:
110
+ console.error(f"Config error: {error}")
111
+ self.valid = False
112
+ self.last_error = error
113
+
114
+ def load_from_file(self):
115
+ """Load the configuration file and validate it."""
116
+ self.clear()
117
+ self.read(CONFIG_FILE)
118
+ self._validate_config()
119
+
120
+ def write_to_file(self):
121
+ """Validate the current configuration and write it to the configuration file if
122
+ it is valid.
123
+ """
124
+ self._validate_config()
125
+
126
+ if self.valid:
127
+ with open(CONFIG_FILE, "w", encoding="utf-8") as config_file:
128
+ self.write(config_file)
129
+
130
+ def read_from_inventory_file(self):
131
+ """
132
+ Read an inventory file into the configuration.
133
+
134
+ This file is generated after a user automatically imports their inventory.
135
+ """
136
+ try:
137
+ with open(INVENTORY_IMPORT_FILE, "r", encoding="utf-8") as inventory_file:
138
+ inventory_data = json.load(inventory_file)
139
+ sorted_inventory_data = dict(sorted(inventory_data.items()))
140
+
141
+ added_to_config = set()
142
+ for item_name, item_owned in sorted_inventory_data.items():
143
+ option = self.name_to_option(item_name, href=True)
144
+ for section in self.sections():
145
+ if option in self.options(section):
146
+ self.set(section, option, str(item_owned))
147
+ added_to_config.add(item_name)
148
+
149
+ for item_name, item_owned in sorted_inventory_data.items():
150
+ if item_name not in added_to_config:
151
+ option = self.name_to_option(item_name, href=True)
152
+ if item_name.startswith("Sticker"):
153
+ self.set("Stickers", option, str(item_owned))
154
+ else:
155
+ self.set("Skins", option, str(item_owned))
156
+
157
+ self.write_to_file()
158
+ except (FileNotFoundError, json.JSONDecodeError) as error:
159
+ console.error(f"Error reading inventory file: {error}")
160
+ self.last_error = error
161
+ self.valid = False
162
+
163
+ def option_to_name(self, option, href=False):
164
+ """
165
+ Convert an internal option representation to a reader-friendly name.
166
+
167
+ :param option: The internal option representation to convert.
168
+ :param custom: If True, the option is for a custom item.
169
+ :return: The reader-friendly name.
170
+ """
171
+ if href:
172
+ if not re.match(STEAM_MARKET_LISTING_REGEX, option):
173
+ raise ValueError(f"Invalid Steam market listing URL: {option}")
174
+
175
+ converted_option = unquote(option.split("/")[-1])
176
+ else:
177
+ converted_option = option.replace("_", " ").title()
178
+
179
+ return converted_option
180
+
181
+ def name_to_option(self, name, href=False):
182
+ """
183
+ Convert a reader-friendly name to an internal option representation.
184
+
185
+ :param name: The reader-friendly name to convert.
186
+ :param custom: If True, the name is for a custom item.
187
+ :return: The internal option representation.
188
+ """
189
+ if href:
190
+ converted_name = STEAM_MARKET_LISTING_BASEURL_CS2 + quote(name)
191
+ else:
192
+ converted_name = name.replace(" ", "_").lower()
193
+
194
+ return converted_name
195
+
196
+ def toggle_app_option(self, option, enabled):
197
+ """
198
+ Toggle the use of proxies for requests. This will update the configuration file.
199
+
200
+ :param enabled: If True, proxies will be used; if False, they will not be used.
201
+ """
202
+ self.set("App Settings", option, str(enabled))
203
+ self.write_to_file()
204
+
205
+ console.info(f"{'Enabled' if enabled else 'Disabled'} option: {option}.")
206
+
207
+ def set_app_option(self, option, value):
208
+ """
209
+ Set an option in the App Settings to a specific value.
210
+
211
+ :param option: The option to set.
212
+ :param value: The value to set the option to.
213
+ """
214
+ self.set("App Settings", option, str(value))
215
+ self.write_to_file()
216
+
217
+ console.info(f"Set {option} to {value}.")
218
+
219
+ def option_exists(self, option, exclude_sections=()):
220
+ """
221
+ Check if an option exists in any section of the configuration.
222
+
223
+ :param option: The option to check.
224
+ :param exclude_sections: Sections to exclude from the check.
225
+ :return: True if the option exists, False otherwise.
226
+ """
227
+ for section in [section for section in self.sections() if section not in exclude_sections]:
228
+ if option in self.options(section):
229
+ return True
230
+ return False
231
+
232
+ @property
233
+ def use_proxy(self):
234
+ """Check if the application should use proxies for requests."""
235
+ return self.getboolean("App Settings", "use_proxy", fallback=False)
236
+
237
+ @property
238
+ def discord_notifications(self):
239
+ """Check if the application should send Discord notifications."""
240
+ return self.getboolean("App Settings", "discord_notifications", fallback=False)
241
+
242
+ @property
243
+ def conversion_currency(self):
244
+ """Get the conversion currency for price calculations."""
245
+ return self.get("App Settings", "conversion_currency", fallback="EUR")
246
+
247
+ @property
248
+ def proxy_api_key(self):
249
+ """Get the API key for the proxy service."""
250
+ return self.get("User Settings", "proxy_api_key", fallback="")
251
+
252
+ @property
253
+ def discord_webhook_url(self):
254
+ """Get the Discord webhook URL for notifications."""
255
+ return self.get("User Settings", "discord_webhook_url", fallback="")
256
+
257
+
258
+ config = ValidatedConfig()
259
+
260
+
261
+ def get_config():
262
+ """Accessor function to retrieve the current configuration."""
263
+ return config