瀏覽代碼

Make extension install command find compatible version (#3608)

* Make extension install compatible version

* Update semver file

* Add test + fix issues for compatible extension install

* Cleanup debugging

* Refactor + fix fetching of package metadata

- Move function out of class
- Ensure scoped packages work
- Move exception handling up, handle wider exception type

* Extract parts of _test_overlap for comparison

_compare_ranges gives -1, 0, 1, specifying which direction the ranges
are compared to each other if the do not overlap.

* Don't pass tempdir to _install_extension

Where _install_extension unpacks seems as in internal detail. If
reverting, ensure a new tempdir is passed in recursive call.

* Remove debug print

* Move warning to avoid double-printing

* More helpful error for incompatible versions

* Check whether older version is valid

When searching for the latest compatible package, ensure a version that
satisfies the dependencies are also valid. If not we can end up with
really old package versions that were simply not declared correctly (or
following older guidlines).

* Do pass tempdir to _install_extension

But make sure that recursive call gets a clean dir.

* Fix test patch
Vidar Tonaas Fauske 7 年之前
父節點
當前提交
3a77ad822b
共有 3 個文件被更改,包括 415 次插入115 次删除
  1. 184 4
      jupyterlab/commands.py
  2. 167 110
      jupyterlab/semver.py
  3. 64 1
      jupyterlab/tests/test_jupyterlab.py

+ 184 - 4
jupyterlab/commands.py

@@ -19,12 +19,14 @@ import site
 import sys
 import sys
 import tarfile
 import tarfile
 from threading import Event
 from threading import Event
+from urllib.request import Request, urlopen, urljoin, quote
+from urllib.error import URLError
 
 
 from ipython_genutils.tempdir import TemporaryDirectory
 from ipython_genutils.tempdir import TemporaryDirectory
 from jupyter_core.paths import jupyter_config_path
 from jupyter_core.paths import jupyter_config_path
 from notebook.nbextensions import GREEN_ENABLED, GREEN_OK, RED_DISABLED, RED_X
 from notebook.nbextensions import GREEN_ENABLED, GREEN_OK, RED_DISABLED, RED_X
 
 
-from .semver import Range, gte, lt, lte, gt
+from .semver import Range, gte, lt, lte, gt, make_semver
 from .jlpmapp import YARN_PATH, HERE, which
 from .jlpmapp import YARN_PATH, HERE, which
 from .process import Process, WatchHelper
 from .process import Process, WatchHelper
 
 
@@ -328,6 +330,8 @@ class _AppHandler(object):
         self.logger = logger or logging.getLogger('jupyterlab')
         self.logger = logger or logging.getLogger('jupyterlab')
         self.info = self._get_app_info()
         self.info = self._get_app_info()
         self.kill_event = kill_event or Event()
         self.kill_event = kill_event or Event()
+        # TODO: Make this configurable
+        self.registry = 'https://registry.npmjs.org'
 
 
     def install_extension(self, extension, existing=None):
     def install_extension(self, extension, existing=None):
         """Install an extension package into JupyterLab.
         """Install an extension package into JupyterLab.
@@ -1117,6 +1121,32 @@ class _AppHandler(object):
             msg = _format_compatibility_errors(
             msg = _format_compatibility_errors(
                 data['name'], data['version'], errors
                 data['name'], data['version'], errors
             )
             )
+            # Check for compatible version unless:
+            # - A specific version was requested (@ in name,
+            #   but after first char to allow for scope marker).
+            # - Package is locally installed.
+            if '@' not in extension[1:] and not info['is_dir']:
+                name = info['name']
+                try:
+                    version = self._latest_compatible_package_version(name)
+                except URLError:
+                    # We cannot add any additional information to error message
+                    raise ValueError(msg)
+
+                if version and name:
+                    self.logger.warning('Incompatible extension:\n%s', msg)
+                    self.logger.warning('Found compatible version: %s', version)
+                    with TemporaryDirectory() as tempdir2:
+                        return self._install_extension(
+                            '%s@%s' % (name, version), tempdir2)
+
+                # Extend message to better guide the user what to do:
+                conflicts = '\n'.join(msg.splitlines()[2:])
+                msg = ''.join((
+                    self._format_no_compatible_package_version(extension),
+                    "\n\n",
+                    conflicts))
+
             raise ValueError(msg)
             raise ValueError(msg)
 
 
         # Move the file to the app directory.
         # Move the file to the app directory.
@@ -1158,6 +1188,75 @@ class _AppHandler(object):
 
 
         return info
         return info
 
 
+
+    def _latest_compatible_package_version(self, name):
+        """Get the latest compatible version of a package"""
+        core_data = self.info['core_data']
+        metadata = _fetch_package_metadata(self.registry, name, self.logger)
+        versions = metadata.get('versions', [])
+
+        # Sort pre-release first, as we will reverse the sort:
+        def sort_key(key_value):
+            return _semver_key(key_value[0], prerelease_first=True)
+
+        for version, data in sorted(versions.items(),
+                                    key=sort_key,
+                                    reverse=True):
+            deps = data.get('dependencies', {})
+            errors = _validate_compatibility(name, deps, core_data)
+            if not errors:
+                # Found a compatible version
+                # Verify that the version is a valid extension.
+                with TemporaryDirectory() as tempdir:
+                    info = self._extract_package('%s@%s' % (name, version), tempdir)
+                if _validate_extension(info['data']):
+                    # Invalid, do not consider other versions
+                    return
+                # Valid
+                return version
+
+    def _format_no_compatible_package_version(self, name):
+        """Get the latest compatible version of a package"""
+        core_data = self.info['core_data']
+        metadata = _fetch_package_metadata(self.registry, name, self.logger)
+        versions = metadata.get('versions', [])
+
+        # Sort pre-release first, as we will reverse the sort:
+        def sort_key(key_value):
+            return _semver_key(key_value[0], prerelease_first=True)
+
+        store = tuple(sorted(versions.items(), key=sort_key, reverse=True))
+        latest_deps = store[0][1].get('dependencies', {})
+        core_deps = core_data['dependencies']
+        singletons = core_data['jupyterlab']['singletonPackages']
+
+        # Whether lab version is too new:
+        lab_newer_than_latest = False
+        # Whether the latest version of the extension depend on a "future" version
+        # of a singleton package (from the perspective of current lab version):
+        latest_newer_than_lab = False
+
+        for (key, value) in latest_deps.items():
+            if key in singletons:
+                c = _compare_ranges(core_deps[key], value)
+                lab_newer_than_latest = lab_newer_than_latest or c < 0
+                latest_newer_than_lab = latest_newer_than_lab or c > 0
+
+        if lab_newer_than_latest:
+            # All singleton deps in current version of lab are newer than those
+            # in the latest version of the extension
+            return ("This extension does not yet support the current version of "
+                    "JupyterLab.\n")
+
+
+        parts = ["No version of {extension} could be found that is compatible with "
+                 "the current version of JupyterLab."]
+        if latest_newer_than_lab:
+            parts.extend(("However, it seems to support a new version of JupyterLab.",
+                          "Consider upgrading JupyterLab."))
+
+        return " ".join(parts).format(extension=name)
+
     def _run(self, cmd, **kwargs):
     def _run(self, cmd, **kwargs):
         """Run the command using our logger and abort callback.
         """Run the command using our logger and abort callback.
 
 
@@ -1318,6 +1417,20 @@ def _test_overlap(spec1, spec2):
     Returns `None` if we cannot determine compatibility,
     Returns `None` if we cannot determine compatibility,
     otherwise whether there is an overlap
     otherwise whether there is an overlap
     """
     """
+    cmp = _compare_ranges(spec1, spec2)
+    if cmp is None:
+        return
+    return cmp == 0
+
+
+def _compare_ranges(spec1, spec2):
+    """Test whether two version specs overlap.
+
+    Returns `None` if we cannot determine compatibility,
+    otherwise return 0 if there is an overlap, 1 if
+    spec1 is lower/older than spec2, and -1 if spec1
+    is higher/newer than spec2.
+    """
     # Test for overlapping semver ranges.
     # Test for overlapping semver ranges.
     r1 = Range(spec1, True)
     r1 = Range(spec1, True)
     r2 = Range(spec2, True)
     r2 = Range(spec2, True)
@@ -1354,12 +1467,17 @@ def _test_overlap(spec1, spec2):
         ly = noop
         ly = noop
 
 
     # Check for overlap.
     # Check for overlap.
-    return (
-        gte(x1, y1, True) and ly(x1, y2, True) or
+    if (gte(x1, y1, True) and ly(x1, y2, True) or
         gy(x2, y1, True) and ly(x2, y2, True) or
         gy(x2, y1, True) and ly(x2, y2, True) or
         gte(y1, x1, True) and lx(y1, x2, True) or
         gte(y1, x1, True) and lx(y1, x2, True) or
         gx(y2, x1, True) and lx(y2, x2, True)
         gx(y2, x1, True) and lx(y2, x2, True)
-    )
+       ):
+       return 0
+    if gte(y1, x2, True):
+        return 1
+    if gte(x1, y2, True):
+        return -1
+    raise AssertionError('Unexpected case comparing version ranges')
 
 
 
 
 def _is_disabled(name, disabled=[]):
 def _is_disabled(name, disabled=[]):
@@ -1407,5 +1525,67 @@ def _get_core_extensions():
     return list(data['extensions']) + list(data['mimeExtensions'])
     return list(data['extensions']) + list(data['mimeExtensions'])
 
 
 
 
+def _semver_prerelease_key(prerelease):
+    """Sort key for prereleases.
+
+    Precedence for two pre-release versions with the same
+    major, minor, and patch version MUST be determined by
+    comparing each dot separated identifier from left to
+    right until a difference is found as follows:
+    identifiers consisting of only digits are compare
+    numerically and identifiers with letters or hyphens
+    are compared lexically in ASCII sort order. Numeric
+    identifiers always have lower precedence than non-
+    numeric identifiers. A larger set of pre-release
+    fields has a higher precedence than a smaller set,
+    if all of the preceding identifiers are equal.
+    """
+    for entry in prerelease:
+        if isinstance(entry, int):
+            # Assure numerics always sort before string
+            yield ('', entry)
+        else:
+            # Use ASCII compare:
+            yield (entry,)
+
+
+def _semver_key(version, prerelease_first=False):
+    v = make_semver(version, True)
+    if prerelease_first:
+        key = (0,) if v.prerelease else (1,)
+    else:
+        key = ()
+    key = key + (v.major, v.minor, v.patch)
+    if not prerelease_first:
+        #  NOT having a prerelease is > having one
+        key = key + (0,) if v.prerelease else (1,)
+    if v.prerelease:
+        key = key + tuple(_semver_prerelease_key(
+            v.prerelease))
+
+    return key
+
+
+def _fetch_package_metadata(registry, name, logger):
+    """Fetch the metadata for a package from the npm registry"""
+    req = Request(
+        urljoin(registry, quote(name, safe='@')),
+        method='GET',
+        headers={
+            'Accept': ('application/vnd.npm.install-v1+json;'
+                        ' q=1.0, application/json; q=0.8, */*')
+        }
+    )
+    logger.debug('Fetching URL: %s' % (req.full_url))
+    try:
+        with urlopen(req) as response:
+            return json.load(response)
+    except URLError as exc:
+        logger.warning(
+            'Failed to fetch package metadata for %r: %r',
+            name, exc)
+        raise
+
+
 if __name__ == '__main__':
 if __name__ == '__main__':
     watch_dev(HERE)
     watch_dev(HERE)

+ 167 - 110
jupyterlab/semver.py

@@ -1,5 +1,5 @@
-
-# This file comes from https://github.com/podhmo/python-semver/blob/f0392c5567717ad001c058d80fa09887e482ad62/semver/__init__.py
+# -*- coding:utf-8 -*-
+# This file comes from https://github.com/podhmo/python-semver/blob/b42e9896e391e086b773fc621b23fa299d16b874/semver/__init__.py
 # 
 # 
 # It is licensed under the following license:
 # It is licensed under the following license:
 # 
 # 
@@ -25,17 +25,13 @@
 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 # SOFTWARE.
 # SOFTWARE.
 
 
-# -*- coding:utf-8 -*-
 import logging
 import logging
-logger = logging.getLogger(__name__)
 import re
 import re
+logger = logging.getLogger(__name__)
+
 
 
 SEMVER_SPEC_VERSION = '2.0.0'
 SEMVER_SPEC_VERSION = '2.0.0'
 
 
-try:
-    string_type = basestring # Python 2
-except NameError:
-    string_type = str # Python 3
 
 
 class _R(object):
 class _R(object):
     def __init__(self, i):
     def __init__(self, i):
@@ -67,6 +63,7 @@ def list_get(xs, i):
     except IndexError:
     except IndexError:
         return None
         return None
 
 
+
 R = _R(0)
 R = _R(0)
 src = Extendlist()
 src = Extendlist()
 regexp = {}
 regexp = {}
@@ -171,7 +168,7 @@ GTLT = R()
 src[GTLT] = '((?:<|>)?=?)'
 src[GTLT] = '((?:<|>)?=?)'
 
 
 #  Something like "2.*" or "1.2.x".
 #  Something like "2.*" or "1.2.x".
-#  Note that "x.x" is a valid xRange identifer, meaning "any version"
+#  Note that "x.x" is a valid xRange identifier, meaning "any version"
 #  Only the first item is strictly required.
 #  Only the first item is strictly required.
 XRANGEIDENTIFIERLOOSE = R()
 XRANGEIDENTIFIERLOOSE = R()
 src[XRANGEIDENTIFIERLOOSE] = src[NUMERICIDENTIFIERLOOSE] + '|x|X|\\*'
 src[XRANGEIDENTIFIERLOOSE] = src[NUMERICIDENTIFIERLOOSE] + '|x|X|\\*'
@@ -182,18 +179,18 @@ XRANGEPLAIN = R()
 src[XRANGEPLAIN] = ('[v=\\s]*(' + src[XRANGEIDENTIFIER] + ')' +
 src[XRANGEPLAIN] = ('[v=\\s]*(' + src[XRANGEIDENTIFIER] + ')' +
                     '(?:\\.(' + src[XRANGEIDENTIFIER] + ')' +
                     '(?:\\.(' + src[XRANGEIDENTIFIER] + ')' +
                     '(?:\\.(' + src[XRANGEIDENTIFIER] + ')' +
                     '(?:\\.(' + src[XRANGEIDENTIFIER] + ')' +
-                    '(?:(' + src[PRERELEASE] + ')' +
-                    ')?)?)?')
+                    '(?:' + src[PRERELEASE] + ')?' +
+                    src[BUILD] + '?' +
+                    ')?)?')
 
 
 XRANGEPLAINLOOSE = R()
 XRANGEPLAINLOOSE = R()
 src[XRANGEPLAINLOOSE] = ('[v=\\s]*(' + src[XRANGEIDENTIFIERLOOSE] + ')' +
 src[XRANGEPLAINLOOSE] = ('[v=\\s]*(' + src[XRANGEIDENTIFIERLOOSE] + ')' +
                          '(?:\\.(' + src[XRANGEIDENTIFIERLOOSE] + ')' +
                          '(?:\\.(' + src[XRANGEIDENTIFIERLOOSE] + ')' +
                          '(?:\\.(' + src[XRANGEIDENTIFIERLOOSE] + ')' +
                          '(?:\\.(' + src[XRANGEIDENTIFIERLOOSE] + ')' +
-                         '(?:(' + src[PRERELEASELOOSE] + ')' +
-                         ')?)?)?')
+                         '(?:' + src[PRERELEASELOOSE] + ')?' +
+                         src[BUILD] + '?' +
+                         ')?)?')
 
 
-#  >=2.x, for example, means >=2.0.0-0
-#  <1.x would be the same as "<1.0.0-0", though.
 XRANGE = R()
 XRANGE = R()
 src[XRANGE] = '^' + src[GTLT] + '\\s*' + src[XRANGEPLAIN] + '$'
 src[XRANGE] = '^' + src[GTLT] + '\\s*' + src[XRANGEPLAIN] + '$'
 XRANGELOOSE = R()
 XRANGELOOSE = R()
@@ -306,6 +303,7 @@ def clean(version, loose):
     else:
     else:
         return None
         return None
 
 
+
 NUMERIC = re.compile("^\d+$")
 NUMERIC = re.compile("^\d+$")
 
 
 
 
@@ -315,7 +313,7 @@ def semver(version, loose):
             return version
             return version
         else:
         else:
             version = version.version
             version = version.version
-    elif not isinstance(version, string_type):  # xxx:
+    elif not isinstance(version, str):  # xxx:
         raise ValueError("Invalid Version: {}".format(version))
         raise ValueError("Invalid Version: {}".format(version))
 
 
     """
     """
@@ -323,6 +321,8 @@ def semver(version, loose):
        return new SemVer(version, loose);
        return new SemVer(version, loose);
     """
     """
     return SemVer(version, loose)
     return SemVer(version, loose)
+
+
 make_semver = semver
 make_semver = semver
 
 
 
 
@@ -371,7 +371,7 @@ class SemVer(object):
         return self.version
         return self.version
 
 
     def __repr__(self):
     def __repr__(self):
-        return "<SemVer {!r} >".format(self)
+        return "<SemVer {} >".format(self)
 
 
     def __str__(self):
     def __str__(self):
         return self.version
         return self.version
@@ -424,37 +424,49 @@ class SemVer(object):
             else:
             else:
                 return compare_identifiers(str(a), str(b))
                 return compare_identifiers(str(a), str(b))
 
 
-    def inc(self, release):
-        self._inc(release)
-        i = -1
-        while len(self.prerelease) > 1 and self.prerelease[i] == 0:
-            self.prerelease.pop()
-        self.format()
-        return self
-
-    def _inc(self, release):
+    def inc(self, release, identifier=None):
         logger.debug("inc release %s %s", self.prerelease, release)
         logger.debug("inc release %s %s", self.prerelease, release)
         if release == 'premajor':
         if release == 'premajor':
-            self._inc("major")
-            self._inc("pre")
+            self.prerelease = []
+            self.patch = 0
+            self.minor = 0
+            self.major += 1
+            self.inc('pre', identifier=identifier)
         elif release == "preminor":
         elif release == "preminor":
-            self._inc("minor")
-            self._inc("pre")
+            self.prerelease = []
+            self.patch = 0
+            self.minor += 1
+            self.inc('pre', identifier=identifier)
         elif release == "prepatch":
         elif release == "prepatch":
-            self._inc("patch")
-            self._inc("pre")
+            # If this is already a prerelease, it will bump to the next version
+            # drop any prereleases that might already exist, since they are not
+            # relevant at this point.
+            self.prerelease = []
+            self.inc('patch', identifier=identifier)
+            self.inc('pre', identifier=identifier)
         elif release == 'prerelease':
         elif release == 'prerelease':
+            # If the input is a non-prerelease version, this acts the same as
+            # prepatch.
             if len(self.prerelease) == 0:
             if len(self.prerelease) == 0:
-                self._inc("patch")
-            self._inc("pre")
+                self.inc("patch", identifier=identifier)
+            self.inc("pre", identifier=identifier)
         elif release == "major":
         elif release == "major":
-            self.major += 1
-            self.minor = -1
-            self.minor += 1
+            # If this is a pre-major version, bump up to the same major version.
+            # Otherwise increment major.
+            # 1.0.0-5 bumps to 1.0.0
+            # 1.1.0 bumps to 2.0.0
+            if self.minor != 0 or self.patch != 0 or len(self.prerelease) == 0:
+                self.major += 1
+            self.minor = 0
             self.patch = 0
             self.patch = 0
             self.prerelease = []
             self.prerelease = []
         elif release == "minor":
         elif release == "minor":
-            self.minor += 1
+            # If this is a pre-minor version, bump up to the same minor version.
+            # Otherwise increment minor.
+            # 1.2.0-5 bumps to 1.2.0
+            # 1.2.1 bumps to 1.3.0
+            if self.patch != 0 or len(self.prerelease) == 0:
+                self.minor += 1
             self.patch = 0
             self.patch = 0
             self.prerelease = []
             self.prerelease = []
         elif release == "patch":
         elif release == "patch":
@@ -478,16 +490,27 @@ class SemVer(object):
                         self.prerelease[i] += 1
                         self.prerelease[i] += 1
                         i -= 2
                         i -= 2
                     i -= 1
                     i -= 1
-                if i == -1:  # didn't increment anything
-                    self.prerelease.append(0)
+                # ## this is needless code in python ##
+                # if i == -1:  # didn't increment anything
+                #     self.prerelease.append(0)
+            if identifier is not None:
+                # 1.2.0-beta.1 bumps to 1.2.0-beta.2,
+                # 1.2.0-beta.fooblz or 1.2.0-beta bumps to 1.2.0-beta.0
+                if self.prerelease[0] == identifier:
+                    if not isinstance(self.prerelease[1], int):
+                        self.prerelease = [identifier, 0]
+                else:
+                    self.prerelease = [identifier, 0]
         else:
         else:
             raise ValueError('invalid increment argument: {}'.format(release))
             raise ValueError('invalid increment argument: {}'.format(release))
+        self.format()
+        self.raw = self.version
         return self
         return self
 
 
 
 
-def inc(version, release, loose):  # wow!
+def inc(version, release, loose, identifier=None):  # wow!
     try:
     try:
-        return make_semver(version, loose).inc(release).version
+        return make_semver(version, loose).inc(release, identifier=identifier).version
     except Exception as e:
     except Exception as e:
         logger.debug(e, exc_info=5)
         logger.debug(e, exc_info=5)
         return None
         return None
@@ -529,13 +552,32 @@ def rcompare(a, b, loose):
     return compare(b, a, loose)
     return compare(b, a, loose)
 
 
 
 
+def make_key_function(loose):
+    def key_function(version):
+        v = make_semver(version, loose)
+        key = (v.major, v.minor, v.patch)
+        if v.prerelease:
+            key = key + tuple(v.prerelease)
+        else:
+            #  NOT having a prerelease is > having one
+            key = key + (float('inf'),)
+
+        return key
+    return key_function
+
+loose_key_function = make_key_function(True)
+full_key_function = make_key_function(True)
+
+
 def sort(list, loose):
 def sort(list, loose):
-    list.sort(lambda a, b: compare(a, b, loose))
+    keyf = loose_key_function if loose else full_key_function
+    list.sort(key=keyf)
     return list
     return list
 
 
 
 
 def rsort(list, loose):
 def rsort(list, loose):
-    list.sort(lambda a, b: rcompare(a, b, loose))
+    keyf = loose_key_function if loose else full_key_function
+    list.sort(key=keyf, reverse=True)
     return list
     return list
 
 
 
 
@@ -595,6 +637,8 @@ def comparator(comp, loose):
     # if (!(this instanceof Comparator))
     # if (!(this instanceof Comparator))
     #   return new Comparator(comp, loose)
     #   return new Comparator(comp, loose)
     return Comparator(comp, loose)
     return Comparator(comp, loose)
+
+
 make_comparator = comparator
 make_comparator = comparator
 
 
 ANY = object()
 ANY = object()
@@ -630,17 +674,6 @@ class Comparator(object):
             self.semver = ANY
             self.semver = ANY
         else:
         else:
             self.semver = semver(m.group(2), self.loose)
             self.semver = semver(m.group(2), self.loose)
-            #  <1.2.3-rc DOES allow 1.2.3-beta (has prerelease)
-            #  >=1.2.3 DOES NOT allow 1.2.3-beta
-            #  <=1.2.3 DOES allow 1.2.3-beta
-            #  However, <1.2.3 does NOT allow 1.2.3-beta,
-            #  even though `1.2.3-beta < 1.2.3`
-            #  The assumption is that the 1.2.3 version has something you
-            #  *don't* want, so we push the prerelease down to the minimum.
-            if (self.operator == '<' and len(self.semver.prerelease) >= 0):
-                self.semver.prerelease = ["0"]
-                self.semver.format()
-                logger.debug("Comparator.parse semver %s", self.semver)
 
 
     def __repr__(self):
     def __repr__(self):
         return '<SemVer Comparator "{}">'.format(self)
         return '<SemVer Comparator "{}">'.format(self)
@@ -671,7 +704,7 @@ class Range(object):
         #  First, split based on boolean or ||
         #  First, split based on boolean or ||
         self.raw = range_
         self.raw = range_
         xs = [self.parse_range(r.strip()) for r in re.split(r"\s*\|\|\s*", range_)]
         xs = [self.parse_range(r.strip()) for r in re.split(r"\s*\|\|\s*", range_)]
-        self.set = [r for r in xs if len(r) >= 0]
+        self.set = [r for r in xs if r]
 
 
         if not len(self.set):
         if not len(self.set):
             raise ValueError("Invalid SemVer Range: {}".format(range_))
             raise ValueError("Invalid SemVer Range: {}".format(range_))
@@ -728,8 +761,12 @@ class Range(object):
         return set_
         return set_
 
 
     def test(self, version):
     def test(self, version):
-        if version is None:  # xxx
+        if not version:  # xxx
             return False
             return False
+
+        if isinstance(version, str):
+            version = make_semver(version, loose=self.loose)
+
         for e in self.set:
         for e in self.set:
             if test_set(e, version):
             if test_set(e, version):
                 return True
                 return True
@@ -788,18 +825,18 @@ def replace_tilde(comp, loose):
         if is_x(M):
         if is_x(M):
             ret = ""
             ret = ""
         elif is_x(m):
         elif is_x(m):
-            ret = '>=' + M + '.0.0-0 <' + str(int(M) + 1) + '.0.0-0'
+            ret = '>=' + M + '.0.0 <' + str(int(M) + 1) + '.0.0'
         elif is_x(p):
         elif is_x(p):
-            # ~1.2 == >=1.2.0- <1.3.0-
-            ret = '>=' + M + '.' + m + '.0-0 <' + M + '.' + str(int(m) + 1) + '.0-0'
+            # ~1.2 == >=1.2.0 <1.3.0
+            ret = '>=' + M + '.' + m + '.0 <' + M + '.' + str(int(m) + 1) + '.0'
         elif pr:
         elif pr:
             logger.debug("replaceTilde pr %s", pr)
             logger.debug("replaceTilde pr %s", pr)
             if (pr[0] != "-"):
             if (pr[0] != "-"):
                 pr = '-' + pr
                 pr = '-' + pr
-            ret = '>=' + M + '.' + m + '.' + p + pr +' <' + M + '.' + str(int(m) + 1) + '.0-0'
+            ret = '>=' + M + '.' + m + '.' + p + pr + ' <' + M + '.' + str(int(m) + 1) + '.0'
         else:
         else:
-            #  ~1.2.3 == >=1.2.3-0 <1.3.0-0
-            ret = '>=' + M + '.' + m + '.' + p + '-0' +' <' + M + '.' + str(int(m) + 1) + '.0-0'
+            #  ~1.2.3 == >=1.2.3 <1.3.0
+            ret = '>=' + M + '.' + m + '.' + p + ' <' + M + '.' + str(int(m) + 1) + '.0'
         logger.debug('tilde return, %s', ret)
         logger.debug('tilde return, %s', ret)
         return ret
         return ret
     return r.sub(repl, comp)
     return r.sub(repl, comp)
@@ -830,31 +867,31 @@ def replace_caret(comp, loose):
         if is_x(M):
         if is_x(M):
             ret = ""
             ret = ""
         elif is_x(m):
         elif is_x(m):
-            ret = '>=' + M + '.0.0-0 <' + str((int(M) + 1)) + '.0.0-0'
+            ret = '>=' + M + '.0.0 <' + str((int(M) + 1)) + '.0.0'
         elif is_x(p):
         elif is_x(p):
             if M == "0":
             if M == "0":
-                ret = '>=' + M + '.' + m + '.0-0 <' + M + '.' + str((int(m) + 1)) + '.0-0'
+                ret = '>=' + M + '.' + m + '.0 <' + M + '.' + str((int(m) + 1)) + '.0'
             else:
             else:
-                ret = '>=' + M + '.' + m + '.0-0 <' + str(int(M) + 1) + '.0.0-0'
+                ret = '>=' + M + '.' + m + '.0 <' + str(int(M) + 1) + '.0.0'
         elif pr:
         elif pr:
             logger.debug('replaceCaret pr %s', pr)
             logger.debug('replaceCaret pr %s', pr)
             if pr[0] != "-":
             if pr[0] != "-":
                 pr = "-" + pr
                 pr = "-" + pr
             if M == "0":
             if M == "0":
                 if m == "0":
                 if m == "0":
-                    ret = '=' + M + '.' + m + '.' + (p or "") + pr
+                    ret = '>=' + M + '.' + m + '.' + (p or "") + pr + ' <' + M + '.' + m + "." + str(int(p or 0) + 1)
                 else:
                 else:
-                    ret = '>=' + M + '.' + m + '.' + (p or "") + pr +' <' + M + '.' + str(int(m) + 1) + '.0-0'
+                    ret = '>=' + M + '.' + m + '.' + (p or "") + pr + ' <' + M + '.' + str(int(m) + 1) + '.0'
             else:
             else:
-                ret = '>=' + M + '.' + m + '.' + (p or "") + pr + ' <' + str(int(M) + 1) + '.0.0-0'
+                ret = '>=' + M + '.' + m + '.' + (p or "") + pr + ' <' + str(int(M) + 1) + '.0.0'
         else:
         else:
             if M == "0":
             if M == "0":
                 if m == "0":
                 if m == "0":
-                    ret = '=' + M + '.' + m + '.' + (p or "")
+                    ret = '>=' + M + '.' + m + '.' + (p or "") + ' <' + M + '.' + m + "." + str(int(p or 0) + 1)
                 else:
                 else:
-                    ret = '>=' + M + '.' + m + '.' + (p or "") + '-0' + ' <' + M + '.' + str((int(m) + 1)) + '.0-0'
+                    ret = '>=' + M + '.' + m + '.' + (p or "") + ' <' + M + '.' + str((int(m) + 1)) + '.0'
             else:
             else:
-                ret = '>=' + M + '.' + m + '.' + (p or "") + '-0' +' <' + str(int(M) + 1) + '.0.0-0'
+                ret = '>=' + M + '.' + m + '.' + (p or "") + ' <' + str(int(M) + 1) + '.0.0'
         logger.debug('caret return %s', ret)
         logger.debug('caret return %s', ret)
         return ret
         return ret
 
 
@@ -889,41 +926,45 @@ def replace_xrange(comp, loose):
             gtlt = ""
             gtlt = ""
 
 
         logger.debug("xrange gtlt=%s any_x=%s", gtlt, any_x)
         logger.debug("xrange gtlt=%s any_x=%s", gtlt, any_x)
-        if gtlt and any_x:
+        if xM:
+            if gtlt == '>' or gtlt == '<':
+                # nothing is allowed
+                ret = '<0.0.0'
+            else:
+                ret = '*'
+        elif gtlt and any_x:
             # replace X with 0, and then append the -0 min-prerelease
             # replace X with 0, and then append the -0 min-prerelease
-            if xM:
-                M = 0
             if xm:
             if xm:
                 m = 0
                 m = 0
             if xp:
             if xp:
                 p = 0
                 p = 0
 
 
             if gtlt == ">":
             if gtlt == ">":
-                #  >1 => >=2.0.0-0
-                #  >1.2 => >=1.3.0-0
-                #  >1.2.3 => >= 1.2.4-0
+                #  >1 => >=2.0.0
+                #  >1.2 => >=1.3.0
+                #  >1.2.3 => >= 1.2.4
                 gtlt = ">="
                 gtlt = ">="
-                if xM:
-                    #  not change
-                    pass
-                elif xm:
+                if xm:
                     M = int(M) + 1
                     M = int(M) + 1
                     m = 0
                     m = 0
                     p = 0
                     p = 0
                 elif xp:
                 elif xp:
                     m = int(m) + 1
                     m = int(m) + 1
                     p = 0
                     p = 0
-            ret = gtlt + str(M) + '.' + str(m) + '.' + str(p) + '-0'
-        elif xM:
-            #  allow any
-            ret = "*"
+            elif gtlt == '<=':
+                # <=0.7.x is actually <0.8.0, since any 0.7.x should
+                # pass.  Similarly, <=7.x is actually <8.0.0, etc.
+                gtlt = '<'
+                if xm:
+                    M = int(M) + 1
+                else:
+                    m = int(m) + 1
+
+            ret = gtlt + str(M) + '.' + str(m) + '.' + str(p)
         elif xm:
         elif xm:
-            #  append '-0' onto the version, otherwise
-            #  '1.x.x' matches '2.0.0-beta', since the tag
-            #  *lowers* the version value
-            ret = '>=' + M + '.0.0-0 <' + str(int(M) + 1) + '.0.0-0'
+            ret = '>=' + M + '.0.0 <' + str(int(M) + 1) + '.0.0'
         elif xp:
         elif xp:
-            ret = '>=' + M + '.' + m + '.0-0 <' + M + '.' + str(int(m) + 1) + '.0-0'
+            ret = '>=' + M + '.' + m + '.0 <' + M + '.' + str(int(m) + 1) + '.0'
         logger.debug('xRange return %s', ret)
         logger.debug('xRange return %s', ret)
 
 
         return ret
         return ret
@@ -940,26 +981,26 @@ def replace_stars(comp, loose):
 
 
 #  This function is passed to string.replace(re[HYPHENRANGE])
 #  This function is passed to string.replace(re[HYPHENRANGE])
 #  M, m, patch, prerelease, build
 #  M, m, patch, prerelease, build
-#  1.2 - 3.4.5 => >=1.2.0-0 <=3.4.5
-#  1.2.3 - 3.4 => >=1.2.0-0 <3.5.0-0 Any 3.4.x will do
-#  1.2 - 3.4 => >=1.2.0-0 <3.5.0-0
+#  1.2 - 3.4.5 => >=1.2.0 <=3.4.5
+#  1.2.3 - 3.4 => >=1.2.0 <3.5.0 Any 3.4.x will do
+#  1.2 - 3.4 => >=1.2.0 <3.5.0
 def hyphen_replace(mob):
 def hyphen_replace(mob):
     from_, fM, fm, fp, fpr, fb, to, tM, tm, tp, tpr, tb = mob.groups()
     from_, fM, fm, fp, fpr, fb, to, tM, tm, tp, tpr, tb = mob.groups()
     if is_x(fM):
     if is_x(fM):
         from_ = ""
         from_ = ""
     elif is_x(fm):
     elif is_x(fm):
-        from_ = '>=' + fM + '.0.0-0'
+        from_ = '>=' + fM + '.0.0'
     elif is_x(fp):
     elif is_x(fp):
-        from_ = '>=' + fM + '.' + fm + '.0-0'
+        from_ = '>=' + fM + '.' + fm + '.0'
     else:
     else:
         from_ = ">=" + from_
         from_ = ">=" + from_
 
 
     if is_x(tM):
     if is_x(tM):
         to = ""
         to = ""
     elif is_x(tm):
     elif is_x(tm):
-        to = '<' + str(int(tM) + 1) + '.0.0-0'
+        to = '<' + str(int(tM) + 1) + '.0.0'
     elif is_x(tp):
     elif is_x(tp):
-        to = '<' + tM + '.' + str(int(tm) + 1) + '.0-0'
+        to = '<' + tM + '.' + str(int(tm) + 1) + '.0'
     elif tpr:
     elif tpr:
         to = '<=' + tM + '.' + tm + '.' + tp + '-' + tpr
         to = '<=' + tM + '.' + tm + '.' + tp + '-' + tpr
     else:
     else:
@@ -971,10 +1012,25 @@ def test_set(set_, version):
     for e in set_:
     for e in set_:
         if not e.test(version):
         if not e.test(version):
             return False
             return False
+    if len(version.prerelease) > 0:
+        # Find the set of versions that are allowed to have prereleases
+        # For example, ^1.2.3-pr.1 desugars to >=1.2.3-pr.1 <2.0.0
+        # That should allow `1.2.3-pr.2` to pass.
+        # However, `1.2.4-alpha.notready` should NOT be allowed,
+        # even though it's within the range set by the comparators.
+        for e in set_:
+            if e.semver == ANY:
+                continue
+            if len(e.semver.prerelease) > 0:
+                allowed = e.semver
+                if allowed.major == version.major and allowed.minor == version.minor and allowed.patch == version.patch:
+                    return True
+        # Version has a -pre, but it's not one of the ones we like.
+        return False
     return True
     return True
 
 
 
 
-def satisfies(version, range_, loose):
+def satisfies(version, range_, loose=False):
     try:
     try:
         range_ = make_range(range_, loose)
         range_ = make_range(range_, loose)
     except Exception as e:
     except Exception as e:
@@ -982,18 +1038,19 @@ def satisfies(version, range_, loose):
     return range_.test(version)
     return range_.test(version)
 
 
 
 
-def max_satisfying(versions, range_, loose):
-    xs = [version for version in versions if satisfies(version, range_, loose)]
-    if len(xs) <= 0:
+def max_satisfying(versions, range_, loose=False):
+    try:
+        range_ob = make_range(range_, loose=loose)
+    except:
         return None
         return None
-    selected = xs[0]
-    for x in xs[1:]:
-        try:
-            if rcompare(selected, x, loose) == 1:
-                selected = x
-        except ValueError:
-            logger.warn("{} is invalud version".format(x))
-    return selected
+    max_ = None
+    max_sv = None
+    for v in versions:
+        if range_ob.test(v):  # satisfies(v, range_, loose=loose)
+            if max_ is None or max_sv.compare(v) == -1:  # compare(max, v, true)
+                max_ = v
+                max_sv = make_semver(max_, loose=loose)
+    return max_
 
 
 
 
 def valid_range(range_, loose):
 def valid_range(range_, loose):

+ 64 - 1
jupyterlab/tests/test_jupyterlab.py

@@ -28,7 +28,7 @@ from jupyterlab.commands import (
     install_extension, uninstall_extension, list_extensions,
     install_extension, uninstall_extension, list_extensions,
     build, link_package, unlink_package, build_check,
     build, link_package, unlink_package, build_check,
     disable_extension, enable_extension, get_app_info,
     disable_extension, enable_extension, get_app_info,
-    check_extension, _test_overlap
+    check_extension, _test_overlap, _get_core_data
 )
 )
 
 
 here = os.path.dirname(os.path.abspath(__file__))
 here = os.path.dirname(os.path.abspath(__file__))
@@ -466,3 +466,66 @@ class TestExtension(TestCase):
 
 
         assert _test_overlap('*', '0.6') is None
         assert _test_overlap('*', '0.6') is None
         assert _test_overlap('<0.6', '0.1') is None
         assert _test_overlap('<0.6', '0.1') is None
+
+    def test_install_compatible(self):
+        core_data = _get_core_data()
+        current_app_dep = core_data['dependencies']['@jupyterlab/application']
+        def _gen_dep(ver):
+            return { "dependencies": {
+                '@jupyterlab/application': ver
+            }}
+        def _mock_metadata(registry, name, logger):
+            assert name == 'mockextension'
+            return {
+                "name": name,
+                "versions": {
+                    "0.9.0": _gen_dep(current_app_dep),
+                    "1.0.0": _gen_dep(current_app_dep),
+                    "1.1.0": _gen_dep(current_app_dep),
+                    "2.0.0": _gen_dep('^2000.0.0'),
+                    "2.0.0-b0": _gen_dep(current_app_dep),
+                    "2.1.0-b0": _gen_dep('^2000.0.0'),
+                    "2.1.0": _gen_dep('^2000.0.0'),
+                }
+            }
+
+        def _mock_extract(self, source, tempdir):
+            data = dict(
+                name=source, version='2.1.0',
+                jupyterlab=dict(extension=True),
+                jupyterlab_extracted_files=['index.js'],
+            )
+            data.update(_gen_dep('^2000.0.0'))
+            info = dict(
+                source=source, is_dir=False, data=data,
+                name=source, version=data['version'],
+                filename='mockextension.tgz',
+                path=pjoin(tempdir, 'mockextension.tgz'),
+            )
+            return info
+
+        class Success(Exception):
+            pass
+
+        def _mock_install(self, name, *args, **kwargs):
+            assert name in ('mockextension', 'mockextension@1.1.0')
+            if name == 'mockextension@1.1.0':
+                raise Success()
+            return orig_install(self, name, *args, **kwargs)
+
+        p1 = patch.object(
+            commands,
+            '_fetch_package_metadata',
+            _mock_metadata)
+        p2 = patch.object(
+            commands._AppHandler,
+            '_extract_package',
+            _mock_extract)
+        p3 = patch.object(
+            commands._AppHandler,
+            '_install_extension',
+            _mock_install)
+        with p1, p2:
+            orig_install = commands._AppHandler._install_extension
+            with p3, pytest.raises(Success):
+                install_extension('mockextension')