Implement PEP 658: .whl.metadata files (#3981)

This commit is contained in:
Juniper Tyree 2023-07-17 15:17:24 +03:00 committed by GitHub
parent 68f1fdbf04
commit 0980b51677
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 133 additions and 6 deletions

View File

@ -68,6 +68,13 @@ myst:
- Upgraded scikit-learn to 1.3.0 {pr}`3976` - Upgraded scikit-learn to 1.3.0 {pr}`3976`
- Upgraded pyodide-http to 0.2.1 - Upgraded pyodide-http to 0.2.1
### CLI
- {{ Enhancement }} `pyodide build-recipes` now accepts a `--metadata-files`
option to install `*.whl.metadata` files as specified in
[PEP 658](https://peps.python.org/pep-0658/).
{pr}`3981`
## Version 0.23.4 ## Version 0.23.4
_July 6, 2023_ _July 6, 2023_

View File

@ -10,6 +10,7 @@ all:
--recipe-dir=./ \ --recipe-dir=./ \
--install \ --install \
--install-dir=../dist \ --install-dir=../dist \
--metadata-files \
--n-jobs $${PYODIDE_JOBS:-4} \ --n-jobs $${PYODIDE_JOBS:-4} \
--log-dir=./build-logs \ --log-dir=./build-logs \
--compression-level "$(PYODIDE_ZIP_COMPRESSION_LEVEL)" --compression-level "$(PYODIDE_ZIP_COMPRESSION_LEVEL)"

View File

@ -29,6 +29,7 @@ from rich.table import Table
from . import build_env, recipe from . import build_env, recipe
from .buildpkg import needs_rebuild from .buildpkg import needs_rebuild
from .common import ( from .common import (
extract_wheel_metadata_file,
find_matching_wheels, find_matching_wheels,
find_missing_executables, find_missing_executables,
repack_zip_archive, repack_zip_archive,
@ -706,7 +707,10 @@ def generate_lockfile(
def copy_packages_to_dist_dir( def copy_packages_to_dist_dir(
packages: Iterable[BasePackage], output_dir: Path, compression_level: int = 6 packages: Iterable[BasePackage],
output_dir: Path,
compression_level: int = 6,
metadata_files: bool = False,
) -> None: ) -> None:
for pkg in packages: for pkg in packages:
if pkg.package_type == "static_library": if pkg.package_type == "static_library":
@ -719,6 +723,12 @@ def copy_packages_to_dist_dir(
output_dir / dist_artifact_path.name, compression_level=compression_level output_dir / dist_artifact_path.name, compression_level=compression_level
) )
if metadata_files and dist_artifact_path.suffix == ".whl":
extract_wheel_metadata_file(
dist_artifact_path,
output_dir / f"{dist_artifact_path.name}.metadata",
)
test_path = pkg.tests_path() test_path = pkg.tests_path()
if test_path: if test_path:
shutil.copy(test_path, output_dir) shutil.copy(test_path, output_dir)
@ -773,7 +783,10 @@ def copy_logs(pkg_map: dict[str, BasePackage], log_dir: Path) -> None:
def install_packages( def install_packages(
pkg_map: dict[str, BasePackage], output_dir: Path, compression_level: int = 6 pkg_map: dict[str, BasePackage],
output_dir: Path,
compression_level: int = 6,
metadata_files: bool = False,
) -> None: ) -> None:
""" """
Install packages into the output directory. Install packages into the output directory.
@ -792,7 +805,10 @@ def install_packages(
logger.info(f"Copying built packages to {output_dir}") logger.info(f"Copying built packages to {output_dir}")
copy_packages_to_dist_dir( copy_packages_to_dist_dir(
pkg_map.values(), output_dir, compression_level=compression_level pkg_map.values(),
output_dir,
compression_level=compression_level,
metadata_files=metadata_files,
) )
lockfile_path = output_dir / "pyodide-lock.json" lockfile_path = output_dir / "pyodide-lock.json"

View File

@ -30,6 +30,12 @@ def recipe(
help="Path to install built packages and pyodide-lock.json. " help="Path to install built packages and pyodide-lock.json. "
"If not specified, the default is `./dist`.", "If not specified, the default is `./dist`.",
), ),
metadata_files: bool = typer.Option(
False,
help="If true, extract the METADATA file from the built wheels "
"to a matching *.whl.metadata file. "
"If false, no *.whl.metadata file is produced.",
),
cflags: str = typer.Option( cflags: str = typer.Option(
None, help="Extra compiling flags. Default: SIDE_MODULE_CFLAGS" None, help="Extra compiling flags. Default: SIDE_MODULE_CFLAGS"
), ),
@ -91,9 +97,9 @@ def recipe(
build_args = buildall.set_default_build_args(build_args) build_args = buildall.set_default_build_args(build_args)
if no_deps: if no_deps:
if install or log_dir_: if install or log_dir_ or metadata_files:
logger.warning( logger.warning(
"WARNING: when --no-deps is set, --install and --log-dir parameters are ignored", "WARNING: when --no-deps is set, the --install, --log-dir, and --metadata-files parameters are ignored",
) )
# TODO: use multiprocessing? # TODO: use multiprocessing?
@ -118,5 +124,12 @@ def recipe(
if install: if install:
buildall.install_packages( buildall.install_packages(
pkg_map, install_dir_, compression_level=compression_level pkg_map,
install_dir_,
compression_level=compression_level,
metadata_files=metadata_files,
)
elif metadata_files:
logger.warning(
"WARNING: when --install is not set, the --metadata-files parameter is ignored",
) )

View File

@ -16,8 +16,10 @@ from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from typing import Any, NoReturn from typing import Any, NoReturn
from zipfile import ZipFile
from packaging.tags import Tag from packaging.tags import Tag
from packaging.utils import canonicalize_name as canonicalize_package_name
from packaging.utils import parse_wheel_filename from packaging.utils import parse_wheel_filename
from .logger import logger from .logger import logger
@ -312,3 +314,60 @@ def modify_wheel(wheel: Path) -> Iterator[Path]:
yield wheel_dir yield wheel_dir
wheel.unlink() wheel.unlink()
pack_wheel(wheel_dir, wheel.parent) pack_wheel(wheel_dir, wheel.parent)
def extract_wheel_metadata_file(wheel_path: Path, output_path: Path) -> None:
"""Extracts the METADATA file from the given wheel and writes it to the
output path.
Raises an exception if the METADATA file does not exist.
For a wheel called "NAME-VERSION-...", the METADATA file is expected to be
found in a directory inside the wheel archive, whose name starts with NAME
and ends with ".dist-info". See:
https://packaging.python.org/en/latest/specifications/binary-distribution-format/#file-contents
"""
with ZipFile(wheel_path, mode="r") as wheel:
pkg_name = wheel_path.name.split("-", 1)[0]
dist_info_dir = get_wheel_dist_info_dir(wheel, pkg_name)
metadata_path = f"{dist_info_dir}/METADATA"
try:
wheel.getinfo(metadata_path).filename = output_path.name
wheel.extract(metadata_path, output_path.parent)
except KeyError as err:
raise Exception(f"METADATA file not found for {pkg_name}") from err
def get_wheel_dist_info_dir(wheel: ZipFile, pkg_name: str) -> str:
"""Returns the path of the contained .dist-info directory.
Raises an Exception if the directory is not found, more than
one is found, or it does not match the provided `pkg_name`.
Adapted from:
https://github.com/pypa/pip/blob/ea727e4d6ab598f34f97c50a22350febc1214a97/src/pip/_internal/utils/wheel.py#L38
"""
# Zip file path separators must be /
subdirs = {name.split("/", 1)[0] for name in wheel.namelist()}
info_dirs = [subdir for subdir in subdirs if subdir.endswith(".dist-info")]
if len(info_dirs) == 0:
raise Exception(f".dist-info directory not found for {pkg_name}")
if len(info_dirs) > 1:
raise Exception(
f"multiple .dist-info directories found for {pkg_name}: {', '.join(info_dirs)}"
)
(info_dir,) = info_dirs
info_dir_name = canonicalize_package_name(info_dir)
canonical_name = canonicalize_package_name(pkg_name)
if not info_dir_name.startswith(canonical_name):
raise Exception(
f".dist-info directory {info_dir!r} does not start with {canonical_name!r}"
)
return info_dir

View File

@ -4,6 +4,7 @@ import pytest
from pyodide_build.common import ( from pyodide_build.common import (
environment_substitute_args, environment_substitute_args,
extract_wheel_metadata_file,
find_missing_executables, find_missing_executables,
get_num_cores, get_num_cores,
make_zip_archive, make_zip_archive,
@ -123,3 +124,31 @@ def test_repack_zip_archive(
assert fh.namelist() == ["a/b.txt", "a/b/c.txt"] assert fh.namelist() == ["a/b.txt", "a/b/c.txt"]
assert fh.getinfo("a/b.txt").compress_type == expected_compression_type assert fh.getinfo("a/b.txt").compress_type == expected_compression_type
assert input_path.stat().st_size == expected_size assert input_path.stat().st_size == expected_size
def test_extract_wheel_metadata_file(tmp_path):
# Test extraction if metadata exists
input_path = tmp_path / "pkg-0.1-abc.whl"
metadata_path = "pkg-0.1.dist-info/METADATA"
metadata_str = "This is METADATA"
with zipfile.ZipFile(input_path, "w") as fh:
fh.writestr(metadata_path, metadata_str)
output_path = tmp_path / f"{input_path.name}.metadata"
extract_wheel_metadata_file(input_path, output_path)
assert output_path.read_text() == metadata_str
# Test extraction if metadata is missing
input_path_empty = tmp_path / "pkg-0.2-abc.whl"
with zipfile.ZipFile(input_path_empty, "w") as fh:
pass
output_path_empty = tmp_path / f"{input_path_empty.name}.metadata"
with pytest.raises(Exception):
extract_wheel_metadata_file(input_path_empty, output_path_empty)

View File

@ -137,6 +137,8 @@ def deploy_to_s3_main(
# However, JsDelivr will currently not serve .ts file in the # However, JsDelivr will currently not serve .ts file in the
# custom CDN configuration, so it does not really matter. # custom CDN configuration, so it does not really matter.
content_type = "text/x.typescript" content_type = "text/x.typescript"
elif file_path.name.endswith(".whl.metadata"):
content_type = "text/plain"
else: else:
content_type = mimetypes.guess_type(file_path)[0] content_type = mimetypes.guess_type(file_path)[0]
if content_type is None: if content_type is None: