From 9131b3edd63e1de24ddecc07d3b4874004b76b20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Thu, 23 Jun 2016 11:26:51 +0200 Subject: [PATCH 1/5] Allow defining a class on confirmation dialog --- src/octoprint/static/js/app/helpers.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/octoprint/static/js/app/helpers.js b/src/octoprint/static/js/app/helpers.js index f691acb4..a26f9566 100644 --- a/src/octoprint/static/js/app/helpers.js +++ b/src/octoprint/static/js/app/helpers.js @@ -529,6 +529,7 @@ function showConfirmationDialog(msg, onacknowledge, options) { var proceed = options.proceed || gettext("Proceed"); var proceedClass = options.proceedClass || "danger"; var onproceed = options.onproceed || undefined; + var dialogClass = options.dialogClass || ""; var modalHeader = $('

' + title + '

'); var modalBody = $('

' + message + '

' + question + '

'); @@ -541,6 +542,7 @@ function showConfirmationDialog(msg, onacknowledge, options) { var modal = $('
') .addClass('modal hide fade') + .addClass(dialogClass) .append($('
').addClass('modal-header').append(modalHeader)) .append($('
').addClass('modal-body').append(modalBody)) .append($('
').addClass('modal-footer').append(cancelButton).append(proceedButton)); From 4398931b5cf539fd966fb8d26ba6f23861b2bdfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Fri, 24 Jun 2016 09:23:53 +0200 Subject: [PATCH 2/5] Normalize paths for sub path testing in pluginmgr --- src/octoprint/plugin/core.py | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/octoprint/plugin/core.py b/src/octoprint/plugin/core.py index 869d15df..d30e5c77 100644 --- a/src/octoprint/plugin/core.py +++ b/src/octoprint/plugin/core.py @@ -491,9 +491,9 @@ class PluginManager(object): cmd.finalize_options() self._python_install_dir = cmd.install_lib - self._python_prefix = sys.prefix + self._python_prefix = os.path.realpath(sys.prefix) self._python_virtual_env = hasattr(sys, "real_prefix") \ - or (hasattr(sys, "base_prefix") and sys.prefix != sys.base_prefix) + or (hasattr(sys, "base_prefix") and os.path.realpath(sys.prefix) != os.path.realpath(sys.base_prefix)) @property def plugins(self): @@ -614,7 +614,7 @@ class PluginManager(object): # of the virtual env, so this check is necessary plugin.managable = os.access(plugin.location, os.W_OK) \ and (not self._python_virtual_env - or plugin.location.startswith(self._python_prefix)) + or is_sub_path_of(plugin.location, self._python_prefix)) plugin.enabled = False result[key] = plugin @@ -648,7 +648,7 @@ class PluginManager(object): def _import_plugin(self, key, f, filename, description, name=None, version=None, summary=None, author=None, url=None, license=None): try: instance = imp.load_module(key, f, filename, description) - return PluginInfo(key, filename, instance, name=name, version=version, description=summary, author=author, url=url, license=license) + return PluginInfo(key, os.path.realpath(filename), instance, name=name, version=version, description=summary, author=author, url=url, license=license) except: self.logger.exception("Error loading plugin {key}".format(key=key)) return None @@ -1239,6 +1239,27 @@ class PluginManager(object): raise ValueError("Invalid hook definition, neither a callable nor a 2-tuple (callback, order): {!r}".format(hook)) +def is_sub_path_of(path, parent): + """ + Tests if `path` is a sub path (or identical) to `path`. + + >>> is_sub_path_of("/a/b/c", "/a/b") + True + >>> is_sub_path_of("/a/b/c", "/a/b2") + False + >>> is_sub_path_of("/a/b/c", "/b/c") + False + >>> is_sub_path_of("/foo/bar/../../a/b/c", "/a/b") + True + >>> is_sub_path_of("/a/b", "/a/b") + True + """ + rel_path = os.path.relpath(os.path.realpath(path), + os.path.realpath(parent)) + return not (rel_path == os.pardir or + rel_path.startswith(os.pardir + os.sep)) + + class InstalledEntryPoint(pkginfo.Installed): def __init__(self, entry_point, metadata_version=None): From f8a4d73c30bb361e920887886b3e53b7bc788ca3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Fri, 24 Jun 2016 11:06:23 +0200 Subject: [PATCH 3/5] Also detect editable plugins installs as managable --- src/octoprint/plugin/core.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/octoprint/plugin/core.py b/src/octoprint/plugin/core.py index d30e5c77..29f47584 100644 --- a/src/octoprint/plugin/core.py +++ b/src/octoprint/plugin/core.py @@ -614,7 +614,11 @@ class PluginManager(object): # of the virtual env, so this check is necessary plugin.managable = os.access(plugin.location, os.W_OK) \ and (not self._python_virtual_env - or is_sub_path_of(plugin.location, self._python_prefix)) + or is_sub_path_of(plugin.location, self._python_prefix) + or is_editable_install(self._python_install_dir, + package_name, + module_name, + plugin.location)) plugin.enabled = False result[key] = plugin @@ -648,7 +652,7 @@ class PluginManager(object): def _import_plugin(self, key, f, filename, description, name=None, version=None, summary=None, author=None, url=None, license=None): try: instance = imp.load_module(key, f, filename, description) - return PluginInfo(key, os.path.realpath(filename), instance, name=name, version=version, description=summary, author=author, url=url, license=license) + return PluginInfo(key, filename, instance, name=name, version=version, description=summary, author=author, url=url, license=license) except: self.logger.exception("Error loading plugin {key}".format(key=key)) return None @@ -1260,6 +1264,22 @@ def is_sub_path_of(path, parent): rel_path.startswith(os.pardir + os.sep)) +def is_editable_install(install_dir, package, module, location): + package_link = os.path.join(install_dir, "{}.egg-link".format(package)) + if os.path.isfile(package_link): + expected_target = os.path.normcase(os.path.realpath(location)) + try: + with open(package_link) as f: + contents = f.readlines() + for line in contents: + target = os.path.normcase(os.path.realpath(os.path.join(line.strip(), module))) + if target == expected_target: + return True + except: + pass + return False + + class InstalledEntryPoint(pkginfo.Installed): def __init__(self, entry_point, metadata_version=None): From 0fb1a41fef7cb44569bdafeca9de92a75efca406 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Fri, 24 Jun 2016 12:18:13 +0200 Subject: [PATCH 4/5] Fix ETag computation for UIPlugins --- src/octoprint/server/views.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/octoprint/server/views.py b/src/octoprint/server/views.py index bb7e373f..dc35db57 100644 --- a/src/octoprint/server/views.py +++ b/src/octoprint/server/views.py @@ -162,7 +162,7 @@ def index(): files = collect_files() return _compute_date(files) - def compute_etag(files=None, lastmodified=None): + def compute_etag(files=None, lastmodified=None, additional=None): if callable(custom_etag): try: etag = custom_etag() @@ -178,6 +178,8 @@ def index(): if lastmodified and not isinstance(lastmodified, basestring): from werkzeug.http import http_date lastmodified = http_date(lastmodified) + if additional is None: + additional = [] import hashlib hash = hashlib.sha1() @@ -186,11 +188,13 @@ def index(): hash.update(",".join(sorted(files))) if lastmodified: hash.update(lastmodified) + for add in additional: + hash.update(add) return hash.hexdigest() decorated_view = view decorated_view = util.flask.lastmodified(lambda _: compute_lastmodified())(decorated_view) - decorated_view = util.flask.etagged(lambda _: compute_etag())(decorated_view) + decorated_view = util.flask.etagged(lambda _: compute_etag(additional=cache_key()))(decorated_view) decorated_view = util.flask.cached(timeout=-1, refreshif=validate_cache, key=cache_key, From 7ed71fad289baa411f98a2f07ae8db123beafb80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Fri, 24 Jun 2016 14:28:19 +0200 Subject: [PATCH 5/5] Improved request and preemptive caching * UiPlugins may now disable preemptive caching * Preemptive cache recording will also be disabled when preemptive caching is disabled according to settings (so far only execution on startup was prevented then) * Preemptive cache forces configured view. E.g. if plugin "someplugin" was recorded in the preemptive cache, a custom context now makes sure that only that this view will be cached even if request parameters are hard to define to get routing to work correctly for an alternative UI --- src/octoprint/plugin/types.py | 10 +++++ src/octoprint/server/__init__.py | 19 +++++++++- src/octoprint/server/util/flask.py | 16 ++++++-- src/octoprint/server/views.py | 61 +++++++++++++++++++++++------- 4 files changed, 87 insertions(+), 19 deletions(-) diff --git a/src/octoprint/plugin/types.py b/src/octoprint/plugin/types.py index 2a284483..4cf435fc 100644 --- a/src/octoprint/plugin/types.py +++ b/src/octoprint/plugin/types.py @@ -741,6 +741,16 @@ class UiPlugin(OctoPrintPlugin, SortablePlugin): """ return None + def get_ui_preemptive_caching_enabled(self): + """ + Allows to control whether the view provided by the plugin should be preemptively + cached on server startup (default) or not. + + Returns: + bool: Whether to enable preemptive caching or not + """ + return True + def get_ui_data_for_preemptive_caching(self): """ Allows defining additional data to be persisted in the preemptive cache configuration, on diff --git a/src/octoprint/server/__init__.py b/src/octoprint/server/__init__.py index 37c631e4..74bbbb27 100644 --- a/src/octoprint/server/__init__.py +++ b/src/octoprint/server/__init__.py @@ -752,6 +752,20 @@ class Server(object): entries = reversed(sorted(cache_data[route], key=lambda x: x.get("_count", 0))) for kwargs in entries: plugin = kwargs.get("plugin", None) + if plugin: + try: + plugin_info = pluginManager.get_plugin_info(plugin, require_enabled=True) + implementation = plugin_info.implementation + if implementation is None or not isinstance(implementation, octoprint.plugin.UiPlugin): + self._logger.debug("Plugin {} is not a UiPlugin, preemptive caching makes no sense".format(plugin)) + continue + if not implementation.get_ui_preemptive_caching_enabled(): + self._logger.debug("Plugin {} has disabled preemptive caching".format(plugin)) + continue + except: + self._logger.exception("Error while trying to check if plugin {} has preemptive caching enabled, skipping entry") + continue + additional_request_data = kwargs.get("_additional_request_data", dict()) kwargs = dict((k, v) for k, v in kwargs.items() if not k.startswith("_") and not k == "plugin") kwargs.update(additional_request_data) @@ -761,8 +775,9 @@ class Server(object): else: self._logger.info("Preemptively caching {} for {!r}".format(route, kwargs)) builder = EnvironBuilder(**kwargs) - with preemptive_cache.disable_access_logging(): - app(builder.get_environ(), lambda *a, **kw: None) + with preemptive_cache.cache_environment(dict(plugin=plugin if plugin is not None else "_default")): + with preemptive_cache.disable_access_logging(): + app(builder.get_environ(), lambda *a, **kw: None) except: self._logger.exception("Error while trying to preemptively cache {} for {!r}".format(route, kwargs)) diff --git a/src/octoprint/server/util/flask.py b/src/octoprint/server/util/flask.py index d175bb77..8c7c3a18 100644 --- a/src/octoprint/server/util/flask.py +++ b/src/octoprint/server/util/flask.py @@ -383,11 +383,14 @@ class PreemptiveCache(object): def __init__(self, cachefile): self.cachefile = cachefile + self.environment = None - self._lock = threading.RLock() self._logger = logging.getLogger(__name__ + "." + self.__class__.__name__) self._log_access = True + self._lock = threading.RLock() + self._environment_lock = threading.RLock() + def record(self, data, unless=None): if callable(unless) and unless(): return @@ -407,6 +410,13 @@ class PreemptiveCache(object): yield self._log_access = True + @contextlib.contextmanager + def cache_environment(self, environment): + with self._environment_lock: + self.environment = environment + yield + self.environment = None + def clean_all_data(self, cleanup_function): assert callable(cleanup_function) @@ -585,13 +595,13 @@ def conditional(condition, met): def check_etag(etag): return flask.request.method in ("GET", "HEAD") and \ - flask.request.if_none_match and \ + flask.request.if_none_match is not None and \ etag in flask.request.if_none_match def check_lastmodified(lastmodified): return flask.request.method in ("GET", "HEAD") and \ - flask.request.if_modified_since and \ + flask.request.if_modified_since is not None and \ lastmodified >= flask.request.if_modified_since diff --git a/src/octoprint/server/views.py b/src/octoprint/server/views.py index dc35db57..feb6588f 100644 --- a/src/octoprint/server/views.py +++ b/src/octoprint/server/views.py @@ -37,6 +37,8 @@ _valid_div_re = re.compile("[a-zA-Z_-]+") def index(): global _templates, _plugin_names, _plugin_vars + preemptive_cache_enabled = settings().getBoolean(["devel", "cache", "preemptive"]) + # helper to check if wizards are active def wizard_active(templates): return templates is not None and bool(templates["wizard"]["order"]) @@ -116,11 +118,13 @@ def index(): files = collect_files() lastmodified = compute_lastmodified(files) lastmodified_ok = util.flask.check_lastmodified(lastmodified) - etag_ok = util.flask.check_etag(compute_etag(files, lastmodified)) + etag_ok = util.flask.check_etag(compute_etag(files=files, + lastmodified=lastmodified, + additional=cache_key())) return lastmodified_ok and etag_ok def validate_cache(cached): - etag_different = compute_etag() != cached.get_etag()[0] + etag_different = compute_etag(additional=cache_key()) != cached.get_etag()[0] return force_refresh or etag_different def collect_files(): @@ -202,8 +206,7 @@ def index(): decorated_view = util.flask.conditional(check_etag_and_lastmodified, NOT_MODIFIED)(decorated_view) return decorated_view - ui_plugins = pluginManager.get_implementations(octoprint.plugin.UiPlugin, sorting_context="UiPlugin.on_ui_render") - for plugin in ui_plugins: + def plugin_view(plugin): if plugin.will_handle_ui(request): # plugin claims responsibility, let it render the UI cached = get_cached_view(plugin._identifier, @@ -214,17 +217,18 @@ def index(): custom_etag=plugin.get_ui_custom_etag, custom_lastmodified=plugin.get_ui_custom_lastmodified) - preemptively_cached = get_preemptively_cached_view(plugin._identifier, - cached, - plugin.get_ui_data_for_preemptive_caching, - plugin.get_ui_additional_request_data_for_preemptive_caching, - plugin.get_ui_additional_unless) + if preemptive_cache_enabled and plugin.get_ui_preemptive_caching_enabled(): + view = get_preemptively_cached_view(plugin._identifier, + cached, + plugin.get_ui_data_for_preemptive_caching, + plugin.get_ui_additional_request_data_for_preemptive_caching, + plugin.get_ui_additional_unless) + else: + view = cached - response = preemptively_cached(now, request, render_kwargs) - if response is not None: - break + return view(now, request, render_kwargs) - else: + def default_view(): wizard = wizard_active(_templates) enable_accesscontrol = userManager.enabled accesscontrol_active = enable_accesscontrol and userManager.hasBeenCustomized() @@ -254,7 +258,36 @@ def index(): cached, dict(), dict()) - response = preemptively_cached() + return preemptively_cached() + + forced_view = None + preemptive_cache_environment = preemptiveCache.environment + if preemptive_cache_environment is not None and isinstance(preemptive_cache_environment, dict): + forced_view = preemptive_cache_environment.get("plugin", "_default") + + if forced_view: + # we have view forced by the preemptive cache + _logger.debug("Forcing rendering of view {}".format(forced_view)) + response = None + if forced_view != "_default": + plugin = pluginManager.get_plugin_info(forced_view, require_enabled=True) + if plugin is not None and isinstance(plugin.implementation, octoprint.plugin.UiPlugin): + response = plugin_view(plugin.implementation) + + if response is None: + return default_view() + + else: + # select view from plugins and fall back on default view if no plugin will handle it + ui_plugins = pluginManager.get_implementations(octoprint.plugin.UiPlugin, sorting_context="UiPlugin.on_ui_render") + for plugin in ui_plugins: + identifier = plugin._identifier + if plugin.will_handle_ui(request): + response = plugin_view(plugin) + if response is not None: + break + else: + response = default_view() return response