Repository: pyodide/pyodide

Issue:
Micropip should be able to install wheels from the file system
## 🚀 Feature

Micropip should be able to install a wheel which is in the emscripten file system from a path.

### Motivation

In node (and some day maybe in chrome), we can mount the local file system into the Emscripten file system. It would be useful to install these, for instance when testing whether a wheel was built correctly.


Discussion:
Posting here for visibility.

The latest JupyterLite release adds support for accessing files from the Python (Pyodide-based) kernel: 

- https://github.com/jupyterlite/jupyterlite/releases/tag/v0.1.0b9
- https://github.com/jupyterlite/jupyterlite/pull/655

While the mounting logic is a bit specific to JupyterLite / JupyterLab and enabled via a `ServiceWorker`, making `micropip` able to install packages from a local path would still be useful.

For example one could upload a wheel (or ship it as part of the default content) and install it with:

```py
import micropip
await micropip.install('./snowballstemmer-2.2.0-py2.py3-none-any.whl')
```

![image](https://user-images.githubusercontent.com/591645/174308580-85a6303a-ad42-46da-84cc-725ddeaebe94.png)
Maybe it can be `micropip.install_file`? `install_from_filesystem`?
Probably from a user point of view keeping the same `micropip.install()` would be more convenient? (like regular `pip`)
We currently interpret `micropip.install("a/b/c.whl")` as a relative URL, so there is no syntax space for that. Unless you want to do `micropip.install(from_path="a/b/c.whl")`?
What about intercepting `file://` before fetch as meaning _file in the WASM machine_? I'd think pyodide itself could not be usefully loaded when "hosted" from `file://` because `.json`, much less `.wasm`, would not be served properly. Further no _other_ protocol would be able to resolve it.
What about relative paths though?
Well, URIs don't _necessarily_ have to start with `<protocol>:/`: there's certainly precedent for non-slashed paths, e.g. python's [sqlite3]() module, which inherit's [sqlite's implementation](https://www.sqlite.org/uri.html):

```py
# Open a database in read-only mode.
con = sqlite3.connect("file:template.db?mode=ro", uri=True)
```

So, with the default settings, `file:some-package.whl` could be interpreted the same as `file:/home/pyodide/some-package.whl`. 

Indeed, allowing `<protocol>` to be extended is a _whacking_ good idea: `ipfs://` springs to mind as an _almost perfect_ tool for peer-to-peer package distribution.
> What about intercepting file:// before fetch as meaning file in the WASM machine?

The problem is that when run in Node.js, `file://`  [means host file system](https://nodejs.org/api/fs.html#file-url-paths). Though for Python `file://` would probably still mean the whatever FS Python is installed in. We would want to keep a consistent behavior in the browser and Node. Maybe `memfs://` at least as far as micropip is concerned?
hey guys! awesome to see this being on the table so recently
got here while searching for a way to use my own packages in pyscript
for example i've got a package called xo-gd, which i want to use, but i cant seem to import it

seems like this is a requested feature not yet available seemlessly,
does anyone know of a way for me to do this? do i need to build pyodide myself with this package? or should i just copy all the python files from my package locally ? (it's already all on pip, so i thought it should be straight-forward)

any info would be appreciated! thx & have a good one! 
@fire17 Would you mind opening a separate issue about it? It's a bit orthogonal to the current discussion. The general documentation for adding a package can be found [here](https://pyodide.org/en/stable/development/new-packages.html). Looking at the xo-gd description, it looks like a lof of the functionality it exposes will not be supported in the browser (let's discuss the details in a separate issue).
I am leaning towards going with `micropip.install(from_path="path/to/some/wheel.whl")` for now. I guess this won't work with e.g., `py-env` or requirement lists but if we want some string prefix that indicates to install from the file system we can figure out the details a bit later. My immediate use case is getting CI to work for out of tree package builds which know that they are trying to load a wheel from the local file system.

I guess another thought is it might be worth having a list of local directories that micropip could automatically search when looking for a wheel. This would better handle the case where you have a wheel and several dependencies of the wheel all in the same folder and you want micropip to correctly resolve the dependencies and load all of them.
> micropip.install(from_path= [..] we can figure out the details a bit later.

Maybe someone should try to do a draft implementation. I suspect that having two inputs would make the implementation less straightforward as currently we just pass a list of requirements everywhere. Also, it's not very common in Python to have multiple and mutually exclusive input arguments, that's probably the reason for the URL file prefix, instead of doing `urlopen(from_http=None, from_https=None, from_ftp=None, etc)`.

For some things, we can figure out the details later, but micropip.install is likely one of the most frequently used functions by end users. So it would be good to agree on a public API and implement it, so we can avoid API breakages or deprecations for this (unless we can finish iterating on it before the next release).
Well what about going with @bollwyvl's suggestion. We could set it up so that `file://` makes a fetch request but `file:some/path` can goes to the local file system.
Or maybe `emfs:some/path`. I don't care for `memfs` because it is possible to mount other file systems e.g., `nodefs` and then it's a bit confusing. But they are all the Emscripten file system.
"emfs:" is concise yet distinctive. The unslashed-is-relative seems
reasonable as well.

I do still like the idea being able to  (re)register of new protocol
handlers that can handle both wheels and warehouse-like API responses...
But "wheels on the disk" seems like a fine place to start.

OK, let's go with `emfs:`.

Actually I wasn't aware that,
> - The // after the file: denotes that either a hostname or the literal term localhost will follow,[[2]](https://en.wikipedia.org/wiki/File_URI_scheme#cite_note-2) although this part may be omitted entirely, or may contain an empty hostname.[[3]](https://en.wikipedia.org/wiki/File_URI_scheme#cite_note-3)
>-  file://path (i.e. two slashes, without a hostname) is never correct, but is often used

https://en.wikipedia.org/wiki/File_URI_scheme
Yeah, URLs are definitely funny things. Here's a little with what `urlparse.parse` and `new URL` do with various things:

```py
from pathlib import Path
from urllib.parse import urlparse
import js
for p in ["emfs:foo.whl", "emfs:./foo.whl", "emfs:/home/pydodide/bar.whl", "emfs://home/pyodide/baz.whl"]:
    print("\n---", p, "---")
    py_parsed = urlparse(p)
    js_parsed = js.eval(f"new URL('{p}')").to_py()
    print("py parsed:", py_parsed)
    print("... url:", py_parsed.geturl())
    print("... path:", Path(py_parsed.path))
    print("... abspath:", Path(py_parsed.path).resolve())
    print("js parsed:", js_parsed)
    print("... parts", f"protocol='{js_parsed.protocol}', origin='{js_parsed.origin}', pathname='{js_parsed.pathname}'")
```

```

--- emfs:foo.whl ---
py parsed: ParseResult(scheme='emfs', netloc='', path='foo.whl', params='', query='', fragment='')
... url: emfs:foo.whl
... path: foo.whl
... abspath: /home/pyodide/foo.whl
js parsed: emfs:foo.whl
... parts protocol='emfs:', origin='null', pathname='foo.whl'

--- emfs:./foo.whl ---
py parsed: ParseResult(scheme='emfs', netloc='', path='./foo.whl', params='', query='', fragment='')
... url: emfs:./foo.whl
... path: foo.whl
... abspath: /home/pyodide/foo.whl
js parsed: emfs:./foo.whl
... parts protocol='emfs:', origin='null', pathname='./foo.whl'

--- emfs:/home/pydodide/bar.whl ---
py parsed: ParseResult(scheme='emfs', netloc='', path='/home/pydodide/bar.whl', params='', query='', fragment='')
... url: emfs:/home/pydodide/bar.whl
... path: /home/pydodide/bar.whl
... abspath: /home/pydodide/bar.whl
js parsed: emfs:/home/pydodide/bar.whl
... parts protocol='emfs:', origin='null', pathname='/home/pydodide/bar.whl'

--- emfs://home/pyodide/baz.whl ---
py parsed: ParseResult(scheme='emfs', netloc='home', path='/pyodide/baz.whl', params='', query='', fragment='')
... url: emfs://home/pyodide/baz.whl
... path: /pyodide/baz.whl
... abspath: /pyodide/baz.whl
js parsed: emfs://home/pyodide/baz.whl
... parts protocol='emfs:', origin='null', pathname='//home/pyodide/baz.whl'
```
Thanks everyone for the discussion!

Resolving patch:
diff --git a/packages/micropip/src/micropip/_compat_in_pyodide.py b/packages/micropip/src/micropip/_compat_in_pyodide.py
--- a/packages/micropip/src/micropip/_compat_in_pyodide.py
+++ b/packages/micropip/src/micropip/_compat_in_pyodide.py
@@ -1,3 +1,7 @@
+from io import BytesIO
+from typing import IO
+from urllib.parse import urlparse
+
 from pyodide._core import IN_BROWSER
 from pyodide.http import pyfetch
 
@@ -14,10 +18,15 @@
     # Otherwise, this is pytest test collection so let it go.
 
 
-async def fetch_bytes(url: str, kwargs: dict[str, str]) -> bytes:
-    if url.startswith("file://"):
-        return (await loadBinaryFile("", url)).to_bytes()
-    return await (await pyfetch(url, **kwargs)).bytes()
+async def fetch_bytes(url: str, kwargs: dict[str, str]) -> IO[bytes]:
+    parsed_url = urlparse(url)
+    if parsed_url.scheme == "emfs":
+        return open(parsed_url.path, "rb")
+    if parsed_url.scheme == "file":
+        result_bytes = (await loadBinaryFile("", parsed_url.path)).to_bytes()
+    else:
+        result_bytes = await (await pyfetch(url, **kwargs)).bytes()
+    return BytesIO(result_bytes)
 
 
 async def fetch_string(url: str, kwargs: dict[str, str]) -> str:
diff --git a/packages/micropip/src/micropip/_compat_not_in_pyodide.py b/packages/micropip/src/micropip/_compat_not_in_pyodide.py
--- a/packages/micropip/src/micropip/_compat_not_in_pyodide.py
+++ b/packages/micropip/src/micropip/_compat_not_in_pyodide.py
@@ -1,4 +1,5 @@
-from typing import Any
+from io import BytesIO
+from typing import IO, Any
 
 REPODATA_PACKAGES: dict[str, dict[str, Any]] = {}
 
@@ -12,12 +13,12 @@ def to_py():
 from urllib.request import Request, urlopen
 
 
-async def fetch_bytes(url: str, kwargs: dict[str, str]) -> bytes:
-    return urlopen(Request(url, headers=kwargs)).read()
+async def fetch_bytes(url: str, kwargs: dict[str, str]) -> IO[bytes]:
+    return BytesIO(urlopen(Request(url, headers=kwargs)).read())
 
 
 async def fetch_string(url: str, kwargs: dict[str, str]) -> str:
-    return (await fetch_bytes(url, kwargs)).decode()
+    return (await fetch_bytes(url, kwargs)).read().decode()
 
 
 async def loadDynlib(dynlib: str, is_shared_lib: bool) -> None:
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
@@ -8,9 +8,9 @@
 from importlib.metadata import PackageNotFoundError
 from importlib.metadata import distributions as importlib_distributions
 from importlib.metadata import version as importlib_version
-from io import BytesIO
 from pathlib import Path
-from typing import Any
+from typing import IO, Any
+from urllib.parse import ParseResult, urlparse
 from zipfile import ZipFile
 
 from packaging.markers import default_environment
@@ -55,9 +55,10 @@ class WheelInfo:
     build: tuple[int, str] | tuple[()]
     tags: frozenset[Tag]
     url: str
+    parsed_url: ParseResult
     project_name: str | None = None
     digests: dict[str, str] | None = None
-    data: BytesIO | None = None
+    data: IO[bytes] | None = None
     _dist: Any = None
     dist_info: Path | None = None
     _requires: list[Requirement] | None = None
@@ -68,7 +69,8 @@ def from_url(url: str) -> "WheelInfo":
 
         See https://www.python.org/dev/peps/pep-0427/#file-name-convention
         """
-        file_name = Path(url).name
+        parsed_url = urlparse(url)
+        file_name = Path(parsed_url.path).name
         name, version, build, tags = parse_wheel_filename(file_name)
         return WheelInfo(
             name=name,
@@ -77,6 +79,7 @@ def from_url(url: str) -> "WheelInfo":
             build=build,
             tags=tags,
             url=url,
+            parsed_url=parsed_url,
         )
 
     def is_compatible(self):
@@ -87,23 +90,27 @@ def is_compatible(self):
                 return True
         return False
 
-    async def download(self, fetch_kwargs):
+    async def _fetch_bytes(self, fetch_kwargs):
         try:
-            wheel_bytes = await fetch_bytes(self.url, fetch_kwargs)
+            return await fetch_bytes(self.url, fetch_kwargs)
         except OSError as e:
-            if self.url.startswith("https://files.pythonhosted.org/"):
+            if self.parsed_url.hostname in [
+                "files.pythonhosted.org",
+                "cdn.jsdelivr.net",
+            ]:
                 raise e
             else:
                 raise ValueError(
-                    f"Can't fetch wheel from '{self.url}'."
+                    f"Can't fetch wheel from '{self.url}'. "
                     "One common reason for this is when the server blocks "
-                    "Cross-Origin Resource Sharing (CORS)."
+                    "Cross-Origin Resource Sharing (CORS). "
                     "Check if the server is sending the correct 'Access-Control-Allow-Origin' header."
                 ) from e
 
-        self.data = BytesIO(wheel_bytes)
-
-        with ZipFile(self.data) as zip_file:
+    async def download(self, fetch_kwargs):
+        data = await self._fetch_bytes(fetch_kwargs)
+        self.data = data
+        with ZipFile(data) as zip_file:
             self._dist = pkg_resources_distribution_for_wheel(
                 zip_file, self.name, "???"
             )
@@ -117,11 +124,10 @@ def validate(self):
             # No checksums available, e.g. because installing
             # from a different location than PyPI.
             return
-        sha256 = self.digests["sha256"]
-        m = hashlib.sha256()
+        sha256_expected = self.digests["sha256"]
         assert self.data
-        m.update(self.data.getvalue())
-        if m.hexdigest() != sha256:
+        sha256_actual = _generate_package_hash(self.data)
+        if sha256_actual != sha256_expected:
             raise ValueError("Contents don't match hash")
 
     def extract(self, target: Path) -> None:
@@ -398,9 +404,9 @@ async def install(
 
     See :ref:`loading packages <loading_packages>` for more information.
 
-    This only works for packages that are either pure Python or for packages
-    with C extensions that are built in Pyodide. If a pure Python package is not
-    found in the Pyodide repository it will be loaded from PyPI.
+    If a package is not found in the Pyodide repository it will be loaded from
+    PyPI. Micropip can only load pure Python packages or for packages with C
+    extensions that are built for Pyodide.
 
     When used in web browsers, downloads from PyPI will be cached. When run in
     Node.js, packages are currently not cached, and will be re-downloaded each
@@ -411,15 +417,28 @@ async def install(
     requirements : ``str | List[str]``
 
         A requirement or list of requirements to install. Each requirement is a
-        string, which should be either a package name or URL to a wheel:
+        string, which should be either a package name or a wheel URI:
+
+        - If the requirement does not end in ``.whl``, it will be interpreted as
+          a package name. A package with this name must either be present
+          in the Pyodide lock file or on PyPI.
+
+        - If the requirement ends in ``.whl``, it is a wheel URI. The part of
+          the requirement after the last ``/``  must be a valid wheel name in
+          compliance with the `PEP 427 naming convention
+          <https://www.python.org/dev/peps/pep-0427/#file-format>`_.
+
+        - If a wheel URI starts with ``emfs:``, it will be interpreted as a path
+          in the Emscripten file system (Pyodide's file system). E.g.,
+          `emfs:../relative/path/wheel.whl` or `emfs:/absolute/path/wheel.whl`.
+          In this case, only .whl files are supported.
+
+        - If a wheel URI requirement starts with ``http:`` or ``https:`` it will
+          be interpreted as a URL.
 
-        - If the requirement ends in ``.whl`` it will be interpreted as a URL.
-          The file must be a wheel named in compliance with the
-          `PEP 427 naming convention <https://www.python.org/dev/peps/pep-0427/#file-format>`_.
+        - In node, you can access the native file system using a URI that starts
+          with ``file:``. In the browser this will not work.
 
-        - If the requirement does not end in ``.whl``, it will interpreted as the
-          name of a package. A package by this name must either be present in the
-          Pyodide repository at :any:`indexURL <globalThis.loadPyodide>` or on PyPI
 
     keep_going : ``bool``, default: False
 
@@ -427,28 +446,29 @@ async def install(
         Python package without a pure Python wheel while doing dependency
         resolution:
 
-        - If ``False``, an error will be raised on first package with a missing wheel.
+        - If ``False``, an error will be raised on first package with a missing
+          wheel.
 
-        - If ``True``, the micropip will keep going after the first error, and report a list
-          of errors at the end.
+        - 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.
+        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
-        `fetch() <https://developer.mozilla.org/en-US/docs/Web/API/fetch>`__ function
-        which is used to download the package.
+        `fetch() <https://developer.mozilla.org/en-US/docs/Web/API/fetch>`__
+        function which is used to download the package.
 
         When not specified, ``fetch()`` is called without ``credentials``.
 
     pre : ``bool``, default: False
 
-        If ``True``, include pre-release and development versions.
-        By default, micropip only finds stable versions.
+        If ``True``, include pre-release and development versions. By default,
+        micropip only finds stable versions.
 
     Returns
     -------
@@ -512,7 +532,7 @@ async def install(
     await gather(*wheel_promises)
 
 
-def _generate_package_hash(data: BytesIO) -> str:
+def _generate_package_hash(data: IO[bytes]) -> str:
     sha256_hash = hashlib.sha256()
     data.seek(0)
     while chunk := data.read(4096):


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
@@ -153,19 +153,19 @@ async def _fetch_bytes(self, url, kwargs):
 
         metadata_dir = f"{name}-{version}.dist-info"
 
-        with io.BytesIO() as tmp:
-            with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as archive:
+        tmp = io.BytesIO()
+        with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as archive:
 
-                def write_file(filename, contents):
-                    archive.writestr(f"{metadata_dir}/{filename}", contents)
+            def write_file(filename, contents):
+                archive.writestr(f"{metadata_dir}/{filename}", contents)
 
-                write_file("METADATA", metadata_str)
-                write_file("WHEEL", "Wheel-Version: 1.0")
-                write_file("top_level.txt", toplevel_str)
+            write_file("METADATA", metadata_str)
+            write_file("WHEEL", "Wheel-Version: 1.0")
+            write_file("top_level.txt", toplevel_str)
 
-            tmp.seek(0)
+        tmp.seek(0)
 
-            return tmp.read()
+        return tmp
 
 
 @pytest.fixture
@@ -193,6 +193,9 @@ def selenium_standalone_micropip(selenium_standalone):
     yield selenium_standalone
 
 
+SNOWBALL_WHEEL = "snowballstemmer-2.0.0-py2.py3-none-any.whl"
+
+
 def test_install_simple(selenium_standalone_micropip):
     selenium = selenium_standalone_micropip
     assert (
@@ -215,27 +218,45 @@ def test_install_simple(selenium_standalone_micropip):
     )
 
 
-def test_parse_wheel_url():
+@pytest.mark.parametrize(
+    "path",
+    [
+        SNOWBALL_WHEEL,
+        f"/{SNOWBALL_WHEEL}" f"a/{SNOWBALL_WHEEL}",
+        f"/a/{SNOWBALL_WHEEL}",
+        f"//a/{SNOWBALL_WHEEL}",
+    ],
+)
+@pytest.mark.parametrize("protocol", ["https:", "file:", "emfs:", ""])
+def test_parse_wheel_url1(protocol, path):
     pytest.importorskip("packaging")
     from micropip._micropip import WheelInfo
 
-    url = "https://a/snowballstemmer-2.0.0-py2.py3-none-any.whl"
+    url = protocol + path
     wheel = WheelInfo.from_url(url)
     assert wheel.name == "snowballstemmer"
     assert str(wheel.version) == "2.0.0"
     assert wheel.digests is None
-    assert wheel.filename == "snowballstemmer-2.0.0-py2.py3-none-any.whl"
+    assert wheel.filename == SNOWBALL_WHEEL
     assert wheel.url == url
     assert wheel.tags == frozenset(
         {Tag("py2", "none", "any"), Tag("py3", "none", "any")}
     )
 
+
+def test_parse_wheel_url2():
+    from micropip._micropip import WheelInfo
+
     msg = r"Invalid wheel filename \(wrong number of parts\)"
     with pytest.raises(ValueError, match=msg):
         url = "https://a/snowballstemmer-2.0.0-py2.whl"
-        wheel = WheelInfo.from_url(url)
+        WheelInfo.from_url(url)
+
+
+def test_parse_wheel_url3():
+    from micropip._micropip import WheelInfo
 
-    url = "http://scikit_learn-0.22.2.post1-cp35-cp35m-macosx_10_9_intel.whl"
+    url = "http://a/scikit_learn-0.22.2.post1-cp35-cp35m-macosx_10_9_intel.whl"
     wheel = WheelInfo.from_url(url)
     assert wheel.name == "scikit-learn"
     assert wheel.tags == frozenset({Tag("cp35", "cp35m", "macosx_10_9_intel")})
@@ -248,7 +269,7 @@ def test_install_custom_url(selenium_standalone_micropip, base_url):
     with spawn_web_server(Path(__file__).parent / "test") as server:
         server_hostname, server_port, _ = server
         base_url = f"http://{server_hostname}:{server_port}/"
-        url = base_url + "snowballstemmer-2.0.0-py2.py3-none-any.whl"
+        url = base_url + SNOWBALL_WHEEL
 
         selenium.run_js(
             f"""
@@ -271,7 +292,7 @@ def test_install_file_protocol_node(selenium_standalone_micropip):
         f"""
         await pyodide.runPythonAsync(`
             import micropip
-            await micropip.install('file://{pyparsing_wheel_name}')
+            await micropip.install('file:{pyparsing_wheel_name}')
             import pyparsing
         `);
         """
@@ -301,7 +322,7 @@ async def test_add_requirement():
     with spawn_web_server(Path(__file__).parent / "test") as server:
         server_hostname, server_port, _ = server
         base_url = f"http://{server_hostname}:{server_port}/"
-        url = base_url + "snowballstemmer-2.0.0-py2.py3-none-any.whl"
+        url = base_url + SNOWBALL_WHEEL
 
         transaction = create_transaction(Transaction)
         await transaction.add_requirement(url)
@@ -309,7 +330,7 @@ async def test_add_requirement():
     wheel = transaction.wheels[0]
     assert wheel.name == "snowballstemmer"
     assert str(wheel.version) == "2.0.0"
-    assert wheel.filename == "snowballstemmer-2.0.0-py2.py3-none-any.whl"
+    assert wheel.filename == SNOWBALL_WHEEL
     assert wheel.url == url
     assert wheel.tags == frozenset(
         {Tag("py2", "none", "any"), Tag("py3", "none", "any")}
@@ -431,7 +452,7 @@ async def test_install_non_pure_python_wheel():
 
     msg = "not a pure Python 3 wheel"
     with pytest.raises(ValueError, match=msg):
-        url = "http://scikit_learn-0.22.2.post1-cp35-cp35m-macosx_10_9_intel.whl"
+        url = "http://a/scikit_learn-0.22.2.post1-cp35-cp35m-macosx_10_9_intel.whl"
         transaction = create_transaction(Transaction)
         await transaction.add_requirement(url)
 
@@ -635,7 +656,7 @@ def test_list_load_package_from_url(selenium_standalone_micropip):
     with spawn_web_server(Path(__file__).parent / "test") as server:
         server_hostname, server_port, _ = server
         base_url = f"http://{server_hostname}:{server_port}/"
-        url = base_url + "snowballstemmer-2.0.0-py2.py3-none-any.whl"
+        url = base_url + SNOWBALL_WHEEL
 
         selenium = selenium_standalone_micropip
         selenium.run_js(
@@ -763,3 +784,29 @@ async def test_freeze(mock_fetch: mock_fetch_cls) -> None:
     assert pkg_metadata["imports"] == toplevel[0]
     assert dep1_metadata["imports"] == toplevel[1]
     assert dep2_metadata["imports"] == toplevel[2]
+
+
+def test_emfs(selenium_standalone_micropip):
+    with spawn_web_server(Path(__file__).parent / "test") as server:
+        server_hostname, server_port, _ = server
+        url = f"http://{server_hostname}:{server_port}/"
+
+        @run_in_pyodide(packages=["micropip"])
+        async def run_test(selenium, url, wheel_name):
+            import micropip
+            from pyodide.http import pyfetch
+
+            resp = await pyfetch(url + wheel_name)
+            await resp._into_file(open(wheel_name, "wb"))
+            await micropip.install("emfs:" + wheel_name)
+            import snowballstemmer
+
+            stemmer = snowballstemmer.stemmer("english")
+            assert stemmer.stemWords("go going goes gone".split()) == [
+                "go",
+                "go",
+                "goe",
+                "gone",
+            ]
+
+        run_test(selenium_standalone_micropip, url, SNOWBALL_WHEEL)
diff --git a/pyodide-test-runner/pyodide_test_runner/decorator.py b/pyodide-test-runner/pyodide_test_runner/decorator.py
--- a/pyodide-test-runner/pyodide_test_runner/decorator.py
+++ b/pyodide-test-runner/pyodide_test_runner/decorator.py
@@ -74,11 +74,14 @@ def <func_name>(<selenium_arg_name>, arg1, arg2, arg3):
         name=node.name, args=node_args, body=[], lineno=1, decorator_list=[]
     )
 
+    run_test_id = "run-test-not-valid-identifier"
+
     # Make onwards call with two args:
     # 1. <selenium_arg_name>
     # 2. all other arguments in a tuple
     func_body = ast.parse("return run_test(selenium_arg_name, (arg1, arg2, ...))").body
     onwards_call = func_body[0].value
+    onwards_call.func = ast.Name(id=run_test_id, ctx=ast.Load())
     onwards_call.args[0].id = selenium_arg_name  # Set variable name
     onwards_call.args[1].elts = [  # Set tuple elements
         ast.Name(id=arg.arg, ctx=ast.Load()) for arg in node_args.args[1:]
@@ -105,7 +108,7 @@ def fake_body_for_traceback(arg1, arg2, selenium_arg_name):
 
     # Need to give our code access to the actual "run_test" object which it
     # invokes.
-    globs = {"run_test": run_test}
+    globs = {run_test_id: run_test}
     exec(co, globs)
 
     return globs[node.name]
@@ -251,12 +254,17 @@ def _generate_pyodide_ast(
             ):
                 nodes.append(node)
 
+            if (
+                node.end_lineno
+                and node.end_lineno > func_line_no
+                and node.lineno < func_line_no
+            ):
+                it = iter(node.body)
+                continue
+
             # We also want the function definition for the current test
             if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                 continue
-            if node.end_lineno > func_line_no and node.lineno < func_line_no:
-                it = iter(node.body)
-                continue
 
             if node.lineno < func_line_no:
                 continue