Repository: pyodide/pyodide

Issue:
Add micropip install no_deps option
It would be useful to add the option `no_deps=False` to `micropip.install` to disable dependency resolution. This can be useful in cases where 
 - the dependency resolution doesn't work as expected (in particular with custom wheels https://github.com/pyodide/pyodide/issues/2408)
 - generally to be able to force install a set of packages, for instance ignoring requirements that should have been optional in an arbitrary PyPi package.

The no_deps name is by analogy with `pip install --no-deps`, though maybe `deps=True` would also work, although being not very explicit. 



Discussion:
We might also want to expose this without `micropip`. Like why not just `pyodide.loadPackage("some_wheel.whl")`? From a loading perspective it is a bit sad to do:
1. load Pyodide + distutils
2. Once all of that is finished, load micropip
3. Once that is finished load wheel

Without `micropip` we don't have `packaging` which we'd want to use to analyze the wheel metadata. Maybe we could record any wheels loaded with have `micropip` analyze the already loaded wheels when it is first imported since that metadata will be lying around in the file system even if we don't know how to read it easily without `packaging`.
Yes, particularly now that all packages are wheels we might want to merge together micropip.install, and pyodide.loadPackage somehow.  Though I like that micropip is a somewhat independent Python package and not included inside core pyodide.

Though it's an orthogonal issue, for now, we could add this option to both.
Yeah I still don't think we should merge `micropip` with `loadPackage`. I think I've said this a bunch but we should add `micropip.freeze()` that creates a new `packages.json` with the currently loaded packages, and then if you use that `packages.json` on a subsequent load then you won't need micropip at all. This way people who only need a fixed set of dependencies from PyPI can resolve those dependencies once and in their deployed app they don't need to wait for micropip's slower dynamic resolution strategy. As you say, this is related but different than the current topic.

Also worth noting that it is possible to do a no-deps install for a pure Python wheel as follows:
```js
const wheel_promise = fetch("blah.whl").then(x => x.arrayBuffer());
const pyodide = await loadPyodide();
const buffer = await wheel_promise;
pyodide.unpackArchive(buffer, "whl");
pyodide.runPython(`
import blah
# ...
`);
```
This is the best possible option in the sense that the wheel can be downloaded concurrently with the rest of Pyodide. 

But currently `micropip` won't know that `blah` is installed. One option would be to make micropip actually look at the `.dist-info` directories like real pip. Then it would know about wheels installed by whatever means.
>  One option would be to make micropip actually look at the .dist-info directories like real pip. Then it would know about wheels installed by whatever means.

Agreed. Looking at those instead of having our own system to keep track of state seems like a good idea.
> > One option would be to make micropip actually look at the .dist-info directories like real pip. Then it would know about wheels installed by whatever means.
> 
> Agreed. Looking at those instead of having our own system to keep track of state seems like a good idea.

+1 to it.

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
@@ -151,6 +151,7 @@ async def gather_requirements(
         requirements: str | list[str],
         ctx=None,
         keep_going: bool = False,
+        deps: bool = True,
         **kwargs,
     ):
         ctx = ctx or default_environment()
@@ -164,6 +165,7 @@ async def gather_requirements(
             "locked": copy.deepcopy(self.installed_packages),
             "failed": [],
             "keep_going": keep_going,
+            "deps": deps,
         }
         requirement_promises = []
         for requirement in requirements:
@@ -179,6 +181,7 @@ async def install(
         requirements: str | list[str],
         ctx=None,
         keep_going: bool = False,
+        deps: bool = True,
         credentials: str | None = None,
     ):
         async def _install(install_func, done_callback):
@@ -190,7 +193,7 @@ async def _install(install_func, done_callback):
         if credentials:
             fetch_extra_kwargs["credentials"] = credentials
         transaction = await self.gather_requirements(
-            requirements, ctx, keep_going, **fetch_extra_kwargs
+            requirements, ctx, keep_going, deps, **fetch_extra_kwargs
         )
 
         if transaction["failed"]:
@@ -355,8 +358,9 @@ async def add_wheel(
         if project_name == "UNKNOWN":
             project_name = name
 
-        for recurs_req in dist.requires(extras):
-            await self.add_requirement(recurs_req, ctx, transaction)
+        if transaction["deps"]:
+            for recurs_req in dist.requires(extras):
+                await self.add_requirement(recurs_req, ctx, transaction)
 
         transaction["wheels"].append((project_name, wheel, version))
 
@@ -401,6 +405,7 @@ def find_wheel(
 def install(
     requirements: str | list[str],
     keep_going: bool = False,
+    deps: bool = True,
     credentials: str | None = None,
 ):
     """Install the given package and all of its dependencies.
@@ -441,6 +446,11 @@ def install(
         - If ``True``, the micropip will keep going after the first error, and report a list
           of errors at the end.
 
+    deps : ``bool``, default: True
+
+        If ``True``, install dependencies specified in METADATA file for
+        each package. Otherwise do not install dependencies.
+
     credentials : ``Optional[str]``
 
         This parameter specifies the value of ``credentials`` when calling the
@@ -460,7 +470,7 @@ def install(
     importlib.invalidate_caches()
     return asyncio.ensure_future(
         PACKAGE_MANAGER.install(
-            requirements, keep_going=keep_going, credentials=credentials
+            requirements, keep_going=keep_going, deps=deps, credentials=credentials
         )
     )
 


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
@@ -189,13 +189,15 @@ def test_add_requirement():
         base_url = f"http://{server_hostname}:{server_port}/"
         url = base_url + "snowballstemmer-2.0.0-py2.py3-none-any.whl"
 
-        transaction: dict[str, Any] = {
-            "wheels": [],
-            "locked": {},
-        }
-        asyncio.get_event_loop().run_until_complete(
-            _micropip.PACKAGE_MANAGER.add_requirement(url, {}, transaction)
-        )
+    transaction: dict[str, Any] = {
+        "wheels": [],
+        "locked": {},
+        "keep_going": True,
+        "deps": True,
+    }
+    asyncio.get_event_loop().run_until_complete(
+        _micropip.PACKAGE_MANAGER.add_requirement(url, {}, transaction)
+    )
 
     [name, req, version] = transaction["wheels"][0]
     assert name == "snowballstemmer"
@@ -348,6 +350,33 @@ def test_install_keep_going(monkeypatch):
         )
 
 
+def test_install_no_deps(monkeypatch):
+    pytest.importorskip("packaging")
+    from micropip import _micropip
+
+    dummy_pkg_name = "dummy"
+    dep_pkg_name = "dependency_dummy"
+    _mock_get_pypi_json = mock_get_pypi_json(
+        {
+            dummy_pkg_name: f"{dummy_pkg_name}-1.0.0-py3-none-any.whl",
+            dep_pkg_name: f"{dep_pkg_name}-1.0.0-py3-none-any.whl",
+        }
+    )
+    _mock_fetch_bytes = mock_fetch_bytes(
+        dummy_pkg_name, f"Requires-Dist: {dep_pkg_name}\n\nUNKNOWN"
+    )
+
+    monkeypatch.setattr(_micropip, "_get_pypi_json", _mock_get_pypi_json)
+    monkeypatch.setattr(_micropip, "fetch_bytes", _mock_fetch_bytes)
+
+    asyncio.get_event_loop().run_until_complete(
+        _micropip.install(dummy_pkg_name, deps=False)
+    )
+
+    assert dummy_pkg_name in _micropip._list()
+    assert dep_pkg_name not in _micropip._list()
+
+
 def test_fetch_wheel_fail(monkeypatch):
     pytest.importorskip("packaging")
     from micropip import _micropip