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.txt", 99 "examples/wheel/lib/module_with_data.py", 100 "examples/wheel/lib/simple_module.py", 101 "examples/wheel/main.py", 102 "example_minimal_package-0.0.1.dist-info/WHEEL", 103 "example_minimal_package-0.0.1.dist-info/METADATA", 104 "example_minimal_package-0.0.1.dist-info/RECORD", 105 ], 106 ) 107 self.assertFileSha256Equal( 108 filename, "b4815a1d3a17cc6a5ce717ed42b940fa7788cb5168f5c1de02f5f50abed7083e" 109 ) 110 111 def test_customized_wheel(self): 112 filename = self._get_path( 113 "example_customized-0.0.1-py3-none-any.whl", 114 ) 115 with zipfile.ZipFile(filename) as zf: 116 self.assertAllEntriesHasReproducibleMetadata(zf) 117 self.assertEqual( 118 zf.namelist(), 119 [ 120 "examples/wheel/lib/data.txt", 121 "examples/wheel/lib/module_with_data.py", 122 "examples/wheel/lib/simple_module.py", 123 "examples/wheel/main.py", 124 "example_customized-0.0.1.dist-info/WHEEL", 125 "example_customized-0.0.1.dist-info/METADATA", 126 "example_customized-0.0.1.dist-info/entry_points.txt", 127 "example_customized-0.0.1.dist-info/NOTICE", 128 "example_customized-0.0.1.dist-info/README", 129 "example_customized-0.0.1.dist-info/RECORD", 130 ], 131 ) 132 record_contents = zf.read("example_customized-0.0.1.dist-info/RECORD") 133 wheel_contents = zf.read("example_customized-0.0.1.dist-info/WHEEL") 134 metadata_contents = zf.read("example_customized-0.0.1.dist-info/METADATA") 135 entry_point_contents = zf.read( 136 "example_customized-0.0.1.dist-info/entry_points.txt" 137 ) 138 139 self.assertEqual( 140 record_contents, 141 # The entries are guaranteed to be sorted. 142 b"""\ 143examples/wheel/lib/data.txt,sha256=9vJKEdfLu8bZRArKLroPZJh1XKkK3qFMXiM79MBL2Sg,12 144examples/wheel/lib/module_with_data.py,sha256=8s0Khhcqz3yVsBKv2IB5u4l4TMKh7-c_V6p65WVHPms,637 145examples/wheel/lib/simple_module.py,sha256=z2hwciab_XPNIBNH8B1Q5fYgnJvQTeYf0ZQJpY8yLLY,637 146examples/wheel/main.py,sha256=sgg5iWN_9inYBjm6_Zw27hYdmo-l24fA-2rfphT-IlY,909 147example_customized-0.0.1.dist-info/WHEEL,sha256=sobxWSyDDkdg_rinUth-jxhXHqoNqlmNMJY3aTZn2Us,91 148example_customized-0.0.1.dist-info/METADATA,sha256=QYQcDJFQSIqan8eiXqL67bqsUfgEAwf2hoK_Lgi1S-0,559 149example_customized-0.0.1.dist-info/entry_points.txt,sha256=pqzpbQ8MMorrJ3Jp0ntmpZcuvfByyqzMXXi2UujuXD0,137 150example_customized-0.0.1.dist-info/NOTICE,sha256=Xpdw-FXET1IRgZ_wTkx1YQfo1-alET0FVf6V1LXO4js,76 151example_customized-0.0.1.dist-info/README,sha256=WmOFwZ3Jga1bHG3JiGRsUheb4UbLffUxyTdHczS27-o,40 152example_customized-0.0.1.dist-info/RECORD,, 153""", 154 ) 155 self.assertEqual( 156 wheel_contents, 157 b"""\ 158Wheel-Version: 1.0 159Generator: bazel-wheelmaker 1.0 160Root-Is-Purelib: true 161Tag: py3-none-any 162""", 163 ) 164 self.assertEqual( 165 metadata_contents, 166 b"""\ 167Metadata-Version: 2.1 168Name: example_customized 169Author: Example Author with non-ascii characters: \xc5\xbc\xc3\xb3\xc5\x82w 170Author-email: [email protected] 171Home-page: www.example.com 172License: Apache 2.0 173Description-Content-Type: text/markdown 174Summary: A one-line summary of this test package 175Project-URL: Bug Tracker, www.example.com/issues 176Project-URL: Documentation, www.example.com/docs 177Classifier: License :: OSI Approved :: Apache Software License 178Classifier: Intended Audience :: Developers 179Requires-Dist: pytest 180Version: 0.0.1 181 182This is a sample description of a wheel. 183""", 184 ) 185 self.assertEqual( 186 entry_point_contents, 187 b"""\ 188[console_scripts] 189another = foo.bar:baz 190customized_wheel = examples.wheel.main:main 191 192[group2] 193first = first.main:f 194second = second.main:s""", 195 ) 196 self.assertFileSha256Equal( 197 filename, "27f3038be6e768d28735441a1bc567eca2213bd3568d18b22a414e6399a2d48e" 198 ) 199 200 def test_filename_escaping(self): 201 filename = self._get_path( 202 "file_name_escaping-0.0.1rc1+ubuntu.r7-py3-none-any.whl", 203 ) 204 with zipfile.ZipFile(filename) as zf: 205 self.assertEqual( 206 zf.namelist(), 207 [ 208 "examples/wheel/lib/data.txt", 209 "examples/wheel/lib/module_with_data.py", 210 "examples/wheel/lib/simple_module.py", 211 "examples/wheel/main.py", 212 # PEP calls for replacing only in the archive filename. 213 # Alas setuptools also escapes in the dist-info directory 214 # name, so let's be compatible. 215 "file_name_escaping-0.0.1rc1+ubuntu.r7.dist-info/WHEEL", 216 "file_name_escaping-0.0.1rc1+ubuntu.r7.dist-info/METADATA", 217 "file_name_escaping-0.0.1rc1+ubuntu.r7.dist-info/RECORD", 218 ], 219 ) 220 metadata_contents = zf.read( 221 "file_name_escaping-0.0.1rc1+ubuntu.r7.dist-info/METADATA" 222 ) 223 self.assertEqual( 224 metadata_contents, 225 b"""\ 226Metadata-Version: 2.1 227Name: File--Name-Escaping 228Version: 0.0.1rc1+ubuntu.r7 229 230UNKNOWN 231""", 232 ) 233 234 def test_custom_package_root_wheel(self): 235 filename = self._get_path( 236 "examples_custom_package_root-0.0.1-py3-none-any.whl", 237 ) 238 239 with zipfile.ZipFile(filename) as zf: 240 self.assertAllEntriesHasReproducibleMetadata(zf) 241 self.assertEqual( 242 zf.namelist(), 243 [ 244 "wheel/lib/data.txt", 245 "wheel/lib/module_with_data.py", 246 "wheel/lib/simple_module.py", 247 "wheel/main.py", 248 "examples_custom_package_root-0.0.1.dist-info/WHEEL", 249 "examples_custom_package_root-0.0.1.dist-info/METADATA", 250 "examples_custom_package_root-0.0.1.dist-info/entry_points.txt", 251 "examples_custom_package_root-0.0.1.dist-info/RECORD", 252 ], 253 ) 254 255 record_contents = zf.read( 256 "examples_custom_package_root-0.0.1.dist-info/RECORD" 257 ).decode("utf-8") 258 259 # Ensure RECORD files do not have leading forward slashes 260 for line in record_contents.splitlines(): 261 self.assertFalse(line.startswith("/")) 262 self.assertFileSha256Equal( 263 filename, "f034b3278781f4df32a33df70d794bb94170b450e477c8bd9cd42d2d922476ae" 264 ) 265 266 def test_custom_package_root_multi_prefix_wheel(self): 267 filename = self._get_path( 268 "example_custom_package_root_multi_prefix-0.0.1-py3-none-any.whl", 269 ) 270 271 with zipfile.ZipFile(filename) as zf: 272 self.assertAllEntriesHasReproducibleMetadata(zf) 273 self.assertEqual( 274 zf.namelist(), 275 [ 276 "data.txt", 277 "module_with_data.py", 278 "simple_module.py", 279 "main.py", 280 "example_custom_package_root_multi_prefix-0.0.1.dist-info/WHEEL", 281 "example_custom_package_root_multi_prefix-0.0.1.dist-info/METADATA", 282 "example_custom_package_root_multi_prefix-0.0.1.dist-info/RECORD", 283 ], 284 ) 285 286 record_contents = zf.read( 287 "example_custom_package_root_multi_prefix-0.0.1.dist-info/RECORD" 288 ).decode("utf-8") 289 290 # Ensure RECORD files do not have leading forward slashes 291 for line in record_contents.splitlines(): 292 self.assertFalse(line.startswith("/")) 293 self.assertFileSha256Equal( 294 filename, "ff19f5e4540948247742716338bb4194d619cb56df409045d1a99f265ce8e36c" 295 ) 296 297 def test_custom_package_root_multi_prefix_reverse_order_wheel(self): 298 filename = self._get_path( 299 "example_custom_package_root_multi_prefix_reverse_order-0.0.1-py3-none-any.whl", 300 ) 301 302 with zipfile.ZipFile(filename) as zf: 303 self.assertAllEntriesHasReproducibleMetadata(zf) 304 self.assertEqual( 305 zf.namelist(), 306 [ 307 "lib/data.txt", 308 "lib/module_with_data.py", 309 "lib/simple_module.py", 310 "main.py", 311 "example_custom_package_root_multi_prefix_reverse_order-0.0.1.dist-info/WHEEL", 312 "example_custom_package_root_multi_prefix_reverse_order-0.0.1.dist-info/METADATA", 313 "example_custom_package_root_multi_prefix_reverse_order-0.0.1.dist-info/RECORD", 314 ], 315 ) 316 317 record_contents = zf.read( 318 "example_custom_package_root_multi_prefix_reverse_order-0.0.1.dist-info/RECORD" 319 ).decode("utf-8") 320 321 # Ensure RECORD files do not have leading forward slashes 322 for line in record_contents.splitlines(): 323 self.assertFalse(line.startswith("/")) 324 self.assertFileSha256Equal( 325 filename, "4331e378ea8b8148409ae7c02177e4eb24d151a85ef937bb44b79ff5258d634b" 326 ) 327 328 def test_python_requires_wheel(self): 329 filename = self._get_path( 330 "example_python_requires_in_a_package-0.0.1-py3-none-any.whl", 331 ) 332 with zipfile.ZipFile(filename) as zf: 333 self.assertAllEntriesHasReproducibleMetadata(zf) 334 metadata_contents = zf.read( 335 "example_python_requires_in_a_package-0.0.1.dist-info/METADATA" 336 ) 337 # The entries are guaranteed to be sorted. 338 self.assertEqual( 339 metadata_contents, 340 b"""\ 341Metadata-Version: 2.1 342Name: example_python_requires_in_a_package 343Requires-Python: >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.* 344Version: 0.0.1 345 346UNKNOWN 347""", 348 ) 349 self.assertFileSha256Equal( 350 filename, "b34676828f93da8cd898d50dcd4f36e02fe273150e213aacb999310a05f5f38c" 351 ) 352 353 def test_python_abi3_binary_wheel(self): 354 arch = "amd64" 355 if platform.system() != "Windows": 356 arch = subprocess.check_output(["uname", "-m"]).strip().decode() 357 # These strings match the strings from py_wheel() in BUILD 358 os_strings = { 359 "Linux": "manylinux2014", 360 "Darwin": "macosx_11_0", 361 "Windows": "win", 362 } 363 os_string = os_strings[platform.system()] 364 filename = self._get_path( 365 f"example_python_abi3_binary_wheel-0.0.1-cp38-abi3-{os_string}_{arch}.whl", 366 ) 367 with zipfile.ZipFile(filename) as zf: 368 self.assertAllEntriesHasReproducibleMetadata(zf) 369 metadata_contents = zf.read( 370 "example_python_abi3_binary_wheel-0.0.1.dist-info/METADATA" 371 ) 372 # The entries are guaranteed to be sorted. 373 self.assertEqual( 374 metadata_contents, 375 b"""\ 376Metadata-Version: 2.1 377Name: example_python_abi3_binary_wheel 378Requires-Python: >=3.8 379Version: 0.0.1 380 381UNKNOWN 382""", 383 ) 384 wheel_contents = zf.read( 385 "example_python_abi3_binary_wheel-0.0.1.dist-info/WHEEL" 386 ) 387 self.assertEqual( 388 wheel_contents.decode(), 389 f"""\ 390Wheel-Version: 1.0 391Generator: bazel-wheelmaker 1.0 392Root-Is-Purelib: false 393Tag: cp38-abi3-{os_string}_{arch} 394""", 395 ) 396 397 def test_rule_creates_directory_and_is_included_in_wheel(self): 398 filename = self._get_path( 399 "use_rule_with_dir_in_outs-0.0.1-py3-none-any.whl", 400 ) 401 402 with zipfile.ZipFile(filename) as zf: 403 self.assertAllEntriesHasReproducibleMetadata(zf) 404 self.assertEqual( 405 zf.namelist(), 406 [ 407 "examples/wheel/main.py", 408 "examples/wheel/someDir/foo.py", 409 "use_rule_with_dir_in_outs-0.0.1.dist-info/WHEEL", 410 "use_rule_with_dir_in_outs-0.0.1.dist-info/METADATA", 411 "use_rule_with_dir_in_outs-0.0.1.dist-info/RECORD", 412 ], 413 ) 414 self.assertFileSha256Equal( 415 filename, "ac9216bd54dcae1a6270c35fccf8a73b0be87c1b026c28e963b7c76b2f9b722b" 416 ) 417 418 def test_rule_expands_workspace_status_keys_in_wheel_metadata(self): 419 filename = self._get_path( 420 "example_minimal_library{BUILD_USER}-0.1.{BUILD_TIMESTAMP}-py3-none-any.whl" 421 ) 422 423 with zipfile.ZipFile(filename) as zf: 424 self.assertAllEntriesHasReproducibleMetadata(zf) 425 metadata_file = None 426 for f in zf.namelist(): 427 self.assertNotIn("{BUILD_TIMESTAMP}", f) 428 self.assertNotIn("{BUILD_USER}", f) 429 if os.path.basename(f) == "METADATA": 430 metadata_file = f 431 self.assertIsNotNone(metadata_file) 432 433 version = None 434 name = None 435 with zf.open(metadata_file) as fp: 436 for line in fp: 437 if line.startswith(b"Version:"): 438 version = line.decode().split()[-1] 439 if line.startswith(b"Name:"): 440 name = line.decode().split()[-1] 441 self.assertIsNotNone(version) 442 self.assertIsNotNone(name) 443 self.assertNotIn("{BUILD_TIMESTAMP}", version) 444 self.assertNotIn("{BUILD_USER}", name) 445 446 def test_requires_file_and_extra_requires_files(self): 447 filename = self._get_path("requires_files-0.0.1-py3-none-any.whl") 448 449 with zipfile.ZipFile(filename) as zf: 450 self.assertAllEntriesHasReproducibleMetadata(zf) 451 metadata_file = None 452 for f in zf.namelist(): 453 if os.path.basename(f) == "METADATA": 454 metadata_file = f 455 self.assertIsNotNone(metadata_file) 456 457 requires = [] 458 with zf.open(metadata_file) as fp: 459 for line in fp: 460 if line.startswith(b"Requires-Dist:"): 461 requires.append(line.decode("utf-8").strip()) 462 463 print(requires) 464 self.assertEqual( 465 [ 466 "Requires-Dist: tomli>=2.0.0", 467 "Requires-Dist: starlark", 468 "Requires-Dist: pyyaml!=6.0.1,>=6.0.0; extra == 'example'", 469 'Requires-Dist: toml; ((python_version == "3.11" or python_version == "3.12") and python_version != "3.8") and extra == \'example\'', 470 'Requires-Dist: wheel; (python_version == "3.11" or python_version == "3.12") and extra == \'example\'', 471 ], 472 requires, 473 ) 474 475 def test_minimal_data_files(self): 476 filename = self._get_path("minimal_data_files-0.0.1-py3-none-any.whl") 477 478 with zipfile.ZipFile(filename) as zf: 479 self.assertAllEntriesHasReproducibleMetadata(zf) 480 metadata_file = None 481 self.assertEqual( 482 zf.namelist(), 483 [ 484 "minimal_data_files-0.0.1.dist-info/WHEEL", 485 "minimal_data_files-0.0.1.dist-info/METADATA", 486 "minimal_data_files-0.0.1.data/data/target/path/README.md", 487 "minimal_data_files-0.0.1.data/scripts/NOTICE", 488 "minimal_data_files-0.0.1.dist-info/RECORD", 489 ], 490 ) 491 492 def test_extra_requires(self): 493 filename = self._get_path("extra_requires-0.0.1-py3-none-any.whl") 494 495 with zipfile.ZipFile(filename) as zf: 496 self.assertAllEntriesHasReproducibleMetadata(zf) 497 metadata_file = None 498 for f in zf.namelist(): 499 if os.path.basename(f) == "METADATA": 500 metadata_file = f 501 self.assertIsNotNone(metadata_file) 502 503 requires = [] 504 with zf.open(metadata_file) as fp: 505 for line in fp: 506 if line.startswith(b"Requires-Dist:"): 507 requires.append(line.decode("utf-8").strip()) 508 509 print(requires) 510 self.assertEqual( 511 [ 512 "Requires-Dist: tomli>=2.0.0", 513 "Requires-Dist: starlark", 514 'Requires-Dist: pytest; python_version != "3.8"', 515 "Requires-Dist: pyyaml!=6.0.1,>=6.0.0; extra == 'example'", 516 'Requires-Dist: toml; ((python_version == "3.11" or python_version == "3.12") and python_version != "3.8") and extra == \'example\'', 517 'Requires-Dist: wheel; (python_version == "3.11" or python_version == "3.12") and extra == \'example\'', 518 ], 519 requires, 520 ) 521 522 523if __name__ == "__main__": 524 unittest.main() 525