1import sys
2import ast
3import os
4import glob
5import re
6import stat
7
8from setuptools.command.egg_info import egg_info, manifest_maker
9from setuptools.dist import Distribution
10from setuptools.extern.six.moves import map
11
12import pytest
13
14from . import environment
15from .files import build_files
16from .textwrap import DALS
17from . import contexts
18
19
20class Environment(str):
21    pass
22
23
24class TestEggInfo(object):
25
26    setup_script = DALS("""
27        from setuptools import setup
28
29        setup(
30            name='foo',
31            py_modules=['hello'],
32            entry_points={'console_scripts': ['hi = hello.run']},
33            zip_safe=False,
34        )
35        """)
36
37    def _create_project(self):
38        build_files({
39            'setup.py': self.setup_script,
40            'hello.py': DALS("""
41                def run():
42                    print('hello')
43                """)
44        })
45
46    @pytest.yield_fixture
47    def env(self):
48        with contexts.tempdir(prefix='setuptools-test.') as env_dir:
49            env = Environment(env_dir)
50            os.chmod(env_dir, stat.S_IRWXU)
51            subs = 'home', 'lib', 'scripts', 'data', 'egg-base'
52            env.paths = dict(
53                (dirname, os.path.join(env_dir, dirname))
54                for dirname in subs
55            )
56            list(map(os.mkdir, env.paths.values()))
57            build_files({
58                env.paths['home']: {
59                    '.pydistutils.cfg': DALS("""
60                    [egg_info]
61                    egg-base = %(egg-base)s
62                    """ % env.paths)
63                }
64            })
65            yield env
66
67    def test_egg_info_save_version_info_setup_empty(self, tmpdir_cwd, env):
68        """
69        When the egg_info section is empty or not present, running
70        save_version_info should add the settings to the setup.cfg
71        in a deterministic order, consistent with the ordering found
72        on Python 2.7 with PYTHONHASHSEED=0.
73        """
74        setup_cfg = os.path.join(env.paths['home'], 'setup.cfg')
75        dist = Distribution()
76        ei = egg_info(dist)
77        ei.initialize_options()
78        ei.save_version_info(setup_cfg)
79
80        with open(setup_cfg, 'r') as f:
81            content = f.read()
82
83        assert '[egg_info]' in content
84        assert 'tag_build =' in content
85        assert 'tag_date = 0' in content
86
87        expected_order = 'tag_build', 'tag_date',
88
89        self._validate_content_order(content, expected_order)
90
91    @staticmethod
92    def _validate_content_order(content, expected):
93        """
94        Assert that the strings in expected appear in content
95        in order.
96        """
97        pattern = '.*'.join(expected)
98        flags = re.MULTILINE | re.DOTALL
99        assert re.search(pattern, content, flags)
100
101    def test_egg_info_save_version_info_setup_defaults(self, tmpdir_cwd, env):
102        """
103        When running save_version_info on an existing setup.cfg
104        with the 'default' values present from a previous run,
105        the file should remain unchanged.
106        """
107        setup_cfg = os.path.join(env.paths['home'], 'setup.cfg')
108        build_files({
109            setup_cfg: DALS("""
110            [egg_info]
111            tag_build =
112            tag_date = 0
113            """),
114        })
115        dist = Distribution()
116        ei = egg_info(dist)
117        ei.initialize_options()
118        ei.save_version_info(setup_cfg)
119
120        with open(setup_cfg, 'r') as f:
121            content = f.read()
122
123        assert '[egg_info]' in content
124        assert 'tag_build =' in content
125        assert 'tag_date = 0' in content
126
127        expected_order = 'tag_build', 'tag_date',
128
129        self._validate_content_order(content, expected_order)
130
131    def test_egg_base_installed_egg_info(self, tmpdir_cwd, env):
132        self._create_project()
133
134        self._run_install_command(tmpdir_cwd, env)
135        actual = self._find_egg_info_files(env.paths['lib'])
136
137        expected = [
138            'PKG-INFO',
139            'SOURCES.txt',
140            'dependency_links.txt',
141            'entry_points.txt',
142            'not-zip-safe',
143            'top_level.txt',
144        ]
145        assert sorted(actual) == expected
146
147    def test_manifest_template_is_read(self, tmpdir_cwd, env):
148        self._create_project()
149        build_files({
150            'MANIFEST.in': DALS("""
151                recursive-include docs *.rst
152            """),
153            'docs': {
154                'usage.rst': "Run 'hi'",
155            }
156        })
157        self._run_install_command(tmpdir_cwd, env)
158        egg_info_dir = self._find_egg_info_files(env.paths['lib']).base
159        sources_txt = os.path.join(egg_info_dir, 'SOURCES.txt')
160        with open(sources_txt) as f:
161            assert 'docs/usage.rst' in f.read().split('\n')
162
163    def _setup_script_with_requires(self, requires, use_setup_cfg=False):
164        setup_script = DALS(
165            '''
166            from setuptools import setup
167
168            setup(name='foo', zip_safe=False, %s)
169            '''
170        ) % ('' if use_setup_cfg else requires)
171        setup_config = requires if use_setup_cfg else ''
172        build_files({'setup.py': setup_script,
173                     'setup.cfg': setup_config})
174
175    mismatch_marker = "python_version<'{this_ver}'".format(
176        this_ver=sys.version_info[0],
177    )
178    # Alternate equivalent syntax.
179    mismatch_marker_alternate = 'python_version < "{this_ver}"'.format(
180        this_ver=sys.version_info[0],
181    )
182    invalid_marker = "<=>++"
183
184    class RequiresTestHelper(object):
185
186        @staticmethod
187        def parametrize(*test_list, **format_dict):
188            idlist = []
189            argvalues = []
190            for test in test_list:
191                test_params = test.lstrip().split('\n\n', 3)
192                name_kwargs = test_params.pop(0).split('\n')
193                if len(name_kwargs) > 1:
194                    val = name_kwargs[1].strip()
195                    install_cmd_kwargs = ast.literal_eval(val)
196                else:
197                    install_cmd_kwargs = {}
198                name = name_kwargs[0].strip()
199                setup_py_requires, setup_cfg_requires, expected_requires = (
200                    DALS(a).format(**format_dict) for a in test_params
201                )
202                for id_, requires, use_cfg in (
203                    (name, setup_py_requires, False),
204                    (name + '_in_setup_cfg', setup_cfg_requires, True),
205                ):
206                    idlist.append(id_)
207                    marks = ()
208                    if requires.startswith('@xfail\n'):
209                        requires = requires[7:]
210                        marks = pytest.mark.xfail
211                    argvalues.append(pytest.param(requires, use_cfg,
212                                                  expected_requires,
213                                                  install_cmd_kwargs,
214                                                  marks=marks))
215            return pytest.mark.parametrize(
216                'requires,use_setup_cfg,'
217                'expected_requires,install_cmd_kwargs',
218                argvalues, ids=idlist,
219            )
220
221    @RequiresTestHelper.parametrize(
222        # Format of a test:
223        #
224        # id
225        # install_cmd_kwargs [optional]
226        #
227        # requires block (when used in setup.py)
228        #
229        # requires block (when used in setup.cfg)
230        #
231        # expected contents of requires.txt
232
233        '''
234        install_requires_deterministic
235
236        install_requires=["fake-factory==0.5.2", "pytz"]
237
238        [options]
239        install_requires =
240            fake-factory==0.5.2
241            pytz
242
243        fake-factory==0.5.2
244        pytz
245        ''',
246
247        '''
248        install_requires_ordered
249
250        install_requires=["fake-factory>=1.12.3,!=2.0"]
251
252        [options]
253        install_requires =
254            fake-factory>=1.12.3,!=2.0
255
256        fake-factory!=2.0,>=1.12.3
257        ''',
258
259        '''
260        install_requires_with_marker
261
262        install_requires=["barbazquux;{mismatch_marker}"],
263
264        [options]
265        install_requires =
266            barbazquux; {mismatch_marker}
267
268        [:{mismatch_marker_alternate}]
269        barbazquux
270        ''',
271
272        '''
273        install_requires_with_extra
274        {'cmd': ['egg_info']}
275
276        install_requires=["barbazquux [test]"],
277
278        [options]
279        install_requires =
280            barbazquux [test]
281
282        barbazquux[test]
283        ''',
284
285        '''
286        install_requires_with_extra_and_marker
287
288        install_requires=["barbazquux [test]; {mismatch_marker}"],
289
290        [options]
291        install_requires =
292            barbazquux [test]; {mismatch_marker}
293
294        [:{mismatch_marker_alternate}]
295        barbazquux[test]
296        ''',
297
298        '''
299        setup_requires_with_markers
300
301        setup_requires=["barbazquux;{mismatch_marker}"],
302
303        [options]
304        setup_requires =
305            barbazquux; {mismatch_marker}
306
307        ''',
308
309        '''
310        tests_require_with_markers
311        {'cmd': ['test'], 'output': "Ran 0 tests in"}
312
313        tests_require=["barbazquux;{mismatch_marker}"],
314
315        [options]
316        tests_require =
317            barbazquux; {mismatch_marker}
318
319        ''',
320
321        '''
322        extras_require_with_extra
323        {'cmd': ['egg_info']}
324
325        extras_require={{"extra": ["barbazquux [test]"]}},
326
327        [options.extras_require]
328        extra = barbazquux [test]
329
330        [extra]
331        barbazquux[test]
332        ''',
333
334        '''
335        extras_require_with_extra_and_marker_in_req
336
337        extras_require={{"extra": ["barbazquux [test]; {mismatch_marker}"]}},
338
339        [options.extras_require]
340        extra =
341            barbazquux [test]; {mismatch_marker}
342
343        [extra]
344
345        [extra:{mismatch_marker_alternate}]
346        barbazquux[test]
347        ''',
348
349        # FIXME: ConfigParser does not allow : in key names!
350        '''
351        extras_require_with_marker
352
353        extras_require={{":{mismatch_marker}": ["barbazquux"]}},
354
355        @xfail
356        [options.extras_require]
357        :{mismatch_marker} = barbazquux
358
359        [:{mismatch_marker}]
360        barbazquux
361        ''',
362
363        '''
364        extras_require_with_marker_in_req
365
366        extras_require={{"extra": ["barbazquux; {mismatch_marker}"]}},
367
368        [options.extras_require]
369        extra =
370            barbazquux; {mismatch_marker}
371
372        [extra]
373
374        [extra:{mismatch_marker_alternate}]
375        barbazquux
376        ''',
377
378        '''
379        extras_require_with_empty_section
380
381        extras_require={{"empty": []}},
382
383        [options.extras_require]
384        empty =
385
386        [empty]
387        ''',
388        # Format arguments.
389        invalid_marker=invalid_marker,
390        mismatch_marker=mismatch_marker,
391        mismatch_marker_alternate=mismatch_marker_alternate,
392    )
393    def test_requires(
394            self, tmpdir_cwd, env, requires, use_setup_cfg,
395            expected_requires, install_cmd_kwargs):
396        self._setup_script_with_requires(requires, use_setup_cfg)
397        self._run_install_command(tmpdir_cwd, env, **install_cmd_kwargs)
398        egg_info_dir = os.path.join('.', 'foo.egg-info')
399        requires_txt = os.path.join(egg_info_dir, 'requires.txt')
400        if os.path.exists(requires_txt):
401            with open(requires_txt) as fp:
402                install_requires = fp.read()
403        else:
404            install_requires = ''
405        assert install_requires.lstrip() == expected_requires
406        assert glob.glob(os.path.join(env.paths['lib'], 'barbazquux*')) == []
407
408    def test_install_requires_unordered_disallowed(self, tmpdir_cwd, env):
409        """
410        Packages that pass unordered install_requires sequences
411        should be rejected as they produce non-deterministic
412        builds. See #458.
413        """
414        req = 'install_requires={"fake-factory==0.5.2", "pytz"}'
415        self._setup_script_with_requires(req)
416        with pytest.raises(AssertionError):
417            self._run_install_command(tmpdir_cwd, env)
418
419    def test_extras_require_with_invalid_marker(self, tmpdir_cwd, env):
420        tmpl = 'extras_require={{":{marker}": ["barbazquux"]}},'
421        req = tmpl.format(marker=self.invalid_marker)
422        self._setup_script_with_requires(req)
423        with pytest.raises(AssertionError):
424            self._run_install_command(tmpdir_cwd, env)
425        assert glob.glob(os.path.join(env.paths['lib'], 'barbazquux*')) == []
426
427    def test_extras_require_with_invalid_marker_in_req(self, tmpdir_cwd, env):
428        tmpl = 'extras_require={{"extra": ["barbazquux; {marker}"]}},'
429        req = tmpl.format(marker=self.invalid_marker)
430        self._setup_script_with_requires(req)
431        with pytest.raises(AssertionError):
432            self._run_install_command(tmpdir_cwd, env)
433        assert glob.glob(os.path.join(env.paths['lib'], 'barbazquux*')) == []
434
435    def test_provides_extra(self, tmpdir_cwd, env):
436        self._setup_script_with_requires(
437            'extras_require={"foobar": ["barbazquux"]},')
438        environ = os.environ.copy().update(
439            HOME=env.paths['home'],
440        )
441        code, data = environment.run_setup_py(
442            cmd=['egg_info'],
443            pypath=os.pathsep.join([env.paths['lib'], str(tmpdir_cwd)]),
444            data_stream=1,
445            env=environ,
446        )
447        egg_info_dir = os.path.join('.', 'foo.egg-info')
448        with open(os.path.join(egg_info_dir, 'PKG-INFO')) as pkginfo_file:
449            pkg_info_lines = pkginfo_file.read().split('\n')
450        assert 'Provides-Extra: foobar' in pkg_info_lines
451        assert 'Metadata-Version: 2.1' in pkg_info_lines
452
453    def test_doesnt_provides_extra(self, tmpdir_cwd, env):
454        self._setup_script_with_requires(
455            '''install_requires=["spam ; python_version<'3.3'"]''')
456        environ = os.environ.copy().update(
457            HOME=env.paths['home'],
458        )
459        environment.run_setup_py(
460            cmd=['egg_info'],
461            pypath=os.pathsep.join([env.paths['lib'], str(tmpdir_cwd)]),
462            data_stream=1,
463            env=environ,
464        )
465        egg_info_dir = os.path.join('.', 'foo.egg-info')
466        with open(os.path.join(egg_info_dir, 'PKG-INFO')) as pkginfo_file:
467            pkg_info_text = pkginfo_file.read()
468        assert 'Provides-Extra:' not in pkg_info_text
469
470    def test_long_description_content_type(self, tmpdir_cwd, env):
471        # Test that specifying a `long_description_content_type` keyword arg to
472        # the `setup` function results in writing a `Description-Content-Type`
473        # line to the `PKG-INFO` file in the `<distribution>.egg-info`
474        # directory.
475        # `Description-Content-Type` is described at
476        # https://github.com/pypa/python-packaging-user-guide/pull/258
477
478        self._setup_script_with_requires(
479            """long_description_content_type='text/markdown',""")
480        environ = os.environ.copy().update(
481            HOME=env.paths['home'],
482        )
483        code, data = environment.run_setup_py(
484            cmd=['egg_info'],
485            pypath=os.pathsep.join([env.paths['lib'], str(tmpdir_cwd)]),
486            data_stream=1,
487            env=environ,
488        )
489        egg_info_dir = os.path.join('.', 'foo.egg-info')
490        with open(os.path.join(egg_info_dir, 'PKG-INFO')) as pkginfo_file:
491            pkg_info_lines = pkginfo_file.read().split('\n')
492        expected_line = 'Description-Content-Type: text/markdown'
493        assert expected_line in pkg_info_lines
494        assert 'Metadata-Version: 2.1' in pkg_info_lines
495
496    def test_project_urls(self, tmpdir_cwd, env):
497        # Test that specifying a `project_urls` dict to the `setup`
498        # function results in writing multiple `Project-URL` lines to
499        # the `PKG-INFO` file in the `<distribution>.egg-info`
500        # directory.
501        # `Project-URL` is described at https://packaging.python.org
502        #     /specifications/core-metadata/#project-url-multiple-use
503
504        self._setup_script_with_requires(
505            """project_urls={
506                'Link One': 'https://example.com/one/',
507                'Link Two': 'https://example.com/two/',
508                },""")
509        environ = os.environ.copy().update(
510            HOME=env.paths['home'],
511        )
512        code, data = environment.run_setup_py(
513            cmd=['egg_info'],
514            pypath=os.pathsep.join([env.paths['lib'], str(tmpdir_cwd)]),
515            data_stream=1,
516            env=environ,
517        )
518        egg_info_dir = os.path.join('.', 'foo.egg-info')
519        with open(os.path.join(egg_info_dir, 'PKG-INFO')) as pkginfo_file:
520            pkg_info_lines = pkginfo_file.read().split('\n')
521        expected_line = 'Project-URL: Link One, https://example.com/one/'
522        assert expected_line in pkg_info_lines
523        expected_line = 'Project-URL: Link Two, https://example.com/two/'
524        assert expected_line in pkg_info_lines
525
526    def test_python_requires_egg_info(self, tmpdir_cwd, env):
527        self._setup_script_with_requires(
528            """python_requires='>=2.7.12',""")
529        environ = os.environ.copy().update(
530            HOME=env.paths['home'],
531        )
532        code, data = environment.run_setup_py(
533            cmd=['egg_info'],
534            pypath=os.pathsep.join([env.paths['lib'], str(tmpdir_cwd)]),
535            data_stream=1,
536            env=environ,
537        )
538        egg_info_dir = os.path.join('.', 'foo.egg-info')
539        with open(os.path.join(egg_info_dir, 'PKG-INFO')) as pkginfo_file:
540            pkg_info_lines = pkginfo_file.read().split('\n')
541        assert 'Requires-Python: >=2.7.12' in pkg_info_lines
542        assert 'Metadata-Version: 1.2' in pkg_info_lines
543
544    def test_python_requires_install(self, tmpdir_cwd, env):
545        self._setup_script_with_requires(
546            """python_requires='>=1.2.3',""")
547        self._run_install_command(tmpdir_cwd, env)
548        egg_info_dir = self._find_egg_info_files(env.paths['lib']).base
549        pkginfo = os.path.join(egg_info_dir, 'PKG-INFO')
550        with open(pkginfo) as f:
551            assert 'Requires-Python: >=1.2.3' in f.read().split('\n')
552
553    def test_manifest_maker_warning_suppression(self):
554        fixtures = [
555            "standard file not found: should have one of foo.py, bar.py",
556            "standard file 'setup.py' not found"
557        ]
558
559        for msg in fixtures:
560            assert manifest_maker._should_suppress_warning(msg)
561
562    def _run_install_command(self, tmpdir_cwd, env, cmd=None, output=None):
563        environ = os.environ.copy().update(
564            HOME=env.paths['home'],
565        )
566        if cmd is None:
567            cmd = [
568                'install',
569                '--home', env.paths['home'],
570                '--install-lib', env.paths['lib'],
571                '--install-scripts', env.paths['scripts'],
572                '--install-data', env.paths['data'],
573            ]
574        code, data = environment.run_setup_py(
575            cmd=cmd,
576            pypath=os.pathsep.join([env.paths['lib'], str(tmpdir_cwd)]),
577            data_stream=1,
578            env=environ,
579        )
580        if code:
581            raise AssertionError(data)
582        if output:
583            assert output in data
584
585    def _find_egg_info_files(self, root):
586        class DirList(list):
587            def __init__(self, files, base):
588                super(DirList, self).__init__(files)
589                self.base = base
590
591        results = (
592            DirList(filenames, dirpath)
593            for dirpath, dirnames, filenames in os.walk(root)
594            if os.path.basename(dirpath) == 'EGG-INFO'
595        )
596        # expect exactly one result
597        result, = results
598        return result
599