Repository: pyodide/pyodide-lock (language: python)

Issue:
Move parse_top_level_import_name from Pyodide build
When creating the `pyodide-lock.json` we would need to be able to identify import names for a given pure Python wheel. 
So it would be useful to move the [`parse_top_level_import_name`](https://github.com/pyodide/pyodide/blob/50b5e1ccb6863507c46f832adb743be0917b4dc1/pyodide-build/pyodide_build/common.py#L56C5-L56C32) function + the associated tests from Pyodide build here.

Then at a later stage use it in `pyodide-build` imported from this package.


Discussion / hints:
Sounds good to me.
@juntyr FYI a small good first issue.

Fix patch:
diff --git a/.gitignore b/.gitignore
index dafe602..d53b827 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
 __pycache__
+.coverage
 .hypothesis
 dist/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b539c77..eb4b2da 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## Unreleased
+
+### Added
+
+- Adds `parse_top_level_import_name` for finding importable names in `.whl` files
+  [#17](https://github.com/pyodide/pyodide-lock/pull/17)
+
 ## [0.1.0a3] - 2023-09-15
 
 ### Fixed
diff --git a/pyodide_lock/__init__.py b/pyodide_lock/__init__.py
index 2bc2c6d..febce82 100644
--- a/pyodide_lock/__init__.py
+++ b/pyodide_lock/__init__.py
@@ -1,3 +1,7 @@
 from .spec import PyodideLockSpec
+from .utils import parse_top_level_import_name
 
-__all__ = ["PyodideLockSpec"]
+__all__ = [
+    "PyodideLockSpec",
+    "parse_top_level_import_name",
+]
diff --git a/pyodide_lock/utils.py b/pyodide_lock/utils.py
index e2b90c1..03a24e6 100644
--- a/pyodide_lock/utils.py
+++ b/pyodide_lock/utils.py
@@ -1,6 +1,62 @@
 import hashlib
+import logging
+import zipfile
+from collections import deque
 from pathlib import Path
 
+logger = logging.getLogger(__name__)
+
+
+def parse_top_level_import_name(whlfile: Path) -> list[str] | None:
+    """
+    Parse the top-level import names from a wheel file.
+
+    While this behavior matches the way ``setuptools`` creates ``top_level.txt``,
+    some cases, such as PEP 420 namespace packages, may not be handled correctly.
+    """
+
+    if not whlfile.name.endswith(".whl"):
+        raise RuntimeError(f"{whlfile} is not a wheel file.")
+
+    whlzip = zipfile.Path(whlfile)
+
+    # If there is no top_level.txt file, we will find top level imports by
+    # 1) a python file on a top-level directory
+    # 2) a sub directory with __init__.py
+    # following: https://github.com/pypa/setuptools/blob/d680efc8b4cd9aa388d07d3e298b870d26e9e04b/setuptools/discovery.py#L122
+    top_level_imports = []
+    for subdir in whlzip.iterdir():
+        if subdir.is_file() and subdir.name.endswith(".py"):
+            top_level_imports.append(subdir.name[:-3])
+        elif subdir.is_dir() and _valid_package_name(subdir.name):
+            if _has_python_file(subdir):
+                top_level_imports.append(subdir.name)
+
+    if not top_level_imports:
+        logger.warning(
+            f"WARNING: failed to parse top level import name from {whlfile}."
+        )
+        return None
+
+    return top_level_imports
+
+
+def _valid_package_name(dirname: str) -> bool:
+    return all([invalid_chr not in dirname for invalid_chr in ".- "])
+
+
+def _has_python_file(subdir: zipfile.Path) -> bool:
+    queue = deque([subdir])
+    while queue:
+        nested_subdir = queue.pop()
+        for subfile in nested_subdir.iterdir():
+            if subfile.is_file() and subfile.name.endswith(".py"):
+                return True
+            elif subfile.is_dir() and _valid_package_name(subfile.name):
+                queue.append(subfile)
+
+    return False
+
 
 def _generate_package_hash(full_path: Path) -> str:
     """Generate a sha256 hash for a package


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 0000000..c762f5e
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,57 @@
+import zipfile
+
+import pytest
+
+from pyodide_lock import parse_top_level_import_name
+
+
+@pytest.mark.parametrize(
+    "pkg",
+    [
+        {
+            "name": "pkg_singlefile-1.0.0-py3-none-any.whl",
+            "file": "singlefile.py",
+            "content": "pass\n",
+            "top_level": ["singlefile"],
+        },
+        {
+            "name": "pkg_flit-1.0.0-py3-none-any.whl",
+            "file": "pkg_flit/__init__.py",
+            "content": "pass\n",
+            "top_level": ["pkg_flit"],
+        },
+        {
+            "name": "pkg_ruamel_yaml_dingdong-1.0.0-py3-none-any.whl",
+            "file": "pkg_ruamel/yaml/ding/dong/__init__.py",
+            "content": "pass\n",
+            "top_level": ["pkg_ruamel"],
+        },
+        {
+            "name": "bad_no_python-1.0.0-py3-none-any.whl",
+            "file": "no/python/README.md",
+            "content": "pass\n",
+            "top_level": None,
+        },
+        {
+            "name": "bad_spaces-1.0.0-py3-none-any.whl",
+            "file": "space in path/README.md",
+            "content": "pass\n",
+            "top_level": None,
+        },
+    ],
+)
+def test_top_level_imports(pkg, tmp_path):
+    with zipfile.ZipFile(tmp_path / pkg["name"], "w") as whlzip:
+        whlzip.writestr(pkg["file"], data=pkg["content"])
+
+    top_level = parse_top_level_import_name(tmp_path / pkg["name"])
+    assert top_level == pkg["top_level"]
+
+
+def test_not_wheel(tmp_path):
+    path = tmp_path / "pkg.zip"
+    with zipfile.ZipFile(path, "w") as whlzip:
+        whlzip.writestr("README.md", data="#")
+
+    with pytest.raises(RuntimeError, match="not a wheel"):
+        parse_top_level_import_name(path)