diff --git a/.mypy.ini b/.mypy.ini index 03840fd9..f9ec5997 100644 --- a/.mypy.ini +++ b/.mypy.ini @@ -2,3 +2,4 @@ follow_imports = skip ignore_missing_imports = True scripts_are_modules = True +; platform = darwin diff --git a/Contributing.md b/Contributing.md index bdbf4634..0b1f4d58 100644 --- a/Contributing.md +++ b/Contributing.md @@ -228,6 +228,7 @@ handy if you want to step through the testing code to be sure of anything. Otherwise, see the [pytest documentation](https://docs.pytest.org/en/stable/contents.html). --- + ## Debugging network sends Rather than risk sending bad data to a remote service, even if only through @@ -484,6 +485,20 @@ Please be verbose here, more info about weird choices is always prefered over ma Additionally, if your hack is over around 5 lines, please include a `# HACK END` or similar comment to indicate the end of the hack. +# Use `sys.platform` for platform guards + +`mypy` (and `pylance`) understand platform guards and will show unreachable code / resolve imports correctly +for platform specific things. However, this only works if you directly reference `sys.platform`, importantly +the following does not work: + +```py +from sys import platform +if platform == 'darwin': + ... +``` + +It **MUST** be `if sys.platform`. + --- ## Build process diff --git a/EDMarketConnector.py b/EDMarketConnector.py index 01c9d846..6aa27ca3 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """Entry point for the main GUI application.""" +from __future__ import annotations import argparse import html @@ -14,7 +15,6 @@ import webbrowser from builtins import object, str from os import chdir, environ from os.path import dirname, join -from sys import platform from time import localtime, strftime, time from typing import TYPE_CHECKING, Optional, Tuple, Union @@ -23,7 +23,7 @@ from typing import TYPE_CHECKING, Optional, Tuple, Union # place for things like config.py reading .gitversion if getattr(sys, 'frozen', False): # Under py2exe sys.path[0] is the executable name - if platform == 'win32': + if sys.platform == 'win32': chdir(dirname(sys.path[0])) # Allow executable to be invoked from any cwd environ['TCL_LIBRARY'] = join(dirname(sys.path[0]), 'lib', 'tcl') @@ -234,7 +234,7 @@ if __name__ == '__main__': # noqa: C901 """Handle any edmc:// auth callback, else foreground existing window.""" logger.trace_if('frontier-auth.windows', 'Begin...') - if platform == 'win32': + if sys.platform == 'win32': # If *this* instance hasn't locked, then another already has and we # now need to do the edmc:// checks for auth callback @@ -370,8 +370,9 @@ if __name__ == '__main__': # noqa: C901 # isort: off if TYPE_CHECKING: from logging import TRACE # type: ignore # noqa: F401 # Needed to update mypy - import update - from infi.systray import SysTrayIcon + + if sys.platform == 'win32': + from infi.systray import SysTrayIcon # isort: on def _(x: str) -> str: @@ -443,7 +444,7 @@ class AppWindow(object): self.prefsdialog = None - if platform == 'win32': + if sys.platform == 'win32': from infi.systray import SysTrayIcon def open_window(systray: 'SysTrayIcon') -> None: @@ -456,8 +457,8 @@ class AppWindow(object): plug.load_plugins(master) - if platform != 'darwin': - if platform == 'win32': + if sys.platform != 'darwin': + if sys.platform == 'win32': self.w.wm_iconbitmap(default='EDMarketConnector.ico') else: @@ -527,7 +528,7 @@ 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 platform == 'darwin' else 28, 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] @@ -540,14 +541,15 @@ class AppWindow(object): theme.button_bind(self.theme_button, self.capi_request_data) for child in frame.winfo_children(): - child.grid_configure(padx=self.PADX, pady=(platform != 'win32' or isinstance(child, tk.Frame)) and 2 or 0) + child.grid_configure(padx=self.PADX, pady=( + sys.platform != 'win32' or isinstance(child, tk.Frame)) and 2 or 0) # The type needs defining for adding the menu entry, but won't be # properly set until later self.updater: update.Updater = None self.menubar = tk.Menu() - if platform == 'darwin': + if sys.platform == 'darwin': # Can't handle (de)iconify if topmost is set, so suppress iconify button # http://wiki.tcl.tk/13428 and p15 of # https://developer.apple.com/legacy/library/documentation/Carbon/Conceptual/HandlingWindowsControls/windowscontrols.pdf @@ -603,7 +605,7 @@ class AppWindow(object): self.help_menu.add_command(command=lambda: not self.HelpAbout.showing and self.HelpAbout(self.w)) self.menubar.add_cascade(menu=self.help_menu) - if platform == 'win32': + if sys.platform == 'win32': # Must be added after at least one "real" menu entry self.always_ontop = tk.BooleanVar(value=bool(config.get_int('always_ontop'))) self.system_menu = tk.Menu(self.menubar, name='system', tearoff=tk.FALSE) @@ -674,11 +676,11 @@ class AppWindow(object): if config.get_str('geometry'): match = re.match(r'\+([\-\d]+)\+([\-\d]+)', config.get_str('geometry')) if match: - if platform == 'darwin': + if sys.platform == 'darwin': # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7 if int(match.group(2)) >= 0: self.w.geometry(config.get_str('geometry')) - elif platform == 'win32': + elif sys.platform == 'win32': # Check that the titlebar will be at least partly on screen import ctypes from ctypes.wintypes import POINT @@ -776,7 +778,7 @@ class AppWindow(object): self.suit_shown = True if not self.suit_shown: - if platform != 'win32': + if sys.platform != 'win32': pady = 2 else: @@ -826,7 +828,7 @@ class AppWindow(object): self.system_label['text'] = _('System') + ':' # LANG: Label for 'System' line in main UI self.station_label['text'] = _('Station') + ':' # LANG: Label for 'Station' line in main UI self.button['text'] = self.theme_button['text'] = _('Update') # LANG: Update button in main window - if platform == 'darwin': + if sys.platform == 'darwin': self.menubar.entryconfigure(1, label=_('File')) # LANG: 'File' menu title on OSX self.menubar.entryconfigure(2, label=_('Edit')) # LANG: 'Edit' menu title on OSX self.menubar.entryconfigure(3, label=_('View')) # LANG: 'View' menu title on OSX @@ -873,7 +875,7 @@ class AppWindow(object): self.button['state'] = self.theme_button['state'] = tk.DISABLED - if platform == 'darwin': + if sys.platform == 'darwin': self.view_menu.entryconfigure(0, state=tk.DISABLED) # Status self.file_menu.entryconfigure(0, state=tk.DISABLED) # Save Raw Data @@ -887,7 +889,7 @@ class AppWindow(object): # LANG: Successfully authenticated with the Frontier website self.status['text'] = _('Authentication successful') - if platform == 'darwin': + if sys.platform == 'darwin': self.view_menu.entryconfigure(0, state=tk.NORMAL) # Status self.file_menu.entryconfigure(0, state=tk.NORMAL) # Save Raw Data @@ -1211,7 +1213,7 @@ class AppWindow(object): companion.session.invalidate() self.login() - except companion.ServerConnectionError as e: + except companion.ServerConnectionError as e: # TODO: unreachable (subclass of ServerLagging -- move to above) logger.warning(f'Exception while contacting server: {e}') err = self.status['text'] = str(e) play_bad = True @@ -1429,7 +1431,7 @@ class AppWindow(object): companion.session.auth_callback() # LANG: Successfully authenticated with the Frontier website self.status['text'] = _('Authentication successful') - if platform == 'darwin': + if sys.platform == 'darwin': self.view_menu.entryconfigure(0, state=tk.NORMAL) # Status self.file_menu.entryconfigure(0, state=tk.NORMAL) # Save Raw Data @@ -1570,11 +1572,11 @@ class AppWindow(object): # position over parent # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7 - if platform != 'darwin' or parent.winfo_rooty() > 0: + if sys.platform != 'darwin' or parent.winfo_rooty() > 0: self.geometry(f'+{parent.winfo_rootx():d}+{parent.winfo_rooty():d}') # remove decoration - if platform == 'win32': + if sys.platform == 'win32': self.attributes('-toolwindow', tk.TRUE) self.resizable(tk.FALSE, tk.FALSE) @@ -1651,7 +1653,7 @@ class AppWindow(object): """ default_extension: str = '' - if platform == 'darwin': + if sys.platform == 'darwin': default_extension = '.json' timestamp: str = strftime('%Y-%m-%dT%H.%M.%S', localtime()) @@ -1676,7 +1678,7 @@ class AppWindow(object): def onexit(self, event=None) -> None: """Application shutdown procedure.""" - if platform == 'win32': + if sys.platform == 'win32': shutdown_thread = threading.Thread(target=self.systray.shutdown) shutdown_thread.setDaemon(True) shutdown_thread.start() @@ -1684,7 +1686,7 @@ class AppWindow(object): config.set_shutdown() # Signal we're in shutdown now. # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7 - if platform != 'darwin' or self.w.winfo_rooty() > 0: + if sys.platform != 'darwin' or self.w.winfo_rooty() > 0: x, y = self.w.geometry().split('+')[1:3] # e.g. '212x170+2881+1267' config.set('geometry', f'+{x}+{y}') diff --git a/config.py b/config.py deleted file mode 100644 index b32b2613..00000000 --- a/config.py +++ /dev/null @@ -1,1115 +0,0 @@ -""" -Code dealing with the configuration of the program. - -Windows uses the Registry to store values in a flat manner. -Linux uses a file, but for commonality it's still a flat data structure. -macOS uses a 'defaults' object. -""" - -# spell-checker: words HKEY FOLDERID wchar wstring edcdhkey - -import abc -import contextlib -import functools -import logging -import os -import pathlib -import re -import subprocess -import sys -import traceback -import warnings -from abc import abstractmethod -from sys import platform -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Type, TypeVar, Union - -import semantic_version - -from constants import GITVERSION_FILE, applongname, appname - -# Any of these may be imported by plugins -appcmdname = 'EDMC' -# appversion **MUST** follow Semantic Versioning rules: -# -# Major.Minor.Patch(-prerelease)(+buildmetadata) -# NB: Do *not* import this, use the functions appversion() and appversion_nobuild() -_static_appversion = '5.3.0-beta4' -_cached_version: Optional[semantic_version.Version] = None -copyright = '© 2015-2019 Jonathan Harris, 2020-2022 EDCD' - -update_feed = 'https://raw.githubusercontent.com/EDCD/EDMarketConnector/releases/edmarketconnector.xml' -update_interval = 8*60*60 -# Providers marked to be in debug mode. Generally this is expected to switch to sending data to a log file -debug_senders: List[str] = [] -# TRACE logging code that should actually be used. Means not spamming it -# *all* if only interested in some things. -trace_on: List[str] = [] - -capi_pretend_down: bool = False -capi_debug_access_token: Optional[str] = None -# This must be done here in order to avoid an import cycle with EDMCLogging. -# Other code should use EDMCLogging.get_main_logger -if os.getenv("EDMC_NO_UI"): - logger = logging.getLogger(appcmdname) - -else: - logger = logging.getLogger(appname) - -if platform == 'darwin': - from Foundation import ( # type: ignore - NSApplicationSupportDirectory, NSBundle, NSDocumentDirectory, NSSearchPathForDirectoriesInDomains, - NSUserDefaults, NSUserDomainMask - ) - -elif platform == 'win32': - import ctypes - import uuid - import winreg - from ctypes.wintypes import DWORD, HANDLE - if TYPE_CHECKING: - import ctypes.windll # type: ignore - - REG_RESERVED_ALWAYS_ZERO = 0 - - # This is the only way to do this from python without external deps (which do this anyway). - FOLDERID_Documents = uuid.UUID('{FDD39AD0-238F-46AF-ADB4-6C85480369C7}') - FOLDERID_LocalAppData = uuid.UUID('{F1B32785-6FBA-4FCF-9D55-7B8E7F157091}') - FOLDERID_Profile = uuid.UUID('{5E6C858F-0E22-4760-9AFE-EA3317B67173}') - FOLDERID_SavedGames = uuid.UUID('{4C5C32FF-BB9D-43b0-B5B4-2D72E54EAAA4}') - - SHGetKnownFolderPath = ctypes.windll.shell32.SHGetKnownFolderPath - SHGetKnownFolderPath.argtypes = [ctypes.c_char_p, DWORD, HANDLE, ctypes.POINTER(ctypes.c_wchar_p)] - - CoTaskMemFree = ctypes.windll.ole32.CoTaskMemFree - CoTaskMemFree.argtypes = [ctypes.c_void_p] - - def known_folder_path(guid: uuid.UUID) -> Optional[str]: - """Look up a Windows GUID to actual folder path name.""" - buf = ctypes.c_wchar_p() - if SHGetKnownFolderPath(ctypes.create_string_buffer(guid.bytes_le), 0, 0, ctypes.byref(buf)): - return None - retval = buf.value # copy data - CoTaskMemFree(buf) # and free original - return retval - -elif platform == 'linux': - from configparser import ConfigParser - - -_T = TypeVar('_T') - - -########################################################################### -def git_shorthash_from_head() -> str: - """ - Determine short hash for current git HEAD. - - Includes `.DIRTY` if any changes have been made from HEAD - - :return: str - None if we couldn't determine the short hash. - """ - shorthash: str = None # type: ignore - - try: - git_cmd = subprocess.Popen('git rev-parse --short HEAD'.split(), - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT - ) - out, err = git_cmd.communicate() - - except Exception as e: - logger.info(f"Couldn't run git command for short hash: {e!r}") - - else: - shorthash = out.decode().rstrip('\n') - if re.match(r'^[0-9a-f]{7,}$', shorthash) is None: - logger.error(f"'{shorthash}' doesn't look like a valid git short hash, forcing to None") - shorthash = None # type: ignore - - if shorthash is not None: - with contextlib.suppress(Exception): - result = subprocess.run('git diff --stat HEAD'.split(), capture_output=True) - if len(result.stdout) > 0: - shorthash += '.DIRTY' - - if len(result.stderr) > 0: - logger.warning(f'Data from git on stderr:\n{str(result.stderr)}') - - return shorthash - - -def appversion() -> semantic_version.Version: - """ - Determine app version including git short hash if possible. - - :return: The augmented app version. - """ - global _cached_version - if _cached_version is not None: - return _cached_version - - if getattr(sys, 'frozen', False): - # Running frozen, so we should have a .gitversion file - # Yes, .parent because if frozen we're inside library.zip - with open(pathlib.Path(sys.path[0]).parent / GITVERSION_FILE, 'r', encoding='utf-8') as gitv: - shorthash = gitv.read() - - else: - # Running from source - shorthash = git_shorthash_from_head() - if shorthash is None: - shorthash = 'UNKNOWN' - - _cached_version = semantic_version.Version(f'{_static_appversion}+{shorthash}') - return _cached_version - - -user_agent = f'EDCD-{appname}-{appversion()}' - - -def appversion_nobuild() -> semantic_version.Version: - """ - Determine app version without *any* build meta data. - - This will not only strip any added git short hash, but also any trailing - '+' in _static_appversion. - - :return: App version without any build meta data. - """ - return appversion().truncate('prerelease') -########################################################################### - - -class AbstractConfig(abc.ABC): - """Abstract root class of all platform specific Config implementations.""" - - OUT_MKT_EDDN = 1 - # OUT_MKT_BPC = 2 # No longer supported - OUT_MKT_TD = 4 - OUT_MKT_CSV = 8 - OUT_SHIP = 16 - # OUT_SHIP_EDS = 16 # Replaced by OUT_SHIP - # OUT_SYS_FILE = 32 # No longer supported - # OUT_STAT = 64 # No longer available - # OUT_SHIP_CORIOLIS = 128 # Replaced by OUT_SHIP - OUT_STATION_ANY = OUT_MKT_EDDN | OUT_MKT_TD | OUT_MKT_CSV - # OUT_SYS_EDSM = 256 # Now a plugin - # OUT_SYS_AUTO = 512 # Now always automatic - OUT_MKT_MANUAL = 1024 - OUT_SYS_EDDN = 2048 - OUT_SYS_DELAY = 4096 - - app_dir_path: pathlib.Path - plugin_dir_path: pathlib.Path - internal_plugin_dir_path: pathlib.Path - respath_path: pathlib.Path - home_path: pathlib.Path - default_journal_dir_path: pathlib.Path - - identifier: str - - __in_shutdown = False # Is the application currently shutting down ? - __auth_force_localserver = False # Should we use localhost for auth callback ? - __auth_force_edmc_protocol = False # Should we force edmc:// protocol ? - __eddn_url = None # Non-default EDDN URL - __eddn_tracking_ui = False # Show EDDN tracking UI ? - - def __init__(self) -> None: - self.home_path = pathlib.Path.home() - - def set_shutdown(self): - """Set flag denoting we're in the shutdown sequence.""" - self.__in_shutdown = True - - @property - def shutting_down(self) -> bool: - """ - Determine if we're in the shutdown sequence. - - :return: bool - True if in shutdown sequence. - """ - return self.__in_shutdown - - def set_auth_force_localserver(self): - """Set flag to force use of localhost web server for Frontier Auth callback.""" - self.__auth_force_localserver = True - - @property - def auth_force_localserver(self) -> bool: - """ - Determine if use of localhost is forced for Frontier Auth callback. - - :return: bool - True if we should use localhost web server. - """ - return self.__auth_force_localserver - - def set_auth_force_edmc_protocol(self): - """Set flag to force use of localhost web server for Frontier Auth callback.""" - self.__auth_force_edmc_protocol = True - - @property - def auth_force_edmc_protocol(self) -> bool: - """ - Determine if use of localhost is forced for Frontier Auth callback. - - :return: bool - True if we should use localhost web server. - """ - return self.__auth_force_edmc_protocol - - def set_eddn_url(self, eddn_url: str): - """Set the specified eddn URL.""" - self.__eddn_url = eddn_url - - @property - def eddn_url(self) -> Optional[str]: - """ - Provide the custom EDDN URL. - - :return: str - Custom EDDN URL to use. - """ - return self.__eddn_url - - def set_eddn_tracking_ui(self): - """Activate EDDN tracking UI.""" - self.__eddn_tracking_ui = True - - @property - def eddn_tracking_ui(self) -> bool: - """ - Determine if the EDDN tracking UI be shown. - - :return: bool - Should tracking UI be active? - """ - return self.__eddn_tracking_ui - - @property - def app_dir(self) -> str: - """Return a string version of app_dir.""" - return str(self.app_dir_path) - - @property - def plugin_dir(self) -> str: - """Return a string version of plugin_dir.""" - return str(self.plugin_dir_path) - - @property - def internal_plugin_dir(self) -> str: - """Return a string version of internal_plugin_dir.""" - return str(self.internal_plugin_dir_path) - - @property - def respath(self) -> str: - """Return a string version of respath.""" - return str(self.respath_path) - - @property - def home(self) -> str: - """Return a string version of home.""" - return str(self.home_path) - - @property - def default_journal_dir(self) -> str: - """Return a string version of default_journal_dir.""" - return str(self.default_journal_dir_path) - - @staticmethod - def _suppress_call( - func: Callable[..., _T], exceptions: Union[Type[BaseException], List[Type[BaseException]]] = Exception, - *args: Any, **kwargs: Any - ) -> Optional[_T]: - if exceptions is None: - exceptions = [Exception] - - if not isinstance(exceptions, list): - exceptions = [exceptions] - - with contextlib.suppress(*exceptions): # type: ignore # it works fine, mypy - return func(*args, **kwargs) - - return None - - def get(self, key: str, default: Union[list, str, bool, int] = None) -> Union[list, str, bool, int]: - """ - Return the data for the requested key, or a default. - - :param key: The key data is being requested for. - :param default: The default to return if the key does not exist, defaults to None. - :raises OSError: On Windows, if a Registry error occurs. - :return: The data or the default. - """ - warnings.warn(DeprecationWarning('get is Deprecated. use the specific getter for your type')) - logger.debug('Attempt to use Deprecated get() method\n' + ''.join(traceback.format_stack())) - - if (l := self._suppress_call(self.get_list, ValueError, key, default=None)) is not None: - return l - - elif (s := self._suppress_call(self.get_str, ValueError, key, default=None)) is not None: - return s - - elif (b := self._suppress_call(self.get_bool, ValueError, key, default=None)) is not None: - return b - - elif (i := self._suppress_call(self.get_int, ValueError, key, default=None)) is not None: - return i - - return default # type: ignore - - @abstractmethod - def get_list(self, key: str, *, default: list = None) -> list: - """ - Return the list referred to by the given key if it exists, or the default. - - Implements :meth:`AbstractConfig.get_list`. - """ - raise NotImplementedError - - @abstractmethod - def get_str(self, key: str, *, default: str = None) -> str: - """ - Return the string referred to by the given key if it exists, or the default. - - :param key: The key data is being requested for. - :param default: Default to return if the key does not exist, defaults to None. - :raises ValueError: If an internal error occurs getting or converting a value. - :raises OSError: On Windows, if a Registry error occurs. - :return: The requested data or the default. - """ - raise NotImplementedError - - @abstractmethod - def get_bool(self, key: str, *, default: bool = None) -> bool: - """ - Return the bool referred to by the given key if it exists, or the default. - - :param key: The key data is being requested for. - :param default: Default to return if the key does not exist, defaults to None - :raises ValueError: If an internal error occurs getting or converting a value - :raises OSError: On Windows, if a Registry error occurs. - :return: The requested data or the default - """ - raise NotImplementedError - - def getint(self, key: str, *, default: int = 0) -> int: - """ - Getint is a Deprecated getter method. - - See get_int for its replacement. - :raises OSError: On Windows, if a Registry error occurs. - """ - warnings.warn(DeprecationWarning('getint is Deprecated. Use get_int instead')) - logger.debug('Attempt to use Deprecated getint() method\n' + ''.join(traceback.format_stack())) - - return self.get_int(key, default=default) - - @abstractmethod - def get_int(self, key: str, *, default: int = 0) -> int: - """ - Return the int referred to by key if it exists in the config. - - For legacy reasons, the default is 0 and not None. - - :param key: The key data is being requested for. - :param default: Default to return if the key does not exist, defaults to 0. - :raises ValueError: If the internal representation of this key cannot be converted to an int. - :raises OSError: On Windows, if a Registry error occurs. - :return: The requested data or the default. - """ - raise NotImplementedError - - @abstractmethod - def set(self, key: str, val: Union[int, str, List[str], bool]) -> None: - """ - Set the given key's data to the given value. - - :param key: The key to set the value on. - :param val: The value to set the key's data to. - :raises ValueError: On an invalid type. - :raises OSError: On Windows, if a Registry error occurs. - """ - raise NotImplementedError - - @abstractmethod - def delete(self, key: str, *, suppress=False) -> None: - """ - Delete the given key from the config. - - :param key: The key to delete. - :param suppress: bool - Whether to suppress any errors. Useful in case - code to migrate settings is blindly removing an old key. - :raises OSError: On Windows, if a registry error occurs. - """ - raise NotImplementedError - - @abstractmethod - def save(self) -> None: - """ - Save the current configuration. - - :raises OSError: On Windows, if a Registry error occurs. - """ - raise NotImplementedError - - @abstractmethod - def close(self) -> None: - """Close this config and release any associated resources.""" - raise NotImplementedError - - def get_password(self, account: str) -> None: - """Legacy password retrieval.""" - warnings.warn("password subsystem is no longer supported", DeprecationWarning) - - def set_password(self, account: str, password: str) -> None: - """Legacy password setting.""" - warnings.warn("password subsystem is no longer supported", DeprecationWarning) - - def delete_password(self, account: str) -> None: - """Legacy password deletion.""" - warnings.warn("password subsystem is no longer supported", DeprecationWarning) - - -class WinConfig(AbstractConfig): - """Implementation of AbstractConfig for Windows.""" - - def __init__(self, do_winsparkle=True) -> None: - self.app_dir_path = pathlib.Path(str(known_folder_path(FOLDERID_LocalAppData))) / appname - self.app_dir_path.mkdir(exist_ok=True) - - self.plugin_dir_path = self.app_dir_path / 'plugins' - self.plugin_dir_path.mkdir(exist_ok=True) - - if getattr(sys, 'frozen', False): - self.respath_path = pathlib.Path(sys.executable).parent - self.internal_plugin_dir_path = self.respath_path / 'plugins' - - else: - self.respath_path = pathlib.Path(__file__).parent - self.internal_plugin_dir_path = self.respath_path / 'plugins' - - self.home_path = pathlib.Path.home() - - journal_dir_str = known_folder_path(FOLDERID_SavedGames) - journaldir = pathlib.Path(journal_dir_str) if journal_dir_str is not None else None - self.default_journal_dir_path = None # type: ignore - if journaldir is not None: - self.default_journal_dir_path = journaldir / 'Frontier Developments' / 'Elite Dangerous' - - create_key_defaults = functools.partial( - winreg.CreateKeyEx, - key=winreg.HKEY_CURRENT_USER, - access=winreg.KEY_ALL_ACCESS | winreg.KEY_WOW64_64KEY, - ) - - try: - self.__reg_handle: winreg.HKEYType = create_key_defaults( - sub_key=r'Software\Marginal\EDMarketConnector' - ) - if do_winsparkle: - self.__setup_winsparkle() - - except OSError: - logger.exception('could not create required registry keys') - raise - - self.identifier = applongname - if (outdir_str := self.get_str('outdir')) is None or not pathlib.Path(outdir_str).is_dir(): - docs = known_folder_path(FOLDERID_Documents) - self.set('outdir', docs if docs is not None else self.home) - - def __setup_winsparkle(self): - """Ensure the necessary Registry keys for WinSparkle are present.""" - create_key_defaults = functools.partial( - winreg.CreateKeyEx, - key=winreg.HKEY_CURRENT_USER, - access=winreg.KEY_ALL_ACCESS | winreg.KEY_WOW64_64KEY, - ) - try: - edcd_handle: winreg.HKEYType = create_key_defaults(sub_key=r'Software\EDCD\EDMarketConnector') - winsparkle_reg: winreg.HKEYType = winreg.CreateKeyEx( - edcd_handle, sub_key='WinSparkle', access=winreg.KEY_ALL_ACCESS | winreg.KEY_WOW64_64KEY - ) - - except OSError: - logger.exception('could not open WinSparkle handle') - raise - - # set WinSparkle defaults - https://github.com/vslavik/winsparkle/wiki/Registry-Settings - winreg.SetValueEx( - winsparkle_reg, 'UpdateInterval', REG_RESERVED_ALWAYS_ZERO, winreg.REG_SZ, str(update_interval) - ) - - try: - winreg.QueryValueEx(winsparkle_reg, 'CheckForUpdates') - - except FileNotFoundError: - # Key doesn't exist, set it to a default - winreg.SetValueEx(winsparkle_reg, 'CheckForUpdates', REG_RESERVED_ALWAYS_ZERO, winreg.REG_SZ, '1') - - winsparkle_reg.Close() - edcd_handle.Close() - - def __get_regentry(self, key: str) -> Union[None, list, str, int]: - """Access the Registry for the raw entry.""" - try: - value, _type = winreg.QueryValueEx(self.__reg_handle, key) - except FileNotFoundError: - # Key doesn't exist - return None - - # The type returned is actually as we'd expect for each of these. The casts are here for type checkers and - # For programmers who want to actually know what is going on - if _type == winreg.REG_SZ: - return str(value) - - elif _type == winreg.REG_DWORD: - return int(value) - - elif _type == winreg.REG_MULTI_SZ: - return list(value) - - else: - logger.warning(f'registry key {key=} returned unknown type {_type=} {value=}') - return None - - def get_str(self, key: str, *, default: str = None) -> str: - """ - Return the string referred to by the given key if it exists, or the default. - - Implements :meth:`AbstractConfig.get_str`. - """ - res = self.__get_regentry(key) - if res is None: - return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default - - elif not isinstance(res, str): - raise ValueError(f'Data from registry is not a string: {type(res)=} {res=}') - - return res - - def get_list(self, key: str, *, default: list = None) -> list: - """ - Return the list referred to by the given key if it exists, or the default. - - Implements :meth:`AbstractConfig.get_list`. - """ - res = self.__get_regentry(key) - if res is None: - return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default - - elif not isinstance(res, list): - raise ValueError(f'Data from registry is not a list: {type(res)=} {res}') - - return res - - def get_int(self, key: str, *, default: int = 0) -> int: - """ - Return the int referred to by key if it exists in the config. - - Implements :meth:`AbstractConfig.get_int`. - """ - res = self.__get_regentry(key) - if res is None: - return default - - if not isinstance(res, int): - raise ValueError(f'Data from registry is not an int: {type(res)=} {res}') - - return res - - def get_bool(self, key: str, *, default: bool = None) -> bool: - """ - Return the bool referred to by the given key if it exists, or the default. - - Implements :meth:`AbstractConfig.get_bool`. - """ - res = self.get_int(key, default=default) # type: ignore - if res is None: - return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default - - return bool(res) - - def set(self, key: str, val: Union[int, str, List[str], bool]) -> None: - """ - Set the given key's data to the given value. - - Implements :meth:`AbstractConfig.set`. - """ - reg_type = None - if isinstance(val, str): - reg_type = winreg.REG_SZ - winreg.SetValueEx(self.__reg_handle, key, REG_RESERVED_ALWAYS_ZERO, winreg.REG_SZ, val) - - elif isinstance(val, int): # The original code checked for numbers.Integral, I dont think that is needed. - reg_type = winreg.REG_DWORD - - elif isinstance(val, list): - reg_type = winreg.REG_MULTI_SZ - - elif isinstance(val, bool): - reg_type = winreg.REG_DWORD - val = int(val) - - else: - raise ValueError(f'Unexpected type for value {type(val)=}') - - # Its complaining about the list, it works, tested on windows, ignored. - winreg.SetValueEx(self.__reg_handle, key, REG_RESERVED_ALWAYS_ZERO, reg_type, val) # type: ignore - - def delete(self, key: str, *, suppress=False) -> None: - """ - Delete the given key from the config. - - 'key' is relative to the base Registry path we use. - - Implements :meth:`AbstractConfig.delete`. - """ - try: - winreg.DeleteValue(self.__reg_handle, key) - except OSError: - if suppress: - return - - raise - - def save(self) -> None: - """ - Save the configuration. - - Not required for WinConfig as Registry keys are flushed on write. - """ - pass - - def close(self): - """ - Close this config and release any associated resources. - - Implements :meth:`AbstractConfig.close`. - """ - self.__reg_handle.Close() - - -class MacConfig(AbstractConfig): - """MacConfig is the implementation of AbstractConfig for Darwin based OSes.""" - - def __init__(self) -> None: - super().__init__() - support_path = pathlib.Path( - NSSearchPathForDirectoriesInDomains( - NSApplicationSupportDirectory, NSUserDomainMask, True - )[0] - ) - - self.app_dir_path = support_path / appname - self.app_dir_path.mkdir(exist_ok=True) - - self.plugin_dir_path = self.app_dir_path / 'plugins' - self.plugin_dir_path.mkdir(exist_ok=True) - - # Bundle IDs identify a singled app though out a system - - if getattr(sys, 'frozen', False): - exe_dir = pathlib.Path(sys.executable).parent - self.internal_plugin_dir_path = exe_dir.parent / 'Library' / 'plugins' - self.respath_path = exe_dir.parent / 'Resources' - self.identifier = NSBundle.mainBundle().bundleIdentifier() - - else: - file_dir = pathlib.Path(__file__).parent - self.internal_plugin_dir_path = file_dir / 'plugins' - self.respath_path = file_dir - - self.identifier = f'uk.org.marginal.{appname.lower()}' - NSBundle.mainBundle().infoDictionary()['CFBundleIdentifier'] = self.identifier - - self.default_journal_dir_path = support_path / 'Frontier Developments' / 'Elite Dangerous' - self._defaults = NSUserDefaults.standardUserDefaults() - self._settings: Dict[str, Union[int, str, list]] = dict( - self._defaults.persistentDomainForName_(self.identifier) or {} - ) # make writeable - - if (out_dir := self.get_str('out_dir')) is None or not pathlib.Path(out_dir).exists(): - self.set('outdir', NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, True)[0]) - - def __raw_get(self, key: str) -> Union[None, list, str, int]: - """ - Retrieve the raw data for the given key. - - :param str: str - The key data is being requested for. - :return: The requested data. - """ - res = self._settings.get(key) - # On MacOS Catalina, with python.org python 3.9.2 any 'list' - # has type __NSCFArray so a simple `isinstance(res, list)` is - # False. So, check it's not-None, and not the other types. - # - # If we can find where to import the definition of NSCFArray - # then we could possibly test against that. - if res is not None and not isinstance(res, str) and not isinstance(res, int): - return list(res) - - return res - - def get_str(self, key: str, *, default: str = None) -> str: - """ - Return the string referred to by the given key if it exists, or the default. - - Implements :meth:`AbstractConfig.get_str`. - """ - res = self.__raw_get(key) - if res is None: - return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default - - if not isinstance(res, str): - raise ValueError(f'unexpected data returned from __raw_get: {type(res)=} {res}') - - return res - - def get_list(self, key: str, *, default: list = None) -> list: - """ - Return the list referred to by the given key if it exists, or the default. - - Implements :meth:`AbstractConfig.get_list`. - """ - res = self.__raw_get(key) - if res is None: - return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default - - elif not isinstance(res, list): - raise ValueError(f'__raw_get returned unexpected type {type(res)=} {res!r}') - - return res - - def get_int(self, key: str, *, default: int = 0) -> int: - """ - Return the int referred to by key if it exists in the config. - - Implements :meth:`AbstractConfig.get_int`. - """ - res = self.__raw_get(key) - if res is None: - return default - - elif not isinstance(res, (str, int)): - raise ValueError(f'__raw_get returned unexpected type {type(res)=} {res!r}') - - try: - return int(res) - - except ValueError as e: - logger.error(f'__raw_get returned {res!r} which cannot be parsed to an int: {e}') - return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default - - def get_bool(self, key: str, *, default: bool = None) -> bool: - """ - Return the bool referred to by the given key if it exists, or the default. - - Implements :meth:`AbstractConfig.get_bool`. - """ - res = self.__raw_get(key) - if res is None: - return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default - - elif not isinstance(res, bool): - raise ValueError(f'__raw_get returned unexpected type {type(res)=} {res!r}') - - return res - - def set(self, key: str, val: Union[int, str, List[str], bool]) -> None: - """ - Set the given key's data to the given value. - - Implements :meth:`AbstractConfig.set`. - """ - if self._settings is None: - raise ValueError('attempt to use a closed _settings') - - if not isinstance(val, (bool, str, int, list)): - raise ValueError(f'Unexpected type for value {type(val)=}') - - self._settings[key] = val - - def delete(self, key: str, *, suppress=False) -> None: - """ - Delete the given key from the config. - - Implements :meth:`AbstractConfig.delete`. - """ - try: - del self._settings[key] - - except Exception: - if suppress: - pass - - def save(self) -> None: - """ - Save the current configuration. - - Implements :meth:`AbstractConfig.save`. - """ - self._defaults.setPersistentDomain_forName_(self._settings, self.identifier) - self._defaults.synchronize() - - def close(self) -> None: - """ - Close this config and release any associated resources. - - Implements :meth:`AbstractConfig.close`. - """ - self.save() - self._defaults = None - - -class LinuxConfig(AbstractConfig): - """Linux implementation of AbstractConfig.""" - - SECTION = 'config' - # TODO: I dislike this, would rather use a sane config file format. But here we are. - __unescape_lut = {'\\': '\\', 'n': '\n', ';': ';', 'r': '\r', '#': '#'} - __escape_lut = {'\\': '\\', '\n': 'n', ';': ';', '\r': 'r'} - - def __init__(self, filename: Optional[str] = None) -> None: - super().__init__() - # http://standards.freedesktop.org/basedir-spec/latest/ar01s03.html - xdg_data_home = pathlib.Path(os.getenv('XDG_DATA_HOME', default='~/.local/share')).expanduser() - self.app_dir_path = xdg_data_home / appname - self.app_dir_path.mkdir(exist_ok=True, parents=True) - - self.plugin_dir_path = self.app_dir_path / 'plugins' - self.plugin_dir_path.mkdir(exist_ok=True) - - self.respath_path = pathlib.Path(__file__).parent - - self.internal_plugin_dir_path = self.respath_path / 'plugins' - self.default_journal_dir_path = None # type: ignore - self.identifier = f'uk.org.marginal.{appname.lower()}' # TODO: Unused? - - config_home = pathlib.Path(os.getenv('XDG_CONFIG_HOME', default='~/.config')).expanduser() - - self.filename = config_home / appname / f'{appname}.ini' - if filename is not None: - self.filename = pathlib.Path(filename) - - self.filename.parent.mkdir(exist_ok=True, parents=True) - - self.config: Optional[ConfigParser] = ConfigParser(comment_prefixes=('#',), interpolation=None) - self.config.read(self.filename) # read() ignores files that dont exist - - # Ensure that our section exists. This is here because configparser will happily create files for us, but it - # does not magically create sections - try: - self.config[self.SECTION].get("this_does_not_exist", fallback=None) - except KeyError: - logger.info("Config section not found. Backing up existing file (if any) and readding a section header") - if self.filename.exists(): - (self.filename.parent / f'{appname}.ini.backup').write_bytes(self.filename.read_bytes()) - - self.config.add_section(self.SECTION) - - if (outdir := self.get_str('outdir')) is None or not pathlib.Path(outdir).is_dir(): - self.set('outdir', self.home) - - def __escape(self, s: str) -> str: - """ - Escape a string using self.__escape_lut. - - This does NOT support multi-character escapes. - - :param s: str - String to be escaped. - :return: str - The escaped string. - """ - out = "" - for c in s: - if c not in self.__escape_lut: - out += c - continue - - out += '\\' + self.__escape_lut[c] - - return out - - def __unescape(self, s: str) -> str: - """ - Unescape a string. - - :param s: str - The string to unescape. - :return: str - The unescaped string. - """ - out: List[str] = [] - i = 0 - while i < len(s): - c = s[i] - if c != '\\': - out.append(c) - i += 1 - continue - - # We have a backslash, check what its escaping - if i == len(s)-1: - raise ValueError('Escaped string has unescaped trailer') - - unescaped = self.__unescape_lut.get(s[i+1]) - if unescaped is None: - raise ValueError(f'Unknown escape: \\ {s[i+1]}') - - out.append(unescaped) - i += 2 - - return "".join(out) - - def __raw_get(self, key: str) -> Optional[str]: - """ - Get a raw data value from the config file. - - :param key: str - The key data is being requested for. - :return: str - The raw data, if found. - """ - if self.config is None: - raise ValueError('Attempt to use a closed config') - - return self.config[self.SECTION].get(key) - - def get_str(self, key: str, *, default: str = None) -> str: - """ - Return the string referred to by the given key if it exists, or the default. - - Implements :meth:`AbstractConfig.get_str`. - """ - data = self.__raw_get(key) - if data is None: - return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default - - if '\n' in data: - raise ValueError('asked for string, got list') - - return self.__unescape(data) - - def get_list(self, key: str, *, default: list = None) -> list: - """ - Return the list referred to by the given key if it exists, or the default. - - Implements :meth:`AbstractConfig.get_list`. - """ - data = self.__raw_get(key) - - if data is None: - return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default - - split = data.split('\n') - if split[-1] != ';': - raise ValueError('Encoded list does not have trailer sentinel') - - return list(map(self.__unescape, split[:-1])) - - def get_int(self, key: str, *, default: int = 0) -> int: - """ - Return the int referred to by key if it exists in the config. - - Implements :meth:`AbstractConfig.get_int`. - """ - data = self.__raw_get(key) - - if data is None: - return default - - try: - return int(data) - - except ValueError as e: - raise ValueError(f'requested {key=} as int cannot be converted to int') from e - - def get_bool(self, key: str, *, default: bool = None) -> bool: - """ - Return the bool referred to by the given key if it exists, or the default. - - Implements :meth:`AbstractConfig.get_bool`. - """ - if self.config is None: - raise ValueError('attempt to use a closed config') - - data = self.__raw_get(key) - if data is None: - return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default - - return bool(int(data)) - - def set(self, key: str, val: Union[int, str, List[str]]) -> None: - """ - Set the given key's data to the given value. - - Implements :meth:`AbstractConfig.set`. - """ - if self.config is None: - raise ValueError('attempt to use a closed config') - - to_set: Optional[str] = None - if isinstance(val, bool): - to_set = str(int(val)) - - elif isinstance(val, str): - to_set = self.__escape(val) - - elif isinstance(val, int): - to_set = str(val) - - elif isinstance(val, list): - to_set = '\n'.join([self.__escape(s) for s in val] + [';']) - - else: - raise ValueError(f'Unexpected type for value {type(val)=}') - - self.config.set(self.SECTION, key, to_set) - self.save() - - def delete(self, key: str, *, suppress=False) -> None: - """ - Delete the given key from the config. - - Implements :meth:`AbstractConfig.delete`. - """ - if self.config is None: - raise ValueError('attempt to use a closed config') - - self.config.remove_option(self.SECTION, key) - self.save() - - def save(self) -> None: - """ - Save the current configuration. - - Implements :meth:`AbstractConfig.save`. - """ - if self.config is None: - raise ValueError('attempt to use a closed config') - - with open(self.filename, 'w', encoding='utf-8') as f: - self.config.write(f) - - def close(self) -> None: - """ - Close this config and release any associated resources. - - Implements :meth:`AbstractConfig.close`. - """ - self.save() - self.config = None - - -def get_config(*args, **kwargs) -> AbstractConfig: - """ - Get the appropriate config class for the current platform. - - :param args: Args to be passed through to implementation. - :param kwargs: Args to be passed through to implementation. - :return: Instance of the implementation. - """ - if sys.platform == "darwin": - return MacConfig(*args, **kwargs) - elif sys.platform == "win32": - return WinConfig(*args, **kwargs) - elif sys.platform == "linux": - return LinuxConfig(*args, **kwargs) - else: - raise ValueError(f'Unknown platform: {sys.platform=}') - - -config = get_config() diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 00000000..eff2859b --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,473 @@ +""" +Code dealing with the configuration of the program. + +Windows uses the Registry to store values in a flat manner. +Linux uses a file, but for commonality it's still a flat data structure. +macOS uses a 'defaults' object. +""" + + +__all__ = [ + # defined in the order they appear in the file + 'GITVERSION_FILE', + 'appname', + 'applongname', + 'appcmdname', + 'copyright', + 'update_feed', + 'update_interval', + 'debug_senders', + 'trace_on', + 'capi_pretend_down', + 'capi_debug_access_token', + 'logger', + 'git_shorthash_from_head', + 'appversion', + 'user_agent', + 'appversion_nobuild', + 'AbstractConfig', + 'config' +] + +import abc +import contextlib +import logging +import os +import pathlib +import re +import subprocess +import sys +import traceback +import warnings +from abc import abstractmethod +from typing import Any, Callable, List, Optional, Type, TypeVar, Union + +import semantic_version + +from constants import GITVERSION_FILE, applongname, appname + +# Any of these may be imported by plugins +appcmdname = 'EDMC' +# appversion **MUST** follow Semantic Versioning rules: +# +# Major.Minor.Patch(-prerelease)(+buildmetadata) +# NB: Do *not* import this, use the functions appversion() and appversion_nobuild() +_static_appversion = '5.3.0-beta4' +_cached_version: Optional[semantic_version.Version] = None +copyright = '© 2015-2019 Jonathan Harris, 2020-2022 EDCD' + +update_feed = 'https://raw.githubusercontent.com/EDCD/EDMarketConnector/releases/edmarketconnector.xml' +update_interval = 8*60*60 +# Providers marked to be in debug mode. Generally this is expected to switch to sending data to a log file +debug_senders: List[str] = [] +# TRACE logging code that should actually be used. Means not spamming it +# *all* if only interested in some things. +trace_on: List[str] = [] + +capi_pretend_down: bool = False +capi_debug_access_token: Optional[str] = None +# This must be done here in order to avoid an import cycle with EDMCLogging. +# Other code should use EDMCLogging.get_main_logger +if os.getenv("EDMC_NO_UI"): + logger = logging.getLogger(appcmdname) + +else: + logger = logging.getLogger(appname) + + +_T = TypeVar('_T') + + +########################################################################### +def git_shorthash_from_head() -> str: + """ + Determine short hash for current git HEAD. + + Includes `.DIRTY` if any changes have been made from HEAD + + :return: str - None if we couldn't determine the short hash. + """ + shorthash: str = None # type: ignore + + try: + git_cmd = subprocess.Popen('git rev-parse --short HEAD'.split(), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT + ) + out, err = git_cmd.communicate() + + except Exception as e: + logger.info(f"Couldn't run git command for short hash: {e!r}") + + else: + shorthash = out.decode().rstrip('\n') + if re.match(r'^[0-9a-f]{7,}$', shorthash) is None: + logger.error(f"'{shorthash}' doesn't look like a valid git short hash, forcing to None") + shorthash = None # type: ignore + + if shorthash is not None: + with contextlib.suppress(Exception): + result = subprocess.run('git diff --stat HEAD'.split(), capture_output=True) + if len(result.stdout) > 0: + shorthash += '.DIRTY' + + if len(result.stderr) > 0: + logger.warning(f'Data from git on stderr:\n{str(result.stderr)}') + + return shorthash + + +def appversion() -> semantic_version.Version: + """ + Determine app version including git short hash if possible. + + :return: The augmented app version. + """ + global _cached_version + if _cached_version is not None: + return _cached_version + + if getattr(sys, 'frozen', False): + # Running frozen, so we should have a .gitversion file + # Yes, .parent because if frozen we're inside library.zip + with open(pathlib.Path(sys.path[0]).parent / GITVERSION_FILE, 'r', encoding='utf-8') as gitv: + shorthash = gitv.read() + + else: + # Running from source + shorthash = git_shorthash_from_head() + if shorthash is None: + shorthash = 'UNKNOWN' + + _cached_version = semantic_version.Version(f'{_static_appversion}+{shorthash}') + return _cached_version + + +user_agent = f'EDCD-{appname}-{appversion()}' + + +def appversion_nobuild() -> semantic_version.Version: + """ + Determine app version without *any* build meta data. + + This will not only strip any added git short hash, but also any trailing + '+' in _static_appversion. + + :return: App version without any build meta data. + """ + return appversion().truncate('prerelease') +########################################################################### + + +class AbstractConfig(abc.ABC): + """Abstract root class of all platform specific Config implementations.""" + + OUT_MKT_EDDN = 1 + # OUT_MKT_BPC = 2 # No longer supported + OUT_MKT_TD = 4 + OUT_MKT_CSV = 8 + OUT_SHIP = 16 + # OUT_SHIP_EDS = 16 # Replaced by OUT_SHIP + # OUT_SYS_FILE = 32 # No longer supported + # OUT_STAT = 64 # No longer available + # OUT_SHIP_CORIOLIS = 128 # Replaced by OUT_SHIP + OUT_STATION_ANY = OUT_MKT_EDDN | OUT_MKT_TD | OUT_MKT_CSV + # OUT_SYS_EDSM = 256 # Now a plugin + # OUT_SYS_AUTO = 512 # Now always automatic + OUT_MKT_MANUAL = 1024 + OUT_SYS_EDDN = 2048 + OUT_SYS_DELAY = 4096 + + app_dir_path: pathlib.Path + plugin_dir_path: pathlib.Path + internal_plugin_dir_path: pathlib.Path + respath_path: pathlib.Path + home_path: pathlib.Path + default_journal_dir_path: pathlib.Path + + identifier: str + + __in_shutdown = False # Is the application currently shutting down ? + __auth_force_localserver = False # Should we use localhost for auth callback ? + __auth_force_edmc_protocol = False # Should we force edmc:// protocol ? + __eddn_url = None # Non-default EDDN URL + __eddn_tracking_ui = False # Show EDDN tracking UI ? + + def __init__(self) -> None: + self.home_path = pathlib.Path.home() + + def set_shutdown(self): + """Set flag denoting we're in the shutdown sequence.""" + self.__in_shutdown = True + + @property + def shutting_down(self) -> bool: + """ + Determine if we're in the shutdown sequence. + + :return: bool - True if in shutdown sequence. + """ + return self.__in_shutdown + + def set_auth_force_localserver(self): + """Set flag to force use of localhost web server for Frontier Auth callback.""" + self.__auth_force_localserver = True + + @property + def auth_force_localserver(self) -> bool: + """ + Determine if use of localhost is forced for Frontier Auth callback. + + :return: bool - True if we should use localhost web server. + """ + return self.__auth_force_localserver + + def set_auth_force_edmc_protocol(self): + """Set flag to force use of localhost web server for Frontier Auth callback.""" + self.__auth_force_edmc_protocol = True + + @property + def auth_force_edmc_protocol(self) -> bool: + """ + Determine if use of localhost is forced for Frontier Auth callback. + + :return: bool - True if we should use localhost web server. + """ + return self.__auth_force_edmc_protocol + + def set_eddn_url(self, eddn_url: str): + """Set the specified eddn URL.""" + self.__eddn_url = eddn_url + + @property + def eddn_url(self) -> Optional[str]: + """ + Provide the custom EDDN URL. + + :return: str - Custom EDDN URL to use. + """ + return self.__eddn_url + + def set_eddn_tracking_ui(self): + """Activate EDDN tracking UI.""" + self.__eddn_tracking_ui = True + + @property + def eddn_tracking_ui(self) -> bool: + """ + Determine if the EDDN tracking UI be shown. + + :return: bool - Should tracking UI be active? + """ + return self.__eddn_tracking_ui + + @property + def app_dir(self) -> str: + """Return a string version of app_dir.""" + return str(self.app_dir_path) + + @property + def plugin_dir(self) -> str: + """Return a string version of plugin_dir.""" + return str(self.plugin_dir_path) + + @property + def internal_plugin_dir(self) -> str: + """Return a string version of internal_plugin_dir.""" + return str(self.internal_plugin_dir_path) + + @property + def respath(self) -> str: + """Return a string version of respath.""" + return str(self.respath_path) + + @property + def home(self) -> str: + """Return a string version of home.""" + return str(self.home_path) + + @property + def default_journal_dir(self) -> str: + """Return a string version of default_journal_dir.""" + return str(self.default_journal_dir_path) + + @staticmethod + def _suppress_call( + func: Callable[..., _T], exceptions: Union[Type[BaseException], List[Type[BaseException]]] = Exception, + *args: Any, **kwargs: Any + ) -> Optional[_T]: + if exceptions is None: + exceptions = [Exception] + + if not isinstance(exceptions, list): + exceptions = [exceptions] + + with contextlib.suppress(*exceptions): # type: ignore # it works fine, mypy + return func(*args, **kwargs) + + return None + + def get(self, key: str, default: Union[list, str, bool, int] = None) -> Union[list, str, bool, int]: + """ + Return the data for the requested key, or a default. + + :param key: The key data is being requested for. + :param default: The default to return if the key does not exist, defaults to None. + :raises OSError: On Windows, if a Registry error occurs. + :return: The data or the default. + """ + warnings.warn(DeprecationWarning('get is Deprecated. use the specific getter for your type')) + logger.debug('Attempt to use Deprecated get() method\n' + ''.join(traceback.format_stack())) + + if (l := self._suppress_call(self.get_list, ValueError, key, default=None)) is not None: + return l + + elif (s := self._suppress_call(self.get_str, ValueError, key, default=None)) is not None: + return s + + elif (b := self._suppress_call(self.get_bool, ValueError, key, default=None)) is not None: + return b + + elif (i := self._suppress_call(self.get_int, ValueError, key, default=None)) is not None: + return i + + return default # type: ignore + + @abstractmethod + def get_list(self, key: str, *, default: list = None) -> list: + """ + Return the list referred to by the given key if it exists, or the default. + + Implements :meth:`AbstractConfig.get_list`. + """ + raise NotImplementedError + + @abstractmethod + def get_str(self, key: str, *, default: str = None) -> str: + """ + Return the string referred to by the given key if it exists, or the default. + + :param key: The key data is being requested for. + :param default: Default to return if the key does not exist, defaults to None. + :raises ValueError: If an internal error occurs getting or converting a value. + :raises OSError: On Windows, if a Registry error occurs. + :return: The requested data or the default. + """ + raise NotImplementedError + + @abstractmethod + def get_bool(self, key: str, *, default: bool = None) -> bool: + """ + Return the bool referred to by the given key if it exists, or the default. + + :param key: The key data is being requested for. + :param default: Default to return if the key does not exist, defaults to None + :raises ValueError: If an internal error occurs getting or converting a value + :raises OSError: On Windows, if a Registry error occurs. + :return: The requested data or the default + """ + raise NotImplementedError + + def getint(self, key: str, *, default: int = 0) -> int: + """ + Getint is a Deprecated getter method. + + See get_int for its replacement. + :raises OSError: On Windows, if a Registry error occurs. + """ + warnings.warn(DeprecationWarning('getint is Deprecated. Use get_int instead')) + logger.debug('Attempt to use Deprecated getint() method\n' + ''.join(traceback.format_stack())) + + return self.get_int(key, default=default) + + @abstractmethod + def get_int(self, key: str, *, default: int = 0) -> int: + """ + Return the int referred to by key if it exists in the config. + + For legacy reasons, the default is 0 and not None. + + :param key: The key data is being requested for. + :param default: Default to return if the key does not exist, defaults to 0. + :raises ValueError: If the internal representation of this key cannot be converted to an int. + :raises OSError: On Windows, if a Registry error occurs. + :return: The requested data or the default. + """ + raise NotImplementedError + + @abstractmethod + def set(self, key: str, val: Union[int, str, List[str], bool]) -> None: + """ + Set the given key's data to the given value. + + :param key: The key to set the value on. + :param val: The value to set the key's data to. + :raises ValueError: On an invalid type. + :raises OSError: On Windows, if a Registry error occurs. + """ + raise NotImplementedError + + @abstractmethod + def delete(self, key: str, *, suppress=False) -> None: + """ + Delete the given key from the config. + + :param key: The key to delete. + :param suppress: bool - Whether to suppress any errors. Useful in case + code to migrate settings is blindly removing an old key. + :raises OSError: On Windows, if a registry error occurs. + """ + raise NotImplementedError + + @abstractmethod + def save(self) -> None: + """ + Save the current configuration. + + :raises OSError: On Windows, if a Registry error occurs. + """ + raise NotImplementedError + + @abstractmethod + def close(self) -> None: + """Close this config and release any associated resources.""" + raise NotImplementedError + + def get_password(self, account: str) -> None: + """Legacy password retrieval.""" + warnings.warn("password subsystem is no longer supported", DeprecationWarning) + + def set_password(self, account: str, password: str) -> None: + """Legacy password setting.""" + warnings.warn("password subsystem is no longer supported", DeprecationWarning) + + def delete_password(self, account: str) -> None: + """Legacy password deletion.""" + warnings.warn("password subsystem is no longer supported", DeprecationWarning) + + +def get_config(*args, **kwargs) -> AbstractConfig: + """ + Get the appropriate config class for the current platform. + + :param args: Args to be passed through to implementation. + :param kwargs: Args to be passed through to implementation. + :return: Instance of the implementation. + """ + if sys.platform == "darwin": + from .darwin import MacConfig + return MacConfig(*args, **kwargs) + + elif sys.platform == "win32": + from .windows import WinConfig + return WinConfig(*args, **kwargs) + + elif sys.platform == "linux": + from .linux import LinuxConfig + return LinuxConfig(*args, **kwargs) + + else: + raise ValueError(f'Unknown platform: {sys.platform=}') + + +config = get_config() diff --git a/config/darwin.py b/config/darwin.py new file mode 100644 index 00000000..eb2b887f --- /dev/null +++ b/config/darwin.py @@ -0,0 +1,184 @@ +import pathlib +import sys +from typing import Any, Dict, List, Union + +from Foundation import ( # type: ignore + NSApplicationSupportDirectory, NSBundle, NSDocumentDirectory, NSSearchPathForDirectoriesInDomains, NSUserDefaults, + NSUserDomainMask +) + +from config import AbstractConfig, appname, logger + +assert sys.platform == 'darwin' + + +class MacConfig(AbstractConfig): + """MacConfig is the implementation of AbstractConfig for Darwin based OSes.""" + + def __init__(self) -> None: + super().__init__() + support_path = pathlib.Path( + NSSearchPathForDirectoriesInDomains( + NSApplicationSupportDirectory, NSUserDomainMask, True + )[0] + ) + + self.app_dir_path = support_path / appname + self.app_dir_path.mkdir(exist_ok=True) + + self.plugin_dir_path = self.app_dir_path / 'plugins' + self.plugin_dir_path.mkdir(exist_ok=True) + + # Bundle IDs identify a singled app though out a system + + if getattr(sys, 'frozen', False): + exe_dir = pathlib.Path(sys.executable).parent + self.internal_plugin_dir_path = exe_dir.parent / 'Library' / 'plugins' + self.respath_path = exe_dir.parent / 'Resources' + self.identifier = NSBundle.mainBundle().bundleIdentifier() + + else: + file_dir = pathlib.Path(__file__).parent.parent + self.internal_plugin_dir_path = file_dir / 'plugins' + self.respath_path = file_dir + + self.identifier = f'uk.org.marginal.{appname.lower()}' + NSBundle.mainBundle().infoDictionary()['CFBundleIdentifier'] = self.identifier + + self.default_journal_dir_path = support_path / 'Frontier Developments' / 'Elite Dangerous' + self._defaults: Any = NSUserDefaults.standardUserDefaults() + self._settings: Dict[str, Union[int, str, list]] = dict( + self._defaults.persistentDomainForName_(self.identifier) or {} + ) # make writeable + + if (out_dir := self.get_str('out_dir')) is None or not pathlib.Path(out_dir).exists(): + self.set('outdir', NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, True)[0]) + + def __raw_get(self, key: str) -> Union[None, list, str, int]: + """ + Retrieve the raw data for the given key. + + :param str: str - The key data is being requested for. + :return: The requested data. + """ + res = self._settings.get(key) + # On MacOS Catalina, with python.org python 3.9.2 any 'list' + # has type __NSCFArray so a simple `isinstance(res, list)` is + # False. So, check it's not-None, and not the other types. + # + # If we can find where to import the definition of NSCFArray + # then we could possibly test against that. + if res is not None and not isinstance(res, str) and not isinstance(res, int): + return list(res) + + return res + + def get_str(self, key: str, *, default: str = None) -> str: + """ + Return the string referred to by the given key if it exists, or the default. + + Implements :meth:`AbstractConfig.get_str`. + """ + res = self.__raw_get(key) + if res is None: + return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default + + if not isinstance(res, str): + raise ValueError(f'unexpected data returned from __raw_get: {type(res)=} {res}') + + return res + + def get_list(self, key: str, *, default: list = None) -> list: + """ + Return the list referred to by the given key if it exists, or the default. + + Implements :meth:`AbstractConfig.get_list`. + """ + res = self.__raw_get(key) + if res is None: + return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default + + elif not isinstance(res, list): + raise ValueError(f'__raw_get returned unexpected type {type(res)=} {res!r}') + + return res + + def get_int(self, key: str, *, default: int = 0) -> int: + """ + Return the int referred to by key if it exists in the config. + + Implements :meth:`AbstractConfig.get_int`. + """ + res = self.__raw_get(key) + if res is None: + return default + + elif not isinstance(res, (str, int)): + raise ValueError(f'__raw_get returned unexpected type {type(res)=} {res!r}') + + try: + return int(res) + + except ValueError as e: + logger.error(f'__raw_get returned {res!r} which cannot be parsed to an int: {e}') + return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default + + def get_bool(self, key: str, *, default: bool = None) -> bool: + """ + Return the bool referred to by the given key if it exists, or the default. + + Implements :meth:`AbstractConfig.get_bool`. + """ + res = self.__raw_get(key) + if res is None: + return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default + + elif not isinstance(res, bool): + raise ValueError(f'__raw_get returned unexpected type {type(res)=} {res!r}') + + return res + + def set(self, key: str, val: Union[int, str, List[str], bool]) -> None: + """ + Set the given key's data to the given value. + + Implements :meth:`AbstractConfig.set`. + """ + if self._settings is None: + raise ValueError('attempt to use a closed _settings') + + if not isinstance(val, (bool, str, int, list)): + raise ValueError(f'Unexpected type for value {type(val)=}') + + self._settings[key] = val + + def delete(self, key: str, *, suppress=False) -> None: + """ + Delete the given key from the config. + + Implements :meth:`AbstractConfig.delete`. + """ + try: + del self._settings[key] + + except Exception: + if suppress: + pass + + def save(self) -> None: + """ + Save the current configuration. + + Implements :meth:`AbstractConfig.save`. + """ + self._defaults.setPersistentDomain_forName_(self._settings, self.identifier) + self._defaults.synchronize() + + def close(self) -> None: + """ + Close this config and release any associated resources. + + Implements :meth:`AbstractConfig.close`. + """ + self.save() + self._defaults = None diff --git a/config/linux.py b/config/linux.py new file mode 100644 index 00000000..58c0f7cd --- /dev/null +++ b/config/linux.py @@ -0,0 +1,245 @@ +"""Linux config implementation.""" +import os +import pathlib +import sys +from configparser import ConfigParser +from typing import TYPE_CHECKING, List, Optional, Union + +from config import AbstractConfig, appname, logger + +assert sys.platform == 'linux' + + +class LinuxConfig(AbstractConfig): + """Linux implementation of AbstractConfig.""" + + SECTION = 'config' + # TODO: I dislike this, would rather use a sane config file format. But here we are. + __unescape_lut = {'\\': '\\', 'n': '\n', ';': ';', 'r': '\r', '#': '#'} + __escape_lut = {'\\': '\\', '\n': 'n', ';': ';', '\r': 'r'} + + def __init__(self, filename: Optional[str] = None) -> None: + super().__init__() + # http://standards.freedesktop.org/basedir-spec/latest/ar01s03.html + xdg_data_home = pathlib.Path(os.getenv('XDG_DATA_HOME', default='~/.local/share')).expanduser() + self.app_dir_path = xdg_data_home / appname + self.app_dir_path.mkdir(exist_ok=True, parents=True) + + self.plugin_dir_path = self.app_dir_path / 'plugins' + self.plugin_dir_path.mkdir(exist_ok=True) + + self.respath_path = pathlib.Path(__file__).parent.parent + + self.internal_plugin_dir_path = self.respath_path / 'plugins' + self.default_journal_dir_path = None # type: ignore + self.identifier = f'uk.org.marginal.{appname.lower()}' # TODO: Unused? + + config_home = pathlib.Path(os.getenv('XDG_CONFIG_HOME', default='~/.config')).expanduser() + + self.filename = config_home / appname / f'{appname}.ini' + if filename is not None: + self.filename = pathlib.Path(filename) + + self.filename.parent.mkdir(exist_ok=True, parents=True) + + self.config: Optional[ConfigParser] = ConfigParser(comment_prefixes=('#',), interpolation=None) + self.config.read(self.filename) # read() ignores files that dont exist + + # Ensure that our section exists. This is here because configparser will happily create files for us, but it + # does not magically create sections + try: + self.config[self.SECTION].get("this_does_not_exist", fallback=None) + except KeyError: + logger.info("Config section not found. Backing up existing file (if any) and readding a section header") + if self.filename.exists(): + (self.filename.parent / f'{appname}.ini.backup').write_bytes(self.filename.read_bytes()) + + self.config.add_section(self.SECTION) + + if (outdir := self.get_str('outdir')) is None or not pathlib.Path(outdir).is_dir(): + self.set('outdir', self.home) + + def __escape(self, s: str) -> str: + """ + Escape a string using self.__escape_lut. + + This does NOT support multi-character escapes. + + :param s: str - String to be escaped. + :return: str - The escaped string. + """ + out = "" + for c in s: + if c not in self.__escape_lut: + out += c + continue + + out += '\\' + self.__escape_lut[c] + + return out + + def __unescape(self, s: str) -> str: + """ + Unescape a string. + + :param s: str - The string to unescape. + :return: str - The unescaped string. + """ + out: List[str] = [] + i = 0 + while i < len(s): + c = s[i] + if c != '\\': + out.append(c) + i += 1 + continue + + # We have a backslash, check what its escaping + if i == len(s)-1: + raise ValueError('Escaped string has unescaped trailer') + + unescaped = self.__unescape_lut.get(s[i+1]) + if unescaped is None: + raise ValueError(f'Unknown escape: \\ {s[i+1]}') + + out.append(unescaped) + i += 2 + + return "".join(out) + + def __raw_get(self, key: str) -> Optional[str]: + """ + Get a raw data value from the config file. + + :param key: str - The key data is being requested for. + :return: str - The raw data, if found. + """ + if self.config is None: + raise ValueError('Attempt to use a closed config') + + return self.config[self.SECTION].get(key) + + def get_str(self, key: str, *, default: str = None) -> str: + """ + Return the string referred to by the given key if it exists, or the default. + + Implements :meth:`AbstractConfig.get_str`. + """ + data = self.__raw_get(key) + if data is None: + return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default + + if '\n' in data: + raise ValueError('asked for string, got list') + + return self.__unescape(data) + + def get_list(self, key: str, *, default: list = None) -> list: + """ + Return the list referred to by the given key if it exists, or the default. + + Implements :meth:`AbstractConfig.get_list`. + """ + data = self.__raw_get(key) + + if data is None: + return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default + + split = data.split('\n') + if split[-1] != ';': + raise ValueError('Encoded list does not have trailer sentinel') + + return list(map(self.__unescape, split[:-1])) + + def get_int(self, key: str, *, default: int = 0) -> int: + """ + Return the int referred to by key if it exists in the config. + + Implements :meth:`AbstractConfig.get_int`. + """ + data = self.__raw_get(key) + + if data is None: + return default + + try: + return int(data) + + except ValueError as e: + raise ValueError(f'requested {key=} as int cannot be converted to int') from e + + def get_bool(self, key: str, *, default: bool = None) -> bool: + """ + Return the bool referred to by the given key if it exists, or the default. + + Implements :meth:`AbstractConfig.get_bool`. + """ + if self.config is None: + raise ValueError('attempt to use a closed config') + + data = self.__raw_get(key) + if data is None: + return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default + + return bool(int(data)) + + def set(self, key: str, val: Union[int, str, List[str]]) -> None: + """ + Set the given key's data to the given value. + + Implements :meth:`AbstractConfig.set`. + """ + if self.config is None: + raise ValueError('attempt to use a closed config') + + to_set: Optional[str] = None + if isinstance(val, bool): + to_set = str(int(val)) + + elif isinstance(val, str): + to_set = self.__escape(val) + + elif isinstance(val, int): + to_set = str(val) + + elif isinstance(val, list): + to_set = '\n'.join([self.__escape(s) for s in val] + [';']) + + else: + raise ValueError(f'Unexpected type for value {type(val)=}') + + self.config.set(self.SECTION, key, to_set) + self.save() + + def delete(self, key: str, *, suppress=False) -> None: + """ + Delete the given key from the config. + + Implements :meth:`AbstractConfig.delete`. + """ + if self.config is None: + raise ValueError('attempt to use a closed config') + + self.config.remove_option(self.SECTION, key) + self.save() + + def save(self) -> None: + """ + Save the current configuration. + + Implements :meth:`AbstractConfig.save`. + """ + if self.config is None: + raise ValueError('attempt to use a closed config') + + with open(self.filename, 'w', encoding='utf-8') as f: + self.config.write(f) + + def close(self) -> None: + """ + Close this config and release any associated resources. + + Implements :meth:`AbstractConfig.close`. + """ + self.save() + self.config = None diff --git a/config/windows.py b/config/windows.py new file mode 100644 index 00000000..d29a2b42 --- /dev/null +++ b/config/windows.py @@ -0,0 +1,259 @@ +"""Windows config implementation.""" + +# spell-checker: words folderid deps hkey edcd +import ctypes +import functools +import pathlib +import sys +import uuid +import winreg +from ctypes.wintypes import DWORD, HANDLE +from typing import List, Optional, Union + +from config import AbstractConfig, applongname, appname, logger, update_interval + +assert sys.platform == 'win32' + +REG_RESERVED_ALWAYS_ZERO = 0 + +# This is the only way to do this from python without external deps (which do this anyway). +FOLDERID_Documents = uuid.UUID('{FDD39AD0-238F-46AF-ADB4-6C85480369C7}') +FOLDERID_LocalAppData = uuid.UUID('{F1B32785-6FBA-4FCF-9D55-7B8E7F157091}') +FOLDERID_Profile = uuid.UUID('{5E6C858F-0E22-4760-9AFE-EA3317B67173}') +FOLDERID_SavedGames = uuid.UUID('{4C5C32FF-BB9D-43b0-B5B4-2D72E54EAAA4}') + +SHGetKnownFolderPath = ctypes.windll.shell32.SHGetKnownFolderPath +SHGetKnownFolderPath.argtypes = [ctypes.c_char_p, DWORD, HANDLE, ctypes.POINTER(ctypes.c_wchar_p)] + +CoTaskMemFree = ctypes.windll.ole32.CoTaskMemFree +CoTaskMemFree.argtypes = [ctypes.c_void_p] + + +def known_folder_path(guid: uuid.UUID) -> Optional[str]: + """Look up a Windows GUID to actual folder path name.""" + buf = ctypes.c_wchar_p() + if SHGetKnownFolderPath(ctypes.create_string_buffer(guid.bytes_le), 0, 0, ctypes.byref(buf)): + return None + retval = buf.value # copy data + CoTaskMemFree(buf) # and free original + return retval + + +class WinConfig(AbstractConfig): + """Implementation of AbstractConfig for Windows.""" + + def __init__(self, do_winsparkle=True) -> None: + self.app_dir_path = pathlib.Path(str(known_folder_path(FOLDERID_LocalAppData))) / appname + self.app_dir_path.mkdir(exist_ok=True) + + self.plugin_dir_path = self.app_dir_path / 'plugins' + self.plugin_dir_path.mkdir(exist_ok=True) + + if getattr(sys, 'frozen', False): + self.respath_path = pathlib.Path(sys.executable).parent + self.internal_plugin_dir_path = self.respath_path / 'plugins' + + else: + self.respath_path = pathlib.Path(__file__).parent.parent + self.internal_plugin_dir_path = self.respath_path / 'plugins' + + self.home_path = pathlib.Path.home() + + journal_dir_str = known_folder_path(FOLDERID_SavedGames) + journaldir = pathlib.Path(journal_dir_str) if journal_dir_str is not None else None + self.default_journal_dir_path = None # type: ignore + if journaldir is not None: + self.default_journal_dir_path = journaldir / 'Frontier Developments' / 'Elite Dangerous' + + create_key_defaults = functools.partial( + winreg.CreateKeyEx, + key=winreg.HKEY_CURRENT_USER, + access=winreg.KEY_ALL_ACCESS | winreg.KEY_WOW64_64KEY, + ) + + try: + self.__reg_handle: winreg.HKEYType = create_key_defaults( + sub_key=r'Software\Marginal\EDMarketConnector' + ) + if do_winsparkle: + self.__setup_winsparkle() + + except OSError: + logger.exception('could not create required registry keys') + raise + + self.identifier = applongname + if (outdir_str := self.get_str('outdir')) is None or not pathlib.Path(outdir_str).is_dir(): + docs = known_folder_path(FOLDERID_Documents) + self.set('outdir', docs if docs is not None else self.home) + + def __setup_winsparkle(self): + """Ensure the necessary Registry keys for WinSparkle are present.""" + create_key_defaults = functools.partial( + winreg.CreateKeyEx, + key=winreg.HKEY_CURRENT_USER, + access=winreg.KEY_ALL_ACCESS | winreg.KEY_WOW64_64KEY, + ) + try: + edcd_handle: winreg.HKEYType = create_key_defaults(sub_key=r'Software\EDCD\EDMarketConnector') + winsparkle_reg: winreg.HKEYType = winreg.CreateKeyEx( + edcd_handle, sub_key='WinSparkle', access=winreg.KEY_ALL_ACCESS | winreg.KEY_WOW64_64KEY + ) + + except OSError: + logger.exception('could not open WinSparkle handle') + raise + + # set WinSparkle defaults - https://github.com/vslavik/winsparkle/wiki/Registry-Settings + winreg.SetValueEx( + winsparkle_reg, 'UpdateInterval', REG_RESERVED_ALWAYS_ZERO, winreg.REG_SZ, str(update_interval) + ) + + try: + winreg.QueryValueEx(winsparkle_reg, 'CheckForUpdates') + + except FileNotFoundError: + # Key doesn't exist, set it to a default + winreg.SetValueEx(winsparkle_reg, 'CheckForUpdates', REG_RESERVED_ALWAYS_ZERO, winreg.REG_SZ, '1') + + winsparkle_reg.Close() + edcd_handle.Close() + + def __get_regentry(self, key: str) -> Union[None, list, str, int]: + """Access the Registry for the raw entry.""" + try: + value, _type = winreg.QueryValueEx(self.__reg_handle, key) + except FileNotFoundError: + # Key doesn't exist + return None + + # The type returned is actually as we'd expect for each of these. The casts are here for type checkers and + # For programmers who want to actually know what is going on + if _type == winreg.REG_SZ: + return str(value) + + elif _type == winreg.REG_DWORD: + return int(value) + + elif _type == winreg.REG_MULTI_SZ: + return list(value) + + else: + logger.warning(f'registry key {key=} returned unknown type {_type=} {value=}') + return None + + def get_str(self, key: str, *, default: str = None) -> str: + """ + Return the string referred to by the given key if it exists, or the default. + + Implements :meth:`AbstractConfig.get_str`. + """ + res = self.__get_regentry(key) + if res is None: + return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default + + elif not isinstance(res, str): + raise ValueError(f'Data from registry is not a string: {type(res)=} {res=}') + + return res + + def get_list(self, key: str, *, default: list = None) -> list: + """ + Return the list referred to by the given key if it exists, or the default. + + Implements :meth:`AbstractConfig.get_list`. + """ + res = self.__get_regentry(key) + if res is None: + return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default + + elif not isinstance(res, list): + raise ValueError(f'Data from registry is not a list: {type(res)=} {res}') + + return res + + def get_int(self, key: str, *, default: int = 0) -> int: + """ + Return the int referred to by key if it exists in the config. + + Implements :meth:`AbstractConfig.get_int`. + """ + res = self.__get_regentry(key) + if res is None: + return default + + if not isinstance(res, int): + raise ValueError(f'Data from registry is not an int: {type(res)=} {res}') + + return res + + def get_bool(self, key: str, *, default: bool = None) -> bool: + """ + Return the bool referred to by the given key if it exists, or the default. + + Implements :meth:`AbstractConfig.get_bool`. + """ + res = self.get_int(key, default=default) # type: ignore + if res is None: + return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default + + return bool(res) + + def set(self, key: str, val: Union[int, str, List[str], bool]) -> None: + """ + Set the given key's data to the given value. + + Implements :meth:`AbstractConfig.set`. + """ + reg_type = None + if isinstance(val, str): + reg_type = winreg.REG_SZ + winreg.SetValueEx(self.__reg_handle, key, REG_RESERVED_ALWAYS_ZERO, winreg.REG_SZ, val) + + elif isinstance(val, int): # The original code checked for numbers.Integral, I dont think that is needed. + reg_type = winreg.REG_DWORD + + elif isinstance(val, list): + reg_type = winreg.REG_MULTI_SZ + + elif isinstance(val, bool): + reg_type = winreg.REG_DWORD + val = int(val) + + else: + raise ValueError(f'Unexpected type for value {type(val)=}') + + # Its complaining about the list, it works, tested on windows, ignored. + winreg.SetValueEx(self.__reg_handle, key, REG_RESERVED_ALWAYS_ZERO, reg_type, val) # type: ignore + + def delete(self, key: str, *, suppress=False) -> None: + """ + Delete the given key from the config. + + 'key' is relative to the base Registry path we use. + + Implements :meth:`AbstractConfig.delete`. + """ + try: + winreg.DeleteValue(self.__reg_handle, key) + except OSError: + if suppress: + return + + raise + + def save(self) -> None: + """ + Save the configuration. + + Not required for WinConfig as Registry keys are flushed on write. + """ + pass + + def close(self): + """ + Close this config and release any associated resources. + + Implements :meth:`AbstractConfig.close`. + """ + self.__reg_handle.Close() diff --git a/dashboard.py b/dashboard.py index 43e7e52b..c371e2b7 100644 --- a/dashboard.py +++ b/dashboard.py @@ -2,11 +2,11 @@ import json import pathlib +import sys import time import tkinter as tk from calendar import timegm from os.path import getsize, isdir, isfile -from sys import platform from typing import Any, Dict from config import config @@ -14,11 +14,11 @@ from EDMCLogging import get_main_logger logger = get_main_logger() -if platform == 'darwin': +if sys.platform == 'darwin': from watchdog.events import FileSystemEventHandler from watchdog.observers import Observer -elif platform == 'win32': +elif sys.platform == 'win32': from watchdog.events import FileSystemEventHandler from watchdog.observers import Observer @@ -71,26 +71,25 @@ class Dashboard(FileSystemEventHandler): # File system events are unreliable/non-existent over network drives on Linux. # We can't easily tell whether a path points to a network drive, so assume # any non-standard logdir might be on a network drive and poll instead. - polling = platform != 'win32' - if not polling and not self.observer: + if not (sys.platform != 'win32') and not self.observer: logger.debug('Setting up observer...') self.observer = Observer() self.observer.daemon = True self.observer.start() logger.debug('Done') - elif polling and self.observer: + elif (sys.platform != 'win32') and self.observer: logger.debug('Using polling, stopping observer...') self.observer.stop() self.observer = None # type: ignore logger.debug('Done') - if not self.observed and not polling: + if not self.observed and not (sys.platform != 'win32'): logger.debug('Starting observer...') self.observed = self.observer.schedule(self, self.currentdir) logger.debug('Done') - logger.info(f'{polling and "Polling" or "Monitoring"} Dashboard "{self.currentdir}"') + logger.info(f'{(sys.platform != "win32") and "Polling" or "Monitoring"} Dashboard "{self.currentdir}"') # Even if we're not intending to poll, poll at least once to process pre-existing # data and to check whether the watchdog thread has crashed due to events not diff --git a/journal_lock.py b/journal_lock.py index 6b2e9951..1c984f90 100644 --- a/journal_lock.py +++ b/journal_lock.py @@ -1,10 +1,10 @@ """Implements locking of Journal directory.""" import pathlib +import sys import tkinter as tk from enum import Enum from os import getpid as os_getpid -from sys import platform from tkinter import ttk from typing import TYPE_CHECKING, Callable, Optional @@ -94,7 +94,7 @@ class JournalLock: :return: LockResult - See the class Enum definition """ - if platform == 'win32': + if sys.platform == 'win32': logger.trace_if('journal-lock', 'win32, using msvcrt') # win32 doesn't have fcntl, so we have to use msvcrt import msvcrt @@ -143,7 +143,7 @@ class JournalLock: return True # We weren't locked, and still aren't unlocked = False - if platform == 'win32': + if sys.platform == 'win32': logger.trace_if('journal-lock', 'win32, using msvcrt') # win32 doesn't have fcntl, so we have to use msvcrt import msvcrt @@ -206,10 +206,10 @@ class JournalLock: self.title(_('Journal directory already locked')) # remove decoration - if platform == 'win32': + if sys.platform == 'win32': self.attributes('-toolwindow', tk.TRUE) - elif platform == 'darwin': + elif sys.platform == 'darwin': # http://wiki.tcl.tk/13428 parent.call('tk::unsupported::MacWindowStyle', 'style', self, 'utility') diff --git a/l10n.py b/l10n.py index b77c07de..9db28022 100755 --- a/l10n.py +++ b/l10n.py @@ -12,7 +12,6 @@ import warnings from collections import OrderedDict from contextlib import suppress from os.path import basename, dirname, isdir, isfile, join -from sys import platform from typing import TYPE_CHECKING, Dict, Iterable, Optional, Set, TextIO, Union, cast if TYPE_CHECKING: @@ -37,12 +36,12 @@ LANGUAGE_ID = '!Language' LOCALISATION_DIR = 'L10n' -if platform == 'darwin': +if sys.platform == 'darwin': from Foundation import ( # type: ignore # exists on Darwin NSLocale, NSNumberFormatter, NSNumberFormatterDecimalStyle ) -elif platform == 'win32': +elif sys.platform == 'win32': import ctypes from ctypes.wintypes import BOOL, DWORD, LPCVOID, LPCWSTR, LPWSTR if TYPE_CHECKING: @@ -176,7 +175,7 @@ class _Translations: def available(self) -> Set[str]: """Return a list of available language codes.""" path = self.respath() - if getattr(sys, 'frozen', False) and platform == 'darwin': + if getattr(sys, 'frozen', False) and sys.platform == 'darwin': available = { x[:-len('.lproj')] for x in os.listdir(path) if x.endswith('.lproj') and isfile(join(x, 'Localizable.strings')) @@ -204,7 +203,7 @@ class _Translations: def respath(self) -> pathlib.Path: """Path to localisation files.""" if getattr(sys, 'frozen', False): - if platform == 'darwin': + if sys.platform == 'darwin': return (pathlib.Path(sys.executable).parents[0] / os.pardir / 'Resources').resolve() return pathlib.Path(dirname(sys.executable)) / LOCALISATION_DIR @@ -233,7 +232,7 @@ class _Translations: except OSError: logger.exception(f'could not open {f}') - elif getattr(sys, 'frozen', False) and platform == 'darwin': + elif getattr(sys, 'frozen', False) and sys.platform == 'darwin': return (self.respath() / f'{lang}.lproj' / 'Localizable.strings').open('r', encoding='utf-16') return (self.respath() / f'{lang}.strings').open('r', encoding='utf-8') @@ -243,7 +242,7 @@ class _Locale: """Locale holds a few utility methods to convert data to and from localized versions.""" def __init__(self) -> None: - if platform == 'darwin': + if sys.platform == 'darwin': self.int_formatter = NSNumberFormatter.alloc().init() self.int_formatter.setNumberStyle_(NSNumberFormatterDecimalStyle) self.float_formatter = NSNumberFormatter.alloc().init() @@ -276,7 +275,7 @@ class _Locale: if decimals == 0 and not isinstance(number, numbers.Integral): number = int(round(number)) - if platform == 'darwin': + if sys.platform == 'darwin': if not decimals and isinstance(number, numbers.Integral): return self.int_formatter.stringFromNumber_(number) @@ -298,7 +297,7 @@ class _Locale: :param string: The string to convert :return: None if the string cannot be parsed, otherwise an int or float dependant on input data. """ - if platform == 'darwin': + if sys.platform == 'darwin': return self.float_formatter.numberFromString_(string) with suppress(ValueError): @@ -321,10 +320,10 @@ class _Locale: :return: The preferred language list """ languages: Iterable[str] - if platform == 'darwin': + if sys.platform == 'darwin': languages = NSLocale.preferredLanguages() - elif platform != 'win32': + elif sys.platform != 'win32': # POSIX lang = locale.getlocale()[0] languages = lang and [lang.replace('_', '-')] or [] diff --git a/monitor.py b/monitor.py index 40259ece..972b2b89 100644 --- a/monitor.py +++ b/monitor.py @@ -1,15 +1,18 @@ """Monitor for new Journal files and contents of latest.""" +# v [sic] +# spell-checker: words onfoot unforseen relog fsdjump suitloadoutid slotid suitid loadoutid fauto Intimidator +# spell-checker: words joinacrew quitacrew sellshiponrebuy newbal navroute npccrewpaidwage sauto import json import pathlib import queue import re +import sys import threading from calendar import timegm from collections import OrderedDict, defaultdict from os import SEEK_END, SEEK_SET, listdir from os.path import basename, expanduser, isdir, join -from sys import platform from time import gmtime, localtime, mktime, sleep, strftime, strptime, time from typing import TYPE_CHECKING, Any, BinaryIO, Dict, List, MutableMapping, Optional from typing import OrderedDict as OrderedDictT @@ -33,7 +36,7 @@ if TYPE_CHECKING: def _(x: str) -> str: return x -if platform == 'darwin': +if sys.platform == 'darwin': from fcntl import fcntl from AppKit import NSWorkspace @@ -41,7 +44,7 @@ if platform == 'darwin': from watchdog.observers import Observer F_GLOBAL_NOCACHE = 55 -elif platform == 'win32': +elif sys.platform == 'win32': import ctypes from ctypes.wintypes import BOOL, HWND, LPARAM, LPWSTR @@ -62,6 +65,10 @@ elif platform == 'win32': else: # Linux's inotify doesn't work over CIFS or NFS, so poll FileSystemEventHandler = object # dummy + if TYPE_CHECKING: + # this isn't ever used, but this will make type checking happy + from watchdog.events import FileCreatedEvent + from watchdog.observers import Observer # Journal handler @@ -225,7 +232,7 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below # File system events are unreliable/non-existent over network drives on Linux. # We can't easily tell whether a path points to a network drive, so assume # any non-standard logdir might be on a network drive and poll instead. - polling = bool(config.get_str('journaldir')) and platform != 'win32' + polling = bool(config.get_str('journaldir')) and sys.platform != 'win32' if not polling and not self.observer: logger.debug('Not polling, no observer, starting an observer...') self.observer = Observer() @@ -282,6 +289,7 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below if self.observed: logger.debug('self.observed: Calling unschedule_all()') self.observed = None + assert self.observer is not None, 'Observer was none but it is in use?' self.observer.unschedule_all() logger.debug('Done') @@ -341,7 +349,7 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below logfile = self.logfile if logfile: loghandle: BinaryIO = open(logfile, 'rb', 0) # unbuffered - if platform == 'darwin': + if sys.platform == 'darwin': fcntl(loghandle, F_GLOBAL_NOCACHE, -1) # required to avoid corruption on macOS over SMB self.catching_up = True @@ -394,9 +402,13 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below self.event_queue.put(None) self.live = False + emitter = None # Watchdog thread -- there is a way to get this by using self.observer.emitters and checking for an attribute: # watch, but that may have unforseen differences in behaviour. - emitter = self.observed and self.observer._emitter_for_watch[self.observed] # Note: Uses undocumented attribute + if self.observed: + assert self.observer is not None, 'self.observer is None but also in use?' + # Note: Uses undocumented attribute + emitter = self.observed and self.observer._emitter_for_watch[self.observed] logger.debug('Entering loop...') while True: @@ -452,7 +464,7 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below if logfile: loghandle = open(logfile, 'rb', 0) # unbuffered - if platform == 'darwin': + if sys.platform == 'darwin': fcntl(loghandle, F_GLOBAL_NOCACHE, -1) # required to avoid corruption on macOS over SMB log_pos = 0 @@ -699,7 +711,7 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below # This event is logged when a player (on foot) gets into a ship or SRV # Parameters: # • SRV: true if getting into SRV, false if getting into a ship - # • Taxi: true when boarding a taxi transposrt ship + # • Taxi: true when boarding a taxi transport ship # • Multicrew: true when boarding another player’s vessel # • ID: player’s ship ID (if players own vessel) # • StarSystem @@ -727,7 +739,7 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below # # Parameters: # • SRV: true if getting out of SRV, false if getting out of a ship - # • Taxi: true when getting out of a taxi transposrt ship + # • Taxi: true when getting out of a taxi transport ship # • Multicrew: true when getting out of another player’s vessel # • ID: player’s ship ID (if players own vessel) # • StarSystem @@ -1942,12 +1954,12 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below :return: bool - True if the game is running. """ - if platform == 'darwin': + if sys.platform == 'darwin': for app in NSWorkspace.sharedWorkspace().runningApplications(): if app.bundleIdentifier() == 'uk.co.frontier.EliteDangerous': return True - elif platform == 'win32': + elif sys.platform == 'win32': def WindowTitle(h): # noqa: N802 # type: ignore if h: length = GetWindowTextLength(h) + 1 diff --git a/myNotebook.py b/myNotebook.py index 19f2aaa6..85566a21 100644 --- a/myNotebook.py +++ b/myNotebook.py @@ -4,22 +4,21 @@ # - OSX: page background should be a darker gray than systemWindowBody # selected tab foreground should be White when the window is active # - -from sys import platform - +import sys import tkinter as tk from tkinter import ttk # Entire file may be imported by plugins # Can't do this with styles on OSX - http://www.tkdocs.com/tutorial/styles.html#whydifficult -if platform == 'darwin': +if sys.platform == 'darwin': from platform import mac_ver PAGEFG = 'systemButtonText' PAGEBG = 'systemButtonActiveDarkShadow' -elif platform == 'win32': + +elif sys.platform == 'win32': PAGEFG = 'SystemWindowText' - PAGEBG = 'SystemWindow' # typically white + PAGEBG = 'SystemWindow' # typically white class Notebook(ttk.Notebook): @@ -29,14 +28,14 @@ class Notebook(ttk.Notebook): ttk.Notebook.__init__(self, master, **kw) style = ttk.Style() - if platform=='darwin': - if list(map(int, mac_ver()[0].split('.'))) >= [10,10]: + if sys.platform == 'darwin': + if list(map(int, mac_ver()[0].split('.'))) >= [10, 10]: # Hack for tab appearance with 8.5 on Yosemite & El Capitan. For proper fix see # https://github.com/tcltk/tk/commit/55c4dfca9353bbd69bbcec5d63bf1c8dfb461e25 - style.configure('TNotebook.Tab', padding=(12,10,12,2)) + style.configure('TNotebook.Tab', padding=(12, 10, 12, 2)) style.map('TNotebook.Tab', foreground=[('selected', '!background', 'systemWhite')]) - self.grid(sticky=tk.NSEW) # Already padded apropriately - elif platform == 'win32': + self.grid(sticky=tk.NSEW) # Already padded apropriately + elif sys.platform == 'win32': style.configure('nb.TFrame', background=PAGEBG) style.configure('nb.TButton', background=PAGEBG) style.configure('nb.TCheckbutton', foreground=PAGEFG, background=PAGEBG) @@ -47,56 +46,60 @@ class Notebook(ttk.Notebook): self.grid(padx=10, pady=10, sticky=tk.NSEW) -class Frame(platform == 'darwin' and tk.Frame or ttk.Frame): +class Frame(sys.platform == 'darwin' and tk.Frame or ttk.Frame): def __init__(self, master=None, **kw): - if platform == 'darwin': + if sys.platform == 'darwin': kw['background'] = kw.pop('background', PAGEBG) tk.Frame.__init__(self, master, **kw) tk.Frame(self).grid(pady=5) - elif platform == 'win32': + elif sys.platform == 'win32': ttk.Frame.__init__(self, master, style='nb.TFrame', **kw) - ttk.Frame(self).grid(pady=5) # top spacer + ttk.Frame(self).grid(pady=5) # top spacer else: ttk.Frame.__init__(self, master, **kw) - ttk.Frame(self).grid(pady=5) # top spacer - self.configure(takefocus = 1) # let the frame take focus so that no particular child is focused + ttk.Frame(self).grid(pady=5) # top spacer + self.configure(takefocus=1) # let the frame take focus so that no particular child is focused + class Label(tk.Label): def __init__(self, master=None, **kw): - if platform in ['darwin', 'win32']: + if sys.platform in ['darwin', 'win32']: kw['foreground'] = kw.pop('foreground', PAGEFG) kw['background'] = kw.pop('background', PAGEBG) else: kw['foreground'] = kw.pop('foreground', ttk.Style().lookup('TLabel', 'foreground')) kw['background'] = kw.pop('background', ttk.Style().lookup('TLabel', 'background')) - tk.Label.__init__(self, master, **kw) # Just use tk.Label on all platforms + tk.Label.__init__(self, master, **kw) # Just use tk.Label on all platforms -class Entry(platform == 'darwin' and tk.Entry or ttk.Entry): + +class Entry(sys.platform == 'darwin' and tk.Entry or ttk.Entry): def __init__(self, master=None, **kw): - if platform == 'darwin': + if sys.platform == 'darwin': kw['highlightbackground'] = kw.pop('highlightbackground', PAGEBG) tk.Entry.__init__(self, master, **kw) else: ttk.Entry.__init__(self, master, **kw) -class Button(platform == 'darwin' and tk.Button or ttk.Button): + +class Button(sys.platform == 'darwin' and tk.Button or ttk.Button): def __init__(self, master=None, **kw): - if platform == 'darwin': + if sys.platform == 'darwin': kw['highlightbackground'] = kw.pop('highlightbackground', PAGEBG) tk.Button.__init__(self, master, **kw) - elif platform == 'win32': + elif sys.platform == 'win32': ttk.Button.__init__(self, master, style='nb.TButton', **kw) else: ttk.Button.__init__(self, master, **kw) -class ColoredButton(platform == 'darwin' and tk.Label or tk.Button): + +class ColoredButton(sys.platform == 'darwin' and tk.Label or tk.Button): def __init__(self, master=None, **kw): - if platform == 'darwin': + if sys.platform == 'darwin': # Can't set Button background on OSX, so use a Label instead kw['relief'] = kw.pop('relief', tk.RAISED) self._command = kw.pop('command', None) @@ -105,52 +108,55 @@ class ColoredButton(platform == 'darwin' and tk.Label or tk.Button): else: tk.Button.__init__(self, master, **kw) - if platform == 'darwin': + if sys.platform == 'darwin': def _press(self, event): self._command() -class Checkbutton(platform == 'darwin' and tk.Checkbutton or ttk.Checkbutton): + +class Checkbutton(sys.platform == 'darwin' and tk.Checkbutton or ttk.Checkbutton): def __init__(self, master=None, **kw): - if platform == 'darwin': + if sys.platform == 'darwin': kw['foreground'] = kw.pop('foreground', PAGEFG) kw['background'] = kw.pop('background', PAGEBG) tk.Checkbutton.__init__(self, master, **kw) - elif platform == 'win32': + elif sys.platform == 'win32': ttk.Checkbutton.__init__(self, master, style='nb.TCheckbutton', **kw) else: ttk.Checkbutton.__init__(self, master, **kw) -class Radiobutton(platform == 'darwin' and tk.Radiobutton or ttk.Radiobutton): + +class Radiobutton(sys.platform == 'darwin' and tk.Radiobutton or ttk.Radiobutton): def __init__(self, master=None, **kw): - if platform == 'darwin': + if sys.platform == 'darwin': kw['foreground'] = kw.pop('foreground', PAGEFG) kw['background'] = kw.pop('background', PAGEBG) tk.Radiobutton.__init__(self, master, **kw) - elif platform == 'win32': + elif sys.platform == 'win32': ttk.Radiobutton.__init__(self, master, style='nb.TRadiobutton', **kw) else: ttk.Radiobutton.__init__(self, master, **kw) -class OptionMenu(platform == 'darwin' and tk.OptionMenu or ttk.OptionMenu): + +class OptionMenu(sys.platform == 'darwin' and tk.OptionMenu or ttk.OptionMenu): def __init__(self, master, variable, default=None, *values, **kw): - if platform == 'darwin': + if sys.platform == 'darwin': variable.set(default) bg = kw.pop('background', PAGEBG) tk.OptionMenu.__init__(self, master, variable, *values, **kw) self['background'] = bg - elif platform == 'win32': + elif sys.platform == 'win32': # OptionMenu derives from Menubutton at the Python level, so uses Menubutton's style ttk.OptionMenu.__init__(self, master, variable, default, *values, style='nb.TMenubutton', **kw) - self['menu'].configure(background = PAGEBG) + self['menu'].configure(background=PAGEBG) # Workaround for https://bugs.python.org/issue25684 for i in range(0, self['menu'].index('end')+1): self['menu'].entryconfig(i, variable=variable) else: ttk.OptionMenu.__init__(self, master, variable, default, *values, **kw) - self['menu'].configure(background = ttk.Style().lookup('TMenu', 'background')) + self['menu'].configure(background=ttk.Style().lookup('TMenu', 'background')) # Workaround for https://bugs.python.org/issue25684 for i in range(0, self['menu'].index('end')+1): self['menu'].entryconfig(i, variable=variable) diff --git a/prefs.py b/prefs.py index d6728ab4..03b8dcd8 100644 --- a/prefs.py +++ b/prefs.py @@ -3,10 +3,10 @@ import contextlib import logging +import sys import tkinter as tk import webbrowser from os.path import expanduser, expandvars, join, normpath -from sys import platform from tkinter import colorchooser as tkColorChooser # type: ignore # noqa: N812 from tkinter import ttk from types import TracebackType @@ -154,7 +154,7 @@ class AutoInc(contextlib.AbstractContextManager): return None -if platform == 'darwin': +if sys.platform == 'darwin': import objc # type: ignore from Foundation import NSFileManager # type: ignore try: @@ -179,7 +179,7 @@ if platform == 'darwin': was_accessible_at_launch = AXIsProcessTrusted() # type: ignore -elif platform == 'win32': +elif sys.platform == 'win32': import ctypes import winreg from ctypes.wintypes import HINSTANCE, HWND, LPARAM, LPCWSTR, LPVOID, LPWSTR, MAX_PATH, POINT, RECT, SIZE, UINT @@ -246,7 +246,7 @@ class PreferencesDialog(tk.Toplevel): self.parent = parent self.callback = callback - if platform == 'darwin': + if sys.platform == 'darwin': # LANG: File > Preferences menu entry for macOS self.title(_('Preferences')) @@ -258,15 +258,15 @@ class PreferencesDialog(tk.Toplevel): self.transient(parent) # position over parent - if platform != 'darwin' or parent.winfo_rooty() > 0: # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7 + if sys.platform != 'darwin' or parent.winfo_rooty() > 0: # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7 # TODO this is fixed supposedly. self.geometry(f'+{parent.winfo_rootx()}+{parent.winfo_rooty()}') # remove decoration - if platform == 'win32': + if sys.platform == 'win32': self.attributes('-toolwindow', tk.TRUE) - elif platform == 'darwin': + elif sys.platform == 'darwin': # http://wiki.tcl.tk/13428 parent.call('tk::unsupported::MacWindowStyle', 'style', self, 'utility') @@ -294,7 +294,7 @@ class PreferencesDialog(tk.Toplevel): self.__setup_appearance_tab(notebook) self.__setup_plugin_tab(notebook) - if platform == 'darwin': + if sys.platform == 'darwin': self.protocol("WM_DELETE_WINDOW", self.apply) # close button applies changes else: @@ -322,7 +322,7 @@ class PreferencesDialog(tk.Toplevel): self.grab_set() # Ensure fully on-screen - if platform == 'win32' and CalculatePopupWindowPosition: + if sys.platform == 'win32' and CalculatePopupWindowPosition: position = RECT() GetWindowRect(GetParent(self.winfo_id()), position) if CalculatePopupWindowPosition( @@ -396,7 +396,7 @@ class PreferencesDialog(tk.Toplevel): self.outdir_entry = nb.Entry(output_frame, takefocus=False) self.outdir_entry.grid(columnspan=2, padx=self.PADX, pady=(0, self.PADY), sticky=tk.EW, row=row.get()) - if platform == 'darwin': + if sys.platform == 'darwin': text = (_('Change...')) # LANG: macOS Preferences - files location selection button else: @@ -446,7 +446,7 @@ class PreferencesDialog(tk.Toplevel): self.logdir_entry.grid(columnspan=4, padx=self.PADX, pady=(0, self.PADY), sticky=tk.EW, row=row.get()) - if platform == 'darwin': + if sys.platform == 'darwin': text = (_('Change...')) # LANG: macOS Preferences - files location selection button else: @@ -470,7 +470,7 @@ class PreferencesDialog(tk.Toplevel): state=tk.NORMAL if config.get_str('journaldir') else tk.DISABLED ).grid(column=2, pady=self.PADY, sticky=tk.EW, row=row.get()) - if platform in ('darwin', 'win32'): + if sys.platform in ('darwin', 'win32'): ttk.Separator(config_frame, orient=tk.HORIZONTAL).grid( columnspan=4, padx=self.PADX, pady=self.PADY*4, sticky=tk.EW, row=row.get() ) @@ -482,11 +482,11 @@ class PreferencesDialog(tk.Toplevel): nb.Label( config_frame, text=_('Keyboard shortcut') if # LANG: Hotkey/Shortcut settings prompt on OSX - platform == 'darwin' else + sys.platform == 'darwin' else _('Hotkey') # LANG: Hotkey/Shortcut settings prompt on Windows ).grid(padx=self.PADX, sticky=tk.W, row=row.get()) - if platform == 'darwin' and not was_accessible_at_launch: + if sys.platform == 'darwin' and not was_accessible_at_launch: if AXIsProcessTrusted(): # Shortcut settings prompt on OSX nb.Label( @@ -511,7 +511,8 @@ class PreferencesDialog(tk.Toplevel): ) else: - self.hotkey_text = nb.Entry(config_frame, width=(20 if platform == 'darwin' else 30), justify=tk.CENTER) + self.hotkey_text = nb.Entry(config_frame, width=( + 20 if sys.platform == 'darwin' else 30), justify=tk.CENTER) self.hotkey_text.insert( 0, # No hotkey/shortcut currently defined @@ -741,7 +742,7 @@ class PreferencesDialog(tk.Toplevel): appearance_frame, text=_('Dark'), variable=self.theme, value=1, command=self.themevarchanged ).grid(columnspan=3, padx=self.BUTTONX, sticky=tk.W, row=row.get()) - if platform == 'win32': + if sys.platform == 'win32': nb.Radiobutton( appearance_frame, # LANG: Label for 'Transparent' theme radio button @@ -870,7 +871,7 @@ class PreferencesDialog(tk.Toplevel): ) self.ontop_button.grid(columnspan=3, padx=self.BUTTONX, sticky=tk.W, row=row.get()) # Appearance setting - if platform == 'win32': + if sys.platform == 'win32': nb.Checkbutton( appearance_frame, # LANG: Appearance option for Windows "minimize to system tray" @@ -997,7 +998,7 @@ class PreferencesDialog(tk.Toplevel): def tabchanged(self, event: tk.Event) -> None: """Handle preferences active tab changing.""" self.outvarchanged() - if platform == 'darwin': + if sys.platform == 'darwin': # Hack to recompute size so that buttons show up under Mojave notebook = event.widget frame = self.nametowidget(notebook.winfo_parent()) @@ -1027,9 +1028,8 @@ class PreferencesDialog(tk.Toplevel): # If encoding isn't UTF-8 we can't use the tkinter dialog current_locale = locale.getlocale(locale.LC_CTYPE) - from sys import platform as sys_platform directory = None - if sys_platform == 'win32' and current_locale[1] not in ('utf8', 'UTF8', 'utf-8', 'UTF-8'): + if sys.platform == 'win32' and current_locale[1] not in ('utf8', 'UTF8', 'utf-8', 'UTF-8'): def browsecallback(hwnd, uMsg, lParam, lpData): # noqa: N803 # Windows API convention # set initial folder if uMsg == BFFM_INITIALIZED and lpData: @@ -1075,7 +1075,7 @@ class PreferencesDialog(tk.Toplevel): # TODO: This is awful. entryfield['state'] = tk.NORMAL # must be writable to update entryfield.delete(0, tk.END) - if platform == 'win32': + if sys.platform == 'win32': start = len(config.home.split('\\')) if pathvar.get().lower().startswith(config.home.lower()) else 0 display = [] components = normpath(pathvar.get()).split('\\') @@ -1096,7 +1096,7 @@ class PreferencesDialog(tk.Toplevel): entryfield.insert(0, '\\'.join(display)) # None if path doesn't exist - elif platform == 'darwin' and NSFileManager.defaultManager().componentsToDisplayForPath_(pathvar.get()): + elif sys.platform == 'darwin' and NSFileManager.defaultManager().componentsToDisplayForPath_(pathvar.get()): if pathvar.get().startswith(config.home): display = ['~'] + NSFileManager.defaultManager().componentsToDisplayForPath_(pathvar.get())[ len(NSFileManager.defaultManager().componentsToDisplayForPath_(config.home)): @@ -1236,7 +1236,7 @@ class PreferencesDialog(tk.Toplevel): else: config.set('journaldir', logdir) - if platform in ('darwin', 'win32'): + if sys.platform in ('darwin', 'win32'): config.set('hotkey_code', self.hotkey_code) config.set('hotkey_mods', self.hotkey_mods) config.set('hotkey_always', int(not self.hotkey_only.get())) @@ -1282,7 +1282,7 @@ class PreferencesDialog(tk.Toplevel): self.parent.wm_attributes('-topmost', 1 if config.get_int('always_ontop') else 0) self.destroy() - if platform == 'darwin': + if sys.platform == 'darwin': def enableshortcuts(self) -> None: """Set up macOS preferences shortcut.""" self.apply() diff --git a/pyproject.toml b/pyproject.toml index 35ab26e4..a7f41ad4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,10 +3,13 @@ max_line_length = 120 [tool.isort] multi_line_output = 5 -line_length = 119 +line_length = 119 [tool.pytest.ini_options] testpaths = ["tests"] # Search for tests in tests/ [tool.coverage.run] -omit = ["venv/*"] # when running pytest --cov, dont report coverage in venv directories +omit = ["venv/*"] # when running pytest --cov, dont report coverage in venv directories + +[tool.pyright] +# pythonPlatform = 'Darwin' diff --git a/stats.py b/stats.py index ef993a53..6886eb23 100644 --- a/stats.py +++ b/stats.py @@ -1,9 +1,9 @@ """CMDR Status information.""" import csv import json +import sys import tkinter import tkinter as tk -from sys import platform from tkinter import ttk from typing import TYPE_CHECKING, Any, AnyStr, Callable, Dict, List, NamedTuple, Optional, Sequence, cast @@ -20,11 +20,9 @@ logger = EDMCLogging.get_main_logger() if TYPE_CHECKING: def _(x: str) -> str: ... -if platform == 'win32': +if sys.platform == 'win32': import ctypes from ctypes.wintypes import HWND, POINT, RECT, SIZE, UINT - if TYPE_CHECKING: - import ctypes.windll # type: ignore # Fake this into existing, its really a magic dll thing try: CalculatePopupWindowPosition = ctypes.windll.user32.CalculatePopupWindowPosition @@ -372,15 +370,15 @@ class StatsResults(tk.Toplevel): self.transient(parent) # position over parent - if platform != 'darwin' or parent.winfo_rooty() > 0: # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7 + if sys.platform != 'darwin' or parent.winfo_rooty() > 0: # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7 self.geometry(f"+{parent.winfo_rootx()}+{parent.winfo_rooty()}") # remove decoration self.resizable(tk.FALSE, tk.FALSE) - if platform == 'win32': + if sys.platform == 'win32': self.attributes('-toolwindow', tk.TRUE) - elif platform == 'darwin': + elif sys.platform == 'darwin': # http://wiki.tcl.tk/13428 parent.call('tk::unsupported::MacWindowStyle', 'style', self, 'utility') @@ -421,7 +419,7 @@ class StatsResults(tk.Toplevel): ttk.Frame(page).grid(pady=5) # bottom spacer notebook.add(page, text=_('Ships')) # LANG: Status dialog title - if platform != 'darwin': + if sys.platform != 'darwin': buttonframe = ttk.Frame(frame) buttonframe.grid(padx=10, pady=(0, 10), sticky=tk.NSEW) # type: ignore # the tuple is supported buttonframe.columnconfigure(0, weight=1) @@ -433,7 +431,7 @@ class StatsResults(tk.Toplevel): self.grab_set() # Ensure fully on-screen - if platform == 'win32' and CalculatePopupWindowPosition: + if sys.platform == 'win32' and CalculatePopupWindowPosition: position = RECT() GetWindowRect(GetParent(self.winfo_id()), position) if CalculatePopupWindowPosition( diff --git a/tests/config.py/_old_config.py b/tests/config.py/_old_config.py index b1609975..5983e050 100644 --- a/tests/config.py/_old_config.py +++ b/tests/config.py/_old_config.py @@ -4,7 +4,6 @@ import warnings from configparser import NoOptionError from os import getenv, makedirs, mkdir, pardir from os.path import dirname, expanduser, isdir, join, normpath -from sys import platform from typing import TYPE_CHECKING, Optional, Union from config import applongname, appname, update_interval @@ -12,13 +11,13 @@ from EDMCLogging import get_main_logger logger = get_main_logger() -if platform == 'darwin': +if sys.platform == 'darwin': from Foundation import ( # type: ignore NSApplicationSupportDirectory, NSBundle, NSDocumentDirectory, NSSearchPathForDirectoriesInDomains, NSUserDefaults, NSUserDomainMask ) -elif platform == 'win32': +elif sys.platform == 'win32': import ctypes import uuid from ctypes.wintypes import DWORD, HANDLE, HKEY, LONG, LPCVOID, LPCWSTR @@ -90,7 +89,7 @@ elif platform == 'win32': CoTaskMemFree(buf) # and free original return retval -elif platform == 'linux': +elif sys.platform == 'linux': import codecs from configparser import RawConfigParser @@ -114,7 +113,7 @@ class OldConfig(): OUT_SYS_EDDN = 2048 OUT_SYS_DELAY = 4096 - if platform == 'darwin': # noqa: C901 # It's gating *all* the functions + if sys.platform == 'darwin': # noqa: C901 # It's gating *all* the functions def __init__(self): self.app_dir = join( @@ -199,7 +198,7 @@ class OldConfig(): self.save() self.defaults = None - elif platform == 'win32': + elif sys.platform == 'win32': def __init__(self): self.app_dir = join(known_folder_path(FOLDERID_LocalAppData), appname) # type: ignore # Not going to change @@ -362,7 +361,7 @@ class OldConfig(): RegCloseKey(self.hkey) self.hkey = None - elif platform == 'linux': + elif sys.platform == 'linux': SECTION = 'config' def __init__(self): diff --git a/tests/config.py/test_config.py b/tests/config.py/test_config.py index 0d10a3c2..4f72d437 100644 --- a/tests/config.py/test_config.py +++ b/tests/config.py/test_config.py @@ -1,4 +1,12 @@ -"""Test the config system.""" +""" +Test the config system. + +Note: These tests to arbitrary reads and writes to an existing config, including +key deletions. Said modifications are to keys that are generated internally. + +Most of these tests are parity tests with the "old" config, and likely one day can be +entirely removed. +""" from __future__ import annotations import contextlib @@ -19,7 +27,7 @@ print(sys.path) from _old_config import old_config # noqa: E402 -from config import LinuxConfig, config # noqa: E402 +from config import config # noqa: E402 def _fuzz_list(length: int) -> List[str]: @@ -77,6 +85,11 @@ class TestNewConfig: def __update_linuxconfig(self) -> None: """On linux config uses ConfigParser, which doesn't update from disk changes. Force the update here.""" + if sys.platform != 'linux': + return + + from config.linux import LinuxConfig + if isinstance(config, LinuxConfig) and config.config is not None: config.config.read(config.filename) @@ -163,6 +176,10 @@ class TestOldNewConfig: def __update_linuxconfig(self) -> None: """On linux config uses ConfigParser, which doesn't update from disk changes. Force the update here.""" + if sys.platform != 'linux': + return + + from config.linux import LinuxConfig if isinstance(config, LinuxConfig) and config.config is not None: config.config.read(config.filename) diff --git a/theme.py b/theme.py index cac65ba7..8515bfbb 100644 --- a/theme.py +++ b/theme.py @@ -6,9 +6,9 @@ # import os +import sys import tkinter as tk from os.path import join -from sys import platform from tkinter import font as tkFont from tkinter import ttk @@ -18,20 +18,20 @@ from ttkHyperlinkLabel import HyperlinkLabel if __debug__: from traceback import print_exc -if platform == "linux": +if sys.platform == "linux": from ctypes import POINTER, Structure, byref, c_char_p, c_int, c_long, c_uint, c_ulong, c_void_p, cdll -if platform == 'win32': +if sys.platform == 'win32': import ctypes from ctypes.wintypes import DWORD, LPCVOID, LPCWSTR AddFontResourceEx = ctypes.windll.gdi32.AddFontResourceExW AddFontResourceEx.restypes = [LPCWSTR, DWORD, LPCVOID] - FR_PRIVATE = 0x10 + FR_PRIVATE = 0x10 FR_NOT_ENUM = 0x20 AddFontResourceEx(join(config.respath, u'EUROCAPS.TTF'), FR_PRIVATE, 0) -elif platform == 'linux': +elif sys.platform == 'linux': # pyright: reportUnboundVariable=false XID = c_ulong # from X.h: typedef unsigned long XID Window = XID @@ -40,7 +40,7 @@ elif platform == 'linux': PropModeReplace = 0 PropModePrepend = 1 - PropModeAppend = 2 + PropModeAppend = 2 # From xprops.h MWM_HINTS_FUNCTIONS = 1 << 0 @@ -69,16 +69,17 @@ elif platform == 'linux': ('input_mode', c_long), ('status', c_ulong), ] - + # workaround for https://github.com/EDCD/EDMarketConnector/issues/568 - if not os.getenv("EDMC_NO_UI") : + if not os.getenv("EDMC_NO_UI"): try: xlib = cdll.LoadLibrary('libX11.so.6') XInternAtom = xlib.XInternAtom XInternAtom.argtypes = [POINTER(Display), c_char_p, c_int] XInternAtom.restype = Atom XChangeProperty = xlib.XChangeProperty - XChangeProperty.argtypes = [POINTER(Display), Window, Atom, Atom, c_int, c_int, POINTER(MotifWmHints), c_int] + XChangeProperty.argtypes = [POINTER(Display), Window, Atom, Atom, c_int, + c_int, POINTER(MotifWmHints), c_int] XChangeProperty.restype = c_int XFlush = xlib.XFlush XFlush.argtypes = [POINTER(Display)] @@ -87,29 +88,31 @@ elif platform == 'linux': XOpenDisplay.argtypes = [c_char_p] XOpenDisplay.restype = POINTER(Display) XQueryTree = xlib.XQueryTree - XQueryTree.argtypes = [POINTER(Display), Window, POINTER(Window), POINTER(Window), POINTER(Window), POINTER(c_uint)] + XQueryTree.argtypes = [POINTER(Display), Window, POINTER( + Window), POINTER(Window), POINTER(Window), POINTER(c_uint)] XQueryTree.restype = c_int dpy = xlib.XOpenDisplay(None) if not dpy: 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_dark = MotifWmHints(MWM_HINTS_FUNCTIONS | MWM_HINTS_DECORATIONS, - MWM_FUNC_RESIZE | MWM_FUNC_MOVE | MWM_FUNC_MINIMIZE | MWM_FUNC_CLOSE, - 0, 0, 0) + 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: - if __debug__: print_exc() + if __debug__: + print_exc() dpy = None class _Theme(object): def __init__(self): - self.active = None # Starts out with no theme + self.active = None # Starts out with no theme self.minwidth = None self.widgets = {} self.widgets_pair = [] @@ -124,18 +127,18 @@ class _Theme(object): if not self.defaults: # Can't initialise this til window is created # Windows, MacOS self.defaults = { - 'fg' : tk.Label()['foreground'], # SystemButtonText, systemButtonText - 'bg' : tk.Label()['background'], # SystemButtonFace, White - 'font' : tk.Label()['font'], # TkDefaultFont - 'bitmapfg' : tk.BitmapImage()['foreground'], # '-foreground {} {} #000000 #000000' - 'bitmapbg' : tk.BitmapImage()['background'], # '-background {} {} {} {}' - 'entryfg' : tk.Entry()['foreground'], # SystemWindowText, Black - 'entrybg' : tk.Entry()['background'], # SystemWindow, systemWindowBody - 'entryfont' : tk.Entry()['font'], # TkTextFont - 'frame' : tk.Frame()['background'], # SystemButtonFace, systemWindowBody - 'menufg' : tk.Menu()['foreground'], # SystemMenuText, - 'menubg' : tk.Menu()['background'], # SystemMenu, - 'menufont' : tk.Menu()['font'], # TkTextFont + 'fg': tk.Label()['foreground'], # SystemButtonText, systemButtonText + 'bg': tk.Label()['background'], # SystemButtonFace, White + 'font': tk.Label()['font'], # TkDefaultFont + 'bitmapfg': tk.BitmapImage()['foreground'], # '-foreground {} {} #000000 #000000' + 'bitmapbg': tk.BitmapImage()['background'], # '-background {} {} {} {}' + 'entryfg': tk.Entry()['foreground'], # SystemWindowText, Black + 'entrybg': tk.Entry()['background'], # SystemWindow, systemWindowBody + 'entryfont': tk.Entry()['font'], # TkTextFont + 'frame': tk.Frame()['background'], # SystemButtonFace, systemWindowBody + 'menufg': tk.Menu()['foreground'], # SystemMenuText, + 'menubg': tk.Menu()['background'], # SystemMenu, + 'menufont': tk.Menu()['font'], # TkTextFont } if widget not in self.widgets: @@ -189,26 +192,27 @@ class _Theme(object): def _enter(self, event, image): widget = event.widget if widget and widget['state'] != tk.DISABLED: - widget.configure(state = tk.ACTIVE) + widget.configure(state=tk.ACTIVE) if image: - image.configure(foreground = self.current['activeforeground'], background = self.current['activebackground']) + image.configure(foreground=self.current['activeforeground'], + background=self.current['activebackground']) def _leave(self, event, image): widget = event.widget if widget and widget['state'] != tk.DISABLED: - widget.configure(state = tk.NORMAL) + widget.configure(state=tk.NORMAL) if image: - image.configure(foreground = self.current['foreground'], background = self.current['background']) + image.configure(foreground=self.current['foreground'], background=self.current['background']) # Set up colors def _colors(self, root, theme): style = ttk.Style() - if platform == 'linux': + if sys.platform == 'linux': style.theme_use('clam') # Default dark theme colors if not config.get_str('dark_text'): - config.set('dark_text', '#ff8000') # "Tangerine" in OSX color picker + config.set('dark_text', '#ff8000') # "Tangerine" in OSX color picker if not config.get_str('dark_highlight'): config.set('dark_highlight', 'white') @@ -216,40 +220,40 @@ class _Theme(object): # Dark (r, g, b) = root.winfo_rgb(config.get_str('dark_text')) self.current = { - 'background' : 'grey4', # OSX inactive dark titlebar color - '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)), - 'highlight' : config.get_str('dark_highlight'), + 'background': 'grey4', # OSX inactive dark titlebar color + '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)), + 'highlight': config.get_str('dark_highlight'), # 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 - 'TkDefaultFont'), + 'font': (theme > 1 and not 0x250 < ord(_('Cmdr')[0]) < 0x3000 and + tkFont.Font(family='Euro Caps', size=10, weight=tkFont.NORMAL) or + 'TkDefaultFont'), } else: # (Mostly) system colors style = ttk.Style() self.current = { - 'background' : (platform == 'darwin' and 'systemMovableModalBackground' or - style.lookup('TLabel', 'background')), - 'foreground' : style.lookup('TLabel', 'foreground'), - 'activebackground' : (platform == 'win32' and 'SystemHighlight' or - style.lookup('TLabel', 'background', ['active'])), - 'activeforeground' : (platform == 'win32' and 'SystemHighlightText' or - style.lookup('TLabel', 'foreground', ['active'])), - 'disabledforeground' : style.lookup('TLabel', 'foreground', ['disabled']), - 'highlight' : 'blue', - 'font' : 'TkDefaultFont', + 'background': (sys.platform == 'darwin' and 'systemMovableModalBackground' or + style.lookup('TLabel', 'background')), + 'foreground': style.lookup('TLabel', 'foreground'), + 'activebackground': (sys.platform == 'win32' and 'SystemHighlight' or + style.lookup('TLabel', 'background', ['active'])), + 'activeforeground': (sys.platform == 'win32' and 'SystemHighlightText' or + style.lookup('TLabel', 'foreground', ['active'])), + 'disabledforeground': style.lookup('TLabel', 'foreground', ['disabled']), + 'highlight': 'blue', + 'font': 'TkDefaultFont', } - # Apply current theme to a widget and its children, and register it for future updates + def update(self, widget): 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() + 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): @@ -258,56 +262,57 @@ class _Theme(object): # 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']) + assert widget in self.widgets, '%s %s "%s"' % ( + widget.winfo_class(), widget, 'text' in widget.keys() and widget['text']) attribs = self.widgets.get(widget, []) if isinstance(widget, tk.BitmapImage): # not a widget if 'fg' not in attribs: - widget.configure(foreground = self.current['foreground']), + widget.configure(foreground=self.current['foreground']), if 'bg' not in attribs: - widget.configure(background = self.current['background']) + widget.configure(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']), - if 'insertbackground' in widget.keys(): # tk.Entry - widget.configure(insertbackground = self.current['foreground']), + widget.configure(foreground=self.current['highlight']), + if 'insertbackground' in widget.keys(): # tk.Entry + widget.configure(insertbackground=self.current['foreground']), if 'bg' not in attribs: - widget.configure(background = self.current['background']) - if 'highlightbackground' in widget.keys(): # tk.Entry - widget.configure(highlightbackground = self.current['background']) + widget.configure(background=self.current['background']) + if 'highlightbackground' in widget.keys(): # tk.Entry + widget.configure(highlightbackground=self.current['background']) if 'font' not in attribs: - widget.configure(font = self.current['font']) + widget.configure(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.configure(foreground=self.current['foreground'], + activeforeground=self.current['activeforeground'], + disabledforeground=self.current['disabledforeground']) if 'bg' not in attribs: - widget.configure(background = self.current['background'], - activebackground = self.current['activebackground']) - if platform == 'darwin' and isinstance(widget, tk.Button): - widget.configure(highlightbackground = self.current['background']) + widget.configure(background=self.current['background'], + activebackground=self.current['activebackground']) + if sys.platform == 'darwin' and isinstance(widget, tk.Button): + widget.configure(highlightbackground=self.current['background']) if 'font' not in attribs: - widget.configure(font = self.current['font']) + widget.configure(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.configure(foreground=self.current['foreground']), if 'bg' not in attribs: - widget.configure(background = self.current['background']) + widget.configure(background=self.current['background']) if 'font' not in attribs: - widget.configure(font = self.current['font']) + widget.configure(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.configure(background=self.current['background'], + highlightbackground=self.current['disabledforeground']) # Apply configured theme + def apply(self, root): theme = config.get_int('theme') @@ -316,7 +321,7 @@ class _Theme(object): # Apply colors for widget in set(self.widgets): if isinstance(widget, tk.Widget) and not widget.winfo_exists(): - self.widgets.pop(widget) # has been destroyed + self.widgets.pop(widget) # has been destroyed else: self._update_widget(widget) @@ -334,58 +339,61 @@ class _Theme(object): pair[theme].grid(**gridopts) if self.active == theme: - return # Don't need to mess with the window manager + return # Don't need to mess with the window manager else: self.active = theme - if platform == 'darwin': + if sys.platform == 'darwin': from AppKit import NSAppearance, NSApplication, NSMiniaturizableWindowMask, NSResizableWindowMask - root.update_idletasks() # need main window to be created + root.update_idletasks() # need main window to be created appearance = NSAppearance.appearanceNamed_(theme and 'NSAppearanceNameDarkAqua' or 'NSAppearanceNameAqua') for window in NSApplication.sharedApplication().windows(): - window.setStyleMask_(window.styleMask() & ~(NSMiniaturizableWindowMask | NSResizableWindowMask)) # disable zoom + window.setStyleMask_(window.styleMask() & ~( + NSMiniaturizableWindowMask | NSResizableWindowMask)) # disable zoom window.setAppearance_(appearance) - elif platform == 'win32': + elif sys.platform == 'win32': GWL_STYLE = -16 - WS_MAXIMIZEBOX = 0x00010000 + WS_MAXIMIZEBOX = 0x00010000 # tk8.5.9/win/tkWinWm.c:342 GWL_EXSTYLE = -20 - WS_EX_APPWINDOW = 0x00040000 - WS_EX_LAYERED = 0x00080000 + WS_EX_APPWINDOW = 0x00040000 + WS_EX_LAYERED = 0x00080000 GetWindowLongW = ctypes.windll.user32.GetWindowLongW SetWindowLongW = ctypes.windll.user32.SetWindowLongW root.overrideredirect(theme and 1 or 0) root.attributes("-transparentcolor", theme > 1 and 'grey4' or '') root.withdraw() - root.update_idletasks() # Size and windows styles get recalculated here + root.update_idletasks() # Size and windows styles get recalculated here hwnd = ctypes.windll.user32.GetParent(root.winfo_id()) - SetWindowLongW(hwnd, GWL_STYLE, GetWindowLongW(hwnd, GWL_STYLE) & ~WS_MAXIMIZEBOX) # disable maximize - SetWindowLongW(hwnd, GWL_EXSTYLE, theme > 1 and WS_EX_APPWINDOW|WS_EX_LAYERED or WS_EX_APPWINDOW) # Add to taskbar + SetWindowLongW(hwnd, GWL_STYLE, GetWindowLongW(hwnd, GWL_STYLE) & ~WS_MAXIMIZEBOX) # disable maximize + SetWindowLongW(hwnd, GWL_EXSTYLE, theme > 1 and WS_EX_APPWINDOW | + WS_EX_LAYERED or WS_EX_APPWINDOW) # Add to taskbar root.deiconify() - root.wait_visibility() # need main window to be displayed before returning + root.wait_visibility() # need main window to be displayed before returning else: root.withdraw() - root.update_idletasks() # Size gets recalculated here + root.update_idletasks() # Size gets recalculated here if dpy: xroot = Window() parent = Window() children = Window() nchildren = c_uint() XQueryTree(dpy, root.winfo_id(), byref(xroot), byref(parent), byref(children), byref(nchildren)) - XChangeProperty(dpy, parent, motif_wm_hints_property, motif_wm_hints_property, 32, PropModeReplace, theme and motif_wm_hints_dark or motif_wm_hints_normal, 5) + XChangeProperty(dpy, parent, motif_wm_hints_property, motif_wm_hints_property, 32, + PropModeReplace, theme and motif_wm_hints_dark or motif_wm_hints_normal, 5) XFlush(dpy) else: root.overrideredirect(theme and 1 or 0) root.deiconify() - root.wait_visibility() # need main window to be displayed before returning + root.wait_visibility() # need main window to be displayed before returning if not self.minwidth: - self.minwidth = root.winfo_width() # Minimum width = width on first creation + self.minwidth = root.winfo_width() # Minimum width = width on first creation root.minsize(self.minwidth, -1) diff --git a/ttkHyperlinkLabel.py b/ttkHyperlinkLabel.py index 2f64ab74..8dd08644 100644 --- a/ttkHyperlinkLabel.py +++ b/ttkHyperlinkLabel.py @@ -1,14 +1,12 @@ +import sys import tkinter as tk import webbrowser -from sys import platform from tkinter import font as tkFont from tkinter import ttk -if platform == 'win32': +if sys.platform == 'win32': import subprocess - from winreg import ( - HKEY_CLASSES_ROOT, HKEY_CURRENT_USER, CloseKey, OpenKeyEx, QueryValueEx - ) + from winreg import HKEY_CLASSES_ROOT, HKEY_CURRENT_USER, CloseKey, OpenKeyEx, QueryValueEx # A clickable ttk Label # @@ -18,19 +16,22 @@ if platform == 'win32': # 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 -class HyperlinkLabel(platform == 'darwin' and tk.Label or ttk.Label, object): + + +class HyperlinkLabel(sys.platform == 'darwin' and tk.Label or ttk.Label, object): def __init__(self, master=None, **kw): 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 + self.underline = kw.pop('underline', None) # override ttk.Label's underline self.foreground = kw.get('foreground') or 'blue' - self.disabledforeground = kw.pop('disabledforeground', ttk.Style().lookup('TLabel', 'foreground', ('disabled',))) # ttk.Label doesn't support disabledforeground option + self.disabledforeground = kw.pop('disabledforeground', ttk.Style().lookup( + 'TLabel', 'foreground', ('disabled',))) # ttk.Label doesn't support disabledforeground option - if platform == 'darwin': + if sys.platform == 'darwin': # Use tk.Label 'cos can't set ttk.Label background - http://www.tkdocs.com/tutorial/styles.html#whydifficult kw['background'] = kw.pop('background', 'systemDialogBackgroundActive') - kw['anchor'] = kw.pop('anchor', tk.W) # like ttk.Label + kw['anchor'] = kw.pop('anchor', tk.W) # like ttk.Label tk.Label.__init__(self, master, **kw) else: ttk.Label.__init__(self, master, **kw) @@ -39,16 +40,16 @@ class HyperlinkLabel(platform == 'darwin' and tk.Label or ttk.Label, object): self.menu = tk.Menu(None, tearoff=tk.FALSE) # LANG: Label for 'Copy' as in 'Copy and Paste' - self.menu.add_command(label=_('Copy'), command = self.copy) # As in Copy and Paste - self.bind(platform == 'darwin' and '' or '', self._contextmenu) + self.menu.add_command(label=_('Copy'), command=self.copy) # As in Copy and Paste + self.bind(sys.platform == 'darwin' and '' or '', self._contextmenu) self.bind('', self._enter) self.bind('', self._leave) # set up initial appearance - self.configure(state = kw.get('state', tk.NORMAL), - text = kw.get('text'), - font = kw.get('font', ttk.Style().lookup('TLabel', 'font'))) + self.configure(state=kw.get('state', tk.NORMAL), + 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): @@ -70,17 +71,18 @@ class HyperlinkLabel(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.configure(underline = True) + self.font_u = tkFont.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 if 'cursor' not in kw: if (kw['state'] if 'state' in kw else str(self['state'])) == tk.DISABLED: - kw['cursor'] = 'arrow' # System default + kw['cursor'] = 'arrow' # System default elif self.url and (kw['text'] if 'text' in kw else self['text']): - kw['cursor'] = platform=='darwin' and 'pointinghand' or 'hand2' + kw['cursor'] = sys.platform == 'darwin' and 'pointinghand' or 'hand2' else: - kw['cursor'] = (platform=='darwin' and 'notallowed') or (platform=='win32' and 'no') or 'circle' + kw['cursor'] = (sys.platform == 'darwin' and 'notallowed') or ( + sys.platform == 'win32' and 'no') or 'circle' super(HyperlinkLabel, self).configure(cnf, **kw) @@ -89,22 +91,22 @@ class HyperlinkLabel(platform == 'darwin' and tk.Label or ttk.Label, object): def _enter(self, event): if self.url and self.underline is not False and str(self['state']) != tk.DISABLED: - super(HyperlinkLabel, self).configure(font = self.font_u) + super(HyperlinkLabel, self).configure(font=self.font_u) def _leave(self, event): if not self.underline: - super(HyperlinkLabel, self).configure(font = self.font_n) + super(HyperlinkLabel, self).configure(font=self.font_n) def _click(self, event): 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 + self._leave(event) # Remove underline before we change window to browser openurl(url) def _contextmenu(self, event): if self['text'] and (self.popup_copy(self['text']) if callable(self.popup_copy) else self.popup_copy): - self.menu.post(platform == 'darwin' and event.x_root + 1 or event.x_root, event.y_root) + self.menu.post(sys.platform == 'darwin' and event.x_root + 1 or event.x_root, event.y_root) def copy(self): self.clipboard_clear() @@ -112,13 +114,14 @@ class HyperlinkLabel(platform == 'darwin' and tk.Label or ttk.Label, object): def openurl(url): - if platform == 'win32': + if sys.platform == 'win32': # 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') + hkey = OpenKeyEx(HKEY_CURRENT_USER, + r'Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice') (value, typ) = QueryValueEx(hkey, 'ProgId') CloseKey(hkey) if value in ['IE.HTTP', 'AppXq0fevzme2pys62n3e0fbqa7peapykr8v']: @@ -128,7 +131,7 @@ def openurl(url): else: cls = value except: - cls = 'https' + cls = 'https' if cls: try: