From d3bd990009f3e9ab4e76c10142f0d6f284b0c1c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Fri, 30 Jan 2015 13:12:25 +0100 Subject: [PATCH] The plugin system now also injects self._settings into SettingsPlugins and create the blueprint for BlueprintPlugins Also adjusted a lot of documentation for that stuff and continued writing the Getting Started guide for plugin development. --- docs/plugins/gettingstarted.rst | 259 ++++++++++++++++-- docs/plugins/infrastructure.rst | 2 + docs/plugins/mixins.rst | 5 +- src/octoprint/plugin/__init__.py | 2 +- src/octoprint/plugin/core.py | 33 ++- src/octoprint/plugin/types.py | 258 ++++++++++++----- src/octoprint/server/__init__.py | 14 +- src/octoprint/slicing/__init__.py | 4 +- .../static/js/app/viewmodels/navigation.js | 1 + 9 files changed, 480 insertions(+), 98 deletions(-) diff --git a/docs/plugins/gettingstarted.rst b/docs/plugins/gettingstarted.rst index 46df0ede..96815e42 100644 --- a/docs/plugins/gettingstarted.rst +++ b/docs/plugins/gettingstarted.rst @@ -27,6 +27,8 @@ OctoPrint found that plugin in the folder and took a look into it. The name and entry it got from the ``__plugin_name__`` and ``__plugin_version__`` lines. It also read the description from ``__plugin_description__`` and stored it in an internal data structure, but we'll just ignore this for now. +.. _sec-plugins-gettingstarted-sayinghello: + Saying hello: How to make the plugin actually do something ---------------------------------------------------------- @@ -55,11 +57,11 @@ 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:`StartupPlugin` and another control property, ``__plugin_implementations__``, that instantiates -our plugin class and tells OctoPrint about it. Taking a look at the documentation of :class:`StartupPlugin` we see that -this mixin offers two methods that get called by OctoPrint during startup of the server, ``on_startup`` and -``on_after_startup``. We decided to add our logging output by overriding ``on_after_startup``, but we could also have -used ``on_startup`` instead, in which case our logging statement would be executed before the server was done starting +with :class:`~octoprint.plugin.StartupPlugin` and another control property, ``__plugin_implementations__``, 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 +used :func:`~octoprint.plugin.StartupPlugin.on_startup` instead, in which case our logging statement would be executed before the server was done starting up and ready to serve requests. You'll also note that we are using ``self._logger`` for logging. Where did that one come from? OctoPrint's plugin system @@ -68,6 +70,8 @@ one of those being a fully instantiated `python logger ``. +.. _sec-plugins-gettingstarted-growingup: + Growing up: How to make it distributable ---------------------------------------- @@ -86,9 +90,11 @@ install directly via Python's standard package manager ``pip`` or alternatively So let's begin. First checkout the `Plugin Skeleton `_ and rename the ``octoprint_skeleton`` folder to something better suited to our "Hello World" plugin:: - git clone https://github.com/OctoPrint/OctoPrint-PluginSkeleton.git OctoPrint-HelloWorld - cd OctoPrint-HelloWorld - mv octoprint_skeleton octoprint_helloworld + $ git clone https://github.com/OctoPrint/OctoPrint-PluginSkeleton.git OctoPrint-HelloWorld + Cloning into 'OctoPrint-HelloWorld'... + [...] + $ cd OctoPrint-HelloWorld + $ mv octoprint_skeleton octoprint_helloworld Then edit the configuration in the ``setup.py`` file to mirror our own "Hello World" plugin. The configuration should look something like this: @@ -143,7 +149,8 @@ of information now defined twice: plugin_description = "A quick \"Hello World\" example plugin for OctoPrint" The nice thing about our plugin now being a proper python package is that OctoPrint can and will access the metadata defined -within ``setup.py``! So, we don't really need to define all this data twice. Remove both ``plugin_name`` and ``plugin_version``: +within ``setup.py``! So, we don't really need to define all this data twice. Remove ``__plugin_name__``, ``__plugin_version__`` +and ``__plugin_description__``: .. code-block:: python @@ -163,7 +170,7 @@ and restart OctoPrint:: 2015-01-27 13:46:33,786 - octoprint.plugin.core - INFO - Found 3 plugin(s): OctoPrint-HelloWorld (1.0), CuraEngine (0.1), Discovery (0.1) Our "Hello World" Plugin still gets detected fine, but it's now listed under the same name it's installed under, -"OctoPrint-HelloWorld". That's a bit ugly, so we'll override that bit via ``__plugin_name__`` again: +"OctoPrint-HelloWorld". That's a bit redundant and squashed, so we'll override that bit via ``__plugin_name__`` again: .. code-block:: python :emphasize-lines: 10 @@ -196,25 +203,29 @@ already publish your plugin on Github and it would be directly installable by ot But let's add some more features instead. -Frontend or get out: How to add functionality to OctoPrint's web interface --------------------------------------------------------------------------- +.. _sec-plugins-gettingstarted-templates: + +Frontend fun: How to add functionality to OctoPrint's web interface +------------------------------------------------------------------- Outputting a log line upon server startup is all nice and well, but we want to greet not only the administrator of our OctoPrint instance but actually everyone that opens OctoPrint in their browser. Therefore, we need to modify OctoPrint's web interface itself. We can do this using the :class:`TemplatePlugin` mixin. For now, let's start with a little "Hello World!" in OctoPrint's -navigation bar right at the top. For this we'll first add the :class:`TemplatePlugin` to our ``HelloWorldPlugin`` class: +navigation bar right at the top that links to the Wikipedia node about "Hello World" programs. For this we'll first +add the :class:`TemplatePlugin` to our ``HelloWorldPlugin`` class: .. code-block:: python - :emphasize-lines: 6 + :emphasize-lines: 7 # coding=utf-8 from __future__ import absolute_import import octoprint.plugin - class HelloWorldPlugin(octoprint.plugin.StartupPlugin, octoprint.plugin.TemplatePlugin): + class HelloWorldPlugin(octoprint.plugin.StartupPlugin, + octoprint.plugin.TemplatePlugin): def on_after_startup(self): self._logger.info("Hello World!") @@ -230,13 +241,13 @@ Next, we'll create a sub folder ``templates`` underneath our ``octoprint_hellowo Our plugin's directory structure should now look like this:: - |-+ octoprint_helloworld - | |-+ templates - | | `- helloworld_navbar.jinja2 - | `- __init__.py - |- README.md - |- requirements.txt - `- setup.py + octoprint_helloworld/ + templates/ + helloworld_navbar.jinja2 + __init__.py + README.md + requirements.txt + setup.py Restart OctoPrint and open the web interface in your browser (make sure to clear your browser's cache!). @@ -245,4 +256,206 @@ Restart OctoPrint and open the web interface in your browser (make sure to clear :align: center :alt: Our "Hello World" navigation bar element in action -Now look at that! \ No newline at end of file +Now look at that! + +.. _sec-plugins-gettingstarted-settings: + +Settings Galore: How to make parts of your plugin user adjustable +----------------------------------------------------------------- + +Remember that Wikipedia link we added to our little link in the navigation bar? It links to the english Wikipedia. But +what if we want to allow our users to adjust that according to their wishes, e.g. to link to the german language node +about "Hello World" programs instead? + +To allow your users to customized the behaviour of your plugin you'll need to implement the :class:`~octoprint.plugin.SettingsPlugin` +mixin and override it's :func:`~octoprint.plugin.SettingsPlugin.get_settings_defaults` method. We'll save the URL to +inject into the link under the key ``url`` in our plugin's settings and set it to the old value by default. We'll therefore +return just a single key in our default settings dictionary. To be able to quickly see if we've done that right we'll +extend our little startup message to also log the current setting to the console. We can access that via ``self._settings``, +which is a little settings manager OctoPrint conveniently injects into our Plugin when we include the :class:`~octoprint.plugin.SettingsPlugin` +mixin. + +Let's take a look at how all that would look in our plugin's ``__init__.py``: + +.. code-block:: python + :emphasize-lines: 8, 10, 12-13 + + # coding=utf-8 + from __future__ import absolute_import + + import octoprint.plugin + + class HelloWorldPlugin(octoprint.plugin.StartupPlugin, + octoprint.plugin.TemplatePlugin, + octoprint.plugin.SettingsPlugin): + def on_after_startup(self): + self._logger.info("Hello World! (more: %s)" % self._settings.get(["url"])) + + def get_settings_defaults(self): + return dict(url="https://en.wikipedia.org/wiki/Hello_world") + + __plugin_name__ = "Hello World" + __plugin_implementations__ = [HelloWorldPlugin()] + +Restart OctoPrint. You should see something like this:: + + 2015-01-30 11:41:06,058 - octoprint.plugins.helloworld - INFO - Hello World! (more: https://en.wikipedia.org/wiki/Hello_world) + +So far so good. But how do we now get that value into our template? We have two options, the +static one using so called template variables and a dynamic one which retrieves that data from the backend and binds it +into the template using `Knockout data bindings `_. First let's +take a look at the static version using template variables. We already have the :class:`~octoprint.plugin.TemplatePlugin` +mixin included in our plugin, we just need to override its method :func:`~octoprint.plugin.TemplatePlugin.get_template_vars` +to add our URL as a template variable. + +Adjust your plugin's ``__init__.py`` like this: + +.. code-block:: python + :emphasize-lines: 15-16 + + # coding=utf-8 + from __future__ import absolute_import + + import octoprint.plugin + + class HelloWorldPlugin(octoprint.plugin.StartupPlugin, + octoprint.plugin.TemplatePlugin, + octoprint.plugin.SettingsPlugin): + def on_after_startup(self): + self._logger.info("Hello World! (more: %s)" % self._settings.get(["url"])) + + def get_settings_defaults(self): + return dict(url="https://en.wikipedia.org/wiki/Hello_world") + + def get_template_vars(self): + return dict(url=self._settings.get(["url"])) + + __plugin_name__ = "Hello World" + __plugin_implementations__ = [HelloWorldPlugin()] + +Also adjust your plugin's ``templates/helloworld_navbar.jinja2`` like this: + +.. code-block:: html + + Hello World! + +OctoPrint injects the template variables that your plugin defines prefixed with ``plugin__`` into +the template renderer, so your ``url`` got turned into ``plugin_helloworld_url`` which you can now use as a simple +`Jinja2 Variable `_ in your plugin's template. + +Restart OctoPrint and shift-reload the page in your browser (to make sure you really get a fresh copy). The link should +still work and point to the URL we defined as default. + +Let's change the URL! Open up your OctoPrint instance's ``config.yaml`` file and add the following to it (if a ``plugins`` +section doesn't yet exist in the file, create it): + +.. code-block:: yaml + :emphasize-lines: 3-4 + + # [...] + plugins: + helloworld: + url: https://de.wikipedia.org/wiki/Hallo-Welt-Programm + # [...] + +Restart OctoPrint. Not only should the URL displayed in the log file have changed, but also the link should now (after +a proper shift-reload) point to the german Wikipedia node about "Hello World" programs:: + + 2015-01-30 11:47:18,634 - octoprint.plugins.helloworld - INFO - Hello World! (more: https://de.wikipedia.org/wiki/Hallo-Welt-Programm) + +Nice! But not very user friendly. We don't have any way yet to edit the URL from within OctoPrint and have to restart +the server and reload the page every time we want a value change to take effect. Let's try adding a little settings dialog +for our plugin in which we can edit the URL and take any changes take immediate effect. + +First of all, we'll create the settings dialog. You might already have guessed that we'll need another template for that. +So in your plugin's ``templates`` folder create a new file ``helloworld_settings.jinja2`` and put the following content +into it: + +.. code-block:: html + +
+
+ +
+ +
+
+
+ +Note how we access our plugin's property via ``settings.plugins.helloworld.url``. The ``settings`` observable is made +available in the ``SettingsViewModel`` and holds the exact data structure returned from the server for all of +OctoPrint's settings. Accessing plugin settings hence works by following the path under which they are stored in +OctoPrint's internal settings data model (made public via the ``config.yaml``), ``plugins..``. +We'll bind our own settings dialog to the existing ``SettingsViewModel``, so this will be the way we'll access our +property. + +Now adjust your ``templates/helloworld_navbar.jinja2`` file to use a ``data-bind`` attribute to set the value from the +settings view model into the ``href`` attribute of the link tag: + +.. code-block:: html + + Hello World! + +You might have noticed the quite ugly way to access our plugin's ``url`` property here: ``settings.settings.plugins.helloworld.url``. +The reason for this is that we'll make our plugin use the existing ``NavigationViewModel`` which holds the +``SettingsViewModel`` as a property called ``settings``. So to get to the ``settings`` property of the ``SettingsViewModel`` +from the ``NavigationViewModel``, we'll need to first "switch" to the ``SettingsViewModel`` using its property name. Hence +the ugly access string. + +If you were now to restart OctoPrint and reload the web interface, you'll get the settings dialog placed just fine +in OctoPrint's settings, and the link would also still show up in the navigation bar, but both the input field of the +settings dialog as well as the link's ``href`` attribute would not show our link. The reason for this is that OctoPrint +by default assumes that you'll want to bind your own view models to your templates and hence "unbinds" the included +templates from the templates that are in place at the injected location already. In order to tell OctoPrint to please +don't do this here (since we *do* want to use both ``NavigationViewModel`` and ``SettingsViewModel``), we'll need to +override the default template configuration using the :class:`~octoprint.plugin.TemplatePlugin`s +:func:`~octoprint.plugin.TemplatePlugin.get_template_configs` method. We'll tell OctoPrint to use no custom bindings +for both our ``navbar`` and our ``settings`` plugin. We'll also remove the override of :func:`octoprint.plugin.TemplatePlugin.get_template_vars` +again since we don't use that anymore: + +.. code-block:: python + :emphasize-lines: 15-19 + + # coding=utf-8 + from __future__ import absolute_import + + import octoprint.plugin + + class HelloWorldPlugin(octoprint.plugin.StartupPlugin, + octoprint.plugin.TemplatePlugin, + octoprint.plugin.SettingsPlugin): + def on_after_startup(self): + self._logger.info("Hello World! (more: %s)" % self._settings.get(["url"])) + + def get_settings_defaults(self): + return dict(url="https://en.wikipedia.org/wiki/Hello_world") + + def get_template_configs(self): + return [ + dict(type="navbar", custom_bindings=False), + dict(type="settings", custom_bindings=False) + ] + + __plugin_name__ = "Hello World" + __plugin_implementations__ = [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 +"Plugins". + +.. _fig-plugins-gettingstarted-helloworld_settings: +.. figure:: ../images/plugins_gettingstarted_helloworld_settings.png + :align: center + :alt: Our "Hello World" navigation bar element in action + +Nice! Edit the value, then click "Save". Your link in the navigation bar should now have been updated as well. + +.. note:: + + The way we've done our data binding and how OctoPrint currently works, your link's target will update immediately + when you update the value in the settings dialog. Even if you click Cancel instead of Save, the change will still + be reflected in the UI but will be overwritten again by the stored data upon a reload. This is caused by OctoPrint + not storing a copy of the settings data while it is being edited, which might be changed in the future to + prevent this unexpected behaviour from occurring. + +Congratulations, you've just made your Plugin configurable :) \ No newline at end of file diff --git a/docs/plugins/infrastructure.rst b/docs/plugins/infrastructure.rst index da6a6e5b..52f058e1 100644 --- a/docs/plugins/infrastructure.rst +++ b/docs/plugins/infrastructure.rst @@ -50,6 +50,8 @@ Injected Properties ``self._logger`` A `python logger instance `_ logging to the log target ``octoprint.plugin.``. +``self._settings`` + The plugin's personalized settings manager, injected only into plugins that include the :class:`SettingsPlugin` mixin. ``self._plugin_manager`` OctoPrint's plugin manager. ``self._printer_profile_manager`` diff --git a/docs/plugins/mixins.rst b/docs/plugins/mixins.rst index b59d88c8..083d8bf0 100644 --- a/docs/plugins/mixins.rst +++ b/docs/plugins/mixins.rst @@ -3,6 +3,7 @@ Available plugin mixins ======================= -.. automodule:: octoprint.plugin.types - :members: +.. automodule:: octoprint.plugin + :members: AppPlugin, AssetPlugin, BlueprintPlugin, EventHandlerPlugin, ProgressPlugin, SettingsPlugin, ShutdownPlugin, SimpleApiPlugin, SlicerPlugin, StartupPlugin, TemplatePlugin :undoc-members: + diff --git a/src/octoprint/plugin/__init__.py b/src/octoprint/plugin/__init__.py index d0de0ad9..3b9112e4 100644 --- a/src/octoprint/plugin/__init__.py +++ b/src/octoprint/plugin/__init__.py @@ -41,7 +41,7 @@ def plugin_manager(init=False, plugin_folders=None, plugin_types=None, plugin_en if "enabled" in all_plugin_settings[key] and not all_plugin_settings[key]: plugin_disabled_list.append(key) - _instance = PluginManager(plugin_folders, plugin_types, plugin_entry_points, plugin_disabled_list=plugin_disabled_list) + _instance = PluginManager(plugin_folders, plugin_types, plugin_entry_points, logging_prefix="octoprint.plugins.", plugin_disabled_list=plugin_disabled_list) else: raise ValueError("Plugin Manager not initialized yet") return _instance diff --git a/src/octoprint/plugin/core.py b/src/octoprint/plugin/core.py index 2591019c..3e6075a3 100644 --- a/src/octoprint/plugin/core.py +++ b/src/octoprint/plugin/core.py @@ -72,7 +72,7 @@ class PluginInfo(object): @property def name(self): - return self._get_instance_attribute(self.__class__.attr_name, default=self._name) + return self._get_instance_attribute(self.__class__.attr_name, default=(self._name, self.key)) @property def description(self): @@ -116,15 +116,23 @@ class PluginInfo(object): def _get_instance_attribute(self, attr, default=None): if not hasattr(self.instance, attr): - return default + if isinstance(default, (tuple, list)): + for value in default: + if value is not None: + return value + return None + else: + return default return getattr(self.instance, attr) class PluginManager(object): - def __init__(self, plugin_folders, plugin_types, plugin_entry_points, plugin_disabled_list=None): + def __init__(self, plugin_folders, plugin_types, plugin_entry_points, logging_prefix=None, plugin_disabled_list=None): self.logger = logging.getLogger(__name__) + if logging_prefix is None: + logging_prefix = "" if plugin_disabled_list is None: plugin_disabled_list = [] @@ -132,6 +140,7 @@ class PluginManager(object): self.plugin_types = plugin_types self.plugin_entry_points = plugin_entry_points self.plugin_disabled_list = plugin_disabled_list + self.logging_prefix = logging_prefix self.plugins = dict() self.plugin_hooks = defaultdict(list) @@ -290,21 +299,35 @@ class PluginManager(object): self.log_registered_plugins() - def initialize_implementations(self, additional_injects=None): + def initialize_implementations(self, additional_injects=None, additional_inject_factories=None): if additional_injects is None: additional_injects = dict() + if additional_inject_factories is None: + additional_inject_factories = [] for name, implementations in self.plugin_implementations.items(): plugin = self.plugins[name] for implementation in implementations: kwargs = dict(additional_injects) + + 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): + kwargs.update(return_value) + kwargs.update(dict( identifier=name, plugin_name=plugin.name, plugin_version=plugin.version, basefolder=os.path.realpath(plugin.location), - logger=logging.getLogger("octoprint.plugins." + name), + logger=logging.getLogger(self.logging_prefix + name), )) + try: implementation.pre_initialize(**kwargs) except: diff --git a/src/octoprint/plugin/types.py b/src/octoprint/plugin/types.py index 7c7b73c8..cc6a8b52 100644 --- a/src/octoprint/plugin/types.py +++ b/src/octoprint/plugin/types.py @@ -21,10 +21,10 @@ class StartupPlugin(Plugin): will listen on. Note that the ``host`` may be ``0.0.0.0`` if it will listen on all interfaces, so you can't just blindly use this for constructing publicly reachable URLs. Also note that when this method is called, the server is not actually up yet and none of your plugin's APIs or blueprints will be reachable yet. If you need to be - externally reachable, use ``on_after_startup`` instead or additionally. + externally reachable, use :func:`on_after_startup` instead or additionally. - :param host: the host the server will listen on, may be ``0.0.0.0`` - :param port: the port the server will listen on + :param string host: the host the server will listen on, may be ``0.0.0.0`` + :param int port: the port the server will listen on """ pass @@ -40,7 +40,7 @@ class StartupPlugin(Plugin): class ShutdownPlugin(Plugin): """ The ``ShutdownPlugin`` allows hooking into the shutdown of OctoPrint. It's usually used in conjunction with the - ``StartupPlugin`` mixin, to cleanly shut down additional services again that where started by the ``StartupPlugin`` + :class:`StartupPlugin` mixin, to cleanly shut down additional services again that where started by the :class:`StartupPlugin` part of the plugin. """ @@ -57,16 +57,16 @@ class AssetPlugin(Plugin): be automatically embedded into the pages delivered by the server to be used within the client sided part of the plugin. - A typical usage of the ``AssetPlugin`` functionality is to embed a custom view model to be used on the settings page - of a ``SettingsPlugin``. + A typical usage of the ``AssetPlugin`` functionality is to embed a custom view model to be used by templates injected + through :class:`TemplatePlugin`s. """ def get_asset_folder(self): """ - Defines the folder where the plugin stores its static assets as defined in ``get_assets``. Override this if + Defines the folder where the plugin stores its static assets as defined in :func:`get_assets`. Override this if your plugin stores its assets at some other place than the ``static`` sub folder in the plugin base directory. - :return: the absolute path to the folder where the plugin stores its static assets + :return string: the absolute path to the folder where the plugin stores its static assets """ import os return os.path.join(self._basefolder, "static") @@ -84,7 +84,7 @@ class AssetPlugin(Plugin): LESS files with additional styles, will be embedded into delivered pages when running in LESS mode. The expected format to be returned is a dictionary mapping one or more of these keys to a list of files of that - type, the files being represented as relative paths from the asset folder as defined via ``get_asset_folder``. + type, the files being represented as relative paths from the asset folder as defined via :func:`get_asset_folder`. Example: .. code-block:: python @@ -96,10 +96,10 @@ class AssetPlugin(Plugin): less=['less/my_styles.less'] ) - The assets will be made available by OctoPrint under the URL ``/plugin_assets//``, with - ``plugin_name`` being the plugin's name and ``path`` being the path as defined in the asset dictionary. + The assets will be made available by OctoPrint under the URL ``/plugin_assets//``, with + ``plugin identifier`` being the plugin's identifier and ``path`` being the path as defined in the asset dictionary. - :return: a dictionary describing the static assets to publish for the plugin + :return dict: a dictionary describing the static assets to publish for the plugin """ return dict() @@ -115,22 +115,22 @@ class TemplatePlugin(Plugin): with the current implementation, plugins will always be located *to the left* of the existing links. The included template must be called ``_navbar.jinja2`` (e.g. ``myplugin_navbar.jinja2``) unless - overridden by the configuration supplied through ``get_template_config``. + overridden by the configuration supplied through :func:`get_template_configs`. The template will be already wrapped into the necessary structure, plugins just need to supply the pure content. The wrapper structure will have all additional classes and styles applied as specified via the configuration supplied - through ``get_template_config``. + through :func:`get_template_configs`. Sidebar The left side bar containing Connection, State and Files sections can be enriched with additional sections. Note that with the current implementations, plugins will always be located *beneath* the existing sections. The included template must be called ``_sidebar.jinja2`` (e.g. ``myplugin_sidebar.jinja2``) unless - overridden by the configuration supplied through ``get_template_config``. + overridden by the configuration supplied through :func:`get_template_configs`. The template will be already wrapped into the necessary structure, plugins just need to supply the pure content. The wrapper divs for both the whole box as well as the content pane will have all additional classes and styles applied - as specified via the configuration supplied through ``get_template_config``. + as specified via the configuration supplied through :func:`get_template_configs`. Tabs The available tabs of the main part of the interface may be extended with additional tabs originating from within @@ -138,11 +138,11 @@ class TemplatePlugin(Plugin): tabs. The included template must be called ``_tab.jinja2`` (e.g. ``myplugin_tab.jinja2``) unless - overridden by the configuration supplied through ``get_template_config``. + overridden by the configuration supplied through :func:`get_template_configs`. The template will be already wrapped into the necessary structure, plugins just need to supply the pure content. The wrapper div and the link in the navigation will have the additional classes and styles applied as specified via the - configuration supplied through ``get_template_config``. + configuration supplied through :func:`get_template_configs`. Settings Plugins may inject a dialog into the existing settings view. Note that with the current implementations, plugins @@ -150,11 +150,11 @@ class TemplatePlugin(Plugin): their displayed name. The included template must be called ``_settings.jinja2`` (e.g. ``myplugin_settings.jinja2``) unless - overridden by the configuration supplied through ``get_template_config``. + overridden by the configuration supplied through :func:`get_template_configs`. The template will be already wrapped into the necessary structure, plugins just need to supply the pure content. The wrapper div and the link in the navigation will have the additional classes and styles applied as defined via the - supplied configuration supplied through ``get_template_config``. + supplied configuration supplied through :func:`get_template_configs`. Generic Plugins may also inject arbitrary templates into the page of the web interface itself, e.g. in order to @@ -360,15 +360,16 @@ class TemplatePlugin(Plugin): those, since the implicit default template will only be included automatically if no other templates of that type are defined. - :return: a list containing the configuration options for the plugin's injected templates + :return list: a list containing the configuration options for the plugin's injected templates """ return [] def get_template_vars(self): """ - Defines additional template variables to include into the template renderer. + Defines additional template variables to include into the template renderer. Variable names will be prefixed + with ``plugin__``. - :return: a dictionary containing any additional template variables to include in the renderer + :return dict: a dictionary containing any additional template variables to include in the renderer """ return dict() @@ -377,7 +378,7 @@ class TemplatePlugin(Plugin): Defines the folder where the plugin stores its templates. Override this if your plugin stores its templates at some other place than the ``templates`` sub folder in the plugin base directory. - :return: the absolute path to the folder where the plugin stores its jinja2 templates + :return string: the absolute path to the folder where the plugin stores its jinja2 templates """ import os return os.path.join(self._basefolder, "templates") @@ -397,63 +398,194 @@ class SimpleApiPlugin(Plugin): class BlueprintPlugin(Plugin): """ The ``BlueprintPlugin`` mixin allows plugins to define their own full fledged endpoints for whatever purpose, - be it a more sophisticated API than what is possible via the ``SimpleApiPlugin`` or a custom web frontend. + be it a more sophisticated API than what is possible via the :class:`SimpleApiPlugin` or a custom web frontend. The mechanism at work here is `Flask's `_ own `Blueprint mechanism `_. - Your plugin should define a blueprint like this: + The mixin automatically creates a blueprint for you that will be registered under ``/plugin//``. + All you need to do is decorate all of your view functions with the :func:`route` decorator, + which behaves exactly the same like Flask's regular ``route`` decorators. Example: + + .. code-block:: python + :linenos: + + import octoprint.plugin + import flask + + class MyBlueprintPlugin(octoprint.plugin.BlueprintPlugin): + @octoprint.plugin.BlueprintPlugin.route("/echo", methods=["GET"]) + def myEcho(self): + if not "text" in flask.request.values: + return flask.make_response("Expected a text to echo back.", 400) + return flask.request.values["text"] + + __plugin_implementations__ = [MyPlugin()] + + 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 + ``/plugin/myblueprintplugin/echo``. + + Just like with regular blueprints you'll be able to create URLs via ``url_for``, just use the prefix + ``plugin.``, e.g.: .. code-block:: python - template_folder = os.path.join(os.path.dirname(os.path.realpath(__file__)), "templates") - blueprint = flask.Blueprint("plugin.myplugin", __name__, template_folder=template_folder) + flask.url_for("plugin.myblueprintplugin.echo") # will return "/plugin/myblueprintplugin/echo" - Use your blueprint just like any other Flask blueprint for defining your own endpoints, e.g. - - .. code-block:: python - - @blueprint.route("/echo", methods=["GET"]) - def myEcho(): - if not "text" in flask.request.values: - return flask.make_response("Expected a text to echo back.", 400) - return flask.request.values["text"] - - Your blueprint will be published by OctoPrint under the URL ``/plugin//``, so the above example - would be reachable under ``/plugin/myplugin/echo`` (given that you named your plugin "myplugin"). You'll be able - to create URLs via ``url_for`` under the prefix that you've chosen when constructing your blueprint, ``plugin.myplugin`` - in the above example: - - .. code-block:: python - - flask.url_for("plugin.myplugin.echo") # will return "/plugin/myplugin/echo" - - OctoPrint Blueprint plugins should always follow the naming scheme ``plugin.`` here - to avoid conflicts. """ + @staticmethod + def route(rule, **options): + """ + A decorator to mark view methods in your BlueprintPlugin subclass. Works just the same as Flask's + own ``route`` decorator available on blueprints. + + See `the documentation for flask.Blueprint.route `_ + and `the documentation for flask.Flask.route `_ for more + information. + """ + + from collections import defaultdict + def decorator(f): + # We attach the decorator parameters directly to the function object, because that's the only place + # we can access right now. + # This neat little trick was adapter from the Flask-Classy project: https://pythonhosted.org/Flask-Classy/ + if not hasattr(f, "_blueprint_rules") or f._blueprint_rules is None: + f._blueprint_rules = defaultdict(list) + f._blueprint_rules[f.__name__].append((rule, options)) + return f + return decorator + def get_blueprint(self): """ - Retrieves the blueprint as defined by your plugin. + Creates and returns the blueprint for your plugin. Override this if you want to define and handle your blueprint yourself. + + This method will only be called once during server initialization. :return: the blueprint ready to be registered with Flask """ - return None + import flask + blueprint = flask.Blueprint("plugin." + self._identifier, self._identifier) + for member in [member for member in dir(self) if not member.startswith("_")]: + f = getattr(self, member) + if hasattr(f, "_blueprint_rules") and member in f._blueprint_rules: + for blueprint_rule in f._blueprint_rules[member]: + rule, options = blueprint_rule + blueprint.add_url_rule(rule, options.pop("endpoint", f.__name__), view_func=f, **options) + return blueprint def is_blueprint_protected(self): """ - Whether the blueprint is supposed to be protected by API key (the default) or not. + Whether a valid API key is needed to access the blueprint (the default) or not. """ return True class SettingsPlugin(Plugin): + """ + Including the ``SettingsPlugin`` mixin allows plugins to store and retrieve their own settings within OctoPrint's + configuration. + + Plugins including the mixing will get injected an additional property ``self._settings`` which is an instance of + :class:`PluginSettingsManager` already properly initialized for use by the plugin. In order for the manager to + know about the available settings structure and default values upon initialization, implementing plugins will need + to provide a dictionary with the plugin's default settings through overriding the method :func:`get_settings_defaults`. + The defined structure will then be available to access through the settings manager available as ``self._settings``. + + If your plugin needs to react to the change of specific configuration values on the fly, e.g. to adjust the log level + of a logger when the user changes a corresponding flag via the settings dialog, you can override the + :func:`on_settings_save` method and wrap the call to the implementation from the parent class with retrieval of the + old and the new value and react accordingly. + + Example: + + .. code-block:: python + + import octoprint.plugin + + class MySettingsPlugin(octoprint.plugin.SettingsPlugin, octoprint.plugin.StartupPlugin): + def get_settings_defaults(self): + return dict( + some_setting="foo", + some_value=23, + sub=dict( + some_flag=True + ) + ) + + def on_settings_save(self, data): + old_flag = self._settings.getBoolean(["sub", "some_flag"]) + + super(MySettingsPlugin, self).on_settings_save(data) + + new_flag = self._settings.getBoolean(["sub", "some_flag"]) + if old_flag != new_flag: + self._logger.info("sub.some_flag changed from {old_flag} to {new_flag}".format(**locals())) + + def on_after_startup(self): + some_setting = self._settings.get(["some_setting"]) + some_value = self._settings.getInt(["some_value"]) + some_flag = self._settings.getBoolean(["sub", "some_flag"]) + self._logger.info("some_setting = {some_setting}, some_value = {some_value}, sub.some_flag = {some_flag}".format(**locals()) + + __plugin_implementations__ = [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. + """ + def on_settings_load(self): - return None + """ + Loads the settings for the plugin, called by the Settings API view in order to retrieve all settings from + all plugins. Override this if you want to inject additional settings properties that are not stored within + OctoPrint's configuration. + + .. note:: + + The default implementation will return your plugin's settings as is, so just in the structure and in the types + that are currently stored in OctoPrint's configuration. + + If you need more granular control here, e.g. over the used data types, you'll need to override this method + and iterate yourself over all your settings, using the proper retriever methods on the settings manager + to retrieve the data in the correct format. + + :return: the current settings of the plugin, as a dictionary + """ + return self._settings.get([], asdict=True, merged=True) def on_settings_save(self, data): - pass + """ + Saves the settings for the plugin, called by the Settings API view in order to persist all settings + from all plugins. Override this if you need to directly react to settings changes or want to extract + additional settings properties that are not stored within OctoPrint's configuration. + + .. note:: + + The default implementation will persist your plugin's settings as is, so just in the structure and in the + types that were received by the Settings API view. + + If you need more granular control here, e.g. over the used data types, you'll need to override this method + and iterate yourself over all your settings, retrieving them (if set) from the supplied received ``data`` + and using the proper setter methods on the settings manager to persist the data in the correct format. + + :param dict data: the settings dictionary to be saved for the plugin + """ + import octoprint.util + + current = self._settings.get([], asdict=True, merged=True) + data = octoprint.util.dict_merge(current, data) + self._settings.set([], data) + + def get_settings_defaults(self): + """ + Retrieves the plugin's default settings with which the plugin's settings manager will be initialized. + + Override this in your plugin's implementation and return a dictionary defining your settings data structure + with included default values. + """ + return dict() class EventHandlerPlugin(Plugin): @@ -502,9 +634,9 @@ class ProgressPlugin(Plugin): """ Called by OctoPrint on minimally 1% increments during a running print job. - :param location string: Location of the file - :param path string: Path of the file - :param progress int: Current progress as a value between 0 and 100 + :param string location: Location of the file + :param string path: Path of the file + :param int progress: Current progress as a value between 0 and 100 """ pass @@ -512,12 +644,12 @@ class ProgressPlugin(Plugin): """ Called by OctoPrint on minimally 1% increments during a running slicing job. - :param slicer string: Key of the slicer reporting the progress - :param source_location string: Location of the source file - :param source_path string: Path of the source file - :param destination_location string: Location the destination file - :param destination_path string: Path of the destination file - :param progress int: Current progress as a value between 0 and 100 + :param string slicer: Key of the slicer reporting the progress + :param string source_location: Location of the source file + :param string source_path: Path of the source file + :param string destination_location: Location the destination file + :param string destination_path: Path of the destination file + :param int progress: Current progress as a value between 0 and 100 """ pass diff --git a/src/octoprint/server/__init__.py b/src/octoprint/server/__init__.py index d294dc2d..7e701b5a 100644 --- a/src/octoprint/server/__init__.py +++ b/src/octoprint/server/__init__.py @@ -467,7 +467,14 @@ class Server(): printer = Printer(fileManager, analysisQueue, printerProfileManager) appSessionManager = util.flask.AppSessionManager() - pluginManager.initialize_implementations(dict( + def plugin_settings_factory(name, implementation): + if not isinstance(implementation, octoprint.plugin.SettingsPlugin): + return None + default_settings = implementation.get_settings_defaults() + plugin_settings = octoprint.plugin.plugin_settings(name, defaults=default_settings) + return dict(settings=plugin_settings) + + pluginManager.initialize_implementations(additional_injects=dict( plugin_manager=pluginManager, printer_profile_manager=printerProfileManager, event_bus=eventManager, @@ -475,8 +482,9 @@ class Server(): slicing_manager=slicingManager, file_manager=fileManager, printer=printer, - app_session_manager=appSessionManager, - )) + app_session_manager=appSessionManager + ), additional_inject_factories=[plugin_settings_factory]) + slicingManager.initialize() # configure additional template folders for jinja2 template_plugins = pluginManager.get_implementations(octoprint.plugin.TemplatePlugin) diff --git a/src/octoprint/slicing/__init__.py b/src/octoprint/slicing/__init__.py index af90d414..871e4673 100644 --- a/src/octoprint/slicing/__init__.py +++ b/src/octoprint/slicing/__init__.py @@ -55,11 +55,13 @@ class SlicingManager(object): self._slicers = dict() self._slicer_names = dict() - self._load_slicers() self._progress_callbacks = [] self._last_progress_report = None + def initialize(self): + self._load_slicers() + def register_progress_callback(self, callback): self._progress_callbacks.append(callback) diff --git a/src/octoprint/static/js/app/viewmodels/navigation.js b/src/octoprint/static/js/app/viewmodels/navigation.js index df9a16c3..0330eceb 100644 --- a/src/octoprint/static/js/app/viewmodels/navigation.js +++ b/src/octoprint/static/js/app/viewmodels/navigation.js @@ -3,6 +3,7 @@ function NavigationViewModel(loginStateViewModel, appearanceViewModel, settingsV self.loginState = loginStateViewModel; self.appearance = appearanceViewModel; + self.settings = settingsViewModel; self.systemActions = settingsViewModel.system_actions; self.users = usersViewModel;