"""Tests for wheel binary packages and .dist-info.""" import csv import logging import os import pathlib import sys import textwrap from email import message_from_string from pathlib import Path from typing import Dict, List, Optional, Tuple, cast from unittest.mock import patch import pytest from pip._vendor.packaging.requirements import Requirement from pip._internal.exceptions import InstallationError from pip._internal.locations import get_scheme from pip._internal.models.direct_url import ( DIRECT_URL_METADATA_NAME, ArchiveInfo, DirectUrl, ) from pip._internal.models.scheme import Scheme from pip._internal.operations.build.wheel_legacy import get_legacy_build_wheel_path from pip._internal.operations.install import wheel from pip._internal.operations.install.wheel import ( InstalledCSVRow, RecordPath, get_console_script_specs, ) from pip._internal.utils.compat import WINDOWS from pip._internal.utils.misc import hash_file from pip._internal.utils.unpacking import unpack_file from tests.lib import DATA_DIR, TestData, assert_paths_equal from tests.lib.wheel import make_wheel def call_get_legacy_build_wheel_path( caplog: pytest.LogCaptureFixture, names: List[str] ) -> Optional[str]: wheel_path = get_legacy_build_wheel_path( names=names, temp_dir="/tmp/abcd", name="pendulum", command_args=["arg1", "arg2"], command_output="output line 1\noutput line 2\n", ) return wheel_path def test_get_legacy_build_wheel_path(caplog: pytest.LogCaptureFixture) -> None: actual = call_get_legacy_build_wheel_path(caplog, names=["name"]) assert actual is not None assert_paths_equal(actual, "/tmp/abcd/name") assert not caplog.records def test_get_legacy_build_wheel_path__no_names( caplog: pytest.LogCaptureFixture, ) -> None: caplog.set_level(logging.INFO) actual = call_get_legacy_build_wheel_path(caplog, names=[]) assert actual is None assert len(caplog.records) == 1 record = caplog.records[0] assert record.levelname == "WARNING" assert record.message.splitlines() == [ "Legacy build of wheel for 'pendulum' created no files.", "Command arguments: arg1 arg2", "Command output: [use --verbose to show]", ] def test_get_legacy_build_wheel_path__multiple_names( caplog: pytest.LogCaptureFixture, ) -> None: caplog.set_level(logging.INFO) # Deliberately pass the names in non-sorted order. actual = call_get_legacy_build_wheel_path( caplog, names=["name2", "name1"], ) assert actual is not None assert_paths_equal(actual, "/tmp/abcd/name1") assert len(caplog.records) == 1 record = caplog.records[0] assert record.levelname == "WARNING" assert record.message.splitlines() == [ "Legacy build of wheel for 'pendulum' created more than one file.", "Filenames (choosing first): ['name1', 'name2']", "Command arguments: arg1 arg2", "Command output: [use --verbose to show]", ] @pytest.mark.parametrize( "console_scripts", [ "pip = pip._internal.main:pip", "pip:pip = pip._internal.main:pip", "進入點 = 套件.模組:函式", ], ) def test_get_entrypoints(tmp_path: pathlib.Path, console_scripts: str) -> None: entry_points_text = f""" [console_scripts] {console_scripts} [section] common:one = module:func common:two = module:other_func """ distribution = make_wheel( "simple", "0.1.0", extra_metadata_files={ "entry_points.txt": entry_points_text, }, ).as_distribution("simple") entry_point, entry_point_value = console_scripts.split(" = ") assert wheel.get_entrypoints(distribution) == ({entry_point: entry_point_value}, {}) def test_get_entrypoints_no_entrypoints(tmp_path: pathlib.Path) -> None: distribution = make_wheel("simple", "0.1.0").as_distribution("simple") console, gui = wheel.get_entrypoints(distribution) assert console == {} assert gui == {} @pytest.mark.parametrize( "outrows, expected", [ ( [ ("", "", "a"), ("", "", ""), ], [ ("", "", ""), ("", "", "a"), ], ), ( [ # Include an int to check avoiding the following error: # > TypeError: '<' not supported between instances of 'str' and 'int' ("", "", 1), ("", "", ""), ], [ ("", "", ""), ("", "", "1"), ], ), ( [ # Test the normalization correctly encode everything for csv.writer(). ("😉", "", 1), ("", "", ""), ], [ ("", "", ""), ("😉", "", "1"), ], ), ], ) def test_normalized_outrows( outrows: List[Tuple[RecordPath, str, str]], expected: List[Tuple[str, str, str]] ) -> None: actual = wheel._normalized_outrows(outrows) assert actual == expected def call_get_csv_rows_for_installed(tmpdir: Path, text: str) -> List[InstalledCSVRow]: path = tmpdir.joinpath("temp.txt") path.write_text(text) # Test that an installed file appearing in RECORD has its filename # updated in the new RECORD file. installed = cast(Dict[RecordPath, RecordPath], {"a": "z"}) lib_dir = "/lib/dir" with open(path, **wheel.csv_io_kwargs("r")) as f: record_rows = list(csv.reader(f)) outrows = wheel.get_csv_rows_for_installed( record_rows, installed=installed, changed=set(), generated=[], lib_dir=lib_dir, ) return outrows def test_get_csv_rows_for_installed( tmpdir: Path, caplog: pytest.LogCaptureFixture ) -> None: text = textwrap.dedent( """\ a,b,c d,e,f """ ) outrows = call_get_csv_rows_for_installed(tmpdir, text) expected = [ ("z", "b", "c"), ("d", "e", "f"), ] assert outrows == expected # Check there were no warnings. assert len(caplog.records) == 0 def test_get_csv_rows_for_installed__long_lines( tmpdir: Path, caplog: pytest.LogCaptureFixture ) -> None: text = textwrap.dedent( """\ a,b,c,d e,f,g h,i,j,k """ ) outrows = call_get_csv_rows_for_installed(tmpdir, text) assert outrows == [ ("z", "b", "c"), ("e", "f", "g"), ("h", "i", "j"), ] messages = [rec.message for rec in caplog.records] assert messages == [ "RECORD line has more than three elements: ['a', 'b', 'c', 'd']", "RECORD line has more than three elements: ['h', 'i', 'j', 'k']", ] @pytest.mark.parametrize( "text,expected", [ ("Root-Is-Purelib: true", True), ("Root-Is-Purelib: false", False), ("Root-Is-Purelib: hello", False), ("", False), ("root-is-purelib: true", True), ("root-is-purelib: True", True), ], ) def test_wheel_root_is_purelib(text: str, expected: bool) -> None: assert wheel.wheel_root_is_purelib(message_from_string(text)) == expected def test_dist_from_broken_wheel_fails(data: TestData) -> None: from pip._internal.exceptions import InvalidWheel from pip._internal.metadata import FilesystemWheel, get_wheel_distribution package = data.packages.joinpath("corruptwheel-1.0-py2.py3-none-any.whl") with pytest.raises(InvalidWheel): get_wheel_distribution(FilesystemWheel(os.fspath(package)), "brokenwheel") class TestWheelFile: def test_unpack_wheel_no_flatten(self, tmpdir: Path) -> None: filepath = os.path.join(DATA_DIR, "packages", "meta-1.0-py2.py3-none-any.whl") unpack_file(filepath, os.fspath(tmpdir)) assert os.path.isdir(os.path.join(tmpdir, "meta-1.0.dist-info")) class TestInstallUnpackedWheel: """ Tests for moving files from wheel src to scheme paths """ def prep(self, data: TestData, tmp_path: Path) -> None: # Since Path implements __add__, os.path.join returns a Path object. # Passing Path objects to interfaces expecting str (like # `compileall.compile_file`) can cause failures, so we normalize it # to a string here. tmpdir = str(tmp_path) self.name = "sample" self.wheelpath = make_wheel( "sample", "1.2.0", metadata_body=textwrap.dedent( """ A sample Python project ======================= ... """ ), metadata_updates={ "Requires-Dist": ["peppercorn"], }, extra_files={ "sample/__init__.py": textwrap.dedent( ''' __version__ = '1.2.0' def main(): """Entry point for the application script""" print("Call your main application code here") ''' ), "sample/package_data.dat": "some data", }, extra_metadata_files={ "DESCRIPTION.rst": textwrap.dedent( """ A sample Python project ======================= ... """ ), "top_level.txt": "sample\n", "empty_dir/empty_dir/": "", }, extra_data_files={ "data/my_data/data_file": "some data", }, entry_points={ "console_scripts": ["sample = sample:main"], "gui_scripts": ["sample2 = sample:main"], }, ).save_to_dir(tmpdir) self.req = Requirement("sample") self.src = os.path.join(tmpdir, "src") self.dest = os.path.join(tmpdir, "dest") self.scheme = Scheme( purelib=os.path.join(self.dest, "lib"), platlib=os.path.join(self.dest, "lib"), headers=os.path.join(self.dest, "headers"), scripts=os.path.join(self.dest, "bin"), data=os.path.join(self.dest, "data"), ) self.src_dist_info = os.path.join(self.src, "sample-1.2.0.dist-info") self.dest_dist_info = os.path.join( self.scheme.purelib, "sample-1.2.0.dist-info" ) def assert_permission(self, path: str, mode: int) -> None: target_mode = os.stat(path).st_mode & 0o777 assert (target_mode & mode) == mode, oct(target_mode) def assert_installed(self, expected_permission: int) -> None: # lib assert os.path.isdir(os.path.join(self.scheme.purelib, "sample")) # dist-info metadata = os.path.join(self.dest_dist_info, "METADATA") self.assert_permission(metadata, expected_permission) record = os.path.join(self.dest_dist_info, "RECORD") self.assert_permission(record, expected_permission) # data files data_file = os.path.join(self.scheme.data, "my_data", "data_file") assert os.path.isfile(data_file) # package data pkg_data = os.path.join(self.scheme.purelib, "sample", "package_data.dat") assert os.path.isfile(pkg_data) def test_std_install(self, data: TestData, tmpdir: Path) -> None: self.prep(data, tmpdir) wheel.install_wheel( self.name, self.wheelpath, scheme=self.scheme, req_description=str(self.req), ) self.assert_installed(0o644) @pytest.mark.parametrize("user_mask, expected_permission", [(0o27, 0o640)]) def test_std_install_with_custom_umask( self, data: TestData, tmpdir: Path, user_mask: int, expected_permission: int ) -> None: """Test that the files created after install honor the permissions set when the user sets a custom umask""" prev_umask = os.umask(user_mask) try: self.prep(data, tmpdir) wheel.install_wheel( self.name, self.wheelpath, scheme=self.scheme, req_description=str(self.req), ) self.assert_installed(expected_permission) finally: os.umask(prev_umask) def test_std_install_requested(self, data: TestData, tmpdir: Path) -> None: self.prep(data, tmpdir) wheel.install_wheel( self.name, self.wheelpath, scheme=self.scheme, req_description=str(self.req), requested=True, ) self.assert_installed(0o644) requested_path = os.path.join(self.dest_dist_info, "REQUESTED") assert os.path.isfile(requested_path) def test_std_install_with_direct_url(self, data: TestData, tmpdir: Path) -> None: """Test that install_wheel creates direct_url.json metadata when provided with a direct_url argument. Also test that the RECORDS file contains an entry for direct_url.json in that case. Note direct_url.url is intentionally different from wheelpath, because wheelpath is typically the result of a local build. """ self.prep(data, tmpdir) direct_url = DirectUrl( url="file:///home/user/archive.tgz", info=ArchiveInfo(), ) wheel.install_wheel( self.name, self.wheelpath, scheme=self.scheme, req_description=str(self.req), direct_url=direct_url, ) direct_url_path = os.path.join(self.dest_dist_info, DIRECT_URL_METADATA_NAME) self.assert_permission(direct_url_path, 0o644) with open(direct_url_path, "rb") as f1: expected_direct_url_json = direct_url.to_json() direct_url_json = f1.read().decode("utf-8") assert direct_url_json == expected_direct_url_json # check that the direc_url file is part of RECORDS with open(os.path.join(self.dest_dist_info, "RECORD")) as f2: assert DIRECT_URL_METADATA_NAME in f2.read() def test_install_prefix(self, data: TestData, tmpdir: Path) -> None: prefix = os.path.join(os.path.sep, "some", "path") self.prep(data, tmpdir) scheme = get_scheme( self.name, user=False, home=None, root=str(tmpdir), # Casting needed for CPython 3.10+. See GH-10358. isolated=False, prefix=prefix, ) wheel.install_wheel( self.name, self.wheelpath, scheme=scheme, req_description=str(self.req), ) bin_dir = "Scripts" if WINDOWS else "bin" assert os.path.exists(os.path.join(tmpdir, "some", "path", bin_dir)) assert os.path.exists(os.path.join(tmpdir, "some", "path", "my_data")) def test_dist_info_contains_empty_dir(self, data: TestData, tmpdir: Path) -> None: """ Test that empty dirs are not installed """ # e.g. https://github.com/pypa/pip/issues/1632#issuecomment-38027275 self.prep(data, tmpdir) wheel.install_wheel( self.name, self.wheelpath, scheme=self.scheme, req_description=str(self.req), ) self.assert_installed(0o644) assert not os.path.isdir(os.path.join(self.dest_dist_info, "empty_dir")) @pytest.mark.parametrize("path", ["/tmp/example", "../example", "./../example"]) def test_wheel_install_rejects_bad_paths( self, data: TestData, tmpdir: Path, path: str ) -> None: self.prep(data, tmpdir) wheel_path = make_wheel( "simple", "0.1.0", extra_files={path: "example contents\n"} ).save_to_dir(tmpdir) with pytest.raises(InstallationError) as e: wheel.install_wheel( "simple", str(wheel_path), scheme=self.scheme, req_description="simple", ) exc_text = str(e.value) assert os.path.basename(wheel_path) in exc_text assert "example" in exc_text @pytest.mark.xfail(strict=True) @pytest.mark.parametrize("entrypoint", ["hello = hello", "hello = hello:"]) @pytest.mark.parametrize("entrypoint_type", ["console_scripts", "gui_scripts"]) def test_invalid_entrypoints_fail( self, data: TestData, tmpdir: Path, entrypoint: str, entrypoint_type: str ) -> None: self.prep(data, tmpdir) wheel_path = make_wheel( "simple", "0.1.0", entry_points={entrypoint_type: [entrypoint]} ).save_to_dir(tmpdir) with pytest.raises(InstallationError) as e: wheel.install_wheel( "simple", str(wheel_path), scheme=self.scheme, req_description="simple", ) exc_text = str(e.value) assert os.path.basename(wheel_path) in exc_text assert entrypoint in exc_text class TestMessageAboutScriptsNotOnPATH: tilde_warning_msg = ( "NOTE: The current PATH contains path(s) starting with `~`, " "which may not be expanded by all applications." ) def _template(self, paths: List[str], scripts: List[str]) -> Optional[str]: with patch.dict("os.environ", {"PATH": os.pathsep.join(paths)}): return wheel.message_about_scripts_not_on_PATH(scripts) def test_no_script(self) -> None: retval = self._template(paths=["/a/b", "/c/d/bin"], scripts=[]) assert retval is None def test_single_script__single_dir_not_on_PATH(self) -> None: retval = self._template(paths=["/a/b", "/c/d/bin"], scripts=["/c/d/foo"]) assert retval is not None assert "--no-warn-script-location" in retval assert "foo is installed in '/c/d'" in retval assert self.tilde_warning_msg not in retval def test_two_script__single_dir_not_on_PATH(self) -> None: retval = self._template( paths=["/a/b", "/c/d/bin"], scripts=["/c/d/foo", "/c/d/baz"] ) assert retval is not None assert "--no-warn-script-location" in retval assert "baz and foo are installed in '/c/d'" in retval assert self.tilde_warning_msg not in retval def test_multi_script__multi_dir_not_on_PATH(self) -> None: retval = self._template( paths=["/a/b", "/c/d/bin"], scripts=["/c/d/foo", "/c/d/bar", "/c/d/baz", "/a/b/c/spam"], ) assert retval is not None assert "--no-warn-script-location" in retval assert "bar, baz and foo are installed in '/c/d'" in retval assert "spam is installed in '/a/b/c'" in retval assert self.tilde_warning_msg not in retval def test_multi_script_all__multi_dir_not_on_PATH(self) -> None: retval = self._template( paths=["/a/b", "/c/d/bin"], scripts=["/c/d/foo", "/c/d/bar", "/c/d/baz", "/a/b/c/spam", "/a/b/c/eggs"], ) assert retval is not None assert "--no-warn-script-location" in retval assert "bar, baz and foo are installed in '/c/d'" in retval assert "eggs and spam are installed in '/a/b/c'" in retval assert self.tilde_warning_msg not in retval def test_two_script__single_dir_on_PATH(self) -> None: retval = self._template( paths=["/a/b", "/c/d/bin"], scripts=["/a/b/foo", "/a/b/baz"] ) assert retval is None def test_multi_script__multi_dir_on_PATH(self) -> None: retval = self._template( paths=["/a/b", "/c/d/bin"], scripts=["/a/b/foo", "/a/b/bar", "/a/b/baz", "/c/d/bin/spam"], ) assert retval is None def test_multi_script__single_dir_on_PATH(self) -> None: retval = self._template( paths=["/a/b", "/c/d/bin"], scripts=["/a/b/foo", "/a/b/bar", "/a/b/baz"] ) assert retval is None def test_PATH_check_path_normalization(self) -> None: retval = self._template( paths=["/a/./b/../b//c/", "/d/e/bin"], scripts=["/a/b/c/foo"] ) assert retval is None def test_single_script__single_dir_on_PATH(self) -> None: retval = self._template(paths=["/a/b", "/c/d/bin"], scripts=["/a/b/foo"]) assert retval is None def test_PATH_check_case_insensitive_on_windows(self) -> None: retval = self._template(paths=["C:\\A\\b"], scripts=["c:\\a\\b\\c", "C:/A/b/d"]) if WINDOWS: assert retval is None else: assert retval is not None assert self.tilde_warning_msg not in retval def test_trailing_ossep_removal(self) -> None: retval = self._template( paths=[os.path.join("a", "b", "")], scripts=[os.path.join("a", "b", "c")] ) assert retval is None def test_missing_PATH_env_treated_as_empty_PATH_env( self, monkeypatch: pytest.MonkeyPatch ) -> None: scripts = ["a/b/foo"] monkeypatch.delenv("PATH") retval_missing = wheel.message_about_scripts_not_on_PATH(scripts) monkeypatch.setenv("PATH", "") retval_empty = wheel.message_about_scripts_not_on_PATH(scripts) assert retval_missing == retval_empty def test_no_script_tilde_in_path(self) -> None: retval = self._template(paths=["/a/b", "/c/d/bin", "~/e", "/f/g~g"], scripts=[]) assert retval is None def test_multi_script_all_tilde__multi_dir_not_on_PATH(self) -> None: retval = self._template( paths=["/a/b", "/c/d/bin", "~e/f"], scripts=[ "/c/d/foo", "/c/d/bar", "/c/d/baz", "/a/b/c/spam", "/a/b/c/eggs", "/e/f/tilde", ], ) assert retval is not None assert "--no-warn-script-location" in retval assert "bar, baz and foo are installed in '/c/d'" in retval assert "eggs and spam are installed in '/a/b/c'" in retval assert "tilde is installed in '/e/f'" in retval assert self.tilde_warning_msg in retval def test_multi_script_all_tilde_not_at_start__multi_dir_not_on_PATH(self) -> None: retval = self._template( paths=["/e/f~f", "/c/d/bin"], scripts=[ "/c/d/foo", "/c/d/bar", "/c/d/baz", "/e/f~f/c/spam", "/e/f~f/c/eggs", ], ) assert retval is not None assert "--no-warn-script-location" in retval assert "bar, baz and foo are installed in '/c/d'" in retval assert "eggs and spam are installed in '/e/f~f/c'" in retval assert self.tilde_warning_msg not in retval class TestWheelHashCalculators: def prep(self, tmpdir: Path) -> None: self.test_file = tmpdir.joinpath("hash.file") # Want this big enough to trigger the internal read loops. self.test_file_len = 2 * 1024 * 1024 with open(str(self.test_file), "w") as fp: fp.truncate(self.test_file_len) self.test_file_hash = ( "5647f05ec18958947d32874eeb788fa396a05d0bab7c1b71f112ceb7e9b31eee" ) self.test_file_hash_encoded = ( "sha256=VkfwXsGJWJR9ModO63iPo5agXQurfBtx8RLOt-mzHu4" ) def test_hash_file(self, tmpdir: Path) -> None: self.prep(tmpdir) h, length = hash_file(os.fspath(self.test_file)) assert length == self.test_file_len assert h.hexdigest() == self.test_file_hash def test_rehash(self, tmpdir: Path) -> None: self.prep(tmpdir) h, length = wheel.rehash(os.fspath(self.test_file)) assert length == str(self.test_file_len) assert h == self.test_file_hash_encoded def test_get_console_script_specs_replaces_python_version( monkeypatch: pytest.MonkeyPatch, ) -> None: # Fake Python version. monkeypatch.setattr(sys, "version_info", (10, 11)) entry_points = { "pip": "real_pip", "pip99": "whatever", "pip99.88": "whatever", "easy_install": "real_easy_install", "easy_install-99.88": "whatever", # The following shouldn't be replaced. "not_pip_or_easy_install-99": "whatever", "not_pip_or_easy_install-99.88": "whatever", } specs = get_console_script_specs(entry_points) assert specs == [ "pip = real_pip", "pip10 = real_pip", "pip10.11 = real_pip", "easy_install = real_easy_install", "easy_install-10.11 = real_easy_install", "not_pip_or_easy_install-99 = whatever", "not_pip_or_easy_install-99.88 = whatever", ]