diff --git a/.gitignore b/.gitignore index 1df2f666..923be36e 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ EDMarketConnector.wxs wix/components.wxs edmarketconnector.xml edmarketconnector-beta.xml +master_plugin_list.json # Ignore Visual Elements Manifest file for Windows EDMarketConnector.VisualElementsManifest.xml @@ -59,4 +60,4 @@ pylint.txt # Ignore Submodule data directory coriolis-data/ -FDevIDs/ \ No newline at end of file +FDevIDs/ diff --git a/EDMarketConnector.py b/EDMarketConnector.py index 6022b277..c1bf4b58 100644 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -67,7 +67,6 @@ from config import appversion, appversion_nobuild, config, copyright from EDMCLogging import edmclogger, logger, logging from journal_lock import JournalLock, JournalLockResult -from update import check_for_fdev_updates, check_for_datafile_updates from common_utils import log_locale, SERVER_RETRY if __name__ == '__main__': # noqa: C901 @@ -445,6 +444,34 @@ from theme import theme from ttkHyperlinkLabel import HyperlinkLabel, SHIPYARD_HTML_TEMPLATE +def _config_plugins(frame: tk.Frame, ui_row: int) -> tk.Frame: + for idx, plugin in enumerate(plug.PLUGINS, start=1): + plugin_frame = tk.Frame(frame, name=f"plugin_{idx}") + appitem = plugin.get_app(plugin_frame) + if not appitem: + plugin_frame.destroy() + continue + + # Per plugin separator + plugin_sep = tk.Frame(frame, highlightthickness=1, name=f"plugin_hr_{idx}") + plugin_sep.grid(row=ui_row, columnspan=2, sticky=tk.EW) + ui_row += 1 + + # Plugin frame + plugin_frame.grid(row=ui_row, columnspan=2, sticky=tk.NSEW) + plugin_frame.columnconfigure(1, weight=1) + ui_row += 1 + + # Add plugin widgets + if isinstance(appitem, tuple) and len(appitem) == 2: + appitem[0].grid(row=ui_row - 1, column=0, sticky=tk.W) + appitem[1].grid(row=ui_row - 1, column=1, sticky=tk.EW) + else: + appitem.grid(row=ui_row - 1, column=0, columnspan=2, sticky=tk.EW) + + return frame + + class AppWindow: """Define the main application window.""" @@ -456,13 +483,13 @@ class AppWindow: PADX = 5 - def __init__(self, master: tk.Tk): # noqa: C901, CCR001 # TODO - can possibly factor something out + def __init__(self, master: tk.Tk): # noqa: CCR001 TODO - can possibly factor something out self.early_journal_events: list[MutableMapping[str, Any]] = [] - - self.capi_query_holdoff_time = config.get_int('querytime', default=0) + companion.capi_query_cooldown - self.capi_fleetcarrier_query_holdoff_time = config.get_int('fleetcarrierquerytime', default=0) \ - + companion.capi_fleetcarrier_query_cooldown + query_time = config.get_int('querytime', default=0) + fleetcarrier_time = config.get_int('fleetcarrierquerytime', default=0) + self.capi_query_holdoff_time = query_time + companion.capi_query_cooldown + self.capi_fleetcarrier_query_holdoff_time = fleetcarrier_time + companion.capi_fleetcarrier_query_cooldown self.w = master self.w.title(applongname) @@ -505,9 +532,9 @@ class AppWindow: # TODO: Export to files and merge from them in future ? self.theme_icon = tk.PhotoImage( data='R0lGODlhFAAQAMZQAAoKCQoKCgsKCQwKCQsLCgwLCg4LCQ4LCg0MCg8MCRAMCRANChINCREOChIOChQPChgQChgRCxwTCyYVCSoXCS0YCTkdCTseCT0fCTsjDU0jB0EnDU8lB1ElB1MnCFIoCFMoCEkrDlkqCFwrCGEuCWIuCGQvCFs0D1w1D2wyCG0yCF82D182EHE0CHM0CHQ1CGQ5EHU2CHc3CHs4CH45CIA6CIE7CJdECIdLEolMEohQE5BQE41SFJBTE5lUE5pVE5RXFKNaFKVbFLVjFbZkFrxnFr9oFsNqFsVrF8RsFshtF89xF9NzGNh1GNl2GP+KG////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////yH5BAEKAH8ALAAAAAAUABAAAAeegAGCgiGDhoeIRDiIjIZGKzmNiAQBQxkRTU6am0tPCJSGShuSAUcLoIIbRYMFra4FAUgQAQCGJz6CDQ67vAFJJBi0hjBBD0w9PMnJOkAiJhaIKEI7HRoc19ceNAolwbWDLD8uAQnl5ga1I9CHEjEBAvDxAoMtFIYCBy+kFDKHAgM3ZtgYSLAGgwkp3pEyBOJCC2ELB31QATGioAoVAwEAOw==') # noqa: E501 - self.theme_minimize = tk.BitmapImage( + self.img_theme_minimize = tk.BitmapImage( data='#define im_width 16\n#define im_height 16\nstatic unsigned char im_bits[] = {\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x3f,\n 0xfc, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };\n') # noqa: E501 - self.theme_close = tk.BitmapImage( + self.img_theme_close = tk.BitmapImage( data='#define im_width 16\n#define im_height 16\nstatic unsigned char im_bits[] = {\n 0x00, 0x00, 0x00, 0x00, 0x0c, 0x30, 0x1c, 0x38, 0x38, 0x1c, 0x70, 0x0e,\n 0xe0, 0x07, 0xc0, 0x03, 0xc0, 0x03, 0xe0, 0x07, 0x70, 0x0e, 0x38, 0x1c,\n 0x1c, 0x38, 0x0c, 0x30, 0x00, 0x00, 0x00, 0x00 };\n') # noqa: E501 frame = tk.Frame(self.w, name=appname.lower()) @@ -532,58 +559,27 @@ class AppWindow: ui_row = 1 - self.cmdr_label.grid(row=ui_row, column=0, sticky=tk.W) - self.cmdr.grid(row=ui_row, column=1, sticky=tk.EW) - ui_row += 1 + label_value_pairs = [ + (self.cmdr_label, self.cmdr), + (self.ship_label, self.ship), + ] - self.ship_label.grid(row=ui_row, column=0, sticky=tk.W) - self.ship.grid(row=ui_row, column=1, sticky=tk.EW) - ui_row += 1 - - self.suit_grid_row = ui_row + self.suit_grid_row = ui_row + len(label_value_pairs) self.suit_shown = False - ui_row += 1 + label_value_pairs.append((self.suit_label, self.suit)) - self.system_label.grid(row=ui_row, column=0, sticky=tk.W) - self.system.grid(row=ui_row, column=1, sticky=tk.EW) - ui_row += 1 + label_value_pairs.extend([ + (self.system_label, self.system), + (self.station_label, self.station), + ]) - self.station_label.grid(row=ui_row, column=0, sticky=tk.W) - self.station.grid(row=ui_row, column=1, sticky=tk.EW) - ui_row += 1 + for label, value in label_value_pairs: + label.grid(row=ui_row, column=0, sticky=tk.W) + value.grid(row=ui_row, column=1, sticky=tk.EW) # type: ignore + ui_row += 1 - plugin_no = 0 - for plugin in plug.PLUGINS: - # Per plugin separator - plugin_sep = tk.Frame( - frame, highlightthickness=1, name=f"plugin_hr_{plugin_no + 1}" - ) - # Per plugin frame, for it to use as its parent for own widgets - plugin_frame = tk.Frame( - frame, - name=f"plugin_{plugin_no + 1}" - ) - appitem = plugin.get_app(plugin_frame) - if appitem: - plugin_no += 1 - plugin_sep.grid(columnspan=2, sticky=tk.EW) - ui_row = frame.grid_size()[1] - plugin_frame.grid( - row=ui_row, columnspan=2, sticky=tk.NSEW - ) - plugin_frame.columnconfigure(1, weight=1) - if isinstance(appitem, tuple) and len(appitem) == 2: - ui_row = frame.grid_size()[1] - appitem[0].grid(row=ui_row, column=0, sticky=tk.W) - appitem[1].grid(row=ui_row, column=1, sticky=tk.EW) - - else: - appitem.grid(columnspan=2, sticky=tk.EW) - - else: - # This plugin didn't provide any UI, so drop the frames - plugin_frame.destroy() - plugin_sep.destroy() + ui_row = frame.grid_size()[1] # start row + frame = _config_plugins(frame, ui_row) # LANG: Update button in main window self.button = ttk.Button( @@ -614,8 +610,8 @@ class AppWindow: self.status.grid(columnspan=2, sticky=tk.EW) for child in frame.winfo_children(): - child.grid_configure(padx=self.PADX, pady=( # type: ignore - sys.platform != 'win32' or isinstance(child, tk.Frame)) and 2 or 0) + pad_y = 2 if sys.platform != 'win32' or isinstance(child, tk.Frame) else 0 + child.grid_configure(padx=self.PADX, pady=pad_y) # type: ignore self.menubar = tk.Menu() @@ -692,12 +688,12 @@ class AppWindow: theme_titlebar.bind('', self.drag_start) theme_titlebar.bind('', self.drag_continue) theme_titlebar.bind('', self.drag_end) - theme_minimize = tk.Label(self.theme_menubar, image=self.theme_minimize) + theme_minimize = tk.Label(self.theme_menubar, image=self.img_theme_minimize) theme_minimize.grid(row=0, column=3, padx=2) - theme.button_bind(theme_minimize, self.oniconify, image=self.theme_minimize) - theme_close = tk.Label(self.theme_menubar, image=self.theme_close) + theme.button_bind(theme_minimize, self.oniconify, image=self.img_theme_minimize) + theme_close = tk.Label(self.theme_menubar, image=self.img_theme_close) theme_close.grid(row=0, column=4, padx=2) - theme.button_bind(theme_close, self.onexit, image=self.theme_close) + theme.button_bind(theme_close, self.onexit, image=self.img_theme_close) self.theme_file_menu = tk.Label(self.theme_menubar, anchor=tk.W) self.theme_file_menu.grid(row=1, column=0, padx=self.PADX, sticky=tk.W) theme.button_bind(self.theme_file_menu, @@ -717,15 +713,15 @@ class AppWindow: e.widget.winfo_rooty() + e.widget.winfo_height())) tk.Frame(self.theme_menubar, highlightthickness=1).grid(columnspan=5, padx=self.PADX, sticky=tk.EW) - theme.register(self.theme_minimize) # images aren't automatically registered - theme.register(self.theme_close) + theme.register(self.img_theme_minimize) # images aren't automatically registered + theme.register(self.img_theme_close) self.blank_menubar = tk.Frame(frame, name="blank_menubar") tk.Label(self.blank_menubar).grid() tk.Label(self.blank_menubar).grid() tk.Frame(self.blank_menubar, height=2).grid() theme.register_alternate((self.menubar, self.theme_menubar, self.blank_menubar), {'row': 0, 'columnspan': 2, 'sticky': tk.NSEW}) - self.w.resizable(tk.TRUE, tk.FALSE) + self.w.resizable(True, True) # update geometry if config.get_str('geometry'): @@ -771,13 +767,76 @@ class AppWindow: self.status['text'] = tr.tl("Awaiting Full CMDR Login") # LANG: Await Full CMDR Login to Game # Start a protocol handler to handle cAPI registration. Requires main loop to be running. - self.w.after_idle(lambda: protocol.protocolhandler.start(self.w)) - self.w.after_idle(lambda: self.postprefs(False)) # Companion login happens in callback from monitor + self.w.after_idle(self._after_idle_init) self.toggle_suit_row(visible=False) if args.start_min: logger.warning("Trying to start minimized") self.oniconify() if root.overrideredirect() else self.w.wm_iconify() + def _after_idle_init(self): + """Combine after_idle tasks for startup to ensure only one queue.""" + protocol.protocolhandler.start(self.w) + self.postprefs(False) # Companion login happens in callback from monitor + + def _set_grabber(self, frame: tk.Frame | ttk.Frame) -> None: + if config.get_int("theme") != theme.THEME_DEFAULT: + grip_size = 14 + + self.resize_grip = tk.Canvas( + self.w, + width=grip_size, + height=grip_size, + highlightthickness=0, + bg=frame.cget("background"), + ) + + # draw diagonal grip lines + for i in range(4, grip_size, 4): + self.resize_grip.create_line(i, grip_size, grip_size, i, fill="#404040") + + self.resize_grip.bind( + "", + lambda e: self.resize_grip.configure(bg=frame.cget("background")), + ) + self.resize_grip.bind( + "", + lambda e: self.resize_grip.configure(bg=frame.cget("background")), + ) + + self.resize_grip.place(relx=1.0, rely=1.0, anchor="se") + + def _resize_drag(event: tk.Event): + dx = event.x_root - self._resize_start_x + dy = event.y_root - self._resize_start_y + + # calculate proposed size + new_w = self._resize_start_w + dx + new_h = self._resize_start_h + dy + + # enforce minimum size + min_w, min_h = 50, 50 + if new_w < min_w: + new_w = min_w + if new_h < min_h: + new_h = min_h + + self.w.geometry(f"{new_w}x{new_h}") + + def _resize_start(event: tk.Event): + self._resize_start_x = event.x_root + self._resize_start_y = event.y_root + self._resize_start_w = self.w.winfo_width() + self._resize_start_h = self.w.winfo_height() + + self.resize_grip.bind("", _resize_start) + self.resize_grip.bind("", _resize_drag) + else: + try: + if self.resize_grip: + self.resize_grip.place_forget() + except AttributeError: + pass + def update_suit_text(self) -> None: """Update the suit text for current type and loadout.""" if not monitor.state['Odyssey']: @@ -848,6 +907,8 @@ class AppWindow: if dologin and monitor.cmdr: self.login() # Login if not already logged in with this Cmdr + self._set_grabber(root.nametowidget(".edmarketconnector")) + if postargs.get('Update') and postargs.get('Track'): track = postargs.get('Track') # LANG: Inform the user the Update Track has changed @@ -2382,8 +2443,6 @@ sys.path: {sys.path}''' root.after(2, show_killswitch_poppup, root) # Start the main event loop try: - check_for_fdev_updates() - check_for_datafile_updates() root.mainloop() except KeyboardInterrupt: logger.info("Ctrl+C Detected, Attempting Clean Shutdown") diff --git a/L10n/en.template b/L10n/en.template index f670e39c..4ed7f42a 100644 --- a/L10n/en.template +++ b/L10n/en.template @@ -845,3 +845,51 @@ /* common_coreutils.py: Unidentified colonisation ship; In files: common_coreutils.py:84:84 */ "System Colonisation Ship" = "System Colonisation Ship"; + +/* plugin_browser.py: Filter Plugin Categories */ +"Filter by Category:" = "Filter by Category:"; + +/* plugin_browser.py: Plugin Browser Plug Name */ +"Name" = "Name"; + +/* plugin_browser.py: Plugin Browser Plug Ver */ +"Version" = "Version"; + +/* plugin_browser.py: Plugin Browser Last Update */ +"Last Updated" = "Last Updated"; + +/* plugin_browser.py: Plugin Browser Category */ +"Categories" = "Categories"; + +/* plugin_browser.py: Plugin Browser Description */ +"Description" = "Description"; + +/* plugin_browser.py: Plugin Browser Stars */ +"Stars" = "Stars"; + +/* plugin_browser.py: Plugin Browser Title */ +"Plugin Browser" = "Plugin Browser"; + +/* plugin_browser.py: Open Plugin Repo */ +"Open Plugin Repository" = "Open Plugin Repository"; + +/* plugin_browser.py: Report a Malfunctioning Plugin */ +"Report Plugin" = "Report Plugin"; + +/* plugin_browser.py: Plugin Browser Authors */ +"Authors" = "Authors"; + +/* plugin_browser.py: Plugin Browser License */ +"License" = "License"; + +/* plugin_browser.py: Plugin Browser Reqs */ +"Requirements" = "Requirements"; + +/* plugin_browser.py: Last Tested EDMC Version */ +"Last Tested EDMC: {last_tested}" = "Last Tested EDMC: {last_tested}"; + +/* plugin_browser.py: Plugin Project Link */ +"Main Project Link" = "Main Project Link"; + +/* plugin_browser.py: Plugin VT Link */ +"VirusTotal Link" = "VirusTotal Link"; diff --git a/PLUGINS.md b/PLUGINS.md index fc3d78ae..aa5fbc29 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -61,6 +61,16 @@ You should also keep an eye on [our GitHub Discussions](https://github.com/EDCD/ in case there are any proposed changes to EDMC plugin functionality. You can do this by ensuring 'Discussions' is also ticked when following the steps above to set up a 'Custom' watch on this repository. +--- + +## Plugin Versioning + +We strongly encourage the use of [Semantic Version](https://semver.org/#semantic-versioning-specification-semver) +for plugins. This should be done by establishing a `VERSION` constant or `__version__` dunder as a string with the value `Major.Minor.Patch`. + +This version is used by the EDMC Plugin Browser to compare versions of plugins and allow plugins to use upcoming +planned features such as the Plugin Auto-Updater. While this is not a required part of the Plugin spec, it is highly +recommended and may become required at a later date. --- diff --git a/plug.py b/plug.py index fd6b804e..fb0c1819 100644 --- a/plug.py +++ b/plug.py @@ -177,6 +177,15 @@ class Plugin: logger.exception(f'Failed for Plugin "{self.name}"') return None + def get_version(self) -> str | None: + """ + If the plugin provides a VERSION identifier, return it. + + :returns: String with the VERSION information or None. + """ + has_version = self._get_func('VERSION') or self._get_func('__version__') + return has_version if has_version else None + def load_plugins(master: tk.Tk) -> None: """Find and load all plugins.""" diff --git a/plugin_browser.py b/plugin_browser.py new file mode 100644 index 00000000..8122ed82 --- /dev/null +++ b/plugin_browser.py @@ -0,0 +1,618 @@ +""" +plugin_browser.py - EDMC's Official Plugin Browser. + +Copyright (c) EDCD, All Rights Reserved +Licensed under the GNU General Public License v2 or later. +See LICENSE file. +""" + +from __future__ import annotations +import os +import json +import sys +import webbrowser +import tkinter as tk +from tkinter import ttk +from PIL import Image, ImageTk +from typing import Callable, cast +import requests +from io import BytesIO +from datetime import datetime +import myNotebook as nb # noqa: N813 +import plug +from config import appversion_nobuild +from EDMCLogging import get_main_logger +from l10n import translations as tr +from ttkHyperlinkLabel import HyperlinkLabel +import semantic_version + +logger = get_main_logger() + +# TODO in 6.2 or later: Install/Uninstall Plugin, notify user a plugin update is available. + + +def get_plugins(): + """DEV: Print Installed Non-Core Plugins.""" + print("Installed Plugins") + for plugin in plug.PLUGINS: + if plugin.folder: # Exclude Default Plugins + print(vars(plugin)) + print(plugin.get_version()) + + +def read_plugin_list(): + """Read the Plugin List distributed by EDMC.""" + with open("master_plugin_list.json", encoding="utf-8") as plugin_list_file: + plugin_list = json.load(plugin_list_file) + plugins_by_id = {p["pluginName"]: p for p in plugin_list} + return plugin_list, plugins_by_id + + +def open_plugin_main(plugin) -> None: + """Open the Plugin's Main Repository.""" + webbrowser.open(plugin.get("pluginMainLink")) + + +def report_plugin(plugin) -> None: # FIXME: Update to live repo. + """Open the Webbrowser to the new plugin issue report.""" + webbrowser.open( + f"https://github.com/Rixxan/EDMC_Plugin_Registry_Dev/issues/new?" + f"template=report_plugin.md&title=Plugin Report: " + f"{plugin.get('pluginName')} ({plugin.get('pluginHash')[:8]})" + ) + + +class PluginBrowserMixIn: + """Separated Class for the Plugin Browser.""" + + def __init__(self): + """Initialize the Plugin Browser.""" + self.PADX = 10 + self.BUTTONX = 12 # indent Checkbuttons and Radiobuttons + self.LISTX = 25 # indent listed items + self.PADY = 1 # close spacing + self.BOXY = 2 # box spacing + self.SEPY = 10 # separator line spacing + + # Setup Plugin Browser Options + self.browser_plugins, self.browser_plugins_by_id = read_plugin_list() + self._plugin_sort_reverse = { + "pluginName": False, + "pluginLastUpdate": False, + "pluginStars": False, + } + self.selected_plugin: dict | None = None + self._plugin_last_tested_label: nb.Label | None = None + + # def dev_install_plugin(self, plugin) -> None: + # """TEMP.""" + # print("Install", plugin.get("pluginName")) + # + # def dev_uninstall_plugin(self, plugin) -> None: + # """TEMP.""" + # print("Uninstall", plugin.get("pluginName")) + # + # def dev_update_plugin(self, plugin) -> None: + # """TEMP.""" + # print("Update", plugin.get("pluginName")) + + def setup_browser_tab(self, notebook: nb.Notebook, row) -> None: + """Set up the Plugin Browser tab in Preferences.""" + if not hasattr(self, "browser_plugins"): + self.browser_plugins, self.browser_plugins_by_id = read_plugin_list() + self._plugin_sort_reverse = {"pluginName": False, "pluginLastUpdate": False} + self.selected_plugin = None + + categories = sorted( + { + cat + for plugin in self.browser_plugins + for cat in plugin.get("pluginCategory", []) + } + ) + categories.insert(0, "All") + + plugins_frame = nb.Frame(notebook) + HyperlinkLabel( + plugins_frame, + text="EDMC Plugin Browser", + background=nb.Label().cget("background"), + url="https://github.com/Rixxan/EDMC_Plugin_Registry_Dev", # Fixme + underline=True, + ).grid(row=row.get(), padx=self.PADX, pady=(self.PADY, 1), sticky=tk.W) + + # Add after the "Available Plugins" label + categories = sorted( + { + cat + for plugin in self.browser_plugins + for cat in plugin.get("pluginCategory", []) + } + ) + categories.insert(0, "All") # default to show all + + self.selected_category = tk.StringVar(value="All") + + # Place the dropdown on its own row (separate from the tree) + # LANG: Filter Plugin Categories + category_label = nb.Label(plugins_frame, text=tr.tl("Filter by Category:")) + category_label.grid( + row=row.get(), column=0, sticky=tk.W, padx=self.PADX, pady=(0, self.PADY) + ) + + category_dropdown = nb.OptionMenu( + plugins_frame, + self.selected_category, + "All", + *categories, + command=self.__on_category_selected, # callback + ) + category_dropdown.config(width=25) # make the dropdown wider + category_dropdown.grid( + row=row.get(), column=0, sticky=tk.W, padx=self.PADX, pady=(0, self.PADY) + ) + + ttk.Separator(plugins_frame, orient=tk.HORIZONTAL).grid( + columnspan=4, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=row.get() + ) + tree_container = nb.Frame(plugins_frame) # type: ignore + tree_row = row.get() + tree_container.grid(row=tree_row, column=0, sticky=tk.NSEW, padx=self.PADX) + + plugins_frame.columnconfigure(0, weight=1) + + columns = ("name", "version", "updated", "category", "description", "stars") + + self.plugins_tree = ttk.Treeview( + tree_container, + columns=columns, + show="headings", + selectmode="browse", + height=12, + ) + + self.plugins_tree.heading( + "name", + # LANG: Plugin Browser Plug Name + text=tr.tl("Name"), + command=lambda: self.__sort_plugins("pluginName"), + ) + # LANG: Plugin Browser Plug Ver + self.plugins_tree.heading("version", text=tr.tl("Version")) + self.plugins_tree.heading( + "updated", + text=tr.tl("Last Updated"), # LANG: Plugin Browser Last Update + command=lambda: self.__sort_plugins("pluginLastUpdate"), + ) + self.plugins_tree.heading( + "category", + text=tr.tl("Categories"), # LANG: Plugin Browser Category + ) + self.plugins_tree.heading( + "description", + text=tr.tl("Description"), # LANG: Plugin Browser Description + ) + self.plugins_tree.heading( + "stars", + # LANG: Plugin Browser Stars + text=tr.tl("Stars"), + command=lambda: self.__sort_plugins("pluginStars"), + ) + + self.plugins_tree.column("name", width=40) + self.plugins_tree.column("version", width=40, anchor=tk.CENTER) + self.plugins_tree.column("updated", width=40, anchor=tk.CENTER) + self.plugins_tree.column("category", width=120, anchor=tk.CENTER) + self.plugins_tree.column("description", width=240) + self.plugins_tree.column("stars", width=40) + + scrollbar = ttk.Scrollbar(tree_container, command=self.plugins_tree.yview) + self.plugins_tree.configure(yscrollcommand=scrollbar.set) + + self.plugins_tree.grid(row=0, column=0, sticky=tk.NSEW) + scrollbar.grid(row=0, column=1, sticky=tk.NS) + + tree_container.columnconfigure(0, weight=1) + tree_container.rowconfigure(0, weight=0) + + ttk.Separator(plugins_frame, orient=tk.HORIZONTAL).grid( + columnspan=4, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=row.get() + ) + + self.plugin_details_frame = nb.Frame(plugins_frame) # type: ignore + details_row = row.get() + self.plugin_details_frame.grid( + row=details_row, column=0, sticky=tk.NW, padx=self.PADX + ) + self.plugin_details_frame.columnconfigure(1, weight=1) + self.plugin_details_frame.grid_propagate(True) + + self._plugin_icon_cache: dict[str, ImageTk.PhotoImage] = {} + self._plugin_icon_label = nb.Label(self.plugin_details_frame) + self._plugin_icon_label.grid( + row=0, column=0, rowspan=3, sticky=tk.NW, padx=(0, 12) + ) + self._plugin_hyperlinks: list[HyperlinkLabel] = [] + self._plugin_title = nb.Label( + self.plugin_details_frame, + font=("TkDefaultFont", 10, "bold"), + wraplength=600, + justify=tk.LEFT, + ) + self._plugin_title.grid(row=0, column=1, sticky=tk.W) + + self._plugin_meta = nb.Label( + self.plugin_details_frame, + justify=tk.LEFT, + ) + self._plugin_meta.grid(row=1, column=1, sticky=tk.W, pady=(1, 2)) + + self._plugin_description = nb.Label( + self.plugin_details_frame, + wraplength=600, + justify=tk.LEFT, + ) + self._plugin_description.grid(row=2, column=1, sticky=tk.W) + self._create_plugin_action_buttons() + self.plugins_tree.bind("<>", self.__on_plugin_selected) + self.__populate_plugins_tree() + + notebook.add( + # LANG: Plugin Browser Title + plugins_frame, + text=tr.tl("Plugin Browser"), + ) + + def __get_filtered_plugins(self) -> list[dict]: + category = self.selected_category.get() + if category == "All": + return self.browser_plugins + return [ + p for p in self.browser_plugins if category in p.get("pluginCategory", []) + ] + + def _run_plugin_action(self, handler) -> None: + if self.selected_plugin is None: + return + handler(self.selected_plugin) + + def _create_plugin_action_buttons(self) -> None: + self.plugin_actions_frame = nb.Frame(self.plugin_details_frame) # type: ignore + self.plugin_actions_frame.grid( + row=0, column=2, rowspan=6, sticky=tk.N, padx=(12, 0) + ) + + self._plugin_action_buttons: list[nb.Button] = [] + + actions = [ + ( + # LANG: Open Plugin Repo + tr.tl("Open Plugin Repository"), + open_plugin_main, + ), + # ("Install", self.dev_install_plugin), # Upcoming Feature + # ("Uninstall", self.dev_uninstall_plugin), # Upcoming Feature + # ("Update", self.dev_update_plugin), # Upcoming Feature + ( + # LANG: Report a Malfunctioning Plugin + tr.tl("Report Plugin"), + report_plugin, + ), + ] + + for idx, (label, handler) in enumerate(actions): + btn = nb.Button( + self.plugin_actions_frame, + text=label, + command=cast( + Callable[[], None], lambda h=handler: self._run_plugin_action(h) + ), + ) + btn.grid( + row=idx, + column=0, + padx=self.LISTX, + pady=(0 if idx == 0 else self.PADY), + sticky=tk.EW, + ) + btn.state(["disabled"]) + self._plugin_action_buttons.append(btn) + + self.plugin_actions_frame.columnconfigure(0, weight=1) + self.plugin_actions_frame.grid_remove() + + def __populate_plugins_tree(self, plugins: list[dict] | None = None) -> None: + self.plugins_tree.delete(*self.plugins_tree.get_children()) + + if plugins is None: + plugins = self.__get_filtered_plugins() + + # Rebuild Treeview ID to plugin mapping + self.browser_plugins_by_id = {} + + for index, plugin in enumerate(plugins): + plugin_id = f"plugin_{index}" + self.browser_plugins_by_id[plugin_id] = plugin + self.plugins_tree.insert( + "", + tk.END, + iid=plugin_id, + values=( + plugin.get("pluginName", ""), + plugin.get("pluginVer", ""), + plugin.get("pluginLastUpdate", ""), + ", ".join(plugin.get("pluginCategory", [])), + plugin.get("pluginDesc", ""), + plugin.get("pluginStars", ""), + ), + ) + self._set_plugin_action_visibility(bool(plugins)) + self.plugins_tree.selection_remove(self.plugins_tree.selection()) + self.selected_plugin = None + self.__clear_plugin_details() + + def __on_category_selected(self, _event=None): + # Simply repopulate Treeview with filtered plugins + self.__populate_plugins_tree() + + def __sort_plugins(self, key: str) -> None: + # Initialize sort state if missing + if key not in self._plugin_sort_reverse: + self._plugin_sort_reverse[key] = False + + reverse = self._plugin_sort_reverse[key] + self._plugin_sort_reverse[key] = not reverse + self._current_sort_key = key + + if key == "pluginLastUpdate": + self.browser_plugins.sort( + key=lambda p: datetime.fromisoformat( + p.get("pluginLastUpdate") or "1900-01-01" + ), + reverse=reverse, + ) + + elif key == "pluginStars": + self.browser_plugins.sort( + key=lambda p: int(p.get("pluginStars") or 0), + reverse=reverse, + ) + + else: + self.browser_plugins.sort( + key=lambda p: (p.get(key) or "").casefold(), + reverse=reverse, + ) + + # Repopulate with current category filter + self.__populate_plugins_tree() + + def __on_plugin_selected(self, event) -> None: + self._clear_plugin_links() + + selection = self.plugins_tree.selection() + if not selection: + self._clear_selection_state() + return + + plugin_id = selection[0] + plugin = self.browser_plugins_by_id.get(plugin_id) + if not plugin: + self._clear_selection_state() + return + + self.selected_plugin = plugin + self._set_plugin_action_state(True) + + self._plugin_title.configure(text=plugin.get("pluginName", "")) + + meta_lines = self._build_plugin_meta(plugin) + self._plugin_meta.configure(text="\n".join(meta_lines)) + + link_row = self._update_last_tested(plugin) + + self._build_plugin_links(plugin, link_row) + + self._plugin_description.configure(text=plugin.get("pluginDesc", "")) + + self._update_plugin_icon(plugin_id, plugin) + + self._set_plugin_action_visibility(bool(self.browser_plugins)) + + def _clear_selection_state(self) -> None: + self.selected_plugin = None + self.__clear_plugin_details() + + def _build_plugin_meta(self, plugin: dict) -> list[str]: + fields = [ + (tr.tl("Version"), "pluginVer"), # LANG: Plugin Browser Plug Ver + ( + # LANG: Plugin Browser Last Update + tr.tl("Last Updated"), + "pluginLastUpdate", + ), + ( + # LANG: Plugin Browser Category + tr.tl("Categories"), + "pluginCategory", + lambda v: ", ".join(v), + ), + ( + # LANG: Plugin Browser Authors + tr.tl("Authors"), + "pluginAuthors", + lambda v: ", ".join(v), + ), + (tr.tl("License"), "pluginLicense"), # LANG: Plugin Browser License + (tr.tl("Stars"), "pluginStars"), # LANG: Plugin Browser Stars + ( + # LANG: Plugin Browser Reqs + tr.tl("Requirements"), + "pluginRequirements", + lambda v: ", ".join(v), + ), + ] + + meta = [] + for label, key, *formatter in fields: + value = plugin.get(key) + if value: + if formatter: + value = formatter[0](value) # type: ignore + meta.append(f"{label}: {value}") + + return meta + + def _update_last_tested(self, plugin: dict) -> int: + last_tested = plugin.get("pluginLastTestedEDMC") + if not last_tested: + if getattr(self, "_plugin_last_tested_label", None): + self._plugin_last_tested_label.grid_forget() # type: ignore + self._plugin_last_tested_label = None + return 3 + + tested = semantic_version.Version.coerce(last_tested) + current = appversion_nobuild() + + major_diff = tested.major - current.major + minor_diff = tested.minor - current.minor + + if major_diff == 0 and abs(minor_diff) <= 1: + color = "green" + elif major_diff == 0 and abs(minor_diff) <= 2: + color = "dark goldenrod" + else: + color = "red" + + # LANG: Last Tested EDMC Version + text = tr.tl("Last Tested EDMC: {last_tested}").format(last_tested=last_tested) + + if getattr(self, "_plugin_last_tested_label", None): + self._plugin_last_tested_label.configure(text=text, foreground=color) # type: ignore + else: + self._plugin_last_tested_label = nb.Label( + self.plugin_details_frame, + text=text, + foreground=color, + justify=tk.LEFT, + ) + self._plugin_last_tested_label.grid(row=3, column=1, sticky=tk.W) + + return 4 + + def _clear_plugin_links(self) -> None: + for hl in self._plugin_hyperlinks: + hl.destroy() + self._plugin_hyperlinks.clear() + + def _build_plugin_links(self, plugin: dict, start_row: int) -> None: + links = [ + ( + # LANG: Plugin Project Link + tr.tl("Main Project Link"), + plugin.get("pluginMainLink"), + ), + (tr.tl("VirusTotal Link"), plugin.get("pluginVT")), # LANG: Plugin VT Link + ] + + bg = nb.Label().cget("background") + row = start_row + + for text, url in links: + if not url: + continue + + hl = HyperlinkLabel( + self.plugin_details_frame, + text=text, + background=bg, + url=url, + underline=True, + ) + hl.grid(row=row, column=1, padx=self.PADX, pady=self.PADY, sticky=tk.W) + self._plugin_hyperlinks.append(hl) + row += 1 + + def _update_plugin_icon(self, plugin_id: str, plugin: dict) -> None: + icon_url = plugin.get("pluginIcon") + icon_key = plugin_id if icon_url else "__fallback__" + url = icon_url or os.path.join( + os.path.dirname(sys.argv[0]), + "io.edcd.EDMarketConnector.png", + ) + self.__load_plugin_icon(icon_key, url) + + def __clear_plugin_details(self) -> None: + self._plugin_title.configure(text="") + self._plugin_meta.configure(text="") + self._plugin_description.configure(text="") + + # Last Tested + if getattr(self, "_plugin_last_tested_label", None) is not None: + self._plugin_last_tested_label.grid_forget() # type: ignore + self._plugin_last_tested_label = None + + # Plugin Icon + self._plugin_icon_label.configure(image="") + self._plugin_icon_label.image = None # type: ignore + + # Hyperlinks + if hasattr(self, "_plugin_hyperlinks"): + for hl in self._plugin_hyperlinks: + hl.destroy() + self._plugin_hyperlinks = [] + + self._set_plugin_action_state(False) + self._set_plugin_action_visibility(False) + + def __load_plugin_icon(self, plugin_id: str, url: str) -> None: + if plugin_id in self._plugin_icon_cache: + tk_image = self._plugin_icon_cache[plugin_id] + self._plugin_icon_label.configure(image=tk_image) + self._plugin_icon_label.image = tk_image # type: ignore + return + + try: + # Open PIL image + pil_image = ( + Image.open(url).convert("RGBA") + if os.path.isfile(url) + else Image.open(BytesIO(requests.get(url, timeout=5).content)).convert( + "RGBA" + ) + ) + + # Resize while maintaining aspect ratio + pil_image.thumbnail((64, 64), Image.Resampling.LANCZOS) + + # Paste onto fixed 64x64 transparent canvas + canvas = Image.new("RGBA", (64, 64), (0, 0, 0, 0)) + offset = ((64 - pil_image.width) // 2, (64 - pil_image.height) // 2) + canvas.paste(pil_image, offset) + + # Convert to Tkinter PhotoImage + tk_image = ImageTk.PhotoImage(canvas, master=self.plugins_tree) + self._plugin_icon_cache[plugin_id] = tk_image + + self._plugin_icon_label.configure(image=tk_image) + self._plugin_icon_label.image = tk_image # type: ignore + + except Exception: + self._plugin_icon_label.configure(image="") + self._plugin_icon_label.image = None # type: ignore + + def _set_plugin_action_visibility(self, visible: bool) -> None: + if not hasattr(self, "plugin_actions_frame"): + return + + if visible: + self.plugin_actions_frame.grid() + else: + self.plugin_actions_frame.grid_remove() + + def _set_plugin_action_state(self, enabled: bool) -> None: + for btn in self._plugin_action_buttons: + if enabled: + btn.state(["!disabled"]) + else: + btn.state(["disabled"]) diff --git a/prefs.py b/prefs.py index 2d4405ea..7ef7a8eb 100644 --- a/prefs.py +++ b/prefs.py @@ -15,6 +15,7 @@ from tkinter import ttk from types import TracebackType from typing import Any from collections.abc import Callable +import plugin_browser import myNotebook as nb # noqa: N813 import plug from config import appversion_nobuild, config @@ -238,7 +239,7 @@ if sys.platform == 'win32': return '\\'.join(display_components) -class PreferencesDialog(tk.Toplevel): +class PreferencesDialog(tk.Toplevel, plugin_browser.PluginBrowserMixIn): """The EDMC preferences dialog.""" def __init__(self, parent: tk.Tk, callback: Callable | None): @@ -291,6 +292,7 @@ class PreferencesDialog(tk.Toplevel): self.__setup_output_tab(notebook) self.__setup_privacy_tab(notebook) self.__setup_plugin_tab(notebook) + self.setup_browser_tab(notebook, AutoInc()) self.__setup_plugin_tabs(notebook) # Set up the button frame @@ -981,9 +983,11 @@ class PreferencesDialog(tk.Toplevel): for plugin in enabled_plugins: curr_row = row.get() - label = nb.Label(plugins_frame, - text=plugin.name if plugin.name == plugin.folder - else f'{plugin.folder} ({plugin.name})') + plugin_name = plugin.name if plugin.name == plugin.folder else f'{plugin.folder} ({plugin.name})' + plugver = plugin.get_version() + if plugver: + plugin_name += f" v. {plugver}" + label = nb.Label(plugins_frame, text=plugin_name) label.grid(column=0, columnspan=2, padx=self.LISTX, pady=self.PADY, sticky=tk.W, row=curr_row) diff --git a/theme.py b/theme.py index 68a1a391..a41ba528 100644 --- a/theme.py +++ b/theme.py @@ -220,38 +220,61 @@ class _Theme: widget.bind('', lambda e: self._enter(e, image)) widget.bind('', lambda e: self._leave(e, image)) - def _enter(self, event: tk.Event, image: tk.BitmapImage | None) -> None: - widget = event.widget - if widget and widget['state'] != tk.DISABLED: - try: - widget.configure(state=tk.ACTIVE) # type: ignore + def _enter(self, event, widget=None, image=None): # noqa: CCR001 + """Handle mouse entering a widget or image.""" + if widget is not None: + # Only apply foreground/background if the widget supports them + config_keys = widget.keys() if hasattr(widget, 'keys') else [] + kwargs = {} + if 'foreground' in config_keys: + kwargs['foreground'] = self.current.get('highlightforeground', '') + if 'background' in config_keys: + kwargs['background'] = self.current.get('highlightbackground', '') + if kwargs: + widget.configure(**kwargs) - except Exception: - logger.exception(f'Failure setting widget active: {widget=}') - - if image: + if image is not None: + # PhotoImage cannot be configured with fg/bg, skip safely + if not isinstance(image, tk.PhotoImage): try: - image.configure(foreground=self.current['activeforeground'], - background=self.current['activebackground']) + config_keys = image.keys() if hasattr(image, 'keys') else [] + kwargs = {} + if 'foreground' in config_keys: + kwargs['foreground'] = self.current.get('highlightforeground', '') + if 'background' in config_keys: + kwargs['background'] = self.current.get('highlightbackground', '') + if kwargs: + image.configure(**kwargs) + except tk.TclError: + # silently ignore unsupported image types + pass - except Exception: - logger.exception(f'Failure configuring image: {image=}') + def _leave(self, event, widget=None, image=None): # noqa: CCR001 + """Handle mouse leaving a widget or image.""" + if widget is not None: + config_keys = widget.keys() if hasattr(widget, 'keys') else [] + kwargs = {} + if 'foreground' in config_keys: + kwargs['foreground'] = self.current.get('foreground', '') + if 'background' in config_keys: + kwargs['background'] = self.current.get('background', '') + if kwargs: + widget.configure(**kwargs) - def _leave(self, event: tk.Event, image: tk.BitmapImage | None) -> None: - widget = event.widget - if widget and widget['state'] != tk.DISABLED: - try: - widget.configure(state=tk.NORMAL) # type: ignore - - except Exception: - logger.exception(f'Failure setting widget normal: {widget=}') - - if image: + if image is not None: + # Skip PhotoImage objects; safely handle other types + if not isinstance(image, tk.PhotoImage): try: - image.configure(foreground=self.current['foreground'], background=self.current['background']) - - except Exception: - logger.exception(f'Failure configuring image: {image=}') + config_keys = image.keys() if hasattr(image, 'keys') else [] + kwargs = {} + if 'foreground' in config_keys: + kwargs['foreground'] = self.current.get('foreground', '') + if 'background' in config_keys: + kwargs['background'] = self.current.get('background', '') + if kwargs: + image.configure(**kwargs) + except tk.TclError: + pass # Set up colors def _colors(self, root: tk.Tk, theme: int) -> None: @@ -407,7 +430,7 @@ class _Theme: # Switch menus for pair, gridopts in self.widgets_pair: for widget in pair: - if isinstance(widget, tk.Widget): + if isinstance(widget, tk.Widget) and not widget.winfo_exists(): widget.grid_remove() if isinstance(pair[0], tk.Menu): diff --git a/update.py b/update.py index 4c1b5dd8..7357775d 100644 --- a/update.py +++ b/update.py @@ -8,10 +8,13 @@ See LICENSE file. from __future__ import annotations import pathlib +import hashlib import shutil import sys import threading import os +import time +from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass from tkinter import messagebox from traceback import print_exc @@ -28,35 +31,128 @@ if TYPE_CHECKING: logger = get_main_logger() +HTTP_TIMEOUT = (5, 20) # (connect, read) +HTTP_RETRIES = 3 +RETRY_BACKOFF = 1.5 # seconds multiplier +MAX_WORKERS = 8 -def read_normalized_file(path: pathlib.Path) -> str: + +def read_normalized_file(path: pathlib.Path) -> tuple[str, str, str] | None: """Read a UTF-8 (with BOM-safe) file and normalize line endings and whitespace.""" try: - return path.read_bytes().decode('utf-8-sig').replace('\r\n', '\n').strip() + raw = path.read_bytes() except FileNotFoundError: - return '' + return None + newline = '\r\n' if b'\r\n' in raw else '\n' + text = raw.decode('utf-8-sig') + normalized = text.replace('\r\n', '\n').strip() + + return normalized, hashlib.sha256(normalized.encode()).hexdigest(), newline -def copy_bundle_file(filename: str, dest_dir: pathlib.Path) -> str: - """Attempt to copy a missing file from the bundled FDevIDs folder.""" +def copy_bundle_file(filename: str, dest_dir: pathlib.Path) -> tuple[str, str] | None: + """ + Attempt to copy a missing file from the bundled FDevIDs folder. + + Returns (content, hash) or None. + """ try: - shutil.copy(f"FDevIDs/{filename}", dest_dir) - return read_normalized_file(dest_dir / filename) + dest = dest_dir / filename + shutil.copy(f"FDevIDs/{filename}", dest) + return read_normalized_file(dest) # type: ignore except (FileNotFoundError, shutil.SameFileError): logger.info("Bundle copy failed or identical; continuing with empty content.") - return '' - - -def fetch_remote_file(url: str) -> str | None: - """Fetch a remote file and normalize its content.""" - try: - response = requests.get(url, timeout=20) - response.raise_for_status() - return response.text.replace('\r\n', '\n').strip() - except requests.RequestException: return None +def fetch_remote_file(url: str) -> tuple[str, str] | None: + """ + Fetch a remote file with retries. + + Returns (normalized_text, hash) or None on failure. + """ + delay = 1.0 + + for attempt in range(1, HTTP_RETRIES + 1): + try: + response = requests.get(url, timeout=HTTP_TIMEOUT) + response.raise_for_status() + + text = response.text.replace('\r\n', '\n').strip() + return text, hashlib.sha256(text.encode()).hexdigest() + + except requests.RequestException as exc: + if attempt >= HTTP_RETRIES: + logger.debug("Download failed after %d attempts: %s", attempt, exc) + return None + + time.sleep(delay) + delay *= RETRY_BACKOFF + return None + + +def update_single_file( + directory: pathlib.Path, + filename: str, + url: str, + silent: bool = False, +) -> None: + """Update a single bundle file.""" + file_path = directory / filename + + local = read_normalized_file(file_path) + if local is None: + local = copy_bundle_file(filename, directory) # type: ignore + + if local: + local_text, local_hash, newline = local + else: + local_text, local_hash, newline = '', '', '\n' # noqa: F841 + + remote = fetch_remote_file(url) + if not remote: + if not silent: + logger.error(f'Failed to download {filename}! Unable to continue.') + return + + remote_text, remote_hash = remote + + if local_hash == remote_hash: + if not silent: + logger.info(f'{filename} already up to date.') + return + + if not silent: + logger.info(f'Updating file {filename}...') + + # Restore original newline style + output = remote_text.replace('\n', newline) + file_path.write_text(output, encoding='utf-8', newline='') + + +def update_files( + directory: pathlib.Path, + files_urls: dict[str, str], + silent: bool = False, +) -> None: + """Start threads to update bundle files.""" + directory.mkdir(parents=True, exist_ok=True) + + max_workers = min(MAX_WORKERS, len(files_urls)) + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = [ + executor.submit(update_single_file, directory, filename, url, silent) + for filename, url in files_urls.items() + ] + + for future in as_completed(futures): + try: + future.result() + except Exception: + logger.exception("Unexpected error while updating files") + + def check_for_fdev_updates(silent: bool = False, local: bool = False) -> None: """Check for and download FDEV ID file updates.""" base_path = config.respath_path if local else config.app_dir_path @@ -67,7 +163,8 @@ def check_for_fdev_updates(silent: bool = False, local: bool = False) -> None: 'commodity.csv': 'https://raw.githubusercontent.com/EDCD/FDevIDs/master/commodity.csv', 'rare_commodity.csv': 'https://raw.githubusercontent.com/EDCD/FDevIDs/master/rare_commodity.csv' } - + if not silent: + logger.info(f"Checking for {'local ' if local else ''}FDEVID file updates...") update_files(fdevid_dir, files_urls, silent) @@ -76,30 +173,13 @@ def check_for_datafile_updates(silent: bool = False, local: bool = False) -> Non base_path = config.respath_path if local else config.app_dir_path files_urls = { 'modules.json': 'https://raw.githubusercontent.com/EDCD/EDMarketConnector/refs/heads/releases/modules.json', - 'ships.json': 'https://raw.githubusercontent.com/EDCD/EDMarketConnector/refs/heads/releases/ships.json' + 'ships.json': 'https://raw.githubusercontent.com/EDCD/EDMarketConnector/refs/heads/releases/ships.json', + 'master_plugin_list.json': 'https://raw.githubusercontent.com/Rixxan/' + 'EDMC_Plugin_Registry_Dev/refs/heads/master/master_plugin_list.json' } - update_files(base_path, files_urls, silent) - - -def update_files(directory: pathlib.Path, files_urls: dict[str, str], silent: bool = False) -> None: - """Update the provided files from a remote source.""" - for filename, url in files_urls.items(): - file_path = directory / filename - local_content = read_normalized_file(file_path) or copy_bundle_file(filename, directory) - remote_content = fetch_remote_file(url) - if not remote_content: - if not silent: - logger.error(f'Failed to download {filename}! Unable to continue.') - continue - - if local_content == remote_content: - if not silent: - logger.info(f'{filename} already up to date.') - continue - - if not silent: - logger.info(f'Updating file {filename}...') - file_path.write_text(remote_content, encoding='utf-8', newline='\n') + if not silent: + logger.info(f"Checking for {'local ' if local else ''}datafile file updates...") + update_files(pathlib.Path(base_path), files_urls, silent) @dataclass(slots=True)