diff --git a/.travis.yml b/.travis.yml index df4eb121..dd3b081e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,4 +6,7 @@ python: install: - pip install -e .[develop] script: -- nosetests tests/ \ No newline at end of file +- nosetests --with-doctest +sudo: false +git: + depth: 250 diff --git a/.versioneer-lookup b/.versioneer-lookup index 23ad915a..0ffcdd87 100644 --- a/.versioneer-lookup +++ b/.versioneer-lookup @@ -10,13 +10,13 @@ # master shall not use the lookup table, only tags master -# maintenance is currently the branch for preparation of maintenance release 1.2.7 +# maintenance is currently the branch for preparation of maintenance release 1.2.8 # so are any fix/... branches -maintenance 1.2.7 536bb31965db17b969e7c1c53e241ddac4ae1814 -fix/.* 1.2.7 536bb31965db17b969e7c1c53e241ddac4ae1814 +maintenance 1.2.8 6c622f7c4332b71c6ece59552ffc87c146155c84 pep440-dev +fix/.* 1.2.8 6c622f7c4332b71c6ece59552ffc87c146155c84 pep440-dev # Special case disconnected checkouts, e.g. 'git checkout ' \(detached.* # every other branch is a development branch and thus gets resolved to 1.3.0-dev for now -.* 1.3.0 198d3450d94be1a2 pep440-dev +.* 1.3.0 198d3450d94be1a2 pep440-dev diff --git a/CHANGELOG.md b/CHANGELOG.md index 134b456a..bc5be009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,62 @@ * [#1047](https://github.com/foosel/OctoPrint/issues/1047) - Fixed 90 degree webcam rotation for iOS Safari. +## 1.2.7 (2015-10-20) + +### Improvements + + * [#1062](https://github.com/foosel/OctoPrint/issues/1062) - Plugin Manager + now has a configuration dialog that among other things allows defining the + used `pip` command if auto detection proves to be insufficient here. + * Allow defining additional `pip` parameters in Plugin Manager. That might + make `sudo`-less installation of plugins possible in situations where it's + tricky otherwise. + * Improved timelapse processing (backported from `devel` branch): + * Individually captured frames cannot "overtake" each other anymore through + usage of a capture queue. + * Notifications will now be shown when the capturing of the timelapse's + post roll happens, including an approximation of how long that will take. + * Usage of `requests` instead of `urllib` for fetching the snapshot, + appears to also have [positive effects on webcam compatibility](https://github.com/foosel/OctoPrint/issues/1078). + * Some more defensive escaping for various settings in the UI (e.g. webcam URL) + * Switch to more error resilient saving of configuration files and other files + modified during runtime (save to temporary file & move). Should reduce risk + of file corruption. + * Downloading GCODE and STL files should now set more fitting `Content-Type` + headers (`text/plain` and `application/sla`) for better client side support + for "Open with" like usage scenarios. + * Selecting z-triggered timelapse mode will now inform about that not working + when printing from SD card. + * Software Update Plugin: Removed "The web interface will now be reloaded" + notification after successful update since that became obsolete with + introduction of the "Reload Now" overlay. + * Updated required version of `psutil` and `netifaces` dependencies. + +### Bug Fixes + + * [#1057](https://github.com/foosel/OctoPrint/issues/1057) - Better error + resilience of the Software Update plugin against broken/incomplete update + configurations. + * [#1075](https://github.com/foosel/OctoPrint/issues/1075) - Fixed support + of `sudo` for installing plugins, but added big visible warning about it + as it's **not** recommended. + * [#1077](https://github.com/foosel/OctoPrint/issues/1077) - Do not hiccup + on [UTF-8 BOMs](https://en.wikipedia.org/wiki/Byte_order_mark) (or other + BOMs for that matter) at the beginning of GCODE files. + * Fixed an issue that caused user sessions to not be properly associated, + leading to Sessions getting duplicated, wrongly saved etc. + * Fixed internal server error (HTTP 500) response on REST API calls with + unset `Content-Type` header. + * Fixed an issue leading to drag-and-drop file uploads to trigger frontend + processing in various other file upload widgets. + * Fixed a documentation error. + * Fixed caching behaviour on GCODE/STL downloads, was setting the `ETag` + header improperly. + * Fixed GCODE viewer not properly detecting change of currently visualized + file on Windows systems. + +([Commits](https://github.com/foosel/OctoPrint/compare/1.2.6...1.2.7)) + ## 1.2.6 (2015-09-02) ### Improvements diff --git a/setup.cfg b/setup.cfg index 2c516b27..cbda2efb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,5 @@ - -# See the docstring in versioneer.py for instructions. Note that you must -# re-run 'versioneer.py setup' after changing this section, and commit the -# resulting files. +[metadata] +description-file = README.md [versioneer] VCS = git diff --git a/setup.py b/setup.py index ec24b83f..31dbb8de 100644 --- a/setup.py +++ b/setup.py @@ -49,7 +49,10 @@ EXTRA_REQUIRES = dict( # Documentation dependencies "sphinx>=1.3", "sphinxcontrib-httpdomain", - "sphinx_rtd_theme" + "sphinx_rtd_theme", + + # PyPi upload related + "pypandoc" ], # Dependencies for developing OctoPrint plugins @@ -58,6 +61,9 @@ EXTRA_REQUIRES = dict( ] ) +# Additional requirements for setup +SETUP_REQUIRES = [] + # Dependency links for any of the aforementioned dependencies DEPENDENCY_LINKS = [] @@ -118,8 +124,22 @@ def params(): version = versioneer.get_version() cmdclass = get_cmdclass() - description = "A responsive web interface for 3D printers" + description = "A snappy web interface for 3D printers" long_description = open("README.md").read() + + install_requires = INSTALL_REQUIRES + extras_require = EXTRA_REQUIRES + dependency_links = DEPENDENCY_LINKS + setup_requires = SETUP_REQUIRES + + try: + import pypandoc + setup_requires += ["setuptools-markdown"] + long_description_markdown_filename = "README.md" + del pypandoc + except: + pass + classifiers = [ "Development Status :: 4 - Beta", "Environment :: Web Environment", @@ -156,9 +176,6 @@ def params(): include_package_data = True zip_safe = False - install_requires = INSTALL_REQUIRES - extras_require = EXTRA_REQUIRES - dependency_links = DEPENDENCY_LINKS if os.environ.get('READTHEDOCS', None) == 'True': # we can't tell read the docs to please perform a pip install -e .[develop], so we help diff --git a/src/octoprint/filemanager/storage.py b/src/octoprint/filemanager/storage.py index ab34a374..15386b74 100644 --- a/src/octoprint/filemanager/storage.py +++ b/src/octoprint/filemanager/storage.py @@ -744,24 +744,6 @@ class LocalFileStorage(StorageInterface): Note that for a ``path`` without a trailing slash the last part will be considered a file name and hence be returned at second position. If you only need to convert a folder path, be sure to include a trailing slash for a string ``path`` or an empty last element for a list ``path``. - - Examples:: - - >>> storage = LocalFileStorage("/some/base/folder") - >>> storage.sanitize("some/folder/and/some file.gco") - ("/some/base/folder/some/folder/and", "some_file.gco") - >>> storage.sanitize(("some", "folder", "and", "some file.gco")) - ("/some/base/folder/some/folder/and", "some_file.gco") - >>> storage.sanitize("some file.gco") - ("/some/base/folder", "some_file.gco") - >>> storage.sanitize(("some file.gco",)) - ("/some/base/folder", "some_file.gco") - >>> storage.sanitize("") - ("/some/base/folder", "") - >>> storage.sanitize("some/folder/with/trailing/slash/") - ("/some/base/folder/some/folder/with/trailing/slash", "") - >>> storage.sanitize("some", "folder", "") - ("/some/base/folder/some/folder", "") """ name = None if isinstance(path, (str, unicode, basestring)): @@ -788,24 +770,6 @@ class LocalFileStorage(StorageInterface): Raises a :class:`ValueError` for a ``name`` containing ``/`` or ``\``. Otherwise strips any characters from the given ``name`` that are not any of the ASCII characters, digits, ``-``, ``_``, ``.``, ``(``, ``)`` or space and replaces and spaces with ``_``. - - Examples:: - - >>> storage = LocalFileStorage("/some/base/folder") - >>> storage.sanitize_name("some_file.gco") - "some_file.gco" - >>> storage.sanitize_name("some_file with (parentheses) and ümläuts and digits 123.gco") - "some_file_with_(parentheses)_and_mluts_and_digits_123.gco" - >>> storage.sanitize_name("pengüino pequeño.stl") - "pengino_pequeo.stl" - >>> storage.sanitize_name("some/folder/still/left.gco") - Traceback (most recent call last): - File "", line 1, in - ValueError: name must not contain / or \ - >>> storage.sanitize_name("also\\no\\backslashes.gco") - Traceback (most recent call last): - File "", line 1, in - ValueError: name must not contain / or \ """ if name is None: return None @@ -824,22 +788,6 @@ class LocalFileStorage(StorageInterface): Ensures that the on disk representation of ``path`` is located under the configured basefolder. Resolves all relative path elements (e.g. ``..``) and sanitizes folder names using :func:`sanitize_name`. Final path is the absolute path including leading ``basefolder`` path. - - Examples:: - - >>> storage = LocalFileStorage("/some/base/folder") - >>> storage.sanitize_path("folder/with/subfolder") - "/some/base/folder/folder/with/subfolder" - >>> storage.sanitize_path("folder/with/subfolder/../other/folder") - "/some/base/folder/folder/with/other/folder" - >>> storage.sanitize_path("/folder/with/leading/slash") - "/some/base/folder/folder/with/leading/slash" - >>> storage.sanitize_path(".folder/with/leading/dot") - "/some/base/folder/folder/with/leading/dot - >>> storage.sanitize_path("../../folder/out/of/the/basefolder") - Traceback (most recent call last): - File "", line 1, in - ValueError: path not contained in base folder: /some/folder/out/of/the/basefolder """ if path[0] == "/" or path[0] == ".": path = path[1:] diff --git a/src/octoprint/plugin/__init__.py b/src/octoprint/plugin/__init__.py index 686c7836..c1d453aa 100644 --- a/src/octoprint/plugin/__init__.py +++ b/src/octoprint/plugin/__init__.py @@ -295,19 +295,16 @@ class PluginSettings(object): self.set_preprocessors = dict(plugins=dict()) self.set_preprocessors["plugins"][plugin_key] = set_preprocessors - def prefix_path(path): - return ['plugins', self.plugin_key] + path - def prefix_path_in_args(args, index=0): result = [] if index == 0: - result.append(prefix_path(args[0])) + result.append(self._prefix_path(args[0])) result.extend(args[1:]) else: args_before = args[:index - 1] args_after = args[index + 1:] result.extend(args_before) - result.append(prefix_path(args[index])) + result.append(self._prefix_path(args[index])) result.extend(args_after) return result @@ -326,6 +323,7 @@ class PluginSettings(object): return kwargs self.access_methods = dict( + has =("has", prefix_path_in_args, add_getter_kwargs), get =("get", prefix_path_in_args, add_getter_kwargs), get_int =("getInt", prefix_path_in_args, add_getter_kwargs), get_float =("getFloat", prefix_path_in_args, add_getter_kwargs), @@ -333,7 +331,8 @@ class PluginSettings(object): set =("set", prefix_path_in_args, add_setter_kwargs), set_int =("setInt", prefix_path_in_args, add_setter_kwargs), set_float =("setFloat", prefix_path_in_args, add_setter_kwargs), - set_boolean=("setBoolean", prefix_path_in_args, add_setter_kwargs) + set_boolean=("setBoolean", prefix_path_in_args, add_setter_kwargs), + remove =("remove", prefix_path_in_args) ) self.deprecated_access_methods = dict( getInt ="get_int", @@ -344,6 +343,17 @@ class PluginSettings(object): setBoolean="set_boolean" ) + def _prefix_path(self, path=None): + if path is None: + path = list() + return ['plugins', self.plugin_key] + path + + def global_has(self, path, **kwargs): + return self.settings.has(path, **kwargs) + + def global_remove(self, path, **kwargs): + return self.settings.remove(path, **kwargs) + def global_get(self, path, **kwargs): """ Getter for retrieving settings not managed by the plugin itself from the core settings structure. Use this @@ -437,6 +447,24 @@ class PluginSettings(object): os.makedirs(path) return path + def get_all_data(self, **kwargs): + merged = kwargs.get("merged", True) + asdict = kwargs.get("asdict", True) + defaults = kwargs.get("defaults", self.defaults) + preprocessors = kwargs.get("preprocessors", self.get_preprocessors) + + kwargs.update(dict( + merged=merged, + asdict=asdict, + defaults=defaults, + preprocessors=preprocessors + )) + + return self.settings.get(self._prefix_path(), **kwargs) + + def clean_all_data(self): + self.settings.remove(self._prefix_path()) + def __getattr__(self, item): all_access_methods = self.access_methods.keys() + self.deprecated_access_methods.keys() if item in all_access_methods: diff --git a/src/octoprint/plugin/types.py b/src/octoprint/plugin/types.py index c7acb288..50f5be59 100644 --- a/src/octoprint/plugin/types.py +++ b/src/octoprint/plugin/types.py @@ -1082,18 +1082,34 @@ class BlueprintPlugin(OctoPrintPlugin, RestartNeedingPlugin): :return: the blueprint ready to be registered with Flask """ + if hasattr(self, "_blueprint"): + # if we already constructed the blueprint and hence have it cached, + # return that instance - we don't want to instance it multiple times + return self._blueprint + import flask kwargs = self.get_blueprint_kwargs() blueprint = flask.Blueprint("plugin." + self._identifier, self._identifier, **kwargs) + + # we now iterate over all members of ourselves and look if we find an attribute + # that has data originating from one of our decorators - we ignore anything + # starting with a _ to only handle public stuff 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: + # this attribute was annotated with our @route decorator 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) + if hasattr(f, "_blueprint_error_handler") and member in f._blueprint_error_handler: + # this attribute was annotated with our @error_handler decorator for code_or_exception in f._blueprint_error_handler[member]: blueprint.errorhandler(code_or_exception)(f) + + # cache and return the blueprint object + self._blueprint = blueprint return blueprint def get_blueprint_kwargs(self): @@ -1189,6 +1205,9 @@ class SettingsPlugin(OctoPrintPlugin): the plugin core system upon initialization of the implementation. """ + config_version_key = "_config_version" + """Key of the field in the settings that holds the configuration format version.""" + def on_settings_load(self): """ Loads the settings for the plugin, called by the Settings API view in order to retrieve all settings from @@ -1206,9 +1225,9 @@ class SettingsPlugin(OctoPrintPlugin): :return: the current settings of the plugin, as a dictionary """ - data = self._settings.get([], asdict=True, merged=True) - if "_config_version" in data: - del data["_config_version"] + data = self._settings.get_all_data() + if self.config_version_key in data: + del data[self.config_version_key] return data def on_settings_save(self, data): @@ -1220,7 +1239,8 @@ class SettingsPlugin(OctoPrintPlugin): .. 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. + types that were received by the Settings API view. Values identical to the default settings values + will *not* be persisted. 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`` @@ -1228,15 +1248,38 @@ class SettingsPlugin(OctoPrintPlugin): Arguments: data (dict): The settings dictionary to be saved for the plugin + + Returns: + dict: The settings that differed from the defaults and were actually saved. """ import octoprint.util - if "_config_version" in data: - del data["_config_version"] + # get the current data + current = self._settings.get_all_data() + if current is None: + current = dict() - current = self._settings.get([], asdict=True, merged=True) - merged = octoprint.util.dict_merge(current, data) - self._settings.set([], merged) + # merge our new data on top of it + new_current = octoprint.util.dict_merge(current, data) + if self.config_version_key in new_current: + del new_current[self.config_version_key] + + # determine diff dict that contains minimal set of changes against the + # default settings - we only want to persist that, not everything + diff = octoprint.util.dict_minimal_mergediff(self.get_settings_defaults(), new_current) + + version = self.get_settings_version() + + to_persist = dict(diff) + if version: + to_persist[self.config_version_key] = version + + if to_persist: + self._settings.set([], to_persist) + else: + self._settings.clean_all_data() + + return diff def get_settings_defaults(self): """ @@ -1319,6 +1362,49 @@ class SettingsPlugin(OctoPrintPlugin): """ pass + def on_settings_cleanup(self): + """ + Called after migration and initialization but before call to :func:`on_settings_initialized`. + + Plugins may overwrite this method to perform additional clean up tasks. + + The default implementation just minimizes the data persisted on disk to only contain + the differences to the defaults (in case the current data was persisted with an older + version of OctoPrint that still duplicated default data). + """ + import octoprint.util + from octoprint.settings import NoSuchSettingsPath + + try: + # let's fetch the current persisted config (so only the data on disk, + # without the defaults) + config = self._settings.get_all_data(merged=False, incl_defaults=False, error_on_path=True) + except NoSuchSettingsPath: + # no config persisted, nothing to do => get out of here + return + + if config is None: + # config is set to None, that doesn't make sense, kill it and leave + self._settings.clean_all_data() + return + + if self.config_version_key in config and config[self.config_version_key] is None: + # delete None entries for config version - it's the default, no need + del config[self.config_version_key] + + # calculate a minimal diff between the settings and the current config - + # anything already in the settings will be removed from the persisted + # config, no need to duplicate it + defaults = self.get_settings_defaults() + diff = octoprint.util.dict_minimal_mergediff(defaults, config) + + if not diff: + # no diff to defaults, no need to have anything persisted + self._settings.clean_all_data() + else: + # diff => persist only that + self._settings.set([], diff) + def on_settings_initialized(self): """ Called after the settings have been initialized and - if necessary - also been migrated through a call to diff --git a/src/octoprint/plugins/cura/static/js/cura.js b/src/octoprint/plugins/cura/static/js/cura.js index d99db545..0d091ab3 100644 --- a/src/octoprint/plugins/cura/static/js/cura.js +++ b/src/octoprint/plugins/cura/static/js/cura.js @@ -132,7 +132,7 @@ $(function() { return (item.key == data.key); }); - OctoPrint.slicing.deleteProfileForSlicer("cura", data.key, {url: data.resource}) + OctoPrint.slicing.deleteProfileForSlicer("cura", data.key, {url: data.resource()}) .done(function() { self.requestData(); self.slicingViewModel.requestData(); diff --git a/src/octoprint/printer/profile.py b/src/octoprint/printer/profile.py index 88bdfe3e..700aef07 100644 --- a/src/octoprint/printer/profile.py +++ b/src/octoprint/printer/profile.py @@ -12,7 +12,7 @@ import re import logging from octoprint.settings import settings -from octoprint.util import dict_merge, dict_clean, dict_contains_keys +from octoprint.util import dict_merge, dict_sanitize, dict_contains_keys class SaveError(Exception): pass @@ -214,7 +214,7 @@ class PrinterProfileManager(object): identifier = self._sanitize(identifier) profile["id"] = identifier - profile = dict_clean(profile, self.__class__.default) + profile = dict_sanitize(profile, self.__class__.default) if identifier == "_default": default_profile = dict_merge(self._load_default(), profile) @@ -408,7 +408,7 @@ class PrinterProfileManager(object): for path in (("volume", "width"), ("volume", "depth"), ("volume", "height"), ("extruder", "nozzleDiameter")): try: convert_value(profile, path, float) - except: + except Exception as e: self._logger.warn("Profile has invalid value for path {path!r}: {msg}".format(path=".".join(path), msg=str(e))) return False @@ -416,7 +416,7 @@ class PrinterProfileManager(object): for path in (("axes", "x", "inverted"), ("axes", "y", "inverted"), ("axes", "z", "inverted")): try: convert_value(profile, path, bool) - except: + except Exception as e: self._logger.warn("Profile has invalid value for path {path!r}: {msg}".format(path=".".join(path), msg=str(e))) return False diff --git a/src/octoprint/printer/standard.py b/src/octoprint/printer/standard.py index 5d52f588..6e7f831b 100644 --- a/src/octoprint/printer/standard.py +++ b/src/octoprint/printer/standard.py @@ -656,6 +656,8 @@ class Printer(PrinterInterface, comm.MachineComPrintCallback): if filename is not None: if sd: path_in_storage = filename + if path_in_storage.startswith("/"): + path_in_storage = path_in_storage[1:] path_on_disk = None else: path_in_storage = self._fileManager.path_in_storage(FileDestinations.LOCAL, filename) diff --git a/src/octoprint/server/__init__.py b/src/octoprint/server/__init__.py index b0b3ac6c..9ad2e635 100644 --- a/src/octoprint/server/__init__.py +++ b/src/octoprint/server/__init__.py @@ -224,7 +224,7 @@ class Server(object): set_preprocessors=set_preprocessors) return dict(settings=plugin_settings) - def settings_plugin_config_migration(name, implementation): + def settings_plugin_config_migration_and_cleanup(name, implementation): if not isinstance(implementation, octoprint.plugin.SettingsPlugin): return @@ -232,11 +232,13 @@ class Server(object): settings_migrator = implementation.on_settings_migrate if settings_version is not None and settings_migrator is not None: - stored_version = implementation._settings.get_int(["_config_version"]) + stored_version = implementation._settings.get_int([octoprint.plugin.SettingsPlugin.config_version_key]) if stored_version is None or stored_version < settings_version: settings_migrator(settings_version, stored_version) - implementation._settings.set_int(["_config_version"], settings_version) - implementation._settings.save() + implementation._settings.set_int([octoprint.plugin.SettingsPlugin.config_version_key], settings_version) + + implementation.on_settings_cleanup() + implementation._settings.save() implementation.on_settings_initialized() @@ -246,11 +248,11 @@ class Server(object): settingsPlugins = pluginManager.get_implementations(octoprint.plugin.SettingsPlugin) for implementation in settingsPlugins: try: - settings_plugin_config_migration(implementation._identifier, implementation) + settings_plugin_config_migration_and_cleanup(implementation._identifier, implementation) except: self._logger.exception("Error while trying to migrate settings for plugin {}, ignoring it".format(implementation._identifier)) - pluginManager.implementation_post_inits=[settings_plugin_config_migration] + pluginManager.implementation_post_inits=[settings_plugin_config_migration_and_cleanup] pluginManager.log_all_plugins() diff --git a/src/octoprint/server/api/system.py b/src/octoprint/server/api/system.py index fba71f43..76bc95a8 100644 --- a/src/octoprint/server/api/system.py +++ b/src/octoprint/server/api/system.py @@ -62,6 +62,9 @@ def retrieveSystemCommandsForSource(source): def executeSystemCommand(source, command): logger = logging.getLogger(__name__) + if command == "divider": + return make_response("Dividers cannot be executed", 400) + command_spec = _get_command_spec(source, command) if not command_spec: return make_response("Command {}:{} not found".format(source, command), 404) @@ -158,12 +161,18 @@ def _get_core_command_spec(action): def _get_custom_command_specs(): specs = collections.OrderedDict() + dividers = 0 for spec in s().get(["system", "actions"]): if not "action" in spec: continue copied = dict(spec) copied["source"] = "custom" - specs[spec["action"]] = copied + + action = spec["action"] + if action == "divider": + dividers += 1 + action = "divider_{}".format(dividers) + specs[action] = copied return specs diff --git a/src/octoprint/settings.py b/src/octoprint/settings.py index 75ee8142..65ffae0c 100644 --- a/src/octoprint/settings.py +++ b/src/octoprint/settings.py @@ -312,6 +312,11 @@ default_settings = { valid_boolean_trues = [True, "true", "yes", "y", "1"] """ Values that are considered to be equivalent to the boolean ``True`` value, used for type conversion in various places.""" + +class NoSuchSettingsPath(BaseException): + pass + + class Settings(object): """ The :class:`Settings` class allows managing all of OctoPrint's settings. It takes care of initializing the settings @@ -832,13 +837,13 @@ class Settings(object): stat = os.stat(self._configfile) return stat.st_mtime - #~~ getter + ##~~ Internal getter - def get(self, path, asdict=False, config=None, defaults=None, preprocessors=None, merged=False, incl_defaults=True): + def _get_value(self, path, asdict=False, config=None, defaults=None, preprocessors=None, merged=False, incl_defaults=True): import octoprint.util as util if len(path) == 0: - return None + raise NoSuchSettingsPath() if config is None: config = self._config @@ -856,7 +861,7 @@ class Settings(object): config = {} defaults = defaults[key] else: - return None + raise NoSuchSettingsPath() if preprocessors and isinstance(preprocessors, dict) and key in preprocessors: preprocessors = preprocessors[key] @@ -880,7 +885,7 @@ class Settings(object): elif incl_defaults and key in defaults: value = defaults[key] else: - value = None + raise NoSuchSettingsPath() if preprocessors and isinstance(preprocessors, dict) and key in preprocessors and callable(preprocessors[key]): value = preprocessors[key](value) @@ -898,8 +903,34 @@ class Settings(object): else: return results - def getInt(self, path, config=None, defaults=None, preprocessors=None, incl_defaults=True): - value = self.get(path, config=config, defaults=defaults, preprocessors=preprocessors, incl_defaults=incl_defaults) + #~~ has + + def has(self, path, **kwargs): + try: + self._get_value(path, **kwargs) + except NoSuchSettingsPath: + return False + else: + return True + + #~~ getter + + def get(self, path, **kwargs): + error_on_path = kwargs.get("error_on_path", False) + new_kwargs = dict(kwargs) + if "error_on_path" in new_kwargs: + del new_kwargs["error_on_path"] + + try: + return self._get_value(path, **new_kwargs) + except NoSuchSettingsPath: + if error_on_path: + raise + else: + return None + + def getInt(self, path, **kwargs): + value = self.get(path, **kwargs) if value is None: return None @@ -909,8 +940,8 @@ class Settings(object): self._logger.warn("Could not convert %r to a valid integer when getting option %r" % (value, path)) return None - def getFloat(self, path, config=None, defaults=None, preprocessors=None, incl_defaults=True): - value = self.get(path, config=config, defaults=defaults, preprocessors=preprocessors, incl_defaults=incl_defaults) + def getFloat(self, path, **kwargs): + value = self.get(path, **kwargs) if value is None: return None @@ -920,8 +951,8 @@ class Settings(object): self._logger.warn("Could not convert %r to a valid integer when getting option %r" % (value, path)) return None - def getBoolean(self, path, config=None, defaults=None, preprocessors=None, incl_defaults=True): - value = self.get(path, config=config, defaults=defaults, preprocessors=preprocessors, incl_defaults=incl_defaults) + def getBoolean(self, path, **kwargs): + value = self.get(path, **kwargs) if value is None: return None if isinstance(value, bool): @@ -974,6 +1005,23 @@ class Settings(object): return script + #~~ remove + + def remove(self, path, config=None): + if config is None: + config = self._config + + while len(path) > 1: + key = path.pop(0) + if not isinstance(config, dict) or key not in config: + return + config = config[key] + + key = path.pop(0) + if isinstance(config, dict) and key in config: + del config[key] + self._dirty = True + #~~ setter def set(self, path, value, force=False, defaults=None, config=None, preprocessors=None): @@ -1020,9 +1068,9 @@ class Settings(object): config[key] = value self._dirty = True - def setInt(self, path, value, force=False, defaults=None, config=None, preprocessors=None): + def setInt(self, path, value, **kwargs): if value is None: - self.set(path, None, config=config, force=force, defaults=defaults, preprocessors=preprocessors) + self.set(path, None, **kwargs) return try: @@ -1031,11 +1079,11 @@ class Settings(object): self._logger.warn("Could not convert %r to a valid integer when setting option %r" % (value, path)) return - self.set(path, intValue, config=config, force=force, defaults=defaults, preprocessors=preprocessors) + self.set(path, intValue, **kwargs) - def setFloat(self, path, value, force=False, defaults=None, config=None, preprocessors=None): + def setFloat(self, path, value, **kwargs): if value is None: - self.set(path, None, config=config, force=force, defaults=defaults, preprocessors=preprocessors) + self.set(path, None, **kwargs) return try: @@ -1044,15 +1092,15 @@ class Settings(object): self._logger.warn("Could not convert %r to a valid integer when setting option %r" % (value, path)) return - self.set(path, floatValue, config=config, force=force, defaults=defaults, preprocessors=preprocessors) + self.set(path, floatValue, **kwargs) - def setBoolean(self, path, value, force=False, defaults=None, config=None, preprocessors=None): + def setBoolean(self, path, value, **kwargs): if value is None or isinstance(value, bool): - self.set(path, value, config=config, force=force, defaults=defaults, preprocessors=preprocessors) + self.set(path, value, **kwargs) elif value.lower() in valid_boolean_trues: - self.set(path, True, config=config, force=force, defaults=defaults, preprocessors=preprocessors) + self.set(path, True, **kwargs) else: - self.set(path, False, config=config, force=force, defaults=defaults, preprocessors=preprocessors) + self.set(path, False, **kwargs) def setBaseFolder(self, type, path, force=False): if type not in default_settings["folder"].keys(): diff --git a/src/octoprint/static/js/app/client/control.js b/src/octoprint/static/js/app/client/control.js index d1a0eae7..a9b7187a 100644 --- a/src/octoprint/static/js/app/client/control.js +++ b/src/octoprint/static/js/app/client/control.js @@ -22,8 +22,21 @@ }, opts); }; + var sendGcodeScriptWithParameters = function(script, context, parameters, opts) { + script = script || ""; + context = context || {}; + parameters = parameters || {}; + + return OctoPrint.postJson(commandUrl, { + script: script, + context: context, + parameters: parameters + }, opts); + }; + OctoPrint.control = { sendGcodeWithParameters: sendGcodeWithParameters, + sendGcodeScriptWithParameters: sendGcodeScriptWithParameters, getCustomControls: function (opts) { return OctoPrint.get(customUrl, opts); @@ -34,13 +47,7 @@ }, sendGcodeScript: function (script, context, opts) { - script = script || ""; - context = context || {}; - - return OctoPrint.postJson(commandUrl, { - script: script, - context: context - }, opts); + return sendGcodeScriptWithParameters(script, context, undefined, opts); } } }); diff --git a/src/octoprint/static/js/app/viewmodels/control.js b/src/octoprint/static/js/app/viewmodels/control.js index 70865873..d6c944c9 100644 --- a/src/octoprint/static/js/app/viewmodels/control.js +++ b/src/octoprint/static/js/app/viewmodels/control.js @@ -287,30 +287,26 @@ $(function() { }; self.sendCustomCommand = function (command) { - if (!command) - return; + if (!command) return; + + var parameters = {}; + if (command.hasOwnProperty("input")) { + _.each(command.input, function (input) { + if (!input.hasOwnProperty("parameter") || !input.hasOwnProperty("value")) { + return; + } + + parameters[input.parameter] = input.value(); + }); + } if (command.hasOwnProperty("command") || command.hasOwnProperty("commands")) { var commands = command.commands || [command.command]; - - if (command.hasOwnProperty("input")) { - var parameters = {}; - _.each(command.input, function(input) { - if (!input.hasOwnProperty("parameter") || !input.hasOwnProperty("value")) { - return; - } - - parameters[input.parameter] = input.value(); - }); - OctoPrint.control.sendGcodeWithParameters(commands, parameters); - } else { - OctoPrint.control.sendGcode(commands); - } + OctoPrint.control.sendGcodeWithParameters(commands, parameters); } else if (command.hasOwnProperty("script")) { var script = command.script; var context = command.context || {}; - - OctoPrint.control.sendGcodeScript(script, context); + OctoPrint.control.sendGcodeScriptWithParameters(script, context, parameters); } }; diff --git a/src/octoprint/util/__init__.py b/src/octoprint/util/__init__.py index 9bfb0040..d5066e48 100644 --- a/src/octoprint/util/__init__.py +++ b/src/octoprint/util/__init__.py @@ -383,6 +383,14 @@ def dict_merge(a, b): Taken from https://www.xormedia.com/recursively-merge-dictionaries-in-python/ + Example:: + + >>> a = dict(foo="foo", bar="bar", fnord=dict(a=1)) + >>> b = dict(foo="other foo", fnord=dict(b=2, l=["some", "list"])) + >>> expected = dict(foo="other foo", bar="bar", fnord=dict(a=1, b=2, l=["some", "list"])) + >>> dict_merge(a, b) == expected + True + Arguments: a (dict): The dictionary to merge ``b`` into b (dict): The dictionary to merge into ``a`` @@ -404,14 +412,24 @@ def dict_merge(a, b): return result -def dict_clean(a, b): +def dict_sanitize(a, b): """ - Recursively deep-cleans ``b`` from ``a``, removing all keys and corresponding values from ``a`` that appear in - ``b``. + Recursively deep-sanitizes ``a`` based on ``b``, removing all keys (and + associated values) from ``a`` that do not appear in ``b``. + + Example:: + + >>> a = dict(foo="foo", bar="bar", fnord=dict(a=1, b=2, l=["some", "list"])) + >>> b = dict(foo=None, fnord=dict(a=None, b=None)) + >>> expected = dict(foo="foo", fnord=dict(a=1, b=2)) + >>> dict_sanitize(a, b) == expected + True + >>> dict_clean(a, b) == expected + True Arguments: - a (dict): The dictionary to clean from ``b``. - b (dict): The dictionary to clean ``b`` from. + a (dict): The dictionary to clean against ``b``. + b (dict): The dictionary containing the key structure to clean from ``a``. Results: dict: A new dict based on ``a`` with all keys (and corresponding values) found in ``b`` removed. @@ -426,21 +444,87 @@ def dict_clean(a, b): if not k in b: del result[k] elif isinstance(v, dict): - result[k] = dict_clean(v, b[k]) + result[k] = dict_sanitize(v, b[k]) else: result[k] = deepcopy(v) return result +dict_clean = deprecated("dict_clean has been renamed to dict_sanitize", + includedoc="Replaced by :func:`dict_sanitize`")(dict_sanitize) -def dict_contains_keys(a, b): +def dict_minimal_mergediff(source, target): """ - Recursively deep-checks if ``a`` contains all keys found in ``b``. + Recursively calculates the minimal dict that would be needed to be deep merged with + a in order to produce the same result as deep merging a and 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))) + >>> a = dict(foo=dict(a=1, b=2), bar=dict(c=3, d=4)) + >>> b = dict(bar=dict(c=3, d=5), fnord=None) + >>> c = dict_minimal_mergediff(a, b) + >>> c == dict(bar=dict(d=5), fnord=None) 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))) + >>> dict_merge(a, c) == dict_merge(a, b) + True + + Arguments: + source (dict): Source dictionary + target (dict): Dictionary to compare to source dictionary and derive diff for + + Returns: + dict: The minimal dictionary to deep merge on ``source`` to get the same result + as deep merging ``target`` on ``source``. + """ + + if not isinstance(source, dict) or not isinstance(target, dict): + raise ValueError("source and target must be dictionaries") + + if source == target: + # shortcut: if both are equal, we return an empty dict as result + return dict() + + from copy import deepcopy + + all_keys = set(source.keys() + target.keys()) + result = dict() + for k in all_keys: + if k not in target: + # key not contained in b => not contained in result + continue + + if k in source: + # key is present in both dicts, we have to take a look at the value + value_source = source[k] + value_target = target[k] + + if value_source != value_target: + # we only need to look further if the values are not equal + + if isinstance(value_source, dict) and isinstance(value_target, dict): + # both are dicts => deeper down it goes into the rabbit hole + result[k] = dict_minimal_mergediff(value_source, value_target) + else: + # new b wins over old a + result[k] = deepcopy(value_target) + + else: + # key is new, add it + result[k] = deepcopy(target[k]) + return result + + +def dict_contains_keys(keys, dictionary): + """ + Recursively deep-checks if ``dictionary`` contains all keys found in ``keys``. + + Example:: + + >>> positive = dict(foo="some_other_bar", fnord=dict(b=100)) + >>> negative = dict(foo="some_other_bar", fnord=dict(b=100, d=20)) + >>> dictionary = dict(foo="bar", fnord=dict(a=1, b=2, c=3)) + >>> dict_contains_keys(positive, dictionary) + True + >>> dict_contains_keys(negative, dictionary) False Arguments: @@ -451,14 +535,14 @@ def dict_contains_keys(a, b): 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): + if not isinstance(keys, dict) or not isinstance(dictionary, dict): return False - for k, v in a.iteritems(): - if not k in b: + for k, v in keys.iteritems(): + if not k in dictionary: return False elif isinstance(v, dict): - if not dict_contains_keys(v, b[k]): + if not dict_contains_keys(v, dictionary[k]): return False return True diff --git a/tests/plugin/test_types_blueprint.py b/tests/plugin/test_types_blueprint.py new file mode 100644 index 00000000..17df406c --- /dev/null +++ b/tests/plugin/test_types_blueprint.py @@ -0,0 +1,139 @@ +import unittest +import mock + +import octoprint.plugin + +class BlueprintPluginTest(unittest.TestCase): + + def setUp(self): + self.basefolder = "/some/funny/basefolder" + + self.plugin = octoprint.plugin.BlueprintPlugin() + self.plugin._basefolder = self.basefolder + + class MyAssetPlugin(octoprint.plugin.BlueprintPlugin, octoprint.plugin.AssetPlugin): + def get_asset_folder(self): + return "/some/asset/folder" + + class MyTemplatePlugin(octoprint.plugin.BlueprintPlugin, octoprint.plugin.TemplatePlugin): + def get_template_folder(self): + return "/some/template/folder" + + self.assetplugin = MyAssetPlugin() + self.assetplugin._basefolder = self.basefolder + + self.templateplugin = MyTemplatePlugin() + self.templateplugin._basefolder = self.basefolder + + def test_route(self): + + def test_method(): + pass + + octoprint.plugin.BlueprintPlugin.route("/test/method", methods=["GET"])(test_method) + octoprint.plugin.BlueprintPlugin.route("/test/method/{foo}", methods=["PUT"])(test_method) + + self.assertTrue(hasattr(test_method, "_blueprint_rules")) + self.assertTrue("test_method" in test_method._blueprint_rules) + self.assertTrue(len(test_method._blueprint_rules["test_method"]) == 2) + self.assertListEqual(test_method._blueprint_rules["test_method"], [ + ("/test/method", dict(methods=["GET"])), + ("/test/method/{foo}", dict(methods=["PUT"])) + ]) + + def test_errorhandler(self): + + def test_method(): + pass + + octoprint.plugin.BlueprintPlugin.errorhandler(404)(test_method) + + self.assertTrue(hasattr(test_method, "_blueprint_error_handler")) + self.assertTrue("test_method" in test_method._blueprint_error_handler) + self.assertTrue(len(test_method._blueprint_error_handler["test_method"]) == 1) + self.assertListEqual(test_method._blueprint_error_handler["test_method"], [ + 404 + ]) + + def test_get_blueprint_kwargs(self): + import os + expected = dict( + static_folder=os.path.join(self.basefolder, "static"), + template_folder=os.path.join(self.basefolder, "templates") + ) + + result = self.plugin.get_blueprint_kwargs() + + self.assertEquals(result, expected) + + def test_get_blueprint_kwargs_assetplugin(self): + import os + expected = dict( + static_folder=self.assetplugin.get_asset_folder(), + template_folder=os.path.join(self.basefolder, "templates") + ) + + result = self.assetplugin.get_blueprint_kwargs() + + self.assertEquals(result, expected) + + def test_get_blueprint_kwargs_templateplugin(self): + import os + expected = dict( + static_folder=os.path.join(self.basefolder, "static"), + template_folder=self.templateplugin.get_template_folder() + ) + + result = self.templateplugin.get_blueprint_kwargs() + + self.assertEquals(result, expected) + + def test_get_blueprint(self): + import os + expected_kwargs = dict( + static_folder=os.path.join(self.basefolder, "static"), + template_folder=os.path.join(self.basefolder, "templates") + ) + + class MyPlugin(octoprint.plugin.BlueprintPlugin): + + @octoprint.plugin.BlueprintPlugin.route("/some/path", methods=["GET"]) + def route_method(self): + pass + + @octoprint.plugin.BlueprintPlugin.errorhandler(404) + def errorhandler_method(self): + pass + + @octoprint.plugin.BlueprintPlugin.route("/hidden/path", methods=["GET"]) + def _hidden_method(self): + pass + + plugin = MyPlugin() + plugin._basefolder = self.basefolder + plugin._identifier = "myplugin" + + with mock.patch("flask.Blueprint") as MockBlueprint: + blueprint = mock.MagicMock() + MockBlueprint.return_value = blueprint + + errorhandler = mock.MagicMock() + blueprint.errorhandler.return_value = errorhandler + + result = plugin.get_blueprint() + + self.assertEquals(result, blueprint) + + MockBlueprint.assert_called_once_with("plugin.myplugin", "myplugin", **expected_kwargs) + blueprint.add_url_rule.assert_called_once_with("/some/path", "route_method", view_func=plugin.route_method, methods=["GET"]) + + blueprint.errorhandler.assert_called_once_with(404) + errorhandler.assert_called_once_with(plugin.errorhandler_method) + + def test_get_blueprint_cached(self): + blueprint = mock.MagicMock() + self.plugin._blueprint = blueprint + + result = self.plugin.get_blueprint() + + self.assertEquals(blueprint, result) diff --git a/tests/plugin/test_types_settings.py b/tests/plugin/test_types_settings.py new file mode 100644 index 00000000..31a53df7 --- /dev/null +++ b/tests/plugin/test_types_settings.py @@ -0,0 +1,313 @@ +import unittest +import mock + +import octoprint.plugin + +class TestSettingsPlugin(unittest.TestCase): + + def setUp(self): + self.settings = mock.MagicMock() + + self.plugin = octoprint.plugin.SettingsPlugin() + self.plugin._settings = self.settings + + def test_on_settings_cleanup(self): + """Tests that after cleanup only minimal config is left in storage.""" + + ### setup + + # settings defaults + defaults = dict( + foo=dict( + a=1, + b=2, + l1=["some", "list"], + l2=["another", "list"] + ), + bar=True, + fnord=None + ) + self.plugin.get_settings_defaults = mock.MagicMock() + self.plugin.get_settings_defaults.return_value = defaults + + # stored config, containing one redundant entry (bar=True, same as default) + in_config = dict( + foo=dict( + l1=["some", "other", "list"], + l2=["another", "list"], + l3=["a", "third", "list"] + ), + bar=True, + fnord=dict( + c=3, + d=4 + ) + ) + self.settings.get_all_data.return_value = in_config + + ### execute + + self.plugin.on_settings_cleanup() + + ### assert + + # minimal config (current without redundant value) should have been set + expected = dict( + foo=dict( + l1=["some", "other", "list"], + l3=["a", "third", "list"] + ), + fnord=dict( + c=3, + d=4 + ) + ) + self.settings.set.assert_called_once_with([], expected) + + def test_on_settings_cleanup_configversion(self): + """Tests that set config version is always left stored.""" + + ### setup + + defaults = dict( + foo="fnord" + ) + self.plugin.get_settings_defaults = mock.MagicMock() + self.plugin.get_settings_defaults.return_value = defaults + + in_config = dict( + _config_version=1, + foo="fnord" + ) + self.settings.get_all_data.return_value = in_config + + ### execute + + self.plugin.on_settings_cleanup() + + ### assert + + # minimal config incl. config version should have been set + self.settings.set.assert_called_once_with([], dict(_config_version=1)) + + def test_on_settings_cleanup_noconfigversion(self): + """Tests that config versions of None are cleaned from stored data.""" + + ### setup + + defaults = dict( + foo="bar" + ) + self.plugin.get_settings_defaults = mock.MagicMock() + self.plugin.get_settings_defaults.return_value = defaults + + # stored config version is None + in_config = dict( + _config_version=None, + foo="fnord" + ) + self.settings.get_all_data.return_value = in_config + + ### execute + + self.plugin.on_settings_cleanup() + + ### assert + + # minimal config without config version should have been set + self.settings.set.assert_called_once_with([], dict(foo="fnord")) + + def test_on_settings_cleanup_emptydiff(self): + """Tests that settings are cleaned up if the diff data <-> defaults is empty.""" + + ### setup + + defaults = dict( + foo="bar" + ) + self.plugin.get_settings_defaults = mock.MagicMock() + self.plugin.get_settings_defaults.return_value = defaults + + # current stored config, same as defaults + in_config = dict( + foo="bar" + ) + self.settings.get_all_data.return_value = in_config + + ### execute + + self.plugin.on_settings_cleanup() + + ### assert + + # should have been cleared + self.settings.clean_all_data.assert_called_once_with() + + def test_on_settings_cleanup_nosuchpath(self): + """Tests that no processing is done if nothing is stored in settings.""" + + from octoprint.settings import NoSuchSettingsPath + + ### setup + + # simulate no settings stored in config.yaml + self.settings.get_all_data.side_effect = NoSuchSettingsPath() + + ### execute + + self.plugin.on_settings_cleanup() + + ### assert + + # only get_all_data should have been called + self.settings.get_all_data.assert_called_once_with(merged=False, incl_defaults=False, error_on_path=True) + self.assertTrue(len(self.settings.method_calls) == 1) + + def test_on_settings_cleanup_none(self): + """Tests the None entries in config get cleaned up.""" + + ### setup + + # simulate None entry in config.yaml + self.settings.get_all_data.return_value = None + + ### execute + + self.plugin.on_settings_cleanup() + + ### assert + + # should have been cleaned + self.settings.clean_all_data.assert_called_once_with() + + def test_on_settings_save(self): + """Tests that only the diff is saved.""" + + ### setup + + current = dict( + foo="bar" + ) + self.settings.get_all_data.return_value = current + + defaults = dict( + foo="foo", + bar=dict( + a=1, + b=2 + ) + ) + self.plugin.get_settings_defaults = mock.MagicMock() + self.plugin.get_settings_defaults.return_value = defaults + + ### execute + + data = dict( + foo="fnord", + bar=dict( + a=1, + b=2 + ) + ) + diff = self.plugin.on_settings_save(data) + + ### assert + + # the minimal diff should have been saved + expected = dict( + foo="fnord" + ) + self.settings.set.assert_called_once_with([], expected) + + self.assertEquals(diff, expected) + + def test_on_settings_save_nodiff(self): + """Tests that data is cleaned if there's not difference between data and defaults.""" + + ### setup + + self.settings.get_all_data.return_value = None + + defaults = dict( + foo="bar", + bar=dict( + a=1, + b=2, + l=["some", "list"] + ) + ) + self.plugin.get_settings_defaults = mock.MagicMock() + self.plugin.get_settings_defaults.return_value = defaults + + ### execute + + data = dict(foo="bar") + diff = self.plugin.on_settings_save(data) + + ### assert + + self.settings.clean_all_data.assert_called_once_with() + self.assertEquals(diff, dict()) + + def test_on_settings_save_configversion(self): + """Tests that saved data gets stripped config version and set correct one.""" + + ### setup + + self.settings.get_all_data.return_value = None + + defaults = dict( + foo="bar" + ) + self.plugin.get_settings_defaults = mock.MagicMock() + self.plugin.get_settings_defaults.return_value = defaults + + version = 1 + self.plugin.get_settings_version = mock.MagicMock() + self.plugin.get_settings_version.return_value = version + + ### execute + + data = dict(_config_version=None, foo="bar") + diff = self.plugin.on_settings_save(data) + + ### assert + + expected_diff = dict() + expected_set = dict(_config_version=version) + + # while there was no diff, we should still have saved the new config version + self.settings.set.assert_called_once_with([], expected_set) + + self.assertEquals(diff, expected_diff) + + def test_on_settings_load(self): + """Tests that on_settings_load returns what's stored in the config, without config version.""" + + ### setup + + # current data incl. config version + current = dict( + _config_version=3, + foo="bar", + fnord=dict( + a=1, + b=2, + l=["some", "list"] + ) + ) + + # expected is current without _config_version - we make the copy now + # since our current dict will be modified by the test + expected = dict(current) + del expected["_config_version"] + + self.settings.get_all_data.return_value = expected + + ### execute + + result = self.plugin.on_settings_load() + + ### assert + + self.assertEquals(result, expected)