ENH Add `micropip.freeze`. This adds a function `micropip.freeze()` which creates a `packages.json`
lock file which includes all packages that were loaded by micropip in the
current Pyodide session. This can be used in subsequent sessions to load
the same set of packages with `pyodide.loadPackage`.

For example in our repl:

```py
from js.console import log
import micropip
await micropip.install("sphinx-version-warning") # Installs 19 wheels from pypi
log(micropip.freeze())
```
Then opening the browser console, we can copy the JSON and make a new `packages.json`
file out of it. (Our repl will just say "<Long output truncated>" but the browser
console truncates it and provides tools to make it easy to copy.) Reloading the page
with the new `packages.json`, `versionwarning` will autoload:
```py
import versionwarning # Automatically loads 19 wheels from PyPI
```
We detected that `versionwarning` is an export provided by the `sphinx-version-warning`
package via the `top_level.txt` file. Distressingly this file is part of the `egg-info`
spec but isn't mentioned anywhere in the `dist-info` spec. But wheels seem to include
it nonetheless.
### Description

### Checklists

- [x] Add a [CHANGELOG](https://github.com/pyodide/pyodide/blob/main/docs/project/changelog.md) entry
- [x] Add / update tests
- [x] Add new / update outdated documentation

from collections import namedtuple
from pathlib import Path
from time import sleep
from typing import Any

import pytest

from pyodide_build import buildall

PACKAGES_DIR = Path(__file__).parent / "_test_packages"


def test_generate_dependency_graph():
    pkg_map = buildall.generate_dependency_graph(PACKAGES_DIR, {"beautifulsoup4"})

    assert set(pkg_map.keys()) == {
        "soupsieve",
        "beautifulsoup4",
    }
    assert pkg_map["soupsieve"].dependencies == []
    assert pkg_map["soupsieve"].dependents == {"beautifulsoup4"}
    assert pkg_map["beautifulsoup4"].dependencies == ["soupsieve"]
    assert pkg_map["beautifulsoup4"].dependents == set()


def test_generate_packages_json(tmp_path):
    pkg_map = buildall.generate_dependency_graph(PACKAGES_DIR, {"pkg_1", "pkg_2"})
    for pkg in pkg_map.values():
        pkg.file_name = pkg.file_name or pkg.name + ".file"
        # Write dummy package file for SHA-256 hash verification
        with open(tmp_path / pkg.file_name, "w") as f:
            f.write(pkg.name)

    package_data = buildall.generate_packages_json(tmp_path, pkg_map)
    assert set(package_data.keys()) == {"info", "packages"}
    assert package_data["info"] == {"arch": "wasm32", "platform": "Emscripten-1.0"}
    assert set(package_data["packages"]) == {
        "pkg_1",
        "pkg_1_1",
        "pkg_2",
        "pkg_3",
        "pkg_3_1",
    }
    assert package_data["packages"]["pkg_1"] == {
        "name": "pkg_1",
        "version": "1.0.0",
        "file_name": "pkg_1.file",
        "depends": ["pkg_1_1", "pkg_3"],
        "imports": ["pkg_1"],
        "install_dir": "site",
        "sha256": "c1e38241013b5663e902fff97eb8585e98e6df446585da1dcf2ad121b52c2143",
    }


@pytest.mark.parametrize("n_jobs", [1, 4])
def test_build_dependencies(n_jobs, monkeypatch):
    build_list = []

    class MockPackage(buildall.Package):
        def build(self, outputdir: Path, args: Any) -> None:
            build_list.append(self.name)

    monkeypatch.setattr(buildall, "Package", MockPackage)

    pkg_map = buildall.generate_dependency_graph(PACKAGES_DIR, {"pkg_1", "pkg_2"})

    Args = namedtuple("Args", ["n_jobs", "force_rebuild"])
    buildall.build_from_graph(
        pkg_map, Path("."), Args(n_jobs=n_jobs, force_rebuild=True)
    )

    assert set(build_list) == {
        "pkg_1",
        "pkg_1_1",
        "pkg_2",
        "pkg_3",
        "pkg_3_1",
    }
    assert build_list.index("pkg_1_1") < build_list.index("pkg_1")
    assert build_list.index("pkg_3") < build_list.index("pkg_1")
    assert build_list.index("pkg_3_1") < build_list.index("pkg_3")


@pytest.mark.parametrize("n_jobs", [1, 4])
def test_build_all_dependencies(n_jobs, monkeypatch):
    """Try building all the dependency graph, without the actual build operations"""

    class MockPackage(buildall.Package):
        n_builds = 0

        def build(self, outputdir: Path, args: Any) -> None:
            sleep(0.005)
            self.n_builds += 1
            # check that each build is only run once
            assert self.n_builds == 1

    monkeypatch.setattr(buildall, "Package", MockPackage)

    pkg_map = buildall.generate_dependency_graph(PACKAGES_DIR, packages={"*"})

    Args = namedtuple("Args", ["n_jobs", "force_rebuild"])
    buildall.build_from_graph(
        pkg_map, Path("."), Args(n_jobs=n_jobs, force_rebuild=False)
    )


@pytest.mark.parametrize("n_jobs", [1, 4])
def test_build_error(n_jobs, monkeypatch):
    """Try building all the dependency graph, without the actual build operations"""

    class MockPackage(buildall.Package):
        def build(self, outputdir: Path, args: Any) -> None:
            raise ValueError("Failed build")

    monkeypatch.setattr(buildall, "Package", MockPackage)

    pkg_map = buildall.generate_dependency_graph(PACKAGES_DIR, {"pkg_1"})

    with pytest.raises(ValueError, match="Failed build"):
        Args = namedtuple("Args", ["n_jobs", "force_rebuild"])
        buildall.build_from_graph(
            pkg_map, Path("."), Args(n_jobs=n_jobs, force_rebuild=True)
        )

from collections import namedtuple
from pathlib import Path
from time import sleep
from typing import Any

import pytest

from pyodide_build import buildall

PACKAGES_DIR = Path(__file__).parent / "_test_packages"


def test_generate_dependency_graph():
    pkg_map = buildall.generate_dependency_graph(PACKAGES_DIR, {"beautifulsoup4"})

    assert set(pkg_map.keys()) == {
        "soupsieve",
        "beautifulsoup4",
    }
    assert pkg_map["soupsieve"].dependencies == []
    assert pkg_map["soupsieve"].dependents == {"beautifulsoup4"}
    assert pkg_map["beautifulsoup4"].dependencies == ["soupsieve"]
    assert pkg_map["beautifulsoup4"].dependents == set()


def test_generate_packages_json(tmp_path):
    pkg_map = buildall.generate_dependency_graph(PACKAGES_DIR, {"pkg_1", "pkg_2"})
    for pkg in pkg_map.values():
        pkg.file_name = pkg.file_name or pkg.name + ".file"
        # Write dummy package file for SHA-256 hash verification
        with open(tmp_path / pkg.file_name, "w") as f:
            f.write(pkg.name)

    package_data = buildall.generate_packages_json(tmp_path, pkg_map)
    assert set(package_data.keys()) == {"info", "packages"}
    assert package_data["info"] == {"arch": "wasm32", "platform": "Emscripten-1.0"}
    assert set(package_data["packages"]) == {
        "pkg_1",
        "pkg_1_1",
        "pkg_2",
        "pkg_3",
        "pkg_3_1",
    }
    assert package_data["packages"]["pkg_1"] == {
        "name": "pkg_1",
        "version": "1.0.0",
        "file_name": "pkg_1.file",
        "depends": ["pkg_1_1", "pkg_3"],
        "imports": ["pkg_1"],
        "install_dir": "site",
        "sha256": "c1e38241013b5663e902fff97eb8585e98e6df446585da1dcf2ad121b52c2143",
    }


@pytest.mark.parametrize("n_jobs", [1, 4])
def test_build_dependencies(n_jobs, monkeypatch):
    build_list = []

    class MockPackage(buildall.Package):
        def build(self, outputdir: Path, args: Any) -> None:
            build_list.append(self.name)

    monkeypatch.setattr(buildall, "Package", MockPackage)

    pkg_map = buildall.generate_dependency_graph(PACKAGES_DIR, {"pkg_1", "pkg_2"})

    Args = namedtuple("Args", ["n_jobs", "force_rebuild"])
    buildall.build_from_graph(
        pkg_map, Path("."), Args(n_jobs=n_jobs, force_rebuild=True)
    )

    assert set(build_list) == {
        "pkg_1",
        "pkg_1_1",
        "pkg_2",
        "pkg_3",
        "pkg_3_1",
    }
    assert build_list.index("pkg_1_1") < build_list.index("pkg_1")
    assert build_list.index("pkg_3") < build_list.index("pkg_1")
    assert build_list.index("pkg_3_1") < build_list.index("pkg_3")


@pytest.mark.parametrize("n_jobs", [1, 4])
def test_build_all_dependencies(n_jobs, monkeypatch):
    """Try building all the dependency graph, without the actual build operations"""

    class MockPackage(buildall.Package):
        n_builds = 0

        def build(self, outputdir: Path, args: Any) -> None:
            sleep(0.005)
            self.n_builds += 1
            # check that each build is only run once
            assert self.n_builds == 1

    monkeypatch.setattr(buildall, "Package", MockPackage)

    pkg_map = buildall.generate_dependency_graph(PACKAGES_DIR, packages={"*"})

    Args = namedtuple("Args", ["n_jobs", "force_rebuild"])
    buildall.build_from_graph(
        pkg_map, Path("."), Args(n_jobs=n_jobs, force_rebuild=False)
    )


@pytest.mark.parametrize("n_jobs", [1, 4])
def test_build_error(n_jobs, monkeypatch):
    """Try building all the dependency graph, without the actual build operations"""

    class MockPackage(buildall.Package):
        def build(self, outputdir: Path, args: Any) -> None:
            raise ValueError("Failed build")

    monkeypatch.setattr(buildall, "Package", MockPackage)

    pkg_map = buildall.generate_dependency_graph(PACKAGES_DIR, {"pkg_1"})

    with pytest.raises(ValueError, match="Failed build"):
        Args = namedtuple("Args", ["n_jobs", "force_rebuild"])
        buildall.build_from_graph(
            pkg_map, Path("."), Args(n_jobs=n_jobs, force_rebuild=True)
        )
