1
0
mirror of https://github.com/EDCD/EDMarketConnector.git synced 2025-04-14 16:27:13 +03:00

Merge branch 'develop' into cleanup/prefs

This commit is contained in:
Athanasius 2020-09-22 16:59:44 +01:00 committed by GitHub
commit 20464b80c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1429 additions and 623 deletions

View File

@ -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.

View File

@ -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
===

38
EDMC.manifest Normal file
View File

@ -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>

31
EDMC.py
View File

@ -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')

View File

@ -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:

View File

@ -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>

View File

@ -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')

View File

@ -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í";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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" = "ログレベル";

View File

@ -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";

View File

@ -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";

View File

@ -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" = "Уровень лога";

View File

@ -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";

View File

@ -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";

View File

@ -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
&rarr; 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
&rarr; 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.

View File

@ -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]

View File

@ -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'

View File

@ -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)

View File

@ -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!')

View File

@ -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'])

View File

@ -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}"')

View File

@ -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', {}),

View File

@ -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

View File

@ -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
"""

View File

@ -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:

View File

@ -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,