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