Repository: pyodide/pyodide

Issue:
micropip does not resolve locally available versions 
I have three wheels:

* travertino is v0.1.3
* toga-core is v0.3.0.dev33, and declares travertino>=0.1.3 as a requirement
* toga-web is v0.3.0.dev33, and declares toga-core==0.3.0.dev33 as a requirement

All three exist as `.whl` files in my static files folder. Travertino 0.1.3 has been published to PyPI; toga-core and toga-web dev32 have been published to PyPI, but dev33 are local development builds. 

On my Pyodide page, I run:
```
import micropip
micropip.install([
    './static/travertino-0.1.3-py3-none-any.whl',
    './static/toga_core-0.3.0.dev33-py3-none-any.whl',
    './static/toga_web-0.3.0.dev33-py3-none-any.whl',
])
```
This raises the following exception:
```
Traceback (most recent call last):
  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 183, in install
    transaction = await self.gather_requirements(requirements, ctx, keep_going)
  File "/lib/python3.10/site-packages/micropip/_micropip.py", line 173, 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 245, in add_requirement
    await self.add_wheel(name, wheel, version, (), ctx, transaction)
  File "/lib/python3.10/site-packages/micropip/_micropip.py", line 316, in add_wheel
    await self.add_requirement(recurs_req, ctx, transaction)
  File "/lib/python3.10/site-packages/micropip/_micropip.py", line 286, in add_requirement
    raise ValueError(
ValueError: Couldn't find a pure Python 3 wheel for 'toga-core==0.3.0.dev33'. You can use `micropip.install(..., keep_going=True)` to get a list of all packages with missing wheels.
```

If I relax the `install_requires` on toga-web to remove the declared dependency on toga-core, the import succeeds, and the code runs successfully.

I've tried splitting the install into two passes:
```
await micropip.install([
    './static/travertino-0.1.3-py3-none-any.whl',
    './static/toga_core-0.3.0.dev33-py3-none-any.whl',
])
await micropip.install([
    './static/toga_web-0.3.0.dev33-py3-none-any.whl',
])
```
thinking that if toga-core's installation was completed, toga-web would be able to find it; that makes no difference. 

I've also tried producing the packages without the .dev33 version suffix, thinking that it might be "prerelease" versions that are the issue, but that made no difference either.

However, if I revert the version number on toga-core and toga-web to 0.3.0.dev32 (the last versions published to PyPI), the import *succeeds* - but the old version (published to PyPI) version of the toga-core code is used. 

Looking at the network requests that the page has made indicates that the wheels were all loaded - and then micropip requested the toga-core PyPI metadata, and toga_core-0.3.0.dev32 (the most recent published version) was downloaded from PyPI. Interestingly, *no* request was made to load Travertino 0.1.3 from PyPI as a result of the dependency declared in toga-core.



Discussion:
Thanks for the report. Yes I can confirm that this a bug and splitting the install should work.

The reason of this bug is that micropip is handling the package name differently when it is installed from PyPI or from `.whl` url.

```python
>>> import micropip
>>> await micropip.install("toga-web==0.3.0.dev32")
>>> micropip.list()
Name       | Version     | Source
---------- | ----------- | -------
travertino | 0.1.3       | pypi
toga-core  | 0.3.0.dev32 | pypi
toga-web   | 0.3.0.dev32 | pypi
...
```

```python
>>> import micropip
>>> await micropip.install("https://files.pythonhosted.org/packages/56/93/7bf199546e0de51404d7c714c618f9e67d215c2723351e1af0392e5e0a59/toga_web-0.3.0.dev32-py3-none-any.whl")
>>> micropip.list()
Name       | Version     | Source
---------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------
travertino | 0.1.3       | pypi
toga-core  | 0.3.0.dev32 | pypi
toga_web   | 0.3.0.dev32 | https://files.pythonhosted.org/packages/56/93/7bf199546e0de51404d7c714c618f9e67d215c2723351e1af0392e5e0a59/toga_web-0.3.0.dev32-py3-none-any.whl
...
```

When installed from PyPI, micropip think its name is `toga-web`, while when installed from whl url, micropip parses the name from whl url: `toga_web`.

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
@@ -17,7 +17,7 @@
 from pyodide import IN_BROWSER, to_js
 
 from .externals.pip._internal.utils.wheel import pkg_resources_distribution_for_wheel
-from .package import PackageDict, PackageMetadata
+from .package import PackageDict, PackageMetadata, normalize_package_name
 
 # Provide stubs for testing in native python
 if IN_BROWSER:
@@ -205,7 +205,10 @@ async def _install(install_func, done_callback):
                     ),
                     functools.partial(
                         self.installed_packages.update,
-                        {pkg.name: pkg for pkg in pyodide_packages},
+                        {
+                            normalize_package_name(pkg.name): pkg
+                            for pkg in pyodide_packages
+                        },
                     ),
                 )
             )
@@ -220,7 +223,11 @@ async def _install(install_func, done_callback):
                     _install_wheel(name, wheel),
                     functools.partial(
                         self.installed_packages.update,
-                        {name: PackageMetadata(name, str(ver), wheel_source)},
+                        {
+                            normalize_package_name(name): PackageMetadata(
+                                name, str(ver), wheel_source
+                            )
+                        },
                     ),
                 )
             )
@@ -293,7 +300,11 @@ async def add_requirement(self, requirement: str | Requirement, ctx, transaction
             )
 
     async def add_wheel(self, name, wheel, version, extras, ctx, transaction):
-        transaction["locked"][name] = PackageMetadata(name=name, version=version)
+        normalized_name = normalize_package_name(name)
+        transaction["locked"][normalized_name] = PackageMetadata(
+            name=name,
+            version=version,
+        )
 
         try:
             wheel_bytes = await fetch_bytes(wheel["url"])
@@ -312,10 +323,15 @@ async def add_wheel(self, name, wheel, version, extras, ctx, transaction):
 
         with ZipFile(io.BytesIO(wheel_bytes)) as zip_file:
             dist = pkg_resources_distribution_for_wheel(zip_file, name, "???")
+
+        project_name = dist.project_name
+        if project_name == "UNKNOWN":
+            project_name = name
+
         for recurs_req in dist.requires(extras):
             await self.add_requirement(recurs_req, ctx, transaction)
 
-        transaction["wheels"].append((name, wheel, version))
+        transaction["wheels"].append((project_name, wheel, version))
 
     def find_wheel(
         self, metadata: dict[str, Any], req: Requirement
diff --git a/packages/micropip/src/micropip/package.py b/packages/micropip/src/micropip/package.py
--- a/packages/micropip/src/micropip/package.py
+++ b/packages/micropip/src/micropip/package.py
@@ -1,3 +1,4 @@
+import re
 from collections import UserDict
 from dataclasses import astuple, dataclass
 from typing import Iterable
@@ -31,6 +32,10 @@ def format_row(values, widths, filler=""):
     return "\n".join(rows)
 
 
+def normalize_package_name(pkgname: str):
+    return re.sub(r"[^\w\d.]+", "_", pkgname, re.UNICODE)
+
+
 @dataclass
 class PackageMetadata:
     name: str
@@ -54,6 +59,14 @@ class PackageDict(UserDict):
     def __repr__(self):
         return self._tabularize()
 
+    def __getitem__(self, key):
+        normalized_key = normalize_package_name(key)
+        return super().__getitem__(normalized_key)
+
+    def __contains__(self, key):
+        normalized_key = normalize_package_name(key)
+        return super().__contains__(normalized_key)
+
     def _tabularize(self):
         headers = [key.capitalize() for key in PackageMetadata.keys()]
         table = list(self.values())


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
@@ -401,6 +401,28 @@ def test_list_wheel_package(monkeypatch):
     assert "dummy" in pkg_list and pkg_list["dummy"].source.lower() == dummy_url
 
 
+def test_list_wheel_name_mismatch(monkeypatch):
+    pytest.importorskip("packaging")
+    from micropip import _micropip
+
+    dummy_pkg_name = "dummy-dummy"
+    dummy_url = (
+        f"https://dummy.com/{dummy_pkg_name.replace('-', '_')}-1.0.0-py3-none-any.whl"
+    )
+    _mock_fetch_bytes = mock_fetch_bytes(dummy_pkg_name, f"Name: {dummy_pkg_name}")
+
+    monkeypatch.setattr(_micropip, "fetch_bytes", _mock_fetch_bytes)
+
+    asyncio.get_event_loop().run_until_complete(_micropip.install(dummy_url))
+
+    pkg_list = _micropip._list()
+    assert (
+        dummy_pkg_name in pkg_list
+        and pkg_list[dummy_pkg_name].source.lower() == dummy_url
+        and pkg_list[dummy_pkg_name].name == dummy_pkg_name
+    )
+
+
 def test_list_pyodide_package(selenium_standalone_micropip):
     selenium = selenium_standalone_micropip
     selenium.run_js(