From cd3ead3f300b5fef00f9fb28a991dc9da5bccab3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Fri, 27 Feb 2015 20:28:24 +0100 Subject: [PATCH] More documentation --- docs/conf.py | 4 +- docs/development/interface/index.rst | 5 +- docs/development/interface/plugin.rst | 19 +- docs/development/interface/util.rst | 9 + docs/plugins/mixins.rst | 98 +++++++- docs/requirements.txt | 1 + src/octoprint/plugin/__init__.py | 185 +++++++++++++++- src/octoprint/plugin/core.py | 56 +++++ src/octoprint/plugin/types.py | 68 ++++-- src/octoprint/printer/__init__.py | 2 +- src/octoprint/server/__init__.py | 32 ++- src/octoprint/util/__init__.py | 308 +++++++++++++++++++++++--- 12 files changed, 712 insertions(+), 75 deletions(-) create mode 100644 docs/development/interface/util.rst diff --git a/docs/conf.py b/docs/conf.py index a942c01c..9a5f232c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -27,11 +27,11 @@ year_current = date.today().year # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +#needs_sphinx = '1.3' # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.todo', 'sphinx.ext.autodoc', 'sphinxcontrib.httpdomain'] +extensions = ['sphinx.ext.todo', 'sphinx.ext.autodoc', 'sphinxcontrib.httpdomain', 'sphinx.ext.autosummary', 'sphinxcontrib.napoleon'] todo_include_todos = True # Add any paths that contain templates here, relative to this directory. diff --git a/docs/development/interface/index.rst b/docs/development/interface/index.rst index 6c4866c2..5b4c28fc 100644 --- a/docs/development/interface/index.rst +++ b/docs/development/interface/index.rst @@ -1,7 +1,7 @@ .. _sec-development-interface: -Interfaces -========== +Internal API +============ .. toctree:: :maxdepth: 3 @@ -9,3 +9,4 @@ Interfaces filemanager.rst plugin.rst printer.rst + util.rst diff --git a/docs/development/interface/plugin.rst b/docs/development/interface/plugin.rst index 1b659481..e1dc2c03 100644 --- a/docs/development/interface/plugin.rst +++ b/docs/development/interface/plugin.rst @@ -1,8 +1,19 @@ .. _sec-development-interface-plugin: -``octoprint.plugin`` --------------------- +octoprint.plugin +---------------- .. automodule:: octoprint.plugin - :members: plugin_manager, plugin_settings, call_plugin, PluginSettings - :undoc-members: + +.. _sec-development-interface-plugin-core: + +octoprint.plugin.core +--------------------- + +.. automodule:: octoprint.plugin.core + +octoprint.plugin.types +---------------------- + +.. automodule:: octoprint.plugin.types + diff --git a/docs/development/interface/util.rst b/docs/development/interface/util.rst new file mode 100644 index 00000000..f855ae25 --- /dev/null +++ b/docs/development/interface/util.rst @@ -0,0 +1,9 @@ +.. _sec-development-interface-util: + +octoprint.util +-------------- + +.. automodule:: octoprint.util + :members: + + diff --git a/docs/plugins/mixins.rst b/docs/plugins/mixins.rst index 083d8bf0..a37e9775 100644 --- a/docs/plugins/mixins.rst +++ b/docs/plugins/mixins.rst @@ -3,7 +3,99 @@ Available plugin mixins ======================= -.. automodule:: octoprint.plugin - :members: AppPlugin, AssetPlugin, BlueprintPlugin, EventHandlerPlugin, ProgressPlugin, SettingsPlugin, ShutdownPlugin, SimpleApiPlugin, SlicerPlugin, StartupPlugin, TemplatePlugin - :undoc-members: +The following plugin mixins are currently available: + +.. contents:: + :local: + +Please note that all plugin mixins inherit from :class:`~octoprint.plugin.core.Plugin` and +:class:`~octoprint.plugin.OctoPrintPlugin`, which also provide attributes of interest to plugin developers. + +.. _sec-plugins-mixins-startupplugin: + +StartupPlugin +------------- + +.. autoclass:: octoprint.plugin.StartupPlugin + :members: + +.. _sec-plugins-mixins-shutdownplugin: + +ShutdownPlugin +-------------- + +.. autoclass:: octoprint.plugin.ShutdownPlugin + :members: + +.. _sec-plugins-mixins-settingsplugin: + +SettingsPlugin +-------------- + +.. autoclass:: octoprint.plugin.SettingsPlugin + :members: + +.. _sec-plugins-mixins-assetplugin: + +AssetPlugin +----------- + +.. autoclass:: octoprint.plugin.AssetPlugin + :members: + +.. _sec-plugins-mixins-templateplugin: + +TemplatePlugin +-------------- + +.. autoclass:: octoprint.plugin.TemplatePlugin + :members: + +.. _sec-plugins-mixins-simpleapiplugin: + +SimpleApiPlugin +--------------- + +.. autoclass:: octoprint.plugin.SimpleApiPlugin + :members: + +.. _sec-plugins-mixins-blueprintplugin: + +BlueprintPlugin +--------------- + +.. autoclass:: octoprint.plugin.BlueprintPlugin + :members: + +.. _sec-plugins-mixins-eventhandlerplugin: + +EventHandlerPlugin +------------------ + +.. autoclass:: octoprint.plugin.EventHandlerPlugin + :members: + +.. _sec-plugins-mixins-progressplugin: + +ProgressPlugin +-------------- + +.. autoclass:: octoprint.plugin.ProgressPlugin + :members: + +.. _sec-plugins-mixins-slicerplugin: + +SlicerPlugin +------------ + +.. autoclass:: octoprint.plugin.SlicerPlugin + :members: + +.. _sec-plugins-mixins-appplugin: + +AppPlugin +--------- + +.. autoclass:: octoprint.plugin.AppPlugin + :members: diff --git a/docs/requirements.txt b/docs/requirements.txt index 28a38e6d..42469fd9 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,3 @@ sphinxcontrib-httpdomain +sphinxcontrib-napoleon sphinx_rtd_theme diff --git a/src/octoprint/plugin/__init__.py b/src/octoprint/plugin/__init__.py index 4b8beaf5..377e6229 100644 --- a/src/octoprint/plugin/__init__.py +++ b/src/octoprint/plugin/__init__.py @@ -1,4 +1,19 @@ # coding=utf-8 +""" +This module represents OctoPrint's plugin subsystem. This includes management and helper methods as well as the +registered plugin types. + +.. autofunction:: plugin_manager + +.. autofunction:: plugin_settings + +.. autofunction:: call_plugin + +.. autoclass:: PluginSettings + :members: + +""" + from __future__ import absolute_import __author__ = "Gina Häußge " @@ -18,9 +33,41 @@ from octoprint.util import deprecated _instance = None def plugin_manager(init=False, plugin_folders=None, plugin_types=None, plugin_entry_points=None, plugin_disabled_list=None): + """ + Factory method for initially constructing and consecutively retrieving the :class:`~octoprint.plugin.core.PluginManager` + singleton. + + Arguments: + init (boolean): A flag indicating whether this is the initial call to construct the singleton (True) or not + (False, default). If this is set to True and the plugin manager has already been initialized, a :class:`ValueError` + will be raised. The same will happen if the plugin manager has not yet been initialized and this is set to + False. + plugin_folders (list): A list of folders (as strings containing the absolute path to them) in which to look for + potential plugin modules. If not provided this defaults to the configured ``plugins`` base folder and + ``src/plugins`` within OctoPrint's code base. + plugin_types (list): A list of recognized plugin types for which to look for provided implementations. If not + provided this defaults to the plugin types found in :mod:`octoprint.plugin.types` without + :class:`~octoprint.plugin.OctoPrintPlugin`. + plugin_entry_points (list): A list of entry points pointing to modules which to load as plugins. If not provided + this defaults to the entry point ``octoprint.plugin``. + plugin_disabled_list (list): A list of plugin identifiers that are currently disabled. If not provided this + defaults to all plugins for which ``enabled`` is set to ``False`` in the settings. + + Returns: + PluginManager: A fully initialized :class:`~octoprint.plugin.core.PluginManager` instance to be used for plugin + management tasks. + + Raises: + ValueError: ``init`` was True although the plugin manager was already initialized, or it was False although + the plugin manager was not yet initialized. + """ + global _instance if _instance is None: if init: + if _instance is not None: + raise ValueError("Plugin Manager already initialized") + if plugin_folders is None: plugin_folders = (settings().getBaseFolder("plugins"), os.path.abspath(os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "plugins"))) if plugin_types is None: @@ -51,10 +98,59 @@ def plugin_manager(init=False, plugin_folders=None, plugin_types=None, plugin_en def plugin_settings(plugin_key, defaults=None): + """ + Factory method for creating a :class:`PluginSettings` instance. + + Arguments: + plugin_key (string): The plugin identifier for which to create the settings instance. + defaults (dict): The default settings for the plugin. + + Returns: + PluginSettings: A fully initialized :class:`PluginSettings` instance to be used to access the plugin's + settings + """ return PluginSettings(settings(), plugin_key, defaults=defaults) def call_plugin(types, method, args=None, kwargs=None, callback=None, error_callback=None): + """ + Helper method to invoke the indicated ``method`` on all registered plugin implementations implementing the + indicated ``types``. Allows providing method arguments and registering callbacks to call in case of success + and/or failure of each call which can be used to return individual results to the calling code. + + Example: + + .. sourcecode:: python + + def my_success_callback(name, plugin, result): + print("{name} was called successfully and returned {result!r}".format(**locals())) + + def my_error_callback(name, plugin, exc): + print("{name} raised an exception: {exc!s}".format(**locals())) + + octoprint.plugin.call_plugin( + [octoprint.plugin.StartupPlugin], + "on_startup", + args=(my_host, my_port), + callback=my_success_callback, + error_callback=my_error_callback + ) + + Arguments: + types (list): A list of plugin implementation types to match against. + method (string): Name of the method to call on all matching implementations. + args (tuple): A tuple containing the arguments to supply to the called ``method``. Optional. + kwargs (dict): A dictionary containing the keyword arguments to supply to the called ``method``. Optional. + callback (function): A callback to invoke after an implementation has been called successfully. Will be called + with the three arguments ``name``, ``plugin`` and ``result``. ``name`` will be the plugin identifier, + ``plugin`` the plugin implementation instance itself and ``result`` the result returned from the + ``method`` invocation. + error_callback (function): A callback to invoke after the call of an implementation resulted in an exception. + Will be called with the three arguments ``name``, ``plugin`` and ``exc``. ``name`` will be the plugin + identifier, ``plugin`` the plugin implementation instance itself and ``exc`` the caught exception. + + """ + if not isinstance(types, (list, tuple)): types = [types] if args is None: @@ -69,14 +165,53 @@ def call_plugin(types, method, args=None, kwargs=None, callback=None, error_call result = getattr(plugin, method)(*args, **kwargs) if callback: callback(name, plugin, result) - except Exception as e: + except Exception as exc: logging.getLogger(__name__).exception("Error while calling plugin %s" % name) if error_callback: - error_callback(name, plugin, e) + error_callback(name, plugin, exc) class PluginSettings(object): + """ + The :class:`PluginSettings` class is the interface for plugins to their own or globally defined settings. + + It provides a couple of convenience methods for directly accessing plugin settings via the regular + :class:`octoprint.settings.Settings` interfaces as well as means to access plugin specific folder locations. + + .. method:: get(path, merged=False, asdict=False) + + Retrieves a raw key from the settings for ``path``, optionally merging the raw value with the default settings + if ``merged`` is set to True. + + :param list path: a list of path elements to navigate to the settings value + :param boolean merged: whether to merge the returned result with the default settings (True) or not (False, default) + :return: the retrieved settings value + + .. method:: get_int(path) + + .. method:: get_float(path) + + .. method:: get_boolean(path) + + .. method:: set(path, value, force=False) + + .. method:: set_int(path, value, force=False) + + .. method:: set_float(path, value, force=False) + + .. method:: set_boolean(path, value, force=False) + """ + def __init__(self, settings, plugin_key, defaults=None): + """ + Initializes the object with the provided :class:`octoprint.settings.Settings` manager as ``settings``, using + the ``plugin_key`` and optional ``defaults``. + + :param settings: + :param plugin_key: + :param defaults: + :return: + """ self.settings = settings self.plugin_key = plugin_key @@ -119,39 +254,30 @@ class PluginSettings(object): def global_get(self, path, **kwargs): return self.settings.get(path, **kwargs) - globalGet = deprecated("globalGet has been renamed to global_get")(global_get) def global_get_int(self, path, **kwargs): return self.settings.getInt(path, **kwargs) - globalGetInt = deprecated("globalGetInt has been renamed to global_get_int")(global_get_int) def global_get_float(self, path, **kwargs): return self.settings.getFloat(path, **kwargs) - globalGetFloat = deprecated("globalGetFloat has been renamed to global_get_float")(global_get_float) def global_get_boolean(self, path, **kwargs): return self.settings.getBoolean(path, **kwargs) - globalGetBoolean = deprecated("globalGetBoolean has been renamed to global_get_boolean")(global_get_boolean) def global_set(self, path, value, **kwargs): self.settings.set(path, value, **kwargs) - globalSet = deprecated("globalSet has been renamed to global_set")(global_set) def global_set_int(self, path, value, **kwargs): self.settings.setInt(path, value, **kwargs) - globalSetInt = deprecated("globalSetInt has been renamed to global_set_int")(global_set_int) def global_set_float(self, path, value, **kwargs): self.settings.setFloat(path, value, **kwargs) - globalSetFloat = deprecated("globalSetFloat has been renamed to global_set_float")(global_set_float) def global_set_boolean(self, path, value, **kwargs): self.settings.setBoolean(path, value, **kwargs) - globalSetBoolean = deprecated("globalSetBoolean has been renamed to global_set_boolean")(global_set_boolean) def global_get_basefolder(self, folder_type, **kwargs): return self.settings.getBaseFolder(folder_type, **kwargs) - globalGetBaseFolder = deprecated("globalGetBaseFolder has been renamed to global_get_basefolder")(global_get_basefolder) def get_plugin_logfile_path(self, postfix=None): filename = "plugin_" + self.plugin_key @@ -159,7 +285,6 @@ class PluginSettings(object): filename += "_" + postfix filename += ".log" return os.path.join(self.settings.getBaseFolder("logs"), filename) - getPluginLogfilePath = deprecated("getPluginLogfilePath has been renamed to get_plugin_logfile_path")(get_plugin_logfile_path) def __getattr__(self, item): all_access_methods = self.access_methods.keys() + self.deprecated_access_methods.keys() @@ -179,7 +304,43 @@ class PluginSettings(object): def _func(*args, **kwargs): return orig_func(*args_mapper(args), **kwargs_mapper(kwargs)) _func.__name__ = item + _func.__doc__ = orig_func.__doc__ if "__doc__" in dir(orig_func) else None return _func return getattr(self.settings, item) + + ##~~ deprecated methods follow + + # TODO: Remove with release of 1.2.0 + + globalGet = deprecated("globalGet has been renamed to global_get", + includedoc="Replaced by :func:`global_get`", + since="1.2.0-dev-546")(global_get) + globalGetInt = deprecated("globalGetInt has been renamed to global_get_int", + includedoc="Replaced by :func:`global_get_int`", + since="1.2.0-dev-546")(global_get_int) + globalGetFloat = deprecated("globalGetFloat has been renamed to global_get_float", + includedoc="Replaced by :func:`global_get_float`", + since="1.2.0-dev-546")(global_get_float) + globalGetBoolean = deprecated("globalGetBoolean has been renamed to global_get_boolean", + includedoc="Replaced by :func:`global_get_boolean`", + since="1.2.0-dev-546")(global_get_boolean) + globalSet = deprecated("globalSet has been renamed to global_set", + includedoc="Replaced by :func:`global_set`", + since="1.2.0-dev-546")(global_set) + globalSetInt = deprecated("globalSetInt has been renamed to global_set_int", + includedoc="Replaced by :func:`global_set_int`", + since="1.2.0-dev-546")(global_set_int) + globalSetFloat = deprecated("globalSetFloat has been renamed to global_set_float", + includedoc="Replaced by :func:`global_set_float`", + since="1.2.0-dev-546")(global_set_float) + globalSetBoolean = deprecated("globalSetBoolean has been renamed to global_set_boolean", + includedoc="Replaced by :func:`global_set_boolean`", + since="1.2.0-dev-546")(global_set_boolean) + globalGetBaseFolder = deprecated("globalGetBaseFolder has been renamed to global_get_basefolder", + includedoc="Replaced by :func:`global_get_basefolder`", + since="1.2.0-dev-546")(global_get_basefolder) + getPluginLogfilePath = deprecated("getPluginLogfilePath has been renamed to get_plugin_logfile_path", + includedoc="Replaced by :func:`get_plugin_logfile_path`", + since="1.2.0-dev-546")(get_plugin_logfile_path) diff --git a/src/octoprint/plugin/core.py b/src/octoprint/plugin/core.py index 6af26056..d418483d 100644 --- a/src/octoprint/plugin/core.py +++ b/src/octoprint/plugin/core.py @@ -1,4 +1,16 @@ # coding=utf-8 +""" +In this module resides the core data structures and logic of the plugin system. It is implemented in an OctoPrint-agnostic +way and could be extracted into a separate Python module in the future. + +.. autoclass:: PluginManager + +.. autoclass:: PluginInfo + +.. autoclass:: Plugin + +""" + from __future__ import absolute_import __author__ = "Gina Häußge " @@ -13,6 +25,15 @@ import logging class PluginInfo(object): + """ + The :class:`PluginInfo` class wraps all available information about a registered plugin. + + This includes its meta data (like name, description, version, etc) as well as the actual plugin extensions like + implementations, hooks and helpers. + + It works on Python module objects and extracts the relevant data from those via accessing the + :ref:`control properties `. + """ attr_name = '__plugin_name__' @@ -125,6 +146,12 @@ class PluginInfo(object): class PluginManager(object): + """ + The :class:`PluginManager` is the central component for finding, loading and accessing plugins provided to the + system. + + It is able to discover plugins both through possible file system locations as well as customizable entry points. + """ def __init__(self, plugin_folders, plugin_types, plugin_entry_points, logging_prefix=None, plugin_disabled_list=None): self.logger = logging.getLogger(__name__) @@ -405,5 +432,34 @@ class PluginManager(object): class Plugin(object): + """ + The parent class of all plugin implementations. + + .. attribute:: _identifier + + The identifier of the plugin. Injected by the plugin core system upon initialization of the implementation. + + .. attribute:: _plugin_name + + The name of the plugin. Injected by the plugin core system upon initialization of the implementation. + + .. attribute:: _plugin_version + + The version of the plugin. Injected by the plugin core system upon initialization of the implementation. + + .. attribute:: _basefolder + + The base folder of the plugin. Injected by the plugin core system upon initialization of the implementation. + + .. attribute:: _logger + + The logger instance to use, with the logging name set to the :attr:`PluginManager.logging_prefix` of the + :class:`PluginManager` concatenated with :attr:`_identifier`. Injected by the plugin core system upon + initialization of the implementation. + """ + def initialize(self): + """ + Called by the plugin core after performing all injections. + """ pass diff --git a/src/octoprint/plugin/types.py b/src/octoprint/plugin/types.py index 48d2b36a..71c9ed39 100644 --- a/src/octoprint/plugin/types.py +++ b/src/octoprint/plugin/types.py @@ -1,4 +1,15 @@ # coding=utf-8 +""" +This module bundles all of OctoPrint's supported plugin implementation types as well as their common parent +class, :class:`OctoPrintPlugin`. + +Please note that the plugin implementation types are documented in the section +:ref:`Available plugin mixins `. + +.. autoclass: OctoPrintPlugin + +""" + from __future__ import absolute_import __author__ = "Gina Häußge " @@ -9,7 +20,29 @@ __copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms from .core import Plugin -class StartupPlugin(Plugin): +class OctoPrintPlugin(Plugin): + """ + .. attribute:: _plugin_manager + + .. attribute:: _printer_profile_manager + + .. attribute:: _event_bus + + .. attribute:: _analysis_queue + + .. attribute:: _slicing_manager + + .. attribute:: _file_manager + + .. attribute:: _printer + + .. attribute:: _app_session_manager + """ + + pass + + +class StartupPlugin(OctoPrintPlugin): """ The ``StartupPlugin`` allows hooking into the startup of OctoPrint. It can be used to start up additional services on or just after the startup of the server. @@ -37,7 +70,7 @@ class StartupPlugin(Plugin): pass -class ShutdownPlugin(Plugin): +class ShutdownPlugin(OctoPrintPlugin): """ The ``ShutdownPlugin`` allows hooking into the shutdown of OctoPrint. It's usually used in conjunction with the :class:`StartupPlugin` mixin, to cleanly shut down additional services again that where started by the :class:`StartupPlugin` @@ -51,14 +84,14 @@ class ShutdownPlugin(Plugin): pass -class AssetPlugin(Plugin): +class AssetPlugin(OctoPrintPlugin): """ The ``AssetPlugin`` mixin allows plugins to define additional static assets such as Javascript or CSS files to 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 by templates injected - through :class:`TemplatePlugin`s. + through a :class:`TemplatePlugin`. """ def get_asset_folder(self): @@ -104,7 +137,7 @@ class AssetPlugin(Plugin): return dict() -class TemplatePlugin(Plugin): +class TemplatePlugin(OctoPrintPlugin): """ Using the ``TemplatePlugin`` mixin plugins may inject their own components into the OctoPrint web interface. @@ -408,7 +441,7 @@ class TemplatePlugin(Plugin): return os.path.join(self._basefolder, "templates") -class SimpleApiPlugin(Plugin): +class SimpleApiPlugin(OctoPrintPlugin): """ Utilizing the ``SimpleApiPlugin`` mixin plugins may implement a simple API based around one GET resource and one resource accepting JSON commands POSTed to it. This is the easy alternative for plugin's which don't need the @@ -543,7 +576,7 @@ class SimpleApiPlugin(Plugin): return None -class BlueprintPlugin(Plugin): +class BlueprintPlugin(OctoPrintPlugin): """ 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 :class:`SimpleApiPlugin` or a custom web frontend. @@ -646,7 +679,7 @@ class BlueprintPlugin(Plugin): return True -class SettingsPlugin(Plugin): +class SettingsPlugin(OctoPrintPlugin): """ Including the ``SettingsPlugin`` mixin allows plugins to store and retrieve their own settings within OctoPrint's configuration. @@ -697,6 +730,11 @@ class SettingsPlugin(Plugin): 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. + + .. attribute:: _settings + + The :class:`~octoprint.plugin.PluginSettings` instance to use for accessing the plugin's settings. Injected by + the plugin core system upon initialization of the implementation. """ def on_settings_load(self): @@ -751,7 +789,7 @@ class SettingsPlugin(Plugin): return dict() -class EventHandlerPlugin(Plugin): +class EventHandlerPlugin(OctoPrintPlugin): """ The ``EventHandlerPlugin`` mixin allows OctoPrint plugins to react to any of :ref:`OctoPrint's events `. OctoPrint will call the :func:`on_event` method for any event fired on its internal event bus, supplying the @@ -774,7 +812,7 @@ class EventHandlerPlugin(Plugin): pass -class SlicerPlugin(Plugin): +class SlicerPlugin(OctoPrintPlugin): """ Via the ``SlicerPlugin`` mixin plugins can add support for slicing engines to be used by OctoPrint. @@ -835,10 +873,10 @@ class SlicerPlugin(Plugin): ``False`` (defaults to ``True``), an ``IOError`` should be raised. :param string path: the path to which to save the profile - :param :class:`SlicingProfile` profile: the profile to save + :param SlicingProfile profile: the profile to save :param bool allow_overwrite: whether to allow to overwrite an existing profile at the indicated path (``True``, default) - or not (``False``) - if a profile already exists on the path and this is ``False`` - and :class:`IOError` should be raised + or not (``False``) - if a profile already exists on the path and this is ``False`` + and :class:`IOError` should be raised :param dict overrides: profile overrides to apply to the ``profile`` before saving it """ pass @@ -881,7 +919,7 @@ class SlicerPlugin(Plugin): pass -class ProgressPlugin(Plugin): +class ProgressPlugin(OctoPrintPlugin): """ Via the ``ProgressPlugin`` mixing plugins can let themselves be called upon progress in print jobs or slicing jobs, limited to minimally 1% steps. @@ -911,7 +949,7 @@ class ProgressPlugin(Plugin): pass -class AppPlugin(Plugin): +class AppPlugin(OctoPrintPlugin): def get_additional_apps(self): return [] diff --git a/src/octoprint/printer/__init__.py b/src/octoprint/printer/__init__.py index 4cf3c8af..59cecebe 100644 --- a/src/octoprint/printer/__init__.py +++ b/src/octoprint/printer/__init__.py @@ -719,7 +719,7 @@ class Printer(comm.MachineComPrintCallback): self.refreshSdFiles(blocking=True) existingSdFiles = map(lambda x: x[0], self._comm.getSdFiles()) - remoteName = util.get_dos_filename(filename, existingSdFiles) + remoteName = util.get_dos_filename(filename, existing_filenames=existingSdFiles, extension="gco") self._timeEstimationData = TimeEstimationHelper() self._comm.startFileTransfer(absolutePath, filename, "/" + remoteName) diff --git a/src/octoprint/server/__init__.py b/src/octoprint/server/__init__.py index d133f2af..f16efcfb 100644 --- a/src/octoprint/server/__init__.py +++ b/src/octoprint/server/__init__.py @@ -533,23 +533,33 @@ class Server(): printer = Printer(fileManager, analysisQueue, printerProfileManager) appSessionManager = util.flask.AppSessionManager() - def plugin_settings_factory(name, implementation): + def octoprint_plugin_inject_factory(name, implementation): + if not isinstance(implementation, octoprint.plugin.OctoPrintPlugin): + return None + return dict( + plugin_manager=pluginManager, + printer_profile_manager=printerProfileManager, + event_bus=eventManager, + analysis_queue=analysisQueue, + slicing_manager=slicingManager, + file_manager=fileManager, + printer=printer, + app_session_manager=appSessionManager + ) + + def settings_plugin_inject_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, - analysis_queue=analysisQueue, - slicing_manager=slicingManager, - file_manager=fileManager, - printer=printer, - app_session_manager=appSessionManager - ), additional_inject_factories=[plugin_settings_factory]) + pluginManager.initialize_implementations( + additional_inject_factories=[ + octoprint_plugin_inject_factory, + settings_plugin_inject_factory + ] + ) slicingManager.initialize() # configure additional template folders for jinja2 diff --git a/src/octoprint/util/__init__.py b/src/octoprint/util/__init__.py index df945a69..981eb1d6 100644 --- a/src/octoprint/util/__init__.py +++ b/src/octoprint/util/__init__.py @@ -1,4 +1,9 @@ # coding=utf-8 +""" +This module bundles commonly used utility methods or helper classes that are used in multiple places withing +OctoPrint's source code. +""" + __author__ = "Gina Häußge " __license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' @@ -16,7 +21,7 @@ import warnings logger = logging.getLogger(__name__) def warning_decorator_factory(warning_type): - def specific_warning(message, stacklevel=1): + def specific_warning(message, stacklevel=1, since=None, includedoc=None): def decorator(func): @wraps(func) def func_wrapper(*args, **kwargs): @@ -24,19 +29,75 @@ def warning_decorator_factory(warning_type): # func_wrapper in the log, instead of our caller (which is the real caller of the wrapped function) warnings.warn(message, warning_type, stacklevel=stacklevel + 1) return func(*args, **kwargs) + + if includedoc is not None and since is not None: + docstring = "\n.. deprecated:: {since}\n {message}\n\n".format(since=since, message=includedoc) + if hasattr(func_wrapper, "__doc__") and func_wrapper.__doc__ is not None: + docstring = func_wrapper.__doc__ + "\n" + docstring + func_wrapper.__doc__ = docstring + return func_wrapper return decorator return specific_warning deprecated = warning_decorator_factory(DeprecationWarning) +""" +A decorator for deprecated methods. Logs a deprecation warning via Python's `:mod:`warnings` module including the +supplied ``message``. The call stack level used (for adding the source location of the offending call to the +warning) can be overridden using the optional ``stacklevel`` parameter. If both ``since`` and ``includedoc`` are +provided, a deprecation warning will also be added to the function's docstring by providing or extending its ``__doc__`` +property. + +Arguments: + message (string): The message to include in the deprecation warning. + stacklevel (int): Stack level for including the caller of the offending method in the logged warning. Defaults to 1, + meaning the direct caller of the method. It might make sense to increase this in case of the function call + happening dynamically from a fixed position to not shadow the real caller (e.g. in case of overridden + ``getattr`` methods). + includedoc (string): Message about the deprecation to include in the wrapped function's docstring. + since (string): Version since when the function was deprecated, must be present for the docstring to get extended. + +Returns: + function: The wrapped function with the deprecation warnings in place. +""" + pending_deprecation = warning_decorator_factory(PendingDeprecationWarning) +""" +A decorator for methods pending deprecation. Logs a pending deprecation warning via Python's `:mod:`warnings` module +including the supplied ``message``. The call stack level used (for adding the source location of the offending call to +the warning) can be overridden using the optional ``stacklevel`` parameter. If both ``since`` and ``includedoc`` are +provided, a deprecation warning will also be added to the function's docstring by providing or extending its ``__doc__`` +property. + +Arguments: + message (string): The message to include in the deprecation warning. + stacklevel (int): Stack level for including the caller of the offending method in the logged warning. Defaults to 1, + meaning the direct caller of the method. It might make sense to increase this in case of the function call + happening dynamically from a fixed position to not shadow the real caller (e.g. in case of overridden + ``getattr`` methods). + includedoc (string): Message about the deprecation to include in the wrapped function's docstring. + since (string): Version since when the function was deprecated, must be present for the docstring to get extended. + +Returns: + function: The wrapped function with the deprecation warnings in place. +""" def get_formatted_size(num): """ - Taken from http://stackoverflow.com/a/1094933/2028598 + Formats the given byte count as a human readable rounded size expressed in the most pressing unit among B(ytes), + K(ilo)B(ytes), M(ega)B(ytes), G(iga)B(ytes) and T(era)B(ytes), with one decimal place. + + Based on http://stackoverflow.com/a/1094933/2028598 + + Arguments: + num (int): The byte count to format + + Returns: + string: The formatted byte count. """ - for x in ["bytes","KB","MB","GB"]: + + for x in ["B","KB","MB","GB"]: if num < 1024.0: return "%3.1f%s" % (num, x) num /= 1024.0 @@ -44,10 +105,31 @@ def get_formatted_size(num): def is_allowed_file(filename, extensions): - return "." in filename and filename.rsplit(".", 1)[1] in extensions + """ + Determines if the provided ``filename`` has one of the supplied ``extensions``. The check is done case-insensitive. + + Arguments: + filename (string): The file name to check against the extensions. + extensions (list): The extensions to check against, a list of strings + + Return: + boolean: True if the file name's extension matches one of the allowed extensions, False otherwise. + """ + + return "." in filename and filename.rsplit(".", 1)[1].lower() in map(str.lower, extensions) def get_formatted_timedelta(d): + """ + Formats a timedelta instance as "HH:MM:ss" and returns the resulting string. + + Arguments: + d (datetime.timedelta): The timedelta instance to format + + Returns: + string: The timedelta formatted as "HH:MM:ss" + """ + if d is None: return None hours = d.days * 24 + d.seconds // 3600 @@ -57,6 +139,16 @@ def get_formatted_timedelta(d): def get_formatted_datetime(d): + """ + Formats a datetime instance as "YYYY-mm-dd HH:MM" and returns the resulting string. + + Arguments: + d (datetime.datetime): The datetime instance to format + + Returns: + string: The datetime formatted as "YYYY-mm-dd HH:MM" + """ + if d is None: return None @@ -65,8 +157,20 @@ def get_formatted_datetime(d): def get_class(name): """ - Taken from http://stackoverflow.com/a/452981/2028598 + Retrieves the class object for a given fully qualified class name. + + Taken from http://stackoverflow.com/a/452981/2028598. + + Arguments: + name (string): The fully qualified class name, including all modules separated by ``.`` + + Returns: + type: The class if it could be found. + + Raises: + AttributeError: The class could not be found. """ + parts = name.split(".") module = ".".join(parts[:-1]) m = __import__(module) @@ -76,14 +180,33 @@ def get_class(name): def get_exception_string(): + """ + Retrieves the exception info of the last raised exception and returns it as a string formatted as + ``: @ ::'. + + Returns: + string: The formatted exception information. + """ + locationInfo = traceback.extract_tb(sys.exc_info()[2])[0] return "%s: '%s' @ %s:%s:%d" % (str(sys.exc_info()[0].__name__), str(sys.exc_info()[1]), os.path.basename(locationInfo[0]), locationInfo[2], locationInfo[1]) def get_free_bytes(path): """ + Retrieves the number of free bytes on the partition ``path`` is located at and returns it. Works on both Windows and + *nix. + Taken from http://stackoverflow.com/a/2372171/2028598 + + Arguments: + path (string): The path for which to check the remaining partition space. + + Returns: + int: The amount of bytes still left on the partition. """ + + path = os.path.abspath(path) if sys.platform == "win32": import ctypes freeBytes = ctypes.c_ulonglong(0) @@ -94,28 +217,109 @@ def get_free_bytes(path): return st.f_bavail * st.f_frsize -def get_dos_filename(input, existingFilenames, extension=None): - if input is None: +def get_dos_filename(origin, existing_filenames=None, extension=None, **kwargs): + """ + Converts the provided input filename to a 8.3 DOS compatible filename. If ``existing_filenames`` is provided, the + conversion result will be guaranteed not to collide with any of the filenames provided thus. + + Uses :func:`find_collision_free_name` internally. + + Arguments: + input (string): The original filename incl. extension to convert to the 8.3 format. + existing_filenames (list): A list of existing filenames with which the generated 8.3 name must not collide. + Optional. + extension (string): The .3 file extension to use for the generated filename. If not provided, the extension of + the provided ``filename`` will simply be truncated to 3 characters. + kwargs (dict): Additional keyword arguments to provide to :func:`find_collision_free_name`. + + Returns: + string: A 8.3 compatible translation of the original filename, not colliding with the optionally provided + ``existing_filenames`` and with the provided ``extension`` or the original extension shortened to + a maximum of 3 characters. + + Raises: + ValueError: No 8.3 compatible name could be found that doesn't collide with the provided ``existing_filenames``. + """ + + if origin is None: return None + if existing_filenames is None: + existing_filenames = [] + + filename, ext = os.path.splitext(origin) if extension is None: - extension = "gco" + extension = ext - filename, ext = input.rsplit(".", 1) - return find_collision_free_name(filename, extension, existingFilenames) + return find_collision_free_name(filename, extension, existing_filenames, **kwargs) -def find_collision_free_name(input, extension, existingFilenames): - filename = re.sub(r"\s+", "_", input.lower().translate({ord(i):None for i in ".\"/\\[]:;=,"})) +def find_collision_free_name(filename, extension, existing_filenames, max_power=2): + """ + Tries to find a collision free translation of "." to the 8.3 DOS compatible format, + preventing collisions with any of the ``existing_filenames``. + + First strips all of ``."/\\[]:;=,`` from the filename and extensions, converts them to lower case and truncates + the ``extension`` to a maximum length of 3 characters. + + If the filename is already equal or less than 8 characters in length after that procedure and "." + are not contained in the ``existing_files``, that concatenation will be returned as the result. + + If not, the following algorithm will be applied to try to find a collision free name:: + + set counter := power := 1 + while counter < 10^max_power: + set truncated := substr(filename, 0, 6 - power + 1) + "~" + counter + set result := "." + if result is collision free: + return result + counter++ + if counter >= 10 ** power: + power++ + raise ValueError + + This will basically -- for a given original filename of ``some_filename`` and an extension of ``gco`` -- iterate + through names of the format ``some_f~1.gco``, ``some_f~2.gco``, ..., ``some_~10.gco``, ``some_~11.gco``, ..., + ``~.gco`` for ``n`` less than 10 ^ ``max_power``, returning as soon as one is found that is not colliding. + + Arguments: + filename (string): The filename without the extension to convert to 8.3. + extension (string): The extension to convert to 8.3 -- will be truncated to 3 characters if it's longer than + that. + existing_filenames (list): A list of existing filenames to prevent name collisions with. + max_power (int): Limits the possible attempts of generating a collision free name to 10 ^ ``max_power`` + variations. Defaults to 2, so the name generation will maximally reach ``~99.`` before + aborting and raising an exception. + + Returns: + string: A 8.3 representation of the provided original filename, ensured to not collide with the provided + ``existing_filenames`` + + Raises: + ValueError: No collision free name could be found. + """ + + # TODO unit test! + + def make_valid(text): + return re.sub(r"\s+", "_", text.translate({ord(i):None for i in ".\"/\\[]:;=,"})).lower() + + filename = make_valid(filename) + extension = make_valid(extension) + extension = extension[:3] if len(extension) > 3 else extension + + if len(filename) <= 8 and not filename + "." + extension in existing_filenames: + # early exit + return filename + "." + extension counter = 1 power = 1 - while counter < (10 * power): + while counter < (10 ** max_power): result = filename[:(6 - power + 1)] + "~" + str(counter) + "." + extension - if result not in existingFilenames: + if result not in existing_filenames: return result counter += 1 - if counter == 10 * power: + if counter >= 10 ** power: power += 1 raise ValueError("Can't create a collision free filename") @@ -132,8 +336,14 @@ def safe_rename(old, new, throw_error=False): On other operating systems :func:`shutil.move` will be used instead. - @param old the path to the old file to be renamed - @param new the path to the new file to be created/replaced + Arguments: + old (string): The path to the old file to be renamed. + new (string): The path to the new file to be created/replaced. + throw_error (boolean): Whether to throw an error upon errors during the renaming procedure (True) or not + (False, default). + + Raises: + OSError: One of the renaming steps on windows failed and ``throw_error`` was True """ if sys.platform == "win32": @@ -163,7 +373,8 @@ def silent_remove(file): """ Silently removes a file. Does not raise an error if the file doesn't exist. - @param file the path of the file to be removed + Arguments: + file (string): The path of the file to be removed """ try: @@ -178,9 +389,13 @@ def sanitize_ascii(line): def filter_non_ascii(line): """ - Returns True if the line contains non-ascii characters, false otherwise + Filter predicate to test if a line contains non ASCII characters. - @param line the line to test + Arguments: + line (string): The line to test + + Returns: + boolean: True if the line contains non ASCII characters, False otherwise. """ try: @@ -191,11 +406,18 @@ def filter_non_ascii(line): def dict_merge(a, b): - '''recursively merges dict's. not just simple a['key'] = b['key'], if - both a and bhave a key who's value is a dict then dict_merge is called - on both values and the result stored in the returned dictionary. + """ + Recursively deep-merges two dictionaries. - Taken from https://www.xormedia.com/recursively-merge-dictionaries-in-python/''' + Taken from https://www.xormedia.com/recursively-merge-dictionaries-in-python/ + + Arguments: + a (dict): The dictionary to merge ``b`` into + b (dict): The dictionary to merge into ``a`` + + Returns: + dict: ``b`` deep-merged into ``a`` + """ from copy import deepcopy @@ -211,6 +433,17 @@ def dict_merge(a, b): def dict_clean(a, b): + """ + Recursively deep-cleans ``b`` from ``a``, removing all keys and corresponding values from ``a`` that appear in + ``b``. + + Arguments: + a (dict): The dictionary to clean from ``b``. + b (dict): The dictionary to clean ``b`` from. + + Results: + dict: A new dict based on ``a`` with all keys (and corresponding values) found in ``b`` removed. + """ from copy import deepcopy if not isinstance(b, dict): @@ -228,6 +461,24 @@ def dict_clean(a, b): def dict_contains_keys(a, b): + """ + Recursively deep-checks if ``a`` contains all keys found in ``b``. + + Example:: + + >>> dict_contains_keys(dict(foo="bar", fnord=dict(a=1, b=2, c=3)), dict(foo="some_other_bar", fnord=dict(b=100))) + True + >>> dict_contains_keys(dict(foo="bar", fnord=dict(a=1, b=2, c=3)), dict(foo="some_other_bar", fnord=dict(b=100, d=20))) + False + + Arguments: + a (dict): The dictionary to check for the keys from ``b``. + b (dict): The dictionary whose keys to check ``a`` for. + + Returns: + boolean: True if all keys found in ``b`` are also present in ``a``, False otherwise. + """ + if not isinstance(a, dict) or not isinstance(b, dict): return False @@ -240,11 +491,14 @@ def dict_contains_keys(a, b): return True - class Object(object): pass def interface_addresses(family=None): + """ + Retrieves all of the host's network interface addresses. + """ + import netifaces if not family: family = netifaces.AF_INET @@ -260,6 +514,10 @@ def interface_addresses(family=None): yield ifaddress["addr"] def address_for_client(host, port): + """ + Determines the address of the network interface on this host needed to connect to the indicated client host and port. + """ + import socket for address in interface_addresses():