diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index e90929bb..8547540a 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -85,11 +85,15 @@ jobs: # F63 - 'tests' checking # F7 - syntax errors # F82 - undefined checking - git diff "refs/remotes/${BASE_REPO_OWNER}/${BASE_REF}" -- | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --diff + git diff --name-only --diff-filter=d -z "refs/remotes/${BASE_REPO_OWNER}/${BASE_REF}" -- | \ + grep -E -z -Z '\.py$' | \ + xargs -0 flake8 --count --select=E9,F63,F7,F82 --show-source --statistics # Can optionally add `--exit-zero` to the flake8 arguments so that # this doesn't fail the build. # explicitly ignore docstring errors (start with D) - git diff "refs/remotes/${BASE_REPO_OWNER}/${BASE_REF}" -- | flake8 . --count --statistics --diff --extend-ignore D + git diff --name-only --diff-filter=d -z "refs/remotes/${BASE_REPO_OWNER}/${BASE_REF}" -- | \ + grep -E -z -Z '\.py$' | \ + xargs -0 flake8 --count --statistics --extend-ignore D #################################################################### #################################################################### diff --git a/.github/workflows/push-checks.yml b/.github/workflows/push-checks.yml index b6994b02..e0c98897 100644 --- a/.github/workflows/push-checks.yml +++ b/.github/workflows/push-checks.yml @@ -40,7 +40,22 @@ jobs: DATA=$(jq --raw-output .before $GITHUB_EVENT_PATH) echo "DATA: ${DATA}" + ####################################################################### # stop the build if there are Python syntax errors or undefined names, ignore existing - git diff "$DATA" | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --diff - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - git diff "$DATA" | flake8 . --count --statistics --diff + ####################################################################### + # We need to get just the *filenames* of only *python* files changed. + # Using various -z/-Z/-0 to utilise NUL-terminated strings. + git diff --name-only --diff-filter=d -z "$DATA" | \ + grep -E -z -Z '\.py$' | \ + xargs -0 flake8 --count --select=E9,F63,F7,F82 --show-source --statistics + ####################################################################### + + ####################################################################### + # 'Full' run, but ignoring docstring errors + ####################################################################### + # explicitly ignore docstring errors (start with D) + # Can optionally add `--exit-zero` to the flake8 arguments so that + git diff --name-only --diff-filter=d -z "$DATA" | \ + grep -E -z -Z '\.py$' | \ + xargs -0 flake8 --count --statistics --extend-ignore D + ####################################################################### diff --git a/EDMarketConnector.py b/EDMarketConnector.py index ef97b3a2..60e9f546 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -529,17 +529,19 @@ class AppWindow(object): # LANG: Update button in main window self.button = ttk.Button(frame, text=_('Update'), width=28, default=tk.ACTIVE, state=tk.DISABLED) self.theme_button = tk.Label(frame, width=32 if sys.platform == 'darwin' else 28, state=tk.DISABLED) - self.status = tk.Label(frame, name='status', anchor=tk.W) ui_row = frame.grid_size()[1] self.button.grid(row=ui_row, columnspan=2, sticky=tk.NSEW) self.theme_button.grid(row=ui_row, columnspan=2, sticky=tk.NSEW) theme.register_alternate((self.button, self.theme_button, self.theme_button), {'row': ui_row, 'columnspan': 2, 'sticky': tk.NSEW}) - self.status.grid(columnspan=2, sticky=tk.EW) self.button.bind('', self.capi_request_data) theme.button_bind(self.theme_button, self.capi_request_data) + # Bottom 'status' line. + self.status = tk.Label(frame, name='status', anchor=tk.W) + self.status.grid(columnspan=2, sticky=tk.EW) + for child in frame.winfo_children(): child.grid_configure(padx=self.PADX, pady=( sys.platform != 'win32' or isinstance(child, tk.Frame)) and 2 or 0) @@ -558,7 +560,7 @@ class AppWindow(object): # https://www.tcl.tk/man/tcl/TkCmd/menu.htm self.system_menu = tk.Menu(self.menubar, name='apple') self.system_menu.add_command(command=lambda: self.w.call('tk::mac::standardAboutPanel')) - self.system_menu.add_command(command=lambda: self.updater.checkForUpdates()) + self.system_menu.add_command(command=lambda: self.updater.check_for_updates()) self.menubar.add_cascade(menu=self.system_menu) self.file_menu = tk.Menu(self.menubar, name='file') self.file_menu.add_command(command=self.save_raw) @@ -601,7 +603,7 @@ class AppWindow(object): self.help_menu.add_command(command=self.help_general) self.help_menu.add_command(command=self.help_privacy) self.help_menu.add_command(command=self.help_releases) - self.help_menu.add_command(command=lambda: self.updater.checkForUpdates()) + self.help_menu.add_command(command=lambda: self.updater.check_for_updates()) self.help_menu.add_command(command=lambda: not self.HelpAbout.showing and self.HelpAbout(self.w)) self.menubar.add_cascade(menu=self.help_menu) @@ -724,7 +726,7 @@ class AppWindow(object): self.updater = update.Updater(tkroot=self.w, provider='external') else: self.updater = update.Updater(tkroot=self.w, provider='internal') - self.updater.checkForUpdates() # Sparkle / WinSparkle does this automatically for packaged apps + self.updater.check_for_updates() # Sparkle / WinSparkle does this automatically for packaged apps # Migration from <= 3.30 for username in config.get_list('fdev_usernames', default=[]): @@ -733,7 +735,6 @@ class AppWindow(object): config.delete('username', suppress=True) config.delete('password', suppress=True) config.delete('logdir', suppress=True) - self.postprefs(False) # Companion login happens in callback from monitor self.toggle_suit_row(visible=False) @@ -1382,7 +1383,7 @@ class AppWindow(object): # Disable WinSparkle automatic update checks, IFF configured to do so when in-game if config.get_int('disable_autoappupdatecheckingame') and 1: - self.updater.setAutomaticUpdatesCheck(False) + self.updater.set_automatic_updates_check(False) logger.info('Monitor: Disable WinSparkle automatic update checks') # Can't start dashboard monitoring @@ -1433,7 +1434,7 @@ class AppWindow(object): if entry['event'] == 'ShutDown': # Enable WinSparkle automatic update checks # NB: Do this blindly, in case option got changed whilst in-game - self.updater.setAutomaticUpdatesCheck(True) + self.updater.set_automatic_updates_check(True) logger.info('Monitor: Enable WinSparkle automatic update checks') def auth(self, event=None) -> None: diff --git a/config/linux.py b/config/linux.py index 58c0f7cd..04087b32 100644 --- a/config/linux.py +++ b/config/linux.py @@ -3,7 +3,7 @@ import os import pathlib import sys from configparser import ConfigParser -from typing import TYPE_CHECKING, List, Optional, Union +from typing import List, Optional, Union from config import AbstractConfig, appname, logger diff --git a/journal_lock.py b/journal_lock.py index ef5cf983..b8882a51 100644 --- a/journal_lock.py +++ b/journal_lock.py @@ -133,7 +133,7 @@ class JournalLock: return JournalLockResult.LOCKED - def release_lock(self) -> bool: # noqa: CCR001 + def release_lock(self) -> bool: """ Release lock on journal directory. diff --git a/loadout.py b/loadout.py index dabce54b..2e2061a2 100644 --- a/loadout.py +++ b/loadout.py @@ -1,48 +1,59 @@ -# Export ship loadout in Companion API json format +"""Export ship loadout in Companion API json format.""" import json import os -from os.path import join +import pathlib import re import time +from os.path import join +from typing import Optional -from config import config import companion import util_ships +from config import config +from EDMCLogging import get_main_logger + +logger = get_main_logger() -def export(data, filename=None): +def export(data: companion.CAPIData, requested_filename: Optional[str] = None) -> None: + """ + Write Ship Loadout in Companion API JSON format. + :param data: CAPI data containing ship loadout. + :param requested_filename: Name of file to write to. + """ string = json.dumps( companion.ship(data), cls=companion.CAPIDataEncoder, ensure_ascii=False, indent=2, sort_keys=True, separators=(',', ': ') ) # pretty print - if filename: - with open(filename, 'wt') as h: + if requested_filename is not None and requested_filename: + with open(requested_filename, 'wt') as h: h.write(string) return + elif not requested_filename: + logger.error(f"{requested_filename=} is not valid") + return + # Look for last ship of this type ship = util_ships.ship_file_name(data['ship'].get('shipName'), data['ship']['name']) - regexp = re.compile(re.escape(ship) + '\.\d\d\d\d\-\d\d\-\d\dT\d\d\.\d\d\.\d\d\.txt') + regexp = re.compile(re.escape(ship) + r'\.\d\d\d\d\-\d\d\-\d\dT\d\d\.\d\d\.\d\d\.txt') oldfiles = sorted([x for x in os.listdir(config.get_str('outdir')) if regexp.match(x)]) if oldfiles: with open(join(config.get_str('outdir'), oldfiles[-1]), 'rU') as h: if h.read() == string: - return # same as last time - don't write + return # same as last time - don't write - querytime = config.get_int('querytime', default=int(time.time())) + query_time = config.get_int('querytime', default=int(time.time())) # Write - # - # When this is refactored into multi-line CHECK IT WORKS, avoiding the - # brainfart we had with dangling commas in commodity.py:export() !!! - # - filename = join(config.get_str('outdir'), '%s.%s.txt' % (ship, time.strftime('%Y-%m-%dT%H.%M.%S', time.localtime(querytime)))) - # - # When this is refactored into multi-line CHECK IT WORKS, avoiding the - # brainfart we had with dangling commas in commodity.py:export() !!! - # - with open(filename, 'wt') as h: + + with open( + pathlib.Path(config.get_str('outdir')) / pathlib.Path( + ship + '.' + time.strftime('%Y-%m-%dT%H.%M.%S', time.localtime(query_time)) + '.txt' + ), + 'wt' + ) as h: h.write(string) diff --git a/myNotebook.py b/myNotebook.py index 85566a21..eb17bf2f 100644 --- a/myNotebook.py +++ b/myNotebook.py @@ -1,14 +1,19 @@ -# -# Hacks to fix various display issues with notebooks and their child widgets on OSX and Windows. -# - Windows: page background should be White, not SystemButtonFace -# - OSX: page background should be a darker gray than systemWindowBody -# selected tab foreground should be White when the window is active -# +""" +Custom `ttk.Notebook` to fix various display issues. + +Hacks to fix various display issues with notebooks and their child widgets on +OSX and Windows. + +- Windows: page background should be White, not SystemButtonFace +- OSX: page background should be a darker gray than systemWindowBody + selected tab foreground should be White when the window is active + +Entire file may be imported by plugins. +""" import sys import tkinter as tk from tkinter import ttk - -# Entire file may be imported by plugins +from typing import Optional # Can't do this with styles on OSX - http://www.tkdocs.com/tutorial/styles.html#whydifficult if sys.platform == 'darwin': @@ -22,8 +27,9 @@ elif sys.platform == 'win32': class Notebook(ttk.Notebook): + """Custom ttk.Notebook class to fix some display issues.""" - def __init__(self, master=None, **kw): + def __init__(self, master: Optional[ttk.Frame] = None, **kw): ttk.Notebook.__init__(self, master, **kw) style = ttk.Style() @@ -46,9 +52,13 @@ class Notebook(ttk.Notebook): self.grid(padx=10, pady=10, sticky=tk.NSEW) -class Frame(sys.platform == 'darwin' and tk.Frame or ttk.Frame): +# FIXME: The real fix for this 'dynamic type' would be to split this whole +# thing into being a module with per-platform files, as we've done with config +# That would also make the code cleaner. +class Frame(sys.platform == 'darwin' and tk.Frame or ttk.Frame): # type: ignore + """Custom t(t)k.Frame class to fix some display issues.""" - def __init__(self, master=None, **kw): + def __init__(self, master: Optional[ttk.Frame] = None, **kw): if sys.platform == 'darwin': kw['background'] = kw.pop('background', PAGEBG) tk.Frame.__init__(self, master, **kw) @@ -63,8 +73,9 @@ class Frame(sys.platform == 'darwin' and tk.Frame or ttk.Frame): class Label(tk.Label): + """Custom tk.Label class to fix some display issues.""" - def __init__(self, master=None, **kw): + def __init__(self, master: Optional[ttk.Frame] = None, **kw): if sys.platform in ['darwin', 'win32']: kw['foreground'] = kw.pop('foreground', PAGEFG) kw['background'] = kw.pop('background', PAGEBG) @@ -74,9 +85,10 @@ class Label(tk.Label): tk.Label.__init__(self, master, **kw) # Just use tk.Label on all platforms -class Entry(sys.platform == 'darwin' and tk.Entry or ttk.Entry): +class Entry(sys.platform == 'darwin' and tk.Entry or ttk.Entry): # type: ignore + """Custom t(t)k.Entry class to fix some display issues.""" - def __init__(self, master=None, **kw): + def __init__(self, master: Optional[ttk.Frame] = None, **kw): if sys.platform == 'darwin': kw['highlightbackground'] = kw.pop('highlightbackground', PAGEBG) tk.Entry.__init__(self, master, **kw) @@ -84,9 +96,10 @@ class Entry(sys.platform == 'darwin' and tk.Entry or ttk.Entry): ttk.Entry.__init__(self, master, **kw) -class Button(sys.platform == 'darwin' and tk.Button or ttk.Button): +class Button(sys.platform == 'darwin' and tk.Button or ttk.Button): # type: ignore + """Custom t(t)k.Button class to fix some display issues.""" - def __init__(self, master=None, **kw): + def __init__(self, master: Optional[ttk.Frame] = None, **kw): if sys.platform == 'darwin': kw['highlightbackground'] = kw.pop('highlightbackground', PAGEBG) tk.Button.__init__(self, master, **kw) @@ -96,9 +109,10 @@ class Button(sys.platform == 'darwin' and tk.Button or ttk.Button): ttk.Button.__init__(self, master, **kw) -class ColoredButton(sys.platform == 'darwin' and tk.Label or tk.Button): +class ColoredButton(sys.platform == 'darwin' and tk.Label or tk.Button): # type: ignore + """Custom t(t)k.ColoredButton class to fix some display issues.""" - def __init__(self, master=None, **kw): + def __init__(self, master: Optional[ttk.Frame] = None, **kw): if sys.platform == 'darwin': # Can't set Button background on OSX, so use a Label instead kw['relief'] = kw.pop('relief', tk.RAISED) @@ -113,9 +127,10 @@ class ColoredButton(sys.platform == 'darwin' and tk.Label or tk.Button): self._command() -class Checkbutton(sys.platform == 'darwin' and tk.Checkbutton or ttk.Checkbutton): +class Checkbutton(sys.platform == 'darwin' and tk.Checkbutton or ttk.Checkbutton): # type: ignore + """Custom t(t)k.Checkbutton class to fix some display issues.""" - def __init__(self, master=None, **kw): + def __init__(self, master: Optional[ttk.Frame] = None, **kw): if sys.platform == 'darwin': kw['foreground'] = kw.pop('foreground', PAGEFG) kw['background'] = kw.pop('background', PAGEBG) @@ -126,9 +141,10 @@ class Checkbutton(sys.platform == 'darwin' and tk.Checkbutton or ttk.Checkbutton ttk.Checkbutton.__init__(self, master, **kw) -class Radiobutton(sys.platform == 'darwin' and tk.Radiobutton or ttk.Radiobutton): +class Radiobutton(sys.platform == 'darwin' and tk.Radiobutton or ttk.Radiobutton): # type: ignore + """Custom t(t)k.Radiobutton class to fix some display issues.""" - def __init__(self, master=None, **kw): + def __init__(self, master: Optional[ttk.Frame] = None, **kw): if sys.platform == 'darwin': kw['foreground'] = kw.pop('foreground', PAGEFG) kw['background'] = kw.pop('background', PAGEBG) @@ -139,7 +155,8 @@ class Radiobutton(sys.platform == 'darwin' and tk.Radiobutton or ttk.Radiobutton ttk.Radiobutton.__init__(self, master, **kw) -class OptionMenu(sys.platform == 'darwin' and tk.OptionMenu or ttk.OptionMenu): +class OptionMenu(sys.platform == 'darwin' and tk.OptionMenu or ttk.OptionMenu): # type: ignore + """Custom t(t)k.OptionMenu class to fix some display issues.""" def __init__(self, master, variable, default=None, *values, **kw): if sys.platform == 'darwin': diff --git a/plug.py b/plug.py index 2f3ab888..b1c0d3da 100644 --- a/plug.py +++ b/plug.py @@ -1,6 +1,4 @@ -""" -Plugin hooks for EDMC - Ian Norton, Jonathan Harris -""" +"""Plugin API.""" import copy import importlib import logging @@ -9,8 +7,9 @@ import os import sys import tkinter as tk from builtins import object, str -from typing import Optional +from typing import Any, Callable, List, Mapping, MutableMapping, Optional, Tuple +import companion import myNotebook as nb # noqa: N813 from config import config from EDMCLogging import get_main_logger @@ -21,34 +20,48 @@ logger = get_main_logger() PLUGINS = [] PLUGINS_not_py3 = [] + # For asynchronous error display -last_error = { - 'msg': None, - 'root': None, -} +class LastError: + """Holds the last plugin error.""" + + msg: Optional[str] + root: tk.Frame + + def __init__(self) -> None: + self.msg = None + + +last_error = LastError() class Plugin(object): + """An EDMC plugin.""" - def __init__(self, name: str, loadfile: str, plugin_logger: Optional[logging.Logger]): + def __init__(self, name: str, loadfile: Optional[str], plugin_logger: Optional[logging.Logger]): """ - Load a single plugin - :param name: module name - :param loadfile: the main .py file + Load a single plugin. + + :param name: Base name of the file being loaded from. + :param loadfile: Full path/filename of the plugin. + :param plugin_logger: The logging instance for this plugin to use. :raises Exception: Typically ImportError or OSError """ - - self.name = name # Display name. - self.folder = name # basename of plugin folder. None for internal plugins. + self.name: str = name # Display name. + self.folder: Optional[str] = name # basename of plugin folder. None for internal plugins. self.module = None # None for disabled plugins. - self.logger = plugin_logger + self.logger: Optional[logging.Logger] = plugin_logger if loadfile: logger.info(f'loading plugin "{name.replace(".", "_")}" from "{loadfile}"') try: - module = importlib.machinery.SourceFileLoader('plugin_{}'.format( - name.encode(encoding='ascii', errors='replace').decode('utf-8').replace('.', '_')), - loadfile).load_module() + filename = 'plugin_' + filename += name.encode(encoding='ascii', errors='replace').decode('utf-8').replace('.', '_') + module = importlib.machinery.SourceFileLoader( + filename, + loadfile + ).load_module() + if getattr(module, 'plugin_start3', None): newname = module.plugin_start3(os.path.dirname(loadfile)) self.name = newname and str(newname) or name @@ -58,23 +71,25 @@ class Plugin(object): PLUGINS_not_py3.append(self) else: logger.error(f'plugin {name} has no plugin_start3() function') - except Exception as e: + except Exception: logger.exception(f': Failed for Plugin "{name}"') raise else: logger.info(f'plugin {name} disabled') - def _get_func(self, funcname): + def _get_func(self, funcname: str) -> Optional[Callable]: """ - Get a function from a plugin + Get a function from a plugin. + :param funcname: :returns: The function, or None if it isn't implemented. """ return getattr(self.module, funcname, None) - def get_app(self, parent): + def get_app(self, parent: tk.Frame) -> Optional[tk.Frame]: """ If the plugin provides mainwindow content create and return it. + :param parent: the parent frame for this entry. :returns: None, a tk Widget, or a pair of tk.Widgets """ @@ -84,19 +99,29 @@ class Plugin(object): appitem = plugin_app(parent) if appitem is None: return None + elif isinstance(appitem, tuple): - if len(appitem) != 2 or not isinstance(appitem[0], tk.Widget) or not isinstance(appitem[1], tk.Widget): + if ( + len(appitem) != 2 + or not isinstance(appitem[0], tk.Widget) + or not isinstance(appitem[1], tk.Widget) + ): raise AssertionError + elif not isinstance(appitem, tk.Widget): raise AssertionError + return appitem - except Exception as e: + + except Exception: logger.exception(f'Failed for Plugin "{self.name}"') + return None - def get_prefs(self, parent, cmdr, is_beta): + def get_prefs(self, parent: tk.Frame, cmdr: str, is_beta: bool) -> Optional[tk.Frame]: """ If the plugin provides a prefs frame, create and return it. + :param parent: the parent frame for this preference tab. :param cmdr: current Cmdr name (or None). Relevant if you want to have different settings for different user accounts. @@ -110,16 +135,14 @@ class Plugin(object): if not isinstance(frame, nb.Frame): raise AssertionError return frame - except Exception as e: + except Exception: logger.exception(f'Failed for Plugin "{self.name}"') return None -def load_plugins(master): - """ - Find and load all plugins - """ - last_error['root'] = master +def load_plugins(master: tk.Frame) -> None: # noqa: CCR001 + """Find and load all plugins.""" + last_error.root = master internal = [] for name in sorted(os.listdir(config.internal_plugin_dir_path)): @@ -128,7 +151,7 @@ def load_plugins(master): plugin = Plugin(name[:-3], os.path.join(config.internal_plugin_dir_path, name), logger) plugin.folder = None # Suppress listing in Plugins prefs tab internal.append(plugin) - except Exception as e: + except Exception: logger.exception(f'Failure loading internal Plugin "{name}"') PLUGINS.extend(sorted(internal, key=lambda p: operator.attrgetter('name')(p).lower())) @@ -137,8 +160,10 @@ def load_plugins(master): found = [] # Load any plugins that are also packages first - for name in sorted(os.listdir(config.plugin_dir_path), - key=lambda n: (not os.path.isfile(os.path.join(config.plugin_dir_path, n, '__init__.py')), n.lower())): + for name in sorted( + os.listdir(config.plugin_dir_path), + key=lambda n: (not os.path.isfile(os.path.join(config.plugin_dir_path, n, '__init__.py')), n.lower()) + ): if not os.path.isdir(os.path.join(config.plugin_dir_path, name)) or name[0] in ['.', '_']: pass elif name.endswith('.disabled'): @@ -155,15 +180,16 @@ def load_plugins(master): plugin_logger = EDMCLogging.get_plugin_logger(name) found.append(Plugin(name, os.path.join(config.plugin_dir_path, name, 'load.py'), plugin_logger)) - except Exception as e: + except Exception: logger.exception(f'Failure loading found Plugin "{name}"') pass PLUGINS.extend(sorted(found, key=lambda p: operator.attrgetter('name')(p).lower())) -def provides(fn_name): +def provides(fn_name: str) -> List[str]: """ - Find plugins that provide a function + Find plugins that provide a function. + :param fn_name: :returns: list of names of plugins that provide this function .. versionadded:: 3.0.2 @@ -171,9 +197,12 @@ def provides(fn_name): return [p.name for p in PLUGINS if p._get_func(fn_name)] -def invoke(plugin_name, fallback, fn_name, *args): +def invoke( + plugin_name: str, fallback: str, fn_name: str, *args: Tuple +) -> Optional[str]: """ - Invoke a function on a named plugin + Invoke a function on a named plugin. + :param plugin_name: preferred plugin on which to invoke the function :param fallback: fallback plugin on which to invoke the function, or None :param fn_name: @@ -182,17 +211,24 @@ def invoke(plugin_name, fallback, fn_name, *args): .. versionadded:: 3.0.2 """ for plugin in PLUGINS: - if plugin.name == plugin_name and plugin._get_func(fn_name): - return plugin._get_func(fn_name)(*args) + if plugin.name == plugin_name: + plugin_func = plugin._get_func(fn_name) + if plugin_func is not None: + return plugin_func(*args) + for plugin in PLUGINS: if plugin.name == fallback: - assert plugin._get_func(fn_name), plugin.name # fallback plugin should provide the function - return plugin._get_func(fn_name)(*args) + plugin_func = plugin._get_func(fn_name) + assert plugin_func, plugin.name # fallback plugin should provide the function + return plugin_func(*args) + + return None -def notify_stop(): +def notify_stop() -> Optional[str]: """ Notify each plugin that the program is closing. + If your plugin uses threads then stop and join() them before returning. .. versionadded:: 2.3.7 """ @@ -204,7 +240,7 @@ def notify_stop(): logger.info(f'Asking plugin "{plugin.name}" to stop...') newerror = plugin_stop() error = error or newerror - except Exception as e: + except Exception: logger.exception(f'Plugin "{plugin.name}" failed') logger.info('Done') @@ -212,9 +248,10 @@ def notify_stop(): return error -def notify_prefs_cmdr_changed(cmdr, is_beta): +def notify_prefs_cmdr_changed(cmdr: str, is_beta: bool) -> None: """ - Notify each plugin that the Cmdr has been changed while the settings dialog is open. + Notify plugins that the Cmdr was changed while the settings dialog is open. + Relevant if you want to have different settings for different user accounts. :param cmdr: current Cmdr name (or None). :param is_beta: whether the player is in a Beta universe. @@ -224,13 +261,14 @@ def notify_prefs_cmdr_changed(cmdr, is_beta): if prefs_cmdr_changed: try: prefs_cmdr_changed(cmdr, is_beta) - except Exception as e: + except Exception: logger.exception(f'Plugin "{plugin.name}" failed') -def notify_prefs_changed(cmdr, is_beta): +def notify_prefs_changed(cmdr: str, is_beta: bool) -> None: """ - Notify each plugin that the settings dialog has been closed. + Notify plugins that the settings dialog has been closed. + The prefs frame and any widgets you created in your `get_prefs()` callback will be destroyed on return from this function, so take a copy of any values that you want to save. @@ -242,13 +280,18 @@ def notify_prefs_changed(cmdr, is_beta): if prefs_changed: try: prefs_changed(cmdr, is_beta) - except Exception as e: + except Exception: logger.exception(f'Plugin "{plugin.name}" failed') -def notify_journal_entry(cmdr, is_beta, system, station, entry, state): +def notify_journal_entry( + cmdr: str, is_beta: bool, system: str, station: str, + entry: MutableMapping[str, Any], + state: Mapping[str, Any] +) -> Optional[str]: """ Send a journal entry to each plugin. + :param cmdr: The Cmdr name, or None if not yet known :param system: The current system, or None if not yet known :param station: The current station, or None if not docked or not yet known @@ -268,21 +311,25 @@ def notify_journal_entry(cmdr, is_beta, system, station, entry, state): # Pass a copy of the journal entry in case the callee modifies it newerror = journal_entry(cmdr, is_beta, system, station, dict(entry), dict(state)) error = error or newerror - except Exception as e: + except Exception: logger.exception(f'Plugin "{plugin.name}" failed') return error -def notify_journal_entry_cqc(cmdr, is_beta, entry, state): +def notify_journal_entry_cqc( + cmdr: str, is_beta: bool, + entry: MutableMapping[str, Any], + state: Mapping[str, Any] +) -> Optional[str]: """ - Send a journal entry to each plugin. + Send an in-CQC journal entry to each plugin. + :param cmdr: The Cmdr name, or None if not yet known :param entry: The journal entry as a dictionary :param state: A dictionary containing info about the Cmdr, current ship and cargo :param is_beta: whether the player is in a Beta universe. :returns: Error message from the first plugin that returns one (if any) """ - error = None for plugin in PLUGINS: cqc_callback = plugin._get_func('journal_entry_cqc') @@ -298,9 +345,13 @@ def notify_journal_entry_cqc(cmdr, is_beta, entry, state): return error -def notify_dashboard_entry(cmdr, is_beta, entry): +def notify_dashboard_entry( + cmdr: str, is_beta: bool, + entry: MutableMapping[str, Any], +) -> Optional[str]: """ Send a status entry to each plugin. + :param cmdr: The piloting Cmdr name :param is_beta: whether the player is in a Beta universe. :param entry: The status entry as a dictionary @@ -314,14 +365,18 @@ def notify_dashboard_entry(cmdr, is_beta, entry): # Pass a copy of the status entry in case the callee modifies it newerror = status(cmdr, is_beta, dict(entry)) error = error or newerror - except Exception as e: + except Exception: logger.exception(f'Plugin "{plugin.name}" failed') return error -def notify_newdata(data, is_beta): +def notify_newdata( + data: companion.CAPIData, + is_beta: bool +) -> Optional[str]: """ - Send the latest EDMC data from the FD servers to each plugin + Send the latest EDMC data from the FD servers to each plugin. + :param data: :param is_beta: whether the player is in a Beta universe. :returns: Error message from the first plugin that returns one (if any) @@ -333,12 +388,12 @@ def notify_newdata(data, is_beta): try: newerror = cmdr_data(data, is_beta) error = error or newerror - except Exception as e: + except Exception: logger.exception(f'Plugin "{plugin.name}" failed') return error -def show_error(err): +def show_error(err: str) -> None: """ Display an error message in the status line of the main window. @@ -350,6 +405,6 @@ def show_error(err): logger.info(f'Called during shutdown: "{str(err)}"') return - if err and last_error['root']: - last_error['msg'] = str(err) - last_error['root'].event_generate('<>', when="tail") + if err and last_error.root: + last_error.msg = str(err) + last_error.root.event_generate('<>', when="tail") diff --git a/plugins/eddb.py b/plugins/eddb.py index 111d768c..e050df32 100644 --- a/plugins/eddb.py +++ b/plugins/eddb.py @@ -1,8 +1,4 @@ -# -*- coding: utf-8 -*- -# -# Station display and eddb.io lookup -# - +"""Station display and eddb.io lookup.""" # Tests: # # As there's a lot of state tracking in here, need to ensure (at least) @@ -38,15 +34,15 @@ # Thus you **MUST** check if any imports you add in this file are only # referenced in this file (or only in any other core plugin), and if so... # -# YOU MUST ENSURE THAT PERTINENT ADJUSTMENTS ARE MADE IN `setup.py` -# SO AS TO ENSURE THE FILES ARE ACTUALLY PRESENT IN AN END-USER -# INSTALLATION ON WINDOWS. +# YOU MUST ENSURE THAT PERTINENT ADJUSTMENTS ARE MADE IN +# `Build-exe-and-msi.py` SO AS TO ENSURE THE FILES ARE ACTUALLY PRESENT IN +# AN END-USER INSTALLATION ON WINDOWS. # # # ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# # ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# -import sys -from typing import TYPE_CHECKING, Any, Optional +import tkinter +from typing import TYPE_CHECKING, Any, Mapping, MutableMapping, Optional import requests @@ -59,26 +55,40 @@ from config import config if TYPE_CHECKING: from tkinter import Tk + def _(x: str) -> str: + return x logger = EDMCLogging.get_main_logger() -STATION_UNDOCKED: str = '×' # "Station" name to display when not docked = U+00D7 +class This: + """Holds module globals.""" -this: Any = sys.modules[__name__] # For holding module globals + STATION_UNDOCKED: str = '×' # "Station" name to display when not docked = U+00D7 -# Main window clicks -this.system_link: Optional[str] = None -this.system: Optional[str] = None -this.system_address: Optional[str] = None -this.system_population: Optional[int] = None -this.station_link: 'Optional[Tk]' = None -this.station: Optional[str] = None -this.station_marketid: Optional[int] = None -this.on_foot = False + def __init__(self) -> None: + # Main window clicks + self.system_link: tkinter.Widget + self.system: Optional[str] = None + self.system_address: Optional[str] = None + self.system_population: Optional[int] = None + self.station_link: tkinter.Widget + self.station: Optional[str] = None + self.station_marketid: Optional[int] = None + self.on_foot = False + + +this = This() def system_url(system_name: str) -> str: + """ + Construct an appropriate EDDB.IO URL for the provided system. + + :param system_name: Will be overridden with `this.system_address` if that + is set. + :return: The URL, empty if no data was available to construct it. + """ if this.system_address: return requests.utils.requote_uri(f'https://eddb.io/system/ed-address/{this.system_address}') @@ -89,33 +99,75 @@ def system_url(system_name: str) -> str: def station_url(system_name: str, station_name: str) -> str: + """ + Construct an appropriate EDDB.IO URL for a station. + + Ignores `station_name` in favour of `this.station_marketid`. + + :param system_name: Name of the system the station is in. + :param station_name: **NOT USED** + :return: The URL, empty if no data was available to construct it. + """ if this.station_marketid: return requests.utils.requote_uri(f'https://eddb.io/station/market-id/{this.station_marketid}') return system_url(system_name) -def plugin_start3(plugin_dir): +def plugin_start3(plugin_dir: str) -> str: + """ + Start the plugin. + + :param plugin_dir: NAme of directory this was loaded from. + :return: Identifier string for this plugin. + """ return 'eddb' def plugin_app(parent: 'Tk'): + """ + Construct this plugin's main UI, if any. + + :param parent: The tk parent to place our widgets into. + :return: See PLUGINS.md#display + """ this.system_link = parent.children['system'] # system label in main window this.system = None this.system_address = None this.station = None this.station_marketid = None # Frontier MarketID this.station_link = parent.children['station'] # station label in main window - this.station_link.configure(popup_copy=lambda x: x != STATION_UNDOCKED) + this.station_link['popup_copy'] = lambda x: x != this.STATION_UNDOCKED -def prefs_changed(cmdr, is_beta): +def prefs_changed(cmdr: str, is_beta: bool) -> None: + """ + Update any saved configuration after Settings is closed. + + :param cmdr: Name of Commander. + :param is_beta: If game beta was detected. + """ # Do *NOT* set 'url' here, as it's set to a function that will call # through correctly. We don't want a static string. pass -def journal_entry(cmdr, is_beta, system, station, entry, state): +def journal_entry( # noqa: CCR001 + cmdr: str, is_beta: bool, system: str, station: str, + entry: MutableMapping[str, Any], + state: Mapping[str, Any] +): + """ + Handle a new Journal event. + + :param cmdr: Name of Commander. + :param is_beta: Whether game beta was detected. + :param system: Name of current tracked system. + :param station: Name of current tracked station location. + :param entry: The journal event. + :param state: `monitor.state` + :return: None if no error, else an error string. + """ should_return, new_entry = killswitch.check_killswitch('plugins.eddb.journal', entry) if should_return: # LANG: Journal Processing disabled due to an active killswitch @@ -168,7 +220,7 @@ def journal_entry(cmdr, is_beta, system, station, entry, state): text = this.station if not text: if this.system_population is not None and this.system_population > 0: - text = STATION_UNDOCKED + text = this.STATION_UNDOCKED else: text = '' @@ -179,7 +231,14 @@ def journal_entry(cmdr, is_beta, system, station, entry, state): this.station_link.update_idletasks() -def cmdr_data(data: CAPIData, is_beta): +def cmdr_data(data: CAPIData, is_beta: bool) -> Optional[str]: + """ + Process new CAPI data. + + :param data: The latest merged CAPI data. + :param is_beta: Whether game beta was detected. + :return: Optional error string. + """ # Always store initially, even if we're not the *current* system provider. if not this.station_marketid and data['commander']['docked']: this.station_marketid = data['lastStarport']['id'] @@ -203,7 +262,7 @@ def cmdr_data(data: CAPIData, is_beta): this.station_link['text'] = this.station elif data['lastStarport']['name'] and data['lastStarport']['name'] != "": - this.station_link['text'] = STATION_UNDOCKED + this.station_link['text'] = this.STATION_UNDOCKED else: this.station_link['text'] = '' @@ -211,3 +270,5 @@ def cmdr_data(data: CAPIData, is_beta): # Do *NOT* set 'url' here, as it's set to a function that will call # through correctly. We don't want a static string. this.station_link.update_idletasks() + + return '' diff --git a/plugins/edsm.py b/plugins/edsm.py index 43ecf3c6..130a794d 100644 --- a/plugins/edsm.py +++ b/plugins/edsm.py @@ -1,4 +1,4 @@ -"""System display and EDSM lookup.""" +"""Show EDSM data in display and handle lookups.""" # TODO: # 1) Re-factor EDSM API calls out of journal_entry() into own function. @@ -24,9 +24,9 @@ # Thus you **MUST** check if any imports you add in this file are only # referenced in this file (or only in any other core plugin), and if so... # -# YOU MUST ENSURE THAT PERTINENT ADJUSTMENTS ARE MADE IN `setup.py` -# SO AS TO ENSURE THE FILES ARE ACTUALLY PRESENT IN AN END-USER -# INSTALLATION ON WINDOWS. +# YOU MUST ENSURE THAT PERTINENT ADJUSTMENTS ARE MADE IN +# `Build-exe-and-msi.py` SO AS TO ENSURE THE FILES ARE ACTUALLY PRESENT IN +# AN END-USER INSTALLATION ON WINDOWS. # # # ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# @@ -154,7 +154,13 @@ plEAADs= # Main window clicks def system_url(system_name: str) -> str: - """Get a URL for the current system.""" + """ + Construct an appropriate EDSM URL for the provided system. + + :param system_name: Will be overridden with `this.system_address` if that + is set. + :return: The URL, empty if no data was available to construct it. + """ if this.system_address: return requests.utils.requote_uri(f'https://www.edsm.net/en/system?systemID64={this.system_address}') @@ -165,7 +171,13 @@ def system_url(system_name: str) -> str: def station_url(system_name: str, station_name: str) -> str: - """Get a URL for the current station.""" + """ + Construct an appropriate EDSM URL for a station. + + :param system_name: Name of the system the station is in. + :param station_name: Name of the station. + :return: The URL, empty if no data was available to construct it. + """ if system_name and station_name: return requests.utils.requote_uri( f'https://www.edsm.net/en/system?systemName={system_name}&stationName={station_name}' @@ -186,7 +198,12 @@ def station_url(system_name: str, station_name: str) -> str: def plugin_start3(plugin_dir: str) -> str: - """Plugin setup hook.""" + """ + Start the plugin. + + :param plugin_dir: NAme of directory this was loaded from. + :return: Identifier string for this plugin. + """ # Can't be earlier since can only call PhotoImage after window is created this._IMG_KNOWN = tk.PhotoImage(data=IMG_KNOWN_B64) # green circle this._IMG_UNKNOWN = tk.PhotoImage(data=IMG_UNKNOWN_B64) # red circle @@ -225,7 +242,12 @@ def plugin_start3(plugin_dir: str) -> str: def plugin_app(parent: tk.Tk) -> None: - """Plugin UI setup.""" + """ + Construct this plugin's main UI, if any. + + :param parent: The tk parent to place our widgets into. + :return: See PLUGINS.md#display + """ this.system_link = parent.children['system'] # system label in main window this.system_link.bind_all('<>', update_status) this.station_link = parent.children['station'] # station label in main window @@ -246,7 +268,17 @@ def plugin_stop() -> None: def plugin_prefs(parent: tk.Tk, cmdr: str, is_beta: bool) -> tk.Frame: - """Plugin preferences setup hook.""" + """ + Plugin preferences setup hook. + + Any tkinter UI set up *must* be within an instance of `myNotebook.Frame`, + which is the return value of this function. + + :param parent: tkinter Widget to place items in. + :param cmdr: Name of Commander. + :param is_beta: Whether game beta was detected. + :return: An instance of `myNotebook.Frame`. + """ PADX = 10 # noqa: N806 BUTTONX = 12 # indent Checkbuttons and Radiobuttons # noqa: N806 PADY = 2 # close spacing # noqa: N806 @@ -313,7 +345,12 @@ def plugin_prefs(parent: tk.Tk, cmdr: str, is_beta: bool) -> tk.Frame: def prefs_cmdr_changed(cmdr: str, is_beta: bool) -> None: - """Commanders changed hook.""" + """ + Handle the Commander name changing whilst Settings was open. + + :param cmdr: The new current Commander name. + :param is_beta: Whether game beta was detected. + """ this.log_button['state'] = tk.NORMAL if cmdr and not is_beta else tk.DISABLED this.user['state'] = tk.NORMAL this.user.delete(0, tk.END) @@ -339,7 +376,7 @@ def prefs_cmdr_changed(cmdr: str, is_beta: bool) -> None: def prefsvarchanged() -> None: - """Preferences screen closed hook.""" + """Handle the 'Send data to EDSM' tickbox changing state.""" to_set = tk.DISABLED if this.log.get(): to_set = this.log_button['state'] @@ -363,7 +400,12 @@ def set_prefs_ui_states(state: str) -> None: def prefs_changed(cmdr: str, is_beta: bool) -> None: - """Preferences changed hook.""" + """ + Handle any changes to Settings once the dialog is closed. + + :param cmdr: Name of Commander. + :param is_beta: Whether game beta was detected. + """ config.set('edsm_out', this.log.get()) if cmdr and not is_beta: @@ -427,15 +469,15 @@ def journal_entry( # noqa: C901, CCR001 cmdr: str, is_beta: bool, system: str, station: str, entry: MutableMapping[str, Any], state: Mapping[str, Any] ) -> str: """ - Process a Journal event. + Handle a new Journal event. - :param cmdr: - :param is_beta: - :param system: - :param station: - :param entry: - :param state: - :return: str - empty if no error, else error string. + :param cmdr: Name of Commander. + :param is_beta: Whether game beta was detected. + :param system: Name of current tracked system. + :param station: Name of current tracked station location. + :param entry: The journal event. + :param state: `monitor.state` + :return: None if no error, else an error string. """ should_return, new_entry = killswitch.check_killswitch('plugins.edsm.journal', entry, logger) if should_return: @@ -597,8 +639,14 @@ Queueing: {entry!r}''' # Update system data -def cmdr_data(data: CAPIData, is_beta: bool) -> None: - """CAPI Entry Hook.""" +def cmdr_data(data: CAPIData, is_beta: bool) -> Optional[str]: + """ + Process new CAPI data. + + :param data: The latest merged CAPI data. + :param is_beta: Whether game beta was detected. + :return: Optional error string. + """ system = data['lastSystem']['name'] # Always store initially, even if we're not the *current* system provider. @@ -640,6 +688,8 @@ def cmdr_data(data: CAPIData, is_beta: bool) -> None: this.system_link['image'] = '' this.system_link.update_idletasks() + return '' + TARGET_URL = 'https://www.edsm.net/api-journal-v1' if 'edsm' in debug_senders: diff --git a/plugins/edsy.py b/plugins/edsy.py index df61683e..5ceb21d5 100644 --- a/plugins/edsy.py +++ b/plugins/edsy.py @@ -1,4 +1,4 @@ -# EDShipyard ship export +"""Export data for ED Shipyard.""" # ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# # ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# @@ -15,25 +15,41 @@ # Thus you **MUST** check if any imports you add in this file are only # referenced in this file (or only in any other core plugin), and if so... # -# YOU MUST ENSURE THAT PERTINENT ADJUSTMENTS ARE MADE IN `setup.py` -# SO AS TO ENSURE THE FILES ARE ACTUALLY PRESENT IN AN END-USER -# INSTALLATION ON WINDOWS. +# YOU MUST ENSURE THAT PERTINENT ADJUSTMENTS ARE MADE IN +# `Build-exe-and-msi.py` SO AS TO ENSURE THE FILES ARE ACTUALLY PRESENT IN +# AN END-USER INSTALLATION ON WINDOWS. # # # ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# # ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# import base64 import gzip -import json import io +import json +from typing import Any, Mapping -def plugin_start3(plugin_dir): +def plugin_start3(plugin_dir: str) -> str: + """ + Start the plugin. + + :param plugin_dir: NAme of directory this was loaded from. + :return: Identifier string for this plugin. + """ return 'EDSY' + # Return a URL for the current ship -def shipyard_url(loadout, is_beta): - string = json.dumps(loadout, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode('utf-8') # most compact representation +def shipyard_url(loadout: Mapping[str, Any], is_beta) -> bool | str: + """ + Construct a URL for ship loadout. + + :param loadout: + :param is_beta: + :return: + """ + # most compact representation + string = json.dumps(loadout, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode('utf-8') if not string: return False @@ -41,4 +57,6 @@ def shipyard_url(loadout, is_beta): with gzip.GzipFile(fileobj=out, mode='w') as f: f.write(string) - return (is_beta and 'http://edsy.org/beta/#/I=' or 'http://edsy.org/#/I=') + base64.urlsafe_b64encode(out.getvalue()).decode().replace('=', '%3D') + return ( + is_beta and 'http://edsy.org/beta/#/I=' or 'http://edsy.org/#/I=' + ) + base64.urlsafe_b64encode(out.getvalue()).decode().replace('=', '%3D') diff --git a/protocol.py b/protocol.py index 6db5d5be..290a7031 100644 --- a/protocol.py +++ b/protocol.py @@ -1,5 +1,4 @@ """protocol handler for cAPI authorisation.""" - # spell-checker: words ntdll GURL alloc wfile instantiatable pyright import os import sys @@ -35,7 +34,7 @@ class GenericProtocolHandler: def __init__(self) -> None: self.redirect = protocolhandler_redirect # Base redirection URL self.master: 'tkinter.Tk' = None # type: ignore - self.lastpayload = None + self.lastpayload: Optional[str] = None def start(self, master: 'tkinter.Tk') -> None: """Start Protocol Handler.""" @@ -45,7 +44,7 @@ class GenericProtocolHandler: """Stop / Close Protocol Handler.""" pass - def event(self, url) -> None: + def event(self, url: str) -> None: """Generate an auth event.""" self.lastpayload = url @@ -107,11 +106,12 @@ if sys.platform == 'darwin' and getattr(sys, 'frozen', False): # noqa: C901 # i def handleEvent_withReplyEvent_(self, event, replyEvent) -> None: # noqa: N802 N803 # Required to override """Actual event handling from NSAppleEventManager.""" - protocolhandler.lasturl = urllib.parse.unquote( # type: ignore # Its going to be a DPH in this code + protocolhandler.lasturl = urllib.parse.unquote( # noqa: F821: type: ignore # Its going to be a DPH in + # this code event.paramDescriptorForKeyword_(keyDirectObject).stringValue() ).strip() - protocolhandler.master.after(DarwinProtocolHandler.POLL, protocolhandler.poll) # type: ignore + protocolhandler.master.after(DarwinProtocolHandler.POLL, protocolhandler.poll) # noqa: F821: type: ignore elif (config.auth_force_edmc_protocol @@ -196,7 +196,7 @@ elif (config.auth_force_edmc_protocol # Windows Message handler stuff (IPC) # https://docs.microsoft.com/en-us/previous-versions/windows/desktop/legacy/ms633573(v=vs.85) @WINFUNCTYPE(c_long, HWND, UINT, WPARAM, LPARAM) - def WndProc(hwnd: HWND, message: UINT, wParam, lParam): # noqa: N803 N802 + def WndProc(hwnd: HWND, message: UINT, wParam: WPARAM, lParam: LPARAM) -> c_long: # noqa: N803 N802 """ Deal with DDE requests. @@ -215,8 +215,10 @@ elif (config.auth_force_edmc_protocol topic = create_unicode_buffer(256) # Note that lParam is 32 bits, and broken into two 16 bit words. This will break on 64bit as the math is # wrong - lparam_low = lParam & 0xFFFF # if nonzero, the target application for which a conversation is requested - lparam_high = lParam >> 16 # if nonzero, the topic of said conversation + # if nonzero, the target application for which a conversation is requested + lparam_low = lParam & 0xFFFF # type: ignore + # if nonzero, the topic of said conversation + lparam_high = lParam >> 16 # type: ignore # if either of the words are nonzero, they contain # atoms https://docs.microsoft.com/en-us/windows/win32/dataxchg/about-atom-tables @@ -236,7 +238,10 @@ elif (config.auth_force_edmc_protocol wParam, WM_DDE_ACK, hwnd, PackDDElParam(WM_DDE_ACK, GlobalAddAtomW(appname), GlobalAddAtomW('System')) ) - return 0 + # It works as a constructor as per + return c_long(0) + + return c_long(1) # This is an utter guess -Ath class WindowsProtocolHandler(GenericProtocolHandler): """ @@ -349,7 +354,7 @@ else: # Linux / Run from source self.thread: Optional[threading.Thread] = None - def start(self, master) -> None: + def start(self, master: 'tkinter.Tk') -> None: """Start the HTTP server thread.""" GenericProtocolHandler.start(self, master) self.thread = threading.Thread(target=self.worker, name='OAuth worker') @@ -389,7 +394,7 @@ else: # Linux / Run from source url = urllib.parse.unquote(self.path) if url.startswith('/auth'): logger.debug('Request starts with /auth, sending to protocolhandler.event()') - protocolhandler.event(url) + protocolhandler.event(url) # noqa: F821 self.send_response(200) return True else: @@ -411,7 +416,7 @@ else: # Linux / Run from source else: self.end_headers() - def log_request(self, code, size=None): + def log_request(self, code: int | str = '-', size: int | str = '-') -> None: """Override to prevent logging.""" pass diff --git a/scripts/killswitch_test.py b/scripts/killswitch_test.py index 1b2f54a7..b6874e50 100644 --- a/scripts/killswitch_test.py +++ b/scripts/killswitch_test.py @@ -29,7 +29,7 @@ KNOWN_KILLSWITCH_NAMES: list[str] = [ 'plugins.eddb.journal.event.$event' ] -SPLIT_KNOWN_NAMES = list(map(lambda x: x.split('.'), KNOWN_KILLSWITCH_NAMES)) +SPLIT_KNOWN_NAMES = [x.split('.') for x in KNOWN_KILLSWITCH_NAMES] def match_exists(match: str) -> tuple[bool, str]: diff --git a/shipyard.py b/shipyard.py index 78961ad7..a5234c0a 100644 --- a/shipyard.py +++ b/shipyard.py @@ -1,24 +1,35 @@ -# Export list of ships as CSV +"""Export list of ships as CSV.""" +import csv -import time - -from config import config +import companion from edmc_data import ship_name_map -def export(data, filename): - - querytime = config.get_int('querytime', default=int(time.time())) +def export(data: companion.CAPIData, filename: str) -> None: + """ + Write shipyard data in Companion API JSON format. + :param data: The CAPI data. + :param filename: Optional filename to write to. + :return: + """ assert data['lastSystem'].get('name') assert data['lastStarport'].get('name') assert data['lastStarport'].get('ships') - header = 'System,Station,Ship,FDevID,Date\n' - rowheader = '%s,%s' % (data['lastSystem']['name'], data['lastStarport']['name']) + with open(filename, 'w', newline='') as f: + c = csv.writer(f) + c.writerow(('System', 'Station', 'Ship', 'FDevID', 'Date')) - h = open(filename, 'wt') - h.write(header) - for (name,fdevid) in [(ship_name_map.get(ship['name'].lower(), ship['name']), ship['id']) for ship in list((data['lastStarport']['ships'].get('shipyard_list') or {}).values()) + data['lastStarport']['ships'].get('unavailable_list')]: - h.write('%s,%s,%s,%s\n' % (rowheader, name, fdevid, data['timestamp'])) - h.close() + for (name, fdevid) in [ + ( + ship_name_map.get(ship['name'].lower(), ship['name']), + ship['id'] + ) for ship in list( + (data['lastStarport']['ships'].get('shipyard_list') or {}).values() + ) + data['lastStarport']['ships'].get('unavailable_list') + ]: + c.writerow(( + data['lastSystem']['name'], data['lastStarport']['name'], + name, fdevid, data['timestamp'] + )) diff --git a/td.py b/td.py index 2f91b4a2..cd2c0d3e 100644 --- a/td.py +++ b/td.py @@ -1,70 +1,65 @@ -# Export to Trade Dangerous +"""Export data for Trade Dangerous.""" +import pathlib import time from collections import defaultdict from operator import itemgetter -from os.path import join from platform import system -from sys import platform +from companion import CAPIData from config import applongname, appversion, config # These are specific to Trade Dangerous, so don't move to edmc_data.py -demandbracketmap = { 0: '?', - 1: 'L', - 2: 'M', - 3: 'H', } -stockbracketmap = { 0: '-', - 1: 'L', - 2: 'M', - 3: 'H', } +demandbracketmap = {0: '?', + 1: 'L', + 2: 'M', + 3: 'H', } +stockbracketmap = {0: '-', + 1: 'L', + 2: 'M', + 3: 'H', } -def export(data): - querytime = config.get_int('querytime', default=int(time.time())) +def export(data: CAPIData) -> None: + """Export market data in TD format.""" + data_path = pathlib.Path(config.get_str('outdir')) + timestamp = time.strftime('%Y-%m-%dT%H.%M.%S', time.strptime(data['timestamp'], '%Y-%m-%dT%H:%M:%SZ')) + data_filename = f"{data['lastSystem']['name'].strip()}.{data['lastStarport']['name'].strip()}.{timestamp}.prices" - # - # When this is refactored into multi-line CHECK IT WORKS, avoiding the - # brainfart we had with dangling commas in commodity.py:export() !!! - # - filename = join(config.get_str('outdir'), '%s.%s.%s.prices' % (data['lastSystem']['name'].strip(), data['lastStarport']['name'].strip(), time.strftime('%Y-%m-%dT%H.%M.%S', time.localtime(querytime)))) - # - # When this is refactored into multi-line CHECK IT WORKS, avoiding the - # brainfart we had with dangling commas in commodity.py:export() !!! - # + # codecs can't automatically handle line endings, so encode manually where + # required + with open(data_path / data_filename, 'wb') as h: + # Format described here: https://bitbucket.org/kfsone/tradedangerous/wiki/Price%20Data + h.write('#! trade.py import -\n'.encode('utf-8')) + this_platform = 'darwin' and "Mac OS" or system() + cmdr_name = data['commander']['name'].strip() + h.write( + f'# Created by {applongname} {appversion()} on {this_platform} for Cmdr {cmdr_name}.\n'.encode('utf-8') + ) + h.write( + '#\n# \n\n'.encode('utf-8') + ) + system_name = data['lastSystem']['name'].strip() + starport_name = data['lastStarport']['name'].strip() + h.write(f'@ {system_name}/{starport_name}\n'.encode('utf-8')) - timestamp = time.strftime('%Y-%m-%d %H:%M:%S', time.strptime(data['timestamp'], '%Y-%m-%dT%H:%M:%SZ')) + # sort commodities by category + by_category = defaultdict(list) + for commodity in data['lastStarport']['commodities']: + by_category[commodity['categoryname']].append(commodity) - # Format described here: https://bitbucket.org/kfsone/tradedangerous/wiki/Price%20Data - h = open(filename, 'wb') # codecs can't automatically handle line endings, so encode manually where required - h.write('#! trade.py import -\n# Created by {appname} {appversion} on {platform} for Cmdr {cmdr}.\n' - '#\n# \n\n' - '@ {system}/{starport}\n'.format( - appname=applongname, - appversion=appversion(), - platform=platform == 'darwin' and "Mac OS" or system(), - cmdr=data['commander']['name'].strip(), - system=data['lastSystem']['name'].strip(), - starport=data['lastStarport']['name'].strip() - ).encode('utf-8')) - - # sort commodities by category - bycategory = defaultdict(list) - for commodity in data['lastStarport']['commodities']: - bycategory[commodity['categoryname']].append(commodity) - - for category in sorted(bycategory): - h.write(' + {}\n'.format(category).encode('utf-8')) - # corrections to commodity names can change the sort order - for commodity in sorted(bycategory[category], key=itemgetter('name')): - h.write(' {:<23} {:7d} {:7d} {:9}{:1} {:8}{:1} {}\n'.format( - commodity['name'], - int(commodity['sellPrice']), - int(commodity['buyPrice']), - int(commodity['demand']) if commodity['demandBracket'] else '', - demandbracketmap[commodity['demandBracket']], - int(commodity['stock']) if commodity['stockBracket'] else '', - stockbracketmap[commodity['stockBracket']], - timestamp).encode('utf-8')) - - h.close() + timestamp = time.strftime('%Y-%m-%d %H:%M:%S', time.strptime(data['timestamp'], '%Y-%m-%dT%H:%M:%SZ')) + for category in sorted(by_category): + h.write(f' + {format(category)}\n'.encode('utf-8')) + # corrections to commodity names can change the sort order + for commodity in sorted(by_category[category], key=itemgetter('name')): + h.write( + f" {commodity['name']:<23}" + f" {int(commodity['sellPrice']):7d}" + f" {int(commodity['buyPrice']):7d}" + f" {int(commodity['demand']) if commodity['demandBracket'] else '':9}" + f"{demandbracketmap[commodity['demandBracket']]:1}" + f" {int(commodity['stock']) if commodity['stockBracket'] else '':8}" + f"{stockbracketmap[commodity['stockBracket']]:1}" + f" {timestamp}\n".encode('utf-8') + ) diff --git a/theme.py b/theme.py index 4f60f976..2707ad42 100644 --- a/theme.py +++ b/theme.py @@ -1,23 +1,27 @@ -# -# Theme support -# -# Because of various ttk limitations this app is an unholy mix of Tk and ttk widgets. -# So can't use ttk's theme support. So have to change colors manually. -# +""" +Theme support. + +Because of various ttk limitations this app is an unholy mix of Tk and ttk widgets. +So can't use ttk's theme support. So have to change colors manually. +""" import os import sys import tkinter as tk from os.path import join -from tkinter import font as tkFont +from tkinter import font as tk_font from tkinter import ttk +from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Set, Tuple from config import config -from ttkHyperlinkLabel import HyperlinkLabel from EDMCLogging import get_main_logger +from ttkHyperlinkLabel import HyperlinkLabel logger = get_main_logger() +if TYPE_CHECKING: + def _(x: str) -> str: ... + if __debug__: from traceback import print_exc @@ -29,7 +33,7 @@ if sys.platform == 'win32': import ctypes from ctypes.wintypes import DWORD, LPCVOID, LPCWSTR AddFontResourceEx = ctypes.windll.gdi32.AddFontResourceExW - AddFontResourceEx.restypes = [LPCWSTR, DWORD, LPCVOID] + AddFontResourceEx.restypes = [LPCWSTR, DWORD, LPCVOID] # type: ignore FR_PRIVATE = 0x10 FR_NOT_ENUM = 0x20 AddFontResourceEx(join(config.respath, u'EUROCAPS.TTF'), FR_PRIVATE, 0) @@ -65,6 +69,8 @@ elif sys.platform == 'linux': MWM_DECOR_MAXIMIZE = 1 << 6 class MotifWmHints(Structure): + """MotifWmHints structure.""" + _fields_ = [ ('flags', c_ulong), ('functions', c_ulong), @@ -99,33 +105,44 @@ elif sys.platform == 'linux': raise Exception("Can't find your display, can't continue") motif_wm_hints_property = XInternAtom(dpy, b'_MOTIF_WM_HINTS', False) - motif_wm_hints_normal = MotifWmHints(MWM_HINTS_FUNCTIONS | MWM_HINTS_DECORATIONS, - MWM_FUNC_RESIZE | MWM_FUNC_MOVE | MWM_FUNC_MINIMIZE | MWM_FUNC_CLOSE, - MWM_DECOR_BORDER | MWM_DECOR_RESIZEH | MWM_DECOR_TITLE | MWM_DECOR_MENU | MWM_DECOR_MINIMIZE, - 0, 0) + motif_wm_hints_normal = MotifWmHints( + MWM_HINTS_FUNCTIONS | MWM_HINTS_DECORATIONS, + MWM_FUNC_RESIZE | MWM_FUNC_MOVE | MWM_FUNC_MINIMIZE | MWM_FUNC_CLOSE, + MWM_DECOR_BORDER | MWM_DECOR_RESIZEH | MWM_DECOR_TITLE | MWM_DECOR_MENU | MWM_DECOR_MINIMIZE, + 0, 0 + ) motif_wm_hints_dark = MotifWmHints(MWM_HINTS_FUNCTIONS | MWM_HINTS_DECORATIONS, MWM_FUNC_RESIZE | MWM_FUNC_MOVE | MWM_FUNC_MINIMIZE | MWM_FUNC_CLOSE, 0, 0, 0) - except: + except Exception: if __debug__: print_exc() + dpy = None class _Theme(object): - def __init__(self): + # Enum ? Remember these are, probably, based on 'value' of a tk + # RadioButton set. Looking in prefs.py, they *appear* to be hard-coded + # there as well. + THEME_DEFAULT = 0 + THEME_DARK = 1 + THEME_TRANSPARENT = 2 + + def __init__(self) -> None: self.active = None # Starts out with no theme - self.minwidth = None - self.widgets = {} - self.widgets_pair = [] - self.defaults = {} - self.current = {} + self.minwidth: Optional[int] = None + self.widgets: Dict[tk.Widget, Set] = {} + self.widgets_pair: List = [] + self.defaults: Dict = {} + self.current: Dict = {} self.default_ui_scale = None # None == not yet known self.startup_ui_scale = None - def register(self, widget): - # Note widget and children for later application of a theme. Note if the widget has explicit fg or bg attributes. + def register(self, widget: tk.Widget) -> None: # noqa: CCR001, C901 + # Note widget and children for later application of a theme. Note if + # the widget has explicit fg or bg attributes. assert isinstance(widget, tk.Widget) or isinstance(widget, tk.BitmapImage), widget if not self.defaults: # Can't initialise this til window is created # Windows, MacOS @@ -160,7 +177,10 @@ class _Theme(object): if 'font' in widget.keys() and str(widget['font']) not in ['', self.defaults['entryfont']]: attribs.add('font') elif isinstance(widget, tk.Frame) or isinstance(widget, ttk.Frame) or isinstance(widget, tk.Canvas): - if ('background' in widget.keys() or isinstance(widget, tk.Canvas)) and widget['background'] not in ['', self.defaults['frame']]: + if ( + ('background' in widget.keys() or isinstance(widget, tk.Canvas)) + and widget['background'] not in ['', self.defaults['frame']] + ): attribs.add('bg') elif isinstance(widget, HyperlinkLabel): pass # Hack - HyperlinkLabel changes based on state, so skip @@ -184,15 +204,17 @@ class _Theme(object): for child in widget.winfo_children(): self.register(child) - def register_alternate(self, pair, gridopts): + def register_alternate(self, pair: Tuple, gridopts: Dict) -> None: self.widgets_pair.append((pair, gridopts)) - def button_bind(self, widget, command, image=None): + def button_bind( + self, widget: tk.Widget, command: Callable, image: Optional[tk.BitmapImage] = None + ) -> None: widget.bind('', command) widget.bind('', lambda e: self._enter(e, image)) widget.bind('', lambda e: self._leave(e, image)) - def _enter(self, event, image): + def _enter(self, event: tk.Event, image: Optional[tk.BitmapImage]) -> None: widget = event.widget if widget and widget['state'] != tk.DISABLED: try: @@ -209,7 +231,7 @@ class _Theme(object): except Exception: logger.exception(f'Failure configuring image: {image=}') - def _leave(self, event, image): + def _leave(self, event: tk.Event, image: Optional[tk.BitmapImage]) -> None: widget = event.widget if widget and widget['state'] != tk.DISABLED: try: @@ -226,7 +248,7 @@ class _Theme(object): logger.exception(f'Failure configuring image: {image=}') # Set up colors - def _colors(self, root, theme): + def _colors(self, root: tk.Tk, theme: int) -> None: style = ttk.Style() if sys.platform == 'linux': style.theme_use('clam') @@ -245,12 +267,13 @@ class _Theme(object): 'foreground': config.get_str('dark_text'), 'activebackground': config.get_str('dark_text'), 'activeforeground': 'grey4', - 'disabledforeground': '#%02x%02x%02x' % (int(r/384), int(g/384), int(b/384)), + 'disabledforeground': f'#{int(r/384):02x}{int(g/384):02x}{int(b/384):02x}', 'highlight': config.get_str('dark_highlight'), - # Font only supports Latin 1 / Supplement / Extended, and a few General Punctuation and Mathematical Operators + # Font only supports Latin 1 / Supplement / Extended, and a + # few General Punctuation and Mathematical Operators # LANG: Label for commander name in main window 'font': (theme > 1 and not 0x250 < ord(_('Cmdr')[0]) < 0x3000 and - tkFont.Font(family='Euro Caps', size=10, weight=tkFont.NORMAL) or + tk_font.Font(family='Euro Caps', size=10, weight=tk_font.NORMAL) or 'TkDefaultFont'), } else: @@ -271,10 +294,11 @@ class _Theme(object): # Apply current theme to a widget and its children, and register it for future updates - def update(self, widget): + def update(self, widget: tk.Widget) -> None: assert isinstance(widget, tk.Widget) or isinstance(widget, tk.BitmapImage), widget if not self.current: return # No need to call this for widgets created in plugin_app() + self.register(widget) self._update_widget(widget) if isinstance(widget, tk.Frame) or isinstance(widget, ttk.Frame): @@ -282,73 +306,76 @@ class _Theme(object): self._update_widget(child) # Apply current theme to a single widget - def _update_widget(self, widget): - assert widget in self.widgets, '%s %s "%s"' % ( - widget.winfo_class(), widget, 'text' in widget.keys() and widget['text']) - attribs = self.widgets.get(widget, []) + def _update_widget(self, widget: tk.Widget) -> None: # noqa: CCR001, C901 + if widget not in self.widgets: + assert_str = f'{widget.winfo_class()} {widget} "{"text" in widget.keys() and widget["text"]}"' + raise AssertionError(assert_str) + + attribs: Set = self.widgets.get(widget, set()) try: if isinstance(widget, tk.BitmapImage): # not a widget if 'fg' not in attribs: - widget.configure(foreground=self.current['foreground']), + widget['foreground'] = self.current['foreground'] if 'bg' not in attribs: - widget.configure(background=self.current['background']) + widget['background'] = self.current['background'] elif 'cursor' in widget.keys() and str(widget['cursor']) not in ['', 'arrow']: # Hack - highlight widgets like HyperlinkLabel with a non-default cursor if 'fg' not in attribs: - widget.configure(foreground=self.current['highlight']), + widget['foreground'] = self.current['highlight'] if 'insertbackground' in widget.keys(): # tk.Entry - widget.configure(insertbackground=self.current['foreground']), + widget['insertbackground'] = self.current['foreground'] if 'bg' not in attribs: - widget.configure(background=self.current['background']) + widget['background'] = self.current['background'] if 'highlightbackground' in widget.keys(): # tk.Entry - widget.configure(highlightbackground=self.current['background']) + widget['highlightbackground'] = self.current['background'] if 'font' not in attribs: - widget.configure(font=self.current['font']) + widget['font'] = self.current['font'] elif 'activeforeground' in widget.keys(): # e.g. tk.Button, tk.Label, tk.Menu if 'fg' not in attribs: - widget.configure(foreground=self.current['foreground'], - activeforeground=self.current['activeforeground'], - disabledforeground=self.current['disabledforeground']) + widget['foreground'] = self.current['foreground'] + widget['activeforeground'] = self.current['activeforeground'], + widget['disabledforeground'] = self.current['disabledforeground'] if 'bg' not in attribs: - widget.configure(background=self.current['background'], - activebackground=self.current['activebackground']) + widget['background'] = self.current['background'] + widget['activebackground'] = self.current['activebackground'] if sys.platform == 'darwin' and isinstance(widget, tk.Button): - widget.configure(highlightbackground=self.current['background']) + widget['highlightbackground'] = self.current['background'] if 'font' not in attribs: - widget.configure(font=self.current['font']) + widget['font'] = self.current['font'] + elif 'foreground' in widget.keys(): # e.g. ttk.Label if 'fg' not in attribs: - widget.configure(foreground=self.current['foreground']), + widget['foreground'] = self.current['foreground'] if 'bg' not in attribs: - widget.configure(background=self.current['background']) + widget['background'] = self.current['background'] if 'font' not in attribs: - widget.configure(font=self.current['font']) + widget['font'] = self.current['font'] elif 'background' in widget.keys() or isinstance(widget, tk.Canvas): # e.g. Frame, Canvas if 'bg' not in attribs: - widget.configure(background=self.current['background'], - highlightbackground=self.current['disabledforeground']) + widget['background'] = self.current['background'] + widget['highlightbackground'] = self.current['disabledforeground'] except Exception: logger.exception(f'Plugin widget issue ? {widget=}') # Apply configured theme - def apply(self, root): + def apply(self, root: tk.Tk) -> None: # noqa: CCR001 theme = config.get_int('theme') self._colors(root, theme) @@ -390,16 +417,16 @@ class _Theme(object): window.setAppearance_(appearance) elif sys.platform == 'win32': - GWL_STYLE = -16 - WS_MAXIMIZEBOX = 0x00010000 + GWL_STYLE = -16 # noqa: N806 # ctypes + WS_MAXIMIZEBOX = 0x00010000 # noqa: N806 # ctypes # tk8.5.9/win/tkWinWm.c:342 - GWL_EXSTYLE = -20 - WS_EX_APPWINDOW = 0x00040000 - WS_EX_LAYERED = 0x00080000 - GetWindowLongW = ctypes.windll.user32.GetWindowLongW - SetWindowLongW = ctypes.windll.user32.SetWindowLongW + GWL_EXSTYLE = -20 # noqa: N806 # ctypes + WS_EX_APPWINDOW = 0x00040000 # noqa: N806 # ctypes + WS_EX_LAYERED = 0x00080000 # noqa: N806 # ctypes + GetWindowLongW = ctypes.windll.user32.GetWindowLongW # noqa: N806 # ctypes + SetWindowLongW = ctypes.windll.user32.SetWindowLongW # noqa: N806 # ctypes - root.overrideredirect(theme and 1 or 0) + root.overrideredirect(theme and True or False) root.attributes("-transparentcolor", theme > 1 and 'grey4' or '') root.withdraw() root.update_idletasks() # Size and windows styles get recalculated here diff --git a/ttkHyperlinkLabel.py b/ttkHyperlinkLabel.py index 8dd08644..8abc0d00 100644 --- a/ttkHyperlinkLabel.py +++ b/ttkHyperlinkLabel.py @@ -1,26 +1,39 @@ +""" +A clickable ttk label for HTTP links. + +In addition to standard ttk.Label arguments, takes the following arguments: + url: The URL as a string that the user will be sent to on clicking on + non-empty label text. If url is a function it will be called on click with + the current label text and should return the URL as a string. + underline: If True/False the text is always/never underlined. If None (the + default) the text is underlined only on hover. + popup_copy: Whether right-click on non-empty label text pops up a context + menu with a 'Copy' option. Defaults to no context menu. If popup_copy is a + function it will be called with the current label text and should return a + boolean. + +May be imported by plugins +""" import sys import tkinter as tk import webbrowser -from tkinter import font as tkFont +from tkinter import font as tk_font from tkinter import ttk +from typing import TYPE_CHECKING, Any, Optional if sys.platform == 'win32': import subprocess from winreg import HKEY_CLASSES_ROOT, HKEY_CURRENT_USER, CloseKey, OpenKeyEx, QueryValueEx -# A clickable ttk Label -# -# In addition to standard ttk.Label arguments, takes the following arguments: -# url: The URL as a string that the user will be sent to on clicking on non-empty label text. If url is a function it will be called on click with the current label text and should return the URL as a string. -# underline: If True/False the text is always/never underlined. If None (the default) the text is underlined only on hover. -# popup_copy: Whether right-click on non-empty label text pops up a context menu with a 'Copy' option. Defaults to no context menu. If popup_copy is a function it will be called with the current label text and should return a boolean. -# -# May be imported by plugins +if TYPE_CHECKING: + def _(x: str) -> str: ... -class HyperlinkLabel(sys.platform == 'darwin' and tk.Label or ttk.Label, object): +# FIXME: Split this into multi-file module to separate the platforms +class HyperlinkLabel(sys.platform == 'darwin' and tk.Label or ttk.Label, object): # type: ignore + """Clickable label for HTTP links.""" - def __init__(self, master=None, **kw): + def __init__(self, master: Optional[tk.Tk] = None, **kw: Any) -> None: self.url = 'url' in kw and kw.pop('url') or None self.popup_copy = kw.pop('popup_copy', False) self.underline = kw.pop('underline', None) # override ttk.Label's underline @@ -33,8 +46,9 @@ class HyperlinkLabel(sys.platform == 'darwin' and tk.Label or ttk.Label, object) kw['background'] = kw.pop('background', 'systemDialogBackgroundActive') kw['anchor'] = kw.pop('anchor', tk.W) # like ttk.Label tk.Label.__init__(self, master, **kw) + else: - ttk.Label.__init__(self, master, **kw) + ttk.Label.__init__(self, master, **kw) # type: ignore self.bind('', self._click) @@ -51,8 +65,10 @@ class HyperlinkLabel(sys.platform == 'darwin' and tk.Label or ttk.Label, object) text=kw.get('text'), font=kw.get('font', ttk.Style().lookup('TLabel', 'font'))) - # Change cursor and appearance depending on state and text - def configure(self, cnf=None, **kw): + def configure( # noqa: CCR001 + self, cnf: dict[str, Any] | None = None, **kw: Any + ) -> dict[str, tuple[str, str, str, Any, Any]] | None: + """Change cursor and appearance depending on state and text.""" # This class' state for thing in ['url', 'popup_copy', 'underline']: if thing in kw: @@ -71,7 +87,7 @@ class HyperlinkLabel(sys.platform == 'darwin' and tk.Label or ttk.Label, object) if 'font' in kw: self.font_n = kw['font'] - self.font_u = tkFont.Font(font=self.font_n) + self.font_u = tk_font.Font(font=self.font_n) self.font_u.configure(underline=True) kw['font'] = self.underline is True and self.font_u or self.font_n @@ -84,41 +100,53 @@ class HyperlinkLabel(sys.platform == 'darwin' and tk.Label or ttk.Label, object) kw['cursor'] = (sys.platform == 'darwin' and 'notallowed') or ( sys.platform == 'win32' and 'no') or 'circle' - super(HyperlinkLabel, self).configure(cnf, **kw) + return super(HyperlinkLabel, self).configure(cnf, **kw) - def __setitem__(self, key, value): + def __setitem__(self, key: str, value: Any) -> None: + """ + Allow for dict member style setting of options. + + :param key: option name + :param value: option value + """ self.configure(None, **{key: value}) - def _enter(self, event): + def _enter(self, event: tk.Event) -> None: if self.url and self.underline is not False and str(self['state']) != tk.DISABLED: super(HyperlinkLabel, self).configure(font=self.font_u) - def _leave(self, event): + def _leave(self, event: tk.Event) -> None: if not self.underline: super(HyperlinkLabel, self).configure(font=self.font_n) - def _click(self, event): + def _click(self, event: tk.Event) -> None: if self.url and self['text'] and str(self['state']) != tk.DISABLED: url = self.url(self['text']) if callable(self.url) else self.url if url: self._leave(event) # Remove underline before we change window to browser openurl(url) - def _contextmenu(self, event): + def _contextmenu(self, event: tk.Event) -> None: if self['text'] and (self.popup_copy(self['text']) if callable(self.popup_copy) else self.popup_copy): self.menu.post(sys.platform == 'darwin' and event.x_root + 1 or event.x_root, event.y_root) - def copy(self): + def copy(self) -> None: + """Copy the current text to the clipboard.""" self.clipboard_clear() self.clipboard_append(self['text']) -def openurl(url): +def openurl(url: str) -> None: # noqa: CCR001 + """ + Open the given URL in appropriate browser. + + :param url: URL to open. + """ if sys.platform == 'win32': + # FIXME: Is still still true with supported Windows 10 and 11 ? # On Windows webbrowser.open calls os.startfile which calls ShellExecute which can't handle long arguments, # so discover and launch the browser directly. # https://blogs.msdn.microsoft.com/oldnewthing/20031210-00/?p=41553 - try: hkey = OpenKeyEx(HKEY_CURRENT_USER, r'Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice') @@ -128,23 +156,28 @@ def openurl(url): # IE and Edge can't handle long arguments so just use webbrowser.open and hope # https://blogs.msdn.microsoft.com/ieinternals/2014/08/13/url-length-limits/ cls = None + else: cls = value - except: + + except Exception: cls = 'https' if cls: try: - hkey = OpenKeyEx(HKEY_CLASSES_ROOT, r'%s\shell\open\command' % cls) - (value, typ) = QueryValueEx(hkey, None) + hkey = OpenKeyEx(HKEY_CLASSES_ROOT, rf'{cls}\shell\open\command') + (value, typ) = QueryValueEx(hkey, '') CloseKey(hkey) if 'iexplore' not in value.lower(): if '%1' in value: - subprocess.Popen(buf.value.replace('%1', url)) + subprocess.Popen(value.replace('%1', url)) + else: - subprocess.Popen('%s "%s"' % (buf.value, url)) + subprocess.Popen(f'{value} "{url}"') + return - except: + + except Exception: pass webbrowser.open(url) diff --git a/update.py b/update.py index 00fe9e6d..bbd65ab2 100644 --- a/update.py +++ b/update.py @@ -1,15 +1,22 @@ +"""Checking for updates to this application.""" import os -from os.path import dirname, join import sys import threading +from os.path import dirname, join from traceback import print_exc -import semantic_version from typing import TYPE_CHECKING, Optional +from xml.etree import ElementTree + +import requests +import semantic_version + if TYPE_CHECKING: import tkinter as tk -# ensure registry is set up on Windows before we start from config import appname, appversion_nobuild, config, update_feed +from EDMCLogging import get_main_logger + +logger = get_main_logger() class EDMCVersion(object): @@ -25,6 +32,7 @@ class EDMCVersion(object): sv: semantic_version.base.Version semantic_version object for this version """ + def __init__(self, version: str, title: str, sv: semantic_version.base.Version): self.version: str = version self.title: str = title @@ -33,45 +41,47 @@ class EDMCVersion(object): class Updater(object): """ - Updater class to handle checking for updates, whether using internal code - or an external library such as WinSparkle on win32. + Handle checking for updates. + + This is used whether using internal code or an external library such as + WinSparkle on win32. """ def shutdown_request(self) -> None: - """ - Receive (Win)Sparkle shutdown request and send it to parent. - :rtype: None - """ - if not config.shutting_down: + """Receive (Win)Sparkle shutdown request and send it to parent.""" + if not config.shutting_down and self.root: self.root.event_generate('<>', when="tail") def use_internal(self) -> bool: """ - :return: if internal update checks should be used. - :rtype: bool + Signal if internal update checks should be used. + + :return: bool """ if self.provider == 'internal': return True return False - def __init__(self, tkroot: 'tk.Tk'=None, provider: str='internal'): + def __init__(self, tkroot: Optional['tk.Tk'] = None, provider: str = 'internal'): """ + Initialise an Updater instance. + :param tkroot: reference to the root window of the GUI :param provider: 'internal' or other string if not """ - self.root: 'tk.Tk' = tkroot + self.root: Optional['tk.Tk'] = tkroot self.provider: str = provider - self.thread: threading.Thread = None + self.thread: Optional[threading.Thread] = None if self.use_internal(): - return + return if sys.platform == 'win32': import ctypes try: - self.updater = ctypes.cdll.WinSparkle + self.updater: Optional[ctypes.CDLL] = ctypes.cdll.WinSparkle # Set the appcast URL self.updater.win_sparkle_set_appcast_url(update_feed.encode()) @@ -84,8 +94,6 @@ class Updater(object): self.updater.win_sparkle_set_app_build_version(str(appversion_nobuild())) # set up shutdown callback - global root - root = tkroot self.callback_t = ctypes.CFUNCTYPE(None) # keep reference self.callback_fn = self.callback_t(self.shutdown_request) self.updater.win_sparkle_set_shutdown_request_callback(self.callback_fn) @@ -93,7 +101,7 @@ class Updater(object): # Get WinSparkle running self.updater.win_sparkle_init() - except Exception as ex: + except Exception: print_exc() self.updater = None @@ -101,19 +109,24 @@ class Updater(object): if sys.platform == 'darwin': import objc + try: - objc.loadBundle('Sparkle', globals(), join(dirname(sys.executable), os.pardir, 'Frameworks', 'Sparkle.framework')) - self.updater = SUUpdater.sharedUpdater() - except: + objc.loadBundle( + 'Sparkle', globals(), join(dirname(sys.executable), os.pardir, 'Frameworks', 'Sparkle.framework') + ) + # loadBundle presumably supplies `SUUpdater` + self.updater = SUUpdater.sharedUpdater() # noqa: F821 + + except Exception: # can't load framework - not frozen or not included in app bundle? print_exc() self.updater = None - def setAutomaticUpdatesCheck(self, onoroff: bool) -> None: + def set_automatic_updates_check(self, onoroff: bool) -> None: """ - Helper to set (Win)Sparkle to perform automatic update checks, or not. + Set (Win)Sparkle to perform automatic update checks, or not. + :param onoroff: bool for if we should have the library check or not. - :return: None """ if self.use_internal(): return @@ -124,13 +137,10 @@ class Updater(object): if sys.platform == 'darwin' and self.updater: self.updater.SUEnableAutomaticChecks(onoroff) - def checkForUpdates(self) -> None: - """ - Trigger the requisite method to check for an update. - :return: None - """ + def check_for_updates(self) -> None: + """Trigger the requisite method to check for an update.""" if self.use_internal(): - self.thread = threading.Thread(target = self.worker, name = 'update worker') + self.thread = threading.Thread(target=self.worker, name='update worker') self.thread.daemon = True self.thread.start() @@ -142,65 +152,78 @@ class Updater(object): def check_appcast(self) -> Optional[EDMCVersion]: """ - Manually (no Sparkle or WinSparkle) check the update_feed appcast file - to see if any listed version is semantically greater than the current + Manually (no Sparkle or WinSparkle) check the update_feed appcast file. + + Checks if any listed version is semantically greater than the current running version. :return: EDMCVersion or None if no newer version found """ - import requests - from xml.etree import ElementTree - newversion = None items = {} try: r = requests.get(update_feed, timeout=10) + except requests.RequestException as ex: - print('Error retrieving update_feed file: {}'.format(str(ex)), file=sys.stderr) + logger.exception(f'Error retrieving update_feed file: {ex}') return None try: feed = ElementTree.fromstring(r.text) + except SyntaxError as ex: - print('Syntax error in update_feed file: {}'.format(str(ex)), file=sys.stderr) + logger.exception(f'Syntax error in update_feed file: {ex}') return None + if sys.platform == 'darwin': + sparkle_platform = 'macos' + + else: + # For *these* purposes anything else is the same as 'windows', as + # non-win32 would be running from source. + sparkle_platform = 'windows' + for item in feed.findall('channel/item'): - ver = item.find('enclosure').attrib.get('{http://www.andymatuschak.org/xml-namespaces/sparkle}version') + # xml is a pain with types, hence these ignores + ver = item.find('enclosure').attrib.get( # type: ignore + '{http://www.andymatuschak.org/xml-namespaces/sparkle}version' + ) + ver_platform = item.find('enclosure').attrib.get( # type: ignore + '{http://www.andymatuschak.org/xml-namespaces/sparkle}os' + ) + if ver_platform != sparkle_platform: + continue + # This will change A.B.C.D to A.B.C+D sv = semantic_version.Version.coerce(ver) - items[sv] = EDMCVersion(version=ver, # sv might have mangled version - title=item.find('title').text, - sv=sv + items[sv] = EDMCVersion( + version=str(ver), # sv might have mangled version + title=item.find('title').text, # type: ignore + sv=sv ) # Look for any remaining version greater than appversion simple_spec = semantic_version.SimpleSpec(f'>{appversion_nobuild()}') newversion = simple_spec.select(items.keys()) - if newversion: return items[newversion] + return None def worker(self) -> None: - """ - Thread worker to perform internal update checking and update GUI - status if a newer version is found. - :return: None - """ + """Perform internal update checking & update GUI status if needs be.""" newversion = self.check_appcast() - if newversion: - # TODO: Surely we can do better than this - # nametowidget('.{}.status'.format(appname.lower()))['text'] - self.root.nametowidget('.{}.status'.format(appname.lower()))['text'] = newversion.title + ' is available' + if newversion and self.root: + status = self.root.nametowidget(f'.{appname.lower()}.status') + status['text'] = newversion.title + ' is available' self.root.update_idletasks() def close(self) -> None: """ - Handles the EDMarketConnector.AppWindow.onexit() request. + Handle the EDMarketConnector.AppWindow.onexit() request. NB: We just 'pass' here because: 1) We might have a worker() going, but no way to make that @@ -208,7 +231,5 @@ class Updater(object): 2) If we're running frozen then we're using (Win)Sparkle to check and *it* might have asked this whole application to quit, in which case we don't want to ask *it* to quit - - :return: None """ pass diff --git a/util_ships.py b/util_ships.py index 204e21f5..bd3ae3df 100644 --- a/util_ships.py +++ b/util_ships.py @@ -1,6 +1,7 @@ """Utility functions relating to ships.""" from edmc_data import ship_name_map + def ship_file_name(ship_name: str, ship_type: str) -> str: """Return a ship name suitable for a filename.""" name = str(ship_name or ship_name_map.get(ship_type.lower(), ship_type)).strip()