1
0
mirror of https://github.com/EDCD/EDMarketConnector.git synced 2025-04-18 18:07:37 +03:00

Merge pull request #1740 from EDCD/fix/1723/flake8-everything

Get everything passing flake8 checks
This commit is contained in:
Athanasius 2022-12-05 11:05:41 +00:00 committed by GitHub
commit 3ed568e544
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 740 additions and 415 deletions

View File

@ -85,11 +85,15 @@ jobs:
# F63 - 'tests' checking
# F7 - syntax errors
# F82 - undefined checking
git diff "refs/remotes/${BASE_REPO_OWNER}/${BASE_REF}" -- | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --diff
git diff --name-only --diff-filter=d -z "refs/remotes/${BASE_REPO_OWNER}/${BASE_REF}" -- | \
grep -E -z -Z '\.py$' | \
xargs -0 flake8 --count --select=E9,F63,F7,F82 --show-source --statistics
# Can optionally add `--exit-zero` to the flake8 arguments so that
# this doesn't fail the build.
# explicitly ignore docstring errors (start with D)
git diff "refs/remotes/${BASE_REPO_OWNER}/${BASE_REF}" -- | flake8 . --count --statistics --diff --extend-ignore D
git diff --name-only --diff-filter=d -z "refs/remotes/${BASE_REPO_OWNER}/${BASE_REF}" -- | \
grep -E -z -Z '\.py$' | \
xargs -0 flake8 --count --statistics --extend-ignore D
####################################################################
####################################################################

View File

@ -40,7 +40,22 @@ jobs:
DATA=$(jq --raw-output .before $GITHUB_EVENT_PATH)
echo "DATA: ${DATA}"
#######################################################################
# stop the build if there are Python syntax errors or undefined names, ignore existing
git diff "$DATA" | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --diff
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
git diff "$DATA" | flake8 . --count --statistics --diff
#######################################################################
# We need to get just the *filenames* of only *python* files changed.
# Using various -z/-Z/-0 to utilise NUL-terminated strings.
git diff --name-only --diff-filter=d -z "$DATA" | \
grep -E -z -Z '\.py$' | \
xargs -0 flake8 --count --select=E9,F63,F7,F82 --show-source --statistics
#######################################################################
#######################################################################
# 'Full' run, but ignoring docstring errors
#######################################################################
# explicitly ignore docstring errors (start with D)
# Can optionally add `--exit-zero` to the flake8 arguments so that
git diff --name-only --diff-filter=d -z "$DATA" | \
grep -E -z -Z '\.py$' | \
xargs -0 flake8 --count --statistics --extend-ignore D
#######################################################################

View File

@ -529,17 +529,19 @@ class AppWindow(object):
# LANG: Update button in main window
self.button = ttk.Button(frame, text=_('Update'), width=28, default=tk.ACTIVE, state=tk.DISABLED)
self.theme_button = tk.Label(frame, width=32 if sys.platform == 'darwin' else 28, state=tk.DISABLED)
self.status = tk.Label(frame, name='status', anchor=tk.W)
ui_row = frame.grid_size()[1]
self.button.grid(row=ui_row, columnspan=2, sticky=tk.NSEW)
self.theme_button.grid(row=ui_row, columnspan=2, sticky=tk.NSEW)
theme.register_alternate((self.button, self.theme_button, self.theme_button),
{'row': ui_row, 'columnspan': 2, 'sticky': tk.NSEW})
self.status.grid(columnspan=2, sticky=tk.EW)
self.button.bind('<Button-1>', self.capi_request_data)
theme.button_bind(self.theme_button, self.capi_request_data)
# Bottom 'status' line.
self.status = tk.Label(frame, name='status', anchor=tk.W)
self.status.grid(columnspan=2, sticky=tk.EW)
for child in frame.winfo_children():
child.grid_configure(padx=self.PADX, pady=(
sys.platform != 'win32' or isinstance(child, tk.Frame)) and 2 or 0)
@ -558,7 +560,7 @@ class AppWindow(object):
# https://www.tcl.tk/man/tcl/TkCmd/menu.htm
self.system_menu = tk.Menu(self.menubar, name='apple')
self.system_menu.add_command(command=lambda: self.w.call('tk::mac::standardAboutPanel'))
self.system_menu.add_command(command=lambda: self.updater.checkForUpdates())
self.system_menu.add_command(command=lambda: self.updater.check_for_updates())
self.menubar.add_cascade(menu=self.system_menu)
self.file_menu = tk.Menu(self.menubar, name='file')
self.file_menu.add_command(command=self.save_raw)
@ -601,7 +603,7 @@ class AppWindow(object):
self.help_menu.add_command(command=self.help_general)
self.help_menu.add_command(command=self.help_privacy)
self.help_menu.add_command(command=self.help_releases)
self.help_menu.add_command(command=lambda: self.updater.checkForUpdates())
self.help_menu.add_command(command=lambda: self.updater.check_for_updates())
self.help_menu.add_command(command=lambda: not self.HelpAbout.showing and self.HelpAbout(self.w))
self.menubar.add_cascade(menu=self.help_menu)
@ -724,7 +726,7 @@ class AppWindow(object):
self.updater = update.Updater(tkroot=self.w, provider='external')
else:
self.updater = update.Updater(tkroot=self.w, provider='internal')
self.updater.checkForUpdates() # Sparkle / WinSparkle does this automatically for packaged apps
self.updater.check_for_updates() # Sparkle / WinSparkle does this automatically for packaged apps
# Migration from <= 3.30
for username in config.get_list('fdev_usernames', default=[]):
@ -733,7 +735,6 @@ class AppWindow(object):
config.delete('username', suppress=True)
config.delete('password', suppress=True)
config.delete('logdir', suppress=True)
self.postprefs(False) # Companion login happens in callback from monitor
self.toggle_suit_row(visible=False)
@ -1382,7 +1383,7 @@ class AppWindow(object):
# Disable WinSparkle automatic update checks, IFF configured to do so when in-game
if config.get_int('disable_autoappupdatecheckingame') and 1:
self.updater.setAutomaticUpdatesCheck(False)
self.updater.set_automatic_updates_check(False)
logger.info('Monitor: Disable WinSparkle automatic update checks')
# Can't start dashboard monitoring
@ -1433,7 +1434,7 @@ class AppWindow(object):
if entry['event'] == 'ShutDown':
# Enable WinSparkle automatic update checks
# NB: Do this blindly, in case option got changed whilst in-game
self.updater.setAutomaticUpdatesCheck(True)
self.updater.set_automatic_updates_check(True)
logger.info('Monitor: Enable WinSparkle automatic update checks')
def auth(self, event=None) -> None:

View File

@ -3,7 +3,7 @@ import os
import pathlib
import sys
from configparser import ConfigParser
from typing import TYPE_CHECKING, List, Optional, Union
from typing import List, Optional, Union
from config import AbstractConfig, appname, logger

View File

@ -133,7 +133,7 @@ class JournalLock:
return JournalLockResult.LOCKED
def release_lock(self) -> bool: # noqa: CCR001
def release_lock(self) -> bool:
"""
Release lock on journal directory.

View File

@ -1,48 +1,59 @@
# Export ship loadout in Companion API json format
"""Export ship loadout in Companion API json format."""
import json
import os
from os.path import join
import pathlib
import re
import time
from os.path import join
from typing import Optional
from config import config
import companion
import util_ships
from config import config
from EDMCLogging import get_main_logger
logger = get_main_logger()
def export(data, filename=None):
def export(data: companion.CAPIData, requested_filename: Optional[str] = None) -> None:
"""
Write Ship Loadout in Companion API JSON format.
:param data: CAPI data containing ship loadout.
:param requested_filename: Name of file to write to.
"""
string = json.dumps(
companion.ship(data), cls=companion.CAPIDataEncoder,
ensure_ascii=False, indent=2, sort_keys=True, separators=(',', ': ')
) # pretty print
if filename:
with open(filename, 'wt') as h:
if requested_filename is not None and requested_filename:
with open(requested_filename, 'wt') as h:
h.write(string)
return
elif not requested_filename:
logger.error(f"{requested_filename=} is not valid")
return
# Look for last ship of this type
ship = util_ships.ship_file_name(data['ship'].get('shipName'), data['ship']['name'])
regexp = re.compile(re.escape(ship) + '\.\d\d\d\d\-\d\d\-\d\dT\d\d\.\d\d\.\d\d\.txt')
regexp = re.compile(re.escape(ship) + r'\.\d\d\d\d\-\d\d\-\d\dT\d\d\.\d\d\.\d\d\.txt')
oldfiles = sorted([x for x in os.listdir(config.get_str('outdir')) if regexp.match(x)])
if oldfiles:
with open(join(config.get_str('outdir'), oldfiles[-1]), 'rU') as h:
if h.read() == string:
return # same as last time - don't write
querytime = config.get_int('querytime', default=int(time.time()))
query_time = config.get_int('querytime', default=int(time.time()))
# Write
#
# When this is refactored into multi-line CHECK IT WORKS, avoiding the
# brainfart we had with dangling commas in commodity.py:export() !!!
#
filename = join(config.get_str('outdir'), '%s.%s.txt' % (ship, time.strftime('%Y-%m-%dT%H.%M.%S', time.localtime(querytime))))
#
# When this is refactored into multi-line CHECK IT WORKS, avoiding the
# brainfart we had with dangling commas in commodity.py:export() !!!
#
with open(filename, 'wt') as h:
with open(
pathlib.Path(config.get_str('outdir')) / pathlib.Path(
ship + '.' + time.strftime('%Y-%m-%dT%H.%M.%S', time.localtime(query_time)) + '.txt'
),
'wt'
) as h:
h.write(string)

View File

@ -1,14 +1,19 @@
#
# Hacks to fix various display issues with notebooks and their child widgets on OSX and Windows.
# - Windows: page background should be White, not SystemButtonFace
# - OSX: page background should be a darker gray than systemWindowBody
# selected tab foreground should be White when the window is active
#
"""
Custom `ttk.Notebook` to fix various display issues.
Hacks to fix various display issues with notebooks and their child widgets on
OSX and Windows.
- Windows: page background should be White, not SystemButtonFace
- OSX: page background should be a darker gray than systemWindowBody
selected tab foreground should be White when the window is active
Entire file may be imported by plugins.
"""
import sys
import tkinter as tk
from tkinter import ttk
# Entire file may be imported by plugins
from typing import Optional
# Can't do this with styles on OSX - http://www.tkdocs.com/tutorial/styles.html#whydifficult
if sys.platform == 'darwin':
@ -22,8 +27,9 @@ elif sys.platform == 'win32':
class Notebook(ttk.Notebook):
"""Custom ttk.Notebook class to fix some display issues."""
def __init__(self, master=None, **kw):
def __init__(self, master: Optional[ttk.Frame] = None, **kw):
ttk.Notebook.__init__(self, master, **kw)
style = ttk.Style()
@ -46,9 +52,13 @@ class Notebook(ttk.Notebook):
self.grid(padx=10, pady=10, sticky=tk.NSEW)
class Frame(sys.platform == 'darwin' and tk.Frame or ttk.Frame):
# FIXME: The real fix for this 'dynamic type' would be to split this whole
# thing into being a module with per-platform files, as we've done with config
# That would also make the code cleaner.
class Frame(sys.platform == 'darwin' and tk.Frame or ttk.Frame): # type: ignore
"""Custom t(t)k.Frame class to fix some display issues."""
def __init__(self, master=None, **kw):
def __init__(self, master: Optional[ttk.Frame] = None, **kw):
if sys.platform == 'darwin':
kw['background'] = kw.pop('background', PAGEBG)
tk.Frame.__init__(self, master, **kw)
@ -63,8 +73,9 @@ class Frame(sys.platform == 'darwin' and tk.Frame or ttk.Frame):
class Label(tk.Label):
"""Custom tk.Label class to fix some display issues."""
def __init__(self, master=None, **kw):
def __init__(self, master: Optional[ttk.Frame] = None, **kw):
if sys.platform in ['darwin', 'win32']:
kw['foreground'] = kw.pop('foreground', PAGEFG)
kw['background'] = kw.pop('background', PAGEBG)
@ -74,9 +85,10 @@ class Label(tk.Label):
tk.Label.__init__(self, master, **kw) # Just use tk.Label on all platforms
class Entry(sys.platform == 'darwin' and tk.Entry or ttk.Entry):
class Entry(sys.platform == 'darwin' and tk.Entry or ttk.Entry): # type: ignore
"""Custom t(t)k.Entry class to fix some display issues."""
def __init__(self, master=None, **kw):
def __init__(self, master: Optional[ttk.Frame] = None, **kw):
if sys.platform == 'darwin':
kw['highlightbackground'] = kw.pop('highlightbackground', PAGEBG)
tk.Entry.__init__(self, master, **kw)
@ -84,9 +96,10 @@ class Entry(sys.platform == 'darwin' and tk.Entry or ttk.Entry):
ttk.Entry.__init__(self, master, **kw)
class Button(sys.platform == 'darwin' and tk.Button or ttk.Button):
class Button(sys.platform == 'darwin' and tk.Button or ttk.Button): # type: ignore
"""Custom t(t)k.Button class to fix some display issues."""
def __init__(self, master=None, **kw):
def __init__(self, master: Optional[ttk.Frame] = None, **kw):
if sys.platform == 'darwin':
kw['highlightbackground'] = kw.pop('highlightbackground', PAGEBG)
tk.Button.__init__(self, master, **kw)
@ -96,9 +109,10 @@ class Button(sys.platform == 'darwin' and tk.Button or ttk.Button):
ttk.Button.__init__(self, master, **kw)
class ColoredButton(sys.platform == 'darwin' and tk.Label or tk.Button):
class ColoredButton(sys.platform == 'darwin' and tk.Label or tk.Button): # type: ignore
"""Custom t(t)k.ColoredButton class to fix some display issues."""
def __init__(self, master=None, **kw):
def __init__(self, master: Optional[ttk.Frame] = None, **kw):
if sys.platform == 'darwin':
# Can't set Button background on OSX, so use a Label instead
kw['relief'] = kw.pop('relief', tk.RAISED)
@ -113,9 +127,10 @@ class ColoredButton(sys.platform == 'darwin' and tk.Label or tk.Button):
self._command()
class Checkbutton(sys.platform == 'darwin' and tk.Checkbutton or ttk.Checkbutton):
class Checkbutton(sys.platform == 'darwin' and tk.Checkbutton or ttk.Checkbutton): # type: ignore
"""Custom t(t)k.Checkbutton class to fix some display issues."""
def __init__(self, master=None, **kw):
def __init__(self, master: Optional[ttk.Frame] = None, **kw):
if sys.platform == 'darwin':
kw['foreground'] = kw.pop('foreground', PAGEFG)
kw['background'] = kw.pop('background', PAGEBG)
@ -126,9 +141,10 @@ class Checkbutton(sys.platform == 'darwin' and tk.Checkbutton or ttk.Checkbutton
ttk.Checkbutton.__init__(self, master, **kw)
class Radiobutton(sys.platform == 'darwin' and tk.Radiobutton or ttk.Radiobutton):
class Radiobutton(sys.platform == 'darwin' and tk.Radiobutton or ttk.Radiobutton): # type: ignore
"""Custom t(t)k.Radiobutton class to fix some display issues."""
def __init__(self, master=None, **kw):
def __init__(self, master: Optional[ttk.Frame] = None, **kw):
if sys.platform == 'darwin':
kw['foreground'] = kw.pop('foreground', PAGEFG)
kw['background'] = kw.pop('background', PAGEBG)
@ -139,7 +155,8 @@ class Radiobutton(sys.platform == 'darwin' and tk.Radiobutton or ttk.Radiobutton
ttk.Radiobutton.__init__(self, master, **kw)
class OptionMenu(sys.platform == 'darwin' and tk.OptionMenu or ttk.OptionMenu):
class OptionMenu(sys.platform == 'darwin' and tk.OptionMenu or ttk.OptionMenu): # type: ignore
"""Custom t(t)k.OptionMenu class to fix some display issues."""
def __init__(self, master, variable, default=None, *values, **kw):
if sys.platform == 'darwin':

187
plug.py
View File

@ -1,6 +1,4 @@
"""
Plugin hooks for EDMC - Ian Norton, Jonathan Harris
"""
"""Plugin API."""
import copy
import importlib
import logging
@ -9,8 +7,9 @@ import os
import sys
import tkinter as tk
from builtins import object, str
from typing import Optional
from typing import Any, Callable, List, Mapping, MutableMapping, Optional, Tuple
import companion
import myNotebook as nb # noqa: N813
from config import config
from EDMCLogging import get_main_logger
@ -21,34 +20,48 @@ logger = get_main_logger()
PLUGINS = []
PLUGINS_not_py3 = []
# For asynchronous error display
last_error = {
'msg': None,
'root': None,
}
class LastError:
"""Holds the last plugin error."""
msg: Optional[str]
root: tk.Frame
def __init__(self) -> None:
self.msg = None
last_error = LastError()
class Plugin(object):
"""An EDMC plugin."""
def __init__(self, name: str, loadfile: str, plugin_logger: Optional[logging.Logger]):
def __init__(self, name: str, loadfile: Optional[str], plugin_logger: Optional[logging.Logger]):
"""
Load a single plugin
:param name: module name
:param loadfile: the main .py file
Load a single plugin.
:param name: Base name of the file being loaded from.
:param loadfile: Full path/filename of the plugin.
:param plugin_logger: The logging instance for this plugin to use.
:raises Exception: Typically ImportError or OSError
"""
self.name = name # Display name.
self.folder = name # basename of plugin folder. None for internal plugins.
self.name: str = name # Display name.
self.folder: Optional[str] = name # basename of plugin folder. None for internal plugins.
self.module = None # None for disabled plugins.
self.logger = plugin_logger
self.logger: Optional[logging.Logger] = plugin_logger
if loadfile:
logger.info(f'loading plugin "{name.replace(".", "_")}" from "{loadfile}"')
try:
module = importlib.machinery.SourceFileLoader('plugin_{}'.format(
name.encode(encoding='ascii', errors='replace').decode('utf-8').replace('.', '_')),
loadfile).load_module()
filename = 'plugin_'
filename += name.encode(encoding='ascii', errors='replace').decode('utf-8').replace('.', '_')
module = importlib.machinery.SourceFileLoader(
filename,
loadfile
).load_module()
if getattr(module, 'plugin_start3', None):
newname = module.plugin_start3(os.path.dirname(loadfile))
self.name = newname and str(newname) or name
@ -58,23 +71,25 @@ class Plugin(object):
PLUGINS_not_py3.append(self)
else:
logger.error(f'plugin {name} has no plugin_start3() function')
except Exception as e:
except Exception:
logger.exception(f': Failed for Plugin "{name}"')
raise
else:
logger.info(f'plugin {name} disabled')
def _get_func(self, funcname):
def _get_func(self, funcname: str) -> Optional[Callable]:
"""
Get a function from a plugin
Get a function from a plugin.
:param funcname:
:returns: The function, or None if it isn't implemented.
"""
return getattr(self.module, funcname, None)
def get_app(self, parent):
def get_app(self, parent: tk.Frame) -> Optional[tk.Frame]:
"""
If the plugin provides mainwindow content create and return it.
:param parent: the parent frame for this entry.
:returns: None, a tk Widget, or a pair of tk.Widgets
"""
@ -84,19 +99,29 @@ class Plugin(object):
appitem = plugin_app(parent)
if appitem is None:
return None
elif isinstance(appitem, tuple):
if len(appitem) != 2 or not isinstance(appitem[0], tk.Widget) or not isinstance(appitem[1], tk.Widget):
if (
len(appitem) != 2
or not isinstance(appitem[0], tk.Widget)
or not isinstance(appitem[1], tk.Widget)
):
raise AssertionError
elif not isinstance(appitem, tk.Widget):
raise AssertionError
return appitem
except Exception as e:
except Exception:
logger.exception(f'Failed for Plugin "{self.name}"')
return None
def get_prefs(self, parent, cmdr, is_beta):
def get_prefs(self, parent: tk.Frame, cmdr: str, is_beta: bool) -> Optional[tk.Frame]:
"""
If the plugin provides a prefs frame, create and return it.
:param parent: the parent frame for this preference tab.
:param cmdr: current Cmdr name (or None). Relevant if you want to have
different settings for different user accounts.
@ -110,16 +135,14 @@ class Plugin(object):
if not isinstance(frame, nb.Frame):
raise AssertionError
return frame
except Exception as e:
except Exception:
logger.exception(f'Failed for Plugin "{self.name}"')
return None
def load_plugins(master):
"""
Find and load all plugins
"""
last_error['root'] = master
def load_plugins(master: tk.Frame) -> None: # noqa: CCR001
"""Find and load all plugins."""
last_error.root = master
internal = []
for name in sorted(os.listdir(config.internal_plugin_dir_path)):
@ -128,7 +151,7 @@ def load_plugins(master):
plugin = Plugin(name[:-3], os.path.join(config.internal_plugin_dir_path, name), logger)
plugin.folder = None # Suppress listing in Plugins prefs tab
internal.append(plugin)
except Exception as e:
except Exception:
logger.exception(f'Failure loading internal Plugin "{name}"')
PLUGINS.extend(sorted(internal, key=lambda p: operator.attrgetter('name')(p).lower()))
@ -137,8 +160,10 @@ def load_plugins(master):
found = []
# Load any plugins that are also packages first
for name in sorted(os.listdir(config.plugin_dir_path),
key=lambda n: (not os.path.isfile(os.path.join(config.plugin_dir_path, n, '__init__.py')), n.lower())):
for name in sorted(
os.listdir(config.plugin_dir_path),
key=lambda n: (not os.path.isfile(os.path.join(config.plugin_dir_path, n, '__init__.py')), n.lower())
):
if not os.path.isdir(os.path.join(config.plugin_dir_path, name)) or name[0] in ['.', '_']:
pass
elif name.endswith('.disabled'):
@ -155,15 +180,16 @@ def load_plugins(master):
plugin_logger = EDMCLogging.get_plugin_logger(name)
found.append(Plugin(name, os.path.join(config.plugin_dir_path, name, 'load.py'), plugin_logger))
except Exception as e:
except Exception:
logger.exception(f'Failure loading found Plugin "{name}"')
pass
PLUGINS.extend(sorted(found, key=lambda p: operator.attrgetter('name')(p).lower()))
def provides(fn_name):
def provides(fn_name: str) -> List[str]:
"""
Find plugins that provide a function
Find plugins that provide a function.
:param fn_name:
:returns: list of names of plugins that provide this function
.. versionadded:: 3.0.2
@ -171,9 +197,12 @@ def provides(fn_name):
return [p.name for p in PLUGINS if p._get_func(fn_name)]
def invoke(plugin_name, fallback, fn_name, *args):
def invoke(
plugin_name: str, fallback: str, fn_name: str, *args: Tuple
) -> Optional[str]:
"""
Invoke a function on a named plugin
Invoke a function on a named plugin.
:param plugin_name: preferred plugin on which to invoke the function
:param fallback: fallback plugin on which to invoke the function, or None
:param fn_name:
@ -182,17 +211,24 @@ def invoke(plugin_name, fallback, fn_name, *args):
.. versionadded:: 3.0.2
"""
for plugin in PLUGINS:
if plugin.name == plugin_name and plugin._get_func(fn_name):
return plugin._get_func(fn_name)(*args)
if plugin.name == plugin_name:
plugin_func = plugin._get_func(fn_name)
if plugin_func is not None:
return plugin_func(*args)
for plugin in PLUGINS:
if plugin.name == fallback:
assert plugin._get_func(fn_name), plugin.name # fallback plugin should provide the function
return plugin._get_func(fn_name)(*args)
plugin_func = plugin._get_func(fn_name)
assert plugin_func, plugin.name # fallback plugin should provide the function
return plugin_func(*args)
return None
def notify_stop():
def notify_stop() -> Optional[str]:
"""
Notify each plugin that the program is closing.
If your plugin uses threads then stop and join() them before returning.
.. versionadded:: 2.3.7
"""
@ -204,7 +240,7 @@ def notify_stop():
logger.info(f'Asking plugin "{plugin.name}" to stop...')
newerror = plugin_stop()
error = error or newerror
except Exception as e:
except Exception:
logger.exception(f'Plugin "{plugin.name}" failed')
logger.info('Done')
@ -212,9 +248,10 @@ def notify_stop():
return error
def notify_prefs_cmdr_changed(cmdr, is_beta):
def notify_prefs_cmdr_changed(cmdr: str, is_beta: bool) -> None:
"""
Notify each plugin that the Cmdr has been changed while the settings dialog is open.
Notify plugins that the Cmdr was changed while the settings dialog is open.
Relevant if you want to have different settings for different user accounts.
:param cmdr: current Cmdr name (or None).
:param is_beta: whether the player is in a Beta universe.
@ -224,13 +261,14 @@ def notify_prefs_cmdr_changed(cmdr, is_beta):
if prefs_cmdr_changed:
try:
prefs_cmdr_changed(cmdr, is_beta)
except Exception as e:
except Exception:
logger.exception(f'Plugin "{plugin.name}" failed')
def notify_prefs_changed(cmdr, is_beta):
def notify_prefs_changed(cmdr: str, is_beta: bool) -> None:
"""
Notify each plugin that the settings dialog has been closed.
Notify plugins that the settings dialog has been closed.
The prefs frame and any widgets you created in your `get_prefs()` callback
will be destroyed on return from this function, so take a copy of any
values that you want to save.
@ -242,13 +280,18 @@ def notify_prefs_changed(cmdr, is_beta):
if prefs_changed:
try:
prefs_changed(cmdr, is_beta)
except Exception as e:
except Exception:
logger.exception(f'Plugin "{plugin.name}" failed')
def notify_journal_entry(cmdr, is_beta, system, station, entry, state):
def notify_journal_entry(
cmdr: str, is_beta: bool, system: str, station: str,
entry: MutableMapping[str, Any],
state: Mapping[str, Any]
) -> Optional[str]:
"""
Send a journal entry to each plugin.
:param cmdr: The Cmdr name, or None if not yet known
:param system: The current system, or None if not yet known
:param station: The current station, or None if not docked or not yet known
@ -268,21 +311,25 @@ def notify_journal_entry(cmdr, is_beta, system, station, entry, state):
# Pass a copy of the journal entry in case the callee modifies it
newerror = journal_entry(cmdr, is_beta, system, station, dict(entry), dict(state))
error = error or newerror
except Exception as e:
except Exception:
logger.exception(f'Plugin "{plugin.name}" failed')
return error
def notify_journal_entry_cqc(cmdr, is_beta, entry, state):
def notify_journal_entry_cqc(
cmdr: str, is_beta: bool,
entry: MutableMapping[str, Any],
state: Mapping[str, Any]
) -> Optional[str]:
"""
Send a journal entry to each plugin.
Send an in-CQC journal entry to each plugin.
:param cmdr: The Cmdr name, or None if not yet known
:param entry: The journal entry as a dictionary
:param state: A dictionary containing info about the Cmdr, current ship and cargo
:param is_beta: whether the player is in a Beta universe.
:returns: Error message from the first plugin that returns one (if any)
"""
error = None
for plugin in PLUGINS:
cqc_callback = plugin._get_func('journal_entry_cqc')
@ -298,9 +345,13 @@ def notify_journal_entry_cqc(cmdr, is_beta, entry, state):
return error
def notify_dashboard_entry(cmdr, is_beta, entry):
def notify_dashboard_entry(
cmdr: str, is_beta: bool,
entry: MutableMapping[str, Any],
) -> Optional[str]:
"""
Send a status entry to each plugin.
:param cmdr: The piloting Cmdr name
:param is_beta: whether the player is in a Beta universe.
:param entry: The status entry as a dictionary
@ -314,14 +365,18 @@ def notify_dashboard_entry(cmdr, is_beta, entry):
# Pass a copy of the status entry in case the callee modifies it
newerror = status(cmdr, is_beta, dict(entry))
error = error or newerror
except Exception as e:
except Exception:
logger.exception(f'Plugin "{plugin.name}" failed')
return error
def notify_newdata(data, is_beta):
def notify_newdata(
data: companion.CAPIData,
is_beta: bool
) -> Optional[str]:
"""
Send the latest EDMC data from the FD servers to each plugin
Send the latest EDMC data from the FD servers to each plugin.
:param data:
:param is_beta: whether the player is in a Beta universe.
:returns: Error message from the first plugin that returns one (if any)
@ -333,12 +388,12 @@ def notify_newdata(data, is_beta):
try:
newerror = cmdr_data(data, is_beta)
error = error or newerror
except Exception as e:
except Exception:
logger.exception(f'Plugin "{plugin.name}" failed')
return error
def show_error(err):
def show_error(err: str) -> None:
"""
Display an error message in the status line of the main window.
@ -350,6 +405,6 @@ def show_error(err):
logger.info(f'Called during shutdown: "{str(err)}"')
return
if err and last_error['root']:
last_error['msg'] = str(err)
last_error['root'].event_generate('<<PluginError>>', when="tail")
if err and last_error.root:
last_error.msg = str(err)
last_error.root.event_generate('<<PluginError>>', when="tail")

View File

@ -1,8 +1,4 @@
# -*- coding: utf-8 -*-
#
# Station display and eddb.io lookup
#
"""Station display and eddb.io lookup."""
# Tests:
#
# As there's a lot of state tracking in here, need to ensure (at least)
@ -38,15 +34,15 @@
# Thus you **MUST** check if any imports you add in this file are only
# referenced in this file (or only in any other core plugin), and if so...
#
# YOU MUST ENSURE THAT PERTINENT ADJUSTMENTS ARE MADE IN `setup.py`
# SO AS TO ENSURE THE FILES ARE ACTUALLY PRESENT IN AN END-USER
# INSTALLATION ON WINDOWS.
# YOU MUST ENSURE THAT PERTINENT ADJUSTMENTS ARE MADE IN
# `Build-exe-and-msi.py` SO AS TO ENSURE THE FILES ARE ACTUALLY PRESENT IN
# AN END-USER INSTALLATION ON WINDOWS.
#
#
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
import sys
from typing import TYPE_CHECKING, Any, Optional
import tkinter
from typing import TYPE_CHECKING, Any, Mapping, MutableMapping, Optional
import requests
@ -59,26 +55,40 @@ from config import config
if TYPE_CHECKING:
from tkinter import Tk
def _(x: str) -> str:
return x
logger = EDMCLogging.get_main_logger()
class This:
"""Holds module globals."""
STATION_UNDOCKED: str = '×' # "Station" name to display when not docked = U+00D7
this: Any = sys.modules[__name__] # For holding module globals
def __init__(self) -> None:
# Main window clicks
this.system_link: Optional[str] = None
this.system: Optional[str] = None
this.system_address: Optional[str] = None
this.system_population: Optional[int] = None
this.station_link: 'Optional[Tk]' = None
this.station: Optional[str] = None
this.station_marketid: Optional[int] = None
this.on_foot = False
self.system_link: tkinter.Widget
self.system: Optional[str] = None
self.system_address: Optional[str] = None
self.system_population: Optional[int] = None
self.station_link: tkinter.Widget
self.station: Optional[str] = None
self.station_marketid: Optional[int] = None
self.on_foot = False
this = This()
def system_url(system_name: str) -> str:
"""
Construct an appropriate EDDB.IO URL for the provided system.
:param system_name: Will be overridden with `this.system_address` if that
is set.
:return: The URL, empty if no data was available to construct it.
"""
if this.system_address:
return requests.utils.requote_uri(f'https://eddb.io/system/ed-address/{this.system_address}')
@ -89,33 +99,75 @@ def system_url(system_name: str) -> str:
def station_url(system_name: str, station_name: str) -> str:
"""
Construct an appropriate EDDB.IO URL for a station.
Ignores `station_name` in favour of `this.station_marketid`.
:param system_name: Name of the system the station is in.
:param station_name: **NOT USED**
:return: The URL, empty if no data was available to construct it.
"""
if this.station_marketid:
return requests.utils.requote_uri(f'https://eddb.io/station/market-id/{this.station_marketid}')
return system_url(system_name)
def plugin_start3(plugin_dir):
def plugin_start3(plugin_dir: str) -> str:
"""
Start the plugin.
:param plugin_dir: NAme of directory this was loaded from.
:return: Identifier string for this plugin.
"""
return 'eddb'
def plugin_app(parent: 'Tk'):
"""
Construct this plugin's main UI, if any.
:param parent: The tk parent to place our widgets into.
:return: See PLUGINS.md#display
"""
this.system_link = parent.children['system'] # system label in main window
this.system = None
this.system_address = None
this.station = None
this.station_marketid = None # Frontier MarketID
this.station_link = parent.children['station'] # station label in main window
this.station_link.configure(popup_copy=lambda x: x != STATION_UNDOCKED)
this.station_link['popup_copy'] = lambda x: x != this.STATION_UNDOCKED
def prefs_changed(cmdr, is_beta):
def prefs_changed(cmdr: str, is_beta: bool) -> None:
"""
Update any saved configuration after Settings is closed.
:param cmdr: Name of Commander.
:param is_beta: If game beta was detected.
"""
# Do *NOT* set 'url' here, as it's set to a function that will call
# through correctly. We don't want a static string.
pass
def journal_entry(cmdr, is_beta, system, station, entry, state):
def journal_entry( # noqa: CCR001
cmdr: str, is_beta: bool, system: str, station: str,
entry: MutableMapping[str, Any],
state: Mapping[str, Any]
):
"""
Handle a new Journal event.
:param cmdr: Name of Commander.
:param is_beta: Whether game beta was detected.
:param system: Name of current tracked system.
:param station: Name of current tracked station location.
:param entry: The journal event.
:param state: `monitor.state`
:return: None if no error, else an error string.
"""
should_return, new_entry = killswitch.check_killswitch('plugins.eddb.journal', entry)
if should_return:
# LANG: Journal Processing disabled due to an active killswitch
@ -168,7 +220,7 @@ def journal_entry(cmdr, is_beta, system, station, entry, state):
text = this.station
if not text:
if this.system_population is not None and this.system_population > 0:
text = STATION_UNDOCKED
text = this.STATION_UNDOCKED
else:
text = ''
@ -179,7 +231,14 @@ def journal_entry(cmdr, is_beta, system, station, entry, state):
this.station_link.update_idletasks()
def cmdr_data(data: CAPIData, is_beta):
def cmdr_data(data: CAPIData, is_beta: bool) -> Optional[str]:
"""
Process new CAPI data.
:param data: The latest merged CAPI data.
:param is_beta: Whether game beta was detected.
:return: Optional error string.
"""
# Always store initially, even if we're not the *current* system provider.
if not this.station_marketid and data['commander']['docked']:
this.station_marketid = data['lastStarport']['id']
@ -203,7 +262,7 @@ def cmdr_data(data: CAPIData, is_beta):
this.station_link['text'] = this.station
elif data['lastStarport']['name'] and data['lastStarport']['name'] != "":
this.station_link['text'] = STATION_UNDOCKED
this.station_link['text'] = this.STATION_UNDOCKED
else:
this.station_link['text'] = ''
@ -211,3 +270,5 @@ def cmdr_data(data: CAPIData, is_beta):
# Do *NOT* set 'url' here, as it's set to a function that will call
# through correctly. We don't want a static string.
this.station_link.update_idletasks()
return ''

View File

@ -1,4 +1,4 @@
"""System display and EDSM lookup."""
"""Show EDSM data in display and handle lookups."""
# TODO:
# 1) Re-factor EDSM API calls out of journal_entry() into own function.
@ -24,9 +24,9 @@
# Thus you **MUST** check if any imports you add in this file are only
# referenced in this file (or only in any other core plugin), and if so...
#
# YOU MUST ENSURE THAT PERTINENT ADJUSTMENTS ARE MADE IN `setup.py`
# SO AS TO ENSURE THE FILES ARE ACTUALLY PRESENT IN AN END-USER
# INSTALLATION ON WINDOWS.
# YOU MUST ENSURE THAT PERTINENT ADJUSTMENTS ARE MADE IN
# `Build-exe-and-msi.py` SO AS TO ENSURE THE FILES ARE ACTUALLY PRESENT IN
# AN END-USER INSTALLATION ON WINDOWS.
#
#
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
@ -154,7 +154,13 @@ plEAADs=
# Main window clicks
def system_url(system_name: str) -> str:
"""Get a URL for the current system."""
"""
Construct an appropriate EDSM URL for the provided system.
:param system_name: Will be overridden with `this.system_address` if that
is set.
:return: The URL, empty if no data was available to construct it.
"""
if this.system_address:
return requests.utils.requote_uri(f'https://www.edsm.net/en/system?systemID64={this.system_address}')
@ -165,7 +171,13 @@ def system_url(system_name: str) -> str:
def station_url(system_name: str, station_name: str) -> str:
"""Get a URL for the current station."""
"""
Construct an appropriate EDSM URL for a station.
:param system_name: Name of the system the station is in.
:param station_name: Name of the station.
:return: The URL, empty if no data was available to construct it.
"""
if system_name and station_name:
return requests.utils.requote_uri(
f'https://www.edsm.net/en/system?systemName={system_name}&stationName={station_name}'
@ -186,7 +198,12 @@ def station_url(system_name: str, station_name: str) -> str:
def plugin_start3(plugin_dir: str) -> str:
"""Plugin setup hook."""
"""
Start the plugin.
:param plugin_dir: NAme of directory this was loaded from.
:return: Identifier string for this plugin.
"""
# Can't be earlier since can only call PhotoImage after window is created
this._IMG_KNOWN = tk.PhotoImage(data=IMG_KNOWN_B64) # green circle
this._IMG_UNKNOWN = tk.PhotoImage(data=IMG_UNKNOWN_B64) # red circle
@ -225,7 +242,12 @@ def plugin_start3(plugin_dir: str) -> str:
def plugin_app(parent: tk.Tk) -> None:
"""Plugin UI setup."""
"""
Construct this plugin's main UI, if any.
:param parent: The tk parent to place our widgets into.
:return: See PLUGINS.md#display
"""
this.system_link = parent.children['system'] # system label in main window
this.system_link.bind_all('<<EDSMStatus>>', update_status)
this.station_link = parent.children['station'] # station label in main window
@ -246,7 +268,17 @@ def plugin_stop() -> None:
def plugin_prefs(parent: tk.Tk, cmdr: str, is_beta: bool) -> tk.Frame:
"""Plugin preferences setup hook."""
"""
Plugin preferences setup hook.
Any tkinter UI set up *must* be within an instance of `myNotebook.Frame`,
which is the return value of this function.
:param parent: tkinter Widget to place items in.
:param cmdr: Name of Commander.
:param is_beta: Whether game beta was detected.
:return: An instance of `myNotebook.Frame`.
"""
PADX = 10 # noqa: N806
BUTTONX = 12 # indent Checkbuttons and Radiobuttons # noqa: N806
PADY = 2 # close spacing # noqa: N806
@ -313,7 +345,12 @@ def plugin_prefs(parent: tk.Tk, cmdr: str, is_beta: bool) -> tk.Frame:
def prefs_cmdr_changed(cmdr: str, is_beta: bool) -> None:
"""Commanders changed hook."""
"""
Handle the Commander name changing whilst Settings was open.
:param cmdr: The new current Commander name.
:param is_beta: Whether game beta was detected.
"""
this.log_button['state'] = tk.NORMAL if cmdr and not is_beta else tk.DISABLED
this.user['state'] = tk.NORMAL
this.user.delete(0, tk.END)
@ -339,7 +376,7 @@ def prefs_cmdr_changed(cmdr: str, is_beta: bool) -> None:
def prefsvarchanged() -> None:
"""Preferences screen closed hook."""
"""Handle the 'Send data to EDSM' tickbox changing state."""
to_set = tk.DISABLED
if this.log.get():
to_set = this.log_button['state']
@ -363,7 +400,12 @@ def set_prefs_ui_states(state: str) -> None:
def prefs_changed(cmdr: str, is_beta: bool) -> None:
"""Preferences changed hook."""
"""
Handle any changes to Settings once the dialog is closed.
:param cmdr: Name of Commander.
:param is_beta: Whether game beta was detected.
"""
config.set('edsm_out', this.log.get())
if cmdr and not is_beta:
@ -427,15 +469,15 @@ def journal_entry( # noqa: C901, CCR001
cmdr: str, is_beta: bool, system: str, station: str, entry: MutableMapping[str, Any], state: Mapping[str, Any]
) -> str:
"""
Process a Journal event.
Handle a new Journal event.
:param cmdr:
:param is_beta:
:param system:
:param station:
:param entry:
:param state:
:return: str - empty if no error, else error string.
:param cmdr: Name of Commander.
:param is_beta: Whether game beta was detected.
:param system: Name of current tracked system.
:param station: Name of current tracked station location.
:param entry: The journal event.
:param state: `monitor.state`
:return: None if no error, else an error string.
"""
should_return, new_entry = killswitch.check_killswitch('plugins.edsm.journal', entry, logger)
if should_return:
@ -597,8 +639,14 @@ Queueing: {entry!r}'''
# Update system data
def cmdr_data(data: CAPIData, is_beta: bool) -> None:
"""CAPI Entry Hook."""
def cmdr_data(data: CAPIData, is_beta: bool) -> Optional[str]:
"""
Process new CAPI data.
:param data: The latest merged CAPI data.
:param is_beta: Whether game beta was detected.
:return: Optional error string.
"""
system = data['lastSystem']['name']
# Always store initially, even if we're not the *current* system provider.
@ -640,6 +688,8 @@ def cmdr_data(data: CAPIData, is_beta: bool) -> None:
this.system_link['image'] = ''
this.system_link.update_idletasks()
return ''
TARGET_URL = 'https://www.edsm.net/api-journal-v1'
if 'edsm' in debug_senders:

View File

@ -1,4 +1,4 @@
# EDShipyard ship export
"""Export data for ED Shipyard."""
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
@ -15,25 +15,41 @@
# Thus you **MUST** check if any imports you add in this file are only
# referenced in this file (or only in any other core plugin), and if so...
#
# YOU MUST ENSURE THAT PERTINENT ADJUSTMENTS ARE MADE IN `setup.py`
# SO AS TO ENSURE THE FILES ARE ACTUALLY PRESENT IN AN END-USER
# INSTALLATION ON WINDOWS.
# YOU MUST ENSURE THAT PERTINENT ADJUSTMENTS ARE MADE IN
# `Build-exe-and-msi.py` SO AS TO ENSURE THE FILES ARE ACTUALLY PRESENT IN
# AN END-USER INSTALLATION ON WINDOWS.
#
#
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
import base64
import gzip
import json
import io
import json
from typing import Any, Mapping
def plugin_start3(plugin_dir):
def plugin_start3(plugin_dir: str) -> str:
"""
Start the plugin.
:param plugin_dir: NAme of directory this was loaded from.
:return: Identifier string for this plugin.
"""
return 'EDSY'
# Return a URL for the current ship
def shipyard_url(loadout, is_beta):
string = json.dumps(loadout, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode('utf-8') # most compact representation
def shipyard_url(loadout: Mapping[str, Any], is_beta) -> bool | str:
"""
Construct a URL for ship loadout.
:param loadout:
:param is_beta:
:return:
"""
# most compact representation
string = json.dumps(loadout, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode('utf-8')
if not string:
return False
@ -41,4 +57,6 @@ def shipyard_url(loadout, is_beta):
with gzip.GzipFile(fileobj=out, mode='w') as f:
f.write(string)
return (is_beta and 'http://edsy.org/beta/#/I=' or 'http://edsy.org/#/I=') + base64.urlsafe_b64encode(out.getvalue()).decode().replace('=', '%3D')
return (
is_beta and 'http://edsy.org/beta/#/I=' or 'http://edsy.org/#/I='
) + base64.urlsafe_b64encode(out.getvalue()).decode().replace('=', '%3D')

View File

@ -1,5 +1,4 @@
"""protocol handler for cAPI authorisation."""
# spell-checker: words ntdll GURL alloc wfile instantiatable pyright
import os
import sys
@ -35,7 +34,7 @@ class GenericProtocolHandler:
def __init__(self) -> None:
self.redirect = protocolhandler_redirect # Base redirection URL
self.master: 'tkinter.Tk' = None # type: ignore
self.lastpayload = None
self.lastpayload: Optional[str] = None
def start(self, master: 'tkinter.Tk') -> None:
"""Start Protocol Handler."""
@ -45,7 +44,7 @@ class GenericProtocolHandler:
"""Stop / Close Protocol Handler."""
pass
def event(self, url) -> None:
def event(self, url: str) -> None:
"""Generate an auth event."""
self.lastpayload = url
@ -107,11 +106,12 @@ if sys.platform == 'darwin' and getattr(sys, 'frozen', False): # noqa: C901 # i
def handleEvent_withReplyEvent_(self, event, replyEvent) -> None: # noqa: N802 N803 # Required to override
"""Actual event handling from NSAppleEventManager."""
protocolhandler.lasturl = urllib.parse.unquote( # type: ignore # Its going to be a DPH in this code
protocolhandler.lasturl = urllib.parse.unquote( # noqa: F821: type: ignore # Its going to be a DPH in
# this code
event.paramDescriptorForKeyword_(keyDirectObject).stringValue()
).strip()
protocolhandler.master.after(DarwinProtocolHandler.POLL, protocolhandler.poll) # type: ignore
protocolhandler.master.after(DarwinProtocolHandler.POLL, protocolhandler.poll) # noqa: F821: type: ignore
elif (config.auth_force_edmc_protocol
@ -196,7 +196,7 @@ elif (config.auth_force_edmc_protocol
# Windows Message handler stuff (IPC)
# https://docs.microsoft.com/en-us/previous-versions/windows/desktop/legacy/ms633573(v=vs.85)
@WINFUNCTYPE(c_long, HWND, UINT, WPARAM, LPARAM)
def WndProc(hwnd: HWND, message: UINT, wParam, lParam): # noqa: N803 N802
def WndProc(hwnd: HWND, message: UINT, wParam: WPARAM, lParam: LPARAM) -> c_long: # noqa: N803 N802
"""
Deal with DDE requests.
@ -215,8 +215,10 @@ elif (config.auth_force_edmc_protocol
topic = create_unicode_buffer(256)
# Note that lParam is 32 bits, and broken into two 16 bit words. This will break on 64bit as the math is
# wrong
lparam_low = lParam & 0xFFFF # if nonzero, the target application for which a conversation is requested
lparam_high = lParam >> 16 # if nonzero, the topic of said conversation
# if nonzero, the target application for which a conversation is requested
lparam_low = lParam & 0xFFFF # type: ignore
# if nonzero, the topic of said conversation
lparam_high = lParam >> 16 # type: ignore
# if either of the words are nonzero, they contain
# atoms https://docs.microsoft.com/en-us/windows/win32/dataxchg/about-atom-tables
@ -236,7 +238,10 @@ elif (config.auth_force_edmc_protocol
wParam, WM_DDE_ACK, hwnd, PackDDElParam(WM_DDE_ACK, GlobalAddAtomW(appname), GlobalAddAtomW('System'))
)
return 0
# It works as a constructor as per <https://docs.python.org/3/library/ctypes.html#fundamental-data-types>
return c_long(0)
return c_long(1) # This is an utter guess -Ath
class WindowsProtocolHandler(GenericProtocolHandler):
"""
@ -349,7 +354,7 @@ else: # Linux / Run from source
self.thread: Optional[threading.Thread] = None
def start(self, master) -> None:
def start(self, master: 'tkinter.Tk') -> None:
"""Start the HTTP server thread."""
GenericProtocolHandler.start(self, master)
self.thread = threading.Thread(target=self.worker, name='OAuth worker')
@ -389,7 +394,7 @@ else: # Linux / Run from source
url = urllib.parse.unquote(self.path)
if url.startswith('/auth'):
logger.debug('Request starts with /auth, sending to protocolhandler.event()')
protocolhandler.event(url)
protocolhandler.event(url) # noqa: F821
self.send_response(200)
return True
else:
@ -411,7 +416,7 @@ else: # Linux / Run from source
else:
self.end_headers()
def log_request(self, code, size=None):
def log_request(self, code: int | str = '-', size: int | str = '-') -> None:
"""Override to prevent logging."""
pass

View File

@ -29,7 +29,7 @@ KNOWN_KILLSWITCH_NAMES: list[str] = [
'plugins.eddb.journal.event.$event'
]
SPLIT_KNOWN_NAMES = list(map(lambda x: x.split('.'), KNOWN_KILLSWITCH_NAMES))
SPLIT_KNOWN_NAMES = [x.split('.') for x in KNOWN_KILLSWITCH_NAMES]
def match_exists(match: str) -> tuple[bool, str]:

View File

@ -1,24 +1,35 @@
# Export list of ships as CSV
"""Export list of ships as CSV."""
import csv
import time
from config import config
import companion
from edmc_data import ship_name_map
def export(data, filename):
querytime = config.get_int('querytime', default=int(time.time()))
def export(data: companion.CAPIData, filename: str) -> None:
"""
Write shipyard data in Companion API JSON format.
:param data: The CAPI data.
:param filename: Optional filename to write to.
:return:
"""
assert data['lastSystem'].get('name')
assert data['lastStarport'].get('name')
assert data['lastStarport'].get('ships')
header = 'System,Station,Ship,FDevID,Date\n'
rowheader = '%s,%s' % (data['lastSystem']['name'], data['lastStarport']['name'])
with open(filename, 'w', newline='') as f:
c = csv.writer(f)
c.writerow(('System', 'Station', 'Ship', 'FDevID', 'Date'))
h = open(filename, 'wt')
h.write(header)
for (name,fdevid) in [(ship_name_map.get(ship['name'].lower(), ship['name']), ship['id']) for ship in list((data['lastStarport']['ships'].get('shipyard_list') or {}).values()) + data['lastStarport']['ships'].get('unavailable_list')]:
h.write('%s,%s,%s,%s\n' % (rowheader, name, fdevid, data['timestamp']))
h.close()
for (name, fdevid) in [
(
ship_name_map.get(ship['name'].lower(), ship['name']),
ship['id']
) for ship in list(
(data['lastStarport']['ships'].get('shipyard_list') or {}).values()
) + data['lastStarport']['ships'].get('unavailable_list')
]:
c.writerow((
data['lastSystem']['name'], data['lastStarport']['name'],
name, fdevid, data['timestamp']
))

83
td.py
View File

@ -1,12 +1,12 @@
# Export to Trade Dangerous
"""Export data for Trade Dangerous."""
import pathlib
import time
from collections import defaultdict
from operator import itemgetter
from os.path import join
from platform import system
from sys import platform
from companion import CAPIData
from config import applongname, appversion, config
# These are specific to Trade Dangerous, so don't move to edmc_data.py
@ -19,52 +19,47 @@ stockbracketmap = { 0: '-',
2: 'M',
3: 'H', }
def export(data):
querytime = config.get_int('querytime', default=int(time.time()))
#
# When this is refactored into multi-line CHECK IT WORKS, avoiding the
# brainfart we had with dangling commas in commodity.py:export() !!!
#
filename = join(config.get_str('outdir'), '%s.%s.%s.prices' % (data['lastSystem']['name'].strip(), data['lastStarport']['name'].strip(), time.strftime('%Y-%m-%dT%H.%M.%S', time.localtime(querytime))))
#
# When this is refactored into multi-line CHECK IT WORKS, avoiding the
# brainfart we had with dangling commas in commodity.py:export() !!!
#
timestamp = time.strftime('%Y-%m-%d %H:%M:%S', time.strptime(data['timestamp'], '%Y-%m-%dT%H:%M:%SZ'))
def export(data: CAPIData) -> None:
"""Export market data in TD format."""
data_path = pathlib.Path(config.get_str('outdir'))
timestamp = time.strftime('%Y-%m-%dT%H.%M.%S', time.strptime(data['timestamp'], '%Y-%m-%dT%H:%M:%SZ'))
data_filename = f"{data['lastSystem']['name'].strip()}.{data['lastStarport']['name'].strip()}.{timestamp}.prices"
# codecs can't automatically handle line endings, so encode manually where
# required
with open(data_path / data_filename, 'wb') as h:
# Format described here: https://bitbucket.org/kfsone/tradedangerous/wiki/Price%20Data
h = open(filename, 'wb') # codecs can't automatically handle line endings, so encode manually where required
h.write('#! trade.py import -\n# Created by {appname} {appversion} on {platform} for Cmdr {cmdr}.\n'
'#\n# <item name> <sellCR> <buyCR> <demand> <stock> <timestamp>\n\n'
'@ {system}/{starport}\n'.format(
appname=applongname,
appversion=appversion(),
platform=platform == 'darwin' and "Mac OS" or system(),
cmdr=data['commander']['name'].strip(),
system=data['lastSystem']['name'].strip(),
starport=data['lastStarport']['name'].strip()
).encode('utf-8'))
h.write('#! trade.py import -\n'.encode('utf-8'))
this_platform = 'darwin' and "Mac OS" or system()
cmdr_name = data['commander']['name'].strip()
h.write(
f'# Created by {applongname} {appversion()} on {this_platform} for Cmdr {cmdr_name}.\n'.encode('utf-8')
)
h.write(
'#\n# <item name> <sellCR> <buyCR> <demand> <stock> <timestamp>\n\n'.encode('utf-8')
)
system_name = data['lastSystem']['name'].strip()
starport_name = data['lastStarport']['name'].strip()
h.write(f'@ {system_name}/{starport_name}\n'.encode('utf-8'))
# sort commodities by category
bycategory = defaultdict(list)
by_category = defaultdict(list)
for commodity in data['lastStarport']['commodities']:
bycategory[commodity['categoryname']].append(commodity)
by_category[commodity['categoryname']].append(commodity)
for category in sorted(bycategory):
h.write(' + {}\n'.format(category).encode('utf-8'))
timestamp = time.strftime('%Y-%m-%d %H:%M:%S', time.strptime(data['timestamp'], '%Y-%m-%dT%H:%M:%SZ'))
for category in sorted(by_category):
h.write(f' + {format(category)}\n'.encode('utf-8'))
# corrections to commodity names can change the sort order
for commodity in sorted(bycategory[category], key=itemgetter('name')):
h.write(' {:<23} {:7d} {:7d} {:9}{:1} {:8}{:1} {}\n'.format(
commodity['name'],
int(commodity['sellPrice']),
int(commodity['buyPrice']),
int(commodity['demand']) if commodity['demandBracket'] else '',
demandbracketmap[commodity['demandBracket']],
int(commodity['stock']) if commodity['stockBracket'] else '',
stockbracketmap[commodity['stockBracket']],
timestamp).encode('utf-8'))
h.close()
for commodity in sorted(by_category[category], key=itemgetter('name')):
h.write(
f" {commodity['name']:<23}"
f" {int(commodity['sellPrice']):7d}"
f" {int(commodity['buyPrice']):7d}"
f" {int(commodity['demand']) if commodity['demandBracket'] else '':9}"
f"{demandbracketmap[commodity['demandBracket']]:1}"
f" {int(commodity['stock']) if commodity['stockBracket'] else '':8}"
f"{stockbracketmap[commodity['stockBracket']]:1}"
f" {timestamp}\n".encode('utf-8')
)

151
theme.py
View File

@ -1,23 +1,27 @@
#
# Theme support
#
# Because of various ttk limitations this app is an unholy mix of Tk and ttk widgets.
# So can't use ttk's theme support. So have to change colors manually.
#
"""
Theme support.
Because of various ttk limitations this app is an unholy mix of Tk and ttk widgets.
So can't use ttk's theme support. So have to change colors manually.
"""
import os
import sys
import tkinter as tk
from os.path import join
from tkinter import font as tkFont
from tkinter import font as tk_font
from tkinter import ttk
from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Set, Tuple
from config import config
from ttkHyperlinkLabel import HyperlinkLabel
from EDMCLogging import get_main_logger
from ttkHyperlinkLabel import HyperlinkLabel
logger = get_main_logger()
if TYPE_CHECKING:
def _(x: str) -> str: ...
if __debug__:
from traceback import print_exc
@ -29,7 +33,7 @@ if sys.platform == 'win32':
import ctypes
from ctypes.wintypes import DWORD, LPCVOID, LPCWSTR
AddFontResourceEx = ctypes.windll.gdi32.AddFontResourceExW
AddFontResourceEx.restypes = [LPCWSTR, DWORD, LPCVOID]
AddFontResourceEx.restypes = [LPCWSTR, DWORD, LPCVOID] # type: ignore
FR_PRIVATE = 0x10
FR_NOT_ENUM = 0x20
AddFontResourceEx(join(config.respath, u'EUROCAPS.TTF'), FR_PRIVATE, 0)
@ -65,6 +69,8 @@ elif sys.platform == 'linux':
MWM_DECOR_MAXIMIZE = 1 << 6
class MotifWmHints(Structure):
"""MotifWmHints structure."""
_fields_ = [
('flags', c_ulong),
('functions', c_ulong),
@ -99,33 +105,44 @@ elif sys.platform == 'linux':
raise Exception("Can't find your display, can't continue")
motif_wm_hints_property = XInternAtom(dpy, b'_MOTIF_WM_HINTS', False)
motif_wm_hints_normal = MotifWmHints(MWM_HINTS_FUNCTIONS | MWM_HINTS_DECORATIONS,
motif_wm_hints_normal = MotifWmHints(
MWM_HINTS_FUNCTIONS | MWM_HINTS_DECORATIONS,
MWM_FUNC_RESIZE | MWM_FUNC_MOVE | MWM_FUNC_MINIMIZE | MWM_FUNC_CLOSE,
MWM_DECOR_BORDER | MWM_DECOR_RESIZEH | MWM_DECOR_TITLE | MWM_DECOR_MENU | MWM_DECOR_MINIMIZE,
0, 0)
0, 0
)
motif_wm_hints_dark = MotifWmHints(MWM_HINTS_FUNCTIONS | MWM_HINTS_DECORATIONS,
MWM_FUNC_RESIZE | MWM_FUNC_MOVE | MWM_FUNC_MINIMIZE | MWM_FUNC_CLOSE,
0, 0, 0)
except:
except Exception:
if __debug__:
print_exc()
dpy = None
class _Theme(object):
def __init__(self):
# Enum ? Remember these are, probably, based on 'value' of a tk
# RadioButton set. Looking in prefs.py, they *appear* to be hard-coded
# there as well.
THEME_DEFAULT = 0
THEME_DARK = 1
THEME_TRANSPARENT = 2
def __init__(self) -> None:
self.active = None # Starts out with no theme
self.minwidth = None
self.widgets = {}
self.widgets_pair = []
self.defaults = {}
self.current = {}
self.minwidth: Optional[int] = None
self.widgets: Dict[tk.Widget, Set] = {}
self.widgets_pair: List = []
self.defaults: Dict = {}
self.current: Dict = {}
self.default_ui_scale = None # None == not yet known
self.startup_ui_scale = None
def register(self, widget):
# Note widget and children for later application of a theme. Note if the widget has explicit fg or bg attributes.
def register(self, widget: tk.Widget) -> None: # noqa: CCR001, C901
# Note widget and children for later application of a theme. Note if
# the widget has explicit fg or bg attributes.
assert isinstance(widget, tk.Widget) or isinstance(widget, tk.BitmapImage), widget
if not self.defaults:
# Can't initialise this til window is created # Windows, MacOS
@ -160,7 +177,10 @@ class _Theme(object):
if 'font' in widget.keys() and str(widget['font']) not in ['', self.defaults['entryfont']]:
attribs.add('font')
elif isinstance(widget, tk.Frame) or isinstance(widget, ttk.Frame) or isinstance(widget, tk.Canvas):
if ('background' in widget.keys() or isinstance(widget, tk.Canvas)) and widget['background'] not in ['', self.defaults['frame']]:
if (
('background' in widget.keys() or isinstance(widget, tk.Canvas))
and widget['background'] not in ['', self.defaults['frame']]
):
attribs.add('bg')
elif isinstance(widget, HyperlinkLabel):
pass # Hack - HyperlinkLabel changes based on state, so skip
@ -184,15 +204,17 @@ class _Theme(object):
for child in widget.winfo_children():
self.register(child)
def register_alternate(self, pair, gridopts):
def register_alternate(self, pair: Tuple, gridopts: Dict) -> None:
self.widgets_pair.append((pair, gridopts))
def button_bind(self, widget, command, image=None):
def button_bind(
self, widget: tk.Widget, command: Callable, image: Optional[tk.BitmapImage] = None
) -> None:
widget.bind('<Button-1>', command)
widget.bind('<Enter>', lambda e: self._enter(e, image))
widget.bind('<Leave>', lambda e: self._leave(e, image))
def _enter(self, event, image):
def _enter(self, event: tk.Event, image: Optional[tk.BitmapImage]) -> None:
widget = event.widget
if widget and widget['state'] != tk.DISABLED:
try:
@ -209,7 +231,7 @@ class _Theme(object):
except Exception:
logger.exception(f'Failure configuring image: {image=}')
def _leave(self, event, image):
def _leave(self, event: tk.Event, image: Optional[tk.BitmapImage]) -> None:
widget = event.widget
if widget and widget['state'] != tk.DISABLED:
try:
@ -226,7 +248,7 @@ class _Theme(object):
logger.exception(f'Failure configuring image: {image=}')
# Set up colors
def _colors(self, root, theme):
def _colors(self, root: tk.Tk, theme: int) -> None:
style = ttk.Style()
if sys.platform == 'linux':
style.theme_use('clam')
@ -245,12 +267,13 @@ class _Theme(object):
'foreground': config.get_str('dark_text'),
'activebackground': config.get_str('dark_text'),
'activeforeground': 'grey4',
'disabledforeground': '#%02x%02x%02x' % (int(r/384), int(g/384), int(b/384)),
'disabledforeground': f'#{int(r/384):02x}{int(g/384):02x}{int(b/384):02x}',
'highlight': config.get_str('dark_highlight'),
# Font only supports Latin 1 / Supplement / Extended, and a few General Punctuation and Mathematical Operators
# Font only supports Latin 1 / Supplement / Extended, and a
# few General Punctuation and Mathematical Operators
# LANG: Label for commander name in main window
'font': (theme > 1 and not 0x250 < ord(_('Cmdr')[0]) < 0x3000 and
tkFont.Font(family='Euro Caps', size=10, weight=tkFont.NORMAL) or
tk_font.Font(family='Euro Caps', size=10, weight=tk_font.NORMAL) or
'TkDefaultFont'),
}
else:
@ -271,10 +294,11 @@ class _Theme(object):
# Apply current theme to a widget and its children, and register it for future updates
def update(self, widget):
def update(self, widget: tk.Widget) -> None:
assert isinstance(widget, tk.Widget) or isinstance(widget, tk.BitmapImage), widget
if not self.current:
return # No need to call this for widgets created in plugin_app()
self.register(widget)
self._update_widget(widget)
if isinstance(widget, tk.Frame) or isinstance(widget, ttk.Frame):
@ -282,73 +306,76 @@ class _Theme(object):
self._update_widget(child)
# Apply current theme to a single widget
def _update_widget(self, widget):
assert widget in self.widgets, '%s %s "%s"' % (
widget.winfo_class(), widget, 'text' in widget.keys() and widget['text'])
attribs = self.widgets.get(widget, [])
def _update_widget(self, widget: tk.Widget) -> None: # noqa: CCR001, C901
if widget not in self.widgets:
assert_str = f'{widget.winfo_class()} {widget} "{"text" in widget.keys() and widget["text"]}"'
raise AssertionError(assert_str)
attribs: Set = self.widgets.get(widget, set())
try:
if isinstance(widget, tk.BitmapImage):
# not a widget
if 'fg' not in attribs:
widget.configure(foreground=self.current['foreground']),
widget['foreground'] = self.current['foreground']
if 'bg' not in attribs:
widget.configure(background=self.current['background'])
widget['background'] = self.current['background']
elif 'cursor' in widget.keys() and str(widget['cursor']) not in ['', 'arrow']:
# Hack - highlight widgets like HyperlinkLabel with a non-default cursor
if 'fg' not in attribs:
widget.configure(foreground=self.current['highlight']),
widget['foreground'] = self.current['highlight']
if 'insertbackground' in widget.keys(): # tk.Entry
widget.configure(insertbackground=self.current['foreground']),
widget['insertbackground'] = self.current['foreground']
if 'bg' not in attribs:
widget.configure(background=self.current['background'])
widget['background'] = self.current['background']
if 'highlightbackground' in widget.keys(): # tk.Entry
widget.configure(highlightbackground=self.current['background'])
widget['highlightbackground'] = self.current['background']
if 'font' not in attribs:
widget.configure(font=self.current['font'])
widget['font'] = self.current['font']
elif 'activeforeground' in widget.keys():
# e.g. tk.Button, tk.Label, tk.Menu
if 'fg' not in attribs:
widget.configure(foreground=self.current['foreground'],
activeforeground=self.current['activeforeground'],
disabledforeground=self.current['disabledforeground'])
widget['foreground'] = self.current['foreground']
widget['activeforeground'] = self.current['activeforeground'],
widget['disabledforeground'] = self.current['disabledforeground']
if 'bg' not in attribs:
widget.configure(background=self.current['background'],
activebackground=self.current['activebackground'])
widget['background'] = self.current['background']
widget['activebackground'] = self.current['activebackground']
if sys.platform == 'darwin' and isinstance(widget, tk.Button):
widget.configure(highlightbackground=self.current['background'])
widget['highlightbackground'] = self.current['background']
if 'font' not in attribs:
widget.configure(font=self.current['font'])
widget['font'] = self.current['font']
elif 'foreground' in widget.keys():
# e.g. ttk.Label
if 'fg' not in attribs:
widget.configure(foreground=self.current['foreground']),
widget['foreground'] = self.current['foreground']
if 'bg' not in attribs:
widget.configure(background=self.current['background'])
widget['background'] = self.current['background']
if 'font' not in attribs:
widget.configure(font=self.current['font'])
widget['font'] = self.current['font']
elif 'background' in widget.keys() or isinstance(widget, tk.Canvas):
# e.g. Frame, Canvas
if 'bg' not in attribs:
widget.configure(background=self.current['background'],
highlightbackground=self.current['disabledforeground'])
widget['background'] = self.current['background']
widget['highlightbackground'] = self.current['disabledforeground']
except Exception:
logger.exception(f'Plugin widget issue ? {widget=}')
# Apply configured theme
def apply(self, root):
def apply(self, root: tk.Tk) -> None: # noqa: CCR001
theme = config.get_int('theme')
self._colors(root, theme)
@ -390,16 +417,16 @@ class _Theme(object):
window.setAppearance_(appearance)
elif sys.platform == 'win32':
GWL_STYLE = -16
WS_MAXIMIZEBOX = 0x00010000
GWL_STYLE = -16 # noqa: N806 # ctypes
WS_MAXIMIZEBOX = 0x00010000 # noqa: N806 # ctypes
# tk8.5.9/win/tkWinWm.c:342
GWL_EXSTYLE = -20
WS_EX_APPWINDOW = 0x00040000
WS_EX_LAYERED = 0x00080000
GetWindowLongW = ctypes.windll.user32.GetWindowLongW
SetWindowLongW = ctypes.windll.user32.SetWindowLongW
GWL_EXSTYLE = -20 # noqa: N806 # ctypes
WS_EX_APPWINDOW = 0x00040000 # noqa: N806 # ctypes
WS_EX_LAYERED = 0x00080000 # noqa: N806 # ctypes
GetWindowLongW = ctypes.windll.user32.GetWindowLongW # noqa: N806 # ctypes
SetWindowLongW = ctypes.windll.user32.SetWindowLongW # noqa: N806 # ctypes
root.overrideredirect(theme and 1 or 0)
root.overrideredirect(theme and True or False)
root.attributes("-transparentcolor", theme > 1 and 'grey4' or '')
root.withdraw()
root.update_idletasks() # Size and windows styles get recalculated here

View File

@ -1,26 +1,39 @@
"""
A clickable ttk label for HTTP links.
In addition to standard ttk.Label arguments, takes the following arguments:
url: The URL as a string that the user will be sent to on clicking on
non-empty label text. If url is a function it will be called on click with
the current label text and should return the URL as a string.
underline: If True/False the text is always/never underlined. If None (the
default) the text is underlined only on hover.
popup_copy: Whether right-click on non-empty label text pops up a context
menu with a 'Copy' option. Defaults to no context menu. If popup_copy is a
function it will be called with the current label text and should return a
boolean.
May be imported by plugins
"""
import sys
import tkinter as tk
import webbrowser
from tkinter import font as tkFont
from tkinter import font as tk_font
from tkinter import ttk
from typing import TYPE_CHECKING, Any, Optional
if sys.platform == 'win32':
import subprocess
from winreg import HKEY_CLASSES_ROOT, HKEY_CURRENT_USER, CloseKey, OpenKeyEx, QueryValueEx
# A clickable ttk Label
#
# In addition to standard ttk.Label arguments, takes the following arguments:
# url: The URL as a string that the user will be sent to on clicking on non-empty label text. If url is a function it will be called on click with the current label text and should return the URL as a string.
# underline: If True/False the text is always/never underlined. If None (the default) the text is underlined only on hover.
# popup_copy: Whether right-click on non-empty label text pops up a context menu with a 'Copy' option. Defaults to no context menu. If popup_copy is a function it will be called with the current label text and should return a boolean.
#
# May be imported by plugins
if TYPE_CHECKING:
def _(x: str) -> str: ...
class HyperlinkLabel(sys.platform == 'darwin' and tk.Label or ttk.Label, object):
# FIXME: Split this into multi-file module to separate the platforms
class HyperlinkLabel(sys.platform == 'darwin' and tk.Label or ttk.Label, object): # type: ignore
"""Clickable label for HTTP links."""
def __init__(self, master=None, **kw):
def __init__(self, master: Optional[tk.Tk] = None, **kw: Any) -> None:
self.url = 'url' in kw and kw.pop('url') or None
self.popup_copy = kw.pop('popup_copy', False)
self.underline = kw.pop('underline', None) # override ttk.Label's underline
@ -33,8 +46,9 @@ class HyperlinkLabel(sys.platform == 'darwin' and tk.Label or ttk.Label, object)
kw['background'] = kw.pop('background', 'systemDialogBackgroundActive')
kw['anchor'] = kw.pop('anchor', tk.W) # like ttk.Label
tk.Label.__init__(self, master, **kw)
else:
ttk.Label.__init__(self, master, **kw)
ttk.Label.__init__(self, master, **kw) # type: ignore
self.bind('<Button-1>', self._click)
@ -51,8 +65,10 @@ class HyperlinkLabel(sys.platform == 'darwin' and tk.Label or ttk.Label, object)
text=kw.get('text'),
font=kw.get('font', ttk.Style().lookup('TLabel', 'font')))
# Change cursor and appearance depending on state and text
def configure(self, cnf=None, **kw):
def configure( # noqa: CCR001
self, cnf: dict[str, Any] | None = None, **kw: Any
) -> dict[str, tuple[str, str, str, Any, Any]] | None:
"""Change cursor and appearance depending on state and text."""
# This class' state
for thing in ['url', 'popup_copy', 'underline']:
if thing in kw:
@ -71,7 +87,7 @@ class HyperlinkLabel(sys.platform == 'darwin' and tk.Label or ttk.Label, object)
if 'font' in kw:
self.font_n = kw['font']
self.font_u = tkFont.Font(font=self.font_n)
self.font_u = tk_font.Font(font=self.font_n)
self.font_u.configure(underline=True)
kw['font'] = self.underline is True and self.font_u or self.font_n
@ -84,41 +100,53 @@ class HyperlinkLabel(sys.platform == 'darwin' and tk.Label or ttk.Label, object)
kw['cursor'] = (sys.platform == 'darwin' and 'notallowed') or (
sys.platform == 'win32' and 'no') or 'circle'
super(HyperlinkLabel, self).configure(cnf, **kw)
return super(HyperlinkLabel, self).configure(cnf, **kw)
def __setitem__(self, key, value):
def __setitem__(self, key: str, value: Any) -> None:
"""
Allow for dict member style setting of options.
:param key: option name
:param value: option value
"""
self.configure(None, **{key: value})
def _enter(self, event):
def _enter(self, event: tk.Event) -> None:
if self.url and self.underline is not False and str(self['state']) != tk.DISABLED:
super(HyperlinkLabel, self).configure(font=self.font_u)
def _leave(self, event):
def _leave(self, event: tk.Event) -> None:
if not self.underline:
super(HyperlinkLabel, self).configure(font=self.font_n)
def _click(self, event):
def _click(self, event: tk.Event) -> None:
if self.url and self['text'] and str(self['state']) != tk.DISABLED:
url = self.url(self['text']) if callable(self.url) else self.url
if url:
self._leave(event) # Remove underline before we change window to browser
openurl(url)
def _contextmenu(self, event):
def _contextmenu(self, event: tk.Event) -> None:
if self['text'] and (self.popup_copy(self['text']) if callable(self.popup_copy) else self.popup_copy):
self.menu.post(sys.platform == 'darwin' and event.x_root + 1 or event.x_root, event.y_root)
def copy(self):
def copy(self) -> None:
"""Copy the current text to the clipboard."""
self.clipboard_clear()
self.clipboard_append(self['text'])
def openurl(url):
def openurl(url: str) -> None: # noqa: CCR001
"""
Open the given URL in appropriate browser.
:param url: URL to open.
"""
if sys.platform == 'win32':
# FIXME: Is still still true with supported Windows 10 and 11 ?
# On Windows webbrowser.open calls os.startfile which calls ShellExecute which can't handle long arguments,
# so discover and launch the browser directly.
# https://blogs.msdn.microsoft.com/oldnewthing/20031210-00/?p=41553
try:
hkey = OpenKeyEx(HKEY_CURRENT_USER,
r'Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice')
@ -128,23 +156,28 @@ def openurl(url):
# IE and Edge can't handle long arguments so just use webbrowser.open and hope
# https://blogs.msdn.microsoft.com/ieinternals/2014/08/13/url-length-limits/
cls = None
else:
cls = value
except:
except Exception:
cls = 'https'
if cls:
try:
hkey = OpenKeyEx(HKEY_CLASSES_ROOT, r'%s\shell\open\command' % cls)
(value, typ) = QueryValueEx(hkey, None)
hkey = OpenKeyEx(HKEY_CLASSES_ROOT, rf'{cls}\shell\open\command')
(value, typ) = QueryValueEx(hkey, '')
CloseKey(hkey)
if 'iexplore' not in value.lower():
if '%1' in value:
subprocess.Popen(buf.value.replace('%1', url))
subprocess.Popen(value.replace('%1', url))
else:
subprocess.Popen('%s "%s"' % (buf.value, url))
subprocess.Popen(f'{value} "{url}"')
return
except:
except Exception:
pass
webbrowser.open(url)

127
update.py
View File

@ -1,15 +1,22 @@
"""Checking for updates to this application."""
import os
from os.path import dirname, join
import sys
import threading
from os.path import dirname, join
from traceback import print_exc
import semantic_version
from typing import TYPE_CHECKING, Optional
from xml.etree import ElementTree
import requests
import semantic_version
if TYPE_CHECKING:
import tkinter as tk
# ensure registry is set up on Windows before we start
from config import appname, appversion_nobuild, config, update_feed
from EDMCLogging import get_main_logger
logger = get_main_logger()
class EDMCVersion(object):
@ -25,6 +32,7 @@ class EDMCVersion(object):
sv: semantic_version.base.Version
semantic_version object for this version
"""
def __init__(self, version: str, title: str, sv: semantic_version.base.Version):
self.version: str = version
self.title: str = title
@ -33,36 +41,38 @@ class EDMCVersion(object):
class Updater(object):
"""
Updater class to handle checking for updates, whether using internal code
or an external library such as WinSparkle on win32.
Handle checking for updates.
This is used whether using internal code or an external library such as
WinSparkle on win32.
"""
def shutdown_request(self) -> None:
"""
Receive (Win)Sparkle shutdown request and send it to parent.
:rtype: None
"""
if not config.shutting_down:
"""Receive (Win)Sparkle shutdown request and send it to parent."""
if not config.shutting_down and self.root:
self.root.event_generate('<<Quit>>', when="tail")
def use_internal(self) -> bool:
"""
:return: if internal update checks should be used.
:rtype: bool
Signal if internal update checks should be used.
:return: bool
"""
if self.provider == 'internal':
return True
return False
def __init__(self, tkroot: 'tk.Tk'=None, provider: str='internal'):
def __init__(self, tkroot: Optional['tk.Tk'] = None, provider: str = 'internal'):
"""
Initialise an Updater instance.
:param tkroot: reference to the root window of the GUI
:param provider: 'internal' or other string if not
"""
self.root: 'tk.Tk' = tkroot
self.root: Optional['tk.Tk'] = tkroot
self.provider: str = provider
self.thread: threading.Thread = None
self.thread: Optional[threading.Thread] = None
if self.use_internal():
return
@ -71,7 +81,7 @@ class Updater(object):
import ctypes
try:
self.updater = ctypes.cdll.WinSparkle
self.updater: Optional[ctypes.CDLL] = ctypes.cdll.WinSparkle
# Set the appcast URL
self.updater.win_sparkle_set_appcast_url(update_feed.encode())
@ -84,8 +94,6 @@ class Updater(object):
self.updater.win_sparkle_set_app_build_version(str(appversion_nobuild()))
# set up shutdown callback
global root
root = tkroot
self.callback_t = ctypes.CFUNCTYPE(None) # keep reference
self.callback_fn = self.callback_t(self.shutdown_request)
self.updater.win_sparkle_set_shutdown_request_callback(self.callback_fn)
@ -93,7 +101,7 @@ class Updater(object):
# Get WinSparkle running
self.updater.win_sparkle_init()
except Exception as ex:
except Exception:
print_exc()
self.updater = None
@ -101,19 +109,24 @@ class Updater(object):
if sys.platform == 'darwin':
import objc
try:
objc.loadBundle('Sparkle', globals(), join(dirname(sys.executable), os.pardir, 'Frameworks', 'Sparkle.framework'))
self.updater = SUUpdater.sharedUpdater()
except:
objc.loadBundle(
'Sparkle', globals(), join(dirname(sys.executable), os.pardir, 'Frameworks', 'Sparkle.framework')
)
# loadBundle presumably supplies `SUUpdater`
self.updater = SUUpdater.sharedUpdater() # noqa: F821
except Exception:
# can't load framework - not frozen or not included in app bundle?
print_exc()
self.updater = None
def setAutomaticUpdatesCheck(self, onoroff: bool) -> None:
def set_automatic_updates_check(self, onoroff: bool) -> None:
"""
Helper to set (Win)Sparkle to perform automatic update checks, or not.
Set (Win)Sparkle to perform automatic update checks, or not.
:param onoroff: bool for if we should have the library check or not.
:return: None
"""
if self.use_internal():
return
@ -124,11 +137,8 @@ class Updater(object):
if sys.platform == 'darwin' and self.updater:
self.updater.SUEnableAutomaticChecks(onoroff)
def checkForUpdates(self) -> None:
"""
Trigger the requisite method to check for an update.
:return: None
"""
def check_for_updates(self) -> None:
"""Trigger the requisite method to check for an update."""
if self.use_internal():
self.thread = threading.Thread(target=self.worker, name='update worker')
self.thread.daemon = True
@ -142,65 +152,78 @@ class Updater(object):
def check_appcast(self) -> Optional[EDMCVersion]:
"""
Manually (no Sparkle or WinSparkle) check the update_feed appcast file
to see if any listed version is semantically greater than the current
Manually (no Sparkle or WinSparkle) check the update_feed appcast file.
Checks if any listed version is semantically greater than the current
running version.
:return: EDMCVersion or None if no newer version found
"""
import requests
from xml.etree import ElementTree
newversion = None
items = {}
try:
r = requests.get(update_feed, timeout=10)
except requests.RequestException as ex:
print('Error retrieving update_feed file: {}'.format(str(ex)), file=sys.stderr)
logger.exception(f'Error retrieving update_feed file: {ex}')
return None
try:
feed = ElementTree.fromstring(r.text)
except SyntaxError as ex:
print('Syntax error in update_feed file: {}'.format(str(ex)), file=sys.stderr)
logger.exception(f'Syntax error in update_feed file: {ex}')
return None
if sys.platform == 'darwin':
sparkle_platform = 'macos'
else:
# For *these* purposes anything else is the same as 'windows', as
# non-win32 would be running from source.
sparkle_platform = 'windows'
for item in feed.findall('channel/item'):
ver = item.find('enclosure').attrib.get('{http://www.andymatuschak.org/xml-namespaces/sparkle}version')
# xml is a pain with types, hence these ignores
ver = item.find('enclosure').attrib.get( # type: ignore
'{http://www.andymatuschak.org/xml-namespaces/sparkle}version'
)
ver_platform = item.find('enclosure').attrib.get( # type: ignore
'{http://www.andymatuschak.org/xml-namespaces/sparkle}os'
)
if ver_platform != sparkle_platform:
continue
# This will change A.B.C.D to A.B.C+D
sv = semantic_version.Version.coerce(ver)
items[sv] = EDMCVersion(version=ver, # sv might have mangled version
title=item.find('title').text,
items[sv] = EDMCVersion(
version=str(ver), # sv might have mangled version
title=item.find('title').text, # type: ignore
sv=sv
)
# Look for any remaining version greater than appversion
simple_spec = semantic_version.SimpleSpec(f'>{appversion_nobuild()}')
newversion = simple_spec.select(items.keys())
if newversion:
return items[newversion]
return None
def worker(self) -> None:
"""
Thread worker to perform internal update checking and update GUI
status if a newer version is found.
:return: None
"""
"""Perform internal update checking & update GUI status if needs be."""
newversion = self.check_appcast()
if newversion:
# TODO: Surely we can do better than this
# nametowidget('.{}.status'.format(appname.lower()))['text']
self.root.nametowidget('.{}.status'.format(appname.lower()))['text'] = newversion.title + ' is available'
if newversion and self.root:
status = self.root.nametowidget(f'.{appname.lower()}.status')
status['text'] = newversion.title + ' is available'
self.root.update_idletasks()
def close(self) -> None:
"""
Handles the EDMarketConnector.AppWindow.onexit() request.
Handle the EDMarketConnector.AppWindow.onexit() request.
NB: We just 'pass' here because:
1) We might have a worker() going, but no way to make that
@ -208,7 +231,5 @@ class Updater(object):
2) If we're running frozen then we're using (Win)Sparkle to check
and *it* might have asked this whole application to quit, in
which case we don't want to ask *it* to quit
:return: None
"""
pass

View File

@ -1,6 +1,7 @@
"""Utility functions relating to ships."""
from edmc_data import ship_name_map
def ship_file_name(ship_name: str, ship_type: str) -> str:
"""Return a ship name suitable for a filename."""
name = str(ship_name or ship_name_map.get(ship_type.lower(), ship_type)).strip()