diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index d0f21e70..2c7e615b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,18 +1,26 @@ --- name: Bug report -about: Create a report to help us improve +about: Report an issue with the program title: '' labels: bug, unconfirmed assignees: '' --- +**Please check the [Known Issues](https://github.com/EDCD/EDMarketConnector/issues/618) in case this has already been reported.** + +**Please also check if the issue is covered in our [Troubleshooting Guide](https://github.com/EDCD/EDMarketConnector/wiki/Troubleshooting).** It might be something with a known work around, or where a third party (such as EDSM) is causing logging that is harmless. + **Please complete the following information:** - Version [e.g. 4.0.6 - See 'Help > About E:D Market Connector'] - OS: [e.g. Windows 10] - OS Locale: [e.g. English, French, Serbian...] - If applicable: Browser [e.g. chrome, safari] - - Please attach the file `%TEMP%\EDMarketConnector.log` from *immediately* after the bug occurs (re-running the application overwrites this file). Use the Icon that looks like `_`, `|` and `^` all in one character towards the right of the toolbar above. + - Please attach a log file: + 1. If running 4.1.0 (including betas) or later: `%TEMP%\EDMarketConnector\EDMarketConnector.log`. See [Debug Log File](https://github.com/EDCD/EDMarketConnector/wiki/Troubleshooting#debug-log-files). + 1. Else `%TEMP%\EDMarketConnector.log` from *immediately* after the bug occurs (re-running the application overwrites this file). + + Use the Icon that looks like `_`, `|` and `^` all in one character towards the right of the toolbar above. **Describe the bug** A clear and concise description of what the bug is. diff --git a/ChangeLog.md b/ChangeLog.md index fe54941b..dbeaeb09 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,6 +1,51 @@ This is the master changelog for Elite Dangerous Market Connector. Entries are in reverse chronological order (latest first). --- +Pre-Release 4.1.0-beta6 +=== + +* Include a manifest in both EDMarketConnector.exe and EDMC.exe to specify + they're Unicode applications so that they default to using the UTF-8 + codepage. Fixes [#711](https://github.com/EDCD/EDMarketConnector/issues/711). + +* Add extra debug logging to EDSM plugin for `CarrierJump`, `FSDJump`, + `Location` and `Docked` events. Hopefully this will help track down the + cause of [#713](https://github.com/EDCD/EDMarketConnector/issues/713). + +* Various code cleanups which shouldn't impact user experience. + +* EDMC.exe will now log useful startup state information if run with the + `--loglevel DEBUG` arguments. + +Pre-Release 4.1.0-beta5 +=== + +* We are now explicitly setting a UTF8 encoding at startup. This *shouldn't* + have any side effects and has allowed us to switch to the native tkinter + file dialogues rather than some custom code. + + If you do encounter errors that might be related to this then it would be + useful to see the logging output that details the Locale settings at + various points during startup. Examples might include incorrect text being + rendered for your language when you have it set, or issues with filenames + and their content, but any of these are unlikely. + +* The error `'list' object has no attribute 'values'` should now be fixed. + +* Code dealing with Frontier's CAPI was cleaned up, so please report any + issues related to that (mostly when just docked or when you press the Update + button). + +* Extra logging added for when we process FSDJump and CarrierJump events for + EDSM. This is aimed at checking if we do have a bug with CarrierJump events, + but having FSDJump trigger the logging as well made it easier to test. + +* Default `logging` level for plugins is now DEBUG. This won't change what's + actually logged, it just ensures that everything gets through to the two + channels that then decide what is output. + +* Translations updated. Thanks again to all contributors! + Pre-Release 4.1.0-beta4 === diff --git a/EDMC.manifest b/EDMC.manifest new file mode 100644 index 00000000..9b412178 --- /dev/null +++ b/EDMC.manifest @@ -0,0 +1,38 @@ +<?xml version='1.0' encoding='UTF-8' standalone='yes'?> +<assembly xmlns='urn:schemas-microsoft-com:asm.v1' manifestVersion='1.0' xmlns:asmv3="urn:schemas-microsoft-com:asm.v3" > + <trustInfo xmlns="urn:schemas-microsoft-com:asm.v3"> + <security> + <requestedPrivileges> + <requestedExecutionLevel level='asInvoker' uiAccess='false' /> + </requestedPrivileges> + </security> + </trustInfo> + <!-- https://docs.microsoft.com/en-us/windows/win32/sysinfo/targeting-your-application-at-windows-8-1 --> + <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"> + <application> + <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/> <!-- Windows 10 --> + <supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/> <!-- Windows 8.1 --> + <supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/> <!-- Windows 8 --> + <supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/> <!-- Windows 7 --> + </application> + </compatibility> + <asmv3:application> + <asmv3:windowsSettings xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings"> + <!-- Declare that we want to use the UTF-8 code page --> + <activeCodePage xmlns="http://schemas.microsoft.com/SMI/2019/WindowsSettings">UTF-8</activeCodePage> + </asmv3:windowsSettings> + </asmv3:application> + <dependency> + </dependency> + <dependency> + <dependentAssembly> + <assemblyIdentity + type="win32" + name="Microsoft.Windows.Common-Controls" + version="6.0.0.0" + processorArchitecture="*" + publicKeyToken="6595b64144ccf1df" + language="*" /> + </dependentAssembly> + </dependency> +</assembly> diff --git a/EDMC.py b/EDMC.py index 8ef2e78c..d1f9411d 100755 --- a/EDMC.py +++ b/EDMC.py @@ -6,6 +6,7 @@ import argparse import json +import locale import logging import os import re @@ -14,6 +15,9 @@ from os.path import getmtime, join from time import sleep, time from typing import Any, Optional +# workaround for https://github.com/EDCD/EDMarketConnector/issues/568 +os.environ["EDMC_NO_UI"] = "1" + import collate import commodity import companion @@ -36,15 +40,22 @@ sys.path.append(config.internal_plugin_dir) import eddn # noqa: E402 # isort: on -# workaround for https://github.com/EDCD/EDMarketConnector/issues/568 -os.environ["EDMC_NO_UI"] = "1" - -l10n.Translations.install_dummy() - logger = EDMCLogging.Logger(appcmdname).get_logger() logger.setLevel(logging.INFO) +def log_locale(prefix: str) -> None: + logger.debug(f'''Locale: {prefix} +Locale LC_COLLATE: {locale.getlocale(locale.LC_COLLATE)} +Locale LC_CTYPE: {locale.getlocale(locale.LC_CTYPE)} +Locale LC_MONETARY: {locale.getlocale(locale.LC_MONETARY)} +Locale LC_NUMERIC: {locale.getlocale(locale.LC_NUMERIC)} +Locale LC_TIME: {locale.getlocale(locale.LC_TIME)}''' + ) + + +l10n.Translations.install_dummy() + SERVER_RETRY = 5 # retry pause for Companion servers [s] EXIT_SUCCESS, EXIT_SERVER, EXIT_CREDENTIALS, EXIT_VERIFICATION, EXIT_LAGGING, EXIT_SYS_ERR, EXIT_ARGS = range(7) @@ -112,7 +123,15 @@ def main(): sys.exit(EXIT_ARGS) logger.setLevel(args.loglevel) - logger.debug('Startup') + logger.debug(f'Startup v{appversion} : Running on Python v{sys.version}') + logger.debug(f'''Platform: {sys.platform} +argv[0]: {sys.argv[0]} +exec_prefix: {sys.exec_prefix} +executable: {sys.executable} +sys.path: {sys.path}''' + ) + + log_locale('Initial Locale') if args.j: logger.debug('Import and collate from JSON dump') diff --git a/EDMCLogging.py b/EDMCLogging.py index 4208dc28..f882f924 100644 --- a/EDMCLogging.py +++ b/EDMCLogging.py @@ -10,13 +10,14 @@ strings. import inspect import logging import logging.handlers +import os import pathlib import tempfile # So that any warning about accessing a protected member is only in one place. from sys import _getframe as getframe from typing import Tuple -from config import appname, config +from config import appcmdname, appname, config # TODO: Tests: # @@ -41,7 +42,7 @@ from config import appname, config # # 14. Call from *package* -_default_loglevel = logging.INFO +_default_loglevel = logging.DEBUG class Logger: @@ -122,7 +123,7 @@ class Logger: return self.logger_channel -def get_plugin_logger(name: str, loglevel: int = _default_loglevel) -> logging.Logger: +def get_plugin_logger(plugin_name: str, loglevel: int = _default_loglevel) -> logging.Logger: """ Return a logger suitable for a plugin. @@ -143,7 +144,12 @@ def get_plugin_logger(name: str, loglevel: int = _default_loglevel) -> logging.L :param loglevel: Optional logLevel for this Logger. :return: logging.Logger instance, all set up. """ - plugin_logger = logging.getLogger(name) + if not os.getenv('EDMC_NO_UI'): + base_logger_name = appname + else: + base_logger_name = appcmdname + + plugin_logger = logging.getLogger(f'{base_logger_name}.{plugin_name}') plugin_logger.setLevel(loglevel) plugin_logger.addFilter(EDMCContextFilter()) @@ -326,6 +332,18 @@ class EDMCContextFilter(logging.Filter): return module_name + +def get_main_logger() -> logging.Logger: + """Return the correct logger for how the program is being run.""" + + if not os.getenv("EDMC_NO_UI"): + # GUI app being run + return logging.getLogger(appname) + else: + # Must be the CLI + return logging.getLogger(appcmdname) + + # Singleton loglevel = config.get('loglevel') if not loglevel: diff --git a/EDMarketConnector.manifest b/EDMarketConnector.manifest index 2985bbfa..ef78813a 100644 --- a/EDMarketConnector.manifest +++ b/EDMarketConnector.manifest @@ -19,6 +19,8 @@ <asmv3:application> <asmv3:windowsSettings xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings"> <dpiAware>true</dpiAware> + <!-- Declare that we want to use the UTF-8 code page --> + <activeCodePage xmlns="http://schemas.microsoft.com/SMI/2019/WindowsSettings">UTF-8</activeCodePage> </asmv3:windowsSettings> </asmv3:application> <dependency> diff --git a/EDMarketConnector.py b/EDMarketConnector.py index 4d25fe17..ccfac5fe 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -23,6 +23,7 @@ if __name__ == "__main__": if getattr(sys, 'frozen', False): # By default py2exe tries to write log to dirname(sys.executable) which fails when installed import tempfile + # unbuffered not allowed for text in python3, so use `1 for line buffering sys.stdout = sys.stderr = open(join(tempfile.gettempdir(), f'{appname}.log'), mode='wt', buffering=1) @@ -45,6 +46,7 @@ if __debug__: if platform != 'win32': import pdb import signal + signal.signal(signal.SIGTERM, lambda sig, frame: pdb.Pdb().set_trace(frame)) import companion @@ -61,7 +63,6 @@ from protocol import protocolhandler from dashboard import dashboard from theme import theme - SERVER_RETRY = 5 # retry pause for Companion servers [s] SHIPYARD_HTML_TEMPLATE = """ @@ -81,7 +82,6 @@ SHIPYARD_HTML_TEMPLATE = """ class AppWindow(object): - # Tkinter Event types EVENT_KEYPRESS = 2 EVENT_BUTTON = 4 @@ -104,10 +104,14 @@ class AppWindow(object): if platform == 'win32': self.w.wm_iconbitmap(default='EDMarketConnector.ico') else: - self.w.tk.call('wm', 'iconphoto', self.w, '-default', tk.PhotoImage(file=join(config.respath, 'EDMarketConnector.png'))) # noqa: E501 - self.theme_icon = tk.PhotoImage(data='R0lGODlhFAAQAMZQAAoKCQoKCgsKCQwKCQsLCgwLCg4LCQ4LCg0MCg8MCRAMCRANChINCREOChIOChQPChgQChgRCxwTCyYVCSoXCS0YCTkdCTseCT0fCTsjDU0jB0EnDU8lB1ElB1MnCFIoCFMoCEkrDlkqCFwrCGEuCWIuCGQvCFs0D1w1D2wyCG0yCF82D182EHE0CHM0CHQ1CGQ5EHU2CHc3CHs4CH45CIA6CIE7CJdECIdLEolMEohQE5BQE41SFJBTE5lUE5pVE5RXFKNaFKVbFLVjFbZkFrxnFr9oFsNqFsVrF8RsFshtF89xF9NzGNh1GNl2GP+KG////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////yH5BAEKAH8ALAAAAAAUABAAAAeegAGCgiGDhoeIRDiIjIZGKzmNiAQBQxkRTU6am0tPCJSGShuSAUcLoIIbRYMFra4FAUgQAQCGJz6CDQ67vAFJJBi0hjBBD0w9PMnJOkAiJhaIKEI7HRoc19ceNAolwbWDLD8uAQnl5ga1I9CHEjEBAvDxAoMtFIYCBy+kFDKHAgM3ZtgYSLAGgwkp3pEyBOJCC2ELB31QATGioAoVAwEAOw==') # noqa: E501 - self.theme_minimize = tk.BitmapImage(data='#define im_width 16\n#define im_height 16\nstatic unsigned char im_bits[] = {\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x3f,\n 0xfc, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };\n') # noqa: E501 - self.theme_close = tk.BitmapImage(data='#define im_width 16\n#define im_height 16\nstatic unsigned char im_bits[] = {\n 0x00, 0x00, 0x00, 0x00, 0x0c, 0x30, 0x1c, 0x38, 0x38, 0x1c, 0x70, 0x0e,\n 0xe0, 0x07, 0xc0, 0x03, 0xc0, 0x03, 0xe0, 0x07, 0x70, 0x0e, 0x38, 0x1c,\n 0x1c, 0x38, 0x0c, 0x30, 0x00, 0x00, 0x00, 0x00 };\n') # noqa: E501 + self.w.tk.call('wm', 'iconphoto', self.w, '-default', + tk.PhotoImage(file=join(config.respath, 'EDMarketConnector.png'))) # noqa: E501 + self.theme_icon = tk.PhotoImage( + data='R0lGODlhFAAQAMZQAAoKCQoKCgsKCQwKCQsLCgwLCg4LCQ4LCg0MCg8MCRAMCRANChINCREOChIOChQPChgQChgRCxwTCyYVCSoXCS0YCTkdCTseCT0fCTsjDU0jB0EnDU8lB1ElB1MnCFIoCFMoCEkrDlkqCFwrCGEuCWIuCGQvCFs0D1w1D2wyCG0yCF82D182EHE0CHM0CHQ1CGQ5EHU2CHc3CHs4CH45CIA6CIE7CJdECIdLEolMEohQE5BQE41SFJBTE5lUE5pVE5RXFKNaFKVbFLVjFbZkFrxnFr9oFsNqFsVrF8RsFshtF89xF9NzGNh1GNl2GP+KG////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////yH5BAEKAH8ALAAAAAAUABAAAAeegAGCgiGDhoeIRDiIjIZGKzmNiAQBQxkRTU6am0tPCJSGShuSAUcLoIIbRYMFra4FAUgQAQCGJz6CDQ67vAFJJBi0hjBBD0w9PMnJOkAiJhaIKEI7HRoc19ceNAolwbWDLD8uAQnl5ga1I9CHEjEBAvDxAoMtFIYCBy+kFDKHAgM3ZtgYSLAGgwkp3pEyBOJCC2ELB31QATGioAoVAwEAOw==') # noqa: E501 + self.theme_minimize = tk.BitmapImage( + data='#define im_width 16\n#define im_height 16\nstatic unsigned char im_bits[] = {\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x3f,\n 0xfc, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };\n') # noqa: E501 + self.theme_close = tk.BitmapImage( + data='#define im_width 16\n#define im_height 16\nstatic unsigned char im_bits[] = {\n 0x00, 0x00, 0x00, 0x00, 0x0c, 0x30, 0x1c, 0x38, 0x38, 0x1c, 0x70, 0x0e,\n 0xe0, 0x07, 0xc0, 0x03, 0xc0, 0x03, 0xe0, 0x07, 0x70, 0x0e, 0x38, 0x1c,\n 0x1c, 0x38, 0x0c, 0x30, 0x00, 0x00, 0x00, 0x00 };\n') # noqa: E501 frame = tk.Frame(self.w, name=appname.lower()) frame.grid(sticky=tk.NSEW) @@ -152,7 +156,8 @@ class AppWindow(object): row = frame.grid_size()[1] self.button.grid(row=row, columnspan=2, sticky=tk.NSEW) self.theme_button.grid(row=row, columnspan=2, sticky=tk.NSEW) - theme.register_alternate((self.button, self.theme_button, self.theme_button), {'row': row, 'columnspan': 2, 'sticky': tk.NSEW}) # noqa: E501 + theme.register_alternate((self.button, self.theme_button, self.theme_button), + {'row': row, 'columnspan': 2, 'sticky': tk.NSEW}) # noqa: E501 self.status.grid(columnspan=2, sticky=tk.EW) self.button.bind('<Button-1>', self.getandsend) theme.button_bind(self.theme_button, self.getandsend) @@ -291,6 +296,7 @@ class AppWindow(object): # Check that the titlebar will be at least partly on screen import ctypes from ctypes.wintypes import POINT + # https://msdn.microsoft.com/en-us/library/dd145064 MONITOR_DEFAULTTONULL = 0 # noqa: N806 if ctypes.windll.user32.MonitorFromPoint(POINT(int(match.group(1)) + 16, int(match.group(2)) + 16), @@ -322,6 +328,7 @@ class AppWindow(object): # Load updater after UI creation (for WinSparkle) import update + if getattr(sys, 'frozen', False): # Running in frozen .exe, so use (Win)Sparkle self.updater = update.Updater(tkroot=self.w, provider='external') @@ -453,7 +460,7 @@ class AppWindow(object): if not retrying: if time() < self.holdofftime: # Was invoked by key while in cooldown self.status['text'] = '' - if play_sound and (self.holdofftime-time()) < companion.holdoff*0.75: + if play_sound and (self.holdofftime - time()) < companion.holdoff * 0.75: hotkeymgr.play_bad() # Don't play sound in first few seconds to prevent repeats return elif play_sound: @@ -493,7 +500,7 @@ class AppWindow(object): if isdir('dump'): with open('dump/{system}{station}.{timestamp}.json'.format( system=data['lastSystem']['name'], - station=data['commander'].get('docked') and '.'+data['lastStarport']['name'] or '', + station=data['commander'].get('docked') and '.' + data['lastStarport']['name'] or '', timestamp=strftime('%Y-%m-%dT%H.%M.%S', localtime())), 'wb') as h: h.write(json.dumps(data, ensure_ascii=False, @@ -525,7 +532,7 @@ class AppWindow(object): self.status['text'] = _("You're not docked at a station!") play_bad = True # Ignore possibly missing shipyard info - elif (config.getint('output') & config.OUT_MKT_EDDN)\ + elif (config.getint('output') & config.OUT_MKT_EDDN) \ and not (data['lastStarport'].get('commodities') or data['lastStarport'].get('modules')): if not self.status['text']: self.status['text'] = _("Station doesn't have anything!") @@ -585,12 +592,12 @@ class AppWindow(object): if not data['commander'].get('docked'): # might have un-docked while we were waiting for retry in which case station data is unreliable pass - elif (data.get('lastSystem', {}).get('name') == monitor.system and + elif (data.get('lastSystem', {}).get('name') == monitor.system and data.get('lastStarport', {}).get('name') == monitor.station and data.get('lastStarport', {}).get('ships', {}).get('shipyard_list')): self.eddn.export_shipyard(data, monitor.is_beta) elif tries > 1: # bogus data - retry - self.w.after(int(SERVER_RETRY * 1000), lambda: self.retry_for_shipyard(tries-1)) + self.w.after(int(SERVER_RETRY * 1000), lambda: self.retry_for_shipyard(tries - 1)) except Exception: pass @@ -600,11 +607,11 @@ class AppWindow(object): def crewroletext(role): # Return translated crew role. Needs to be dynamic to allow for changing language. return { - None: '', - 'Idle': '', + None : '', + 'Idle' : '', 'FighterCon': _('Fighter'), # Multicrew role - 'FireCon': _('Gunner'), # Multicrew role - 'FlightCon': _('Helm'), # Multicrew role + 'FireCon' : _('Gunner'), # Multicrew role + 'FlightCon' : _('Helm'), # Multicrew role }.get(role, role) while True: @@ -626,8 +633,8 @@ class AppWindow(object): self.ship_label['text'] = _('Ship') + ':' # Main window self.ship.configure( text=monitor.state['ShipName'] - or companion.ship_map.get(monitor.state['ShipType'], monitor.state['ShipType']) - or '', + or companion.ship_map.get(monitor.state['ShipType'], monitor.state['ShipType']) + or '', url=self.shipyard_url) else: self.cmdr['text'] = '' @@ -673,7 +680,7 @@ class AppWindow(object): logger.info("Can't start Status monitoring") # Export loadout - if entry['event'] == 'Loadout' and not monitor.state['Captain']\ + if entry['event'] == 'Loadout' and not monitor.state['Captain'] \ and config.getint('output') & config.OUT_SHIP: monitor.export_ship() @@ -690,10 +697,10 @@ class AppWindow(object): hotkeymgr.play_bad() # Auto-Update after docking, but not if auth callback is pending - if entry['event'] in ('StartUp', 'Location', 'Docked')\ - and monitor.station\ - and not config.getint('output') & config.OUT_MKT_MANUAL\ - and config.getint('output') & config.OUT_STATION_ANY\ + if entry['event'] in ('StartUp', 'Location', 'Docked') \ + and monitor.station \ + and not config.getint('output') & config.OUT_MKT_MANUAL \ + and config.getint('output') & config.OUT_STATION_ANY \ and companion.session.state != companion.Session.STATE_AUTH: self.w.after(int(SERVER_RETRY * 1000), self.getandsend) @@ -760,10 +767,10 @@ class AppWindow(object): return f'file://localhost/{file_name}' def system_url(self, system): - return plug.invoke(config.get('system_provider'), 'EDSM', 'system_url', monitor.system) + return plug.invoke(config.get('system_provider'), 'EDSM', 'system_url', monitor.system) def station_url(self, station): - return plug.invoke(config.get('station_provider'), 'eddb', 'station_url', monitor.system, monitor.station) + return plug.invoke(config.get('station_provider'), 'eddb', 'station_url', monitor.system, monitor.station) def cooldown(self): if time() < self.holdofftime: @@ -837,7 +844,7 @@ class AppWindow(object): ############################################################ # version <link to changelog> - ttk.Label(frame).grid(row=row, column=0) # spacer + ttk.Label(frame).grid(row=row, column=0) # spacer row += 1 self.appversion_label = tk.Label(frame, text=appversion) self.appversion_label.grid(row=row, column=0, sticky=tk.E) @@ -855,7 +862,7 @@ class AppWindow(object): ############################################################ # <copyright> - ttk.Label(frame).grid(row=row, column=0) # spacer + ttk.Label(frame).grid(row=row, column=0) # spacer row += 1 self.copyright = tk.Label(frame, text=copyright) self.copyright.grid(row=row, columnspan=3, sticky=tk.EW) @@ -864,7 +871,7 @@ class AppWindow(object): ############################################################ # OK button to close the window - ttk.Label(frame).grid(row=row, column=0) # spacer + ttk.Label(frame).grid(row=row, column=0) # spacer row += 1 button = ttk.Button(frame, text=_('OK'), command=self.apply) button.grid(row=row, column=2, sticky=tk.E) @@ -895,7 +902,7 @@ class AppWindow(object): last_system: str = data.get("lastSystem", {}).get("name", "Unknown") last_starport: str = '' if data['commander'].get('docked'): - last_starport = '.'+data.get('lastStarport', {}).get('name', 'Unknown') + last_starport = '.' + data.get('lastStarport', {}).get('name', 'Unknown') timestamp: str = strftime('%Y-%m-%dT%H.%M.%S', localtime()) f = tkinter.filedialog.asksaveasfilename(parent=self.w, defaultextension=default_extension, @@ -971,6 +978,7 @@ def enforce_single_instance() -> None: if platform == 'win32': import ctypes from ctypes.wintypes import HWND, LPWSTR, LPCWSTR, INT, BOOL, LPARAM + EnumWindows = ctypes.windll.user32.EnumWindows # noqa: N806 GetClassName = ctypes.windll.user32.GetClassNameW # noqa: N806 GetClassName.argtypes = [HWND, LPWSTR, ctypes.c_int] # noqa: N806 @@ -1004,9 +1012,9 @@ def enforce_single_instance() -> None: def enumwindowsproc(window_handle, l_param): # class name limited to 256 - https://msdn.microsoft.com/en-us/library/windows/desktop/ms633576 cls = ctypes.create_unicode_buffer(257) - if GetClassName(window_handle, cls, 257)\ - and cls.value == 'TkTopLevel'\ - and window_title(window_handle) == applongname\ + if GetClassName(window_handle, cls, 257) \ + and cls.value == 'TkTopLevel' \ + and window_title(window_handle) == applongname \ and GetProcessHandleFromHwnd(window_handle): # If GetProcessHandleFromHwnd succeeds then the app is already running as this user if len(sys.argv) > 1 and sys.argv[1].startswith(protocolhandler.redirect): @@ -1028,38 +1036,53 @@ def test_logging(): logger.debug('Test from EDMarketConnector.py top-level test_logging()') -# Run the app -if __name__ == "__main__": - enforce_single_instance() - - from EDMCLogging import logger - logger.info(f'Startup v{appversion} : Running on Python v{sys.version}') - logger.debug(f'''Platform: {sys.platform} -argv[0]: {sys.argv[0]} -exec_prefix: {sys.exec_prefix} -executable: {sys.executable} -sys.path: {sys.path} +def log_locale(prefix: str) -> None: + logger.debug(f'''Locale: {prefix} Locale LC_COLLATE: {locale.getlocale(locale.LC_COLLATE)} Locale LC_CTYPE: {locale.getlocale(locale.LC_CTYPE)} Locale LC_MONETARY: {locale.getlocale(locale.LC_MONETARY)} Locale LC_NUMERIC: {locale.getlocale(locale.LC_NUMERIC)} Locale LC_TIME: {locale.getlocale(locale.LC_TIME)}''' -) + ) + + +# Run the app +if __name__ == "__main__": + enforce_single_instance() + + from EDMCLogging import logger + + logger.info(f'Startup v{appversion} : Running on Python v{sys.version}') + logger.debug(f'''Platform: {sys.platform} +argv[0]: {sys.argv[0]} +exec_prefix: {sys.exec_prefix} +executable: {sys.executable} +sys.path: {sys.path}''' + ) # Change locale to a utf8 one - # First make sure the local is actually set as per locale's idea of defaults + # Log what we have at startup + log_locale('Initial Locale') + # Make sure the local is actually set as per locale's idea of defaults locale.setlocale(locale.LC_ALL, '') + log_locale('After LC_ALL defaults set') # Now find out the current locale, mostly the language locale_startup = locale.getlocale(locale.LC_ALL) + logger.debug(f'Locale LC_ALL: {locale_startup}') # Now set that same language, but utf8 encoding (it was probably cp1252 # or equivalent for other languages). - locale.setlocale(locale.LC_ALL, (locale_startup[0], 'utf8')) + # UTF-8, not utf8: <https://en.wikipedia.org/wiki/UTF-8#Naming> + locale.setlocale(locale.LC_ALL, (locale_startup[0], 'UTF-8')) + log_locale('After switching to UTF-8 encoding (same language)') # TODO: unittests in place of these # logger.debug('Test from __main__') # test_logging() + class A(object): + class B(object): + def __init__(self): logger.debug('A call from A.B.__init__') @@ -1102,7 +1125,8 @@ Locale LC_TIME: {locale.getlocale(locale.LC_TIME)}''' # Now the string should match, so try translation popup_text = _(popup_text) # And substitute in the other words. - popup_text = popup_text.format(PLUGINS=_('Plugins'), FILE=_('File'), SETTINGS=_('Settings'), DISABLED='.disabled') + popup_text = popup_text.format(PLUGINS=_('Plugins'), FILE=_('File'), SETTINGS=_('Settings'), + DISABLED='.disabled') # And now we do need these to be actual \r\n popup_text = popup_text.replace('\\n', '\n') popup_text = popup_text.replace('\\r', '\r') diff --git a/L10n/cs.strings b/L10n/cs.strings index 36e26ab8..fad071e0 100644 --- a/L10n/cs.strings +++ b/L10n/cs.strings @@ -1,9 +1,6 @@ /* Label for 'UI Scaling' option [prefs.py] */ "UI Scale Percentage" = "Procenta UI škálování"; - -/* Text describing that value '0' means 'default', and changes require a restart [prefs.py] */ -"0 means Default{CR}Restart Required for{CR}changes to take effect!" = "0 je výchozí hodnota{CR}Je potřeba restartovat{CR}aby se změna projevila!"; /* Text describing that value '100' means 'default', and changes require a restart [prefs.py] */ "100 means Default{CR}Restart Required for{CR}changes to take effect!" = "100 je výchozí hodnota{CR}je potřeba restartovat,{CR}aby se změna projevila!"; @@ -535,8 +532,5 @@ /* Shortcut settings prompt on OSX. [prefs.py] */ "{APP} needs permission to use shortcuts" = "{APP} vyžaduje oprávnění pro použití klávesových zkratek"; - -/* Label for 'UI Scaling' option [prefs.py] */ -"UI Scaling" = "UI škálování"; /* Label for user configured level of logging [prefs.py] */ "Log Level" = "Míra logování"; diff --git a/L10n/de.strings b/L10n/de.strings index e70acc1a..73239607 100644 --- a/L10n/de.strings +++ b/L10n/de.strings @@ -1,9 +1,6 @@ /* Label for 'UI Scaling' option [prefs.py] */ "UI Scale Percentage" = "UI-Skalierung in Prozent"; - -/* Text describing that value '0' means 'default', and changes require a restart [prefs.py] */ -"0 means Default{CR}Restart Required for{CR}changes to take effect!" = "0 bedeutet Standardwert{CR}Neustart erforderlich, um{CR}Änderungen zu übernehmen!"; /* Text describing that value '100' means 'default', and changes require a restart [prefs.py] */ "100 means Default{CR}Restart Required for{CR}changes to take effect!" = "100 bedeutet Standardwert{CR}Neustart erforderlich, um{CR}Änderungen zu übernehmen!"; @@ -535,8 +532,5 @@ /* Shortcut settings prompt on OSX. [prefs.py] */ "{APP} needs permission to use shortcuts" = "{APP} benötigt für Tastenkürzel Systemrechte (Bedienungshilfen)"; - -/* Label for 'UI Scaling' option [prefs.py] */ -"UI Scaling" = "UI-Skalierung"; /* Label for user configured level of logging [prefs.py] */ "Log Level" = "Log-Level"; diff --git a/L10n/en.template b/L10n/en.template index a8ca6dc6..cf14aa37 100644 --- a/L10n/en.template +++ b/L10n/en.template @@ -1,3 +1,9 @@ +/* Label for 'UI Scaling' option [prefs.py] */ +"UI Scale Percentage" = "UI Scale Percentage"; + +/* Text describing that value '100' means 'default', and changes require a restart [prefs.py] */ +"100 means Default{CR}Restart Required for{CR}changes to take effect!" = "100 means Default{CR}Restart Required for{CR}changes to take effect!"; + /* Language name */ "!Language" = "English"; @@ -526,11 +532,5 @@ /* Shortcut settings prompt on OSX. [prefs.py] */ "{APP} needs permission to use shortcuts" = "{APP} needs permission to use shortcuts"; -/* Label for 'UI Scaling' option [prefs.py] */ -"UI Scale Percentage" = "UI Scale Percentage"; - -/* Text describing that value '100' means 'default', and changes require a restart [prefs.py] */ -"100 means Default{CR}Restart Required for{CR}changes to take effect!" = "100 means Default{CR}Restart Required for{CR}changes to take effect!"; - /* Label for user configured level of logging [prefs.py] */ "Log Level" = "Log Level"; diff --git a/L10n/fi.strings b/L10n/fi.strings index fd0ff40a..e3e5eacb 100644 --- a/L10n/fi.strings +++ b/L10n/fi.strings @@ -1,3 +1,9 @@ +/* Label for 'UI Scaling' option [prefs.py] */ +"UI Scale Percentage" = "Käyttöliittymän skaalausprosentti"; + +/* Text describing that value '100' means 'default', and changes require a restart [prefs.py] */ +"100 means Default{CR}Restart Required for{CR}changes to take effect!" = "100 tarkoittaa oletusta{CR}Muutosten käyttöönotto{CR}edellyttää uudelleenkäynnistystä!"; + /* Language name */ "!Language" = "Suomi"; @@ -526,3 +532,5 @@ /* Shortcut settings prompt on OSX. [prefs.py] */ "{APP} needs permission to use shortcuts" = "{APP} tarvitsee lupasi pikakuvakkeiden käyttämiseen"; +/* Label for user configured level of logging [prefs.py] */ +"Log Level" = "Lokin syvyys"; diff --git a/L10n/it.strings b/L10n/it.strings index a6659043..0706161f 100644 --- a/L10n/it.strings +++ b/L10n/it.strings @@ -526,8 +526,5 @@ /* Shortcut settings prompt on OSX. [prefs.py] */ "{APP} needs permission to use shortcuts" = "{APP} ha bisogno dei permessi per usare le scorciatoie"; - -/* Label for 'UI Scaling' option [prefs.py] */ -"UI Scaling" = "Adattamento UI"; /* Label for user configured level of logging [prefs.py] */ "Log Level" = "Livello di Log"; diff --git a/L10n/ja.strings b/L10n/ja.strings index 61d89a98..64bca474 100644 --- a/L10n/ja.strings +++ b/L10n/ja.strings @@ -1,9 +1,6 @@ /* Label for 'UI Scaling' option [prefs.py] */ "UI Scale Percentage" = "UIスケール比率"; - -/* Text describing that value '0' means 'default', and changes require a restart [prefs.py] */ -"0 means Default{CR}Restart Required for{CR}changes to take effect!" = "0はデフォルトを意味します{CR}変更した値を反映するには{CR}再起動が必要です!"; /* Text describing that value '100' means 'default', and changes require a restart [prefs.py] */ "100 means Default{CR}Restart Required for{CR}changes to take effect!" = "100はデフォルトを意味します{CR}変更した値を反映するには{CR}再起動が必要です!"; @@ -535,8 +532,5 @@ /* Shortcut settings prompt on OSX. [prefs.py] */ "{APP} needs permission to use shortcuts" = "{APP} がショートカットを利用するには許可が必要です"; - -/* Label for 'UI Scaling' option [prefs.py] */ -"UI Scaling" = "UIスケーリング"; /* Label for user configured level of logging [prefs.py] */ "Log Level" = "ログレベル"; diff --git a/L10n/pt-BR.strings b/L10n/pt-BR.strings index eaeb23b1..7e6b1e5f 100644 --- a/L10n/pt-BR.strings +++ b/L10n/pt-BR.strings @@ -1,9 +1,6 @@ /* Label for 'UI Scaling' option [prefs.py] */ "UI Scale Percentage" = "Porcentagem de escala da IU"; - -/* Text describing that value '0' means 'default', and changes require a restart [prefs.py] */ -"0 means Default{CR}Restart Required for{CR}changes to take effect!" = "0 significa Padrão{CR}Necessário Reiniciar para as{CR}alterações entrarem em vigor!"; /* Text describing that value '100' means 'default', and changes require a restart [prefs.py] */ "100 means Default{CR}Restart Required for{CR}changes to take effect!" = "100 significa Padrão{CR}Necessário Reiniciar para as{CR}alterações entrarem em vigor!"; @@ -535,8 +532,5 @@ /* Shortcut settings prompt on OSX. [prefs.py] */ "{APP} needs permission to use shortcuts" = "{APP} precisa de permissão para usar atalhos"; - -/* Label for 'UI Scaling' option [prefs.py] */ -"UI Scaling" = "Escala da UI"; /* Label for user configured level of logging [prefs.py] */ "Log Level" = "Nível de Log"; diff --git a/L10n/pt-PT.strings b/L10n/pt-PT.strings index 9cd3d0b7..79542cf1 100644 --- a/L10n/pt-PT.strings +++ b/L10n/pt-PT.strings @@ -1,3 +1,9 @@ +/* Label for 'UI Scaling' option [prefs.py] */ +"UI Scale Percentage" = "Percentagem da escala de UI"; + +/* Text describing that value '100' means 'default', and changes require a restart [prefs.py] */ +"100 means Default{CR}Restart Required for{CR}changes to take effect!" = "100 significa Predefinido{CR}Requer reinício para{CR}que as mudanças façam efeito!"; + /* Language name */ "!Language" = "Português (Portugal)"; @@ -526,3 +532,5 @@ /* Shortcut settings prompt on OSX. [prefs.py] */ "{APP} needs permission to use shortcuts" = "A {APP} precisa de autorização para usar atalhos"; +/* Label for user configured level of logging [prefs.py] */ +"Log Level" = "Nível de Log"; diff --git a/L10n/ru.strings b/L10n/ru.strings index b31c20ac..64a8c6dc 100644 --- a/L10n/ru.strings +++ b/L10n/ru.strings @@ -1,3 +1,9 @@ +/* Label for 'UI Scaling' option [prefs.py] */ +"UI Scale Percentage" = "Процент масштабирования интерфейса"; + +/* Text describing that value '100' means 'default', and changes require a restart [prefs.py] */ +"100 means Default{CR}Restart Required for{CR}changes to take effect!" = "100 - значение по умолчанию{CR}требуется перезапуск для вступления в силу изменений{CR}!"; + /* Language name */ "!Language" = "Русский"; @@ -56,7 +62,7 @@ "Chief Petty Officer" = "Главный старшина"; /* Main window. [EDMarketConnector.py] */ -"Cmdr" = "Командир"; +"Cmdr" = "КМДР"; /* Ranking. [stats.py] */ "Combat" = "Боевой"; @@ -86,7 +92,7 @@ "Dangerous" = "Опасный"; /* Appearance theme setting. [prefs.py] */ -"Dark" = "Темная"; +"Dark" = "Тёмная"; /* Combat rank. [stats.py] */ "Deadly" = "Смертоносный"; @@ -101,7 +107,7 @@ "Delay sending until docked" = "Отложить отправку данных до завершения стыковки"; /* Option to disabled Automatic Check For Updates whilst in-game [prefs.py] */ -"Disable Automatic Application Updates Check when in-game" = "Отключить автоматическое обновление приложения во время нахождения в игре."; +"Disable Automatic Application Updates Check when in-game" = "Отключить автоматическое обновление приложения во время нахождения в игре"; /* List of plugins in settings. [prefs.py] */ "Disabled Plugins" = "Отключенные плагины"; @@ -125,13 +131,13 @@ "Elite" = "Элита"; /* Section heading in settings. [edsm.py] */ -"Elite Dangerous Star Map credentials" = "Учетные данные Elite Dangerous Star Map"; +"Elite Dangerous Star Map credentials" = "Учётные данные Elite Dangerous Star Map"; /* Ranking. [stats.py] */ "Empire" = "Империя"; /* List of plugins in settings. [prefs.py] */ -"Enabled Plugins" = "Включенные плагины"; +"Enabled Plugins" = "Включённые плагины"; /* Federation rank. [stats.py] */ "Ensign" = "Энсин"; @@ -221,7 +227,7 @@ "Hotkey" = "Горячая клавиша"; /* Section heading in settings. [inara.py] */ -"Inara credentials" = "Учетные данные Inara"; +"Inara credentials" = "Учётные данные Inara"; /* Hotkey/Shortcut settings prompt on OSX. [prefs.py] */ "Keyboard shortcut" = "Сочетание клавиш"; @@ -302,7 +308,7 @@ "Open" = "Открыть"; /* Shortcut settings button on OSX. [prefs.py] */ -"Open System Preferences" = "Открыты «Системные настройки»"; +"Open System Preferences" = "Открыты «Настройки системы»"; /* Tab heading in settings. [prefs.py] */ "Output" = "Данные"; @@ -356,7 +362,7 @@ "Post Commander" = "Начальник гарнизона"; /* Ranking. [stats.py] */ -"Powerplay" = "Ранг в Игре Сил"; +"Powerplay" = "Ранг у галактических держав"; /* [prefs.py] */ "Preferences" = "Настройки"; @@ -416,13 +422,13 @@ "Semi Professional" = "Полупрофессионал"; /* [edsm.py] */ -"Send flight log and Cmdr status to EDSM" = "Отправлять данные полетного журнала и информацию статусе командира в EDSM"; +"Send flight log and Cmdr status to EDSM" = "Отправлять данные журнала полёта и информацию статусе командира в EDSM"; /* [inara.py] */ -"Send flight log and Cmdr status to Inara" = "Отправлять журнал полёта и состояние командира в Inara"; +"Send flight log and Cmdr status to Inara" = "Отправлять данные журнала полёта и информацию статусе командира в Inara"; /* Output setting. [eddn.py] */ -"Send station data to the Elite Dangerous Data Network" = "Отправлять станционную информацию на Elite Dangerous Data Network"; +"Send station data to the Elite Dangerous Data Network" = "Отправлять информацию о станциях на Elite Dangerous Data Network"; /* Output setting new in E:D 2.2. [eddn.py] */ "Send system and scan data to the Elite Dangerous Data Network" = "Отправлять данные о системе и данные сканирования в Elite Dangerous Data Network"; @@ -437,13 +443,13 @@ "Settings" = "Настройки"; /* Main window. [EDMarketConnector.py] */ -"Ship" = "Судно"; +"Ship" = "Корабль"; /* Output setting. [prefs.py] */ -"Ship loadout" = "Оборудование судна"; +"Ship loadout" = "Оборудование корабля"; /* Status dialog title. [stats.py] */ -"Ships" = "Суда"; +"Ships" = "Корабли"; /* Setting to decide which ship outfitting website to link to - either E:D Shipyard or Coriolis. [prefs.py] */ "Shipyard" = "Верфи"; @@ -491,7 +497,7 @@ "Update" = "Обновить данные"; /* Option to use alternate URL method on shipyard links [prefs.py] */ -"Use alternate URL method" = "Альтернативный URL(адрес)"; +"Use alternate URL method" = "Альтернативный URL (адрес)"; /* Status dialog subtitle - CR value of ship. [stats.py] */ "Value" = "Стоимость"; @@ -509,7 +515,7 @@ "Warrant Officer" = "Уорент-офицер"; /* Shouldn't happen. [EDMarketConnector.py] */ -"What are you flying?!" = "Невозможно определить тип судна!"; +"What are you flying?!" = "Невозможно определить тип корабля!"; /* Shouldn't happen. [EDMarketConnector.py] */ "Where are you?!" = "Невозможно определить местоположение!"; @@ -521,13 +527,10 @@ "Window" = "Окно"; /* [EDMarketConnector.py] */ -"You're not docked at a station!" = "Ваше судно не пристыковано к станции!"; +"You're not docked at a station!" = "Ваш корабль не пристыкован к станции!"; /* Shortcut settings prompt on OSX. [prefs.py] */ "{APP} needs permission to use shortcuts" = "{APP} требуется разрешение на использование глобальных сочетаний клавиш"; - -/* Label for 'UI Scaling' option [prefs.py] */ -"UI Scaling" = "Масштабирование интерфейса"; /* Label for user configured level of logging [prefs.py] */ "Log Level" = "Уровень лога"; diff --git a/L10n/sr-Latn-BA.strings b/L10n/sr-Latn-BA.strings index 0c55376b..70c6e66b 100644 --- a/L10n/sr-Latn-BA.strings +++ b/L10n/sr-Latn-BA.strings @@ -1,9 +1,6 @@ /* Label for 'UI Scaling' option [prefs.py] */ "UI Scale Percentage" = "Procenat UI skaliranja"; - -/* Text describing that value '0' means 'default', and changes require a restart [prefs.py] */ -"0 means Default{CR}Restart Required for{CR}changes to take effect!" = "0 je standardna vrijednost{CR}Potreban je restart{CR}za primjenu podešavanja!"; /* Text describing that value '100' means 'default', and changes require a restart [prefs.py] */ "100 means Default{CR}Restart Required for{CR}changes to take effect!" = "100 je standardna vrijednost{CR}Potreban je restart{CR}za primjenu podešavanja!"; @@ -535,8 +532,5 @@ /* Shortcut settings prompt on OSX. [prefs.py] */ "{APP} needs permission to use shortcuts" = "{APP} traži dozvolu da koristi prečice"; - -/* Label for 'UI Scaling' option [prefs.py] */ -"UI Scaling" = "UI skaliranje"; /* Label for user configured level of logging [prefs.py] */ "Log Level" = "Log nivo"; diff --git a/L10n/sr-Latn.strings b/L10n/sr-Latn.strings index 45eacc40..39f5bce3 100644 --- a/L10n/sr-Latn.strings +++ b/L10n/sr-Latn.strings @@ -1,3 +1,9 @@ +/* Label for 'UI Scaling' option [prefs.py] */ +"UI Scale Percentage" = "Procenat UI skaliranja"; + +/* Text describing that value '100' means 'default', and changes require a restart [prefs.py] */ +"100 means Default{CR}Restart Required for{CR}changes to take effect!" = "100 znači podrazumevano{CR}Restart je potreban{CR}da bi se promene primenile!"; + /* Language name */ "!Language" = "Srpski (Latinica)"; @@ -526,3 +532,5 @@ /* Shortcut settings prompt on OSX. [prefs.py] */ "{APP} needs permission to use shortcuts" = "{APP} traži dozvolu da koristi skraćenice"; +/* Label for user configured level of logging [prefs.py] */ +"Log Level" = "Log Nivo"; diff --git a/PLUGINS.md b/PLUGINS.md index c391a3ab..717171f7 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -3,6 +3,7 @@ Plugins allow you to customise and extend the behavior of EDMC. ## Installing a Plugin + See [Plugins](https://github.com/EDCD/EDMarketConnector/wiki/Plugins) on the wiki. @@ -12,16 +13,22 @@ Plugins are loaded when EDMC starts up. Each plugin has it's own folder in the `plugins` directory: -* Windows: `%LOCALAPPDATA%\EDMarketConnector\plugins` -* Mac: `~/Library/Application Support/EDMarketConnector/plugins` -* Linux: `$XDG_DATA_HOME/EDMarketConnector/plugins`, or - `~/.local/share/EDMarketConnector/plugins` if `$XDG_DATA_HOME` is unset. +- Windows: `%LOCALAPPDATA%\EDMarketConnector\plugins` +- Mac: `~/Library/Application Support/EDMarketConnector/plugins` +- Linux: `$XDG_DATA_HOME/EDMarketConnector/plugins`, or `~/.local/share/EDMarketConnector/plugins` if `$XDG_DATA_HOME` is unset. Plugins are python files. The plugin folder must have a file named `load.py` that must provide one module level function and optionally provide a few others. --- + +### Examples + +We have some example plugins available in the docs/examples directory. See the readme in each folder for more info. + +--- + ### Available imports **`import`ing anything from the core EDMarketConnector code that is not @@ -32,34 +39,36 @@ breaking with future code changes.** `from theme import theme` - So plugins can theme their own UI elements to match the main UI. - + `from config import appname, applongname, appcmdname, appversion , copyright, config` - to access config. `from prefs import prefsVersion` - to allow for versioned preferences. `from companion import category_map` - Or any of the other static date - contained therein. NB: There's a plan to move such to a `data` module. +contained therein. NB: There's a plan to move such to a `data` module. `import plug` - Mostly for using `plug.show_error()`. Also the flags - for `dashboard_entry()` to be useful (see example below). Relying on anything - else isn't supported. - +for `dashboard_entry()` to be useful (see example below). Relying on anything +else isn't supported. + `from monitor import gamerunning` - in case a plugin needs to know if we think the game is running. `import timeout_session` - provides a method called `new_session` that creates a requests.session with a default timeout on all requests. Recommended to reduce noise in HTTP requests - ```python from ttkHyperlinkLabel import HyperlinkLabel import myNotebook as nb ``` + For creating UI elements. --- + ### Logging + In the past the only way to provide any logged output from a plugin was to use `print(...)` statements. When running the application from the packaged executeable all output is redirected to a log file. See @@ -70,8 +79,8 @@ EDMC now implements proper logging using the Python `logging` module. Plugin developers should now use the following code instead of simple `print(...)` statements. -Insert this at the top-level of your load.py file (so not inside -`plugin_start3()` ): +Insert this at the top-level of your load.py file (so not inside `plugin_start3()` ): + ```python import logging @@ -82,6 +91,8 @@ plugin_name = os.path.basename(os.path.dirname(__file__)) # A Logger is used per 'found' plugin to make it easy to include the plugin's # folder name in the logging output format. +# NB: plugin_name here *must* be the plugin's folder name as per the preceding +# code, else the logger won't be properly set up. logger = logging.getLogger(f'{appname}.{plugin_name}') # If the Logger has handlers then it was already set up by the core code, else @@ -98,6 +109,13 @@ if not logger.hasHandlers(): logger.addHandler(logger_channel) ``` +Note the admonishment about `plugin_name` being the folder name of your plugin. +It can't be anything else (such as a different string returned from +`plugin_start3()`) because the code in plug.py that sets up the logger uses +exactly the folder name. Our custom `qualname` and `class` formatters won't +work with a 'bare' logger, and will cause your code to throw exceptions if +you're not using our supplied logger. + If running with 4.1.0-beta1 or later of EDMC the logging setup happens in the core code and will include the extra logfile destinations. If your plugin is run under a pre-4.1.0 version of EDMC then the above will set up @@ -132,7 +150,7 @@ Replace all `print(...)` statements with one of the following: logger.debug('Exception we only note in debug output', exc_info=e) ``` -Remember you can use fstrings to include variables, and even the returns of +Remember you can use [fstrings](https://www.python.org/dev/peps/pep-0498/) to include variables, and even the returns of functions, in the output. ```python @@ -140,40 +158,50 @@ functions, in the output. ``` --- + ### Startup + EDMC will import the `load.py` file as a module and then call the `plugin_start3()` function. ```python -def plugin_start3(plugin_dir): +def plugin_start3(plugin_dir: str) -> str: """ Load this plugin into EDMC """ - print("I am loaded! My plugin folder is {}".format(plugin_dir)) + print(f"I am loaded! My plugin folder is {plugin_dir}") return "Test" ``` + The string you return is used as the internal name of the plugin. Any errors or print statements from your plugin will appear in `%TMP%\EDMarketConnector.log` on Windows, `$TMPDIR/EDMarketConnector.log` on Mac, and `$TMP/EDMarketConnector.log` on Linux. +| Parameter | Type | Description | +| :----------- | :---: | :------------------------------------------------------ | +| `plugin_dir` | `str` | The directory that your plugin is located in. | +| `RETURN` | `str` | The name you want to be used for your plugin internally | + ### Shutdown + This gets called when the user closes the program: ```python -def plugin_stop(): +def plugin_stop() -> None: """ EDMC is closing """ print("Farewell cruel world!") ``` -If your plugin uses one or more threads to handle Events then stop and join() -the threads before returning from this function. +If your plugin uses one or more threads to handle Events then `stop()` and `join()` +(to wait for their exit -- Recommended, not required) the threads before returning from this function. ## Plugin Hooks -### Configuration + +### Configuration If you want your plugin to be configurable via the GUI you can define a frame (panel) to be displayed on its own tab in EDMC's settings dialog. The tab @@ -192,39 +220,53 @@ numbers in a locale-independent way. ```python import tkinter as tk +from tkinter import ttk import myNotebook as nb from config import config +from typing import Optional -this = sys.modules[__name__] # For holding module globals +my_setting: Optional[tk.IntVar] = None -def plugin_prefs(parent, cmdr, is_beta): +def plugin_prefs(parent: nb.Notebook, cmdr: str, is_beta: bool) -> Optional[tk.Frame]: """ Return a TK Frame for adding to the EDMC settings dialog. """ - this.mysetting = tk.IntVar(value=config.getint("MyPluginSetting")) # Retrieve saved value from config + global my_setting + my_setting = tk.IntVar(value=config.getint("MyPluginSetting")) # Retrieve saved value from config frame = nb.Frame(parent) nb.Label(frame, text="Hello").grid() nb.Label(frame, text="Commander").grid() - nb.Checkbutton(frame, text="My Setting", variable=this.mysetting).grid() + nb.Checkbutton(frame, text="My Setting", variable=my_setting).grid() return frame ``` +| Parameter | Type | Description | +| :-------- | :-----------: | :----------------------------------------------- | +| `parent` | `nb.Notebook` | Root Notebook object the preferences window uses | +| `cmdr` | `str` | The current commander | +| `is_beta` | `bool` | If the game is currently a beta version | + This gets called when the user dismisses the settings dialog: ```python -def prefs_changed(cmdr, is_beta): +def prefs_changed(cmdr: str, is_beta: bool) -> None: """ Save settings. """ - config.set('MyPluginSetting', this.mysetting.getint()) # Store new value in config + config.set('MyPluginSetting', my_setting.get()) # Store new value in config ``` +| Parameter | Type | Description | +| :-------- | :----: | :-------------------------------------- | +| `cmdr` | `str` | The current commander | +| `is_beta` | `bool` | If the game is currently a beta version | + ### Display You can also have your plugin add an item to the EDMC main window and update from your event hooks. This works in the same way as `plugin_prefs()`. For a -simple one-line item return a tk.Label widget or a pair of widgets as a tuple. +simple one-line item return a `tk.Label` widget or a 2 tuple of widgets. For a more complicated item create a tk.Frame widget and populate it with other ttk widgets. Return `None` if you just want to use this as a callback after the main window and all other plugins are initialised. @@ -233,42 +275,62 @@ You can use `stringFromNumber()` from EDMC's `l10n.Locale` object to format numbers in your widgets in a locale-independent way. ```python -this = sys.modules[__name__] # For holding module globals +from typing import Optional, Tuple +import tkinter as tk -def plugin_app(parent): +status: Optional[tk.Label] + + +def plugin_app(parent: tk.Frame) -> Tuple[tk.Label, tk.Label]: """ Create a pair of TK widgets for the EDMC main window """ + global status label = tk.Label(parent, text="Status:") # By default widgets inherit the current theme's colors - this.status = tk.Label(parent, text="", foreground="yellow") # Override theme's foreground color - return (label, this.status) - + status = tk.Label(parent, text="", foreground="yellow") # Override theme's foreground color + return (label, status) + # later on your event functions can update the contents of these widgets - this.status["text"] = "Happy!" - this.status["foreground"] = "green" +def some_other_function() -> None: + global status + status["text"] = "Happy!" + status["foreground"] = "green" ``` -You can dynamically add and remove widgets on the main window by returning a -tk.Frame from `plugin_app()` and later creating and destroying child widgets -of that frame. +| Parameter | Type | Description | +| :-------- | :---------------------------------------------: | :---------------------------------------------------------- | +| `parent` | `tk.Frame` | The root EDMC window | +| `RETURN` | `Union[tk.Widget, Tuple[tk.Widget, tk.Widget]]` | A widget to add to the main window. See below for more info | + +The return from plugin_app can either be any widget (Frame, Label, Notebook, etc.), or a 2 tuple of widgets. In the case of +a 2 tuple, indices 0 and 1 are placed automatically in the outer grid on column indices 0 and 1. Otherwise, the only thing done +to your return widget is it is set to use a columnspan of 2, and placed on the grid. + +You can dynamically add and remove widgets on the main window by returning a tk.Frame from `plugin_app()` and later +creating and destroying child widgets of that frame. ```python +from typing import Option +import tkinter as tk + from theme import theme -this = sys.modules[__name__] # For holding module globals +frame: Optional[tk.Frame] = None -def plugin_app(parent): +def plugin_app(parent: tk.Frame) -> tk.Frame: """ Create a frame for the EDMC main window """ - this.frame = tk.Frame(parent) - return this.frame + global frame + frame = tk.Frame(parent) + return frame +def some_other_function_called_later() -> None: # later on your event functions can add or remove widgets - row = this.frame.grid_size()[1] - new_widget_1 = tk.Label(this.frame, text="Status:") + row = frame.grid_size()[1] + new_widget_1 = tk.Label(frame, text="Status:") new_widget_1.grid(row=row, column=0, sticky=tk.W) - new_widget_2 = tk.Label(this.frame, text="Unhappy!", foreground="red") # Override theme's foreground color + new_widget_2 = tk.Label(frame, text="Unhappy!", foreground="red") # Override theme's foreground color new_widget_2.grid(row=row, column=1, sticky=tk.W) theme.update(this.frame) # Apply theme colours to the frame and its children, including the new widgets ``` @@ -295,58 +357,54 @@ for an example of these techniques. #### Journal Entry ```python -def journal_entry(cmdr, is_beta, system, station, entry, state): +def journal_entry( + cmdr: str, is_beta: bool, system: str, station: str, entry: Dict[str, Any], state: Dict[str, Any] +) -> None: if entry['event'] == 'FSDJump': # We arrived at a new system! if 'StarPos' in entry: - sys.stderr.write("Arrived at {} ({},{},{})\n".format(entry['StarSystem'], *tuple(entry['StarPos']))) + logger.info(f'Arrived at {entry["StarSystem"]} {entry["StarPos"') + else: - sys.stderr.write("Arrived at {}\n".format(entry['StarSystem'])) + logger.info(f'Arrived at {entry["StarSystem"]}') ``` This gets called when EDMC sees a new entry in the game's journal. - -- `cmdr` is a `str` denoting the current Commander Name. -- `is_beta` is a `bool` denoting if data came from a beta version of the game. -- `system` is a `str` holding the name of the current system, or `None` if not - yet known. -- `station` is a `str` holding the name of the current station, or `None` if - not yet known or appropriate. -- `entry` is an `OrderedDict` holding the Journal event. -- `state` is a `dictionary` containing information about the Cmdr and their - ship and cargo (including the effect of the current journal entry). - - `Captain` - `str` of name of Commander's crew you joined in multi-crew, - else `None` - - `Cargo` - `dict` with details of current cargo. - - `Credits` - Current credit balance. - - `FID` - Frontier Cmdr ID - - `Horizons` - `bool` denoting if Horizons expansion active. - - `Loan` - Current loan amount, else None. - - `Raw` - `dict` with details of "Raw" materials held. - - `Manufactured` - `dict` with details of "Manufactured" materials held. - - `Encoded` - `dict` with details of "Encoded" materials held. - - `Engineers` - `dict` with details of Rank Progress for Engineers. - - `Rank` - `dict` of current Ranks. Each entry is a `tuple` of - (<rank `int`>, <progress %age `int`>) - - `Reputation` - `dict` of Major Faction reputations, scale is -100 to +100 - See Frontier's Journal Manual for detail of bands. - - `Statistics` - `dict` of a Journal "Statistics" event, i.e. data shown - in the statistics panel on the right side of the cockpit. See Frontier's - Journal Manual for details. - - `Role` - Crew role if multi-crewing in another Commander's ship: - - `None` - - "Idle" - - "FireCon" - - "FighterCon" - - `Friends` -`set` of online friends. - - `ShipID` - `int` that denotes Frontier internal ID for your current ship. - - `ShipIdent` - `str` of your current ship's textual ID (which you set). - - `ShipName` - `str` of your current ship's textual Name (which you set). - - `ShipType` - `str` of your current ship's model, e.g. "CobraMkIII". - - `HullValue` - `int` of current ship's credits value, excluding modules. - - `ModulesValue` - `int` of current ship's module's total credits value. - - `Rebuy` - `int` of current ship's rebuy cost in credits. - - `Modules` - `dict` with data on currently fitted modules. + +| Parameter | Type | Description | +| :-------- | :--------------: | :--------------------------------------------------------------------- | +| `cmdr` | `str` | Current commander name | +| `is_beta` | `bool` | Is the game currently in beta | +| `system` | `Optional[str]` | Current system, if known | +| `station` | `Optional[str]` | Current station, if any | +| `entry` | `Dict[str, Any]` | The journal event | +| `state` | `Dict[str, Any]` | More info about the commander, their ship, and their cargo (see below) | + +Content of `state` (updated to the current journal entry): + +| Field | Type | Description | +| :------------- | :-------------------------: | :-------------------------------------------------------------------------------------------------------------- | +| `Captian` | `Optional[str]` | Name of the commander who's crew you're on, if any | +| `Cargo` | `dict` | Current cargo | +| `Credits` | `int` | Current credits balance | +| `FID` | `str` | Frontier commander ID | +| `Loan` | `Optional[int]` | Current loan amount, if any | +| `Raw` | `dict` | Current raw engineering materials | +| `Manufactured` | `dict` | Current manufactured engineering materials | +| `Encoded` | `dict` | Current encoded engineering materials | +| `Engineers` | `dict` | Current Raw engineering materials | +| `Rank` | `Dict[str, Tuple[int, int]` | Current ranks, each entry is a tuple of the current rank, and age | +| `Statistics` | `dict` | Contents of a Journal Statistics event, ie, data shown in the stats panel. See the Journal manual for more info | +| `Role` | `Optional[str]` | Current role if in multi-crew, one of `Idle`, `FireCon`, `FighterCon` | +| `Friends` | `set` | Currently online friend | +| `ShipID` | `int` | Frontier ID of current ship | +| `ShipIdent` | `str` | Current user-set ship ID | +| `ShipName` | `str` | Current user-set ship name | +| `ShipType` | `str` | Internal name for the current ship type | +| `HullValue` | `int` | Current ship value, excluding modules | +| `ModulesValue` | `int` | Value of the current ship's modules | +| `Rebuy` | `int` | Current ship's rebuy cost | +| `Modules` | `dict` | Currently fitted modules | A special "StartUp" entry is sent if EDMC is started while the game is already running. In this case you won't receive initial events such as "LoadGame", @@ -357,29 +415,27 @@ Similarly, a special "ShutDown" entry is sent when the game is quitted while EDMC is running. This event is not sent when EDMC is running on a different machine so you should not *rely* on receiving this event. - #### Player Dashboard ```python import plug -def dashboard_entry(cmdr, is_beta, entry): +def dashboard_entry(cmdr: str, is_beta: bool, entry: Dict[str, Any]): is_deployed = entry['Flags'] & plug.FlagsHardpointsDeployed sys.stderr.write("Hardpoints {}\n".format(is_deployed and "deployed" or "stowed")) ``` This gets called when something on the player's cockpit display changes - typically about once a second when in orbital flight. - -- `cmdr` is a `str` denoting the current Commander Name. -- `is_beta` is a `bool` denoting if data came from a beta version of the game. -- `entry` is a `dict` loaded from the Status.json file the game writes. - See the "Status File" section in the Frontier [Journal documentation](https://forums.frontier.co.uk/showthread.php/401661) - for the available `entry` properties and for the list of available `"Flags"`. - Ask on the EDCD Discord server to be sure you have the latest version. - Refer to the source code of [plug.py](./plug.py) for the list of available - constants. +| Parameter | Type | Description | +| :-------- | :----: | :-------------------------------- | +| `cmdr` | `str` | Current command name | +| `is_beta` | `bool` | if the game is currently in beta | +| `entry` | `dict` | Data from status.json (see below) | + + For more info on `status.json`, See the "Status File" section in the Frontier [Journal documentation](https://forums.frontier.co.uk/showthread.php/401661) for the available `entry` properties and for the list of available `"Flags"`. Refer to the source code of [plug.py](./plug.py) for the list of available constants. + #### Getting Commander Data ```python @@ -387,19 +443,19 @@ def cmdr_data(data, is_beta): """ We have new data on our commander """ - sys.stderr.write(data.get('commander') and data.get('commander').get('name') or '') + if data.get('commander') is None or data['commander'].get('name') is None: + raise ValueError("this isn't possible") + + logger.info(data['commander']['name']) ``` This gets called when EDMC has just fetched fresh Cmdr and station data from Frontier's servers. -- `data` is a dictionary containing the response from Frontier to a CAPI -`/profile` request, augmented with two extra keys: - - `marketdata` - contains the CAPI data from the `/market` endpoint, if - docked and the station has the commodites service. - - `shipdata` - contains the CAPI data from the `/shipyard` endpoint, if - docked and the station has the shipyard service. -- `is_beta` is a `bool` denoting if data came from a beta version of the game. +| Parameter | Type | Description | +| :-------- | :--------------: | :------------------------------------------------------------------------------------------------------- | +| `data` | `Dict[str, Any]` | `/profile` API response, with `/market` and `/shipyard` added under the keys `marketdata` and `shipdata` | +| `is_beta` | `bool` | If the game is currently in beta | #### Plugin-specific events @@ -409,37 +465,41 @@ def edsm_notify_system(reply): `reply` holds the response from a call to https://www.edsm.net/en/api-journal-v1 """ if not reply: - sys.stderr.write("Error: Can't connect to EDSM\n") + logger.info("Error: Can't connect to EDSM") + elif reply['msgnum'] // 100 not in (1,4): - sys.stderr.write('Error: EDSM {MSG}\n').format(MSG=reply['msg']) + logger.info(f'Error: EDSM {reply["msg"]}') + elif reply.get('systemCreated'): - sys.stderr.write('New EDSM system!\n') + logger.info('New EDSM system!') + else: - sys.stderr.write('Known EDSM system\n') + logger.info('Known EDSM system') ``` If the player has chosen to "Send flight log and Cmdr status to EDSM" this gets called when the player starts the game or enters a new system. It is called some time after the corresponding `journal_entry()` event. ---- +| Parameter | Type | Description | +| :-------- | :--------------: | :--------------------------------------------------------------------------------------------- | +| `reply` | `Dict[str, Any]` | Response to an API call to [EDSM's journal API target](https://www.edsm.net/en/api-journal-v1) | + ```python -def inara_notify_location(eventData): +def inara_notify_location(event_data): """ - `eventData` holds the response to one of the "Commander's Flight Log" events https://inara.cz/inara-api-docs/#event-29 + `event_data` holds the response to one of the "Commander's Flight Log" events https://inara.cz/inara-api-docs/#event-29 """ - if eventData.get('starsystemInaraID'): - sys.stderr.write('Now in Inara system {ID} at {URL}\n'.format(ID=eventData['starsystemInaraID'], - URL=eventData['starsystemInaraURL']) - ) + if event_data.get('starsystemInaraID'): + logging.info(f'Now in Inara system {event_data["starsystemInaraID"]} at {event_data["starsystemInaraURL"]}') else: - sys.stderr.write('System not known to Inara\n') - if eventData.get('stationInaraID'): - sys.stderr.write('Docked at Inara station {ID} at {URL}\n'.format(ID=eventData['stationInaraID'], - URL=eventData['stationInaraURL']) - ) + logger.info('System not known to Inara') + + if event_data.get('stationInaraID'): + logger.info(f'Docked at Inara station {event_data["stationInaraID"]} at {event_data["stationInaraURL"]}') + else: - sys.stderr.write('Undocked or station unknown to Inara\n') + logger.info('Undocked or station unknown to Inara') ``` If the player has chosen to "Send flight log and Cmdr status to Inara" this @@ -447,15 +507,20 @@ gets called when the player starts the game, enters a new system, docks or undocks. It is called some time after the corresponding `journal_entry()` event. +| Parameter | Type | Description | +| :----------- | :--------------: | :----------------------------------------------------------------------------------------------------------- | +| `event_data` | `Dict[str, Any]` | Response to an API call to [INARA's `Commander Flight Log` event](https://inara.cz/inara-api-docs/#event-29) | + --- + ```python -def inara_notify_ship(eventData): +def inara_notify_ship(event_data): """ - `eventData` holds the response to an addCommanderShip or setCommanderShip event https://inara.cz/inara-api-docs/#event-11 + `event_data` holds the response to an addCommanderShip or setCommanderShip event https://inara.cz/inara-api-docs/#event-11 """ - if eventData.get('shipInaraID'): - sys.stderr.write('Now in Inara ship {ID} at {URL}\n'.format(ID=eventData['shipInaraID'], - URL=eventData['shipInaraURL']) + if event_data.get('shipInaraID'): + logger.info( + f'Now in Inara ship {event_data['shipInaraID'],} at {event_data['shipInaraURL']} ) ``` @@ -463,6 +528,10 @@ If the player has chosen to "Send flight log and Cmdr status to Inara" this gets called when the player starts the game or switches ship. It is called some time after the corresponding `journal_entry()` event. +| Parameter | Type | Description | +| :----------- | :--------------: | :----------------------------------------------------------------------------------------------------------------------------- | +| `event_data` | `Dict[str, Any]` | Response to an API call to [INARA's `addCommanderShip` or `setCommanderShip` event](https://inara.cz/inara-api-docs/#event-11) | + ## Error messages You can display an error in EDMC's status area by returning a string from your @@ -490,7 +559,7 @@ _ = functools.partial(l10n.Translations.translate, context=__file__) Wrap each string that needs translating with the `_()` function, e.g.: ```python - this.status["text"] = _('Happy!') # Main window status + status["text"] = _('Happy!') # Main window status ``` If you display localized strings in EDMC's main window you should refresh them @@ -508,7 +577,6 @@ See EDMC's own [`L10n`](https://github.com/EDCD/EDMarketConnector/tree/master/L1 folder for the list of supported language codes and for example translation files. - ## Python Package Plugins A _Package Plugin_ is both a standard Python package (i.e. contains an @@ -519,20 +587,19 @@ before any non-Package plugins. Other plugins can access features in a Package Plugin by `import`ing the package by name in the usual way. - ## Distributing a Plugin To package your plugin for distribution simply create a `.zip` archive of your plugin's folder: -* Windows: In Explorer right click on your plugin's folder and choose Send to - → Compressed (zipped) folder. -* Mac: In Finder right click on your plugin's folder and choose Compress. +- Windows: In Explorer right click on your plugin's folder and choose Send to + → Compressed (zipped) folder. +- Mac: In Finder right click on your plugin's folder and choose Compress. If there are any external dependencies then include them in the plugin's folder. -Optionally, for tidiness delete any `.pyc` and `.pyo` files in the archive. +Optionally, for tidiness delete any `.pyc` and `.pyo` files in the archive, as well as the `__pycache__` directory. ## Disable a plugin @@ -545,23 +612,26 @@ Disabled and enabled plugins are listed on the "Plugins" Settings tab ## Migration Starting with pre-release 3.5 EDMC uses Python **3.7**. The first full -release under Python 3.7 will be 4.0.0.0. This is a brief outline of the steps +release under Python 3.7 was 4.0.0.0. This is a brief outline of the steps required to migrate a plugin from earlier versions of EDMC: - Rename the function `plugin_start` to `plugin_start3(plugin_dir)`. - Plugins without a `plugin_start3` function are listed as disabled on EDMC's - "Plugins" tab and a message like "plugin SuperSpaceHelper needs migrating" - appears in the log. Such plugins are also listed in a section "Plugins Without - Python 3.x Support:" on the Settings > Plugins tab. + Plugins without a `plugin_start3` function are listed as disabled on EDMC's + "Plugins" tab and a message like "plugin SuperSpaceHelper needs migrating" + appears in the log. Such plugins are also listed in a section "Plugins Without + Python 3.x Support:" on the Settings > Plugins tab. + - Check that callback functions `plugin_prefs`, `prefs_changed`, - `journal_entry`, `dashboard_entry` and `cmdr_data` if used are declared with - the correct number of arguments. Older versions of this app were tolerant - of missing arguments in these function declarations. + `journal_entry`, `dashboard_entry` and `cmdr_data` if used are declared with + the correct number of arguments. Older versions of this app were tolerant + of missing arguments in these function declarations. + - Port the code to Python 3.7. The [2to3](https://docs.python.org/3/library/2to3.html) - tool can automate much of this work. + tool can automate much of this work. Depending on the complexity of the plugin it may be feasible to make it compatible with both EDMC 3.4 + Python 2.7 and EDMC 3.5 + Python 3.7. + [Here's](https://python-future.org/compatible_idioms.html) a guide on writing Python 2/3 compatible code and [here's](https://github.com/Marginal/HabZone/commit/3c41cd41d5ad81ef36aab40e967e3baf77b4bd06) an example of the changes required for a simple plugin. diff --git a/companion.py b/companion.py index 947ec170..ee6369eb 100644 --- a/companion.py +++ b/companion.py @@ -1,33 +1,42 @@ -from builtins import str -from builtins import range -from builtins import object +""" +Handle use of Frontier's Companion API (CAPI) service. + +Deals with initiating authentication for, and use of, CAPI. +Some associated code is in protocol.py which creates and handles the edmc:// +protocol used for the callback. +""" + import base64 import csv -import requests -from typing import TYPE_CHECKING - -# TODO: see https://github.com/EDCD/EDMarketConnector/issues/569 -from http.cookiejar import LWPCookieJar # noqa: F401 - No longer needed but retained in case plugins use it -from email.utils import parsedate import hashlib import numbers import os -from os.path import join import random import time import urllib.parse import webbrowser +from builtins import object, range, str +from email.utils import parsedate +# TODO: see https://github.com/EDCD/EDMarketConnector/issues/569 +from http.cookiejar import LWPCookieJar # noqa: F401 - No longer needed but retained in case plugins use it +from os.path import join +from typing import TYPE_CHECKING, Any, Dict, List, NewType, Union + +import requests from config import appname, appversion, config +from EDMCLogging import get_main_logger from protocol import protocolhandler -import logging -logger = logging.getLogger(appname) +logger = get_main_logger() if TYPE_CHECKING: - _ = lambda x: x # noqa # to make flake8 stop complaining that the hacked in _ method doesnt exist + _ = lambda x: x # noqa: E731 # to make flake8 stop complaining that the hacked in _ method doesnt exist +# Define custom type for the dicts that hold CAPI data +CAPIData = NewType('CAPIData', Dict) + holdoff = 60 # be nice timeout = 10 # requests timeout auth_timeout = 30 # timeout for initial auth @@ -35,83 +44,89 @@ auth_timeout = 30 # timeout for initial auth # Currently the "Elite Dangerous Market Connector (EDCD/Athanasius)" one in # Athanasius' Frontier account # Obtain from https://auth.frontierstore.net/client/signup -CLIENT_ID = os.getenv('CLIENT_ID') or 'fb88d428-9110-475f-a3d2-dc151c2b9c7a' +CLIENT_ID = os.getenv('CLIENT_ID') or 'fb88d428-9110-475f-a3d2-dc151c2b9c7a' SERVER_AUTH = 'https://auth.frontierstore.net' -URL_AUTH = '/auth' +URL_AUTH = '/auth' URL_TOKEN = '/token' -USER_AGENT = 'EDCD-{}-{}'.format(appname, appversion) +USER_AGENT = f'EDCD-{appname}-{appversion}' SERVER_LIVE = 'https://companion.orerve.net' SERVER_BETA = 'https://pts-companion.orerve.net' -URL_QUERY = '/profile' -URL_MARKET = '/market' -URL_SHIPYARD= '/shipyard' +URL_QUERY = '/profile' +URL_MARKET = '/market' +URL_SHIPYARD = '/shipyard' # Map values reported by the Companion interface to names displayed in-game # May be imported by plugins category_map = { - 'Narcotics' : 'Legal Drugs', - 'Slaves' : 'Slavery', - 'Waste ' : 'Waste', - 'NonMarketable' : False, # Don't appear in the in-game market so don't report + 'Narcotics': 'Legal Drugs', + 'Slaves': 'Slavery', + 'Waste ': 'Waste', + 'NonMarketable': False, # Don't appear in the in-game market so don't report } -commodity_map = {} +commodity_map: Dict = {} ship_map = { - 'adder' : 'Adder', - 'anaconda' : 'Anaconda', - 'asp' : 'Asp Explorer', - 'asp_scout' : 'Asp Scout', - 'belugaliner' : 'Beluga Liner', - 'cobramkiii' : 'Cobra MkIII', - 'cobramkiv' : 'Cobra MkIV', - 'clipper' : 'Panther Clipper', - 'cutter' : 'Imperial Cutter', - 'diamondback' : 'Diamondback Scout', - 'diamondbackxl' : 'Diamondback Explorer', - 'dolphin' : 'Dolphin', - 'eagle' : 'Eagle', - 'empire_courier' : 'Imperial Courier', - 'empire_eagle' : 'Imperial Eagle', - 'empire_fighter' : 'Imperial Fighter', - 'empire_trader' : 'Imperial Clipper', - 'federation_corvette' : 'Federal Corvette', - 'federation_dropship' : 'Federal Dropship', - 'federation_dropship_mkii' : 'Federal Assault Ship', - 'federation_gunship' : 'Federal Gunship', - 'federation_fighter' : 'F63 Condor', - 'ferdelance' : 'Fer-de-Lance', - 'hauler' : 'Hauler', - 'independant_trader' : 'Keelback', - 'independent_fighter' : 'Taipan Fighter', - 'krait_mkii' : 'Krait MkII', - 'krait_light' : 'Krait Phantom', - 'mamba' : 'Mamba', - 'orca' : 'Orca', - 'python' : 'Python', - 'scout' : 'Taipan Fighter', - 'sidewinder' : 'Sidewinder', - 'testbuggy' : 'Scarab', - 'type6' : 'Type-6 Transporter', - 'type7' : 'Type-7 Transporter', - 'type9' : 'Type-9 Heavy', - 'type9_military' : 'Type-10 Defender', - 'typex' : 'Alliance Chieftain', - 'typex_2' : 'Alliance Crusader', - 'typex_3' : 'Alliance Challenger', - 'viper' : 'Viper MkIII', - 'viper_mkiv' : 'Viper MkIV', - 'vulture' : 'Vulture', + 'adder': 'Adder', + 'anaconda': 'Anaconda', + 'asp': 'Asp Explorer', + 'asp_scout': 'Asp Scout', + 'belugaliner': 'Beluga Liner', + 'cobramkiii': 'Cobra MkIII', + 'cobramkiv': 'Cobra MkIV', + 'clipper': 'Panther Clipper', + 'cutter': 'Imperial Cutter', + 'diamondback': 'Diamondback Scout', + 'diamondbackxl': 'Diamondback Explorer', + 'dolphin': 'Dolphin', + 'eagle': 'Eagle', + 'empire_courier': 'Imperial Courier', + 'empire_eagle': 'Imperial Eagle', + 'empire_fighter': 'Imperial Fighter', + 'empire_trader': 'Imperial Clipper', + 'federation_corvette': 'Federal Corvette', + 'federation_dropship': 'Federal Dropship', + 'federation_dropship_mkii': 'Federal Assault Ship', + 'federation_gunship': 'Federal Gunship', + 'federation_fighter': 'F63 Condor', + 'ferdelance': 'Fer-de-Lance', + 'hauler': 'Hauler', + 'independant_trader': 'Keelback', + 'independent_fighter': 'Taipan Fighter', + 'krait_mkii': 'Krait MkII', + 'krait_light': 'Krait Phantom', + 'mamba': 'Mamba', + 'orca': 'Orca', + 'python': 'Python', + 'scout': 'Taipan Fighter', + 'sidewinder': 'Sidewinder', + 'testbuggy': 'Scarab', + 'type6': 'Type-6 Transporter', + 'type7': 'Type-7 Transporter', + 'type9': 'Type-9 Heavy', + 'type9_military': 'Type-10 Defender', + 'typex': 'Alliance Chieftain', + 'typex_2': 'Alliance Crusader', + 'typex_3': 'Alliance Challenger', + 'viper': 'Viper MkIII', + 'viper_mkiv': 'Viper MkIV', + 'vulture': 'Vulture', } -# Companion API sometimes returns an array as a json array, sometimes as a json object indexed by "int". -# This seems to depend on whether the there are 'gaps' in the Cmdr's data - i.e. whether the array is sparse. -# In practice these arrays aren't very sparse so just convert them to lists with any 'gaps' holding None. -def listify(thing): +def listify(thing: Union[List, Dict]) -> List: + """ + Convert actual JSON array or int-indexed dict into a Python list. + + Companion API sometimes returns an array as a json array, sometimes as + a json object indexed by "int". This seems to depend on whether the + there are 'gaps' in the Cmdr's data - i.e. whether the array is sparse. + In practice these arrays aren't very sparse so just convert them to + lists with any 'gaps' holding None. + """ if thing is None: return [] # data is not present @@ -119,7 +134,7 @@ def listify(thing): return list(thing) # array is not sparse elif isinstance(thing, dict): - retval = [] + retval: List[Any] = [] for k, v in thing.items(): idx = int(k) @@ -132,10 +147,12 @@ def listify(thing): return retval else: - raise ValueError("expected an array or sparse array, got {!r}".format(thing)) + raise ValueError(f"expected an array or sparse array, got {thing!r}") class ServerError(Exception): + """Exception Class for CAPI ServerErrors.""" + def __init__(self, *args): # Raised when cannot contact the Companion API server self.args = args @@ -144,23 +161,34 @@ class ServerError(Exception): class ServerLagging(Exception): + """Exception Class for CAPI Server lagging. + + Raised when Companion API server is returning old data, e.g. when the + servers are too busy. + """ + def __init__(self, *args): - # Raised when Companion API server is returning old data, e.g. when the servers are too busy self.args = args if not args: self.args = (_('Error: Frontier server is lagging'),) class SKUError(Exception): + """Exception Class for CAPI SKU error. + + Raised when the Companion API server thinks that the user has not + purchased E:D i.e. doesn't have the correct 'SKU'. + """ + def __init__(self, *args): - # Raised when the Companion API server thinks that the user has not purchased E:D - # i.e. doesn't have the correct 'SKU' self.args = args if not args: self.args = (_('Error: Frontier server SKU problem'),) class CredentialsError(Exception): + """Exception Class for CAPI Credentials error.""" + def __init__(self, *args): self.args = args if not args: @@ -168,24 +196,38 @@ class CredentialsError(Exception): class CmdrError(Exception): + """Exception Class for CAPI Commander error. + + Raised when the user has multiple accounts and the username/password + setting is not for the account they're currently playing OR the user has + reset their Cmdr and the Companion API server is still returning data + for the old Cmdr. + """ + def __init__(self, *args): - # Raised when the user has multiple accounts and the username/password setting is not for - # the account they're currently playing OR the user has reset their Cmdr and the Companion API - # server is still returning data for the old Cmdr self.args = args if not args: self.args = (_('Error: Wrong Cmdr'),) class Auth(object): - def __init__(self, cmdr): - self.cmdr = cmdr + """Handles authentication with the Frontier CAPI service via oAuth2.""" + + def __init__(self, cmdr: str): + self.cmdr: str = cmdr self.session = requests.Session() self.session.headers['User-Agent'] = USER_AGENT - self.verifier = self.state = None + self.verifier: Union[bytes, None] = None + self.state: Union[str, None] = None - def refresh(self): - # Try refresh token. Returns new refresh token if successful, otherwise makes new authorization request. + def refresh(self) -> Union[str, None]: + """ + Attempt use of Refresh Token to get a valid Access Token. + + If the Refresh Token doesn't work, make a new authorization request. + + :return: Access Token if retrieved, else None. + """ logger.debug(f'Trying for "{self.cmdr}"') self.verifier = None @@ -199,14 +241,14 @@ class Auth(object): tokens = tokens + [''] * (len(cmdrs) - len(tokens)) if tokens[idx]: logger.debug('We have a refresh token for that idx') - try: - data = { - 'grant_type': 'refresh_token', - 'client_id': CLIENT_ID, - 'refresh_token': tokens[idx], - } + data = { + 'grant_type': 'refresh_token', + 'client_id': CLIENT_ID, + 'refresh_token': tokens[idx], + } - logger.debug('Attempting refresh with Frontier...') + logger.debug('Attempting refresh with Frontier...') + try: r = self.session.post(SERVER_AUTH + URL_TOKEN, data=data, timeout=auth_timeout) if r.status_code == requests.codes.ok: data = r.json() @@ -219,7 +261,7 @@ class Auth(object): logger.error(f"Frontier CAPI Auth: Can't refresh token for \"{self.cmdr}\"") self.dump(r) - except Exception as e: + except (ValueError, requests.RequestException, ): logger.exception(f"Frontier CAPI Auth: Can't refresh token for \"{self.cmdr}\"") else: @@ -233,21 +275,19 @@ class Auth(object): self.state = self.base64_url_encode(s.to_bytes(32, byteorder='big')) # Won't work under IE: https://blogs.msdn.microsoft.com/ieinternals/2011/07/13/understanding-protocols/ logger.debug(f'Trying auth from scratch for Commander "{self.cmdr}"') + challenge = self.base64_url_encode(hashlib.sha256(self.verifier).digest()) webbrowser.open( - '{server_auth}{url_auth}?response_type=code&audience=frontier&scope=capi&client_id={client_id}&code_challenge={challenge}&code_challenge_method=S256&state={state}&redirect_uri={redirect}'.format( # noqa: E501 # I cant make this any shorter - server_auth=SERVER_AUTH, - url_auth=URL_AUTH, - client_id=CLIENT_ID, - challenge=self.base64_url_encode(hashlib.sha256(self.verifier).digest()), - state=self.state, - redirect=protocolhandler.redirect - ) + f'{SERVER_AUTH}{URL_AUTH}?response_type=code&audience=frontier&scope=capi&client_id={CLIENT_ID}&code_challenge={challenge}&code_challenge_method=S256&state={self.state}&redirect_uri={protocolhandler.redirect}' # noqa: E501 # I cant make this any shorter ) - def authorize(self, payload): + return None + + def authorize(self, payload: str) -> str: + """Handle oAuth authorization callback. + + :return: access token if successful, otherwise raises CredentialsError. + """ logger.debug('Checking oAuth authorization callback') - # Handle OAuth authorization code callback. - # Returns access token if successful, otherwise raises CredentialsError if '?' not in payload: logger.error(f'Frontier CAPI Auth: Malformed response (no "?" in payload)\n{payload}\n') raise CredentialsError('malformed payload') # Not well formed @@ -255,19 +295,20 @@ class Auth(object): data = urllib.parse.parse_qs(payload[(payload.index('?') + 1):]) if not self.state or not data.get('state') or data['state'][0] != self.state: logger.error(f'Frontier CAPI Auth: Unexpected response\n{payload}\n') - raise CredentialsError('Unexpected response from authorization {!r}'.format(payload)) # Unexpected reply + raise CredentialsError(f'Unexpected response from authorization {payload!r}') if not data.get('code'): logger.error(f'Frontier CAPI Auth: Negative response (no "code" in returned data)\n{payload}\n') error = next( - (data[k] for k in ('error_description', 'error', 'message') if k in data), ('<unknown error>',) + (data[k] for k in ('error_description', 'error', 'message') if k in data), + '<unknown error>' ) - raise CredentialsError('Error: {!r}'.format(error)[0]) + raise CredentialsError(f'Error: {error!r}') + r = None try: logger.debug('Got code, posting it back...') - r = None - data = { + request_data = { 'grant_type': 'authorization_code', 'client_id': CLIENT_ID, 'code_verifier': self.verifier, @@ -275,7 +316,7 @@ class Auth(object): 'redirect_uri': protocolhandler.redirect, } - r = self.session.post(SERVER_AUTH + URL_TOKEN, data=data, timeout=auth_timeout) + r = self.session.post(SERVER_AUTH + URL_TOKEN, data=request_data, timeout=auth_timeout) data = r.json() if r.status_code == requests.codes.ok: logger.info(f'Frontier CAPI Auth: New token for \"{self.cmdr}\"') @@ -287,7 +328,7 @@ class Auth(object): config.set('fdev_apikeys', tokens) config.save() # Save settings now for use by command-line app - return data.get('access_token') + return str(data.get('access_token')) except Exception as e: logger.exception(f"Frontier CAPI Auth: Can't get token for \"{self.cmdr}\"") @@ -298,11 +339,15 @@ class Auth(object): logger.error(f"Frontier CAPI Auth: Can't get token for \"{self.cmdr}\"") self.dump(r) - error = next((data[k] for k in ('error_description', 'error', 'message') if k in data), ('<unknown error>',)) - raise CredentialsError('Error: {!r}'.format(error)[0]) + error = next( + (data[k] for k in ('error_description', 'error', 'message') if k in data), + '<unknown error>' + ) + raise CredentialsError(f'Error: {error!r}') @staticmethod - def invalidate(cmdr): + def invalidate(cmdr: str) -> None: + """Invalidate Refresh Token for specified Commander.""" logger.info(f'Frontier CAPI Auth: Invalidated token for "{cmdr}"') cmdrs = config.get('cmdrs') idx = cmdrs.index(cmdr) @@ -312,25 +357,36 @@ class Auth(object): config.set('fdev_apikeys', tokens) config.save() # Save settings now for use by command-line app - def dump(self, r): + # noinspection PyMethodMayBeStatic + def dump(self, r: requests.Response) -> None: + """Dump details of HTTP failure from oAuth attempt.""" logger.debug(f'Frontier CAPI Auth: {r.url} {r.status_code} {r.reason if r.reason else "None"} {r.text}') - def base64_url_encode(self, text): + # noinspection PyMethodMayBeStatic + def base64_url_encode(self, text: bytes) -> str: + """Base64 encode text for URL.""" return base64.urlsafe_b64encode(text).decode().replace('=', '') class Session(object): + """Methods for handling an oAuth2 session.""" + STATE_INIT, STATE_AUTH, STATE_OK = list(range(3)) def __init__(self): self.state = Session.STATE_INIT + self.server = None self.credentials = None self.session = None self.auth = None self.retrying = False # Avoid infinite loop when successful auth / unsuccessful query - def login(self, cmdr=None, is_beta=None): - # Returns True if login succeeded, False if re-authorization initiated. + def login(self, cmdr: str = None, is_beta: Union[None, bool] = None) -> bool: + """ + Attempt oAuth2 login. + + :return: True if login succeeded, False if re-authorization initiated. + """ if not CLIENT_ID: logger.error('CLIENT_ID is None') raise CredentialsError('cannot login without a valid Client ID') @@ -376,7 +432,8 @@ class Session(object): # Wait for callback # Callback from protocol handler - def auth_callback(self): + def auth_callback(self) -> None: + """Handle callback from edmc:// handler.""" logger.debug('Handling callback from edmc:// handler') if self.state != Session.STATE_AUTH: # Shouldn't be getting a callback @@ -394,14 +451,16 @@ class Session(object): self.auth = None raise # Bad thing happened - def start(self, access_token): + def start(self, access_token: str) -> None: + """Start an oAuth2 session.""" logger.debug('Starting session') self.session = requests.Session() - self.session.headers['Authorization'] = 'Bearer {}'.format(access_token) + self.session.headers['Authorization'] = f'Bearer {access_token}' self.session.headers['User-Agent'] = USER_AGENT self.state = Session.STATE_OK - def query(self, endpoint): + def query(self, endpoint: str) -> CAPIData: + """Perform a query against the specified CAPI endpoint.""" logger.debug(f'Performing query for endpoint "{endpoint}"') if self.state == Session.STATE_INIT: if self.login(): @@ -432,7 +491,7 @@ class Session(object): # Server error. Typically 500 "Internal Server Error" if server is down logger.debug('500 status back from CAPI') self.dump(r) - raise ServerError('Received error {} from server'.format(r.status_code)) + raise ServerError(f'Received error {r.status_code} from server') try: r.raise_for_status() # Typically 403 "Forbidden" on token expiry @@ -463,40 +522,45 @@ class Session(object): self.retrying = False if 'timestamp' not in data: logger.debug('timestamp not in data, adding from response headers') - data['timestamp'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', parsedate(r.headers['Date'])) + data['timestamp'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', parsedate(r.headers['Date'])) # type: ignore return data - def profile(self): + def profile(self) -> CAPIData: + """Perform general CAPI /profile endpoint query.""" return self.query(URL_QUERY) - def station(self): + def station(self) -> CAPIData: + """Perform CAPI /profile endpoint query for station data.""" data = self.query(URL_QUERY) - if data['commander'].get('docked'): - services = data['lastStarport'].get('services', {}) + if not data['commander'].get('docked'): + return data - last_starport_name = data['lastStarport']['name'] - last_starport_id = int(data['lastStarport']['id']) + services = data['lastStarport'].get('services', {}) - if services.get('commodities'): - marketdata = self.query(URL_MARKET) - if (last_starport_name != marketdata['name'] or last_starport_id != int(marketdata['id'])): - raise ServerLagging() + last_starport_name = data['lastStarport']['name'] + last_starport_id = int(data['lastStarport']['id']) - else: - data['lastStarport'].update(marketdata) + if services.get('commodities'): + marketdata = self.query(URL_MARKET) + if last_starport_name != marketdata['name'] or last_starport_id != int(marketdata['id']): + raise ServerLagging() - if services.get('outfitting') or services.get('shipyard'): - shipdata = self.query(URL_SHIPYARD) - if (last_starport_name != shipdata['name'] or last_starport_id != int(shipdata['id'])): - raise ServerLagging() + else: + data['lastStarport'].update(marketdata) - else: - data['lastStarport'].update(shipdata) + if services.get('outfitting') or services.get('shipyard'): + shipdata = self.query(URL_SHIPYARD) + if last_starport_name != shipdata['name'] or last_starport_id != int(shipdata['id']): + raise ServerLagging() + + else: + data['lastStarport'].update(shipdata) return data - def close(self): + def close(self) -> None: + """Close CAPI authorization session.""" self.state = Session.STATE_INIT if self.session: try: @@ -507,18 +571,26 @@ class Session(object): self.session = None - def invalidate(self): + def invalidate(self) -> None: + """Invalidate oAuth2 credentials.""" logger.debug('Forcing a full re-authentication') # Force a full re-authentication self.close() Auth.invalidate(self.credentials['cmdr']) - def dump(self, r): + # noinspection PyMethodMayBeStatic + def dump(self, r: requests.Response) -> None: + """Log, as error, status of requests.Response from CAPI request.""" logger.error(f'Frontier CAPI Auth: {r.url} {r.status_code} {r.reason and r.reason or "None"} {r.text}') -# Returns a shallow copy of the received data suitable for export to older tools -# English commodity names and anomalies fixed up -def fixup(data): + +def fixup(data: CAPIData) -> CAPIData: # noqa: C901, CCR001 # Can't be usefully simplified + """ + Fix up commodity names to English & miscellaneous anomalies fixes. + + :return: a shallow copy of the received data suitable for export to + older tools. + """ if not commodity_map: # Lazily populate for f in ('commodity.csv', 'rare_commodity.csv'): @@ -586,15 +658,16 @@ def fixup(data): datacopy = data.copy() datacopy['lastStarport'] = data['lastStarport'].copy() datacopy['lastStarport']['commodities'] = commodities - return datacopy + return CAPIData(datacopy) -# Return a subset of the received data describing the current ship -def ship(data): - def filter_ship(d): - filtered = {} +def ship(data: CAPIData) -> CAPIData: + """Construct a subset of the received data describing the current ship.""" + def filter_ship(d: CAPIData) -> CAPIData: + """Filter provided ship data.""" + filtered: CAPIData = CAPIData({}) for k, v in d.items(): - if v == []: + if not v: pass # just skip empty fields for brevity elif k in ('alive', 'cargo', 'cockpitBreached', 'health', 'oxygenRemaining', @@ -619,8 +692,8 @@ def ship(data): return filter_ship(data['ship']) -# Ship name suitable for writing to a file -def ship_file_name(ship_name, ship_type): +def ship_file_name(ship_name: str, ship_type: str) -> str: + """Return a ship name suitable for a filename.""" name = str(ship_name or ship_map.get(ship_type.lower(), ship_type)).strip() if name.endswith('.'): name = name[:-1] diff --git a/config.py b/config.py index 61ec5a1a..2fb400e3 100644 --- a/config.py +++ b/config.py @@ -13,7 +13,7 @@ appcmdname = 'EDMC' # appversion **MUST** follow Semantic Versioning rules: # <https://semver.org/#semantic-versioning-specification-semver> # Major.Minor.Patch(-prerelease)(+buildmetadata) -appversion = '4.1.0-beta4' #-rc1+a872b5f' +appversion = '4.1.0-beta6' #-rc1+a872b5f' # For some things we want appversion without (possible) +build metadata appversion_nobuild = str(semantic_version.Version(appversion).truncate('prerelease')) copyright = u'© 2015-2019 Jonathan Harris, 2020 EDCD' diff --git a/docs/examples/click_counter/load.py b/docs/examples/click_counter/load.py new file mode 100644 index 00000000..02403f2f --- /dev/null +++ b/docs/examples/click_counter/load.py @@ -0,0 +1,126 @@ +""" +Example EDMC plugin. + +It adds a single button to the EDMC interface that displays the number of times it has been clicked. +""" + +import logging +import tkinter as tk +from typing import Optional + +import myNotebook as nb +from config import appname, config + +PLUGIN_NAME = "ClickCounter" + +logger = logging.getLogger(f"{appname}.{PLUGIN_NAME}") + + +class ClickCounter: + """ + ClickCounter implements the EDMC plugin interface. + + It adds a button to the EDMC UI that displays the number of times it has been clicked, and a preference to set + the number directly. + """ + + def __init__(self) -> None: + # Be sure to use names that wont collide in our config variables + self.click_count: Optional[tk.StringVar] = tk.StringVar(value=str(config.getint('click_counter_count'))) + logger.info("ClickCounter instantiated") + + def on_load(self) -> str: + """ + on_load is called by plugin_start3 below. + + It is the first point EDMC interacts with our code after loading our module. + + :return: The name of the plugin, which will be used by EDMC for logging and for the settings window + """ + return PLUGIN_NAME + + def on_unload(self) -> None: + """ + on_unload is called by plugin_stop below. + + It is the last thing called before EDMC shuts down. Note that blocking code here will hold the shutdown process. + """ + self.on_preferences_closed("", False) # Save our prefs + + def setup_preferences(self, parent: nb.Notebook, cmdr: str, is_beta: bool) -> Optional[tk.Frame]: + """ + setup_preferences is called by plugin_prefs below. + + It is where we can setup our own settings page in EDMC's settings window. Our tab is defined for us. + + :param parent: the tkinter parent that our returned Frame will want to inherit from + :param cmdr: The current ED Commander + :param is_beta: Whether or not EDMC is currently marked as in beta mode + :return: The frame to add to the settings window + """ + current_row = 0 + frame = nb.Frame(parent) + + # setup our config in a "Click Count: number" + nb.Label(frame, text='Click Count').grid(row=current_row) + nb.Entry(frame, textvariable=self.click_count).grid(row=current_row, column=1) + current_row += 1 # Always increment our row counter, makes for far easier tkinter design. + return frame + + def on_preferences_closed(self, cmdr: str, is_beta: bool) -> None: + """ + on_preferences_closed is called by prefs_changed below. + + It is called when the preferences dialog is dismissed by the user. + + :param cmdr: The current ED Commander + :param is_beta: Whether or not EDMC is currently marked as in beta mode + """ + config.set('click_counter_count', self.click_count.get()) + + def setup_main_ui(self, parent: tk.Frame) -> tk.Frame: + """ + Create our entry on the main EDMC UI. + + This is called by plugin_app below. + + :param parent: EDMC main window Tk + :return: Our frame + """ + current_row = 0 + frame = tk.Frame(parent) + button = tk.Button( + frame, + text="Count me", + command=lambda: self.click_count.set(str(int(self.click_count.get()) + 1)) + ) + button.grid(row=current_row) + current_row += 1 + nb.Label(frame, text="Count:").grid(row=current_row, sticky=tk.W) + nb.Label(frame, textvariable=self.click_count).grid(row=current_row, column=1) + return frame + + +cc = ClickCounter() + + +# Note that all of these could be simply replaced with something like: +# plugin_start3 = cc.on_load +def plugin_start3(plugin_dir: str) -> str: + return cc.on_load() + + +def plugin_stop() -> None: + return cc.on_unload() + + +def plugin_prefs(parent: nb.Notebook, cmdr: str, is_beta: bool) -> Optional[tk.Frame]: + return cc.setup_preferences(parent, cmdr, is_beta) + + +def prefs_changed(cmdr: str, is_beta: bool) -> None: + return cc.on_preferences_closed(cmdr, is_beta) + + +def plugin_app(parent: tk.Frame) -> Optional[tk.Frame]: + return cc.setup_main_ui(parent) diff --git a/docs/examples/plugintest/SubA/__init__.py b/docs/examples/plugintest/SubA/__init__.py new file mode 100644 index 00000000..1630863a --- /dev/null +++ b/docs/examples/plugintest/SubA/__init__.py @@ -0,0 +1,18 @@ +"""Test class logging.""" + +import logging + + +class SubA: + """Simple class to test logging.""" + + def __init__(self, logger: logging.Logger): + self.logger = logger + + def ping(self) -> None: + """ + Log a ping to demonstrate correct logging. + + :return: + """ + self.logger.info('ping!') diff --git a/docs/examples/plugintest/load.py b/docs/examples/plugintest/load.py new file mode 100644 index 00000000..4a614aa2 --- /dev/null +++ b/docs/examples/plugintest/load.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python +# vim: textwidth=0 wrapmargin=0 tabstop=4 shiftwidth=4 softtabstop=4 smartindent smarttab +"""Plugin that tests that modules we bundle for plugins are present and working.""" + +import logging +import os +import shutil +import sqlite3 +import sys +import zipfile + +from SubA import SubA + +from config import appname + +# This could also be returned from plugin_start3() +plugin_name = os.path.basename(os.path.dirname(__file__)) + +# Logger per found plugin, so the folder name is included in +# the logging format. +logger = logging.getLogger(f'{appname}.{plugin_name}') +if not logger.hasHandlers(): + level = logging.INFO # So logger.info(...) is equivalent to print() + + logger.setLevel(level) + logger_channel = logging.StreamHandler() + logger_channel.setLevel(level) + logger_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(module)s:%(lineno)d:%(funcName)s: %(message)s') # noqa: E501 + logger_formatter.default_time_format = '%Y-%m-%d %H:%M:%S' + logger_formatter.default_msec_format = '%s.%03d' + logger_channel.setFormatter(logger_formatter) + logger.addHandler(logger_channel) + + +this = sys.modules[__name__] # For holding module globals +this.DBFILE = 'plugintest.db' +this.mt = None + + +class PluginTest(object): + """Class that performs actual tests on bundled modules.""" + + def __init__(self, directory: str): + logger.debug(f'directory = "{directory}') + dbfile = os.path.join(directory, this.DBFILE) + + # Test 'import zipfile' + with zipfile.ZipFile(dbfile + '.zip', 'w') as zip: + if os.path.exists(dbfile): + zip.write(dbfile) + zip.close() + + # Testing 'import shutil' + if os.path.exists(dbfile): + shutil.copyfile(dbfile, dbfile + '.bak') + + # Testing 'import sqlite3' + self.sqlconn = sqlite3.connect(dbfile) + self.sqlc = self.sqlconn.cursor() + try: + self.sqlc.execute('CREATE TABLE entries (timestamp TEXT, cmdrname TEXT, system TEXT, station TEXT, eventtype TEXT)') # noqa: E501 + except sqlite3.OperationalError: + logger.exception('sqlite3.OperationalError when CREATE TABLE entries:') + + def store(self, timestamp: str, cmdrname: str, system: str, station: str, event: str) -> None: + """ + Store the provided data in sqlite database. + + :param timestamp: + :param cmdrname: + :param system: + :param station: + :param event: + :return: None + """ + logger.debug(f'timestamp = "{timestamp}", cmdr = "{cmdrname}", system = "{system}", station = "{station}", event = "{event}"') # noqa: E501 + self.sqlc.execute('INSERT INTO entries VALUES(?, ?, ?, ?, ?)', (timestamp, cmdrname, system, station, event)) + self.sqlconn.commit() + return None + + +def plugin_start3(plugin_dir: str) -> str: + """ + Plugin startup method. + + :param plugin_dir: + :return: 'Pretty' name of this plugin. + """ + logger.info(f'Folder is {plugin_dir}') + this.mt = PluginTest(plugin_dir) + + this.suba = SubA(logger) + + this.suba.ping() + + return plugin_name + + +def plugin_stop() -> None: + """ + Plugin stop method. + + :return: + """ + logger.info('Stopping') + + +def journal_entry(cmdrname: str, is_beta: bool, system: str, station: str, entry: dict, state: dict) -> None: + """ + Handle the given journal entry. + + :param cmdrname: + :param is_beta: + :param system: + :param station: + :param entry: + :param state: + :return: None + """ + logger.debug(f'cmdr = "{cmdrname}", is_beta = "{is_beta}", system = "{system}", station = "{station}"') + this.mt.store(entry['timestamp'], cmdrname, system, station, entry['event']) diff --git a/plug.py b/plug.py index 51d73531..f3df95eb 100644 --- a/plug.py +++ b/plug.py @@ -9,15 +9,15 @@ import sys import operator import threading # noqa: F401 - We don't use it, but plugins might from typing import Optional -import logging import tkinter as tk import myNotebook as nb # noqa: N813 -from config import config, appname +from config import appcmdname, appname, config +from EDMCLogging import get_main_logger import logging -logger = logging.getLogger(appname) +logger = get_main_logger() # Dashboard Flags constants FlagsDocked = 1 << 0 # on a landing pad @@ -203,8 +203,8 @@ def load_plugins(master): # Create a logger for this 'found' plugin. Must be before the # load.py is loaded. import EDMCLogging - plugin_logger = EDMCLogging.get_plugin_logger(f'{appname}.{name}') + plugin_logger = EDMCLogging.get_plugin_logger(name) found.append(Plugin(name, os.path.join(config.plugin_dir, name, 'load.py'), plugin_logger)) except Exception as e: logger.exception(f'Failure loading found Plugin "{name}"') diff --git a/plugins/eddn.py b/plugins/eddn.py index e03cb3cb..5e7f27fe 100644 --- a/plugins/eddn.py +++ b/plugins/eddn.py @@ -2,7 +2,6 @@ import itertools import json -import logging import pathlib import re import sys @@ -11,15 +10,16 @@ from collections import OrderedDict from os import SEEK_SET from os.path import join from platform import system -from typing import TYPE_CHECKING, Any, AnyStr, Dict, Iterator, List, Mapping, MutableMapping, Optional +from typing import TYPE_CHECKING, Any, AnyStr, Dict, Iterator, List, Mapping, MutableMapping, Optional, Tuple from typing import OrderedDict as OrderedDictT -from typing import Sequence, TextIO, Tuple +from typing import Sequence, TextIO import requests import myNotebook as nb # noqa: N813 from companion import category_map -from config import applongname, appname, appversion, config +from config import applongname, appversion, config +from EDMCLogging import get_main_logger from myNotebook import Frame from prefs import prefsVersion from ttkHyperlinkLabel import HyperlinkLabel @@ -32,7 +32,7 @@ if TYPE_CHECKING: def _(x: str) -> str: return x -logger = logging.getLogger(appname) +logger = get_main_logger() this: Any = sys.modules[__name__] # For holding module globals @@ -262,6 +262,32 @@ Msg:\n{msg}''') this.commodities = commodities + def safe_modules_and_ships(self, data: Mapping[str, Any]) -> Tuple[Dict, Dict]: + modules: Dict[str, Any] = data['lastStarport'].get('modules') + if modules is None or not isinstance(modules, dict): + if modules is None: + logger.debug('modules was None. FC or Damaged Station?') + elif isinstance(modules, list): + if len(modules) == 0: + logger.debug('modules is empty list. Damaged Station?') + else: + logger.error(f'modules is non-empty list: {modules!r}') + else: + logger.error(f'modules was not None, a list, or a dict! type = {type(modules)}') + # Set a safe value + modules = {} + + ships: Dict[str, Any] = data['lastStarport'].get('ships') + if ships is None or not isinstance(ships, dict): + if ships is None: + logger.debug('ships was None') + else: + logger.error(f'ships was neither None nor a Dict! Type = {type(ships)}') + # Set a safe value + ships = {'shipyard_list': {}, 'unavailable_list': []} + + return modules, ships + def export_outfitting(self, data: Mapping[str, Any], is_beta: bool) -> None: """ export_outfitting updates EDDN with the current (lastStarport) station's outfitting options, if any. @@ -270,15 +296,7 @@ Msg:\n{msg}''') :param data: dict containing the outfitting data :param is_beta: whether or not we're currently in beta mode """ - modules: Dict[str, Any] = data['lastStarport'].get('modules') - if modules is None: - logger.debug('modules was None') - modules = {} - - ships: Dict[str, Any] = data['lastStarport'].get('ships') - if ships is None: - logger.debug('ships was None') - ships = {'shipyard_list': {}, 'unavailable_list': []} + modules, ships = self.safe_modules_and_ships(data) # Horizons flag - will hit at least Int_PlanetApproachSuite other than at engineer bases ("Colony"), # prison or rescue Megaships, or under Pirate Attack etc @@ -321,15 +339,7 @@ Msg:\n{msg}''') :param data: dict containing the shipyard data :param is_beta: whether or not we are in beta mode """ - modules: Dict[str, Any] = data['lastStarport'].get('modules') - if modules is None: - logger.debug('modules was None') - modules = {} - - ships: Dict[str, Any] = data['lastStarport'].get('ships') - if ships is None: - logger.debug('ships was None') - ships = {'shipyard_list': {}, 'unavailable_list': []} + modules, ships = self.safe_modules_and_ships(data) horizons: bool = is_horizons( data['lastStarport'].get('economies', {}), diff --git a/plugins/edsm.py b/plugins/edsm.py index 806536cb..b57575a3 100644 --- a/plugins/edsm.py +++ b/plugins/edsm.py @@ -1,6 +1,4 @@ -# -# System display and EDSM lookup -# +"""System display and EDSM lookup.""" # TODO: # 1) Re-factor EDSM API calls out of journal_entry() into own function. @@ -12,51 +10,80 @@ # text is always fired. i.e. CAPI cmdr_data() processing. import json -import requests import sys +import tkinter as tk from queue import Queue from threading import Thread -import logging +from typing import TYPE_CHECKING, Any, List, Mapping, MutableMapping, Optional, Tuple + +import requests -import tkinter as tk -from ttkHyperlinkLabel import HyperlinkLabel import myNotebook as nb # noqa: N813 - -from config import appname, applongname, appversion, config import plug +from config import applongname, appversion, config +from EDMCLogging import get_main_logger +from ttkHyperlinkLabel import HyperlinkLabel -logger = logging.getLogger(appname) +if TYPE_CHECKING: + def _(x: str) -> str: + return x + +logger = get_main_logger() EDSM_POLL = 0.1 _TIMEOUT = 20 -this = sys.modules[__name__] # For holding module globals -this.session = requests.Session() -this.queue = Queue() # Items to be sent to EDSM by worker thread -this.discardedEvents = [] # List discarded events from EDSM -this.lastlookup = False # whether the last lookup succeeded +this: Any = sys.modules[__name__] # For holding module globals +this.session: requests.Session = requests.Session() +this.queue: Queue = Queue() # Items to be sent to EDSM by worker thread +this.discardedEvents: List[str] = [] # List discarded events from EDSM +this.lastlookup: bool = False # whether the last lookup succeeded # Game state -this.multicrew = False # don't send captain's ship info to EDSM while on a crew -this.coordinates = None -this.newgame = False # starting up - batch initial burst of events -this.newgame_docked = False # starting up while docked -this.navbeaconscan = 0 # batch up burst of Scan events after NavBeaconScan -this.system_link = None -this.system = None -this.system_address = None # Frontier SystemAddress -this.system_population = None -this.station_link = None -this.station = None -this.station_marketid = None # Frontier MarketID +this.multicrew: bool = False # don't send captain's ship info to EDSM while on a crew +this.coordinates: Optional[Tuple[int, int, int]] = None +this.newgame: bool = False # starting up - batch initial burst of events +this.newgame_docked: bool = False # starting up while docked +this.navbeaconscan: int = 0 # batch up burst of Scan events after NavBeaconScan +this.system_link: tk.Tk = None +this.system: tk.Tk = None +this.system_address: Optional[int] = None # Frontier SystemAddress +this.system_population: Optional[int] = None +this.station_link: tk.Tk = None +this.station: Optional[str] = None +this.station_marketid: Optional[int] = None # Frontier MarketID STATION_UNDOCKED: str = '×' # "Station" name to display when not docked = U+00D7 +__cleanup = str.maketrans({' ': None, '\n': None}) +IMG_KNOWN_B64 = """ +R0lGODlhEAAQAMIEAFWjVVWkVWS/ZGfFZ////////////////yH5BAEKAAQALAAAAAAQABAAAAMvSLrc/lAFIUIkYOgNXt5g14Dk0AQlaC1CuglM6w7wgs7r +MpvNV4q932VSuRiPjQQAOw== +""".translate(__cleanup) +IMG_UNKNOWN_B64 = """ +R0lGODlhEAAQAKEDAGVLJ+ddWO5fW////yH5BAEKAAMALAAAAAAQABAAAAItnI+pywYRQBtA2CtVvTwjDgrJFlreEJRXgKSqwB5keQ6vOKq1E+7IE5kIh4kC +ADs= +""".translate(__cleanup) +IMG_NEW_B64 = """ +R0lGODlhEAAQAMZwANKVHtWcIteiHuiqLPCuHOS1MN22ZeW7ROG6Zuu9MOy+K/i8Kf/DAuvCVf/FAP3BNf/JCf/KAPHHSv7ESObHdv/MBv/GRv/LGP/QBPXO +PvjPQfjQSvbRSP/UGPLSae7Sfv/YNvLXgPbZhP7dU//iI//mAP/jH//kFv7fU//fV//ebv/iTf/iUv/kTf/iZ/vgiP/hc/vgjv/jbfriiPriiv7ka//if//j +d//sJP/oT//tHv/mZv/sLf/rRP/oYv/rUv/paP/mhv/sS//oc//lkf/mif/sUf/uPv/qcv/uTv/uUv/vUP/qhP/xP//pm//ua//sf//ubf/wXv/thv/tif/s +lv/tjf/smf/yYP/ulf/2R//2Sv/xkP/2av/0gP/ylf/2df/0i//0j//0lP/5cP/7a//1p//5gf/7ev/3o//2sf/5mP/6kv/2vP/3y//+jP////////////// +/////////////////////////////////////////////////yH5BAEKAH8ALAAAAAAQABAAAAePgH+Cg4SFhoJKPIeHYT+LhVppUTiPg2hrUkKPXWdlb2xH +Jk9jXoNJQDk9TVtkYCUkOy4wNjdGfy1UXGJYOksnPiwgFwwYg0NubWpmX1ArHREOFYUyWVNIVkxXQSoQhyMoNVUpRU5EixkcMzQaGy8xhwsKHiEfBQkSIg+G +BAcUCIIBBDSYYGiAAUMALFR6FAgAOw== +""".translate(__cleanup) + +IMG_ERR_B64 = """ +R0lGODlhEAAQAKEBAAAAAP///////////yH5BAEKAAIALAAAAAAQABAAAAIwlBWpeR0AIwwNPRmZuVNJinyWuClhBlZjpm5fqnIAHJPtOd3Hou9mL6NVgj2L +plEAADs= +""".translate(__cleanup) # Main window clicks -def system_url(system_name): +def system_url(system_name: str) -> str: + """Get a URL for the current system.""" if this.system_address: return requests.utils.requote_uri(f'https://www.edsm.net/en/system?systemID64={this.system_address}') @@ -65,140 +92,224 @@ def system_url(system_name): return '' -def station_url(system_name, station_name): + +def station_url(system_name: str, station_name: str) -> str: + """Get a URL for the current station.""" if system_name and station_name: - return requests.utils.requote_uri(f'https://www.edsm.net/en/system?systemName={system_name}&stationName={station_name}') + return requests.utils.requote_uri( + f'https://www.edsm.net/en/system?systemName={system_name}&stationName={station_name}' + ) # monitor state might think these are gone, but we don't yet if this.system and this.station: - return requests.utils.requote_uri(f'https://www.edsm.net/en/system?systemName={this.system}&stationName={this.station}') + return requests.utils.requote_uri( + f'https://www.edsm.net/en/system?systemName={this.system}&stationName={this.station}' + ) if system_name: - return requests.utils.requote_uri(f'https://www.edsm.net/en/system?systemName={system_name}&stationName=ALL') + return requests.utils.requote_uri( + f'https://www.edsm.net/en/system?systemName={system_name}&stationName=ALL' + ) return '' -def plugin_start3(plugin_dir): + +def plugin_start3(plugin_dir: str) -> str: + """Plugin setup hook.""" # Can't be earlier since can only call PhotoImage after window is created - this._IMG_KNOWN = tk.PhotoImage(data = 'R0lGODlhEAAQAMIEAFWjVVWkVWS/ZGfFZ////////////////yH5BAEKAAQALAAAAAAQABAAAAMvSLrc/lAFIUIkYOgNXt5g14Dk0AQlaC1CuglM6w7wgs7rMpvNV4q932VSuRiPjQQAOw==') # green circle - this._IMG_UNKNOWN = tk.PhotoImage(data = 'R0lGODlhEAAQAKEDAGVLJ+ddWO5fW////yH5BAEKAAMALAAAAAAQABAAAAItnI+pywYRQBtA2CtVvTwjDgrJFlreEJRXgKSqwB5keQ6vOKq1E+7IE5kIh4kCADs=') # red circle - this._IMG_NEW = tk.PhotoImage(data = 'R0lGODlhEAAQAMZwANKVHtWcIteiHuiqLPCuHOS1MN22ZeW7ROG6Zuu9MOy+K/i8Kf/DAuvCVf/FAP3BNf/JCf/KAPHHSv7ESObHdv/MBv/GRv/LGP/QBPXOPvjPQfjQSvbRSP/UGPLSae7Sfv/YNvLXgPbZhP7dU//iI//mAP/jH//kFv7fU//fV//ebv/iTf/iUv/kTf/iZ/vgiP/hc/vgjv/jbfriiPriiv7ka//if//jd//sJP/oT//tHv/mZv/sLf/rRP/oYv/rUv/paP/mhv/sS//oc//lkf/mif/sUf/uPv/qcv/uTv/uUv/vUP/qhP/xP//pm//ua//sf//ubf/wXv/thv/tif/slv/tjf/smf/yYP/ulf/2R//2Sv/xkP/2av/0gP/ylf/2df/0i//0j//0lP/5cP/7a//1p//5gf/7ev/3o//2sf/5mP/6kv/2vP/3y//+jP///////////////////////////////////////////////////////////////yH5BAEKAH8ALAAAAAAQABAAAAePgH+Cg4SFhoJKPIeHYT+LhVppUTiPg2hrUkKPXWdlb2xHJk9jXoNJQDk9TVtkYCUkOy4wNjdGfy1UXGJYOksnPiwgFwwYg0NubWpmX1ArHREOFYUyWVNIVkxXQSoQhyMoNVUpRU5EixkcMzQaGy8xhwsKHiEfBQkSIg+GBAcUCIIBBDSYYGiAAUMALFR6FAgAOw==') - this._IMG_ERROR = tk.PhotoImage(data = 'R0lGODlhEAAQAKEBAAAAAP///////////yH5BAEKAAIALAAAAAAQABAAAAIwlBWpeR0AIwwNPRmZuVNJinyWuClhBlZjpm5fqnIAHJPtOd3Hou9mL6NVgj2LplEAADs=') # BBC Mode 5 '?' + this._IMG_KNOWN = tk.PhotoImage(data=IMG_KNOWN_B64) # green circle + this._IMG_UNKNOWN = tk.PhotoImage(data=IMG_UNKNOWN_B64) # red circle + this._IMG_NEW = tk.PhotoImage(data=IMG_NEW_B64) + this._IMG_ERROR = tk.PhotoImage(data=IMG_ERR_B64) # BBC Mode 5 '?' # Migrate old settings if not config.get('edsm_cmdrs'): if isinstance(config.get('cmdrs'), list) and config.get('edsm_usernames') and config.get('edsm_apikeys'): # Migrate <= 2.34 settings config.set('edsm_cmdrs', config.get('cmdrs')) + elif config.get('edsm_cmdrname'): # Migrate <= 2.25 settings. edsm_cmdrs is unknown at this time config.set('edsm_usernames', [config.get('edsm_cmdrname') or '']) config.set('edsm_apikeys', [config.get('edsm_apikey') or '']) + config.delete('edsm_cmdrname') config.delete('edsm_apikey') + if config.getint('output') & 256: # Migrate <= 2.34 setting config.set('edsm_out', 1) + config.delete('edsm_autoopen') config.delete('edsm_historical') - this.thread = Thread(target = worker, name = 'EDSM worker') + this.thread = Thread(target=worker, name='EDSM worker') this.thread.daemon = True this.thread.start() return 'EDSM' -def plugin_app(parent): + +def plugin_app(parent: tk.Tk) -> None: + """Plugin UI setup.""" this.system_link = parent.children['system'] # system label in main window this.system_link.bind_all('<<EDSMStatus>>', update_status) this.station_link = parent.children['station'] # station label in main window -def plugin_stop(): + +def plugin_stop() -> None: + """Plugin exit hook.""" # Signal thread to close and wait for it this.queue.put(None) this.thread.join() this.thread = None - # Suppress 'Exception ignored in: <function Image.__del__ at ...>' errors + # Suppress 'Exception ignored in: <function Image.__del__ at ...>' errors # TODO: this is bad. this._IMG_KNOWN = this._IMG_UNKNOWN = this._IMG_NEW = this._IMG_ERROR = None -def plugin_prefs(parent, cmdr, is_beta): - PADX = 10 - BUTTONX = 12 # indent Checkbuttons and Radiobuttons - PADY = 2 # close spacing +def plugin_prefs(parent: tk.Tk, cmdr: str, is_beta: bool) -> tk.Frame: + """Plugin preferences setup hook.""" + PADX = 10 # noqa: N806 + BUTTONX = 12 # indent Checkbuttons and Radiobuttons # noqa: N806 + PADY = 2 # close spacing # noqa: N806 frame = nb.Frame(parent) frame.columnconfigure(1, weight=1) - HyperlinkLabel(frame, text='Elite Dangerous Star Map', background=nb.Label().cget('background'), url='https://www.edsm.net/', underline=True).grid(columnspan=2, padx=PADX, sticky=tk.W) # Don't translate - this.log = tk.IntVar(value = config.getint('edsm_out') and 1) - this.log_button = nb.Checkbutton(frame, text=_('Send flight log and Cmdr status to EDSM'), variable=this.log, command=prefsvarchanged) - this.log_button.grid(columnspan=2, padx=BUTTONX, pady=(5,0), sticky=tk.W) + HyperlinkLabel( + frame, + text='Elite Dangerous Star Map', + background=nb.Label().cget('background'), + url='https://www.edsm.net/', + underline=True + ).grid(columnspan=2, padx=PADX, sticky=tk.W) # Don't translate + + this.log = tk.IntVar(value=config.getint('edsm_out') and 1) + this.log_button = nb.Checkbutton( + frame, text=_('Send flight log and Cmdr status to EDSM'), variable=this.log, command=prefsvarchanged + ) + + this.log_button.grid(columnspan=2, padx=BUTTONX, pady=(5, 0), sticky=tk.W) + + nb.Label(frame).grid(sticky=tk.W) # big spacer + # Section heading in settings + this.label = HyperlinkLabel( + frame, + text=_('Elite Dangerous Star Map credentials'), + background=nb.Label().cget('background'), + url='https://www.edsm.net/settings/api', + underline=True + ) + + cur_row = 10 - nb.Label(frame).grid(sticky=tk.W) # big spacer - this.label = HyperlinkLabel(frame, text=_('Elite Dangerous Star Map credentials'), background=nb.Label().cget('background'), url='https://www.edsm.net/settings/api', underline=True) # Section heading in settings this.label.grid(columnspan=2, padx=PADX, sticky=tk.W) - this.cmdr_label = nb.Label(frame, text=_('Cmdr')) # Main window - this.cmdr_label.grid(row=10, padx=PADX, sticky=tk.W) + this.cmdr_label = nb.Label(frame, text=_('Cmdr')) # Main window + this.cmdr_label.grid(row=cur_row, padx=PADX, sticky=tk.W) this.cmdr_text = nb.Label(frame) - this.cmdr_text.grid(row=10, column=1, padx=PADX, pady=PADY, sticky=tk.W) + this.cmdr_text.grid(row=cur_row, column=1, padx=PADX, pady=PADY, sticky=tk.W) - this.user_label = nb.Label(frame, text=_('Commander Name')) # EDSM setting - this.user_label.grid(row=11, padx=PADX, sticky=tk.W) + cur_row += 1 + + this.user_label = nb.Label(frame, text=_('Commander Name')) # EDSM setting + this.user_label.grid(row=cur_row, padx=PADX, sticky=tk.W) this.user = nb.Entry(frame) - this.user.grid(row=11, column=1, padx=PADX, pady=PADY, sticky=tk.EW) + this.user.grid(row=cur_row, column=1, padx=PADX, pady=PADY, sticky=tk.EW) - this.apikey_label = nb.Label(frame, text=_('API Key')) # EDSM setting - this.apikey_label.grid(row=12, padx=PADX, sticky=tk.W) + cur_row += 1 + + this.apikey_label = nb.Label(frame, text=_('API Key')) # EDSM setting + this.apikey_label.grid(row=cur_row, padx=PADX, sticky=tk.W) this.apikey = nb.Entry(frame) - this.apikey.grid(row=12, column=1, padx=PADX, pady=PADY, sticky=tk.EW) + this.apikey.grid(row=cur_row, column=1, padx=PADX, pady=PADY, sticky=tk.EW) prefs_cmdr_changed(cmdr, is_beta) return frame -def prefs_cmdr_changed(cmdr, is_beta): - this.log_button['state'] = cmdr and not is_beta and tk.NORMAL or tk.DISABLED + +def prefs_cmdr_changed(cmdr: str, is_beta: bool) -> None: + """Commanders changed hook.""" + this.log_button['state'] = tk.NORMAL if cmdr and not is_beta else tk.DISABLED this.user['state'] = tk.NORMAL this.user.delete(0, tk.END) this.apikey['state'] = tk.NORMAL this.apikey.delete(0, tk.END) if cmdr: - this.cmdr_text['text'] = cmdr + (is_beta and ' [Beta]' or '') + this.cmdr_text['text'] = f'{cmdr}{" [Beta]" if is_beta else ""}' cred = credentials(cmdr) + if cred: this.user.insert(0, cred[0]) this.apikey.insert(0, cred[1]) + else: this.cmdr_text['text'] = _('None') # No hotkey/shortcut currently defined - this.label['state'] = this.cmdr_label['state'] = this.cmdr_text['state'] = this.user_label['state'] = this.user['state'] = this.apikey_label['state'] = this.apikey['state'] = cmdr and not is_beta and this.log.get() and tk.NORMAL or tk.DISABLED -def prefsvarchanged(): - this.label['state'] = this.cmdr_label['state'] = this.cmdr_text['state'] = this.user_label['state'] = this.user['state'] = this.apikey_label['state'] = this.apikey['state'] = this.log.get() and this.log_button['state'] or tk.DISABLED + to_set = tk.DISABLED + if cmdr and not is_beta and this.log.get(): + to_set = tk.NORMAL -def prefs_changed(cmdr, is_beta): + set_prefs_ui_states(to_set) + + +def prefsvarchanged() -> None: + """Preferences screen closed hook.""" + to_set = tk.DISABLED + if this.log.get(): + to_set = this.log_button['state'] + + set_prefs_ui_states(to_set) + + +def set_prefs_ui_states(state: str) -> None: + """ + Set the state of various config UI entries. + + :param state: the state to set each entry to + """ + this.label['state'] = state + this.cmdr_label['state'] = state + this.cmdr_text['state'] = state + this.user_label['state'] = state + this.user['state'] = state + this.apikey_label['state'] = state + this.apikey['state'] = state + + +def prefs_changed(cmdr: str, is_beta: bool) -> None: + """Preferences changed hook.""" config.set('edsm_out', this.log.get()) if cmdr and not is_beta: - cmdrs = config.get('edsm_cmdrs') - usernames = config.get('edsm_usernames') or [] - apikeys = config.get('edsm_apikeys') or [] + # TODO: remove this when config is rewritten. + cmdrs: List[str] = list(config.get('edsm_cmdrs') or []) + usernames: List[str] = list(config.get('edsm_usernames') or []) + apikeys: List[str] = list(config.get('edsm_apikeys') or []) if cmdr in cmdrs: idx = cmdrs.index(cmdr) usernames.extend([''] * (1 + idx - len(usernames))) usernames[idx] = this.user.get().strip() apikeys.extend([''] * (1 + idx - len(apikeys))) apikeys[idx] = this.apikey.get().strip() + else: config.set('edsm_cmdrs', cmdrs + [cmdr]) usernames.append(this.user.get().strip()) apikeys.append(this.apikey.get().strip()) + config.set('edsm_usernames', usernames) config.set('edsm_apikeys', apikeys) -def credentials(cmdr): +def credentials(cmdr: str) -> Optional[Tuple[str, str]]: + """ + Get credentials for the given commander, if they exist. + + :param cmdr: The commander to get credentials for + :return: The credentials, or None + """ # Credentials for cmdr if not cmdr: return None @@ -212,16 +323,28 @@ def credentials(cmdr): if cmdr in cmdrs and config.get('edsm_usernames') and config.get('edsm_apikeys'): idx = cmdrs.index(cmdr) return (config.get('edsm_usernames')[idx], config.get('edsm_apikeys')[idx]) + else: return None -def journal_entry(cmdr, is_beta, system, station, entry, state): +def journal_entry( + cmdr: str, is_beta: bool, system: str, station: str, entry: MutableMapping[str, Any], state: Mapping[str, Any] +) -> None: + """Journal Entry hook.""" + if entry['event'] in ('CarrierJump', 'FSDJump', 'Location', 'Docked'): + logger.debug(f'''{entry["event"]} +Commander: {cmdr} +System: {system} +Station: {station} +state: {state!r} +entry: {entry!r}''' + ) # Always update our system address even if we're not currently the provider for system or station, but dont update # on events that contain "future" data, such as FSDTarget if entry['event'] in ('Location', 'Docked', 'CarrierJump', 'FSDJump'): - this.system_address = entry.get('SystemAddress') or this.system_address - this.system = entry.get('StarSystem') or this.system + this.system_address = entry.get('SystemAddress', this.system_address) + this.system = entry.get('StarSystem', this.system) # We need pop == 0 to set the value so as to clear 'x' in systems with # no stations. @@ -229,47 +352,62 @@ def journal_entry(cmdr, is_beta, system, station, entry, state): if pop is not None: this.system_population = pop - this.station = entry.get('StationName') or this.station - this.station_marketid = entry.get('MarketID') or this.station_marketid + this.station = entry.get('StationName', this.station) + this.station_marketid = entry.get('MarketID', this.station) # We might pick up StationName in DockingRequested, make sure we clear it if leaving if entry['event'] in ('Undocked', 'FSDJump', 'SupercruiseEntry'): this.station = None this.station_marketid = None if config.get('station_provider') == 'EDSM': - this.station_link['text'] = this.station or (this.system_population and this.system_population > 0 and STATION_UNDOCKED or '') - this.station_link['url'] = station_url(this.system, this.station) + to_set = this.station + if not this.station: + if this.system_population and this.system_population > 0: + to_set = STATION_UNDOCKED + + else: + to_set = '' + + this.station_link['text'] = to_set + this.station_link['url'] = station_url(this.system, str(this.station)) this.station_link.update_idletasks() # Update display of 'EDSM Status' image if this.system_link['text'] != system: - this.system_link['text'] = system or '' + this.system_link['text'] = system if system else '' this.system_link['image'] = '' this.system_link.update_idletasks() this.multicrew = bool(state['Role']) if 'StarPos' in entry: this.coordinates = entry['StarPos'] + elif entry['event'] == 'LoadGame': this.coordinates = None - if entry['event'] in ['LoadGame', 'Commander', 'NewCommander']: + if entry['event'] in ('LoadGame', 'Commander', 'NewCommander'): this.newgame = True this.newgame_docked = False this.navbeaconscan = 0 + elif entry['event'] == 'StartUp': this.newgame = False this.newgame_docked = False this.navbeaconscan = 0 + elif entry['event'] == 'Location': this.newgame = True this.newgame_docked = entry.get('Docked', False) this.navbeaconscan = 0 + elif entry['event'] == 'NavBeaconScan': this.navbeaconscan = entry['NumBodies'] # Send interesting events to EDSM - if config.getint('edsm_out') and not is_beta and not this.multicrew and credentials(cmdr) and entry['event'] not in this.discardedEvents: + if ( + config.getint('edsm_out') and not is_beta and not this.multicrew and credentials(cmdr) and + entry['event'] not in this.discardedEvents + ): # Introduce transient states into the event transient = { '_systemName': system, @@ -277,6 +415,7 @@ def journal_entry(cmdr, is_beta, system, station, entry, state): '_stationName': station, '_shipId': state['ShipID'], } + entry.update(transient) if entry['event'] == 'LoadGame': @@ -284,26 +423,36 @@ def journal_entry(cmdr, is_beta, system, station, entry, state): materials = { 'timestamp': entry['timestamp'], 'event': 'Materials', - 'Raw': [ { 'Name': k, 'Count': v } for k,v in state['Raw'].items() ], - 'Manufactured': [ { 'Name': k, 'Count': v } for k,v in state['Manufactured'].items() ], - 'Encoded': [ { 'Name': k, 'Count': v } for k,v in state['Encoded'].items() ], + 'Raw': [{'Name': k, 'Count': v} for k, v in state['Raw'].items()], + 'Manufactured': [{'Name': k, 'Count': v} for k, v in state['Manufactured'].items()], + 'Encoded': [{'Name': k, 'Count': v} for k, v in state['Encoded'].items()], } materials.update(transient) this.queue.put((cmdr, materials)) + if entry['event'] in ('CarrierJump', 'FSDJump', 'Location', 'Docked'): + logger.debug(f'''{entry["event"]} +Queueing: {entry!r}''' + ) this.queue.put((cmdr, entry)) # Update system data -def cmdr_data(data, is_beta): +def cmdr_data(data: Mapping[str, Any], is_beta: bool) -> None: + """CAPI Entry Hook.""" system = data['lastSystem']['name'] # Always store initially, even if we're not the *current* system provider. - if not this.station_marketid: - this.station_marketid = data['commander']['docked'] and data['lastStarport']['id'] + if not this.station_marketid and data['commander']['docked']: + this.station_marketid = data['lastStarport']['id'] + # Only trust CAPI if these aren't yet set - this.system = this.system or data['lastSystem']['name'] - this.station = this.station or data['commander']['docked'] and data['lastStarport']['name'] + if not this.system: + this.system = data['lastSystem']['name'] + + if not this.station and data['commander']['docked']: + this.station = data['lastStarport']['name'] + # TODO: Fire off the EDSM API call to trigger the callback for the icons if config.get('system_provider') == 'EDSM': @@ -311,11 +460,14 @@ def cmdr_data(data, is_beta): # Do *NOT* set 'url' here, as it's set to a function that will call # through correctly. We don't want a static string. this.system_link.update_idletasks() + if config.get('station_provider') == 'EDSM': if data['commander']['docked']: this.station_link['text'] = this.station + elif data['lastStarport']['name'] and data['lastStarport']['name'] != "": this.station_link['text'] = STATION_UNDOCKED + else: this.station_link['text'] = '' @@ -331,22 +483,33 @@ def cmdr_data(data, is_beta): # Worker thread -def worker(): +def worker() -> None: + """ + Upload worker. - pending = [] # Unsent events + Processes `this.queue` until the queued item is None. + """ + pending = [] # Unsent events closing = False while True: - item = this.queue.get() + item: Optional[Tuple[str, Mapping[str, Any]]] = this.queue.get() if item: (cmdr, entry) = item else: - closing = True # Try to send any unsent events before we close + closing = True # Try to send any unsent events before we close retrying = 0 while retrying < 3: try: - if item and entry['event'] not in this.discardedEvents: + if TYPE_CHECKING: + # Tell the type checker that these two are bound. + # TODO: While this works because of the item check below, these names are still technically unbound + # TODO: in some cases, therefore this should be refactored. + cmdr: str = "" + entry: Mapping[str, Any] = {} + + if item and entry['event'] not in this.discardedEvents: # TODO: Technically entry can be unbound here. pending.append(entry) # Get list of events to discard @@ -354,12 +517,26 @@ def worker(): r = this.session.get('https://www.edsm.net/api-journal-v1/discard', timeout=_TIMEOUT) r.raise_for_status() this.discardedEvents = set(r.json()) - this.discardedEvents.discard('Docked') # should_send() assumes that we send 'Docked' events - assert this.discardedEvents # wouldn't expect this to be empty - pending = [x for x in pending if x['event'] not in this.discardedEvents] # Filter out unwanted events + this.discardedEvents.discard('Docked') # should_send() assumes that we send 'Docked' events + if not this.discardedEvents: + logger.error( + 'Unexpected empty discarded events list from EDSM. Bailing out of send: ' + f'{type(this.discardedEvents)} -- {this.discardedEvents}' + ) + continue + + # Filter out unwanted events + pending = list(filter(lambda x: x['event'] not in this.discardedEvents, pending)) if should_send(pending): - (username, apikey) = credentials(cmdr) + if any(p for p in pending if p['event'] in ('CarrierJump', 'FSDJump', 'Location', 'Docked')): + logger.debug('CarrierJump (or FSDJump) in pending and it passed should_send()') + + creds = credentials(cmdr) # TODO: possibly unbound + if creds is None: + raise ValueError("Unexpected lack of credentials") + + username, apikey = creds data = { 'commanderName': username.encode('utf-8'), 'apiKey': apikey, @@ -367,33 +544,44 @@ def worker(): 'fromSoftwareVersion': appversion, 'message': json.dumps(pending, ensure_ascii=False).encode('utf-8'), } + + if any(p for p in pending if p['event'] in ('CarrierJump', 'FSDJump', 'Location', 'Docked')): + data_elided = data.copy() + data_elided['apiKey'] = '<elided>' + logger.debug(f'CarrierJump (or FSDJump): Attempting API call\ndata: {data_elided!r}') + r = this.session.post('https://www.edsm.net/api-journal-v1', data=data, timeout=_TIMEOUT) r.raise_for_status() reply = r.json() - (msgnum, msg) = reply['msgnum'], reply['msg'] + msg_num = reply['msgnum'] + msg = reply['msg'] # 1xx = OK # 2xx = fatal error # 3&4xx not generated at top-level # 5xx = error but events saved for later processing - if msgnum // 100 == 2: - logger.warning(f'EDSM\t{msgnum} {msg}\t{json.dumps(pending, separators = (",", ": "))}') + if msg_num // 100 == 2: + logger.warning(f'EDSM\t{msg_num} {msg}\t{json.dumps(pending, separators=(",", ": "))}') plug.show_error(_('Error: EDSM {MSG}').format(MSG=msg)) + else: for e, r in zip(pending, reply['events']): - if not closing and e['event'] in ['StartUp', 'Location', 'FSDJump', 'CarrierJump']: + if not closing and e['event'] in ('StartUp', 'Location', 'FSDJump', 'CarrierJump'): # Update main window's system status this.lastlookup = r # calls update_status in main thread this.system_link.event_generate('<<EDSMStatus>>', when="tail") + elif r['msgnum'] // 100 != 1: logger.warning(f'EDSM\t{r["msgnum"]} {r["msg"]}\t' f'{json.dumps(e, separators = (",", ": "))}') + pending = [] break except Exception as e: logger.debug('Sending API events', exc_info=e) retrying += 1 + else: plug.show_error(_("Error: Can't connect to EDSM")) @@ -401,17 +589,25 @@ def worker(): return -# Whether any of the entries should be sent immediately -def should_send(entries): +def should_send(entries: List[Mapping[str, Any]]) -> bool: + """ + Whether or not any of the given entries should be sent to EDSM. + :param entries: The entries to check + :return: bool indicating whether or not to send said entries + """ # batch up burst of Scan events after NavBeaconScan if this.navbeaconscan: if entries and entries[-1]['event'] == 'Scan': this.navbeaconscan -= 1 if this.navbeaconscan: return False + else: - assert(False) + logger.error( + 'Invalid state NavBeaconScan exists, but passed entries either ' + "doesn't exist or doesn't have the expected content" + ) this.navbeaconscan = 0 for entry in entries: @@ -420,32 +616,40 @@ def should_send(entries): this.newgame = False this.newgame_docked = False return True + elif this.newgame: pass - elif entry['event'] not in ['CommunityGoal', # Spammed periodically - 'ModuleBuy', 'ModuleSell', 'ModuleSwap', # will be shortly followed by "Loadout" - 'ShipyardBuy', 'ShipyardNew', 'ShipyardSwap']: # " + + elif entry['event'] not in ( + 'CommunityGoal', # Spammed periodically + 'ModuleBuy', 'ModuleSell', 'ModuleSwap', # will be shortly followed by "Loadout" + 'ShipyardBuy', 'ShipyardNew', 'ShipyardSwap'): # " return True + return False -# Call edsm_notify_system() in this and other interested plugins with EDSM's response to a 'StartUp', 'Location', 'FSDJump' or 'CarrierJump' event -def update_status(event=None): +def update_status(event=None) -> None: + """Update listening plugins with our response to StartUp, Location, FSDJump, or CarrierJump.""" for plugin in plug.provides('edsm_notify_system'): plug.invoke(plugin, None, 'edsm_notify_system', this.lastlookup) -# Called with EDSM's response to a 'StartUp', 'Location', 'FSDJump' or 'CarrierJump' event. https://www.edsm.net/en/api-journal-v1 +# Called with EDSM's response to a 'StartUp', 'Location', 'FSDJump' or 'CarrierJump' event. +# https://www.edsm.net/en/api-journal-v1 # msgnum: 1xx = OK, 2xx = fatal error, 3xx = error, 4xx = ignorable errors. -def edsm_notify_system(reply): +def edsm_notify_system(reply: Mapping[str, Any]) -> None: + """Update the image next to the system link.""" if not reply: this.system_link['image'] = this._IMG_ERROR plug.show_error(_("Error: Can't connect to EDSM")) - elif reply['msgnum'] // 100 not in (1,4): + + elif reply['msgnum'] // 100 not in (1, 4): this.system_link['image'] = this._IMG_ERROR plug.show_error(_('Error: EDSM {MSG}').format(MSG=reply['msg'])) + elif reply.get('systemCreated'): this.system_link['image'] = this._IMG_NEW + else: this.system_link['image'] = this._IMG_KNOWN - diff --git a/plugins/inara.py b/plugins/inara.py index e78287d1..4b61322c 100644 --- a/plugins/inara.py +++ b/plugins/inara.py @@ -1,10 +1,7 @@ -# -# Inara sync -# +"""Inara Sync.""" import dataclasses import json -import logging import sys import time import tkinter as tk @@ -13,22 +10,25 @@ from collections import OrderedDict, defaultdict, deque from operator import itemgetter from queue import Queue from threading import Lock, Thread -from typing import TYPE_CHECKING, Any, AnyStr, Callable, Deque, Dict, List, Mapping, NamedTuple, Optional +from typing import ( + TYPE_CHECKING, Any, AnyStr, Callable, Deque, Dict, List, Mapping, MutableMapping, NamedTuple, Optional +) from typing import OrderedDict as OrderedDictT -from typing import Sequence, Union +from typing import Sequence, Union, cast import requests import myNotebook as nb # noqa: N813 import plug import timeout_session -from config import applongname, appname, appversion, config +from config import applongname, appversion, config +from EDMCLogging import get_main_logger from ttkHyperlinkLabel import HyperlinkLabel -logger = logging.getLogger(appname) +logger = get_main_logger() if TYPE_CHECKING: - def _(x): + def _(x: str) -> str: return x @@ -81,9 +81,8 @@ STATION_UNDOCKED: str = '×' # "Station" name to display when not docked = U+00 class Credentials(NamedTuple): - """ - Credentials holds the set of credentials required to identify an inara API payload to inara - """ + """Credentials holds the set of credentials required to identify an inara API payload to inara.""" + cmdr: str fid: str api_key: str @@ -93,9 +92,8 @@ EVENT_DATA = Union[Mapping[AnyStr, Any], Sequence[Mapping[AnyStr, Any]]] class Event(NamedTuple): - """ - Event represents an event for the Inara API - """ + """Event represents an event for the Inara API.""" + name: str timestamp: str data: EVENT_DATA @@ -103,12 +101,19 @@ class Event(NamedTuple): @dataclasses.dataclass class NewThis: + """ + NewThis is where the plugin stores all of its data. + + It is named NewThis as it is currently being migrated to. Once migration is complete it will be renamed to This. + """ + events: Dict[Credentials, Deque[Event]] = dataclasses.field(default_factory=lambda: defaultdict(deque)) event_lock: Lock = dataclasses.field(default_factory=Lock) # protects events, for use when rewriting events - def filter_events(self, key: Credentials, predicate: Callable[[Event], bool]): + def filter_events(self, key: Credentials, predicate: Callable[[Event], bool]) -> None: """ filter_events is the equivalent of running filter() on any event list in the events dict. + it will automatically handle locking, and replacing the event list with the filtered version. :param key: the key to filter @@ -124,7 +129,8 @@ new_this = NewThis() TARGET_URL = 'https://inara.cz/inapi/v1/' -def system_url(system_name: str): +def system_url(system_name: str) -> str: + """Get a URL for the current system.""" if this.system_address: return requests.utils.requote_uri(f'https://inara.cz/galaxy-starsystem/?search={this.system_address}') @@ -133,7 +139,17 @@ def system_url(system_name: str): return '' -def station_url(system_name, station_name): + +def station_url(system_name: str, station_name: str) -> str: + """ + Get a URL for the current station. + + If there is no station, the system URL is returned. + + :param system_name: The name of the current system + :param station_name: The name of the current station, if any + :return: A URL to inara for the given system and station + """ if system_name and station_name: return requests.utils.requote_uri(f'https://inara.cz/galaxy-station/?search={system_name}%20[{station_name}]') @@ -146,7 +162,9 @@ def station_url(system_name, station_name): return '' -def plugin_start3(plugin_dir): + +def plugin_start3(plugin_dir: str) -> str: + """Plugin start Hook.""" this.thread = Thread(target=new_worker, name='Inara worker') this.thread.daemon = True this.thread.start() @@ -154,14 +172,16 @@ def plugin_start3(plugin_dir): return 'Inara' -def plugin_app(parent: tk.Tk): +def plugin_app(parent: tk.Tk) -> None: + """Plugin UI setup Hook.""" this.system_link = parent.children['system'] # system label in main window this.station_link = parent.children['station'] # station label in main window this.system_link.bind_all('<<InaraLocation>>', update_location) this.system_link.bind_all('<<InaraShip>>', update_ship) -def plugin_stop(): +def plugin_stop() -> None: + """Plugin shutdown hook.""" # Signal thread to close and wait for it this.queue.put(None) # this.thread.join() @@ -170,7 +190,8 @@ def plugin_stop(): this.timer_run = False -def plugin_prefs(parent: tk.Tk, cmdr: str, is_beta: bool): +def plugin_prefs(parent: tk.Tk, cmdr: str, is_beta: bool) -> tk.Frame: + """Plugin Preferences UI hook.""" PADX = 10 BUTTONX = 12 # indent Checkbuttons and Radiobuttons PADY = 2 # close spacing @@ -212,7 +233,8 @@ def plugin_prefs(parent: tk.Tk, cmdr: str, is_beta: bool): return frame -def prefs_cmdr_changed(cmdr: str, is_beta: bool): +def prefs_cmdr_changed(cmdr: str, is_beta: bool) -> None: + """Plugin commander change hook.""" this.log_button['state'] = tk.NORMAL if cmdr and not is_beta else tk.DISABLED this.apikey['state'] = tk.NORMAL this.apikey.delete(0, tk.END) @@ -231,6 +253,7 @@ def prefs_cmdr_changed(cmdr: str, is_beta: bool): def prefsvarchanged(): + """Preferences window change hook.""" state = tk.DISABLED if this.log.get(): state = this.log_button['state'] @@ -240,7 +263,8 @@ def prefsvarchanged(): this.apikey['state'] = state -def prefs_changed(cmdr: str, is_beta: bool): +def prefs_changed(cmdr: str, is_beta: bool) -> None: + """Preferences window closed hook.""" changed = config.getint('inara_out') != this.log.get() config.set('inara_out', this.log.get()) @@ -264,14 +288,14 @@ def prefs_changed(cmdr: str, is_beta: bool): if this.log.get() and changed: this.newuser = True # Send basic info at next Journal event - new_add_event('getCommanderProfile', time.strftime( - '%Y-%m-%dT%H:%M:%SZ', time.gmtime()), {'searchName': cmdr}) - # call() + new_add_event( + 'getCommanderProfile', time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), {'searchName': cmdr} + ) def credentials(cmdr: str) -> Optional[str]: """ - credentials fetches the credentials for the given commander + Get the credentials for the current commander. :param cmdr: Commander name to search for credentials :return: Credentials for the given commander or None @@ -287,12 +311,11 @@ def credentials(cmdr: str) -> Optional[str]: return None -def journal_entry(cmdr: str, is_beta: bool, system: str, station: str, entry: Dict[str, Any], state: Dict[str, Any]): - # Send any unsent events when switching accounts - # if cmdr and cmdr != this.cmdr: - # call(force=True) - - event_name = entry['event'] +def journal_entry( + cmdr: str, is_beta: bool, system: str, station: str, entry: Dict[str, Any], state: Dict[str, Any] +) -> None: + """Journal entry hook.""" + event_name: str = entry['event'] this.cmdr = cmdr this.FID = state['FID'] this.multicrew = bool(state['Role']) @@ -337,7 +360,7 @@ def journal_entry(cmdr: str, is_beta: bool, system: str, station: str, entry: Di # We need pop == 0 to set the value so as to clear 'x' in systems with # no stations. - pop = entry.get('Population') + pop: Optional[int] = entry.get('Population') if pop is not None: this.system_population = pop @@ -352,10 +375,7 @@ def journal_entry(cmdr: str, is_beta: bool, system: str, station: str, entry: Di current_creds = Credentials(this.cmdr, this.FID, str(credentials(this.cmdr))) try: # Dump starting state to Inara - if (this.newuser or - event_name == 'StartUp' or - (this.newsession and event_name == 'Cargo')): - + if (this.newuser or event_name == 'StartUp' or (this.newsession and event_name == 'Cargo')): this.newuser = False this.newsession = False @@ -379,7 +399,7 @@ def journal_entry(cmdr: str, is_beta: bool, system: str, station: str, entry: Di ) if state['Engineers']: # Not populated < 3.3 - to_send = [] + to_send: List[Mapping[str, Any]] = [] for k, v in state['Engineers'].items(): e = {'engineerName': k} if isinstance(v, tuple): @@ -440,6 +460,7 @@ def journal_entry(cmdr: str, is_beta: bool, system: str, station: str, entry: Di ) elif event_name == 'EngineerProgress' and 'Engineer' in entry: + # TODO: due to this var name being used above, the types are weird to_send = {'engineerName': entry['Engineer']} if 'Rank' in entry: to_send['rankValue'] = entry['Rank'] @@ -477,7 +498,7 @@ def journal_entry(cmdr: str, is_beta: bool, system: str, station: str, entry: Di # Ship change if event_name == 'Loadout' and this.shipswap: - cur_ship = { + cur_ship: Dict[str, Any] = { 'shipType': state['ShipType'], 'shipGameID': state['ShipID'], 'shipName': state['ShipName'], # Can be None @@ -595,7 +616,7 @@ def journal_entry(cmdr: str, is_beta: bool, system: str, station: str, entry: Di new_add_event('setCommanderInventoryCargo', entry['timestamp'], cargo) this.cargo = cargo - materials = [] + materials: List[Mapping[str, Any]] = [] for category in ('Raw', 'Manufactured', 'Encoded'): materials.extend( [OrderedDict([('itemName', k), ('itemCount', state[category][k])]) for k in sorted(state[category])] @@ -929,7 +950,7 @@ def journal_entry(cmdr: str, is_beta: bool, system: str, station: str, entry: Di # )) for goal in entry['CurrentGoals']: - data = OrderedDict([ + data: MutableMapping[str, Any] = OrderedDict([ ('communitygoalGameID', goal['CGID']), ('communitygoalName', goal['Title']), ('starsystemName', goal['SystemName']), @@ -952,7 +973,7 @@ def journal_entry(cmdr: str, is_beta: bool, system: str, station: str, entry: Di new_add_event('setCommunityGoal', entry['timestamp'], data) - data = OrderedDict([ + data: MutableMapping[str, Any] = OrderedDict([ ('communitygoalGameID', goal['CGID']), ('contribution', goal['PlayerContribution']), ('percentileBand', goal['PlayerPercentileBand']), @@ -998,7 +1019,7 @@ def journal_entry(cmdr: str, is_beta: bool, system: str, station: str, entry: Di this.system_link.update_idletasks() if config.get('station_provider') == 'Inara': - to_set = this.station + to_set: str = cast(str, this.station) if not to_set: if this.system_population is not None and this.system_population > 0: to_set = STATION_UNDOCKED @@ -1012,6 +1033,7 @@ def journal_entry(cmdr: str, is_beta: bool, system: str, station: str, entry: Di def cmdr_data(data, is_beta): + """CAPI event hook.""" this.cmdr = data['commander']['name'] # Always store initially, even if we're not the *current* system provider. @@ -1049,7 +1071,8 @@ def cmdr_data(data, is_beta): if not (CREDIT_RATIO > this.lastcredits / data['commander']['credits'] > 1/CREDIT_RATIO): new_this.filter_events( Credentials(this.cmdr, this.FID, str(credentials(this.cmdr))), - lambda e: e.name != 'setCommanderCredits') + lambda e: e.name != 'setCommanderCredits' + ) # this.events = [x for x in this.events if x['eventName'] != 'setCommanderCredits'] # Remove any unsent new_add_event( @@ -1065,6 +1088,12 @@ def cmdr_data(data, is_beta): def make_loadout(state: Dict[str, Any]) -> OrderedDictT[str, Any]: + """ + Construct an inara loadout from an event. + + :param state: The event / state to construct the event from + :return: The constructed loadout + """ modules = [] for m in state['Modules'].values(): module: OrderedDictT[str, Any] = OrderedDict([ @@ -1132,8 +1161,9 @@ def new_add_event( fid: Optional[str] = None ): """ - add a journal event to the queue, to be sent to inara at the next opportunity. If provided, use the given cmdr - name over the current one + Add a journal event to the queue, to be sent to inara at the next opportunity. + + If provided, use the given cmdr name over the current one :param name: name of the event :param timestamp: timestamp of the event @@ -1148,7 +1178,7 @@ def new_add_event( api_key = credentials(this.cmdr) if api_key is None: - logger.warn(f"cannot find an API key for cmdr {this.cmdr!r}") + logger.warning(f"cannot find an API key for cmdr {this.cmdr!r}") return key = Credentials(str(cmdr), str(fid), api_key) # this fails type checking due to `this` weirdness, hence str() @@ -1158,6 +1188,11 @@ def new_add_event( def new_worker(): + """ + Queue worker. + + Will only ever send one message per WORKER_WAIT_TIME, regardless of status. + """ while True: events = get_events() for creds, event_list in events.items(): @@ -1182,9 +1217,9 @@ def new_worker(): time.sleep(WORKER_WAIT_TIME) -def get_events(clear=True) -> Dict[Credentials, List[Event]]: +def get_events(clear: bool = True) -> Dict[Credentials, List[Event]]: """ - get_events fetches all events from the current queue and returns a frozen version of them + Fetch a frozen copy of all events from the current queue. :param clear: whether or not to clear the queues as we go, defaults to True :return: the frozen event list @@ -1199,9 +1234,9 @@ def get_events(clear=True) -> Dict[Credentials, List[Event]]: return out -def try_send_data(url: str, data: Mapping[str, Any]): +def try_send_data(url: str, data: Mapping[str, Any]) -> None: """ - attempt repeatedly to send the payload forward + Attempt repeatedly to send the payload forward. :param url: target URL for the payload :param data: the payload @@ -1219,7 +1254,7 @@ def try_send_data(url: str, data: Mapping[str, Any]): def send_data(url: str, data: Mapping[str, Any]) -> bool: """ - write a set of events to the inara API + Write a set of events to the inara API. :param url: the target URL to post to :param data: the data to POST @@ -1266,10 +1301,9 @@ def send_data(url: str, data: Mapping[str, Any]) -> bool: return True # regardless of errors above, we DID manage to send it, therefore inform our caller as such -def update_location(event=None): +def update_location(event: Dict[str, Any] = None) -> None: """ - Call inara_notify_location in this and other interested plugins with Inara's response when changing system - or station + Update other plugins with our response to system and station changes. :param event: Unused and ignored, defaults to None """ @@ -1278,13 +1312,14 @@ def update_location(event=None): plug.invoke(plugin, None, 'inara_notify_location', this.lastlocation) -def inara_notify_location(eventData): +def inara_notify_location(eventData: Dict[str, Any]) -> None: + """Unused.""" pass -def update_ship(event=None): +def update_ship(event: Dict[str, Any] = None) -> None: """ - Call inara_notify_ship() in interested plugins with Inara's response when changing ship + Update other plugins with our response to changing. :param event: Unused and ignored, defaults to None """ diff --git a/prefs.py b/prefs.py index ebcff0f4..72f90fda 100644 --- a/prefs.py +++ b/prefs.py @@ -15,7 +15,7 @@ from typing import TYPE_CHECKING, Any, Callable, Optional, Type, Union import myNotebook as nb # noqa: N813 import plug from config import applongname, appname, appversion, config -from EDMCLogging import edmclogger +from EDMCLogging import edmclogger, get_main_logger from hotkey import hotkeymgr from l10n import Translations from monitor import monitor @@ -23,7 +23,7 @@ from myNotebook import Notebook from theme import theme from ttkHyperlinkLabel import HyperlinkLabel -logger = logging.getLogger(appname) +logger = get_main_logger() if TYPE_CHECKING: def _(x: str) -> str: diff --git a/setup.py b/setup.py index a3e3d397..4578b3cc 100755 --- a/setup.py +++ b/setup.py @@ -172,6 +172,7 @@ setup( 'version': BASEVERSION, 'product_version': VERSION, 'copyright': COPYRIGHT, + 'other_resources': [(24, 1, open(APPCMDNAME+'.manifest').read())], } ], data_files = DATA_FILES, options = OPTIONS,