1# Copyright 2018 The Bazel Authors. All rights reserved. 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6# 7# http://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14 15import hashlib 16import os 17import platform 18import stat 19import subprocess 20import unittest 21import zipfile 22 23from python.runfiles import runfiles 24 25 26class WheelTest(unittest.TestCase): 27 maxDiff = None 28 29 def setUp(self): 30 super().setUp() 31 self.runfiles = runfiles.Create() 32 33 def _get_path(self, filename): 34 runfiles_path = os.path.join("rules_python/examples/wheel", filename) 35 path = self.runfiles.Rlocation(runfiles_path) 36 # The runfiles API can return None if the path doesn't exist or 37 # can't be resolved. 38 if not path: 39 raise AssertionError(f"Runfiles failed to resolve {runfiles_path}") 40 elif not os.path.exists(path): 41 # A non-None value doesn't mean the file actually exists, though 42 raise AssertionError( 43 f"Path {path} does not exist (from runfiles path {runfiles_path}" 44 ) 45 else: 46 return path 47 48 def assertFileSha256Equal(self, filename, want): 49 hash = hashlib.sha256() 50 with open(filename, "rb") as f: 51 while True: 52 buf = f.read(2**20) 53 if not buf: 54 break 55 hash.update(buf) 56 self.assertEqual(want, hash.hexdigest()) 57 58 def assertAllEntriesHasReproducibleMetadata(self, zf): 59 for zinfo in zf.infolist(): 60 self.assertEqual(zinfo.date_time, (1980, 1, 1, 0, 0, 0), msg=zinfo.filename) 61 self.assertEqual(zinfo.create_system, 3, msg=zinfo.filename) 62 self.assertEqual( 63 zinfo.external_attr, 64 (stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO | stat.S_IFREG) << 16, 65 msg=zinfo.filename, 66 ) 67 self.assertEqual( 68 zinfo.compress_type, zipfile.ZIP_DEFLATED, msg=zinfo.filename 69 ) 70 71 def test_py_library_wheel(self): 72 filename = self._get_path("example_minimal_library-0.0.1-py3-none-any.whl") 73 with zipfile.ZipFile(filename) as zf: 74 self.assertAllEntriesHasReproducibleMetadata(zf) 75 self.assertEqual( 76 zf.namelist(), 77 [ 78 "examples/wheel/lib/module_with_data.py", 79 "examples/wheel/lib/simple_module.py", 80 "example_minimal_library-0.0.1.dist-info/WHEEL", 81 "example_minimal_library-0.0.1.dist-info/METADATA", 82 "example_minimal_library-0.0.1.dist-info/RECORD", 83 ], 84 ) 85 self.assertFileSha256Equal( 86 filename, "79a4e9c1838c0631d5d8fa49a26efd6e9a364f6b38d9597c0f6df112271a0e28" 87 ) 88 89 def test_py_package_wheel(self): 90 filename = self._get_path( 91 "example_minimal_package-0.0.1-py3-none-any.whl", 92 ) 93 with zipfile.ZipFile(filename) as zf: 94 self.assertAllEntriesHasReproducibleMetadata(zf) 95 self.assertEqual( 96 zf.namelist(), 97 [ 98 "examples/wheel/lib/data,with,commas.txt", 99 "examples/wheel/lib/data.txt", 100 "examples/wheel/lib/module_with_data.py", 101 "examples/wheel/lib/simple_module.py", 102 "examples/wheel/main.py", 103 "example_minimal_package-0.0.1.dist-info/WHEEL", 104 "example_minimal_package-0.0.1.dist-info/METADATA", 105 "example_minimal_package-0.0.1.dist-info/RECORD", 106 ], 107 ) 108 self.assertFileSha256Equal( 109 filename, "82370bf61310e2d3c7b1218368457dc7e161bf5dc1a280d7d45102b5e56acf43" 110 ) 111 112 def test_customized_wheel(self): 113 filename = self._get_path( 114 "example_customized-0.0.1-py3-none-any.whl", 115 ) 116 with zipfile.ZipFile(filename) as zf: 117 self.assertAllEntriesHasReproducibleMetadata(zf) 118 self.assertEqual( 119 zf.namelist(), 120 [ 121 "examples/wheel/lib/data,with,commas.txt", 122 "examples/wheel/lib/data.txt", 123 "examples/wheel/lib/module_with_data.py", 124 "examples/wheel/lib/simple_module.py", 125 "examples/wheel/main.py", 126 "example_customized-0.0.1.dist-info/WHEEL", 127 "example_customized-0.0.1.dist-info/METADATA", 128 "example_customized-0.0.1.dist-info/entry_points.txt", 129 "example_customized-0.0.1.dist-info/NOTICE", 130 "example_customized-0.0.1.dist-info/README", 131 "example_customized-0.0.1.dist-info/RECORD", 132 ], 133 ) 134 record_contents = zf.read("example_customized-0.0.1.dist-info/RECORD") 135 wheel_contents = zf.read("example_customized-0.0.1.dist-info/WHEEL") 136 metadata_contents = zf.read("example_customized-0.0.1.dist-info/METADATA") 137 entry_point_contents = zf.read( 138 "example_customized-0.0.1.dist-info/entry_points.txt" 139 ) 140 141 self.assertEqual( 142 record_contents, 143 # The entries are guaranteed to be sorted. 144 b"""\ 145"examples/wheel/lib/data,with,commas.txt",sha256=9vJKEdfLu8bZRArKLroPZJh1XKkK3qFMXiM79MBL2Sg,12 146examples/wheel/lib/data.txt,sha256=9vJKEdfLu8bZRArKLroPZJh1XKkK3qFMXiM79MBL2Sg,12 147examples/wheel/lib/module_with_data.py,sha256=8s0Khhcqz3yVsBKv2IB5u4l4TMKh7-c_V6p65WVHPms,637 148examples/wheel/lib/simple_module.py,sha256=z2hwciab_XPNIBNH8B1Q5fYgnJvQTeYf0ZQJpY8yLLY,637 149examples/wheel/main.py,sha256=sgg5iWN_9inYBjm6_Zw27hYdmo-l24fA-2rfphT-IlY,909 150example_customized-0.0.1.dist-info/WHEEL,sha256=sobxWSyDDkdg_rinUth-jxhXHqoNqlmNMJY3aTZn2Us,91 151example_customized-0.0.1.dist-info/METADATA,sha256=QYQcDJFQSIqan8eiXqL67bqsUfgEAwf2hoK_Lgi1S-0,559 152example_customized-0.0.1.dist-info/entry_points.txt,sha256=pqzpbQ8MMorrJ3Jp0ntmpZcuvfByyqzMXXi2UujuXD0,137 153example_customized-0.0.1.dist-info/NOTICE,sha256=Xpdw-FXET1IRgZ_wTkx1YQfo1-alET0FVf6V1LXO4js,76 154example_customized-0.0.1.dist-info/README,sha256=WmOFwZ3Jga1bHG3JiGRsUheb4UbLffUxyTdHczS27-o,40 155example_customized-0.0.1.dist-info/RECORD,, 156""", 157 ) 158 self.assertEqual( 159 wheel_contents, 160 b"""\ 161Wheel-Version: 1.0 162Generator: bazel-wheelmaker 1.0 163Root-Is-Purelib: true 164Tag: py3-none-any 165""", 166 ) 167 self.assertEqual( 168 metadata_contents, 169 b"""\ 170Metadata-Version: 2.1 171Name: example_customized 172Author: Example Author with non-ascii characters: \xc5\xbc\xc3\xb3\xc5\x82w 173Author-email: example@example.com 174Home-page: www.example.com 175License: Apache 2.0 176Description-Content-Type: text/markdown 177Summary: A one-line summary of this test package 178Project-URL: Bug Tracker, www.example.com/issues 179Project-URL: Documentation, www.example.com/docs 180Classifier: License :: OSI Approved :: Apache Software License 181Classifier: Intended Audience :: Developers 182Requires-Dist: pytest 183Version: 0.0.1 184 185This is a sample description of a wheel. 186""", 187 ) 188 self.assertEqual( 189 entry_point_contents, 190 b"""\ 191[console_scripts] 192another = foo.bar:baz 193customized_wheel = examples.wheel.main:main 194 195[group2] 196first = first.main:f 197second = second.main:s""", 198 ) 199 self.assertFileSha256Equal( 200 filename, "706e8dd45884d8cb26e92869f7d29ab7ed9f683b4e2d08f06c03dbdaa12191b8" 201 ) 202 203 def test_filename_escaping(self): 204 filename = self._get_path( 205 "file_name_escaping-0.0.1rc1+ubuntu.r7-py3-none-any.whl", 206 ) 207 with zipfile.ZipFile(filename) as zf: 208 self.assertEqual( 209 zf.namelist(), 210 [ 211 "examples/wheel/lib/data,with,commas.txt", 212 "examples/wheel/lib/data.txt", 213 "examples/wheel/lib/module_with_data.py", 214 "examples/wheel/lib/simple_module.py", 215 "examples/wheel/main.py", 216 # PEP calls for replacing only in the archive filename. 217 # Alas setuptools also escapes in the dist-info directory 218 # name, so let's be compatible. 219 "file_name_escaping-0.0.1rc1+ubuntu.r7.dist-info/WHEEL", 220 "file_name_escaping-0.0.1rc1+ubuntu.r7.dist-info/METADATA", 221 "file_name_escaping-0.0.1rc1+ubuntu.r7.dist-info/RECORD", 222 ], 223 ) 224 metadata_contents = zf.read( 225 "file_name_escaping-0.0.1rc1+ubuntu.r7.dist-info/METADATA" 226 ) 227 self.assertEqual( 228 metadata_contents, 229 b"""\ 230Metadata-Version: 2.1 231Name: File--Name-Escaping 232Version: 0.0.1rc1+ubuntu.r7 233 234UNKNOWN 235""", 236 ) 237 238 def test_custom_package_root_wheel(self): 239 filename = self._get_path( 240 "examples_custom_package_root-0.0.1-py3-none-any.whl", 241 ) 242 243 with zipfile.ZipFile(filename) as zf: 244 self.assertAllEntriesHasReproducibleMetadata(zf) 245 self.assertEqual( 246 zf.namelist(), 247 [ 248 "wheel/lib/data,with,commas.txt", 249 "wheel/lib/data.txt", 250 "wheel/lib/module_with_data.py", 251 "wheel/lib/simple_module.py", 252 "wheel/main.py", 253 "examples_custom_package_root-0.0.1.dist-info/WHEEL", 254 "examples_custom_package_root-0.0.1.dist-info/METADATA", 255 "examples_custom_package_root-0.0.1.dist-info/entry_points.txt", 256 "examples_custom_package_root-0.0.1.dist-info/RECORD", 257 ], 258 ) 259 260 record_contents = zf.read( 261 "examples_custom_package_root-0.0.1.dist-info/RECORD" 262 ).decode("utf-8") 263 264 # Ensure RECORD files do not have leading forward slashes 265 for line in record_contents.splitlines(): 266 self.assertFalse(line.startswith("/")) 267 self.assertFileSha256Equal( 268 filename, "568922541703f6edf4b090a8413991f9fa625df2844e644dd30bdbe9deb660be" 269 ) 270 271 def test_custom_package_root_multi_prefix_wheel(self): 272 filename = self._get_path( 273 "example_custom_package_root_multi_prefix-0.0.1-py3-none-any.whl", 274 ) 275 276 with zipfile.ZipFile(filename) as zf: 277 self.assertAllEntriesHasReproducibleMetadata(zf) 278 self.assertEqual( 279 zf.namelist(), 280 [ 281 "data,with,commas.txt", 282 "data.txt", 283 "module_with_data.py", 284 "simple_module.py", 285 "main.py", 286 "example_custom_package_root_multi_prefix-0.0.1.dist-info/WHEEL", 287 "example_custom_package_root_multi_prefix-0.0.1.dist-info/METADATA", 288 "example_custom_package_root_multi_prefix-0.0.1.dist-info/RECORD", 289 ], 290 ) 291 292 record_contents = zf.read( 293 "example_custom_package_root_multi_prefix-0.0.1.dist-info/RECORD" 294 ).decode("utf-8") 295 296 # Ensure RECORD files do not have leading forward slashes 297 for line in record_contents.splitlines(): 298 self.assertFalse(line.startswith("/")) 299 self.assertFileSha256Equal( 300 filename, "a8b91ce9d6f570e97b40a357a292a6f595d3470f07c479cb08550257cc9c8306" 301 ) 302 303 def test_custom_package_root_multi_prefix_reverse_order_wheel(self): 304 filename = self._get_path( 305 "example_custom_package_root_multi_prefix_reverse_order-0.0.1-py3-none-any.whl", 306 ) 307 308 with zipfile.ZipFile(filename) as zf: 309 self.assertAllEntriesHasReproducibleMetadata(zf) 310 self.assertEqual( 311 zf.namelist(), 312 [ 313 "lib/data,with,commas.txt", 314 "lib/data.txt", 315 "lib/module_with_data.py", 316 "lib/simple_module.py", 317 "main.py", 318 "example_custom_package_root_multi_prefix_reverse_order-0.0.1.dist-info/WHEEL", 319 "example_custom_package_root_multi_prefix_reverse_order-0.0.1.dist-info/METADATA", 320 "example_custom_package_root_multi_prefix_reverse_order-0.0.1.dist-info/RECORD", 321 ], 322 ) 323 324 record_contents = zf.read( 325 "example_custom_package_root_multi_prefix_reverse_order-0.0.1.dist-info/RECORD" 326 ).decode("utf-8") 327 328 # Ensure RECORD files do not have leading forward slashes 329 for line in record_contents.splitlines(): 330 self.assertFalse(line.startswith("/")) 331 self.assertFileSha256Equal( 332 filename, "8f44e940731757c186079a42cfe7ea3d43cd96b526e3fb2ca2a3ea3048a9d489" 333 ) 334 335 def test_python_requires_wheel(self): 336 filename = self._get_path( 337 "example_python_requires_in_a_package-0.0.1-py3-none-any.whl", 338 ) 339 with zipfile.ZipFile(filename) as zf: 340 self.assertAllEntriesHasReproducibleMetadata(zf) 341 metadata_contents = zf.read( 342 "example_python_requires_in_a_package-0.0.1.dist-info/METADATA" 343 ) 344 # The entries are guaranteed to be sorted. 345 self.assertEqual( 346 metadata_contents, 347 b"""\ 348Metadata-Version: 2.1 349Name: example_python_requires_in_a_package 350Requires-Python: >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.* 351Version: 0.0.1 352 353UNKNOWN 354""", 355 ) 356 self.assertFileSha256Equal( 357 filename, "ba32493f5e43e481346384aaab9e8fa09c23884276ad057c5f432096a0350101" 358 ) 359 360 def test_python_abi3_binary_wheel(self): 361 arch = "amd64" 362 if platform.system() != "Windows": 363 arch = subprocess.check_output(["uname", "-m"]).strip().decode() 364 # These strings match the strings from py_wheel() in BUILD 365 os_strings = { 366 "Linux": "manylinux2014", 367 "Darwin": "macosx_11_0", 368 "Windows": "win", 369 } 370 os_string = os_strings[platform.system()] 371 filename = self._get_path( 372 f"example_python_abi3_binary_wheel-0.0.1-cp38-abi3-{os_string}_{arch}.whl", 373 ) 374 with zipfile.ZipFile(filename) as zf: 375 self.assertAllEntriesHasReproducibleMetadata(zf) 376 metadata_contents = zf.read( 377 "example_python_abi3_binary_wheel-0.0.1.dist-info/METADATA" 378 ) 379 # The entries are guaranteed to be sorted. 380 self.assertEqual( 381 metadata_contents, 382 b"""\ 383Metadata-Version: 2.1 384Name: example_python_abi3_binary_wheel 385Requires-Python: >=3.8 386Version: 0.0.1 387 388UNKNOWN 389""", 390 ) 391 wheel_contents = zf.read( 392 "example_python_abi3_binary_wheel-0.0.1.dist-info/WHEEL" 393 ) 394 self.assertEqual( 395 wheel_contents.decode(), 396 f"""\ 397Wheel-Version: 1.0 398Generator: bazel-wheelmaker 1.0 399Root-Is-Purelib: false 400Tag: cp38-abi3-{os_string}_{arch} 401""", 402 ) 403 404 def test_rule_creates_directory_and_is_included_in_wheel(self): 405 filename = self._get_path( 406 "use_rule_with_dir_in_outs-0.0.1-py3-none-any.whl", 407 ) 408 409 with zipfile.ZipFile(filename) as zf: 410 self.assertAllEntriesHasReproducibleMetadata(zf) 411 self.assertEqual( 412 zf.namelist(), 413 [ 414 "examples/wheel/main.py", 415 "examples/wheel/someDir/foo.py", 416 "use_rule_with_dir_in_outs-0.0.1.dist-info/WHEEL", 417 "use_rule_with_dir_in_outs-0.0.1.dist-info/METADATA", 418 "use_rule_with_dir_in_outs-0.0.1.dist-info/RECORD", 419 ], 420 ) 421 self.assertFileSha256Equal( 422 filename, "ac9216bd54dcae1a6270c35fccf8a73b0be87c1b026c28e963b7c76b2f9b722b" 423 ) 424 425 def test_rule_expands_workspace_status_keys_in_wheel_metadata(self): 426 filename = self._get_path( 427 "example_minimal_library{BUILD_USER}-0.1.{BUILD_TIMESTAMP}-py3-none-any.whl" 428 ) 429 430 with zipfile.ZipFile(filename) as zf: 431 self.assertAllEntriesHasReproducibleMetadata(zf) 432 metadata_file = None 433 for f in zf.namelist(): 434 self.assertNotIn("{BUILD_TIMESTAMP}", f) 435 self.assertNotIn("{BUILD_USER}", f) 436 if os.path.basename(f) == "METADATA": 437 metadata_file = f 438 self.assertIsNotNone(metadata_file) 439 440 version = None 441 name = None 442 with zf.open(metadata_file) as fp: 443 for line in fp: 444 if line.startswith(b"Version:"): 445 version = line.decode().split()[-1] 446 if line.startswith(b"Name:"): 447 name = line.decode().split()[-1] 448 self.assertIsNotNone(version) 449 self.assertIsNotNone(name) 450 self.assertNotIn("{BUILD_TIMESTAMP}", version) 451 self.assertNotIn("{BUILD_USER}", name) 452 453 def test_requires_file_and_extra_requires_files(self): 454 filename = self._get_path("requires_files-0.0.1-py3-none-any.whl") 455 456 with zipfile.ZipFile(filename) as zf: 457 self.assertAllEntriesHasReproducibleMetadata(zf) 458 metadata_file = None 459 for f in zf.namelist(): 460 if os.path.basename(f) == "METADATA": 461 metadata_file = f 462 self.assertIsNotNone(metadata_file) 463 464 requires = [] 465 with zf.open(metadata_file) as fp: 466 for line in fp: 467 if line.startswith(b"Requires-Dist:"): 468 requires.append(line.decode("utf-8").strip()) 469 470 print(requires) 471 self.assertEqual( 472 [ 473 "Requires-Dist: tomli>=2.0.0", 474 "Requires-Dist: starlark", 475 "Requires-Dist: pyyaml!=6.0.1,>=6.0.0; extra == 'example'", 476 'Requires-Dist: toml; ((python_version == "3.11" or python_version == "3.12") and python_version != "3.8") and extra == \'example\'', 477 'Requires-Dist: wheel; (python_version == "3.11" or python_version == "3.12") and extra == \'example\'', 478 ], 479 requires, 480 ) 481 482 def test_minimal_data_files(self): 483 filename = self._get_path("minimal_data_files-0.0.1-py3-none-any.whl") 484 485 with zipfile.ZipFile(filename) as zf: 486 self.assertAllEntriesHasReproducibleMetadata(zf) 487 metadata_file = None 488 self.assertEqual( 489 zf.namelist(), 490 [ 491 "minimal_data_files-0.0.1.dist-info/WHEEL", 492 "minimal_data_files-0.0.1.dist-info/METADATA", 493 "minimal_data_files-0.0.1.data/data/target/path/README.md", 494 "minimal_data_files-0.0.1.data/scripts/NOTICE", 495 "minimal_data_files-0.0.1.dist-info/RECORD", 496 ], 497 ) 498 499 def test_extra_requires(self): 500 filename = self._get_path("extra_requires-0.0.1-py3-none-any.whl") 501 502 with zipfile.ZipFile(filename) as zf: 503 self.assertAllEntriesHasReproducibleMetadata(zf) 504 metadata_file = None 505 for f in zf.namelist(): 506 if os.path.basename(f) == "METADATA": 507 metadata_file = f 508 self.assertIsNotNone(metadata_file) 509 510 requires = [] 511 with zf.open(metadata_file) as fp: 512 for line in fp: 513 if line.startswith(b"Requires-Dist:"): 514 requires.append(line.decode("utf-8").strip()) 515 516 print(requires) 517 self.assertEqual( 518 [ 519 "Requires-Dist: tomli>=2.0.0", 520 "Requires-Dist: starlark", 521 'Requires-Dist: pytest; python_version != "3.8"', 522 "Requires-Dist: pyyaml!=6.0.1,>=6.0.0; extra == 'example'", 523 'Requires-Dist: toml; ((python_version == "3.11" or python_version == "3.12") and python_version != "3.8") and extra == \'example\'', 524 'Requires-Dist: wheel; (python_version == "3.11" or python_version == "3.12") and extra == \'example\'', 525 ], 526 requires, 527 ) 528 529 530if __name__ == "__main__": 531 unittest.main() 532