PGMR: Detect necessity to reinstall

So far using the "... from URL" or "... from an uploaded archive"
mechanism for installing a plugin would fail without further
information if the plugin to be installed was already installed.

The plugin manager will now detect this situation by parsing the
corresponding message from the pip output and trigger a reinstall
instantly.

A message about this will be logged to octoprint.log and the install
output.

Additionally the error handling for installation errors has been
slightly improved (install output no longer says "Done!" but "Error!"
with the reason as provided by the server) and the "could not install
plugin from URL unknown" issue should also be solved.
This commit is contained in:
Gina Häußge 2017-04-04 15:18:37 +02:00
parent e109bf0ca6
commit 163fd83bf5
5 changed files with 172 additions and 64 deletions

View file

@ -261,7 +261,8 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin,
plugin_name = data["plugin"] if "plugin" in data else None
return self.command_install(url=url,
force="force" in data and data["force"] in valid_boolean_trues,
dependency_links="dependency_links" in data and data["dependency_links"] in valid_boolean_trues,
dependency_links="dependency_links" in data
and data["dependency_links"] in valid_boolean_trues,
reinstall=plugin_name)
elif command == "uninstall":
@ -282,37 +283,84 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin,
def command_install(self, url=None, path=None, force=False, reinstall=None, dependency_links=False):
if url is not None:
pip_args = ["install", sarge.shell_quote(url)]
source = url
source_type = "url"
already_installed_check = lambda line: url in line
elif path is not None:
pip_args = ["install", sarge.shell_quote(path)]
path = os.path.abspath(path)
path_url = "file://" + path
if os.sep != "/":
# windows gets special handling
path = path.replace(os.sep, "/").lower()
path_url = "file:///" + path
source = path
source_type = "path"
already_installed_check = lambda line: path_url in line.lower() # lower case in case of windows
else:
raise ValueError("Either URL or path must be provided")
self._logger.info("Installing plugin from {}".format(source))
pip_args = ["install", sarge.shell_quote(source)]
if dependency_links or self._settings.get_boolean(["dependency_links"]):
pip_args.append("--process-dependency-links")
all_plugins_before = self._plugin_manager.find_plugins()
all_plugins_before = self._plugin_manager.find_plugins(existing=dict())
already_installed_string = "Requirement already satisfied (use --upgrade to upgrade)"
success_string = "Successfully installed"
failure_string = "Could not install"
try:
returncode, stdout, stderr = self._call_pip(pip_args)
# pip's output for a package that is already installed looks something like any of these:
#
# Requirement already satisfied (use --upgrade to upgrade): OctoPrint-Plugin==1.0 from \
# https://example.com/foobar.zip in <lib>
# Requirement already satisfied (use --upgrade to upgrade): OctoPrint-Plugin in <lib>
# Requirement already satisfied (use --upgrade to upgrade): OctoPrint-Plugin==1.0 from \
# file:///tmp/foobar.zip in <lib>
# Requirement already satisfied (use --upgrade to upgrade): OctoPrint-Plugin==1.0 from \
# file:///C:/Temp/foobar.zip in <lib>
#
# If we detect any of these matching what we just tried to install, we'll need to trigger a second
# install with reinstall flags.
if not force and any(map(lambda x: x.strip().startswith(already_installed_string) and already_installed_check(x),
stdout)):
self._logger.info("Plugin to be installed from {} was already installed, forcing a reinstall".format(source))
self._log_message("Looks like the plugin was already installed. Forcing a reinstall.")
force = True
except:
self._logger.exception("Could not install plugin from %s" % url)
return make_response("Could not install plugin from URL, see the log for more details", 500)
else:
if force:
# We don't use --upgrade here because that will also happily update all our dependencies - we'd rather
# do that in a controlled manner
pip_args += ["--ignore-installed", "--force-reinstall", "--no-deps"]
try:
returncode, stdout, stderr = self._call_pip(pip_args)
except:
self._logger.exception("Could not install plugin from %s" % url)
return make_response("Could not install plugin from URL, see the log for more details", 500)
self._logger.exception("Could not install plugin from {}".format(source))
return make_response("Could not install plugin from source {}, see the log for more details"
.format(source), 500)
try:
result_line = filter(lambda x: x.startswith(success_string) or x.startswith(failure_string), stdout)[-1]
result_line = filter(lambda x: x.startswith(success_string) or x.startswith(failure_string),
stdout)[-1]
except IndexError:
result = dict(result=False, reason="Could not parse output from pip")
self._logger.error("Installing the plugin from {} failed, could not parse output from pip. "
"See plugin_pluginmanager_console.log for generated output".format(source))
result = dict(result=False,
source=source,
source_type=source_type,
reason="Could not parse output from pip, see plugin_pluginmanager_console.log "
"for generated output")
self._send_result_notification("install", result)
return jsonify(result)
@ -325,8 +373,8 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin,
# Successfully installed OctoPrint-Plugin Dependency-One Dependency-Two
# Cleaning up...
#
# So we'll need to fetch the "Successfully installed" line, strip the "Successfully" part, then split by whitespace
# and strip to get all installed packages.
# So we'll need to fetch the "Successfully installed" line, strip the "Successfully" part, then split
# by whitespace and strip to get all installed packages.
#
# We then need to iterate over all known plugins and see if either the package name or the package name plus
# version number matches one of our installed packages. If it does, that's our installed plugin.
@ -338,62 +386,53 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin,
result_line = result_line.strip()
if not result_line.startswith(success_string):
result = dict(result=False, reason="Pip did not report successful installation")
self._logger.error("Installing the plugin from {} failed, pip did not report successful installation"
.format(source))
result = dict(result=False,
source=source,
source_type=source_type,
reason="Pip did not report successful installation")
self._send_result_notification("install", result)
return jsonify(result)
installed = map(lambda x: x.strip(), result_line[len(success_string):].split(" "))
all_plugins_after = self._plugin_manager.find_plugins(existing=dict(), ignore_uninstalled=False)
for key, plugin in all_plugins_after.items():
if plugin.origin is None or plugin.origin.type != "entry_point":
continue
package_name = plugin.origin.package_name
package_version = plugin.origin.package_version
versioned_package = "{package_name}-{package_version}".format(**locals())
if package_name in installed or versioned_package in installed:
# exact match, we are done here
new_plugin_key = key
new_plugin = plugin
break
else:
# it might still be a version that got stripped by python's package resources, e.g. 1.4.5a0 => 1.4.5a
found = False
for inst in installed:
if inst.startswith(versioned_package):
found = True
break
if found:
new_plugin_key = key
new_plugin = plugin
break
else:
self._logger.warn("The plugin was installed successfully, but couldn't be found afterwards to initialize properly during runtime. Please restart OctoPrint.")
result = dict(result=True, url=url, needs_restart=True, needs_refresh=True, was_reinstalled=False, plugin="unknown")
new_plugin = self._find_installed_plugin(installed, plugins=all_plugins_after)
if new_plugin is None:
self._logger.warn("The plugin was installed successfully, but couldn't be found afterwards to "
"initialize properly during runtime. Please restart OctoPrint.")
result = dict(result=True,
source=source,
source_type=source_type,
needs_restart=True,
needs_refresh=True,
was_reinstalled=False,
plugin="unknown")
self._send_result_notification("install", result)
return jsonify(result)
self._plugin_manager.reload_plugins()
needs_restart = self._plugin_manager.is_restart_needing_plugin(new_plugin) or new_plugin_key in all_plugins_before or reinstall is not None
needs_refresh = new_plugin.implementation and isinstance(new_plugin.implementation, octoprint.plugin.ReloadNeedingPlugin)
needs_restart = self._plugin_manager.is_restart_needing_plugin(new_plugin) \
or new_plugin.key in all_plugins_before \
or reinstall is not None
needs_refresh = new_plugin.implementation \
and isinstance(new_plugin.implementation, octoprint.plugin.ReloadNeedingPlugin)
is_reinstall = self._plugin_manager.is_plugin_marked(new_plugin_key, "uninstalled")
self._plugin_manager.mark_plugin(new_plugin_key,
is_reinstall = self._plugin_manager.is_plugin_marked(new_plugin.key, "uninstalled")
self._plugin_manager.mark_plugin(new_plugin.key,
uninstalled=False,
installed=not is_reinstall and needs_restart)
self._plugin_manager.log_all_plugins()
self._logger.info("The plugin was installed successfully: {}, version {}".format(new_plugin.name, new_plugin.version))
result = dict(result=True,
url=url,
source=source,
source_type=source_type,
needs_restart=needs_restart,
needs_refresh=needs_refresh,
was_reinstalled=new_plugin_key in all_plugins_before or reinstall is not None,
was_reinstalled=new_plugin.key in all_plugins_before or reinstall is not None,
plugin=self._to_external_plugin(new_plugin))
self._send_result_notification("install", result)
return jsonify(result)
@ -512,6 +551,36 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin,
self._send_result_notification(command, result)
return jsonify(result)
def _find_installed_plugin(self, packages, plugins=None):
if plugins is None:
plugins = self._plugin_manager.find_plugins(existing=dict(), ignore_uninstalled=False)
for key, plugin in plugins.items():
if plugin.origin is None or plugin.origin.type != "entry_point":
continue
package_name = plugin.origin.package_name
package_version = plugin.origin.package_version
versioned_package = "{package_name}-{package_version}".format(**locals())
if package_name in packages or versioned_package in packages:
# exact match, we are done here
return plugin
else:
# it might still be a version that got stripped by python's package resources, e.g. 1.4.5a0 => 1.4.5a
found = False
for inst in packages:
if inst.startswith(versioned_package):
found = True
break
if found:
return plugin
return None
def _send_result_notification(self, action, result):
notification = dict(type="result", action=action)
notification.update(result)

View file

@ -1 +1 @@
table th.settings_plugin_plugin_manager_plugins_name,table td.settings_plugin_plugin_manager_plugins_name{text-overflow:ellipsis;text-align:left}table th.settings_plugin_plugin_manager_plugins_actions,table td.settings_plugin_plugin_manager_plugins_actions{text-align:center;width:80px}table th.settings_plugin_plugin_manager_plugins_actions a,table td.settings_plugin_plugin_manager_plugins_actions a{text-decoration:none;color:#000}table th.settings_plugin_plugin_manager_plugins_actions a.disabled,table td.settings_plugin_plugin_manager_plugins_actions a.disabled{color:#ccc;cursor:default}#settings_plugin_pluginmanager_repositorydialog .slimScrollDiv{margin-bottom:20px}#settings_plugin_pluginmanager_repositorydialog h4{position:relative}#settings_plugin_pluginmanager_repositorydialog h4 a.dropdown-toggle{color:inherit;text-decoration:none;font-size:14px}#settings_plugin_pluginmanager_repositorydialog h4 ul.dropdown-menu{font-size:14px}#settings_plugin_pluginmanager_repositorydialog .form-search{text-align:center;margin-bottom:5px!important}#settings_plugin_pluginmanager_repositorydialog .form-inline{padding:5px;padding-right:10px;margin-bottom:0}#settings_plugin_pluginmanager_repositorydialog .form-inline .help-block{margin-bottom:0;font-size:85%}#settings_plugin_pluginmanager_repositorydialog #settings_plugin_pluginmanager_repositorydialog_unavailable{overflow:hidden;width:100%;height:300px;background-image:url("../img/repo_unavailable.png");text-align:center;display:table}#settings_plugin_pluginmanager_repositorydialog #settings_plugin_pluginmanager_repositorydialog_unavailable div{display:table-cell;vertical-align:middle}#settings_plugin_pluginmanager_repositorydialog #settings_plugin_pluginmanager_repositorydialog_list{overflow:hidden;width:auto;height:300px}#settings_plugin_pluginmanager_repositorydialog #settings_plugin_pluginmanager_repositorydialog_list .entry{border-bottom:1px solid #ddd;padding:5px;padding-right:10px}#settings_plugin_pluginmanager_workingdialog_output .message{font-weight:bold}#settings_plugin_pluginmanager_workingdialog_output .stdout{color:#333}#settings_plugin_pluginmanager_workingdialog_output .stderr{color:#900}#settings_plugin_pluginmanager_workingdialog_output .call{color:#009}
table td.settings_plugin_plugin_manager_plugins_name,table th.settings_plugin_plugin_manager_plugins_name{text-overflow:ellipsis;text-align:left}table td.settings_plugin_plugin_manager_plugins_actions,table th.settings_plugin_plugin_manager_plugins_actions{text-align:center;width:80px}table td.settings_plugin_plugin_manager_plugins_actions a,table th.settings_plugin_plugin_manager_plugins_actions a{text-decoration:none;color:#000}table td.settings_plugin_plugin_manager_plugins_actions a.disabled,table th.settings_plugin_plugin_manager_plugins_actions a.disabled{color:#ccc;cursor:default}#settings_plugin_pluginmanager_repositorydialog .slimScrollDiv{margin-bottom:20px}#settings_plugin_pluginmanager_repositorydialog h4{position:relative}#settings_plugin_pluginmanager_repositorydialog h4 a.dropdown-toggle{color:inherit;text-decoration:none;font-size:14px}#settings_plugin_pluginmanager_repositorydialog h4 ul.dropdown-menu{font-size:14px}#settings_plugin_pluginmanager_repositorydialog .form-search{text-align:center;margin-bottom:5px!important}#settings_plugin_pluginmanager_repositorydialog .form-inline{padding:5px 10px 5px 5px;margin-bottom:0}#settings_plugin_pluginmanager_repositorydialog .form-inline .help-block{margin-bottom:0;font-size:85%}#settings_plugin_pluginmanager_repositorydialog #settings_plugin_pluginmanager_repositorydialog_unavailable{overflow:hidden;width:100%;height:300px;background-image:url(../img/repo_unavailable.png);text-align:center;display:table}#settings_plugin_pluginmanager_repositorydialog #settings_plugin_pluginmanager_repositorydialog_unavailable div{display:table-cell;vertical-align:middle}#settings_plugin_pluginmanager_repositorydialog #settings_plugin_pluginmanager_repositorydialog_list{overflow:hidden;width:auto;height:300px}#settings_plugin_pluginmanager_repositorydialog #settings_plugin_pluginmanager_repositorydialog_list .entry{border-bottom:1px solid #ddd;padding:5px 10px 5px 5px}#settings_plugin_pluginmanager_workingdialog_output .message{font-weight:700}#settings_plugin_pluginmanager_workingdialog_output .error{font-weight:700;color:#900}#settings_plugin_pluginmanager_workingdialog_output .stdout{color:#333}#settings_plugin_pluginmanager_workingdialog_output .stderr{color:#900}#settings_plugin_pluginmanager_workingdialog_output .call{color:#009}

View file

@ -279,7 +279,13 @@ $(function() {
});
},
done: function(e, data) {
self._markDone();
var response = data.result;
if (response.result) {
self._markDone();
} else {
self._markDone(response.reason);
}
self.uploadButton.unbind("click");
self.uploadFilename(undefined);
},
@ -290,7 +296,7 @@ $(function() {
type: "error",
hide: false
});
self._markDone();
self._markDone("Could not install plugin, unknown error.");
self.uploadButton.unbind("click");
self.uploadFilename(undefined);
}
@ -498,20 +504,23 @@ $(function() {
}
self._markWorking(workTitle, workText);
var onSuccess = function() {
var onSuccess = function(response) {
if (response.result) {
self._markDone();
} else {
self._markDone(response.reason)
}
self.requestData();
self.installUrl("");
},
onError = function() {
self._markDone("Could not install plugin, unknown error, please consult octoprint.log for details");
new PNotify({
title: gettext("Something went wrong"),
text: gettext("Please consult octoprint.log for details"),
type: "error",
hide: false
});
},
onAlways = function() {
self._markDone();
};
if (reinstall) {
@ -767,9 +776,14 @@ $(function() {
self.workingDialog.modal({keyboard: false, backdrop: "static", show: true});
};
self._markDone = function() {
self._markDone = function(error) {
self.working(false);
self.loglines.push({line: gettext("Done!"), stream: "message"});
if (error) {
self.loglines.push({line: gettext("Error!"), stream: "error"});
self.loglines.push({line: error, stream: "error"})
} else {
self.loglines.push({line: gettext("Done!"), stream: "message"});
}
self._scrollWorkingOutputToEnd();
};
@ -1071,22 +1085,42 @@ $(function() {
}
titleError = gettext("Something went wrong");
var url = "unknown";
if (data.hasOwnProperty("url")) {
url = data.url;
var source = "unknown";
if (data.hasOwnProperty("source")) {
source = data.source;
}
var sourceType = "unknown";
if (data.hasOwnProperty("source_type")) {
sourceType = data.source_type;
}
if (data.hasOwnProperty("reason")) {
if (data.was_reinstalled) {
textError = _.sprintf(gettext("Reinstalling the plugin from URL \"%(url)s\" failed: %(reason)s"), {reason: data.reason, url: url});
if (sourceType == "path") {
textError = _.sprintf(gettext("Reinstalling the plugin from file failed: %(reason)s"), {reason: data.reason});
} else {
textError = _.sprintf(gettext("Reinstalling the plugin from \"%(source)s\" failed: %(reason)s"), {reason: data.reason, source: source});
}
} else {
textError = _.sprintf(gettext("Installing the plugin from URL \"%(url)s\" failed: %(reason)s"), {reason: data.reason, url: url});
if (sourceType == "path") {
textError = _.sprintf(gettext("Installing the plugin from file failed: %(reason)s"), {reason: data.reason});
} else {
textError = _.sprintf(gettext("Installing the plugin from \"%(source)s\" failed: %(reason)s"), {reason: data.reason, source: source});
}
}
} else {
if (data.was_reinstalled) {
textError = _.sprintf(gettext("Reinstalling the plugin from URL \"%(url)s\" failed, please see the log for details."), {url: url});
if (sourceType == "path") {
textError = gettext("Reinstalling the plugin from file failed, please see the log for details.");
} else {
textError = _.sprintf(gettext("Reinstalling the plugin from \"%(source)s\" failed, please see the log for details."), {source: source});
}
} else {
textError = _.sprintf(gettext("Installing the plugin from URL \"%(url)s\" failed, please see the log for details."), {url: url});
if (sourceType == "path") {
textError = gettext("Installing the plugin from file failed, please see the log for details.");
} else {
textError = _.sprintf(gettext("Installing the plugin from \"%(source)s\" failed, please see the log for details."), {source: source});
}
}
}

View file

@ -89,6 +89,11 @@ table {
font-weight: bold;
}
.error {
font-weight: bold;
color: #990000;
}
.stdout {
color: #333333;
}

View file

@ -108,7 +108,7 @@
<h3 data-bind="text: workingTitle"></h3>
</div>
<div class="modal-body">
<pre id="settings_plugin_pluginmanager_workingdialog_output" class="terminal pre-scrollable" style="height: 170px" data-bind="foreach: loglines"><span data-bind="text: line, css: {stdout: stream == 'stdout', stderr: stream == 'stderr', call: stream == 'call', message: stream == 'message'}"></span><br></pre>
<pre id="settings_plugin_pluginmanager_workingdialog_output" class="terminal pre-scrollable" style="height: 170px" data-bind="foreach: loglines"><span data-bind="text: line, css: {stdout: stream == 'stdout', stderr: stream == 'stderr', call: stream == 'call', message: stream == 'message', error: stream == 'error'}"></span><br></pre>
</div>
<div class="modal-footer">
<button class="btn" data-dismiss="modal" data-bind="enable: !$root.working()" aria-hidden="true">{{ _('Close') }}</button>