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

import json
from io import StringIO
from typing import Any, BinaryIO, TextIO

from ._core import JsProxy, to_js

try:
    from js import XMLHttpRequest
except ImportError:
    pass

from ._core import IN_BROWSER
from ._package_loader import unpack_buffer

__all__ = [
    "open_url",
    "pyfetch",
    "FetchResponse",
]


def open_url(url: str) -> StringIO:
    """Fetches a given URL synchronously.

    The download of binary files is not supported. To download binary
    files use :func:`pyodide.http.pyfetch` which is asynchronous.

    Parameters
    ----------
    url : str
       URL to fetch

    Returns
    -------
    io.StringIO
        the contents of the URL.
    """

    req = XMLHttpRequest.new()
    req.open("GET", url, False)
    req.send(None)
    return StringIO(req.response)


class FetchResponse:
    """A wrapper for a Javascript fetch response.

    See also the Javascript fetch
    `Response <https://developer.mozilla.org/en-US/docs/Web/API/Response>`_ api
    docs.

    Parameters
    ----------
    url
        URL to fetch
    js_response
        A JsProxy of the fetch response
    """

    def __init__(self, url: str, js_response: JsProxy):
        self._url = url
        self.js_response = js_response

    @property
    def body_used(self) -> bool:
        """Has the response been used yet?

        (If so, attempting to retrieve the body again will raise an OSError.)
        """
        return self.js_response.bodyUsed

    @property
    def ok(self) -> bool:
        """Was the request successful?"""
        return self.js_response.ok

    @property
    def redirected(self) -> bool:
        """Was the request redirected?"""
        return self.js_response.redirected

    @property
    def status(self) -> str:
        """Response status code"""
        return self.js_response.status

    @property
    def status_text(self) -> str:
        """Response status text"""
        return self.js_response.statusText

    @property
    def type(self) -> str:
        """The `type <https://developer.mozilla.org/en-US/docs/Web/API/Response/type>`_ of the response."""
        return self.js_response.type

    @property
    def url(self) -> str:
        """The `url <https://developer.mozilla.org/en-US/docs/Web/API/Response/url>`_ of the response.

        It may be different than the url passed to fetch.
        """
        return self.js_response.url

    def _raise_if_failed(self):
        if self.js_response.status >= 400:
            raise OSError(
                f"Request for {self._url} failed with status {self.status}: {self.status_text}"
            )
        if self.js_response.bodyUsed:
            raise OSError("Response body is already used")

    def clone(self) -> "FetchResponse":
        """Return an identical copy of the FetchResponse.

        This method exists to allow multiple uses of response objects. See
        `Response.clone <https://developer.mozilla.org/en-US/docs/Web/API/Response/clone>`_
        """
        if self.js_response.bodyUsed:
            raise OSError("Response body is already used")
        return FetchResponse(self._url, self.js_response.clone())

    async def buffer(self) -> JsProxy:
        """Return the response body as a Javascript ArrayBuffer"""
        self._raise_if_failed()
        return await self.js_response.arrayBuffer()

    async def string(self) -> str:
        """Return the response body as a string"""
        self._raise_if_failed()
        return await self.js_response.text()

    async def json(self, **kwargs: Any) -> Any:
        """Return the response body as a Javascript JSON object.

        Any keyword arguments are passed to `json.loads
        <https://docs.python.org/3.8/library/json.html#json.loads>`_.
        """
        self._raise_if_failed()
        return json.loads(await self.string(), **kwargs)

    async def memoryview(self) -> memoryview:
        """Return the response body as a memoryview object"""
        self._raise_if_failed()
        return (await self.buffer()).to_memoryview()

    async def bytes(self) -> bytes:
        """Return the response body as a bytes object"""
        self._raise_if_failed()
        return (await self.buffer()).to_bytes()

    async def _into_file(self, f: TextIO | BinaryIO) -> None:
        """Write the data into an empty file with no copy.

        Warning: should only be used when f is an empty file, otherwise it may
        overwrite the data of f.
        """
        buf = await self.buffer()
        buf._into_file(f)

    async def _create_file(self, path: str) -> None:
        """Uses the data to back a new file without copying it.

        This method avoids copying the data when creating a new file. If you
        want to write the data into an existing file, use

        .. code-block:: python

            buf = await resp.buffer()
            buf.to_file(file)

        Parameters
        ----------
        path : str

            The path to the file to create. The file should not exist but
            it should be in a directory that exists. Otherwise, will raise
            an ``OSError``
        """
        with open(path, "x") as f:
            await self._into_file(f)

    async def unpack_archive(self, *, extract_dir=None, format=None):
        """Treat the data as an archive and unpack it into target directory.

        Assumes that the file is an archive in a format that shutil has an
        unpacker for. The arguments extract_dir and format are passed directly
        on to ``shutil.unpack_archive``.

        Parameters
        ----------
        extract_dir : str
            Directory to extract the archive into. If not
            provided, the current working directory is used.

        format : str
            The archive format: one of “zip”, “tar”, “gztar”, “bztar”.
            Or any other format registered with ``shutil.register_unpack_format()``. If not
            provided, ``unpack_archive()`` will use the archive file name extension
            and see if an unpacker was registered for that extension. In case
            none is found, a ``ValueError`` is raised.
        """
        buf = await self.buffer()
        filename = self._url.rsplit("/", -1)[-1]
        unpack_buffer(buf, filename=filename, format=format, extract_dir=extract_dir)


async def pyfetch(url: str, **kwargs: Any) -> FetchResponse:
    r"""Fetch the url and return the response.

    This functions provides a similar API to the JavaScript `fetch function
    <https://developer.mozilla.org/en-US/docs/Web/API/fetch>`_ however it is
    designed to be convenient to use from Python. The
    :class:`pyodide.http.FetchResponse` has methods with the output types
    already converted to Python objects.

    Parameters
    ----------
    url : str
        URL to fetch.

    \*\*kwargs : Any
        keyword arguments are passed along as `optional parameters to the fetch API
        <https://developer.mozilla.org/en-US/docs/Web/API/fetch#parameters>`_.
    """
    if IN_BROWSER:
        from js import Object
        from js import fetch as _jsfetch

    return FetchResponse(
        url, await _jsfetch(url, to_js(kwargs, dict_converter=Object.fromEntries))
    )

import json
from io import StringIO
from typing import Any, BinaryIO, TextIO

from ._core import JsProxy, to_js

try:
    from js import XMLHttpRequest
except ImportError:
    pass

from ._core import IN_BROWSER
from ._package_loader import unpack_buffer

__all__ = [
    "open_url",
    "pyfetch",
    "FetchResponse",
]


def open_url(url: str) -> StringIO:
    """Fetches a given URL synchronously.

    The download of binary files is not supported. To download binary
    files use :func:`pyodide.http.pyfetch` which is asynchronous.

    Parameters
    ----------
    url : str
       URL to fetch

    Returns
    -------
    io.StringIO
        the contents of the URL.
    """

    req = XMLHttpRequest.new()
    req.open("GET", url, False)
    req.send(None)
    return StringIO(req.response)


class FetchResponse:
    """A wrapper for a Javascript fetch response.

    See also the Javascript fetch
    `Response <https://developer.mozilla.org/en-US/docs/Web/API/Response>`_ api
    docs.

    Parameters
    ----------
    url
        URL to fetch
    js_response
        A JsProxy of the fetch response
    """

    def __init__(self, url: str, js_response: JsProxy):
        self._url = url
        self.js_response = js_response

    @property
    def body_used(self) -> bool:
        """Has the response been used yet?

        (If so, attempting to retrieve the body again will raise an OSError.)
        """
        return self.js_response.bodyUsed

    @property
    def ok(self) -> bool:
        """Was the request successful?"""
        return self.js_response.ok

    @property
    def redirected(self) -> bool:
        """Was the request redirected?"""
        return self.js_response.redirected

    @property
    def status(self) -> str:
        """Response status code"""
        return self.js_response.status

    @property
    def status_text(self) -> str:
        """Response status text"""
        return self.js_response.statusText

    @property
    def type(self) -> str:
        """The `type <https://developer.mozilla.org/en-US/docs/Web/API/Response/type>`_ of the response."""
        return self.js_response.type

    @property
    def url(self) -> str:
        """The `url <https://developer.mozilla.org/en-US/docs/Web/API/Response/url>`_ of the response.

        It may be different than the url passed to fetch.
        """
        return self.js_response.url

    def _raise_if_failed(self):
        if self.js_response.status >= 400:
            raise OSError(
                f"Request for {self._url} failed with status {self.status}: {self.status_text}"
            )
        if self.js_response.bodyUsed:
            raise OSError("Response body is already used")

    def clone(self) -> "FetchResponse":
        """Return an identical copy of the FetchResponse.

        This method exists to allow multiple uses of response objects. See
        `Response.clone <https://developer.mozilla.org/en-US/docs/Web/API/Response/clone>`_
        """
        if self.js_response.bodyUsed:
            raise OSError("Response body is already used")
        return FetchResponse(self._url, self.js_response.clone())

    async def buffer(self) -> JsProxy:
        """Return the response body as a Javascript ArrayBuffer"""
        self._raise_if_failed()
        return await self.js_response.arrayBuffer()

    async def string(self) -> str:
        """Return the response body as a string"""
        self._raise_if_failed()
        return await self.js_response.text()

    async def json(self, **kwargs: Any) -> Any:
        """Return the response body as a Javascript JSON object.

        Any keyword arguments are passed to `json.loads
        <https://docs.python.org/3.8/library/json.html#json.loads>`_.
        """
        self._raise_if_failed()
        return json.loads(await self.string(), **kwargs)

    async def memoryview(self) -> memoryview:
        """Return the response body as a memoryview object"""
        self._raise_if_failed()
        return (await self.buffer()).to_memoryview()

    async def bytes(self) -> bytes:
        """Return the response body as a bytes object"""
        self._raise_if_failed()
        return (await self.buffer()).to_bytes()

    async def _into_file(self, f: TextIO | BinaryIO) -> None:
        """Write the data into an empty file with no copy.

        Warning: should only be used when f is an empty file, otherwise it may
        overwrite the data of f.
        """
        buf = await self.buffer()
        buf._into_file(f)

    async def _create_file(self, path: str) -> None:
        """Uses the data to back a new file without copying it.

        This method avoids copying the data when creating a new file. If you
        want to write the data into an existing file, use

        .. code-block:: python

            buf = await resp.buffer()
            buf.to_file(file)

        Parameters
        ----------
        path : str

            The path to the file to create. The file should not exist but
            it should be in a directory that exists. Otherwise, will raise
            an ``OSError``
        """
        with open(path, "x") as f:
            await self._into_file(f)

    async def unpack_archive(self, *, extract_dir=None, format=None):
        """Treat the data as an archive and unpack it into target directory.

        Assumes that the file is an archive in a format that shutil has an
        unpacker for. The arguments extract_dir and format are passed directly
        on to ``shutil.unpack_archive``.

        Parameters
        ----------
        extract_dir : str
            Directory to extract the archive into. If not
            provided, the current working directory is used.

        format : str
            The archive format: one of “zip”, “tar”, “gztar”, “bztar”.
            Or any other format registered with ``shutil.register_unpack_format()``. If not
            provided, ``unpack_archive()`` will use the archive file name extension
            and see if an unpacker was registered for that extension. In case
            none is found, a ``ValueError`` is raised.
        """
        buf = await self.buffer()
        filename = self._url.rsplit("/", -1)[-1]
        unpack_buffer(buf, filename=filename, format=format, extract_dir=extract_dir)


async def pyfetch(url: str, **kwargs: Any) -> FetchResponse:
    r"""Fetch the url and return the response.

    This functions provides a similar API to the JavaScript `fetch function
    <https://developer.mozilla.org/en-US/docs/Web/API/fetch>`_ however it is
    designed to be convenient to use from Python. The
    :class:`pyodide.http.FetchResponse` has methods with the output types
    already converted to Python objects.

    Parameters
    ----------
    url : str
        URL to fetch.

    \*\*kwargs : Any
        keyword arguments are passed along as `optional parameters to the fetch API
        <https://developer.mozilla.org/en-US/docs/Web/API/fetch#parameters>`_.
    """
    if IN_BROWSER:
        from js import Object
        from js import fetch as _jsfetch

    return FetchResponse(
        url, await _jsfetch(url, to_js(kwargs, dict_converter=Object.fromEntries))
    )
