diff --git a/.gitignore b/.gitignore index 7474987daa..44a4f42133 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ venv htmlcov/ .ignored .coverage +venv.old diff --git a/EDMCLogging.py b/EDMCLogging.py index c00b7ab9f6..30da13134f 100644 --- a/EDMCLogging.py +++ b/EDMCLogging.py @@ -484,37 +484,12 @@ def munge_module_name(cls, frame_info: inspect.Traceback, module_name: str) -> s file_name = pathlib.Path(frame_info.filename).expanduser() plugin_dir = pathlib.Path(config.plugin_dir_path).expanduser() internal_plugin_dir = pathlib.Path(config.internal_plugin_dir_path).expanduser() - # Find the first parent called 'plugins' - plugin_top = file_name - while plugin_top and plugin_top.name != '': - if plugin_top.parent.name == 'plugins': - break + if internal_plugin_dir in file_name.parents: + # its an internal plugin + return f'plugins.{".".join(file_name.relative_to(internal_plugin_dir).parent.parts)}' - plugin_top = plugin_top.parent - - # Check we didn't walk up to the root/anchor - if plugin_top.name != '': - # Check we're still inside config.plugin_dir - if plugin_top.parent == plugin_dir: - # In case of deeper callers we need a range of the file_name - pt_len = len(plugin_top.parts) - name_path = '.'.join(file_name.parts[(pt_len - 1):-1]) - module_name = f'.{name_path}.{module_name}' - - # Check we're still inside the installation folder. - elif file_name.parent == internal_plugin_dir: - # Is this a deeper caller ? - pt_len = len(plugin_top.parts) - name_path = '.'.join(file_name.parts[(pt_len - 1):-1]) - - # Pre-pend 'plugins..' to module - if name_path == '': - # No sub-folder involved so module_name is sufficient - module_name = f'plugins.{module_name}' - - else: - # Sub-folder(s) involved, so include them - module_name = f'plugins.{name_path}.{module_name}' + elif plugin_dir in file_name.parents: + return f'.{".".join(file_name.relative_to(plugin_dir).parent.parts)}' return module_name diff --git a/EDMarketConnector.py b/EDMarketConnector.py index 35d2c58ab9..dc8591b786 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -9,14 +9,13 @@ import queue import re import sys -# import threading import webbrowser from builtins import object, str from os import chdir, environ from os.path import dirname, join from sys import platform from time import localtime, strftime, time -from typing import TYPE_CHECKING, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, Optional, Tuple, Union # Have this as early as possible for people running EDMarketConnector.exe # from cmd.exe or a bat file or similar. Else they might not be in the correct @@ -375,6 +374,8 @@ def _(x: str) -> str: import commodity import plug +import plugin +import plugin.event import prefs import protocol import stats @@ -385,6 +386,10 @@ def _(x: str) -> str: from hotkey import hotkeymgr from l10n import Translations from monitor import monitor +from plugin import event +from plugin.exceptions import LegacyPluginNeedsMigrating +from plugin.manager import PluginManager, string_fire_results +from plugin.provider import EDMCProviders from theme import theme from ttkHyperlinkLabel import HyperlinkLabel @@ -441,8 +446,10 @@ def __init__(self, master: tk.Tk): # noqa: C901, CCR001 # TODO - can possibly f # # Method associated with on_quit is called whenever the systray is closing # self.systray = SysTrayIcon("EDMarketConnector.ico", applongname, menu_options, on_quit=self.exit_tray) # self.systray.start() - - plug.load_plugins(master) + self.status_text = tk.StringVar() + self.plugin_manager = PluginManager(self.set_status_msg) + plug._manager = self.plugin_manager + self._load_all_plugins() if platform != 'darwin': if platform == 'win32': @@ -501,22 +508,31 @@ def __init__(self, master: tk.Tk): # noqa: C901, CCR001 # TODO - can possibly f self.station.grid(row=ui_row, column=1, sticky=tk.EW) ui_row += 1 - for plugin in plug.PLUGINS: - appitem = plugin.get_app(frame) - if appitem: - tk.Frame(frame, highlightthickness=1).grid(columnspan=2, sticky=tk.EW) # separator - if isinstance(appitem, tuple) and len(appitem) == 2: - ui_row = frame.grid_size()[1] - appitem[0].grid(row=ui_row, column=0, sticky=tk.W) - appitem[1].grid(row=ui_row, column=1, sticky=tk.EW) + plugin_ui_wrapper_frame = tk.Frame(frame) + # plugin_ui_wrapper_frame.columnconfigure(0, weight=1) + plugin_ui_wrapper_frame['bg'] = 'red' # TODO: Remove - else: - appitem.grid(columnspan=2, sticky=tk.EW) + self.setup_plugin_uis(plugin_ui_wrapper_frame) + plugin_ui_wrapper_frame.grid(row=ui_row, columnspan=2, sticky=tk.EW) + plugin_ui_wrapper_frame.columnconfigure(0, weight=1) + ui_row += 1 + + # for plugin in plug.PLUGINS: + # appitem = plugin.get_app(frame) + # if appitem: + # tk.Frame(frame, highlightthickness=1).grid(columnspan=2, sticky=tk.EW) # separator + # if isinstance(appitem, tuple) and len(appitem) == 2: + # ui_row = frame.grid_size()[1] + # appitem[0].grid(row=ui_row, column=0, sticky=tk.W) + # appitem[1].grid(row=ui_row, column=1, sticky=tk.EW) + + # else: + # appitem.grid(columnspan=2, sticky=tk.EW) # LANG: Update button in main window self.button = ttk.Button(frame, text=_('Update'), width=28, default=tk.ACTIVE, state=tk.DISABLED) self.theme_button = tk.Label(frame, width=32 if platform == 'darwin' else 28, state=tk.DISABLED) - self.status = tk.Label(frame, name='status', anchor=tk.W) + self.status = tk.Label(frame, name='status', textvariable=self.status_text, anchor=tk.W) ui_row = frame.grid_size()[1] self.button.grid(row=ui_row, columnspan=2, sticky=tk.NSEW) @@ -532,7 +548,7 @@ def __init__(self, master: tk.Tk): # noqa: C901, CCR001 # TODO - can possibly f # The type needs defining for adding the menu entry, but won't be # properly set until later - self.updater: update.Updater = None + self.updater: 'update.Updater' = None # type: ignore self.menubar = tk.Menu() if platform == 'darwin': @@ -568,7 +584,9 @@ def __init__(self, master: tk.Tk): # noqa: C901, CCR001 # TODO - can possibly f self.w.call('set', 'tk::mac::useCompatibilityMetrics', '0') self.w.createcommand('tkAboutDialog', lambda: self.w.call('tk::mac::standardAboutPanel')) self.w.createcommand("::tk::mac::Quit", self.onexit) - self.w.createcommand("::tk::mac::ShowPreferences", lambda: prefs.PreferencesDialog(self.w, self.postprefs)) + self.w.createcommand("::tk::mac::ShowPreferences", + lambda: prefs.PreferencesDialog(self.w, self.postprefs, self.plugin_manager) + ) self.w.createcommand("::tk::mac::ReopenApplication", self.w.deiconify) # click on app in dock = restore self.w.protocol("WM_DELETE_WINDOW", self.w.withdraw) # close button shouldn't quit app self.w.resizable(tk.FALSE, tk.FALSE) # Can't be only resizable on one axis @@ -576,7 +594,8 @@ def __init__(self, master: tk.Tk): # noqa: C901, CCR001 # TODO - can possibly f self.file_menu = self.view_menu = tk.Menu(self.menubar, tearoff=tk.FALSE) # type: ignore self.file_menu.add_command(command=lambda: stats.StatsDialog(self.w, self.status)) self.file_menu.add_command(command=self.save_raw) - self.file_menu.add_command(command=lambda: prefs.PreferencesDialog(self.w, self.postprefs)) + self.file_menu.add_command(command=lambda: prefs.PreferencesDialog( + self.w, self.postprefs, self.plugin_manager)) self.file_menu.add_separator() self.file_menu.add_command(command=self.onexit) self.menubar.add_cascade(menu=self.file_menu) @@ -691,7 +710,6 @@ def __init__(self, master: tk.Tk): # noqa: C901, CCR001 # TODO - can possibly f self.w.bind_all(self._CAPI_RESPONSE_TK_EVENT_NAME, self.capi_handle_response) self.w.bind_all('<>', self.journal_event) # Journal monitoring self.w.bind_all('<>', self.dashboard_event) # Dashboard monitoring - self.w.bind_all('<>', self.plugin_error) # Statusbar self.w.bind_all('<>', self.auth) # cAPI auth self.w.bind_all('<>', self.onexit) # Updater @@ -719,6 +737,67 @@ def __init__(self, master: tk.Tk): # noqa: C901, CCR001 # TODO - can possibly f self.postprefs(False) # Companion login happens in callback from monitor self.toggle_suit_row(visible=False) + def _load_all_plugins(self) -> None: + logger.info('Loading plugins...') + logger.info('Loading internal plugins') + self.plugin_manager.load_all_plugins_in(config.internal_plugin_dir_path) + logger.info('Internal plugin loading complete, loading third party plugins...') + self.plugin_manager.load_all_plugins_in(config.plugin_dir_path) + logger.info('Plugin loading complete') + + def setup_plugin_uis(self, frame: tk.Frame) -> None: + """ + Set up UIs for plugins that wish to have UIs on the main page. + + :param frame: the frame under which plugins should create their widgets. + """ + # Each plugin gets its own wrapper frame. screw that up and it shouldnt go any further + for plugin_name in self.plugin_manager.plugins: + wrapper_frame = tk.Frame(frame) + # Make column zero (the only one *we* at this level have) take up all space. No I dont know why we need this + wrapper_frame.columnconfigure(0, weight=1) + res = self.plugin_manager.fire_targeted_event( + plugin_name, event.BaseDataEvent(event.EDMCPluginEvents.STARTUP_UI, wrapper_frame) + ) + filtered_results: list[tk.Widget] = [r for r in res if r is not None] + if len(filtered_results) == 0: + logger.trace(f'{plugin_name!r} has no startup UI elements') + continue + + tk.Frame(frame, highlightthickness=1).grid(sticky=tk.EW) + tk.Label(frame, text=f'{plugin_name} Plugin').grid(sticky=tk.EW) + for result in filtered_results: + if result is wrapper_frame: + # dont grid wrapper multiple times + continue + + result.grid(sticky=tk.EW) + + wrapper_frame.grid(sticky=tk.EW) + + def _provider_or_default(self, name: str, preferred: str, default: str) -> Any: + providers = self.plugin_manager.get_providers_dict(name) + if len(providers) == 0: + raise ValueError(f'No providers found for name {name!r}') + + if preferred in providers: + return providers[preferred]() + + return providers[default]() + + def update_location_text(self): + """Update the current system and station text based on the results from providers.""" + system_name = self._provider_or_default( + EDMCProviders.SYSTEM_TEXT, config.get_str('system_provider', default='ui_update'), 'ui_update' + ) + station_name = self._provider_or_default( + EDMCProviders.STATION_TEXT, config.get_str('system_provider', default='ui_update'), 'ui_update' + ) + + self.system['text'] = system_name + self.station['text'] = station_name + self.w.update_idletasks() + def update_suit_text(self) -> None: """Update the suit text for current type and loadout.""" if not monitor.state['Odyssey']: @@ -796,7 +875,7 @@ def postprefs(self, dologin: bool = True): # (Re-)install log monitoring if not monitor.start(self.w): # LANG: ED Journal file location appears to be in error - self.status['text'] = _('Error: Check E:D journal file location') + self.set_status_msg(_('Error: Check E:D journal file location')) if dologin and monitor.cmdr: self.login() # Login if not already logged in with this Cmdr @@ -853,7 +932,7 @@ def login(self): """Initiate CAPI/Frontier login and set other necessary state.""" if not self.status['text']: # LANG: Status - Attempting to get a Frontier Auth Access Token - self.status['text'] = _('Logging in...') + self.set_status_msg(_('Logging in...')) self.button['state'] = self.theme_button['state'] = tk.DISABLED @@ -869,7 +948,7 @@ def login(self): try: if companion.session.login(monitor.cmdr, monitor.is_beta): # LANG: Successfully authenticated with the Frontier website - self.status['text'] = _('Authentication successful') + self.set_status_msg(_('Authentication successful')) if platform == 'darwin': self.view_menu.entryconfigure(0, state=tk.NORMAL) # Status @@ -880,11 +959,11 @@ def login(self): self.file_menu.entryconfigure(1, state=tk.NORMAL) # Save Raw Data except (companion.CredentialsError, companion.ServerError, companion.ServerLagging) as e: - self.status['text'] = str(e) + self.set_status_msg(str(e)) except Exception as e: logger.debug('Frontier CAPI Auth', exc_info=e) - self.status['text'] = str(e) + self.set_status_msg(str(e)) self.cooldown() @@ -900,7 +979,7 @@ def export_market_data(self, data: 'CAPIData') -> bool: # noqa: CCR001 # Signal as error because the user might actually be docked # but the server hosting the Companion API hasn't caught up # LANG: Player is not docked at a station, when we expect them to be - self.status['text'] = _("You're not docked at a station!") + self.set_status_msg(_("You're not docked at a station!")) return False # Ignore possibly missing shipyard info @@ -908,12 +987,12 @@ def export_market_data(self, data: 'CAPIData') -> bool: # noqa: CCR001 and not (data['lastStarport'].get('commodities') or data['lastStarport'].get('modules')): if not self.status['text']: # LANG: Status - Either no market or no modules data for station from Frontier CAPI - self.status['text'] = _("Station doesn't have anything!") + self.set_status_msg(_("Station doesn't have anything!")) elif not data['lastStarport'].get('commodities'): if not self.status['text']: # LANG: Status - No station market data from Frontier CAPI - self.status['text'] = _("Station doesn't have a market!") + self.set_status_msg(_("Station doesn't have a market!")) elif config.get_int('output') & (config.OUT_MKT_CSV | config.OUT_MKT_TD): # Fixup anomalies in the commodity data @@ -942,31 +1021,31 @@ def capi_request_data(self, event=None) -> None: if not monitor.cmdr: logger.trace_if('capi.worker', 'Aborting Query: Cmdr unknown') # LANG: CAPI queries aborted because Cmdr name is unknown - self.status['text'] = _('CAPI query aborted: Cmdr name unknown') + self.set_status_msg(_('CAPI query aborted: Cmdr name unknown')) return if not monitor.mode: logger.trace_if('capi.worker', 'Aborting Query: Game Mode unknown') # LANG: CAPI queries aborted because game mode unknown - self.status['text'] = _('CAPI query aborted: Game mode unknown') + self.set_status_msg(_('CAPI query aborted: Game mode unknown')) return if not monitor.system: logger.trace_if('capi.worker', 'Aborting Query: Current star system unknown') # LANG: CAPI queries aborted because current star system name unknown - self.status['text'] = _('CAPI query aborted: Current system unknown') + self.set_status_msg(_('CAPI query aborted: Current system unknown')) return if monitor.state['Captain']: logger.trace_if('capi.worker', 'Aborting Query: In multi-crew') # LANG: CAPI queries aborted because player is in multi-crew on other Cmdr's ship - self.status['text'] = _('CAPI query aborted: In other-ship multi-crew') + self.set_status_msg(_('CAPI query aborted: In other-ship multi-crew')) return if monitor.mode == 'CQC': logger.trace_if('capi.worker', 'Aborting Query: In CQC') # LANG: CAPI queries aborted because player is in CQC (Arena) - self.status['text'] = _('CAPI query aborted: CQC (Arena) detected') + self.set_status_msg(_('CAPI query aborted: CQC (Arena) detected')) return if companion.session.state == companion.Session.STATE_AUTH: @@ -978,7 +1057,7 @@ def capi_request_data(self, event=None) -> None: if not companion.session.retrying: if time() < self.capi_query_holdoff_time: # Was invoked by key while in cooldown if play_sound and (self.capi_query_holdoff_time - time()) < companion.capi_query_cooldown * 0.75: - self.status['text'] = '' + self.set_status_msg('') hotkeymgr.play_bad() # Don't play sound in first few seconds to prevent repeats return @@ -987,7 +1066,7 @@ def capi_request_data(self, event=None) -> None: hotkeymgr.play_good() # LANG: Status - Attempting to retrieve data from Frontier CAPI - self.status['text'] = _('Fetching data...') + self.set_status_msg(_('Fetching data...')) self.button['state'] = self.theme_button['state'] = tk.DISABLED self.w.update_idletasks() @@ -1028,24 +1107,28 @@ def capi_handle_response(self, event=None): # noqa: C901, CCR001 if 'commander' not in capi_response.capi_data: # This can happen with EGS Auth if no commander created yet # LANG: No data was returned for the commander from the Frontier CAPI - err = self.status['text'] = _('CAPI: No commander data returned') + err = _('CAPI: No commander data returned') + self.set_status_msg(err) elif not capi_response.capi_data.get('commander', {}).get('name'): # LANG: We didn't have the commander name when we should have - err = self.status['text'] = _("Who are you?!") # Shouldn't happen + err = _("Who are you?!") # Shouldn't happen + self.set_status_msg(err) elif (not capi_response.capi_data.get('lastSystem', {}).get('name') or (capi_response.capi_data['commander'].get('docked') and not capi_response.capi_data.get('lastStarport', {}).get('name'))): # LANG: We don't know where the commander is, when we should - err = self.status['text'] = _("Where are you?!") # Shouldn't happen + err = _("Where are you?!") # Shouldn't happen + self.set_status_msg(err) elif ( not capi_response.capi_data.get('ship', {}).get('name') or not capi_response.capi_data.get('ship', {}).get('modules') ): # LANG: We don't know what ship the commander is in, when we should - err = self.status['text'] = _("What are you flying?!") # Shouldn't happen + err = _("What are you flying?!") # Shouldn't happen + self.set_status_msg(err) elif monitor.cmdr and capi_response.capi_data['commander']['name'] != monitor.cmdr: # Companion API Commander doesn't match Journal @@ -1138,14 +1221,15 @@ def capi_handle_response(self, event=None): # noqa: C901, CCR001 monitor.state['Loan'] = capi_response.capi_data['commander'].get('debt', 0) # stuff we can do when not docked - err = plug.notify_newdata(capi_response.capi_data, monitor.is_beta) - self.status['text'] = err and err or '' - if err: - play_bad = True + results = self.plugin_manager.fire_event( + plugin.event.CAPIDataEvent(plugin.event.EDMCPluginEvents.CAPI_DATA, capi_response.capi_data) + ) + err = string_fire_results(results) + self.set_status_msg(err) # Export market data if not self.export_market_data(capi_response.capi_data): - err = 'Error: Exporting Market data' + err = 'Error: Exporting Market data' # TODO: err not used? play_bad = True self.capi_query_holdoff_time = capi_response.query_time + companion.capi_query_cooldown @@ -1157,7 +1241,7 @@ def capi_handle_response(self, event=None): # noqa: C901, CCR001 except companion.ServerConnectionError: # LANG: Frontier CAPI server error when fetching data - self.status['text'] = _('Frontier CAPI server error') + self.set_status_msg(_('Frontier CAPI server error')) except companion.CredentialsError: companion.session.retrying = False @@ -1169,7 +1253,7 @@ def capi_handle_response(self, event=None): # noqa: C901, CCR001 except companion.ServerLagging as e: err = str(e) if companion.session.retrying: - self.status['text'] = err + self.set_status_msg(err) play_bad = True else: @@ -1179,24 +1263,27 @@ def capi_handle_response(self, event=None): # noqa: C901, CCR001 return # early exit to avoid starting cooldown count except companion.CmdrError as e: # Companion API return doesn't match Journal - err = self.status['text'] = str(e) + err = str(e) + self.set_status_msg(err) play_bad = True companion.session.invalidate() self.login() except companion.ServerConnectionError as e: logger.warning(f'Exception while contacting server: {e}') - err = self.status['text'] = str(e) + err = str(e) + self.set_status_msg(err) play_bad = True except Exception as e: # Including CredentialsError, ServerError logger.debug('"other" exception', exc_info=e) - err = self.status['text'] = str(e) + err = str(e) + self.set_status_msg(err) play_bad = True if not err: # not self.status['text']: # no errors # LANG: Time when we last obtained Frontier CAPI data - self.status['text'] = strftime(_('Last updated at %H:%M:%S'), localtime(capi_response.query_time)) + self.set_status_msg(strftime(_('Last updated at %H:%M:%S'), localtime(capi_response.query_time))) if capi_response.play_sound and play_bad: hotkeymgr.play_bad() @@ -1308,7 +1395,7 @@ def crewroletext(role: str) -> str: 'EngineerCraft', 'Synthesis', 'JoinACrew'): - self.status['text'] = '' # Periodically clear any old error + self.set_status_msg('') # Periodically clear any old error self.w.update_idletasks() @@ -1319,9 +1406,15 @@ def crewroletext(role: str) -> str: self.login() if monitor.mode == 'CQC' and entry['event']: - err = plug.notify_journal_entry_cqc(monitor.cmdr, monitor.is_beta, entry, monitor.state) + if monitor.cmdr is None: + logger.warning('Commander was None when firing CQC journal event. This may make things weird!') + err = string_fire_results(self.plugin_manager.fire_event(plugin.event.JournalEvent( + plugin.event.EDMCPluginEvents.CQC_JOURNAL_ENTRY, + entry, str(monitor.cmdr), monitor.is_beta, monitor.system, monitor.station, monitor.state + ))) + if err: - self.status['text'] = err + self.set_status_msg(err) if not config.get_int('hotkey_mute'): hotkeymgr.play_bad() @@ -1348,17 +1441,19 @@ def crewroletext(role: str) -> str: and config.get_int('output') & config.OUT_SHIP: monitor.export_ship() - err = plug.notify_journal_entry(monitor.cmdr, - monitor.is_beta, - monitor.system, - monitor.station, - entry, - monitor.state) + err = string_fire_results(self.plugin_manager.fire_event(plugin.event.JournalEvent( + plugin.event.EDMCPluginEvents.JOURNAL_ENTRY, + entry, str(monitor.cmdr), monitor.is_beta, monitor.system, + monitor.station, monitor.state + ))) + if err: - self.status['text'] = err + self.set_status_msg(err) if not config.get_int('hotkey_mute'): hotkeymgr.play_bad() + self.update_location_text() + auto_update = False # Only if auth callback is not pending if companion.session.state != companion.Session.STATE_AUTH: @@ -1401,7 +1496,7 @@ def auth(self, event=None) -> None: try: companion.session.auth_callback() # LANG: Successfully authenticated with the Frontier website - self.status['text'] = _('Authentication successful') + self.set_status_msg(_('Authentication successful')) if platform == 'darwin': self.view_menu.entryconfigure(0, state=tk.NORMAL) # Status self.file_menu.entryconfigure(0, state=tk.NORMAL) # Save Raw Data @@ -1411,11 +1506,11 @@ def auth(self, event=None) -> None: self.file_menu.entryconfigure(1, state=tk.NORMAL) # Save Raw Data except companion.ServerError as e: - self.status['text'] = str(e) + self.set_status_msg(str(e)) except Exception as e: logger.debug('Frontier CAPI Auth:', exc_info=e) - self.status['text'] = str(e) + self.set_status_msg(str(e)) self.cooldown() @@ -1430,19 +1525,17 @@ def dashboard_event(self, event) -> None: entry = dashboard.status # Currently we don't do anything with these events - err = plug.notify_dashboard_entry(monitor.cmdr, monitor.is_beta, entry) + err = string_fire_results(self.plugin_manager.fire_event(plugin.event.BaseDataEvent( + plugin.event.EDMCPluginEvents.DASHBOARD_ENTRY, + entry + ))) if err: - self.status['text'] = err + self.set_status_msg(err) if not config.get_int('hotkey_mute'): hotkeymgr.play_bad() - def plugin_error(self, event=None) -> None: - """Display asynchronous error from plugin.""" - if plug.last_error.get('msg'): - self.status['text'] = plug.last_error['msg'] - self.w.update_idletasks() - if not config.get_int('hotkey_mute'): - hotkeymgr.play_bad() + def set_status_msg(self, msg: str): + self.status_text.set(msg) def shipyard_url(self, shipname: str) -> str: """Despatch a ship URL to the configured handler.""" @@ -1450,22 +1543,26 @@ def shipyard_url(self, shipname: str) -> str: logger.warning('No ship loadout, aborting.') return '' - if not bool(config.get_int("use_alt_shipyard_open")): - return plug.invoke(config.get_str('shipyard_provider'), - 'EDSY', - 'shipyard_url', - loadout, - monitor.is_beta) + providers = self.plugin_manager.get_providers_dict(EDMCProviders.SHIPYARD_URL) + provider_name = config.get_str('shipyard_provider', default='EDSY') + provide_func = providers.get(provider_name) + if provide_func is None: + logger.warning('unable to locate selected shipyard url provider. defaulting to edsy') + provide_func = providers['EDSY'] + provider_name = 'EDSY' + + target = provide_func(shipname, loadout) + + if not config.get_bool("use_alt_shipyard_open", default=False): + return target # Avoid file length limits if possible - provider = config.get_str('shipyard_provider', default='EDSY') - target = plug.invoke(provider, 'EDSY', 'shipyard_url', loadout, monitor.is_beta) file_name = join(config.app_dir_path, "last_shipyard.html") with open(file_name, 'w') as f: print(SHIPYARD_HTML_TEMPLATE.format( link=html.escape(str(target)), - provider_name=html.escape(str(provider)), + provider_name=html.escape(provider_name), ship_name=html.escape(str(shipname)) ), file=f) @@ -1473,11 +1570,21 @@ def shipyard_url(self, shipname: str) -> str: def system_url(self, system: str) -> str: """Despatch a system URL to the configured handler.""" - return plug.invoke(config.get_str('system_provider'), 'EDSM', 'system_url', monitor.system) + providers = self.plugin_manager.get_providers_dict(EDMCProviders.SYSTEM_URL) + if (selected := config.get_str('system_provider')) in providers: + return providers[selected]() + + logger.warning('Unable to locate selected provider for system urls, defaulting to edsm') + return providers['EDSM']() def station_url(self, station: str) -> str: """Despatch a station URL to the configured handler.""" - return plug.invoke(config.get_str('station_provider'), 'eddb', 'station_url', monitor.system, monitor.station) + providers = self.plugin_manager.get_providers_dict(EDMCProviders.STATION_URL) + if (selected := config.get_str('station_provider')) in providers: + return providers[selected]() + + logger.warning('Unable to locate selected provider for station urls, defaulting to eddb') + return providers['eddb']() def cooldown(self) -> None: """Display and update the cooldown timer for 'Update' button.""" @@ -1663,7 +1770,7 @@ def onexit(self, event=None) -> None: # Let the user know we're shutting down. # LANG: The application is shutting down - self.status['text'] = _('Shutting down...') + self.set_status_msg(_('Shutting down...')) self.w.update_idletasks() logger.info('Starting shutdown procedures...') @@ -1675,7 +1782,7 @@ def onexit(self, event=None) -> None: # won't still be running in a manner that might rely on something # we'd otherwise have already stopped. logger.info('Notifying plugins to stop...') - plug.notify_stop() + self.plugin_manager.fire_str_event(plugin.event.EDMCPluginEvents.EDMC_SHUTTING_DOWN) # Handling of application hotkeys now so the user can't possible cause # an issue via triggering one. @@ -1964,7 +2071,11 @@ def test_prop(self): def messagebox_not_py3(): """Display message about plugins not updated for Python 3.x.""" plugins_not_py3_last = config.get_int('plugins_not_py3_last', default=0) - if (plugins_not_py3_last + 86400) < int(time()) and len(plug.PLUGINS_not_py3): + unmigrated_plugins = [ + p[0] for p in app.plugin_manager.failed_loading.items() if isinstance(p[1], LegacyPluginNeedsMigrating) + ] + + if (plugins_not_py3_last + 86400) < int(time()) and len(unmigrated_plugins) > 0: # LANG: Popup-text about 'active' plugins without Python 3.x support popup_text = _( "One or more of your enabled plugins do not yet have support for Python 3.x. Please see the " diff --git a/config.py b/config.py index d690da56fb..4aed1508f8 100644 --- a/config.py +++ b/config.py @@ -875,7 +875,7 @@ def __init__(self, filename: Optional[str] = None) -> None: self.respath_path = pathlib.Path(__file__).parent - self.internal_plugin_dir_path = self.respath_path / 'plugins' + self.internal_plugin_dir_path = self.respath_path / 'plugins2' self.default_journal_dir_path = None # type: ignore self.identifier = f'uk.org.marginal.{appname.lower()}' # TODO: Unused? diff --git a/plug.py b/plug.py index 2f3ab88898..a76d11fb8c 100644 --- a/plug.py +++ b/plug.py @@ -1,341 +1,122 @@ -""" -Plugin hooks for EDMC - Ian Norton, Jonathan Harris -""" -import copy -import importlib -import logging -import operator -import os -import sys -import tkinter as tk -from builtins import object, str -from typing import Optional - -import myNotebook as nb # noqa: N813 +"""EDMC Legacy plugin stubs.""" +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING, Any, Optional + from config import config from EDMCLogging import get_main_logger +from plugin.manager import LoadedPlugin, PluginManager + +if TYPE_CHECKING: + from tkinter import Tk + from typing import TypedDict + + class LastError(TypedDict): + """LastError TypedDict.""" + + msg: str | None + root: Tk + ... logger = get_main_logger() -# List of loaded Plugins -PLUGINS = [] -PLUGINS_not_py3 = [] +# # List of loaded Plugins +# PLUGINS = [] +# PLUGINS_not_py3 = [] -# For asynchronous error display -last_error = { +# # For asynchronous error display +last_error: LastError = { 'msg': None, - 'root': None, + 'root': None, # type: ignore } +_OLD_PROVIDER_LUT = { + 'inara_notify_ship': 'inara.notify_ship', + 'inara_notify_location': 'inara.notify_location', +} -class Plugin(object): - - def __init__(self, name: str, loadfile: str, plugin_logger: Optional[logging.Logger]): - """ - Load a single plugin - :param name: module name - :param loadfile: the main .py file - :raises Exception: Typically ImportError or OSError - """ - - self.name = name # Display name. - self.folder = name # basename of plugin folder. None for internal plugins. - self.module = None # None for disabled plugins. - self.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() - if getattr(module, 'plugin_start3', None): - newname = module.plugin_start3(os.path.dirname(loadfile)) - self.name = newname and str(newname) or name - self.module = module - elif getattr(module, 'plugin_start', None): - logger.warning(f'plugin {name} needs migrating\n') - PLUGINS_not_py3.append(self) - else: - logger.error(f'plugin {name} has no plugin_start3() function') - except Exception as e: - logger.exception(f': Failed for Plugin "{name}"') - raise - else: - logger.info(f'plugin {name} disabled') - - def _get_func(self, funcname): - """ - 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): - """ - 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 - """ - plugin_app = self._get_func('plugin_app') - if plugin_app: - try: - 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): - raise AssertionError - elif not isinstance(appitem, tk.Widget): - raise AssertionError - return appitem - except Exception as e: - logger.exception(f'Failed for Plugin "{self.name}"') - return None - - def get_prefs(self, parent, cmdr, is_beta): - """ - 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. - :param is_beta: whether the player is in a Beta universe. - :returns: a myNotebook Frame - """ - plugin_prefs = self._get_func('plugin_prefs') - if plugin_prefs: - try: - frame = plugin_prefs(parent, cmdr, is_beta) - if not isinstance(frame, nb.Frame): - raise AssertionError - return frame - except Exception as e: - logger.exception(f'Failed for Plugin "{self.name}"') - return None +_manager: Optional[PluginManager] = None -def load_plugins(master): +def provides(name: str) -> list[str]: """ - Find and load all plugins - """ - last_error['root'] = master - - internal = [] - for name in sorted(os.listdir(config.internal_plugin_dir_path)): - if name.endswith('.py') and not name[0] in ['.', '_']: - try: - 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: - logger.exception(f'Failure loading internal Plugin "{name}"') - PLUGINS.extend(sorted(internal, key=lambda p: operator.attrgetter('name')(p).lower())) - - # Add plugin folder to load path so packages can be loaded from plugin folder - sys.path.append(config.plugin_dir) - - 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())): - if not os.path.isdir(os.path.join(config.plugin_dir_path, name)) or name[0] in ['.', '_']: - pass - elif name.endswith('.disabled'): - name, discard = name.rsplit('.', 1) - found.append(Plugin(name, None, logger)) - else: - try: - # Add plugin's folder to load path in case plugin has internal package dependencies - sys.path.append(os.path.join(config.plugin_dir_path, name)) - - # Create a logger for this 'found' plugin. Must be before the - # load.py is loaded. - import EDMCLogging - - 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: - 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): - """ - Find plugins that provide a function - :param fn_name: - :returns: list of names of plugins that provide this function - .. versionadded:: 3.0.2 - """ - return [p.name for p in PLUGINS if p._get_func(fn_name)] + Find plugins that provide a given function. + Note this is a STUB that makes use of the provider system internally, + if possible. -def invoke(plugin_name, fallback, fn_name, *args): + :param name: The name to look for. + :return: A list of plugin names. """ - 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: - :param *args: arguments passed to the function - :returns: return value from the function, or None if the function was not found - .. 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) - 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) + warnings.warn('plug.py is in general deprecated. Please update to newer plugin systems', DeprecationWarning) + if _manager is None: + raise ValueError('Unexpected None Manager') + providers = _manager.get_providers(_OLD_PROVIDER_LUT.get(name, name)) + for plugin in _manager.legacy_plugins: + if plugin in providers: + continue -def notify_stop(): - """ - Notify each plugin that the program is closing. - If your plugin uses threads then stop and join() them before returning. - .. versionadded:: 2.3.7 - """ - error = None - for plugin in PLUGINS: - plugin_stop = plugin._get_func('plugin_stop') - if plugin_stop: - try: - logger.info(f'Asking plugin "{plugin.name}" to stop...') - newerror = plugin_stop() - error = error or newerror - except Exception as e: - logger.exception(f'Plugin "{plugin.name}" failed') + # only do this for legacy plugins. new-style plugins should register + # stuff as providers, even for old-style access. + if hasattr(plugin.module, name): + providers.append(plugin) - logger.info('Done') + return [p.info.name for p in providers] - return error - -def notify_prefs_cmdr_changed(cmdr, is_beta): - """ - Notify each plugin that the Cmdr has been 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. +def _invoke_function(plugin: LoadedPlugin, name: str, args: tuple[Any, ...], kwargs: dict[Any, Any]) -> Any: """ - for plugin in PLUGINS: - prefs_cmdr_changed = plugin._get_func('prefs_cmdr_changed') - if prefs_cmdr_changed: - try: - prefs_cmdr_changed(cmdr, is_beta) - except Exception as e: - logger.exception(f'Plugin "{plugin.name}" failed') + Invoke the given provider name. - -def notify_prefs_changed(cmdr, is_beta): - """ - Notify each plugin 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. - :param cmdr: current Cmdr name (or None). - :param is_beta: whether the player is in a Beta universe. + If the provider does not exist, and the plugin is a MigratedPlugin, attempt to invoke the name directly. """ - for plugin in PLUGINS: - prefs_changed = plugin._get_func('prefs_changed') - if prefs_changed: - try: - prefs_changed(cmdr, is_beta) - except Exception as e: - logger.exception(f'Plugin "{plugin.name}" failed') + func = plugin.provides(name) + if func is not None: + return func(*args, **kwargs) + # We get here if the func doesn't exist. + if not plugin.is_legacy: + return None -def notify_journal_entry(cmdr, is_beta, system, station, entry, state): - """ - 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 - :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) - """ - if entry['event'] in ('Location'): - logger.trace_if('journal.locations', 'Notifying plugins of "Location" event') - - error = None - for plugin in PLUGINS: - journal_entry = plugin._get_func('journal_entry') - if journal_entry: - try: - # 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: - logger.exception(f'Plugin "{plugin.name}" failed') - return error - - -def notify_journal_entry_cqc(cmdr, is_beta, entry, state): - """ - Send a 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) - """ + logger.info(f'name {name!r} invoked via plug on {plugin!r}') - error = None - for plugin in PLUGINS: - cqc_callback = plugin._get_func('journal_entry_cqc') - if cqc_callback is not None and callable(cqc_callback): - try: - # Pass a copy of the journal entry in case the callee modifies it - newerror = cqc_callback(cmdr, is_beta, copy.deepcopy(entry), copy.deepcopy(state)) - error = error or newerror + attr = getattr(plugin.module, name) + if attr is None: + logger.warning(f'Unable to find name {name!r} on {plugin!r} to invoke. bailing!') + return None - except Exception: - logger.exception(f'Plugin "{plugin.name}" failed while handling CQC mode journal entry') + if not callable(attr): + logger.warning(f'Found {name!r} on {plugin!r}, but it is not callable! {attr=}, {type(attr)=}') + return None - return error + return attr(*args, **kwargs) -def notify_dashboard_entry(cmdr, is_beta, entry): +def invoke(plugin: LoadedPlugin | str, fallback: str, func_name: str, *args, **kwargs) -> Any: """ - 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 - :returns: Error message from the first plugin that returns one (if any) - """ - error = None - for plugin in PLUGINS: - status = plugin._get_func('dashboard_entry') - if status: - try: - # 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: - logger.exception(f'Plugin "{plugin.name}" failed') - return error - - -def notify_newdata(data, is_beta): - """ - 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) + Invoke a name on a plugin. + + This is a deprecated plugin. use manager.get_providers instead. + + :param plugin: The plugin to invoke the function on. + :param fallback: A fallback plugin to invoke the function on if plugin doesn't exist or doesn't have the function. + :param func_name: The name of the function to call (this may be translated to a provider name). + :return: The return of the function, if any. """ - error = None - for plugin in PLUGINS: - cmdr_data = plugin._get_func('cmdr_data') - if cmdr_data: - try: - newerror = cmdr_data(data, is_beta) - error = error or newerror - except Exception as e: - logger.exception(f'Plugin "{plugin.name}" failed') - return error + warnings.warn('plug.py is in general deprecated. Please update to newer plugin systems', DeprecationWarning) + if _manager is None: + raise ValueError('Unexpected None Manager') + + real_plugin = plugin if isinstance(plugin, LoadedPlugin) else _manager.get_plugin(plugin) + fallback_plugin = fallback if isinstance(fallback, LoadedPlugin) else _manager.get_plugin(fallback) + + if real_plugin is not None: + return _invoke_function(real_plugin, func_name, args, kwargs) + + if fallback_plugin is not None: + return _invoke_function(fallback_plugin, func_name, args, kwargs) def show_error(err): @@ -346,10 +127,13 @@ def show_error(err): :param err: .. versionadded:: 2.3.7 """ + warnings.warn('plug.py is in general deprecated. Please update to newer plugin systems', DeprecationWarning) if config.shutting_down: 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('<>', when="tail") + if _manager is None: + raise ValueError('Unexpected None Manager') + + _manager.show_status_msg(str(err)) + logger.error(err) diff --git a/plugin/ARCHITECTURE.md b/plugin/ARCHITECTURE.md new file mode 100644 index 0000000000..4a3e7dc583 --- /dev/null +++ b/plugin/ARCHITECTURE.md @@ -0,0 +1,150 @@ +# Architecture + +Plugin implements two things: + +1. A plugin base class and loading system +2. An event engine + +These parts are described below. + +## Plugins + +Plugins are defined as any class that is a subclass of `plugin.Plugin` and is decorated with `decorators.edmc_plugin`, +or a set of functions decorated as callbacks. While the second method of defining a plugin will work, it is discoraged. +TODO: second method does not work + +Plugins are loaded as standard python packages. Meaning that the _only_ file that is directly executed (as in, +not executed via import from another file) is `__init__.py`. This file is required to make the plugin a package anyway. +If a developer does not want to store their plugin classes in `__init__.py`, all that is required within that file is +an import of their main plugin file. + +### Decorators + +There are two decorators that currently defined by plugin: + +1. `edmc_plugin` +2. `hook` +3. `provider` + +`ecmc_plugin` is a class decorator that marks the given class as an edmc plugin to be instantiated later in loading + +`hook` is a function decorator that marks the given function as an edmc callback for any number of events + +`provider` decorates a function that provides information, such as ship and station links for the main EDMC UI. + +### Loading + +On a load call (as in `plugin.manager.PluginManager#load_plugin`), the plugin's module is loaded into the running +interpreter. Once the load is complete, the module is scanned for a decorated class that satisfies the above requirements. +Once a plugin class is found, it is instantiated and the below takes place. + +If the load fails, an exception indicating the failure (likely subclass of PluginLoadingException) will be raised by the +loading machinery, this exception will be caught and logged at the top level of loading. + +### Old style plugins + +Searching for old style plugins is done as part of locating normal plugins. We attempt to load any plugin with an +`__init__.py` as normal, but if it does not contain a decorated plugin class, we then search for a `plugin_start3`. +If we find said function, we load the plugin in the wrapped plugin loading system. Additionally, any suspected plugin +directories that do _not_ have an `__init__.py` are checked for a load.py. Loading of both kinds of legacy plugin +is done in the same way from here on. + +Loading itself is reasonably simple. Plugins are loaded into a shim subclass of `plugin.Plugin` called +`plugin.MigratedPlugin`. `MigratedPlugin` handles delegating events and other callbacks into the legacy plugin. + +During instantiation, `MigratedPlugin` will attempt to gather as much information from the legacy plugin as possible for +use in its `PluginInfo`. Said information is extracted if possible from the modules `__version__`, +`__author__` or `__credits__`, and `__doc__` respectively. If no version is found, a dummy version is substited. +`PluginInfo.name` uses the info returned from `plugin_start3`. + +NB: As legacy plugins do _not_ support reloading, any attempt to reload or unloading these will throw a +`NotImplementedError` + +#### Shimming events + +The `MigratedPlugin` class has a lookup table that tells it what func names to look +for and what they should map to in new event terms. + +Once a given function name has been found, another LUT that contains functions to break +an event object out into the argument format that the methods expect. + +Finally, the function is wrapped with an event handler on the `MigratedPlugin` instance, +which will breakout the event it is passed, pass the breakout to the legacy function, and return the result from the legacy function back to the event source. + +#### Shimming providers + +Providers (like what was called shipyard_url etc) are shimmed in a similar way to events above. + +### Post instantiation of class + +After a plugin class is instantiated, two things happen: + +1. It is scanned for event callbacks +2. Its on load callback is called + +Event callbacks are scanned for and stored as described in the decorator section. + +The choice to load callbacks _before_ on_load is called is intentional -- To prevent `on_load` from modifying callbacks. +If a user wants dynamically generated callbacks, they must do so in `__init__`. This is a design choice that may be +changed, but was made to allow for assumptions that may or may not be made in implementation. + +## Event Engine + +Events are identified by a namespace, and are hooked using the decorator `@hook("namespace.event_name")`. +Event names here are globbed, and thus you can hook onto all events in a given namespace using `@hook("namespace.*")`, +and all events fired with the name `*`. + +all non-special `core` events are as follows: +| Event Name | Expected Signature | Description | +| :------------------ | :------------------------ | ------------------------------------------------------------- | +| `journal_event` | `(JournalEvent) -> None` | Event fired when a new journal event is seen | +| `capi_data` | `(CAPIDataEvent) -> None` | Event fired when new data comes in from CAPI | +| `cqc_journal_event` | `(JournalEvent) -> None` | Event fired when a new journal event is seen and we're in CQC | +TODO: cqc maybe become journal_event.cqc? + +TODO: finish this + +Some `core` events are special, and will work directly with your plugin rather than being global, they are +documented below + +| Event Name | Expected Signature | Description | +| :--------------- | :-------------------------------------------------------- | ---------------------------------------------------------------------------- | +| `core.plugin_ui` | `(BaseDataEvent[tkinter.Tk]) -> Optional[tkinter.Widget]` | Sets up plugins UI (Note that the old style tuple pair is NOT supported) [1] | + +[1]: As an implementation detail, this is fired globally on startup, to simplify getting plugin UIs for all plugins + +### Firing Events + +Events are fired either by using `PluginManager.fire_event` or `PluginManager.fire_targeted_event`. For both, the event +ends up in `LoadedPlugin.fire_event`, which then does the dirty work of finding all of the callbacks that match the +given event name. `LoadedPlugin.fire_event` returns a list of results, which are the return values from each callback, +assuming the callback did not return `None`. If an exception was thrown by a callback, +the Exception object will be placed in the list, if requested by the appropriate option + +Thus it is always safe to assume that `PluginManager.fire_event` returned a dict of at worst `string -> empty list`, or +for `fire_targeted_event`, an empty list on its own. + + +### Providers + +The currently recognised providers of the core are as follows. + +Names stored as variables can be found in `plugin.providers`, and using these names are preferred to literals where +possible. + +| Provider | Expected Signature | Return Description | +| :------------------ | :---------------------------------------------- | ----------------------------------------------------------- | +| `core.shipyard_url` | `(ship_name: str, loadout: LoadoutDict) -> str` | URL to an online shipyard | +| `core.system_url` | `() -> str` | URL to an online information dump of the current system | +| `core.system_text` | `() -> str` | The text to display in the system line on the main UI | +| `core.station_url` | `() -> str` | URL to an online information dump about the current station | +| `core.station_text` | `() -> str` | The text to display in the station line on the main UI | + + +## TODO + +- system|station img to add an image to display alongside name, if any + +- Legacy plugins need `_` to be pushed into their global namespace +- Further tests for unloading that work with unload callbacks, and a test to ensure legacy plugins explode correctly + when unloaded diff --git a/plugin/__init__.py b/plugin/__init__.py new file mode 100644 index 0000000000..20cfb8b4eb --- /dev/null +++ b/plugin/__init__.py @@ -0,0 +1 @@ +"""New plugin system.""" diff --git a/plugin/base_plugin.py b/plugin/base_plugin.py new file mode 100644 index 0000000000..7bf6ce2ccb --- /dev/null +++ b/plugin/base_plugin.py @@ -0,0 +1,69 @@ +""" +Base plugin class. + +This is distinct from plugin.py as plugin.py imports various bits of EDMC that are not needed for testing. +""" +from __future__ import annotations + +import abc +import pathlib +from collections import defaultdict +from typing import TYPE_CHECKING, Callable, Dict, List, Optional + +if TYPE_CHECKING: + from EDMCLogging import LoggerMixin + from plugin.manager import PluginManager + +from plugin.plugin_info import PluginInfo + + +class BasePlugin(abc.ABC): + """Base plugin class.""" + + # TODO: a similar level of paranoia about defined methods where needed + + def __init__(self, logger: LoggerMixin, manager: PluginManager, path: pathlib.Path) -> None: + self.log = logger + self._manager = manager + self.can_reload = True # Set to false to prevent reload support + self.path = path + # TODO: self.loaded? + + @abc.abstractmethod + def load(self) -> PluginInfo: + """ + Load this plugin. + + :param plugin_path: the path at which this module was found. + """ + raise NotImplementedError + + def unload(self) -> None: + """Unload this plugin.""" + raise NotImplementedError + + def reload(self) -> None: + """Reload this plugin.""" + raise NotImplementedError + + def _find_marked_funcs(self, marker) -> Dict[str, List[Callable]]: + out: Dict[str, List[Callable]] = defaultdict(list) + + field_names = list(self.__class__.__dict__.keys()) + list(self.__dict__.keys()) + + for field in (getattr(self, f) for f in field_names): + callbacks: Optional[List[str]] = getattr(field, marker, None) + if callbacks is None: + continue + + for name in callbacks: + out[name].append(field) + + return dict(out) + + def __str__(self) -> str: + """Return BasePlugin represented as a string.""" + return f'Plugin at {self.path} on {self._manager} ' + + def __repr__(self) -> str: + return f'BasePlugin({self._manager!r}, {self.path!r})' diff --git a/plugin/decorators.py b/plugin/decorators.py new file mode 100644 index 0000000000..ee9dde1593 --- /dev/null +++ b/plugin/decorators.py @@ -0,0 +1,161 @@ +"""Decorators for marking plugins and callbacks.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Callable, Dict, Literal, Optional, Type, TypeVar, Union, overload + +from EDMCLogging import get_main_logger +from plugin.base_plugin import BasePlugin +from plugin.event import BaseDataEvent, BaseEvent + +logger = get_main_logger() + +CALLBACK_MARKER = "__edmc_callback_marker__" +PLUGIN_MARKER = "__edmc_plugin_marker__" +PROVIDER_MARKER = "__edmc_provider_marker__" + + +def edmc_plugin(cls: Type[BasePlugin]) -> Type[BasePlugin]: + """Mark any classes decorated with this function.""" + logger.info(f"Found plugin class {cls!r}") + + if not issubclass(cls, BasePlugin): + raise ValueError(f"Cannot decorate non-subclass of Plugin {cls!r} as EDMC Plugin") + + if hasattr(cls, PLUGIN_MARKER): + raise ValueError(f"Cannot re-register plugin class {cls!r}") + + setattr(cls, PLUGIN_MARKER, 0) + logger.trace(f"Successfully marked class {cls!r} as EDMC plugin") + return cls + +# Variadic generics are _not_ currently supported, see https://github.com/python/typing/issues/193 + + +_F = TypeVar('_F', bound=Callable[..., Any]) + + +def _list_decorate(attr_name: str, attr_content: str, func: _F) -> _F: + logger.trace(f'Found function {func!r} to be marked with attr {attr_name!r} and content {attr_content!r}') + if not hasattr(func, attr_name): + setattr(func, attr_name, [attr_content]) + return func + + res: list[str] = getattr(func, attr_name) + if not isinstance(res, list): + raise ValueError(f'Unexpected type on attribute {attr_name!r}: {type(res)=} {res=}') + + if attr_content in res: + raise ValueError(f'Name {attr_content!r} already exists in {func!r}s {attr_name!r} attribute!') + + res.append(attr_content) + setattr(func, attr_name, res) + return func + + +# these are overloads to make typing "normal" edmc hooks easier. they are not special, they can be ignored. +# these names should be up to date with those in event.py -- Unfortunately those constants cannot be used here. + +if TYPE_CHECKING: + # I would put all this in a stub file but it seems mypy continues to vex me. + import tkinter as tk + + from companion import CAPIData + from plugin.event import JournalEvent + from prefs import PreferencesEvent + + # TODO: The rest of these + _TKW = TypeVar('_TKW', bound=tk.Widget) + OWidget = Optional[_TKW] + # _ANY_PREFS_EVENT + _STARTUP_UI = Union[Callable[[Any, BaseDataEvent], OWidget], Callable[[BaseDataEvent], OWidget]] + _JOURNAL_FUNC = Union[Callable[[JournalEvent], None], Callable[[Any, JournalEvent], None]] + + _PLUGIN_PREFS_FUNC = Union[ + Callable[[PreferencesEvent], OWidget], + Callable[[Any, PreferencesEvent], OWidget], + ] + + _NOTIFY_FUNC = Union[Callable[[Any, BaseEvent], None], Callable[[BaseEvent], None]] + + _BDE_DSA = BaseDataEvent[Dict[str, Any]] + _DASHBOARD_FUNC = Union[Callable[[Any, _BDE_DSA], None], Callable[[_BDE_DSA], None]] + _CAPI_ENTRY = Union[Callable[[Any, BaseDataEvent[CAPIData]], None], Callable[[BaseDataEvent[CAPIData]], None]] + _SHUTTING_DOWN_FUNC = _NOTIFY_FUNC + _PREFS_CMDR_CHANGED = _NOTIFY_FUNC + _PREFS_CLOSED = _NOTIFY_FUNC + + +# These overloads cover all of the core events. The Literals for name *MUST* be kept in-sync with those +# found in event.EDMCPluginEvents, otherwise it *fails silently*. +# Unfortunately there is no way to use the annotations from that class. +@overload +def hook(name: Literal['core.setup_ui']) -> Callable[[_STARTUP_UI], _STARTUP_UI]: ... +@overload +def hook(name: Literal['core.journal_event']) -> Callable[[_JOURNAL_FUNC], _JOURNAL_FUNC]: ... +@overload +def hook(name: Literal['core.cqc_journal_event']) -> Callable[[_JOURNAL_FUNC], _JOURNAL_FUNC]: ... +@overload +def hook(name: Literal['core.dashboard_event']) -> Callable[[_DASHBOARD_FUNC], _DASHBOARD_FUNC]: ... +@overload +def hook(name: Literal['core.capi_data']) -> Callable[[_CAPI_ENTRY], _CAPI_ENTRY]: ... +@overload +def hook(name: Literal['core.shutdown']) -> Callable[[_SHUTTING_DOWN_FUNC], _SHUTTING_DOWN_FUNC]: ... +@overload +def hook(name: Literal['core.setup_preferences_ui']) -> Callable[[_PLUGIN_PREFS_FUNC], _PLUGIN_PREFS_FUNC]: ... +@overload +def hook(name: Literal['core.preferences_cmdr_changed']) -> Callable[[_PREFS_CMDR_CHANGED], _PREFS_CMDR_CHANGED]: ... +@overload +def hook(name: Literal['core.preferences_closed']) -> Callable[[_PREFS_CLOSED], _PREFS_CLOSED]: ... +@overload +def hook(name: str) -> Callable[[_F], _F]: ... + + +def hook(name: str): # return type explicitly left to be inferred, because magic is magic. + """ + Create event callback. + + :param name: The event to hook onto + :return: (Internal python decoration implementation) + """ + def _decorate(func: _F) -> _F: + return _list_decorate(CALLBACK_MARKER, name, func) + + return _decorate + + +if TYPE_CHECKING: + ShipyardURLProvider = Literal['core.shipyard_url'] + ShipyardURLReturn = Union[Callable[[str, dict[str, Any]], str], Callable[[Any, str, dict[str, Any]], str]] + StationURLProvider = Literal['core.station_url'] + StationTextProvider = Literal['core.station_text'] + SystemURLProvider = Literal['core.system_url'] + SystemTextProvider = Literal['core.system_text'] + + StringCallableReturners = Union[ + StationURLProvider, StationTextProvider, + SystemURLProvider, SystemTextProvider + ] + StringCallableReturn = Callable[..., str] + StringOverloadReturn = Callable[[StringCallableReturn], StringCallableReturn] + OverloadReturn = Callable[[_F], _F] + + +@overload +def provider(name: ShipyardURLProvider) -> Callable[[ShipyardURLReturn], ShipyardURLReturn]: ... +@overload +def provider(name: StringCallableReturners) -> StringOverloadReturn: ... +@overload +def provider(name: str) -> OverloadReturn: ... + + +def provider(name: str): + """ + Create a provider callback. + + :param name: The provider ID that this provider provides data to + :return: (Internal python decoration implementation) + """ + def _decorate(func: _F) -> _F: + return _list_decorate(PROVIDER_MARKER, name, func) + + return _decorate diff --git a/plugin/event.py b/plugin/event.py new file mode 100644 index 0000000000..cc1963fc8c --- /dev/null +++ b/plugin/event.py @@ -0,0 +1,86 @@ +"""Events for use with manager.pys event system.""" +from __future__ import annotations + +import time +from typing import TYPE_CHECKING, Any, Dict, Generic, Mapping, Optional, TypeVar + +if TYPE_CHECKING: + from companion import CAPIData + + +class EDMCPluginEvents: + """Events EDMC currently uses to communicate with plugins.""" + + STARTUP_UI = 'core.setup_ui' + JOURNAL_ENTRY = 'core.journal_event' + CQC_JOURNAL_ENTRY = 'core.cqc_journal_event' + DASHBOARD_ENTRY = 'core.dashboard_event' + CAPI_DATA = 'core.capi_data' + EDMC_SHUTTING_DOWN = 'core.shutdown' + + PREFERENCES = 'core.setup_preferences_ui' + PREFERNCES_CMDR_CHANGED = 'core.preferences_cmdr_changed' + PREFERENCES_CLOSED = 'core.preferences_closed' + + def __init__(self) -> None: + raise NotImplementedError('This is not to be instantiated.') + + +class BaseEvent: + """ + Base Event class. + + Intended to simply signify that something happened. If you want to pass data + with your event, use one of the subclasses below. + """ + + def __init__(self, name: str, event_time: Optional[float] = None) -> None: + self.name = name + if event_time is None: + event_time = time.time() + + self.time = event_time + + +T = TypeVar('T') + + +class BaseDataEvent(BaseEvent, Generic[T]): + """ + Base Data carrying event class. + + Same as BaseEvent but carries some data as well. + """ + + def __init__(self, name: str, data: T, event_time: float = None) -> None: + super().__init__(name, event_time=event_time) + self.data: T = data + + +class JournalEvent(BaseDataEvent[Mapping[str, Any]]): + """Journal event.""" + + def __init__( + self, name: str, data: Mapping[str, Any], cmdr: str, is_beta: bool, + system: Optional[str], station: Optional[str], state: Dict[str, Any], event_time: float = None + ) -> None: + + super().__init__(name, data=data, event_time=event_time) + self.commander = cmdr + self.get = data.get + + @property + def event_name(self) -> str: + """Get the event name for the current event.""" + return self.data['event'] + + +CAPIDataEvent = BaseDataEvent['CAPIData'] + + +class DashboardEvent(BaseDataEvent[Mapping[str, Any]]): + """Dashboard file changed.""" + + def __init__(self, name: str, commander: str, data: Mapping[Any, Any], event_time: float = None) -> None: + super().__init__(name, data, event_time=event_time) + self.commander = commander diff --git a/plugin/exceptions.py b/plugin/exceptions.py new file mode 100644 index 0000000000..139b097029 --- /dev/null +++ b/plugin/exceptions.py @@ -0,0 +1,37 @@ +"""Exceptions for plugin loading.""" + + +class PluginLoadingException(Exception): + """Plugin load failed.""" + + +class PluginAlreadyLoadedException(PluginLoadingException): + """Plugin is already loaded.""" + + +class PluginHasNoPluginClassException(PluginLoadingException): + """Plugin has no decorated plugin class.""" + + +class PluginDoesNotExistException(PluginLoadingException): + """Requested module does not exist, or requested plugin name does not exist.""" + + def __init__(self, *args: object) -> None: + if len(args) > 0 and isinstance(args[0], str): + new_args: list[object] = [f'Unknown plugin {args[0]!r}'] + new_args.extend(args[1:]) + return super().__init__(*new_args) + + super().__init__(*args) + + +class LegacyPluginNeedsMigrating(PluginLoadingException): + """Legacy plugin has no plugin_start3 but has a plugin_start.""" + + +class LegacyPluginHasNoStart3(PluginLoadingException): + """ + Legacy plugin has no plugin_start3. + + Mostly used as a sentinel to indicate that whatever module is being loaded is not a plugin + """ diff --git a/plugin/legacy_plugin.py b/plugin/legacy_plugin.py new file mode 100644 index 0000000000..71ef2fcdf1 --- /dev/null +++ b/plugin/legacy_plugin.py @@ -0,0 +1,248 @@ +"""Loading machinery for legacy EDMC plugins.""" + +from __future__ import annotations + +import inspect +import pathlib +from types import ModuleType +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple, Union, cast + +import semantic_version + +from plugin import decorators, event +from plugin.exceptions import LegacyPluginHasNoStart3, LegacyPluginNeedsMigrating +from plugin.plugin import EDMCPlugin +from plugin.plugin_info import PluginInfo +from plugin.provider import EDMCProviders + +if TYPE_CHECKING: + import tkinter as tk # see implementation of STARTUP_UI_EVENT below + + from EDMCLogging import LoggerMixin + from plugin.manager import PluginManager + + _LEGACY_UI_FUNC = Callable[ + [tk.Frame], Union[ + Tuple[tk.Widget, tk.Widget], + tk.Widget, + ] + ] + +LEGACY_CALLBACK_LUT: Dict[str, str] = { + # event.EDMCPluginEvents.STARTUP_UI: 'plugin_app', + event.EDMCPluginEvents.PREFERENCES: 'plugin_prefs', + event.EDMCPluginEvents.PREFERENCES_CLOSED: 'prefs_changed', + event.EDMCPluginEvents.JOURNAL_ENTRY: 'journal_entry', + event.EDMCPluginEvents.DASHBOARD_ENTRY: 'dashboard_entry', + event.EDMCPluginEvents.CAPI_DATA: 'cmdr_data', + event.EDMCPluginEvents.EDMC_SHUTTING_DOWN: 'plugin_stop', + + + 'inara.notify_ship': 'inara_notify_ship', + 'inara.notify_location': 'inara_notify_location', + 'edsm.notify_system': 'edsm_notify_system', +} + +LEGACY_CALLBACK_BREAKOUT_LUT: Dict[str, Callable[[Any, 'MigratedPlugin'], Tuple[Any, ...]]] = { + # All of these callables should accept an event.BaseEvent or a subclass thereof + # event.EDMCPluginEvents.STARTUP_UI: lambda e, s: (e.data,), + event.EDMCPluginEvents.PREFERENCES: lambda e, s: (e.notebook, s.commander, s.is_beta), + event.EDMCPluginEvents.PREFERENCES_CLOSED: lambda e, s: (s.commander, s.is_beta), + # 'core.setup_preferences_ui': 'plugin_prefs', + # 'core.preferences_closed': 'prefs_changed', + event.EDMCPluginEvents.JOURNAL_ENTRY: lambda e, s: (s.commander, s.is_beta, s.system, s.station, e.data, s.state), + event.EDMCPluginEvents.DASHBOARD_ENTRY: lambda e, s: (s.commander, s.is_beta, e.data), + event.EDMCPluginEvents.CAPI_DATA: lambda e, s: (e.data, s.is_beta), + + # 'inara.notify_ship': 'inara_notify_ship', + # 'inara.notify_location': 'inara_notify_location', + # 'edsm.notify_system': 'edsm_notify_system', +} + +LEGACY_PROVIDER_LUT: Dict[str, str] = { + EDMCProviders.SYSTEM_URL: 'system_url', + EDMCProviders.STATION_URL: 'station_url', + EDMCProviders.SHIPYARD_URL: 'shipyard_url' +} + +LEGACY_PROVIDER_CONVERT_LUT: Dict[str, Callable[..., Tuple[Tuple[Any, ...], Dict[Any, Any]]]] = { + EDMCProviders.SHIPYARD_URL: lambda ship_name, loadout, /, self: ((loadout, self.is_beta), {}), + EDMCProviders.SYSTEM_URL: lambda self: ((self.system,), {}), + EDMCProviders.STATION_URL: lambda self: ((self.system, self.station), {}) +} # converting args from old to new + + +class MigratedPlugin(EDMCPlugin): + """MigratedPlugin is a wrapper for old-style plugins.""" + + OSTR = Optional[str] + JOURNAL_EVENT_SIG = Callable[[str, bool, OSTR, OSTR, Dict[str, Any], Dict[str, Any]], None] + + def __init__(self, logger: LoggerMixin, module: ModuleType, manager: PluginManager, path: pathlib.Path) -> None: + super().__init__(logger, manager, path) + self.can_reload = False + self.module = module + # Find start3 + plugin_start3: Optional[Callable[[str], str]] = getattr(self.module, 'plugin_start3', None) + plugin_start: Optional[Callable[[str], str]] = getattr(self.module, 'plugin_start', None) + + if plugin_start3 is None: + if plugin_start is not None: + raise LegacyPluginNeedsMigrating + + raise LegacyPluginHasNoStart3 + + self.enforce_load3_signature(plugin_start3) + self.start3 = plugin_start3 + + # We have a start3, lets see what else we have and get ready to prepare hooks for them + self.setup_callbacks() + self.setup_providers() + + def setup_callbacks(self) -> None: + """ + Set up shimmed callbacks for any event the legacy plugin may have. + + See ARCHITECHTURE.md for more explanation. + """ + for new_hook, old_callback in LEGACY_CALLBACK_LUT.items(): + callback: Optional[Callable] = getattr(self.module, old_callback, None) + if callback is None: + continue + + target_name = f"_SYNTHETIC_CALLBACK_{old_callback}" + breakout = LEGACY_CALLBACK_BREAKOUT_LUT.get(new_hook, lambda e, self: ()) + + wrapped = self.generic_callback_handler(callback, breakout) + setattr(self, target_name, decorators.hook(new_hook)(wrapped)) + + def setup_providers(self) -> None: + """Set up shimmed providers for any providers the legacy plugin may have.""" + for new_name, old_name in LEGACY_PROVIDER_LUT.items(): + callback: Optional[Callable] = getattr(self.module, old_name, None) + if callback is None: + continue + + def default_wrapper(*args, **kwargs): + kwargs.pop('self', None) + return args, kwargs + + convert = LEGACY_PROVIDER_CONVERT_LUT.get(new_name, default_wrapper) + wrapped = self.generic_provider_handler(callback, convert) + setattr(self, f'_SYNTHETIC_PROVIDER_{old_name}', decorators.provider(new_name)(wrapped)) + + def load(self) -> PluginInfo: + """ + Load the legacy plugin. + + Do our best to get any comment or version information that may exist in old-style variables and docstrings + + :param plugin_path: The path to this plugin + :return: PluginInfo telling the world about us + """ + name = self.start3(str(self.path)) + + if (version_str := getattr(self.module, "__version__", None)) is not None: + version = semantic_version.Version(version_str) + + else: + version = semantic_version.Version('0.0.0+UNKNOWN') + + authors = getattr(self.module, '__author__', None) + if authors is None: + authors = getattr(self.module, "__credits__", None) + + if authors is not None and not isinstance(authors, list): + authors = [authors] + + comment = getattr(self.module, "__doc__", None) + + return PluginInfo(name, version, authors=authors, comment=comment) + + @staticmethod + def enforce_load3_signature(load3: Callable): + """ + Ensure that plugin_load3 is the expected function. + + :param load3: The callable to check + :raises ValueError: If the given callable is not actually a callable + :raises ValueError: If the given callable accepts the wrong number of args + """ + if not callable(load3): + raise ValueError(f'load3 provided by plugin is not callable: {load3!r}') + + sig = inspect.signature(load3) + if not len(sig.parameters) == 1: + raise ValueError( + 'load3 provided by legacy plugin takes an unexpected arg count:' + f'{len(sig.parameters)}; {sig.parameters}' + ) + + def generic_callback_handler( + self, f: Callable, breakout: Callable[[event.BaseEvent, MigratedPlugin], Tuple[Any, ...]] + ): + """ + Wrap the given callback with the given event breakout. + + It is expected that `breakout` is a callable that accepts any subclass of event.BaseEvent + + :param f: The callback to wrap + :param breakout: The breakout method + """ + def wrapper(e: event.BaseEvent): + return f(*breakout(e, self)) + + setattr(wrapper, "original_func", f) + return wrapper + + def generic_provider_handler(self, f: Callable, convert: Callable): + """Wrap the given provider callback in the given callable.""" + def wrapper(*args, **kwargs): + new_args, new_kwargs = convert(*args, self=self, **kwargs) + try: + return f(*new_args, **new_kwargs) + except Exception: + self.log.warning(f'Exception thrown while calling {f} on {self}', exc_info=True) + raise + + setattr(wrapper, 'original_func', f) + + return wrapper + + @decorators.hook(event.EDMCPluginEvents.STARTUP_UI) + def ui_wrapper(self, data_event: event.BaseDataEvent) -> Optional[tk.Widget]: + """Wrap the legacy UI system with the new system that always expects a single widget.""" + import tkinter as tk # Importing this here to make most subclasses of this not HAVE to have this sitting here + frame: tk.Frame = data_event.data + if (f := getattr(self.module, 'plugin_app', None)) is None: + return None + + f = cast('_LEGACY_UI_FUNC', f) + res = f(frame) + if res is None: + return None + + if isinstance(res, tk.Widget): + return res + + elif ( + isinstance(res, tuple) + and len(res) == 2 + and isinstance(res[0], tk.Widget) + and isinstance(res[1], tk.Widget) + ): + # Its expected that these used out_frame above as their master, thus we simply need to grid them here + # before sending our frame (in a frame, because why not) upwards to the UI + res[0].grid(column=0, row=0) + res[1].grid(column=1, row=0) + + return frame + + self.log.warning( + f'plugin_app returned something unexpected: {type(res)=}, {res=}! Assuming its unsafe and bailing on its UI' + ) + return None + + def unload(self) -> None: + """Legacy plugins do not support unloading.""" + raise NotImplementedError('Legacy plugins do not support unloading') diff --git a/plugin/manager.py b/plugin/manager.py new file mode 100644 index 0000000000..c3056f6582 --- /dev/null +++ b/plugin/manager.py @@ -0,0 +1,526 @@ +"""Main plugin engine.""" +from __future__ import annotations + +import importlib +import itertools +import pathlib +import sys +from fnmatch import fnmatch +from queue import Queue +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Sequence, Tuple, Type, Union + +if TYPE_CHECKING: + from types import ModuleType + from EDMCLogging import LoggerMixin + +from EDMCLogging import get_main_logger, get_plugin_logger +from plugin import decorators +from plugin.base_plugin import BasePlugin +from plugin.event import BaseEvent +from plugin.exceptions import ( + LegacyPluginNeedsMigrating, PluginAlreadyLoadedException, PluginDoesNotExistException, + PluginHasNoPluginClassException, PluginLoadingException +) +from plugin.legacy_plugin import MigratedPlugin +from plugin.plugin_info import PluginInfo + +PLUGIN_MODULE_PAIR = Tuple[Optional[BasePlugin], Optional['ModuleType']] + + +class LoadedPlugin: + """LoadedPlugin represents a single plugin, its module, and callbacks.""" + + def __init__(self, info: PluginInfo, plugin: BasePlugin, module: ModuleType) -> None: + # TODO: System to mark incompatibilities + self.info: PluginInfo = info + self.plugin: BasePlugin = plugin + self.module: ModuleType = module + self.callbacks: Dict[str, List[Callable]] = plugin._find_marked_funcs(decorators.CALLBACK_MARKER) + self.providers: Dict[str, Callable] = {} + + for provides, funcs in plugin._find_marked_funcs(decorators.PROVIDER_MARKER).items(): + if len(funcs) != 1: + raise ValueError('plugin {self} provides multiple functions for provider {provides!r}') + + self.providers[provides] = funcs[0] + + def __str__(self) -> str: + """Represent this plugin as a string.""" + return ( + f'Plugin {self.info.name} from {self.module} on {self.plugin._manager}' + f' with {len(self.callbacks)} callbacks' + ) + + def __repr__(self) -> str: + """Python(ish) string representation.""" + return f'LoadedPlugin({self.info}, {self.plugin}, {self.module})' + + @property + def log(self) -> 'LoggerMixin': + """Get the plugin logger represented by this LoadedPlugin.""" + return self.plugin.log + + @property + def is_legacy(self) -> bool: + return isinstance(self.plugin, MigratedPlugin) + + def _fire_event_funcs(self, event: BaseEvent, funcs: list[Callable], keep_exceptions: bool) -> list[Any]: + out = [] + for func in funcs: + try: + res = func(event) + if res is not None: + out.append(res) + + except Exception as e: + self.log.exception( + f'Caught an exception while firing event {event.name!r} ' + f'for plugin {self.info.name} (on func {func})' + ) + + if keep_exceptions: + out.append(e) + + return out + + def fire_event(self, event: BaseEvent, keep_exceptions: bool = False) -> list[Any]: + """ + Call all event callbacks that match the given event. + + :param event: the event to pass + """ + called: set[Callable] = set() + results = [] + for e, funcs in self.callbacks.items(): + if not (e == event.name or e == '*' or fnmatch(event.name, e)): + continue + + for f in filter(lambda f: f in called, funcs): + self.log.warn(f'Refusing to call func {f} on {self} repeatedly for event {event.name}') + + results.extend(self._fire_event_funcs(event, [f for f in funcs if f not in called], keep_exceptions)) + called = called.union(funcs) + + return results + + def provides(self, name: str) -> Optional[Callable]: + """If this plugin provides a given provider name, return the function that provides it.""" + return self.providers.get(name, None) + + +class PluginManager: + """PluginManager is an event engine and plugin engine.""" + + def __init__(self, show_status_msg: Callable[[str], None] | None) -> None: + self.log = get_main_logger() + self.log.info("starting new plugin management engine") + self.plugins: Dict[str, LoadedPlugin] = {} + self.failed_loading: Dict[pathlib.Path, Exception] = {} # path -> reason + self.disabled_plugins: List[pathlib.Path] = [] + # self._plugins_previously_loaded: Set[str] = set() + + if show_status_msg is None: + self.show_status_msg = lambda s: self.log.info(s) + else: + self.show_status_msg = show_status_msg + + def find_potential_plugins(self, path: pathlib.Path) -> List[pathlib.Path]: + """ + Search for plugins at the given path. + + :param path: The path to search at + :return: All plugins found + """ + return list(filter(lambda f: f.is_dir(), path.iterdir())) + + @staticmethod + def resolve_path_to_plugin(path: pathlib.Path, relative_to=None) -> str: + """ + Convert a file path to a python import path. + + :param path: The path to convert + :param relative_to: A directory in sys.path that is above the given path, defaults to the current working dir + :return: The resolved path + """ + if relative_to is None: + relative_to = pathlib.Path.cwd() + + relative = path.relative_to(relative_to) + return ".".join(relative.parts) + + def load_normal_plugin(self, path: pathlib.Path, autoresolve_sys_path=True) -> PLUGIN_MODULE_PAIR: + """ + Load a plugin at the given path. + + Note that if the parent directory of the given path does _not_ exist in sys.path already, it will be added. + This can be disabled with the autoresolve_sys_path bool + + :param path: The path to load a plugin from + :param autoresolve_sys_path: Whether or not to add the parent of the given directory to sys.path if needed + :return: The LoadedPlugin, or None / an exception. + """ + self.log.info(f"attempting to load plugin(s) at path {path} ({path.absolute()})") + second_parent = path.parent.parent.absolute() + + # TODO: This probably pollutes sys.path more than needed. Either this should take a relative_to arg to pass + # TODO: to resolve_path_to_plugin, or, we should somehow indicate what the base plugin path is to this function + if autoresolve_sys_path and str(second_parent) not in sys.path: + sys.path.append(str(second_parent)) + + try: + resolved = self.resolve_path_to_plugin(path, relative_to=second_parent) + self.log.trace(f"Resolved plugin path to import path {resolved}") + module = importlib.import_module(resolved) + + except ImportError as e: + self.log.warning("Attempted to load nonexistent module path {path}") + raise PluginDoesNotExistException from e + + except Exception as e: + self.log.error(f"Unable to load module {path}") + raise PluginLoadingException(f"Exception occurred while loading: {e}") from e + + uninstantiated: Optional[Type[BasePlugin]] = None + + self.log.trace(f'Searching for decorated plugin class in module at {path}') + # Okay, we have the module loaded, lets find any actual plugins + for class_name, cls in module.__dict__.items(): + if not hasattr(cls, decorators.PLUGIN_MARKER): + continue + + self.log.trace(f'Found decorated plugin class for {path}: {class_name} ({cls!r})') + uninstantiated = cls + break + + if uninstantiated is None: + self.log.trace(f'No plugin class found in module at {path}') + raise PluginHasNoPluginClassException + + plugin_logger = get_plugin_logger(path.parts[-1]) + instance: Optional[BasePlugin] = None + + try: + instance = uninstantiated(plugin_logger, self, path) + + except Exception as e: + self.log.exception(f'Could not load plugin class for plugin at {path} ({uninstantiated!r}): {e}') + raise PluginLoadingException(f'Cannot load plugin {uninstantiated!r}: {e}') from e + + return instance, module + + def __get_plugin_at(self, path: pathlib.Path, autoresolve_sys_path=True) -> PLUGIN_MODULE_PAIR: # noqa: CCR001 + init = path / '__init__.py' + load = path / 'load.py' + + if not path.exists() or (not init.exists() and not load.exists()): + raise PluginDoesNotExistException + + plugin: Optional[BasePlugin] = None + module: Optional[ModuleType] = None + + if init.exists(): + # Could be either type, start by trying a normal plugin + try: + plugin, module = self.load_normal_plugin(path, autoresolve_sys_path=autoresolve_sys_path) + except PluginHasNoPluginClassException: + if not load.exists(): + raise + + except PluginLoadingException as e: + self.log.exception(f'Unable to load plugin at {path}: {e}') + raise + + except Exception as e: + self.log.exception(f'Exception occurred during loading plugin at {path}: {e} THIS IS A BUG!') + raise + + if load.exists() and plugin is None: + # We have a load.py, and loading the plugin as a new style plugin failed. Try migrate the plugin + self.log.trace( + f'Attempt to load {path} as a normal plugin failed. Attempting to load it as a legacy plugin' + ) + + try: + plugin, module = self.load_legacy_plugin(path, autoresolve_sys_path=autoresolve_sys_path) + + except PluginLoadingException as e: + self.log.exception(f'Unable to load legacy plugin at {path}: {e}') + raise + + except Exception as e: + self.log.exception(f'Exception occurred during loading of legacy plugin at {path}: {e} THIS IS A BUG') + raise + + return plugin, module + + def load_all_plugins_in(self, plugin_dir: pathlib.Path) -> List[LoadedPlugin]: + """ + Load all plugins in the given path. + + As a side effect, this also notes what plugins are disabled. + + :param plugin_dir: The directory in which to search for plugins. + :return: All the plugins loaded by this call. + """ + if not plugin_dir.exists(): + return [] + + possible_plugins = self.find_potential_plugins(plugin_dir) + to_load = list(filter(self.is_valid_plugin_directory, possible_plugins)) + self.disabled_plugins = sorted(set(possible_plugins) ^ set(to_load)) + self.log.info( + f'Loading {len(to_load)} plugins in directory {plugin_dir} ({len(self.disabled_plugins)} disabled)' + ) + + return [x for x in self.load_plugins(to_load) if x is not None] + + def load_plugins( + self, paths: Sequence[pathlib.Path], autoresolve_sys_path=True + ) -> list[Optional[LoadedPlugin]]: + """ + Load all plugins described by paths. + + Plugins that error on load will return None rather than a LoadedPlugin + + :param paths: The paths to load + :param autoresolve_sys_path: See load_plugin, defaults to True + :return: Loaded plugins, same order as the given paths (assuming an order exists in the sequence) + """ + out: list[Optional[LoadedPlugin]] = [] + + for path in paths: + try: + res = self.load_plugin(path) + if res is not None: + self.log.info(f'Loaded {res.info.name} from {path}') + + else: + self.log.warning(f'Failed to load plugin at {path}') + + except PluginLoadingException: + res = None + + out.append(res) + + return out + + def load_plugin(self, path: pathlib.Path, autoresolve_sys_path=True) -> Optional[LoadedPlugin]: + """ + Load either a normal or legacy plugin from the given path. + + Normal plugins are tried first, then the two legacy plugin types in order + + :param path: The path to the directory in which the plugin lies + :param autoresolve_sys_path: See load_normal_plugin, defaults to True + :return: The loaded plugin, if successful + """ + # TODO: PLUGINS.md indicates that for legacy plugins, plugins _with_ an __init__.py should be loaded first + # TODO: Likely this will be done a step above in whatever is done for ordering the list for iteration + self.log.trace(f'start load of {path} ({autoresolve_sys_path=}') + + plugin, module = None, None + try: + plugin, module = self.__get_plugin_at(path, autoresolve_sys_path=autoresolve_sys_path) + except LegacyPluginNeedsMigrating as e: + # This is the only "expected" exception that can happen here. + self.failed_loading[path] = e + return None + + if plugin is None or module is None: + raise ValueError('All attempts to load both failed and did not raise any exceptions. THIS IS A BUG') + + # At this point, we have _a_ plugin. Don't really care if its a legacy or otherwise, as far as we're concerned + # if it walks like a duck, talks like a duck, and quacks like a duck, its a plugin + + self.log.trace(f'Calling load method on {plugin}') + try: + info = plugin.load() + except PluginLoadingException as e: + self.failed_loading[path] = e + return None + + except Exception as e: + raise PluginLoadingException(f'Exception in load method of {plugin}: {e}') from e + + if info is None: + raise PluginLoadingException(f'{plugin} did not return a valid PluginInfo') + + elif not isinstance(info, PluginInfo): + raise PluginLoadingException( + f'{plugin} returned an invalid type for its PluginInfo: {type(info)}({info!r})' + ) + + if info.name in self.plugins: + raise PluginAlreadyLoadedException(info.name) + + loaded = LoadedPlugin(info, plugin, module) + self.plugins[info.name] = loaded + self.log.trace(f'successfully loaded {loaded}') + + return loaded + + def load_legacy_plugin(self, path: pathlib.Path, autoresolve_sys_path=True) -> PLUGIN_MODULE_PAIR: + """ + Load a legacy (load.py and plugin_start3()) plugin from the given path. + + :param path: The path to the _directory_ in which the plugin is located + :raises PluginDoesNotExistException: When the plugin does not exist + :raises PluginLoadingException: When an exception occurs during loading + :return: A MigratedPlugin instance + """ + target = path / "load.py" + if not target.exists(): + raise PluginDoesNotExistException + + # TODO: set up the plugin path in sys.path? Note that this probably has special behaviour if an __init__ is + # TODO: present + parent = path.parent.parent # step up two; plugin dir + if autoresolve_sys_path: + if str(parent) not in sys.path: + sys.path.append(str(parent)) + + if str(path) not in sys.path: + sys.path.append(str(path)) + + resolved = self.resolve_path_to_plugin(target, parent)[:-3] # strip off .py + + logger = get_plugin_logger(path.parts[-1]) + try: + module = importlib.import_module(resolved) + except Exception as e: + # Something went wrong _but_ the file _DOES_ exist. + raise PluginLoadingException(f'Exception while loading {resolved}: {e}') from e + + self.log.trace(f'Begin migration of legacy plugin at {path}') + + # This can raise, but we want it to go through us to the upper loading machinery + plugin = MigratedPlugin(logger, module, self, path) + self.log.trace(f'Migration of {plugin} complete.') + + return plugin, module + + def is_plugin_loaded(self, name: str) -> bool: + """ + Check if a plugin is loaded under a given name. + + :param name: The name to search for + :return: Whether or not the name is loaded + """ + return name in self.plugins + + def get_plugin(self, name: str) -> Optional[LoadedPlugin]: + """ + Get the plugin identified by name, if it exists. + + :param name: The plugin name to search for. + :return: The plugin if it exists, otherwise None + """ + return self.plugins.get(name) + + def unload_plugin(self, name: str): + """ + Unload the plugin identified by the given name. + + :param name: The name to unload + """ + to_unload = self.get_plugin(name) + if to_unload is None: + self.log.warn(f"Attempt to unload nonexistent plugin {name}") + return + + try: + to_unload.plugin.unload() + + except Exception as e: + self.log.exception(f"Exception occurred while attempting to fire unload callback on {name}: {e}") + + except SystemExit: + self.log.critical(f"Unload of {name} attempted to stop the running interpreter! Catching!") + + del self.plugins[name] + + def fire_event(self, event: BaseEvent, keep_exceptions: bool = False) -> Dict[str, List[Any]]: + """Call all callbacks listening for the given event.""" + out: Dict[str, Any] = {} + for name, p in self.plugins.items(): + self.log.trace(f'Firing event {event.name} for plugin {name} (keeping exceptions: {keep_exceptions})') + res = p.fire_event(event, keep_exceptions=keep_exceptions) + if name in out: + self.log.warning(f'Two plugins with the same name?????? {out[name]=} {name=} {res=}') + + out[name] = res + + return out + + def fire_str_event( + self, event_name: str, time: Optional[float] = None, keep_exceptions: bool = False + ) -> Dict[str, List[Any]]: + """Construct a BaseEvent from the given string and time and fire it.""" + return self.fire_event(BaseEvent(event_name, event_time=time), keep_exceptions=keep_exceptions) + + def fire_targeted_event(self, target: Union[LoadedPlugin, str], event: BaseEvent) -> list[Any]: + """Fire an event just for a particular plugin.""" + if isinstance(target, str): + found = self.get_plugin(target) + if found is None: + raise PluginDoesNotExistException(found) + + target = found + + self.log.trace(f'Firing targeted event {event.name} at {target.info.name}') + return target.fire_event(event) + + def get_providers(self, name: str) -> List[LoadedPlugin]: + """ + Get all LoadedPlugins that provide the given provider name. + + :param name: The provider name to search for + :return: A list of plugins that provide the given name + """ + out = [] + for p in self.plugins.values(): + if p.provides(name): + out.append(p) + + return out + + def get_providers_dict(self, name: str) -> dict[str, Callable]: + """ + Return a dictionary of plugin name -> provider function for all loaded plugins. + + :param name: The provider name to search for + :return: A dictionary of plugin name -> provider function + """ + return {plug_name: plug.providers[name] for plug_name, plug in self.plugins.items() if plug.provides(name)} + + @property + def legacy_plugins(self) -> List[LoadedPlugin]: + """Return a list of LoadedPlugin instances that are MigratedPlugins.""" + return [p for p in self.plugins.values() if isinstance(p.plugin, MigratedPlugin)] + + @staticmethod + def is_valid_plugin_directory(p: pathlib.Path) -> bool: + """Return whether or not the given path is a valid plugin directory.""" + return p.is_dir() and p.exists() and not ( + p.name.startswith('.') or p.name.startswith('_') or p.name.endswith('.disabled') + ) + + +def string_fire_results(results: Dict[str, List[Any]]) -> str: + """ + Return a string representing the given results list. + + Utility method for EDMarketConnector.py and others to extract + exception infomation to present to users. + + :param results: a list as returned from fire_event + :return: a string with information about thrown exceptions, if any + """ + exceptions = [e for e in itertools.chain(*results.values()) if isinstance(e, Exception)] + if len(exceptions) == 0: + return '' + + if len(exceptions) == 1: + return str(exceptions)[0] + + return f'{len(exceptions)} Exceptions thrown during hook processing' diff --git a/plugin/plugin.py b/plugin/plugin.py new file mode 100644 index 0000000000..09bb4973f7 --- /dev/null +++ b/plugin/plugin.py @@ -0,0 +1,162 @@ +""" +EDMC specific plugin implementations. + +See base_plugin.py for base plugin implementation. + +base_plugin.py and plugin.py are distinct to allow for simpler testing -- this +file imports many different chunks of EDMC that are not needed for testing of the plugin system itself. +""" +from __future__ import annotations + +import pathlib +from typing import TYPE_CHECKING, Any, Optional, final + +import config +import constants +import killswitch +import l10n +import monitor # TODO: This SHOULD be fine, at the time we're loaded +from plugin.base_plugin import BasePlugin +from theme import _Theme, theme + +if TYPE_CHECKING: + import semantic_version + + from config import AbstractConfig + from EDMCLogging import LoggerMixin + from plugin.manager import PluginManager + +# the import of things like monitor is intentionally *NOT* using a `from` import +# this is because internally, we might modify or replace monitor at some point. +# and if a from import was used here, the resolution would break. +# additionally, due to the way we work with plugins and hooks, the value should +# always be correct, assuming you're *not* accessing them from a thread. If you +# ARE accessing them from a thread, the GIL promises that they wont be modified +# at the same time you work with them (from a internal-to-python data race +# perspective.) However, it *may* still change during your processing. +# Caveat Emptor. If you want to be sure its safe, store a copy of the result + + +class EDMCPlugin(BasePlugin): + """Elite Dangerous Market Connector plugin base.""" + + def __init__(self, logger: LoggerMixin, manager: PluginManager, path: pathlib.Path) -> None: + super().__init__(logger, manager, path) + + self.killswitch: killswitch.KillSwitchSet = killswitch.active # Not final so plugins can set their own + + @final + def translate(self, s: str, context: Optional[str] = None) -> str: + """ + Translate the given string. + + :param s: String to translate + :param context: Context to find the translation files, defaults to the plugins directory + :return: The translated string + """ + if context is None: + context = str(self.path) + + return l10n.Translations.translate(s, context=context) + + @final + def show_status_msg(self, msg: str) -> None: + """ + Show a message on the main UI status bar. + + :param msg: The message to show + """ + self._manager.show_status_msg(msg) + + # Properties for accessing various bits of EDMC data + + @property + @final + def theme(self) -> _Theme: + """Theming for plugin widgets.""" + return theme + + @property + @final + def edmc_name(self) -> str: + """EDMC appname.""" + return constants.appname + + @property + @final + def edmc_long_name(self) -> str: + """EDMC applongname.""" + return constants.applongname + + @property + @final + def edmc_cmd_name(self) -> str: + """EDMC cmdname.""" + return config.appcmdname + + @final + def edmc_version(self, no_build=False) -> semantic_version.Version: + """Return the current EDMC Version.""" + if no_build: + return config.appversion_nobuild() + return config.appversion() + + @property + @final + def edmc_copyright(self) -> str: + """Return the current EDMC Copyright statement.""" + return config.copyright + + @property + @final + def is_beta(self) -> bool: + """Return whether or not the running ED instance is a prerelease.""" + return monitor.monitor.is_beta + + @property + @final + def commander(self) -> str | None: + """Return the current commander, if any.""" + return monitor.monitor.cmdr + + @property + @final + def system(self) -> str | None: + """Return the current system, if any.""" + return monitor.monitor.system + + @property + @final + def system_address(self) -> int | None: + """Return the current system address, if any.""" + return monitor.monitor.systemaddress + + @property + @final + def system_population(self) -> int | None: + """Return the current system population, if known.""" + return monitor.monitor.systempopulation + + @property + @final + def station(self) -> str | None: + """Return the current station, if any.""" + return monitor.monitor.station + + @property + @final + def station_marketid(self) -> int | None: + """Return the current marketid for the current station, if any.""" + return monitor.monitor.station_marketid + + @property + @final + def state(self) -> dict[str, Any]: + """Return the currently tracked state, if any.""" + return monitor.monitor.state + + @property + @final + def config(self) -> AbstractConfig: + """Return the currently in use config.""" + return config.config diff --git a/plugin/plugin_info.py b/plugin/plugin_info.py new file mode 100644 index 0000000000..6099c15d15 --- /dev/null +++ b/plugin/plugin_info.py @@ -0,0 +1,24 @@ +"""Information on a given plugin.""" + +import dataclasses +from typing import List, Optional, Union + +import semantic_version + + +@dataclasses.dataclass +class PluginInfo: + """PluginInfo holds information about a loaded plugin.""" + + name: str + version: Union[semantic_version.Version, str] + authors: Optional[List[str]] = None + comment: Optional[str] = None + + # TODO: implement update checking and optional downloading + update_url: Optional[str] = None + + def __post_init__(self) -> None: + """Post-init to convert a string self.version to a Version.""" + if isinstance(self.version, str): + self.version = semantic_version.Version.coerce(self.version) diff --git a/plugin/provider.py b/plugin/provider.py new file mode 100644 index 0000000000..45553d0a59 --- /dev/null +++ b/plugin/provider.py @@ -0,0 +1,11 @@ +"""Nice aliases for standard providers.""" + + +class EDMCProviders: + """Provider name aliases.""" + + SHIPYARD_URL = 'core.shipyard_url' + STATION_URL = 'core.station_url' + STATION_TEXT = 'core.station_text' + SYSTEM_URL = 'core.system_url' + SYSTEM_TEXT = 'core.system_text' diff --git a/plugin/test/__init__.py b/plugin/test/__init__.py new file mode 100644 index 0000000000..8a5dea49be --- /dev/null +++ b/plugin/test/__init__.py @@ -0,0 +1 @@ +"""Test the plugin system.""" diff --git a/plugin/test/conftest.py b/plugin/test/conftest.py new file mode 100644 index 0000000000..cbe6a553cd --- /dev/null +++ b/plugin/test/conftest.py @@ -0,0 +1,21 @@ +"""Setup constants and fixtures for plugin tests.""" +import pathlib +import typing + +from pytest import fixture + +from plugin.manager import PluginManager + + +@fixture +def plugin_manager() -> typing.Generator[PluginManager, None, None]: + """Provide a PluginManager as a fixture.""" + yield PluginManager() + + +current_path = pathlib.Path.cwd() / 'plugin/test/test_plugins' +good_path = current_path / 'good' +bad_path = current_path / 'bad' +legacy_path = current_path / 'legacy' +legacy_good_path = legacy_path / 'good_l' # these are required as the paths being the same messes with imports +legacy_bad_path = legacy_path / 'bad_l' diff --git a/plugin/test/test_event.py b/plugin/test/test_event.py new file mode 100644 index 0000000000..5970c1038c --- /dev/null +++ b/plugin/test/test_event.py @@ -0,0 +1,52 @@ +"""Test that the event engine is working as expected.""" +from typing import cast + +from plugin.event import BaseEvent +from plugin.manager import LoadedPlugin, PluginManager + +from .conftest import good_path + +# spell-checker: words uncore + + +def test_fire_event(plugin_manager: PluginManager) -> None: + """Test that firing an event works correctly from the manager.""" + p = plugin_manager.load_plugin(good_path / 'simple_with_callback') + assert p is not None + + test_event = BaseEvent('core.journal_event') + plugin_manager.fire_event(test_event) + assert test_event in getattr(p.plugin, 'called') + + +def test_catchall_event(plugin_manager: PluginManager) -> None: + """Test that an * event hook is correctly resolved.""" + loaded = plugin_manager.load_plugins( + (good_path / 'simple_with_callback', good_path / 'simple_full_wildcard', good_path / 'simple_nonfull_wildcard') + ) + + assert all(x is not None for x in loaded) + loaded = cast(list[LoadedPlugin], loaded) # type: ignore + + test_event = BaseEvent('core.journal_event') + plugin_manager.fire_event(test_event) + + # if called exists, assert that it has the expected content. + assert all(test_event in getattr(p, 'called') if hasattr(p, 'called') else True for p in loaded) + + +def test_multiple_hooks(plugin_manager: PluginManager) -> None: + """Test that a hook function with multiple defined callbacks works.""" + p = plugin_manager.load_plugin(good_path / 'multi_callback') + assert p is not None + + test_journal = BaseEvent('core.journal_event') + test_not_journal = BaseEvent('uncore.not_journal_event') + + p.fire_event(test_journal) + assert len(getattr(p.plugin, 'called')) == 1 + p.fire_event(test_not_journal) + assert len(getattr(p.plugin, 'called')) == 2 + + assert test_journal in getattr(p.plugin, 'called') + assert test_not_journal in getattr(p.plugin, 'called') diff --git a/plugin/test/test_load.py b/plugin/test/test_load.py new file mode 100644 index 0000000000..ca0e7eb93e --- /dev/null +++ b/plugin/test/test_load.py @@ -0,0 +1,160 @@ +"""Testing suite for plugin loading system.""" +from __future__ import annotations + +import pathlib +from contextlib import nullcontext +from typing import TYPE_CHECKING, Any, ContextManager, List, Tuple + +import pytest + +from plugin.decorators import CALLBACK_MARKER +from plugin.exceptions import ( + PluginAlreadyLoadedException, PluginDoesNotExistException, PluginHasNoPluginClassException, PluginLoadingException +) +from plugin.legacy_plugin import LEGACY_CALLBACK_LUT + +from .conftest import bad_path, good_path, legacy_bad_path, legacy_good_path, legacy_path + +if TYPE_CHECKING: + from plugin.manager import PluginManager + +# spell-checker: words uncore + + +def _idfn(test_data) -> str: + if not isinstance(test_data, pathlib.Path): + return "" + + if legacy_path in test_data.parents: + return f'Legacy_{test_data.parts[-1]}' + + return test_data.parts[-1] + + +LEGACY_TESTS: List[Tuple[pathlib.Path, Any]] = [ + (legacy_good_path / 'simple', nullcontext()), + + # This is tested below, being here and there causes issues with double hooked methods + (legacy_good_path / 'all_callbacks', nullcontext()), + (legacy_bad_path / "load_error", pytest.raises(PluginLoadingException, match=r'Exception in load method.*BANG!$')), + ( + legacy_bad_path / 'import_error', + pytest.raises( + PluginLoadingException, + match="No module named 'ThisDoesNotExistEDMCLibNeedsMoreTextToEnsureUnique'" + ) + ), +] + + +GOOD_TESTS: List[Tuple[pathlib.Path, ContextManager]] = [ + (path, nullcontext()) for path in good_path.iterdir() if path.is_dir() and '__pycache' not in str(path)] + +TESTS = GOOD_TESTS + [ + (bad_path / 'no_plugin', pytest.raises(PluginHasNoPluginClassException)), + (bad_path / 'error', pytest.raises(PluginLoadingException, match="This doesn't load")), + (bad_path / 'class_init_error', pytest.raises(PluginLoadingException, match='Exception in init')), + (bad_path / 'class_load_error', pytest.raises(PluginLoadingException, match='Exception in load')), + (bad_path / 'no_exist', pytest.raises(PluginDoesNotExistException)), + (bad_path / 'null_plugin_info', pytest.raises(PluginLoadingException, match='did not return a valid PluginInfo')), + ( + bad_path / 'str_plugin_info', + pytest.raises( + PluginLoadingException, match='returned an invalid type for its PluginInfo' + ) + ), + +] + LEGACY_TESTS + + +def test_load_them_all(plugin_manager: PluginManager) -> None: + """Test that loading all of the good together plugins behaves as expected.""" + loaded = plugin_manager.load_plugins([x[0] for x in GOOD_TESTS]) + assert all(lambda x: x is not None for x in loaded) + + +@pytest.mark.parametrize('path,context', TESTS, ids=_idfn) +def test_load(plugin_manager: PluginManager, context: ContextManager, path: pathlib.Path) -> None: + """ + Test that plugins load as expected. + + :param plugin_manager: a plugin.PluginManager instance to run tests against + :param context: Context manager to run the test in, pytest.raises is used to assert that an exception is raised + :param path: path to the plugin + """ + with context: + plugin_manager.load_plugin(path) + + +def test_legacy_load(plugin_manager: PluginManager): + """Test that legacy loading system correctly loads a plugin, and creates synthetic hooks for it.""" + target = legacy_good_path / 'all_callbacks' + import sys + sys.path.append(str(target)) + loaded = plugin_manager.load_plugin(target) + assert loaded is not None + + target_name = '_SYNTHETIC_CALLBACK_journal_entry' + + # does the callback exist + assert hasattr(loaded.plugin, target_name) + hook = getattr(loaded.plugin, target_name) + # does the hook function have the original function attached, and if so, is it the same function as the module + assert hasattr(hook, 'original_func') + assert getattr(hook, 'original_func') is getattr(loaded.module, 'journal_entry') + + # has the callback been decorated with hook()? + assert hasattr(hook, CALLBACK_MARKER) + # have all of the functions created automatically as part of callbacks been found by the callback search code? + assert len(loaded.callbacks) == (len(LEGACY_CALLBACK_LUT) + 1) + + +def test_double_load(plugin_manager: PluginManager) -> None: + """Attempt to load a plugin twice.""" + plugin_manager.load_plugin(bad_path / 'double_load') + with pytest.raises(PluginAlreadyLoadedException): + plugin_manager.load_plugin(bad_path / 'double_load') + + +def test_hooks_created(plugin_manager: PluginManager) -> None: + """Test that after loading, callbacks are where and what they are expected to be.""" + p = plugin_manager.load_plugin(good_path / 'simple_with_callback') + assert p is not None + + assert 'core.journal_event' in p.callbacks + assert p.callbacks['core.journal_event'][0] == getattr(p.plugin, 'on_journal') + + +def test_multiple_hooks(plugin_manager: PluginManager) -> None: + """Test that a method with multiple @hook decorators is resolved correctly.""" + p = plugin_manager.load_plugin(good_path / 'multi_callback') + assert p is not None + + assert len(p.callbacks) == 2 + assert 'core.journal_event' in p.callbacks + assert 'uncore.not_journal_event' in p.callbacks + assert p.callbacks['core.journal_event'][0] == p.callbacks['uncore.not_journal_event'][0] # type: ignore + assert p.callbacks['uncore.not_journal_event'][0] == getattr(p.plugin, 'multiple_things') + + +def test_unload_call(plugin_manager: PluginManager): + """Load and unload a single plugin.""" + target = good_path / "simple" + plug = plugin_manager.load_plugin(target) + assert plugin_manager.is_plugin_loaded('good') + assert plug is not None + + unload_called = False + real_unload = plug.plugin.unload + + def mock_unload(): + nonlocal unload_called + unload_called = True + real_unload() + + with pytest.MonkeyPatch.context() as mp: + mp.setattr(plug.plugin, 'unload', mock_unload) # patch the unload method + plugin_manager.unload_plugin('good') + + assert not plugin_manager.is_plugin_loaded('good') + assert unload_called diff --git a/plugin/test/test_plugins/bad/class_init_error/__init__.py b/plugin/test/test_plugins/bad/class_init_error/__init__.py new file mode 100644 index 0000000000..dbdfdecdfa --- /dev/null +++ b/plugin/test/test_plugins/bad/class_init_error/__init__.py @@ -0,0 +1,18 @@ +"""Plugin that errors on __init__().""" + +from plugin.base_plugin import BasePlugin +from plugin.decorators import edmc_plugin +from plugin.plugin_info import PluginInfo + + +@edmc_plugin +class Broken(BasePlugin): + """Test plugin.""" + + def __init__(self, logger, manager, path) -> None: + super().__init__(logger, manager, path) + raise Exception('Exception in init') + + def load(self) -> PluginInfo: + """Implement method required by ABC.""" + return super().load() diff --git a/plugin/test/test_plugins/bad/class_load_error/__init__.py b/plugin/test/test_plugins/bad/class_load_error/__init__.py new file mode 100644 index 0000000000..cabf0fa3b7 --- /dev/null +++ b/plugin/test/test_plugins/bad/class_load_error/__init__.py @@ -0,0 +1,14 @@ +"""Plugin that errors on load().""" + +from plugin.base_plugin import BasePlugin +from plugin.decorators import edmc_plugin +from plugin.plugin_info import PluginInfo + + +@edmc_plugin +class Broken(BasePlugin): + """Test Plugin.""" + + def load(self) -> PluginInfo: + """Plugin startup.""" + raise Exception('Exception in load') diff --git a/plugin/test/test_plugins/bad/double_load/__init__.py b/plugin/test/test_plugins/bad/double_load/__init__.py new file mode 100644 index 0000000000..02faa329ef --- /dev/null +++ b/plugin/test/test_plugins/bad/double_load/__init__.py @@ -0,0 +1,15 @@ +"""Test Plugin.""" +import semantic_version + +from plugin.base_plugin import BasePlugin +from plugin.decorators import edmc_plugin +from plugin.plugin_info import PluginInfo + + +@edmc_plugin +class Broken(BasePlugin): + """Valid (but not loadable twice) plugin.""" + + def load(self) -> PluginInfo: + """Load.""" + return PluginInfo('double_load', semantic_version.Version.coerce('0.0.1')) diff --git a/plugin/test/test_plugins/bad/error/__init__.py b/plugin/test/test_plugins/bad/error/__init__.py new file mode 100644 index 0000000000..d7ad9520ed --- /dev/null +++ b/plugin/test/test_plugins/bad/error/__init__.py @@ -0,0 +1,2 @@ +"""Bang!.""" +raise ValueError("This doesn't load") diff --git a/plugin/test/test_plugins/bad/no_plugin/__init__.py b/plugin/test/test_plugins/bad/no_plugin/__init__.py new file mode 100644 index 0000000000..9384d45518 --- /dev/null +++ b/plugin/test/test_plugins/bad/no_plugin/__init__.py @@ -0,0 +1,2 @@ +"""Invalid plugin.""" +print('I have no plugins defined') diff --git a/plugin/test/test_plugins/bad/null_plugin_info/__init__.py b/plugin/test/test_plugins/bad/null_plugin_info/__init__.py new file mode 100644 index 0000000000..bbca9448b0 --- /dev/null +++ b/plugin/test/test_plugins/bad/null_plugin_info/__init__.py @@ -0,0 +1,13 @@ +"""Test Plugin.""" +from plugin.base_plugin import BasePlugin +from plugin.decorators import edmc_plugin +from plugin.plugin_info import PluginInfo + + +@edmc_plugin +class BadPlugInfo(BasePlugin): + """Plugin that returns a bad PluginInfo object.""" + + def load(self) -> PluginInfo: + """Intentionally broken load().""" + return None # type: ignore # Its intentional diff --git a/plugin/test/test_plugins/bad/str_plugin_info/__init__.py b/plugin/test/test_plugins/bad/str_plugin_info/__init__.py new file mode 100644 index 0000000000..a869a52973 --- /dev/null +++ b/plugin/test/test_plugins/bad/str_plugin_info/__init__.py @@ -0,0 +1,13 @@ +"""Test Plugin.""" +from plugin.base_plugin import BasePlugin +from plugin.decorators import edmc_plugin +from plugin.plugin_info import PluginInfo + + +@edmc_plugin +class BadPlugInfo(BasePlugin): + """Plugin that returns a bad PluginInfo object.""" + + def load(self) -> PluginInfo: + """Intentionally broken load().""" + return 'This is broken' # type: ignore # Its intentional diff --git a/plugin/test/test_plugins/bad/unload_exception/__init__.py b/plugin/test/test_plugins/bad/unload_exception/__init__.py new file mode 100644 index 0000000000..b23c768f14 --- /dev/null +++ b/plugin/test/test_plugins/bad/unload_exception/__init__.py @@ -0,0 +1,20 @@ +"""Plugin that generates an Exception on unload.""" + +import semantic_version + +from plugin.base_plugin import BasePlugin +from plugin.decorators import edmc_plugin +from plugin.plugin_info import PluginInfo + + +@edmc_plugin +class UnloadException(BasePlugin): + """Throws an exception during unload.""" + + def load(self) -> PluginInfo: + """Load.""" + return PluginInfo('unload_exception', semantic_version.Version.coerce('0.0.1')) + + def unload(self) -> None: + """Bang!.""" + raise ValueError('Bang!') diff --git a/plugin/test/test_plugins/bad/unload_shutdown/__init__.py b/plugin/test/test_plugins/bad/unload_shutdown/__init__.py new file mode 100644 index 0000000000..3de474ab95 --- /dev/null +++ b/plugin/test/test_plugins/bad/unload_shutdown/__init__.py @@ -0,0 +1,22 @@ +"""Plugin that generates a SystemExit on unload.""" + +import sys + +import semantic_version + +from plugin.base_plugin import BasePlugin +from plugin.decorators import edmc_plugin +from plugin.plugin_info import PluginInfo + + +@edmc_plugin +class UnloadSystemExit(BasePlugin): + """Throws an exception during unload.""" + + def load(self) -> PluginInfo: + """Load.""" + return PluginInfo("unload_exception", semantic_version.Version.coerce('0.0.1')) + + def unload(self) -> None: + """Bang!.""" + sys.exit(1337) diff --git a/plugin/test/test_plugins/good/multi_callback/__init__.py b/plugin/test/test_plugins/good/multi_callback/__init__.py new file mode 100644 index 0000000000..b351ab7ec9 --- /dev/null +++ b/plugin/test/test_plugins/good/multi_callback/__init__.py @@ -0,0 +1,31 @@ +"""Test plugin that loads correctly.""" + +import semantic_version + +from plugin.base_plugin import BasePlugin +from plugin.decorators import edmc_plugin, hook +from plugin.event import BaseEvent, JournalEvent +from plugin.plugin_info import PluginInfo + + +@edmc_plugin +class GoodPlugin(BasePlugin): + """Plugin that loads correctly.""" + + def load(self) -> PluginInfo: + """Nothing Special.""" + self.called: list[BaseEvent] = [] + + return PluginInfo( + name="good", + version=semantic_version.Version.coerce('0.0.1'), + authors=['A_D'] + ) + + @hook('core.journal_event') + @hook('uncore.not_journal_event') + def multiple_things(self, e: JournalEvent): + """Multiple hooks on one method.""" + self.called.append(e) + + print(id(multiple_things)) diff --git a/plugin/test/test_plugins/good/provides_something/__init__.py b/plugin/test/test_plugins/good/provides_something/__init__.py new file mode 100644 index 0000000000..5958c8596c --- /dev/null +++ b/plugin/test/test_plugins/good/provides_something/__init__.py @@ -0,0 +1,25 @@ +"""Test plugin that loads correctly.""" +import semantic_version + +from plugin.base_plugin import BasePlugin +from plugin.decorators import edmc_plugin, provider +from plugin.plugin_info import PluginInfo + + +@edmc_plugin +class GoodPlugin(BasePlugin): + """Plugin that loads correctly.""" + + def load(self) -> PluginInfo: + """Nothing Special.""" + return PluginInfo( + name="good_provider", + version=semantic_version.Version.coerce('0.0.1'), + authors=['A_D'] + ) + + @staticmethod + @provider('something') + def something() -> str: + """Return something.""" + return "something" diff --git a/plugin/test/test_plugins/good/simple/__init__.py b/plugin/test/test_plugins/good/simple/__init__.py new file mode 100644 index 0000000000..f0c134b758 --- /dev/null +++ b/plugin/test/test_plugins/good/simple/__init__.py @@ -0,0 +1,19 @@ +"""Test plugin that loads correctly.""" +import semantic_version + +from plugin.base_plugin import BasePlugin +from plugin.decorators import edmc_plugin +from plugin.plugin_info import PluginInfo + + +@edmc_plugin +class GoodPlugin(BasePlugin): + """Plugin that loads correctly.""" + + def load(self) -> PluginInfo: + """Nothing Special.""" + return PluginInfo( + name="good", + version=semantic_version.Version.coerce('0.0.1'), + authors=['A_D'] + ) diff --git a/plugin/test/test_plugins/good/simple_full_wildcard/__init__.py b/plugin/test/test_plugins/good/simple_full_wildcard/__init__.py new file mode 100644 index 0000000000..0633353cee --- /dev/null +++ b/plugin/test/test_plugins/good/simple_full_wildcard/__init__.py @@ -0,0 +1,26 @@ +"""Test plugin.""" +import semantic_version + +from plugin import event +from plugin.base_plugin import BasePlugin +from plugin.decorators import edmc_plugin, hook +from plugin.plugin_info import PluginInfo + + +@edmc_plugin +class GoodCallbackPlugin(BasePlugin): + """Plugin that loads correctly.""" + + def load(self) -> PluginInfo: + """Nothing Special.""" + self.called: list[event.BaseEvent] = [] + return PluginInfo( + name="good_callback_wildcard", + version=semantic_version.Version.coerce('0.0.1'), + authors=['A_D'] + ) + + @hook('*') + def on_journal(self, e: event.JournalEvent): + """Fake callback.""" + self.called.append(e) diff --git a/plugin/test/test_plugins/good/simple_nonfull_wildcard/__init__.py b/plugin/test/test_plugins/good/simple_nonfull_wildcard/__init__.py new file mode 100644 index 0000000000..97b97cc637 --- /dev/null +++ b/plugin/test/test_plugins/good/simple_nonfull_wildcard/__init__.py @@ -0,0 +1,26 @@ +"""Test plugin.""" +import semantic_version + +from plugin import event +from plugin.base_plugin import BasePlugin +from plugin.decorators import edmc_plugin, hook +from plugin.plugin_info import PluginInfo + + +@edmc_plugin +class GoodCallbackPlugin(BasePlugin): + """Plugin that loads correctly.""" + + def load(self) -> PluginInfo: + """Nothing Special.""" + self.called: list[event.BaseEvent] = [] + return PluginInfo( + name="good_callback_core_wildcard", + version=semantic_version.Version.coerce('0.0.1'), + authors=['A_D'] + ) + + @hook('core.*') + def on_journal(self, e: event.JournalEvent): + """Fake callback.""" + self.called.append(e) diff --git a/plugin/test/test_plugins/good/simple_with_callback/__init__.py b/plugin/test/test_plugins/good/simple_with_callback/__init__.py new file mode 100644 index 0000000000..e8d409692b --- /dev/null +++ b/plugin/test/test_plugins/good/simple_with_callback/__init__.py @@ -0,0 +1,26 @@ +"""Test plugin.""" +import semantic_version + +from plugin import event +from plugin.base_plugin import BasePlugin +from plugin.decorators import edmc_plugin, hook +from plugin.plugin_info import PluginInfo + + +@edmc_plugin +class GoodCallbackPlugin(BasePlugin): + """Plugin that loads correctly.""" + + def load(self) -> PluginInfo: + """Nothing Special.""" + self.called: list[event.BaseEvent] = [] + return PluginInfo( + name="good_callback", + version=semantic_version.Version.coerce('0.0.1'), + authors=['A_D'] + ) + + @hook('core.journal_event') + def on_journal(self, e: event.JournalEvent): + """Fake callback.""" + self.called.append(e) diff --git a/plugin/test/test_plugins/legacy/bad_l/import_error/load.py b/plugin/test/test_plugins/legacy/bad_l/import_error/load.py new file mode 100644 index 0000000000..9bfbc48609 --- /dev/null +++ b/plugin/test/test_plugins/legacy/bad_l/import_error/load.py @@ -0,0 +1,3 @@ +"""Bang!.""" +# pyright: reportMissingImports = false +import ThisDoesNotExistEDMCLibNeedsMoreTextToEnsureUnique # noqa: F401 diff --git a/plugin/test/test_plugins/legacy/bad_l/load_error/load.py b/plugin/test/test_plugins/legacy/bad_l/load_error/load.py new file mode 100644 index 0000000000..45b5615fc2 --- /dev/null +++ b/plugin/test/test_plugins/legacy/bad_l/load_error/load.py @@ -0,0 +1,6 @@ +"""Test legacy plugin.""" + + +def plugin_start3(_: str) -> str: + """Explodes on call.""" + raise ValueError('BANG!') diff --git a/plugin/test/test_plugins/legacy/good_l/all_callbacks/load.py b/plugin/test/test_plugins/legacy/good_l/all_callbacks/load.py new file mode 100644 index 0000000000..e851dbb032 --- /dev/null +++ b/plugin/test/test_plugins/legacy/good_l/all_callbacks/load.py @@ -0,0 +1,58 @@ +"""Test legacy plugin that implements every callback.""" + + +def plugin_start3(_: str): + """plugin_load3 test function.""" + return 'test_all_callbacks' + + +def plugin_stop() -> None: + """plugin_stop test function.""" + print('Stopping') + + +def plugin_prefs(parent, cmdr: str, is_beta: bool) -> None: + """plugin_prefs test function.""" + print('parent_prefs') + + +def prefs_changed(cmdr: str, is_beta: bool) -> None: + """prefs_changed test function.""" + print('prefs_changed') + + +def plugin_app(parent) -> None: + """plugin_app test function.""" + print('plugin_app') + + +def journal_entry(*args) -> None: + """journal_entry test function.""" + print(f'journal_entry: {args}') + + +def dashboard_entry(*args) -> None: + """dashboard_entry test function.""" + print(f'dashboard_entry: {args}') + + +def cmdr_data(*args) -> None: + """cmdr_data test function.""" + print(f'lieutenant_cmdr_data: {args}') + +# Start of plugin specific events + + +def edsm_notify_system(reply) -> None: + """edsm_notify_system test function.""" + print(f'edsm_notify_system: {reply}') + + +def inara_notify_location(event_data) -> None: + """inara_notify_location test function.""" + print(f'inara_notify_location: {event_data}') + + +def inara_notify_ship(event_data) -> None: + """inara_notify_ship test function.""" + print(f'inara_notify_ship: {event_data}') diff --git a/plugin/test/test_plugins/legacy/good_l/simple/load.py b/plugin/test/test_plugins/legacy/good_l/simple/load.py new file mode 100644 index 0000000000..f0c14da745 --- /dev/null +++ b/plugin/test/test_plugins/legacy/good_l/simple/load.py @@ -0,0 +1,8 @@ +"""Test Legacy Plugin.""" + +__author__ = ['A_D'] + + +def plugin_start3(path: str) -> str: + """Test start3.""" + return 'test_plugin' diff --git a/plugin/test/test_unload.py b/plugin/test/test_unload.py new file mode 100644 index 0000000000..01a854a225 --- /dev/null +++ b/plugin/test/test_unload.py @@ -0,0 +1,41 @@ +"""Test unloading of plugins.""" +import logging +import pathlib + +import pytest + +from plugin.manager import PluginManager + +from .conftest import bad_path, good_path + +UNLOAD_TESTS = [ + (good_path / 'simple', None), + (bad_path / 'unload_exception', 'fire unload callback on unload_exception: Bang!'), + (bad_path / 'unload_shutdown', 'attempted to stop the running interpreter! Catching!'), +] + + +@pytest.mark.parametrize(["path", "expected_log"], UNLOAD_TESTS) +def test_unload(plugin_manager: PluginManager, caplog: pytest.LogCaptureFixture, path: pathlib.Path, expected_log): + """Test various plugin unload scenarios.""" + loaded = plugin_manager.load_plugin(path) + assert loaded is not None, "Unexpected load failure" + + plugin_name = loaded.info.name + + with caplog.at_level(logging.INFO): + plugin_manager.unload_plugin(plugin_name) + + assert not plugin_manager.is_plugin_loaded(plugin_name) + + if expected_log is None: + return + + messages = caplog.text + + if isinstance(expected_log, str): + assert expected_log in messages + + elif isinstance(expected_log, list): + for expected in expected_log: + assert expected in messages diff --git a/prefs.py b/prefs.py index c403023bf3..3dd22474a3 100644 --- a/prefs.py +++ b/prefs.py @@ -10,16 +10,18 @@ from tkinter import colorchooser as tkColorChooser # type: ignore # noqa: N812 from tkinter import ttk from types import TracebackType -from typing import TYPE_CHECKING, Any, Callable, Optional, Type, Union +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Type, Union, cast import myNotebook as nb # noqa: N813 -import plug from config import applongname, appversion_nobuild, config from EDMCLogging import edmclogger, get_main_logger from hotkey import hotkeymgr from l10n import Translations from monitor import monitor from myNotebook import Notebook +from plugin import event +from plugin.exceptions import LegacyPluginNeedsMigrating +from plugin.manager import PluginManager from theme import theme from ttkHyperlinkLabel import HyperlinkLabel @@ -39,6 +41,24 @@ def _(x: str) -> str: # May be imported by plugins +class BasePreferencesEvent(event.BaseEvent): + """Base event for preferences events.""" + + def __init__(self, name: str, commander: Optional[str], is_beta: bool, event_time: Optional[float] = None) -> None: + super().__init__(name, event_time=event_time) + + self.commander = commander + self.is_beta = is_beta + + +class PreferencesEvent(BasePreferencesEvent): + """Event to carry required data to set up plugin preferences.""" + + def __init__(self, notebook: nb.Notebook, commander: Optional[str], is_beta: bool) -> None: + super().__init__(event.EDMCPluginEvents.PREFERENCES, commander=commander, is_beta=is_beta) + self.notebook = notebook + + class PrefsVersion: """ PrefsVersion contains versioned preferences. @@ -240,18 +260,18 @@ class BROWSEINFO(ctypes.Structure): class PreferencesDialog(tk.Toplevel): """The EDMC preferences dialog.""" - def __init__(self, parent: tk.Tk, callback: Optional[Callable]): + def __init__(self, parent: tk.Tk, callback: Optional[Callable], plugin_manager: PluginManager): tk.Toplevel.__init__(self, parent) self.parent = parent self.callback = callback - if platform == 'darwin': + self.title( # LANG: File > Preferences menu entry for macOS - self.title(_('Preferences')) - - else: + _('Preferences') if platform == 'darwin' # LANG: File > Settings (macOS) - self.title(_('Settings')) + else _('Settings') + ) + self.plugin_manager = plugin_manager if parent.winfo_viewable(): self.transient(parent) @@ -286,12 +306,12 @@ def __init__(self, parent: tk.Tk, callback: Optional[Callable]): self.PADY = 2 # close spacing # Set up different tabs - self.__setup_output_tab(notebook) - self.__setup_plugin_tabs(notebook) + self.__setup_appearance_tab(notebook) self.__setup_config_tab(notebook) self.__setup_privacy_tab(notebook) - self.__setup_appearance_tab(notebook) + self.__setup_output_tab(notebook) self.__setup_plugin_tab(notebook) + self.__setup_plugin_tabs(notebook) if platform == 'darwin': self.protocol("WM_DELETE_WINDOW", self.apply) # close button applies changes @@ -417,10 +437,23 @@ def __setup_output_tab(self, root_notebook: nb.Notebook) -> None: root_notebook.add(output_frame, text=_('Output')) # Tab heading in settings def __setup_plugin_tabs(self, notebook: Notebook) -> None: - for plugin in plug.PLUGINS: - plugin_frame = plugin.get_prefs(notebook, monitor.cmdr, monitor.is_beta) - if plugin_frame: - notebook.add(plugin_frame, text=plugin.name) + plugin_results = self.plugin_manager.fire_event(PreferencesEvent(notebook, monitor.cmdr, monitor.is_beta)) + plugin_results = cast(Dict[str, List[tk.Widget]], plugin_results) + for plugin_name, results in plugin_results.items(): + if len(results) == 0: + # Plugin either did something but didn't give us anything back, or doesn't listen to this event + continue + + if len(results) > 1: + logger.warning(f'Plugin {plugin_name} returned more than one prefs page. Just using the first.') + + result = results[0] + notebook.add(result, text=plugin_name) + + # for plugin in plug.PLUGINS: + # plugin_frame = plugin.get_prefs(notebook, monitor.cmdr, monitor.is_beta) + # if plugin_frame: + # notebook.add(plugin_frame, text=plugin.name) def __setup_config_tab(self, notebook: Notebook) -> None: config_frame = nb.Frame(notebook) @@ -572,14 +605,16 @@ def __setup_config_tab(self, notebook: Notebook) -> None: with row as cur_row: shipyard_provider = config.get_str('shipyard_provider') + plugins = [p.info.name for p in self.plugin_manager.get_providers('core.shipyard_url')] self.shipyard_provider = tk.StringVar( - value=str(shipyard_provider if shipyard_provider in plug.provides('shipyard_url') else 'EDSY') + value=str(shipyard_provider if shipyard_provider in plugins else 'EDSY') ) # Setting to decide which ship outfitting website to link to - either E:D Shipyard or Coriolis # LANG: Label for Shipyard provider selection nb.Label(config_frame, text=_('Shipyard')).grid(padx=self.PADX, pady=2*self.PADY, sticky=tk.W, row=cur_row) self.shipyard_button = nb.OptionMenu( - config_frame, self.shipyard_provider, self.shipyard_provider.get(), *plug.provides('shipyard_url') + config_frame, self.shipyard_provider, self.shipyard_provider.get(), + *plugins if len(plugins) > 0 else ['EDSY'] ) self.shipyard_button.configure(width=15) @@ -598,8 +633,9 @@ def __setup_config_tab(self, notebook: Notebook) -> None: with row as cur_row: system_provider = config.get_str('system_provider') + plugins = [p.info.name for p in self.plugin_manager.get_providers('core.system_url')] self.system_provider = tk.StringVar( - value=str(system_provider if system_provider in plug.provides('system_url') else 'EDSM') + value=str(system_provider if system_provider in plugins else 'EDSM') ) # LANG: Configuration - Label for selection of 'System' provider website @@ -608,7 +644,7 @@ def __setup_config_tab(self, notebook: Notebook) -> None: config_frame, self.system_provider, self.system_provider.get(), - *plug.provides('system_url') + *plugins if len(plugins) > 0 else ['EDSM'] ) self.system_button.configure(width=15) @@ -616,8 +652,9 @@ def __setup_config_tab(self, notebook: Notebook) -> None: with row as cur_row: station_provider = config.get_str('station_provider') + plugins = [p.info.name for p in self.plugin_manager.get_providers('core.station_url')] self.station_provider = tk.StringVar( - value=str(station_provider if station_provider in plug.provides('station_url') else 'eddb') + value=str(station_provider if station_provider in plugins else 'eddb') ) # LANG: Configuration - Label for selection of 'Station' provider website @@ -626,7 +663,7 @@ def __setup_config_tab(self, notebook: Notebook) -> None: config_frame, self.station_provider, self.station_provider.get(), - *plug.provides('station_url') + *plugins if len(plugins) > 0 else ['eddb'] ) self.station_button.configure(width=15) @@ -892,19 +929,19 @@ def __setup_plugin_tab(self, notebook: Notebook) -> None: plugdirentry = nb.Entry(plugins_frame, justify=tk.LEFT) self.displaypath(plugdir, plugdirentry) + # Section heading in settings + # LANG: Label for location of third-party plugins folder + nb.Label(plugins_frame, text=_('Plugins folder') + + ':').grid(padx=self.PADX, sticky=tk.W, row=row.get(), column=0) with row as cur_row: - # Section heading in settings - # LANG: Label for location of third-party plugins folder - nb.Label(plugins_frame, text=_('Plugins folder') + ':').grid(padx=self.PADX, sticky=tk.W, row=cur_row) - - plugdirentry.grid(padx=self.PADX, sticky=tk.EW, row=cur_row) + plugdirentry.grid(padx=self.PADX, sticky=tk.EW, row=cur_row, column=0) nb.Button( plugins_frame, # LANG: Label on button used to open a filesystem folder text=_('Open'), # Button that opens a folder in Explorer/Finder command=lambda: webbrowser.open(f'file:///{config.plugin_dir_path}') - ).grid(column=1, padx=(0, self.PADX), sticky=tk.NSEW, row=cur_row) + ).grid(padx=(0, self.PADX), sticky=tk.NSEW, row=cur_row, column=1) nb.Label( plugins_frame, @@ -913,8 +950,9 @@ def __setup_plugin_tab(self, notebook: Notebook) -> None: text=_("Tip: You can disable a plugin by{CR}adding '{EXT}' to its folder name").format(EXT='.disabled') ).grid(columnspan=2, padx=self.PADX, pady=10, sticky=tk.NSEW, row=row.get()) - enabled_plugins = list(filter(lambda x: x.folder and x.module, plug.PLUGINS)) - if len(enabled_plugins): + enabled_plugins = list(self.plugin_manager.plugins.values()) + legacy_plugins = self.plugin_manager.legacy_plugins + if len(enabled_plugins) > 0: ttk.Separator(plugins_frame, orient=tk.HORIZONTAL).grid( columnspan=3, padx=self.PADX, pady=self.PADY * 8, sticky=tk.EW ) @@ -925,57 +963,84 @@ def __setup_plugin_tab(self, notebook: Notebook) -> None: ).grid(padx=self.PADX, sticky=tk.W, row=row.get()) for plugin in enabled_plugins: - if plugin.name == plugin.folder: - label = nb.Label(plugins_frame, text=plugin.name) + text = "" + if plugin.info.name == plugin.plugin.path.name: + text = plugin.info.name else: - label = nb.Label(plugins_frame, text=f'{plugin.folder} ({plugin.name})') + text = f'{plugin.plugin.path} ({plugin.info.name})' - label.grid(columnspan=2, padx=self.PADX*2, sticky=tk.W, row=row.get()) + if plugin in legacy_plugins: + text += ' (legacy)' + + nb.Label(plugins_frame, text=text).grid(columnspan=2, padx=self.PADX*2, sticky=tk.W, row=row.get()) ############################################################ # Show which plugins don't have Python 3.x support ############################################################ - if len(plug.PLUGINS_not_py3): + + legacy_not_py3 = [ + p for (p, e) in self.plugin_manager.failed_loading.items() if isinstance(e, LegacyPluginNeedsMigrating) + ] + + failed_loading_otherwise = { + p: e for (p, e) in self.plugin_manager.failed_loading.items() if p not in legacy_not_py3 + } + + if len(failed_loading_otherwise) > 0: + # Show plugins that errored on load somehow ttk.Separator(plugins_frame, orient=tk.HORIZONTAL).grid( - columnspan=3, padx=self.PADX, pady=self.PADY * 8, sticky=tk.EW, row=row.get() + columnspan=3, padx=self.PADX, pady=self.PADY*8, sticky=tk.EW, row=row.get() + ) + # LANG: Plugins - Label shown as header when there are plugins that failed to load correctly + nb.Label(plugins_frame, text=_('Plugins that failed to load')+':') + for p, e in failed_loading_otherwise.items(): + r = row.get() + nb.Label(plugins_frame, text=f'{p}:').grid( + columnspan=1, column=0, padx=self.PADX*2, row=r, sticky=tk.W + ) + nb.Label(plugins_frame, text=str(e)).grid( + columnspan=1, column=1, padx=self.PADX*2, row=r, sticky=tk.E + ) + + if len(legacy_not_py3) > 0: + ttk.Separator(plugins_frame, orient=tk.HORIZONTAL).grid( + columnspan=3, padx=self.PADX, pady=self.PADY*8, sticky=tk.EW, row=row.get() ) # LANG: Plugins - Label for list of 'enabled' plugins that don't work with Python 3.x nb.Label(plugins_frame, text=_('Plugins Without Python 3.x Support:')+':').grid(padx=self.PADX, sticky=tk.W) - - for plugin in plug.PLUGINS_not_py3: - if plugin.folder: # 'system' ones have this set to None to suppress listing in Plugins prefs tab - nb.Label(plugins_frame, text=plugin.name).grid(columnspan=2, padx=self.PADX*2, sticky=tk.W) + for p in legacy_not_py3: + nb.Label(plugins_frame, text=f'{p.name} ({p})').grid( + columnspan=2, padx=self.PADX*2, sticky=tk.W, row=row.get()) HyperlinkLabel( # LANG: Plugins - Label on URL to documentation about migrating plugins from Python 2.7 plugins_frame, text=_('Information on migrating plugins'), background=nb.Label().cget('background'), - url='https://github.com/EDCD/EDMarketConnector/blob/main/PLUGINS.md#migration-from-python-27', + url='https://github.com/EDCD/EDMarketConnector/blob/main/PLUGINS.md#migration-to-python-37', underline=True ).grid(columnspan=2, padx=self.PADX, sticky=tk.W) - ############################################################ - disabled_plugins = list(filter(lambda x: x.folder and not x.module, plug.PLUGINS)) - if len(disabled_plugins): + if len(self.plugin_manager.disabled_plugins) > 0: ttk.Separator(plugins_frame, orient=tk.HORIZONTAL).grid( columnspan=3, padx=self.PADX, pady=self.PADY * 8, sticky=tk.EW, row=row.get() ) + nb.Label( plugins_frame, - # LANG: Lable on list of user-disabled plugins + # LANG: Label on list of user-disabled plugins text=_('Disabled Plugins')+':' # List of plugins in settings ).grid(padx=self.PADX, sticky=tk.W, row=row.get()) - for plugin in disabled_plugins: - nb.Label(plugins_frame, text=plugin.name).grid( + for bad_path in self.plugin_manager.disabled_plugins: + nb.Label(plugins_frame, text=str(bad_path)).grid( columnspan=2, padx=self.PADX*2, sticky=tk.W, row=row.get() ) # LANG: Label on Settings > Plugins tab notebook.add(plugins_frame, text=_('Plugins')) # Tab heading in settings - def cmdrchanged(self, event=None): + def cmdrchanged(self): """ Notify plugins of cmdr change. @@ -984,7 +1049,9 @@ def cmdrchanged(self, event=None): if self.cmdr != monitor.cmdr or self.is_beta != monitor.is_beta: # Cmdr has changed - update settings if self.cmdr is not False: # Don't notify on first run - plug.notify_prefs_cmdr_changed(monitor.cmdr, monitor.is_beta) + self.plugin_manager.fire_event(BasePreferencesEvent( + event.EDMCPluginEvents.PREFERNCES_CMDR_CHANGED, commander=monitor.commander, is_beta=monitor.is_beta + )) self.cmdr = monitor.cmdr self.is_beta = monitor.is_beta @@ -1268,7 +1335,7 @@ def apply(self) -> None: if self.callback: self.callback() - plug.notify_prefs_changed(monitor.cmdr, monitor.is_beta) + self.plugin_manager.fire_str_event(event.EDMCPluginEvents.PREFERENCES_CLOSED) self._destroy() diff --git a/pyproject.toml b/pyproject.toml index 35ab26e4a2..a6c64f77d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ multi_line_output = 5 line_length = 119 [tool.pytest.ini_options] -testpaths = ["tests"] # Search for tests in tests/ +testpaths = ["tests", "plugin/tests"] # Search for tests in tests/ [tool.coverage.run] omit = ["venv/*"] # when running pytest --cov, dont report coverage in venv directories diff --git a/tests/config.py/_old_config.py b/tests/config.py/_old_config.py index b160997514..6bcad71b32 100644 --- a/tests/config.py/_old_config.py +++ b/tests/config.py/_old_config.py @@ -1,3 +1,4 @@ + import numbers import sys import warnings