Repository: conda/conda

Issue:
Implement PEP-440 compatible release operator
https://www.python.org/dev/peps/pep-0440/#compatible-release

xref: https://github.com/conda/conda/issues/7776


Discussion:

Resolving patch:
diff --git a/conda/models/match_spec.py b/conda/models/match_spec.py
--- a/conda/models/match_spec.py
+++ b/conda/models/match_spec.py
@@ -288,24 +288,29 @@ def __str__(self):
         name_matcher = self._match_components.get('name', '*')
         builder.append(('::%s' if builder else '%s') % name_matcher)
 
-        version_exact = False
         version = self._match_components.get('version')
+        build = self._match_components.get('build')
+        version_exact = False
         if version:
             version = text_type(version)
-            if any(s in version for s in '><$^|,!'):
+            if any(s in version for s in "><$^|,"):
                 brackets.append("version='%s'" % version)
-            elif version.endswith('.*'):
-                builder.append('=' + version[:-2])
-            elif version.endswith('*'):
-                builder.append('=' + version[:-1])
-            elif version.startswith('=='):
+            elif version[:2] in ("!=", "~="):
+                if build:
+                    brackets.append("version='%s'" % version)
+                else:
+                    builder.append(version)
+            elif version[-2:] == ".*":
+                builder.append("=" + version[:-2])
+            elif version[-1] == "*":
+                builder.append("=" + version[:-1])
+            elif version.startswith("=="):
                 builder.append(version)
                 version_exact = True
             else:
-                builder.append('==' + version)
+                builder.append("==" + version)
                 version_exact = True
 
-        build = self._match_components.get('build')
         if build:
             build = text_type(build)
             if any(s in build for s in '><$^|,'):
@@ -500,7 +505,7 @@ def _parse_version_plus_build(v_plus_b):
         >>> _parse_version_plus_build("* *")
         ('*', '*')
     """
-    parts = re.search(r'((?:.+?)[^><!,|]?)(?:(?<![=!|,<>])(?:[ =])([^-=,|<>]+?))?$', v_plus_b)
+    parts = re.search(r'((?:.+?)[^><!,|]?)(?:(?<![=!|,<>~])(?:[ =])([^-=,|<>~]+?))?$', v_plus_b)
     if parts:
         version, build = parts.groups()
         build = build and build.strip()
@@ -640,7 +645,7 @@ def _parse_spec_str(spec_str):
         subdir = brackets.pop('subdir')
 
     # Step 6. strip off package name from remaining version + build
-    m3 = re.match(r'([^ =<>!]+)?([><!= ].+)?', spec_str)
+    m3 = re.match(r'([^ =<>!~]+)?([><!=~ ].+)?', spec_str)
     if m3:
         name, spec_str = m3.groups()
         if name is None:
diff --git a/conda/models/version.py b/conda/models/version.py
--- a/conda/models/version.py
+++ b/conda/models/version.py
@@ -229,6 +229,9 @@ def __init__(self, vstr):
     def __str__(self):
         return self.norm_version
 
+    def __repr__(self):
+        return "%s(\"%s\")" % (self.__class__.__name__, self)
+
     def _eq(self, t1, t2):
         for v1, v2 in zip_longest(t1, t2, fillvalue=[]):
             for c1, c2 in zip_longest(v1, v2, fillvalue=self.fillvalue):
@@ -389,11 +392,15 @@ def untreeify(spec, _inand=False):
     return spec
 
 
+def compatible_release_operator(x, y):
+    return op.__ge__(x, y) and x.startswith(VersionOrder(".".join(text_type(y).split(".")[:-1])))
+
+
 # This RE matches the operators '==', '!=', '<=', '>=', '<', '>'
 # followed by a version string. It rejects expressions like
 # '<= 1.2' (space after operator), '<>1.2' (unknown operator),
 # and '<=!1.2' (nonsensical operator).
-version_relation_re = re.compile(r'^(=|==|!=|<=|>=|<|>)(?![=<>!])(\S+)$')
+version_relation_re = re.compile(r'^(=|==|!=|<=|>=|<|>|~=)(?![=<>!~])(\S+)$')
 regex_split_re = re.compile(r'.*[()|,^$]')
 OPERATOR_MAP = {
     '==': op.__eq__,
@@ -402,10 +409,11 @@ def untreeify(spec, _inand=False):
     '>=': op.__ge__,
     '<': op.__lt__,
     '>': op.__gt__,
-    '=': lambda x, y: text_type(x).startswith(text_type(y)),
-    "!=startswith": lambda x, y: not text_type(x).startswith(text_type(y)),
+    '=': lambda x, y: x.startswith(y),
+    "!=startswith": lambda x, y: not x.startswith(y),
+    "~=": compatible_release_operator,
 }
-OPERATOR_START = frozenset(('=', '<', '>', '!'))
+OPERATOR_START = frozenset(('=', '<', '>', '!', '~'))
 
 class BaseSpec(object):
 


Test patch:
diff --git a/tests/models/test_match_spec.py b/tests/models/test_match_spec.py
--- a/tests/models/test_match_spec.py
+++ b/tests/models/test_match_spec.py
@@ -205,13 +205,20 @@ def test_canonical_string_forms(self):
         assert m("numpy==1.10=py38_0") == "numpy==1.10=py38_0"
         assert m("numpy[version=1.10 build=py38_0]") == "numpy==1.10=py38_0"
 
-        assert m("numpy!=1.10") == "numpy[version='!=1.10']"
-        assert m("numpy !=1.10") == "numpy[version='!=1.10']"
+        assert m("numpy!=1.10") == "numpy!=1.10"
+        assert m("numpy !=1.10") == "numpy!=1.10"
         assert m("numpy!=1.10 py38_0") == "numpy[version='!=1.10',build=py38_0]"
         assert m("numpy !=1.10 py38_0") == "numpy[version='!=1.10',build=py38_0]"
         assert m("numpy!=1.10=py38_0") == "numpy[version='!=1.10',build=py38_0]"
         assert m("numpy !=1.10=py38_0") == "numpy[version='!=1.10',build=py38_0]"
         assert m("numpy >1.7,!=1.10 py38_0") == "numpy[version='>1.7,!=1.10',build=py38_0]"
+        assert m("numpy!=1.10.*") == "numpy!=1.10.*"
+        assert m("numpy!=1.10,!=1.11") == "numpy[version='!=1.10,!=1.11']"
+        assert m("numpy=1.10.*,!=1.10.2") == "numpy[version='=1.10.*,!=1.10.2']"
+
+        assert m("numpy ~=1.10.1") == "numpy~=1.10.1"
+        assert m("numpy~=1.10.1") == "numpy~=1.10.1"
+        assert m("numpy ~=1.10.1 py38_0") == "numpy[version='~=1.10.1',build=py38_0]"
 
         # # a full, exact spec looks like 'defaults/linux-64::numpy==1.8=py26_0'
         # # can we take an old dist str and reliably parse it with MatchSpec?
@@ -312,9 +319,6 @@ def test_matchspec_errors(self):
             # skipping on Windows for now.  don't feel like dealing with the windows url path crud
             assert text_type(MatchSpec("/some/file/on/disk/package-1.2.3-2.tar.bz2")) == '*[url=file:///some/file/on/disk/package-1.2.3-2.tar.bz2]'
 
-        with pytest.raises(InvalidSpec):
-            MatchSpec("appdirs ~=1.4.3")
-
     def test_dist(self):
         dst = Dist('defaults::foo-1.2.3-4.tar.bz2')
         a = MatchSpec(dst)
@@ -437,6 +441,9 @@ def test_bracket_matches(self):
         assert not MatchSpec("numpy[build_number='>7']").match(record)
         assert MatchSpec("numpy[build_number='>=7']").match(record)
 
+        assert MatchSpec("numpy ~=1.10").match(record)
+        assert MatchSpec("numpy~=1.10").match(record)
+
     def test_license_match(self):
         record = {
             'name': 'numpy',
@@ -666,15 +673,20 @@ def test_parse_equal_equal(self):
         }
 
     def test_parse_hard(self):
+        assert _parse_spec_str("numpy~=1.7.1") == {
+            "_original_spec_str": "numpy~=1.7.1",
+            "name": "numpy",
+            "version": "~=1.7.1",
+        }
         assert _parse_spec_str("numpy>1.8,<2|==1.7") == {
             "_original_spec_str": "numpy>1.8,<2|==1.7",
             "name": "numpy",
             "version": ">1.8,<2|==1.7",
         }
-        assert _parse_spec_str("numpy >1.8,<2|==1.7,!=1.9 py34_0") == {
-            "_original_spec_str": "numpy >1.8,<2|==1.7,!=1.9 py34_0",
+        assert _parse_spec_str("numpy >1.8,<2|==1.7,!=1.9,~=1.7.1 py34_0") == {
+            "_original_spec_str": "numpy >1.8,<2|==1.7,!=1.9,~=1.7.1 py34_0",
             "name": "numpy",
-            "version": ">1.8,<2|==1.7,!=1.9",
+            "version": ">1.8,<2|==1.7,!=1.9,~=1.7.1",
             "build": "py34_0",
         }
         assert _parse_spec_str("*>1.8,<2|==1.7") == {
diff --git a/tests/models/test_version.py b/tests/models/test_version.py
--- a/tests/models/test_version.py
+++ b/tests/models/test_version.py
@@ -277,3 +277,24 @@ def test_invalid_version_specs(self):
             VersionSpec("~")
         with pytest.raises(InvalidVersionSpec):
             VersionSpec("^")
+
+    def test_compatible_release_versions(self):
+        assert VersionSpec("~=1.10").match("1.11.0")
+        assert not VersionSpec("~=1.10.0").match("1.11.0")
+
+        assert not VersionSpec("~=3.3.2").match("3.4.0")
+        assert not VersionSpec("~=3.3.2").match("3.3.1")
+        assert VersionSpec("~=3.3.2").match("3.3.2.0")
+        assert VersionSpec("~=3.3.2").match("3.3.3")
+
+        assert VersionSpec("~=3.3.2|==2.2").match("2.2.0")
+        assert VersionSpec("~=3.3.2|==2.2").match("3.3.3")
+        assert not VersionSpec("~=3.3.2|==2.2").match("2.2.1")
+
+        with pytest.raises(InvalidVersionSpec):
+            VersionSpec("~=3.3.2.*")
+
+    def test_pep_440_arbitrary_equality_operator(self):
+        # We're going to leave the not implemented for now.
+        with pytest.raises(InvalidVersionSpec):
+            VersionSpec("===3.3.2")
diff --git a/tests/test_create.py b/tests/test_create.py
--- a/tests/test_create.py
+++ b/tests/test_create.py
@@ -20,7 +20,7 @@
 import re
 from shlex import split
 from shutil import copyfile, rmtree
-from subprocess import check_call, CalledProcessError, check_output
+from subprocess import check_call, CalledProcessError, check_output, Popen, PIPE
 import sys
 from tempfile import gettempdir
 from unittest import TestCase
@@ -1610,6 +1610,37 @@ def test_conda_pip_interop_conda_editable_package(self):
             assert unlink_dists[0]["name"] == "urllib3"
             assert unlink_dists[0]["channel"] == "pypi"
 
+    def test_conda_pip_interop_compatible_release_operator(self):
+        # Regression test for #7776
+        with make_temp_env("pip=10 six=1.9 appdirs") as prefix:
+            assert package_is_installed(prefix, "python")
+            assert package_is_installed(prefix, "six=1.9")
+            assert package_is_installed(prefix, "appdirs>=1.4.3")
+
+            p = Popen(PYTHON_BINARY + " -m pip install fs==2.1.0", stdout=PIPE, stderr=PIPE, cwd=prefix, shell=True)
+            stdout, stderr = p.communicate()
+            rc = p.returncode
+            assert int(rc) != 0
+            assert "Cannot uninstall" in text_type(stderr)
+
+            run_command(Commands.REMOVE, prefix, "six")
+            assert not package_is_installed(prefix, "six")
+
+            output = check_output(PYTHON_BINARY + " -m pip install fs==2.1.0", cwd=prefix, shell=True)
+            print(output)
+            PrefixData._cache_.clear()
+            assert package_is_installed(prefix, "fs==2.1.0")
+            # six_record = next(PrefixData(prefix).query("six"))
+            # print(json_dump(six_record.dump()))
+            assert package_is_installed(prefix, "six~=1.10")
+
+            stdout, stderr = run_command(Commands.LIST, prefix)
+            assert not stderr
+            assert "fs                        2.1.0                    pypi_0    pypi" in stdout
+
+            with pytest.raises(DryRunExit):
+                run_command(Commands.INSTALL, prefix, "agate=1.6 --dry-run")
+
     @pytest.mark.skipif(on_win, reason="gawk is a windows only package")
     def test_search_gawk_not_win_filter(self):
         with make_temp_env() as prefix: