diff --git a/src/octoprint/plugin/core.py b/src/octoprint/plugin/core.py index a9d7f061..52528f1d 100644 --- a/src/octoprint/plugin/core.py +++ b/src/octoprint/plugin/core.py @@ -493,9 +493,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): @@ -616,7 +616,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 plugin.location.startswith(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 @@ -1258,6 +1262,43 @@ 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)) + + +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): 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 a82a85a8..4f3e565d 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 bb7e373f..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(): @@ -162,7 +166,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 +182,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 +192,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, @@ -198,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, @@ -210,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() @@ -250,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 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 = $('
' + 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));