From b1741e2e01cfc7e58f35af200f3374a7875598a8 Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 4 Mar 2021 10:39:34 +0200 Subject: [PATCH 001/152] Added start of new plugin system For details see ARCHITECTURE.md --- plugin/ARCHITECHTRE.md | 46 ++++++ plugin/__init__.py | 0 plugin/decorators.py | 64 ++++++++ plugin/event.py | 11 ++ plugin/manager.py | 171 ++++++++++++++++++++++ plugin/plugin.py | 19 +++ plugin/plugin_info.py | 16 ++ plugin/test/test_load.py | 17 +++ plugin/test/test_plugins/good/__init__.py | 0 plugin/test/test_plugins/good/plugin.py | 21 +++ 10 files changed, 365 insertions(+) create mode 100644 plugin/ARCHITECHTRE.md create mode 100644 plugin/__init__.py create mode 100644 plugin/decorators.py create mode 100644 plugin/event.py create mode 100644 plugin/manager.py create mode 100644 plugin/plugin.py create mode 100644 plugin/plugin_info.py create mode 100644 plugin/test/test_load.py create mode 100644 plugin/test/test_plugins/good/__init__.py create mode 100644 plugin/test/test_plugins/good/plugin.py diff --git a/plugin/ARCHITECHTRE.md b/plugin/ARCHITECHTRE.md new file mode 100644 index 0000000000..029a88f6c0 --- /dev/null +++ b/plugin/ARCHITECHTRE.md @@ -0,0 +1,46 @@ +# 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 + +### Decorators + +There are two decorators that currently defined by plugin: + +1. `edmc_plugin` +2. `hook` + +`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 + +### 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. + +### 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 diff --git a/plugin/__init__.py b/plugin/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugin/decorators.py b/plugin/decorators.py new file mode 100644 index 0000000000..672aa8a5c7 --- /dev/null +++ b/plugin/decorators.py @@ -0,0 +1,64 @@ +"""New plugin system.""" + + +from typing import Callable, List, Type + +from EDMCLogging import get_main_logger +from plugin.plugin import Plugin + +logger = get_main_logger() + +CALLBACK_MARKER = "__edmc_callback_marker" +PLUGIN_MARKER = "__edmc_plugin_marker__" + + +def edmc_plugin(cls: Type[Plugin]) -> Type[Plugin]: + """Mark any classes decorated with this function.""" + logger.info(f"Found plugin class {cls!r}") + + if not issubclass(cls, Plugin): + 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 + + +def hook(name: str) -> Callable: + """ + Create event callback. + + :param name: The event to hook onto + :return: (Internal python decoration implementation) + """ + def decorate(func: Callable) -> Callable: + """ + Decorate a function. + + The outer function is used to provide name to us at the decorate site + """ + logger.debug(f"Found function {func!r} marked as {name!r} callback") + # If this hook is already being used as a callback, just add the given name, otherwise, set it + if hasattr(func, CALLBACK_MARKER): + current: List[str] = getattr(func, CALLBACK_MARKER) + logger.trace(f"func {func!r} already marked as callback for others: {current}") + + if not isinstance(current, list): + raise ValueError(f"Hook function has marker with unexpected content. THIS IS A BUG: {current!r}") + + if name in current: + raise ValueError(f"Hook function hooked onto {name!r} multiple times") + + current.append(name) + setattr(func, CALLBACK_MARKER, current) + + else: + setattr(func, CALLBACK_MARKER, [name]) + + logger.trace(f"successfully marked callback {func!r} as a callback for event {name!r}") + return func + + return decorate diff --git a/plugin/event.py b/plugin/event.py new file mode 100644 index 0000000000..7587e5c210 --- /dev/null +++ b/plugin/event.py @@ -0,0 +1,11 @@ + +import time + + +class Event: + def __init__(self, name: str, event_time: None) -> None: + self.name = "" + self.time = time.time() + + if event_time is not None: + self.time = event_time diff --git a/plugin/manager.py b/plugin/manager.py new file mode 100644 index 0000000000..10ad44cc1b --- /dev/null +++ b/plugin/manager.py @@ -0,0 +1,171 @@ +"""Main plugin engine.""" +from __future__ import annotations + +import sys +import importlib +import pathlib +import dataclasses +from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Type + +if TYPE_CHECKING: + from types import ModuleType + +from EDMCLogging import get_main_logger +from plugin import decorators +from plugin.plugin import Plugin +from plugin.plugin_info import PluginInfo + + +@dataclasses.dataclass +class LoadedPlugin: + """LoadedPlugin represents a single plugin, its module, and callbacks.""" + + info: PluginInfo + plugin: Plugin + module: ModuleType + callbacks: Dict[str, List[Callable]] + + +class PluginManager: + """PluginManager is an event engine and plugin engine.""" + + def __init__(self) -> None: + self.log = get_main_logger() + self.log.info("starting new plugin management engine") + self.plugins: List[LoadedPlugin] = [] + + def find_potential_plugins(self, path: pathlib.Path) -> List[pathlib.Path]: + """ + Search for plugins at the given path. + + :param path: The path to search for + :return: All plugins found + """ + out = [] + + for dir in path.iterdir(): + if not dir.is_dir(): + continue + out.append(dir) + + return out + + def __load_plugin_from_class( + self, path: pathlib.Path, module: ModuleType, class_name: str, cls: Type[Plugin] + ) -> Optional[LoadedPlugin]: + + str_plugin_reference = f"{class_name} -> {cls!r} from path {path}" + self.log.trace(f"Loading plugin class {str_plugin_reference}") + try: + instantiated = cls() + except Exception: + self.log.exception(f"Could not instantiate plugin class for plugin {str_plugin_reference}") + return None + + callbacks: Dict[str, List[Callable]] = {} + + for field_name, class_field in cls.__dict__.items(): + if not hasattr(class_field, decorators.CALLBACK_MARKER): + continue + + events = getattr(class_field, decorators.CALLBACK_MARKER) + + self.log.trace(f"found callback method {field_name} -> {class_field} with callbacks {events}") + for name in events: + callbacks[name] = callbacks.get(name, []) + [class_field] + + self.log.trace(f"finished finding callbacks on plugin class {str_plugin_reference}") + + try: + info = instantiated.load(path) + except Exception: + self.log.exception(f"Could not call load on plugin {str_plugin_reference}") + return None + + return LoadedPlugin(info, instantiated, module, callbacks) + + @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) + ".plugin" + + def load_plugin(self, path: pathlib.Path, autoresolve_sys_path=True) -> bool: + """ + 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: A bool indicating success. + """ + self.log.info(f"attempting to load plugin(s) at path {path} ({path.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(path.parent.absolute()) not in sys.path: + sys.path.append(str(path.parent.absolute())) + + try: + resolved = self.resolve_path_to_plugin(path, relative_to=path.parent.absolute()) + self.log.trace(f"Resolved plugin path to import path {resolved}") + module = importlib.import_module(resolved) + + except Exception: + self.log.exception(f"Unable to load module {path}") + return False + + loaded = None + + # 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 + + loaded = self.__load_plugin_from_class(path, module, class_name, cls) + if loaded is None: + self.log.info(f"Failed to load plugin {class_name} -> {cls!r}") + return False + + if self.is_plugin_loaded(loaded.info.name): + self.log.error("Plugins with the same names attempted to load") + return False + + break + + if loaded is not None: + self.plugins.append(loaded) + return True + + self.log.error(f"No plugin class found in {path}") + return False + + 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 + """ + for plugin in self.plugins: + if plugin.info.name == name: + return True + + return False + + def unload_plugin(self, name: str) -> bool: + ... + + def fire_event(self, name: str, data): + ... diff --git a/plugin/plugin.py b/plugin/plugin.py new file mode 100644 index 0000000000..d004ce30d6 --- /dev/null +++ b/plugin/plugin.py @@ -0,0 +1,19 @@ +"""Base plugin class.""" + +import abc +import pathlib + +from plugin.plugin_info import PluginInfo + + +class Plugin(abc.ABC): + """Base plugin class.""" + + @abc.abstractmethod + def load(self, plugin_path: pathlib.Path) -> PluginInfo: + """ + Load this plugin. + + :param plugin_path: the path at which this module was found. + """ + ... diff --git a/plugin/plugin_info.py b/plugin/plugin_info.py new file mode 100644 index 0000000000..331ffc5b6b --- /dev/null +++ b/plugin/plugin_info.py @@ -0,0 +1,16 @@ +"""Information on a given plugin.""" + +import dataclasses +from typing import List, Optional + +import semantic_version + + +@dataclasses.dataclass +class PluginInfo: + """PluginInfo holds information about a loaded plugin.""" + + name: str + version: semantic_version.Version + authors: Optional[List[str]] = None + comment: Optional[str] = None diff --git a/plugin/test/test_load.py b/plugin/test/test_load.py new file mode 100644 index 0000000000..5350260a32 --- /dev/null +++ b/plugin/test/test_load.py @@ -0,0 +1,17 @@ +import pathlib +import sys + +sys.path.append(str(pathlib.Path(__file__).parent.parent.parent)) + +from plugin.manager import PluginManager # noqa: E402 # Cant be at the top + + +class TestPluginLoad: + """Test plugin loading.""" + + def test_good_load(self): + """Test that loading a known good plugin works.""" + manager = PluginManager() + res = manager.load_plugin(pathlib.Path("./plugin/test/test_plugins/good").absolute()) + + assert res, "Good plugin did not load correctly" diff --git a/plugin/test/test_plugins/good/__init__.py b/plugin/test/test_plugins/good/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugin/test/test_plugins/good/plugin.py b/plugin/test/test_plugins/good/plugin.py new file mode 100644 index 0000000000..c06613accd --- /dev/null +++ b/plugin/test/test_plugins/good/plugin.py @@ -0,0 +1,21 @@ +"""Test plugin that loads correctly.""" +import pathlib + +import semantic_version + +from plugin.decorators import edmc_plugin +from plugin.plugin import Plugin +from plugin.plugin_info import PluginInfo + + +@edmc_plugin +class GoodPlugin(Plugin): + """Plugin that loads correctly.""" + + def load(self, plugin_path: pathlib.Path) -> PluginInfo: + """Nothing Special.""" + return PluginInfo( + name="good", + version=semantic_version.Version.coerce("0.0.1"), + authors=["A_D"] + ) From e2e8810923a47ce14ab404793f3bc106e4a3e924 Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 4 Mar 2021 18:56:50 +0200 Subject: [PATCH 002/152] Added plugin logger to Plugin --- plugin/manager.py | 8 ++++++-- plugin/plugin.py | 25 +++++++++++++++++++++++++ plugin/plugin_info.py | 3 +++ plugin/test/test_load.py | 3 ++- 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/plugin/manager.py b/plugin/manager.py index 10ad44cc1b..e33cbb5077 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -10,7 +10,7 @@ if TYPE_CHECKING: from types import ModuleType -from EDMCLogging import get_main_logger +from EDMCLogging import get_main_logger, get_plugin_logger from plugin import decorators from plugin.plugin import Plugin from plugin.plugin_info import PluginInfo @@ -56,8 +56,12 @@ def __load_plugin_from_class( str_plugin_reference = f"{class_name} -> {cls!r} from path {path}" self.log.trace(f"Loading plugin class {str_plugin_reference}") + + plugin_logger = get_plugin_logger(path.parts[-1]) + try: - instantiated = cls() + instantiated = cls(plugin_logger) + except Exception: self.log.exception(f"Could not instantiate plugin class for plugin {str_plugin_reference}") return None diff --git a/plugin/plugin.py b/plugin/plugin.py index d004ce30d6..a17af557ca 100644 --- a/plugin/plugin.py +++ b/plugin/plugin.py @@ -1,7 +1,12 @@ """Base plugin class.""" +from __future__ import annotations import abc import pathlib +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from EDMCLogging import LoggerMixin from plugin.plugin_info import PluginInfo @@ -9,6 +14,10 @@ class Plugin(abc.ABC): """Base plugin class.""" + def __init__(self, logger: LoggerMixin) -> None: + self.log = logger + super().__init__() + @abc.abstractmethod def load(self, plugin_path: pathlib.Path) -> PluginInfo: """ @@ -17,3 +26,19 @@ def load(self, plugin_path: pathlib.Path) -> PluginInfo: :param plugin_path: the path at which this module was found. """ ... + + def unload(self) -> None: + """Unload this plugin.""" + ... + + +class MigratedPlugin(Plugin): + """MigratedPlugin is a wrapper for old-style plugins.""" + + def __init__(self) -> None: + super().__init__() + + def load(self, plugin_path: pathlib.Path) -> PluginInfo: + return super().load(plugin_path) + + ... diff --git a/plugin/plugin_info.py b/plugin/plugin_info.py index 331ffc5b6b..6930bf9c50 100644 --- a/plugin/plugin_info.py +++ b/plugin/plugin_info.py @@ -14,3 +14,6 @@ class PluginInfo: version: semantic_version.Version authors: Optional[List[str]] = None comment: Optional[str] = None + + # TODO: implement update checking and optional downloading + update_url: Optional[str] = None diff --git a/plugin/test/test_load.py b/plugin/test/test_load.py index 5350260a32..87e26d89ad 100644 --- a/plugin/test/test_load.py +++ b/plugin/test/test_load.py @@ -1,3 +1,4 @@ +"""Testing suite for plugin loading system.""" import pathlib import sys @@ -9,7 +10,7 @@ class TestPluginLoad: """Test plugin loading.""" - def test_good_load(self): + def test_good_load(self) -> None: """Test that loading a known good plugin works.""" manager = PluginManager() res = manager.load_plugin(pathlib.Path("./plugin/test/test_plugins/good").absolute()) From 97dbf271e37c5fa12deda82a16958005b4fdf6b2 Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 4 Mar 2021 19:02:57 +0200 Subject: [PATCH 003/152] Updated architechtre file with constraint on plugin.py --- plugin/ARCHITECHTRE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugin/ARCHITECHTRE.md b/plugin/ARCHITECHTRE.md index 029a88f6c0..2a77388186 100644 --- a/plugin/ARCHITECHTRE.md +++ b/plugin/ARCHITECHTRE.md @@ -13,6 +13,10 @@ Plugins are defined as any class that is a subclass of `plugin.Plugin` and is de 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 +During loading, the only file that is explicitly loaded is `plugin.py` in the directory of the plugin. Anything +that file imports will _also_ be loaded however (as normal with python modules). This means that plugins can be +defined outside of `plugin.py`, but `plugin.py` must exist for loading to occur correctly. + ### Decorators There are two decorators that currently defined by plugin: From cf28f048a2ee70e77aa17f5a7e5adf331cc1caed Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 4 Mar 2021 19:29:02 +0200 Subject: [PATCH 004/152] renamed file --- plugin/{ARCHITECHTRE.md => ARCHITECTURE.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename plugin/{ARCHITECHTRE.md => ARCHITECTURE.md} (100%) diff --git a/plugin/ARCHITECHTRE.md b/plugin/ARCHITECTURE.md similarity index 100% rename from plugin/ARCHITECHTRE.md rename to plugin/ARCHITECTURE.md From e4d329ebdb087d3a1cd19b3e378f1f7f7e7acf27 Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 4 Mar 2021 19:29:12 +0200 Subject: [PATCH 005/152] Added plugin tests --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 55889701713b84463339e2999ac8adac895fcc31 Mon Sep 17 00:00:00 2001 From: A_D Date: Fri, 5 Mar 2021 13:33:53 +0200 Subject: [PATCH 006/152] Made use of exceptions during plugin loading I've been writing too much go apparently. load_plugin() now raises exceptions when things go wrong, rather than an opaque bool return. --- plugin/manager.py | 61 +++++++++++++++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/plugin/manager.py b/plugin/manager.py index e33cbb5077..485f2e8292 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -1,11 +1,11 @@ """Main plugin engine.""" from __future__ import annotations -import sys +import dataclasses import importlib import pathlib -import dataclasses -from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Type +import sys +from typing import TYPE_CHECKING, Callable, Dict, List, Type if TYPE_CHECKING: from types import ModuleType @@ -26,6 +26,22 @@ class LoadedPlugin: callbacks: Dict[str, List[Callable]] +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.""" + + class PluginManager: """PluginManager is an event engine and plugin engine.""" @@ -52,7 +68,7 @@ def find_potential_plugins(self, path: pathlib.Path) -> List[pathlib.Path]: def __load_plugin_from_class( self, path: pathlib.Path, module: ModuleType, class_name: str, cls: Type[Plugin] - ) -> Optional[LoadedPlugin]: + ) -> LoadedPlugin: str_plugin_reference = f"{class_name} -> {cls!r} from path {path}" self.log.trace(f"Loading plugin class {str_plugin_reference}") @@ -64,7 +80,7 @@ def __load_plugin_from_class( except Exception: self.log.exception(f"Could not instantiate plugin class for plugin {str_plugin_reference}") - return None + raise callbacks: Dict[str, List[Callable]] = {} @@ -84,7 +100,7 @@ def __load_plugin_from_class( info = instantiated.load(path) except Exception: self.log.exception(f"Could not call load on plugin {str_plugin_reference}") - return None + raise return LoadedPlugin(info, instantiated, module, callbacks) @@ -101,9 +117,9 @@ def resolve_path_to_plugin(path: pathlib.Path, relative_to=None) -> str: relative_to = pathlib.Path.cwd() relative = path.relative_to(relative_to) - return ".".join(relative.parts) + ".plugin" + return ".".join(relative.parts) - def load_plugin(self, path: pathlib.Path, autoresolve_sys_path=True) -> bool: + def load_plugin(self, path: pathlib.Path, autoresolve_sys_path=True): """ Load a plugin at the given path. @@ -126,9 +142,13 @@ def load_plugin(self, path: pathlib.Path, autoresolve_sys_path=True) -> bool: self.log.trace(f"Resolved plugin path to import path {resolved}") module = importlib.import_module(resolved) - except Exception: - self.log.exception(f"Unable to load module {path}") - return False + 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 loaded = None @@ -136,24 +156,23 @@ def load_plugin(self, path: pathlib.Path, autoresolve_sys_path=True) -> bool: for class_name, cls in module.__dict__.items(): if not hasattr(cls, decorators.PLUGIN_MARKER): continue - - loaded = self.__load_plugin_from_class(path, module, class_name, cls) - if loaded is None: + try: + loaded = self.__load_plugin_from_class(path, module, class_name, cls) + except Exception as e: self.log.info(f"Failed to load plugin {class_name} -> {cls!r}") - return False + raise PluginLoadingException(f"Cannot load plugin {cls!r}: {e}") from e if self.is_plugin_loaded(loaded.info.name): self.log.error("Plugins with the same names attempted to load") - return False + raise PluginAlreadyLoadedException(f"Plugin with name {loaded.info.name} cannot be loaded twice") break - if loaded is not None: - self.plugins.append(loaded) - return True + if loaded is None: + self.log.error(f"No plugin class found in {path}") + raise PluginHasNoPluginClassException(f"No plugin class found in {path}") - self.log.error(f"No plugin class found in {path}") - return False + self.plugins.append(loaded) def is_plugin_loaded(self, name: str) -> bool: """ From d682348b21907d0f63d4db6db264759bf7942dc1 Mon Sep 17 00:00:00 2001 From: A_D Date: Fri, 5 Mar 2021 13:35:57 +0200 Subject: [PATCH 007/152] Added more plugin tests --- plugin/test/test_load.py | 49 ++++++++++++++++--- .../bad/class_init_error/__init__.py | 16 ++++++ .../bad/class_load_error/__init__.py | 13 +++++ .../test/test_plugins/bad/error/__init__.py | 1 + .../test_plugins/bad/no_plugin/__init__.py | 2 + plugin/test/test_plugins/good/__init__.py | 21 ++++++++ plugin/test/test_plugins/good/plugin.py | 21 -------- 7 files changed, 94 insertions(+), 29 deletions(-) create mode 100644 plugin/test/test_plugins/bad/class_init_error/__init__.py create mode 100644 plugin/test/test_plugins/bad/class_load_error/__init__.py create mode 100644 plugin/test/test_plugins/bad/error/__init__.py create mode 100644 plugin/test/test_plugins/bad/no_plugin/__init__.py delete mode 100644 plugin/test/test_plugins/good/plugin.py diff --git a/plugin/test/test_load.py b/plugin/test/test_load.py index 87e26d89ad..5f6b010589 100644 --- a/plugin/test/test_load.py +++ b/plugin/test/test_load.py @@ -1,18 +1,51 @@ """Testing suite for plugin loading system.""" import pathlib import sys +from contextlib import nullcontext +from typing import ContextManager + +import pytest sys.path.append(str(pathlib.Path(__file__).parent.parent.parent)) -from plugin.manager import PluginManager # noqa: E402 # Cant be at the top +from plugin.manager import ( # noqa: E402 # Cant be at the top + PluginDoesNotExistException, PluginHasNoPluginClassException, PluginLoadingException, PluginManager +) + + +def _idfn(test_data) -> str: + if isinstance(test_data, pathlib.Path): + return test_data.parts[-1] + + return "" + + +current_path = pathlib.Path.cwd() / "plugin/test/test_plugins" + +TESTS = [ + (current_path / "good", nullcontext()), + (current_path / "bad/no_plugin", pytest.raises(PluginHasNoPluginClassException)), + (current_path / "bad/error", pytest.raises(PluginLoadingException, match="This doesn't load")), + (current_path / "bad/class_init_error", pytest.raises(PluginLoadingException, match="Exception in init")), + (current_path / "bad/class_load_error", pytest.raises(PluginLoadingException, match="Exception in load")), + (current_path / "bad/no_exist", pytest.raises(PluginDoesNotExistException)), +] + +@pytest.fixture +def plugin_manager(): + """Provide a PluginManager as a fixture.""" + yield PluginManager() -class TestPluginLoad: - """Test plugin loading.""" - def test_good_load(self) -> None: - """Test that loading a known good plugin works.""" - manager = PluginManager() - res = manager.load_plugin(pathlib.Path("./plugin/test/test_plugins/good").absolute()) +@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. - assert res, "Good plugin did not load correctly" + :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: [description] + """ + with context: + plugin_manager.load_plugin(path) 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..27ec58d975 --- /dev/null +++ b/plugin/test/test_plugins/bad/class_init_error/__init__.py @@ -0,0 +1,16 @@ +"""Plugin that errors on __init__().""" + +import pathlib +from plugin.plugin_info import PluginInfo +from plugin.plugin import Plugin +from plugin.decorators import edmc_plugin + + +@edmc_plugin +class Broken(Plugin): + def __init__(self, logger) -> None: + super().__init__(logger) + raise Exception("Exception in init") + + def load(self, plugin_path: pathlib.Path) -> PluginInfo: + return super().load(plugin_path) 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..3ba8e6b9bc --- /dev/null +++ b/plugin/test/test_plugins/bad/class_load_error/__init__.py @@ -0,0 +1,13 @@ +"""Plugin that errors on load().""" + +import pathlib + +from plugin.decorators import edmc_plugin +from plugin.plugin import Plugin +from plugin.plugin_info import PluginInfo + + +@edmc_plugin +class Broken(Plugin): + def load(self, plugin_path: pathlib.Path) -> PluginInfo: + raise Exception("Exception in load") 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..02fae09564 --- /dev/null +++ b/plugin/test/test_plugins/bad/error/__init__.py @@ -0,0 +1 @@ +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..4fff9329c5 --- /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/good/__init__.py b/plugin/test/test_plugins/good/__init__.py index e69de29bb2..c06613accd 100644 --- a/plugin/test/test_plugins/good/__init__.py +++ b/plugin/test/test_plugins/good/__init__.py @@ -0,0 +1,21 @@ +"""Test plugin that loads correctly.""" +import pathlib + +import semantic_version + +from plugin.decorators import edmc_plugin +from plugin.plugin import Plugin +from plugin.plugin_info import PluginInfo + + +@edmc_plugin +class GoodPlugin(Plugin): + """Plugin that loads correctly.""" + + def load(self, plugin_path: pathlib.Path) -> 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/plugin.py b/plugin/test/test_plugins/good/plugin.py deleted file mode 100644 index c06613accd..0000000000 --- a/plugin/test/test_plugins/good/plugin.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Test plugin that loads correctly.""" -import pathlib - -import semantic_version - -from plugin.decorators import edmc_plugin -from plugin.plugin import Plugin -from plugin.plugin_info import PluginInfo - - -@edmc_plugin -class GoodPlugin(Plugin): - """Plugin that loads correctly.""" - - def load(self, plugin_path: pathlib.Path) -> PluginInfo: - """Nothing Special.""" - return PluginInfo( - name="good", - version=semantic_version.Version.coerce("0.0.1"), - authors=["A_D"] - ) From 2ebd47d1db2bfb9c9ba561d2d37a9aba633f6b7d Mon Sep 17 00:00:00 2001 From: A_D Date: Fri, 5 Mar 2021 13:36:14 +0200 Subject: [PATCH 008/152] Updated arch file with exceptions --- plugin/ARCHITECTURE.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/plugin/ARCHITECTURE.md b/plugin/ARCHITECTURE.md index 2a77388186..a6870841e9 100644 --- a/plugin/ARCHITECTURE.md +++ b/plugin/ARCHITECTURE.md @@ -13,9 +13,10 @@ Plugins are defined as any class that is a subclass of `plugin.Plugin` and is de 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 -During loading, the only file that is explicitly loaded is `plugin.py` in the directory of the plugin. Anything -that file imports will _also_ be loaded however (as normal with python modules). This means that plugins can be -defined outside of `plugin.py`, but `plugin.py` must exist for loading to occur correctly. +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 @@ -34,6 +35,9 @@ On a load call (as in `plugin.manager.PluginManager#load_plugin`), the plugin's 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. + ### Post instantiation of class After a plugin class is instantiated, two things happen: From d6e12e32814d648a025fe5a6ad6b8dfe298de986 Mon Sep 17 00:00:00 2001 From: A_D Date: Fri, 5 Mar 2021 13:43:14 +0200 Subject: [PATCH 009/152] reorganised test files --- plugin/test/test_load.py | 16 +++++++++------- .../test_plugins/good/{ => simple}/__init__.py | 0 2 files changed, 9 insertions(+), 7 deletions(-) rename plugin/test/test_plugins/good/{ => simple}/__init__.py (100%) diff --git a/plugin/test/test_load.py b/plugin/test/test_load.py index 5f6b010589..a476d893b6 100644 --- a/plugin/test/test_load.py +++ b/plugin/test/test_load.py @@ -21,14 +21,16 @@ def _idfn(test_data) -> str: current_path = pathlib.Path.cwd() / "plugin/test/test_plugins" +good_path = current_path / "good" +bad_path = current_path / "bad" TESTS = [ - (current_path / "good", nullcontext()), - (current_path / "bad/no_plugin", pytest.raises(PluginHasNoPluginClassException)), - (current_path / "bad/error", pytest.raises(PluginLoadingException, match="This doesn't load")), - (current_path / "bad/class_init_error", pytest.raises(PluginLoadingException, match="Exception in init")), - (current_path / "bad/class_load_error", pytest.raises(PluginLoadingException, match="Exception in load")), - (current_path / "bad/no_exist", pytest.raises(PluginDoesNotExistException)), + (good_path / "simple", nullcontext()), + (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)), ] @@ -38,7 +40,7 @@ def plugin_manager(): yield PluginManager() -@pytest.mark.parametrize('path,context', TESTS, ids=_idfn) +@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. diff --git a/plugin/test/test_plugins/good/__init__.py b/plugin/test/test_plugins/good/simple/__init__.py similarity index 100% rename from plugin/test/test_plugins/good/__init__.py rename to plugin/test/test_plugins/good/simple/__init__.py From 65e49c3436a315339823cc7e87f2b78cd4a6ebc2 Mon Sep 17 00:00:00 2001 From: A_D Date: Fri, 5 Mar 2021 13:49:33 +0200 Subject: [PATCH 010/152] Added migtation plans --- plugin/ARCHITECTURE.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/plugin/ARCHITECTURE.md b/plugin/ARCHITECTURE.md index a6870841e9..56f2c33395 100644 --- a/plugin/ARCHITECTURE.md +++ b/plugin/ARCHITECTURE.md @@ -38,6 +38,19 @@ 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 + +!!UNIMPLEMENTED -- Planning + +During the above loading steps to find the packages for new-style plugins, old style plugins are assumed to be +any python files in the root plugin directory, or any directory under the root that has no `__init__.py`. These will +be wrapped in a compatibility class and should continue to work as normal: + +1. Load file as a module +2. Map any existing functions in the file to their new counterparts, start3 -> load, journal hooks -> event handlers +3. These will explicitly NOT support reloading. And any attempt to reload them will result in a very large and scary + exception being thrown. + ### Post instantiation of class After a plugin class is instantiated, two things happen: @@ -47,8 +60,10 @@ After a plugin class is instantiated, two things happen: 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. +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 + +!! TODO From 4e15a91e89ad7779e09f4a85c8944c0e60105722 Mon Sep 17 00:00:00 2001 From: A_D Date: Fri, 5 Mar 2021 13:49:47 +0200 Subject: [PATCH 011/152] fixed broken init --- plugin/plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugin/plugin.py b/plugin/plugin.py index a17af557ca..70246c280f 100644 --- a/plugin/plugin.py +++ b/plugin/plugin.py @@ -35,8 +35,8 @@ def unload(self) -> None: class MigratedPlugin(Plugin): """MigratedPlugin is a wrapper for old-style plugins.""" - def __init__(self) -> None: - super().__init__() + def __init__(self, logger: LoggerMixin) -> None: + super().__init__(logger) def load(self, plugin_path: pathlib.Path) -> PluginInfo: return super().load(plugin_path) From 4397188195582dad62d425c6e90e1b3d0785857b Mon Sep 17 00:00:00 2001 From: A_D Date: Wed, 10 Mar 2021 15:30:55 +0200 Subject: [PATCH 012/152] Switched plugin storage to a dict --- plugin/manager.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/plugin/manager.py b/plugin/manager.py index 485f2e8292..ce83bc0ae6 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -5,7 +5,7 @@ import importlib import pathlib import sys -from typing import TYPE_CHECKING, Callable, Dict, List, Type +from typing import Optional, Set, TYPE_CHECKING, Callable, Dict, List, Type if TYPE_CHECKING: from types import ModuleType @@ -48,7 +48,8 @@ class PluginManager: def __init__(self) -> None: self.log = get_main_logger() self.log.info("starting new plugin management engine") - self.plugins: List[LoadedPlugin] = [] + self.plugins: Dict[str, LoadedPlugin] = {} + self._plugins_previously_loaded: Set[str] = set() def find_potential_plugins(self, path: pathlib.Path) -> List[pathlib.Path]: """ @@ -172,7 +173,7 @@ def load_plugin(self, path: pathlib.Path, autoresolve_sys_path=True): self.log.error(f"No plugin class found in {path}") raise PluginHasNoPluginClassException(f"No plugin class found in {path}") - self.plugins.append(loaded) + self.plugins[loaded.info.name] = loaded def is_plugin_loaded(self, name: str) -> bool: """ @@ -181,11 +182,16 @@ def is_plugin_loaded(self, name: str) -> bool: :param name: The name to search for :return: Whether or not the name is loaded """ - for plugin in self.plugins: - if plugin.info.name == name: - return True + return name in self.plugins - return False + 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) -> bool: ... From 3fecc05e5e427e00e4088cb7d974569ef45541b4 Mon Sep 17 00:00:00 2001 From: A_D Date: Wed, 10 Mar 2021 15:48:28 +0200 Subject: [PATCH 013/152] Made abstract method explode when called To force plugin authors to implement load --- plugin/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/plugin.py b/plugin/plugin.py index 70246c280f..0ed2c43f1d 100644 --- a/plugin/plugin.py +++ b/plugin/plugin.py @@ -25,7 +25,7 @@ def load(self, plugin_path: pathlib.Path) -> PluginInfo: :param plugin_path: the path at which this module was found. """ - ... + raise NotImplementedError def unload(self) -> None: """Unload this plugin.""" From 77a91359a4432c72d148c5d01721c5fa9a2804a4 Mon Sep 17 00:00:00 2001 From: A_D Date: Wed, 10 Mar 2021 15:49:25 +0200 Subject: [PATCH 014/152] Added check for null PluginInfo Also clarified an exception --- plugin/manager.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugin/manager.py b/plugin/manager.py index ce83bc0ae6..b872be4d85 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -5,7 +5,7 @@ import importlib import pathlib import sys -from typing import Optional, Set, TYPE_CHECKING, Callable, Dict, List, Type +from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Set, Type if TYPE_CHECKING: from types import ModuleType @@ -103,6 +103,9 @@ def __load_plugin_from_class( self.log.exception(f"Could not call load on plugin {str_plugin_reference}") raise + if info is None: + raise PluginLoadingException(f"Plugin {str_plugin_reference} did not return a valid PluginInfo") + return LoadedPlugin(info, instantiated, module, callbacks) @staticmethod @@ -164,7 +167,7 @@ def load_plugin(self, path: pathlib.Path, autoresolve_sys_path=True): raise PluginLoadingException(f"Cannot load plugin {cls!r}: {e}") from e if self.is_plugin_loaded(loaded.info.name): - self.log.error("Plugins with the same names attempted to load") + self.log.error("Plugins with the same names attempted to load (double load?)") raise PluginAlreadyLoadedException(f"Plugin with name {loaded.info.name} cannot be loaded twice") break From 87c6fd6af2dd6e3892591efca16df274fa60696d Mon Sep 17 00:00:00 2001 From: A_D Date: Wed, 10 Mar 2021 15:50:03 +0200 Subject: [PATCH 015/152] added additional tests --- plugin/test/test_load.py | 13 +++++++++++-- .../test_plugins/bad/double_load/__init__.py | 16 ++++++++++++++++ .../bad/null_plugin_info/__init__.py | 11 +++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 plugin/test/test_plugins/bad/double_load/__init__.py create mode 100644 plugin/test/test_plugins/bad/null_plugin_info/__init__.py diff --git a/plugin/test/test_load.py b/plugin/test/test_load.py index a476d893b6..3734465ac1 100644 --- a/plugin/test/test_load.py +++ b/plugin/test/test_load.py @@ -9,7 +9,8 @@ sys.path.append(str(pathlib.Path(__file__).parent.parent.parent)) from plugin.manager import ( # noqa: E402 # Cant be at the top - PluginDoesNotExistException, PluginHasNoPluginClassException, PluginLoadingException, PluginManager + PluginAlreadyLoadedException, PluginDoesNotExistException, PluginHasNoPluginClassException, PluginLoadingException, + PluginManager ) @@ -31,6 +32,7 @@ def _idfn(test_data) -> str: (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")) ] @@ -47,7 +49,14 @@ def test_load(plugin_manager: PluginManager, context: ContextManager, path: path :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: [description] + :param path: path to the plugin """ with context: plugin_manager.load_plugin(path) + + +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") 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..c1f2569d40 --- /dev/null +++ b/plugin/test/test_plugins/bad/double_load/__init__.py @@ -0,0 +1,16 @@ +import pathlib + +import semantic_version + +from plugin.decorators import edmc_plugin +from plugin.plugin import Plugin +from plugin.plugin_info import PluginInfo + + +@edmc_plugin +class Broken(Plugin): + """Valid (but not loadable twice) plugin.""" + + def load(self, plugin_path: pathlib.Path) -> PluginInfo: + """Load.""" + return PluginInfo("double_load", semantic_version.Version.coerce("0.0.1")) 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..145ee8af6d --- /dev/null +++ b/plugin/test/test_plugins/bad/null_plugin_info/__init__.py @@ -0,0 +1,11 @@ +from plugin.decorators import edmc_plugin +from plugin.plugin import Plugin, PluginInfo + + +@edmc_plugin +class BadPlugInfo(Plugin): + """Plugin that returns a bad PluginInfo object.""" + + def load(self, plugin_path) -> PluginInfo: + """Intentionally broken load().""" + return None # type: ignore # Its intentional From 27b82266a79a2052368327552d824f3adf8ae557 Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 11 Mar 2021 15:39:42 +0200 Subject: [PATCH 016/152] First stab at plugin unloading --- plugin/manager.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/plugin/manager.py b/plugin/manager.py index b872be4d85..3fc0dba898 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -196,8 +196,27 @@ def get_plugin(self, name: str) -> Optional[LoadedPlugin]: """ return self.plugins.get(name) - def unload_plugin(self, name: str) -> bool: - ... + 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, name: str, data): ... From 88053570deabc6b2d255e30e375237644df04b59 Mon Sep 17 00:00:00 2001 From: A_D Date: Fri, 12 Mar 2021 04:07:40 +0200 Subject: [PATCH 017/152] Various test changes Split loading and unloading tests into their own files Added a conftest file to setup globals between said two files Added plugin unloading and tests for said plugin unloading Made plugin.manager.PluginManager.load_plugin() return the loaded plugin, if possible --- plugin/manager.py | 5 ++- plugin/test/__init__.py | 0 plugin/test/conftest.py | 21 ++++++++++ plugin/test/test_load.py | 40 +++++++++++------- .../bad/unload_exception/__init__.py | 21 ++++++++++ .../bad/unload_shutdown/__init__.py | 22 ++++++++++ plugin/test/test_unload.py | 41 +++++++++++++++++++ 7 files changed, 134 insertions(+), 16 deletions(-) create mode 100644 plugin/test/__init__.py create mode 100644 plugin/test/conftest.py create mode 100644 plugin/test/test_plugins/bad/unload_exception/__init__.py create mode 100644 plugin/test/test_plugins/bad/unload_shutdown/__init__.py create mode 100644 plugin/test/test_unload.py diff --git a/plugin/manager.py b/plugin/manager.py index 3fc0dba898..ef91b13b99 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -123,7 +123,7 @@ def resolve_path_to_plugin(path: pathlib.Path, relative_to=None) -> str: relative = path.relative_to(relative_to) return ".".join(relative.parts) - def load_plugin(self, path: pathlib.Path, autoresolve_sys_path=True): + def load_plugin(self, path: pathlib.Path, autoresolve_sys_path=True) -> Optional[LoadedPlugin]: """ Load a plugin at the given path. @@ -132,7 +132,7 @@ def load_plugin(self, path: pathlib.Path, autoresolve_sys_path=True): :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: A bool indicating success. + :return: The LoadedPlugin, or None / an exception. """ self.log.info(f"attempting to load plugin(s) at path {path} ({path.absolute()})") @@ -177,6 +177,7 @@ def load_plugin(self, path: pathlib.Path, autoresolve_sys_path=True): raise PluginHasNoPluginClassException(f"No plugin class found in {path}") self.plugins[loaded.info.name] = loaded + return loaded def is_plugin_loaded(self, name: str) -> bool: """ diff --git a/plugin/test/__init__.py b/plugin/test/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugin/test/conftest.py b/plugin/test/conftest.py new file mode 100644 index 0000000000..bec50351b4 --- /dev/null +++ b/plugin/test/conftest.py @@ -0,0 +1,21 @@ +"""Setup constants and fixtures for plugin tests.""" +import pathlib +import sys +import typing + +from pytest import fixture + +sys.path.append(str(pathlib.Path(__file__).parent.parent.parent)) + +from plugin.manager import PluginManager # noqa: E402 # Has to be after the path fiddling + + +@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" diff --git a/plugin/test/test_load.py b/plugin/test/test_load.py index 3734465ac1..2001a8ea31 100644 --- a/plugin/test/test_load.py +++ b/plugin/test/test_load.py @@ -1,18 +1,17 @@ """Testing suite for plugin loading system.""" import pathlib -import sys from contextlib import nullcontext from typing import ContextManager import pytest -sys.path.append(str(pathlib.Path(__file__).parent.parent.parent)) - -from plugin.manager import ( # noqa: E402 # Cant be at the top +from plugin.manager import ( PluginAlreadyLoadedException, PluginDoesNotExistException, PluginHasNoPluginClassException, PluginLoadingException, PluginManager ) +from .conftest import bad_path, good_path + def _idfn(test_data) -> str: if isinstance(test_data, pathlib.Path): @@ -21,10 +20,6 @@ def _idfn(test_data) -> str: return "" -current_path = pathlib.Path.cwd() / "plugin/test/test_plugins" -good_path = current_path / "good" -bad_path = current_path / "bad" - TESTS = [ (good_path / "simple", nullcontext()), (bad_path / "no_plugin", pytest.raises(PluginHasNoPluginClassException)), @@ -36,12 +31,6 @@ def _idfn(test_data) -> str: ] -@pytest.fixture -def plugin_manager(): - """Provide a PluginManager as a fixture.""" - yield PluginManager() - - @pytest.mark.parametrize('path,context', TESTS, ids=_idfn) def test_load(plugin_manager: PluginManager, context: ContextManager, path: pathlib.Path) -> None: """ @@ -60,3 +49,26 @@ def test_double_load(plugin_manager: PluginManager) -> None: plugin_manager.load_plugin(bad_path / "double_load") with pytest.raises(PluginAlreadyLoadedException): plugin_manager.load_plugin(bad_path / "double_load") + + +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/unload_exception/__init__.py b/plugin/test/test_plugins/bad/unload_exception/__init__.py new file mode 100644 index 0000000000..9b82d19343 --- /dev/null +++ b/plugin/test/test_plugins/bad/unload_exception/__init__.py @@ -0,0 +1,21 @@ +"""Plugin that generates an Exception on unload.""" + +import pathlib + +import semantic_version + +from plugin.decorators import edmc_plugin +from plugin.plugin import Plugin, PluginInfo + + +@edmc_plugin +class UnloadException(Plugin): + """Throws an exception during unload.""" + + def load(self, plugin_path: pathlib.Path) -> 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..72b5a1a52a --- /dev/null +++ b/plugin/test/test_plugins/bad/unload_shutdown/__init__.py @@ -0,0 +1,22 @@ +"""Plugin that generates a SystemExit on unload.""" + +import pathlib +import sys + +import semantic_version + +from plugin.decorators import edmc_plugin +from plugin.plugin import Plugin, PluginInfo + + +@edmc_plugin +class UnloadSystemExit(Plugin): + """Throws an exception during unload.""" + + def load(self, plugin_path: pathlib.Path) -> 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_unload.py b/plugin/test/test_unload.py new file mode 100644 index 0000000000..d7dc26953a --- /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 From 8f963abac6f2a20461a70d4c772eee6a2bbf639b Mon Sep 17 00:00:00 2001 From: A_D Date: Mon, 15 Mar 2021 19:08:04 +0200 Subject: [PATCH 018/152] Started work on legacy loading --- plugin/ARCHITECTURE.md | 8 +++++- plugin/manager.py | 55 +++++++++++++++++++++++++++++++++++------- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/plugin/ARCHITECTURE.md b/plugin/ARCHITECTURE.md index 56f2c33395..fb6d3ba8f3 100644 --- a/plugin/ARCHITECTURE.md +++ b/plugin/ARCHITECTURE.md @@ -40,6 +40,10 @@ loading machinery, this exception will be caught and logged at the top level of ### 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 file, we load the plugin in the wrapped plugin loading system. + !!UNIMPLEMENTED -- Planning During the above loading steps to find the packages for new-style plugins, old style plugins are assumed to be @@ -66,4 +70,6 @@ changed, but was made to allow for assumptions that may or may not be made in im ## Event Engine -!! TODO +Events are identified by a namespace, and are hooked using the decorator `@hook("namespace.event_name")`. +You can hook onto all events in a given namespace using `@hook("namespace")`, and all events fired with the special +event name `*`. diff --git a/plugin/manager.py b/plugin/manager.py index ef91b13b99..19d044abad 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -5,14 +5,14 @@ import importlib import pathlib import sys -from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Set, Type +from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Set, Tuple, Type if TYPE_CHECKING: from types import ModuleType from EDMCLogging import get_main_logger, get_plugin_logger from plugin import decorators -from plugin.plugin import Plugin +from plugin.plugin import MigratedPlugin, Plugin from plugin.plugin_info import PluginInfo @@ -55,17 +55,29 @@ def find_potential_plugins(self, path: pathlib.Path) -> List[pathlib.Path]: """ Search for plugins at the given path. - :param path: The path to search for + :param path: The path to search at :return: All plugins found """ - out = [] + return list(filter(lambda f: f.is_dir(), path.iterdir())) - for dir in path.iterdir(): - if not dir.is_dir(): - continue - out.append(dir) + # def clean_potential_plugins(self, paths: List[pathlib.Path]) -> Tuple[List[pathlib.Path], List[pathlib.Path]]: + # """ + # Split potential plugins into normal and legacy plugins. + + # Silently drops any potential plugin paths that dont match requirements. + + # :param paths: The potential plugin paths + # :return: A tuple containing plugin and legacy plugin path lists + # """ + # legacy = [] + # plugins = [] + + # for path in paths: + # for file in list(filter(lambda f: f.is_file(), path.iterdir())): + # if file.match("__init__.py"): + # # Assume a normal plugin - return out + # ... def __load_plugin_from_class( self, path: pathlib.Path, module: ModuleType, class_name: str, cls: Type[Plugin] @@ -179,6 +191,29 @@ def load_plugin(self, path: pathlib.Path, autoresolve_sys_path=True) -> Optional self.plugins[loaded.info.name] = loaded return loaded + def load_legacy_or_normal_plugin(self, path: pathlib.Path, autoresolve_sys_path=True) -> Optional[LoadedPlugin]: + try: + return self.load_plugin(path, autoresolve_sys_path=autoresolve_sys_path) + except PluginDoesNotExistException: + # No __init__.py. Try load it as a legacy plugin directly from the path + ... + ... + + def load_legacy_plugin_from_path(self, path: pathlib.Path) -> Optional[MigratedPlugin]: + target = path / "load.py" + if not target.exists(): + raise PluginDoesNotExistException + + resolved = self.resolve_path_to_plugin(target)[:-3] # strip off .py + + try: + module = importlib.import_module(resolved) + except Exception as e: + # Something went wrong _but_ the file _DOES_ exist. + raise PluginLoadingException from e + + ... + def is_plugin_loaded(self, name: str) -> bool: """ Check if a plugin is loaded under a given name. @@ -221,3 +256,5 @@ def unload_plugin(self, name: str): def fire_event(self, name: str, data): ... + + # TODO: Register(System|station)Provider method, to allow it to be dynamic to plugins From 97e7c9087f84ca1015b1d2c3909424d97e5f9de5 Mon Sep 17 00:00:00 2001 From: A_D Date: Mon, 15 Mar 2021 19:08:18 +0200 Subject: [PATCH 019/152] Updated event classes --- plugin/event.py | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/plugin/event.py b/plugin/event.py index 7587e5c210..5e750b7e70 100644 --- a/plugin/event.py +++ b/plugin/event.py @@ -1,11 +1,35 @@ import time +from typing import Any, Optional -class Event: - def __init__(self, name: str, event_time: None) -> None: - self.name = "" - self.time = time.time() +class BaseEvent: + """ + Base Event class. - if event_time is not None: - self.time = event_time + 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: float = None) -> None: + self.name = name + if event_time is None: + event_time = time.time() + + self.time = event_time + + +class BaseDataEvent(BaseEvent): + """ + Base Data carrying event class. + + Same as BaseEvent but carries some data as well. + """ + + def __init__(self, name: str, data: Any = None, event_time: float = None) -> None: + super().__init__(name, event_time=event_time) + self.data = data + + +class JournalEvent(BaseDataEvent): + """Journal event.""" From 40c6e4c4e9946cb21b08d5cd4a5c1beb294b9ca6 Mon Sep 17 00:00:00 2001 From: A_D Date: Tue, 16 Mar 2021 05:18:05 +0200 Subject: [PATCH 020/152] Added todo --- plugin/test/test_load.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugin/test/test_load.py b/plugin/test/test_load.py index 2001a8ea31..a317bea7b7 100644 --- a/plugin/test/test_load.py +++ b/plugin/test/test_load.py @@ -28,6 +28,7 @@ def _idfn(test_data) -> str: (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")) + # TODO: plugin that imports a nonexistent module ] From 779bad29cb737887f8c282f698cb8d31af85ac44 Mon Sep 17 00:00:00 2001 From: A_D Date: Tue, 16 Mar 2021 05:18:20 +0200 Subject: [PATCH 021/152] Continued MigratedPlugin implementation --- plugin/ARCHITECTURE.md | 8 ++++-- plugin/manager.py | 32 +++++++++------------ plugin/plugin.py | 64 ++++++++++++++++++++++++++++++++++++++---- 3 files changed, 78 insertions(+), 26 deletions(-) diff --git a/plugin/ARCHITECTURE.md b/plugin/ARCHITECTURE.md index fb6d3ba8f3..141c156147 100644 --- a/plugin/ARCHITECTURE.md +++ b/plugin/ARCHITECTURE.md @@ -51,7 +51,7 @@ any python files in the root plugin directory, or any directory under the root t be wrapped in a compatibility class and should continue to work as normal: 1. Load file as a module -2. Map any existing functions in the file to their new counterparts, start3 -> load, journal hooks -> event handlers +2. Map any existing functions in the file to their new counterparts, start3 -> load, journal hooks -> event handlers. This is done via generated members on the MigratedPlugin instance 3. These will explicitly NOT support reloading. And any attempt to reload them will result in a very large and scary exception being thrown. @@ -71,5 +71,7 @@ changed, but was made to allow for assumptions that may or may not be made in im ## Event Engine Events are identified by a namespace, and are hooked using the decorator `@hook("namespace.event_name")`. -You can hook onto all events in a given namespace using `@hook("namespace")`, and all events fired with the special -event name `*`. +You can hook onto all events in a given namespace using `@hook("namespace")`, and all events fired with the special event name `*`. + +Some `core` events are special, and will work directly with your plugin rather than +being global, eg $plugin_prefs_changed_here diff --git a/plugin/manager.py b/plugin/manager.py index 19d044abad..14fc9639b3 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -5,13 +5,16 @@ import importlib import pathlib import sys -from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Set, Tuple, Type +from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Set, Type if TYPE_CHECKING: from types import ModuleType from EDMCLogging import get_main_logger, get_plugin_logger from plugin import decorators +from plugin.exceptions import ( + PluginAlreadyLoadedException, PluginDoesNotExistException, PluginHasNoPluginClassException, PluginLoadingException +) from plugin.plugin import MigratedPlugin, Plugin from plugin.plugin_info import PluginInfo @@ -26,22 +29,6 @@ class LoadedPlugin: callbacks: Dict[str, List[Callable]] -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.""" - - class PluginManager: """PluginManager is an event engine and plugin engine.""" @@ -58,6 +45,7 @@ def find_potential_plugins(self, path: pathlib.Path) -> List[pathlib.Path]: :param path: The path to search at :return: All plugins found """ + # TODO: ignore ones ending in .disabled, either here or lower down return list(filter(lambda f: f.is_dir(), path.iterdir())) # def clean_potential_plugins(self, paths: List[pathlib.Path]) -> Tuple[List[pathlib.Path], List[pathlib.Path]]: @@ -89,7 +77,7 @@ def __load_plugin_from_class( plugin_logger = get_plugin_logger(path.parts[-1]) try: - instantiated = cls(plugin_logger) + instantiated = cls(plugin_logger, self) except Exception: self.log.exception(f"Could not instantiate plugin class for plugin {str_plugin_reference}") @@ -200,6 +188,14 @@ def load_legacy_or_normal_plugin(self, path: pathlib.Path, autoresolve_sys_path= ... def load_legacy_plugin_from_path(self, path: pathlib.Path) -> Optional[MigratedPlugin]: + """ + 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 diff --git a/plugin/plugin.py b/plugin/plugin.py index 0ed2c43f1d..7fbd69b342 100644 --- a/plugin/plugin.py +++ b/plugin/plugin.py @@ -2,21 +2,29 @@ from __future__ import annotations import abc +import inspect import pathlib -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Callable, Dict, List, Optional + +from plugin.exceptions import LegacyPluginNeedsMigrating if TYPE_CHECKING: from EDMCLogging import LoggerMixin + from types import ModuleType + from plugin.manager import PluginManager +from plugin import decorators from plugin.plugin_info import PluginInfo class Plugin(abc.ABC): """Base plugin class.""" - def __init__(self, logger: LoggerMixin) -> None: + # TODO: a similar level of paranoia about defined methods where needed + + def __init__(self, logger: LoggerMixin, manager: PluginManager) -> None: self.log = logger - super().__init__() + self._manager = manager @abc.abstractmethod def load(self, plugin_path: pathlib.Path) -> PluginInfo: @@ -31,14 +39,60 @@ def unload(self) -> None: """Unload this plugin.""" ... + def show_error(self): + # TODO: replacement of plug.show_error + ... + class MigratedPlugin(Plugin): """MigratedPlugin is a wrapper for old-style plugins.""" - def __init__(self, logger: LoggerMixin) -> None: - super().__init__(logger) + OLD_CALLBACKS_AND_BEHAVIOUR = ( + ('plugin_app', lambda x: decorators.hook("core.plugin_ui_setup")(x)), + ('plugin_prefs', lambda x: decorators.hook('core.plugin_preferences_setup')(x)) + ) + + def __init__(self, logger: LoggerMixin, module: ModuleType, manager: PluginManager) -> None: + super().__init__(logger, manager) + self.module = module + # TODO: fill this dict and then generate hooks for everything that needs it + self.callbacks: Dict[str, List[Callable]] = { + 'core.setup_ui': [], + 'core.setup_preferences_ui': [], + 'core.preferences_changed': [], + 'core.journal_entry': [], + 'core.dashboard_entry': [], + 'core.commander_data': [], + + + 'inara.notify_ship': [], + 'inara.notify_location': [], + 'edsm.notify_system': [], + } def load(self, plugin_path: pathlib.Path) -> PluginInfo: + plugin_start3: Optional[Callable[[str], str]] = getattr(self.module, 'plugin_start3') + plugin_start: Optional[Callable[[str], str]] = getattr(self.module, 'plugin_start') + + if plugin_start3 is None: + if plugin_start is not None: + raise LegacyPluginNeedsMigrating + + raise ValueError('Plugin does not define a plugin_start3 method') + + self.enforce_load3_signature(plugin_start3) + + # We have a start3, lets see what else we have and get ready to prepare hooks for them + return super().load(plugin_path) + @staticmethod + def enforce_load3_signature(load3: Callable): + if not callable(load3): + raise ValueError(f'Plugin3 provided by plugin is not callable: {load3!r}') + + sig = inspect.signature(load3) + if not len(sig.parameters) == 1: + raise ValueError(f'Plugin3 provided by legacy plugin takes an unexpected arg count: {len(sig.parameters)}') + ... From 8e618a44a9c4391c315a770a41dcab04bbf9287d Mon Sep 17 00:00:00 2001 From: A_D Date: Tue, 16 Mar 2021 20:50:55 +0200 Subject: [PATCH 022/152] continued work on legacy plugin loading --- plugin/plugin.py | 41 +++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/plugin/plugin.py b/plugin/plugin.py index 7fbd69b342..656e1767a0 100644 --- a/plugin/plugin.py +++ b/plugin/plugin.py @@ -44,6 +44,21 @@ def show_error(self): ... +LEGACY_CALLBACK_LUT: Dict[str, str] = { + 'core.setup_ui': 'plugin_app', + 'core.setup_preferences_ui': 'plugin_prefs', + 'core.preferences_closed': 'prefs_changed', + 'core.journal_entry': 'journal_entry', + 'core.dashboard_entry': 'dashboard_entry', + 'core.commander_data': 'cmdr_data', + + + 'inara.notify_ship': 'inara_notify_ship', + 'inara.notify_location': 'inara_notify_location', + 'edsm.notify_system': 'edsm_notify_system', +} + + class MigratedPlugin(Plugin): """MigratedPlugin is a wrapper for old-style plugins.""" @@ -55,22 +70,7 @@ class MigratedPlugin(Plugin): def __init__(self, logger: LoggerMixin, module: ModuleType, manager: PluginManager) -> None: super().__init__(logger, manager) self.module = module - # TODO: fill this dict and then generate hooks for everything that needs it - self.callbacks: Dict[str, List[Callable]] = { - 'core.setup_ui': [], - 'core.setup_preferences_ui': [], - 'core.preferences_changed': [], - 'core.journal_entry': [], - 'core.dashboard_entry': [], - 'core.commander_data': [], - - - 'inara.notify_ship': [], - 'inara.notify_location': [], - 'edsm.notify_system': [], - } - - def load(self, plugin_path: pathlib.Path) -> PluginInfo: + # Find start3 plugin_start3: Optional[Callable[[str], str]] = getattr(self.module, 'plugin_start3') plugin_start: Optional[Callable[[str], str]] = getattr(self.module, 'plugin_start') @@ -81,8 +81,17 @@ def load(self, plugin_path: pathlib.Path) -> PluginInfo: raise ValueError('Plugin does not define a plugin_start3 method') 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 + for new_hook, old_callback in LEGACY_CALLBACK_LUT.items(): + callback: Optional[Callable] = getattr(self.module, old_callback) + if callback is None: + continue + + setattr(self, f"_SYNTHETIC_CALLBACK_{old_callback}", decorators.hook(new_hook)(old_callback)) + + def load(self, plugin_path: pathlib.Path) -> PluginInfo: return super().load(plugin_path) From 546294f3a59e0b3fef5870d052890bb67b65f976 Mon Sep 17 00:00:00 2001 From: A_D Date: Wed, 17 Mar 2021 00:48:34 +0200 Subject: [PATCH 023/152] First stab at actual plugin loading In theory everything that is required to load a plugin as of the time of this commit is implemented. This is untested and likely contains bugs. --- plugin/plugin.py | 57 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/plugin/plugin.py b/plugin/plugin.py index 656e1767a0..1fc51e316c 100644 --- a/plugin/plugin.py +++ b/plugin/plugin.py @@ -4,7 +4,9 @@ import abc import inspect import pathlib -from typing import TYPE_CHECKING, Callable, Dict, List, Optional +from typing import TYPE_CHECKING, Callable, Dict, Optional + +import semantic_version from plugin.exceptions import LegacyPluginNeedsMigrating @@ -25,6 +27,7 @@ class Plugin(abc.ABC): def __init__(self, logger: LoggerMixin, manager: PluginManager) -> None: self.log = logger self._manager = manager + self.can_reload = True # Set to false to prevent reload support @abc.abstractmethod def load(self, plugin_path: pathlib.Path) -> PluginInfo: @@ -62,13 +65,9 @@ def show_error(self): class MigratedPlugin(Plugin): """MigratedPlugin is a wrapper for old-style plugins.""" - OLD_CALLBACKS_AND_BEHAVIOUR = ( - ('plugin_app', lambda x: decorators.hook("core.plugin_ui_setup")(x)), - ('plugin_prefs', lambda x: decorators.hook('core.plugin_preferences_setup')(x)) - ) - def __init__(self, logger: LoggerMixin, module: ModuleType, manager: PluginManager) -> None: super().__init__(logger, manager) + self.can_reload = False self.module = module # Find start3 plugin_start3: Optional[Callable[[str], str]] = getattr(self.module, 'plugin_start3') @@ -89,19 +88,55 @@ def __init__(self, logger: LoggerMixin, module: ModuleType, manager: PluginManag if callback is None: continue - setattr(self, f"_SYNTHETIC_CALLBACK_{old_callback}", decorators.hook(new_hook)(old_callback)) + target_name = f"_SYNTHETIC_CALLBACK_{old_callback}" + setattr(self, target_name, decorators.hook(new_hook)(old_callback)) + self.log.trace( + f"Successfully created fake callback wrapper {target_name} for old callback {old_callback} ({callback})" + ) def load(self, plugin_path: pathlib.Path) -> 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(plugin_path)) + + if (version_str := getattr(self.module, "__version__")) is not None: + version = semantic_version.Version.coerce(version_str) + + else: + version = semantic_version.Version.coerce('0.0.0+UNKNOWN') - return super().load(plugin_path) + authors = getattr(self.module, '__author__') + if authors is None: + authors = getattr(self.module, "__credits__") + + if authors is not None and not isinstance(authors, list): + authors = [authors] + + comment = getattr(self.module, "__doc__") + + 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'Plugin3 provided by plugin is not callable: {load3!r}') sig = inspect.signature(load3) if not len(sig.parameters) == 1: - raise ValueError(f'Plugin3 provided by legacy plugin takes an unexpected arg count: {len(sig.parameters)}') - - ... + raise ValueError( + f'Plugin3 provided by legacy plugin takes an unexpected arg count:' + f'{len(sig.parameters)}; {sig.parameters}' + ) From cc61c7fde7897508608b53de56bd221fc62c83b2 Mon Sep 17 00:00:00 2001 From: A_D Date: Wed, 17 Mar 2021 00:52:01 +0200 Subject: [PATCH 024/152] Updated arch file with legacy loading changes --- plugin/ARCHITECTURE.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugin/ARCHITECTURE.md b/plugin/ARCHITECTURE.md index 141c156147..146d819545 100644 --- a/plugin/ARCHITECTURE.md +++ b/plugin/ARCHITECTURE.md @@ -46,6 +46,7 @@ If we find said file, we load the plugin in the wrapped plugin loading system. !!UNIMPLEMENTED -- Planning +TODO: This is different now, plugins can have `__init__.py`s even if they're old style During the above loading steps to find the packages for new-style plugins, old style plugins are assumed to be any python files in the root plugin directory, or any directory under the root that has no `__init__.py`. These will be wrapped in a compatibility class and should continue to work as normal: @@ -55,6 +56,10 @@ be wrapped in a compatibility class and should continue to work as normal: 3. These will explicitly NOT support reloading. And any attempt to reload them will result in a very large and scary exception being thrown. +PluginInfo contents such as version, author, and comment are 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`. + ### Post instantiation of class After a plugin class is instantiated, two things happen: From facc04e030220ef82d89eaae72dbed3668b24edf Mon Sep 17 00:00:00 2001 From: A_D Date: Wed, 17 Mar 2021 05:56:11 +0200 Subject: [PATCH 025/152] Fixed log wording --- plugin/plugin.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugin/plugin.py b/plugin/plugin.py index 1fc51e316c..ddfd7a78f6 100644 --- a/plugin/plugin.py +++ b/plugin/plugin.py @@ -8,7 +8,7 @@ import semantic_version -from plugin.exceptions import LegacyPluginNeedsMigrating +from plugin.exceptions import LegacyPluginHasNoStart3, LegacyPluginNeedsMigrating if TYPE_CHECKING: from EDMCLogging import LoggerMixin @@ -77,7 +77,7 @@ def __init__(self, logger: LoggerMixin, module: ModuleType, manager: PluginManag if plugin_start is not None: raise LegacyPluginNeedsMigrating - raise ValueError('Plugin does not define a plugin_start3 method') + raise LegacyPluginHasNoStart3 self.enforce_load3_signature(plugin_start3) self.start3 = plugin_start3 @@ -132,11 +132,11 @@ def enforce_load3_signature(load3: Callable): :raises ValueError: If the given callable accepts the wrong number of args """ if not callable(load3): - raise ValueError(f'Plugin3 provided by plugin is not callable: {load3!r}') + raise ValueError(f'load3 provided by plugin is not callable: {load3!r}') sig = inspect.signature(load3) if not len(sig.parameters) == 1: raise ValueError( - f'Plugin3 provided by legacy plugin takes an unexpected arg count:' + 'load3 provided by legacy plugin takes an unexpected arg count:' f'{len(sig.parameters)}; {sig.parameters}' ) From 4b97e1a71c7530817c64c33a6bf41a0a054184cf Mon Sep 17 00:00:00 2001 From: A_D Date: Wed, 17 Mar 2021 05:56:20 +0200 Subject: [PATCH 026/152] Refactored load_plugin to be plugin type agnostic This means that load_plugin now will load any plugin at a given path, legacy or otherwise. --- plugin/manager.py | 142 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 119 insertions(+), 23 deletions(-) diff --git a/plugin/manager.py b/plugin/manager.py index 14fc9639b3..d3bbf4400e 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -5,7 +5,7 @@ import importlib import pathlib import sys -from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Set, Type +from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Set, Tuple, Type if TYPE_CHECKING: from types import ModuleType @@ -18,6 +18,8 @@ from plugin.plugin import MigratedPlugin, Plugin from plugin.plugin_info import PluginInfo +PLUGIN_MODULE_PAIR = Tuple[Optional[Plugin], Optional[ModuleType]] + @dataclasses.dataclass class LoadedPlugin: @@ -28,6 +30,12 @@ class LoadedPlugin: module: ModuleType callbacks: Dict[str, List[Callable]] + def __str__(self) -> str: + return ( + f'Plugin {self.info.name} from {self.module} on {self.plugin._manager}' + f' with {len(self.callbacks)} callbacks' + ) + class PluginManager: """PluginManager is an event engine and plugin engine.""" @@ -123,7 +131,7 @@ def resolve_path_to_plugin(path: pathlib.Path, relative_to=None) -> str: relative = path.relative_to(relative_to) return ".".join(relative.parts) - def load_plugin(self, path: pathlib.Path, autoresolve_sys_path=True) -> Optional[LoadedPlugin]: + def load_normal_plugin(self, path: pathlib.Path, autoresolve_sys_path=True) -> PLUGIN_MODULE_PAIR: """ Load a plugin at the given path. @@ -154,40 +162,117 @@ def load_plugin(self, path: pathlib.Path, autoresolve_sys_path=True) -> Optional self.log.error(f"Unable to load module {path}") raise PluginLoadingException(f"Exception occurred while loading: {e}") from e - loaded = None + uninstantiated: Optional[Type[Plugin]] = 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[Plugin] = 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' + + plugin: Optional[Plugin] = None + module: Optional[ModuleType] = None + + if init.exists(): + # Could be either type, start by trying a normal plugin try: - loaded = self.__load_plugin_from_class(path, module, class_name, cls) + 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 + return None + except Exception as e: - self.log.info(f"Failed to load plugin {class_name} -> {cls!r}") - raise PluginLoadingException(f"Cannot load plugin {cls!r}: {e}") from e + self.log.exception(f'Exception occurred during loading plugin at {path}: {e} THIS IS A BUG!') + raise - if self.is_plugin_loaded(loaded.info.name): - self.log.error("Plugins with the same names attempted to load (double load?)") - raise PluginAlreadyLoadedException(f"Plugin with name {loaded.info.name} cannot be loaded twice") + 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' + ) - break + try: + plugin, module = self.load_legacy_plugin(path) + except PluginLoadingException as e: + self.log.exception(f'Unable to load legacy plugin at {path}: {e}') + raise + return None - if loaded is None: - self.log.error(f"No plugin class found in {path}") - raise PluginHasNoPluginClassException(f"No plugin class found in {path}") + except Exception as e: + self.log.exception(f'Exception occurred during loading of legacy plugin at {path}: {e} THIS IS A BUG') + raise - self.plugins[loaded.info.name] = loaded - return loaded + return plugin, module - def load_legacy_or_normal_plugin(self, path: pathlib.Path, autoresolve_sys_path=True) -> Optional[LoadedPlugin]: + 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 + + plugin, module = self.__get_plugin_at(path, autoresolve_sys_path=autoresolve_sys_path) + + 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: - return self.load_plugin(path, autoresolve_sys_path=autoresolve_sys_path) - except PluginDoesNotExistException: - # No __init__.py. Try load it as a legacy plugin directly from the path - ... - ... + info = plugin.load() + except PluginLoadingException: + # TODO: store this to note that it was tried but failed + return None - def load_legacy_plugin_from_path(self, path: pathlib.Path) -> Optional[MigratedPlugin]: + except Exception as e: + raise PluginLoadingException from e + + if info.name in self.plugins: + raise PluginAlreadyLoadedException(info.name) + + loaded = LoadedPlugin(info, plugin, module, plugin._find_callbacks()) + self.plugins[info.name] = loaded + self.log.trace(f'successfully loaded {loaded}') + + return loaded + + def load_legacy_plugin(self, path: pathlib.Path) -> PLUGIN_MODULE_PAIR: """ Load a legacy (load.py and plugin_start3()) plugin from the given path. @@ -200,6 +285,9 @@ def load_legacy_plugin_from_path(self, path: pathlib.Path) -> Optional[MigratedP 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 + resolved = self.resolve_path_to_plugin(target)[:-3] # strip off .py try: @@ -208,7 +296,15 @@ def load_legacy_plugin_from_path(self, path: pathlib.Path) -> Optional[MigratedP # Something went wrong _but_ the file _DOES_ exist. raise PluginLoadingException from e - ... + logger = get_plugin_logger(path.parts[-1]) + + 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: """ From 856c563e037f92d80147795e8b955473db7525e8 Mon Sep 17 00:00:00 2001 From: A_D Date: Sat, 20 Mar 2021 08:02:29 +0200 Subject: [PATCH 027/152] Added string and find_callback methods to plugin The find callback method migrates the callback location code from manager to plugin --- plugin/plugin.py | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/plugin/plugin.py b/plugin/plugin.py index ddfd7a78f6..9319daac9f 100644 --- a/plugin/plugin.py +++ b/plugin/plugin.py @@ -4,7 +4,8 @@ import abc import inspect import pathlib -from typing import TYPE_CHECKING, Callable, Dict, Optional +from collections import defaultdict +from typing import TYPE_CHECKING, Callable, Dict, List, Optional import semantic_version @@ -24,13 +25,15 @@ class Plugin(abc.ABC): # TODO: a similar level of paranoia about defined methods where needed - def __init__(self, logger: LoggerMixin, manager: PluginManager) -> None: + 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, plugin_path: pathlib.Path) -> PluginInfo: + def load(self) -> PluginInfo: """ Load this plugin. @@ -46,6 +49,22 @@ def show_error(self): # TODO: replacement of plug.show_error ... + def _find_callbacks(self) -> Dict[str, List[Callable]]: + out: Dict[str, List[Callable]] = defaultdict(list) + + for field in self.__dict__.values(): + callbacks: Optional[List[str]] = getattr(field, decorators.CALLBACK_MARKER) + if callbacks is None: + continue + + for name in callbacks: + out[name].append(field) + + return dict(out) + + def __str__(self) -> str: + return f'Plugin at {self.path} on {self._manager} ' + LEGACY_CALLBACK_LUT: Dict[str, str] = { 'core.setup_ui': 'plugin_app', @@ -65,8 +84,8 @@ def show_error(self): class MigratedPlugin(Plugin): """MigratedPlugin is a wrapper for old-style plugins.""" - def __init__(self, logger: LoggerMixin, module: ModuleType, manager: PluginManager) -> None: - super().__init__(logger, manager) + 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 From 3eb8cf52f98e7ad2dd9e9c8a82179118d0ef5fbb Mon Sep 17 00:00:00 2001 From: A_D Date: Mon, 22 Mar 2021 03:58:09 +0200 Subject: [PATCH 028/152] Getattr explodes if you dont pass a default --- plugin/plugin.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/plugin/plugin.py b/plugin/plugin.py index 9319daac9f..42ad9a56ed 100644 --- a/plugin/plugin.py +++ b/plugin/plugin.py @@ -53,7 +53,7 @@ def _find_callbacks(self) -> Dict[str, List[Callable]]: out: Dict[str, List[Callable]] = defaultdict(list) for field in self.__dict__.values(): - callbacks: Optional[List[str]] = getattr(field, decorators.CALLBACK_MARKER) + callbacks: Optional[List[str]] = getattr(field, decorators.CALLBACK_MARKER, None) if callbacks is None: continue @@ -89,8 +89,8 @@ def __init__(self, logger: LoggerMixin, module: ModuleType, manager: PluginManag self.can_reload = False self.module = module # Find start3 - plugin_start3: Optional[Callable[[str], str]] = getattr(self.module, 'plugin_start3') - plugin_start: Optional[Callable[[str], str]] = getattr(self.module, 'plugin_start') + 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: @@ -103,7 +103,7 @@ def __init__(self, logger: LoggerMixin, module: ModuleType, manager: PluginManag # We have a start3, lets see what else we have and get ready to prepare hooks for them for new_hook, old_callback in LEGACY_CALLBACK_LUT.items(): - callback: Optional[Callable] = getattr(self.module, old_callback) + callback: Optional[Callable] = getattr(self.module, old_callback, None) if callback is None: continue @@ -113,7 +113,7 @@ def __init__(self, logger: LoggerMixin, module: ModuleType, manager: PluginManag f"Successfully created fake callback wrapper {target_name} for old callback {old_callback} ({callback})" ) - def load(self, plugin_path: pathlib.Path) -> PluginInfo: + def load(self) -> PluginInfo: """ Load the legacy plugin. @@ -122,22 +122,22 @@ def load(self, plugin_path: pathlib.Path) -> PluginInfo: :param plugin_path: The path to this plugin :return: PluginInfo telling the world about us """ - name = self.start3(str(plugin_path)) + name = self.start3(str(self.path)) - if (version_str := getattr(self.module, "__version__")) is not None: + if (version_str := getattr(self.module, "__version__", None)) is not None: version = semantic_version.Version.coerce(version_str) else: version = semantic_version.Version.coerce('0.0.0+UNKNOWN') - authors = getattr(self.module, '__author__') + authors = getattr(self.module, '__author__', None) if authors is None: - authors = getattr(self.module, "__credits__") + authors = getattr(self.module, "__credits__", None) if authors is not None and not isinstance(authors, list): authors = [authors] - comment = getattr(self.module, "__doc__") + comment = getattr(self.module, "__doc__", None) return PluginInfo(name, version, authors=authors, comment=comment) From 3dfcce61c81852ec23ebd3b2b0730591c10fb6c7 Mon Sep 17 00:00:00 2001 From: A_D Date: Mon, 22 Mar 2021 03:58:45 +0200 Subject: [PATCH 029/152] Removed unused code --- plugin/manager.py | 63 +---------------------------------------------- 1 file changed, 1 insertion(+), 62 deletions(-) diff --git a/plugin/manager.py b/plugin/manager.py index d3bbf4400e..ebe0b2ce8b 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -18,7 +18,7 @@ from plugin.plugin import MigratedPlugin, Plugin from plugin.plugin_info import PluginInfo -PLUGIN_MODULE_PAIR = Tuple[Optional[Plugin], Optional[ModuleType]] +PLUGIN_MODULE_PAIR = Tuple[Optional[Plugin], Optional['ModuleType']] @dataclasses.dataclass @@ -56,66 +56,6 @@ def find_potential_plugins(self, path: pathlib.Path) -> List[pathlib.Path]: # TODO: ignore ones ending in .disabled, either here or lower down return list(filter(lambda f: f.is_dir(), path.iterdir())) - # def clean_potential_plugins(self, paths: List[pathlib.Path]) -> Tuple[List[pathlib.Path], List[pathlib.Path]]: - # """ - # Split potential plugins into normal and legacy plugins. - - # Silently drops any potential plugin paths that dont match requirements. - - # :param paths: The potential plugin paths - # :return: A tuple containing plugin and legacy plugin path lists - # """ - # legacy = [] - # plugins = [] - - # for path in paths: - # for file in list(filter(lambda f: f.is_file(), path.iterdir())): - # if file.match("__init__.py"): - # # Assume a normal plugin - - # ... - - def __load_plugin_from_class( - self, path: pathlib.Path, module: ModuleType, class_name: str, cls: Type[Plugin] - ) -> LoadedPlugin: - - str_plugin_reference = f"{class_name} -> {cls!r} from path {path}" - self.log.trace(f"Loading plugin class {str_plugin_reference}") - - plugin_logger = get_plugin_logger(path.parts[-1]) - - try: - instantiated = cls(plugin_logger, self) - - except Exception: - self.log.exception(f"Could not instantiate plugin class for plugin {str_plugin_reference}") - raise - - callbacks: Dict[str, List[Callable]] = {} - - for field_name, class_field in cls.__dict__.items(): - if not hasattr(class_field, decorators.CALLBACK_MARKER): - continue - - events = getattr(class_field, decorators.CALLBACK_MARKER) - - self.log.trace(f"found callback method {field_name} -> {class_field} with callbacks {events}") - for name in events: - callbacks[name] = callbacks.get(name, []) + [class_field] - - self.log.trace(f"finished finding callbacks on plugin class {str_plugin_reference}") - - try: - info = instantiated.load(path) - except Exception: - self.log.exception(f"Could not call load on plugin {str_plugin_reference}") - raise - - if info is None: - raise PluginLoadingException(f"Plugin {str_plugin_reference} did not return a valid PluginInfo") - - return LoadedPlugin(info, instantiated, module, callbacks) - @staticmethod def resolve_path_to_plugin(path: pathlib.Path, relative_to=None) -> str: """ @@ -224,7 +164,6 @@ def __get_plugin_at(self, path: pathlib.Path, autoresolve_sys_path=True) -> PLUG except PluginLoadingException as e: self.log.exception(f'Unable to load legacy plugin at {path}: {e}') raise - return None except Exception as e: self.log.exception(f'Exception occurred during loading of legacy plugin at {path}: {e} THIS IS A BUG') From a614f484a1eae6a92abdf604267d9c9c388cb1f4 Mon Sep 17 00:00:00 2001 From: A_D Date: Mon, 22 Mar 2021 03:59:06 +0200 Subject: [PATCH 030/152] Fixed some missed loading errors --- plugin/manager.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/plugin/manager.py b/plugin/manager.py index ebe0b2ce8b..83343bcb6b 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -134,6 +134,9 @@ def __get_plugin_at(self, path: pathlib.Path, autoresolve_sys_path=True) -> PLUG init = path / '__init__.py' load = path / 'load.py' + if not path.exists() or (not init.exists() and not load.exists()): + raise PluginDoesNotExistException + plugin: Optional[Plugin] = None module: Optional[ModuleType] = None @@ -200,7 +203,15 @@ def load_plugin(self, path: pathlib.Path, autoresolve_sys_path=True) -> Optional return None except Exception as e: - raise PluginLoadingException from 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) From 0201accb586595f52c13b2ab033434cc16645d0c Mon Sep 17 00:00:00 2001 From: A_D Date: Mon, 22 Mar 2021 03:59:23 +0200 Subject: [PATCH 031/152] Updated tests, added legacy plugin test --- plugin/test/conftest.py | 3 ++- plugin/test/test_load.py | 14 +++++++++++--- .../test_plugins/bad/class_init_error/__init__.py | 8 ++++---- .../test_plugins/bad/class_load_error/__init__.py | 2 +- .../test/test_plugins/bad/double_load/__init__.py | 2 +- .../test_plugins/bad/null_plugin_info/__init__.py | 2 +- .../test_plugins/bad/str_plugin_info/__init__.py | 12 ++++++++++++ .../test_plugins/bad/unload_exception/__init__.py | 2 +- .../test_plugins/bad/unload_shutdown/__init__.py | 2 +- plugin/test/test_plugins/good/simple/__init__.py | 2 +- plugin/test/test_plugins/legacy/good/load.py | 8 ++++++++ 11 files changed, 43 insertions(+), 14 deletions(-) create mode 100644 plugin/test/test_plugins/bad/str_plugin_info/__init__.py create mode 100644 plugin/test/test_plugins/legacy/good/load.py diff --git a/plugin/test/conftest.py b/plugin/test/conftest.py index bec50351b4..ca01710bec 100644 --- a/plugin/test/conftest.py +++ b/plugin/test/conftest.py @@ -5,7 +5,7 @@ from pytest import fixture -sys.path.append(str(pathlib.Path(__file__).parent.parent.parent)) +# sys.path.append(str(pathlib.Path(__file__).parent.parent.parent)) from plugin.manager import PluginManager # noqa: E402 # Has to be after the path fiddling @@ -19,3 +19,4 @@ def plugin_manager() -> typing.Generator[PluginManager, None, None]: current_path = pathlib.Path.cwd() / "plugin/test/test_plugins" good_path = current_path / "good" bad_path = current_path / "bad" +legacy_path = current_path / "legacy" diff --git a/plugin/test/test_load.py b/plugin/test/test_load.py index a317bea7b7..21fc537285 100644 --- a/plugin/test/test_load.py +++ b/plugin/test/test_load.py @@ -10,7 +10,7 @@ PluginManager ) -from .conftest import bad_path, good_path +from .conftest import bad_path, good_path, legacy_path def _idfn(test_data) -> str: @@ -27,8 +27,16 @@ def _idfn(test_data) -> str: (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")) - # TODO: plugin that imports a nonexistent module + (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 plugins + (legacy_path / "good", nullcontext()), ] diff --git a/plugin/test/test_plugins/bad/class_init_error/__init__.py b/plugin/test/test_plugins/bad/class_init_error/__init__.py index 27ec58d975..6f2d4616ff 100644 --- a/plugin/test/test_plugins/bad/class_init_error/__init__.py +++ b/plugin/test/test_plugins/bad/class_init_error/__init__.py @@ -8,9 +8,9 @@ @edmc_plugin class Broken(Plugin): - def __init__(self, logger) -> None: - super().__init__(logger) + def __init__(self, logger, manager, path) -> None: + super().__init__(logger, manager, path) raise Exception("Exception in init") - def load(self, plugin_path: pathlib.Path) -> PluginInfo: - return super().load(plugin_path) + def load(self) -> PluginInfo: + 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 index 3ba8e6b9bc..bb289c8cd7 100644 --- a/plugin/test/test_plugins/bad/class_load_error/__init__.py +++ b/plugin/test/test_plugins/bad/class_load_error/__init__.py @@ -9,5 +9,5 @@ @edmc_plugin class Broken(Plugin): - def load(self, plugin_path: pathlib.Path) -> PluginInfo: + def load(self) -> PluginInfo: 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 index c1f2569d40..4cbd02b472 100644 --- a/plugin/test/test_plugins/bad/double_load/__init__.py +++ b/plugin/test/test_plugins/bad/double_load/__init__.py @@ -11,6 +11,6 @@ class Broken(Plugin): """Valid (but not loadable twice) plugin.""" - def load(self, plugin_path: pathlib.Path) -> PluginInfo: + def load(self) -> PluginInfo: """Load.""" return PluginInfo("double_load", semantic_version.Version.coerce("0.0.1")) diff --git a/plugin/test/test_plugins/bad/null_plugin_info/__init__.py b/plugin/test/test_plugins/bad/null_plugin_info/__init__.py index 145ee8af6d..3d6c141e60 100644 --- a/plugin/test/test_plugins/bad/null_plugin_info/__init__.py +++ b/plugin/test/test_plugins/bad/null_plugin_info/__init__.py @@ -6,6 +6,6 @@ class BadPlugInfo(Plugin): """Plugin that returns a bad PluginInfo object.""" - def load(self, plugin_path) -> PluginInfo: + 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..c5ad0fc8b6 --- /dev/null +++ b/plugin/test/test_plugins/bad/str_plugin_info/__init__.py @@ -0,0 +1,12 @@ +"""Test Plugin.""" +from plugin.decorators import edmc_plugin +from plugin.plugin import Plugin, PluginInfo + + +@edmc_plugin +class BadPlugInfo(Plugin): + """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 index 9b82d19343..8a8ec7f639 100644 --- a/plugin/test/test_plugins/bad/unload_exception/__init__.py +++ b/plugin/test/test_plugins/bad/unload_exception/__init__.py @@ -12,7 +12,7 @@ class UnloadException(Plugin): """Throws an exception during unload.""" - def load(self, plugin_path: pathlib.Path) -> PluginInfo: + def load(self) -> PluginInfo: """Load.""" return PluginInfo("unload_exception", semantic_version.Version.coerce("0.0.1")) diff --git a/plugin/test/test_plugins/bad/unload_shutdown/__init__.py b/plugin/test/test_plugins/bad/unload_shutdown/__init__.py index 72b5a1a52a..852660c8a9 100644 --- a/plugin/test/test_plugins/bad/unload_shutdown/__init__.py +++ b/plugin/test/test_plugins/bad/unload_shutdown/__init__.py @@ -13,7 +13,7 @@ class UnloadSystemExit(Plugin): """Throws an exception during unload.""" - def load(self, plugin_path: pathlib.Path) -> PluginInfo: + def load(self) -> PluginInfo: """Load.""" return PluginInfo("unload_exception", semantic_version.Version.coerce("0.0.1")) diff --git a/plugin/test/test_plugins/good/simple/__init__.py b/plugin/test/test_plugins/good/simple/__init__.py index c06613accd..db1e07dbc0 100644 --- a/plugin/test/test_plugins/good/simple/__init__.py +++ b/plugin/test/test_plugins/good/simple/__init__.py @@ -12,7 +12,7 @@ class GoodPlugin(Plugin): """Plugin that loads correctly.""" - def load(self, plugin_path: pathlib.Path) -> PluginInfo: + def load(self) -> PluginInfo: """Nothing Special.""" return PluginInfo( name="good", diff --git a/plugin/test/test_plugins/legacy/good/load.py b/plugin/test/test_plugins/legacy/good/load.py new file mode 100644 index 0000000000..77212f1284 --- /dev/null +++ b/plugin/test/test_plugins/legacy/good/load.py @@ -0,0 +1,8 @@ +"""Test Legacy Plugin.""" + +__author__ = ["A_D"] + + +def plugin_start3(path: str) -> str: + """Test start3.""" + return "test_plugin" From 3f9a93da8b950bc136383fffb15d529c3bd6d866 Mon Sep 17 00:00:00 2001 From: A_D Date: Mon, 22 Mar 2021 03:59:54 +0200 Subject: [PATCH 032/152] Apparently I hadnt added this --- plugin/exceptions.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 plugin/exceptions.py diff --git a/plugin/exceptions.py b/plugin/exceptions.py new file mode 100644 index 0000000000..b4ee50fe53 --- /dev/null +++ b/plugin/exceptions.py @@ -0,0 +1,26 @@ +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.""" + + +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 + """ From 8ac2bd87c1d9819f080d451b072445c04d25bbe5 Mon Sep 17 00:00:00 2001 From: A_D Date: Mon, 22 Mar 2021 11:53:31 +0200 Subject: [PATCH 033/152] Added legacy loading tests --- plugin/manager.py | 4 ++- plugin/test/conftest.py | 5 ++- plugin/test/test_load.py | 31 ++++++++++++++----- .../legacy/bad/import_error/load.py | 3 ++ .../legacy/bad/load_error/load.py | 6 ++++ .../legacy/good/{ => simple}/load.py | 0 6 files changed, 37 insertions(+), 12 deletions(-) create mode 100644 plugin/test/test_plugins/legacy/bad/import_error/load.py create mode 100644 plugin/test/test_plugins/legacy/bad/load_error/load.py rename plugin/test/test_plugins/legacy/good/{ => simple}/load.py (100%) diff --git a/plugin/manager.py b/plugin/manager.py index 83343bcb6b..a5103e6eb6 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -147,6 +147,7 @@ def __get_plugin_at(self, path: pathlib.Path, autoresolve_sys_path=True) -> PLUG except PluginHasNoPluginClassException: if not load.exists(): raise + except PluginLoadingException as e: self.log.exception(f'Unable to load plugin at {path}: {e}') raise @@ -164,6 +165,7 @@ def __get_plugin_at(self, path: pathlib.Path, autoresolve_sys_path=True) -> PLUG try: plugin, module = self.load_legacy_plugin(path) + except PluginLoadingException as e: self.log.exception(f'Unable to load legacy plugin at {path}: {e}') raise @@ -244,7 +246,7 @@ def load_legacy_plugin(self, path: pathlib.Path) -> PLUGIN_MODULE_PAIR: module = importlib.import_module(resolved) except Exception as e: # Something went wrong _but_ the file _DOES_ exist. - raise PluginLoadingException from e + raise PluginLoadingException(f'Exception while loading {resolved}: {e}') from e logger = get_plugin_logger(path.parts[-1]) diff --git a/plugin/test/conftest.py b/plugin/test/conftest.py index ca01710bec..eef99d7200 100644 --- a/plugin/test/conftest.py +++ b/plugin/test/conftest.py @@ -1,12 +1,9 @@ """Setup constants and fixtures for plugin tests.""" import pathlib -import sys import typing from pytest import fixture -# sys.path.append(str(pathlib.Path(__file__).parent.parent.parent)) - from plugin.manager import PluginManager # noqa: E402 # Has to be after the path fiddling @@ -20,3 +17,5 @@ def plugin_manager() -> typing.Generator[PluginManager, None, None]: good_path = current_path / "good" bad_path = current_path / "bad" legacy_path = current_path / "legacy" +legacy_good_path = legacy_path / "good" +legacy_bad_path = legacy_path / "bad" diff --git a/plugin/test/test_load.py b/plugin/test/test_load.py index 21fc537285..3183b43488 100644 --- a/plugin/test/test_load.py +++ b/plugin/test/test_load.py @@ -1,7 +1,7 @@ """Testing suite for plugin loading system.""" import pathlib from contextlib import nullcontext -from typing import ContextManager +from typing import Any, ContextManager, List, Tuple import pytest @@ -10,15 +10,30 @@ PluginManager ) -from .conftest import bad_path, good_path, legacy_path +from .conftest import bad_path, good_path, legacy_bad_path, legacy_good_path, legacy_path def _idfn(test_data) -> str: - if isinstance(test_data, pathlib.Path): - return test_data.parts[-1] + if not isinstance(test_data, pathlib.Path): + return "" - 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()), + (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'" + ) + ), +] TESTS = [ (good_path / "simple", nullcontext()), @@ -36,11 +51,11 @@ def _idfn(test_data) -> str: ), # Legacy plugins - (legacy_path / "good", nullcontext()), -] + +] + LEGACY_TESTS -@pytest.mark.parametrize('path,context', TESTS, ids=_idfn) +@ 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. diff --git a/plugin/test/test_plugins/legacy/bad/import_error/load.py b/plugin/test/test_plugins/legacy/bad/import_error/load.py new file mode 100644 index 0000000000..9bfbc48609 --- /dev/null +++ b/plugin/test/test_plugins/legacy/bad/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/load_error/load.py b/plugin/test/test_plugins/legacy/bad/load_error/load.py new file mode 100644 index 0000000000..13971eb88b --- /dev/null +++ b/plugin/test/test_plugins/legacy/bad/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/load.py b/plugin/test/test_plugins/legacy/good/simple/load.py similarity index 100% rename from plugin/test/test_plugins/legacy/good/load.py rename to plugin/test/test_plugins/legacy/good/simple/load.py From de88307d6da068bdaa9a0bf98a128c2988d7fb43 Mon Sep 17 00:00:00 2001 From: A_D Date: Mon, 22 Mar 2021 12:04:30 +0200 Subject: [PATCH 034/152] made quoting consistent --- plugin/test/conftest.py | 12 ++++----- plugin/test/test_load.py | 26 +++++++++---------- .../bad/class_init_error/__init__.py | 10 ++++--- .../bad/class_load_error/__init__.py | 7 ++--- .../test_plugins/bad/double_load/__init__.py | 5 ++-- .../test/test_plugins/bad/error/__init__.py | 1 + .../test_plugins/bad/no_plugin/__init__.py | 2 +- .../bad/null_plugin_info/__init__.py | 1 + .../bad/str_plugin_info/__init__.py | 2 +- .../bad/unload_exception/__init__.py | 6 ++--- .../bad/unload_shutdown/__init__.py | 2 +- .../test/test_plugins/good/simple/__init__.py | 6 ++--- .../legacy/bad/load_error/load.py | 2 +- .../test_plugins/legacy/good/simple/load.py | 4 +-- plugin/test/test_unload.py | 6 ++--- 15 files changed, 46 insertions(+), 46 deletions(-) diff --git a/plugin/test/conftest.py b/plugin/test/conftest.py index eef99d7200..e58128650d 100644 --- a/plugin/test/conftest.py +++ b/plugin/test/conftest.py @@ -13,9 +13,9 @@ def plugin_manager() -> typing.Generator[PluginManager, None, None]: 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" -legacy_bad_path = legacy_path / "bad" +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' +legacy_bad_path = legacy_path / 'bad' diff --git a/plugin/test/test_load.py b/plugin/test/test_load.py index 3183b43488..3e31d00528 100644 --- a/plugin/test/test_load.py +++ b/plugin/test/test_load.py @@ -24,7 +24,7 @@ def _idfn(test_data) -> str: LEGACY_TESTS: List[Tuple[pathlib.Path, Any]] = [ - (legacy_good_path / "simple", nullcontext()), + (legacy_good_path / 'simple', nullcontext()), (legacy_bad_path / "load_error", pytest.raises(PluginLoadingException, match=r'Exception in load method.*BANG!$')), ( legacy_bad_path / 'import_error', @@ -36,13 +36,13 @@ def _idfn(test_data) -> str: ] TESTS = [ - (good_path / "simple", nullcontext()), - (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")), + (good_path / 'simple', nullcontext()), + (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( @@ -70,16 +70,16 @@ def test_load(plugin_manager: PluginManager, context: ContextManager, path: path def test_double_load(plugin_manager: PluginManager) -> None: """Attempt to load a plugin twice.""" - plugin_manager.load_plugin(bad_path / "double_load") + plugin_manager.load_plugin(bad_path / 'double_load') with pytest.raises(PluginAlreadyLoadedException): - plugin_manager.load_plugin(bad_path / "double_load") + plugin_manager.load_plugin(bad_path / 'double_load') 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 plugin_manager.is_plugin_loaded('good') assert plug is not None unload_called = False @@ -92,7 +92,7 @@ def mock_unload(): with pytest.MonkeyPatch.context() as mp: mp.setattr(plug.plugin, 'unload', mock_unload) # patch the unload method - plugin_manager.unload_plugin("good") + plugin_manager.unload_plugin('good') - assert not plugin_manager.is_plugin_loaded("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 index 6f2d4616ff..3947f5bd69 100644 --- a/plugin/test/test_plugins/bad/class_init_error/__init__.py +++ b/plugin/test/test_plugins/bad/class_init_error/__init__.py @@ -1,16 +1,18 @@ """Plugin that errors on __init__().""" -import pathlib -from plugin.plugin_info import PluginInfo -from plugin.plugin import Plugin from plugin.decorators import edmc_plugin +from plugin.plugin import Plugin +from plugin.plugin_info import PluginInfo @edmc_plugin class Broken(Plugin): + """Test plugin.""" + def __init__(self, logger, manager, path) -> None: super().__init__(logger, manager, path) - raise Exception("Exception in init") + raise Exception('Exception in init') def load(self) -> PluginInfo: + """Required.""" 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 index bb289c8cd7..a34515ea79 100644 --- a/plugin/test/test_plugins/bad/class_load_error/__init__.py +++ b/plugin/test/test_plugins/bad/class_load_error/__init__.py @@ -1,7 +1,5 @@ """Plugin that errors on load().""" -import pathlib - from plugin.decorators import edmc_plugin from plugin.plugin import Plugin from plugin.plugin_info import PluginInfo @@ -9,5 +7,8 @@ @edmc_plugin class Broken(Plugin): + """Test Plugin.""" + def load(self) -> PluginInfo: - raise Exception("Exception in load") + """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 index 4cbd02b472..20da080027 100644 --- a/plugin/test/test_plugins/bad/double_load/__init__.py +++ b/plugin/test/test_plugins/bad/double_load/__init__.py @@ -1,5 +1,4 @@ -import pathlib - +"""Test Plugin.""" import semantic_version from plugin.decorators import edmc_plugin @@ -13,4 +12,4 @@ class Broken(Plugin): def load(self) -> PluginInfo: """Load.""" - return PluginInfo("double_load", semantic_version.Version.coerce("0.0.1")) + 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 index 02fae09564..d7ad9520ed 100644 --- a/plugin/test/test_plugins/bad/error/__init__.py +++ b/plugin/test/test_plugins/bad/error/__init__.py @@ -1 +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 index 4fff9329c5..9384d45518 100644 --- a/plugin/test/test_plugins/bad/no_plugin/__init__.py +++ b/plugin/test/test_plugins/bad/no_plugin/__init__.py @@ -1,2 +1,2 @@ """Invalid plugin.""" -print("I have no plugins defined") +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 index 3d6c141e60..d1f5301e32 100644 --- a/plugin/test/test_plugins/bad/null_plugin_info/__init__.py +++ b/plugin/test/test_plugins/bad/null_plugin_info/__init__.py @@ -1,3 +1,4 @@ +"""Test Plugin.""" from plugin.decorators import edmc_plugin from plugin.plugin import Plugin, PluginInfo diff --git a/plugin/test/test_plugins/bad/str_plugin_info/__init__.py b/plugin/test/test_plugins/bad/str_plugin_info/__init__.py index c5ad0fc8b6..b032f11f96 100644 --- a/plugin/test/test_plugins/bad/str_plugin_info/__init__.py +++ b/plugin/test/test_plugins/bad/str_plugin_info/__init__.py @@ -9,4 +9,4 @@ class BadPlugInfo(Plugin): def load(self) -> PluginInfo: """Intentionally broken load().""" - return "This is broken" # type: ignore # Its intentional + 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 index 8a8ec7f639..0438aed609 100644 --- a/plugin/test/test_plugins/bad/unload_exception/__init__.py +++ b/plugin/test/test_plugins/bad/unload_exception/__init__.py @@ -1,7 +1,5 @@ """Plugin that generates an Exception on unload.""" -import pathlib - import semantic_version from plugin.decorators import edmc_plugin @@ -14,8 +12,8 @@ class UnloadException(Plugin): def load(self) -> PluginInfo: """Load.""" - return PluginInfo("unload_exception", semantic_version.Version.coerce("0.0.1")) + return PluginInfo('unload_exception', semantic_version.Version.coerce('0.0.1')) def unload(self) -> None: """Bang!.""" - raise ValueError("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 index 852660c8a9..76ed4c7d02 100644 --- a/plugin/test/test_plugins/bad/unload_shutdown/__init__.py +++ b/plugin/test/test_plugins/bad/unload_shutdown/__init__.py @@ -15,7 +15,7 @@ class UnloadSystemExit(Plugin): def load(self) -> PluginInfo: """Load.""" - return PluginInfo("unload_exception", semantic_version.Version.coerce("0.0.1")) + return PluginInfo("unload_exception", semantic_version.Version.coerce('0.0.1')) def unload(self) -> None: """Bang!.""" diff --git a/plugin/test/test_plugins/good/simple/__init__.py b/plugin/test/test_plugins/good/simple/__init__.py index db1e07dbc0..19b8adc32a 100644 --- a/plugin/test/test_plugins/good/simple/__init__.py +++ b/plugin/test/test_plugins/good/simple/__init__.py @@ -1,6 +1,4 @@ """Test plugin that loads correctly.""" -import pathlib - import semantic_version from plugin.decorators import edmc_plugin @@ -16,6 +14,6 @@ def load(self) -> PluginInfo: """Nothing Special.""" return PluginInfo( name="good", - version=semantic_version.Version.coerce("0.0.1"), - authors=["A_D"] + version=semantic_version.Version.coerce('0.0.1'), + authors=['A_D'] ) diff --git a/plugin/test/test_plugins/legacy/bad/load_error/load.py b/plugin/test/test_plugins/legacy/bad/load_error/load.py index 13971eb88b..45b5615fc2 100644 --- a/plugin/test/test_plugins/legacy/bad/load_error/load.py +++ b/plugin/test/test_plugins/legacy/bad/load_error/load.py @@ -3,4 +3,4 @@ def plugin_start3(_: str) -> str: """Explodes on call.""" - raise ValueError("BANG!") + raise ValueError('BANG!') diff --git a/plugin/test/test_plugins/legacy/good/simple/load.py b/plugin/test/test_plugins/legacy/good/simple/load.py index 77212f1284..f0c14da745 100644 --- a/plugin/test/test_plugins/legacy/good/simple/load.py +++ b/plugin/test/test_plugins/legacy/good/simple/load.py @@ -1,8 +1,8 @@ """Test Legacy Plugin.""" -__author__ = ["A_D"] +__author__ = ['A_D'] def plugin_start3(path: str) -> str: """Test start3.""" - return "test_plugin" + return 'test_plugin' diff --git a/plugin/test/test_unload.py b/plugin/test/test_unload.py index d7dc26953a..01a854a225 100644 --- a/plugin/test/test_unload.py +++ b/plugin/test/test_unload.py @@ -9,9 +9,9 @@ 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!"), + (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!'), ] From b5071740ada410f3140cb8242c1f2347530f6f50 Mon Sep 17 00:00:00 2001 From: A_D Date: Wed, 24 Mar 2021 12:05:21 +0200 Subject: [PATCH 035/152] Fixed a bug, added tests --- plugin/plugin.py | 6 +++++- plugin/test/test_load.py | 25 ++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/plugin/plugin.py b/plugin/plugin.py index 42ad9a56ed..6b0587fb25 100644 --- a/plugin/plugin.py +++ b/plugin/plugin.py @@ -107,8 +107,12 @@ def __init__(self, logger: LoggerMixin, module: ModuleType, manager: PluginManag if callback is None: continue + # TODO: This really will need a wrapper at some point, but those can be defined later + # Dynamically adding methods is done with types.MethodType(function ...) (see docs) + # this is required for access to self, which likely wont be needed here but it also may. Something + # to keep in mind target_name = f"_SYNTHETIC_CALLBACK_{old_callback}" - setattr(self, target_name, decorators.hook(new_hook)(old_callback)) + setattr(self, target_name, decorators.hook(new_hook)(callback)) self.log.trace( f"Successfully created fake callback wrapper {target_name} for old callback {old_callback} ({callback})" ) diff --git a/plugin/test/test_load.py b/plugin/test/test_load.py index 3e31d00528..c8efb4345e 100644 --- a/plugin/test/test_load.py +++ b/plugin/test/test_load.py @@ -1,6 +1,7 @@ """Testing suite for plugin loading system.""" import pathlib from contextlib import nullcontext +from plugin.plugin import LEGACY_CALLBACK_LUT from typing import Any, ContextManager, List, Tuple import pytest @@ -10,6 +11,8 @@ PluginManager ) +from plugin.decorators import CALLBACK_MARKER + from .conftest import bad_path, good_path, legacy_bad_path, legacy_good_path, legacy_path @@ -25,6 +28,7 @@ def _idfn(test_data) -> str: LEGACY_TESTS: List[Tuple[pathlib.Path, Any]] = [ (legacy_good_path / 'simple', nullcontext()), + (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', @@ -55,7 +59,7 @@ def _idfn(test_data) -> str: ] + LEGACY_TESTS -@ pytest.mark.parametrize('path,context', TESTS, ids=_idfn) +@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. @@ -68,6 +72,25 @@ def test_load(plugin_manager: PluginManager, context: ContextManager, path: path 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' + 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) + # does the callback have the same function as the module, _explicitly_ an identity check over an equality check + # as a function equality is shaky at best + assert getattr(loaded.plugin, target_name) is getattr(loaded.module, 'journal_entry') + # has the callback been decorated with hook()? + assert hasattr(getattr(loaded.plugin, target_name), 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) + + def test_double_load(plugin_manager: PluginManager) -> None: """Attempt to load a plugin twice.""" plugin_manager.load_plugin(bad_path / 'double_load') From bf1d6ed3dd421107b4ed998aed6debb89b98ef88 Mon Sep 17 00:00:00 2001 From: A_D Date: Wed, 24 Mar 2021 12:06:07 +0200 Subject: [PATCH 036/152] added test legacy plugin with all callbacks --- .../legacy/good/all_callbacks/load.py | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 plugin/test/test_plugins/legacy/good/all_callbacks/load.py diff --git a/plugin/test/test_plugins/legacy/good/all_callbacks/load.py b/plugin/test/test_plugins/legacy/good/all_callbacks/load.py new file mode 100644 index 0000000000..e851dbb032 --- /dev/null +++ b/plugin/test/test_plugins/legacy/good/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}') From 0fe76ff691c5726c8430c31168cd130a3db71725 Mon Sep 17 00:00:00 2001 From: A_D Date: Fri, 26 Mar 2021 18:53:56 +0200 Subject: [PATCH 037/152] Made legacy plugin callback support work Yay dynamic method creation --- plugin/event.py | 15 +++++++- plugin/plugin.py | 76 +++++++++++++++++++++++++++++++++++----- plugin/test/test_load.py | 12 ++++--- 3 files changed, 89 insertions(+), 14 deletions(-) diff --git a/plugin/event.py b/plugin/event.py index 5e750b7e70..05ae6ef874 100644 --- a/plugin/event.py +++ b/plugin/event.py @@ -1,6 +1,6 @@ import time -from typing import Any, Optional +from typing import Any, Dict, Optional class BaseEvent: @@ -33,3 +33,16 @@ def __init__(self, name: str, data: Any = None, event_time: float = None) -> Non class JournalEvent(BaseDataEvent): """Journal event.""" + + def __init__( + self, name: str, data: Dict[str, Any], event_time: float, cmdr: str, is_beta: bool, + system: Optional[str], station: Optional[str], state: Dict[str, Any] + ) -> None: + + self.data: Dict[str, Any] # Override the definition in BaseDataEvent to be more specific + super().__init__(name, data=data, event_time=event_time) + self.commander = cmdr + self.is_beta = is_beta + self.system = system + self.station = station + self.state = state diff --git a/plugin/plugin.py b/plugin/plugin.py index 6b0587fb25..9c5c8849bf 100644 --- a/plugin/plugin.py +++ b/plugin/plugin.py @@ -5,7 +5,7 @@ import inspect import pathlib from collections import defaultdict -from typing import TYPE_CHECKING, Callable, Dict, List, Optional +from typing import Any, TYPE_CHECKING, Callable, Dict, List, Optional, Tuple, Union, cast import semantic_version @@ -18,6 +18,7 @@ from plugin import decorators from plugin.plugin_info import PluginInfo +from plugin import event class Plugin(abc.ABC): @@ -81,9 +82,32 @@ def __str__(self) -> str: } +def journal_entry_breakout(e: event.JournalEvent) -> Tuple[str, bool, Optional[str], Optional[str], Dict, Dict]: + return (e.commander, e.is_beta, e.system, e.station, e.data, e.state) + + +LEGACY_CALLBACK_BREAKOUT_LUT: Dict[str, Callable[..., Tuple[Any, ...]]] = { + # All of these callables should accept an event.BaseEvent or a subclass thereof + # 'core.setup_ui': 'plugin_app', + # 'core.setup_preferences_ui': 'plugin_prefs', + # 'core.preferences_closed': 'prefs_changed', + 'core.journal_entry': journal_entry_breakout, + # 'core.dashboard_entry': 'dashboard_entry', + # 'core.commander_data': 'cmdr_data', + + + # 'inara.notify_ship': 'inara_notify_ship', + # 'inara.notify_location': 'inara_notify_location', + # 'edsm.notify_system': 'edsm_notify_system', +} + + class MigratedPlugin(Plugin): """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 @@ -102,20 +126,33 @@ def __init__(self, logger: LoggerMixin, module: ModuleType, manager: PluginManag 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() + # for new_hook, old_callback in LEGACY_CALLBACK_LUT.items(): + # callback: Optional[Callable] = getattr(self.module, old_callback, None) + # if callback is None: + # continue + + # # Dynamically adding methods is done with types.MethodType(function ...) (see docs) + # # this is required for access to self, which likely wont be needed here but it also may. Something + # # to keep in mind + # target_name = f"_SYNTHETIC_CALLBACK_{old_callback}" + # setattr(self, target_name, decorators.hook(new_hook)(callback)) + # self.log.trace( + # f"Successfully created fake callback wrapper {target_name} for old callback {old_callback} ({callback})" + # ) + + def setup_callbacks(self) -> None: + # TODO: Update arch with how this works for new_hook, old_callback in LEGACY_CALLBACK_LUT.items(): callback: Optional[Callable] = getattr(self.module, old_callback, None) if callback is None: continue - # TODO: This really will need a wrapper at some point, but those can be defined later - # Dynamically adding methods is done with types.MethodType(function ...) (see docs) - # this is required for access to self, which likely wont be needed here but it also may. Something - # to keep in mind target_name = f"_SYNTHETIC_CALLBACK_{old_callback}" - setattr(self, target_name, decorators.hook(new_hook)(callback)) - self.log.trace( - f"Successfully created fake callback wrapper {target_name} for old callback {old_callback} ({callback})" - ) + breakout = LEGACY_CALLBACK_BREAKOUT_LUT.get(new_hook, lambda e: ()) + + wrapped = self.generic_callback_handler(callback, breakout) + setattr(self, target_name, decorators.hook(new_hook)(wrapped)) def load(self) -> PluginInfo: """ @@ -163,3 +200,24 @@ def enforce_load3_signature(load3: Callable): 'load3 provided by legacy plugin takes an unexpected arg count:' f'{len(sig.parameters)}; {sig.parameters}' ) + + @staticmethod + def generic_callback_handler(f: Callable, breakout: Callable[..., Tuple[Any, ...]]): + def wrapper(e: event.BaseEvent): + return f(*breakout(e)) + + setattr(wrapper, "original_func", f) + return wrapper + + @staticmethod + def journal_callback(f: MigratedPlugin.JOURNAL_EVENT_SIG) -> Callable[[event.JournalEvent], None]: + """ + Wrapper around legacy journal_event calls. + + :param f: Legacy journal_event function + :return: Wrapped callback to the legacy journal_event + """ + def wrapper(e: event.JournalEvent) -> None: + f(e.commander, e.is_beta, e.system, e.station, e.data, e.state) + + return wrapper diff --git a/plugin/test/test_load.py b/plugin/test/test_load.py index c8efb4345e..ecff9b5836 100644 --- a/plugin/test/test_load.py +++ b/plugin/test/test_load.py @@ -28,6 +28,8 @@ def _idfn(test_data) -> str: 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!$')), ( @@ -82,11 +84,13 @@ def test_legacy_load(plugin_manager: PluginManager): # does the callback exist assert hasattr(loaded.plugin, target_name) - # does the callback have the same function as the module, _explicitly_ an identity check over an equality check - # as a function equality is shaky at best - assert getattr(loaded.plugin, target_name) is getattr(loaded.module, 'journal_entry') + 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(getattr(loaded.plugin, target_name), CALLBACK_MARKER) + 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) From a70c1f2b7611375a8096944fc37fdd355f547f19 Mon Sep 17 00:00:00 2001 From: A_D Date: Mon, 29 Mar 2021 10:00:18 +0200 Subject: [PATCH 038/152] added callback test --- plugin/ARCHITECTURE.md | 25 ++++++++--------- plugin/decorators.py | 13 ++++++--- plugin/plugin.py | 28 ++++++++++--------- plugin/test/test_load.py | 5 ++++ .../good/simple_with_callback/__init__.py | 25 +++++++++++++++++ 5 files changed, 65 insertions(+), 31 deletions(-) create mode 100644 plugin/test/test_plugins/good/simple_with_callback/__init__.py diff --git a/plugin/ARCHITECTURE.md b/plugin/ARCHITECTURE.md index 146d819545..e39409cadc 100644 --- a/plugin/ARCHITECTURE.md +++ b/plugin/ARCHITECTURE.md @@ -42,23 +42,20 @@ loading machinery, this exception will be caught and logged at the top level of 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 file, we load the plugin in the wrapped plugin loading system. +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. -!!UNIMPLEMENTED -- Planning +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. -TODO: This is different now, plugins can have `__init__.py`s even if they're old style -During the above loading steps to find the packages for new-style plugins, old style plugins are assumed to be -any python files in the root plugin directory, or any directory under the root that has no `__init__.py`. These will -be wrapped in a compatibility class and should continue to work as normal: - -1. Load file as a module -2. Map any existing functions in the file to their new counterparts, start3 -> load, journal hooks -> event handlers. This is done via generated members on the MigratedPlugin instance -3. These will explicitly NOT support reloading. And any attempt to reload them will result in a very large and scary - exception being thrown. - -PluginInfo contents such as version, author, and comment are extracted if possible from the modules `__version__`, +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`. +`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` ### Post instantiation of class diff --git a/plugin/decorators.py b/plugin/decorators.py index 672aa8a5c7..7f3a78c328 100644 --- a/plugin/decorators.py +++ b/plugin/decorators.py @@ -1,14 +1,14 @@ """New plugin system.""" -from typing import Callable, List, Type +from typing import Any, Callable, List, Type, TypeVar from EDMCLogging import get_main_logger from plugin.plugin import Plugin logger = get_main_logger() -CALLBACK_MARKER = "__edmc_callback_marker" +CALLBACK_MARKER = "__edmc_callback_marker__" PLUGIN_MARKER = "__edmc_plugin_marker__" @@ -26,15 +26,20 @@ def edmc_plugin(cls: Type[Plugin]) -> Type[Plugin]: logger.trace(f"Successfully marked class {cls!r} as EDMC plugin") return cls +# Varidic generics are _not_ currently supported, see https://github.com/python/typing/issues/193 -def hook(name: str) -> Callable: + +_F = TypeVar('_F', bound=Callable[..., Any]) + + +def hook(name: str) -> Callable[[_F], _F]: """ Create event callback. :param name: The event to hook onto :return: (Internal python decoration implementation) """ - def decorate(func: Callable) -> Callable: + def decorate(func: _F) -> _F: """ Decorate a function. diff --git a/plugin/plugin.py b/plugin/plugin.py index 9c5c8849bf..ba3fdebe07 100644 --- a/plugin/plugin.py +++ b/plugin/plugin.py @@ -46,6 +46,9 @@ def unload(self) -> None: """Unload this plugin.""" ... + def reload(self) -> None: + """Reload this plugin.""" + def show_error(self): # TODO: replacement of plug.show_error ... @@ -127,19 +130,6 @@ def __init__(self, logger: LoggerMixin, module: ModuleType, manager: PluginManag # We have a start3, lets see what else we have and get ready to prepare hooks for them self.setup_callbacks() - # for new_hook, old_callback in LEGACY_CALLBACK_LUT.items(): - # callback: Optional[Callable] = getattr(self.module, old_callback, None) - # if callback is None: - # continue - - # # Dynamically adding methods is done with types.MethodType(function ...) (see docs) - # # this is required for access to self, which likely wont be needed here but it also may. Something - # # to keep in mind - # target_name = f"_SYNTHETIC_CALLBACK_{old_callback}" - # setattr(self, target_name, decorators.hook(new_hook)(callback)) - # self.log.trace( - # f"Successfully created fake callback wrapper {target_name} for old callback {old_callback} ({callback})" - # ) def setup_callbacks(self) -> None: # TODO: Update arch with how this works @@ -203,6 +193,14 @@ def enforce_load3_signature(load3: Callable): @staticmethod def generic_callback_handler(f: Callable, breakout: Callable[..., 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)) @@ -221,3 +219,7 @@ def wrapper(e: event.JournalEvent) -> None: f(e.commander, e.is_beta, e.system, e.station, e.data, e.state) return wrapper + + def unload(self) -> None: + """Legacy plugins do not support unloading.""" + raise NotImplementedError('Legacy plugins do not support unloading') diff --git a/plugin/test/test_load.py b/plugin/test/test_load.py index ecff9b5836..66da9526b4 100644 --- a/plugin/test/test_load.py +++ b/plugin/test/test_load.py @@ -102,6 +102,11 @@ def test_double_load(plugin_manager: PluginManager) -> None: plugin_manager.load_plugin(bad_path / 'double_load') +def test_hooks_created(plugin_manager: PluginManager) -> None: + plugin_manager.load_plugin(good_path / ) + ... + + def test_unload_call(plugin_manager: PluginManager): """Load and unload a single plugin.""" target = good_path / "simple" 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..bb4968f4b8 --- /dev/null +++ b/plugin/test/test_plugins/good/simple_with_callback/__init__.py @@ -0,0 +1,25 @@ +"""Test plugin.""" +from plugin import event +import semantic_version + +from plugin.decorators import edmc_plugin, hook +from plugin.plugin import Plugin +from plugin.plugin_info import PluginInfo + + +@edmc_plugin +class GoodCallbackPlugin(Plugin): + """Plugin that loads correctly.""" + + def load(self) -> PluginInfo: + """Nothing Special.""" + 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.BaseEvent): + """Fake callback.""" + ... From 46cf510e72660386a57852336184f696d32c315e Mon Sep 17 00:00:00 2001 From: A_D Date: Mon, 19 Apr 2021 01:27:18 +0200 Subject: [PATCH 039/152] Fixed plugins not loading callbacks Apparently methods don't appear in instance dicts. Don't ask me why or how. This correctly resolves them by name by asking getattr. Yes this breaks if you do recursive things, no I'm not going to fix it. Fix your plugin. --- plugin/plugin.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/plugin/plugin.py b/plugin/plugin.py index ba3fdebe07..a8a29a82d4 100644 --- a/plugin/plugin.py +++ b/plugin/plugin.py @@ -5,7 +5,7 @@ import inspect import pathlib from collections import defaultdict -from typing import Any, TYPE_CHECKING, Callable, Dict, List, Optional, Tuple, Union, cast +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple import semantic_version @@ -16,9 +16,8 @@ from types import ModuleType from plugin.manager import PluginManager -from plugin import decorators +from plugin import decorators, event from plugin.plugin_info import PluginInfo -from plugin import event class Plugin(abc.ABC): @@ -56,7 +55,9 @@ def show_error(self): def _find_callbacks(self) -> Dict[str, List[Callable]]: out: Dict[str, List[Callable]] = defaultdict(list) - for field in self.__dict__.values(): + 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, decorators.CALLBACK_MARKER, None) if callbacks is None: continue From c8cc1c63f44e57c22f5e659c60e3022fb2bcddbc Mon Sep 17 00:00:00 2001 From: A_D Date: Mon, 19 Apr 2021 01:33:07 +0200 Subject: [PATCH 040/152] Added test to check hooks are found --- plugin/test/test_load.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/plugin/test/test_load.py b/plugin/test/test_load.py index 66da9526b4..a45cd79693 100644 --- a/plugin/test/test_load.py +++ b/plugin/test/test_load.py @@ -103,8 +103,12 @@ def test_double_load(plugin_manager: PluginManager) -> None: def test_hooks_created(plugin_manager: PluginManager) -> None: - plugin_manager.load_plugin(good_path / ) - ... + """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_unload_call(plugin_manager: PluginManager): From c89946fca73fbba7c822a1ab69354907189f27b4 Mon Sep 17 00:00:00 2001 From: A_D Date: Tue, 20 Apr 2021 03:20:45 +0200 Subject: [PATCH 041/152] Added util method to load multiple plugin paths --- plugin/manager.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/plugin/manager.py b/plugin/manager.py index a5103e6eb6..0e44c9b5cc 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -176,6 +176,31 @@ def __get_plugin_at(self, path: pathlib.Path, autoresolve_sys_path=True) -> PLUG return plugin, module + 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) + + 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. From 100ef7c2489d8bcdc74ac4078e7d0cdbe43c0a83 Mon Sep 17 00:00:00 2001 From: A_D Date: Tue, 20 Apr 2021 03:21:02 +0200 Subject: [PATCH 042/152] Added event system --- plugin/manager.py | 51 +++++++++++++++++-- plugin/test/test_event.py | 33 ++++++++++++ plugin/test/test_load.py | 18 +++++-- .../good/simple_full_wildcard/__init__.py | 26 ++++++++++ .../good/simple_nonfull_wildcard/__init__.py | 26 ++++++++++ .../good/simple_with_callback/__init__.py | 7 +-- 6 files changed, 150 insertions(+), 11 deletions(-) create mode 100644 plugin/test/test_event.py create mode 100644 plugin/test/test_plugins/good/simple_full_wildcard/__init__.py create mode 100644 plugin/test/test_plugins/good/simple_nonfull_wildcard/__init__.py diff --git a/plugin/manager.py b/plugin/manager.py index 0e44c9b5cc..d2deb0394e 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -5,10 +5,14 @@ import importlib import pathlib import sys -from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Set, Tuple, Type +from fnmatch import fnmatch +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Sequence, Set, Tuple, Type + +from plugin.event import BaseEvent if TYPE_CHECKING: from types import ModuleType + from EDMCLogging import LoggerMixin from EDMCLogging import get_main_logger, get_plugin_logger from plugin import decorators @@ -36,6 +40,39 @@ def __str__(self) -> str: f' with {len(self.callbacks)} callbacks' ) + @property + def log(self) -> 'LoggerMixin': + """Get the plugin logger represented by this LoadedPlugin.""" + return self.plugin.log + + def _fire_event_funcs(self, event: BaseEvent, funcs: list[Callable]) -> list[Any]: + out = [] + for func in funcs: + try: + res = func(event) + if res is not None: + out.append(res) + + except Exception: + self.log.exception(f'Caught an exception while firing event {event.name!r} on func {func}') + + return out + + def fire_event(self, event: BaseEvent) -> list[Any]: + """ + Call all event callbacks that match the given event. + + :param event: the event to pass + """ + results = [] + for e, funcs in self.callbacks.items(): + if not (e == event.name or e == '*' or fnmatch(event.name, e)): + continue + + results.extend(self._fire_event_funcs(event, funcs)) + + return results + class PluginManager: """PluginManager is an event engine and plugin engine.""" @@ -213,6 +250,7 @@ def load_plugin(self, path: pathlib.Path, autoresolve_sys_path=True) -> Optional """ # 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 = self.__get_plugin_at(path, autoresolve_sys_path=autoresolve_sys_path) @@ -323,7 +361,14 @@ def unload_plugin(self, name: str): del self.plugins[name] - def fire_event(self, name: str, data): - ... + def fire_event(self, event: BaseEvent) -> list[list[Any]]: + """Call all callbacks listening for the given event.""" + out: list[list[Any]] = [] + for name, p in self.plugins.items(): + self.log.trace(f'Firing event {event.name} for plugin {name}') + res = p.fire_event(event) + out.append(res) + + return out # TODO: Register(System|station)Provider method, to allow it to be dynamic to plugins diff --git a/plugin/test/test_event.py b/plugin/test/test_event.py new file mode 100644 index 0000000000..c78ff75027 --- /dev/null +++ b/plugin/test/test_event.py @@ -0,0 +1,33 @@ +"""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 + + +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) diff --git a/plugin/test/test_load.py b/plugin/test/test_load.py index a45cd79693..b261825cee 100644 --- a/plugin/test/test_load.py +++ b/plugin/test/test_load.py @@ -1,17 +1,16 @@ """Testing suite for plugin loading system.""" import pathlib from contextlib import nullcontext -from plugin.plugin import LEGACY_CALLBACK_LUT from typing import Any, ContextManager, List, Tuple import pytest +from plugin.decorators import CALLBACK_MARKER from plugin.manager import ( PluginAlreadyLoadedException, PluginDoesNotExistException, PluginHasNoPluginClassException, PluginLoadingException, PluginManager ) - -from plugin.decorators import CALLBACK_MARKER +from plugin.plugin import LEGACY_CALLBACK_LUT from .conftest import bad_path, good_path, legacy_bad_path, legacy_good_path, legacy_path @@ -41,8 +40,11 @@ def _idfn(test_data) -> str: ), ] -TESTS = [ - (good_path / 'simple', nullcontext()), + +GOOD_TESTS: List[Tuple[pathlib.Path, ContextManager]] = [ + (path, nullcontext()) for path in good_path.iterdir() if path.is_dir()] + +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')), @@ -61,6 +63,12 @@ def _idfn(test_data) -> str: ] + 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: """ 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..dcaf7c933e --- /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.decorators import edmc_plugin, hook +from plugin.plugin import Plugin +from plugin.plugin_info import PluginInfo + + +@edmc_plugin +class GoodCallbackPlugin(Plugin): + """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..46e0b76153 --- /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.decorators import edmc_plugin, hook +from plugin.plugin import Plugin +from plugin.plugin_info import PluginInfo + + +@edmc_plugin +class GoodCallbackPlugin(Plugin): + """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 index bb4968f4b8..828b17ddb2 100644 --- a/plugin/test/test_plugins/good/simple_with_callback/__init__.py +++ b/plugin/test/test_plugins/good/simple_with_callback/__init__.py @@ -1,7 +1,7 @@ """Test plugin.""" -from plugin import event import semantic_version +from plugin import event from plugin.decorators import edmc_plugin, hook from plugin.plugin import Plugin from plugin.plugin_info import PluginInfo @@ -13,6 +13,7 @@ class GoodCallbackPlugin(Plugin): def load(self) -> PluginInfo: """Nothing Special.""" + self.called: list[event.BaseEvent] = [] return PluginInfo( name="good_callback", version=semantic_version.Version.coerce('0.0.1'), @@ -20,6 +21,6 @@ def load(self) -> PluginInfo: ) @hook('core.journal_event') - def on_journal(self, e: event.BaseEvent): + def on_journal(self, e: event.JournalEvent): """Fake callback.""" - ... + self.called.append(e) From d4d74b0d1f3ec5308f35d8ff16714c16c2400641 Mon Sep 17 00:00:00 2001 From: A_D Date: Tue, 20 Apr 2021 03:31:49 +0200 Subject: [PATCH 043/152] Tidied some imports --- plugin/test/conftest.py | 2 +- plugin/test/test_load.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugin/test/conftest.py b/plugin/test/conftest.py index e58128650d..7b8d61b3a7 100644 --- a/plugin/test/conftest.py +++ b/plugin/test/conftest.py @@ -4,7 +4,7 @@ from pytest import fixture -from plugin.manager import PluginManager # noqa: E402 # Has to be after the path fiddling +from plugin.manager import PluginManager @fixture diff --git a/plugin/test/test_load.py b/plugin/test/test_load.py index b261825cee..a6d020d288 100644 --- a/plugin/test/test_load.py +++ b/plugin/test/test_load.py @@ -6,10 +6,10 @@ import pytest from plugin.decorators import CALLBACK_MARKER -from plugin.manager import ( - PluginAlreadyLoadedException, PluginDoesNotExistException, PluginHasNoPluginClassException, PluginLoadingException, - PluginManager +from plugin.exceptions import ( + PluginAlreadyLoadedException, PluginDoesNotExistException, PluginHasNoPluginClassException, PluginLoadingException ) +from plugin.manager import PluginManager from plugin.plugin import LEGACY_CALLBACK_LUT from .conftest import bad_path, good_path, legacy_bad_path, legacy_good_path, legacy_path From fe1f743fac823c45327b4bd0684422881f12cd75 Mon Sep 17 00:00:00 2001 From: A_D Date: Tue, 20 Apr 2021 03:32:56 +0200 Subject: [PATCH 044/152] Made exception add some nice wrapping wording --- plugin/exceptions.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/plugin/exceptions.py b/plugin/exceptions.py index b4ee50fe53..139b097029 100644 --- a/plugin/exceptions.py +++ b/plugin/exceptions.py @@ -1,3 +1,6 @@ +"""Exceptions for plugin loading.""" + + class PluginLoadingException(Exception): """Plugin load failed.""" @@ -13,6 +16,14 @@ class PluginHasNoPluginClassException(PluginLoadingException): 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.""" From 2b61bab49fda7b8a6e99ef3aaf044355d4a4b166 Mon Sep 17 00:00:00 2001 From: A_D Date: Tue, 20 Apr 2021 06:55:14 +0200 Subject: [PATCH 045/152] Added targeted event firing --- plugin/manager.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/plugin/manager.py b/plugin/manager.py index d2deb0394e..51823719ac 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -6,7 +6,7 @@ import pathlib import sys from fnmatch import fnmatch -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Sequence, Set, Tuple, Type +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Sequence, Set, Tuple, Type, Union from plugin.event import BaseEvent @@ -188,7 +188,6 @@ def __get_plugin_at(self, path: pathlib.Path, autoresolve_sys_path=True) -> PLUG except PluginLoadingException as e: self.log.exception(f'Unable to load plugin at {path}: {e}') raise - return None except Exception as e: self.log.exception(f'Exception occurred during loading plugin at {path}: {e} THIS IS A BUG!') @@ -363,6 +362,7 @@ def unload_plugin(self, name: str): def fire_event(self, event: BaseEvent) -> list[list[Any]]: """Call all callbacks listening for the given event.""" + # TODO: rather a dict[plugin_name, list[any]] ? out: list[list[Any]] = [] for name, p in self.plugins.items(): self.log.trace(f'Firing event {event.name} for plugin {name}') @@ -371,4 +371,16 @@ def fire_event(self, event: BaseEvent) -> list[list[Any]]: return out + 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) + # TODO: Register(System|station)Provider method, to allow it to be dynamic to plugins From cfae1fa0345fa88ab728caab45b6db29f12ac443 Mon Sep 17 00:00:00 2001 From: A_D Date: Tue, 20 Apr 2021 06:57:13 +0200 Subject: [PATCH 046/152] Made list based decorators more generic --- plugin/decorators.py | 58 ++++++++++++++++++++++++++------------------ 1 file changed, 34 insertions(+), 24 deletions(-) diff --git a/plugin/decorators.py b/plugin/decorators.py index 7f3a78c328..bb31ce769f 100644 --- a/plugin/decorators.py +++ b/plugin/decorators.py @@ -1,7 +1,7 @@ """New plugin system.""" -from typing import Any, Callable, List, Type, TypeVar +from typing import Any, Callable, Type, TypeVar from EDMCLogging import get_main_logger from plugin.plugin import Plugin @@ -32,38 +32,48 @@ def edmc_plugin(cls: Type[Plugin]) -> Type[Plugin]: _F = TypeVar('_F', bound=Callable[..., Any]) -def hook(name: str) -> Callable[[_F], _F]: +def _list_decorate(attr_name: str, attr_content: str, func: _F) -> _F: + logger.debug(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 + + +def hook(name: str) -> Callable[['_F'], _F]: """ Create event callback. :param name: The event to hook onto :return: (Internal python decoration implementation) """ - def decorate(func: _F) -> _F: - """ - Decorate a function. + # return functools.partial(_list_decorate, attr_name=CALLBACK_MARKER, attr_content=name) - The outer function is used to provide name to us at the decorate site - """ - logger.debug(f"Found function {func!r} marked as {name!r} callback") - # If this hook is already being used as a callback, just add the given name, otherwise, set it - if hasattr(func, CALLBACK_MARKER): - current: List[str] = getattr(func, CALLBACK_MARKER) - logger.trace(f"func {func!r} already marked as callback for others: {current}") + def _decorate(func: _F) -> _F: + res = _list_decorate(CALLBACK_MARKER, name, func) + return res - if not isinstance(current, list): - raise ValueError(f"Hook function has marker with unexpected content. THIS IS A BUG: {current!r}") + return _decorate - if name in current: - raise ValueError(f"Hook function hooked onto {name!r} multiple times") - current.append(name) - setattr(func, CALLBACK_MARKER, current) - - else: - setattr(func, CALLBACK_MARKER, [name]) +def provider(name: str) -> Callable[[_F], _F]: + """ + Create a provider callback. - logger.trace(f"successfully marked callback {func!r} as a callback for event {name!r}") - return func + :param name: The provider ID that this provider provides data to + :return: (Internal python decoration implementation) + """ + def _decorate(func: _F) -> _F: + raise NotImplementedError - return decorate + return _decorate From 771473c74d82a02fcf84b4ae73a437b3420011eb Mon Sep 17 00:00:00 2001 From: A_D Date: Tue, 20 Apr 2021 06:58:13 +0200 Subject: [PATCH 047/152] fixed finding pycache for tests --- plugin/test/test_load.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/plugin/test/test_load.py b/plugin/test/test_load.py index a6d020d288..637daf12e9 100644 --- a/plugin/test/test_load.py +++ b/plugin/test/test_load.py @@ -42,7 +42,7 @@ def _idfn(test_data) -> str: GOOD_TESTS: List[Tuple[pathlib.Path, ContextManager]] = [ - (path, nullcontext()) for path in good_path.iterdir() if path.is_dir()] + (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)), @@ -58,8 +58,6 @@ def _idfn(test_data) -> str: ) ), - # Legacy plugins - ] + LEGACY_TESTS @@ -119,6 +117,18 @@ def test_hooks_created(plugin_manager: PluginManager) -> None: 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" From 7492bbda71fdd9d55d986d635f0bd151f9e65d10 Mon Sep 17 00:00:00 2001 From: A_D Date: Tue, 20 Apr 2021 06:59:03 +0200 Subject: [PATCH 048/152] added test for method with multiple callbacks --- plugin/test/test_event.py | 17 +++++++++++ .../good/multi_callback/__init__.py | 30 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 plugin/test/test_plugins/good/multi_callback/__init__.py diff --git a/plugin/test/test_event.py b/plugin/test/test_event.py index c78ff75027..df71353231 100644 --- a/plugin/test/test_event.py +++ b/plugin/test/test_event.py @@ -31,3 +31,20 @@ def test_catchall_event(plugin_manager: PluginManager) -> None: # 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_plugins/good/multi_callback/__init__.py b/plugin/test/test_plugins/good/multi_callback/__init__.py new file mode 100644 index 0000000000..2766023cac --- /dev/null +++ b/plugin/test/test_plugins/good/multi_callback/__init__.py @@ -0,0 +1,30 @@ +"""Test plugin that loads correctly.""" +import semantic_version + +from plugin.decorators import edmc_plugin, hook +from plugin.plugin import Plugin +from plugin.event import BaseEvent +from plugin.plugin_info import PluginInfo + + +@edmc_plugin +class GoodPlugin(Plugin): + """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: BaseEvent): + """Multiple hooks on one method.""" + self.called.append(e) + + print(id(multiple_things)) From 73dfc223a8b732cd4424caf6c2ab74efa0a2a918 Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 22 Apr 2021 01:45:15 +0200 Subject: [PATCH 049/152] Updated arch file --- plugin/ARCHITECTURE.md | 26 ++++++++++++++++++++++++-- plugin/plugin.py | 30 ++++++++---------------------- 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/plugin/ARCHITECTURE.md b/plugin/ARCHITECTURE.md index e39409cadc..24dcca4a5d 100644 --- a/plugin/ARCHITECTURE.md +++ b/plugin/ARCHITECTURE.md @@ -24,11 +24,14 @@ 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 @@ -54,9 +57,20 @@ use in its `PluginInfo`. Said information is extracted if possible from the modu `__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 +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. + ### Post instantiation of class After a plugin class is instantiated, two things happen: @@ -73,7 +87,15 @@ changed, but was made to allow for assumptions that may or may not be made in im ## Event Engine Events are identified by a namespace, and are hooked using the decorator `@hook("namespace.event_name")`. -You can hook onto all events in a given namespace using `@hook("namespace")`, and all events fired with the special 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 `*`. Some `core` events are special, and will work directly with your plugin rather than being global, eg $plugin_prefs_changed_here + +## TODO + +- Further tests for unloading that work with unload callbacks, and a test to ensure legacy plugins explode correctly + when unloaded +- Integrate into EDMC + - Replacement for legacy functions that are deprecationwarning-ed to hell diff --git a/plugin/plugin.py b/plugin/plugin.py index a8a29a82d4..e0c588430e 100644 --- a/plugin/plugin.py +++ b/plugin/plugin.py @@ -52,13 +52,13 @@ def show_error(self): # TODO: replacement of plug.show_error ... - def _find_callbacks(self) -> Dict[str, List[Callable]]: + 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, decorators.CALLBACK_MARKER, None) + callbacks: Optional[List[str]] = getattr(field, marker, None) if callbacks is None: continue @@ -86,20 +86,15 @@ def __str__(self) -> str: } -def journal_entry_breakout(e: event.JournalEvent) -> Tuple[str, bool, Optional[str], Optional[str], Dict, Dict]: - return (e.commander, e.is_beta, e.system, e.station, e.data, e.state) - - LEGACY_CALLBACK_BREAKOUT_LUT: Dict[str, Callable[..., Tuple[Any, ...]]] = { # All of these callables should accept an event.BaseEvent or a subclass thereof # 'core.setup_ui': 'plugin_app', # 'core.setup_preferences_ui': 'plugin_prefs', # 'core.preferences_closed': 'prefs_changed', - 'core.journal_entry': journal_entry_breakout, + 'core.journal_entry': lambda e: (e.commander, e.is_beta, e.system, e.station, e.data, e.state), # 'core.dashboard_entry': 'dashboard_entry', # 'core.commander_data': 'cmdr_data', - # 'inara.notify_ship': 'inara_notify_ship', # 'inara.notify_location': 'inara_notify_location', # 'edsm.notify_system': 'edsm_notify_system', @@ -133,7 +128,11 @@ def __init__(self, logger: LoggerMixin, module: ModuleType, manager: PluginManag self.setup_callbacks() def setup_callbacks(self) -> None: - # TODO: Update arch with how this works + """ + 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: @@ -208,19 +207,6 @@ def wrapper(e: event.BaseEvent): setattr(wrapper, "original_func", f) return wrapper - @staticmethod - def journal_callback(f: MigratedPlugin.JOURNAL_EVENT_SIG) -> Callable[[event.JournalEvent], None]: - """ - Wrapper around legacy journal_event calls. - - :param f: Legacy journal_event function - :return: Wrapped callback to the legacy journal_event - """ - def wrapper(e: event.JournalEvent) -> None: - f(e.commander, e.is_beta, e.system, e.station, e.data, e.state) - - return wrapper - def unload(self) -> None: """Legacy plugins do not support unloading.""" raise NotImplementedError('Legacy plugins do not support unloading') From c4bfc7d7650d97d88f8d42981b98d092232abde2 Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 22 Apr 2021 01:48:07 +0200 Subject: [PATCH 050/152] added repr and docstrings for it and str --- plugin/manager.py | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/plugin/manager.py b/plugin/manager.py index 51823719ac..8cf15e62f4 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -25,21 +25,33 @@ PLUGIN_MODULE_PAIR = Tuple[Optional[Plugin], Optional['ModuleType']] -@dataclasses.dataclass class LoadedPlugin: """LoadedPlugin represents a single plugin, its module, and callbacks.""" - info: PluginInfo - plugin: Plugin - module: ModuleType - callbacks: Dict[str, List[Callable]] + def __init__(self, info: PluginInfo, plugin: Plugin, module: ModuleType) -> None: + self.info: PluginInfo = info + self.plugin: Plugin = 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.""" @@ -73,6 +85,10 @@ def fire_event(self, event: BaseEvent) -> list[Any]: 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.""" @@ -280,7 +296,7 @@ def load_plugin(self, path: pathlib.Path, autoresolve_sys_path=True) -> Optional if info.name in self.plugins: raise PluginAlreadyLoadedException(info.name) - loaded = LoadedPlugin(info, plugin, module, plugin._find_callbacks()) + loaded = LoadedPlugin(info, plugin, module) self.plugins[info.name] = loaded self.log.trace(f'successfully loaded {loaded}') From ea61ca1d9fe23d4dfa8463f66a48777c6bcbed87 Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 22 Apr 2021 01:48:47 +0200 Subject: [PATCH 051/152] added start of provider test --- .../good/provides_something/__init__.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 plugin/test/test_plugins/good/provides_something/__init__.py 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..5be4582ac9 --- /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.decorators import edmc_plugin, provider +from plugin.plugin import Plugin +from plugin.plugin_info import PluginInfo + + +@edmc_plugin +class GoodPlugin(Plugin): + """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" From c42eb48925e0e3ba73a31b7388d3e1dfb3fac1d8 Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 22 Apr 2021 01:49:05 +0200 Subject: [PATCH 052/152] Removed import --- plugin/manager.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugin/manager.py b/plugin/manager.py index 8cf15e62f4..0433a97e3f 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -1,7 +1,6 @@ """Main plugin engine.""" from __future__ import annotations -import dataclasses import importlib import pathlib import sys From 9e5d43fe12931fe96ee9f3419a340d164c684509 Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 22 Apr 2021 01:49:32 +0200 Subject: [PATCH 053/152] added provider decorator --- plugin/decorators.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/plugin/decorators.py b/plugin/decorators.py index bb31ce769f..b5252fed72 100644 --- a/plugin/decorators.py +++ b/plugin/decorators.py @@ -10,6 +10,7 @@ CALLBACK_MARKER = "__edmc_callback_marker__" PLUGIN_MARKER = "__edmc_plugin_marker__" +PROVIDER_MARKER = "__edmc_provider_marker__" def edmc_plugin(cls: Type[Plugin]) -> Type[Plugin]: @@ -57,11 +58,8 @@ def hook(name: str) -> Callable[['_F'], _F]: :param name: The event to hook onto :return: (Internal python decoration implementation) """ - # return functools.partial(_list_decorate, attr_name=CALLBACK_MARKER, attr_content=name) - def _decorate(func: _F) -> _F: - res = _list_decorate(CALLBACK_MARKER, name, func) - return res + return _list_decorate(CALLBACK_MARKER, name, func) return _decorate @@ -74,6 +72,6 @@ def provider(name: str) -> Callable[[_F], _F]: :return: (Internal python decoration implementation) """ def _decorate(func: _F) -> _F: - raise NotImplementedError + return _list_decorate(PROVIDER_MARKER, name, func) return _decorate From a26212eebbb6085303d230ee56f073d280be44bd Mon Sep 17 00:00:00 2001 From: A_D Date: Sat, 24 Apr 2021 10:40:47 +0200 Subject: [PATCH 054/152] Reorganised files, passed flake8 The various plugin classes have been split up into their own file for ease of testing and organisation. --- plugin/ARCHITECTURE.md | 1 + plugin/__init__.py | 1 + plugin/base_plugin.py | 65 ++++++ plugin/decorators.py | 10 +- plugin/event.py | 2 +- plugin/legacy_plugin.py | 159 +++++++++++++ plugin/manager.py | 18 +- plugin/plugin.py | 217 +----------------- plugin/test/__init__.py | 1 + plugin/test/test_load.py | 10 +- .../bad/class_init_error/__init__.py | 6 +- .../bad/class_load_error/__init__.py | 4 +- .../test_plugins/bad/double_load/__init__.py | 4 +- .../bad/null_plugin_info/__init__.py | 5 +- .../bad/str_plugin_info/__init__.py | 5 +- .../bad/unload_exception/__init__.py | 5 +- .../bad/unload_shutdown/__init__.py | 6 +- .../good/multi_callback/__init__.py | 4 +- .../good/provides_something/__init__.py | 4 +- .../test/test_plugins/good/simple/__init__.py | 4 +- .../good/simple_full_wildcard/__init__.py | 4 +- .../good/simple_nonfull_wildcard/__init__.py | 4 +- .../good/simple_with_callback/__init__.py | 4 +- 23 files changed, 290 insertions(+), 253 deletions(-) create mode 100644 plugin/base_plugin.py create mode 100644 plugin/legacy_plugin.py diff --git a/plugin/ARCHITECTURE.md b/plugin/ARCHITECTURE.md index 24dcca4a5d..d372c6aae0 100644 --- a/plugin/ARCHITECTURE.md +++ b/plugin/ARCHITECTURE.md @@ -95,6 +95,7 @@ being global, eg $plugin_prefs_changed_here ## TODO +- 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 - Integrate into EDMC diff --git a/plugin/__init__.py b/plugin/__init__.py index e69de29bb2..20cfb8b4eb 100644 --- a/plugin/__init__.py +++ 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..27fd1af4a9 --- /dev/null +++ b/plugin/base_plugin.py @@ -0,0 +1,65 @@ +""" +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.""" + ... + + def reload(self) -> None: + """Reload this plugin.""" + + 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} ' diff --git a/plugin/decorators.py b/plugin/decorators.py index b5252fed72..9c6f214686 100644 --- a/plugin/decorators.py +++ b/plugin/decorators.py @@ -1,10 +1,10 @@ -"""New plugin system.""" +"""Decorators for marking plugins and callbacks.""" from typing import Any, Callable, Type, TypeVar from EDMCLogging import get_main_logger -from plugin.plugin import Plugin +from plugin.base_plugin import BasePlugin logger = get_main_logger() @@ -13,11 +13,11 @@ PROVIDER_MARKER = "__edmc_provider_marker__" -def edmc_plugin(cls: Type[Plugin]) -> Type[Plugin]: +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, Plugin): + if not issubclass(cls, BasePlugin): raise ValueError(f"Cannot decorate non-subclass of Plugin {cls!r} as EDMC Plugin") if hasattr(cls, PLUGIN_MARKER): @@ -27,7 +27,7 @@ def edmc_plugin(cls: Type[Plugin]) -> Type[Plugin]: logger.trace(f"Successfully marked class {cls!r} as EDMC plugin") return cls -# Varidic generics are _not_ currently supported, see https://github.com/python/typing/issues/193 +# Variadic generics are _not_ currently supported, see https://github.com/python/typing/issues/193 _F = TypeVar('_F', bound=Callable[..., Any]) diff --git a/plugin/event.py b/plugin/event.py index 05ae6ef874..6d931f533d 100644 --- a/plugin/event.py +++ b/plugin/event.py @@ -1,4 +1,4 @@ - +"""Events for use with manager.pys event system.""" import time from typing import Any, Dict, Optional diff --git a/plugin/legacy_plugin.py b/plugin/legacy_plugin.py new file mode 100644 index 0000000000..2e068aec10 --- /dev/null +++ b/plugin/legacy_plugin.py @@ -0,0 +1,159 @@ +"""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 + +import semantic_version + +from plugin import decorators, event +from plugin.base_plugin import BasePlugin +from plugin.exceptions import LegacyPluginHasNoStart3, LegacyPluginNeedsMigrating +from plugin.plugin_info import PluginInfo + +if TYPE_CHECKING: + from EDMCLogging import LoggerMixin + from plugin.manager import PluginManager + +LEGACY_CALLBACK_LUT: Dict[str, str] = { + 'core.setup_ui': 'plugin_app', + 'core.setup_preferences_ui': 'plugin_prefs', + 'core.preferences_closed': 'prefs_changed', + 'core.journal_entry': 'journal_entry', + 'core.dashboard_entry': 'dashboard_entry', + 'core.commander_data': 'cmdr_data', + + + '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[..., Tuple[Any, ...]]] = { + # All of these callables should accept an event.BaseEvent or a subclass thereof + # 'core.setup_ui': 'plugin_app', + # 'core.setup_preferences_ui': 'plugin_prefs', + # 'core.preferences_closed': 'prefs_changed', + 'core.journal_entry': lambda e: (e.commander, e.is_beta, e.system, e.station, e.data, e.state), + # 'core.dashboard_entry': 'dashboard_entry', + # 'core.commander_data': 'cmdr_data', + + # 'inara.notify_ship': 'inara_notify_ship', + # 'inara.notify_location': 'inara_notify_location', + # 'edsm.notify_system': 'edsm_notify_system', +} + + +class MigratedPlugin(BasePlugin): + """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() + + 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: ()) + + wrapped = self.generic_callback_handler(callback, breakout) + setattr(self, target_name, decorators.hook(new_hook)(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}' + ) + + @staticmethod + def generic_callback_handler(f: Callable, breakout: Callable[..., 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)) + + setattr(wrapper, "original_func", f) + return wrapper + + 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 index 0433a97e3f..336dc88ea3 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -7,29 +7,29 @@ from fnmatch import fnmatch from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Sequence, Set, Tuple, Type, Union -from plugin.event import BaseEvent - 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 ( PluginAlreadyLoadedException, PluginDoesNotExistException, PluginHasNoPluginClassException, PluginLoadingException ) -from plugin.plugin import MigratedPlugin, Plugin +from plugin.legacy_plugin import MigratedPlugin from plugin.plugin_info import PluginInfo -PLUGIN_MODULE_PAIR = Tuple[Optional[Plugin], Optional['ModuleType']] +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: Plugin, module: ModuleType) -> None: + def __init__(self, info: PluginInfo, plugin: BasePlugin, module: ModuleType) -> None: self.info: PluginInfo = info - self.plugin: Plugin = plugin + 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] = {} @@ -154,7 +154,7 @@ def load_normal_plugin(self, path: pathlib.Path, autoresolve_sys_path=True) -> P self.log.error(f"Unable to load module {path}") raise PluginLoadingException(f"Exception occurred while loading: {e}") from e - uninstantiated: Optional[Type[Plugin]] = None + 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 @@ -171,7 +171,7 @@ def load_normal_plugin(self, path: pathlib.Path, autoresolve_sys_path=True) -> P raise PluginHasNoPluginClassException plugin_logger = get_plugin_logger(path.parts[-1]) - instance: Optional[Plugin] = None + instance: Optional[BasePlugin] = None try: instance = uninstantiated(plugin_logger, self, path) @@ -189,7 +189,7 @@ def __get_plugin_at(self, path: pathlib.Path, autoresolve_sys_path=True) -> PLUG if not path.exists() or (not init.exists() and not load.exists()): raise PluginDoesNotExistException - plugin: Optional[Plugin] = None + plugin: Optional[BasePlugin] = None module: Optional[ModuleType] = None if init.exists(): diff --git a/plugin/plugin.py b/plugin/plugin.py index e0c588430e..5eceef1c08 100644 --- a/plugin/plugin.py +++ b/plugin/plugin.py @@ -1,212 +1,15 @@ -"""Base plugin class.""" -from __future__ import annotations - -import abc -import inspect -import pathlib -from collections import defaultdict -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple - -import semantic_version - -from plugin.exceptions import LegacyPluginHasNoStart3, LegacyPluginNeedsMigrating - -if TYPE_CHECKING: - from EDMCLogging import LoggerMixin - from types import ModuleType - from plugin.manager import PluginManager - -from plugin import decorators, event -from plugin.plugin_info import PluginInfo - - -class Plugin(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.""" - ... - - def reload(self) -> None: - """Reload this plugin.""" - - def show_error(self): - # TODO: replacement of plug.show_error - ... - - 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 f'Plugin at {self.path} on {self._manager} ' - - -LEGACY_CALLBACK_LUT: Dict[str, str] = { - 'core.setup_ui': 'plugin_app', - 'core.setup_preferences_ui': 'plugin_prefs', - 'core.preferences_closed': 'prefs_changed', - 'core.journal_entry': 'journal_entry', - 'core.dashboard_entry': 'dashboard_entry', - 'core.commander_data': 'cmdr_data', - - - '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[..., Tuple[Any, ...]]] = { - # All of these callables should accept an event.BaseEvent or a subclass thereof - # 'core.setup_ui': 'plugin_app', - # 'core.setup_preferences_ui': 'plugin_prefs', - # 'core.preferences_closed': 'prefs_changed', - 'core.journal_entry': lambda e: (e.commander, e.is_beta, e.system, e.station, e.data, e.state), - # 'core.dashboard_entry': 'dashboard_entry', - # 'core.commander_data': 'cmdr_data', +""" +EDMC specific plugin implementations. - # 'inara.notify_ship': 'inara_notify_ship', - # 'inara.notify_location': 'inara_notify_location', - # 'edsm.notify_system': 'edsm_notify_system', -} +See _plugin.py for base plugin implementation. +_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 -class MigratedPlugin(Plugin): - """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() - - 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: ()) - - wrapped = self.generic_callback_handler(callback, breakout) - setattr(self, target_name, decorators.hook(new_hook)(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.coerce(version_str) - - else: - version = semantic_version.Version.coerce('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}' - ) - - @staticmethod - def generic_callback_handler(f: Callable, breakout: Callable[..., 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)) +from plugin.base_plugin import BasePlugin - setattr(wrapper, "original_func", f) - return wrapper - def unload(self) -> None: - """Legacy plugins do not support unloading.""" - raise NotImplementedError('Legacy plugins do not support unloading') +class EDMCPlugin(BasePlugin): + """Elite Dangerous Market Connector plugin base.""" diff --git a/plugin/test/__init__.py b/plugin/test/__init__.py index e69de29bb2..8a5dea49be 100644 --- a/plugin/test/__init__.py +++ b/plugin/test/__init__.py @@ -0,0 +1 @@ +"""Test the plugin system.""" diff --git a/plugin/test/test_load.py b/plugin/test/test_load.py index 637daf12e9..d8c83982c7 100644 --- a/plugin/test/test_load.py +++ b/plugin/test/test_load.py @@ -1,7 +1,9 @@ """Testing suite for plugin loading system.""" +from __future__ import annotations + import pathlib from contextlib import nullcontext -from typing import Any, ContextManager, List, Tuple +from typing import TYPE_CHECKING, Any, ContextManager, List, Tuple import pytest @@ -9,11 +11,13 @@ from plugin.exceptions import ( PluginAlreadyLoadedException, PluginDoesNotExistException, PluginHasNoPluginClassException, PluginLoadingException ) -from plugin.manager import PluginManager -from plugin.plugin import LEGACY_CALLBACK_LUT +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 + def _idfn(test_data) -> str: if not isinstance(test_data, pathlib.Path): diff --git a/plugin/test/test_plugins/bad/class_init_error/__init__.py b/plugin/test/test_plugins/bad/class_init_error/__init__.py index 3947f5bd69..dbdfdecdfa 100644 --- a/plugin/test/test_plugins/bad/class_init_error/__init__.py +++ b/plugin/test/test_plugins/bad/class_init_error/__init__.py @@ -1,12 +1,12 @@ """Plugin that errors on __init__().""" +from plugin.base_plugin import BasePlugin from plugin.decorators import edmc_plugin -from plugin.plugin import Plugin from plugin.plugin_info import PluginInfo @edmc_plugin -class Broken(Plugin): +class Broken(BasePlugin): """Test plugin.""" def __init__(self, logger, manager, path) -> None: @@ -14,5 +14,5 @@ def __init__(self, logger, manager, path) -> None: raise Exception('Exception in init') def load(self) -> PluginInfo: - """Required.""" + """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 index a34515ea79..cabf0fa3b7 100644 --- a/plugin/test/test_plugins/bad/class_load_error/__init__.py +++ b/plugin/test/test_plugins/bad/class_load_error/__init__.py @@ -1,12 +1,12 @@ """Plugin that errors on load().""" +from plugin.base_plugin import BasePlugin from plugin.decorators import edmc_plugin -from plugin.plugin import Plugin from plugin.plugin_info import PluginInfo @edmc_plugin -class Broken(Plugin): +class Broken(BasePlugin): """Test Plugin.""" def load(self) -> PluginInfo: diff --git a/plugin/test/test_plugins/bad/double_load/__init__.py b/plugin/test/test_plugins/bad/double_load/__init__.py index 20da080027..02faa329ef 100644 --- a/plugin/test/test_plugins/bad/double_load/__init__.py +++ b/plugin/test/test_plugins/bad/double_load/__init__.py @@ -1,13 +1,13 @@ """Test Plugin.""" import semantic_version +from plugin.base_plugin import BasePlugin from plugin.decorators import edmc_plugin -from plugin.plugin import Plugin from plugin.plugin_info import PluginInfo @edmc_plugin -class Broken(Plugin): +class Broken(BasePlugin): """Valid (but not loadable twice) plugin.""" def load(self) -> PluginInfo: diff --git a/plugin/test/test_plugins/bad/null_plugin_info/__init__.py b/plugin/test/test_plugins/bad/null_plugin_info/__init__.py index d1f5301e32..bbca9448b0 100644 --- a/plugin/test/test_plugins/bad/null_plugin_info/__init__.py +++ b/plugin/test/test_plugins/bad/null_plugin_info/__init__.py @@ -1,10 +1,11 @@ """Test Plugin.""" +from plugin.base_plugin import BasePlugin from plugin.decorators import edmc_plugin -from plugin.plugin import Plugin, PluginInfo +from plugin.plugin_info import PluginInfo @edmc_plugin -class BadPlugInfo(Plugin): +class BadPlugInfo(BasePlugin): """Plugin that returns a bad PluginInfo object.""" def load(self) -> PluginInfo: diff --git a/plugin/test/test_plugins/bad/str_plugin_info/__init__.py b/plugin/test/test_plugins/bad/str_plugin_info/__init__.py index b032f11f96..a869a52973 100644 --- a/plugin/test/test_plugins/bad/str_plugin_info/__init__.py +++ b/plugin/test/test_plugins/bad/str_plugin_info/__init__.py @@ -1,10 +1,11 @@ """Test Plugin.""" +from plugin.base_plugin import BasePlugin from plugin.decorators import edmc_plugin -from plugin.plugin import Plugin, PluginInfo +from plugin.plugin_info import PluginInfo @edmc_plugin -class BadPlugInfo(Plugin): +class BadPlugInfo(BasePlugin): """Plugin that returns a bad PluginInfo object.""" def load(self) -> PluginInfo: diff --git a/plugin/test/test_plugins/bad/unload_exception/__init__.py b/plugin/test/test_plugins/bad/unload_exception/__init__.py index 0438aed609..b23c768f14 100644 --- a/plugin/test/test_plugins/bad/unload_exception/__init__.py +++ b/plugin/test/test_plugins/bad/unload_exception/__init__.py @@ -2,12 +2,13 @@ import semantic_version +from plugin.base_plugin import BasePlugin from plugin.decorators import edmc_plugin -from plugin.plugin import Plugin, PluginInfo +from plugin.plugin_info import PluginInfo @edmc_plugin -class UnloadException(Plugin): +class UnloadException(BasePlugin): """Throws an exception during unload.""" def load(self) -> PluginInfo: diff --git a/plugin/test/test_plugins/bad/unload_shutdown/__init__.py b/plugin/test/test_plugins/bad/unload_shutdown/__init__.py index 76ed4c7d02..3de474ab95 100644 --- a/plugin/test/test_plugins/bad/unload_shutdown/__init__.py +++ b/plugin/test/test_plugins/bad/unload_shutdown/__init__.py @@ -1,16 +1,16 @@ """Plugin that generates a SystemExit on unload.""" -import pathlib import sys import semantic_version +from plugin.base_plugin import BasePlugin from plugin.decorators import edmc_plugin -from plugin.plugin import Plugin, PluginInfo +from plugin.plugin_info import PluginInfo @edmc_plugin -class UnloadSystemExit(Plugin): +class UnloadSystemExit(BasePlugin): """Throws an exception during unload.""" def load(self) -> PluginInfo: diff --git a/plugin/test/test_plugins/good/multi_callback/__init__.py b/plugin/test/test_plugins/good/multi_callback/__init__.py index 2766023cac..843fb5cd1a 100644 --- a/plugin/test/test_plugins/good/multi_callback/__init__.py +++ b/plugin/test/test_plugins/good/multi_callback/__init__.py @@ -1,14 +1,14 @@ """Test plugin that loads correctly.""" import semantic_version +from plugin.base_plugin import BasePlugin from plugin.decorators import edmc_plugin, hook -from plugin.plugin import Plugin from plugin.event import BaseEvent from plugin.plugin_info import PluginInfo @edmc_plugin -class GoodPlugin(Plugin): +class GoodPlugin(BasePlugin): """Plugin that loads correctly.""" def load(self) -> PluginInfo: diff --git a/plugin/test/test_plugins/good/provides_something/__init__.py b/plugin/test/test_plugins/good/provides_something/__init__.py index 5be4582ac9..5958c8596c 100644 --- a/plugin/test/test_plugins/good/provides_something/__init__.py +++ b/plugin/test/test_plugins/good/provides_something/__init__.py @@ -1,13 +1,13 @@ """Test plugin that loads correctly.""" import semantic_version +from plugin.base_plugin import BasePlugin from plugin.decorators import edmc_plugin, provider -from plugin.plugin import Plugin from plugin.plugin_info import PluginInfo @edmc_plugin -class GoodPlugin(Plugin): +class GoodPlugin(BasePlugin): """Plugin that loads correctly.""" def load(self) -> PluginInfo: diff --git a/plugin/test/test_plugins/good/simple/__init__.py b/plugin/test/test_plugins/good/simple/__init__.py index 19b8adc32a..f0c134b758 100644 --- a/plugin/test/test_plugins/good/simple/__init__.py +++ b/plugin/test/test_plugins/good/simple/__init__.py @@ -1,13 +1,13 @@ """Test plugin that loads correctly.""" import semantic_version +from plugin.base_plugin import BasePlugin from plugin.decorators import edmc_plugin -from plugin.plugin import Plugin from plugin.plugin_info import PluginInfo @edmc_plugin -class GoodPlugin(Plugin): +class GoodPlugin(BasePlugin): """Plugin that loads correctly.""" def load(self) -> PluginInfo: diff --git a/plugin/test/test_plugins/good/simple_full_wildcard/__init__.py b/plugin/test/test_plugins/good/simple_full_wildcard/__init__.py index dcaf7c933e..0633353cee 100644 --- a/plugin/test/test_plugins/good/simple_full_wildcard/__init__.py +++ b/plugin/test/test_plugins/good/simple_full_wildcard/__init__.py @@ -2,13 +2,13 @@ import semantic_version from plugin import event +from plugin.base_plugin import BasePlugin from plugin.decorators import edmc_plugin, hook -from plugin.plugin import Plugin from plugin.plugin_info import PluginInfo @edmc_plugin -class GoodCallbackPlugin(Plugin): +class GoodCallbackPlugin(BasePlugin): """Plugin that loads correctly.""" def load(self) -> PluginInfo: diff --git a/plugin/test/test_plugins/good/simple_nonfull_wildcard/__init__.py b/plugin/test/test_plugins/good/simple_nonfull_wildcard/__init__.py index 46e0b76153..97b97cc637 100644 --- a/plugin/test/test_plugins/good/simple_nonfull_wildcard/__init__.py +++ b/plugin/test/test_plugins/good/simple_nonfull_wildcard/__init__.py @@ -2,13 +2,13 @@ import semantic_version from plugin import event +from plugin.base_plugin import BasePlugin from plugin.decorators import edmc_plugin, hook -from plugin.plugin import Plugin from plugin.plugin_info import PluginInfo @edmc_plugin -class GoodCallbackPlugin(Plugin): +class GoodCallbackPlugin(BasePlugin): """Plugin that loads correctly.""" def load(self) -> PluginInfo: diff --git a/plugin/test/test_plugins/good/simple_with_callback/__init__.py b/plugin/test/test_plugins/good/simple_with_callback/__init__.py index 828b17ddb2..e8d409692b 100644 --- a/plugin/test/test_plugins/good/simple_with_callback/__init__.py +++ b/plugin/test/test_plugins/good/simple_with_callback/__init__.py @@ -2,13 +2,13 @@ import semantic_version from plugin import event +from plugin.base_plugin import BasePlugin from plugin.decorators import edmc_plugin, hook -from plugin.plugin import Plugin from plugin.plugin_info import PluginInfo @edmc_plugin -class GoodCallbackPlugin(Plugin): +class GoodCallbackPlugin(BasePlugin): """Plugin that loads correctly.""" def load(self) -> PluginInfo: From 6a2fefd572c6b9bca15f21182ee59c1e6c10598c Mon Sep 17 00:00:00 2001 From: A_D Date: Fri, 30 Apr 2021 13:31:24 +0200 Subject: [PATCH 055/152] Started removing needed plugin imports --- plugin/plugin.py | 83 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 2 deletions(-) diff --git a/plugin/plugin.py b/plugin/plugin.py index 5eceef1c08..26a60e3b72 100644 --- a/plugin/plugin.py +++ b/plugin/plugin.py @@ -1,15 +1,94 @@ """ EDMC specific plugin implementations. -See _plugin.py for base plugin implementation. +See base_plugin.py for base plugin implementation. -_plugin.py and plugin.py are distinct to allow for simpler testing -- this +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, Optional, final + +import config +import constants +import l10n from plugin.base_plugin import BasePlugin +from theme import _Theme, theme + +if TYPE_CHECKING: + import semantic_version + + from EDMCLogging import LoggerMixin + from plugin.manager import PluginManager 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) + + @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_error(self, msg: str) -> None: + """ + Show an error on the UI and log it. + + :param msg: The message to show + """ + self.log.error(msg) + raise NotImplementedError + + # 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 From e34931b4c8deac70fc92381cdacd9c22e4b9dd78 Mon Sep 17 00:00:00 2001 From: A_D Date: Sun, 9 May 2021 16:42:45 +0200 Subject: [PATCH 056/152] Added ease of use getitem and get methods to DictDataEvent --- plugin/event.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/plugin/event.py b/plugin/event.py index 6d931f533d..dd243d9be3 100644 --- a/plugin/event.py +++ b/plugin/event.py @@ -31,7 +31,20 @@ def __init__(self, name: str, data: Any = None, event_time: float = None) -> Non self.data = data -class JournalEvent(BaseDataEvent): +class DictDataEvent(BaseDataEvent): + """Same as a data event, but promises data is a dict.""" + + def __init__(self, name: str, data: dict[Any, Any], event_time: float) -> None: + super().__init__(name, data=data, event_time=event_time) + self.data: dict[Any, Any] = data + + self.get = self.data.get + + def __getitem__(self, name: str) -> Any: + return self.data[name] + + +class JournalEvent(DictDataEvent): """Journal event.""" def __init__( @@ -39,10 +52,17 @@ def __init__( system: Optional[str], station: Optional[str], state: Dict[str, Any] ) -> None: - self.data: Dict[str, Any] # Override the definition in BaseDataEvent to be more specific + self.data: dict[str, Any] # Override the definition in BaseDataEvent to be more specific super().__init__(name, data=data, event_time=event_time) self.commander = cmdr self.is_beta = is_beta self.system = system self.station = station self.state = state + + self.get = self.data.get # Ease of use wrapper + + @property + def event_name(self) -> str: + """Get the event name for the current event.""" + return self.data['event'] From 6c806f54b4193343cc2edff5488dc99ea26684f1 Mon Sep 17 00:00:00 2001 From: A_D Date: Sun, 9 May 2021 16:43:12 +0200 Subject: [PATCH 057/152] Ensured callbacks are only ever called once for a given event --- plugin/manager.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/plugin/manager.py b/plugin/manager.py index 336dc88ea3..3c1677ff0b 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -75,12 +75,17 @@ def fire_event(self, event: BaseEvent) -> list[Any]: :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 - results.extend(self._fire_event_funcs(event, funcs)) + 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])) + called = called.union(funcs) return results From bf265d0750f435a0693322aa037ae2f21d1e452e Mon Sep 17 00:00:00 2001 From: A_D Date: Sun, 9 May 2021 16:43:39 +0200 Subject: [PATCH 058/152] Allowed string versions on PluginInfo --- plugin/plugin_info.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/plugin/plugin_info.py b/plugin/plugin_info.py index 6930bf9c50..e3e4fc1170 100644 --- a/plugin/plugin_info.py +++ b/plugin/plugin_info.py @@ -1,7 +1,7 @@ """Information on a given plugin.""" import dataclasses -from typing import List, Optional +from typing import List, Optional, Union import semantic_version @@ -11,9 +11,14 @@ class PluginInfo: """PluginInfo holds information about a loaded plugin.""" name: str - version: semantic_version.Version + 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): + """Post-init to convert a string self.version to a Version.""" + if isinstance(self.version, str): + self.version = semantic_version.Version.coerce(self.version) From 0f11113d2b79b1240c355eba8f7d418b9aa145dd Mon Sep 17 00:00:00 2001 From: A_D Date: Sun, 9 May 2021 16:44:00 +0200 Subject: [PATCH 059/152] Added access to killswitch to EDMCPlugin --- plugin/plugin.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugin/plugin.py b/plugin/plugin.py index 26a60e3b72..1d2feccbdc 100644 --- a/plugin/plugin.py +++ b/plugin/plugin.py @@ -13,6 +13,7 @@ import config import constants +import killswitch import l10n from plugin.base_plugin import BasePlugin from theme import _Theme, theme @@ -30,6 +31,8 @@ class EDMCPlugin(BasePlugin): 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: """ From c077257bad3bee7e5efc0c00bea878dc60a70594 Mon Sep 17 00:00:00 2001 From: A_D Date: Sun, 9 May 2021 16:44:24 +0200 Subject: [PATCH 060/152] Began description of core events --- plugin/ARCHITECTURE.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/plugin/ARCHITECTURE.md b/plugin/ARCHITECTURE.md index d372c6aae0..d5b55911fa 100644 --- a/plugin/ARCHITECTURE.md +++ b/plugin/ARCHITECTURE.md @@ -90,8 +90,18 @@ Events are identified by a namespace, and are hooked using the decorator `@hook( 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` | `(event.JournalEvent) -> None` | Event fired when a new journal event is seen | +| `cmdr_data` | `(event.BaseDataEvent) -> None` | Event fired when new data comes in from CAPI TODO: `capi_data`? | + Some `core` events are special, and will work directly with your plugin rather than -being global, eg $plugin_prefs_changed_here +being global, they are documented below + +| Event Name | Expected Signature | Description | +| :--------------- | :----------------------------------------- | ------------------ | +| `core.plugin_ui` | `(tkinter.Tk) -> Optional[tkinter.Widget]` | Sets up plugins UI | ## TODO From 68eb4cdd68a444374ab6c2ea8c8eb21788c164ac Mon Sep 17 00:00:00 2001 From: A_D Date: Mon, 10 May 2021 13:33:07 +0200 Subject: [PATCH 061/152] Rewrote coriolis and eddn to new plugin format --- plugins/new_plugins/coriolis.py | 51 ++++++++ plugins/new_plugins/eddb.py | 201 ++++++++++++++++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 plugins/new_plugins/coriolis.py create mode 100644 plugins/new_plugins/eddb.py diff --git a/plugins/new_plugins/coriolis.py b/plugins/new_plugins/coriolis.py new file mode 100644 index 0000000000..b132bba93c --- /dev/null +++ b/plugins/new_plugins/coriolis.py @@ -0,0 +1,51 @@ +"""Coriolis ship export.""" + +import base64 +import gzip +import io +import json +from typing import Any, Union + +import semantic_version + +# Migrate settings from <= 3.01 +from config import config +from plugin import decorators +from plugin.plugin import EDMCPlugin +from plugin.plugin_info import PluginInfo + + +@decorators.edmc_plugin +class Coriolis(EDMCPlugin): + """Plugin to provide a link to the current ship on coriolis.io.""" + + def load(self) -> PluginInfo: + """Load the plugin.""" + self._migrate_old_configs() + + return PluginInfo( + name='Coriolis', version=semantic_version.Version('1.0.0'), authors=['The EDMC Developers'], + comment='Provides a link to the current ship on https://coriolis.io' + ) + + def _migrate_old_configs(self) -> None: + if not config.get_str('shipyard_provider') and config.get_int('shipyard'): + config.set('shipyard_provider', 'Coriolis') + + config.delete('shipyard', suppress=True) + + @decorators.provider('shipyard') + def shipyard_url(self, loadout: dict[str, Any], is_beta: bool) -> Union[str, bool]: + """Return a shipyard URL for the given loadout.""" + to_send = json.dumps(loadout, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode('uft-8') + if not to_send: + return False + + out = io.BytesIO() + with gzip.GzipFile(fileobj=out, mode='w') as f: + f.write(to_send) + + encoded = base64.urlsafe_b64encode(out.getvalue()).decode().replace('=', '%3D') + url = 'https://beta.coriolis.io/import?data=' if is_beta else 'https://coriolis.io/import?data=' + + return f'{url}{encoded}' diff --git a/plugins/new_plugins/eddb.py b/plugins/new_plugins/eddb.py new file mode 100644 index 0000000000..a55537013f --- /dev/null +++ b/plugins/new_plugins/eddb.py @@ -0,0 +1,201 @@ +"""Station display and eddb.io lookup.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional, cast + +from requests.utils import requote_uri + +import EDMCLogging +import plug +from config import config +from plugin import decorators +from plugin.event import DictDataEvent, JournalEvent +from plugin.plugin import EDMCPlugin +from plugin.plugin_info import PluginInfo +from ttkHyperlinkLabel import HyperlinkLabel + +# -*- coding: utf-8 -*- +# +# Station display and eddb.io lookup +# + +# Tests: +# +# As there's a lot of state tracking in here, need to ensure (at least) +# the URL text and link follow along correctly with: +# +# 1) Game not running, EDMC started. +# 2) Then hit 'Update' for CAPI data pull +# 3) Login fully to game, and whether #2 happened or not: +# a) If docked then update Station +# b) Either way update System +# 4) Undock, SupercruiseEntry, FSDJump should change Station text to 'x' +# and link to system one. +# 5) RequestDocking should populate Station, no matter if the request +# succeeded or not. +# 6) FSDJump should update System text+link. +# 7) Switching to a different provider and then back... combined with +# any of the above in the interim. +# + + +if TYPE_CHECKING: + from tkinter import Tk + + +logger = EDMCLogging.get_main_logger() + + +STATION_UNDOCKED: str = '×' # "Station" name to display when not docked = U+00D7 + + +@decorators.edmc_plugin +class EDDB(EDMCPlugin): + """EDDB Plugin.""" + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + self.system_link: HyperlinkLabel = None # type: ignore # They're always going to be there post-init + self.system: Optional[str] = None + self.system_address: Optional[str] = None + self.system_population: Optional[int] = None + self.station_link: HyperlinkLabel = None # type: ignore # They're always going to be there post-init + self.station: Optional[str] = None + self.station_marketid: Optional[int] = None + self.on_foot = False + + def load(self) -> PluginInfo: + """Load the plugin.""" + return PluginInfo( + name='eddb', version='1.0.0', authors=['The EDMC Authors'], + comment='Provides links for the current station and system on https://eddb.io' + ) + + @decorators.provider('system_url') + def system_url(self, system_name: str) -> str: + if self.system_address: + return requote_uri(f'https://eddb.io/system/ed-address/{self.system_address}') + + if system_name: + return requote_uri(f'https://eddb.io/system/name/{system_name}') + + return '' + + @decorators.provider('station_url') + def station_url(self, system_name: str, station_name: str) -> str: + if self.station_marketid: + return requote_uri(f'https://eddb.io/station/market-id/{self.station_marketid}') + + return self.system_url(system_name) + + @decorators.hook('core.plugin_ui') + def setup_ui(self, parent: Tk) -> None: + self.system_link = cast(HyperlinkLabel, parent.children['system']) # system label in main window + self.system = None + self.system_address = None + self.station = None + self.station_marketid = None # Frontier MarketID + self.station_link = cast(HyperlinkLabel, parent.children['station']) # station label in main window + self.station_link.configure(popup_copy=lambda x: x != STATION_UNDOCKED) + + @decorators.hook('core.journal_entry') + def update_self(self, event: JournalEvent) -> None: # noqa: CCR001 # Cant be split easily currently + """Keep track of the current system and station.""" + # TODO: All of this can likely be dropped for event.state[whatever], see + # TODO: https://github.com/EDCD/EDMarketConnector/issues/1042 + if (ks := self.killswitch.get_disabled('plugins.eddb.journal')).disabled: + logger.warning(f'Journal processing for EDDB has been disabled: {ks.reason}') + plug.show_error('EDDB Journal processing disabled. See Log') + return + + elif (ks := self.killswitch.get_disabled(f'plugins.eddb.journal.event.{event.event_name}')).disabled: + logger.warning(f'Processing of event {event.event_name} has been disabled: {ks.reason}') + return + + self.on_foot = event.state['OnFoot'] + # Always update our system address even if we're not currently the provider for system or station, + # but dont update on events that contain "future" data, such as FSDTarget + if event.event_name in ('Location', 'Docked', 'CarrierJump', 'FSDJump'): + self.system_address = event.get('SystemAddress') or self.system_address + self.system = event.get('StarSystem') or self.system + + # We need pop == 0 to set the value so as to clear 'x' in systems with + # no stations. + pop = event.get('Population') + if pop is not None: + self.system_population = pop + + self.station = event.get('StationName', self.station) + # on_foot station detection + if not self.station and event.event_name == 'Location' and event['BodyType'] == 'Station': + self.station = event['Body'] + + self.station_marketid = event.get('MarketID', self.station_marketid) + # We might pick up StationName in DockingRequested, make sure we clear it if leaving + if event.event_name in ('Undocked', 'FSDJump', 'SupercruiseEntry'): + self.station = None + self.station_marketid = None + + if event.event_name == 'Embark' and not event.get('OnStation'): + # If we're embarking OnStation to a Taxi/Dropship we'll also get an + # Undocked event. + self.station = None + self.station_marketid = None + + # Only actually update text if we are current provider. (this provides our fancy dots) + if config.get_str('system_provider') == 'eddb': # TODO: this will be messed with when providers are fleshed out + self.system_link['text'] = self.system + # Do *NOT* set 'url' here, as it's set to a function that will call + # through correctly. We don't want a static string. + self.system_link.update_idletasks() + + # But only actually change the text if we are current station provider. + if config.get_str('station_provider') == 'eddb': + text = self.station + if not text: + if self.system_population is not None and self.system_population > 0: + text = STATION_UNDOCKED + + else: + text = '' + + self.station_link['text'] = text + # Do *NOT* set 'url' here, as it's set to a function that will call + # through correctly. We don't want a static string. + self.station_link.update_idletasks() + + @decorators.hook('core.cmdr_data') + def update_cmdr(self, event: DictDataEvent): + """Update internal state with CAPI data.""" + # Always store initially, even if we're not the *current* system provider. + if not self.station_marketid and event['commander']['docked']: + self.station_marketid = event['lastStarport']['id'] + + # Only trust CAPI if these aren't yet set + if not self.system: + self.system = event['lastSystem']['name'] + + if not self.station and event['commander']['docked']: + self.station = event['lastStarport']['name'] + + # Override standard URL functions + if config.get_str('system_provider') == 'eddb': + self.system_link['text'] = self.system + # Do *NOT* set 'url' here, as it's set to a function that will call + # through correctly. We don't want a static string. + self.system_link.update_idletasks() + + if config.get_str('station_provider') == 'eddb': + if event['commander']['docked'] or self.on_foot and self.station: + self.station_link['text'] = self.station + + elif event['lastStarport']['name'] and event['lastStarport']['name'] != "": + self.station_link['text'] = STATION_UNDOCKED + + else: + self.station_link['text'] = '' + + # Do *NOT* set 'url' here, as it's set to a function that will call + # through correctly. We don't want a static string. + self.station_link.update_idletasks() From 7c31d532aa6a14c38f3cec136c1cea2927b4f971 Mon Sep 17 00:00:00 2001 From: A_D Date: Mon, 17 May 2021 08:49:53 +0200 Subject: [PATCH 062/152] Added TODO --- plugin/manager.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugin/manager.py b/plugin/manager.py index 3c1677ff0b..e9418bbeb1 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -28,6 +28,7 @@ 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 From 7617b98b6a7c532bc6895eff607e37bf5d9ec0cc Mon Sep 17 00:00:00 2001 From: A_D Date: Fri, 4 Jun 2021 14:18:28 +0200 Subject: [PATCH 063/152] Began integration of new plugin system --- EDMarketConnector.py | 96 ++++- config.py | 2 +- plug.py | 710 ++++++++++++++++----------------- plugin/ARCHITECTURE.md | 6 +- plugin/decorators.py | 2 +- plugin/event.py | 14 +- plugin/legacy_plugin.py | 18 +- plugin/manager.py | 46 ++- prefs.py | 143 ++++--- tests/config.py/_old_config.py | 1 + 10 files changed, 584 insertions(+), 454 deletions(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index 35d2c58ab9..f3eec33cd1 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -7,6 +7,8 @@ import locale import pathlib import queue +from plugin.exceptions import LegacyPluginNeedsMigrating +from plugin import event import re import sys # import threading @@ -16,7 +18,7 @@ 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 Any, List, TYPE_CHECKING, Optional, Tuple, Union, cast # 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 @@ -387,6 +389,9 @@ def _(x: str) -> str: from monitor import monitor from theme import theme from ttkHyperlinkLabel import HyperlinkLabel +from plugin.manager import PluginManager +import plugin +import plugin.event SERVER_RETRY = 5 # retry pause for Companion servers [s] @@ -442,7 +447,10 @@ def __init__(self, master: tk.Tk): # noqa: C901, CCR001 # TODO - can possibly f # self.systray = SysTrayIcon("EDMarketConnector.ico", applongname, menu_options, on_quit=self.exit_tray) # self.systray.start() - plug.load_plugins(master) + self.plugin_manager = PluginManager() + self._load_all_plugins() + + # plug.load_plugins(master) if platform != 'darwin': if platform == 'win32': @@ -501,17 +509,19 @@ 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) + self.setup_plugin_uis(frame) - else: - appitem.grid(columnspan=2, sticky=tk.EW) + # 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) @@ -568,7 +578,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 +588,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) @@ -719,6 +732,53 @@ 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: + internal_to_load_paths: List[pathlib.Path] = [ + p for p in config.internal_plugin_dir_path.iterdir() if self.plugin_manager.is_valid_plugin_directory(p) + ] + + self.plugin_manager.load_plugins(internal_to_load_paths, autoresolve_sys_path=False) + + 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. + """ + res = self.plugin_manager.fire_event(event.BaseDataEvent(event.PLUGIN_STARTUP_UI_EVENT, frame)) + + for plugin_name, results in res.items(): + # result = cast(Union[None, Tuple[tk.Widget, tk.Widget], tk.Widget, Any], result) + if results is None: + logger.trace(f'{plugin_name!r} has no startup UI elements') + continue + + for result in results: + logger.trace(f'{plugin_name} has startup UI elements. adding...') + # create separator for plugin line + tk.Frame(frame, highlightthickness=1).grid(columnspan=2, sticky=tk.EW) + if isinstance(result, tuple) and len(result) == 2: + logger.warning(f'Plugin {plugin_name} uses legacy tuple[Widget, Widget] UI construction') + result = cast(Tuple[tk.Widget, tk.Widget], result) + ui_row = frame.grid_size()[1] + result[0].grid(row=ui_row, column=0, sticky=tk.W) + result[1].grid(row=ui_row, column=1, sticky=tk.EW) + + elif isinstance(result, tk.Widget): + result.grid(columnspan=2, sticky=tk.EW) + + else: + logger.warning( + f'Plugin {plugin_name} returned non-widget {type(result)=} from ui creation handler! Attempting' + ' to use as widget' + ) + result = cast(Any, result) + + try: + result.grid(columnspan=2, sticky=tk.EW) + except Exception: + logger.warning('Failed to use result as widget') + def update_suit_text(self) -> None: """Update the suit text for current type and loadout.""" if not monitor.state['Odyssey']: @@ -1675,7 +1735,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_event(plugin.event.BaseEvent(plugin.event.PLUGIN_EDMC_SHUTTING_DOWN)) # Handling of application hotkeys now so the user can't possible cause # an issue via triggering one. @@ -1964,7 +2024,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..de1a6052a7 100644 --- a/plug.py +++ b/plug.py @@ -1,355 +1,355 @@ -""" -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 -from config import config -from EDMCLogging import get_main_logger - -logger = get_main_logger() - -# List of loaded Plugins -PLUGINS = [] -PLUGINS_not_py3 = [] - -# For asynchronous error display -last_error = { - 'msg': None, - 'root': None, -} - - -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 - - -def load_plugins(master): - """ - 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)] - - -def invoke(plugin_name, fallback, fn_name, *args): - """ - 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) - - -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') - - logger.info('Done') - - 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. - """ - 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') - - -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. - """ - 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') - - -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) - """ - - 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 - - except Exception: - logger.exception(f'Plugin "{plugin.name}" failed while handling CQC mode journal entry') - - return error - - -def notify_dashboard_entry(cmdr, is_beta, entry): - """ - 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) - """ - 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 - - -def show_error(err): - """ - Display an error message in the status line of the main window. - - Will be NOP during shutdown to avoid Tk hang. - :param err: - .. versionadded:: 2.3.7 - """ - 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") +# """ +# 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 +# from config import config +# from EDMCLogging import get_main_logger + +# logger = get_main_logger() + +# # List of loaded Plugins +# PLUGINS = [] +# PLUGINS_not_py3 = [] + +# # For asynchronous error display +# last_error = { +# 'msg': None, +# 'root': None, +# } + + +# 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 + + +# def load_plugins(master): +# """ +# 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)] + + +# def invoke(plugin_name, fallback, fn_name, *args): +# """ +# 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) + + +# 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') + +# logger.info('Done') + +# 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. +# """ +# 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') + + +# 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. +# """ +# 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') + + +# 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) +# """ + +# 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 + +# except Exception: +# logger.exception(f'Plugin "{plugin.name}" failed while handling CQC mode journal entry') + +# return error + + +# def notify_dashboard_entry(cmdr, is_beta, entry): +# """ +# 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) +# """ +# 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 + + +# def show_error(err): +# """ +# Display an error message in the status line of the main window. + +# Will be NOP during shutdown to avoid Tk hang. +# :param err: +# .. versionadded:: 2.3.7 +# """ +# 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") diff --git a/plugin/ARCHITECTURE.md b/plugin/ARCHITECTURE.md index d5b55911fa..56607474af 100644 --- a/plugin/ARCHITECTURE.md +++ b/plugin/ARCHITECTURE.md @@ -99,9 +99,9 @@ all non-special `core` events are as follows: 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` | `(tkinter.Tk) -> Optional[tkinter.Widget]` | Sets up plugins UI | +| Event Name | Expected Signature | Description | +| :--------------- | :----------------------------------------- | ------------------------------------------------------------------------ | +| `core.plugin_ui` | `(tkinter.Tk) -> Optional[tkinter.Widget]` | Sets up plugins UI (Note that the old style tuple pair is NOT supported) | ## TODO diff --git a/plugin/decorators.py b/plugin/decorators.py index 9c6f214686..c61403e683 100644 --- a/plugin/decorators.py +++ b/plugin/decorators.py @@ -34,7 +34,7 @@ def edmc_plugin(cls: Type[BasePlugin]) -> Type[BasePlugin]: def _list_decorate(attr_name: str, attr_content: str, func: _F) -> _F: - logger.debug(f'Found function {func!r} to be marked with attr {attr_name!r} and content {attr_content!r}') + 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 diff --git a/plugin/event.py b/plugin/event.py index dd243d9be3..01b5b34f04 100644 --- a/plugin/event.py +++ b/plugin/event.py @@ -2,6 +2,14 @@ import time from typing import Any, Dict, Optional +PLUGIN_STARTUP_UI_EVENT = 'core.setup_ui' +PLUGIN_PREFERENCES_EVENT = 'core.setup_preferences_ui' +PLUGIN_PREFERENCES_CLOSED_EVENT = 'core.preferences_closed' +PLUGIN_JOURNAL_ENTRY_EVENT = 'core.journal_entry' +PLUGIN_DASHBOARD_ENTRY_EVENT = 'core.dashboard_entry' +PLUGIN_CAPI_DATA_EVENT = 'core.capi_data' +PLUGIN_EDMC_SHUTTING_DOWN = 'core.shutdown' + class BaseEvent: """ @@ -34,7 +42,7 @@ def __init__(self, name: str, data: Any = None, event_time: float = None) -> Non class DictDataEvent(BaseDataEvent): """Same as a data event, but promises data is a dict.""" - def __init__(self, name: str, data: dict[Any, Any], event_time: float) -> None: + def __init__(self, name: str, data: dict[Any, Any], event_time: float = None) -> None: super().__init__(name, data=data, event_time=event_time) self.data: dict[Any, Any] = data @@ -48,8 +56,8 @@ class JournalEvent(DictDataEvent): """Journal event.""" def __init__( - self, name: str, data: Dict[str, Any], event_time: float, cmdr: str, is_beta: bool, - system: Optional[str], station: Optional[str], state: Dict[str, Any] + self, name: str, data: Dict[str, Any], cmdr: str, is_beta: bool, + system: Optional[str], station: Optional[str], state: Dict[str, Any], event_time: float = None ) -> None: self.data: dict[str, Any] # Override the definition in BaseDataEvent to be more specific diff --git a/plugin/legacy_plugin.py b/plugin/legacy_plugin.py index 2e068aec10..f938d1e2ae 100644 --- a/plugin/legacy_plugin.py +++ b/plugin/legacy_plugin.py @@ -19,12 +19,13 @@ from plugin.manager import PluginManager LEGACY_CALLBACK_LUT: Dict[str, str] = { - 'core.setup_ui': 'plugin_app', - 'core.setup_preferences_ui': 'plugin_prefs', - 'core.preferences_closed': 'prefs_changed', - 'core.journal_entry': 'journal_entry', - 'core.dashboard_entry': 'dashboard_entry', - 'core.commander_data': 'cmdr_data', + event.PLUGIN_STARTUP_UI_EVENT: 'plugin_app', + event.PLUGIN_PREFERENCES_EVENT: 'plugin_prefs', + event.PLUGIN_PREFERENCES_CLOSED_EVENT: 'prefs_changed', + event.PLUGIN_JOURNAL_ENTRY_EVENT: 'journal_entry', + event.PLUGIN_DASHBOARD_ENTRY_EVENT: 'dashboard_entry', + event.PLUGIN_CAPI_DATA_EVENT: 'cmdr_data', + event.PLUGIN_EDMC_SHUTTING_DOWN: 'plugin_stop', 'inara.notify_ship': 'inara_notify_ship', @@ -35,10 +36,11 @@ LEGACY_CALLBACK_BREAKOUT_LUT: Dict[str, Callable[..., Tuple[Any, ...]]] = { # All of these callables should accept an event.BaseEvent or a subclass thereof - # 'core.setup_ui': 'plugin_app', + event.PLUGIN_STARTUP_UI_EVENT: lambda e: (e.data,), + event.PLUGIN_PREFERENCES_EVENT: lambda e: (e.notebook, e.commander, e.is_beta), # 'core.setup_preferences_ui': 'plugin_prefs', # 'core.preferences_closed': 'prefs_changed', - 'core.journal_entry': lambda e: (e.commander, e.is_beta, e.system, e.station, e.data, e.state), + event.PLUGIN_JOURNAL_ENTRY_EVENT: lambda e: (e.commander, e.is_beta, e.system, e.station, e.data, e.state), # 'core.dashboard_entry': 'dashboard_entry', # 'core.commander_data': 'cmdr_data', diff --git a/plugin/manager.py b/plugin/manager.py index e9418bbeb1..8e37bfe22c 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -16,7 +16,7 @@ from plugin.base_plugin import BasePlugin from plugin.event import BaseEvent from plugin.exceptions import ( - PluginAlreadyLoadedException, PluginDoesNotExistException, PluginHasNoPluginClassException, PluginLoadingException + LegacyPluginNeedsMigrating, PluginAlreadyLoadedException, PluginDoesNotExistException, PluginHasNoPluginClassException, PluginLoadingException ) from plugin.legacy_plugin import MigratedPlugin from plugin.plugin_info import PluginInfo @@ -102,7 +102,8 @@ def __init__(self) -> None: self.log = get_main_logger() self.log.info("starting new plugin management engine") self.plugins: Dict[str, LoadedPlugin] = {} - self._plugins_previously_loaded: Set[str] = set() + self.failed_loading: Dict[pathlib.Path, Exception] = {} # path -> reason + # self._plugins_previously_loaded: Set[str] = set() def find_potential_plugins(self, path: pathlib.Path) -> List[pathlib.Path]: """ @@ -272,7 +273,13 @@ def load_plugin(self, path: pathlib.Path, autoresolve_sys_path=True) -> Optional # 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 = self.__get_plugin_at(path, autoresolve_sys_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') @@ -283,8 +290,8 @@ def load_plugin(self, path: pathlib.Path, autoresolve_sys_path=True) -> Optional self.log.trace(f'Calling load method on {plugin}') try: info = plugin.load() - except PluginLoadingException: - # TODO: store this to note that it was tried but failed + except PluginLoadingException as e: + self.failed_loading[path] = e return None except Exception as e: @@ -381,14 +388,16 @@ def unload_plugin(self, name: str): del self.plugins[name] - def fire_event(self, event: BaseEvent) -> list[list[Any]]: + def fire_event(self, event: BaseEvent) -> Dict[str, List[Any]]: """Call all callbacks listening for the given event.""" - # TODO: rather a dict[plugin_name, list[any]] ? - out: list[list[Any]] = [] + out: Dict[str, Any] = {} for name, p in self.plugins.items(): self.log.trace(f'Firing event {event.name} for plugin {name}') res = p.fire_event(event) - out.append(res) + if name in out: + self.log.warning(f'Two plugins with the same name?????? {out[name]=} {name=} {res=}') + + out[name] = res return out @@ -404,4 +413,21 @@ def fire_targeted_event(self, target: Union[LoadedPlugin, str], event: BaseEvent self.log.trace(f'Firing targeted event {event.name} at {target.info.name}') return target.fire_event(event) - # TODO: Register(System|station)Provider method, to allow it to be dynamic to plugins + 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 + + @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('_')) diff --git a/prefs.py b/prefs.py index c403023bf3..0bae893901 100644 --- a/prefs.py +++ b/prefs.py @@ -10,10 +10,9 @@ 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 Dict, List, TYPE_CHECKING, Any, Callable, 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 @@ -22,6 +21,8 @@ from myNotebook import Notebook from theme import theme from ttkHyperlinkLabel import HyperlinkLabel +from plugin.manager import PluginManager +from plugin import event logger = get_main_logger() @@ -39,6 +40,16 @@ def _(x: str) -> str: # May be imported by plugins +class PluginPreferencesEvent(event.BaseEvent): + """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.PLUGIN_PREFERENCES_EVENT, event_time=None) + self.notebook = notebook + self.commander = commander + self.is_beta = is_beta + + class PrefsVersion: """ PrefsVersion contains versioned preferences. @@ -240,18 +251,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) @@ -417,10 +428,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(PluginPreferencesEvent(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 results is None or 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 +596,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 +624,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 +635,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 +643,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 +654,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) @@ -913,7 +941,8 @@ 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)) + # enabled_plugins = list(filter(lambda x: x.folder and x.module, plug.PLUGINS)) + enabled_plugins = list(self.plugin_manager.plugins.values()) if len(enabled_plugins): ttk.Separator(plugins_frame, orient=tk.HORIZONTAL).grid( columnspan=3, padx=self.PADX, pady=self.PADY * 8, sticky=tk.EW @@ -925,52 +954,52 @@ 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) + if plugin.info.name == plugin.plugin.path.name: + label = nb.Label(plugins_frame, text=plugin.info.name) else: - label = nb.Label(plugins_frame, text=f'{plugin.folder} ({plugin.name})') + label = nb.Label(plugins_frame, text=f'{plugin.plugin.path} ({plugin.info.name})') label.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): - 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) - - 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', - 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): - 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 - 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( - columnspan=2, padx=self.PADX*2, sticky=tk.W, row=row.get() - ) + # if len(plug.PLUGINS_not_py3): + # 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) + + # 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', + # 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): + # 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 + # 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( + # 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 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 From 68e10dadb2d150911323e3f5e7a0863f2d1ff3d3 Mon Sep 17 00:00:00 2001 From: A_D Date: Fri, 4 Jun 2021 14:33:56 +0200 Subject: [PATCH 064/152] Updated arch info --- plugin/ARCHITECTURE.md | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/plugin/ARCHITECTURE.md b/plugin/ARCHITECTURE.md index 56607474af..de0fa0ced0 100644 --- a/plugin/ARCHITECTURE.md +++ b/plugin/ARCHITECTURE.md @@ -96,12 +96,26 @@ all non-special `core` events are as follows: | `journal_event` | `(event.JournalEvent) -> None` | Event fired when a new journal event is seen | | `cmdr_data` | `(event.BaseDataEvent) -> None` | Event fired when new data comes in from CAPI TODO: `capi_data`? | -Some `core` events are special, and will work directly with your plugin rather than -being global, they are documented below +TODO: finish this -| Event Name | Expected Signature | Description | -| :--------------- | :----------------------------------------- | ------------------------------------------------------------------------ | -| `core.plugin_ui` | `(tkinter.Tk) -> Optional[tkinter.Widget]` | Sets up plugins UI (Note that the old style tuple pair is NOT supported) | +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` | `(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`. + +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. ## TODO From 4a0dd18cc81ae3dc07d353a73fae25c576546011 Mon Sep 17 00:00:00 2001 From: A_D Date: Fri, 4 Jun 2021 14:34:15 +0200 Subject: [PATCH 065/152] removed None checks that arent needed --- EDMarketConnector.py | 3 +-- plugin/manager.py | 4 +++- prefs.py | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index f3eec33cd1..7296f8884c 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -749,9 +749,8 @@ def setup_plugin_uis(self, frame: tk.Frame) -> None: for plugin_name, results in res.items(): # result = cast(Union[None, Tuple[tk.Widget, tk.Widget], tk.Widget, Any], result) - if results is None: + if len(results): logger.trace(f'{plugin_name!r} has no startup UI elements') - continue for result in results: logger.trace(f'{plugin_name} has startup UI elements. adding...') diff --git a/plugin/manager.py b/plugin/manager.py index 8e37bfe22c..d1e9937eb6 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -16,7 +16,8 @@ from plugin.base_plugin import BasePlugin from plugin.event import BaseEvent from plugin.exceptions import ( - LegacyPluginNeedsMigrating, PluginAlreadyLoadedException, PluginDoesNotExistException, PluginHasNoPluginClassException, PluginLoadingException + LegacyPluginNeedsMigrating, PluginAlreadyLoadedException, PluginDoesNotExistException, + PluginHasNoPluginClassException, PluginLoadingException ) from plugin.legacy_plugin import MigratedPlugin from plugin.plugin_info import PluginInfo @@ -103,6 +104,7 @@ def __init__(self) -> None: self.log.info("starting new plugin management engine") self.plugins: Dict[str, LoadedPlugin] = {} self.failed_loading: Dict[pathlib.Path, Exception] = {} # path -> reason + # TODO: plugins that were skipped because they started with _ or . # self._plugins_previously_loaded: Set[str] = set() def find_potential_plugins(self, path: pathlib.Path) -> List[pathlib.Path]: diff --git a/prefs.py b/prefs.py index 0bae893901..81cfe7e0ae 100644 --- a/prefs.py +++ b/prefs.py @@ -431,7 +431,7 @@ def __setup_plugin_tabs(self, notebook: Notebook) -> None: plugin_results = self.plugin_manager.fire_event(PluginPreferencesEvent(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 results is None or len(results) == 0: + if len(results) == 0: # Plugin either did something but didn't give us anything back, or doesn't listen to this event continue From 7f8078aca02439f806e691d67da66c127d8fd1ab Mon Sep 17 00:00:00 2001 From: A_D Date: Sat, 5 Jun 2021 22:26:59 +0200 Subject: [PATCH 066/152] plugin lists in prefs --- prefs.py | 55 +++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/prefs.py b/prefs.py index 81cfe7e0ae..54318a7f8e 100644 --- a/prefs.py +++ b/prefs.py @@ -3,6 +3,7 @@ import contextlib import logging +from plugin.exceptions import LegacyPluginNeedsMigrating import tkinter as tk import webbrowser from os.path import exists, expanduser, expandvars, join, normpath @@ -943,7 +944,8 @@ def __setup_plugin_tab(self, notebook: Notebook) -> None: # enabled_plugins = list(filter(lambda x: x.folder and x.module, plug.PLUGINS)) enabled_plugins = list(self.plugin_manager.plugins.values()) - if len(enabled_plugins): + 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 ) @@ -965,25 +967,38 @@ def __setup_plugin_tab(self, notebook: Notebook) -> None: ############################################################ # Show which plugins don't have Python 3.x support ############################################################ - # if len(plug.PLUGINS_not_py3): - # 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) - - # 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', - # underline=True - # ).grid(columnspan=2, padx=self.PADX, sticky=tk.W) - # ############################################################ + + 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: + ttk.Separator(plugins_frame, orient=tk.HORIZONTAL).grid( + columnspan=3, padx=self.PADX, pady=self.PADY*8, sticky=tk.EW, row=row.get() + ) + ... + + 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 p in legacy_not_py3: + nb.Label(plugins_frame, text=f'{p.name} ({p})').grid(columnspan=2, padx=self.PADX*2, sticky=tk.W) + + 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-to-python-37', + underline=True + ).grid(columnspan=2, padx=self.PADX, sticky=tk.W) + + # disabled = [] # TODO # disabled_plugins = list(filter(lambda x: x.folder and not x.module, plug.PLUGINS)) # if len(disabled_plugins): From 65c263ec0a31fbd603cb26bd8dc84f89be6d9da8 Mon Sep 17 00:00:00 2001 From: A_D Date: Sat, 5 Jun 2021 22:27:19 +0200 Subject: [PATCH 067/152] added legacy plugins getter --- plugin/manager.py | 4 ++++ prefs.py | 1 + 2 files changed, 5 insertions(+) diff --git a/plugin/manager.py b/plugin/manager.py index d1e9937eb6..a8ab247a6a 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -429,6 +429,10 @@ def get_providers(self, name: str) -> List[LoadedPlugin]: return out + @property + def legacy_plugins(self) -> List[MigratedPlugin]: + return [p for p in self.plugins if isinstance(p, MigratedPlugin)] + @staticmethod def is_valid_plugin_directory(p: pathlib.Path) -> bool: """Return whether or not the given path is a valid plugin directory.""" diff --git a/prefs.py b/prefs.py index 54318a7f8e..b58560f69b 100644 --- a/prefs.py +++ b/prefs.py @@ -979,6 +979,7 @@ def __setup_plugin_tab(self, notebook: Notebook) -> None: ttk.Separator(plugins_frame, orient=tk.HORIZONTAL).grid( columnspan=3, padx=self.PADX, pady=self.PADY*8, sticky=tk.EW, row=row.get() ) + # TODO (A_D) Complete showing failed loads ... if len(legacy_not_py3) > 0: From 98b4c774dd73beb052e74a9dad6e06cb09349504 Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 19 Aug 2021 07:08:15 +0200 Subject: [PATCH 068/152] typed decorator more tightly when types are known --- plugin/decorators.py | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/plugin/decorators.py b/plugin/decorators.py index c61403e683..a5a4a67996 100644 --- a/plugin/decorators.py +++ b/plugin/decorators.py @@ -1,11 +1,32 @@ """Decorators for marking plugins and callbacks.""" -from typing import Any, Callable, Type, TypeVar +import functools +from typing import TYPE_CHECKING, Any, Callable, Literal, Optional, Type, TypeVar, Union, overload from EDMCLogging import get_main_logger from plugin.base_plugin import BasePlugin +if TYPE_CHECKING: + import tkinter as tk + + from plugin.event import JournalEvent + + _UI_SETUP_FUNC = TypeVar( + '_UI_SETUP_FUNC', bound=Union[ + Callable[[Any, tk.Frame], Optional[tk.Widget]], + Callable[[tk.Frame], Optional[tk.Widget]], + ] + ) + + _JOURNAL_FUNC = TypeVar( + '_JOURNAL_FUNC', + bound=Union[ + Callable[[JournalEvent], None], + Callable[[Any, JournalEvent], None] + ] + ) + logger = get_main_logger() CALLBACK_MARKER = "__edmc_callback_marker__" @@ -51,6 +72,20 @@ def _list_decorate(attr_name: str, attr_content: str, func: _F) -> _F: 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. +@overload +def hook(name: Literal['core.setup_ui']) -> Callable[[_UI_SETUP_FUNC], _UI_SETUP_FUNC]: ... + + +@overload +def hook(name: Literal['core.journal_event']) -> Callable[[_JOURNAL_FUNC], _JOURNAL_FUNC]: ... + + +@overload +def hook(name: str) -> Callable[['_F'], _F]: ... + + def hook(name: str) -> Callable[['_F'], _F]: """ Create event callback. From 0f473c7f8c870a3cc78578004aaab114123d133c Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 19 Aug 2021 07:13:44 +0200 Subject: [PATCH 069/152] streamlined plugin UI loading --- EDMarketConnector.py | 51 +++++++++++++++++--------------------------- 1 file changed, 19 insertions(+), 32 deletions(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index 7296f8884c..efb9425104 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -6,9 +6,6 @@ import html import locale import pathlib -import queue -from plugin.exceptions import LegacyPluginNeedsMigrating -from plugin import event import re import sys # import threading @@ -18,7 +15,7 @@ from os.path import dirname, join from sys import platform from time import localtime, strftime, time -from typing import Any, List, TYPE_CHECKING, Optional, Tuple, Union, cast +from typing import TYPE_CHECKING, List, Optional, Tuple, cast # 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 @@ -377,6 +374,8 @@ def _(x: str) -> str: import commodity import plug +import plugin +import plugin.event import prefs import protocol import stats @@ -387,11 +386,11 @@ def _(x: str) -> str: from hotkey import hotkeymgr from l10n import Translations from monitor import monitor +from plugin.exceptions import LegacyPluginNeedsMigrating +from plugin.manager import PluginManager +from protocol import protocolhandler from theme import theme from ttkHyperlinkLabel import HyperlinkLabel -from plugin.manager import PluginManager -import plugin -import plugin.event SERVER_RETRY = 5 # retry pause for Companion servers [s] @@ -738,6 +737,7 @@ def _load_all_plugins(self) -> None: ] self.plugin_manager.load_plugins(internal_to_load_paths, autoresolve_sys_path=False) + # TODO: Load non-internal plugins def setup_plugin_uis(self, frame: tk.Frame) -> None: """ @@ -748,35 +748,22 @@ def setup_plugin_uis(self, frame: tk.Frame) -> None: res = self.plugin_manager.fire_event(event.BaseDataEvent(event.PLUGIN_STARTUP_UI_EVENT, frame)) for plugin_name, results in res.items(): + results = cast(List[Optional[tk.Widget]], results) + # result = cast(Union[None, Tuple[tk.Widget, tk.Widget], tk.Widget, Any], result) - if len(results): + if len(results) == 0: logger.trace(f'{plugin_name!r} has no startup UI elements') + continue - for result in results: - logger.trace(f'{plugin_name} has startup UI elements. adding...') - # create separator for plugin line - tk.Frame(frame, highlightthickness=1).grid(columnspan=2, sticky=tk.EW) - if isinstance(result, tuple) and len(result) == 2: - logger.warning(f'Plugin {plugin_name} uses legacy tuple[Widget, Widget] UI construction') - result = cast(Tuple[tk.Widget, tk.Widget], result) - ui_row = frame.grid_size()[1] - result[0].grid(row=ui_row, column=0, sticky=tk.W) - result[1].grid(row=ui_row, column=1, sticky=tk.EW) - - elif isinstance(result, tk.Widget): - result.grid(columnspan=2, sticky=tk.EW) - - else: - logger.warning( - f'Plugin {plugin_name} returned non-widget {type(result)=} from ui creation handler! Attempting' - ' to use as widget' - ) - result = cast(Any, result) + # Separator for plugin line + tk.Frame(frame, highlightthickness=1).grid(columnspan=2, sticky=tk.EW) + logger.trace( + f'{plugin_name} has {f"{len(results)} " if len(results) > 1 else ""}startup UI elements. adding...' + ) - try: - result.grid(columnspan=2, sticky=tk.EW) - except Exception: - logger.warning('Failed to use result as widget') + # WORKAROUND 19-08-2021 | mypy workaround -- filter() does not correctly re-type to FilterObj[tk.Widget] + for result in cast(List[tk.Widget], filter(lambda r: r is not None, results)): + result.grid(columnspan=2, sticky=tk.EW) def update_suit_text(self) -> None: """Update the suit text for current type and loadout.""" From b4761df0dbfbd0928086794f7fbaf9d170e79b17 Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 19 Aug 2021 07:14:19 +0200 Subject: [PATCH 070/152] added legacy plugin wrapper UI wrapper --- plugin/legacy_plugin.py | 42 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/plugin/legacy_plugin.py b/plugin/legacy_plugin.py index f938d1e2ae..64cb6bdec6 100644 --- a/plugin/legacy_plugin.py +++ b/plugin/legacy_plugin.py @@ -5,7 +5,7 @@ import inspect import pathlib from types import ModuleType -from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple, Union, cast import semantic_version @@ -15,9 +15,18 @@ from plugin.plugin_info import PluginInfo 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.PLUGIN_STARTUP_UI_EVENT: 'plugin_app', event.PLUGIN_PREFERENCES_EVENT: 'plugin_prefs', @@ -156,6 +165,37 @@ def wrapper(e: event.BaseEvent): setattr(wrapper, "original_func", f) return wrapper + @decorators.hook(event.PLUGIN_STARTUP_UI_EVENT) + def ui_wrapper(self, frame: tk.Frame) -> 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 + if (f := getattr(self.module, 'plugin_app')) is None: + return None + out_frame = tk.Frame(frame) + f = cast(_LEGACY_UI_FUNC, f) + res = f(out_frame) + + if isinstance(res, tk.Widget): + return out_frame + + 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 out_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') From 31769641e2e2126e5e876a12401bfde23fb292f7 Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 19 Aug 2021 07:15:50 +0200 Subject: [PATCH 071/152] fixed type on test --- plugin/test/test_plugins/good/multi_callback/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugin/test/test_plugins/good/multi_callback/__init__.py b/plugin/test/test_plugins/good/multi_callback/__init__.py index 843fb5cd1a..a86bf2c160 100644 --- a/plugin/test/test_plugins/good/multi_callback/__init__.py +++ b/plugin/test/test_plugins/good/multi_callback/__init__.py @@ -3,7 +3,7 @@ from plugin.base_plugin import BasePlugin from plugin.decorators import edmc_plugin, hook -from plugin.event import BaseEvent +from plugin.event import BaseEvent, JournalEvent from plugin.plugin_info import PluginInfo @@ -23,7 +23,7 @@ def load(self) -> PluginInfo: @hook('core.journal_event') @hook('uncore.not_journal_event') - def multiple_things(self, e: BaseEvent): + def multiple_things(self, e: JournalEvent): """Multiple hooks on one method.""" self.called.append(e) From e73ed5fa8a9b8938453ef530889bc4e96acc99d7 Mon Sep 17 00:00:00 2001 From: A_D Date: Sat, 21 Aug 2021 04:56:12 +0200 Subject: [PATCH 072/152] even better decorators typing, renamed events --- plugin/decorators.py | 54 ++++++++++++++++++++++------------------- plugin/event.py | 25 +++++++++++++------ plugin/legacy_plugin.py | 22 ++++++++--------- 3 files changed, 57 insertions(+), 44 deletions(-) diff --git a/plugin/decorators.py b/plugin/decorators.py index a5a4a67996..ba7cae29e7 100644 --- a/plugin/decorators.py +++ b/plugin/decorators.py @@ -1,31 +1,11 @@ """Decorators for marking plugins and callbacks.""" +from __future__ import annotations - -import functools -from typing import TYPE_CHECKING, Any, Callable, Literal, Optional, Type, TypeVar, Union, overload +from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, Optional, Type, TypeVar, Union, overload from EDMCLogging import get_main_logger from plugin.base_plugin import BasePlugin -if TYPE_CHECKING: - import tkinter as tk - - from plugin.event import JournalEvent - - _UI_SETUP_FUNC = TypeVar( - '_UI_SETUP_FUNC', bound=Union[ - Callable[[Any, tk.Frame], Optional[tk.Widget]], - Callable[[tk.Frame], Optional[tk.Widget]], - ] - ) - - _JOURNAL_FUNC = TypeVar( - '_JOURNAL_FUNC', - bound=Union[ - Callable[[JournalEvent], None], - Callable[[Any, JournalEvent], None] - ] - ) logger = get_main_logger() @@ -74,8 +54,32 @@ def _list_decorate(attr_name: str, attr_content: str, func: _F) -> _F: # 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 plugin.event import JournalEvent + from prefs import BasePreferencesEvent + + _UI_SETUP = Union[ + Callable[[Any, tk.Frame], Optional[tk.Widget]], + Callable[[tk.Frame], Optional[tk.Widget]], + ] + + _JOURNAL_FUNC = Union[ + Callable[[JournalEvent], None], + Callable[[Any, JournalEvent], None] + ] + + _PLUGIN_PREFS_FUNC = TypeVar('_PLUGIN_PREFS_FUNC', bound=Union[ + Callable[[BasePreferencesEvent], None], + Callable[[Any, BasePreferencesEvent], None], + ]) + + @overload -def hook(name: Literal['core.setup_ui']) -> Callable[[_UI_SETUP_FUNC], _UI_SETUP_FUNC]: ... +def hook(name: Literal['core.setup_ui']) -> Callable[[_UI_SETUP], _UI_SETUP]: ... @overload @@ -83,10 +87,10 @@ def hook(name: Literal['core.journal_event']) -> Callable[[_JOURNAL_FUNC], _JOUR @overload -def hook(name: str) -> Callable[['_F'], _F]: ... +def hook(name: str) -> Callable[[_F], _F]: ... -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. diff --git a/plugin/event.py b/plugin/event.py index 01b5b34f04..ec255fb18b 100644 --- a/plugin/event.py +++ b/plugin/event.py @@ -2,13 +2,22 @@ import time from typing import Any, Dict, Optional -PLUGIN_STARTUP_UI_EVENT = 'core.setup_ui' -PLUGIN_PREFERENCES_EVENT = 'core.setup_preferences_ui' -PLUGIN_PREFERENCES_CLOSED_EVENT = 'core.preferences_closed' -PLUGIN_JOURNAL_ENTRY_EVENT = 'core.journal_entry' -PLUGIN_DASHBOARD_ENTRY_EVENT = 'core.dashboard_entry' -PLUGIN_CAPI_DATA_EVENT = 'core.capi_data' -PLUGIN_EDMC_SHUTTING_DOWN = 'core.shutdown' + +class EDMCPluginEvents: + """Events EDMC currently uses to communicate with plugins.""" + + STARTUP_UI = 'core.setup_ui' + JOURNAL_ENTRY = 'core.journal_entry' + DASHBOARD_ENTRY = 'core.dashboard_entry' + 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: @@ -19,7 +28,7 @@ class BaseEvent: with your event, use one of the subclasses below. """ - def __init__(self, name: str, event_time: float = None) -> None: + def __init__(self, name: str, event_time: Optional[float] = None) -> None: self.name = name if event_time is None: event_time = time.time() diff --git a/plugin/legacy_plugin.py b/plugin/legacy_plugin.py index 64cb6bdec6..503f7165c6 100644 --- a/plugin/legacy_plugin.py +++ b/plugin/legacy_plugin.py @@ -28,13 +28,13 @@ ] LEGACY_CALLBACK_LUT: Dict[str, str] = { - event.PLUGIN_STARTUP_UI_EVENT: 'plugin_app', - event.PLUGIN_PREFERENCES_EVENT: 'plugin_prefs', - event.PLUGIN_PREFERENCES_CLOSED_EVENT: 'prefs_changed', - event.PLUGIN_JOURNAL_ENTRY_EVENT: 'journal_entry', - event.PLUGIN_DASHBOARD_ENTRY_EVENT: 'dashboard_entry', - event.PLUGIN_CAPI_DATA_EVENT: 'cmdr_data', - event.PLUGIN_EDMC_SHUTTING_DOWN: 'plugin_stop', + 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', @@ -45,11 +45,11 @@ LEGACY_CALLBACK_BREAKOUT_LUT: Dict[str, Callable[..., Tuple[Any, ...]]] = { # All of these callables should accept an event.BaseEvent or a subclass thereof - event.PLUGIN_STARTUP_UI_EVENT: lambda e: (e.data,), - event.PLUGIN_PREFERENCES_EVENT: lambda e: (e.notebook, e.commander, e.is_beta), + event.EDMCPluginEvents.STARTUP_UI: lambda e: (e.data,), + event.EDMCPluginEvents.PREFERENCES: lambda e: (e.notebook, e.commander, e.is_beta), # 'core.setup_preferences_ui': 'plugin_prefs', # 'core.preferences_closed': 'prefs_changed', - event.PLUGIN_JOURNAL_ENTRY_EVENT: lambda e: (e.commander, e.is_beta, e.system, e.station, e.data, e.state), + event.EDMCPluginEvents.JOURNAL_ENTRY: lambda e: (e.commander, e.is_beta, e.system, e.station, e.data, e.state), # 'core.dashboard_entry': 'dashboard_entry', # 'core.commander_data': 'cmdr_data', @@ -165,7 +165,7 @@ def wrapper(e: event.BaseEvent): setattr(wrapper, "original_func", f) return wrapper - @decorators.hook(event.PLUGIN_STARTUP_UI_EVENT) + @decorators.hook(event.EDMCPluginEvents.STARTUP_UI) def ui_wrapper(self, frame: tk.Frame) -> 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 From 2c1ae2abaf8ed28ae2915c55dd79d0d21df68502 Mon Sep 17 00:00:00 2001 From: A_D Date: Sat, 21 Aug 2021 04:58:29 +0200 Subject: [PATCH 073/152] missing newline --- plugin/test/test_plugins/good/multi_callback/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugin/test/test_plugins/good/multi_callback/__init__.py b/plugin/test/test_plugins/good/multi_callback/__init__.py index a86bf2c160..b351ab7ec9 100644 --- a/plugin/test/test_plugins/good/multi_callback/__init__.py +++ b/plugin/test/test_plugins/good/multi_callback/__init__.py @@ -1,4 +1,5 @@ """Test plugin that loads correctly.""" + import semantic_version from plugin.base_plugin import BasePlugin From 00c6f788102c2294420d61d8575b65688c1fa210 Mon Sep 17 00:00:00 2001 From: A_D Date: Sat, 21 Aug 2021 04:58:45 +0200 Subject: [PATCH 074/152] import cleanup --- plugin/manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/manager.py b/plugin/manager.py index a8ab247a6a..fe7594153c 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -5,7 +5,7 @@ import pathlib import sys from fnmatch import fnmatch -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Sequence, Set, Tuple, Type, Union +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Sequence, Tuple, Type, Union if TYPE_CHECKING: from types import ModuleType From a284f76938f997dd03e2a89c0f9d6181db19bae5 Mon Sep 17 00:00:00 2001 From: A_D Date: Sat, 21 Aug 2021 05:00:34 +0200 Subject: [PATCH 075/152] revised how loaded (and/or errored) plugins are shown --- prefs.py | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/prefs.py b/prefs.py index b58560f69b..22ad60bafd 100644 --- a/prefs.py +++ b/prefs.py @@ -3,7 +3,6 @@ import contextlib import logging -from plugin.exceptions import LegacyPluginNeedsMigrating import tkinter as tk import webbrowser from os.path import exists, expanduser, expandvars, join, normpath @@ -11,7 +10,7 @@ from tkinter import colorchooser as tkColorChooser # type: ignore # noqa: N812 from tkinter import ttk from types import TracebackType -from typing import Dict, List, TYPE_CHECKING, Any, Callable, Optional, Type, Union, cast +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Type, Union, cast import myNotebook as nb # noqa: N813 from config import applongname, appversion_nobuild, config @@ -20,10 +19,11 @@ 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 -from plugin.manager import PluginManager -from plugin import event logger = get_main_logger() @@ -956,13 +956,17 @@ def __setup_plugin_tab(self, notebook: Notebook) -> None: ).grid(padx=self.PADX, sticky=tk.W, row=row.get()) for plugin in enabled_plugins: + text = "" if plugin.info.name == plugin.plugin.path.name: - label = nb.Label(plugins_frame, text=plugin.info.name) + text = plugin.info.name else: - label = nb.Label(plugins_frame, text=f'{plugin.plugin.path} ({plugin.info.name})') + text = f'{plugin.plugin.path} ({plugin.info.name})' + + if plugin in legacy_plugins: + text += ' (legacy)' - label.grid(columnspan=2, padx=self.PADX*2, sticky=tk.W, row=row.get()) + 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 @@ -971,16 +975,26 @@ def __setup_plugin_tab(self, notebook: Notebook) -> None: 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() ) - # TODO (A_D) Complete showing failed loads - ... + # 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( @@ -989,7 +1003,8 @@ def __setup_plugin_tab(self, notebook: Notebook) -> None: # 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 p in legacy_not_py3: - nb.Label(plugins_frame, text=f'{p.name} ({p})').grid(columnspan=2, padx=self.PADX*2, sticky=tk.W) + 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 From 04044a059c805d649b34f6c9ec80f2fee2f1d2c7 Mon Sep 17 00:00:00 2001 From: A_D Date: Sat, 21 Aug 2021 05:01:00 +0200 Subject: [PATCH 076/152] start on moving prefs events to new system --- prefs.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/prefs.py b/prefs.py index 22ad60bafd..499deb2ce1 100644 --- a/prefs.py +++ b/prefs.py @@ -41,14 +41,22 @@ def _(x: str) -> str: # May be imported by plugins -class PluginPreferencesEvent(event.BaseEvent): +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.PLUGIN_PREFERENCES_EVENT, event_time=None) + super().__init__(event.EDMCPluginEvents.PREFERENCES, commander=commander, is_beta=is_beta) self.notebook = notebook - self.commander = commander - self.is_beta = is_beta class PrefsVersion: @@ -429,7 +437,7 @@ 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: - plugin_results = self.plugin_manager.fire_event(PluginPreferencesEvent(notebook, monitor.cmdr, monitor.is_beta)) + 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: @@ -1035,7 +1043,7 @@ def __setup_plugin_tab(self, notebook: Notebook) -> None: # 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. @@ -1044,7 +1052,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 From 789b7e8fed4a699964ee0324134c9b4185348561 Mon Sep 17 00:00:00 2001 From: A_D Date: Mon, 23 Aug 2021 01:11:16 +0200 Subject: [PATCH 077/152] reorder config window tabs --- prefs.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/prefs.py b/prefs.py index 499deb2ce1..0daae8860f 100644 --- a/prefs.py +++ b/prefs.py @@ -306,12 +306,12 @@ def __init__(self, parent: tk.Tk, callback: Optional[Callable], plugin_manager: 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 From 097d3f5bf9fb04490e20d46a25f1e2077d928f9d Mon Sep 17 00:00:00 2001 From: A_D Date: Mon, 23 Aug 2021 12:12:13 +0200 Subject: [PATCH 078/152] added util method for simple notification events --- plugin/manager.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugin/manager.py b/plugin/manager.py index fe7594153c..fdf9bd1127 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -403,6 +403,10 @@ def fire_event(self, event: BaseEvent) -> Dict[str, List[Any]]: return out + def fire_str_event(self, event_name: str, time: Optional[float] = None) -> 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)) + 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): From f4b405186969dd37bb717e51c4ff0a07de5b2d42 Mon Sep 17 00:00:00 2001 From: A_D Date: Mon, 23 Aug 2021 12:12:35 +0200 Subject: [PATCH 079/152] fixed unresolved import --- EDMarketConnector.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index efb9425104..d51d05c59c 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -388,6 +388,7 @@ def _(x: str) -> str: from monitor import monitor from plugin.exceptions import LegacyPluginNeedsMigrating from plugin.manager import PluginManager +from plugin import event from protocol import protocolhandler from theme import theme from ttkHyperlinkLabel import HyperlinkLabel @@ -745,7 +746,7 @@ def setup_plugin_uis(self, frame: tk.Frame) -> None: :param frame: the frame under which plugins should create their widgets. """ - res = self.plugin_manager.fire_event(event.BaseDataEvent(event.PLUGIN_STARTUP_UI_EVENT, frame)) + res = self.plugin_manager.fire_event(event.BaseDataEvent(event.EDMCPluginEvents.STARTUP_UI, frame)) for plugin_name, results in res.items(): results = cast(List[Optional[tk.Widget]], results) @@ -1721,7 +1722,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...') - self.plugin_manager.fire_event(plugin.event.BaseEvent(plugin.event.PLUGIN_EDMC_SHUTTING_DOWN)) + 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. From 7586e45a53a0d94835dfe1a8ffe57c2e7bdb9f24 Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 30 Sep 2021 06:55:22 +0200 Subject: [PATCH 080/152] Include exceptions in results when firing event functions --- plugin/manager.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugin/manager.py b/plugin/manager.py index fdf9bd1127..66ad7342da 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -66,8 +66,9 @@ def _fire_event_funcs(self, event: BaseEvent, funcs: list[Callable]) -> list[Any if res is not None: out.append(res) - except Exception: + except Exception as e: self.log.exception(f'Caught an exception while firing event {event.name!r} on func {func}') + out.append(e) return out From 4cb1eef5f59cb32c1d8115531f7751d3b7e3af75 Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 30 Sep 2021 06:55:45 +0200 Subject: [PATCH 081/152] missed import during rebase --- EDMarketConnector.py | 1 + 1 file changed, 1 insertion(+) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index d51d05c59c..39455b821f 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -8,6 +8,7 @@ import pathlib import re import sys +import queue # import threading import webbrowser from builtins import object, str From edfd44f1e17673512ba62d17a6f07cc2cb180c89 Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 30 Sep 2021 08:15:09 +0200 Subject: [PATCH 082/152] Add monitor access to EDMCPlugin --- plugin/plugin.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/plugin/plugin.py b/plugin/plugin.py index 1d2feccbdc..49088aa409 100644 --- a/plugin/plugin.py +++ b/plugin/plugin.py @@ -9,12 +9,13 @@ from __future__ import annotations import pathlib -from typing import TYPE_CHECKING, Optional, final +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 @@ -95,3 +96,27 @@ def edmc_version(self, no_build=False) -> semantic_version.Version: 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 system(self) -> str | None: + """Return the current system, if any.""" + return monitor.monitor.system + + @property + @final + def station(self) -> str | None: + """Return the current station, if any.""" + return monitor.monitor.station + + @property + @final + def state(self) -> dict[str, Any]: + """Return the currently tracked state, if any.""" + return monitor.monitor.state From e6a3a2721fb329887610f0d1e43bf5dca53168fa Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 30 Sep 2021 08:51:45 +0200 Subject: [PATCH 083/152] make BaseDataEvent a generic --- plugin/event.py | 53 +++++++++++++++++++++++-------------------------- 1 file changed, 25 insertions(+), 28 deletions(-) diff --git a/plugin/event.py b/plugin/event.py index ec255fb18b..a0a6140afe 100644 --- a/plugin/event.py +++ b/plugin/event.py @@ -1,14 +1,18 @@ """Events for use with manager.pys event system.""" +from __future__ import annotations import time -from typing import Any, Dict, Optional +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_entry' - DASHBOARD_ENTRY = 'core.dashboard_entry' + 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' @@ -36,50 +40,43 @@ def __init__(self, name: str, event_time: Optional[float] = None) -> None: self.time = event_time -class BaseDataEvent(BaseEvent): +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: Any = None, event_time: float = None) -> None: + def __init__(self, name: str, data: T, event_time: float = None) -> None: super().__init__(name, event_time=event_time) - self.data = data - - -class DictDataEvent(BaseDataEvent): - """Same as a data event, but promises data is a dict.""" - - def __init__(self, name: str, data: dict[Any, Any], event_time: float = None) -> None: - super().__init__(name, data=data, event_time=event_time) - self.data: dict[Any, Any] = data - - self.get = self.data.get + self.data: T = data - def __getitem__(self, name: str) -> Any: - return self.data[name] - -class JournalEvent(DictDataEvent): +class JournalEvent(BaseDataEvent[Mapping[str, Any]]): """Journal event.""" def __init__( - self, name: str, data: Dict[str, Any], cmdr: str, is_beta: bool, + 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: - self.data: dict[str, Any] # Override the definition in BaseDataEvent to be more specific super().__init__(name, data=data, event_time=event_time) self.commander = cmdr - self.is_beta = is_beta - self.system = system - self.station = station - self.state = state - - self.get = self.data.get # Ease of use wrapper + 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]]): + 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 From 860c970775bdb46eabfd3368049f8cfd5bd2c641 Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 30 Sep 2021 09:10:03 +0200 Subject: [PATCH 084/152] Integrate normal event handlers --- EDMarketConnector.py | 41 +++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index 39455b821f..cf3ae26548 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -388,7 +388,7 @@ def _(x: str) -> str: from l10n import Translations from monitor import monitor from plugin.exceptions import LegacyPluginNeedsMigrating -from plugin.manager import PluginManager +from plugin.manager import PluginManager, string_fire_results from plugin import event from protocol import protocolhandler from theme import theme @@ -451,8 +451,6 @@ def __init__(self, master: tk.Tk): # noqa: C901, CCR001 # TODO - can possibly f self.plugin_manager = PluginManager() self._load_all_plugins() - # plug.load_plugins(master) - if platform != 'darwin': if platform == 'win32': self.w.wm_iconbitmap(default='EDMarketConnector.ico') @@ -751,6 +749,7 @@ def setup_plugin_uis(self, frame: tk.Frame) -> None: for plugin_name, results in res.items(): results = cast(List[Optional[tk.Widget]], results) + results = [r for r in results if not isinstance(r, Exception)] # result = cast(Union[None, Tuple[tk.Widget, tk.Widget], tk.Widget, Any], result) if len(results) == 0: @@ -1186,14 +1185,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('core.capi_data', capi_response.capi_data) + ) + err = string_fire_results(results) + self.status['text'] = 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 @@ -1367,7 +1367,13 @@ 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 if not config.get_int('hotkey_mute'): @@ -1396,12 +1402,12 @@ 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 if not config.get_int('hotkey_mute'): @@ -1478,7 +1484,10 @@ 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.DictDataEvent( + plugin.event.EDMCPluginEvents.DASHBOARD_ENTRY, + entry + ))) if err: self.status['text'] = err if not config.get_int('hotkey_mute'): From 688010c9317cfc6eeaeffa0a16ae14c2314a1934 Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 30 Sep 2021 09:17:19 +0200 Subject: [PATCH 085/152] unstubbed as much of old plug as needed --- plug.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/plug.py b/plug.py index de1a6052a7..acbdfcaa2a 100644 --- a/plug.py +++ b/plug.py @@ -22,10 +22,10 @@ # PLUGINS_not_py3 = [] # # For asynchronous error display -# last_error = { -# 'msg': None, -# 'root': None, -# } +last_error = { + 'msg': None, + 'root': None, +} # class Plugin(object): @@ -338,18 +338,18 @@ # return error -# def show_error(err): -# """ -# Display an error message in the status line of the main window. +def show_error(err): + """ + Display an error message in the status line of the main window. -# Will be NOP during shutdown to avoid Tk hang. -# :param err: -# .. versionadded:: 2.3.7 -# """ -# if config.shutting_down: -# logger.info(f'Called during shutdown: "{str(err)}"') -# return + Will be NOP during shutdown to avoid Tk hang. + :param err: + .. versionadded:: 2.3.7 + """ + 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 err and last_error['root']: + last_error['msg'] = str(err) + last_error['root'].event_generate('<>', when="tail") From d5ef2d10d1be15efbd444f898cce42eafa47417b Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 30 Sep 2021 09:17:37 +0200 Subject: [PATCH 086/152] fixed bad import / DictDataEvent usage --- EDMarketConnector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index cf3ae26548..ccab377164 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -1484,7 +1484,7 @@ def dashboard_event(self, event) -> None: entry = dashboard.status # Currently we don't do anything with these events - err = string_fire_results(self.plugin_manager.fire_event(plugin.event.DictDataEvent( + err = string_fire_results(self.plugin_manager.fire_event(plugin.event.BaseDataEvent( plugin.event.EDMCPluginEvents.DASHBOARD_ENTRY, entry ))) From 35388f07784677f76de002f5c92887129b0f56b6 Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 30 Sep 2021 09:18:00 +0200 Subject: [PATCH 087/152] fixed typing on decorator --- plugin/decorators.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugin/decorators.py b/plugin/decorators.py index ba7cae29e7..6f3d5920db 100644 --- a/plugin/decorators.py +++ b/plugin/decorators.py @@ -1,5 +1,6 @@ """Decorators for marking plugins and callbacks.""" from __future__ import annotations +from plugin.event import BaseDataEvent from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, Optional, Type, TypeVar, Union, overload @@ -63,8 +64,8 @@ def _list_decorate(attr_name: str, attr_content: str, func: _F) -> _F: from prefs import BasePreferencesEvent _UI_SETUP = Union[ - Callable[[Any, tk.Frame], Optional[tk.Widget]], - Callable[[tk.Frame], Optional[tk.Widget]], + Callable[[Any, BaseDataEvent], Optional[tk.Widget]], + Callable[[BaseDataEvent], Optional[tk.Widget]], ] _JOURNAL_FUNC = Union[ From 1a2b0978ade5863d37f0e17835c367ea51f2025e Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 30 Sep 2021 09:18:30 +0200 Subject: [PATCH 088/152] added commander getter --- plugin/plugin.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/plugin/plugin.py b/plugin/plugin.py index 49088aa409..8017c867e0 100644 --- a/plugin/plugin.py +++ b/plugin/plugin.py @@ -103,6 +103,12 @@ 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 system, if any.""" + return monitor.monitor.cmdr + @property @final def system(self) -> str | None: From df94b62503ce22e8cb2741679f07456b17ddcd05 Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 30 Sep 2021 09:18:50 +0200 Subject: [PATCH 089/152] updated breakout LUTs to access `self` as well --- plugin/legacy_plugin.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/plugin/legacy_plugin.py b/plugin/legacy_plugin.py index 503f7165c6..0a5914cacb 100644 --- a/plugin/legacy_plugin.py +++ b/plugin/legacy_plugin.py @@ -5,12 +5,13 @@ import inspect import pathlib from types import ModuleType -from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple, Union, cast +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple, TypeVar, Union, cast import semantic_version from plugin import decorators, event from plugin.base_plugin import BasePlugin +from plugin.plugin import EDMCPlugin from plugin.exceptions import LegacyPluginHasNoStart3, LegacyPluginNeedsMigrating from plugin.plugin_info import PluginInfo @@ -42,14 +43,14 @@ 'edsm.notify_system': 'edsm_notify_system', } - -LEGACY_CALLBACK_BREAKOUT_LUT: Dict[str, Callable[..., Tuple[Any, ...]]] = { +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: (e.data,), - event.EDMCPluginEvents.PREFERENCES: lambda e: (e.notebook, e.commander, e.is_beta), + 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: (e.commander, e.is_beta, e.system, e.station, e.data, e.state), + event.EDMCPluginEvents.JOURNAL_ENTRY: lambda e, s: (s.commander, s.is_beta, s.system, s.station, e.data, s.state), # 'core.dashboard_entry': 'dashboard_entry', # 'core.commander_data': 'cmdr_data', @@ -59,7 +60,7 @@ } -class MigratedPlugin(BasePlugin): +class MigratedPlugin(EDMCPlugin): """MigratedPlugin is a wrapper for old-style plugins.""" OSTR = Optional[str] @@ -97,7 +98,7 @@ def setup_callbacks(self) -> None: continue target_name = f"_SYNTHETIC_CALLBACK_{old_callback}" - breakout = LEGACY_CALLBACK_BREAKOUT_LUT.get(new_hook, lambda e: ()) + 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)) @@ -149,8 +150,7 @@ def enforce_load3_signature(load3: Callable): f'{len(sig.parameters)}; {sig.parameters}' ) - @staticmethod - def generic_callback_handler(f: Callable, breakout: Callable[..., Tuple[Any, ...]]): + def generic_callback_handler(self, f: Callable, breakout: Callable[[event.BaseEvent, MigratedPlugin], Tuple[Any, ...]]): """ Wrap the given callback with the given event breakout. @@ -160,20 +160,23 @@ def generic_callback_handler(f: Callable, breakout: Callable[..., Tuple[Any, ... :param breakout: The breakout method """ def wrapper(e: event.BaseEvent): - return f(*breakout(e)) + return f(*breakout(e, self)) setattr(wrapper, "original_func", f) return wrapper @decorators.hook(event.EDMCPluginEvents.STARTUP_UI) - def ui_wrapper(self, frame: tk.Frame) -> Optional[tk.Widget]: + 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 - if (f := getattr(self.module, 'plugin_app')) is None: + frame: tk.Frame = data_event.data + if (f := getattr(self.module, 'plugin_app', None)) is None: return None out_frame = tk.Frame(frame) - f = cast(_LEGACY_UI_FUNC, f) + f = cast('_LEGACY_UI_FUNC', f) res = f(out_frame) + if res is None: + return None if isinstance(res, tk.Widget): return out_frame From 585239f17b3a88405d9b0de79f3f986f939eece8 Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 30 Sep 2021 09:20:39 +0200 Subject: [PATCH 090/152] added option to keep exceptions on fired events --- plugin/manager.py | 40 +++++-- plugins/new_plugins/coriolis.py | 51 -------- plugins/new_plugins/eddb.py | 201 -------------------------------- 3 files changed, 31 insertions(+), 261 deletions(-) delete mode 100644 plugins/new_plugins/coriolis.py delete mode 100644 plugins/new_plugins/eddb.py diff --git a/plugin/manager.py b/plugin/manager.py index 66ad7342da..e8f0a963ca 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -2,6 +2,7 @@ from __future__ import annotations import importlib +import itertools import pathlib import sys from fnmatch import fnmatch @@ -58,7 +59,7 @@ def log(self) -> 'LoggerMixin': """Get the plugin logger represented by this LoadedPlugin.""" return self.plugin.log - def _fire_event_funcs(self, event: BaseEvent, funcs: list[Callable]) -> list[Any]: + def _fire_event_funcs(self, event: BaseEvent, funcs: list[Callable], keep_exceptions: bool) -> list[Any]: out = [] for func in funcs: try: @@ -68,11 +69,12 @@ def _fire_event_funcs(self, event: BaseEvent, funcs: list[Callable]) -> list[Any except Exception as e: self.log.exception(f'Caught an exception while firing event {event.name!r} on func {func}') - out.append(e) + if keep_exceptions: + out.append(e) return out - def fire_event(self, event: BaseEvent) -> list[Any]: + def fire_event(self, event: BaseEvent, keep_exceptions: bool = False) -> list[Any]: """ Call all event callbacks that match the given event. @@ -87,7 +89,7 @@ def fire_event(self, event: BaseEvent) -> list[Any]: 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])) + 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 @@ -391,12 +393,12 @@ def unload_plugin(self, name: str): del self.plugins[name] - def fire_event(self, event: BaseEvent) -> Dict[str, List[Any]]: + 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}') - res = p.fire_event(event) + 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=}') @@ -404,9 +406,9 @@ def fire_event(self, event: BaseEvent) -> Dict[str, List[Any]]: return out - def fire_str_event(self, event_name: str, time: Optional[float] = None) -> Dict[str, List[Any]]: + 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)) + 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.""" @@ -442,3 +444,23 @@ def legacy_plugins(self) -> List[MigratedPlugin]: 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('_')) + + +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/plugins/new_plugins/coriolis.py b/plugins/new_plugins/coriolis.py deleted file mode 100644 index b132bba93c..0000000000 --- a/plugins/new_plugins/coriolis.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Coriolis ship export.""" - -import base64 -import gzip -import io -import json -from typing import Any, Union - -import semantic_version - -# Migrate settings from <= 3.01 -from config import config -from plugin import decorators -from plugin.plugin import EDMCPlugin -from plugin.plugin_info import PluginInfo - - -@decorators.edmc_plugin -class Coriolis(EDMCPlugin): - """Plugin to provide a link to the current ship on coriolis.io.""" - - def load(self) -> PluginInfo: - """Load the plugin.""" - self._migrate_old_configs() - - return PluginInfo( - name='Coriolis', version=semantic_version.Version('1.0.0'), authors=['The EDMC Developers'], - comment='Provides a link to the current ship on https://coriolis.io' - ) - - def _migrate_old_configs(self) -> None: - if not config.get_str('shipyard_provider') and config.get_int('shipyard'): - config.set('shipyard_provider', 'Coriolis') - - config.delete('shipyard', suppress=True) - - @decorators.provider('shipyard') - def shipyard_url(self, loadout: dict[str, Any], is_beta: bool) -> Union[str, bool]: - """Return a shipyard URL for the given loadout.""" - to_send = json.dumps(loadout, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode('uft-8') - if not to_send: - return False - - out = io.BytesIO() - with gzip.GzipFile(fileobj=out, mode='w') as f: - f.write(to_send) - - encoded = base64.urlsafe_b64encode(out.getvalue()).decode().replace('=', '%3D') - url = 'https://beta.coriolis.io/import?data=' if is_beta else 'https://coriolis.io/import?data=' - - return f'{url}{encoded}' diff --git a/plugins/new_plugins/eddb.py b/plugins/new_plugins/eddb.py deleted file mode 100644 index a55537013f..0000000000 --- a/plugins/new_plugins/eddb.py +++ /dev/null @@ -1,201 +0,0 @@ -"""Station display and eddb.io lookup.""" -from __future__ import annotations - -from typing import TYPE_CHECKING, Optional, cast - -from requests.utils import requote_uri - -import EDMCLogging -import plug -from config import config -from plugin import decorators -from plugin.event import DictDataEvent, JournalEvent -from plugin.plugin import EDMCPlugin -from plugin.plugin_info import PluginInfo -from ttkHyperlinkLabel import HyperlinkLabel - -# -*- coding: utf-8 -*- -# -# Station display and eddb.io lookup -# - -# Tests: -# -# As there's a lot of state tracking in here, need to ensure (at least) -# the URL text and link follow along correctly with: -# -# 1) Game not running, EDMC started. -# 2) Then hit 'Update' for CAPI data pull -# 3) Login fully to game, and whether #2 happened or not: -# a) If docked then update Station -# b) Either way update System -# 4) Undock, SupercruiseEntry, FSDJump should change Station text to 'x' -# and link to system one. -# 5) RequestDocking should populate Station, no matter if the request -# succeeded or not. -# 6) FSDJump should update System text+link. -# 7) Switching to a different provider and then back... combined with -# any of the above in the interim. -# - - -if TYPE_CHECKING: - from tkinter import Tk - - -logger = EDMCLogging.get_main_logger() - - -STATION_UNDOCKED: str = '×' # "Station" name to display when not docked = U+00D7 - - -@decorators.edmc_plugin -class EDDB(EDMCPlugin): - """EDDB Plugin.""" - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - self.system_link: HyperlinkLabel = None # type: ignore # They're always going to be there post-init - self.system: Optional[str] = None - self.system_address: Optional[str] = None - self.system_population: Optional[int] = None - self.station_link: HyperlinkLabel = None # type: ignore # They're always going to be there post-init - self.station: Optional[str] = None - self.station_marketid: Optional[int] = None - self.on_foot = False - - def load(self) -> PluginInfo: - """Load the plugin.""" - return PluginInfo( - name='eddb', version='1.0.0', authors=['The EDMC Authors'], - comment='Provides links for the current station and system on https://eddb.io' - ) - - @decorators.provider('system_url') - def system_url(self, system_name: str) -> str: - if self.system_address: - return requote_uri(f'https://eddb.io/system/ed-address/{self.system_address}') - - if system_name: - return requote_uri(f'https://eddb.io/system/name/{system_name}') - - return '' - - @decorators.provider('station_url') - def station_url(self, system_name: str, station_name: str) -> str: - if self.station_marketid: - return requote_uri(f'https://eddb.io/station/market-id/{self.station_marketid}') - - return self.system_url(system_name) - - @decorators.hook('core.plugin_ui') - def setup_ui(self, parent: Tk) -> None: - self.system_link = cast(HyperlinkLabel, parent.children['system']) # system label in main window - self.system = None - self.system_address = None - self.station = None - self.station_marketid = None # Frontier MarketID - self.station_link = cast(HyperlinkLabel, parent.children['station']) # station label in main window - self.station_link.configure(popup_copy=lambda x: x != STATION_UNDOCKED) - - @decorators.hook('core.journal_entry') - def update_self(self, event: JournalEvent) -> None: # noqa: CCR001 # Cant be split easily currently - """Keep track of the current system and station.""" - # TODO: All of this can likely be dropped for event.state[whatever], see - # TODO: https://github.com/EDCD/EDMarketConnector/issues/1042 - if (ks := self.killswitch.get_disabled('plugins.eddb.journal')).disabled: - logger.warning(f'Journal processing for EDDB has been disabled: {ks.reason}') - plug.show_error('EDDB Journal processing disabled. See Log') - return - - elif (ks := self.killswitch.get_disabled(f'plugins.eddb.journal.event.{event.event_name}')).disabled: - logger.warning(f'Processing of event {event.event_name} has been disabled: {ks.reason}') - return - - self.on_foot = event.state['OnFoot'] - # Always update our system address even if we're not currently the provider for system or station, - # but dont update on events that contain "future" data, such as FSDTarget - if event.event_name in ('Location', 'Docked', 'CarrierJump', 'FSDJump'): - self.system_address = event.get('SystemAddress') or self.system_address - self.system = event.get('StarSystem') or self.system - - # We need pop == 0 to set the value so as to clear 'x' in systems with - # no stations. - pop = event.get('Population') - if pop is not None: - self.system_population = pop - - self.station = event.get('StationName', self.station) - # on_foot station detection - if not self.station and event.event_name == 'Location' and event['BodyType'] == 'Station': - self.station = event['Body'] - - self.station_marketid = event.get('MarketID', self.station_marketid) - # We might pick up StationName in DockingRequested, make sure we clear it if leaving - if event.event_name in ('Undocked', 'FSDJump', 'SupercruiseEntry'): - self.station = None - self.station_marketid = None - - if event.event_name == 'Embark' and not event.get('OnStation'): - # If we're embarking OnStation to a Taxi/Dropship we'll also get an - # Undocked event. - self.station = None - self.station_marketid = None - - # Only actually update text if we are current provider. (this provides our fancy dots) - if config.get_str('system_provider') == 'eddb': # TODO: this will be messed with when providers are fleshed out - self.system_link['text'] = self.system - # Do *NOT* set 'url' here, as it's set to a function that will call - # through correctly. We don't want a static string. - self.system_link.update_idletasks() - - # But only actually change the text if we are current station provider. - if config.get_str('station_provider') == 'eddb': - text = self.station - if not text: - if self.system_population is not None and self.system_population > 0: - text = STATION_UNDOCKED - - else: - text = '' - - self.station_link['text'] = text - # Do *NOT* set 'url' here, as it's set to a function that will call - # through correctly. We don't want a static string. - self.station_link.update_idletasks() - - @decorators.hook('core.cmdr_data') - def update_cmdr(self, event: DictDataEvent): - """Update internal state with CAPI data.""" - # Always store initially, even if we're not the *current* system provider. - if not self.station_marketid and event['commander']['docked']: - self.station_marketid = event['lastStarport']['id'] - - # Only trust CAPI if these aren't yet set - if not self.system: - self.system = event['lastSystem']['name'] - - if not self.station and event['commander']['docked']: - self.station = event['lastStarport']['name'] - - # Override standard URL functions - if config.get_str('system_provider') == 'eddb': - self.system_link['text'] = self.system - # Do *NOT* set 'url' here, as it's set to a function that will call - # through correctly. We don't want a static string. - self.system_link.update_idletasks() - - if config.get_str('station_provider') == 'eddb': - if event['commander']['docked'] or self.on_foot and self.station: - self.station_link['text'] = self.station - - elif event['lastStarport']['name'] and event['lastStarport']['name'] != "": - self.station_link['text'] = STATION_UNDOCKED - - else: - self.station_link['text'] = '' - - # Do *NOT* set 'url' here, as it's set to a function that will call - # through correctly. We don't want a static string. - self.station_link.update_idletasks() From 0b9c009bc62b54da6a401a868e5faa87017bedb8 Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 30 Sep 2021 09:34:16 +0200 Subject: [PATCH 091/152] Added preferences closed support --- prefs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prefs.py b/prefs.py index 0daae8860f..20881adad7 100644 --- a/prefs.py +++ b/prefs.py @@ -1338,7 +1338,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() From f175cfa506310685a8dcaafc51043ca604337d00 Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 30 Sep 2021 09:34:24 +0200 Subject: [PATCH 092/152] updated arch --- plugin/ARCHITECTURE.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/plugin/ARCHITECTURE.md b/plugin/ARCHITECTURE.md index de0fa0ced0..f2605ddd63 100644 --- a/plugin/ARCHITECTURE.md +++ b/plugin/ARCHITECTURE.md @@ -91,19 +91,21 @@ Event names here are globbed, and thus you can hook onto all events in a given n and all events fired with the name `*`. all non-special `core` events are as follows: -| Event Name | Expected Signature | Description | -| :-------------- | :------------------------------ | --------------------------------------------------------------- | -| `journal_event` | `(event.JournalEvent) -> None` | Event fired when a new journal event is seen | -| `cmdr_data` | `(event.BaseDataEvent) -> None` | Event fired when new data comes in from CAPI TODO: `capi_data`? | +| 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` | `(tkinter.Tk) -> Optional[tkinter.Widget]` | Sets up plugins UI (Note that the old style tuple pair is NOT supported) [1] | +| 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 @@ -112,7 +114,8 @@ documented below 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`. +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. From d6146c411beb6219ed784b2c0a73743577c0ec03 Mon Sep 17 00:00:00 2001 From: A_D Date: Mon, 4 Oct 2021 12:41:15 +0200 Subject: [PATCH 093/152] Added load_all_plugins util method As a side effect, disabled_plugins is populated from here. Thus this should be used by higher level plugin load requests unless you need something specific --- plugin/manager.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/plugin/manager.py b/plugin/manager.py index e8f0a963ca..048dcbd52a 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -107,7 +107,7 @@ def __init__(self) -> None: self.log.info("starting new plugin management engine") self.plugins: Dict[str, LoadedPlugin] = {} self.failed_loading: Dict[pathlib.Path, Exception] = {} # path -> reason - # TODO: plugins that were skipped because they started with _ or . + self.disabled_plugins: List[pathlib.Path] = [] # self._plugins_previously_loaded: Set[str] = set() def find_potential_plugins(self, path: pathlib.Path) -> List[pathlib.Path]: @@ -239,6 +239,24 @@ def __get_plugin_at(self, path: pathlib.Path, autoresolve_sys_path=True) -> PLUG 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)) + + 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]]: From 912eabf2f9daf56284f6753ba6414a2d09a7aba8 Mon Sep 17 00:00:00 2001 From: A_D Date: Mon, 4 Oct 2021 12:44:49 +0200 Subject: [PATCH 094/152] fixed PluginManager.legacy_plugins returning [] --- plugin/manager.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugin/manager.py b/plugin/manager.py index 048dcbd52a..8a12da745c 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -455,8 +455,9 @@ def get_providers(self, name: str) -> List[LoadedPlugin]: return out @property - def legacy_plugins(self) -> List[MigratedPlugin]: - return [p for p in self.plugins if isinstance(p, MigratedPlugin)] + 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: From c60ec2c11b437de445fcb350867bcb00ec7a7639 Mon Sep 17 00:00:00 2001 From: A_D Date: Mon, 4 Oct 2021 12:46:10 +0200 Subject: [PATCH 095/152] fixed path autoresolution being one level too low --- plugin/manager.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/plugin/manager.py b/plugin/manager.py index 8a12da745c..6e8ce61794 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -117,7 +117,6 @@ def find_potential_plugins(self, path: pathlib.Path) -> List[pathlib.Path]: :param path: The path to search at :return: All plugins found """ - # TODO: ignore ones ending in .disabled, either here or lower down return list(filter(lambda f: f.is_dir(), path.iterdir())) @staticmethod @@ -147,14 +146,15 @@ def load_normal_plugin(self, path: pathlib.Path, autoresolve_sys_path=True) -> P :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(path.parent.absolute()) not in sys.path: - sys.path.append(str(path.parent.absolute())) + 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=path.parent.absolute()) + 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) @@ -352,8 +352,11 @@ def load_legacy_plugin(self, path: pathlib.Path) -> PLUGIN_MODULE_PAIR: # 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 str(parent) not in sys.path: + sys.path.append(str(parent)) - resolved = self.resolve_path_to_plugin(target)[:-3] # strip off .py + resolved = self.resolve_path_to_plugin(target, parent)[:-3] # strip off .py try: module = importlib.import_module(resolved) From 33bd326c233179ec84b9d4c12051fa8cce572b09 Mon Sep 17 00:00:00 2001 From: A_D Date: Mon, 4 Oct 2021 12:50:40 +0200 Subject: [PATCH 096/152] added disabled plugins to prefs plugins tab --- prefs.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/prefs.py b/prefs.py index 20881adad7..4af0ba8447 100644 --- a/prefs.py +++ b/prefs.py @@ -950,7 +950,6 @@ 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)) enabled_plugins = list(self.plugin_manager.plugins.values()) legacy_plugins = self.plugin_manager.legacy_plugins if len(enabled_plugins) > 0: @@ -1022,6 +1021,22 @@ def __setup_plugin_tab(self, notebook: Notebook) -> None: underline=True ).grid(columnspan=2, padx=self.PADX, sticky=tk.W) + 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: 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 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() + ) + # disabled = [] # TODO # disabled_plugins = list(filter(lambda x: x.folder and not x.module, plug.PLUGINS)) From 1a470c707872b7775e68ae2160a428c83a43b904 Mon Sep 17 00:00:00 2001 From: A_D Date: Mon, 4 Oct 2021 12:51:41 +0200 Subject: [PATCH 097/152] Made use of load_plugins_in, loaded third party plugins --- EDMarketConnector.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index ccab377164..a62bbc74cb 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -732,12 +732,8 @@ def __init__(self, master: tk.Tk): # noqa: C901, CCR001 # TODO - can possibly f self.toggle_suit_row(visible=False) def _load_all_plugins(self) -> None: - internal_to_load_paths: List[pathlib.Path] = [ - p for p in config.internal_plugin_dir_path.iterdir() if self.plugin_manager.is_valid_plugin_directory(p) - ] - - self.plugin_manager.load_plugins(internal_to_load_paths, autoresolve_sys_path=False) - # TODO: Load non-internal plugins + self.plugin_manager.load_all_plugins_in(config.internal_plugin_dir_path) + self.plugin_manager.load_all_plugins_in(config.plugin_dir_path) def setup_plugin_uis(self, frame: tk.Frame) -> None: """ From 74f97ea7fea429c8cc52f2d7035548da43fb786f Mon Sep 17 00:00:00 2001 From: A_D Date: Mon, 4 Oct 2021 13:25:39 +0200 Subject: [PATCH 098/152] long winded comment about self.monitor and co --- plugin/plugin.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/plugin/plugin.py b/plugin/plugin.py index 8017c867e0..ddf83cf13e 100644 --- a/plugin/plugin.py +++ b/plugin/plugin.py @@ -25,6 +25,16 @@ 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.""" From 53c909e2c5b9e6d24c64889b24f3a94e30ca31b3 Mon Sep 17 00:00:00 2001 From: A_D Date: Tue, 5 Oct 2021 06:58:17 +0200 Subject: [PATCH 099/152] added util method for provider access --- plugin/manager.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/plugin/manager.py b/plugin/manager.py index 6e8ce61794..9eee89ec5e 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -457,6 +457,15 @@ def get_providers(self, name: str) -> List[LoadedPlugin]: 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.""" From fb2e1b5abb54482ab4423d6fc3c531ec53d09842 Mon Sep 17 00:00:00 2001 From: A_D Date: Tue, 5 Oct 2021 06:58:42 +0200 Subject: [PATCH 100/152] added aliases to provider names --- plugin/provider.py | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 plugin/provider.py diff --git a/plugin/provider.py b/plugin/provider.py new file mode 100644 index 0000000000..5b2d2f056b --- /dev/null +++ b/plugin/provider.py @@ -0,0 +1,9 @@ +"""Nice aliases for standard providers.""" + + +class EDMCProviders: + """Provider name aliases.""" + + SHIPYARD = 'core.shipyard_url' + STATION = 'core.station_url' + SYSTEM = 'core.system_url' From 39e82dd692beaf3c0d275a6759daaa85c2007dfe Mon Sep 17 00:00:00 2001 From: A_D Date: Tue, 5 Oct 2021 06:59:37 +0200 Subject: [PATCH 101/152] make use of providers for get(shipyard|station|system)_url --- EDMarketConnector.py | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index a62bbc74cb..6e3f3ab2c1 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -389,6 +389,7 @@ def _(x: str) -> str: from monitor import monitor from plugin.exceptions import LegacyPluginNeedsMigrating from plugin.manager import PluginManager, string_fire_results +from plugin.provider import EDMCProviders from plugin import event from protocol import protocolhandler from theme import theme @@ -1503,22 +1504,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) + 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) @@ -1526,11 +1531,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) + if (selected := config.get_str('system_provider')) in providers: + return providers[selected](monitor.system) + + logger.warning('Unable to locate selected provider for system urls, defaulting to edsm') + return providers['EDSM'](monitor.system) 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) + if (selected := config.get_str('station_provider')) in providers: + return providers[selected](monitor.system) + + logger.warning('Unable to locate selected provider for station urls, defaulting to eddb') + return providers['eddb'](monitor.system) def cooldown(self) -> None: """Display and update the cooldown timer for 'Update' button.""" From 6927d3eb14bc6c2569f929865388d81eb0901b41 Mon Sep 17 00:00:00 2001 From: A_D Date: Tue, 5 Oct 2021 07:00:07 +0200 Subject: [PATCH 102/152] add wrappers for known providers --- plugin/legacy_plugin.py | 40 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/plugin/legacy_plugin.py b/plugin/legacy_plugin.py index 0a5914cacb..05c3ac1483 100644 --- a/plugin/legacy_plugin.py +++ b/plugin/legacy_plugin.py @@ -5,15 +5,15 @@ import inspect import pathlib from types import ModuleType -from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple, TypeVar, Union, cast +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple, Union, cast import semantic_version from plugin import decorators, event -from plugin.base_plugin import BasePlugin -from plugin.plugin import EDMCPlugin 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 @@ -59,6 +59,16 @@ # 'edsm.notify_system': 'edsm_notify_system', } +LEGACY_PROVIDER_LUT: Dict[str, str] = { + EDMCProviders.SYSTEM: 'system_url', + EDMCProviders.STATION: 'station_url', + EDMCProviders.SHIPYARD: 'shipyard_url' +} + +LEGACY_PROVIDER_CONVERT_LUT: Dict[str, Callable[..., Tuple[Tuple[Any, ...], Dict[Any, Any]]]] = { + EDMCProviders.SHIPYARD: lambda ship_name, loadout, /, self: ((loadout, self.is_beta), {}) +} # converting args from old to new + class MigratedPlugin(EDMCPlugin): """MigratedPlugin is a wrapper for old-style plugins.""" @@ -85,6 +95,7 @@ def __init__(self, logger: LoggerMixin, module: ModuleType, manager: PluginManag # 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: """ @@ -103,6 +114,20 @@ def setup_callbacks(self) -> None: 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): + 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. @@ -165,6 +190,15 @@ def wrapper(e: event.BaseEvent): setattr(wrapper, "original_func", f) return wrapper + def generic_provider_handler(self, f: Callable, convert: Callable): + def wrapper(*args, **kwargs): + new_args, new_kwargs = convert(*args, self=self, **kwargs) + return f(*new_args, **new_kwargs) + + 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.""" From 0aa89a80836dcb81dbb19648cadc63c783c0fceb Mon Sep 17 00:00:00 2001 From: A_D Date: Tue, 5 Oct 2021 07:00:17 +0200 Subject: [PATCH 103/152] cleanup imports --- plugin/event.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugin/event.py b/plugin/event.py index a0a6140afe..6b726d1592 100644 --- a/plugin/event.py +++ b/plugin/event.py @@ -1,9 +1,8 @@ """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: From b878f6cb95fb8412e335501380198e459f16d486 Mon Sep 17 00:00:00 2001 From: A_D Date: Tue, 5 Oct 2021 07:37:17 +0200 Subject: [PATCH 104/152] added info to shimmed providers --- plugin/ARCHITECTURE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugin/ARCHITECTURE.md b/plugin/ARCHITECTURE.md index f2605ddd63..cdef3ff8fe 100644 --- a/plugin/ARCHITECTURE.md +++ b/plugin/ARCHITECTURE.md @@ -71,6 +71,10 @@ 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: From 3318285a6a28c05622c0a7fce5ea5f6eafd4328b Mon Sep 17 00:00:00 2001 From: A_D Date: Tue, 5 Oct 2021 07:37:38 +0200 Subject: [PATCH 105/152] added some logging during plugin load --- EDMarketConnector.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index 6e3f3ab2c1..d05bda2bca 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -733,8 +733,12 @@ def __init__(self, master: tk.Tk): # noqa: C901, CCR001 # TODO - can possibly f 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: """ From 387c72564aa84f648da11f35e6d1c8acb9592c49 Mon Sep 17 00:00:00 2001 From: A_D Date: Tue, 5 Oct 2021 07:44:45 +0200 Subject: [PATCH 106/152] update ARCHITECTURE.md with currently known providers --- plugin/ARCHITECTURE.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/plugin/ARCHITECTURE.md b/plugin/ARCHITECTURE.md index cdef3ff8fe..bfdc0fa508 100644 --- a/plugin/ARCHITECTURE.md +++ b/plugin/ARCHITECTURE.md @@ -122,7 +122,24 @@ assuming the callback did not return `None`. If an exception was thrown by a cal 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. +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` | `(system_name: str | None) -> str` | URL to an online information dump of the current system | +| `core.station_url` | `(station_name: str | None) -> str` | URL to an online information dump about the current station | + +TODO: Possibly for system/station provide the current *ID*s of the system/station and allow plugins to use monitor +if they actually need the names? ## TODO From 28ce76cd5a298e7b004222fb9417721b0790ef8a Mon Sep 17 00:00:00 2001 From: A_D Date: Tue, 5 Oct 2021 07:44:59 +0200 Subject: [PATCH 107/152] fix line too long --- plugin/manager.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/plugin/manager.py b/plugin/manager.py index 9eee89ec5e..eca35de517 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -254,6 +254,9 @@ def load_all_plugins_in(self, plugin_dir: pathlib.Path) -> List[LoadedPlugin]: 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] @@ -274,6 +277,11 @@ def load_plugins( 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 From 58d70c64ca23a98a202fc2e19161632fd6a44177 Mon Sep 17 00:00:00 2001 From: A_D Date: Tue, 5 Oct 2021 13:18:05 +0200 Subject: [PATCH 108/152] be smarter about detecting plugins in logs --- EDMCLogging.py | 35 +++++------------------------------ 1 file changed, 5 insertions(+), 30 deletions(-) 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 From a9e27320adefe609410d440047c998c28673582c Mon Sep 17 00:00:00 2001 From: A_D Date: Tue, 5 Oct 2021 16:03:03 +0200 Subject: [PATCH 109/152] un-gut plug a bit --- plug.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/plug.py b/plug.py index acbdfcaa2a..f6f1b95c05 100644 --- a/plug.py +++ b/plug.py @@ -12,17 +12,18 @@ # from typing import Optional # import myNotebook as nb # noqa: N813 -# from config import config -# from EDMCLogging import get_main_logger +from typing import Dict, Optional +from config import config +from EDMCLogging import get_main_logger -# logger = get_main_logger() +logger = get_main_logger() # # List of loaded Plugins # PLUGINS = [] # PLUGINS_not_py3 = [] # # For asynchronous error display -last_error = { +last_error: Dict[str, Optional[str]] = { 'msg': None, 'root': None, } From 13bee55dc86e72a4b429d52f41e174daa3a0663d Mon Sep 17 00:00:00 2001 From: A_D Date: Tue, 5 Oct 2021 16:03:24 +0200 Subject: [PATCH 110/152] use constants --- EDMarketConnector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index d05bda2bca..e7d310d806 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -1187,7 +1187,7 @@ def capi_handle_response(self, event=None): # noqa: C901, CCR001 # stuff we can do when not docked results = self.plugin_manager.fire_event( - plugin.event.CAPIDataEvent('core.capi_data', capi_response.capi_data) + plugin.event.CAPIDataEvent(plugin.event.EDMCPluginEvents.CAPI_DATA, capi_response.capi_data) ) err = string_fire_results(results) self.status['text'] = err From 6105bc47e9dcda35e18839c1a31ebd454f339630 Mon Sep 17 00:00:00 2001 From: A_D Date: Tue, 5 Oct 2021 16:03:39 +0200 Subject: [PATCH 111/152] fix legacy capidata --- plugin/legacy_plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/legacy_plugin.py b/plugin/legacy_plugin.py index 05c3ac1483..20d1117fbe 100644 --- a/plugin/legacy_plugin.py +++ b/plugin/legacy_plugin.py @@ -52,7 +52,7 @@ # '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), # 'core.dashboard_entry': 'dashboard_entry', - # 'core.commander_data': 'cmdr_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', From c3c5ac741d2caf0a00e205fb2f76da325e6cedda Mon Sep 17 00:00:00 2001 From: A_D Date: Wed, 6 Oct 2021 11:19:42 +0200 Subject: [PATCH 112/152] add provides wrapper --- plug.py | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/plug.py b/plug.py index f6f1b95c05..04b3dab4b0 100644 --- a/plug.py +++ b/plug.py @@ -1,3 +1,12 @@ +from __future__ import annotations + +import warnings +# import myNotebook as nb # noqa: N813 +from typing import TYPE_CHECKING, Dict, Optional + +from config import config +from EDMCLogging import get_main_logger + # """ # Plugin hooks for EDMC - Ian Norton, Jonathan Harris # """ @@ -11,10 +20,9 @@ # from builtins import object, str # from typing import Optional -# import myNotebook as nb # noqa: N813 -from typing import Dict, Optional -from config import config -from EDMCLogging import get_main_logger + +if TYPE_CHECKING: + from plugin.manager import LoadedPlugin, PluginManager logger = get_main_logger() @@ -28,6 +36,21 @@ 'root': None, } +_OLD_PROVIDER_LUT = { + 'inara_notify_ship': 'inara.notify_ship', + 'inara_notify_location': 'inara.notify_location', +} + +_manager: Optional[PluginManager] = None + + +def provides(name: str) -> list[LoadedPlugin]: + warnings.warn('plug.py is in general deprecated. Please update to newer plugin systems', DeprecationWarning) + if _manager is None: + raise ValueError('Unexpected None Manager') + + return _manager.get_providers(_OLD_PROVIDER_LUT.get(name, name)) + ... # class Plugin(object): From 2a94d5bd4965d63497ed20e5bde3ecc8a4da8d84 Mon Sep 17 00:00:00 2001 From: A_D Date: Wed, 6 Oct 2021 11:20:10 +0200 Subject: [PATCH 113/152] dont add two callbacks for plugin_app wrappers --- plugin/legacy_plugin.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugin/legacy_plugin.py b/plugin/legacy_plugin.py index 20d1117fbe..df653c0ddb 100644 --- a/plugin/legacy_plugin.py +++ b/plugin/legacy_plugin.py @@ -29,7 +29,7 @@ ] LEGACY_CALLBACK_LUT: Dict[str, str] = { - event.EDMCPluginEvents.STARTUP_UI: 'plugin_app', + # event.EDMCPluginEvents.STARTUP_UI: 'plugin_app', event.EDMCPluginEvents.PREFERENCES: 'plugin_prefs', event.EDMCPluginEvents.PREFERENCES_CLOSED: 'prefs_changed', event.EDMCPluginEvents.JOURNAL_ENTRY: 'journal_entry', @@ -45,7 +45,7 @@ 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.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', @@ -206,6 +206,7 @@ def ui_wrapper(self, data_event: event.BaseDataEvent) -> Optional[tk.Widget]: frame: tk.Frame = data_event.data if (f := getattr(self.module, 'plugin_app', None)) is None: return None + out_frame = tk.Frame(frame) f = cast('_LEGACY_UI_FUNC', f) res = f(out_frame) From b4f4130418f6ce80370316cd96221ae98ff0ab88 Mon Sep 17 00:00:00 2001 From: A_D Date: Wed, 6 Oct 2021 11:20:38 +0200 Subject: [PATCH 114/152] create plugin loggers for legacy plugins before importing them --- plugin/manager.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/plugin/manager.py b/plugin/manager.py index eca35de517..581f3d3581 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -68,7 +68,8 @@ def _fire_event_funcs(self, event: BaseEvent, funcs: list[Callable], keep_except out.append(res) except Exception as e: - self.log.exception(f'Caught an exception while firing event {event.name!r} on func {func}') + self.log.exception( + f'Caught an exception while firing event {event.name!r} for plugin {self.info.name} (on func {func})') if keep_exceptions: out.append(e) @@ -364,16 +365,18 @@ def load_legacy_plugin(self, path: pathlib.Path) -> PLUGIN_MODULE_PAIR: 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 - logger = get_plugin_logger(path.parts[-1]) - 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 From a0503bec4079a81f7e941e682cb654709fe3708d Mon Sep 17 00:00:00 2001 From: A_D Date: Wed, 6 Oct 2021 11:21:19 +0200 Subject: [PATCH 115/152] provide plug access to the running plugin_manager --- EDMarketConnector.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index e7d310d806..ff4d0596e6 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -6,9 +6,9 @@ import html import locale import pathlib +import queue import re import sys -import queue # import threading import webbrowser from builtins import object, str @@ -18,6 +18,8 @@ from time import localtime, strftime, time from typing import TYPE_CHECKING, List, Optional, Tuple, cast +import plug + # 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 # place for things like config.py reading .gitversion @@ -387,10 +389,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 plugin import event from protocol import protocolhandler from theme import theme from ttkHyperlinkLabel import HyperlinkLabel @@ -450,6 +452,7 @@ def __init__(self, master: tk.Tk): # noqa: C901, CCR001 # TODO - can possibly f # self.systray.start() self.plugin_manager = PluginManager() + plug._manager = self.plugin_manager self._load_all_plugins() if platform != 'darwin': From 713caadf9de806ff3642c3f9d34b8f27546bed41 Mon Sep 17 00:00:00 2001 From: A_D Date: Wed, 6 Oct 2021 14:21:02 +0200 Subject: [PATCH 116/152] Made plugins UIs frames within frames Its frames all the way down! But seriously, the rationale here is that while a plugin may screw with the column count of itself, it wont screw with anything else. The number of frames is now silly, however. But that's okay I still hate tkinter. This took over an hour to get right. But it is right now, and that's what matters! --- EDMarketConnector.py | 39 ++++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index ff4d0596e6..b7d9c9acb8 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -512,7 +512,13 @@ 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 - self.setup_plugin_uis(frame) + plugin_ui_wrapper_frame = tk.Frame(frame) + # plugin_ui_wrapper_frame.columnconfigure(0, weight=1) + plugin_ui_wrapper_frame['bg'] = 'red' + + self.setup_plugin_uis(plugin_ui_wrapper_frame) + plugin_ui_wrapper_frame.grid(row=ui_row, columnspan=2) + ui_row += 1 # for plugin in plug.PLUGINS: # appitem = plugin.get_app(frame) @@ -749,26 +755,25 @@ def setup_plugin_uis(self, frame: tk.Frame) -> None: :param frame: the frame under which plugins should create their widgets. """ - res = self.plugin_manager.fire_event(event.BaseDataEvent(event.EDMCPluginEvents.STARTUP_UI, frame)) - - for plugin_name, results in res.items(): - results = cast(List[Optional[tk.Widget]], results) - results = [r for r in results if not isinstance(r, Exception)] - - # result = cast(Union[None, Tuple[tk.Widget, tk.Widget], tk.Widget, Any], result) - if len(results) == 0: + # 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 - # Separator for plugin line - tk.Frame(frame, highlightthickness=1).grid(columnspan=2, sticky=tk.EW) - logger.trace( - f'{plugin_name} has {f"{len(results)} " if len(results) > 1 else ""}startup UI elements. adding...' - ) + 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: + result.grid(sticky=tk.EW) - # WORKAROUND 19-08-2021 | mypy workaround -- filter() does not correctly re-type to FilterObj[tk.Widget] - for result in cast(List[tk.Widget], filter(lambda r: r is not None, results)): - result.grid(columnspan=2, sticky=tk.EW) + wrapper_frame.grid(sticky=tk.EW) def update_suit_text(self) -> None: """Update the suit text for current type and loadout.""" From 78e961a0136fe50c45c679499b795027f411bee7 Mon Sep 17 00:00:00 2001 From: A_D Date: Wed, 6 Oct 2021 14:29:56 +0200 Subject: [PATCH 117/152] as all plugins now get the frame-within-frame treatment, dont do it again --- plugin/legacy_plugin.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/plugin/legacy_plugin.py b/plugin/legacy_plugin.py index df653c0ddb..2ce1d88caa 100644 --- a/plugin/legacy_plugin.py +++ b/plugin/legacy_plugin.py @@ -207,14 +207,13 @@ def ui_wrapper(self, data_event: event.BaseDataEvent) -> Optional[tk.Widget]: if (f := getattr(self.module, 'plugin_app', None)) is None: return None - out_frame = tk.Frame(frame) f = cast('_LEGACY_UI_FUNC', f) - res = f(out_frame) + res = f(frame) if res is None: return None if isinstance(res, tk.Widget): - return out_frame + return res elif ( isinstance(res, tuple) From b00df08cef6764b7593ad1b4a54c5effe9d10c9e Mon Sep 17 00:00:00 2001 From: A_D Date: Wed, 6 Oct 2021 15:14:14 +0200 Subject: [PATCH 118/152] fixed tuple legacy plugins not showing UI --- EDMarketConnector.py | 4 ++++ plugin/legacy_plugin.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index b7d9c9acb8..1b30c4a5d9 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -771,6 +771,10 @@ def setup_plugin_uis(self, frame: tk.Frame) -> None: 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) diff --git a/plugin/legacy_plugin.py b/plugin/legacy_plugin.py index 2ce1d88caa..83188a2ef9 100644 --- a/plugin/legacy_plugin.py +++ b/plugin/legacy_plugin.py @@ -226,7 +226,7 @@ def ui_wrapper(self, data_event: event.BaseDataEvent) -> Optional[tk.Widget]: res[0].grid(column=0, row=0) res[1].grid(column=1, row=0) - return out_frame + return frame self.log.warning( f'plugin_app returned something unexpected: {type(res)=}, {res=}! Assuming its unsafe and bailing on its UI' From 879a9d017e6947faa95a919f30d4bc51afe1a222 Mon Sep 17 00:00:00 2001 From: A_D Date: Wed, 6 Oct 2021 16:09:11 +0200 Subject: [PATCH 119/152] fixed tests --- .gitignore | 1 + plugin/manager.py | 11 ++++++----- plugin/test/conftest.py | 4 ++-- plugin/test/test_load.py | 4 +++- .../legacy/{bad => bad_l}/import_error/load.py | 0 .../legacy/{bad => bad_l}/load_error/load.py | 0 .../legacy/{good => good_l}/all_callbacks/load.py | 0 .../legacy/{good => good_l}/simple/load.py | 0 8 files changed, 12 insertions(+), 8 deletions(-) rename plugin/test/test_plugins/legacy/{bad => bad_l}/import_error/load.py (100%) rename plugin/test/test_plugins/legacy/{bad => bad_l}/load_error/load.py (100%) rename plugin/test/test_plugins/legacy/{good => good_l}/all_callbacks/load.py (100%) rename plugin/test/test_plugins/legacy/{good => good_l}/simple/load.py (100%) 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/plugin/manager.py b/plugin/manager.py index 581f3d3581..91837866a6 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -346,7 +346,7 @@ def load_plugin(self, path: pathlib.Path, autoresolve_sys_path=True) -> Optional return loaded - def load_legacy_plugin(self, path: pathlib.Path) -> PLUGIN_MODULE_PAIR: + 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. @@ -362,11 +362,12 @@ def load_legacy_plugin(self, path: pathlib.Path) -> PLUGIN_MODULE_PAIR: # 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 str(parent) not in sys.path: - sys.path.append(str(parent)) + 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)) + if str(path) not in sys.path: + sys.path.append(str(path)) resolved = self.resolve_path_to_plugin(target, parent)[:-3] # strip off .py diff --git a/plugin/test/conftest.py b/plugin/test/conftest.py index 7b8d61b3a7..cbe6a553cd 100644 --- a/plugin/test/conftest.py +++ b/plugin/test/conftest.py @@ -17,5 +17,5 @@ def plugin_manager() -> typing.Generator[PluginManager, None, None]: good_path = current_path / 'good' bad_path = current_path / 'bad' legacy_path = current_path / 'legacy' -legacy_good_path = legacy_path / 'good' -legacy_bad_path = legacy_path / 'bad' +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_load.py b/plugin/test/test_load.py index d8c83982c7..b9051f457f 100644 --- a/plugin/test/test_load.py +++ b/plugin/test/test_load.py @@ -87,6 +87,8 @@ def test_load(plugin_manager: PluginManager, context: ContextManager, path: 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 @@ -102,7 +104,7 @@ def test_legacy_load(plugin_manager: PluginManager): # 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) + assert len(loaded.callbacks) == (len(LEGACY_CALLBACK_LUT) + 1) def test_double_load(plugin_manager: PluginManager) -> None: diff --git a/plugin/test/test_plugins/legacy/bad/import_error/load.py b/plugin/test/test_plugins/legacy/bad_l/import_error/load.py similarity index 100% rename from plugin/test/test_plugins/legacy/bad/import_error/load.py rename to plugin/test/test_plugins/legacy/bad_l/import_error/load.py diff --git a/plugin/test/test_plugins/legacy/bad/load_error/load.py b/plugin/test/test_plugins/legacy/bad_l/load_error/load.py similarity index 100% rename from plugin/test/test_plugins/legacy/bad/load_error/load.py rename to plugin/test/test_plugins/legacy/bad_l/load_error/load.py diff --git a/plugin/test/test_plugins/legacy/good/all_callbacks/load.py b/plugin/test/test_plugins/legacy/good_l/all_callbacks/load.py similarity index 100% rename from plugin/test/test_plugins/legacy/good/all_callbacks/load.py rename to plugin/test/test_plugins/legacy/good_l/all_callbacks/load.py diff --git a/plugin/test/test_plugins/legacy/good/simple/load.py b/plugin/test/test_plugins/legacy/good_l/simple/load.py similarity index 100% rename from plugin/test/test_plugins/legacy/good/simple/load.py rename to plugin/test/test_plugins/legacy/good_l/simple/load.py From 6c27df35c69b1c555f311bf3cc70f1f77f719969 Mon Sep 17 00:00:00 2001 From: A_D Date: Wed, 6 Oct 2021 16:11:17 +0200 Subject: [PATCH 120/152] pass autoresolve to lower loading machinery --- plugin/manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/manager.py b/plugin/manager.py index 91837866a6..a9e7f2d53d 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -228,7 +228,7 @@ def __get_plugin_at(self, path: pathlib.Path, autoresolve_sys_path=True) -> PLUG ) try: - plugin, module = self.load_legacy_plugin(path) + 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}') From 44581a4928fab375290e33b003ee783f815650e3 Mon Sep 17 00:00:00 2001 From: A_D Date: Wed, 6 Oct 2021 16:12:00 +0200 Subject: [PATCH 121/152] make linters happy --- plugin/manager.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/plugin/manager.py b/plugin/manager.py index a9e7f2d53d..472856bcdc 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -69,7 +69,10 @@ def _fire_event_funcs(self, event: BaseEvent, funcs: list[Callable], keep_except except Exception as e: self.log.exception( - f'Caught an exception while firing event {event.name!r} for plugin {self.info.name} (on func {func})') + 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) @@ -439,7 +442,9 @@ def fire_event(self, event: BaseEvent, keep_exceptions: bool = False) -> Dict[st return out - def fire_str_event(self, event_name: str, time: Optional[float] = None, keep_exceptions: bool = False) -> Dict[str, List[Any]]: + 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) From f0ec22d26b5cd792ca043ef5ce581d6c3091a8c2 Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 7 Oct 2021 14:12:52 +0200 Subject: [PATCH 122/152] Fix various linter errors --- plugin/decorators.py | 6 +++--- plugin/event.py | 7 ++++++- plugin/legacy_plugin.py | 5 ++++- plugin/plugin_info.py | 2 +- plugin/test/test_event.py | 2 ++ plugin/test/test_load.py | 2 ++ 6 files changed, 18 insertions(+), 6 deletions(-) diff --git a/plugin/decorators.py b/plugin/decorators.py index 6f3d5920db..ecab314f9d 100644 --- a/plugin/decorators.py +++ b/plugin/decorators.py @@ -1,12 +1,11 @@ """Decorators for marking plugins and callbacks.""" from __future__ import annotations -from plugin.event import BaseDataEvent -from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, Optional, Type, TypeVar, Union, overload +from typing import TYPE_CHECKING, Any, Callable, Literal, Optional, Type, TypeVar, Union, overload from EDMCLogging import get_main_logger from plugin.base_plugin import BasePlugin - +from plugin.event import BaseDataEvent logger = get_main_logger() @@ -63,6 +62,7 @@ def _list_decorate(attr_name: str, attr_content: str, func: _F) -> _F: from plugin.event import JournalEvent from prefs import BasePreferencesEvent + # TODO: The rest of these _UI_SETUP = Union[ Callable[[Any, BaseDataEvent], Optional[tk.Widget]], Callable[[BaseDataEvent], Optional[tk.Widget]], diff --git a/plugin/event.py b/plugin/event.py index 6b726d1592..8e73fc3e44 100644 --- a/plugin/event.py +++ b/plugin/event.py @@ -4,6 +4,9 @@ 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.""" @@ -72,10 +75,12 @@ def event_name(self) -> str: return self.data['event'] -CAPIDataEvent = BaseDataEvent['CAPIData'] +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/legacy_plugin.py b/plugin/legacy_plugin.py index 83188a2ef9..8aaabd8b27 100644 --- a/plugin/legacy_plugin.py +++ b/plugin/legacy_plugin.py @@ -175,7 +175,9 @@ def enforce_load3_signature(load3: Callable): f'{len(sig.parameters)}; {sig.parameters}' ) - def generic_callback_handler(self, f: Callable, breakout: Callable[[event.BaseEvent, MigratedPlugin], Tuple[Any, ...]]): + def generic_callback_handler( + self, f: Callable, breakout: Callable[[event.BaseEvent, MigratedPlugin], Tuple[Any, ...]] + ): """ Wrap the given callback with the given event breakout. @@ -191,6 +193,7 @@ def wrapper(e: event.BaseEvent): 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) return f(*new_args, **new_kwargs) diff --git a/plugin/plugin_info.py b/plugin/plugin_info.py index e3e4fc1170..6099c15d15 100644 --- a/plugin/plugin_info.py +++ b/plugin/plugin_info.py @@ -18,7 +18,7 @@ class PluginInfo: # TODO: implement update checking and optional downloading update_url: Optional[str] = None - def __post_init__(self): + 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/test/test_event.py b/plugin/test/test_event.py index df71353231..5970c1038c 100644 --- a/plugin/test/test_event.py +++ b/plugin/test/test_event.py @@ -6,6 +6,8 @@ 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.""" diff --git a/plugin/test/test_load.py b/plugin/test/test_load.py index b9051f457f..ca0e7eb93e 100644 --- a/plugin/test/test_load.py +++ b/plugin/test/test_load.py @@ -18,6 +18,8 @@ if TYPE_CHECKING: from plugin.manager import PluginManager +# spell-checker: words uncore + def _idfn(test_data) -> str: if not isinstance(test_data, pathlib.Path): From b9ab008977d74a4a5a207e97b39abe1986d978bf Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 7 Oct 2021 14:23:59 +0200 Subject: [PATCH 123/152] fix dashboard_entry not being correctly called --- plugin/legacy_plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/legacy_plugin.py b/plugin/legacy_plugin.py index 8aaabd8b27..73f71a176b 100644 --- a/plugin/legacy_plugin.py +++ b/plugin/legacy_plugin.py @@ -51,7 +51,7 @@ # '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), - # 'core.dashboard_entry': 'dashboard_entry', + 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', From 9e19fd4a23201c762ab690bb6b31754e4a429c4c Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 7 Oct 2021 14:36:52 +0200 Subject: [PATCH 124/152] cannot use TYPE_CHECKING guarded imports in non-annotations --- plugin/event.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/event.py b/plugin/event.py index 8e73fc3e44..cc1963fc8c 100644 --- a/plugin/event.py +++ b/plugin/event.py @@ -75,7 +75,7 @@ def event_name(self) -> str: return self.data['event'] -CAPIDataEvent = BaseDataEvent[CAPIData] +CAPIDataEvent = BaseDataEvent['CAPIData'] class DashboardEvent(BaseDataEvent[Mapping[str, Any]]): From f419ce4ac603a20e9e2e1a11bf4902ff749f8097 Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 7 Oct 2021 15:42:18 +0200 Subject: [PATCH 125/152] added the rest of the overloads to `hook` --- plugin/decorators.py | 57 +++++++++++++++++++++++++++++--------------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/plugin/decorators.py b/plugin/decorators.py index ecab314f9d..d1ced79697 100644 --- a/plugin/decorators.py +++ b/plugin/decorators.py @@ -1,11 +1,12 @@ """Decorators for marking plugins and callbacks.""" from __future__ import annotations -from typing import TYPE_CHECKING, Any, Callable, Literal, Optional, Type, TypeVar, Union, overload +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 +from plugin.event import BaseDataEvent, BaseEvent +from prefs import PreferencesEvent logger = get_main_logger() @@ -59,34 +60,52 @@ def _list_decorate(attr_name: str, attr_content: str, func: _F) -> _F: # 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 BasePreferencesEvent # TODO: The rest of these - _UI_SETUP = Union[ - Callable[[Any, BaseDataEvent], Optional[tk.Widget]], - Callable[[BaseDataEvent], Optional[tk.Widget]], + _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], ] - _JOURNAL_FUNC = Union[ - Callable[[JournalEvent], None], - Callable[[Any, JournalEvent], None] - ] + _NOTIFY_FUNC = Union[Callable[[Any, BaseEvent], None], Callable[[BaseEvent], None]] - _PLUGIN_PREFS_FUNC = TypeVar('_PLUGIN_PREFS_FUNC', bound=Union[ - Callable[[BasePreferencesEvent], None], - Callable[[Any, BasePreferencesEvent], 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[[_UI_SETUP], _UI_SETUP]: ... - - +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]: ... From 0632ca404097197a8398563c0627687cb6e3f0a1 Mon Sep 17 00:00:00 2001 From: A_D Date: Sun, 10 Oct 2021 10:48:48 +0200 Subject: [PATCH 126/152] fix import location --- plugin/decorators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/decorators.py b/plugin/decorators.py index d1ced79697..ef7184c9ec 100644 --- a/plugin/decorators.py +++ b/plugin/decorators.py @@ -6,7 +6,6 @@ from EDMCLogging import get_main_logger from plugin.base_plugin import BasePlugin from plugin.event import BaseDataEvent, BaseEvent -from prefs import PreferencesEvent logger = get_main_logger() @@ -62,6 +61,7 @@ def _list_decorate(attr_name: str, attr_content: str, func: _F) -> _F: from companion import CAPIData from plugin.event import JournalEvent + from prefs import PreferencesEvent # TODO: The rest of these _TKW = TypeVar('_TKW', bound=tk.Widget) From 6192d9581bae148695198ae0b86be5264e30c120 Mon Sep 17 00:00:00 2001 From: A_D Date: Sun, 10 Oct 2021 19:02:32 +0200 Subject: [PATCH 127/152] fixed plugin frame not being sticky additionally fixed that the columns werent weighted right --- EDMarketConnector.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index 1b30c4a5d9..9ce74214b1 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -514,10 +514,11 @@ def __init__(self, master: tk.Tk): # noqa: C901, CCR001 # TODO - can possibly f plugin_ui_wrapper_frame = tk.Frame(frame) # plugin_ui_wrapper_frame.columnconfigure(0, weight=1) - plugin_ui_wrapper_frame['bg'] = 'red' + plugin_ui_wrapper_frame['bg'] = 'red' # TODO: Remove self.setup_plugin_uis(plugin_ui_wrapper_frame) - plugin_ui_wrapper_frame.grid(row=ui_row, columnspan=2) + 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: From 06edf5ddfe08dc42ef01ddcb348a9ff9b1012974 Mon Sep 17 00:00:00 2001 From: A_D Date: Tue, 12 Oct 2021 13:56:57 +0200 Subject: [PATCH 128/152] reimplemented plug methods specifically, show_error, provides, and invoke --- plug.py | 398 ++++++++++++-------------------------------------------- 1 file changed, 83 insertions(+), 315 deletions(-) diff --git a/plug.py b/plug.py index 04b3dab4b0..5659d387e0 100644 --- a/plug.py +++ b/plug.py @@ -2,7 +2,7 @@ import warnings # import myNotebook as nb # noqa: N813 -from typing import TYPE_CHECKING, Dict, Optional +from typing import TYPE_CHECKING, Any, Dict, Optional from config import config from EDMCLogging import get_main_logger @@ -23,6 +23,13 @@ if TYPE_CHECKING: from plugin.manager import LoadedPlugin, PluginManager + from tkinter import Tk + from typing import TypedDict + + class LastError(TypedDict): + msg: str | None + root: Tk + ... logger = get_main_logger() @@ -31,9 +38,9 @@ # PLUGINS_not_py3 = [] # # For asynchronous error display -last_error: Dict[str, Optional[str]] = { +last_error: LastError = { 'msg': None, - 'root': None, + 'root': None, # type: ignore } _OLD_PROVIDER_LUT = { @@ -44,322 +51,83 @@ _manager: Optional[PluginManager] = None -def provides(name: str) -> list[LoadedPlugin]: +def provides(name: str) -> list[str]: + """ + Find plugins that provide a given function. + + Note this is a STUB that makes use of the provider system internally, + if possible. + + :param name: The name to look for. + :return: A list of plugin names. + """ warnings.warn('plug.py is in general deprecated. Please update to newer plugin systems', DeprecationWarning) if _manager is None: raise ValueError('Unexpected None Manager') - return _manager.get_providers(_OLD_PROVIDER_LUT.get(name, name)) - ... - -# 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 - - -# def load_plugins(master): -# """ -# 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)] - - -# def invoke(plugin_name, fallback, fn_name, *args): -# """ -# 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) - - -# 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') - -# logger.info('Done') - -# 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. -# """ -# 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') - - -# 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. -# """ -# 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') - - -# 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) -# """ - -# 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 - -# except Exception: -# logger.exception(f'Plugin "{plugin.name}" failed while handling CQC mode journal entry') - -# return error - - -# def notify_dashboard_entry(cmdr, is_beta, entry): -# """ -# 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) -# """ -# 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 + providers = _manager.get_providers(_OLD_PROVIDER_LUT.get(name, name)) + for plugin in _manager.legacy_plugins: + if plugin in providers: + continue + + # only do this for legacy plugins. new-style plugins should register + # stuff as providers, even for old-style access. + if getattr(plugin.module, name): + providers.append(plugin) + + return [p.info.name for p in providers] + + +def _invoke_function(plugin: LoadedPlugin, name: str, args: tuple[Any, ...], kwargs: dict[Any, Any]) -> Any: + """ + Invoke the given provider name. + + If the provider does not exist, and the plugin is a MigratedPlugin, attempt to invoke the name directly. + """ + 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 + + logger.info(f'name {name!r} invoked via plug on {plugin!r}') + + 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 + + if not callable(attr): + logger.warning(f'Found {name!r} on {plugin!r}, but it is not callable! {attr=}, {type(attr)=}') + return None + + return attr(*args, **kwargs) + + +def invoke(plugin: LoadedPlugin | str, fallback: str, func_name: str, *args, **kwargs) -> 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. + """ + 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): From a721fe0e750042c5bef14c60caccb2b6f7c25310 Mon Sep 17 00:00:00 2001 From: A_D Date: Tue, 12 Oct 2021 13:57:46 +0200 Subject: [PATCH 129/152] made unload/load raise NotImplementedError --- plugin/base_plugin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugin/base_plugin.py b/plugin/base_plugin.py index 27fd1af4a9..1b3ec971a5 100644 --- a/plugin/base_plugin.py +++ b/plugin/base_plugin.py @@ -40,10 +40,11 @@ def load(self) -> PluginInfo: 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) From 29255afce75445c1ff8039ce96109940a1c7cf69 Mon Sep 17 00:00:00 2001 From: A_D Date: Tue, 12 Oct 2021 13:58:19 +0200 Subject: [PATCH 130/152] added repr method --- plugin/base_plugin.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugin/base_plugin.py b/plugin/base_plugin.py index 1b3ec971a5..7bf6ce2ccb 100644 --- a/plugin/base_plugin.py +++ b/plugin/base_plugin.py @@ -64,3 +64,6 @@ def _find_marked_funcs(self, marker) -> Dict[str, List[Callable]]: 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})' From e50a1974d133adc4a81703f120f2dc38f957a4f3 Mon Sep 17 00:00:00 2001 From: A_D Date: Tue, 12 Oct 2021 13:58:58 +0200 Subject: [PATCH 131/152] added is_legacy utility property --- plugin/manager.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugin/manager.py b/plugin/manager.py index 472856bcdc..29c06a028d 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -59,6 +59,10 @@ 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: From f3802e79457c606516e5cc92051ff37793cb7950 Mon Sep 17 00:00:00 2001 From: A_D Date: Tue, 12 Oct 2021 14:01:01 +0200 Subject: [PATCH 132/152] beginning of replacement of plug.show_error featuree --- plugin/manager.py | 3 +++ plugin/plugin.py | 9 +++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/plugin/manager.py b/plugin/manager.py index 29c06a028d..752e3aec17 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -6,6 +6,7 @@ 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: @@ -118,6 +119,8 @@ def __init__(self) -> None: self.disabled_plugins: List[pathlib.Path] = [] # self._plugins_previously_loaded: Set[str] = set() + self.status_msg_queue: Queue[str] = Queue(-1) + def find_potential_plugins(self, path: pathlib.Path) -> List[pathlib.Path]: """ Search for plugins at the given path. diff --git a/plugin/plugin.py b/plugin/plugin.py index ddf83cf13e..800d4e0c79 100644 --- a/plugin/plugin.py +++ b/plugin/plugin.py @@ -59,14 +59,15 @@ def translate(self, s: str, context: Optional[str] = None) -> str: return l10n.Translations.translate(s, context=context) @final - def show_error(self, msg: str) -> None: + def show_status_msg(self, msg: str) -> None: """ - Show an error on the UI and log it. + Show a message on the main UI status bar. + + This relies on crossing a few times. It may not be instant. But it will not block plugin code. :param msg: The message to show """ - self.log.error(msg) - raise NotImplementedError + self._manager.status_msg_queue.put(msg) # Properties for accessing various bits of EDMC data From fe3a29f20e35076aebf93cf6169b17238e1d90a2 Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 14 Oct 2021 16:01:16 +0200 Subject: [PATCH 133/152] getattr != hasattr --- plug.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plug.py b/plug.py index 5659d387e0..b74cea1083 100644 --- a/plug.py +++ b/plug.py @@ -72,7 +72,7 @@ def provides(name: str) -> list[str]: # only do this for legacy plugins. new-style plugins should register # stuff as providers, even for old-style access. - if getattr(plugin.module, name): + if hasattr(plugin.module, name): providers.append(plugin) return [p.info.name for p in providers] From 8ee2a234f29f95b66b2d2cdc1f3a7f21772ee3a6 Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 14 Oct 2021 20:27:12 +0200 Subject: [PATCH 134/152] fixed nameerror, cleaned up imports --- plug.py | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/plug.py b/plug.py index b74cea1083..6294583a38 100644 --- a/plug.py +++ b/plug.py @@ -1,32 +1,20 @@ +"""EDMC Legacy plugin stubs.""" from __future__ import annotations import warnings -# import myNotebook as nb # noqa: N813 -from typing import TYPE_CHECKING, Any, Dict, Optional +from typing import TYPE_CHECKING, Any, Optional from config import config from EDMCLogging import get_main_logger - -# """ -# 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 - +from plugin.manager import LoadedPlugin, PluginManager if TYPE_CHECKING: - from plugin.manager import LoadedPlugin, PluginManager from tkinter import Tk from typing import TypedDict class LastError(TypedDict): + """LastError TypedDict.""" + msg: str | None root: Tk ... From 65b44be135885278eb06f8514f53acb6ef6b3888 Mon Sep 17 00:00:00 2001 From: A_D Date: Fri, 15 Oct 2021 16:15:44 +0200 Subject: [PATCH 135/152] reimplement status line message system --- EDMarketConnector.py | 46 +++++++++++++++++++++++++++++++++++--------- plug.py | 8 +++++--- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index 9ce74214b1..161abc996f 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -9,14 +9,14 @@ import queue import re import sys -# import threading +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, List, Optional, Tuple, cast +from typing import TYPE_CHECKING, Any, List, Optional, Tuple, cast import plug @@ -450,8 +450,9 @@ 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() - + self.status_msg_queue: queue.Queue[str] = queue.Queue() self.plugin_manager = PluginManager() + threading.Thread(name='edmc-statusmsg', target=self.status_msg_thread, daemon=True).start() plug._manager = self.plugin_manager self._load_all_plugins() @@ -714,7 +715,7 @@ 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.show_status_msg) # Statusbar self.w.bind_all('<>', self.auth) # cAPI auth self.w.bind_all('<>', self.onexit) # Updater @@ -1507,14 +1508,41 @@ def dashboard_event(self, event) -> None: 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'] + def status_msg_thread(self): + """Monitor the plugin managers status message queue.""" + logger.info('starting statusmsg thread') + while True: + msg = self.plugin_manager.status_msg_queue.get() + self.status_msg_queue.put(msg) + + while True: + try: + next = self.plugin_manager.status_msg_queue.get_nowait() + self.status_msg_queue.put(next) + except queue.Empty: + break + + self.w.event_generate('<>', data=msg, when='tail') + self.status_msg_queue.join() # wait until everythings been processed + + def show_status_msg(self, event: tk.Event): + """Show all status messages in the queue.""" + while True: + try: + msg = self.status_msg_queue.get_nowait() + except queue.Empty: + return + + if not msg: + continue + + self.status['text'] = msg self.w.update_idletasks() - if not config.get_int('hotkey_mute'): + if not config.get_bool('hotkey_mute'): hotkeymgr.play_bad() + self.status_msg_queue.task_done() + def shipyard_url(self, shipname: str) -> str: """Despatch a ship URL to the configured handler.""" if not (loadout := monitor.ship()): diff --git a/plug.py b/plug.py index 6294583a38..4254ccad3c 100644 --- a/plug.py +++ b/plug.py @@ -130,6 +130,8 @@ def show_error(err): logger.info(f'Called during shutdown: "{str(err)}"') return - if err and last_error['root']: - last_error['msg'] = str(err) - last_error['root'].event_generate('<>', when="tail") + if _manager is None: + raise ValueError('Unexpected None Manager') + + _manager.status_msg_queue.put(str(err)) + logger.error(err) From 3b5d60334492ce8b8880fb690bbf15d189785210 Mon Sep 17 00:00:00 2001 From: A_D Date: Sat, 16 Oct 2021 13:08:33 +0200 Subject: [PATCH 136/152] remove names from URL providers these are available on the plugin itself --- plugin/ARCHITECTURE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugin/ARCHITECTURE.md b/plugin/ARCHITECTURE.md index bfdc0fa508..911cce3634 100644 --- a/plugin/ARCHITECTURE.md +++ b/plugin/ARCHITECTURE.md @@ -135,8 +135,8 @@ possible. | Provider | Expected Signature | Return Description | | :------------------ | :---------------------------------------------- | ----------------------------------------------------------- | | `core.shipyard_url` | `(ship_name: str, loadout: LoadoutDict) -> str` | URL to an online shipyard | -| `core.system_url` | `(system_name: str | None) -> str` | URL to an online information dump of the current system | -| `core.station_url` | `(station_name: str | None) -> str` | URL to an online information dump about the current station | +| `core.system_url` | `() -> str` | URL to an online information dump of the current system | +| `core.station_url` | `() -> str` | URL to an online information dump about the current station | TODO: Possibly for system/station provide the current *ID*s of the system/station and allow plugins to use monitor if they actually need the names? From 6c98082e0842a1e64f46f18fb125e75caef217ba Mon Sep 17 00:00:00 2001 From: A_D Date: Sat, 16 Oct 2021 13:09:00 +0200 Subject: [PATCH 137/152] add type overloads to @provider --- plugin/decorators.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/plugin/decorators.py b/plugin/decorators.py index ef7184c9ec..ee9dde1593 100644 --- a/plugin/decorators.py +++ b/plugin/decorators.py @@ -123,7 +123,32 @@ def _decorate(func: _F) -> _F: return _decorate -def provider(name: str) -> Callable[[_F], _F]: +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. From a18895e0051145a3d86e40288e9a6feae3729699 Mon Sep 17 00:00:00 2001 From: A_D Date: Sat, 16 Oct 2021 13:09:16 +0200 Subject: [PATCH 138/152] fix legacy providers --- plugin/legacy_plugin.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/plugin/legacy_plugin.py b/plugin/legacy_plugin.py index 73f71a176b..71ef2fcdf1 100644 --- a/plugin/legacy_plugin.py +++ b/plugin/legacy_plugin.py @@ -60,13 +60,15 @@ } LEGACY_PROVIDER_LUT: Dict[str, str] = { - EDMCProviders.SYSTEM: 'system_url', - EDMCProviders.STATION: 'station_url', - EDMCProviders.SHIPYARD: 'shipyard_url' + 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: lambda ship_name, loadout, /, self: ((loadout, self.is_beta), {}) + 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 @@ -122,6 +124,7 @@ def setup_providers(self) -> None: continue def default_wrapper(*args, **kwargs): + kwargs.pop('self', None) return args, kwargs convert = LEGACY_PROVIDER_CONVERT_LUT.get(new_name, default_wrapper) @@ -196,7 +199,11 @@ 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) - return f(*new_args, **new_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) From e1d5f59e63ed6c89d75df71e6845dc9caca8bf31 Mon Sep 17 00:00:00 2001 From: A_D Date: Sat, 16 Oct 2021 13:10:08 +0200 Subject: [PATCH 139/152] respect .disabled --- plugin/manager.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugin/manager.py b/plugin/manager.py index 752e3aec17..085ede8b1e 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -498,7 +498,9 @@ def legacy_plugins(self) -> List[LoadedPlugin]: @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('_')) + 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: From ac8699b42ac2981091d5c4f32eaece8b3d814c34 Mon Sep 17 00:00:00 2001 From: A_D Date: Sat, 16 Oct 2021 13:11:14 +0200 Subject: [PATCH 140/152] add system_address, system_population, and station_marketid properties --- plugin/plugin.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/plugin/plugin.py b/plugin/plugin.py index 800d4e0c79..7f2b15462b 100644 --- a/plugin/plugin.py +++ b/plugin/plugin.py @@ -117,7 +117,7 @@ def is_beta(self) -> bool: @property @final def commander(self) -> str | None: - """Return the current system, if any.""" + """Return the current commander, if any.""" return monitor.monitor.cmdr @property @@ -126,12 +126,30 @@ 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]: From 29c5607400352d830c5a2328f4c45026e5d7627f Mon Sep 17 00:00:00 2001 From: A_D Date: Sat, 16 Oct 2021 13:11:36 +0200 Subject: [PATCH 141/152] rename constants for providers --- plugin/provider.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/plugin/provider.py b/plugin/provider.py index 5b2d2f056b..45553d0a59 100644 --- a/plugin/provider.py +++ b/plugin/provider.py @@ -4,6 +4,8 @@ class EDMCProviders: """Provider name aliases.""" - SHIPYARD = 'core.shipyard_url' - STATION = 'core.station_url' - SYSTEM = 'core.system_url' + 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' From 43c8607b228855c8b60201ed1d59d38d27a5eed7 Mon Sep 17 00:00:00 2001 From: A_D Date: Sat, 16 Oct 2021 13:12:28 +0200 Subject: [PATCH 142/152] update location text on every journal event --- EDMarketConnector.py | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index 161abc996f..64ab29862f 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -781,6 +781,29 @@ def setup_plugin_uis(self, frame: tk.Frame) -> None: 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']: @@ -1428,6 +1451,8 @@ def crewroletext(role: str) -> str: 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: @@ -1549,7 +1574,7 @@ def shipyard_url(self, shipname: str) -> str: logger.warning('No ship loadout, aborting.') return '' - providers = self.plugin_manager.get_providers_dict(EDMCProviders.SHIPYARD) + 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: @@ -1576,16 +1601,16 @@ def shipyard_url(self, shipname: str) -> str: def system_url(self, system: str) -> str: """Despatch a system URL to the configured handler.""" - providers = self.plugin_manager.get_providers_dict(EDMCProviders.SYSTEM) + providers = self.plugin_manager.get_providers_dict(EDMCProviders.SYSTEM_URL) if (selected := config.get_str('system_provider')) in providers: - return providers[selected](monitor.system) + return providers[selected]() logger.warning('Unable to locate selected provider for system urls, defaulting to edsm') - return providers['EDSM'](monitor.system) + return providers['EDSM']() def station_url(self, station: str) -> str: """Despatch a station URL to the configured handler.""" - providers = self.plugin_manager.get_providers_dict(EDMCProviders.STATION) + providers = self.plugin_manager.get_providers_dict(EDMCProviders.STATION_URL) if (selected := config.get_str('station_provider')) in providers: return providers[selected](monitor.system) From b119771d55b7987f2ba2fa00cbed6b72277ee115 Mon Sep 17 00:00:00 2001 From: A_D Date: Sat, 16 Oct 2021 13:12:50 +0200 Subject: [PATCH 143/152] more deprecation warnings --- plug.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plug.py b/plug.py index 4254ccad3c..1d792fb777 100644 --- a/plug.py +++ b/plug.py @@ -105,6 +105,7 @@ def invoke(plugin: LoadedPlugin | str, fallback: str, func_name: str, *args, **k :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. """ + warnings.warn('plug.py is in general deprecated. Please update to newer plugin systems', DeprecationWarning) if _manager is None: raise ValueError('Unexpected None Manager') @@ -126,6 +127,7 @@ 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 From 74f8561f9be7883293606dfc1518603041ac45dd Mon Sep 17 00:00:00 2001 From: A_D Date: Sat, 16 Oct 2021 13:13:09 +0200 Subject: [PATCH 144/152] remove unused code --- prefs.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/prefs.py b/prefs.py index 4af0ba8447..7c801c594c 100644 --- a/prefs.py +++ b/prefs.py @@ -1037,24 +1037,6 @@ def __setup_plugin_tab(self, notebook: Notebook) -> None: columnspan=2, padx=self.PADX*2, sticky=tk.W, row=row.get() ) - # disabled = [] # TODO - - # disabled_plugins = list(filter(lambda x: x.folder and not x.module, plug.PLUGINS)) - # if len(disabled_plugins): - # 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 - # 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( - # 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 From 5b1e1aa21088b16bac885d389535c32025d3892a Mon Sep 17 00:00:00 2001 From: A_D Date: Sat, 16 Oct 2021 13:18:18 +0200 Subject: [PATCH 145/152] fix overlapping plugin directory text box --- prefs.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/prefs.py b/prefs.py index 7c801c594c..3dd22474a3 100644 --- a/prefs.py +++ b/prefs.py @@ -929,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, From 9fa7c47603b6d32bbd81a3ab34e76d620afed4d7 Mon Sep 17 00:00:00 2001 From: A_D Date: Sun, 17 Oct 2021 00:12:10 +0200 Subject: [PATCH 146/152] didnt properly fix that --- EDMarketConnector.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index 64ab29862f..2fd9b43470 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -1612,10 +1612,10 @@ def station_url(self, station: str) -> str: """Despatch a station URL to the configured handler.""" providers = self.plugin_manager.get_providers_dict(EDMCProviders.STATION_URL) if (selected := config.get_str('station_provider')) in providers: - return providers[selected](monitor.system) + return providers[selected]() logger.warning('Unable to locate selected provider for station urls, defaulting to eddb') - return providers['eddb'](monitor.system) + return providers['eddb']() def cooldown(self) -> None: """Display and update the cooldown timer for 'Update' button.""" From f97185c8504afbf39bc6cc2f1e7ce62fd2f94bf5 Mon Sep 17 00:00:00 2001 From: A_D Date: Sun, 17 Oct 2021 15:52:48 +0200 Subject: [PATCH 147/152] Use a StringVar for statusbar messages I... Have no idea why this wasn't a thing before. Its exactly the "correct" way to do this. There's no thread issues, no timing issues. Just nice, simple, string setting. --- EDMarketConnector.py | 119 +++++++++++++++++-------------------------- plug.py | 2 +- plugin/manager.py | 7 ++- plugin/plugin.py | 4 +- 4 files changed, 53 insertions(+), 79 deletions(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index 2fd9b43470..50a349a1dc 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -450,9 +450,8 @@ 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() - self.status_msg_queue: queue.Queue[str] = queue.Queue() - self.plugin_manager = PluginManager() - threading.Thread(name='edmc-statusmsg', target=self.status_msg_thread, daemon=True).start() + self.status_text = tk.StringVar() + self.plugin_manager = PluginManager(self.set_status_msg) plug._manager = self.plugin_manager self._load_all_plugins() @@ -537,7 +536,7 @@ def __init__(self, master: tk.Tk): # noqa: C901, CCR001 # TODO - can possibly f # 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) @@ -715,7 +714,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.show_status_msg) # Statusbar self.w.bind_all('<>', self.auth) # cAPI auth self.w.bind_all('<>', self.onexit) # Updater @@ -881,7 +879,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 @@ -938,7 +936,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 @@ -954,7 +952,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 @@ -965,11 +963,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() @@ -985,7 +983,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 @@ -993,12 +991,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 @@ -1027,31 +1025,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: @@ -1063,7 +1061,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 @@ -1072,7 +1070,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() @@ -1113,24 +1111,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 @@ -1227,7 +1229,7 @@ def capi_handle_response(self, event=None): # noqa: C901, CCR001 plugin.event.CAPIDataEvent(plugin.event.EDMCPluginEvents.CAPI_DATA, capi_response.capi_data) ) err = string_fire_results(results) - self.status['text'] = err + self.set_status_msg(err) # Export market data if not self.export_market_data(capi_response.capi_data): @@ -1243,7 +1245,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 @@ -1255,7 +1257,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: @@ -1265,24 +1267,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() @@ -1394,7 +1399,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() @@ -1413,7 +1418,7 @@ def crewroletext(role: str) -> str: ))) if err: - self.status['text'] = err + self.set_status_msg(err) if not config.get_int('hotkey_mute'): hotkeymgr.play_bad() @@ -1447,7 +1452,7 @@ def crewroletext(role: str) -> str: ))) if err: - self.status['text'] = err + self.set_status_msg(err) if not config.get_int('hotkey_mute'): hotkeymgr.play_bad() @@ -1495,7 +1500,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 @@ -1505,11 +1510,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() @@ -1529,44 +1534,12 @@ def dashboard_event(self, event) -> None: entry ))) if err: - self.status['text'] = err + self.set_status_msg(err) if not config.get_int('hotkey_mute'): hotkeymgr.play_bad() - def status_msg_thread(self): - """Monitor the plugin managers status message queue.""" - logger.info('starting statusmsg thread') - while True: - msg = self.plugin_manager.status_msg_queue.get() - self.status_msg_queue.put(msg) - - while True: - try: - next = self.plugin_manager.status_msg_queue.get_nowait() - self.status_msg_queue.put(next) - except queue.Empty: - break - - self.w.event_generate('<>', data=msg, when='tail') - self.status_msg_queue.join() # wait until everythings been processed - - def show_status_msg(self, event: tk.Event): - """Show all status messages in the queue.""" - while True: - try: - msg = self.status_msg_queue.get_nowait() - except queue.Empty: - return - - if not msg: - continue - - self.status['text'] = msg - self.w.update_idletasks() - if not config.get_bool('hotkey_mute'): - hotkeymgr.play_bad() - - self.status_msg_queue.task_done() + 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.""" @@ -1801,7 +1774,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...') diff --git a/plug.py b/plug.py index 1d792fb777..a76d11fb8c 100644 --- a/plug.py +++ b/plug.py @@ -135,5 +135,5 @@ def show_error(err): if _manager is None: raise ValueError('Unexpected None Manager') - _manager.status_msg_queue.put(str(err)) + _manager.show_status_msg(str(err)) logger.error(err) diff --git a/plugin/manager.py b/plugin/manager.py index 085ede8b1e..c3056f6582 100644 --- a/plugin/manager.py +++ b/plugin/manager.py @@ -111,7 +111,7 @@ def provides(self, name: str) -> Optional[Callable]: class PluginManager: """PluginManager is an event engine and plugin engine.""" - def __init__(self) -> None: + 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] = {} @@ -119,7 +119,10 @@ def __init__(self) -> None: self.disabled_plugins: List[pathlib.Path] = [] # self._plugins_previously_loaded: Set[str] = set() - self.status_msg_queue: Queue[str] = Queue(-1) + 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]: """ diff --git a/plugin/plugin.py b/plugin/plugin.py index 7f2b15462b..2c758d95d8 100644 --- a/plugin/plugin.py +++ b/plugin/plugin.py @@ -63,11 +63,9 @@ def show_status_msg(self, msg: str) -> None: """ Show a message on the main UI status bar. - This relies on crossing a few times. It may not be instant. But it will not block plugin code. - :param msg: The message to show """ - self._manager.status_msg_queue.put(msg) + self._manager.show_status_msg(msg) # Properties for accessing various bits of EDMC data From fb33d3687fe3b59914cee7546af898e995a5adc8 Mon Sep 17 00:00:00 2001 From: A_D Date: Mon, 18 Oct 2021 15:48:55 +0200 Subject: [PATCH 148/152] remove protocolhandler --- EDMarketConnector.py | 1 - 1 file changed, 1 deletion(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index 50a349a1dc..d119660510 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -393,7 +393,6 @@ def _(x: str) -> str: from plugin.exceptions import LegacyPluginNeedsMigrating from plugin.manager import PluginManager, string_fire_results from plugin.provider import EDMCProviders -from protocol import protocolhandler from theme import theme from ttkHyperlinkLabel import HyperlinkLabel From 9f99a2822d4d2e785be472a6e6bf593037e9cc45 Mon Sep 17 00:00:00 2001 From: A_D Date: Mon, 18 Oct 2021 15:58:23 +0200 Subject: [PATCH 149/152] add system and station text providers to arch file --- plugin/ARCHITECTURE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugin/ARCHITECTURE.md b/plugin/ARCHITECTURE.md index 911cce3634..8a747bdd56 100644 --- a/plugin/ARCHITECTURE.md +++ b/plugin/ARCHITECTURE.md @@ -136,7 +136,9 @@ possible. | :------------------ | :---------------------------------------------- | ----------------------------------------------------------- | | `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: Possibly for system/station provide the current *ID*s of the system/station and allow plugins to use monitor if they actually need the names? From ec448155945afb7b1d0a362b2f3f016c93ac5c72 Mon Sep 17 00:00:00 2001 From: A_D Date: Mon, 18 Oct 2021 17:22:16 +0200 Subject: [PATCH 150/152] import cleanup --- EDMarketConnector.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index d119660510..dc8591b786 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -9,16 +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, Any, List, Optional, Tuple, cast - -import plug +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 @@ -551,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': From 1547abc584e0e907279584960dcb2c2d5a47f946 Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 28 Oct 2021 15:50:59 +0200 Subject: [PATCH 151/152] added todo --- plugin/ARCHITECTURE.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/plugin/ARCHITECTURE.md b/plugin/ARCHITECTURE.md index 8a747bdd56..4a3e7dc583 100644 --- a/plugin/ARCHITECTURE.md +++ b/plugin/ARCHITECTURE.md @@ -140,13 +140,11 @@ possible. | `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: Possibly for system/station provide the current *ID*s of the system/station and allow plugins to use monitor -if they actually need the names? ## 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 -- Integrate into EDMC - - Replacement for legacy functions that are deprecationwarning-ed to hell From cb3ebba351b76a6f2483161c03abf217656808c3 Mon Sep 17 00:00:00 2001 From: A_D Date: Thu, 28 Oct 2021 15:51:28 +0200 Subject: [PATCH 152/152] Added access to config --- plugin/plugin.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/plugin/plugin.py b/plugin/plugin.py index 2c758d95d8..09bb4973f7 100644 --- a/plugin/plugin.py +++ b/plugin/plugin.py @@ -22,6 +22,7 @@ if TYPE_CHECKING: import semantic_version + from config import AbstractConfig from EDMCLogging import LoggerMixin from plugin.manager import PluginManager @@ -153,3 +154,9 @@ def station_marketid(self) -> int | None: 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