From 8ff0096eb61960fd23285a97b7d34178592ea033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Mon, 30 Mar 2015 16:50:06 +0200 Subject: [PATCH] Fix & Docs: Plugins may only have one mixin implementation Multiple mixins are allowed of course. Allowing multiple implementations lead to too many problems due to plugin names for referring to the APIs of SimpleApiPlugins or the assets of AssetPlugins. Hence __plugin_implementations__ has been deprecated in favor of __plugin_implementation__. The plugin subsystem will automatically copy the first implementation from __plugin_implementations__ to __plugin_implementation__ and log a deprecation warning. Adjusted documentation accordingly. Also added docs for helpers. --- CHANGELOG.md | 3 +- docs/plugins/concepts.rst | 75 +++++- docs/plugins/gettingstarted.rst | 22 +- docs/plugins/infrastructure.rst | 4 +- src/octoprint/filemanager/__init__.py | 4 +- src/octoprint/plugin/__init__.py | 8 +- src/octoprint/plugin/core.py | 251 ++++++++++++++---- src/octoprint/plugin/types.py | 6 +- src/octoprint/plugins/cura/__init__.py | 2 +- src/octoprint/plugins/discovery/__init__.py | 14 +- src/octoprint/printer/standard.py | 4 +- src/octoprint/server/__init__.py | 25 +- src/octoprint/server/api/__init__.py | 19 +- src/octoprint/server/api/settings.py | 7 +- src/octoprint/server/apps/__init__.py | 2 +- src/octoprint/server/util/sockjs.py | 6 +- src/octoprint/settings.py | 2 +- src/octoprint/slicing/__init__.py | 2 +- tests/plugin/_plugins/deprecated_plugin.py | 16 ++ .../plugin/_plugins/mixed_plugin/__init__.py | 2 +- tests/plugin/_plugins/settings_plugin.py | 2 +- tests/plugin/_plugins/startup_plugin.py | 2 +- tests/plugin/test_core.py | 70 +++-- tests/slicing/test_slicingmanager.py | 2 +- 24 files changed, 404 insertions(+), 146 deletions(-) create mode 100644 tests/plugin/_plugins/deprecated_plugin.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 95c09d71..92ce050c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,7 +37,8 @@ * Controls for adjusting feed and flow rate factor added to Controls ([#362](https://github.com/foosel/OctoPrint/issues/362)) * Custom controls now also support slider controls * Custom controls now support a row layout -* Users can now define custom GCODE scripts to run upon starting/pausing/resuming/success/failure of a print +* Users can now define custom GCODE scripts to run upon starting/pausing/resuming/success/failure of a print or for + custom controls ([#457](https://github.com/foosel/OctoPrint/issues/457), [#347](https://github.com/foosel/OctoPrint/issues/347)) ### Improvements diff --git a/docs/plugins/concepts.rst b/docs/plugins/concepts.rst index a5405e81..368f51e4 100644 --- a/docs/plugins/concepts.rst +++ b/docs/plugins/concepts.rst @@ -20,7 +20,7 @@ Mixins Plugin mixins are the heart of OctoPrint's plugin system. They are :ref:`special base classes ` which are to be subclassed and extended to add functionality to OctoPrint. Plugins declare their instances that -implement one or multiple mixins using the ``__plugin_implementations__`` control property. OctoPrint's plugin core +implement one or multiple mixins using the ``__plugin_implementation__`` control property. OctoPrint's plugin core collects those from the plugins and offers methods to access them based on the mixin type, which get used at multiple locations within OctoPrint. @@ -135,7 +135,7 @@ If you want your hook handler to be an instance method of a mixin implementation need access to instance variables handed to your implementation via mixin invocations), you can get this work by using a small trick. Instead of defining it directly via ``__plugin_hooks__`` utilize the ``__plugin_init__`` property instead, manually instantiate your implementation instance and then add its hook handler method to the -``__plugin_hooks__`` property and itself to the ``__plugin_implementations__`` property. See the following example. +``__plugin_hooks__`` property and itself to the ``__plugin_implementation__`` property. See the following example. .. onlineinclude:: https://raw.githubusercontent.com/OctoPrint/Plugin-Examples/master/custom_action_command.py :linenos: @@ -153,3 +153,74 @@ property instead, manually instantiate your implementation instance and then add Helpers ------- + +Helpers are methods that plugin can exposed to other plugins in order to make common functionality available on the +system. They are registered with the OctoPrint plugin system through the use of the control property ``__plugin_helpers__``. + +An example for providing a couple of helper functions to the system can be found in the +`Discovery Plugin `_, +which provides it's SSDP browsing and Zeroconf browsing and publishing functions as helper methods. + +.. code-block:: python + :linenos: + :emphasize-lines: 11-20 + :caption: Excerpt from the Discovery Plugin showing the declaration of its exported helpers. + :name: sec-plugin-concepts-helpers-example-export + + def __plugin_init__(): + if not pybonjour: + # no pybonjour available, we can't use that + logging.getLogger("octoprint.plugins." + __name__).info("pybonjour is not installed, Zeroconf Discovery won't be available") + + plugin = DiscoveryPlugin() + + global __plugin_implementation__ + __plugin_implementation__ = plugin + + global __plugin_helpers__ + __plugin_helpers__ = dict( + ssdp_browse=plugin.ssdp_browse + ) + if pybonjour: + __plugin_helpers__.update(dict( + zeroconf_browse=plugin.zeroconf_browse, + zeroconf_register=plugin.zeroconf_register, + zeroconf_unregister=plugin.zeroconf_unregister + )) + +An example of how to use helpers can be found in the `Growl Plugin `_. +Using :meth:`~octoprint.plugin.code.PluginManager.get_helpers` plugins can retrieve exported helper methods and call +them as (hopefully) documented. + +.. code-block:: python + :linenos: + :emphasize-lines: 6-8,20 + :caption: Excerpt from the Growl Plugin showing utilization of the helpers published by the Discovery Plugin. + :name: sec-plugin-concepts-helpers-example-usage + + def on_after_startup(self): + host = self._settings.get(["hostname"]) + port = self._settings.getInt(["port"]) + password = self._settings.get(["password"]) + + helpers = self._plugin_manager.get_helpers("discovery", "zeroconf_browse") + if helpers and "zeroconf_browse" in helpers: + self.zeroconf_browse = helpers["zeroconf_browse"] + + self.growl, _ = self._register_growl(host, port, password=password) + + # ... + + def on_api_get(self, request): + if not self.zeroconf_browse: + return flask.jsonify(dict( + browsing_enabled=False + )) + + browse_results = self.zeroconf_browse("_gntp._tcp", block=True) + growl_instances = [dict(name=v["name"], host=v["host"], port=v["port"]) for v in browse_results] + + return flask.jsonify(dict( + browsing_enabled=True, + growl_instances=growl_instances + )) \ No newline at end of file diff --git a/docs/plugins/gettingstarted.rst b/docs/plugins/gettingstarted.rst index bac44528..66a22a98 100644 --- a/docs/plugins/gettingstarted.rst +++ b/docs/plugins/gettingstarted.rst @@ -53,14 +53,14 @@ Apart from being discovered by OctoPrint, our plugin does nothing yet. We want t __plugin_name__ = "Hello World" __plugin_version__ = "1.0" __plugin_description__ = "A quick \"Hello World\" example plugin for OctoPrint" - __plugin_implementations__ = [HelloWorldPlugin()] + __plugin_implementation__ = HelloWorldPlugin() and restart OctoPrint. You now get this output in the log:: 2015-01-27 11:17:10,792 - octoprint.plugins.helloworld - INFO - Hello World! Neat, isn't it? We added a custom class that subclasses one of OctoPrint's :ref:`plugin mixins ` -with :class:`~octoprint.plugin.StartupPlugin` and another control property, ``__plugin_implementations__``, that instantiates +with :class:`~octoprint.plugin.StartupPlugin` and another control property, ``__plugin_implementation__``, that instantiates our plugin class and tells OctoPrint about it. Taking a look at the documentation of :class:`~octoprint.plugin.StartupPlugin` we see that this mixin offers two methods that get called by OctoPrint during startup of the server, :func:`~octoprint.plugin.StartupPlugin.on_startup` and :func:`~octoprint.plugin.StartupPlugin.on_after_startup`. We decided to add our logging output by overriding :func:`~octoprint.plugin.StartupPlugin.on_after_startup`, but we could also have @@ -169,7 +169,7 @@ and ``__plugin_description__``: def on_after_startup(self): self._logger.info("Hello World!") - __plugin_implementations__ = [HelloWorldPlugin()] + __plugin_implementation__ = HelloWorldPlugin() and restart OctoPrint:: @@ -192,7 +192,7 @@ Our "Hello World" Plugin still gets detected fine, but it's now listed under the self._logger.info("Hello World!") __plugin_name__ = "Hello World" - __plugin_implementations__ = [HelloWorldPlugin()] + __plugin_implementation__ = HelloWorldPlugin() Restart OctoPrint again:: @@ -238,7 +238,7 @@ add the :class:`TemplatePlugin` to our ``HelloWorldPlugin`` class: self._logger.info("Hello World!") __plugin_name__ = "Hello World" - __plugin_implementations__ = [HelloWorldPlugin()] + __plugin_implementation__ = HelloWorldPlugin() Next, we'll create a sub folder ``templates`` underneath our ``octoprint_helloworld`` folder, and within that a file ``helloworld_navbar.jinja2`` like so: @@ -304,7 +304,7 @@ Let's take a look at how all that would look in our plugin's ``__init__.py``: return dict(url="https://en.wikipedia.org/wiki/Hello_world") __plugin_name__ = "Hello World" - __plugin_implementations__ = [HelloWorldPlugin()] + __plugin_implementation__ = HelloWorldPlugin() Restart OctoPrint. You should see something like this:: @@ -341,7 +341,7 @@ Adjust your plugin's ``__init__.py`` like this: return dict(url=self._settings.get(["url"])) __plugin_name__ = "Hello World" - __plugin_implementations__ = [HelloWorldPlugin()] + __plugin_implementation__ = HelloWorldPlugin() Also adjust your plugin's ``templates/helloworld_navbar.jinja2`` like this: @@ -451,7 +451,7 @@ again since we don't use that anymore: ] __plugin_name__ = "Hello World" - __plugin_implementations__ = [HelloWorldPlugin()] + __plugin_implementation__ = HelloWorldPlugin() Restart OctoPrint and shift-reload your browser. Your link in the navigation bar should still point to the URL we defined in ``config.yaml`` earlier. Open the "Settings" and click on the new "Hello World" entry that shows up under @@ -550,7 +550,7 @@ like so: ) __plugin_name__ = "Hello World" - __plugin_implementations__ = [HelloWorldPlugin()] + __plugin_implementation__ = HelloWorldPlugin() Note how we did not add another entry to the return value of :func:`~octoprint.plugin.TemplatePlugin.get_template_configs`. Remember how we only added those since we wanted OctoPrint to use existing bindings on our navigation bar and settings @@ -731,7 +731,7 @@ a reference to our CSS file: ) __plugin_name__ = "Hello World" - __plugin_implementations__ = [HelloWorldPlugin()] + __plugin_implementation__ = HelloWorldPlugin() Restart OctoPrint, shift-reload your browser and take a look. Everything should still look like before, but now OctoPrint linked to our stylesheet and the style information for the ``iframe`` is taken from that instead of @@ -801,7 +801,7 @@ Then adjust our returned assets to include our LESS file as well: ) __plugin_name__ = "Hello World" - __plugin_implementations__ = [HelloWorldPlugin()] + __plugin_implementation__ = HelloWorldPlugin() and enable LESS mode by adjusting one of OctoPrint's ``devel`` flags via the ``config.yaml`` file: diff --git a/docs/plugins/infrastructure.rst b/docs/plugins/infrastructure.rst index 52f058e1..94a192c8 100644 --- a/docs/plugins/infrastructure.rst +++ b/docs/plugins/infrastructure.rst @@ -21,8 +21,8 @@ Control Properties provided. ``__plugin_license__`` License of your plugin, optional, overrides the license specified in ``setup.py`` if provided. -``__plugin_implementations__`` - Instances of one or more of the various :ref:`plugin mixins ` +``__plugin_implementation__`` + Instance of an implementation of one or more :ref:`plugin mixins ` ``__plugin_hooks__`` Handlers for one or more of the various :ref:`plugin hooks ` ``__plugin_check__`` diff --git a/src/octoprint/filemanager/__init__.py b/src/octoprint/filemanager/__init__.py index e4a646ff..068caa2e 100644 --- a/src/octoprint/filemanager/__init__.py +++ b/src/octoprint/filemanager/__init__.py @@ -257,11 +257,11 @@ class FileManager(object): if progress_int: def call_plugins(slicer, source_location, source_path, dest_location, dest_path, progress): - for name, plugin in self._progress_plugins.items(): + for plugin in self._progress_plugins: try: plugin.on_slicing_progress(slicer, source_location, source_path, dest_location, dest_path, progress) except: - self._logger.exception("Exception while sending slicing progress to plugin %s" % name) + self._logger.exception("Exception while sending slicing progress to plugin %s" % plugin._identifier) import threading thread = threading.Thread(target=call_plugins, args=(slicer, source_location, source_path, dest_location, dest_path, progress_int)) diff --git a/src/octoprint/plugin/__init__.py b/src/octoprint/plugin/__init__.py index 0bf57c78..96cd50d5 100644 --- a/src/octoprint/plugin/__init__.py +++ b/src/octoprint/plugin/__init__.py @@ -164,16 +164,16 @@ def call_plugin(types, method, args=None, kwargs=None, callback=None, error_call kwargs = dict() plugins = plugin_manager().get_implementations(*types) - for name, plugin in plugins.items(): + for plugin in plugins: if hasattr(plugin, method): try: result = getattr(plugin, method)(*args, **kwargs) if callback: - callback(name, plugin, result) + callback(plugin._identifier, plugin, result) except Exception as exc: - logging.getLogger(__name__).exception("Error while calling plugin %s" % name) + logging.getLogger(__name__).exception("Error while calling plugin %s" % plugin._identifier) if error_callback: - error_callback(name, plugin, exc) + error_callback(plugin._identifier, plugin, exc) class PluginSettings(object): diff --git a/src/octoprint/plugin/core.py b/src/octoprint/plugin/core.py index 38f0551d..5b1f831f 100644 --- a/src/octoprint/plugin/core.py +++ b/src/octoprint/plugin/core.py @@ -70,8 +70,20 @@ class PluginInfo(object): attr_hooks = '__plugin_hooks__' """ Module attribute from which to retrieve the plugin's provided hooks. """ + attr_implementation = '__plugin_implementation__' + """ Module attribute from which to retrieve the plugin's provided mixin implementation. """ + attr_implementations = '__plugin_implementations__' - """ Module attribute from which to retrieve the plugin's provided implementations. """ + """ + Module attribute from which to retrieve the plugin's provided implementations. + + This deprecated attribute will only be used if a plugin does not yet offer :attr:`attr_implementation`. Only the + first entry will be evaluated. + + .. deprecated:: 1.2.0-dev-694 + + Use :attr:`attr_implementation` instead. + """ attr_helpers = '__plugin_helpers__' """ Module attribute from which to retrieve the plugin's provided helpers. """ @@ -97,6 +109,25 @@ class PluginInfo(object): self._url = url self._license = license + self._validate() + + def _validate(self): + # if the plugin still uses __plugin_implementations__, log a deprecation warning and put the first + # item into __plugin_implementation__ + if hasattr(self.instance, self.__class__.attr_implementations): + if not hasattr(self.instance, self.__class__.attr_implementation): + # deprecation warning + import warnings + warnings.warn("{name} uses deprecated control property __plugin_implementations__, use __plugin_implementation__ instead - only the first implementation of {name} will be recognized".format(name=self.key), DeprecationWarning) + + # put first item into __plugin_implementation__ + implementations = getattr(self.instance, self.__class__.attr_implementations) + if len(implementations) > 0: + setattr(self.instance, self.__class__.attr_implementation, implementations[0]) + + # delete __plugin_implementations__ + delattr(self.instance, self.__class__.attr_implementations) + def __str__(self): if self.version: return "{name} ({version})".format(name=self.name, version=self.version) @@ -134,24 +165,23 @@ class PluginInfo(object): return None return self.hooks[hook] - def get_implementations(self, *types): + def get_implementation(self, *types): """ Arguments: types (list): List of :class:`Plugin` sub classes all returned implementations need to implement. Returns: - ~__builtin__.set: The plugin's implementations matching all of the requested ``types``. Might be empty. + object: The plugin's implementation if it matches all of the requested ``types``, None otherwise. """ - result = set() - for implementation in self.implementations: - matches_all = True - for type in types: - if not isinstance(implementation, type): - matches_all = False - if matches_all: - result.add(implementation) - return result + if not self.implementation: + return None + + for t in types: + if not isinstance(self.implementation, t): + return None + + return self.implementation @property def name(self): @@ -232,7 +262,7 @@ class PluginInfo(object): return self._get_instance_attribute(self.__class__.attr_hooks, default={}) @property - def implementations(self): + def implementation(self): """ Implementations provided by the plugin. Will be taken from the implementations attribute of the plugin module as defined in :attr:`attr_implementations` if available, otherwise an empty list is returned. @@ -240,7 +270,7 @@ class PluginInfo(object): Returns: list: Implementations provided by the plugin. """ - return self._get_instance_attribute(self.__class__.attr_implementations, default=[]) + return self._get_instance_attribute(self.__class__.attr_implementation, default=None) @property def helpers(self): @@ -310,7 +340,7 @@ class PluginManager(object): self.plugins = dict() self.plugin_hooks = defaultdict(list) - self.plugin_implementations = defaultdict(set) + self.plugin_implementations = dict() self.plugin_implementations_by_type = defaultdict(list) self.disabled_plugins = dict() @@ -460,12 +490,13 @@ class PluginManager(object): # evaluate registered implementations for plugin_type in self.plugin_types: - implementations = plugin.get_implementations(plugin_type) - self.plugin_implementations_by_type[plugin_type] += ( (name, implementation) for implementation in implementations ) + implementation = plugin.get_implementation(plugin_type) + if implementation is not None: + self.plugin_implementations_by_type[plugin_type].append((name, implementation)) - plugin_implementations = plugin.get_implementations() - if len(plugin_implementations): - self.plugin_implementations[name].update(plugin_implementations) + plugin_implementation = plugin.get_implementation() + if plugin_implementation is not None: + self.plugin_implementations[name] = plugin_implementation except: self.logger.exception("There was an error loading plugin %s" % name) @@ -474,7 +505,7 @@ class PluginManager(object): else: self.logger.info("Found {count} plugin(s) providing {implementations} mixin implementations, {hooks} hook handlers".format( count=len(self.plugins) + len(self.disabled_plugins), - implementations=sum(map(lambda x: len(x), self.plugin_implementations.values())), + implementations=len(self.plugin_implementations), hooks=sum(map(lambda x: len(x), self.plugin_hooks.values())) )) @@ -484,40 +515,40 @@ class PluginManager(object): if additional_inject_factories is None: additional_inject_factories = [] - for name, implementations in self.plugin_implementations.items(): + for name, implementation in self.plugin_implementations.items(): plugin = self.plugins[name] - for implementation in implementations: - try: - kwargs = dict(additional_injects) + try: + kwargs = dict(additional_injects) - kwargs.update(dict( - identifier=name, - plugin_name=plugin.name, - plugin_version=plugin.version, - basefolder=os.path.realpath(plugin.location), - logger=logging.getLogger(self.logging_prefix + name), - )) + kwargs.update(dict( + identifier=name, + plugin_name=plugin.name, + plugin_version=plugin.version, + basefolder=os.path.realpath(plugin.location), + logger=logging.getLogger(self.logging_prefix + name), + )) - # inject the additional_injects - for arg, value in kwargs.items(): - setattr(implementation, "_" + arg, value) + # inject the additional_injects + for arg, value in kwargs.items(): + setattr(implementation, "_" + arg, value) - # inject any injects produced in the additional_inject_factories - for factory in additional_inject_factories: - try: - return_value = factory(name, implementation) - except: - self.logger.exception("Exception while executing injection factory %r" % factory) - else: - if return_value is not None: - if isinstance(return_value, dict): - for arg, value in return_value.items(): - setattr(implementation, "_" + arg, value) + # inject any injects produced in the additional_inject_factories + for factory in additional_inject_factories: + try: + return_value = factory(name, implementation) + except: + self.logger.exception("Exception while executing injection factory %r" % factory) + else: + if return_value is not None: + if isinstance(return_value, dict): + for arg, value in return_value.items(): + setattr(implementation, "_" + arg, value) - implementation.initialize() - except: - self.logger.exception("Exception while initializing plugin") - # TODO disable plugin! + # allow implementations to be disabled here if False is returned + implementation.initialize() + except: + self.logger.exception("Exception while initializing plugin") + # TODO disable plugin! self.logger.debug("Initialized {count} plugin mixin implementation(s)".format(count=len(self.plugin_implementations))) @@ -539,17 +570,72 @@ class PluginManager(object): ) ))) - def get_plugin(self, name): - if not name in self.plugins: - return None - return self.plugins[name].instance + def get_plugin(self, identifier, require_enabled=True): + """ + Retrieves the module of the plugin identified by ``identifier``. If the plugin is not registered or disabled and + ``required_enabled`` is True (the default) None will be returned. + + Arguments: + identifier (str): The identifier of the plugin to retrieve. + require_enabled (boolean): Whether to only return the plugin if is enabled (True, default) or also if it's + disabled. + + Returns: + module: The requested plugin module or None + """ + + plugin_info = self.get_plugin_info(identifier, require_enabled=require_enabled) + if plugin_info is not None: + return plugin_info.instance + return None + + def get_plugin_info(self, identifier, require_enabled=True): + """ + Retrieves the :class:`PluginInfo` instance identified by ``identifier``. If the plugin is not registered or + disabled and ``required_enabled`` is True (the default) None will be returned. + + Arguments: + identifier (str): The identifier of the plugin to retrieve. + require_enabled (boolean): Whether to only return the plugin if is enabled (True, default) or also if it's + disabled. + + Returns: + ~.PluginInfo: The requested :class:`PluginInfo` or None + """ + + if identifier in self.plugins: + return self.plugins[identifier] + elif not require_enabled and identifier in self.disabled_plugins: + return self.disabled_plugins[identifier] + + return None def get_hooks(self, hook): + """ + Retrieves all registered handlers for the specified hook. + + Arguments: + hook (str): The hook for which to retrieve the handlers. + + Returns: + dict: A dict containing all registered handlers mapped by their plugin's identifier. + """ + if not hook in self.plugin_hooks: return dict() return {hook[0]: hook[1] for hook in self.plugin_hooks[hook]} def get_implementations(self, *types): + """ + Get all mixin implementations that implement *all* of the provided ``types``. + + Arguments: + types (one or more type): The types a mixin implementation needs to implement in order to be returned. + + Returns: + list: A list of all found implementations + """ + result = None for t in types: @@ -561,9 +647,40 @@ class PluginManager(object): if result is None: return dict() - return {impl[0]: impl[1] for impl in result} + return [impl[1] for impl in result] + + def get_filtered_implementations(self, f, *types): + """ + Get all mixin implementation that implementat *all* of the provided ``types`` and match the provided filter `f`. + + Arguments: + f (callable): A filter function returning True for implementations to return and False for those to exclude. + types (one or more type): The types a mixin implementation needs to implement in order to be returned. + + Returns: + list: A list of all found and matching implementations. + """ + + assert callable(f) + implementations = self.get_implementations(*types) + return filter(f, implementations) def get_helpers(self, name, *helpers): + """ + Retrieves the named ``helpers`` for the plugin with identifier ``name``. + + If the plugin is not available, returns None. Otherwise returns a :class:`dict` with the requested plugin + helper names mapped to the method - if a helper could not be resolved, it will be missing from the dict. + + Arguments: + name (str): Identifier of the plugin for which to look up the ``helpers``. + helpers (one or more str): Identifiers of the helpers of plugin ``name`` to return. + + Returns: + dict: A dictionary of all resolved helpers, mapped by their identifiers, or None if the plugin was not + registered with the system. + """ + if not name in self.plugins: return None plugin = self.plugins[name] @@ -574,17 +691,35 @@ class PluginManager(object): else: return all_helpers - def register_client(self, client): + def register_message_receiver(self, client): + """ + Registers a ``client`` for receiving plugin messages. The ``client`` needs to be a callable accepting two + input arguments, ``plugin`` (the sending plugin's identifier) and ``data`` (the message itself). + """ + if client is None: return self.registered_clients.append(client) - def unregister_client(self, client): + def unregister_message_receiver(self, client): + """ + Unregisters a ``client`` for receiving plugin messages. + """ + self.registered_clients.remove(client) def send_plugin_message(self, plugin, data): + """ + Sends ``data`` in the name of ``plugin`` to all currently registered message receivers by invoking them + with the two arguments. + + Arguments: + plugin (str): The sending plugin's identifier. + data (object): The message. + """ + for client in self.registered_clients: - try: client.sendPluginMessage(plugin, data) + try: client(plugin, data) except: self.logger.exception("Exception while sending plugin data to client") diff --git a/src/octoprint/plugin/types.py b/src/octoprint/plugin/types.py index 02d61973..fdaff494 100644 --- a/src/octoprint/plugin/types.py +++ b/src/octoprint/plugin/types.py @@ -522,7 +522,7 @@ class SimpleApiPlugin(OctoPrintPlugin): def on_api_get(self, request): return flask.jsonify(foo="bar") - __plugin_implementations__ = [MySimpleApiPlugin()] + __plugin_implementation__ = MySimpleApiPlugin() Our plugin defines two commands, ``command1`` with no mandatory parameters and ``command2`` with one @@ -635,7 +635,7 @@ class BlueprintPlugin(OctoPrintPlugin): return flask.make_response("Expected a text to echo back.", 400) return flask.request.values["text"] - __plugin_implementations__ = [MyBlueprintPlugin()] + __plugin_implementation__ = MyBlueprintPlugin() Your blueprint will be published by OctoPrint under the base URL ``/plugin//``, so the above example of a plugin with the identifier "myblueprintplugin" would be reachable under @@ -761,7 +761,7 @@ class SettingsPlugin(OctoPrintPlugin): some_flag = self._settings.get_boolean(["sub", "some_flag"]) self._logger.info("some_setting = {some_setting}, some_value = {some_value}, sub.some_flag = {some_flag}".format(**locals()) - __plugin_implementations__ = [MySettingsPlugin()] + __plugin_implementation__ = MySettingsPlugin() Of course, you are always free to completely override both :func:`on_settings_load` and :func:`on_settings_save` if the default implementations do not fit your requirements. diff --git a/src/octoprint/plugins/cura/__init__.py b/src/octoprint/plugins/cura/__init__.py index 9209ada5..8b74a032 100644 --- a/src/octoprint/plugins/cura/__init__.py +++ b/src/octoprint/plugins/cura/__init__.py @@ -420,4 +420,4 @@ __plugin_author__ = "Gina Häußge" __plugin_url__ = "https://github.com/foosel/OctoPrint/wiki/Plugin:-Cura" __plugin_description__ = "Adds support for slicing via CuraEngine from within OctoPrint" __plugin_license__ = "AGPLv3" -__plugin_implementations__ = [CuraPlugin()] \ No newline at end of file +__plugin_implementation__ = CuraPlugin() \ No newline at end of file diff --git a/src/octoprint/plugins/discovery/__init__.py b/src/octoprint/plugins/discovery/__init__.py index 8ded1122..90009281 100644 --- a/src/octoprint/plugins/discovery/__init__.py +++ b/src/octoprint/plugins/discovery/__init__.py @@ -33,20 +33,20 @@ def __plugin_init__(): # no pybonjour available, we can't use that logging.getLogger("octoprint.plugins." + __name__).info("pybonjour is not installed, Zeroconf Discovery won't be available") - discovery_plugin = DiscoveryPlugin() + plugin = DiscoveryPlugin() - global __plugin_implementations__ - __plugin_implementations__ = [discovery_plugin] + global __plugin_implementation__ + __plugin_implementation__ = plugin global __plugin_helpers__ __plugin_helpers__ = dict( - ssdp_browse=discovery_plugin.ssdp_browse + ssdp_browse=plugin.ssdp_browse ) if pybonjour: __plugin_helpers__.update(dict( - zeroconf_browse=discovery_plugin.zeroconf_browse, - zeroconf_register=discovery_plugin.zeroconf_register, - zeroconf_unregister=discovery_plugin.zeroconf_unregister + zeroconf_browse=plugin.zeroconf_browse, + zeroconf_register=plugin.zeroconf_register, + zeroconf_unregister=plugin.zeroconf_unregister )) class DiscoveryPlugin(octoprint.plugin.StartupPlugin, diff --git a/src/octoprint/printer/standard.py b/src/octoprint/printer/standard.py index 289741b5..5d248cda 100644 --- a/src/octoprint/printer/standard.py +++ b/src/octoprint/printer/standard.py @@ -172,11 +172,11 @@ class Printer(PrinterInterface, comm.MachineComPrintCallback): filename = self._selectedFile["filename"] def call_plugins(storage, filename, progress): - for name, plugin in self._progressPlugins.items(): + for plugin in self._progressPlugins: try: plugin.on_print_progress(storage, filename, progress) except: - self._logger.exception("Exception while sending print progress to plugin %s" % name) + self._logger.exception("Exception while sending print progress to plugin %s" % plugin._identifier) thread = threading.Thread(target=call_plugins, args=(storage, filename, progress)) thread.daemon = False diff --git a/src/octoprint/server/__init__.py b/src/octoprint/server/__init__.py index b04be2ff..2a625d5d 100644 --- a/src/octoprint/server/__init__.py +++ b/src/octoprint/server/__init__.py @@ -175,7 +175,8 @@ def index(): assets["stylesheets"].append(("css", url_for('static', filename='css/octoprint.css'))) asset_plugins = pluginManager.get_implementations(octoprint.plugin.AssetPlugin) - for name, implementation in asset_plugins.items(): + for implementation in asset_plugins: + name = implementation._identifier all_assets = implementation.get_assets() if "js" in all_assets: @@ -285,8 +286,11 @@ def index(): ) plugin_vars = dict() - plugin_names = template_plugins.keys() - for name, implementation in template_plugins.items(): + plugin_names = set() + for implementation in template_plugins: + name = implementation._identifier + plugin_names.add(name) + vars = implementation.get_template_vars() if not isinstance(vars, dict): vars = dict() @@ -458,11 +462,15 @@ def robotsTxt(): @app.route("/plugin_assets//") def plugin_assets(name, filename): - asset_plugins = pluginManager.get_implementations(octoprint.plugin.AssetPlugin) + asset_plugins = pluginManager.get_filtered_implementations(lambda p: p._identifier == name, octoprint.plugin.AssetPlugin) - if not name in asset_plugins: + if not asset_plugins: return make_response("Asset not found", 404) - asset_plugin = asset_plugins[name] + + if len(asset_plugins) > 1: + return make_response("More than one asset provider for {name}, can't proceed".format(name=name), 500) + + asset_plugin = asset_plugins[0] asset_folder = asset_plugin.get_asset_folder() if asset_folder is None: return make_response("Asset not found", 404) @@ -598,7 +606,7 @@ class Server(): # configure additional template folders for jinja2 template_plugins = pluginManager.get_implementations(octoprint.plugin.TemplatePlugin) additional_template_folders = [] - for plugin in template_plugins.values(): + for plugin in template_plugins: folder = plugin.get_template_folder() if folder is not None: additional_template_folders.append(plugin.get_template_folder()) @@ -671,7 +679,8 @@ class Server(): # also register any blueprints defined in BlueprintPlugins blueprint_plugins = octoprint.plugin.plugin_manager().get_implementations(octoprint.plugin.BlueprintPlugin) - for name, plugin in blueprint_plugins.items(): + for plugin in blueprint_plugins: + name = plugin._identifier blueprint = plugin.get_blueprint() if blueprint is None: continue diff --git a/src/octoprint/server/api/__init__.py b/src/octoprint/server/api/__init__.py index 29bf958a..431d8346 100644 --- a/src/octoprint/server/api/__init__.py +++ b/src/octoprint/server/api/__init__.py @@ -48,11 +48,14 @@ api.after_request(corsResponseHandler) @api.route("/plugin/", methods=["GET"]) def pluginData(name): - api_plugins = octoprint.plugin.plugin_manager().get_implementations(octoprint.plugin.SimpleApiPlugin) - if not name in api_plugins: + api_plugins = octoprint.plugin.plugin_manager().get_filtered_implementations(lambda p: p._identifier == name, octoprint.plugin.SimpleApiPlugin) + if not api_plugins: return make_response("Not found", 404) - api_plugin = api_plugins[name] + if len(api_plugins) > 1: + return make_response("More than one api provider registered for {name}, can't proceed".format(name=name), 500) + + api_plugin = api_plugins[0] response = api_plugin.on_api_get(request) if response is not None: @@ -64,11 +67,15 @@ def pluginData(name): @api.route("/plugin/", methods=["POST"]) @restricted_access def pluginCommand(name): - api_plugins = octoprint.plugin.plugin_manager().get_implementations(octoprint.plugin.SimpleApiPlugin) - if not name in api_plugins: + api_plugins = octoprint.plugin.plugin_manager().get_filtered_implementations(lambda p: p._identifier == name, octoprint.plugin.SimpleApiPlugin) + + if not api_plugins: return make_response("Not found", 404) - api_plugin = api_plugins[name] + if len(api_plugins) > 1: + return make_response("More than one api provider registered for {name}, can't proceed".format(name=name), 500) + + api_plugin = api_plugins[0] valid_commands = api_plugin.get_api_commands() if valid_commands is None: return make_response("Method not allowed", 405) diff --git a/src/octoprint/server/api/settings.py b/src/octoprint/server/api/settings.py index 0cf9b74b..ba63ddd1 100644 --- a/src/octoprint/server/api/settings.py +++ b/src/octoprint/server/api/settings.py @@ -224,9 +224,10 @@ def setSettings(): s.saveScript("gcode", name, script.replace("\r\n", "\n").replace("\r", "\n")) if "plugins" in data: - for name, plugin in octoprint.plugin.plugin_manager().get_implementations(octoprint.plugin.SettingsPlugin).items(): - if name in data["plugins"]: - plugin.on_settings_save(data["plugins"][name]) + for plugin in octoprint.plugin.plugin_manager().get_implementations(octoprint.plugin.SettingsPlugin).items(): + plugin_id = plugin._identifer + if plugin_id in data["plugins"]: + plugin.on_settings_save(data["plugins"][plugin_id]) if s.save(): diff --git a/src/octoprint/server/apps/__init__.py b/src/octoprint/server/apps/__init__.py index 47a68cca..e3650d95 100644 --- a/src/octoprint/server/apps/__init__.py +++ b/src/octoprint/server/apps/__init__.py @@ -87,7 +87,7 @@ def _get_registered_apps(): apps[app]["enabled"] = True app_plugins = octoprint.server.pluginManager.get_implementations(octoprint.plugin.AppPlugin) - for name, plugin in app_plugins.items(): + for plugin in app_plugins: additional_apps = plugin.get_additional_apps() any_version_enabled = dict() diff --git a/src/octoprint/server/util/sockjs.py b/src/octoprint/server/util/sockjs.py index 186e55b3..67137a99 100644 --- a/src/octoprint/server/util/sockjs.py +++ b/src/octoprint/server/util/sockjs.py @@ -54,7 +54,7 @@ class PrinterStateConnection(sockjs.tornado.SockJSConnection, octoprint.printer. self._printer.register_callback(self) self._fileManager.register_slicingprogress_callback(self) octoprint.timelapse.registerCallback(self) - self._pluginManager.register_client(self) + self._pluginManager.register_message_receiver(self.on_plugin_message) self._eventManager.fire(Events.CLIENT_OPENED, {"remoteAddress": self._remoteAddress}) for event in octoprint.events.all_events(): @@ -67,7 +67,7 @@ class PrinterStateConnection(sockjs.tornado.SockJSConnection, octoprint.printer. self._printer.unregister_callback(self) self._fileManager.unregister_slicingprogress_callback(self) octoprint.timelapse.unregisterCallback(self) - self._pluginManager.unregister_client(self) + self._pluginManager.unregister_message_receiver(self.on_plugin_message) self._eventManager.fire(Events.CLIENT_CLOSED, {"remoteAddress": self._remoteAddress}) for event in octoprint.events.all_events(): @@ -119,7 +119,7 @@ class PrinterStateConnection(sockjs.tornado.SockJSConnection, octoprint.printer. dict(slicer=slicer, source_location=source_location, source_path=source_path, dest_location=dest_location, dest_path=dest_path, progress=progress) ) - def sendPluginMessage(self, plugin, data): + def on_plugin_message(self, plugin, data): self._emit("plugin", dict(plugin=plugin, data=data)) def on_printer_add_log(self, data): diff --git a/src/octoprint/settings.py b/src/octoprint/settings.py index baa78816..4ba776a4 100644 --- a/src/octoprint/settings.py +++ b/src/octoprint/settings.py @@ -221,7 +221,7 @@ default_settings = { "apps": {} }, "terminalFilters": [ - { "name": "Suppress M105 requests/responses", "regex": "(Send: M105)|(Recv: ok T\d*:)" }, + { "name": "Suppress M105 requests/responses", "regex": "(Send: M105)|(Recv: ok (B|T\d*):)" }, { "name": "Suppress M27 requests/responses", "regex": "(Send: M27)|(Recv: SD printing byte)" } ], "plugins": {}, diff --git a/src/octoprint/slicing/__init__.py b/src/octoprint/slicing/__init__.py index ab86c421..d5c74e2d 100644 --- a/src/octoprint/slicing/__init__.py +++ b/src/octoprint/slicing/__init__.py @@ -121,7 +121,7 @@ class SlicingManager(object): available slicers. """ plugins = octoprint.plugin.plugin_manager().get_implementations(octoprint.plugin.SlicerPlugin) - for name, plugin in plugins.items(): + for plugin in plugins: self._slicers[plugin.get_slicer_properties()["type"]] = plugin @property diff --git a/tests/plugin/_plugins/deprecated_plugin.py b/tests/plugin/_plugins/deprecated_plugin.py new file mode 100644 index 00000000..7137d0cb --- /dev/null +++ b/tests/plugin/_plugins/deprecated_plugin.py @@ -0,0 +1,16 @@ +# coding=utf-8 +from __future__ import absolute_import + +import octoprint.plugin + +class TestDeprecatedAssetPlugin(octoprint.plugin.AssetPlugin): + pass + + +class TestSecondaryDeprecatedAssetPlugin(octoprint.plugin.AssetPlugin): + pass + + +__plugin_name__ = "Deprecated Plugin" +__plugin_description__ = "Test deprecated plugin" +__plugin_implementations__ = [TestDeprecatedAssetPlugin(), TestSecondaryDeprecatedAssetPlugin()] \ No newline at end of file diff --git a/tests/plugin/_plugins/mixed_plugin/__init__.py b/tests/plugin/_plugins/mixed_plugin/__init__.py index c8c8996a..aed5a5ed 100644 --- a/tests/plugin/_plugins/mixed_plugin/__init__.py +++ b/tests/plugin/_plugins/mixed_plugin/__init__.py @@ -14,4 +14,4 @@ class TestMixedPlugin(octoprint.plugin.StartupPlugin, octoprint.plugin.SettingsP __plugin_name__ = "Mixed Plugin" __plugin_description__ = "Test mixed plugin" -__plugin_implementations__ = (TestMixedPlugin(),) \ No newline at end of file +__plugin_implementation__ = TestMixedPlugin() \ No newline at end of file diff --git a/tests/plugin/_plugins/settings_plugin.py b/tests/plugin/_plugins/settings_plugin.py index 01fb6524..f046a3ad 100644 --- a/tests/plugin/_plugins/settings_plugin.py +++ b/tests/plugin/_plugins/settings_plugin.py @@ -9,4 +9,4 @@ class TestSettingsPlugin(octoprint.plugin.SettingsPlugin): __plugin_name__ = "Settings Plugin" __plugin_description__ = "Test settings plugin" -__plugin_implementations__ = (TestSettingsPlugin(),) \ No newline at end of file +__plugin_implementation__ = TestSettingsPlugin() \ No newline at end of file diff --git a/tests/plugin/_plugins/startup_plugin.py b/tests/plugin/_plugins/startup_plugin.py index 7c3202b5..b75ca851 100644 --- a/tests/plugin/_plugins/startup_plugin.py +++ b/tests/plugin/_plugins/startup_plugin.py @@ -9,4 +9,4 @@ class TestStartupPlugin(octoprint.plugin.StartupPlugin): __plugin_name__ = "Startup Plugin" __plugin_description__ = "Test startup plugin" -__plugin_implementations__ = (TestStartupPlugin(),) \ No newline at end of file +__plugin_implementation__ = TestStartupPlugin() \ No newline at end of file diff --git a/tests/plugin/test_core.py b/tests/plugin/test_core.py index 22efe1a9..8a3f037d 100644 --- a/tests/plugin/test_core.py +++ b/tests/plugin/test_core.py @@ -17,25 +17,33 @@ class PluginTestCase(unittest.TestCase): self.plugin_folder = os.path.join(os.path.dirname(os.path.realpath(__file__)), "_plugins") plugin_folders = [self.plugin_folder] - plugin_types = [octoprint.plugin.SettingsPlugin, octoprint.plugin.StartupPlugin] + plugin_types = [octoprint.plugin.SettingsPlugin, octoprint.plugin.StartupPlugin, octoprint.plugin.AssetPlugin] plugin_entry_points = None self.plugin_manager = octoprint.plugin.core.PluginManager(plugin_folders, plugin_types, plugin_entry_points, plugin_disabled_list=[], logging_prefix="logging_prefix.") + self.plugin_manager.initialize_implementations() def test_plugin_loading(self): - self.assertEquals(4, len(self.plugin_manager.plugins)) + self.assertEquals(5, len(self.plugin_manager.plugins)) self.assertEquals(1, len(self.plugin_manager.plugin_hooks)) - self.assertEquals(3, len(self.plugin_manager.plugin_implementations)) - self.assertEquals(2, len(self.plugin_manager.plugin_implementations_by_type)) + self.assertEquals(4, len(self.plugin_manager.plugin_implementations)) + self.assertEquals(3, len(self.plugin_manager.plugin_implementations_by_type)) + # hook_plugin self.assertTrue("octoprint.core.startup" in self.plugin_manager.plugin_hooks) self.assertEquals(1, len(self.plugin_manager.plugin_hooks["octoprint.core.startup"])) + # TestStartupPlugin & TestMixedPlugin self.assertTrue(octoprint.plugin.StartupPlugin in self.plugin_manager.plugin_implementations_by_type) self.assertEquals(2, len(self.plugin_manager.plugin_implementations_by_type[octoprint.plugin.StartupPlugin])) + # TestSettingsPlugin & TestMixedPlugin self.assertTrue(octoprint.plugin.SettingsPlugin in self.plugin_manager.plugin_implementations_by_type) self.assertEquals(2, len(self.plugin_manager.plugin_implementations_by_type[octoprint.plugin.SettingsPlugin])) + # TestDeprecatedAssetPlugin, NOT TestSecondaryDeprecatedAssetPlugin + self.assertTrue(octoprint.plugin.AssetPlugin in self.plugin_manager.plugin_implementations_by_type) + self.assertEquals(1, len(self.plugin_manager.plugin_implementations_by_type[octoprint.plugin.AssetPlugin])) + def test_plugin_initializing(self): def test_factory(name, implementation): @@ -55,11 +63,8 @@ class PluginTestCase(unittest.TestCase): ) all_implementations = self.plugin_manager.plugin_implementations - self.assertEquals(3, len(all_implementations)) - for name, implementations in all_implementations.items(): - self.assertEquals(1, len(implementations)) - impl = implementations.pop() - + self.assertEquals(4, len(all_implementations)) + for name, impl in all_implementations.items(): self.assertTrue(name in self.plugin_manager.plugins) plugin = self.plugin_manager.plugins[name] @@ -97,6 +102,14 @@ class PluginTestCase(unittest.TestCase): plugin = self.plugin_manager.get_plugin("unknown_plugin") self.assertIsNone(plugin) + def test_get_plugin_info(self): + plugin_info = self.plugin_manager.get_plugin_info("hook_plugin") + self.assertIsNotNone(plugin_info) + self.assertEquals("Hook Plugin", plugin_info.name) + + plugin_info = self.plugin_manager.get_plugin_info("unknown_plugin") + self.assertIsNone(plugin_info) + def test_get_hooks(self): hooks = self.plugin_manager.get_hooks("octoprint.core.startup") self.assertEquals(1, len(hooks)) @@ -108,44 +121,49 @@ class PluginTestCase(unittest.TestCase): def test_get_implementation(self): implementations = self.plugin_manager.get_implementations(octoprint.plugin.StartupPlugin) - self.assertEquals(2, len(implementations)) - self.assertTrue('startup_plugin' in implementations) - self.assertTrue('mixed_plugin' in implementations) + self.assertEquals(2, len(implementations)) # startup_plugin, mixed_plugin implementations = self.plugin_manager.get_implementations(octoprint.plugin.SettingsPlugin) - self.assertEquals(2, len(implementations)) - self.assertTrue('settings_plugin' in implementations) - self.assertTrue('mixed_plugin' in implementations) + self.assertEquals(2, len(implementations)) # settings_plugin, mixed_plugin implementations = self.plugin_manager.get_implementations(octoprint.plugin.StartupPlugin, octoprint.plugin.SettingsPlugin) - self.assertEquals(1, len(implementations)) - self.assertTrue('mixed_plugin' in implementations) + self.assertEquals(1, len(implementations)) # mixed_plugin + + implementations = self.plugin_manager.get_implementations(octoprint.plugin.AssetPlugin) + self.assertEquals(1, len(implementations)) # deprecated_plugin, but only first implementation! def test_client_registration(self): - client = mock.Mock() + def test_client(*args, **kwargs): + pass self.assertEquals(0, len(self.plugin_manager.registered_clients)) - self.plugin_manager.register_client(client) + self.plugin_manager.register_message_receiver(test_client) self.assertEquals(1, len(self.plugin_manager.registered_clients)) - self.assertIn(client, self.plugin_manager.registered_clients) + self.assertIn(test_client, self.plugin_manager.registered_clients) - self.plugin_manager.unregister_client(client) + self.plugin_manager.unregister_message_receiver(test_client) self.assertEquals(0, len(self.plugin_manager.registered_clients)) - self.assertNotIn(client, self.plugin_manager.registered_clients) + self.assertNotIn(test_client, self.plugin_manager.registered_clients) def test_send_plugin_message(self): client1 = mock.Mock() client2 = mock.Mock() - self.plugin_manager.register_client(client1) - self.plugin_manager.register_client(client2) + self.plugin_manager.register_message_receiver(client1.on_plugin_message) + self.plugin_manager.register_message_receiver(client2.on_plugin_message) plugin = "some plugin" data = "some data" self.plugin_manager.send_plugin_message(plugin, data) - client1.sendPluginMessage.assert_called_once_with(plugin, data) - client2.sendPluginMessage.assert_called_once_with(plugin, data) + client1.on_plugin_message.assert_called_once_with(plugin, data) + client2.on_plugin_message.assert_called_once_with(plugin, data) + def test_validate_plugin(self): + self.assertTrue("deprecated_plugin" in self.plugin_manager.plugins) + + plugin = self.plugin_manager.plugins["deprecated_plugin"] + self.assertTrue(hasattr(plugin.instance, plugin.__class__.attr_implementation)) + self.assertFalse(hasattr(plugin.instance, plugin.__class__.attr_implementations)) diff --git a/tests/slicing/test_slicingmanager.py b/tests/slicing/test_slicingmanager.py index 1b17a89d..c91b4dd5 100644 --- a/tests/slicing/test_slicingmanager.py +++ b/tests/slicing/test_slicingmanager.py @@ -50,7 +50,7 @@ class TestSlicingManager(unittest.TestCase): def get_implementations(*types): import octoprint.plugin if octoprint.plugin.SlicerPlugin in types: - return dict(("slicer_" + plugin.get_slicer_properties()["type"], plugin) for plugin in plugins) + return plugins return dict() self.plugin_manager.return_value.get_implementations.side_effect = get_implementations