Repository: pyodide/pyodide

Issue:
micropip didn't resolve package versions correctly
## 🐛 Bug

Hi, I am using micropip to installs two pure python packages, but got the error message complaining:
```
ValueError: Requested 'imjoy-rpc>=0.5.7', but imjoy-rpc==0.5.10a2 is already installed
```
<!-- A clear and concise description of what the bug is. -->

### To Reproduce

<!-- Minimal code example to reproduce the bug. -->
```
Welcome to the Pyodide terminal emulator 🐍
Python 3.10.2 (main, May  4 2022 10:50:05) on WebAssembly VM
Type "help", "copyright", "credits" or "license" for more information.
>>> import micropip
>>> await micropip.install("imjoy-rpc==0.5.10a2")
>>> await micropip.install("imjoy-rpc>=0.5.7")
Traceback (most recent call last):
  File "<console>", line 1, in <module>
  File "/lib/python3.10/asyncio/futures.py", line 284, in __await__
    yield self  # This tells Task to wait for completion.
  File "/lib/python3.10/asyncio/tasks.py", line 304, in __wakeup
    future.result()
  File "/lib/python3.10/asyncio/futures.py", line 201, in result
    raise self._exception
  File "/lib/python3.10/asyncio/tasks.py", line 234, in __step
    result = coro.throw(exc)
  File "/lib/python3.10/site-packages/micropip/_micropip.py", line 192, in install
    transaction = await self.gather_requirements(
  File "/lib/python3.10/site-packages/micropip/_micropip.py", line 174, in gather_requirements
    await gather(*requirement_promises)
  File "/lib/python3.10/asyncio/futures.py", line 284, in __await__
    yield self  # This tells Task to wait for completion.
  File "/lib/python3.10/asyncio/tasks.py", line 304, in __wakeup
    future.result()
  File "/lib/python3.10/asyncio/futures.py", line 201, in result
    raise self._exception
  File "/lib/python3.10/asyncio/tasks.py", line 232, in __step
    result = coro.send(None)
  File "/lib/python3.10/site-packages/micropip/_micropip.py", line 302, in add_requirement
    raise ValueError(
ValueError: Requested 'imjoy-rpc>=0.5.7', but imjoy-rpc==0.5.10a2 is already installed
```

### Expected behavior

When I run the second install command, it should know that the version is already satisfied and do nothing instead of generating an error.

### Environment

Latest version at https://pyodide-cdn2.iodide.io/dev/full/console.html




Discussion:
Thanks for the report! Probably it did something like `'0.5.10' >= '0.5.7'` and got `False`.
> Thanks for the report! Probably it did something like `'0.5.10' >= '0.5.7'` and got `False`.

FYI: also failed if we install the later one without specifying the version, it appears that it only matches the exact string.
https://github.com/pypa/packaging/blob/ba124324e866e518ebfe0378a25b6ba4816e5880/packaging/specifiers.py#L185-L189

Setting `req.prereleases = True` will work I guess.

Resolving patch:
diff --git a/packages/micropip/src/micropip/_micropip.py b/packages/micropip/src/micropip/_micropip.py
--- a/packages/micropip/src/micropip/_micropip.py
+++ b/packages/micropip/src/micropip/_micropip.py
@@ -275,13 +275,13 @@ async def add_requirement(
             return
         else:
             req = Requirement(requirement)
+
         req.name = req.name.lower()
 
         # If there's a Pyodide package that matches the version constraint, use
         # the Pyodide package instead of the one on PyPI
-        if (
-            req.name in BUILTIN_PACKAGES
-            and BUILTIN_PACKAGES[req.name]["version"] in req.specifier
+        if req.name in BUILTIN_PACKAGES and req.specifier.contains(
+            BUILTIN_PACKAGES[req.name]["version"], prereleases=True
         ):
             version = BUILTIN_PACKAGES[req.name]["version"]
             transaction["pyodide_packages"].append(
@@ -298,7 +298,7 @@ async def add_requirement(
         # Is some version of this package is already installed?
         if req.name in transaction["locked"]:
             ver = transaction["locked"][req.name].version
-            if ver in req.specifier:
+            if req.specifier.contains(ver, prereleases=True):
                 # installed version matches, nothing to do
                 return
             else:


Test patch:
diff --git a/packages/micropip/test_micropip.py b/packages/micropip/test_micropip.py
--- a/packages/micropip/test_micropip.py
+++ b/packages/micropip/test_micropip.py
@@ -16,7 +16,7 @@ def mock_get_pypi_json(pkg_map):
 
     Parameters
     ----------
-    pkg_map : ``None | Dict[str, str]``
+    pkg_map : ``None | Dict[str, Any]``
 
         Dictionary that maps package name to dummy release file.
         Packages that are not in this dictionary will return
@@ -34,13 +34,14 @@ def __eq__(self, other):
 
     async def _mock_get_pypi_json(pkgname, **kwargs):
         if pkgname in pkg_map:
-            pkg_file = pkg_map[pkgname]
+            pkg_file = pkg_map[pkgname]["name"]
+            pkg_version = pkg_map[pkgname].get("version", "1.0.0")
         else:
             pkg_file = f"{pkgname}-1.0.0.tar.gz"
-
+            pkg_version = "1.0.0"
         return {
             "releases": {
-                "1.0.0": [
+                pkg_version: [
                     {
                         "filename": pkg_file,
                         "url": "",
@@ -333,7 +334,7 @@ def test_install_keep_going(monkeypatch):
 
     dummy_pkg_name = "dummy"
     _mock_get_pypi_json = mock_get_pypi_json(
-        {dummy_pkg_name: f"{dummy_pkg_name}-1.0.0-py3-none-any.whl"}
+        {dummy_pkg_name: {"name": f"{dummy_pkg_name}-1.0.0-py3-none-any.whl"}}
     )
     _mock_fetch_bytes = mock_fetch_bytes(
         dummy_pkg_name, "Requires-Dist: dep1\nRequires-Dist: dep2\n\nUNKNOWN"
@@ -350,6 +351,51 @@ def test_install_keep_going(monkeypatch):
         )
 
 
+def test_install_version_compare_prerelease(monkeypatch):
+    pytest.importorskip("packaging")
+    from micropip import _micropip
+
+    dummy_pkg_name = "dummy"
+    version_new = "3.2.1a1"
+    version_old = "3.2.0"
+
+    _mock_get_pypi_json_new = mock_get_pypi_json(
+        {
+            dummy_pkg_name: {
+                "name": f"{dummy_pkg_name}-{version_new}-py3-none-any.whl",
+                "version": version_new,
+            }
+        }
+    )
+
+    _mock_get_pypi_json_old = mock_get_pypi_json(
+        {
+            dummy_pkg_name: {
+                "name": f"{dummy_pkg_name}-{version_old}-py3-none-any.whl",
+                "version": version_old,
+            }
+        }
+    )
+
+    _mock_fetch_bytes = mock_fetch_bytes(dummy_pkg_name, "UNKNOWN")
+
+    monkeypatch.setattr(_micropip, "fetch_bytes", _mock_fetch_bytes)
+
+    monkeypatch.setattr(_micropip, "_get_pypi_json", _mock_get_pypi_json_new)
+    asyncio.get_event_loop().run_until_complete(
+        _micropip.install(f"{dummy_pkg_name}=={version_new}")
+    )
+
+    monkeypatch.setattr(_micropip, "_get_pypi_json", _mock_get_pypi_json_old)
+    asyncio.get_event_loop().run_until_complete(
+        _micropip.install(f"{dummy_pkg_name}>={version_old}")
+    )
+
+    installed_pkgs = _micropip._list()
+    # Older version should not be installed
+    assert installed_pkgs[dummy_pkg_name].version == version_new
+
+
 def test_install_no_deps(monkeypatch):
     pytest.importorskip("packaging")
     from micropip import _micropip
@@ -399,7 +445,7 @@ def test_list_pypi_package(monkeypatch):
 
     dummy_pkg_name = "dummy"
     _mock_get_pypi_json = mock_get_pypi_json(
-        {dummy_pkg_name: f"{dummy_pkg_name}-1.0.0-py3-none-any.whl"}
+        {dummy_pkg_name: {"name": f"{dummy_pkg_name}-1.0.0-py3-none-any.whl"}}
     )
     _mock_fetch_bytes = mock_fetch_bytes(dummy_pkg_name, "UNKNOWN")