From 7d38d664896579893b6f1ea534d53d7a5642bf66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Tue, 29 Sep 2015 13:39:08 +0200 Subject: [PATCH 01/19] PipUtil now uses --user argument with pip install if detected as necessary --- src/octoprint/util/pip.py | 208 +++++++++++++++------ src/octoprint/util/piptestballoon/setup.py | 30 +++ 2 files changed, 180 insertions(+), 58 deletions(-) create mode 100644 src/octoprint/util/piptestballoon/setup.py diff --git a/src/octoprint/util/pip.py b/src/octoprint/util/pip.py index 8d0a008f..ccf8796c 100644 --- a/src/octoprint/util/pip.py +++ b/src/octoprint/util/pip.py @@ -9,6 +9,7 @@ __copyright__ = "Copyright (C) 2015 The OctoPrint Project - Released under terms import sarge import sys import logging +import re import pkg_resources @@ -34,6 +35,8 @@ class PipCaller(CommandlineCaller): self._version = None self._version_string = None self._use_sudo = False + self._use_user = False + self._install_dir = None self.trigger_refresh() @@ -41,6 +44,14 @@ class PipCaller(CommandlineCaller): self.on_log_stdout = lambda *args, **kwargs: None self.on_log_stderr = lambda *args, **kwargs: None + def _reset(self): + self._command = None + self._version = None + self._version_string = None + self._use_sudo = False + self._use_user = False + self._install_dir = None + def __le__(self, other): return self.version is not None and self.version <= other @@ -65,17 +76,26 @@ class PipCaller(CommandlineCaller): def version_string(self): return self._version_string + @property + def install_dir(self): + return self._install_dir + @property def use_sudo(self): return self._use_sudo + @property + def use_user(self): + return self._use_user + @property def available(self): return self._command is not None def trigger_refresh(self): + self._reset() try: - self._command, self._version, self._version_string, self._use_sudo = self._find_pip() + self._setup_pip() except: self._logger.exception("Error while discovering pip command") self._command = None @@ -97,13 +117,15 @@ class PipCaller(CommandlineCaller): if self.version in self.__class__.no_use_wheel and not "--no-use-wheel" in arg_list: self._logger.debug("Version {} needs --no-use-wheel to properly work.".format(self.version)) arg_list.append("--no-use-wheel") + if self.use_user: + arg_list.append("--user") command = [self._command] + list(args) if self._use_sudo: command = ["sudo"] + command return self.call(command) - def _find_pip(self): + def _setup_pip(self): pip_command = self.configured if pip_command is not None and pip_command.startswith("sudo "): @@ -112,75 +134,145 @@ class PipCaller(CommandlineCaller): else: pip_sudo = False - pip_version = None - version_segment = None + if pip_command is None: + pip_command = self._autodetect_pip() if pip_command is None: - import os - python_command = sys.executable - binary_dir = os.path.dirname(python_command) + return - pip_command = os.path.join(binary_dir, "pip") - if sys.platform == "win32": - # Windows is a bit special... first of all the file will be called pip.exe, not just pip, and secondly - # for a non-virtualenv install (e.g. global install) the pip binary will not be located in the - # same folder as python.exe, but in a subfolder Scripts, e.g. - # - # C:\Python2.7\ - # |- python.exe - # `- Scripts - # `- pip.exe + # Determine the pip version - # virtual env? - pip_command = os.path.join(binary_dir, "pip.exe") + self._logger.debug("Found pip at {}, going to figure out its version".format(pip_command)) - if not os.path.isfile(pip_command): - # nope, let's try the Scripts folder then - scripts_dir = os.path.join(binary_dir, "Scripts") - if os.path.isdir(scripts_dir): - pip_command = os.path.join(scripts_dir, "pip.exe") + pip_version, version_segment = self._get_pip_version(pip_command, pip_sudo) + if pip_version is None: + return - if not os.path.isfile(pip_command) or not os.access(pip_command, os.X_OK): - pip_command = None + if pip_version in self.__class__.broken: + self._logger.error("This version of pip is known to have errors that make it incompatible with how it needs to be used by OctoPrint. Please upgrade your pip version.") + return - if pip_command is not None: - self._logger.debug("Found pip at {}, going to figure out its version".format(pip_command)) + self._logger.info("Version of pip at {} is {}".format(pip_command, version_segment)) - sarge_command = [pip_command, "--version"] - if pip_sudo: - sarge_command = ["sudo"] + sarge_command + # Now figure out if pip belongs to a virtual environment and if the + # default installation directory is writable. + # + # The idea is the following: If OctoPrint is installed globally, + # the site-packages folder is probably not writable by our user. + # However, the user site-packages folder as usable by providing the + # --user parameter during install is. This we may not use though if + # the provided pip belongs to a virtual env (since that hiccups hard). + # + # So we figure out the installation directory, check if it's writable + # and if not if pip belongs to a virtual environment. Only if the + # installation directory is NOT writable by us but we also don't run + # in a virtual environment may we proceed with the --user parameter. + + ok, pip_user, pip_install_dir = self._check_pip_setup(pip_command) + if not ok: + self._logger.error("Pip install directory {} is not writable and is part of a virtual environment, can't use this constellation".format(pip_install_dir)) + return + + self._logger.info("pip at {} installs to {}, --user flag needed => {}".format(pip_command, pip_install_dir, "yes" if pip_user else "no")) + + self._command = pip_command + self._version = pip_version + self._version_string = version_segment + self._use_sudo = pip_sudo + self._use_user = pip_user + self._install_dir = pip_install_dir + + def _autodetect_pip(self): + import os + python_command = sys.executable + binary_dir = os.path.dirname(python_command) + + pip_command = os.path.join(binary_dir, "pip") + if sys.platform == "win32": + # Windows is a bit special... first of all the file will be called pip.exe, not just pip, and secondly + # for a non-virtualenv install (e.g. global install) the pip binary will not be located in the + # same folder as python.exe, but in a subfolder Scripts, e.g. + # + # C:\Python2.7\ + # |- python.exe + # `- Scripts + # `- pip.exe + + # virtual env? + pip_command = os.path.join(binary_dir, "pip.exe") + + if not os.path.isfile(pip_command): + # nope, let's try the Scripts folder then + scripts_dir = os.path.join(binary_dir, "Scripts") + if os.path.isdir(scripts_dir): + pip_command = os.path.join(scripts_dir, "pip.exe") + + if not os.path.isfile(pip_command) or not os.access(pip_command, os.X_OK): + pip_command = None + + return pip_command + + def _get_pip_version(self, pip_command, pip_sudo): + sarge_command = [pip_command, "--version"] + if pip_sudo: + sarge_command = ["sudo"] + sarge_command + p = sarge.run(sarge_command, stdout=sarge.Capture(), stderr=sarge.Capture()) + + if p.returncode != 0: + self._logger.warn("Error while trying to run pip --version: {}".format(p.stderr.text)) + return None, None + + output = p.stdout.text + # output should look something like this: + # + # pip from () + # + # we'll just split on whitespace and then try to use the second entry + + if not output.startswith("pip"): + self._logger.warn("pip command returned unparseable output, can't determine version: {}".format(output)) + + split_output = map(lambda x: x.strip(), output.split()) + if len(split_output) < 2: + self._logger.warn("pip command returned unparseable output, can't determine version: {}".format(output)) + + version_segment = split_output[1] + + try: + pip_version = pkg_resources.parse_version(version_segment) + except: + self._logger.exception("Error while trying to parse version string from pip command") + return None, None + + return pip_version, version_segment + + pip_install_dir_regex = re.compile("^\s*!!! PIP_INSTALL_DIR=(.*)\s*$", re.MULTILINE) + pip_virtual_env_regex = re.compile("^\s*!!! PIP_VIRTUAL_ENV=(True|False)\s*$", re.MULTILINE) + pip_writable_regex = re.compile("^\s*!!! PIP_WRITABLE=(True|False)\s*$", re.MULTILINE) + + def _check_pip_setup(self, pip_command): + import os + testballoon = os.path.join(os.path.realpath(os.path.dirname(__file__)), "piptestballoon") + + sarge_command = [pip_command, "install", testballoon, "--verbose"] + try: p = sarge.run(sarge_command, stdout=sarge.Capture(), stderr=sarge.Capture()) - if p.returncode != 0: - self._logger.warn("Error while trying to run pip --version: {}".format(p.stderr.text)) - pip_command = None - output = p.stdout.text - # output should look something like this: - # - # pip from () - # - # we'll just split on whitespace and then try to use the second entry + install_dir_match = self.__class__.pip_install_dir_regex.search(output) + virtual_env_match = self.__class__.pip_virtual_env_regex.search(output) + writable_match = self.__class__.pip_writable_regex.search(output) - if not output.startswith("pip"): - self._logger.warn("pip command returned unparseable output, can't determine version: {}".format(output)) + if install_dir_match and virtual_env_match and writable_match: + install_dir = install_dir_match.group(1) + virtual_env = virtual_env_match.group(1) == "True" + writable = writable_match.group(1) == "True" - split_output = map(lambda x: x.strip(), output.split()) - if len(split_output) < 2: - self._logger.warn("pip command returned unparseable output, can't determine version: {}".format(output)) - - version_segment = split_output[1] - - try: - pip_version = pkg_resources.parse_version(version_segment) - except: - self._logger.exception("Error while trying to parse version string from pip command") - return None, None, None + return writable or not virtual_env, not writable and not virtual_env, install_dir else: - self._logger.info("Found pip at {}, version is {}".format(pip_command, version_segment)) + return False, False, None - if pip_version in self.__class__.broken: - self._logger.error("This version of pip is known to have errors that make it incompatible with how it needs to be used by OctoPrint. Please upgrade your pip version.") - return None, None, None, False + finally: + sarge_command = [pip_command, "uninstall", "-y", "OctoPrint-PipTestBalloon"] + sarge.run(sarge_command, stdout=sarge.Capture(), stderr=sarge.Capture()) - return pip_command, pip_version, version_segment, pip_sudo diff --git a/src/octoprint/util/piptestballoon/setup.py b/src/octoprint/util/piptestballoon/setup.py new file mode 100644 index 00000000..7e0036b7 --- /dev/null +++ b/src/octoprint/util/piptestballoon/setup.py @@ -0,0 +1,30 @@ +from setuptools import setup + +def run_checks(): + from distutils.command.install import install as cmd_install + from distutils.dist import Distribution + import sys + import os + + cmd = cmd_install(Distribution()) + cmd.finalize_options() + + install_dir = cmd.install_lib + virtual_env = hasattr(sys, "real_prefix") + writable = os.access(install_dir, os.W_OK) + + print("!!! PIP_INSTALL_DIR={}".format(install_dir)) + print("!!! PIP_VIRTUAL_ENV={}".format(virtual_env)) + print("!!! PIP_WRITABLE={}".format(writable)) + sys.stdout.flush() + +def parameters(): + run_checks() + + return dict( + name="OctoPrint-PipTestBalloon", + version="1.0", + description="Just a test balloon to check a couple of pip related settings" + ) + +setup(**parameters()) From 000f8e9310f04ead9d79136ea48e491cdc2aa276 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Tue, 29 Sep 2015 13:47:35 +0200 Subject: [PATCH 02/19] Debug logging for testballoon install --- src/octoprint/util/pip.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/octoprint/util/pip.py b/src/octoprint/util/pip.py index ccf8796c..9a860421 100644 --- a/src/octoprint/util/pip.py +++ b/src/octoprint/util/pip.py @@ -259,6 +259,8 @@ class PipCaller(CommandlineCaller): p = sarge.run(sarge_command, stdout=sarge.Capture(), stderr=sarge.Capture()) output = p.stdout.text + self._logger.debug("Got output from {}: {}".format(" ".join(sarge_command), output)) + install_dir_match = self.__class__.pip_install_dir_regex.search(output) virtual_env_match = self.__class__.pip_virtual_env_regex.search(output) writable_match = self.__class__.pip_writable_regex.search(output) From 5df576b73e320b41423e845e7ba8a63c1fc7b2c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Tue, 29 Sep 2015 13:53:48 +0200 Subject: [PATCH 03/19] Install testballoon using pip install . in testballoon folder --- src/octoprint/util/pip.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/octoprint/util/pip.py b/src/octoprint/util/pip.py index 9a860421..aad082b2 100644 --- a/src/octoprint/util/pip.py +++ b/src/octoprint/util/pip.py @@ -254,9 +254,12 @@ class PipCaller(CommandlineCaller): import os testballoon = os.path.join(os.path.realpath(os.path.dirname(__file__)), "piptestballoon") - sarge_command = [pip_command, "install", testballoon, "--verbose"] + sarge_command = [pip_command, "install", ".", "--verbose"] try: - p = sarge.run(sarge_command, stdout=sarge.Capture(), stderr=sarge.Capture()) + p = sarge.run(sarge_command, + stdout=sarge.Capture(), + stderr=sarge.Capture(), + cwd=testballoon) output = p.stdout.text self._logger.debug("Got output from {}: {}".format(" ".join(sarge_command), output)) From cae73c1ee5ad323bcb8330280fd3314105d17082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Tue, 29 Sep 2015 14:22:29 +0200 Subject: [PATCH 04/19] Cache for pip setup and version information Can take a bit of time to collect that data since it needs some invocations of pip, so we cache that data unless told otherwise. --- src/octoprint/util/pip.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/octoprint/util/pip.py b/src/octoprint/util/pip.py index aad082b2..1303caf9 100644 --- a/src/octoprint/util/pip.py +++ b/src/octoprint/util/pip.py @@ -15,6 +15,7 @@ import pkg_resources from .commandline import CommandlineCaller +_cache = dict(version=dict(), setup=dict()) class UnknownPip(Exception): pass @@ -24,12 +25,13 @@ class PipCaller(CommandlineCaller): no_use_wheel = pkg_resources.parse_requirements("pip==1.5.0") broken = pkg_resources.parse_requirements("pip>=6.0.1,<=6.0.3") - def __init__(self, configured=None): + def __init__(self, configured=None, ignore_cache=False): CommandlineCaller.__init__(self) self._logger = logging.getLogger(__name__) self.configured = configured self.refresh = False + self.ignore_cache = ignore_cache self._command = None self._version = None @@ -144,7 +146,7 @@ class PipCaller(CommandlineCaller): self._logger.debug("Found pip at {}, going to figure out its version".format(pip_command)) - pip_version, version_segment = self._get_pip_version(pip_command, pip_sudo) + pip_version, version_segment = self._get_pip_version(pip_command) if pip_version is None: return @@ -212,10 +214,12 @@ class PipCaller(CommandlineCaller): return pip_command - def _get_pip_version(self, pip_command, pip_sudo): + def _get_pip_version(self, pip_command): + if not self.ignore_cache and pip_command in _cache["version"]: + self._logger.debug("Using cached pip version information for {}".format(pip_command)) + return _cache["version"][pip_command] + sarge_command = [pip_command, "--version"] - if pip_sudo: - sarge_command = ["sudo"] + sarge_command p = sarge.run(sarge_command, stdout=sarge.Capture(), stderr=sarge.Capture()) if p.returncode != 0: @@ -244,13 +248,19 @@ class PipCaller(CommandlineCaller): self._logger.exception("Error while trying to parse version string from pip command") return None, None - return pip_version, version_segment + result = pip_version, version_segment + _cache["version"][pip_command] = result + return result pip_install_dir_regex = re.compile("^\s*!!! PIP_INSTALL_DIR=(.*)\s*$", re.MULTILINE) pip_virtual_env_regex = re.compile("^\s*!!! PIP_VIRTUAL_ENV=(True|False)\s*$", re.MULTILINE) pip_writable_regex = re.compile("^\s*!!! PIP_WRITABLE=(True|False)\s*$", re.MULTILINE) def _check_pip_setup(self, pip_command): + if not self.ignore_cache and pip_command in _cache["setup"]: + self._logger.debug("Using cached pip setup information for {}".format(pip_command)) + return _cache["setup"][pip_command] + import os testballoon = os.path.join(os.path.realpath(os.path.dirname(__file__)), "piptestballoon") @@ -273,9 +283,9 @@ class PipCaller(CommandlineCaller): virtual_env = virtual_env_match.group(1) == "True" writable = writable_match.group(1) == "True" - return writable or not virtual_env, not writable and not virtual_env, install_dir - else: - return False, False, None + result = writable or not virtual_env, not writable and not virtual_env, install_dir + _cache["setup"][pip_command] = result + return result finally: sarge_command = [pip_command, "uninstall", "-y", "OctoPrint-PipTestBalloon"] From 22509f02b5c0f6a3f4752637af68e862eb238686 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Tue, 29 Sep 2015 14:23:10 +0200 Subject: [PATCH 05/19] Make sure to include testballoon package in install --- MANIFEST.in | 1 + setup.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index abffeac2..15833310 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,6 +2,7 @@ recursive-include src/octoprint/static * recursive-include src/octoprint/templates * recursive-include src/octoprint/plugins * recursive-include src/octoprint/translations * +include src/octoprint/util/piptestballoon/setup.py include versioneer.py include src/octoprint/_version.py include AUTHORS.md diff --git a/setup.py b/setup.py index 3633a628..ec24b83f 100644 --- a/setup.py +++ b/setup.py @@ -149,7 +149,9 @@ def params(): "": "src", } package_data = { - "octoprint": octoprint_setuptools.package_data_dirs('src/octoprint', ['static', 'templates', 'plugins', 'translations']) + "octoprint": octoprint_setuptools.package_data_dirs('src/octoprint', + ['static', 'templates', 'plugins', 'translations']) + + ['util/piptestballoon/setup.py'] } include_package_data = True From 62478fd98f964a9b0af967d80406cb0a654e0110 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Tue, 29 Sep 2015 14:51:58 +0200 Subject: [PATCH 06/19] More pip data in plugin manager front end --- .../plugins/pluginmanager/__init__.py | 6 ++++-- .../pluginmanager/static/js/pluginmanager.js | 13 +++++++++++++ .../templates/pluginmanager_settings.jinja2 | 18 +++++++++++++++--- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/octoprint/plugins/pluginmanager/__init__.py b/src/octoprint/plugins/pluginmanager/__init__.py index 7b5679b7..d724f8e5 100644 --- a/src/octoprint/plugins/pluginmanager/__init__.py +++ b/src/octoprint/plugins/pluginmanager/__init__.py @@ -188,8 +188,8 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin, return jsonify(plugins=self._get_plugins(), repository=dict( - available=self._repository_available, - plugins=self._repository_plugins + available=self._repository_available, + plugins=self._repository_plugins ), os=self._get_os(), octoprint=self._get_octoprint_version_string(), @@ -197,7 +197,9 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin, available=self._pip_caller.available, command=self._pip_caller.command, version=self._pip_caller.version_string, + install_dir=self._pip_caller.install_dir, use_sudo=self._pip_caller.use_sudo, + use_user=self._pip_caller.use_user, additional_args=self._settings.get(["pip_args"]) )) diff --git a/src/octoprint/plugins/pluginmanager/static/js/pluginmanager.js b/src/octoprint/plugins/pluginmanager/static/js/pluginmanager.js index 75c8e2f3..0f61a927 100644 --- a/src/octoprint/plugins/pluginmanager/static/js/pluginmanager.js +++ b/src/octoprint/plugins/pluginmanager/static/js/pluginmanager.js @@ -82,9 +82,18 @@ $(function() { self.pipAvailable = ko.observable(false); self.pipCommand = ko.observable(); self.pipVersion = ko.observable(); + self.pipInstallDir = ko.observable(); + self.pipUseUser = ko.observable(); self.pipUseSudo = ko.observable(); self.pipAdditionalArgs = ko.observable(); + self.pipUseSudoString = ko.computed(function() { + return self.pipUseSudo() ? "yes" : "no"; + }); + self.pipUseUserString = ko.computed(function() { + return self.pipUseUser() ? "yes" : "no"; + }); + self.working = ko.observable(false); self.workingTitle = ko.observable(); self.workingDialog = undefined; @@ -211,11 +220,15 @@ $(function() { if (data.available) { self.pipCommand(data.command); self.pipVersion(data.version); + self.pipInstallDir(data.install_dir); + self.pipUseUser(data.use_user); self.pipUseSudo(data.use_sudo); self.pipAdditionalArgs(data.additional_args); } else { self.pipCommand(undefined); self.pipVersion(undefined); + self.pipInstallDir(undefined); + self.pipUseUser(data.use_user); self.pipUseSudo(undefined); self.pipAdditionalArgs(undefined); } diff --git a/src/octoprint/plugins/pluginmanager/templates/pluginmanager_settings.jinja2 b/src/octoprint/plugins/pluginmanager/templates/pluginmanager_settings.jinja2 index d399b593..9c55fac0 100644 --- a/src/octoprint/plugins/pluginmanager/templates/pluginmanager_settings.jinja2 +++ b/src/octoprint/plugins/pluginmanager/templates/pluginmanager_settings.jinja2 @@ -71,9 +71,21 @@ -

- Using pip at "" (Version , additional arguments: ) -

+
+ +
+ + Installation directory: ("--user" flag: , sudo: )
+ Additional Arguments: +
+
+