From eb28a3b502f7881f08a9d4da02d4b891fd89c9d5 Mon Sep 17 00:00:00 2001 From: A_D Date: Mon, 24 Jan 2022 13:04:22 +0200 Subject: [PATCH 1/6] Use sys.platform, minor type updates --- monitor.py | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/monitor.py b/monitor.py index 42d282b1f..be52290af 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 @@ 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 @@ def _(x: str) -> str: 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 @@ def _(x: str) -> str: 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 @@ -223,7 +230,7 @@ def start(self, root: 'tkinter.Tk') -> bool: # noqa: CCR001 # 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() @@ -280,6 +287,7 @@ def stop(self) -> None: 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') @@ -339,7 +347,7 @@ def worker(self) -> None: # noqa: C901, CCR001 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 for line in loghandle: @@ -390,9 +398,13 @@ def worker(self) -> None: # noqa: C901, CCR001 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: @@ -448,7 +460,7 @@ def worker(self) -> None: # noqa: C901, CCR001 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 @@ -695,7 +707,7 @@ def parse_entry(self, line: bytes) -> MutableMapping[str, Any]: # noqa: C901, C # 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 @@ -723,7 +735,7 @@ def parse_entry(self, line: bytes) -> MutableMapping[str, Any]: # noqa: C901, C # # 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 @@ -1938,12 +1950,12 @@ def game_running(self) -> bool: # noqa: CCR001 :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 From 86292e02e2f9443640060ee83dcc083566d9a402 Mon Sep 17 00:00:00 2001 From: A_D Date: Mon, 24 Jan 2022 13:40:36 +0200 Subject: [PATCH 2/6] Move config to module, separate out implementation Does what it says on the tin. Moves config implementations out to individual files, guards those around platforms to make stuff more reasonably split out. --- config.py | 1115 -------------------------------------------- config/__init__.py | 473 +++++++++++++++++++ config/darwin.py | 184 ++++++++ config/linux.py | 245 ++++++++++ config/windows.py | 259 ++++++++++ 5 files changed, 1161 insertions(+), 1115 deletions(-) delete mode 100644 config.py create mode 100644 config/__init__.py create mode 100644 config/darwin.py create mode 100644 config/linux.py create mode 100644 config/windows.py diff --git a/config.py b/config.py deleted file mode 100644 index b32b26135..000000000 --- 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 000000000..eff2859be --- /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 000000000..eb2b887f2 --- /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 000000000..04087b32e --- /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 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 000000000..d29a2b42f --- /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() From bff6175ee79c2687c07e5578a3ba3747ff7096b0 Mon Sep 17 00:00:00 2001 From: A_D Date: Mon, 24 Jan 2022 13:54:04 +0200 Subject: [PATCH 3/6] Update to use sys.platform over platform --- EDMarketConnector.py | 52 +++++---- dashboard.py | 15 ++- journal_lock.py | 10 +- l10n.py | 21 ++-- myNotebook.py | 82 +++++++------ prefs.py | 48 ++++---- stats.py | 16 ++- tests/config.py/_old_config.py | 13 +-- theme.py | 206 +++++++++++++++++---------------- ttkHyperlinkLabel.py | 57 ++++----- 10 files changed, 267 insertions(+), 253 deletions(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index 01c9d8463..6aa27ca38 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 @@ 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 @@ # 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 @@ def handle_edmc_callback_or_foregrounding() -> None: # noqa: CCR001 """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 @@ def already_running_popup(): # 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 @@ def __init__(self, master: tk.Tk): # noqa: C901, CCR001 # TODO - can possibly f 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 @@ def open_window(systray: 'SysTrayIcon') -> None: 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 @@ def open_window(systray: 'SysTrayIcon') -> None: # 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 @@ def open_window(systray: 'SysTrayIcon') -> None: 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 @@ def open_window(systray: 'SysTrayIcon') -> None: 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 @@ def open_window(systray: 'SysTrayIcon') -> None: 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 @@ def toggle_suit_row(self, visible: Optional[bool] = None) -> None: self.suit_shown = True if not self.suit_shown: - if platform != 'win32': + if sys.platform != 'win32': pady = 2 else: @@ -826,7 +828,7 @@ def set_labels(self): 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 @@ def login(self): 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 @@ def login(self): # 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 @@ def capi_handle_response(self, event=None): # noqa: C901, CCR001 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 @@ def auth(self, event=None) -> None: 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 @@ def __init__(self, parent: tk.Tk): # 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 @@ def save_raw(self) -> None: """ 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 @@ def exit_tray(self, systray: 'SysTrayIcon') -> None: 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 @@ def onexit(self, event=None) -> None: 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/dashboard.py b/dashboard.py index 43e7e52b1..c371e2b70 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 @@ 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 @@ def start(self, root: tk.Tk, started: int) -> bool: # 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 6b2e99516..1c984f90a 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 @@ def _obtain_lock(self) -> JournalLockResult: :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 @@ def release_lock(self) -> bool: # noqa: CCR001 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 @@ def __init__(self, parent: tk.Tk, callback: Callable) -> None: 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 b77c07de2..9db28022d 100755 --- a/l10n.py +++ b/l10n.py @@ -12,7 +12,6 @@ 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 @@ def _(x: str) -> str: ... 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 @@ def translate(self, x: str, context: Optional[str] = None) -> str: 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 @@ def available_names(self) -> Dict[Optional[str], str]: 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 @@ def file(self, lang: str, plugin_path: Optional[str] = None) -> Optional[TextIO] 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 @@ def string_from_number(self, number: Union[float, int], decimals: int = 5) -> st 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 @@ def number_from_string(self, string: str) -> Union[int, float, None]: :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 @@ def preferred_languages(self) -> Iterable[str]: # noqa: CCR001 :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/myNotebook.py b/myNotebook.py index 19f2aaa6b..85566a214 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 @@ def __init__(self, master=None, **kw): 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 @@ def __init__(self, master=None, **kw): 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 @@ def __init__(self, master=None, **kw): 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 d6728ab40..03b8dcd8d 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 @@ def __exit__( return None -if platform == 'darwin': +if sys.platform == 'darwin': import objc # type: ignore from Foundation import NSFileManager # type: ignore try: @@ -179,7 +179,7 @@ def __exit__( 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 @@ def __init__(self, parent: tk.Tk, callback: Optional[Callable]): 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 @@ def __init__(self, parent: tk.Tk, callback: Optional[Callable]): 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 @@ def __init__(self, parent: tk.Tk, callback: Optional[Callable]): 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 @@ def __init__(self, parent: tk.Tk, callback: Optional[Callable]): 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 @@ def __setup_output_tab(self, root_notebook: nb.Notebook) -> None: 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 @@ def __setup_config_tab(self, notebook: Notebook) -> None: # noqa: CCR001 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 @@ def __setup_config_tab(self, notebook: Notebook) -> None: # noqa: CCR001 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 @@ def __setup_config_tab(self, notebook: Notebook) -> None: # noqa: CCR001 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 @@ def __setup_config_tab(self, notebook: Notebook) -> None: # noqa: CCR001 ) 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 @@ def __setup_appearance_tab(self, notebook: Notebook) -> None: 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 @@ def __setup_appearance_tab(self, notebook: Notebook) -> None: ) 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 @@ def cmdrchanged(self, event=None): 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 @@ def filebrowse(self, title, pathvar): # 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 @@ def displaypath(self, pathvar: tk.StringVar, entryfield: tk.Entry) -> None: # 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 @@ def displaypath(self, pathvar: tk.StringVar, entryfield: tk.Entry) -> None: 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 @@ def apply(self) -> None: 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 @@ def _destroy(self) -> None: 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/stats.py b/stats.py index ef993a531..6886eb23a 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 @@ 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 @@ def __init__(self, parent: tk.Tk, data: Dict[str, Any]) -> None: 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 @@ def __init__(self, parent: tk.Tk, data: Dict[str, Any]) -> None: 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 @@ def __init__(self, parent: tk.Tk, data: Dict[str, Any]) -> None: 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 b16099751..5983e0507 100644 --- a/tests/config.py/_old_config.py +++ b/tests/config.py/_old_config.py @@ -4,7 +4,6 @@ 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 @@ 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 @@ def known_folder_path(guid: uuid.UUID) -> Optional[str]: 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 @@ def close(self) -> None: 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 @@ def close(self) -> None: RegCloseKey(self.hkey) self.hkey = None - elif platform == 'linux': + elif sys.platform == 'linux': SECTION = 'config' def __init__(self): diff --git a/theme.py b/theme.py index cac65ba74..8515bfbbe 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 @@ 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 @@ PropModeReplace = 0 PropModePrepend = 1 - PropModeAppend = 2 + PropModeAppend = 2 # From xprops.h MWM_HINTS_FUNCTIONS = 1 << 0 @@ -69,16 +69,17 @@ class MotifWmHints(Structure): ('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 @@ class MotifWmHints(Structure): 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 @@ def register(self, widget): 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 @@ def button_bind(self, widget, command, image=None): 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 @@ def _colors(self, root, theme): # 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 @@ def update(self, widget): # 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 @@ def apply(self, root): # 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 @@ def apply(self, root): 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 2f64ab74c..8dd086443 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 @@ # 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 @@ def __init__(self, master=None, **kw): 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 @@ def configure(self, cnf=None, **kw): 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 @@ def __setitem__(self, key, value): 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 @@ def copy(self): 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: From f160acfe5721ea014abe93fda8c914363b747e57 Mon Sep 17 00:00:00 2001 From: A_D Date: Mon, 24 Jan 2022 14:19:18 +0200 Subject: [PATCH 4/6] add configs for switching platforms --- .mypy.ini | 1 + pyproject.toml | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.mypy.ini b/.mypy.ini index 03840fd98..f9ec59976 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/pyproject.toml b/pyproject.toml index 35ab26e4a..a7f41ad41 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' From 7bcf3a6f4da7e2f6d7115121caaab35a290eaf85 Mon Sep 17 00:00:00 2001 From: A_D Date: Wed, 26 Jan 2022 17:46:30 +0200 Subject: [PATCH 5/6] note sys.platform requirements in contributing --- Contributing.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Contributing.md b/Contributing.md index bdbf46345..0b1f4d584 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 From 8c2a0ae95afb1c4a54d6cde5b2d6ae8f2fbe5f9b Mon Sep 17 00:00:00 2001 From: A_D Date: Wed, 26 Jan 2022 20:36:29 +0200 Subject: [PATCH 6/6] make tests not explode on windows --- config/linux.py | 2 +- tests/config.py/test_config.py | 21 +++++++++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/config/linux.py b/config/linux.py index 04087b32e..58c0f7cd6 100644 --- a/config/linux.py +++ b/config/linux.py @@ -3,7 +3,7 @@ import pathlib import sys from configparser import ConfigParser -from typing import List, Optional, Union +from typing import TYPE_CHECKING, List, Optional, Union from config import AbstractConfig, appname, logger diff --git a/tests/config.py/test_config.py b/tests/config.py/test_config.py index 0d10a3c2c..4f72d4376 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 @@ 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 @@ def cleanup_entry(self, entry: str) -> None: 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)