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