1# -*- coding: utf-8 -*- 2 3"""Tests for wheel binary packages and .dist-info.""" 4import csv 5import logging 6import os 7import textwrap 8from email import message_from_string 9 10import pytest 11from mock import patch 12from pip._vendor.packaging.requirements import Requirement 13 14from pip._internal.exceptions import InstallationError 15from pip._internal.locations import get_scheme 16from pip._internal.models.direct_url import ( 17 DIRECT_URL_METADATA_NAME, 18 ArchiveInfo, 19 DirectUrl, 20) 21from pip._internal.models.scheme import Scheme 22from pip._internal.operations.build.wheel_legacy import get_legacy_build_wheel_path 23from pip._internal.operations.install import wheel 24from pip._internal.utils.compat import WINDOWS 25from pip._internal.utils.misc import hash_file 26from pip._internal.utils.unpacking import unpack_file 27from pip._internal.utils.wheel import pkg_resources_distribution_for_wheel 28from tests.lib import DATA_DIR, assert_paths_equal, skip_if_python2 29from tests.lib.wheel import make_wheel 30 31 32def call_get_legacy_build_wheel_path(caplog, names): 33 wheel_path = get_legacy_build_wheel_path( 34 names=names, 35 temp_dir='/tmp/abcd', 36 name='pendulum', 37 command_args=['arg1', 'arg2'], 38 command_output='output line 1\noutput line 2\n', 39 ) 40 return wheel_path 41 42 43def test_get_legacy_build_wheel_path(caplog): 44 actual = call_get_legacy_build_wheel_path(caplog, names=['name']) 45 assert_paths_equal(actual, '/tmp/abcd/name') 46 assert not caplog.records 47 48 49def test_get_legacy_build_wheel_path__no_names(caplog): 50 caplog.set_level(logging.INFO) 51 actual = call_get_legacy_build_wheel_path(caplog, names=[]) 52 assert actual is None 53 assert len(caplog.records) == 1 54 record = caplog.records[0] 55 assert record.levelname == 'WARNING' 56 assert record.message.splitlines() == [ 57 "Legacy build of wheel for 'pendulum' created no files.", 58 "Command arguments: arg1 arg2", 59 'Command output: [use --verbose to show]', 60 ] 61 62 63def test_get_legacy_build_wheel_path__multiple_names(caplog): 64 caplog.set_level(logging.INFO) 65 # Deliberately pass the names in non-sorted order. 66 actual = call_get_legacy_build_wheel_path( 67 caplog, names=['name2', 'name1'], 68 ) 69 assert_paths_equal(actual, '/tmp/abcd/name1') 70 assert len(caplog.records) == 1 71 record = caplog.records[0] 72 assert record.levelname == 'WARNING' 73 assert record.message.splitlines() == [ 74 "Legacy build of wheel for 'pendulum' created more than one file.", 75 "Filenames (choosing first): ['name1', 'name2']", 76 "Command arguments: arg1 arg2", 77 'Command output: [use --verbose to show]', 78 ] 79 80 81@pytest.mark.parametrize( 82 "console_scripts", 83 [ 84 u"pip = pip._internal.main:pip", 85 u"pip:pip = pip._internal.main:pip", 86 pytest.param(u"進入點 = 套件.模組:函式", marks=skip_if_python2), 87 ], 88) 89def test_get_entrypoints(console_scripts): 90 entry_points_text = u""" 91 [console_scripts] 92 {} 93 [section] 94 common:one = module:func 95 common:two = module:other_func 96 """.format(console_scripts) 97 98 wheel_zip = make_wheel( 99 "simple", 100 "0.1.0", 101 extra_metadata_files={ 102 "entry_points.txt": entry_points_text, 103 }, 104 ).as_zipfile() 105 distribution = pkg_resources_distribution_for_wheel( 106 wheel_zip, "simple", "<in memory>" 107 ) 108 109 assert wheel.get_entrypoints(distribution) == ( 110 dict([console_scripts.split(' = ')]), 111 {}, 112 ) 113 114 115def test_get_entrypoints_no_entrypoints(): 116 wheel_zip = make_wheel("simple", "0.1.0").as_zipfile() 117 distribution = pkg_resources_distribution_for_wheel( 118 wheel_zip, "simple", "<in memory>" 119 ) 120 121 console, gui = wheel.get_entrypoints(distribution) 122 assert console == {} 123 assert gui == {} 124 125 126@pytest.mark.parametrize("outrows, expected", [ 127 ([ 128 (u'', '', 'a'), 129 (u'', '', ''), 130 ], [ 131 ('', '', ''), 132 ('', '', 'a'), 133 ]), 134 ([ 135 # Include an int to check avoiding the following error: 136 # > TypeError: '<' not supported between instances of 'str' and 'int' 137 (u'', '', 1), 138 (u'', '', ''), 139 ], [ 140 ('', '', ''), 141 ('', '', '1'), 142 ]), 143 ([ 144 # Test the normalization correctly encode everything for csv.writer(). 145 (u'', '', 1), 146 (u'', '', ''), 147 ], [ 148 ('', '', ''), 149 ('', '', '1'), 150 ]), 151]) 152def test_normalized_outrows(outrows, expected): 153 actual = wheel._normalized_outrows(outrows) 154 assert actual == expected 155 156 157def call_get_csv_rows_for_installed(tmpdir, text): 158 path = tmpdir.joinpath('temp.txt') 159 path.write_text(text) 160 161 # Test that an installed file appearing in RECORD has its filename 162 # updated in the new RECORD file. 163 installed = {u'a': 'z'} 164 changed = set() 165 generated = [] 166 lib_dir = '/lib/dir' 167 168 with open(path, **wheel.csv_io_kwargs('r')) as f: 169 record_rows = list(csv.reader(f)) 170 outrows = wheel.get_csv_rows_for_installed( 171 record_rows, installed=installed, changed=changed, 172 generated=generated, lib_dir=lib_dir, 173 ) 174 return outrows 175 176 177def test_get_csv_rows_for_installed(tmpdir, caplog): 178 text = textwrap.dedent("""\ 179 a,b,c 180 d,e,f 181 """) 182 outrows = call_get_csv_rows_for_installed(tmpdir, text) 183 184 expected = [ 185 ('z', 'b', 'c'), 186 ('d', 'e', 'f'), 187 ] 188 assert outrows == expected 189 # Check there were no warnings. 190 assert len(caplog.records) == 0 191 192 193def test_get_csv_rows_for_installed__long_lines(tmpdir, caplog): 194 text = textwrap.dedent("""\ 195 a,b,c,d 196 e,f,g 197 h,i,j,k 198 """) 199 outrows = call_get_csv_rows_for_installed(tmpdir, text) 200 201 expected = [ 202 ('z', 'b', 'c'), 203 ('e', 'f', 'g'), 204 ('h', 'i', 'j'), 205 ] 206 assert outrows == expected 207 208 messages = [rec.message for rec in caplog.records] 209 expected = [ 210 "RECORD line has more than three elements: ['a', 'b', 'c', 'd']", 211 "RECORD line has more than three elements: ['h', 'i', 'j', 'k']" 212 ] 213 assert messages == expected 214 215 216@pytest.mark.parametrize("text,expected", [ 217 ("Root-Is-Purelib: true", True), 218 ("Root-Is-Purelib: false", False), 219 ("Root-Is-Purelib: hello", False), 220 ("", False), 221 ("root-is-purelib: true", True), 222 ("root-is-purelib: True", True), 223]) 224def test_wheel_root_is_purelib(text, expected): 225 assert wheel.wheel_root_is_purelib(message_from_string(text)) == expected 226 227 228class TestWheelFile(object): 229 230 def test_unpack_wheel_no_flatten(self, tmpdir): 231 filepath = os.path.join(DATA_DIR, 'packages', 232 'meta-1.0-py2.py3-none-any.whl') 233 unpack_file(filepath, tmpdir) 234 assert os.path.isdir(os.path.join(tmpdir, 'meta-1.0.dist-info')) 235 236 237class TestInstallUnpackedWheel(object): 238 """ 239 Tests for moving files from wheel src to scheme paths 240 """ 241 242 def prep(self, data, tmpdir): 243 # Since Path implements __add__, os.path.join returns a Path object. 244 # Passing Path objects to interfaces expecting str (like 245 # `compileall.compile_file`) can cause failures, so we normalize it 246 # to a string here. 247 tmpdir = str(tmpdir) 248 self.name = 'sample' 249 self.wheelpath = make_wheel( 250 "sample", 251 "1.2.0", 252 metadata_body=textwrap.dedent( 253 """ 254 A sample Python project 255 ======================= 256 257 ... 258 """ 259 ), 260 metadata_updates={ 261 "Requires-Dist": ["peppercorn"], 262 }, 263 extra_files={ 264 "sample/__init__.py": textwrap.dedent( 265 ''' 266 __version__ = '1.2.0' 267 268 def main(): 269 """Entry point for the application script""" 270 print("Call your main application code here") 271 ''' 272 ), 273 "sample/package_data.dat": "some data", 274 }, 275 extra_metadata_files={ 276 "DESCRIPTION.rst": textwrap.dedent( 277 """ 278 A sample Python project 279 ======================= 280 281 ... 282 """ 283 ), 284 "top_level.txt": "sample\n", 285 "empty_dir/empty_dir/": "", 286 }, 287 extra_data_files={ 288 "data/my_data/data_file": "some data", 289 }, 290 entry_points={ 291 "console_scripts": ["sample = sample:main"], 292 "gui_scripts": ["sample2 = sample:main"], 293 }, 294 ).save_to_dir(tmpdir) 295 self.req = Requirement('sample') 296 self.src = os.path.join(tmpdir, 'src') 297 self.dest = os.path.join(tmpdir, 'dest') 298 self.scheme = Scheme( 299 purelib=os.path.join(self.dest, 'lib'), 300 platlib=os.path.join(self.dest, 'lib'), 301 headers=os.path.join(self.dest, 'headers'), 302 scripts=os.path.join(self.dest, 'bin'), 303 data=os.path.join(self.dest, 'data'), 304 ) 305 self.src_dist_info = os.path.join( 306 self.src, 'sample-1.2.0.dist-info') 307 self.dest_dist_info = os.path.join( 308 self.scheme.purelib, 'sample-1.2.0.dist-info') 309 310 def assert_permission(self, path, mode): 311 target_mode = os.stat(path).st_mode & 0o777 312 assert (target_mode & mode) == mode, oct(target_mode) 313 314 def assert_installed(self, expected_permission): 315 # lib 316 assert os.path.isdir( 317 os.path.join(self.scheme.purelib, 'sample')) 318 # dist-info 319 metadata = os.path.join(self.dest_dist_info, 'METADATA') 320 self.assert_permission(metadata, expected_permission) 321 record = os.path.join(self.dest_dist_info, 'RECORD') 322 self.assert_permission(record, expected_permission) 323 # data files 324 data_file = os.path.join(self.scheme.data, 'my_data', 'data_file') 325 assert os.path.isfile(data_file) 326 # package data 327 pkg_data = os.path.join( 328 self.scheme.purelib, 'sample', 'package_data.dat') 329 assert os.path.isfile(pkg_data) 330 331 def test_std_install(self, data, tmpdir): 332 self.prep(data, tmpdir) 333 wheel.install_wheel( 334 self.name, 335 self.wheelpath, 336 scheme=self.scheme, 337 req_description=str(self.req), 338 ) 339 self.assert_installed(0o644) 340 341 @pytest.mark.parametrize("user_mask, expected_permission", [ 342 (0o27, 0o640) 343 ]) 344 def test_std_install_with_custom_umask(self, data, tmpdir, 345 user_mask, expected_permission): 346 """Test that the files created after install honor the permissions 347 set when the user sets a custom umask""" 348 349 prev_umask = os.umask(user_mask) 350 try: 351 self.prep(data, tmpdir) 352 wheel.install_wheel( 353 self.name, 354 self.wheelpath, 355 scheme=self.scheme, 356 req_description=str(self.req), 357 ) 358 self.assert_installed(expected_permission) 359 finally: 360 os.umask(prev_umask) 361 362 def test_std_install_requested(self, data, tmpdir): 363 self.prep(data, tmpdir) 364 wheel.install_wheel( 365 self.name, 366 self.wheelpath, 367 scheme=self.scheme, 368 req_description=str(self.req), 369 requested=True, 370 ) 371 self.assert_installed(0o644) 372 requested_path = os.path.join(self.dest_dist_info, 'REQUESTED') 373 assert os.path.isfile(requested_path) 374 375 def test_std_install_with_direct_url(self, data, tmpdir): 376 """Test that install_wheel creates direct_url.json metadata when 377 provided with a direct_url argument. Also test that the RECORDS 378 file contains an entry for direct_url.json in that case. 379 Note direct_url.url is intentionally different from wheelpath, 380 because wheelpath is typically the result of a local build. 381 """ 382 self.prep(data, tmpdir) 383 direct_url = DirectUrl( 384 url="file:///home/user/archive.tgz", 385 info=ArchiveInfo(), 386 ) 387 wheel.install_wheel( 388 self.name, 389 self.wheelpath, 390 scheme=self.scheme, 391 req_description=str(self.req), 392 direct_url=direct_url, 393 ) 394 direct_url_path = os.path.join( 395 self.dest_dist_info, DIRECT_URL_METADATA_NAME 396 ) 397 self.assert_permission(direct_url_path, 0o644) 398 with open(direct_url_path, 'rb') as f: 399 expected_direct_url_json = direct_url.to_json() 400 direct_url_json = f.read().decode("utf-8") 401 assert direct_url_json == expected_direct_url_json 402 # check that the direc_url file is part of RECORDS 403 with open(os.path.join(self.dest_dist_info, "RECORD")) as f: 404 assert DIRECT_URL_METADATA_NAME in f.read() 405 406 def test_install_prefix(self, data, tmpdir): 407 prefix = os.path.join(os.path.sep, 'some', 'path') 408 self.prep(data, tmpdir) 409 scheme = get_scheme( 410 self.name, 411 user=False, 412 home=None, 413 root=tmpdir, 414 isolated=False, 415 prefix=prefix, 416 ) 417 wheel.install_wheel( 418 self.name, 419 self.wheelpath, 420 scheme=scheme, 421 req_description=str(self.req), 422 ) 423 424 bin_dir = 'Scripts' if WINDOWS else 'bin' 425 assert os.path.exists(os.path.join(tmpdir, 'some', 'path', bin_dir)) 426 assert os.path.exists(os.path.join(tmpdir, 'some', 'path', 'my_data')) 427 428 def test_dist_info_contains_empty_dir(self, data, tmpdir): 429 """ 430 Test that empty dirs are not installed 431 """ 432 # e.g. https://github.com/pypa/pip/issues/1632#issuecomment-38027275 433 self.prep(data, tmpdir) 434 wheel.install_wheel( 435 self.name, 436 self.wheelpath, 437 scheme=self.scheme, 438 req_description=str(self.req), 439 ) 440 self.assert_installed(0o644) 441 assert not os.path.isdir( 442 os.path.join(self.dest_dist_info, 'empty_dir')) 443 444 @pytest.mark.parametrize( 445 "path", 446 ["/tmp/example", "../example", "./../example"] 447 ) 448 def test_wheel_install_rejects_bad_paths(self, data, tmpdir, path): 449 self.prep(data, tmpdir) 450 wheel_path = make_wheel( 451 "simple", "0.1.0", extra_files={path: "example contents\n"} 452 ).save_to_dir(tmpdir) 453 with pytest.raises(InstallationError) as e: 454 wheel.install_wheel( 455 "simple", 456 str(wheel_path), 457 scheme=self.scheme, 458 req_description="simple", 459 ) 460 461 exc_text = str(e.value) 462 assert os.path.basename(wheel_path) in exc_text 463 assert "example" in exc_text 464 465 @pytest.mark.xfail(strict=True) 466 @pytest.mark.parametrize( 467 "entrypoint", ["hello = hello", "hello = hello:"] 468 ) 469 @pytest.mark.parametrize( 470 "entrypoint_type", ["console_scripts", "gui_scripts"] 471 ) 472 def test_invalid_entrypoints_fail( 473 self, data, tmpdir, entrypoint, entrypoint_type 474 ): 475 self.prep(data, tmpdir) 476 wheel_path = make_wheel( 477 "simple", "0.1.0", entry_points={entrypoint_type: [entrypoint]} 478 ).save_to_dir(tmpdir) 479 with pytest.raises(InstallationError) as e: 480 wheel.install_wheel( 481 "simple", 482 str(wheel_path), 483 scheme=self.scheme, 484 req_description="simple", 485 ) 486 487 exc_text = str(e.value) 488 assert os.path.basename(wheel_path) in exc_text 489 assert entrypoint in exc_text 490 491 492class TestMessageAboutScriptsNotOnPATH(object): 493 494 tilde_warning_msg = ( 495 "NOTE: The current PATH contains path(s) starting with `~`, " 496 "which may not be expanded by all applications." 497 ) 498 499 def _template(self, paths, scripts): 500 with patch.dict('os.environ', {'PATH': os.pathsep.join(paths)}): 501 return wheel.message_about_scripts_not_on_PATH(scripts) 502 503 def test_no_script(self): 504 retval = self._template( 505 paths=['/a/b', '/c/d/bin'], 506 scripts=[] 507 ) 508 assert retval is None 509 510 def test_single_script__single_dir_not_on_PATH(self): 511 retval = self._template( 512 paths=['/a/b', '/c/d/bin'], 513 scripts=['/c/d/foo'] 514 ) 515 assert retval is not None 516 assert "--no-warn-script-location" in retval 517 assert "foo is installed in '/c/d'" in retval 518 assert self.tilde_warning_msg not in retval 519 520 def test_two_script__single_dir_not_on_PATH(self): 521 retval = self._template( 522 paths=['/a/b', '/c/d/bin'], 523 scripts=['/c/d/foo', '/c/d/baz'] 524 ) 525 assert retval is not None 526 assert "--no-warn-script-location" in retval 527 assert "baz and foo are installed in '/c/d'" in retval 528 assert self.tilde_warning_msg not in retval 529 530 def test_multi_script__multi_dir_not_on_PATH(self): 531 retval = self._template( 532 paths=['/a/b', '/c/d/bin'], 533 scripts=['/c/d/foo', '/c/d/bar', '/c/d/baz', '/a/b/c/spam'] 534 ) 535 assert retval is not None 536 assert "--no-warn-script-location" in retval 537 assert "bar, baz and foo are installed in '/c/d'" in retval 538 assert "spam is installed in '/a/b/c'" in retval 539 assert self.tilde_warning_msg not in retval 540 541 def test_multi_script_all__multi_dir_not_on_PATH(self): 542 retval = self._template( 543 paths=['/a/b', '/c/d/bin'], 544 scripts=[ 545 '/c/d/foo', '/c/d/bar', '/c/d/baz', 546 '/a/b/c/spam', '/a/b/c/eggs' 547 ] 548 ) 549 assert retval is not None 550 assert "--no-warn-script-location" in retval 551 assert "bar, baz and foo are installed in '/c/d'" in retval 552 assert "eggs and spam are installed in '/a/b/c'" in retval 553 assert self.tilde_warning_msg not in retval 554 555 def test_two_script__single_dir_on_PATH(self): 556 retval = self._template( 557 paths=['/a/b', '/c/d/bin'], 558 scripts=['/a/b/foo', '/a/b/baz'] 559 ) 560 assert retval is None 561 562 def test_multi_script__multi_dir_on_PATH(self): 563 retval = self._template( 564 paths=['/a/b', '/c/d/bin'], 565 scripts=['/a/b/foo', '/a/b/bar', '/a/b/baz', '/c/d/bin/spam'] 566 ) 567 assert retval is None 568 569 def test_multi_script__single_dir_on_PATH(self): 570 retval = self._template( 571 paths=['/a/b', '/c/d/bin'], 572 scripts=['/a/b/foo', '/a/b/bar', '/a/b/baz'] 573 ) 574 assert retval is None 575 576 def test_single_script__single_dir_on_PATH(self): 577 retval = self._template( 578 paths=['/a/b', '/c/d/bin'], 579 scripts=['/a/b/foo'] 580 ) 581 assert retval is None 582 583 def test_PATH_check_case_insensitive_on_windows(self): 584 retval = self._template( 585 paths=['C:\\A\\b'], 586 scripts=['c:\\a\\b\\c', 'C:/A/b/d'] 587 ) 588 if WINDOWS: 589 assert retval is None 590 else: 591 assert retval is not None 592 assert self.tilde_warning_msg not in retval 593 594 def test_trailing_ossep_removal(self): 595 retval = self._template( 596 paths=[os.path.join('a', 'b', '')], 597 scripts=[os.path.join('a', 'b', 'c')] 598 ) 599 assert retval is None 600 601 def test_missing_PATH_env_treated_as_empty_PATH_env(self, monkeypatch): 602 scripts = ['a/b/foo'] 603 604 monkeypatch.delenv('PATH') 605 retval_missing = wheel.message_about_scripts_not_on_PATH(scripts) 606 607 monkeypatch.setenv('PATH', '') 608 retval_empty = wheel.message_about_scripts_not_on_PATH(scripts) 609 610 assert retval_missing == retval_empty 611 612 def test_no_script_tilde_in_path(self): 613 retval = self._template( 614 paths=['/a/b', '/c/d/bin', '~/e', '/f/g~g'], 615 scripts=[] 616 ) 617 assert retval is None 618 619 def test_multi_script_all_tilde__multi_dir_not_on_PATH(self): 620 retval = self._template( 621 paths=['/a/b', '/c/d/bin', '~e/f'], 622 scripts=[ 623 '/c/d/foo', '/c/d/bar', '/c/d/baz', 624 '/a/b/c/spam', '/a/b/c/eggs', '/e/f/tilde' 625 ] 626 ) 627 assert retval is not None 628 assert "--no-warn-script-location" in retval 629 assert "bar, baz and foo are installed in '/c/d'" in retval 630 assert "eggs and spam are installed in '/a/b/c'" in retval 631 assert "tilde is installed in '/e/f'" in retval 632 assert self.tilde_warning_msg in retval 633 634 def test_multi_script_all_tilde_not_at_start__multi_dir_not_on_PATH(self): 635 retval = self._template( 636 paths=['/e/f~f', '/c/d/bin'], 637 scripts=[ 638 '/c/d/foo', '/c/d/bar', '/c/d/baz', 639 '/e/f~f/c/spam', '/e/f~f/c/eggs' 640 ] 641 ) 642 assert retval is not None 643 assert "--no-warn-script-location" in retval 644 assert "bar, baz and foo are installed in '/c/d'" in retval 645 assert "eggs and spam are installed in '/e/f~f/c'" in retval 646 assert self.tilde_warning_msg not in retval 647 648 649class TestWheelHashCalculators(object): 650 651 def prep(self, tmpdir): 652 self.test_file = tmpdir.joinpath("hash.file") 653 # Want this big enough to trigger the internal read loops. 654 self.test_file_len = 2 * 1024 * 1024 655 with open(str(self.test_file), "w") as fp: 656 fp.truncate(self.test_file_len) 657 self.test_file_hash = \ 658 '5647f05ec18958947d32874eeb788fa396a05d0bab7c1b71f112ceb7e9b31eee' 659 self.test_file_hash_encoded = \ 660 'sha256=VkfwXsGJWJR9ModO63iPo5agXQurfBtx8RLOt-mzHu4' 661 662 def test_hash_file(self, tmpdir): 663 self.prep(tmpdir) 664 h, length = hash_file(self.test_file) 665 assert length == self.test_file_len 666 assert h.hexdigest() == self.test_file_hash 667 668 def test_rehash(self, tmpdir): 669 self.prep(tmpdir) 670 h, length = wheel.rehash(self.test_file) 671 assert length == str(self.test_file_len) 672 assert h == self.test_file_hash_encoded 673